Everything on dev since #135. CI green on the head, b28cbe06 (run 8463).
Loudness leveling (M464)
#4998 Per-user leveling preference, synced across devices: off/track/album/auto, target −18/−16/−14 LUFS, headroom or limiter.
#4999 Web player levels by it. Cuts use element volume; boosts go through Web Audio.
#5000 Android levels with a gain processor in the audio sink (Media3 1.11). Room migration 9→10 adds the gain columns and resets the sync cursor, so the first sync after updating is a full one.
#5001 Server leveled FLAC stream for Sonos/UPnP: rendered to a cache (admin "Speaker cache (MB)"), Range, tags stripped. Migration 0068.
#5002 The Android Sonos queue plays leveled URLs and renders the next track ahead (2 at a time at most).
Fixes
#5139 MBID backfills skip tracks whose files are missing. Stops the "open failed" flood on every scan.
The Android app refuses plain http:// to the Minstrel server when the connection lands on a public address. LAN, Tailscale and UPnP are unchanged. Anyone signing in over plain http to a public address must switch to https://.
Live checks are in reviews #5076 and #5161 (dated).
Everything on dev since #135. CI green on the head, b28cbe06 (run 8463).
**Loudness leveling (M464)**
- #4998 Per-user leveling preference, synced across devices: off/track/album/auto, target −18/−16/−14 LUFS, headroom or limiter.
- #4999 Web player levels by it. Cuts use element volume; boosts go through Web Audio.
- #5000 Android levels with a gain processor in the audio sink (Media3 1.11). Room migration 9→10 adds the gain columns and resets the sync cursor, so the first sync after updating is a full one.
- #5001 Server leveled FLAC stream for Sonos/UPnP: rendered to a cache (admin "Speaker cache (MB)"), Range, tags stripped. Migration 0068.
- #5002 The Android Sonos queue plays leveled URLs and renders the next track ahead (2 at a time at most).
**Fixes**
- #5139 MBID backfills skip tracks whose files are missing. Stops the "open failed" flood on every scan.
- Integration suite gets a 20m package timeout.
**APK distribution (family idea #5103, #5116)**
- The release build never falls back to the debug key; main no longer uploads a debug APK.
- CI checks the APK's signer against the pinned release certificate digest and blocks the publish otherwise.
- Debug builds don't offer server updates.
**Security baseline (family idea #5105, #5111)**
- The Android app refuses plain `http://` to the Minstrel server when the connection lands on a public address. LAN, Tailscale and UPnP are unchanged. Anyone signing in over plain http to a public address must switch to https://.
Live checks are in reviews #5076 and #5161 (dated).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Mode (off, auto, track, album), target (-18, -16, -14 LUFS) and boost
(within headroom, or fully with a limiter), stored per user on the server
so the web player, the Android app and casts apply the same one.
- Server: user_normalization_prefs (migration 0067), GET/PUT
/api/me/normalization; a whole-body PUT, validated, last write wins.
- Web: Settings > Playback > Volume leveling. Saves at once, restores the
old choice if the save fails, and caches the value for the player.
- Android: Settings card. The device keeps a copy for offline playback
(Room v9 with an explicit migration, so the upgrade wipes nothing).
Writes are offline-first: shown at once, PUT best effort, queued on
failure (NORMALIZATION_SET, collapsed to the newest). A refresh never
overwrites a change still queued.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ListenBrainz settings test finds the page's one checkbox, and the
boost control is a toggle anyway. detekt counts MutationQueue's enqueue
functions; suppressed as the replayer's dispatchers already are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
internal/api takes ~6.5 min under -race on an idle runner; with a second
run on the same runner it crossed go test's default 10m (run 8368: FAIL
at 600.016s with the running test 2s old, so load, not a hang).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cuts go through element.volume. Boosts route the element through a Web
Audio GainNode and a DynamicsCompressor (a -1 dBFS limiter in limiter
mode, a pass-through otherwise), built only when a track wants a boost
and only once an AudioContext is confirmed running; iOS never gets the
graph. Auto mode takes album gain when a queue neighbour is from the
same album in track order. Gains are fetched for the next 50 tracks as
the queue moves, with a 10s deadline. The prefetch element is untouched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Media3 1.10.1 -> 1.11.0, the version Renovate proposes; 1.11 flushes the
sink's audio processors at every item boundary with the playlist timeline
and the new item's period. GainAudioProcessor uses that to find the track
and its play-order neighbours (auto mode's album rule) and applies the
gain from the first sample, gapless transitions included, with a -1 dBFS
peak limiter in limiter mode and a full-scale clamp otherwise.
Gains come from the library cache first (sync now carries track and album
ReplayGain values; Room v10 adds the columns and rewinds the sync cursor
so an existing cache re-pulls them), then GET /api/tracks/replay-gain,
then none. The player service refreshes the leveling preference at start.
Web: a same-album neighbour without a track number no longer counts as
in-order album play, matching Android.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`id in map` on a ConcurrentHashMap resolves to its legacy contains(),
which tests values (KT-18053); the compiler refuses it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Speakers fetch their own audio, so the phone cannot level it. A cast
token minted with level=true (and the client's asAlbum, which only the
queue holder knows) now returns GET /api/tracks/{id}/leveled.flac: the
track rendered by ffmpeg at the user's gain (volume=XdB, plus
alimiter at -1 dBFS for a limiter-mode boost), metadata stripped, FLAC
at 16 or 24 bits and at most 48 kHz. The gain is computed server-side
from the user's preference and the stored loudness, carried as
?g=<centi-dB>&lim=0|1 and signed into the token, so an edited URL does
not verify. Unity gains get the plain stream.
Renders are written beside the cache file and renamed in, keyed by the
source's size and mtime, coalesced per file (singleflight, detached
from the requesting speaker so a retry finds the render running),
started at mint time so the fetch finds them ready, and evicted least
recently used past leveled_cache_mb, a new admin setting (migration
0068, Loudness analysis card).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every URL the Sonos queue loader sends is minted with level=true and
the track's album-play verdict from its neighbours in the queue; the
server returns the plain stream when leveling is off or changes
nothing. The playing track and the one after it are rendered ahead,
and each time the renderer moves on, the next is.
Server: a mint no longer prerenders on its own. A queue load mints
every track, which would have started an ffmpeg render per track at
once. The request now carries prerender, and at most two prerenders
run at a time; past that they are dropped, since a fetch renders on
demand anyway.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The track backfill listed every track with a NULL mbid, missing or
not, so each scan tried to open every missing file and logged an
"open failed" warning per track. Nothing ever healed. The album
backfill could pick a missing track as the one to read, and since
that pass is capped per scan, albums stuck that way were retried
ahead of the rest every time.
Both now read only tracks still on disk; an album with none left is
skipped until a scan finds its files again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adopts the rest of family idea #5103 (distributing your own APK):
- Practice 2: build.gradle.kts no longer falls back to the debug key
when ANDROID_KEYSTORE_PATH is unset; the release build is signed with
the release key or left unsigned. Main no longer builds and uploads a
debug-signed app-debug.apk, which no install could ever update.
- Practice 3: android-release runs apksigner on the built APK, prints
the signer's DN and SHA-256 digest, and fails on a debug signer.
An unsigned build fails the same step, since there is no
app-release.apk to verify.
- Practice 9: debug builds offer no server update. The banner does not
poll and the About card says updates come from Android Studio, since
the release-signed APK cannot install over a debug-signed app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The check failed only on a debug signer, so an APK signed by any other
wrong key (a regenerated keystore, a swapped secret) would publish and
then reach no installed phone: Android updates in place only when the
signer matches. The step now requires exactly one signer whose
SHA-256 digest is the release certificate's (CN=Minstrel,
O=FabledSword, read from run 8446), and names a debug key or the
digest it got when it fails. Rotating the key on purpose changes the
digest in the same commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cleartext stays permitted app-wide for LAN servers and UPnP (#2439),
but a password or session cookie sent over plain HTTP to a public
address can be read by anyone on the path. A network interceptor now
refuses a cleartext request to the Minstrel server when the connection
lands on a public address, before any request byte is written.
Checked per connection, on the address actually reached, rather than
when the URL is typed: a name that resolved to the home network at
entry resolves to a public address once the phone leaves home.
Allowed: loopback, 10/8, 172.16/12, 192.168/16, link-local, 100.64/10
(Tailscale and other overlay VPNs) and fc00::/7. Only requests
BaseUrlInterceptor tagged as server-bound are checked; external
fetches and UPnP are untouched. The refusal has its own message.
Family baseline #5105, practice 13.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Everything on dev since #135. CI green on the head,
b28cbe06(run 8463).Loudness leveling (M464)
Fixes
APK distribution (family idea #5103, #5116)
Security baseline (family idea #5105, #5111)
http://to the Minstrel server when the connection lands on a public address. LAN, Tailscale and UPnP are unchanged. Anyone signing in over plain http to a public address must switch to https://.Live checks are in reviews #5076 and #5161 (dated).
🤖 Generated with Claude Code
Speakers fetch their own audio, so the phone cannot level it. A cast token minted with level=true (and the client's asAlbum, which only the queue holder knows) now returns GET /api/tracks/{id}/leveled.flac: the track rendered by ffmpeg at the user's gain (volume=XdB, plus alimiter at -1 dBFS for a limiter-mode boost), metadata stripped, FLAC at 16 or 24 bits and at most 48 kHz. The gain is computed server-side from the user's preference and the stored loudness, carried as ?g=<centi-dB>&lim=0|1 and signed into the token, so an edited URL does not verify. Unity gains get the plain stream. Renders are written beside the cache file and renamed in, keyed by the source's size and mtime, coalesced per file (singleflight, detached from the requesting speaker so a retry finds the render running), started at mint time so the fetch finds them ready, and evicted least recently used past leveled_cache_mb, a new admin setting (migration 0068, Loudness analysis card). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>