CI on 8b586c2e caught it: the $1::text cast in the md5 ORDER BY made
sqlc infer the parameter as plain string instead of pgtype.UUID,
generating Column1 string instead of UserID pgtype.UUID on the
ListRediscover*ForUserParams structs. go vet flagged both call
sites in internal/recommendation/home.go where the Go code passes
UserID by name.
Fix: hash on album_id (or artist_id) + current_date only. The
eligibility filter already differs per user (different liked sets),
so the daily-rotation goal is preserved; we just lose per-user salt
in the within-day ordering of overlapping items - acceptable.
Applies to both primary queries AND both fallback queries (all four
had the same cast).
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).
staticcheck S1016 in the new golangci-lint v2 flagged four sites
copying field-by-field between two types with identical struct
shapes. Direct type conversion is the canonical form:
- internal/library/scanrun.go:
- LibraryStageTallies copy from Stats → LibraryStageTallies(lastStats)
- MBIDBackfillStageTallies copy from BackfillMBIDsResult →
MBIDBackfillStageTallies(lastRes)
- internal/recommendation/home.go:
- dbq.ListRediscoverAlbumsForUserRow copy from the Fallback row
type → dbq.ListRediscoverAlbumsForUserRow(r)
- Same pattern for the Artists rediscover pair.
No behavior change; the underlying struct shapes are identical
(staticcheck verified the conversion is valid). Net -18 +4 lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Operator decision: the enricher is canonical. No MBID still runs the
provider chain (name-based providers — Deezer/Last.fm — resolve
without an MBID); if every provider returns ErrNotFound the row
settles cover/artist source 'none' at the current sources version
(re-eligible only when the registered provider set changes). It does
NOT skip-and-leave-NULL.
The two _NoMBID_LeavesNull tests predated the name-based providers
(0020 slice) and asserted the old skip→NULL contract. Updated:
- TestEnrichArtist_NoMBID_SettlesNone: stub now returns ErrNotFound
(realistic MBID-only-provider-with-empty-MBID), expect source 'none'.
- TestEnrichAlbum_NoSidecarNoMBID_SettlesNone: empty registry →
allWere404 stays true → expect 'none'.
Last failing cluster from the CI-integration initiative; suite should
now be fully green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The test marked id2 'none' via SetAlbumCover, which (covers.sql:42)
does NOT set cover_art_sources_version. ListAlbumsMissingCover treats
'none' AND version != current as eligible, so id2 (version 0, current
1) was wrongly drained → processed=2. The comment's intent ("'none'
with current version → not drained") requires SetAlbumCoverWithVersion
(albums.sql) stamped with the live GetCurrentSourcesVersion — the same
value EnrichBatch compares against. Test-only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two distinct pre-existing test bugs in the last failing cluster:
1. newTestEnricher() called resetRegistryForTests() itself, wiping the
fake providers callers Register() right before calling it (its own
doc says callers register first and reconcile() picks them up). The
~8 TestEnrichArtist_* failures (source stayed NULL, no thumb
written) all stem from reconcile() seeing an empty registry. Remove
the internal reset; make every caller that lacked one own the
registry lifecycle explicitly (resetRegistryForTests + t.Cleanup):
SidecarFound, NoSidecarNoMBID, AlreadySidecar_NoOp,
DrainsNullSourceOnly.
2. apiTestAlbumProvider.FetchAlbumCover had a stale signature
(context, string) predating the AlbumRef refactor; it no longer
satisfied coverart.AlbumCoverProvider, so the p.(AlbumCoverProvider)
capability assertion failed and TestAdminListCoverSources got
supports=[]. Fix the param to coverart.AlbumRef.
Test-only. (A1/A2 were correct; they unmasked these. Any residual
album-enricher semantics failures will be root-caused from the next
clean run, not bundled speculatively.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A2 — migration 0030: the albums/artists art `*_source` CHECK was a
fixed provider-ID allowlist fighting the extensible coverart.Register
registry (per-provider migration churn at 0016/0018/0020; rejected
test stub providers → ~11 enricher tests failed). Relax to "NULL or
non-empty"; the registry is the source of truth. Same brittleness
class as the #433 discovery-mix CHECK.
D — lidarr Approve resolves metadata/quality profiles (JSON arrays)
before the add; the test stubs returned {"id":1} for every path, so
ListMetadataProfiles failed to unmarshal → 500/Approve errors. Make
the lidarrrequests + admin_requests stubs path-aware (arrays for
/metadataprofile, /qualityprofile).
E:
- auth: requireUser (prelude.go) emitted code "auth_required"; the
canonical code is "unauthenticated" (operator decision). Change the
code; drop the now-dead "auth_required" web error-copy key
("unauthenticated" already has copy).
- playlists_system_test wrapped the real auth.RequireUser middleware
but only injected withUser() context → 401. Like every other api
handler test, drop the middleware and rely on requireUser().
- admin_users dup-username test seeded "test-existing" (seedUser
prefixes) but POSTed "existing" → no collision; POST the prefixed
name.
- me_timezone test decoded a top-level {"code"} but the envelope is
{"error":{"code"}}; decode the nested shape.
- audit test asserted compact JSON; Postgres jsonb::text is spaced.
- put-lidarr-config tests predated the missing_defaults gate (correct
handler behavior); supply the required defaults in the bodies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A redo: the prior commit truncated cover_art_sources_meta, but
SettingsService.reconcile() only READS that singleton (seeded once by
migration 0018) and never recreates it → ~45 coverart/api tests hard-
failed "get current sources version: no rows". Correct reset: keep
cover_art_provider_settings in the truncate set (boot idempotently
re-UpsertProviderSettings), drop cover_art_sources_meta from it, and
instead `UPDATE cover_art_sources_meta SET current_version = 1` so the
row survives while cross-test version accumulation is cleared.
B residual (exposed once the play_events FK fix let these run):
- seedQuarantine used reason 'test-hide', invalid for the
lidarr_quarantine_reason enum (bad_rip/wrong_file/wrong_tags/
duplicate/other) → use 'other'.
- TestBuildSystemPlaylists_SufficientActivity predated #411/#352: the
variant switch errored on the 5 new seedless mixes and capped
track_count at 25. Accept deep_cuts/rediscover/new_for_you/
on_this_day/first_listens as seedless; raise the cap to 100.
Test/test-harness only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Latent failures exposed now that integration tests run in CI (#339).
All test/test-harness only — no production code changes.
A (dbtest.ResetDB): also truncate cover_art_provider_settings +
cover_art_sources_meta. The monotonic source-version counter (seeded 1
by 0018) accumulated across internal/coverart tests → CurrentVersion=4
want 1, key-only-bump assertions, enricher source skips. SettingsService
re-seeds both at boot, so a truncated start is the correct fresh state.
B (playlists system_test.seedPlayEvent): inserted play_events with a
random session_id → play_events_session_id_fkey violation (7 tests).
Create the parent play_sessions row in the same statement (CTE).
C (similarity worker_integration_test.newTestWorker): built the client
with only BaseURL. Post-4fca0e6 similarity hits the Labs API via
LabsBaseURL, so an unset LabsBaseURL fell through to the real labs.api
and the stub never ran → 0 similarity rows. Set LabsBaseURL to the stub.
F (library scanner_test): NOT a flake — deterministic. Synthetic
ID3-only MP3s have no decodable duration; the scanner intentionally
won't skip duration_ms=0 rows (retries duration backfill), so every
re-scan reported Updated not Skipped. Seed duration_ms>0 before the
second scan so the mtime-based incremental-skip path under test is
actually exercised. Production scanner behavior unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Out-of-library suggestion artists (artist_similarity_unmatched rows)
have no local art row, so the Discover card always showed a
placeholder. Resolve art on-demand from Lidarr's artist lookup,
matched by MBID (foreignArtistId == candidate_mbid), and pass the
remote image URL straight through — no caching (a suggestion may
never be viewed; the browser fetches the URL directly).
- suggestionView gains image_url (omitempty); handler resolves it via
bounded-concurrency Lidarr LookupArtist, best-effort, never fails
the request.
- Lidarr is the sole source: disabled / unreachable / no-match →
empty → existing placeholder. (No inline TheAudioDB fallback — it's
externally rate-limited and there's no cache to amortize it.)
- web: ArtistSuggestion.image_url?; SuggestionFeed passes it to
DiscoverResultCard (already supports imageUrl).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follows 005965d (#388), which removed the coverArtBackfillCap param
from server.New. server_test.go is in package server so it calls New()
unqualified — missed by the signature-caller grep. Updated all 6
positional calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Operator feedback (2026-05-09): the 500-album cap meant cover art rolled
in over many nightly runs even when local sources (sidecar/embedded)
could finish in minutes. Remove the global cap; rely on the existing
per-provider HTTP throttle (coverart httpClient MinInterval) so local
art is disk-speed and remote providers stay TOS-friendly.
- enricher.go / artist_enricher.go: EnrichBatch, EnrichArtistBatch,
EnrichRetryMissing now treat limit<0 as unbounded (0 still = stage
disabled). The cap was a SQL LIMIT; unbounded uses max int32.
- main.go: RunScanConfig EnrichCap/ArtistEnrichCap = -1 (unbounded).
- Drop LibraryConfig.CoverArtBackfillCap + the
MINSTREL_LIBRARY_COVERART_BACKFILL_CAP env var.
- Drop the now-dead coverBackfillCap param threaded through
server.New + api.Mount + the handlers struct.
- Admin bulk refetch (/api/admin/covers/refetch-missing) now drains
unbounded; response {queued:int} → {started:bool} (the count is
unknowable synchronously for a fire-and-forget drain). Web copy +
client type + Go/web tests updated to match.
No doc refs existed (config.example.yaml / docker-compose / README
never documented the env var).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of zero LB recommendations (every similar-recordings AND
similar-artists call returned HTTP 404, worker logs):
- Wrong host/path: client called
api.listenbrainz.org/1/explore/similar-{recordings,artists}/{mbid}.
/explore/... is a WEBSITE route, not an API endpoint — it 308s then
404s. Similarity datasets live on the separate Labs API.
- Invalid algorithm: the hardcoded
session_…_session_30_…_limit_100_filter_True_… is not a permitted
Labs enum member (400s) regardless of host.
Verified against the live Labs API:
GET labs.api.listenbrainz.org/similar-recordings/json
?recording_mbids=<mbid>&algorithm=<algo>
GET labs.api.listenbrainz.org/similar-artists/json
?artist_mbids=<mbid>&algorithm=<algo>
algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30
→ 200 for both. Response field names (recording_mbid/artist_mbid/
name/score) already match the existing structs — parsing unchanged.
- Add defaultLabsBaseURL + Client.LabsBaseURL (separate from the main
BaseURL; scrobble submission still uses api.listenbrainz.org).
- Drop the count/limit query param — result size is encoded in the
algorithm name (limit_50); caller still applies its own top-K.
- Tests: newTestClient sets LabsBaseURL; the two *_LimitParamSet tests
become *_MbidParamSet (assert the Labs path + mbid query param).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follows ca1bc5a, which raised the worker batch default. The defaults
test pinned the old value; align it with the intended new default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- similarity.Worker batch 5→25 (tracks AND artists per 1h tick). At
5/h a freshly-scrobbled library took days to build a usable
similarity pool; 25/h converges in hours, still well under
ListenBrainz rate limits (429 aborts the tick).
- scanrun Stage 2b track backfill now runs unbounded (-1) instead of
reusing the album backfill's 5000 staged cap. It's a one-time
whole-library heal with no progress UI; a cap just left tracks.mbid
partially NULL (3634/18056 after one pass) until several future
scans caught up, re-reading untagged files each time. One uncapped
pass converges; later scans only re-read the remaining NULL rows.
Album backfill keeps its 5000 cap (it has staged scan_runs UX).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
HOTFIX for v2026.05.15.0. R3 added deep_cuts/rediscover/new_for_you/
on_this_day/first_listens producers + registry entries but not their
system_variant values to the playlists_kind_variant_consistent /
playlists_seed_consistent CHECK constraints (0021's whitelist).
insertSystemPlaylist for any new mix → SQLSTATE 23514, and since
BuildSystemPlaylists is one all-or-nothing txn that aborts the
ENTIRE build — For-You/Discover refresh 500s and the daily lazy
build fails too. System playlists are fully broken in prod.
Migration 0028 drops + re-adds both constraints with all five new
seedless variants (mirrors the 0021 drop-and-readd pattern).
CHECK-only — sqlc/dbq unaffected (regen produced no drift).
Bumps to 2026.5.15+7 for the v2026.05.15.1 patch release.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
The commented Refreshable field broke gofmt's struct-tag column
alignment in playlistRowView. Pure formatting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
go vet broke because playlists_{discover,foryou}_refresh_test.go
referenced the handlers/types deleted in R2 (d67c0de). Consolidated
into playlists_system_test.go covering the generic
/playlists/system/{kind}/{refresh} endpoint: for_you + discover
200/shape, non-singleton & unknown kind → 404, no-auth → 401.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the per-kind refresh/shuffle handlers with one generic
pair driven off the kind registry, in lockstep across both clients.
Server:
- systemPlaylistKind gains Singleton; RefreshableSystemKind(key)
exported. for_you/discover singleton; songs_like_artist not.
- New generic POST /api/playlists/system/{kind}/refresh and
GET /api/playlists/system/{kind}/shuffle ({kind} = raw
system_variant). Non-singleton/unknown kind → 404. Deleted
playlists_{foryou,discover}_refresh.go and the per-kind shuffle
wrappers; serveSystemPlaylistShuffle core kept.
- playlistRowView.refreshable: server-derived flag so clients show
the refresh affordance generically without hardcoding kinds.
Web (not drift-cached → uses the server flag):
- refreshSystem(variant) replaces refreshForYou/refreshDiscover;
systemShuffle drops the for_you→for-you mapping (raw variant).
- PlaylistCard + detail page gate the kebab/Refresh button on
playlist.refreshable; label is "Refresh {name}". Tests reworked;
obsolete refresh-foryou/discover api tests deleted.
Flutter (list tiles are drift-cache-sourced → derive, no migration):
- Playlist.refreshable getter = isSystem && variant !=
songs_like_artist (holds for all current + planned kinds).
- refreshSystem/systemShuffle use the raw variant; PlaylistCard +
detail screen gate kebab/Regenerate/source-tagging on refreshable
so songs_like_artist plays via get() (no by-kind endpoint).
Pure-plumbing refactor; CI verifies parity. Next (R3): the five
discovery mixes — each a candidate query + one registry entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
revive unused-parameter: produceDiscover keys off dateStr, not now,
but must keep the uniform systemPlaylistProducer signature. Blank
the unused param (param names don't affect func-type identity).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Behavior-preserving prep for the new mix types. Extracts the three
inline candidate computations in BuildSystemPlaylists into
producers (produceForYou / produceSeedMixes / produceDiscover) and
drives the build off a systemPlaylistRegistry. The shared
machinery (run-claim guard, atomic delete+insert tx, post-commit
collages) is now generic over a []builtPlaylist.
Fatal-vs-skip error semantics unchanged: a base query failure
(PickTopPlayedTracksForUser, PickSeedArtists) still aborts the
whole build; candidate-load / per-seed-artist / Discover-bucket
failures are still logged and just yield fewer playlists.
Materialize order (for_you, songs_like_artist, discover) is
unchanged and functionally irrelevant.
No API/client/schema change — CI's system/foryou/service tests
verify For You / Songs-like-X / Discover parity. Adding a new mix
is now: a producer + one registry entry + its candidate query.
Next (R2): generic /api/playlists/system/{kind}/{refresh,shuffle}
off the registry; then the new kinds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server half of the offline-replay capture. New writer path
RecordOfflinePlay: writes a complete start+end play in one txn from
a caller-supplied `at` + duration_played_ms, applying the spec §6
skip rule (same AND-of-thresholds as RecordPlayEnded) and threading
`source` so #415 rotation advances for system-playlist plays just
like the live path. Generalizes RecordSyntheticCompletedPlay (which
hard-codes full completion). duration clamped to [0, track len].
New /api/events type "play_offline" → handleEventPlayOffline:
validates track + duration, reuses the existing req.At parse so the
play lands on the original timeline, not replay time. Subsonic
shim + live 3-call lifecycle untouched.
Flutter half next: EventsApi.playOffline, a play.offline
MutationQueue kind, and PlayEventsReporter capturing the completed
play + enqueuing it when the live calls have no server id / fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /api/playlists/system/{discover,for-you}/shuffle returns the
caller's system playlist with tracks ordered: unplayed-this-rotation
first (shuffled), then already-heard (shuffled). When the whole
snapshot has been heard, ResetRotationState fires and the full list
reshuffles fresh.
Option A (operator's choice): a separate, intentionally-uncached
endpoint. The cached GET /api/playlists/{id} detail path stays pure
for "open to view"; this varies per play. Same JSON shape as the
detail GET so Stage 3 clients reuse track parsing with no new model.
Two explicit static routes per variant mirror the refresh handlers
and avoid chi static-vs-param ambiguity under /playlists/system/.
Empty/absent snapshot → 200 with empty track list (nothing to play,
not an error). Rotation reset failure is non-fatal — still returns a
playable reshuffled list.
No client wiring yet — Stage 3 makes web + Flutter call this on the
play/tile gesture and send `source` on play_started. Handler-level
test deferred to Stage 3 (needs the full service+pool harness; the
end-to-end path is exercised there).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Closes Fable #414. forYouHeadN/forYouTailN go 12/13 → 50/50 so the
For-You snapshot is 100 tracks, matching Discover. Motivated by the
shuffle-on-play default that just shipped (#413): a 25-track shuffle
pool repeats fast; 100 makes re-plays within a day feel varied.
pickHeadAndTail already degrades gracefully when the candidate pool
is too thin for a full head/tail split (returns top-N-by-score),
mirroring how Discover returns <100 when its buckets are thin — no
new edge-case handling needed. No build-path test asserts the
For-You total; pickHeadAndTail unit tests pass their own head/tail
values so they're unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Discover playlists could surface the same track twice with the
duplicates landing back-to-back — a "first song plays, then plays
again, skip works" symptom user reported on v2026.05.13.0. Root
cause: interleaveBuckets rotates one track per pass per bucket but
never tracks which IDs it has already emitted, so a track that's
both a dormant-artist pick AND a random-unheard pick comes out
once from each bucket.
On a single-user server the crossUser bucket is empty, so the
redistribute step rolls its slots into dormant + random. Their
output then interleaves d0, r0, d1, r1, … — and when d0 == r0
(common: a dormant-artist track is also valid for random-unheard)
the result is [X, X, …] with adjacent duplicates.
Fix: track seen track IDs across all buckets while interleaving;
skip already-taken IDs and advance to the next index in that
bucket. Dedup priority is bucket order, so a track in both
dormant and random comes from dormant.
Regression test covers the single-user case directly. The existing
round-robin test still passes — no shared IDs in that fixture.
Note: stale duplicates already written to drift / served as cached
playlists will clear naturally on the next playlist rebuild (the
03:00-local refresh, or any manual /api/me/playlists/refresh).
Sibling to /api/home that returns the same five sections (recently
added, rediscover albums, rediscover artists, most played, last
played) but as flat slices of entity ID strings instead of
denormalized objects. The Flutter client uses this to drive its
per-item rendering pass — small discovery response then per-tile
hydration via the existing /api/albums/:id, /api/artists/:id,
/api/tracks/:id endpoints.
Reuses recommendation.HomeData so the DB cost is identical to
/api/home. JSON payload shrinks roughly an order of magnitude on
populated libraries (no embedded title / artist / cover URL fields).
Old /api/home stays untouched so the web client and older Flutter
builds keep working — no min-client-version bump needed until both
clients have migrated.
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>
#392 shipped track.liked / track.unliked but skipped the album +
artist symmetric pairs. Closes that gap so the Flutter Liked tab's
albums and artists sub-lists can listen for changes the same way
the tracks sub-list will (#402 wire-up lands next commit).
publishLikeEvent already handles the entity_type dispatch; the four
handler call sites just need the new lines.
For #402 follow-up to #392.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gocron v2's WithLocation is a SchedulerOption (process-wide), not a
JobOption — there's no per-job location knob. To get per-user
timezones we use a cron expression with the CRON_TZ= prefix, which
the underlying robfig/cron parser honors:
CRON_TZ=America/New_York 0 3 * * *
Fires at 03:00 in the named zone every day. Same DST-correctness as
the original WithLocation approach.
Fall-back to UTC moves inline (was validateTimezoneOrUTC); kept the
helper because the test file still exercises it.
Caught by go vet on CI after slice 2 pushed:
"cannot use gocron.WithLocation(loc) (value of type
gocron.SchedulerOption) as gocron.JobOption value"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the server side of #392 Half B.
PUT /api/me/timezone now calls scheduler.Refresh(ctx, userID) after
the DB write so the rescheduled daily-at-03:00-local job takes
effect synchronously. Failure to refresh is logged but doesn't
undo the DB write — the hourly reconciliation pass would pick it
up within an hour regardless.
POST /api/auth/register calls Refresh after successful user
insert so brand-new users get scheduled immediately rather than
waiting for the hourly pass to discover them.
system_cron.go deleted: the new scheduler subsumes its
responsibilities. The StartSystemPlaylistCron call in main.go is
also removed. Server restart now runs the new scheduler's startup
recovery + catch-up instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds the per-user daily-at-03:00-local scheduler for #392 Half B.
Uses github.com/go-co-op/gocron/v2 with WithLocation(userTZ) for
each user's job. Hourly reconciliation pass keeps the in-memory
job set in sync with the active-users query.
Start sequence: clear stale in_flight rows; register a daily job
for every active user at their stored timezone; fire a one-shot
runOnce to catch up missed schedules during downtime; start gocron.
Stop drains and shuts down the gocron loop.
Refresh(ctx, userID) removes the user's existing job (if any) and
registers a fresh one at their current timezone — wired into the
PUT /api/me/timezone handler and new-user registration in the next
commit.
Server struct gains PlaylistScheduler; main.go constructs it after
the eventbus and threads it through to api.Mount. The existing
StartSystemPlaylistCron call stays for one more commit so we don't
have a window where no scheduler is running; Task 3 deletes it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Five diversity mechanics — applied to both For-You and Songs-Like-X.
1. For-You seed rotates daily across the user's top-5 most-played
tracks. pickForYouSeedForDay uses userIDHash(user, day) mod
len(seeds) so today's mix uses an entirely different similarity
pool than tomorrow's. Within-day determinism preserved.
2. JitterMagnitude bumped 0.0 → 0.1. The scoring RNG is now seeded
by userIDHash(user, day) rather than the no-op, so near-tied
candidates shuffle daily without breaking within-day stability.
3. Head/tail split moves from 20+5 to 12+13. Roughly half the
playlist comes from the tail now (daily-deterministic via
tieBreakHash), giving the user substantially different content
while a 12-track anchor of strong similarity matches keeps the
mix recognizable.
4. Songs-Like-X seed artists shuffle daily across the user's top-5
played artists. pickSeedArtistsForDay applies a userIDHash-seeded
Fisher-Yates and takes 3.
5. scoreAndSortCandidates / pickTopN / pickHeadAndTail gain a userID
parameter so the RNG can be seeded per-user; existing call sites
updated; noopRNG removed.
Test fixtures widened similarity gaps (e.g. float64(50-i) instead of
(50-i)/50) so the new jitter (±0.1) doesn't perturb head ordering in
assertions about the head/tail mechanism. New seed_selection_test
coverage for userIDHash + pickForYouSeedForDay + pickSeedArtistsForDay
spans deterministic-within-day, varies-across-days, and graceful
degradation with small candidate pools.
PickTopPlayedTrackForUser replaced by PickTopPlayedTracksForUser
:many in the prior commit (b4801c2). The For-You seed lookup now
goes through pickForYouSeedForDay over the returned slice.
PickSeedArtists's LIMIT widened to 5 in the same prior commit.
For #392 Half A — system playlist content diversity. Half B
(per-user timezone scheduling) is a separate spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The hand-unrolled byte-pair version in reconciler.go tripped gofmt -s
and goimports. Replaced with the same loop-based formatter that
internal/library/eventbus.go uses — same behaviour, fewer lines, lint
clean. Two copies of the helper still exist (one per package) to keep
the no-back-edge property for both internal/library and
internal/lidarrrequests, but they're now identical and the duplication
is tiny.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 3c — completes the server-side producer set.
Publishes scan.run_started when InsertScanRun succeeds and
scan.run_finished after FinishScanRun completes (success or with an
error_message payload). Per-file progress events are intentionally NOT
emitted — they'd flood the stream during large library scans without
giving the admin scan card anything actionable. The two-beat signal
gives the UI enough to invalidate scanStatusProvider at the right
moments.
Bus access uses a package-level setter on internal/library because
threading bus through RunScan + TryStartScan + Scheduler + all their
callers would touch ~10 sites without changing behavior at the boundaries
that don't publish. Per-process singleton, matches the log.SetDefault
idiom. cmd/minstrel/main.go calls library.SetEventBus(bus) once at
startup; test contexts that never call it skip publishing safely
(publishScanEvent is a no-op when bus is nil).
This completes the server side of #392. Slice 4 wires the Flutter
consumer: live_events_provider.dart (StreamProvider) +
live_events_dispatcher.dart (event-kind → provider invalidation)
+ AppLifecycleState cold-start invalidation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 3b — extends event publishing to background workers.
When the reconciler matches an approved Lidarr request against the
library and flips it to 'completed', it now publishes a
request.status_changed event scoped to the original requester so their
/requests page invalidates without polling. Three call sites — one per
kind (artist / album / track) — capture the completed row instead of
discarding it so the publish has the user_id.
Bus plumbing: the reconciler runs in cmd/minstrel/main.go which
constructs services before server.New is called. Moved bus
construction into main; the Server struct gained a Bus field that
Router() reads, falling back to a fresh local instance when nil
(test contexts). Result: one bus per process shared by every
publisher and the SSE subscriber endpoint.
reconciler.go inlines a uuid->string helper rather than reaching into
internal/api for one — avoids a back-edge dependency.
Test compat: 9 NewReconciler call sites in reconciler_integration_test.go
get `nil` for the new bus param. The reconciler's publishCompleted helper
is a no-op when the bus is nil so tests that don't care about events
keep passing.
Scanner producer (scan.progress) deferred to slice 3c — needs more
thought about which lifecycle transitions warrant events vs. flood the
stream.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 3a — extends the producer set onto the SSE bus.
quarantine events (5 producer sites):
- quarantine.flagged (user-side flag): broadcast so the flagger's other
clients invalidate their Hidden tab and admins' clients invalidate
their queue.
- quarantine.unflagged (user-side unflag): scoped to the user.
- quarantine.resolved / .file_deleted / .deleted_via_lidarr (admin
actions): broadcast because a single admin action can affect every
user who'd flagged that track.
playlist events (6 producer sites):
- playlist.created / .updated / .deleted: owner-scoped.
- playlist.tracks_changed: emitted for AppendTracks / RemoveTrack /
Reorder. Owner-scoped. Single kind for all three mutations because
the client invalidation logic is identical (refetch the detail).
Public-playlist subscribers (other users viewing someone else's public
playlist) intentionally left out — needs a separate broadcast kind, not
exercised yet at single-household scale.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 2 of #392 — wires the first producers onto the bus that slice 1
built. After this commit, an SSE subscriber sees real events fire:
- track.liked / track.unliked when the user toggles the heart on a track
(handleLikeTrack / handleUnlikeTrack). Album + artist like events
intentionally deferred — they're symmetric trivial follow-ups but the
operator's primary like surface is tracks.
- request.status_changed when a Lidarr request is created, cancelled,
approved, or rejected. Auto-approve will fire twice (pending then
approved) in rapid succession, which is semantically correct; client
invalidation handles that fine.
Events are user-scoped via row.UserID so admin approve/reject route to
the requester, not the admin acting. Helpers live in events_publish.go
so the wire shape (kind names, payload keys) stays in one place — future
producers in slice 3 reuse the same pattern.
events_publish.go is no-op when h.eventbus is nil so tests that
construct handlers without a bus continue to pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
go vet caught a missed test caller of api.Mount after slice 1's signature
change added the *eventbus.Bus parameter. Pass eventbus.New() in the test
— the TestRoutesRegisteredInMount test only walks the route table for
existence, never publishes or subscribes, so a throwaway bus is fine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 1 of the #392 hybrid live-refresh work. Ships the in-process pub/sub
bus and the SSE subscriber endpoint; no producers wired yet, so the stream
emits only heartbeats today. Verifiable in isolation by curl-ing the
endpoint with a valid Bearer token — the connection opens, ": heartbeat"
lines arrive every 15s, the connection closes cleanly on client disconnect.
eventbus.Bus is a small fan-out broadcaster: subscribers register through
Subscribe (returns a receive channel + an unsubscribe closure), writers
call Publish, and the bus drops events for any subscriber whose buffer is
full rather than blocking the writer. No persistence — clients are
expected to resync via normal /api/* fetches on (re)connect.
The SSE handler emits an initial ": connected" comment so the client sees
the connection open immediately, then forwards events whose UserID matches
the authenticated user (or is empty for broadcast). Heartbeat comments
keep proxy connections alive. Context cancellation cleanly tears down the
subscription on client disconnect.
Producers (likes, request status, quarantine, scanner, playlist mutations)
land in subsequent slices.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Two unrelated wins as a single batch.
Flutter — lazy source building in setQueueFromTracks:
Today: Future.wait builds all N AudioSource objects (drift queries +
LockCaching ctor) before the player can call setAudioSources →
play(). Measured at 83ms for a 25-track playlist, on top of the
~285ms initial-source preload.
New flow: build only the initial source, hand it to setAudioSources
([initial], initialIndex: 0) so play() can start, then background-
fill the rest. Forward direction (skipNext targets) added via
addAudioSource. Backward direction (skipPrev) inserted at index 0..
initialIndex-1 with _suppressIndexUpdates true so the unavoidable
currentIndex shifts don't push the wrong MediaItem onto the stream.
Saves the up-front source-build wait — tap-to-audio for long queues
should drop by ~80-100ms even on cache hits.
Server — Cache-Control on the three byte-serving endpoints:
- /api/albums/{id}/cover: max-age=86400, must-revalidate. Covers
change rarely (re-scan, MBID enrichment); a day of cache is safe
and skips conditional GETs for the bulk of a session.
- /api/playlists/{id}/cover: max-age=300, must-revalidate. Collages
recompute when contents change; short enough for edits to feel
fresh, long enough to skip repeat fetches during a session.
- /api/tracks/{id}/stream: max-age=31536000, immutable. Track bytes
are immutable for a given id (scanner re-indexes by file_path; new
files get new ids). LockCachingAudioSource on the Flutter side
already disk-caches, but proper headers let it skip even the
conditional 304 on repeat plays.
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.