Compare commits

...
Author SHA1 Message Date
bvandeusen 7529266f5b Merge pull request 'DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542)' (#156) from dev into main
release / web (push) Successful in 1m29s
release / go (push) Successful in 1m49s
release / govulncheck (push) Successful in 26s
release / android (push) Successful in 6m19s
release / integration (push) Successful in 6m47s
release / Build signed APK (releases and dev) (push) Successful in 6m17s
release / Attach APK to the Release (tag releases only) (push) Successful in 3s
release / Build + push container image (push) Successful in 23s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-10-26 12:32:32 -04:00
bvandeusenandClaude Opus 5.5 7d5dcd454e docs: two comments state the rule rather than its history (#6277)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m47s
release / go (push) Successful in 2m2s
release / android (push) Successful in 5m49s
release / Build signed APK (releases and dev) (push) Successful in 6m1s
release / integration (push) Successful in 6m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 18s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:20:17 -04:00
bvandeusenandClaude Opus 5.5 3fa5d9fc3a fix: an app update is offered only when the app changed; casting no longer sticks on "loading" when the same queue plays again (#6455, #6456)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m46s
release / android (push) Successful in 5m2s
release / Build signed APK (releases and dev) (push) Successful in 5m13s
release / integration (push) Successful in 5m22s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 13s
release / Verify release artifacts (tag releases only) (push) Skipped
ci/version.sh --apk names the APK from android/ alone, and the APK lane
calls it. The app offers an update when the server's APK name differs from
its own, so a name derived from the whole repo offered every phone a rebuild
of the app it already had on each server-only deploy. The image keeps the
union set: it bundles the APK under client/.

Casting (#6456):
- setMediaItems with the tracks already queued (play the queued album from
  another track) cleared the cast and waited for a resync that the id-keyed
  collector never ran. It is now a move within the queue: Seek TRACK_NR on a
  Sonos, start that track on a one-track renderer; the play() goes through.
- A queue replaced through setMediaItems could take the Sonos incremental
  diff, which cleared target without restoring active, so the app thought it
  was back on the phone while the speaker played on, and the chosen start
  track was ignored. The diff now runs only while the route is still active;
  a replaced queue reloads in full at its start index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:08:02 -04:00
bvandeusenandClaude Opus 5.5 fa75bd08d7 feat: a newer app is told in a snackbar on Home and installed from a sheet holding Settings' card, fetched ahead on Wi-Fi (#6452)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m41s
release / go (push) Successful in 1m55s
release / android (push) Successful in 5m19s
release / Build signed APK (releases and dev) (push) Successful in 5m31s
release / integration (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 28s
release / Verify release artifacts (tag releases only) (push) Skipped
Mirrors Librarian (#6446, #6448), after Gallery's update tray (canon #6367).

- Home shows "Version X of the app is on your server." / "…is downloaded
  and ready." with Update / Install; the button opens a bottom sheet
  holding the same About card Settings shows, so there is one install
  path. Each build is told at most once a day, kept on disk.
- The shell-wide update banner and its in-memory dismissals are gone.
- AppUpdates (singleton) holds the offer and the install, so Home and
  Settings show the same state. Checks run when either screen opens, on
  every return to the front, every 5 minutes while shown, and on Home's
  pull to refresh.
- UpdateFetchWorker downloads the newer APK ahead on an unmetered network
  (battery and storage not low, three tries); the download writes a .part
  file and keeps it under the build's name only once its size and sha256
  match, so Install is usually one tap.
- Back from allowing installs, the update the listener asked for carries
  on (kept in SavedStateHandle) instead of asking them to tap again.
- An offer needs a higher code AND a different name: the same app built
  again at a later minute is not offered.
- Server: /api/client/version reports the APK's sha256, worked out from
  the served file and cached by size and mtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:46:01 -04:00
bvandeusenandClaude Opus 5.5 de41169ccb fix(android): detekt — no four-way destructuring, single-track loader within the function cap (#6451)
The library-duration fallback rides on the poll outcome instead of being its
own member, and the adopt path reads the target's fields by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:46:01 -04:00
bvandeusenandClaude Opus 5.5 b1e55adc14 feat(android): any UPnP/DLNA renderer plays, not only a Sonos (#6451)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m33s
release / android (push) Failing after 1m44s
release / go (push) Successful in 1m48s
release / Build signed APK (releases and dev) (push) Successful in 4m54s
release / integration (push) Successful in 5m12s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Discovery already listed every MediaRenderer on the LAN, but playback only
spoke the Sonos native queue (AddURIToQueue, x-rincon-queue, Seek TRACK_NR,
Next/Previous), which a generic renderer faults on, so picking one fell
straight back to the phone.

A renderer that isn't a Sonos is now fed one track at a time by
SingleTrackLoader: SetAVTransportURI + Play for the current track, and
SetNextAVTransportURI for the one after it so the renderer moves on by
itself without a gap. A renderer without SetNext is advanced by the app
when the poll sees it stop (or report NO_MEDIA_PRESENT) at the end of a
track; a stop partway is still the stall watchdog's.

ActiveUpnp carries the queue mode, picked from the route at select. Skip,
previous and queue taps point a one-track renderer at the cursor's track,
the poll takes the queue position from the loader instead of the
renderer's Track=1, queue changes keep the playing track and re-arm the
next, and adopting a running session matches by track id. Sonos keeps its
native queue unchanged.

DIDL-Lite drops the Rincon metadata for non-Sonos renderers and declares
DLNA.ORG_OP=01 so renderers that need it will seek.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:38:29 -04:00
bvandeusenandClaude Opus 5.5 5dc6aa9c2f feat(android): Storage shows the Liked songs and Recently played budgets (#6309)
release / web (push) Successful in 1m46s
release / govulncheck (push) Successful in 18s
release / go (push) Successful in 1m57s
release / integration (push) Successful in 5m28s
release / android (push) Successful in 5m39s
release / Build signed APK (releases and dev) (push) Successful in 5m53s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 13s
release / Verify release artifacts (tag releases only) (push) Skipped
Two cap selectors, each with its usage, and a "Keep liked songs
offline" switch replace the single cache limit and its next-launch
caveat; caps apply at once.

Docs that said the liked bucket did not exist are restated, and the
audio-cache index's unused per-source eviction queries are deleted:
eviction follows the liked set, not the index's source column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:23:49 -04:00
bvandeusenandClaude Opus 5.5 ec1e59921b feat(android): liking a track downloads its audio into the Liked budget on Wi-Fi (#6308)
LikedAudioPinner downloads liked tracks' whole audio when "Keep liked
songs offline" is on: most recently liked first, one at a time, only
on an unmetered network while the server is reachable, and only while
the Liked budget has room for another track at CD bitrate, so it never
pushes out liked audio already on the phone. Tracks liked before this
ships are covered by the same pass. Any change (a like, an unlike, the
switch, the network) restarts the pass and cancels a download in flight.

Supporting changes:
- ConnectivityObserver.unmetered: the default network's NOT_METERED bit.
- Liked track ids are read newest first, and refreshIds stamps the
  server's newest-first order into likedAt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:23:49 -04:00
bvandeusenandClaude Opus 5.5 7ec8a82871 feat(android): the audio cache keeps a Liked songs budget and a Recently played budget (#6307)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m23s
release / Build signed APK (releases and dev) (push) Successful in 5m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
SimpleCache ran one LRU evictor at the rolling cap, so liked audio was
dropped like anything else and the offline Liked pool shrank as other
music played. TwoBucketCacheEvictor replaces it:

- a span counts against Liked when its track is liked, else Recently
  played; a liked track that is also played is charged to Liked only;
- each budget has its own cap (0 = unlimited) and drops its own
  least-recently-used audio first;
- CacheBudgetSync feeds it the signed-in user's liked ids and the Storage
  caps, so a like, an unlike or a cap change applies at once;
- nothing is evicted until the liked set is known, so startup never trims
  liked audio as Recently played.

The decisions live in a Media3-free BucketLedger with unit tests.
CacheConfig and PlayerModule, which only snapshotted the caps at launch,
are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:16:36 -04:00
bvandeusenandClaude Opus 5.5 b61474744b chore(android): delete the unread deprecated FabledSwordTokens alias (#6295)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m40s
release / integration (push) Successful in 5m14s
release / android (push) Successful in 5m20s
release / Build signed APK (releases and dev) (push) Successful in 5m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
UI code reads LocalFabledSwordTheme.current; nothing referenced the
dark-bound alias.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:23:55 -04:00
bvandeusenandClaude Opus 5.5 1aba1a41e6 fix(tracks): removing a track writes a track_remove audit row (#6294)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m13s
release / android (push) Successful in 5m19s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
RemoveTrack took the admin's id and dropped it, so a merge left an
audit trail and the more destructive removal did not. It now reads the
track's title, artist, album and file path before the delete and writes
them, with whether the album and artist went too, under the new
track_remove action.

The audit action groups lose their plan-era U1/U2/U3 headings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:16:41 -04:00
bvandeusenandClaude Opus 5.5 0d0b04bff0 test(subsonic): album-list fixture users carry distinct api token hashes (#6293)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m45s
release / integration (push) Successful in 5m23s
release / android (push) Successful in 5m35s
release / Build signed APK (releases and dev) (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
users.api_token_hash is unique, so the second seeded user needs its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:05:45 -04:00
bvandeusenandClaude Opus 5.5 f3b124f9b2 feat(subsonic): getAlbumList2 recent and frequent read the caller's play history (#6293)
release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m37s
release / go (push) Successful in 1m45s
release / integration (push) Failing after 5m27s
release / android (push) Successful in 5m40s
release / Build signed APK (releases and dev) (push) Successful in 5m55s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
type=recent orders the caller's albums by their latest play (skips
included); type=frequent orders them by completed plays (skips
excluded). Plays of tracks whose file is gone do not count, and both
page by size/offset like the other types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:58:09 -04:00
bvandeusenandClaude Opus 5.5 09e78962b2 fix(subsonic): getUser answers an admin's lookup with the requested user (#6292)
An admin asking for another username got their own name and roles back.
The handler now looks the user up by exact username and answers with
that user's payload; an unknown username is ErrDataNotFound. A
non-admin asking about someone else is still ErrNotAuthorized.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:58:09 -04:00
bvandeusenandClaude Opus 5.5 466912e202 docs: plan-era "v1", "Task N" and "Phase N" wording restated as facts (#6277)
release / govulncheck (push) Successful in 25s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m47s
release / integration (push) Successful in 4m35s
release / android (push) Successful in 7m5s
release / Build signed APK (releases and dev) (push) Successful in 7m16s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m1s
release / Verify release artifacts (tag releases only) (push) Skipped
The marker list behind the earlier server and web passes did not catch
"for v1", "Task 5", "Phase 2" or "today: Flutter". Those comments now say
what the code does.

Corrected: coverart said "both v1 providers" ship on. There are four
providers (MBCAA, TheAudioDB, Deezer, Last.fm), and Last.fm, which needs a
key, ships off.

Non-comment tokens unchanged against HEAD: 10 Go files, 7 TS/Svelte files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:05:03 -04:00
bvandeusenandClaude Opus 5.5 c96231106f docs: setup files and the client, shared and CI docs describe what is (#6277)
.dockerignore, .gitignore, deploy/initdb, ci-requirements.md,
client/README.md, shared/README.md and two web/scripts test names: dates,
issue/rule/spike ids and the stories behind each exclusion removed.

Corrected:
- client/README.md: the dev lane bundles its own APK; :main no longer
  exists; main bundles the recorded sidecar instead of re-deriving the
  version name. Its smoke-test step wrote a bare "v0.2.0", which the
  server rejects as a sidecar that is not JSON; it now writes the JSON
  shape, which the README documents.
- ci-requirements.md: one workflow, not four; the android lane builds no
  debug APK; main reads the recorded sidecar.
- .gitignore: CI fills client/ on every image build, not tag releases only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:04:02 -04:00
bvandeusenandClaude Opus 5.5 557887bb07 docs: workflow, Dockerfile and brand-tool comments say what each does (#6277)
release.yml, the Dockerfile and tools/gen-brand-assets.py keep every
constraint their comments explain (the tag map, the gate, immutable tags,
the shared-daemon Postgres guard, the version/channel split) and drop the
history around them: rule and family-idea numbers, issue and run ids,
dates, an operator quote, the "this used to say the opposite" paragraphs
and one-runner timings.

Corrected: the Dockerfile said client/ is filled on tag pushes only; every
build fills it (dev and tag push bundle their own APK, main bundles the
latest release's).

Only full-line `#` comments changed: non-comment lines are identical to
HEAD in all three files, and the three run: scripts whose text changed
differ only in shell comment lines. The release guard tests match raw
workflow text; none of their required or forbidden strings appear in the
new comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:01:39 -04:00
bvandeusenandClaude Opus 5.5 ae7e05c579 docs: Android comments say what the app does, not how it came to be (#6277)
release / govulncheck (push) Successful in 23s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m42s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 6m2s
release / Build signed APK (releases and dev) (push) Successful in 6m15s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Every flagged comment block in the Android app, its tests and its XML/TOML
config, rewritten with the cited issue in view: the constraint is kept, the
story behind it dropped, and issue, milestone, rule, phase and drift ids,
dates and operator quotes removed. "Mirrors the Flutter client's X"
pointers are gone; that client is no longer in the repo.

Comments that no longer matched the code, corrected:
- SyncController: a 410 resets the cursor and re-pulls; it never dropped
  the three local caches.
- PlayerFactory, PlayerModule, AudioCacheIndexEntity, CacheConfig,
  CacheSource: there is no eviction worker and no liked-track bucket;
  SimpleCache evicts by plain LRU over the rolling cap, and the liked cap
  is stored but unenforced.
- CacheSettings, StorageCard: the prefetcher exists (AudioPrefetcher);
  pin-on-like still does not.
- LibraryApi, LibraryRepository, BrowseViewModel: the cache now carries
  the missing-file mark; the browse indexes stay server-backed for one
  source of grouping.
- OutputRoute: UPnP routes exist; only CAST and SONOS are reserved.
- PlayerFactory, RemotePlayerState, OutputPickerController: a failed
  transport command does not drop the UPnP route.
- LibraryScreen: History, Liked and Hidden are real tabs, not placeholders.
- AVTransportClient: the extend loop lives in SonosQueueLoader.
- "the operator" meaning the listener now says "the user".

Verified with a Kotlin lexer comparing every file's non-comment tokens
against HEAD: 394 files, 0 differences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:59:07 -04:00
bvandeusenandClaude Opus 5.5 9f588b6d61 docs: the history phrases the id sweep missed, restated as present-tense facts or traps (#6277)
release / govulncheck (push) Successful in 19s
release / web (push) Successful in 1m14s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 5m18s
release / android (push) Successful in 5m33s
release / Build signed APK (releases and dev) (push) Successful in 5m40s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
A wider pass for "previous", "before the fix", "we shipped" and the
like. The chi double-subtree, uppercase Vorbis key and stopPropagation
comments now describe the trap they guard; commit hashes, CI run
numbers and a deleted file name are gone; uuidString's comment says
what it returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:43:11 -04:00
bvandeusenandClaude Opus 5.5 10d50dccbd docs: web comments say what the app does, not how it came to be (#6277)
Rewrites the web app's comments to rule 32. Task, milestone, rule and
spec ids are gone; regression stories are restated as the trap they
guard (public auth routes, the menu's stopPropagation, universal loads
under adapter-static, metric deltas with their margin). Flutter and
Tauri mentions now name the Android app or a native shell, operator
quotes and dates are dropped, and one library's counts no longer
appear outside test fixtures. Verified with a TypeScript-scanner token
comparison (template literals and regex literals re-scanned): only
comment text changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:42:12 -04:00
bvandeusenandClaude Opus 5.5 6028d78844 docs: migration headers keep the before and after, without ids, quotes or one library's numbers (#6277)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m33s
release / go (push) Successful in 1m49s
release / integration (push) Successful in 5m42s
release / android (push) Successful in 5m59s
release / Build signed APK (releases and dev) (push) Successful in 6m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Applies rule 32's migration exception: each header still says what the
schema was, what changes and what it prevents, but task, milestone,
decision and rule ids are replaced by the practice they stood for, the
operator quotes by the decision, single-install counts are dropped, and
pointers to a deleted package and an external design doc are gone.
golang-migrate keeps no checksum, so editing applied migrations'
comments is safe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:34:39 -04:00
bvandeusenandClaude Opus 5.5 1d4ef1a23f docs: query comments say what each query selects and why, not how it came to be (#6277)
Rewrites the internal/db/queries comments to rule 32 and regenerates
dbq, whose doc comments and embedded SQL carry them. The bugs behind the
tiered mixes, the md5 order seed, the score bands and the round-robin
arms are restated as the constraint each one enforces; counts from one
library and stale cross-references are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:31:13 -04:00
bvandeusenandClaude Opus 5.5 7e6228c13f docs: library comments say what the scanner and resolver do, not how they came to be (#6277)
release / web (push) Successful in 1m20s
release / govulncheck (push) Successful in 20s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m26s
release / Build signed APK (releases and dev) (push) Successful in 5m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Rewrites the internal/library comment blocks to rule 32. Task, milestone,
decision and rule ids are gone; the incidents behind them are restated as
the constraint they left (why a file is deleted before its row, why the
missing mark is a mark, why duplicates block on duration). Counts and
album names from one library are dropped from production comments; test
comments keep the names their fixtures use. The tagReadVersion log keeps
each version's before and after, without the measurements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:27:34 -04:00
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:23:58 -04:00
bvandeusenandClaude Opus 5.5 fcf5b7e5a8 docs: wanted, dbtest, recommendation and playlists comments say what the code does (#6277)
The same rewrite as d191a282, for four packages whose comments carried the
most history: tuning stories, quotes from the operator, single-install
measurements in library_scale.go, and 'used to' accounts of bugs. The
constraint each story protected stays as a present-tense reason (why tier 3
is reduced not removed, why the randomised arms take an order seed, why the
seed artist is eligible in Songs-like). Comments naming system_cron.go and
config.RecommendationConfig, neither of which holds what they claimed, now
name the scheduler and internal/recsettings.

Checked: non-comment tokens identical to HEAD; gofmt clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:19:15 -04:00
bvandeusenandClaude Opus 5.5 d191a28217 docs: server comments say what the code does, not how it came to be (#6277)
Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.

Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
  it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
  not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
  today rather than a milestone that would change it.

Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:15:05 -04:00
bvandeusenandClaude Opus 5.5 9f9609591d docs: README in the standard order; setup files say what works today (#6277)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m40s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m32s
release / Build signed APK (releases and dev) (push) Successful in 5m51s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
README: logo header, short summary, a compose quick start with one-line
comments, first-run steps, a configuration table, setup pitfalls,
updating and backups, then features grouped by what you do. The Specs
section (it called the Android client the "Flutter companion app"), the
dated tag-history sentence and the Fable pointer are gone. Feature lines
were checked against the code: radio seeds from a track only, and the
Android app plays from its cache rather than offering downloads.

.env.example used the SMARTMUSIC_ prefix, which the config loader has not
read for a long time, so copying it set nothing. It now uses MINSTREL_.

config.example.yaml is also the config the image bakes in at
/etc/smartmusic/config.yaml. It listed five recommendation weights
(base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude)
that moved to Admin > Tuning; yaml.Unmarshal ignored them silently. They
are removed, and the file parses to exactly Default(), so the running
server is unchanged. coverart_from_mbcaa is not listed: nothing reads it.

docker-compose.yml, the Makefile and the spec banners lose their task and
milestone ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:02:25 -04:00
bvandeusenandClaude Opus 5.5 eac320c8d7 test: the notifications view model is built inside each test, after the test Main dispatcher is set (#6275)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m12s
release / android (push) Successful in 5m23s
release / Build signed APK (releases and dev) (push) Successful in 5m42s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
Built as a property initializer it ran at class construction, before
MainDispatcherExtension's beforeEach, so viewModelScope.launch in init hit
the missing Main dispatcher. Its uncaught exceptions also failed the next
coroutine test in the JVM (RetryTransientIoTest).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:49:22 -04:00
bvandeusenandClaude Opus 5.5 f381622d20 feat: opening the notifications inbox marks what it shows as read (#6275)
release / govulncheck (push) Successful in 22s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m39s
release / android (push) Failing after 4m25s
release / integration (push) Successful in 5m8s
release / Build signed APK (releases and dev) (push) Successful in 5m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
The web panel and the Android Notifications screen now mark every unread
notice they show as read the moment they appear, so the badge clears on a
look instead of a dismissal. Rows that were unread keep looking new until
the inbox closes. The read goes through read-all with a cutoff of the newest
notice shown plus 1 ms, so one that arrived unseen stays unread. 'Mark all
read' is gone; opening the inbox does its job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:41:37 -04:00
bvandeusenandClaude Opus 5.5 4756e957ce docs: the shutdown comments say what happens, without the task number
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:41:37 -04:00
bvandeusenandClaude Opus 5.5 79b90bda03 fix: shutdown ends the live-event streams so a redeploy drains instead of waiting out its deadline (#5293)
release / govulncheck (push) Successful in 24s
release / web (push) Successful in 1m49s
release / go (push) Successful in 2m3s
release / integration (push) Successful in 6m1s
release / android (push) Successful in 6m25s
release / Build signed APK (releases and dev) (push) Successful in 6m24s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
http.Server.Shutdown waits for in-flight requests but never cancels them,
and an SSE stream only ends when its client leaves, so any open tab or
signed-in phone held every redeploy for the full 10s and the old task
exited 1. With the swarm's stop-first update that was ~10s of downtime per
deploy. The bus now closes on shutdown, ending each stream; clients
reconnect to the next instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 06:46:18 -04:00
bvandeusenandClaude Opus 5.5 fefc51439e fix: Android's admin requests names who asked from the request itself (#6267)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m35s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m27s
release / android (push) Successful in 5m33s
release / Build signed APK (releases and dev) (push) Successful in 5m51s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
The admin requests list now sends each requester's username (#6265), so
the Android queue reads it off the row instead of fetching the whole
admin users list alongside and mapping ids to names. The short id stays
as the fallback for a row that comes back without a name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:10:17 -04:00
bvandeusenandClaude Opus 5.5 687a7cf3ee test: the admin requests list test names the requester by their seeded username (#6265)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m36s
release / go (push) Successful in 1m54s
release / integration (push) Successful in 5m37s
release / android (push) Successful in 5m48s
release / Build signed APK (releases and dev) (push) Successful in 5m48s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m9s
release / Verify release artifacts (tag releases only) (push) Skipped
seedUser stores users as test-<name>, so the assertion compares against
alice.Username rather than the literal "alice".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:49:54 -04:00
bvandeusenandClaude Opus 5.5 c98bd359f7 fix: the admin requests list names who asked, not a cut-off user id (#6265)
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m51s
release / integration (push) Failing after 5m37s
release / android (push) Successful in 5m54s
release / Build signed APK (releases and dev) (push) Successful in 5m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
/admin/requests showed "by user f7e1ec86" — the first eight characters of
the requester's id, a placeholder from the first build. The admin list now
sends each requester's username, looked up for the whole page in one query,
and the row reads "by <username>". The id stays as the fallback for a list
that comes back without names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:43:18 -04:00
bvandeusenandClaude Opus 5.5 b9684e6d12 fix: a group of one recording in several slots is settled by getting the album again, not merged (#6184)
release / govulncheck (push) Successful in 36s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m45s
release / android (push) Successful in 5m9s
release / integration (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
The duplicates review offered only Merge for a wrong-slot group. A merge keeps
one copy's title whether or not it is the audio's, and neither sets the release
aside nor wants the missing song back.

For class mismatch the page now asks which song the recording is. "Get it
again" (the operator can't tell) removes every copy; "Keep <title>" keeps that
copy and removes the ones under other titles. Either way it runs the resolver's
own path: the release the files came from is set aside, the removed copies'
tracks stay marked missing and emptied, and the album is wanted again.

- library.SettleWrongSlot, POST /api/admin/library/duplicates/{id}/refetch
- ListDuplicateGroupsForResolve takes an optional group_id
- removeWrongSlots takes the acting operator (manual: not automatic, actor in
  the audit row) and removes files through removeTrackFileOnDisk, so a
  read-only library answers library_not_writable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:47:43 -04:00
bvandeusenandClaude Opus 5.5 5bcbc80555 fix: the taste arm round-robins across taste artists, and tag ties follow the order seed (#3880)
release / govulncheck (push) Successful in 31s
release / web (push) Successful in 1m37s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m22s
release / Build signed APK (releases and dev) (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m29s
release / Verify release artifacts (tag releases only) (push) Skipped
Measured on the operator's library (26,005 tracks, pool scale 2.28): the
arms the library scale grows all have far more rows than it asks for, and
two arms collapsed whatever their limits were.

- taste_overlap ordered by weight then track id, so the strongest taste
  artist's catalogue filled it: 20 tracks of saib. (197 tracks) out of 174
  taste artists, the same lowest-id tracks every time. It now takes one
  track per taste artist, strongest first, md5-seeded, as similar_artists
  does since #5297.
- tag_overlap broke ties in no order. A one-tag seed ties 7,804 tracks,
  and the LIMIT took whichever Postgres reached first, which the order
  seed did not control. Ties now follow md5(id || seed).
- library_scale.go records the per-arm measurement beside the reference
  size it leaves as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:12:58 -04:00
bvandeusenandClaude Opus 5.5 484a81f779 fix: a release its download client will not let Minstrel follow is held back, not taken every pass (#6176)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m34s
release / integration (push) Successful in 5m5s
release / android (push) Successful in 5m7s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 22s
release / Verify release artifacts (tag releases only) (push) Skipped
The Black Keys' Ohio Players was grabbed 40 times in a day. FabledExchange
already held the torrent under another category from before Minstrel took
the downloads over: the add answered "duplicate", which counted as added,
and Exchange's listing, filtered to Minstrel's category, never showed it.
Half an hour later the grab ended gone, and the next pass took it again.

- A duplicate add that the client does not list for Minstrel is not a
  grab: the release is held back for a day with the client's own reason
  (already downloading X (category)), and the search moves on. Forced,
  it is refused with that reason.
- A release that ends gone a second time within a week is held back for
  a day, whatever the cause.
- release_blocks gains the reason 'gone' (migration 0098), shown as
  "Held back: its download client lost track of it".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:31:57 -04:00
bvandeusenandClaude Opus 5.5 cb1f619a21 fix: a track shorter than the fingerprint span keeps its print (#6178)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m26s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 5m14s
release / android (push) Successful in 5m15s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m18s
release / Verify release artifacts (tag releases only) (push) Skipped
The image's fpcalc 1.5.1 exits 3 with "Error decoding audio frame (End
of file)" whenever it reaches the end of a file before -length, at any
length, so every track under two minutes failed both the AcoustID lookup
print and the duplicate-detection print: 471 lookups and all 1,979
present short tracks' chromaprints.

- runFingerprintTool returns the tool's stdout beside a toolExitError.
- fpcalcReadToEnd keeps a partial exit whose stderr is the end of file
  and whose declared DURATION is within the span asked for. A damaged
  file that stops early declares more than it read, and still fails.
- Migration 0097 clears the stored failures so the lookup worker and
  the fingerprint backfill take those tracks again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:01:23 -04:00
bvandeusenandClaude Opus 5.5 e44025435e fix: ListenBrainz similarity is stored relative to the seed's best, and the candidate arms keep tier order (#6177)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m4s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 30s
release / Verify release artifacts (tag releases only) (push) Skipped
LB's similarity scores are counts (16 to 1445 for recordings, to 10,875
for artists), and they reached a scorer that reads similarity as 0 to 1.
One edge added 64 to 5,800 points under Songs-like, so likes, skips and
recency stopped mattering for any candidate with an edge.

- ResolveListenBrainzTrackEdges divides each edge by the seed's best
  score in its whole cached answer. The cache keeps the raw answer.
- The worker stores matched and unmatched artist rows over the seed's
  top artist.
- Migration 0096 rescales the rows already stored.
- The candidate arms hold sim_score bands so tier 1 stays above tier 2:
  lb_similar 0.5 + 0.5 x edge, tag_overlap halved, likes_overlap 0.3.
  Otherwise a seed's weaker LB edges fell below a shared genre tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:56:26 -04:00
bvandeusenandClaude Opus 5.5 46e1dd6c9c test(android): run the sync pass in the test body, not through init's background collector (#5654)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m5s
release / android (push) Successful in 5m10s
release / Build signed APK (releases and dev) (push) Successful in 5m23s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
advanceUntilIdle does not count backgroundScope work as pending, so the
pass init would start never ran. The network state is Offline so
construction starts none, and each test calls syncSafe itself and asserts
lastSyncError stays null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:03:51 -04:00
bvandeusenandClaude Opus 5.5 77f06b60f7 test(android): name the 204 body type so Response.success resolves (#5654)
release / govulncheck (push) Successful in 19s
release / web (push) Successful in 1m32s
release / android (push) Failing after 4m13s
release / go (push) Successful in 1m45s
release / integration (push) Successful in 5m16s
release / Build signed APK (releases and dev) (push) Successful in 5m15s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:57:29 -04:00
bvandeusenandClaude Opus 5.5 42132810b0 fix(android): sync pulls every page of the change log, and a 404 drops the cached row (#5654)
release / govulncheck (push) Successful in 32s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m37s
release / android (push) Failing after 3m58s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m38s
release / Build signed APK (releases and dev) (push) Canceled after 4m39s
The server returns at most 1000 changes per /api/library/sync call and
expects clients to loop with the returned cursor. SyncController took one
page per trigger (cold start, reconnect), so the cache fell behind a log
that a full re-read grows by ~26,000 rows and, past the 30-day compaction,
reset through 410 to cursor 0 without ever reaching the merge deletes of
2026-10-09/10. The freshness sweep then re-asked for those tracks every
30 minutes: the same ~100 404s on every pass.

- SyncController pages until 204, storing the cursor after each page, and
  stops on a cursor that does not advance.
- MetadataProvider drops a cached album, artist or track the server
  answers 404 for, so rows orphaned before this fix stop being refetched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:52:54 -04:00
bvandeusenandClaude Opus 5.5 210f884b2d fix: a file tagged with no release group joins the same-named album its folder holds
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m31s
release / integration (push) Successful in 4m58s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Under e45561e2 a file with no group tag took the oldest same-named album row,
which left three of Weezer's 1994 bonus tracks on the 2016 album. The lookup
now ranks same-named rows by how many tracks they hold from the file's
folder; tagReadVersion 8 re-places the existing ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:44:45 -04:00
bvandeusenandClaude Opus 5.5 e45561e282 fix: same-titled albums of two release groups are two albums, and a missing track with no disc is on disc 1
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m30s
release / integration (push) Successful in 5m0s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
The scanner found an album by artist and title alone, so Weezer's six
self-titled albums (1994–2026) were one 118-track row and their merges,
wants and upgrades counted them as one. A same-named row whose release group
is known and differs from the file's is now another album; tagReadVersion 7
re-reads the library so existing tracks move to their own rows.

WantHoldings compared discs with IS NOT DISTINCT FROM, so a missing track 6
with no disc was never covered by a present disc 1 track 6. Air's Love 2
stayed wanted and was downloaded again for nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:13:58 -04:00
bvandeusenandClaude Opus 5.5 f32abc974f fix: a duplicate fold never deletes a lossless copy for a lossy one, nor folds two versions (M498)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m31s
release / android (push) Successful in 4m58s
release / integration (push) Successful in 5m0s
release / Build signed APK (releases and dev) (push) Successful in 5m7s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
The first album-copy fold pass on 2026-10-20 removed five FLACs (Måneskin
Rush!, Weezer 2016) in favour of mp3s whose only edge was a recording MBID,
and an explicit "Suite-Pee" for its clean version. ProposeSurvivor now ranks
lossless before tag fit; tag fit still outranks file size, which is what the
Humanz rips needed. The fold keys copies by their version words (title and
file name), so a clean, live or instrumental copy holding the same title tag
is linked, not folded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:36:55 -04:00
bvandeusenandClaude Opus 5.5 16a152b862 test: the album-copy fold test makes each album once, as UpsertAlbum folds only by MBID (#5401)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m38s
release / android (push) Successful in 5m8s
release / integration (push) Successful in 5m8s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:01:16 -04:00
bvandeusenandClaude Opus 5.5 3ebd35e51d fix: a slot the resolver emptied folds only into a copy of its own song (M498)
release / web (push) Successful in 1m28s
release / go (push) Successful in 1m36s
release / android (push) Successful in 5m2s
release / govulncheck (push) Successful in 16s
release / integration (push) Failing after 5m7s
release / Build signed APK (releases and dev) (push) Successful in 5m12s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
On 2026-10-20 the emptied "Open the Gateway" slot of Auvic's Speed Demons
was folded by title into another file tagged "Open the Gateway" that holds
Onward (244 s against the song's 254 s), so the album was never wanted
back. A wrong-slot removal now stamps tracks.emptied_at (migration 0095)
when the album holds no copy of the song as long as its MusicBrainz slot,
and missing-pair folding pairs such a row by title only with a track added
since: a copy fetched again. A slot whose song the album does hold (VOID
DLC's Weeper beside Void's) folds into it as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:55:23 -04:00
bvandeusenandClaude Opus 5.5 09e74a57a2 feat: an album holding a song twice beside its single keeps one copy (#5401)
A cross-release group was only ever linked, never merged, so an album that
held the song twice among the releases kept both: Humanz has Saturnz Barz,
Andromeda, We Got the Power and Garage Palace as its FLAC and a YouTube mp3,
each beside the single. The resolver now merges an album's extra copies
into its best (the one that is not a video rip, else ProposeSurvivor:
lossless first) with ReplaceTrack, history and all, then links what is
left as one song.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:55:23 -04:00
bvandeusenandClaude Opus 5.5 80ecc2f7e7 fix: imported FLAC, Ogg and Opus files keep their track and disc numbers (#6171)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 5m1s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
The importer wrote track=N/M for every format, which ffmpeg stores in a
Vorbis comment as TRACKNUMBER=3/20; dhowden/tag reads a Vorbis TRACKNUMBER
with a bare Atoi, so it came back 0. All 245 tracks imported since
2026-10-10 (Humanz, Plastic Beach, the Gorillaz super deluxe and twelve
more albums) have no track or disc number, and their album pages have no
order.

- The importer writes a Vorbis track and disc number alone, with
  TRACKTOTAL and DISCTOTAL, as Picard does. ID3 and MP4 keep N/M.
- ReadTrackAndDisc takes the number before a slash when the Vorbis reader
  returns 0, for the scanner and for the import's own check of a download.
- tagReadVersion 6 re-reads the files already held (tag reads only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:49:44 -04:00
bvandeusenandClaude Opus 5.5 42e48efb79 fix: the duplicate resolver waits for a running scan, so a copy it removes is not marked present again (#6169)
release / govulncheck (push) Successful in 13s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m38s
release / integration (push) Successful in 5m6s
release / android (push) Successful in 5m10s
release / Build signed APK (releases and dev) (push) Successful in 5m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
At boot the scan lists the library, the resolver removes wrong-slot copies
and marks their tracks missing, then the scan reconciles from its list and
marks them present: 7 of the 13 removed on 2026-10-20 15:00 read as
present with no file, and would not be wanted back until a later scan. A
pass now waits while a scan younger than StuckScanThreshold is in flight
and tries again a minute later; a stuck run does not hold it up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:04:46 -04:00
bvandeusenandClaude Opus 5.5 3a482bd715 fix: AcoustID decides any one-album mismatch the lengths cannot, not only copies each cut to its slot (#6169)
release / govulncheck (push) Successful in 13s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m3s
release / android (push) Successful in 5m6s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
The first pass on the operator's library held 16 groups. In several, every
copy's own lookup names one recording (Wombats Record Shop/Tormentor,
Alchemist (2)/Prelude, Monday Chill 8/9, 404sessions), but one copy sits 3-10s
from its slot, so the every-copy-fits precondition kept AcoustID out. Now:
copies whose lookups all agree are that recording, whatever their
fingerprints read; copies of the same audio are asked about as before; and
the track list places the recording in one copy's slot, or none. Copies
filed as one song are not settled here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:52:42 -04:00
bvandeusenandClaude Opus 5.5 18d31e9cdd feat: one recording cut to several songs' lengths is named by AcoustID; the copy in its slot stays, the rest go, and the release is set aside (#6169)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m31s
release / android (push) Successful in 5m1s
release / integration (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
Savant's Mortals held one recording as 16 of its 50 tracks, each trimmed to
its own slot's MusicBrainz length, so the lengths could not say which slot
was its own and every group waited for the operator. When a one-album
mismatch is the same audio (identical, or fingerprints within 0.02) and
every copy fits its own slot, the resolver asks what the recording is: the
copies' own AcoustID lookups, or a fresh lookup of one copy (five a pass).
The track list names each slot's recording; the copy in that slot is kept
and the others removed, or every copy when the recording is in none of
their slots or AcoustID knows none. AcoustID unreachable or answers in
several copies' slots: the group waits.

Whenever wrong-slot copies are removed, the download the files were
imported from is set aside, so the album fetched again is not the same
files. Albums imported before Minstrel downloaded anything have none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:33:55 -04:00
bvandeusenandClaude Opus 5.5 e3eddab80f feat: the duplicates review says what each match rests on and plays every copy (#6168)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m34s
release / android (push) Successful in 4m58s
release / integration (push) Successful in 5m4s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Each group now states its evidence: identical audio data, or the share of
audio fingerprints that agree. Copies titled as different songs in one
acoustic group say so and ask the reviewer to listen, and each copy has a
play button so they can.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:28:29 -04:00
bvandeusenandClaude Opus 5.5 460e78c955 fix: an album with missing tracks is wanted back by its release group, not only when it carries a release MBID (#6167)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m32s
release / integration (push) Successful in 5m1s
release / android (push) Successful in 5m7s
release / Build signed APK (releases and dev) (push) Successful in 5m17s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
The release id was only ever used to find the group. 238 albums matched to a
group carried none, so their losses, five of the resolver's wrong-slot
removals among them, were never fetched again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:21:06 -04:00
bvandeusenandClaude Opus 5.5 39029b5cd9 fix: clearing a wrong slot's MBID clears its source too, as tracks_mbid_source_pair requires (M498)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m37s
release / android (push) Successful in 5m1s
release / integration (push) Successful in 5m4s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:47:02 -04:00
bvandeusenandClaude Opus 5.5 8daa3d07b5 fix: a slot emptied of another song's file drops that file's recording MBID, so missing-pair folding cannot fold it into the copy kept (M498)
release / govulncheck (push) Successful in 24s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 5m1s
release / android (push) Successful in 5m2s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
On 2026-10-20 twelve of the 24 slots the resolver emptied were folded by MBID
into the kept copy, so their albums were never wanted back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:39:17 -04:00
bvandeusenandClaude Opus 5.5 304caaee02 fix: the AcoustID rate-limit test bounds arrivals by the service's 3 a second, not the client's send spacing
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m42s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m26s
release / Build signed APK (releases and dev) (push) Successful in 5m31s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
Run 9543 saw two arrivals 389ms apart though the client sent them 400ms apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:29:00 -04:00
bvandeusenandClaude Opus 5.5 a3d9effca3 feat: when no slot an album gives one recording is its own, every copy is removed and the album fetched again (M498)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m28s
release / go (push) Failing after 1m31s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m23s
release / Build signed APK (releases and dev) (push) Successful in 5m37s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Shnabubula's whole album, one 45-minute file, was filed as both Fading Light
and Looking for Answers. Neither slot fits, so wrongSlots had nothing to keep
and held the group. Every copy now goes when each is clearly another song's;
a copy near its slot still holds the group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:21:43 -04:00
bvandeusenandClaude Opus 5.5 c8e855ae4a feat: the duplicate resolver fetches a missing album track list from MusicBrainz to decide a wrong slot, five albums a pass (M498)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m16s
release / go (push) Successful in 1m33s
release / integration (push) Successful in 5m0s
release / android (push) Successful in 5m7s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
20 of the 34 groups left for review had no cached track list for their album:
the catalogue only reads one when the wanted list or the importer needs it.
The resolver now asks catalogue.RefreshReleaseGroup for an album it must
decide and the catalogue has never read, through the shared MusicBrainz pacer,
at most five a pass (hourly). An unreachable MusicBrainz stops the pass's
fetching; a group read and found empty is not asked about again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:02:53 -04:00
bvandeusenandClaude Opus 5.5 c5868c00cc feat: a version named after a dash counts as a version, and a song held twice beside a wrong slot no longer blocks it (M498)
release / govulncheck (push) Successful in 25s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m31s
release / android (push) Successful in 4m51s
release / integration (push) Successful in 4m53s
release / Build signed APK (releases and dev) (push) Successful in 5m0s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 17s
release / Verify release artifacts (tag releases only) (push) Skipped
- songKey drops a dashed version suffix (" - Instrumental Version", " - Radio
  Edit", live, remix, mix, acoustic, demo, reprise, rework, VIP), so Andora's
  "Goodbye Yesterday - Instrumental Version" is distinct, not a wrong slot.
- wrongSlots accepts several fitting copies when they share one title
  (Killjoy twice beside Dreamland): the other slot's copy is removed, and the
  doubled song is left for the next sweep to find as a pair the resolver merges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:24:47 -04:00
bvandeusenandClaude Opus 5.5 80c711ac9d fix: a wrong-slot removal reads without a counted noun (M498)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m28s
release / go (push) Successful in 1m38s
release / integration (push) Successful in 5m9s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:31:11 -04:00
bvandeusenandClaude Opus 5.5 0f58ebcd6c feat: duplicates settle themselves: retitled copies across releases link, alike versions on one album are dismissed, and a file in another song's slot is removed so the album is fetched again (M498 #6159-#6162)
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 1m26s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 5m8s
release / android (push) Successful in 5m12s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
The operator's library had 536 groups waiting for review, 2026-10-19. A sample
showed most were one recording under two titles on two releases.

- The headline and the Duplicates count leave out cross-release groups, which
  the resolver keeps and links: the headline read 3,479 when 536 needed review.
- Any group spanning albums is cross_release and is linked as one song,
  whatever its titles say. Nothing is deleted.
- One album, two versions named as such ("Lovesick (Instrumental)") and not
  byte-identical: a new class, distinct, dismissed by the resolver.
- One album, one recording under different song titles (mismatch): the
  album's cached MusicBrainz track list decides. When exactly one slot's song
  is as long as the file and every other slot's song is clearly not, the other
  copies are removed and their tracks marked missing, so the reacquisition
  sweeper wants the album back. Otherwise the group stays for review.
- The duplicates_found notice moves from the sweep to the resolver: it names
  only groups a pass first left for review.
- Resolved automatically lists dismissals and wrong-slot removals beside
  merges.

Migration 0094 adds 'distinct' to the class CHECK (rule 36).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:22:38 -04:00
bvandeusenandClaude Opus 5.5 8538f0dc68 fix: the playback-error test matches the prefixed username, and announcementFor keeps detekt's two returns (M542)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 5m7s
release / android (push) Successful in 5m8s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:53:13 -04:00
bvandeusenandClaude Opus 5.5 854c307834 feat: notifications say what changed and alert once: a grouped notice names the tracks, grows without re-alerting, and a phone pile lists its titles (M542 #6146-#6151)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m33s
release / android (push) Failing after 1m42s
release / integration (push) Failing after 4m42s
release / Build signed APK (releases and dev) (push) Successful in 4m55s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
- An update to a grouped notice keeps its arrival time and email state, so the
  phone and the email no longer go off again each time it grows.
- Notices carry the names of what changed (newest ten), shown as three and
  "and N more": tracks still missing after the scan's move/replacement fold,
  duplicate groups new to a sweep, songs merged automatically, and the track
  and listener a playback error was for.
- Duplicates found counts only the groups new to that sweep; playback errors
  count reports, not the unresolved total.
- A scan failure says what stopped and links to Admin > Library.
- Android: a pile of more than three alerts lists the newest five titles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:44:19 -04:00
bvandeusenandClaude Opus 5.5 2365514e5b test: TestPlanRoots looks for the empty-list refusal as the message words it (#6129)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m34s
release / integration (push) Successful in 5m2s
release / android (push) Successful in 5m6s
release / Build signed APK (releases and dev) (push) Successful in 5m16s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m22s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:40:54 -04:00
bvandeusenandClaude Opus 5.5 edcb3a57ef feat: the library's folders are set in Admin › Library, and changing a folder moves its tracks with it, keeping their plays, likes and playlists (#6129)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m20s
release / go (push) Failing after 1m37s
release / integration (push) Failing after 4m56s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:37:03 -04:00
bvandeusenandClaude Opus 5.5 453a977e48 refactor: the admin and user Requests pages draw a request with one RequestRow, and the kind chip is one KindPill there and on Hidden tracks (#5651)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m16s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m10s
release / android (push) Successful in 5m17s
release / Build signed APK (releases and dev) (push) Successful in 5m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:20:53 -04:00
bvandeusenandClaude Opus 5.5 db15713798 refactor: the server says a count one way, words.Count, for notifications, a wanted match's reason and the duplicate resolver's activity line (M505 #5643)
release / govulncheck (push) Successful in 22s
release / web (push) Successful in 1m35s
release / go (push) Successful in 1m45s
release / integration (push) Successful in 5m41s
release / android (push) Successful in 6m7s
release / Build signed APK (releases and dev) (push) Successful in 6m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:18:50 -04:00
bvandeusenandClaude Opus 5.5 9e24b2b4fc refactor: every count in the web app is said by one plural(), with the viewer's thousands separator; a source guard names any hand-written copy (M505 #5642)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:18:04 -04:00
bvandeusenandClaude Opus 5.5 af2d4f9859 refactor: a running download is worded the same on the admin Overview as on the wanted list, from one liveDownloadLine (M505 #5641)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m29s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m8s
release / android (push) Successful in 5m21s
release / Build signed APK (releases and dev) (push) Successful in 5m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:15:21 -04:00
bvandeusenandClaude Opus 5.5 622f248ea2 refactor: track lengths and file sizes come from the shared formatters on Missing files, Duplicates, a want's Releases and the app download; an hour-long track reads 1:01:05, not 61:05 (M505 #5639 #5640)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:14:01 -04:00
bvandeusenandClaude Opus 5.5 9e7d50b38e fix: FLAC, Ogg and Opus tracks carry their MusicBrainz recording id from the tag: one reader in library gives the scanner, the backfills and the importer the same ids, and the next scan re-reads the library (#5637)
release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m50s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m50s
release / Build signed APK (releases and dev) (push) Successful in 6m12s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:55:25 -04:00
bvandeusenandClaude Opus 5.5 c1305a547d feat: the admin Overview shows who is listening, downloads, the library and its disk, and the server's version, uptime and recent errors; a download waiting on you, a client not answering or a full disk count in its verdict (M504 #5627 #5628 #5629 #5630)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m35s
release / go (push) Successful in 2m0s
release / integration (push) Successful in 5m40s
release / android (push) Successful in 6m15s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
The version moves to internal/buildinfo, so /rest/ping reports the build
instead of "dev".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:51:41 -04:00
bvandeusenandClaude Opus 5.5 ead0abf0cf feat: one Missing files page under Collection: folders gone, each with its album's want, last search or download, and Search, Releases and Remove; an album held before its files went is no longer shown as held again (M504 #5626)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m23s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 6m2s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:44:33 -04:00
bvandeusenandClaude Opus 5.5 38b2325d2d feat: Reports, Duplicates and Suspect sources live under Collection, with their counts on its rail and a "To look at" line naming the ones waiting; notices link there (M504 #5625)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m31s
release / android (push) Successful in 6m5s
release / Build signed APK (releases and dev) (push) Successful in 6m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:35:59 -04:00
bvandeusenandClaude Opus 5.5 12725e71b0 fix: the reports test name keeps its apostrophe inside double quotes, and the merge API test sends no unmonitor (M502 #5525)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 5m12s
release / android (push) Successful in 5m18s
release / Build signed APK (releases and dev) (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:55:55 -04:00
bvandeusenandClaude Opus 5.5 3a16732883 feat: Minstrel no longer talks to Lidarr: its connection, Delete via Lidarr and the unmonitor options are gone, and the track reports are plain quarantine (M502 #5525)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 17s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Migration 0091 drops lidarr_config and renames the quarantine tables, types,
indexes and the history column off the Lidarr name. The Integrations Lidarr
card, the Reports "Delete via Lidarr" action, the merge and remove-track
unmonitor options, the Lidarr audit actions and error codes go, on the
server, web and Android. The README describes acquisition as it is now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:48:20 -04:00
bvandeusenandClaude Opus 5.5 c54792c51e feat: a held album below the quality you settle for, or holding a video rip, stays wanted for a better copy, and the import replaces the old tracks with their likes and plays carried over (M502 #5524)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
- acquisition_settings.upgrade_until (default High quality lossy, Lidarr's
  cutoff, note #5527; "" is off), an "Upgrade until" row under Searching.
- The wanted worker judges held albums hourly: tier per format from size per
  second (the scanner records no bitrate), the album's worst. Below the
  cutoff it is wanted for quality; a video rip among its tracks always.
- The schedule searches upgrades weekly, after open wants; a release no
  better than the copy held is refused ("not_better").
- The import matches each file to the track it would replace (recording,
  then disc/track) and replaces it when it is a rip or the file is a better
  tier: over the same path in one rename, or filed beside it and the old
  track folded in (library.ReplaceTrack: file first, then foldTrackInto).
  Nothing better: the release is set aside.
- Collection › Upgrades (Lidarr's Cutoff Unmet); Suspect sources says when a
  clean copy was looked for instead of Lidarr's search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:59 -04:00
bvandeusenandClaude Opus 5.5 a5ad301c94 feat: Users, Diagnostics and Tuning sit on the admin layout like the rest: debug mode is a row action on Users, every admin load error has Try again, and figures carry their scale (M503 #5608)
release / android (push) Successful in 5m24s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m34s
release / integration (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
- Users: a one-line count of accounts, admins and open invites; Turn on/off debug mode in each row's actions, and a "debug on" badge that opens that account's events
- Diagnostics: no title or paragraph of its own; devices named by account, OS, app version and last seen, not client id; opens on ?account=
- Tuning: each profile, the taste build and Discover are row groups with one-line hints; the sparkline has a 0–100% axis
- Requests, Duplicates, Missing files and Suspect sources: load errors offer Try again
- Library: cover art reads "N of M albums have art"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:52:51 -04:00
bvandeusenandClaude Opus 5.5 0049b64ff1 fix: the AcoustID and Library tests expect the new copy and one folder per line (M503 #5607)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m27s
release / go (push) Successful in 1m43s
release / android (push) Successful in 5m19s
release / integration (push) Successful in 5m22s
release / Build signed APK (releases and dev) (push) Successful in 5m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:30:20 -04:00
bvandeusenandClaude Opus 5.5 7f913e3e70 feat: the admin settings cards read as menus: each a titled group of rows with one short line, status first, failures in words (M503 #5607)
release / govulncheck (push) Successful in 16s
release / web (push) Failing after 1m27s
release / go (push) Successful in 1m44s
release / android (push) Successful in 5m13s
release / integration (push) Successful in 5m18s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Indexers and clients, searching, missing files, Lidarr's neighbours, AcoustID, fingerprinting, loudness, outgoing email's schedule, public address and proxies move from paragraph cards to SettingsGroup and SettingRow. Switches are role="switch". Also fixes the two type errors run 9023 found: the Lidarr test result is narrowed before its lists are read, and the tag-source save mock carries version_bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:25:46 -04:00
bvandeusenandClaude Opus 5.5 7e1cfb8d08 feat: Integrations splits into Library, Acquisition, Metadata sources and Email & network, each a page on the admin rail; the Overview keeps only what needs you (M503 #5607)
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 16s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m54s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
- Library: the folders, Run scan and a row per scan job, cover art, fingerprints and loudness (off the Overview and Duplicates)
- Acquisition: indexers and clients, searching, re-acquisition (off Missing files), Lidarr
- Metadata sources: AcoustID, and one provider block for cover art and tags in place of the pasted pair
- Email & network: outgoing email, the notification schedule, public address, proxies
- Lidarr, outgoing email and the provider block are rebuilt as SettingsGroup rows; failures read as sentences, not codes
- Duplicates and Missing files say what their setting is and link to where it is set

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:21:48 -04:00
bvandeusenandClaude Opus 5.5 0cc4bc686a fix: a report's details keep the space before a note, and the Reports menu test opens the row it means (M503 #5606)
release / govulncheck (push) Successful in 19s
release / web (push) Successful in 1m38s
release / go (push) Successful in 1m49s
release / android (push) Successful in 5m22s
release / integration (push) Successful in 5m27s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:57:02 -04:00
bvandeusenandClaude Opus 5.5 89a2653995 feat: Admin › Reports is one queue of broken tracks, whether a listener flagged it or it failed to play, one row per track with one set of actions (M503 #5606)
release / govulncheck (push) Successful in 16s
release / web (push) Failing after 1m18s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m27s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
- /admin/reports folds the quarantine queue and the unresolved playback
  errors into one row per track, newest first: who flagged it and why,
  what failed and for whom, the file, with Resolve (clears the reports and
  marks the errors fixed), Play, Copy details, Delete file and, for an album
  Lidarr holds, Delete via Lidarr.
- One delete path: Delete file removes the track, and its reports and
  errors go with the row.
- A Handled tab shows what was done: reports cleared, files deleted,
  errors marked fixed or ignored.
- The summary counts reports by track across both sources, so the rail,
  the Overview and the page agree.
- /admin/quarantine and /admin/playback-errors redirect to Reports: the
  notifications and the Android app link to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:53:58 -04:00
bvandeusenandClaude Opus 5.5 864eb45580 feat: Settings uses the width of a wide screen: groups sit in two columns, each narrow enough that a row's control stays near its name (M503 #5595)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m13s
release / android (push) Successful in 5m15s
release / Build signed APK (releases and dev) (push) Successful in 5m25s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
The content was capped at max-w-3xl, so a wide window showed one narrow
strip and a lot of empty space. SettingsColumns lays a page's groups out as
one column on a phone or narrow window and two side by side from xl up;
the page cap moves to 100rem so the columns don't stretch on a very wide
screen.

- Account: Android app, Profile and Password, with Signed-in devices beside.
- Playback: "Between tracks" (crossfade) beside "Volume leveling".
- Connections: ListenBrainz beside Subsonic apps.
- Appearance, Notifications, About: one column's width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:28:06 -04:00
bvandeusenandClaude Opus 5.5 ace7e22e6a feat: Admin is a left rail with how many wait on you in each queue, and the Overview opens with a verdict, the queues that need you and since when, then the clear ones on one line (M503 #5605)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m10s
release / android (push) Successful in 5m16s
release / Build signed APK (releases and dev) (push) Successful in 5m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
- GET /api/admin/summary: every admin queue (requests, reported tracks,
  playback errors, duplicates, suspect sources, missing files) with its
  count and the age of its oldest item, in one query.
- The admin layout drops the 11-tab strip for SectionRail, as Settings and
  the Collection have, with each queue's count as its badge; the counts
  refresh on each admin navigation and every 30s.
- The Overview leads with "Nothing needs you." or "N things need you.",
  lists each waiting queue with "oldest 3d ago" and a link to it, counts a
  failed library scan as a problem, and names the empty queues on one line.
  The request and quarantine previews and the stat tiles are gone; those
  queues are worked on their own pages. A failed summary says so with Try
  again instead of reading as all-clear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:58:13 -04:00
bvandeusenandClaude Opus 5.5 7c89e59536 fix: admin bugs from the audit: a playback-error delete no longer reports failure, a failed Overview load no longer reads as all-clear, and email settings can't be saved over with defaults (M503 #5604)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m49s
release / integration (push) Successful in 5m20s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / android (push) Successful in 6m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
- Playback errors: Delete file stops resolving the error row the track
  deletion already took with it (ON DELETE CASCADE), which 404'd into
  "Delete failed".
- Overview: a queue that fails to load says so with Try again, and its tile
  shows a dash, not 0. Delete via Lidarr shows only for an album Lidarr
  holds while Lidarr is connected. An approve refreshes the Collection.
- Quarantine: Delete via Lidarr is in the row menu, so a phone reaches it;
  "1 report", not "1 reports".
- Duplicates: the Keep badge follows the copy picked.
- Integrations: provider save failures and load errors are said, not
  swallowed or shown as "No providers registered"; the Lidarr test catches
  a failed call; SMTP fills once and Save stays off until it has loaded.
  Lidarr's blurb says what it is still used for.
- Requests: release profiles point at Collection › Following.
- Library: an empty library sends an admin to Admin to run a scan, and
  tells anyone else the admin scans it, instead of opening Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:40:11 -04:00
bvandeusenandClaude Opus 5.5 c94c09aeae feat: Settings is one page per section down a left rail, Account first, every setting a row; the profile form no longer saves blanks over your name and email (M503 #5595 #5596 #5597)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m45s
release / go (push) Successful in 1m58s
release / integration (push) Successful in 5m39s
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Attach APK to the Release (tag releases only) (push) Skipped
- /settings lands on Account: the Android app, profile, password, signed-in
  devices. Then Playback, Appearance, Notifications, Connections, About.
- Each setting is a row (name, one short line, control at the end) in a
  capped-width column; one-of-N choices are a shared Segmented control.
- Collection and Settings share one SectionRail.
- Connections leads ListenBrainz with a status line: not connected,
  connected and not sending, or sending with the last scrobble's age.
- Notifications prints its column labels once; the digest email's
  settings link points at /settings/notifications.
- #5596: the profile form now loads what is stored and sends only the
  fields that changed. It used to start blank, and the server reads a
  blank as "clear", so one Save wiped the display name and email.
- Recommendation metrics moves to Library › Your listening, and Hidden
  tracks gets a Library tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:34:51 -04:00
bvandeusenandClaude Opus 5.5 c0c0f8a71f feat: Now Playing's cover grows to fill the screen, and the page takes the cover's dominant colour as Android does (#5592)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m45s
release / go (push) Successful in 2m9s
release / integration (push) Successful in 5m44s
release / android (push) Successful in 5m49s
release / Build signed APK (releases and dev) (push) Successful in 6m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
The web colour helper now picks Palette's dominant swatch (5-bit
quantise, Palette's default filter, median cut to 16, the most-populated
box) instead of the 1x1 mean, so the web and Android agree; PlayerBar's
accent strip shares it. Now Playing draws Android's gradient (55% at the
top, 18% at 45%, then the page) as a masked background colour that fades
over 600ms between tracks, holding the old colour until the new resolves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:05:05 -04:00
bvandeusenandClaude Opus 5.5 2f2573dd07 fix: the re-tag test sets its clock one statement at a time, as a prepared statement takes only one (M502 #5579)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 5m9s
release / android (push) Successful in 5m15s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:23:16 -04:00
bvandeusenandClaude Opus 5.5 a8786abace feat: Collection › Unmatched lists the library albums no MusicBrainz album matched, each matched by hand from a search or a link, or left off (M502 #5579)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m34s
release / integration (push) Failing after 5m34s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m44s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Like Lidarr's Unmapped Files. A local band's album, which MusicBrainz will
never have, is left off: never matched again and not counted. A re-tagged
album is matched again on the next listing. The Collection's verdict now
says when library albums need matching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:12:30 -04:00
bvandeusenandClaude Opus 5.5 10be068dcc fix: the player bar's heart is Lucide's, outlined then filled as on Android, and its title and artist read at the size of the rest of the page (#5580)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:12:29 -04:00
bvandeusenandClaude Opus 5.5 358849de77 feat: the Wanted view becomes Collection, shaped like Lidarr: an artist catalogue first, the state sections down a left rail (M502 #5577)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m54s
release / integration (push) Successful in 5m33s
release / android (push) Successful in 5m40s
release / Build signed APK (releases and dev) (push) Successful in 5m56s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
The section opens on a catalogue of every artist on the list, A–Z with a
letter rail, each card showing how many of their albums are held. An artist
opens on their page: every album of theirs, held or not, with its status,
search, releases and tracks. Wanted, Missing files, Activity, Following and
Blocked sit down a left rail as the sections that show state; Held is gone,
the catalogue shows it. The section moves from /wanted to /collection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:14:33 -04:00
bvandeusenandClaude Opus 5.5 a4fbe44bd5 feat: the Wanted view lists artists A–Z with their albums inside, each album's tracks a click away (M502 #5572)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m38s
release / integration (push) Successful in 5m35s
release / android (push) Successful in 5m59s
release / Build signed APK (releases and dev) (push) Successful in 6m10s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
The list runs like the library: artists by the library's sort name, each
artist's albums oldest first, grouped under the artist (linked into the
library when it holds them). A filter narrows it to an artist or album by
name, on the server, so it finds what has not been scrolled to. Tracks
opens an album's track list: the library's own for an album it holds, a
lost file marked, otherwise MusicBrainz's standard edition, named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:43:32 -04:00
bvandeusenandClaude Opus 5.5 ca57133c8a fix: a release group whose title names a live, remix or demo version is wanted only when the profile allows that version (M502 #5573)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m51s
release / android (push) Successful in 5m18s
release / integration (push) Successful in 5m23s
release / Build + push container image (push) Successful in 21s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
MusicBrainz types My Chemical Romance's "Common People (Recorded For BBC
Radio 1)" as a plain Single, so a studio-only follow wanted it. Passes now
reads the title's qualifier (brackets, or after a dash) for the version it
names and treats it as a secondary type; a version no type names, an
acoustic or instrumental one, no profile passes. The follow check takes off
the list an open follow want the rule now refuses. Judge counts bbc,
sessions and unplugged as live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:39:35 -04:00
bvandeusenandClaude Opus 5.5 7ef1fca90c fix: an album holding every track of an edition, and more besides, is that release group (M502 #5569)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m40s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m23s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Savant's "Void" folder holds all 23 tracks of the VOID (DLC) edition plus
20 more, and was refused at 23 of 43. A whole edition of three or more
tracks now matches whatever else the folder holds; singles and two-track
releases still need most of the album's tracks, since many albums hold
their songs. Matcher version 4 retries the refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:14:56 -04:00
bvandeusenandClaude Opus 5.5 1936fe77c6 fix: the matcher-retry test stamps the listing's cutoff after its albums exist, as a listing done before them would not see them (M502 #5569)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m26s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 5m29s
release / android (push) Successful in 5m35s
release / Build signed APK (releases and dev) (push) Successful in 5m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:51:42 -04:00
bvandeusenandClaude Opus 5.5 bf409d5f3f fix: a second copy of a track counts once, joining words don't part two titles, and a newer matcher retries an older one's refusals (M502 #5569)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m18s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 5m33s
release / android (push) Successful in 5m47s
release / Build signed APK (releases and dev) (push) Successful in 6m4s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
The second run's refusals were mostly albums holding a track twice (a
two-track single with three files), which no edition can pair, and
release names that differ only in "+" against "/". Tracks are now read
once per disc, number and title, and "and", "&" and "+" count for nothing
when titles are compared; an edition's own title also counts as the
album's name.

library_album_matches records which matcher version decided each album.
Refusals by an older version are matched again, even after the listing is
done, so an improvement reaches them without a migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:44:06 -04:00
bvandeusenandClaude Opus 5.5 80d2fdc3ab fix: under the album's own name, matching track titles are enough to match it; a refusal says which track differed and how (M502 #5569)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m33s
release / android (push) Successful in 4m54s
release / integration (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m6s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
The first run on the library refused singles whose one track has the
album's title ("0 of 1 agree") and near-complete albums (11 of 14), because
every track's length had to fit as well. Lengths now only decide between
groups whose names merely overlap. The reason for a refusal names the first
track that differed, with both titles and lengths, so what is left can be
judged. Migration 0087 clears the refusals so those albums are matched
again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:29:42 -04:00
bvandeusenandClaude Opus 5.5 b887ab759b fix: every library album is matched to MusicBrainz before it is listed, by its tags, its artist's discography or a search, and checked against its tracks (M502 #5569)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m28s
release / android (push) Successful in 5m44s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Verify release artifacts (tag releases only) (push) Skipped
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
The one-time listing took only albums whose tags already named their
release group and artist, and left 340 off the list. The rest are now
matched: the release their tags name, else the artist's release groups
called what the album is called, else a MusicBrainz search by artist and
title. A group found by name is taken only when its tracks agree with the
album's (by disc and number, then title; length and title, or recording),
so a partial copy still matches and a same-titled other album does not.

library_album_matches records each album the listing decided, listed or
unmatched with why, so an album taken off the list is never re-listed. The
albums no match was found for are named on the Held tab. Album rows with
no tracks are no longer counted.

The length and title checks move from the importer to release, shared by
both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:16:05 -04:00
bvandeusenandClaude Opus 5.5 2c677b3184 feat: the library's albums go on the wanted list as held, once; after that the list is the truth (M502 #5567)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m44s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m31s
release / Build signed APK (releases and dev) (push) Successful in 5m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
A one-time job in the wanted worker lists every album in the library as a
held want, one per release group, with a record that it happened
(acquisition_settings.library_listed_at). Albums whose tags name only a
release are named on MusicBrainz first, 120 a pass; what nothing names is
counted and shown on the Held tab.

Re-acquisition now wants back only albums on the list. Taking a held album
off the list leaves its files, and it is never fetched back; albums put in
the library folder by hand afterwards are not added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:13:46 -04:00
bvandeusenandClaude Opus 5.5 bc198a9389 fix: the Activity test's page carries clients_down as a value, not a literal Page<T> would refuse (M502 #5523)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m38s
release / integration (push) Successful in 5m31s
release / android (push) Successful in 5m46s
release / Build signed APK (releases and dev) (push) Successful in 5m59s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:35:21 -04:00
bvandeusenandClaude Opus 5.5 8245f4406e feat: download lifecycle — stalled and failed downloads are given up and searched again; imported ones seed, then go (M502 #5523)
release / govulncheck (push) Successful in 17s
release / web (push) Failing after 17s
release / go (push) Successful in 1m11s
release / integration (push) Successful in 4m50s
release / android (push) Successful in 4m54s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Ported from Fabled Librarian's System 104. A new lifecycle poller, every
ten minutes:
- a download the client fails is given up at once;
- one nobody shares, or still fetching its file list, past the stall
  setting (6 h) is asked for again three times, then given up;
- giving up removes the download with its files, holds the release back
  for a week (release_blocks.expires_at), ends the grab and searches the
  album again, with no limit per album;
- a running download the client stops listing for half an hour ends and is
  searched again, not held back; an empty list is not taken as gone;
- an imported download seeds to ratio 2.0 or 14 days, then is removed;
- a download the client would not remove is asked again every pass.

Every condition and act is one open row per (client, download, condition)
in download_interventions. Activity shows what is wrong with a download and
how often it was retried, why one ended, its seeding, and a banner for a
client that does not answer. A running download can be given up by hand
(set aside for good). The seed ratio, seed days and stall hours are on the
search settings card. The download client lookup moves to
acquire.DownloadClientFor, shared by the importer and the lifecycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:27:22 -04:00
bvandeusenandClaude Opus 5.5 d9e00d8bc2 fix: the integrations test's library folder isn't /music, which a Lidarr root folder there is named (M502 #5522)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m5s
release / android (push) Successful in 5m15s
release / Build signed APK (releases and dev) (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:53:43 -04:00
bvandeusenandClaude Opus 5.5 f268b4df09 fix: WantList's prop is list, not state, which Svelte reads as the $state store; the length test's 4-minute case fits within 3% (M502 #5522)
release / govulncheck (push) Successful in 16s
release / web (push) Failing after 1m40s
release / go (push) Successful in 1m53s
release / integration (push) Successful in 5m38s
release / android (push) Successful in 5m39s
release / Build signed APK (releases and dev) (push) Successful in 5m51s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:44:21 -04:00
bvandeusenandClaude Opus 5.5 ebbc49b902 feat: import with verification, and a Wanted view of its own (M502 #5522)
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 15s
release / go (push) Failing after 1m16s
release / integration (push) Failing after 4m49s
release / android (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
A finished download is checked against MusicBrainz before anything is
filed: the files are paired with an edition's tracks by position or path,
and AcoustID, tag ids, lengths and titles say whether it is the album.
What is the album is copied into the library as
Artist/Album (Year)/Artist - Album - NN - Title, tagged with Picard's
MusicBrainz ids, and scanned; the download keeps seeding. What is not is
refused: set aside, removed from the client, and the album searched for
again. A download the check cannot settle waits for a person, who imports
it anyway or refuses it. Tracks the library already holds are skipped.

The wanted list leaves Admin for a top-level Wanted view, admin-only and
web-only, with tabs for Wanted, Activity (downloads and their imports,
the ones needing a person first), Missing files, Held, Following (with the
release-type profiles, moved off Integrations) and Blocked. The search
settings gain "File new music into" when the library has several folders.

The search pass logs why each failed search failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:36:21 -04:00
bvandeusenandClaude Opus 5.5 77081f1cef fix: the search schedule logs its state when it changes, so an idle pass says why (M502 #5521)
release / govulncheck (push) Successful in 22s
release / web (push) Successful in 1m16s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 4m53s
release / android (push) Successful in 4m54s
release / Build signed APK (releases and dev) (push) Successful in 5m4s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:30:26 -04:00
bvandeusenandClaude Opus 5.5 7f1fe57935 fix: the search test reads the last pass as JSON, since jsonb respaces it (M502 #5521)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m42s
release / integration (push) Successful in 5m18s
release / android (push) Successful in 5m18s
release / Build signed APK (releases and dev) (push) Successful in 5m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 30s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:02:18 -04:00
bvandeusenandClaude Opus 5.5 89b3b44779 feat: search and grab — music release names, a quality ladder, a paced search schedule and one live grab per want (M502 #5521)
release / govulncheck (push) Successful in 21s
release / go (push) Failing after 33s
release / web (push) Successful in 56s
release / integration (push) Failing after 4m48s
release / android (push) Successful in 5m6s
release / Build signed APK (releases and dev) (push) Successful in 5m16s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
- internal/release: reads a release name (format, bitrate, bit depth and
  sample rate, source, year, video, discography) and judges it against the
  album wanted: the artist and title must be in it; video rips,
  discographies, other versions (drumless, instrumental, karaoke, remix,
  live unless the album is live…) and singles or EPs for an album are
  refused. Quality tiers on Lidarr's ladder; a .torrent's file list is read
  for format, video files and a track count short of the album.
- internal/acquire: fan-out across every search source with a narrowed
  search kept, a process-wide pacer per source, every candidate judged and
  kept with its reasons, and the grab: one live grab per want, tie-broken
  (a person's grab beats the schedule's; a better release replaces a worse
  one unless it is nearly done). Each download lands in its own folder
  under the client's downloads folder.
- internal/wanted: the search schedule, every ten minutes, never-searched
  first, then requests, missing files and followed artists, taking the
  best release when auto-grab is on (the default).
- Migration 0082: acquisition_settings (auto-grab, interval, allowed
  qualities seeded from Lidarr's "Any"), want search columns,
  want_candidates, grabs, release_blocks.
- Web: the Wanted page shows each want's last search, a Search button and
  every release found with its verdict (Grab, Grab anyway, Never this one,
  Allow again). Integrations gains "Searching and grabbing".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:56:47 -04:00
bvandeusenandClaude Opus 5.5 1a1646a920 fix: the wanted-list tests set a track's MBID with its source, and find the missing-files link in its row (M502 #5520)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m32s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m7s
release / android (push) Successful in 5m15s
release / Build signed APK (releases and dev) (push) Successful in 5m25s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:54:01 -04:00
bvandeusenandClaude Opus 5.5 04515a977c feat: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Minstrel keeps its own list of what it is to get, keyed by MusicBrainz
release group. A want comes from an approved request, a new release by a
followed artist, or an album whose files went missing, and is held once the
library has a full edition of it.

- Migration 0081: release_type_profiles (Standard seeded: Album, EP,
  Single, studio only), follows, follow_seen_groups and wants.
  lidarr_requests becomes requests with plain *_mbid columns; approved
  album and track requests move onto the wanted list, approved artist
  requests become follows, and the library's album artists start out
  followed. missing_reacquisitions and the retry settings go.
- internal/wanted: follows are checked against MusicBrainz discographies
  daily. The first check sets a baseline, and only releases after it are
  wanted unless the follow asked for what already exists. Profiles filter
  by primary and secondary type, so live recordings stay out unless a
  profile ticks Live.
- internal/requests (was lidarrrequests): approving puts the request on
  the wanted list, and an artist request can be approved on a chosen
  profile. A request completes when its want is held.
- The re-acquisition sweeper wants a missing album back instead of asking
  Lidarr; searching for it is the search schedule's (#5521).
- Web: an admin Wanted page (Wanted, Missing files, Held, Following), a
  follow control on artist pages, a release-type profiles card on
  Integrations, "Approve on a profile" on Requests, and want state on
  Missing files. The artist detail API returns the artist's MBID.
- Android: the request body sends artist_mbid, album_mbid and track_mbid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:46:22 -04:00
bvandeusenandClaude Opus 5.5 867f52358c fix: the result card's state prop no longer shadows the $state rune (M502 #5519)
release / govulncheck (push) Successful in 27s
release / web (push) Successful in 1m0s
release / go (push) Successful in 2m4s
release / integration (push) Successful in 4m7s
release / Build signed APK (releases and dev) (push) Successful in 9m44s
release / android (push) Successful in 9m48s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
svelte-check read `$state(false)` as a subscription to the `state` prop.
The prop keeps its name for callers and is bound as cardState inside.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:12:59 -04:00
bvandeusenandClaude Opus 5.5 77389f88bc feat: a release catalogue from MusicBrainz; request search asks it directly (M502 #5519)
release / govulncheck (push) Successful in 22s
release / web (push) Failing after 24s
release / go (push) Successful in 1m57s
release / integration (push) Successful in 4m1s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 4m37s
release / android (push) Canceled after 4m38s
Minstrel keeps its own record of what artists have released, read from
MusicBrainz, so the wanted list, search and import no longer need Lidarr.

- internal/musicbrainz: searches (artists, release groups, recordings) and
  browses (an artist's release groups; a group's releases with track lists).
  One process-wide pacer at 1.1s, shared with tag enrichment, because
  MusicBrainz's limit is per address. A 503 throttle is retried.
- Migration 0080: mb_artists, mb_release_groups, mb_artist_release_groups
  (a collaboration belongs to each credited artist), mb_releases,
  mb_release_tracks.
- internal/catalogue: Discography and Releases serve the held copy and
  re-read it when older than 7 or 30 days. An unreachable MusicBrainz
  serves the stale copy, marked. An hourly pass re-reads due artists.
- Art for what isn't held comes through /api/catalogue/art from the
  enabled cover-art sources (Cover Art Archive by release group, then the
  name-based sources) and is kept on disk, so the browser never fetches a
  third-party host. A miss is a 404 and the card shows its icon.
- Request search moves from /api/lidarr/search to /api/catalogue/search,
  with the same response shape. Albums are release groups, and "in
  library" matches albums.release_group_mbid. A 10-minute cache absorbs
  search-as-you-type. Discover suggestions point at the art endpoint
  instead of looking up Lidarr.
- Android calls the new path and loads Discover art through ServerImage.
  Installed builds older than this one lose Discover search until they
  update (no compatibility path, rule 22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:08:22 -04:00
bvandeusenandClaude Opus 5.5 a2e1890d4c feat: indexers and download clients you add and configure (M502 #5518)
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m13s
release / go (push) Successful in 2m12s
release / integration (push) Successful in 4m6s
release / Build signed APK (releases and dev) (push) Successful in 8m41s
release / android (push) Successful in 9m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 59s
release / Verify release artifacts (tag releases only) (push) Skipped
Admin → Integrations gains "Indexers and download clients": two lists of
connections the operator adds, Fabled Librarian's model. Nothing is seeded
or copied from Lidarr.

- Indexers: Prowlarr (its native /api/v1/search, which asks every indexer
  at once; Prowlarr has no aggregate Torznab feed) and any Torznab feed.
- Download clients: qBittorrent (WebUI v2, with re-login on an expired
  session) and FabledExchange, one entry with a switch per job:
  downloads (/api/downloads), search (/api/search) and release details
  (/api/torrents/{infohash}). All three start on; its key is optional.
- Each entry: enable, name, address, a write-only key or password, Test.
  A download client's Test lists its torrents and checks the downloads
  folder is visible and writable from Minstrel; an Exchange's Test
  reports each job switched on.
- Download clients carry a path mapping (Minstrel's /downloads vs the
  client's own name for it); search sources carry searches per hour.

Migration 0079 adds acquisition_services with its CHECKs. Ported from
Fabled Librarian: internal/indexer (Torznab, 429 Retry-After, magnet
redirects), internal/prowlarr, internal/exchange, internal/downloader,
and internal/acquire's one builder per kind, which Test already uses
so a passing Test vouches for the path a grab will take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:34:51 -04:00
bvandeusenandClaude Opus 5.5 5306a261c1 feat: duplicates resolve themselves without Lidarr (M502 #5517)
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m46s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 6m48s
release / Verify release artifacts (tag releases only) (push) Skipped
Lidarr is off, and with it the resolver had stopped merging: it only
merged when Lidarr had said which copies it mapped. Minstrel is now the
only program writing the library, so the rule is about the copies:

- A song held twice on one album: the extra copies merge into the one
  whose tags fit best (then lossless, larger, older).
- Across releases or under another title: when every copy but one is a
  video rip (held back, or named like one and not marked fine), the rips
  merge into the one that is not.
- Clean copies on different releases are linked as one song, as before.
- Anything else waits for the operator.

Gone with Lidarr: release switches and their settle window, the
unmapped-file read, the rip search, the merge guard and its 409/503,
lidarr.Client's release methods, and duplicate_group_members.lidarr_state
(migration 0078). The duplicates page drops the per-copy Lidarr badge
and the blocked-merge notice; past release changes stay in its history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:12:49 -04:00
bvandeusenandClaude Opus 5.5 8bd48b4302 fix: a merge keeps the song link when the removed copy names the song (M498)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m56s
release / integration (push) Successful in 5m11s
release / android (push) Successful in 5m58s
release / Build signed APK (releases and dev) (push) Successful in 6m11s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
MergeInheritSongLink took "linked" to mean song_key <> id, but the copy
whose id became the song's key has no song_id of its own. Removing that
copy dropped the survivor out of the song. It now asks whether another
copy shares the key. The test covers both copies as the one removed, so
it no longer passes or fails on which random id sorts first.

The release-change test counted audit rows other tests left behind
(ResetDB keeps audit_log); it now counts what its own pass adds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:53:35 -04:00
bvandeusenandClaude Opus 5.5 e1ad1c9ba2 fix: a release change Lidarr applied but did not answer is recorded (M498)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m43s
release / integration (push) Failing after 5m5s
release / android (push) Successful in 5m58s
release / Build signed APK (releases and dev) (push) Successful in 6m17s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
On the first deploy pass Lidarr took longer than the client's 30s timeout to
answer the release change for Cracker Island: its album update unlinks the
files and queues the rescan before it replies. The change may well have
landed, but the resolver logged a failure and wrote no audit row, so the
album had no settle window and the report did not show it.

An unanswered PUT (ErrUnreachable) is now read back: when the album's
monitored release is the chosen one, the change is recorded as made. Any
other error, or a read-back that disagrees, stands as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:42:50 -04:00
bvandeusenandClaude Opus 5.5 e17556dce9 feat: Lidarr searches for a better copy of a rip that has none (M498 #5447)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m26s
release / go (push) Successful in 1m45s
release / integration (push) Successful in 5m6s
release / android (push) Successful in 5m58s
release / Build signed APK (releases and dev) (push) Successful in 6m17s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
The operator chose option 2: an album holding a video rip that is the only
copy of its song is searched in Lidarr (AlbumSearch), at most once a week
and ten albums a pass. Lidarr grabs only when the quality profile allows an
upgrade, so nothing is removed and the rip stays held back either way.

- lidarr.Client.SearchAlbums posts the AlbumSearch command.
- ListAlbumsWithSoleCopyRips finds the albums: a held-back rip with no
  present clean copy sharing its song or its pending group, and not searched
  in the last week (audit action lidarr_rip_search).
- Suspect sources shows when Lidarr last searched the track's album.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:32:30 -04:00
bvandeusen 165c87047e Merge pull request 'Duplicates and video rips resolve themselves (M498)' (#155) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 4m15s
release / android (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 28s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 22:30:10 -04:00
bvandeusenandClaude Opus 5.5 d59b7fdc2a fix(test): the mix-writer test reaches oneCopyPerSong through export_test; suspect fixtures carry the verdict (M498 #5439)
release / govulncheck (push) Successful in 26s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m49s
release / integration (push) Successful in 4m36s
release / android (push) Successful in 6m15s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:17:17 -04:00
bvandeusenandClaude Opus 5.5 53eb954c86 feat: video rips and stray copies handle themselves (M498 #5439)
release / web (push) Failing after 26s
release / govulncheck (push) Successful in 25s
release / go (push) Failing after 58s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 3m25s
release / Build signed APK (releases and dev) (push) Canceled after 3m26s
release / android (push) Canceled after 3m28s
- The resolver also merges cross-release and mismatch groups where Lidarr
  maps exactly one copy: the others fulfil nothing, so removing them opens no
  hole (D-a rule 1). That covers a rip beside the clean copy on another
  release and a wrong-file import Lidarr holds unmapped. With two or more
  mapped copies each fulfils its own release and nothing is removed.
- A track whose file name carries a video-rip marker is held back from radio
  and the system mixes (tracks.source_verdict, migration 0077). It still plays
  when chosen. A renamed file is released; the operator's "fine" sticks.
- Suspect sources shows what was done to each track, with "This one is fine"
  and "Hold back again" (PUT /api/admin/library/suspect-sources/{id}).
- The Liked list prefers a copy that is not held back.

Replacing a rip that has no clean copy is left for the operator to decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:13:37 -04:00
bvandeusenandClaude Opus 5.5 5b372f61d7 feat: the same song on several releases counts as one song (M498 #5438)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m7s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m11s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 5m58s
release / Build signed APK (releases and dev) (push) Canceled after 5m59s
A single and the album it is on stay two files, since each fulfils its own
release in Lidarr, but they are one song to the listener.

- tracks.song_id links copies; the generated song_key (song_id, else the
  track's own id) is what they share (migration 0076).
- The resolver links each cross-release group every pass (idempotent; only
  with auto-resolve on) and, when a link is new, shares existing likes across
  the song and logs them for sync.
- A like or unlike (web and Subsonic) reaches every copy; each change is
  logged and published so clients update every heart.
- The Liked list and its count show the song once; Shuffle and the mix writer
  take one copy per song.
- A merge keeps the removed copy's song link; "Not the same song" on the
  Across releases tab dismisses the group and undoes the link.

Shared plays ("heard via another copy") are left for a later step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:07:55 -04:00
bvandeusenandClaude Opus 5.5 4ecff52f19 feat: duplicates resolve themselves where Lidarr says it is safe (M498)
release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 4m25s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 5m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m54s
release / Verify release artifacts (tag releases only) (push) Skipped
The duplicate sweep proposed 4,197 groups and every one waited for the
operator. Most are safe to settle, and Lidarr defines what safe means: it
maps one file to each track of the release it monitors and downloads any
mapped file that disappears. Deleting a mapped copy opens exactly the hole
the operator saw Lidarr fill.

Classify (#5435)
- Migration 0075: duplicate_groups.class (same_release, cross_release,
  mismatch, review), resolve_note, resolved_automatically;
  duplicate_group_members.lidarr_state (tracked, unmapped);
  fingerprint_settings.auto_resolve; notification kind
  duplicates_resolved with both kind CHECKs swapped (rule 36).
- library.ClassifyDuplicateGroup, with MatchTitleKey dropping featuring
  credits, remaster notes and video-rip markers, and keeping live, demo,
  remix and instrumental. The rip markers move from api to library.

Choose the copy to keep (#5436)
- ProposeSurvivor ranks the copy Lidarr maps first, then tag fit (a
  clash-free track number, no rip marker in the name, an MBID), then the
  quality rules. File size picked the wrong Humanz copy in 6 of 21 groups.

Act (#5437)
- An hourly resolver pass reads Lidarr's unmapped files, matched by the
  last three path components, and records each copy's state.
- Same album, with at most one copy mapped: merged into the mapped copy.
  The merge is guarded, so a mapped copy can never be removed
  (MergeDuplicateGroupGuarded, ErrCopyTrackedByLidarr).
- Same album, every copy mapped: the monitored release lists the song
  twice (Humanz's 14x12" box set). The pass moves Lidarr to the release
  that lists each song once and best covers what is on disk. It never
  picks one covering less, and is capped at 10 albums per pass.
  - Fixed point (lesson #4183): the chosen release no longer repeats.
  - The album is left alone for 24h while Lidarr rescans, so "every copy
    unmapped" mid-rescan is never read as licence to merge.
- Both actions are audited with no actor and summarised to admins. The
  operator can switch them off in the Fingerprinting card (rule 25).
- Manual merges use the same guard: 409 copy_tracked_by_lidarr, or 503
  lidarr_unavailable when Lidarr cannot say.

Web
- Duplicates gets tabs: Needs review, Across releases, Resolved
  automatically. Each loads as you scroll (rule 172), replacing the
  pager.
- Each copy says whether Lidarr uses it.
- The resolver's note shows on each group.
- The merge confirm blocks, before sending, a merge that would remove
  the copy Lidarr uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:49:37 -04:00
bvandeusenandClaude Opus 5.5 01e2294471 ci: probe and relax the integration Postgres over TCP, with retries (#5426)
release / web (push) Successful in 1m34s
release / govulncheck (push) Successful in 24s
release / go (push) Successful in 1m51s
release / integration (push) Successful in 5m21s
release / android (push) Successful in 5m33s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 13s
release / Verify release artifacts (tag releases only) (push) Skipped
On first boot the postgres image runs initdb under a temporary server that
listens on the unix socket only, then stops it. The socket readiness probe
could catch that server, and the fsync relax then hit 'the database system
is shutting down', leaving the suite ~15x slower on internal/api. Only the
real server listens on TCP, so probe and relax via -h 127.0.0.1, retry the
relax, and print the settings that took effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:18:49 -04:00
bvandeusen 05bc85f2b8 Merge dev: scroll-loaded lists, Liked tabs, missing files kept out of the queue (#5416, #5417, #5418)
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m4s
release / go (push) Successful in 1m21s
release / integration (push) Successful in 3m56s
release / android (push) Successful in 4m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 20:08:04 -04:00
bvandeusenandClaude Opus 5.5 bf0bc45c4b fix(web): filter a refetched mix inside the self-heal step, not by wrapping the closure (#5418)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m42s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m16s
release / integration (push) Successful in 17m42s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 22s
release / Verify release artifacts (tag releases only) (push) Skipped
The wrapper added a promise hop, so the self-heal test's single
microtask wait saw 'loading' instead of 'error'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:33:10 -04:00
bvandeusenandClaude Opus 5.5 8bd2f66416 fix(web): missing files show greyed in track lists and never enter the queue (#5418)
release / web (push) Failing after 1m23s
release / govulncheck (push) Successful in 25s
release / go (push) Successful in 1m35s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m34s
release / android (push) Canceled after 4m39s
release / Build signed APK (releases and dev) (push) Canceled after 3m21s
TrackRef gains the unavailable flag the server already sends on album
detail and search. TrackRow greys such a row with '· File missing' and
hides like, radio, add and the menu, as playlist rows already do. Every
player-store entry point (playQueue, enqueue, play next, and a refetched
mix) drops missing files; playQueue keeps the chosen track as the start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:28:36 -04:00
bvandeusenandClaude Opus 5.5 015fe1d3b2 fix(web): admin error lines use text-error-fg, not the untokened text-oxblood (#5417)
release / govulncheck (push) Successful in 36s
release / web (push) Successful in 1m42s
release / go (push) Successful in 1m52s
release / integration (push) Successful in 5m7s
release / android (push) Successful in 5m48s
release / Build signed APK (releases and dev) (push) Successful in 5m22s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m28s
release / Verify release artifacts (tag releases only) (push) Skipped
text-oxblood is not a theme token, so Tailwind emits nothing and the scan
error on Admin → Overview and "It stopped early" on Duplicates rendered as
body text. Same fix as the album page in #5392; no uses remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:17:34 -04:00
bvandeusenandClaude Opus 5.5 0f9ddbca97 feat(web): every paged list loads as you scroll; Liked becomes tabs (#5416)
release / go (push) Successful in 1m53s
release / govulncheck (push) Successful in 49s
release / web (push) Successful in 1m50s
release / android (push) Successful in 6m44s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 21m41s
Preference 172: no "Load more" buttons. Eight remained, on Liked (three
sections), the three Search overflow pages, Genres and Years.

- ListContinuation: the one bottom-of-list block. It loads the next page
  ahead of the reader, announces "Loading more…" through aria-live, shows an
  optional end line, and on a failed page shows the error with Try again,
  dropping the sentinel so a dead endpoint isn't re-hit on every scroll.
  Used on all ten paged lists, including the four that already autoloaded.
- A failed next page no longer replaces the list. TanStack sets isError for
  it, so the page-level error branches now apply only to a failed first
  load. Genres and Years did the same with their own loader; a later
  page's failure now keeps the grid.
- Liked: Artists | Albums | Tracks tabs (operator's choice). Stacked, a
  long Artists list loading as you scroll would bury the other two. It
  opens on the first tab that has likes.
- TabStrip: the in-page tab strip, now shared by Liked, Playback errors
  and Requests.
- test-utils/intersectionObserver: a stand-in so tests can scroll to the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:55:33 -04:00
bvandeusen d1fe3758d5 Merge dev: Suspect sources report (#5410)
release / Build signed APK (releases and dev) (push) Skipped
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m28s
release / govulncheck (push) Successful in 14s
release / integration (push) Successful in 4m46s
release / android (push) Successful in 5m29s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 30s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 16:03:53 -04:00
bvandeusenandClaude Opus 5.5 2205b2927b test: fix the Suspect sources tests CI rejected (#5410)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 4m36s
release / android (push) Successful in 5m4s
release / Build signed APK (releases and dev) (push) Successful in 5m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m11s
release / Verify release artifacts (tag releases only) (push) Skipped
- staticcheck QF1001: the nil-vs-empty check in TestSourceMarkersFor is
  now an early continue instead of a negated conjunction.
- The page test mocks #lib/api/admin.js whole, so suspectSourcesNextOffset
  was undefined there. Its test moves to admin.suspect-sources.test.ts,
  beside admin.scan.test.ts, against the real module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:56:18 -04:00
bvandeusenandClaude Opus 5.5 3f0540cb7a feat: admin Suspect sources report, for files named like video rips (#5410)
release / govulncheck (push) Successful in 37s
release / go (push) Failing after 1m10s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / web (push) Failing after 1m24s
release / integration (push) Successful in 4m38s
release / android (push) Successful in 5m5s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 5m18s
Humanz turned out to be YouTube rips: "(Official Video)", "Visualizer", a
reaction video filed as a song, junk disc numbers (#5401). The library holds
about 200 more files named the same way. This report lists them, grouped by
folder like Missing files, with the markers each file name carries.

- GET /api/admin/library/suspect-sources: one marker list in Go builds both
  the Postgres ~* filter and each row's labels, so they cannot drift.
  Basename only; missing files are left out.
- Markers calibrated on the live library: "live in/at" dropped (real live
  albums), "reaction" narrowed (it caught "Chain Reaction").
- Admin tab "Suspect sources", read-only, loads as you scroll; a folder split
  across pages is joined back into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:51:01 -04:00
bvandeusen 664b129947 Merge pull request 'One track-list shape across the web app, drag reorder that snaps (M494)' (#152) from dev into main
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m36s
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 27s
release / integration (push) Successful in 4m20s
release / android (push) Successful in 4m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 15:08:09 -04:00
bvandeusenandClaude Opus 5.5 918db27fec feat(web): shared collection header, Play and Shuffle, and the centred page column (M494)
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m48s
release / integration (push) Successful in 5m51s
release / android (push) Successful in 6m53s
release / Build signed APK (releases and dev) (push) Successful in 7m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m29s
release / Verify release artifacts (tag releases only) (push) Skipped
- CollectionHeader holds cover, title, facts, then Play and Shuffle, for
  album and playlist. The playlist page gains Play and Shuffle; it had
  neither. The cover uses the shared Cover component, so the playlist
  header also gets the fallback image.
- PlayActions is the one Play and Shuffle pair: filled Play, outlined
  Shuffle, on album, playlist and artist pages (operator, 2026-10-08).
  The artist page's round icon button said Play but shuffled; Play now
  keeps the server's order, and Shuffle shuffles.
- PageColumn is the playlist page's centred column, now used by album,
  playlist, Liked, History and Search → Tracks.
- The playlist page builds its queue with playlistTrackToRef, the shared
  converter, in place of its own copy. Its copy skipped only removed
  tracks, so a missing file (#2527) could still be queued from there.
- shuffled() in lib/utils is the one shuffle, replacing three private
  copies (album page, artist page, ArtistCard).
- The album's refetch error now uses text-error-fg. `text-oxblood` is
  not a colour in the theme, so it never rendered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:18:44 -04:00
bvandeusenandClaude Opus 5.5 aaa6fa15d6 test(web): import history components after their mocks (M494)
HistoryRow now renders LikeButton, and both history test files imported
the component before the likes mock helper. LikeButton then loaded while
emptyLikesMock was still uninitialised, and the suites failed at import.
Every other suite imports components after its mocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:18:44 -04:00
bvandeusenandClaude Opus 5.5 b7f50c42a9 feat(web): one track-list row shape, and drag reorder that snaps (M494, #5393)
release / govulncheck (push) Successful in 34s
release / go (push) Successful in 1m37s
release / web (push) Failing after 1m20s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m38s
release / android (push) Canceled after 4m39s
release / Build signed APK (releases and dev) (push) Canceled after 3m49s
Every track list (album, Liked, Search, Search → Tracks, playlist,
History) now draws its rows with TrackListRow inside a TrackList box,
in place of four row components that had drifted apart:

- TrackListRow owns the look: lead column, title over "artist · album",
  like, radio, add to queue, menu, duration, then a trailing slot. It
  also owns the stripe, hover, the now-playing band and the
  unavailable state. The stripe is dropped on now-playing and selected
  rows, where Tailwind would otherwise let `odd:` win.
- TrackRow, PlaylistTrackRow and HistoryRow are wrappers that keep only
  their behaviour: track number and multi-select; grip, remove and
  dead-entry states; cover and timestamp. Playlist and History rows
  gain radio, add and the menu, so every list offers the same actions.
- The radio control is Lucide's Radio icon, not the 📻 emoji.
- Enter on a control inside a row now belongs to that control. The old
  TrackRow played the track when Enter was pressed on "add to queue".
- TrackList has no fill, so the stripe shows on playlists too.

Drag reorder (#5393): neodrag kept the drop offset, so after the list
re-rendered the dragged row sat between two slots. reorderDrag, shared
by the playlist and queue rows:
- snaps the row to whole rows while dragging;
- resets it to its slot on drop;
- on playlists, gives the touch gesture to the grip only, so the list
  still scrolls by touch.
The playlist page reorders optimistically and rolls back on error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:14:06 -04:00
bvandeusenandClaude Opus 5.5 e16d182417 fix(web): drop the shelf-header accent rule Tailwind 4 started drawing (#5021)
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m27s
release / go (push) Successful in 1m44s
release / integration (push) Successful in 4m47s
release / android (push) Successful in 5m17s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
The rule after each shelf title used `after:bg-accent/60`. Tailwind 3
cannot put an opacity modifier on a `var()` colour, so it emitted nothing
for that class and the rule never showed. Tailwind 4 mixes the opacity
in with color-mix, so the rule appeared after the upgrade. Removed, so
the headers look as they did before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:17:15 -04:00
bvandeusen c9fa18ecb7 Merge pull request 'Notifications inbox (M489), Discover taste-arm fix, web tooling majors' (#151) from dev into main
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m8s
release / Build signed APK (releases and dev) (push) Skipped
release / go (push) Successful in 1m27s
release / integration (push) Successful in 5m1s
release / android (push) Successful in 5m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m34s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 11:11:08 -04:00
bvandeusenandClaude Opus 5.5 ca9917d8cd chore(web): clear kit 3's alias and Vite's config-loader deprecations (#5021)
release / govulncheck (push) Successful in 23s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 4m49s
release / android (push) Successful in 5m32s
release / Build signed APK (releases and dev) (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 28s
release / Verify release artifacts (tag releases only) (push) Skipped
CI run 8789 warned on both:

- kit 3 deprecates `config.alias`. The $test-utils alias becomes a
  `#test-utils/*` subpath import, matching #lib, and its 43 import sites
  move with it.
- Vite's coming native config loader needs the extension on
  vitest.config.ts's import of vite.config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:03:38 -04:00
bvandeusenandClaude Opus 5.5 b1b10b0c77 fix(web): run Tailwind 4 through @tailwindcss/vite, not PostCSS (#5021)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 56s
release / go (push) Successful in 2m22s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
The image build on fecd030b failed in `vite build`:

  [postcss] ENOENT: no such file or directory, open '/web/tailwindcss'

With a PostCSS config present, Vite's own @import inliner resolves
`@import 'tailwindcss'` before @tailwindcss/postcss sees it, and reads it
as a relative file. svelte-check and Vitest never build CSS, so only the
image job caught it. The Vite plugin is Tailwind's documented setup for
Vite projects and handles the import itself, so postcss.config.cjs and
the direct postcss and @tailwindcss/postcss dependencies go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:55:48 -04:00
bvandeusenandClaude Opus 5.5 94a9c8cbe3 chore(deps): SvelteKit 3 with adapter-static 4 and TypeScript 6, full-tree npm audit (#5021)
Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps,
plus the migration they need. The mechanical part is `sv migrate
sveltekit-3`, run one task at a time and reviewed:

- svelte.config.js is gone. Its options move into sveltekit() in
  vite.config.ts, exported as kitOptions so vitest.config.ts runs the
  same kit setup, including the $test-utils alias the tests import.
- $lib becomes #lib through package.json "imports". There is no
  src/lib/index, so only the "#lib/*" entry is kept.
- tsconfig extends $app/tsconfig.
- Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite
  ^8.0.12, svelte-check ^4.7.5.

By hand, from the codemod's list of non-automated tasks:

- goto's replaceState option is now replace; keepFocus becomes
  reset: false. For the search typeahead, reset: false also stops the
  scroll-to-top, which is wanted while typing.
- The test setup mocks drop pushState/replaceState and $app/paths
  base/assets, which kit 3 removed, and mock refreshAll in place of
  invalidateAll.
- The other flagged files only read page.url or goto internal routes,
  so they needed no change.

TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares
typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to
7 once both accept it.

With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0,
so the web lane now audits every dependency rather than only what
ships to browsers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:50:39 -04:00
bvandeusen beba5b2082 Merge remote-tracking branch 'origin/renovate/sveltejs-kit-3.x' into dev 2026-10-08 10:49:44 -04:00
bvandeusenandClaude Opus 5.5 fecd030b68 chore(deps): Tailwind 4 (#5021)
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m53s
release / integration (push) Successful in 5m37s
release / android (push) Successful in 6m54s
release / Build signed APK (releases and dev) (push) Successful in 7m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Failing after 34s
release / Verify release artifacts (tag releases only) (push) Skipped
Renovate's tailwindcss bump (PR #150) plus the migration it needs:

- Theme moves from tailwind.config.js into app.css as `@theme inline`,
  mapping the same FabledSword tokens. tailwind.config.js is gone.
- PostCSS runs @tailwindcss/postcss; autoprefixer is dropped, since
  Tailwind 4 prefixes through Lightning CSS.
- Class renames from @tailwindcss/upgrade 4.3.3, reviewed: outline-none
  -> outline-hidden, focus-visible:outline -> outline-solid, shadow ->
  shadow-sm, shadow-sm -> shadow-xs, flex-shrink-0 -> shrink-0. Bare
  `rounded` stays: v4 keeps it at 0.25rem, as before.
- Three v3 preflight defaults kept in a base layer so nothing changes on
  screen: gray-200 default border colour, gray-400 placeholder text and
  the pointer cursor on buttons.
- The unused class-based dark variant is not carried over; no template
  uses `dark:`.

Clears the five high and two moderate npm audit findings that came in
through Tailwind 3 (braces, chokidar, micromatch, fast-glob,
postcss-selector-parser).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:46:07 -04:00
Renovate Bot 0202c86338 chore(deps): update dependency tailwindcss to v4
renovate/artifacts Artifact file update failure
renovate/stability-days Updates have met minimum release age requirement
2026-10-08 14:45:03 +00:00
Renovate Bot 0500f6aac5 chore(deps): update dependency @sveltejs/kit to v3
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
2026-10-08 14:45:01 +00:00
Renovate Bot c83933b61a chore(deps): update dependency @sveltejs/adapter-static to v4
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
2026-10-08 14:45:00 +00:00
bvandeusenandClaude Opus 5.5 308045d056 chore(deps): vite 8, vite-plugin-svelte 7 and vitest 5 together (#5021)
release / govulncheck (push) Successful in 33s
release / web (push) Successful in 1m41s
release / go (push) Successful in 1m50s
release / integration (push) Successful in 4m57s
release / android (push) Successful in 6m25s
release / Build signed APK (releases and dev) (push) Successful in 6m37s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m13s
release / Verify release artifacts (tag releases only) (push) Skipped
Merges Renovate's three branches (PRs #145, #146, #147), which fail
alone: vite-plugin-svelte 7 requires vite 8, and vitest 5 is the vitest
for vite 8. Renovate changed only package.json, so npm ci failed on each.

The lockfile is regenerated for just these packages: the stale entries
for vite, vitest, @vitest/* and vite-plugin-svelte (with its old
inspector) were dropped and re-resolved, leaving everything else locked.
SvelteKit stays on 2.70.3, which accepts vite 8 and plugin 7. Vite 8
builds with Rolldown, so esbuild moves to 0.28 and rollup leaves the tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:24:51 -04:00
bvandeusen 5d5af359b6 Merge remote-tracking branch 'origin/renovate/vite-8.x' into dev 2026-10-08 10:23:28 -04:00
Renovate Bot 67f1975f53 chore(deps): update dependency vitest to v5
renovate/artifacts Artifact file update failure
renovate/stability-days Updates have met minimum release age requirement
2026-10-08 14:19:20 +00:00
Renovate Bot 5dfbe1361e chore(deps): update dependency vite to v8
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
2026-10-08 14:19:18 +00:00
Renovate Bot 39c33e4306 chore(deps): update dependency @sveltejs/vite-plugin-svelte to v7
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
2026-10-08 14:19:16 +00:00
bvandeusenandClaude Opus 5.5 9be5bbae3c fix(discover): cap the taste-matched arm per album and artist before its LIMIT (#5356)
release / web (push) Successful in 1m37s
release / govulncheck (push) Successful in 53s
release / go (push) Successful in 2m7s
release / integration (push) Successful in 5m2s
release / android (push) Successful in 6m34s
release / Build signed APK (releases and dev) (push) Successful in 6m7s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m36s
release / Verify release artifacts (tag releases only) (push) Skipped
Summed tag weight rewards a track for carrying many of the user's tags, so
on the deploy two artists whose every track carries the whole lo-fi profile
took all 120 rows of the taste-unheard query. capByAlbumAndArtist ran after
the LIMIT and left 6, and the arm with the lowest skip rate (12% against
~23%) handed its slots to dormant and random.

The query now ranks within album, then within artist over what the album
cap kept, before the LIMIT: the same walk the Go cap makes, so the bucket
fills from as many artists as match. The caps come from the Go constants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:24:20 -04:00
bvandeusenandClaude Opus 5.5 9940bc375d feat(android): notifications when the app is closed, a specialUse delivery service (#5347)
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m8s
release / go (push) Successful in 1m31s
release / integration (push) Successful in 5m6s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 6m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m13s
release / Verify release artifacts (tag releases only) (push) Skipped
Roundtable's shape: no FCM. A specialUse foreground service keeps the
process alive; EventsStream stays connected; DeliveryLauncher runs the
catch-up on every notification.created nudge and every reconnect, and is
the single owner of the service's lifetime (signed in AND the device's
"Notifications when the app is closed", on by default).

- NotificationSync pulls the newest page and announces unread notices past
  a high-water mark (auth_session.notifiedUpTo, read and written through
  the DAO), honouring the per-kind phone pref. A first look sets the mark
  without announcing; more than three collapse to one line; nothing is
  posted while the app is on screen.
- Two channels: "Your requests" and "Library health"; the ongoing notice
  sits on a MIN "Background connection" channel.
- EventsStream: a connected flow, 2s→5min backoff with ±25% jitter, and an
  immediate reconnect when a network comes up (a hint, not VALIDATED) or
  the app comes to the foreground.
- BootReceiver restarts delivery after a reboot or a self-update.
- A tap opens what the notice links to (routeForLink), a pile the inbox.
- POST_NOTIFICATIONS is asked for on Android 13+ once delivery is wanted,
  and again when the toggle is turned on.
- Room v12: auth_session.backgroundDelivery and notifiedUpTo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:59:11 -04:00
bvandeusenandClaude Opus 5.5 63709a433d feat(notifications): grouped email digest, new music as a daily summary (#5346)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m27s
release / android (push) Successful in 5m47s
release / Build signed APK (releases and dev) (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
Nothing is emailed per event. New music (request_completed) goes out at
most once a day, at the summary hour in each user's own timezone, grouped
by artist. Everything else is batched: one email a window after the first
un-emailed item, holding whatever accumulated.

- Migration 0074: notification_email_settings (summary hour, batch window,
  admin-configurable) and user_notification_email_state (batch start, last
  sent, failures and retry_after per user and group). Existing rows are
  stamped emailed so the upgrade sends no backlog.
- The Notifier stamps emailed_at at write time when the recipient's email
  channel is off, so turning email on later doesn't send old items.
- Read rows are never selected. A row is stamped only after the mailer
  accepts, in one transaction with the state, against the read's clock, so
  a coalesced row updated mid-send stays pending.
- A failed send backs off 5m doubling to 6h; SMTP not configured just waits.
- Links come from the public address; without one the email has none.
- The mailer now RFC 2047-encodes subjects and strips line breaks from them.
- Admin → Integrations gains a Notification emails card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:48:22 -04:00
bvandeusenandClaude Opus 5.5 62f76290fb fix(android): split notification kinds out of the replayer's dispatch (detekt)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m16s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 4m39s
release / android (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
dispatch reached cyclomatic complexity 16 with the three M489 kinds; they
now share one entry that hands off to dispatchNotification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:38:43 -04:00
bvandeusenandClaude Opus 5.5 c416157a1b feat(android): notifications bell, inbox screen and settings, offline-first (#5343, #5345)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m29s
release / android (push) Failing after 1m41s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 2m49s
release / Build signed APK (releases and dev) (push) Canceled after 2m50s
- The top bar carries a bell with a badge. The badge is inverse, not
  error red, and counts to 9, then shows 9+. It opens the Notifications
  screen: one line per notice, how long ago, "Mark all read", pull to
  refresh. A tap marks the notice read and opens its screen (albums,
  artists, requests, admin requests and quarantine; other admin pages
  land on Admin).
- The newest 50 notices live in Room (cached_notifications, v11 with
  MIGRATION_10_11), so the badge and the list work offline. A refresh
  keeps a read made here that the server hasn't seen yet.
- Reads, read-all and setting toggles go through the MutationQueue
  (rule 100): NOTIFICATION_READ, NOTIFICATIONS_READ_ALL and
  NOTIFICATION_SETTING_SET.
  - Read-all sends the newest notice shown, rounded up a millisecond
    (Room keeps ms, the server µs), so a late replay leaves newer notices
    unread.
  - Settings collapse per kind and channel.
- The `notification.created` live event, a return to the foreground and
  reconnecting all refresh the inbox.
- Settings → Notifications: a row per kind with Inbox, Phone and Email.
  Admin kinds sit under "Library health". Phone and email ride on the
  inbox. One line says why email is off. If the system blocks
  notifications, a row opens Minstrel's notification settings; it is
  re-checked on resume.
- Signing out clears the cached inbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:35:56 -04:00
bvandeusenandClaude Opus 5.5 f11dba2336 feat(api): read-all takes an optional up_to cutoff (M489)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m40s
release / integration (push) Successful in 4m40s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m12s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
POST /api/me/notifications/read-all accepts {"up_to": RFC3339}. Android
queues "mark all read" for replay when offline, and a replay landing later
must not mark notices that arrived in between, which the user never saw.
A coalesced notice updated since then has a newer created_at, so it stays
unread. An empty body still marks everything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:27:29 -04:00
bvandeusenandClaude Opus 5.5 87f8ed6147 feat(web): notification settings per kind and channel (#5345, web half)
- A Notifications section on Settings has a row per kind and a toggle
  each for Inbox, Phone and Email. Labels are short, menu-style.
- Admin kinds sit under "Library health", for admins only.
- Toggles are optimistic and send only the kind and channel touched. A
  failed save reverts unless something newer has happened (snippet
  #5106's generation counter).
- With the inbox off, phone and email are disabled: they ride on it.
- When email isn't usable, one line says why. With no address it links
  to the profile. With no SMTP an admin gets a link to Integrations and a
  listener is simply told. Saving the profile refreshes the settings so
  the line clears.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:25:31 -04:00
bvandeusenandClaude Opus 5.5 956058d4a0 feat(web): notifications bell, unread badge and inbox panel in the header (#5342)
- The bell sits between search and the user menu. Its badge is
  parchment on obsidian, not the accent, which the house style keeps
  off general chrome. It counts up to 9, then shows 9+.
- The panel lists the server-rendered title, body and relative time,
  newest first, with unread rows marked. Clicking a row marks it read
  and opens its link. "Mark all read" appears while anything is unread,
  and an empty inbox says "Nothing waiting for you."
- createNotificationsQuery and createUnreadCountQuery poll every 60s
  while the tab is visible. The `notification.created` live event
  invalidates ['notifications'] so the badge and list refresh promptly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:23:32 -04:00
bvandeusenandClaude Opus 5.5 1e9408c835 feat(notifications): library health reaches admins, coalesced (#5341)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m16s
release / Build signed APK (releases and dev) (push) Successful in 5m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
- A failed scan run sends scan_failed. Each failure adds to the count and
  the notice shows the latest error. A scan cut short by shutdown says
  nothing.
- Marking tracks missing sends tracks_missing with a running count.
- A duplicate sweep that proposes a group it had not proposed before
  sends duplicates_found, counting everything awaiting review. A sweep
  that only re-finds known groups stays quiet, so a read notice isn't
  repeated every sweep (CountDuplicateGroupsDetectedSince).
- A playback-error report sends playback_errors, counting the unresolved
  errors (CountUnresolvedPlaybackErrors).

The library package gets its notifier as a package-level SetNotifier
beside SetEventBus, for the same reason the bus is package-level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:20:33 -04:00
bvandeusenandClaude Opus 5.5 069baeb14d feat(notifications): requests and flags reach the people who act on them (#5340)
- A new request still pending after any auto-approval notifies the
  admins (request_pending), but not the requester if they are an admin.
  A request that dedups into one already in flight is not announced again.
- Approving or rejecting a request notifies the requester, and a
  rejection carries the admin's notes as the reason. An admin deciding
  their own request gets nothing.
- The reconciler notifies the requester when their request arrives
  (request_completed), linking the matched album or artist.
- A request the re-acquisition sweeper files and cannot approve itself
  notifies the admins.
- A quarantine flag notifies every admin except the flagger, naming the
  track, the flagger and the reason.

lidarrrequests.Service.CreateTracked reports whether a request was
inserted or deduped; Create wraps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:17:11 -04:00
bvandeusenandClaude Opus 5.5 94ef8c1887 feat(api): notifications inbox and per-user settings endpoints (#5339)
release / go (push) Successful in 1m35s
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m18s
release / integration (push) Successful in 4m35s
release / android (push) Successful in 4m58s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
M489 step 2.

- GET /api/me/notifications?limit&before: newest first, keyset-paged on
  (created_at, id) with an opaque cursor, plus the unread count.
- GET /api/me/notifications/unread-count: the badge's cheap call.
- POST /api/me/notifications/{id}/read and /read-all. Mark-read is
  idempotent; another user's id is a 404, the same as a malformed one.
- GET/PUT /api/me/notification-settings: every kind the caller can receive
  (admin kinds only for admins) with inbox/phone/email. PUT is partial, so an
  offline replay sends only what was touched, and a batch with any invalid
  change applies nothing. The response says whether email can be delivered
  at all: no address on file, or SMTP not configured. A failed SMTP config
  read is a 500, not "not configured".

notifications.Render turns kind + payload into title, body and link on the
server, so the web inbox, the Android inbox, the phone's shade and the email
digest all say the same thing. mailer.Configured lifts Send's readiness
check out so settings can report it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:10:05 -04:00
bvandeusenandClaude Opus 5.5 8bf333e748 feat(notifications): the inbox store, one writer, coalescing and retention (#5338)
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m34s
release / go (push) Successful in 1m51s
release / integration (push) Successful in 4m59s
release / android (push) Successful in 5m24s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
M489 step 1. The event bus is fire-and-forget, so a client that isn't
connected never hears that a request completed or that tracks went missing.
user_notifications is the durable record; the bus only nudges.

- Migration 0073: user_notifications (kind CHECK-gated, payload jsonb,
  read_at, coalesce_key, emailed_at) and user_notification_prefs (per user,
  per kind: inbox, phone, email). A missing pref row means the kind's
  defaults, so nothing is seeded.
- internal/notifications.Notifier is the only writer. It resolves recipients
  (admin kinds reach admins only, and never the excepted user), honours the
  inbox pref (phone and email ride on it), writes, and publishes a
  contentless notification.created nudge per recipient.
- Burst-prone admin kinds coalesce into one unread row: tracks_missing and
  scan_failed add up their counts, duplicates_found and playback_errors take
  the latest total. Once read, the next event is a new row.
- Retention: read rows go after 90 days, anything after a year, on the
  library_changes compactor's daily shape.

Tests: unit (channel rules, kind table) and integration (recipients, nudge,
coalescing both ways, prefs, owner-scoped idempotent mark-read, every kind
against both schema CHECKs, retention cut-offs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:06:39 -04:00
bvandeusenandClaude Opus 5.5 5dffe51b95 feat(web): duplicates report flags identical audio under different titles (#3885)
release / govulncheck (push) Successful in 15s
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
release / web (push) Successful in 1m10s
release / go (push) Successful in 1m28s
release / integration (push) Successful in 4m21s
release / android (push) Successful in 4m43s
release / Build signed APK (releases and dev) (push) Successful in 4m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
An exact-tier group whose copies carry different titles means at least one
file's tags are wrong, and the recording the other title names may be missing
from the library. WWW (2020) was this: "WWW" was a second copy of the
instrumental, the vocal was absent, and nothing said so. The report now names
the titles and says what it implies, so the absence surfaces at the moment of
choosing which copy to keep.

Titles compare case- and whitespace-insensitively. Acoustic-tier groups are
left alone: across encodings a "Remastered" suffix is routine, not a mislabel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:33:37 -04:00
bvandeusen 3077ed75fe Merge pull request 'Readable accent text app-wide; Android queue drag math under test' (#144) from dev into main
release / govulncheck (push) Successful in 18s
release / Build signed APK (releases and dev) (push) Skipped
release / web (push) Successful in 1m16s
release / go (push) Successful in 1m30s
release / integration (push) Successful in 4m2s
release / android (push) Successful in 4m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 06:29:48 -04:00
bvandeusenandClaude Opus 5.5 43c369f082 test(android): queue drag math is a pure function, held to web's cases (#2436)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m29s
release / android (push) Successful in 4m46s
release / Build signed APK (releases and dev) (push) Successful in 4m58s
release / integration (push) Successful in 15m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
The drag-offset-to-row arithmetic lived inline in queueReorderDrag's
onDragEnd lambda, which no JVM test can reach. Web's copy, offsetToDelta,
has been extracted and tested since the start, and the Android comment
says it mirrors web, but only one side could be held to that.

- QueueDragMath.kt adds queueDragDelta (web's offsetToDelta) and
  queueDragTarget (delta plus the clamp to the queue). Kotlin's roundToInt
  breaks ties toward positive infinity, the same as JS Math.round, so the
  web cases carry over exactly, including half a row up staying put.
- queueDragTarget returns the start index for an empty queue instead of
  letting coerceIn(0, -1) throw.
- QueueDragMathTest mirrors queue-row-math.test.ts one case at a time,
  plus clamping past either end, a sub-half-row drag, an unmeasured row,
  and the empty queue.

No behaviour change for a non-empty queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:18:43 -04:00
bvandeusenandClaude Opus 5.5 b46c080d19 fix(web): all accent text and icons use accent-fg (#5318)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m26s
release / integration (push) Successful in 4m18s
release / android (push) Successful in 4m44s
release / Build signed APK (releases and dev) (push) Successful in 4m53s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
The raw accent fails AA as text on every dark surface, not only on its
own tint: 3.04:1 on the page, 2.70 on iron, 2.21 on slate, against 4.5.
accent-fg (the house formula, 45% toward parchment) measures 5.62 at
worst across both modes. The operator chose the readable colour over the
signature teal for text, on 2026-10-08.

- 36 sites swap. They are 35 Tailwind uses: links, "Now playing", the
  ingest progress line, active shuffle/repeat, the liked heart, the app
  download icon and its hover. The last is the alphabet rail's pending
  spinner in CSS. Icons follow the text: as graphics they need only
  3:1, and the raw accent misses even that on iron.
- check-tint-contrast adds accent to TEXT_NEVER_RAW, so a new raw
  text-accent or color: var(--fs-accent) fails the web lane. Run against
  the files before the swap, it finds all 36. Borders, rings and fills
  keep the raw accent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:17:19 -04:00
bvandeusen 97e84a79eb Merge pull request 'Multi-value genres for FLAC/Ogg/MP4, readable tinted and error text, no missing-track seeds' (#143) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 31s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m41s
release / android (push) Successful in 4m44s
release / integration (push) Successful in 17m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-07 23:53:09 -04:00
bvandeusenandClaude Opus 5.5 37d3a5bcd3 fix(library): read every genre of FLAC, Ogg, Opus and MP4 files (#2500)
release / govulncheck (push) Successful in 50s
release / web (push) Successful in 2m3s
release / go (push) Successful in 2m18s
release / integration (push) Successful in 5m42s
release / android (push) Successful in 6m30s
release / Build signed APK (releases and dev) (push) Successful in 6m0s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
Vorbis comments repeat a field to give it several values (GENRE=Boom Bap,
GENRE=Downtempo, ...). dhowden/tag keeps comments in a map keyed by field
name, so each repeat overwrote the previous one and only the last genre
was stored. MP4 has the same gap: several data atoms in one ©gen atom, or
repeated ©gen atoms, collapse to one value.

On the operator's library 3,658 of 4,092 FLACs declare more than one
genre. Kupla's Life Forms carries eight and was stored as "Instrumental
Hip Hop" alone, so browse and the taste profile never saw the other seven.

- vorbisgenre.go reads the comment block directly: FLAC's metadata block
  (including FLACs behind an ID3v2 tag), and the comment packet of Ogg
  Vorbis and Opus, reassembled across pages when cover art makes it span
  several.
- mp4genre.go walks moov > udta > meta > ilst and returns every ©gen text
  value. It handles ISO and QuickTime meta layouts and a moov placed after
  mdat. A file with only the numeric gnre atom still falls back to
  dhowden, which resolves it.
- extractGenres routes VORBIS and MP4 through them, as #2499 did for
  ID3v2.
- tagReadVersion 3 -> 4, so the next scan re-reads the tags of files
  already indexed. Unchanged files keep their duration and fingerprint,
  so the pass costs tag reads only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:40:56 -04:00
bvandeusenandClaude Opus 5.5 efa3bf54ce fix(playlists): a missing track never seeds For You or Songs-like (#2701)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m27s
release / android (push) Successful in 5m2s
release / Build signed APK (releases and dev) (push) Successful in 5m17s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m15s
release / Verify release artifacts (tag releases only) (push) Skipped
PickTopPlayedTracksForUser's liked tier read general_likes without
joining tracks. With no plays to seed from, For You could pick a liked
track whose file is gone. The play tiers were already safe: their
play_events join filters missing_since.

The same gap was in PickTopPlayedTrackForArtistByUser's fallback, which
seeds Songs-like from the artist's newest album when there are no recent
plays. It could pick a missing track, and since #5296 a missing track is
never fetched for similarity, so that seed has no edges either. The
caller already skips an empty seed, so an artist whose tracks are all
missing gets no Songs-like mix instead of one aimed at nothing.

Integration tests cover both cases: a liked-but-missing track is not a
seed, and the Songs-like fallback moves to the next album once the
newest one's track goes missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:10:17 -04:00
bvandeusenandClaude Opus 5.5 743b6f5eac fix(web): error text uses error-fg on every surface, not only on tints (#3150)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m6s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m30s
release / android (push) Successful in 5m18s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Raw error red fails AA as text even with no tint behind it: in dark mode
it measures 3.63:1 on obsidian, 3.23 on iron and 2.64 on slate, against
4.5. error-fg (the house formula, 50% toward parchment) measures 5.30 at
worst across both modes.

- All 19 text-error uses become text-error-fg: the "Couldn't load"
  messages on the admin pages, the integrations form errors, the flag
  popover, and the error toast's text. The toast keeps its error border,
  since a border is a graphic with a 3:1 floor.
- check-tint-contrast flags raw error text anywhere (text-error,
  class:text-error, color: var(--fs-error)) and leaves borders and
  outlines alone. Run against the files before the swap, it finds all 19.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:00:28 -04:00
bvandeusenandClaude Opus 5.5 fdee77eaec fix(web): text on a tint of its own hue uses the house -fg tokens (#3150)
release / govulncheck (push) Successful in 38s
release / integration (push) Successful in 5m1s
release / Build + push container image (push) Successful in 1m17s
release / Verify release artifacts (tag releases only) (push) Skipped
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m48s
release / android (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Attach APK to the Release (tag releases only) (push) Skipped
A hue painted as text on a color-mix tint of itself sits close to the
surface under it. On Minstrel's surfaces the raw accent on its 15% tint
measures 1.97:1 at worst (dark mode, hover surface), against AA's 4.5.

- tokens.json gains colors.fg: the five FabledSword -fg formulas (accent
  45%, success 45%, warning, error and info 50%), each mixed toward
  parchment so one declaration serves both modes. Success is Minstrel's
  moss. tokens-to-css emits them in :root.
- Tailwind exposes them as text-accent-fg, text-warning-fg, text-error-fg
  and text-info-fg.
- 23 sites swapped: 14 Tailwind class strings (PlayerBar and the admin
  count pills) and 9 CSS rules (StatusPill's four tones and five accent
  chips). Worst case after: accent-fg 5.03, error-fg 4.75, warning-fg
  4.92, success-fg 4.85.
- scripts/check-tint-contrast.js finds the pair in either spelling. Its
  test scans src in the web Vitest lane and fails on any new site, with
  fixture cases showing it can fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:51:52 -04:00
bvandeusenandClaude Opus 5.5 c17f4273c6 test(web): stub SvelteKit app modules suite-wide so no test loads the client runtime (#3943)
release / govulncheck (push) Successful in 32s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 4m55s
release / android (push) Successful in 6m15s
release / Build signed APK (releases and dev) (push) Successful in 6m42s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m4s
The home page reaches the real $app/navigation through AlbumCard and
AlbumMenu. That loads SvelteKit's client runtime, whose $app/paths reads
__SVELTEKIT_PAYLOAD__ at module load. The global is only there when the
kit plugin's define reaches the module, and under vitest that is not
reliable: page.test.ts failed to load on CI run 6576 and passed on its
re-run. #374 was the same class of failure.

vitest.setup.ts now mocks $app/navigation, $app/state and $app/paths for
every test. Per-file mocks still win. A small guard test fails if the
suite-wide mocks are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:26:21 -04:00
bvandeusen a29ceaaeab Merge pull request 'Similarity cache and round-robin artist arms (#5296, #5297)' (#142) from dev into main
release / android (push) Successful in 5m1s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m25s
release / Verify release artifacts (tag releases only) (push) Skipped
release / go (push) Successful in 2m28s
release / web (push) Successful in 57s
release / govulncheck (push) Successful in 19s
release / Build signed APK (releases and dev) (push) Skipped
release / integration (push) Successful in 5m9s
2026-10-07 20:02:59 -04:00
bvandeusenandClaude Opus 5.5 e5dac9ddf0 fix(similarity): keep ListenBrainz's whole answer and resolve it locally (#5296)
release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m27s
release / go (push) Successful in 1m51s
release / integration (push) Successful in 5m36s
release / android (push) Successful in 7m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build signed APK (releases and dev) (push) Successful in 8m29s
release / Build + push container image (push) Successful in 1m47s
release / Verify release artifacts (tag releases only) (push) Skipped
The worker kept only the similar recordings already in the library, at most
20 of ListenBrainz's 50, and judged freshness by the edges it had written.
Two failures followed, both measured on the operator's library (#3879):

- A seed whose answer matched nothing wrote nothing, so it was never fresh.
  With the queue ordered by id, 25 such seeds held its head and were
  re-asked every hour; 17 of 2,466 played seeds had any edges.
- A recording that reached the library after its seed was fetched (a
  Lidarr import, an MBID from the AcoustID lookup) was never linked until
  a refetch, which for the stuck seeds never came.

Now every answer is cached whole in listenbrainz_similar_recordings and
every answer, an empty one or a permanent 4xx included, is recorded in
track_similarity_fetches. The queue reads the fetch record: never-fetched
first, then the oldest, refreshed after 30 days. The listenbrainz edges are
derived in SQL from the cache, one present track per recording and no cap,
for the seed just fetched and for every seed once per tick, so new arrivals
link within the hour without asking ListenBrainz again.

Artists get the same queue fix via artist_similarity_fetches; their answer
was already kept in artist_similarity and artist_similarity_unmatched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:56:22 -04:00
bvandeusenandClaude Opus 5.5 aa390c711a fix(recommendation): artist-level arms take one track per related artist in turn (#5297)
The similar_artists and coplay_artists arms ordered by artist score, so the
closest related artist's catalogue filled the whole LIMIT. On the operator's
library the 30-row similar_artists arm held exactly one artist for all 17
seeds measured (#3879), though each seed had 7-33 similar artists in the
library. Both arms now rank tracks within each artist and take every
artist's first track, best artist first, before anyone's second.

Both also skip missing tracks: the outer select already dropped them, but
only after they had taken places in the arm's LIMIT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:56:21 -04:00
bvandeusen 54b2720d76 Merge pull request 'Fold stale missing tracks into their on-disk replacement (M485)' (#141) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m2s
release / go (push) Successful in 1m20s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
release / integration (push) Successful in 3m51s
release / android (push) Successful in 4m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
2026-10-07 18:17:51 -04:00
bvandeusenandClaude Opus 5.5 865a3176c9 feat(library): fold a missing track into its on-disk replacement (M485 #5286 #5287)
release / web (push) Successful in 2m21s
release / go (push) Successful in 2m34s
release / govulncheck (push) Successful in 40s
release / integration (push) Successful in 6m17s
release / android (push) Successful in 6m36s
release / Build signed APK (releases and dev) (push) Successful in 6m6s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m19s
release / Verify release artifacts (tag releases only) (push) Skipped
A track marked missing whose replacement is already on disk under another
row — on the operator's library 355 of 451 missing tracks, nearly all Lidarr
mp3 -> flac upgrades — is folded into the replacement: likes, plays, playlist
entries and tags move across and the missing row goes. Move adoption could
not catch these: the replacements were re-encodes (no shared audio hash) with
no recording MBID at import.

Pairs (ListMissingTrackPairs): the same recording MBID within the album
group, or the same album row and title ignoring case. Each side must have
exactly one candidate; conflicting MBIDs refuse a pair. No duration or track
position gate: on the 142 pairs known to be one recording, 18% differed by
over 2s and the poorly tagged set is where numbering is broken (spike #5274).

Each pair folds in its own transaction after locking both rows and checking
the pair still holds. Runs automatically (operator, 2026-10-07) after a full
scan, after a watcher batch that added or updated tracks, and after an
AcoustID pass that matched any track. The first scan after deploy repairs the
existing rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:23:32 -04:00
bvandeusenandClaude Opus 5.5 2df28345b4 refactor(library): the per-copy fold is a helper the duplicate merge calls (M485 #5285)
MergeDuplicateGroup's loop body — repoint and copy every FK from a removed
copy onto the survivor, inherit its MBID, delete its row, tidy an emptied
album — moves into foldTrackInto, so the missing-pair pass can fold a stale
missing row into its replacement with the same mechanics. The group lock,
file removal and group bookkeeping stay in the merge. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:23:32 -04:00
bvandeusen fe142e3451 Merge dev: request completion fix, x/text bump, full-logo tab icon (#140)
release / govulncheck (push) Successful in 22s
release / Build signed APK (releases and dev) (push) Skipped
release / web (push) Successful in 1m54s
release / go (push) Successful in 2m22s
release / integration (push) Successful in 5m7s
release / android (push) Successful in 5m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 17s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-07 15:35:01 -04:00
bvandeusenandClaude Opus 5.5 0766349397 feat(brand): the browser tab icon is the full logo (#5267)
release / web (push) Successful in 1m15s
release / govulncheck (push) Successful in 47s
release / go (push) Successful in 2m19s
release / integration (push) Successful in 5m34s
release / Build signed APK (releases and dev) (push) Successful in 6m23s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / android (push) Successful in 6m10s
release / Build + push container image (push) Successful in 1m30s
release / Verify release artifacts (tag releases only) (push) Skipped
The tab icon was a hand-drawn reduced hat, made because the traced art loses
detail at 16px. A redrawing reads as a different logo. The tab icon now uses
the same traced mark as the header: a high-resolution screen draws a tab icon
from 32px, where it holds, and at 16px it keeps the logo's shape.

The drawn reduced mark had no other consumer, so it leaves the generator,
and mark-small.svg (referenced nowhere) is removed. Regenerating changed only
favicon.svg and favicon.png; every other brand asset is byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:19:10 -04:00
bvandeusenandClaude Opus 5.5 fcbad3ad40 fix(deps): raise golang.org/x/text to v0.41.0 for GO-2026-6629
release / govulncheck (push) Successful in 12s
release / web (push) Successful in 1m18s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 4m34s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 6m5s
release / android (push) Canceled after 6m8s
govulncheck began failing on a new advisory: a panic parsing crafted input in
x/text/secure/precis, reachable from db.Open via pgxpool. x/text is indirect
(through pgx), so the Dependency Dashboard has no update queued for it.
x/text v0.41.0 requires x/sync v0.22.0, which go mod tidy raised with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:13:09 -04:00
bvandeusenandClaude Opus 5.5 4e3ce4065c fix(lidarr): complete a request only when the album actually came back (#5263)
release / govulncheck (push) Failing after 33s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m44s
release / integration (push) Successful in 4m37s
release / android (push) Successful in 5m29s
release / Build signed APK (releases and dev) (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Re-acquisition targets albums with ANY track missing, and completion only
asked for a track on disk, so the tracks that never left completed every
re-acquisition request the moment Lidarr accepted the add (52 on the deploy,
each ~150ms after its add).

An album or track request now completes when an album named by its release
or group has a track on disk AND either a track arrived after the request
(a new album, or Lidarr fetching another release into its own row) or no
track that was missing at the request is still missing. added_at is the
arrival clock; updated_at moves on every tag re-read.

Migration 0071 reopens completed album/track requests whose matched album
fails that test, as approved with the match cleared; the Lidarr add stays
confirmed, so nothing is re-sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:55:32 -04:00
bvandeusen 3bdfa94f39 Merge dev: M483 release-group ids for Lidarr (#139)
release / integration (push) Successful in 3m46s
release / android (push) Successful in 4m24s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m1s
release / go (push) Successful in 1m21s
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-07 12:29:12 -04:00
bvandeusenandClaude Opus 5.5 e509d7d5a9 feat(lidarr): ask Lidarr for release groups; repair stored release-id requests (M483 #5244)
release / web (push) Successful in 1m41s
release / go (push) Successful in 2m33s
release / govulncheck (push) Successful in 22s
release / integration (push) Successful in 6m0s
release / android (push) Successful in 6m7s
release / Build signed APK (releases and dev) (push) Successful in 6m8s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m43s
release / Verify release artifacts (tag releases only) (push) Skipped
Lidarr's metadata is keyed by MusicBrainz release group, but re-acquisition
requested albums by their release id, so every add came back "not found".

- Sweeper requests an album by its tag-supplied release group, else the one
  MusicBrainz names (cached onto the album). An album MusicBrainz cannot name
  is skipped and counted, with no attempt spent.
- Reconciler: an add refused as not found re-reads the request's album id as
  a release (library first, then MusicBrainz), rewrites the request to the
  group and adds again. This repairs the requests already stored.
- Completion matches an album by release id or release group, and only once a
  track of it is on disk, so a re-acquisition request no longer completes
  against the row of the album it is trying to bring back.

Closes #5241.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:38:45 -04:00
bvandeusenandClaude Opus 5.5 2b4e274b12 feat(tags): resolve a MusicBrainz release to its release group (M483 #5243)
ReleaseGroupForRelease asks /ws/2/release/<id>?inc=release-groups through
the registered MusicBrainz provider, so it shares that provider's client
and 1 req/s limiter with tag enrichment and respects its on/off switch.
ErrNotFound when switched off or MusicBrainz has no such release (an id
that is already a release group included); ErrTransient to retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:34:04 -04:00
bvandeusenandClaude Opus 5.5 5c19a916ba feat(library): store each album's MusicBrainz release-group id (M483 #5242)
release / govulncheck (push) Successful in 22s
release / web (push) Successful in 1m14s
release / go (push) Successful in 1m33s
release / integration (push) Successful in 4m36s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 5m24s
release / Build signed APK (releases and dev) (push) Canceled after 5m29s
albums.mbid is the release id (Picard's musicbrainz_albumid, one edition).
Lidarr names albums by release group, so re-acquisition and request
completion need that id too (#5241).

Migration 0070 adds albums.release_group_mbid (nullable, non-unique index:
several releases share a group). The scanner reads musicbrainz_releasegroupid
through extractReleaseGroupMBID, writes it on insert and heals it onto
existing rows when NULL. tagReadVersion goes to 3 so the next scan fills
it for the library already indexed, bound by tag reads (no ffprobe, no
decode).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:33:26 -04:00
bvandeusen 22ea27efff Merge pull request 'Lidarr: album adds that Lidarr accepts, artist monitoring that sticks' (#138) from dev into main
release / govulncheck (push) Successful in 27s
release / Build signed APK (releases and dev) (push) Skipped
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m48s
release / integration (push) Successful in 4m24s
release / android (push) Successful in 5m16s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
2026-10-07 11:08:18 -04:00
bvandeusenandClaude Opus 5.5 bf6364b709 fix(lidarr): send the artist add's monitor choice in addOptions (#5239)
release / govulncheck (push) Successful in 29s
release / web (push) Successful in 1m58s
release / go (push) Successful in 2m14s
release / integration (push) Successful in 5m17s
release / android (push) Successful in 6m21s
release / Build signed APK (releases and dev) (push) Successful in 6m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m1s
release / Verify release artifacts (tag releases only) (push) Skipped
ArtistResource has no top-level `monitor`. Lidarr reads the choice from
AddOptions (AddArtistOptions, a MonitoringOptions), so the "all"/"future"
we sent there was dropped on deserialisation. AddOptions.Monitor stayed
Unknown, and AlbumMonitoredService.SetAlbumMonitoredStatus returns early
on Unknown. The request's monitoring was never applied.

Send monitor and monitored inside addOptions with searchForMissingAlbums,
the shape Lidarr's getNewArtist.js posts, and set monitorNewItems "all"
explicitly: both choices mean new releases are watched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:58:55 -04:00
bvandeusenandClaude Opus 5.5 670b30c954 fix(lidarr): add an album as the looked-up resource with its artist nested (#5234)
release / web (push) Successful in 1m42s
release / go (push) Successful in 2m7s
release / govulncheck (push) Successful in 37s
release / integration (push) Successful in 5m31s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 5m14s
release / Build signed APK (releases and dev) (push) Canceled after 4m42s
Lidarr's POST /api/v1/album validates `artist` as a nested resource
(AlbumController: RuleFor(s => s.Artist).NotNull()), so the flat payload
we sent was refused with "'Artist' must not be empty" every time. The
album add has never worked against a real Lidarr; approved album and
track requests sat in the reconciler retrying every 5 minutes.

AddAlbum now does what Lidarr's own add-album UI does (getNewAlbum /
getNewArtist): look the album up by MBID (album/lookup?term=lidarr:<mbid>),
then POST that resource back with monitored + searchForNewAlbum. When
Lidarr doesn't have the artist yet, the nested artist gets the request's
quality/metadata profile and root folder, monitors this album only
(monitor "none" + albumsToMonitor, which AlbumMonitoredService prefers)
and no future releases. An artist Lidarr already has is left as it is.
An MBID Lidarr's metadata doesn't know is ErrNotFound with no POST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:55:41 -04:00
bvandeusen 09bd71629a Merge pull request 'M401: AcoustID recording-id lookup' (#137) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m6s
release / go (push) Successful in 1m24s
release / integration (push) Successful in 4m3s
release / android (push) Successful in 5m17s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m26s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-07 09:50:11 -04:00
bvandeusenandClaude Opus 5.5 e8eee55325 fix(web): AcoustID card's loading line names itself (M401 #3922)
release / integration (push) Successful in 6m4s
release / android (push) Successful in 8m1s
release / Build signed APK (releases and dev) (push) Successful in 8m22s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m31s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 28s
release / go (push) Successful in 1m41s
release / web (push) Successful in 1m21s
Its bare "Loading…" made the Integrations page's cover-providers test find
two matches for /loading…/i (Vitest, run 8487). "Loading AcoustID
settings…" also says which card is loading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:47:14 -04:00
bvandeusenandClaude Opus 5.5 03e07e7c66 fix(web): send the empty body api.post requires for AcoustID run-now (M401 #3922)
release / govulncheck (push) Successful in 25s
release / web (push) Failing after 1m18s
release / go (push) Successful in 1m43s
release / Build + push container image (push) Canceled after 0s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m51s
release / android (push) Canceled after 4m51s
release / Build signed APK (releases and dev) (push) Canceled after 4m6s
svelte-check on 0a7f7883: api.post takes a body. Every other body-less POST
in the client passes {}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:42:25 -04:00
bvandeusenandClaude Opus 5.5 0a7f788390 feat(web): AcoustID card on Integrations — key, threshold, coverage by source (M401 #3922)
release / go (push) Successful in 2m25s
release / web (push) Failing after 26s
release / govulncheck (push) Successful in 21s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 3m34s
release / android (push) Canceled after 1m3s
release / Build signed APK (releases and dev) (push) Canceled after 1m3s
The card takes the slot of the unimplemented "MusicBrainz overrides"
placeholder. Rows:
- the on switch
- a write-only key field (the stored key is never sent back), with a
  link to register an application
- the minimum score (0.5 to 1)

Below them, recording-id coverage reads as a column: from tags, looked
up, none, and of the none how many are waiting, no match, ambiguous or
failed. There is a "Look up now" button and a folded list of the tracks
the lookup could not settle.

Off, keyless and stopped-short passes are each a visible state with the
reason (rule 164).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:29:34 -04:00
bvandeusenandClaude Opus 5.5 3c575b137c feat(library): AcoustID lookup worker fills the MBIDs tags leave empty (M401 #3920 #3921)
release / web (push) Successful in 1m44s
release / go (push) Successful in 2m1s
release / govulncheck (push) Successful in 17s
release / integration (push) Successful in 5m22s
release / android (push) Successful in 5m48s
release / Build signed APK (releases and dev) (push) Successful in 5m53s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m6s
release / Verify release artifacts (tag releases only) (push) Skipped
Migration 0069 adds tracks.mbid_source (tag | acoustid), a lookup state per
track (matched | ambiguous | no_match | failed) and the acoustid_settings
row (off, no key, min score 0.85).

The file's tag outranks a lookup (D4). UpsertTrack keeps a looked-up id
through a re-read that finds no tag id and replaces it as soon as one
appears. SetTrackMbidFromAcoustID refuses to write over a tag id.

The worker fingerprints each untagged track with fpcalc's compressed
print, looks it up and writes an id only when D5 settles it: one
recording at or above the threshold, or one left after matching title and
length. Ambiguous and no-match results write nothing. A key AcoustID
refuses, or the service being unreachable, stops the pass and is reported
in the worker's status. It never counts as a verdict on a track.

A changed file drops its lookup in the scan. The re-lookup takes back an
id that no longer matches.

Admin API: GET /api/admin/library/acoustid (settings, status, coverage by
source), PUT …/acoustid-settings (write-only key), POST …/acoustid/run,
GET …/acoustid/unsettled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:26:03 -04:00
bvandeusenandClaude Opus 5.5 f13da62797 feat(library): AcoustID lookup client and the compressed fpcalc print (M401 #3919)
internal/acoustid posts AcoustID's v2 lookup as a gzip form with
meta=recordings, one request per 400ms (their limit is 3/s) and a 20s
deadline. It returns every linked recording with its best score; choosing
among them is the worker's job (D5). The server's error codes map to an
invalid key (stop and say so), a rejected fingerprint (a verdict on the
track) or unavailable (try again later). A cancelled caller stays a
cancellation.

fpcalcLookupArgs and parseFpcalcCompressed read fpcalc's default output,
the compressed string the lookup takes (D1), always over the first 120s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:16:52 -04:00
bvandeusen c83670d216 Merge pull request 'M464 loudness leveling (steps 4–8), APK distribution and security baseline adoptions' (#136) from dev into main
release / web (push) Successful in 1m41s
release / go (push) Successful in 2m0s
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 21s
release / integration (push) Successful in 6m6s
release / android (push) Successful in 7m23s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-06 23:11:35 -04:00
bvandeusenandClaude Opus 5.5 b28cbe0600 feat(android): refuse plain http:// to a public server address (#5111)
release / go (push) Successful in 1m47s
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m15s
release / integration (push) Successful in 4m44s
release / android (push) Successful in 5m37s
release / Build signed APK (releases and dev) (push) Successful in 5m43s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m13s
release / Verify release artifacts (tag releases only) (push) Skipped
Cleartext stays permitted app-wide for LAN servers and UPnP (#2439),
but a password or session cookie sent over plain HTTP to a public
address can be read by anyone on the path. A network interceptor now
refuses a cleartext request to the Minstrel server when the connection
lands on a public address, before any request byte is written.

Checked per connection, on the address actually reached, rather than
when the URL is typed: a name that resolved to the home network at
entry resolves to a public address once the phone leaves home.
Allowed: loopback, 10/8, 172.16/12, 192.168/16, link-local, 100.64/10
(Tailscale and other overlay VPNs) and fc00::/7. Only requests
BaseUrlInterceptor tagged as server-bound are checked; external
fetches and UPnP are untouched. The refusal has its own message.

Family baseline #5105, practice 13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:02:07 -04:00
bvandeusenandClaude Opus 5.5 40dd5bb52c ci(android): pin the release certificate in the signer check (#5116)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m25s
release / android (push) Successful in 6m25s
release / Build signed APK (releases and dev) (push) Successful in 7m0s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 1m3s
The check failed only on a debug signer, so an APK signed by any other
wrong key (a regenerated keystore, a swapped secret) would publish and
then reach no installed phone: Android updates in place only when the
signer matches. The step now requires exactly one signer whose
SHA-256 digest is the release certificate's (CN=Minstrel,
O=FabledSword, read from run 8446), and names a debug key or the
digest it got when it fails. Rotating the key on purpose changes the
digest in the same commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:51:33 -04:00
bvandeusenandClaude Opus 5.5 a1e9de2c84 ci(android): never ship a debug-signed APK; check the signer (#5116)
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m57s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m26s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 26s
release / go (push) Successful in 2m5s
release / web (push) Successful in 1m24s
Adopts the rest of family idea #5103 (distributing your own APK):

- Practice 2: build.gradle.kts no longer falls back to the debug key
  when ANDROID_KEYSTORE_PATH is unset; the release build is signed with
  the release key or left unsigned. Main no longer builds and uploads a
  debug-signed app-debug.apk, which no install could ever update.
- Practice 3: android-release runs apksigner on the built APK, prints
  the signer's DN and SHA-256 digest, and fails on a debug signer.
  An unsigned build fails the same step, since there is no
  app-release.apk to verify.
- Practice 9: debug builds offer no server update. The banner does not
  poll and the About card says updates come from Android Studio, since
  the release-signed APK cannot install over a debug-signed app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:33:22 -04:00
bvandeusenandClaude Opus 5.5 13a7a3629a fix(library): MBID backfills skip tracks whose files are missing (#5139)
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m34s
release / integration (push) Successful in 4m55s
release / Build signed APK (releases and dev) (push) Successful in 6m39s
release / android (push) Successful in 6m22s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m53s
release / Verify release artifacts (tag releases only) (push) Skipped
The track backfill listed every track with a NULL mbid, missing or
not, so each scan tried to open every missing file and logged an
"open failed" warning per track. Nothing ever healed. The album
backfill could pick a missing track as the one to read, and since
that pass is capped per scan, albums stuck that way were retried
ahead of the rest every time.

Both now read only tracks still on disk; an album with none left is
skipped until a scan finds its files again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:22:05 -04:00
bvandeusenandClaude Opus 5.5 2e36e70268 feat(android): Sonos/UPnP queue plays leveled URLs, rendered a track ahead (M464 #5002)
release / go (push) Successful in 2m49s
release / web (push) Successful in 2m21s
release / govulncheck (push) Successful in 25s
release / integration (push) Successful in 5m54s
release / android (push) Successful in 8m10s
release / Build signed APK (releases and dev) (push) Successful in 8m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m42s
release / Verify release artifacts (tag releases only) (push) Skipped
Every URL the Sonos queue loader sends is minted with level=true and
the track's album-play verdict from its neighbours in the queue; the
server returns the plain stream when leveling is off or changes
nothing. The playing track and the one after it are rendered ahead,
and each time the renderer moves on, the next is.

Server: a mint no longer prerenders on its own. A queue load mints
every track, which would have started an ffmpeg render per track at
once. The request now carries prerender, and at most two prerenders
run at a time; past that they are dropped, since a fetch renders on
demand anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:38:21 -04:00
bvandeusenandClaude Opus 5.5 f34423a0e0 feat: leveled FLAC stream for Sonos/UPnP speakers (M464 #5001)
release / go (push) Successful in 2m17s
release / govulncheck (push) Successful in 26s
release / web (push) Successful in 2m4s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m39s
release / android (push) Canceled after 2m53s
release / Build signed APK (releases and dev) (push) Canceled after 2m24s
Speakers fetch their own audio, so the phone cannot level it. A cast
token minted with level=true (and the client's asAlbum, which only the
queue holder knows) now returns GET /api/tracks/{id}/leveled.flac: the
track rendered by ffmpeg at the user's gain (volume=XdB, plus
alimiter at -1 dBFS for a limiter-mode boost), metadata stripped, FLAC
at 16 or 24 bits and at most 48 kHz. The gain is computed server-side
from the user's preference and the stored loudness, carried as
?g=<centi-dB>&lim=0|1 and signed into the token, so an edited URL does
not verify. Unity gains get the plain stream.

Renders are written beside the cache file and renamed in, keyed by the
source's size and mtime, coalesced per file (singleflight, detached
from the requesting speaker so a retry finds the render running),
started at mint time so the fetch finds them ready, and evicted least
recently used past leveled_cache_mb, a new admin setting (migration
0068, Loudness analysis card).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:31:07 -04:00
bvandeusenandClaude Opus 5.5 92c3f9bdb8 fix(android): look gains up by key, not value (M464 #5000)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m28s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 4m38s
release / android (push) Successful in 5m19s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m13s
release / Verify release artifacts (tag releases only) (push) Skipped
`id in map` on a ConcurrentHashMap resolves to its legacy contains(),
which tests values (KT-18053); the compiler refuses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:18:15 -04:00
bvandeusenandClaude Opus 5.5 1013c283da feat(android): level playback with a gain processor in the audio sink (M464 #5000)
release / go (push) Successful in 1m43s
release / web (push) Successful in 1m27s
release / govulncheck (push) Successful in 35s
release / integration (push) Successful in 5m16s
release / android (push) Failing after 3m52s
release / Build signed APK (releases and dev) (push) Failing after 3m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Media3 1.10.1 -> 1.11.0, the version Renovate proposes; 1.11 flushes the
sink's audio processors at every item boundary with the playlist timeline
and the new item's period. GainAudioProcessor uses that to find the track
and its play-order neighbours (auto mode's album rule) and applies the
gain from the first sample, gapless transitions included, with a -1 dBFS
peak limiter in limiter mode and a full-scale clamp otherwise.

Gains come from the library cache first (sync now carries track and album
ReplayGain values; Room v10 adds the columns and rewinds the sync cursor
so an existing cache re-pulls them), then GET /api/tracks/replay-gain,
then none. The player service refreshes the leveling preference at start.

Web: a same-album neighbour without a track number no longer counts as
in-order album play, matching Android.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:10:35 -04:00
bvandeusenandClaude Opus 5.5 38290bf8f9 feat(web): level playback by the user's normalization preference (M464 #4999)
release / integration (push) Successful in 4m26s
release / android (push) Successful in 5m20s
release / Build signed APK (releases and dev) (push) Successful in 5m31s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
Cuts go through element.volume. Boosts route the element through a Web
Audio GainNode and a DynamicsCompressor (a -1 dBFS limiter in limiter
mode, a pass-through otherwise), built only when a track wants a boost
and only once an AudioContext is confirmed running; iOS never gets the
graph. Auto mode takes album gain when a queue neighbour is from the
same album in track order. Gains are fetched for the next 50 tracks as
the queue moves, with a 10s deadline. The prefetch element is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:21:41 -04:00
bvandeusenandClaude Opus 5.5 2a7eb3dd19 ci(integration): give the suite a 20m package timeout
release / govulncheck (push) Successful in 37s
release / go (push) Successful in 1m32s
release / web (push) Successful in 1m12s
release / android (push) Successful in 6m26s
release / Build signed APK (releases and dev) (push) Successful in 6m49s
release / integration (push) Successful in 19m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m18s
release / Verify release artifacts (tag releases only) (push) Skipped
internal/api takes ~6.5 min under -race on an idle runner; with a second
run on the same runner it crossed go test's default 10m (run 8368: FAIL
at 600.016s with the running test 2s old, so load, not a hang).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:52:01 -04:00
bvandeusenandClaude Opus 5.5 d5dfcf5b7c fix: boost control is a switch; MutationQueue keeps one enqueue per kind (M464 #4998)
release / go (push) Successful in 2m15s
release / govulncheck (push) Successful in 29s
release / web (push) Successful in 1m42s
release / android (push) Successful in 5m57s
release / Build signed APK (releases and dev) (push) Successful in 5m49s
release / integration (push) Failing after 20m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
The ListenBrainz settings test finds the page's one checkbox, and the
boost control is a toggle anyway. detekt counts MutationQueue's enqueue
functions; suppressed as the replayer's dispatchers already are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:34:28 -04:00
bvandeusenandClaude Opus 5.5 af36b2f24a feat: per-user volume leveling preference, synced across devices (M464 #4998)
release / web (push) Failing after 1m5s
release / govulncheck (push) Successful in 22s
release / go (push) Successful in 1m17s
release / android (push) Failing after 1m51s
release / integration (push) Canceled after 4m12s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 3m21s
Mode (off, auto, track, album), target (-18, -16, -14 LUFS) and boost
(within headroom, or fully with a limiter), stored per user on the server
so the web player, the Android app and casts apply the same one.

- Server: user_normalization_prefs (migration 0067), GET/PUT
  /api/me/normalization; a whole-body PUT, validated, last write wins.
- Web: Settings > Playback > Volume leveling. Saves at once, restores the
  old choice if the save fails, and caches the value for the player.
- Android: Settings card. The device keeps a copy for offline playback
  (Room v9 with an explicit migration, so the upgrade wipes nothing).
  Writes are offline-first: shown at once, PUT best effort, queued on
  failure (NORMALIZATION_SET, collapsed to the newest). A refresh never
  overwrites a change still queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:29:40 -04:00
bvandeusen 1e9aed214b Merge pull request 'M462 security hardening and M464 loudness analysis (steps 1–3)' (#135) from dev into main
release / go (push) Successful in 2m19s
release / web (push) Successful in 1m43s
release / govulncheck (push) Successful in 35s
release / Build signed APK (releases and dev) (push) Skipped
release / android (push) Successful in 6m10s
release / integration (push) Successful in 19m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m24s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-06 14:19:58 -04:00
bvandeusenandClaude Opus 5.5 e3aa8629d3 feat(api): deliver loudness gains to every client (M464 #4997)
release / web (push) Successful in 1m44s
release / go (push) Successful in 2m12s
release / govulncheck (push) Successful in 40s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 4m42s
release / integration (push) Successful in 15m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m21s
release / Verify release artifacts (tag releases only) (push) Skipped
ReplayGain 2.0 values (gain to -18 LUFS, linear peak) derived from the
stored track and album loudness:

- Web: GET /api/tracks/replay-gain?ids=... (up to 200), a lookup the
  player calls for its queue, rather than a field on every TrackRef
  surface.
- Android: track_gain/track_peak and album_gain/album_peak on the sync
  views, so cached tracks level offline. Storing a measurement logs a
  track change, and an album's values moving logs an album change, both
  before the write (#2704), so caches pick the gains up.
- OpenSubsonic: replayGain on every song (album, getSong, search3,
  starred), as a JSON object and an XML element.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:54:05 -04:00
bvandeusenandClaude Opus 5.5 c2f81bf8df feat(library): album loudness from the tracks' summed block histograms (M464 #4996)
release / go (push) Successful in 1m47s
release / govulncheck (push) Successful in 27s
release / web (push) Successful in 1m27s
release / integration (push) Successful in 4m51s
release / android (push) Successful in 6m23s
release / Build signed APK (releases and dev) (push) Successful in 6m25s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m30s
release / Verify release artifacts (tag releases only) (push) Skipped
Album-mode normalization plays a whole album at one gain. That gain comes
from album_loudness (migration 0066): BS.1770's gated loudness over every
block on the album, computed by summing the tracks' stored histograms and
gating the sum. No audio is decoded again. Album true peak is the loudest
track's.

- Recomputed by the loudness worker each tick, after the track pass and
  whether or not analysis is switched on. ListAlbumsNeedingLoudness lists
  albums whose md5 over (present track id, measurement version and time) no
  longer matches the stored digest. One comparison covers every way
  membership changes (scan retag, duplicate merge, delete, missing and
  restored) without hooking each.
- No album value until every present track has a settled measurement, so
  an album's gain doesn't shift mid-listen as the rest is measured. Silent
  and unreadable tracks count as settled.
- Rows for albums with no present track left are dropped.
- The parser and the merge share trimBins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:27:02 -04:00
bvandeusenandClaude Opus 5.5 aee4b50bd4 test(api): pass the loudness settings to Mount in the library test router (M464 #4995)
release / go (push) Successful in 2m21s
release / web (push) Successful in 2m7s
release / govulncheck (push) Successful in 19s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 6m24s
release / Build signed APK (releases and dev) (push) Successful in 6m37s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m24s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:06:44 -04:00
bvandeusenandClaude Opus 5.5 f3196b3443 feat(library): measure every track's loudness in the background (M464 #4995)
release / go (push) Failing after 1m18s
release / govulncheck (push) Successful in 35s
release / web (push) Successful in 1m27s
release / android (push) Canceled after 5m47s
release / Build signed APK (releases and dev) (push) Canceled after 4m21s
release / integration (push) Failing after 4m13s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
The first step of loudness normalization: the server measures each track
with ffmpeg's EBU R128 filter (true peak, mono as dual mono) and stores the
integrated loudness, true peak and loudness range in track_loudness
(migration 0065).

It also keeps a histogram of the 400 ms gating blocks at 0.1 LU, so album
loudness can be computed exactly later with no second decode (#4996). The
histogram reproduces ffmpeg's own figure (-10.68 against -10.7 on the
captured fixture), and the analyzer logs a warning if the two ever drift.

- A background worker, cloned from the fingerprint backfill, measures every
  track, new ones included. Measuring inline in the scan was dropped: the
  analysis decodes the whole file, and a large import could pass the scan's
  one-hour stuck threshold. The scan only deletes a changed file's
  measurement; the worker ticks every 10 minutes.
- Timeouts, the cancel/missing-binary split and settled verdicts follow the
  fingerprint runner. Silence and undecodable files are stored as verdicts;
  stalls are retried. The deadline scales with track length.
- loudness_settings (enabled, files at once) and an admin card with the
  coverage gauge, under GET/PUT /api/admin/library/loudness-settings and
  GET /api/admin/library/loudness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:01:01 -04:00
bvandeusenandClaude Opus 5.5 edd9a3a6db fix(auth): the Subsonic password is generated, never the login password (M462 #5026)
release / govulncheck (push) Successful in 39s
release / web (push) Successful in 1m8s
release / go (push) Successful in 1m30s
release / integration (push) Successful in 4m37s
release / android (push) Successful in 5m56s
release / Build signed APK (releases and dev) (push) Successful in 5m55s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
`minstrel admin reset-password` copied the new login password into
subsonic_password, which is stored in plain text because Subsonic t/s
sign-in needs it. Every account recovered through the CLI had its login
password readable in the database, and changing the password later left
the copy behind.

- reset-password now changes only password_hash.
- Migration 0064 clears every subsonic_password, removing the copies.
- Settings gets a Subsonic password card: the server generates a random
  password, shows it once, and it can be regenerated or turned off
  (GET/POST/DELETE /api/me/subsonic-password, audited). Generated rather
  than user-chosen so it can never be a reused password.
- docs/security.md describes the separate password instead of the known
  issue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:54:30 -04:00
bvandeusenandClaude Opus 5.5 522503e011 docs: hosting guide and security notes; README setup and HTTPS guidance (M462 #4986)
- docs/hosting.md: LAN vs internet; binding 4533 to 127.0.0.1 behind an
  HTTPS proxy (Caddy example, no buffering, long read timeouts for SSE and
  streams); the Client IP detection hop count (default 1, so 0 with no
  proxy or clients can forge X-Forwarded-For); the public address that
  password-reset links need; finding the setup token.
- docs/security.md: sessions, API keys, rate limits, headers and CSP; why
  CSRF rests on SameSite=Strict plus JSON-only cookie writes; the Subsonic
  password column, including the known issue that admin reset-password
  writes the login password there (#5026); why Android allows plain HTTP;
  the CI publish gate.
- README: keeps the LAN-first port mapping with a pointer for internet
  hosts, scopes "plain http:// is fine" to trusted networks, explains the
  setup token in first-run step 1, and links both docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:37:32 -04:00
bvandeusenandClaude Opus 5.5 6de8d4136d feat(android): keep the session cookie in Keystore-encrypted storage (M462 #4985)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m18s
release / go (push) Successful in 1m40s
release / integration (push) Successful in 4m29s
release / android (push) Successful in 5m17s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
The session cookie is a bearer credential, and it sat in plain text in the
Room auth_session row. It now lives in a SessionVault: AES-256-GCM under a
key held in the Android Keystore, with only the ciphertext in a private
prefs file. A copy of the app's files no longer yields a usable session.
Platform APIs only, no new dependency (androidx.security-crypto is
deprecated).

Nobody is signed out by the upgrade. On first launch AuthStore moves a
cookie still in the row into the vault and clears the column. If the
Keystore can't be used on a device, the cookie stays in the row as before
rather than being lost. A sign-in or 401 that lands during the move wins
over the value it read, and the move never throws. The auth gate now
waits for this before choosing Login or Home, with a 10s deadline so a
wedged Keystore can't leave the start screen spinning.

Tests: AuthStoreSessionVaultTest (upgrade move, vault-only load, Keystore
fallback, sign-in/out, hydration race) and SealedBoxTest (round trip,
fresh IV, tamper and wrong-key rejection). The real Keystore path needs
a device; the first launch after updating is that check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:29:32 -04:00
bvandeusenandClaude Opus 5.5 60c87da38e ci(govulncheck): check out with plain git; the golang image has no node (M462 #4984)
release / go (push) Successful in 2m13s
release / web (push) Successful in 1m40s
release / govulncheck (push) Successful in 41s
release / integration (push) Successful in 4m54s
release / android (push) Successful in 5m32s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m15s
release / Verify release artifacts (tag releases only) (push) Skipped
actions/checkout runs on node, which golang:1.26-bookworm does not carry,
so the lane died at checkout (run 8272, exit 127) before scanning
anything. That run was also the gate's first red: every other lane
passed, and image-release and release-assets both skipped, leaving :dev
on the previous build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:00:24 -04:00
bvandeusenandClaude Opus 5.5 b36125fa67 ci: one workflow graph, so nothing publishes on red; add govulncheck and npm audit (M462 #4984)
release / govulncheck (push) Failing after 2s
release / go (push) Successful in 1m49s
release / web (push) Successful in 1m8s
release / integration (push) Successful in 4m39s
release / android (push) Successful in 5m45s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
test-go, test-web and android were separate workflows on the same push as
release.yml, so the image build could not see their verdict: :dev meant
"it built", never "it passed". All lanes now live in release.yml, and
both publishing jobs (image-release and the new release-assets) need
every lane and require `result == 'success'` from each by name, so a
skipped lane blocks the publish just as a failed one does (rule 177).

- New lanes: govulncheck (in golang:1.26-bookworm, the builder's image,
  so it checks the stdlib that ships) and `npm audit --omit=dev` in web.
- Attaching the APK to a Release moved out of android-release into
  release-assets, behind the gate; the APK still builds in parallel.
- `docker buildx build --pull`, so floating base tags can't serve a
  stale Go patch release from the runner's cache.
- Integration wait uses `pg_isready` via docker exec: the old /dev/tcp
  probe never connects under dash (rule 81) and burned two minutes a run.
- workflow_dispatch input force_red fails the go lane on purpose, to
  watch the gate refuse.
- release_gate_test.go pins the gate: every job must be classified, and
  every publisher must need and require success from every lane.

Lanes have no path filters any more; a web-only push runs the Go suite
too, because "not run" must never read as "passed".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:51:37 -04:00
bvandeusenandClaude Opus 5.5 3217e10168 fix(deps): clear known vulnerabilities in what ships; build on the Go line CI tests (M462 #4984)
- golang.org/x/text v0.37.0 -> v0.39.0 (GO-2026-5970, infinite loop on
  invalid input, reachable from pgxpool). x/sync follows to v0.21.0.
- web lockfile: in-range updates from `npm audit fix` for devalue (high)
  and svelte (moderate), both of which ship in the browser bundle.
  package.json is unchanged.
- Dockerfile builder golang:1.25 -> golang:1.26. CI has tested on 1.26
  since the ci-go migration while the image was still compiled with 1.25,
  left over from the April skeleton; the shipped binary now uses the
  toolchain the tests ran on. govulncheck under golang:1.26-bookworm
  (go1.26.8) reports 0 vulnerabilities reachable from our code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:51:37 -04:00
bvandeusenandClaude Opus 5.5 327d49428f feat(auth): store Subsonic API keys hashed; a new key is shown once (M462 #4983)
test-web / test (push) Successful in 2m4s
test-go / test (push) Successful in 2m23s
test-go / integration (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 6m29s
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
users.api_token held each user's apiKey in plaintext and was looked up by
equality, so a leaked row or backup handed out working keys. Migration
0063 replaces it with api_token_hash (sha256, hex), computed in place
from the existing keys so every Subsonic client keeps working.

The key can no longer be read back: GET /api/me/api-token is gone, and
POST returns the new key once. Settings shows it right after Regenerate
with a copy button and a "won't be shown again" note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:42:35 -04:00
bvandeusenandClaude Opus 5.5 2f3fbccab6 feat(auth): first account on a new server needs the setup token from the server log (M462 #4982)
test-go / test (push) Successful in 2m3s
test-web / test (push) Successful in 1m14s
test-go / integration (push) Successful in 4m38s
release / Build signed APK (releases and dev) (push) Successful in 5m36s
release / Build + push container image (push) Successful in 1m24s
release / Verify release artifacts (tag releases only) (push) Skipped
While no accounts exist, the server mints a random setup token at boot and
logs it. Registering the first account (which becomes admin) must carry it,
so whoever reaches a freshly exposed instance first cannot claim it. The
register page asks GET /api/auth/setup-status and shows a "Setup token"
field in place of the invite field while setup is pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:35:52 -04:00
bvandeusenandClaude Opus 5.5 46194a609d fix(auth): build password-reset links from an operator-set public address, never the Host header (M462 #4981)
test-go / test (push) Successful in 1m29s
test-web / test (push) Successful in 1m37s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
test-go / integration (push) Canceled after 2m45s
release / Build signed APK (releases and dev) (push) Canceled after 3m40s
buildResetURL used r.Host and r.TLS, so a forgot-password request with a
forged Host emailed the victim a real reset token on a link to the
attacker's server. Links now come only from network_settings.public_url
(migration 0062), and no reset email is sent while it is empty; the response
stays the same opaque 200 and the log says why.

The address is set on a new "Public address" card under Admin → Integrations,
which offers the page's own origin and warns while unset. PUT
/api/admin/network-settings takes either field alone, so the proxy card and
this one can't overwrite each other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:32:10 -04:00
bvandeusenandClaude Opus 5.5 d411693bb2 feat(server): security headers and a hash-based CSP for the web app (M462 #4980)
test-web / test (push) Successful in 55s
test-go / test (push) Successful in 1m14s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 2m50s
test-go / integration (push) Canceled after 2m50s
There were no security headers at all. Now:
- every response: nosniff, Referrer-Policy strict-origin-when-cross-origin,
  Permissions-Policy (no camera/mic/geolocation), X-Frame-Options DENY;
  each set only if the handler hasn't.
- HSTS only when the trusted proxy reports HTTPS (rule 94); never a redirect.
- index.html carries a Content-Security-Policy whose script-src is 'self'
  plus the sha256 of each inline script in the page as served, computed
  after the branding template runs. No 'unsafe-inline' or 'unsafe-eval'
  for scripts. img-src admits remote https/http because Lidarr suggestion
  art is a remote poster URL.

Hashing in Go rather than via SvelteKit's kit.csp covers the inline scripts
SvelteKit doesn't know about (app.html's theme bootstrap and the branding
global injected at build) and stays correct whatever the app name is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:29:26 -04:00
bvandeusenandClaude Opus 5.5 24b767c23b feat(server): cap request bodies, bound body reads, private cache headers, JSON-only cookie writes (M462 #4979)
test-go / test (push) Successful in 1m48s
test-web / test (push) Successful in 2m0s
android / Build + lint + test (push) Successful in 6m27s
release / Build signed APK (releases and dev) (push) Successful in 6m42s
test-go / integration (push) Successful in 4m55s
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
- Every request body is capped at 4 MiB and must arrive within 30s. The
  deadline is set per request and cleared at end of body rather than via
  http.Server.ReadTimeout, which would cancel audio streams and the SSE
  stream once the background read hit it.
- IdleTimeout 120s closes idle keep-alive connections. Still no global
  WriteTimeout, for the same streaming reason.
- Streams, album covers and playlist covers are Cache-Control: private, so
  a shared cache never keeps an authenticated response for others.
- A cookie-authenticated write to /api must be application/json (415
  otherwise). SameSite=Strict can't see a sibling app on the same
  registrable domain; forms and no-preflight fetches can't send JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:34:26 -04:00
bvandeusenandClaude Opus 5.5 755f997b0d feat(auth): sessions expire server-side; password change and reset end other sessions (M462 #4978)
Sessions had no server-side expiry: only the web cookie's 30-day Max-Age
limited them, and a bearer token (Android) lived until revoked by hand.
GetSessionByTokenHash and ListSessionsForUser now ignore sessions idle for
30 days or older than a year, and the GC worker deletes them hourly.

A password change was a plain UPDATE, so a session opened with the old
password survived it. Now:
- self-service change signs out every other device and keeps this one;
- reset by email ends every session the account has;
- an admin reset ends the target's sessions (keeping the admin's own when
  they reset themselves).

The success copy on web and Android says the other devices were signed out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:32:39 -04:00
bvandeusenandClaude Opus 5.5 719dc62b0d fix(auth): set the session cookie's Secure flag behind a TLS-terminating proxy (M462 #4977)
The cookie set Secure from r.TLS, which is nil whenever TLS terminates at
Traefik or Cloudflare, so a public deployment's session cookie went out
without Secure. auth.IsHTTPS now decides it from X-Forwarded-Proto, believed
only through the trusted-hop count (rule 94) and read positionally like
X-Forwarded-For. Plain-HTTP and LAN logins are unchanged; nothing redirects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:30:46 -04:00
bvandeusenandClaude Opus 5.5 3bfddd0862 feat(auth): throttle login, register, password reset and Subsonic auth failures (M462 #4976)
test-go / test (push) Successful in 1m54s
test-web / test (push) Successful in 1m34s
test-go / integration (push) Successful in 4m56s
android / Build + lint + test (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m52s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
Every password-shaped check was mounted bare, so guessing was limited only
by bcrypt cost. A shared in-memory AttemptLimiter now sits in front of them:

- login: 10 failures per account and 50 per address per 15 min, checked
  before the user lookup and bcrypt; 429 with Retry-After. A success clears
  the account's count but not the address's.
- unknown usernames run a dummy bcrypt compare, so timing no longer says
  which accounts exist.
- register: 10 per address per hour; forgot-password: 5 per address and 3
  per email per hour (applied whether or not the email matches); reset: 20
  failed tokens per address per 15 min.
- Subsonic /rest: same limits as login, counting only wrong credentials,
  since clients authenticate on every request.

Web login, register, reset and forgot-password screens say how long to
wait; web and Android carry copy for the rate_limited code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:28:29 -04:00
bvandeusen 3638d1d822 Merge pull request 'M400: acoustic duplicate detection, history-preserving merge, and fingerprinting settings' (#134) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / Build + push container image (push) Successful in 1m32s
release / Verify release artifacts (tag releases only) (push) Skipped
test-go / test (push) Successful in 1m46s
test-go / integration (push) Successful in 4m19s
test-web / test (push) Successful in 33s
2026-09-11 20:56:55 -04:00
bvandeusenandClaude Opus 5 516413f4ca fix(admin): re-acquisition settings take effect without a restart, and say why a save was refused (#3936, #3937)
test-web / test (push) Successful in 1m9s
test-go / test (push) Successful in 1m28s
test-go / integration (push) Successful in 3m57s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
#3936: Router() built a reacquisition.SettingsService of its own, so a save
from the admin card refreshed that instance's cache while the sweeper in
main.go kept serving what it loaded at boot. The card showed the new
policy, the feature ran the old one, and only a restart reconciled them.
main.go now hands its instance to the server (srv.ReacqSettings), as it
already did for RecSettings, TagSettings and FingerprintSettings, and
Router() constructs one only when that field is nil. The regression test
saves through the router and reads the sweeper's instance.

#3937: the card's catch tested `e instanceof Error`, but api.put throws a
plain {code, message, status} object, so every reason the server gave was
discarded in favour of "Couldn't save settings." It now uses errMessage,
which appends the server's message for invalid_setting. Its test rejected
with an Error no code path produces, so it passed throughout; it now
rejects with what the client actually throws.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 20:15:35 -04:00
bvandeusenandClaude Opus 5 37d4906033 test(api): pass fingerprint settings to Mount in the route-registration test (M400 #3913)
test-go / test (push) Successful in 1m0s
test-go / integration (push) Successful in 3m25s
release / Build signed APK (releases and dev) (push) Successful in 4m35s
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
The unprefixed Mount call in library_test.go was missed when #3913 added
the parameter, failing go vet. Also pins the fingerprint coverage,
fingerprint settings and duplicates routes as admin-gated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 17:58:05 -04:00
bvandeusenandClaude Opus 5 077ae61235 feat(admin): fingerprinting settings — on/off, length, match threshold, concurrency, sweep interval (M400 #3913)
test-go / test (push) Failing after 44s
test-web / test (push) Successful in 49s
test-go / integration (push) Failing after 2m42s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 4m8s
Rule 25: the fingerprinting knobs move out of source into a DB-backed
singleton (migration 0061), edited from a card on the Duplicates page and
shared live with the scanner, the backfill and the duplicate sweep through
one service instance, so a save needs no restart.

The length is the knob that can silently break the library: prints taken
at two lengths never match. Each track_fingerprints row now records the
length it was taken at, and every reader filters on the current one — the
backfill treats another length as stale, the gauge counts it pending, the
sweep never streams it. Equivalent to a version bump, except that setting
the length back makes rows not yet redone current again. The card warns
before a length change re-fingerprints the library.

Off stops every decode: the scan takes only the stream hash (a demux, and
what recognises a moved file) and stores nothing, dropping a changed file's
stale row; the backfill idles. A save also makes a sweep due, since a new
threshold or length changes what the same prints group into, and the sweep
interval gains slack so an hourly interval on an hourly tick doesn't skip
every other tick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 17:53:55 -04:00
bvandeusenandClaude Opus 5 c8bf9dc929 refactor(library): move detection matches on the audio hash, not size and duration (M400 #3914)
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m45s
release / Build signed APK (releases and dev) (push) Successful in 4m52s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
A file that comes back renamed or moved keeps its track row, and with
it its likes and play history, by being matched to the missing row it
replaces (#2528). Untagged files were matched on (file_size,
duration_ms), which was never a fingerprint. It could pair two
unrelated files that happened to share a byte count and a duration,
and it missed a file retagged in place, whose size changes. The only
defence was requiring a unique match and otherwise giving up.

Now there is a real identity. FindMissingTrackByAudioHash matches a
missing track by the SHA-256 of its encoded audio (track_fingerprints,
#3906). That survives a rename, a move and a retag, and only an
identical recording can match it. adoptMovedTrack takes the new file's
hash, which the scan already computes before adoption. The size and
duration query and fallback are removed outright, with no second path
(rule 22).

Unchanged:
- MBID first: it identifies the recording and survives a re-encode
  that even the hash does not
- a unique match is still required
- an absent hash is never looked up, so unhashable files cannot pair
  with each other

The test fake answers the hash lookup only for the hash it holds, so
the tests can tell adoption by identity apart from adoption by
coincidence. That includes the case the old pair got wrong: different
audio of equal size and duration is not adopted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 17:31:42 -04:00
bvandeusenandClaude Opus 5 11ef044ef6 feat(library): merge duplicates without losing history (M400 #3911)
test-web / test (push) Successful in 57s
test-go / test (push) Successful in 1m16s
test-go / integration (push) Successful in 3m39s
release / Build signed APK (releases and dev) (push) Successful in 4m46s
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
Merge keeps one copy of a duplicate group and removes the rest. Every
table that references tracks does so ON DELETE CASCADE, so deleting a
duplicate's row outright would silently destroy its likes, plays,
playlist entries and tags. The merge moves all of that onto the kept
copy first, then deletes the empty row.

In one transaction, holding a lock on the group:
- repoints play_events, skip_events, contextual_likes, playback_errors,
  lidarr_requests.matched_track_id and playlist_tracks. The last is
  keyed by position, so every entry stays where it was.
- merges general_likes one per user, dated to the earlier like
- takes the union of track_tags, keeping the kept copy's own weight on
  a shared tag
- rewrites track_similarity onto the kept copy, dropping edges that
  would point a track at itself and keeping the kept copy's existing
  edge on a collision
- lets the kept copy take a recording MBID only the removed copy had
- deletes the removed copies' rows, tidies emptied albums and artists,
  marks the group merged
- logs sync changes: track deletes, and like and playlist-track
  delete/upsert pairs

The removed copies' files are deleted first, before any row changes,
through the same helper as DeleteTrackFile (now shared, along with the
album tidy-up). A merge that left the file behind would be undone by
the next scan re-importing it. An unwritable library answers 409
library_not_writable and nothing changes.

tracks.Service.MergeDuplicates wraps it with the opt-in Lidarr unmonitor
from RemoveTrack, skipped when the removed copy is a second file of the
kept copy's own album track: unmonitoring that would stop Lidarr
managing the kept file. It writes a duplicate_merge audit row after
commit, per the audit package's best-effort contract, naming both
paths.

POST /api/admin/library/duplicates/{id}/merge takes an optional
survivor_track_id (the report's proposal otherwise) and unmonitor.

On the report page:
- each copy gets a Keep choice, defaulting to the proposed one
- Merge needs a second click, on a button that says how many files it
  removes, with the consequence stated beside an opt-in Lidarr checkbox

Integration tests cover:
- every piece of history landing on the kept copy exactly: likes
  deduped at the earlier time, plays and skips counted, playlist
  position unchanged, tags unioned, similarity rewritten with no
  duplicate or self-edge, MBID inherited
- the removed file gone, and a second merge refused
- an unwritable file leaving likes, plays, row and group untouched
- a survivor outside the group refused

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 17:25:06 -04:00
bvandeusenandClaude Opus 5 ff493a8c7d feat(admin): the duplicates report — review proposed duplicate groups (M400 #3912)
test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m31s
release / Build signed APK (releases and dev) (push) Successful in 4m32s
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
A new admin tab, Duplicates, beside Missing files: the proposals from
the duplicate sweep, with a Sweep now trigger and a Not duplicates
dismissal. Nothing on it merges or deletes; the merge is #3911.

Each group shows:
- whether it is identical audio or the same recording, with a match
  percentage from the weakest link between members
- every copy's format, size, duration, path, and the likes and plays
  it carries (every user's; this is admin-only, and it is what decides
  which copy to keep)
- the copy proposed to keep, and the rule that chose it

The survivor rule is library.ProposeSurvivor, a pure function the
merge will reuse: lossless over lossy, then the larger file, then the
copy in the library longest, then lowest id. Bitrate is not in it
because the scanner never fills tracks.bitrate, and for one recording
at one duration a larger file is the higher bitrate. m4a is not counted
as lossless: it may be AAC. The reason names the rule that separated
first place from second, not every rule the winner passed.

An empty report has three causes, and the page says which: still
fingerprinting, the sweep has never run, or it ran and found nothing.
The sweep's state and the backfill's progress come back with the groups
for that reason. Groups left with fewer than two members since the
sweep are not shown.

GET /api/admin/library/duplicates, POST .../sweep (202, or 409
sweep_in_progress), POST .../{id}/dismiss (404
duplicate_group_not_pending when already resolved).

Migration 0060 indexes play_events by track_id. Its only indexes led
with user_id, so each copy's play count, and the merge's repointing of
play history, would scan the whole table.

Web only, like Missing files: Android has no library-health admin
screens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 17:11:11 -04:00
bvandeusenandClaude Opus 5 6379b6c31d feat(library): the duplicate sweep — propose duplicate groups from fingerprints (M400 #3910)
test-go / test (push) Successful in 1m0s
test-go / integration (push) Successful in 3m17s
release / Build signed APK (releases and dev) (push) Successful in 4m51s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
Reads fingerprints, runs them through the matcher, and records
proposals in duplicate_groups (migration 0059). Nothing is merged or
deleted: a group is a proposal for the admin report (#3912).

Streaming. The whole library's fingerprints are hundreds of megabytes,
but tracks are only compared within 3s of each other in duration. So
candidates stream in (duration_ms, id) order, keyset-paged on a new
tracks(duration_ms, id) index. The grouper holds only the tracks within
3s of the oldest one not yet settled. A seed is settled once a track
arrives beyond its window, which gives the same result as grouping the
whole sorted list. groupDuplicates is rebuilt on the same streamGrouper,
so there is one grouping rule and the #3909 tests still cover it. Each
fingerprint's alignment index and variety check are computed once
instead of for every pair.

Exact duplicates are grouped library-wide in SQL. The first member the
stream meets stands in for the whole group in the acoustic pass. An
exact group caught in an oversize acoustic cluster is still proposed:
the acoustic evidence is discarded, identical bytes are not.

Re-sweeping:
- a group is identified by its sorted member ids, so finding it again
  refreshes the row in place
- a proposal whose members all sat in one dismissed group is not
  proposed again (a subset repeats the verdict; a superset is new
  evidence)
- a pending proposal no sweep has found again is retired, but only
  after a complete sweep, and only if an earlier sweep last confirmed
  it, so two overlapping sweeps cannot delete each other's findings
- dismissals are kept

DuplicateSweepWorker checks hourly and sweeps only when a fingerprint
was written after the last sweep started. TryStartDuplicateSweep guards
against two sweeps at once and reaps one stuck in flight for 2h. The
sweep row is closed on a detached context with a deadline, so a sweep
cancelled at shutdown still records that it ended.

The integration test pages one row at a time and checks:
- an acoustic pair and an exact pair are found
- a track with no fingerprint, a missing track and a near-duration
  unrelated song are left out
- a dismissed group is suppressed while the pending one refreshes
  without duplicating
- a proposal that stops holding is retired and the dismissal survives

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 17:00:07 -04:00
bvandeusenandClaude Opus 5 c06af48cd6 feat(library): the duplicate matcher — a pure comparison over fingerprints (M400 #3909)
test-go / test (push) Successful in 1m2s
test-go / integration (push) Successful in 3m24s
release / Build signed APK (releases and dev) (push) Successful in 5m8s
release / Build + push container image (push) Successful in 1m15s
release / Verify release artifacts (tag releases only) (push) Skipped
Decides whether tracks are proposed as one recording. No database, no
files, so every rule is falsifiable in a unit test.

Two tiers:
- exact: equal audio_stream_sha256 (identical encoded audio bytes). No
  threshold and no false positives.
- acoustic: chromaprint fingerprints that agree once aligned. Two
  fingerprints can start at slightly different points in the audio
  (padding trimmed differently), so offsets within ±120 items (~15s)
  are voted on using items that share their high 14 bits. Bit-error
  rate is then measured over the overlap at the winning offset. The
  approach and both constants follow AcoustID's pg_acoustid; it was
  reimplemented from that description and no code was copied.

No verdict below ~10s of overlap, or for low-information fingerprints
(silence, a sustained tone). Two such tracks agree without being one
recording.

Grouping uses complete linkage: a track joins a group only if it
matches every member. Otherwise A close to B and B close to C would
merge A and C, which are not close, and it means any member can be the
survivor. Other rules:
- durations must be within 3s
- acoustic groups are capped at 8, and larger clusters are reported
  and discarded as a likely shared jingle
- an exact group absorbed into an acoustic one takes the acoustic tier
- output does not depend on input order

The acoustic threshold is 0.15 bit-error rate: deliberately
conservative, since the operator's concern is different recordings of
one song being merged, and an instrumental shares its vocal's harmony.
It is unmeasured, and needs calibrating against real pairs once the
backfill has populated fingerprints (#3913 exposes it).

Nothing calls this yet; the sweep (#3910) does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 16:48:06 -04:00
bvandeusen 18618bd135 Backfill fingerprints for the existing library (M400 #3908) (#133)
release / Build signed APK (releases and dev) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
test-web / test (push) Successful in 49s
test-go / test (push) Successful in 1m11s
test-go / integration (push) Successful in 3m16s
2026-09-11 15:34:19 -04:00
bvandeusenandClaude Opus 5 21c698a616 test(web): give the admin page mock the fingerprint coverage query
test-web / test (push) Successful in 35s
release / Build signed APK (releases and dev) (push) Successful in 4m25s
release / Build + push container image (push) Successful in 34s
release / Verify release artifacts (tag releases only) (push) Skipped
b8855b48 made the admin overview page create a fingerprint coverage
query, but admin.test.ts mocks $lib/api/admin with an explicit factory
that only returned the cover coverage query. Every test that renders
the page threw on the missing export (run 6512, 11 failures). The mock
now returns it in the same empty-store shape, so the gauge stays hidden
in these tests the way the cover gauge does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 15:00:38 -04:00
bvandeusenandClaude Opus 5 b8855b480f feat(library): backfill fingerprints for the existing library — M400 #3908
test-web / test (push) Failing after 50s
test-go / test (push) Successful in 1m7s
test-go / integration (push) Successful in 3m27s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 4m23s
The scan fingerprints only bytes it has not seen, so everything imported
before fingerprinting existed, and any row derived by an older
fingerprintVersion, needs a pass of its own.

That pass is a background worker, not a stage in RunScan. RunScan runs
at boot and then every 12h, and an in-flight scan older than an hour is
reaped and a second started beside it. A stage would have to stop inside
the hour: a few hundred decodes a run, so about a month for a 50k-track
library. It would also hold the run in flight and answer manual
rescans with 409 while it worked.

FingerprintBackfillWorker runs once at start, then hourly. Nothing a
pass does (error or panic) can stop the next tick. A pass walks tracks
with no fingerprint or a stale version, skipping missing tracks,
keyset-paged on id. The cursor is what lets a pass end: an inconclusive
attempt writes no row, so a file that keeps timing out would otherwise
be re-listed and retried forever. Two decodes at a time, deliberately:
they compete with transcoding for CPU and with streaming for the mount.

storeFingerprint is now one package function shared by the scan and
the worker, and reports whether the attempt was fingerprinted,
rejected, inconclusive or failed to store.

Progress is a live gauge on the Admin scan card, served by
GET /api/admin/library/fingerprints: fingerprinted / rejected / pending
of total, with missing tracks excluded so it can reach the end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 14:56:18 -04:00
bvandeusen d2985f3841 Acoustic fingerprints at ingest, a delete that can't lose history, and a reproducible candidate draw (#132)
release / Build signed APK (releases and dev) (push) Skipped
release / Build + push container image (push) Successful in 20s
release / Verify release artifacts (tag releases only) (push) Skipped
test-web / test (push) Successful in 1m0s
test-go / test (push) Successful in 1m20s
test-go / integration (push) Successful in 3m35s
android / Build + lint + test (push) Successful in 4m48s
2026-09-11 14:43:00 -04:00
bvandeusenandClaude Opus 5 71d4335584 docs(readme): the music mount is writable — Minstrel deletes when asked
test-go / test (push) Successful in 1m8s
test-go / integration (push) Successful in 4m10s
release / Build signed APK (releases and dev) (push) Successful in 5m23s
release / Build + push container image (push) Successful in 1m16s
release / Verify release artifacts (tag releases only) (push) Skipped
The quickstart mounted the library :ro and promised "Minstrel never
writes to your library". That stopped being true long before #3918:
quarantine's Delete file removes files, and under :ro it failed. The
operator has accepted delete ownership (Scribe note #3926).

The quickstart now mounts it writable and says exactly what Minstrel
writes: it deletes a file when an admin asks, and never moves, renames
or retags. It notes that uid 1000 needs write access, and that :ro
still works, with deletes refusing and explaining why.

Reorganising and tag writes stay out, pending whether Minstrel absorbs
Lidarr's role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 14:34:28 -04:00
bvandeusenandClaude Opus 5 702b48ce36 fix(lidarrquarantine): pass dataDir at the four stub-client test constructors
d7a8e5f3 added a dataDir parameter to NewService and updated the 13
call sites spelled NewService(pool, lidarrconfig.New(pool), nil). Four
more build their client from a Lidarr stub, NewService(pool, cfg,
clientFn), and were missed, so the package's tests did not compile and
run 6495 failed both go vet and the integration build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 14:34:28 -04:00
bvandeusenandClaude Opus 5 d7a8e5f300 fix(library): a track delete that cannot remove its file deletes nothing — #3918
test-go / test (push) Failing after 55s
test-web / test (push) Successful in 56s
test-go / integration (push) Failing after 4m50s
android / Build + lint + test (push) Successful in 5m52s
release / Build signed APK (releases and dev) (push) Successful in 6m5s
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
Two delete paths had opposite failure policies. tracks.RemoveTrack
logged a failed os.Remove and deleted the row anyway, which CASCADEs
likes, plays, playlist memberships and tags, while the file survived
for the next scan to re-import as a stranger. library.DeleteTrackFile
stopped correctly but reported it as a bare 500 nobody could read.

One path now: library.DeleteTrackFile removes the file first and, on
anything but ErrNotExist, returns *FileRemoveError with nothing
deleted. Only then does it delete the row and tidy an emptied album
and artist in one transaction, log the sync change and clear orphaned
artist art. RemoveTrack calls it, which also fixes RemoveTrack never
logging a sync change. Quarantine Delete file now tidies emptied
albums and artists too.

Both endpoints answer an unwritable library (EROFS, EACCES, EPERM) with
409 library_not_writable. The message names the directory (removal
writes to the parent), the uid:gid the server runs as, and that
nothing was deleted. Other remove errors are 500 file_delete_failed
with the path.

The reachable surface is quarantine Delete file, which failed
silently: no copy for the code on either client, and Android swallowed
the exception so the row just reappeared. Web and Android now have
copy for both codes and append the server message for exactly those
two. Android's quarantine screen shows it in a snackbar.

DELETE /api/admin/tracks/{id} has had no client since f7278f24, which
kept it on purpose for a safer admin surface, so its history loss was
latent. Fixed rather than removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 14:23:01 -04:00
bvandeusenandClaude Opus 5 cba77a5187 feat(library): fingerprint every new or changed file — M400 #3905-#3907
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m28s
release / Build signed APK (releases and dev) (push) Successful in 4m38s
release / Build + push container image (push) Successful in 1m26s
release / Verify release artifacts (tag releases only) (push) Skipped
Two identities per track, because they answer different questions:

- audio_stream_sha256: SHA-256 of the ENCODED audio packets
  (ffmpeg -map 0:a -c:a copy -f hash). Equal means identical audio
  whatever the tags say. Measured against the #3885 pair: the two WWW
  files hash identically here and differently as whole files. Packets
  rather than decoded samples, so an ffmpeg upgrade cannot silently
  change every stored hash, and nothing is decoded.
- chromaprint: fpcalc -raw -signed. The same recording at another
  bitrate or codec, for the acoustic tier.

fpcalc ships in the image (libchromaprint-tools); shelled out because
CGO_ENABLED=0 rules out bindings.

Stored in a track_fingerprints table rather than on tracks: eight
queries read tracks with SELECT *, including album pages, search and
the Subsonic surface, and a ~4 KB array there would be de-TOASTed on
every one of them.

The scan fingerprints only bytes it has not seen (a new path, or mtime
past the row's). A tag-repair pass leaves fingerprints alone, and
unchanged files with no fingerprint are the backfill's job (#3908).
Folding that into the skip check would re-decode the whole library on
the first scan after upgrade and push a sync change per track.

A failure that says nothing about the file (timeout, cancelled scan,
tool not installed) is never stored, and on changed bytes it removes
the old row. A tool that rejects the file stores NULL at the current
version, so the backfill does not retry it every boot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 13:21:15 -04:00
bvandeusenandClaude Opus 5 eff3d88931 fix(recommendation): make the candidate draw reproducible, not accidentally so
test-go / test (push) Successful in 1m18s
test-go / integration (push) Successful in 4m52s
release / Build signed APK (releases and dev) (push) Successful in 6m9s
release / Build + push container image (push) Successful in 2m5s
release / Verify release artifacts (tag releases only) (push) Skipped
Four arms of the candidate query ended in a bare `ORDER BY random()` with no
seed: similar_artists, likes_overlap, coplay_artists and random_fill.

Such an arm returns a STABLE set only while its LIMIT exceeds the rows
eligible for it — at that point it returns all of them and the order stops
mattering, because scoreAndSortCandidates sorts by track id before drawing
jitter. Below that threshold it returns a random SUBSET, and two builds on
the same day draw different ones.

So daily determinism held BY ACCIDENT, and only for libraries smaller than
the limits. Any real library is larger, which means same-day rebuilds have
been producing different mixes since those arms were written — invisible,
because a mix that changes after a refresh looks like a feature rather than
a broken promise.

Found by breaking it: cutting RandomFill to 10 while tuning Songs-like
turned TestBuildSystemPlaylists_DailyNonceDeterminism red. That test seeds
~20 tracks against a default RandomFill of 30, so its determinism came from
the limit exceeding the library, not from the code being right. It is now a
real guard.

The arms order by md5(id || $12) instead. The CALLER decides what that
means, which is the point: system mixes pass a per-(user, day) seed and get
the determinism they promise, radio passes a fresh value per request and
keeps varying, which is what a radio should do. Same shape the browse
queries in this file already use (`md5(id::text || current_date::text)`) —
existing idiom, not a new one.

This also unblocks the trim that #3881 wanted and could not have. Shrinking
a randomly-ordered arm was what broke membership; a seeded one takes a
smaller but REPRODUCIBLE slice. Songs-like's seed-independent share drops
from 29% to 12%, which was the original intent before determinism forced it
back to 20%.

TestSongsLikeLimits_DoNotShrinkTheUnseededRandomArms is DELETED rather than
kept passing. It existed to stop anyone trimming those arms while the
ordering was broken; the ordering is fixed, so the constraint is gone and a
guard enforcing it would now forbid correct code.

Was filed as blocked on tooling. It was not: `make generate-go` runs sqlc as
a pinned Go tool and is the same path CI takes.

One thing worth knowing for next time: three files in internal/db/dbq are
owned by root, left by `make generate` running sqlc in Docker. sqlc errored
on the first it could not write. They are untouched by this change and the
regeneration of recommendation.sql.go completed, but `make generate` will
keep failing until they are chowned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 08:44:33 -04:00
bvandeusen f70df9f827 Recommendation relevance, the rollback unit, and a version that names what shipped (#131)
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m33s
test-go / integration (push) Successful in 4m25s
android / Build + lint + test (push) Successful in 5m33s
release / Build signed APK (releases and dev) (push) Successful in 4m38s
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-09-10 23:37:57 -04:00
bvandeusenandClaude Opus 5 4ce47397a9 fix(recommendation): a nil LibrarySize must degrade, not panic
test-go / test (push) Successful in 1m4s
test-go / integration (push) Successful in 3m41s
release / Build signed APK (releases and dev) (push) Successful in 4m37s
release / Build + push container image (push) Successful in 1m58s
release / Verify release artifacts (tag releases only) (push) Skipped
Fixes the integration failure from 72115484: a SIGSEGV inside handleRadio
took down TestHandleRadio_ColdStart_OnlySeedReturned.

    recommendation.(*LibrarySize).Get(0x0, ...)
      library_scale.go:146
    api.(*handlers).handleRadio(...)
      radio.go:95

internal/api builds its handlers struct directly in a dozen tests, none of
which know about every field, so librarySize arrives nil there. Get took
l.mu.Lock() straight off the nil receiver.

The shape of the bug is what matters more than the nil check. This value's
entire contract is that it degrades — an errored count keeps the last known
number, a never-counted cache returns 0, and 0 scales to the base limits,
i.e. today's behaviour. A pool-sizing HINT then turned a request into a
crash, which is the precise opposite of that.

A nil receiver is now VALID and means "no cache": the count still runs, it
is just not memoised. Correct-but-uncached rather than zero, so a wiring
miss in production would cost a query per request, not silently unscale
every pool — a performance bug is findable, a quietly-wrong pool is not.

Patching the test constructors was the alternative and is worse: a dozen
call sites, and the next test to build a handlers literal reintroduces it.

Guarded with the nil path exercised directly, including that it counts
again rather than memoising, and still returns 0 on a failed count. The old
shape fails it by panicking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 23:29:35 -04:00
bvandeusenandClaude Opus 5 721154847e fix(recommendation): size the candidate pool to the library
test-go / test (push) Successful in 1m5s
test-go / integration (push) Failing after 3m39s
release / Build signed APK (releases and dev) (push) Successful in 4m55s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 1m38s
Operator, 2026-09-10: "is the pool that we draw from somehow scaled to the
amount of music in the library... my earlier understanding of the tuning and
work may have been skewed by what was in my library."

It was not. DefaultCandidateSourceLimits returns what its own comment calls
"the v1 hardcoded constants per spec" — ~170 candidates for a 500-track
library and a 100,000-track one alike. The pool therefore samples a
shrinking FRACTION of a growing collection: 17% of 1,000 tracks, 1.7% of
10,000, 0.17% of 100,000. RandomFill, whose whole job is exploration,
becomes a thinner and noisier slice at exactly the moment a library gets
more diverse — which is the "starting to feel weird" being reported.

    1,000 tracks -> pool 170     (unchanged)
    5,000        -> pool 170     (unchanged)
   20,000        -> pool 280
   80,000        -> pool 500     (ceiling)

THE SCALING IS PER-ARM, and that is the substance rather than a refinement.
A limit only matters if there are rows for it to cut off, so what an arm is
BOUNDED BY decides whether library size can help it. LBSimilar,
SimilarArtist, TagOverlap and RandomFill grow: they are bounded by
similarity/tag data and by the library itself. LikesOverlap, UserCoplay and
TasteOverlap do not: they are bounded by the user's likes, the instance's
co-play graph and the taste profile, none of which grow when the library
does. Raising those would sample more of a set that did not change — churn,
not reach. It also keeps this from inflating the sim_score-0 share, since
TasteOverlap is one of the two zero-similarity arms.

sqrt, not linear: linear would put a 100,000-track library at a
3,400-candidate pool, long past where more candidates improve the answer.
A 4x ceiling bounds it at ~500.

Never shrinks an arm. The base limits are a floor, and #3889 makes that
load-bearing rather than tidy — shrinking an arm ordered by unseeded
random() changes pool membership between same-day rebuilds.

Library size comes from a TTL-cached count reusing CountTracksMatching with
an empty pattern (rule 28 — a new query would need sqlc regeneration, which
is blocked). The ILIKE defeats every index, so it is a full scan and must
not run per request. It degrades rather than fails: an error keeps the last
known value, a never-counted cache returns 0, and 0 scales to the base
limits — today's behaviour exactly. Nothing about sizing a pool justifies
failing the request it is sizing. Bounded by a 3s deadline (rule 156), and
a failed refresh does not stamp the clock, so a blip cannot pin a stale
value for the whole TTL.

THE REFERENCE IS ASSUMED, NOT MEASURED. libraryScaleReference = 5000 is
where growth starts, and the size the v1 constants were really tuned against
is unrecorded. #3879 should replace it; until then that constant is the one
thing to change. Deliberately conservative: below it nothing scales at all,
so no existing install changes behaviour.

Falsification caught a weak guard: the sqrt-vs-linear assertion was written
at SIXTEEN times the reference, where linear has already been clamped by the
ceiling and both curves land on 4x. It proved nothing. Moved to four times
the reference, below the ceiling for both, where sqrt gives 2x and linear
would give 4x.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 23:23:04 -04:00
bvandeusenandClaude Opus 5 633d4f591f fix(radio): cap any one artist's share of a radio session
test-go / test (push) Successful in 1m13s
test-go / integration (push) Successful in 3m53s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
Operator, 2026-09-10: started radio from a song and "literally all of the
songs in the playlist after that were from a single artist which was not
expected."

There was no per-artist cap anywhere in the radio path. radio.go built the
pool and handed it straight to Shuffle, which scores, sorts and takes the
top N — nothing between those steps bounded any artist's share, so a pool
dominated by one artist produced an output dominated by it. The asymmetry
was the tell: discover.go, you_might_like.go and home.go all cap; radio
never got one.

With the fixture that reproduces it — 20 liked tracks by one artist plus 10
by ten others — the old path returns 10 tracks from 1 artist. It now returns
10 from 8.

TWO PASSES, and that is the whole design. A hard cap was the easy mistake:
radio asks for 50 tracks by default and 200 at most, so capping at three per
artist over a concentrated pool would hand back a six-track "radio". Pass
one takes candidates that fit under the caps; pass two fills any remaining
slots from those it skipped, still in score order. The result always holds
min(limit, len(candidates)) — the caps change WHICH tracks are picked, never
HOW MANY. Rule 131's principle past the system mixes it was written for.

The caps SCALE with the requested length rather than being a constant.
Three-per-artist is a sensible 12% of a 25-track mix and an absurd 1.5% of a
200-track radio, where every selection would sit in the relaxation path and
the cap would be decorative. RadioDiversityCaps holds the system mixes'
proportion at any length: 3/2 at 25, 6/4 at 50, 24/16 at 200, with floors so
a very short radio is not capped down to one track per artist.

A BOUND, NOT AN EXCLUSION — the operator asked for the opposite of removal:
"again it should be able to add songs from the same artist." The dominant
artist still appears, just not exclusively. Guarded, because the tempting
wrong fix is the filter songs-like used to carry.

Shuffle grew the parameter rather than gaining a capped twin: radio is its
only production caller, so a second function would have left the original
dead (rule 22).

Falsified against each named regression: uncapped gives 10/10 to one artist;
a hard cap returns 3 of 10 on a single-artist pool; a cap-as-exclusion drops
the artist entirely; a fixed cap stays 3 where the scaled one reaches 24.

Caught while writing the guards: the artist-key constant was hand-written
hex and wrong — the fixture's artist UUID carries 0001 in its fourth group,
so the lookup missed and the assertion measured nothing. Derived from the
same construction the fixture uses now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 22:14:42 -04:00
bvandeusenandClaude Opus 5 f5dd4462de test(playlists): the same-artist guard needed a fixture that has same artists
test-go / test (push) Successful in 1m25s
test-go / integration (push) Successful in 4m46s
release / Build signed APK (releases and dev) (push) Successful in 5m45s
release / Build + push container image (push) Successful in 17s
release / Verify release artifacts (tag releases only) (push) Skipped
Fixes the integration failure from 31190657. The test was wrong, not the
code: it could not have passed whatever produceSeedMixes did.

seedActiveLibrary builds its tracks through seedTrack, whose own comment
says "artist and album are not deduplicated across calls (mbid-less
upsert)". So every track gets a fresh artist row despite sharing a name —
4 artists x 5 tracks is really 20 artists with one track each. A seed
artist's only track IS the seed, which is excluded from its own mix, so
"does this mix contain a track by its seed artist" was structurally
answerable only as no.

That is the failure mode worth naming: the assertion was measuring the
fixture, not the behaviour, and it reported the behaviour as broken.

seedSharedArtistLibrary upserts each artist ONCE and reuses the id across
its tracks, so a seed artist genuinely owns five others. Albums are still
not deduplicated, which suits this test — the per-album cap never binds, so
the per-artist cap (3) is unambiguously what is under test. Noted in the
fixture, because "tidying" the album titles into something shared would
silently change which cap the assertion measures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 21:25:49 -04:00
bvandeusenandClaude Opus 5 31190657d8 feat(recommendation): Songs-like can include the seed artist's own music
test-go / test (push) Successful in 1m16s
test-go / integration (push) Failing after 3m55s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Operator, 2026-09-10: "it should also be able to include music from the same
artist." Completes #3881 — the weights and pool landed in f367eeaa; this is
the eligibility half.

produceSeedMixes filtered the seed artist out entirely:

    // "Songs like X" excludes X's own songs.
    if !pgtypeUUIDEqual(c.Track.ArtistID, artistID) { ... }

That reads as obviously right and is not. The seed is a TRACK — the artist's
top-played one — and the tracks most likely to sound like it are usually the
rest of that artist's catalogue. The filter threw away the seed's nearest
neighbours, then reached FURTHER OUT to replace them. On the one surface
whose job is staying in a neighbourhood, that is backwards, and it worked
against the coherence tuning rather than with it.

Domination is bounded by the cap instead of by exclusion, which is the
distinction that makes this safe rather than a new problem:
capCandidatesByAlbumAndArtist already allows at most 3 tracks per artist in
a 25-track mix, so the seed artist gets 12% at most — a presence, not a
takeover. Without that bound this would just be the radio failure (#3882)
arriving on a different surface. The seed track itself still cannot appear;
it is passed to LoadCandidatesFromSimilarity as an exclusion.

Guarded end-to-end rather than by reading the source, for two reasons: the
check has to survive the filter returning in a different shape, and an
absence check would now match the comment that explains why the filter is
gone — rule 167's prose trap exactly. The test asserts both directions, that
at least one mix contains its seed artist and that none exceeds the cap.

Its falsification is by construction rather than by execution: under the
previous code every mix's own-artist count was necessarily zero, so the
assertion could not have passed. Running it needs Postgres, which is the
integration lane's job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 21:13:32 -04:00
bvandeusenandClaude Opus 5 ecfa056d4d fix(recommendation): don't shrink a candidate arm ordered by unseeded random()
test-go / test (push) Successful in 1m10s
test-go / integration (push) Successful in 3m35s
release / Build signed APK (releases and dev) (push) Successful in 5m6s
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Fixes the integration failure from f367eeaa:
"same-day rebuild produced different track lists".

Cutting RandomFill 30→10 for Songs-like broke
TestBuildSystemPlaylists_DailyNonceDeterminism, and the reason is worth
stating because the number is not the bug.

`likes_overlap` and `random_fill` end in a bare `ORDER BY random()` with no
daily seed (recommendation.sql:118, :161). Such an arm returns a STABLE set
only while its LIMIT exceeds the rows eligible for it — then it returns all
of them, and the random order stops mattering because scoreAndSortCandidates
sorts by track id before drawing jitter. Below that threshold the arm
returns a random SUBSET, and two builds on the same day draw different ones.

So the test was green by accident. It seeds ~20 tracks against a default
RandomFill of 30; the limit exceeded the library, so the arm returned
everything. Determinism held for a reason unrelated to the code being right.

Which means it does NOT hold in production. Any real library is larger than
30, so same-day rebuilds have been drawing different mixes since that arm
was written — invisible, because a mix changing after a refresh looks like a
feature. Filed as #3889; the fix is a seeded ordering per (user, day), which
needs a .sql change and sqlc regeneration and so cannot land from here.

The correction: grow an arm freely, never shrink one whose ordering is
unseeded random. LikesOverlap and RandomFill go back to the defaults;
TasteOverlap stays halved because it sorts by `tpa.weight DESC, t.id` and is
genuinely deterministic. Guarded by a test that names the reasoning, so the
next person to trim these has to read why first — and it should be DELETED
once #3889 lands rather than worked around.

The cost is honest: the seed-independent share of the Songs-like pool falls
from 29% to 20% instead of the intended cut. That matters less than it
sounds. The pool only biases the draw; the songs_like WEIGHTS are what
actually demote sim_score-0 candidates, and they are untouched here — a
perfect match still scores 5.00 against an unrelated favourite's 2.00.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 20:59:39 -04:00
bvandeusenandClaude Opus 5 f367eeaa9d fix(recommendation): Songs-like gets its own profile so it stops wandering
test-web / test (push) Successful in 1m7s
test-go / test (push) Successful in 1m31s
test-go / integration (push) Failing after 4m21s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
Operator, 2026-09-10: "when I play it I'm expecting to get a consistent
sound and style from the experience... I was getting a seeming wide variety
of music from each one when I was hoping to stay in a certain neighborhood."

Songs-like shared the `daily_mix` weight profile with For-You, and that
sharing WAS the bug. The two surfaces want opposite things: For-You answers
"what will they enjoy today" and is supposed to roam; Songs-like answers
"what sounds like THIS". Under one profile the broad answer wins.

The arithmetic, from the shared weights:

    unrelated track, liked, not played recently → 1.0 + 2.0 + 1.0 = 4.0
    PERFECT similarity match, not liked         → 1.0 + 1.5       = 2.5

Liking something outranked sounding like the seed, because LikeBoost (2.0)
exceeded SimilarityWeight's whole range (1.5) and TasteWeight (1.5, and
seed-INDEPENDENT) matched it outright. Under the new profile the same pair
scores 5.00 vs 2.00.

Two levers, because either alone leaves the other's failure intact:

POOL. Songs-like now takes its own CandidateSourceLimits. The default gave
~29% of candidates a sim_score of literally zero — `taste_overlap` and
`random_fill` are both `0.0::float8` in recommendation.sql, seed-independent
by construction. Same total pool size; composition shifts to arms that
measure distance from the seed, LBSimilar doubled.

WEIGHTS. A third profile beside radio and daily_mix, DB-backed and live per
rule 25, with the property that similarity's range exceeds the combined
range of every seed-independent differentiator — so a closer match cannot
be beaten on likes, freshness and taste alone, while tracks within ~0.39
similarity of each other still get ordered by what the user likes.

Rule 131 changed the pool design mid-way and for the better. Zeroing the
two seed-independent arms was the first instinct and is exactly the
vanish-or-nothing shape that rule forbids: a seed with thin ListenBrainz
coverage would yield a short mix or none. They are the tier-3 FLOOR — cut
hard, never removed — and the weights keep them at the bottom of the
ranking rather than out of the pool. "A few tracks further from the seed
than we'd like" beats "no playlist".

Caught while wiring it: switching only pickTopN's final Score would have
been nearly INERT. scoreAndSortCandidates does the selection sort, and the
caller caps and truncates in that order — so the playlist would still have
been chosen by daily_mix and merely relabelled with songs_like numbers. It
now takes the profile as a parameter, and each surface passes its own.

Also corrects the daily_mix card's blurb, which claimed Songs-like as one
of its surfaces and no longer is.

Guards pin behaviour rather than the numbers, since numbers get retuned:
that similarity beats an unrelated liked track, that daily_mix still
DOESN'T (or the split buys nothing), that the tier-3 floor is non-zero,
and that the UI card shows its own values rather than falling back. Each
falsified against its named regression first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 20:51:15 -04:00
bvandeusenandClaude Opus 5 270ad7a71b fix(ci): tests do not ship, so they must not re-version an artifact
test-go / test (push) Successful in 1m1s
test-go / integration (push) Successful in 3m17s
release / Build signed APK (releases and dev) (push) Successful in 4m41s
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Completes the pathspec. 17212e9e excluded CI, docs and tooling but left
tests in the shipped set, so its own commit re-versioned the image on the
strength of a _test.go file. `go build` drops *_test.go outright and the
Vite build never imports a .test.ts — neither reaches an image or an APK.

Globs over files rather than a directory exclusion, because this repo has
no tests/ tree to exclude: Go tests sit inline beside the code they cover
(158 files) and the web suite beside its modules (115). Patterns match what
exists and nothing speculative — there are no .spec.* files, no __tests__/
directories and no androidTest/ tree. If any appear they re-version until
named, which is the harmless direction and the point of a denylist.

The guard that matters is not "a test-only commit is inert" — it is that a
commit touching a test AND its source still moves the version. `':!internal'`
would satisfy every inertness assertion while silently excluding the entire
server, which is the stale-version-on-changed-artifact failure this whole
derivation exists to prevent.

Falsified: drop the Go exclusion and a _test.go commit moves the version;
drop the web one and a .test.ts does; replace the globs with `':!internal'`
and the source-alongside-test case breaks.

That last check failed first time, on a bug in the FIXTURE rather than the
derivation, and it is worth recording because it makes a test pass for the
wrong reason. Both commit helpers wrote the constant "x\n", so re-writing a
file with identical bytes recorded NOTHING — the "source and test together"
commit actually contained only the test, and the assertion was quietly
checking the case it was meant to contrast against. Content is now derived
from the commit's epoch, and the test asserts HEAD really contains both
paths before drawing any conclusion from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 18:21:12 -04:00
bvandeusenandClaude Opus 5 17212e9eb4 fix(ci): version derives from the shipped set; untrack an 18MB binary
test-go / test (push) Successful in 1m5s
test-go / integration (push) Successful in 3m30s
release / Build signed APK (releases and dev) (push) Successful in 5m10s
release / Build + push container image (push) Successful in 1m31s
release / Verify release artifacts (tag releases only) (push) Skipped
Three build-hygiene fixes that turned up while explaining the pathspec.

**version.sh derives from what SHIPPED.** It read bare HEAD, so any commit
moved the version — including one touching only CI or a README. Rules 148
and 149 both specify the pathspec form. Now a denylist, and the direction
is the point: as an allowlist the list must be updated by whoever adds a
directory and nothing fails if they don't, so the failure mode is a changed
artifact keeping its old version silently on a green run. Inverted, new
content counts by default.

android/ is deliberately NOT excluded, and that is the subtle part. This
repo ships TWO artifacts from ONE derivation: android/ is in no server
image, but it is the APK's entire source, and excluding it would stop an
Android-only commit from moving the APK's own version — the silent
downgrade the versioning rework exists to prevent. So the list is the
union: exclude only what ships in neither, and accept that an Android
commit also nudges the server's reported version. Over-inclusion across the
two, which is the harmless direction. roundtable/roundtable-android each
keep tighter lists because they are one-artifact repos; don't copy theirs.

**.dockerignore excluded the wrong CI directory.** It named .forgejo/ and
.github/, neither of which this repo has. Gitea Actions reads .gitea/, so
the one directory that exists was the one not excluded. The "Flutter mobile
client" block had also lost its PATTERN when flutter_client/ was deleted,
leaving a comment describing an exclusion that was not happening — android/
never took its place, so 4.1MB of Gradle project entered the context and
busted the `COPY . .` layer on every Android-only change. bin/ excluded too.

**bin/minstrel was tracked** — an 18MB binary last refreshed by a commit
about web test mocks, and re-dirtied by every `make build` since. Untracked
and ignored; the file stays on disk.

Guards are behavioural rather than textual: they build throwaway repos with
pinned commit timestamps and run version.sh against them, so they break when
the derivation changes rather than when the wording does. Falsified — drop
the .gitea exclusion and the CI-only commit moves the version; add an
android exclusion and an Android commit stops moving it; exclude everything
and a source commit refuses.

One honest note on the refusal test: the script already refused an empty
result via the downstream date check, so the new explicit check improves the
diagnostic ("no commit touches the shipped file set — shallow clone?") and
not the safety. The test pins the property, which is defended in depth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 17:55:36 -04:00
bvandeusenandClaude Opus 5 8f4b76a638 fix(ci): artifacts move to stock upload-artifact@v7 / download-artifact@v8
test-go / test (push) Successful in 1m4s
test-go / integration (push) Successful in 3m54s
android / Build + lint + test (push) Successful in 4m58s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
android.yml's debug upload and release.yml's minstrel-apk pair went
through the bvandeusen fork mirrors, with comments saying stock actions
refuse this hostname, that the pair had to be matched on the bundled
@actions/artifact major, and that download v7 was off-limits for node24.
None of that holds on gitea/runner 3.x: the runner edits the GHES refusal
out of the action bundles, every download major v4-v8 reads every upload
major v4-v7 (Scribe spike #3843, CI-runner run 6312), and every CI image
carries Node 24. The mirror pair itself was last verified at tag run 6286.

Same artifact names, paths and if-no-files-found. ci-requirements.md
drops the pairing table and keeps what is still true: @v3 is invisible.

Scribe snippet #2271, milestone 395.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
2026-09-10 17:25:10 -04:00
bvandeusenandClaude Opus 5 aeb8781c4e fix(release): drop version image tags, mint the rollback unit on main
test-go / test (push) Successful in 1m43s
test-web / test (push) Successful in 1m13s
test-go / integration (push) Successful in 4m12s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 38s
release / Verify release artifacts (tag releases only) (push) Skipped
The image tag map was the inverse of family rules 145 and 147 on every
count: it published :vYYYY.MM.DD.HHMM that nobody pinned, published :main
that rule 147 says should not exist, and published no commit-addressable
image at all — so the rollback unit the rule names did not exist in this
repo. A bad main push had nothing to roll back to but the previous
release tag, which may be many commits back.

The whole map is now:

  dev  → :dev
  main → :latest + :<sha>
  tag  → :latest

A release refreshes the channel and mints nothing else. The tag build
rebuilds the SAME SOURCE as main's build minutes earlier, differing only
in which APK is baked in, so rule 145's immutability clause applies
directly: move the channel tag, never re-push a commit-addressable one.
:latest has to move here rather than waiting for the next main push, or
the channel would carry the previous release's APK indefinitely — a
channel that cannot refresh itself (rule 146).

Two consequences that are not optional:

The verify job asserted the :<version> image existed. With version tags
gone that would fail every release for a tag nothing mints. Re-pointed at
the :<sha> image rather than deleted — deleting it is the tempting way to
make a failing guard go green, and it earns its keep twice now: it still
catches an image push that silently did not happen, and it additionally
proves the ordering, since a tag cut on a commit whose main build never
completed has no rollback target.

The server's self-reported version was the literal string "main" or
"dev". That was survivable while :vYYYY.MM.DD.HHMM existed to identify a
build; with version tags gone it is the ONLY thing that says which build
is running, and two dev images months apart were indistinguishable. It
now carries the derived name from ci/version.sh on every lane, with the
channel as a sibling field (rule 149) rather than folded into the string.
Surfaced at /healthz and beside the version in Settings.

Guards added for each arm of the policy, and every one was falsified
against the specific regression it names before committing. That caught
two real bugs in the guards themselves: stepBody cut at the next
`- name:`, which returns an EMPTY body for the last step in a job and
made the assertions pass vacuously, and its replacement cut at any blank
line followed by indentation, which truncated a step mid-run-block. The
helper now refuses an empty body outright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 15:10:15 -04:00
bvandeusen 439c8625d5 Fix the rebundle step: a non-matching grep must not kill it (#130)
test-go / test (push) Successful in 59s
test-go / integration (push) Successful in 3m1s
release / Build signed APK (releases and dev) (push) Successful in 4m41s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-09-10 10:49:40 -04:00
bvandeusenandClaude Opus 5 88508b536b fix(release): a non-matching grep must not kill the rebundle step
test-go / test (push) Successful in 1m39s
release / Build signed APK (releases and dev) (push) Successful in 6m11s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
test-go / integration (push) Successful in 5m59s
The first `main` build after the version rework failed, and the bug was
mine. :latest was never moved — "Build and push" was skipped — so nothing
reached production, but every subsequent main push would have failed the
same way.

The runner invokes `shell: bash` as `bash -e -o pipefail`. Under pipefail a
command substitution reports the FIRST non-zero status in its pipeline, not
the last, so

  VAR="$(printf ... | grep -oP ... | grep -E '\.apk\.version$' | head -1)"

exits non-zero when that grep matches nothing, even though `head` succeeded.
With -e the step dies AT THE ASSIGNMENT — before reaching the `if` written
to handle exactly the empty case.

Which is what happened: v2026.09.09 predates sidecar assets, so its
`.apk.version` grep matched nothing and the step aborted instead of falling
through to the name-only branch I added in 9f3e0b8c for precisely that
release. The transition case was described correctly in that commit message
and then not handled in code.

The other two assignments carried the same latent hazard and had simply
never fired, because a release always has a tag_name and an .apk asset. So
the step's documented promise — "degrades to an empty client/ (404 update
channel) — never a wrong version — if no release or APK asset can be
resolved" — was never actually reachable under pipefail. All three now
carry `|| true`.

Reproduced under the runner's exact shell before fixing: without `|| true`
the script exits 1 with no output at all, proving it never reaches the
branch; with it, the fallback runs and emits the name-only sidecar.

Guarded, since the graceful degradation depends on this and the failure is
invisible until the one release that triggers it: the new test asserts every
command-substitution grep in that step ends with `|| true`, and was
falsified by removing it from the sidecar line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 08:37:23 -04:00
bvandeusen 1d67c160b2 Versioning rework, a dev channel, and the miniplayer gap (#129)
release / Build signed APK (releases and dev) (push) Skipped
android / Build + lint + test (push) Failing after 4s
release / Build + push container image (push) Failing after 5s
release / Verify release artifacts (tag releases only) (push) Skipped
test-go / test (push) Successful in 1m24s
test-go / integration (push) Successful in 6m25s
2026-09-10 08:33:26 -04:00
bvandeusenandClaude Opus 5 90bb3538c6 feat(release): build a dev channel so testing stops requiring a release
test-go / test (push) Successful in 1m11s
release / Build signed APK (releases and dev) (push) Successful in 5m0s
test-go / integration (push) Successful in 5m55s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
There was no test channel at all. release.yml ran only on main and tags, so
no :dev image existed and no APK was produced outside a release — the only
way to get a build onto a phone was to ship one, which made `main` the
staging area by default.

A push to dev now builds a signed APK, bundles it, and publishes :dev.

Signed with the SAME key as release builds, deliberately. A differently
signed APK cannot install over the stable app, so anyone moving between
channels would have to uninstall and lose their local data. Same key means
both directions work.

:dev is published ALONE, with no per-commit tag. A rolling channel is
rolling by definition; a commit-addressable image for it would be a rollback
target nobody ever pulls, kept forever. Recovery on dev is to fix forward,
and that is a deliberate trade rather than an omission.

The channel is derived from the REF, not the commit, which is why it is
computed in the workflow and not in ci/version.sh. The same commit built on
dev and on main reports the same version NAME and differs only in the
channel field — that separation is the entire point of keeping the three
values apart.

What this repo deliberately does NOT get: a cross-repo dispatch to refresh
the channel when its bundled APK is rebuilt. That mechanism exists elsewhere
in the family because the app and server live in separate repos, and a
channel that can only be refreshed by an unrelated commit is not a channel.
Minstrel is a monorepo — one push builds the APK and the image in the same
run from the same commit, so the channel cannot go stale against its own
artifact. The requirement is met structurally; copying the mechanism would
add a moving part to fix a problem that does not exist here.

Two guards, for the two ways this wiring can fail quietly:

A dev push must never move :latest. That would ship untested code to every
stable operator on their next pull, with the build green and the image
perfectly valid — just the wrong audience. Nothing else in the suite would
notice.

The two bundling paths must stay mutually exclusive. The rebundle step is
now gated to main specifically, not to "not a tag": under the looser
condition a dev push would run BOTH steps, staging its fresh APK and then
overwriting it with the previous release's. The image still builds, the
sidecar still parses, and the channel whose whole job is being current
quietly serves stale art.

Both falsified against the regressions they name before committing.

Scribe task #3819, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 22:47:42 -04:00
bvandeusenandClaude Opus 5 a687ef439c fix(release): refuse an ordering key that overflows versionCode
test-go / test (push) Successful in 1m31s
test-go / integration (push) Successful in 5m48s
The script asserted the key was positive but never that it fits. Android's
versionCode is a signed 32-bit int and the platform rejects an APK above it,
so a build machine with a badly wrong clock would emit a code the script
happily hands on and the install then refuses.

Worse than a rejected build: an over-ceiling code is also unreachably high,
so every correct build afterwards would fail to outrank it and the update
channel would be permanently stuck. Cheaper to refuse at the source than to
diagnose it from a phone that will not update.

The Go guard already asserted this, but only against a pinned value. The
script is what actually runs at build time, so the check belongs here too.

Falsified at the boundary rather than by eye — exactly at the ceiling exits
0, one minute past exits 1. My first probe used a year-6000 clock and did
NOT fire, which turned out to be the probe being wrong rather than the
check: that epoch still lands under the ceiling. The ceiling is reached in
6103, roughly 4079 years out, so this only ever catches a misconfigured
clock.

This commit deliberately touches ci/version.sh alone, to verify the path
filters added in eaf4654c actually fire the Go lane for release-machinery
changes. That run proved nothing about them, because it also touched
internal/** and would have run regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 22:17:07 -04:00
bvandeusenandClaude Opus 5 eaf4654c0a test(release): make the version derivation executable, and guard it on dev
test-go / test (push) Successful in 1m10s
test-go / integration (push) Canceled after 5m10s
Steps 1 and 2 of this milestone shipped with no CI coverage at all, and the
reason generalises: release.yml triggers only on main and tags, so nothing
inside it is exercised until a release is already running. That is the worst
place in the repo to be unguarded, because the failure mode is silence — a
version nobody can compare looks exactly like being up to date, and nobody
reports an update they were never offered.

The fix is not a test that reads YAML. The derivation moved into
ci/version.sh, so it can be RUN, and internal/server/release_version_test.go
runs it on every push. release.yml now calls the same script, so the thing
that ships and the thing under test are one artifact rather than two copies
that agree until they don't.

test-go.yml gains 'ci/**' and '.gitea/workflows/release.yml' in its paths.
Without that the guard exists but never fires on the changes it protects,
which is the same nothing it replaces.

What is pinned, and why each one:

  - HHMM is zero-padded. A build at 00:42 must emit "0042"; a stripped
    leading zero shifts the segment two orders of magnitude and reverses
    comparisons against every other build that day. It only bites for a
    tenth of the day, so it will not be found by chance.
  - The name derives from the COMMIT and the code from the BUILD. Asserted
    by holding one clock and moving the other: the name must not move, the
    code must.
  - The code clears 1895, the highest versionCode the retired commit-count
    scheme shipped. Below that Android refuses the upgrade as a downgrade
    and the channel becomes a one-way door.
  - The tag is the name with a `v`, never chosen.
  - release.yml still calls the script, and does not derive a commit count
    again. This pins the WIRING: without it every other assertion keeps
    passing while the shipped path silently drifts out of coverage.

The script rejects unusable clocks rather than emitting something plausible,
and those rejections are tested — a guard that cannot fail is worse than
none, because it reads as coverage.

Falsified before committing rather than after: ran the script against good
and broken inputs and watched all three failure paths fire; verified every
asserted value by executing it rather than by reading it; and checked the
two workflow predicates catch their regressions while staying immune to a
comment that merely names the old formula.

Step 5 of 5 — Scribe task #3812, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 22:11:51 -04:00
bvandeusenandClaude Opus 5 ca1c18bbbb fix(android): miniplayer content sat at the top of its bar, not centred
android / Build + lint + test (push) Successful in 4m9s
Reported with a screenshot: the bar drew at full height but the cover,
title and transport row hugged its top edge, leaving an empty strip of
surface above the gesture area.

The Surface is a fixed 80dp. Inside it a plain Column stacked a 4dp
progress fill and then MiniRow at its INTRINSIC height — 48dp, set by the
cover and the icon buttons. A Column stacks from the top and nothing
claimed the remainder, so 80 - 4 - 48 = 28dp collected at the bottom.

Measured off the screenshot rather than eyeballed, and the bands agree
exactly: progress fill 14px (4dp at 3.5x), surface 280px (80dp), cover
167px (48dp), empty below 99px (28.3dp). That the arithmetic lands on the
measurement is what makes this the whole cause rather than one contributor.

MiniRow was already centring its content correctly — inside a box that was
only ever 48dp tall. Giving it weight(1f) lets it take what the progress
fill leaves, so it measures 76dp and centres 48dp of content: 14dp above
and below. The fill stays pinned to the top edge, which is where a
progress indicator belongs.

Not the same bug as issue #2681. That was a dead strip ABOVE the
miniplayer from an unclaimed navigation-bar inset, fixed in v2026.08.18.
This is inside the bar, pure layout, no insets — the surface already
stopped correctly above the gesture area.

CI cannot see this one. There are no Compose UI tests in the repo; the
Android lane is ktlint, detekt and JVM unit tests, and a layout bug needs
an instrumented test to catch. Compilation and lint are all this commit
gets from CI — the visual check is on a device, and the APK only builds on
a tagged release.

Scribe issue #3826.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 22:07:23 -04:00
bvandeusenandClaude Opus 5 68136c64c0 fix(android): decide updates on the ordering key, not the version name
android / Build + lint + test (push) Successful in 3m55s
The app compared NAMES while Android installs by versionCode, with nothing
keeping the two orderings consistent. So it could offer a build the platform
then refused as a downgrade, or stay silent about one it would have
accepted. The offer and the install were asking different questions.

Both consumers — the shell banner and the About card — now route through
one isUpdateAvailable(): decide on the ordering key whenever the server
reports one, since that is the same value the package installer compares,
so an offer implies an install that will actually be accepted. Name
comparison survives only as the fallback for a server predating the field.

isVersionNewer is deliberately untouched. It already degrades per segment
and is not what was broken; rewriting it while nearby would have put the
fallback path at risk for no gain.

code is nullable on the wire, and that is load-bearing rather than
stylistic. The app's Json sets coerceInputValues = true, which replaces a
JSON null with the declared default on a NON-nullable property — so
`val code: Long = 0` would have turned "this server reports no ordering
key" into "its key is 0" silently, ranking every such server as infinitely
behind and offering its build to everyone forever. Reading the field
declaration alone would never show that; it lives in AppModule.

A third caller turned up during the sweep and was deliberately left alone.
NetworkStatusController compares the /healthz minClientVersion, which is a
server-declared compatibility floor rather than the bundled APK — there is
no ordering key on that wire at all, so names remain the only thing it can
compare. Different question, correctly still using the old helper.

The update channel had no tests whatsoever before this, which is worth
stating: the thing deciding whether anyone is ever offered an update fails
silently in both directions. The new suite pins that the key wins when it
disagrees with the name, that a null key falls back rather than reading as
zero, the recorded migration constraint (a new-scheme name outranks an
old-scheme one across a day boundary but NOT within the same day), and the
degradation cases — including that an unparseable DECIDING segment reads as
zero and loses, which is why the channel must never live inside the name.

Every assertion was checked against the real comparison by mirroring it,
rather than from reading it: two of my first-draft comments described the
wrong mechanism and were corrected on the evidence.

Step 4 of 5 — Scribe task #3811, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 22:01:26 -04:00
bvandeusenandClaude Opus 5 9f3e0b8cd3 feat(version): sidecar and /api/client/version carry name, code and channel
test-go / test (push) Successful in 1m25s
test-go / integration (push) Successful in 5m52s
The client compares names while Android installs by versionCode, and the
wire had no way to close that gap: the sidecar was one positional line and
the endpoint returned a name only. This is the plumbing that makes the
ordering key decidable by the client at all.

The sidecar is now JSON rather than a grown positional string. That shape
was chosen against a specific failure: the obvious growth path was
"<name> <code>", which a first-space split silently mangles the moment a
third field appears — the code stops parsing as an integer and the reader
falls back to name comparison WITHOUT erroring. JSON cannot mistake a new
field for an old one.

code is a POINTER on both sides, and omitempty on the wire. Absent has to
stay distinguishable from zero: a build published before ordering keys were
recorded genuinely has no code, and zero would claim it is infinitely old
rather than unknown.

A malformed sidecar now fails loudly instead of serving a blank version.
If an unreadable file produced an empty name, every client would compare
against nothing, conclude it was current, and go quiet — "I cannot read
this" and "there is nothing newer" would return the same answer, which is
the failure mode nobody reports because nobody is offered anything to
report.

The non-tag :latest path no longer RECONSTRUCTS the bundled APK's version.
android-release now publishes the sidecar as a release asset beside the
APK, and the image build downloads it. The old reconstruction duplicated a
derivation formula across two files, and could only ever recover the name —
the ordering key is build-time minutes and exists nowhere once that build
ends. Releases predating the sidecar report their name with a null code,
which is the honest answer rather than a guessed one.

image-release also drops to a shallow checkout: it needed full history and
tags only to re-derive versions from the tagged commit, and now touches git
for nothing. MINSTREL_VERSION comes from GITHUB_REF.

Two things checked rather than assumed. The Android Json sets
ignoreUnknownKeys, so the added fields cannot break already-installed apps.
It also sets coerceInputValues, which will silently turn a null code into 0
if step 4 declares the field non-nullable — recorded on task #3811, because
reading the field declaration alone would never reveal it.

Also fixes a stale comment block describing "the Flutter client", deleted
in v2026.08.18.

Step 3 of 5 — Scribe task #3810, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 21:51:41 -04:00
bvandeusenandClaude Opus 5 e46c6bcccf docs(release): tags become vYYYY.MM.DD.HHMM, and stop telling people to move them
The tag is now the artifact's own version name with a `v` in front, so
`v2026.09.10.1432` and `2026.09.10.1432` are one string. Nothing has to
reconcile what the tag claims against what the APK reports, and minting one
is arithmetic on the tagged commit's timestamp rather than a lookup.

The substantive change is the prose. release.yml's header instructed the
reader to `git push -f origin vYYYY.MM.DD` on a same-day re-cut. That is
the operation the family rulebook forbids outright, and it has incidents
behind it — moving a same-day tag forward once took a published release
down with it. Anyone who had installed from that tag was holding something
it no longer pointed at.

With HHMM there is nothing left for mutability to buy: every tag is unique
by construction, so a second release the same day is not a collision to
resolve, just another tag.

The old instruction is recorded as retired rather than deleted. Someone who
remembers it should learn it was withdrawn and why, not find it silently
absent and assume they misremembered.

README contradicted itself inside one sentence — "immutable per-day release
tags ... a same-day re-cut moves the tag forward" — and now says which it
is, plus a note that pre-2026-09-10 tags keep the old shape and still work.

Transition wrinkle, deliberately left for step 3: the non-tag :latest path
reconstructs the bundled APK's name from the latest release's commit
timestamp, which for the one existing old-shape release yields
2026.09.09.1828 while that APK actually declares 2026.09.09.1895. It fails
SAFE — 1828 compares lower, so no false update is offered — and it
self-corrects at the first new-scheme release. Step 3 removes the
reconstruction entirely by having the sidecar carry recorded values instead
of derived ones.

Step 2 of 5 — Scribe task #3809, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 21:43:33 -04:00
bvandeusenandClaude Opus 5 bfdaed9365 fix(release): derive versionCode from build time, versionName from commit time
android / Build + lint + test (push) Successful in 4m19s
versionCode was `git rev-list --count HEAD`, and build.gradle.kts called it
"monotonic forever". It is not, and that claim was sitting directly above
the bug it denied.

A commit count runs ahead on `dev`. So a dev build carried a HIGHER code
than the `main` release meant to supersede it, and Android refuses that
install as a downgrade — a channel you can enter and cannot leave without
uninstalling and losing local data.

Two clocks now, and the split is deliberate even though it reads like an
inconsistency:

The NAME answers "is this the same code?", so it derives from COMMIT time
and reads identically on every lane building this source. A dev build and
a main build of one commit must report the same string. Build time cannot
do that — it prints two numbers for one thing.

The ORDERING KEY answers "may this be installed over that?", so it must be
monotonic BY CONSTRUCTION: minutes since 2020-01-01. Commit time fails
here for the mirror-image reason — rebuild an older commit and it goes
DOWN, which on a phone is a refused install rather than a confusing label.

The non-tag :latest path reconstructed the bundled APK's name with the old
formula, so it is moved to the same commit-timestamp derivation. That
duplication is temporary: once the tag becomes `v<version-name>` it
collapses to `${TAG#v}` with nothing left to keep in step.

Verified locally by running the derivations rather than reasoning about
them: HEAD yields 2026.09.09.1828; the key yields 3519456 against ~1895
from the old scheme, inside int32 with ~4000 years of headroom; a commit
at 00:42 UTC yields "0042", not "42". The workflow now asserts the emitted
shape too — a malformed name builds, signs and publishes happily and only
surfaces as an update nobody is offered, which nobody reports.

That local check is the only verification this commit gets. release.yml
triggers on main and tags only, so nothing on `dev` executes the new
derivation; CI here proves the Gradle file still parses and nothing else.

Also confirms the migration constraint recorded in milestone #390: this
commit would name a release 2026.09.09.1828, which is LOWER than the
installed 2026.09.09.1895 under name comparison. The first new-scheme
release must be cut on a later calendar day, or existing installs will
never be offered it.

Step 1 of 5 — Scribe task #3808, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 21:37:30 -04:00
bvandeusen 237380b122 New brand mark, and both clients stop fetching their fonts at runtime (#128)
test-web / test (push) Successful in 54s
android / Build + lint + test (push) Successful in 5m36s
release / Build signed APK (tag releases only) (push) Successful in 4m36s
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-09-09 14:36:15 -04:00
bvandeusenandClaude Opus 5 c27f9d484a test(android): guard that the typefaces stay bundled
android / Build + lint + test (push) Successful in 5m17s
The web side has no-external-assets.test.ts; Android had nothing, so the
font provider could come back with no test noticing. This is the Android
half.

Expectations are read out of Typography.kt rather than hardcoded, which
is what makes it a structural pin instead of a list that rots: the guard
extracts every Font(R.font.X, FontWeight.WN) declaration and checks that
X.ttf exists, is really TrueType, and reports N as its OS/2
usWeightClass. Add a face without vendoring it and this fails; change a
declared weight without refetching the matching static instance and it
fails too.

usWeightClass is the check worth having. css2 silently collapses a
multi-weight request to 400 for legacy clients, so Medium comes back as
Regular — a valid TrueType file that renders at the wrong weight
everywhere, and the only field that distinguishes it.

Comments are stripped before the absence check, so the KDoc explaining
why there is no GoogleFont reference cannot satisfy the assertion that
forbids it.

Falsified by mirroring every predicate and byte offset against the real
files: it passes on what is committed, and trips on HEAD~1's
Typography.kt via both the forbidden-symbol check and the
no-declarations-found check. A 400 file asserted against a declared 500
fails, so the weight comparison is not vacuous.

Compilation itself is unverified locally — no Gradle run here — so CI is
the first thing to actually build this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 14:28:56 -04:00
bvandeusenandClaude Opus 5 b52a00df66 fix(android): bundle the typefaces instead of fetching them at runtime
android / Build + lint + test (push) Successful in 4m19s
Typography.kt resolved Fraunces, Inter and JetBrains Mono through the Play
Services font provider, which fetches them over the network on first use.
Same rule-164 problem the web client had, with a second failure mode on
top: the provider is absent entirely on devices without Play Services, so
the app fell back to the platform default and stopped looking like
Minstrel — quietly, with no error.

The five static instances now live in res/font, vendored by the same
tools/vendor-fonts.py that produces the web bundle. Both clients draw
from one list of faces so they cannot drift apart. Cost is ~0.86 MB of
APK; the runtime path is removed rather than kept as a fallback — the
ui-text-google-fonts dependency, its version-catalog entry and the
provider certificate hashes in font_certs.xml are all gone.

Two things about fetching TTFs that are worth writing down, because both
fail by succeeding:

Google Fonts picks the format from the User-Agent, and there is no
parameter to ask for one. A modern UA gets woff2, which res/font cannot
load. The obvious "use an old UA" fix gets EOT — an IE-only format that
downloads happily, has a plausible size, and is entirely useless here. An
Android 4.4 UA is what actually yields TrueType.

css2 also collapses a multi-weight request to 400 for legacy clients, so
asking for Medium silently returns Regular: a valid TrueType file that
renders at the wrong weight everywhere. Each weight is therefore fetched
on its own URL, and the script now asserts OS/2 usWeightClass on every
download — that field is the only thing distinguishing the two files.

Verified before wiring: all five carry TrueType magic, the 400/500 pairs
differ, and their usWeightClass reads 400/400/500/500/400 as declared
beside them in the FontFamily.

Not covered: there is no guard for this on the Android side. The web
equivalent is asserted by no-external-assets.test.ts, but the Android
tree has no source-inspection test pattern to follow and no way to
falsify one without a local Gradle run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 14:13:57 -04:00
bvandeusenandClaude Opus 5 16005054eb fix(web): vendor the web fonts instead of loading them from Google
test-web / test (push) Successful in 42s
app.html linked its stylesheet straight from fonts.googleapis.com, with
preconnects to that host and fonts.gstatic.com. A deployed instance has
no outbound network, so those requests never arrive and the whole UI
renders in fallback faces — Georgia for the display face, whatever the
system has for Inter and JetBrains Mono.

This is invisible in development, which is why it survived: the dev
machine has internet, so the fonts load and everything looks right. Only
a real deployment shows the failure.

tools/vendor-fonts.py fetches the three families once and writes them
under web/static/fonts with a generated stylesheet. static/ is copied
into the SvelteKit build, which Go embeds, so the faces travel inside the
binary. 32 woff2 files, 912K.

Two details that matter for correctness rather than size:

Urls in the generated CSS are relative (./Inter-400-latin.woff2), not
absolute. A url() resolves against the stylesheet's own address, so the
directory keeps working when the app is served under a base path;
/fonts/... would not.

Every subset Google slices is kept, with unicode-range intact. The
browser still fetches only the ranges a page uses, so this costs
repository bytes rather than request bytes — and a library full of
Cyrillic or Greek artist names renders instead of falling back mid-list.

The guard asserts the property, not the vendor: any absolute url in a
resource-loading attribute fails, whoever hosts it, since naming Google
would pass the day someone reached for a different CDN. It also checks
preconnect separately (those carry no fetch of their own, so the url
check misses them), strips HTML comments before asserting an absence so
prose describing the forbidden thing cannot satisfy the check, and pins
the font families to tokens.json rather than a hardcoded list.

Falsified against the pre-change app.html: it trips both the external-url
and preconnect assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 13:49:47 -04:00
bvandeusenandClaude Opus 5 b06a1adfe8 feat(brand): draw a reduced mark so the favicon reads at 16px
The full hat does not resolve below ~32px, which the header worked around
by sizing up. A browser tab cannot: it renders the favicon at 16px and
does not ask. There the mark was a blob.

Deriving a small form from the traced art does not work, and this is the
non-obvious part. Hole-filling, morphological smoothing and dropping
components were all tried; every one of them preserves the overall
silhouette, and the overall silhouette — dominated by a long diagonal
plume — is precisely what fails. The result each time was a diagonal
smear that reads as no object at all.

So the reduced form is drawn rather than derived: a strong horizontal
brim under a crown that peaks left of centre, a band slit so the two do
not fuse, and a short pointed plume. Same lean and proportions as the
full mark, detail removed instead of minified.

favicon.svg and favicon.png now use it; apple-touch, icon-512 and the
Android launcher icons keep the full art, being large enough for it. The
plume carries the accent, which measures 3.04:1 on obsidian and 5.43:1 on
the light ground — both clear of the 3:1 graphics floor.

Also corrects the accent-on-iron figure in the generator's comment from
2.80:1 to 2.70:1. The real --fs-iron is #1E2228; 2.80 came from measuring
against a value I had guessed rather than read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 13:49:31 -04:00
bvandeusenandClaude Opus 5 5593f7ce17 feat(brand): replace the M mark with the traced bard-hat logo
test-web / test (push) Successful in 44s
android / Build + lint + test (push) Successful in 5m9s
The mark is now a feathered hat with an arc of eighth notes, traced from
the operator's reference artwork at 99.74% IoU. The hat takes the text
colour and the note arc holds the accent — the same construction the M
used, and for the same reason: parchment on a light surface is invisible,
so the silhouette has to flip with its background while the accent stays
constant.

This reverses the subject-neutrality argument recorded in Minstrel's
design system, which held that depicting a bard would tell a new user the
app is for renaissance-faire music and had twice rejected a hat. The
operator commissioned this artwork and chose it with that objection on the
table; the record is updated rather than silently contradicted.

Both accent-filled alternatives were measured and rejected: #4A6B5C is
3.04:1 on obsidian and 2.80:1 on the raised iron, so an accent hat drops
under the 3:1 graphics floor as soon as it sits on a card.

tools/gen-brand-assets.py is the single source for the four copies, which
cannot share a file because each needs a different colour mechanism —
currentColor inlined, a prefers-color-scheme swap in the favicon, literal
fills in mark.svg, flat pixels in the rasters. Hand-copying 20KB of path
data four ways is how a silhouette change lands in three of them.

Two notes on the trace, both non-obvious: it runs on the original
antialiased greyscale rather than a binary mask, because tracing a
supersampled mask scores ~100% IoU by reproducing the pixel staircase
exactly — a perfect number for jagged art at 120KB of path, versus 99.74%
at 20KB. And potrace reads PBM where bit 1 is black, so the ink mask is
inverted going in; backwards, it traces the background and still emits a
plausible-looking SVG.

The header lockup moves 20px → 28px: the hat carries far more detail than
the M and does not resolve below ~32px. The 16px browser-tab favicon is
still a blob at that size and is not addressed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 12:52:57 -04:00
bvandeusen 4f077736b6 Merge PR #127: Sonos queue verification, cast double-download fix, stutter instrumentation
android / Build + lint + test (push) Successful in 4m16s
release / Build signed APK (tag releases only) (push) Successful in 5m25s
release / Build + push container image (push) Successful in 1m16s
release / Verify release artifacts (tag releases only) (push) Successful in 1s
2026-08-18 10:50:10 -04:00
bvandeusen 0103953953 refactor(diagnostics): split the flap window and episode rule apart
android / Build + lint + test (push) Successful in 4m8s
detekt ReturnCount. Extracting the pruning and the is-this-an-episode
predicate reads better than suppressing it, and the cooldown rule now
has a name and a docstring of its own.
2026-08-18 10:00:43 -04:00
bvandeusen 72c0e96f92 fix(player): stop the local player during a cast; capture transport flap
android / Build + lint + test (push) Failing after 1m11s
Two changes for the Sonos stutter the operator describes as rapid
play-pause-play at the start of a track.

The measurable one: nothing in the diagnostics could see it.
player_state records source/loading/error but not whether we are
playing, track_change needs the queue index to move, and the heartbeat
samples once every 45s. A few seconds of oscillation that changes no
index fell through all three, which is why the symptom has been
described repeatedly and measured never. The poll loop now publishes
raw GetTransportInfo readings on change, and TransportFlapDetector
turns a burst of them into one summary event carrying the sequence
alongside local-vs-Sonos track and position -- enough to tell a cursor
disagreement from the renderer rebuffering. It samples at the 1Hz poll
cadence, so a faster oscillation lands aliased; that still answers
whether the renderer is leaving PLAYING, which is the open question.

The suspect one: during a cast the wrapped ExoPlayer was paused, not
stopped. pause() is only playWhenReady=false -- LoadControl keeps
loading, so the phone went on downloading the track the renderer was
streaming, over the same WiFi, re-arming at every track change via
syncLocalCursorToRemote's seekTo. At FLAC bitrates that is a second
full-rate download competing with the speaker, beginning exactly when
a new track does. stop() ends it; Media3 keeps media items, index and
position, and getPlaybackState() already reports STATE_READY while
remote, so cursor sync and handoff are unaffected. The route teardown
re-prepares for local playback.

Whether that download is the cause is unproven -- hence the
instrument landing alongside it rather than after it.
2026-08-18 09:55:41 -04:00
bvandeusen e87516bbe4 refactor(player): split Sonos queue loading out of the picker — #2728
android / Build + lint + test (push) Successful in 3m48s
detekt flagged OutputPickerController as LargeClass once the verify
path landed. Extracting rather than suppressing: how the renderer's
queue is shaped is a different concern from which route is selected,
and it had grown big enough to hide a bug — every write in here is a
SOAP call that can fail on its own, and nothing ever read the result
back.

SonosQueueLoader now owns load / extend / verify / append and the
incremental diff. The picker keeps route selection and asks it for
queue work. No behaviour change.
2026-08-17 22:35:53 -04:00
bvandeusen 8e21bce103 fix(player): verify the Sonos queue actually landed — #2728
android / Build + lint + test (push) Failing after 1m18s
The renderer's queue was written and never read back. loadQueueOnSonos
background-appends the tail one AddURIToQueue at a time and gives up
after 3 consecutive failures; Sonos rate-limits burst adds, so that
happens. The renderer was then left holding fewer tracks than we
believed, played what it actually had, and stopped — which looked
exactly like playback dying for no reason.

GetMediaInfo's NrTracks is the cheap authoritative answer and was not
being asked for anywhere in the app. Now:

- verifyQueueLength after every load (including when there is no tail
  to append — the initial batch can be dropped the same way), appending
  what the renderer is missing, bounded at 2 passes.
- RemoteStallWatchdog gains QueueState, so a stop is classified rather
  than assumed: a stream that died resumes, a truncated queue gets
  repaired at the next track, and a queue that simply ended does
  nothing at all.

That last case was a bug shipped in #2700: the normal end of a queue is
a confirmed STOPPED with play intent, so every cast session would have
ended with three resume attempts and a `stalled` error for playback
that finished perfectly. No test described the end of a queue, so CI
had nothing to catch it with.

Queue reads are gated on the transport being stopped and cached for 5s,
so this never becomes a third SOAP call per second.
2026-08-17 22:29:54 -04:00
bvandeusen 727f68950e Merge PR #126: missing-file lifecycle, UPnP stall recovery, Android browse parity, Flutter client removed
release / Build signed APK (tag releases only) (push) Skipped
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m26s
release / Build + push container image (push) Successful in 1m38s
release / Verify release artifacts (tag releases only) (push) Skipped
android / Build + lint + test (push) Successful in 4m54s
test-go / integration (push) Successful in 5m19s
2026-08-17 16:28:13 -04:00
bvandeusen 955a61194e fix(library): a fully-missing album leaves the year axis too — #2702
test-go / test (push) Successful in 54s
test-go / integration (push) Successful in 5m59s
Filed as a product decision, but the code had already made it: the genre
queries filter tracks.missing_since inside their EXISTS, so an album
whose every file had gone was ALREADY absent from genre while still
listed under its year — where opening it found nothing playable. The two
browse axes disagreed, and whichever answer won, one of them had to
change.

Hiding is the answer. Browsing is how you go looking for something to
play, and the rule for that case is to take it out of view; the admin
missing-files surface is where absence gets reported, with far more
detail than a silent gap in a grid. It also means changing the axis that
was inconsistent rather than the one that was already right.

All three year queries move together — index, list and count. That is
the invariant #367 needed care for at the genre level: if the index
groups differently from the filter, a year leads to an empty page, and
if the count disagrees with the list then "Load more" promises rows that
never arrive.

The predicate is "has at least one playable track", which also excludes
an album carrying no tracks at all. Same answer for the same reason —
nothing to play, nothing to browse to — and it is what genre has always
done, since an album with no tracks contributes no genres either.

That last part changed two existing tests, which had been seeding
trackless albums as a convenience. Their intent (undated albums never
appear in a range) is untouched; they now seed a track each, which is
what a real album looks like anyway. Two new tests pin the actual
behaviour: a fully-missing album leaves the axis while a half-missing
one stays, and the count agrees with the filtered list.
2026-08-17 13:38:08 -04:00
bvandeusen b96285d6d9 test(android): TrackRef needs albumId and artistId — #2704
android / Build + lint + test (push) Successful in 3m46s
The queue-filter test built TrackRefs without them; they have no
defaults, so compileDebugUnitTestKotlin failed. Caught by CI on the
Android lane while I was reading the Go one.
2026-08-17 13:09:06 -04:00
bvandeusen 7ba673ed83 fix(library): tell clients when a file goes missing or comes back — #2704
test-go / test (push) Successful in 53s
test-go / integration (push) Successful in 5m2s
The wire field shipped in 366692a1 was inert. MarkTracksMissing and
ClearTracksMissing are plain UPDATEs, and /api/library/sync is a
change-log feed: a row that never produces a change row is never
re-sent. Clients would have kept their stale copy until an unrelated
edit touched the track or the cursor fell out of the retention window
and forced a full resync -- so the flag existed and nothing ever told
anyone to read it.

Found by checking the consumer set rather than the code: the field was
threaded end to end and every test passed, because none of them asked
the question "how does this reach a client?".

Logged BEFORE the mutation, which is the opposite of the scanner's
log-after-success pattern, and deliberately so. The failure modes are
not symmetric. Log-then-fail-to-mark makes clients re-read a track that
has not changed: one wasted fetch. Mark-then-fail-to-log leaves the mark
with no change row -- and because both statements are idempotent
(missing_since IS NULL / IS NOT NULL guards), the next scan will not
retry the pair, so the client never learns. Permanently. A spurious
re-read is much the cheaper mistake.

Restoring logs too. A file coming back that nobody is told about stays
greyed out on every device until something unrelated touches it, which
would be a worse bug than the one being fixed.

Op is upsert, not delete: the track still exists and keeps its history.
Delete would tell clients to drop the row, which is precisely the design
#2704 rejected when it chose to ship state instead of filtering the feed.

Adds sync.LogChanges alongside LogChange, backed by an unnest batch
insert. Every existing caller mutates one entity, so per-row was right
for them; reconcile can mark a quarter of a library in one sweep, where
a loop would be thousands of round-trips inside an already-slow scan.
2026-08-17 13:04:14 -04:00
bvandeusen 366692a1fc fix: stop the sync feed hiding missing files from clients — #2704
test-go / test (push) Successful in 2m3s
test-go / integration (push) Successful in 5m8s
android / Build + lint + test (push) Failing after 4m24s
#2523 filtered missing tracks out of every path that CHOOSES music, but
the client sync feed was never touched: GetTracksByIDs has no filter and
the wire had no field for it. So every Android client held a cached
library containing tracks whose files are gone, with no way to tell, and
could queue them from any cache-first path -- the exact failure #2523
existed to prevent, reached by a different route.

Ships the state rather than filtering the feed, of the two options the
ticket weighed. A missing file is expected to come back: the scanner
clears the mark, and adopts the row if it returns renamed (#2528).
Withholding the row would mean a delete-and-recreate on every client for
what is usually a transient unmount, churning caches and throwing away
the identity #2528 works to preserve.

Room goes to v8. No hand-written migration: the pre-v1 destructive
fallback rebuilds from sync, which repopulates every row with the new
column -- exactly the case that policy exists for.

The interesting part was working out what "missing" means to a client,
and it is NOT "unplayable". Two findings shaped the fix:

Server search and album detail never filtered missing tracks either, and
that turns out to be right rather than an oversight. The consistent rule
the codebase already follows is that Minstrel never PICKS a missing
track for you -- recommendation, discover, mixes and browse all exclude
them -- but it does not hide one you went looking for by name or opened
an album to find. Hiding track 4 makes an album look wrong. So the fix
is to mark and to keep it out of queues, not to hide it.

And a track whose server file is missing still plays perfectly if its
audio is already in the device cache. ShuffleSource's offline pools
filter to exactly those residents, so it now clears the mark on the way
out: the bytes are local and the server's loss is irrelevant. Without
that, the queue filter below would have thrown away tracks that work,
turning a fix into an offline regression.

The queue protection is one choke point rather than five call sites.
setQueue is where playlists, album play-all, search, radio and cold-boot
resume all converge. dropUnavailable is pure so the index arithmetic is
pinned by tests -- removing entries ahead of the requested position
would otherwise start playback on the wrong track, and asking to start
on a missing track now starts the next playable one, which is the
"gets skipped" behaviour the operator asked for. An entirely missing
queue returns empty and the caller leaves the player alone rather than
replacing what is playing with silence.
2026-08-17 12:56:31 -04:00
bvandeusen 6d729d1512 fix(web): timeUntil rounds, so a 4h wait doesn't read as 3h — #2527
test-web / test (push) Successful in 34s
CI caught a real bug, not just a brittle test. The page rendered an
attempt four hours away as "in 3h", because timeUntil floored the way
relativeTime does.

Flooring an elapsed time is honest: "3h ago" means at least three hours
have passed. Flooring a countdown is not -- 3h59m away became "in 3h",
so the operator comes back an hour early and finds nothing has happened.
It rounds now, with the boundary cases pinned: sub-minute is "any
moment", 59.6m is "in 1h", 24h is "in 1d".

That divergence is now the fourth documented difference between the two
formatters, all deliberate, all in snippet #2699 with a test asserting
they disagree so nobody unifies them later.

The test assertions were also genuinely wrong: they read raw textContent
from a template that wraps mid-sentence, so "last 2d ago" arrived as
"last\n                2d ago". Added a whitespace-normalising helper —
asserting on raw textContent makes a test fail when the markup reflows,
which says nothing about the behaviour.
2026-08-17 00:25:40 -04:00
bvandeusen 414dfb23b6 feat: show what re-acquisition has done, per folder — #2527
test-web / test (push) Failing after 42s
test-go / test (push) Successful in 1m0s
test-go / integration (push) Successful in 5m3s
Completes milestone #290. The sweeper has been running and the settings
have been editable, but the list itself said nothing about either, so
the only way to tell "not tried yet" from "asked twice and nothing came
back" was to go and read the Requests queue.

Each folder now carries its album's attempt record: how many times, when
last, when next -- or that it gave up, with the reassurance that a file
coming back and going missing later starts the process over. Null when
nothing has been attempted, which is the common case for a folder that
just went missing and would be noise on every row.

next_attempt_at is computed, not stored. The schedule is a function of
the attempt count and the current settings, so persisting it would go
stale the moment an operator edited the backoff -- and the card lets
them do exactly that.

Needed a forward-looking formatter. relativeTime deliberately collapses
a future timestamp to "just now" (pinned by its own test) because that
is the right answer for a clock-skewed past event; it is the wrong one
for a scheduled future attempt, which would have rendered "next just
now". timeUntil is its companion rather than a sign-aware rewrite: the
two read differently in the same sentence -- "last tried 3d ago, next in
4h" -- and a test asserts they disagree about the future on purpose, so
nobody later "fixes" the divergence.

The state lookup is one batched query for the whole page and best-effort:
this is context on a list whose real job is showing what is missing, so
a failure leaves the groups bare rather than failing the page. The
settings service is read with a nil guard falling back to the shipped
defaults, since contexts that wire routing without services exist and a
backoff projection is not worth a nil-pointer panic (rule #48).
2026-08-17 00:19:48 -04:00
bvandeusen 952132714e feat(web): re-acquisition settings card on the missing-files page — #2527
test-web / test (push) Successful in 34s
Rule #27: the sweeper has been running since bab9b168 with no way to see
or change what it does. This is the half that makes it a feature.

Placed above the list it governs rather than under Integrations. An
operator looking at missing files is exactly the person deciding what
should happen to them; Lidarr is the mechanism, not the subject, and
separating the policy from the problem would mean finding one to
understand the other.

The card states the retry schedule the numbers add up to -- "6h -> 12h
-> 24h" -- because the fields are meaningless individually. "First retry
gap: 6" tells you nothing until you know it doubles and where it stops,
and an operator should not have to simulate the algorithm to predict it.
It recomputes as they type, including the clamp.

It also states the unnameable-album count with its reason. Those albums
will never produce a request no matter how long they sit in the list
below, because Lidarr cannot be asked for a release MusicBrainz cannot
name. Watching rows never move with no explanation is how a working
feature gets reported as broken.

Save errors surface the server's own message. The Go layer validates the
same ranges the CHECKs enforce and names the field, so the operator
reads "grace_hours must be 1-720" rather than a generic failure.

The dirty check compares only the stored fields: unnameable_albums is
server-computed, and including it would make the form look edited
whenever the library changed underneath.

Nine tests, including the schedule clamp, the disabled-until-dirty Save,
the surfaced validation message, and a failed load offering a retry
instead of an empty card. The existing missing-files page suite gains a
stub for the card's own settings fetch -- it mocks the whole admin API
module, so the card's imports would otherwise be undefined at mount.
2026-08-17 00:13:20 -04:00
bvandeusen c2862e97bd test(api): cover the missing-file admin routes in the Mount test — #2527
test-go / integration (push) Successful in 5m3s
test-go / test (push) Successful in 57s
go vet caught the Mount signature change: library_test.go calls it from
inside the package, so the earlier grep for "api.Mount(" missed it.

Rather than only appending the argument, the route table now includes
both admin surfaces from this arc. That test exists to prove every route
is actually registered — a 404 there means the route is missing — and
the two paths added today had no such coverage. Both are in the admin
group, so reaching the 401 is what proves they are wired.

The new service is passed as nil, matching the other optional services
in this call: the test asserts routing, never executes an admin handler,
and constructing a settings service would need a pool round-trip for
nothing.
2026-08-17 00:07:10 -04:00
bvandeusen 30a5ac56ce feat(api): admin endpoints for the re-acquisition policy — #2527
test-go / test (push) Failing after 46s
test-go / integration (push) Canceled after 4m20s
GET/PUT /api/admin/library/reacquisition, so every knob the sweeper
reads is editable without a restart (rule #25). Routed under /library
beside the missing-files list it governs rather than under /lidarr:
Lidarr is the mechanism, but missing files are the problem the operator
came to solve, and that is the surface they meet it on.

The payload carries one thing the settings table doesn't: the count of
albums with missing files that can never be auto-requested, because
neither they nor their artist has an MBID. Nothing can be asked of
Lidarr for a release MusicBrainz cannot name, and a feature that
silently does nothing for part of its input reads as broken -- so the
card states the number instead of leaving it to be inferred. Counted
best-effort: the settings are the point of the endpoint, and failing the
whole card because a count query hiccuped would be the wrong trade.

Range errors come back as 400 naming the field. The Go-side validation
mirrors migration 0056's CHECKs precisely so the operator reads
"grace_hours must be 1-720" rather than a constraint-violation string
surfacing as a 500.
2026-08-17 00:02:41 -04:00
bvandeusen bab9b16831 feat(library): a missing file asks Lidarr for itself, on a backoff — #2527
test-go / test (push) Successful in 1m10s
test-go / integration (push) Successful in 5m56s
Answers the open fork on #2527's last slice: automatic, not a button.
Until now missing_since was a dead end -- reconcile marks it, every
selection path skips it, the admin surface lists it, and there it sits.

Two decisions carry most of the safety, both at the design level rather
than as rate limits bolted on afterwards.

The unit is the ALBUM, not the track. Lidarr acquires releases; there is
no meaningful "fetch me one track", and a track-kind request needs a
recording MBID plenty of files lack. Grouping means the loss that
produced #2523 -- three reorganised albums, ~40 missing files -- becomes
three requests instead of forty. The flood problem mostly dissolves.

And nothing is requested until a file has been missing longer than the
grace window (24h default). A filesystem lies transiently: an unmounted
volume, a container that started before its media mount attached, a NAS
mid-reboot. Every one of those resolves itself well inside a day at no
cost. missing_since is never re-stamped (#2523), so it is a true "gone
since" clock to measure against, not "when we last noticed". This is
the difference between automatic and trigger-happy.

Then the backoff proper: 6h -> 12h -> 24h -> 48h per album, clamped to a
week, three attempts before giving up, and a per-pass ceiling so a
genuinely large loss trickles instead of dumping hundreds of rows into
the queue. Giving up is stamped as a timestamp rather than inferred from
attempts >= max, so the verdict survives an operator later raising the
maximum and the surface can say when.

A sweeper, not a hook inside reconcile. Reconcile runs inside a scan and
has no business deciding to talk to a third-party service; it also
re-runs often, which would make "attempt once, then back off" awkward to
express. A worker paces itself, survives a restart, and retries without
needing another scan. Recovered albums have their state deleted rather
than reset -- a future loss is a new problem, not a continuation.

Requests are attributed to the oldest admin: lidarr_requests.user_id is
NOT NULL and a re-acquisition has no requesting human, so this keeps the
row auditable and in the same queue as everything else without inventing
a synthetic principal the schema would have to understand.

Auto-approve defaults ON. Requests are created pending and nothing
reaches Lidarr until approval, so with it off this would be a
notification rather than an attempt. Lidarr disabled leaves the request
pending rather than counting a failure -- the record of intent is still
right and becomes actionable the moment Lidarr is configured.

Albums with no MBID are counted, not silently skipped: nothing can be
asked of Lidarr for a release MusicBrainz cannot name, and quietly doing
nothing would read as the feature being broken.

Settings are DB-backed per rule #25 with CHECK-guarded ranges, validated
in Go as well so the API answers 400 rather than surfacing a constraint
violation. The admin card and the state on the missing-files page are
next; this is the engine.
2026-08-16 23:53:21 -04:00
bvandeusen 03a8d12079 docs: stop pointing at the deleted Flutter tree — #2710
test-go / test (push) Successful in 2m5s
android / Build + lint + test (push) Successful in 4m40s
test-go / integration (push) Successful in 5m10s
Every comment naming a path in flutter_client/ now resolves to nothing,
which is the failure mode this project has already been bitten by twice
-- drift #572 came from delete.go describing behaviour it no longer had,
and that same docstring was still wrong when it was fixed last week. A
pointer to a deleted directory is the same thing in slower motion: the
reader follows it, finds nothing, and cannot tell whether the comment is
stale or they are looking in the wrong place.

Three treatments, per what each comment was actually doing:

  - Naming a concept ("mirrors db.dart's CachedTracks Drift table"):
    keep the concept, drop the path. The Drift table is why the entity
    looks as it does; the file it lived in is not.
  - Pure port bookkeeping ("Mirrors <path>." and nothing else): deleted.
    Git history records the port; the comment only restated it.
  - Substance introduced by a pointer (a lifecycle list, a 200 px/s
    threshold, an inverted control-row placement): keep the substance,
    drop the lead-in.

The two Go comments were the valuable ones and got more than a trim.
They stated a live contract -- "field names match the client's FromJson
helpers exactly, or fields are silently dropped" -- against a client
that no longer exists. They now name the real consumer,
SyncResponseWire.kt, and say why the failure is silent there too:
kotlinx.serialization skips unknown keys, so a renamed field arrives as
a default value rather than an error.

The ticket counted 64 files by grepping flutter_client/. A second tier
turned up during the sweep: 15 more references naming bare Dart files
(player_bar.dart, now_playing_screen.dart:464, auth_provider.dart) with
no directory prefix. Same dead tree, same treatment, folded in here.

Comments only -- verified no non-comment line is touched in the diff.
2026-08-16 22:40:58 -04:00
bvandeusen 0036f534db chore: delete the Flutter client — #2710
android / Build + lint + test (push) Successful in 4m1s
Superseded by the M8 native Android rewrite. Last touched 2026-05-31,
no workflow has built it since flutter.yml was removed, and rule #22
says a replaced path goes rather than lingering as something a reader
has to work out the status of. 245 files, ~24.6k lines.

Config references go with it: the .gitignore block (and its now-empty
"# Flutter" header), the .dockerignore entry, and renovate's ignorePaths
entry, which was suppressing dependency scanning for a directory that
no longer exists.

ci-requirements.md said ci-flutter "will retire once that directory
goes". It has gone, so the doc now says so -- CI-Runner can drop the
image, and nothing in this repo needs a Flutter toolchain.

One thing is kept rather than deleted: shared/fabledsword.tokens.json.
It lived under flutter_client/shared/ but was never Flutter's property
-- it is the canonical statement of the palette, the only place the
dark, light and flat cohorts are written down together, and
FabledSwordTokens.kt names it as its source of truth. Losing it would
have been collateral damage, so it moves to the repo root with a README
saying what it is and that neither client generates from it. That
comment in FabledSwordTokens.kt is repointed here.

What deliberately does NOT change: `runs-on: flutter-ci` in android.yml
and release.yml. That is a runner LABEL, not a path -- the Android jobs
schedule on it while pulling ci-android:36, per the label/image split
ci-requirements.md documents. Removing it would break scheduling for a
cosmetic win, so the doc now spells that out beside the retirement note.

Left for #2710: 64 files whose comments still name flutter_client/
paths. Sweeping them here would have buried the deletion, and each
needs a judgement -- keep the substance and drop the dead path, delete
pure "ported from" bookkeeping, or leave design rationale that happens
to mention the Flutter build.
2026-08-16 22:32:51 -04:00
bvandeusen bfb6c9acfe style(android): satisfy detekt on the new browse tabs — #2467
android / Build + lint + test (push) Successful in 3m54s
Two findings, both fair:

LibraryScreen was one line over the 60-line cap once Genres and Years
were added to its pager. Split the page bodies into LibraryTabPage, so
the screen is the scaffold and tab bar while the routing table lives on
its own -- adding a tab is now one line there and one label in
LIBRARY_TABS, rather than growing a function that was already at its
limit.

The decade arithmetic used a bare 10 twice. Named it YEARS_PER_DECADE:
floor-to-decade reads as arbitrary without it.
2026-08-16 16:04:22 -04:00
bvandeusen 3eada70aac feat(android): Genres and Years browse axes in the Library — #2467
android / Build + lint + test (push) Failing after 1m22s
#367 shipped genre and year browsing on web only, which left the web
tab bar's own comment -- "mirrors Android's LibraryScreen" -- half
aspirational. Android now has both, straight after Albums, in the same
order the web bar uses.

Server-backed, and that is the one real decision here. Every other
Library tab reads Room, and building these indexes locally was the
obvious move: the cache is a full mirror and carries both genre and
releaseDate. It does not work. /api/library/sync hydrates through
GetTracksByIDs, which has no missing_since filter, and neither
SyncTrackWire nor CachedTrackEntity has a field for it -- so the cache
holds tracks whose files are gone and cannot tell you which, while the
browse index excludes them. A locally-derived index would quietly
disagree with the server's and with the web client, and could offer a
genre that exists only in missing files. Filed as #2704; until it is
resolved these two tabs need a connection, and their empty states say
what they are rather than looking broken.

Genre is a query parameter end to end, never a path segment: "Rock/Pop"
is a real ID3 tag and a slash does not survive a path. That is also why
the drill-down is a second state inside the tab instead of a nav
destination -- a route would have had to carry the label.

Index shapes mirror web because the reasoning was already worked out
there: genres default to count order, since raw tags carry a long tail
of one-offs that A-Z buries the real genres under, with an A-Z chip for
when you already know the name; years group by decade, newest first,
because a flat list of every year in a decades-deep library is a wall
of numbers. Page size matches web's BROWSE_PAGE_SIZE so "Load more (N
left)" steps identically on both.

The orderings and grouping are pure functions, tested: server order left
alone under count sort, case-insensitive A-Z, no mutation of the loaded
state's list, a slashed tag surviving the filter intact, decade
bucketing including the boundary year, and a count label that stays
blank rather than flashing "0 albums" while the first page loads.
2026-08-16 15:59:37 -04:00
bvandeusen d9238ec5be fix(android): notice when a Sonos stops on its own, and get it going again — #2700
android / Build + lint + test (push) Successful in 3m57s
The 2026-08-16 diagnostics show a session that did not stutter so much
as end. In Doze the 1Hz poll freezes -- 14:22:53 and 14:26:14 report
byte-identical snapshots, and that flat 5000ms sonos-vs-local delta is
the last poll's staleness held still, not drift. When the screen came
back on, the first poll in 3.5 minutes found queue track 10 at 113s,
stopped. The Sonos had advanced, played 1:53 of a flac and quit while
the phone slept. The app then reported that faithfully and did nothing
about it, twice more, until the operator noticed.

A UPnP renderer streams autonomously, which is the whole point of
casting and also why a dead stream is invisible: pollOnce read STOPPED,
called applyTransportStopped and returned. Nothing asked "we meant to
be playing -- why aren't we?"

RemoteStallWatchdog asks. It is pure decision state -- no coroutines, no
SOAP -- so the counting, keying and giving-up is testable without a
renderer, and pollOnce just acts on the verdict.

Conservative by construction:
  - STOPPED or an error status only. PAUSED is left alone: that is
    somebody at the Sonos app or a wall controller, and taking the
    transport back off a person is a fight they always lose. A stream
    that dies stops, it does not pause.
  - Only against play intent. A stop we asked for is not a stall.
  - Three consecutive polls must agree. Sonos passes through STOPPED
    between queue items, so one reading would make every track change
    fight itself.
  - Three attempts per track, 5s apart, then give up -- an unplayable
    file must not become an infinite retry loop against a speaker.
  - Resume seeks back to the last position seen while playing, so a
    stream that died 90s in comes back near there, not at zero.

GetTransportInfo now keeps CurrentTransportStatus, which it previously
parsed and discarded. ERROR_OCCURRED is the only unambiguous way to
tell "the stream died" from "somebody pressed stop", since both land in
STOPPED. Absent or unrecognised reads as OK so a quiet renderer is
never mistaken for a broken one.

Giving up reports kind="stalled" through the existing
PlaybackErrorReporter: snackbar for the user, admin-inbox row for the
operator. That kind has been in migration 0032's CHECK whitelist and
labelled on the admin page since the table was built, and nothing had
ever emitted it.

This does not explain WHY the stream died -- see #2700 for the
hairpin-routing lead. It does mean a dropout is a recoverable hiccup
instead of the end of the session.
2026-08-16 12:43:17 -04:00
bvandeusen a31b672b14 test(web): admin nav is nine tabs — #2527
test-web / test (push) Successful in 40s
The tab list is pinned by name and order, so adding Missing files
failed the assertion. That is the test doing its job: the nav is a
deliberate ordering, not an accident, and a new entry should have to
be declared rather than slipping in.
2026-08-16 12:08:42 -04:00
bvandeusen 8d1f2674fd feat(web): admin page for files the library has lost — #2527
test-web / test (push) Failing after 32s
Renders GET /api/admin/library/missing under Admin -> Missing files.
Folder-grouped, because that is the unit an operator decides about: the
case behind #2523 was three reorganised albums, and forty individual
rows hides that it is really three decisions.

Each row leads with the fact that settles whether a missing file is
worth chasing -- "last played 2d ago" against "never played". The group
header carries how many tracks and how long they have been gone.

Read-only. No remove button anywhere: the row, its play history and its
likes survive a file going missing, and the scanner clears the mark by
itself when the file returns (or adopts the row if it returns renamed,
#2528). The page says so in its own copy rather than leaving the
operator to infer it.

Empty state explains the feature instead of the emptiness -- what puts a
row here (moved outside Minstrel, deleted, a drive that didn't mount)
and that rows leave on their own. Someone who has never seen this page
should not have to guess.

Paging follows the house pattern -- plain offset into the factory,
wrapped in $derived so a page change re-creates the query with a new
key. Passing a getter instead would capture the key once and paging
would silently not refetch. The pager only renders when it can do
something.
2026-08-16 12:03:59 -04:00
bvandeusen 845f45fb0b refactor(web): one relativeTime for the triage surfaces — #2527
test-web / test (push) Successful in 40s
Admin quarantine, admin playback-errors and library/hidden each carried
a byte-identical private copy of the same coarse "3d ago / 5h ago /
12m ago / just now" formatter. Writing the missing-files surface would
have made it four, so extract it instead.

They are one concept, not three that happen to look alike: each shows
the age of something an operator is deciding about, and they have to
agree -- a row reading "2d ago" on one screen and "2 days" on another
makes the reader wonder whether the two mean different things.

Three near neighbours are deliberately NOT folded in, because they are
different intents rather than drifted copies:
  - HistoryRow shows a weekday and clock time under a week ("Tue 21:40"):
    for listening history, WHEN you played something beats how long ago.
  - ActiveSessions.when() writes prose ("1 hour ago", "yesterday") and
    falls back to a locale date past 30 days -- a security surface where
    the longer form reads better.
  - PlaylistCard.refreshedLabel() is day-boundary aware and prefixed
    ("Refreshed today"), and already carries a comment saying it is
    deliberately not the m/h-ago style.
Merging any of those would mean forcing one caller's wording onto
another, which is the wrong-abstraction failure, so they stay put.

Tests pin the boundaries the copies never covered: each unit step, that
only the largest whole unit is reported (25h is "1d ago", never
"1d 1h ago"), and that a future timestamp from a skewed client clock
degrades to "just now" instead of rendering a negative age.
2026-08-16 12:01:13 -04:00
bvandeusen aab90a7a39 feat(android): name the missing file behind a greyed playlist row — #2527
android / Build + lint + test (push) Successful in 3m42s
Android was already skipping these by accident: toPlayableTrackRefs
filters on a non-empty streamUrl, and the server stopped emitting one
for a missing file, so they never reached the queue. Correct behaviour,
no idea why -- the row just sat there greyed with the same treatment as
a track deleted from the library, which is a different and permanent
thing.

isAvailable now covers both cases explicitly rather than inferring one
from an empty URL, so every reader (row alpha, click gating, queue
building) gets the same answer from one place. The flag stands on its
own deliberately: a detail fetched before the file went missing can
still carry a stale streamUrl from cache, and that must not resurrect
the row.

The row says which kind of dead it is. A missing file gets "· File
missing" on the subtitle line, because that one can fix itself -- the
scanner clears the mark when the file returns and adopts the row if it
returns renamed (#2528) -- so it is worth telling the user about. A
removed track keeps its bare greyed treatment; there is nothing to act
on once it's gone from the library.

Matches the web treatment landed in 4c49ee2c (rules #23/#27: parity,
not web-only).
2026-08-16 11:57:55 -04:00
bvandeusen 4c49ee2cc6 feat(web): a playlist entry whose file is missing greys out and is skipped — #2527
test-web / test (push) Successful in 33s
The row treatment for a dead playlist entry already existed -- muted
text, no play on click, no drag, no kebab, never "now playing" -- but it
only fired for track_id === null, the track-deleted case. A missing file
kept a live-looking row that failed on click.

The behavioural gate now covers both, and the presentation distinguishes
them, because they mean different things to the person reading the list.
A removed track is gone for good and keeps the strikethrough. A missing
file is a track we still have -- history, likes, the lot -- whose bytes
aren't on disk right now, so it gets an explicit "File missing" and a
title explaining it stays in the playlist and comes back on its own if
the file does. A strikethrough there would claim it was deleted, which
is a lie about a file the scanner may well adopt back tomorrow (#2528).

Skipping routes through playlistTrackToRef, which already returned null
for removed tracks and whose callers already filter nulls. Adding the
unavailable check there means every queue builder -- PlaylistCard,
systemRefetch, the detail page -- skips a missing file without any of
them learning what missing_since is.

Remove stays available on a dead row: the owner must still be able to
take it out of their own list.
2026-08-16 11:55:36 -04:00
bvandeusen 4dd0a58d63 feat(api): admin surface for files the library has lost — #2527
test-go / test (push) Successful in 52s
test-go / integration (push) Successful in 5m21s
The scan has marked missing files since f6d1cf24 and every selection
path filters them out, so they cause no harm -- and are invisible. The
operator found out about the first batch only because an unrelated MBID
backfill logged "no such file or directory" forty times.

GET /api/admin/library/missing reports them, grouped by directory. The
grouping is the whole ergonomic argument: the case that produced #2523
was three reorganised albums, which a flat list renders as forty
unrelated problems and a folder list renders as three decisions.
ListMissingTracks orders by directory so the handler can fold runs
without a map, which also keeps the query's ordering instead of Go's
random map iteration.

Each row carries last_played_at, nullable, because "gone six months,
never played" and "gone yesterday, played 200 times" deserve opposite
reactions and a file path tells you neither. The correlated MAX needs
its ::timestamptz cast or sqlc infers interface{} and the Go layer
loses the type.

Read-only, deliberately. Nothing here deletes: a missing file keeps its
row, its play history and its likes because it may come back, and if it
comes back renamed the scanner adopts it (#2528). The route sits under
/library rather than /tracks so it can't be confused with the
destructive DELETE /admin/tracks/{id} beside it.
2026-08-16 11:48:45 -04:00
bvandeusen c3f3a17c6d feat(library): a missing file stays in the playlist, greyed and unplayable — #2527
test-go / test (push) Successful in 59s
test-go / integration (push) Successful in 4m49s
Every browse, discover and mix query filters missing_since, so a track
whose file vanished disappears from the places Minstrel chooses music.
A playlist is different: the entry is there because the user put it
there, and silently dropping it rewrites their list behind their back.

So playlists keep the row and mark it instead. ListPlaylistTracks now
carries missing_since (still deliberately unfiltered), the service
layer surfaces it as PlaylistTrack.Unavailable, and the wire gains
"unavailable" on each entry.

A missing entry also loses its stream_url. Refusing to hand out a URL
that cannot serve is stronger than trusting every client to honour the
flag, and "stream_url": null is a shape the clients already model --
PlaylistWire.streamUrl is documented nullable for the track-removed
case -- so an older build degrades to "present but not playable" with
no change.

Nothing is deleted here and nothing should be: the row, its play
history, its likes and its taste contribution all survive a file going
missing, because the file may come back (and #2528 will adopt it if it
comes back renamed).

Also corrects two comments that had drifted into lying. delete.go still
claimed the file-gone case was NOT auto-reconciled and told admins to
delete rows by hand -- untrue since f6d1cf24, and that exact staleness
is what produced drift #572. It now says what DeleteTrackFile really is:
the destructive admin action, which CASCADEs play_events and likes, and
is emphatically not the missing-file path. watcher.go claimed the
safety-net scan "covers anything missed"; the walk only covers
additions, and it is reconcile that covers removals.
2026-08-16 11:43:05 -04:00
bvandeusen 20bd7bfaf8 fix(android): let list content reach the MiniPlayer — #2681
android / Build + lint + test (push) Successful in 4m28s
The shell is a Column (content weight(1f), then the bar), so the
content viewport already ends at the MiniPlayer's top edge. But
nothing owned the bottom navigation-bar inset under edge-to-edge:
each in-shell screen's own Scaffold claimed it via the default
contentWindowInsets and padded its content up by the nav-bar height
a second time. That padding is the dead strip the operator sees
between the last list row and the bar — and the bar's own bottom
was drawing under the gesture pill.

ShellScaffold now owns the inset end to end: the content region
consumes it, and a Spacer below the MiniPlayer re-holds the space
for the system bar (unconditional — MiniPlayer renders nothing when
no track is loaded). Modifier.consumeWindowInsets alone can't fix
it: ScaffoldLayout reads contentWindowInsets.asPaddingValues()
directly, outside the modifier consumption chain, so every in-shell
Scaffold is handed the new zero ShellContentWindowInsets. The
full-screen routes (NowPlaying / Queue / Login / ServerUrl) keep the
default — no shell sits above them.

Also drops the hardcoded 140dp bottom contentPadding on Album and
Playlist detail, a Flutter-era value for a player bar that overlaid
its list; here the shell reserves that space in layout already.
2026-08-16 10:40:05 -04:00
bvandeusen aa9f534f3c Merge pull request 'Genre index: sort A–Z, and repair casing damage at scan time' (#124) from dev into main
test-web / test (push) Successful in 49s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 5m1s
release / Build signed APK (tag releases only) (push) Successful in 4m2s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-08-07 21:40:33 -04:00
bvandeusen 8e1d25a772 fix(scanner): repair acronym and apostrophe casing on genre tags — #2468
test-go / test (push) Successful in 53s
test-go / integration (push) Successful in 5m2s
Operator decision: keep the ID3v1 table canonical, fix the casing.

The operator's library carries "Edm", "Idm", "Aor", "Uk Garage", "Uk
Hardcore", "Trap Edm", "Glitch Hop Edm" and "Children'S Music" — an external
tag editor title-cased the whole genre field. The "'S" is the giveaway.

Fixed at SCAN time, not in the display layer: taste_profile.sql reads
tracks.genre directly, so a cosmetic-only fix would leave the taste
vocabulary holding "Edm" while the UI showed "EDM", and any correctly
tagged file would contribute a second, separate tag.

trueUpCasing only ever changes case, never letters, so it cannot silently
turn one genre into a different one — that is what separates it from the
label-remapping idea this task rejected. Two narrow rules:

- A short, evidence-led acronym list, matched case-insensitively so "edm",
  "Edm" and "EDM" all land on "EDM". This is a deliberate exception to the
  project's rule that genre case is exposed as the file says it: "Rock" and
  "rock" still stay separate rows, because folding those is a judgement about
  labels, whereas there is no genre named "Edm".
- Apostrophe suffixes from a FIXED contraction list, so "Children'S" is
  repaired while "O'Brien" and "D'Angelo" keep their capital. A blanket
  "lowercase after an apostrophe" would have broken both.

Matching uses the word's letter core rather than the raw word, so "(Edm)"
and "Edm," are repaired and their punctuation re-attached. Interior
punctuation stays in the core, so "Lo-Fi" and "R&B" are compared whole and
cannot match a fragment by accident. My first version missed this and a test
expecting "(Live EDM)" caught it.

Names resolved from the ID3v1 table are deliberately NOT re-cased, per the
operator's call — entry 40's "AlternRock" stays as the table spells it, with
a test pinning that so a later tidy-up doesn't quietly "fix" it.

tagReadVersion 1 -> 2, so this reaches the existing library on the next scan
rather than new files only. That re-read reuses stored durations, so it costs
tag reads and no ffprobe.
2026-08-07 14:43:36 -04:00
bvandeusen 4509f740f8 feat(web): sort the genre index A–Z as well as by count — #2468
test-web / test (push) Successful in 38s
Operator decision: the taxonomy this task proposed is cancelled. With their
repaired library measured — 391 genres, 90,774 tag applications over ~24,185
tracks, so ~3.7 genres per track — multi-membership already puts each track
under everything it claims, and grouping would add nothing while destroying
real specificity (Neurofunk, Wassoulou, Soukous are not noise).

The task's premise was also wrong. It argued from case variants, "Alt. Rock"
abbreviations and a junk tail; none exist. That apparent mess was the scanner
welding multi-value tags (#2499) plus ghost rows from deleted files (#2523),
both ours, both now fixed. A category system would have papered over both.

So the ask reduces to sorting and search. The search box already existed
(QuickFilter, with its own no-matches state), so this adds only the sort:
count-first by default — the server's order, and the right default since the
head is where you're going — or A–Z for when you can already name the thing
but can't find it among 391 rows. The filtered count now reads "12 of 391"
so a filter's effect is visible.

Client-side only: /api/library/genres is unpaged and already returns the whole
set (~12KB), so neither control needs a round trip or a server change.

Two things the tests pin down:

- The sort COPIES before sorting. With no filter applied the derived list is
  the very array held by the query cache, and Array.sort mutates in place —
  sorting it directly would reorder cached data under every other consumer.
- Count mode passes the server's order through rather than re-sorting. The
  fixture is deliberately not in count order so the test asserts pass-through
  instead of coincidence.

Verified locally: svelte-check 0 errors, 110 files / 788 tests.
2026-08-07 13:11:10 -04:00
bvandeusen 011b4d9a9c Merge pull request 'ci(release): verify a tag release actually shipped its artifacts' (#123) from dev into main
release / Build signed APK (tag releases only) (push) Successful in 3m38s
release / Build + push container image (push) Successful in 1m31s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-08-07 08:32:48 -04:00
bvandeusen a254cb2273 ci(release): close the verify blind spot, check preconditions before the build
Auditing the gating turned up two problems.

verify-release only checked the APK. Because it runs with `always()`, it
runs even when image-release FAILED — so android succeeding while the image
push died would have reported "verified" on a release with no immutable
:vYYYY.MM.DD image. That is exactly half of what was missing when
v2026.08.07 had to be re-cut, so the guard would have caught the incident we
had and waved through its mirror image. Now checks the image too, via
docker manifest inspect.

"Attach APK to gitea Release" resolves the release by tag and fails if it is
absent — but it is the LAST step, so a bare `git push origin vX` built an
APK for several minutes before discovering it had nowhere to put it. Same
check now runs immediately after version computation: seconds, not minutes.
Releases created through the API create tag and release together and pass it.

The rest of the gating audits clean, and one part is worth not "fixing":
image-release's `if: !failure() && !cancelled()` looks odd next to
`needs: [android-release]` but is correct. On main pushes android-release is
SKIPPED, and a skipped dependency is not success() — so the obvious
`if: success()` would silently stop main from ever publishing :latest.
Steps 4/5 vs 6 are mutually exclusive on the tag context, and every image
step gates on the Dockerfile+go.mod guard.

Validated: YAML parses, and `bash -n` over every run: block in all three
jobs is clean.
2026-08-07 08:27:09 -04:00
bvandeusen e368b82f0a ci(release): fail loudly when a tag release ends up without its APK
v2026.08.07 had to be re-cut, and the tag build's android-release job
never started — no job log was written at all, so all eight steps reported
`failure` with none executed and image-release showed `skipped`.

The run was red, but the release PAGE rendered fine and main's own push
build had already moved :latest, so the code was deployable and nothing
looked obviously wrong. What was actually missing — the attached APK and
the immutable :vYYYY.MM.DD image — is easy to skim past, and I nearly did.

This cannot prevent that. The cause was a runner failing to launch a
container, not anything in this file, and it did not reproduce on an
unchanged re-run. What this does is make the CONSEQUENCE legible: an
incomplete release now fails with a named error instead of eight mystery
step failures, and the message says to re-run the run rather than delete
and re-create the tag.

`if: always()` is load-bearing — the job has to report precisely when the
jobs above did not succeed.

Correcting the record while here: I first blamed this on the workflow's
`cancel-in-progress` concurrency block. That was wrong. Cancellation needs
a NEWER run in the same group, and there was exactly one run on the tag
ref (total_count 632 -> 633 on release creation); the main-push runs sit
in a different group. Plausible mechanism, unchecked precondition.

Validated locally: YAML parses, `bash -n` clean, and the asset-parsing
logic unit-checked against a release with an APK, one with no assets, and
one with a non-APK asset.
2026-08-07 08:23:11 -04:00
bvandeusen d5aa081157 Merge pull request 'Recommendation metrics: publish the margin of error on every delta' (#122) from dev into main
test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m10s
test-go / integration (push) Successful in 4m53s
release / Build signed APK (tag releases only) (push) Successful in 3m57s
release / Build + push container image (push) Successful in 1m37s
2026-08-06 21:50:41 -04:00
bvandeusen 304de88c50 test(tuning): assert headers by exact accessible name — #2495
test-web / test (push) Successful in 34s
Third attempt at the same assertion, so I stopped guessing and got vitest
running locally instead: the web lane uses the same ci-go image, so
`docker run ... -w /src/web ci-go:1.26 npx vitest run` works and turns a
5-minute CI round trip into 7 seconds.

/^Skip/ matched the "Skip rate by week" sparkline column as well as
"Skip (last wk)", just as /Plays/ had matched the caption. Exact names
say what the assertion means and cannot drift onto a neighbour.

Verified locally before pushing: svelte-check 0 errors, 110 files /
786 tests pass.
2026-08-06 21:27:22 -04:00
bvandeusen 96abb48086 test(tuning): query the window/last-week headers as column headers — #2495
test-web / test (push) Failing after 33s
getByText(/Plays/) matched my own new caption as well as the header, since
the caption explains which columns cover the window. Query by columnheader
role instead, which is what the assertion actually means.

Also reordered the caption: prepending the clarification turned it into a
run-on that opened mid-explanation before saying what the chart was.
2026-08-06 21:20:18 -04:00
bvandeusen a094d5f8b0 test(metrics): target deltas by test id, not by glyph — #2495
test-web / test (push) Failing after 35s
Two CI failures, both in my own new tests, both informative.

settings: queryByText(/≈/) matched the LEGEND explaining the glyph rather
than a delta, so the "no delta" case failed on the explanation being
present. Delta spans now carry data-testid so a test can name what it
means instead of pattern-matching prose that sits next to it.

tuning: getByText("40%") found two elements. testing-library matches an
element and its OWN direct text nodes, so the skip cell still matches
"40%" despite the trailing play-count span — and discover late-week
completion is also 40%. Genuinely ambiguous now; assert the count.
2026-08-06 21:14:53 -04:00
bvandeusen 481f906059 feat(metrics): publish margin of error on every delta — #2495, #2524
test-web / test (push) Failing after 43s
test-go / test (push) Successful in 1m0s
test-go / integration (push) Successful in 4m58s
The metrics card had one volume threshold doing two jobs.
recMetricsLowVolume = 20 is a DISPLAY floor — below that a skip rate is
anecdote — but the card then presented deltas as though it were also a
DECISION floor. Those differ by an order of magnitude: detecting the ~13pp
differences that matter needs ~133 plays per arm for 80% power at a=0.05.

So Discover's taste-matched (59 plays) and random-unheard (70) both rendered
as full-confidence rows with a bold delta beside them, and that comparison
sits at p ~ 0.06. The card said "signal"; the arithmetic said "maybe". It
produced a recommendation the data didn't support, and any reader with the
same numbers would have made the same call.

Deltas now carry a 95% margin of error and a `distinguishable` flag, computed
server-side so both clients read the same arithmetic instead of each
re-deriving it. Skip rate is a two-proportion difference; completion is
Welch, which needs a variance — hence completion_sqsum in the query. It is
the sum of squares rather than stddev_samp on purpose: raw source rows are
merged into surface families in Go, and sums of squares combine across groups
exactly whereas standard deviations cannot.

recMetricsLowVolume is untouched. "Too thin to show" and "too thin to act on"
are different questions.

Web renders an indistinguishable delta as dimmed and prefixed "≈", with the
range on hover and a legend explaining the glyph. Colour is withheld unless
the delta clears its margin — colouring noise red is what made the old card
misleading. Breakdown rows go through the same path; those are the thinnest
samples on screen and where the old card misled most.

Also fixes the admin trends view, which had the same problem worse: its
"Latest skip"/"Latest completion" columns are one WEEK while the adjacent
Plays column is the whole window. I misread exactly that and briefly
concluded Deep cuts was the worst surface, from ~17 plays in a single week —
over 180 days it is one of the best. Headers now name their period and the
skip cell carries that week's play count.

#2524: resolveArtist now recognises a duplicate-MBID unique violation as the
expected condition it is, matching resolveAlbum. Two rows mapping to one
MusicBrainz artist is a merge candidate, not a fault; without the branch it
logged a generic warning plus a Postgres ERROR line on every scan, which
teaches an operator to ignore database errors.
2026-08-06 21:08:58 -04:00
bvandeusen a99f855e98 Merge pull request 'Missing files: detect them, stop offering them, and follow them when they move' (#121) from dev into main
test-go / test (push) Successful in 56s
test-go / integration (push) Successful in 5m0s
release / Build signed APK (tag releases only) (push) Successful in 4m14s
release / Build + push container image (push) Successful in 15s
2026-08-06 20:40:39 -04:00
bvandeusen 24d330424f feat(library): adopt moved files instead of forking their history — #2528
test-go / test (push) Successful in 52s
test-go / integration (push) Successful in 5m0s
Track identity was file_path, so a file that came back renamed or in a
different directory looked like a deletion plus an unrelated new track:
the old row kept the like and every play_event while a fresh zero-history
row appeared, and nothing connected them. A liked song read as unliked, its
play count reset, and Rediscover could offer it as a discovery — silently.
Renumbering an album was enough, which is what happened to the operator's
copy of Minutes to Midnight.

Adoption re-points the existing row's file_path at the new location and
clears its missing mark. The normal UpsertTrack then conflicts on file_path
and updates THAT row, so the track id survives and likes, plays and
playlist memberships travel with it — and clients see an update rather than
a delete-and-create, so no cache churn either.

Matching is MBID first (identifies the recording, so it survives a
re-encode), then file_size + duration_ms for untagged files. Both
fingerprint components must be non-zero: duration_ms is 0 when ffprobe
failed, and matching 0 against 0 would pair up unrelated broken files.
Only rows already marked missing are eligible — a row whose file is present
elsewhere is a duplicate, not a move, and re-pointing it would corrupt the
copy that still exists. An ambiguous match inserts fresh rather than
adopting one arbitrarily: a fork is recoverable later, a wrong merge isn't.

Scan is now three phases, and the order is the point. Adoption can only
claim a row that is ALREADY marked missing, but reconcile previously ran
after processing — so a rename performed while the server was down surfaced
the deletion and the addition in the same scan, the new path inserted first,
and the fork became permanent. Enumeration is therefore separated from
processing so reconcile can run between them: walk (paths only, no tag
reads or probes) -> reconcile -> process in walk order.

Consequence worth knowing: when reconcile refuses (an absent root, or a
reorganisation exceeding the 25% mark cap) adoption cannot fire and renamed
files fork as before. That's the pre-#2528 behaviour rather than a new
failure, and the warning now names it.

The old outer walk-error branch was unreachable — the callback always
returned nil, so WalkDir never surfaced an error — and verifyRootsPresent is
the real protection, so enumerate counts walk errors instead of pretending
to abort on them.
2026-08-06 15:56:07 -04:00
bvandeusen f6d1cf24f0 feat(library): detect missing files and stop offering them — #2523
test-go / test (push) Successful in 1m0s
test-go / integration (push) Successful in 5m10s
Nothing in Minstrel ever noticed a deleted file. The walk only visits
paths that exist, so a row whose file was gone was never scanned, never
errored, never counted — permanently invisible. classifyEvent ignores
fsnotify removals by design, and the safety-net scan is the same walk, so
it covers additions only. Rows accumulated forever.

Found on the operator's library: a completed scan reported
skipped=24185 errored=0 while the MBID backfill (which opens files by DB
path rather than walking) logged ~40 "no such file or directory" across
three reorganised albums. Those rows also kept their pre-#2499 welded
genre, which is how this surfaced — the version-stamped tag re-read can
only reach files the walk visits.

The harm is not cosmetic. tracks is the candidate universe for
recommendation.sql / discover.sql / system_mixes.sql and nothing filtered
on file existence, so a mix could spend a slot on a track that cannot
stream.

Marks rather than deletes. A missing file is a claim about the filesystem
and the filesystem lies transiently — an unmounted volume, a network
blip, a container that started before its media mount attached. Every
sweep in internal/gc resolves a truth INSIDE the database and is safe to
run blind; this one is not, so no deletion happens here. Three guards
refuse to act on ambiguous evidence: every scan root must resolve to a
non-empty directory, the walk must have seen at least one file, and one
reconcile may newly mark at most 25% of the library. Clearing a mark is
never the dangerous direction, so it runs unconditionally — otherwise a
library that tripped the cap could never recover once the mount returned.

Only a full Scan reconciles. The walk's set of seen paths is the
evidence, and ScanFiles has no basis for concluding anything about files
it did not look at.

Excludes marked tracks from all 13 track-emitting queries (radio x2,
system mixes x5, discover x4, most-played x2), the 6 play-history seed
picks, and the genre browse axis. Deliberately NOT filtered: the shared
ListPlaylistTracks read path, because it also serves user-curated
playlists where hiding a track the user added would be wrong — system
playlists shed orphans on their next daily rebuild instead. History and
the taste profile also keep them: those record the past, and a track you
played 200 times still says something about your taste.

Reconcile tallies land in scan_runs so a disappearance is visible rather
than discovered when a mix comes up short.
2026-08-06 14:34:53 -04:00
bvandeusen 7e4727fc49 Merge pull request 'Genre tags: read multi-value frames correctly, and repair existing rows' (#120) from dev into main
test-go / test (push) Successful in 57s
test-go / integration (push) Successful in 4m57s
release / Build signed APK (tag releases only) (push) Successful in 4m23s
release / Build + push container image (push) Successful in 1m39s
2026-08-05 22:10:41 -04:00
bvandeusen fd27819cdd style(scanner): tagged switch on ID3 major version — #2499
test-go / test (push) Successful in 55s
test-go / integration (push) Successful in 4m55s
2026-08-05 21:22:53 -04:00
bvandeusen 37b396a7e4 fix(scanner): read multi-value genre frames correctly — #2499
test-go / test (push) Failing after 41s
test-go / integration (push) Canceled after 4m46s
dhowden/tag's readTFrame splits ID3v2 null-separated multi-value text
frames and rejoins them with the EMPTY string, so a file tagged
"Alternative Rock" + "Rock" was stored as "Alternative RockRock". It also
leaves bare numeric ID3v1 references unresolved, which is why the
library showed genres like "4017" and "526617".

This corrupted more than the browse axis added in #367: taste_profile.sql
reads tracks.genre directly, so the welded tokens were entering the taste
profile's tag vocabulary, and recommendation.sql/discover.sql were
comparing them as single opaque tags. Genre counts were wrong everywhere.

ffprobe is not a fix — ffmpeg's read_ttag calls decode_str once with no
loop, keeping only the first value. Truncating multi-genre tags would
blunt the similarity signal genre mainly feeds. So the TCON frame is now
parsed directly (ID3v2.2/2.3/2.4, all four text encodings, per-frame and
tag-level unsynchronisation, numeric and parenthesised ID3v1 references);
everything else still comes from dhowden/tag. Values are stored
";"-delimited, which the read side already splits on, so no query changes.

Existing rows are repaired without an operator-run rebuild: migration
0054 adds tracks.tag_read_version DEFAULT 0, below the scanner's current
tagReadVersion, so the next scan re-reads tags it would otherwise skip on
mtime. Such a re-read reuses the stored duration instead of re-running
ffprobe, keeping a repair pass tag-read-bound rather than one fork+exec
per file. Bumping the constant is how a future extraction fix reaches an
existing library.

Only ID3v2 is in scope — dhowden welds nowhere else. The Vorbis/MP4
repeated-field question is #2500, unproven and deliberately not built.
2026-08-05 21:17:59 -04:00
bvandeusen 1b7fa635d8 Merge pull request 'Silent self-update, active sessions with real client IPs, genre/year browsing, handoff fix' (#119) from dev into main
test-web / test (push) Successful in 1m3s
test-go / test (push) Successful in 1m13s
test-go / integration (push) Successful in 5m29s
android / Build + lint + test (push) Successful in 5m34s
release / Build signed APK (tag releases only) (push) Successful in 5m5s
release / Build + push container image (push) Successful in 16s
2026-08-05 15:14:48 -04:00
bvandeusen 78aa9befb6 fix(connectivity): probe on foreground; a burst can't corroborate ServerDown — #1209
android / Build + lint + test (push) Successful in 4m12s
Two changes so a network handoff stops making the app refuse to play music.

## Correction first: half of what I proposed already existed

I recommended "require corroboration before ServerDown, since Unstable is
non-gating." ReachabilityMachine has done exactly that since it was written —
onProbeFailure takes Reachable → Unstable, and escalates only on corroboration
or the 120s backstop. There is even a test named `single probe failure is
unstable not down`. I proposed building a thing that shipped months ago.

Reading the machine properly turned up the real gap, which is narrower and more
specific.

## 1. Probe when the app returns to the foreground

The genuine missing piece, and #1209's own note had it backwards: it listed
this as "already happens via link probe." It doesn't. `recheck()` had exactly
two callers — a button in VersionTooOldBanner and pull-to-refresh — and nothing
observed ProcessLifecycleOwner. The link probe fires on a connectivity
*change*, so an app backgrounded on stable Wi-Fi gets none.

That made a stale ServerDown outlive its cause: the poll loop's delay() is
throttled while screen-off/doze, so recovery waited for whenever the OS next
let the loop run. June's capture recovering at "EXACTLY 22:31:10 app_foreground"
was the throttled delay resuming, not a deliberate probe — same timestamp,
different mechanism, and that difference is the whole bug.

NetworkStatusController now implements DefaultLifecycleObserver and calls the
existing recheck() on ON_START. force = true, so it also bypasses
ARBITRATE_MIN_GAP_MS: a user opening the app is exactly when a stale banner and
a refused track are most visible, and it's once per foreground.

## 2. A burst of op failures no longer corroborates itself

The actual defect in the escalation path. Corroboration required 2 op failures
within 30s — but a link handoff fails every in-flight request at once, so a
burst is ONE event producing N failures, not N independent observations that
the server is gone. Two simultaneous failures walked straight to Unreachable.

onOpFailure now drops a failure landing within CORROBORATION_MIN_SPACING_MS
(3s) of the last recorded one. Above the sub-second window a handoff occupies,
low enough that a real outage still corroborates within seconds once anything
retries.

## Why this matters more than the task implied

#1209 called the follow-ups "cosmetic in the diagnostics". They aren't.
OfflineGatedDataSource.gateOnHealth() throws OfflineException on ServerDown
BEFORE touching the network, and TrackRow disables rows. So a spurious
ServerDown means the app declines to play uncached tracks that would play
fine — for a blip that already resolved. The note's "captured skips advanced
fine" was timing luck, not evidence the gate is harmless.

## Tests

`two op failures plus a failed probe escalate immediately` used timestamps
500ms apart, which the new rule treats as a burst — so I re-spaced it and
renamed it `two SPACED op failures...`. That's a deliberate reversal of an
encoded expectation, not a broken test being patched.

Also re-spaced `stale op failures do not corroborate` (used 0 and 1_000): left
alone it would still have passed, but for the wrong reason — burst-dropping
rather than staleness — and a test that can't fail for its stated reason is
worse than no test.

Added: a burst of four failures plus a failed probe stays Unstable, and a burst
that never recovers still escalates via the sustained backstop, so dropping
duplicates can't make a real outage undetectable.

The foreground hook itself is unverifiable in a JVM test (ProcessLifecycleOwner
needs the framework, and there's no instrumentation lane). Checked instead that
nothing constructs NetworkStatusController outside Hilt, so init's
ProcessLifecycleOwner.get() only runs on the main thread during
Application.onCreate — the same pattern LiveEventsDispatcher already uses.
2026-08-05 14:41:48 -04:00
bvandeusen a9ca49dc4e feat(library): genre + year quick-jumps on album and artist detail — #367
test-web / test (push) Successful in 44s
test-go / test (push) Successful in 1m1s
test-go / integration (push) Successful in 4m59s
Last bullet of #367. From an album you like, one click to everything else from
that year or in that genre.

Year was free — AlbumRef already carried it. Genre was not: AlbumDetail is
AlbumRef + tracks and neither carried genre, because genre lives on TRACKS. So
both detail responses gained a derived `genres` array, computed from the
entity's tracks rather than stored, since an album's tracks can legitimately
disagree about genre.

Split and trimmed identically to the browse index. That's the invariant this
whole task turned on: if the chip's matching diverged from the index's
splitting, a chip would lead to a page that doesn't contain the album you
clicked from.

No year link on artist detail. An artist spans many years, so a single one
would be a lie about the discography — genres only there.

Genre lookup failure is logged and degrades to no chips rather than failing the
request; a navigation nicety must not 404 a detail page that otherwise loaded.
`genres` is always an array at JSON, never null, matching how every other list
field in this package is emitted.

## Type widening, and the TypeScript version of a lesson from earlier today

Adding a required field to AlbumDetail/ArtistDetail breaks every typed fixture
that constructs one. Six of them across three test files. That's the same shape
as the Go signature changes that cost three CI rounds in #2453 — change a type,
then go find everything that builds it — so I searched for the constructions
before pushing instead of after. All six updated.

Tests: the encoded href for a slash-bearing genre ("Rock/Pop" →
?g=Rock%2FPop), the year href, and the no-tags case rendering no chips at all.
gofmt verified clean via docker rather than guessed.
2026-08-05 13:50:29 -04:00
bvandeusen feb1c2eca8 feat(web): genre and year browse pages — #367
test-web / test (push) Successful in 33s
Client half of #367. Two new Library tabs, each an index plus a drill-down.

Genres are ordered by track count rather than alphabetically. Raw ID3 carries a
long tail of one-off tags, so alphabetical would bury the handful of genres you
actually have a library's worth of. Years are grouped into decades — a flat
list of every year in a decades-deep library is a wall of numbers, and the
decade is how people actually think about it.

## Selection travels in the query string, not the path

`?g=Rock%2FPop`, not `/library/genres/Rock%2FPop`. A slash-bearing genre cannot
survive a path segment — the server sees two segments, and a hard reload
wouldn't reconstruct it through the SPA fallback either. There's a test pinning
the encoded href and another pinning that the DECODED value reaches the API.

## Why these two pages don't use svelte-query for their lists

The indexes do — fetched once per mount, so static options suffice and the
cache survives bouncing in and out of a drill-down.

The drill-down lists deliberately don't. Their selection comes from the URL and
changes WITHOUT remounting the page, and this codebase has no
reactive-query-options pattern anywhere; inventing one here would be a larger
change than the feature justifies, and one I can't exercise locally. So they
use $effect keyed on the derived selection with an explicit Load more.

The stale-response guard is a plain `let`, not $state, and that's load-bearing:
as reactive state, reading the token inside the fetch path would make the
effect depend on its own writes. Its job is to discard a late response for a
previously selected genre instead of painting it over the current one.

## Also

Added the year filter to /library/albums' contract but NOT to that page's UI —
its infinite scroll is a svelte-query infinite query, and making it react to a
filter is the same reactive-options problem. The dedicated pages cover the
capability, which is the shape the task offered as its alternative.

Library tab bar's comment claims it mirrors Android's LibraryScreen. These two
tabs have no Android equivalent, so I noted that inline rather than leaving the
claim quietly false. Parity remains an open call.

Not yet done from #367's bullet list: genre/year quick-jump links on album and
artist detail. Year is free (AlbumRef already carries it) but genre is exposed
nowhere client-side — AlbumDetail is AlbumRef + tracks, and neither carries
genre — so it needs a small API addition. Following as its own commit.
2026-08-05 13:41:51 -04:00
bvandeusen f8f2273aec style: gofmt alignment in library_browse_test — #367
test-go / test (push) Successful in 55s
test-go / integration (push) Successful in 4m58s
One space. `name:` had to align with `query:` inside a composite literal where
both sat on their own lines.

Found via `docker run golang:1.25-alpine gofmt -l`, which is the actual point
of this commit: gofmt is available here the same way sqlc is, and there is no
reason to have let CI discover a formatting nit. Whole tree verified clean, not
just this file.

The substance of 1126bfcf was already sound — verify-generate, vet and the full
integration suite passed, so the genre-splitting behaviour holds against a real
database. Only the formatter objected.
2026-08-05 13:29:28 -04:00
bvandeusen 1126bfcf78 feat(library): genre + year browse queries and endpoints — #367
test-go / test (push) Failing after 46s
test-go / integration (push) Successful in 5m0s
Server half of #367. Web UI follows.

Genres are exposed AS-IS per the operator: split on the delimiter, trimmed,
but no case folding and no synonym mapping. So "Rock" and "rock" appear as
separate rows, as does "Rock/Pop" alongside "Rock" and "Pop". The raw spread
has to be visible before anyone can judge whether it needs normalising, and
the alternative is a mapping table to invent and then maintain.

Trimming is not an exception to that. Splitting "Rock; Pop" yields " Pop", and
showing that as a genre distinct from "Pop" would be a bug in OUR splitting,
not fidelity to the operator's tags.

## The correctness trap this had to avoid

ListAlbumsByGenre compared tracks.genre verbatim, while recommendation.sql and
discover.sql have always split it on [;,]. Building the browse index by
splitting while matching exactly would have listed genres whose pages are
empty — every multi-genre track unreachable from either of its genres.

So ListAlbumsByGenre now splits too. That also fixes Subsonic
getAlbumList?type=byGenre, its only caller, which silently missed every
multi-genre track. Its Genre param went *string → string as a result.

EXISTS rather than JOIN + DISTINCT ON throughout: the lateral split emits one
row per (track, fragment), so a join multiplies rows per album and needs
DISTINCT to undo itself. EXISTS asks the question directly, and the count
query then matches the list query by construction rather than by coincidence.

## Genre is a query parameter, not a path segment

Because "Rock/Pop" is a real ID3 tag — the one the task itself cites — and a
slash cannot survive a path segment: Go normalises %2F and the router would
split the value in two. So filtering rides GET /api/library/albums?genre=,
which also reuses the existing paged album surface instead of adding a
parallel one.

Endpoints:

  GET /api/library/genres                          unpaged index + track counts
  GET /api/library/years                           unpaged index + album counts
  GET /api/library/albums?genre=                   filtered page
  GET /api/library/albums?year_from=&year_to=      filtered page, either edge open

The indexes are unpaged deliberately: a client needs the whole set to render a
browsable picker, and paging would let it show only a prefix of an ordering
the user didn't choose.

Two refusals rather than guesses: genre+year together is a 400 (the UI browses
them as separate axes, and quietly dropping half a filter would report a
narrower result than it returned), and an inverted year range is a 400 rather
than being silently swapped.

Undated albums are absent from the year axis rather than bucketed under 0 —
"unknown" is not a year, and a 0 row would sort to one end of a chronological
list looking like data.

Tests: parseYearFilter is pure and runs in the fast lane. The integration
tests assert the thing that would otherwise be silently broken — that a
"Rock;Pop" track is reachable from BOTH genres, that "Rock/Pop" survives as a
filter value, that fragment whitespace is trimmed, and that undated albums
stay out of every year range. Reused the existing seedAlbum/seedTrackWithGenre
fixtures, which already took exactly the year and genre arguments needed.
2026-08-05 13:22:30 -04:00
bvandeusen 5b36d79ff9 fix(server): access log reports the real client, not the proxy — #2453
test-go / test (push) Successful in 1m3s
test-go / integration (push) Successful in 5m9s
Closes the disagreement left open by #2453: requestlog.go logged raw
r.RemoteAddr while the Active-sessions surface resolved through the operator's
configured proxy depth. Behind a proxy — the normal deployment for anything
public — every access-log line carried the same useless proxy address, and the
two surfaces contradicted each other about who connected. Logs and UI
disagreeing is worse than either being wrong alone, because it costs you trust
in both.

`remote` now holds auth.ClientIP(r, hops). The attribute KEY is deliberately
unchanged so existing log greps keep working; only its accuracy improved.

Wiring note. The access log covers /healthz and the SPA, so it's registered
before the pool-bearing branch that used to build the settings service. Rather
than close over a variable reassigned later — which works, but leaves a
mutable-after-registration seam and an awkward question about races — I hoisted
netsettings.New above the router entirely. It already handles a nil pool by
returning a default-valued service, so no branch is needed and the accessor
stays a plain method value.

Applied the lesson from the last three CI failures BEFORE pushing this time: a
bare-identifier grep for `requestLog(` found three call sites in
requestlog_test.go that a qualified pattern could never have matched, since
the function is package-private and its tests are in-package. Also swept
netsettings.New and ClientIP the same way.

Tests: the behaviour change gets its own table — nil accessor and depth 0 log
the socket peer, depth 1 through a PUBLIC-addressed proxy logs the client
(the exact case the old heuristic got wrong forever), depth 2 reaches through
a CDN. Added `remote` to the required-keys assertion so the attribute can't
quietly disappear.
2026-08-05 13:02:36 -04:00
bvandeusen 11538095be fix(net): validate hop range before checking availability — #2453
test-go / test (push) Successful in 54s
test-go / integration (push) Successful in 5m1s
TestSetHops_RejectsOutOfRange caught a real ordering bug in code I wrote in
the same commit: the nil-pool guard sat ahead of the range check, so
SetHops(-1) on a service with no pool returned "network settings unavailable"
instead of ErrHopsOutOfRange.

Range first is correct, and the distinction is user-visible rather than
cosmetic: the argument is invalid regardless of whether the database is
reachable, and admin_network.go maps ErrHopsOutOfRange to 400 while anything
else becomes 500. The old order blamed the server for the caller's input.

Note this is the first failure in this sequence that wasn't a missed call
site — vet and golangci-lint both passed, and a test asserting a specific
sentinel error found it. Worth the extra assertion; `err != nil` would have
passed happily.
2026-08-05 10:27:10 -04:00
bvandeusen d5ab3b0764 fix(net): update the in-package Mount call site in library_test — #2453
test-go / test (push) Failing after 57s
test-go / integration (push) Failing after 4m57s
Third attempt at the same class of mistake, so worth naming precisely.

TestRoutesRegisteredInMount calls Mount() from INSIDE package api, so the
call reads `Mount(...)` unqualified. My verification grep was `api.Mount(`,
which cannot match it. Same shape as the previous failure, where I grepped
`auth.ClientIP(` and missed nothing — but only because those callers happened
to be in other packages.

The lesson generalises: after changing an exported signature, search for the
bare identifier, not the package-qualified form. In-package callers — which
in Go means most tests — are invisible to the qualified pattern.

This time I swept every signature I touched (Mount, RequireUser, ClientIP,
TouchSessionLastSeen) with an unqualified pattern before pushing, rather than
letting CI enumerate them one per run.

Passing h.netSettings (nil in test handlers) is deliberate, not a placeholder:
this test asserts route registration, and Hops() is nil-safe by design so the
middleware reads "trust nothing" rather than panicking.

Also gave netsettings' logger field a use — it was assigned and never read,
which staticcheck's unused pass can flag. A hop-count change alters how much
of a client-supplied header the server believes, so it earns a log line for
anyone later debugging odd addresses in the sessions list.
2026-08-05 10:21:16 -04:00
bvandeusen a07fb3867a fix(net): thread hops into session creation; disambiguate card tests — #2453
test-web / test (push) Successful in 42s
test-go / test (push) Failing after 43s
test-go / integration (push) Failing after 4m22s
Two CI failures from 381e9ced, both mine.

**Go (vet, which cascaded into the integration job).** Widening
auth.ClientIP to take a hop count, I updated the middleware that TOUCHES a
session but missed the two places that CREATE one — handleLogin and
handleRegister. So `created_ip`, the frozen origin address that the whole
"address changed" comparison rests on, was the one value still being
computed the old way. Both now read h.netSettings.Hops(), which is nil-safe
so test handlers constructed without the service still work.

Worth noting the shape of this miss: I checked call sites by searching for
the middleware's own usage and stopped there, rather than for every caller of
the function whose signature I changed. vet found it in seconds; a grep for
`auth.ClientIP(` would have too.

**Web (vitest).** Three tests waited on `findByText('198.51.100.7')`, which
matches TWO elements in the fixture — the detected client address and the
forwarded chain, identical strings for a single-proxy setup — and findByText
throws on multiple matches. Now they wait on the unique "Your address right
now" label and assert the address with getAllByText where duplication is
legitimate. The duplication is correct behaviour, so the test moved rather
than the component.
2026-08-05 10:14:38 -04:00
bvandeusen 381e9cedb7 feat(net): trusted-proxy depth so real client IPs survive a proxy — #2453
test-go / test (push) Failing after 50s
test-web / test (push) Failing after 50s
test-go / integration (push) Failing after 2m19s
Fixes the defect the operator spotted in #370 immediately after it shipped:
auth.ClientIP ignored X-Forwarded-For whenever RemoteAddr was public, so a
proxy on a public address — a separate host, or a CDN, i.e. anyone running
this publicly, since public means TLS means a proxy — recorded the PROXY for
every session. created_ip and last_ip were then always equal and the
"Address changed" signal could never fire. The feature looked like it worked
and reported nothing.

Replaced with the standard trusted-hop model (Rails, Caddy, Traefik, nginx).
XFF grows left-to-right as each proxy appends the peer it received from, so
for client -> CDN -> own-proxy -> app the app sees [client, CDN] with
RemoteAddr = own-proxy, and the client sits at XFF[len - hops]:

  0  RemoteAddr, XFF ignored — no proxy
  1  the address your own proxy observed
  2  through a CDN in front of your proxy

Default 1, per the operator: publicly reachable means a TLS terminator in
front.

The cost is real and stated rather than hidden. hops >= 1 DECLARES that a
proxy exists; set it with no proxy, or deeper than the actual chain, and the
index reaches attacker-supplied entries, letting a visitor choose which
address their own session shows — defeating exactly the detection #370 is
for. That's inherent to the model, which is why 0 is a first-class value and
the admin card says "count your proxies, don't guess high" instead of just
exposing a number. Both mis-set shapes are pinned by tests so they stay known
consequences rather than surprises.

Migration 0053 + internal/netsettings, cached under an RWMutex. That's not an
optimisation: ClientIP runs in RequireUser for every authenticated request, so
a per-request query would put the database on the critical path of the whole
API. New() always returns a usable service so a boot-time DB hiccup degrades
to the default instead of breaking that path (rule #131), and Hops() is
nil-safe because test routers construct middleware without it.

RequireUser now takes a func() int rather than an int — the value is
operator-editable at runtime while the middleware is built once at boot, and
reading it per request is what makes a save take effect with no restart
(rule #25).

The admin card is verifiable, not just configurable: it reports the address
the CURRENT setting resolves THIS request to, the raw forwarded chain, and the
socket peer — so you set the number, save, and confirm the address matches the
machine you're on. It also counts the arriving chain and says how many proxies
that implies. GET/PUT both return that payload, PUT recomputed under the new
value, so the effect is visible without a reload.

Also fixes styling in the #370 card that CI could not catch: text-destructive
and bg-destructive don't exist in this Tailwind config — the palette is
colors.action.destructive — so the "Address changed" warning and the
sign-out-others button were rendering unstyled. Both now use
text-action-destructive / bg-action-destructive / text-action-fg.

Not done here: requestlog.go still logs raw RemoteAddr and will disagree with
the sessions UI about who connected. Left for its own change.
2026-08-05 10:07:43 -04:00
bvandeusen bf649f3beb feat(web): active sessions card in Settings — #370
test-web / test (push) Successful in 32s
Client half of #370. Lists every device signed in to your account, with a
per-row sign-out and a "sign out all other devices" action.

The card does one thing the API alone doesn't: it says "Address changed" when
created_ip and last_ip differ, rather than printing two addresses and leaving
you to compare them. That mismatch — same device string, different origin —
is the shape of a stolen token, and it's the reason IP capture was worth a
migration. Making the operator spot it by eye would have wasted the data.

Placed with Password and API Token rather than at the bottom of the page:
those three are the account-security group, and this is the one that tells
you the other two need attention.

Details worth naming:

- The current session gets a "This device" badge and NO sign-out button —
  offering one would log you out of the page you're standing on. The server
  already excludes it from logout-others; this makes that visible.
- Sign-out-all-others is a two-step confirm and states the count, so the
  button can't be a surprise.
- A 404 on revoke reloads instead of erroring. It means the session is
  already gone — revoked elsewhere, or expired — so the list was simply
  stale and showing the truth is the right response. The code is
  `session_not_found`, not `not_found`: apierror.NotFound(what) prefixes it.
- Empty and error states both handled (rule #24); the empty case is
  practically unreachable since listing requires an authenticated request,
  and is handled rather than assumed.
- User-agent parsing is deliberately coarse. A real UA parser is a
  dependency and a maintenance burden for a string whose only job is "do you
  recognise this?" — the addresses carry the actual signal.

Tests cover the parts that would be quiet if broken: the current-session
badge suppressing its own sign-out button, the address-changed warning
appearing and NOT appearing, the two-step confirm not firing on first click,
and the load-failure retry.

Android parity is a separate decision, not assumed.
2026-08-05 09:25:20 -04:00
bvandeusen d86af7397d feat(auth): active sessions API with origin/current IP — #370
test-go / test (push) Successful in 55s
test-go / integration (push) Successful in 4m53s
Server half of the active-sessions surface. Web UI follows.

The operator wants this specifically to notice a compromised account, which
sets the bar: the addresses have to be trustworthy, or the feature is worse
than absent because it looks like evidence.

Migration 0052 adds created_ip + last_ip. Two columns, not one, and the pair
is the signal: a session issued at home and now being used from elsewhere is
the shape of a stolen token, and neither column alone can show that. Typed
text, matching the user_agent column beside it — these are displayed, never
queried by subnet, and inet round-trips through pgx as a netip.Prefix that
renders "1.2.3.4/32".

The rest of the schema was already waiting. Migration 0004 anticipated this
exactly: "last_seen_at enables an 'active sessions' UI later (not wired in
this plan) without schema churn." last_seen_at is live data — the auth
middleware already touches it per request — so last_ip rides that same
UPDATE for free.

Getting the address right is the substance here. Nothing extracted a client
IP anywhere before, and both obvious approaches are wrong:

- RemoteAddr alone shows the reverse proxy on every session, which is the
  normal self-hosted deployment. Noise shaped like data.
- Trusting X-Forwarded-For lets any client choose what its victim sees. A
  security surface an attacker can write to is worse than none.

So auth.ClientIP trusts the header only when the request actually arrived
from a proxy range. Public RemoteAddr means a direct connection, so XFF is
attacker-controlled and ignored outright. Private RemoteAddr means we walk
XFF right-to-left — proxies append, so the right end is what our own
infrastructure wrote — and take the first non-proxy address. A forged XFF
only prepends to the left end, which that walk never reaches. Unit-tested,
including both spoofing shapes.

Fails closed on a public-addressed proxy (separate host, CDN): we report the
proxy rather than trusting a forgeable header. Documented at the function.

Endpoints, all scoped by user_id per rule #47:

  GET    /api/me/sessions                → list, flagging the current row
  DELETE /api/me/sessions/{id}           → 204, or 404 if not yours
  POST   /api/me/sessions/logout-others  → {"revoked": n}

Keyed on session id alone, any household member could revoke another's
session by guessing a uuid, so the delete carries user_id in its WHERE and
:execrows distinguishes "not yours" (404) from a false 204. There's a test
that asserts the row actually survives, not merely that we returned 404.

The middleware now also puts the session id in context. logout-others is
defined by exclusion, and without knowing which session is ours the
safe-looking action deletes everything including the caller's — so it
refuses rather than guesses when the id is absent, and that refusal is
tested for non-deletion too.

audit_log.action is plain text with no CHECK, so the two new actions need no
migration (rule #36 checked, not assumed).

Codegen is real sqlc 1.31.1 via the container in `make generate` — docker is
present on this workstation even though Go and sqlc aren't — rather than the
hand-written .sql.go shortcut used in milestone #268.
2026-08-05 09:17:40 -04:00
bvandeusen 2e1a8a62d8 refactor(android): move cleartext opt-out into a networkSecurityConfig — #2439
android / Build + lint + test (push) Successful in 3m46s
`android:usesCleartextTraffic="true"` sat on <application> as a bare opt-out of
the platform's network-security default, with nothing recorded about why. It's
now a res/xml/network_security_config.xml carrying the same permission and the
reasoning behind it.

Behaviour is unchanged. networkSecurityConfig supersedes the attribute on API
24+ and our minSdk is 26, so the attribute is removed rather than kept
alongside.

Cleartext stays permitted because two independent things need it, and neither
can be narrowed to a domain list:

- The Minstrel server's host is user-entered at runtime, and plenty of
  self-hosters run plain HTTP on a LAN.
- UPnP/DLNA/Sonos — device-description and SOAP control URLs arrive in SSDP
  responses at runtime and are plain HTTP essentially always. This one wasn't in
  the original ticket, which only considered the server; it independently rules
  out the "tighten it later to RFC1918" idea, since <domain-config> matches
  literal hostnames, not CIDR ranges, and renderer IPs are unknowable ahead of
  time.

Trust anchors deliberately left at the platform default. Adding
<certificates src="user" /> would let self-hosters use HTTPS with a private CA —
which Mihon does, and which suits this product — but it also trusts every CA on
the device including a corporate MITM proxy. Raised separately rather than
assumed as a default.

tools:ignore="InsecureBaseConfiguration" mirrors Mihon's config and keeps
lintVitalRelease quiet about a choice that is deliberate and now documented.
2026-08-05 08:41:05 -04:00
bvandeusen 1bf0e388cb docs(readme): state scope and responsible use up front
Minstrel integrates with Lidarr, and that integration is the kind of thing a
reader can misread as content sourcing. It isn't, and the README never said so
explicitly. Now it does, before the Quickstart rather than buried at the bottom.

The section states what is simply true: Minstrel indexes files already on disk
and streams them; it ships no indexers, no trackers, no torrent/Usenet/NZB
client, and no DRM circumvention; the Lidarr integration is optional, inert
until an operator supplies a URL and API key, and points at an instance they
already run. Library contents and configured sources are the operator's
responsibility. Plus a non-affiliation line for Lidarr, ListenBrainz,
MusicBrainz and Subsonic.

Also made the Lidarr highlight explicit that the instance is yours and the
integration is off by default — the bullet previously read as though Minstrel
brought Lidarr with it.

Written as scope-setting rather than legalese, deliberately: a confident
description of what the software does is both more useful to a reader and
better evidence of intent than an anxious disclaimer would be.

Docs only — no workflow path filter matches README.md, so no CI lane runs.
2026-08-04 21:26:26 -04:00
bvandeusen a4b6f22d86 feat(update): silent self-update via PackageInstaller session — #2438
android / Build + lint + test (push) Successful in 3m54s
Replaces the ACTION_VIEW + application/vnd.android.package-archive handoff
with a PackageInstaller session, and declares
UPDATE_PACKAGES_WITHOUT_USER_ACTION so the update can land with no confirm
dialog at all.

The platform grants the silent path when the installer opts in via
setRequireUserAction(USER_ACTION_NOT_REQUIRED), the installed app targets
API 29+, the installer holds that permission, and the target is the
installer itself. Minstrel updating Minstrel satisfies all four. Where it
can't be granted — anything pre-S — the platform returns
STATUS_PENDING_USER_ACTION and we show its dialog instead, so this degrades
rather than failing.

Prior art: Mihon, which is out-of-store and self-updating and whose updates
are quiet for exactly this reason. It also confirmed REQUEST_INSTALL_PACKAGES
is not what draws install warnings — Mihon declares it too.

No setRequestUpdateOwnership(true), despite it reading like the obvious
declaration for a self-updater. Ownership can only be claimed on initial
installation (a no-op on update) and additionally wants the privileged
ENFORCE_UPDATE_OWNERSHIP permission. It's an API for app stores claiming the
apps they install.

Also: the install now has an outcome. The old path fired an intent and
assumed, so a failure and a user declining were indistinguishable. Sessions
report back, so InstallOutcome distinguishes Installed / Cancelled / Failed,
and cancelling returns to IDLE rather than showing an error — the user chose
it. DOWNLOADING and INSTALLING became separate stages because the install
half now genuinely waits, and "Downloading…" through a confirm dialog is a
lie.

The FileProvider and res/xml/file_paths.xml are gone. They existed only to
expose the cached APK as a content:// URI for the old intent; a session
takes a stream. Nothing else used that authority.

Two judgement calls worth naming:

- The pending-user-action intent is only launched if it resolves to a system
  component. Below API 34 a dynamically registered receiver can't declare
  itself unexported, so another app can broadcast at us, and an unchecked
  startActivity on an attacker-supplied extra would be an escalation
  primitive. The real confirm activity is a system app, so the check costs
  the legitimate path nothing.
- Cancellation unregisters the receiver but deliberately does NOT abandon the
  session. By then it's committed, and killing an install because the user
  navigated away from the banner misreads their intent.

Untestable here: no androidTest source set and no Robolectric, so the
gesture-level behaviour is operator on-device verification.
2026-08-04 16:19:24 -04:00
bvandeusen 57d2299180 Merge pull request 'Queue row gestures: album art as grab surface + swipe-to-remove' (#118) from dev into main
test-web / test (push) Successful in 48s
android / Build + lint + test (push) Successful in 5m15s
release / Build signed APK (tag releases only) (push) Successful in 4m38s
release / Build + push container image (push) Successful in 1m48s
2026-08-04 11:37:30 -04:00
bvandeusenandClaude Opus 5 8b630e71ca refactor(player): split the queue row out of QueueScreen.kt — #2435
android / Build + lint + test (push) Successful in 3m40s
detekt TooManyFunctions: the swipe work took the file to 12 functions
against a limit of 11. Suppressing it was the option; splitting is the
better one, because the seam was already there — the row carries two
gestures, a swipe background, and its own accessibility surface, which is
more behaviour than the screen that merely lists it.

QueueScreen.kt keeps the screen, list, pill, and summary (4). QueueRow.kt
takes the row and its helpers (8). No behaviour change: same code, same
order, per-file imports recomputed, QueueRow internal so QueueList can
still call it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6vZoJ4Se5YyaqdtGVkap5
2026-08-04 10:52:14 -04:00
bvandeusenandClaude Opus 5 1910a5ce61 feat(player): swipe a queue row left to remove it — #2435
android / Build + lint + test (push) Failing after 1m25s
Replaces the trailing X button on the Android queue row, for the same
reason #2395 replaced the grip: horizontal space in the narrowest row in
the app. Web keeps its X — the operator's call, and the right one, since
the constraint being solved doesn't exist there.

SwipeToDismissBox with enableDismissFromStartToEnd = false; a right-swipe
means nothing here and would only delete tracks on a mis-aimed gesture.
The red fill under the row is oxblood (LocalActionColors.destructive), not
colorScheme.error — the design system keeps those apart because an error
is a failure that happened and a destructive action is one about to.

Adds a "Remove from queue" custom accessibility action. Both gestures the
row now relies on are touch-only, and each replaced a control TalkBack
could find, so without this the change would have quietly removed
remove-from-queue for anyone not using touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6vZoJ4Se5YyaqdtGVkap5
2026-08-04 10:47:48 -04:00
bvandeusenandClaude Opus 5 a92a9f2198 fix(player): extract the reorder a11y actions to clear detekt LongMethod — #2395
android / Build + lint + test (push) Successful in 3m51s
QueueRow hit 61 statements against detekt's 60 — the semantics block I added
for the screen-reader move actions pushed it one over.

Extracted to a `Modifier.queueReorderActions` extension, which mirrors the
`queueReorderDrag` extension from the same change: the row now composes two
named modifiers, one for the gesture and one for the accessibility actions,
instead of carrying either inline. Better than suppressing the rule — the
suppression would have been permanent and the split reads better anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 08:52:40 -04:00
bvandeusenandClaude Opus 5 6dea45a634 feat(player): album art is the queue's grab surface — #2395
test-web / test (push) Successful in 34s
android / Build + lint + test (push) Failing after 1m30s
The grip icon took a column out of every queue row, competing with the title
for space — worst on Android, where the row is narrowest and the icon plus
its 12dp gap cost roughly 36dp. Operator pre-approved dropping the icon and
making the album art the drag surface; that's what this does.

## Android: the gesture change is the load-bearing part

Moved the drag from the grip onto the thumbnail AND switched
detectDragGestures → detectDragGesturesAfterLongPress. That second half is
not cosmetic. The grip was a small target, so a plain drag detector on it
never competed with anything; a 48dp thumbnail is a large chunk of every
row, and with a plain detector any vertical pan starting on artwork would be
swallowed as a reorder instead of scrolling the queue. The list would have
felt broken exactly where it's easiest to touch. Long-press-then-drag
separates the three gestures: pan scrolls, long-press reorders, tap still
plays (the detector doesn't consume a plain tap, so it reaches the row's
clickable).

Dropping the grip also removed its contentDescription ("Reorder track"),
which was the ONLY thing telling a screen reader this list could be
reordered — and a long-press drag isn't operable with TalkBack regardless.
Added "Move up"/"Move down" custom accessibility actions on the row, the
Android counterpart to the web row's ArrowUp/ArrowDown. Without them this
change would have quietly removed reordering for anyone not using touch.

## Web: the grip was never the drag surface

`use:draggable` is on the row, not the handle, so dragging already worked
from anywhere — the grip's only unique jobs were being the visual cue and
the keyboard target. It now sits OVER the art, costing zero horizontal
space, and keeps both jobs.

Deliberately still VISIBLE at rest, just quiet, with the scrim appearing
only on hover/focus. Overlaying already solved the space complaint, so
hiding it buys nothing and would cost the only cue that the queue is
reorderable — on touch especially, which has no hover.

## Scope walked back

Also considered the web PlaylistTrackRow, which carries an identical grip.
Left alone: it has no album art, so the approved direction doesn't apply,
and its handle is already the smallest of the three at 14px. Forcing
consistency would have meant inventing a third treatment for a surface
nobody complained about. (Android has no playlist reorder at all — that
parity gap is pre-existing and out of scope here.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 08:43:36 -04:00
bvandeusen fa7ea41ccf Merge pull request 'Minstrel gets a mark — favicon, header lockup, Android adaptive icon' (#117) from dev into main
test-web / test (push) Successful in 47s
android / Build + lint + test (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 8m36s
release / Build + push container image (push) Successful in 1m37s
2026-08-03 20:52:27 -04:00
bvandeusenandClaude Opus 5 e1e591b520 feat(brand): Minstrel mark — favicon, header lockup, Android adaptive icon
test-web / test (push) Successful in 45s
android / Build + lint + test (push) Successful in 4m14s
A Didone M whose right leg is an eighth note: stem, flag and notehead in the
accent, the letter in parchment. Traced from the operator's reference at
99.74% IoU (potrace, 26 + 22 segments), so the geometry is theirs, not an
approximation of it.

Subject-neutral on purpose. "Minstrel" pulls toward a lute or a bard, which
would tell a new user this is a renaissance-faire player rather than one for
all music. A geometric letter plus universal notation says "music" without
saying which music. The family look arrives through palette and drawing
style instead of through the subject — see the design-system discussion.

Starting state: web/static/favicon.png was a 1x1 PIXEL placeholder, so there
was effectively no favicon at all; Android had legacy bitmaps only, so modern
launchers letterboxed the square instead of masking it.

## The colour problem, and why each surface differs

Parchment on white is invisible — the operator caught this. The M therefore
has to flip with its background, while the accent note holds in both:

  - mark.svg / MinstrelMark.svelte use currentColor, so the letter takes the
    surrounding text colour and one asset covers both palettes.
  - favicon.svg bakes colours with a prefers-color-scheme swap, because a
    favicon sits on browser chrome and has no cascade to inherit from.
  - PNG fallback, apple-touch-icon and Android are PLATED. A PNG can't
    respond to scheme and iOS composites onto white regardless.

MinstrelMark is inlined rather than <img src>, because an <img> cannot
inherit currentColor and inheriting it is the entire point.

## Plate colour chosen by measurement

Obsidian (#14171A), not the raised-surface iron. The accent note only clears
the 3:1 non-text contrast threshold against the darker value: 3.04:1 vs iron's
2.70:1. My own earlier suggestion — lighten the plate — is WRONG and the
numbers say so: slate scores 2.21:1, worse, because the note is a dark colour
and lifting the plate closes the gap. Recorded in colors.xml so the reasoning
sits with the value.

## Construction

Traced as a full ink silhouette with the note painted OVER it, rather than as
two separate shapes. Separate shapes needed either a 2px seam where letter and
note touch, or an anti-aliasing fringe (2,430 misclassified pixels) around the
note. Painting over avoids both and yields a monochrome version for free — the
base layer alone is the whole mark in one colour, which is what
mipmap-anydpi-v26's <monochrome> uses for themed icons.

Android foreground sits at 61% of the 108dp canvas so it stays inside the
66dp safe zone and no launcher mask can clip it.

Paths are duplicated between the component and the two static SVGs, since one
needs currentColor and the others need literals. A comment in each names the
others.

Verified by render at 16/20/32/64/180 on obsidian, white, parchment and
plated; one optical size holds across the whole range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:37:25 -04:00
1687 changed files with 109234 additions and 46920 deletions
+15 -6
View File
@@ -6,10 +6,16 @@
**/build
web/build
# Flutter mobile client — built separately on developer machines / Flutter CI.
# Including it in the Go build context wastes ~70 files and invalidates the
# `COPY . .` layer cache on every Flutter-only change.
flutter_client/
# The Android client — built by its own job, never from this context. The APK
# reaches the image through client/, downloaded as a CI artifact, so nothing
# here reads android/ sources.
# Excluded so the Gradle project neither bloats the context nor busts the
# `COPY . .` layer on every Android-only change.
android/
# Local `make build` output — an 18 MB binary the image never uses, since the
# builder stage compiles its own.
bin/
# Docs and IDE noise
docs/
@@ -27,5 +33,8 @@ docs/
!.env.example
# CI workflow files don't need to ship in the image.
.forgejo/
.github/
#
# This said `.forgejo/` and `.github/` — neither of which this repo has. Gitea
# Actions reads `.gitea/`, so the one directory that actually exists was the
# one not excluded, and every workflow edit invalidated the context.
.gitea/
+9 -10
View File
@@ -1,12 +1,11 @@
# Minstrel environment variable overrides.
# Copy to .env for docker compose, or export before `go run ./cmd/minstrel`.
# Values shown are sensible defaults for the local docker-compose postgres.
# Environment for running the server outside Docker (`go run ./cmd/minstrel`).
# The values match the Postgres in docker-compose.yml.
SMARTMUSIC_SERVER_ADDRESS=:4533
SMARTMUSIC_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable
SMARTMUSIC_LOG_LEVEL=info
SMARTMUSIC_LOG_FORMAT=text
MINSTREL_SERVER_ADDRESS=:4533
MINSTREL_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable
MINSTREL_LOG_LEVEL=info
MINSTREL_LOG_FORMAT=text
# Set this when running `go test ./...` without -short to exercise the
# migration test against a running postgres.
# MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable
# Runs the database tests in `go test ./...` (without -short). Use a separate
# database: the tests truncate every table.
# MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable
-95
View File
@@ -1,95 +0,0 @@
name: android
# Native Android (Kotlin/Compose/Media3) — M8 rewrite, now the only client.
# This workflow is testing only — lint + detekt + unit tests on every push
# to dev/main, plus a debug APK artifact for main. The signed-release
# build + asset attach + image-bundling lives in release.yml under a
# `needs:` chain so the docker image cannot ship without the APK.
on:
push:
branches: [main, dev]
paths:
- 'android/**'
- '.gitea/workflows/android.yml'
# pull_request trigger intentionally omitted — see test-web.yml for
# the rationale (single-author repo, push covers PR-merge equivalent).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
# Silences the JDK 22+ "restricted method in java.lang.System has been
# called" warning that Gradle 9.1's bundled native-platform jar trips
# at launch (System.load for native primitives). Affects the LAUNCHER
# JVM, not the daemon — that's why org.gradle.jvmargs in
# gradle.properties isn't enough. Future-compat: required opt-in once
# JDK 25 promotes the warning to an error.
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
jobs:
build:
name: Build + lint + test
# Using flutter-ci runner label because it's the only proven-working
# label with docker that can pull our container.image. Switch to
# android-ci once the operator registers that runner label.
runs-on: flutter-ci
container:
image: git.fabledsword.com/bvandeusen/ci-android:36
defaults:
run:
working-directory: android
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache Gradle dirs
# Resolved deps + Gradle distribution + Kotlin daemon caches.
# Saves ~3 min per CI run after the first warm-up.
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.kotlin
key: gradle-${{ runner.os }}-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Make gradlew executable
run: chmod +x ./gradlew
- name: Gradle wrapper validation
run: ./gradlew --version
- name: ktlint
run: ./gradlew ktlintCheck
- name: detekt
run: ./gradlew detekt
- name: Unit tests
run: ./gradlew testDebugUnitTest
- name: Assemble debug
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: ./gradlew assembleDebug
- name: Upload debug APK
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# Mirrored action, never actions/upload-artifact. @v4+ throws
# GHESNotSupportedError client-side on the hostname (no server setting
# reaches that check), and @v3 is worse — it reports success while Gitea
# serves artifacts back only through the v4 API, so the upload is stored
# and invisible to every retrieval path. @v3 is what left 72 unreachable
# artifacts on this repo. Pinned by SHA because the mirror auto-syncs;
# full URL because DEFAULT_ACTIONS_URL sends bare owner/repo to github.com.
# See Scribe issues 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with:
name: minstrel-android-debug-${{ github.sha }}
path: android/app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error
+749 -100
View File
@@ -2,55 +2,409 @@ name: release
# Builds and pushes the minstrel container image to the Gitea registry.
#
# push to main → :main and :latest (latest-release APK bundled)
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
# workflow_dispatch → manual trigger (same rules based on the ref)
# push to dev → :dev (freshly-built dev APK bundled)
# push to main → :latest + :<sha> (latest-release APK bundled)
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
# workflow_dispatch → manual trigger (same rules based on the ref)
#
# Release model: per-day CalVer tags (no trailing patch digit). The day's
# tag is intentionally mutable — if a second release happens the same day,
# move the tag with `git push -f origin vYYYY.MM.DD` and the image tag of
# the same name gets overwritten. :latest is updated by every main push
# AND every tag push, so it always reflects the newest blessed image.
# That is the whole tag map.
#
# :<sha> on main is the ROLLBACK UNIT — every production commit addressable
# without a release ceremony. It is minted only on main, where rollback is
# actually worth having: merges to main are deliberate, so they number in the
# dozens per year, while on dev they would be one per push, forever, for a
# channel whose entire contract is that it moves.
#
# There are NO :<version> image tags. Git and the build's own self-reported
# version answer "which build is this"; a third name for the same thing is
# upkeep for a model we do not run. Only the APK needs version numbers, for
# its update process.
#
# There is no :main either. :latest tracks main's tip with no gate between
# them, so a second name for the same image sends readers looking for a
# distinction that does not exist.
#
# The dev channel exists so testing a build does not require shipping one:
# without it the only way to get an APK onto a phone is to cut a release,
# which makes `main` the staging area. `:dev` carries its own freshly-built
# APK, signed with the SAME key as release builds — a different key cannot
# install over the stable app, so anyone crossing channels would have to
# uninstall and lose their data.
#
# :dev is published ALONE, with no per-commit tag. A rolling channel is
# rolling by definition; a commit-addressable image for it would be a
# rollback target nobody ever pulls, kept forever. Recovery on dev is to fix
# forward.
#
# No cross-repo dispatch is needed to refresh a channel when its bundled APK
# is rebuilt: Minstrel is a monorepo, so one push builds the APK and the
# image in the same run from the same commit, and the channel cannot go
# stale against its own artifact.
#
# Release model: the tag IS the artifact's version name with a `v` in front.
# `v2026.09.10.1432` and `2026.09.10.1432` are the same string, derived from
# the tagged commit's UTC timestamp — so there is no mismatch to reconcile
# between what the tag says and what the APK reports, and nothing to look up
# when minting one.
#
# TAGS ARE IMMUTABLE. Never move, retarget or delete a published tag. A
# same-day second release is not a collision — HHMM makes every tag unique
# by construction, so the answer is simply another tag. Anyone installing
# from a moved tag would hold something the tag no longer points at, which
# is a worse failure than an extra row in the tag list.
#
# :latest is updated by every main push AND every tag push, so it always
# reflects the newest blessed image.
#
# APK pipeline: on tag pushes the android-release job builds + signs the
# Android APK and uploads it as a workflow artifact. The image-release
# job declares `needs: android-release`, so the docker image cannot
# start building until the APK is guaranteed-ready — no polling, no
# race, no silent-failure mode. Asset attachment to the gitea Release
# happens in the same android-release job, so the Release-page download
# link and the in-image bundled APK are both populated atomically.
# race, no silent-failure mode. Attaching the APK to the gitea Release is
# its own job (release-assets), behind the test gate below.
#
# :latest always carries an APK. Because every main push also moves
# :latest (not just tags), a main build with no APK would silently strip
# the in-app update channel off :latest until the next release. So on
# non-tag builds image-release pulls the MOST RECENT release's signed APK
# and reconstructs its exact versionName (tag + commit-count, the same
# formula android-release bakes in) for the version sidecar — no rebuild,
# just rebundle. Tag builds keep bundling their own freshly-built APK.
# AND the version sidecar published beside it — the recorded values, not
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds
# keep bundling their own freshly-built APK.
#
# Android testing (lint + detekt + unit tests, debug APK upload on main)
# lives in android.yml and runs independently on every push.
# THE GATE. Every verifying lane lives in this file —
# Go (vet, lint, short tests), the Postgres integration suite, the web app
# (npm audit, svelte-check, vitest), Android (ktlint, detekt, unit tests) and
# govulncheck — and every job that publishes something names each of them in
# `needs:` and requires `success` from each, by name. Nothing publishes on red.
#
# One workflow, because separate workflows cannot see each other's verdict:
# :dev would mean "it built", never "it passed", and a red test run and a
# fresh :dev could carry the same timestamp. One graph is the only place the
# edge can be written.
#
# A skipped lane is NOT a pass. The publishing conditions check
# `result == 'success'` per lane rather than `!failure()`, so a lane that
# never started blocks the publish exactly as a red one does. Lanes carry no
# path filters for the same reason: a web-only push still runs the Go suite,
# because "not run" must never read as "passed".
#
# What publishes, and is therefore gated: the image tags (image-release) and
# the APK + version sidecar attached to a tag's Release (release-assets).
# android-release only BUILDS the signed APK into a workflow artifact, which
# nobody outside this run can pull, so it runs in parallel with the lanes
# instead of after them; attaching it to the Release is the publishing half,
# and that half waits for the gate.
#
# To watch the gate refuse: dispatch this workflow with force_red=true. The go
# lane fails on purpose, and both publishing jobs must report skipped.
on:
push:
branches: [main]
branches: [main, dev]
tags: ['v*']
paths-ignore:
- 'docs/**'
- '**/*.md'
workflow_dispatch:
inputs:
force_red:
description: Fail the go lane on purpose, to check that nothing publishes on red
type: boolean
default: false
# Force-moving the per-day tag (or rapidly re-pushing to main) should
# supersede the in-flight build — the operator explicitly wants the
# later commit to win.
# A rapid re-push to main supersedes the in-flight build: the later commit
# wins. Tags are immutable and unique, so no tag build can be superseded by
# another run on the same ref.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ---------------------------------------------------------------- lanes --
# Verifying jobs. Each one is named in the `needs:` of every publishing job
# below; add a lane here and it must be added there in the same commit.
go:
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Forced failure (gate check)
if: github.event.inputs.force_red == 'true'
run: |
echo "::error::force_red dispatch: failing on purpose so the publishing jobs must skip"
exit 1
- name: Toolchain versions
run: |
go version
golangci-lint --version
- name: Generated code matches queries (sqlc)
run: make verify-generate
- name: go vet
run: go vet ./...
- name: golangci-lint
run: golangci-lint run ./...
- name: go test (short, race)
run: go test -short -race ./...
# Full `go test -race` against an ephemeral Postgres.
#
# DB wiring follows the act_runner shared-daemon pattern: the runner's Docker
# daemon also runs a dev compose stack, so service containers get NO
# published ports (collision) and no service-name DNS. We discover the
# service container through the mounted docker socket and reach it by bridge
# IP. The exactly-one assertion is a hard guard — pointing tests at the dev
# Postgres would truncate it.
#
# The key stays `integration` with no `name:`: act_runner derives the
# service container's name from the job's display name.
#
# `web/build/` has a committed placeholder index.html so go:embed succeeds
# without the SPA being built first.
integration:
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: minstrel
POSTGRES_PASSWORD: minstrel
POSTGRES_DB: minstrel_test
# No `ports:` — the runner shares the dev compose
# Docker daemon; publishing a fixed host port collides.
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Integration suite (discover service by bridge IP, migrate, test)
run: |
set -eux
# Discover THIS job's Postgres service container via the
# mounted docker socket. act_runner attaches the job
# container and its service container(s) to a shared per-job
# network, so scope discovery to a postgres that sits on a
# network THIS job container is also on. A name filter alone
# would match EVERY concurrent integration run's postgres (a dev
# push and a main-merge run overlapping give 2 candidates and a
# false "expected exactly 1" abort). The dev compose
# `minstrel-postgres-*` is never on this job's network; skip it
# explicitly as belt-and-suspenders (a wrong target would
# truncate real data).
SELF=$(cat /etc/hostname)
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
test -n "$SELF_NETS"
echo "self ($SELF) networks: $SELF_NETS"
PG_ID=""
PG_NAME=""
for cid in $(docker ps --filter "ancestor=postgres:16-alpine" -q); do
nm=$(docker inspect -f '{{.Name}}' "$cid" | sed 's#^/##')
case "$nm" in *minstrel-postgres*|*_postgres_*) continue ;; esac
for net in $(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cid"); do
case " $SELF_NETS " in *" $net "*) PG_ID="$cid"; PG_NAME="$nm"; break 2 ;; esac
done
done
test -n "$PG_ID" || { echo "FATAL: no postgres service container on this job's network (self nets: $SELF_NETS)"; exit 1; }
echo "selected postgres: $PG_ID $PG_NAME"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID")
test -n "$PG_IP"
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
# Wait for Postgres to accept connections. Asked of the service
# container itself: the run: shell is dash, where a `/dev/tcp`
# probe never connects and the loop would silently burn its full
# two minutes.
#
# Over TCP (-h 127.0.0.1), not the unix socket. On first
# boot the image's entrypoint runs initdb under a temporary server
# that listens on the socket only, then stops it and starts the
# real one. A socket probe can catch the temporary server, and the
# next step then meets "the database system is shutting down".
# Only the real server listens on TCP.
ready=""
for i in $(seq 1 60); do
if docker exec "$PG_ID" pg_isready -h 127.0.0.1 -U minstrel -d minstrel_test -q; then ready=1; break; fi
sleep 2
done
test -n "$ready" || { echo "FATAL: postgres never became ready"; exit 1; }
# Relax durability on the throwaway CI Postgres. Our test pattern
# is dbtest.ResetDB → TRUNCATE … RESTART IDENTITY CASCADE before
# every test, and the per-TRUNCATE commit fsync is the dominant
# cost of the integration suite. The CI DB is rebuilt every run so
# fsync / full_page_writes / synchronous_commit buy nothing. Apply
# via docker exec because:
# - The act_runner `services:` block can't override the container
# command, so `postgres -c fsync=off` at boot isn't an option.
# - ALTER SYSTEM cannot run inside a transaction; psql -c
# auto-commits each statement, which is what we need.
# - fsync / full_page_writes are sighup GUCs and
# synchronous_commit is user-context, so pg_reload_conf() picks
# all three up with no restart.
# Non-fatal: a failure degrades to "slower", never red CI. But
# slower is ~15x on internal/api, so retry a few times and
# print what took effect.
relaxed=""
for i in 1 2 3 4 5; do
if docker exec -e PGPASSWORD=minstrel "$PG_ID" psql -h 127.0.0.1 -U minstrel -d minstrel_test \
-c "ALTER SYSTEM SET fsync = off" \
-c "ALTER SYSTEM SET synchronous_commit = off" \
-c "ALTER SYSTEM SET full_page_writes = off" \
-c "SELECT pg_reload_conf()"; then relaxed=1; break; fi
sleep 2
done
if [ -n "$relaxed" ]; then
docker exec -e PGPASSWORD=minstrel "$PG_ID" psql -h 127.0.0.1 -U minstrel -d minstrel_test -At \
-c "SELECT 'durability: ' || string_agg(name || '=' || setting, ' ' ORDER BY name) FROM pg_settings WHERE name IN ('fsync','synchronous_commit','full_page_writes')" \
|| true
else
echo "::warning::durability relax failed after 5 tries; the suite will run several times slower"
fi
# Apply embedded migrations to the fresh test DB, then run the
# full suite (no -short → integration tests execute). -p 1:
# every integration package TRUNCATEs the one shared test DB;
# concurrent package binaries → TRUNCATE deadlocks. Serialize
# package execution (the documented local invocation too).
# -timeout 20m: internal/api alone takes several minutes under
# -race on an idle runner, and a dev and a main run sharing the
# runner can push it past go test's default 10m with nothing
# hung.
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
go test -p 1 -race -timeout 20m ./...
web:
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
defaults:
run:
working-directory: web
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install deps
run: npm ci
# The whole tree, build and test tooling included: the tooling that
# builds the shipped bundle is held to the same bar as the bundle.
- name: npm audit (all dependencies)
run: npm audit --audit-level=moderate
- name: Type-check + svelte-check
run: npm run check
- name: Vitest
run: npm test
android:
# The flutter-ci runner label is the only proven-working label with
# docker that can pull our container.image.
runs-on: flutter-ci
container:
image: git.fabledsword.com/bvandeusen/ci-android:36
defaults:
run:
working-directory: android
env:
# Silences the JDK 22+ "restricted method in java.lang.System has been
# called" warning that Gradle's bundled native-platform jar trips at
# launch. Affects the LAUNCHER JVM, not the daemon — that's why
# org.gradle.jvmargs in gradle.properties isn't enough.
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache Gradle dirs
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.kotlin
key: gradle-${{ runner.os }}-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Make gradlew executable
run: chmod +x ./gradlew
- name: Gradle wrapper validation
run: ./gradlew --version
- name: ktlint
run: ./gradlew ktlintCheck
- name: detekt
run: ./gradlew detekt
- name: Unit tests
run: ./gradlew testDebugUnitTest
# No debug APK is built or uploaded here: a debug build is signed by a
# key regenerated in every container, which no install can update.
# Phones get builds from android-release, signed with the one release
# key, on dev and on tags.
# Known vulnerabilities in the Go code and the standard library it is built
# with. Runs in the SAME image the Dockerfile's builder stage uses, so the
# standard library it checks is the one that ends up in the shipped binary;
# the ci-go image carries its own Go and would be checking a different
# toolchain. Keep this image and the Dockerfile's builder in step.
#
# govulncheck is fetched at CI time, unpinned: the vulnerability
# database and the tool that reads it should both be current.
govulncheck:
runs-on: go-ci
container:
image: golang:1.26-bookworm
steps:
# Plain git, not actions/checkout: that action runs on node, which the
# golang image does not carry. This step is dash.
- name: Checkout
env:
TOKEN: ${{ github.token }}
run: |
set -eu
auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git -c http.extraHeader="Authorization: Basic ${auth}" fetch -q --depth 1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
git log -1 --format='%H %s'
- name: govulncheck
run: |
go version
go run golang.org/x/vuln/cmd/govulncheck@latest ./...
# ------------------------------------------------------------ artifacts --
android-release:
name: Build signed APK (tag releases only)
if: startsWith(github.ref, 'refs/tags/v')
name: Build signed APK (releases and dev)
# Also builds on `dev`, which is what makes a test channel possible at
# all: without it the only way to get a build onto a phone is to cut a
# release, which quietly turns `main` into the staging area.
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
runs-on: flutter-ci
container:
image: git.fabledsword.com/bvandeusen/ci-android:36
@@ -75,14 +429,18 @@ jobs:
outputs:
version_name: ${{ steps.ver.outputs.name }}
version_code: ${{ steps.ver.outputs.code }}
channel: ${{ steps.ver.outputs.channel }}
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.
# Full history. The version name reads only the tip commit's
# timestamp, so a shallow clone would technically serve — but this
# job derives a value that ships to devices, and a shallow checkout
# changes what git-derived values resolve to WITHOUT failing. The
# whole failure class here is a green build carrying a wrong
# version, so the cheap guarantee is worth keeping.
fetch-depth: 0
- name: Compute release version
@@ -91,12 +449,49 @@ jobs:
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})"
# The derivation lives in ci/version.sh, not here, so it can be
# executed by a test on every push. Anything inline in this file is
# unverifiable until a release is already running.
out="$(ci/version.sh --apk HEAD)"
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
# The channel is a property of the LANE, not of the commit, which is
# why it is derived here rather than in version.sh. Same commit built
# on dev and on main reports the same NAME and differs only here —
# that is the whole point of separating the two values.
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
channel=dev
else
channel=stable
fi
echo "channel=${channel}" >> "$GITHUB_OUTPUT"
echo "::notice::APK $(printf '%s' "${out}" | tr '\n' ' ') channel=${channel}"
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea
# Release" below resolves the release by tag and fails if it is absent —
# but that is the final step, so a tag pushed without a release would
# build an APK for several minutes first and only then find it has
# nowhere to put it. Same check, seconds in instead of minutes.
#
# Releases are normally created through the API (which creates the tag and
# the release together, so this passes). A bare `git push origin vX` is the
# case this catches.
- name: Release must exist for this tag
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
working-directory: ${{ github.workspace }}
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
if ! curl -fsSL -o /dev/null \
-H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}"; then
echo "::error::no release exists for ${TAG}. Create the release (which creates the tag) rather than pushing a bare tag — otherwise there is nothing to attach the APK to."
exit 1
fi
echo "::notice::release found for ${TAG}"
- name: Cache Gradle dirs
uses: actions/cache@v4
@@ -130,14 +525,47 @@ jobs:
-PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
# The APK every phone updates from must carry THE release key: Android
# updates an app in place only when the signer matches, so an APK
# signed by any other key (debug, a regenerated keystore, a swapped
# secret) reaches no installed phone. The certificate's digest is
# pinned below; it is public, not a secret. Gradle signs with the
# release key or leaves the APK unsigned, and an unsigned build fails
# here too, as there is no app-release.apk to verify. apksigner, not
# keytool: keytool prints nothing for a v2-only APK.
#
# Rotating the key on purpose means every install must be removed and
# reinstalled; change the digest here in the same commit.
- name: The APK carries the release key
shell: bash
env:
# CN=Minstrel, O=FabledSword.
RELEASE_CERT_SHA256: 43d183307bc46b821789d90444a960b137f78f2166ff431efb2406d0fceaf612
run: |
set -euo pipefail
sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
signer="$(ls "$sdk"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1 || true)"
test -n "$signer" || { echo "::error::no apksigner under '$sdk/build-tools'"; exit 1; }
certs="$("$signer" verify --print-certs app/build/outputs/apk/release/app-release.apk)"
printf '%s\n' "$certs" | grep -E '^Signer #[0-9]+ certificate (DN|SHA-256 digest)'
# One signer, and it is ours. A second signer would be a lineage or
# a mistake; either way not something to ship unexamined.
digests="$(printf '%s\n' "$certs" | sed -n 's/^Signer #[0-9]* certificate SHA-256 digest: //p')"
if [ "$digests" != "$RELEASE_CERT_SHA256" ]; then
if printf '%s' "$certs" | grep -q 'CN=Android Debug'; then
echo "::error::the release APK is signed with a debug key"
else
echo "::error::the release APK is not signed by the release key: got '${digests//$'\n'/ }', want ${RELEASE_CERT_SHA256}"
fi
exit 1
fi
- name: Upload APK as workflow artifact
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
# hostname, @v3 uploads something Gitea will never serve back. This is
# the producing half of a pair: image-release downloads `minstrel-apk`
# below with the matching download-artifact mirror. Both must stay on
# the v4 protocol — mixing a v3 upload with a v4 download (or the
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
# Stock action — never @v3, which uploads something Gitea will never
# serve back. This is the producing half of a pair: image-release
# downloads `minstrel-apk` below. Any upload v4+ pairs with any
# download v4+ on this forge, so the two pins need not move together.
uses: actions/upload-artifact@v7
with:
name: minstrel-apk
path: android/app/build/outputs/apk/release/app-release.apk
@@ -145,17 +573,63 @@ jobs:
# artifact existing, so an empty upload must fail here, not there.
if-no-files-found: error
# Publishes the signed APK and its version sidecar on the tag's Release.
# Split out of android-release so the APK can be BUILT in parallel with the
# lanes while being PUBLISHED only once they have all passed.
release-assets:
name: Attach APK to the Release (tag releases only)
needs: [go, integration, web, android, govulncheck, android-release]
if: >-
${{
!cancelled()
&& needs.go.result == 'success'
&& needs.integration.result == 'success'
&& needs.web.result == 'success'
&& needs.android.result == 'success'
&& needs.govulncheck.result == 'success'
&& needs.android-release.result == 'success'
&& startsWith(github.ref, 'refs/tags/v') }}
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
steps:
- name: Download signed APK artifact
uses: actions/download-artifact@v8
with:
name: minstrel-apk
path: release-apk/
- name: Attach APK to gitea Release
# Tag releases only. A dev build has no Release to hang assets on and
# does not need one — the :dev image bundles the APK, and the server
# serves it from /api/client/apk like any other.
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
VERSION_CODE: ${{ needs.android-release.outputs.version_code }}
run: |
set -euxo pipefail
TAG="${GITHUB_REF#refs/tags/}"
REPO="${GITHUB_REPOSITORY}"
APK_PATH="app/build/outputs/apk/release/app-release.apk"
APK_PATH="release-apk/app-release.apk"
ls -lh "${APK_PATH}"
# Publish the version sidecar as a release asset next to the APK.
#
# This is what lets a later :latest build stop RECONSTRUCTING the
# bundled APK's version and simply read what was recorded. The
# ordering key in particular cannot be re-derived after the fact —
# it is build-time minutes, so once this job ends the value exists
# nowhere else. Reconstruction could only ever recover the name,
# and only by duplicating a formula that then has to be kept in
# step across two files.
SIDECAR_PATH="/tmp/minstrel.apk.version"
printf '{"name":"%s","code":%s,"channel":"stable"}\n' \
"${VERSION_NAME}" "${VERSION_CODE}" > "${SIDECAR_PATH}"
cat "${SIDECAR_PATH}"
RELEASE_JSON="$(curl -fsSL \
-H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}")"
@@ -177,15 +651,37 @@ jobs:
exit 1
fi
# Same treatment for the sidecar. Named `.apk.version` so the
# downloader's `\.apk$` match cannot pick it up by mistake.
SIDECAR_HTTP=$(curl -sS -L -o /tmp/upload-sidecar.out -w '%{http_code}' \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@${SIDECAR_PATH}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk.version")
echo "sidecar_upload_http=${SIDECAR_HTTP}"
cat /tmp/upload-sidecar.out || true
echo
if [ "${SIDECAR_HTTP}" -lt 200 ] || [ "${SIDECAR_HTTP}" -ge 300 ]; then
echo "::error::version sidecar upload returned HTTP ${SIDECAR_HTTP}"
exit 1
fi
image-release:
name: Build + push container image
# `needs:` waits for android-release. For tag pushes android-release
# runs and must succeed before this job starts — guaranteeing the
# APK artifact is present. For main pushes android-release is
# skipped; the `if: ...` below lets this job run anyway and the
# download/copy steps gate themselves on the tag context.
needs: [android-release]
if: ${{ !failure() && !cancelled() }}
# Every lane must have SUCCEEDED, each named here. Then the
# APK: tag and dev pushes build one and it must have succeeded; main
# pushes skip android-release and bundle the latest release's APK
# instead, so for main alone a skipped android-release is expected.
needs: [go, integration, web, android, govulncheck, android-release]
if: >-
${{
!cancelled()
&& needs.go.result == 'success'
&& needs.integration.result == 'success'
&& needs.web.result == 'success'
&& needs.android.result == 'success'
&& needs.govulncheck.result == 'success'
&& (needs.android-release.result == 'success'
|| (needs.android-release.result == 'skipped' && github.ref == 'refs/heads/main')) }}
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
@@ -197,11 +693,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags so non-tag :latest builds can resolve the
# latest release tag's commit count and reconstruct the bundled
# APK's exact versionName (see "Bundle latest release APK" below).
# Full history: any job that DERIVES the version name needs it,
# because a shallow clone changes what git-derived values resolve to
# WITHOUT failing — a too-low value, silently, with every lane green.
#
# This job runs ci/version.sh itself: with no :<version> image tags,
# the server's self-reported version is the only thing that says which
# build an image is.
fetch-depth: 0
fetch-tags: true
- name: Detect buildable project
id: guard
@@ -219,21 +718,62 @@ jobs:
if: steps.guard.outputs.ready == 'true'
shell: bash
run: |
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF#refs/tags/}"
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "::notice::Release build: ${VERSION} + latest"
else
# Main is the protected, post-PR-merge branch. Treat it as the
# rolling stable channel — every main push moves :latest.
# Pinned consumers can target :vYYYY.MM.DD; everyone else
# gets the newest main.
echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "version=main" >> "$GITHUB_OUTPUT"
echo "::notice::Main-branch build: :main + :latest"
set -euo pipefail
# THE VERSION, and it is derived the same way on every ref — the
# branch decides the CHANNEL, never the version.
#
# A per-ref string ("main", "dev", the tag name) would not order, and
# "dev" would be the same string forever — two dev images eight weeks
# apart indistinguishable in the UI. With no version image tags, this
# is how an admin tells which build a container is running.
#
# `sed -n s///p` rather than `grep`: it exits 0 when nothing matches,
# so the empty check below is actually reachable. A grep here would
# kill the step at the assignment under the runner's pipefail.
VERSION="$(ci/version.sh HEAD | sed -n 's/^name=//p')"
if [ -z "${VERSION}" ]; then
echo "::error::could not derive a build version from ci/version.sh"
exit 1
fi
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
# A release refreshes the CHANNEL and mints nothing else.
#
# The tag build exists to produce the signed APK and attach it to
# the release; the image it rebuilds is the SAME SOURCE as the main
# build minutes earlier, differing only in which APK is baked in.
# When the same source is rebuilt with different contents,
# publish the moving channel tag and never a commit-addressable
# one.
#
# :latest must move here rather than waiting for the next main
# push, or the channel would carry the PREVIOUS release's APK
# indefinitely — a channel that cannot refresh itself.
CHANNEL=stable
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then
# The rolling test channel, and :dev ALONE — deliberately no
# per-commit tag. A rolling channel is rolling by definition, so a
# commit-addressable image here would be a rollback target nobody
# has ever pulled, accumulating in the registry forever. Recovery
# on dev is to fix forward.
CHANNEL=dev
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
echo "::notice::Dev-branch build ${VERSION}: :dev"
else
# The production line: :latest tracks main's tip and :<sha> is
# the rollback unit. Full 40-char SHA, so a rollback target is
# addressable straight from the commit anyone is reading.
CHANNEL=stable
echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
- name: Registry login
if: steps.guard.outputs.ready == 'true'
shell: bash
@@ -242,54 +782,57 @@ jobs:
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
- name: Download signed APK artifact
# Tag pushes only — android-release just produced this. Non-tag
# builds take the "Bundle latest release APK" path below instead.
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
# Consuming half of the pair — never actions/download-artifact. Same fork,
# same reason: upstream's client-side GHES check rejects this hostname
# before it connects. bvandeusen/download-artifact mirrors
# code.forgejo.org/forgejo/download-artifact.
#
# SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
# the action's own version number — the two actions release on unrelated
# cadences, and download v5 would pair a ^2.3.2 client with this file's
# ^4.0.0 uploader. v6 is the tag whose bundled library major (^4.0.0) is
# the same one proven against this instance by the upload side.
# Deliberately NOT v7: it moves to node24 and upstream requires runner
# >= 2.327.1 for it, which act_runner does not claim to satisfy.
# Pinned, not tagged — the mirror auto-syncs every 8h.
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
# Tag and dev pushes — android-release just produced this. Only `main`
# takes the "Bundle latest release APK" path below, because it is the
# one ref that moves a channel without building an APK of its own.
if: >-
steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
# Consuming half of the pair: stock download-artifact, which works here for
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
# out of the bundle). v8 runs on node24, which every CI-runner image
# carries — the runner uses the image's own node.
uses: actions/download-artifact@v8
with:
name: minstrel-apk
path: client/
- name: Stage bundled APK + version sidecar
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
if: >-
steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
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.
# All three pulled from android-release's outputs so the sidecar the
# server hands clients matches exactly what is baked into the APK
# they are comparing against.
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
APK_VERSION_CODE: ${{ needs.android-release.outputs.version_code }}
APK_CHANNEL: ${{ needs.android-release.outputs.channel }}
run: |
set -euxo pipefail
# 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 "${APK_VERSION_NAME}" > client/minstrel.apk.version
printf '{"name":"%s","code":%s,"channel":"%s"}\n' \
"${APK_VERSION_NAME}" "${APK_VERSION_CODE}" "${APK_CHANNEL}" \
> client/minstrel.apk.version
cat client/minstrel.apk.version
ls -lh client/
- name: Bundle latest release APK (non-tag :latest builds)
# Main pushes don't build an APK, but they DO move :latest — so
# without this the in-app update channel would vanish from :latest
# until the next tag. Pull the most-recent release's signed APK and
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
# TAG) — identical to android-release's formula) so the version
# sidecar the server hands clients matches the installed build.
# the sidecar published beside it, so what the server reports is what
# that build actually recorded rather than something re-derived here.
# Degrades to an empty client/ (404 update channel) — never a wrong
# version — if no release / APK asset / tag-count can be resolved.
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
# version — if no release or APK asset can be resolved. That
# degradation only actually works because the greps below carry
# `|| true`; under the runner's default pipefail a non-matching grep
# kills the step instead of falling through to the empty-case branch.
if: steps.guard.outputs.ready == 'true' && github.ref == 'refs/heads/main'
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
@@ -301,24 +844,130 @@ jobs:
if [ -z "${REL_JSON}" ]; then
echo "::notice::no published release — image ships without bundled APK"; exit 0
fi
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
# `|| true` on every one of these, and it is load-bearing rather
# than defensive habit. The runner already invokes this shell as
# `bash -e -o pipefail`, so a pipeline whose grep matches NOTHING
# exits non-zero even though `head` succeeded — and the step dies at
# the assignment, before ever reaching the `if` written to handle the
# empty case. Every "degrades gracefully" branch below is unreachable
# without this.
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)" || true
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)" || true
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
fi
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
if [ -z "${COUNT}" ]; then
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
fi
VERSION_NAME="${TAG#v}.${COUNT}"
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
echo "${VERSION_NAME}" > client/minstrel.apk.version
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
# Take the version the release RECORDED rather than recomputing it.
# Re-deriving it from the tagged commit would keep the formula in two
# files that must stay in step, and could only ever recover the name —
# the ordering key is build-time minutes and exists nowhere after that
# build ends.
SIDECAR_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk\.version$' | head -1)" || true
if [ -n "${SIDECAR_URL}" ]; then
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk.version "${SIDECAR_URL}"
cat client/minstrel.apk.version
else
# Releases published before sidecars were attached. Their name is
# still recoverable from the tag, but their ordering key genuinely
# is not — so it is reported ABSENT rather than guessed. A wrong
# key is an install the platform refuses; an absent one just tells
# the client to fall back to comparing names, which is exactly
# what those builds already do.
echo "::notice::release ${TAG} predates the version sidecar — bundling with name only, no ordering key"
printf '{"name":"%s","code":null,"channel":"stable"}\n' "${TAG#v}" > client/minstrel.apk.version
fi
echo "::notice::bundled release APK from ${TAG}"
ls -lh client/
- name: Build and push
if: steps.guard.outputs.ready == 'true'
# --pull: the Dockerfile's base images are floating tags (golang:1.26,
# debian:bookworm-slim). Without it the runner's daemon reuses
# whatever it cached, and the shipped binary can sit on a Go patch
# release govulncheck already flagged while the lane, which pulls
# fresh, reports clean.
run: |
docker buildx build \
docker buildx build --pull \
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
--build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \
--push ${{ steps.tags.outputs.args }} .
# Verifies a tag release actually ended up complete, and names the specific
# thing that's missing if not.
#
# A release can be incomplete while looking fine. If android-release never
# starts (a runner failing to launch), its steps all report `failure` with
# none executed and image-release shows `skipped`. The run is red, but the
# release page renders and main's own push build has already moved
# `:latest`, so a release missing its APK and its image is easy to skim
# past.
#
# This job cannot prevent that; it turns an incomplete release into an
# explicit, named error instead of a row of mystery step failures.
#
# `if: always()` is the whole point: it has to report precisely when the jobs
# above did NOT succeed.
verify-release:
name: Verify release artifacts (tag releases only)
needs: [android-release, release-assets, image-release]
if: ${{ always() && startsWith(github.ref, 'refs/tags/v') }}
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
steps:
- name: Release must have an APK attached
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
REPO="${GITHUB_REPOSITORY}"
REL_JSON="$(curl -fsSL \
-H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" || true)"
if [ -z "${REL_JSON}" ]; then
echo "::error::no release found for ${TAG} — the tag exists but nothing was published"
exit 1
fi
APK="$(printf '%s' "${REL_JSON}" \
| grep -oP '"browser_download_url":\s*"\K[^"]+' \
| grep -E '\.apk$' | head -1 || true)"
if [ -z "${APK}" ]; then
echo "::error::release ${TAG} has NO APK attached — in-app update will offer nothing, and the bundled-APK path on future :latest builds has no source."
echo "::error::Fix by RE-RUNNING this workflow run. Do NOT delete and re-create the tag; if it fails again the runner never started the container, and the evidence is in act_runner on the host (Gitea will hold no job log)."
exit 1
fi
echo "::notice::APK attached: ${APK}"
# The other half. Checking only the APK would report success on a release
# whose image push failed. `always()` on this job means it runs even when
# image-release failed, so without this the guard would cheerfully verify
# an incomplete release.
#
# The image checked is the :<sha> rollback target, which main's own build
# published for this same commit before the tag was cut (there are no
# version image tags). It catches an image push that silently did not
# happen, and it proves the ORDERING — a tag cut on a commit whose main
# build never completed has no rollback target, and that is worth failing
# on rather than discovering during an incident.
- name: Rollback image must exist for the tagged commit
shell: bash
run: |
set -euo pipefail
IMAGE="git.fabledsword.com/bvandeusen/minstrel"
echo "${{ secrets.CI_TOKEN }}" \
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
if ! docker manifest inspect "${IMAGE}:${GITHUB_SHA}" > /dev/null 2>&1; then
echo "::error::image ${IMAGE}:${GITHUB_SHA} does not exist — this commit has no rollback target."
echo "::error::That image is published by the MAIN build of this commit, not by the tag build. If main's build never ran or failed, fix that first; a release whose commit cannot be rolled back to is the thing this check exists to refuse."
exit 1
fi
echo "::notice::rollback target verified: ${IMAGE}:${GITHUB_SHA}"
-150
View File
@@ -1,150 +0,0 @@
name: test-go
# Go server: vet + golangci-lint + short race tests. Runs on push to
# dev/main and PRs to main, scoped to Go-side files only — web-only or
# Flutter-only diffs don't trigger this workflow.
#
# Two jobs: `test` (fast — vet + lint + `go test -short -race`, no DB) and
# `integration` (full `go test -race` against an ephemeral Postgres).
#
# Integration-job DB wiring follows the act_runner shared-daemon pattern:
# the runner's Docker daemon also runs the operator's dev compose stack,
# so service containers get NO published ports (collision) and no
# service-name DNS. We discover the service container by the job-scoped
# name filter via the mounted docker socket and reach it by bridge IP.
# The exactly-one assertion is a hard guard — pointing tests at the dev
# Postgres would truncate it (the disaster Fable #339 exists to prevent).
#
# `web/build/` has a committed placeholder index.html so go:embed succeeds
# without needing the SPA to be freshly built. Real builds happen in
# release.yml (container) and locally during dev.
on:
push:
branches: [dev, main]
paths:
- '**/*.go'
- 'go.mod'
- 'go.sum'
- 'sqlc.yaml'
- 'Makefile'
- 'internal/**'
- 'cmd/**'
- '.golangci.yml'
- '.gitea/workflows/test-go.yml'
# pull_request trigger intentionally omitted — see test-web.yml for
# the rationale (single-author repo, push covers PR-merge equivalent).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Toolchain versions
run: |
go version
golangci-lint --version
- name: Generated code matches queries (sqlc)
run: make verify-generate
- name: go vet
run: go vet ./...
- name: golangci-lint
run: golangci-lint run ./...
- name: go test (short, race)
run: go test -short -race ./...
integration:
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: minstrel
POSTGRES_PASSWORD: minstrel
POSTGRES_DB: minstrel_test
# No `ports:` — the runner shares the operator's dev compose
# Docker daemon; publishing a fixed host port collides.
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Integration suite (discover service by bridge IP, migrate, test)
run: |
set -eux
# Discover THIS job's Postgres service container via the
# mounted docker socket. act_runner attaches the job
# container and its service container(s) to a shared per-job
# network, so scope discovery to a postgres that sits on a
# network THIS job container is also on. The old
# `--filter name=integration` matched EVERY concurrent
# integration run's postgres (a dev push + the main-merge run
# overlap → 2 candidates → false "expected exactly 1" abort).
# The operator's dev compose `minstrel-postgres-*` is never on
# this job's network; skip it explicitly as belt-and-suspenders
# (a wrong target would truncate real data).
SELF=$(cat /etc/hostname)
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
test -n "$SELF_NETS"
echo "self ($SELF) networks: $SELF_NETS"
PG_ID=""
PG_NAME=""
for cid in $(docker ps --filter "ancestor=postgres:16-alpine" -q); do
nm=$(docker inspect -f '{{.Name}}' "$cid" | sed 's#^/##')
case "$nm" in *minstrel-postgres*|*_postgres_*) continue ;; esac
for net in $(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cid"); do
case " $SELF_NETS " in *" $net "*) PG_ID="$cid"; PG_NAME="$nm"; break 2 ;; esac
done
done
test -n "$PG_ID" || { echo "FATAL: no postgres service container on this job's network (self nets: $SELF_NETS)"; exit 1; }
echo "selected postgres: $PG_ID $PG_NAME"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID")
test -n "$PG_IP"
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
# Wait for Postgres to accept TCP (no health-check dependency).
for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done
# Relax durability on the throwaway CI Postgres. Our test pattern
# is dbtest.ResetDB → TRUNCATE … RESTART IDENTITY CASCADE before
# every test, and the per-TRUNCATE commit fsync is the dominant
# cost of the integration suite. The CI DB is rebuilt every run so
# fsync / full_page_writes / synchronous_commit buy nothing. Apply
# via docker exec because:
# - The act_runner `services:` block can't override the container
# command, so `postgres -c fsync=off` at boot isn't an option.
# - ALTER SYSTEM cannot run inside a transaction; psql -c
# auto-commits each statement, which is what we need.
# - fsync / full_page_writes are sighup GUCs and
# synchronous_commit is user-context, so pg_reload_conf() picks
# all three up with no restart.
# Non-fatal: a perms surprise degrades to "slower", never red CI.
docker exec "$PG_ID" psql -U minstrel -d minstrel_test \
-c "ALTER SYSTEM SET fsync = off" \
-c "ALTER SYSTEM SET synchronous_commit = off" \
-c "ALTER SYSTEM SET full_page_writes = off" \
-c "SELECT pg_reload_conf()" \
|| echo "WARN: durability relax failed; continuing"
# Apply embedded migrations to the fresh test DB, then run the
# full suite (no -short → integration tests execute). -p 1:
# every integration package TRUNCATEs the one shared test DB;
# concurrent package binaries → TRUNCATE deadlocks. Serialize
# package execution (the documented local invocation too).
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
go test -p 1 -race ./...
-44
View File
@@ -1,44 +0,0 @@
name: test-web
# Web SPA: vitest + svelte-check. Runs on push to dev/main only —
# the `pull_request` trigger is intentionally omitted because every
# branch on this repo is local-only (no fork PRs), so the dev push
# fully covers what a PR run would re-execute. Keeping both events
# doubled CI cost on every commit.
on:
push:
branches: [dev, main]
paths:
- 'web/**'
- '.gitea/workflows/test-web.yml'
# Cancel an earlier in-flight run for the same ref when a newer
# commit arrives. With cancel-in-progress, rapid re-pushes don't
# pile up zombie runs.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
defaults:
run:
working-directory: web
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install deps
run: npm ci
- name: Type-check + svelte-check
run: npm run check
- name: Vitest
run: npm test
+8 -20
View File
@@ -12,8 +12,11 @@
# Test binary, built with `go test -c`
*.test
# Bundled Android APK + version sidecar (#397). Populated by CI for
# tag releases; never committed. README in client/ explains the flow.
# `make build` output — an 18 MB binary that is never committed.
bin/
# Bundled Android APK + version sidecar. Populated by CI on every image
# build; never committed. README in client/ explains the flow.
client/minstrel.apk
client/minstrel.apk.version
@@ -39,10 +42,9 @@ go.work.sum
docs/superpowers/
# Per-machine Claude Code settings + remember-skill memory artifacts +
# project-level Claude/AI instruction files. All operator-scoped; the
# canonical project memory lives under ~/.claude/projects/ outside the
# repo. CLAUDE.md/AGENTS.md/GEMINI.md stay on the operator's machine
# only — they're tool-specific working notes, not project artifacts.
# project-level Claude/AI instruction files. All per-machine;
# CLAUDE.md/AGENTS.md/GEMINI.md are tool-specific working notes, not
# project artifacts.
.claude/
.remember/
CLAUDE.md
@@ -52,20 +54,6 @@ GEMINI.md
.windsurfrules
.aider.conf.yml
# Flutter
flutter_client/.dart_tool/
flutter_client/.flutter-plugins
flutter_client/.flutter-plugins-dependencies
flutter_client/build/
flutter_client/.idea/
flutter_client/ios/Podfile.lock
flutter_client/ios/Pods/
flutter_client/android/.gradle/
flutter_client/android/app/build/
flutter_client/android/local.properties
flutter_client/android/key.properties
flutter_client/*.iml
# Native Android (Kotlin/Compose) — M8 rewrite
android/.gradle/
android/.kotlin/
+26 -10
View File
@@ -7,7 +7,7 @@ RUN npm ci
COPY web/ ./
RUN npm run build
FROM golang:1.25-bookworm AS builder
FROM golang:1.26-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
@@ -15,17 +15,31 @@ COPY . .
# Overwrite the committed placeholder with the freshly-built SPA assets.
COPY --from=web /web/build ./web/build
ENV CGO_ENABLED=0
# Version stamping: release.yml passes the git tag via MINSTREL_VERSION
# build-arg; local `docker build` falls back to "dev". Surfaced at
# /healthz for operator-side image-version verification.
# Version stamping. release.yml passes the DERIVED version name
# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back
# to "dev"/"local". Both are surfaced at /healthz.
#
# These are two values on purpose: the same commit built on dev and on main
# reports the same NAME and differs only in CHANNEL. Folding the channel
# into the version string would make two dev images eight weeks apart
# indistinguishable.
ARG MINSTREL_VERSION=dev
ARG MINSTREL_CHANNEL=local
RUN go build -trimpath \
-ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
-ldflags="-s -w \
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo.Version=${MINSTREL_VERSION}' \
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo.Channel=${MINSTREL_CHANNEL}'" \
-o /out/minstrel ./cmd/minstrel
FROM debian:bookworm-slim
# ffmpeg: duration probes and the exact-tier audio hash (a SHA-256 of the
# encoded audio packets, so no decode). libchromaprint-tools: fpcalc, the
# acoustic fingerprint that tells the same recording at two bitrates apart
# from two different recordings. Both are baked in at build time so a
# deployed instance never fetches either; fpcalc is shelled out
# rather than bound because CGO_ENABLED=0 above rules out cgo.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg libchromaprint-tools \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1000 minstrel \
@@ -42,10 +56,12 @@ COPY config.example.yaml /etc/smartmusic/config.yaml
RUN mkdir -p /app/data /app/client && chown -R minstrel:minstrel /app
WORKDIR /app
# In-app update channel (#397). client/ in the build context holds
# minstrel.apk + minstrel.apk.version (populated by release.yml on tag
# pushes; .gitkeep + README otherwise). Endpoints return 404 when the
# APK files aren't present, so non-tag images degrade gracefully.
# In-app update channel. client/ in the build context holds
# minstrel.apk + minstrel.apk.version, which release.yml fills on every
# build (dev and tag pushes bundle their own APK, main bundles the latest
# release's); a local build has only .gitkeep + README. Endpoints return
# 404 when the APK files aren't present, so such an image degrades
# gracefully.
COPY --chown=minstrel:minstrel client/ /app/client/
USER minstrel
+4 -7
View File
@@ -13,11 +13,8 @@ generate:
generate-go:
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v$(SQLC_VERSION) generate
# Fail if the committed generated code no longer matches the .sql sources.
#
# Nothing verified this before, so internal/db/dbq could silently drift from
# internal/db/queries — a hand-edit, a half-applied regen, or a schema change
# without a regen would all pass CI while the typed layer lied about the SQL.
# Fail if the committed generated code no longer matches the .sql sources:
# a hand-edit, a half-applied regen, or a schema change without a regen.
#
# The diff is printed BEFORE the exit-code check on purpose: when this fails,
# the log then contains sqlc's exact expected output, which is what you commit.
@@ -36,8 +33,8 @@ test-short:
go test -short -race ./...
# Full suite incl. integration tests, against the dedicated minstrel_test
# DB so a run never truncates the dev `minstrel` DB (Fable #339). Ensures
# the test DB exists (idempotent — createdb errors if present, ignored).
# DB so a run never truncates the dev `minstrel` DB. Ensures the test DB
# exists (createdb fails harmlessly if it is already there).
test-integration:
docker compose up -d postgres
-docker compose exec -T postgres createdb -U minstrel minstrel_test
+81 -89
View File
@@ -1,21 +1,14 @@
# Minstrel
<p align="center"><img src="web/static/brand/mark.svg" width="120" alt="Minstrel logo"></p>
A self-hosted music server that thinks for you. Smart shuffle, contextual likes, ListenBrainz-aware radio, and Lidarr automation — server-side, so every client (web, mobile, Subsonic third-party) gets the same intelligence.
<h1 align="center">Minstrel</h1>
> State and intelligence belong on the server, not the client.
<p align="center">A self-hosted music server that picks what to play next.</p>
Minstrel serves your music library to the web, to its own Android app, and to any Subsonic client. Shuffle, radio, likes and play history are worked out on the server, so every device gets the same mixes and the same history. It can also find, download and file the albums you want, through a search service and a download client you run yourself. It is for anyone who keeps their own music collection and wants it to sound like more than a random shuffle.
<a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
## Highlights
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
## Quickstart
## Quick start
```yaml
# compose.yaml
@@ -24,18 +17,10 @@ services:
image: git.fabledsword.com/bvandeusen/minstrel:latest
ports: ['4533:4533']
volumes:
# Your music library. Point ./music at wherever your audio files
# live. Mounted read-only — Minstrel never writes to your library.
- ./music:/music:ro
# Generated data: playlist cover collages, artist art, caches.
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
# sets to /app/data — keep this mount on /app/data or your cache
# won't survive a container recreate.
- minstrel-data:/app/data
- ./music:/music # your library; must be writable by uid 1000
- minstrel-data:/app/data # art and caches
environment:
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
# Colon-separated library roots to scan; must match the container
# path of the read-only music mount above (/music here).
MINSTREL_LIBRARY_SCAN_PATHS: /music
depends_on: [db]
@@ -45,9 +30,7 @@ services:
POSTGRES_USER: minstrel
POSTGRES_PASSWORD: minstrel
POSTGRES_DB: minstrel
# Postgres data dir — users, likes, play history, sessions, settings.
# The one volume you must never lose; back it up with pg_dump.
volumes: [pgdata:/var/lib/postgresql/data]
volumes: [pgdata:/var/lib/postgresql/data] # users, likes, history: back this up
volumes:
minstrel-data:
@@ -56,92 +39,101 @@ volumes:
```bash
docker compose up -d
docker compose logs minstrel | grep setup_token
```
## First run
Open `http://<host>:4533/register` and create the first account with that setup token. The first account is the administrator.
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address (plain `http://` is fine — no TLS required).
### First run
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance is automatically the administrator; later users join through the same form or an invite token (step 5).
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
**2. Let the first library scan finish.** `scan_on_startup` is on by default, so Minstrel walks your mounted library on boot and imports artists, albums, and tracks — no button to press. Watch progress (and re-scan any time) on the **Admin** page (`/admin`); the scan runs in stages and is incremental, so later restarts only pick up what changed.
1. **Let the first scan finish.** Minstrel scans the library on startup. Watch it, or scan again, on the **Admin** page (`/admin`). Later scans only pick up what changed.
2. **Install the Android app.** In **Settings** (`/settings`), the *Install the Android app* card downloads the app this server ships. It updates itself from your server after that.
3. **Invite the household.** In **Admin › Users** (`/admin/users`), make an invite token or turn on open registration. Each person gets their own likes, history and recommendations.
4. **Optional: connect services.** Add a search service and a download client in **Admin › Acquisition** (`/admin/acquisition`). Each person adds their own ListenBrainz token in **Settings › Connections**.
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
**3. (Optional) Name the instance and wire up integrations.** In admin **Settings → Integrations** (`/admin/integrations`), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="ListenBrainz and Lidarr integration cards in admin Settings"></a>
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
<a href="docs/screenshots/android-download.png"><img src="docs/screenshots/android-download.png" width="820" alt="The &quot;Install the Android app&quot; download card in Settings"></a>
**5. Invite the rest of the household.** From admin **Users** (`/admin/users`), generate an invite token (or enable open registration). Each person gets their own account, so likes, play history, and recommendations stay per-user.
<a href="docs/screenshots/invite-users.png"><img src="docs/screenshots/invite-users.png" width="820" alt="Generating an invite token in admin Users"></a>
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
## Configuration
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
The quick start sets everything that's required. These are the settings people most often change:
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
- `MINSTREL_STORAGE_DATA_DIR` — where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
| Variable | What it does |
|---|---|
| `MINSTREL_DATABASE_URL` | Postgres connection string. Required. |
| `MINSTREL_LIBRARY_SCAN_PATHS` | Library folders inside the container, colon-separated (`/music:/audiobooks`). They are used on first start only; after that, change them in **Admin › Library**. |
| `MINSTREL_BRANDING_APP_NAME` | The name shown in the header, the browser tab and link previews. |
| `MINSTREL_LOG_LEVEL` | `debug`, `info`, `warn` or `error`. |
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
Search services, download clients, ListenBrainz, email and recommendation tuning are set in the web UI and take effect without a restart. The full file-based surface is in [`config.example.yaml`](./config.example.yaml); to use it, mount your copy at `/etc/smartmusic/config.yaml`.
Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommendation and events tuning are yaml-only. See [`config.example.yaml`](./config.example.yaml) for the authoritative surface.
## Setup pitfalls
## Updating
- **The library mount has to be writable by uid 1000.** Minstrel files the albums it downloads into the library, and it deletes a file when an admin asks it to. It never moves, renames or retags files that were already there. Mount the library `:ro` to forbid writes: those actions then refuse and say why.
- **`MINSTREL_LIBRARY_SCAN_PATHS` names the path inside the container** (`/music`), not the folder on your host.
- **On the internet, put it behind HTTPS.** Plain `http://` is fine on a network you trust. For a public server, bind the port to `127.0.0.1` and put a reverse proxy in front: see [docs/hosting.md](docs/hosting.md).
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
## Updating and backups
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
- `:vYYYY.MM.DD` — immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.)
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
```bash
docker compose pull && docker compose up -d
```
Every `:latest` and every `:vYYYY.MM.DD` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
Database migrations run at startup. Image tags:
## Specs
- `:latest`: the stable release. Most people should run this.
- `:<commit-sha>`: one fixed build. Pin one to stop updates, or to roll back.
- `:dev`: the test channel, rebuilt on every change. It may break, and the fix is the next build.
Authoritative scope lives under [`docs/`](./docs):
Every image carries a signed Android app, all with the same key, so a phone can move between channels without reinstalling. Rolling back a SHA tag rolls back the app it carries too.
- [Server spec](./docs/smart-music-server-spec.md) — current implementation focus.
- [Client spec](./docs/smart-music-client-spec.md) — Flutter companion app.
**Back up the Postgres volume** with `pg_dump`. It holds accounts, likes, play history, playlists and settings, and a migration can only be undone by restoring a dump. The `minstrel-data` volume holds artwork and caches that Minstrel rebuilds if lost.
## Features
**Listening**
- Smart shuffle that weighs what you like, what you skip and what you've heard lately, instead of picking at random.
- Two kinds of like: a song you love, and a song that's right for what you're listening to now.
- Radio that starts from any song, using ListenBrainz similarity as well as your own history.
- Daily mixes and system playlists built from your listening.
- Playback on Sonos and other UPnP speakers from the Android app.
- Loudness levelling between tracks, so you never reach for the volume.
**Your library**
- Browse by artist, album, genre and year, with search across everything.
- Duplicates, missing files and suspect copies are found and listed under **Collection** for an admin to fix.
- Missing album art and MusicBrainz ids are filled in automatically.
**Getting music**
- A wanted list: requests from the household, artists you follow, and albums whose files went missing.
- Minstrel searches the indexers you configure, hands the best release to your download client, checks what arrives against MusicBrainz, tags it, and files it. A better copy replaces a poor one.
- Off until you add a search service and a download client.
**Clients and accounts**
- A full web app, built into the server.
- A native Android app that ships inside the server image, keeps playing what it has cached when the server is out of reach, and updates itself from your server.
- OpenSubsonic-compatible, so existing clients such as DSub, Symfonium and play:Sub connect as they are.
- Separate accounts for each person, with invites, and an inbox for what happened while you were away.
## Scope and responsible use
Minstrel indexes the files you point it at and streams them to your own clients. It ships no indexers, no trackers, no torrent, Usenet or NZB engine of its own, and no DRM circumvention.
Getting music is optional and does nothing until you configure it. It searches only the search service (Prowlarr or a Torznab indexer) and the download client (qBittorrent) you add yourself.
What you put in your library, and the sources you configure, are your responsibility. Copyright law applies to your collection as it does to any other software that plays a file; please respect it, and the terms of any service you connect.
Minstrel is not affiliated with or endorsed by ListenBrainz, MusicBrainz, Prowlarr, qBittorrent or Subsonic.
## Development
Two concurrent dev processes:
- **Backend:** `docker compose up` in this repo runs Postgres and Minstrel on `:4533`.
- **Web:** `cd web && npm install && npm run dev` starts Vite on `:5173`. It proxies `/api/*` and `/rest/*` to `:4533`.
- **Tests:** `make test-short` runs the unit tests. `make test-integration` also runs the database tests, against a separate `minstrel_test` database so your dev data is never truncated.
- **Image:** `docker build -t minstrel .` builds the web app and embeds it in the server binary.
- **Branches:** work lands on `dev`; `main` changes through pull requests from `dev`.
1. **Backend:** `docker compose up` — Postgres + Minstrel on `:4533`.
2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR. The Vite server proxies `/api/*` and `/rest/*` to `:4533` so session cookies work.
### Testing
- Unit + race (no DB): `make test-short`.
- Full suite incl. integration tests: `make test-integration`. This runs
against a dedicated `minstrel_test` database so a test run never
truncates your dev `minstrel` data (admin user, library, likes). It
brings up the compose Postgres and creates the test DB if missing.
- CI runs both: a fast `go test -short -race` gate plus an integration
job with its own ephemeral Postgres (`.gitea/workflows/test-go.yml`).
### Production build
`docker build -t minstrel .` runs the SvelteKit build inside a `node` stage, copies the output into the `golang` stage, and `//go:embed`s it into the final binary. The container serves the SPA from `/` alongside the API surfaces; no separate static-file server is required.
### Branches
- Day-to-day work happens on `dev` (or feature branches merged into `dev`).
- `main` is **protected** — changes land via PR from `dev`.
- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Gitea registry.
Task and milestone tracking: Fable (`Minstrel` project, id 12).
How accounts and sessions are protected: [docs/security.md](docs/security.md).
## License
See [LICENSE](./LICENSE).
MIT. See [LICENSE](./LICENSE).
+21 -12
View File
@@ -1,9 +1,9 @@
plugins {
alias(libs.plugins.android.application)
// kotlin-android NOT applied: AGP 9 enables built-in Kotlin by default,
// and KSP 2.3.x supports it (PR #2674, merged Oct 2025). serialization
// + compose-compiler are language-level Kotlin compiler plugins and
// still need explicit application.
// and KSP 2.3.x supports it. serialization + compose-compiler are
// language-level Kotlin compiler plugins and still need explicit
// application.
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.ksp)
@@ -21,13 +21,19 @@ android {
applicationId = "com.fabledsword.minstrel"
minSdk = 26
targetSdk = 36
// 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).
// versionName / versionCode are released-build values injected by CI.
// Local / debug builds fall back to "dev" so the About card reads
// honestly.
//
// versionName is "YYYY.MM.DD.HHMM" from the COMMIT's timestamp, so
// every lane building this source reports the same string and the
// channel is the only thing that differs between them.
//
// versionCode is minutes since 2020-01-01 at BUILD time. It is the
// value the platform decides installs by, so it must be monotonic by
// construction. Not a commit count: that runs ahead on `dev`, so a dev
// build would outrank the `main` release meant to replace it and
// Android would refuse the install as a downgrade.
val versionNameOverride =
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val versionCodeOverride =
@@ -61,9 +67,13 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Signed with the release key or not at all. Falling back to the
// debug key would turn a missing secret into a published APK that no
// install could ever update. An unsigned build installs nowhere, so
// the gap shows at once.
signingConfig =
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
signingConfigs.getByName("debug")
null
} else {
signingConfigs.getByName("release")
}
@@ -150,7 +160,6 @@ dependencies {
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.material3)
implementation(libs.compose.ui.text.google.fonts)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.tooling.preview)
+38 -9
View File
@@ -8,7 +8,20 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Notifications when the app is closed: the delivery
service, and starting it again after a reboot or an update. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- In-app self-update. REQUEST_INSTALL_PACKAGES lets us hand an APK to the
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
is what lets that install happen with NO confirm dialog. The platform
grants the silent path only when the installer opts in via
SessionParams.setRequireUserAction(USER_ACTION_NOT_REQUIRED), the
installed app targets API 29+, the installer holds this permission, and
the target is the installer itself — all true here, since Minstrel is
updating Minstrel. See update/data/SelfUpdateSession.kt. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
@@ -19,9 +32,9 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Minstrel"
android:usesCleartextTraffic="true"
tools:targetApi="34">
<!-- Portrait-locked until a tablet/landscape layout exists.
@@ -48,15 +61,31 @@
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
<!-- Keeps the process alive so notifications arrive with the app
closed. specialUse: dataSync is stopped after six hours on
Android 15, and shortService after three minutes. -->
<service
android:name=".notifications.delivery.DeliveryService"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintains the connection to the user's own Minstrel server that delivers their notifications, in place of a third-party push service." />
</service>
<!-- Starts delivery after a reboot or an update; both broadcasts may
start a foreground service from the background. -->
<receiver
android:name=".notifications.delivery.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- No FileProvider: the self-update streams the downloaded APK into a
PackageInstaller session, so nothing needs a content:// URI. -->
<!-- On-demand WorkManager initialization: MinstrelApplication
implements Configuration.Provider and supplies the
@@ -1,10 +1,14 @@
package com.fabledsword.minstrel
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
@@ -16,9 +20,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.cache.CachedTrackIds
import com.fabledsword.minstrel.connectivity.LocalServerHealth
@@ -27,7 +34,10 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph
import com.fabledsword.minstrel.nav.Notifications
import com.fabledsword.minstrel.nav.NowPlaying
import com.fabledsword.minstrel.notifications.delivery.deliveryWanted
import com.fabledsword.minstrel.notifications.ui.routeForLink
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
import com.fabledsword.minstrel.theme.MinstrelTheme
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
@@ -35,6 +45,10 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
@@ -42,41 +56,72 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds
@Inject lateinit var serverHealth: NetworkStatusController
@Inject lateinit var authStore: AuthStore
// 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)
// Set when the user taps a notification: the media one asks for the full
// player, a Minstrel notice for what it is about. The App composable
// navigates there once the NavHost is ready, then calls back to clear it
// so the navigation doesn't re-fire on the next recomposition.
private val pendingRoute = MutableStateFlow<Any?>(null)
// The answer needs no handling: the system remembers it, and the
// notification settings screen reads it on every resume.
private val askToNotify = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
consumeOpenNowPlayingIntent(intent)
consumeRouteIntent(intent)
askToNotifyOnceWanted()
setContent {
App(
seedCache = seedCache,
cachedTrackIds = cachedTrackIds,
serverHealth = serverHealth,
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
pendingRoute = pendingRoute.asStateFlow(),
onOpenedRoute = { pendingRoute.value = null },
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
consumeOpenNowPlayingIntent(intent)
consumeRouteIntent(intent)
}
private fun consumeOpenNowPlayingIntent(intent: Intent?) {
if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
pendingOpenNowPlaying.value = true
private fun consumeRouteIntent(intent: Intent?) {
if (intent == null) return
if (intent.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false)) {
pendingRoute.value = NowPlaying
// Strip the extra so a subsequent config-change recreation
// doesn't re-trigger the navigation.
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
}
intent.getStringExtra(EXTRA_NOTIFICATION_LINK)?.let { link ->
// A notice the app has no screen for, or a pile of them, opens
// the inbox.
pendingRoute.value = routeForLink(link) ?: Notifications
intent.removeExtra(EXTRA_NOTIFICATION_LINK)
}
}
/**
* Android 13+ asks before an app may post notifications.
* Asked once delivery is wanted (signed in, background delivery on),
* each launch until answered: after a second "no" the system stops
* showing the prompt by itself, and Settings → Notifications links to
* the system page.
*/
private fun askToNotifyOnceWanted() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
lifecycleScope.launch {
val signedIn = authStore.sessionCookie.map { !it.isNullOrEmpty() }
combine(signedIn, authStore.backgroundDelivery, ::deliveryWanted).first { it }
val permission = Manifest.permission.POST_NOTIFICATIONS
val granted = ContextCompat.checkSelfPermission(this@MainActivity, permission) ==
PackageManager.PERMISSION_GRANTED
if (!granted) askToNotify.launch(permission)
}
}
companion object {
@@ -84,6 +129,10 @@ class MainActivity : ComponentActivity() {
* 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"
/** PendingIntent extra on a Minstrel notice: the web path it links to,
* or empty for a pile, which opens the inbox. */
const val EXTRA_NOTIFICATION_LINK = "com.fabledsword.minstrel.action.NOTIFICATION_LINK"
}
}
@@ -92,15 +141,15 @@ private fun App(
seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds,
serverHealth: NetworkStatusController,
pendingOpenNowPlaying: StateFlow<Boolean>,
onOpenedNowPlaying: () -> Unit,
pendingRoute: StateFlow<Any?>,
onOpenedRoute: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(),
gate: AuthGateViewModel = hiltViewModel(),
) {
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
val pending by pendingRoute.collectAsStateWithLifecycle()
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
CompositionLocalProvider(
LocalDetailSeedCache provides seedCache,
@@ -119,16 +168,14 @@ private fun App(
// 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.
// mounted. launchSingleTop avoids stacking copies of a
// screen if the user taps the notification while already
// on it; the callback clears it so a later recomposition
// (config change, theme switch) doesn't re-navigate.
LaunchedEffect(pending, navController) {
if (pending) {
navController.navigate(NowPlaying) { launchSingleTop = true }
onOpenedNowPlaying()
}
val route = pending ?: return@LaunchedEffect
navController.navigate(route) { launchSingleTop = true }
onOpenedRoute()
}
MinstrelNavGraph(
navController = navController,
@@ -8,6 +8,7 @@ import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import com.fabledsword.minstrel.cache.CacheIndexer
import com.fabledsword.minstrel.cache.audiocache.CacheBudgetSync
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.di.ApplicationScope
@@ -16,12 +17,13 @@ import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEventsDispatcher
import com.fabledsword.minstrel.metadata.FreshnessSweeper
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
import com.fabledsword.minstrel.player.AudioPrefetcher
import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.LikedAudioPinner
import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter
import com.fabledsword.minstrel.player.ResumeController
import com.fabledsword.minstrel.update.data.UpdateBannerController
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
@@ -75,6 +77,14 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var mutationReplayer: MutationReplayer
/**
* Same construct-the-singleton trick — DeliveryLauncher starts and stops
* the background-delivery service and runs the notification catch-up on
* every nudge and reconnect. Without this @Inject no phone
* notification would ever be posted.
*/
@Suppress("unused") @Inject lateinit var deliveryLauncher: DeliveryLauncher
/**
* Same construct-the-singleton trick — PlayEventsReporter's init
* block subscribes to PlayerController.uiState and reports the
@@ -111,6 +121,21 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer
/**
* Same construct-the-singleton trick — CacheBudgetSync's init block
* feeds the audio cache's evictor the liked set and the Storage caps.
* Without this @Inject the evictor would never arm, and the cache
* would grow without a limit.
*/
@Suppress("unused") @Inject lateinit var cacheBudgetSync: CacheBudgetSync
/**
* Same construct-the-singleton trick — LikedAudioPinner's init block
* downloads liked tracks into the Liked songs budget. Without this
* @Inject, liking a track would not keep it offline.
*/
@Suppress("unused") @Inject lateinit var likedAudioPinner: LikedAudioPinner
/**
* Same construct-the-singleton trick — AudioPrefetcher's init
* block subscribes to PlayerController.uiState +
@@ -129,14 +154,6 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
/**
* Same construct-the-singleton trick — UpdateBannerController polls
* /api/client/version at launch + every 24h and drives the shell's
* soft "update available" banner. Without this @Inject the poll
* loop would never start and the banner would never surface.
*/
@Suppress("unused") @Inject lateinit var updateBannerController: UpdateBannerController
/**
* Same construct-the-singleton trick — FreshnessSweeper's init
* block starts a 30-min sweep loop that re-fetches cached
@@ -162,7 +179,7 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
// Device diagnostics (M9). The reporter gates itself on the account's
// Device diagnostics. The reporter gates itself on the account's
// debug flag; the uploader drains its buffer on a tick / recovery.
@Suppress("unused") @Inject lateinit var diagnosticsReporter: DiagnosticsReporter
@Suppress("unused") @Inject lateinit var diagnosticsUploader: DiagnosticsUploader
@@ -172,7 +189,7 @@ class MinstrelApplication :
override fun onCreate() {
super.onCreate()
// Debug builds get the full DebugTree (verbose). Release builds
// get a WARN+ tree so operator-driven diagnosis via `adb logcat`
// get a WARN+ tree so diagnosis via `adb logcat`
// still surfaces UPnP / cast failures, OkHttp errors, and our
// own Timber.w / Timber.e calls — without the chatty DEBUG /
// INFO traffic flooding the buffer in production.
@@ -220,10 +237,9 @@ class MinstrelApplication :
* before Coil makes its first request.
*
* Crossfade is set here rather than per-call so every cover surface
* in the app fades its artwork in instead of snapping it. Art
* landing a beat after its tile was the most visible pop-in on Home
* (issue #2327); `ServerImage` fades its placeholder out over the
* same window so the two read as one cross-dissolve.
* in the app fades its artwork in instead of snapping it, and
* `ServerImage` fades its placeholder out over the same window so the
* two read as one cross-dissolve.
*/
override fun newImageLoader(context: android.content.Context): ImageLoader =
ImageLoader.Builder(context)
@@ -11,10 +11,9 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Read-through accessor for the admin quarantine queue. Mirrors the
* Flutter `AdminQuarantineController`. No Room cache — same rationale
* as AdminRequests. Three resolution actions all delete the row from
* the queue once the server accepts them.
* Read-through accessor for the admin quarantine queue. No Room cache —
* same rationale as AdminRequests. Three resolution actions all delete
* the row from the queue once the server accepts them.
*/
@Singleton
class AdminQuarantineRepository @Inject constructor(
@@ -27,8 +26,6 @@ class AdminQuarantineRepository @Inject constructor(
suspend fun resolve(trackId: String) = api.resolve(trackId)
suspend fun deleteFile(trackId: String) = api.deleteFile(trackId)
suspend fun deleteViaLidarr(trackId: String) = api.deleteViaLidarr(trackId)
}
// ── Mappers ──
@@ -49,7 +46,6 @@ private fun AdminQuarantineItemWire.toDomain(): AdminQuarantineItemRef =
artistName = artistName,
albumTitle = albumTitle,
albumId = albumId,
lidarrAlbumMbid = lidarrAlbumMbid,
reportCount = reportCount,
latestAt = latestAt,
reasonCounts = reasonCounts,
@@ -11,12 +11,10 @@ import javax.inject.Singleton
/**
* Read-through accessor for the admin cross-user requests queue.
* Mirrors `flutter_client/lib/admin/admin_providers.dart`'s
* AdminRequestsController.
*
* No Room caching — admin actions are infrequent and don't benefit
* from offline scrollback. `approve` and `reject` fire direct REST
* (no mutation queue) because operator confirmation is point-and-shoot;
* (no mutation queue) because admin confirmation is point-and-shoot;
* if the request fails the UI surfaces the error and rolls back.
*/
@Singleton
@@ -35,6 +33,7 @@ class AdminRequestsRepository @Inject constructor(
private fun RequestWire.toDomain(): RequestRef = RequestRef(
id = id,
userId = userId,
username = username,
status = RequestStatus.fromWire(status),
kind = kind,
artistName = artistName,
@@ -12,7 +12,7 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Read-through accessor for the tag-enrichment provider settings (#1521).
* Read-through accessor for the tag-enrichment provider settings.
* No Room caching — admin settings are infrequent point-and-shoot edits;
* mutations fire direct REST and the ViewModel reconciles on failure.
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
@@ -13,8 +13,8 @@ import javax.inject.Singleton
/**
* Read-through accessor for the admin users list + the four per-user
* mutations. Mirrors Flutter's `AdminUsersController`. Same
* no-Room-cache, no-MutationQueue pattern as the other admin slices.
* mutations. Same no-Room-cache, no-MutationQueue pattern as the other
* admin repositories.
*/
@Singleton
class AdminUsersRepository @Inject constructor(
@@ -39,7 +39,7 @@ class AdminInvitesViewModel @Inject constructor(
init {
refresh()
// Screen-level auto-recovery (issue #1245): reload a failed list
// Screen-level auto-recovery: reload a failed list
// when server health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
@@ -41,6 +41,7 @@ import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
@@ -112,6 +113,7 @@ fun AdminLandingScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -152,7 +154,7 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
SectionCard(
icon = Lucide.Inbox,
title = "Requests",
subtitle = "Approve or reject Lidarr requests",
subtitle = "Approve or reject requests for new music",
count = counts.requests,
onClick = { navController.navigate(AdminRequests) },
)
@@ -9,16 +9,19 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
@@ -28,6 +31,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -41,7 +45,14 @@ fun AdminQuarantineScreen(
viewModel: AdminQuarantineViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.transientMessages.collect { msg ->
snackbarHostState.showSnackbar(msg)
}
}
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -51,6 +62,7 @@ fun AdminQuarantineScreen(
onBack = { navController.popBackStack() },
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { inner ->
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
@@ -72,7 +84,6 @@ fun AdminQuarantineScreen(
rows = s.rows,
onResolve = viewModel::resolve,
onDeleteFile = viewModel::deleteFile,
onDeleteViaLidarr = viewModel::deleteViaLidarr,
)
}
}
@@ -84,7 +95,6 @@ private fun QueueList(
rows: List<AdminQuarantineItemRef>,
onResolve: (String) -> Unit,
onDeleteFile: (String) -> Unit,
onDeleteViaLidarr: (String) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.trackId }) { row ->
@@ -92,7 +102,6 @@ private fun QueueList(
row = row,
onResolve = { onResolve(row.trackId) },
onDeleteFile = { onDeleteFile(row.trackId) },
onDeleteViaLidarr = { onDeleteViaLidarr(row.trackId) },
)
HorizontalDivider()
}
@@ -104,7 +113,6 @@ private fun QuarantineRow(
row: AdminQuarantineItemRef,
onResolve: () -> Unit,
onDeleteFile: () -> Unit,
onDeleteViaLidarr: () -> Unit,
) {
Column(
modifier = Modifier
@@ -153,7 +161,6 @@ private fun QuarantineRow(
ActionButtons(
onResolve = onResolve,
onDeleteFile = onDeleteFile,
onDeleteViaLidarr = onDeleteViaLidarr,
)
}
}
@@ -162,7 +169,6 @@ private fun QuarantineRow(
private fun ActionButtons(
onResolve: () -> Unit,
onDeleteFile: () -> Unit,
onDeleteViaLidarr: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -170,6 +176,5 @@ private fun ActionButtons(
) {
TextButton(onClick = onResolve) { Text("Resolve") }
OutlinedButton(onClick = onDeleteFile) { Text("Delete file") }
Button(onClick = onDeleteViaLidarr) { Text("Delete + Lidarr") }
}
}
@@ -10,10 +10,13 @@ import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -34,6 +37,15 @@ class AdminQuarantineViewModel @Inject constructor(
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
val uiState: StateFlow<AdminQuarantineUiState> = internal.asStateFlow()
/**
* One-shot messages for the screen's snackbar. A failed action has to say
* why: the row quietly reappearing reads as a glitch, and for a Delete
* file refused by a read-only library it hides the one thing an
* admin can fix.
*/
private val transientMessagesChannel = Channel<String>(Channel.BUFFERED)
val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow()
init {
refresh()
viewModelScope.launch {
@@ -41,7 +53,7 @@ class AdminQuarantineViewModel @Inject constructor(
.filter { it.kind.startsWith("quarantine.") }
.collect { refresh() }
}
// Screen-level auto-recovery (issue #1245): reload a failed list
// Screen-level auto-recovery: reload a failed list
// when server health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
@@ -70,7 +82,6 @@ class AdminQuarantineViewModel @Inject constructor(
fun resolve(trackId: String) = act(trackId) { repository.resolve(it) }
fun deleteFile(trackId: String) = act(trackId) { repository.deleteFile(it) }
fun deleteViaLidarr(trackId: String) = act(trackId) { repository.deleteViaLidarr(it) }
private fun act(trackId: String, action: suspend (String) -> Unit) {
val before = internal.value
@@ -86,8 +97,9 @@ class AdminQuarantineViewModel @Inject constructor(
try {
action(trackId)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
transientMessagesChannel.trySend(ErrorCopy.fromThrowable(e))
refresh()
}
}
@@ -27,6 +27,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -41,6 +42,7 @@ fun AdminRequestsScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -59,7 +61,7 @@ fun AdminRequestsScreen(
AdminRequestsUiState.Loading -> LoadingCentered()
AdminRequestsUiState.Empty -> EmptyState(
title = "No requests waiting",
body = "When users ask Lidarr for new music, their pending " +
body = "When users ask for new music, their pending " +
"requests show up here for approval.",
)
is AdminRequestsUiState.Error -> ErrorRetry(
@@ -69,7 +71,6 @@ fun AdminRequestsScreen(
)
is AdminRequestsUiState.Success -> RequestList(
rows = s.rows,
usernames = s.usernames,
onApprove = viewModel::approve,
onReject = viewModel::reject,
)
@@ -81,7 +82,6 @@ fun AdminRequestsScreen(
@Composable
private fun RequestList(
rows: List<RequestRef>,
usernames: Map<String, String>,
onApprove: (String) -> Unit,
onReject: (String) -> Unit,
) {
@@ -89,9 +89,9 @@ private fun RequestList(
items(items = rows, key = { it.id }) { req ->
AdminRequestRow(
req = req,
requester = usernames[req.userId]
?: req.userId.takeIf { it.isNotEmpty() }?.take(USER_ID_PREFIX_LEN)
?: "unknown",
requester = req.username.ifEmpty {
req.userId.take(USER_ID_PREFIX_LEN).ifEmpty { "unknown" }
},
onApprove = { onApprove(req.id) },
onReject = { onReject(req.id) },
)
@@ -158,6 +158,6 @@ private fun AdminRequestRow(
}
}
// UUID prefix length used as a friendlier-than-raw-UUID fallback when
// the admin users list fetch fails and we can't resolve userId → name.
// UUID prefix length used as a friendlier-than-raw-UUID fallback when a
// row comes back without the requester's username.
private const val USER_ID_PREFIX_LEN = 8
@@ -3,7 +3,6 @@ package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
@@ -11,8 +10,6 @@ import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -25,17 +22,13 @@ private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
sealed interface AdminRequestsUiState {
data object Loading : AdminRequestsUiState
data object Empty : AdminRequestsUiState
data class Success(
val rows: List<RequestRef>,
val usernames: Map<String, String>,
) : AdminRequestsUiState
data class Success(val rows: List<RequestRef>) : AdminRequestsUiState
data class Error(val message: String) : AdminRequestsUiState
}
@HiltViewModel
class AdminRequestsViewModel @Inject constructor(
private val repository: AdminRequestsRepository,
private val usersRepository: AdminUsersRepository,
private val eventsStream: EventsStream,
networkStatus: NetworkStatusController,
) : ViewModel() {
@@ -50,7 +43,7 @@ class AdminRequestsViewModel @Inject constructor(
.filter { it.kind in RELEVANT_EVENT_KINDS }
.collect { refresh() }
}
// Screen-level auto-recovery (issue #1245): reload a failed list
// Screen-level auto-recovery: reload a failed list
// when server health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
@@ -62,19 +55,11 @@ class AdminRequestsViewModel @Inject constructor(
fun refresh(): Job = viewModelScope.launch {
internal.value = AdminRequestsUiState.Loading
try {
val (rows, usernames) = coroutineScope {
val requestsJob = async { repository.list() }
val usernamesJob = async {
runCatching {
usersRepository.list().associate { it.id to it.username }
}.getOrDefault(emptyMap())
}
requestsJob.await() to usernamesJob.await()
}
val rows = repository.list()
internal.value = if (rows.isEmpty()) {
AdminRequestsUiState.Empty
} else {
AdminRequestsUiState.Success(rows = rows, usernames = usernames)
AdminRequestsUiState.Success(rows = rows)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
@@ -101,7 +86,7 @@ class AdminRequestsViewModel @Inject constructor(
internal.value = if (filtered.isEmpty()) {
AdminRequestsUiState.Empty
} else {
AdminRequestsUiState.Success(rows = filtered, usernames = before.usernames)
AdminRequestsUiState.Success(rows = filtered)
}
}
viewModelScope.launch {
@@ -35,6 +35,7 @@ import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -49,6 +50,7 @@ fun AdminTagSourcesScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -38,7 +38,7 @@ class AdminTagSourcesViewModel @Inject constructor(
init {
refresh()
// Screen-level auto-recovery: reload a failed list when server
// health returns instead of waiting for a manual pull (issue #1245).
// health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is AdminTagSourcesUiState.Error) refresh()
@@ -49,6 +49,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminUserRef
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import kotlinx.coroutines.launch
@@ -127,6 +128,7 @@ private fun AdminUsersScaffold(
onRevokeInvite: (String) -> Unit,
) {
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -33,7 +33,7 @@ class AdminUsersViewModel @Inject constructor(
init {
refresh()
// Screen-level auto-recovery (issue #1245): reload a failed list
// Screen-level auto-recovery: reload a failed list
// when server health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
@@ -12,18 +12,12 @@ import javax.inject.Singleton
* 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.
* the same way BaseUrlInterceptor is. The shared OkHttpClient also
* serves Coil's image fetches to external hosts such as
* artwork.musicbrainz.org and coverartarchive.org; unscoped, this
* would hand the session identifier to third-party logging, and a 401
* from any of those hosts would sign the user out of Minstrel.
*/
@Singleton
class AuthCookieInterceptor @Inject constructor(
@@ -17,15 +17,14 @@ import javax.inject.Singleton
*
* This interceptor closes the gap: Retrofit can stay built with the
* 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).
* retargeted at the live AuthStore value, so the user can change the
* server URL in Settings without an app relaunch.
*
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
* rewritten. Absolute external URLs — Lidarr-surfaced artwork from
* rewritten. Absolute external URLs — 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.
* reach their authored host unchanged: rewritten to the Minstrel host
* they 404, and Discover suggestions show no covers.
*
* No-op when the stored base URL is unparseable (falls through to
* whatever Retrofit had) — that case shows up as a transport-level
@@ -50,7 +49,14 @@ class BaseUrlInterceptor @Inject constructor(
.port(baseUrl.port)
.build()
} ?: original.url
return chain.proceed(original.newBuilder().url(rewritten).build())
return chain.proceed(
original.newBuilder()
.url(rewritten)
// Lets CleartextGuardInterceptor tell server requests from
// external fetches once the placeholder host is gone.
.tag(MinstrelServerRequest::class.java, MinstrelServerRequest)
.build(),
)
}
companion object {
@@ -0,0 +1,83 @@
package com.fabledsword.minstrel.api
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
/**
* Marks a request as bound for the Minstrel server, set by
* [BaseUrlInterceptor] when it retargets the placeholder host. Those are the
* requests that carry the session cookie and the password.
*/
object MinstrelServerRequest
/**
* Plain `http://` to the Minstrel server is allowed only when the connection
* actually lands on a private address.
*
* Cleartext stays permitted app-wide for LAN servers and UPnP
* (network_security_config.xml), but a password or session cookie sent
* over plain HTTP to a public address can be read by anyone on the path.
*
* Checked per connection, on the address the socket really reached, not on
* the URL when it was typed: a name that resolved to the home network when it
* was entered resolves to a public address once the phone leaves home, and
* that is exactly when the password would go out in the clear. A network
* interceptor runs after the connection is made and before any request byte
* is written, so nothing is sent.
*/
class CleartextGuardInterceptor(
// The policy is a parameter so a test can refuse loopback, the only
// address a test server can listen on.
private val allows: (InetAddress) -> Boolean = CleartextPolicy::allows,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (request.isHttps || request.tag(MinstrelServerRequest::class.java) == null) {
return chain.proceed(request)
}
val address = chain.connection()?.route()?.socketAddress?.address
if (address != null && !allows(address)) {
throw CleartextToPublicHostException(request.url.host)
}
return chain.proceed(request)
}
}
/** The server was reached over plain HTTP at a public address, and refused. */
class CleartextToPublicHostException(host: String) :
IOException("refusing plain http:// to $host: it is a public address")
/** Which addresses plain HTTP may reach: the home network, never the internet. */
object CleartextPolicy {
private const val CGNAT_FIRST_OCTET = 100
private const val CGNAT_SECOND_MASK = 0xC0
private const val CGNAT_SECOND_PREFIX = 64
private const val ULA_MASK = 0xFE
private const val ULA_PREFIX = 0xFC
private const val BYTE = 0xFF
fun allows(address: InetAddress): Boolean =
address.isLoopbackAddress ||
address.isSiteLocalAddress || // 10/8, 172.16/12, 192.168/16
address.isLinkLocalAddress || // 169.254/16, fe80::/10
address.isAnyLocalAddress ||
isCarrierGradeNat(address) ||
isUniqueLocal(address)
// 100.64/10. Tailscale and other overlay VPNs hand these out; the overlay
// encrypts the traffic itself.
private fun isCarrierGradeNat(address: InetAddress): Boolean {
if (address !is Inet4Address) return false
val b = address.address
return (b[0].toInt() and BYTE) == CGNAT_FIRST_OCTET &&
(b[1].toInt() and CGNAT_SECOND_MASK) == CGNAT_SECOND_PREFIX
}
// fc00::/7, IPv6's private range.
private fun isUniqueLocal(address: InetAddress): Boolean =
address is Inet6Address && (address.address[0].toInt() and ULA_MASK) == ULA_PREFIX
}
@@ -8,8 +8,7 @@ import java.io.IOException
/**
* Maps server error codes (and common transport failures) to
* friendly, sentence-case copy. Mirrors
* `flutter_client/assets/error-copy.json` + `error_copy.dart`.
* friendly, sentence-case copy.
*
* Server errors are `{"error":{"code":"...","message":"..."}}`.
* [fromThrowable] pulls the code out of a Retrofit [HttpException]'s
@@ -38,18 +37,36 @@ object ErrorCopy {
* as connection failures.
*/
fun fromThrowable(t: Throwable): String = when (t) {
is HttpException -> messageFor(codeFromHttp(t))
is HttpException -> fromHttp(t)
is CleartextToPublicHostException -> messageFor("cleartext_public")
is IOException -> messageFor("connection_refused")
else -> TABLE.getValue("unknown")
}
private fun codeFromHttp(e: HttpException): String {
/**
* Codes whose server message carries specifics an admin needs in
* order to act — which directory, which uid — that fixed copy cannot say.
* For these the message follows the copy. Kept to a named set on
* purpose: most server messages are internal detail. Mirrors web's
* errors.ts.
*/
private val DETAIL_CODES = setOf("library_not_writable", "file_delete_failed")
private fun fromHttp(e: HttpException): String {
val body = bodyFromHttp(e)
val copy = messageFor(body.code.ifEmpty { "unknown" })
return if (body.code in DETAIL_CODES && body.message.isNotBlank()) {
"$copy ${body.message}"
} else {
copy
}
}
private fun bodyFromHttp(e: HttpException): Body {
val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull()
?: return "unknown"
val code = runCatching { json.decodeFromString<Envelope>(raw).error?.code }
.getOrNull()
.orEmpty()
return code.ifEmpty { "unknown" }
?: return Body()
return runCatching { json.decodeFromString<Envelope>(raw).error }
.getOrNull() ?: Body()
}
private val TABLE: Map<String, String> = mapOf(
@@ -59,6 +76,7 @@ object ErrorCopy {
"forbidden" to "You don't have permission to do that.",
"not_authorized" to "You don't have permission to do that.",
"invalid_credentials" to "Wrong username or password.",
"rate_limited" to "Too many attempts. Wait a few minutes and try again.",
"wrong_password" to "Current password is incorrect.",
"password_too_short" to "Password must be at least 8 characters.",
"username_invalid" to "That username isn't valid.",
@@ -84,22 +102,16 @@ object ErrorCopy {
"mbid_required" to "An MBID is required for this lookup.",
"system_playlist_readonly" to "System playlists can't be edited directly.",
"connection_refused" to "Couldn't reach the server. Check the URL and try again.",
"lidarr_unreachable" to
"Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
"lidarr_disabled" to "Lidarr integration is not enabled.",
"lidarr_auth_failed" to "Lidarr authentication failed.",
"lidarr_defaults_incomplete" to
"Lidarr is missing a default quality profile or root folder. " +
"Set them in Admin → Integrations.",
"lidarr_server_error" to "Lidarr returned an error. Check Lidarr's logs for the cause.",
"lidarr_rejected" to
"Lidarr rejected the request. Check the server logs for the field-level reason.",
"lidarr_album_lookup_failed" to
"Lidarr doesn't recognize this album. Try Resolve or Delete file instead.",
"album_mbid_missing" to "This track has no Lidarr album to remove.",
"cleartext_public" to
"This server is on the internet, so its URL must start with https://. " +
"Plain http:// only works on your home network.",
"request_not_pending" to "This request is no longer pending.",
"request_not_found" to "That request no longer exists.",
"not_followable" to "Various Artists and MusicBrainz placeholders can't be followed.",
"profile_not_found" to "That release-type profile no longer exists.",
"track_not_found" to "That track no longer exists.",
"library_not_writable" to "The music library isn't writable by the server.",
"file_delete_failed" to "The file couldn't be deleted.",
"album_not_found" to "That album no longer exists.",
"artist_not_found" to "That artist no longer exists.",
"playlist_not_found" to "That playlist no longer exists.",
@@ -57,19 +57,21 @@ object NetworkModule {
.addInterceptor(reachability)
// 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
// Minstrel-server requests from external image fetches. 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.
// BaseUrlInterceptor retargets to the live AuthStore host for
// transport.
.addInterceptor(auth)
.addInterceptor(baseUrl)
.addInterceptor(logging)
// A network interceptor, so it sees the address the connection
// really reached and runs before any request byte is written.
.addNetworkInterceptor(CleartextGuardInterceptor())
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
@@ -10,8 +10,7 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/invites`. Mirrors
* `flutter_client/lib/api/endpoints/admin_invites.dart`.
* Retrofit interface for `/api/admin/invites`.
*
* Server TTL is hardcoded at 24h; the only configurable field is the
* optional `note` on create.
@@ -28,9 +27,8 @@ interface AdminInvitesApi {
}
/**
* Body for `POST /api/admin/invites`. The Flutter client omits the
* field when note is empty; Kotlin/serialization just serializes
* null which the server treats identically.
* Body for `POST /api/admin/invites`. An empty note is sent as null,
* which the server treats the same as an absent field.
*/
@Serializable
data class CreateInviteBody(val note: String? = null)
@@ -6,13 +6,11 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/quarantine`. Mirrors
* `flutter_client/lib/api/endpoints/admin_quarantine.dart`.
* Retrofit interface for `/api/admin/quarantine`.
*
* Three resolution endpoints:
* - `resolve` → admin reviewed, no action taken (clears flags).
* - `delete-file` → drop the local file; Lidarr keeps the track.
* - `delete-via-lidarr` → ask Lidarr to remove the track upstream too.
* Two resolution endpoints:
* - `resolve` → admin reviewed, no action taken (clears flags).
* - `delete-file` → delete the track's file and row.
*/
interface AdminQuarantineApi {
@GET("api/admin/quarantine")
@@ -23,7 +21,4 @@ interface AdminQuarantineApi {
@POST("api/admin/quarantine/{trackId}/delete-file")
suspend fun deleteFile(@Path("trackId") trackId: String)
@POST("api/admin/quarantine/{trackId}/delete-via-lidarr")
suspend fun deleteViaLidarr(@Path("trackId") trackId: String)
}
@@ -6,8 +6,7 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/requests`. Mirrors
* `flutter_client/lib/api/endpoints/admin_requests.dart`.
* Retrofit interface for `/api/admin/requests`.
*
* Server returns the same `requestView` shape as the user-side
* `/api/requests`, so RequestWire is reused. Different listing scope —
@@ -12,9 +12,9 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin
* surface for the tag-enrichment provider settings (#1490). Mirrors the
* web integrations "Tag enrichment sources" card. Same shape as the
* Retrofit interface for `/api/admin/tag-sources` — the admin surface
* for the tag-enrichment provider settings. Mirrors the web
* integrations "Tag enrichment sources" card. Same shape as the
* cover-sources admin surface; kept independent so a new tag source is
* added without touching art settings.
*/
@@ -10,8 +10,7 @@ import retrofit2.http.PUT
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/users`. Mirrors
* `flutter_client/lib/api/endpoints/admin_users.dart`.
* Retrofit interface for `/api/admin/users`.
*
* Note: the PUT-auto-approve body field is `auto_approve`, NOT
* `auto_approve_requests` — the request shape differs from the
@@ -6,8 +6,7 @@ import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for `/api/auth`. Mirrors
* `flutter_client/lib/api/endpoints/auth.dart`.
* Retrofit interface for `/api/auth`.
*
* The actual session-cookie capture happens in
* [com.fabledsword.minstrel.api.AuthCookieInterceptor]; we don't
@@ -29,11 +29,20 @@ interface CastApi {
* Request body. [expSeconds] is clamped server-side to [60, 86400];
* the 21_600 default (6h) is long enough to play through any typical
* track without re-minting mid-playback.
*
* [level] asks for the leveled stream: the track rendered at
* the user's loudness gain, which the server works out from their setting.
* [asAlbum] says the track plays among its album in order, which picks
* album gain in auto mode. [prerender] says the speaker will fetch it
* soon, so the server renders it ahead.
*/
@Serializable
data class StreamTokenRequest(
val trackId: String,
val expSeconds: Int = 21_600,
val level: Boolean = false,
val asAlbum: Boolean = false,
val prerender: Boolean = false,
)
/**
@@ -53,4 +62,6 @@ data class StreamTokenResponse(
val url: String,
val mime: String = "audio/mpeg",
val title: String = "",
/** [url] is the leveled stream; false when leveling is off or changes nothing. */
val leveled: Boolean = false,
)
@@ -7,7 +7,7 @@ import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for device diagnostics ingest (M9). One call
* Retrofit interface for device diagnostics ingest. One call
* uploads a batch of buffered events. The server stores them only when
* the account's debug_mode_enabled flag is on (it returns 204 otherwise,
* which the uploader treats as success so it can drop the batch).
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
import com.fabledsword.minstrel.models.wire.CatalogueSearchResultWire
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
import retrofit2.http.Body
@@ -13,24 +13,23 @@ import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for Discover / Lidarr search / request creation.
* Mirrors `flutter_client/lib/api/endpoints/discover.dart`.
* Retrofit interface for Discover / catalogue search / request creation.
*
* `/api/lidarr/search` has a 60s LRU on the server so quick re-types
* of the same query are cheap.
* `/api/catalogue/search` asks MusicBrainz, which allows one request a
* second, so the Discover screen searches when the query is submitted,
* not on every keystroke.
*
* Cancel + my-requests-list endpoints live on RequestsApi (Phase 10
* Commit B), not here.
* Cancel + my-requests-list endpoints live on RequestsApi, not here.
*/
interface DiscoverApi {
@GET("api/discover/suggestions")
suspend fun listSuggestions(): List<ArtistSuggestionWire>
@GET("api/lidarr/search")
@GET("api/catalogue/search")
suspend fun search(
@Query("q") query: String,
@Query("kind") kind: String,
): List<LidarrSearchResultWire>
): List<CatalogueSearchResultWire>
@POST("api/requests")
suspend fun createRequest(@Body body: CreateRequestBody)
@@ -9,8 +9,7 @@ import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for `POST /api/events`. Mirrors the relevant
* slice of `flutter_client/lib/api/endpoints/events.dart`. All four
* Retrofit interface for `POST /api/events`. All four
* variants share the same URL — the discriminator is in the request
* body's `type` field. Server contract is best-effort per spec;
* callers (the live path in PlayEventsReporter) swallow errors and
@@ -5,10 +5,9 @@ import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `/api/me/history`. Mirrors the relevant
* subset of `flutter_client/lib/api/endpoints/me.dart` (only
* `history()`; profile / timezone / quarantine endpoints land with
* their respective phases).
* Retrofit interface for `/api/me/history` — history only. The profile,
* timezone and quarantine endpoints on `/api/me` live with their own
* features rather than here.
*/
interface HistoryApi {
@GET("api/me/history")
@@ -4,10 +4,9 @@ import com.fabledsword.minstrel.models.wire.HomeIndexWire
import retrofit2.http.GET
/**
* Retrofit interface for the Home discovery endpoint. Mirrors
* `flutter_client/lib/api/endpoints/home.dart` — just the ID-only
* `/api/home/index` variant. The Flutter port has a heavier
* `/api/home` (full embedded payload) too; we don't use it because
* Retrofit interface for the Home discovery endpoint. Only the ID-only
* `/api/home/index` variant is used. The server also serves a heavier
* `/api/home` (full embedded payload); we don't use it because
* the per-item hydration path (sync controller → Room → Flow) is
* the only one the native client needs.
*/
@@ -3,14 +3,16 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
import com.fabledsword.minstrel.models.wire.ArtistWire
import com.fabledsword.minstrel.models.wire.GenreCountWire
import com.fabledsword.minstrel.models.wire.PagedAlbumsWire
import com.fabledsword.minstrel.models.wire.TrackWire
import com.fabledsword.minstrel.models.wire.YearCountWire
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for the server's native `/api/...` library surface.
* Mirrors `flutter_client/lib/api/endpoints/library.dart` 1:1.
*
* Notes on shapes:
* - `GET /api/artists/{id}` returns ArtistDetailWire (ArtistRef fields
@@ -54,6 +56,48 @@ interface LibraryApi {
@GET("api/library/shuffle")
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
// Browse axes. Both indexes are unpaged by design: the client needs
// the whole set to render a browsable picker, and even a messy library
// yields hundreds of rows, not thousands.
//
// These read the server rather than the local cache on purpose: the
// server's index leaves out tracks whose files are missing and is the
// one the web client shows, so the two clients agree. The cache carries
// the same mark (cached_tracks.missing), but an index built from it
// would be a second copy of the server's grouping that could drift.
@GET("api/library/genres")
suspend fun getGenres(): List<GenreCountWire>
@GET("api/library/years")
suspend fun getAlbumYears(): List<YearCountWire>
/**
* Albums carrying [genre] on any of their tracks.
*
* @Query, never @Path: "Rock/Pop" is a real ID3 tag and a slash cannot
* survive a path segment. Retrofit percent-encodes query values correctly;
* a @Path would either 404 or silently address a different genre.
*/
@GET("api/library/albums")
suspend fun getAlbumsByGenre(
@Query("genre") genre: String,
@Query("limit") limit: Int,
@Query("offset") offset: Int,
): PagedAlbumsWire
/**
* Albums released in an inclusive year range. Pass the same year twice for
* a single year. Sending a genre alongside these is a deliberate 400 on the
* server (`unsupported_filter_combination`) -- they are separate axes.
*/
@GET("api/library/albums")
suspend fun getAlbumsByYear(
@Query("year_from") yearFrom: Int,
@Query("year_to") yearTo: Int,
@Query("limit") limit: Int,
@Query("offset") offset: Int,
): PagedAlbumsWire
private companion object {
const val SIMILAR_ARTISTS_LIMIT = 12
const val TOP_TRACKS_LIMIT = 5
@@ -7,8 +7,7 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/likes`. Mirrors
* `flutter_client/lib/api/endpoints/likes.dart`.
* Retrofit interface for `/api/likes`.
*
* Path segment `kind` is one of "artists" | "albums" | "tracks"
* (plural, matching the server route). The Repository hides that
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
import com.fabledsword.minstrel.models.wire.MyProfileWire
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
@@ -11,7 +12,6 @@ import retrofit2.http.PUT
/**
* Retrofit interface for the `/api/me` endpoints — caller-scoped account endpoints.
* Mirrors the relevant slice of `flutter_client/lib/api/endpoints/settings.dart`.
*
* History + timezone + system-playlists-status live under /api/me too
* but are handled by their respective feature repositories; this
@@ -60,6 +60,14 @@ interface MeApi {
*/
@PUT("api/me/listenbrainz")
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire
/** The caller's loudness-normalization preference, or the defaults if never set. */
@GET("api/me/normalization")
suspend fun getNormalization(): NormalizationPrefs
/** Replaces the whole preference; returns what the server stored. */
@PUT("api/me/normalization")
suspend fun putNormalization(@Body body: NormalizationPrefs): NormalizationPrefs
}
/**
@@ -84,9 +92,7 @@ data class ChangePasswordRequest(
/**
* Body for `PUT /api/me/listenbrainz`. Either `token` or `enabled`
* is set; the server treats other fields as untouched. Mirrors
* Flutter's `setListenBrainzToken` / `setListenBrainzEnabled` split
* over a single endpoint shape.
* is set; the server treats other fields as untouched.
*/
@Serializable
data class ListenBrainzPutBody(
@@ -0,0 +1,90 @@
package com.fabledsword.minstrel.api.endpoints
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
/**
* The notifications inbox and its per-user settings. The server
* renders each notice's title, body and link, so the app shows them as given.
*/
interface NotificationsApi {
@GET("api/me/notifications")
suspend fun list(@Query("limit") limit: Int): NotificationsPageWire
/** 204; 404 when the notice is gone, which a replay treats as done. */
@POST("api/me/notifications/{id}/read")
suspend fun markRead(@Path("id") id: String)
@POST("api/me/notifications/read-all")
suspend fun readAll(@Body body: ReadAllBody)
@GET("api/me/notification-settings")
suspend fun getSettings(): NotificationSettingsWire
@PUT("api/me/notification-settings")
suspend fun putSettings(@Body body: PutNotificationSettingsBody): NotificationSettingsWire
}
@Serializable
data class NotificationWire(
val id: String,
val kind: String,
val title: String,
val body: String,
val link: String,
@SerialName("created_at") val createdAt: String,
@SerialName("read_at") val readAt: String? = null,
)
@Serializable
data class NotificationsPageWire(
val items: List<NotificationWire>,
@SerialName("unread_count") val unreadCount: Long,
@SerialName("next_before") val nextBefore: String? = null,
)
/**
* `upTo` limits "mark all read" to what existed when the user asked, so a
* replay landing later leaves newer notices unread. No default on purpose:
* the app's Json drops default-valued fields.
*/
@Serializable
data class ReadAllBody(@SerialName("up_to") val upTo: String?)
@Serializable
data class NotificationKindSettingWire(
val kind: String,
@SerialName("admin_only") val adminOnly: Boolean,
val inbox: Boolean,
val phone: Boolean,
val email: Boolean,
)
@Serializable
data class NotificationSettingsWire(
val kinds: List<NotificationKindSettingWire>,
@SerialName("email_available") val emailAvailable: Boolean,
@SerialName("email_unavailable_reason") val emailUnavailableReason: String? = null,
)
/**
* One kind's change. Untouched channels stay null and, being equal to their
* default, are left out of the JSON, so the server changes only the channel
* the user touched.
*/
@Serializable
data class NotificationSettingChangeWire(
val kind: String,
val inbox: Boolean? = null,
val phone: Boolean? = null,
val email: Boolean? = null,
)
@Serializable
data class PutNotificationSettingsBody(val kinds: List<NotificationSettingChangeWire>)
@@ -11,8 +11,7 @@ import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for `/api/playlists`. Mirrors
* `flutter_client/lib/api/endpoints/playlists.dart`.
* Retrofit interface for `/api/playlists`.
*/
interface PlaylistsApi {
/**
@@ -50,19 +49,19 @@ interface PlaylistsApi {
suspend fun refreshSystem(@Path("kind") variant: String): RefreshSystemResponse
/**
* GET /api/playlists/system/{kind}/shuffle (#415 / #411 R2). Returns
* GET /api/playlists/system/{kind}/shuffle. Returns
* the system playlist's tracks in rotation-aware order without
* rebuilding — used by the Home play-button overlay so taps on For
* You / Discover / Today's mix advance rotation rather than picking
* the stored order. Mirrors `playlists.dart.systemShuffle`.
* the stored order.
*/
@GET("api/playlists/system/{kind}/shuffle")
suspend fun systemShuffle(@Path("kind") variant: String): PlaylistDetailWire
}
/**
* POST /api/playlists/{id}/tracks body. Server expects snake_case
* `track_ids` per the Flutter wire shape.
* POST /api/playlists/{id}/tracks body. The server expects snake_case
* `track_ids`.
*/
@Serializable
data class AppendTracksRequest(
@@ -8,13 +8,12 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/quarantine`. Mirrors the relevant
* parts of `flutter_client/lib/api/endpoints/quarantine.dart` (flag
* and unflag) plus the `/api/quarantine/mine` endpoint from `me.dart`.
* Retrofit interface for `/api/quarantine`: flag and unflag, plus the
* `/api/quarantine/mine` listing.
*
* Both flag and unflag are user-scoped — callers act on their own
* quarantine entries. The cross-user admin surface is a separate
* `/api/admin/quarantine` route (Phase 10 Commit D).
* `/api/admin/quarantine` route.
*/
interface QuarantineApi {
@GET("api/quarantine/mine")
@@ -5,9 +5,7 @@ import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `/api/radio`. Mirrors the relevant slice of
* `flutter_client/lib/api/endpoints/radio.dart` (a single GET that
* returns the seeded queue). The server picks a fresh shuffle each
* Retrofit interface for `/api/radio`. The server picks a fresh shuffle each
* invocation — clients call this once per radio start.
*/
interface RadioApi {
@@ -0,0 +1,15 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire
import retrofit2.http.GET
import retrofit2.http.Query
/** The player's loudness lookup, kept apart from the browse surface in [LibraryApi]. */
interface ReplayGainApi {
/**
* ReplayGain values for up to 200 comma-separated track ids. An id
* missing from `items` has not been measured yet.
*/
@GET("api/tracks/replay-gain")
suspend fun getReplayGain(@Query("ids") ids: String): ReplayGainResponseWire
}
@@ -6,12 +6,11 @@ import retrofit2.http.GET
import retrofit2.http.Path
/**
* Retrofit interface for the user-side `/api/requests`. Mirrors
* `flutter_client/lib/api/endpoints/requests.dart`.
* Retrofit interface for the user-side `/api/requests`.
*
* Server scopes results to the caller — admins see only their own
* requests through this endpoint. The cross-user admin view lives on
* a separate `/api/admin/requests` route (Phase 10 Commit D).
* a separate `/api/admin/requests` route.
*
* The DELETE endpoint returns the cancelled row (not 204) so the UI
* can patch local state without a refetch.
@@ -5,8 +5,7 @@ import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `GET /api/search`. Mirrors
* `flutter_client/lib/api/endpoints/search.dart`. Server returns 400
* Retrofit interface for `GET /api/search`. Server returns 400
* on empty/whitespace-only `q` — the caller is responsible for
* guarding.
*/
@@ -6,7 +6,7 @@ import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `/api/library/sync` (#357). The endpoint
* Retrofit interface for `/api/library/sync`. The endpoint
* returns three possible status codes that the SyncController
* branches on:
* - 200: parsed body with cursor + upserts + deletes
@@ -17,8 +17,7 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Singleton facade over the auth state machine. Mirrors Flutter's
* `AuthController` from `auth_provider.dart`.
* Singleton facade over the auth state machine.
*
* Cookie persistence is handled by [AuthCookieInterceptor] capturing
* Set-Cookie on the login response; the user identity itself
@@ -58,7 +57,7 @@ class AuthController @Inject constructor(
}
}
// Refresh from /api/me on startup so a remotely-changed account
// flag (e.g. admin enabling debug mode, M9) reaches the device
// flag (e.g. an admin enabling debug mode) reaches the device
// without a re-login. Best-effort; failures keep the cached value.
scope.launch { refreshProfile() }
}
@@ -4,12 +4,19 @@ import com.fabledsword.minstrel.cache.audiocache.CacheSettings
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@@ -27,6 +34,14 @@ import javax.inject.Singleton
* in-memory state changes synchronously so the next interceptor read
* sees the new value immediately; the DAO write coroutine catches up
* shortly after.
*
* **The session cookie is the exception**: it is persisted through
* [SessionVault] (Keystore-encrypted), not the Room row. A cookie still
* in the row is moved into the vault on launch and the column cleared,
* so nobody is signed out by the move. If the Keystore cannot be used
* on a device, the cookie stays in the row rather than being lost.
* [awaitSessionHydrated] lets a caller that needs a definitive answer
* (the auth gate) wait for this.
*/
// AuthStore is the single-row facade over auth_session (de-facto
// app_preferences — see entity comment). It legitimately owns one
@@ -39,6 +54,7 @@ import javax.inject.Singleton
@Singleton
class AuthStore @Inject constructor(
private val dao: AuthSessionDao,
private val vault: SessionVault,
@ApplicationScope private val scope: CoroutineScope,
) {
private val sessionCookieState = MutableStateFlow<String?>(null)
@@ -62,18 +78,39 @@ class AuthStore @Inject constructor(
private val diagnosticsOptOutState = MutableStateFlow(false)
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
// Background delivery: the device's choice, on by default.
// The shade's high-water mark lives in the same row but is read and
// written through the DAO by NotificationSync, awaited, never cached here.
private val backgroundDeliveryState = MutableStateFlow(true)
val backgroundDelivery: StateFlow<Boolean> = backgroundDeliveryState.asStateFlow()
private val json = Json { ignoreUnknownKeys = true }
// Serialises every cookie persist with the one-time hydration, so a
// sign-in or a 401 that lands while hydration runs is never overwritten
// by the stale value hydration read.
private val cookieLock = Mutex()
// Set by setSessionCookie. Once something has written the cookie this
// process, that value wins over whatever hydration finds on disk.
@Volatile private var cookieTouched = false
private val cookieHydration: Deferred<Unit> = scope.async { hydrateSessionCookie() }
init {
scope.launch {
dao.observe().collect { row ->
sessionCookieState.value = row?.sessionCookie
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
userJsonState.value = row?.userJson
themeModeState.value = row?.themeMode
clientIdState.value = row?.clientId
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
normalizationState.value = decodeNormalization(row?.normalizationJson)
backgroundDeliveryState.value = row?.backgroundDelivery ?: true
}
}
}
@@ -85,9 +122,57 @@ class AuthStore @Inject constructor(
}.getOrDefault(CacheSettings.DEFAULT)
}
private fun decodeNormalization(raw: String?): NormalizationPrefs {
if (raw.isNullOrEmpty()) return NormalizationPrefs.DEFAULT
return runCatching {
json.decodeFromString(NormalizationPrefs.serializer(), raw)
}.getOrDefault(NormalizationPrefs.DEFAULT)
}
/**
* Suspends until the stored session cookie has been loaded into
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes, so a wedged
* Keystore cannot leave the start screen spinning. Returns false on
* the deadline; the caller then decides from whatever has loaded, and a
* late hydration still lands in [sessionCookie].
*/
suspend fun awaitSessionHydrated(): Boolean {
val done = withTimeoutOrNull(HYDRATION_DEADLINE_MS) { cookieHydration.await() } != null
if (!done) Timber.w("auth store: session hydration passed its deadline; deciding without it")
return done
}
fun setSessionCookie(value: String?) {
cookieTouched = true
sessionCookieState.value = value
scope.launch { persistCookie(value) }
scope.launch { cookieLock.withLock { storeCookie(value) } }
}
private suspend fun hydrateSessionCookie() = cookieLock.withLock {
val legacy = runCatching { dao.get()?.sessionCookie }.getOrNull()
if (cookieTouched) return@withLock
// A cookie in the row is the newer one when both exist: the row is
// only written when the vault failed, and an install upgrading from
// before the vault has nothing in the vault yet.
val cookie = legacy ?: vault.read()
// Best-effort: if moving it fails, the session still loads this time
// and the move is retried on the next launch. Hydration must never
// throw, or awaitSessionHydrated would leave the auth gate stuck.
if (legacy != null) {
runCatching { storeCookie(legacy) }
.onFailure { Timber.w(it, "auth store: could not move the session cookie into the vault") }
}
sessionCookieState.value = cookie
}
// Vault first; the Room row only when the Keystore is unusable, so a
// broken Keystore degrades to the old storage rather than a sign-out.
private suspend fun storeCookie(value: String?) {
if (vault.write(value)) {
if (dao.get()?.sessionCookie != null) dao.setSessionCookie(null)
} else {
persistLegacyCookie(value)
}
}
fun setBaseUrl(value: String) {
@@ -121,7 +206,24 @@ class AuthStore @Inject constructor(
scope.launch { persistDiagnosticsOptOut(value) }
}
private suspend fun persistCookie(value: String?) {
fun setNormalization(value: NormalizationPrefs) {
normalizationState.value = value
val encoded = json.encodeToString(NormalizationPrefs.serializer(), value)
scope.launch { persistNormalization(encoded) }
}
fun setBackgroundDelivery(value: Boolean) {
backgroundDeliveryState.value = value
scope.launch {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(backgroundDelivery = value))
} else {
dao.setBackgroundDelivery(value)
}
}
}
private suspend fun persistLegacyCookie(value: String?) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(sessionCookie = value))
} else {
@@ -177,9 +279,19 @@ class AuthStore @Inject constructor(
}
}
private suspend fun persistNormalization(json: String) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(normalizationJson = json))
} else {
dao.setNormalizationJson(json)
}
}
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
id = ROW_ID,
sessionCookie = sessionCookieState.value,
// Never copied into the row: the cookie lives in the vault, and
// persistLegacyCookie sets it explicitly on the fallback path.
sessionCookie = null,
baseUrl = baseUrlState.value,
userJson = userJsonState.value,
themeMode = themeModeState.value,
@@ -189,10 +301,19 @@ class AuthStore @Inject constructor(
cacheSettingsState.value,
),
diagnosticsOptOut = diagnosticsOptOutState.value,
normalizationJson = json.encodeToString(
NormalizationPrefs.serializer(),
normalizationState.value,
),
backgroundDelivery = backgroundDeliveryState.value,
)
companion object {
const val DEFAULT_BASE_URL: String = "http://localhost:8080"
// Generous on purpose: hydration is one local row read and one
// Keystore decrypt, normally milliseconds. This only bounds "never".
const val HYDRATION_DEADLINE_MS: Long = 10_000
private const val ROW_ID = 0
}
}
@@ -0,0 +1,146 @@
package com.fabledsword.minstrel.auth
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import timber.log.Timber
import java.security.KeyStore
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton
/**
* Where the session cookie lives at rest.
*
* The cookie is a bearer credential: anyone holding it is signed in as the
* user until the server expires or revokes it. In plain text it would be
* readable from any copy of the app's data directory (a rooted device, an
* adb backup of a debuggable build, a forensic image). Only ciphertext is
* stored, under an AES key that lives in the Android Keystore and never
* leaves it, so a copy of the files alone yields nothing usable.
*/
interface SessionVault {
/** The stored cookie, or null when none is stored or it can't be decrypted. */
fun read(): String?
/**
* Stores [value], or clears the stored cookie when null. Returns false
* when the Keystore could not be used, so the caller can fall back
* rather than lose the session.
*/
fun write(value: String?): Boolean
}
/**
* AES-GCM sealing of a short string, framed as base64(iv || ciphertext+tag).
* Kept apart from the Keystore so the framing can be unit-tested on the JVM
* with an ordinary key; the Android Keystore has no JVM implementation.
*/
internal object SealedBox {
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val TAG_BITS = 128
private const val IV_BYTES = 12
// Binds a sealed value to its purpose: a blob sealed for something else
// under the same key will not open as a session cookie.
private val AAD = "minstrel-session-cookie-v1".toByteArray(Charsets.UTF_8)
fun seal(key: SecretKey, plaintext: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
// No IV passed: the provider generates a fresh random one. Keystore
// keys refuse a caller-chosen IV for encryption by default.
cipher.init(Cipher.ENCRYPT_MODE, key)
cipher.updateAAD(AAD)
val sealed = cipher.iv + cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return Base64.getEncoder().encodeToString(sealed)
}
fun open(key: SecretKey, sealed: String): String {
val bytes = Base64.getDecoder().decode(sealed)
require(bytes.size > IV_BYTES) { "sealed value too short" }
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, bytes, 0, IV_BYTES))
cipher.updateAAD(AAD)
return String(cipher.doFinal(bytes, IV_BYTES, bytes.size - IV_BYTES), Charsets.UTF_8)
}
}
/**
* [SessionVault] backed by a Keystore AES key and a private prefs file that
* holds only the sealed value.
*
* A value that will not open (the key was wiped by a factory-reset of the
* Keystore, or the file was restored onto another device; app backup is off,
* but a vendor transfer tool may still copy files) is discarded and reported
* as absent. The user signs in again, which is the right outcome for a
* credential that no longer verifies.
*/
@Singleton
class KeystoreSessionVault @Inject constructor(
@ApplicationContext context: Context,
) : SessionVault {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
override fun read(): String? {
val sealed = prefs.getString(KEY_COOKIE, null) ?: return null
return runCatching { SealedBox.open(key(), sealed) }
.onFailure {
Timber.w(it, "session vault: stored cookie would not decrypt; discarding it")
prefs.edit().remove(KEY_COOKIE).commit()
}
.getOrNull()
}
override fun write(value: String?): Boolean = runCatching {
val editor = prefs.edit()
if (value == null) {
editor.remove(KEY_COOKIE)
} else {
editor.putString(KEY_COOKIE, SealedBox.seal(key(), value))
}
editor.commit()
}.onFailure {
Timber.w(it, "session vault: Keystore unavailable; cookie not stored in the vault")
}.getOrDefault(false)
private fun key(): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(KEY_BITS)
.build(),
)
return generator.generateKey()
}
private companion object {
const val ANDROID_KEYSTORE = "AndroidKeyStore"
const val KEY_ALIAS = "minstrel_session_cookie"
const val KEY_BITS = 256
const val PREFS_NAME = "session_vault"
const val KEY_COOKIE = "sealed_cookie"
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class SessionVaultModule {
@Binds
abstract fun bindSessionVault(impl: KeystoreSessionVault): SessionVault
}
@@ -16,10 +16,11 @@ import javax.inject.Inject
/**
* Computes the initial startDestination for the root NavHost based on
* persisted auth state. Sits on top of AuthSessionDao directly rather
* than AuthStore's StateFlow because the StateFlow defaults to null
* until Room's first async emission — we need a definitive answer
* before drawing any nav graph.
* persisted auth state. Reads the row from AuthSessionDao directly, and
* the session cookie only after [AuthStore.awaitSessionHydrated]: both
* StateFlows default to null until their async load lands, and we need a
* definitive answer before drawing any nav graph. The cookie lives in the
* Keystore-backed SessionVault, not the row.
*
* - no row at all → ServerUrl (first launch)
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
@@ -31,6 +32,7 @@ import javax.inject.Inject
@HiltViewModel
class AuthGateViewModel @Inject constructor(
private val dao: AuthSessionDao,
private val authStore: AuthStore,
) : ViewModel() {
private val internal = MutableStateFlow<Any?>(null)
@@ -38,12 +40,13 @@ class AuthGateViewModel @Inject constructor(
init {
viewModelScope.launch {
authStore.awaitSessionHydrated()
val signedIn = !authStore.sessionCookie.value.isNullOrEmpty()
val row = dao.get()
internal.value = when {
row == null -> ServerUrl
row.baseUrl == AuthStore.DEFAULT_BASE_URL && row.sessionCookie.isNullOrEmpty() ->
ServerUrl
row.sessionCookie.isNullOrEmpty() -> Login
row.baseUrl == AuthStore.DEFAULT_BASE_URL && !signedIn -> ServerUrl
!signedIn -> Login
else -> Home
}
}
@@ -18,15 +18,13 @@ import javax.inject.Singleton
* Populates `audio_cache_index` from playback so the offline pools
* ([ShuffleSource]) and the cached indicator have a data source.
*
* Mirrors the user-visible behavior of Flutter's `AudioCacheManager`
* (`registerStreamCache` + `touch`): a track you play gets cached as a
* side effect of streaming, and playing it bumps its recency. The
* Android idiom differs intentionally — Media3's `SimpleCache` is the
* real byte store (span-based, keyed by trackId via the MediaItem's
* custom cache key), so this index is a lightweight *play-recency*
* record (source = INCIDENTAL) rather than Flutter's file-per-track
* table. SimpleCache stays the source of truth for "is it actually on
* disk"; this index orders "recently played" for the offline pools.
* A track you play gets cached as a side effect of streaming, and
* playing it bumps its recency. Media3's `SimpleCache` is the real byte
* store (span-based, keyed by trackId via the MediaItem's custom cache
* key), so this index is a lightweight *play-recency* record
* (source = INCIDENTAL), not a file-per-track table. SimpleCache stays
* the source of truth for "is it actually on disk"; this index orders
* "recently played" for the offline pools.
*
* State observed against [PlayerController.uiState], deduped by track
* id so each entry into the player records once. Eager-constructed via
@@ -12,19 +12,17 @@ import javax.inject.Singleton
private const val POOL_LIMIT = 100
/**
* Offline play sources over the local audio-cache index. Mirrors
* `flutter_client/lib/cache/shuffle_source.dart`.
* Offline play sources over the local audio-cache index.
*
* Both pools are UNIONs over the cache regardless of storage bucket
* (liked AND recently-played both included). The two-bucket split is
* storage/eviction-only and never filters playback — exactly what
* this relies on.
*
* Divergence note: Flutter's `audioCacheIndex` only holds fully-cached
* tracks, so its pools are inherently resident. Android's index records
* plays (see `CacheIndexer`), so the recency list is intersected with
* true Media3 SimpleCache residency here — the pool never offers a
* played-then-evicted track that would fail to play offline.
* The index records plays (see `CacheIndexer`), not residency, so the
* recency list is intersected with true Media3 SimpleCache residency
* here — the pool never offers a played-then-evicted track that would
* fail to play offline.
*
* Surfaced on Home's Playlists row only when offline; tap shuffles +
* plays the pool from the cache.
@@ -58,6 +56,14 @@ class ShuffleSource @Inject constructor(
private suspend fun materialize(orderedIds: List<String>): List<TrackRef> {
if (orderedIds.isEmpty()) return emptyList()
val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
return orderedIds.mapNotNull { byId[it]?.toDomain() }
return orderedIds.mapNotNull { id ->
// Clear the server's missing mark. Every id reaching here
// came through residentIdsByRecency, which already proved the
// AUDIO is in the local cache — so these play regardless of what
// the server has lost, and the queue filter in PlayerController
// would otherwise throw away tracks that work perfectly. Missing
// means "cannot stream", not "cannot play".
byId[id]?.toDomain()?.copy(unavailable = false)
}
}
}
@@ -0,0 +1,122 @@
package com.fabledsword.minstrel.cache.audiocache
import java.util.TreeSet
/** Which budget a cached track's bytes count against. */
enum class CacheBucket { LIKED, RECENT }
/**
* The bookkeeping behind [TwoBucketCacheEvictor]: which budget each cached
* span counts against, how full each budget is, and which span to drop
* next. It knows nothing of Media3, so it is tested directly.
*
* A span belongs to [CacheBucket.LIKED] when its key (the track id) is in
* the liked set, and to [CacheBucket.RECENT] otherwise. A track that is
* liked and also recently played is therefore charged to Liked only; the
* two budgets never count the same bytes. Each bucket drops its own
* least-recently-used span first, so filling one never evicts the other.
*
* Nothing is evicted until [setLiked] has been called once. Before then
* every span would read as Recently played, and trimming at the Recently
* played cap would throw away liked audio at startup.
*
* Not thread-safe; the evictor serialises every call.
*/
class BucketLedger<S : Any>(
private val keyOf: (S) -> String,
private val lengthOf: (S) -> Long,
lruOrder: Comparator<S>,
) {
private val spans = mapOf(
CacheBucket.LIKED to TreeSet(lruOrder),
CacheBucket.RECENT to TreeSet(lruOrder),
)
private val bytes = mutableMapOf(CacheBucket.LIKED to 0L, CacheBucket.RECENT to 0L)
private val spansByKey = HashMap<String, MutableSet<S>>()
private val bucketByKey = HashMap<String, CacheBucket>()
private var liked: Set<String> = emptySet()
private var armed = false
/** Cap on Liked bytes; 0 means unlimited. */
var likedCapBytes: Long = 0L
/** Cap on Recently played bytes; 0 means unlimited. */
var recentCapBytes: Long = 0L
fun bucketOf(key: String): CacheBucket =
if (key in liked) CacheBucket.LIKED else CacheBucket.RECENT
fun bytesIn(bucket: CacheBucket): Long = bytes.getValue(bucket)
/** Bytes the Liked budget can still take; [Long.MAX_VALUE] when unlimited. */
fun likedRoomBytes(): Long = when {
likedCapBytes <= 0L -> Long.MAX_VALUE
else -> (likedCapBytes - bytesIn(CacheBucket.LIKED)).coerceAtLeast(0L)
}
fun add(span: S) {
val key = keyOf(span)
val bucket = bucketByKey[key] ?: bucketOf(key).also { bucketByKey[key] = it }
if (spans.getValue(bucket).add(span)) {
bytes[bucket] = bytesIn(bucket) + lengthOf(span)
spansByKey.getOrPut(key) { HashSet() }.add(span)
}
}
fun remove(span: S) {
val key = keyOf(span)
val bucket = bucketByKey[key] ?: return
if (spans.getValue(bucket).remove(span)) {
bytes[bucket] = bytesIn(bucket) - lengthOf(span)
}
val forKey = spansByKey[key] ?: return
forKey.remove(span)
if (forKey.isEmpty()) {
spansByKey.remove(key)
bucketByKey.remove(key)
}
}
/**
* Replace the liked set and move every cached track whose bucket
* changed: a newly liked track's bytes leave Recently played for Liked,
* an unliked track's go the other way. Arms eviction.
*/
fun setLiked(ids: Set<String>) {
liked = ids
armed = true
val moves = bucketByKey.filter { (key, bucket) -> bucketOf(key) != bucket }.keys.toList()
for (key in moves) {
val from = bucketByKey.getValue(key)
val to = bucketOf(key)
for (span in spansByKey[key].orEmpty()) {
if (spans.getValue(from).remove(span)) {
bytes[from] = bytesIn(from) - lengthOf(span)
spans.getValue(to).add(span)
bytes[to] = bytesIn(to) + lengthOf(span)
}
}
bucketByKey[key] = to
}
}
/**
* The span to drop next, or null when both budgets fit. [pendingKey] and
* [pendingBytes] describe a write about to start, so room is made for it
* in its own bucket first. Recently played is trimmed before Liked.
*/
fun nextEviction(pendingKey: String? = null, pendingBytes: Long = 0L): S? {
if (!armed) return null
val pendingBucket = pendingKey?.let(::bucketOf)
return listOf(CacheBucket.RECENT, CacheBucket.LIKED).firstNotNullOfOrNull { bucket ->
val incoming = if (bucket == pendingBucket) pendingBytes.coerceAtLeast(0L) else 0L
oldestIfOver(bucket, incoming)
}
}
private fun oldestIfOver(bucket: CacheBucket, incoming: Long): S? {
val cap = if (bucket == CacheBucket.LIKED) likedCapBytes else recentCapBytes
val over = cap > 0L && bytesIn(bucket) + incoming > cap
return if (over) spans.getValue(bucket).firstOrNull() else null
}
}
@@ -0,0 +1,40 @@
package com.fabledsword.minstrel.cache.audiocache
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Keeps [TwoBucketCacheEvictor] in step with the signed-in user's liked
* tracks and the Storage caps. A like moves that track's cached audio into
* the Liked budget, an unlike moves it out, and a cap change trims at once.
*
* The liked set only flows once a user is signed in, and the evictor drops
* nothing until it has one, so liked audio is never trimmed as Recently
* played while the session is still loading.
*
* Eager-constructed via the construct-the-singleton trick in
* `MinstrelApplication`.
*/
@Singleton
class CacheBudgetSync @Inject constructor(
likes: LikesRepository,
authStore: AuthStore,
evictor: TwoBucketCacheEvictor,
@ApplicationScope scope: CoroutineScope,
) {
init {
scope.launch(Dispatchers.IO) {
combine(likes.observeSignedInLikedTrackIds(), authStore.cacheSettings, ::Pair)
.collect { (ids, settings) ->
evictor.update(ids, settings.likedCapBytes, settings.rollingCapBytes)
}
}
}
}
@@ -1,23 +0,0 @@
package com.fabledsword.minstrel.cache.audiocache
/**
* Defaults for the 2-bucket audio cache. Matches the Flutter client.
*
* - `likedCapBytes`: cap for the protected bucket — cached files for
* tracks the user has liked. Evicted only after the rolling bucket
* is fully drained (almost never under normal use).
* - `rollingCapBytes`: cap for the unprotected bucket — incidental /
* auto-prefetch downloads. LRU within this bucket.
*
* Phase 11 (Settings) will allow user overrides; the values are kept
* generous so first-time installs work well without configuration.
*/
data class CacheConfig(
val likedCapBytes: Long = DEFAULT_LIKED_CAP_BYTES,
val rollingCapBytes: Long = DEFAULT_ROLLING_CAP_BYTES,
) {
companion object {
const val DEFAULT_LIKED_CAP_BYTES: Long = 200L * 1024 * 1024 // 200 MiB
const val DEFAULT_ROLLING_CAP_BYTES: Long = 150L * 1024 * 1024 // 150 MiB
}
}
@@ -6,23 +6,22 @@ private const val FIVE_GIB_BYTES = 5L * 1024 * 1024 * 1024
private const val DEFAULT_PREFETCH_WINDOW = 5
/**
* User-tunable audio cache settings. Mirrors Flutter's `CacheSettings`
* (cache_settings_provider.dart) field-for-field. Persisted as a JSON
* User-tunable audio cache settings. Persisted as a JSON
* blob on the auth_session single-row table via [AuthStore].
*
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
* unlimited. Effective at next app start (SimpleCache is built once
* per process).
* - [rollingCapBytes]: budget for incidental / auto-prefetch downloads.
* Same 0=unlimited / restart-to-apply caveat.
* - [prefetchWindow]: 1..10. Number of next-tracks the prefetcher
* pre-downloads. Has no effect until the prefetcher lands (separate
* audit follow-up) — persisted now so the user can pre-configure.
* - [cacheLikedTracks]: when true, liking a track should also pin its
* audio to the liked-cache bucket. Has no effect until pin-on-like
* lands (separate follow-up).
* - [likedCapBytes]: the Liked songs budget, for the cached audio of
* liked tracks. 0 means unlimited.
* - [rollingCapBytes]: the Recently played budget, for everything else
* streamed or prefetched. 0 means unlimited.
* A track that is liked and also recently played counts against Liked
* only. Both caps apply as soon as they change ([TwoBucketCacheEvictor]).
* - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher`
* pre-downloads.
* - [cacheLikedTracks]: "Keep liked songs offline". When true,
* `LikedAudioPinner` downloads liked tracks into the Liked budget on
* Wi-Fi, most recently liked first, while the budget has room.
*
* Defaults match Flutter: 5 GiB per bucket, prefetch=5, cache-liked=true.
* Defaults: 5 GiB per bucket, prefetch=5, cache-liked=true.
*/
@Serializable
data class CacheSettings(
@@ -0,0 +1,112 @@
package com.fabledsword.minstrel.cache.audiocache
import androidx.media3.common.C
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheEvictor
import androidx.media3.datasource.cache.CacheSpan
import javax.inject.Inject
import javax.inject.Singleton
/**
* Bytes held against each audio-cache budget, and how much more the Liked
* budget can take ([Long.MAX_VALUE] when it is unlimited).
*/
data class CacheUsage(val likedBytes: Long, val recentBytes: Long, val likedRoomBytes: Long)
/**
* The audio cache's eviction policy: two budgets, Liked songs and Recently
* played, each with its own cap and its own least-recently-used order (see
* [BucketLedger]). SimpleCache takes exactly one evictor, so this one
* carries both budgets.
*
* [CacheBudgetSync] feeds it the signed-in user's liked set and the caps
* from Storage settings, so a like, an unlike or a cap change applies at
* once, without restarting the app.
*
* Locking: SimpleCache calls every [CacheEvictor] method while holding its
* own monitor. Outside calls take the cache's monitor first and the
* ledger's second, the same order as those callbacks, so the two never
* deadlock.
*/
@Singleton
class TwoBucketCacheEvictor @Inject constructor() : CacheEvictor {
private val ledger = BucketLedger<CacheSpan>(
keyOf = { it.key },
lengthOf = { it.length },
lruOrder = compareBy<CacheSpan> { it.lastTouchTimestamp }.then(naturalOrder()),
)
@Volatile private var cache: Cache? = null
/** Called once the SimpleCache exists, so updates can trim it. */
fun attach(cache: Cache) {
this.cache = cache
}
/** Apply a new liked set and caps, then trim whatever no longer fits. */
fun update(likedIds: Set<String>, likedCapBytes: Long, recentCapBytes: Long) {
val c = cache
val apply = {
ledger.likedCapBytes = likedCapBytes
ledger.recentCapBytes = recentCapBytes
ledger.setLiked(likedIds)
}
if (c == null) {
synchronized(ledger) { apply() }
} else {
synchronized(c) {
synchronized(ledger) {
apply()
evict(c, pendingKey = null, pendingBytes = 0L)
}
}
}
}
fun usage(): CacheUsage = synchronized(ledger) {
CacheUsage(
likedBytes = ledger.bytesIn(CacheBucket.LIKED),
recentBytes = ledger.bytesIn(CacheBucket.RECENT),
likedRoomBytes = ledger.likedRoomBytes(),
)
}
override fun requiresCacheSpanTouches(): Boolean = true
override fun onCacheInitialized() = Unit
override fun onStartFile(cache: Cache, key: String, position: Long, length: Long) {
synchronized(ledger) {
evict(cache, key, if (length == C.LENGTH_UNSET.toLong()) 0L else length)
}
}
override fun onSpanAdded(cache: Cache, span: CacheSpan) {
synchronized(ledger) {
ledger.add(span)
evict(cache, pendingKey = null, pendingBytes = 0L)
}
}
override fun onSpanRemoved(cache: Cache, span: CacheSpan) {
synchronized(ledger) { ledger.remove(span) }
}
override fun onSpanTouched(cache: Cache, oldSpan: CacheSpan, newSpan: CacheSpan) {
synchronized(ledger) {
ledger.remove(oldSpan)
ledger.add(newSpan)
evict(cache, pendingKey = null, pendingBytes = 0L)
}
}
private fun evict(cache: Cache, pendingKey: String?, pendingBytes: Long) {
while (true) {
val span = ledger.nextEviction(pendingKey, pendingBytes) ?: return
cache.removeSpan(span)
// removeSpan reports back through onSpanRemoved; removing here as
// well guarantees the loop advances even if it did not.
ledger.remove(span)
}
}
}
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.cache.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
@@ -11,6 +13,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
@@ -26,6 +29,8 @@ import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
@@ -35,17 +40,12 @@ import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
/**
* Single Room database for the whole app. Mirrors the Flutter client's
* Drift `AppDb` — same logical schema, one entity-family per @Entity.
* Single Room database for the whole app, one entity family per @Entity.
*
* Native v1 starts at schema version 1 (the Flutter Drift schemaVersion 11
* is an internal client detail; the server is the source of truth and the
* sync controller refills on first launch). Future schema bumps land per
* change with explicit `Migration` entries; `fallbackToDestructiveMigration`
* in `DatabaseModule` is the safety net while we're pre-v1.
*
* Entity families land in Task 4.2 slices. The set will grow until the
* whole Drift schema is mirrored.
* The server is the source of truth: the sync controller refills the
* cache on first launch. Schema bumps land with explicit `Migration`
* entries; `fallbackToDestructiveMigration` in `DatabaseModule` rebuilds
* the database from a version that has no migration path.
*/
@Database(
entities = [
@@ -64,10 +64,30 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedHistorySnapshotEntity::class,
AuthSessionEntity::class,
DiagnosticEventEntity::class,
CachedNotificationEntity::class,
CachedNotificationSettingsEntity::class,
],
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch.
version = 7,
// v12: + auth_session.backgroundDelivery and notifiedUpTo, the device's
// background-delivery choice and its shade high-water mark.
// v11: + cached_notifications and cached_notification_settings, the
// notifications inbox and its settings. MIGRATION_10_11 creates
// both; nothing to backfill, the first refresh fills them.
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
// albumPeak, the ReplayGain values the player levels by.
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
// every row and an existing cache gains its values.
// v9: + auth_session.normalizationJson, the loudness-normalization
// preference. The first schema step with an explicit Migration
// (MIGRATION_8_9): a destructive rebuild would also wipe this row —
// the server address and theme — and the queued offline writes,
// which is too much to lose for one added column.
// v8: + cached_tracks.missing, the server's missing-file mark,
// so cache-first surfaces stop offering files that cannot stream.
// v7: + diagnostic_events table and the diagnosticsOptOut column
// on auth_session. Versions before v8 rebuild destructively, which is
// right for them: the next sync refills every row with the new
// columns populated, so there is nothing to migrate by hand.
version = 12,
exportSchema = true,
)
@TypeConverters(MinstrelTypeConverters::class)
@@ -87,4 +107,58 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
abstract fun authSessionDao(): AuthSessionDao
abstract fun diagnosticEventDao(): DiagnosticEventDao
abstract fun cachedNotificationDao(): CachedNotificationDao
}
/** v8 → v9: add the nullable normalization preference column. */
val MIGRATION_8_9: Migration = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
}
}
/**
* v9 → v10: the gain columns. Rows synced before this carry no gains,
* and the sync is incremental, so it would never re-send them: the cursor goes
* back to 0 and the next sync is a full one, upserting every row in place.
*/
val MIGRATION_9_10: Migration = object : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE cached_tracks ADD COLUMN trackGain REAL")
db.execSQL("ALTER TABLE cached_tracks ADD COLUMN trackPeak REAL")
db.execSQL("ALTER TABLE cached_albums ADD COLUMN albumGain REAL")
db.execSQL("ALTER TABLE cached_albums ADD COLUMN albumPeak REAL")
db.execSQL("UPDATE sync_metadata SET cursor = 0")
}
}
/**
* v10 → v11: the notifications inbox cache and its settings. The SQL
* matches what Room generates for the two entities; Room checks it on open.
*/
val MIGRATION_10_11: Migration = object : Migration(10, 11) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `cached_notifications` (" +
"`id` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, " +
"`body` TEXT NOT NULL, `link` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, " +
"`readAt` INTEGER, PRIMARY KEY(`id`))",
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `cached_notification_settings` (" +
"`id` INTEGER NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`id`))",
)
}
}
/**
* v11 → v12: background delivery. The device choice defaults on,
* as the entity's column default says; the high-water mark starts empty, so
* the first catch-up sets it without announcing anything.
*/
val MIGRATION_11_12: Migration = object : Migration(11, 12) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE auth_session ADD COLUMN backgroundDelivery INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE auth_session ADD COLUMN notifiedUpTo INTEGER")
}
}
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
@@ -32,11 +33,11 @@ object DatabaseModule {
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
// Pre-v1 safety net: rebuild on any schema mismatch. The sync
// controller refills the cache from the server on first
// launch, so users lose only the unsynced mutation queue
// (acceptable while we're iterating). Replace with explicit
// Migration entries before the first tagged release.
// Explicit migrations from v8 onward; an older schema has no path
// and rebuilds. The sync controller refills the cache from the
// server on first launch, so a rebuild loses only the unsynced
// mutation queue.
.addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
@@ -111,5 +112,10 @@ object DatabaseModule {
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
db.diagnosticEventDao()
@Provides
@Singleton
fun provideCachedNotificationDao(db: AppDatabase): CachedNotificationDao =
db.cachedNotificationDao()
private const val DATABASE_NAME = "minstrel.db"
}
@@ -4,17 +4,10 @@ import androidx.room.TypeConverter
import kotlinx.datetime.Instant
/**
* Source category for a cached audio file. Mirrors the Drift `CacheSource`
* enum used by the Flutter client's `audio_cache_index` table 1:1.
*
* - `MANUAL`: user explicitly pinned (e.g. "save for offline")
* - `AUTO_LIKED`: auto-cached because the track is liked
* - `AUTO_PLAYLIST`: auto-cached because it's in a pinned playlist
* - `AUTO_PREFETCH`: pre-warmed by background prefetch heuristics
* - `INCIDENTAL`: cached as a side effect of playback streaming
*
* Eviction priority order (first to evict): INCIDENTAL → AUTO_PREFETCH
* → AUTO_PLAYLIST → AUTO_LIKED → MANUAL.
* How a row reached `audio_cache_index`. `CacheIndexer` writes
* `INCIDENTAL` (the track was played); the other values are stored names
* no writer uses. Eviction does not read this: the audio cache's two
* budgets follow the liked set (see `TwoBucketCacheEvictor`).
*/
enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL }
@@ -22,10 +15,8 @@ enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCID
* Cross-cutting Room type converters. Registered on `@Database` via
* `@TypeConverters(MinstrelTypeConverters::class)`.
*
* `kotlinx.datetime.Instant` ↔ `Long` epoch millis: the same convention the
* Flutter client uses (Drift `text()` column with ISO-8601 strings would
* also work but Long is leaner; we don't need human-readable rows in the
* SQLite file).
* `kotlinx.datetime.Instant` ↔ `Long` epoch millis: leaner than ISO-8601
* text, and nothing needs human-readable rows in the SQLite file.
*/
class MinstrelTypeConverters {
@TypeConverter fun instantToLong(i: Instant?): Long? = i?.toEpochMilliseconds()
@@ -4,7 +4,6 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.CacheSource
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
import kotlinx.coroutines.flow.Flow
@@ -32,23 +31,6 @@ interface AudioCacheIndexDao {
@Query("SELECT trackId FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST")
suspend fun trackIdsByRecency(): List<String>
/** Eviction-candidate ordering — oldest lastPlayedAt first within a source. */
@Query(
"SELECT * FROM audio_cache_index " +
"WHERE source = :source " +
"ORDER BY lastPlayedAt ASC NULLS FIRST, cachedAt ASC",
)
suspend fun evictionCandidates(source: CacheSource): List<AudioCacheIndexEntity>
/** Sum of bytes across rows; the rolling/liked bucket caps compare against this. */
@Query("SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index")
suspend fun totalBytes(): Long
@Query(
"SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index WHERE source = :source",
)
suspend fun bytesBySource(source: CacheSource): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: AudioCacheIndexEntity)
@@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.Instant
@Dao
interface AuthSessionDao {
@@ -46,4 +47,16 @@ interface AuthSessionDao {
/** Partial update: change only the per-device diagnostics opt-out. */
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
suspend fun setDiagnosticsOptOut(optOut: Boolean)
/** Partial update: change only the serialized normalization preference. */
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
suspend fun setNormalizationJson(json: String?)
/** Partial update: change only the background-delivery choice. */
@Query("UPDATE auth_session SET backgroundDelivery = :enabled WHERE id = 0")
suspend fun setBackgroundDelivery(enabled: Boolean)
/** Partial update: change only the shade's high-water mark. */
@Query("UPDATE auth_session SET notifiedUpTo = :upTo WHERE id = 0")
suspend fun setNotifiedUpTo(upTo: Instant?)
}
@@ -10,12 +10,16 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface CachedLikeDao {
/** All track IDs the user has liked. Audio-cache eviction uses this set. */
/**
* The user's liked track IDs, most recently liked first. The audio
* cache's Liked budget and the liked-audio downloads read this.
*/
@Query(
"SELECT entityId FROM cached_likes " +
"WHERE userId = :userId AND entityType = 'track'",
"WHERE userId = :userId AND entityType = 'track' " +
"ORDER BY likedAt DESC",
)
fun observeLikedTrackIds(userId: String): Flow<List<String>>
fun observeLikedTrackIdsNewestFirst(userId: String): Flow<List<String>>
@Query(
"SELECT entityId FROM cached_likes " +
@@ -44,10 +48,9 @@ interface CachedLikeDao {
/**
* 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.
* so an unlike made on another device (a row the server no longer
* surfaces) leaves the local cache too; upserts alone would keep a
* stale Liked tab entry forever.
*/
@Transaction
suspend fun replaceAllForUser(userId: String, rows: List<CachedLikeEntity>) {
@@ -31,4 +31,8 @@ interface CachedMutationDao {
@Query("DELETE FROM cached_mutations")
suspend fun clear()
/** Whether a write of [kind] is still waiting to be replayed. */
@Query("SELECT EXISTS(SELECT 1 FROM cached_mutations WHERE kind = :kind)")
suspend fun hasPending(kind: String): Boolean
}
@@ -0,0 +1,51 @@
package com.fabledsword.minstrel.cache.db.dao
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.CachedNotificationEntity
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.Instant
@Dao
interface CachedNotificationDao {
@Query("SELECT * FROM cached_notifications ORDER BY createdAt DESC, id DESC")
fun observeAll(): Flow<List<CachedNotificationEntity>>
@Query("SELECT COUNT(*) FROM cached_notifications WHERE readAt IS NULL")
fun observeUnreadCount(): Flow<Int>
@Query("SELECT * FROM cached_notifications")
suspend fun getAll(): List<CachedNotificationEntity>
@Query("DELETE FROM cached_notifications")
suspend fun clear()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(rows: List<CachedNotificationEntity>)
/** The newest page replaces the cache whole: a notice gone server-side goes here too. */
@Transaction
suspend fun replaceAll(rows: List<CachedNotificationEntity>) {
clear()
insertAll(rows)
}
@Query("UPDATE cached_notifications SET readAt = :at WHERE id = :id AND readAt IS NULL")
suspend fun markRead(id: String, at: Instant)
@Query("UPDATE cached_notifications SET readAt = :at WHERE readAt IS NULL")
suspend fun markAllRead(at: Instant)
@Query("SELECT * FROM cached_notification_settings WHERE id = 1")
fun observeSettings(): Flow<CachedNotificationSettingsEntity?>
@Query("SELECT * FROM cached_notification_settings WHERE id = 1")
suspend fun getSettings(): CachedNotificationSettingsEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertSettings(row: CachedNotificationSettingsEntity)
}
@@ -59,7 +59,6 @@ interface CachedPlaylistDao {
/**
* Atomically reconciles the cache against the fresh list response.
* Mirrors `flutter_client/lib/playlists/playlists_provider.dart:54` —
* `BuildSystemPlaylists` rotates system-playlist UUIDs every
* rebuild, so upsert alone leaves stale rows whose detail fetch
* 404s. Delete any of the user's rows not in [freshOwnedIds] (this
@@ -46,13 +46,13 @@ interface CachedPlaylistTrackDao {
* Atomic on purpose. Room's InvalidationTracker only notifies observers
* after the transaction commits, so [observeByPlaylist] never sees the
* empty gap between the delete and the re-insert. Un-transacted, that
* gap is a real observed state — it's what made every Home row visibly
* collapse to empty and refill before issue #2327 fixed the equivalent
* write in `CachedHomeIndexDao`.
* gap is a real observed state: the list visibly collapses to empty
* and refills, which is why `CachedHomeIndexDao` transacts its writes
* too.
*
* Nothing observes [observeByPlaylist] live today, so this is
* pre-emptive: it means making playlist detail cache-first later can't
* silently reintroduce that flicker.
* pre-emptive: making playlist detail cache-first later can't
* silently bring that flicker in.
*/
@Transaction
suspend fun replacePlaylistTracks(
@@ -41,8 +41,7 @@ interface CachedQuarantineDao {
/**
* Atomic full-replace from the server snapshot — one transaction so
* the `observeAll()` watcher sees a single merged emission, not a
* delete-then-insert flicker. Mirrors Flutter's MyQuarantineController
* `_refreshFromServer` transaction.
* delete-then-insert flicker.
*/
@Transaction
suspend fun replaceAll(rows: List<CachedQuarantineEntity>) {
@@ -38,6 +38,27 @@ interface CachedTrackDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedTrackEntity>)
/**
* ReplayGain values for [ids]: the track's own from its row,
* the album's from its album row. A track not in the cache has no row.
*/
@Query(
"SELECT t.id AS id, t.trackGain AS trackGain, t.trackPeak AS trackPeak, " +
"a.albumGain AS albumGain, a.albumPeak AS albumPeak " +
"FROM cached_tracks t LEFT JOIN cached_albums a ON a.id = t.albumId " +
"WHERE t.id IN (:ids)",
)
suspend fun replayGains(ids: List<String>): List<CachedReplayGain>
@Query("DELETE FROM cached_tracks WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>)
}
/** One row of [CachedTrackDao.replayGains]. */
data class CachedReplayGain(
val id: String,
val trackGain: Float?,
val trackPeak: Float?,
val albumGain: Float?,
val albumPeak: Float?,
)
@@ -7,18 +7,14 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* One row per fully-downloaded audio file. Mirrors
* `flutter_client/lib/cache/db.dart`'s `AudioCacheIndex` Drift table.
* One row per track that has played through the audio cache (see
* `CacheIndexer`); SimpleCache, not this table, says what is on disk.
*
* Drives the 2-bucket LRU eviction (Phase 12 AudioCacheEvictionWorker):
* - `incidental` files (streamed-and-cached side effect) evict first
* - `autoPrefetch` second
* - `manual` (user explicitly pinned) evict last
*
* `cachedAt` = when we downloaded. `lastPlayedAt` = when the user last
* played it; drives the offline "recently played" ordering and is the
* tiebreaker for LRU eviction within a source bucket. Nullable on
* brand-new rows until the first post-cache play touches them.
* `source` says how the row arrived (see [CacheSource]); eviction does
* not read it. `cachedAt` = when the row was written. `lastPlayedAt` =
* when the user last played the track, which orders the offline
* "recently played" pool. Nullable on brand-new rows until the first
* post-cache play touches them.
*/
@Entity(tableName = "audio_cache_index")
data class AudioCacheIndexEntity(
@@ -1,7 +1,9 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Instant
/**
* Single-row table holding the user's session cookie, configured
@@ -37,10 +39,29 @@ data class AuthSessionEntity(
*/
val cacheSettingsJson: String? = null,
/**
* Per-device opt-out of diagnostics reporting (M9). When the
* Per-device opt-out of diagnostics reporting. When the
* account's debug mode is enabled by an admin, the user can still
* turn reporting OFF on this device (battery/privacy) — that local
* choice lives here. Default false = honor the account flag.
*/
val diagnosticsOptOut: Boolean = false,
/**
* JSON-encoded NormalizationPrefs (settings/data), the last value seen
* from the server or set here. Null = never fetched; the
* defaults apply. Kept so offline playback still levels.
*/
val normalizationJson: String? = null,
/**
* "Notifications when the app is closed": keep the
* delivery foreground service running. A device choice, on by default.
* The column default matches MIGRATION_11_12's, which Room checks.
*/
@ColumnInfo(defaultValue = "1")
val backgroundDelivery: Boolean = true,
/**
* The newest notice this device has announced in the shade. A catch-up
* announces only what is newer, so a reboot or a reconnect never
* re-announces a backlog. Cleared on sign-out.
*/
val notifiedUpTo: Instant? = null,
)
@@ -5,10 +5,7 @@ import androidx.room.PrimaryKey
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one album. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedAlbums` Drift table.
*/
/** Cache row for one album. */
@Entity(tableName = "cached_albums")
data class CachedAlbumEntity(
@PrimaryKey val id: String,
@@ -18,5 +15,8 @@ data class CachedAlbumEntity(
val releaseDate: String? = null,
val coverPath: String? = null,
val mbid: String? = null,
// ReplayGain 2.0 album values; null until every track is measured.
val albumGain: Float? = null,
val albumPeak: Float? = null,
val fetchedAt: Instant = Clock.System.now(),
)
@@ -6,13 +6,11 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one artist. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedArtists` Drift table.
* Cache row for one artist.
*
* Column names follow Kotlin idiom (camelCase) rather than Drift's
* snake_case — the schema is purely internal to the native client; the
* sync controller exchanges snake_case JSON with the server and our
* mappers translate at the boundary.
* Column names follow Kotlin idiom (camelCase) — the schema is purely
* internal to the app; the sync controller exchanges snake_case JSON
* with the server and our mappers translate at the boundary.
*/
@Entity(tableName = "cached_artists")
data class CachedArtistEntity(
@@ -8,8 +8,7 @@ import kotlinx.datetime.Instant
/**
* Single-row snapshot of the `/api/me/history` response, stored as the
* raw wire JSON so the History tab paints instantly from disk on cold
* open and survives connectivity loss. Mirrors the Flutter Drift
* `CachedHistorySnapshot` table (id defaults to 1; one row total).
* open and survives connectivity loss. One row total; id defaults to 1.
*
* Cache-first + SWR: the tab observes this row and a background refresh
* overwrites it, so the freshest plays land without a blocking spinner.
@@ -5,8 +5,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Per-item row driving the Home screen sections. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedHomeIndex` Drift table.
* Per-item row driving the Home screen sections.
*
* `section` is one of (matching /api/home keys):
* - "recently_added_albums"
@@ -5,14 +5,13 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Like membership row. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedLikes` Drift table. Composite primary key — one user may
* Like membership row. Composite primary key — one user may
* independently like a track AND its album AND its artist; rows are
* disambiguated by the (userId, entityType, entityId) triple.
*
* `entityType` is one of "track" | "album" | "artist". Stored as a
* plain string for parity with the Drift schema and the server's wire
* format; an enum doesn't earn its keep here.
* plain string to match the server's wire format; an enum doesn't
* earn its keep here.
*/
@Entity(
tableName = "cached_likes",
@@ -6,12 +6,11 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* One row per pending offline-write. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedMutations` Drift table.
* One row per pending offline write.
*
* MutationQueue.enqueue() inserts a row when a server-write fails with
* an IOException; MutationReplayer.drain() pops and re-attempts each
* row when connectivity returns (Phase 12.2).
* row when connectivity returns.
*
* `kind` is a stable string registered in `MutationKind` — the replayer
* looks up the handler in a kind→suspend-function map. Unknown kinds
@@ -0,0 +1,21 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Instant
/**
* One notice from the user's inbox, kept so the Notifications screen
* and the bell's badge work offline, and so a read made offline shows at once.
* The newest page is cached; older notices are the server's to keep.
*/
@Entity(tableName = "cached_notifications")
data class CachedNotificationEntity(
@PrimaryKey val id: String,
val kind: String,
val title: String,
val body: String,
val link: String,
val createdAt: Instant,
val readAt: Instant?,
)
@@ -0,0 +1,18 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Single-row copy of the user's notification settings, stored as the
* wire JSON, so the settings screen opens offline and a toggle shows at once.
*/
@Entity(tableName = "cached_notification_settings")
data class CachedNotificationSettingsEntity(
@PrimaryKey val id: Int = SINGLETON_ID,
val json: String,
) {
companion object {
const val SINGLETON_ID = 1
}
}
@@ -6,8 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one playlist (user or system). Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedPlaylists` Drift table.
* Cache row for one playlist (user or system).
*
* `systemVariant` is null for user playlists and one of
* "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc.
@@ -3,8 +3,7 @@ package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
/**
* Ordered membership of tracks within a playlist. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedPlaylistTracks` Drift table.
* Ordered membership of tracks within a playlist.
* Composite PK so the same track can only appear once per playlist;
* `position` carries the ordering.
*/
@@ -6,9 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* The current user's quarantine flag for one track. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedQuarantineMine` Drift
* table.
* The current user's quarantine flag for one track.
*
* The flat denormalized track/album/artist columns let the Quarantine
* screen render off this single table without a join — the server
@@ -7,18 +7,15 @@ import kotlinx.datetime.Instant
/**
* Single-row snapshot of the last playback session — queue (as JSON),
* current index, position, and source tag. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedResumeState` Drift table.
* current index, position, and source tag.
*
* Lets a torn-down session (the player's idle/dismissed teardown)
* resume on next launch; without it the headset / lock-screen play
* button has nothing to act on. The Flutter codebase persists this on
* every queue change and reads on cold start.
* button has nothing to act on.
*
* The `json` field carries the entire ResumeState shape (queue trackrefs,
* currentIndex, positionMs, sourceTag). Kotlin-side deserialization
* happens in the ResumeController (Phase 6.5) so the schema stays
* stable across resume-shape changes.
* currentIndex, positionMs, sourceTag). The ResumeController decodes it,
* so the schema stays stable across resume-shape changes.
*/
@Entity(tableName = "cached_resume_state")
data class CachedResumeStateEntity(
@@ -6,8 +6,11 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one track. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedTracks` Drift table.
* Cache row for one track.
*
* [missing] carries the server's missing-file mark. Every read that
* can put a track in front of the user — or in a queue — must exclude it, and
* the DAO queries do that rather than each call site remembering to.
*/
@Entity(tableName = "cached_tracks")
data class CachedTrackEntity(
@@ -21,5 +24,10 @@ data class CachedTrackEntity(
val filePath: String? = null,
val fileFormat: String? = null,
val genre: String? = null,
val missing: Boolean = false,
// ReplayGain 2.0 track values, kept so cached audio levels
// offline. Null until the server has measured the track.
val trackGain: Float? = null,
val trackPeak: Float? = null,
val fetchedAt: Instant = Clock.System.now(),
)
@@ -4,14 +4,14 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* One buffered device-diagnostics event (M9). The DiagnosticsReporter
* One buffered device-diagnostics event. The DiagnosticsReporter
* writes rows here when the account's debug mode is on; the
* DiagnosticsUploader drains them to POST /api/diagnostics and deletes
* on success.
*
* This is a deliberate ring buffer that is NOT routed through the offline
* MutationQueue: diagnostics are high-volume, best-effort telemetry, and
* the bug we're chasing (roaming / dead-zone recovery) happens WHILE
* the failures they record (roaming / dead-zone recovery) happen WHILE
* offline — so events must persist locally through the dead zone and
* upload on recovery, without clogging the user-data mutation replay.
*
@@ -7,12 +7,8 @@ import kotlinx.datetime.Instant
/**
* Single-row table that tracks where the SyncController left off.
*
* Mirrors the Flutter Drift `sync_metadata` table. There is only ever
* one row at `id = 0`; the SyncController upserts on every successful
* `/api/sync` call.
*
* Pulled into Task 4.1 to give Room a compileable @Database (it needs
* at least one entity); the SyncController itself lands in Phase 12.4.
* There is only ever one row at `id = 0`; the SyncController upserts it
* on every successful `/api/library/sync` call.
*/
@Entity(tableName = "sync_metadata")
data class SyncMetadataEntity(
@@ -1,7 +1,9 @@
package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -31,16 +33,31 @@ object MutationKind {
// Durable close-by-id for a play whose server row is already open
// (play_started succeeded). Distinct from PLAY_OFFLINE, which records
// a *whole* play when no server row exists. Using close-by-id on
// background avoids the duplicate + orphan row the old offline-on-stop
// path produced (see 2026-06-11 contract audit).
// a *whole* play when no server row exists. Recording a whole play on
// background would duplicate the open row and leave it orphaned.
const val PLAY_ENDED: String = "play_ended"
// #2374 suggestion snooze. ONE toggle kind rather than separate
// Suggestion snooze. ONE toggle kind rather than separate
// snooze/unsnooze kinds, mirroring LIKE_TOGGLE, so a snooze followed by
// an undo collapses to the latest intent instead of replaying as two
// opposed calls whose order decides the outcome.
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
// Loudness-normalization preference. The payload is the whole
// preference, a target state like the toggles above, so queued changes
// collapse to the last one and an older one can never be replayed last.
const val NORMALIZATION_SET: String = "normalization_set"
// Notifications inbox. A read is a one-way action (read never goes
// back to unread), so neither read kind needs collapsing. Read-all
// carries the moment the user asked, so a late replay leaves newer
// notices unread.
const val NOTIFICATION_READ: String = "notification_read"
const val NOTIFICATIONS_READ_ALL: String = "notifications_read_all"
// Per-kind notification channel. One row per (kind, channel) target
// state, collapsed on that pair, so the newest choice is the one sent.
const val NOTIFICATION_SETTING_SET: String = "notification_setting_set"
}
/**
@@ -57,8 +74,8 @@ data class LikeTogglePayload(
)
/**
* Persisted payload for `MutationKind.REQUEST_CREATE` (Discover →
* Lidarr request that landed during a connectivity hiccup). Optional
* Persisted payload for `MutationKind.REQUEST_CREATE` (a Discover
* request that landed during a connectivity hiccup). Optional
* fields stay null when not applicable; the replayer rebuilds the
* `CreateRequestBody` from these.
*/
@@ -74,17 +91,15 @@ data class RequestCreatePayload(
)
/**
* Minimal write-side of the offline mutation queue. Phase 8 v1 only
* enqueues — the connectivity-aware replayer that drains the queue
* lands with Phase 12.2's SyncController.
* Write side of the offline mutation queue; [MutationReplayer] drains it.
*
* Callers (LikesRepository et al.) follow the optimistic-write
* pattern: mutate Room first so the UI reflects the change
* immediately, fire the REST call as a best-effort attempt, and on
* failure enqueue here so the replayer eventually pushes it through.
* This matches `feedback_offline_first_for_server_writes` — writes
* never go fire-and-forget.
* Writes never go fire-and-forget.
*/
@Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers
@Singleton
class MutationQueue @Inject constructor(
private val dao: CachedMutationDao,
@@ -177,6 +192,31 @@ class MutationQueue @Inject constructor(
),
)
/** Queues the user's whole normalization preference for replay. */
suspend fun enqueueNormalizationSet(prefs: NormalizationPrefs): Long = insertUserDriven(
MutationKind.NORMALIZATION_SET,
json.encodeToString(NormalizationPrefs.serializer(), prefs),
)
suspend fun enqueueNotificationRead(id: String): Long = insertUserDriven(
MutationKind.NOTIFICATION_READ,
json.encodeToString(NotificationReadPayload.serializer(), NotificationReadPayload(id)),
)
suspend fun enqueueNotificationsReadAll(upToIso: String?): Long = insertUserDriven(
MutationKind.NOTIFICATIONS_READ_ALL,
json.encodeToString(
NotificationsReadAllPayload.serializer(),
NotificationsReadAllPayload(upToIso),
),
)
suspend fun enqueueNotificationSettingSet(payload: NotificationSettingPayload): Long =
insertUserDriven(
MutationKind.NOTIFICATION_SETTING_SET,
json.encodeToString(NotificationSettingPayload.serializer(), payload),
)
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
MutationKind.REQUEST_CANCEL,
json.encodeToString(
@@ -218,7 +258,7 @@ class MutationQueue @Inject constructor(
}
/**
* Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE` (#2374).
* Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE`.
* `desiredSnoozed` is the *target* state, matching [LikeTogglePayload], so
* the replayer can collapse repeated toggles for one candidate down to the
* last intent. Both directions are idempotent server-side: re-snoozing
@@ -241,10 +281,10 @@ data class SuggestionSnoozeTogglePayload(
data class QuarantineUnflagPayload(val trackId: String)
/**
* Persisted payload for `MutationKind.PLAYLIST_APPEND`. Mirrors the
* Flutter wire shape: list of track IDs to append to a specific
* playlist. Server is idempotent at the (playlist_id, track_id) pair
* level — re-firing the same payload yields no duplicates.
* Persisted payload for `MutationKind.PLAYLIST_APPEND`: the track IDs to
* append to one playlist. The server is idempotent at the
* (playlist_id, track_id) pair level — re-firing the same payload yields
* no duplicates.
*/
@Serializable
data class PlaylistAppendPayload(
@@ -270,7 +310,7 @@ data class QuarantineFlagPayload(
* re-fires `POST /api/events` with type=play_offline; the server
* records start+end from `atIso` (the original play-start time)
* + `durationPlayedMs`, applies the canonical skip rule, and
* advances #415 rotation when source is a system playlist.
* advances rotation when source is a system playlist.
*/
@Serializable
data class PlayOfflinePayload(
@@ -279,7 +319,7 @@ data class PlayOfflinePayload(
val atIso: String,
val durationPlayedMs: Long,
val source: String? = null,
// #1551: device class for context conditioning; null on payloads queued
// Device class for context conditioning; null on payloads queued
// before this field existed (decodes to null → server stores NULL).
val deviceClass: String? = null,
)
@@ -322,3 +362,36 @@ data class PlaybackErrorReportPayload(
val detail: String? = null,
val clientId: String,
)
/** Persisted payload for `MutationKind.NOTIFICATION_READ`. */
@Serializable
data class NotificationReadPayload(val id: String)
/**
* Persisted payload for `MutationKind.NOTIFICATIONS_READ_ALL`.
* `upToIso` is the newest notice the user could see when they asked; null
* when the inbox was empty on the device, which marks everything.
*/
@Serializable
data class NotificationsReadAllPayload(val upToIso: String?)
/**
* Persisted payload for `MutationKind.NOTIFICATION_SETTING_SET`: one
* kind's one channel, as a target state. `channel` is "inbox" | "phone" |
* "email". No defaults, so every field is always written.
*/
@Serializable
data class NotificationSettingPayload(
val kind: String,
val channel: String,
val value: Boolean,
)
/** The wire change for one queued channel setting, or null for an unknown channel. */
internal fun notificationSettingChange(p: NotificationSettingPayload): NotificationSettingChangeWire? =
when (p.channel) {
"inbox" -> NotificationSettingChangeWire(kind = p.kind, inbox = p.value)
"phone" -> NotificationSettingChangeWire(kind = p.kind, phone = p.value)
"email" -> NotificationSettingChangeWire(kind = p.kind, email = p.value)
else -> null
}
@@ -7,6 +7,10 @@ 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.MeApi
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody
import com.fabledsword.minstrel.api.endpoints.ReadAllBody
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
@@ -22,6 +26,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -79,6 +84,8 @@ class MutationReplayer @Inject constructor(
private val eventsApi: EventsApi = retrofit.create()
private val requestsApi: RequestsApi = retrofit.create()
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
private val meApi: MeApi = retrofit.create()
private val notificationsApi: NotificationsApi = retrofit.create()
private val mutex = Mutex()
@@ -153,7 +160,7 @@ class MutationReplayer @Inject constructor(
/**
* Returns the outcome of replaying one row. SENT on a 2xx; DROP for an
* unknown kind/variant. Network / HTTP failures THROW and are classified
* by [outcomeFor] — dispatchers no longer swallow.
* by [outcomeFor] — dispatchers never swallow them.
*/
private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) {
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
@@ -166,10 +173,23 @@ class MutationReplayer @Inject constructor(
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
MutationKind.NOTIFICATION_READ,
MutationKind.NOTIFICATIONS_READ_ALL,
MutationKind.NOTIFICATION_SETTING_SET,
-> dispatchNotification(row)
// Unknown kind — drop so a stale schema entry can't wedge the queue.
else -> Outcome.DROP
}
/** The notifications inbox's kinds, split out to keep [dispatch] simple. */
private suspend fun dispatchNotification(row: CachedMutationEntity): Outcome = when (row.kind) {
MutationKind.NOTIFICATION_READ -> dispatchNotificationRead(row.payload)
MutationKind.NOTIFICATIONS_READ_ALL -> dispatchNotificationsReadAll(row.payload)
MutationKind.NOTIFICATION_SETTING_SET -> dispatchNotificationSettingSet(row.payload)
else -> Outcome.DROP
}
private suspend fun dispatchLikeToggle(payload: String): Outcome {
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
val kindPath = when (decoded.entityType) {
@@ -279,6 +299,37 @@ class MutationReplayer @Inject constructor(
return Outcome.SENT
}
/**
* Sends the queued normalization preference. The device already shows
* it, so the server's echo is not written back: a change made since the
* row was queued would be a newer row, and the collapse keeps only that.
*/
private suspend fun dispatchNormalizationSet(payload: String): Outcome {
meApi.putNormalization(json.decodeFromString(NormalizationPrefs.serializer(), payload))
return Outcome.SENT
}
/** A 404 (the notice was trimmed or already gone) is a 4xx, so DROP: nothing left to do. */
private suspend fun dispatchNotificationRead(payload: String): Outcome {
val decoded = json.decodeFromString(NotificationReadPayload.serializer(), payload)
notificationsApi.markRead(decoded.id)
return Outcome.SENT
}
private suspend fun dispatchNotificationsReadAll(payload: String): Outcome {
val decoded = json.decodeFromString(NotificationsReadAllPayload.serializer(), payload)
notificationsApi.readAll(ReadAllBody(upTo = decoded.upToIso))
return Outcome.SENT
}
/** An unknown channel can only come from a corrupt row: DROP it. */
private suspend fun dispatchNotificationSettingSet(payload: String): Outcome {
val decoded = json.decodeFromString(NotificationSettingPayload.serializer(), payload)
val change = notificationSettingChange(decoded) ?: return Outcome.DROP
notificationsApi.putSettings(PutNotificationSettingsBody(listOf(change)))
return Outcome.SENT
}
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
playbackErrorsApi.report(
@@ -303,7 +354,8 @@ class MutationReplayer @Inject constructor(
/**
* Row ids of desired-state toggles superseded by a later toggle for the same
* entity. Applies to every kind whose payload encodes a TARGET state rather
* than an action — like-toggles and suggestion snoozes (#2374) — because
* than an action — like-toggles, suggestion snoozes and the
* normalization preference — because
* replaying a stale one last would invert the final state.
*
* Top-level and pure so it can be unit-tested without standing up a Retrofit
@@ -340,5 +392,14 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
MutationKind.NOTIFICATION_SETTING_SET -> runCatching {
json.decodeFromString(NotificationSettingPayload.serializer(), row.payload)
}.getOrNull()?.let { "${row.kind}:${it.kind}:${it.channel}" }
// One preference per user, so every normalization row shares one key.
MutationKind.NORMALIZATION_SET -> runCatching {
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
}.getOrNull()?.let { row.kind }
else -> null
}
@@ -34,9 +34,8 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Drives the delta sync against `/api/library/sync` (#357). Mirrors
* the Flutter `SyncController`, scoped to the entity types the
* native client cares about (artist/album/track). Likes and
* Drives the delta sync against `/api/library/sync`, scoped to the
* entity types the app caches (artist/album/track). Likes and
* playlists get their own dedicated refresh paths so the sync
* controller doesn't need to track them.
*
@@ -46,10 +45,9 @@ import javax.inject.Singleton
* with a persisted cookie). Re-running on every cookie change is
* what makes the Library tabs populate within seconds of sign-in.
*
* Wipes-on-410: when the server's compaction window has passed the
* stored cursor, the response is 410 Gone. The controller drops the
* three local caches and recurses with cursor=0 — same wipe behavior
* as the Flutter port.
* Restart-on-410: when the server's compaction window has passed the
* stored cursor, the response is 410 Gone. The controller resets the
* cursor to 0 and pulls the full entity set again (see [resetCursor]).
*/
@Singleton
class SyncController @Inject constructor(
@@ -109,19 +107,36 @@ class SyncController @Inject constructor(
}
}
/**
* Pulls pages until the server has nothing newer. The server returns
* at most 1000 changes a call, and a full library re-read adds tens
* of thousands, so one page per trigger would leave the cache behind:
* merge deletes would never arrive and the freshness sweep would keep
* asking for tracks that are gone. The cursor is stored after each
* page, so an interrupted pass resumes where it stopped.
*/
private suspend fun sync() {
repeat(MAX_PAGES_PER_PASS) {
if (!syncPage()) return
}
}
/** One page of the change log. True when the server may hold more. */
private suspend fun syncPage(): Boolean {
val meta = syncMetadataDao.get()
val cursor = meta?.cursor ?: 0L
val response = api.sync(cursor)
when (response.code()) {
return when (response.code()) {
HTTP_NO_CONTENT -> {
touchLastSyncAt(cursor)
false
}
HTTP_GONE -> {
wipeAndRetry()
resetCursor()
true
}
HTTP_OK -> {
val body = response.body() ?: return
val body = response.body() ?: return false
applyDeltas(body.upserts.artist, body.upserts.album, body.upserts.track)
applyDeletes(body.deletes.artist, body.deletes.album, body.deletes.track)
syncMetadataDao.upsert(
@@ -131,10 +146,14 @@ class SyncController @Inject constructor(
lastSyncAt = Clock.System.now(),
),
)
// A cursor that did not advance would ask for the same page
// again; stop rather than spin.
body.cursor > cursor
}
else -> {
// Unhandled status — treat as a no-op for v1; the next
// Unhandled status — treat as a no-op; the next
// app start retries from the same cursor.
false
}
}
}
@@ -165,25 +184,27 @@ class SyncController @Inject constructor(
)
}
private suspend fun wipeAndRetry() {
private suspend fun resetCursor() {
// The Library DAOs only expose deleteByIds; for the rare 410
// path we re-issue the same request with cursor=0 after
// resetting our cursor. The next response carries the full
// path the next page is requested with cursor=0 after
// resetting our cursor. The following pages carry the full
// entity set, which upsertAll overwrites the existing rows
// with — old rows for entities the server no longer knows
// about will linger until a later 410 or until the user clears
// app data. Acceptable for v1; a future "wipe all" DAO method
// tightens this up.
// about linger until MetadataProvider's refresh meets their
// 404 and drops them.
syncMetadataDao.upsert(
SyncMetadataEntity(id = 0, cursor = 0, lastSyncAt = null),
)
sync()
}
private companion object {
const val HTTP_OK = 200
const val HTTP_NO_CONTENT = 204
const val HTTP_GONE = 410
// A guard, not a budget: 1000 pages is a million changes, far past
// anything a library writes between two app starts.
const val MAX_PAGES_PER_PASS = 1000
}
}
@@ -206,6 +227,8 @@ private fun SyncAlbumWire.toEntity(): CachedAlbumEntity = CachedAlbumEntity(
releaseDate = releaseDate,
coverPath = coverArtPath,
mbid = mbid,
albumGain = albumGain,
albumPeak = albumPeak,
)
private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
@@ -219,4 +242,7 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
filePath = filePath,
fileFormat = fileFormat,
genre = genre,
missing = missing,
trackGain = trackGain,
trackPeak = trackPeak,
)
@@ -74,6 +74,35 @@ class ConnectivityObserver @Inject constructor(
awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
/**
* Whether the network the device is using now is unmetered: Wi-Fi or
* Ethernet, but not mobile data or a hotspot Android marks metered.
* Downloads the user did not start (the liked-song downloads) wait for
* this.
*/
val unmetered: Flow<Boolean> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities,
) {
trySend(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))
}
override fun onLost(network: Network) {
trySend(false)
}
}
cm.registerDefaultNetworkCallback(callback)
trySend(isActiveNetworkUnmetered())
awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
private fun isActiveNetworkUnmetered(): Boolean {
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
return caps != null &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
private fun hasActiveNetwork(): Boolean {
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
return caps != null &&

Some files were not shown because too many files have changed in this diff Show More