Milestone #160 Opt 3b. Adds device class as a third context axis on top
of the #1531 time-of-day/weekday affinity: on the radio path, a candidate
is boosted when its artist concentrates in the current (daypart × weekday
× device) cell. Client-sent (client_id is opaque; no UA stored), so it's
captured going forward and applies to radio only (daily mixes are
cron-built with no device → stay device-agnostic).
Server:
- Migration 0048: play_events.device_class text NULL (no CHECK; normalized
in Go — one whitelist entry per new client class, not a migration).
- events.go: eventRequest.device_class + normalizeDeviceClass (whitelist →
mobile/web/…, else "other", empty → NULL); threaded through both
RecordPlayStartedWithSource and RecordOfflinePlay into InsertPlayEvent.
- ListArtistContextPlayCountsForUser gains a current-device param; the cell
FILTER adds AND ($2='' OR device_class=$2) — '' reproduces the #1531
time-only behaviour exactly (used by mixes). SessionVector.DeviceClass
carries it; the radio handler derives the current device from the user's
latest play (GetLatestPlayDeviceClassForUser) — request-free proxy.
- No new tuning knob: device narrows the existing ContextAffinityScore
(reuses context_time_weight).
Clients:
- web: play_started sends device_class 'web'.
- android: play_started + offline replay send 'mobile' (EventsWire +
PlayOfflinePayload + MutationReplayer + PlayEventsReporter).
Test: LoadContextAffinity device-narrowing integration test (mobile vs web
artist separation; device-agnostic parity).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Milestone #160 Opt 2b (mood half of the era+mood option). A fourth taste
facet alongside artists + genre tags + eras: signed weights over canonical
mood buckets (melancholic / energetic / chill / …) derived from a track's
enriched folksonomy tags (#1490).
- internal/mood: shared vocabulary — Of(tags) maps folksonomy tags to
canonical mood buckets (synonyms collapse). Imported by both the taste
builder and the scorer so a track's mood is derived identically.
- Migration 0047: taste_profile_moods table + taste_tuning.mood_scale
(DEFAULT 0.5).
- Build side (internal/taste): Config.MoodScale ([0,1] damper, mirrors
EraScale); accumulate folds each play/like's mood buckets at
base*MoodScale; persist atomic-replaces the mood rows.
- Scorer (internal/recommendation): TasteProfile gains a mood term
(own tanh scale + additive 0.12 share, so it never weakens the existing
signal when a track has no mood tags). Match now takes the candidate's
mood buckets; loaded per candidate (ListTrackTagsForTracks → mood.Of) in
the primary similarity loader only — the near-whole-library fallback
pool passes nil (mood → 0) to avoid a full-library tag scan.
- Tuning lab: mood_scale threaded through recsettings + admin API + web
card ("Mood weight" row) + Go/web tests.
Coverage is partial (grows with tag enrichment; richer once Last.fm is
keyed), so mood is a supplement — neutral for tracks with no mood tags.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Milestone #160 Opt 5. A collaborative candidate arm: tracks by artists
co-played across the instance with the seed's artist.
Minstrel is a single shared-library, multi-user server (no per-user
library ACL — verified: no owner/share/group model), so the "household"
is the whole instance's user set; the rule #47 scoping is satisfied by
the shared-library boundary. Single-user servers produce no edges.
- No migration: source='user_cooccurrence' was pre-whitelisted in the
0009 similarity CHECK from day one.
- internal/db/queries/coplay.sql: Delete + Insert artist co-play edges.
Score = Jaccard of the two artists' distinct-player sets (controls for
globally-popular artists); >= 2 co-players AND Jaccard >= floor kept
(the floor also self-limits hub artists). Completed plays, 365d window.
- internal/coplay: periodic worker (6h) that atomic-replaces the
user_cooccurrence edge set from play_events — pure local SQL, no
external calls. Wired in main.go alongside the similarity worker.
- LoadRadioCandidatesV2: new coplay_artists arm (source='user_cooccurrence',
seed-artist based, 0.5 damp like similar_artists) + $11 limit;
CandidateSourceLimits.UserCoplay (default 20, For-You 40).
- Integration tests: perfect-overlap Jaccard=1.0 edge + single-user
empty-set gate.
Device axis and AcousticBrainz (Opt 4) are separately tracked; this
closes the milestone-#160 sequential options.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Milestone #160 Opt 3 (temporal half). A new additive scoring term that
boosts a candidate when its artist's play history concentrates in the
CURRENT daypart × weekday-type cell, in the user's local timezone.
- Migration 0046: recommendation_weight_profiles.context_time_weight
(per-profile scoring weight, DEFAULT 1.0).
- Query ListArtistContextPlayCountsForUser: per-artist completed-play
counts split by the current cell (daypart night[22,5)/morning[5,12)/
afternoon[12,17)/evening[17,22) × weekday-vs-weekend) via
started_at AT TIME ZONE users.timezone; 365-day window, skips excluded.
- internal/recommendation/context.go: LoadContextAffinity computes each
artist's shrunk cell-share minus the user's baseline share, clamped to
[-1,1]; sparse artists shrink toward baseline (pseudo-count 5), unknown
artists → 0 (cold-start neutral).
- Score() gains context_affinity_score · ContextTimeWeight; both
candidate loaders set it per candidate.
- Tuning lab: ContextTimeWeight threaded through recsettings + admin API
+ web card ("Time-of-day weight" row) + Go/web tests. Shipped 1.0 both
profiles (uniform start, re-bakeable).
Device-class axis deferred to #1551 (needs a client_id → device-class
mapping that doesn't exist yet).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Milestone #160 Opt 2 (era half). A third taste facet alongside artists
+ genre tags: signed weights over decade buckets ("1990s") derived from
albums.release_date, rebuilt daily and scored into the taste match.
- Migration 0045: taste_profile_eras table (mirrors taste_profile_tags)
+ taste_tuning.era_scale column (DEFAULT 0.5).
- Build side (internal/taste): Config.EraScale ([0,1] damper, mirrors
EnrichedTagScale), accumulate folds each play/like's decade at
base*EraScale, persist atomic-replaces the era rows.
- Scorer (internal/recommendation): TasteProfile gains an era term (own
tanh scale + additive 0.15 share so it never weakens the existing
artist/tag signal when a track is undated); candidate queries return
album release_date; decadeOf mirrors the builder helper.
- Tuning lab: era_scale threaded through recsettings + admin API + web
card (auto-renders the new row) + Go/web tests.
Mood facet deferred to #1534 (partial enrichment coverage + needs
candidate-side enriched-tag loading).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the tag-sources settings surface to the Android admin, over
/api/admin/tag-sources — the operator can enable/disable each provider,
paste an API key (e.g. Last.fm), and test the connection from the phone,
mirroring the web integrations card.
New vertical stack (mirrors the AdminUsers/AdminRequests pattern):
- AdminTagSourcesApi (Retrofit, api/admin/tag-sources GET/PATCH/{id}/test)
+ UpdateTagSourceBody
- AdminTagSourceWire / list envelope / TestTagSourceWire + domain
AdminTagSourceRef / TagSourceTestResult
- AdminTagSourcesRepository (shared Retrofit, .toDomain() at bottom)
- AdminTagSourcesViewModel (@HiltViewModel, sealed UiState, optimistic
toggle + key-save + per-row test result, network auto-recovery)
- AdminTagSourcesScreen (MinstrelTopAppBar + PullToRefreshScaffold; per
provider: Switch, password key field + Save, Test connection + result)
- nav route + graph registration; AdminLanding gains a "Tag sources"
section card (count = enabled providers).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promote the enriched-tag weight (#1490) from a taste.Config default into
the DB-backed tuning lab so operators can dial how much folksonomy tags
count vs raw ID3 genre (rule #25).
- Migration 0044: taste_tuning.enriched_tag_scale (DEFAULT 0.5, backfills
the existing row).
- recsettings: TasteTuning gains the field; seeded/read/updated through
reconcile + persistTaste; applyTastePatch validates it to [0,1]
(generic non-half-life clamp) and diffTaste audits it; TasteConfig maps
it into the profile build.
- API: tasteTuningResp exposes enriched_tag_scale.
- Web tuning card: a data-driven "Enriched tag weight" knob (0 = genre
only). Tests: recsettings persist+range, web fixture field.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Widen keyless coverage: when a track's recording is untagged or has no
recording MBID, fall back to the artist's tags (/ws/2/artist/{mbid}
?inc=tags), down-weighted 0.6 as a coarser signal. Recording tags still
win outright when present.
- ListTracksMissingTags returns a.mbid AS artist_mbid; TrackRef gains
ArtistMBID; the enricher threads it through.
- MB provider: recording-first, artist-fallback via a shared
fetchEntityTags helper. A transient error at the recording step is
returned (retry) rather than masked by the fallback.
Enricher, registry, and settings are untouched — the pluggable design
absorbs the wider lookup. Tests cover fallback-when-untagged,
artist-only-when-no-recording-MBID, and recording-preferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Tag enrichment sources" card to the admin integrations page,
mirroring the cover-art providers card: per-provider enable toggle +
API-key field + Save + Test connection, over /api/admin/tag-sources.
Enabling/keying a source (e.g. pasting a Last.fm key) re-opens settled
tracks for re-enrichment via the version bump.
- admin.ts: TagProvider types + get/update/test functions +
createTagProvidersQuery; qk.tagProviders key.
- integrations/+page.svelte: the card + local edit state, with
MusicBrainz (keyless baseline) and Last.fm (needs a free key) notes.
- Tests: admin.tag-sources API test + integrations component tests
(render / enable+key save / test connection), plus the mock plumbing
the existing suite needs for the new query.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add tag_provider_settings to the truncation list and reset
tag_sources_meta.current_version to 1 in ResetDB, mirroring the cover-art
settings reset. Without it the migration-seeded musicbrainz/lastfm rows
leaked across tests and desynced the enabled-set signature, so a key-only
PATCH spuriously reported version_bumped=true
(TestAdminUpdateTagSource_KeyOnlyDoesNotBump).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose the tag-enrichment provider settings over the admin API, mirroring
the cover-sources surface so a Last.fm key can be pasted and sources
toggled from the web admin UI (rules #25/#27).
- GET /api/admin/tag-sources — list providers + version
- PATCH/api/admin/tag-sources/{id} — enable / set api_key
- POST /api/admin/tag-sources/{id}/test — test connection
- POST /api/admin/tag-sources/research — bump version, re-open
settled rows for re-enrich
- Thread tags.SettingsService through server.New (struct field, like
RecSettings) → Router → api.Mount → handlers.tagSettings; main.go sets
srv.TagSettings.
Handler tests mirror admin_cover_sources_test (list / flip-bumps-version /
key-only-no-bump / unknown-404 / non-admin-403 / not-testable-ok-false),
integration-tier (skip without MINSTREL_TEST_DATABASE_URL).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The taste recompute's tag facet now unions the cached track_tags
(MusicBrainz/Last.fm folksonomy tags) alongside raw ID3 genre, so a coarse
"Rock" gains "post-punk / shoegaze / melancholic".
- taste_profile.sql: ListPlayEngagementInputsForUser +
ListLikedTrackTasteInputsForUser now return track_id to key the
enriched-tag lookup.
- accumulate(): for each play, fold its track's enriched tags weighted by
engagement × tag.weight × EnrichedTagScale; for each liked track, by the
tag-like bonus × tag.weight × scale. A track with no cached tags
contributes genre only (graceful).
- New Config.EnrichedTagScale (default 0.5) — enriched tags augment the
ID3 signal without swamping it; 0 = genre-only. Flows through
recsettings.TasteConfig() (starts from DefaultConfig). Promoting it into
the admin tuning lab is a small follow-up.
Unit-tested the pure foldEnrichedTags helper (overlap accumulation +
scale=0 disable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Construct the tag SettingsService + Enricher at boot (mirroring coverart:
reconcile providers, bump the sources version if the provider set changed
to re-open settled rows), then run a standalone background Worker that
drains tracks needing folksonomy tags on a periodic tick.
Standalone (not threaded through the file-scan chain like cover art)
because tag lookups need only DB fields — recording MBID / artist / title
— so it mirrors the ListenBrainz similarity worker instead: an initial
drain shortly after boot, then every 30 min, up to 200 tracks per tick.
MusicBrainz's 1 req/s ceiling is the real throttle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Milestone #160 Option 1, Step 2. New internal/tags package that enriches
the taste profile's tag facet beyond raw ID3 genre, built so adding a
source later is "implement TrackTagProvider + Register()" — no enricher,
settings, or schema change (operator directive).
Mirrors the internal/coverart pattern:
- Provider interface + package registry (Register/AllProviders/ByID);
TrackTagProvider fetch capability + TestableProvider for the admin test.
- DB-backed SettingsService over new tag_provider_settings +
tag_sources_meta (migration 0043) — enable/key/version, boot
reconciliation, and a provider-hash bump that re-opens 'none' rows when
the compiled-in provider set changes.
- Slim self-contained httpClient (rate-limit + retry + User-Agent), kept
local so tag enrichment never depends on coverart internals.
Providers:
- MusicBrainz: keyless, default-ON, recording tags by MBID (rule #26 baseline).
- Last.fm: keyed, default-OFF, track.getTopTags by artist+track — opt-in
once a key is supplied.
Enricher uses MERGE semantics (differs from coverart's first-success-wins
for a single image): unions tags across every enabled provider, caps to
top-K by weight, and stamps tag_source musicbrainz|lastfm|mixed|none.
Writes are transactional (atomic replace of track_tags).
Unit-tested without a DB: registry mechanics, provider JSON parsing +
weight normalization via httptest, and the pure merge/top-K/source-label
helpers. Wiring (startup + on-scan), integration tests, and the taste
union (Step 3) + Settings UI (Step 4) come next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The play button overlaid on artist circles sat under the circular frame:
CoverTile clipped the Box that held both the artwork and the overlay, so
a BottomEnd button on a CircleShape avatar — which falls in the square's
corner, outside the circle — got clipped away.
Draw the overlay on an outer un-clipped Box; clip only the inner artwork
+ background to `shape`. Corner-anchored overlays (play button, variant
pill) now sit on top of the frame. Bounds/alignment unchanged, so Album
and Playlist tiles keep their layout (pill is padding-inset; their play
buttons are simply no longer clipped at the corner radius).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The trailing-lambda assertTrue overload treats the block as the
*condition*, not a lazy message — switch to assertTrue(Boolean, String).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the three early returns into a single `when` expression —
detekt's ReturnCount capped at 2. No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promote the best-performing surface ("Songs like {artist}", ~8% skip /
~86% completion) out of the shared Playlists carousel into its own Home
row on both Android and web, and widen the daily build from 3 to 6 mixes
so the dedicated row shows a wider spread.
Server (internal/playlists):
- PickSeedArtists candidate pool 5 → 12; pickSeedArtistsForDay now takes
songsLikeSeedCount (6) instead of a hardcoded 3. Graceful degradation
and daily rotation preserved.
Android (HomeScreen.kt):
- New songsLikeSection + buildSongsLikeRow; PlaylistsRow takes a title so
it renders both the "Playlists" and "Songs like…" rows. buildOnlineRow
/ orderedRealPlaylists no longer reserve the 3 songs-like slots.
Offline shows cached mixes (available-first), hides the row when none.
Web (+page.svelte):
- Dedicated "Songs like…" row from songsLikeRow; dropped the 3-slot cap
and removed songs-like from the Playlists carousel.
Tests: seed_selection_test.go, BuildPlaylistsRowTest.kt, page.test.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous commit staged only track_tags.sql.go and left the rest of
the sqlc regen uncommitted, so HEAD referenced TrackTag / the new
tracks.tag_source columns without their definitions — a broken tree.
Adding tracks.tag_source + tag_sources_version to the tracks table
regenerated every generated file that returns/embeds the Track model
(models.go, tracks/events/history/likes/recommendation). Commit them all
together so dev HEAD compiles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First step of taste-profile fidelity via metadata enrichment (milestone
#160, task #1490) — no ML sidecar, operator's constraint.
The taste profile's tag facet is built purely from raw ID3 tracks.genre
(splitGenres in internal/taste/profile.go). This lands the data layer for
enriching it with track-level folksonomy tags:
- track_tags(track_id, tag, weight) — a global cache of style/mood tags,
top-K per track, weight = normalized folksonomy strength [0,1].
- tracks.tag_source / tag_sources_version — versioned enrichment
bookkeeping mirroring artists.artist_art_source (NULL = eligible,
provider name = found, 'none' = settled, version bump = re-process).
- Queries: ListTracksMissingTags (batch drainer), DeleteTrackTags +
InsertTrackTag (atomic per-track replace), SetTrackTagSource, and
ListPlayed/LikedTrackTagsForUser for the recompute to union enriched
tags into the tag facet alongside genre.
No consumer yet — the enricher (MusicBrainz + Last.fm providers,
track-level) and the taste-recompute integration land next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 2-week metrics review found Discover beating the manual baseline on
skip rate — which for a discovery surface means it plays it safe. On a
single-user server it's effectively dormant + crude-random, and the
per-user taste profile (taste_profile_tags, #796) went unused (#1254 gap).
Add a fourth Discover candidate bucket, ListTasteUnheardTracksForDiscover:
unheard / non-liked / non-quarantined tracks ranked by summed taste-tag
weight over tracks.genre (split like the radio tag_overlap arm), md5
tiebreak. Its picks are stamped pick_kind = 'taste_unheard' (migration
0041 widens the CHECK on playlist_tracks + play_events, rule #36).
Rebalance the slot allocation 40/30/30 → taste_unheard 35 / dormant 30 /
cross_user 20 / random 15, and lead the interleave with taste_unheard so
a track shared with another bucket keeps the taste stamp and the
targeted-novelty arm stays measurable. Metrics label "Taste-matched" +
order entry added to the single server-side pickKindLabels map, so web
and Android surface the new breakdown row with no client change.
Cold start (empty taste_profile_tags) yields an empty taste bucket that
redistributes to the survivors, so Discover still fills.
Scribe #1488. Companion review outcomes: Songs-like starvation already
fixed (#1255); For You v2 ratified as-is (fresh-injection cost disproven).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 03:00 system-playlist rebuild (playlist.system_rebuilt) re-pulls
Home, and refreshIndex() rewrites every section delete-then-insert — so
all 7 rows + the Playlists row visibly collapse to empty, refill with
skeletons, then pop in per-tile as metadata hydrates. Read as a lot of
busy on-screen movement.
Raise an "Updating your mixes…" veil for automatic refreshes only (daily
rebuild + reconnect re-pull): HomeViewModel.isUpdating, driven by a new
refreshBehindVeil() the event/recovery collectors call in place of
refresh(). It holds through the pull plus a short settle so hydration
lands behind the veil, then wipes off. Manual pull-to-refresh keeps its
PullToRefreshBox spinner; cold start keeps the skeleton.
The veil is a near-opaque, background-tinted overlay that wipes in from
the left and swallows taps while raised. Extracted HomeStateCrossfade so
HomeScreen stays under detekt's LongMethod.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>