Commit Graph

8 Commits

Author SHA1 Message Date
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 47aa178850 feat(playevents): #426B server — RecordOfflinePlay + /api/events play_offline
Server half of the offline-replay capture. New writer path
RecordOfflinePlay: writes a complete start+end play in one txn from
a caller-supplied `at` + duration_played_ms, applying the spec §6
skip rule (same AND-of-thresholds as RecordPlayEnded) and threading
`source` so #415 rotation advances for system-playlist plays just
like the live path. Generalizes RecordSyntheticCompletedPlay (which
hard-codes full completion). duration clamped to [0, track len].

New /api/events type "play_offline" → handleEventPlayOffline:
validates track + duration, reuses the existing req.At parse so the
play lands on the original timeline, not replay time. Subsonic
shim + live 3-call lifecycle untouched.

Flutter half next: EventsApi.playOffline, a play.offline
MutationQueue kind, and PlayEventsReporter capturing the completed
play + enqueuing it when the live calls have no server id / fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:14:21 -04:00
bvandeusen d2a0b7d780 feat(playlists): #415 stage 1 — rotation-state schema + play ingest
First of three stages for system-playlist sample-history dedup
(Fable #415, server-side rotation per the operator's choice).

Schema (migration 0027):
- play_events.source (nullable text): which surface a play came
  from. 'for_you' / 'discover' feed rotation; NULL for library /
  user-playlist / radio / Subsonic.
- system_playlist_rotation_state(user_id, playlist_kind,
  played_track_ids uuid[], rotation_started_at, updated_at): the
  per-(user,kind) set of already-heard tracks this rotation.

Ingest:
- New RecordPlayStartedWithSource on the writer; RecordPlayStarted
  is now a thin source="" wrapper so the frozen Subsonic shim is
  untouched (no signature ripple).
- When source is a known system kind, the same txn appends the
  track to rotation state (AppendRotationPlayed keeps the array a
  set via the conflict CASE).
- /api/events play_started accepts an optional "source".

No serve-behavior change yet — Stage 2 makes shuffle prefer the
unplayed tail + resets on exhaustion; Stage 3 wires the clients to
send source and consume the rotation-aware order.

Tests: rotation appends + dedupes for a system source; source-less
play writes no rotation row. (Existing RecordPlayStarted tests are
unchanged — same wrapper signature, identical behavior at source="".)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:40:17 -04:00
bvandeusen 937a1930ad test: add dbtest.ResetDB helper that preserves admin user
Integration tests previously TRUNCATEd the users table at setup,
which wiped the operator's admin login every time the suite ran
against a Postgres instance shared with their dev environment.

This commit:

- Adds internal/dbtest/reset.go exposing ResetDB(t, pool) and
  TestUserPrefix. ResetDB truncates every data table EXCEPT users,
  then deletes only users whose username starts with TestUserPrefix.
- Migrates 9 test files (subsonic/scrobble, subsonic/star,
  recommendation/candidates, scrobble/queue, similarity/worker,
  playevents/writer, playsessions/service, api/auth, api/me) to
  call ResetDB instead of issuing TRUNCATE-with-users.
- Renames hardcoded test usernames to use TestUserPrefix so ResetDB
  cleans them between runs. seedUser in api/auth_test now prefixes
  internally; existing call sites pass bare names.
- Leaves auth/bootstrap_test.go alone — it legitimately tests
  first-time admin bootstrap and must wipe users.

Verified locally: full integration suite with -p 1 runs cleanly
under the existing MINSTREL_TEST_DATABASE_URL setup, and the admin
row in the shared dev DB survives the run.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 11:55:06 -04:00
bvandeusen 4d123f1b71 feat(playevents): post-commit MaybeEnqueue in RecordPlayEnded + Synthetic 2026-04-28 09:23:21 -04:00
bvandeusen fe1cc46752 feat(api): capture contextual_likes on like during open play_event
Two new helpers in internal/playevents (CaptureContextualLikeIfPlaying,
SoftDeleteContextualLikes) called by both api and subsonic surfaces.
Like inserts a new contextual_likes row when LikeTrack actually
inserted (rows=1) AND there's an open play_event with a vector.
Unlike soft-deletes via deleted_at. Idempotent in both directions.

Subsonic wiring lands in the next commit.
2026-04-27 11:22:35 -04:00
bvandeusen d6acb3b63c feat(playevents): persist session_vector_at_play on every record path
RecordPlayStarted and RecordSyntheticCompletedPlay both capture the
session vector inside their existing transactions. Single private
helper queries the prior 5 tracks, builds the vector, UPDATEs the
just-inserted play_event. Tests verify the seed flag boundary and
session-scope isolation.
2026-04-27 11:19:16 -04:00
bvandeusen 0c7e1b0408 feat(playevents): add Writer with start/end/skipped/synthetic paths
Owns the auto-close-prior step (caps each user at one open row),
spec §6 skip classification rule on play_ended (AND of completion <
0.5 and duration_played < 30s), and skip_events row writes. Synthetic
completed play wires the Subsonic /rest/scrobble?submission=true path
into the same store as native events.
2026-04-26 00:10:02 -04:00