4f69c230c45e5f603e4a296060d407c2a150c696
111 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5749f48b4a |
feat(taste): device-class context conditioning — #1551
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> |
||
|
|
f0c08e7326 |
feat(taste): mood taste facet — #1534
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> |
||
|
|
199fec2058 |
feat(taste): household co-play similarity — #1533
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> |
||
|
|
65dd132b3d |
feat(taste): time-of-day / weekday context conditioning — #1531
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> |
||
|
|
40384cc05e |
feat(taste): era/decade taste facet — #1530
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> |
||
|
|
2b3be8311a |
feat(tuning): expose EnrichedTagScale in the tuning lab (#1520)
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> |
||
|
|
c30511e71b |
feat(tags): MusicBrainz artist-MBID tag fallback (#1519)
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>
|
||
|
|
7d18a3c808 |
feat(taste): fold enriched folksonomy tags into the profile (#1490 Step 3)
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> |
||
|
|
fbfd5550ff |
feat(tags): folksonomy tag enricher — pluggable provider chain (#1490 Step 2)
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> |
||
|
|
c1d143cf4a |
feat(home): Songs-like → dedicated row + wider spread (#1491)
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>
|
||
|
|
20a76f4b39 |
feat(taste): track_tags schema + enrichment queries (Opt 1 foundation)
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> |
||
|
|
7226dab9ff |
feat(discover): taste-targeted novelty bucket + rebalance toward discovery
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> |
||
|
|
9ad4343c76 |
feat(tuning): weekly trend view — per-surface series + knob-turn markers
The verify half of the tune→verify loop (#1251), on the same admin Tuning page as the knobs: - RecommendationWeeklyTrends: weekly per-source outcomes aggregated across all users (the knobs are global, so judging a turn needs global outcomes — rows carry rates only, no track/user identity), with a taste-hit count per bucket: plays whose track's artist has a positive weight in the player's current taste profile. That's the "cheap recompute" reading — retroactive over the whole window, at the cost of profile drift. - GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52): per-family weekly series (skip rate, sample-weighted completion, taste-hit rate) plus the tuning-audit markers inside the window. - Web: sparkline table under the tuning cards — skip rate per week on a shared axis with dashed ticks at knob turns, latest-week columns, window taste-hit rate, low-volume rows dimmed as anecdote, and a plain-text list of the window's tuning changes. Also fixes the revive unused-parameter lint on the tuning GET handler that failed CI run 1903 on the previous commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
0d0a8f46b1 |
feat(tuning): scoring weights → DB-backed admin tuning lab
The recommendation scoring knobs move out of YAML (radio profile) and out of the systemMixWeights hard-code (daily_mix profile) into DB-backed settings with live effect (#1250) — the defaults-discovery lab per decision #1247: the operator turns knobs to find good values, which then get baked back into shipped defaults; end users and other operators should never need the card. - Migration 0040: recommendation_weight_profiles (radio / daily_mix, 8 weight columns), taste_tuning singleton (engagement half-life + completion-curve points), recommendation_tuning_audit (one row per change with a {field, old, new} diff — the trend view's markers, #1251). - internal/recsettings: boot reconcile seeds shipped defaults without clobbering tuned rows (coverart SettingsService pattern), validates patches (bounds, curve ordering), writes audit rows, and pushes daily_mix weights + taste config into package playlists. No-op patches write no audit row. - playlists gains SetSystemMixWeights / SetTasteConfig swap points under a RWMutex — no signature threading through the producers; the scheduler's taste rebuild reads the pushed config. - Radio reads its weight profile from the service per request; the 8 weight fields leave config.RecommendationConfig (YAML keeps only RecentlyPlayedHours / RadioSize / RadioSizeMax). - Admin API: GET/PATCH/reset under /api/admin/recommendation-tuning, echoing current + shipped values. - Web: new admin Tuning tab — two weight profiles side by side, taste card, per-scope save (changed fields only) + reset, deviation dots against shipped defaults. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
9e02878b61 |
feat(playlists): For You composition v2 — multi-seed blend + weighted fresh tail
Two approved composition changes (#1269), mechanism only — the taste/fresh share stays data-decided (#1252) and pick_kind attribution is unchanged. Multi-seed blending: each day's build now seeds from up to 3 of the user's top-5 tracks (pickDailySeeds, the generalized daily shuffle) instead of one rotating anchor, so the mix spans neighborhoods within a day and stops feeling bipolar as the rotation swings between dissimilar seeds. Per-seed pools merge first-seen-deduped; the head is filled best-first under 50/30/20 per-seed quotas (60/40 for two seeds) so one neighborhood can't monopolize it, with thin-seed quota spilling best-first. Score-weighted fresh tail: the tail sample (rank 2*headN onward) was uniform — the 380th-best candidate as likely as the 101st. It now uses deterministic Efraimidis-Spirakis keys with weight halving every 50 ranks, so freshness keeps its "you'll probably enjoy this" half while still rotating daily. The retired single-seed picker's one other caller, You-might-like, moves to pickDailySeeds(n=1) — a single neighborhood per day is right for a short shelf, and the behavior note is inline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
48f288e2e5 |
feat(mixes): tiered rebuilds for New for you + First listens (rule #131)
Both mixes move from a single hard eligibility rule to the tiered ladder, with their tier stamped onto playlist_tracks.pick_kind via the #1270 provenance pipeline. New for you (#1267) — consume on play, degrade by stepping back: - "Consumed" = any track attempted >=30s; played albums leave the mix at the next build instead of crowding it until the calendar window expires. - Tier 1: unconsumed albums added <30d by direct-affinity artists. Tier 2: unconsumed affinity albums from the wider 30-90d window — added while you weren't looking. Tier 3: any unconsumed album added <90d, newest first. First listens (#1268) — track-level "attempted" threshold: - A 2-second accidental brush no longer disqualifies a whole album; "attempted" is duration_played_ms >= 30000 per track. - Tier 1: albums with zero attempted tracks. Tier 2: barely-attempted albums (<=25% of tracks reached 30s), minus the attempted tracks themselves. The artist-affinity ordering signal also moves to the >=30s definition so skip-only contact doesn't read as trust. Producer plumbing: fetch adapters map the tier column onto pick kinds, finishMix propagates PickKind into the persisted candidates, and rotateForDay now rotates within contiguous same-pick-kind blocks so daily rotation can't hoist tier-3 filler above tier-1's exact fits (untiered pools are one block — original behavior). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
2be07ef271 |
fix(mixes): close three intent gaps found in the system-playlists audit
Three discovery-mix defects from the intent audit (Scribe note #1254), all sharing the same root pattern — skips treated as non-events: - Deep Cuts (#1257): eligibility counted only unskipped plays, so a track skipped twice with zero completed listens read as "barely heard" and kept being re-offered. Tracks with >=2 skips no longer qualify; a single accidental skip doesn't banish. - Rediscover (#1258): a skip on a rediscover-sourced play — the user explicitly declining the resurfacing invitation — changed nothing, so declined tracks re-qualified the next day forever. Such tracks now sit out 90 days. - On This Day (#1256): day-of-year distance used plain ABS, so Dec 28 vs Jan 3 read as 359 days apart and the window silently gutted itself for ~3 weeks around every New Year. Now circular (LEAST(d, 365-d)), anchored on the build-date parameter instead of now() so it's testable and consistent with the mix's daily determinism. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
a670840114 |
fix(playlists): Songs-like mixes no longer vanish after a quiet week
PickSeedArtists had a hard 7-day window with no fallback: a week without listening emptied the seed pool, produceSeedMixes returned zero playlists, and the daily atomic-replace build deleted every existing "Songs like X" mix until the user played something again (#1255). The query now falls back through widening engagement windows — 7d → 30d → all-time → liked artists — the same tiered shape that fixed the identical vanish for For You's seeds (PickTopPlayedTracksForUser). Like-boost scoring is preserved in every tier. All returned rows share the winning tier, and produceSeedMixes maps it onto the rule-#131 pick-kind ladder (7d = tier1 exact, 30d = tier2, all-time/liked = tier3) and stamps the built tracks — the #1270 provenance pipeline then attributes plays and skips to seed freshness, so the metrics card can say whether stale-seeded mixes actually perform worse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
5faa57634b |
feat(metrics): provenance as standard — pick_kind for all system mixes
The #1249 mechanism (stamp WHY a track is in the snapshot at build time, freeze it onto the play at ingestion, break it down in metrics) generalizes from a For You one-off to the standard for every system mix (#1270): - Migration 0039 widens both pick_kind CHECKs (drop + re-add in the same change) to taste/fresh + Discover's dormant/cross_user/random + tier1-3 for the rule-#131 eligibility ladders. - GetForYouPickKindForTrack becomes GetSystemPickKindForTrack (user, variant, track); ingestion stamps any systemPlaylistSources play from its own variant's live snapshot, live + offline paths. - Discover stamps its candidate bucket on discoverTrack before the interleave, making the 40/30/30 allocation measurable; dedup keeps the taking bucket's stamp. - Metrics replace the for_you special-case with one pick-kind vocabulary — any family with attributed plays gets a breakdown, future stamping mixes need no metrics change. - Web: breakdown sub-rows are now toggled per surface (collapsed by default) so eight stamping mixes don't swamp the card. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
fb4431207d |
feat(recommendation): For You exploration attribution — taste vs fresh picks
Milestone #127 step 2 (#1249). For You deliberately blends two populations — a head of top-scored taste picks and a tail sampled from deeper ranking (the freshness injection) — but the metrics judged it as one blob, so its skip rate couldn't distinguish "the taste engine is missing" from "the freshness tax is too high". That number decides the exploration share before we tune it. - Migration 0038: nullable pick_kind ('taste'|'fresh') on both playlist_tracks (stamped at snapshot build) and play_events (frozen at play-ingestion — the snapshot rebuilds daily, so attribution cannot be reconstructed at read time). - Builder: pickHeadAndTail marks head=taste / tail=fresh; the small-pool fallback is all taste (top-N-by-score IS the taste mechanism). Other variants persist NULL. - Ingestion: for_you plays (live + offline replay) look the track up in the user's current snapshot; not found → unattributed, never guessed. - Metrics: For You's row gains a breakdown (taste / fresh / earlier unattributed plays), parent row stays the sum; web card renders the sub-rows indented with the same baseline deltas + low-data dimming. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
60533073ad |
feat(metrics): bucketed surface families + manual-plays baseline (#1248, milestone 127)
The recommendation metrics table was observable but not actionable: raw source strings (album:<uuid> one-offs) drowned the stable surfaces, and manual plays were excluded so skip rates had no control group. - SQL: include NULL-source rows (the baseline) and carry completion_n so family merges can weight avg_completion correctly. - Handler buckets raw sources into stable families (radio:<uuid> → Radio, album:/artist: → direct plays, etc.) grouped by surface intent: go-to / discovery / direct — each band judged against its job, since discovery mixes are expected to skip hotter. Families under 20 plays are flagged low-confidence, not hidden. - Settings card renders the baseline row and per-surface deltas in percentage points vs baseline (worse-than-baseline deltas in danger color), intent hint copy per group, low-data rows dimmed. - Pure-unit test for the bucketing/merge; DB test updated to the new contract (baseline included, radio:<uuid> collapse). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
4b150a277e |
fix(recommendation): Rediscover no longer ships a one-song playlist (#1246)
Confirmed against prod: exactly one track (17 plays, cold since May 21) met the c>=5 + 30d-cold bar, and three process defects turned that into a 1-track playlist instead of the locked placeholder. - ListRediscoverTracks: collapse the two-tier UNION into one blended pool. The old shallow-tier gate (WHERE NOT EXISTS deep) was all-or-nothing — one 6-month row suppressed the entire 30-day tier — and deep was a strict subset of shallow anyway. Eligibility drops to >=3 non-skip plays (on a weeks-old history the >=5-play tracks are precisely the ones still in rotation); ordering prefers >=6mo cold, then >=5 plays, then raw count. - Minimum viable mix floor for all five discovery mixes: below minLen (15; 5 for the album-coherent NewForYou/FirstListens) the variant is withheld so Home renders the 'listen more to unlock' placeholder instead of a mix that reads as built-wrong. - /api/events: clamp client-supplied 'at' to [user.created_at, now+5m]. Unbounded client clocks could write arbitrarily old plays and poison the 6-month ordering (prod data verified clean — no scrub needed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
4ed831d9c3 |
feat(server/diagnostics): device debug-reporting ingest + admin timeline + retention (M9)
New diagnostic_events table + per-account users.debug_mode_enabled flag.
When an account's flag is on, its client(s) POST a batch timeseries of
connectivity / UPnP-sync / power / lifecycle events to /api/diagnostics
(no-op 204 when off, kind whitelist mirrors the CHECK constraint).
Admin surface: GET /api/admin/diagnostics (optional account/device/kind/
time-window filters, RFC3339-or-epoch-ms, export-sized paging) + a
/diagnostics/devices overview + PUT /api/admin/users/{id}/debug-mode to
flip an account remotely while a bug is live. debug_mode_enabled is now
exposed on /api/me (client gate) and the admin user views.
Retention: a 30-day gc-worker sweep (GcPruneDiagnostics), keyed on the
server clock so a skewed device clock can't keep rows alive.
Refs Scribe M9 (#119), tasks #1172 #1173.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
|
||
|
|
1a7515e6ea |
feat(taste): phase 4 — recommendation observability (#796)
Per-source play outcomes so the operator can see whether each recommendation
surface is landing and tune the now-operator-tunable taste weights.
Server:
- query RecommendationSourceMetricsForUser: groups the user's play_events by
source (system-playlist surface), reporting plays / skips / avg completion
over a window; NULL-source (library/radio) plays excluded.
- GET /api/me/recommendation-metrics?days=30 (default 30, capped 365) →
{window_days, sources:[{source, plays, skips, skip_rate, avg_completion}]}.
- handler test: 401 unauth; per-source aggregation + NULL-source exclusion +
skip_rate / avg_completion math.
Web:
- lib/api/metrics.ts: query + friendly source labels.
- settings page gains a "Recommendation metrics" card (table of surface / plays
/ skip rate / avg completion), with loading/error/empty states.
- settings tests mock the new query (manual subscribe-store, hoisting-safe).
Note: You-might-like plays aren't source-tagged (it's a Home row, not a system
playlist), so this covers For-You / Discover / the mixes. Tagging YML plays
would be a client follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
6c26ba807e |
feat(taste): phase 2b — taste_overlap candidate arm (#796)
2a re-ranks the existing pool by TasteMatch; this ensures taste-relevant tracks ARE in the pool. Adds a 6th arm to LoadRadioCandidatesV2: in-library tracks by the user's top positively-weighted taste-profile artists ($10 K, weight > 0, deterministic weight-DESC,id order so it doesn't reintroduce same-day nondeterminism). Pool-inclusion only (sim_score 0) — TasteMatch already scores the fit. Empty for cold-start users (no profile). - CandidateSourceLimits.TasteOverlap; default 20 (radio), 80 for For-You via systemForYouSourceLimits. - You-might-like deliberately sets TasteOverlap=0: it surfaces NOT-actively- engaged artists, so flooding its pool with top-taste (mostly already-played) artists would just feed the read-time dedup. - Test: positive-weight artist's track enters via the arm; negative-weight one is excluded (weight > 0). Existing pool tests unaffected (no profile seeded). Deferred within 2b: profile-seeded For-You — marginal given the arm + TasteMatch already inject taste broadly (top-played seed ≈ top-taste artist). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c7adf2c87a |
fix(recommendation): broaden You-might-like fallback to liked album/track artists (#790)
The fallback pulled artists only from explicit artist-likes (general_likes_artists),
but most users like albums and tracks far more than artists — so the artists row
still came up thin (a couple of tiles) even with a rich library, while the albums
row filled fine.
Broaden both fallbacks to "entities you've shown affinity for":
- artist fallback = explicit artist-likes ∪ artists of liked albums ∪ artists of
liked tracks.
- album fallback = explicit album-likes ∪ albums of liked tracks.
New dedicated queries (ListYouMightLike{Artist,Album}FallbackForUser) replace the
narrow Rediscover-fallback reuse; same projection so the Go layer still converts
directly. (Aliased + fully-qualified the UNION arms — sqlc merges UNION scopes,
so unqualified user_id was ambiguous across the three like tables.)
Test: 12 liked TRACKS by distinct artists, no artist-likes → the artist row now
fills from their artists (was empty before).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
6e0d0e5723 |
feat(taste): phase 1 — persistent per-user taste profile from graded engagement (#796)
Build a persistent, decaying model of each user's taste, recomputed daily, that later phases consume across every recommendation surface. Phase 1 only BUILDS the object — no behaviour change to what's surfaced yet. Core mechanic — graded engagement (replaces binary was_skipped for learning; was_skipped stays for History): a play's completion ratio maps to a signal in [-1,+1] via two linear ramps (instant-skip → -1, ~0.30 neutral, ≥0.90 → +1). Time-decayed (half-life ~75d) so recent behaviour dominates and the profile tracks drift. Per operator constraints: - No explicit dislike button — negatives come only from passive behaviour (early skips). Nothing recorded to regret or opt out of. - Negatives are track-scoped; artist/tag weight is the decayed SUM of their tracks' engagement, so one skip nets out against many good plays (a DB test asserts a liked artist stays positive despite an early-skipped track). A floor clamp bounds how negative any single entity can get. - migration 0035: taste_profile_artists / taste_profile_tags (signed weight, indexed by (user, weight DESC)). - internal/taste: engagement.go (pure curve + decay) + profile.go (accumulate plays + like bonuses, floor damping, size caps, atomic-replace). - scheduler: rebuildUserDaily recomputes the profile before the playlist build (so phase 2 can read it), best-effort — a taste failure never blocks playlist building. Wired into the daily job + startup catch-up only (not manual/lazy rebuilds). - tests: pure (engagement curve, decay, ranking, floor, genre split) + DB-backed (positive/negative weights, aggregation-protects-artist, like bonus, atomic replace). All green vs real Postgres. Config knobs live in taste.DefaultConfig() for now; wiring them into the server RecommendationConfig is a later follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
fdd14ef04c |
feat(server): "You might like" album/artist Home rows (#790)
Surface in-library albums/artists the listener doesn't actively spin but is predicted to enjoy, derived from the same similarity + like-weighted candidate engine that powers For-You — rolled up from track scores to album/artist granularity. Built in the daily 3am BuildSystemPlaylists pass, atomic-replaced alongside the system playlists, and read back by /api/home (+ /api/home/index). Cold-start gate: skips generation entirely below 20 distinct unskipped tracks AND 5 distinct artists, so a thin profile ships empty rows rather than near-random tiles. - migration 0034: you_might_like_albums / you_might_like_artists (id+rank, CASCADE, per-user rank index). - playlists/you_might_like.go: cold-start gate + similarity roll-up (sum-of-top-3 aggregation, per-artist album cap, daily-rotating via the same userIDHash jitter as For-You) + atomic-replace persist in the tx. - recommendation/home.go: two new HomePayload sections with read-time cross-section dedup vs Most Played / Rediscover / Last Played, trimmed to 10 each. - api: you_might_like_albums / you_might_like_artists on /api/home and /api/home/index, reusing albumRefFrom / artistRefFromCovered. - tests: pure roll-up/aggregation/cap unit tests + DB-backed gate, sufficiency, and atomic-replace tests (all green vs real Postgres). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
63b25e65ad |
feat(server): similar-artists + per-user artist top-tracks endpoints
GET /api/artists/{id}/similar — in-library artists ranked by similarity
score (deduped across sources), ArtistRef list with cover + album count.
GET /api/artists/{id}/top-tracks — current user's most-played tracks for
the artist (skips excluded, quarantine filtered).
|
||
|
|
e994aae613 |
feat(server): retire scan scheduler for watcher + safety-net walk
Wire the fsnotify watcher and a fixed 12h safety-net delta walk in main; remove the configurable scan scheduler (scheduler.go, scan_schedule table via migration 0033, GET/PATCH /api/admin/scan/schedule, and the server/api plumbing). Manual scan + scan status are unchanged. |
||
|
|
258bc1f75c |
feat(server): drift audit batch 7 — periodic GC worker for 5 lifecycle gaps
New `internal/gc` package with a single Worker that runs all five lifecycle / retention sweeps from the 2026-06-02 drift audit on a 1-hour tick. Each sweep is small, idempotent (re-running on already-clean rows is a no-op), and logs its affected-row count. Sweeps (Scribe parent #552): - **#566** GcCloseStalePlayEvents — play_events rows opened > 24h ago that never got a play_ended (client crash, network drop). Synthesizes ended_at from duration_played_ms when known, falls back to now() so the row stops looking "open" to downstream filters (ended_at IS NULL). - **#565** GcClosePlaySessionsWithNoRecentEvents — play_sessions with last_event_at older than 6h get ended_at = last_event_at ("user moved on"); empty sessions older than 1h get closed too (stale handshakes from clients that never recorded a play). The audit caught that the column was added but never populated by any writer — every session row was "open" forever, breaking downstream dedup queries that assume closed semantics. - **#567** GcExpireScrobbleQueueFailedRows — drops scrobble_queue rows in status='failed' older than 14 days. The worker stops retrying after maxAttempts so these otherwise accumulate forever on a persistent ListenBrainz outage / revoked token. - **#574** GcResetStuckSystemPlaylistRuns — flips system_playlist_runs.in_flight back to false on rows whose last_run_at is older than 10 minutes. Catches goroutine-panic wedges where the generator died between SET in_flight=true and SET in_flight=false; the duplicate-prevention check refuses to start a fresh regen while in_flight, so a stuck row would otherwise deadlock all future regens for that user. Records "stuck-row auto-reset by gc" in last_error so the operator can tell auto-reset from a recent real failure. - **#575** GcDeleteExpiredPasswordResets — deletes expired password_resets rows. Unused expired rows go after a 1h grace (gives the operator time to debug an active reset attempt); used rows are kept 7 days for audit. Wiring: - main.go `go gcWorker.Run(ctx)` alongside the other periodic workers (scrobble, similarity, lidarr). - tickOnce fires once at start so a freshly-deployed server does its initial sweep without waiting a full tick, matching the scrobble worker pattern. - Errors per sweep are logged but do NOT abort the remaining ones — a transient pgx error from one query shouldn't prevent the others from running. Tests: - 4 integration tests, one per UPDATE/DELETE sweep, that seed rows-to-sweep + rows-to-leave-alone and assert the right rows changed state. Skip unless MINSTREL_TEST_DATABASE_URL is set (mirrors the api package pattern). - Empty-tables no-op smoke test. - Run() cancellation honoured (no spinning goroutine at test-runner exit). That's all five remaining server-side lifecycle findings from the audit. The Android LOCAL_USER_ID hardcode (#576) is a separate refactor that needs auth-store wiring and stays in the queue. |
||
|
|
47d2f61161 |
fix: drift audit batch 1 — six small mechanical wins
Six findings from the 2026-06-02 multi-system drift audit (Scribe parent task #552): - **#553 (web)** Tailwind class fix: web admin playback-errors Delete confirm button was using `bg-action-danger`, an undefined token — swap to `bg-action-destructive` to match every other destructive button. Restored the Oxblood signal that distinguishes Delete from Cancel. - **#555 (web)** Type the `source` field on `play_started` in the EventRequest discriminated union. Server's eventRequest accepts it; web's TS type was missing the slot, so a "drop extra properties" refactor could silently strip the source tag and break system- playlist rotation attribution. - **#556 + #557 (server)** Coverage rollup whitelist was pinned to ('sidecar','embedded','mbcaa','theaudiodb'); migration 0020 added 'deezer' and 'lastfm' as valid cover_art_source values but those never got wired in, so albums with art from those providers silently counted as MISSING in the admin Coverage dashboard. The rollup test was seeding only the pre-0020 sources, masking the gap in CI. Extend the query to include deezer + lastfm; seed the test with one row per valid source (regression-guards future additions). - **#558 (web)** Auth gate was blocking /forgot-password and /reset-password/<token> — both are entered without a session by definition, so the email-link reset flow was bouncing signed-out users to /login. Add /forgot-password to the public set and a /reset-password/ prefix matcher. New tests assert both routes reach their pages without redirect. - **#571 (server)** Library scanner was indexing only .mp3/.m4a/.flac /.ogg while the stream handler (media.go) had been extended to serve .opus, .aac, and .wav. A user with .opus files in their library never saw them in artist/album listings because the scanner skipped indexing — silent data loss. Aligned the scanner to match the media handler. Scribe statuses updated to in_progress; flipping to done after the push since these are mechanical and verified directly against the cited file:lines. |
||
|
|
15b59a214d |
feat(server): playback_errors table + admin inbox endpoints
New client-reported playback-error log. Surfaces zero-duration
tracks (and future load_failed / stalled kinds) into an admin
inbox so the operator can hide / delete / re-request the
offending track.
Schema (migration 0032):
- playback_errors table with CHECK constraints on the kind +
resolution enums (per the standing rule that new enum values
need a migration to add)
- Partial index on unresolved rows for fast inbox lookup
- ON DELETE CASCADE from tracks + users so cleanup is automatic
Endpoints:
- POST /api/playback-errors: any signed-in user reports. Body
validates track existence + kind whitelist; client_id required
so support can correlate reports from the same device.
- GET /api/admin/playback-errors?resolved=false&offset=&limit=:
admin list with join to track/album/artist for table render
without per-row round-trips. Pagination capped at 200/page.
- POST /api/admin/playback-errors/{id}/resolve: admin marks
resolved with a resolution enum string.
Auto-resolve on Hide/Delete/Re-request from the inbox row is
driven from the web client (two sequential calls) — keeps the
existing track-action endpoints unchanged.
|
||
|
|
54903c4760 |
fix(recommendation): drop user_id from Rediscover daily hash to preserve sqlc type inference
CI on
|
||
|
|
8b586c2e50 |
feat(recommendation): rework Rediscover - track-derived eligibility + daily rotation + caps
User request 2026-06-01: the previous Rediscover only fired on explicit album/artist likes, so users who only liked at the track level got an empty Rediscover row. Also no daily rotation - the section never changed between data updates - and 25 items felt long for the Home carousel. SQL (internal/db/queries/recommendation.sql): - ListRediscoverAlbumsForUser UNIONs the existing explicit album-like signal with a new track-derived path: an album qualifies if it has >=2 liked tracks where the earliest like is >30 days old. - ListRediscoverArtistsForUser does the same with threshold 3 (artists span more releases, so the bar is higher to avoid one-hit-wonder affinities). - Both ordering switched from longest-since-last-play to a daily- stable random hash md5(entity_id || user_id || current_date) so the row randomizes but stays consistent within a day. - Fallback queries also switched to the daily-stable hash so the fallback rows don't reshuffle on every refresh. Go (internal/recommendation/home.go): - HomeRediscoverLimit dropped from 25 to 10 (carousel-sized). - rediscoverInnerLimit (30) is the per-query cap; gives headroom so the Go-layer filters don't undershoot. - applyRediscoverAlbumFilters does cross-section dedup vs Most Played (no point surfacing albums the user is actively spinning) plus a diversity cap of max 2 albums per artist (prevents one liked-but- forgotten artist dominating the row). - applyRediscoverArtistFilters does cross-section dedup vs Most Played's artist set; no diversity cap needed (one row per artist). Tests (internal/recommendation/home_integration_test.go): - TrackDerived path: 2 liked tracks >30d old + no recent plays = album appears in Rediscover. - Threshold guard: 1 liked track = album does NOT appear. - Diversity cap: 4 explicit album-likes from same artist = 2 in output. - Dedup vs MostPlayed: eligible album whose track is in MostPlayed gets filtered out. - Existing FallbackWhenSparse test preserved (semantics unchanged for recent likes that miss the >30d primary filter). Clients unchanged - they render whatever /api/home/index returns. No parity-map row needed (this is a server-internal recommendation change, not a layout divergence). |
||
|
|
e68e1b10a6 |
fix(player+lidarr): mini-player sync race; durable approve + dedup
player: setQueueFromTracks fast-starts a single source at player-index 0 while the full queue is broadcast, so the transient currentIndexStream→0 emission clobbered the correct mediaItem with queue.value[0] (the first track). Mini bar / playlist marker pinned to the wrong track until a later index event (~the "passive ~30s recovery"). Track a logical-index base so the player→queue mapping stays correct during the fill window; also fixes the latent forward-fill auto-advance off-by-base. lidarr #50: approving no longer fails when Lidarr is down. Approve records the decision durably first, then best-effort adds; the reconciler idempotently (re)sends unconfirmed adds every tick until they stick (new additive lidarr_add_confirmed_at; AddArtist/AddAlbum map Lidarr's "already exists" 400 → ErrAlreadyExists). No failed-state or expiry by design — Lidarr keeps trying, operator monitors. lidarr #51: Create() is now idempotent — a non-terminal request for the same MBID returns the existing row instead of inserting a duplicate. Rewrites the obsolete LidarrUnreachable_503 test to assert the durable- approve contract; threads a client factory into NewReconciler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e2432caa65 |
fix(library): extract recording MBID → tracks.mbid; unblock LB similarity
tracks.mbid was 100% NULL: the scanner only extracted album + artist MBIDs, never the recording MBID. The ListenBrainz similarity worker is gated on tracks.mbid IS NOT NULL, so track_similarity could never populate — starving For-You/radio's strongest candidate source (lb_similar) on every library. - mbids.go: add extractRecordingMBID (mbz.Recording / Picard musicbrainz_recordingid). Separate fn so extractMBIDs' signature + unit tests stay untouched. - scanner.go: persist recording MBID via UpsertTrack (heals on the file_path conflict, so re-scans backfill for free). - BackfillTrackMBIDs: one-shot pass mirroring BackfillMBIDs, wired as scan Stage 2b (idempotent via SetTrackMbidIfNull, gated by BackfillCap, log-only progress). - migration 0029: tracks_mbid_unique (0002, written when the column was always NULL) wrongly assumes one MBID == one track. A recording appears on multiple releases, so rows legitimately share a recording MBID. Replace with a non-unique partial index. Zero-risk: column is 100% NULL at migration time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2e7b81fdfe |
fix(playlists): robust For-You seed + deep fill; young-library mix fallbacks
For-You silently vanished after ~7 days of not listening (seed query required a non-skip play in the last 7 days) and capped at ~40 on self-hosted libraries with no ListenBrainz similarity data. - PickTopPlayedTracksForUser: tiered seed — last 30d top plays, else all-time top plays, else liked tracks. For-You only disappears now if the account has zero plays AND zero likes. - produceForYou uses deeper candidate source limits (raised random/ tag/similar K) so it reaches ~100 even with empty lb_similar / similar_artists; richer when LB enrichment is present. - Rediscover: tiered — 6-month-dormant, else ≥5-play 30-day-dormant. - On This Day: floor 60→30 days, window ±7→±10 doy; still skips cleanly (no rows → no playlist) on insufficient history. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a5e2abb8c4 |
feat(offline): #427 S4a — Shuffle all + offline system-playlist gate
Headline of S4. "Shuffle all" is always present (home app-bar shuffle icon); the pool degrades with reachability: - online → GET /api/library/shuffle?limit=N (new): N random library tracks server-side, per-user quarantine filtered (ListRandomTracksForUser, ORDER BY random()). - offline → ShuffleSource shuffles the whole local cache index (audio_cache_index ∩ cached_tracks, names from cached_artists/ albums) — a UNION over liked AND recently-played, since the two-bucket split is storage-only and never filters playback. Offline gating: refreshable (singleton) system playlists need the live build/shuffle endpoints, so their tile play is disabled when offlineProvider is true — Shuffle all is the offline path. User playlists still play from cache. playlist_card now reads offlineProvider in build → wrapped the direct-render widget test in ProviderScope (offlineProvider is smoke-safe from S1: tracked timers, no connectivity mount). S4b (offline Recently-played / Liked browsable surfaces over the cache index) next. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c7ee0871a5 |
feat(playlists): #411 R3 — five discovery mixes (#419-423)
Each is one candidate query + one registry entry; zero client work (R2 made tiles/refresh/shuffle generic, all are singleton kinds so web's server `refreshable` flag and Flutter's derived getter both light up automatically). - deep_cuts (#419): <=2-play tracks from liked / heavily-played artists; diversity-capped. - rediscover (#420): >=5-play tracks not heard in 6 months, by historical affection. - new_for_you (#421): tracks from albums added <=30d whose artist the user likes/plays; album-coherent (no cap). - on_this_day (#422): tracks played within ±7 day-of-year in prior windows (>60d ago), weighted by play count. - first_listens (#423): never-played albums, tiered liked-artist → played-artist → rest; album-coherent. system_mixes.go producers mirror the Discover model (SQL gives the ranking; finishMix caps+truncates to 100 to match For-You/Discover shuffle depth; album-coherent mixes skip the cap). Builder query failure is non-fatal (logged, yields no playlist) like Discover. Existing system_test existence checks are unaffected. Closes the #411 system-playlists-v2 umbrella's new-types thread. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d2a0b7d780 |
feat(playlists): #415 stage 1 — rotation-state schema + play ingest
First of three stages for system-playlist sample-history dedup (Fable #415, server-side rotation per the operator's choice). Schema (migration 0027): - play_events.source (nullable text): which surface a play came from. 'for_you' / 'discover' feed rotation; NULL for library / user-playlist / radio / Subsonic. - system_playlist_rotation_state(user_id, playlist_kind, played_track_ids uuid[], rotation_started_at, updated_at): the per-(user,kind) set of already-heard tracks this rotation. Ingest: - New RecordPlayStartedWithSource on the writer; RecordPlayStarted is now a thin source="" wrapper so the frozen Subsonic shim is untouched (no signature ripple). - When source is a known system kind, the same txn appends the track to rotation state (AppendRotationPlayed keeps the array a set via the conflict CASE). - /api/events play_started accepts an optional "source". No serve-behavior change yet — Stage 2 makes shuffle prefer the unplayed tail + resets on exhaustion; Stage 3 wires the clients to send source and consume the rotation-aware order. Tests: rotation appends + dedupes for a system source; source-less play writes no rotation row. (Existing RecordPlayStarted tests are unchanged — same wrapper signature, identical behavior at source="".) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7cfaafd360 |
feat(#357): library_changes retention compactor
Closes the last deferred follow-up from #357. The library_changes table is the append-only change log that drives /api/library/sync's delta semantics — every mutation (scanner upsert, like, playlist edit, track delete) writes one row. Without a retention policy the table grows unbounded; the original migration (0025) called out the follow-up explicitly. New goroutine: sync.Compactor runs daily, deletes rows where changed_at < now - 30 days. Logs a row count when non-zero so operators can see compaction activity in the journal. First tick fires on startup so a process that hasn't been compacted in a while catches up immediately. 30-day retention matches the offline-mode spec (docs/superpowers/specs/2026-05-09-flutter-offline-mode-design.md). Clients with a cursor older than that hit the existing 410 fallback path and resync from scratch. Imported as syncpkg in main.go to follow the existing convention (see internal/library/scanner.go). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
230da7bdcb |
feat(users): timezone column + PUT /api/me/timezone
Schema + endpoint scaffolding for #392 Half B (per-user timezone scheduling). Adds two columns to the users table: - timezone text NOT NULL DEFAULT 'UTC' (IANA name) - timezone_updated_at timestamptz (nullable; populated on each PUT) PUT /api/me/timezone validates the IANA value via time.LoadLocation and writes the row. No scheduler integration yet — the scheduler struct lands in the next commit and the handler-side Refresh call in the commit after. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
b4801c2dd3 |
refactor(playlists): SQL queries return top-5 candidate seeds
For-You + Songs-Like-X seed selection moves to Go-side daily rotation (next commit). The SQL change just widens the candidate pool: top-5 played tracks instead of single top played track; top-5 artists instead of top-3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1f0f7eee1a |
fix(playlists): unblock Discover by removing SELECT DISTINCT + ORDER BY plan error
The cross-user bucket query combined SELECT DISTINCT with ORDER BY md5(...), which Postgres rejects at plan time (SQLSTATE 42P10). buildDiscoverCandidates returned that error on first bucket failure, so the whole Discover playlist was skipped every nightly run — even though the random bucket pool was healthy. Switched to GROUP BY so the md5 ordering expression no longer needs to appear in the select list, and hardened the function so future single- bucket failures degrade gracefully via slot redistribution instead of taking out the whole playlist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
af5744f8ab |
perf(home): aggregate-first rewrites for two scan-the-world queries
handleGetHome itself is well-architected (5 sections in parallel via goroutines, latency-bound by the slowest single query). The cold- start lag is two of those queries doing wider scans than necessary. ListLastPlayedArtistsForUser was iterating FROM artists a with a LATERAL play_events join per row — O(total_artists in library) plan even for users who've only played a handful. Inverted: aggregate the user's plays by artist_id first via the play_events → tracks join (uses play_events_user_track_idx + tracks pkey), then attach the artist row and lateral cover/count subqueries only for the artists that actually appear. Cost now bounded by play history, not library size. ListMostPlayedTracksForUser was joining tracks/albums/artists for every play_event row before grouping — O(total plays) work for joins. Pre-aggregated play_events into a CTE keyed by track_id + count(*), then joined to tracks/albums/artists only for the distinct-tracks survivors. Order-by uses the pre-computed count. No handler or generated-Go signature changes — both queries return the same rowset shape, just much faster on libraries where total artists/plays >> distinct-played-artists/distinct-played-tracks. |
||
|
|
c7549bbe48 |
perf(api): collapse N+1 in /api/artists/{id} + 1 round-trip in /api/albums/{id}
Two new sqlc queries replace three sequential per-album round trips that were dominating detail-screen latency. GetAlbumWithArtist: handleGetAlbum was doing GetAlbumByID then GetArtistByID — separate round trips for one logical lookup. The new query joins albums + artists with sqlc.embed and returns both in one SELECT. Detail-page DB cost: 3 trips → 2. ListAlbumsByArtistWithTrackCount: handleGetArtist was loading the artist's album list, then issuing one CountTracksByAlbum per album to populate track_count. On a 30-album artist that's 32 sequential queries — each ~5ms over a local DB, ~30ms over a remote one. The new query embeds the album row + a correlated count(*) subquery, so every album's track count comes back in one SELECT regardless of album count. Detail-page DB cost: 1 + N → 1 + 1. Together these account for the bulk of cold-cache navigation latency on the Flutter client. Combined with the existing SWR + nav hydration on the client side, detail screens should render their header instantly and the body within one round trip instead of N+constant. |
||
|
|
9bf4b504b3 |
feat(server): GET /api/library/sync endpoint
Returns batched upserts + deletes since the supplied cursor. Empty cursor
returns full snapshot; subsequent calls pull deltas. Per-user entities
(likes, playlists) are scoped to the authed user. Composite-key entities
(likes, playlist_tracks) use stable string ids encoded by sync.EncodeLikeID
/ sync.EncodePlaylistTrackID.
Behavior:
204 No Content - no changes since cursor
200 OK - JSON syncResponse {cursor, upserts, deletes}
410 Gone - cursor older than oldest log row; client must reset
401 / 500 - standard envelope errors
Adds sqlc queries GetAlbumsByIDs, GetTracksByIDs, GetPlaylistsByIDs to
mirror the existing GetArtistsByIDs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
cb35133843 |
feat(server): library_changes log table + sqlc queries
Append-only change log for library entities. Every mutation on artists/albums/tracks/likes/playlists/playlist_tracks will write a row in the same transaction as the mutation itself (wired in subsequent commits). Powers the Flutter delta-sync endpoint (#357). - 0025_library_changes migration (up + down) - internal/db/queries/library_changes.sql (Insert, GetSince, MaxCursor, MinCursor) - regenerated dbq from sqlc Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
bffa397250 | refactor(server/sql): unify *ForUser track queries via nullable user_id (A1) |