-
v2026.08.18 — a lost file stops being a dead end, and the Sonos stops going quiet
Stableandroid / Build + lint + test (push) Successful in 4m16srelease / Build signed APK (tag releases only) (push) Successful in 5m25srelease / Build + push container image (push) Successful in 1m16srelease / Verify release artifacts (tag releases only) (push) Successful in 1sreleased this
2026-08-18 11:17:27 -04:00 | 0 commits to main since this releasePer-day CalVer. Server + web + Android, and there is a schema migration this time. Merged via PRs #126 and #127;
mainat4f077736, CI green across every lane that ran.Twenty-nine commits, and the largest single line in the diff is a deletion: the Flutter client is gone.
This is the first Android release since v2026.08.08. Thirteen Android commits have been sitting on
mainwith no way to reach a phone — everything below marked (app) arrives with this one.
A missing file is now a state, not a dead end
Minstrel already knew when a file vanished.
missing_sincegot set by the scanner, the selection paths quietly stopped picking those tracks, and that was the end of it — nothing else in the product knew, and nothing ever tried to get the file back.The original plan was to delete orphaned tracks. That was wrong, and you said so:
the intent of 'deleting' a track is to remove it from being played and to put the system in a state where something like lidarr will attempt to replace the file. we should be keeping records of the track the file was supposed to represent and all the history of how it was used.
So nothing is deleted. The row survives, and so do its plays, its likes and everything it contributed to your taste profile. The mark is the state, and the file is expected to come back.
In a playlist the entry stays where it was, greyed out and skipped rather than silently vanishing — a playlist that quietly shrinks is worse than one that shows you the hole. The server also refuses to hand out a stream URL for it, rather than handing one over and trusting every client to honour a flag.
In the admin UI, a new Missing files page groups by folder. The case that prompted this was three reorganised albums, which a flat list renders as forty unrelated problems. Each row carries when the track was last played, because "gone six months, never played" and "gone yesterday, played 200 times" deserve completely different reactions from you.
And it asks for the file back. An hourly sweeper turns missing albums into Lidarr requests on an exponential backoff. Two decisions do the safety work: it requests the album, not the track — three requests instead of forty — and it waits out a grace period first, because an unmounted volume fixes itself well inside a day and a trigger-happy system would flood your indexers over a loose cable.
⚠️ Auto-approve is ON by default. The first scan that finds an album missing past the grace window will create and approve a Lidarr request without asking. That is the intended behaviour, but it is the one setting here that acts on its own. Admin → Missing files → Re-acquisition turns it off if you would rather approve by hand. Decide before your next scan.
Everything is DB-backed and tunable from that card, and it states the resulting schedule in plain words — "first retry gap: 6" means nothing until you know it doubles.
Clients get told. This one nearly shipped inert.
/api/library/syncis a change-log feed, and marking a file missing is a plain UPDATE — so the flag existed on the wire and nothing ever asked a client to re-read the track. Reconcile now emits change rows, written before the mutation, because the two failure modes are not symmetric: a spurious re-read costs one fetch, a missed change row means that client never finds out, permanently.A fully-missing album also drops out of the Years axis now, which is what Genres already did. The two browse axes had quietly disagreed.
The Sonos
Two different faults, found in that order, both from one diagnostics dump.
It would stop, and nothing noticed (app)
A Sonos stopped by itself mid-track while the phone was asleep, and the app went on reporting "not playing" for minutes without acting. A renderer streams on its own — that is the point of casting — so a stream that dies is invisible unless something is watching. Now something is, and it retries before giving up and telling you.
It is deliberately timid about it: only a genuine stop, never a pause (a pause is usually a person at the Sonos app, and taking the transport back off them is a fight they always lose), and only after three polls agree.
It would stop because we gave it half a queue (app)
The better answer, found after you pushed back on my first one — I had been blaming the network, and you told me plainly to assume the app is where the problem lives and the only thing we should change. Re-read that way, the same dump pointed somewhere else.
The app loads your queue onto the Sonos one track at a time, and Sonos rate-limits rapid adds. The loader gave up after a few failures, logged a warning, and moved on. Nothing ever read the queue back. So the renderer would be holding ten tracks while the app believed there were forty, play its ten, and stop — correctly. That is not a stream dying; that is a speaker finishing exactly what it was handed.
It now asks the renderer how many tracks it actually has, and appends whatever is missing. And a stop is classified rather than assumed: a dead stream gets resumed, a short queue gets repaired and continues at the next track, and a queue that simply ended does nothing at all.
That last case matters more than it sounds. Reaching the end of a queue is how every listening session ends, and the watchdog above would have treated it as a fault — three retries and an error in your inbox every time you finished listening. It never reached a device, because it and its fix are both in this release.
And the stutter (app)
The play-pause-play-pause you have been describing at the start of a track. Two things here, and I want to be straight about which is which.
Something that was definitely wrong: while casting, the phone kept its own player paused rather than stopped — and a paused player keeps downloading. So your phone was pulling the same track the Sonos was streaming, over the same WiFi, starting fresh at every track change. FLAC, so roughly a second full-rate download competing with the speaker, beginning exactly when a new track begins. That is fixed.
Something I do not know yet: whether that was the cause. It fits the timing, but my previous theory about these symptoms fit too and was wrong.
So this release also instruments the symptom. The reason it has gone undiagnosed is that the diagnostics could not see it: the event that records player state does not include whether we are playing, the track-change event needs the queue position to move, and the heartbeat samples once every 45 seconds. A few seconds of oscillation at one track fell through all three, which is why you have described it several times and it has never once been measured.
Now a burst of transport changes writes a single event carrying the sequence, plus what the app and the Sonos each thought was playing at that moment. If those two disagree, it is our bug; if they agree while the speaker keeps rebuffering, it is the stream. Opposite fixes, and one captured episode tells us which — without you having to interpret anything.
Also
Genres and Years now exist in the Android Library (app), matching the web tab order — which closes the last browse gap between the two clients.
The dead strip above the MiniPlayer is gone (app). An unclaimed navigation-bar inset was leaking into all fifteen in-shell screens, plus 140dp of padding inherited from the Flutter era.
The Flutter client is deleted — 245 files, about 24,600 lines, last touched in May and built by nothing since its workflow was removed. Its design tokens were kept and moved to the repo root; they lived in that folder but were never its property.
Install
There is a migration this time (
0056_missing_reacquisition). Redeploy the server image and let migrations run.Update the Android app. This is the first APK in ten days and carries everything marked (app).
Then decide about auto-approve before your next scan — see the warning above.
What to look at afterwards
- Admin → Missing files. If it is empty, nothing is lost and there is nothing to do. If it is not, check the grouping matches how the files actually moved.
- The Re-acquisition card, and whether you want it approving on its own.
- Cast something to the Kitchen and let it run. That exercises the queue verification, the stall watchdog and the download fix all at once.
- Library → Genres / Years on the phone.
- Whether the miniplayer still hides the bottom of a list.
Known, and deliberate
The stutter fix is a candidate, not a diagnosis. If it persists, the new diagnostics will have captured it and that is the first thing to read.
The queue-truncation explanation is inferred, from the code and an old logcat, never confirmed against hardware. The loader now logs how many tracks the renderer actually holds on every cast, which settles it either way.
None of the Sonos work has been tested against a real speaker — there is no Sonos in CI, and there is no Lidarr in CI either, so the re-acquisition path has never made a real request. The quickest check is the admin Requests queue after a scan finds something past the grace window.
🤖 Generated with Claude Code
Downloads
-
v2026.08.08 — the genre list gets an A–Z, and stops shouting EDM as "Edm"
Stabletest-web / test (push) Successful in 49stest-go / test (push) Successful in 1m9stest-go / integration (push) Successful in 5m1srelease / Build signed APK (tag releases only) (push) Successful in 4m2srelease / Build + push container image (push) Successful in 14srelease / Verify release artifacts (tag releases only) (push) Successful in 2sreleased this
2026-08-07 22:04:58 -04:00 | 29 commits to main since this releasePer-day CalVer. Server + web, no schema migration. Merged via PR #124;
mainataa9f534f, CI green across the Go lane, the web lane, the integration suite and the image build.A small release, and mostly the end of a thread rather than a new one. The last three days fixed genre data that turned out to be broken in two separate ways; this is what's left once it's actually correct.
The category system isn't happening, and that's the right answer
Three days ago the plan was to roll your genre tags into curated categories, because the list looked unusable. You'd said it would "pile up fast and not be user friendly," and you were reading the screen accurately.
Now that the underlying data is fixed, here's what your library actually contains:
391 genres across 90,774 tag applications — about 3.7 genres per track.
That ratio is the whole argument against categories. Because a track already appears under every genre it claims, grouping them adds nothing you don't already have — and it would actively destroy the useful part.
Neurofunk,Wassoulou,Soukous,Emoviolenceare not noise to be tidied into "Electronic" and "World"; they're the most precise labels in your collection, and something in there is the only thing that describes those records properly.The mess was never your tagging. It was Minstrel welding multi-value tags together and then keeping ghost entries for deleted files — both fixed in the last two releases. Worth stating plainly because the earlier plan was built on the assumption that your files were the problem.
So instead: an A–Z
The genre list already had a filter box. It now also has a sort control — Most tracks or A–Z.
Most-tracks stays the default, because the top of that list is genuinely where you're going. A–Z is for the other case: you know roughly what the thing is called and you just need to find it among several hundred rows. When you filter, the count now reads "12 of 391" so you can see what the filter did.
Both are instant — the whole list is already in the browser, so neither one asks the server anything.
EDM is spelled EDM
Something in your tagging history title-cased the entire genre field, which left your library saying
Edm,Idm,Aor,Uk Garage,Trap Edmand — the one that gives the game away —Children'S Music.Those are now repaired when the file is scanned, not just prettied up on screen. That distinction matters: your taste profile reads genres straight from the library, so a display-only fix would leave it quietly learning "Edm" as a tag while showing you "EDM", and any correctly-tagged file would have contributed a second, separate tag for the same thing.
The repair only ever changes capitalisation, never letters — so it cannot silently turn one genre into a different one. That's the line that keeps it from becoming the category system by the back door. It handles two things: a short list of initialisms, and possessives like
Children's. Names keep their capitals, soO'BrienandD'Angeloare left alone.What deliberately wasn't touched
AlternRock— 10 tracks. That's not damage, it's the genuine 1990s spelling of entry 40 in the original ID3 genre table, which is what those files literally reference by number. You chose to keep that table faithful, so it stays, and there's now a test making sure nobody "helpfully" fixes it later.Rockandrockstill list separately. Genre capitalisation is otherwise shown exactly as your files say it. Deciding those two are the same is a judgement about labels; deciding there's no genre called "Edm" isn't.Nine non-music labels —
Non-Music,Interview,Nature Sounds,Field Recording,Spoken Word,Drumline,Christmas Music,Comedy,Sound Art. Those are accurate descriptions of those files, so they're staying.
Install
Redeploy the server image. No migration this time.
Then let a scan run, or trigger one from Admin → Library. The casing repair needs to re-read your tags, so it reaches existing tracks on that scan rather than only new files. That re-read reuses durations it already knows, so it costs tag reads and no audio probing — noticeably faster than a first-time scan.
What to look at afterwards
- Library → Genres — the new Sort control, and
EDM/IDM/UK Garage/Children's Musicreading correctly once the scan has run. - Filter to something and check the "12 of 391" count matches what you see.
- If you're curious whether it worked at all:
AlternRockshould still be there. It's the deliberate exception.
Known and deliberate
The acronym list is short and driven by what your library actually contained, not by guesswork — a speculative list risks mangling a word that merely looks like an initialism. If you spot another one reading wrong, it's a one-line addition.
Anything needing a letter change rather than a case change is out of scope by design, and would be a different feature with different risks.
🤖 Generated with Claude Code
Downloads
- Library → Genres — the new Sort control, and
-
released this
2026-08-07 08:38:56 -04:00 | 32 commits to main since this releasePer-day CalVer. Server + web, with schema migration 0055. Merged via PRs #121, #122 and #123;
mainat011b4d9a, CI green.This tag was re-cut twice.
v2026.08.07first covered the missing-file work alone (a99f855e), then gained the metrics work (d5aa0811), and now the release-pipeline guard below (011b4d9a). Rather than mint extra dated tags for the same day, the tag and its release were replaced each time so today has one release covering everything. Superseded builds remain addressable by their commit-SHA images (:a99f855e,:d5aa0811).Two threads for you, and one for the build pipeline. The first began as a bug report about genre names and ended somewhere else entirely; the second is about a screen that was quietly overstating its own confidence.
Some of your library doesn't exist
Yesterday's release fixed how genres are read. You redeployed, and still saw nonsense genres in the list. That turned out to be a completely different bug, and a considerably worse one.
You reported entries like
Alternative RockAlternative MetalRockRap MetalPop Rock…surviving a completed scan. The scan log saidscanned=24185 skipped=24185 errored=0— everything checked, nothing wrong.Meanwhile, a different stage of the same scan was quietly logging this, about forty times:
track mbid backfill: open failed path=".../Linkin Park - Minutes to Midnight - 07 - Hands Held High.mp3" err="no such file or directory"Both statements were true. The library scanner walks your folders, so it only ever sees files that exist. A database row whose file has been deleted or moved is never visited — never scanned, never counted, never reported as an error. It is simply invisible to the scan. The MBID stage opens files by their recorded path instead of walking, which is why it tripped over them and the scanner never did.
Those rows had been sitting there since whenever those albums were last reorganised, and nothing in Minstrel has ever removed one. The genre fix couldn't reach them for exactly the same reason: it can only re-read files the walk visits.
Why it mattered more than a messy list
A row like that is a live library entry. Nothing downstream knew the file was gone, and the track table is the pool every recommendation draws from — so Discover, For You, Deep Cuts, radio, all of it could hand you a track that cannot play. That's a mix quietly coming up short, or playback stopping for no visible reason.
It now notices — and deliberately doesn't delete anything
Minstrel records that a file is missing rather than removing the row, and stops offering it. Missing tracks no longer appear in genre browse, and no longer get picked for any mix, radio station or playlist.
Deleting felt like the obvious answer and is the wrong one, for two separate reasons.
The filesystem lies. "This file isn't there" is a claim that can be temporarily false — an unmounted drive, a network share that hiccupped, a container that started before its media volume attached. Every other cleanup job in Minstrel answers a question the database can settle by itself, which is safe to do unattended. This one can't. So it marks, which is reversible: when the file comes back, the mark clears on its own.
Three guards refuse to guess:
- every configured library folder must exist and contain something — an empty folder is what a mount point looks like with nothing mounted on it
- the scan must have found at least one file
- at most a quarter of your library can be newly marked in one pass
Recovery deliberately ignores that last limit, so a library that hit it once can still un-mark everything when the drive returns.
And deleting would damage your own history. This is the part worth knowing: your taste profile reads your plays through the track, so a marked-but-kept row keeps contributing what you learned from it. Deleting the row takes its play history and its like with it. That's the thing that would actually distort your recommendations — so keeping it is the accurate choice, not the timid one.
What stays untouched, on purpose: your listening history and your own playlists. A track you added yourself doesn't vanish because a file moved, and history is a record of what happened. Only the "what should we play next" machinery filters them out.
A renamed song no longer loses its past
You raised this yourself, and it turned out to be the more important half.
Minstrel identified a track by its file path. So renaming a file — retagging an album, fixing track numbers, reorganising folders — looked like one song being deleted and a different song appearing. The consequences were silent:
- a song you'd liked came back unliked
- its play count reset to zero
- Rediscover could offer you something you'd played fifty times, as a discovery
Renumbering a single album was enough to do it. Your copy of Minutes to Midnight is the case that proved it: the database still held
02 - Bleed It Outwhile the file on disk is now04 - Bleed It Out.Minstrel now recognises a returning file as the same recording and reconnects it to its existing entry, keeping its likes, plays and playlist spots. It matches on the MusicBrainz recording ID where your files have one, and falls back to exact file size plus exact duration where they don't.
It is careful about it. It will only ever reconnect a file to an entry it already believes is missing — anything else is a duplicate, and adopting one of those would break the copy that still exists. And if two entries match equally well, it refuses to choose and starts a fresh one instead: a split history can be repaired later, whereas two songs wrongly merged cannot.
The bug that nearly shipped in this feature
Worth recording because it's the kind that hides. Reconnecting requires knowing a file went missing before the new one is examined — and a rename made while the server is off produces both facts in the same scan. In the first version, the new file got filed as a brand-new song moments before the old one was marked missing, and after that the chance was gone forever.
Which means it would have worked perfectly for renames made while Minstrel was running, and silently failed for exactly the case that produced your problem. The scan is now split so it takes stock of what's gone before deciding what's new.
The tuning numbers now admit what they don't know
Different kind of fix. Nothing was broken here — the screen was just more confident than the arithmetic warranted, and it talked me into a wrong recommendation.
Settings → Recommendation metrics compares each surface against your own manual picks. Discover's taste-matched arm showed a 13-point better skip rate than its random-unheard arm, rendered as a bold coloured number like any other. It looks like a finding. It isn't: at 59 and 70 plays that gap is well inside its own noise, and you'd need roughly 133 plays per arm before it could be called.
The card had one volume threshold, and it was doing two jobs. Twenty plays is enough to be worth showing — below that a rate is anecdote. It is nowhere near enough to act on. Those are different questions and the screen only answered the first.
Every delta now carries its own margin of error. When a difference is smaller than that margin it renders dimmed with a
≈and no colour, and hovering gives the range. So a−6drawn from 400 plays and a−12drawn from 59 no longer look alike — because the second one might be nothing.There's a hint of it in the copy too: the exact comparison above is now a test that fails if the false confidence ever comes back.
Admin → Tuning had the same problem, worse. Its skip and completion columns are the most recent week while the Plays column beside them covers the whole window — so a rate off seventeen plays sat next to a four-figure total and read as solid. I misjudged Deep cuts on exactly that basis; over 180 days it's one of your better surfaces. The columns now say which period they cover, and the skip rate carries that week's play count.
Also in this release
Scans no longer log a recurring database
ERRORfor a condition that is entirely expected — two artist rows mapping to the same MusicBrainz artist, which means they're candidates to be merged, not a fault. The album side had always treated it that way; the artist side hadn't.
And the release pipeline now checks its own work
Nothing here changes the app. It's a fix to how releases are verified, prompted by this very tag.
The first attempt at cutting this release silently shipped incomplete. The job that builds the Android APK never started — no log, no output, every step marked failed without running. The build went red, but the release page rendered perfectly and the rolling
:latestimage had already been published by a separate build, so the code was deployable and nothing looked wrong. What was actually missing was the attached APK and the pinnable:v2026.08.07image. It's the kind of gap you'd only notice by going to look for it.The release now verifies itself: after building, it checks that the release really has an APK attached and that the version-pinned image really got pushed, and fails with a plain description of whichever is missing instead of a wall of unexplained step failures.
Two smaller things came out of the same review. A precondition that used to be checked at the very end of the Android build — "does a release actually exist for this tag?" — now runs at the start, so a mistake fails in seconds rather than after a full build. And a check that only verified the APK would have passed a release whose image push had failed, which is half of what went wrong; it verifies both.
Install
Redeploy the server image. That's the whole install — migration 0055 applies automatically on restart. No new APK; the Android app is unchanged.
Then let a scan run, or trigger one from Admin → Library.
What to look at afterwards
- Library → Genres. The remaining welded entries should finally disappear — they were coming from those phantom rows.
- The scan record now reports how many tracks were marked missing and how many came back.
- The server log names each reconnection with both paths, so a reorganisation is visible as "track moved, history preserved" rather than passing unnoticed.
- Give it a while, then check a renamed favourite. Is it still liked? Does it still show its play count?
- Settings → Recommendation metrics. Expect to see more
≈than you might like. That's the honest state of a household-sized dataset, not a regression.
Known and deliberate
Nothing is ever deleted, so those rows stay. They're inert — excluded from everything that picks music — but they're still in the database, and there is no screen for reviewing them yet. That's the next piece of work, and it's deliberately separate: it's the only part that can destroy something, and it deserves to be built on its own rather than bundled in here.
Reorganising a very large share of your library at once trips the quarter-of-the-library guard. Nothing breaks, and it's logged plainly, but reconnection can't run that time and those files will start fresh histories. At your library's size this needs thousands of files moved in one go.
Reconnection needs something to recognise. A file that was re-encoded — not just renamed — changes both its size and its duration, and if it also lacks a MusicBrainz ID there's nothing left to match on. That's genuinely a different file, so a fresh entry is the honest outcome.
The margins don't correct for how many comparisons you're scanning. Looking at twenty surfaces and arms at once means the most striking-looking gap is likely to be striking by chance. The per-delta margin catches most of that; the rest is a judgement call, and the honest posture is to distrust the single best-looking number on the page.
The new release checks can't prevent the failure that prompted them — that was infrastructure, and it didn't recur. They make an incomplete release loud instead of quiet, which is the part that nearly got missed.
🤖 Generated with Claude Code
Downloads
-
released this
2026-08-05 22:19:42 -04:00 | 43 commits to main since this releasePer-day CalVer. Server-only, with schema migration 0054. Merged via PR #120;
mainat7e4727fc, CI green across the Go lane (incl. the real-migration integration suite) and the image build.One fix. It started as a cosmetic complaint about the genre list and turned out to be corrupting recommendations.
What you saw
Yesterday's release added Library → Genres and asked you to look at the raw spread, because the plan was to design a category taxonomy around whatever your library actually contained. You looked. It contained things like this:
Alternative RockRock 420 tracks Boom BapDowntempoHip HopInstrumentalLo-FiLo-Fi Hip Hop… 275 Indie RockRock 235 ElectronicElectronic RockTrap 103 4017 2 17Indie Rock40Britpop 1About two-thirds of the ~700 entries looked like that. The reasonable conclusion was that your tags are a mess and need cleaning up.
They aren't. That was us.
What was actually happening
ID3v2 stores multiple values in one tag field separated by a null byte — a file tagged both Alternative Rock and Rock holds
Alternative Rock\0Rock.The tag-reading library Minstrel used split on that null and rejoined the pieces with nothing between them. Two genres became one nonsense genre. Eight genres became one 70-character nonsense genre.
The bare numbers are the same bug wearing a different hat. ID3 lets a genre be a numeric code —
17means Rock,40means AlternRock — and those went unresolved before being welded together.4017was never a genre. It was AlternRock and Rock.526617was Electronic, New Wave, Rock.Why this mattered more than the list looking bad
Genre isn't just a browse axis. It feeds your taste profile, and through that, recommendations.
- Your taste profile was learning
"Alternative RockRock"as a tag it had never seen anywhere else — a dead-end vocabulary entry instead of two real genres shared with hundreds of other tracks. - The recommendation and Discover queries split genre on
;and,before comparing tracks. There was nothing to split, so they compared welded tokens that matched almost nothing. - Every count was wrong. Rock reported 3413 tracks while silently excluding every track whose Rock tag had been welded to something else.
So the effect was not that a screen looked untidy. Genre similarity — one of the inputs deciding what turns up in your mixes — has been running on a vocabulary of pseudo-tags this whole time.
The fix, and the road not taken
Minstrel already runs
ffprobeon every file for duration, so the obvious move was to ask ffmpeg for the genre too — no new dependency, one line.That was checked rather than assumed, and it was the wrong answer. ffmpeg reads the first value out of a multi-value field and silently throws the rest away. It would have replaced a visible bug with an invisible one: no more nonsense labels, but Alternative Rock / Rock quietly becomes just Alternative Rock, and multi-genre tags are precisely the signal that makes genre similarity useful.
So Minstrel now reads the genre field itself — all three ID3v2 generations, all four text encodings, and the awkward corners of the format. Everything else about your files is still read by the same library as before; only genre changed hands.
Numeric codes are resolved properly now too, including the older parenthesised style.
Your library repairs itself
This is the part worth knowing, because there's a button you might go looking for and won't find.
Minstrel skips files that haven't changed since the last scan — that's what makes scans fast. But re-tagging a file doesn't change the file, so a normal scan would have skipped every already-indexed track and repaired nothing. There was no "force full rescan" anywhere either.
Rather than add one, tracks now record which version of the tag-reading logic last touched them. Everything currently in your library is marked as stale, so the next ordinary scan re-reads it — automatically, once, no action from you. Future tag fixes can reuse the same mechanism.
That repair pass also reuses the duration it already knows instead of re-running
ffprobeper file, since the audio hasn't moved. On a large library that's the difference between minutes and hours.Your taste profile rebuilds on its own schedule and replaces its tag list wholesale, so the polluted entries clear themselves shortly after the scan.
Install
Redeploy the server image. That's the whole install — this release carries migration 0054, which applies automatically on restart. No new APK; the Android app is unchanged.
Then let a scan run, or trigger one from Admin → Library.
What to look at afterwards
- Library → Genres. The welded entries and the bare numbers should be gone. Expect the list to get shorter and the counts to get bigger — Rock in particular should jump well past 3413. That's the fix working, not a new bug.
- Multi-genre albums. An album tagged with several genres should now be reachable from each of them, and its detail page should show several chips instead of one strange one.
- Give recommendations a couple of weeks. Genre similarity is now comparing real tags. Whether that's noticeable is an honest open question — genre is one input among several — but it's the first time it's been fed correct data.
Known and deliberate
Genres are still exposed exactly as your files spell them. No case folding, no synonym mapping —
Rockandrockremain separate rows. That's unchanged from yesterday and still deliberate.The category-taxonomy idea that started all this is on hold on purpose, because it can't be designed against corrupted data. What's underneath the corruption turns out to be a clean, consistent, MusicBrainz-style vocabulary of roughly 250 labels — granular, but tidy. That's a different and much smaller problem than "clean up a mess," and it may not need a taxonomy at all so much as better sorting and search. Worth re-deciding once you've seen the repaired list.
Only MP3s were affected. This particular flaw is specific to how ID3v2 tags were read, so FLAC, OGG and M4A files were never welded. Whether those formats lose secondary genres for a different reason is filed, unproven, and deliberately not built on speculation.
🤖 Generated with Claude Code
Downloads
- Your taste profile was learning
-
v2026.08.05 — quiet updates, who's signed in, and browsing by genre or year
Stabletest-web / test (push) Successful in 1m3stest-go / test (push) Successful in 1m13stest-go / integration (push) Successful in 5m29sandroid / Build + lint + test (push) Successful in 5m34srelease / Build signed APK (tag releases only) (push) Successful in 5m5srelease / Build + push container image (push) Successful in 16sreleased this
2026-08-05 15:15:51 -04:00 | 46 commits to main since this releasePer-day CalVer. Server + web + Android, with schema migrations 0052 and 0053. Merged via PR #119;
mainat1b7fa635.Four threads, and three of them started with something being quietly wrong rather than missing.
Updating the app should stop nagging you
You reported a screen claiming the app was bypassing Android security, and that every install asks to scan the app. Those are two different things and only one of them was ours.
The install is now silent
ApkInstallerusedACTION_VIEWwith an APK MIME type — the deprecated dialect, and the exact shape a malware dropper uses. It's replaced by aPackageInstallersession declaringUSER_ACTION_NOT_REQUIRED, withUPDATE_PACKAGES_WITHOUT_USER_ACTIONin the manifest.Android grants a completely silent self-update when four things hold: the installer opts in, the installed app targets API 29+, the installer holds that permission, and the target is the installer itself. Minstrel updating Minstrel satisfies all four — so on Android 12+ the update should land with no dialog at all. Older devices have no such API and get the platform's confirm dialog, which is a graceful floor rather than a failure.
Comparing against Mihon settled this. It's out-of-store and self-updating and its updates are quiet for precisely this reason — and it disproved the obvious theory along the way, since Mihon declares
REQUEST_INSTALL_PACKAGEStoo. The permission was never what drew the warning; the mechanism was.The update also knows whether it worked
The old path fired an intent and assumed. A failed install and you declining looked identical, so the banner couldn't tell you which happened. Now they're distinct: a cancel returns quietly to Install, a genuine failure says so.
What this doesn't fix
The "send this app to Google for scanning" prompt. That one isn't about how we install — it's that Minstrel's signing key is unknown to Play Protect, and no code change reaches it. Registering as a developer would, and we looked at it: the free tier costs nothing but caps at 20 devices, which for self-hosted software creates a worse cliff than it removes. Decision was to stay unregistered. Sideloading stays possible either way — Android's advanced flow is a one-time 24-hour unlock per Google account, not per app.
You can now see who's signed in
Settings → Active sessions. Every device with a live session: what it is, when it was last used, where it signed in from, and where it's being used from now. Sign out one, or everything except the device you're holding.
The interesting column is the pair. A session that was created somewhere you recognise but is being used from somewhere you don't is what a stolen token looks like from the inside — so rather than printing two addresses and leaving you to compare them, the row says "Address changed".
And the addresses are real ones
This shipped broken, and the second half of the release is fixing it.
The first version ignored
X-Forwarded-Forwhenever the connection came from a public address — defensible in isolation, because that header is written by whoever connects. But anything publicly reachable needs a reverse proxy for TLS, and if that proxy has a public address, every session recorded the proxy. Identical origin and current address, forever, so the one signal the feature exists for could never fire. It looked like it worked and told you nothing.Admin → Integrations → Client IP detection now sets how many proxies to read through:
- 0 — no proxy; Minstrel is reached directly
- 1 — one reverse proxy, e.g. nginx, Caddy or Traefik terminating TLS
- 2 — a CDN in front of your own proxy, e.g. Cloudflare → nginx
The card is built to be checked, not just set: it shows the address your current setting resolves this very request to, alongside the raw forwarded chain and the direct connection — so you set the number, save, and confirm it matches the machine you're sitting at. It also counts the arriving chain and tells you how many proxies that implies.
It also says plainly what the setting costs, because this is a security surface and the failure mode is silent: count your proxies, don't guess high. Set above your real chain — or above 0 with no proxy at all — and a visitor can choose which address their own session shows. That's inherent to how every framework does this, which is why 0 is a real option rather than a hidden escape hatch.
Server access logs now report the same resolved address, so your logs and this card can't disagree about who connected.
Browse by genre, or by year
Two new Library tabs, and the data was already there —
genrefrom your file tags,release_dateon albums — just never navigable.Genres are ordered by track count rather than alphabetically. Raw tags carry a long tail of one-offs, and alphabetical buries the handful you actually have a library's worth of. Years are grouped by decade, because a flat list of every year in a deep library is a wall of numbers.
Album and artist pages gained matching quick-jumps: from a record you like, one click to everything else from that year or in that genre.
A bug this uncovered
Album-by-genre compared the whole genre field verbatim, while the recommendation engine has always split it on
;and,. So a track taggedRock;Popwas unreachable from eitherRockorPop. Building the new index by splitting while matching exactly would have produced genres whose pages are empty.Both now split. Which also fixes Subsonic
getAlbumList?type=byGenre— third-party clients asking for a genre had been silently missing every multi-genre track.Genres are raw, for now, on purpose
No case folding and no synonym mapping, so
Rockandrockwill appear separately, as willRock/PopbesideRockandPop. That's deliberate for this release: the honest spread has to be visible before anyone designs a taxonomy around it. You'll want to look at yours — the plan is to roll raw labels into curated categories with an editable mapping, and what your library actually contains should shape that.
Music no longer stops when your phone switches networks
Walking out of Wi-Fi range or roaming between access points could leave the app refusing to play anything not already cached, with "the Minstrel server is unreachable" — while the server was fine the whole time.
Two causes, both fixed:
A handoff fails every in-flight request at once. The server-down detector required two failures within 30 seconds as corroboration, treating a burst as two independent votes when it was one event's fallout. Failures within 3 seconds of each other now count once. A genuine outage still escalates — it just has to actually persist.
Recovery was lazy. The health poll is throttled while the screen is off, and nothing re-checked when you picked the phone back up, so a stale "unreachable" could outlive its cause by minutes. The app now probes the server the moment it returns to the foreground.
Also in this release
- Android's blanket cleartext-traffic opt-out moved into a documented network-security config. Same behaviour, but the reason is written down and there's now one place to tighten it.
- The README states up front what Minstrel is: a server for music you already have, shipping no indexers, no trackers, no torrent or Usenet client, and no DRM circumvention. The Lidarr integration is optional and points at an instance you run.
Install
- Redeploy the server image. Required — this release carries migrations 0052 and 0053, which apply automatically on restart.
- Install the attached signed APK. This one still needs installing the normal way; the silent path applies to the next update, since the app doing the installing has to be the new version.
Verify on device
CI covered all three lanes on every commit, including the real migrations. These are the parts it cannot judge:
- Take the next in-app update and see whether any dialog appears at all. Silent means it worked.
- Settings → Active sessions — do you recognise every row? Sign out something and confirm it disappears.
- Admin → Integrations → Client IP detection — does "Your address right now" match the machine you're on? If it shows your proxy, raise the number by one.
- Play an uncached track and force a network switch. It should keep playing rather than failing.
- Library → Genres — how bad is the raw spread? That answer shapes the taxonomy work.
Known and deliberate
Genres are unnormalised in this release, as above. Genres and Years are web-only for now — Android's Library has no equivalent yet, and that parity is tracked.
The client-IP setting is honest but not magic: if your proxy sits on a public address and you leave the depth at 0, you'll see the proxy. The admin card exists to make that visible in one glance rather than something you discover from a confusing session list.
🤖 Generated with Claude Code
Downloads
-
released this
2026-08-04 11:39:00 -04:00 | 62 commits to main since this releasePer-day CalVer. Client-only — no server changes, no schema migrations. Merged via PR #117 (the mark) and PR #118 (queue gestures);
mainat57d22991.This tag was re-cut. An earlier
v2026.08.04covered the mark alone atfa7ea41c. Rather than mint a second dated tag for the same day, the tag and its release were replaced so today has one release covering both changes. The superseded build remains addressable by its commit-SHA image (:fa7ea41c) if you ever need it. Installed builds carry2026.08.04.1822(old) vs2026.08.04.1827(this one) — the commit-count component distinguishes them, so in-app update still resolves correctly in the right direction.
The queue row now answers to gestures
Two operator reports, both about the same 64px of screen.
The drag handle was eating the row
A
GripVerticalglyph held its own column in every queue row — permanent horizontal cost for an affordance you use rarely. It's gone as a column.- Android: no grip at all. Long-press the row and drag to reorder.
- Web: the grip moved on top of the album art, so it costs no horizontal space at all. It stays visible at rest — quiet, but present, because it's the only thing signalling the list is reorderable, and hiding it until hover would trade a space complaint for a discoverability one (and leave nothing at all for touch, which has no hover). A scrim appears on hover/focus so the artwork stays legible the rest of the time; a drop shadow keeps the glyph readable over pale covers.
Swipe left to remove — Android
The X button is gone from the Android row. Swipe a row left and it slides off to reveal an oxblood fill with a trash glyph and the word Remove.
Right-swipe does nothing on purpose. It has no meaning here, and leaving it live would delete tracks on a gesture aimed in either direction.
The fill is the destructive action colour, not the error colour. The design system keeps those apart deliberately — an error is a failure that already happened, a destructive action is one about to happen — and dressing an intentional gesture in error red would report it as a fault.
Web keeps its X button. There's enough horizontal room there that the argument for removing it doesn't apply.
Both gestures are touch-only, so the row grew an accessibility surface
This is the part that isn't cosmetic. Reordering used to have a focusable grip labelled "Reorder track"; removal used to have a button labelled "Remove from queue". Replacing both with touch gestures would have silently removed both capabilities for anyone not using touch.
The Android row now carries three custom accessibility actions — Move up, Move down, Remove from queue — reachable through TalkBack's actions menu. They're the counterpart to the web row's ArrowUp/ArrowDown keys and its surviving X button.
Minstrel gets a mark
Minstrel had no mark.
favicon.pngwas a 1×1 pixel placeholder, so every browser tab showed the blank-page glyph, and Android shipped legacy bitmaps only — modern launchers letterboxed a square instead of masking it to the device's icon shape.The mark
A Didone M whose right leg is an eighth note: stem, flag and notehead in the forest-teal accent as one object, the letter in the text colour.
It's deliberately subject-neutral. "Minstrel" pulls hard toward a lute, a bard, a troubadour — and any of those would tell someone opening the app for the first time that this is a player for renaissance-faire music rather than for everything they own. A geometric letter plus universal notation says music without saying which music. The family look arrives through the palette and the drawing, not through the subject.
It changes with its background, because it has to
Parchment on a white surface is invisible. So the letter flips with whatever it sits on, while the note keeps the accent in both — teal holds against either:
- In the app, the mark takes the surrounding text colour, so one asset serves both light and dark themes.
- In the browser tab, an SVG favicon swaps the letter's colour with your system scheme; a tab strip is chrome the app doesn't control.
- On your home screen and in iOS, the mark sits on its own dark plate, because those composite onto backgrounds we can't predict — iOS forces white behind anything transparent.
The plate colour was picked by measurement rather than taste: the note only clears the 3:1 contrast floor for graphics against the deepest obsidian, and lightening the plate would have made it worse, not better.
Android gets a proper adaptive icon
New
mipmap-anydpi-v26foreground/background layers, with the mark inside the 66dp safe zone so no launcher mask can clip it — round, squircle, teardrop, whatever your launcher uses. It also supplies a monochrome layer, so Android 13+ themed icons tint correctly with your wallpaper instead of falling back to a generic badge.
Install
- Install the attached signed APK — carries the mark, the adaptive icon, and both queue gestures.
- Redeploy the server image to pick up the web favicon, the header mark, and the web queue's relocated grip. Optional if you only care about the Android app — this release carries no server changes.
Verify on device
CI covered lint, detekt, resource compilation, unit tests and a full debug assemble across all three lanes. This module has no instrumentation lane, so every gesture below is unverified by CI and needs a human hand:
Queue gestures (Android)
- Long-press a queue row and drag it up or down. Does it land where you dropped it? The offset→row-delta arithmetic is an untested inline copy of web's tested version (tracked as a known gap), so this is the one most worth a hard look.
- Swipe a row left. The oxblood fill and trash glyph should appear under the row as it slides, and the track should leave the queue on release. A partial swipe should snap back without removing anything.
- Swipe right. Nothing should happen.
- Both gestures on the same row, in sequence — the two shouldn't fight each other, and a row mid-reorder shouldn't slide out of its swipe container.
- TalkBack, if you use it: the row should offer Move up / Move down / Remove from queue.
Queue grip (web)
- The grip should be legible over both dark and pale album art, and keyboard-focusable with ArrowUp/ArrowDown reordering.
The mark
- Your launcher. Long-press the icon, check it isn't clipped, and if you use themed icons confirm it tints rather than falling back.
- Your tab strip. Legible at 16px among other tabs, and the letter should invert if you switch your system between light and dark.
- The header, on both themes — the letter should flip while the note stays teal.
Known and deliberate
The Android drag arithmetic is duplicated from web rather than shared, and only web's copy has tests. Extracting it to a pure function so the existing test cases can be mirrored is filed and low-priority; it predates this release.
🤖 Generated with Claude Code
Downloads
-
v2026.08.03 — Discover requests: taste-aware, rotating, snoozable, tag-targeted
Stabletest-web / test (push) Successful in 1m5stest-go / test (push) Successful in 1m30sandroid / Build + lint + test (push) Successful in 5m1stest-go / integration (push) Successful in 5m29srelease / Build signed APK (tag releases only) (push) Successful in 4m21srelease / Build + push container image (push) Successful in 17sreleased this
2026-08-03 08:46:50 -04:00 | 69 commits to main since this releasePer-day CalVer. Milestone #268 — the Discover request surface, rebuilt. Server + web + Android, with schema migrations. Merged via PR #116;
mainCI green on324059b2(Go, web, Android incl. debug assemble, and the image build).The Discover request surface was the last recommendation surface still running its early-May M5c implementation. Reported symptoms: "shows the same artists until you 'request' one" and "once it has a strong signal of your taste seems to go stale." Both were real, with separate causes — and the second got worse the more you listened, by construction.
Why it went stale
Four mechanisms, all fixed:
- No rotation. A deterministic top-N over a score ordering shows the same faces every day until one is requested away — the tail of the ranking was simply unreachable.
- Unbounded signal.
signal = 5×liked + Σexp(-age/halflife)grew without limit, so a few heavily-played artists monopolised every slot and entrenched harder as listening accumulated. - Skips counted as engagement. No
was_skippedfilter, so skipping an artist repeatedly increased its signal and pushed more of its neighbours at you. - No diversity cap. All twelve slots could be neighbours of one artist.
Seeded from your taste profile, not raw play counts
Seeds now come from
taste_profile_artists.weight— already decayed, damped and signed, so an artist you've drifted away from stops contributing instead of accumulating forever, and can contribute negatively. Log-damped so one heavy artist can't monopolise. Skips no longer read as affinity.While the profile is still empty (new account, or before the first nightly recompute) it falls back to likes + non-skipped plays, so the surface never goes blank.
The deck rotates daily
Top scorers always lead; the remaining slots rotate on
md5(mbid || date)— stable within a day so pull-to-refresh doesn't reshuffle under you, different tomorrow, and it needs no stored state. A per-seed cap stops one artist owning the deck, with a floor: diversity is a preference with score-order top-up, never a quota that leaves you with a three-card surface."Not right now" — a snooze, not a dislike
You can park a suggestion for ~90 days. It expires on its own, records no verdict on the music, and never touches your taste profile — this is acquisition triage, not negative feedback. That's enforced structurally, not by convention: the filter sits at the candidate stage rather than in the score, because a scoring term is exactly where it would leak.
Both clients flip the card in place with an Undo rather than yanking it out from under your finger, and keep a "Not right now" list below the deck with a return estimate — that list is the only route back once a card has left the deck, so it isn't optional garnish. On Android the write goes through the offline queue, modelled so a snooze queued while offline can't replay after you've undone it.
It can now tell you why
Cards say "Matches your taste in shoegaze and dream pop." when the artist's own tags overlap your taste profile — describing the music rather than the graph ("adjacent to something you played").
This needed a tag cache for artists you don't own (
track_tagscan't serve it — it's keyed to tracks you have). A background worker fills it strongest-candidates-first, because the candidate pool is far larger than MusicBrainz's ~1 req/s can ever fully cover. Expect this to be sparse at first and to fill in over days. Coverage will always be partial — obscure artists stay thin — so an untagged candidate is scored exactly as before rather than pushed down.New: Admin → Tuning → "Discover requests"
Two DB-backed knobs, no restart:
- Taste-tag weight — how strongly a tag match boosts a candidate.
score × (1 + w × overlap). Setting it to 0 turns the tag term off entirely and returns the deck to similarity-only ranking. - Snooze length — how long "not right now" parks something.
Its own scope rather than part of the taste card, deliberately: snooze length filed under "taste" would sit one careless join away from the leak described above.
Also fixed
- Generated database code was never verified against its SQL sources (#2380). 307 queries, ~12k lines of committed codegen, and nothing checked it still matched. CI now verifies it before anything else runs — and it has already caught two real drifts that the integration tests passed straight through. Passing tests against a real database proves the SQL is valid, not that the committed Go matches it.
- That check was blind to newly added files (#2382) —
git diffignores untracked paths, so a commit that added a query and forgot its generated file would have passed. This release contains the first commit that would have tripped it. - "in about a month" was unreachable in both clients (#2392) — a threshold sitting above its own divisor made the branch dead code. Found by the unit test written for it.
Install
- Redeploy the server image — this release carries server changes and migrations 0049, 0050, 0051, which apply automatically on restart. 0051 also widens the tuning-audit scope constraint (same migration, per policy).
- Install the attached signed APK — the Android client carries the snooze affordance and the tag attribution.
Web needs nothing beyond the server redeploy.
Verify
CI covered all three lanes on every commit. These are the parts it cannot sign off:
- Come back tomorrow. Does the deck actually change without you requesting anything? That's symptom one.
- Over the next weeks, does it stop narrowing as your listening concentrates? That's symptom two, and it's the one that used to get worse with use.
- Snooze a suggestion. The card should flip in place with an Undo, not vanish under your finger — then appear in "Not right now" below with a return estimate. Undo from either place.
- Tag attribution. Watch for "Matches your taste in …" appearing on more cards over the coming days as the enricher works through the backlog. Sparse at first is expected, not a fault.
- Admin → Tuning → Discover requests. Set taste-tag weight to 0, refresh Discover: the ordering should visibly revert to similarity-only. Reset to defaults to restore.
Known and deliberate
Tag coverage for out-of-library artists is permanently partial — it depends on the artist having an MBID and on MusicBrainz/Last.fm actually having tags for them. The ranking is built so that costs an untagged candidate nothing rather than penalising it, and the admin card's copy says so. Coverage counts are queryable server-side if you want to see the shape of it.
🤖 Generated with Claude Code
Downloads
-
released this
2026-07-31 23:38:11 -04:00 | 87 commits to main since this releasePer-day CalVer. Android-only — no server or web changes, no schema migrations.
mainCI green on0cea8298(lint + detekt + unit tests + signed-debug assemble, plus the image build). Merged via PR #114; closes issue #2327.The "Updating your mixes…" veil from v2026.07.14 was reported as "swipes on and off before the update completes in most cases", with "a number of other times where the contents will visibly update without the veil triggering." This is that, fixed at the root rather than re-tuned.
Most of the churn no longer happens at all
The veil was papering over a data-layer bug.
replaceSectionwas delete-then-insert, per section and un-transacted, so the observing Flow genuinely emittedemptyList()— a real, visible collapse — before the new ids landed, and did it seven times in sequence. Even unchanged tiles flickered.- One
@Transactionswap across all seven sections. Room notifies observers only on commit, so the empty gap is never observed and Home swaps in a single emission. (CachedQuarantineDaoalready carried this exact fix for this exact reason; Home never got it.) distinctUntilChangedon the id list ahead offlatMapLatest, so a section whose contents didn't move no longer tears down and rebuilds every tile's hydration flow.
The veil is now driven by the screen, not a stopwatch
Three separate reasons it used to lower early: the old
refresh().join() + 500msmeasured the network pull, which finishes long before the visible work (ids land → tiles hydrate → artwork loads); a plainisUpdatingBoolean cleared in afinallygot cleared by whichever of two overlapping refreshes finished first (reconnect and the 03:00 rebuild routinely arrive together); and failures were swallowed, so a failed pull reported success with no retry.It now raises, works (retrying behind the veil), then holds until the content has been still for a quiet window and nothing is still loading — with a floor so it can't flash and a hard ceiling so it can never strand you. The ceiling caps the veil, never the refresh.
It only appears when something actually changed
A refresh that returns what's already cached raises nothing, so a launch over a warm cache is immediate — no veil hiding an update that isn't happening.
Artwork no longer pops in after the veil lifts
Art was the most visible offender and the refresh coroutine can't see it, so the composition reports upward:
ServerImage— the single choke point behind every album, artist and playlist cover — counts its in-flight loads into a tracker the veil waits on. Art also crossfades app-wide now, with the placeholder fading out over the same window, which softens the pop everywhere the veil isn't involved.And it tells you when nothing changed
Because an unchanged refresh raises no veil, a pull that found nothing new would otherwise give no response at all. A pull now always produces exactly one piece of feedback: the veil if something changed, "Already up to date" if not, "Couldn't check for updates" if it couldn't reach the server. Only for refreshes you asked for — background checks stay silent rather than toasting on every launch.
Also veiled now: library-scan completion (Home never reacted to it before), playlist create/update/delete/tracks-changed, manual pull, and the initial load over a warm cache.
Install
Install the attached signed APK. Redeploying the server image is optional — it carries no changes in this release; it only matters for offering the new APK to other devices via in-app update.
Verify on device
This release is almost entirely about feel, which is the part CI cannot sign off:
- Open the app on a warm cache → content appears immediately. No veil, no sections collapsing and refilling.
- Pull to refresh with nothing new → "Already up to date".
- Pull to refresh after the 03:00 rebuild → the veil wipes on and holds until tiles and their artwork have landed, then wipes off. Nothing should pop in after it lifts.
- Pull with the server unreachable → "Couldn't check for updates" after it retries; your cached content stays put.
- First run / cleared cache → skeleton as before, no veil over an empty screen.
Known and deliberate
The background freshness sweeper still re-fetches stale metadata, so a single tile can change unannounced. Left unveiled on purpose — raising a full-screen panel for one tile would be worse than the pop, and the new crossfade softens it. Worth revisiting only if it's noticeable in use.
🤖 Generated with Claude Code
Downloads
- One
-
released this
2026-07-23 08:38:10 -04:00 | 95 commits to main since this releasePer-day CalVer. A web playback-blocking fix plus a batch of cross-client queue ("up next") improvements.
mainCI green ona26ef4e9(all three lanes, incl. the signed debug APK assemble).Fix — web: clicking play did nothing (#1928)
QueueDrawerwas rendered outsideQueryClientProvider. Its always-mounted<aside>renders a row per queued track, and the queue like-heart (added in #1596) callsuseQueryClient()at init — so the instant the queue populated on first play, it threw "No QueryClient was found in Svelte context" and aborted the reactive flush that starts playback. The play button appeared dead and the console logged the error. Moved the drawer inside the provider.CI didn't catch it because
QueueDrawer.test.tsmocks the likes API, which hid the real provider dependency.Queue enhancements (web + Android)
- Opens on the now-playing track — the queue no longer starts at the top when the current song is further down (#1929 Android, #1931 web).
- Auto-follow + "Jump to current" — the queue keeps the playing row in view as the track auto-advances, but only while you're watching it; scroll away to browse and a Jump to current pill brings you back.
- Album-art thumbnails in every queue row.
- Clear-queue action.
- Android drag-to-reorder + per-row remove — closes the functional gap with the web queue (which already had both), plus a header showing track count + total time.
Verify
- Web: open a system playlist (e.g. Discover) and press play → it plays, no console error. Open the queue → it's scrolled to the current track; let it advance → it follows; scroll up → the "Jump to current" pill appears.
- Android: open the Queue from the player → it's positioned on the now-playing track, rows show artwork; drag the grip handle to reorder; tap ✕ to remove; the clear-queue action empties it. (The reorder/pill are new touch interactions — worth a quick real-device sanity check.)
🤖 Generated with Claude Code
Downloads
-
released this
2026-07-15 19:17:49 -04:00 | 105 commits to main since this releasePer-day CalVer, re-cut forward to
47de7be4(PR #110 + notification-skip follow-up PR #111). Android UPnP/Sonos playback-state rework + a cross-client queue like button.Install this APK. The cursor sync + no-flicker behaviour is device-confirmed; this re-cut adds the notification next/prev fix — please re-verify the transport controls.
UPnP/Sonos — one authoritative "what's playing" (Android, #171)
While casting, the local ExoPlayer cursor and the Sonos renderer were two competing sources of truth. The local cursor lagged (forward-only, index-based sync switched off during every queue-load / re-cast window) and two writers of the current-track index fought — so the in-app player flickered to the pre-cast track on reopen and the notification metadata went stale.
The fix completes
MinstrelForwardingPlayerinto a single source of truth:- Identity-locked cursor — the paused local cursor now tracks the song the renderer is actually playing, matched by track identity (the id in the Sonos
TrackURIvs the localMediaItem.mediaId), not the raw index. Survives queue-reload index wobble; moves both directions. ✅ device-confirmed: stays in sync, no flicker. - One writer — the position tick only moves the scrubber; the current track/index is owned solely by the player-event path. The stale-tick stomp is gone.
- Handoff for free — ending the cast resumes local playback on the correct track + position.
- Notification transport now controls Sonos — the system next/prev buttons issue
COMMAND_SEEK_TO_NEXT/COMMAND_SEEK_TO_PREVIOUS→Player.seekToNext()/seekToPrevious(), which are distinct from theseekTo*MediaItem()methods the in-app buttons use. Those two were un-overridden, so notification next/prev nudged the paused local player instead of Sonos (they read as dead — play/pause worked because it was routed). Now overridden to route to Sonos while casting. (new in this re-cut)
Resolves the index-drift half of #1211; builds on the June UPnP foundation (#606 / #608 / #612).
Verify on device
- Sync / no flicker — already confirmed. ✅
- Notification/lock-screen transport — while casting, the next and previous buttons (and play/pause) should now drive Sonos.
- Handoff — end the cast mid-queue → local resumes on the same track + position.
Queue like button (web + Android, #1596)
The queue ("up next") rows in the full-screen player were the only track-list surface without a like heart. Added the shared like button to the web queue rows (
QueueTrackRow— covers the/now-playingside panel and the mobile queue drawer) and the Android "Queue" screen rows. Liked state stays sourced from the shared store by track id — no data-model change.🤖 Generated with Claude Code
Downloads
- Identity-locked cursor — the paused local cursor now tracks the song the renderer is actually playing, matched by track identity (the id in the Sonos