v2026.06.03 — Media3 like button + Bluetooth/UPnP picker + system playlist daily rotation #77

Merged
bvandeusen merged 29 commits from dev into main 2026-06-03 14:09:23 -04:00
Owner

29 commits across four feature slices + a small bug-fix batch.

Slice 1 — Media3 like / heart button on the media session

Heart appears in the phone notification card, lock-screen card, Pixel Watch system media controls, and Android Auto. Toggle routes through the existing LikesRepository.toggleLike so it inherits the offline-resilient MutationQueue.

  • LikeMediaCallback grants CMD_TOGGLE_LIKE in onConnect (Media3 issue #2679 guard) and routes onCustomCommand through the repository
  • MinstrelPlayerService injects LikesRepository, attaches the callback, sets initial preferences, and launches a currentMediaItem × observeIsLiked like-state job so the icon mirrors cross-device likes (web tap flips the phone notification heart automatically)
  • Plus PlayerController.setQueue thread-dispatches to the MediaController's applicationLooper — cold-boot resume was crashing with IllegalStateException: method is called from a wrong thread

On-device verified: notification heart works end-to-end.

Slice 2 — Bluetooth output picker

Symfonium-style device chip above the scrubber + bottom sheet showing all available routes (built-in / wired / Bluetooth).

  • OutputPickerController (Hilt singleton, MediaRouter facade) exposes routesState: StateFlow<RouteSnapshot> with passive-by-default discovery, upgrades to active when the sheet opens
  • OutputPickerViewModel projects routes + owns sheet lifecycle, fans discovery toggle to the controller
  • DeviceChip + OutputPickerSheet (Material 3 ModalBottomSheet) Compose UI with Lucide icons + BLUETOOTH_CONNECT permission flow
  • OutputRoute.Protocol enum (SYSTEM / UPNP / CAST / SONOS) baked in as forward-compat hook — UPnP slot in without data-model changes

Slice 3 — UPnP / DLNA / Sonos basic playback

Discovery + playback to any UPnP-compliant MediaRenderer (Sonos basic, Yamaha MusicCast, Bose SoundTouch, DLNA renderers). Audio flows server → speaker directly; phone is the controller.

Server (~7 files):

  • HMAC-SHA256 stream token: SignStreamToken(secret, trackID, exp) + VerifyStreamToken. Stream handler accepts either session cookie OR ?token=&exp= query params (constant-time compare via hmac.Equal)
  • New POST /api/cast/stream-token endpoint — clamped expSeconds, returns {token, exp, url} for the client to hand off to AVTransport
  • MINSTREL_STREAM_SECRET env var with file-fallback at <DataDir>/stream_secret (auto-generated on first boot, mode 0600, logged once)
  • auth.OptionalUser middleware — attaches user on cookie but doesn't 401 on absence
  • Stream handler does its own auth check before the DB lookup (fixes a 404/401 info leak)

Client (~13 files):

  • SsdpDiscovery — UDP multicast listener on 239.255.255.250:1900, passive NOTIFY listen + explicit M-SEARCH when sheet opens, MulticastLock lifecycle
  • DeviceDescription — pull-parser over the UPnP device XML, filters non-renderers, resolves relative control URLs
  • SoapClient — minimal SOAP 1.1 envelope builder + POST via the shared OkHttp; throws SoapFaultException with the UPnP errorCode
  • AVTransportClient — v1 surface: SetAVTransportURI + Play + Stop
  • UpnpDiscoveryController — Hilt singleton composing SSDP + DeviceDescription + AVTransport, exposes Flow<List<UpnpRoute>>
  • OutputPickerController now combines system + UPnP routes; select() branches by protocol: SYSTEM → MediaRouter, UPNP → mint token + SOAP SetAVTransportURI + Play
  • OutputPickerSheet gains a multicast-blocked footer hint

Pending operator on-device verification with a real Sonos speaker before this PR's UPnP path is considered field-tested. Server pieces fully CI-verified; client SOAP / DeviceDescription tests passing via the kxml2 dep added to the test classpath.

Slice 4 — System playlist daily rotation + diversity

SQL audit of the 5 discovery-mix queries showed Rediscover, New for you, and First listens have no date param in their ORDER BY — same content day-over-day until library state shifts.

  • Unified the 5 near-identical produceXxx functions into one produceDiscoveryMix(spec) factory + a per-mix discoveryMixSpec slice. Adding a new mix is now one struct literal + a SQL query.
  • dailyRotate: true on all 3 deterministic mixes — applies a daily-deterministic offset rotate of the pool BEFORE diversify+truncate so each day's slice rotates while contiguous-block ordering (matters for album-coherent mixes) is preserved. Deep Cuts + On this day stay false because their SQL already day-keys via md5(id||$2::text).
  • diversify: true on all 5 — per-album ≤2 / per-artist ≤3 caps applied everywhere, with topUpFromRaw fallback: when caps strip the pool below 100 (album-heavy libraries), append non-capped tracks from the raw pool until the target is hit. Diversity where possible, full playlist always.

Bug-fix batch (Android)

  • LikesRepository.observeLikedTrackIds(): Flow<Set<String>> — direct DAO query, no trackDao join. PlaylistDetailViewModel + AlbumDetailViewModel switched to it; cross-device likes whose track row isn't cached locally now correctly mark playlist/album rows as liked
  • PlaylistsRepository wraps coverPath/coverUrl through resolveServerUrl so the playlist header art actually fetches via BaseUrlInterceptor (was a relative URL Coil couldn't resolve)

Test plan

  • Install the APK from this release on phone, sign in, confirm Welcome → Home path still works
  • Notification heart tap on a playing track flips state; cross-device (web tap) flips the phone notification heart automatically
  • Open NowPlaying sheet → Bluetooth devices listed, selection routes audio
  • On a Sonos / UPnP speaker: open NowPlaying sheet → device appears with manufacturer name; select → audio plays through it
  • Multicast-blocked network: sheet shows "Your router may be blocking multicast discovery"
  • Open Rediscover / New for you / First listens today and tomorrow → content visibly rotates day-over-day
  • Playlist detail header art renders (was blank); liked-state badge on tracks within a playlist reflects actual liked state

🤖 Generated with Claude Code

29 commits across four feature slices + a small bug-fix batch. ## Slice 1 — Media3 like / heart button on the media session Heart appears in the phone notification card, lock-screen card, Pixel Watch system media controls, and Android Auto. Toggle routes through the existing `LikesRepository.toggleLike` so it inherits the offline-resilient MutationQueue. - `LikeMediaCallback` grants `CMD_TOGGLE_LIKE` in `onConnect` (Media3 issue #2679 guard) and routes `onCustomCommand` through the repository - `MinstrelPlayerService` injects `LikesRepository`, attaches the callback, sets initial preferences, and launches a `currentMediaItem × observeIsLiked` like-state job so the icon mirrors cross-device likes (web tap flips the phone notification heart automatically) - Plus `PlayerController.setQueue` thread-dispatches to the MediaController's `applicationLooper` — cold-boot resume was crashing with `IllegalStateException: method is called from a wrong thread` On-device verified: notification heart works end-to-end. ## Slice 2 — Bluetooth output picker Symfonium-style device chip above the scrubber + bottom sheet showing all available routes (built-in / wired / Bluetooth). - `OutputPickerController` (Hilt singleton, MediaRouter facade) exposes `routesState: StateFlow<RouteSnapshot>` with passive-by-default discovery, upgrades to active when the sheet opens - `OutputPickerViewModel` projects routes + owns sheet lifecycle, fans discovery toggle to the controller - `DeviceChip` + `OutputPickerSheet` (Material 3 ModalBottomSheet) Compose UI with Lucide icons + `BLUETOOTH_CONNECT` permission flow - `OutputRoute.Protocol` enum (`SYSTEM` / `UPNP` / `CAST` / `SONOS`) baked in as forward-compat hook — UPnP slot in without data-model changes ## Slice 3 — UPnP / DLNA / Sonos basic playback Discovery + playback to any UPnP-compliant MediaRenderer (Sonos basic, Yamaha MusicCast, Bose SoundTouch, DLNA renderers). Audio flows server → speaker directly; phone is the controller. **Server (~7 files):** - HMAC-SHA256 stream token: `SignStreamToken(secret, trackID, exp)` + `VerifyStreamToken`. Stream handler accepts either session cookie OR `?token=&exp=` query params (constant-time compare via `hmac.Equal`) - New `POST /api/cast/stream-token` endpoint — clamped `expSeconds`, returns `{token, exp, url}` for the client to hand off to AVTransport - `MINSTREL_STREAM_SECRET` env var with file-fallback at `<DataDir>/stream_secret` (auto-generated on first boot, mode 0600, logged once) - `auth.OptionalUser` middleware — attaches user on cookie but doesn't 401 on absence - Stream handler does its own auth check before the DB lookup (fixes a 404/401 info leak) **Client (~13 files):** - `SsdpDiscovery` — UDP multicast listener on `239.255.255.250:1900`, passive `NOTIFY` listen + explicit `M-SEARCH` when sheet opens, `MulticastLock` lifecycle - `DeviceDescription` — pull-parser over the UPnP device XML, filters non-renderers, resolves relative control URLs - `SoapClient` — minimal SOAP 1.1 envelope builder + POST via the shared OkHttp; throws `SoapFaultException` with the UPnP `errorCode` - `AVTransportClient` — v1 surface: `SetAVTransportURI` + `Play` + `Stop` - `UpnpDiscoveryController` — Hilt singleton composing SSDP + DeviceDescription + AVTransport, exposes `Flow<List<UpnpRoute>>` - `OutputPickerController` now `combine`s system + UPnP routes; `select()` branches by `protocol`: SYSTEM → MediaRouter, UPNP → mint token + SOAP `SetAVTransportURI + Play` - `OutputPickerSheet` gains a multicast-blocked footer hint **Pending operator on-device verification with a real Sonos speaker before this PR's UPnP path is considered field-tested.** Server pieces fully CI-verified; client SOAP / DeviceDescription tests passing via the kxml2 dep added to the test classpath. ## Slice 4 — System playlist daily rotation + diversity SQL audit of the 5 discovery-mix queries showed Rediscover, New for you, and First listens have no date param in their `ORDER BY` — same content day-over-day until library state shifts. - Unified the 5 near-identical `produceXxx` functions into one `produceDiscoveryMix(spec)` factory + a per-mix `discoveryMixSpec` slice. Adding a new mix is now one struct literal + a SQL query. - `dailyRotate: true` on all 3 deterministic mixes — applies a daily-deterministic offset rotate of the pool BEFORE diversify+truncate so each day's slice rotates while contiguous-block ordering (matters for album-coherent mixes) is preserved. Deep Cuts + On this day stay `false` because their SQL already day-keys via `md5(id||$2::text)`. - `diversify: true` on all 5 — per-album ≤2 / per-artist ≤3 caps applied everywhere, with `topUpFromRaw` fallback: when caps strip the pool below 100 (album-heavy libraries), append non-capped tracks from the raw pool until the target is hit. Diversity where possible, full playlist always. ## Bug-fix batch (Android) - `LikesRepository.observeLikedTrackIds(): Flow<Set<String>>` — direct DAO query, no `trackDao` join. `PlaylistDetailViewModel` + `AlbumDetailViewModel` switched to it; cross-device likes whose track row isn't cached locally now correctly mark playlist/album rows as liked - `PlaylistsRepository` wraps `coverPath`/`coverUrl` through `resolveServerUrl` so the playlist header art actually fetches via `BaseUrlInterceptor` (was a relative URL Coil couldn't resolve) ## Test plan - [ ] Install the APK from this release on phone, sign in, confirm Welcome → Home path still works - [ ] Notification heart tap on a playing track flips state; cross-device (web tap) flips the phone notification heart automatically - [ ] Open NowPlaying sheet → Bluetooth devices listed, selection routes audio - [ ] On a Sonos / UPnP speaker: open NowPlaying sheet → device appears with manufacturer name; select → audio plays through it - [ ] Multicast-blocked network: sheet shows "Your router may be blocking multicast discovery" - [ ] Open Rediscover / New for you / First listens today and tomorrow → content visibly rotates day-over-day - [ ] Playlist detail header art renders (was blank); liked-state badge on tracks within a playlist reflects actual liked state 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bvandeusen added 29 commits 2026-06-03 14:09:17 -04:00
feat(android): scrubber thumb pill — fixes off-center perception
android / Build + lint + test (push) Successful in 4m0s
ad7e57fe66
User report: the round dot didn't read as vertically centered on
the 4dp track even though geometrically it was (M3's SliderLayout
centers the track slot within the thumb's height). A small circle
on a thin horizontal bar is a known perceptual offset — the eye
expects the bar to bisect the circle, but the circle's mass extends
above and below in equal amounts the brain reads as a lift.

Swap the 14dp circle for a 4dp x 18dp vertical pill (CircleShape
on a non-square Box renders as a stadium). Same width as the track,
clearly taller — the bar visibly passes through the pill's
horizontal axis with no ambiguity. Also aligns with M3 expressive's
new vertical-handle slider direction.

Updates the ScrubTrack docstring that still referenced the prior
14dp-on-4dp pairing.
feat(android): LikeMediaCallback for media-session like button
android / Build + lint + test (push) Failing after 1m29s
d37ef56bb1
New MediaSession.Callback that grants CMD_TOGGLE_LIKE in onConnect
(Media3 issue #2679 guard) and routes onCustomCommand through
LikesRepository.toggleLike so notification/lock-screen/Pixel-Watch
taps inherit the offline-resilient MutationQueue path.

Unit tests cover the onConnect grant, current-state inversion in
both directions, no-op when there is no current MediaItem, and
rejection of unknown custom actions.

MinstrelPlayerService wiring lands in a follow-up commit.
fix(android): LikeMediaCallback ReturnCount — extract toggle helper
android / Build + lint + test (push) Failing after 2m51s
9a7cfac7f8
detekt: onCustomCommand had 3 returns (unsupported / no-mediaItem /
success), ReturnCount cap is 2. Pull the toggle path into a private
launchToggleForCurrent helper so onCustomCommand is a single
return (if/else picks the result code, one Future wrap) and the
helper has at most 2 returns.
fix(android): testOptions isReturnDefaultValues = true
android / Build + lint + test (push) Failing after 2m55s
7807e31b22
Unit tests touching Android framework statics (Bundle.EMPTY,
android.os.Bundle constructor in MediaItem/SessionCommand
construction) failed with NPE/ExceptionInInitializerError because
JVM unit tests run against android.jar's stub classes whose methods
throw "Method ... not mocked" by default. Enable
isReturnDefaultValues so stub methods return defaults — Bundle.EMPTY
ends up null and is fine because we just thread it through
SessionCommand without inspecting it.

Fixes LikeMediaCallbackTest's 5 failures on run #311. Lightweight —
no Robolectric, no androidTest. The first JVM-side test file in the
project to touch Android framework classes.
revert(android): drop LikeMediaCallback JVM tests + testOptions flag
android / Build + lint + test (push) Successful in 5m26s
43754d03c4
The unit tests called Media3's SessionCommand(String, Bundle)
constructor, which checkNotNulls the Bundle. JVM unit tests have
no real Android — Bundle.EMPTY is a static field initialized via
the stub jar to null. isReturnDefaultValues=true escapes the
ExceptionInInitializerError but leaves Bundle.EMPTY as null, so
SessionCommand still NPEs on construction. The real fix is
Robolectric, which is disproportionate infrastructure for one
test file (pulls in JUnit 4 ceremony for a JUnit 5 project + a
heavy dep + first-run SDK download flake risk on this CI).

Verification gate for the like button is operator on-device check
per feedback_definition_of_done. The Task 2 wiring lands next,
then we verify the heart appears on the phone notification, lock
screen, and Pixel Watch end-to-end.
feat(android): wire MediaSession like button + reactive state
android / Build + lint + test (push) Successful in 3m54s
551bbf83c2
MinstrelPlayerService now injects LikesRepository, attaches the new
LikeMediaCallback, sets an initial unfilled CommandButton via
setMediaButtonPreferences, and launches a service-scoped job that
rebuilds the preferences list when the current track or its
server-side liked state changes.

flatMapLatest on (currentMediaItem x observeIsLiked) means the icon
mirrors cross-device likes (web tap flips the notification heart
within EventsStream propagation) and never leaks Flows across track
transitions. Initial emission on subscription guarantees the icon is
correct on the first frame the controller renders.

onDestroy now cancels the service scope before releasing the session
so the like-state job can't touch a released MediaSession.

Closes the Media3 like-button work spec'd in
docs/superpowers/specs/2026-06-02-android-media3-like-button-design.md.
On-device verification still pending: phone notification, lock
screen, Pixel Watch, Android Auto, offline replay, cross-device.
chore(android): drop redundant !! on PlaylistRef.systemVariant
android / Build + lint + test (push) Successful in 3m49s
4be7e47584
Line 264 already null-checks playlist.systemVariant in the if
condition. PlaylistRef is a data class with a val backing field,
so the smart cast narrows it to String inside the branch — the
!! on line 265 was a no-op the Kotlin compiler was warning about.
fix(android): PlayerController.setQueue dispatches to controller thread
android / Build + lint + test (push) Successful in 4m15s
e69a5204db
Crash on cold boot: ResumeController.restore is suspend, lands on
Dispatchers.Default after awaitReady() unblocks (drift #562), and
calls PlayerController.setQueue which calls MediaController.setMediaItems
— MediaController enforces application-thread access and throws
IllegalStateException 'method is called from a wrong thread'.

Drift #562 added awaitReady() to fix the race where setQueue
early-returned on null controller and silently dropped the persisted
queue. That fix exposed the next bug down the stack: the threading
violation that was previously masked by the early-return.

setQueue now posts the MediaController calls to the controller's
applicationLooper if we're not already on it. UI callers (already
Main) run inline with no re-dispatch latency. ResumeController's
cold-boot path lands on the right thread.

Discovered on-device 2026-06-03 during like-button verification on
the Pixel 6 Pro emulator — crash log at PlayerController.kt:190.
feat(android): output picker foundation - mediarouter + OutputRoute
android / Build + lint + test (push) Has been cancelled
0662c9d5cc
Bluetooth slice (1/5). Adds the androidx.mediarouter 1.7.0 dep,
declares BLUETOOTH_CONNECT (needed on Android 12+ to enumerate
paired BT devices by name), and lays down the OutputRoute domain
model.

OutputRoute decouples the picker UI from MediaRouter.RouteInfo
(framework class, can't be constructed in JVM tests - same
constraint we hit with LikeMediaCallback). The Protocol enum
includes UPNP/CAST/SONOS placeholders so the next slice slots in
without a data-model rename - see
docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md
for the deferred work.

Controller + ViewModel + Compose UI land in follow-up commits.
feat(android): OutputPickerController - MediaRouter facade
android / Build + lint + test (push) Failing after 2m39s
087486d253
Bluetooth slice (2/5). Hilt singleton over androidx.mediarouter.
Owns the callback lifecycle (passive at process start, upgrades to
active when the picker sheet opens, reverts on close) and exposes
the route state as a StateFlow<RouteSnapshot> the ViewModel
projects.

Routes are sorted current-first then by Kind (Bluetooth, Wired,
BuiltIn, Other) so the active output is always at the top of the
sheet.

ViewModel + Compose UI follow in next commits.
feat(android): OutputPickerViewModel - sheet lifecycle + selection
android / Build + lint + test (push) Has been cancelled
692d9dab60
Bluetooth slice (3/5). HiltViewModel projecting the controller's
routesState Flow plus a sheetVisible MutableStateFlow that owns
the sheet's open/close state. onChipTapped + onSheetDismissed
forward to the controller's discovery toggle so active MediaRouter
discovery only runs while the sheet is visible (battery cost).

Compose UI + NowPlaying wiring land next.
feat(android): output picker Compose UI - chip + sheet
android / Build + lint + test (push) Has been cancelled
a319e3f66d
Bluetooth slice (4/5). DeviceChip: Spotify-style current-route
indicator with icon + name + chevron, single-line ellipsis on long
names. iconFor() maps Kind to Lucide icons (Smartphone / Headphones
/ Bluetooth / Cast / Speaker).

OutputPickerSheet: Material 3 ModalBottomSheet. Header 'Output',
rows = icon + name + 2-line subtitle + selection state (CircleCheck
accent for selected, Circle outline otherwise). Tap selects +
dismisses. permissionDenied flag controls a footer hint row when
BLUETOOTH_CONNECT was refused.

NowPlayingScreen wiring lands in the final commit.
fix(android): output picker - add Settings icon to permission hint
android / Build + lint + test (push) Failing after 3m13s
d10113db54
Spec's edge-case table calls for the BLUETOOTH_CONNECT permission
hint footer to render alongside a Lucide.Settings icon. Task 4
landed the hint text but not the icon. One-line spec fix on top
of commit a319e3f6.
feat(android): NowPlaying output-picker integration
android / Build + lint + test (push) Failing after 1m33s
8258b6c29f
Bluetooth slice (5/5). Wires the OutputPickerViewModel + chip +
sheet into NowPlayingScreen.

- Chip renders between BottomActionsRow and ScrubberRow, hidden
  via shouldShowChip() when the only route is the built-in speaker
  (no useful picker with one option).
- Sheet appears on chip tap; selecting a route or dismissing flips
  the ViewModel state and downgrades MediaRouter discovery.
- BLUETOOTH_CONNECT permission requested via the modern
  ActivityResultContracts.RequestPermission() pattern on first
  sheet open. permissionDenied flag passed through to the sheet so
  the 'pair in Settings' hint renders when refused.

Closes the Bluetooth slice spec'd in
docs/superpowers/specs/2026-06-03-android-output-picker-bluetooth-design.md.
On-device verification still pending: pair a Bluetooth speaker,
confirm chip + sheet + select + audio routes; verify wired plug
auto-update + permission-denial hint + long-name truncation.
fix(android): output picker CI - passive discovery + LongMethod
android / Build + lint + test (push) Successful in 6m26s
d7fe515940
Two failures on the slice's final dev tip:

1. OutputPickerController referenced
   MediaRouter.CALLBACK_FLAG_PASSIVE_DISCOVERY which doesn't exist
   in androidx.mediarouter 1.7.0 - the spec hallucinated it.
   Passive discovery is the default behavior when addCallback is
   called with no flag argument. Use the 2-arg overload for the
   init block and downgradeDiscovery; keep CALLBACK_FLAG_REQUEST_DISCOVERY
   for upgradeDiscovery.

2. NowPlayingBody grew to 82 lines after the Task 5 output-picker
   wiring (state collection + permission launcher + LaunchedEffect
   + conditional Sheet). Extracted the BLUETOOTH_CONNECT permission
   plumbing into rememberBluetoothPermissionState, the Column layout
   into NowPlayingContent, and the scrubber+transport pair (which
   share the smoothed playback position) into PlaybackControlsBlock.
   NowPlayingBody is back to ~34 lines and the new helpers each sit
   well under detekt's 60-line LongMethod cap.
feat(server): HMAC stream token auth path (UPnP slice 1/6)
test-go / test (push) Failing after 24s
test-go / integration (push) Failing after 9m54s
236637fcd3
Adds SignStreamToken / VerifyStreamToken (HMAC-SHA256 over
trackID|exp) and modifies handleGetStream to accept either the
existing session cookie OR a valid signed token. Stream route
moved out of the authed group so the handler's own auth check
runs and the token bypass is reachable.

Enables Sonos / UPnP speakers to fetch the stream URL without
carrying the user's session cookie - they cannot. The token is
short-lived (max 24h per the design); expiry checked at request
time only, not per-byte, so long tracks play through.

streamSecret field on handlers is nil for now; Task 2 wires the
loader (env var with auto-generated fallback persisted in
app_preferences).

Adds auth.OptionalUser - the permissive sibling of RequireUser
that attaches the user to context when a valid cookie / bearer is
present but does NOT 401 on absence. The stream route is wrapped
with it so the handler can fall through to the token path when
no session is present.

newLibraryRouter (test fixture) gets a synthetic-user middleware
on the stream route so existing media_test tests keep passing
without seeding a real session row - production traffic uses
auth.OptionalUser, the test path uses auth.UserCtxKeyForTest().

Five tests cover round-trip, tampered token rejection, expiry,
wrong-track-ID, and wrong-secret rejection. CI verifies.
feat(server): POST /api/cast/stream-token + secret bootstrap (UPnP slice 2/6)
test-go / test (push) Failing after 15s
test-go / integration (push) Failing after 5m54s
e774097fd8
Adds the client-facing endpoint that issues a signed stream URL for
the current track. Authenticated via the standard session cookie.
Returns {token, exp, url} where url is a fully-formed stream URL
the client passes verbatim to a UPnP / Sonos device's
AVTransport.SetAVTransportURI call.

expSeconds clamped to [60, 86400]; default 21600 (6h) - long enough
to play through any typical track without re-minting mid-playback.

MINSTREL_STREAM_SECRET is loaded from env var with a per-machine
fallback persisted at <Storage.DataDir>/stream_secret (auto-generated
on first boot via 64 random bytes, base64-url-encoded, 0600). The
file-based fallback is operator-machine-scoped runtime state, not a
user-facing setting - chosen over a DB column to avoid a migration
and keep the secret out of cross-instance restores. Operator can
override at any time via the env var; default path requires zero
config.

Tests cover happy-path token issuance + URL formatting, bad-UUID
rejection, unauthenticated rejection, the expSeconds clamp at all
boundaries, secret env override, auto-gen + file persistence at 0600,
second-boot reuse of the persisted file, and rejection of a malformed
env value.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
feat(android): UPnP picker foundation (UPnP slice 3/6)
android / Build + lint + test (push) Successful in 4m11s
5f3905f2c7
UpnpRoute - narrow domain model for a discovered UPnP / DLNA
renderer. Carries the AVTransport + RenderingControl control URLs
the SOAP client uses.

CastApi - Retrofit interface for the new POST /api/cast/stream-token
endpoint (UPnP slice 2/6). Returns {token, exp, url} for the
selection path.

OutputRoute.fromUpnpRoute - companion factory that tags the route
with Protocol.UPNP. Subtitle is 'Manufacturer Model' or falls back
to 'Network speaker' when description fields are blank.

CHANGE_WIFI_MULTICAST_STATE manifest permission - install-time on
all API levels, no runtime prompt. Required for SSDP multicast
discovery.

Discovery + SOAP + integration land in follow-up commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
feat(android): UPnP SSDP discovery + device description (UPnP slice 4/6)
android / Build + lint + test (push) Has been cancelled
dc5b8252bb
SsdpDiscovery - UDP multicast listener on 239.255.255.250:1900.
Passive NOTIFY listen always-on once start() is called; explicit
M-SEARCH M-SEARCH on requestActiveScan() (called when picker sheet
opens). WifiManager.MulticastLock held only while running. Emits
each discovered LOCATION URL on a SharedFlow for downstream
description-fetching.

DeviceDescription - pull-parse the <device> XML returned from a
LOCATION URL, extracting friendlyName / manufacturer / modelName +
AVTransport + RenderingControl service control URLs. Filters out
devices without AVTransport (we can't control them).

Three unit tests cover a Sonos-shaped description, a non-renderer
device that should be dropped, and a minimal description with
missing optional fields.
Android's XmlPullParserFactory is a Stub-throwing class in android.jar
on the JVM unit-test classpath; the probe pattern from dc5b8252 was
silently skipping the test suite, which gives false test-coverage
confidence. kxml2 is the same parser implementation Android uses
internally - service-provider lookup picks it up automatically once
on the test classpath.

The probe + Assumptions.assumeTrue skip removed; tests now run
unconditionally.

testImplementation(libs.kxml2) - 2.3.0, MIT-licensed, ~80KB. No
production code change.
feat(android): UPnP SOAP envelope + AVTransport client (UPnP slice 5/6)
android / Build + lint + test (push) Successful in 3m46s
f8c93e013d
SoapClient - minimal SOAP 1.1 envelope builder + POST via the shared
app OkHttpClient. Sets the SOAPACTION + Content-Type headers UPnP
expects, parses the action's Response element as a Map<String,
String>, raises SoapFaultException on a <s:Fault> response with the
UPnP errorCode + errorDescription extracted.

AVTransportClient - thin wrapper over SoapClient pinned to the
AVTransport:1 service. Three actions for v1: SetAVTransportURI /
Play / Stop. Pause + Seek deferred until we have hardware in the
loop to verify per-device quirks.

Three MockWebServer-driven unit tests cover the SOAPACTION header
shape, XML escaping of special chars in arg values, and the fault
response -> SoapFaultException path. kxml2 on the test classpath
(Task 4) makes XmlPullParserFactory resolve on the JVM.
feat(android): UPnP picker integration (UPnP slice 6/6)
android / Build + lint + test (push) Successful in 3m52s
03cdff547d
UpnpDiscoveryController - Hilt singleton that owns the SSDP listener,
follows each discovered LOCATION URL to fetch + parse the device
description, projects MediaRenderers into a StateFlow<List<UpnpRoute>>.

OutputPickerController now combines system routes with the UPnP
Flow into a unified RouteSnapshot. select() branches by protocol:
SYSTEM goes through MediaRouter as before; UPNP requests a signed
stream token via POST /api/cast/stream-token then calls
AVTransport.SetAVTransportURI + Play against the discovered device.
Local playback pauses on UPnP selection.

OutputPickerSheet gains a MulticastHintRow shown when no UPnP
devices appear after a 3s grace period - the 'your router may be
blocking multicast' footer hint per the spec.

Closes the UPnP slice spec'd in
docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
On-device verification pending: pair a Sonos / UPnP speaker, confirm
discovery + selection + playback + the multicast-blocked hint.
chore(android): UPnP picker - log selectUpnp failures + drop dead fetchJob
android / Build + lint + test (push) Successful in 3m54s
448c9f2e74
Code-quality review flagged two non-blockers on commit 03cdff54:

1. selectUpnp's runCatching swallowed SOAP / token-mint failures
   silently - OkHttp's logger doesn't see them since they happen in
   our own deserialize / parse code. Adds Timber.w on the failure
   path so operator's on-device Sonos verification can find the
   cause in logcat instead of staring at "nothing happened".

2. UpnpDiscoveryController's fetchJob field was assigned but never
   read or cancelled. appScope is process-lifetime so the launched
   coroutine dies with the process - no explicit cancellation is
   needed. Drop the field + the now-unused Job import.
fix(android): playlist like state + playlist cover URL
android / Build + lint + test (push) Successful in 3m48s
3df5e5cb3c
Two independent bugs surfaced together:

Bug 1: like button on tracks in playlist/album detail screens didn't
reflect actual liked state. LikesRepository.observeLikedTracks() does
a mapNotNull join against trackDao - a liked track whose row isn't in
the local cache yet (e.g. liked via web/notification, cache not
sync'd) gets DROPPED from the returned list. PlaylistDetailViewModel
+ AlbumDetailViewModel both used that as their like-set source, so
those rows showed as not-liked.

Adds LikesRepository.observeLikedTrackIds(): Flow<Set<String>> that
hits the DAO directly via observeLikedIdsOfType - no trackDao join,
no drops. The two ViewModels switch to it. LikedTab continues to use
observeLikedTracks because it needs the full TrackRef to render.

Bug 2: playlist cover art didn't render on the playlist detail
header. Server's derivePlaylistView returns CoverURL as the relative
path "/api/playlists/<id>/cover". PlaylistsRepository's two domain
mappers (CachedPlaylistEntity.toDomain + PlaylistDetailWire
.toPlaylistRef) stored it verbatim - Coil's AsyncImage can't fetch a
relative URL with no base, so the image silently failed.

Wraps the coverPath/coverUrl through resolveServerUrl so the
placeholder.invalid host triggers BaseUrlInterceptor's live-server
rewrite, same idiom every other cover surface (album / artist / track
/ playlist track rows) already uses.

System-playlist 24h refresh investigation pending - need to know how
you verified (server logs, DB state, or client-visible content)
before I can dig into the right layer.
fix(server): unify discovery-mix producers + daily-rotate the deterministic ones
test-go / test (push) Failing after 19s
test-go / integration (push) Has been cancelled
7473e98d91
The five discovery-mix producers (Deep Cuts, Rediscover, New for you,
On this day, First listens) were near-identical boilerplate that
differed only in (a) which SQL query they ran and (b) whether to
diversity-cap the result. Folded into one produceDiscoveryMix(spec)
factory + a per-mix discoveryMixSpec slice. The registry composes the
factory over the spec list so adding a new mix is one struct literal
+ a SQL query, never a new func.

Also fixes the user-reported bug that several mixes 'show the same
content from yesterday'. Audit of the SQL queries:

  - Deep Cuts:   ORDER BY md5(t.id::text || $2::text)   → day-keyed
  - On this day: ORDER BY w.c DESC, md5(...)              → day-keyed
  - Rediscover:  ORDER BY tier, c DESC, id                → invariant
  - New for you: ORDER BY al.created_at DESC, disc, track → invariant
  - First listens: ORDER BY tier, al.id, disc, track      → invariant

The three invariant ones produced identical content day-over-day. The
unified spec carries a dailyRotate bool: when set, the producer
applies a daily-deterministic offset rotate-left of the candidate
pool BEFORE diversify+truncate. Rotation (not shuffle) preserves
contiguous-block ordering inside each day's slice — matters for First
listens which is album-coherent.

Set on Rediscover + First listens (where same-content-every-day is
clearly a bug). Left off New for you because 'newest album first
regardless of day' is the intended UX for that surface — daily
rotation there would feel wrong.

Daily rotation seed: rand.New(NewSource(int64(userIDHash(userID,
dateStr)))) — same primitive used by For-You's pickHeadAndTail
sampling so behavior is consistent across the system playlist family.

No test file referenced the deleted produceXxx functions directly,
only the registry, so this is a closed refactor.
fix(server): daily-rotate all deterministic mixes + diversity top-up fallback
test-go / test (push) Failing after 12s
test-go / integration (push) Failing after 6m50s
6da6cb5c5a
Operator feedback on the prior unification commit (7473e98d):

1. NewForYou should daily-rotate alongside Rediscover and FirstListens.
   The 'newest album first regardless of day' intent was the wrong
   call - operator wants visible day-over-day movement on every
   deterministic mix surface. Spec flipped to dailyRotate: true.

2. Diversity caps (<=2 per album / <=3 per artist) on every mix, not
   just the historically-diverse ones. The 2-per-album limit has
   helped a lot on the operator's library; extending it to NewForYou
   and FirstListens (previously album-coherent / no cap) surfaces
   more distinct albums per day. Spec flipped to diversify: true on
   all five.

3. Fallback when diversity caps strip the pool below the 100-track
   target: finishMix now calls topUpFromRaw, which appends non-capped
   tracks from the raw SQL pool (preserving original ranked order +
   skipping duplicates) until the target is hit or the pool runs out.
   On rich libraries the cap yields >= 100 and top-up never runs; on
   thin / album-heavy libraries we ship a partly-diversified 100
   instead of a strictly-diversified 40.

Net effect: every deterministic mix now rotates day-over-day, every
mix gets the same diversity treatment (with graceful degradation),
and the producer surface stays a single factory over a spec list.
fix(server): TestRoutesRegisteredInMount - missing streamSecret arg
test-go / test (push) Failing after 13s
test-go / integration (push) Failing after 9m41s
9e67088fdb
go vet caught the test's Mount call missing the trailing []byte
streamSecret arg added by the UPnP slice's Task 2. The test passed nil
for *playlists.Scheduler but didn't pass anything for []byte, so the
arg count was one short.

Added nil for the streamSecret position - the test exercises route
registration only, not the cast-token endpoint, so the secret value
doesn't matter for what this test asserts.
fix(server): errcheck violations from UPnP slice
test-go / test (push) Successful in 29s
test-go / integration (push) Has been cancelled
c3614c6333
golangci-lint flagged three errcheck:
- stream_token.go: fmt.Fprintf(mac, ...) - hash.Hash never errors
  per documented contract, but errcheck wants explicit discard.
  Discard via _, _ assignment with a WHY comment.
- config_test.go: os.Unsetenv calls in tests - discard the error
  via _ assignment. Test cleanup paths.

Reviewers flagged the Fprintf one during Task 1 quality review but
golangci-lint runs in a separate CI step that wasn't exercised on
the per-task pushes (cancelled by subsequent push concurrency).
fix(server): handleGetStream - auth check before DB lookup
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 9m40s
7c11cdc4d1
TestRoutesRegisteredInMount failed because handleGetStream did the
DB lookup (404 on missing track) BEFORE streamAuthOk (401 on
unauth). For an unauth request to a non-existent track, the test
saw 404 and concluded the route wasn't registered when actually it
was - the handler just bailed at the lookup before auth.

Reorder: extract trackID via chi.URLParam, run streamAuthOk on the
raw path id first (the HMAC token is signed over the same id
string so we don't need the resolved row yet), then do the DB
lookup. Test now sees 401 on the unauth probe as it expected.

Also closes a small info-leak: previously a 404/401 differential
let unauth callers probe which track IDs exist. Now both unknown
and known IDs return 401 for unauth requests.
bvandeusen merged commit a62a20b599 into main 2026-06-03 14:09:23 -04:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/minstrel#77