Commit Graph

404 Commits

Author SHA1 Message Date
bvandeusen 222742e368 test(api): replace per-kind refresh tests with generic system tests
go vet broke because playlists_{discover,foryou}_refresh_test.go
referenced the handlers/types deleted in R2 (d67c0de). Consolidated
into playlists_system_test.go covering the generic
/playlists/system/{kind}/{refresh} endpoint: for_you + discover
200/shape, non-singleton & unknown kind → 404, no-auth → 401.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:29:27 -04:00
bvandeusen d67c0de596 refactor(playlists): #411 R2 — generic registry-driven system endpoints
Replaces the per-kind refresh/shuffle handlers with one generic
pair driven off the kind registry, in lockstep across both clients.

Server:
- systemPlaylistKind gains Singleton; RefreshableSystemKind(key)
  exported. for_you/discover singleton; songs_like_artist not.
- New generic POST /api/playlists/system/{kind}/refresh and
  GET /api/playlists/system/{kind}/shuffle ({kind} = raw
  system_variant). Non-singleton/unknown kind → 404. Deleted
  playlists_{foryou,discover}_refresh.go and the per-kind shuffle
  wrappers; serveSystemPlaylistShuffle core kept.
- playlistRowView.refreshable: server-derived flag so clients show
  the refresh affordance generically without hardcoding kinds.

Web (not drift-cached → uses the server flag):
- refreshSystem(variant) replaces refreshForYou/refreshDiscover;
  systemShuffle drops the for_you→for-you mapping (raw variant).
- PlaylistCard + detail page gate the kebab/Refresh button on
  playlist.refreshable; label is "Refresh {name}". Tests reworked;
  obsolete refresh-foryou/discover api tests deleted.

Flutter (list tiles are drift-cache-sourced → derive, no migration):
- Playlist.refreshable getter = isSystem && variant !=
  songs_like_artist (holds for all current + planned kinds).
- refreshSystem/systemShuffle use the raw variant; PlaylistCard +
  detail screen gate kebab/Regenerate/source-tagging on refreshable
  so songs_like_artist plays via get() (no by-kind endpoint).

Pure-plumbing refactor; CI verifies parity. Next (R3): the five
discovery mixes — each a candidate query + one registry entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:21:09 -04:00
bvandeusen e3957b8eed fix(lint): rename unused now param in produceDiscover to _
revive unused-parameter: produceDiscover keys off dateStr, not now,
but must keep the uniform systemPlaylistProducer signature. Blank
the unused param (param names don't affect func-type identity).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:33:05 -04:00
bvandeusen 1379595e82 refactor(playlists): #411 R1 — system-playlist kind registry
Behavior-preserving prep for the new mix types. Extracts the three
inline candidate computations in BuildSystemPlaylists into
producers (produceForYou / produceSeedMixes / produceDiscover) and
drives the build off a systemPlaylistRegistry. The shared
machinery (run-claim guard, atomic delete+insert tx, post-commit
collages) is now generic over a []builtPlaylist.

Fatal-vs-skip error semantics unchanged: a base query failure
(PickTopPlayedTracksForUser, PickSeedArtists) still aborts the
whole build; candidate-load / per-seed-artist / Discover-bucket
failures are still logged and just yield fewer playlists.
Materialize order (for_you, songs_like_artist, discover) is
unchanged and functionally irrelevant.

No API/client/schema change — CI's system/foryou/service tests
verify For You / Songs-like-X / Discover parity. Adding a new mix
is now: a producer + one registry entry + its candidate query.

Next (R2): generic /api/playlists/system/{kind}/{refresh,shuffle}
off the registry; then the new kinds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:25:54 -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 e43281d1d0 feat(playlists): #415 stage 2 — rotation-aware shuffle endpoint
GET /api/playlists/system/{discover,for-you}/shuffle returns the
caller's system playlist with tracks ordered: unplayed-this-rotation
first (shuffled), then already-heard (shuffled). When the whole
snapshot has been heard, ResetRotationState fires and the full list
reshuffles fresh.

Option A (operator's choice): a separate, intentionally-uncached
endpoint. The cached GET /api/playlists/{id} detail path stays pure
for "open to view"; this varies per play. Same JSON shape as the
detail GET so Stage 3 clients reuse track parsing with no new model.

Two explicit static routes per variant mirror the refresh handlers
and avoid chi static-vs-param ambiguity under /playlists/system/.
Empty/absent snapshot → 200 with empty track list (nothing to play,
not an error). Rotation reset failure is non-fatal — still returns a
playable reshuffled list.

No client wiring yet — Stage 3 makes web + Flutter call this on the
play/tile gesture and send `source` on play_started. Handler-level
test deferred to Stage 3 (needs the full service+pool harness; the
end-to-end path is exercised there).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:47:57 -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 69569a5c2b feat(playlists): expand For You to 100 tracks (Discover parity)
Closes Fable #414. forYouHeadN/forYouTailN go 12/13 → 50/50 so the
For-You snapshot is 100 tracks, matching Discover. Motivated by the
shuffle-on-play default that just shipped (#413): a 25-track shuffle
pool repeats fast; 100 makes re-plays within a day feel varied.

pickHeadAndTail already degrades gracefully when the candidate pool
is too thin for a full head/tail split (returns top-N-by-score),
mirroring how Discover returns <100 when its buckets are thin — no
new edge-case handling needed. No build-path test asserts the
For-You total; pickHeadAndTail unit tests pass their own head/tail
values so they're unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:32:55 -04:00
bvandeusen c29d25d1cb fix(playlists): dedup tracks across discover buckets
Discover playlists could surface the same track twice with the
duplicates landing back-to-back — a "first song plays, then plays
again, skip works" symptom user reported on v2026.05.13.0. Root
cause: interleaveBuckets rotates one track per pass per bucket but
never tracks which IDs it has already emitted, so a track that's
both a dormant-artist pick AND a random-unheard pick comes out
once from each bucket.

On a single-user server the crossUser bucket is empty, so the
redistribute step rolls its slots into dormant + random. Their
output then interleaves d0, r0, d1, r1, … — and when d0 == r0
(common: a dormant-artist track is also valid for random-unheard)
the result is [X, X, …] with adjacent duplicates.

Fix: track seen track IDs across all buckets while interleaving;
skip already-taken IDs and advance to the next index in that
bucket. Dedup priority is bucket order, so a track in both
dormant and random comes from dormant.

Regression test covers the single-user case directly. The existing
round-robin test still passes — no shared IDs in that fixture.

Note: stale duplicates already written to drift / served as cached
playlists will clear naturally on the next playlist rebuild (the
03:00-local refresh, or any manual /api/me/playlists/refresh).
2026-05-14 07:50:35 -04:00
bvandeusen 0119eacf14 feat(api): GET /api/home/index for per-item rendering (Slice B)
Sibling to /api/home that returns the same five sections (recently
added, rediscover albums, rediscover artists, most played, last
played) but as flat slices of entity ID strings instead of
denormalized objects. The Flutter client uses this to drive its
per-item rendering pass — small discovery response then per-tile
hydration via the existing /api/albums/:id, /api/artists/:id,
/api/tracks/:id endpoints.

Reuses recommendation.HomeData so the DB cost is identical to
/api/home. JSON payload shrinks roughly an order of magnitude on
populated libraries (no embedded title / artist / cover URL fields).

Old /api/home stays untouched so the web client and older Flutter
builds keep working — no min-client-version bump needed until both
clients have migrated.
2026-05-13 20:41:44 -04:00
bvandeusen 7cfaafd360 feat(#357): library_changes retention compactor
Closes the last deferred follow-up from #357. The library_changes
table is the append-only change log that drives /api/library/sync's
delta semantics — every mutation (scanner upsert, like, playlist
edit, track delete) writes one row. Without a retention policy the
table grows unbounded; the original migration (0025) called out the
follow-up explicitly.

New goroutine: sync.Compactor runs daily, deletes rows where
changed_at < now - 30 days. Logs a row count when non-zero so
operators can see compaction activity in the journal. First tick
fires on startup so a process that hasn't been compacted in a
while catches up immediately.

30-day retention matches the offline-mode spec
(docs/superpowers/specs/2026-05-09-flutter-offline-mode-design.md).
Clients with a cursor older than that hit the existing 410 fallback
path and resync from scratch.

Imported as syncpkg in main.go to follow the existing convention
(see internal/library/scanner.go).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:49:07 -04:00
bvandeusen 89ded7b46c feat(#402): publish album/artist like events on the SSE bus
#392 shipped track.liked / track.unliked but skipped the album +
artist symmetric pairs. Closes that gap so the Flutter Liked tab's
albums and artists sub-lists can listen for changes the same way
the tracks sub-list will (#402 wire-up lands next commit).

publishLikeEvent already handles the entity_type dispatch; the four
handler call sites just need the new lines.

For #402 follow-up to #392.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:27:23 -04:00
bvandeusen c2168afbf0 fix(playlists): use CRON_TZ-prefixed cron expressions for per-job timezone
gocron v2's WithLocation is a SchedulerOption (process-wide), not a
JobOption — there's no per-job location knob. To get per-user
timezones we use a cron expression with the CRON_TZ= prefix, which
the underlying robfig/cron parser honors:

  CRON_TZ=America/New_York 0 3 * * *

Fires at 03:00 in the named zone every day. Same DST-correctness as
the original WithLocation approach.

Fall-back to UTC moves inline (was validateTimezoneOrUTC); kept the
helper because the test file still exercises it.

Caught by go vet on CI after slice 2 pushed:
"cannot use gocron.WithLocation(loc) (value of type
gocron.SchedulerOption) as gocron.JobOption value"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:09:02 -04:00
bvandeusen 7fac264c73 feat(playlists): wire Refresh into PUT /api/me/timezone + registration
Completes the server side of #392 Half B.

PUT /api/me/timezone now calls scheduler.Refresh(ctx, userID) after
the DB write so the rescheduled daily-at-03:00-local job takes
effect synchronously. Failure to refresh is logged but doesn't
undo the DB write — the hourly reconciliation pass would pick it
up within an hour regardless.

POST /api/auth/register calls Refresh after successful user
insert so brand-new users get scheduled immediately rather than
waiting for the hourly pass to discover them.

system_cron.go deleted: the new scheduler subsumes its
responsibilities. The StartSystemPlaylistCron call in main.go is
also removed. Server restart now runs the new scheduler's startup
recovery + catch-up instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:58:24 -04:00
bvandeusen 46c8edfa82 feat(playlists): gocron-based per-user scheduler
Builds the per-user daily-at-03:00-local scheduler for #392 Half B.
Uses github.com/go-co-op/gocron/v2 with WithLocation(userTZ) for
each user's job. Hourly reconciliation pass keeps the in-memory
job set in sync with the active-users query.

Start sequence: clear stale in_flight rows; register a daily job
for every active user at their stored timezone; fire a one-shot
runOnce to catch up missed schedules during downtime; start gocron.

Stop drains and shuts down the gocron loop.

Refresh(ctx, userID) removes the user's existing job (if any) and
registers a fresh one at their current timezone — wired into the
PUT /api/me/timezone handler and new-user registration in the next
commit.

Server struct gains PlaylistScheduler; main.go constructs it after
the eventbus and threads it through to api.Mount. The existing
StartSystemPlaylistCron call stays for one more commit so we don't
have a window where no scheduler is running; Task 3 deletes it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:57:13 -04:00
bvandeusen 230da7bdcb feat(users): timezone column + PUT /api/me/timezone
Schema + endpoint scaffolding for #392 Half B (per-user timezone
scheduling). Adds two columns to the users table:

  - timezone text NOT NULL DEFAULT 'UTC' (IANA name)
  - timezone_updated_at timestamptz (nullable; populated on each PUT)

PUT /api/me/timezone validates the IANA value via time.LoadLocation
and writes the row. No scheduler integration yet — the scheduler
struct lands in the next commit and the handler-side Refresh call
in the commit after.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:54:45 -04:00
bvandeusen 5cd342d521 feat(playlists): daily seed rotation + jitter + 12+13 split for system playlists
Five diversity mechanics — applied to both For-You and Songs-Like-X.

1. For-You seed rotates daily across the user's top-5 most-played
   tracks. pickForYouSeedForDay uses userIDHash(user, day) mod
   len(seeds) so today's mix uses an entirely different similarity
   pool than tomorrow's. Within-day determinism preserved.
2. JitterMagnitude bumped 0.0 → 0.1. The scoring RNG is now seeded
   by userIDHash(user, day) rather than the no-op, so near-tied
   candidates shuffle daily without breaking within-day stability.
3. Head/tail split moves from 20+5 to 12+13. Roughly half the
   playlist comes from the tail now (daily-deterministic via
   tieBreakHash), giving the user substantially different content
   while a 12-track anchor of strong similarity matches keeps the
   mix recognizable.
4. Songs-Like-X seed artists shuffle daily across the user's top-5
   played artists. pickSeedArtistsForDay applies a userIDHash-seeded
   Fisher-Yates and takes 3.
5. scoreAndSortCandidates / pickTopN / pickHeadAndTail gain a userID
   parameter so the RNG can be seeded per-user; existing call sites
   updated; noopRNG removed.

Test fixtures widened similarity gaps (e.g. float64(50-i) instead of
(50-i)/50) so the new jitter (±0.1) doesn't perturb head ordering in
assertions about the head/tail mechanism. New seed_selection_test
coverage for userIDHash + pickForYouSeedForDay + pickSeedArtistsForDay
spans deterministic-within-day, varies-across-days, and graceful
degradation with small candidate pools.

PickTopPlayedTrackForUser replaced by PickTopPlayedTracksForUser
:many in the prior commit (b4801c2). The For-You seed lookup now
goes through pickForYouSeedForDay over the returned slice.
PickSeedArtists's LIMIT widened to 5 in the same prior commit.

For #392 Half A — system playlist content diversity. Half B
(per-user timezone scheduling) is a separate spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:19:49 -04:00
bvandeusen b4801c2dd3 refactor(playlists): SQL queries return top-5 candidate seeds
For-You + Songs-Like-X seed selection moves to Go-side daily rotation
(next commit). The SQL change just widens the candidate pool: top-5
played tracks instead of single top played track; top-5 artists
instead of top-3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:04:39 -04:00
bvandeusen 15063ca0b4 fix(#392): simplify uuid formatter — gofmt-clean loop
The hand-unrolled byte-pair version in reconciler.go tripped gofmt -s
and goimports. Replaced with the same loop-based formatter that
internal/library/eventbus.go uses — same behaviour, fewer lines, lint
clean. Two copies of the helper still exist (one per package) to keep
the no-back-edge property for both internal/library and
internal/lidarrrequests, but they're now identical and the duplication
is tiny.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:21:38 -04:00
bvandeusen f7dfeff256 feat(#392): scan run lifecycle events on the SSE bus
Slice 3c — completes the server-side producer set.

Publishes scan.run_started when InsertScanRun succeeds and
scan.run_finished after FinishScanRun completes (success or with an
error_message payload). Per-file progress events are intentionally NOT
emitted — they'd flood the stream during large library scans without
giving the admin scan card anything actionable. The two-beat signal
gives the UI enough to invalidate scanStatusProvider at the right
moments.

Bus access uses a package-level setter on internal/library because
threading bus through RunScan + TryStartScan + Scheduler + all their
callers would touch ~10 sites without changing behavior at the boundaries
that don't publish. Per-process singleton, matches the log.SetDefault
idiom. cmd/minstrel/main.go calls library.SetEventBus(bus) once at
startup; test contexts that never call it skip publishing safely
(publishScanEvent is a no-op when bus is nil).

This completes the server side of #392. Slice 4 wires the Flutter
consumer: live_events_provider.dart (StreamProvider) +
live_events_dispatcher.dart (event-kind → provider invalidation)
+ AppLifecycleState cold-start invalidation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:52:06 -04:00
bvandeusen 84fc6b8d1b feat(#392): lidarr reconciler publishes request.status_changed on complete
Slice 3b — extends event publishing to background workers.

When the reconciler matches an approved Lidarr request against the
library and flips it to 'completed', it now publishes a
request.status_changed event scoped to the original requester so their
/requests page invalidates without polling. Three call sites — one per
kind (artist / album / track) — capture the completed row instead of
discarding it so the publish has the user_id.

Bus plumbing: the reconciler runs in cmd/minstrel/main.go which
constructs services before server.New is called. Moved bus
construction into main; the Server struct gained a Bus field that
Router() reads, falling back to a fresh local instance when nil
(test contexts). Result: one bus per process shared by every
publisher and the SSE subscriber endpoint.

reconciler.go inlines a uuid->string helper rather than reaching into
internal/api for one — avoids a back-edge dependency.

Test compat: 9 NewReconciler call sites in reconciler_integration_test.go
get `nil` for the new bus param. The reconciler's publishCompleted helper
is a no-op when the bus is nil so tests that don't care about events
keep passing.

Scanner producer (scan.progress) deferred to slice 3c — needs more
thought about which lifecycle transitions warrant events vs. flood the
stream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:38:49 -04:00
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 3ffa5608d8 feat(#392): publish track-like + request-status events to SSE bus
Slice 2 of #392 — wires the first producers onto the bus that slice 1
built. After this commit, an SSE subscriber sees real events fire:

- track.liked / track.unliked when the user toggles the heart on a track
  (handleLikeTrack / handleUnlikeTrack). Album + artist like events
  intentionally deferred — they're symmetric trivial follow-ups but the
  operator's primary like surface is tracks.
- request.status_changed when a Lidarr request is created, cancelled,
  approved, or rejected. Auto-approve will fire twice (pending then
  approved) in rapid succession, which is semantically correct; client
  invalidation handles that fine.

Events are user-scoped via row.UserID so admin approve/reject route to
the requester, not the admin acting. Helpers live in events_publish.go
so the wire shape (kind names, payload keys) stays in one place — future
producers in slice 3 reuse the same pattern.

events_publish.go is no-op when h.eventbus is nil so tests that
construct handlers without a bus continue to pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:55:33 -04:00
bvandeusen a5500aeeff fix(#392): update library_test.go for new Mount signature
go vet caught a missed test caller of api.Mount after slice 1's signature
change added the *eventbus.Bus parameter. Pass eventbus.New() in the test
— the TestRoutesRegisteredInMount test only walks the route table for
existence, never publishes or subscribes, so a throwaway bus is fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:49:55 -04:00
bvandeusen 170614baf1 feat(#392): SSE event stream foundation — eventbus + /api/events/stream
Slice 1 of the #392 hybrid live-refresh work. Ships the in-process pub/sub
bus and the SSE subscriber endpoint; no producers wired yet, so the stream
emits only heartbeats today. Verifiable in isolation by curl-ing the
endpoint with a valid Bearer token — the connection opens, ": heartbeat"
lines arrive every 15s, the connection closes cleanly on client disconnect.

eventbus.Bus is a small fan-out broadcaster: subscribers register through
Subscribe (returns a receive channel + an unsubscribe closure), writers
call Publish, and the bus drops events for any subscriber whose buffer is
full rather than blocking the writer. No persistence — clients are
expected to resync via normal /api/* fetches on (re)connect.

The SSE handler emits an initial ": connected" comment so the client sees
the connection open immediately, then forwards events whose UserID matches
the authenticated user (or is empty for broadcast). Heartbeat comments
keep proxy connections alive. Context cancellation cleanly tears down the
subscription on client disconnect.

Producers (likes, request status, quarantine, scanner, playlist mutations)
land in subsequent slices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:44:37 -04:00
bvandeusen 1f0f7eee1a fix(playlists): unblock Discover by removing SELECT DISTINCT + ORDER BY plan error
The cross-user bucket query combined SELECT DISTINCT with ORDER BY md5(...),
which Postgres rejects at plan time (SQLSTATE 42P10). buildDiscoverCandidates
returned that error on first bucket failure, so the whole Discover playlist
was skipped every nightly run — even though the random bucket pool was
healthy. Switched to GROUP BY so the md5 ordering expression no longer needs
to appear in the select list, and hardened the function so future single-
bucket failures degrade gracefully via slot redistribution instead of taking
out the whole playlist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:50:01 -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 af5744f8ab perf(home): aggregate-first rewrites for two scan-the-world queries
handleGetHome itself is well-architected (5 sections in parallel via
goroutines, latency-bound by the slowest single query). The cold-
start lag is two of those queries doing wider scans than necessary.

ListLastPlayedArtistsForUser was iterating FROM artists a with a
LATERAL play_events join per row — O(total_artists in library) plan
even for users who've only played a handful. Inverted: aggregate the
user's plays by artist_id first via the play_events → tracks join
(uses play_events_user_track_idx + tracks pkey), then attach the
artist row and lateral cover/count subqueries only for the artists
that actually appear. Cost now bounded by play history, not library
size.

ListMostPlayedTracksForUser was joining tracks/albums/artists for
every play_event row before grouping — O(total plays) work for
joins. Pre-aggregated play_events into a CTE keyed by track_id +
count(*), then joined to tracks/albums/artists only for the
distinct-tracks survivors. Order-by uses the pre-computed count.

No handler or generated-Go signature changes — both queries return
the same rowset shape, just much faster on libraries where total
artists/plays >> distinct-played-artists/distinct-played-tracks.
2026-05-11 18:26:20 -04:00
bvandeusen c7549bbe48 perf(api): collapse N+1 in /api/artists/{id} + 1 round-trip in /api/albums/{id}
Two new sqlc queries replace three sequential per-album round trips
that were dominating detail-screen latency.

GetAlbumWithArtist: handleGetAlbum was doing GetAlbumByID then
GetArtistByID — separate round trips for one logical lookup. The new
query joins albums + artists with sqlc.embed and returns both in one
SELECT. Detail-page DB cost: 3 trips → 2.

ListAlbumsByArtistWithTrackCount: handleGetArtist was loading the
artist's album list, then issuing one CountTracksByAlbum per album to
populate track_count. On a 30-album artist that's 32 sequential
queries — each ~5ms over a local DB, ~30ms over a remote one. The
new query embeds the album row + a correlated count(*) subquery, so
every album's track count comes back in one SELECT regardless of
album count. Detail-page DB cost: 1 + N → 1 + 1.

Together these account for the bulk of cold-cache navigation latency
on the Flutter client. Combined with the existing SWR + nav
hydration on the client side, detail screens should render their
header instantly and the body within one round trip instead of
N+constant.
2026-05-11 18:13:27 -04:00
bvandeusen c3ecd1bec8 fix: gofmt -s on version.go (tab-indent doc-comment code block) 2026-05-10 23:15:29 -04:00
bvandeusen 4c4399c9bb feat(server,web): expose server version via /healthz + display in Settings
Came up debugging the in-app update flow — wasn't obvious which image
the container was actually running without exec'ing in. New flow:

### Server
- internal/server/version.go: new `var ServerVersion = "dev"`,
  overridden via -ldflags at build time.
- /healthz response gains a "version" key alongside the existing
  "status" + "min_client_version". Backward-compat: existing clients
  ignore unknown JSON fields. Endpoint stays unauthenticated.

### Build
- Dockerfile: new `ARG MINSTREL_VERSION=dev`, threaded into the
  go build -ldflags so the binary's ServerVersion is stamped at
  link time. Default "dev" preserves local `docker build` ergonomics.
- .forgejo/workflows/release.yml: tags step also emits a `version`
  output (the git tag for tag pushes, "main" for branch pushes);
  build step passes it as `--build-arg MINSTREL_VERSION=...`.

### Web
- web/src/lib/components/ServerVersion.svelte: small understated
  text ("Server v2026.05.10.2") that fetches /healthz on mount.
  Renders nothing on parse failure or pre-version images.
- Mounted at the bottom of Settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:13:19 -04:00
bvandeusen 154f415f92 fix(server,web): auth-gate client APK + version endpoints + per-user rate limit (#397)
Closes the bandwidth-abuse vector on the in-app update flow. Both
endpoints now sit inside the authed.Group; APK additionally gets a
60s/user rate limit to suppress accidental hammering or scripted
abuse.

### Server

- internal/api/client_assets.go:
  - clientAPKAllowDownload(): in-memory map[userID]time.Time under
    a mutex. Returns 0 (allow) or wait duration (block).
  - handleClientAPK reads user from context, checks the limit,
    returns 429 + Retry-After header if blocked.
  - testResetClientAPKRateLimit() lets tests start clean.
- internal/api/api.go: routes moved from the root /api group into
  the authed.Group block (alongside /quarantine, /requests, etc.).
- Tests: added TestClientAPK_401WhenUnauthenticated and
  TestClientAPK_RateLimit_429OnRapidSecondCall (also verifies
  different user gets a fresh slot). Existing tests updated to use
  authedRequest() helper.

### Web

- MobileAppDownload.svelte: switched from bare fetch (no credentials)
  to api.get<>() which carries the session cookie. 404 / 401 /
  network errors all silently hide the download row.
- Removed the login-page mount entirely — pre-auth surfaces should
  never show this. Settings → Mobile app section keeps it for
  logged-in users.

Flutter unaffected: dio's Bearer interceptor already attaches the
token, and the polling only fires once the post-login shell mounts
the banner widget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:28:27 -04:00
bvandeusen 14d7678624 fix: CI lint errors after #397 commits
- flutter analyze: Riverpod 3 dropped StateProvider; replaced
  _dismissedVersionsProvider with a small Notifier<Set<String>>
  + NotifierProvider, mutating via an `add(version)` method.
  Public API (shouldShowUpdateBannerProvider, dismissUpdateProvider)
  unchanged.
- golangci errcheck: defer f.Close() now wrapped in func() { _ = f.Close() }();
  os.Setenv calls in test helper switched to t.Setenv (cleaner — auto-restores
  on test cleanup, no manual Unsetenv needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:01:43 -04:00
bvandeusen a2bea8601a feat(server): /api/client/version + /api/client/apk endpoints (#397 phase 1)
Phase 1 of the in-app update flow — server side. Endpoints serve the
bundled Android APK + sidecar version file from /app/client/.
Returns 404 gracefully when files aren't present, so dev environments
and pre-CI-wiring images degrade cleanly to "no update available."

- internal/api/client_assets.go: handleClientVersion + handleClientAPK.
  Both unauthenticated (matches /healthz) so install flow doesn't
  depend on a live session. APK served with proper
  application/vnd.android.package-archive Content-Type +
  http.ServeContent so Range requests work for resumable downloads
  on flaky networks.
- Path resolves to /app/client/ by default; MINSTREL_CLIENT_APK_DIR
  env var overrides for dev.
- Dockerfile creates /app/client/ + commented COPY hooks for the CI
  sequencing phase.
- Tests cover all four states: missing apk, apk-but-no-version,
  both present (200 with correct shape), apk stream (200 with
  correct Content-Type + body bytes).

Phases 2 (Flutter client provider + banner + install intent + Android
manifest changes) and 3 (CI sequencing to bake the APK into the image)
land in follow-up commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:35:24 -04:00
bvandeusen 27f123f7d9 fix(server,flutter): sync wire format + systemVariant column (#357)
Two fixes in one commit because they're entangled — the systemVariant
work would have been theater otherwise.

## The wire-format bug

/api/library/sync was emitting PascalCase JSON for artist / album /
track / playlist upserts (raw json.Marshal of sqlc-generated structs
with no JSON tags — sqlc.yaml: emit_json_tags=false). Flutter's
sync_controller _*FromJson reads snake_case keys, so all metadata
sync rows landed in drift with empty strings / zero ints.

The like_track / like_album / like_artist / playlist_track entities
work because they're hand-built `map[string]string` payloads with
snake_case keys — they sidestepped the bug. The 4 raw-marshal
entities did not.

Existing sync test caught zero of this — it asserts on len(upserts)
not field shape.

Fix: server-side view structs in library_sync_views.go with proper
JSON tags + pgtype-flattening (UUID → 8-4-4-4-12 hex string,
Date → "2006-01-02"). Mirrors the playlistRowView pattern from
/api/playlists. New library_sync_views_test.go pins the wire keys
so future field-name drift breaks loud.

## systemVariant column (closes #357 plan C v1 limitation)

playlistSyncView now carries `system_variant` server → wire.

Flutter drift schema bumped from 1 → 2 with onUpgrade adding the
`systemVariant TEXT NULL` column to cached_playlists. Cursor reset
to 0 in the migration so existing rows refresh with the new field
on the next sync.

playlistsListProvider now filters locally by systemVariant:
- kind='user'   → systemVariant IS NULL  (the add-to-playlist sheet's intent)
- kind='system' → systemVariant IS NOT NULL
- kind='all'    → no filter

Closes the documented v1 limitation where the add-to-playlist sheet
showed system playlists alongside user-created ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:47:30 -04:00
bvandeusen 8d5c90e0ed fix(server): wrap defer tx.Rollback for errcheck + gofmt -s alignment
golangci-lint surfaced both on 9c7dec6:
- errcheck on bare 'defer tx.Rollback(ctx)' in 2 test files
- gofmt -s wanted tighter map-key alignment in library_sync.go +
  library_sync_test.go (auto-fixed by 'gofmt -s -w')

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:47:25 -04:00
bvandeusen 6fb6729beb feat(server): playlists service writes library_changes
Five sites wired:

- Create / Update / Delete playlist: pool-bound LogChange after success
- AppendTracks: tx-bound LogChange per inserted playlist_track row
- RemoveTrack: tx-bound LogChange after the delete + lookup the
  track_id pre-delete so the composite key is still resolvable

The two tx-bound paths (AppendTracks, RemoveTrack) treat LogChange as
required — failure rolls back the playlist mutation too. The pool-bound
paths Warn on failure to match the scanner / likes pattern (mutation
already committed; missed log row recovers on next mutation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:41:59 -04:00
bvandeusen e0c5789cee feat(server): like/unlike handlers write library_changes
All 6 like/unlike sites (track/album/artist × like/unlike) now emit a
library_changes row via the shared logLikeChange helper. Best-effort:
LogChange failures Warn but don't fail the HTTP response — a missed
log row is recovered at the next mutation on the same entity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:40:56 -04:00
bvandeusen 0fa7dc7982 feat(server): scanner + DeleteTrackFile write library_changes
Wires sync.LogChange into the library mutation sites so /api/library/sync
reflects upserts and deletes.

Architectural pivot: LogChange's signature is now (ctx, dbq.DBTX, ...) so
it works with both *pgxpool.Pool and pgx.Tx. The scanner doesn't run
mutations in explicit transactions, so it pool-binds; delete.go matches.
Tx-bound callers (likes/playlists in subsequent commits) keep atomicity.

Also: sync.FormatUUID centralizes the pgtype.UUID → canonical string
conversion that both the scanner and the sync handler need; library_sync.go
now uses it instead of a local copy.

Best-effort logging on scanner failures (Warn, don't fail the scan): a
LogChange error after a successful upsert is rare and self-healing — the
next scan that touches the entity re-emits the change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:39:58 -04:00
bvandeusen 6bd8a15c7a feat(server): mount /api/library/sync route
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:36:46 -04:00
bvandeusen 9bf4b504b3 feat(server): GET /api/library/sync endpoint
Returns batched upserts + deletes since the supplied cursor. Empty cursor
returns full snapshot; subsequent calls pull deltas. Per-user entities
(likes, playlists) are scoped to the authed user. Composite-key entities
(likes, playlist_tracks) use stable string ids encoded by sync.EncodeLikeID
/ sync.EncodePlaylistTrackID.

Behavior:
  204 No Content - no changes since cursor
  200 OK         - JSON syncResponse {cursor, upserts, deletes}
  410 Gone       - cursor older than oldest log row; client must reset
  401 / 500      - standard envelope errors

Adds sqlc queries GetAlbumsByIDs, GetTracksByIDs, GetPlaylistsByIDs to
mirror the existing GetArtistsByIDs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:36:26 -04:00
bvandeusen 3959c85111 feat(server): sync.LogChange helper + EntityType/Op constants
New package internal/sync. LogChange writes a library_changes row inside
the supplied tx. Encode helpers produce stable composite ids for like_*
and playlist_track entries. Subsequent commits wire LogChange into the
scanner / likes / playlists services.

Also: dbtest.dataTables now includes library_changes so test isolation
holds across runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:29:51 -04:00
bvandeusen cb35133843 feat(server): library_changes log table + sqlc queries
Append-only change log for library entities. Every mutation on
artists/albums/tracks/likes/playlists/playlist_tracks will write a row
in the same transaction as the mutation itself (wired in subsequent
commits). Powers the Flutter delta-sync endpoint (#357).

- 0025_library_changes migration (up + down)
- internal/db/queries/library_changes.sql (Insert, GetSince, MaxCursor, MinCursor)
- regenerated dbq from sqlc

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:28:26 -04:00
bvandeusen 22f03a5fe8 test(server/admin): cover new test endpoint response shape
- Updates the happy-path test stub to handle the three list endpoints
  Lidarr exposes (qualityprofile / metadataprofile / rootfolder) and
  asserts profiles + folders make it through to the response.
- Adds a partial-failure case where one list endpoint 5xxs; verifies
  ok=true, the failing list is omitted, and list_errors carries its
  bucket code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:07:47 -04:00
bvandeusen 0bc17a85fb feat(server/admin): test endpoint returns profiles + folders on success
Extends POST /api/admin/lidarr/test response to include quality_profiles,
metadata_profiles, root_folders, and an optional list_errors map when the
Lidarr connection succeeds. The three list fetches run in parallel after
the ping; any per-list failure goes into list_errors so the response can
still report ok=true with whatever data did come back. Failed-ping
response shape is unchanged.

This lets /admin/integrations populate its dropdowns on a single
round-trip during first-time setup, fixing the chicken-and-egg where
the dropdowns previously gated on cfg.Enabled (which can't be true
until the first save, which itself needs non-zero defaults).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:07:15 -04:00
bvandeusen c4cccea775 refactor(server): remove bootstrap admin path
The bootstrap-admin-from-env-vars flow was a holdover from before
self-registration could create the first admin. Now that handleRegister
+ CreateUserFirstAdminRace promotes the first user to register on an
empty users table (verified shipped in v2026.05.08.2 / #376), the
bootstrap path is just a second source of truth that confuses operators
(and leaves an "admin" account in the DB that nobody asked for).

Removes:
- internal/auth/bootstrap.go + its test
- The auth.Bootstrap call from cmd/minstrel/main.go
- AuthConfig + AdminBootstrapConfig structs from config
- MINSTREL_AUTH_ADMIN_USERNAME / _PASSWORD env reads + their test
- The auth: block from config.example.yaml
- Bootstrap-related comments in docker-compose.yml + README.md

The README quickstart now points operators at /register on first start
instead of "watch the logs for a one-time password."

Existing instances keep their bootstrap-admin row in the DB; operators
who want it gone can register a new admin via /register, promote them
in /admin/users, then delete the old bootstrap user (last-admin guard
will require the new admin to be promoted first). No migration needed.

Recovery story for forgotten admin passwords now hinges on Fable #321
(admin password reset CLI) — currently the only path back in if no
other admin exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:14:33 -04:00
bvandeusen 4f669c26e9 fix(coverart/test): set MinInterval in rate-limit-serialization tests 2026-05-08 13:21:45 -04:00
bvandeusen b5a138bb27 fix(server): repair recursive withUser, gofmt 2 files (golangci-lint) 2026-05-08 12:05:35 -04:00
bvandeusen 6a444334ea fix(server/api/test): drop unused 'context' imports A3 sed left behind 2026-05-08 11:29:36 -04:00
bvandeusen 923b8286ee fix(server/api/test): retype callRadio user param to dbq.User for withUser helper 2026-05-08 11:05:25 -04:00