Compare commits

...
36 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
175 changed files with 12376 additions and 2028 deletions
+40 -103
View File
@@ -2,9 +2,9 @@ name: release
# Builds and pushes the minstrel container image to the Gitea registry.
#
# push to dev → :dev (freshly-built dev APK bundled)
# push to main → :latest + :<sha> (latest-release APK bundled)
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
# push to dev → :dev (freshly-built dev APK bundled)
# push to main → :latest + :<sha> (freshly-built stable 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)
#
# That is the whole tag map.
@@ -41,6 +41,12 @@ name: release
# image in the same run from the same commit, and the channel cannot go
# stale against its own artifact.
#
# Every branch build carries the app built from its own commit, so a merge to
# 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.
# `v2026.09.10.1432` and `2026.09.10.1432` are the same string, derived from
# the tagged commit's UTC timestamp — so there is no mismatch to reconcile
@@ -56,20 +62,14 @@ name: release
# :latest is updated by every main push AND every tag push, so it always
# reflects the newest blessed image.
#
# APK pipeline: on tag pushes the android-release job builds + signs the
# Android APK and uploads it as a workflow artifact. The image-release
# job declares `needs: android-release`, so the docker image cannot
# start building until the APK is guaranteed-ready — no polling, no
# race, no silent-failure mode. Attaching the APK to the gitea Release is
# its own job (release-assets), behind the test gate below.
#
# :latest always carries an APK. Because every main push also moves
# :latest (not just tags), a main build with no APK would silently strip
# the in-app update channel off :latest until the next release. So on
# non-tag builds image-release pulls the MOST RECENT release's signed APK
# AND 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.
# APK pipeline: on every push — dev, main and tags alike — the
# android-release job builds + signs the Android APK and uploads it as a
# workflow artifact. The image-release job declares `needs: android-release`
# and requires its success, so the docker image cannot start building until
# the APK is guaranteed-ready — no polling, no race, no silent-failure mode,
# 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.
#
# THE GATE. Every verifying lane lives in this file —
# Go (vet, lint, short tests), the Postgres integration suite, the web app
@@ -400,11 +400,14 @@ jobs:
# ------------------------------------------------------------ artifacts --
android-release:
name: Build signed APK (releases and dev)
# Also builds on `dev`, which is what makes a test channel possible at
# all: without it the only way to get a build onto a phone is to cut a
# release, which quietly turns `main` into the staging area.
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
name: Build signed APK
# Builds on every ref, so each channel's image carries the app from its
# own commit: `dev` for the test channel, `main` for stable phones, and a
# tag for the copy attached to its Release.
if: >-
startsWith(github.ref, 'refs/tags/v')
|| github.ref == 'refs/heads/dev'
|| github.ref == 'refs/heads/main'
runs-on: flutter-ci
container:
image: git.fabledsword.com/bvandeusen/ci-android:36
@@ -452,7 +455,7 @@ jobs:
# The derivation lives in ci/version.sh, not here, so it can be
# executed by a test on every push. Anything inline in this file is
# unverifiable until a release is already running.
out="$(ci/version.sh HEAD)"
out="$(ci/version.sh --apk HEAD)"
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
# The channel is a property of the LANE, not of the commit, which is
@@ -601,9 +604,9 @@ jobs:
path: release-apk/
- name: Attach APK to gitea Release
# Tag releases only. A dev build has no Release to hang assets on and
# does not need one — the :dev image bundles the APK, and the server
# serves it from /api/client/apk like any other.
# Tag releases only. A branch build has no Release to hang assets on
# and does not need one — its image bundles the APK, and the server
# serves it from /api/client/apk.
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
@@ -667,10 +670,9 @@ jobs:
image-release:
name: Build + push container image
# Every lane must have SUCCEEDED, each named here. Then the
# APK: tag and dev pushes build one and it must have succeeded; main
# pushes skip android-release and bundle the latest release's APK
# instead, so for main alone a skipped android-release is expected.
# Every lane must have SUCCEEDED, each named here, and so must the APK
# build: every ref bundles the APK built from its own commit, so an
# image without one would move a channel with no app for phones to take.
needs: [go, integration, web, android, govulncheck, android-release]
if: >-
${{
@@ -680,8 +682,7 @@ jobs:
&& needs.web.result == 'success'
&& needs.android.result == 'success'
&& needs.govulncheck.result == 'success'
&& (needs.android-release.result == 'success'
|| (needs.android-release.result == 'skipped' && github.ref == 'refs/heads/main')) }}
&& needs.android-release.result == 'success' }}
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
@@ -747,9 +748,10 @@ jobs:
# publish the moving channel tag and never a commit-addressable
# one.
#
# :latest must move here rather than waiting for the next main
# push, or the channel would carry the PREVIOUS release's APK
# indefinitely — a channel that cannot refresh itself.
# :latest moves here so the server hands out the same APK build
# that is attached to the Release: a phone that installed from the
# 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
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
@@ -782,12 +784,8 @@ jobs:
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
- name: Download signed APK artifact
# Tag and dev pushes — android-release just produced this. Only `main`
# takes the "Bundle latest release APK" path below, because it is the
# one ref that moves a channel without building an APK of its own.
if: >-
steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
# android-release just produced this, on every ref.
if: steps.guard.outputs.ready == 'true'
# Consuming half of the pair: stock download-artifact, which works here for
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
# out of the bundle). v8 runs on node24, which every CI-runner image
@@ -798,9 +796,7 @@ jobs:
path: client/
- name: Stage bundled APK + version sidecar
if: >-
steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
if: steps.guard.outputs.ready == 'true'
shell: bash
env:
# All three pulled from android-release's outputs so the sidecar the
@@ -821,65 +817,6 @@ jobs:
cat client/minstrel.apk.version
ls -lh client/
- name: Bundle latest release APK (non-tag :latest builds)
# Main pushes don't build an APK, but they DO move :latest — so
# without this the in-app update channel would vanish from :latest
# until the next tag. Pull the most-recent release's signed APK and
# 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.
# Re-deriving it from the tagged commit would keep the formula in two
# files that must stay in step, and could only ever recover the name —
# the ordering key is build-time minutes and exists nowhere after that
# build ends.
SIDECAR_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk\.version$' | head -1)" || true
if [ -n "${SIDECAR_URL}" ]; then
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk.version "${SIDECAR_URL}"
cat client/minstrel.apk.version
else
# Releases published before sidecars were attached. Their name is
# still recoverable from the tag, but their ordering key genuinely
# is not — so it is reported ABSENT rather than guessed. A wrong
# key is an install the platform refuses; an absent one just tells
# the client to fall back to comparing names, which is exactly
# what those builds already do.
echo "::notice::release ${TAG} predates the version sidecar — bundling with name only, no ordering key"
printf '{"name":"%s","code":null,"channel":"stable"}\n' "${TAG#v}" > client/minstrel.apk.version
fi
echo "::notice::bundled release APK from ${TAG}"
ls -lh client/
- name: Build and push
if: steps.guard.outputs.ready == 'true'
# --pull: the Dockerfile's base images are floating tags (golang:1.26,
@@ -24,6 +24,7 @@ import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.fabledsword.minstrel.auth.AuthStore
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.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.Login
import com.fabledsword.minstrel.nav.MinstrelNavGraph
import com.fabledsword.minstrel.nav.Notifications
import com.fabledsword.minstrel.nav.NowPlaying
@@ -177,6 +179,7 @@ private fun App(
navController.navigate(route) { launchSingleTop = true }
onOpenedRoute()
}
SignInWhenSessionRejected(gate, navController)
MinstrelNavGraph(
navController = navController,
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
private fun BootSplash() {
Surface(
@@ -8,6 +8,7 @@ import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import com.fabledsword.minstrel.cache.CacheIndexer
import com.fabledsword.minstrel.cache.audiocache.CacheBudgetSync
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.di.ApplicationScope
@@ -19,10 +20,10 @@ import com.fabledsword.minstrel.metadata.FreshnessSweeper
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
import com.fabledsword.minstrel.player.AudioPrefetcher
import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.LikedAudioPinner
import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter
import com.fabledsword.minstrel.player.ResumeController
import com.fabledsword.minstrel.update.data.UpdateBannerController
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
@@ -120,6 +121,21 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer
/**
* Same construct-the-singleton trick — CacheBudgetSync's init block
* feeds the audio cache's evictor the liked set and the Storage caps.
* Without this @Inject the evictor would never arm, and the cache
* would grow without a limit.
*/
@Suppress("unused") @Inject lateinit var cacheBudgetSync: CacheBudgetSync
/**
* Same construct-the-singleton trick — LikedAudioPinner's init block
* downloads liked tracks into the Liked songs budget. Without this
* @Inject, liking a track would not keep it offline.
*/
@Suppress("unused") @Inject lateinit var likedAudioPinner: LikedAudioPinner
/**
* Same construct-the-singleton trick — AudioPrefetcher's init
* block subscribes to PlayerController.uiState +
@@ -138,14 +154,6 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
/**
* Same construct-the-singleton trick — UpdateBannerController polls
* /api/client/version at launch + every 24h and drives the shell's
* soft "update available" banner. Without this @Inject the poll
* loop would never start and the banner would never surface.
*/
@Suppress("unused") @Inject lateinit var updateBannerController: UpdateBannerController
/**
* Same construct-the-singleton trick — FreshnessSweeper's init
* block starts a 30-min sweep loop that re-fetches cached
@@ -8,9 +8,9 @@ import javax.inject.Singleton
/**
* Attaches the session cookie from [AuthStore] to Minstrel-server
* requests, captures Set-Cookie from successful responses (login
* flow), and clears the store on 401 so downstream code can react
* to logout.
* requests (waiting for the stored one to load at launch), captures
* Set-Cookie from successful responses (login flow), and signs out when
* the server refuses the cookie a request carried.
*
* Scoped to the [BaseUrlInterceptor.PLACEHOLDER_HOST] sentinel host
* the same way BaseUrlInterceptor is. The shared OkHttpClient also
@@ -32,7 +32,7 @@ class AuthCookieInterceptor @Inject constructor(
// session. Pass through untouched.
return chain.proceed(original)
}
val cookie = authStore.sessionCookie.value
val cookie = authStore.sessionCookieForRequest()
val request = original.newBuilder().apply {
if (!cookie.isNullOrEmpty()) header("Cookie", cookie)
}.build()
@@ -40,7 +40,11 @@ class AuthCookieInterceptor @Inject constructor(
val response = chain.proceed(request)
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) {
// Capture the first Set-Cookie pair on login; subsequent
// responses that include Set-Cookie keep the store fresh.
@@ -89,8 +89,11 @@ class AuthController @Inject constructor(
* + persisted userJson. Carries account-level flags (debug mode)
* that aren't in the login response. Best-effort: a network failure
* 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() {
authStore.awaitSessionHydrated()
if (!isSignedIn) return
val p = runCatching { meApi.getProfile() }.getOrNull() ?: return
val user = UserRef(
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
@@ -148,6 +149,49 @@ class AuthStore @Inject constructor(
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 {
val legacy = runCatching { dao.get()?.sessionCookie }.getOrNull()
if (cookieTouched) return@withLock
@@ -38,6 +38,15 @@ class AuthGateViewModel @Inject constructor(
private val internal = MutableStateFlow<Any?>(null)
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 {
viewModelScope.launch {
authStore.awaitSessionHydrated()
@@ -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 audio cache; the user's Storage settings override them.
*
* - `likedCapBytes`: cap meant for a protected bucket of liked tracks'
* files. Nothing enforces it yet; SimpleCache evicts by the rolling
* cap alone.
* - `rollingCapBytes`: cap for incidental / auto-prefetch downloads,
* evicted least-recently-used first.
*
* The values are 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,17 +9,17 @@ private const val DEFAULT_PREFETCH_WINDOW = 5
* User-tunable audio cache settings. Persisted as a JSON
* blob on the auth_session single-row table via [AuthStore].
*
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
* unlimited. Nothing enforces it yet: SimpleCache evicts by
* [rollingCapBytes] alone, and the Storage card hides this control.
* - [rollingCapBytes]: budget for incidental / auto-prefetch downloads.
* 0 means unlimited. Effective at next app start (SimpleCache is
* built once per process).
* - [likedCapBytes]: the Liked songs budget, for the cached audio of
* liked tracks. 0 means unlimited.
* - [rollingCapBytes]: the Recently played budget, for everything else
* streamed or prefetched. 0 means unlimited.
* A track that is liked and also recently played counts against Liked
* only. Both caps apply as soon as they change ([TwoBucketCacheEvictor]).
* - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher`
* pre-downloads.
* - [cacheLikedTracks]: when true, liking a track should also pin its
* audio to the liked-cache bucket. Nothing reads it yet, and the
* Storage card hides this control.
* - [cacheLikedTracks]: "Keep liked songs offline". When true,
* `LikedAudioPinner` downloads liked tracks into the Liked budget on
* Wi-Fi, most recently liked first, while the budget has room.
*
* Defaults: 5 GiB per bucket, prefetch=5, cache-liked=true.
*/
@@ -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)
}
}
}
@@ -4,18 +4,10 @@ import androidx.room.TypeConverter
import kotlinx.datetime.Instant
/**
* Source category for a cached audio file in `audio_cache_index`.
*
* - `MANUAL`: user explicitly pinned (e.g. "save for offline")
* - `AUTO_LIKED`: auto-cached because the track is liked
* - `AUTO_PLAYLIST`: auto-cached because it's in a pinned playlist
* - `AUTO_PREFETCH`: pre-warmed by background prefetch heuristics
* - `INCIDENTAL`: cached as a side effect of playback streaming
*
* Eviction priority (first to evict): INCIDENTAL → AUTO_PREFETCH
* → AUTO_PLAYLIST → AUTO_LIKED → MANUAL. Only INCIDENTAL is written
* today, and SimpleCache evicts by plain LRU, so nothing applies the
* order yet.
* How a row reached `audio_cache_index`. `CacheIndexer` writes
* `INCIDENTAL` (the track was played); the other values are stored names
* no writer uses. Eviction does not read this: the audio cache's two
* budgets follow the liked set (see `TwoBucketCacheEvictor`).
*/
enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL }
@@ -4,7 +4,6 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.CacheSource
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
import kotlinx.coroutines.flow.Flow
@@ -32,23 +31,6 @@ interface AudioCacheIndexDao {
@Query("SELECT trackId FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST")
suspend fun trackIdsByRecency(): List<String>
/** Eviction-candidate ordering — oldest lastPlayedAt first within a source. */
@Query(
"SELECT * FROM audio_cache_index " +
"WHERE source = :source " +
"ORDER BY lastPlayedAt ASC NULLS FIRST, cachedAt ASC",
)
suspend fun evictionCandidates(source: CacheSource): List<AudioCacheIndexEntity>
/** Sum of bytes across rows; the rolling/liked bucket caps compare against this. */
@Query("SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index")
suspend fun totalBytes(): Long
@Query(
"SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index WHERE source = :source",
)
suspend fun bytesBySource(source: CacheSource): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: AudioCacheIndexEntity)
@@ -10,12 +10,16 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface CachedLikeDao {
/** All track IDs the user has liked. Audio-cache eviction uses this set. */
/**
* The user's liked track IDs, most recently liked first. The audio
* cache's Liked budget and the liked-audio downloads read this.
*/
@Query(
"SELECT entityId FROM cached_likes " +
"WHERE userId = :userId AND entityType = 'track'",
"WHERE userId = :userId AND entityType = 'track' " +
"ORDER BY likedAt DESC",
)
fun observeLikedTrackIds(userId: String): Flow<List<String>>
fun observeLikedTrackIdsNewestFirst(userId: String): Flow<List<String>>
@Query(
"SELECT entityId FROM cached_likes " +
@@ -10,13 +10,11 @@ import kotlinx.datetime.Instant
* One row per track that has played through the audio cache (see
* `CacheIndexer`); SimpleCache, not this table, says what is on disk.
*
* The `source` column is meant to drive a 2-bucket eviction
* (incidental first, then auto-prefetch, manual last). Nothing evicts
* by it yet: SimpleCache evicts by plain LRU over the rolling cap.
* `cachedAt` = when we downloaded. `lastPlayedAt` = when the user last
* played it; drives the offline "recently played" ordering and is the
* tiebreaker for LRU eviction within a source bucket. Nullable on
* brand-new rows until the first post-cache play touches them.
* `source` says how the row arrived (see [CacheSource]); eviction does
* not read it. `cachedAt` = when the row was written. `lastPlayedAt` =
* when the user last played the track, which orders the offline
* "recently played" pool. Nullable on brand-new rows until the first
* post-cache play touches them.
*/
@Entity(tableName = "audio_cache_index")
data class AudioCacheIndexEntity(
@@ -74,6 +74,35 @@ class ConnectivityObserver @Inject constructor(
awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
/**
* Whether the network the device is using now is unmetered: Wi-Fi or
* Ethernet, but not mobile data or a hotspot Android marks metered.
* Downloads the user did not start (the liked-song downloads) wait for
* this.
*/
val unmetered: Flow<Boolean> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities,
) {
trySend(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))
}
override fun onLost(network: Network) {
trySend(false)
}
}
cm.registerDefaultNetworkCallback(callback)
trySend(isActiveNetworkUnmetered())
awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
private fun isActiveNetworkUnmetered(): Boolean {
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
return caps != null &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
private fun hasActiveNetwork(): Boolean {
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
return caps != null &&
@@ -70,6 +70,9 @@ class DiagnosticsUploader @Inject constructor(
/** Coalesces concurrent triggers via tryLock — a drain in flight wins. */
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
// Signed out / no client id yet → nothing to do. Single guard keeps
// the return count within the detekt gate.
@@ -102,6 +102,8 @@ import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
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 kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
@@ -553,6 +555,7 @@ class HomeViewModel @Inject constructor(
fun HomeScreen(
navController: NavHostController,
viewModel: HomeViewModel = hiltViewModel(),
updates: UpdateViewModel = hiltViewModel(),
) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
@@ -575,7 +578,11 @@ fun HomeScreen(
val offline by viewModel.offline.collectAsStateWithLifecycle()
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
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),
) {
Box(Modifier.fillMaxSize()) {
@@ -590,6 +597,7 @@ fun HomeScreen(
// hide behind an "Updating your mixes…" wipe that stays up
// until the screen has actually stopped moving.
UpdatingVeil(visible = updating)
UpdateNotices(updates)
}
}
}
@@ -773,6 +781,13 @@ private fun HomeSuccessContent(
onPlayPool = onPlayPool,
onPlayPlaylist = onPlayPlaylist,
)
genreMixSection(
playlists = sections.playlists,
offline = offline,
onPlaylistClick = onPlaylistClick,
onPlayPool = onPlayPool,
onPlayPlaylist = onPlayPlaylist,
)
youMightLikeSection(
albums = sections.youMightLikeAlbums,
artists = sections.youMightLikeArtists,
@@ -825,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(
albums: List<HomeTile<AlbumRef>>,
onAlbumClick: (String) -> Unit,
@@ -1225,6 +1265,25 @@ 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).
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
@@ -14,14 +14,20 @@ import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
/**
* Cache-first reads + offline-first writes for likes.
@@ -117,6 +123,24 @@ class LikesRepository @Inject constructor(
fun observeLikedTrackIds(): Flow<Set<String>> =
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { it.toSet() }
/**
* The liked track ids of whoever is signed in, most recently liked
* first, following sign-in and account changes. Emits nothing while
* signed out, so a long-lived collector never mistakes the anonymous
* empty set for "no likes".
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun observeSignedInLikedTrackIdsNewestFirst(): Flow<List<String>> =
authController.currentUser
.map { it?.id }
.distinctUntilChanged()
.filterNotNull()
.flatMapLatest { userId -> likeDao.observeLikedTrackIdsNewestFirst(userId) }
/** [observeSignedInLikedTrackIdsNewestFirst] as a set. */
fun observeSignedInLikedTrackIds(): Flow<Set<String>> =
observeSignedInLikedTrackIdsNewestFirst().map { it.toSet() }
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
suspend fun likedTrackIds(): Set<String> =
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
@@ -166,14 +190,21 @@ class LikesRepository @Inject constructor(
* device would stay on the Liked tab forever. The replaceAllForUser
* DAO method runs a transactional delete-then-insert so the local
* set is exactly what the server reports.
*
* The server lists each kind most recently liked first but sends no
* times, so the rows are stamped a millisecond apart in that order;
* `likedAt` then sorts the way the server does.
*/
suspend fun refreshIds() {
val uid = currentUserId()
val wire = api.ids()
val now = Clock.System.now()
fun rowsOf(type: String, ids: List<String>) =
ids.mapIndexed { i, id -> CachedLikeEntity(uid, type, id, likedAt = now - i.milliseconds) }
val rows = mutableListOf<CachedLikeEntity>()
rows += wire.artistIds.map { CachedLikeEntity(uid, ENTITY_ARTIST, it) }
rows += wire.albumIds.map { CachedLikeEntity(uid, ENTITY_ALBUM, it) }
rows += wire.trackIds.map { CachedLikeEntity(uid, ENTITY_TRACK, it) }
rows += rowsOf(ENTITY_ARTIST, wire.artistIds)
rows += rowsOf(ENTITY_ALBUM, wire.albumIds)
rows += rowsOf(ENTITY_TRACK, wire.trackIds)
likeDao.replaceAllForUser(uid, rows)
}
@@ -26,10 +26,11 @@ data class PlaylistRef(
/**
* Whether the "Regenerate" affordance applies: every system playlist
* EXCEPT songs_like_artist (the server's songs-like family exposes no
* refresh trigger; rebuilds are scheduler-driven).
* EXCEPT the kinds with several playlists per user (songs_like_artist,
* genre_mix), which the server can't address by kind; those rebuild on
* the daily schedule and play by playlist id.
*/
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist"
val refreshable: Boolean get() = isSystem && systemVariant !in PER_PLAYLIST_SYSTEM_VARIANTS
/**
* Can't be relied on while offline — either it needs the live server to
@@ -39,6 +40,9 @@ data class PlaylistRef(
val unavailableOffline: Boolean get() = refreshable || !fullyCached
}
/** System kinds with several playlists per user, played by playlist id. */
private val PER_PLAYLIST_SYSTEM_VARIANTS = setOf("songs_like_artist", "genre_mix")
/**
* One row of a playlist's tracks.
*
@@ -15,7 +15,9 @@ package com.fabledsword.minstrel.models
* verbatim rather than validated, so an unexpected value is shown rather
* than dropped.
*
* [apkUrl] is server-relative (e.g. `/api/client/apk`).
* [apkUrl] is server-relative (e.g. `/api/client/apk`). [sha256] is the
* served file's digest, in hex; empty from a server that predates it, and
* then a download is judged whole by its size alone.
*/
data class UpdateInfo(
val version: String,
@@ -23,4 +25,5 @@ data class UpdateInfo(
val channel: String?,
val apkUrl: String,
val sizeBytes: Long,
val sha256: String,
)
@@ -26,4 +26,5 @@ data class UpdateInfoWire(
val channel: String? = null,
@SerialName("apk_url") val apkUrl: String = "/api/client/apk",
@SerialName("size_bytes") val sizeBytes: Long = 0,
val sha256: String = "",
)
@@ -9,23 +9,17 @@ import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -35,7 +29,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
@@ -45,8 +38,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.composables.icons.lucide.ChevronRight
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
import com.fabledsword.minstrel.nav.NotificationSettings
@@ -54,13 +45,19 @@ import com.fabledsword.minstrel.notifications.data.NotificationChannel
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.theme.Muted
import com.fabledsword.minstrel.theme.RowChevron
import com.fabledsword.minstrel.theme.Section
import com.fabledsword.minstrel.theme.SectionEdge
import com.fabledsword.minstrel.theme.SectionGap
import com.fabledsword.minstrel.theme.SettingRow
import com.fabledsword.minstrel.theme.SwitchRow
private val CELL_WIDTH = 56.dp
private val CELL_WIDTH = 48.dp
/**
* Per-user notification settings: a row per kind with a box for each
* channel. Rows read as a menu; anything that needs explaining
* is one short line.
* Per-user notification settings: what this device does, then a row per
* kind with a box for each channel.
*/
@Composable
fun NotificationSettingsScreen(
@@ -109,38 +106,36 @@ private fun SettingsBody(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
.padding(horizontal = SectionEdge, vertical = SectionEdge),
verticalArrangement = Arrangement.spacedBy(SectionGap),
) {
deviceRows()
HeaderRow()
settings.kinds.filter { !it.adminOnly }.forEach { KindRow(it, settings.emailAvailable, onSet) }
Section("This device") { deviceRows() }
Section("Requests", trailing = { ChannelHeaders() }) {
settings.kinds.filter { !it.adminOnly }.forEach { KindRow(it, settings.emailAvailable, onSet) }
if (!settings.emailAvailable) {
Muted(emailUnavailableLine(settings.emailUnavailableReason, isAdmin))
}
}
val adminKinds = settings.kinds.filter { it.adminOnly }
if (adminKinds.isNotEmpty()) {
SectionLabel("Library health")
adminKinds.forEach { KindRow(it, settings.emailAvailable, onSet) }
}
if (!settings.emailAvailable) {
Muted(emailUnavailableLine(settings.emailUnavailableReason, isAdmin))
Section("Library health", trailing = { ChannelHeaders() }) {
adminKinds.forEach { KindRow(it, settings.emailAvailable, onSet) }
}
}
}
}
/** The channel names over the boxes, in the section's title line. */
@Composable
private fun HeaderRow() {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.weight(1f))
NotificationChannel.entries.forEach { c ->
Text(
text = channelLabel(c),
modifier = Modifier.width(CELL_WIDTH),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
private fun ChannelHeaders() {
NotificationChannel.entries.forEach { c ->
Text(
text = channelLabel(c),
modifier = Modifier.width(CELL_WIDTH),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@@ -151,11 +146,7 @@ private fun KindRow(
onSet: (String, NotificationChannel, Boolean) -> Unit,
) {
val label = kindLabel(row.kind)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
SettingRow(title = label) {
NotificationChannel.entries.forEach { c ->
val enabled = channelEnabled(row, c, emailAvailable)
Box(Modifier.width(CELL_WIDTH), contentAlignment = Alignment.Center) {
@@ -204,60 +195,24 @@ private fun DeviceRows(backgroundDelivery: Boolean, onBackgroundDelivery: (Boole
@Composable
private fun BackgroundDeliveryRow(checked: Boolean, onChange: (Boolean) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.toggleable(value = checked, role = Role.Switch, onValueChange = onChange)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(Modifier.weight(1f)) {
Text("Notifications when the app is closed", style = MaterialTheme.typography.bodyLarge)
// The ongoing notice is the price of not using Google's push;
// said plainly rather than left to be discovered.
Muted("Keeps a quiet notice in the shade, in place of Google's push", padded = false)
}
Switch(checked = checked, onCheckedChange = null)
}
// The ongoing notice is the price of not using Google's push; said
// plainly rather than left to be discovered.
SwitchRow(
title = "Notifications when the app is closed",
summary = "Keeps a quiet notice in the shade, in place of Google's push",
checked = checked,
onCheckedChange = onChange,
)
}
@Composable
private fun PhoneBlockedRow(onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(Modifier.weight(1f)) {
Text("Phone alerts are off", style = MaterialTheme.typography.bodyLarge)
Muted("Allow notifications for Minstrel in system settings", padded = false)
}
Icon(Lucide.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun SectionLabel(text: String) {
Text(
text = text,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun Muted(text: String, padded: Boolean = true) {
Text(
text = text,
modifier = if (padded) Modifier.padding(horizontal = 16.dp, vertical = 12.dp) else Modifier,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SettingRow(
title = "Phone alerts are off",
summary = "Allow notifications for Minstrel in system settings",
tone = MaterialTheme.colorScheme.error,
onClick = onClick,
) { RowChevron() }
}
private fun openAppNotificationSettings(context: Context) {
@@ -0,0 +1,132 @@
package com.fabledsword.minstrel.player
import android.net.Uri
import androidx.media3.common.C
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.CacheWriter
import androidx.media3.datasource.cache.ContentMetadata
import androidx.media3.datasource.okhttp.OkHttpDataSource
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.resolveServerUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
// The Liked budget must have room for a track at CD bitrate (1411 kbps)
// before one is downloaded, so a download never pushes out liked audio that
// is already on the phone. A track of unknown length is assumed to be ten
// minutes.
private const val CD_BYTES_PER_SECOND = 176_400L
private const val UNKNOWN_DURATION_SEC = 600
/**
* Keeps liked songs on the phone: when "Keep liked songs offline" is on,
* every liked track's whole audio is downloaded into the cache, where
* [TwoBucketCacheEvictor] counts it against the Liked songs budget.
*
* - Most recently liked first, one track at a time, so the newest likes get
* the room and the server is never asked for more than one file at once.
* - Only on an unmetered network (Wi-Fi or Ethernet) while the server is
* reachable. A like made on mobile data downloads once the phone is back
* on Wi-Fi.
* - Stops when the Liked budget cannot fit another track. Liked tracks
* played after that are still cached as they stream.
* - Any change (a like, an unlike, the switch, the network) restarts the
* pass, cancelling a download in flight. Tracks already fully on the
* phone are skipped without touching the network, and a track whose
* download failed is tried again on the next pass.
*
* Downloads are keyed by track id exactly like playback and
* [AudioPrefetcher], so the player reads what this writes.
*
* Eager-constructed via the construct-the-singleton trick in
* `MinstrelApplication`.
*/
@Singleton
class LikedAudioPinner @Inject constructor(
@ApplicationScope scope: CoroutineScope,
likes: LikesRepository,
authStore: AuthStore,
connectivity: ConnectivityObserver,
serverHealth: NetworkStatusController,
private val playerFactory: PlayerFactory,
private val evictor: TwoBucketCacheEvictor,
private val trackDao: CachedTrackDao,
okHttpClient: OkHttpClient,
) {
private val cacheDataSourceFactory: CacheDataSource.Factory =
CacheDataSource.Factory()
.setCache(playerFactory.simpleCache)
.setUpstreamDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
private data class Pass(val likedNewestFirst: List<String>, val enabled: Boolean, val canDownload: Boolean)
init {
scope.launch(Dispatchers.IO) {
combine(
likes.observeSignedInLikedTrackIdsNewestFirst(),
authStore.cacheSettings.map { it.cacheLikedTracks },
connectivity.unmetered,
serverHealth.state.map { it == ServerHealth.Healthy || it == ServerHealth.Unstable },
) { liked, enabled, unmetered, reachable ->
Pass(liked, enabled, unmetered && reachable)
}
.distinctUntilChanged()
.collectLatest { pass ->
if (pass.enabled && pass.canDownload) pinAll(pass.likedNewestFirst)
}
}
}
private suspend fun pinAll(likedNewestFirst: List<String>) {
for (trackId in likedNewestFirst) {
val track = downloadable(trackId)
if (track != null) {
val durationSec = track.durationSec.takeIf { it > 0 } ?: UNKNOWN_DURATION_SEC
if (evictor.usage().likedRoomBytes < durationSec * CD_BYTES_PER_SECOND) return
runCatching { runInterruptible { download(track.id, track.streamUrl) } }
.onFailure { Timber.d(it, "liked audio: download of %s failed; next pass retries", track.id) }
}
}
}
/** The track, when it still needs downloading and the server can serve it. */
private suspend fun downloadable(trackId: String): TrackRef? {
if (isFullyCached(trackId)) return null
val track = trackDao.getById(trackId)?.toDomain()
return track?.takeIf { !it.unavailable && it.streamUrl.isNotBlank() }
}
private fun isFullyCached(trackId: String): Boolean {
val cache = playerFactory.simpleCache
val length = ContentMetadata.getContentLength(cache.getContentMetadata(trackId))
return length != C.LENGTH_UNSET.toLong() && cache.isCached(trackId, 0, length)
}
private fun download(trackId: String, streamUrl: String) {
val dataSpec = DataSpec.Builder()
.setUri(Uri.parse(resolveServerUrl(streamUrl) ?: streamUrl))
.setKey(trackId)
.build()
CacheWriter(cacheDataSourceFactory.createDataSource(), dataSpec, null, null).cache()
}
}
@@ -14,6 +14,9 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.QueueMode
import com.fabledsword.minstrel.player.output.SingleTrackLoader
import com.fabledsword.minstrel.player.output.streamUriTrackId
import com.fabledsword.minstrel.player.output.upnp.PositionInfo
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportInfo
@@ -57,14 +60,21 @@ import timber.log.Timber
* wraps that callback into a SharedFlow consumed by the NowPlaying
* surface as a snackbar.
*
* Queue mode: OutputPickerController loads the full queue into Sonos's
* native queue via ClearQueue + AddURIToQueue, then points the
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
* advance natively. PollLoop keeps the local cursor aligned to the
* track the renderer is actually playing, matched by track identity
* (the id in GetPositionInfo's TrackURI) so it survives queue-reload
* index drift -- see [syncLocalCursorToRemote].
* Queue mode ([ActiveUpnp.queueMode]):
* - NATIVE_QUEUE (Sonos): OutputPickerController loads the full queue
* into Sonos's native queue via ClearQueue + AddURIToQueue, then points
* the transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
* advance natively.
* - SINGLE_TRACK (any other UPnP / DLNA renderer): the renderer holds one
* track. Skip/prev/seekTo point it at the track the local cursor moved
* to, and each poll lets [SingleTrackLoader] follow or drive the advance
* to the next track and supply the queue position the renderer cannot.
*
* PollLoop keeps the local cursor aligned to the track the renderer is
* actually playing, matched by track identity (the id in
* GetPositionInfo's TrackURI) so it survives queue-reload index drift --
* see [syncLocalCursorToRemote].
*/
class MinstrelForwardingPlayer(
private val delegate: Player,
@@ -72,6 +82,7 @@ class MinstrelForwardingPlayer(
private val remoteState: RemotePlayerState,
private val castNetworkLock: CastNetworkLock,
private val networkStatus: NetworkStatusController,
private val singleTrack: SingleTrackLoader,
private val events: RemoteEvents = RemoteEvents(),
) : ForwardingPlayer(delegate) {
@@ -205,24 +216,56 @@ class MinstrelForwardingPlayer(
// drops via isLoadingUpnp() = true; the OutputPickerController
// observes the uiState.queue change and runs the resync (re-clears
// Sonos's native queue + AddURIToQueue the new tracks + Play).
//
// The SAME tracks set again (play on the album already queued, from
// another track) is not a new queue: the renderer already holds it, and
// the uiState collector -- which keys on the track ids -- would never see
// a change to resync on, leaving the cast stuck "loading". It is a move
// within the queue, so the renderer is pointed at the new track and the
// play() that follows goes through.
override fun setMediaItems(mediaItems: List<MediaItem>) {
val sameQueue = holdsSameItems(mediaItems)
super.setMediaItems(mediaItems)
markPendingResyncIfRemote()
afterQueueSet(sameQueue)
}
override fun setMediaItems(mediaItems: List<MediaItem>, resetPosition: Boolean) {
val sameQueue = holdsSameItems(mediaItems)
super.setMediaItems(mediaItems, resetPosition)
markPendingResyncIfRemote()
afterQueueSet(sameQueue)
}
override fun setMediaItems(mediaItems: List<MediaItem>, startIndex: Int, startPositionMs: Long) {
val sameQueue = holdsSameItems(mediaItems)
super.setMediaItems(mediaItems, startIndex, startPositionMs)
markPendingResyncIfRemote()
afterQueueSet(sameQueue)
}
private fun markPendingResyncIfRemote() {
val wasActive = holder.active.value ?: return
private fun holdsSameItems(mediaItems: List<MediaItem>): Boolean =
mediaItems.size == delegate.mediaItemCount &&
mediaItems.indices.all { mediaItems[it].mediaId == delegate.getMediaItemAt(it).mediaId }
private fun afterQueueSet(sameQueue: Boolean) {
val active = holder.active.value ?: return
if (!sameQueue) {
markPendingResync(active)
return
}
val index = delegate.currentMediaItemIndex
val positionMs = delegate.currentPosition.coerceAtLeast(0L)
Timber.w(
"setMediaItems with the queue %s already holds -- moving to track %d",
active.routeName,
index + 1,
)
when (active.queueMode) {
QueueMode.SINGLE_TRACK -> startSingleTrack(active, index, positionMs)
QueueMode.NATIVE_QUEUE -> seekNativeQueue(active, index, positionMs)
}
}
private fun markPendingResync(wasActive: ActiveUpnp) {
Timber.w(
"setMediaItems while UPnP active (%s) -- marking pending resync",
wasActive.routeName,
@@ -319,18 +362,18 @@ class MinstrelForwardingPlayer(
return
}
super.seekTo(mediaItemIndex, positionMs)
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching {
retryTransport {
active.avTransport.seekToTrack(mediaItemIndex + 1)
if (positionMs > 0L) {
active.avTransport.seek(positionMs)
}
}
}.onFailure { handleTransportFailure(active, it) }
when (active.queueMode) {
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, mediaItemIndex, positionMs)
QueueMode.NATIVE_QUEUE -> seekNativeQueue(active, mediaItemIndex, positionMs)
}
}
private fun seekNativeQueue(active: ActiveUpnp, index: Int, positionMs: Long) {
sendNativeQueueCommand(active) {
active.avTransport.seekToTrack(index + 1)
if (positionMs > 0L) {
active.avTransport.seek(positionMs)
}
}
}
@@ -349,12 +392,9 @@ class MinstrelForwardingPlayer(
// then delegate to Sonos Next. PollLoop reconciles cursor via
// Track index if they diverge.
super.seekToNextMediaItem()
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { retryTransport { active.avTransport.next() } }
.onFailure { handleTransportFailure(active, it) }
when (active.queueMode) {
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, delegate.currentMediaItemIndex)
QueueMode.NATIVE_QUEUE -> sendNativeQueueCommand(active) { active.avTransport.next() }
}
}
@@ -372,11 +412,56 @@ class MinstrelForwardingPlayer(
// Super first for immediate local cursor advance (UI feedback);
// then delegate to Sonos Previous. PollLoop reconciles.
super.seekToPreviousMediaItem()
when (active.queueMode) {
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, delegate.currentMediaItemIndex)
QueueMode.NATIVE_QUEUE -> sendNativeQueueCommand(active) { active.avTransport.previous() }
}
}
/**
* Send a track change to a Sonos native queue. The local cursor has
* already moved; the pending window holds the poll's cursor sync off
* until Sonos reports the new track, so it can't undo the press.
*/
private fun sendNativeQueueCommand(active: ActiveUpnp, command: suspend () -> Unit) {
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { retryTransport { active.avTransport.previous() } }
runCatching { retryTransport { command() } }
.onFailure { handleTransportFailure(active, it) }
}
}
/**
* Point a one-track renderer at [index] of the queue, the track the local
* cursor was just moved to. The same track as the renderer's is a seek
* within it, so next on the last track (the cursor cannot move) sends
* nothing rather than restarting the track. The seek after a new track is
* best-effort: a renderer still loading the URI may refuse it, and the
* track playing from the start beats it not playing.
*/
private fun playSingleTrackAt(active: ActiveUpnp, index: Int, positionMs: Long = 0L) {
if (index == singleTrack.currentIndex) {
if (positionMs > 0L) seekTo(positionMs)
} else {
startSingleTrack(active, index, positionMs)
}
}
private fun startSingleTrack(active: ActiveUpnp, index: Int, positionMs: Long) {
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { retryTransport { singleTrack.playIndex(active.avTransport, index) } }
.onSuccess {
if (positionMs > 0L) {
runCatching { active.avTransport.seek(positionMs) }
.onFailure { Timber.w(it, "UPnP seek after track change refused") }
}
pollTrigger.trySend(Unit)
}
.onFailure { handleTransportFailure(active, it) }
}
}
@@ -611,6 +696,14 @@ class MinstrelForwardingPlayer(
*/
private suspend fun pollOnce(active: ActiveUpnp) {
val info = active.avTransport.getPositionInfo()
val transport = active.avTransport.getTransportInfo()
val single = pollSingleTrack(active, info, transport)
// The loader just started the next track, so this reading describes
// the one that ended. Report the new track from its start and leave
// the cursor to the new index; the next poll reads it for real.
val advanced = single?.advanced == true
val rendererTrack = single?.trackNumber ?: info.track
val trackUri = if (advanced) "" else info.trackUri
val now = SystemClock.elapsedRealtime()
val inSeekAckWindow = lastSeekIssuedAtMs > 0L &&
(now - lastSeekIssuedAtMs) < SEEK_ACK_WINDOW_MS
@@ -618,15 +711,52 @@ class MinstrelForwardingPlayer(
// seekTo -- the poll's reported position is stale until Sonos finishes
// processing the Seek SOAP. Other fields still refresh from the poll.
remoteState.applyPositionInfo(
positionMs = if (inSeekAckWindow) remoteState.positionMs else info.relTimeMs,
durationMs = info.trackDurationMs,
trackUri = info.trackUri,
trackNumber = info.track,
positionMs = when {
advanced -> 0L
inSeekAckWindow -> remoteState.positionMs
else -> info.relTimeMs
},
durationMs = durationFor(info, single),
trackUri = trackUri,
trackNumber = rendererTrack,
)
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
events.onRendererTrack(info.track)
val transport = active.avTransport.getTransportInfo()
when (transport.state) {
syncLocalCursorToRemote(sonosTrack = rendererTrack, trackUri = trackUri)
if (single == null) events.onRendererTrack(info.track)
if (advanced) {
applyTransportState(TransportState.PLAYING)
} else {
applyTransportState(transport.state)
observeTransport(transport, info)
checkForStall(active, info.trackUri, transport)
}
notifyRemoteStateChanged()
}
/**
* A one-track renderer reports Track=1 whatever it plays; the loader
* knows the queue position, and may start the next track from here.
* Null for a Sonos, whose native queue answers for itself.
*/
private suspend fun pollSingleTrack(
active: ActiveUpnp,
info: PositionInfo,
transport: TransportInfo,
): SingleTrackLoader.PollOutcome? =
if (active.queueMode == QueueMode.SINGLE_TRACK) {
singleTrack.onPoll(
transport = active.avTransport,
trackUri = info.trackUri,
state = transport.state,
positionMs = info.relTimeMs,
durationMs = info.trackDurationMs,
playIntent = remoteState.lastPlayIntent,
)
} else {
null
}
private fun applyTransportState(state: TransportState) {
when (state) {
TransportState.PLAYING -> {
nonPlayingPollStreak = 0
remoteState.applyTransportPlaying()
@@ -643,13 +773,18 @@ class MinstrelForwardingPlayer(
remoteState.applyTransportStopped()
}
}
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
TransportState.TRANSITIONING, TransportState.NO_MEDIA, TransportState.UNKNOWN -> Unit
}
observeTransport(transport, info)
checkForStall(active, info.trackUri, transport)
notifyRemoteStateChanged()
}
/**
* The track's duration as the renderer reports it. A one-track renderer
* that reports none (some leave TrackDuration at 0:00:00) falls back to
* the library's duration, so the scrubber has an end to run to.
*/
private fun durationFor(info: PositionInfo, single: SingleTrackLoader.PollOutcome?): Long =
if (single != null && info.trackDurationMs <= 0L) single.knownDurationMs else info.trackDurationMs
/**
* Ask the watchdog what to make of this poll, and act on its answer.
*
@@ -711,7 +846,7 @@ class MinstrelForwardingPlayer(
)
// Tell the user and the admin inbox. Silence here would be the
// original bug: playback simply ends and nobody finds out.
trackIdFromStreamUri(trackUri)?.let { handler.post { events.onStalled(it) } }
streamUriTrackId(trackUri)?.let { handler.post { events.onStalled(it) } }
}
RemoteStallWatchdog.Decision.None -> Unit
}
@@ -763,6 +898,9 @@ class MinstrelForwardingPlayer(
): RemoteStallWatchdog.QueueState {
val stalled = transport.state == TransportState.STOPPED || !transport.statusOk
if (!stalled) return RemoteStallWatchdog.QueueState.UNKNOWN
// A one-track renderer has no queue length to ask for; the loader
// knows whether it just played out the queue's last track.
if (active.queueMode == QueueMode.SINGLE_TRACK) return singleTrack.queueState()
val now = SystemClock.elapsedRealtime()
if (now - lastMediaInfoAtMs > MEDIA_INFO_TTL_MS) {
@@ -834,7 +972,7 @@ class MinstrelForwardingPlayer(
* Must run on the application looper -- reads the delegate timeline.
*/
private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
val byId = trackIdFromStreamUri(trackUri)?.let { id ->
val byId = streamUriTrackId(trackUri)?.let { id ->
nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
}
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
@@ -860,20 +998,7 @@ class MinstrelForwardingPlayer(
return best
}
/** Extract `{id}` from a `.../api/tracks/{id}/stream...` stream URI. */
private fun trackIdFromStreamUri(uri: String): String? {
val start = uri.indexOf(TRACKS_PATH_MARKER)
if (start < 0) return null
val idStart = start + TRACKS_PATH_MARKER.length
val idEnd = uri.indexOf('/', idStart)
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
}
private companion object {
// Path segment that precedes the track-id in a stream URI
// (…/api/tracks/{id}/stream…) — used to map the renderer's current URI
// back to a delegate MediaItem by identity.
const val TRACKS_PATH_MARKER = "/api/tracks/"
const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2
const val SEEK_ACK_WINDOW_MS = 2_000L
@@ -10,7 +10,6 @@ import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.cache.CacheDataSink
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
@@ -20,10 +19,11 @@ import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CacheBitmapLoader
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
import com.fabledsword.minstrel.player.gain.ReplayGainStore
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.SingleTrackLoader
import com.fabledsword.minstrel.player.output.SonosQueueLoader
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.BufferOverflow
@@ -42,8 +42,8 @@ import javax.inject.Singleton
*
* `simpleCache` is exposed as a val for what needs the on-disk bytes:
* the cached-track ids, the offline pools, the prefetcher and the
* Storage card. The `LeastRecentlyUsedCacheEvictor` bounds it at the
* rolling cap; there is no separate liked-track bucket yet.
* Storage card. [TwoBucketCacheEvictor] bounds it by two budgets, Liked
* songs and Recently played.
*
* `build()` returns a [MinstrelForwardingPlayer] wrapping the internal
* ExoPlayer. When the UPnP route drops (the poll loop's consecutive-
@@ -56,21 +56,22 @@ import javax.inject.Singleton
class PlayerFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient,
private val cacheConfig: CacheConfig,
private val cacheEvictor: TwoBucketCacheEvictor,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
private val authStore: AuthStore,
private val replayGains: ReplayGainStore,
private val sonosQueue: SonosQueueLoader,
private val singleTrack: SingleTrackLoader,
) {
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
val simpleCache: SimpleCache = SimpleCache(
cacheDir,
LeastRecentlyUsedCacheEvictor(cacheConfig.rollingCapBytes),
cacheEvictor,
StandaloneDatabaseProvider(context),
)
).also(cacheEvictor::attach)
// MutableSharedFlow with extraBufferCapacity=1 + DROP_OLDEST so a burst
// of drop events (rapid SOAP failures during a single tear-down) surfaces
@@ -122,6 +123,7 @@ class PlayerFactory @Inject constructor(
remoteState = remoteState,
castNetworkLock = CastNetworkLock(context),
networkStatus = serverHealth,
singleTrack = singleTrack,
events = MinstrelForwardingPlayer.RemoteEvents(
onDrop = { name -> emitDrop(name) },
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
@@ -1,43 +0,0 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Player + audio-cache wiring. PlayerFactory has @Inject so it doesn't
* need a @Provides here; this module provides the configuration values
* (CacheConfig) it depends on, and any player-related singletons that
* aren't @Inject-constructible.
*
* MinstrelPlayerService injects PlayerFactory and calls `build()` from
* `onCreate`.
*/
@Module
@InstallIn(SingletonComponent::class)
object PlayerModule {
/**
* CacheConfig snapshot from the user's persisted CacheSettings.
* SimpleCache is constructed once per process, so changes via the
* Settings UI take effect on next app launch (not mid-flight).
* AuthStore's StateFlow hydrates async from Room — when the
* factory @Provides runs, the value is either the persisted
* setting (if Room finished first) or CacheSettings.DEFAULT.
* Worst case the user's tuning lands on next-next launch, which
* is acceptable for a single-user device pref.
*/
@Provides
@Singleton
fun provideCacheConfig(authStore: AuthStore): CacheConfig {
val s = authStore.cacheSettings.value
return CacheConfig(
likedCapBytes = s.likedCapBytes,
rollingCapBytes = s.rollingCapBytes,
)
}
}
@@ -13,14 +13,31 @@ import javax.inject.Singleton
* + rendering clients. Decouples [com.fabledsword.minstrel.player.MinstrelForwardingPlayer]
* from [OutputPickerController] -- the picker writes; the forwarding
* player reads. Null = no UPnP active (local ExoPlayer path).
*
* [queueMode] says how the renderer is fed tracks, which decides what a
* skip sends and where the poll reads the queue position from.
*/
data class ActiveUpnp(
val routeId: String,
val routeName: String,
val avTransport: AVTransportClient,
val rendering: RenderingControlClient?,
val queueMode: QueueMode,
)
enum class QueueMode {
/** A Sonos holds the whole queue: [SonosQueueLoader]. */
NATIVE_QUEUE,
/** Any other renderer holds one track at a time: [SingleTrackLoader]. */
SINGLE_TRACK,
;
companion object {
fun forRoute(isSonos: Boolean): QueueMode = if (isSonos) NATIVE_QUEUE else SINGLE_TRACK
}
}
@Singleton
class ActiveUpnpHolder @Inject constructor() {
@@ -15,6 +15,7 @@ import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
import com.fabledsword.minstrel.player.output.upnp.SoapClient
import com.fabledsword.minstrel.player.output.upnp.TransportState
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
import com.fabledsword.minstrel.player.output.upnp.bareUdn
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
@@ -28,7 +29,6 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
@@ -58,10 +58,11 @@ data class RouteSnapshot(
* Selection branches on [OutputRoute.Protocol]:
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
* wired, Bluetooth)
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
* [SonosQueueLoader], drive the discovered renderer with
* AVTransport.SetAVTransportURI + Play, pause local playback so
* audio yields to the network speaker
* - [OutputRoute.Protocol.UPNP] — pause local playback so audio yields
* to the network speaker, then hand the renderer the queue: a Sonos
* takes all of it through [SonosQueueLoader]; any other UPnP / DLNA
* renderer takes one track at a time through [SingleTrackLoader]
* (see [QueueMode])
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
* not implemented; selecting one does nothing.
*/
@@ -73,6 +74,7 @@ class OutputPickerController @Inject constructor(
private val playerController: PlayerController,
private val playerFactory: PlayerFactory,
private val sonosQueue: SonosQueueLoader,
private val singleTrack: SingleTrackLoader,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val okHttp: OkHttpClient,
@@ -330,6 +332,12 @@ class OutputPickerController @Inject constructor(
Timber.w("Sonos queue repair: route or transport gone for %s", routeId)
return@withLock
}
if (!upnpRoute.isSonos) {
// Only a native queue can be truncated; a one-track renderer
// never asks for this.
Timber.w("Sonos queue repair skipped: %s holds no queue", upnpRoute.name)
return@withLock
}
val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute)
Timber.w(
"Sonos queue repair: reloading %d tracks on %s (resuming at index %d)",
@@ -371,23 +379,31 @@ class OutputPickerController @Inject constructor(
selectedUpnpRouteIdInternal.value = null
return@withLock
}
val handledIncrementally = runCatching {
if (!upnpRoute.isSonos) {
resyncSingleTrack(upnpRoute, transport, newQueue, newCurrentIndex)
return@withLock
}
// The incremental diff is for a queue changed under a cast that kept
// running (play next, an append, a removal): it leaves the playing
// track alone. A queue REPLACED through setMediaItems has already
// cleared active, and the listener asked for its start track and for
// it to play -- both of which the diff would ignore, and with active
// never restored the app would think it had gone back to the phone
// while the speaker played on. That case reloads in full below.
val stillActive = activeUpnpHolder.active.value?.routeId == routeId
val handledIncrementally = stillActive && runCatching {
sonosQueue.tryIncrementalResync(transport, oldIds, newQueue)
}.getOrElse { e ->
Timber.w(e, "Sonos incremental resync errored; falling back to full reload")
false
}
if (handledIncrementally) {
// Active was never cleared on the incremental path; clear any
// target that markPendingResyncIfRemote set (it didn't, for
// incremental cases that don't go through setMediaItems, but
// belt-and-suspenders).
activeUpnpHolder.setTarget(null)
return@withLock
}
// Full rebuild: ensure active is cleared so transport calls drop
// (markPendingResyncIfRemote may already have done this on the
// setMediaItems path).
// (the forwarding player has already done this on the setMediaItems
// path).
if (activeUpnpHolder.active.value != null) {
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(routeId)
@@ -403,6 +419,7 @@ class OutputPickerController @Inject constructor(
routeName = outputRoute.name,
avTransport = transport,
rendering = rendering,
queueMode = QueueMode.NATIVE_QUEUE,
),
)
activeUpnpHolder.setTarget(null)
@@ -413,6 +430,57 @@ class OutputPickerController @Inject constructor(
}
}
/**
* The one-track counterpart of the Sonos resync. A mutation that left the
* cast running (play next, radio append, a removal) keeps the current
* track playing and only re-arms what follows it. A queue replaced
* through setMediaItems -- the forwarding player has already cleared
* active, and dropped the play() that came with it -- starts the new
* queue at its current index, as the listener asked. So does a mutation
* that removed the playing track.
*/
private suspend fun resyncSingleTrack(
upnpRoute: UpnpRoute,
transport: AVTransportClient,
newQueue: List<TrackRef>,
newCurrentIndex: Int,
) {
val stillActive = activeUpnpHolder.active.value?.routeId == upnpRoute.id
if (stillActive && requeueKeepsTrack(transport, newQueue)) {
activeUpnpHolder.setTarget(null)
return
}
if (activeUpnpHolder.active.value != null) {
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(upnpRoute.id)
}
Timber.w("UPnP single-track resync: starting track %d on %s", newCurrentIndex + 1, upnpRoute.name)
runCatching {
singleTrack.load(transport, newQueue, newCurrentIndex)
activeUpnpHolder.set(
ActiveUpnp(
routeId = upnpRoute.id,
routeName = upnpRoute.name,
avTransport = transport,
rendering = renderingClientFor(upnpRoute.id),
queueMode = QueueMode.SINGLE_TRACK,
),
)
activeUpnpHolder.setTarget(null)
remoteState.setPlayIntent(true)
}.onFailure { e ->
Timber.w(e, "UPnP single-track resync failed -- dropping to local")
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
}
}
/** True when the playing track survived the change and was kept playing. */
private suspend fun requeueKeepsTrack(transport: AVTransportClient, newQueue: List<TrackRef>): Boolean =
runCatching { singleTrack.requeue(transport, newQueue) }
.onFailure { Timber.w(it, "UPnP single-track requeue failed; restarting the queue") }
.getOrDefault(false)
/**
* Called when the active UPnP route drops unexpectedly (the poll loop's
@@ -496,11 +564,10 @@ class OutputPickerController @Inject constructor(
}
/**
* Drive the UPnP renderer using Sonos native queue mode:
* clear the device's queue, load every track from our local queue
* via AddURIToQueue, point the transport at the queue URI, seek to
* the current index, and play. Wrapped in `runCatching` — SOAP
* failure abandons the selection cleanly.
* Hand the renderer the local queue and play it. A Sonos gets its native
* queue loaded ([SonosQueueLoader]); any other renderer is started on the
* current track with the next one armed ([SingleTrackLoader]). Wrapped in
* `runCatching` — SOAP failure abandons the selection cleanly.
*
* Order of operations is deliberate:
* 1. Pause local so the user doesn't keep hearing local audio.
@@ -531,6 +598,7 @@ class OutputPickerController @Inject constructor(
return@withLock
}
val rendering = renderingClientFor(effectiveRoute.id)
val queueMode = queueModeFor(effectiveRoute.id)
// Pause local before flipping UI state -- user shouldn't keep hearing
// local audio while we queue up Sonos.
playerController.pause()
@@ -539,7 +607,12 @@ class OutputPickerController @Inject constructor(
// session we got disconnected from), attach to it in place instead of
// wiping + reloading -- no jarring restart, and skip/seek land on its
// live queue. Falls through to the clear+reload path when it isn't ours.
if (tryAdoptRunningSession(transport, rendering, effectiveRoute, uiState.queue)) {
val adopted = tryAdoptRunningSession(
AdoptTarget(transport, rendering, effectiveRoute, queueMode),
uiState.queue,
uiState.queueIndex,
)
if (adopted) {
return@withLock
}
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
@@ -547,7 +620,12 @@ class OutputPickerController @Inject constructor(
// taps don't hit Sonos's stale state from a prior session.
activeUpnpHolder.setTarget(effectiveRoute.id)
runCatching {
sonosQueue.load(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
when (queueMode) {
QueueMode.NATIVE_QUEUE ->
sonosQueue.load(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
QueueMode.SINGLE_TRACK ->
singleTrack.load(transport, uiState.queue, uiState.queueIndex)
}
// Wire active LAST -- SOAP path is now safe to use.
activeUpnpHolder.set(
ActiveUpnp(
@@ -555,6 +633,7 @@ class OutputPickerController @Inject constructor(
routeName = effectiveRoute.name,
avTransport = transport,
rendering = rendering,
queueMode = queueMode,
),
)
// Selecting a route loads the queue and Plays on the renderer, so
@@ -570,38 +649,49 @@ class OutputPickerController @Inject constructor(
/**
* The renderer's current position + transport state, validated as an
* adoptable continuation of our local queue: same track id at the same
* 1-based index. Null means "not ours / not playing" -> caller reloads.
* adoptable continuation of our local queue. Null means "not ours / not
* playing" -> caller reloads.
*/
private data class AdoptPlan(
val localIndex: Int,
val positionMs: Long,
val durationMs: Long,
val trackUri: String,
val trackNumber: Int,
val playing: Boolean,
)
/** The renderer a select is about to drive, and how. */
private data class AdoptTarget(
val transport: AVTransportClient,
val rendering: RenderingControlClient?,
val route: OutputRoute,
val queueMode: QueueMode,
)
/**
* Attach to a renderer that is *already* playing our queue (a session the
* phone got disconnected from but the speaker kept streaming) instead of
* clearing + reloading it. Probes the renderer's live state, and if it's
* mid-playback on the same track id at the same queue index, syncs the
* mid-playback on one of our tracks where our queue has it, syncs the
* local cursor to match and wires [ActiveUpnpHolder] -- the speaker never
* skips a beat, and skip/seek now drive its live queue. Returns false (and
* the caller does the full clear+reload) when the renderer isn't playing
* our queue. The local player is already paused by the caller.
* skips a beat, and skip/seek now drive it. Returns false (and the caller
* does the full load) when the renderer isn't playing our queue. The
* local player is already paused by the caller.
*/
private suspend fun tryAdoptRunningSession(
transport: AVTransportClient,
rendering: RenderingControlClient?,
route: OutputRoute,
target: AdoptTarget,
localQueue: List<TrackRef>,
localIndex: Int,
): Boolean {
val plan = probeAdoptable(transport, route, localQueue) ?: return false
val route = target.route
val plan = probeAdoptable(target, localQueue, localIndex) ?: return false
if (target.queueMode == QueueMode.SINGLE_TRACK) {
// The renderer holds only this track; arm the one after it.
singleTrack.adopt(target.transport, localQueue, plan.localIndex, plan.trackUri)
}
Timber.w(
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
route.name, plan.trackNumber, plan.positionMs,
route.name, plan.localIndex + 1, plan.positionMs,
)
// active is still null here, so this aligns the *local* cursor without
// emitting SOAP; the holder.set below then starts the poll loop, which
@@ -611,45 +701,54 @@ class OutputPickerController @Inject constructor(
positionMs = plan.positionMs,
durationMs = plan.durationMs,
trackUri = plan.trackUri,
trackNumber = plan.trackNumber,
trackNumber = plan.localIndex + 1,
)
remoteState.setPlayIntent(plan.playing)
activeUpnpHolder.set(
ActiveUpnp(
routeId = route.id,
routeName = route.name,
avTransport = transport,
rendering = rendering,
avTransport = target.transport,
rendering = target.rendering,
queueMode = target.queueMode,
),
)
return true
}
/**
* A Sonos must be on our track at the same 1-based index of its native
* queue, since skips drive that queue. A one-track renderer has no index
* to agree on: it is ours if it is playing one of our tracks, taken
* nearest the local cursor.
*/
private suspend fun probeAdoptable(
transport: AVTransportClient,
route: OutputRoute,
target: AdoptTarget,
localQueue: List<TrackRef>,
localIndex: Int,
): AdoptPlan? {
val probe = runCatching {
transport.getTransportInfo() to transport.getPositionInfo()
target.transport.getTransportInfo() to target.transport.getPositionInfo()
}.getOrElse {
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", route.name)
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", target.route.name)
return null
}
val (info, pos) = probe
val idx0 = pos.track - 1
val playingId = trackIdFromStreamUrl(pos.trackUri)
val playingId = streamUriTrackId(pos.trackUri)
val midPlayback =
info.state == TransportState.PLAYING || info.state == TransportState.PAUSED
val aligned = midPlayback && playingId != null &&
idx0 in localQueue.indices && localQueue[idx0].id == playingId
return if (aligned) {
val idx0 = when {
playingId == null -> null
target.queueMode == QueueMode.SINGLE_TRACK ->
nearestIndexOf(localQueue, playingId, localIndex)
else -> (pos.track - 1).takeIf { it in localQueue.indices && localQueue[it].id == playingId }
}
return if (midPlayback && idx0 != null) {
AdoptPlan(
localIndex = idx0,
positionMs = pos.relTimeMs,
durationMs = pos.trackDurationMs,
trackUri = pos.trackUri,
trackNumber = pos.track,
playing = info.state == TransportState.PLAYING,
)
} else {
@@ -657,12 +756,9 @@ class OutputPickerController @Inject constructor(
}
}
/** Extract our track id from a stream URL `.../api/tracks/<id>/stream...`. */
private fun trackIdFromStreamUrl(url: String): String? {
val segments = url.toHttpUrlOrNull()?.pathSegments ?: return null
val i = segments.indexOf("tracks")
return if (i >= 0) segments.getOrNull(i + 1) else null
}
/** How the route is fed tracks; a route gone from discovery reads as not-Sonos. */
private fun queueModeFor(routeId: String): QueueMode =
QueueMode.forRoute(upnpDiscovery.routes.value.firstOrNull { it.id == routeId }?.isSonos == true)
private fun renderingClientFor(routeId: String): RenderingControlClient? {
@@ -0,0 +1,266 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.RemoteStallWatchdog
import com.fabledsword.minstrel.player.StreamTokenProvider
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import javax.inject.Inject
import kotlin.math.abs
import javax.inject.Singleton
/**
* Drives a UPnP / DLNA renderer that has no queue of its own — anything that
* isn't a Sonos. [SonosQueueLoader] hands a Sonos the whole queue; the queue
* verbs it uses are Sonos extensions a generic renderer faults on. Here the
* app holds the queue and the renderer holds one track at a time:
*
* - SetAVTransportURI + Play starts a track.
* - SetNextAVTransportURI hands over the one after it, so a renderer that
* supports it moves on by itself, without a gap. The poll sees the
* renderer's URI change to that one and arms the next.
* - A renderer without SetNext (it faults) or one that accepts it and
* ignores it stops at the end of the track; [TrackEndDetector] tells that
* from a stop partway, and the app starts the next track itself.
*
* The renderer reports Track=1 for whatever it is playing, so the 1-based
* queue position everything else keys on comes from here ([PollOutcome]).
*
* One mutex covers every write, because the poll's auto-advance and the
* listener's skip both point the renderer somewhere and must not interleave.
*/
@Singleton
class SingleTrackLoader @Inject constructor(
private val streamTokens: StreamTokenProvider,
) {
private val mutex = Mutex()
@Volatile private var queue: List<TrackRef> = emptyList()
/** 0-based index of the track the renderer was last pointed at; -1 when none. */
@Volatile var currentIndex: Int = -1
private set
private var currentUri: String = ""
private var armedNextIndex: Int = -1
private var armedNextUri: String = ""
// Set when the renderer faults SetNextAVTransportURI: it does not have the
// action, and asking again per track would only collect the same fault.
// Cleared on every load, since a load may be a different renderer.
private var nextUnsupported = false
// Polls to wait before re-trying a SetNext that failed in transit. Each
// try mints a URL from the server, so a failure is not retried every
// second.
private var armRetryPolls = 0
// Whether the last poll found the current track played to its end. Read
// by [queueState] for the stall watchdog.
private var endedOnLastPoll = false
private val endDetector = TrackEndDetector()
/**
* What a poll learned. [trackNumber] is the 1-based queue position the
* renderer is playing (0 when nothing is loaded). [advanced] is true when
* this poll started the next track itself, so the reading it was given is
* already stale and the caller should not judge a stall from it.
* [knownDurationMs] is the library's duration for that track, for a
* renderer that reports none.
*/
data class PollOutcome(val trackNumber: Int, val advanced: Boolean, val knownDurationMs: Long = 0L)
/** Take [queue] as the one to play, and start [index] on the renderer. */
suspend fun load(transport: AVTransportClient, queue: List<TrackRef>, index: Int) =
mutex.withLock {
this.queue = queue
nextUnsupported = false
start(transport, index)
}
/**
* Start [index] of the queue already held — a skip, a previous, or a tap
* on a queue row. Returns false when [index] is outside the queue.
*/
suspend fun playIndex(transport: AVTransportClient, index: Int): Boolean =
mutex.withLock { start(transport, index) }
/**
* Attach to a renderer already playing [index] of [queue] at [uri] — a
* session the phone lost touch with and the renderer carried on with —
* without restarting it. Only arms the next track.
*/
suspend fun adopt(transport: AVTransportClient, queue: List<TrackRef>, index: Int, uri: String) =
mutex.withLock {
this.queue = queue
nextUnsupported = false
pointedAt(index, uri)
armNext(transport)
}
/**
* The local queue changed under a playing track (play next, radio
* append, a removal). Keep playing the current track and re-arm what
* follows it. Returns false when the current track is gone from
* [newQueue]; the caller then starts the new queue with [load].
*/
suspend fun requeue(transport: AVTransportClient, newQueue: List<TrackRef>): Boolean =
mutex.withLock {
val playingId = queue.getOrNull(currentIndex)?.id
val idx = playingId?.let { nearestIndexOf(newQueue, it, currentIndex) }
queue = newQueue
if (idx == null) return@withLock false
currentIndex = idx
// What was armed was chosen from the old queue; SetNext replaces it.
armedNextIndex = -1
armedNextUri = ""
armNext(transport)
true
}
/**
* One poll's reading of the renderer. Follows a renderer that moved on by
* itself, re-tries arming the next track, and starts the next track when
* the current one has played to its end.
*/
suspend fun onPoll(
transport: AVTransportClient,
trackUri: String,
state: TransportState,
positionMs: Long,
durationMs: Long,
playIntent: Boolean,
): PollOutcome = mutex.withLock {
if (currentIndex !in queue.indices) return@withLock PollOutcome(0, advanced = false)
val moved = followRenderer(trackUri)
armNext(transport)
if (moved) return@withLock PollOutcome(currentIndex + 1, advanced = false, knownDurationMs(queue, currentIndex))
endedOnLastPoll = endDetector.onPoll(state, positionMs, durationMs, knownDurationMs(queue, currentIndex))
val next = currentIndex + 1
val advance = endedOnLastPoll && playIntent && next in queue.indices
if (advance) {
Timber.w("UPnP single-track: track %d ended; starting %d", currentIndex + 1, next + 1)
runCatching { start(transport, next) }
.onFailure { Timber.w(it, "UPnP single-track: could not start track %d", next + 1) }
}
PollOutcome(currentIndex + 1, advanced = advance, knownDurationMs(queue, currentIndex))
}
/**
* For the stall watchdog: a renderer stopped at the end of the queue's
* last track has simply finished. Anything else is left for the watchdog
* to judge, as it would for a renderer that says nothing.
*/
fun queueState(): RemoteStallWatchdog.QueueState =
if (endedOnLastPoll && currentIndex == queue.lastIndex) {
RemoteStallWatchdog.QueueState.COMPLETE
} else {
RemoteStallWatchdog.QueueState.UNKNOWN
}
private suspend fun start(transport: AVTransportClient, index: Int): Boolean {
if (index !in queue.indices) return false
val token = mint(index)
transport.setAVTransportURIWithMetadata(token.url, token.mime, token.title)
transport.play()
pointedAt(index, token.url)
armNext(transport)
return true
}
private fun pointedAt(index: Int, uri: String) {
currentIndex = index
currentUri = uri
armedNextIndex = -1
armedNextUri = ""
armRetryPolls = 0
endedOnLastPoll = false
endDetector.reset()
}
/**
* Update [currentIndex] when the renderer's URI says it is playing
* something other than the track we started. The armed next URI is the
* common case (it advanced by itself); otherwise the track id in the URI
* is looked up, for a renderer that was re-pointed some other way.
* Returns true when the index moved.
*/
private fun followRenderer(trackUri: String): Boolean {
val idx = when {
trackUri.isEmpty() || trackUri == currentUri -> null
trackUri == armedNextUri -> armedNextIndex
else -> streamUriTrackId(trackUri)?.let { nearestIndexOf(queue, it, currentIndex + 1) }
}
if (idx == null || idx == currentIndex) return false
Timber.w("UPnP single-track: renderer moved on to track %d", idx + 1)
pointedAt(idx, trackUri)
return true
}
private suspend fun armNext(transport: AVTransportClient) {
val next = currentIndex + 1
if (nextUnsupported || next !in queue.indices || next == armedNextIndex) return
if (armRetryPolls > 0) {
armRetryPolls -= 1
return
}
runCatching {
val token = mint(next)
transport.setNextAVTransportURI(token.url, token.mime, token.title)
token.url
}.onSuccess { url ->
armedNextIndex = next
armedNextUri = url
}.onFailure { e ->
if (e is SoapFaultException) {
// The renderer answered: it has no SetNext. The app advances it.
Timber.w("UPnP single-track: SetNext refused (%s); advancing from the app", e.code)
nextUnsupported = true
} else {
Timber.w(e, "UPnP single-track: SetNext failed; retrying later")
armRetryPolls = ARM_RETRY_POLLS
}
}
}
// Prerender both: the current track is about to be fetched, and the next
// one is fetched the moment the renderer moves on.
private suspend fun mint(index: Int) = streamTokens.mint(
queue[index].id,
asAlbum = SonosQueueLoader.playingAsAlbum(queue, index),
prerender = true,
)
private companion object {
const val ARM_RETRY_POLLS = 10
}
}
/** The library's duration of [queue]'s track at [index], or 0 when there is none. */
private fun knownDurationMs(queue: List<TrackRef>, index: Int): Long =
queue.getOrNull(index)?.durationSec?.times(MILLIS_PER_SECOND) ?: 0L
/**
* Index in [queue] of the track with [id], taking the occurrence nearest
* [preferNear] so a track queued twice does not send the cursor across the
* queue. Null when [id] is not queued.
*/
internal fun nearestIndexOf(queue: List<TrackRef>, id: String, preferNear: Int): Int? =
queue.indices.filter { queue[it].id == id }.minByOrNull { abs(it - preferNear) }
/** The track id in a `.../api/tracks/{id}/stream...` URL, or null for any other URL. */
internal fun streamUriTrackId(uri: String): String? {
val start = uri.indexOf(TRACKS_PATH_MARKER)
if (start < 0) return null
val idStart = start + TRACKS_PATH_MARKER.length
val idEnd = uri.indexOf('/', idStart)
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
}
private const val TRACKS_PATH_MARKER = "/api/tracks/"
private const val MILLIS_PER_SECOND = 1_000L
@@ -0,0 +1,63 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.player.output.upnp.TransportState
/**
* Tells a renderer that finished its track from one that stopped partway.
*
* A renderer with no queue of its own reports the same STOPPED either way.
* Finishing means the app should start the next track; stopping partway is
* the stall watchdog's business (the stream died, or someone pressed stop on
* the device), and starting the next track then would skip music.
*
* The test is where the renderer was last seen playing: within [END_SLACK_MS]
* of the end counts as finished. The slack covers the 1 s poll cadence plus a
* renderer that stops a little short of the duration it reported. A track is
* only judged once it has been seen playing, so the brief STOPPED a renderer
* passes through while it loads a new URI never reads as an ending.
*
* Pure state, no SOAP: [SingleTrackLoader] feeds it each poll.
*/
internal class TrackEndDetector {
private var sawPlaying = false
private var lastPlayingPositionMs = 0L
private var durationMs = 0L
/**
* Feed one poll, learn whether the current track has ended.
* [reportedDurationMs] is the renderer's TrackDuration, which some
* renderers leave at zero; [knownDurationMs] is the library's duration
* for the same track, used when the renderer says nothing.
*/
fun onPoll(
state: TransportState,
positionMs: Long,
reportedDurationMs: Long,
knownDurationMs: Long,
): Boolean {
val duration = reportedDurationMs.takeIf { it > 0L } ?: knownDurationMs
if (duration > 0L) durationMs = duration
if (state == TransportState.PLAYING) {
sawPlaying = true
lastPlayingPositionMs = positionMs
return false
}
val stopped = state == TransportState.STOPPED || state == TransportState.NO_MEDIA
return stopped &&
sawPlaying &&
durationMs > 0L &&
lastPlayingPositionMs >= durationMs - END_SLACK_MS
}
/** A new track was handed to the renderer: judge it from scratch. */
fun reset() {
sawPlaying = false
lastPlayingPositionMs = 0L
durationMs = 0L
}
private companion object {
const val END_SLACK_MS = 5_000L
}
}
@@ -4,15 +4,21 @@ import okhttp3.HttpUrl
/**
* High-level wrapper for the UPnP AVTransport service.
* Covers SetAVTransportURI / Play / Pause / Stop / Seek /
* GetPositionInfo / GetTransportInfo / queue management
* (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous, SeekToTrack).
* Covers SetAVTransportURI / SetNextAVTransportURI / Play / Pause / Stop /
* Seek / GetPositionInfo / GetTransportInfo, plus the Sonos queue
* extensions (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous,
* SeekToTrack).
*
* [sonos] says which kind of renderer is on the other end. It shapes the
* DIDL-Lite every track is described with, so it is fixed at construction
* rather than chosen per call.
*/
// One method per AVTransport SOAP verb; splitting would obscure the 1:1 protocol mapping.
@Suppress("TooManyFunctions")
class AVTransportClient(
private val soap: SoapClient,
private val controlUrl: HttpUrl,
private val sonos: Boolean,
) {
suspend fun setAVTransportURI(uri: String, metadata: String = "") {
soap.call(
@@ -39,15 +45,34 @@ class AVTransportClient(
* <res protocolInfo="http-get:*:<mime>:*">...</res>
* </item>
* </DIDL-Lite>
* Generic UPnP renderers tolerate this shape too — there's no
* downside to always sending it. Title falls back to "Minstrel"
* when the caller doesn't supply one.
* The Sonos-only parts are left out for other renderers; see
* [buildDidlLite]. Title falls back to "Minstrel" when the caller
* doesn't supply one.
*/
suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) {
val safeTitle = title.ifBlank { "Minstrel" }
setAVTransportURI(uri, buildDidlLite(uri, mime, safeTitle))
}
/**
* Hand the renderer the track to play once the current one ends, so it
* can move on without a gap and without waiting for us. Optional in the
* UPnP spec: a renderer without it faults (401 Invalid Action, or 602
* Not Implemented), and the caller then advances the renderer itself.
*/
suspend fun setNextAVTransportURI(uri: String, mime: String, title: String) {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "SetNextAVTransportURI",
args = mapOf(
"InstanceID" to "0",
"NextURI" to uri,
"NextURIMetaData" to buildDidlLite(uri, mime, title),
),
)
}
/**
* Remove all tracks from the renderer's queue. UPnP action name is
* `RemoveAllTracksFromQueue`. Used at activation time to clear out any
@@ -247,6 +272,7 @@ class AVTransportClient(
"PAUSED_PLAYBACK" -> TransportState.PAUSED
"STOPPED" -> TransportState.STOPPED
"TRANSITIONING" -> TransportState.TRANSITIONING
"NO_MEDIA_PRESENT" -> TransportState.NO_MEDIA
else -> TransportState.UNKNOWN
}
// CurrentTransportStatus is the renderer's own verdict on whether it is
@@ -267,22 +293,31 @@ class AVTransportClient(
// discards our metadata content and regenerates its own with
// class=object.item and the URL query string as the title
// instead. Match SoCo's pattern.
//
// Other renderers get plain DIDL with DLNA.ORG_OP=01 in the
// protocolInfo, which declares that the stream takes byte-range
// requests. Several DLNA renderers (TVs, AV receivers) refuse to
// seek without it; the server's streams all serve Range.
val safeTitle = title.ifBlank { "Minstrel" }
val fourthField = if (sonos) "*" else DLNA_SEEKABLE
return buildString {
append("<DIDL-Lite ")
append("xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" ")
append("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ")
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" ")
append("xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\"")
if (sonos) append(" xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\"")
append(">")
append("<item id=\"-1\" parentID=\"-1\" restricted=\"true\">")
append("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>")
append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>")
append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime))
append(":*\">").append(xmlEscape(uri)).append("</res>")
append("<desc id=\"cdudn\" ")
append("nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
append("RINCON_AssociatedZPUDN")
append("</desc>")
append(":").append(fourthField).append("\">").append(xmlEscape(uri)).append("</res>")
if (sonos) {
append("<desc id=\"cdudn\" ")
append("nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
append("RINCON_AssociatedZPUDN")
append("</desc>")
}
append("</item>")
append("</DIDL-Lite>")
}
@@ -308,6 +343,7 @@ class AVTransportClient(
private companion object {
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
const val DLNA_SEEKABLE = "DLNA.ORG_OP=01"
const val MILLIS_PER_SECOND = 1000L
const val SECONDS_PER_MINUTE = 60L
const val SECONDS_PER_HOUR = 3600L
@@ -330,7 +366,12 @@ data class PositionInfo(
*/
data class MediaInfo(val nrTracks: Int, val currentUri: String)
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
/**
* [NO_MEDIA] is NO_MEDIA_PRESENT: nothing loaded. Several DLNA renderers land
* there instead of STOPPED when a track plays out, so a one-track renderer's
* ending reads it as a stop; it is not treated as a stall.
*/
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, NO_MEDIA, UNKNOWN }
/**
* [statusOk] is CurrentTransportStatus, defaulted true so the many call sites
@@ -135,13 +135,14 @@ class UpnpDiscoveryController @Inject constructor(
return AVTransportClient(
loggingSoapClient(controlHttp, route.name),
route.avTransportControlUrl,
sonos = route.isSonos,
)
}
private suspend fun handleDiscovery(locationUrl: String) {
val route = fetchRoute(locationUrl) ?: return
upsertRoute(route)
if (route.manufacturer.contains("Sonos", ignoreCase = true)) {
if (route.isSonos) {
refreshSonosTopology(route)
}
}
@@ -25,4 +25,13 @@ data class UpnpRoute(
val avTransportControlUrl: HttpUrl,
val renderingControlUrl: HttpUrl?,
val zoneGroupTopologyControlUrl: HttpUrl? = null,
)
) {
/**
* Whether this renderer is a Sonos, which is driven through its native
* queue. Anything else gets one track at a time — the Sonos queue verbs
* are vendor extensions a generic renderer faults on.
*/
val isSonos: Boolean
get() = zoneGroupTopologyControlUrl != null ||
manufacturer.contains("Sonos", ignoreCase = true)
}
@@ -171,6 +171,7 @@ private fun systemLabelFor(variant: String?): String = when (variant) {
"new_for_you" -> "New for you"
"on_this_day" -> "On this day"
"first_listens" -> "First listens"
"genre_mix" -> "Genre mix"
null -> ""
else -> variant.replace('_', ' ').replaceFirstChar { it.uppercase() }
}
@@ -1,137 +0,0 @@
package com.fabledsword.minstrel.settings.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.update.data.ApkInstaller
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateRepository
import com.fabledsword.minstrel.update.data.isBusy
import com.fabledsword.minstrel.update.data.isUpdateAvailable
import com.fabledsword.minstrel.update.data.message
import com.fabledsword.minstrel.update.data.stage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
/**
* One of three terminal states the Check-for-updates button surfaces.
* `Idle` is the pre-check state; `Latest` means the installed build
* matches or exceeds the server's bundled APK; `UpdateAvailable`
* surfaces an "Install vX.Y.Z" button that downloads the APK and
* installs it via [ApkInstaller].
*/
sealed interface UpdateCheckResult {
data object Idle : UpdateCheckResult
data object Latest : UpdateCheckResult
data class UpdateAvailable(val info: UpdateInfo) : UpdateCheckResult
data class Error(val message: String) : UpdateCheckResult
}
data class AboutUiState(
val installedVersion: String = BuildConfig.VERSION_NAME,
// The value the platform installs by, and therefore the one the update
// check must decide on. Held in state rather than read inline so a test
// can drive the comparison without a BuildConfig.
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(),
// A debug build is signed with this machine's debug key, so the server's
// release-signed APK can never install over it. It updates from Android
// Studio instead.
val selfUpdates: Boolean = !BuildConfig.DEBUG,
val isChecking: Boolean = false,
val installStage: InstallStage = InstallStage.IDLE,
val installMessage: String? = null,
val result: UpdateCheckResult = UpdateCheckResult.Idle,
)
/**
* Backs the About card's update controls. "Check for updates" calls
* [UpdateRepository.getLatest], compares versus this build via
* [isUpdateAvailable] — on the ordering key where the server reports one,
* on the name otherwise — and reports the terminal state.
* When an update is available, [install] downloads the APK via
* [ApkInstaller] and installs it — routing the user to the "install
* unknown apps" settings page first when that permission hasn't been
* granted.
*/
@HiltViewModel
class AboutCardViewModel @Inject constructor(
private val repository: UpdateRepository,
private val installer: ApkInstaller,
) : ViewModel() {
private val internal = MutableStateFlow(AboutUiState())
val state: StateFlow<AboutUiState> = internal.asStateFlow()
fun checkForUpdates() {
if (internal.value.isChecking || !internal.value.selfUpdates) return
viewModelScope.launch {
internal.update { it.copy(isChecking = true, installMessage = null) }
val installed = internal.value.installedVersion
val installedCode = internal.value.installedCode
val result = runCatching { repository.getLatest() }
.map { latest ->
if (
isUpdateAvailable(
serverCode = latest.code,
serverName = latest.version,
installedCode = installedCode,
installedName = installed,
)
) {
UpdateCheckResult.UpdateAvailable(latest)
} else {
UpdateCheckResult.Latest
}
}
.getOrElse { e -> UpdateCheckResult.Error(ErrorCopy.fromThrowable(e)) }
internal.update { it.copy(isChecking = false, result = result) }
}
}
fun install(info: UpdateInfo) {
if (internal.value.installStage.isBusy()) return
if (!installer.canInstall()) {
installer.requestInstallPermission()
internal.update {
it.copy(installMessage = "Allow installs for Minstrel, then tap Install again.")
}
return
}
viewModelScope.launch {
internal.update {
it.copy(installStage = InstallStage.DOWNLOADING, installMessage = null)
}
val apk = download(info.apkUrl)
if (apk != null) {
// The install half now suspends on the platform's verdict, so it
// gets its own stage — reporting "Downloading…" through it would
// be a lie once a confirm dialog is on screen.
internal.update { it.copy(installStage = InstallStage.INSTALLING) }
val outcome = installer.install(apk)
internal.update {
it.copy(installStage = outcome.stage(), installMessage = outcome.message())
}
}
}
}
private suspend fun download(apkUrl: String): File? =
runCatching { installer.downloadApk(apkUrl) }
.onFailure { e ->
internal.update {
it.copy(
installStage = InstallStage.ERROR,
installMessage = "Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
)
}
}
.getOrNull()
}
@@ -1,20 +1,14 @@
package com.fabledsword.minstrel.settings.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -25,44 +19,29 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fabledsword.minstrel.models.ListenBrainzStatus
import com.fabledsword.minstrel.theme.Muted
import com.fabledsword.minstrel.theme.Section
import com.fabledsword.minstrel.theme.SwitchRow
@Composable
fun ListenBrainzCard(viewModel: ListenBrainzViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "ListenBrainz",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
Section("ListenBrainz") {
if (state.isLoading) {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
} else {
ListenBrainzForm(
status = state.status,
tokenInput = state.tokenInput,
isSaving = state.isSaving,
message = state.message,
onTokenChange = viewModel::setTokenInput,
onSaveToken = viewModel::saveToken,
onSetEnabled = viewModel::setEnabled,
)
Text(
text = "Send your plays to ListenBrainz so you can keep your " +
"scrobble history portable. Get a token at " +
"listenbrainz.org/profile — it's stored encrypted on " +
"the server and never read back.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (state.isLoading) {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
} else {
ListenBrainzForm(
status = state.status,
tokenInput = state.tokenInput,
isSaving = state.isSaving,
message = state.message,
onTokenChange = viewModel::setTokenInput,
onSaveToken = viewModel::saveToken,
onSetEnabled = viewModel::setEnabled,
)
}
}
}
}
@@ -77,25 +56,25 @@ private fun ListenBrainzForm(
onSaveToken: () -> Unit,
onSetEnabled: (Boolean) -> Unit,
) {
TokenField(
tokenInput = tokenInput,
tokenSet = status?.tokenSet == true,
onChange = onTokenChange,
val tokenSet = status?.tokenSet == true
SwitchRow(
title = "Send my plays to ListenBrainz",
summary = when {
!tokenSet -> "Save a token first"
status?.lastScrobbledAt != null -> "Last scrobble: ${status.lastScrobbledAt}"
else -> null
},
checked = status?.enabled == true,
onCheckedChange = onSetEnabled,
enabled = !isSaving && tokenSet,
)
Spacer(Modifier.height(4.dp))
TokenField(tokenInput = tokenInput, tokenSet = tokenSet, onChange = onTokenChange)
SaveTokenButton(
isSaving = isSaving,
enabled = !isSaving && tokenInput.isNotBlank(),
onClick = onSaveToken,
)
EnabledRow(status = status, isSaving = isSaving, onSetEnabled = onSetEnabled)
if (message != null) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (message != null) Muted(message)
}
@Composable
@@ -105,6 +84,7 @@ private fun TokenField(tokenInput: String, tokenSet: Boolean, onChange: (String)
onValueChange = onChange,
label = { Text(if (tokenSet) "Replace token" else "Token") },
placeholder = { Text(if (tokenSet) "Token saved" else "") },
supportingText = { Text("From listenbrainz.org/profile; never shown again") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
@@ -129,36 +109,3 @@ private fun SaveTokenButton(isSaving: Boolean, enabled: Boolean, onClick: () ->
Text(if (isSaving) "Saving…" else "Save token")
}
}
@Composable
private fun EnabledRow(
status: ListenBrainzStatus?,
isSaving: Boolean,
onSetEnabled: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.padding(end = 12.dp)) {
Text(
text = "Send my plays to ListenBrainz",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (status?.lastScrobbledAt != null) {
Text(
text = "Last scrobble: ${status.lastScrobbledAt}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Switch(
checked = status?.enabled == true,
onCheckedChange = onSetEnabled,
enabled = !isSaving && status?.tokenSet == true,
)
}
}
@@ -1,30 +1,15 @@
package com.fabledsword.minstrel.settings.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fabledsword.minstrel.settings.data.NormalizationBoost
import com.fabledsword.minstrel.settings.data.NormalizationMode
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import com.fabledsword.minstrel.theme.ChoiceRow
import com.fabledsword.minstrel.theme.Section
import com.fabledsword.minstrel.theme.SwitchRow
/**
* Volume leveling. The choice is stored on the server, so the
@@ -36,98 +21,39 @@ fun NormalizationCard(viewModel: NormalizationViewModel = hiltViewModel()) {
NormalizationCardContent(prefs = prefs, onChange = viewModel::update)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun NormalizationCardContent(
prefs: NormalizationPrefs,
onChange: (NormalizationPrefs) -> Unit,
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Volume leveling",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Hint(modeHint(prefs.mode))
val modes = NormalizationMode.entries
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
modes.forEachIndexed { index, mode ->
SegmentedButton(
selected = mode == prefs.mode,
onClick = { onChange(prefs.copy(mode = mode)) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = modes.size,
),
) { Text(modeLabel(mode)) }
}
}
if (prefs.mode != NormalizationMode.OFF) {
TargetRow(prefs = prefs, onChange = onChange)
BoostRow(prefs = prefs, onChange = onChange)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TargetRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
Text(
text = "Target loudness",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
val targets = NormalizationPrefs.TARGETS
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
targets.forEachIndexed { index, lufs ->
SegmentedButton(
selected = lufs == prefs.targetLufs,
onClick = { onChange(prefs.copy(targetLufs = lufs)) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = targets.size,
),
) { Text("$lufs LUFS") }
}
}
}
@Composable
private fun BoostRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Boost quiet tracks fully",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Hint("A limiter catches the peaks.")
}
Spacer(Modifier.size(12.dp))
Switch(
checked = prefs.boost == NormalizationBoost.LIMITER,
onCheckedChange = { on ->
val boost = if (on) NormalizationBoost.LIMITER else NormalizationBoost.HEADROOM
onChange(prefs.copy(boost = boost))
},
Section("Volume leveling") {
ChoiceRow(
title = "Leveling",
summary = modeHint(prefs.mode),
options = NormalizationMode.entries.map { it to modeLabel(it) },
selected = prefs.mode,
onSelect = { onChange(prefs.copy(mode = it)) },
)
if (prefs.mode != NormalizationMode.OFF) {
ChoiceRow(
title = "Target loudness",
options = NormalizationPrefs.TARGETS.map { it to "$it LUFS" },
selected = prefs.targetLufs,
onSelect = { onChange(prefs.copy(targetLufs = it)) },
)
SwitchRow(
title = "Boost quiet tracks fully",
summary = "A limiter catches the peaks",
checked = prefs.boost == NormalizationBoost.LIMITER,
onCheckedChange = { on ->
val boost = if (on) NormalizationBoost.LIMITER else NormalizationBoost.HEADROOM
onChange(prefs.copy(boost = boost))
},
)
}
}
}
@Composable
private fun Hint(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
private fun modeLabel(mode: NormalizationMode): String = when (mode) {
NormalizationMode.OFF -> "Off"
NormalizationMode.AUTO -> "Auto"
@@ -1,7 +1,5 @@
package com.fabledsword.minstrel.settings.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -10,7 +8,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -22,59 +19,45 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fabledsword.minstrel.theme.Muted
import com.fabledsword.minstrel.theme.Section
@Composable
fun PasswordCard(viewModel: PasswordViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
Section("Password") {
PasswordField(
value = state.current,
label = "Current password",
onChange = viewModel::setCurrent,
)
PasswordField(
value = state.next,
label = "New password",
onChange = viewModel::setNext,
)
PasswordField(
value = state.confirm,
label = "Confirm new password",
onChange = viewModel::setConfirm,
)
Spacer(Modifier.height(4.dp))
Button(
onClick = viewModel::change,
enabled = !state.isChanging,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Password",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
PasswordField(
value = state.current,
label = "Current password",
onChange = viewModel::setCurrent,
)
PasswordField(
value = state.next,
label = "New password",
onChange = viewModel::setNext,
)
PasswordField(
value = state.confirm,
label = "Confirm new password",
onChange = viewModel::setConfirm,
)
Spacer(Modifier.height(4.dp))
Button(
onClick = viewModel::change,
enabled = !state.isChanging,
modifier = Modifier.fillMaxWidth(),
) {
if (state.isChanging) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.size(8.dp))
}
Text(if (state.isChanging) "Changing…" else "Change password")
}
if (state.message != null) {
Text(
text = state.message!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
if (state.isChanging) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.size(8.dp))
}
Text(if (state.isChanging) "Changing…" else "Change password")
}
state.message?.let { Muted(it) }
}
}
@@ -1,8 +1,6 @@
package com.fabledsword.minstrel.settings.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -10,7 +8,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -21,36 +18,28 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fabledsword.minstrel.theme.Muted
import com.fabledsword.minstrel.theme.Section
@Composable
fun ProfileCard(viewModel: ProfileViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Profile",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
Section("Profile") {
if (state.isLoading) {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
} else {
ProfileForm(
displayName = state.displayName,
email = state.email,
isSaving = state.isSaving,
message = state.message,
onDisplayNameChange = viewModel::setDisplayName,
onEmailChange = viewModel::setEmail,
onSave = viewModel::save,
)
if (state.isLoading) {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
} else {
ProfileForm(
displayName = state.displayName,
email = state.email,
isSaving = state.isSaving,
message = state.message,
onDisplayNameChange = viewModel::setDisplayName,
onEmailChange = viewModel::setEmail,
onSave = viewModel::save,
)
}
}
}
}
@@ -95,11 +84,5 @@ private fun ProfileForm(
}
Text(if (isSaving) "Saving…" else "Save profile")
}
if (message != null) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (message != null) Muted(message)
}
@@ -1,34 +1,19 @@
@file:Suppress("TooManyFunctions") // Settings screen + per-card helper composables
@file:Suppress("TooManyFunctions") // Settings screen + per-section composables
package com.fabledsword.minstrel.settings.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -37,32 +22,35 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.composables.icons.lucide.Bell
import com.composables.icons.lucide.ChevronRight
import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.LogOut
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Shield
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.NotificationSettings
import com.fabledsword.minstrel.nav.Requests
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
import com.fabledsword.minstrel.nav.ServerUrl
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.theme.ChoiceRow
import com.fabledsword.minstrel.theme.RowChevron
import com.fabledsword.minstrel.theme.RowSpinner
import com.fabledsword.minstrel.theme.Section
import com.fabledsword.minstrel.theme.SectionEdge
import com.fabledsword.minstrel.theme.SectionGap
import com.fabledsword.minstrel.theme.SettingRow
import com.fabledsword.minstrel.theme.SwitchRow
import com.fabledsword.minstrel.theme.ThemeMode
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.isBusy
import com.fabledsword.minstrel.update.ui.AppVersionCard
import com.fabledsword.minstrel.update.ui.CheckWhileShown
import com.fabledsword.minstrel.update.ui.UpdateViewModel
@Composable
fun SettingsScreen(
@@ -119,7 +107,7 @@ fun SettingsScreen(
@Composable
private fun SettingsList(
inner: androidx.compose.foundation.layout.PaddingValues,
inner: PaddingValues,
state: SettingsState,
themeMode: ThemeMode,
onPickTheme: (ThemeMode) -> Unit,
@@ -134,48 +122,26 @@ private fun SettingsList(
.fillMaxSize()
.padding(inner)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
.padding(horizontal = SectionEdge, vertical = SectionEdge),
verticalArrangement = Arrangement.spacedBy(SectionGap),
) {
AccountCard(
username = state.username,
isAdmin = state.isAdmin,
serverUrl = state.serverUrl,
AccountSection(
state = state,
onNavToRequests = onNavToRequests,
onNavToNotifications = onNavToNotifications,
onNavToAdmin = onNavToAdmin,
onSignOutClick = onSignOutClick,
)
NavTile(
icon = Lucide.ListMusic,
title = "My requests",
subtitle = "Track what you've asked Minstrel to add",
onClick = onNavToRequests,
)
NavTile(
icon = Lucide.Bell,
title = "Notifications",
subtitle = "What reaches you, and where",
onClick = onNavToNotifications,
)
if (state.isAdmin) {
NavTile(
icon = Lucide.Shield,
title = "Admin",
subtitle = "Manage requests, quarantine, and users",
onClick = onNavToAdmin,
)
}
ProfileCard()
PasswordCard()
ListenBrainzCard()
if (state.diagnosticsEnabledByAdmin) {
DiagnosticsCard(
optOut = state.diagnosticsOptOut,
onToggle = onToggleDiagnostics,
)
DiagnosticsSection(optOut = state.diagnosticsOptOut, onToggle = onToggleDiagnostics)
}
NormalizationCard()
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
AppearanceSection(themeMode = themeMode, onPick = onPickTheme)
StorageCard()
AboutCard()
SignOutButton(isSigningOut = state.isSigningOut, onSignOut = onSignOutClick)
}
}
@@ -205,84 +171,48 @@ private fun SignOutConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
)
}
/** Who is signed in, where, the screens that belong to the account, and signing out. */
@Composable
private fun NavTile(
icon: ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit,
private fun AccountSection(
state: SettingsState,
onNavToRequests: () -> Unit,
onNavToNotifications: () -> Unit,
onNavToAdmin: () -> Unit,
onSignOutClick: () -> Unit,
) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.size(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
imageVector = Lucide.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun AccountCard(username: String, isAdmin: Boolean, serverUrl: String) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "Account",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
val adminTag = if (isAdmin) " · admin" else ""
Text(
text = when {
username.isEmpty() -> "Signed in"
else -> "Signed in as $username$adminTag"
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Server",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = serverUrl,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Section("Account") {
SettingRow(
title = state.username.ifEmpty { "Signed in" },
summary = if (state.isAdmin) "Signed in · admin" else "Signed in",
)
SettingRow(title = "Server", summary = state.serverUrl)
SettingRow(
title = "My requests",
summary = "What you've asked Minstrel to add",
icon = Lucide.ListMusic,
onClick = onNavToRequests,
) { RowChevron() }
SettingRow(
title = "Notifications",
summary = "What reaches you, and where",
icon = Lucide.Bell,
onClick = onNavToNotifications,
) { RowChevron() }
if (state.isAdmin) {
SettingRow(
title = "Admin",
summary = "Requests, quarantine and users",
icon = Lucide.Shield,
onClick = onNavToAdmin,
) { RowChevron() }
}
SettingRow(
title = if (state.isSigningOut) "Signing out…" else "Sign out",
icon = Lucide.LogOut,
tone = MaterialTheme.colorScheme.error,
enabled = !state.isSigningOut,
onClick = onSignOutClick,
) { if (state.isSigningOut) RowSpinner() }
}
}
@@ -290,74 +220,27 @@ private fun AccountCard(username: String, isAdmin: Boolean, serverUrl: String) {
// the user turn reporting OFF on this device (battery/privacy) without
// touching the account flag. Checked = reporting on = NOT opted out.
@Composable
private fun DiagnosticsCard(optOut: Boolean, onToggle: (Boolean) -> Unit) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Diagnostics",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Send diagnostic reports",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Your admin enabled debug mode to investigate an " +
"issue. This device streams connectivity and playback " +
"diagnostics. Turn off any time.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.size(12.dp))
Switch(
checked = !optOut,
onCheckedChange = { checked -> onToggle(!checked) },
)
}
}
private fun DiagnosticsSection(optOut: Boolean, onToggle: (Boolean) -> Unit) {
Section("Diagnostics") {
SwitchRow(
title = "Send diagnostic reports",
summary = "Your admin turned on debug mode",
checked = !optOut,
onCheckedChange = { checked -> onToggle(!checked) },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
val options = ThemeMode.entries
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Appearance",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "System follows your device setting.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
options.forEachIndexed { index, mode ->
SegmentedButton(
selected = mode == themeMode,
onClick = { onPick(mode) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = options.size,
),
) { Text(displayName(mode)) }
}
}
}
private fun AppearanceSection(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
Section("Appearance") {
ChoiceRow(
title = "Theme",
summary = if (themeMode == ThemeMode.SYSTEM) "Follows your device" else null,
options = ThemeMode.entries.map { it to displayName(it) },
selected = themeMode,
onSelect = onPick,
)
}
}
@@ -367,136 +250,12 @@ private fun displayName(mode: ThemeMode): String = when (mode) {
ThemeMode.DARK -> "Dark"
}
/**
* The About card is the app-version card Home's update sheet holds too; on
* Settings it also asks the server again while the screen is up.
*/
@Composable
private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "About",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Installed: Minstrel ${state.installedVersion} " +
"(${BuildConfig.VERSION_CODE})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
UpdateControls(state = state, viewModel = viewModel)
}
}
}
@Composable
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
if (!state.selfUpdates) {
Text(
text = "Debug build: updates install from Android Studio.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
UpdateCheckLine(result = state.result)
Button(
onClick = viewModel::checkForUpdates,
enabled = !state.isChecking && !state.installStage.isBusy(),
modifier = Modifier.fillMaxWidth(),
) {
if (state.isChecking) {
ButtonSpinner()
}
Text(if (state.isChecking) "Checking…" else "Check for updates")
}
val available = state.result as? UpdateCheckResult.UpdateAvailable
if (available != null) {
InstallButton(
version = available.info.version,
stage = state.installStage,
onClick = { viewModel.install(available.info) },
)
}
if (state.installMessage != null) {
Text(
text = state.installMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun InstallButton(version: String, stage: InstallStage, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = !stage.isBusy(),
modifier = Modifier.fillMaxWidth(),
) {
if (stage.isBusy()) {
ButtonSpinner()
}
Text(
when (stage) {
InstallStage.DOWNLOADING -> "Downloading…"
InstallStage.INSTALLING -> "Installing…"
else -> "Install $version"
},
)
}
}
@Composable
private fun ButtonSpinner() {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.size(8.dp))
}
@Composable
private fun UpdateCheckLine(result: UpdateCheckResult) {
val text = when (result) {
UpdateCheckResult.Idle -> return
UpdateCheckResult.Latest -> "You're on the latest version."
is UpdateCheckResult.UpdateAvailable ->
"Update available: ${result.info.version}"
is UpdateCheckResult.Error -> "Couldn't check: ${result.message}"
}
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun SignOutButton(isSigningOut: Boolean, onSignOut: () -> Unit) {
Button(
onClick = onSignOut,
enabled = !isSigningOut,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
if (isSigningOut) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onErrorContainer,
strokeWidth = 2.dp,
)
} else {
Box(modifier = Modifier.padding(end = 4.dp)) {
Icon(Lucide.LogOut, contentDescription = null)
}
Text("Sign out")
}
}
private fun AboutCard(model: UpdateViewModel = hiltViewModel()) {
CheckWhileShown(model)
AppVersionCard(model)
}
@@ -1,21 +1,9 @@
package com.fabledsword.minstrel.settings.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -23,11 +11,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fabledsword.minstrel.theme.ChoiceRow
import com.fabledsword.minstrel.theme.RowSpinner
import com.fabledsword.minstrel.theme.Section
import com.fabledsword.minstrel.theme.SettingRow
import com.fabledsword.minstrel.theme.SwitchRow
private const val GIB: Long = 1024L * 1024 * 1024
private const val MIB: Long = 1024L * 1024
@@ -45,39 +35,39 @@ private val CAP_OPTIONS: List<Pair<Long, String>> = listOf(
fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
var showClearConfirm by remember { mutableStateOf(false) }
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Storage",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
UsageRow(usedBytes = state.usedBytes)
// Only the rolling cap is shown. Nothing enforces a separate
// liked cap or pins liked tracks yet, and a control that silently
// does nothing is worse than none. The prefetch window has no
// control either; AudioPrefetcher uses the stored value (default
// 5). CacheSettings keeps every field so the controls can return.
CapDropdown(
label = "Cache limit",
value = state.settings.rollingCapBytes,
onSet = viewModel::setRollingCapBytes,
)
Text(
text = "Takes effect on the next app launch.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
StorageActions(
isSyncing = state.isSyncing,
isClearing = state.isClearing,
onSync = viewModel::syncNow,
onClear = { showClearConfirm = true },
)
}
Section("Storage") {
// Two budgets: a liked track that is also recently played counts
// against Liked songs only. The prefetch window has no control;
// AudioPrefetcher uses the stored value (default 5).
CapRow(
title = "Liked songs",
used = state.likedBytes,
value = state.settings.likedCapBytes,
onSet = viewModel::setLikedCapBytes,
)
CapRow(
title = "Recently played",
used = state.recentBytes,
value = state.settings.rollingCapBytes,
onSet = viewModel::setRollingCapBytes,
)
SwitchRow(
title = "Keep liked songs offline",
summary = "Downloads on Wi-Fi",
checked = state.settings.cacheLikedTracks,
onCheckedChange = viewModel::setCacheLikedTracks,
)
SettingRow(
title = if (state.isSyncing) "Syncing…" else "Sync library now",
enabled = !state.isSyncing,
onClick = viewModel::syncNow,
) { if (state.isSyncing) RowSpinner() }
SettingRow(
title = if (state.isClearing) "Clearing…" else "Clear cached audio",
tone = MaterialTheme.colorScheme.error,
enabled = !state.isClearing,
onClick = { showClearConfirm = true },
) { if (state.isClearing) RowSpinner() }
}
if (showClearConfirm) {
ClearCacheConfirmDialog(
@@ -90,117 +80,19 @@ fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) {
}
}
/** A storage budget: how much it holds now, and the cap it may grow to. */
@Composable
private fun UsageRow(usedBytes: Long) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Used",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
text = formatBytes(usedBytes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@Composable
private fun CapDropdown(label: String, value: Long, onSet: (Long) -> Unit) {
val selectedLabel = CAP_OPTIONS.firstOrNull { it.first == value }?.second
?: formatBytes(value)
LabeledDropdown(
label = label,
selectedLabel = selectedLabel,
private fun CapRow(title: String, used: Long, value: Long, onSet: (Long) -> Unit) {
ChoiceRow(
title = title,
summary = "${formatBytes(used)} used",
options = CAP_OPTIONS,
selected = value,
onSelect = onSet,
selectedLabel = CAP_OPTIONS.firstOrNull { it.first == value }?.second ?: formatBytes(value),
)
}
/**
* Label on the left, button-with-current-value on the right that opens
* a DropdownMenu of [options]. Used by the cap selectors.
*/
@Composable
private fun <T> LabeledDropdown(
label: String,
selectedLabel: String,
options: List<Pair<T, String>>,
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
OutlinedButton(onClick = { expanded = true }) {
Text(selectedLabel)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { (value, name) ->
DropdownMenuItem(
text = { Text(name) },
onClick = {
expanded = false
onSelect(value)
},
)
}
}
}
}
}
@Composable
private fun StorageActions(
isSyncing: Boolean,
isClearing: Boolean,
onSync: () -> Unit,
onClear: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = onClear,
enabled = !isClearing,
) {
if (isClearing) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.size(8.dp))
}
Text(if (isClearing) "Clearing…" else "Clear cache")
}
OutlinedButton(
onClick = onSync,
enabled = !isSyncing,
) {
if (isSyncing) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.size(8.dp))
}
Text(if (isSyncing) "Syncing…" else "Sync now")
}
}
}
@Composable
private fun ClearCacheConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
@@ -227,7 +119,7 @@ private fun ClearCacheConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit
)
}
private fun formatBytes(n: Long): String {
internal fun formatBytes(n: Long): String {
if (n <= 0L) return "0 B"
return when {
n < KIB -> "$n B"
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.CacheSettings
import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.player.PlayerFactory
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -18,25 +19,25 @@ import javax.inject.Inject
data class StorageUiState(
val settings: CacheSettings = CacheSettings.DEFAULT,
val usedBytes: Long = 0L,
val likedBytes: Long = 0L,
val recentBytes: Long = 0L,
val isSyncing: Boolean = false,
val isClearing: Boolean = false,
)
/**
* Backs the Storage section of the Settings screen. Exposes the
* persisted CacheSettings + the current SimpleCache usage, plus
* actions for "Sync now" and "Clear cache".
* persisted CacheSettings and how much of each audio budget (Liked
* songs, Recently played) is in use, plus "Sync now" and "Clear cache".
*
* Settings changes go through AuthStore.setCacheSettings (write-
* through to Room). The cap settings only take effect on next app
* launch because SimpleCache is constructed once per process; the
* UI surfaces that caveat (next commit).
* through to Room), and the evictor applies a new cap at once.
*/
@HiltViewModel
class StorageViewModel @Inject constructor(
private val authStore: AuthStore,
private val playerFactory: PlayerFactory,
private val cacheEvictor: TwoBucketCacheEvictor,
private val syncController: SyncController,
) : ViewModel() {
@@ -47,9 +48,10 @@ class StorageViewModel @Inject constructor(
viewModelScope.launch {
authStore.cacheSettings.collect { s ->
internal.update { it.copy(settings = s) }
// A smaller cap trims the cache, so re-read the usage.
refreshUsage()
}
}
refreshUsage()
}
fun setLikedCapBytes(bytes: Long) {
@@ -108,13 +110,11 @@ class StorageViewModel @Inject constructor(
}
}
/** Recomputes used bytes from SimpleCache. Cheap; just reads a long. */
/** Re-reads each budget's usage from the evictor. Cheap: two longs. */
private fun refreshUsage() {
viewModelScope.launch {
val used = withContext(Dispatchers.IO) {
runCatching { playerFactory.simpleCache.cacheSpace }.getOrDefault(0L)
}
internal.update { it.copy(usedBytes = used) }
val usage = withContext(Dispatchers.IO) { cacheEvictor.usage() }
internal.update { it.copy(likedBytes = usage.likedBytes, recentBytes = usage.recentBytes) }
}
}
@@ -21,7 +21,6 @@ import com.fabledsword.minstrel.cache.mutations.OfflineWriteHintViewModel
import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
import com.fabledsword.minstrel.player.ui.MiniPlayer
import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel
import com.fabledsword.minstrel.update.ui.UpdateBanner
import com.fabledsword.minstrel.update.ui.VersionTooOldBanner
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
@@ -63,7 +62,7 @@ val ShellContentWindowInsets: WindowInsets = WindowInsets(0, 0, 0, 0)
* [onExpandPlayer] is the navigate-to-NowPlaying callback passed
* through to MiniPlayer's tap-handler. No NavHostController parameter
* because nothing inside the shell currently navigates - if a banner
* (UpdateBanner / VersionTooOldBanner / ConnectionErrorBanner) ever
* (VersionTooOldBanner / ConnectionErrorBanner) ever
* grows tap-to-navigate, plumb a NavHostController in at that point.
*/
@Composable
@@ -97,12 +96,10 @@ fun ShellScaffold(
// the status bar — no double inset, and no banner drawing under it.
Column(modifier = modifier.fillMaxSize().statusBarsPadding()) {
// Banner slot. VersionTooOldBanner appears when the server's
// min_client_version exceeds our build; UpdateBanner softly
// nudges an available (newer-than-installed) APK; and
// ConnectionErrorBanner shows when the device loses usable
// internet.
// min_client_version exceeds our build, and ConnectionErrorBanner
// shows when the device loses usable internet. A newer app is not
// a banner: Home tells of it in a snackbar (UpdateNotices).
VersionTooOldBanner()
UpdateBanner()
ConnectionErrorBanner()
// The window is edge-to-edge, so something has to own the
// bottom navigation-bar inset, and the shell claims it once
@@ -63,34 +63,3 @@ object FabledSwordFlatTokens {
val radiusLg = 12.dp
val radiusXl = 16.dp
}
/**
* Deprecated alias (dark surfaces + flat) that binds to dark-mode values
* regardless of system mode. Nothing reads it; UI code reads
* `LocalFabledSwordTheme.current`.
*/
@Deprecated(
"Use LocalFabledSwordTheme.current — see FabledSwordTheme.kt",
level = DeprecationLevel.WARNING,
)
object FabledSwordTokens {
val obsidian = FabledSwordDarkTokens.obsidian
val iron = FabledSwordDarkTokens.iron
val slate = FabledSwordDarkTokens.slate
val pewter = FabledSwordDarkTokens.pewter
val parchment = FabledSwordDarkTokens.parchment
val vellum = FabledSwordDarkTokens.vellum
val ash = FabledSwordDarkTokens.ash
val moss = FabledSwordFlatTokens.moss
val bronze = FabledSwordFlatTokens.bronze
val oxblood = FabledSwordFlatTokens.oxblood
val warning = FabledSwordFlatTokens.warning
val error = FabledSwordFlatTokens.error
val info = FabledSwordFlatTokens.info
val accent = FabledSwordFlatTokens.accent
val onAction = FabledSwordFlatTokens.onAction
val radiusSm = FabledSwordFlatTokens.radiusSm
val radiusMd = FabledSwordFlatTokens.radiusMd
val radiusLg = FabledSwordFlatTokens.radiusLg
val radiusXl = FabledSwordFlatTokens.radiusXl
}
@@ -0,0 +1,262 @@
@file:Suppress("TooManyFunctions") // the settings vocabulary: a plate and its row kinds
package com.fabledsword.minstrel.theme
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.ChevronRight
import com.composables.icons.lucide.ChevronDown
import com.composables.icons.lucide.Lucide
/**
* The plate every group of settings sits on. Every section gets the same
* plate, so the screen reads as one thing; a section that matters earns its
* weight with what is inside it, never with a louder box.
*
* The rhythm between lines lives here, not at the call sites.
*/
@Composable
fun Section(
title: String,
modifier: Modifier = Modifier,
trailing: @Composable (RowScope.() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(FabledSwordFlatTokens.radiusLg),
// `iron`, the raised surface, on the `obsidian` ground.
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Column(
modifier = Modifier.padding(SectionInset),
verticalArrangement = Arrangement.spacedBy(SectionRow),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(title, style = MaterialTheme.typography.titleMedium)
if (trailing != null) {
Row(verticalAlignment = Alignment.CenterVertically, content = trailing)
}
}
Spacer(Modifier.height(TITLE_GAP - SectionRow))
content()
}
}
}
/** The quiet line: a caption, a count, a consequence. */
@Composable
fun Muted(said: String, modifier: Modifier = Modifier, tone: Color = Color.Unspecified) {
Text(
said,
style = MaterialTheme.typography.bodySmall,
color = if (tone == Color.Unspecified) MaterialTheme.colorScheme.onSurfaceVariant else tone,
modifier = modifier,
)
}
/**
* One line of a settings section: what it is, one short line about it, and
* its control at the end. A settings screen is a menu, so the summary is one
* short line, never an explanation.
*
* With [onClick] the whole row is the tap target. [tone] colours the title of
* a row reporting something wrong, so the control beside it still reads as a
* control.
*/
@Composable
fun SettingRow(
title: String,
modifier: Modifier = Modifier,
summary: String? = null,
icon: ImageVector? = null,
tone: Color = Color.Unspecified,
enabled: Boolean = true,
onClick: (() -> Unit)? = null,
trailing: @Composable (RowScope.() -> Unit)? = null,
) {
val tappable = if (onClick != null) {
Modifier
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusMd))
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
} else {
Modifier
}
Row(
modifier = modifier
.fillMaxWidth()
.heightIn(min = ROW_HEIGHT)
.then(tappable)
.alpha(if (enabled) 1f else DISABLED_ALPHA),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
Icon(
icon,
contentDescription = null,
tint = if (tone == Color.Unspecified) MaterialTheme.colorScheme.onSurfaceVariant else tone,
modifier = Modifier.padding(end = ICON_GAP).size(ICON),
)
}
Column(Modifier.weight(1f).padding(vertical = ROW_PAD)) {
Text(
title,
style = MaterialTheme.typography.bodyLarge,
color = if (tone == Color.Unspecified) MaterialTheme.colorScheme.onSurface else tone,
)
if (summary != null) Muted(summary)
}
if (trailing != null) {
Row(
modifier = Modifier.padding(start = ICON_GAP),
verticalAlignment = Alignment.CenterVertically,
content = trailing,
)
}
}
}
/** A row whose control is a switch; the whole row toggles it. */
@Composable
fun SwitchRow(
title: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
summary: String? = null,
enabled: Boolean = true,
) {
SettingRow(
title = title,
summary = summary,
enabled = enabled,
modifier = modifier
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusMd))
.toggleable(value = checked, enabled = enabled, role = Role.Switch, onValueChange = onCheckedChange),
) {
Switch(checked = checked, onCheckedChange = null, enabled = enabled)
}
}
/**
* A one-of-N setting: the row shows the current choice, and a tap opens the
* choices under it.
*/
@Composable
fun <T> ChoiceRow(
title: String,
options: List<Pair<T, String>>,
selected: T,
onSelect: (T) -> Unit,
modifier: Modifier = Modifier,
summary: String? = null,
selectedLabel: String = options.firstOrNull { it.first == selected }?.second.orEmpty(),
) {
var open by remember { mutableStateOf(false) }
Box(modifier) {
SettingRow(title = title, summary = summary, onClick = { open = true }) {
RowValue(selectedLabel)
Icon(
Lucide.ChevronDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp).size(SMALL_ICON),
)
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
options.forEach { (value, name) ->
DropdownMenuItem(
text = { Text(name) },
onClick = {
open = false
onSelect(value)
},
)
}
}
}
}
/** A row's value, set to the right: a count, a version, a size. */
@Composable
fun RowValue(said: String) {
Text(
said,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/** Work in progress, at the size a row's trailing slot expects. */
@Composable
fun RowSpinner() {
CircularProgressIndicator(modifier = Modifier.size(SPINNER), strokeWidth = 2.dp)
}
/** "This opens something", for a row whose tap leads somewhere. */
@Composable
fun RowChevron() {
Icon(Lucide.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
/** The screen margin either side of a plate. */
val SectionEdge = 16.dp
/** Between one plate and the next. */
val SectionGap = 14.dp
/** Inside a plate. */
val SectionInset = 18.dp
/** Between one line of a plate and the next. */
val SectionRow = 6.dp
private val TITLE_GAP = 12.dp
/** Material's one-line list item height: the smallest a thumb reliably hits. */
private val ROW_HEIGHT = 48.dp
private val ROW_PAD = 6.dp
private val ICON = 22.dp
private val SMALL_ICON = 16.dp
private val ICON_GAP = 14.dp
private const val DISABLED_ALPHA = 0.5f
private val SPINNER = 16.dp
@@ -6,24 +6,13 @@ import android.net.Uri
import android.os.Build
import android.provider.Settings
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
private const val APK_FILENAME = "minstrel-update.apk"
/**
* Downloads the server-bundled APK and installs it over ourselves.
*
* The download goes through the shared [OkHttpClient] so it inherits
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
* server-relative, e.g. `/api/client/apk`). The APK lands in the
* cache dir; [SelfUpdateSession] streams it from there into a
* Installs a downloaded APK over ourselves. [UpdateRepository] brings it
* down; [SelfUpdateSession] streams it from there into a
* [android.content.pm.PackageInstaller] session.
*
* On Android O+ the user must have granted "install unknown apps"
@@ -34,26 +23,8 @@ private const val APK_FILENAME = "minstrel-update.apk"
@Singleton
class ApkInstaller @Inject constructor(
@ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient,
private val session: SelfUpdateSession,
) {
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("http://placeholder.invalid$apkUrl")
.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Download failed: HTTP ${response.code}")
}
val body = response.body ?: throw IOException("Empty download body")
val file = File(context.cacheDir, APK_FILENAME)
body.byteStream().use { input ->
file.outputStream().use { output -> input.copyTo(output) }
}
file
}
}
/** True when the system will accept an install intent without a permission detour. */
fun canInstall(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
@@ -0,0 +1,196 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.UpdateInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
data class UpdateUiState(
/** Null when there is nothing newer to offer. */
val offer: UpdateInfo? = null,
val stage: InstallStage = InstallStage.IDLE,
val progress: Float = 0f,
val problem: String? = null,
/** Asking the server which app it offers. */
val checking: Boolean = false,
/** Why the last ask got no answer, when it did not. */
val unchecked: String? = null,
/** The offer is already downloaded and checked: Install is all that is left. */
val ready: Boolean = false,
)
/** A newer build to tell the listener of, and whether it is already here to install. */
data class UpdateNews(val offer: UpdateInfo, val ready: Boolean)
/**
* The one record of whether a newer app is on offer and where installing it
* has got to, shared by Home (which tells of it) and the About card (in
* Settings, and in the sheet Home's news opens). A singleton because each
* screen gets its own view model, and an install started from one must
* show on the other.
*
* Only the platform's answer retires an offer: declined, failed or refused,
* it stays on screen. A newer build is fetched ahead on Wi-Fi
* ([UpdateScheduler]), so the listener is usually told it is ready and
* Install is one tap. Home is told at most once a day for each build
* ([UpdateTold]).
*/
@Singleton
class AppUpdates @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
private val updates: UpdateRepository,
private val installer: ApkInstaller,
private val told: UpdateTold,
private val fetching: UpdateScheduler,
) {
private val mutable = MutableStateFlow(UpdateUiState())
val state: StateFlow<UpdateUiState> = mutable.asStateFlow()
private val telling = Channel<UpdateNews>(Channel.BUFFERED)
/** A newer build to tell of, once; a screen that shows it calls [tellHere] first. */
val news: Flow<UpdateNews> = telling.receiveAsFlow()
private var asking: Job? = null
private var tells = false
/**
* Asks the server which app it offers: when Home or Settings opens, on a
* pull, and every few minutes while one is up. A newer one being fetched
* or installed is never taken off the screen by a later answer.
*/
fun check(): Job {
asking?.takeIf { it.isActive }?.let { return it }
return scope.launch { refresh() }.also { asking = it }
}
private suspend fun refresh() {
mutable.update { it.copy(checking = true) }
val offer = updates.check()
mutable.update { now ->
val busy = now.stage != InstallStage.IDLE
when (offer) {
is Offer.Newer -> now.copy(offer = if (busy) now.offer else offer.info, unchecked = null)
Offer.Current -> now.copy(offer = if (busy) now.offer else null, unchecked = null)
is Offer.Problem -> now.copy(unchecked = offer.message)
}.copy(checking = false)
}
fetchOrTidy(offer)
lookForReady()
tellIfDue()
}
/** A newer build is fetched ahead; once none is offered, a package kept for one is only space. */
private suspend fun fetchOrTidy(offer: Offer) {
if (mutable.value.stage != InstallStage.IDLE) return
when (offer) {
is Offer.Newer -> fetching.fetchAhead()
Offer.Current -> updates.tidy()
is Offer.Problem -> Unit
}
}
/** Home is up: what is on offer now is told, if it is due. */
fun tellHere() {
tells = true
tellIfDue()
}
private fun tellIfDue() {
val now = mutable.value
val offer = now.offer
if (!tells || offer == null || now.stage != InstallStage.IDLE) return
val key = buildKey(offer.code, offer.version)
val at = System.currentTimeMillis()
if (dueToSay(key, told.build(), told.at(), at)) {
told.told(key, at)
telling.trySend(UpdateNews(offer, now.ready))
}
}
/** Whether the offer has been downloaded meanwhile, perhaps in the background. */
fun lookForReady() {
mutable.update { it.copy(ready = it.offer?.let(updates::ready) != null) }
}
fun mayInstall(): Boolean = installer.canInstall()
/** Sends the listener to allow installs; the update carries on when they come back. */
fun askToInstall() {
mutable.update { it.copy(problem = ALLOW) }
installer.requestInstallPermission()
}
/** Came back from allowing installs without allowing them. */
fun refused() {
mutable.update { it.copy(problem = REFUSED) }
}
/**
* Downloads the offer (or takes the one already here) and installs it.
* Runs in the app's scope, so leaving the screen does not stop it.
*/
fun fetchAndInstall() {
val offer = mutable.value.offer ?: return
if (mutable.value.stage != InstallStage.IDLE) return
mutable.update { it.copy(stage = InstallStage.DOWNLOADING, progress = 0f, problem = null) }
scope.launch {
val download = updates.download(offer) { done -> mutable.update { it.copy(progress = done) } }
when (download) {
is Download.Problem -> mutable.update {
it.copy(stage = InstallStage.IDLE, problem = download.message)
}
is Download.Ready -> {
mutable.update { it.copy(ready = true) }
install(download.apk)
}
}
}
}
private suspend fun install(apk: File) {
mutable.update { it.copy(stage = InstallStage.INSTALLING) }
// On a successful silent install this never returns: the process is replaced.
val outcome = installer.install(apk)
mutable.update { it.copy(stage = InstallStage.IDLE, problem = outcome.message()) }
}
private companion object {
const val ALLOW = "Allow Minstrel to install apps. The update carries on when you come back."
const val REFUSED =
"Minstrel is still not allowed to install apps, so nothing was installed. Tap Update to try again."
}
}
/**
* What coming back to the app does to an update the listener asked for and
* was sent to allow installs for.
*/
internal enum class AfterAsking {
/** Allowed now: carry on with the update they already asked for. */
FETCH,
/** Came back without allowing it. The offer stays; they are told. */
REFUSED,
/** Nothing asked, something already under way, or the offer not known yet. */
WAIT,
}
internal fun afterAsking(asked: Boolean, mayInstall: Boolean, offered: Boolean, busy: Boolean): AfterAsking =
when {
!asked || busy || !offered -> AfterAsking.WAIT
mayInstall -> AfterAsking.FETCH
else -> AfterAsking.REFUSED
}
@@ -26,37 +26,22 @@ sealed interface InstallOutcome {
}
/**
* Where an install has got to, for the two surfaces that show it: the shell's
* [UpdateBanner][com.fabledsword.minstrel.update.ui.UpdateBanner] and the
* Settings About card.
* Where an install has got to, for the card that shows it (in Settings and
* in the sheet Home's update news opens).
*
* DOWNLOADING and INSTALLING are deliberately distinct: the install awaits
* the platform's verdict and can sit on a confirm dialog indefinitely, so
* one combined state would leave the UI claiming "Downloading…" through it.
* A failure is not a stage: it returns to IDLE with the reason beside it,
* and the offer stays so the listener can try again.
*/
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING, ERROR }
/** True while an install is underway and a second tap should do nothing. */
fun InstallStage.isBusy(): Boolean =
this == InstallStage.DOWNLOADING || this == InstallStage.INSTALLING
/**
* The stage an outcome lands the UI in. A cancelled install returns to IDLE
* rather than ERROR — the user chose it, so presenting it as a failure would
* be a lie with a red tint.
*/
fun InstallOutcome.stage(): InstallStage = when (this) {
InstallOutcome.Installed, InstallOutcome.Cancelled -> InstallStage.IDLE
is InstallOutcome.Failed -> InstallStage.ERROR
}
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING }
/**
* User-facing copy for an outcome; null when there is nothing worth saying.
*
* Lives beside the outcome rather than in either UI package because two
* separate screens surface the same verdicts and must not drift — the same
* reasoning that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy]
* outside the UI layer.
* Lives beside the outcome rather than in the UI package, the same reasoning
* that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy] outside the
* UI layer.
*/
fun InstallOutcome.message(): String? = when (this) {
InstallOutcome.Installed -> null
@@ -1,78 +0,0 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.UpdateInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L
/**
* Drives the shell's soft "update available" banner. Polls
* `/api/client/version` at launch + every 24h and, when the bundled
* APK outranks this build — by ordering key where the server reports one,
* by name otherwise — exposes its [UpdateInfo] so
* [com.fabledsword.minstrel.update.ui.UpdateBanner] can nudge an
* install.
*
* Dismissals are kept in memory and keyed by version, so dismissing
* one release's banner still lets a later release re-surface — and a
* restart re-shows it, which is acceptable nudging. Server 404 /
* network errors stay silent. Constructed at
* launch via the construct-the-singleton trick in `MinstrelApplication`.
*
* A debug build never polls: it is signed with a local debug key, so the
* server's release-signed APK could never install over it.
*/
@Singleton
class UpdateBannerController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
private val repository: UpdateRepository,
) {
private val latest = MutableStateFlow<UpdateInfo?>(null)
private val dismissed = MutableStateFlow<Set<String>>(emptySet())
/** The available update, or null when none / dismissed / unreachable. */
val available: StateFlow<UpdateInfo?> =
combine(latest, dismissed) { info, seen ->
info?.takeIf { it.version !in seen }
}.stateIn(scope, SharingStarted.Eagerly, null)
init {
if (!BuildConfig.DEBUG) startPolling()
}
private fun startPolling() {
scope.launch {
while (true) {
runOnce()
delay(POLL_INTERVAL_MS)
}
}
}
fun dismiss(version: String) {
dismissed.value = dismissed.value + version
}
private suspend fun runOnce() {
val info = runCatching { repository.getLatest() }.getOrNull() ?: return
latest.value = info.takeIf {
isUpdateAvailable(
serverCode = it.code,
serverName = it.version,
installedCode = BuildConfig.VERSION_CODE.toLong(),
installedName = BuildConfig.VERSION_NAME,
)
}
}
}
@@ -0,0 +1,84 @@
package com.fabledsword.minstrel.update.data
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Brings the newer app down before the listener is asked to install it, so
* Install is one tap rather than a wait. It never installs and shows
* nothing: whether to update stays the listener's.
*
* It asks the server again rather than being told what to fetch. WorkManager
* may run it hours after it was asked for, when Wi-Fi comes, and by then the
* server may offer another build.
*/
@HiltWorker
class UpdateFetchWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val updates: UpdateRepository,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = when (val offer = updates.check()) {
// A server out of reach is a phone away from home; the next check asks for this again.
is Offer.Problem -> Result.success()
Offer.Current -> {
updates.tidy()
Result.success()
}
is Offer.Newer -> when (updates.download(offer.info) {}) {
is Download.Ready -> Result.success()
// A dropped connection is worth another go after WorkManager's
// backoff, which also outlasts the server's one-download-a-minute
// limit; a few, not forever.
is Download.Problem -> if (runAttemptCount < TRIES) Result.retry() else Result.success()
}
}
private companion object {
const val TRIES = 3
}
}
/**
* Asks the platform to fetch the newer app when it is free to: on an
* unmetered network, with the battery and storage not low. Nothing turns
* this onto mobile data, because the listener's Update still downloads at
* once over whatever network there is; this only saves the wait.
*
* Kept rather than replaced, so a check every few minutes never restarts a
* download already under way.
*/
@Singleton
class UpdateScheduler @Inject constructor(@ApplicationContext private val context: Context) {
fun fetchAhead() {
WorkManager.getInstance(context).enqueueUniqueWork(
FETCHING,
ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<UpdateFetchWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
.setRequiresStorageNotLow(true)
.build(),
)
.build(),
)
}
private companion object {
const val FETCHING = "update-fetch"
}
}
@@ -0,0 +1,55 @@
package com.fabledsword.minstrel.update.data
import android.content.Context
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.update.api.ClientVersionApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import retrofit2.Retrofit
import java.io.File
import java.io.IOException
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object UpdateModule {
/**
* The download goes through the shared [OkHttpClient] so it inherits the
* auth cookie and the BaseUrlInterceptor host rewrite (apkUrl is
* server-relative, e.g. `/api/client/apk`). A refusal, such as the
* server's one-download-a-minute limit, reads as an IOException, which
* the repository reports and the fetch worker retries after a backoff.
*/
@Provides
@Singleton
fun provideUpdateRepository(
@ApplicationContext context: Context,
retrofit: Retrofit,
okHttpClient: OkHttpClient,
): UpdateRepository = UpdateRepository(
api = retrofit.create(ClientVersionApi::class.java),
apk = ApkSource { apkUrl ->
withContext(Dispatchers.IO) {
val request = Request.Builder().url("http://placeholder.invalid$apkUrl").build()
val response = okHttpClient.newCall(request).execute()
if (!response.isSuccessful) {
response.close()
throw IOException("Download failed: HTTP ${response.code}")
}
response.body ?: throw IOException("Empty download body")
}
},
dir = File(context.cacheDir, "update"),
installedCode = BuildConfig.VERSION_CODE.toLong(),
installedName = BuildConfig.VERSION_NAME,
offers = !BuildConfig.DEBUG,
legacyApk = File(context.cacheDir, "minstrel-update.apk"),
)
}
@@ -1,21 +1,158 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.models.wire.UpdateInfoWire
import com.fabledsword.minstrel.update.api.ClientVersionApi
import retrofit2.Retrofit
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import okhttp3.ResponseBody
import retrofit2.HttpException
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection.HTTP_NOT_FOUND
import java.security.MessageDigest
import kotlin.coroutines.cancellation.CancellationException
/** What asking the server for its app came to. */
sealed interface Offer {
/** The server's app is another build than this one, and installs over it. */
data class Newer(val info: UpdateInfo) : Offer
/** This is the server's app, or the same app built again, or the server has none to offer. */
data object Current : Offer
/** The server could not be asked. [message] says why, for the About card. */
data class Problem(val message: String) : Offer
}
/** What a download came to. */
sealed interface Download {
data class Ready(val apk: File) : Download
data class Problem(val message: String) : Download
}
/** Opens the server's APK for reading. The caller closes the body. */
fun interface ApkSource {
suspend fun open(apkUrl: String): ResponseBody
}
/**
* Thin facade over `/api/client/version`, used by the About card's
* Check for updates button and by [UpdateBannerController].
* Whether the server offers a newer app than this one, and fetching it.
*
* One way a package arrives, whether [UpdateFetchWorker] brought it down
* ahead on Wi-Fi or the listener pressed Update: [download]. The two take
* turns, and each looks first for what the other already brought, so
* nothing is downloaded twice. A package only gets its build's name once
* it checked out against the size and digest the server gave, so a file at
* that name is that build, whole ([ready]).
*
* A debug build is never offered one ([offers] false): it is signed with the
* debug key, so the platform would refuse the release build over it anyway.
*/
@Singleton
class UpdateRepository @Inject constructor(retrofit: Retrofit) {
private val api: ClientVersionApi = retrofit.create(ClientVersionApi::class.java)
class UpdateRepository(
private val api: ClientVersionApi,
private val apk: ApkSource,
private val dir: File,
private val installedCode: Long,
private val installedName: String,
private val offers: Boolean,
// A download left by older builds of the app, which nothing else removes.
private val legacyApk: File? = null,
) {
private val turns = Mutex()
suspend fun getLatest(): UpdateInfo = api.getVersion().toDomain()
suspend fun check(): Offer {
if (!offers) return Offer.Current
return try {
val info = api.getVersion().toDomain()
if (isUpdateAvailable(info.code, info.version, installedCode, installedName)) {
Offer.Newer(info)
} else {
Offer.Current
}
} catch (e: CancellationException) {
throw e
} catch (e: HttpException) {
// 404 is the server saying it bundles no app: nothing to offer.
if (e.code() == HTTP_NOT_FOUND) Offer.Current else Offer.Problem(ErrorCopy.fromThrowable(e))
} catch (e: IOException) {
Offer.Problem(ErrorCopy.fromThrowable(e))
} catch (e: SerializationException) {
Offer.Problem(ErrorCopy.fromThrowable(e))
}
}
/** The package for [info], when it is already here and whole. */
fun ready(info: UpdateInfo): File? =
waiting(info).takeIf { it.isFile && it.length() == info.sizeBytes }
/**
* The server's app, checked against the size and digest the server gave,
* so what is handed to the installer is the build that was offered, all
* of it. [progress] is called with 0 to 1 as it arrives.
*/
suspend fun download(info: UpdateInfo, progress: (Float) -> Unit): Download =
turns.withLock { ready(info)?.let { Download.Ready(it) } ?: fetch(info, progress) }
/** Drops every package kept here: none is on offer any more. */
suspend fun tidy() {
turns.withLock {
withContext(Dispatchers.IO) {
dir.listFiles()?.forEach { it.delete() }
legacyApk?.delete()
}
}
}
/**
* Writes to a part file and gives it the build's name only once it
* checks out, so a download cut off partway is never taken for one that
* finished. Any package for another build goes then.
*/
private suspend fun fetch(info: UpdateInfo, progress: (Float) -> Unit): Download {
val target = waiting(info)
val part = File(dir, target.name + PART)
return try {
val digest = withContext(Dispatchers.IO) {
save(apk.open(info.apkUrl), part, info.sizeBytes, progress)
}
withContext(Dispatchers.IO) { settle(info, part, target, digest) }
} catch (e: CancellationException) {
part.delete()
throw e
} catch (e: IOException) {
part.delete()
Download.Problem("Couldn't download the update: ${ErrorCopy.fromThrowable(e)}")
}
}
private fun settle(info: UpdateInfo, part: File, target: File, digest: String): Download {
// A server that predates the digest leaves the size as the only check.
val whole = part.length() == info.sizeBytes &&
(info.sha256.isEmpty() || digest.equals(info.sha256, ignoreCase = true))
return if (whole && part.renameTo(target)) {
dir.listFiles()?.filter { it != target }?.forEach { it.delete() }
Download.Ready(target)
} else {
part.delete()
Download.Problem("The download did not match the app the server offered. Try again.")
}
}
/** Where the package for [info] waits: named for its build. */
private fun waiting(info: UpdateInfo): File =
File(dir, "minstrel-${info.code ?: info.version.replace(UNSAFE, "_")}.apk")
private companion object {
const val PART = ".part"
val UNSAFE = Regex("[^A-Za-z0-9._-]")
}
}
private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
@@ -24,20 +161,55 @@ private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
channel = channel,
apkUrl = apkUrl,
sizeBytes = sizeBytes,
sha256 = sha256,
)
/** Writes [body] to [file], reporting progress against [size], and returns the SHA-256 written, in hex. */
internal fun save(body: ResponseBody, file: File, size: Long, progress: (Float) -> Unit): String {
file.parentFile?.mkdirs()
val digest = MessageDigest.getInstance("SHA-256")
// Closing the stream closes the body.
body.byteStream().use { input ->
file.outputStream().use { output -> copy(input, output, digest) { written -> report(written, size, progress) } }
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun copy(input: InputStream, output: OutputStream, digest: MessageDigest, written: (Long) -> Unit) {
val buffer = ByteArray(BUFFER_BYTES)
var total = 0L
var read = input.read(buffer)
while (read >= 0) {
output.write(buffer, 0, read)
digest.update(buffer, 0, read)
total += read
written(total)
read = input.read(buffer)
}
}
private fun report(written: Long, size: Long, progress: (Float) -> Unit) {
if (size > 0) progress((written.toFloat() / size).coerceAtMost(1f))
}
private const val BUFFER_BYTES = 64 * 1024
/**
* True when [server] should be offered over the installed build.
* True when [serverName] / [serverCode] should be offered over the installed
* build. Two questions, each asked of the value that answers it.
*
* **Decide on the ordering key whenever the server sends one.** That is the
* same value Android's package installer compares, so an offer made this way
* implies an install the platform will actually accept. Comparing NAMES
* while the platform installs by `versionCode` keeps nothing consistent
* between the two orderings: it can offer a build Android then refuses as
* a downgrade, or stay quiet about one it would take.
* **May it be installed over this one** is the ordering key's, whenever the
* server sends one: it is the same value Android's package installer
* compares, so an offer made on it is an install the platform will take.
*
* Name comparison survives only as the fallback for a server that predates
* the field. A null code means "this server cannot tell me" — never "zero" —
* **Is it another app at all** is the name's, compared for equality only.
* Every server image builds the app again at a later minute, so the code
* rises with a CI re-run or a change outside the shipped set while the app
* itself does not; asking only the first question offered the installed app
* to itself after each one.
*
* Name ordering survives only as the fallback for a server that predates the
* code. A null code means "this server cannot tell me" — never "zero" —
* because treating absent as zero would rank every such server as infinitely
* old and offer its build to everyone, forever.
*/
@@ -48,7 +220,7 @@ fun isUpdateAvailable(
installedName: String,
): Boolean =
if (serverCode != null) {
serverCode > installedCode
serverCode > installedCode && serverName != installedName
} else {
isVersionNewer(serverName, installedName)
}
@@ -0,0 +1,58 @@
package com.fabledsword.minstrel.update.data
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* The build Home last told the listener of, and when: on disk, so an app
* Android reopened does not say the same news again.
*/
@Singleton
class UpdateTold @Inject constructor(@ApplicationContext context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun build(): String = prefs.getString(BUILD, "").orEmpty()
fun at(): Long = prefs.getLong(AT, 0L)
fun told(build: String, at: Long) {
prefs.edit().putString(BUILD, build).putLong(AT, at).apply()
}
private companion object {
const val PREFS_NAME = "update_told"
const val BUILD = "build"
const val AT = "at"
}
}
/**
* The key a build is told under: its code, or its name from a server that
* sends no code.
*/
internal fun buildKey(code: Long?, version: String): String = code?.toString() ?: version
/**
* Whether a build on offer is said again: a build not yet told of is news at
* once; the same one again a day later, until it is installed. A clock that
* went backwards says it rather than hiding an update for a day.
*/
internal fun dueToSay(build: String, toldBuild: String, toldAt: Long, now: Long): Boolean {
val since = now - toldAt
return build != toldBuild || since < 0 || since >= SAY_AGAIN_AFTER_MS
}
/** What Home's snackbar says of an offer; its button opens the sheet. */
internal fun toldWords(version: String, ready: Boolean): String =
if (ready) {
"Version $version of the app is downloaded and ready."
} else {
"Version $version of the app is on your server."
}
/** The snackbar's button: Install when there is nothing left to download. */
internal fun toldAction(ready: Boolean): String = if (ready) "Install" else "Update"
internal const val SAY_AGAIN_AFTER_MS = 24L * 60 * 60 * 1000
@@ -0,0 +1,89 @@
package com.fabledsword.minstrel.update.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.settings.ui.formatBytes
import com.fabledsword.minstrel.theme.Muted
import com.fabledsword.minstrel.theme.RowSpinner
import com.fabledsword.minstrel.theme.RowValue
import com.fabledsword.minstrel.theme.Section
import com.fabledsword.minstrel.theme.SettingRow
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateUiState
/**
* The app's own version, the newer one when the server has it, and a check
* that is always there. Settings shows it as its About card, and the sheet
* Home's update news opens holds the very same card, so there is one place
* the app installs from. Back from allowing installs, the update the
* listener asked for carries on.
*/
@Composable
fun AppVersionCard(model: UpdateViewModel, modifier: Modifier = Modifier) {
LifecycleResumeEffect(model) {
model.resumed()
onPauseOrDispose {}
}
val state by model.state.collectAsStateWithLifecycle()
Section("About", modifier) {
SettingRow(title = "Version") {
RowValue("${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
}
val offer = state.offer
if (offer != null) {
Offered(offer, state, model::update)
} else {
val canCheck = !BuildConfig.DEBUG && !state.checking && state.stage == InstallStage.IDLE
SettingRow(
title = "Check for updates",
summary = versionWords(state, selfUpdates = !BuildConfig.DEBUG),
enabled = canCheck || state.checking,
onClick = if (canCheck) model::check else null,
) { if (state.checking) RowSpinner() }
}
state.problem?.let { Muted(it, tone = MaterialTheme.colorScheme.error) }
}
}
/** The newer build, and the one button that fetches and installs it. */
@Composable
private fun Offered(offer: UpdateInfo, state: UpdateUiState, update: () -> Unit) {
SettingRow(
title = if (state.ready) "Ready to install" else "New version on your server",
summary = describe(offer),
) {
when (state.stage) {
InstallStage.IDLE -> Button(onClick = update) { Text(if (state.ready) "Install" else "Update") }
InstallStage.DOWNLOADING -> RowSpinner()
InstallStage.INSTALLING -> RowValue("Installing…")
}
}
if (state.stage == InstallStage.DOWNLOADING) {
LinearProgressIndicator(progress = { state.progress }, modifier = Modifier.fillMaxWidth())
}
}
/** Whether this app is the newest, as far as the server has said. */
internal fun versionWords(state: UpdateUiState, selfUpdates: Boolean = true): String = when {
// Its own package and the debug key: the server's app would install beside it, never over it.
!selfUpdates -> "Debug build: updates install from Android Studio."
state.checking -> "Checking for a newer version…"
state.unchecked != null -> "Couldn't check for a newer version: ${state.unchecked}"
else -> "This is the newest version."
}
/** The name, the channel when it is the test line, and the size. */
internal fun describe(offer: UpdateInfo): String {
val channel = if (offer.channel == "dev") " (test build)" else ""
return "${offer.version}$channel · ${formatBytes(offer.sizeBytes)}"
}
@@ -1,149 +0,0 @@
package com.fabledsword.minstrel.update.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Download
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.X
import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.isBusy
/**
* Shell-level soft banner that nudges an available update. Renders
* nothing until [UpdateBannerController] surfaces a newer bundled APK
* (and the user hasn't dismissed that version): a download glyph,
* "Update Minstrel · {version} available", an Install button
* (download → system installer), and a dismiss X. Uses an understated
* surface tone, not the alarming error color reserved for
* `VersionTooOldBanner`.
*
* [ApkInstaller][com.fabledsword.minstrel.update.data.ApkInstaller]
* exposes no byte progress, so the downloading state is an
* indeterminate bar.
*/
@Composable
fun UpdateBanner(viewModel: UpdateBannerViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val info = state.info
AnimatedVisibility(
visible = info != null,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
// info can flip to null during the exit animation; hold the last
// non-null so the collapsing banner keeps its content.
val shown = info ?: return@AnimatedVisibility
BannerBody(
info = shown,
stage = state.stage,
message = state.message,
onInstall = { viewModel.install(shown) },
onDismiss = { viewModel.dismiss(shown.version) },
)
}
}
@Composable
private fun BannerBody(
info: UpdateInfo,
stage: InstallStage,
message: String?,
onInstall: () -> Unit,
onDismiss: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
) {
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
if (stage.isBusy()) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
color = MaterialTheme.colorScheme.primary,
)
}
if (message != null) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
@Composable
private fun BannerRow(
info: UpdateInfo,
stage: InstallStage,
onInstall: () -> Unit,
onDismiss: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Lucide.Download,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = if (stage == InstallStage.ERROR) {
"Update failed"
} else {
"Update Minstrel · ${info.version} available"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onInstall, enabled = !stage.isBusy()) {
// Downloading and installing are separate words because they're now
// separate waits — the install half suspends on the platform, which
// may be sitting on a confirm dialog.
Text(
when (stage) {
InstallStage.DOWNLOADING -> "Downloading…"
InstallStage.INSTALLING -> "Installing…"
else -> "Install"
},
)
}
IconButton(onClick = onDismiss) {
Icon(
imageVector = Lucide.X,
contentDescription = "Dismiss",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -1,92 +0,0 @@
package com.fabledsword.minstrel.update.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.update.data.ApkInstaller
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateBannerController
import com.fabledsword.minstrel.update.data.isBusy
import com.fabledsword.minstrel.update.data.message
import com.fabledsword.minstrel.update.data.stage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
data class UpdateBannerUiState(
val info: UpdateInfo? = null,
val stage: InstallStage = InstallStage.IDLE,
val message: String? = null,
)
/**
* Thin VM over [UpdateBannerController]. Surfaces the available update
* and runs the download → install handoff via [ApkInstaller], mirroring
* the About card's flow (route to "install unknown apps" settings first
* when the permission is missing).
*/
@HiltViewModel
class UpdateBannerViewModel @Inject constructor(
private val controller: UpdateBannerController,
private val installer: ApkInstaller,
) : ViewModel() {
private val installState = MutableStateFlow(InstallSnapshot(InstallStage.IDLE, null))
val uiState: StateFlow<UpdateBannerUiState> =
combine(controller.available, installState) { info, install ->
UpdateBannerUiState(info = info, stage = install.stage, message = install.message)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = UpdateBannerUiState(),
)
fun dismiss(version: String) = controller.dismiss(version)
fun install(info: UpdateInfo) {
if (installState.value.stage.isBusy()) return
if (!installer.canInstall()) {
installer.requestInstallPermission()
installState.value = InstallSnapshot(
InstallStage.IDLE,
"Allow installs for Minstrel, then tap Install again.",
)
return
}
viewModelScope.launch {
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
val apk = download(info.apkUrl)
if (apk != null) {
// Await the platform's verdict rather than firing an intent and
// assuming it worked. On a silent install this suspends until
// the process is replaced, so the line below is only reached
// when the install did NOT simply succeed.
installState.value = InstallSnapshot(InstallStage.INSTALLING, null)
val outcome = installer.install(apk)
installState.value = InstallSnapshot(outcome.stage(), outcome.message())
}
}
}
private suspend fun download(apkUrl: String): File? =
runCatching { installer.downloadApk(apkUrl) }
.onFailure { e ->
installState.value = InstallSnapshot(
InstallStage.ERROR,
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
)
}
.getOrNull()
}
private data class InstallSnapshot(val stage: InstallStage, val message: String?)
@@ -0,0 +1,88 @@
package com.fabledsword.minstrel.update.ui
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.fabledsword.minstrel.update.data.toldAction
import com.fabledsword.minstrel.update.data.toldWords
import kotlinx.coroutines.delay
/** How often a screen on show asks the server about a newer app by itself. */
private const val CHECK_EVERY_MS = 5 * 60 * 1000L
/**
* A newer app, told on Home (after Librarian's and Gallery's update tray): a
* snackbar at the foot of the screen, never a dialog, and its button opens a
* sheet holding the very card Settings shows, so there is one place the app
* installs from. Each build is told at most once a day. The sheet stays open
* through a trip to allow installs, which is where the card carries on from.
*/
@Composable
fun BoxScope.UpdateNotices(model: UpdateViewModel) {
val notices = remember { SnackbarHostState() }
var updating by rememberSaveable { mutableStateOf(false) }
CheckWhileShown(model)
LaunchedEffect(model) {
model.tellHere()
model.news.collect { news ->
val tapped = notices.showSnackbar(
message = toldWords(news.offer.version, news.ready),
actionLabel = toldAction(news.ready),
withDismissAction = true,
duration = SnackbarDuration.Long,
)
if (tapped == SnackbarResult.ActionPerformed) updating = true
}
}
SnackbarHost(notices, Modifier.align(Alignment.BottomCenter).padding(12.dp))
if (updating) UpdateSheet(model) { updating = false }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun UpdateSheet(model: UpdateViewModel, dismiss: () -> Unit) {
ModalBottomSheet(onDismissRequest = dismiss) {
AppVersionCard(
model,
Modifier.navigationBarsPadding().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
)
}
}
/**
* Asks the server about a newer app when the screen opens, as soon as the
* app comes back to the front, and every [CHECK_EVERY_MS] while it is on
* screen. A screen in the background never asks.
*/
@Composable
fun CheckWhileShown(model: UpdateViewModel) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(model, lifecycle) {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
while (true) {
model.check()
delay(CHECK_EVERY_MS)
}
}
}
}
@@ -0,0 +1,86 @@
package com.fabledsword.minstrel.update.ui
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.update.data.AfterAsking
import com.fabledsword.minstrel.update.data.AppUpdates
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateNews
import com.fabledsword.minstrel.update.data.UpdateUiState
import com.fabledsword.minstrel.update.data.afterAsking
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* A screen's handle on [AppUpdates]. What it adds is the one thing that
* belongs to the screen: that the listener pressed Update and was sent to
* allow installs. That is kept in [saved], so it outlives the process
* Android may restart from the settings page, and the update carries on
* when they come back ([resumed]) instead of asking them to tap again.
*/
@HiltViewModel
class UpdateViewModel @Inject constructor(
private val updates: AppUpdates,
private val saved: SavedStateHandle,
) : ViewModel() {
val state: StateFlow<UpdateUiState> = updates.state
val news: Flow<UpdateNews> = updates.news
/** Asks the server again; once the answer is in, an update asked for before a restart carries on. */
fun check() {
viewModelScope.launch {
updates.check().join()
carryOn()
}
}
fun tellHere() = updates.tellHere()
/** The app is in front again, perhaps back from allowing installs. */
fun resumed() {
updates.lookForReady()
carryOn()
}
fun update() {
val now = state.value
when {
now.offer == null || now.stage != InstallStage.IDLE -> Unit
!updates.mayInstall() -> {
saved[ASKED] = true
updates.askToInstall()
}
else -> updates.fetchAndInstall()
}
}
private fun carryOn() {
val now = state.value
val next = afterAsking(
asked = saved.get<Boolean>(ASKED) == true,
mayInstall = updates.mayInstall(),
offered = now.offer != null,
busy = now.stage != InstallStage.IDLE,
)
when (next) {
AfterAsking.WAIT -> Unit
AfterAsking.REFUSED -> {
saved[ASKED] = false
updates.refused()
}
AfterAsking.FETCH -> {
saved[ASKED] = false
updates.fetchAndInstall()
}
}
}
private companion object {
/** The listener asked for the update and was sent to allow installs. */
const val ASKED = "update.asked_to_allow"
}
}
@@ -2,23 +2,32 @@ package com.fabledsword.minstrel.api
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.auth.FakeSessionVault
import com.fabledsword.minstrel.auth.SessionVault
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class AuthCookieInterceptorTest {
@@ -92,6 +101,87 @@ class AuthCookieInterceptorTest {
assertNull(authStore.sessionCookie.value)
}
@Test
fun `a refused session asks the UI to open sign-in`() {
authStore.setSessionCookie("session=expired")
server.enqueue(MockResponse().setResponseCode(401))
client.newCall(Request.Builder().url(placeholderUrl("/api/test")).build()).execute()
assertTrue(authStore.sessionRejected.value)
}
// A refusal of a request that carried no cookie says nothing about the
// stored login, and must not raise the sign-in screen either.
@Test
fun `a 401 to a request sent without a cookie signs nobody out`() {
server.enqueue(MockResponse().setResponseCode(401))
client.newCall(Request.Builder().url(placeholderUrl("/api/test")).build()).execute()
assertFalse(authStore.sessionRejected.value)
}
// The interceptor reads the cookie when the request starts. A sign-in
// that lands while an old request is in flight must survive that old
// request's 401.
@Test
fun `a 401 for an older cookie keeps the one stored since`() {
authStore.setSessionCookie("session=old")
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
authStore.setSessionCookie("session=new")
return MockResponse().setResponseCode(401)
}
}
client.newCall(Request.Builder().url(placeholderUrl("/api/test")).build()).execute()
assertEquals("session=new", authStore.sessionCookie.value)
assertFalse(authStore.sessionRejected.value)
}
// At launch the stored cookie loads from the Keystore off the main
// thread. A request made before that finishes waits for it rather than
// going out without the login.
@Test
fun `a request made while the stored cookie is still loading carries it`() {
val slowVault =
object : SessionVault {
override fun read(): String? {
Thread.sleep(SLOW_VAULT_MS)
return "session=stored"
}
override fun write(value: String?): Boolean = true
}
// The store runs on a real background dispatcher here, so its row
// collector lands after construction; the row itself carries the
// server URL so that collector cannot reset it to the default.
val row = AuthSessionEntity(baseUrl = server.url("/").toString())
val dao =
mockk<AuthSessionDao> {
every { observe() } returns flowOf(row)
coEvery { get() } returns row
coEvery { upsert(any()) } returns Unit
coEvery { setSessionCookie(any()) } returns Unit
coEvery { setBaseUrl(any()) } returns Unit
}
val loading = AuthStore(dao, slowVault, CoroutineScope(SupervisorJob() + Dispatchers.Default))
val launchClient =
OkHttpClient.Builder()
.addInterceptor(AuthCookieInterceptor(loading))
.addInterceptor(BaseUrlInterceptor(loading))
.build()
server.enqueue(MockResponse().setResponseCode(200))
launchClient.newCall(Request.Builder().url(placeholderUrl("/api/library/sync")).build()).execute()
assertEquals("session=stored", server.takeRequest().getHeader("Cookie"))
assertEquals("session=stored", loading.sessionCookie.value)
}
@Test
fun `captures Set-Cookie on successful response`() {
server.enqueue(
@@ -145,4 +235,8 @@ class AuthCookieInterceptorTest {
assertEquals("session=keep", authStore.sessionCookie.value)
}
private companion object {
const val SLOW_VAULT_MS = 300L
}
}
@@ -0,0 +1,87 @@
package com.fabledsword.minstrel.auth
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.flowOf
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class AuthControllerTest {
private lateinit var server: MockWebServer
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val json = Json { ignoreUnknownKeys = true }
@BeforeEach
fun setup() {
server = MockWebServer().apply { start() }
}
@AfterEach
fun teardown() {
scope.cancel()
server.shutdown()
}
// At launch the controller refreshes the profile (it carries account
// flags such as debug mode). The stored login loads from the Keystore
// off the main thread, so the refresh has to wait for it rather than
// read the user as signed out and skip.
@Test
fun `the launch profile refresh waits for the stored login`() {
val slowVault =
object : SessionVault {
override fun read(): String? {
Thread.sleep(SLOW_VAULT_MS)
return "session=stored"
}
override fun write(value: String?): Boolean = true
}
val row = AuthSessionEntity(baseUrl = server.url("/").toString())
val dao =
mockk<AuthSessionDao> {
every { observe() } returns flowOf(row)
coEvery { get() } returns row
coEvery { upsert(any()) } returns Unit
coEvery { setSessionCookie(any()) } returns Unit
coEvery { setBaseUrl(any()) } returns Unit
}
val store = AuthStore(dao, slowVault, scope)
val retrofit =
Retrofit.Builder()
.baseUrl(server.url("/"))
.client(OkHttpClient.Builder().build())
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
server.enqueue(MockResponse().setResponseCode(200).setBody("""{"id":"u1","username":"listener"}"""))
AuthController(store, json, scope, retrofit)
val request = server.takeRequest(REQUEST_WAIT_SECONDS, TimeUnit.SECONDS)
assertNotNull(request, "the profile refresh never reached the server")
assertEquals("/api/me", request.path)
}
private companion object {
const val SLOW_VAULT_MS = 300L
const val REQUEST_WAIT_SECONDS = 5L
}
}
@@ -0,0 +1,155 @@
package com.fabledsword.minstrel.cache.audiocache
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* The two audio-cache budgets: Liked songs and Recently played. A track
* that is liked and also played counts against Liked only, and each budget
* drops its own least-recently-used audio first.
*/
class BucketLedgerTest {
private data class Span(val key: String, val position: Long, val length: Long, val touched: Long)
private fun ledger(likedCap: Long = 0L, recentCap: Long = 0L) = BucketLedger<Span>(
keyOf = { it.key },
lengthOf = { it.length },
lruOrder = compareBy<Span> { it.touched }.thenBy { it.key }.thenBy { it.position },
).apply {
likedCapBytes = likedCap
recentCapBytes = recentCap
}
/** Drop spans the way the evictor does, until both budgets fit. */
private fun BucketLedger<Span>.drain(): List<Span> {
val dropped = mutableListOf<Span>()
while (true) {
val span = nextEviction() ?: return dropped
remove(span)
dropped += span
}
}
@Test
fun `a liked track's bytes count against Liked only`() {
val l = ledger()
l.setLiked(setOf("liked"))
l.add(Span("liked", 0, 300, touched = 1))
l.add(Span("other", 0, 200, touched = 2))
assertEquals(300, l.bytesIn(CacheBucket.LIKED))
assertEquals(200, l.bytesIn(CacheBucket.RECENT))
}
@Test
fun `filling Recently played drops its oldest audio and never liked audio`() {
val l = ledger(likedCap = 1_000, recentCap = 250)
l.setLiked(setOf("liked"))
val liked = Span("liked", 0, 100, touched = 0)
val oldest = Span("a", 0, 100, touched = 1)
l.add(liked)
l.add(oldest)
l.add(Span("b", 0, 100, touched = 2))
l.add(Span("c", 0, 100, touched = 3))
assertEquals(listOf(oldest), l.drain())
assertEquals(200, l.bytesIn(CacheBucket.RECENT))
assertEquals(100, l.bytesIn(CacheBucket.LIKED))
}
@Test
fun `filling Liked drops its least recently played liked audio`() {
val l = ledger(likedCap = 150, recentCap = 1_000)
l.setLiked(setOf("x", "y"))
val older = Span("x", 0, 100, touched = 1)
l.add(older)
l.add(Span("y", 0, 100, touched = 2))
l.add(Span("recent", 0, 500, touched = 0))
assertEquals(listOf(older), l.drain())
assertEquals(500, l.bytesIn(CacheBucket.RECENT))
}
@Test
fun `liking a cached track moves its bytes into Liked`() {
val l = ledger(likedCap = 1_000, recentCap = 150)
l.setLiked(emptySet())
l.add(Span("t", 0, 100, touched = 1))
l.add(Span("t", 100, 50, touched = 2))
l.add(Span("u", 0, 100, touched = 3))
assertEquals(250, l.bytesIn(CacheBucket.RECENT))
l.setLiked(setOf("t"))
assertEquals(150, l.bytesIn(CacheBucket.LIKED))
assertEquals(100, l.bytesIn(CacheBucket.RECENT))
assertEquals(emptyList(), l.drain())
}
@Test
fun `unliking moves the bytes to Recently played, which then trims`() {
val l = ledger(likedCap = 1_000, recentCap = 150)
l.setLiked(setOf("t"))
val unliked = Span("t", 0, 100, touched = 1)
l.add(unliked)
l.add(Span("u", 0, 100, touched = 2))
l.setLiked(emptySet())
assertEquals(listOf(unliked), l.drain())
assertEquals(0, l.bytesIn(CacheBucket.LIKED))
}
@Test
fun `shrinking a cap trims at once`() {
val l = ledger(recentCap = 1_000)
l.setLiked(emptySet())
l.add(Span("a", 0, 100, touched = 1))
l.add(Span("b", 0, 100, touched = 2))
assertEquals(emptyList(), l.drain())
l.recentCapBytes = 100
assertEquals(listOf("a"), l.drain().map { it.key })
}
@Test
fun `a cap of 0 never evicts`() {
val l = ledger(likedCap = 0, recentCap = 0)
l.setLiked(setOf("liked"))
repeat(50) { l.add(Span("k$it", 0, 1_000_000, touched = it.toLong())) }
l.add(Span("liked", 0, 1_000_000, touched = 99))
assertNull(l.nextEviction())
}
@Test
fun `nothing is evicted before the liked set is known`() {
val l = ledger(likedCap = 100, recentCap = 100)
l.add(Span("maybe-liked", 0, 500, touched = 1))
assertNull(l.nextEviction())
}
@Test
fun `a write about to start makes room in its own bucket`() {
val l = ledger(likedCap = 1_000, recentCap = 200)
l.setLiked(setOf("liked"))
l.add(Span("a", 0, 150, touched = 1))
assertNull(l.nextEviction(pendingKey = "liked", pendingBytes = 100))
assertEquals("a", l.nextEviction(pendingKey = "new", pendingBytes = 100)?.key)
}
@Test
fun `Liked room is what the Liked cap has left`() {
val l = ledger(likedCap = 500)
l.setLiked(setOf("x"))
l.add(Span("x", 0, 200, touched = 1))
assertEquals(300, l.likedRoomBytes())
l.likedCapBytes = 0
assertEquals(Long.MAX_VALUE, l.likedRoomBytes())
}
}
@@ -4,6 +4,7 @@ import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.SystemPlaylistsStatus
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class BuildPlaylistsRowTest {
@@ -103,4 +104,40 @@ class BuildPlaylistsRowTest {
assertTrue(buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = true).isEmpty())
}
private fun genreMix(name: String, cached: Boolean) = PlaylistRef(
id = name,
userId = "u",
name = name,
systemVariant = "genre_mix",
trackCount = 50,
fullyCached = cached,
)
@Test
fun `genre mixes get their own row in name order and stay out of the carousel`() {
val owned = listOf(
genreMix("Shoegaze mix", cached = true),
genreMix("dub mix", cached = true),
genreMix("Jazz mix", cached = true),
user("u1", cached = true),
)
val row = buildGenreMixRow(owned, offline = false)
val names = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.name }
assertEquals(listOf("dub mix", "Jazz mix", "Shoegaze mix"), names)
val carousel = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = false)
.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
assertEquals(listOf("u1"), carousel)
}
@Test
fun `genre mixes are not refreshable, so a cached one plays offline`() {
assertFalse(genreMix("Jazz mix", cached = true).refreshable)
val owned = listOf(genreMix("A mix", cached = false), genreMix("B mix", cached = true))
val names = buildGenreMixRow(owned, offline = true)
.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.name }
assertEquals(listOf("B mix", "A mix"), names)
assertTrue(buildGenreMixRow(emptyList(), offline = false).isEmpty())
}
}
@@ -0,0 +1,65 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* The pieces that let a renderer without a queue be driven from ours: which
* renderers get which mode, and mapping the URI it reports back to a queue
* position.
*/
class SingleTrackQueueTest {
private fun t(id: String) = TrackRef(id = id, title = "", albumId = "", artistId = "")
private fun route(manufacturer: String, zgt: Boolean = false) = UpnpRoute(
id = "uuid:x",
name = "Room",
manufacturer = manufacturer,
modelName = "",
avTransportControlUrl = "http://192.168.0.2:1400/AVTransport/Control".toHttpUrl(),
renderingControlUrl = null,
zoneGroupTopologyControlUrl = if (zgt) "http://192.168.0.2:1400/ZGT/Control".toHttpUrl() else null,
)
@Test
fun `a Sonos is driven through its native queue`() {
assertEquals(QueueMode.NATIVE_QUEUE, QueueMode.forRoute(route("Sonos, Inc.").isSonos))
assertEquals(QueueMode.NATIVE_QUEUE, QueueMode.forRoute(route("", zgt = true).isSonos))
}
@Test
fun `any other renderer is driven one track at a time`() {
assertEquals(QueueMode.SINGLE_TRACK, QueueMode.forRoute(route("Yamaha Corporation").isSonos))
assertEquals(QueueMode.SINGLE_TRACK, QueueMode.forRoute(route("").isSonos))
}
@Test
fun `the track id is read from a stream URL, leveled or not`() {
assertEquals("abc", streamUriTrackId("http://h:8080/api/tracks/abc/stream.flac?t=tok"))
assertEquals("abc", streamUriTrackId("http://h/api/tracks/abc/leveled.flac?t=tok"))
}
@Test
fun `a URL that is not ours has no track id`() {
assertNull(streamUriTrackId("x-rincon-queue:RINCON_ABC#0"))
assertNull(streamUriTrackId(""))
assertNull(streamUriTrackId("http://h/api/tracks/"))
}
@Test
fun `a track queued twice resolves to the copy nearest the cursor`() {
val queue = listOf(t("a"), t("b"), t("a"), t("c"), t("a"))
assertEquals(2, nearestIndexOf(queue, "a", preferNear = 3))
assertEquals(0, nearestIndexOf(queue, "a", preferNear = 0))
}
@Test
fun `a track not in the queue has no index`() {
assertNull(nearestIndexOf(listOf(t("a")), "z", preferNear = 0))
}
}
@@ -0,0 +1,68 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.player.output.upnp.TransportState
import org.junit.jupiter.api.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* A one-track renderer says STOPPED both when a track plays out and when it
* stops partway; only the first should start the next track.
*/
class TrackEndDetectorTest {
private val d = TrackEndDetector()
private fun poll(state: TransportState, positionMs: Long, durationMs: Long = 200_000L, known: Long = 0L) =
d.onPoll(state, positionMs, durationMs, known)
@Test
fun `a stop just after the last playing reading near the end is an ending`() {
poll(TransportState.PLAYING, 198_000L)
assertTrue(poll(TransportState.STOPPED, 0L))
}
@Test
fun `a stop partway through is not an ending`() {
poll(TransportState.PLAYING, 90_000L)
assertFalse(poll(TransportState.STOPPED, 90_000L))
}
@Test
fun `the stop a renderer passes through while loading a track is not an ending`() {
// Short track, never seen playing yet: without the playing guard,
// position 0 would already be within the slack of the end.
assertFalse(poll(TransportState.STOPPED, 0L, durationMs = 3_000L))
}
@Test
fun `no media present after a track plays out is an ending`() {
poll(TransportState.PLAYING, 199_500L)
assertTrue(poll(TransportState.NO_MEDIA, 0L, durationMs = 0L))
}
@Test
fun `a pause near the end is not an ending`() {
poll(TransportState.PLAYING, 199_000L)
assertFalse(poll(TransportState.PAUSED, 199_000L))
}
@Test
fun `the library duration stands in for a renderer that reports none`() {
poll(TransportState.PLAYING, 178_000L, durationMs = 0L, known = 180_000L)
assertTrue(poll(TransportState.STOPPED, 0L, durationMs = 0L, known = 180_000L))
}
@Test
fun `with no duration from anywhere nothing is judged an ending`() {
poll(TransportState.PLAYING, 5_000L, durationMs = 0L)
assertFalse(poll(TransportState.STOPPED, 0L, durationMs = 0L))
}
@Test
fun `reset forgets the track that was playing`() {
poll(TransportState.PLAYING, 199_000L)
d.reset()
assertFalse(poll(TransportState.STOPPED, 0L))
}
}
@@ -21,7 +21,7 @@ class AVTransportClientTest {
server = MockWebServer()
server.start()
val controlUrl = server.url("/MediaRenderer/AVTransport/Control")
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl)
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl, sonos = true)
}
@AfterEach
@@ -229,6 +229,45 @@ class AVTransportClientTest {
assertEquals(0, client.getMediaInfo().nrTracks)
}
@Test
fun `setNextAVTransportURI sends NextURI with its metadata`() = runTest {
server.enqueue(emptyResponse("SetNextAVTransportURI"))
client.setNextAVTransportURI(
uri = "http://example.com/api/tracks/t2/stream.flac",
mime = "audio/flac",
title = "Next",
)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("SetNextAVTransportURI")) { "body: $body" }
assertTrue(body.contains("<NextURI>http://example.com/api/tracks/t2/stream.flac</NextURI>")) {
"body: $body"
}
assertTrue(body.contains("<NextURIMetaData>")) { "body: $body" }
}
@Test
fun `a Sonos gets the Rincon metadata it requires`() = runTest {
server.enqueue(emptyResponse("SetAVTransportURI"))
client.setAVTransportURIWithMetadata("http://x/a.flac", "audio/flac", "Song")
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("RINCON_AssociatedZPUDN")) { "body: $body" }
}
@Test
fun `a generic renderer gets plain DIDL that declares the stream seekable`() = runTest {
val generic = AVTransportClient(
SoapClient(OkHttpClient()),
server.url("/MediaRenderer/AVTransport/Control"),
sonos = false,
)
server.enqueue(emptyResponse("SetAVTransportURI"))
generic.setAVTransportURIWithMetadata("http://x/a.flac", "audio/flac", "Song")
val body = server.takeRequest().body.readUtf8()
assertFalse(body.contains("RINCON")) { "body: $body" }
assertFalse(body.contains("rinconnetworks")) { "body: $body" }
assertTrue(body.contains("audio/flac:DLNA.ORG_OP=01")) { "body: $body" }
}
private fun emptyResponse(action: String): MockResponse = MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
@@ -0,0 +1,28 @@
package com.fabledsword.minstrel.update.data
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
/** Back from the "install unknown apps" page, an update that was asked for carries on. */
class AfterAskingTest {
@Test
fun `allowed now, the update carries on`() {
assertEquals(AfterAsking.FETCH, afterAsking(asked = true, mayInstall = true, offered = true, busy = false))
}
@Test
fun `back without allowing it, the listener is told and the offer stays`() {
assertEquals(AfterAsking.REFUSED, afterAsking(asked = true, mayInstall = false, offered = true, busy = false))
}
@Test
fun `nothing asked, nothing happens`() {
assertEquals(AfterAsking.WAIT, afterAsking(asked = false, mayInstall = true, offered = true, busy = false))
}
@Test
fun `an offer not known yet, or an install under way, waits`() {
assertEquals(AfterAsking.WAIT, afterAsking(asked = true, mayInstall = true, offered = false, busy = false))
assertEquals(AfterAsking.WAIT, afterAsking(asked = true, mayInstall = true, offered = true, busy = true))
}
}
@@ -0,0 +1,120 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.models.wire.UpdateInfoWire
import com.fabledsword.minstrel.update.api.ClientVersionApi
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import retrofit2.HttpException
import retrofit2.Response
import java.io.File
import java.io.IOException
import java.security.MessageDigest
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The fetch worker and the listener's Update both download through here; a
* package only counts as ready once it is the whole build that was offered.
*/
class UpdateRepositoryTest {
@TempDir
lateinit var dir: File
private val apkBytes = "the newer app".toByteArray()
private val sha = MessageDigest.getInstance("SHA-256").digest(apkBytes).joinToString("") { "%02x".format(it) }
private fun wire(code: Long? = 200L, version: String = "2026.10.26.1200", sha256: String = sha) = UpdateInfoWire(
version = version,
code = code,
sizeBytes = apkBytes.size.toLong(),
sha256 = sha256,
)
private var downloads = 0
private fun repo(
version: () -> UpdateInfoWire = { wire() },
body: () -> ByteArray = { apkBytes },
offers: Boolean = true,
) = UpdateRepository(
api = object : ClientVersionApi {
override suspend fun getVersion(): UpdateInfoWire = version()
},
apk = ApkSource {
downloads += 1
body().toResponseBody()
},
dir = dir,
installedCode = 100L,
installedName = "2026.10.20.0900",
offers = offers,
)
@Test
fun `a newer build is offered`() = runTest {
assertIs<Offer.Newer>(repo().check())
}
@Test
fun `the same app built again at a later minute is not offered`() = runTest {
assertEquals(Offer.Current, repo(version = { wire(version = "2026.10.20.0900") }).check())
}
@Test
fun `a server with no app to offer is not a problem`() = runTest {
val none = repo(version = { throw HttpException(Response.error<Any>(404, "".toResponseBody())) })
assertEquals(Offer.Current, none.check())
}
@Test
fun `a server out of reach is a problem, not an all-clear`() = runTest {
assertIs<Offer.Problem>(repo(version = { throw IOException("unreachable") }).check())
}
@Test
fun `a debug build is never offered one`() = runTest {
assertEquals(Offer.Current, repo(offers = false).check())
}
@Test
fun `a build already fetched is installed from disk without a second download`() = runTest {
val r = repo()
val info = (r.check() as Offer.Newer).info
assertIs<Download.Ready>(r.download(info) {})
assertNotNull(r.ready(info))
val again = r.download(info) {}
assertIs<Download.Ready>(again)
assertEquals(1, downloads)
}
@Test
fun `a download cut off partway is not taken for the build`() = runTest {
val r = repo(body = { apkBytes.copyOf(5) })
val info = (r.check() as Offer.Newer).info
assertIs<Download.Problem>(r.download(info) {})
assertNull(r.ready(info))
assertTrue(dir.listFiles().orEmpty().isEmpty(), "the part file is removed")
}
@Test
fun `a download that does not match the digest is not taken for the build`() = runTest {
val r = repo(body = { "the other app".toByteArray() }) // same length, other bytes
val info = (r.check() as Offer.Newer).info
assertIs<Download.Problem>(r.download(info) {})
assertNull(r.ready(info))
}
@Test
fun `tidy drops a package kept for a build no longer offered`() = runTest {
val r = repo()
val info = (r.check() as Offer.Newer).info
r.download(info) {}
r.tidy()
assertNull(r.ready(info))
}
}
@@ -0,0 +1,53 @@
package com.fabledsword.minstrel.update.data
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/** Home tells of each build at most once a day, and says whether it is ready. */
class UpdateToldTest {
private val day = SAY_AGAIN_AFTER_MS
private val t0 = 1_000_000_000L
@Test
fun `a build not yet told of is told at once`() {
assertTrue(dueToSay("3523900", toldBuild = "3523847", toldAt = t0, now = t0 + 1))
assertTrue(dueToSay("3523900", toldBuild = "", toldAt = 0L, now = t0))
}
@Test
fun `the same build is not told again within a day`() {
assertFalse(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 + day - 1))
}
@Test
fun `the same build is told again the day after`() {
assertTrue(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 + day))
}
@Test
fun `a clock set back tells rather than hiding the update`() {
assertTrue(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 - 1))
}
@Test
fun `a build is keyed by its code, or by its name from a server with none`() {
assertEquals("3523900", buildKey(3523900L, "2026.09.10.1432"))
assertEquals("2026.09.10.1432", buildKey(null, "2026.09.10.1432"))
}
@Test
fun `the snackbar says whether the update is already here`() {
assertEquals(
"Version 2026.10.26.1200 of the app is on your server.",
toldWords("2026.10.26.1200", ready = false),
)
assertEquals(
"Version 2026.10.26.1200 of the app is downloaded and ready.",
toldWords("2026.10.26.1200", ready = true),
)
assertEquals("Update", toldAction(ready = false))
assertEquals("Install", toldAction(ready = true))
}
}
@@ -62,6 +62,21 @@ class UpdateVersioningTest {
)
}
/**
* Every server image builds the app again at a later minute, so a CI
* re-run, or a change outside the shipped set, raises the code while the
* app is the same one. The name says so, and it is not offered.
*/
@Test
fun `the same app built again at a later minute is not offered`() {
assertFalse(
isUpdateAvailable(
serverCode = 3523900, serverName = "2026.09.10.1432",
installedCode = 3523847, installedName = "2026.09.10.1432",
),
)
}
@Test
fun `falls back to the name when the server reports no ordering key`() {
assertTrue(
+41 -11
View File
@@ -3,11 +3,17 @@
# Derives the three values a build is stamped with, and the tag that names it.
#
# name=YYYY.MM.DD.HHMM label for people, from the timestamp of the newest
# commit that CHANGED SOMETHING SHIPPED (see SHIPPED)
# commit that CHANGED SOMETHING SHIPPED (see SHIPPED,
# or SHIPPED_APK with --apk)
# code=<int> ordering key, minutes since 2020-01-01 at BUILD time
# tag=v<name> what a release of this commit must be called
#
# Usage: ci/version.sh [<commit-ish>] (default HEAD)
# Usage: ci/version.sh [--apk] [<commit-ish>] (default HEAD)
#
# --apk names the Android app from android/ alone. The app compares this
# name with the one it is running, and a different name is what
# offers an update — so a name that moved for a server-only commit
# would offer every phone a rebuild of the app it already has.
#
# This exists as a script rather than inline workflow YAML for one reason:
# release.yml only runs on `main` and on tags, so anything living inside it is
@@ -30,6 +36,12 @@
set -euo pipefail
readonly EPOCH_2020=1577836800 # 2020-01-01T00:00:00Z
apk=false
if [ "${1:-}" = "--apk" ]; then
apk=true
shift
fi
readonly REF="${1:-HEAD}"
# Both clocks are overridable so a test can pin them. Nothing but tests should
@@ -48,14 +60,17 @@ readonly REF="${1:-HEAD}"
# wrongly included → version moves when nothing shipped. Cosmetic noise in
# a string nobody sorts.
#
# THIS REPO SHIPS TWO ARTIFACTS FROM ONE DERIVATION, and that is why the list
# is shorter than it looks like it should be. The server image ships cmd/,
# internal/, shared/, web/, config.example.yaml and client/; the APK ships
# android/. Neither ships the other's sources — but excluding android/ here
# would stop an Android-only commit from moving the APK's OWN version, which
# is the dangerous direction. So this is the union: exclude only what ships in
# NEITHER, and accept that an Android commit also nudges the server's reported
# version. Over-inclusion across the two, which is the harmless direction.
# THIS LIST NAMES THE SERVER IMAGE, and it is shorter than it looks like it
# should be. The image ships cmd/, internal/, shared/, web/,
# config.example.yaml and client/ — and client/ holds the APK, so android/
# stays in: an Android-only commit changes what the image carries. Exclude
# only what ships in neither artifact; over-inclusion is the harmless
# direction here.
#
# The APK itself is named from SHIPPED_APK (below), where over-inclusion is
# NOT harmless: the app offers an update whenever the name differs from its
# own, so a name moved by a server-only commit offers every phone a rebuild
# of the app it already has.
#
# The family's other repos (roundtable / roundtable-android) each keep a
# tighter list because they are separate repos with one artifact apiece. Do
@@ -100,9 +115,24 @@ readonly SHIPPED=(
':!web/vitest.setup.ts'
)
# What the APK is built from: android/ and nothing else (the Gradle build
# reads no path outside it). Still a denylist within it, for the same reason
# as above — a new directory under android/ counts until named here.
readonly SHIPPED_APK=(
android
':!android/app/src/test' # JVM unit tests
':!android/*.md'
)
if [ "${apk}" = true ]; then
shipped=("${SHIPPED_APK[@]}")
else
shipped=("${SHIPPED[@]}")
fi
commit_epoch="${MINSTREL_COMMIT_EPOCH:-}"
if [ -z "${commit_epoch}" ]; then
commit_epoch="$(git log --format=%ct -1 "${REF}" -- "${SHIPPED[@]}")"
commit_epoch="$(git log --format=%ct -1 "${REF}" -- "${shipped[@]}")"
# Loudly, on purpose. A silent fallback here is the landmine this whole
# script exists to avoid: a plausible-looking version that is quietly wrong,
# on a green run. Realistically this means a shallow clone (no commit in
+67 -1
View File
@@ -16,6 +16,8 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/acoustid"
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
"git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
@@ -35,6 +37,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
@@ -58,14 +61,40 @@ func main() {
os.Exit(1)
}
return
case "restore":
if err := runRestore(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "minstrel restore: %v\n", err)
os.Exit(1)
}
return
}
}
if err := run(); err != nil {
err := run()
if errors.Is(err, errRestart) {
err = restartInPlace()
}
if err != nil {
fmt.Fprintf(os.Stderr, "minstrel: %v\n", err)
os.Exit(1)
}
}
// errRestart is run's answer when the server stopped in order to start again:
// after a web restore, so the restored settings are read at boot.
var errRestart = errors.New("restart requested")
// restartInPlace replaces this process with a fresh start of the same binary,
// arguments and environment. The process id stays the same, so a container
// keeps running without needing a restart policy.
func restartInPlace() error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("restart: %w", err)
}
// Exec returns only when it failed.
return fmt.Errorf("restart: %w", syscall.Exec(exe, os.Args, os.Environ()))
}
// safetyNetScanInterval backstops the fsnotify watcher with a low-frequency
// full delta walk, catching anything the watcher missed (inotify watch-limit
// exhaustion, dropped events). Fixed — there is no operator configuration.
@@ -128,6 +157,18 @@ func run() error {
}
defer pool.Close()
// Stored credentials are sealed under a key derived from the stream
// secret. Install it before anything reads one, and seal any value still
// stored in the clear.
secret.Install(cfg.StreamSecret)
sealed, err := secret.SealStored(ctx, pool)
if err != nil {
return fmt.Errorf("seal stored credentials: %w", err)
}
if sealed > 0 {
logger.Info("sealed stored credentials", "count", sealed)
}
// Fingerprinting settings: one instance, shared by the scanner,
// the fingerprint backfill, the duplicate sweep and the admin API, so a save
// reaches all of them without a restart. A load failure is logged, not fatal:
@@ -457,7 +498,20 @@ func run() error {
// The sweeper above holds this same instance, so a save from the admin
// card changes what it does on its next tick.
srv.ReacqSettings = reacqSettings
// The server's own backup: the loop runs the nightly schedule, and the
// admin page starts, tests and configures the same instance.
backups := backup.NewService(pool, logger.With("component", "backup"), buildinfo.Version)
backups.Roots = scanner.Roots
go backups.Loop(ctx)
srv.Backups = backups
srv.StreamSecret = cfg.StreamSecret
restart := make(chan struct{}, 1)
srv.Restart = func() {
select {
case restart <- struct{}{}:
default:
}
}
httpServer := &http.Server{
Addr: cfg.Server.Address,
Handler: srv.Router(),
@@ -483,20 +537,32 @@ func run() error {
close(errCh)
}()
restarting := false
select {
case <-ctx.Done():
logger.Info("shutdown signal received")
case <-restart:
logger.Info("restarting to load the restored backup")
restarting = true
case err := <-errCh:
if err != nil {
return fmt.Errorf("server: %w", err)
}
}
// Ends the background work (scans, workers, schedules) as a signal would.
stop()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
// A backup still running is stopped and records that it was cut off.
backups.Close()
if restarting {
logger.Info("minstrel stopped; starting again")
return errRestart
}
logger.Info("minstrel stopped")
return nil
}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
)
const restoreUsage = "usage: minstrel restore [-config PATH] FILE"
// runRestore loads a backup file into a new Minstrel's database: one with no
// accounts and no library yet. It brings the schema up to date first, so a
// backup from an older Minstrel loads into this one. The server isn't running
// meanwhile, so the library scan waits for it to start.
func runRestore(args []string) error {
fs := flag.NewFlagSet("restore", flag.ContinueOnError)
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 1 {
return errors.New(restoreUsage)
}
cfg, err := config.Load(*configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if cfg.Database.URL == "" {
return errors.New("no database URL configured (set it in the config file or MINSTREL_DATABASE_URL)")
}
logger, err := logging.New(os.Stderr, cfg.Log.Level, cfg.Log.Format)
if err != nil {
return fmt.Errorf("init logger: %w", err)
}
f, err := os.Open(fs.Arg(0))
if err != nil {
return err
}
defer func() { _ = f.Close() }()
manifest, contents, err := backup.Read(f)
if err != nil {
return err
}
if err := db.Migrate(cfg.Database.URL, logger); err != nil {
return fmt.Errorf("migrate: %w", err)
}
ctx := context.Background()
pool, err := db.Open(ctx, cfg.Database.URL)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer pool.Close()
if err := backup.Restore(ctx, pool, contents); err != nil {
return err
}
printRestored(os.Stdout, manifest)
return nil
}
// printRestored says what came back and what happens next.
func printRestored(w io.Writer, m backup.Manifest) {
_, _ = fmt.Fprintf(w, "minstrel: restored the backup made %s by Minstrel %s (%d accounts, %d tracks, %d playlists).\n",
m.CreatedAt.Local().Format("2 Jan 2006 15:04 MST"), m.Build,
m.Rows["users"], m.Rows["tracks"], m.Rows["playlists"])
_, _ = fmt.Fprintln(w, "Start the server. The library scan (at startup, or Scan now under Admin › Library) finds each music file at its path, and the artwork, tags and analysis the backup leaves out are fetched and worked out again.")
_, _ = fmt.Fprintln(w, "Everyone signs in with the password they had. Keys for other services, and the backup's own destination, are entered again in Admin.")
}
+65
View File
@@ -83,6 +83,71 @@ Creating the first account (which becomes the admin) requires that token, so
whoever reaches a newly exposed server first can't claim it. The token
changes on every restart until an account exists.
## Backups
Minstrel can back itself up once a night: accounts, likes, playlists,
listening history, requests and settings, in one file. It is off until an
admin switches it on under **Admin › Backups**. Anything Minstrel can fetch
or work out again (artwork, fingerprints, MusicBrainz data) is left out, and
so are the keys and passwords Minstrel keeps for other services, which are
entered again on a restored server. Sign-in passwords are kept.
A backup goes to one of:
- **an S3 bucket**, path-style (MinIO, Garage, or any S3-compatible store).
The key only needs to add and list objects. Files land under `database/`.
- **a folder** on the server, under `database/` inside it. Mount a volume
there; Minstrel refuses to write into a folder that isn't there.
Minstrel only adds files, named by date, and never deletes one. Keep as many
as you like with the destination's own retention. **Test destination** lists
what is there without writing anything.
**Include the music** also sends the library's files to `music/` in the same
destination, laid out as they are under the library folders. After each
night's database file has landed, Minstrel sends the files it hasn't sent
yet, one at a time, for up to six hours; a large library takes several
nights the first time, and the panel shows how much is in. After that, a
night sends only new files and files that changed. A changed file goes to
its old name, and Minstrel still never deletes anything there, so a file
taken out of the library stays in the backup. On a bucket that keeps
versions or locks objects, set its retention to keep what you want. A file
the bucket refuses (an overwrite a locked object won't take) is listed on
the panel and goes again once it changes or the settings are saved; three
refusals in a row stop that night's music, since that is usually a key that
may not write under `music/`. A bucket's upload limit applies
to the music as well.
### Restoring
A backup restores into a new Minstrel: one nobody has signed up to yet. If
it has already scanned the music, that's fine; the backup's library takes
the place of what the scan found.
1. **Put the music back first**, at the path the library had. The restored
settings name the old library folders, and each track is found again by
its path. If the backup includes the music, copy what is inside `music/`
into the library folder (with several library folders, into the folder
that holds them all).
2. **Load the backup**, either way:
- In the web app, choose **Create account**, then **Restore a backup**.
Pick a `minstrel-….tar` file from the backup's `database/` folder and
enter the setup token from the log (see *First account*). Minstrel
restarts by itself once the backup is in, so it runs on the restored
settings.
- Or, with the server stopped, run
`docker compose run --rm minstrel restore /backups/database/minstrel-20261102T030000Z.tar`,
with the file on a volume the container can read. It brings the database
schema up to date first, so a backup from an older Minstrel loads into a
newer one; a backup from a newer Minstrel is refused.
3. **Sign in** with the password you had. Everyone else's accounts, likes,
playlists and history are back too.
The library scan then finds each file, and the artwork, tags, fingerprints
and loudness are fetched or worked out again over the following hours. Keys
for other services (ListenBrainz, AcoustID, artwork and tag providers,
email, download clients) and the backup's own destination are entered again
under **Admin**. An app that synced with the old server starts its sync over.
## Android app
The app connects over whatever URL you give it, HTTP or HTTPS. Once the
+27 -6
View File
@@ -49,22 +49,43 @@ cross-origin preflight. Requests authenticated with a bearer token or the
Subsonic query parameters carry nothing a browser attaches on its own, so
they aren't checked.
## Subsonic sign-in, and the one password stored in plain text
## Credentials Minstrel has to read back
Some credentials can't be hashed, because Minstrel has to send or compare the
value itself:
- the API keys and passwords for indexers, Prowlarr and download clients;
- the AcoustID key, and the cover-art and tag provider keys;
- the SMTP password;
- the secret key of the S3 bucket backups go to;
- each user's ListenBrainz token and Subsonic password.
These are encrypted in the database (AES-256-GCM). Each column has its own key,
derived from the stream secret (`MINSTREL_STREAM_SECRET`, or the
`stream_secret` file in the data directory), and each value is bound to its
row. The stream secret is never stored in the database, so a copy of the
database alone doesn't reveal any of them. None of them is ever sent back to a
browser: Settings only shows whether one is set.
**Keep the stream secret with the data directory, and don't change it
casually.** If it changes, every stored credential reads as not set, and each
one has to be entered again in Settings (Subsonic passwords generated again).
Minstrel logs a warning for each one that no longer opens.
## Subsonic sign-in
Classic Subsonic clients sign in with `t` and `s`: the MD5 of the password
followed by a random salt. To check that, the server has to know the password
itself, so supporting this sign-in method means storing a password Minstrel
can read. That is the Subsonic password, and it is the only credential
Minstrel keeps unhashed.
can read: the Subsonic password, encrypted as described above.
- **The recommended way in is the API key.** Clients that support the
OpenSubsonic `apiKey` should use it. The key is stored hashed and can be
replaced at any time in Settings.
- **The Subsonic password is never your login password.** Minstrel generates
it (**Settings → Subsonic password**), shows it once, and lets you replace
or turn it off. Because it is random, a copy of the database exposes access
to this server's Subsonic API and nothing else: it can't be a password you
also use somewhere else.
or turn it off. Because it is random, even someone holding both the database
and the stream secret gains access to this server's Subsonic API and nothing
else: it can't be a password you also use somewhere else.
- **`t`/`s` and `p=` sign-in are off for an account until it has a Subsonic
password.** Plain `p=` sign-in is additionally off server-wide unless
`subsonic.allow_plaintext_password` is enabled.
+1 -1
View File
@@ -24,7 +24,7 @@ func Build(ctx context.Context, pool *pgxpool.Pool) (*Service, dbq.AcquisitionSe
if err != nil {
return nil, settings, fmt.Errorf("acquire: read search sources: %w", err)
}
clients, err := q.ListUsableAcquisitionServices(ctx, JobDownload)
clients, err := usableServices(ctx, q, JobDownload)
if err != nil {
return nil, settings, fmt.Errorf("acquire: read download clients: %w", err)
}
+32 -2
View File
@@ -23,6 +23,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/exchange"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
"git.fabledsword.com/bvandeusen/minstrel/internal/prowlarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
)
// Kinds: which list a row appears in.
@@ -159,7 +160,7 @@ func DownloadClient(row dbq.AcquisitionService) (downloader.Driver, error) {
// DownloadClientFor builds the download client a grab went to, by the code
// of its acquisition_services row.
func DownloadClientFor(ctx context.Context, pool *pgxpool.Pool, code string) (downloader.Driver, error) {
row, err := dbq.New(pool).GetAcquisitionService(ctx, code)
row, err := getService(ctx, dbq.New(pool), code)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("the download client %q is no longer set up", code)
@@ -171,7 +172,7 @@ func DownloadClientFor(ctx context.Context, pool *pgxpool.Pool, code string) (do
// Sources builds every enabled search source.
func Sources(ctx context.Context, pool *pgxpool.Pool) ([]Source, error) {
rows, err := dbq.New(pool).ListUsableAcquisitionServices(ctx, JobSearch)
rows, err := usableServices(ctx, dbq.New(pool), JobSearch)
if err != nil {
return nil, err
}
@@ -182,4 +183,33 @@ func Sources(ctx context.Context, pool *pgxpool.Pool) ([]Source, error) {
return out, nil
}
// getService and usableServices are the only reads of a full
// acquisition_services row. They hand back the credentials opened, so the
// clients built from a row are given the key and password themselves.
func getService(ctx context.Context, q *dbq.Queries, code string) (dbq.AcquisitionService, error) {
row, err := q.GetAcquisitionService(ctx, code)
if err != nil {
return row, err
}
return revealed(row), nil
}
func usableServices(ctx context.Context, q *dbq.Queries, job string) ([]dbq.AcquisitionService, error) {
rows, err := q.ListUsableAcquisitionServices(ctx, job)
if err != nil {
return nil, err
}
for i := range rows {
rows[i] = revealed(rows[i])
}
return rows, nil
}
// revealed is row with its sealed credentials opened.
func revealed(row dbq.AcquisitionService) dbq.AcquisitionService {
row.ApiKey = secret.Reveal(secret.AcquisitionAPIKey.Purpose(), row.Code, row.ApiKey)
row.Password = secret.Reveal(secret.AcquisitionPassword.Purpose(), row.Code, row.Password)
return row
}
func msDuration(ms int32) time.Duration { return time.Duration(ms) * time.Millisecond }
+15 -6
View File
@@ -11,6 +11,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
)
// ErrNotFound is a code with no row.
@@ -49,7 +50,7 @@ func (s *Store) List(ctx context.Context) ([]dbq.ListAcquisitionServicesRow, err
// Get returns one row, secrets included: for building a client only.
func (s *Store) Get(ctx context.Context, code string) (dbq.AcquisitionService, error) {
row, err := dbq.New(s.pool).GetAcquisitionService(ctx, code)
row, err := getService(ctx, dbq.New(s.pool), code)
if errors.Is(err, pgx.ErrNoRows) {
return row, ErrNotFound
}
@@ -163,19 +164,27 @@ func (s *Store) Delete(ctx context.Context, code string) error {
return nil
}
// SetAPIKey stores a key, trimmed; empty clears it.
// SetAPIKey stores a key, trimmed and sealed; empty clears it.
func (s *Store) SetAPIKey(ctx context.Context, code, key string) error {
sealed, err := secret.Seal(secret.AcquisitionAPIKey.Purpose(), code, strings.TrimSpace(key))
if err != nil {
return err
}
n, err := dbq.New(s.pool).SetAcquisitionServiceAPIKey(ctx, dbq.SetAcquisitionServiceAPIKeyParams{
Code: code, ApiKey: strings.TrimSpace(key),
Code: code, ApiKey: sealed,
})
return affected(n, err)
}
// SetPassword stores a password as typed (spaces are part of a password);
// empty clears it.
// SetPassword stores a password as typed (spaces are part of a password),
// sealed; empty clears it.
func (s *Store) SetPassword(ctx context.Context, code, password string) error {
sealed, err := secret.Seal(secret.AcquisitionPassword.Purpose(), code, password)
if err != nil {
return err
}
n, err := dbq.New(s.pool).SetAcquisitionServicePassword(ctx, dbq.SetAcquisitionServicePasswordParams{
Code: code, Password: password,
Code: code, Password: sealed,
})
return affected(n, err)
}
+10
View File
@@ -12,6 +12,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
)
func newPool(t *testing.T) *pgxpool.Pool {
@@ -83,6 +84,15 @@ func TestStore_AddsConfiguresAndRemoves_Integration(t *testing.T) {
if err != nil || full.ApiKey != "key" {
t.Errorf("stored key = %q (err %v), want it trimmed", full.ApiKey, err)
}
// The column holds the key sealed, never the key.
var raw string
if err := s.pool.QueryRow(ctx,
"SELECT api_key FROM acquisition_services WHERE code = $1", first.Code).Scan(&raw); err != nil {
t.Fatalf("read api_key: %v", err)
}
if raw == "key" || !secret.IsSealed(raw) {
t.Errorf("api_key column = %q, want it sealed", raw)
}
rows, err := s.List(ctx)
if err != nil || len(rows) != 2 {
t.Fatalf("list: %d rows, err %v", len(rows), err)
+94
View File
@@ -0,0 +1,94 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
)
// GET /api/admin/backups — the backup's verdict, settings and recent runs.
func (h *handlers) handleGetBackups(w http.ResponseWriter, r *http.Request) {
st, err := h.backups.Status(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin backups: status", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, st)
}
func decodeBackupUpdate(w http.ResponseWriter, r *http.Request) (backup.Update, bool) {
var u backup.Update
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
writeErr(w, apierror.BadRequest("invalid_body", ""))
return u, false
}
return u, true
}
// fieldErr writes a setting that can't be kept as a 400 whose message says
// which setting and why, and reports whether err was one.
func fieldErr(w http.ResponseWriter, err error) bool {
var fe *backup.FieldError
if !errors.As(err, &fe) {
return false
}
writeErr(w, apierror.BadRequest("invalid_backup_setting", fe.Message))
return true
}
// PUT /api/admin/backups — save the settings. An empty secret_key keeps the
// stored one. Returns the new status.
func (h *handlers) handleUpdateBackups(w http.ResponseWriter, r *http.Request) {
u, ok := decodeBackupUpdate(w, r)
if !ok {
return
}
if err := h.backups.Save(r.Context(), u); err != nil {
if !fieldErr(w, err) {
writeErrWithLog(w, h.logger, "admin backups: save", apierror.Internal(err))
}
return
}
h.handleGetBackups(w, r)
}
// POST /api/admin/backups/test — check the destination in the body without
// saving it, by listing the backups already there. Nothing is written.
func (h *handlers) handleTestBackups(w http.ResponseWriter, r *http.Request) {
u, ok := decodeBackupUpdate(w, r)
if !ok {
return
}
res, err := h.backups.Test(r.Context(), u)
if err != nil {
if fieldErr(w, err) {
return
}
msg := strings.TrimPrefix(err.Error(), "backup: ")
writeErr(w, &apierror.Error{Status: http.StatusBadGateway, Code: "destination_failed", Message: msg})
return
}
writeJSON(w, http.StatusOK, res)
}
// POST /api/admin/backups/run — start a backup now. 202 once it has started;
// 409 while one runs or before the destination is set up.
func (h *handlers) handleRunBackup(w http.ResponseWriter, r *http.Request) {
err := h.backups.Start(r.Context(), backup.TriggerManual)
switch {
case err == nil:
w.WriteHeader(http.StatusAccepted)
case errors.Is(err, backup.ErrRunning):
writeErr(w, apierror.Conflict("backup_running", "A backup is already running."))
case errors.Is(err, backup.ErrNotReady):
writeErr(w, apierror.Conflict("backup_not_ready", "Set up where backups go first."))
case errors.Is(err, backup.ErrClosed):
writeErr(w, apierror.Conflict("shutting_down", "The server is shutting down."))
default:
writeErrWithLog(w, h.logger, "admin backups: start", apierror.Internal(err))
}
}
+10 -5
View File
@@ -10,6 +10,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
)
type smtpConfigResp struct {
@@ -76,11 +77,15 @@ func (h *handlers) handleUpdateSMTPConfig(w http.ResponseWriter, r *http.Request
writeErrWithLog(w, h.logger, "admin smtp: get failed", apierror.Internal(err))
return
}
// Preserve existing password when the request sends "" (so the
// admin doesn't have to re-enter it on every save).
password := req.Password
if password == "" {
password = current.Password
// Preserve the stored (sealed) password when the request sends "", so
// the admin doesn't have to re-enter it on every save.
password := current.Password
if req.Password != "" {
password, err = secret.Seal(secret.SMTPPassword.Purpose(), "", req.Password)
if err != nil {
writeErrWithLog(w, h.logger, "admin smtp: seal password", apierror.Internal(err))
return
}
}
if err := q.UpdateSMTPConfig(r.Context(), dbq.UpdateSMTPConfigParams{
Enabled: req.Enabled,
+15 -1
View File
@@ -15,6 +15,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
@@ -40,7 +41,7 @@ import (
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, requestSvc *requests.Service, quarantineSvc *quarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) {
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, requestSvc *requests.Service, quarantineSvc *quarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker, backups *backup.Service, restart func()) {
rng := rand.New(rand.NewSource(rand.Int63()))
setupToken, err := auth.NewSetupToken()
if err != nil {
@@ -74,6 +75,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
fingerprintSettings: fpSettings,
loudnessSettings: loudSettings,
acoustIDLookup: acoustIDLookup,
backups: backups,
restart: restart,
acquisition: acquire.NewStore(pool),
catalogue: cat,
wanted: wanted.New(pool, cat, logger),
@@ -97,6 +100,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
api.Post("/auth/login", h.handleLogin)
api.Post("/auth/register", h.handleRegister)
api.Get("/auth/setup-status", h.handleSetupStatus)
api.Post("/auth/restore", h.handleRestore)
api.Post("/auth/forgot-password", h.handleForgotPassword)
api.Post("/auth/reset-password", h.handleResetPassword)
@@ -358,6 +362,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
admin.Post("/tag-sources/research", h.handleResearchTags)
admin.Get("/backups", h.handleGetBackups)
admin.Put("/backups", h.handleUpdateBackups)
admin.Post("/backups/test", h.handleTestBackups)
admin.Post("/backups/run", h.handleRunBackup)
admin.Get("/smtp-config", h.handleGetSMTPConfig)
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
@@ -429,6 +438,11 @@ type handlers struct {
// acoustIDLookup is the AcoustID lookup worker: its settings, its
// status and "look up now". Nil serves the defaults (off).
acoustIDLookup *library.AcoustIDLookupWorker
// backups is the server's own backup: settings, status, test and run.
backups *backup.Service
// restart stops the server and starts it again in place, so a restored
// backup's settings are read at boot. Nil in tests.
restart func()
// acquisition is the configured indexers and download clients.
acquisition *acquire.Store
// catalogue is the release catalogue read from MusicBrainz, and
+94
View File
@@ -0,0 +1,94 @@
package api
import (
"errors"
"net/http"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// SetupTokenHeader carries the setup token on a restore upload, whose body is
// the backup file itself.
const SetupTokenHeader = "X-Setup-Token"
type restoreResp struct {
// Accounts is how many accounts came back; everyone signs in with the
// password they had.
Accounts int64 `json:"accounts"`
// Restarting says the server restarts now to load the restored settings.
// The scan at its start finds each music file and fills in what the
// backup leaves out.
Restarting bool `json:"restarting"`
}
// handleRestore implements POST /api/auth/restore: a new server, with no
// accounts yet, loads a backup file sent as the request body. It asks for the
// setup token from the server log, as creating the first account does, since
// the backup's admins become this server's admins. A library the server's
// first scan found is replaced by the backup's.
//
// Then the server restarts. Settings are read once, at boot, so the restored
// ones (the library folders among them) take effect the same way they do
// after `minstrel restore`, and the startup scan finds each file at its path.
//
// 200 restored · 400 not_a_backup · 403 setup_token_invalid ·
// 409 not_empty (accounts already) · 409 newer_backup
func (h *handlers) handleRestore(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
q := dbq.New(h.pool)
n, err := q.CountUsers(ctx)
if err != nil {
writeErrWithLog(w, h.logger, "restore: count users", apierror.Internal(err))
return
}
if n > 0 {
writeErr(w, apierror.Conflict("not_empty", "this server already has accounts; a backup restores into a new one"))
return
}
if h.requireSetupToken && !h.setupToken.Matches(r.Header.Get(SetupTokenHeader)) {
h.logger.Warn("restore: needs the setup token", "setup_token", h.setupToken.Value())
writeErr(w, apierror.Forbidden("setup_token_invalid", "the setup token from the server log is required to restore a backup"))
return
}
manifest, contents, err := backup.Read(r.Body)
if err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
writeErr(w, &apierror.Error{Status: http.StatusRequestEntityTooLarge, Code: "too_large", Message: "the file is larger than a backup can be"})
return
}
writeErr(w, apierror.BadRequest("not_a_backup", "that file isn't a Minstrel backup"))
return
}
switch err := backup.Restore(ctx, h.pool, contents); {
case errors.Is(err, backup.ErrNotEmpty):
writeErr(w, apierror.Conflict("not_empty", "this server already has accounts; a backup restores into a new one"))
return
case errors.Is(err, backup.ErrNewerSchema):
writeErr(w, apierror.Conflict("newer_backup", "the backup was made by a newer Minstrel; update this server first"))
return
case errors.Is(err, backup.ErrNotABackup):
writeErr(w, apierror.BadRequest("not_a_backup", "that file isn't a Minstrel backup"))
return
case err != nil:
writeErrWithLog(w, h.logger, "restore: load the backup", apierror.Internal(err))
return
}
out := restoreResp{}
if out.Accounts, err = q.CountUsers(ctx); err != nil {
h.logger.Warn("restore: count restored accounts", "err", err)
}
h.logger.Info("restored a backup", "made_at", manifest.CreatedAt, "build", manifest.Build,
"schema", manifest.Schema, "accounts", out.Accounts)
out.Restarting = h.restart != nil
writeJSON(w, http.StatusOK, out)
if h.restart != nil {
// The server drains this request before it stops, so the answer
// reaches the browser first.
h.restart()
}
}
+98
View File
@@ -0,0 +1,98 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
)
// A new server takes a backup only with the setup token from its log, only
// while it has no accounts, and only a file that is a backup; then the
// backup's accounts sign in again.
func TestRestore_ANewServerTakesABackupWithTheSetupToken(t *testing.T) {
h, pool := testHandlers(t)
ctx := context.Background()
truncateLibrary(t, pool)
resetUsers(t, h)
seedUser(t, pool, "restored", "abcd1234", true)
c, err := backup.Snapshot(ctx, pool)
if err != nil {
t.Fatal(err)
}
var file bytes.Buffer
if _, err := backup.Write(&file, c, time.Now(), "test"); err != nil {
t.Fatal(err)
}
resetUsers(t, h)
token, err := auth.NewSetupToken()
if err != nil {
t.Fatal(err)
}
h.setupToken = token
h.requireSetupToken = true
restarts := 0
h.restart = func() { restarts++ }
restore := func(setupToken string, body []byte) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/auth/restore", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/x-tar")
if setupToken != "" {
req.Header.Set(SetupTokenHeader, setupToken)
}
rec := httptest.NewRecorder()
h.handleRestore(rec, req)
return rec
}
if w := restore("", file.Bytes()); w.Code != http.StatusForbidden {
t.Errorf("no setup token: status = %d, want 403", w.Code)
}
if w := restore("wrong", file.Bytes()); w.Code != http.StatusForbidden {
t.Errorf("a wrong setup token: status = %d, want 403", w.Code)
}
if w := restore(token.Value(), []byte("not a tar file")); w.Code != http.StatusBadRequest ||
!strings.Contains(w.Body.String(), "not_a_backup") {
t.Errorf("not a backup: status = %d body = %s, want 400 not_a_backup", w.Code, w.Body.String())
}
var n int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM users").Scan(&n); err != nil || n != 0 {
t.Fatalf("a refused restore left %d accounts (%v)", n, err)
}
if restarts != 0 {
t.Errorf("a refused restore restarted the server %d times", restarts)
}
w := restore(token.Value(), file.Bytes())
if w.Code != http.StatusOK {
t.Fatalf("restore: status = %d body = %s", w.Code, w.Body.String())
}
var out restoreResp
if err := json.NewDecoder(w.Body).Decode(&out); err != nil || out.Accounts != 1 || !out.Restarting {
t.Errorf("restore answered %+v (%v), want one account and a restart", out, err)
}
if restarts != 1 {
t.Errorf("the restore restarted the server %d times, want once so the restored settings load", restarts)
}
var admin bool
if err := pool.QueryRow(ctx, "SELECT is_admin FROM users WHERE username = $1",
dbtest.TestUserPrefix+"restored").Scan(&admin); err != nil || !admin {
t.Errorf("the restored admin: is_admin = %v, %v", admin, err)
}
if w := restore(token.Value(), file.Bytes()); w.Code != http.StatusConflict ||
!strings.Contains(w.Body.String(), "not_empty") {
t.Errorf("a second restore: status = %d body = %s, want 409 not_empty", w.Code, w.Body.String())
}
if restarts != 1 {
t.Errorf("a refused second restore restarted the server (%d restarts)", restarts)
}
}
+50
View File
@@ -17,8 +17,11 @@ package api
// client treats 404 as "no update channel available."
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
@@ -62,6 +65,41 @@ var (
clientAPKLastDownloadMu sync.Mutex
)
// clientAPKSum is the bundled APK's sha256, kept until the file changes
// under it (another size or modification time), so a version check every
// few minutes from every phone does not re-read tens of megabytes each time.
type clientAPKSum struct {
size int64
mod time.Time
hex string
}
var (
clientAPKSumCache clientAPKSum
clientAPKSumCacheMu sync.Mutex
)
// clientAPKDigest returns the sha256 of the APK at path, in hex.
func clientAPKDigest(path string, st os.FileInfo) (string, error) {
clientAPKSumCacheMu.Lock()
defer clientAPKSumCacheMu.Unlock()
c := clientAPKSumCache
if c.hex != "" && c.size == st.Size() && c.mod.Equal(st.ModTime()) {
return c.hex, nil
}
f, err := os.Open(path)
if err != nil {
return "", err
}
defer func() { _ = f.Close() }()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
clientAPKSumCache = clientAPKSum{size: st.Size(), mod: st.ModTime(), hex: hex.EncodeToString(h.Sum(nil))}
return clientAPKSumCache.hex, nil
}
func testResetClientAPKRateLimit() {
clientAPKLastDownloadMu.Lock()
defer clientAPKLastDownloadMu.Unlock()
@@ -115,6 +153,11 @@ type clientVersionResponse struct {
Channel string `json:"channel,omitempty"`
APKURL string `json:"apk_url"`
SizeBytes int64 `json:"size_bytes"`
// SHA256 is worked out from the file being served, not copied from the
// sidecar, so it cannot disagree with the bytes the phone receives. The
// app downloads ahead on Wi-Fi and installs later; this is how it knows
// the file it kept is the whole build that was offered.
SHA256 string `json:"sha256"`
}
// handleClientVersion returns the bundled Android client version + a
@@ -159,12 +202,19 @@ func (h *handlers) handleClientVersion(w http.ResponseWriter, _ *http.Request) {
return
}
sum, err := clientAPKDigest(apkPath, stat)
if err != nil {
writeErrWithLog(w, h.logger, "client_version: digest apk", err)
return
}
writeJSON(w, http.StatusOK, clientVersionResponse{
Version: strings.TrimSpace(sidecar.Name),
Code: sidecar.Code,
Channel: strings.TrimSpace(sidecar.Channel),
APKURL: "/api/client/apk",
SizeBytes: stat.Size(),
SHA256: sum,
})
}
+35
View File
@@ -2,6 +2,8 @@ package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log/slog"
@@ -128,6 +130,39 @@ func TestClientVersion_200WithBothFiles(t *testing.T) {
if resp.SizeBytes != size {
t.Errorf("size_bytes: want %d, got %d", size, resp.SizeBytes)
}
want := sha256.Sum256([]byte("fake apk content"))
if resp.SHA256 != hex.EncodeToString(want[:]) {
t.Errorf("sha256: want the served file's digest %x, got %q", want, resp.SHA256)
}
}
// The digest is cached by size and modification time; a new APK dropped in
// place must be answered with its own digest, not the old one's.
func TestClientVersion_DigestFollowsAReplacedAPK(t *testing.T) {
writeClientAssets(t, `{"name":"a","code":1}`)
first := decodeClientVersion(t, getClientVersion(t))
dir := os.Getenv("MINSTREL_CLIENT_APK_DIR")
apk := filepath.Join(dir, clientAPKFilename)
if err := os.WriteFile(apk, []byte("another, longer build"), 0o644); err != nil {
t.Fatal(err)
}
second := decodeClientVersion(t, getClientVersion(t))
want := sha256.Sum256([]byte("another, longer build"))
if second.SHA256 != hex.EncodeToString(want[:]) || second.SHA256 == first.SHA256 {
t.Errorf("sha256 after replace: want %x, got %q (before: %q)", want, second.SHA256, first.SHA256)
}
}
func decodeClientVersion(t *testing.T, rr *httptest.ResponseRecorder) clientVersionResponse {
t.Helper()
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body: %s)", rr.Code, rr.Body.String())
}
var resp clientVersionResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
return resp
}
// A release published before ordering keys were recorded has a name and
+27 -7
View File
@@ -36,7 +36,9 @@ type syncResponse struct {
// Responses:
// - 204 No Content — no changes since cursor
// - 200 OK — JSON syncResponse with upserts, deletes, new cursor
// - 410 Gone — cursor older than oldest log row; client must reset
// - 410 Gone — the client must reset its cursor to 0: the cursor is
// older than a delete the log has dropped, or past anything this server
// has handed out
// - 401 / 500 — standard envelope errors
func (h *handlers) handleLibrarySync(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
@@ -52,16 +54,17 @@ func (h *handlers) handleLibrarySync(w http.ResponseWriter, r *http.Request) {
q := dbq.New(h.pool)
// 410 path: if the requested cursor is older than the oldest row,
// tell the client to reset. (No compaction job in this slice; the
// path exists for future compaction support.)
// The log keeps every entity's latest change, so a client syncing from
// 0 gets the whole library. It drops deletes older than its retention,
// and a cursor below the newest dropped one may have missed it: the
// client resets and syncs again from 0.
if since > 0 {
minCursor, err := q.GetMinLibraryChangeCursor(ctx)
trimmed, err := q.GetLibraryChangesTrimmedThrough(ctx)
if err != nil {
writeErrWithLog(w, h.logger, "library_sync: GetMinLibraryChangeCursor", err)
writeErrWithLog(w, h.logger, "library_sync: GetLibraryChangesTrimmedThrough", err)
return
}
if minCursor > 0 && since+1 < minCursor {
if since < trimmed {
writeErr(w, &apierror.Error{
Status: http.StatusGone,
Code: "cursor_too_old",
@@ -69,6 +72,23 @@ func (h *handlers) handleLibrarySync(w http.ResponseWriter, r *http.Request) {
})
return
}
// A cursor past anything this server has handed out comes from a
// server restored from an older backup: the changes between the
// backup and the cursor are gone, and the ones made from here on would
// reuse those ids. The app resets the same way.
lastCursor, err := q.GetLastIssuedLibraryChangeCursor(ctx)
if err != nil {
writeErrWithLog(w, h.logger, "library_sync: GetLastIssuedLibraryChangeCursor", err)
return
}
if since > lastCursor {
writeErr(w, &apierror.Error{
Status: http.StatusGone,
Code: "cursor_ahead",
Message: "this server holds no changes up to that cursor; reset client cursor to 0 and resync",
})
return
}
}
changes, err := q.GetLibraryChangesSince(ctx, dbq.GetLibraryChangesSinceParams{
+81
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
@@ -204,3 +205,83 @@ func TestSplitOnce(t *testing.T) {
}
}
}
// A server restored from an older backup has handed out fewer cursors than an
// app remembers; the app is told to reset rather than wait for ids it would
// then skip. A compacted log is not that: the cursor the server handed out
// still counts.
func TestLibrarySync_ACursorPastTheServersLastChangeIsReset(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "alice", "x", false)
ctx := context.Background()
var artistID string
if err := pool.QueryRow(ctx,
`INSERT INTO artists (name, sort_name) VALUES ('A', 'A') RETURNING id::text`).Scan(&artistID); err != nil {
t.Fatal(err)
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
if err := sync.LogChange(ctx, tx, sync.EntityArtist, artistID, sync.OpUpsert); err != nil {
t.Fatal(err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatal(err)
}
var last int64
if err := pool.QueryRow(ctx, `SELECT max(id) FROM library_changes`).Scan(&last); err != nil {
t.Fatal(err)
}
w := callLibrarySync(h, u, strconv.FormatInt(last+50, 10))
if w.Code != http.StatusGone || !strings.Contains(w.Body.String(), "cursor_ahead") {
t.Errorf("a cursor past the last change: status = %d body = %s, want 410 cursor_ahead", w.Code, w.Body.String())
}
// The compactor trims every row; the app's cursor is still one this
// server handed out.
if _, err := pool.Exec(ctx, `DELETE FROM library_changes`); err != nil {
t.Fatal(err)
}
if w := callLibrarySync(h, u, strconv.FormatInt(last, 10)); w.Code != http.StatusNoContent {
t.Errorf("the last cursor after compaction: status = %d body = %s, want 204", w.Code, w.Body.String())
}
}
// An app is sent back to 0 only when its cursor is below a delete the log has
// dropped, which it may have missed. A cursor at that delete has seen it, and
// one between dropped and kept rows replays what is still there.
func TestLibrarySync_ACursorBelowADroppedDeleteIsReset(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "alice", "x", false)
ctx := context.Background()
for i := 0; i < 5; i++ {
if _, err := pool.Exec(ctx, `INSERT INTO library_changes (entity_type, entity_id, op) VALUES ('artist', $1, 'upsert')`,
strconv.Itoa(i)); err != nil {
t.Fatal(err)
}
}
var dropped int64
if err := pool.QueryRow(ctx, `SELECT min(id) + 2 FROM library_changes`).Scan(&dropped); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `UPDATE library_changes_trimmed SET through = $1`, dropped); err != nil {
t.Fatal(err)
}
if w := callLibrarySync(h, u, strconv.FormatInt(dropped-1, 10)); w.Code != http.StatusGone ||
!strings.Contains(w.Body.String(), "cursor_too_old") {
t.Errorf("a cursor below the dropped delete: status = %d body = %s, want 410 cursor_too_old", w.Code, w.Body.String())
}
if w := callLibrarySync(h, u, strconv.FormatInt(dropped, 10)); w.Code != http.StatusOK {
t.Errorf("a cursor at the dropped delete: status = %d body = %s, want 200", w.Code, w.Body.String())
}
if w := callLibrarySync(h, u, "0"); w.Code != http.StatusOK {
t.Errorf("a sync from 0: status = %d body = %s, want 200", w.Code, w.Body.String())
}
}
+2 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
@@ -467,7 +468,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.requests, h.quarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.requests, h.quarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil, backup.NewService(h.pool, h.logger, "test"), nil)
paths := []string{
"/api/artists",
+6 -4
View File
@@ -11,6 +11,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
)
// LBStatus is the GET response body and the PUT response body. Token is
@@ -53,10 +54,11 @@ func (h *handlers) handlePutListenBrainz(w http.ResponseWriter, r *http.Request)
q := dbq.New(h.pool)
if body.Token != nil {
t := *body.Token
var tokenPtr *string
if t != "" {
tokenPtr = &t
tokenPtr, err := secret.SealPtr(secret.ListenBrainzToken.Purpose(), user.ID.String(), body.Token)
if err != nil {
h.logger.Error("api: seal listenbrainz token", "err", err)
writeErr(w, apierror.InternalMsg("update failed", err))
return
}
if err := q.SetListenBrainzToken(r.Context(), dbq.SetListenBrainzTokenParams{
ID: user.ID, ListenbrainzToken: tokenPtr,
@@ -88,6 +88,8 @@ func bucketRecSource(src string) recFamily {
return recFamily{"for_you", "For You", intentGoTo}
case src == "songs_like_artist":
return recFamily{"songs_like_artist", "Songs like…", intentGoTo}
case src == "genre_mix":
return recFamily{"genre_mix", "Genre mixes", intentGoTo}
case src == "radio" || strings.HasPrefix(src, "radio:"):
return recFamily{"radio", "Radio", intentGoTo}
case src == "discover":
+8 -1
View File
@@ -8,6 +8,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
)
// The Subsonic password is for clients that sign in with Subsonic's t/s
@@ -51,9 +52,15 @@ func (h *handlers) handleGenerateMySubsonicPassword(w http.ResponseWriter, r *ht
return
}
pw := base64.RawURLEncoding.EncodeToString(b)
sealed, err := secret.Seal(secret.SubsonicPassword.Purpose(), user.ID.String(), pw)
if err != nil {
h.logger.Error("generate subsonic password: seal failed", "err", err)
writeErr(w, apierror.Internal(err))
return
}
if err := dbq.New(h.pool).SetSubsonicPassword(r.Context(), dbq.SetSubsonicPasswordParams{
ID: user.ID,
SubsonicPassword: &pw,
SubsonicPassword: &sealed,
}); err != nil {
h.logger.Error("generate subsonic password: update failed", "err", err)
writeErr(w, apierror.Internal(err))
+14 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
)
func newMeSubsonicPasswordRouter(h *handlers) chi.Router {
@@ -21,6 +22,8 @@ func newMeSubsonicPasswordRouter(h *handlers) chi.Router {
return r
}
// readSubsonicPassword returns the stored Subsonic password opened. It fails
// the test if the column holds the password in the clear.
func readSubsonicPassword(t *testing.T, h *handlers, user dbq.User) *string {
t.Helper()
var pw *string
@@ -28,7 +31,17 @@ func readSubsonicPassword(t *testing.T, h *handlers, user dbq.User) *string {
"SELECT subsonic_password FROM users WHERE id = $1", user.ID).Scan(&pw); err != nil {
t.Fatalf("read subsonic_password: %v", err)
}
return pw
if pw == nil {
return nil
}
if !secret.IsSealed(*pw) {
t.Fatalf("subsonic_password is stored in the clear")
}
plain, err := secret.Open(secret.SubsonicPassword.Purpose(), user.ID.String(), *pw)
if err != nil {
t.Fatalf("open subsonic_password: %v", err)
}
return &plain
}
func TestSubsonicPassword_GenerateIsRandomAndNotTheLoginPassword(t *testing.T) {
+12 -9
View File
@@ -20,14 +20,13 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Action is the discriminator stored in audit_log.action. New values
// added in U2/U3 + future tasks; declare them here so callers don't
// stringly-type and so a future audit-search UI has a single source
// of truth for the vocabulary.
// Action is the discriminator stored in audit_log.action. Every value is
// declared here, so callers never stringly-type an action and the vocabulary
// has one source.
type Action string
const (
// U1
// Accounts and invites.
ActionRegister Action = "register"
ActionPromoteAdmin Action = "promote_admin"
ActionDemoteAdmin Action = "demote_admin"
@@ -35,15 +34,13 @@ const (
ActionInviteRedeem Action = "invite_redeem"
ActionInviteRevoke Action = "invite_revoke"
// U2 (declared early; callers in U1 don't use them yet, but
// having them here means U2's diff is purely additive on the
// caller side, not also touching this file).
// Admin user management.
ActionCreateUserAdmin Action = "create_user_admin"
ActionDeleteUser Action = "delete_user"
ActionPasswordResetAdmin Action = "password_reset_admin"
ActionAutoApproveToggle Action = "auto_approve_toggle"
// U3
// Credentials.
ActionPasswordChangeSelf Action = "password_change_self"
ActionTokenRegenerate Action = "token_regenerate"
ActionForgotPasswordInit Action = "forgot_password_initiated"
@@ -65,6 +62,12 @@ const (
ActionDuplicateDismiss Action = "duplicate_dismiss"
ActionDuplicateWrongFile Action = "duplicate_wrong_file"
// An admin removing a track: its file and row are deleted, with every
// like, play and playlist entry that hung off the row. The metadata names
// the track and its file, so the log can say what went after the row is
// gone.
ActionTrackRemove Action = "track_remove"
// Subsonic password: generated in Settings for t/s-only clients.
ActionSubsonicPasswordSet Action = "subsonic_password_set"
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
+226
View File
@@ -0,0 +1,226 @@
package backup
import (
"archive/tar"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
// The backup file is laid out so that what didn't change between two days is
// the same bytes both times, which suits a deduplicating off-site copy such as
// Fabled Archives' restic:
//
// - Never compressed. Compression rewrites every byte after the first
// change, so no chunk would match again.
// - One member per table, tables/<table>.jsonl: one row per line, in
// primary-key order, keys sorted, no spacing.
// - What changes every run lives only in manifest.json, the first member.
// Member headers are fixed (mtime 0, owner 0, mode 0644).
// - A new dated name each run, so the target keeps a history and a sender
// only ever adds.
const (
FormatName = "minstrel-backup-tar"
FormatVersion = 1
manifestName = "manifest.json"
tablesDir = "tables/"
tableSuffix = ".jsonl"
namePrefix = "minstrel-"
nameSuffix = ".tar"
// maxMember bounds any one member a read takes in: far past the largest
// library's tracks table, small enough that a hostile file can't fill
// memory.
maxMember = 1 << 30
)
// Manifest is the file's first member.
type Manifest struct {
Format string `json:"format"`
FormatVersion int `json:"format_version"`
CreatedAt time.Time `json:"created_at"`
// Build is the Minstrel version that wrote the file.
Build string `json:"build"`
// Schema is the database's migration version when the file was written.
// A restore refuses a file from a newer schema than its own.
Schema int64 `json:"schema"`
// Rows is each table's row count, so a read can tell a whole member from
// a cut-off one.
Rows map[string]int `json:"rows"`
}
// FileName is a backup's file name: minstrel-20261102T030000Z.tar, in UTC, so
// names sort by time.
func FileName(at time.Time) string {
return namePrefix + at.UTC().Format("20060102T150405Z") + nameSuffix
}
// IsFileName reports whether a name is one FileName gives.
func IsFileName(name string) bool {
stamp, ok := strings.CutPrefix(name, namePrefix)
if !ok {
return false
}
stamp, ok = strings.CutSuffix(stamp, nameSuffix)
if !ok {
return false
}
_, err := time.Parse("20060102T150405Z", stamp)
return err == nil
}
// Write writes c as a backup file to w and returns its manifest.
func Write(w io.Writer, c Contents, at time.Time, build string) (Manifest, error) {
names := make([]string, 0, len(c.Tables))
m := Manifest{
Format: FormatName,
FormatVersion: FormatVersion,
CreatedAt: at.UTC(),
Build: build,
Schema: c.Schema,
Rows: make(map[string]int, len(c.Tables)),
}
for name, lines := range c.Tables {
names = append(names, name)
m.Rows[name] = len(lines)
}
slices.Sort(names)
manifest, err := json.MarshalIndent(m, "", " ")
if err != nil {
return m, err
}
tw := tar.NewWriter(w)
if err := member(tw, manifestName, append(manifest, '\n')); err != nil {
return m, err
}
for _, name := range names {
var body bytes.Buffer
for _, line := range c.Tables[name] {
body.Write(line)
body.WriteByte('\n')
}
if err := member(tw, tablesDir+name+tableSuffix, body.Bytes()); err != nil {
return m, err
}
}
return m, tw.Close()
}
// WriteFile writes c into dir under its dated name. The bytes go to a hidden
// .part file first, are synced, and only then take the backup's name, so
// anything watching the folder never sees half a backup under a backup's
// name, and a run that fails leaves nothing behind.
func WriteFile(dir string, c Contents, at time.Time, build string) (string, Manifest, error) {
final := filepath.Join(dir, FileName(at))
part := filepath.Join(dir, "."+FileName(at)+".part")
f, err := os.OpenFile(part, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return "", Manifest{}, fmt.Errorf("backup: create %s: %w", part, err)
}
m, err := Write(f, c, at, build)
if err == nil {
err = f.Sync()
}
if cerr := f.Close(); err == nil {
err = cerr
}
if err == nil {
err = os.Rename(part, final)
}
if err != nil {
_ = os.Remove(part)
return "", m, fmt.Errorf("backup: write %s: %w", final, err)
}
if d, err := os.Open(dir); err == nil {
_ = d.Sync()
_ = d.Close()
}
return final, m, nil
}
func member(tw *tar.Writer, name string, data []byte) error {
hdr := &tar.Header{
Name: name,
Mode: 0o644,
Size: int64(len(data)),
ModTime: time.Unix(0, 0),
Typeflag: tar.TypeReg,
Format: tar.FormatUSTAR,
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
// Read reads a backup file, refusing anything that isn't a whole one.
func Read(r io.Reader) (Manifest, Contents, error) {
var m Manifest
c := Contents{Tables: map[string][][]byte{}}
tr := tar.NewReader(r)
first := true
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return m, c, fmt.Errorf("%w: %w", ErrNotABackup, err)
}
if hdr.Size > maxMember {
return m, c, fmt.Errorf("%w: %s is too large", ErrNotABackup, hdr.Name)
}
data, err := io.ReadAll(io.LimitReader(tr, maxMember))
if err != nil {
return m, c, fmt.Errorf("%w: %w", ErrNotABackup, err)
}
if first {
first = false
if hdr.Name != manifestName || json.Unmarshal(data, &m) != nil || m.Format != FormatName {
return m, c, ErrNotABackup
}
if m.FormatVersion > FormatVersion {
return m, c, fmt.Errorf("%w: it was written by a newer Minstrel (format %d)", ErrNotABackup, m.FormatVersion)
}
c.Schema = m.Schema
continue
}
table, ok := strings.CutPrefix(hdr.Name, tablesDir)
if !ok {
continue
}
if table, ok = strings.CutSuffix(table, tableSuffix); !ok || table == "" {
continue
}
var lines [][]byte
for line := range bytes.SplitSeq(data, []byte("\n")) {
if len(line) == 0 {
continue
}
if !json.Valid(line) {
return m, c, fmt.Errorf("%w: a row in %s is not JSON", ErrNotABackup, table)
}
lines = append(lines, line)
}
c.Tables[table] = lines
}
if first {
return m, c, ErrNotABackup
}
for table, want := range m.Rows {
if got := len(c.Tables[table]); got != want {
return m, c, fmt.Errorf("%w: %s holds %d rows, the manifest says %d", ErrNotABackup, table, got, want)
}
}
return m, c, nil
}
+194
View File
@@ -0,0 +1,194 @@
package backup
import (
"archive/tar"
"bytes"
"errors"
"io"
"os"
"path/filepath"
"testing"
"time"
)
func sample() Contents {
return Contents{
Schema: 98,
Tables: map[string][][]byte{
"users": {[]byte(`{"id":"u1","username":"ada"}`), []byte(`{"id":"u2","username":"bo"}`)},
"tracks": {[]byte(`{"file_path":"/music/a.flac","id":"t1"}`)},
"wants": {},
},
}
}
func members(t *testing.T, file []byte) map[string][]byte {
t.Helper()
out := map[string][]byte{}
tr := tar.NewReader(bytes.NewReader(file))
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
return out
}
if err != nil {
t.Fatal(err)
}
data, _ := io.ReadAll(tr)
out[hdr.Name] = data
}
}
func TestABackupFileReadsBackAsWritten(t *testing.T) {
var buf bytes.Buffer
at := time.Date(2026, 11, 2, 3, 0, 0, 0, time.UTC)
if _, err := Write(&buf, sample(), at, "2026.11.02.0300"); err != nil {
t.Fatal(err)
}
m, c, err := Read(&buf)
if err != nil {
t.Fatal(err)
}
if m.Schema != 98 || c.Schema != 98 || m.Build != "2026.11.02.0300" || !m.CreatedAt.Equal(at) {
t.Errorf("manifest = %+v", m)
}
want := sample()
for name, lines := range want.Tables {
got := c.Tables[name]
if len(got) != len(lines) {
t.Errorf("%s: %d rows, want %d", name, len(got), len(lines))
continue
}
for i := range lines {
if !bytes.Equal(got[i], lines[i]) {
t.Errorf("%s row %d = %s, want %s", name, i, got[i], lines[i])
}
}
}
}
// Two days over unchanged data differ only in the manifest, so a
// deduplicating copy stores the tables once.
func TestOnlyTheManifestChangesBetweenDays(t *testing.T) {
var day1, day2 bytes.Buffer
if _, err := Write(&day1, sample(), time.Date(2026, 11, 1, 3, 0, 0, 0, time.UTC), "a"); err != nil {
t.Fatal(err)
}
if _, err := Write(&day2, sample(), time.Date(2026, 11, 2, 3, 0, 0, 0, time.UTC), "b"); err != nil {
t.Fatal(err)
}
a, b := members(t, day1.Bytes()), members(t, day2.Bytes())
if len(a) != 4 {
t.Fatalf("members = %d, want the manifest and three tables", len(a))
}
for name, data := range a {
same := bytes.Equal(data, b[name])
if name == manifestName && same {
t.Error("the manifest is identical across days; it should carry the run's time")
}
if name != manifestName && !same {
t.Errorf("%s differs between days over the same rows", name)
}
}
// The table members' bytes, headers included, sit unchanged in both files.
tracks := a[tablesDir+"tracks"+tableSuffix]
if !bytes.Contains(day1.Bytes(), tracks) || !bytes.Contains(day2.Bytes(), tracks) {
t.Error("the tracks member is not stored verbatim")
}
}
func TestReadRefusesWhatIsNotAWholeBackup(t *testing.T) {
var good bytes.Buffer
if _, err := Write(&good, sample(), time.Now(), "x"); err != nil {
t.Fatal(err)
}
tarOf := func(name string, data []byte) []byte {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
_ = member(tw, name, data)
_ = tw.Close()
return buf.Bytes()
}
short := func() []byte {
// The manifest promises two users; the member holds one.
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
_ = member(tw, manifestName, []byte(`{"format":"minstrel-backup-tar","format_version":1,"rows":{"users":2}}`))
_ = member(tw, tablesDir+"users"+tableSuffix, []byte(`{"id":"u1"}`+"\n"))
_ = tw.Close()
return buf.Bytes()
}
cases := map[string][]byte{
"empty": nil,
"not a tar": []byte("hello"),
"cut off": good.Bytes()[:good.Len()/2],
"no manifest": tarOf(tablesDir+"users"+tableSuffix, []byte(`{"id":"u1"}`)),
"another format": tarOf(manifestName, []byte(`{"format":"fablednotary-backup-tar","format_version":1}`)),
"a newer format": tarOf(manifestName, []byte(`{"format":"minstrel-backup-tar","format_version":99}`)),
"a member short": short(),
}
for name, file := range cases {
if _, _, err := Read(bytes.NewReader(file)); !errors.Is(err, ErrNotABackup) {
t.Errorf("%s: err = %v, want ErrNotABackup", name, err)
}
}
}
func TestWriteFileLandsUnderTheDatedNameOnly(t *testing.T) {
dir := t.TempDir()
at := time.Date(2026, 11, 2, 3, 0, 0, 0, time.UTC)
path, _, err := WriteFile(dir, sample(), at, "x")
if err != nil {
t.Fatal(err)
}
if filepath.Base(path) != "minstrel-20261102T030000Z.tar" {
t.Errorf("path = %s", path)
}
entries, _ := os.ReadDir(dir)
if len(entries) != 1 || entries[0].Name() != filepath.Base(path) {
t.Errorf("the folder holds %v, want only the backup", entries)
}
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() { _ = f.Close() }()
if _, _, err := Read(f); err != nil {
t.Errorf("the written file doesn't read: %v", err)
}
}
func TestAFailedWriteFileLeavesNothingBehind(t *testing.T) {
dir := filepath.Join(t.TempDir(), "missing")
if _, _, err := WriteFile(dir, sample(), time.Now(), "x"); err == nil {
t.Fatal("writing into a missing folder succeeded")
}
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Errorf("the failed write created %s", dir)
}
}
func TestFileNamesSortByTimeAndAreRecognised(t *testing.T) {
earlier := FileName(time.Date(2026, 1, 9, 23, 0, 0, 0, time.UTC))
// 01:00 at UTC-5 is 06:00 UTC: the name is in UTC, not the clock's zone.
later := FileName(time.Date(2026, 1, 10, 1, 0, 0, 0, time.FixedZone("x", -5*3600)))
if later != "minstrel-20260110T060000Z.tar" {
t.Errorf("name = %s, want it in UTC", later)
}
if earlier >= later {
t.Errorf("%s does not sort before %s", earlier, later)
}
for name, want := range map[string]bool{
earlier: true,
"minstrel-20261102T030000Z.tar": true,
".minstrel-20261102T030000Z.tar.part": false,
"minstrel-yesterday.tar": false,
"fablednotary-20261102T030000Z.tar": false,
} {
if IsFileName(name) != want {
t.Errorf("IsFileName(%q) = %v, want %v", name, !want, want)
}
}
}
+256
View File
@@ -0,0 +1,256 @@
package backup_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"os"
"slices"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
)
func newPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
func index(name string) int {
return slices.IndexFunc(backup.Tables, func(t backup.Table) bool { return t.Name == name })
}
// Every table in the schema is backed up or excluded with a reason, so a new
// table fails here until someone decides which.
func TestEveryTableIsAccountedFor_Integration(t *testing.T) {
pool := newPool(t)
rows, err := pool.Query(context.Background(), "SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
if err != nil {
t.Fatal(err)
}
schema, err := pgx.CollectRows(rows, pgx.RowTo[string])
if err != nil {
t.Fatal(err)
}
for _, p := range backup.Coverage(schema) {
t.Error(p)
}
}
// A restore loads each table after the ones it references, and never needs a
// table the backup leaves out.
func TestTablesComeAfterWhatTheyReference_Integration(t *testing.T) {
pool := newPool(t)
rows, err := pool.Query(context.Background(), `
SELECT cl.relname, pc.relname
FROM pg_constraint co
JOIN pg_class cl ON cl.oid = co.conrelid
JOIN pg_class pc ON pc.oid = co.confrelid
JOIN pg_namespace n ON n.oid = cl.relnamespace
WHERE co.contype = 'f' AND n.nspname = 'public'`)
if err != nil {
t.Fatal(err)
}
type fk struct{ child, parent string }
fks, err := pgx.CollectRows(rows, func(r pgx.CollectableRow) (fk, error) {
var f fk
err := r.Scan(&f.child, &f.parent)
return f, err
})
if err != nil {
t.Fatal(err)
}
if len(fks) == 0 {
t.Fatal("found no foreign keys; the query is wrong")
}
for _, f := range fks {
child, parent := index(f.child), index(f.parent)
if child < 0 {
continue
}
switch {
case parent < 0:
t.Errorf("%s is backed up but references %s, which is excluded", f.child, f.parent)
case parent >= child && f.parent != f.child:
t.Errorf("%s references %s, so it must come after it in Tables", f.child, f.parent)
}
}
}
// Every omitted column exists, so a renamed column fails here rather than a
// sealed credential slipping into the file under its new name.
func TestOmittedColumnsExist_Integration(t *testing.T) {
pool := newPool(t)
for _, tbl := range backup.Tables {
for _, col := range tbl.Omit {
var n int
if err := pool.QueryRow(context.Background(), `
SELECT count(*) FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2`,
tbl.Name, col).Scan(&n); err != nil || n != 1 {
t.Errorf("%s.%s: found %d columns, err %v", tbl.Name, col, n, err)
}
}
}
}
func mustExec(t *testing.T, pool *pgxpool.Pool, sql string, args ...any) {
t.Helper()
if _, err := pool.Exec(context.Background(), sql, args...); err != nil {
t.Fatalf("%s: %v", sql, err)
}
}
func mustID(t *testing.T, pool *pgxpool.Pool, sql string, args ...any) string {
t.Helper()
var id string
if err := pool.QueryRow(context.Background(), sql, args...).Scan(&id); err != nil {
t.Fatalf("%s: %v", sql, err)
}
return id
}
// A backup restored into an emptied instance gives back the same rows, byte
// for byte, without the credentials and markers it leaves out.
func TestARestoredInstanceHoldsWhatTheBackupHeld_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
user := mustID(t, pool, `INSERT INTO users (username, password_hash, api_token_hash, listenbrainz_token)
VALUES ($1, 'hash', 'backup-token', 'lb-token') RETURNING id::text`, dbtest.TestUserPrefix+"backup")
artist := mustID(t, pool, `INSERT INTO artists (name, sort_name, artist_art_source) VALUES ('Artist', 'artist', 'none') RETURNING id::text`)
album := mustID(t, pool, `INSERT INTO albums (title, sort_title, artist_id, cover_art_source, cover_art_path)
VALUES ('Album', 'album', $1, 'mbcaa', '/data/covers/a.jpg') RETURNING id::text`, artist)
track := mustID(t, pool, `INSERT INTO tracks (title, album_id, artist_id, duration_ms, file_path, file_size, file_format, tag_source)
VALUES ('Track', $1, $2, 180000, '/music/a.flac', 1000, 'flac', 'musicbrainz') RETURNING id::text`, album, artist)
session := mustID(t, pool, `INSERT INTO play_sessions (user_id, started_at, last_event_at) VALUES ($1, now(), now()) RETURNING id::text`, user)
mustExec(t, pool, `INSERT INTO play_events (user_id, track_id, session_id, started_at) VALUES ($1, $2, $3, now())`, user, track, session)
mustExec(t, pool, `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, user, track)
playlist := mustID(t, pool, `INSERT INTO playlists (user_id, name) VALUES ($1, 'Mine') RETURNING id::text`, user)
mustExec(t, pool, `INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec)
VALUES ($1, 0, $2, 'Track', 'Artist', 'Album', 180)`, playlist, track)
mustExec(t, pool, `INSERT INTO follows (artist_mbid, artist_name, profile_id, followed_by)
SELECT 'mbid-1', 'Artist', id, $1 FROM release_type_profiles WHERE is_default`, user)
mustExec(t, pool, `INSERT INTO library_changes (entity_type, entity_id, op) VALUES ('track', $1, 'upsert')`, track)
first, err := backup.Snapshot(ctx, pool)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
for _, tbl := range backup.Tables {
for _, line := range first.Tables[tbl.Name] {
var row map[string]json.RawMessage
if err := json.Unmarshal(line, &row); err != nil {
t.Fatalf("%s: %v", tbl.Name, err)
}
for _, col := range tbl.Omit {
if _, ok := row[col]; ok {
t.Errorf("%s.%s is in the backup", tbl.Name, col)
}
}
}
}
for _, tbl := range []string{"users", "tracks", "play_events", "general_likes", "playlist_tracks", "follows", "library_changes"} {
if len(first.Tables[tbl]) == 0 {
t.Errorf("the backup holds no %s", tbl)
}
}
if err := backup.Restore(ctx, pool, first); !errors.Is(err, backup.ErrNotEmpty) {
t.Fatalf("restore over data: err = %v, want ErrNotEmpty", err)
}
newer := first
newer.Schema++
if err := backup.Restore(ctx, pool, newer); !errors.Is(err, backup.ErrNewerSchema) {
t.Fatalf("restore from a newer schema: err = %v, want ErrNewerSchema", err)
}
// A new server scans its music before anyone signs up, so it already has
// a library, here with a track the backup doesn't know. It still takes
// the backup: the backup's library replaces what the scan found, which the
// next scan finds again under the backup's ids.
mustExec(t, pool, "TRUNCATE users CASCADE")
scannedArtist := mustID(t, pool, `INSERT INTO artists (name, sort_name) VALUES ('Scanned', 'scanned') RETURNING id::text`)
scannedAlbum := mustID(t, pool, `INSERT INTO albums (title, sort_title, artist_id) VALUES ('Scanned', 'scanned', $1) RETURNING id::text`, scannedArtist)
scanned := mustID(t, pool, `INSERT INTO tracks (title, album_id, artist_id, duration_ms, file_path, file_size, file_format)
VALUES ('Scanned', $1, $2, 1000, '/music/b.flac', 10, 'flac') RETURNING id::text`, scannedAlbum, scannedArtist)
mustExec(t, pool, `INSERT INTO library_changes (entity_type, entity_id, op) VALUES ('track', $1, 'upsert')`, scanned)
if err := backup.Restore(ctx, pool, first); err != nil {
t.Fatalf("restore: %v", err)
}
second, err := backup.Snapshot(ctx, pool)
if err != nil {
t.Fatalf("snapshot after restore: %v", err)
}
for _, tbl := range backup.Tables {
a, b := first.Tables[tbl.Name], second.Tables[tbl.Name]
if len(a) != len(b) {
t.Errorf("%s: %d rows restored, the backup held %d", tbl.Name, len(b), len(a))
continue
}
for i := range a {
if !bytes.Equal(a[i], b[i]) {
t.Errorf("%s row %d:\nbacked up %s\nrestored %s", tbl.Name, i+1, a[i], b[i])
}
}
}
// What the backup leaves out comes back unset, so the server asks for the
// credential again and redoes the art and tags.
var lbToken, coverSource, tagSource *string
if err := pool.QueryRow(ctx, `SELECT u.listenbrainz_token, al.cover_art_source, tr.tag_source
FROM users u, albums al, tracks tr WHERE u.id = $1 AND al.id = $2 AND tr.id = $3`,
user, album, track).Scan(&lbToken, &coverSource, &tagSource); err != nil {
t.Fatal(err)
}
if lbToken != nil || coverSource != nil || tagSource != nil {
t.Errorf("token %v, cover source %v, tag source %v; want all unset", lbToken, coverSource, tagSource)
}
// The change feed carries on after the restored ids.
var restoredMax, issued, next int64
if err := pool.QueryRow(ctx, "SELECT max(id) FROM library_changes").Scan(&restoredMax); err != nil {
t.Fatal(err)
}
// The sequence says the restored history ends there, which is what the
// library sync compares an app's cursor with.
if err := pool.QueryRow(ctx, `SELECT COALESCE(pg_sequence_last_value(
pg_get_serial_sequence('library_changes', 'id')::regclass), 0)`).Scan(&issued); err != nil {
t.Fatal(err)
}
if issued != restoredMax {
t.Errorf("the change sequence stands at %d, want the restored %d", issued, restoredMax)
}
if err := pool.QueryRow(ctx, `INSERT INTO library_changes (entity_type, entity_id, op)
VALUES ('track', $1, 'upsert') RETURNING id`, track).Scan(&next); err != nil {
t.Fatal(err)
}
if next <= restoredMax {
t.Errorf("next change id %d, want it after the restored %d", next, restoredMax)
}
}
+281
View File
@@ -0,0 +1,281 @@
package backup
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
const (
// The music's folder inside the destination, beside database/.
musicDir = "music"
// MusicBudget is how long one run may spend sending music. A library
// bigger than a night's worth carries on from where it stopped on the
// next run, so the first copy of a large library spans several nights.
MusicBudget = 6 * time.Hour
musicBatch = 200
// refusedInARow stops a pass: one refusal is that file, several in a row
// are the key or the bucket refusing the music as a whole.
refusedInARow = 3
)
// MusicProgress is how much of the library the backup holds as it is now.
type MusicProgress struct {
TotalFiles int64 `json:"total_files"`
TotalBytes int64 `json:"total_bytes"`
SentFiles int64 `json:"sent_files"`
SentBytes int64 `json:"sent_bytes"`
RefusedFiles int64 `json:"refused_files"`
Refused []RefusedFile `json:"refused"`
}
// RefusedFile is a library file the destination would not take, with its
// answer. It is tried again once the file changes.
type RefusedFile struct {
Path string `json:"path"`
Error string `json:"error"`
At time.Time `json:"at"`
}
// musicSent is what one run's music pass did.
type musicSent struct {
files int
bytes int64
note string
}
// commonRoot is the deepest folder holding every library root, so the music's
// paths in the backup are the same whichever root a file is under.
func commonRoot(roots []string) string {
if len(roots) == 0 {
return ""
}
common := strings.Split(filepath.Clean(roots[0]), string(filepath.Separator))
for _, r := range roots[1:] {
parts := strings.Split(filepath.Clean(r), string(filepath.Separator))
n := 0
for n < len(common) && n < len(parts) && common[n] == parts[n] {
n++
}
common = common[:n]
}
root := strings.Join(common, string(filepath.Separator))
if root == "" {
return string(filepath.Separator)
}
return root
}
// musicPath is file's path inside music/, relative to root, with forward
// slashes. A file outside root keeps its whole path.
func musicPath(root, file string) string {
rel, err := filepath.Rel(root, file)
if root == "" || err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
rel = strings.TrimPrefix(filepath.Clean(file), string(filepath.Separator))
}
return musicDir + "/" + filepath.ToSlash(rel)
}
func (s *Service) musicProgress(ctx context.Context) (*MusicProgress, error) {
q := dbq.New(s.pool)
p, err := q.BackupMusicProgress(ctx)
if err != nil {
return nil, fmt.Errorf("backup: read music progress: %w", err)
}
refused, err := q.RefusedBackupMusic(ctx, recentRuns)
if err != nil {
return nil, fmt.Errorf("backup: list refused music: %w", err)
}
out := &MusicProgress{
TotalFiles: p.TotalFiles, TotalBytes: p.TotalBytes,
SentFiles: p.SentFiles, SentBytes: p.SentBytes,
RefusedFiles: p.RefusedFiles, Refused: make([]RefusedFile, 0, len(refused)),
}
for _, r := range refused {
out.Refused = append(out.Refused, RefusedFile{Path: r.FilePath, Error: r.Error, At: r.CheckedAt.Time})
}
return out, nil
}
// sendMusic sends library files the destination doesn't hold as they are now,
// one at a time in path order, until all are sent or ctx ends. Every file is
// recorded as it lands, so a run cut short loses nothing. It never deletes
// anything at the destination.
func (s *Service) sendMusic(ctx context.Context, st dbq.BackupSetting) musicSent {
var out musicSent
q := dbq.New(s.pool)
var roots []string
if s.Roots != nil {
roots = s.Roots()
}
root := commonRoot(roots)
var bucket Bucket
if st.Destination == DestinationS3 {
b, err := bucketOf(st)
if err != nil {
out.note = describe(err)
return out
}
bucket = b
}
after := ""
refused := 0
for {
rows, err := q.PendingBackupMusic(ctx, dbq.PendingBackupMusicParams{AfterPath: after, MaxRows: musicBatch})
if err != nil {
out.note = musicStopped(ctx, err)
return out
}
if len(rows) == 0 {
return out
}
for _, r := range rows {
after = r.FilePath
if err := ctx.Err(); err != nil {
out.note = musicStopped(ctx, err)
return out
}
info, err := os.Stat(r.FilePath)
if err != nil || !info.Mode().IsRegular() {
// Gone or unreadable: the library scan settles that, and the
// file is looked at again on the next run.
continue
}
mtime := info.ModTime().Truncate(time.Microsecond)
key := musicPath(root, r.FilePath)
location := key
if st.Destination == DestinationFolder {
location = filepath.Join(st.Folder, filepath.FromSlash(key))
}
rec := dbq.RecordBackupMusicParams{
TrackID: r.ID, Size: info.Size(),
Mtime: pgtype.Timestamptz{Time: mtime, Valid: true},
Location: location,
}
if r.SentSize != nil && *r.SentSize == info.Size() && r.SentMtime.Valid && r.SentMtime.Time.Equal(mtime) &&
(r.SentError == nil || *r.SentError == "") {
// Touched by a scan but the same file: nothing to send.
if err := q.RecordBackupMusic(ctx, rec); err != nil {
out.note = musicStopped(ctx, err)
return out
}
continue
}
if st.Destination == DestinationFolder {
err = copyFile(r.FilePath, location, mtime)
} else {
err = putFile(ctx, bucket, key, r.FilePath, int64(st.UploadLimitKib)*1024)
}
switch {
case err == nil:
out.files++
out.bytes += info.Size()
refused = 0
case ObjectRefused(err):
rec.Error = strings.TrimPrefix(err.Error(), "backup: ")
if refused++; refused >= refusedInARow {
out.note = fmt.Sprintf("Music stopped: the destination refused %d files in a row (%s). "+
"Check the key may write under %s/; refused files go again once you save the settings.",
refused, rec.Error, musicDir)
}
case ctx.Err() == nil && errors.Is(err, errSourceRead):
// The library file couldn't be read through: looked at again next run.
continue
default:
out.note = musicStopped(ctx, err)
return out
}
if err := q.RecordBackupMusic(ctx, rec); err != nil {
out.note = musicStopped(ctx, err)
return out
}
if out.note != "" {
return out
}
}
}
}
// musicStopped is why a music pass ended early, as the panel shows it.
func musicStopped(ctx context.Context, err error) string {
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
return fmt.Sprintf("Music stopped for tonight after %d hours; it carries on with the next backup.", int(MusicBudget/time.Hour))
case ctx.Err() != nil:
return "Music stopped because the server stopped; it carries on with the next backup."
}
return "Music stopped: " + describe(err) + " It carries on with the next backup."
}
// errSourceRead marks a library file that could not be read.
var errSourceRead = errors.New("backup: the library file could not be read")
// putFile uploads file to key. It reads the file twice, once to hash it
// (S3 signs over the body's hash) and once to send it through the limit.
func putFile(ctx context.Context, b Bucket, key, file string, perSecond int64) error {
f, err := os.Open(file)
if err != nil {
return fmt.Errorf("%w: %v", errSourceRead, err)
}
defer func() { _ = f.Close() }()
h := sha256.New()
size, err := io.Copy(h, f)
if err != nil {
return fmt.Errorf("%w: %v", errSourceRead, err)
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("%w: %v", errSourceRead, err)
}
return b.Put(ctx, key, Throttle(ctx, f, perSecond), size, hex.EncodeToString(h.Sum(nil)))
}
// copyFile copies file to dst under a hidden .part name first, so the folder
// never holds half a file under the file's own name. The copy keeps the
// file's modification time. The upload limit is the bucket's: a folder copy
// runs at the disk's pace.
func copyFile(file, dst string, mtime time.Time) error {
in, err := os.Open(file)
if err != nil {
return fmt.Errorf("%w: %v", errSourceRead, err)
}
defer func() { _ = in.Close() }()
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return fmt.Errorf("backup: create %s: %w", filepath.Dir(dst), err)
}
part := filepath.Join(filepath.Dir(dst), "."+filepath.Base(dst)+".part")
out, err := os.OpenFile(part, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("backup: create %s: %w", part, err)
}
_, err = io.Copy(out, in)
if err == nil {
err = out.Sync()
}
if cerr := out.Close(); err == nil {
err = cerr
}
if err == nil {
err = os.Chtimes(part, mtime, mtime)
}
if err == nil {
err = os.Rename(part, dst)
}
if err != nil {
_ = os.Remove(part)
return fmt.Errorf("backup: write %s: %w", dst, err)
}
return nil
}
+217
View File
@@ -0,0 +1,217 @@
package backup_test
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
)
// library writes music files under a new library folder and adds a track for
// each. It returns the folder.
func library(t *testing.T, pool *pgxpool.Pool, files map[string]string) string {
t.Helper()
root := t.TempDir()
artist := mustID(t, pool, `INSERT INTO artists (name, sort_name) VALUES ('Artist', 'artist') RETURNING id::text`)
album := mustID(t, pool, `INSERT INTO albums (title, sort_title, artist_id) VALUES ('Album', 'album', $1) RETURNING id::text`, artist)
for rel, body := range files {
path := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
mustExec(t, pool, `INSERT INTO tracks (title, album_id, artist_id, duration_ms, file_path, file_size, file_format)
VALUES ($1, $2, $3, 1000, $4, $5, 'flac')`, rel, album, artist, path, len(body))
}
return root
}
func runOnce(t *testing.T, svc *backup.Service) backup.Run {
t.Helper()
if err := svc.Start(context.Background(), backup.TriggerManual); err != nil {
t.Fatal(err)
}
waitIdle(t, svc)
st := status(t, svc)
if len(st.Runs) == 0 || st.Runs[0].Status != "succeeded" {
t.Fatalf("the run didn't succeed: %+v", st.Runs)
}
return st.Runs[0]
}
func TestTheMusicGoesToTheFolderOnceAndAgainOnlyWhenItChanges_Integration(t *testing.T) {
svc, pool := newService(t)
ctx := context.Background()
lib := library(t, pool, map[string]string{"A/one.flac": "one", "B/two.flac": "second"})
svc.Roots = func() []string { return []string{lib} }
dest := t.TempDir()
if err := svc.Save(ctx, backup.Update{Enabled: true, Destination: backup.DestinationFolder, Folder: dest, RunHour: 3, IncludeMusic: true}); err != nil {
t.Fatal(err)
}
first := runOnce(t, svc)
if first.MusicFiles != 2 || first.MusicBytes != int64(len("one")+len("second")) || first.MusicNote != "" {
t.Errorf("the first run sent %d files, %d bytes, note %q", first.MusicFiles, first.MusicBytes, first.MusicNote)
}
got, err := os.ReadFile(filepath.Join(dest, "music", "B", "two.flac"))
if err != nil || string(got) != "second" {
t.Errorf("music/B/two.flac = %q, %v", got, err)
}
src, _ := os.Stat(filepath.Join(lib, "A", "one.flac"))
dst, err := os.Stat(filepath.Join(dest, "music", "A", "one.flac"))
// The database keeps microseconds, so the copy's time is the file's to the µs.
if err != nil || !dst.ModTime().Equal(src.ModTime().Truncate(time.Microsecond)) {
t.Errorf("the copy's time = %v, want the file's %v (%v)", dst, src.ModTime(), err)
}
if p := status(t, svc).Music; p == nil || p.TotalFiles != 2 || p.SentFiles != 2 || p.RefusedFiles != 0 {
t.Errorf("progress = %+v, want 2 of 2", p)
}
if again := runOnce(t, svc); again.MusicFiles != 0 {
t.Errorf("a second run sent %d files, want none", again.MusicFiles)
}
// A scan that touched the track but found the same file sends nothing.
mustExec(t, pool, `UPDATE tracks SET updated_at = now()`)
if touched := runOnce(t, svc); touched.MusicFiles != 0 {
t.Errorf("after a scan touched the tracks the run sent %d files, want none", touched.MusicFiles)
}
if p := status(t, svc).Music; p == nil || p.SentFiles != 2 {
t.Errorf("progress after the scan = %+v, want 2 sent", p)
}
// A changed file goes again, to the same place.
path := filepath.Join(lib, "A", "one.flac")
if err := os.WriteFile(path, []byte("one, retagged"), 0o644); err != nil {
t.Fatal(err)
}
later := time.Now().Add(time.Minute)
if err := os.Chtimes(path, later, later); err != nil {
t.Fatal(err)
}
mustExec(t, pool, `UPDATE tracks SET file_size = $1 WHERE file_path = $2`, len("one, retagged"), path)
if changed := runOnce(t, svc); changed.MusicFiles != 1 {
t.Errorf("after one file changed the run sent %d files, want 1", changed.MusicFiles)
}
if got, _ := os.ReadFile(filepath.Join(dest, "music", "A", "one.flac")); string(got) != "one, retagged" {
t.Errorf("music/A/one.flac = %q, want the changed file", got)
}
if entries, _ := os.ReadDir(filepath.Join(dest, "music", "A")); len(entries) != 1 {
t.Errorf("music/A holds %d entries, want just the file", len(entries))
}
}
// lockedBucket is a fakeBucket that refuses to overwrite an object, like a
// bucket with object lock. With all set it refuses every music object, like
// a key that may write only under database/.
type lockedBucket struct {
*fakeBucket
all *atomic.Bool
}
func (b lockedBucket) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/minstrel/music/") {
b.mu.Lock()
_, held := b.puts[r.URL.Path]
b.mu.Unlock()
if held || (b.all != nil && b.all.Load()) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`<Error><Code>AccessDenied</Code><Message>object is locked</Message></Error>`))
return
}
}
b.fakeBucket.ServeHTTP(w, r)
}
func TestTheMusicGoesToTheBucketAndARefusedOverwriteIsKept_Integration(t *testing.T) {
svc, pool := newService(t)
ctx := context.Background()
fake := &fakeBucket{puts: map[string][]byte{}}
srv := httptest.NewServer(lockedBucket{fakeBucket: fake})
defer srv.Close()
lib := library(t, pool, map[string]string{"A/one.flac": "one", "B/two.flac": "second"})
svc.Roots = func() []string { return []string{lib} }
u := bucketUpdate(srv.URL)
u.IncludeMusic = true
if err := svc.Save(ctx, u); err != nil {
t.Fatal(err)
}
if first := runOnce(t, svc); first.MusicFiles != 2 {
t.Fatalf("the first run sent %d files, want 2", first.MusicFiles)
}
fake.mu.Lock()
body := string(fake.puts["/minstrel/music/A/one.flac"])
fake.mu.Unlock()
if body != "one" {
t.Errorf("music/A/one.flac in the bucket = %q", body)
}
path := filepath.Join(lib, "A", "one.flac")
if err := os.WriteFile(path, []byte("one, retagged"), 0o644); err != nil {
t.Fatal(err)
}
mustExec(t, pool, `UPDATE tracks SET file_size = $1, updated_at = now() WHERE file_path = $2`, len("one, retagged"), path)
run := runOnce(t, svc)
if run.MusicFiles != 0 || run.MusicNote != "" {
t.Errorf("a refused overwrite: sent %d, note %q; want nothing sent and no note", run.MusicFiles, run.MusicNote)
}
p := status(t, svc).Music
if p == nil || p.SentFiles != 1 || p.RefusedFiles != 1 || len(p.Refused) != 1 ||
p.Refused[0].Path != path || !strings.Contains(p.Refused[0].Error, "AccessDenied") {
t.Errorf("progress = %+v, want one sent and the overwrite refused", p)
}
// Refused is settled until the file changes again.
if again := runOnce(t, svc); again.MusicFiles != 0 {
t.Errorf("the run after a refusal sent %d files, want none", again.MusicFiles)
}
if p := status(t, svc).Music; p == nil || p.RefusedFiles != 1 {
t.Errorf("progress after the next run = %+v, want the refusal kept", p)
}
}
func TestAKeyThatMayNotWriteTheMusicStopsThePassAndASaveTriesAgain_Integration(t *testing.T) {
svc, pool := newService(t)
ctx := context.Background()
fake := &fakeBucket{puts: map[string][]byte{}}
all := &atomic.Bool{}
all.Store(true)
srv := httptest.NewServer(lockedBucket{fakeBucket: fake, all: all})
defer srv.Close()
lib := library(t, pool, map[string]string{"a.flac": "a", "b.flac": "b", "c.flac": "c", "d.flac": "d"})
svc.Roots = func() []string { return []string{lib} }
u := bucketUpdate(srv.URL)
u.IncludeMusic = true
if err := svc.Save(ctx, u); err != nil {
t.Fatal(err)
}
run := runOnce(t, svc)
if run.MusicFiles != 0 || !strings.Contains(run.MusicNote, "refused 3 files in a row") {
t.Errorf("sent %d, note %q; want the pass stopped after three refusals", run.MusicFiles, run.MusicNote)
}
if p := status(t, svc).Music; p == nil || p.RefusedFiles != 3 {
t.Errorf("progress = %+v, want three refused and the fourth not tried", p)
}
// The key is fixed and the settings saved: the refused files go again.
all.Store(false)
if err := svc.Save(ctx, u); err != nil {
t.Fatal(err)
}
if again := runOnce(t, svc); again.MusicFiles != 4 || again.MusicNote != "" {
t.Errorf("after the save: sent %d, note %q; want all four", again.MusicFiles, again.MusicNote)
}
}

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