M464 loudness leveling (steps 4–8), APK distribution and security baseline adoptions #136

Merged
bvandeusen merged 12 commits from dev into main 2026-10-06 23:11:36 -04:00
12 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 b28cbe0600 feat(android): refuse plain http:// to a public server address (#5111)
release / go (push) Successful in 1m47s
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m15s
release / integration (push) Successful in 4m44s
release / android (push) Successful in 5m37s
release / Build signed APK (releases and dev) (push) Successful in 5m43s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m13s
release / Verify release artifacts (tag releases only) (push) Skipped
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>
2026-10-06 23:02:07 -04:00
bvandeusenandClaude Opus 5.5 40dd5bb52c ci(android): pin the release certificate in the signer check (#5116)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m25s
release / android (push) Successful in 6m25s
release / Build signed APK (releases and dev) (push) Successful in 7m0s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 1m3s
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>
2026-10-06 22:51:33 -04:00
bvandeusenandClaude Opus 5.5 a1e9de2c84 ci(android): never ship a debug-signed APK; check the signer (#5116)
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m57s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m26s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 26s
release / go (push) Successful in 2m5s
release / web (push) Successful in 1m24s
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>
2026-10-06 21:33:22 -04:00
bvandeusenandClaude Opus 5.5 13a7a3629a fix(library): MBID backfills skip tracks whose files are missing (#5139)
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m34s
release / integration (push) Successful in 4m55s
release / Build signed APK (releases and dev) (push) Successful in 6m39s
release / android (push) Successful in 6m22s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m53s
release / Verify release artifacts (tag releases only) (push) Skipped
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>
2026-10-06 21:22:05 -04:00
bvandeusenandClaude Opus 5.5 2e36e70268 feat(android): Sonos/UPnP queue plays leveled URLs, rendered a track ahead (M464 #5002)
release / go (push) Successful in 2m49s
release / web (push) Successful in 2m21s
release / govulncheck (push) Successful in 25s
release / integration (push) Successful in 5m54s
release / android (push) Successful in 8m10s
release / Build signed APK (releases and dev) (push) Successful in 8m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m42s
release / Verify release artifacts (tag releases only) (push) Skipped
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>
2026-10-06 19:38:21 -04:00
bvandeusenandClaude Opus 5.5 f34423a0e0 feat: leveled FLAC stream for Sonos/UPnP speakers (M464 #5001)
release / go (push) Successful in 2m17s
release / govulncheck (push) Successful in 26s
release / web (push) Successful in 2m4s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m39s
release / android (push) Canceled after 2m53s
release / Build signed APK (releases and dev) (push) Canceled after 2m24s
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>
2026-10-06 19:31:07 -04:00
bvandeusenandClaude Opus 5.5 92c3f9bdb8 fix(android): look gains up by key, not value (M464 #5000)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m28s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 4m38s
release / android (push) Successful in 5m19s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m13s
release / Verify release artifacts (tag releases only) (push) Skipped
`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>
2026-10-06 19:18:15 -04:00
bvandeusenandClaude Opus 5.5 1013c283da feat(android): level playback with a gain processor in the audio sink (M464 #5000)
release / go (push) Successful in 1m43s
release / web (push) Successful in 1m27s
release / govulncheck (push) Successful in 35s
release / integration (push) Successful in 5m16s
release / android (push) Failing after 3m52s
release / Build signed APK (releases and dev) (push) Failing after 3m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
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>
2026-10-06 19:10:35 -04:00
bvandeusenandClaude Opus 5.5 38290bf8f9 feat(web): level playback by the user's normalization preference (M464 #4999)
release / integration (push) Successful in 4m26s
release / android (push) Successful in 5m20s
release / Build signed APK (releases and dev) (push) Successful in 5m31s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
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>
2026-10-06 18:21:41 -04:00
bvandeusenandClaude Opus 5.5 2a7eb3dd19 ci(integration): give the suite a 20m package timeout
release / govulncheck (push) Successful in 37s
release / go (push) Successful in 1m32s
release / web (push) Successful in 1m12s
release / android (push) Successful in 6m26s
release / Build signed APK (releases and dev) (push) Successful in 6m49s
release / integration (push) Successful in 19m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m18s
release / Verify release artifacts (tag releases only) (push) Skipped
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>
2026-10-06 17:52:01 -04:00
bvandeusenandClaude Opus 5.5 d5dfcf5b7c fix: boost control is a switch; MutationQueue keeps one enqueue per kind (M464 #4998)
release / go (push) Successful in 2m15s
release / govulncheck (push) Successful in 29s
release / web (push) Successful in 1m42s
release / android (push) Successful in 5m57s
release / Build signed APK (releases and dev) (push) Successful in 5m49s
release / integration (push) Failing after 20m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
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>
2026-10-06 14:34:28 -04:00
bvandeusenandClaude Opus 5.5 af36b2f24a feat: per-user volume leveling preference, synced across devices (M464 #4998)
release / web (push) Failing after 1m5s
release / govulncheck (push) Successful in 22s
release / go (push) Successful in 1m17s
release / android (push) Failing after 1m51s
release / integration (push) Canceled after 4m12s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 3m21s
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>
2026-10-06 14:29:40 -04:00