-
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 | 0 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