-
v2026.10.26.1520
Stablerelease / web (push) Successful in 1m29srelease / go (push) Successful in 1m49srelease / govulncheck (push) Successful in 26srelease / android (push) Successful in 6m19srelease / integration (push) Successful in 6m47srelease / Build signed APK (releases and dev) (push) Successful in 6m17srelease / Attach APK to the Release (tag releases only) (push) Successful in 3srelease / Build + push container image (push) Successful in 23srelease / Verify release artifacts (tag releases only) (push) Successful in 2sreleased this
2026-10-26 12:47:56 -04:00 | 2 commits to main since this releaseEverything merged in #156.
Android app
- Any UPnP/DLNA renderer plays, not only a Sonos.
- Casting no longer sticks on "loading" when the same queue plays again.
- A newer app is announced in a snackbar on Home and installed from a sheet, downloaded ahead on Wi-Fi. An update is offered only when the app itself changed.
- Liked songs and Recently played keep their own offline budgets; liking a track downloads it on Wi-Fi.
Server and web
- Minstrel searches, grabs, imports and verifies downloads itself, without Lidarr.
- Duplicates settle themselves; AcoustID names recordings in the wrong slot.
- Collection replaces Wanted; Admin and Settings are left-rail pages.
- Notifications say what changed and alert once.
Downloads
-
v2026.09.11.0329
Stabletest-web / test (push) Successful in 1m5stest-go / test (push) Successful in 1m33stest-go / integration (push) Successful in 4m25sandroid / Build + lint + test (push) Successful in 5m33srelease / Build signed APK (releases and dev) (push) Successful in 4m38srelease / Build + push container image (push) Successful in 16srelease / Verify release artifacts (tag releases only) (push) Successful in 2sreleased this
2026-09-10 23:44:15 -04:00 | 248 commits to main since this releaseRecommendations that stay where you put them, a rollback unit, and a version that names what shipped.
Your mixes should feel tighter
"Songs like X" stopped wandering. It shared a scoring profile with For You, and those two want opposite things — For You roams by design, Songs-like should not. Under the shared weights, an unrelated track you'd liked and hadn't played recently outranked a perfect similarity match you hadn't liked. Liking something beat sounding like the seed.
It now has its own profile where similarity outweighs everything not related to the seed. The same pair that scored 2.50 against 5.50 now scores 5.00 against 2.00.
Songs-like can include the seed artist's own music. It used to filter it out, which threw away the tracks most likely to sound like the seed and then reached further away to replace them. It's bounded at 3 of 25 by the diversity cap rather than excluded.
Radio no longer collapses onto one artist. There was no per-artist cap in the radio path at all, while every comparable surface had one. The caps scale with the length you ask for, and they change which tracks are picked rather than how many — a radio never comes back short because the cap couldn't be satisfied.
The candidate pool scales with your library. It was a fixed ~170 candidates whether you had 500 tracks or 100,000, so it sampled a shrinking fraction of a growing collection. It now grows with the library, per-arm, bounded.
These are starting values rather than measured ones. Everything above is adjustable in Admin → Tuning with no restart; if Songs-like now feels too narrow, lower its Similarity weight.
Image tags changed — read this if you pin one
:latest production; tracks main's tip :<commit-sha> NEW — immutable, the rollback unit :dev the rolling test channel:vYYYY.MM.DD.HHMMand:mainare no longer published. Existing version-tagged images still work and are untouched; they simply aren't extended. If you pinned one, move to a:<commit-sha>— everymainpush publishes one now, so any production commit is addressable without waiting for a release.Rolling back to
:<commit-sha>pins the server, 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 then.Also
- A release tag now names the last commit that actually shipped something — a CI-only or docs-only commit no longer moves the version.
- The build context shrank by half (42.7 MB → 21.8 MB);
.dockerignorehad been excluding CI directories this repo doesn't have while missing the one it does. - Two Moe Shop files in
WWW (2020)are the same audio under different tags — a library-content issue, not a player one, if you'd noticed something odd there.
Upgrading
Nothing to do. Migration 0057 runs at startup. System mixes rebuild on their next daily cycle, so the tuning above takes effect then rather than instantly.
🤖 Generated with Claude Code
Downloads
-
v2026.09.10.1449
Stabletest-go / test (push) Successful in 59stest-go / integration (push) Successful in 3m1srelease / Build signed APK (releases and dev) (push) Successful in 4m41srelease / Build + push container image (push) Successful in 14srelease / Verify release artifacts (tag releases only) (push) Successful in 2sreleased this
2026-09-10 10:55:19 -04:00 | 260 commits to main since this releaseThe versioning rework, and the first release under the new tag shape.
Versions are three values now, not one
A build reports a name, an ordering key, and a channel, and they answer different questions:
name—YYYY.MM.DD.HHMM, derived from the commit's timestamp. A label for people. Every lane that builds a given commit reports the same name.code— minutes since 2020-01-01 at build time. This is the value Android itself installs by, so it is the value the update check decides on. Monotonic by construction; a commit count was not.channel— a sibling field, never a suffix inside the name.
The Android client now decides whether an update is available on the ordering key rather than the displayed name, so an offer it makes is one the platform will actually accept. When a server predates the field, it falls back to comparing names — the behaviour those builds already had.
The derivation moved out of workflow YAML into
ci/version.sh, which means it is executable and guarded on every push that touches release machinery. It previously could not be tested until a release was already under way, and its failure mode is silence: an update nobody is offered looks exactly like being up to date.A dev channel
:devis rebuilt on every push todevand carries its own freshly-built, identically-signed APK. Testing no longer requires cutting a release. It rolls constantly, has no per-commit tag to pin, and its only recovery path is forward.Tag shape
Release tags are now
vYYYY.MM.DD.HHMM— the build's own version name with avin front, derived from the tagged commit. Two releases can never collide and a re-cut is simply a new tag. Tags are never moved or deleted.Releases before today use the older per-day
vYYYY.MM.DDshape. Those tags still exist and still work; they are simply not extended.Fixes
- Miniplayer content sat at the top of its bar instead of filling it, leaving a gap at the bottom.
- Rebundle step died at a variable assignment when its
grepmatched nothing. The runner invokesshell: bashwith-o pipefail, so a command substitution whose grep finds nothing is fatal at the assignment — before reaching theifwritten to handle the empty case. Every "degrades gracefully" branch in that step was unreachable. Caught by the firstmainbuild after the rework, when the newest release predated sidecar assets. - versionCode ceiling — a build clock set far in the future would emit a key above
int32, which the platform refuses and which would permanently outrank every real build that followed. Refused at the source instead.
Upgrading
Nothing to do.
:latestand:vYYYY.MM.DD.HHMMboth bundle a signed APK, so the in-app update channel stays live. Stable and dev builds share a signing key, so a phone can move between channels without uninstalling.🤖 Generated with Claude Code
Downloads
-
v2026.09.09 — a new mark, and both clients stop borrowing their fonts
Stabletest-web / test (push) Successful in 54sandroid / Build + lint + test (push) Successful in 5m36srelease / Build signed APK (tag releases only) (push) Successful in 4m36srelease / Build + push container image (push) Successful in 24srelease / Verify release artifacts (tag releases only) (push) Successful in 2sreleased this
2026-09-09 15:36:09 -04:00 | 271 commits to main since this releasePer-day CalVer. Server + web + Android, and there is no schema migration this time. Merged via PR #128;
mainat237380b1, CI green across every lane.Five commits. One of them changes what the app looks like; the other four fix a thing that was quietly broken everywhere the app runs without internet.
The mark
Minstrel had a placeholder: a Didone M whose right leg was an eighth note. It is now a feathered bard's hat with an arc of notes rising over it, traced from your artwork at 99.74% IoU.
The hat takes the surrounding text colour and the note arc carries the accent. That split is not decoration — it is the whole mechanism. Parchment on a light surface is invisible, so the silhouette has to flip with whatever it sits on, and only the accent stays constant because teal reads against both.
This deliberately overrides the design system. Minstrel's own record argued the mark had to be subject-neutral by requirement — that "Minstrel" pulls toward a lute, a bard, a troubadour, and depicting one tells a new user this is a player for renaissance-faire music. It had rejected a hat twice on exactly those grounds. That objection was put on the table before anything was traced, and you overruled it. Both design-system records were rewritten to match rather than left contradicting what ships, and the house style's account of that test now says plainly that it is an argument the app's owner may overrule — what it forbids is failing it by accident.
Two measured decisions came out of it. The whole-mark-in-accent and hat-in-accent treatments were both rejected because
#4A6B5Cmeasures 2.70:1 on the raised-surface iron, under the 3:1 floor for graphics — an accent-filled hat goes out of contrast the moment it sits on a card. And the source artwork's own deep-forest green is not a Minstrel colour; it was the reference image's background, and it appears nowhere.The favicon is a different drawing
The hat carries far more detail than the M did, and it stops resolving below about 32px. The header lockup was sized up from 20px to 28px to accommodate it. A browser tab cannot be sized up — it renders at 16px and does not negotiate, and there the mark was an unreadable blob.
Deriving a smaller version from the traced art does not work, and every mechanical route was tried first: filling the interior holes, morphological smoothing, dropping components. All of them preserve the overall silhouette, and the overall silhouette — dominated by a long diagonal plume — is exactly what fails. Each attempt produced a diagonal smear that reads as no object at all.
So the small mark is drawn, not shrunk: a strong horizontal brim under a crown that peaks left of centre, a band slit so the two do not fuse into one lump, and a short plume. Same lean and proportions as the full mark, with the detail removed rather than minified. The favicon uses it; every larger consumer keeps the real thing.
Both clients were borrowing their fonts
This is the part that was actually broken.
The web linked its stylesheet from
fonts.googleapis.com. Android resolved all three families through the Play Services font provider. Both fetch over the network, at runtime, on first use.A deployed instance is not guaranteed a network. Those requests never arrive, and the entire UI renders in fallback faces — Georgia standing in for Fraunces on the web, the platform default on the phone. Nothing errors. It reads as a styling regression rather than a missing dependency.
It survived this long because it is invisible while developing: this machine has internet, the fonts load, everything looks correct.
Android carried a second failure on top of that one. The Play Services font provider is absent entirely on devices without Play Services — so on those, text fell back to the system face regardless of how good the network was.
All three families are now vendored and served from the app's own origin: 32 subsetted
woff2for the web (embedded in the Go binary) and 5 static TTFs on Android. The runtime path is removed rather than kept as a fallback — theui-text-google-fontsdependency, its catalogue entry and the provider certificate hashes are all gone. Fraunces, Inter and JetBrains Mono are all SIL Open Font License, which is what makes bundling them the operator's call rather than a question.Two things about fetching fonts turned out to fail by succeeding, and both are now asserted rather than trusted:
Google picks the format from your User-Agent, with no parameter to ask for one. A modern browser string returns
woff2, which Android'sres/fontcannot load at all. The obvious fix — pretend to be an old browser — returns EOT, an Internet Explorer format that downloads happily, has an entirely plausible file size, and is useless here. It took checking magic bytes to notice.And the endpoint silently collapses a multi-weight request to 400 for older clients. Ask for Medium, get Regular: a perfectly valid font file that renders at the wrong weight everywhere, forever.
OS/2 usWeightClassis the only field that tells the two apart, so each weight is now fetched separately and verified against what the code declares beside it.Both clients have guards now, and both were falsified against the broken versions before being committed — the Android one reads its expectations out of
Typography.ktitself, so adding a typeface without vendoring it fails the build.
Install
No migration this time. Redeploy the server image.
Update the Android app. The bundled typefaces and the new launcher icon only arrive with this APK — a
mainpush moves:latestbut does not build one, so nothing before this release carried them.What to look at afterwards
- The header, and the browser tab. The tab icon is the separate small drawing; it should read as a hat at 16px rather than a smudge.
- The launcher icon, including a themed/monochrome launcher if you use one — the mark's base layer doubles as the monochrome version.
- Switch the web UI to light mode. The mark should flip to dark ink while the note arc stays teal. If the hat vanishes into the background, that is the exact bug the construction exists to prevent.
- An instance with no internet, if you have one handy. Headings should be Fraunces, not Georgia. That is the whole point of this release and the only place the old bug was ever visible.
Known, and deliberate
The mark fails the house style's two-second test on purpose. It is recorded that way in both design-system records, with the argument against it kept intact rather than deleted.
The favicon is not a scaled copy of the app icon. It is a separate drawing, so the two are family members rather than identical — that is the intended result, not drift.
The APK grew by about 0.86 MB, which is the cost of not depending on Play Services to draw text.
The Android font guard has never been falsified locally — there is no Gradle run on this workstation, so its predicates were verified by mirroring them against the real files, and CI was the first thing to actually compile it. It passes, but it has only ever been proven to pass, not watched to fail in place.
🤖 Generated with Claude Code
Downloads
-
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 | 277 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 | 306 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 | 309 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 | 320 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 | 323 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 | 339 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