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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
type=recent orders the caller's albums by their latest play (skips
included); type=frequent orders them by completed plays (skips
excluded). Plays of tracks whose file is gone do not count, and both
page by size/offset like the other types.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An admin asking for another username got their own name and roles back.
The handler now looks the user up by exact username and answers with
that user's payload; an unknown username is ErrDataNotFound. A
non-admin asking about someone else is still ErrNotAuthorized.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The marker list behind the earlier server and web passes did not catch
"for v1", "Task 5", "Phase 2" or "today: Flutter". Those comments now say
what the code does.
Corrected: coverart said "both v1 providers" ship on. There are four
providers (MBCAA, TheAudioDB, Deezer, Last.fm), and Last.fm, which needs a
key, ships off.
Non-comment tokens unchanged against HEAD: 10 Go files, 7 TS/Svelte files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.dockerignore, .gitignore, deploy/initdb, ci-requirements.md,
client/README.md, shared/README.md and two web/scripts test names: dates,
issue/rule/spike ids and the stories behind each exclusion removed.
Corrected:
- client/README.md: the dev lane bundles its own APK; :main no longer
exists; main bundles the recorded sidecar instead of re-deriving the
version name. Its smoke-test step wrote a bare "v0.2.0", which the
server rejects as a sidecar that is not JSON; it now writes the JSON
shape, which the README documents.
- ci-requirements.md: one workflow, not four; the android lane builds no
debug APK; main reads the recorded sidecar.
- .gitignore: CI fills client/ on every image build, not tag releases only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
release.yml, the Dockerfile and tools/gen-brand-assets.py keep every
constraint their comments explain (the tag map, the gate, immutable tags,
the shared-daemon Postgres guard, the version/channel split) and drop the
history around them: rule and family-idea numbers, issue and run ids,
dates, an operator quote, the "this used to say the opposite" paragraphs
and one-runner timings.
Corrected: the Dockerfile said client/ is filled on tag pushes only; every
build fills it (dev and tag push bundle their own APK, main bundles the
latest release's).
Only full-line `#` comments changed: non-comment lines are identical to
HEAD in all three files, and the three run: scripts whose text changed
differ only in shell comment lines. The release guard tests match raw
workflow text; none of their required or forbidden strings appear in the
new comments.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every flagged comment block in the Android app, its tests and its XML/TOML
config, rewritten with the cited issue in view: the constraint is kept, the
story behind it dropped, and issue, milestone, rule, phase and drift ids,
dates and operator quotes removed. "Mirrors the Flutter client's X"
pointers are gone; that client is no longer in the repo.
Comments that no longer matched the code, corrected:
- SyncController: a 410 resets the cursor and re-pulls; it never dropped
the three local caches.
- PlayerFactory, PlayerModule, AudioCacheIndexEntity, CacheConfig,
CacheSource: there is no eviction worker and no liked-track bucket;
SimpleCache evicts by plain LRU over the rolling cap, and the liked cap
is stored but unenforced.
- CacheSettings, StorageCard: the prefetcher exists (AudioPrefetcher);
pin-on-like still does not.
- LibraryApi, LibraryRepository, BrowseViewModel: the cache now carries
the missing-file mark; the browse indexes stay server-backed for one
source of grouping.
- OutputRoute: UPnP routes exist; only CAST and SONOS are reserved.
- PlayerFactory, RemotePlayerState, OutputPickerController: a failed
transport command does not drop the UPnP route.
- LibraryScreen: History, Liked and Hidden are real tabs, not placeholders.
- AVTransportClient: the extend loop lives in SonosQueueLoader.
- "the operator" meaning the listener now says "the user".
Verified with a Kotlin lexer comparing every file's non-comment tokens
against HEAD: 394 files, 0 differences.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A wider pass for "previous", "before the fix", "we shipped" and the
like. The chi double-subtree, uppercase Vorbis key and stopPropagation
comments now describe the trap they guard; commit hashes, CI run
numbers and a deleted file name are gone; uuidString's comment says
what it returns.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rewrites the web app's comments to rule 32. Task, milestone, rule and
spec ids are gone; regression stories are restated as the trap they
guard (public auth routes, the menu's stopPropagation, universal loads
under adapter-static, metric deltas with their margin). Flutter and
Tauri mentions now name the Android app or a native shell, operator
quotes and dates are dropped, and one library's counts no longer
appear outside test fixtures. Verified with a TypeScript-scanner token
comparison (template literals and regex literals re-scanned): only
comment text changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Applies rule 32's migration exception: each header still says what the
schema was, what changes and what it prevents, but task, milestone,
decision and rule ids are replaced by the practice they stood for, the
operator quotes by the decision, single-install counts are dropped, and
pointers to a deleted package and an external design doc are gone.
golang-migrate keeps no checksum, so editing applied migrations'
comments is safe.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rewrites the internal/db/queries comments to rule 32 and regenerates
dbq, whose doc comments and embedded SQL carry them. The bugs behind the
tiered mixes, the md5 order seed, the score bands and the round-robin
arms are restated as the constraint each one enforces; counts from one
library and stale cross-references are gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rewrites the internal/library comment blocks to rule 32. Task, milestone,
decision and rule ids are gone; the incidents behind them are restated as
the constraint they left (why a file is deleted before its row, why the
missing mark is a mark, why duplicates block on duration). Counts and
album names from one library are dropped from production comments; test
comments keep the names their fixtures use. The tagReadVersion log keeps
each version's before and after, without the measurements.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The same rewrite as d191a282, for four packages whose comments carried the
most history: tuning stories, quotes from the operator, single-install
measurements in library_scale.go, and 'used to' accounts of bugs. The
constraint each story protected stays as a present-tense reason (why tier 3
is reduced not removed, why the randomised arms take an order seed, why the
seed artist is eligible in Songs-like). Comments naming system_cron.go and
config.RecommendationConfig, neither of which holds what they claimed, now
name the scheduler and internal/recsettings.
Checked: non-comment tokens identical to HEAD; gofmt clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.
Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
today rather than a milestone that would change it.
Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README: logo header, short summary, a compose quick start with one-line
comments, first-run steps, a configuration table, setup pitfalls,
updating and backups, then features grouped by what you do. The Specs
section (it called the Android client the "Flutter companion app"), the
dated tag-history sentence and the Fable pointer are gone. Feature lines
were checked against the code: radio seeds from a track only, and the
Android app plays from its cache rather than offering downloads.
.env.example used the SMARTMUSIC_ prefix, which the config loader has not
read for a long time, so copying it set nothing. It now uses MINSTREL_.
config.example.yaml is also the config the image bakes in at
/etc/smartmusic/config.yaml. It listed five recommendation weights
(base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude)
that moved to Admin > Tuning; yaml.Unmarshal ignored them silently. They
are removed, and the file parses to exactly Default(), so the running
server is unchanged. coverart_from_mbcaa is not listed: nothing reads it.
docker-compose.yml, the Makefile and the spec banners lose their task and
milestone ids.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Built as a property initializer it ran at class construction, before
MainDispatcherExtension's beforeEach, so viewModelScope.launch in init hit
the missing Main dispatcher. Its uncaught exceptions also failed the next
coroutine test in the JVM (RetryTransientIoTest).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web panel and the Android Notifications screen now mark every unread
notice they show as read the moment they appear, so the badge clears on a
look instead of a dismissal. Rows that were unread keep looking new until
the inbox closes. The read goes through read-all with a cutoff of the newest
notice shown plus 1 ms, so one that arrived unseen stays unread. 'Mark all
read' is gone; opening the inbox does its job.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
http.Server.Shutdown waits for in-flight requests but never cancels them,
and an SSE stream only ends when its client leaves, so any open tab or
signed-in phone held every redeploy for the full 10s and the old task
exited 1. With the swarm's stop-first update that was ~10s of downtime per
deploy. The bus now closes on shutdown, ending each stream; clients
reconnect to the next instance.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The admin requests list now sends each requester's username (#6265), so
the Android queue reads it off the row instead of fetching the whole
admin users list alongside and mapping ids to names. The short id stays
as the fallback for a row that comes back without a name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
seedUser stores users as test-<name>, so the assertion compares against
alice.Username rather than the literal "alice".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/admin/requests showed "by user f7e1ec86" — the first eight characters of
the requester's id, a placeholder from the first build. The admin list now
sends each requester's username, looked up for the whole page in one query,
and the row reads "by <username>". The id stays as the fallback for a list
that comes back without names.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The duplicates review offered only Merge for a wrong-slot group. A merge keeps
one copy's title whether or not it is the audio's, and neither sets the release
aside nor wants the missing song back.
For class mismatch the page now asks which song the recording is. "Get it
again" (the operator can't tell) removes every copy; "Keep <title>" keeps that
copy and removes the ones under other titles. Either way it runs the resolver's
own path: the release the files came from is set aside, the removed copies'
tracks stay marked missing and emptied, and the album is wanted again.
- library.SettleWrongSlot, POST /api/admin/library/duplicates/{id}/refetch
- ListDuplicateGroupsForResolve takes an optional group_id
- removeWrongSlots takes the acting operator (manual: not automatic, actor in
the audit row) and removes files through removeTrackFileOnDisk, so a
read-only library answers library_not_writable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured on the operator's library (26,005 tracks, pool scale 2.28): the
arms the library scale grows all have far more rows than it asks for, and
two arms collapsed whatever their limits were.
- taste_overlap ordered by weight then track id, so the strongest taste
artist's catalogue filled it: 20 tracks of saib. (197 tracks) out of 174
taste artists, the same lowest-id tracks every time. It now takes one
track per taste artist, strongest first, md5-seeded, as similar_artists
does since #5297.
- tag_overlap broke ties in no order. A one-tag seed ties 7,804 tracks,
and the LIMIT took whichever Postgres reached first, which the order
seed did not control. Ties now follow md5(id || seed).
- library_scale.go records the per-arm measurement beside the reference
size it leaves as is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Black Keys' Ohio Players was grabbed 40 times in a day. FabledExchange
already held the torrent under another category from before Minstrel took
the downloads over: the add answered "duplicate", which counted as added,
and Exchange's listing, filtered to Minstrel's category, never showed it.
Half an hour later the grab ended gone, and the next pass took it again.
- A duplicate add that the client does not list for Minstrel is not a
grab: the release is held back for a day with the client's own reason
(already downloading X (category)), and the search moves on. Forced,
it is refused with that reason.
- A release that ends gone a second time within a week is held back for
a day, whatever the cause.
- release_blocks gains the reason 'gone' (migration 0098), shown as
"Held back: its download client lost track of it".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The image's fpcalc 1.5.1 exits 3 with "Error decoding audio frame (End
of file)" whenever it reaches the end of a file before -length, at any
length, so every track under two minutes failed both the AcoustID lookup
print and the duplicate-detection print: 471 lookups and all 1,979
present short tracks' chromaprints.
- runFingerprintTool returns the tool's stdout beside a toolExitError.
- fpcalcReadToEnd keeps a partial exit whose stderr is the end of file
and whose declared DURATION is within the span asked for. A damaged
file that stops early declares more than it read, and still fails.
- Migration 0097 clears the stored failures so the lookup worker and
the fingerprint backfill take those tracks again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LB's similarity scores are counts (16 to 1445 for recordings, to 10,875
for artists), and they reached a scorer that reads similarity as 0 to 1.
One edge added 64 to 5,800 points under Songs-like, so likes, skips and
recency stopped mattering for any candidate with an edge.
- ResolveListenBrainzTrackEdges divides each edge by the seed's best
score in its whole cached answer. The cache keeps the raw answer.
- The worker stores matched and unmatched artist rows over the seed's
top artist.
- Migration 0096 rescales the rows already stored.
- The candidate arms hold sim_score bands so tier 1 stays above tier 2:
lb_similar 0.5 + 0.5 x edge, tag_overlap halved, likes_overlap 0.3.
Otherwise a seed's weaker LB edges fell below a shared genre tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
advanceUntilIdle does not count backgroundScope work as pending, so the
pass init would start never ran. The network state is Offline so
construction starts none, and each test calls syncSafe itself and asserts
lastSyncError stays null.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server returns at most 1000 changes per /api/library/sync call and
expects clients to loop with the returned cursor. SyncController took one
page per trigger (cold start, reconnect), so the cache fell behind a log
that a full re-read grows by ~26,000 rows and, past the 30-day compaction,
reset through 410 to cursor 0 without ever reaching the merge deletes of
2026-10-09/10. The freshness sweep then re-asked for those tracks every
30 minutes: the same ~100 404s on every pass.
- SyncController pages until 204, storing the cursor after each page, and
stops on a cursor that does not advance.
- MetadataProvider drops a cached album, artist or track the server
answers 404 for, so rows orphaned before this fix stop being refetched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under e45561e2 a file with no group tag took the oldest same-named album row,
which left three of Weezer's 1994 bonus tracks on the 2016 album. The lookup
now ranks same-named rows by how many tracks they hold from the file's
folder; tagReadVersion 8 re-places the existing ones.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scanner found an album by artist and title alone, so Weezer's six
self-titled albums (1994–2026) were one 118-track row and their merges,
wants and upgrades counted them as one. A same-named row whose release group
is known and differs from the file's is now another album; tagReadVersion 7
re-reads the library so existing tracks move to their own rows.
WantHoldings compared discs with IS NOT DISTINCT FROM, so a missing track 6
with no disc was never covered by a present disc 1 track 6. Air's Love 2
stayed wanted and was downloaded again for nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first album-copy fold pass on 2026-10-20 removed five FLACs (Måneskin
Rush!, Weezer 2016) in favour of mp3s whose only edge was a recording MBID,
and an explicit "Suite-Pee" for its clean version. ProposeSurvivor now ranks
lossless before tag fit; tag fit still outranks file size, which is what the
Humanz rips needed. The fold keys copies by their version words (title and
file name), so a clean, live or instrumental copy holding the same title tag
is linked, not folded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On 2026-10-20 the emptied "Open the Gateway" slot of Auvic's Speed Demons
was folded by title into another file tagged "Open the Gateway" that holds
Onward (244 s against the song's 254 s), so the album was never wanted
back. A wrong-slot removal now stamps tracks.emptied_at (migration 0095)
when the album holds no copy of the song as long as its MusicBrainz slot,
and missing-pair folding pairs such a row by title only with a track added
since: a copy fetched again. A slot whose song the album does hold (VOID
DLC's Weeper beside Void's) folds into it as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A cross-release group was only ever linked, never merged, so an album that
held the song twice among the releases kept both: Humanz has Saturnz Barz,
Andromeda, We Got the Power and Garage Palace as its FLAC and a YouTube mp3,
each beside the single. The resolver now merges an album's extra copies
into its best (the one that is not a video rip, else ProposeSurvivor:
lossless first) with ReplaceTrack, history and all, then links what is
left as one song.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The importer wrote track=N/M for every format, which ffmpeg stores in a
Vorbis comment as TRACKNUMBER=3/20; dhowden/tag reads a Vorbis TRACKNUMBER
with a bare Atoi, so it came back 0. All 245 tracks imported since
2026-10-10 (Humanz, Plastic Beach, the Gorillaz super deluxe and twelve
more albums) have no track or disc number, and their album pages have no
order.
- The importer writes a Vorbis track and disc number alone, with
TRACKTOTAL and DISCTOTAL, as Picard does. ID3 and MP4 keep N/M.
- ReadTrackAndDisc takes the number before a slash when the Vorbis reader
returns 0, for the scanner and for the import's own check of a download.
- tagReadVersion 6 re-reads the files already held (tag reads only).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At boot the scan lists the library, the resolver removes wrong-slot copies
and marks their tracks missing, then the scan reconciles from its list and
marks them present: 7 of the 13 removed on 2026-10-20 15:00 read as
present with no file, and would not be wanted back until a later scan. A
pass now waits while a scan younger than StuckScanThreshold is in flight
and tries again a minute later; a stuck run does not hold it up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first pass on the operator's library held 16 groups. In several, every
copy's own lookup names one recording (Wombats Record Shop/Tormentor,
Alchemist (2)/Prelude, Monday Chill 8/9, 404sessions), but one copy sits 3-10s
from its slot, so the every-copy-fits precondition kept AcoustID out. Now:
copies whose lookups all agree are that recording, whatever their
fingerprints read; copies of the same audio are asked about as before; and
the track list places the recording in one copy's slot, or none. Copies
filed as one song are not settled here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Savant's Mortals held one recording as 16 of its 50 tracks, each trimmed to
its own slot's MusicBrainz length, so the lengths could not say which slot
was its own and every group waited for the operator. When a one-album
mismatch is the same audio (identical, or fingerprints within 0.02) and
every copy fits its own slot, the resolver asks what the recording is: the
copies' own AcoustID lookups, or a fresh lookup of one copy (five a pass).
The track list names each slot's recording; the copy in that slot is kept
and the others removed, or every copy when the recording is in none of
their slots or AcoustID knows none. AcoustID unreachable or answers in
several copies' slots: the group waits.
Whenever wrong-slot copies are removed, the download the files were
imported from is set aside, so the album fetched again is not the same
files. Albums imported before Minstrel downloaded anything have none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each group now states its evidence: identical audio data, or the share of
audio fingerprints that agree. Copies titled as different songs in one
acoustic group say so and ask the reviewer to listen, and each copy has a
play button so they can.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release id was only ever used to find the group. 238 albums matched to a
group carried none, so their losses, five of the resolver's wrong-slot
removals among them, were never fetched again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On 2026-10-20 twelve of the 24 slots the resolver emptied were folded by MBID
into the kept copy, so their albums were never wanted back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shnabubula's whole album, one 45-minute file, was filed as both Fading Light
and Looking for Answers. Neither slot fits, so wrongSlots had nothing to keep
and held the group. Every copy now goes when each is clearly another song's;
a copy near its slot still holds the group.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 of the 34 groups left for review had no cached track list for their album:
the catalogue only reads one when the wanted list or the importer needs it.
The resolver now asks catalogue.RefreshReleaseGroup for an album it must
decide and the catalogue has never read, through the shared MusicBrainz pacer,
at most five a pass (hourly). An unreachable MusicBrainz stops the pass's
fetching; a group read and found empty is not asked about again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- songKey drops a dashed version suffix (" - Instrumental Version", " - Radio
Edit", live, remix, mix, acoustic, demo, reprise, rework, VIP), so Andora's
"Goodbye Yesterday - Instrumental Version" is distinct, not a wrong slot.
- wrongSlots accepts several fitting copies when they share one title
(Killjoy twice beside Dreamland): the other slot's copy is removed, and the
doubled song is left for the next sweep to find as a pair the resolver merges.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The operator's library had 536 groups waiting for review, 2026-10-19. A sample
showed most were one recording under two titles on two releases.
- The headline and the Duplicates count leave out cross-release groups, which
the resolver keeps and links: the headline read 3,479 when 536 needed review.
- Any group spanning albums is cross_release and is linked as one song,
whatever its titles say. Nothing is deleted.
- One album, two versions named as such ("Lovesick (Instrumental)") and not
byte-identical: a new class, distinct, dismissed by the resolver.
- One album, one recording under different song titles (mismatch): the
album's cached MusicBrainz track list decides. When exactly one slot's song
is as long as the file and every other slot's song is clearly not, the other
copies are removed and their tracks marked missing, so the reacquisition
sweeper wants the album back. Otherwise the group stays for review.
- The duplicates_found notice moves from the sweep to the resolver: it names
only groups a pass first left for review.
- Resolved automatically lists dismissals and wrong-slot removals beside
merges.
Migration 0094 adds 'distinct' to the class CHECK (rule 36).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- An update to a grouped notice keeps its arrival time and email state, so the
phone and the email no longer go off again each time it grows.
- Notices carry the names of what changed (newest ten), shown as three and
"and N more": tracks still missing after the scan's move/replacement fold,
duplicate groups new to a sweep, songs merged automatically, and the track
and listener a playback error was for.
- Duplicates found counts only the groups new to that sweep; playback errors
count reports, not the unresolved total.
- A scan failure says what stopped and links to Admin > Library.
- Android: a pile of more than three alerts lists the newest five titles.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Migration 0091 drops lidarr_config and renames the quarantine tables, types,
indexes and the history column off the Lidarr name. The Integrations Lidarr
card, the Reports "Delete via Lidarr" action, the merge and remove-track
unmonitor options, the Lidarr audit actions and error codes go, on the
server, web and Android. The README describes acquisition as it is now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- acquisition_settings.upgrade_until (default High quality lossy, Lidarr's
cutoff, note #5527; "" is off), an "Upgrade until" row under Searching.
- The wanted worker judges held albums hourly: tier per format from size per
second (the scanner records no bitrate), the album's worst. Below the
cutoff it is wanted for quality; a video rip among its tracks always.
- The schedule searches upgrades weekly, after open wants; a release no
better than the copy held is refused ("not_better").
- The import matches each file to the track it would replace (recording,
then disc/track) and replaces it when it is a rip or the file is a better
tier: over the same path in one rename, or filed beside it and the old
track folded in (library.ReplaceTrack: file first, then foldTrackInto).
Nothing better: the release is set aside.
- Collection › Upgrades (Lidarr's Cutoff Unmet); Suspect sources says when a
clean copy was looked for instead of Lidarr's search.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Users: a one-line count of accounts, admins and open invites; Turn on/off debug mode in each row's actions, and a "debug on" badge that opens that account's events
- Diagnostics: no title or paragraph of its own; devices named by account, OS, app version and last seen, not client id; opens on ?account=
- Tuning: each profile, the taste build and Discover are row groups with one-line hints; the sparkline has a 0–100% axis
- Requests, Duplicates, Missing files and Suspect sources: load errors offer Try again
- Library: cover art reads "N of M albums have art"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Indexers and clients, searching, missing files, Lidarr's neighbours, AcoustID, fingerprinting, loudness, outgoing email's schedule, public address and proxies move from paragraph cards to SettingsGroup and SettingRow. Switches are role="switch". Also fixes the two type errors run 9023 found: the Lidarr test result is narrowed before its lists are read, and the tag-source save mock carries version_bumped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Library: the folders, Run scan and a row per scan job, cover art, fingerprints and loudness (off the Overview and Duplicates)
- Acquisition: indexers and clients, searching, re-acquisition (off Missing files), Lidarr
- Metadata sources: AcoustID, and one provider block for cover art and tags in place of the pasted pair
- Email & network: outgoing email, the notification schedule, public address, proxies
- Lidarr, outgoing email and the provider block are rebuilt as SettingsGroup rows; failures read as sentences, not codes
- Duplicates and Missing files say what their setting is and link to where it is set
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- /admin/reports folds the quarantine queue and the unresolved playback
errors into one row per track, newest first: who flagged it and why,
what failed and for whom, the file, with Resolve (clears the reports and
marks the errors fixed), Play, Copy details, Delete file and, for an album
Lidarr holds, Delete via Lidarr.
- One delete path: Delete file removes the track, and its reports and
errors go with the row.
- A Handled tab shows what was done: reports cleared, files deleted,
errors marked fixed or ignored.
- The summary counts reports by track across both sources, so the rail,
the Overview and the page agree.
- /admin/quarantine and /admin/playback-errors redirect to Reports: the
notifications and the Android app link to them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The content was capped at max-w-3xl, so a wide window showed one narrow
strip and a lot of empty space. SettingsColumns lays a page's groups out as
one column on a phone or narrow window and two side by side from xl up;
the page cap moves to 100rem so the columns don't stretch on a very wide
screen.
- Account: Android app, Profile and Password, with Signed-in devices beside.
- Playback: "Between tracks" (crossfade) beside "Volume leveling".
- Connections: ListenBrainz beside Subsonic apps.
- Appearance, Notifications, About: one column's width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- GET /api/admin/summary: every admin queue (requests, reported tracks,
playback errors, duplicates, suspect sources, missing files) with its
count and the age of its oldest item, in one query.
- The admin layout drops the 11-tab strip for SectionRail, as Settings and
the Collection have, with each queue's count as its badge; the counts
refresh on each admin navigation and every 30s.
- The Overview leads with "Nothing needs you." or "N things need you.",
lists each waiting queue with "oldest 3d ago" and a link to it, counts a
failed library scan as a problem, and names the empty queues on one line.
The request and quarantine previews and the stat tiles are gone; those
queues are worked on their own pages. A failed summary says so with Try
again instead of reading as all-clear.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Playback errors: Delete file stops resolving the error row the track
deletion already took with it (ON DELETE CASCADE), which 404'd into
"Delete failed".
- Overview: a queue that fails to load says so with Try again, and its tile
shows a dash, not 0. Delete via Lidarr shows only for an album Lidarr
holds while Lidarr is connected. An approve refreshes the Collection.
- Quarantine: Delete via Lidarr is in the row menu, so a phone reaches it;
"1 report", not "1 reports".
- Duplicates: the Keep badge follows the copy picked.
- Integrations: provider save failures and load errors are said, not
swallowed or shown as "No providers registered"; the Lidarr test catches
a failed call; SMTP fills once and Save stays off until it has loaded.
Lidarr's blurb says what it is still used for.
- Requests: release profiles point at Collection › Following.
- Library: an empty library sends an admin to Admin to run a scan, and
tells anyone else the admin scans it, instead of opening Settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- /settings lands on Account: the Android app, profile, password, signed-in
devices. Then Playback, Appearance, Notifications, Connections, About.
- Each setting is a row (name, one short line, control at the end) in a
capped-width column; one-of-N choices are a shared Segmented control.
- Collection and Settings share one SectionRail.
- Connections leads ListenBrainz with a status line: not connected,
connected and not sending, or sending with the last scrobble's age.
- Notifications prints its column labels once; the digest email's
settings link points at /settings/notifications.
- #5596: the profile form now loads what is stored and sends only the
fields that changed. It used to start blank, and the server reads a
blank as "clear", so one Save wiped the display name and email.
- Recommendation metrics moves to Library › Your listening, and Hidden
tracks gets a Library tab.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web colour helper now picks Palette's dominant swatch (5-bit
quantise, Palette's default filter, median cut to 16, the most-populated
box) instead of the 1x1 mean, so the web and Android agree; PlayerBar's
accent strip shares it. Now Playing draws Android's gradient (55% at the
top, 18% at 45%, then the page) as a masked background colour that fades
over 600ms between tracks, holding the old colour until the new resolves.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Like Lidarr's Unmapped Files. A local band's album, which MusicBrainz will
never have, is left off: never matched again and not counted. A re-tagged
album is matched again on the next listing. The Collection's verdict now
says when library albums need matching.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The section opens on a catalogue of every artist on the list, A–Z with a
letter rail, each card showing how many of their albums are held. An artist
opens on their page: every album of theirs, held or not, with its status,
search, releases and tracks. Wanted, Missing files, Activity, Following and
Blocked sit down a left rail as the sections that show state; Held is gone,
the catalogue shows it. The section moves from /wanted to /collection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The list runs like the library: artists by the library's sort name, each
artist's albums oldest first, grouped under the artist (linked into the
library when it holds them). A filter narrows it to an artist or album by
name, on the server, so it finds what has not been scrolled to. Tracks
opens an album's track list: the library's own for an album it holds, a
lost file marked, otherwise MusicBrainz's standard edition, named.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MusicBrainz types My Chemical Romance's "Common People (Recorded For BBC
Radio 1)" as a plain Single, so a studio-only follow wanted it. Passes now
reads the title's qualifier (brackets, or after a dash) for the version it
names and treats it as a secondary type; a version no type names, an
acoustic or instrumental one, no profile passes. The follow check takes off
the list an open follow want the rule now refuses. Judge counts bbc,
sessions and unplugged as live.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Savant's "Void" folder holds all 23 tracks of the VOID (DLC) edition plus
20 more, and was refused at 23 of 43. A whole edition of three or more
tracks now matches whatever else the folder holds; singles and two-track
releases still need most of the album's tracks, since many albums hold
their songs. Matcher version 4 retries the refusals.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The second run's refusals were mostly albums holding a track twice (a
two-track single with three files), which no edition can pair, and
release names that differ only in "+" against "/". Tracks are now read
once per disc, number and title, and "and", "&" and "+" count for nothing
when titles are compared; an edition's own title also counts as the
album's name.
library_album_matches records which matcher version decided each album.
Refusals by an older version are matched again, even after the listing is
done, so an improvement reaches them without a migration.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first run on the library refused singles whose one track has the
album's title ("0 of 1 agree") and near-complete albums (11 of 14), because
every track's length had to fit as well. Lengths now only decide between
groups whose names merely overlap. The reason for a refusal names the first
track that differed, with both titles and lengths, so what is left can be
judged. Migration 0087 clears the refusals so those albums are matched
again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one-time listing took only albums whose tags already named their
release group and artist, and left 340 off the list. The rest are now
matched: the release their tags name, else the artist's release groups
called what the album is called, else a MusicBrainz search by artist and
title. A group found by name is taken only when its tracks agree with the
album's (by disc and number, then title; length and title, or recording),
so a partial copy still matches and a same-titled other album does not.
library_album_matches records each album the listing decided, listed or
unmatched with why, so an album taken off the list is never re-listed. The
albums no match was found for are named on the Held tab. Album rows with
no tracks are no longer counted.
The length and title checks move from the importer to release, shared by
both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A one-time job in the wanted worker lists every album in the library as a
held want, one per release group, with a record that it happened
(acquisition_settings.library_listed_at). Albums whose tags name only a
release are named on MusicBrainz first, 120 a pass; what nothing names is
counted and shown on the Held tab.
Re-acquisition now wants back only albums on the list. Taking a held album
off the list leaves its files, and it is never fetched back; albums put in
the library folder by hand afterwards are not added.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ported from Fabled Librarian's System 104. A new lifecycle poller, every
ten minutes:
- a download the client fails is given up at once;
- one nobody shares, or still fetching its file list, past the stall
setting (6 h) is asked for again three times, then given up;
- giving up removes the download with its files, holds the release back
for a week (release_blocks.expires_at), ends the grab and searches the
album again, with no limit per album;
- a running download the client stops listing for half an hour ends and is
searched again, not held back; an empty list is not taken as gone;
- an imported download seeds to ratio 2.0 or 14 days, then is removed;
- a download the client would not remove is asked again every pass.
Every condition and act is one open row per (client, download, condition)
in download_interventions. Activity shows what is wrong with a download and
how often it was retried, why one ended, its seeding, and a banner for a
client that does not answer. A running download can be given up by hand
(set aside for good). The seed ratio, seed days and stall hours are on the
search settings card. The download client lookup moves to
acquire.DownloadClientFor, shared by the importer and the lifecycle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A finished download is checked against MusicBrainz before anything is
filed: the files are paired with an edition's tracks by position or path,
and AcoustID, tag ids, lengths and titles say whether it is the album.
What is the album is copied into the library as
Artist/Album (Year)/Artist - Album - NN - Title, tagged with Picard's
MusicBrainz ids, and scanned; the download keeps seeding. What is not is
refused: set aside, removed from the client, and the album searched for
again. A download the check cannot settle waits for a person, who imports
it anyway or refuses it. Tracks the library already holds are skipped.
The wanted list leaves Admin for a top-level Wanted view, admin-only and
web-only, with tabs for Wanted, Activity (downloads and their imports,
the ones needing a person first), Missing files, Held, Following (with the
release-type profiles, moved off Integrations) and Blocked. The search
settings gain "File new music into" when the library has several folders.
The search pass logs why each failed search failed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- internal/release: reads a release name (format, bitrate, bit depth and
sample rate, source, year, video, discography) and judges it against the
album wanted: the artist and title must be in it; video rips,
discographies, other versions (drumless, instrumental, karaoke, remix,
live unless the album is live…) and singles or EPs for an album are
refused. Quality tiers on Lidarr's ladder; a .torrent's file list is read
for format, video files and a track count short of the album.
- internal/acquire: fan-out across every search source with a narrowed
search kept, a process-wide pacer per source, every candidate judged and
kept with its reasons, and the grab: one live grab per want, tie-broken
(a person's grab beats the schedule's; a better release replaces a worse
one unless it is nearly done). Each download lands in its own folder
under the client's downloads folder.
- internal/wanted: the search schedule, every ten minutes, never-searched
first, then requests, missing files and followed artists, taking the
best release when auto-grab is on (the default).
- Migration 0082: acquisition_settings (auto-grab, interval, allowed
qualities seeded from Lidarr's "Any"), want search columns,
want_candidates, grabs, release_blocks.
- Web: the Wanted page shows each want's last search, a Search button and
every release found with its verdict (Grab, Grab anyway, Never this one,
Allow again). Integrations gains "Searching and grabbing".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Minstrel keeps its own list of what it is to get, keyed by MusicBrainz
release group. A want comes from an approved request, a new release by a
followed artist, or an album whose files went missing, and is held once the
library has a full edition of it.
- Migration 0081: release_type_profiles (Standard seeded: Album, EP,
Single, studio only), follows, follow_seen_groups and wants.
lidarr_requests becomes requests with plain *_mbid columns; approved
album and track requests move onto the wanted list, approved artist
requests become follows, and the library's album artists start out
followed. missing_reacquisitions and the retry settings go.
- internal/wanted: follows are checked against MusicBrainz discographies
daily. The first check sets a baseline, and only releases after it are
wanted unless the follow asked for what already exists. Profiles filter
by primary and secondary type, so live recordings stay out unless a
profile ticks Live.
- internal/requests (was lidarrrequests): approving puts the request on
the wanted list, and an artist request can be approved on a chosen
profile. A request completes when its want is held.
- The re-acquisition sweeper wants a missing album back instead of asking
Lidarr; searching for it is the search schedule's (#5521).
- Web: an admin Wanted page (Wanted, Missing files, Held, Following), a
follow control on artist pages, a release-type profiles card on
Integrations, "Approve on a profile" on Requests, and want state on
Missing files. The artist detail API returns the artist's MBID.
- Android: the request body sends artist_mbid, album_mbid and track_mbid.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
svelte-check read `$state(false)` as a subscription to the `state` prop.
The prop keeps its name for callers and is bound as cardState inside.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Minstrel keeps its own record of what artists have released, read from
MusicBrainz, so the wanted list, search and import no longer need Lidarr.
- internal/musicbrainz: searches (artists, release groups, recordings) and
browses (an artist's release groups; a group's releases with track lists).
One process-wide pacer at 1.1s, shared with tag enrichment, because
MusicBrainz's limit is per address. A 503 throttle is retried.
- Migration 0080: mb_artists, mb_release_groups, mb_artist_release_groups
(a collaboration belongs to each credited artist), mb_releases,
mb_release_tracks.
- internal/catalogue: Discography and Releases serve the held copy and
re-read it when older than 7 or 30 days. An unreachable MusicBrainz
serves the stale copy, marked. An hourly pass re-reads due artists.
- Art for what isn't held comes through /api/catalogue/art from the
enabled cover-art sources (Cover Art Archive by release group, then the
name-based sources) and is kept on disk, so the browser never fetches a
third-party host. A miss is a 404 and the card shows its icon.
- Request search moves from /api/lidarr/search to /api/catalogue/search,
with the same response shape. Albums are release groups, and "in
library" matches albums.release_group_mbid. A 10-minute cache absorbs
search-as-you-type. Discover suggestions point at the art endpoint
instead of looking up Lidarr.
- Android calls the new path and loads Discover art through ServerImage.
Installed builds older than this one lose Discover search until they
update (no compatibility path, rule 22).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Admin → Integrations gains "Indexers and download clients": two lists of
connections the operator adds, Fabled Librarian's model. Nothing is seeded
or copied from Lidarr.
- Indexers: Prowlarr (its native /api/v1/search, which asks every indexer
at once; Prowlarr has no aggregate Torznab feed) and any Torznab feed.
- Download clients: qBittorrent (WebUI v2, with re-login on an expired
session) and FabledExchange, one entry with a switch per job:
downloads (/api/downloads), search (/api/search) and release details
(/api/torrents/{infohash}). All three start on; its key is optional.
- Each entry: enable, name, address, a write-only key or password, Test.
A download client's Test lists its torrents and checks the downloads
folder is visible and writable from Minstrel; an Exchange's Test
reports each job switched on.
- Download clients carry a path mapping (Minstrel's /downloads vs the
client's own name for it); search sources carry searches per hour.
Migration 0079 adds acquisition_services with its CHECKs. Ported from
Fabled Librarian: internal/indexer (Torznab, 429 Retry-After, magnet
redirects), internal/prowlarr, internal/exchange, internal/downloader,
and internal/acquire's one builder per kind, which Test already uses
so a passing Test vouches for the path a grab will take.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lidarr is off, and with it the resolver had stopped merging: it only
merged when Lidarr had said which copies it mapped. Minstrel is now the
only program writing the library, so the rule is about the copies:
- A song held twice on one album: the extra copies merge into the one
whose tags fit best (then lossless, larger, older).
- Across releases or under another title: when every copy but one is a
video rip (held back, or named like one and not marked fine), the rips
merge into the one that is not.
- Clean copies on different releases are linked as one song, as before.
- Anything else waits for the operator.
Gone with Lidarr: release switches and their settle window, the
unmapped-file read, the rip search, the merge guard and its 409/503,
lidarr.Client's release methods, and duplicate_group_members.lidarr_state
(migration 0078). The duplicates page drops the per-copy Lidarr badge
and the blocked-merge notice; past release changes stay in its history.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MergeInheritSongLink took "linked" to mean song_key <> id, but the copy
whose id became the song's key has no song_id of its own. Removing that
copy dropped the survivor out of the song. It now asks whether another
copy shares the key. The test covers both copies as the one removed, so
it no longer passes or fails on which random id sorts first.
The release-change test counted audit rows other tests left behind
(ResetDB keeps audit_log); it now counts what its own pass adds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the first deploy pass Lidarr took longer than the client's 30s timeout to
answer the release change for Cracker Island: its album update unlinks the
files and queues the rescan before it replies. The change may well have
landed, but the resolver logged a failure and wrote no audit row, so the
album had no settle window and the report did not show it.
An unanswered PUT (ErrUnreachable) is now read back: when the album's
monitored release is the chosen one, the change is recorded as made. Any
other error, or a read-back that disagrees, stands as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The operator chose option 2: an album holding a video rip that is the only
copy of its song is searched in Lidarr (AlbumSearch), at most once a week
and ten albums a pass. Lidarr grabs only when the quality profile allows an
upgrade, so nothing is removed and the rip stays held back either way.
- lidarr.Client.SearchAlbums posts the AlbumSearch command.
- ListAlbumsWithSoleCopyRips finds the albums: a held-back rip with no
present clean copy sharing its song or its pending group, and not searched
in the last week (audit action lidarr_rip_search).
- Suspect sources shows when Lidarr last searched the track's album.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The resolver also merges cross-release and mismatch groups where Lidarr
maps exactly one copy: the others fulfil nothing, so removing them opens no
hole (D-a rule 1). That covers a rip beside the clean copy on another
release and a wrong-file import Lidarr holds unmapped. With two or more
mapped copies each fulfils its own release and nothing is removed.
- A track whose file name carries a video-rip marker is held back from radio
and the system mixes (tracks.source_verdict, migration 0077). It still plays
when chosen. A renamed file is released; the operator's "fine" sticks.
- Suspect sources shows what was done to each track, with "This one is fine"
and "Hold back again" (PUT /api/admin/library/suspect-sources/{id}).
- The Liked list prefers a copy that is not held back.
Replacing a rip that has no clean copy is left for the operator to decide.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A single and the album it is on stay two files, since each fulfils its own
release in Lidarr, but they are one song to the listener.
- tracks.song_id links copies; the generated song_key (song_id, else the
track's own id) is what they share (migration 0076).
- The resolver links each cross-release group every pass (idempotent; only
with auto-resolve on) and, when a link is new, shares existing likes across
the song and logs them for sync.
- A like or unlike (web and Subsonic) reaches every copy; each change is
logged and published so clients update every heart.
- The Liked list and its count show the song once; Shuffle and the mix writer
take one copy per song.
- A merge keeps the removed copy's song link; "Not the same song" on the
Across releases tab dismisses the group and undoes the link.
Shared plays ("heard via another copy") are left for a later step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The duplicate sweep proposed 4,197 groups and every one waited for the
operator. Most are safe to settle, and Lidarr defines what safe means: it
maps one file to each track of the release it monitors and downloads any
mapped file that disappears. Deleting a mapped copy opens exactly the hole
the operator saw Lidarr fill.
Classify (#5435)
- Migration 0075: duplicate_groups.class (same_release, cross_release,
mismatch, review), resolve_note, resolved_automatically;
duplicate_group_members.lidarr_state (tracked, unmapped);
fingerprint_settings.auto_resolve; notification kind
duplicates_resolved with both kind CHECKs swapped (rule 36).
- library.ClassifyDuplicateGroup, with MatchTitleKey dropping featuring
credits, remaster notes and video-rip markers, and keeping live, demo,
remix and instrumental. The rip markers move from api to library.
Choose the copy to keep (#5436)
- ProposeSurvivor ranks the copy Lidarr maps first, then tag fit (a
clash-free track number, no rip marker in the name, an MBID), then the
quality rules. File size picked the wrong Humanz copy in 6 of 21 groups.
Act (#5437)
- An hourly resolver pass reads Lidarr's unmapped files, matched by the
last three path components, and records each copy's state.
- Same album, with at most one copy mapped: merged into the mapped copy.
The merge is guarded, so a mapped copy can never be removed
(MergeDuplicateGroupGuarded, ErrCopyTrackedByLidarr).
- Same album, every copy mapped: the monitored release lists the song
twice (Humanz's 14x12" box set). The pass moves Lidarr to the release
that lists each song once and best covers what is on disk. It never
picks one covering less, and is capped at 10 albums per pass.
- Fixed point (lesson #4183): the chosen release no longer repeats.
- The album is left alone for 24h while Lidarr rescans, so "every copy
unmapped" mid-rescan is never read as licence to merge.
- Both actions are audited with no actor and summarised to admins. The
operator can switch them off in the Fingerprinting card (rule 25).
- Manual merges use the same guard: 409 copy_tracked_by_lidarr, or 503
lidarr_unavailable when Lidarr cannot say.
Web
- Duplicates gets tabs: Needs review, Across releases, Resolved
automatically. Each loads as you scroll (rule 172), replacing the
pager.
- Each copy says whether Lidarr uses it.
- The resolver's note shows on each group.
- The merge confirm blocks, before sending, a merge that would remove
the copy Lidarr uses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On first boot the postgres image runs initdb under a temporary server that
listens on the unix socket only, then stops it. The socket readiness probe
could catch that server, and the fsync relax then hit 'the database system
is shutting down', leaving the suite ~15x slower on internal/api. Only the
real server listens on TCP, so probe and relax via -h 127.0.0.1, retry the
relax, and print the settings that took effect.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wrapper added a promise hop, so the self-heal test's single
microtask wait saw 'loading' instead of 'error'.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TrackRef gains the unavailable flag the server already sends on album
detail and search. TrackRow greys such a row with '· File missing' and
hides like, radio, add and the menu, as playlist rows already do. Every
player-store entry point (playQueue, enqueue, play next, and a refetched
mix) drops missing files; playQueue keeps the chosen track as the start.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
text-oxblood is not a theme token, so Tailwind emits nothing and the scan
error on Admin → Overview and "It stopped early" on Duplicates rendered as
body text. Same fix as the album page in #5392; no uses remain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preference 172: no "Load more" buttons. Eight remained, on Liked (three
sections), the three Search overflow pages, Genres and Years.
- ListContinuation: the one bottom-of-list block. It loads the next page
ahead of the reader, announces "Loading more…" through aria-live, shows an
optional end line, and on a failed page shows the error with Try again,
dropping the sentinel so a dead endpoint isn't re-hit on every scroll.
Used on all ten paged lists, including the four that already autoloaded.
- A failed next page no longer replaces the list. TanStack sets isError for
it, so the page-level error branches now apply only to a failed first
load. Genres and Years did the same with their own loader; a later
page's failure now keeps the grid.
- Liked: Artists | Albums | Tracks tabs (operator's choice). Stacked, a
long Artists list loading as you scroll would bury the other two. It
opens on the first tab that has likes.
- TabStrip: the in-page tab strip, now shared by Liked, Playback errors
and Requests.
- test-utils/intersectionObserver: a stand-in so tests can scroll to the end.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- staticcheck QF1001: the nil-vs-empty check in TestSourceMarkersFor is
now an early continue instead of a negated conjunction.
- The page test mocks #lib/api/admin.js whole, so suspectSourcesNextOffset
was undefined there. Its test moves to admin.suspect-sources.test.ts,
beside admin.scan.test.ts, against the real module.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Humanz turned out to be YouTube rips: "(Official Video)", "Visualizer", a
reaction video filed as a song, junk disc numbers (#5401). The library holds
about 200 more files named the same way. This report lists them, grouped by
folder like Missing files, with the markers each file name carries.
- GET /api/admin/library/suspect-sources: one marker list in Go builds both
the Postgres ~* filter and each row's labels, so they cannot drift.
Basename only; missing files are left out.
- Markers calibrated on the live library: "live in/at" dropped (real live
albums), "reaction" narrowed (it caught "Chain Reaction").
- Admin tab "Suspect sources", read-only, loads as you scroll; a folder split
across pages is joined back into one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- CollectionHeader holds cover, title, facts, then Play and Shuffle, for
album and playlist. The playlist page gains Play and Shuffle; it had
neither. The cover uses the shared Cover component, so the playlist
header also gets the fallback image.
- PlayActions is the one Play and Shuffle pair: filled Play, outlined
Shuffle, on album, playlist and artist pages (operator, 2026-10-08).
The artist page's round icon button said Play but shuffled; Play now
keeps the server's order, and Shuffle shuffles.
- PageColumn is the playlist page's centred column, now used by album,
playlist, Liked, History and Search → Tracks.
- The playlist page builds its queue with playlistTrackToRef, the shared
converter, in place of its own copy. Its copy skipped only removed
tracks, so a missing file (#2527) could still be queued from there.
- shuffled() in lib/utils is the one shuffle, replacing three private
copies (album page, artist page, ArtistCard).
- The album's refetch error now uses text-error-fg. `text-oxblood` is
not a colour in the theme, so it never rendered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HistoryRow now renders LikeButton, and both history test files imported
the component before the likes mock helper. LikeButton then loaded while
emptyLikesMock was still uninitialised, and the suites failed at import.
Every other suite imports components after its mocks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every track list (album, Liked, Search, Search → Tracks, playlist,
History) now draws its rows with TrackListRow inside a TrackList box,
in place of four row components that had drifted apart:
- TrackListRow owns the look: lead column, title over "artist · album",
like, radio, add to queue, menu, duration, then a trailing slot. It
also owns the stripe, hover, the now-playing band and the
unavailable state. The stripe is dropped on now-playing and selected
rows, where Tailwind would otherwise let `odd:` win.
- TrackRow, PlaylistTrackRow and HistoryRow are wrappers that keep only
their behaviour: track number and multi-select; grip, remove and
dead-entry states; cover and timestamp. Playlist and History rows
gain radio, add and the menu, so every list offers the same actions.
- The radio control is Lucide's Radio icon, not the 📻 emoji.
- Enter on a control inside a row now belongs to that control. The old
TrackRow played the track when Enter was pressed on "add to queue".
- TrackList has no fill, so the stripe shows on playlists too.
Drag reorder (#5393): neodrag kept the drop offset, so after the list
re-rendered the dragged row sat between two slots. reorderDrag, shared
by the playlist and queue rows:
- snaps the row to whole rows while dragging;
- resets it to its slot on drop;
- on playlists, gives the touch gesture to the grip only, so the list
still scrolls by touch.
The playlist page reorders optimistically and rolls back on error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rule after each shelf title used `after:bg-accent/60`. Tailwind 3
cannot put an opacity modifier on a `var()` colour, so it emitted nothing
for that class and the rule never showed. Tailwind 4 mixes the opacity
in with color-mix, so the rule appeared after the upgrade. Removed, so
the headers look as they did before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI run 8789 warned on both:
- kit 3 deprecates `config.alias`. The $test-utils alias becomes a
`#test-utils/*` subpath import, matching #lib, and its 43 import sites
move with it.
- Vite's coming native config loader needs the extension on
vitest.config.ts's import of vite.config.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The image build on fecd030b failed in `vite build`:
[postcss] ENOENT: no such file or directory, open '/web/tailwindcss'
With a PostCSS config present, Vite's own @import inliner resolves
`@import 'tailwindcss'` before @tailwindcss/postcss sees it, and reads it
as a relative file. svelte-check and Vitest never build CSS, so only the
image job caught it. The Vite plugin is Tailwind's documented setup for
Vite projects and handles the import itself, so postcss.config.cjs and
the direct postcss and @tailwindcss/postcss dependencies go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps,
plus the migration they need. The mechanical part is `sv migrate
sveltekit-3`, run one task at a time and reviewed:
- svelte.config.js is gone. Its options move into sveltekit() in
vite.config.ts, exported as kitOptions so vitest.config.ts runs the
same kit setup, including the $test-utils alias the tests import.
- $lib becomes #lib through package.json "imports". There is no
src/lib/index, so only the "#lib/*" entry is kept.
- tsconfig extends $app/tsconfig.
- Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite
^8.0.12, svelte-check ^4.7.5.
By hand, from the codemod's list of non-automated tasks:
- goto's replaceState option is now replace; keepFocus becomes
reset: false. For the search typeahead, reset: false also stops the
scroll-to-top, which is wanted while typing.
- The test setup mocks drop pushState/replaceState and $app/paths
base/assets, which kit 3 removed, and mock refreshAll in place of
invalidateAll.
- The other flagged files only read page.url or goto internal routes,
so they needed no change.
TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares
typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to
7 once both accept it.
With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0,
so the web lane now audits every dependency rather than only what
ships to browsers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Renovate's tailwindcss bump (PR #150) plus the migration it needs:
- Theme moves from tailwind.config.js into app.css as `@theme inline`,
mapping the same FabledSword tokens. tailwind.config.js is gone.
- PostCSS runs @tailwindcss/postcss; autoprefixer is dropped, since
Tailwind 4 prefixes through Lightning CSS.
- Class renames from @tailwindcss/upgrade 4.3.3, reviewed: outline-none
-> outline-hidden, focus-visible:outline -> outline-solid, shadow ->
shadow-sm, shadow-sm -> shadow-xs, flex-shrink-0 -> shrink-0. Bare
`rounded` stays: v4 keeps it at 0.25rem, as before.
- Three v3 preflight defaults kept in a base layer so nothing changes on
screen: gray-200 default border colour, gray-400 placeholder text and
the pointer cursor on buttons.
- The unused class-based dark variant is not carried over; no template
uses `dark:`.
Clears the five high and two moderate npm audit findings that came in
through Tailwind 3 (braces, chokidar, micromatch, fast-glob,
postcss-selector-parser).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merges Renovate's three branches (PRs #145, #146, #147), which fail
alone: vite-plugin-svelte 7 requires vite 8, and vitest 5 is the vitest
for vite 8. Renovate changed only package.json, so npm ci failed on each.
The lockfile is regenerated for just these packages: the stale entries
for vite, vitest, @vitest/* and vite-plugin-svelte (with its old
inspector) were dropped and re-resolved, leaving everything else locked.
SvelteKit stays on 2.70.3, which accepts vite 8 and plugin 7. Vite 8
builds with Rolldown, so esbuild moves to 0.28 and rollup leaves the tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summed tag weight rewards a track for carrying many of the user's tags, so
on the deploy two artists whose every track carries the whole lo-fi profile
took all 120 rows of the taste-unheard query. capByAlbumAndArtist ran after
the LIMIT and left 6, and the arm with the lowest skip rate (12% against
~23%) handed its slots to dormant and random.
The query now ranks within album, then within artist over what the album
cap kept, before the LIMIT: the same walk the Go cap makes, so the bucket
fills from as many artists as match. The caps come from the Go constants.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Roundtable's shape: no FCM. A specialUse foreground service keeps the
process alive; EventsStream stays connected; DeliveryLauncher runs the
catch-up on every notification.created nudge and every reconnect, and is
the single owner of the service's lifetime (signed in AND the device's
"Notifications when the app is closed", on by default).
- NotificationSync pulls the newest page and announces unread notices past
a high-water mark (auth_session.notifiedUpTo, read and written through
the DAO), honouring the per-kind phone pref. A first look sets the mark
without announcing; more than three collapse to one line; nothing is
posted while the app is on screen.
- Two channels: "Your requests" and "Library health"; the ongoing notice
sits on a MIN "Background connection" channel.
- EventsStream: a connected flow, 2s→5min backoff with ±25% jitter, and an
immediate reconnect when a network comes up (a hint, not VALIDATED) or
the app comes to the foreground.
- BootReceiver restarts delivery after a reboot or a self-update.
- A tap opens what the notice links to (routeForLink), a pile the inbox.
- POST_NOTIFICATIONS is asked for on Android 13+ once delivery is wanted,
and again when the toggle is turned on.
- Room v12: auth_session.backgroundDelivery and notifiedUpTo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing is emailed per event. New music (request_completed) goes out at
most once a day, at the summary hour in each user's own timezone, grouped
by artist. Everything else is batched: one email a window after the first
un-emailed item, holding whatever accumulated.
- Migration 0074: notification_email_settings (summary hour, batch window,
admin-configurable) and user_notification_email_state (batch start, last
sent, failures and retry_after per user and group). Existing rows are
stamped emailed so the upgrade sends no backlog.
- The Notifier stamps emailed_at at write time when the recipient's email
channel is off, so turning email on later doesn't send old items.
- Read rows are never selected. A row is stamped only after the mailer
accepts, in one transaction with the state, against the read's clock, so
a coalesced row updated mid-send stays pending.
- A failed send backs off 5m doubling to 6h; SMTP not configured just waits.
- Links come from the public address; without one the email has none.
- The mailer now RFC 2047-encodes subjects and strips line breaks from them.
- Admin → Integrations gains a Notification emails card.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dispatch reached cyclomatic complexity 16 with the three M489 kinds; they
now share one entry that hands off to dispatchNotification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The top bar carries a bell with a badge. The badge is inverse, not
error red, and counts to 9, then shows 9+. It opens the Notifications
screen: one line per notice, how long ago, "Mark all read", pull to
refresh. A tap marks the notice read and opens its screen (albums,
artists, requests, admin requests and quarantine; other admin pages
land on Admin).
- The newest 50 notices live in Room (cached_notifications, v11 with
MIGRATION_10_11), so the badge and the list work offline. A refresh
keeps a read made here that the server hasn't seen yet.
- Reads, read-all and setting toggles go through the MutationQueue
(rule 100): NOTIFICATION_READ, NOTIFICATIONS_READ_ALL and
NOTIFICATION_SETTING_SET.
- Read-all sends the newest notice shown, rounded up a millisecond
(Room keeps ms, the server µs), so a late replay leaves newer notices
unread.
- Settings collapse per kind and channel.
- The `notification.created` live event, a return to the foreground and
reconnecting all refresh the inbox.
- Settings → Notifications: a row per kind with Inbox, Phone and Email.
Admin kinds sit under "Library health". Phone and email ride on the
inbox. One line says why email is off. If the system blocks
notifications, a row opens Minstrel's notification settings; it is
re-checked on resume.
- Signing out clears the cached inbox.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /api/me/notifications/read-all accepts {"up_to": RFC3339}. Android
queues "mark all read" for replay when offline, and a replay landing later
must not mark notices that arrived in between, which the user never saw.
A coalesced notice updated since then has a newer created_at, so it stays
unread. An empty body still marks everything.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A Notifications section on Settings has a row per kind and a toggle
each for Inbox, Phone and Email. Labels are short, menu-style.
- Admin kinds sit under "Library health", for admins only.
- Toggles are optimistic and send only the kind and channel touched. A
failed save reverts unless something newer has happened (snippet
#5106's generation counter).
- With the inbox off, phone and email are disabled: they ride on it.
- When email isn't usable, one line says why. With no address it links
to the profile. With no SMTP an admin gets a link to Integrations and a
listener is simply told. Saving the profile refreshes the settings so
the line clears.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The bell sits between search and the user menu. Its badge is
parchment on obsidian, not the accent, which the house style keeps
off general chrome. It counts up to 9, then shows 9+.
- The panel lists the server-rendered title, body and relative time,
newest first, with unread rows marked. Clicking a row marks it read
and opens its link. "Mark all read" appears while anything is unread,
and an empty inbox says "Nothing waiting for you."
- createNotificationsQuery and createUnreadCountQuery poll every 60s
while the tab is visible. The `notification.created` live event
invalidates ['notifications'] so the badge and list refresh promptly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A failed scan run sends scan_failed. Each failure adds to the count and
the notice shows the latest error. A scan cut short by shutdown says
nothing.
- Marking tracks missing sends tracks_missing with a running count.
- A duplicate sweep that proposes a group it had not proposed before
sends duplicates_found, counting everything awaiting review. A sweep
that only re-finds known groups stays quiet, so a read notice isn't
repeated every sweep (CountDuplicateGroupsDetectedSince).
- A playback-error report sends playback_errors, counting the unresolved
errors (CountUnresolvedPlaybackErrors).
The library package gets its notifier as a package-level SetNotifier
beside SetEventBus, for the same reason the bus is package-level.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A new request still pending after any auto-approval notifies the
admins (request_pending), but not the requester if they are an admin.
A request that dedups into one already in flight is not announced again.
- Approving or rejecting a request notifies the requester, and a
rejection carries the admin's notes as the reason. An admin deciding
their own request gets nothing.
- The reconciler notifies the requester when their request arrives
(request_completed), linking the matched album or artist.
- A request the re-acquisition sweeper files and cannot approve itself
notifies the admins.
- A quarantine flag notifies every admin except the flagger, naming the
track, the flagger and the reason.
lidarrrequests.Service.CreateTracked reports whether a request was
inserted or deduped; Create wraps it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:17:11 -04:00
1331 changed files with 76178 additions and 27257 deletions
-c "SELECT 'durability: ' || string_agg(name || '=' || setting, ' ' ORDER BY name) FROM pg_settings WHERE name IN ('fsync','synchronous_commit','full_page_writes')" \
|| true
else
echo "::warning::durability relax failed after 5 tries; the suite will run several times slower"
fi
# Apply embedded migrations to the fresh test DB, then run the
# full suite (no -short → integration tests execute). -p 1:
# every integration package TRUNCATEs the one shared test DB;
A self-hosted music server that thinks for you. Smart shuffle, contextual likes, ListenBrainz-aware radio, and Lidarr automation — server-side, so every client (web, mobile, Subsonic third-party) gets the same intelligence.
<h1 align="center">Minstrel</h1>
> State and intelligence belong on the server, not the client.
<p align="center">A self-hosted music server that picks what to play next.</p>
Minstrel serves your music library to the web, to its own Android app, and to any Subsonic client. Shuffle, radio, likes and play history are worked out on the server, so every device gets the same mixes and the same history. It can also find, download and file the albums you want, through a search service and a download client you run yourself. It is for anyone who keeps their own music collection and wants it to sound like more than a random shuffle.
<a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
## Highlights
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit — against a Lidarr instance *you* run and configure. Optional, and off until you supply a URL and API key.
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
## Scope and responsible use
**Minstrel serves music you already have.** It is a library server: it indexes files on disk you point it at, and streams them to your own clients. It does not source, search for, or acquire content, and it has no opinion about where your files came from.
Concretely, Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB client, and **no** DRM circumvention of any kind. There is nothing to point at a content source because Minstrel has no such subsystem.
The **Lidarr integration is optional and inert until you configure it.** You supply the URL and API key of a Lidarr instance you are already running; Minstrel then calls that instance's API to trigger scans, submit album requests, and reconcile imports. Minstrel neither bundles nor installs Lidarr, and configures no indexers on your behalf — Lidarr ships with none either, and any it uses are ones you added yourself.
**What you put in your library, and what sources you configure in your own Lidarr, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz, or Subsonic.
# Colon-separated library roots to scan; must match the container
# path of the music mount above (/music here).
MINSTREL_LIBRARY_SCAN_PATHS:/music
depends_on:[db]
@@ -64,9 +30,7 @@ services:
POSTGRES_USER:minstrel
POSTGRES_PASSWORD:minstrel
POSTGRES_DB:minstrel
# Postgresdata dir — users, likes, play history, sessions, settings.
# The one volume you must never lose; back it up with pg_dump.
volumes:[pgdata:/var/lib/postgresql/data]
volumes:[pgdata:/var/lib/postgresql/data] # users, likes, history: back this up
volumes:
minstrel-data:
@@ -75,105 +39,101 @@ volumes:
```bash
docker compose up -d
docker compose logs minstrel | grep setup_token
```
## First run
Open `http://<host>:4533/register` and create the first account with that setup token. The first account is the administrator.
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address. Plain `http://` is fine on a network you trust; a server reachable from the internet belongs behind HTTPS, which [docs/hosting.md](docs/hosting.md) walks through.
### First run
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance becomes the administrator, and creating it asks for the **setup token** the server prints in its log (`docker compose logs minstrel | grep setup_token`), so nobody else can claim a newly exposed server first. Later users join through the same form or an invite token (step 5).
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
**2. Let the first library scan finish.**`scan_on_startup` is on by default, so Minstrel walks your mounted library on boot and imports artists, albums, and tracks — no button to press. Watch progress (and re-scan any time) on the **Admin** page (`/admin`); the scan runs in stages and is incremental, so later restarts only pick up what changed.
1.**Let the first scan finish.** Minstrel scans the library on startup. Watch it, or scan again, on the **Admin** page (`/admin`). Later scans only pick up what changed.
2.**Install the Android app.** In **Settings** (`/settings`), the *Install the Android app* card downloads the app this server ships. It updates itself from your server after that.
3.**Invite the household.** In **Admin › Users** (`/admin/users`), make an invite token or turn on open registration. Each person gets their own likes, history and recommendations.
4.**Optional: connect services.** Add a search service and a download client in **Admin › Acquisition** (`/admin/acquisition`). Each person adds their own ListenBrainz token in **Settings › Connections**.
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
**3. (Optional) Name the instance and wire up integrations.** In admin **Settings → Integrations** (`/admin/integrations`), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="ListenBrainz and Lidarr integration cards in admin Settings"></a>
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
<a href="docs/screenshots/android-download.png"><img src="docs/screenshots/android-download.png" width="820" alt="The "Install the Android app" download card in Settings"></a>
**5. Invite the rest of the household.** From admin **Users** (`/admin/users`), generate an invite token (or enable open registration). Each person gets their own account, so likes, play history, and recommendations stay per-user.
<a href="docs/screenshots/invite-users.png"><img src="docs/screenshots/invite-users.png" width="820" alt="Generating an invite token in admin Users"></a>
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
Hosting Minstrel on the internet: see [docs/hosting.md](docs/hosting.md). What Minstrel does to protect accounts, and why: [docs/security.md](docs/security.md).
## Configuration
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
The quick start sets everything that's required. These are the settings people most often change:
-`MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
-`MINSTREL_STORAGE_DATA_DIR` — where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
-`MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
| `MINSTREL_LIBRARY_SCAN_PATHS` | Library folders inside the container, colon-separated (`/music:/audiobooks`). They are used on first start only; after that, change them in **Admin › Library**. |
| `MINSTREL_BRANDING_APP_NAME` | The name shown in the header, the browser tab and link previews. |
| `MINSTREL_LOG_LEVEL` | `debug`, `info`, `warn` or `error`. |
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
Search services, download clients, ListenBrainz, email and recommendation tuning are set in the web UI and take effect without a restart. The full file-based surface is in [`config.example.yaml`](./config.example.yaml); to use it, mount your copy at `/etc/smartmusic/config.yaml`.
Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommendation and events tuning are yaml-only. See [`config.example.yaml`](./config.example.yaml) for the authoritative surface.
## Setup pitfalls
## Updating
- **The library mount has to be writable by uid 1000.** Minstrel files the albums it downloads into the library, and it deletes a file when an admin asks it to. It never moves, renames or retags files that were already there. Mount the library `:ro` to forbid writes: those actions then refuse and say why.
- **`MINSTREL_LIBRARY_SCAN_PATHS` names the path inside the container** (`/music`), not the folder on your host.
- **On the internet, put it behind HTTPS.** Plain `http://` is fine on a network you trust. For a public server, bind the port to `127.0.0.1` and put a reverse proxy in front: see [docs/hosting.md](docs/hosting.md).
-`:latest` — production. Tracks `main`'s tip and moves on every `main` push and every release. What most operators should run.
-`:<commit-sha>` — the rollback unit. Every `main` push publishes one, so any production commit is addressable without a release ceremony. Immutable: a given SHA tag is never re-pushed. Pin one if you need a deployment that cannot change under you, and use it to roll back.
-`:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this to try something before it ships. It moves constantly, has no per-commit tag, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback.
```bash
docker compose pull && docker compose up -d
```
That is the whole tag map. **There are no version-numbered image tags**, and no `:main`. Git and the build's own self-reported version answer "which build is this" — the Settings page shows it, and so does `/healthz`. Release *tags* in git are still `vYYYY.MM.DD.HHMM`; they name a changelog entry and the APK attached to it, not an image.
Database migrations run at startup. Image tags:
Rolling back to `:<commit-sha>` pins the **server code** at that commit — not the server-and-app pair. The Android APK is baked in at image build time, so a SHA image carries whichever app was current when that commit was built, which may be older than what `:latest` bundles now. If both halves matter, check what the image bundles rather than trusting the tag's name.
-`:latest`: the stable release. Most people should run this.
-`:<commit-sha>`: one fixed build. Pin one to stop updates, or to roll back.
-`:dev`: the test channel, rebuilt on every change. It may break, and the fix is the next build.
Every `:latest`, `:<commit-sha>` and `:dev` bundles a signed Android APK, so the in-app update channel is always live. All are signed with the same key, so a phone can move between the stable and dev channels without uninstalling — point it at a `:dev` server and the in-app updater offers that channel's build.
Every image carries a signed Android app, all with the same key, so a phone can move between channels without reinstalling. Rolling back a SHA tag rolls back the app it carries too.
The app reports which channel it is on alongside its version, and decides whether an update is available using the build's ordering key rather than its displayed name — the same value Android installs by, so an offer it makes is one the platform will accept.
**Back up the Postgres volume** with `pg_dump`. It holds accounts, likes, play history, playlists and settings, and a migration can only be undone by restoring a dump. The `minstrel-data` volume holds artwork and caches that Minstrel rebuilds if lost.
Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
## Features
Releases up to 2026-09-10 also published a `:vYYYY.MM.DD[.HHMM]` image tag. Those images still exist and still work — they are simply not extended.
**Listening**
- Smart shuffle that weighs what you like, what you skip and what you've heard lately, instead of picking at random.
- Two kinds of like: a song you love, and a song that's right for what you're listening to now.
- Radio that starts from any song, using ListenBrainz similarity as well as your own history.
- Daily mixes and system playlists built from your listening.
- Playback on Sonos and other UPnP speakers from the Android app.
- Loudness levelling between tracks, so you never reach for the volume.
## Specs
**Your library**
- Browse by artist, album, genre and year, with search across everything.
- Duplicates, missing files and suspect copies are found and listed under **Collection** for an admin to fix.
- Missing album art and MusicBrainz ids are filled in automatically.
Authoritative scope lives under [`docs/`](./docs):
**Getting music**
- A wanted list: requests from the household, artists you follow, and albums whose files went missing.
- Minstrel searches the indexers you configure, hands the best release to your download client, checks what arrives against MusicBrainz, tags it, and files it. A better copy replaces a poor one.
- Off until you add a search service and a download client.
- [Server spec](./docs/smart-music-server-spec.md) — current implementation focus.
- A native Android app that ships inside the server image, keeps playing what it has cached when the server is out of reach, and updates itself from your server.
- OpenSubsonic-compatible, so existing clients such as DSub, Symfonium and play:Sub connect as they are.
- Separate accounts for each person, with invites, and an inbox for what happened while you were away.
## Scope and responsible use
Minstrel indexes the files you point it at and streams them to your own clients. It ships no indexers, no trackers, no torrent, Usenet or NZB engine of its own, and no DRM circumvention.
Getting music is optional and does nothing until you configure it. It searches only the search service (Prowlarr or a Torznab indexer) and the download client (qBittorrent) you add yourself.
What you put in your library, and the sources you configure, are your responsibility. Copyright law applies to your collection as it does to any other software that plays a file; please respect it, and the terms of any service you connect.
Minstrel is not affiliated with or endorsed by ListenBrainz, MusicBrainz, Prowlarr, qBittorrent or Subsonic.
## Development
Two concurrent dev processes:
- **Backend:** `docker compose up` in this repo runs Postgres and Minstrel on `:4533`.
- **Web:** `cd web && npm install && npm run dev` starts Vite on `:5173`. It proxies `/api/*` and `/rest/*` to `:4533`.
- **Tests:** `make test-short` runs the unit tests. `make test-integration` also runs the database tests, against a separate `minstrel_test` database so your dev data is never truncated.
- **Image:** `docker build -t minstrel .` builds the web app and embeds it in the server binary.
- **Branches:** work lands on `dev`; `main` changes through pull requests from `dev`.
1.**Backend:**`docker compose up` — Postgres + Minstrel on `:4533`.
2.**Frontend:**`cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR. The Vite server proxies `/api/*` and `/rest/*` to `:4533` so session cookies work.
### Testing
- Unit + race (no DB): `make test-short`.
- Full suite incl. integration tests: `make test-integration`. This runs
against a dedicated `minstrel_test` database so a test run never
truncates your dev `minstrel` data (admin user, library, likes). It
brings up the compose Postgres and creates the test DB if missing.
- CI runs both: a fast `go test -short -race` gate plus an integration
job with its own ephemeral Postgres (the `integration` lane in
`.gitea/workflows/release.yml`, which also gates every image publish).
### Production build
`docker build -t minstrel .` runs the SvelteKit build inside a `node` stage, copies the output into the `golang` stage, and `//go:embed`s it into the final binary. The container serves the SPA from `/` alongside the API surfaces; no separate static-file server is required.
### Branches
- Day-to-day work happens on `dev` (or feature branches merged into `dev`).
-`main` is **protected** — changes land via PR from `dev`.
- Releases are cut by tagging `v*` off `main`; the release workflow builds the signed APK, attaches it to the release, and refreshes `:latest` around it.
Task and milestone tracking: Fable (`Minstrel` project, id 12).
How accounts and sessions are protected: [docs/security.md](docs/security.md).
android:value="Maintains the connection to the user's own Minstrel server that delivers their notifications, in place of a third-party push service."/>
</service>
<!-- Starts delivery after a reboot or an update; both broadcasts may
start a foreground service from the background. -->
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.