Compare commits

...
70 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 dabe1e052d feat: genre mixes pick specific genres that don't share songs (#6933)
release / go (push) Successful in 2m0s
release / web (push) Successful in 1m33s
release / govulncheck (push) Successful in 18s
release / integration (push) Successful in 5m11s
release / android (push) Successful in 4m14s
release / Build signed APK (push) Successful in 4m38s
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 shelf had four mixes built largely from the same lo-fi beats: about
1,250 tracks carry Hip Hop, Lo-Fi, Instrumental, Downtempo and four more
tags at once, and the near-duplicate guard compared artists, not songs, so
each tag passed as its own channel.

Genre selection now works on songs:
- ListGenreMixCandidates returns each genre's track ids instead of its
  artist ids.
- A broad genre, one whose songs mostly also carry clearly narrower
  candidates (Rock over Pop Punk and Ska Punk), is considered after the
  specific genres of its tier.
- A genre is kept only when at least half its songs are not already in a
  kept genre.
- Each mix leaves out songs tagged with a genre kept before it
  (ListGenreMixTracks exclude_genres), so a song is in one mix at most.

The tiers, the minimum shelf and the minimum mix length are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:55:19 -05:00
bvandeusenandClaude Opus 5.5 14debe2ea9 feat: genre mixes lean toward what the listener likes (#6921)
release / web (push) Successful in 1m40s
release / go (push) Successful in 2m5s
release / govulncheck (push) Successful in 19s
release / integration (push) Successful in 5m2s
release / android (push) Successful in 4m40s
release / Build signed APK (push) Successful in 5m7s
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
Within a genre both halves of the mix were a uniform daily draw: a song
played forty times was no likelier than one played once, and the unheard
40% ignored who the artist was. ListGenreMixTracks now draws each half by a
date-seeded weighted lottery (Efraimidis-Spirakis: smallest -ln(u)/weight
wins), so the mix still changes daily and holds within a day.

- Familiar: 1 + ln(1 + plays) + 1 if liked, times the share of plays that
  weren't skips.
- Unheard: 1 + ln(1 + the artist's plays + 2 x its liked tracks)
  + 2 x the artist's best similarity to the user's own artists, times the
  artist's non-skip share. An unknown artist weighs 1, so new artists still
  come up.

Which genres are offered, the exclusions and the familiar/unheard blend
are unchanged. DB tests check the lean averaged over ten days, and that a
stranger's tracks stay in the pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:21:04 -05:00
bvandeusenandClaude Opus 5.5 adbb3a3cd8 fix(android): the launch profile refresh waits for the stored login
release / govulncheck (push) Successful in 18s
release / Build signed APK (push) Successful in 4m37s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m50s
release / integration (push) Successful in 4m36s
release / android (push) Successful in 3m59s
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
AuthController refreshes /api/me when the app starts, to pick up account
flags such as debug mode. That ran before the session cookie had loaded
from the Keystore, read the user as signed out, and skipped. It now waits
for the load (bounded by the hydration deadline). The diagnostics
uploader's drain waits the same way, so a launch-time trigger isn't
skipped. BootReceiver already waited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:55:59 -05:00
bvandeusenandClaude Opus 5.5 e73950458f test(android): the slow-hydration test's row carries the mock server URL
release / web (push) Successful in 1m34s
release / govulncheck (push) Successful in 20s
release / integration (push) Successful in 4m57s
release / go (push) Successful in 1m58s
release / android (push) Successful in 4m30s
release / Build signed APK (push) Successful in 4m56s
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
On a real background dispatcher the store's row collector lands after
construction and reset the base URL to the default, so the request went to
localhost:8080 instead of the mock server. The row now holds the server URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:30:42 -05:00
bvandeusenandClaude Opus 5.5 454d71d079 fix(android): the app stops signing itself out at launch (#6911)
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m52s
release / govulncheck (push) Successful in 18s
release / integration (push) Successful in 4m43s
release / android (push) Failing after 3m25s
release / Build signed APK (push) Successful in 4m41s
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
At launch the stored session cookie loads from the Keystore off the main
thread. A request sent before that finished went out without it, typically
the library sync that fires as soon as the server answers. The server then
answered 401, and the interceptor cleared the store on any 401, which also
stopped the load from restoring the cookie and wrote null over the stored
one. The user had to sign in on every launch. A session lost mid-use also
left them on Home with every request failing, because the app only routes
to sign-in when it starts.

- The interceptor waits for the stored cookie to load before attaching it,
  bounded by the existing hydration deadline.
- A 401 signs out only when the request carried the current cookie
  (compare-and-clear). A request sent without one, or with a cookie a newer
  sign-in has replaced, leaves the login alone.
- When the server refuses the session mid-use, the app opens the sign-in
  screen. A sign-out from Settings does not trigger this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:22:59 -05:00
bvandeusenandClaude Opus 5.5 8251fef604 test: seedGenre gives each artist one artist row and one album
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m49s
release / govulncheck (push) Successful in 22s
release / android (push) Successful in 4m22s
release / Build + push container image (push) Successful in 1m12s
release / Verify release artifacts (tag releases only) (push) Skipped
release / integration (push) Successful in 4m52s
release / Build signed APK (push) Successful in 4m46s
release / Attach APK to the Release (tag releases only) (push) Skipped
seedTrack mints a new artist per call when there is no mbid, so the genre
fixtures held one artist per track and the candidate query's artist_ids
counted 30 where the test meant 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:39:29 -05:00
bvandeusenandClaude Opus 5.5 dbaa8b7d57 feat: genre mixes — a daily playlist per genre you listen to, on a "Your genres" Home row (#6904)
release / Build signed APK (push) Successful in 4m34s
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
release / web (push) Successful in 1m24s
release / govulncheck (push) Successful in 25s
release / go (push) Successful in 1m47s
release / integration (push) Failing after 4m38s
release / android (push) Successful in 4m3s
A new system playlist kind, genre_mix: up to 10 "{Genre} mix" playlists per
user, rebuilt daily with the other system mixes.

Which genres: library genres with at least 25 playable tracks across 3
artists, ranked in tiers — taste-profile genres first (matched
case-insensitively, so ID3 "Post-Punk" and folksonomy "post-punk" are one),
then genres the user has played or liked, then the rest of the library, which
only tops a short shelf up to 4. A genre whose artist set is a near
duplicate of one already picked (Jaccard >= 0.5) is skipped, so "Lo-Fi" and
"Lo-Fi Hip Hop" don't both appear but "Alternative Rock" survives "Rock". A
genre that can't fill 15 tracks gives way to the next.

Each mix is 50 tracks: 3 in 5 the user already knows (played through or
liked), the rest unheard in that genre, interleaved, with the usual
per-album/per-artist caps. Tracks skipped twice and more often than played
are left out. The order changes daily and holds within a day. Picks are
stamped taste / taste_unheard, and genre_mix plays count toward rotation and
the recommendation metrics.

Migration 0102 adds genre_mix to the playlists variant constraints.

Web and Android each get a "Your genres" row in name order, hidden until a
mix exists. Kinds with several playlists per user now play by playlist id
wherever the client used to test seed_artist_id (web card, detail page
self-heal), keyed on the server's refreshable flag; on Android genre_mix
joins songs_like_artist as not refreshable. The web detail page's
"mix was rebuilt" reload now finds a Songs-like or genre mix by name instead
of calling the by-kind shuffle, which those kinds don't have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:30:41 -05:00
bvandeusenandClaude Opus 5.5 477f453100 feat(android): settings read as a menu — every group on one Section plate, every setting one SettingRow (#6768)
release / web (push) Successful in 1m19s
release / govulncheck (push) Successful in 18s
release / android (push) Successful in 4m16s
release / Build signed APK (push) Successful in 4m38s
release / go (push) Successful in 1m48s
release / integration (push) Successful in 4m29s
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
Settings and Notification settings were hand-rolled ElevatedCards, each with
its own padding, title style and row layout, and several rows carried
two- and three-line explanations. They are now built from one vocabulary in
theme/Section.kt: Section (the plate), SettingRow (title, one short line, an
optional icon, the control at the end), SwitchRow and ChoiceRow (the whole
row toggles or opens the choices), RowValue, RowSpinner, RowChevron, Muted.

The plate is Minstrel's own raised surface with its radius tokens, so the
app keeps its look. Sign out, sync and clear cache are rows, not loose
buttons; the theme, leveling, loudness target and storage caps are choice
rows; the About card (also the update sheet on Home) shows the version as a
value and the update as a row with its one button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:31:25 -05:00
bvandeusenandClaude Opus 5.5 598abeb8cf fix: a fresh app install syncs the whole library, not the last 30 days of changes (#6778)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m44s
release / android (push) Successful in 4m54s
release / Build signed APK (push) Successful in 5m10s
release / integration (push) Successful in 5m19s
release / Build + push container image (push) Successful in 15s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
The change feed dropped every row older than 30 days, and an app syncing
from cursor 0 builds its library from the feed, so a fresh install, cleared
data or a 410 reset got only what had changed lately.

The compactor now keeps each entity's latest change however old. It drops a
row once a newer one for the same entity exists, and a delete once it is 30
days old, raising a stored mark (library_changes_trimmed) past it; an app
whose cursor is below the mark is sent back to 0, as before. Migration 0101
adds the index the compactor needs, seeds the mark where the old compactor
left off, and gives every artist, album and track whose latest row is not an
upsert a fresh one, so the rows the old compactor dropped come back. The
backup carries the mark with the feed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:07:42 -05:00
bvandeusenandClaude Opus 5.5 ad48d8c8c9 fix: the route-registration test passes Mount its restart hook
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m25s
release / android (push) Successful in 4m57s
release / Build signed APK (push) Successful in 5m9s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 5m21s
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:25:39 -05:00
bvandeusenandClaude Opus 5.5 68e1e47534 fix: a new server takes a backup after its first scan, and restarts to load the restored settings (#6786)
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 / govulncheck (push) Successful in 16s
release / go (push) Failing after 49s
release / web (push) Successful in 1m9s
release / integration (push) Failing after 4m14s
release / android (push) Canceled after 4m38s
release / Build signed APK (push) Canceled after 4m38s
A one-off end-to-end run of the :dev image (scratch Postgres, generated
FLAC files, no outbound network) found that the web restore, done the way
docs/hosting.md describes, was refused with not_empty: a new server's
startup scan fills the library before anyone signs up, and Restore counted
tracks as occupied. The command-line restore passed the same run.

- Restore now asks only that the instance has no accounts. The library the
  first scan found is replaced by the backup's (every foreign key into
  artists/albums/tracks cascades or sets null), and the restore locks the
  library tables so a running scan waits rather than adding to them.
- Settings are read once at boot by about ten services, the library folders
  among them, so after a web restore the server ran on its defaults until
  restarted. The restore endpoint now answers, then asks the process to
  restart in place (syscall.Exec of the same binary, arguments and
  environment), which needs no container restart policy. The startup scan
  of the restarted server replaces the scan the endpoint used to start.
- The restore form says Minstrel is restarting; the docs say a scanned new
  server still takes a backup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:21:00 -05:00
bvandeusenandClaude Opus 5.5 a521326686 fix: the restore form's file picker isn't required, so the form submits once a file is chosen
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m40s
release / android (push) Successful in 5m12s
release / integration (push) Successful in 5m24s
release / Build signed APK (push) Successful in 5m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 23s
release / Verify release artifacts (tag releases only) (push) Skipped
The Restore button already waits for a file. A required file input also kept
jsdom from submitting after a test picked one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:16:27 -05:00
bvandeusenandClaude Opus 5.5 29042e4eb2 feat: a new Minstrel restores a backup, from the web app or the command line, then rescans (#6775)
release / govulncheck (push) Successful in 29s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m26s
release / android (push) Successful in 5m27s
release / Build signed APK (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
A new server (no accounts, no library) offers "Restore a backup" beside
"Create account". The file goes up as the request body to
POST /api/auth/restore with the setup token from the log in X-Setup-Token,
since the backup's admins become this server's. It loads in one transaction,
then a library scan starts and finds each file at its path. The same load runs
offline as `minstrel restore FILE`, which migrates the schema first.

That path alone gets a 1 GiB, 15-minute body allowance instead of 4 MiB and
30s, and skips the JSON-only check for cookie writes: a stale cookie from the
old server would otherwise refuse the upload, and the custom header already
rules out a cross-site form.

The library sync answers 410 cursor_ahead to a cursor past the last one the
server has handed out, read from the change sequence, so an app synced with
the old server after the backup was made starts over instead of skipping the
ids the restored server reuses. Restore now leaves each sequence as having
issued the restored maximum, so that reading holds; a compacted log still
counts its handed-out cursors.

backup.Read keeps the underlying error in its chain, so an oversized upload
answers 413 rather than "not a backup". The hosting docs say how to put the
music back first and what is entered again afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:10:06 -05:00
bvandeusenandClaude Opus 5.5 5090c89d20 fix: the backup panel test reads the music sizes as formatBytes prints them
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m40s
release / android (push) Successful in 5m4s
release / Build signed APK (push) Successful in 5m15s
release / integration (push) Successful in 5m25s
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:35:58 -05:00
bvandeusenandClaude Opus 5.5 967763f6ce feat: the backup can include the music, sending each library file once to music/ and again only when it changes (#6774)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m17s
release / go (push) Successful in 1m33s
release / integration (push) Successful in 5m16s
release / Build signed APK (push) Successful in 6m13s
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
release / android (push) Successful in 5m56s
With "Include the music" on, each night's run sends the library files the
destination doesn't hold yet, after the database file has landed, for up to
six hours; a large library spans several nights the first time. Files keep
their path under the library folders' common root, beside database/ in the
same folder or bucket.

backup_music_files records each file's size and mtime as sent, so a night
sends only new and changed files, and a rescan that touches a track but finds
the same file sends nothing. A changed file goes to its old name; Minstrel
never deletes there. An object the bucket refuses (a locked overwrite) is
kept with its answer and goes again once the file changes or the settings are
saved; three refusals in a row stop the night's music, since that is a key
that may not write under music/. A folder copy is written under a .part name
and renamed, keeping the file's time.

The panel shows how much of the library is in, the refused files, and each
run's music count and note. The verdict still turns on the database file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:26:20 -05:00
bvandeusenandClaude Opus 5.5 ff5ee755f2 fix: the backup test result says its count through plural(), and the panel tests read the size and folder field as shown
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m46s
release / android (push) Successful in 5m23s
release / integration (push) Successful in 5m32s
release / Build signed APK (push) Successful in 5m33s
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-11-02 15:04:32 -05:00
bvandeusenandClaude Opus 5.5 f7222732e3 fix: the route test passes the backup service, and the backup panel narrows its status inside each column
release / govulncheck (push) Successful in 19s
release / web (push) Failing after 1m32s
release / go (push) Successful in 1m47s
release / android (push) Successful in 5m16s
release / integration (push) Successful in 5m31s
release / Build signed APK (push) Successful in 5m24s
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-11-02 14:56:26 -05:00
bvandeusenandClaude Opus 5.5 9d324ab2d0 feat: Minstrel backs itself up nightly to a folder or an S3 bucket, off until an admin switches it on (#6773)
release / integration (push) Failing after 3m29s
release / web (push) Failing after 15s
release / govulncheck (push) Successful in 16s
release / go (push) Failing after 41s
release / android (push) Successful in 4m7s
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 (push) Canceled after 5m34s
Admin › Backups shows a verdict first (off, not set up, waiting, backed up,
overdue, failing since when), Back up now, Test destination and the latest
runs, with the settings beside them.

- backup.Service: one run at a time, bounded at 30 minutes; the schedule
  ticks every 5 minutes, catches up a slot missed while the server was down,
  and retries a failure after an hour and then after as long as it has been
  failing. Runs are recorded in backup_runs, pruned after 90 days; a run cut
  off by a shutdown is marked failed at the next start.
- A folder destination writes database/minstrel-<UTC stamp>.tar and refuses
  a folder that isn't there. An S3 destination stages the file, hashes it,
  and uploads it under database/ through a throttled reader (4 MiB/s by
  default, 0 for no limit).
- Test destination checks the form as typed and only lists.
- The bucket secret is sealed like every stored credential; backup_settings
  and backup_runs are left out of the backup itself.
- The panel polls only while a run is going, capped at the run limit plus
  five minutes.

The music switch named in the task is left to #6774, so no control ships
that does nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:50:55 -05:00
bvandeusenandClaude Opus 5.5 faf19ef67b test: the file-name test's later backup is later in UTC too
release / govulncheck (push) Successful in 22s
release / web (push) Successful in 1m33s
release / go (push) Successful in 1m49s
release / android (push) Successful in 4m59s
release / integration (push) Successful in 5m27s
release / Build signed APK (push) Successful in 5m1s
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-11-02 14:01:31 -05:00
bvandeusenandClaude Opus 5.5 e11011e977 fix: the backup package checks or explicitly drops every error the linter flags
release / govulncheck (push) Successful in 24s
release / go (push) Failing after 1m42s
release / web (push) Successful in 1m25s
release / android (push) Successful in 4m52s
release / Build signed APK (push) Successful in 5m1s
release / integration (push) Canceled after 5m17s
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
Closing a refused S3 response and a test file, and the fake bucket's
writes, now say they ignore the error on purpose; handler parameters the
tests don't use are named _.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:56:22 -05:00
bvandeusenandClaude Opus 5.5 b6ae205862 feat: Minstrel can put and list objects in a path-style S3 bucket, signed with SigV4 and no SDK (#6771)
release / govulncheck (push) Successful in 14s
release / android (push) Successful in 4m37s
release / Build signed APK (push) Successful in 4m47s
release / integration (push) Failing after 5m3s
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
release / go (push) Failing after 1m7s
release / web (push) Successful in 1m14s
Bucket.Put stores a body under a key, signed over its SHA-256; Bucket.List
pages through ListObjectsV2. A backup only adds and lists, so an
append-only key is enough. Keys are encoded once, exactly as signed, so
music paths with spaces, plus signs and accents reach the bucket intact.
A refusal names S3's code in words ("the secret key is wrong").

The signer is pinned to AWS's published GET Object, PUT Object and
ListObjects examples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:51:02 -05:00
bvandeusenandClaude Opus 5.5 8be69b752a feat: a backup is one uncompressed tar of per-table JSON Lines that stays the same bytes day to day (#6769)
The file is minstrel-<UTC stamp>.tar: manifest.json first, then
tables/<table>.jsonl, rows one per line in primary-key order. The run's
time lives only in the manifest and member headers are fixed, so a
deduplicating off-site copy stores unchanged tables once.

WriteFile writes to a hidden .part file, syncs it and renames it, so a
watched folder never shows half a backup. Read refuses anything that
isn't a whole backup: another format, a newer format version, a cut-off
file, or a member holding fewer rows than the manifest says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:51:02 -05:00
bvandeusenandClaude Opus 5.5 a67897a6cd feat: Minstrel can read its irreplaceable data out as JSON Lines and load it back into an empty instance (#6770)
release / web (push) Successful in 1m17s
release / android (push) Successful in 4m41s
release / Build signed APK (push) Successful in 4m52s
release / govulncheck (push) Successful in 15s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 5m3s
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
internal/backup names every table a backup holds, in parent-first order,
and every table it leaves out with the reason: what the server computes
from the music files, provider caches it fetches again, sign-ins and
one-time links, and logs of past runs. The library's identity rows travel
because every like, play and playlist entry hangs off a track id, and the
sync change feed travels because the apps' first sync reads it.

Sealed credentials are left out of the file, and so are the markers that
say tag and art enrichment are done, so a restored server asks for the
credentials again and redoes that work rather than believing it has it.

Snapshot reads one REPEATABLE READ, read-only transaction, each table in
primary-key order, keys sorted. Restore refuses an instance with accounts
or tracks and a backup from a newer schema, replaces seeded settings,
inserts by column intersection in batches, skips generated columns, and
carries serial sequences on past the restored ids.

Guards: every schema table is backed up or excluded; every backed-up
table comes after what it references and references nothing excluded;
every omitted column exists; a restore gives back the backup byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:43:16 -05:00
bvandeusenandClaude Opus 5.5 a8a2d20307 feat: stored credentials are sealed at rest, so a copy of the database alone reveals none of them (#6767)
release / govulncheck (push) Successful in 28s
release / web (push) Successful in 1m18s
release / go (push) Successful in 1m35s
release / android (push) Successful in 4m45s
release / Build signed APK (push) Successful in 4m57s
release / integration (push) Successful in 5m3s
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
Minstrel keeps credentials it has to read back: indexer, Prowlarr and
download-client keys and passwords, the AcoustID key, cover-art and tag
provider keys, the SMTP password, and each user's ListenBrainz token and
Subsonic password. They were stored in the clear; they are now sealed with
AES-256-GCM under a per-column HKDF key derived from the stream secret, each
value bound to its row.

- internal/secret: Seal/Open/Reveal, and Columns, the one list of sealed
  columns (a backup will leave them out). Install takes the master key at
  startup; SealStored seals any value still in the clear, so an existing
  database is brought over in place on the first boot.
- Every write seals and every read opens: acquire (one read path, getService
  / usableServices), acoustid settings, SMTP, ListenBrainz, Subsonic auth and
  password generation, cover-art and tag provider settings.
- A value that does not open (the stream secret changed) is logged and reads
  as unset, to be entered again in Settings. docs/security.md says so.
- Tests: secret unit tests (wrong owner/purpose/key, malformed, no key), a
  SealStored integration test over all eight columns plus a schema check that
  each listed column exists, and the existing tests now assert sealed storage.
  dbtest installs a test key for integration tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:23:19 -05:00
bvandeusenandClaude Opus 5.5 be642cd53f ci: main builds and bundles its own signed APK, so a merge ships the app without a release (#6465)
release / web (push) Successful in 1m51s
release / go (push) Successful in 2m6s
release / govulncheck (push) Successful in 20s
release / integration (push) Successful in 6m53s
release / android (push) Successful in 5m41s
release / Build signed APK (push) Successful in 5m38s
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
android-release now runs on main as well as dev and tags, and image-release
requires its success on every ref. :latest carries the stable APK built from
main's own commit instead of the newest release's, so a merged app change
reaches stable phones on the next pull. Releases stay as optional bookmarks
that attach the APK for download.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:49:39 -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
1042 changed files with 19552 additions and 7509 deletions
+2 -6
View File
@@ -9,12 +9,8 @@ web/build
# The Android client — built by its own job, never from this context. The APK # 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 # reaches the image through client/, downloaded as a CI artifact, so nothing
# here reads android/ sources. # here reads android/ sources.
# # Excluded so the Gradle project neither bloats the context nor busts the
# This block named `flutter_client/` until 2026-09-10 and lost its PATTERN when # `COPY . .` layer on every Android-only change.
# that tree was deleted, leaving a comment describing an exclusion that was no
# longer happening. android/ never took its place, so 4.1 MB of Gradle project
# has been entering the context and busting the `COPY . .` layer on every
# Android-only change.
android/ android/
# Local `make build` output — an 18 MB binary the image never uses, since the # Local `make build` output — an 18 MB binary the image never uses, since the
+9 -10
View File
@@ -1,12 +1,11 @@
# Minstrel environment variable overrides. # Environment for running the server outside Docker (`go run ./cmd/minstrel`).
# Copy to .env for docker compose, or export before `go run ./cmd/minstrel`. # The values match the Postgres in docker-compose.yml.
# Values shown are sensible defaults for the local docker-compose postgres.
SMARTMUSIC_SERVER_ADDRESS=:4533 MINSTREL_SERVER_ADDRESS=:4533
SMARTMUSIC_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable MINSTREL_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable
SMARTMUSIC_LOG_LEVEL=info MINSTREL_LOG_LEVEL=info
SMARTMUSIC_LOG_FORMAT=text MINSTREL_LOG_FORMAT=text
# Set this when running `go test ./...` without -short to exercise the # Runs the database tests in `go test ./...` (without -short). Use a separate
# migration test against a running postgres. # database: the tests truncate every table.
# MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable # MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable
+149 -249
View File
@@ -2,50 +2,50 @@ name: release
# Builds and pushes the minstrel container image to the Gitea registry. # Builds and pushes the minstrel container image to the Gitea registry.
# #
# push to dev → :dev (freshly-built dev APK bundled) # push to dev → :dev (freshly-built dev APK bundled)
# push to main → :latest + :<sha> (latest-release APK bundled) # push to main → :latest + :<sha> (freshly-built stable APK bundled)
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled) # push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled, and attached to the Release)
# workflow_dispatch → manual trigger (same rules based on the ref) # workflow_dispatch → manual trigger (same rules based on the ref)
# #
# That is the whole tag map, and it is family rule 145 + 147 as written. # That is the whole tag map.
# #
# :<sha> on main is the ROLLBACK UNIT — every production commit addressable # :<sha> on main is the ROLLBACK UNIT — every production commit addressable
# without a release ceremony. It is minted only on main, where rollback is # without a release ceremony. It is minted only on main, where rollback is
# actually worth having: merges are gated (rule 2) so they number in the dozens # actually worth having: merges to main are deliberate, so they number in the
# per year, while on dev they would be one per push, forever, for a channel # dozens per year, while on dev they would be one per push, forever, for a
# whose entire contract is that it moves. # channel whose entire contract is that it moves.
# #
# There are NO :<version> image tags. This repo published :vYYYY.MM.DD.HHMM # There are NO :<version> image tags. Git and the build's own self-reported
# until 2026-09-10 and it was the inverse of the rule on both counts — minting # version answer "which build is this"; a third name for the same thing is
# a version tag nobody pinned while the rollback unit the rule names did not # upkeep for a model we do not run. Only the APK needs version numbers, for
# exist here at all. Git and the build's own self-reported version answer # its update process.
# "which build is this"; a third name for the same thing is upkeep for a model
# we do not run. Operator, 2026-09-10: "only things like the APK need that kind
# of versioning for their update process."
# #
# There is no :main either. :latest tracks main's tip with no gate between them # There is no :main either. :latest tracks main's tip with no gate between
# (rule 147), so a second name for the same image sends readers looking for a # them, so a second name for the same image sends readers looking for a
# distinction that does not exist. # distinction that does not exist.
# #
# The dev channel exists so testing a build does not require shipping one. # The dev channel exists so testing a build does not require shipping one:
# Before it, the only way to get an APK onto a phone was to cut a release, # without it the only way to get an APK onto a phone is to cut a release,
# which made `main` the staging area by default. `:dev` carries its own # which makes `main` the staging area. `:dev` carries its own freshly-built
# freshly-built APK, signed with the SAME key as release builds — a different # APK, signed with the SAME key as release builds — a different key cannot
# key cannot install over the stable app, so anyone crossing channels would # install over the stable app, so anyone crossing channels would have to
# have to uninstall and lose their data. # uninstall and lose their data.
# #
# :dev is published ALONE, with no per-commit tag. A rolling channel is # :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 # 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 # rollback target nobody ever pulls, kept forever. Recovery on dev is to fix
# forward. # forward.
# #
# Note what this repo does NOT need: a cross-repo dispatch to refresh the # No cross-repo dispatch is needed to refresh a channel when its bundled APK
# channel when its bundled APK is rebuilt. That mechanism exists elsewhere in # is rebuilt: Minstrel is a monorepo, so one push builds the APK and the
# the family because the app and the server live in separate repos. Minstrel # image in the same run from the same commit, and the channel cannot go
# is a monorepo — one push builds the APK and the image in the same run from # stale against its own artifact.
# the same commit, so the channel cannot go stale against its own artifact. #
# The requirement is satisfied structurally; copying the mechanism would add # Every branch build carries the app built from its own commit, so a merge to
# a moving part to fix a problem that does not exist here. # main is all it takes to put a new app in front of stable phones: `:latest`
# serves it from /api/client/apk and the in-app update offers it. A release
# is an optional bookmark — a named point in history with the APK attached
# for download — never a step the app has to wait for.
# #
# Release model: the tag IS the artifact's version name with a `v` in front. # 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 # `v2026.09.10.1432` and `2026.09.10.1432` are the same string, derived from
@@ -55,43 +55,30 @@ name: release
# #
# TAGS ARE IMMUTABLE. Never move, retarget or delete a published tag. A # 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 # same-day second release is not a collision — HHMM makes every tag unique
# by construction, so the answer is simply another tag. # 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
# This block used to say the opposite: that the per-day tag was # is a worse failure than an extra row in the tag list.
# "intentionally mutable" and that a same-day re-cut should
# `git push -f origin vYYYY.MM.DD`. That instruction is what the family
# rulebook now forbids outright, and it has incidents behind it — moving a
# same-day tag forward once took a published release down with it. Anyone
# installing from a tag is holding 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 # :latest is updated by every main push AND every tag push, so it always
# reflects the newest blessed image. # reflects the newest blessed image.
# #
# APK pipeline: on tag pushes the android-release job builds + signs the # APK pipeline: on every push — dev, main and tags alike — the
# Android APK and uploads it as a workflow artifact. The image-release # android-release job builds + signs the Android APK and uploads it as a
# job declares `needs: android-release`, so the docker image cannot # workflow artifact. The image-release job declares `needs: android-release`
# start building until the APK is guaranteed-ready — no polling, no # and requires its success, so the docker image cannot start building until
# race, no silent-failure mode. Attaching the APK to the gitea Release is # the APK is guaranteed-ready — no polling, no race, no silent-failure mode,
# its own job (release-assets), behind the test gate below. # and no image that moves a channel without an app inside it. Attaching the
# APK to the gitea Release is its own job (release-assets), tags only, behind
# the test gate below.
# #
# :latest always carries an APK. Because every main push also moves # THE GATE. Every verifying lane lives in this file —
# :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 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.
#
# THE GATE (rule 177, M462 #4984). Every verifying lane lives in this file —
# Go (vet, lint, short tests), the Postgres integration suite, the web app # Go (vet, lint, short tests), the Postgres integration suite, the web app
# (npm audit, svelte-check, vitest), Android (ktlint, detekt, unit tests) and # (npm audit, svelte-check, vitest), Android (ktlint, detekt, unit tests) and
# govulncheck — and every job that publishes something names each of them in # govulncheck — and every job that publishes something names each of them in
# `needs:` and requires `success` from each, by name. Nothing publishes on red. # `needs:` and requires `success` from each, by name. Nothing publishes on red.
# #
# They used to be three separate workflows (test-go, test-web, android) on the # One workflow, because separate workflows cannot see each other's verdict:
# same push trigger as this one. Separate workflows cannot see each other's # :dev would mean "it built", never "it passed", and a red test run and a
# verdict, so :dev meant "it built", never "it passed": a red test run and a
# fresh :dev could carry the same timestamp. One graph is the only place the # fresh :dev could carry the same timestamp. One graph is the only place the
# edge can be written. # edge can be written.
# #
@@ -125,10 +112,9 @@ on:
type: boolean type: boolean
default: false default: false
# A rapid re-push to main should supersede the in-flight build — the # A rapid re-push to main supersedes the in-flight build: the later commit
# operator explicitly wants the later commit to win. Tags no longer enter # wins. Tags are immutable and unique, so no tag build can be superseded by
# into this: they are immutable and unique, so no tag build can ever be # another run on the same ref.
# superseded by another run on the same ref.
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
@@ -173,14 +159,14 @@ jobs:
# Full `go test -race` against an ephemeral Postgres. # Full `go test -race` against an ephemeral Postgres.
# #
# DB wiring follows the act_runner shared-daemon pattern: the runner's Docker # 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 # daemon also runs a dev compose stack, so service containers get NO
# get NO published ports (collision) and no service-name DNS. We discover the # published ports (collision) and no service-name DNS. We discover the
# service container through the mounted docker socket and reach it by bridge # 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 # 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). # Postgres would truncate it.
# #
# The key stays `integration` with no `name:` (rule 80): act_runner derives # The key stays `integration` with no `name:`: act_runner derives the
# the service container's name from the job's display name. # service container's name from the job's display name.
# #
# `web/build/` has a committed placeholder index.html so go:embed succeeds # `web/build/` has a committed placeholder index.html so go:embed succeeds
# without the SPA being built first. # without the SPA being built first.
@@ -195,7 +181,7 @@ jobs:
POSTGRES_USER: minstrel POSTGRES_USER: minstrel
POSTGRES_PASSWORD: minstrel POSTGRES_PASSWORD: minstrel
POSTGRES_DB: minstrel_test POSTGRES_DB: minstrel_test
# No `ports:` — the runner shares the operator's dev compose # No `ports:` — the runner shares the dev compose
# Docker daemon; publishing a fixed host port collides. # Docker daemon; publishing a fixed host port collides.
steps: steps:
@@ -209,13 +195,13 @@ jobs:
# mounted docker socket. act_runner attaches the job # mounted docker socket. act_runner attaches the job
# container and its service container(s) to a shared per-job # container and its service container(s) to a shared per-job
# network, so scope discovery to a postgres that sits on a # network, so scope discovery to a postgres that sits on a
# network THIS job container is also on. The old # network THIS job container is also on. A name filter alone
# `--filter name=integration` matched EVERY concurrent # would match EVERY concurrent integration run's postgres (a dev
# integration run's postgres (a dev push + the main-merge run # push and a main-merge run overlapping give 2 candidates and a
# overlap → 2 candidates → false "expected exactly 1" abort). # false "expected exactly 1" abort). The dev compose
# The operator's dev compose `minstrel-postgres-*` is never on # `minstrel-postgres-*` is never on this job's network; skip it
# this job's network; skip it explicitly as belt-and-suspenders # explicitly as belt-and-suspenders (a wrong target would
# (a wrong target would truncate real data). # truncate real data).
SELF=$(cat /etc/hostname) SELF=$(cat /etc/hostname)
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF") SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
test -n "$SELF_NETS" test -n "$SELF_NETS"
@@ -236,11 +222,11 @@ jobs:
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable" 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 # Wait for Postgres to accept connections. Asked of the service
# container itself: the run: shell is dash (rule 81), where the # container itself: the run: shell is dash, where a `/dev/tcp`
# old `/dev/tcp` probe never connects and the loop silently # probe never connects and the loop would silently burn its full
# burned its full two minutes on every run. # two minutes.
# #
# Over TCP (-h 127.0.0.1), not the unix socket (#5426). On first # Over TCP (-h 127.0.0.1), not the unix socket. On first
# boot the image's entrypoint runs initdb under a temporary server # boot the image's entrypoint runs initdb under a temporary server
# that listens on the socket only, then stops it and starts the # that listens on the socket only, then stops it and starts the
# real one. A socket probe can catch the temporary server, and the # real one. A socket probe can catch the temporary server, and the
@@ -267,7 +253,7 @@ jobs:
# synchronous_commit is user-context, so pg_reload_conf() picks # synchronous_commit is user-context, so pg_reload_conf() picks
# all three up with no restart. # all three up with no restart.
# Non-fatal: a failure degrades to "slower", never red CI. But # Non-fatal: a failure degrades to "slower", never red CI. But
# slower is ~15x on internal/api (#5426), so retry a few times and # slower is ~15x on internal/api, so retry a few times and
# print what took effect. # print what took effect.
relaxed="" relaxed=""
for i in 1 2 3 4 5; do for i in 1 2 3 4 5; do
@@ -291,10 +277,10 @@ jobs:
# every integration package TRUNCATEs the one shared test DB; # every integration package TRUNCATEs the one shared test DB;
# concurrent package binaries → TRUNCATE deadlocks. Serialize # concurrent package binaries → TRUNCATE deadlocks. Serialize
# package execution (the documented local invocation too). # package execution (the documented local invocation too).
# -timeout 20m: internal/api alone takes ~6.5 min under -race on # -timeout 20m: internal/api alone takes several minutes under
# an idle runner, and a dev and a main run sharing the runner # -race on an idle runner, and a dev and a main run sharing the
# pushed it past go test's default 10m (run 8368, 600.016s, the # runner can push it past go test's default 10m with nothing
# running test 2s old — nothing hung). # hung.
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
go test -p 1 -race -timeout 20m ./... go test -p 1 -race -timeout 20m ./...
@@ -314,11 +300,8 @@ jobs:
- name: Install deps - name: Install deps
run: npm ci run: npm ci
# The whole tree, build and test tooling included. Until #5021 this # The whole tree, build and test tooling included: the tooling that
# audited only what ships to browsers (`--omit=dev`), because vite, # builds the shipped bundle is held to the same bar as the bundle.
# vitest, tailwind and kit carried advisories that needed major
# upgrades. Those upgrades landed and the full tree audits clean, so
# the tooling that builds the shipped bundle is held to the same bar.
- name: npm audit (all dependencies) - name: npm audit (all dependencies)
run: npm audit --audit-level=moderate run: npm audit --audit-level=moderate
@@ -329,8 +312,8 @@ jobs:
run: npm test run: npm test
android: android:
# Using flutter-ci runner label because it's the only proven-working # The flutter-ci runner label is the only proven-working label with
# label with docker that can pull our container.image. # docker that can pull our container.image.
runs-on: flutter-ci runs-on: flutter-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-android:36 image: git.fabledsword.com/bvandeusen/ci-android:36
@@ -376,11 +359,10 @@ jobs:
- name: Unit tests - name: Unit tests
run: ./gradlew testDebugUnitTest run: ./gradlew testDebugUnitTest
# No debug APK is built or uploaded here. Main used to upload a # No debug APK is built or uploaded here: a debug build is signed by a
# debug-signed app-debug.apk: a build signed by a key regenerated in # key regenerated in every container, which no install can update.
# every container, which no install can update (family idea #5103, # Phones get builds from android-release, signed with the one release
# practice 2). Phones get builds from android-release, signed with # key, on dev and on tags.
# the one release key, on dev and on tags.
# Known vulnerabilities in the Go code and the standard library it is built # 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 # with. Runs in the SAME image the Dockerfile's builder stage uses, so the
@@ -388,7 +370,7 @@ jobs:
# the ci-go image carries its own Go and would be checking a different # 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. # toolchain. Keep this image and the Dockerfile's builder in step.
# #
# govulncheck is fetched at CI time, unpinned (rule 154): the vulnerability # govulncheck is fetched at CI time, unpinned: the vulnerability
# database and the tool that reads it should both be current. # database and the tool that reads it should both be current.
govulncheck: govulncheck:
runs-on: go-ci runs-on: go-ci
@@ -397,7 +379,7 @@ jobs:
steps: steps:
# Plain git, not actions/checkout: that action runs on node, which the # Plain git, not actions/checkout: that action runs on node, which the
# golang image does not carry. This step is dash (rule 81). # golang image does not carry. This step is dash.
- name: Checkout - name: Checkout
env: env:
TOKEN: ${{ github.token }} TOKEN: ${{ github.token }}
@@ -418,11 +400,14 @@ jobs:
# ------------------------------------------------------------ artifacts -- # ------------------------------------------------------------ artifacts --
android-release: android-release:
name: Build signed APK (releases and dev) name: Build signed APK
# Also builds on `dev`, which is what makes a test channel possible at # Builds on every ref, so each channel's image carries the app from its
# all. Without it the only way to get a build onto a phone was to cut a # own commit: `dev` for the test channel, `main` for stable phones, and a
# release, which quietly turns `main` into the staging area. # tag for the copy attached to its Release.
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev' if: >-
startsWith(github.ref, 'refs/tags/v')
|| github.ref == 'refs/heads/dev'
|| github.ref == 'refs/heads/main'
runs-on: flutter-ci runs-on: flutter-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-android:36 image: git.fabledsword.com/bvandeusen/ci-android:36
@@ -453,7 +438,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
# Full history. The version name now reads only the tip commit's # Full history. The version name reads only the tip commit's
# timestamp, so a shallow clone would technically serve — but this # timestamp, so a shallow clone would technically serve — but this
# job derives a value that ships to devices, and a shallow checkout # job derives a value that ships to devices, and a shallow checkout
# changes what git-derived values resolve to WITHOUT failing. The # changes what git-derived values resolve to WITHOUT failing. The
@@ -470,7 +455,7 @@ jobs:
# The derivation lives in ci/version.sh, not here, so it can be # 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 # executed by a test on every push. Anything inline in this file is
# unverifiable until a release is already running. # unverifiable until a release is already running.
out="$(ci/version.sh HEAD)" out="$(ci/version.sh --apk HEAD)"
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT" printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
# The channel is a property of the LANE, not of the commit, which is # The channel is a property of the LANE, not of the commit, which is
@@ -487,9 +472,9 @@ jobs:
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea # 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 — # 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 built an # but that is the final step, so a tag pushed without a release would
# APK for several minutes first and only then discovered it had nowhere to # build an APK for several minutes first and only then find it has
# put it. Same check, seconds in instead of minutes. # nowhere to put it. Same check, seconds in instead of minutes.
# #
# Releases are normally created through the API (which creates the tag and # 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 # the release together, so this passes). A bare `git push origin vX` is the
@@ -549,16 +534,15 @@ jobs:
# secret) reaches no installed phone. The certificate's digest is # secret) reaches no installed phone. The certificate's digest is
# pinned below; it is public, not a secret. Gradle signs with the # pinned below; it is public, not a secret. Gradle signs with the
# release key or leaves the APK unsigned, and an unsigned build fails # release key or leaves the APK unsigned, and an unsigned build fails
# here too, as there is no app-release.apk to verify (family idea # here too, as there is no app-release.apk to verify. apksigner, not
# #5103, practice 3). apksigner, not keytool: keytool prints nothing # keytool: keytool prints nothing for a v2-only APK.
# for a v2-only APK.
# #
# Rotating the key on purpose means every install must be removed and # Rotating the key on purpose means every install must be removed and
# reinstalled; change the digest here in the same commit. # reinstalled; change the digest here in the same commit.
- name: The APK carries the release key - name: The APK carries the release key
shell: bash shell: bash
env: env:
# CN=Minstrel, O=FabledSword. Read from run 8446 (#5116). # CN=Minstrel, O=FabledSword.
RELEASE_CERT_SHA256: 43d183307bc46b821789d90444a960b137f78f2166ff431efb2406d0fceaf612 RELEASE_CERT_SHA256: 43d183307bc46b821789d90444a960b137f78f2166ff431efb2406d0fceaf612
run: | run: |
set -euo pipefail set -euo pipefail
@@ -580,11 +564,10 @@ jobs:
fi fi
- name: Upload APK as workflow artifact - name: Upload APK as workflow artifact
# Stock action (snippet #2271) — never @v3, which uploads something Gitea # Stock action — never @v3, which uploads something Gitea will never
# will never serve back. This is the producing half of a pair: # serve back. This is the producing half of a pair: image-release
# image-release downloads `minstrel-apk` below. Any upload v4+ pairs with # downloads `minstrel-apk` below. Any upload v4+ pairs with any
# any download v4+ on this forge (every combination tested 2026-09-10, # download v4+ on this forge, so the two pins need not move together.
# Scribe spike #3843), so the two pins need not move together.
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: minstrel-apk name: minstrel-apk
@@ -621,9 +604,9 @@ jobs:
path: release-apk/ path: release-apk/
- name: Attach APK to gitea Release - name: Attach APK to gitea Release
# Tag releases only. A dev build has no Release to hang assets on and # Tag releases only. A branch build has no Release to hang assets on
# does not need one — the :dev image bundles the APK, and the server # and does not need one — its image bundles the APK, and the server
# serves it from /api/client/apk like any other. # serves it from /api/client/apk.
shell: bash shell: bash
env: env:
CI_TOKEN: ${{ secrets.CI_TOKEN }} CI_TOKEN: ${{ secrets.CI_TOKEN }}
@@ -687,10 +670,9 @@ jobs:
image-release: image-release:
name: Build + push container image name: Build + push container image
# Every lane must have SUCCEEDED, each named here (rule 177). Then the # Every lane must have SUCCEEDED, each named here, and so must the APK
# APK: tag and dev pushes build one and it must have succeeded; main # build: every ref bundles the APK built from its own commit, so an
# pushes skip android-release and bundle the latest release's APK # image without one would move a channel with no app for phones to take.
# instead, so for main alone a skipped android-release is expected.
needs: [go, integration, web, android, govulncheck, android-release] needs: [go, integration, web, android, govulncheck, android-release]
if: >- if: >-
${{ ${{
@@ -700,8 +682,7 @@ jobs:
&& needs.web.result == 'success' && needs.web.result == 'success'
&& needs.android.result == 'success' && needs.android.result == 'success'
&& needs.govulncheck.result == 'success' && needs.govulncheck.result == 'success'
&& (needs.android-release.result == 'success' && needs.android-release.result == 'success' }}
|| (needs.android-release.result == 'skipped' && github.ref == 'refs/heads/main')) }}
runs-on: go-ci runs-on: go-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26 image: git.fabledsword.com/bvandeusen/ci-go:1.26
@@ -713,15 +694,13 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
# Full history, and rule 149 names this specifically: any job that # Full history: any job that DERIVES the version name needs it,
# DERIVES the version name needs it, because a shallow clone changes # because a shallow clone changes what git-derived values resolve to
# what git-derived values resolve to WITHOUT failing — a too-low # WITHOUT failing — a too-low value, silently, with every lane green.
# value, silently, with every lane green.
# #
# This job was depth-1 while it took the version from GITHUB_REF. It # This job runs ci/version.sh itself: with no :<version> image tags,
# now runs ci/version.sh itself, because with :<version> image tags # the server's self-reported version is the only thing that says which
# gone the server's self-reported version is the only thing that says # build an image is.
# which build an image is.
fetch-depth: 0 fetch-depth: 0
- name: Detect buildable project - name: Detect buildable project
@@ -743,21 +722,16 @@ jobs:
set -euo pipefail set -euo pipefail
# THE VERSION, and it is derived the same way on every ref — the # THE VERSION, and it is derived the same way on every ref — the
# branch decides the CHANNEL, never the version (family rule 149). # branch decides the CHANNEL, never the version.
# #
# This used to be three different things: the literal string "main" # A per-ref string ("main", "dev", the tag name) would not order, and
# on main, "dev" on dev, and the tag name on a tag. None of them # "dev" would be the same string forever — two dev images eight weeks
# ordered, and the first two were the same string forever — two dev # apart indistinguishable in the UI. With no version image tags, this
# images eight weeks apart were indistinguishable in the UI. That # is how an admin tells which build a container is running.
# mattered little while :vYYYY.MM.DD.HHMM existed to identify a
# build; with version image tags gone, this IS how an operator tells
# which build a container is running.
# #
# `sed -n s///p` rather than `grep`: it exits 0 when nothing matches, # `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 # so the empty check below is actually reachable. A grep here would
# kill the step at the assignment under the runner's pipefail — the # kill the step at the assignment under the runner's pipefail.
# exact bug that took down the first main build after the version
# rework.
VERSION="$(ci/version.sh HEAD | sed -n 's/^name=//p')" VERSION="$(ci/version.sh HEAD | sed -n 's/^name=//p')"
if [ -z "${VERSION}" ]; then if [ -z "${VERSION}" ]; then
echo "::error::could not derive a build version from ci/version.sh" echo "::error::could not derive a build version from ci/version.sh"
@@ -770,13 +744,14 @@ jobs:
# The tag build exists to produce the signed APK and attach it to # 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 # the release; the image it rebuilds is the SAME SOURCE as the main
# build minutes earlier, differing only in which APK is baked in. # build minutes earlier, differing only in which APK is baked in.
# Rule 145 is explicit about that case: when the same source is # When the same source is rebuilt with different contents,
# rebuilt with different contents, publish the moving channel tag # publish the moving channel tag and never a commit-addressable
# and never a commit-addressable one. # one.
# #
# :latest must move here rather than waiting for the next main # :latest moves here so the server hands out the same APK build
# push, or the channel would carry the PREVIOUS release's APK # that is attached to the Release: a phone that installed from the
# indefinitely — a channel that cannot refresh itself (rule 146). # Release page and one that updated in-app hold one build, with one
# ordering key. Cut a release on main's tip, as :latest follows it.
CHANNEL=stable CHANNEL=stable
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT" echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK" echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
@@ -790,10 +765,9 @@ jobs:
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT" echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
echo "::notice::Dev-branch build ${VERSION}: :dev" echo "::notice::Dev-branch build ${VERSION}: :dev"
else else
# The production line: :latest tracks main's tip (rule 147) and # The production line: :latest tracks main's tip and :<sha> is
# :<sha> is the rollback unit (rule 145). Full 40-char SHA, matching # the rollback unit. Full 40-char SHA, so a rollback target is
# the family's other repos, so a rollback target is addressable # addressable straight from the commit anyone is reading.
# straight from the commit anyone is reading.
CHANNEL=stable CHANNEL=stable
echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT" echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}" echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}"
@@ -810,25 +784,19 @@ jobs:
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
- name: Download signed APK artifact - name: Download signed APK artifact
# Tag and dev pushes — android-release just produced this. Only `main` # android-release just produced this, on every ref.
# takes the "Bundle latest release APK" path below, because it is the if: steps.guard.outputs.ready == 'true'
# 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 # 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 # the same reason as the upload (gitea/runner 3.x edits the GHES refusal
# out of the bundle; snippet #2271). v8 runs on node24, which every # out of the bundle). v8 runs on node24, which every CI-runner image
# CI-runner image carries — the runner uses the image's own node. # carries — the runner uses the image's own node.
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
name: minstrel-apk name: minstrel-apk
path: client/ path: client/
- name: Stage bundled APK + version sidecar - name: Stage bundled APK + version sidecar
if: >- if: steps.guard.outputs.ready == 'true'
steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
shell: bash shell: bash
env: env:
# All three pulled from android-release's outputs so the sidecar the # All three pulled from android-release's outputs so the sidecar the
@@ -849,65 +817,6 @@ jobs:
cat client/minstrel.apk.version cat client/minstrel.apk.version
ls -lh client/ 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
# 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 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 }}
run: |
set -eu
REPO="${GITHUB_REPOSITORY}"
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
if [ -z "${REL_JSON}" ]; then
echo "::notice::no published release — image ships without bundled APK"; exit 0
fi
# `|| 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
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
# Take the version the release RECORDED rather than recomputing it.
# This used to re-derive the name from the tagged commit, which meant
# the formula lived in two files that had to be kept in step, and it
# could only ever recover the name — the ordering key is build-time
# minutes and does not exist anywhere 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 - name: Build and push
if: steps.guard.outputs.ready == 'true' if: steps.guard.outputs.ready == 'true'
# --pull: the Dockerfile's base images are floating tags (golang:1.26, # --pull: the Dockerfile's base images are floating tags (golang:1.26,
@@ -924,18 +833,15 @@ jobs:
# Verifies a tag release actually ended up complete, and names the specific # Verifies a tag release actually ended up complete, and names the specific
# thing that's missing if not. # thing that's missing if not.
# #
# Added 2026-08-07 after v2026.08.07 was re-cut. The android-release job never # A release can be incomplete while looking fine. If android-release never
# started — no log was written at all — so all eight of its steps reported # starts (a runner failing to launch), its steps all report `failure` with
# `failure` with none executed and image-release showed `skipped`. The run was # none executed and image-release shows `skipped`. The run is red, but the
# red, but the *release page rendered fine*, and `main`'s own push build had # release page renders and main's own push build has already moved
# already moved `:latest`, so the code was deployable and nothing looked # `:latest`, so a release missing its APK and its image is easy to skim
# obviously wrong. The release was simply missing its APK and its image, # past.
# which is easy to skim past.
# #
# This job cannot prevent that (the cause was a runner failing to launch, not # This job cannot prevent that; it turns an incomplete release into an
# anything in this file). What it does is turn an incomplete release into an # explicit, named error instead of a row of mystery step failures.
# explicit, named error instead of eight mystery step failures — so the
# consequence is legible without having to infer it.
# #
# `if: always()` is the whole point: it has to report precisely when the jobs # `if: always()` is the whole point: it has to report precisely when the jobs
# above did NOT succeed. # above did NOT succeed.
@@ -977,22 +883,16 @@ jobs:
echo "::notice::APK attached: ${APK}" echo "::notice::APK attached: ${APK}"
# The other half. Checking only the APK would report success on a release # The other half. Checking only the APK would report success on a release
# whose image push failed — which is precisely the second thing that was # whose image push failed. `always()` on this job means it runs even when
# missing when v2026.08.07 had to be re-cut. `always()` on this job means # image-release failed, so without this the guard would cheerfully verify
# it runs even when image-release failed, so without this the guard would # an incomplete release.
# cheerfully verify an incomplete release.
# #
# This asserted `:${TAG}` — the :vYYYY.MM.DD.HHMM image — until # The image checked is the :<sha> rollback target, which main's own build
# 2026-09-10. Version image tags are no longer published (rule 145), so # published for this same commit before the tag was cut (there are no
# that assertion would now fail every release for a tag nothing mints. # version image tags). It catches an image push that silently did not
# The rollback target it was really protecting is the :<sha> image, which # happen, and it proves the ORDERING — a tag cut on a commit whose main
# main's own build published for this same commit before the tag was cut. # build never completed has no rollback target, and that is worth failing
# # on rather than discovering during an incident.
# Checking it here earns its keep twice over: it still catches an image
# push that silently did not happen, and it additionally 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 - name: Rollback image must exist for the tagged commit
shell: bash shell: bash
run: | run: |
+6 -9
View File
@@ -12,13 +12,11 @@
# Test binary, built with `go test -c` # Test binary, built with `go test -c`
*.test *.test
# `make build` output. bin/minstrel was tracked until 2026-09-10 — an 18 MB # `make build` output — an 18 MB binary that is never committed.
# binary committed by accident, last refreshed by a commit about web test
# mocks, and re-dirtied by every local build since.
bin/ bin/
# Bundled Android APK + version sidecar (#397). Populated by CI for # Bundled Android APK + version sidecar. Populated by CI on every image
# tag releases; never committed. README in client/ explains the flow. # build; never committed. README in client/ explains the flow.
client/minstrel.apk client/minstrel.apk
client/minstrel.apk.version client/minstrel.apk.version
@@ -44,10 +42,9 @@ go.work.sum
docs/superpowers/ docs/superpowers/
# Per-machine Claude Code settings + remember-skill memory artifacts + # Per-machine Claude Code settings + remember-skill memory artifacts +
# project-level Claude/AI instruction files. All operator-scoped; the # project-level Claude/AI instruction files. All per-machine;
# canonical project memory lives under ~/.claude/projects/ outside the # CLAUDE.md/AGENTS.md/GEMINI.md are tool-specific working notes, not
# repo. CLAUDE.md/AGENTS.md/GEMINI.md stay on the operator's machine # project artifacts.
# only — they're tool-specific working notes, not project artifacts.
.claude/ .claude/
.remember/ .remember/
CLAUDE.md CLAUDE.md
+12 -11
View File
@@ -19,11 +19,10 @@ ENV CGO_ENABLED=0
# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back # (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back
# to "dev"/"local". Both are surfaced at /healthz. # to "dev"/"local". Both are surfaced at /healthz.
# #
# These are two values on purpose (family rule 149): the same commit built on # These are two values on purpose: the same commit built on dev and on main
# dev and on main reports the same NAME and differs only in CHANNEL. Folding # reports the same NAME and differs only in CHANNEL. Folding the channel
# the channel into the version string is what the rule forbids — the version # into the version string would make two dev images eight weeks apart
# used to BE the channel word here ("main"/"dev"), which meant two dev images # indistinguishable.
# eight weeks apart were indistinguishable.
ARG MINSTREL_VERSION=dev ARG MINSTREL_VERSION=dev
ARG MINSTREL_CHANNEL=local ARG MINSTREL_CHANNEL=local
RUN go build -trimpath \ RUN go build -trimpath \
@@ -36,8 +35,8 @@ FROM debian:bookworm-slim
# ffmpeg: duration probes and the exact-tier audio hash (a SHA-256 of the # ffmpeg: duration probes and the exact-tier audio hash (a SHA-256 of the
# encoded audio packets, so no decode). libchromaprint-tools: fpcalc, the # encoded audio packets, so no decode). libchromaprint-tools: fpcalc, the
# acoustic fingerprint that tells the same recording at two bitrates apart # acoustic fingerprint that tells the same recording at two bitrates apart
# from two different recordings (M400). Both are baked in at build time so a # from two different recordings. Both are baked in at build time so a
# deployed instance never fetches either (rule 164); fpcalc is shelled out # deployed instance never fetches either; fpcalc is shelled out
# rather than bound because CGO_ENABLED=0 above rules out cgo. # rather than bound because CGO_ENABLED=0 above rules out cgo.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg libchromaprint-tools \ && apt-get install -y --no-install-recommends ca-certificates ffmpeg libchromaprint-tools \
@@ -57,10 +56,12 @@ COPY config.example.yaml /etc/smartmusic/config.yaml
RUN mkdir -p /app/data /app/client && chown -R minstrel:minstrel /app RUN mkdir -p /app/data /app/client && chown -R minstrel:minstrel /app
WORKDIR /app WORKDIR /app
# In-app update channel (#397). client/ in the build context holds # In-app update channel. client/ in the build context holds
# minstrel.apk + minstrel.apk.version (populated by release.yml on tag # minstrel.apk + minstrel.apk.version, which release.yml fills on every
# pushes; .gitkeep + README otherwise). Endpoints return 404 when the # build (dev and tag pushes bundle their own APK, main bundles the latest
# APK files aren't present, so non-tag images degrade gracefully. # 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/ COPY --chown=minstrel:minstrel client/ /app/client/
USER minstrel USER minstrel
+4 -7
View File
@@ -13,11 +13,8 @@ generate:
generate-go: generate-go:
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v$(SQLC_VERSION) generate 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. # 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.
# 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.
# #
# The diff is printed BEFORE the exit-code check on purpose: when this fails, # 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. # the log then contains sqlc's exact expected output, which is what you commit.
@@ -36,8 +33,8 @@ test-short:
go test -short -race ./... go test -short -race ./...
# Full suite incl. integration tests, against the dedicated minstrel_test # Full suite incl. integration tests, against the dedicated minstrel_test
# DB so a run never truncates the dev `minstrel` DB (Fable #339). Ensures # DB so a run never truncates the dev `minstrel` DB. Ensures the test DB
# the test DB exists (idempotent — createdb errors if present, ignored). # exists (createdb fails harmlessly if it is already there).
test-integration: test-integration:
docker compose up -d postgres docker compose up -d postgres
-docker compose exec -T postgres createdb -U minstrel minstrel_test -docker compose exec -T postgres createdb -U minstrel minstrel_test
+76 -118
View File
@@ -1,62 +1,26 @@
# 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 a wanted list it fetches for you — 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> <a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
## Highlights ## Quick start
- **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.
- **Finds, downloads and files music itself.** A wanted list — requests, followed artists, albums gone missing — searched through indexers *you* configure and downloaded by a client *you* run. Each download is checked against MusicBrainz, tagged, and filed into your library, and a better copy replaces a poor one. Optional, and off until you add a search service and a download client.
- **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).
## Scope and responsible use
**Minstrel is a library server.** It indexes files on disk you point it at, and streams them to your own clients.
Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB engine of its own, and **no** DRM circumvention of any kind.
**Acquisition is optional and inert until you configure it.** You add the search service (a Prowlarr instance or a Torznab indexer) and the download client (qBittorrent) you already run; Minstrel then searches those for the albums on its wanted list, hands the release it picks to your client, and checks and files what arrives. It configures no sources on your behalf: any it uses are ones you added yourself.
**What you put in your library, and what sources you configure, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
Minstrel is not affiliated with or endorsed by ListenBrainz, MusicBrainz, Prowlarr, qBittorrent, or Subsonic.
## Quickstart
```yaml ```yaml
# compose.yaml # compose.yaml
services: services:
minstrel: minstrel:
image: git.fabledsword.com/bvandeusen/minstrel:latest image: git.fabledsword.com/bvandeusen/minstrel:latest
# Reachable from your LAN at http://<host>:4533. If this host faces the
# internet, bind it to 127.0.0.1 and put an HTTPS proxy in front instead:
# see docs/hosting.md.
ports: ['4533:4533'] ports: ['4533:4533']
volumes: volumes:
# Your music library. Point ./music at wherever your audio files - ./music:/music # your library; must be writable by uid 1000
# live. Writable, because Minstrel files the albums it downloads into - minstrel-data:/app/data # art and caches
# it (as Artist/Album (Year)/..., tagged with MusicBrainz ids) and
# deletes a file when an admin asks it to (for example, quarantine's
# "Delete file"). Files already there are never moved, renamed or
# retagged. The container runs as uid 1000, so that user needs write
# access to the folders. Mount it :ro to forbid writes: those actions
# then refuse, say why, and change nothing.
- ./music:/music
# 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
environment: environment:
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
# Colon-separated library roots to scan; must match the container
# path of the music mount above (/music here).
MINSTREL_LIBRARY_SCAN_PATHS: /music MINSTREL_LIBRARY_SCAN_PATHS: /music
depends_on: [db] depends_on: [db]
@@ -66,9 +30,7 @@ services:
POSTGRES_USER: minstrel POSTGRES_USER: minstrel
POSTGRES_PASSWORD: minstrel POSTGRES_PASSWORD: minstrel
POSTGRES_DB: minstrel POSTGRES_DB: minstrel
# Postgres data dir — users, likes, play history, sessions, settings. volumes: [pgdata:/var/lib/postgresql/data] # users, likes, history: back this up
# The one volume you must never lose; back it up with pg_dump.
volumes: [pgdata:/var/lib/postgresql/data]
volumes: volumes:
minstrel-data: minstrel-data:
@@ -77,105 +39,101 @@ volumes:
```bash ```bash
docker compose up -d 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 on a network you trust; a server reachable from the internet belongs behind HTTPS, which [docs/hosting.md](docs/hosting.md) walks through. ### First run
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance becomes the administrator, and creating it asks for the **setup token** the server prints in its log (`docker compose logs minstrel | grep setup_token`), so nobody else can claim a newly exposed server first. Later users join through the same form or an invite token (step 5). 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.
<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> 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**.
**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.
<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> <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) Set up acquisition and connections.** In admin **Acquisition** (`/admin/acquisition`), add a search service and a download client so Minstrel can fetch what's wanted; each person adds their own ListenBrainz token (scrobbling + similarity radio) under **Settings → Connections** (`/settings/connections`). 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="Search services, download clients and searching in admin Acquisition"></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).
Hosting Minstrel on the internet: see [docs/hosting.md](docs/hosting.md). What Minstrel does to protect accounts, and why: [docs/security.md](docs/security.md).
## Configuration ## 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. | Variable | What it does |
- `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`). | `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 (per-user scrobble + similarity tokens) and acquisition (search services and download clients) are configured in the 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` — production. Tracks `main`'s tip and moves on every `main` push and every release. What most operators should run. ```bash
- `:<commit-sha>` — the rollback unit. Every `main` push publishes one, so any production commit is addressable without a release ceremony. Immutable: a given SHA tag is never re-pushed. Pin one if you need a deployment that cannot change under you, and use it to roll back. docker compose pull && docker compose up -d
- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this to try something before it ships. It moves constantly, has no per-commit tag, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback. ```
That is the whole tag map. **There are no version-numbered image tags**, and no `:main`. Git and the build's own self-reported version answer "which build is this" — the Settings page shows it, and so does `/healthz`. Release *tags* in git are still `vYYYY.MM.DD.HHMM`; they name a changelog entry and the APK attached to it, not an image. Database migrations run at startup. Image tags:
Rolling back to `:<commit-sha>` pins the **server code** at that commit — not the server-and-app pair. The Android APK is baked in at image build time, so a SHA image carries whichever app was current when that commit was built, which may be older than what `:latest` bundles now. If both halves matter, check what the image bundles rather than trusting the tag's name. - `: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.
Every `:latest`, `:<commit-sha>` and `:dev` bundles a signed Android APK, so the in-app update channel is always live. All are signed with the same key, so a phone can move between the stable and dev channels without uninstalling — point it at a `:dev` server and the in-app updater offers that channel's build. 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.
The app reports which channel it is on alongside its version, and decides whether an update is available using the build's ordering key rather than its displayed name — the same value Android installs by, so an offer it makes is one the platform will accept. **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.
Database migrations run automatically at startup; rollbacks require restoring a Postgres dump. ## Features
Releases up to 2026-09-10 also published a `:vYYYY.MM.DD[.HHMM]` image tag. Those images still exist and still work — they are simply not extended. **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.
## Specs **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.
Authoritative scope lives under [`docs/`](./docs): **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.
- [Server spec](./docs/smart-music-server-spec.md) — current implementation focus. **Clients and accounts**
- [Client spec](./docs/smart-music-client-spec.md) — Flutter companion app. - 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 ## 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`. How accounts and sessions are protected: [docs/security.md](docs/security.md).
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 (the `integration` lane in
`.gitea/workflows/release.yml`, which also gates every image publish).
### 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 the signed APK, attaches it to the release, and refreshes `:latest` around it.
Task and milestone tracking: Fable (`Minstrel` project, id 12).
## License ## License
See [LICENSE](./LICENSE). MIT. See [LICENSE](./LICENSE).
+9 -14
View File
@@ -1,9 +1,9 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
// kotlin-android NOT applied: AGP 9 enables built-in Kotlin by default, // 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 // and KSP 2.3.x supports it. serialization + compose-compiler are
// + compose-compiler are language-level Kotlin compiler plugins and // language-level Kotlin compiler plugins and still need explicit
// still need explicit application. // application.
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
@@ -31,14 +31,9 @@ android {
// //
// versionCode is minutes since 2020-01-01 at BUILD time. It is the // 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 // value the platform decides installs by, so it must be monotonic by
// construction. // construction. Not a commit count: that runs ahead on `dev`, so a dev
// // build would outrank the `main` release meant to replace it and
// This comment used to say versionCode was a commit count and that it // Android would refuse the install as a downgrade.
// was "monotonic forever". It was neither — a commit count runs ahead
// on `dev`, so a dev build outranked the `main` release meant to
// replace it and Android refused the install as a downgrade. Worth
// knowing the claim was here, stated as a reassurance, while the bug
// it denied was live.
val versionNameOverride = val versionNameOverride =
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() } (project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val versionCodeOverride = val versionCodeOverride =
@@ -73,9 +68,9 @@ android {
"proguard-rules.pro", "proguard-rules.pro",
) )
// Signed with the release key or not at all. Falling back to the // Signed with the release key or not at all. Falling back to the
// debug key made a missing secret into a published APK that no // debug key would turn a missing secret into a published APK that no
// install could ever update (family idea #5103, practice 2). An // install could ever update. An unsigned build installs nowhere, so
// unsigned build installs nowhere, so the gap shows at once. // the gap shows at once.
signingConfig = signingConfig =
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) { if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
null null
+3 -6
View File
@@ -8,7 +8,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Notifications when the app is closed (M489 #5347): the delivery <!-- Notifications when the app is closed: the delivery
service, and starting it again after a reboot or an update. --> 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.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -84,11 +84,8 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- The FileProvider that used to live here existed solely to expose the <!-- No FileProvider: the self-update streams the downloaded APK into a
downloaded update APK as a content:// URI for the old ACTION_VIEW PackageInstaller session, so nothing needs a content:// URI. -->
install intent. A PackageInstaller session takes a stream instead,
so both the provider and res/xml/file_paths.xml are gone — nothing
else in the app ever used that authority. -->
<!-- On-demand WorkManager initialization: MinstrelApplication <!-- On-demand WorkManager initialization: MinstrelApplication
implements Configuration.Provider and supplies the implements Configuration.Provider and supplies the
@@ -24,6 +24,7 @@ import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.fabledsword.minstrel.auth.AuthStore import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
@@ -33,6 +34,7 @@ import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.NetworkStatusController import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.nav.DetailSeedCache import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.Login
import com.fabledsword.minstrel.nav.MinstrelNavGraph import com.fabledsword.minstrel.nav.MinstrelNavGraph
import com.fabledsword.minstrel.nav.Notifications import com.fabledsword.minstrel.nav.Notifications
import com.fabledsword.minstrel.nav.NowPlaying import com.fabledsword.minstrel.nav.NowPlaying
@@ -106,7 +108,7 @@ class MainActivity : ComponentActivity() {
} }
/** /**
* Android 13+ asks before an app may post notifications (M489 #5347). * Android 13+ asks before an app may post notifications.
* Asked once delivery is wanted (signed in, background delivery on), * Asked once delivery is wanted (signed in, background delivery on),
* each launch until answered: after a second "no" the system stops * each launch until answered: after a second "no" the system stops
* showing the prompt by itself, and Settings → Notifications links to * showing the prompt by itself, and Settings → Notifications links to
@@ -177,6 +179,7 @@ private fun App(
navController.navigate(route) { launchSingleTop = true } navController.navigate(route) { launchSingleTop = true }
onOpenedRoute() onOpenedRoute()
} }
SignInWhenSessionRejected(gate, navController)
MinstrelNavGraph( MinstrelNavGraph(
navController = navController, navController = navController,
startDestination = resolved, startDestination = resolved,
@@ -187,6 +190,24 @@ private fun App(
} }
} }
/**
* When the server ends the session while the app is in use, opens the
* sign-in screen rather than leaving the user on screens whose every
* request fails.
*/
@Composable
private fun SignInWhenSessionRejected(gate: AuthGateViewModel, navController: NavHostController) {
val sessionRejected by gate.sessionRejected.collectAsStateWithLifecycle()
LaunchedEffect(sessionRejected, navController) {
if (!sessionRejected) return@LaunchedEffect
navController.navigate(Login) {
popUpTo(0) { inclusive = true }
launchSingleTop = true
}
gate.consumeSessionRejected()
}
}
@Composable @Composable
private fun BootSplash() { private fun BootSplash() {
Surface( Surface(
@@ -8,6 +8,7 @@ import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade import coil3.request.crossfade
import com.fabledsword.minstrel.cache.CacheIndexer 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.mutations.MutationReplayer
import com.fabledsword.minstrel.cache.sync.SyncController import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
@@ -19,10 +20,10 @@ import com.fabledsword.minstrel.metadata.FreshnessSweeper
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
import com.fabledsword.minstrel.player.AudioPrefetcher import com.fabledsword.minstrel.player.AudioPrefetcher
import com.fabledsword.minstrel.player.CoverPrefetcher import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.LikedAudioPinner
import com.fabledsword.minstrel.player.PlayEventsReporter import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter import com.fabledsword.minstrel.player.PlaybackErrorReporter
import com.fabledsword.minstrel.player.ResumeController import com.fabledsword.minstrel.player.ResumeController
import com.fabledsword.minstrel.update.data.UpdateBannerController
import com.fabledsword.minstrel.connectivity.NetworkStatusController import com.fabledsword.minstrel.connectivity.NetworkStatusController
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -79,7 +80,7 @@ class MinstrelApplication :
/** /**
* Same construct-the-singleton trick — DeliveryLauncher starts and stops * Same construct-the-singleton trick — DeliveryLauncher starts and stops
* the background-delivery service and runs the notification catch-up on * the background-delivery service and runs the notification catch-up on
* every nudge and reconnect (M489 #5347). Without this @Inject no phone * every nudge and reconnect. Without this @Inject no phone
* notification would ever be posted. * notification would ever be posted.
*/ */
@Suppress("unused") @Inject lateinit var deliveryLauncher: DeliveryLauncher @Suppress("unused") @Inject lateinit var deliveryLauncher: DeliveryLauncher
@@ -120,6 +121,21 @@ class MinstrelApplication :
*/ */
@Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer @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 * Same construct-the-singleton trick — AudioPrefetcher's init
* block subscribes to PlayerController.uiState + * block subscribes to PlayerController.uiState +
@@ -138,14 +154,6 @@ class MinstrelApplication :
*/ */
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController @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 * Same construct-the-singleton trick — FreshnessSweeper's init
* block starts a 30-min sweep loop that re-fetches cached * block starts a 30-min sweep loop that re-fetches cached
@@ -171,7 +179,7 @@ class MinstrelApplication :
*/ */
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher @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. // debug flag; the uploader drains its buffer on a tick / recovery.
@Suppress("unused") @Inject lateinit var diagnosticsReporter: DiagnosticsReporter @Suppress("unused") @Inject lateinit var diagnosticsReporter: DiagnosticsReporter
@Suppress("unused") @Inject lateinit var diagnosticsUploader: DiagnosticsUploader @Suppress("unused") @Inject lateinit var diagnosticsUploader: DiagnosticsUploader
@@ -181,7 +189,7 @@ class MinstrelApplication :
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Debug builds get the full DebugTree (verbose). Release builds // 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 // still surfaces UPnP / cast failures, OkHttp errors, and our
// own Timber.w / Timber.e calls — without the chatty DEBUG / // own Timber.w / Timber.e calls — without the chatty DEBUG /
// INFO traffic flooding the buffer in production. // INFO traffic flooding the buffer in production.
@@ -229,10 +237,9 @@ class MinstrelApplication :
* before Coil makes its first request. * before Coil makes its first request.
* *
* Crossfade is set here rather than per-call so every cover surface * Crossfade is set here rather than per-call so every cover surface
* in the app fades its artwork in instead of snapping it. Art * in the app fades its artwork in instead of snapping it, and
* landing a beat after its tile was the most visible pop-in on Home * `ServerImage` fades its placeholder out over the same window so the
* (issue #2327); `ServerImage` fades its placeholder out over the * two read as one cross-dissolve.
* same window so the two read as one cross-dissolve.
*/ */
override fun newImageLoader(context: android.content.Context): ImageLoader = override fun newImageLoader(context: android.content.Context): ImageLoader =
ImageLoader.Builder(context) ImageLoader.Builder(context)
@@ -11,10 +11,9 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Read-through accessor for the admin quarantine queue. Mirrors the * Read-through accessor for the admin quarantine queue. No Room cache —
* Flutter `AdminQuarantineController`. No Room cache — same rationale * same rationale as AdminRequests. Three resolution actions all delete
* as AdminRequests. Three resolution actions all delete the row from * the row from the queue once the server accepts them.
* the queue once the server accepts them.
*/ */
@Singleton @Singleton
class AdminQuarantineRepository @Inject constructor( class AdminQuarantineRepository @Inject constructor(
@@ -14,7 +14,7 @@ import javax.inject.Singleton
* *
* No Room caching — admin actions are infrequent and don't benefit * No Room caching — admin actions are infrequent and don't benefit
* from offline scrollback. `approve` and `reject` fire direct REST * 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. * if the request fails the UI surfaces the error and rolls back.
*/ */
@Singleton @Singleton
@@ -33,6 +33,7 @@ class AdminRequestsRepository @Inject constructor(
private fun RequestWire.toDomain(): RequestRef = RequestRef( private fun RequestWire.toDomain(): RequestRef = RequestRef(
id = id, id = id,
userId = userId, userId = userId,
username = username,
status = RequestStatus.fromWire(status), status = RequestStatus.fromWire(status),
kind = kind, kind = kind,
artistName = artistName, artistName = artistName,
@@ -12,7 +12,7 @@ import javax.inject.Inject
import javax.inject.Singleton 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; * No Room caching — admin settings are infrequent point-and-shoot edits;
* mutations fire direct REST and the ViewModel reconciles on failure. * mutations fire direct REST and the ViewModel reconciles on failure.
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy). * 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 * Read-through accessor for the admin users list + the four per-user
* mutations. Mirrors Flutter's `AdminUsersController`. Same * mutations. Same no-Room-cache, no-MutationQueue pattern as the other
* no-Room-cache, no-MutationQueue pattern as the other admin slices. * admin repositories.
*/ */
@Singleton @Singleton
class AdminUsersRepository @Inject constructor( class AdminUsersRepository @Inject constructor(
@@ -39,7 +39,7 @@ class AdminInvitesViewModel @Inject constructor(
init { init {
refresh() 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. // when server health returns instead of waiting for a manual pull.
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { networkStatus.recoveries().collect {
@@ -40,8 +40,8 @@ class AdminQuarantineViewModel @Inject constructor(
/** /**
* One-shot messages for the screen's snackbar. A failed action has to say * 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 * 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 the * file refused by a read-only library it hides the one thing an
* operator can fix (#3918). * admin can fix.
*/ */
private val transientMessagesChannel = Channel<String>(Channel.BUFFERED) private val transientMessagesChannel = Channel<String>(Channel.BUFFERED)
val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow() val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow()
@@ -53,7 +53,7 @@ class AdminQuarantineViewModel @Inject constructor(
.filter { it.kind.startsWith("quarantine.") } .filter { it.kind.startsWith("quarantine.") }
.collect { refresh() } .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. // when server health returns instead of waiting for a manual pull.
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { networkStatus.recoveries().collect {
@@ -71,7 +71,6 @@ fun AdminRequestsScreen(
) )
is AdminRequestsUiState.Success -> RequestList( is AdminRequestsUiState.Success -> RequestList(
rows = s.rows, rows = s.rows,
usernames = s.usernames,
onApprove = viewModel::approve, onApprove = viewModel::approve,
onReject = viewModel::reject, onReject = viewModel::reject,
) )
@@ -83,7 +82,6 @@ fun AdminRequestsScreen(
@Composable @Composable
private fun RequestList( private fun RequestList(
rows: List<RequestRef>, rows: List<RequestRef>,
usernames: Map<String, String>,
onApprove: (String) -> Unit, onApprove: (String) -> Unit,
onReject: (String) -> Unit, onReject: (String) -> Unit,
) { ) {
@@ -91,9 +89,9 @@ private fun RequestList(
items(items = rows, key = { it.id }) { req -> items(items = rows, key = { it.id }) { req ->
AdminRequestRow( AdminRequestRow(
req = req, req = req,
requester = usernames[req.userId] requester = req.username.ifEmpty {
?: req.userId.takeIf { it.isNotEmpty() }?.take(USER_ID_PREFIX_LEN) req.userId.take(USER_ID_PREFIX_LEN).ifEmpty { "unknown" }
?: "unknown", },
onApprove = { onApprove(req.id) }, onApprove = { onApprove(req.id) },
onReject = { onReject(req.id) }, onReject = { onReject(req.id) },
) )
@@ -160,6 +158,6 @@ private fun AdminRequestRow(
} }
} }
// UUID prefix length used as a friendlier-than-raw-UUID fallback when // UUID prefix length used as a friendlier-than-raw-UUID fallback when a
// the admin users list fetch fails and we can't resolve userId → name. // row comes back without the requester's username.
private const val USER_ID_PREFIX_LEN = 8 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.connectivity.recoveries
@@ -11,8 +10,6 @@ import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.models.RequestRef
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -25,17 +22,13 @@ private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
sealed interface AdminRequestsUiState { sealed interface AdminRequestsUiState {
data object Loading : AdminRequestsUiState data object Loading : AdminRequestsUiState
data object Empty : AdminRequestsUiState data object Empty : AdminRequestsUiState
data class Success( data class Success(val rows: List<RequestRef>) : AdminRequestsUiState
val rows: List<RequestRef>,
val usernames: Map<String, String>,
) : AdminRequestsUiState
data class Error(val message: String) : AdminRequestsUiState data class Error(val message: String) : AdminRequestsUiState
} }
@HiltViewModel @HiltViewModel
class AdminRequestsViewModel @Inject constructor( class AdminRequestsViewModel @Inject constructor(
private val repository: AdminRequestsRepository, private val repository: AdminRequestsRepository,
private val usersRepository: AdminUsersRepository,
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
networkStatus: NetworkStatusController, networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
@@ -50,7 +43,7 @@ class AdminRequestsViewModel @Inject constructor(
.filter { it.kind in RELEVANT_EVENT_KINDS } .filter { it.kind in RELEVANT_EVENT_KINDS }
.collect { refresh() } .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. // when server health returns instead of waiting for a manual pull.
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { networkStatus.recoveries().collect {
@@ -62,19 +55,11 @@ class AdminRequestsViewModel @Inject constructor(
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
internal.value = AdminRequestsUiState.Loading internal.value = AdminRequestsUiState.Loading
try { try {
val (rows, usernames) = coroutineScope { val rows = repository.list()
val requestsJob = async { repository.list() }
val usernamesJob = async {
runCatching {
usersRepository.list().associate { it.id to it.username }
}.getOrDefault(emptyMap())
}
requestsJob.await() to usernamesJob.await()
}
internal.value = if (rows.isEmpty()) { internal.value = if (rows.isEmpty()) {
AdminRequestsUiState.Empty AdminRequestsUiState.Empty
} else { } else {
AdminRequestsUiState.Success(rows = rows, usernames = usernames) AdminRequestsUiState.Success(rows = rows)
} }
} catch ( } catch (
@Suppress("TooGenericExceptionCaught") e: Throwable, @Suppress("TooGenericExceptionCaught") e: Throwable,
@@ -101,7 +86,7 @@ class AdminRequestsViewModel @Inject constructor(
internal.value = if (filtered.isEmpty()) { internal.value = if (filtered.isEmpty()) {
AdminRequestsUiState.Empty AdminRequestsUiState.Empty
} else { } else {
AdminRequestsUiState.Success(rows = filtered, usernames = before.usernames) AdminRequestsUiState.Success(rows = filtered)
} }
} }
viewModelScope.launch { viewModelScope.launch {
@@ -38,7 +38,7 @@ class AdminTagSourcesViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery: reload a failed list when server // 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 { viewModelScope.launch {
networkStatus.recoveries().collect { networkStatus.recoveries().collect {
if (internal.value is AdminTagSourcesUiState.Error) refresh() if (internal.value is AdminTagSourcesUiState.Error) refresh()
@@ -33,7 +33,7 @@ class AdminUsersViewModel @Inject constructor(
init { init {
refresh() 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. // when server health returns instead of waiting for a manual pull.
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { networkStatus.recoveries().collect {
@@ -8,22 +8,16 @@ import javax.inject.Singleton
/** /**
* Attaches the session cookie from [AuthStore] to Minstrel-server * Attaches the session cookie from [AuthStore] to Minstrel-server
* requests, captures Set-Cookie from successful responses (login * requests (waiting for the stored one to load at launch), captures
* flow), and clears the store on 401 so downstream code can react * Set-Cookie from successful responses (login flow), and signs out when
* to logout. * the server refuses the cookie a request carried.
*
* Mirrors the Flutter Dio interceptor pattern.
* *
* Scoped to the [BaseUrlInterceptor.PLACEHOLDER_HOST] sentinel host * Scoped to the [BaseUrlInterceptor.PLACEHOLDER_HOST] sentinel host
* the same way BaseUrlInterceptor is. Drift #568 / #569 caught two * the same way BaseUrlInterceptor is. The shared OkHttpClient also
* leaks here: (1) the session cookie was being attached to every * serves Coil's image fetches to external hosts such as
* external request the shared OkHttpClient services — including * artwork.musicbrainz.org and coverartarchive.org; unscoped, this
* Coil image fetches to artwork.musicbrainz.org / coverartarchive.org * would hand the session identifier to third-party logging, and a 401
* — exposing the session * from any of those hosts would sign the user out of Minstrel.
* 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.
*/ */
@Singleton @Singleton
class AuthCookieInterceptor @Inject constructor( class AuthCookieInterceptor @Inject constructor(
@@ -38,7 +32,7 @@ class AuthCookieInterceptor @Inject constructor(
// session. Pass through untouched. // session. Pass through untouched.
return chain.proceed(original) return chain.proceed(original)
} }
val cookie = authStore.sessionCookie.value val cookie = authStore.sessionCookieForRequest()
val request = original.newBuilder().apply { val request = original.newBuilder().apply {
if (!cookie.isNullOrEmpty()) header("Cookie", cookie) if (!cookie.isNullOrEmpty()) header("Cookie", cookie)
}.build() }.build()
@@ -46,7 +40,11 @@ class AuthCookieInterceptor @Inject constructor(
val response = chain.proceed(request) val response = chain.proceed(request)
if (response.code == HTTP_UNAUTHORIZED) { if (response.code == HTTP_UNAUTHORIZED) {
authStore.setSessionCookie(null) // Only a refusal of the cookie this request carried ends the
// session. A request sent without one says nothing about the
// stored login, and a refusal of an older cookie must not
// discard one a sign-in has stored since.
if (!cookie.isNullOrEmpty()) authStore.dropRejectedSessionCookie(cookie)
} else if (response.isSuccessful) { } else if (response.isSuccessful) {
// Capture the first Set-Cookie pair on login; subsequent // Capture the first Set-Cookie pair on login; subsequent
// responses that include Set-Cookie keep the store fresh. // responses that include Set-Cookie keep the store fresh.
@@ -17,15 +17,14 @@ import javax.inject.Singleton
* *
* This interceptor closes the gap: Retrofit can stay built with the * This interceptor closes the gap: Retrofit can stay built with the
* placeholder forever, and every actual Minstrel-bound request gets * placeholder forever, and every actual Minstrel-bound request gets
* retargeted at the live AuthStore value. Lets the user change * retargeted at the live AuthStore value, so the user can change the
* server URL in Settings without an app relaunch (Phase 11 wiring). * server URL in Settings without an app relaunch.
* *
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are * Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
* rewritten. Absolute external URLs — artwork from * rewritten. Absolute external URLs — artwork from
* artwork.musicbrainz.org / coverartarchive.org, for example — must * artwork.musicbrainz.org / coverartarchive.org, for example — must
* reach their authored host unchanged; rewriting them to the * reach their authored host unchanged: rewritten to the Minstrel host
* Minstrel host produced 404s the user saw as missing Discover * they 404, and Discover suggestions show no covers.
* suggestion covers.
* *
* No-op when the stored base URL is unparseable (falls through to * No-op when the stored base URL is unparseable (falls through to
* whatever Retrofit had) — that case shows up as a transport-level * whatever Retrofit had) — that case shows up as a transport-level
@@ -16,11 +16,10 @@ object MinstrelServerRequest
/** /**
* Plain `http://` to the Minstrel server is allowed only when the connection * Plain `http://` to the Minstrel server is allowed only when the connection
* actually lands on a private address (family security baseline #5105, * actually lands on a private address.
* practice 13).
* *
* Cleartext stays permitted app-wide for LAN servers and UPnP * Cleartext stays permitted app-wide for LAN servers and UPnP
* (network_security_config.xml, #2439), but a password or session cookie sent * (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. * 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 * Checked per connection, on the address the socket really reached, not on
@@ -44,9 +44,9 @@ object ErrorCopy {
} }
/** /**
* Codes whose server message carries specifics the operator needs in * Codes whose server message carries specifics an admin needs in
* order to act — which directory, which uid — that fixed copy cannot say. * order to act — which directory, which uid — that fixed copy cannot say.
* For these the message follows the copy (#3918). Kept to a named set on * For these the message follows the copy. Kept to a named set on
* purpose: most server messages are internal detail. Mirrors web's * purpose: most server messages are internal detail. Mirrors web's
* errors.ts. * errors.ts.
*/ */
@@ -57,16 +57,15 @@ object NetworkModule {
.addInterceptor(reachability) .addInterceptor(reachability)
// AuthCookieInterceptor MUST run before BaseUrlInterceptor. // AuthCookieInterceptor MUST run before BaseUrlInterceptor.
// Both scope on `host == PLACEHOLDER_HOST` to distinguish // Both scope on `host == PLACEHOLDER_HOST` to distinguish
// Minstrel-server requests from external image fetches // Minstrel-server requests from external image fetches. If
// (drift #568 / #569). If BaseUrlInterceptor runs first it // BaseUrlInterceptor runs first it rewrites the host to the real
// rewrites the host to the real server before auth sees the // server before auth sees the request, auth's placeholder check
// request, auth's placeholder check fails, and the session // fails, and the session cookie is neither attached on outgoing
// cookie is neither attached on outgoing requests nor // requests nor captured from Set-Cookie on login — fresh installs
// captured from Set-Cookie on login — fresh installs get // get stuck at the Welcome screen. Auth first means it sees
// stuck at the Welcome screen. Auth first means it sees
// placeholder.invalid, attaches/captures correctly, then // placeholder.invalid, attaches/captures correctly, then
// BaseUrlInterceptor retargets to the live AuthStore host // BaseUrlInterceptor retargets to the live AuthStore host for
// for transport. // transport.
.addInterceptor(auth) .addInterceptor(auth)
.addInterceptor(baseUrl) .addInterceptor(baseUrl)
.addInterceptor(logging) .addInterceptor(logging)
@@ -27,9 +27,8 @@ interface AdminInvitesApi {
} }
/** /**
* Body for `POST /api/admin/invites`. The Flutter client omits the * Body for `POST /api/admin/invites`. An empty note is sent as null,
* field when note is empty; Kotlin/serialization just serializes * which the server treats the same as an absent field.
* null which the server treats identically.
*/ */
@Serializable @Serializable
data class CreateInviteBody(val note: String? = null) data class CreateInviteBody(val note: String? = null)
@@ -12,9 +12,9 @@ import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
/** /**
* Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin * Retrofit interface for `/api/admin/tag-sources` — the admin surface
* surface for the tag-enrichment provider settings (#1490). Mirrors the * for the tag-enrichment provider settings. Mirrors the web
* web integrations "Tag enrichment sources" card. Same shape as the * integrations "Tag enrichment sources" card. Same shape as the
* cover-sources admin surface; kept independent so a new tag source is * cover-sources admin surface; kept independent so a new tag source is
* added without touching art settings. * added without touching art settings.
*/ */
@@ -30,7 +30,7 @@ interface CastApi {
* the 21_600 default (6h) is long enough to play through any typical * the 21_600 default (6h) is long enough to play through any typical
* track without re-minting mid-playback. * track without re-minting mid-playback.
* *
* [level] asks for the leveled stream (M464 #5001): the track rendered at * [level] asks for the leveled stream: the track rendered at
* the user's loudness gain, which the server works out from their setting. * 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 * [asAlbum] says the track plays among its album in order, which picks
* album gain in auto mode. [prerender] says the speaker will fetch it * album gain in auto mode. [prerender] says the speaker will fetch it
@@ -7,7 +7,7 @@ import retrofit2.http.Body
import retrofit2.http.POST 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 * 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, * 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). * which the uploader treats as success so it can drop the batch).
@@ -19,8 +19,7 @@ import retrofit2.http.Query
* second, so the Discover screen searches when the query is submitted, * second, so the Discover screen searches when the query is submitted,
* not on every keystroke. * not on every keystroke.
* *
* Cancel + my-requests-list endpoints live on RequestsApi (Phase 10 * Cancel + my-requests-list endpoints live on RequestsApi, not here.
* Commit B), not here.
*/ */
interface DiscoverApi { interface DiscoverApi {
@GET("api/discover/suggestions") @GET("api/discover/suggestions")
@@ -56,16 +56,15 @@ interface LibraryApi {
@GET("api/library/shuffle") @GET("api/library/shuffle")
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire> suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
// Browse axes (#367). Both indexes are unpaged by design: the client needs // Browse axes. Both indexes are unpaged by design: the client needs
// the whole set to render a browsable picker, and even a messy library // the whole set to render a browsable picker, and even a messy library
// yields hundreds of rows, not thousands. // yields hundreds of rows, not thousands.
// //
// These read the server rather than the local cache on purpose. The cache // These read the server rather than the local cache on purpose: the
// is a full mirror of the library, but /api/library/sync ships tracks whose // server's index leaves out tracks whose files are missing and is the
// files are missing and carries no flag for it (#2704), while the browse // one the web client shows, so the two clients agree. The cache carries
// index filters them out -- so a locally-computed index would disagree with // the same mark (cached_tracks.missing), but an index built from it
// the server's and with the web client. One source of truth wins over // would be a second copy of the server's grouping that could drift.
// offline capability here until #2704 is resolved.
@GET("api/library/genres") @GET("api/library/genres")
suspend fun getGenres(): List<GenreCountWire> suspend fun getGenres(): List<GenreCountWire>
@@ -92,9 +92,7 @@ data class ChangePasswordRequest(
/** /**
* Body for `PUT /api/me/listenbrainz`. Either `token` or `enabled` * Body for `PUT /api/me/listenbrainz`. Either `token` or `enabled`
* is set; the server treats other fields as untouched. Mirrors * is set; the server treats other fields as untouched.
* Flutter's `setListenBrainzToken` / `setListenBrainzEnabled` split
* over a single endpoint shape.
*/ */
@Serializable @Serializable
data class ListenBrainzPutBody( data class ListenBrainzPutBody(
@@ -10,7 +10,7 @@ import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
/** /**
* The notifications inbox and its per-user settings (M489). The server * 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. * renders each notice's title, body and link, so the app shows them as given.
*/ */
interface NotificationsApi { interface NotificationsApi {
@@ -49,7 +49,7 @@ interface PlaylistsApi {
suspend fun refreshSystem(@Path("kind") variant: String): RefreshSystemResponse 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 * the system playlist's tracks in rotation-aware order without
* rebuilding — used by the Home play-button overlay so taps on For * rebuilding — used by the Home play-button overlay so taps on For
* You / Discover / Today's mix advance rotation rather than picking * You / Discover / Today's mix advance rotation rather than picking
@@ -60,8 +60,8 @@ interface PlaylistsApi {
} }
/** /**
* POST /api/playlists/{id}/tracks body. Server expects snake_case * POST /api/playlists/{id}/tracks body. The server expects snake_case
* `track_ids` per the Flutter wire shape. * `track_ids`.
*/ */
@Serializable @Serializable
data class AppendTracksRequest( data class AppendTracksRequest(
@@ -13,7 +13,7 @@ import retrofit2.http.Path
* *
* Both flag and unflag are user-scoped — callers act on their own * Both flag and unflag are user-scoped — callers act on their own
* quarantine entries. The cross-user admin surface is a separate * quarantine entries. The cross-user admin surface is a separate
* `/api/admin/quarantine` route (Phase 10 Commit D). * `/api/admin/quarantine` route.
*/ */
interface QuarantineApi { interface QuarantineApi {
@GET("api/quarantine/mine") @GET("api/quarantine/mine")
@@ -4,7 +4,7 @@ import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Query import retrofit2.http.Query
/** The player's loudness lookup (#4997), kept apart from the browse surface in [LibraryApi]. */ /** The player's loudness lookup, kept apart from the browse surface in [LibraryApi]. */
interface ReplayGainApi { interface ReplayGainApi {
/** /**
* ReplayGain values for up to 200 comma-separated track ids. An id * ReplayGain values for up to 200 comma-separated track ids. An id
@@ -10,7 +10,7 @@ import retrofit2.http.Path
* *
* Server scopes results to the caller — admins see only their own * Server scopes results to the caller — admins see only their own
* requests through this endpoint. The cross-user admin view lives on * 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 * The DELETE endpoint returns the cancelled row (not 204) so the UI
* can patch local state without a refetch. * can patch local state without a refetch.
@@ -6,7 +6,7 @@ import retrofit2.http.GET
import retrofit2.http.Query 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 * returns three possible status codes that the SyncController
* branches on: * branches on:
* - 200: parsed body with cursor + upserts + deletes * - 200: parsed body with cursor + upserts + deletes
@@ -57,7 +57,7 @@ class AuthController @Inject constructor(
} }
} }
// Refresh from /api/me on startup so a remotely-changed account // 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. // without a re-login. Best-effort; failures keep the cached value.
scope.launch { refreshProfile() } scope.launch { refreshProfile() }
} }
@@ -89,8 +89,11 @@ class AuthController @Inject constructor(
* + persisted userJson. Carries account-level flags (debug mode) * + persisted userJson. Carries account-level flags (debug mode)
* that aren't in the login response. Best-effort: a network failure * that aren't in the login response. Best-effort: a network failure
* leaves the cached identity untouched. No-op when signed out. * leaves the cached identity untouched. No-op when signed out.
* Waits for the stored login to load first: at launch this runs before
* it has, and would otherwise read the user as signed out and skip.
*/ */
suspend fun refreshProfile() { suspend fun refreshProfile() {
authStore.awaitSessionHydrated()
if (!isSignedIn) return if (!isSignedIn) return
val p = runCatching { meApi.getProfile() }.getOrNull() ?: return val p = runCatching { meApi.getProfile() }.getOrNull() ?: return
val user = UserRef( val user = UserRef(
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@@ -35,13 +36,13 @@ import javax.inject.Singleton
* sees the new value immediately; the DAO write coroutine catches up * sees the new value immediately; the DAO write coroutine catches up
* shortly after. * shortly after.
* *
* **The session cookie is the exception** (M462 #4985): it is persisted * **The session cookie is the exception**: it is persisted through
* through [SessionVault] (Keystore-encrypted), not the Room row. On the * [SessionVault] (Keystore-encrypted), not the Room row. A cookie still
* first launch after the upgrade, a cookie still in the row is moved into * in the row is moved into the vault on launch and the column cleared,
* the vault and the column cleared, so nobody is signed out by the change. * so nobody is signed out by the move. If the Keystore cannot be used
* If the Keystore cannot be used on a device, the cookie stays in the row * on a device, the cookie stays in the row rather than being lost.
* as before rather than being lost. [awaitSessionHydrated] lets a caller * [awaitSessionHydrated] lets a caller that needs a definitive answer
* that needs a definitive answer (the auth gate) wait for this. * (the auth gate) wait for this.
*/ */
// AuthStore is the single-row facade over auth_session (de-facto // AuthStore is the single-row facade over auth_session (de-facto
// app_preferences — see entity comment). It legitimately owns one // app_preferences — see entity comment). It legitimately owns one
@@ -81,7 +82,7 @@ class AuthStore @Inject constructor(
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT) private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow() val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
// Background delivery (M489 #5347): the device's choice, on by default. // Background delivery: the device's choice, on by default.
// The shade's high-water mark lives in the same row but is read and // 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. // written through the DAO by NotificationSync, awaited, never cached here.
private val backgroundDeliveryState = MutableStateFlow(true) private val backgroundDeliveryState = MutableStateFlow(true)
@@ -131,8 +132,8 @@ class AuthStore @Inject constructor(
/** /**
* Suspends until the stored session cookie has been loaded into * Suspends until the stored session cookie has been loaded into
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged * [sessionCookie], or [HYDRATION_DEADLINE_MS] passes, so a wedged
* Keystore must not leave the start screen spinning). Returns false on * Keystore cannot leave the start screen spinning. Returns false on
* the deadline; the caller then decides from whatever has loaded, and a * the deadline; the caller then decides from whatever has loaded, and a
* late hydration still lands in [sessionCookie]. * late hydration still lands in [sessionCookie].
*/ */
@@ -148,6 +149,49 @@ class AuthStore @Inject constructor(
scope.launch { cookieLock.withLock { storeCookie(value) } } scope.launch { cookieLock.withLock { storeCookie(value) } }
} }
/**
* The cookie to attach to an outgoing request. Blocks the calling
* (OkHttp) thread until the stored cookie has loaded, up to
* [HYDRATION_DEADLINE_MS], so a request made in the first moment after
* launch carries the login instead of going out without it. Once
* hydration has finished this is a plain read.
*/
fun sessionCookieForRequest(): String? {
if (!cookieHydration.isCompleted) {
runBlocking { withTimeoutOrNull(HYDRATION_DEADLINE_MS) { cookieHydration.await() } }
}
return sessionCookieState.value
}
/**
* True once the server has refused the current session (see
* [dropRejectedSessionCookie]) and the UI has not yet sent the user to
* sign in. Held rather than fired, so a refusal that lands while the app
* is in the background is still acted on when it comes back. A sign-out
* the user asked for does not set it.
*/
private val sessionRejectedState = MutableStateFlow(false)
val sessionRejected: StateFlow<Boolean> = sessionRejectedState.asStateFlow()
/** The UI has opened the sign-in screen for a refused session. */
fun consumeSessionRejected() {
sessionRejectedState.value = false
}
/**
* Signs out because the server answered 401 to a request that carried
* [rejected]. Does nothing when the store has since moved on to another
* cookie (a sign-in landed meanwhile), so a refusal of an old session
* never discards a new one. What is persisted is read inside the lock,
* so a sign-in racing this call is the value that ends up stored.
*/
fun dropRejectedSessionCookie(rejected: String) {
if (!sessionCookieState.compareAndSet(rejected, null)) return
cookieTouched = true
scope.launch { cookieLock.withLock { storeCookie(sessionCookieState.value) } }
sessionRejectedState.value = true
}
private suspend fun hydrateSessionCookie() = cookieLock.withLock { private suspend fun hydrateSessionCookie() = cookieLock.withLock {
val legacy = runCatching { dao.get()?.sessionCookie }.getOrNull() val legacy = runCatching { dao.get()?.sessionCookie }.getOrNull()
if (cookieTouched) return@withLock if (cookieTouched) return@withLock
@@ -19,15 +19,14 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Where the session cookie lives at rest (M462 #4985). * Where the session cookie lives at rest.
* *
* The cookie is a bearer credential: anyone holding it is signed in as the * The cookie is a bearer credential: anyone holding it is signed in as the
* user until the server expires or revokes it. It used to sit in plain text * user until the server expires or revokes it. In plain text it would be
* in the Room `auth_session` row, readable from any copy of the app's data * readable from any copy of the app's data directory (a rooted device, an
* directory (a rooted device, an adb backup of a debuggable build, a * adb backup of a debuggable build, a forensic image). Only ciphertext is
* forensic image). Now only ciphertext is stored, under an AES key that * stored, under an AES key that lives in the Android Keystore and never
* lives in the Android Keystore and never leaves it, so a copy of the * leaves it, so a copy of the files alone yields nothing usable.
* files alone yields nothing usable.
*/ */
interface SessionVault { interface SessionVault {
/** The stored cookie, or null when none is stored or it can't be decrypted. */ /** The stored cookie, or null when none is stored or it can't be decrypted. */
@@ -19,8 +19,8 @@ import javax.inject.Inject
* persisted auth state. Reads the row from AuthSessionDao directly, and * persisted auth state. Reads the row from AuthSessionDao directly, and
* the session cookie only after [AuthStore.awaitSessionHydrated]: both * the session cookie only after [AuthStore.awaitSessionHydrated]: both
* StateFlows default to null until their async load lands, and we need a * StateFlows default to null until their async load lands, and we need a
* definitive answer before drawing any nav graph. The cookie is no longer * definitive answer before drawing any nav graph. The cookie lives in the
* in the row (it lives in the Keystore-backed SessionVault, #4985). * Keystore-backed SessionVault, not the row.
* *
* - no row at all → ServerUrl (first launch) * - no row at all → ServerUrl (first launch)
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in) * - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
@@ -38,6 +38,15 @@ class AuthGateViewModel @Inject constructor(
private val internal = MutableStateFlow<Any?>(null) private val internal = MutableStateFlow<Any?>(null)
val startDestination: StateFlow<Any?> = internal.asStateFlow() val startDestination: StateFlow<Any?> = internal.asStateFlow()
/**
* True when the server has ended the session while the app was in use;
* the root NavHost answers by opening the sign-in screen, then calls
* [consumeSessionRejected].
*/
val sessionRejected: StateFlow<Boolean> = authStore.sessionRejected
fun consumeSessionRejected() = authStore.consumeSessionRejected()
init { init {
viewModelScope.launch { viewModelScope.launch {
authStore.awaitSessionHydrated() authStore.awaitSessionHydrated()
@@ -18,15 +18,13 @@ import javax.inject.Singleton
* Populates `audio_cache_index` from playback so the offline pools * Populates `audio_cache_index` from playback so the offline pools
* ([ShuffleSource]) and the cached indicator have a data source. * ([ShuffleSource]) and the cached indicator have a data source.
* *
* Mirrors the user-visible behavior of Flutter's `AudioCacheManager` * A track you play gets cached as a side effect of streaming, and
* (`registerStreamCache` + `touch`): a track you play gets cached as a * playing it bumps its recency. Media3's `SimpleCache` is the real byte
* side effect of streaming, and playing it bumps its recency. The * store (span-based, keyed by trackId via the MediaItem's custom cache
* Android idiom differs intentionally — Media3's `SimpleCache` is the * key), so this index is a lightweight *play-recency* record
* real byte store (span-based, keyed by trackId via the MediaItem's * (source = INCIDENTAL), not a file-per-track table. SimpleCache stays
* custom cache key), so this index is a lightweight *play-recency* * the source of truth for "is it actually on disk"; this index orders
* record (source = INCIDENTAL) rather than Flutter's file-per-track * "recently played" for the offline pools.
* 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 * State observed against [PlayerController.uiState], deduped by track
* id so each entry into the player records once. Eager-constructed via * id so each entry into the player records once. Eager-constructed via
@@ -19,11 +19,10 @@ private const val POOL_LIMIT = 100
* storage/eviction-only and never filters playback — exactly what * storage/eviction-only and never filters playback — exactly what
* this relies on. * this relies on.
* *
* Divergence note: Flutter's `audioCacheIndex` only holds fully-cached * The index records plays (see `CacheIndexer`), not residency, so the
* tracks, so its pools are inherently resident. Android's index records * recency list is intersected with true Media3 SimpleCache residency
* plays (see `CacheIndexer`), so the recency list is intersected with * here — the pool never offers a played-then-evicted track that would
* true Media3 SimpleCache residency here — the pool never offers a * fail to play offline.
* played-then-evicted track that would fail to play offline.
* *
* Surfaced on Home's Playlists row only when offline; tap shuffles + * Surfaced on Home's Playlists row only when offline; tap shuffles +
* plays the pool from the cache. * plays the pool from the cache.
@@ -58,7 +57,7 @@ class ShuffleSource @Inject constructor(
if (orderedIds.isEmpty()) return emptyList() if (orderedIds.isEmpty()) return emptyList()
val byId = trackDao.getByIds(orderedIds).associateBy { it.id } val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
return orderedIds.mapNotNull { id -> return orderedIds.mapNotNull { id ->
// Clear the server's missing mark (#2704). Every id reaching here // Clear the server's missing mark. Every id reaching here
// came through residentIdsByRecency, which already proved the // came through residentIdsByRecency, which already proved the
// AUDIO is in the local cache — so these play regardless of what // AUDIO is in the local cache — so these play regardless of what
// the server has lost, and the queue filter in PlayerController // the server has lost, and the queue filter in PlayerController
@@ -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.
*
* - `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
}
}
@@ -9,19 +9,19 @@ private const val DEFAULT_PREFETCH_WINDOW = 5
* User-tunable audio cache settings. Persisted as a JSON * User-tunable audio cache settings. Persisted as a JSON
* blob on the auth_session single-row table via [AuthStore]. * blob on the auth_session single-row table via [AuthStore].
* *
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means * - [likedCapBytes]: the Liked songs budget, for the cached audio of
* unlimited. Effective at next app start (SimpleCache is built once * liked tracks. 0 means unlimited.
* per process). * - [rollingCapBytes]: the Recently played budget, for everything else
* - [rollingCapBytes]: budget for incidental / auto-prefetch downloads. * streamed or prefetched. 0 means unlimited.
* Same 0=unlimited / restart-to-apply caveat. * A track that is liked and also recently played counts against Liked
* - [prefetchWindow]: 1..10. Number of next-tracks the prefetcher * only. Both caps apply as soon as they change ([TwoBucketCacheEvictor]).
* pre-downloads. Has no effect until the prefetcher lands (separate * - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher`
* audit follow-up) — persisted now so the user can pre-configure. * pre-downloads.
* - [cacheLikedTracks]: when true, liking a track should also pin its * - [cacheLikedTracks]: "Keep liked songs offline". When true,
* audio to the liked-cache bucket. Has no effect until pin-on-like * `LikedAudioPinner` downloads liked tracks into the Liked budget on
* lands (separate follow-up). * 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 @Serializable
data class CacheSettings( 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)
}
}
}
@@ -40,17 +40,12 @@ import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
/** /**
* Single Room database for the whole app. Mirrors the Flutter client's * Single Room database for the whole app, one entity family per @Entity.
* Drift `AppDb` — same logical schema, one entity-family per @Entity.
* *
* Native v1 starts at schema version 1 (the Flutter Drift schemaVersion 11 * The server is the source of truth: the sync controller refills the
* is an internal client detail; the server is the source of truth and the * cache on first launch. Schema bumps land with explicit `Migration`
* sync controller refills on first launch). Future schema bumps land per * entries; `fallbackToDestructiveMigration` in `DatabaseModule` rebuilds
* change with explicit `Migration` entries; `fallbackToDestructiveMigration` * the database from a version that has no migration path.
* 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.
*/ */
@Database( @Database(
entities = [ entities = [
@@ -73,25 +68,25 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedNotificationSettingsEntity::class, CachedNotificationSettingsEntity::class,
], ],
// v12: + auth_session.backgroundDelivery and notifiedUpTo, the device's // v12: + auth_session.backgroundDelivery and notifiedUpTo, the device's
// background-delivery choice and its shade high-water mark (M489 #5347). // background-delivery choice and its shade high-water mark.
// v11: + cached_notifications and cached_notification_settings, the // v11: + cached_notifications and cached_notification_settings, the
// notifications inbox and its settings (M489). MIGRATION_10_11 creates // notifications inbox and its settings. MIGRATION_10_11 creates
// both; nothing to backfill, the first refresh fills them. // both; nothing to backfill, the first refresh fills them.
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/ // v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
// albumPeak, the ReplayGain values the player levels by (M464 #5000). // albumPeak, the ReplayGain values the player levels by.
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends // MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
// every row and an existing cache gains its values. // every row and an existing cache gains its values.
// v9: + auth_session.normalizationJson, the loudness-normalization // v9: + auth_session.normalizationJson, the loudness-normalization
// preference (M464 #4998). The first schema step with an explicit // preference. The first schema step with an explicit Migration
// Migration (MIGRATION_8_9): a destructive rebuild would also wipe this // (MIGRATION_8_9): a destructive rebuild would also wipe this row —
// row — the server address and theme — and the queued offline writes, // the server address and theme — and the queued offline writes,
// which is too much to lose for one added column. // which is too much to lose for one added column.
// v8: + cached_tracks.missing, the server's missing-file mark (#2704), // v8: + cached_tracks.missing, the server's missing-file mark,
// so cache-first surfaces stop offering files that cannot stream. // so cache-first surfaces stop offering files that cannot stream.
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column // v7: + diagnostic_events table and the diagnosticsOptOut column
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch — // on auth_session. Versions before v8 rebuild destructively, which is
// which is exactly right here: the next sync refills every row with the // right for them: the next sync refills every row with the new
// new column populated, so there is nothing to migrate by hand. // columns populated, so there is nothing to migrate by hand.
version = 12, version = 12,
exportSchema = true, exportSchema = true,
) )
@@ -115,7 +110,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cachedNotificationDao(): CachedNotificationDao abstract fun cachedNotificationDao(): CachedNotificationDao
} }
/** v8 → v9: add the nullable normalization preference column (#4998). */ /** v8 → v9: add the nullable normalization preference column. */
val MIGRATION_8_9: Migration = object : Migration(8, 9) { val MIGRATION_8_9: Migration = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT") db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
@@ -123,7 +118,7 @@ val MIGRATION_8_9: Migration = object : Migration(8, 9) {
} }
/** /**
* v9 → v10: the gain columns (#5000). Rows synced before this carry no gains, * 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 * 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. * back to 0 and the next sync is a full one, upserting every row in place.
*/ */
@@ -138,7 +133,7 @@ val MIGRATION_9_10: Migration = object : Migration(9, 10) {
} }
/** /**
* v10 → v11: the notifications inbox cache and its settings (M489). The SQL * v10 → v11: the notifications inbox cache and its settings. The SQL
* matches what Room generates for the two entities; Room checks it on open. * matches what Room generates for the two entities; Room checks it on open.
*/ */
val MIGRATION_10_11: Migration = object : Migration(10, 11) { val MIGRATION_10_11: Migration = object : Migration(10, 11) {
@@ -157,7 +152,7 @@ val MIGRATION_10_11: Migration = object : Migration(10, 11) {
} }
/** /**
* v11 → v12: background delivery (M489 #5347). The device choice defaults on, * v11 → v12: background delivery. The device choice defaults on,
* as the entity's column default says; the high-water mark starts empty, so * as the entity's column default says; the high-water mark starts empty, so
* the first catch-up sets it without announcing anything. * the first catch-up sets it without announcing anything.
*/ */
@@ -33,11 +33,10 @@ object DatabaseModule {
@Singleton @Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase = fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME) Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
// Pre-v1 safety net: rebuild on any schema mismatch. The sync // Explicit migrations from v8 onward; an older schema has no path
// controller refills the cache from the server on first // and rebuilds. The sync controller refills the cache from the
// launch, so users lose only the unsynced mutation queue // server on first launch, so a rebuild loses only the unsynced
// (acceptable while we're iterating). Replace with explicit // mutation queue.
// Migration entries before the first tagged release.
.addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12) .addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
.fallbackToDestructiveMigration(dropAllTables = true) .fallbackToDestructiveMigration(dropAllTables = true)
.build() .build()
@@ -4,17 +4,10 @@ import androidx.room.TypeConverter
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* Source category for a cached audio file. Mirrors the Drift `CacheSource` * How a row reached `audio_cache_index`. `CacheIndexer` writes
* enum used by the Flutter client's `audio_cache_index` table 1:1. * `INCIDENTAL` (the track was played); the other values are stored names
* * no writer uses. Eviction does not read this: the audio cache's two
* - `MANUAL`: user explicitly pinned (e.g. "save for offline") * budgets follow the liked set (see `TwoBucketCacheEvictor`).
* - `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.
*/ */
enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL } 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 * Cross-cutting Room type converters. Registered on `@Database` via
* `@TypeConverters(MinstrelTypeConverters::class)`. * `@TypeConverters(MinstrelTypeConverters::class)`.
* *
* `kotlinx.datetime.Instant` ↔ `Long` epoch millis: the same convention the * `kotlinx.datetime.Instant` ↔ `Long` epoch millis: leaner than ISO-8601
* Flutter client uses (Drift `text()` column with ISO-8601 strings would * text, and nothing needs human-readable rows in the SQLite file.
* also work but Long is leaner; we don't need human-readable rows in the
* SQLite file).
*/ */
class MinstrelTypeConverters { class MinstrelTypeConverters {
@TypeConverter fun instantToLong(i: Instant?): Long? = i?.toEpochMilliseconds() @TypeConverter fun instantToLong(i: Instant?): Long? = i?.toEpochMilliseconds()
@@ -4,7 +4,6 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.fabledsword.minstrel.cache.db.CacheSource
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -32,23 +31,6 @@ interface AudioCacheIndexDao {
@Query("SELECT trackId FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST") @Query("SELECT trackId FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST")
suspend fun trackIdsByRecency(): List<String> 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) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: AudioCacheIndexEntity) suspend fun upsert(row: AudioCacheIndexEntity)
@@ -10,12 +10,16 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface CachedLikeDao { 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( @Query(
"SELECT entityId FROM cached_likes " + "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( @Query(
"SELECT entityId FROM cached_likes " + "SELECT entityId FROM cached_likes " +
@@ -44,10 +48,9 @@ interface CachedLikeDao {
/** /**
* Atomically replaces the user's entire cached_likes set with * Atomically replaces the user's entire cached_likes set with
* [rows]. Used by [com.fabledsword.minstrel.likes.data.LikesRepository.refreshIds] * [rows]. Used by [com.fabledsword.minstrel.likes.data.LikesRepository.refreshIds]
* so cross-device unlikes (a row that the server no longer * so an unlike made on another device (a row the server no longer
* surfaces) get removed from the local cache — drift #570 * surfaces) leaves the local cache too; upserts alone would keep a
* caught the missing delete pass that left stale Liked tab * stale Liked tab entry forever.
* entries pointing at tracks the user had unliked elsewhere.
*/ */
@Transaction @Transaction
suspend fun replaceAllForUser(userId: String, rows: List<CachedLikeEntity>) { suspend fun replaceAllForUser(userId: String, rows: List<CachedLikeEntity>) {
@@ -46,13 +46,13 @@ interface CachedPlaylistTrackDao {
* Atomic on purpose. Room's InvalidationTracker only notifies observers * Atomic on purpose. Room's InvalidationTracker only notifies observers
* after the transaction commits, so [observeByPlaylist] never sees the * after the transaction commits, so [observeByPlaylist] never sees the
* empty gap between the delete and the re-insert. Un-transacted, that * 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 * gap is a real observed state: the list visibly collapses to empty
* collapse to empty and refill before issue #2327 fixed the equivalent * and refills, which is why `CachedHomeIndexDao` transacts its writes
* write in `CachedHomeIndexDao`. * too.
* *
* Nothing observes [observeByPlaylist] live today, so this is * Nothing observes [observeByPlaylist] live today, so this is
* pre-emptive: it means making playlist detail cache-first later can't * pre-emptive: making playlist detail cache-first later can't
* silently reintroduce that flicker. * silently bring that flicker in.
*/ */
@Transaction @Transaction
suspend fun replacePlaylistTracks( suspend fun replacePlaylistTracks(
@@ -41,8 +41,7 @@ interface CachedQuarantineDao {
/** /**
* Atomic full-replace from the server snapshot — one transaction so * Atomic full-replace from the server snapshot — one transaction so
* the `observeAll()` watcher sees a single merged emission, not a * the `observeAll()` watcher sees a single merged emission, not a
* delete-then-insert flicker. Mirrors Flutter's MyQuarantineController * delete-then-insert flicker.
* `_refreshFromServer` transaction.
*/ */
@Transaction @Transaction
suspend fun replaceAll(rows: List<CachedQuarantineEntity>) { suspend fun replaceAll(rows: List<CachedQuarantineEntity>) {
@@ -39,7 +39,7 @@ interface CachedTrackDao {
suspend fun upsertAll(rows: List<CachedTrackEntity>) suspend fun upsertAll(rows: List<CachedTrackEntity>)
/** /**
* ReplayGain values for [ids] (M464 #5000): the track's own from its row, * 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. * the album's from its album row. A track not in the cache has no row.
*/ */
@Query( @Query(
@@ -7,18 +7,14 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* One row per fully-downloaded audio file. Mirrors * One row per track that has played through the audio cache (see
* the Flutter client's `AudioCacheIndex` Drift table. * `CacheIndexer`); SimpleCache, not this table, says what is on disk.
* *
* Drives the 2-bucket LRU eviction (Phase 12 AudioCacheEvictionWorker): * `source` says how the row arrived (see [CacheSource]); eviction does
* - `incidental` files (streamed-and-cached side effect) evict first * not read it. `cachedAt` = when the row was written. `lastPlayedAt` =
* - `autoPrefetch` second * when the user last played the track, which orders the offline
* - `manual` (user explicitly pinned) evict last * "recently played" pool. Nullable on brand-new rows until the first
* * post-cache play touches them.
* `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.
*/ */
@Entity(tableName = "audio_cache_index") @Entity(tableName = "audio_cache_index")
data class AudioCacheIndexEntity( data class AudioCacheIndexEntity(
@@ -39,7 +39,7 @@ data class AuthSessionEntity(
*/ */
val cacheSettingsJson: String? = null, 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 * account's debug mode is enabled by an admin, the user can still
* turn reporting OFF on this device (battery/privacy) — that local * turn reporting OFF on this device (battery/privacy) — that local
* choice lives here. Default false = honor the account flag. * choice lives here. Default false = honor the account flag.
@@ -47,12 +47,12 @@ data class AuthSessionEntity(
val diagnosticsOptOut: Boolean = false, val diagnosticsOptOut: Boolean = false,
/** /**
* JSON-encoded NormalizationPrefs (settings/data), the last value seen * JSON-encoded NormalizationPrefs (settings/data), the last value seen
* from the server or set here (M464 #4998). Null = never fetched; the * from the server or set here. Null = never fetched; the
* defaults apply. Kept so offline playback still levels. * defaults apply. Kept so offline playback still levels.
*/ */
val normalizationJson: String? = null, val normalizationJson: String? = null,
/** /**
* "Notifications when the app is closed" (M489 #5347): keep the * "Notifications when the app is closed": keep the
* delivery foreground service running. A device choice, on by default. * delivery foreground service running. A device choice, on by default.
* The column default matches MIGRATION_11_12's, which Room checks. * The column default matches MIGRATION_11_12's, which Room checks.
*/ */
@@ -5,10 +5,7 @@ import androidx.room.PrimaryKey
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /** Cache row for one album. */
* Cache row for one album. Mirrors the Flutter client's
* `CachedAlbums` Drift table.
*/
@Entity(tableName = "cached_albums") @Entity(tableName = "cached_albums")
data class CachedAlbumEntity( data class CachedAlbumEntity(
@PrimaryKey val id: String, @PrimaryKey val id: String,
@@ -18,7 +15,7 @@ data class CachedAlbumEntity(
val releaseDate: String? = null, val releaseDate: String? = null,
val coverPath: String? = null, val coverPath: String? = null,
val mbid: String? = null, val mbid: String? = null,
// ReplayGain 2.0 album values (M464); null until every track is measured. // ReplayGain 2.0 album values; null until every track is measured.
val albumGain: Float? = null, val albumGain: Float? = null,
val albumPeak: Float? = null, val albumPeak: Float? = null,
val fetchedAt: Instant = Clock.System.now(), val fetchedAt: Instant = Clock.System.now(),
@@ -6,13 +6,11 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* Cache row for one artist. Mirrors the Flutter client's * Cache row for one artist.
* `CachedArtists` Drift table.
* *
* Column names follow Kotlin idiom (camelCase) rather than Drift's * Column names follow Kotlin idiom (camelCase) — the schema is purely
* snake_case — the schema is purely internal to the native client; the * internal to the app; the sync controller exchanges snake_case JSON
* sync controller exchanges snake_case JSON with the server and our * with the server and our mappers translate at the boundary.
* mappers translate at the boundary.
*/ */
@Entity(tableName = "cached_artists") @Entity(tableName = "cached_artists")
data class CachedArtistEntity( data class CachedArtistEntity(
@@ -8,8 +8,7 @@ import kotlinx.datetime.Instant
/** /**
* Single-row snapshot of the `/api/me/history` response, stored as the * 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 * raw wire JSON so the History tab paints instantly from disk on cold
* open and survives connectivity loss. Mirrors the Flutter Drift * open and survives connectivity loss. One row total; id defaults to 1.
* `CachedHistorySnapshot` table (id defaults to 1; one row total).
* *
* Cache-first + SWR: the tab observes this row and a background refresh * Cache-first + SWR: the tab observes this row and a background refresh
* overwrites it, so the freshest plays land without a blocking spinner. * overwrites it, so the freshest plays land without a blocking spinner.
@@ -5,8 +5,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* Per-item row driving the Home screen sections. Mirrors * Per-item row driving the Home screen sections.
* the Flutter client's `CachedHomeIndex` Drift table.
* *
* `section` is one of (matching /api/home keys): * `section` is one of (matching /api/home keys):
* - "recently_added_albums" * - "recently_added_albums"
@@ -5,14 +5,13 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* Like membership row. Mirrors the Flutter client's * Like membership row. Composite primary key — one user may
* `CachedLikes` Drift table. Composite primary key — one user may
* independently like a track AND its album AND its artist; rows are * independently like a track AND its album AND its artist; rows are
* disambiguated by the (userId, entityType, entityId) triple. * disambiguated by the (userId, entityType, entityId) triple.
* *
* `entityType` is one of "track" | "album" | "artist". Stored as a * `entityType` is one of "track" | "album" | "artist". Stored as a
* plain string for parity with the Drift schema and the server's wire * plain string to match the server's wire format; an enum doesn't
* format; an enum doesn't earn its keep here. * earn its keep here.
*/ */
@Entity( @Entity(
tableName = "cached_likes", tableName = "cached_likes",
@@ -6,12 +6,11 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* One row per pending offline-write. Mirrors * One row per pending offline write.
* the Flutter client's `CachedMutations` Drift table.
* *
* MutationQueue.enqueue() inserts a row when a server-write fails with * MutationQueue.enqueue() inserts a row when a server-write fails with
* an IOException; MutationReplayer.drain() pops and re-attempts each * 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 * `kind` is a stable string registered in `MutationKind` — the replayer
* looks up the handler in a kind→suspend-function map. Unknown kinds * looks up the handler in a kind→suspend-function map. Unknown kinds
@@ -5,7 +5,7 @@ import androidx.room.PrimaryKey
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* One notice from the user's inbox (M489), kept so the Notifications screen * 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. * 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. * The newest page is cached; older notices are the server's to keep.
*/ */
@@ -4,7 +4,7 @@ import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
/** /**
* Single-row copy of the user's notification settings (M489), stored as the * 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. * wire JSON, so the settings screen opens offline and a toggle shows at once.
*/ */
@Entity(tableName = "cached_notification_settings") @Entity(tableName = "cached_notification_settings")
@@ -6,8 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* Cache row for one playlist (user or system). Mirrors * Cache row for one playlist (user or system).
* the Flutter client's `CachedPlaylists` Drift table.
* *
* `systemVariant` is null for user playlists and one of * `systemVariant` is null for user playlists and one of
* "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc. * "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc.
@@ -3,8 +3,7 @@ package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity import androidx.room.Entity
/** /**
* Ordered membership of tracks within a playlist. Mirrors * Ordered membership of tracks within a playlist.
* the Flutter client's `CachedPlaylistTracks` Drift table.
* Composite PK so the same track can only appear once per playlist; * Composite PK so the same track can only appear once per playlist;
* `position` carries the ordering. * `position` carries the ordering.
*/ */
@@ -6,9 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* The current user's quarantine flag for one track. Mirrors * The current user's quarantine flag for one track.
* the Flutter client's `CachedQuarantineMine` Drift
* table.
* *
* The flat denormalized track/album/artist columns let the Quarantine * The flat denormalized track/album/artist columns let the Quarantine
* screen render off this single table without a join — the server * 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), * Single-row snapshot of the last playback session — queue (as JSON),
* current index, position, and source tag. Mirrors * current index, position, and source tag.
* the Flutter client's `CachedResumeState` Drift table.
* *
* Lets a torn-down session (the player's idle/dismissed teardown) * Lets a torn-down session (the player's idle/dismissed teardown)
* resume on next launch; without it the headset / lock-screen play * resume on next launch; without it the headset / lock-screen play
* button has nothing to act on. The Flutter codebase persists this on * button has nothing to act on.
* every queue change and reads on cold start.
* *
* The `json` field carries the entire ResumeState shape (queue trackrefs, * The `json` field carries the entire ResumeState shape (queue trackrefs,
* currentIndex, positionMs, sourceTag). Kotlin-side deserialization * currentIndex, positionMs, sourceTag). The ResumeController decodes it,
* happens in the ResumeController (Phase 6.5) so the schema stays * so the schema stays stable across resume-shape changes.
* stable across resume-shape changes.
*/ */
@Entity(tableName = "cached_resume_state") @Entity(tableName = "cached_resume_state")
data class CachedResumeStateEntity( data class CachedResumeStateEntity(
@@ -6,10 +6,9 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* Cache row for one track. Mirrors the Flutter client's * Cache row for one track.
* `CachedTracks` Drift table.
* *
* [missing] carries the server's missing-file mark (#2704). Every read that * [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 * 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. * the DAO queries do that rather than each call site remembering to.
*/ */
@@ -26,7 +25,7 @@ data class CachedTrackEntity(
val fileFormat: String? = null, val fileFormat: String? = null,
val genre: String? = null, val genre: String? = null,
val missing: Boolean = false, val missing: Boolean = false,
// ReplayGain 2.0 track values (M464), kept so cached audio levels // ReplayGain 2.0 track values, kept so cached audio levels
// offline. Null until the server has measured the track. // offline. Null until the server has measured the track.
val trackGain: Float? = null, val trackGain: Float? = null,
val trackPeak: Float? = null, val trackPeak: Float? = null,
@@ -4,14 +4,14 @@ import androidx.room.Entity
import androidx.room.PrimaryKey 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 * writes rows here when the account's debug mode is on; the
* DiagnosticsUploader drains them to POST /api/diagnostics and deletes * DiagnosticsUploader drains them to POST /api/diagnostics and deletes
* on success. * on success.
* *
* This is a deliberate ring buffer that is NOT routed through the offline * This is a deliberate ring buffer that is NOT routed through the offline
* MutationQueue: diagnostics are high-volume, best-effort telemetry, and * 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 * offline — so events must persist locally through the dead zone and
* upload on recovery, without clogging the user-data mutation replay. * 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. * Single-row table that tracks where the SyncController left off.
* *
* Mirrors the Flutter Drift `sync_metadata` table. There is only ever * There is only ever one row at `id = 0`; the SyncController upserts it
* one row at `id = 0`; the SyncController upserts on every successful * on every successful `/api/library/sync` call.
* `/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.
*/ */
@Entity(tableName = "sync_metadata") @Entity(tableName = "sync_metadata")
data class SyncMetadataEntity( data class SyncMetadataEntity(
@@ -33,30 +33,29 @@ object MutationKind {
// Durable close-by-id for a play whose server row is already open // Durable close-by-id for a play whose server row is already open
// (play_started succeeded). Distinct from PLAY_OFFLINE, which records // (play_started succeeded). Distinct from PLAY_OFFLINE, which records
// a *whole* play when no server row exists. Using close-by-id on // a *whole* play when no server row exists. Recording a whole play on
// background avoids the duplicate + orphan row the old offline-on-stop // background would duplicate the open row and leave it orphaned.
// path produced (see 2026-06-11 contract audit).
const val PLAY_ENDED: String = "play_ended" 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 // snooze/unsnooze kinds, mirroring LIKE_TOGGLE, so a snooze followed by
// an undo collapses to the latest intent instead of replaying as two // an undo collapses to the latest intent instead of replaying as two
// opposed calls whose order decides the outcome. // opposed calls whose order decides the outcome.
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle" const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
// M464 #4998 loudness-normalization preference. The payload is the whole // Loudness-normalization preference. The payload is the whole
// preference, a target state like the toggles above, so queued changes // 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. // collapse to the last one and an older one can never be replayed last.
const val NORMALIZATION_SET: String = "normalization_set" const val NORMALIZATION_SET: String = "normalization_set"
// M489 notifications inbox. A read is a one-way action (read never goes // Notifications inbox. A read is a one-way action (read never goes
// back to unread), so neither read kind needs collapsing. Read-all // back to unread), so neither read kind needs collapsing. Read-all
// carries the moment the user asked, so a late replay leaves newer // carries the moment the user asked, so a late replay leaves newer
// notices unread. // notices unread.
const val NOTIFICATION_READ: String = "notification_read" const val NOTIFICATION_READ: String = "notification_read"
const val NOTIFICATIONS_READ_ALL: String = "notifications_read_all" const val NOTIFICATIONS_READ_ALL: String = "notifications_read_all"
// M489 per-kind channel setting. One row per (kind, channel) target // Per-kind notification channel. One row per (kind, channel) target
// state, collapsed on that pair, so the newest choice is the one sent. // state, collapsed on that pair, so the newest choice is the one sent.
const val NOTIFICATION_SETTING_SET: String = "notification_setting_set" const val NOTIFICATION_SETTING_SET: String = "notification_setting_set"
} }
@@ -92,16 +91,13 @@ data class RequestCreatePayload(
) )
/** /**
* Minimal write-side of the offline mutation queue. Phase 8 v1 only * Write side of the offline mutation queue; [MutationReplayer] drains it.
* enqueues — the connectivity-aware replayer that drains the queue
* lands with Phase 12.2's SyncController.
* *
* Callers (LikesRepository et al.) follow the optimistic-write * Callers (LikesRepository et al.) follow the optimistic-write
* pattern: mutate Room first so the UI reflects the change * pattern: mutate Room first so the UI reflects the change
* immediately, fire the REST call as a best-effort attempt, and on * immediately, fire the REST call as a best-effort attempt, and on
* failure enqueue here so the replayer eventually pushes it through. * failure enqueue here so the replayer eventually pushes it through.
* This matches `feedback_offline_first_for_server_writes` — writes * Writes never go fire-and-forget.
* never go fire-and-forget.
*/ */
@Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers @Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers
@Singleton @Singleton
@@ -262,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 * `desiredSnoozed` is the *target* state, matching [LikeTogglePayload], so
* the replayer can collapse repeated toggles for one candidate down to the * the replayer can collapse repeated toggles for one candidate down to the
* last intent. Both directions are idempotent server-side: re-snoozing * last intent. Both directions are idempotent server-side: re-snoozing
@@ -285,10 +281,10 @@ data class SuggestionSnoozeTogglePayload(
data class QuarantineUnflagPayload(val trackId: String) data class QuarantineUnflagPayload(val trackId: String)
/** /**
* Persisted payload for `MutationKind.PLAYLIST_APPEND`. Mirrors the * Persisted payload for `MutationKind.PLAYLIST_APPEND`: the track IDs to
* Flutter wire shape: list of track IDs to append to a specific * append to one playlist. The server is idempotent at the
* playlist. Server is idempotent at the (playlist_id, track_id) pair * (playlist_id, track_id) pair level — re-firing the same payload yields
* level — re-firing the same payload yields no duplicates. * no duplicates.
*/ */
@Serializable @Serializable
data class PlaylistAppendPayload( data class PlaylistAppendPayload(
@@ -314,7 +310,7 @@ data class QuarantineFlagPayload(
* re-fires `POST /api/events` with type=play_offline; the server * re-fires `POST /api/events` with type=play_offline; the server
* records start+end from `atIso` (the original play-start time) * records start+end from `atIso` (the original play-start time)
* + `durationPlayedMs`, applies the canonical skip rule, and * + `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 @Serializable
data class PlayOfflinePayload( data class PlayOfflinePayload(
@@ -323,7 +319,7 @@ data class PlayOfflinePayload(
val atIso: String, val atIso: String,
val durationPlayedMs: Long, val durationPlayedMs: Long,
val source: String? = null, 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). // before this field existed (decodes to null → server stores NULL).
val deviceClass: String? = null, val deviceClass: String? = null,
) )
@@ -367,12 +363,12 @@ data class PlaybackErrorReportPayload(
val clientId: String, val clientId: String,
) )
/** Persisted payload for `MutationKind.NOTIFICATION_READ` (M489). */ /** Persisted payload for `MutationKind.NOTIFICATION_READ`. */
@Serializable @Serializable
data class NotificationReadPayload(val id: String) data class NotificationReadPayload(val id: String)
/** /**
* Persisted payload for `MutationKind.NOTIFICATIONS_READ_ALL` (M489). * Persisted payload for `MutationKind.NOTIFICATIONS_READ_ALL`.
* `upToIso` is the newest notice the user could see when they asked; null * `upToIso` is the newest notice the user could see when they asked; null
* when the inbox was empty on the device, which marks everything. * when the inbox was empty on the device, which marks everything.
*/ */
@@ -380,7 +376,7 @@ data class NotificationReadPayload(val id: String)
data class NotificationsReadAllPayload(val upToIso: String?) data class NotificationsReadAllPayload(val upToIso: String?)
/** /**
* Persisted payload for `MutationKind.NOTIFICATION_SETTING_SET` (M489): one * Persisted payload for `MutationKind.NOTIFICATION_SETTING_SET`: one
* kind's one channel, as a target state. `channel` is "inbox" | "phone" | * kind's one channel, as a target state. `channel` is "inbox" | "phone" |
* "email". No defaults, so every field is always written. * "email". No defaults, so every field is always written.
*/ */
@@ -160,7 +160,7 @@ class MutationReplayer @Inject constructor(
/** /**
* Returns the outcome of replaying one row. SENT on a 2xx; DROP for an * Returns the outcome of replaying one row. SENT on a 2xx; DROP for an
* unknown kind/variant. Network / HTTP failures THROW and are classified * 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) { private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) {
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload) MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
@@ -182,7 +182,7 @@ class MutationReplayer @Inject constructor(
else -> Outcome.DROP else -> Outcome.DROP
} }
/** The notifications inbox's kinds (M489), split out to keep [dispatch] simple. */ /** The notifications inbox's kinds, split out to keep [dispatch] simple. */
private suspend fun dispatchNotification(row: CachedMutationEntity): Outcome = when (row.kind) { private suspend fun dispatchNotification(row: CachedMutationEntity): Outcome = when (row.kind) {
MutationKind.NOTIFICATION_READ -> dispatchNotificationRead(row.payload) MutationKind.NOTIFICATION_READ -> dispatchNotificationRead(row.payload)
MutationKind.NOTIFICATIONS_READ_ALL -> dispatchNotificationsReadAll(row.payload) MutationKind.NOTIFICATIONS_READ_ALL -> dispatchNotificationsReadAll(row.payload)
@@ -354,8 +354,8 @@ class MutationReplayer @Inject constructor(
/** /**
* Row ids of desired-state toggles superseded by a later toggle for the same * 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 * entity. Applies to every kind whose payload encodes a TARGET state rather
* than an action — like-toggles, suggestion snoozes (#2374) and the * than an action — like-toggles, suggestion snoozes and the
* normalization preference (#4998) — because * normalization preference — because
* replaying a stale one last would invert the final state. * 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 * Top-level and pure so it can be unit-tested without standing up a Retrofit
@@ -34,9 +34,8 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Drives the delta sync against `/api/library/sync` (#357). Mirrors * Drives the delta sync against `/api/library/sync`, scoped to the
* the Flutter `SyncController`, scoped to the entity types the * entity types the app caches (artist/album/track). Likes and
* native client cares about (artist/album/track). Likes and
* playlists get their own dedicated refresh paths so the sync * playlists get their own dedicated refresh paths so the sync
* controller doesn't need to track them. * 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 * with a persisted cookie). Re-running on every cookie change is
* what makes the Library tabs populate within seconds of sign-in. * what makes the Library tabs populate within seconds of sign-in.
* *
* Wipes-on-410: when the server's compaction window has passed the * Restart-on-410: when the server's compaction window has passed the
* stored cursor, the response is 410 Gone. The controller drops the * stored cursor, the response is 410 Gone. The controller resets the
* three local caches and recurses with cursor=0 — same wipe behavior * cursor to 0 and pulls the full entity set again (see [resetCursor]).
* as the Flutter port.
*/ */
@Singleton @Singleton
class SyncController @Inject constructor( 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() { 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 meta = syncMetadataDao.get()
val cursor = meta?.cursor ?: 0L val cursor = meta?.cursor ?: 0L
val response = api.sync(cursor) val response = api.sync(cursor)
when (response.code()) { return when (response.code()) {
HTTP_NO_CONTENT -> { HTTP_NO_CONTENT -> {
touchLastSyncAt(cursor) touchLastSyncAt(cursor)
false
} }
HTTP_GONE -> { HTTP_GONE -> {
wipeAndRetry() resetCursor()
true
} }
HTTP_OK -> { HTTP_OK -> {
val body = response.body() ?: return val body = response.body() ?: return false
applyDeltas(body.upserts.artist, body.upserts.album, body.upserts.track) applyDeltas(body.upserts.artist, body.upserts.album, body.upserts.track)
applyDeletes(body.deletes.artist, body.deletes.album, body.deletes.track) applyDeletes(body.deletes.artist, body.deletes.album, body.deletes.track)
syncMetadataDao.upsert( syncMetadataDao.upsert(
@@ -131,10 +146,14 @@ class SyncController @Inject constructor(
lastSyncAt = Clock.System.now(), 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 -> { 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. // 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 // The Library DAOs only expose deleteByIds; for the rare 410
// path we re-issue the same request with cursor=0 after // path the next page is requested with cursor=0 after
// resetting our cursor. The next response carries the full // resetting our cursor. The following pages carry the full
// entity set, which upsertAll overwrites the existing rows // entity set, which upsertAll overwrites the existing rows
// with — old rows for entities the server no longer knows // with — old rows for entities the server no longer knows
// about will linger until a later 410 or until the user clears // about linger until MetadataProvider's refresh meets their
// app data. Acceptable for v1; a future "wipe all" DAO method // 404 and drops them.
// tightens this up.
syncMetadataDao.upsert( syncMetadataDao.upsert(
SyncMetadataEntity(id = 0, cursor = 0, lastSyncAt = null), SyncMetadataEntity(id = 0, cursor = 0, lastSyncAt = null),
) )
sync()
} }
private companion object { private companion object {
const val HTTP_OK = 200 const val HTTP_OK = 200
const val HTTP_NO_CONTENT = 204 const val HTTP_NO_CONTENT = 204
const val HTTP_GONE = 410 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
} }
} }
@@ -74,6 +74,35 @@ class ConnectivityObserver @Inject constructor(
awaitClose { cm.unregisterNetworkCallback(callback) } awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged() }.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 { private fun hasActiveNetwork(): Boolean {
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) } val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
return caps != null && return caps != null &&
@@ -29,27 +29,25 @@ private const val POLL_DEGRADED_MS = 20_000L
private const val ARBITRATE_MIN_GAP_MS = 2_000L private const val ARBITRATE_MIN_GAP_MS = 2_000L
/** /**
* THE single authority on network/server reachability. Absorbs the former * THE single authority on network/server reachability: the /healthz poll,
* VersionCheckController (the /healthz poll + version parsing) and * version parsing and the tri-state health derive live in one
* ServerHealthController (the tri-state derive) into one signal-driven unit so * signal-driven unit, so the app has one place that answers "can we reach
* the app has one place that answers "can we reach Minstrel?" — not three * Minstrel?".
* half-systems reporting differently.
* *
* Inputs (all funnel through a single-consumer intent channel for thread * Inputs (all funnel through a single-consumer intent channel for thread
* safety — [reportSuccess]/[reportFailure] are called from the audio read path * safety — [reportSuccess]/[reportFailure] are called from the audio read path
* and OkHttp threads): * and OkHttp threads):
* - device link transitions ([ConnectivityObserver]); link-return triggers an * - device link transitions ([ConnectivityObserver]); link-return triggers an
* immediate probe so recovery is near-instant (fixes the sticky banner). * immediate probe so recovery is near-instant.
* - periodic /healthz probe, adaptive cadence (calm when Healthy, fast when down). * - periodic /healthz probe, adaptive cadence (calm when Healthy, fast when down).
* - reportSuccess / reportFailure from the API interceptor, the audio data * - reportSuccess / reportFailure from the API interceptor, the audio data
* source, and the playback-error reporter. * source, and the playback-error reporter.
* - recheck() from pull-to-refresh and the banner. * - recheck() from pull-to-refresh and the banner.
* - a forced probe when the app returns to the foreground (#1209). Without * - a forced probe when the app returns to the foreground. The poll
* it a stale ServerDown outlived the condition that caused it: the poll * loop's delay() is throttled while screen-off/doze, so without it a
* loop's delay() is throttled while screen-off/doze, so recovery waited on * stale ServerDown would outlive its cause until the OS next let the
* whenever the OS next let the loop run. Meanwhile ServerDown makes * loop run — and ServerDown makes OfflineGatedDataSource refuse every
* OfflineGatedDataSource refuse every uncached track, so the app declined * uncached track, declining music that would play fine.
* to play music that would have played fine.
* *
* Version compatibility is a byproduct of the same /healthz response. * Version compatibility is a byproduct of the same /healthz response.
* *
@@ -112,7 +110,7 @@ class NetworkStatusController @Inject constructor(
/** /**
* App returned to the foreground — probe now rather than waiting for the * App returned to the foreground — probe now rather than waiting for the
* poll loop (#1209). * poll loop.
* *
* The link-return probe in `init` does NOT cover this: it fires on a * The link-return probe in `init` does NOT cover this: it fires on a
* connectivity *change*, and an app backgrounded on stable Wi-Fi sees none. * connectivity *change*, and an app backgrounded on stable Wi-Fi sees none.
@@ -172,7 +170,7 @@ class NetworkStatusController @Inject constructor(
private suspend fun probeOnce(force: Boolean = false) { private suspend fun probeOnce(force: Boolean = false) {
// Startup guard: don't poll the localhost placeholder before AuthStore // Startup guard: don't poll the localhost placeholder before AuthStore
// hydrates the real base URL — that false failure used to flash the banner. // hydrates the real base URL — that false failure would flash the banner.
if (authStore.baseUrl.value == AuthStore.DEFAULT_BASE_URL) return if (authStore.baseUrl.value == AuthStore.DEFAULT_BASE_URL) return
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (!force && now - lastProbeAtMs.get() < ARBITRATE_MIN_GAP_MS) return if (!force && now - lastProbeAtMs.get() < ARBITRATE_MIN_GAP_MS) return
@@ -5,15 +5,14 @@ internal const val CORROBORATION_WINDOW_MS = 30_000L
internal const val CORROBORATION_OP_THRESHOLD = 2 internal const val CORROBORATION_OP_THRESHOLD = 2
/** /**
* Minimum gap between op failures for them to count as SEPARATE evidence * Minimum gap between op failures for them to count as SEPARATE evidence.
* (#1209).
* *
* A link handoff fails every in-flight request at once, so a burst is one * 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 * event producing N failures — not N independent observations that the server
* is gone. Without this, two simultaneous failures corroborated each other * is gone. Counted separately, two simultaneous failures would corroborate
* straight to Unreachable, and ServerDown makes OfflineGatedDataSource refuse * each other straight to Unreachable, and ServerDown makes
* every uncached track. The app declined to play music that would have played * OfflineGatedDataSource refuse every uncached track: the app would decline
* fine, for a blip that had already resolved. * music that plays fine, for a blip that had already resolved.
* *
* 3s is comfortably above the sub-second window an OS handoff occupies while * 3s is comfortably above the sub-second window an OS handoff occupies while
* still letting a genuine outage corroborate within seconds once a client * still letting a genuine outage corroborate within seconds once a client
@@ -38,7 +38,7 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* On-device diagnostics collector (M9). Active only while the account's * On-device diagnostics collector. Active only while the account's
* debug mode is enabled (server flag, read via AuthController.currentUser) * debug mode is enabled (server flag, read via AuthController.currentUser)
* AND the user hasn't opted out locally. When active it taps existing * AND the user hasn't opted out locally. When active it taps existing
* connectivity / player / UPnP / power seams READ-ONLY and writes a * connectivity / player / UPnP / power seams READ-ONLY and writes a
@@ -196,13 +196,11 @@ class DiagnosticsReporter @Inject constructor(
/** /**
* Catch the renderer rapidly leaving and re-entering PLAYING. * Catch the renderer rapidly leaving and re-entering PLAYING.
* *
* The operator reports the Sonos "play pause play pause, like someone * The symptom: a Sonos rapidly play-pause-play-pausing, about every half
* pressing it every half second", usually as a track starts, cleared by a * second, usually as a track starts, cleared by a manual pause or skip.
* manual pause or skip. Nothing here could see that: `player_state` * No other collector sees it: `player_state` carries
* carries source/loading/error but not playing, `track_change` needs the * source/loading/error but not playing, `track_change` needs the index
* index to move, and the heartbeat samples once per 45s. The symptom fell * to move, and the heartbeat samples once per 45s.
* through every existing collector, which is why it has only ever been
* described and never measured.
* *
* Records every raw transport change (cheap — steady playback produces * Records every raw transport change (cheap — steady playback produces
* a couple per track) and, when they come in a burst, one summary event * a couple per track) and, when they come in a burst, one summary event
@@ -27,7 +27,7 @@ import javax.inject.Singleton
/** /**
* Drains the [DiagnosticEventDao] ring buffer to POST /api/diagnostics * Drains the [DiagnosticEventDao] ring buffer to POST /api/diagnostics
* (M9). Triggers mirror [com.fabledsword.minstrel.cache.mutations.MutationReplayer]: *. Triggers mirror [com.fabledsword.minstrel.cache.mutations.MutationReplayer]:
* a periodic tick, a sign-in (sessionCookie non-null), and a server-health * a periodic tick, a sign-in (sessionCookie non-null), and a server-health
* recovery to [ServerHealth.Healthy] — so a dead-zone's buffered events * recovery to [ServerHealth.Healthy] — so a dead-zone's buffered events
* upload the moment the link comes back. * upload the moment the link comes back.
@@ -70,6 +70,9 @@ class DiagnosticsUploader @Inject constructor(
/** Coalesces concurrent triggers via tryLock — a drain in flight wins. */ /** Coalesces concurrent triggers via tryLock — a drain in flight wins. */
private suspend fun drainSafe() { private suspend fun drainSafe() {
// A trigger at launch can fire before the stored login has loaded;
// wait for it, so a signed-in user is not read as signed out.
authStore.awaitSessionHydrated()
val clientId = authStore.clientId.value val clientId = authStore.clientId.value
// Signed out / no client id yet → nothing to do. Single guard keeps // Signed out / no client id yet → nothing to do. Single guard keeps
// the return count within the detekt gate. // the return count within the detekt gate.
@@ -6,10 +6,9 @@ import com.fabledsword.minstrel.player.TransportObservation
* Decides when a run of renderer transport changes is a *flap* — the renderer * Decides when a run of renderer transport changes is a *flap* — the renderer
* repeatedly failing to settle — rather than an ordinary track transition. * repeatedly failing to settle — rather than an ordinary track transition.
* *
* The operator reports the Sonos "play pause play pause, like someone pressing * The symptom: a Sonos rapidly play-pause-play-pausing, about every half
* it every half second", usually as a track starts. No diagnostic event could * second, usually as a track starts. No other diagnostic event records it;
* see it, so it has been described several times and measured never. This is * this is the rule that decides when an episode is worth writing down.
* the rule that decides when an episode is worth writing down.
* *
* Pure decision state, like [com.fabledsword.minstrel.player.RemoteStallWatchdog]: * Pure decision state, like [com.fabledsword.minstrel.player.RemoteStallWatchdog]:
* the caller owns the flow and the recording, this only answers "is this an * the caller owns the flow and the recording, this only answers "is this an
@@ -29,16 +29,11 @@ enum class RequestOutcome { ACCEPTED, QUEUED }
/** /**
* Read-through Discover surface: catalogue search, suggestion feed, and * Read-through Discover surface: catalogue search, suggestion feed, and
* the request-creation write path. Mirrors the Flutter * the request-creation write path. Writes never go fire-and-forget, so
* `DiscoverApi` + `mutationQueueProvider` integration — * `createRequest` falls back to the MutationQueue on transport failure.
* `feedback_offline_first_for_server_writes` says writes never go
* fire-and-forget, so `createRequest` falls back to the
* MutationQueue on transport failure.
* *
* Reads are not cached locally yet — discovery results are inherently * Reads are not cached locally — discovery results are inherently
* transient (60s server-side LRU); the cache wins are minor and * transient (60s server-side LRU), so a local cache would win little.
* unblock no UX. Phase 13's offline pass adds an offline scrollback
* for the suggestion feed only.
*/ */
@Singleton @Singleton
class DiscoverRepository @Inject constructor( class DiscoverRepository @Inject constructor(
@@ -54,7 +49,7 @@ class DiscoverRepository @Inject constructor(
api.listSnoozes().map { it.toDomain() } api.listSnoozes().map { it.toDomain() }
/** /**
* Parks a suggestion ("not right now"). Offline-first per rule #100: on * Parks a suggestion ("not right now"). Offline-first: on
* transport failure the target state is queued for the replayer rather * transport failure the target state is queued for the replayer rather
* than dropped. * than dropped.
* *
@@ -195,8 +195,8 @@ private fun KindChips(kind: RequestKind, onChange: (RequestKind) -> Unit) {
/** /**
* The snooze surface's data and callbacks, bundled rather than threaded * The snooze surface's data and callbacks, bundled rather than threaded
* through as four more parameters — the pane grew from one action to three * through as separate parameters: the pane carries three actions, and the
* with slice 4 and the signatures stopped being readable. * signatures stop being readable otherwise.
*/ */
private data class SnoozeUi( private data class SnoozeUi(
val locallySnoozedMbids: Set<String>, val locallySnoozedMbids: Set<String>,
@@ -240,9 +240,9 @@ private fun SuggestionsList(
) { ) {
item { SuggestionsHeader() } item { SuggestionsHeader() }
if (items.isEmpty()) { if (items.isEmpty()) {
// An empty deck used to mean one thing — no listening signal yet. // An empty deck means one of two things — no listening signal yet,
// With snoozing it can also mean "you parked them all", and telling // or "you parked them all" — and telling the second user to go
// that user to go listen to something would be wrong advice. // listen to something would be wrong advice.
item { item {
CenteredMessage( CenteredMessage(
if (snoozeUi.snoozes.isEmpty()) { if (snoozeUi.snoozes.isEmpty()) {
@@ -79,8 +79,8 @@ internal fun SuggestionTile(
IconButton(onClick = onSnooze) { IconButton(onClick = onSnooze) {
Icon( Icon(
imageVector = Lucide.Clock, imageVector = Lucide.Clock,
// Rule #101: the label states what happens, and passes no // The label states what happens and passes no judgement on the
// judgement on the music. Never "not for me". // music: never "not for me".
contentDescription = "Not right now — hide ${s.name} for a while", contentDescription = "Not right now — hide ${s.name} for a while",
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -173,7 +173,7 @@ private fun Avatar(imageUrl: String, fallback: ImageVector, circular: Boolean) {
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
// The server hands back its own art endpoint (relative, M502 #5519), // The server hands back its own art endpoint (relative),
// which answers 404 when no source has an image: ServerImage resolves // which answers 404 when no source has an image: ServerImage resolves
// the path and shows the icon while loading and on a miss. // the path and shows the icon while loading and on a miss.
ServerImage( ServerImage(
@@ -68,7 +68,7 @@ class DiscoverViewModel @Inject constructor(
init { init {
loadSuggestions() loadSuggestions()
// Screen-level auto-recovery (issue #1245): re-run whichever pane // Screen-level auto-recovery: re-run whichever pane
// failed while the server was unreachable once health returns. // failed while the server was unreachable once health returns.
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { networkStatus.recoveries().collect {
@@ -54,7 +54,7 @@ private const val EVENTS_BUFFER_CAPACITY = 64
* retry. Without this a single blip silently kills cross-device * retry. Without this a single blip silently kills cross-device
* reactivity until the next app launch. * reactivity until the next app launch.
* - [connected] says whether a stream is open. Background delivery * - [connected] says whether a stream is open. Background delivery
* (M489 #5347) catches up on every rising edge: nothing replays a * catches up on every rising edge: nothing replays a
* frame sent while the stream was down. * frame sent while the stream was down.
* *
* The URL passes through the placeholder host that * The URL passes through the placeholder host that
@@ -4,8 +4,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
/** /**
* Parsed event from the server's SSE stream. Mirrors * Parsed event from the server's SSE stream.
* the Flutter client's `LiveEvent`.
* *
* - [kind] is the SSE `event:` field (e.g. "track.liked", "playlist.deleted"). * - [kind] is the SSE `event:` field (e.g. "track.liked", "playlist.deleted").
* - [userId] is the actor whose user-scoped state changed (empty for * - [userId] is the actor whose user-scoped state changed (empty for
@@ -51,7 +51,7 @@ class LiveEventsDispatcher @Inject constructor(
"artist.liked", "artist.liked",
"artist.unliked", "artist.unliked",
-> refreshLikes() -> refreshLikes()
// M489: a contentless nudge; the inbox refetches its newest page. // a contentless nudge; the inbox refetches its newest page.
"notification.created" -> refreshNotifications() "notification.created" -> refreshNotifications()
} }
// Other kinds (playlist.*, quarantine.*, request.status_changed, // Other kinds (playlist.*, quarantine.*, request.status_changed,
@@ -3,14 +3,14 @@ package com.fabledsword.minstrel.events
import kotlin.random.Random import kotlin.random.Random
/** /**
* How long [EventsStream] waits before reconnecting (M489 #5347). * How long [EventsStream] waits before reconnecting.
* *
* The stream is wanted around the clock once notifications arrive with the app * The stream is wanted around the clock once notifications arrive with the app
* closed, so a server that is down overnight must not cost a radio wakeup * closed, so a server that is down overnight must not cost a radio wakeup
* every few seconds: Roundtable's flat 2s came to about 43,000 of them. The * every few seconds: a flat 2s retry is 1,800 wakeups an hour. The wait
* wait doubles from [BASE_MS] to [MAX_MS]. The jitter spreads the moment every * doubles from [BASE_MS] to [MAX_MS]. The jitter spreads the moment every
* phone on the server reconnects, which is when the server has just come back * phone on the server reconnects, which is when the server has just come
* and can least take a spike. * back and can least take a spike.
* *
* A network coming up (a hint, never a gate) or the app coming to the * A network coming up (a hint, never a gate) or the app coming to the
* foreground reconnects at once and starts the ladder over. * foreground reconnects at once and starts the ladder over.
@@ -33,9 +33,7 @@ data class HistoryPage(
) )
/** /**
* Cache-first listening history. Mirrors Flutter's `_historyProvider` * Cache-first listening history, in the Room + Flow idiom:
* (`cacheFirst` over the `cached_history_snapshot` drift row with
* `alwaysRefresh`) using the native Room + Flow idiom:
* *
* - [observeHistory] emits the cached snapshot instantly (null until * - [observeHistory] emits the cached snapshot instantly (null until
* the first fetch lands), so the History tab paints from disk on * the first fetch lands), so the History tab paints from disk on
@@ -47,8 +45,8 @@ data class HistoryPage(
* *
* The whole page is stored as one JSON blob (single-row table) rather * The whole page is stored as one JSON blob (single-row table) rather
* than per-row Room entities — history is a read-only timestamp-keyed * than per-row Room entities — history is a read-only timestamp-keyed
* snapshot, so a blob is the simplest faithful mirror of the Drift * snapshot, so a blob is the simplest faithful copy and avoids
* snapshot and avoids ordering/paging bookkeeping for no UX gain. * ordering/paging bookkeeping for no UX gain.
*/ */
@Singleton @Singleton
class HistoryRepository @Inject constructor( class HistoryRepository @Inject constructor(
@@ -86,7 +86,7 @@ class HistoryTabViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): re-pull when server // Screen-level auto-recovery: re-pull when server
// health returns, so a load that failed offline heals unprompted. // health returns, so a load that failed offline heals unprompted.
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { refresh() } networkStatus.recoveries().collect { refresh() }
@@ -99,7 +99,7 @@ class HistoryTabViewModel @Inject constructor(
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) } .onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
} }
/** Single-track play, matching the Flutter history-row behavior. */ /** Plays the tapped track on its own. */
fun playTrack(track: TrackRef) { fun playTrack(track: TrackRef) {
if (track.streamUrl.isEmpty()) return if (track.streamUrl.isEmpty()) return
player.setQueue(listOf(track), initialIndex = 0, source = "history") player.setQueue(listOf(track), initialIndex = 0, source = "history")
@@ -33,8 +33,7 @@ import javax.inject.Singleton
* [MetadataProvider] Flow that emits the cached row (or null while it * [MetadataProvider] Flow that emits the cached row (or null while it
* hydrates) and fires a dedup'd, concurrency-gated on-miss fetch. A * hydrates) and fires a dedup'd, concurrency-gated on-miss fetch. A
* null [HomeTile.value] renders as a skeleton in position; the tile * null [HomeTile.value] renders as a skeleton in position; the tile
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's * reveals when the fetch lands and Room re-emits.
* per-item tile providers.
* *
* `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in * `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in
* one transaction (so the rows update together in a single emission * one transaction (so the rows update together in a single emission
@@ -99,8 +98,8 @@ class HomeRepository @Inject constructor(
* One transaction for all seven sections is deliberate: Room notifies * One transaction for all seven sections is deliberate: Room notifies
* observers once, on commit, so Home swaps from the old content to * observers once, on commit, so Home swaps from the old content to
* the new in a single emission. Per-section, un-transacted writes * the new in a single emission. Per-section, un-transacted writes
* made every row visibly collapse to empty and refill, one after * would make every row visibly collapse to empty and refill, one
* another (issue #2327). * after another.
*/ */
suspend fun refreshIndex() { suspend fun refreshIndex() {
val wire = api.getHomeIndex() val wire = api.getHomeIndex()
@@ -141,7 +140,7 @@ class HomeRepository @Inject constructor(
* the id list instead means a section whose contents didn't actually * the id list instead means a section whose contents didn't actually
* move never restarts the `flatMapLatest` below, which would * move never restarts the `flatMapLatest` below, which would
* otherwise tear down and rebuild all of its tiles' hydration flows * otherwise tear down and rebuild all of its tiles' hydration flows
* and flicker unchanged tiles (issue #2327). * and flicker unchanged tiles.
*/ */
private fun observeSectionIds(section: String): Flow<List<String>> = private fun observeSectionIds(section: String): Flow<List<String>> =
homeIndexDao.observeBySection(section) homeIndexDao.observeBySection(section)
@@ -102,6 +102,8 @@ import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import com.fabledsword.minstrel.update.ui.UpdateNotices
import com.fabledsword.minstrel.update.ui.UpdateViewModel
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@@ -133,7 +135,7 @@ private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
// "Updating your mixes…" veil. UpdateVeilController decides both whether it // "Updating your mixes…" veil. UpdateVeilController decides both whether it
// appears at all — only when a refresh actually changes something — and how // appears at all — only when a refresh actually changes something — and how
// long it stays, by watching the screen settle rather than by a fixed delay, // long it stays, by watching the screen settle rather than by a fixed delay,
// which lowered it while tiles and artwork were still landing (#2327). // which would lower it while tiles and artwork are still landing.
// Near-opaque (VEIL_ALPHA) so the section churn never bleeds through. // Near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
private const val VEIL_WIPE_MS = 280 private const val VEIL_WIPE_MS = 280
private const val VEIL_ALPHA = 0.96f private const val VEIL_ALPHA = 0.96f
@@ -244,9 +246,8 @@ class HomeViewModel @Inject constructor(
/** /**
* Tap on a Most-played tile: replace the queue with the section's * Tap on a Most-played tile: replace the queue with the section's
* tracks and start at the tapped index. Mirrors Flutter's * tracks and start at the tapped index. Home's section is itself the
* `_resolveSectionTracks` — Home's section is itself the playable * playable unit, not a list of album-detail links.
* unit, not a list of album-detail links.
*/ */
fun playMostPlayed(tracks: List<com.fabledsword.minstrel.models.TrackRef>, startIndex: Int) { fun playMostPlayed(tracks: List<com.fabledsword.minstrel.models.TrackRef>, startIndex: Int) {
if (tracks.isEmpty()) return if (tracks.isEmpty()) return
@@ -259,13 +260,11 @@ class HomeViewModel @Inject constructor(
/** /**
* Tap the album play-button overlay: fetch the album detail and * Tap the album play-button overlay: fetch the album detail and
* play its tracks from the first. Mirrors Flutter's AlbumCard * play its tracks from the first. Suspending so the button's spinner
* `_playAlbum`. Suspending so the button's spinner waits for the * waits for the fetch + queue setup; the inner work is launched in
* fetch + queue setup; the inner work is launched in viewModelScope * viewModelScope so cancellation from leaving the screen mid-fetch
* so cancellation from leaving the screen mid-fetch doesn't kill * doesn't kill playback. Errors surface via the snackbar channel,
* playback. Errors surface via the snackbar channel (Android * like the ArtistDetail play handler.
* improvement over Flutter's silent-fail; matches the existing
* ArtistDetail play handler).
*/ */
suspend fun playAlbum(albumId: String) { suspend fun playAlbum(albumId: String) {
viewModelScope.launch { viewModelScope.launch {
@@ -288,9 +287,8 @@ class HomeViewModel @Inject constructor(
/** /**
* Tap the artist play-button overlay: fetch every track across the * Tap the artist play-button overlay: fetch every track across the
* artist's albums, Fisher-Yates shuffle, play from index 0. Mirrors * artist's albums, Fisher-Yates shuffle, play from index 0. Empty +
* Flutter's ArtistCard `_playArtistShuffle`. Empty + error semantics * error semantics follow [playAlbum].
* follow [playAlbum].
*/ */
suspend fun playArtistShuffled(artistId: String) { suspend fun playArtistShuffled(artistId: String) {
viewModelScope.launch { viewModelScope.launch {
@@ -315,9 +313,8 @@ class HomeViewModel @Inject constructor(
* Tap the playlist play-button overlay: fetch the playlist's tracks * Tap the playlist play-button overlay: fetch the playlist's tracks
* (rotation-aware shuffle for refreshable system playlists, stored * (rotation-aware shuffle for refreshable system playlists, stored
* order for everything else), 8s timeout, play from index 0. * order for everything else), 8s timeout, play from index 0.
* Mirrors Flutter PlaylistCard `_playPlaylist`. Empty-mix and * Empty-mix and timeout get their own copy strings; other errors
* timeout get their own copy strings; other errors fall through to * fall through to [ErrorCopy.fromThrowable].
* [ErrorCopy.fromThrowable].
*/ */
suspend fun playPlaylist(playlist: PlaylistRef) { suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch { viewModelScope.launch {
@@ -366,9 +363,9 @@ class HomeViewModel @Inject constructor(
fun retry() = veil.request(userInitiated = true) fun retry() = veil.request(userInitiated = true)
/** /**
* Manual pull-to-refresh. Goes behind the veil like every other refresh * Manual pull-to-refresh. Goes behind the veil like every other
* (operator call, 2026-07-31: the churn a pull causes is identical to * refresh: a pull churns the screen exactly as the automatic paths
* the automatic paths, and a small spinner didn't hide it). * do, and a small spinner does not hide it.
* *
* Suspends until the veil has taken over OR the session has finished, * Suspends until the veil has taken over OR the session has finished,
* so the pull indicator hands off to exactly one successor: the veil if * so the pull indicator hands off to exactly one successor: the veil if
@@ -488,7 +485,7 @@ class HomeViewModel @Inject constructor(
* Only user-initiated sessions say anything. The same outcome from a * Only user-initiated sessions say anything. The same outcome from a
* background check — the initial load, the 03:00 rebuild, a reconnect — * background check — the initial load, the 03:00 rebuild, a reconnect —
* is noise, and "Already up to date" on every launch would be worse * is noise, and "Already up to date" on every launch would be worse
* than silence (operator's call, 2026-07-31). * than silence.
*/ */
private fun reportRefreshOutcome(result: VeilSessionResult) { private fun reportRefreshOutcome(result: VeilSessionResult) {
if (!result.userInitiated) return if (!result.userInitiated) return
@@ -505,7 +502,7 @@ class HomeViewModel @Inject constructor(
* *
* Raised only when a refresh actually changes what's on screen, and then * Raised only when a refresh actually changes what's on screen, and then
* held until Home settles — tiles hydrated, artwork loaded — instead of * held until Home settles — tiles hydrated, artwork loaded — instead of
* for a fixed delay after the network pull returns (issue #2327). A * for a fixed delay after the network pull returns. A
* refresh that returns what's already cached shows no veil at all; * refresh that returns what's already cached shows no veil at all;
* [reportRefreshOutcome] tells the user instead, if they asked. * [reportRefreshOutcome] tells the user instead, if they asked.
*/ */
@@ -515,9 +512,9 @@ class HomeViewModel @Inject constructor(
// Every refresh path goes through the controller, which decides // Every refresh path goes through the controller, which decides
// per session whether to raise the veil. That includes the initial // per session whether to raise the veil. That includes the initial
// load: over a warm cache it's a full re-pull that churns every // load: over a warm cache it's a full re-pull that churns every
// section, and it used to run completely unveiled. // section.
veil.request() veil.request()
// Screen-level auto-recovery (issue #1245): a Home that failed to // Screen-level auto-recovery: a Home that failed to
// load while the server was unreachable re-pulls itself the moment // load while the server was unreachable re-pulls itself the moment
// health returns — same idiom as SyncController, one layer up. // health returns — same idiom as SyncController, one layer up.
// This is also the recovery that keeps trying after the veil has // This is also the recovery that keeps trying after the veil has
@@ -525,9 +522,8 @@ class HomeViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { veil.request() } networkStatus.recoveries().collect { veil.request() }
} }
// Server-side changes that rewrite what Home renders (#968 and // Server-side changes that rewrite what Home renders re-pull
// the 2026-07-31 widening) re-pull behind the veil. Mirrors the // behind the veil, as the web SSE consumer does.
// web SSE consumer.
viewModelScope.launch { viewModelScope.launch {
eventsStream.events eventsStream.events
.filter { it.kind in VEILED_EVENT_KINDS } .filter { it.kind in VEILED_EVENT_KINDS }
@@ -559,6 +555,7 @@ class HomeViewModel @Inject constructor(
fun HomeScreen( fun HomeScreen(
navController: NavHostController, navController: NavHostController,
viewModel: HomeViewModel = hiltViewModel(), viewModel: HomeViewModel = hiltViewModel(),
updates: UpdateViewModel = hiltViewModel(),
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -581,7 +578,11 @@ fun HomeScreen(
val offline by viewModel.offline.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle()
val updating by viewModel.isUpdating.collectAsStateWithLifecycle() val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
PullToRefreshScaffold( PullToRefreshScaffold(
onRefresh = { viewModel.refreshFromPull() }, onRefresh = {
// A pull asks about a newer app too, so one shows up at once.
updates.check()
viewModel.refreshFromPull()
},
modifier = Modifier.fillMaxSize().padding(inner), modifier = Modifier.fillMaxSize().padding(inner),
) { ) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
@@ -596,6 +597,7 @@ fun HomeScreen(
// hide behind an "Updating your mixes…" wipe that stays up // hide behind an "Updating your mixes…" wipe that stays up
// until the screen has actually stopped moving. // until the screen has actually stopped moving.
UpdatingVeil(visible = updating) UpdatingVeil(visible = updating)
UpdateNotices(updates)
} }
} }
} }
@@ -758,8 +760,7 @@ private fun HomeSuccessContent(
// Always rendered: real system/user playlists, with // Always rendered: real system/user playlists, with
// placeholder cards filling the For You / Discover slots // placeholder cards filling the For You / Discover slots
// that haven't generated yet. When offline, the cache-backed // that haven't generated yet. When offline, the cache-backed
// pool cards lead the row. Songs-like now lives in its own // pool cards lead the row. Songs-like has its own row below.
// dedicated row below (#1491), no longer inside this carousel.
PlaylistsRow( PlaylistsRow(
title = "Playlists", title = "Playlists",
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline), rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
@@ -769,10 +770,9 @@ private fun HomeSuccessContent(
onPlayPlaylist = onPlayPlaylist, onPlayPlaylist = onPlayPlaylist,
) )
} }
// Songs-like is the best-performing surface (#1491) — promoted out // Songs-like gets its own row so it can show a wider spread of
// of the Playlists carousel into its own row so it shows a wider // "Songs like {artist}" mixes than a carousel slot allows. Hidden
// spread of "Songs like {artist}" mixes. Hidden when there's // when there's nothing to show (offline with none cached).
// nothing to show (offline with none cached).
songsLikeSection( songsLikeSection(
playlists = sections.playlists, playlists = sections.playlists,
status = systemStatus, status = systemStatus,
@@ -781,6 +781,13 @@ private fun HomeSuccessContent(
onPlayPool = onPlayPool, onPlayPool = onPlayPool,
onPlayPlaylist = onPlayPlaylist, onPlayPlaylist = onPlayPlaylist,
) )
genreMixSection(
playlists = sections.playlists,
offline = offline,
onPlaylistClick = onPlaylistClick,
onPlayPool = onPlayPool,
onPlayPlaylist = onPlayPlaylist,
)
youMightLikeSection( youMightLikeSection(
albums = sections.youMightLikeAlbums, albums = sections.youMightLikeAlbums,
artists = sections.youMightLikeArtists, artists = sections.youMightLikeArtists,
@@ -805,11 +812,11 @@ private fun HomeSuccessContent(
} }
/** /**
* The dedicated "Songs like…" row (#1491). Reuses [PlaylistsRow]'s card * The dedicated "Songs like…" row. Reuses [PlaylistsRow]'s card
* rendering with a distinct title; hidden entirely when there's nothing * rendering with a distinct title; hidden entirely when there's nothing
* to show (offline with no cached mixes). Online with none generated yet * to show (offline with no cached mixes). Online with none generated yet
* still shows a few placeholders so the building / seed-needed state is * still shows a few placeholders so the building / seed-needed state is
* visible, matching the pre-promotion carousel behavior. * visible.
*/ */
private fun LazyListScope.songsLikeSection( private fun LazyListScope.songsLikeSection(
playlists: List<PlaylistRef>, playlists: List<PlaylistRef>,
@@ -833,6 +840,31 @@ private fun LazyListScope.songsLikeSection(
} }
} }
/**
* The "Your genres" row: one mix per genre the user listens to. Hidden
* when there is nothing to show, built or cached.
*/
private fun LazyListScope.genreMixSection(
playlists: List<PlaylistRef>,
offline: Boolean,
onPlaylistClick: (String) -> Unit,
onPlayPool: (OfflinePoolKind) -> Unit,
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
) {
val rowItems = buildGenreMixRow(playlists, offline)
if (rowItems.isEmpty()) return
item {
PlaylistsRow(
title = "Your genres",
rowItems = rowItems,
offline = offline,
onPlaylistClick = onPlaylistClick,
onPlayPool = onPlayPool,
onPlayPlaylist = onPlayPlaylist,
)
}
}
private fun LazyListScope.recentlyAddedSection( private fun LazyListScope.recentlyAddedSection(
albums: List<HomeTile<AlbumRef>>, albums: List<HomeTile<AlbumRef>>,
onAlbumClick: (String) -> Unit, onAlbumClick: (String) -> Unit,
@@ -1000,7 +1032,7 @@ private fun EmptySection(title: String, body: String) {
/** /**
* Rediscover may have both an albums sub-row and an artists sub-row. When * Rediscover may have both an albums sub-row and an artists sub-row. When
* both are present the artist row gets an empty title so the section * both are present the artist row gets an empty title so the section
* reads as a single visual block — matches the Flutter behavior. * reads as a single visual block.
*/ */
@Composable @Composable
private fun RediscoverBlock( private fun RediscoverBlock(
@@ -1164,18 +1196,12 @@ enum class OfflinePoolKind(val label: String) {
* placeholder otherwise), then the secondary system kinds (deep cuts / * placeholder otherwise), then the secondary system kinds (deep cuts /
* rediscover / new for you / on this day / first listens) when they exist — * rediscover / new for you / on this day / first listens) when they exist —
* no placeholders for these since they're conditional on library shape — then * no placeholders for these since they're conditional on library shape — then
* user-owned playlists. Songs-like has its own dedicated row (#1491) via * user-owned playlists. Songs-like has its own row via [buildSongsLikeRow].
* [buildSongsLikeRow] and no longer appears in this carousel.
* *
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the * Offline: the two cache-backed pools (Recently played, Liked) lead, then the
* same real playlists in curated order but stably partitioned fully-cached * same real playlists in curated order but stably partitioned fully-cached
* first / greyed after, and the "building/pending" placeholders are dropped * first / greyed after, and the "building/pending" placeholders are dropped
* (they need the server to generate, so they're meaningless offline). * (they need the server to generate, so they're meaningless offline).
*
* Diverges from Flutter (the Flutter client
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
* surfaces the secondary kinds on Home. Operator authorized the
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
*/ */
internal fun buildPlaylistsRow( internal fun buildPlaylistsRow(
owned: List<PlaylistRef>, owned: List<PlaylistRef>,
@@ -1204,7 +1230,7 @@ private fun buildOnlineRow(
out += owned.firstOrNull { it.systemVariant == "discover" } out += owned.firstOrNull { it.systemVariant == "discover" }
?.let { PlaylistRowItem.Real(it) } ?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status)) ?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
// Songs-like is no longer here — it has its own dedicated row (#1491). // Songs-like is not here: it has its own row.
for (variant in SECONDARY_SYSTEM_VARIANTS) { for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) } owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
} }
@@ -1213,8 +1239,8 @@ private fun buildOnlineRow(
} }
/** /**
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}" * Builds the dedicated Songs-like row: every "Songs like {artist}" mix
* mixes the server generated, no longer capped to the 3 carousel slots. * the server generated, uncapped.
* *
* Online: every generated mix as a real card; when none exist yet, a few * Online: every generated mix as a real card; when none exist yet, a few
* placeholders so the building / seed-needed state stays visible. * placeholders so the building / seed-needed state stays visible.
@@ -1239,12 +1265,31 @@ internal fun buildSongsLikeRow(
} }
} }
/**
* Builds the "Your genres" row: every genre mix, in name order so the
* shelf reads like a stable channel list. No placeholders — a genre mix
* needs listening history or a genre-tagged library, so none built means
* nothing to show. Offline, fully-cached mixes come first and the rest are
* greyed. Mirrors the web Home's genreMixPlaylists.
*/
internal fun buildGenreMixRow(
owned: List<PlaylistRef>,
offline: Boolean,
): List<PlaylistRowItem> {
val mixes = owned
.filter { it.systemVariant == "genre_mix" }
.sortedBy { it.name.lowercase() }
if (!offline) return mixes.map { PlaylistRowItem.Real(it) }
val (available, greyed) = mixes.partition { !it.unavailableOffline }
return (available + greyed).map { PlaylistRowItem.Real(it) }
}
/** /**
* Curated real-playlist order (system primaries, then secondary, then user). * Curated real-playlist order (system primaries, then secondary, then user).
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and * Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
* only differs by dropping placeholders + partitioning available-first. * only differs by dropping placeholders + partitioning available-first.
* Songs-like is excluded here too — it renders in its own row via * Songs-like is excluded here too — it renders in its own row via
* [buildSongsLikeRow] in both online and offline modes (#1491). * [buildSongsLikeRow] in both online and offline modes.
*/ */
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> { private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
val out = mutableListOf<PlaylistRef>() val out = mutableListOf<PlaylistRef>()
@@ -1266,7 +1311,7 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
// How many "Songs like…" placeholder cards the dedicated row shows while // How many "Songs like…" placeholder cards the dedicated row shows while
// the mixes haven't generated yet (building / seed-needed). Real mixes, // the mixes haven't generated yet (building / seed-needed). Real mixes,
// once generated, are shown in full and no longer capped by this (#1491). // once generated, are shown in full, uncapped.
private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3 private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3
/** /**
@@ -1308,8 +1353,7 @@ private fun ArtistsRow(
/** /**
* Most-played section. Renders compact track tiles (cover + title + artist). * Most-played section. Renders compact track tiles (cover + title + artist).
* Tapping a tile replaces the queue with the entire section starting * Tapping a tile replaces the queue with the entire section starting
* at that tile — mirrors Flutter's `_resolveSectionTracks` behaviour * at that tile — Home sections are themselves playable units.
* where Home sections are themselves playable units.
*/ */
@Composable @Composable
private fun MostPlayedRow( private fun MostPlayedRow(
@@ -1373,12 +1417,8 @@ private const val MOST_PLAYED_TILE_HEIGHT_DP = 56
private const val MOST_PLAYED_COVER_DP = 48 private const val MOST_PLAYED_COVER_DP = 48
// 3 rows of MOST_PLAYED_TILE_HEIGHT_DP + 2 * 8dp inter-row spacing, // 3 rows of MOST_PLAYED_TILE_HEIGHT_DP + 2 * 8dp inter-row spacing,
// rounded up. Mirrors Flutter (`CompactTrackCard` in // rounded up. Horizontal-row cards, much denser than the square
// the Flutter client) which // per-track tiles web uses, so more tracks fit in less space.
// uses a horizontal-row card pattern - much denser than the square
// per-track tiles that web uses (operator request 2026-06-01: "in the
// flutter iteration the tiles were different and smaller so more of
// them could fit in a smaller space").
private const val MOST_PLAYED_GRID_HEIGHT_DP = 200 private const val MOST_PLAYED_GRID_HEIGHT_DP = 200
@Composable @Composable
@@ -23,10 +23,9 @@ import javax.inject.Singleton
* server and upsert into Room — the Flow emissions then propagate * server and upsert into Room — the Flow emissions then propagate
* automatically. * automatically.
* *
* Constructs its own LibraryApi from the shared Retrofit. Per the * Constructs its own LibraryApi from the shared Retrofit: repositories
* "repositories own their interfaces" pattern adopted in Phase 5.1's * own their interfaces (see NetworkModule), so there is no @Provides
* NetworkModule, we don't @Provides per-endpoint Retrofit interfaces * per endpoint — fewer Hilt bindings, locality of reference.
* — fewer Hilt bindings, locality of reference.
*/ */
// LibraryRepository owns reads + refreshes for three entity families // LibraryRepository owns reads + refreshes for three entity families
// (artists / albums / tracks) plus shuffle. Function count scales with // (artists / albums / tracks) plus shuffle. Function count scales with
@@ -46,8 +45,7 @@ class LibraryRepository @Inject constructor(
// Joins each artist to its first cached album (by sort title) so the // Joins each artist to its first cached album (by sort title) so the
// tile cover derives from /api/albums/{id}/cover — cached_artists // tile cover derives from /api/albums/{id}/cover — cached_artists
// stores no cover and sync carries none. Mirrors Flutter's // stores no cover and sync carries none.
// artistTileProvider JOIN.
fun observeArtists(): Flow<List<ArtistRef>> = fun observeArtists(): Flow<List<ArtistRef>> =
combine(artistDao.observeAll(), albumDao.observeAll()) { artists, albums -> combine(artistDao.observeAll(), albumDao.observeAll()) { artists, albums ->
val firstAlbumByArtist = albums val firstAlbumByArtist = albums
@@ -161,13 +159,12 @@ class LibraryRepository @Inject constructor(
suspend fun shuffleLibrary(limit: Int = SHUFFLE_DEFAULT_LIMIT): List<TrackRef> = suspend fun shuffleLibrary(limit: Int = SHUFFLE_DEFAULT_LIMIT): List<TrackRef> =
api.shuffleLibrary(limit = limit).map { it.toDomain() } api.shuffleLibrary(limit = limit).map { it.toDomain() }
// ---- Browse axes (#367 / #2467) ---- // ---- Browse axes ----
// //
// Server-backed rather than cache-first, unlike everything above. The // Server-backed rather than cache-first, unlike everything above: the
// cache mirrors the whole library but includes tracks whose files are // server's index leaves out tracks whose files are missing and is the
// missing, with no flag to spot them (#2704), while the server's index // one the web client shows. See LibraryApi for why the cache's own
// excludes them -- so a locally-derived index would quietly disagree with // missing mark is not used to build it locally.
// the web client's. Revisit when #2704 lands.
/** Genre index, ordered by track count then name (server order). */ /** Genre index, ordered by track count then name (server order). */
suspend fun genres(): List<GenreCount> = suspend fun genres(): List<GenreCount> =
@@ -67,7 +67,7 @@ class AlbumDetailViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): a detail that failed to // Screen-level auto-recovery: a detail that failed to
// load while the server was unreachable re-fetches when health // load while the server was unreachable re-fetches when health
// returns, so coming back to the screen shows content, not an error. // returns, so coming back to the screen shows content, not an error.
viewModelScope.launch { viewModelScope.launch {
@@ -310,9 +310,9 @@ private fun ArtistHeader(
@Composable @Composable
private fun ArtistAvatar(artist: ArtistRef, fallbackAlbumId: String? = null) { private fun ArtistAvatar(artist: ArtistRef, fallbackAlbumId: String? = null) {
// Server coverUrl wins; otherwise mirror the server's "use the // Server coverUrl wins; otherwise use the first loaded album's
// first album's cover" rule via the loaded album list (Flutter // cover, the same rule the server applies. Bare Lucide.User only
// _ArtistAvatar). Bare Lucide.User only when neither exists. // when neither exists.
val model = when { val model = when {
artist.coverUrl.isNotEmpty() -> artist.coverUrl artist.coverUrl.isNotEmpty() -> artist.coverUrl
fallbackAlbumId != null -> albumCoverPath(fallbackAlbumId) fallbackAlbumId != null -> albumCoverPath(fallbackAlbumId)
@@ -72,7 +72,7 @@ class ArtistDetailViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245). From Error, redo the // Screen-level auto-recovery. From Error, redo the
// whole load; from Success, re-pull just the secondary sections — // whole load; from Success, re-pull just the secondary sections —
// their fetch failures are swallowed to emptyList, so a dead zone // their fetch failures are swallowed to emptyList, so a dead zone
// during the first load leaves them permanently absent otherwise. // during the first load leaves them permanently absent otherwise.
@@ -139,10 +139,9 @@ class ArtistDetailViewModel @Inject constructor(
/** /**
* Pull every track across the artist's albums, shuffle, and start * Pull every track across the artist's albums, shuffle, and start
* playing. Mirrors Flutter's artist Play button — pressing Play on * playing. Pressing Play on an artist means "shuffle this artist",
* an artist is implicitly "shuffle this artist", not "play album by * not "play album by album in tracklist order". One network
* album in tracklist order". Network round-trip per click; fine * round-trip per click; fine because the user explicitly tapped Play.
* because the user explicitly tapped Play.
*/ */
fun playArtist() { fun playArtist() {
viewModelScope.launch { viewModelScope.launch {

Some files were not shown because too many files have changed in this diff Show More