Commit Graph

9 Commits

Author SHA1 Message Date
bvandeusen ee7f0cdb42 feat(#392): publish quarantine + playlist mutation events
Slice 3a — extends the producer set onto the SSE bus.

quarantine events (5 producer sites):
- quarantine.flagged (user-side flag): broadcast so the flagger's other
  clients invalidate their Hidden tab and admins' clients invalidate
  their queue.
- quarantine.unflagged (user-side unflag): scoped to the user.
- quarantine.resolved / .file_deleted / .deleted_via_lidarr (admin
  actions): broadcast because a single admin action can affect every
  user who'd flagged that track.

playlist events (6 producer sites):
- playlist.created / .updated / .deleted: owner-scoped.
- playlist.tracks_changed: emitted for AppendTracks / RemoveTrack /
  Reorder. Owner-scoped. Single kind for all three mutations because
  the client invalidation logic is identical (refetch the detail).

Public-playlist subscribers (other users viewing someone else's public
playlist) intentionally left out — needs a separate broadcast kind, not
exercised yet at single-household scale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:28:05 -04:00
bvandeusen 1ddde12959 perf: lazy player source build + Cache-Control on byte endpoints
Two unrelated wins as a single batch.

Flutter — lazy source building in setQueueFromTracks:
Today: Future.wait builds all N AudioSource objects (drift queries +
LockCaching ctor) before the player can call setAudioSources →
play(). Measured at 83ms for a 25-track playlist, on top of the
~285ms initial-source preload.

New flow: build only the initial source, hand it to setAudioSources
([initial], initialIndex: 0) so play() can start, then background-
fill the rest. Forward direction (skipNext targets) added via
addAudioSource. Backward direction (skipPrev) inserted at index 0..
initialIndex-1 with _suppressIndexUpdates true so the unavoidable
currentIndex shifts don't push the wrong MediaItem onto the stream.

Saves the up-front source-build wait — tap-to-audio for long queues
should drop by ~80-100ms even on cache hits.

Server — Cache-Control on the three byte-serving endpoints:
- /api/albums/{id}/cover: max-age=86400, must-revalidate. Covers
  change rarely (re-scan, MBID enrichment); a day of cache is safe
  and skips conditional GETs for the bulk of a session.
- /api/playlists/{id}/cover: max-age=300, must-revalidate. Collages
  recompute when contents change; short enough for edits to feel
  fresh, long enough to skip repeat fetches during a session.
- /api/tracks/{id}/stream: max-age=31536000, immutable. Track bytes
  are immutable for a given id (scanner re-indexes by file_path; new
  files get new ids). LockCachingAudioSource on the Flutter side
  already disk-caches, but proper headers let it skip even the
  conditional 304 on repeat plays.
2026-05-11 22:18:58 -04:00
bvandeusen 4ce7be4296 fix(server): repair go vet failures from A1+A2 cleanup (orphan imports + needle deref) 2026-05-08 10:46:43 -04:00
bvandeusen 8cd8825efc refactor(server/api): migrate likes.go + playlists.go preludes (A2 2/N) 2026-05-08 08:20:33 -04:00
bvandeusen 754a7955cc refactor(server): final writeErr migration + ErrNotFound aliases (T3c) 2026-05-07 21:19:35 -04:00
bvandeusen 735383fb1d feat(server/playlists): generate cover collages for system playlists
System-generated playlists ("For You", "Songs like X") rendered with
empty placeholder boxes in the UI even when their contributing tracks
had album art. The build path inserted playlist rows directly without
ever invoking the existing playlists.GenerateCollage machinery —
that was only wired into user-edited playlist mutations.

After this change, BuildSystemPlaylists generates a 4-cell collage
for every system playlist it creates, post-commit. Same cover
mechanism user playlists use, same on-disk layout
(<DataDir>/playlist_covers/<id>.jpg). Cells whose source album lacks
art fall back to the FabledSword glyph, so a partially-covered
library still produces a sensible visual.

Threading: BuildSystemPlaylists, StartSystemPlaylistCron, and the
api lazy-build path all gain a dataDir string parameter; main.go
passes cfg.Storage.DataDir.

Drops the now-redundant PickTopAlbumCoverForArtistByUser lookup —
the collage is more visually informative than a single album cover
copy, and the seed-artist's top album is one of the contributing
tracks in the mix anyway, so it'll appear as one of the four cells.

Tests pass t.TempDir() for the dataDir parameter.
2026-05-07 08:14:13 -04:00
bvandeusen 0a1e76564d feat(server/m7-352): 403 system_playlist_readonly on playlist edit endpoints
Push kind='system' guard into each of the five Service edit methods
(Update, Delete, AppendTracks, RemoveTrack, Reorder) immediately after
the ownership check; map the typed error to 403 in writePlaylistErr;
add table-driven test covering all five verbs against a seeded system
playlist.
2026-05-04 09:29:37 -04:00
bvandeusen a90ff11f0f feat(server/m7-352): kind filter on GET /api/playlists + lazy fallback
Adds ?kind= filter (user|system|all, default user) to GET /api/playlists
so system mixes don't leak into slice-1 SPA views. Lazy background build
fires when kind=system|all and the caller's run row is stale (>24h).
Propagates Kind/SystemVariant/SeedArtistID through PlaylistRow and
playlistRowView wire types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:24:27 -04:00
bvandeusen c331168d3b feat(api): /api/playlists* handlers for M7 #352 slice 1
9 handlers covering create / get / list / update / delete / append /
remove / reorder / cover. Permissions enforced in the service layer
(ErrForbidden -> 403 not_authorized) so the handler is a thin
HTTP-shape adapter. /cover serves the cached collage from disk via
http.ServeFile; 404 when cover_path is nil.

Server gained a DataDir field so the playlists service can find the
collage cache; api.Mount picks up two new params (playlistsSvc and
dataDir). The stale TestRoutesRegisteredInMount Mount() call in
library_test.go was missing the tracks.Service argument added by an
earlier slice — fixed in passing.

Wire codes follow the project's existing nested errorBody envelope:
not_found, not_authorized, unauthenticated, bad_request, server_error.
The plan called for a flat envelope, but the api package's writeErr
already produces {"error":{"code":"...","message":"..."}} and
deviating here would break every existing client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:10:21 -04:00