Compare commits

..

45 Commits

Author SHA1 Message Date
bvandeusen d9b2dd957c Merge pull request 'fix(android): interceptor order — auth before baseUrl (hotfix for v2026.06.02)' (#76) from dev into main
android / Build + lint + test (push) Successful in 5m36s
release / Build signed APK (tag releases only) (push) Successful in 4m28s
release / Build + push container image (push) Successful in 2m1s
2026-06-02 22:07:32 -04:00
bvandeusen 94b3b87785 fix(android): interceptor order — auth before baseUrl
android / Build + lint + test (push) Successful in 4m24s
Drift #568/#569 scoped AuthCookieInterceptor to PLACEHOLDER_HOST so
the shared OkHttp client wouldn't leak the session cookie to external
image fetches (Coil → musicbrainz, coverartarchive, Lidarr). The fix
was correct but assumed AuthCookieInterceptor would see the original
placeholder.invalid URL — production NetworkModule had BaseUrlInterceptor
running FIRST, so by the time auth's intercept() ran the host was
already rewritten to the real Minstrel server and the placeholder
check failed on every request.

Symptom on v2026.06.02: fresh install login appears to succeed but
no cookie is captured from Set-Cookie and no cookie is attached to
subsequent requests, so the user stays at the Welcome screen.

AuthCookieInterceptorTest already chains the interceptors in the
correct order, which is why the regression went undetected — only
production was wrong.

Fix: swap to (auth, baseUrl, logging). Auth now sees
placeholder.invalid, attaches/captures the cookie, then BaseUrl
rewrites the host for transport.
2026-06-02 21:47:53 -04:00
bvandeusen 46dcd38fd8 Merge pull request 'Drift audit 2026-06-02 — 26 findings shipped' (#75) from dev into main
test-go / test (push) Successful in 43s
test-web / test (push) Successful in 56s
android / Build + lint + test (push) Successful in 5m47s
release / Build signed APK (tag releases only) (push) Successful in 5m10s
release / Build + push container image (push) Successful in 24s
test-go / integration (push) Failing after 12m37s
2026-06-02 19:21:53 -04:00
bvandeusen cb2f9a2ea2 fix(server): GC test seeds tracks with file_size + file_format
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 11m13s
Second go-round of the same shape of bug: tracks has file_size +
file_format NOT NULL (0002_core_library.up.sql) and my GC test seed
omitted both. The previous fix only addressed the artists.sort_name
column; the tracks INSERT was missing two more.

Use plausible stub values — the GC sweep only joins on track_id,
none of these columns affect what the test exercises.
2026-06-02 19:04:00 -04:00
bvandeusen 305d4780ac fix(server): TestGcCloseStalePlayEvents seeds artist with sort_name
test-go / test (push) Successful in 36s
test-go / integration (push) Failing after 14m34s
The artists table requires sort_name (NOT NULL constraint added by
0009_artist_sort.up.sql). My GC integration test was inserting only
name + relying on a separate SELECT to pull the id back, which both
(a) violated the NOT NULL constraint and (b) was unnecessarily
indirect. RETURNING the id directly is the standard pattern used
everywhere else in the test suite.

Test now matches the real-world insert pattern in api.search +
library scan (sort_name mirrors name when no MBID-driven sort hint
is available). Other GC tests in this file don't touch artists so
they were already fine.
2026-06-02 18:47:31 -04:00
bvandeusen dbcadf0f93 fix(android): drift #576 — LikesRepository uses real userId, clears on switch
android / Build + lint + test (push) Successful in 4m1s
Final drift audit finding (Scribe parent #552). LikesRepository
hardcoded LOCAL_USER_ID = "local" as the cached_likes discriminator
since before the auth slice landed. After auth shipped, the app
has a real per-user session but every device wrote rows under the
same "local" bucket — so sharing an Android device between two
Minstrel accounts left the previous user's likes visible to the
new user.

Changes:
- Inject AuthController + ApplicationScope so the repo can read
  the current user UUID and subscribe to user-switch events.
- `currentUserId()` resolves the cached_likes discriminator to
  `authController.currentUser.value?.id` with the legacy "local"
  fallback (ANONYMOUS_USER_ID, renamed from LOCAL_USER_ID) so
  pre-#576 cache rows from existing installs stay queryable until
  the first authenticated refreshIds() overwrites them.
- All eight call sites that used the constant now use the helper:
  observeLikedArtists/Albums/Tracks, observeIsLiked, likedTrackIds,
  toggleLike (optimistic upsert + delete), refreshIds (server
  replace).
- init {} subscribes to authController.currentUser; when the
  signed-in id changes, the OUTGOING user's rows get
  likeDao.clearForUser. Mostly a hygiene fix — the discriminator
  already prevents the wrong user from SEEING leaked rows, but
  without this they pile up forever as different accounts
  sign in/out on the same device.

This closes the final drift audit finding from the 2026-06-02 run.
26 of 26 candidate findings either confirmed-and-shipped (24) or
cancelled-as-duplicate (1) or shipped-with-honest-doc-fix (1).
2026-06-02 18:35:54 -04:00
bvandeusen 258bc1f75c feat(server): drift audit batch 7 — periodic GC worker for 5 lifecycle gaps
test-go / test (push) Successful in 29s
test-go / integration (push) Failing after 11m57s
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.
2026-06-02 18:32:22 -04:00
bvandeusen bda0896d82 docs(server): drift #572 — delete.go honest about missing reconcile
test-go / test (push) Successful in 28s
test-go / integration (push) Has been cancelled
The docstring claimed "the next library scan reconciles missing
files by removing their tracks rows" — but scanner.go only does
filepath.WalkDir + UpsertTrack; it never enumerates existing rows
to check file_path presence, and it never DELETEs orphan rows. The
audit verified this — repo-wide grep finds no orphan-sweep code.

The lie is load-bearing: lidarrquarantine/service.go:270 leans on
this guarantee, so downstream code thinks the orphan case heals
itself. Fix the comment to state reality (admin re-trigger or
manual cleanup) and reference the open follow-up for adding a real
sweep. The actual reconcile pass is a separate piece of work
(needs scanrun integration + retention semantics + tests) and
stays in the Scribe audit queue.
2026-06-02 18:26:45 -04:00
bvandeusen 413d729711 fix(web): drift audit batch 5 — SSR redirect + radio exclude cap
test-web / test (push) Successful in 33s
Two web-side findings from the 2026-06-02 drift audit (#552):

- **#559** /library and /playlists each had a +page.server.ts
  file calling redirect(308, ...). The app is configured as
  adapter-static + ssr=false (+layout.ts:5), so +page.server.ts
  files only run at build time / dev server — NEVER at runtime in
  the deployed build. Direct navigation to /library or /playlists
  (mobile bookmarks, hand-typed URLs) hit a blank page or 404. We
  worked around this earlier today by linking the nav directly to
  /library/artists, but bookmarks stayed broken. Converted both
  files to +page.ts (universal load) — same redirect logic, runs
  client-side in the SPA, which is what actually executes.

- **#554** Radio auto-refresh built its exclude= query parameter
  from the ENTIRE queue, growing unbounded each refresh as new
  tracks were appended. UUIDs are ~36 chars + comma; with the
  common 8KB query-string limit, ~220 tracks is the ceiling. A
  multi-hour radio session eventually 414'd; the .catch() ate the
  error and the player silently stopped topping up — dead radio
  with no user-visible signal. Cap exclude to the most recent 100
  ids; the server's RecentlyPlayedHours filter already handles
  broader history dedup so the request-side cap only needs to
  cover the visible queue's recent tail.
2026-06-02 18:25:52 -04:00
bvandeusen b970b87343 fix(server): drift #578 — /api/me returns profile shape with display_name + email
test-go / test (push) Successful in 32s
test-go / integration (push) Has been cancelled
Server-side fix for the drift audit finding (Scribe #578, parent
#552). Mirrored on Android (and Flutter) but the root cause and
the smallest blast-radius fix both live here.

The bug:
- Android MeApi.getProfile() calls GET /api/me and deserializes
  into MyProfileWire which has nullable display_name + email.
- Server's handleGetMe was emitting the narrower UserView shape
  (id, username, is_admin only).
- Android always saw displayName=null, email=null. The Settings →
  Profile screen rendered BLANK form fields for users with stored
  values.
- Saving from the blank state submitted empty strings to
  PUT /api/me/profile, which interprets empty as "clear to NULL"
  (me_profile.go:53-65) — DESTROYING the user's saved profile.
- Flutter (flutter_client/lib/api/endpoints/settings.dart:9-12)
  has the identical bug pattern.

The fix:
- handleGetMe now emits profileViewFromUser(user) — the same
  shape PUT /api/me/profile already returns (meProfileResp:
  id, username, display_name, email, is_admin).
- auth.UserFromContext already returns a full dbq.User row, so no
  extra DB lookup needed.
- Web's User TypeScript type is narrower than this response but
  doesn't care about the extra fields (TS structural typing).
- LoginResp.User still uses UserView; login response unchanged.

New test asserts the regression directly: a user with stored
display_name + email sees them in /api/me. Old test updated to
decode into meProfileResp and assert the nullable fields are
correctly null for an unset profile.

Android side needs no change — the existing wire shape already
expected display_name + email; this just delivers them.
2026-06-02 18:24:23 -04:00
bvandeusen 5014f7548e fix: drift audit batch 3b — cold-boot resume correctness
android / Build + lint + test (push) Successful in 3m49s
Two related findings from the 2026-06-02 drift audit (#552):

- **#560 (Android)** PlayerController.setQueue() unconditionally
  called controller.play() at the end, with no way for
  ResumeController to opt out. Cold-boot resume therefore restored
  the persisted queue AND auto-started playback, which surprised
  users who had paused mid-track before backgrounding the app.
  Add `autoplay: Boolean = true` parameter; ResumeController
  passes false. Every existing setQueue call site continues to
  autoplay (the default is unchanged).

- **#562 (Android)** ResumeController.restore() ran from
  MinstrelApplication.onCreate alongside PlayerController's own
  init {} block that asynchronously binds the MediaController to
  MinstrelPlayerService. On fast devices with slow IPC the
  restore could land before mediaController was non-null;
  PlayerController.setQueue early-returns on null mediaController,
  so the restored queue was silently dropped — the user would
  open the app to an empty player after explicitly using "resume
  previous queue". Add `awaitReady()` suspend that completes when
  the MediaController binding lands; ResumeController awaits it
  before calling setQueue.

The two fixes ship together because the autoplay opt-out only
matters once the await fix guarantees the queue actually reaches
the player.
2026-06-02 18:21:39 -04:00
bvandeusen b19c621743 fix: drift audit batch 3a — Android offline correctness
android / Build + lint + test (push) Has been cancelled
Two findings from the 2026-06-02 drift audit (Scribe parent #552):

- **#577 (Android)** RequestsViewModel.cancel() called refresh() on
  BOTH Synced and Queued outcomes. Synced is fine (re-fetch the
  canonical list); Queued is offline by definition — the optimistic
  removal at the top of cancel() is already correct, and refresh()
  on Queued either (a) gets the not-yet-delivered cancelled row
  back from the server and snaps it into the list (confusing), or
  (b) fails with a transport error and flips the screen to
  UiState.Error so the user thinks the cancel failed even though
  it's queued. Gate refresh() on outcome == Synced; the mutation
  replayer reconciles when connectivity returns.

- **#570 (Android)** LikesRepository.refreshIds() pulled the
  server's likes list and INSERTed it into cached_likes — but
  never DELETEd local rows the server no longer surfaces. A
  cross-device unlike (user likes on web, then unlikes on web)
  left the entry visible on Android's Liked tab indefinitely with
  no way to clear short of wiping app data. Add
  CachedLikeDao.clearForUser + a @Transaction replaceAllForUser
  that atomically wipes-then-inserts the user's set; refreshIds()
  uses replaceAllForUser so the local cache is exactly what the
  server reports. The LOCAL_USER_ID hardcode is its own drift
  (#576) and stays for now — fixing it needs threading
  AuthStore.userId through the repo.
2026-06-02 18:18:59 -04:00
bvandeusen fb3116d640 fix: drift audit batch 2 — patterned fixes mirroring prior work
test-go / test (push) Successful in 29s
android / Build + lint + test (push) Has been cancelled
test-go / integration (push) Has been cancelled
Five findings + one cancelled duplicate from the 2026-06-02 drift
audit (Scribe parent task #552):

- **#561 (Android)** PlayerController.playbackErrorEventsChannel was
  Channel.CONFLATED. The PlaybackErrorReporter coroutine reads it in
  a debounce loop that buffers events to coalesce into "Skipped N
  unplayable tracks" — but CONFLATED silently dropped every emission
  except the latest each time the reader wasn't actively pulling.
  A network blip that failed 5 tracks back-to-back surfaced only the
  last failure to the snackbar AND only POSTed one playback_errors
  row to the admin inbox. Switch to BUFFERED (default capacity 64,
  well above any plausible burst rate). Coalescing path now reaches
  N > 1 and the admin inbox sees every failure.

- **#563 (server)** systemPlaylistSources rotation whitelist in
  playevents/writer.go had drifted behind the migrations. It listed
  only for_you + discover; migrations 0021 + 0028 added 6 more
  variants (deep_cuts, rediscover, new_for_you, on_this_day,
  first_listens, songs_like_artist) that ship as refreshable system
  mixes. Plays from those surfaces never advanced the per-user
  rotation, so "unplayed first" ordering staled — the same tracks
  kept resurfacing. Add all 6 to the map; comment now points at the
  migration's CHECK list as the canonical source so future variants
  notice the requirement. #573 was the duplicate auditor hit for
  the same drift; cancelled in Scribe.

- **#564 (Android)** Android emitted source = "playlist:<variant>"
  for system-mix plays from Home and PlaylistDetail, but the
  server's rotation matcher keys on the BARE variant string (web
  sends the bare form — PlaylistCard.svelte:83). Misalignment meant
  system-mix plays from Android never advanced rotation; switching
  from web to Android effectively reset the perceived "unplayed
  next" ordering. Fix HomeScreen.kt:291 to send bare variant and
  PlaylistDetailScreen.kt's play() to prefer systemVariant over the
  playlist:<id> tag when the playlist is a refreshable system mix.
  User playlists keep playlist:<id> (intentional — rotation only
  applies to system mixes anyway).

- **#568 + #569 (Android)** AuthCookieInterceptor was unconditionally
  attaching the Minstrel session cookie to every outgoing request
  AND wiping the session on any 401. The shared OkHttpClient is also
  used by Coil for external image fetches (artwork.musicbrainz.org,
  coverartarchive.org, Lidarr /MediaCover URLs); this leaked the
  session cookie to those hosts (privacy posture) AND silently
  signed users out of Minstrel if any external image host returned
  401. Scope both attach + clear to the placeholder.invalid sentinel
  host the same way BaseUrlInterceptor was scoped in aec10ce7. Two
  new regression tests cover the external-host pass-through. Existing
  tests rewritten to make requests through the placeholder URL so
  they exercise the in-scope path explicitly.

All five Scribe tasks updated to in_progress at start, will flip to
done after CI green on this push.
2026-06-02 18:16:29 -04:00
bvandeusen 47d2f61161 fix: drift audit batch 1 — six small mechanical wins
test-go / test (push) Successful in 32s
test-web / test (push) Successful in 42s
test-go / integration (push) Has been cancelled
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.
2026-06-02 18:11:25 -04:00
bvandeusen 7838038047 Merge pull request 'Playback errors slice + scrubber polish + various polish' (#74) from dev into main
test-go / test (push) Successful in 39s
test-web / test (push) Successful in 51s
android / Build + lint + test (push) Successful in 4m45s
release / Build signed APK (tag releases only) (push) Successful in 5m12s
release / Build + push container image (push) Successful in 22s
test-go / integration (push) Successful in 12m7s
2026-06-02 14:14:11 -04:00
bvandeusen 9f0af9c24b feat(android): CI-injected versionName with commit-count iteration
android / Build + lint + test (push) Successful in 4m12s
The APK was shipping with a hardcoded versionName="0.1.0-native"
and versionCode=1 — so the About card never reflected the actual
release, and two same-day re-cuts of the per-day mutable tag
v2026.06.02 looked identical to the update-banner comparator.
Operator wants the iteration restored on the APK side (the docker
tag stays plain per the earlier intentional change).

Scheme:
- Per release: versionName = "${tag}.${commit_count}", e.g.
  "2026.06.02.142", where commit_count = `git rev-list --count HEAD`.
  Monotonic across the project lifetime, deterministic, no manual
  counter to maintain.
- versionCode = commit_count. Monotonic, fits in Int forever (we're
  not hitting 2.1B commits).
- Local / debug / dev builds fall back to versionName="dev" /
  versionCode=1 so the About card reads honestly.

build.gradle.kts:
- defaultConfig reads MINSTREL_VERSION_NAME / MINSTREL_VERSION_CODE
  Gradle properties via project.findProperty with the dev fallbacks.

.gitea/workflows/release.yml:
- android-release: checkout with fetch-depth: 0 (the default shallow
  clone would return 1 for `git rev-list --count HEAD`); new
  Compute release version step exports name + code as step outputs;
  assembleRelease passes them via -P; new job-level outputs propagate
  them to the downstream image-release job.
- image-release: Stage bundled APK + version sidecar pulls the
  computed version_name from needs.android-release.outputs and
  writes it into client/minstrel.apk.version, so the server's
  /api/client/version reports the exact string baked into the APK.
  Without this the sidecar would say "v2026.06.02" while the
  installed APK has "2026.06.02.142" — isVersionNewer would call
  the bundled APK older and the update banner would thrash.

The existing isVersionNewer comparator already handles the
4-component shape ("2026.06.02.142" > "2026.06.02.141"), so no
client-side logic changes are needed.
2026-06-02 14:07:57 -04:00
bvandeusen 76e64fd022 fix(android): close PlaybackErrorsApi kdoc — Kotlin nested-comment trap
android / Build + lint + test (push) Successful in 4m55s
The kdoc on PlaybackErrorReportRequest mentioned the existing
/api/plays/* endpoint. Kotlin's lexer treats /* inside a /** ... */
kdoc as a NESTED comment opener, which then swallows the outer
*/ — so the entire PlaybackErrorReportRequest data class
disappeared from the symbol table and the four call sites in
PlaybackErrorsApi.kt / MutationReplayer.kt / PlaybackErrorRepository.kt
all reported "Unresolved reference".

This is the trap recorded in the project's KSP-could-not-be-
resolved memory; mark it again. Fix is mechanical: rewrite the
prose as `/api/plays/...` so no /* sequence appears inside a
block comment.
2026-06-02 11:51:19 -04:00
bvandeusen 337fce83a1 fix(android): satisfy detekt — ReturnCount + LongMethod refactors
android / Build + lint + test (push) Failing after 3m20s
Three rule trips from the playback-errors + scrubber commits:

PlayerController.startRadio (4 returns → 2): extract the mid-queue
append branch into appendRadioToQueue(). startRadio just does the
guard checks and dispatches; the helper handles the cursor trim +
addMediaItems. Behavior identical.

PlayerController.onPlaybackStateChanged (4 returns → 2): extract
the duration check + zero-duration error emission + skip logic
into handleZeroDurationIfNeeded(). The listener stays compact (one
return for non-READY, one for repeat-evaluation guard); the helper
owns the failure path.

NowPlayingScreen.ScrubberRow (63 lines → ~50): extract the custom
track Box block into a ScrubTrack(fraction, accent) composable.
The Slider's `track` lambda becomes a one-line call. Pixel output
is identical.
2026-06-02 11:45:45 -04:00
bvandeusen de61305fde feat(web): admin playback-errors inbox
test-web / test (push) Successful in 34s
Surfaces client-reported playback failures from /api/admin/playback-errors
in a new admin tab. Tabs: Unresolved (default) / Resolved. Each row
shows track + artist + album, error kind badge, who hit it, when,
optional client-supplied detail, and the absolute file path so the
operator can grep the library mount without leaving the page.

Per-row actions (RowActionsMenu):
- Resolve (primary) — modal with Fixed / Ignored dropdown for the
  "no further action taken" cases.
- Copy — JSON payload to clipboard with track_id / file_path / kind
  / detail / reporter / client_id / occurred_at. Matches the
  operator's "logs with a copy-out function" ask.
- Delete file (danger, modal-confirm) — uses the existing
  /api/admin/quarantine/{track_id}/delete-file endpoint AND
  auto-stamps resolution='deleted' so a single click closes both
  the file and the inbox row.

Deferred to a follow-up: Hide (the existing quarantine flow is
per-user-flag, not a true library-hide), and Re-request via Lidarr
(needs album MBID join — not in the current ListAdminPlaybackErrors
projection).

Also: admin tab list grows from five to six; AdminTabs.test.ts
updated.
2026-06-02 11:34:46 -04:00
bvandeusen 99e1df4920 feat(android): detect zero-duration tracks, fail fast, report to server
android / Build + lint + test (push) Failing after 2m7s
Operator hit a track that loaded with zero duration; player just sat
on it. Two things needed: skip the dead track immediately, and tell
the server so the admin inbox can surface the bad file.

PlayerController:
- Player.Listener.onPlaybackStateChanged(STATE_READY) now checks
  duration. If it's <= 0 or C.TIME_UNSET, fires a PlaybackErrorEvent
  with kind="zero_duration" and calls seekToNextMediaItem (or stop
  if it was the last item). Per-item evaluation guard keeps repeat
  STATE_READY events (post-seek, post-resume) from re-firing.
- onPlayerError now also surfaces a PlaybackErrorEvent with
  kind="load_failed" + the Media3 exception message as detail.
- playbackErrorEvents flow changes from Flow<String> (title only) to
  Flow<PlaybackErrorEvent> (track_id + kind + title + detail) so
  downstream consumers can both surface a snackbar AND POST to the
  admin inbox without duplicating event emission.

PlaybackErrorRepository (new):
- Wraps POST /api/playback-errors with the offline-first MutationQueue
  fallback per the standing rule for server writes.
- Reuses AuthStore.clientId for the client_id field — same UUID-per-
  install identifier the play-events reporter sends, so support can
  correlate playback errors with surrounding plays.

PlaybackErrorReporter:
- Consumes the new richer event shape. Fires the server report per
  event (no debounce — the admin inbox should capture every report,
  not a coalesced summary). Continues to debounce the user-facing
  snackbar in the 2s window so a burst doesn't spam toasts.

MutationQueue / MutationReplayer:
- Adds PLAYBACK_ERROR_REPORT kind + PlaybackErrorReportPayload +
  enqueuePlaybackErrorReport entry point + replayer dispatch case
  hitting the new PlaybackErrorsApi.

Web admin inbox + UI is the next commit.
2026-06-02 11:30:35 -04:00
bvandeusen 15b59a214d feat(server): playback_errors table + admin inbox endpoints
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 11m25s
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.
2026-06-02 11:25:50 -04:00
bvandeusen 6a54d074fd revert(android): drop NowPlaying scrubber height clamp
android / Build + lint + test (push) Failing after 1m34s
Operator: the slider height clamp shifted the surrounding layout
above and below — not what they intended to change. Revert the
Modifier.height(20.dp) and remove the SCRUB_SLIDER_HEIGHT_DP
constant; the Slider goes back to its M3-default 48dp interactive
component height so adjacent rows sit where they did before.

The slim 4dp custom track stays — that's what addresses the
"puffy bar" feel — and the thumb still sits on it as a visible
14dp circle, with 17dp empty vertical space above and below.
That's the M3 standard layout the operator wants restored.
2026-06-02 10:28:27 -04:00
bvandeusen d9c7aae268 feat(android): slim NowPlaying scrubber — 4dp track + tight slider
android / Build + lint + test (push) Failing after 1m44s
The M3 Slider default track is 16dp tall and the Slider itself
expands to the 48dp interactive-component minimum, so the 14dp
thumb we'd already shrunk to a flat circle was still sitting in
the middle of a fat horizontal pill with lots of empty space
above and below. Operator framing: "puffy, not a tool."

Two changes:
- Custom 4dp rounded track replaces SliderDefaults.Track. The
  thumb (14dp) now reads as visibly taller than the bar — the
  classic "handle on a string" cue that says "tool, draggable."
  Also drops M3's stop-indicator dot which the web scrubber
  doesn't have.
- Clamp the Slider's vertical footprint to 20dp via Modifier.
  height. 14dp thumb + 3dp clearance each side, vs the default
  ~17dp empty above and below. Touch area stays usable since the
  drag axis is horizontal — pulling left/right anywhere on the
  thin bar feels natural, and Slider's gesture detector still
  responds to a tap anywhere along its row.

Keeps an Android flavor (slightly thicker than the web's 2-3px
hairline; rounded caps; accent fill) without reading as bulky.
2026-06-02 10:24:31 -04:00
bvandeusen 5fd1a5724a fix(android): always use dominant swatch for NowPlaying background
android / Build + lint + test (push) Has been cancelled
Operator framing: the cover art is the main feature of NowPlaying,
not the background. A vibrant accent on the cover (small bright
logo, sticker, stripe) should pop against the background, not be
matched by it. The previous vibrant → muted → dominant fallback
chain often picked a high-saturation accent that covered only a
sliver of the cover, producing gradients that clashed with the
actual image.

Drop to dominantSwatch only — the majority-by-pixel-count color.
If the palette resolves no dominant swatch (extremely rare;
essentially uniform/empty bitmap) the held color stays on the
previous track's dominant, matching the existing "keep previous
on failure" docstring contract.
2026-06-02 10:24:19 -04:00
bvandeusen 516d22fc73 feat(android): Start Radio preserves current playback, queues after
android / Build + lint + test (push) Failing after 1m24s
Operator: tapping Start Radio while music plays previously
reloaded the current track from position 0 because the radio
seed response includes the seed at index 0 and the handler called
setQueue(tracks, 0) — Flutter's playerActions.startRadio does the
same. They want the current track to keep playing untouched,
upcoming queue cleared, radio results appended after.

PlayerController.startRadio now branches on mediaItemCount:
  - Empty queue: existing behavior — setQueue from index 0.
  - Active queue: keep currentMediaItem, removeMediaItems from
    currentIdx+1 to end, then addMediaItems with the radio list.
    When the seed is the currently-playing track (the common
    "Start Radio on the song I'm listening to" case), drop the
    seed from the appended list so it doesn't immediately repeat
    after the current track ends.

queueRefs is updated alongside the controller so the cached
TrackRef list stays consistent. Source tag "radio:<id>" is
preserved for the appended items so play_started attribution
stays correct.

Intentional divergence from Flutter — recorded in the docstring
so future ports notice it.
2026-06-02 10:09:00 -04:00
bvandeusen 1fdd785ee5 fix(android): key UiState Crossfades on state::class to stop refresh flicker
android / Build + lint + test (push) Has been cancelled
Pull-to-refresh produced a strong full-screen fade on Library
(both tabs) and the Album / Artist / Playlist detail screens
because their Crossfades were keyed on the entire state value.
A refresh emits a fresh UiState.Success with a NEW data instance
(same kind, different content) so Crossfade animated old grid →
new grid even though both are the same Success branch — the
visible result was a flash that read as "broken/heavy."

HomeScreen already keys on `state::class` (4b9d-ish prior fix);
apply the same pattern to the four screens that still flicker.
Inner content reads the outer `state` directly via `val s = state`
so the branch still has access to the typed value. Row-level diffs
are owned by LazyVerticalGrid / LazyColumn via item keys, so the
visual update is smooth and granular instead of a full fade.

Only Loading ↔ Success ↔ Error ↔ Empty transitions animate now —
the intended use of Crossfade. Same-kind state updates flow
through Compose's normal recomposition.
2026-06-02 10:07:10 -04:00
bvandeusen 9b3ec65476 feat(android): swipe to change Library tabs
android / Build + lint + test (push) Has been cancelled
Operator: tabs in the Library view should feel swipeable, not just
tappable. Replace the selectedTab Int state + when-block content
with HorizontalPager whose state drives the PrimaryScrollableTabRow.
Tap routes through animateScrollToPage so swipe + tap share one
source of truth.

Horizontal pager gestures don't conflict with the LazyVerticalGrid
inside each tab (different axes) or with PullToRefreshScaffold's
vertical pull (different axes). HorizontalPager renders only the
current page by default; adjacent tabs remain composed during the
swipe but not eager-mounted at start.
2026-06-02 10:04:38 -04:00
bvandeusen b83a6a4bdb revert(android): top-nav Library icon back to Lucide.LibraryBig
android / Build + lint + test (push) Has been cancelled
Operator polled another user and reversed the earlier swap to
Material's LibraryMusic. Restore Lucide.LibraryBig and drop the
material-icons-extended Gradle dependency we added for the
intermediate icon, keeping the icon set Lucide-only.
2026-06-02 10:03:15 -04:00
bvandeusen b64965b38d Merge pull request 'Discover artwork + Library icon + notification tap routing' (#73) from dev into main
android / Build + lint + test (push) Successful in 5m55s
release / Build signed APK (tag releases only) (push) Successful in 5m26s
release / Build + push container image (push) Successful in 16s
2026-06-02 09:58:10 -04:00
bvandeusen 4cd38aa62f fix(android): keep BaseUrlInterceptor.intercept under detekt ReturnCount
android / Build + lint + test (push) Successful in 4m18s
aec10ce7 added a third early return (the placeholder-host bail)
on top of the existing unparseable-baseUrl elvis return, tripping
detekt's ReturnCount ceiling of 2 on the dev test workflow.

Refactor: keep the placeholder-host early bail (it preserves the
no-op cost for external URLs — no AuthStore read, no URL parse),
fold the unparseable-baseUrl case into a `?:` that falls back to
the original URL. Result is two returns and identical observable
behavior — placeholder hosts get rewritten when baseUrl parses,
fall through unchanged when it doesn't.

Existing unit tests cover all three paths and continue to assert
the same outputs.
2026-06-02 09:50:05 -04:00
bvandeusen 438e81a117 feat(android): media-notification tap opens NowPlaying
android / Build + lint + test (push) Failing after 1m32s
Tapping the system media notification previously landed on
whatever shell route MainActivity last rendered (Home / Library /
Search) because MinstrelPlayerService never configured the
session-activity PendingIntent, so Media3 defaulted to the
launcher activity entry point. Operator request: tap should go
straight to the full player.

MinstrelPlayerService.onCreate now builds a PendingIntent
targeting MainActivity with an EXTRA_OPEN_NOW_PLAYING flag and
passes it to MediaSession.Builder.setSessionActivity. The flag
also covers the lock-screen card and the Pixel Watch tile —
both use the same session-activity PendingIntent.

MainActivity reads the extra in onCreate AND onNewIntent (so a
warm app gets the navigation too, not just cold launches), flips
a pendingOpenNowPlaying StateFlow, then strips the extra so a
config-change recreation doesn't re-trigger. The App composable
observes the flag and runs a LaunchedEffect to navigate once the
NavHost is mounted — handles both cold start (BootSplash →
resolved → navigate) and warm start. launchSingleTop avoids
stacking copies if NowPlaying is already on top, and the
onOpenedNowPlaying callback clears the flag post-navigation so
later recompositions don't re-fire.

Divergence from Flutter (intentional): audio_service's default
notification tap behavior just opens the launcher activity at
whatever screen it was on — exactly the behavior the operator
asked to improve.
2026-06-02 09:43:45 -04:00
bvandeusen faf2cac0c9 feat(android): use Material Outlined LibraryMusic for top-nav Library
android / Build + lint + test (push) Failing after 2m34s
Lucide has no music-library glyph — Library / LibraryBig /
SquareLibrary all read as a generic books-on-shelf icon without
a label. Operator picked Material's LibraryMusic (the canonical
"books + music note" symbol used by every major music app) as
the recognizable alternative.

Use the Outlined variant: filled icons would clash with the
neighbouring stroked Lucide icons (House, Search, EllipsisVertical),
but Outlined's stroke style matches Lucide closely enough that
the mix is subtle.

Adds the compose-material-icons-extended dependency (version
pinned by compose-bom). R8 strips unused icons in release builds
so the APK cost is just the ones we actually reference.
2026-06-02 09:40:49 -04:00
bvandeusen 22dc343b39 feat(android): swap top-nav Library icon to Lucide.SquareLibrary
android / Build + lint + test (push) Failing after 1m25s
LibraryBig (stacked book spines) didn't read as "Library" without
the label — easy to misread as a generic stack/columns icon.
SquareLibrary frames the same books-on-shelf glyph inside a
rounded square, matching the visual weight of the neighbouring
House and Search icons better and reading more clearly as a
distinct tappable destination.

Untouched: the RequestsScreen per-row "album"-kind avatar still
uses LibraryBig (parity with Flutter's lib/requests/requests_screen.dart).
2026-06-02 09:35:58 -04:00
bvandeusen aec10ce787 fix(android): scope BaseUrlInterceptor to placeholder host only
android / Build + lint + test (push) Failing after 1m41s
Discover suggestion artist images failed to load on Android while
loading fine in the web client. Root cause: BaseUrlInterceptor
unconditionally rewrote every outgoing request's scheme/host/port
to AuthStore.baseUrl. That's correct for Minstrel-bound requests
built with the http://placeholder.invalid sentinel (Retrofit's
frozen baseUrl, every cover-URL builder, ServerImage's
resolveServerUrl). But Lidarr surfaces artist artwork as absolute
URLs to external hosts (artwork.musicbrainz.org,
coverartarchive.org); rewriting those to the Minstrel host
produced 404s that Coil silently fell back from to the User icon.

Web works because the browser fetches the URL as authored. Coil
on Android shares the OkHttp client (and so the interceptor chain)
with Retrofit, which is why the bug surfaced here only.

Add a PLACEHOLDER_HOST companion constant and short-circuit the
rewrite for non-placeholder hosts. Test coverage:
- placeholder host → rewritten to live baseUrl
- absolute external URL → host/scheme/path preserved
- unparseable baseUrl → falls through (no throw)

AuthCookieInterceptor still attaches the Minstrel session cookie
to external requests; external hosts ignore unrecognized cookies
so that's not breaking anything, but it's worth a follow-up DRY
pass to scope auth attachment the same way.
2026-06-02 09:16:36 -04:00
bvandeusen bf2f9f3811 Merge pull request 'Lock Android MainActivity to portrait' (#72) from dev into main
android / Build + lint + test (push) Successful in 4m2s
release / Build signed APK (tag releases only) (push) Successful in 3m56s
release / Build + push container image (push) Successful in 15s
2026-06-02 08:19:07 -04:00
bvandeusen b9186937b3 chore(android): lock MainActivity to portrait
android / Build + lint + test (push) Successful in 3m39s
Operator feedback: landscape just stretches the phone-portrait
Compose layout awkwardly — every screen was sized for one column
of cards, so rotation produces wide rows of unrelated content with
big dead bands top and bottom. Until a dedicated tablet/landscape
layout exists, lock the activity to portrait via screenOrientation.

Revisit when a sw600dp resource set + multi-pane layouts land.
2026-06-02 08:12:58 -04:00
bvandeusen deb726a285 Merge pull request 'Keep onPostScroll under detekt ReturnCount limit' (#71) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 10s
android / Build + lint + test (push) Successful in 4m9s
2026-06-02 08:10:12 -04:00
bvandeusen 1004b61159 fix(android): keep onPostScroll under detekt ReturnCount limit
android / Build + lint + test (push) Successful in 3m43s
The dismissed-latch added a third early return to onPostScroll —
detekt's ReturnCount ceiling is 2 per the project rule. Fold the
NestedScrollSource.UserInput guard into the existing if/else if/else
chain that branches on the drag direction. Behavior is identical;
the source check just becomes the first arm of the expression
rather than an early bail.
2026-06-02 08:04:21 -04:00
bvandeusen 7ede83a586 Merge pull request 'Alphabet rail page-chasing + Songs Like fix + scrubber polish' (#70) from dev into main
test-web / test (push) Successful in 37s
android / Build + lint + test (push) Failing after 1m29s
release / Build signed APK (tag releases only) (push) Successful in 3m51s
release / Build + push container image (push) Successful in 12s
2026-06-01 23:36:07 -04:00
bvandeusen 6ac36dd334 fix(web): user-playlist play omits third arg to satisfy contract test
test-web / test (push) Successful in 35s
74bae74f routed per-artist system mixes through getPlaylist and
always passed the source attribution as the third arg, falling
back to undefined for true user playlists. Vitest's toHaveBeenCalledWith
is arity-strict — playQueue(refs, 0, undefined) is not the same as
playQueue(refs, 0) — so the PlaylistCard contract test failed on
the user-playlist case.

Split the call: pass three args only when a variant tag exists,
two args for user playlists. Preserves source attribution for
songs_like_artist and keeps user playlists source-less as the
test pins.
2026-06-01 23:31:45 -04:00
bvandeusen 6a7d9afdbc fix(android): latch NowPlaying drag-dismiss to prevent back-stack underflow
android / Build + lint + test (push) Failing after 1m24s
Operator reproduced black-screen-on-resume by drag-down dismissing
the full player. Root cause: the NestedScrollConnection accumulator
crossed the dismiss threshold, called navController.popBackStack(),
reset accumulated to 0 — but the user's finger was still down and
the pop transition was still running. The next frame's onPostScroll
re-accumulated and re-fired onDismiss(), popping the screen BENEATH
NowPlaying. When that left the back stack empty the NavHost had no
destination to draw, producing a black window until the process
was killed and the activity was cold-launched.

Add a `dismissed` latch that survives until the connection is
disposed (which only happens when NowPlayingScreen leaves the
composition, i.e. the pop completes). After the latch sets we
consume the remaining drag (return `available`) so the underlying
scrollable doesn't paint over-scroll while the pop transitions.
onPreFling also bails after dismissal so the fling can't restart
the accumulator.
2026-06-01 23:29:36 -04:00
bvandeusen 15a545a25c feat(web): two-pane now-playing on desktop with permanent queue panel
test-web / test (push) Failing after 33s
The full player previously sat dead-center on wide viewports — cover
+ controls capped at max-w-md left the right two-thirds of the screen
empty while the queue remained behind a drawer toggle. Split the
layout at lg+:

- Extract QueueList from QueueDrawer (header + count + scrollable
  list body + empty state); the drawer now wraps QueueList for its
  slide-in chrome, and the now-playing route embeds it directly.
- now-playing: at lg+ render the player as a left section + a 384px
  (xl: 448px) aside with QueueList. Below lg the layout is unchanged
  single-column and the drawer toggle in the bottom row still opens
  it (lg:hidden on the toggle button keeps it out of the way once
  the panel is permanent).
- QueueList owns the close X conditionally — drawer passes onClose
  and the bind:closeButtonRef for focus management; embedded panel
  omits both since it has no dismiss action.

QueueTrackRow's drag-to-reorder, click-to-jump, and current-row
highlight all carry over for free since they're owned by the row
component.
2026-06-01 23:24:06 -04:00
bvandeusen b77a7121ca feat(android): restyle NowPlaying scrubber thumb to match web client
android / Build + lint + test (push) Successful in 3m39s
User feedback: dislikes both the old M3 20dp thumb and the current
10dp slim variant; wants the bare HTML range thumb the web client
shows. Replace SliderDefaults.Thumb with a plain Box(CircleShape +
accent fill, 14dp). Drops the M3 state-layer halo on press and the
implicit elevation/border so the on-screen result matches a
<input type=\"range\" accent-color> rendering. Slider's 48dp hit
slop is intrinsic to the composable, so tapability is unchanged.
2026-06-01 22:40:35 -04:00
bvandeusen 74bae74f9f fix(web): songs-like play overlay + scrubber smoothing
test-web / test (push) Failing after 45s
PlaylistCard: per-artist system variants (songs_like_artist) all share
one variant tag but exist as one playlist per seed artist; routing
their play handler through systemShuffle(variant) hit the wrong
playlist (or 404). Detect via seed_artist_id != null and fall through
to getPlaylist(playlist.id) for those; still tag the queue with the
variant so source attribution stays correct.

smoothPosition.svelte.ts: new useSmoothPosition() hook mirrors
Android's rememberSmoothPositionMs. player.position only updates
~4 Hz (HTML audio timeupdate), so the seek thumb stepped visibly;
$effect resets on each canonical tick / seek / track-change and a
rAF loop extrapolates at playback rate between ticks.

Wired into both PlayerBar.svelte (mini + expanded seek rows) and
now-playing/+page.svelte. Seek input handler still reads the raw
range value (not smoothed.value) so user drags stay authoritative.
2026-06-01 22:39:24 -04:00
bvandeusen d4c6bb3f2d feat(web): alphabet rail chases pages on click for unloaded letters
test-web / test (push) Successful in 32s
Operator: prior rail disabled empty buckets, so clicking a letter
like 'Z' did nothing if it wasn't loaded yet. AlphabeticalGrid now
accepts a paginated source and walks pages on click until the
target letter surfaces.

- New props: hasMore, onLoadMore. When hasMore=true, every empty
  bucket stays enabled (clicking will chase pages); when hasMore=
  false, only populated buckets are clickable.
- jumpTo(bucket): if populated, scrollIntoView. Otherwise loop
  onLoadMore + tick() until the bucket appears or no more pages.
  Loader2 spinner replaces the letter on the pending button;
  cursor:wait + aria-busy. Other rail buttons disable while one is
  pending so clicks don't stack.
- Library Artists + Albums pass hasMore + onLoadMore through to the
  grid. Existing InfiniteScrollSentinel still handles scroll-driven
  loading.

Test: new case asserts rail enables empty buckets when hasMore=true
(the click would trigger onLoadMore).
2026-06-01 22:29:13 -04:00
68 changed files with 3076 additions and 239 deletions
+36 -3
View File
@@ -60,9 +60,35 @@ jobs:
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
# Job outputs propagate the computed release version to image-release
# so the bundled sidecar file matches what's baked into the APK —
# otherwise the server would report a different version string than
# the installed client and the update banner could thrash.
outputs:
version_name: ${{ steps.ver.outputs.name }}
version_code: ${{ steps.ver.outputs.code }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# fetch-depth: 0 retrieves full history; default shallow clone
# would return 1 for `git rev-list --count HEAD`, breaking the
# iteration suffix.
fetch-depth: 0
- name: Compute release version
id: ver
shell: bash
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/v}"
COMMIT_COUNT=$(git rev-list --count HEAD)
VERSION_NAME="${TAG}.${COMMIT_COUNT}"
echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
- name: Cache Gradle dirs
uses: actions/cache@v4
@@ -91,7 +117,10 @@ jobs:
echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}"
- name: Build release APK
run: ./gradlew assembleRelease
run: |
./gradlew assembleRelease \
-PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
- name: Upload APK as workflow artifact
# @v3 because Gitea Actions emulates GHES and the v2 artifact
@@ -204,14 +233,18 @@ jobs:
- name: Stage bundled APK + version sidecar
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
# Pulled from android-release.outputs.version_name so the
# sidecar string the server hands clients matches the
# versionName baked into the APK they're comparing against.
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
run: |
set -euxo pipefail
TAG="${GITHUB_REF#refs/tags/}"
# The artifact lands as `app-release.apk` (the original Gradle
# output name). The Dockerfile COPYs client/* into /app/client/
# and the server reads minstrel.apk + minstrel.apk.version.
mv client/app-release.apk client/minstrel.apk
echo "${TAG}" > client/minstrel.apk.version
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
ls -lh client/
- name: Build and push
+13 -2
View File
@@ -21,8 +21,19 @@ android {
applicationId = "com.fabledsword.minstrel"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0-native"
// versionName / versionCode are released-build values injected by
// CI from the git tag + commit count. Local / debug builds fall
// back to "dev" so the About card reads honestly. Releases ship
// versionName="YYYY.MM.DD.<commits>" (e.g. "2026.06.02.142") and
// versionCode=<commits>, which is monotonic forever and lets the
// shared isVersionNewer comparator distinguish two same-day
// re-cuts (the iteration suffix differs).
val versionNameOverride =
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val versionCodeOverride =
(project.findProperty("MINSTREL_VERSION_CODE") as String?)?.toIntOrNull()
versionCode = versionCodeOverride ?: 1
versionName = versionNameOverride ?: "dev"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
}
+5
View File
@@ -22,9 +22,14 @@
android:usesCleartextTraffic="true"
tools:targetApi="34">
<!-- Portrait-locked until a tablet/landscape layout exists.
Current Compose screens are sized for phone-portrait;
landscape just stretches the column awkwardly. Revisit
this when a dedicated tablet layout lands. -->
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:theme="@style/Theme.Minstrel">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -11,6 +12,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -22,10 +24,14 @@ import com.fabledsword.minstrel.cache.CachedTrackIds
import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph
import com.fabledsword.minstrel.nav.NowPlaying
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
import com.fabledsword.minstrel.theme.MinstrelTheme
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
@AndroidEntryPoint
@@ -33,10 +39,46 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds
// Flipped to true when the user taps the media notification (or
// any other entry point that asks for the full player). The App
// composable observes this, navigates to NowPlaying once the
// NavHost is ready, then calls back to reset the flag so the
// navigation doesn't re-fire on the next recomposition.
private val pendingOpenNowPlaying = MutableStateFlow(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { App(seedCache = seedCache, cachedTrackIds = cachedTrackIds) }
consumeOpenNowPlayingIntent(intent)
setContent {
App(
seedCache = seedCache,
cachedTrackIds = cachedTrackIds,
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
consumeOpenNowPlayingIntent(intent)
}
private fun consumeOpenNowPlayingIntent(intent: Intent?) {
if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
pendingOpenNowPlaying.value = true
// Strip the extra so a subsequent config-change recreation
// doesn't re-trigger the navigation.
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
}
}
companion object {
/** PendingIntent extra set by [com.fabledsword.minstrel.player.MinstrelPlayerService]
* so a media-notification tap lands on the full NowPlaying screen
* instead of whatever shell route MainActivity last rendered. */
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING"
}
}
@@ -44,11 +86,14 @@ class MainActivity : ComponentActivity() {
private fun App(
seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds,
pendingOpenNowPlaying: StateFlow<Boolean>,
onOpenedNowPlaying: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(),
gate: AuthGateViewModel = hiltViewModel(),
) {
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
CompositionLocalProvider(
LocalDetailSeedCache provides seedCache,
@@ -65,6 +110,18 @@ private fun App(
// `ShellScaffold` wrap; full-screen routes (NowPlaying /
// Queue / unauthenticated) bypass the shell entirely.
val navController = rememberNavController()
// Honour a pending notification-tap once the NavHost is
// mounted. launchSingleTop avoids stacking copies of
// NowPlaying if the user taps the notification while
// already on it; the callback clears the flag so a later
// recomposition (config change, theme switch) doesn't
// re-navigate.
LaunchedEffect(pending, navController) {
if (pending) {
navController.navigate(NowPlaying) { launchSingleTop = true }
onOpenedNowPlaying()
}
}
MinstrelNavGraph(
navController = navController,
startDestination = resolved,
@@ -7,11 +7,23 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Attaches the session cookie from [AuthStore] to every request,
* captures Set-Cookie from successful responses (login flow), and
* clears the store on 401 so downstream code can react to logout.
* Attaches the session cookie from [AuthStore] to Minstrel-server
* requests, captures Set-Cookie from successful responses (login
* flow), and clears the store on 401 so downstream code can react
* to logout.
*
* Mirrors the Flutter Dio interceptor pattern.
*
* Scoped to the [BaseUrlInterceptor.PLACEHOLDER_HOST] sentinel host
* the same way BaseUrlInterceptor is. Drift #568 / #569 caught two
* leaks here: (1) the session cookie was being attached to every
* external request the shared OkHttpClient services — including
* Coil image fetches to artwork.musicbrainz.org / coverartarchive.org
* / Lidarr's /MediaCover endpoints — exposing the session
* identifier to third-party logging; (2) a 401 from any of those
* external hosts silently wiped the user's Minstrel session.
* Restricting both attach + clear to placeholder-host requests
* closes both gaps.
*/
@Singleton
class AuthCookieInterceptor @Inject constructor(
@@ -19,8 +31,15 @@ class AuthCookieInterceptor @Inject constructor(
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
if (original.url.host != BaseUrlInterceptor.PLACEHOLDER_HOST) {
// External request — no Minstrel session cookie attached,
// and a 401 from this host does NOT clear the user's
// session. Pass through untouched.
return chain.proceed(original)
}
val cookie = authStore.sessionCookie.value
val request = chain.request().newBuilder().apply {
val request = original.newBuilder().apply {
if (!cookie.isNullOrEmpty()) header("Cookie", cookie)
}.build()
@@ -9,16 +9,23 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Rewrites every outgoing request's scheme/host/port to match the
* Rewrites Minstrel-server requests' scheme/host/port to match the
* current [AuthStore.baseUrl]. Retrofit's `.baseUrl(...)` is read
* once at Retrofit creation, but AuthStore.baseUrl loads from Room
* asynchronously — so at injection time the Retrofit instance is
* frozen pointing at the [AuthStore.DEFAULT_BASE_URL] placeholder.
*
* This interceptor closes the gap: Retrofit can stay built with the
* placeholder forever, and every actual request gets retargeted at
* the live AuthStore value. Lets the user change server URL in
* Settings without an app relaunch (Phase 11 wiring).
* placeholder forever, and every actual Minstrel-bound request gets
* retargeted at the live AuthStore value. Lets the user change
* server URL in Settings without an app relaunch (Phase 11 wiring).
*
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
* rewritten. Absolute external URLs — Lidarr-surfaced artwork from
* artwork.musicbrainz.org / coverartarchive.org, for example — must
* reach their authored host unchanged; rewriting them to the
* Minstrel host produced 404s the user saw as missing Discover
* suggestion covers.
*
* No-op when the stored base URL is unparseable (falls through to
* whatever Retrofit had) — that case shows up as a transport-level
@@ -31,13 +38,26 @@ class BaseUrlInterceptor @Inject constructor(
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
val baseUrl = authStore.baseUrl.value.toHttpUrlOrNull()
?: return chain.proceed(original)
val rewritten: HttpUrl = original.url.newBuilder()
.scheme(baseUrl.scheme)
.host(baseUrl.host)
.port(baseUrl.port)
.build()
// Early-bail keeps the no-op path for external URLs cheap
// (no AuthStore read, no URL parse).
if (original.url.host != PLACEHOLDER_HOST) return chain.proceed(original)
// Unparseable baseUrl folds into the same proceed path — keeps
// detekt's ReturnCount happy by avoiding a second early return.
val rewritten: HttpUrl = authStore.baseUrl.value.toHttpUrlOrNull()?.let { baseUrl ->
original.url.newBuilder()
.scheme(baseUrl.scheme)
.host(baseUrl.host)
.port(baseUrl.port)
.build()
} ?: original.url
return chain.proceed(original.newBuilder().url(rewritten).build())
}
companion object {
/** Sentinel host used by Retrofit + every code path that builds
* a Minstrel-server URL via the `http://placeholder.invalid/...`
* form (see [com.fabledsword.minstrel.shared.resolveServerUrl],
* CoverUrls.kt, AlbumRef/TrackRef cover getters). */
const val PLACEHOLDER_HOST = "placeholder.invalid"
}
}
@@ -48,11 +48,20 @@ object NetworkModule {
logging: HttpLoggingInterceptor,
): OkHttpClient =
OkHttpClient.Builder()
// BaseUrlInterceptor first — it rewrites scheme/host/port
// to match the live AuthStore.baseUrl on every request.
// Auth then sees the final URL.
.addInterceptor(baseUrl)
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
// Minstrel-server requests from external image fetches
// (drift #568 / #569). If BaseUrlInterceptor runs first it
// rewrites the host to the real server before auth sees the
// request, auth's placeholder check fails, and the session
// cookie is neither attached on outgoing requests nor
// captured from Set-Cookie on login — fresh installs get
// stuck at the Welcome screen. Auth first means it sees
// placeholder.invalid, attaches/captures correctly, then
// BaseUrlInterceptor retargets to the live AuthStore host
// for transport.
.addInterceptor(auth)
.addInterceptor(baseUrl)
.addInterceptor(logging)
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
@@ -0,0 +1,33 @@
package com.fabledsword.minstrel.api.endpoints
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for `POST /api/playback-errors`. Reports a
* client-detected playback failure (zero-duration, decode error, ...)
* so the admin inbox surfaces it.
*
* Failures during dispatch are reported via the MutationQueue path
* for offline replay (see [com.fabledsword.minstrel.cache.mutations.MutationQueue.enqueuePlaybackErrorReport]).
*/
interface PlaybackErrorsApi {
@POST("api/playback-errors")
suspend fun report(@Body body: PlaybackErrorReportRequest)
}
/**
* POST body for `/api/playback-errors`. `kind` is one of
* "zero_duration" / "load_failed" / "stalled"; server validates against
* the CHECK constraint enum. `detail` is optional free-text (Media3
* error message, etc.). `clientId` reuses the existing playback
* client identifier the device already sends with `/api/plays/...`
* so support can correlate reports across surfaces.
*/
@kotlinx.serialization.Serializable
data class PlaybackErrorReportRequest(
@kotlinx.serialization.SerialName("track_id") val trackId: String,
val kind: String,
val detail: String? = null,
@kotlinx.serialization.SerialName("client_id") val clientId: String,
)
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
import kotlinx.coroutines.flow.Flow
@@ -36,4 +37,21 @@ interface CachedLikeDao {
"WHERE userId = :userId AND entityType = :entityType AND entityId = :entityId",
)
suspend fun delete(userId: String, entityType: String, entityId: String)
@Query("DELETE FROM cached_likes WHERE userId = :userId")
suspend fun clearForUser(userId: String)
/**
* Atomically replaces the user's entire cached_likes set with
* [rows]. Used by [com.fabledsword.minstrel.likes.data.LikesRepository.refreshIds]
* so cross-device unlikes (a row that the server no longer
* surfaces) get removed from the local cache — drift #570
* caught the missing delete pass that left stale Liked tab
* entries pointing at tracks the user had unliked elsewhere.
*/
@Transaction
suspend fun replaceAllForUser(userId: String, rows: List<CachedLikeEntity>) {
clearForUser(userId)
upsertAll(rows)
}
}
@@ -21,6 +21,7 @@ object MutationKind {
const val QUARANTINE_FLAG: String = "quarantine_flag"
const val PLAY_OFFLINE: String = "play_offline"
const val REQUEST_CANCEL: String = "request_cancel"
const val PLAYBACK_ERROR_REPORT: String = "playback_error_report"
}
/**
@@ -144,6 +145,13 @@ class MutationQueue @Inject constructor(
),
),
)
suspend fun enqueuePlaybackErrorReport(payload: PlaybackErrorReportPayload): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.PLAYBACK_ERROR_REPORT,
payload = json.encodeToString(PlaybackErrorReportPayload.serializer(), payload),
),
)
}
/**
@@ -203,3 +211,20 @@ data class PlayOfflinePayload(
*/
@Serializable
data class RequestCancelPayload(val requestId: String)
/**
* Persisted payload for `MutationKind.PLAYBACK_ERROR_REPORT` — the
* `POST /api/playback-errors` call lost during a connectivity hiccup.
* The replayer re-fires the POST with this body. Server is naturally
* idempotent enough — multiple reports of the same (track, user,
* kind) become multiple rows in the admin inbox, which is acceptable
* (the admin can resolve them all with one action). `clientId` is
* the existing playback client identifier from AuthStore.
*/
@Serializable
data class PlaybackErrorReportPayload(
val trackId: String,
val kind: String,
val detail: String? = null,
val clientId: String,
)
@@ -5,6 +5,8 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
import com.fabledsword.minstrel.api.endpoints.EventsApi
import com.fabledsword.minstrel.api.endpoints.FlagRequest
import com.fabledsword.minstrel.api.endpoints.LikesApi
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
import com.fabledsword.minstrel.api.endpoints.RequestsApi
@@ -66,6 +68,7 @@ class MutationReplayer @Inject constructor(
private val playlistsApi: PlaylistsApi = retrofit.create()
private val eventsApi: EventsApi = retrofit.create()
private val requestsApi: RequestsApi = retrofit.create()
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
private val mutex = Mutex()
@@ -116,6 +119,7 @@ class MutationReplayer @Inject constructor(
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
else -> {
// Unknown kind — drop the row by claiming success so a
// stale schema entry can't wedge the queue forever.
@@ -258,4 +262,24 @@ class MutationReplayer @Inject constructor(
false
}
}
private suspend fun dispatchPlaybackErrorReport(payload: String): Boolean {
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
return try {
playbackErrorsApi.report(
PlaybackErrorReportRequest(
trackId = decoded.trackId,
kind = decoded.kind,
detail = decoded.detail,
clientId = decoded.clientId,
),
)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: row stays queued; next drain pass retries.
false
}
}
}
@@ -288,7 +288,15 @@ class HomeViewModel @Inject constructor(
poolMessages.trySend("Mix isn't ready yet - try again in a moment")
return@launch
}
val source = if (playlist.refreshable) "playlist:${playlist.systemVariant}" else null
// Drift #564: send the BARE systemVariant string, not
// "playlist:<variant>" — the server's rotation matcher
// (internal/playevents/writer.go systemPlaylistSources)
// keys on the bare variant. Web sends the bare form too
// (web/src/lib/components/PlaylistCard.svelte:83), so this
// brings Android into alignment. Wrong prefix here meant
// system-mix plays from Android Home never advanced the
// rotation.
val source = if (playlist.refreshable) playlist.systemVariant else null
player.setQueue(tracks, initialIndex = 0, source = source)
}.join()
}
@@ -109,8 +109,11 @@ private fun AlbumDetailStateContent(
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
navController: NavHostController,
) {
Crossfade(targetState = state, label = "album-detail") { s ->
when (s) {
// Crossfade keyed on `state::class` so a Success → fresh Success
// refresh (same kind, new detail) doesn't re-fade the whole body;
// only Loading ↔ Success ↔ Error transitions animate.
Crossfade(targetState = state::class, label = "album-detail") { _ ->
when (val s = state) {
is AlbumDetailUiState.Loading ->
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
is AlbumDetailUiState.Error -> EmptyState(
@@ -87,8 +87,11 @@ fun ArtistDetailScreen(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
Crossfade(targetState = state, label = "artist-detail") { s ->
when (s) {
// Crossfade keyed on `state::class` so a Success → fresh
// Success refresh (same kind, new detail) doesn't re-fade
// the body; only Loading ↔ Success ↔ Error animate.
Crossfade(targetState = state::class, label = "artist-detail") { _ ->
when (val s = state) {
is ArtistDetailUiState.Loading ->
if (s.seed != null) {
SeededArtistLoading(s.seed)
@@ -4,7 +4,6 @@ package com.fabledsword.minstrel.library.ui
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
@@ -12,6 +11,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -22,11 +23,10 @@ import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
@@ -69,8 +69,12 @@ fun LibraryScreen(
navController: NavHostController,
viewModel: LibraryViewModel = hiltViewModel(),
) {
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = LIBRARY_TABS
// HorizontalPager owns the active-tab state — the TabRow reads
// pagerState.currentPage and animateScrollToPage drives it on tap,
// so swipe and tap stay in sync.
val pagerState = rememberPagerState(pageCount = { tabs.size })
val scope = rememberCoroutineScope()
Scaffold(
modifier = Modifier.fillMaxSize(),
@@ -91,13 +95,13 @@ fun LibraryScreen(
},
)
PrimaryScrollableTabRow(
selectedTabIndex = selectedTab,
selectedTabIndex = pagerState.currentPage,
edgePadding = 16.dp,
) {
tabs.forEachIndexed { index, label ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
selected = pagerState.currentPage == index,
onClick = { scope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(label) },
)
}
@@ -105,8 +109,11 @@ fun LibraryScreen(
}
},
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
when (selectedTab) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize().padding(inner),
) { page ->
when (page) {
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
TAB_HISTORY -> HistoryTab(
@@ -135,8 +142,13 @@ private fun ArtistsTab(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
Crossfade(targetState = state, label = "library-artists") { s ->
when (s) {
// Crossfade is keyed on `state::class` (not the whole state)
// so a refresh that emits a fresh UiState.Success — same kind,
// new data — doesn't re-fade the entire grid. Only Loading ↔
// Success ↔ Error ↔ Empty transitions animate; row-level diff
// is owned by LazyVerticalGrid via its item keys.
Crossfade(targetState = state::class, label = "library-artists") { _ ->
when (val s = state) {
UiState.Loading -> SkeletonArtistsGrid()
UiState.Empty -> EmptyState(
title = "No artists yet",
@@ -163,8 +175,8 @@ private fun AlbumsTab(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
Crossfade(targetState = state, label = "library-albums") { s ->
when (s) {
Crossfade(targetState = state::class, label = "library-albums") { _ ->
when (val s = state) {
UiState.Loading -> SkeletonAlbumsGrid()
UiState.Empty -> EmptyState(
title = "No albums yet",
@@ -1,19 +1,23 @@
package com.fabledsword.minstrel.likes.data
import com.fabledsword.minstrel.api.endpoints.LikesApi
import com.fabledsword.minstrel.auth.AuthController
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
@@ -24,11 +28,14 @@ import javax.inject.Singleton
* Flutter `likesControllerProvider` + `cached_likes`-driven Liked tab
* pattern.
*
* Local `userId` discriminator is a constant for now (`LOCAL_USER_ID`)
* because the AuthController hookup lands with Phase 11. Single-user
* device is the only shape we target; if multi-tenant on one device
* ever shows up, swap this constant for `AuthStore.userId.value`
* everywhere and migrate the cached_likes rows.
* Local `userId` discriminator is the server-side user UUID
* (resolved via [AuthController.currentUser]); falls back to
* [ANONYMOUS_USER_ID] when no user is signed in so existing local
* cache rows from pre-#576 builds remain queryable until the first
* authenticated [refreshIds] call overwrites them. On user-switch
* (sign-out / sign-in as different user) the OUTGOING user's
* cached_likes rows are wiped so they don't leak into the new
* session — drift #576 audit caught the leak.
*
* Write path follows `feedback_offline_first_for_server_writes` —
* never fire-and-forget:
@@ -48,33 +55,64 @@ class LikesRepository @Inject constructor(
private val artistDao: CachedArtistDao,
private val trackDao: CachedTrackDao,
private val mutationQueue: MutationQueue,
private val authController: AuthController,
@ApplicationScope private val scope: CoroutineScope,
retrofit: Retrofit,
) {
private val api: LikesApi = retrofit.create()
init {
// Drift #576: clear the outgoing user's cached_likes rows
// when the signed-in user changes. Mostly a hygiene fix —
// discriminator-based queries already prevent the wrong
// user from SEEING leaked rows, but without this the rows
// pile up forever as different accounts sign in/out on the
// same device.
scope.launch {
var previousId: String? = null
authController.currentUser.collect { user ->
val newId = user?.id
val prev = previousId
if (prev != null && prev != newId) {
likeDao.clearForUser(prev)
}
previousId = newId
}
}
}
/**
* Server-user-uuid for the current session, or [ANONYMOUS_USER_ID]
* fallback when there's no signed-in user. Used as the
* `cached_likes.userId` discriminator so each account's likes are
* stored under its own bucket.
*/
private fun currentUserId(): String =
authController.currentUser.value?.id ?: ANONYMOUS_USER_ID
// ── Reads ─────────────────────────────────────────────────────────
fun observeLikedArtists(): Flow<List<ArtistRef>> =
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_ARTIST).map { ids ->
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_ARTIST).map { ids ->
ids.mapNotNull { artistDao.getById(it)?.toDomain() }
}
fun observeLikedAlbums(): Flow<List<AlbumRef>> =
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_ALBUM).map { ids ->
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_ALBUM).map { ids ->
ids.mapNotNull { albumDao.getById(it)?.toDomain() }
}
fun observeLikedTracks(): Flow<List<TrackRef>> =
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_TRACK).map { ids ->
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { ids ->
ids.mapNotNull { trackDao.getById(it)?.toDomain() }
}
fun observeIsLiked(entityType: String, entityId: String): Flow<Boolean> =
likeDao.observeIsLiked(LOCAL_USER_ID, entityType, entityId)
likeDao.observeIsLiked(currentUserId(), entityType, entityId)
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
suspend fun likedTrackIds(): Set<String> =
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_TRACK).first().toSet()
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
// ── Writes (optimistic local → best-effort REST → enqueue on fail) ──
@@ -85,13 +123,15 @@ class LikesRepository @Inject constructor(
* if it got enqueued for later replay.
*/
suspend fun toggleLike(entityType: String, entityId: String, desiredState: Boolean): Boolean {
// 1. Optimistic Room mutation.
// 1. Optimistic Room mutation — scoped to the signed-in user
// (or the legacy "local" fallback for pre-#576 rows).
val uid = currentUserId()
if (desiredState) {
likeDao.upsertAll(
listOf(CachedLikeEntity(LOCAL_USER_ID, entityType, entityId)),
listOf(CachedLikeEntity(uid, entityType, entityId)),
)
} else {
likeDao.delete(LOCAL_USER_ID, entityType, entityId)
likeDao.delete(uid, entityType, entityId)
}
// 2. Best-effort REST call; 3. enqueue on failure.
val kindPath = serverPathFor(entityType)
@@ -111,24 +151,38 @@ class LikesRepository @Inject constructor(
}
/**
* Pulls `GET /api/likes/ids` and reconciles `cached_likes`. Called
* by the LikedTab ViewModel on init (and by the SyncController in
* Phase 12). Adds rows for IDs the server has but we don't; the
* delete-of-stale-rows pass lives in the SyncController where the
* full reconciliation runs.
* Pulls `GET /api/likes/ids` and atomically replaces the user's
* cached_likes set with the server's canonical view. Called by
* the LikedTab ViewModel on init (and by the SyncController in
* Phase 12).
*
* Drift #570 fix: previously this called `upsertAll` only, which
* added rows for IDs the server had but never removed rows the
* server no longer surfaced — so a cross-device unlike (user
* likes on web, then unlikes on web) left the Liked tab on
* Android showing the now-unliked entry forever. The atomic
* replaceAllForUser DAO method runs a transactional delete-then-
* insert so the local set is exactly what the server reports.
*/
suspend fun refreshIds() {
val uid = currentUserId()
val wire = api.ids()
val rows = mutableListOf<CachedLikeEntity>()
rows += wire.artistIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_ARTIST, it) }
rows += wire.albumIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_ALBUM, it) }
rows += wire.trackIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_TRACK, it) }
likeDao.upsertAll(rows)
rows += wire.artistIds.map { CachedLikeEntity(uid, ENTITY_ARTIST, it) }
rows += wire.albumIds.map { CachedLikeEntity(uid, ENTITY_ALBUM, it) }
rows += wire.trackIds.map { CachedLikeEntity(uid, ENTITY_TRACK, it) }
likeDao.replaceAllForUser(uid, rows)
}
companion object {
// TODO(Phase 11): swap for AuthStore.userId.value once auth lands.
const val LOCAL_USER_ID: String = "local"
// Pre-auth fallback discriminator. Drift #576: when no user is
// signed in OR for cached_likes rows written by pre-#576
// builds (which all tagged "local"), reads continue to query
// this bucket so the Liked tab isn't suddenly empty after the
// upgrade. The first authenticated refreshIds() call writes
// rows under the real user UUID; reads then transparently
// switch over via currentUserId().
const val ANONYMOUS_USER_ID: String = "local"
const val ENTITY_ARTIST: String = "artist"
const val ENTITY_ALBUM: String = "album"
const val ENTITY_TRACK: String = "track"
@@ -1,9 +1,11 @@
package com.fabledsword.minstrel.player
import android.app.PendingIntent
import android.content.Intent
import androidx.media3.common.Player
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.fabledsword.minstrel.MainActivity
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@@ -38,7 +40,36 @@ class MinstrelPlayerService : MediaSessionService() {
override fun onCreate() {
super.onCreate()
val player = playerFactory.build()
mediaSession = MediaSession.Builder(this, player).build()
mediaSession = MediaSession.Builder(this, player)
.setSessionActivity(buildNowPlayingPendingIntent())
.build()
}
/**
* Intent that the media notification / lock-screen card / Pixel
* Watch tile launches when tapped. Carries an extra MainActivity
* consumes to navigate the NavController straight to the full
* NowPlaying screen — without it, Media3 defaults to the launcher
* activity entry point, which lands on whatever shell route
* MainActivity last rendered.
*
* FLAG_ACTIVITY_SINGLE_TOP keeps the existing MainActivity instance
* alive when one is already foregrounded; onNewIntent fires and
* picks up the fresh extra. FLAG_IMMUTABLE is required on
* Android 12+; FLAG_UPDATE_CURRENT keeps the extra in sync if the
* Builder rebuilds the PendingIntent.
*/
private fun buildNowPlayingPendingIntent(): PendingIntent {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(MainActivity.EXTRA_OPEN_NOW_PLAYING, true)
}
return PendingIntent.getActivity(
this,
REQUEST_OPEN_NOW_PLAYING,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
@@ -61,4 +92,11 @@ class MinstrelPlayerService : MediaSessionService() {
mediaSession = null
super.onDestroy()
}
private companion object {
// Stable request code for the session-activity PendingIntent.
// Arbitrary but distinct from any other PendingIntent we ever
// build against MainActivity.
const val REQUEST_OPEN_NOW_PLAYING = 0x100
}
}
@@ -13,19 +13,23 @@ import javax.inject.Singleton
private const val DEBOUNCE_MS = 2_000L
/**
* Surfaces ExoPlayer track-load failures (404 / decoder failure /
* premature EOS / network drop) as user-visible snackbar messages.
* Surfaces playback failures (load errors + zero-duration tracks) as
* user-visible snackbar messages AND admin-inbox reports.
*
* Without this the player just silently skips the dead track — the
* worst kind of bug, because the user can't tell "this file is
* broken" from "the app is flaky".
* Without the snackbar the player just silently skips the dead track —
* the worst kind of bug, because the user can't tell "this file is
* broken" from "the app is flaky". Without the admin report the
* operator never finds out the track is bad and the next user hits
* the same wall.
*
* Pattern mirrors Flutter's `playback_error_reporter.dart`: collect
* per-error events from [PlayerController.playbackErrorEvents],
* debounce in a 2s window, and emit "Couldn't play 'X' — skipping"
* for a single error or "Skipped N unplayable tracks" when a burst
* lands inside the window. Stops the user from seeing a stack of N
* toasts on a network blip that fails N tracks in a row.
* The snackbar text mirrors Flutter's `playback_error_reporter.dart`:
* collect [PlayerController.playbackErrorEvents], debounce in a 2s
* window, emit "Couldn't play 'X' — skipping" for a single error or
* "Skipped N unplayable tracks" when a burst lands inside the window.
*
* The server report fires per event (no debounce) via
* [PlaybackErrorRepository], which handles the offline-first
* MutationQueue fallback per the standing rule for server writes.
*
* ShellScaffold collects [messages] into its existing snackbar host
* so the reporter doesn't need its own UI surface. Constructed at
@@ -35,6 +39,7 @@ private const val DEBOUNCE_MS = 2_000L
@Singleton
class PlaybackErrorReporter @Inject constructor(
private val playerController: PlayerController,
private val repository: PlaybackErrorRepository,
@ApplicationScope private val scope: CoroutineScope,
) {
private val outChannel = Channel<String>(Channel.BUFFERED)
@@ -46,8 +51,11 @@ class PlaybackErrorReporter @Inject constructor(
scope.launch {
val buffer = mutableListOf<String>()
var debounceJob: kotlinx.coroutines.Job? = null
playerController.playbackErrorEvents.collect { title ->
buffer.add(title)
playerController.playbackErrorEvents.collect { event ->
// Fire-and-forget the server report — repository handles
// success/queue branching so callers don't see throws.
scope.launch { repository.report(event) }
buffer.add(event.title)
debounceJob?.cancel()
debounceJob = scope.launch {
delay(DEBOUNCE_MS)
@@ -0,0 +1,78 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.cache.mutations.PlaybackErrorReportPayload
import retrofit2.Retrofit
import retrofit2.create
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Writes a [PlaybackErrorEvent] to the server. Follows the standing
* offline-first rule for server writes: try the POST first, fall back
* to the MutationQueue on transport failure so a tracked report is
* never lost.
*
* Reused client_id comes from [AuthStore.clientId] — the same UUID-
* per-install identifier the play-events reporter already sends, so
* support can correlate playback errors with the surrounding plays.
*/
@Singleton
class PlaybackErrorRepository @Inject constructor(
private val authStore: AuthStore,
private val mutationQueue: MutationQueue,
retrofit: Retrofit,
) {
private val api: PlaybackErrorsApi = retrofit.create()
/**
* Reports a playback failure. Returns [Outcome.ACCEPTED] on a 2xx,
* [Outcome.QUEUED] when the call was buffered to the MutationQueue
* for later replay. Never throws — callers don't need a try block.
*/
suspend fun report(event: PlaybackErrorEvent): Outcome {
val clientId = resolveClientId()
val payload = PlaybackErrorReportPayload(
trackId = event.trackId,
kind = event.kind,
detail = event.detail,
clientId = clientId,
)
return try {
api.report(
PlaybackErrorReportRequest(
trackId = payload.trackId,
kind = payload.kind,
detail = payload.detail,
clientId = payload.clientId,
),
)
Outcome.ACCEPTED
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow — see LikesRepository.toggleLike for
// the same offline-first rationale.
mutationQueue.enqueuePlaybackErrorReport(payload)
Outcome.QUEUED
}
}
/**
* Returns the persisted client id, generating + persisting one on
* first read. Mirrors [PlayEventsReporter.resolveClientId] — the
* value is shared across all client-id-carrying surfaces.
*/
private fun resolveClientId(): String {
authStore.clientId.value?.let { return it }
val fresh = UUID.randomUUID().toString()
authStore.setClientId(fresh)
return fresh
}
enum class Outcome { ACCEPTED, QUEUED }
}
@@ -68,13 +68,32 @@ class PlayerController @Inject constructor(
/**
* Per-error events for [com.fabledsword.minstrel.player.PlaybackErrorReporter].
* Emits the failing track's title (or `"Track"` when unknown) each
* time Media3's `onPlayerError` fires — not bound to the lifetime of
* any UI subscriber so a torn-down screen doesn't drop events.
* Conflated channel so backpressure can't stall the player loop.
* Fires twice: once when Media3's `onPlayerError` surfaces a load
* failure (decoder, transport, EOS), and once when the player
* reaches STATE_READY with a duration of zero / TIME_UNSET — the
* "track loaded but has no audio" case the user sees as the
* player sitting frozen on a track.
*
* Drift #561: this was originally Channel.CONFLATED, which silently
* dropped every emission except the latest each time the reporter
* loop wasn't actively reading. A network blip that failed 5
* tracks back-to-back would surface only the last failure to the
* snackbar (the "Skipped 5 unplayable tracks" coalescing path was
* dead code) AND only POST one playback_errors row to the admin
* inbox instead of 5. Buffered so every burst event reaches the
* reporter; default capacity is 64 which is well above any real
* burst rate.
*/
private val playbackErrorEventsChannel = Channel<String>(Channel.CONFLATED)
val playbackErrorEvents: Flow<String> = playbackErrorEventsChannel.receiveAsFlow()
private val playbackErrorEventsChannel = Channel<PlaybackErrorEvent>(Channel.BUFFERED)
val playbackErrorEvents: Flow<PlaybackErrorEvent> = playbackErrorEventsChannel.receiveAsFlow()
/**
* Tracks which queue index has already been evaluated for the
* zero-duration / load-failed checks so STATE_READY firing
* repeatedly (after every seek, pause/resume) doesn't re-emit
* the same error event. Reset on each onMediaItemTransition.
*/
private var lastEvaluatedItemIndex: Int = -1
/**
* Stable queue snapshot kept in sync with the player's MediaItems —
@@ -83,10 +102,30 @@ class PlayerController @Inject constructor(
*/
private var queueRefs: List<TrackRef> = emptyList()
/**
* Completes when [mediaController] is non-null and the listener has
* been attached. Used by [awaitReady] so cold-boot callers like
* [ResumeController] can wait for the IPC bind before calling
* transport methods that would otherwise no-op silently. Drift
* #562 caught the race where a fast restore() landed before the
* MediaSessionService connection was up, silently dropping the
* restored queue.
*/
private val readyDeferred = kotlinx.coroutines.CompletableDeferred<Unit>()
init {
scope.launch { connectAndObserve() }
}
/**
* Suspends until the MediaController binding to
* [MinstrelPlayerService] is up and a Player.Listener is attached,
* so a subsequent transport call (setQueue, startRadio, playNext,
* …) will actually reach the player rather than being silently
* swallowed by the `mediaController ?: return` guards.
*/
suspend fun awaitReady(): Unit = readyDeferred.await()
// ── Transport (no-op until the controller is connected) ──────────────
fun play() { mediaController?.play() }
@@ -125,18 +164,32 @@ class PlayerController @Inject constructor(
}
/**
* Replace the queue with [tracks] and start playing from [initialIndex].
* Replace the queue with [tracks] starting at [initialIndex].
* [source] tags the queue with its origin (e.g. "for_you") so the
* server-side rotation reporter can advance it; carried in
* MediaItem extras.
*
* [autoplay] controls whether playback starts immediately. The
* default is true to preserve the "user pressed play on a tile"
* UX every existing caller relies on. Drift #560: cold-boot
* resume passes autoplay = false so the persisted queue is
* restored without auto-starting playback after the user has
* been away from the app — starting audio on cold launch was
* surprising for users who had paused mid-track before
* backgrounding.
*/
fun setQueue(tracks: List<TrackRef>, initialIndex: Int = 0, source: String? = null) {
fun setQueue(
tracks: List<TrackRef>,
initialIndex: Int = 0,
source: String? = null,
autoplay: Boolean = true,
) {
val controller = mediaController ?: return
queueRefs = tracks
val items = tracks.map { it.toMediaItem(source) }
controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
controller.prepare()
controller.play()
if (autoplay) controller.play()
}
/**
@@ -166,17 +219,84 @@ class PlayerController @Inject constructor(
}
/**
* Seed a fresh radio queue from [trackId] and start playback.
* Replaces the existing queue. The `source` tag is "radio:<id>"
* so the server-side rotation reporter can distinguish radio
* plays from album / playlist plays. No-op on an empty server
* response; throws on transport / non-2xx for the caller to
* surface in a snackbar.
* Seed a fresh radio queue from [trackId]. The `source` tag is
* "radio:<id>" so the server-side rotation reporter can
* distinguish radio plays from album / playlist plays.
*
* Two modes:
* - Nothing in the queue: start the radio cold from track 0
* (the seed plays first, recommendations follow).
* - Queue already populated: do NOT interrupt — keep the current
* track playing as-is, trim every upcoming track, and append
* the radio results after the current item. If the seed is the
* currently playing track (the common "tap Start Radio on the
* song I'm listening to" case), drop it from the appended list
* so it doesn't immediately repeat. This is an intentional
* divergence from Flutter's `playerActions.startRadio`, which
* always calls `playTracks` and restarts playback from 0.
*
* No-op on an empty server response; throws on transport / non-2xx
* for the caller to surface in a snackbar.
*/
suspend fun startRadio(trackId: String) {
val controller = mediaController ?: return
val tracks = radio.seed(trackId)
if (tracks.isEmpty()) return
setQueue(tracks, initialIndex = 0, source = "radio:$trackId")
val source = "radio:$trackId"
if (controller.mediaItemCount == 0) {
setQueue(tracks, initialIndex = 0, source = source)
} else {
appendRadioToQueue(controller, tracks, trackId, source)
}
}
/**
* Mid-queue radio insert. The current track keeps playing; every
* upcoming item is removed; the radio results are appended after.
* Drops the seed from the appended list when it matches the
* currently-playing track so it doesn't immediately repeat.
*/
private fun appendRadioToQueue(
controller: MediaController,
tracks: List<TrackRef>,
seedTrackId: String,
source: String,
) {
val currentIdx = controller.currentMediaItemIndex
val currentTrack = queueRefs.getOrNull(currentIdx)
val toAppend = if (currentTrack?.id == seedTrackId) tracks.drop(1) else tracks
if (toAppend.isEmpty()) return
val nextIdx = currentIdx + 1
if (nextIdx < controller.mediaItemCount) {
controller.removeMediaItems(nextIdx, controller.mediaItemCount)
}
queueRefs = queueRefs.take(nextIdx) + toAppend
controller.addMediaItems(toAppend.map { it.toMediaItem(source) })
}
/**
* When a STATE_READY transition for a freshly-loaded item reports
* a zero / TIME_UNSET duration, fire a `zero_duration` error event
* and advance past the dead track. Otherwise no-op.
*/
private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) {
val current = queueRefs.getOrNull(idx) ?: return
val duration = controller.duration
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
if (!isZeroDuration) return
playbackErrorEventsChannel.trySend(
PlaybackErrorEvent(
trackId = current.id,
kind = "zero_duration",
title = current.title.ifEmpty { "Track" },
detail = "duration=$duration",
),
)
if (controller.hasNextMediaItem()) {
controller.seekToNextMediaItem()
} else {
controller.stop()
}
}
// ── Internal: async connect + Listener-driven UI state sync ──────────
@@ -196,16 +316,43 @@ class PlayerController @Inject constructor(
return
}
mediaController = controller
// Drift #562: signal awaitReady() callers (ResumeController et al.)
// that the controller is bound. Listener is attached below in
// the same coroutine so by the time downstream code runs after
// awaitReady, the Player.Listener is wired too.
if (!readyDeferred.isCompleted) readyDeferred.complete(Unit)
startPositionPolling(controller)
controller.addListener(
object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
val title = queueRefs
.getOrNull(controller.currentMediaItemIndex)
?.title
?.takeIf { it.isNotEmpty() }
?: "Track"
playbackErrorEventsChannel.trySend(title)
val current = queueRefs.getOrNull(controller.currentMediaItemIndex)
val title = current?.title?.takeIf { it.isNotEmpty() } ?: "Track"
val trackId = current?.id ?: return
playbackErrorEventsChannel.trySend(
PlaybackErrorEvent(
trackId = trackId,
kind = "load_failed",
title = title,
detail = error.message,
),
)
}
override fun onMediaItemTransition(
mediaItem: androidx.media3.common.MediaItem?,
reason: Int,
) {
// Reset the per-item evaluation guard so the new
// item's STATE_READY transition gets a fresh check.
lastEvaluatedItemIndex = -1
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState != Player.STATE_READY) return
val idx = controller.currentMediaItemIndex
if (idx == lastEvaluatedItemIndex) return
lastEvaluatedItemIndex = idx
handleZeroDurationIfNeeded(controller, idx)
}
override fun onEvents(player: Player, events: Player.Events) {
@@ -334,3 +481,19 @@ class PlayerController @Inject constructor(
// surfaces are driven by Media3's MediaSession callbacks separately;
// they don't need this poll.
private const val POSITION_POLL_INTERVAL_MS = 500L
/**
* Structured playback failure event for [PlaybackErrorReporter]. Drives
* both the user-facing snackbar ("Couldn't play X — skipping") and the
* /api/playback-errors admin inbox.
*
* `kind` is one of "zero_duration" / "load_failed" / "stalled" matching
* the server's CHECK constraint. `detail` is free-text (the Media3
* error message, the observed duration value, etc.).
*/
data class PlaybackErrorEvent(
val trackId: String,
val kind: String,
val title: String,
val detail: String? = null,
)
@@ -54,12 +54,22 @@ class ResumeController @Inject constructor(
.getOrNull()
?.takeIf { it.tracks.isNotEmpty() }
?: return
// Drift #562: wait for the MediaController binding before
// calling setQueue, otherwise restore() racing the IPC
// handshake silently drops the persisted queue (PlayerController
// setQueue early-returns when mediaController is null).
playerController.awaitReady()
playerController.setQueue(
tracks = payload.tracks,
initialIndex = payload.queueIndex
.coerceAtLeast(0)
.coerceAtMost(payload.tracks.lastIndex),
source = payload.source,
// Drift #560: cold-boot resume must NOT auto-start
// playback. The user has been away from the app; starting
// audio on cold launch is surprising. They tap play to
// resume. Queue + position are restored; transport is idle.
autoplay = false,
)
// PositionMs not seeked yet — Player connection may still be
// establishing; the seek call would no-op. Acceptable to start
@@ -59,9 +59,14 @@ fun rememberDominantColor(coverUrl: String?): Color {
val palette = withContext(Dispatchers.Default) {
Palette.from(bitmap).generate()
}
val swatch = palette.vibrantSwatch
?: palette.mutedSwatch
?: palette.dominantSwatch
// Always use the dominant (majority-by-pixel-count) swatch.
// The cover art is the main feature of this view; a vibrant
// accent on the cover should pop against the background, not
// be matched by it. Previously we tried vibrant first, which
// picked the highest-saturation swatch even when it covered
// a tiny fraction of the cover — small bright accents made
// the gradient feel disconnected from the actual image.
val swatch = palette.dominantSwatch
if (swatch != null) {
extracted = Color(swatch.rgb)
}
@@ -12,12 +12,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -31,7 +33,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Slider
import androidx.compose.ui.unit.DpSize
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.graphics.Brush
@@ -85,6 +86,8 @@ private const val COVER_MAX_WIDTH_DP = 320
private const val TRANSPORT_ICON_DP = 36
private const val PLAY_PAUSE_ICON_DP = 56
private const val POP_GRACE_MS = 500L
private val SCRUB_TRACK_HEIGHT_DP = 4.dp
private val SCRUB_TRACK_CORNER_DP = 2.dp
// Vertical drag-down threshold (in pixels) past which the gesture
// pops the player. Matches Flutter's 80px threshold in spirit;
@@ -189,16 +192,32 @@ private fun rememberDragDismissConnection(
object : NestedScrollConnection {
private var accumulated = 0f
// One-shot latch. popBackStack is async — between the first
// dismissal and the screen actually leaving the composition,
// the user's finger is still down and more onPostScroll
// frames arrive. Without this guard the accumulator rebuilds
// and onDismiss() fires a second time, popping the screen
// BENEATH NowPlaying. If that leaves the back stack empty
// the NavHost renders nothing → black screen on resume.
private var dismissed = false
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
if (source != NestedScrollSource.UserInput) return Offset.Zero
return if (available.y > 0f) {
// After dismissal, eat all remaining drag so the
// scrollable doesn't paint over-scroll deltas during the
// pop transition. Source guard folded into the `else if`
// chain so the function stays under detekt's ReturnCount
// ceiling of 2.
if (dismissed) return available
return if (source != NestedScrollSource.UserInput) {
Offset.Zero
} else if (available.y > 0f) {
accumulated += available.y
if (accumulated >= thresholdPx) {
accumulated = 0f
dismissed = true
onDismiss()
}
Offset(0f, available.y)
@@ -209,6 +228,7 @@ private fun rememberDragDismissConnection(
}
override suspend fun onPreFling(available: Velocity): Velocity {
if (dismissed) return available
accumulated = 0f
return Velocity.Zero
}
@@ -458,18 +478,21 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
modifier = Modifier.fillMaxWidth(),
colors = sliderColors,
interactionSource = interactionSource,
// Slim 10dp thumb shrinks the scrubber's visual weight. M3
// default is 20dp; halving keeps it tappable (Slider's own
// 48dp hit slop is unchanged) but lets it recede into the UI.
// Track left at default — the colour pin alone already
// matches Flutter's slate look.
// Plain filled circle to match the web client's `<input
// type="range" accent-color>` thumb — flat, no state-layer
// ring, no border. M3's SliderDefaults.Thumb paints a state
// layer halo on press; we drop it for visual parity. Slider's
// 48dp hit slop still applies, so tapability is unchanged.
thumb = {
SliderDefaults.Thumb(
interactionSource = interactionSource,
colors = sliderColors,
thumbSize = DpSize(10.dp, 10.dp),
Box(
modifier = Modifier
.size(14.dp)
.clip(CircleShape)
.background(accent),
)
},
// Custom 4dp rounded track — see ScrubTrack for rationale.
track = { _ -> ScrubTrack(fraction = fraction, accent = accent) },
)
Row(
modifier = Modifier.fillMaxWidth(),
@@ -488,6 +511,34 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
}
}
/**
* Custom 4dp rounded scrubber track. M3's default Track is 16dp tall
* and reads as a heavy pill rather than a measurement line; making
* the thumb visibly taller than the track (14dp thumb on a 4dp bar)
* restores the "handle on a string" cue your eye reads as "tool,
* draggable." Also drops M3's stop indicator dot, which the web
* scrubber doesn't have. Extracted so [ScrubberRow] stays under
* detekt's LongMethod ceiling.
*/
@Composable
private fun ScrubTrack(fraction: Float, accent: Color) {
Box(modifier = Modifier.fillMaxWidth().height(SCRUB_TRACK_HEIGHT_DP)) {
Box(
modifier = Modifier
.matchParentSize()
.clip(RoundedCornerShape(SCRUB_TRACK_CORNER_DP))
.background(MaterialTheme.colorScheme.surfaceVariant),
)
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.fillMaxHeight()
.clip(RoundedCornerShape(SCRUB_TRACK_CORNER_DP))
.background(accent),
)
}
}
@Composable
private fun TransportRow(
isPlaying: Boolean,
@@ -227,7 +227,16 @@ class PlaylistDetailViewModel @Inject constructor(
val refs = tracks.toPlayableTrackRefs()
if (refs.isEmpty()) return
val startIndex = refs.indexOfFirst { it.id == startTrackId }.coerceAtLeast(0)
player.setQueue(refs, initialIndex = startIndex, source = "playlist:$playlistId")
// Drift #564: for refreshable system playlists, send the bare
// systemVariant string so the rotation matcher
// (systemPlaylistSources in playevents/writer.go) sees the
// play. User playlists keep the "playlist:<id>" tag for
// attribution but it doesn't trigger rotation (intentional —
// rotation only applies to system mixes).
val playlist = (internal.value as? PlaylistDetailUiState.Success)?.detail?.playlist
val source = playlist?.systemVariant?.takeIf { playlist.refreshable }
?: "playlist:$playlistId"
player.setQueue(refs, initialIndex = startIndex, source = source)
}
}
@@ -288,7 +297,10 @@ private fun PlaylistDetailContent(
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
navController: NavHostController,
) {
Crossfade(targetState = state, label = "playlist-detail") { s -> when (s) {
// Crossfade keyed on `state::class` so refreshes that emit a
// fresh Success (same kind, new detail) don't re-fade the body;
// only Loading ↔ Success ↔ Error transitions animate.
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
is PlaylistDetailUiState.Loading ->
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.requests.data.CancelOutcome
import com.fabledsword.minstrel.requests.data.RequestsRepository
import com.fabledsword.minstrel.shared.UiState
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -71,7 +72,7 @@ class RequestsViewModel @Inject constructor(
}
viewModelScope.launch {
try {
val (_, updated) = repository.cancel(id)
val (outcome, updated) = repository.cancel(id)
if (updated != null) {
internal.update { state ->
if (state !is UiState.Success) {
@@ -87,12 +88,20 @@ class RequestsViewModel @Inject constructor(
}
}
}
// Refresh fetches the canonical list whether the cancel
// went through live (Synced) or got enqueued (Queued).
// The Queued case shows the optimistic removal until the
// replayer drains; the next list fetch surfaces the
// server's canonical view.
refresh()
// Drift #577: only refetch on the Synced outcome. The
// Queued path is offline by definition — the optimistic
// removal at the top of cancel() is correct, and a
// refresh() call here would either (a) get the
// not-yet-delivered cancelled row back from the server
// and snap it into the list (confusing), or (b) fail
// with a transport error and flip the whole screen to
// UiState.Error, making the user think the cancel
// failed. The mutation queue will replay the cancel
// when connectivity returns; the next on-screen refresh
// (pull-to-refresh, navigation back) reconciles.
if (outcome == CancelOutcome.Synced) {
refresh()
}
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
@@ -45,12 +45,26 @@ class AuthCookieInterceptorTest {
}
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
// BaseUrlInterceptor rewrites placeholder.invalid → mock server.
// AuthCookieInterceptor scopes its attach + clear behavior to
// the placeholder host so external Coil image fetches don't get
// the Minstrel session cookie attached and don't trigger a
// session-clear on 401. Tests issue requests to
// http://placeholder.invalid/... so the auth interceptor sees
// the in-scope host, and BaseUrlInterceptor (running AFTER, so
// the auth interceptor sees the original host) rewrites the URL
// before transport.
authStore.setBaseUrl(server.url("/").toString())
client =
OkHttpClient.Builder()
.addInterceptor(AuthCookieInterceptor(authStore))
.addInterceptor(BaseUrlInterceptor(authStore))
.build()
}
private fun placeholderUrl(path: String): String =
"http://${BaseUrlInterceptor.PLACEHOLDER_HOST}$path"
@AfterEach
fun teardown() {
server.shutdown()
@@ -61,7 +75,7 @@ class AuthCookieInterceptorTest {
authStore.setSessionCookie("session=abc123")
server.enqueue(MockResponse().setResponseCode(200))
client.newCall(Request.Builder().url(server.url("/api/test")).build()).execute()
client.newCall(Request.Builder().url(placeholderUrl("/api/test")).build()).execute()
val recorded = server.takeRequest()
assertEquals("session=abc123", recorded.getHeader("Cookie"))
@@ -72,7 +86,7 @@ class AuthCookieInterceptorTest {
authStore.setSessionCookie("session=expired")
server.enqueue(MockResponse().setResponseCode(401))
client.newCall(Request.Builder().url(server.url("/api/test")).build()).execute()
client.newCall(Request.Builder().url(placeholderUrl("/api/test")).build()).execute()
assertNull(authStore.sessionCookie.value)
}
@@ -85,7 +99,7 @@ class AuthCookieInterceptorTest {
.addHeader("Set-Cookie", "session=newvalue; Path=/; HttpOnly"),
)
client.newCall(Request.Builder().url(server.url("/api/login")).build()).execute()
client.newCall(Request.Builder().url(placeholderUrl("/api/login")).build()).execute()
assertEquals("session=newvalue", authStore.sessionCookie.value)
}
@@ -95,8 +109,41 @@ class AuthCookieInterceptorTest {
authStore.setSessionCookie("session=existing")
server.enqueue(MockResponse().setResponseCode(200))
client.newCall(Request.Builder().url(server.url("/api/anything")).build()).execute()
client.newCall(Request.Builder().url(placeholderUrl("/api/anything")).build()).execute()
assertEquals("session=existing", authStore.sessionCookie.value)
}
// Drift #568 regression guard: requests to external hosts must NOT
// carry the Minstrel session cookie even when the auth store has
// one. The shared OkHttpClient is also used by Coil for image
// fetches to artwork.musicbrainz.org and the like; leaking the
// session cookie to those hosts is a posture violation.
@Test
fun `does NOT attach cookie to non-placeholder host`() {
authStore.setSessionCookie("session=secret")
server.enqueue(MockResponse().setResponseCode(200))
// Request goes directly to the mock server's host:port — NOT
// through the placeholder sentinel — so the auth interceptor
// should pass it through untouched.
client.newCall(Request.Builder().url(server.url("/external/image.jpg")).build()).execute()
val recorded = server.takeRequest()
assertNull(recorded.getHeader("Cookie"))
}
// Drift #569 regression guard: a 401 from an external host must
// NOT wipe the user's Minstrel session. A misbehaving CDN or a
// Lidarr that's 401-ing on /MediaCover URLs used to silently sign
// the user out.
@Test
fun `does NOT clear cookie on 401 from non-placeholder host`() {
authStore.setSessionCookie("session=keep")
server.enqueue(MockResponse().setResponseCode(401))
client.newCall(Request.Builder().url(server.url("/external/image.jpg")).build()).execute()
assertEquals("session=keep", authStore.sessionCookie.value)
}
}
@@ -0,0 +1,130 @@
package com.fabledsword.minstrel.api
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
class BaseUrlInterceptorTest {
private lateinit var server: MockWebServer
private lateinit var authStore: AuthStore
@BeforeEach
fun setup() {
server = MockWebServer().apply { start() }
val dao =
mockk<AuthSessionDao> {
every { observe() } returns flowOf(null)
coEvery { get() } returns null
coEvery { upsert(any()) } returns Unit
coEvery { setSessionCookie(any()) } returns Unit
coEvery { setBaseUrl(any()) } returns Unit
}
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
}
@AfterEach
fun teardown() {
server.shutdown()
}
@Test
fun `rewrites placeholder host to live baseUrl host port and scheme`() {
// baseUrl points at the mock server; outbound request targets the
// placeholder sentinel — interceptor should rewrite to the mock.
authStore.setBaseUrl(server.url("/").toString())
server.enqueue(MockResponse().setResponseCode(200))
val client = OkHttpClient.Builder().addInterceptor(BaseUrlInterceptor(authStore)).build()
client.newCall(
Request.Builder().url("http://placeholder.invalid/api/test").build(),
).execute()
val recorded = server.takeRequest()
assertEquals("/api/test", recorded.path)
// MockWebServer's recorded Host header is the rewritten host:port
assertEquals("${server.hostName}:${server.port}", recorded.getHeader("Host"))
}
@Test
fun `passes external absolute URL through unchanged`() {
// Lidarr-surfaced artwork hosts (artwork.musicbrainz.org,
// coverartarchive.org) are absolute external URLs. The
// interceptor must NOT rewrite them to the Minstrel base —
// doing so produces 404s the user saw as missing Discover
// suggestion covers.
authStore.setBaseUrl(server.url("/").toString())
var seen: HttpUrl? = null
val client =
OkHttpClient.Builder()
.addInterceptor(BaseUrlInterceptor(authStore))
.addInterceptor { chain ->
seen = chain.request().url
Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("".toResponseBody())
.build()
}
.build()
client.newCall(
Request.Builder()
.url("https://artwork.musicbrainz.org/release-group/abc/front-250.jpg")
.build(),
).execute()
assertEquals("artwork.musicbrainz.org", seen?.host)
assertEquals("https", seen?.scheme)
assertEquals("/release-group/abc/front-250.jpg", seen?.encodedPath)
}
@Test
fun `falls through when stored baseUrl is unparseable`() {
// Empty / malformed baseUrl can happen between cold start and
// first AuthStore load; the interceptor should leave the
// request alone rather than throw.
authStore.setBaseUrl("not a url")
var seen: HttpUrl? = null
val client =
OkHttpClient.Builder()
.addInterceptor(BaseUrlInterceptor(authStore))
.addInterceptor { chain ->
seen = chain.request().url
Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("".toResponseBody())
.build()
}
.build()
client.newCall(
Request.Builder().url("http://placeholder.invalid/api/test").build(),
).execute()
assertEquals("placeholder.invalid", seen?.host)
}
}
+11
View File
@@ -15,6 +15,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/gc"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
@@ -161,6 +162,16 @@ func run() error {
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
go similarityWorker.Run(ctx)
// Start the GC worker. Runs every 1h and sweeps lifecycle tables
// that have no writer-side close path or retention policy:
// orphan play_events, stale play_sessions, expired
// scrobble_queue failures, stuck system_playlist_runs, expired
// password_resets. Each sweep is idempotent — a row that's
// already clean is a no-op. Addresses drift audit findings
// #565 #566 #567 #574 #575 (Scribe parent #552).
gcWorker := gc.NewWorker(pool, logger.With("component", "gc"))
go gcWorker.Run(ctx)
// Start the Lidarr reconciler worker. Per spec §M5a, polls pending Lidarr
// import requests and reconciles them against the library. Short-circuits
// to no-op when lidarr_config.enabled = false.
+28 -9
View File
@@ -69,15 +69,32 @@ func TestAdminLibraryCoverage_MixedRowsReturnCorrectBuckets(t *testing.T) {
sourceNone := "none"
sourceSidecar := "sidecar"
sourceMbcaa := "mbcaa"
sourceEmbedded := "embedded"
sourceTheaudiodb := "theaudiodb"
sourceDeezer := "deezer"
sourceLastfm := "lastfm"
mbid1 := "11111111-1111-1111-1111-111111111111"
mbid2 := "22222222-2222-2222-2222-222222222222"
mbid3 := "33333333-3333-3333-3333-333333333333"
mbid4 := "44444444-4444-4444-4444-444444444444"
mbid5 := "55555555-5555-5555-5555-555555555555"
mbid6 := "66666666-6666-6666-6666-666666666666"
mbid7 := "77777777-7777-7777-7777-777777777777"
// Cover every cover_art_source value the migrations allow so a future
// addition without updating the rollup query trips this test —
// regression guard for drift #557, the gap that hid #556 (deezer +
// lastfm omission introduced by migration 0020 but never wired into
// the rollup whitelist).
rows := []seed{
{title: "WithArtSidecar", source: &sourceSidecar, mbid: &mbid1}, // with_art
{title: "WithArtMbcaa", source: &sourceMbcaa, mbid: &mbid2}, // with_art
{title: "PendingHasMbid", source: nil, mbid: &mbid3}, // pending (eligible)
{title: "PendingNoMbid", source: nil, mbid: nil}, // pending + pending_no_mbid
{title: "Settled", source: &sourceNone, mbid: nil}, // settled (no mbid is fine here)
{title: "WithArtSidecar", source: &sourceSidecar, mbid: &mbid1}, // with_art
{title: "WithArtMbcaa", source: &sourceMbcaa, mbid: &mbid2}, // with_art
{title: "WithArtEmbedded", source: &sourceEmbedded, mbid: &mbid4}, // with_art
{title: "WithArtTheaudiodb", source: &sourceTheaudiodb, mbid: &mbid5}, // with_art
{title: "WithArtDeezer", source: &sourceDeezer, mbid: &mbid6}, // with_art
{title: "WithArtLastfm", source: &sourceLastfm, mbid: &mbid7}, // with_art
{title: "PendingHasMbid", source: nil, mbid: &mbid3}, // pending (eligible)
{title: "PendingNoMbid", source: nil, mbid: nil}, // pending + pending_no_mbid
{title: "Settled", source: &sourceNone, mbid: nil}, // settled (no mbid is fine here)
}
for _, s := range rows {
if _, err := pool.Exec(context.Background(), `
@@ -100,11 +117,13 @@ func TestAdminLibraryCoverage_MixedRowsReturnCorrectBuckets(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Total != 5 {
t.Errorf("Total = %d, want 5", resp.Total)
if resp.Total != 9 {
t.Errorf("Total = %d, want 9", resp.Total)
}
if resp.WithArt != 2 {
t.Errorf("WithArt = %d, want 2", resp.WithArt)
// Six with_art rows — one per valid cover_art_source value
// (sidecar, mbcaa, embedded, theaudiodb, deezer, lastfm).
if resp.WithArt != 6 {
t.Errorf("WithArt = %d, want 6", resp.WithArt)
}
if resp.Pending != 2 {
t.Errorf("Pending = %d, want 2", resp.Pending)
+7
View File
@@ -107,6 +107,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Delete("/quarantine/{track_id}", h.handleUnflag)
authed.Get("/quarantine/mine", h.handleListMyQuarantine)
// Client-reported playback errors (zero-duration tracks,
// load failures). Admin-only inbox; any user can report.
authed.Post("/playback-errors", h.handleReportPlaybackError)
// Self-hosted in-app update channel (#397). Auth-gated to
// prevent anonymous bandwidth abuse on the APK stream;
// /apk additionally per-user rate-limited.
@@ -127,6 +131,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/quarantine", h.handleListAdminQuarantine)
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
admin.Get("/playback-errors", h.handleListAdminPlaybackErrors)
admin.Post("/playback-errors/{id}/resolve", h.handleResolvePlaybackError)
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
+17 -7
View File
@@ -1,7 +1,6 @@
package api
import (
"encoding/json"
"errors"
"net/http"
@@ -9,6 +8,22 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
)
// handleGetMe returns the authenticated user's profile shape — id,
// username, display_name, email, is_admin. Mirrors the response shape
// of PUT /api/me/profile so the Android Settings → Profile screen
// (and Flutter's equivalent) can read its starting state from a GET
// before the user has done a first save.
//
// Drift #578 fix: this previously emitted the narrower UserView
// (id, username, is_admin only). Android's MeApi.getProfile() deser-
// ialised into MyProfileWire and saw display_name=null, email=null
// on every read — wiping the form fields on every Settings open.
// Saving from that blank state then submitted empty strings, which
// handleUpdateMyProfile treats as "clear to NULL" — DESTROYING the
// user's stored display_name + email. Returning the full profile
// shape here closes the loop. Web (User type narrower than this
// response) keeps working via TypeScript structural typing — the
// extra fields are ignored.
func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
@@ -18,10 +33,5 @@ func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) {
writeErr(w, apierror.InternalMsg("missing auth context", errors.New("missing auth context")))
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(UserView{
ID: user.ID,
Username: user.Username,
IsAdmin: user.IsAdmin,
})
writeJSON(w, http.StatusOK, profileViewFromUser(user))
}
+50 -1
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -21,13 +22,61 @@ func TestHandleGetMe_ReturnsAuthenticatedUser(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
var got UserView
var got meProfileResp
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Username != "test-alice" || !got.IsAdmin {
t.Errorf("user = %+v, want test-alice/admin", got)
}
if got.DisplayName != nil {
t.Errorf("DisplayName = %v, want nil for a user with no profile set", *got.DisplayName)
}
if got.Email != nil {
t.Errorf("Email = %v, want nil for a user with no profile set", *got.Email)
}
}
// Drift #578 regression guard: a user whose profile fields ARE set
// must see them in the /api/me response. Previously this handler
// emitted the narrower UserView so Android Settings → Profile saw
// display_name=null and email=null on every read, wiped the form
// fields, and submitting from that blank state destroyed the user's
// stored values via the "empty string clears to NULL" semantics of
// PUT /api/me/profile.
func TestHandleGetMe_IncludesDisplayNameAndEmail(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
displayName := "Alice Liddell"
email := "alice@example.com"
updated, err := dbq.New(pool).UpdateUserProfile(context.Background(), dbq.UpdateUserProfileParams{
ID: user.ID,
DisplayName: &displayName,
Email: &email,
})
if err != nil {
t.Fatalf("UpdateUserProfile: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req = withUser(req, updated)
w := httptest.NewRecorder()
h.handleGetMe(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
var got meProfileResp
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.DisplayName == nil || *got.DisplayName != displayName {
t.Errorf("DisplayName = %v, want %q", got.DisplayName, displayName)
}
if got.Email == nil || *got.Email != email {
t.Errorf("Email = %v, want %q", got.Email, email)
}
}
func TestHandleGetMe_MissingContextReturns500(t *testing.T) {
+242
View File
@@ -0,0 +1,242 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Valid `kind` values. Mirrors the CHECK constraint in migration 0032
// — keep both lists in sync if you add a kind here, otherwise inserts
// pass the handler whitelist and trip the DB constraint instead.
var validPlaybackErrorKinds = map[string]struct{}{
"zero_duration": {},
"load_failed": {},
"stalled": {},
}
// Valid `resolution` values. Hidden / deleted / requested are stamped
// automatically when the admin clicks the matching action from the
// inbox; fixed / ignored are operator-driven (no action taken on the
// track itself). Mirrors the CHECK constraint in migration 0032.
var validPlaybackErrorResolutions = map[string]struct{}{
"hidden": {},
"deleted": {},
"requested": {},
"fixed": {},
"ignored": {},
}
// reportPlaybackErrorRequest is the body of POST /api/playback-errors.
// TrackID is a UUID string; Kind is one of [validPlaybackErrorKinds];
// Detail is optional free-text from the client (e.g. ExoPlayer error
// message); ClientID identifies the device/browser so support can
// correlate reports across surfaces.
type reportPlaybackErrorRequest struct {
TrackID string `json:"track_id"`
Kind string `json:"kind"`
Detail *string `json:"detail,omitempty"`
ClientID string `json:"client_id"`
}
// adminPlaybackErrorView is one row in the admin inbox response. The
// track / album / artist fields are joined so the SPA can render
// without a second round-trip per row.
type adminPlaybackErrorView struct {
ID string `json:"id"`
TrackID string `json:"track_id"`
UserID string `json:"user_id"`
Username string `json:"username"`
ClientID string `json:"client_id"`
Kind string `json:"kind"`
Detail *string `json:"detail,omitempty"`
OccurredAt string `json:"occurred_at"`
ResolvedAt *string `json:"resolved_at,omitempty"`
Resolution *string `json:"resolution,omitempty"`
TrackTitle string `json:"track_title"`
TrackFilePath string `json:"track_file_path"`
ArtistName string `json:"artist_name"`
AlbumTitle string `json:"album_title"`
AlbumID string `json:"album_id"`
}
// resolvePlaybackErrorRequest is the body of POST
// /api/admin/playback-errors/{id}/resolve. The resolution string is
// validated against [validPlaybackErrorResolutions].
type resolvePlaybackErrorRequest struct {
Resolution string `json:"resolution"`
}
// Pagination defaults for the admin list endpoint. 50 fits the
// admin inbox table on a laptop without scrolling; max 200 prevents
// a misbehaving client from asking for the whole table at once.
const (
defaultPlaybackErrorPageSize = 50
maxPlaybackErrorPageSize = 200
)
// handleReportPlaybackError implements POST /api/playback-errors.
// Any signed-in user can report; the handler validates kind + track
// existence and inserts a row for admin review.
func (h *handlers) handleReportPlaybackError(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
var req reportPlaybackErrorRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, apierror.BadRequest("bad_request", "invalid JSON body"))
return
}
trackID, ok := parseUUID(req.TrackID)
if !ok {
writeErr(w, apierror.BadRequest("bad_request", "invalid track_id"))
return
}
if _, ok := validPlaybackErrorKinds[req.Kind]; !ok {
writeErr(w, apierror.BadRequest("bad_request", "invalid kind"))
return
}
if req.ClientID == "" {
writeErr(w, apierror.BadRequest("bad_request", "client_id required"))
return
}
if _, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "track not found"})
return
}
h.logger.Error("api: playback_error: lookup track", "err", err)
writeErr(w, apierror.Internal(err))
return
}
row, err := dbq.New(h.pool).InsertPlaybackError(r.Context(), dbq.InsertPlaybackErrorParams{
TrackID: trackID,
UserID: user.ID,
ClientID: req.ClientID,
Kind: req.Kind,
Detail: req.Detail,
})
if err != nil {
h.logger.Error("api: playback_error: insert", "err", err)
writeErr(w, apierror.Internal(err))
return
}
writeJSON(w, http.StatusCreated, map[string]string{"id": uuidToString(row.ID)})
}
// handleListAdminPlaybackErrors implements GET
// /api/admin/playback-errors?resolved=false&offset=0&limit=50.
func (h *handlers) handleListAdminPlaybackErrors(w http.ResponseWriter, r *http.Request) {
resolved := r.URL.Query().Get("resolved") == "true"
limit := parsePlaybackErrorLimit(r.URL.Query().Get("limit"))
offset := parsePlaybackErrorOffset(r.URL.Query().Get("offset"))
rows, err := dbq.New(h.pool).ListAdminPlaybackErrors(r.Context(), dbq.ListAdminPlaybackErrorsParams{
ResolvedFilter: resolved,
Off: int32(offset),
Lim: int32(limit),
})
if err != nil {
h.logger.Error("admin: list playback_errors", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
out := make([]adminPlaybackErrorView, 0, len(rows))
for _, row := range rows {
view := adminPlaybackErrorView{
ID: uuidToString(row.ID),
TrackID: uuidToString(row.TrackID),
UserID: uuidToString(row.UserID),
Username: row.Username,
ClientID: row.ClientID,
Kind: row.Kind,
Detail: row.Detail,
OccurredAt: formatTimestamp(row.OccurredAt),
Resolution: row.Resolution,
TrackTitle: row.TrackTitle,
TrackFilePath: row.TrackFilePath,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
AlbumID: uuidToString(row.AlbumID),
}
if row.ResolvedAt.Valid {
ts := formatTimestamp(row.ResolvedAt)
view.ResolvedAt = &ts
}
out = append(out, view)
}
writeJSON(w, http.StatusOK, out)
}
// handleResolvePlaybackError implements POST
// /api/admin/playback-errors/{id}/resolve.
func (h *handlers) handleResolvePlaybackError(w http.ResponseWriter, r *http.Request) {
admin, ok := auth.UserFromContext(r.Context())
if !ok {
writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized")
return
}
id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
var req resolvePlaybackErrorRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "bad_request")
return
}
if _, ok := validPlaybackErrorResolutions[req.Resolution]; !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_resolution")
return
}
resolution := req.Resolution
row, err := dbq.New(h.pool).ResolvePlaybackError(r.Context(), dbq.ResolvePlaybackErrorParams{
ID: id,
ResolvedBy: admin.ID,
Resolution: &resolution,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeAdminJSONErr(w, http.StatusNotFound, "not_found")
return
}
h.logger.Error("admin: resolve playback_error", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
writeJSON(w, http.StatusOK, map[string]string{"id": uuidToString(row.ID)})
}
func parsePlaybackErrorLimit(raw string) int {
if raw == "" {
return defaultPlaybackErrorPageSize
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return defaultPlaybackErrorPageSize
}
if n > maxPlaybackErrorPageSize {
return maxPlaybackErrorPageSize
}
return n
}
func parsePlaybackErrorOffset(raw string) int {
if raw == "" {
return 0
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return 0
}
return n
}
+8 -5
View File
@@ -126,7 +126,8 @@ func (q *Queries) GetAlbumByID(ctx context.Context, id pgtype.UUID) (Album, erro
const getAlbumCoverageRollup = `-- name: GetAlbumCoverageRollup :one
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE cover_art_source IN ('sidecar','embedded','mbcaa','theaudiodb')) AS with_art,
COUNT(*) FILTER (WHERE cover_art_source IN
('sidecar','embedded','mbcaa','theaudiodb','deezer','lastfm')) AS with_art,
COUNT(*) FILTER (WHERE cover_art_source IS NULL) AS pending,
COUNT(*) FILTER (WHERE cover_art_source = 'none') AS settled,
COUNT(*) FILTER (WHERE cover_art_source IS NULL AND mbid IS NULL) AS pending_no_mbid
@@ -151,10 +152,12 @@ type GetAlbumCoverageRollupRow struct {
// Invariant: with_art + pending + settled = total. (pending_no_mbid is
// not part of the sum — it's a subset of pending, surfaced separately.)
//
// The IN list below ('sidecar','embedded','mbcaa','theaudiodb') must stay in sync
// with the cover_art_source CHECK constraint in migration
// 0016_album_cover_source.up.sql. If a new source value is added there
// without updating this query, with_art will silently undercount.
// The IN list below must stay in sync with the cover_art_source CHECK
// constraint — currently relaxed by migration 0020 to include 'deezer'
// and 'lastfm', and migration 0030 further relaxed it to any non-empty
// string. If a new source value is added without updating this query,
// with_art will silently undercount. Drift #556 caught the deezer +
// lastfm omission introduced by migration 0020.
func (q *Queries) GetAlbumCoverageRollup(ctx context.Context) (GetAlbumCoverageRollupRow, error) {
row := q.db.QueryRow(ctx, getAlbumCoverageRollup)
var i GetAlbumCoverageRollupRow
+126
View File
@@ -0,0 +1,126 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: gc.sql
package dbq
import (
"context"
)
const gcClosePlaySessionsWithNoRecentEvents = `-- name: GcClosePlaySessionsWithNoRecentEvents :execrows
UPDATE play_sessions
SET ended_at = COALESCE(last_event_at, started_at)
WHERE ended_at IS NULL
AND (
(track_count > 0 AND last_event_at < now() - INTERVAL '6 hours')
OR
(track_count = 0 AND started_at < now() - INTERVAL '1 hour')
)
`
// #565: play_sessions.ended_at was added but never populated by any
// writer. Close sessions whose last_event_at is older than 6h —
// treating that as "user moved on" the same way audio_service does
// after grace periods. Sessions with NO events (track_count = 0)
// older than 1h are also closed (stale handshakes from clients that
// never recorded a play). ended_at is set to last_event_at so the
// session's duration reads naturally.
func (q *Queries) GcClosePlaySessionsWithNoRecentEvents(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcClosePlaySessionsWithNoRecentEvents)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const gcCloseStalePlayEvents = `-- name: GcCloseStalePlayEvents :execrows
UPDATE play_events
SET ended_at = COALESCE(
started_at + (duration_played_ms * INTERVAL '1 millisecond'),
now()
)
WHERE ended_at IS NULL
AND started_at < now() - INTERVAL '24 hours'
`
// Background garbage-collector / lifecycle queries. All five address
// drift findings from the 2026-06-02 audit (Scribe parent #552):
// #565 #566 #567 #574 #575. Sweeper runs on a long tick (default 1h)
// so per-query cost is amortised; each is idempotent (re-running on
// already-closed/-deleted rows is a no-op).
// #566: play_events rows opened more than 24h ago that never got a
// play_ended. Client crashed mid-track, network dropped, etc. We
// synthesize ended_at = started_at + duration_played_ms when present,
// otherwise leave duration_played_ms null and stamp ended_at = now()
// so the row stops looking "open" for downstream queries that filter
// ended_at IS NULL. Doesn't touch was_skipped (we genuinely don't
// know if the user skipped).
func (q *Queries) GcCloseStalePlayEvents(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcCloseStalePlayEvents)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const gcDeleteExpiredPasswordResets = `-- name: GcDeleteExpiredPasswordResets :execrows
DELETE FROM password_resets
WHERE (used_at IS NOT NULL AND used_at < now() - INTERVAL '7 days')
OR (used_at IS NULL AND expires_at < now() - INTERVAL '1 hour')
`
// #575: password_resets accumulates expired + used rows forever. The
// validation path already rejects them; this just keeps the table
// from growing unbounded. Used rows are kept for 7 days for audit;
// unused expired rows go immediately.
func (q *Queries) GcDeleteExpiredPasswordResets(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcDeleteExpiredPasswordResets)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const gcExpireScrobbleQueueFailedRows = `-- name: GcExpireScrobbleQueueFailedRows :execrows
DELETE FROM scrobble_queue
WHERE status = 'failed'
AND enqueued_at < now() - INTERVAL '14 days'
`
// #567: scrobble_queue rows that have been in status='failed' for
// more than 14 days. The worker stops retrying after maxAttempts;
// failed rows accumulate forever otherwise. CASCADE from play_events
// already drops the row when the underlying event is deleted, so this
// only handles persistent failures (token revoked, etc.).
func (q *Queries) GcExpireScrobbleQueueFailedRows(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcExpireScrobbleQueueFailedRows)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const gcResetStuckSystemPlaylistRuns = `-- name: GcResetStuckSystemPlaylistRuns :execrows
UPDATE system_playlist_runs
SET in_flight = false,
last_error = COALESCE(last_error, 'stuck-row auto-reset by gc')
WHERE in_flight = true
AND last_run_at < now() - INTERVAL '10 minutes'
`
// #574: system_playlist_runs.in_flight = true can wedge on a
// goroutine panic 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 blocks all future regens for that user.
// Reset rows where last_run_at is older than 10 minutes (regens
// shouldn't take that long).
func (q *Queries) GcResetStuckSystemPlaylistRuns(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcResetStuckSystemPlaylistRuns)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
+13
View File
@@ -383,6 +383,19 @@ type PlaySession struct {
ClientID *string
}
type PlaybackError struct {
ID pgtype.UUID
TrackID pgtype.UUID
UserID pgtype.UUID
ClientID string
Kind string
Detail *string
OccurredAt pgtype.Timestamptz
ResolvedAt pgtype.Timestamptz
ResolvedBy pgtype.UUID
Resolution *string
}
type Playlist struct {
ID pgtype.UUID
UserID pgtype.UUID
+210
View File
@@ -0,0 +1,210 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: playback_errors.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const insertPlaybackError = `-- name: InsertPlaybackError :one
INSERT INTO playback_errors (track_id, user_id, client_id, kind, detail)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, track_id, user_id, client_id, kind, detail, occurred_at,
resolved_at, resolved_by, resolution
`
type InsertPlaybackErrorParams struct {
TrackID pgtype.UUID
UserID pgtype.UUID
ClientID string
Kind string
Detail *string
}
// Records a client-reported playback failure. The handler validates
// the kind enum before this runs; the CHECK constraint is a
// belt-and-braces guard.
func (q *Queries) InsertPlaybackError(ctx context.Context, arg InsertPlaybackErrorParams) (PlaybackError, error) {
row := q.db.QueryRow(ctx, insertPlaybackError,
arg.TrackID,
arg.UserID,
arg.ClientID,
arg.Kind,
arg.Detail,
)
var i PlaybackError
err := row.Scan(
&i.ID,
&i.TrackID,
&i.UserID,
&i.ClientID,
&i.Kind,
&i.Detail,
&i.OccurredAt,
&i.ResolvedAt,
&i.ResolvedBy,
&i.Resolution,
)
return i, err
}
const listAdminPlaybackErrors = `-- name: ListAdminPlaybackErrors :many
SELECT
pe.id AS id,
pe.track_id AS track_id,
pe.user_id AS user_id,
u.username AS username,
pe.client_id AS client_id,
pe.kind AS kind,
pe.detail AS detail,
pe.occurred_at AS occurred_at,
pe.resolved_at AS resolved_at,
pe.resolution AS resolution,
t.title AS track_title,
t.file_path AS track_file_path,
ar.name AS artist_name,
al.title AS album_title,
al.id AS album_id
FROM playback_errors pe
JOIN users u ON u.id = pe.user_id
JOIN tracks t ON t.id = pe.track_id
JOIN albums al ON al.id = t.album_id
JOIN artists ar ON ar.id = t.artist_id
WHERE ($1::bool AND pe.resolved_at IS NOT NULL)
OR (NOT $1::bool AND pe.resolved_at IS NULL)
ORDER BY pe.occurred_at DESC
LIMIT $3::int OFFSET $2::int
`
type ListAdminPlaybackErrorsParams struct {
ResolvedFilter bool
Off int32
Lim int32
}
type ListAdminPlaybackErrorsRow struct {
ID pgtype.UUID
TrackID pgtype.UUID
UserID pgtype.UUID
Username string
ClientID string
Kind string
Detail *string
OccurredAt pgtype.Timestamptz
ResolvedAt pgtype.Timestamptz
Resolution *string
TrackTitle string
TrackFilePath string
ArtistName string
AlbumTitle string
AlbumID pgtype.UUID
}
// Admin inbox query. Returns one row per playback_errors row joined
// with track / album / artist metadata so the SPA can render without
// a second round-trip. Filter by resolved status via the sqlc.arg —
// pass true for the Resolved tab, false for Unresolved (the default).
func (q *Queries) ListAdminPlaybackErrors(ctx context.Context, arg ListAdminPlaybackErrorsParams) ([]ListAdminPlaybackErrorsRow, error) {
rows, err := q.db.Query(ctx, listAdminPlaybackErrors, arg.ResolvedFilter, arg.Off, arg.Lim)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAdminPlaybackErrorsRow
for rows.Next() {
var i ListAdminPlaybackErrorsRow
if err := rows.Scan(
&i.ID,
&i.TrackID,
&i.UserID,
&i.Username,
&i.ClientID,
&i.Kind,
&i.Detail,
&i.OccurredAt,
&i.ResolvedAt,
&i.Resolution,
&i.TrackTitle,
&i.TrackFilePath,
&i.ArtistName,
&i.AlbumTitle,
&i.AlbumID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const resolveAllPlaybackErrorsForTrack = `-- name: ResolveAllPlaybackErrorsForTrack :execrows
UPDATE playback_errors
SET resolved_at = now(),
resolved_by = $2,
resolution = $3
WHERE track_id = $1 AND resolved_at IS NULL
`
type ResolveAllPlaybackErrorsForTrackParams struct {
TrackID pgtype.UUID
ResolvedBy pgtype.UUID
Resolution *string
}
// Bulk-resolve every unresolved error for a track when an admin acts
// on it from a non-inbox surface (existing quarantine flow, etc.).
// Returns the affected row count so the caller can surface "N reports
// auto-resolved" in the response if useful.
func (q *Queries) ResolveAllPlaybackErrorsForTrack(ctx context.Context, arg ResolveAllPlaybackErrorsForTrackParams) (int64, error) {
result, err := q.db.Exec(ctx, resolveAllPlaybackErrorsForTrack, arg.TrackID, arg.ResolvedBy, arg.Resolution)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const resolvePlaybackError = `-- name: ResolvePlaybackError :one
UPDATE playback_errors
SET resolved_at = now(),
resolved_by = $2,
resolution = $3
WHERE id = $1
RETURNING id, track_id, user_id, client_id, kind, detail, occurred_at,
resolved_at, resolved_by, resolution
`
type ResolvePlaybackErrorParams struct {
ID pgtype.UUID
ResolvedBy pgtype.UUID
Resolution *string
}
// Marks a single error resolved. Idempotent over (id, resolution) —
// a second resolve with the same resolution is a no-op rewrite. Returns
// the row so the handler can echo it. The CHECK constraint enforces
// the resolution enum.
func (q *Queries) ResolvePlaybackError(ctx context.Context, arg ResolvePlaybackErrorParams) (PlaybackError, error) {
row := q.db.QueryRow(ctx, resolvePlaybackError, arg.ID, arg.ResolvedBy, arg.Resolution)
var i PlaybackError
err := row.Scan(
&i.ID,
&i.TrackID,
&i.UserID,
&i.ClientID,
&i.Kind,
&i.Detail,
&i.OccurredAt,
&i.ResolvedAt,
&i.ResolvedBy,
&i.Resolution,
)
return i, err
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS playback_errors;
@@ -0,0 +1,41 @@
-- Client-reported playback errors. Populated by clients when a track
-- fails to play (zero duration, decode error, etc.); surfaced in the
-- admin /admin/playback-errors inbox so the operator can hide / delete
-- / re-request the offending track.
--
-- resolved_at + resolved_by + resolution are NULL until an admin acts
-- on the row. Auto-resolved by the client when the admin clicks Hide /
-- Delete / Re-request from the inbox row, with the resolution string
-- recording which path was taken. The partial index keeps the
-- unresolved-queue lookup fast even as resolved history accumulates.
--
-- CHECK constraints on kind/resolution gate the enum values so client
-- typos can't pollute the data (per the standing rule about
-- enum-CHECK whitelists needing migrations).
CREATE TABLE playback_errors (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id text NOT NULL,
kind text NOT NULL,
detail text,
occurred_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz,
resolved_by uuid REFERENCES users(id),
resolution text,
CONSTRAINT playback_errors_kind_check
CHECK (kind IN ('zero_duration', 'load_failed', 'stalled')),
CONSTRAINT playback_errors_resolution_check
CHECK (resolution IS NULL OR resolution IN
('hidden', 'deleted', 'requested', 'fixed', 'ignored')),
CONSTRAINT playback_errors_resolved_consistency
CHECK ((resolved_at IS NULL) = (resolution IS NULL))
);
CREATE INDEX idx_playback_errors_unresolved
ON playback_errors (occurred_at DESC)
WHERE resolved_at IS NULL;
CREATE INDEX idx_playback_errors_track
ON playback_errors (track_id);
+8 -5
View File
@@ -156,13 +156,16 @@ SELECT a.id AS album_id,
-- Invariant: with_art + pending + settled = total. (pending_no_mbid is
-- not part of the sum — it's a subset of pending, surfaced separately.)
--
-- The IN list below ('sidecar','embedded','mbcaa','theaudiodb') must stay in sync
-- with the cover_art_source CHECK constraint in migration
-- 0016_album_cover_source.up.sql. If a new source value is added there
-- without updating this query, with_art will silently undercount.
-- The IN list below must stay in sync with the cover_art_source CHECK
-- constraint — currently relaxed by migration 0020 to include 'deezer'
-- and 'lastfm', and migration 0030 further relaxed it to any non-empty
-- string. If a new source value is added without updating this query,
-- with_art will silently undercount. Drift #556 caught the deezer +
-- lastfm omission introduced by migration 0020.
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE cover_art_source IN ('sidecar','embedded','mbcaa','theaudiodb')) AS with_art,
COUNT(*) FILTER (WHERE cover_art_source IN
('sidecar','embedded','mbcaa','theaudiodb','deezer','lastfm')) AS with_art,
COUNT(*) FILTER (WHERE cover_art_source IS NULL) AS pending,
COUNT(*) FILTER (WHERE cover_art_source = 'none') AS settled,
COUNT(*) FILTER (WHERE cover_art_source IS NULL AND mbid IS NULL) AS pending_no_mbid
+70
View File
@@ -0,0 +1,70 @@
-- Background garbage-collector / lifecycle queries. All five address
-- drift findings from the 2026-06-02 audit (Scribe parent #552):
-- #565 #566 #567 #574 #575. Sweeper runs on a long tick (default 1h)
-- so per-query cost is amortised; each is idempotent (re-running on
-- already-closed/-deleted rows is a no-op).
-- name: GcCloseStalePlayEvents :execrows
-- #566: play_events rows opened more than 24h ago that never got a
-- play_ended. Client crashed mid-track, network dropped, etc. We
-- synthesize ended_at = started_at + duration_played_ms when present,
-- otherwise leave duration_played_ms null and stamp ended_at = now()
-- so the row stops looking "open" for downstream queries that filter
-- ended_at IS NULL. Doesn't touch was_skipped (we genuinely don't
-- know if the user skipped).
UPDATE play_events
SET ended_at = COALESCE(
started_at + (duration_played_ms * INTERVAL '1 millisecond'),
now()
)
WHERE ended_at IS NULL
AND started_at < now() - INTERVAL '24 hours';
-- name: GcClosePlaySessionsWithNoRecentEvents :execrows
-- #565: play_sessions.ended_at was added but never populated by any
-- writer. Close sessions whose last_event_at is older than 6h —
-- treating that as "user moved on" the same way audio_service does
-- after grace periods. Sessions with NO events (track_count = 0)
-- older than 1h are also closed (stale handshakes from clients that
-- never recorded a play). ended_at is set to last_event_at so the
-- session's duration reads naturally.
UPDATE play_sessions
SET ended_at = COALESCE(last_event_at, started_at)
WHERE ended_at IS NULL
AND (
(track_count > 0 AND last_event_at < now() - INTERVAL '6 hours')
OR
(track_count = 0 AND started_at < now() - INTERVAL '1 hour')
);
-- name: GcExpireScrobbleQueueFailedRows :execrows
-- #567: scrobble_queue rows that have been in status='failed' for
-- more than 14 days. The worker stops retrying after maxAttempts;
-- failed rows accumulate forever otherwise. CASCADE from play_events
-- already drops the row when the underlying event is deleted, so this
-- only handles persistent failures (token revoked, etc.).
DELETE FROM scrobble_queue
WHERE status = 'failed'
AND enqueued_at < now() - INTERVAL '14 days';
-- name: GcResetStuckSystemPlaylistRuns :execrows
-- #574: system_playlist_runs.in_flight = true can wedge on a
-- goroutine panic 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 blocks all future regens for that user.
-- Reset rows where last_run_at is older than 10 minutes (regens
-- shouldn't take that long).
UPDATE system_playlist_runs
SET in_flight = false,
last_error = COALESCE(last_error, 'stuck-row auto-reset by gc')
WHERE in_flight = true
AND last_run_at < now() - INTERVAL '10 minutes';
-- name: GcDeleteExpiredPasswordResets :execrows
-- #575: password_resets accumulates expired + used rows forever. The
-- validation path already rejects them; this just keeps the table
-- from growing unbounded. Used rows are kept for 7 days for audit;
-- unused expired rows go immediately.
DELETE FROM password_resets
WHERE (used_at IS NOT NULL AND used_at < now() - INTERVAL '7 days')
OR (used_at IS NULL AND expires_at < now() - INTERVAL '1 hour');
+63
View File
@@ -0,0 +1,63 @@
-- name: InsertPlaybackError :one
-- Records a client-reported playback failure. The handler validates
-- the kind enum before this runs; the CHECK constraint is a
-- belt-and-braces guard.
INSERT INTO playback_errors (track_id, user_id, client_id, kind, detail)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, track_id, user_id, client_id, kind, detail, occurred_at,
resolved_at, resolved_by, resolution;
-- name: ListAdminPlaybackErrors :many
-- Admin inbox query. Returns one row per playback_errors row joined
-- with track / album / artist metadata so the SPA can render without
-- a second round-trip. Filter by resolved status via the sqlc.arg —
-- pass true for the Resolved tab, false for Unresolved (the default).
SELECT
pe.id AS id,
pe.track_id AS track_id,
pe.user_id AS user_id,
u.username AS username,
pe.client_id AS client_id,
pe.kind AS kind,
pe.detail AS detail,
pe.occurred_at AS occurred_at,
pe.resolved_at AS resolved_at,
pe.resolution AS resolution,
t.title AS track_title,
t.file_path AS track_file_path,
ar.name AS artist_name,
al.title AS album_title,
al.id AS album_id
FROM playback_errors pe
JOIN users u ON u.id = pe.user_id
JOIN tracks t ON t.id = pe.track_id
JOIN albums al ON al.id = t.album_id
JOIN artists ar ON ar.id = t.artist_id
WHERE (sqlc.arg(resolved_filter)::bool AND pe.resolved_at IS NOT NULL)
OR (NOT sqlc.arg(resolved_filter)::bool AND pe.resolved_at IS NULL)
ORDER BY pe.occurred_at DESC
LIMIT sqlc.arg(lim)::int OFFSET sqlc.arg(off)::int;
-- name: ResolvePlaybackError :one
-- Marks a single error resolved. Idempotent over (id, resolution) —
-- a second resolve with the same resolution is a no-op rewrite. Returns
-- the row so the handler can echo it. The CHECK constraint enforces
-- the resolution enum.
UPDATE playback_errors
SET resolved_at = now(),
resolved_by = $2,
resolution = $3
WHERE id = $1
RETURNING id, track_id, user_id, client_id, kind, detail, occurred_at,
resolved_at, resolved_by, resolution;
-- name: ResolveAllPlaybackErrorsForTrack :execrows
-- Bulk-resolve every unresolved error for a track when an admin acts
-- on it from a non-inbox surface (existing quarantine flow, etc.).
-- Returns the affected row count so the caller can surface "N reports
-- auto-resolved" in the response if useful.
UPDATE playback_errors
SET resolved_at = now(),
resolved_by = $2,
resolution = $3
WHERE track_id = $1 AND resolved_at IS NULL;
+101
View File
@@ -0,0 +1,101 @@
// Package gc runs periodic garbage-collection / lifecycle sweeps
// against tables that have NO writer-side close path or NO retention
// policy. Each sweep addresses a drift finding from the 2026-06-02
// audit (Scribe parent #552) and is idempotent — re-running it on
// already-clean rows is a no-op.
//
// One Worker handles all sweeps so a single long-tick goroutine
// amortises the per-tick fixed cost. Each individual sweep is small
// (single UPDATE / DELETE with a time-bounded WHERE) and emits a
// log line with the affected-row count so the sweep cadence is
// visible in the application log without an explicit metrics layer.
//
// Sweeps:
// - GcCloseStalePlayEvents (#566)
// - GcClosePlaySessionsWithNoRecentEvents (#565)
// - GcExpireScrobbleQueueFailedRows (#567)
// - GcResetStuckSystemPlaylistRuns (#574)
// - GcDeleteExpiredPasswordResets (#575)
package gc
import (
"context"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// defaultTick is the production sweep cadence. 1 hour is generous
// since each sweep's WHERE clause uses a multi-hour staleness
// threshold; the worst-case delay between a row becoming sweepable
// and the worker noticing is bounded by tick + threshold.
const defaultTick = 1 * time.Hour
// Worker holds the pool + logger + tick interval. Construct with
// [NewWorker]; pass the returned Worker to a goroutine that calls
// [Worker.Run] with a context that's cancelled on shutdown.
type Worker struct {
pool *pgxpool.Pool
logger *slog.Logger
tick time.Duration
}
// NewWorker builds a Worker with the production tick (1h). Tests can
// reach into the Worker after construction to override `tick` for
// faster iteration.
func NewWorker(pool *pgxpool.Pool, logger *slog.Logger) *Worker {
return &Worker{pool: pool, logger: logger, tick: defaultTick}
}
// Run blocks until ctx is cancelled, running every sweep on each
// tick. Sweeps fire in fixed order; an error in one does NOT abort
// the rest (the panic-vs-just-failed distinction matters here — a
// pgx transient error from one query shouldn't prevent the others
// from running).
func (w *Worker) Run(ctx context.Context) {
// Fire once at start so a freshly-deployed server doesn't wait a
// full tick before doing the initial sweep. Matches the scrobble
// + similarity workers' "sweep then tick" pattern.
w.tickOnce(ctx)
t := time.NewTicker(w.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
w.tickOnce(ctx)
}
}
}
// tickOnce runs each sweep once, logging the affected-row count.
// Errors are logged per-sweep but do NOT abort the remaining ones —
// each sweep is independent.
func (w *Worker) tickOnce(ctx context.Context) {
q := dbq.New(w.pool)
w.runSweep(ctx, "close_stale_play_events", q.GcCloseStalePlayEvents)
w.runSweep(ctx, "close_play_sessions", q.GcClosePlaySessionsWithNoRecentEvents)
w.runSweep(ctx, "expire_scrobble_failed", q.GcExpireScrobbleQueueFailedRows)
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
}
// runSweep is a small adapter so each sweep call site is a one-liner
// in tickOnce. Logs at info on rows>0 and debug on rows=0 to keep
// the normal-case (nothing-to-do) noise out of operator logs.
func (w *Worker) runSweep(ctx context.Context, name string, fn func(context.Context) (int64, error)) {
rows, err := fn(ctx)
if err != nil {
w.logger.Error("gc sweep failed", "sweep", name, "err", err)
return
}
if rows > 0 {
w.logger.Info("gc sweep", "sweep", name, "rows_affected", rows)
} else {
w.logger.Debug("gc sweep", "sweep", name, "rows_affected", 0)
}
}
+320
View File
@@ -0,0 +1,320 @@
package gc
import (
"context"
"io"
"log/slog"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
)
// testWorker constructs a Worker against MINSTREL_TEST_DATABASE_URL.
// Mirrors the api package's testHandlers pattern — skip when not in
// integration mode, migrate + reset, return the pool for the caller
// to seed.
func testWorker(t *testing.T) (*Worker, *pgxpool.Pool) {
t.Helper()
if testing.Short() {
t.Skip("skipping gc integration in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := db.Migrate(dsn, logger); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return NewWorker(pool, logger), pool
}
// seedUser creates a minimal user row for tests that need play_events
// / play_sessions / scrobble_queue rows. Username is prefixed so
// dbtest.ResetDB cleans up between runs.
func seedUser(t *testing.T, pool *pgxpool.Pool, name string) pgtype.UUID {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name,
PasswordHash: "test-hash",
ApiToken: "test-token-" + name,
IsAdmin: false,
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
return u.ID
}
func TestGcCloseStalePlayEvents_ClosesOnly24hOldRows(t *testing.T) {
w, pool := testWorker(t)
ctx := context.Background()
userID := seedUser(t, pool, "alice")
// Need a track + session to satisfy FKs on play_events.
var trackID pgtype.UUID
var artistID pgtype.UUID
// `artists` has sort_name NOT NULL; mirror the title in sort_name
// like every real insert site does (see api.search / library scan).
if err := pool.QueryRow(ctx, `
INSERT INTO artists (name, sort_name) VALUES ('A', 'A') RETURNING id
`).Scan(&artistID); err != nil {
t.Fatalf("seed artist row: %v", err)
}
var albumID pgtype.UUID
if err := pool.QueryRow(ctx, `
INSERT INTO albums (artist_id, title, sort_title) VALUES ($1, 'X', 'X') RETURNING id
`, artistID).Scan(&albumID); err != nil {
t.Fatalf("seed album: %v", err)
}
// `tracks` has file_size + file_format NOT NULL. Use plausible
// stub values — the GC sweep doesn't read any of these columns,
// it only joins on track_id.
if err := pool.QueryRow(ctx, `
INSERT INTO tracks (album_id, artist_id, title, file_path,
duration_ms, file_size, file_format)
VALUES ($1, $2, 'T', '/x.mp3', 180000, 4_000_000, 'mp3')
RETURNING id
`, albumID, artistID).Scan(&trackID); err != nil {
t.Fatalf("seed track: %v", err)
}
var sessionID pgtype.UUID
if err := pool.QueryRow(ctx, `
INSERT INTO play_sessions (user_id, started_at, last_event_at)
VALUES ($1, now() - interval '2 hours', now()) RETURNING id
`, userID).Scan(&sessionID); err != nil {
t.Fatalf("seed session: %v", err)
}
// Stale row (25h old, no ended_at) — should be closed.
if _, err := pool.Exec(ctx, `
INSERT INTO play_events (user_id, track_id, session_id, started_at)
VALUES ($1, $2, $3, now() - interval '25 hours')
`, userID, trackID, sessionID); err != nil {
t.Fatalf("seed stale event: %v", err)
}
// Fresh row (1h old, no ended_at) — should be left alone.
if _, err := pool.Exec(ctx, `
INSERT INTO play_events (user_id, track_id, session_id, started_at)
VALUES ($1, $2, $3, now() - interval '1 hour')
`, userID, trackID, sessionID); err != nil {
t.Fatalf("seed fresh event: %v", err)
}
w.tickOnce(ctx)
var closedStale, openFresh bool
if err := pool.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM play_events
WHERE started_at < now() - interval '24 hours'
AND ended_at IS NOT NULL)
`).Scan(&closedStale); err != nil {
t.Fatalf("check stale: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM play_events
WHERE started_at > now() - interval '2 hours'
AND ended_at IS NULL)
`).Scan(&openFresh); err != nil {
t.Fatalf("check fresh: %v", err)
}
if !closedStale {
t.Errorf("stale play_events row not closed")
}
if !openFresh {
t.Errorf("fresh play_events row was closed (should be left alone)")
}
}
func TestGcClosePlaySessions_ClosesIdleAndEmptyStarvedSessions(t *testing.T) {
w, pool := testWorker(t)
ctx := context.Background()
userID := seedUser(t, pool, "bob")
// Idle session — has events, last_event_at 8h ago.
if _, err := pool.Exec(ctx, `
INSERT INTO play_sessions (user_id, started_at, last_event_at, track_count)
VALUES ($1, now() - interval '8 hours', now() - interval '8 hours', 5)
`, userID); err != nil {
t.Fatalf("seed idle session: %v", err)
}
// Empty-starved session — no events, started 2h ago.
if _, err := pool.Exec(ctx, `
INSERT INTO play_sessions (user_id, started_at, last_event_at, track_count)
VALUES ($1, now() - interval '2 hours', now() - interval '2 hours', 0)
`, userID); err != nil {
t.Fatalf("seed empty session: %v", err)
}
// Active session — recent last_event_at, has events.
if _, err := pool.Exec(ctx, `
INSERT INTO play_sessions (user_id, started_at, last_event_at, track_count)
VALUES ($1, now() - interval '30 minutes', now() - interval '5 minutes', 3)
`, userID); err != nil {
t.Fatalf("seed active session: %v", err)
}
w.tickOnce(ctx)
var closedCount, openCount int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM play_sessions
WHERE user_id = $1 AND ended_at IS NOT NULL
`, userID).Scan(&closedCount); err != nil {
t.Fatalf("count closed: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM play_sessions
WHERE user_id = $1 AND ended_at IS NULL
`, userID).Scan(&openCount); err != nil {
t.Fatalf("count open: %v", err)
}
if closedCount != 2 {
t.Errorf("closed sessions = %d, want 2 (idle + empty-starved)", closedCount)
}
if openCount != 1 {
t.Errorf("open sessions = %d, want 1 (active)", openCount)
}
}
func TestGcResetStuckSystemPlaylistRuns(t *testing.T) {
w, pool := testWorker(t)
ctx := context.Background()
stuckUser := seedUser(t, pool, "stuck")
activeUser := seedUser(t, pool, "active")
// Stuck row: in_flight, last_run_at 30 min ago.
if _, err := pool.Exec(ctx, `
INSERT INTO system_playlist_runs (user_id, last_run_at, in_flight)
VALUES ($1, now() - interval '30 minutes', true)
`, stuckUser); err != nil {
t.Fatalf("seed stuck run: %v", err)
}
// Active row: in_flight, last_run_at 2 min ago — still legitimate.
if _, err := pool.Exec(ctx, `
INSERT INTO system_playlist_runs (user_id, last_run_at, in_flight)
VALUES ($1, now() - interval '2 minutes', true)
`, activeUser); err != nil {
t.Fatalf("seed active run: %v", err)
}
w.tickOnce(ctx)
var stuckFlight, activeFlight bool
if err := pool.QueryRow(ctx, `
SELECT in_flight FROM system_playlist_runs WHERE user_id = $1
`, stuckUser).Scan(&stuckFlight); err != nil {
t.Fatalf("read stuck row: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT in_flight FROM system_playlist_runs WHERE user_id = $1
`, activeUser).Scan(&activeFlight); err != nil {
t.Fatalf("read active row: %v", err)
}
if stuckFlight {
t.Errorf("stuck row still in_flight after sweep")
}
if !activeFlight {
t.Errorf("active row was reset (should be left alone — only 2 min old)")
}
}
func TestGcDeleteExpiredPasswordResets(t *testing.T) {
w, pool := testWorker(t)
ctx := context.Background()
userID := seedUser(t, pool, "pwd")
// Expired-unused (> 1h past expires_at) — should delete.
if _, err := pool.Exec(ctx, `
INSERT INTO password_resets (token, user_id, expires_at)
VALUES ('expired-old', $1, now() - interval '2 hours')
`, userID); err != nil {
t.Fatalf("seed expired: %v", err)
}
// Used (> 7 days past used_at) — should delete.
if _, err := pool.Exec(ctx, `
INSERT INTO password_resets (token, user_id, expires_at, used_at)
VALUES ('used-old', $1, now() - interval '8 days', now() - interval '8 days')
`, userID); err != nil {
t.Fatalf("seed used-old: %v", err)
}
// Active (expires in future) — should survive.
if _, err := pool.Exec(ctx, `
INSERT INTO password_resets (token, user_id, expires_at)
VALUES ('active', $1, now() + interval '1 hour')
`, userID); err != nil {
t.Fatalf("seed active: %v", err)
}
// Recently used (< 7 days) — should survive for audit.
if _, err := pool.Exec(ctx, `
INSERT INTO password_resets (token, user_id, expires_at, used_at)
VALUES ('used-recent', $1, now() - interval '1 hour', now() - interval '1 day')
`, userID); err != nil {
t.Fatalf("seed used-recent: %v", err)
}
w.tickOnce(ctx)
var remaining []string
rows, err := pool.Query(ctx, `SELECT token FROM password_resets WHERE user_id = $1`, userID)
if err != nil {
t.Fatalf("list remaining: %v", err)
}
defer rows.Close()
for rows.Next() {
var tok string
if err := rows.Scan(&tok); err != nil {
t.Fatalf("scan: %v", err)
}
remaining = append(remaining, tok)
}
wantSet := map[string]bool{"active": true, "used-recent": true}
if len(remaining) != len(wantSet) {
t.Errorf("remaining tokens = %v, want %v", remaining, []string{"active", "used-recent"})
}
for _, tok := range remaining {
if !wantSet[tok] {
t.Errorf("unexpected surviving token %q", tok)
}
}
}
// Verifies tickOnce doesn't blow up when the tables are completely
// empty — sweeps just no-op. Regression guard against an EXEC vs
// QUERY-row-count mismatch failing on zero rows.
func TestGcTickOnce_NoOpOnEmptyTables(t *testing.T) {
w, _ := testWorker(t)
w.tickOnce(context.Background())
}
// Sanity check on the Run() loop's cancel behaviour — we don't want
// to leave a goroutine spinning at test-runner exit. 10ms tick with
// an immediate cancel should return promptly.
func TestGcRun_HonoursContextCancel(t *testing.T) {
w, _ := testWorker(t)
w.tick = 10 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
w.Run(ctx)
close(done)
}()
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run did not return after cancel")
}
}
+6 -3
View File
@@ -29,9 +29,12 @@ var ErrTrackNotFound = errors.New("library: track not found")
//
// Order matters: file first, then DB. If the file delete fails (permission,
// I/O error), we leave the DB row alone so the admin can retry. The reverse
// failure mode — file gone, DB row still present — is recoverable: the next
// library scan reconciles missing files by removing their tracks rows. So
// the function is retry-safe rather than atomic, by design.
// failure mode — file gone, DB row still present — is currently NOT
// auto-reconciled (drift #572 audit found the misleading prior claim
// that a scan would clean it up — the scanner only walks + upserts;
// it does not enumerate orphan rows). An admin must re-trigger
// DeleteTrackFile or delete the row manually. A scanrun orphan-row
// sweep is tracked as future work in the audit queue.
func DeleteTrackFile(ctx context.Context, pool *pgxpool.Pool, trackID pgtype.UUID) error {
q := dbq.New(pool)
track, err := q.GetTrackByID(ctx, trackID)
+9 -2
View File
@@ -23,13 +23,20 @@ import (
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// audioExtensions is the v1 set. Duration extraction is not wired, so all of
// these get duration_ms=0 until an ffprobe / native-decoder pass lands.
// audioExtensions is the set the scanner indexes. Keep in sync with
// `internal/api/media.go` MIME detection — the stream handler must be
// able to serve every extension the scanner indexes, and there is no
// point in adding extensions to the stream handler that the scanner
// will silently skip. Drift #571 caught the divergence after .opus,
// .aac, and .wav were added to media.go but not here.
var audioExtensions = map[string]bool{
".mp3": true,
".m4a": true,
".flac": true,
".ogg": true,
".opus": true,
".aac": true,
".wav": true,
}
type Stats struct {
+25 -4
View File
@@ -69,11 +69,32 @@ func (w *Writer) RecordPlayStarted(
}
// systemPlaylistSources are the play_events.source values that count
// against a system playlist's rotation (Fable #415). Add future
// system-playlist kinds here as they ship (deep_cuts, rediscover, …).
// against a system playlist's rotation (Fable #415). Mirrors the
// `playlists_kind_variant_consistent` CHECK in migration
// 0028_discovery_mix_variants.up.sql — every variant in that CHECK
// list that ships as a refreshable system mix needs to be here so
// the rotation reporter sees Android + web plays from that surface.
//
// Drift #563: this map drifted behind the migrations. It had only
// for_you + discover but migrations 0021 + 0028 added 6 more
// variants. Plays from Rediscover / Deep Cuts / Songs Like X /
// New for You / On This Day / First Listens didn't advance the
// per-user rotation, so "unplayed first" ordering staled on those
// mixes — same tracks kept surfacing.
//
// Future variant additions should update this map AND the CHECK in
// the matching migration in the same change; the
// `db_check_constraint_for_new_variants` standing rule covers the
// CHECK half.
var systemPlaylistSources = map[string]bool{
"for_you": true,
"discover": true,
"for_you": true,
"discover": true,
"deep_cuts": true,
"rediscover": true,
"new_for_you": true,
"on_this_day": true,
"first_listens": true,
"songs_like_artist": true,
}
// RecordPlayStartedWithSource is RecordPlayStarted plus a `source`
+31 -1
View File
@@ -3,6 +3,7 @@ import { api } from './client';
import { qk } from './queries';
import type {
ActionResult,
AdminPlaybackError,
AdminQuarantineRow,
LidarrConfig,
LidarrMetadataProfile,
@@ -11,7 +12,8 @@ import type {
LidarrRequest,
LidarrRequestStatus,
LidarrRootFolder,
LidarrTestResult
LidarrTestResult,
PlaybackErrorResolution
} from './types';
// Admin Lidarr config -----------------------------------------------------
@@ -174,6 +176,34 @@ export function createQuarantineActionsQuery(limit: number = 50) {
});
}
// Admin playback errors ---------------------------------------------------
export async function listAdminPlaybackErrors(
resolved: boolean = false
): Promise<AdminPlaybackError[]> {
return api.get<AdminPlaybackError[]>(
`/api/admin/playback-errors?resolved=${resolved}`
);
}
export async function resolvePlaybackError(
id: string,
resolution: PlaybackErrorResolution
): Promise<{ id: string }> {
return api.post<{ id: string }>(
`/api/admin/playback-errors/${id}/resolve`,
{ resolution }
);
}
export function createAdminPlaybackErrorsQuery(resolved: boolean = false) {
return createQuery({
queryKey: qk.adminPlaybackErrors(resolved),
queryFn: () => listAdminPlaybackErrors(resolved),
staleTime: 30_000
});
}
// Admin cover art ---------------------------------------------------------
export type RefetchAlbumCoverResponse = {
+2
View File
@@ -43,6 +43,8 @@ export const qk = {
adminQuarantine: () => ['adminQuarantine'] as const,
adminQuarantineActions: (limit?: number) =>
['adminQuarantineActions', { limit: limit ?? 50 }] as const,
adminPlaybackErrors: (resolved?: boolean) =>
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
scanStatus: () => ['scanStatus'] as const,
scanSchedule: () => ['scanSchedule'] as const,
coverage: () => ['coverage'] as const,
+34 -1
View File
@@ -106,7 +106,13 @@ export type LikedIdsResponse = {
};
export type EventRequest =
| { type: 'play_started'; track_id: string; client_id?: string }
// `source` is the system-playlist variant the play came from
// (for_you, discover, deep_cuts, …); empty/undefined for library
// / user-playlist / radio / Subsonic. Server stores it on
// play_events.source and uses it to advance the rotation. Drift
// #555: the type was missing `source` so callers had no typed slot
// for it and a "clean up extra properties" refactor could drop it.
| { type: 'play_started'; track_id: string; client_id?: string; source?: string }
| { type: 'play_ended'; play_event_id: string; duration_played_ms: number }
| { type: 'play_skipped'; play_event_id: string; position_ms: number };
@@ -264,6 +270,33 @@ export type AdminQuarantineReport = {
created_at: string;
};
// What GET /api/admin/playback-errors returns per row
export type PlaybackErrorKind = 'zero_duration' | 'load_failed' | 'stalled';
export type PlaybackErrorResolution =
| 'hidden'
| 'deleted'
| 'requested'
| 'fixed'
| 'ignored';
export type AdminPlaybackError = {
id: string;
track_id: string;
user_id: string;
username: string;
client_id: string;
kind: PlaybackErrorKind;
detail?: string | null;
occurred_at: string;
resolved_at?: string | null;
resolution?: PlaybackErrorResolution | null;
track_title: string;
track_file_path: string;
artist_name: string;
album_title: string;
album_id: string;
};
export type LidarrQuarantineAction = 'resolved' | 'deleted_file' | 'deleted_via_lidarr';
export type LidarrQuarantineActionRow = {
+18
View File
@@ -30,4 +30,22 @@ describe('isPublicRoute', () => {
expect(isPublicRoute('/login/extra')).toBe(false);
expect(isPublicRoute('/loginx')).toBe(false);
});
// Regression guard for drift #558: /forgot-password and the
// /reset-password/<token> deep links must be reachable without a
// session — the reset flow is entered by clicking an email link
// while signed out, so the auth gate redirecting to /login broke
// account recovery.
test('/forgot-password is public', () => {
expect(isPublicRoute('/forgot-password')).toBe(true);
});
test('/reset-password/<token> is public', () => {
expect(isPublicRoute('/reset-password/abc-123')).toBe(true);
expect(isPublicRoute('/reset-password/')).toBe(true);
});
test('/reset-password (no trailing slash) is NOT public — only the token sub-path', () => {
expect(isPublicRoute('/reset-password')).toBe(false);
});
});
+9 -2
View File
@@ -2,8 +2,15 @@
// when the visitor is unauthenticated. Bootstrap-admin self-registration
// (#376) requires /register to be reachable without a session — without
// /register here, the login page's "Register" link bounces back to /login.
const PUBLIC_ROUTES = new Set(['/login', '/register']);
//
// Drift #558: /forgot-password and /reset-password/<token> were missing
// from this set. The email-link reset flow is by definition entered
// without a session — a signed-out user clicking their reset link was
// being bounced to /login, breaking account recovery.
const PUBLIC_ROUTES = new Set(['/login', '/register', '/forgot-password']);
const PUBLIC_PREFIXES = ['/reset-password/'];
export function isPublicRoute(pathname: string): boolean {
return PUBLIC_ROUTES.has(pathname);
if (PUBLIC_ROUTES.has(pathname)) return true;
return PUBLIC_PREFIXES.some((p) => pathname.startsWith(p));
}
+6 -5
View File
@@ -4,11 +4,12 @@
type Item = { href: string; label: string };
const items: Item[] = [
{ href: '/admin', label: 'Overview' },
{ href: '/admin/integrations', label: 'Integrations' },
{ href: '/admin/requests', label: 'Requests' },
{ href: '/admin/quarantine', label: 'Quarantine' },
{ href: '/admin/users', label: 'Users' }
{ href: '/admin', label: 'Overview' },
{ href: '/admin/integrations', label: 'Integrations' },
{ href: '/admin/requests', label: 'Requests' },
{ href: '/admin/quarantine', label: 'Quarantine' },
{ href: '/admin/playback-errors', label: 'Playback errors' },
{ href: '/admin/users', label: 'Users' }
];
function isActive(href: string): boolean {
+2 -1
View File
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
);
});
test('renders exactly five tabs', () => {
test('renders all six tabs in order', () => {
state.pageUrl = new URL('http://localhost/admin');
render(AdminTabs);
const links = screen.getAllByRole('link');
@@ -61,6 +61,7 @@ describe('AdminTabs', () => {
'Integrations',
'Requests',
'Quarantine',
'Playback errors',
'Users'
]);
});
+69 -5
View File
@@ -1,14 +1,25 @@
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import { tick } from 'svelte';
import { Loader2 } from 'lucide-svelte';
let {
items,
getKey,
item
item,
hasMore = false,
onLoadMore
}: {
items: T[];
getKey: (it: T) => string;
item: Snippet<[T]>;
// When true, clicking a rail letter that's not in the currently
// loaded items will trigger onLoadMore() repeatedly until that
// letter surfaces (or no more pages exist). Lets a paginated
// source like createArtistsQuery still feel like a full jump
// rail without eager-loading the whole dataset.
hasMore?: boolean;
onLoadMore?: () => Promise<unknown> | unknown;
} = $props();
// Bucket the first character into one of three classes so the
@@ -56,10 +67,42 @@
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const railEntries: string[] = ['#', ...ALPHABET, '&'];
function jumpTo(bucket: string) {
// Bucket the caller is currently chasing via onLoadMore. Used to
// show a spinner on the rail button while pages stream in.
let pendingBucket = $state<string | null>(null);
function scrollNow(bucket: string) {
const el = document.getElementById(`alpha-${bucket}`);
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
async function jumpTo(bucket: string) {
// Already loaded — jump in the same frame.
if (populated.has(bucket)) {
scrollNow(bucket);
return;
}
// No paginated source wired in — nothing to load.
if (!hasMore || !onLoadMore) return;
// Don't stack loaders.
if (pendingBucket !== null) return;
pendingBucket = bucket;
try {
// Walk pages until the bucket appears or we exhaust the
// dataset. `populated` is $derived from `items`; `tick()`
// forces Svelte to flush the prop update before we re-read
// populated for the loop condition.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (hasMore && !populated.has(bucket)) {
await onLoadMore();
await tick();
}
if (populated.has(bucket)) scrollNow(bucket);
} finally {
pendingBucket = null;
}
}
</script>
<div class="alpha-layout">
@@ -80,17 +123,28 @@
vertically centered via position:sticky + top:50%. -->
<nav class="alpha-rail" aria-label="Jump to letter">
{#each railEntries as entry}
{@const enabled = populated.has(entry)}
{@const isLoaded = populated.has(entry)}
{@const couldLoad = !isLoaded && hasMore}
{@const enabled = isLoaded || couldLoad}
{@const isPending = pendingBucket === entry}
<button
type="button"
class="rail-btn"
class:disabled={!enabled}
disabled={!enabled}
class:pending={isPending}
disabled={!enabled || pendingBucket !== null}
onclick={() => enabled && jumpTo(entry)}
aria-label={enabled
? `Jump to ${entry === '#' ? 'numbers' : entry === '&' ? 'symbols' : entry}`
: `${entry === '#' ? 'Numbers' : entry === '&' ? 'Symbols' : entry} no entries`}
>{entry}</button>
aria-busy={isPending}
>
{#if isPending}
<Loader2 size={11} strokeWidth={2} class="spin" aria-hidden="true" />
{:else}
{entry}
{/if}
</button>
{/each}
</nav>
{/if}
@@ -146,4 +200,14 @@
color: color-mix(in srgb, var(--fs-ash) 50%, transparent);
cursor: default;
}
.rail-btn.pending {
color: var(--fs-accent);
cursor: wait;
}
:global(.rail-btn .spin) {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
@@ -23,27 +23,43 @@ const itemSnippet = createRawSnippet<[Item]>((getIt) => ({
type AnyProps = any;
describe('AlphabeticalGrid', () => {
test('rail renders the full #/A-Z/& set; empty buckets are disabled', () => {
test('rail renders the full #/A-Z/& set; empty buckets are disabled when no more data could load', () => {
render(AlphabeticalGrid, {
props: {
items,
getKey: (it: Item) => it.key,
item: itemSnippet
// hasMore default = false → empty buckets stay disabled.
} as AnyProps
});
// Populated buckets are enabled with 'Jump to <letter>' label.
expect(screen.getByRole('button', { name: 'Jump to A' })).not.toBeDisabled();
expect(screen.getByRole('button', { name: 'Jump to B' })).not.toBeDisabled();
expect(screen.getByRole('button', { name: 'Jump to D' })).not.toBeDisabled();
// Empty buckets still render but are disabled and labelled
// 'X — no entries' so screen readers announce the empty state.
// Empty buckets disabled when nothing more could load. aria-label
// changes too so screen readers announce the empty state.
expect(screen.getByRole('button', { name: 'C — no entries' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Z — no entries' })).toBeDisabled();
// # (numbers) and & (symbols) always present too.
expect(screen.getByRole('button', { name: 'Numbers — no entries' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Symbols — no entries' })).toBeDisabled();
});
test('with hasMore=true, empty buckets are enabled (clicking would trigger onLoadMore)', () => {
render(AlphabeticalGrid, {
props: {
items,
getKey: (it: Item) => it.key,
item: itemSnippet,
hasMore: true,
onLoadMore: () => Promise.resolve()
} as AnyProps
});
// Even with no C/Z items loaded, the buttons remain enabled so a
// click can chase pages until the letter surfaces.
expect(screen.getByRole('button', { name: 'Jump to C' })).not.toBeDisabled();
expect(screen.getByRole('button', { name: 'Jump to Z' })).not.toBeDisabled();
});
test('renders items in given order followed by the jump rail', () => {
const { container } = render(AlphabeticalGrid, {
props: {
+15 -6
View File
@@ -14,11 +14,17 @@
import { formatDuration } from '$lib/media/duration';
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor';
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
const current = $derived(player.current);
// Per-frame interpolated playhead so the scrubber bar moves
// smoothly between server ticks instead of stepping every ~250ms.
// Reset on track/play-state change via the helper's $effect.
const smoothed = useSmoothPosition();
const skipPrevDisabled = $derived(player.index === 0 && player.position < 3);
const skipNextDisabled = $derived(
player.index === player.queue.length - 1 && player.repeat === 'off'
@@ -226,10 +232,12 @@
</div>
</div>
<!-- Bottom row: full-width seek slider + time labels -->
<!-- Bottom row: full-width seek slider + time labels.
Reads the smoothed position so the slider thumb glides
between server ticks at playback rate. -->
<div class="flex items-center gap-2">
<span class="w-9 shrink-0 text-right text-[10px] tabular-nums text-text-secondary">
{formatDuration(player.position)}
{formatDuration(smoothed.value)}
</span>
<input
type="range"
@@ -237,7 +245,7 @@
min="0"
max={player.duration || 0}
step="0.1"
value={player.position}
value={smoothed.value}
oninput={onSeekInput}
class="flex-1 accent-accent"
/>
@@ -346,10 +354,11 @@
<SkipForward size={20} strokeWidth={1.5} fill="currentColor" />
</button>
</div>
<!-- Seek row -->
<!-- Seek row — reads smoothed position so the thumb glides
between server ticks. See useSmoothPosition. -->
<div class="flex items-center gap-3">
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-text-secondary">
{formatDuration(player.position)}
{formatDuration(smoothed.value)}
</span>
<input
type="range"
@@ -357,7 +366,7 @@
min="0"
max={player.duration || 0}
step="0.1"
value={player.position}
value={smoothed.value}
oninput={onSeekInput}
class="flex-1 accent-accent"
/>
+20 -2
View File
@@ -64,7 +64,15 @@
starting = true;
try {
const variant = playlist.system_variant;
if (variant != null) {
// systemShuffle keys by variant alone, which only works for
// SINGLE-instance variants (for_you, discover, deep_cuts, …).
// songs_like_artist has one playlist PER seed artist; using
// the variant alone would return the wrong one (or fail).
// For those, fall through to getPlaylist so the play handler
// hits the exact playlist the user clicked on. Detect via
// seed_artist_id which is non-null only for per-artist mixes.
const isPerArtist = playlist.seed_artist_id != null;
if (variant != null && !isPerArtist) {
// #415: server returns the rotation-aware order (unplayed
// this rotation first). Play it as-is — no client shuffle —
// and tag the queue so play_started carries `source` and
@@ -78,7 +86,17 @@
const detail = await getPlaylist(playlist.id);
const refs = toTrackRefs(detail.tracks);
if (refs.length > 0) {
playQueue(refs, 0);
// Per-artist system mixes (songs_like_artist) carry a
// variant tag so play_started still attributes the source
// correctly even though we routed through the per-id path.
// True user playlists (variant == null) call with two args
// so source attribution stays absent — the PlaylistCard
// test pins this contract.
if (variant != null) {
playQueue(refs, 0, { source: variant });
} else {
playQueue(refs, 0);
}
}
}
} finally {
+3 -38
View File
@@ -1,18 +1,10 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import { player, closeQueueDrawer } from '$lib/player/store.svelte';
import QueueTrackRow from './QueueTrackRow.svelte';
import QueueList from './QueueList.svelte';
let previouslyFocused: HTMLElement | null = null;
let closeButton: HTMLButtonElement | undefined = $state();
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
const m = Math.floor(totalSec / 60);
const h = Math.floor(m / 60);
return h > 0 ? `${h}h ${m % 60}m` : `${m} min`;
}
$effect(() => {
if (player.queueDrawerOpen) {
previouslyFocused = document.activeElement as HTMLElement | null;
@@ -40,35 +32,8 @@
aria-hidden={!player.queueDrawerOpen}
inert={!player.queueDrawerOpen}
class="fixed top-0 right-0 h-full w-full sm:w-96 bg-surface z-50
transition-transform duration-200 flex flex-col
transition-transform duration-200
{player.queueDrawerOpen ? 'translate-x-0' : 'translate-x-full'}"
>
<div class="flex items-center justify-between border-b border-border px-4 py-3">
<div>
<h2 class="text-lg font-semibold">Queue</h2>
<p class="text-xs text-text-secondary">
{player.queue.length} {player.queue.length === 1 ? 'track' : 'tracks'}
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
</p>
</div>
<button
type="button"
bind:this={closeButton}
aria-label="Close queue"
onclick={() => closeQueueDrawer()}
class="text-text-secondary hover:text-text-primary"
>
<X size={20} />
</button>
</div>
<div class="flex-1 overflow-y-auto">
{#if player.queue.length === 0}
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
{:else}
{#each player.queue as track, i (track.id)}
<QueueTrackRow {track} index={i} isCurrent={i === player.index} />
{/each}
{/if}
</div>
<QueueList onClose={() => closeQueueDrawer()} bind:closeButtonRef={closeButton} />
</aside>
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import { player } from '$lib/player/store.svelte';
import QueueTrackRow from './QueueTrackRow.svelte';
// onClose: when provided, renders an X button in the header so the
// slide-in drawer can dismiss itself. The embedded panel on the
// now-playing route (visible at lg+ widths) omits it.
// closeButtonRef: bind:this hook so the drawer can focus the X for
// keyboard users on open.
type Props = {
onClose?: () => void;
closeButtonRef?: HTMLButtonElement;
};
let { onClose, closeButtonRef = $bindable() }: Props = $props();
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
const m = Math.floor(totalSec / 60);
const h = Math.floor(m / 60);
return h > 0 ? `${h}h ${m % 60}m` : `${m} min`;
}
</script>
<div class="flex h-full flex-col">
<div class="flex items-center justify-between border-b border-border px-4 py-3">
<div>
<h2 class="text-lg font-semibold">Queue</h2>
<p class="text-xs text-text-secondary">
{player.queue.length} {player.queue.length === 1 ? 'track' : 'tracks'}
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
</p>
</div>
{#if onClose}
<button
type="button"
bind:this={closeButtonRef}
aria-label="Close queue"
onclick={onClose}
class="text-text-secondary hover:text-text-primary"
>
<X size={20} />
</button>
{/if}
</div>
<div class="flex-1 overflow-y-auto">
{#if player.queue.length === 0}
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
{:else}
{#each player.queue as track, i (track.id)}
<QueueTrackRow {track} index={i} isCurrent={i === player.index} />
{/each}
{/if}
</div>
</div>
@@ -0,0 +1,51 @@
import { player } from './store.svelte';
/**
* Reactive smoothed playhead position in seconds. Mirrors Android's
* `rememberSmoothPositionMs`: the underlying `player.position` updates
* every ~250 ms (HTML audio `timeupdate` fires ~4 Hz), so reading it
* directly snaps the scrubber forward in discrete steps. This helper
* extrapolates per-frame between server ticks so the bar glides at
* playback rate.
*
* The `$effect` re-runs whenever the canonical position / isPlaying /
* duration changes, which both handles natural ticks (re-sync to the
* new authoritative value) and seeks (instant jump on user action).
* The rAF loop advances at wall-clock seconds since the segment
* started, so as long as the canonical position keeps up with real
* time the resync on each tick is sub-frame and invisible.
*
* Returns a `{ value }` accessor instead of a bare number so callers
* can read it reactively inside components (Svelte's rune wiring
* follows the property access).
*/
export function useSmoothPosition(): { readonly value: number } {
let displayed = $state(player.position);
$effect(() => {
// Reset whenever the canonical position changes (server tick,
// seek, track change) so we anchor to the truth.
displayed = player.position;
if (!player.isPlaying || player.duration <= 0) return;
const startReal = performance.now();
const startPos = player.position;
const duration = player.duration;
let rafId = 0;
const tick = () => {
const elapsed = (performance.now() - startReal) / 1000;
displayed = Math.min(startPos + elapsed, duration);
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
});
return {
get value() {
return displayed;
}
};
}
+12 -1
View File
@@ -462,7 +462,18 @@ $effect.root(() => {
_radioRefreshInFlight = true;
const seed = _radioSeedId;
const exclude = _queue.map((t) => t.id).join(',');
// Drift #554: cap the exclude list so a multi-hour radio session
// doesn't grow the query string past common 8KB limits and start
// 414-ing /api/radio. The .catch() below would silently swallow
// that failure and the player would stop topping up — a dead
// radio. The server's RecentlyPlayedHours filter already handles
// broader history dedup, so the request-side exclude only needs
// to cover the visible queue's recent tail.
const RADIO_EXCLUDE_CAP = 100;
const exclude = _queue
.slice(-RADIO_EXCLUDE_CAP)
.map((t) => t.id)
.join(',');
api
.get<RadioResponse>(
`/api/radio?seed_track=${encodeURIComponent(seed)}&exclude=${exclude}`
@@ -0,0 +1,316 @@
<script lang="ts">
import { pageTitle } from '$lib/branding';
import { Copy, Trash2, CheckCircle2 } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import RowActionsMenu, { type RowAction } from '$lib/components/RowActionsMenu.svelte';
import {
createAdminPlaybackErrorsQuery,
resolvePlaybackError,
deleteQuarantineFile
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
import Modal from '$lib/components/Modal.svelte';
import type { AdminPlaybackError, PlaybackErrorResolution } from '$lib/api/types';
// Client-reported playback errors inbox. Two tabs — Unresolved
// (default) / Resolved. Per-row: copy details to clipboard, delete
// the source file (auto-resolves as 'deleted'), or mark resolved
// manually with a fixed/ignored reason. Other resolutions (hidden,
// requested) get stamped by their respective workflows in follow-up
// slices.
const client = useQueryClient();
// Tab state — observed by the query factory so swapping tabs
// re-fetches the corresponding list.
let resolved = $state(false);
const queryStore = $derived(createAdminPlaybackErrorsQuery(resolved));
const query = $derived($queryStore);
const rows = $derived((query.data ?? []) as AdminPlaybackError[]);
function relativeTime(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
const days = Math.floor(ms / (24 * 3_600_000));
if (days >= 1) return `${days}d ago`;
const hours = Math.floor(ms / 3_600_000);
if (hours >= 1) return `${hours}h ago`;
const minutes = Math.floor(ms / 60_000);
if (minutes >= 1) return `${minutes}m ago`;
return 'just now';
}
// Maps the kind enum to a short readable badge label.
function kindLabel(kind: string): string {
switch (kind) {
case 'zero_duration': return 'Zero duration';
case 'load_failed': return 'Load failed';
case 'stalled': return 'Stalled';
default: return kind;
}
}
// Maps the resolution enum to a short label for the Resolved tab.
function resolutionLabel(r: string | null | undefined): string {
if (!r) return '';
switch (r) {
case 'hidden': return 'Hidden';
case 'deleted': return 'Deleted';
case 'requested': return 'Re-requested';
case 'fixed': return 'Fixed';
case 'ignored': return 'Ignored';
default: return r;
}
}
async function invalidate() {
await client.invalidateQueries({ queryKey: qk.adminPlaybackErrors(resolved) });
}
// Copy a single-row JSON payload to the clipboard. Includes the
// server-side file_path so the operator can grep their library
// mount without round-tripping back through the UI.
async function onCopy(r: AdminPlaybackError) {
const payload = {
id: r.id,
track_id: r.track_id,
track_title: r.track_title,
artist_name: r.artist_name,
album_title: r.album_title,
file_path: r.track_file_path,
kind: r.kind,
detail: r.detail,
reported_by: r.username,
client_id: r.client_id,
occurred_at: r.occurred_at
};
try {
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
pushToast('Copied error details to clipboard');
} catch (e: unknown) {
pushToast(`Copy failed: ${errMessage(e)}`, 'error');
}
}
// Delete the source file via the existing quarantine admin endpoint;
// on success stamp this row as resolved='deleted' so the inbox
// reflects the action immediately.
let deleteFileOpen = $state<AdminPlaybackError | null>(null);
async function confirmDeleteFile() {
const row = deleteFileOpen;
if (!row) return;
deleteFileOpen = null;
try {
await deleteQuarantineFile(row.track_id);
await resolvePlaybackError(row.id, 'deleted');
pushToast(`Deleted "${row.track_title}"`);
await invalidate();
} catch (e: unknown) {
pushToast(`Delete failed: ${errMessage(e)}`, 'error');
}
}
// Manual mark-resolved modal. Resolution dropdown limited to the two
// "no further action taken" cases (fixed / ignored). Delete already
// stamps 'deleted'; hide/request land in follow-up slices.
let resolveOpen = $state<AdminPlaybackError | null>(null);
let resolveChoice = $state<PlaybackErrorResolution>('fixed');
function openResolve(row: AdminPlaybackError) {
resolveOpen = row;
resolveChoice = 'fixed';
}
async function confirmResolve() {
const row = resolveOpen;
if (!row) return;
resolveOpen = null;
try {
await resolvePlaybackError(row.id, resolveChoice);
pushToast(`Marked "${row.track_title}" ${resolutionLabel(resolveChoice).toLowerCase()}`);
await invalidate();
} catch (e: unknown) {
pushToast(`Resolve failed: ${errMessage(e)}`, 'error');
}
}
function actionsFor(r: AdminPlaybackError): { primary: RowAction; secondary: RowAction[] } {
return {
primary: {
icon: CheckCircle2,
label: 'Resolve',
ariaLabel: `Resolve ${r.track_title}`,
onclick: () => openResolve(r)
},
secondary: [
{
icon: Copy,
label: 'Copy',
ariaLabel: `Copy details for ${r.track_title}`,
onclick: () => onCopy(r)
},
{
icon: Trash2,
label: 'Delete file',
ariaLabel: `Delete file for ${r.track_title}`,
danger: true,
onclick: () => { deleteFileOpen = r; }
}
]
};
}
</script>
<svelte:head><title>{pageTitle('Admin · Playback errors')}</title></svelte:head>
<div class="space-y-4">
<header class="flex items-end justify-between gap-3">
<div>
<h1 class="font-display text-2xl font-medium text-text-primary">Playback errors</h1>
<p class="text-sm text-text-secondary">
Tracks that failed to play on a client. Reported automatically by
the Android player when a track loads with zero duration or
decode-fails — the player skips fast and logs here for triage.
</p>
</div>
{#if !query.isPending && !query.isError && !resolved}
<span class="rounded-full bg-accent/15 px-3 py-1 text-sm text-accent">
{rows.length} unresolved
</span>
{/if}
</header>
<!-- Tab toggle. Resolved / unresolved are two separate query keys so
the cache holds both lists without re-fetching when you flip. -->
<div role="tablist" aria-label="Resolution status" class="flex gap-2 border-b border-border">
<button
type="button"
role="tab"
aria-selected={!resolved}
onclick={() => { resolved = false; }}
class="border-b-2 px-3 py-2 text-sm transition-colors {!resolved
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
>Unresolved</button>
<button
type="button"
role="tab"
aria-selected={resolved}
onclick={() => { resolved = true; }}
class="border-b-2 px-3 py-2 text-sm transition-colors {resolved
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
>Resolved</button>
</div>
{#if query.isError}
<p class="text-error">Couldn't load: {errMessage(query.error)}</p>
{:else if query.isPending}
<p class="text-text-secondary">Loading…</p>
{:else if rows.length === 0}
<p class="text-text-secondary">
{resolved ? 'No resolved errors yet.' : 'No unresolved errors.'}
</p>
{:else}
<ul class="divide-y divide-border rounded-md border border-border">
{#each rows as r (r.id)}
<li class="flex items-start gap-3 px-3 py-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span class="truncate text-sm font-medium text-text-primary">
{r.track_title}
</span>
<span class="truncate text-xs text-text-secondary">
{r.artist_name} · {r.album_title}
</span>
<span class="rounded bg-surface-hover px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-text-secondary">
{kindLabel(r.kind)}
</span>
{#if resolved && r.resolution}
<span class="rounded bg-accent/15 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-accent">
{resolutionLabel(r.resolution)}
</span>
{/if}
</div>
<div class="mt-0.5 text-xs text-text-muted">
by {r.username} · {relativeTime(r.occurred_at)}
{#if r.detail}<span class="text-text-secondary"> · {r.detail}</span>{/if}
</div>
<div class="mt-1 truncate text-[11px] font-mono text-text-muted" title={r.track_file_path}>
{r.track_file_path}
</div>
</div>
{#if !resolved}
{@const a = actionsFor(r)}
<RowActionsMenu primary={a.primary} secondary={a.secondary} />
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<Modal
title="Delete file?"
open={deleteFileOpen !== null}
onClose={() => { deleteFileOpen = null; }}
>
{#if deleteFileOpen}
<p class="text-sm text-text-secondary">
This removes <span class="font-medium text-text-primary">{deleteFileOpen.track_title}</span>
from the library and deletes the underlying file. The error row
will be marked resolved.
</p>
<p class="mt-2 text-xs text-text-muted font-mono">{deleteFileOpen.track_file_path}</p>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => { deleteFileOpen = null; }}
class="rounded px-3 py-2 text-sm text-text-secondary hover:text-text-primary"
>Cancel</button>
<button
type="button"
onclick={confirmDeleteFile}
class="rounded bg-action-destructive px-3 py-2 text-sm text-action-fg hover:opacity-90"
>Delete file</button>
</div>
{/if}
</Modal>
<Modal
title="Mark resolved"
open={resolveOpen !== null}
onClose={() => { resolveOpen = null; }}
>
{#if resolveOpen}
<p class="text-sm text-text-secondary">
Marking <span class="font-medium text-text-primary">{resolveOpen.track_title}</span> resolved.
Pick how you handled it:
</p>
<label class="mt-3 block text-xs text-text-secondary">
Resolution
<select
bind:value={resolveChoice}
class="mt-1 block w-full rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
>
<option value="fixed">Fixed — track now plays</option>
<option value="ignored">Ignored — no action needed</option>
</select>
</label>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => { resolveOpen = null; }}
class="rounded px-3 py-2 text-sm text-text-secondary hover:text-text-primary"
>Cancel</button>
<button
type="button"
onclick={confirmResolve}
class="rounded bg-action-primary px-3 py-2 text-sm text-action-fg hover:opacity-90"
>Mark resolved</button>
</div>
{/if}
</Modal>
@@ -4,6 +4,11 @@ import { redirect } from '@sveltejs/kit';
// always renders the active sub-page. Bare hits go to the default tab
// (Artists, matching Android's LibraryScreen default). 308 = permanent
// + preserve method, so SPA navigations and direct loads behave the same.
//
// Drift #559: this was a +page.server.ts which DOES NOT run in
// production — the app is configured as adapter-static + ssr=false
// (see +layout.ts). +page.ts (universal load) runs client-side, which
// is what the SPA actually executes when a route is hit.
export const load = () => {
throw redirect(308, '/library/artists');
};
@@ -73,6 +73,8 @@
<AlphabeticalGrid
items={filtered}
getKey={(a: AlbumRef) => a.sort_title || a.title}
hasMore={!!query.hasNextPage}
onLoadMore={() => query.fetchNextPage()}
>
{#snippet item(album: AlbumRef)}
<AlbumCard {album} />
@@ -71,6 +71,8 @@
<AlphabeticalGrid
items={filtered}
getKey={(a: ArtistRef) => a.sort_name || a.name}
hasMore={!!query.hasNextPage}
onLoadMore={() => query.fetchNextPage()}
>
{#snippet item(a: ArtistRef)}
<ArtistCard artist={a} />
+24 -4
View File
@@ -14,11 +14,17 @@
} from '$lib/player/store.svelte';
import { formatDuration } from '$lib/media/duration';
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
import LikeButton from '$lib/components/LikeButton.svelte';
import QueueList from '$lib/components/QueueList.svelte';
import { pageTitle } from '$lib/branding';
const current = $derived(player.current);
// Per-frame interpolated playhead so the scrubber thumb glides
// smoothly between server ticks. Shares the helper with PlayerBar.
const smoothed = useSmoothPosition();
const skipPrevDisabled = $derived(player.index === 0 && player.position < 3);
const skipNextDisabled = $derived(
player.index === player.queue.length - 1 && player.repeat === 'off'
@@ -84,7 +90,13 @@
</p>
</div>
{:else}
<main class="flex flex-1 flex-col items-center justify-center gap-6 px-4 pb-6">
<!-- At lg+ the player splits into two panes: the focus column on
the left and a permanent Queue panel on the right. Below lg
the queue stays in the slide-in QueueDrawer (toggle button in
the bottom row). Wide-screen viewers get queue context without
opening a drawer; mobile keeps the focused single-column flow. -->
<main class="flex flex-1 overflow-hidden">
<section class="flex flex-1 flex-col items-center justify-center gap-6 px-4 pb-6 overflow-y-auto">
<div class="aspect-square w-full max-w-md overflow-hidden rounded-lg bg-surface-hover shadow-lg">
<img
src={coverUrl(current.album_id)}
@@ -123,13 +135,13 @@
min="0"
max={player.duration || 0}
step="0.1"
value={player.position}
value={smoothed.value}
oninput={onSeekInput}
aria-label="Seek"
class="w-full accent-accent"
/>
<div class="mt-1 flex justify-between text-xs text-text-secondary tabular-nums">
<span>{formatDuration(player.position)}</span>
<span>{formatDuration(smoothed.value)}</span>
<span>{formatDuration(player.duration)}</span>
</div>
</div>
@@ -230,12 +242,20 @@
aria-label="Open queue"
class="flex items-center gap-1.5 rounded px-3 py-2 min-h-[44px]
text-text-secondary hover:text-text-primary
focus-visible:ring-2 focus-visible:ring-accent"
focus-visible:ring-2 focus-visible:ring-accent
lg:hidden"
>
<ListMusic size={20} strokeWidth={1.5} />
<span class="text-sm tabular-nums">{player.queue.length}</span>
</button>
</div>
</section>
<aside
aria-label="Queue"
class="hidden lg:flex w-96 xl:w-[28rem] flex-col border-l border-border bg-surface"
>
<QueueList />
</aside>
</main>
{/if}
</div>
@@ -5,6 +5,10 @@ import { redirect } from '@sveltejs/kit';
// still lives here at /routes/playlists/[id]/+page.svelte — that URL
// matches the server API shape and is unchanged.
// 308 = permanent + preserve method; old bookmarks land on the new URL.
//
// Drift #559: this was a +page.server.ts which DOES NOT run in
// production (adapter-static + ssr=false, see +layout.ts). +page.ts
// universal load runs client-side and IS what the SPA executes.
export const load = () => {
throw redirect(308, '/library/playlists');
};