• v2026.08.05 — quiet updates, who's signed in, and browsing by genre or year
    test-web / test (push) Successful in 1m3s
    test-go / test (push) Successful in 1m13s
    test-go / integration (push) Successful in 5m29s
    android / Build + lint + test (push) Successful in 5m34s
    release / Build signed APK (tag releases only) (push) Successful in 5m5s
    release / Build + push container image (push) Successful in 16s
    Stable

    bvandeusen released this 2026-08-05 15:15:51 -04:00 | 0 commits to main since this release

    Per-day CalVer. Server + web + Android, with schema migrations 0052 and 0053. Merged via PR #119; main at 1b7fa635.

    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

    ApkInstaller used ACTION_VIEW with an APK MIME type — the deprecated dialect, and the exact shape a malware dropper uses. It's replaced by a PackageInstaller session declaring USER_ACTION_NOT_REQUIRED, with UPDATE_PACKAGES_WITHOUT_USER_ACTION in 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_PACKAGES too. 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-For whenever 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 — genre from your file tags, release_date on 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 tagged Rock;Pop was unreachable from either Rock or Pop. 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 Rock and rock will appear separately, as will Rock/Pop beside Rock and Pop. 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

    1. Redeploy the server image. Required — this release carries migrations 0052 and 0053, which apply automatically on restart.
    2. 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