Closes Scribe #614, #615, server half of #616 surfaced by the 2026-06-04 divergent-provider audit.
- streamURL helper now used everywhere /api/tracks/{id}/stream is built (was inline concat in playlists.go and cast_token.go); add streamURLWithExt for the .ext cast variant.
- audioContentType in media.go is the canonical file_format -> MIME lookup; mimeForFormat in cast_token.go is now a thin wrapper that overrides the unknown-format fallback to audio/mpeg (Sonos rejects octet-stream). Adds mpeg/vorbis/wave aliases. Subsonic's contentTypeForFormat stays frozen per docs.
- coverart.ResolveAlbumPath extracted; api and subsonic both delegate to it.
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>
handleStar's track branch calls playevents.CaptureContextualLikeIfPlaying
when the underlying LikeTrack actually inserted a row. handleUnstar
calls SoftDeleteContextualLikes after every track-id unstar. Same
helpers as the api surface — single source of truth.
mediaHandlers struct gains a logger field threaded through Mount.
ListRecentSessionTracks + UpdatePlayEventVector for the vector capture
path inside RecordPlayStarted. LikeTrack switches to :execrows so the
contextual-likes capture can detect insert vs already-exists. Existing
callers updated to ignore the count for now; later tasks consume it.
Both return user's starred artists/albums/songs sorted liked_at DESC.
Cap at 500 entries per category for v1 (Subsonic spec doesn't define
pagination on these endpoints; M3+ can revisit if needed).
Validate-all-first atomicity on star: a missing entity refuses the whole
call with Subsonic error 70. Unstar is a best-effort delete (missing
entities are no-ops, matching client expectations after library moves).
submission=false → play_started (replaces in-memory nowPlayingMap).
submission=true (default) → synthetic completed play. The nowPlayingMap
struct + factory + the nowPlaying field on mediaHandlers are deleted;
play_events WHERE ended_at IS NULL is now the source of truth for
'currently playing,' reachable from M3+ work.
api.Mount now takes the shared *playevents.Writer instead of cfg so
the same writer instance feeds both surfaces.
Feishin's first-login flow hits getUser to discover the authenticated
identity's roles. We never registered the route, so chi returned a
plain-text 404 — Feishin's parser treats anything non-Subsonic as a
generic auth failure ("Failed to log in"), masking the real cause.
- Implement /rest/getUser with the full role bag. Admins get every
role; non-admins get the play-music subset. Single-user M1 means
cross-user lookups by admins return the caller's roles for now;
revisit when user management lands.
- Set sub.NotFound on /rest/* to emit a Subsonic envelope (code 0,
"Method not implemented") instead of plain text. Any future client
probing an unimplemented endpoint now sees a parseable failure.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
getCoverArt now falls back to sidecar images next to the first track in
an album, so browse responses can advertise a coverArt id whether or not
albums.cover_art_path is set. Worst case the client gets a Subsonic 70
when no sidecar exists.
http.ServeContent handles Range/ETag/If-Modified-Since; download forces
attachment disposition. getCoverArt tries albums.cover_art_path, then
falls back to cover.{jpg,jpeg,png}/folder.{jpg,jpeg,png} next to the first
track's file. scrobble submission=false records a track into an in-memory
nowPlaying map keyed by user — M2 will read this and wire real events.
Adds the Subsonic browse wire types (MusicFolder, Index, ArtistRef,
ArtistDetail, AlbumRef, AlbumDetail, SongRef, and their response
envelopes) with dual json+xml tags so a single struct serves both
formats. Includes UUID wire helpers (uuidToID/parseUUID), indexing
helpers (indexLetter), MIME mapping (contentTypeForFormat), and the
artist-name resolver used by album listings.
IDs are bare UUID strings on the wire — endpoint context disambiguates.
apiKey (OpenSubsonic) is preferred. t+s uses md5(subsonic_password+salt)
with a constant-time compare. p is disabled by default and gated by
SubsonicConfig.AllowPlaintextPassword; enc:HEX obfuscation decoded.
Auth failures write a Subsonic "failed" envelope so clients don't see
HTTP 401.
Renders one wire format per ?f= parameter; JSON wraps in
{"subsonic-response":...}, XML emits with xmlns, JSONP wraps in callback
or falls back to JSON when callback is empty.