Compare commits

..

35 Commits

Author SHA1 Message Date
bvandeusen 3c4c27fb08 Merge pull request 'v2026.06.03 hotfix — Sonos cast URL + UPnP picker polish' (#78) from dev into main
test-go / test (push) Successful in 30s
android / Build + lint + test (push) Successful in 4m49s
test-go / integration (push) Successful in 10m56s
release / Build signed APK (tag releases only) (push) Successful in 4m15s
release / Build + push container image (push) Successful in 17s
2026-06-03 15:30:09 -04:00
bvandeusen 7d15f57e86 fix(server): cast token URL honors X-Forwarded-Proto / Host
test-go / test (push) Successful in 36s
test-go / integration (push) Successful in 9m48s
On-device test against Sonos showed SetAVTransportURI returning UPnP
error 714 (IllegalMimeType). Logcat:

  POST /api/cast/stream-token -> 200 (token minted)
  SetAVTransportURI to http://minstrel.fabledsword.com/...
  <-- 500 from Sonos: SoapFaultException SOAP fault 714

The server is behind a TLS-terminating reverse proxy, so r.TLS is
nil and the URL builder emitted http://. Sonos does a HEAD probe to
detect the audio MIME type; against an http:// URL that 301s to
https://, the probe finds no audio body and bails with 714.

The Task 2 code-quality reviewer flagged this exact scenario at the
time. Closing it now: honor X-Forwarded-Proto + X-Forwarded-Host
before falling back to r.TLS + r.Host. Public URL the speaker
fetches now matches the scheme/host the client used to reach the
endpoint.
2026-06-03 15:17:34 -04:00
bvandeusen a9edc12523 fix(android): release-build Timber tree at WARN+ for operator diagnosis
android / Build + lint + test (push) Successful in 4m2s
Debug builds got DebugTree; release builds had no tree planted at
all, so Timber.w / Timber.e calls were dropped silently in
production. That's how the UPnP select diagnostic-prints went
invisible during on-device testing - the released APK had no Timber
output reaching logcat.

Plant a release-only Tree that emits at WARN and above via
android.util.Log.println with the canonical 'Minstrel' tag (or the
caller-supplied tag when present). Keeps DEBUG / INFO traffic out of
production logcat (the chatty stuff is the part we don't want
flooding the buffer) while letting operator-driven adb logcat
sessions still see real failures.
2026-06-03 15:01:03 -04:00
bvandeusen 96f12d6aac fix(android): UPnP select - loud logs on every code path
android / Build + lint + test (push) Has been cancelled
Two silent early returns in selectUpnp were swallowing the most
likely failure modes:
  - currentTrack null (nothing playing locally → can't cast a track)
  - transportFor() returns null (route disappeared or id mismatch)

On-device verification reported 'tap collapses the sheet but no
audio routes', with logcat empty - one of these was firing without
any signal.

Each early-return now Timber.w's why; the runCatching block adds
Timber.i breadcrumbs at every step (mint token, SetAVTransportURI,
Play, done) so the next failure shows exactly how far we got.
2026-06-03 14:59:57 -04:00
bvandeusen 036da9dea8 fix(android): UPnP - clean Sonos friendlyName for picker display
android / Build + lint + test (push) Successful in 4m28s
Sonos uses the friendlyName format
  'Room - Device Type - RINCON_<UDN>'

The picker was showing it verbatim, so the user saw rows like
  'Living Room - Sonos Play:1 Media Renderer - RINCON_5CAAFD79...'

Now strips on the first ' - ' for Sonos manufacturer matches, so the
chip shows just 'Living Room' / 'Kitchen' / etc. Subtitle (manufacturer
+ model) still renders below per the existing sheet design, so the
device-type info isn't lost.

Generic UPnP devices that append a '(192.168.x.x)' IP suffix get that
stripped too via an end-of-string-anchored regex. Empty / blank
friendlyName still falls back to 'Network speaker'.
2026-06-03 14:54:39 -04:00
bvandeusen a62a20b599 Merge pull request 'v2026.06.03 — Media3 like button + Bluetooth/UPnP picker + system playlist daily rotation' (#77) from dev into main
test-go / test (push) Successful in 30s
android / Build + lint + test (push) Successful in 4m59s
test-go / integration (push) Successful in 11m14s
release / Build signed APK (tag releases only) (push) Successful in 4m22s
release / Build + push container image (push) Successful in 2m6s
2026-06-03 14:09:23 -04:00
bvandeusen 7c11cdc4d1 fix(server): handleGetStream - auth check before DB lookup
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 9m40s
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.
2026-06-03 13:43:28 -04:00
bvandeusen c3614c6333 fix(server): errcheck violations from UPnP slice
test-go / test (push) Successful in 29s
test-go / integration (push) Has been cancelled
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).
2026-06-03 13:41:54 -04:00
bvandeusen 9e67088fdb fix(server): TestRoutesRegisteredInMount - missing streamSecret arg
test-go / test (push) Failing after 13s
test-go / integration (push) Failing after 9m41s
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.
2026-06-03 13:30:36 -04:00
bvandeusen 6da6cb5c5a 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
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.
2026-06-03 13:22:23 -04:00
bvandeusen 7473e98d91 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
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.
2026-06-03 13:18:17 -04:00
bvandeusen 3df5e5cb3c fix(android): playlist like state + playlist cover URL
android / Build + lint + test (push) Successful in 3m48s
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.
2026-06-03 13:11:55 -04:00
bvandeusen 448c9f2e74 chore(android): UPnP picker - log selectUpnp failures + drop dead fetchJob
android / Build + lint + test (push) Successful in 3m54s
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.
2026-06-03 12:56:45 -04:00
bvandeusen 03cdff547d feat(android): UPnP picker integration (UPnP slice 6/6)
android / Build + lint + test (push) Successful in 3m52s
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.
2026-06-03 12:52:15 -04:00
bvandeusen f8c93e013d feat(android): UPnP SOAP envelope + AVTransport client (UPnP slice 5/6)
android / Build + lint + test (push) Successful in 3m46s
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.
2026-06-03 12:43:54 -04:00
bvandeusen 1f02813cc6 fix(android): UPnP - add kxml2 to test classpath so DeviceDescriptionTest runs
android / Build + lint + test (push) Successful in 4m7s
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.
2026-06-03 12:02:51 -04:00
bvandeusen dc5b8252bb feat(android): UPnP SSDP discovery + device description (UPnP slice 4/6)
android / Build + lint + test (push) Has been cancelled
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.
2026-06-03 12:01:00 -04:00
bvandeusen 5f3905f2c7 feat(android): UPnP picker foundation (UPnP slice 3/6)
android / Build + lint + test (push) Successful in 4m11s
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>
2026-06-03 11:51:11 -04:00
bvandeusen e774097fd8 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
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>
2026-06-03 11:46:57 -04:00
bvandeusen 236637fcd3 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
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.
2026-06-03 11:34:02 -04:00
bvandeusen d7fe515940 fix(android): output picker CI - passive discovery + LongMethod
android / Build + lint + test (push) Successful in 6m26s
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.
2026-06-03 10:49:32 -04:00
bvandeusen 8258b6c29f feat(android): NowPlaying output-picker integration
android / Build + lint + test (push) Failing after 1m33s
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.
2026-06-03 10:42:30 -04:00
bvandeusen d10113db54 fix(android): output picker - add Settings icon to permission hint
android / Build + lint + test (push) Failing after 3m13s
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.
2026-06-03 10:39:05 -04:00
bvandeusen a319e3f66d feat(android): output picker Compose UI - chip + sheet
android / Build + lint + test (push) Has been cancelled
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.
2026-06-03 10:35:48 -04:00
bvandeusen 692d9dab60 feat(android): OutputPickerViewModel - sheet lifecycle + selection
android / Build + lint + test (push) Has been cancelled
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.
2026-06-03 10:33:06 -04:00
bvandeusen 087486d253 feat(android): OutputPickerController - MediaRouter facade
android / Build + lint + test (push) Failing after 2m39s
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.
2026-06-03 10:30:23 -04:00
bvandeusen 0662c9d5cc feat(android): output picker foundation - mediarouter + OutputRoute
android / Build + lint + test (push) Has been cancelled
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.
2026-06-03 10:27:02 -04:00
bvandeusen e69a5204db fix(android): PlayerController.setQueue dispatches to controller thread
android / Build + lint + test (push) Successful in 4m15s
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.
2026-06-03 09:48:38 -04:00
bvandeusen 4be7e47584 chore(android): drop redundant !! on PlaylistRef.systemVariant
android / Build + lint + test (push) Successful in 3m49s
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.
2026-06-03 09:38:24 -04:00
bvandeusen 551bbf83c2 feat(android): wire MediaSession like button + reactive state
android / Build + lint + test (push) Successful in 3m54s
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.
2026-06-03 09:28:37 -04:00
bvandeusen 43754d03c4 revert(android): drop LikeMediaCallback JVM tests + testOptions flag
android / Build + lint + test (push) Successful in 5m26s
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.
2026-06-03 09:19:37 -04:00
bvandeusen 7807e31b22 fix(android): testOptions isReturnDefaultValues = true
android / Build + lint + test (push) Failing after 2m55s
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.
2026-06-03 09:11:59 -04:00
bvandeusen 9a7cfac7f8 fix(android): LikeMediaCallback ReturnCount — extract toggle helper
android / Build + lint + test (push) Failing after 2m51s
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.
2026-06-03 07:40:33 -04:00
bvandeusen d37ef56bb1 feat(android): LikeMediaCallback for media-session like button
android / Build + lint + test (push) Failing after 1m29s
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.
2026-06-02 23:48:25 -04:00
bvandeusen ad7e57fe66 feat(android): scrubber thumb pill — fixes off-center perception
android / Build + lint + test (push) Successful in 4m0s
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.
2026-06-02 22:48:40 -04:00
41 changed files with 2980 additions and 169 deletions
+7
View File
@@ -173,6 +173,7 @@ dependencies {
implementation(libs.media3.exoplayer)
implementation(libs.media3.session)
implementation(libs.media3.datasource.okhttp)
implementation(libs.mediarouter)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
@@ -186,6 +187,12 @@ dependencies {
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.okhttp.mockwebserver)
// kxml2 — provides an org.xmlpull.v1 impl on the JVM unit-test
// classpath. Android's stock XmlPullParserFactory resolves to the
// android.jar Stub on JVM tests; kxml2 is picked up via service-
// provider lookup and makes XmlPullParserFactory.newInstance() work
// unconditionally so DeviceDescriptionTest runs in CI.
testImplementation(libs.kxml2)
// kotlin.test for assertEquals/assertNull/etc. — version managed by
// the applied Kotlin plugin so no explicit version pin needed.
testImplementation(kotlin("test"))
+2
View File
@@ -9,6 +9,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<application
android:name=".MinstrelApplication"
@@ -158,10 +158,43 @@ class MinstrelApplication :
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
// Debug builds get the full DebugTree (verbose). Release builds
// get a WARN+ tree so operator-driven diagnosis via `adb logcat`
// still surfaces UPnP / cast failures, OkHttp errors, and our
// own Timber.w / Timber.e calls — without the chatty DEBUG /
// INFO traffic flooding the buffer in production.
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
} else {
Timber.plant(ReleaseTree())
}
appScope.launch { resumeController.restore() }
}
/**
* Release-build Timber tree: emits at WARN and above only.
* `android.util.Log` with the canonical tag so `adb logcat` shows
* the line under the standard tag column without falling through
* to the package-stack-trace tag DebugTree produces.
*/
private class ReleaseTree : Timber.Tree() {
override fun isLoggable(tag: String?, priority: Int): Boolean =
priority >= android.util.Log.WARN
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
val resolvedTag = tag ?: "Minstrel"
if (t == null) {
android.util.Log.println(priority, resolvedTag, message)
} else {
android.util.Log.println(
priority,
resolvedTag,
message + '\n' + android.util.Log.getStackTraceString(t),
)
}
}
}
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
@@ -0,0 +1,49 @@
package com.fabledsword.minstrel.api.endpoints
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for the cast-token endpoint. Used by the UPnP
* selection path in `OutputPickerController` to obtain a signed stream
* URL that a network speaker can fetch without the user's session
* cookie — those devices cannot carry the session, so the signed
* query string is the only way they can fetch the bytes.
*
* Endpoint: `POST /api/cast/stream-token`
* Auth: standard session cookie (`AuthCookieInterceptor` handles it).
*
* Server contract lives at `internal/api/cast_token.go`
* (commit e774097f). Field names here mirror the server's `json:`
* tags verbatim — `trackId` / `expSeconds` — so no `@SerialName` is
* needed on the request, and `token` / `exp` / `url` map straight
* through on the response.
*/
interface CastApi {
@POST("api/cast/stream-token")
suspend fun streamToken(@Body req: StreamTokenRequest): StreamTokenResponse
}
/**
* Request body. [expSeconds] is clamped server-side to [60, 86400];
* the 21_600 default (6h) is long enough to play through any typical
* track without re-minting mid-playback.
*/
@Serializable
data class StreamTokenRequest(
val trackId: String,
val expSeconds: Int = 21_600,
)
/**
* Response body. [url] is a fully-formed stream URL with [token] and
* [exp] already embedded as query params — callers pass it verbatim
* to `AVTransport.SetAVTransportURI`.
*/
@Serializable
data class StreamTokenResponse(
val token: String,
val exp: Long,
val url: String,
)
@@ -262,7 +262,7 @@ class HomeViewModel @Inject constructor(
val detail = try {
withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) {
if (playlist.refreshable && playlist.systemVariant != null) {
playlistsRepository.systemShuffle(playlist.systemVariant!!)
playlistsRepository.systemShuffle(playlist.systemVariant)
} else {
playlistsRepository.refreshDetail(playlist.id)
}
@@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -56,8 +55,7 @@ class AlbumDetailViewModel @Inject constructor(
)
val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTracks()
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
likes.observeLikedTrackIds()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
@@ -110,6 +110,20 @@ class LikesRepository @Inject constructor(
fun observeIsLiked(entityType: String, entityId: String): Flow<Boolean> =
likeDao.observeIsLiked(currentUserId(), entityType, entityId)
/**
* Reactive set of liked track ids. Use this when the caller only
* needs "is X in liked set", NOT when it needs to render the liked
* tracks themselves — [observeLikedTracks] does a `mapNotNull` join
* against `trackDao` and drops any liked id whose track isn't in
* local cache yet, so an "is liked" UI built on `observeLikedTracks`
* misses cross-device likes whose track row hasn't cached.
*
* Used by playlist/album detail screens to color a row's like
* button independent of whether the track is in the local library.
*/
fun observeLikedTrackIds(): Flow<Set<String>> =
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { it.toSet() }
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
suspend fun likedTrackIds(): Set<String> =
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
@@ -0,0 +1,78 @@
package com.fabledsword.minstrel.player
import android.os.Bundle
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* MediaSession callback that adds the like/heart custom command to
* every connecting controller (notification, lock screen, Pixel Watch,
* Android Auto) and routes taps through [LikesRepository.toggleLike]
* so they inherit the offline-resilient MutationQueue path.
*
* The [onConnect] grant is REQUIRED — without it the button is
* silently invisible on some surfaces (Media3 issue #2679). See the
* spec at docs/superpowers/specs/2026-06-02-android-media3-like-button-design.md.
*
* Suspend work in [onCustomCommand] is launched on [scope] (a
* service-lifetime scope) so the callback returns synchronously and
* the controller does not block on Room + REST. State updates flow
* back through [LikesRepository.observeIsLiked] so the icon refreshes
* via the like-state job in [MinstrelPlayerService].
*/
class LikeMediaCallback(
private val likes: LikesRepository,
private val scope: CoroutineScope,
) : MediaSession.Callback {
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo,
): MediaSession.ConnectionResult {
val grants = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS
.buildUpon()
.add(SessionCommand(CMD_TOGGLE_LIKE, Bundle.EMPTY))
.build()
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(grants)
.build()
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle,
): ListenableFuture<SessionResult> {
val code = if (customCommand.customAction == CMD_TOGGLE_LIKE) {
launchToggleForCurrent(session)
} else {
SessionResult.RESULT_ERROR_NOT_SUPPORTED
}
return Futures.immediateFuture(SessionResult(code))
}
private fun launchToggleForCurrent(session: MediaSession): Int {
val mediaId = session.player.currentMediaItem?.mediaId
?: return SessionResult.RESULT_INFO_SKIPPED
scope.launch {
val current = likes.observeIsLiked(ENTITY_TRACK, mediaId).first()
likes.toggleLike(ENTITY_TRACK, mediaId, !current)
}
return SessionResult.RESULT_SUCCESS
}
companion object {
/** Custom-action key for the like/heart button. Namespaced so
* future custom commands (output picker, etc.) don't collide. */
const val CMD_TOGGLE_LIKE: String = "minstrel.toggle_like"
}
}
@@ -2,11 +2,29 @@ package com.fabledsword.minstrel.player
import android.app.PendingIntent
import android.content.Intent
import android.os.Bundle
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.session.CommandButton
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import androidx.media3.session.SessionCommand
import com.fabledsword.minstrel.MainActivity
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
import com.google.common.collect.ImmutableList
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@@ -20,29 +38,49 @@ import javax.inject.Inject
* MediaButtonReceiver is registered automatically by the library, no
* manual receiver class needed.
*
* The session advertises a single custom CommandButton — the like/heart
* toggle. [LikeMediaCallback] grants the command in `onConnect` and
* routes taps through [LikesRepository.toggleLike] so they inherit the
* offline-resilient MutationQueue path. The icon mirrors server state:
* a service-scoped coroutine collects `currentMediaItem × isLiked` and
* rebuilds the preferences list on each emission so a cross-device
* like (web tap) flips the notification heart automatically.
*
* Lifecycle:
* - onCreate: build the ExoPlayer + MediaSession once.
* - onCreate: build the ExoPlayer + MediaSession once, attach the
* callback, set initial preferences, launch the like-state job.
* - onGetSession: return the live session to any binding controller
* (system UI media card, Wear OS companion, MediaController3 clients).
* - onTaskRemoved: if the user swipes the app away, keep playing when
* audio is active (standard media-app behavior — music shouldn't
* die because the app left recents); otherwise stop the service so
* the lingering notification clears.
* - onDestroy: release the session + player.
* - onDestroy: cancel the service scope, then release the session +
* player. Cancellation comes first so the like-state job doesn't
* touch a released session.
*/
@AndroidEntryPoint
class MinstrelPlayerService : MediaSessionService() {
@Inject lateinit var playerFactory: PlayerFactory
@Inject lateinit var likesRepository: LikesRepository
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var mediaSession: MediaSession? = null
override fun onCreate() {
super.onCreate()
val player = playerFactory.build()
mediaSession = MediaSession.Builder(this, player)
val callback = LikeMediaCallback(likesRepository, serviceScope)
val session = MediaSession.Builder(this, player)
.setSessionActivity(buildNowPlayingPendingIntent())
.setCallback(callback)
.setMediaButtonPreferences(ImmutableList.of(buildLikeButton(isLiked = false)))
.build()
mediaSession = session
serviceScope.launch { observeLikeState(session, player) }
}
/**
@@ -72,6 +110,67 @@ class MinstrelPlayerService : MediaSessionService() {
)
}
/**
* Build the like/heart CommandButton. Icon flips between
* ICON_HEART_FILLED and ICON_HEART_UNFILLED to mirror server-side
* like state. The session command is the same in both states so
* the callback's onCustomCommand handles them identically (it
* inverts the current observed state regardless of which icon was
* tapped).
*/
private fun buildLikeButton(isLiked: Boolean): CommandButton {
val icon = if (isLiked) {
CommandButton.ICON_HEART_FILLED
} else {
CommandButton.ICON_HEART_UNFILLED
}
return CommandButton.Builder(icon)
.setDisplayName(if (isLiked) "Unlike" else "Like")
.setSessionCommand(SessionCommand(LikeMediaCallback.CMD_TOGGLE_LIKE, Bundle.EMPTY))
.build()
}
/**
* Collect player.currentMediaItem changes (via a Player.Listener
* lifted into a Flow) and, for each non-null mediaId, observe its
* liked state. On every emission, rebuild the session's media
* button preferences with the icon flipped accordingly.
* flatMapLatest cancels the previous track's subscription so we
* never leak Flows across track transitions.
*/
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun observeLikeState(session: MediaSession, player: Player) {
currentMediaIdFlow(player)
.flatMapLatest { mediaId ->
if (mediaId == null) {
flowOf(false)
} else {
likesRepository.observeIsLiked(ENTITY_TRACK, mediaId)
}
}
.collect { isLiked ->
session.setMediaButtonPreferences(
ImmutableList.of(buildLikeButton(isLiked = isLiked)),
)
}
}
/**
* Lift Player.currentMediaItem changes into a Flow. Emits the
* current mediaId on subscription so the initial icon state is
* correct on the first frame the controller renders (no
* unfilled-then-flips flicker).
*/
private fun currentMediaIdFlow(player: Player) = callbackFlow<String?> {
val listener = object : Player.Listener {
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
trySend(item?.mediaId)
}
}
player.addListener(listener)
awaitClose { player.removeListener(listener) }
}.onStart { emit(player.currentMediaItem?.mediaId) }
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
mediaSession
@@ -85,6 +184,7 @@ class MinstrelPlayerService : MediaSessionService() {
}
override fun onDestroy() {
serviceScope.cancel()
mediaSession?.run {
player.release()
release()
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.player
import android.content.ComponentName
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
@@ -187,9 +189,26 @@ class PlayerController @Inject constructor(
val controller = mediaController ?: return
queueRefs = tracks
val items = tracks.map { it.toMediaItem(source) }
controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
controller.prepare()
if (autoplay) controller.play()
// Drift #562 cold-boot resume calls this from a non-Main suspend
// context after awaitReady() unblocks (ResumeController launches
// on Dispatchers.Default by the time it reaches us). MediaController
// enforces application-thread access and throws
// IllegalStateException otherwise — post to its applicationLooper
// if we're already there, run directly to avoid the re-dispatch
// latency UI callers depend on.
runOnControllerThread(controller) {
controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
controller.prepare()
if (autoplay) controller.play()
}
}
private fun runOnControllerThread(controller: MediaController, block: () -> Unit) {
if (Looper.myLooper() == controller.applicationLooper) {
block()
} else {
Handler(controller.applicationLooper).post(block)
}
}
/**
@@ -0,0 +1,84 @@
package com.fabledsword.minstrel.player.output
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Bluetooth
import com.composables.icons.lucide.Cast
import com.composables.icons.lucide.ChevronDown
import com.composables.icons.lucide.Headphones
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Smartphone
import com.composables.icons.lucide.Speaker
/**
* Spotify-style chip showing the current output route. Sits between
* BottomActionsRow and ScrubberRow in NowPlayingScreen. Tap to open
* the picker sheet.
*
* Visibility rule per the spec: hidden when the route list has
* exactly one entry AND that entry is BuiltIn — no reason to surface
* a picker for "the only thing available." Visibility logic owned
* by the caller (NowPlayingScreen).
*/
@Composable
fun DeviceChip(
route: OutputRoute,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
onClick = onClick,
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = iconFor(route.kind),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(CHIP_ICON_DP.dp),
)
Text(
text = route.name,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
imageVector = Lucide.ChevronDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(CHIP_CHEVRON_DP.dp),
)
}
}
}
internal fun iconFor(kind: OutputRoute.Kind): ImageVector = when (kind) {
OutputRoute.Kind.BuiltIn -> Lucide.Smartphone
OutputRoute.Kind.Wired -> Lucide.Headphones
OutputRoute.Kind.Bluetooth -> Lucide.Bluetooth
OutputRoute.Kind.Cast -> Lucide.Cast
OutputRoute.Kind.Other -> Lucide.Speaker
}
private const val CHIP_ICON_DP = 16
private const val CHIP_CHEVRON_DP = 14
@@ -0,0 +1,237 @@
package com.fabledsword.minstrel.player.output
import android.content.Context
import androidx.mediarouter.media.MediaControlIntent
import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import com.fabledsword.minstrel.api.endpoints.CastApi
import com.fabledsword.minstrel.api.endpoints.StreamTokenRequest
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import retrofit2.create
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Snapshot of the audio output route state. [current] is the live
* route audio is being delivered to. [available] is every route the
* picker knows about — MediaRouter system routes merged with
* UPnP/DLNA renderers discovered on the LAN — sorted current-first
* then by [OutputRoute.Kind] (Bluetooth, Wired, BuiltIn, Other).
*/
data class RouteSnapshot(
val current: OutputRoute,
val available: List<OutputRoute>,
)
/**
* Hilt-singleton facade over [MediaRouter] + [UpnpDiscoveryController].
* Owns the callback lifecycle — default passive behavior at process
* start (route list updates without forcing Bluetooth scans), upgrades
* to active discovery while the picker sheet is open so newly-paired
* devices appear promptly. The [routesState] StateFlow projects the
* current route + sorted available list as a [RouteSnapshot]; the
* picker ViewModel collects it.
*
* Selection branches on [OutputRoute.Protocol]:
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
* wired, Bluetooth)
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
* [CastApi.streamToken], drive the discovered renderer with
* AVTransport.SetAVTransportURI + Play, pause local playback so
* audio yields to the network speaker
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
* reserved for follow-up slices; ignored for now.
*
* Mirrors the OutputPickerController role described in
* docs/superpowers/specs/2026-06-03-android-output-picker-bluetooth-design.md
* and the UPnP extensions in
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
*/
@Singleton
class OutputPickerController @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope private val scope: CoroutineScope,
private val upnpDiscovery: UpnpDiscoveryController,
private val playerController: PlayerController,
retrofit: Retrofit,
) {
private val castApi: CastApi = retrofit.create()
private val mediaRouter = MediaRouter.getInstance(context)
private val selector = MediaRouteSelector.Builder()
.addControlCategory(MediaControlIntent.CATEGORY_LIVE_AUDIO)
.build()
/**
* Internal projection of the live MediaRouter snapshot. Refreshed
* on every Callback event; combined downstream with UPnP routes
* into the public [routesState].
*/
private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter())
val routesState: StateFlow<RouteSnapshot> = combine(
systemRoutesInternal,
upnpDiscovery.routes,
) { sys, upnp ->
val merged = sys.available + upnp.map { OutputRoute.fromUpnpRoute(it) }
RouteSnapshot(current = sys.current, available = sortRoutes(sys.current, merged))
}.stateIn(scope, SharingStarted.Eagerly, systemRoutesInternal.value)
private val callback = object : MediaRouter.Callback() {
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) =
refresh()
override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) =
refresh()
override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) =
refresh()
override fun onRouteSelected(
router: MediaRouter,
route: MediaRouter.RouteInfo,
reason: Int,
) = refresh()
override fun onRouteUnselected(
router: MediaRouter,
route: MediaRouter.RouteInfo,
reason: Int,
) = refresh()
}
init {
// Two-arg addCallback registers with no discovery flag —
// androidx.mediarouter 1.7.0's default passive behavior:
// route-list updates flow through onRouteAdded/Removed/Changed
// without forcing Bluetooth scans. (There is no
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
mediaRouter.addCallback(selector, callback)
}
/**
* Upgrade callback registration to active discovery — call when
* the picker sheet opens so newly-paired Bluetooth devices
* surface within a few seconds, and fire an SSDP M-SEARCH burst
* for UPnP renderers. Idempotent: re-registering with a
* new flag set replaces the prior registration in MediaRouter.
*/
fun upgradeDiscovery() {
mediaRouter.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY)
upnpDiscovery.upgradeDiscovery()
}
/**
* Downgrade callback registration back to default passive behavior
* — call when the picker sheet closes so we don't keep Bluetooth
* scanning on for battery cost. Two-arg overload = no flag =
* passive. Same idempotent re-registration semantics. The UPnP
* side has no symmetric downgrade (passive SSDP NOTIFY listen is
* always on); the call is preserved for API parity.
*/
fun downgradeDiscovery() {
mediaRouter.addCallback(selector, callback)
upnpDiscovery.downgradeDiscovery()
}
/**
* Select [route]. SYSTEM routes hand off to [MediaRouter]; UPNP
* routes mint a signed stream token + drive AVTransport on the
* discovered renderer + pause local playback. Other protocols
* (CAST / SONOS) are reserved for follow-up slices and are
* silently ignored — the picker shouldn't show them yet.
*/
fun select(route: OutputRoute) {
when (route.protocol) {
OutputRoute.Protocol.SYSTEM -> selectSystem(route)
OutputRoute.Protocol.UPNP -> scope.launch { selectUpnp(route) }
OutputRoute.Protocol.CAST, OutputRoute.Protocol.SONOS -> Unit
}
}
private fun selectSystem(route: OutputRoute) {
val target = mediaRouter.routes.firstOrNull { it.id == route.id } ?: return
mediaRouter.selectRoute(target)
}
/**
* Drive the UPnP renderer: mint a token for the currently playing
* track, set the renderer's URI, play, then pause local playback so
* audio yields to the speaker. Wrapped in `runCatching` at each
* step — token failure, transport-lookup failure, and SOAP failure
* each abandon the selection cleanly rather than crashing. Failures
* log at warn level via Timber so on-device verification can find
* the cause in logcat (OkHttp's logger doesn't cover our own
* deserialize / SOAP-parse code paths).
*/
private suspend fun selectUpnp(route: OutputRoute) {
val trackId = playerController.uiState.value.currentTrack?.id
if (trackId == null) {
Timber.w("UPnP select skipped: no currentTrack (start playback first)")
return
}
val transport = upnpDiscovery.transportFor(route.id)
if (transport == null) {
Timber.w(
"UPnP select skipped: no transport for route id=${route.id} " +
"(route disappeared or id mismatch with discovery list)",
)
return
}
runCatching {
Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}")
val token = castApi.streamToken(StreamTokenRequest(trackId = trackId))
Timber.i("UPnP select: SetAVTransportURI to ${token.url}")
transport.setAVTransportURI(token.url)
Timber.i("UPnP select: Play")
transport.play()
playerController.pause()
Timber.i("UPnP select: done")
}.onFailure { e ->
Timber.w(e, "UPnP select failed for route ${route.id}")
}
}
private fun refresh() {
systemRoutesInternal.value = snapshotFromRouter()
}
private fun snapshotFromRouter(): RouteSnapshot {
val all = mediaRouter.routes
.filter { it.matchesSelector(selector) }
.map { OutputRoute.fromRouteInfo(it) }
val current = OutputRoute.fromRouteInfo(mediaRouter.selectedRoute)
return RouteSnapshot(current = current, available = sortRoutes(current, all))
}
/**
* Selected first, then Bluetooth, then Wired, then BuiltIn, then
* Other (UPnP renderers fall in Other). Keeps the active output at
* the top + likely-wanted alternatives next + fallback last.
*/
private fun sortRoutes(current: OutputRoute, all: List<OutputRoute>): List<OutputRoute> {
val rank: (OutputRoute) -> Int = { route ->
when {
route.id == current.id -> 0
route.kind == OutputRoute.Kind.Bluetooth -> 1
route.kind == OutputRoute.Kind.Wired -> 2
route.kind == OutputRoute.Kind.BuiltIn -> 3
else -> 4
}
}
return all.sortedBy(rank)
}
}
@@ -0,0 +1,200 @@
package com.fabledsword.minstrel.player.output
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Circle
import com.composables.icons.lucide.CircleCheck
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Settings
import com.composables.icons.lucide.WifiOff
/**
* Material 3 ModalBottomSheet listing the available output routes.
* Tap a row to select + dismiss. Selected route shows the accent
* CircleCheck; others show an empty Circle. Long route names
* truncate cleanly via maxLines = 2 + Ellipsis.
*
* Two footer hints, each driven by a flag the host screen owns:
* - [permissionDenied] — NowPlayingScreen owns the BLUETOOTH_CONNECT
* request flow and sets this true on denial so the user sees the
* "pair in Settings" affordance.
* - [noUpnpDiscovered] — set true when the picker has been open for
* a few seconds and no UPnP renderers have arrived; surfaces the
* "router may be blocking multicast" hint. Defaults to false so
* existing call sites that haven't wired the discovery-timing
* logic continue to render without the hint.
*
* Keeping the hint flags external preserves this composable's
* focus-on-rendering shape.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OutputPickerSheet(
snapshot: RouteSnapshot,
permissionDenied: Boolean,
onRouteSelected: (OutputRoute) -> Unit,
onDismiss: () -> Unit,
noUpnpDiscovered: Boolean = false,
) {
val sheetState = rememberModalBottomSheetState()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "Output",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(vertical = 8.dp),
)
snapshot.available.forEach { route ->
RouteRow(
route = route,
isSelected = route.id == snapshot.current.id,
onClick = { onRouteSelected(route) },
)
}
if (permissionDenied) {
PermissionHintRow()
}
if (noUpnpDiscovered) {
MulticastHintRow()
}
}
}
}
@Composable
private fun RouteRow(
route: OutputRoute,
isSelected: Boolean,
onClick: () -> Unit,
) {
val tint = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
onClick = onClick,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
imageVector = iconFor(route.kind),
contentDescription = null,
tint = tint,
modifier = Modifier.size(ROW_ICON_DP.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = route.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val subtitle = route.description ?: defaultSubtitle(route)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = if (isSelected) Lucide.CircleCheck else Lucide.Circle,
contentDescription = if (isSelected) "Selected" else "Not selected",
tint = tint,
modifier = Modifier.size(ROW_ICON_DP.dp),
)
}
}
}
@Composable
private fun PermissionHintRow() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Lucide.Settings,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(HINT_ICON_DP.dp),
)
Text(
text = "Pair a Bluetooth device in Settings to see it here.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun MulticastHintRow() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Lucide.WifiOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(HINT_ICON_DP.dp),
)
Text(
text = "Your router may be blocking multicast discovery.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
OutputRoute.Kind.BuiltIn -> "Phone speaker"
OutputRoute.Kind.Wired -> "Wired"
OutputRoute.Kind.Bluetooth -> if (route.isConnected) "Connected" else "Available"
OutputRoute.Kind.Cast -> "Cast"
OutputRoute.Kind.Other -> "Available"
}
private const val ROW_ICON_DP = 24
private const val HINT_ICON_DP = 20
@@ -0,0 +1,60 @@
package com.fabledsword.minstrel.player.output
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
/**
* NowPlaying-scoped projection over [OutputPickerController]. The
* controller is the long-lived Hilt singleton (its MediaRouter
* callback must not churn with screen lifecycle); this ViewModel is
* a thin lens over its [OutputPickerController.routesState] Flow
* plus the sheet's visibility state.
*
* Sheet open/close are forwarded to the controller's discovery
* toggle so active MediaRouter discovery only runs while the sheet
* is actually visible.
*/
@HiltViewModel
class OutputPickerViewModel @Inject constructor(
private val controller: OutputPickerController,
) : ViewModel() {
val routes: StateFlow<RouteSnapshot> = controller.routesState
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS),
initialValue = controller.routesState.value,
)
private val sheetVisibleInternal = MutableStateFlow(false)
val sheetVisible: StateFlow<Boolean> = sheetVisibleInternal.asStateFlow()
fun onChipTapped() {
sheetVisibleInternal.value = true
controller.upgradeDiscovery()
}
fun onSheetDismissed() {
sheetVisibleInternal.value = false
controller.downgradeDiscovery()
}
fun onRouteSelected(route: OutputRoute) {
controller.select(route)
sheetVisibleInternal.value = false
controller.downgradeDiscovery()
}
private companion object {
// SharingStarted timeout so quick screen-orientation changes
// don't tear down + re-subscribe the controller's Flow.
const val STOP_TIMEOUT_MS = 5_000L
}
}
@@ -0,0 +1,103 @@
package com.fabledsword.minstrel.player.output
import androidx.mediarouter.media.MediaRouter
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
/**
* Narrow domain model for an audio output route. Independent of
* [MediaRouter.RouteInfo] so the picker UI can preview without an
* Android framework presence (RouteInfo can't be constructed in
* JVM tests, mirroring the LikeMediaCallback constraint).
*
* The [protocol] field is a forward-compatibility hook for the
* UPnP / DLNA / Sonos / Cast slice (scope captured in
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md).
* THIS slice always sets `protocol = SYSTEM` — the system-route
* categories MediaRouter surfaces (built-in / wired / Bluetooth).
*/
data class OutputRoute(
val id: String,
val name: String,
val description: String?,
val kind: Kind,
val protocol: Protocol,
val isConnected: Boolean,
) {
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
enum class Protocol {
/** System-managed routes — built-in speaker, wired, Bluetooth. */
SYSTEM,
/** Generic UPnP / DLNA renderers — reserved for the next slice. */
UPNP,
/** Chromecast via the Cast SDK — reserved for a later slice. */
CAST,
/** Sonos via the Sonos extension on top of UPnP — reserved. */
SONOS,
}
companion object {
/**
* Lift a MediaRouter [route] into the domain model. Kind is
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
* device types fall through to [Kind.Other]. The
* `connectionState` proxy is good enough for the chip's
* "Connected"/"Available" subtitle.
*/
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
val kind = when (route.deviceType) {
MediaRouter.RouteInfo.DEVICE_TYPE_BUILTIN_SPEAKER -> Kind.BuiltIn
MediaRouter.RouteInfo.DEVICE_TYPE_WIRED_HEADSET,
MediaRouter.RouteInfo.DEVICE_TYPE_WIRED_HEADPHONES,
-> Kind.Wired
MediaRouter.RouteInfo.DEVICE_TYPE_BLUETOOTH_A2DP -> Kind.Bluetooth
MediaRouter.RouteInfo.DEVICE_TYPE_TV -> Kind.Other
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
else -> Kind.Other
}
val connected =
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
return OutputRoute(
id = route.id,
name = route.name,
description = route.description,
kind = kind,
protocol = Protocol.SYSTEM,
isConnected = connected,
)
}
/**
* Lift a discovered UPnP renderer into the picker's domain
* model. Used by the UPnP discovery controller to merge
* network speakers into the same `OutputPickerController`
* routes stream the system routes come through.
*
* `isConnected = false` because UPnP devices have no
* MediaRouter connection-state concept — they're always
* "available" on the LAN, and the picker's selected-route
* rendering handles the "currently playing" indicator.
*
* Subtitle is `manufacturer modelName` joined by a single
* space, falling back to "Network speaker" when both fields
* are blank.
*/
fun fromUpnpRoute(route: UpnpRoute): OutputRoute {
val description = listOfNotNull(
route.manufacturer.takeIf { it.isNotBlank() },
route.modelName.takeIf { it.isNotBlank() },
).joinToString(" ").ifBlank { "Network speaker" }
return OutputRoute(
id = route.id,
name = route.name,
description = description,
kind = Kind.Other,
protocol = Protocol.UPNP,
isConnected = false,
)
}
}
}
@@ -0,0 +1,50 @@
package com.fabledsword.minstrel.player.output.upnp
import okhttp3.HttpUrl
/**
* High-level wrapper for the UPnP AVTransport service. Three calls
* for v1: SetAVTransportURI / Play / Stop. Pause + Seek deferred
* until we have hardware in the loop to verify each device's quirks
* (Sonos and BubbleUPnP accept the standard shape; some smart TVs
* reject Pause without DIDL).
*/
class AVTransportClient(
private val soap: SoapClient,
private val controlUrl: HttpUrl,
) {
suspend fun setAVTransportURI(uri: String, metadata: String = "") {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "SetAVTransportURI",
args = mapOf(
"InstanceID" to "0",
"CurrentURI" to uri,
"CurrentURIMetaData" to metadata,
),
)
}
suspend fun play() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Play",
args = mapOf("InstanceID" to "0", "Speed" to "1"),
)
}
suspend fun stop() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Stop",
args = mapOf("InstanceID" to "0"),
)
}
private companion object {
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
}
}
@@ -0,0 +1,122 @@
package com.fabledsword.minstrel.player.output.upnp
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
/**
* Pull-parsed UPnP device description (the XML returned from a
* discovered LOCATION URL). Carries the bits we actually need for
* the picker: friendlyName / manufacturer / modelName for display,
* AVTransport + RenderingControl control URLs for command dispatch.
*
* Filtered to MediaRenderer-capable devices — anything without an
* AVTransport service control URL is dropped by [parse] returning
* null (we can't make it play).
*/
data class DeviceDescription(
val udn: String,
val friendlyName: String,
val manufacturer: String,
val modelName: String,
val avTransportControlUrl: HttpUrl,
val renderingControlUrl: HttpUrl?,
) {
companion object {
private const val AVT_SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
private const val RC_SERVICE_TYPE = "urn:schemas-upnp-org:service:RenderingControl:1"
private const val TAG_SERVICE = "service"
private const val TAG_UDN = "UDN"
private const val TAG_FRIENDLY_NAME = "friendlyName"
private const val TAG_MANUFACTURER = "manufacturer"
private const val TAG_MODEL_NAME = "modelName"
private const val TAG_SERVICE_TYPE = "serviceType"
private const val TAG_CONTROL_URL = "controlURL"
/**
* Parse [xml] (the body fetched from the SSDP LOCATION URL),
* resolving relative service control URLs against [base].
* Returns null when AVTransport is missing — we have no way
* to control the device without it.
*/
fun parse(xml: String, base: HttpUrl): DeviceDescription? {
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setInput(xml.reader())
}
val acc = ParseState()
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
handleEvent(parser, acc, base)
parser.next()
}
val avt = acc.avtControlUrl ?: return null
return DeviceDescription(
udn = acc.udn,
friendlyName = acc.friendlyName,
manufacturer = acc.manufacturer,
modelName = acc.modelName,
avTransportControlUrl = avt,
renderingControlUrl = acc.rcControlUrl,
)
}
private fun handleEvent(parser: XmlPullParser, acc: ParseState, base: HttpUrl) {
when (parser.eventType) {
XmlPullParser.START_TAG -> handleStartTag(parser, acc)
XmlPullParser.END_TAG -> handleEndTag(parser, acc, base)
else -> Unit
}
}
private fun handleStartTag(parser: XmlPullParser, acc: ParseState) {
when (parser.name) {
TAG_SERVICE -> {
acc.inService = true
acc.serviceType = ""
acc.serviceControlUrl = ""
}
TAG_UDN -> acc.udn = parser.nextTextSafe()
TAG_FRIENDLY_NAME -> acc.friendlyName = parser.nextTextSafe()
TAG_MANUFACTURER -> acc.manufacturer = parser.nextTextSafe()
TAG_MODEL_NAME -> acc.modelName = parser.nextTextSafe()
TAG_SERVICE_TYPE -> if (acc.inService) acc.serviceType = parser.nextTextSafe()
TAG_CONTROL_URL -> if (acc.inService) acc.serviceControlUrl = parser.nextTextSafe()
}
}
private fun handleEndTag(parser: XmlPullParser, acc: ParseState, base: HttpUrl) {
if (parser.name != TAG_SERVICE) return
val resolved = resolveControlUrl(base, acc.serviceControlUrl)
when (acc.serviceType) {
AVT_SERVICE_TYPE -> acc.avtControlUrl = resolved
RC_SERVICE_TYPE -> acc.rcControlUrl = resolved
}
acc.inService = false
}
private fun resolveControlUrl(base: HttpUrl, path: String): HttpUrl? {
if (path.isBlank()) return null
return path.toHttpUrlOrNull() ?: base.resolve(path)
}
private fun XmlPullParser.nextTextSafe(): String =
runCatching { nextText() }.getOrDefault("")
}
/**
* Mutable accumulator used during pull-parsing. Lives only for the
* duration of one [parse] call.
*/
private class ParseState {
var udn: String = ""
var friendlyName: String = ""
var manufacturer: String = ""
var modelName: String = ""
var avtControlUrl: HttpUrl? = null
var rcControlUrl: HttpUrl? = null
var inService: Boolean = false
var serviceType: String = ""
var serviceControlUrl: String = ""
}
}
@@ -0,0 +1,140 @@
package com.fabledsword.minstrel.player.output.upnp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
/**
* Minimal SOAP/UPnP envelope builder + POST. Hand-rolled rather than
* pulled in via jupnp; we control every line and integrate cleanly
* with the app's OkHttpClient (shared connection pool, timeouts).
*
* Builds the standard SOAP 1.1 envelope, POSTs with the required
* SOAPACTION + Content-Type headers, returns the parsed
* `<action>Response` element as a Map<String, String> (UPnP responses
* are flat string maps).
*
* Throws [SoapFaultException] on a `<s:Fault>` response (the UPnP
* device's way of saying "I rejected your request"). Other transport
* errors propagate as IOException.
*/
class SoapClient(
private val okHttp: OkHttpClient,
) {
suspend fun call(
controlUrl: HttpUrl,
serviceType: String,
action: String,
args: Map<String, String> = emptyMap(),
): Map<String, String> = withContext(Dispatchers.IO) {
val envelope = buildEnvelope(serviceType, action, args)
val request = Request.Builder()
.url(controlUrl)
.post(envelope.toRequestBody(null))
.header("Content-Type", SOAP_CONTENT_TYPE)
.header("SOAPACTION", "\"$serviceType#$action\"")
.build()
okHttp.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
if (!response.isSuccessful) {
throw SoapFaultException(faultCodeOf(body), faultDescriptionOf(body))
}
parseResponseArgs(body, action)
}
}
private fun buildEnvelope(
serviceType: String,
action: String,
args: Map<String, String>,
): String = buildString {
append("<?xml version=\"1.0\"?>")
append("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"")
append(" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">")
append("<s:Body>")
append("<u:").append(action)
append(" xmlns:u=\"").append(serviceType).append("\">")
args.forEach { (k, v) ->
append("<").append(k).append(">")
append(xmlEscape(v))
append("</").append(k).append(">")
}
append("</u:").append(action).append(">")
append("</s:Body>")
append("</s:Envelope>")
}
private fun xmlEscape(v: String): String = v
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
private fun parseResponseArgs(body: String, action: String): Map<String, String> {
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setInput(body.reader())
}
val responseTag = "${action}Response"
val args = mutableMapOf<String, String>()
var inResponse = false
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
inResponse = handleParserEvent(parser, responseTag, inResponse, args)
parser.next()
}
return args
}
private fun handleParserEvent(
parser: XmlPullParser,
responseTag: String,
inResponse: Boolean,
args: MutableMap<String, String>,
): Boolean = when (parser.eventType) {
XmlPullParser.START_TAG -> {
if (parser.name == responseTag) {
true
} else {
if (inResponse) {
val name = parser.name
val text = runCatching { parser.nextText() }.getOrDefault("")
args[name] = text
}
inResponse
}
}
XmlPullParser.END_TAG -> if (parser.name == responseTag) false else inResponse
else -> inResponse
}
private fun faultCodeOf(body: String): String =
extractBetween(body, "<errorCode>", "</errorCode>") ?: "unknown"
private fun faultDescriptionOf(body: String): String =
extractBetween(body, "<errorDescription>", "</errorDescription>").orEmpty()
private fun extractBetween(body: String, open: String, close: String): String? {
val start = body.indexOf(open)
if (start < 0) return null
val contentStart = start + open.length
val end = body.indexOf(close, contentStart)
return if (end < 0) null else body.substring(contentStart, end)
}
private companion object {
const val SOAP_CONTENT_TYPE = "text/xml; charset=\"utf-8\""
}
}
/**
* Thrown when a UPnP device responds with `<s:Fault>` — typically wraps
* a UPnPError with `errorCode` + `errorDescription`. Code is preserved
* as a string (UPnP codes are numeric in spec but we don't constrain).
*/
class SoapFaultException(val code: String, val description: String) :
Exception("SOAP fault $code: $description")
@@ -0,0 +1,126 @@
package com.fabledsword.minstrel.player.output.upnp
import android.content.Context
import android.net.wifi.WifiManager
import androidx.core.content.getSystemService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.DatagramPacket
import java.net.InetAddress
import java.net.MulticastSocket
import java.nio.charset.StandardCharsets
/**
* UDP multicast SSDP listener + M-SEARCH sender. Emits each discovery
* response's LOCATION URL on the [discoveries] SharedFlow; the
* `UpnpDiscoveryController` follows up by fetching + parsing each
* device description.
*
* Passive listen is always-on once [start] is called (NOTIFY packets
* speakers send when they boot / refresh). Active discovery (an
* explicit M-SEARCH request) is triggered by [requestActiveScan] —
* called when the picker sheet opens so newly-paired devices appear
* promptly.
*
* WifiManager.MulticastLock is held while the listener is running.
* Released by [stop] / cancellation of the parent scope.
*/
class SsdpDiscovery(
private val context: Context,
) {
private val discoveriesInternal =
MutableSharedFlow<String>(extraBufferCapacity = DISCOVERY_BUFFER_CAPACITY)
val discoveries: SharedFlow<String> = discoveriesInternal.asSharedFlow()
private var multicastLock: WifiManager.MulticastLock? = null
private var socket: MulticastSocket? = null
private var listenJob: Job? = null
fun start(scope: CoroutineScope) {
if (listenJob != null) return
val wifi = context.getSystemService<WifiManager>() ?: return
multicastLock = wifi.createMulticastLock(MULTICAST_LOCK_TAG).apply {
setReferenceCounted(false)
acquire()
}
val sock = MulticastSocket(ANY_LOCAL_PORT).apply {
joinGroup(InetAddress.getByName(SSDP_MULTICAST_ADDR))
}
socket = sock
listenJob = scope.launch(Dispatchers.IO) {
val buf = ByteArray(SOCKET_READ_BUFFER_BYTES)
val packet = DatagramPacket(buf, buf.size)
while (isActive) {
runCatching { sock.receive(packet) }.onSuccess {
val raw = String(packet.data, 0, packet.length, StandardCharsets.UTF_8)
parseLocation(raw)?.let { discoveriesInternal.tryEmit(it) }
}
}
}
}
fun stop() {
listenJob?.cancel()
listenJob = null
runCatching { socket?.close() }
socket = null
runCatching { multicastLock?.release() }
multicastLock = null
}
/**
* Send an M-SEARCH packet asking for MediaRenderer:1 devices.
* Responses arrive on the listener socket and emit via
* [discoveries] as their LOCATION URL.
*/
suspend fun requestActiveScan() = withContext(Dispatchers.IO) {
val sock = socket ?: return@withContext
val payload = buildMSearchPayload().toByteArray(StandardCharsets.UTF_8)
val packet = DatagramPacket(
payload,
payload.size,
InetAddress.getByName(SSDP_MULTICAST_ADDR),
SSDP_PORT,
)
runCatching { sock.send(packet) }
Unit
}
private fun buildMSearchPayload(): String = buildString {
append("M-SEARCH * HTTP/1.1\r\n")
append("HOST: ").append(SSDP_MULTICAST_ADDR).append(":").append(SSDP_PORT).append("\r\n")
append("MAN: \"ssdp:discover\"\r\n")
append("MX: ").append(MSEARCH_MX_SECONDS).append("\r\n")
append("ST: ").append(MEDIA_RENDERER_TARGET).append("\r\n")
append("\r\n")
}
private fun parseLocation(raw: String): String? {
raw.lineSequence().forEach { line ->
val trimmed = line.trim()
if (trimmed.startsWith(LOCATION_HEADER, ignoreCase = true)) {
return trimmed.substring(LOCATION_HEADER.length).trim()
}
}
return null
}
private companion object {
const val SSDP_MULTICAST_ADDR = "239.255.255.250"
const val SSDP_PORT = 1900
const val ANY_LOCAL_PORT = 0
const val SOCKET_READ_BUFFER_BYTES = 4096
const val DISCOVERY_BUFFER_CAPACITY = 32
const val MSEARCH_MX_SECONDS = 2
const val MULTICAST_LOCK_TAG = "minstrel.upnp.ssdp"
const val MEDIA_RENDERER_TARGET = "urn:schemas-upnp-org:device:MediaRenderer:1"
const val LOCATION_HEADER = "LOCATION:"
}
}
@@ -0,0 +1,157 @@
package com.fabledsword.minstrel.player.output.upnp
import android.content.Context
import com.fabledsword.minstrel.di.ApplicationScope
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import javax.inject.Inject
import javax.inject.Singleton
/**
* Owns the SSDP listener lifecycle, fetches each discovered LOCATION's
* device description XML, and projects discovered MediaRenderers into a
* `StateFlow<List<UpnpRoute>>` that [OutputPickerController] merges with
* system routes.
*
* Passive listen runs from process start (via `init`). Active discovery
* (an M-SEARCH burst) is triggered by [upgradeDiscovery] when the
* picker sheet opens; SSDP has no symmetric "downgrade" — the passive
* NOTIFY listener stays on for the process lifetime, so
* [downgradeDiscovery] is a no-op preserved for API parity with the
* MediaRouter-side controller.
*
* Discovered devices are de-duplicated by UDN: a repeated NOTIFY for
* the same speaker replaces the prior entry rather than appending a
* duplicate row to the picker.
*/
@Singleton
class UpnpDiscoveryController @Inject constructor(
@ApplicationContext context: Context,
@ApplicationScope private val appScope: CoroutineScope,
private val okHttp: OkHttpClient,
) {
private val ssdp = SsdpDiscovery(context)
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
init {
ssdp.start(appScope)
// appScope is process-lifetime (SupervisorJob + Dispatchers.Default),
// so the launched collector dies with the process — no explicit
// cancellation needed.
appScope.launch(Dispatchers.IO) {
ssdp.discoveries.collect { locationUrl -> handleDiscovery(locationUrl) }
}
}
/**
* Fire an M-SEARCH burst so newly-paired speakers appear within a
* few seconds rather than waiting for the next NOTIFY beacon
* (typical SSDP cadence: every ~30min — far too slow for a UI
* that just opened).
*/
fun upgradeDiscovery() {
appScope.launch { ssdp.requestActiveScan() }
}
/**
* No-op for the SSDP socket — passive NOTIFY listen is always on
* once [init] has run. Kept symmetric with the MediaRouter side's
* `downgradeDiscovery` so the picker controller fans out to both
* without conditional logic.
*/
fun downgradeDiscovery() {
// intentionally empty: see kdoc
}
/**
* Build an [AVTransportClient] bound to the previously-discovered
* route's control URL. Returns null when the routeId isn't in the
* current snapshot — the speaker disappeared between picker open
* and tap, or the caller passed a non-UPnP routeId. Callers handle
* the null by abandoning the selection (no crash, no fallback).
*/
fun transportFor(routeId: String): AVTransportClient? {
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
return AVTransportClient(SoapClient(okHttp), route.avTransportControlUrl)
}
private suspend fun handleDiscovery(locationUrl: String) {
val route = fetchRoute(locationUrl) ?: return
routesInternal.value =
routesInternal.value.filterNot { it.id == route.id } + route
}
/**
* Fetch + parse the device description at [locationUrl] into a
* [UpnpRoute]. Returns null on URL parse failure, transport
* failure, empty body, or non-renderer device. Single-return form
* (chained `let`s + an early null guard) so detekt's default
* ReturnCount cap is respected.
*/
private fun fetchRoute(locationUrl: String): UpnpRoute? {
val url = locationUrl.toHttpUrlOrNull() ?: return null
return fetchBody(url)
?.let { DeviceDescription.parse(it, url) }
?.let { desc ->
UpnpRoute(
id = desc.udn,
name = displayName(desc.friendlyName, desc.manufacturer),
manufacturer = desc.manufacturer,
modelName = desc.modelName,
avTransportControlUrl = desc.avTransportControlUrl,
renderingControlUrl = desc.renderingControlUrl,
)
}
}
/**
* Clean the raw UPnP friendlyName for picker display. Sonos uses
* the format `Room - Device Type - RINCON_<UDN>`; we strip
* everything after the first " - " so the chip shows just "Living
* Room" / "Kitchen" / etc. Generic UPnP devices (Yamaha, Samsung
* TV, etc.) often append a "(192.168.x.x)" IP suffix; strip that
* too. Empty / blank → "Network speaker" fallback.
*
* Device subtitle (manufacturer + model) is rendered separately
* by the picker sheet, so room-only here doesn't lose information.
*/
private fun displayName(friendlyName: String, manufacturer: String): String {
val trimmed = friendlyName.trim()
if (trimmed.isBlank()) return "Network speaker"
val byVendor = when {
manufacturer.contains("Sonos", ignoreCase = true) -> {
trimmed.substringBefore(" - ", trimmed)
}
else -> trimmed
}
val withoutIpSuffix = byVendor.replace(IP_SUFFIX_REGEX, "").trim()
return withoutIpSuffix.ifBlank { "Network speaker" }
}
private fun fetchBody(url: HttpUrl): String? {
val body = runCatching {
okHttp.newCall(Request.Builder().url(url).build()).execute().use {
it.body?.string()
}
}.getOrNull().orEmpty()
return body.ifEmpty { null }
}
private companion object {
// " (192.168.0.77)" trailing host suffix some generic UPnP
// devices append. Anchored to end-of-string so it never eats
// a legitimate parenthetical inside a name.
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
}
}
@@ -0,0 +1,27 @@
package com.fabledsword.minstrel.player.output.upnp
import okhttp3.HttpUrl
/**
* A discovered UPnP / DLNA MediaRenderer (Sonos, Yamaha MusicCast,
* Bose SoundTouch, generic DLNA renderers). Lifted out of the SOAP /
* SSDP details so the picker UI consumes a narrow domain shape.
*
* Generic UPnP only for THIS slice — Sonos-specific grouping value-adds
* (group join/leave, zone topology) live in a separate Sonos extension
* scoped in
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md.
*
* [id] is the device UDN (e.g. `uuid:RINCON_ABC...`). [name] is the
* raw `<friendlyName>` straight from the device description — callers
* fall back to "Network speaker" upstream when it's blank; this class
* does not perform that substitution itself.
*/
data class UpnpRoute(
val id: String,
val name: String,
val manufacturer: String,
val modelName: String,
val avTransportControlUrl: HttpUrl,
val renderingControlUrl: HttpUrl?,
)
@@ -2,6 +2,10 @@
package com.fabledsword.minstrel.player.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState
@@ -44,13 +48,17 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
@@ -66,6 +74,11 @@ import com.composables.icons.lucide.Shuffle
import com.composables.icons.lucide.SkipBack
import com.composables.icons.lucide.SkipForward
import com.fabledsword.minstrel.player.RepeatMode
import com.fabledsword.minstrel.player.output.DeviceChip
import com.fabledsword.minstrel.player.output.OutputPickerSheet
import com.fabledsword.minstrel.player.output.OutputPickerViewModel
import com.fabledsword.minstrel.player.output.OutputRoute
import com.fabledsword.minstrel.player.output.RouteSnapshot
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER
@@ -88,6 +101,8 @@ private const val PLAY_PAUSE_ICON_DP = 56
private const val POP_GRACE_MS = 500L
private val SCRUB_TRACK_HEIGHT_DP = 4.dp
private val SCRUB_TRACK_CORNER_DP = 2.dp
private val SCRUB_THUMB_WIDTH_DP = 4.dp
private val SCRUB_THUMB_HEIGHT_DP = 18.dp
// Vertical drag-down threshold (in pixels) past which the gesture
// pops the player. Matches Flutter's 80px threshold in spirit;
@@ -266,6 +281,44 @@ private fun NowPlayingBody(
) {
val isLiked by trackActionsViewModel.isLikedFlow(track.id)
.collectAsStateWithLifecycle(initialValue = false)
val outputViewModel: OutputPickerViewModel = hiltViewModel()
val routes by outputViewModel.routes.collectAsStateWithLifecycle()
val sheetVisible by outputViewModel.sheetVisible.collectAsStateWithLifecycle()
val permissionDenied = rememberBluetoothPermissionState(sheetVisible)
NowPlayingContent(
inner = inner,
state = state,
track = track,
navController = navController,
viewModel = viewModel,
trackActionsViewModel = trackActionsViewModel,
routes = routes,
isLiked = isLiked,
onChipTapped = outputViewModel::onChipTapped,
)
if (sheetVisible) {
OutputPickerSheet(
snapshot = routes,
permissionDenied = permissionDenied,
onRouteSelected = outputViewModel::onRouteSelected,
onDismiss = outputViewModel::onSheetDismissed,
)
}
}
@Composable
@Suppress("LongParameterList") // mirrors NowPlayingBody — pure layout wiring
private fun NowPlayingContent(
inner: androidx.compose.foundation.layout.PaddingValues,
state: com.fabledsword.minstrel.player.PlayerUiState,
track: com.fabledsword.minstrel.models.TrackRef,
navController: NavHostController,
viewModel: PlayerViewModel,
trackActionsViewModel: TrackActionsViewModel,
routes: RouteSnapshot,
isLiked: Boolean,
onChipTapped: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -293,27 +346,93 @@ private fun NowPlayingBody(
onToggleShuffle = viewModel::toggleShuffle,
onCycleRepeat = viewModel::cycleRepeat,
)
// Spec visibility rule: hide the chip when the only route is the
// built-in speaker — no picker is useful with one option. Chip
// reappears the moment a Bluetooth pair or wired plug arrives.
if (shouldShowChip(routes)) {
Spacer(Modifier.height(8.dp))
DeviceChip(
route = routes.current,
onClick = onChipTapped,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
Spacer(Modifier.height(4.dp))
val smoothPositionMs by rememberSmoothPositionMs(
positionMs = state.positionMs,
durationMs = state.durationMs,
isPlaying = state.isPlaying,
)
ScrubberRow(
positionMs = smoothPositionMs,
durationMs = state.durationMs,
onSeek = viewModel::seekTo,
)
Spacer(Modifier.height(4.dp))
TransportRow(
isPlaying = state.isPlaying,
onPrev = viewModel::skipToPrevious,
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
onNext = viewModel::skipToNext,
)
PlaybackControlsBlock(state = state, viewModel = viewModel)
}
}
/**
* Scrubber + transport pair, sharing the smoothed playback position.
* Extracted from [NowPlayingContent] to keep that body under detekt's
* LongMethod ceiling — the two rows belong together (the scrubber's
* smoothed position would otherwise need to be hoisted into the
* caller just to thread it into the row below).
*/
@Composable
private fun PlaybackControlsBlock(
state: com.fabledsword.minstrel.player.PlayerUiState,
viewModel: PlayerViewModel,
) {
val smoothPositionMs by rememberSmoothPositionMs(
positionMs = state.positionMs,
durationMs = state.durationMs,
isPlaying = state.isPlaying,
)
ScrubberRow(
positionMs = smoothPositionMs,
durationMs = state.durationMs,
onSeek = viewModel::seekTo,
)
Spacer(Modifier.height(4.dp))
TransportRow(
isPlaying = state.isPlaying,
onPrev = viewModel::skipToPrevious,
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
onNext = viewModel::skipToNext,
)
}
/**
* One-shot BLUETOOTH_CONNECT permission flow tied to picker-sheet
* visibility. The launcher is remembered across recompositions; the
* LaunchedEffect fires when the sheet opens and the permission is not
* already granted. Subsequent opens are no-ops once the user has
* answered — the system remembers their choice. Returns whether the
* user explicitly denied, so the sheet can surface the rationale row.
*
* Extracted from [NowPlayingBody] to keep that body under detekt's
* LongMethod ceiling — the permission plumbing is incidental to the
* player layout.
*/
@Composable
private fun rememberBluetoothPermissionState(sheetVisible: Boolean): Boolean {
val context = LocalContext.current
var permissionDenied by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
permissionDenied = !granted
}
LaunchedEffect(sheetVisible) {
if (sheetVisible &&
ContextCompat.checkSelfPermission(
context,
Manifest.permission.BLUETOOTH_CONNECT,
) != PackageManager.PERMISSION_GRANTED
) {
permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
}
}
return permissionDenied
}
private fun shouldShowChip(snapshot: RouteSnapshot): Boolean {
val onlyBuiltIn = snapshot.available.size == 1 &&
snapshot.available.first().kind == OutputRoute.Kind.BuiltIn
return !onlyBuiltIn
}
@Composable
private fun BottomActionsRow(
navController: NavHostController,
@@ -478,15 +597,22 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
modifier = Modifier.fillMaxWidth(),
colors = sliderColors,
interactionSource = interactionSource,
// Plain filled circle to match the web client's `<input
// type="range" accent-color>` thumb — flat, no state-layer
// ring, no border. M3's SliderDefaults.Thumb paints a state
// layer halo on press; we drop it for visual parity. Slider's
// 48dp hit slop still applies, so tapability is unchanged.
// Thin vertical pill (4dp wide × 18dp tall, fully rounded).
// A small circle on a thin horizontal bar reads as visually
// off-center even when geometrically aligned — the eye
// expects the bar to bisect the thumb but a 14dp circle's
// mass extends above and below in equal amounts that the
// brain perceives as offset. A vertical pill the same width
// as the track removes the ambiguity: the bar passes through
// the pill's horizontal axis cleanly. Also matches M3's
// expressive-slider handle direction. State-layer halo is
// still dropped for visual parity with the web scrubber.
// Slider's 48dp hit slop still applies, so tapability is
// unchanged.
thumb = {
Box(
modifier = Modifier
.size(14.dp)
.size(width = SCRUB_THUMB_WIDTH_DP, height = SCRUB_THUMB_HEIGHT_DP)
.clip(CircleShape)
.background(accent),
)
@@ -513,12 +639,11 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
/**
* Custom 4dp rounded scrubber track. M3's default Track is 16dp tall
* and reads as a heavy pill rather than a measurement line; making
* the thumb visibly taller than the track (14dp thumb on a 4dp bar)
* restores the "handle on a string" cue your eye reads as "tool,
* draggable." Also drops M3's stop indicator dot, which the web
* scrubber doesn't have. Extracted so [ScrubberRow] stays under
* detekt's LongMethod ceiling.
* and reads as a heavy pill rather than a measurement line; the
* pill-thumb-on-thin-track pairing restores the "handle on a string"
* cue your eye reads as "tool, draggable." Also drops M3's stop
* indicator dot, which the web scrubber doesn't have. Extracted so
* [ScrubberRow] stays under detekt's LongMethod ceiling.
*/
@Composable
private fun ScrubTrack(fraction: Float, accent: Color) {
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.models.wire.PlaylistDetailWire
import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
import com.fabledsword.minstrel.models.wire.PlaylistWire
import com.fabledsword.minstrel.shared.resolveServerUrl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import retrofit2.Retrofit
@@ -241,7 +242,11 @@ private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
isPublic = isPublic,
systemVariant = systemVariant,
trackCount = trackCount,
coverUrl = coverPath ?: "",
// Server returns a relative cover path like "/api/playlists/<id>/cover";
// wrap through the placeholder host so BaseUrlInterceptor + the shared
// Coil/OkHttpClient resolve it against the live server. Without this
// wrap AsyncImage silently fails on the relative URL.
coverUrl = resolveServerUrl(coverPath) ?: "",
)
private fun PlaylistWire.toEntity(): CachedPlaylistEntity =
@@ -277,7 +282,10 @@ private fun PlaylistDetailWire.toPlaylistRef(): PlaylistRef =
isPublic = isPublic,
systemVariant = systemVariant,
trackCount = trackCount,
coverUrl = coverUrl,
// Same relative-path wrap as CachedPlaylistEntity.toDomain — the wire
// gives us /api/playlists/<id>/cover and Coil needs the placeholder
// host for BaseUrlInterceptor to rewrite to the live server.
coverUrl = resolveServerUrl(coverUrl) ?: "",
ownerUsername = ownerUsername,
)
@@ -87,7 +87,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -141,8 +140,7 @@ class PlaylistDetailViewModel @Inject constructor(
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTracks()
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
likes.observeLikedTrackIds()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
@@ -0,0 +1,108 @@
package com.fabledsword.minstrel.player.output.upnp
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
/**
* Unit tests for the UPnP device-description pull-parser.
*
* Android's stock `XmlPullParserFactory.newInstance()` resolves to a
* real impl on-device but resolves to the android.jar stub class on
* JVM unit tests. kxml2 is added as a testImplementation dep so
* `XmlPullParserFactory.newInstance()` finds it via service-provider
* lookup on the test classpath; the tests run unconditionally.
*/
class DeviceDescriptionTest {
private val base = "http://192.168.1.50:1400/xml/device_description.xml".toHttpUrl()
@Test
fun `parses Sonos-shaped description`() {
val xml = """
<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
<friendlyName>Living Room</friendlyName>
<manufacturer>Sonos, Inc.</manufacturer>
<modelName>Sonos One</modelName>
<UDN>uuid:RINCON_ABC</UDN>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
<controlURL>/MediaRenderer/AVTransport/Control</controlURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
<controlURL>/MediaRenderer/RenderingControl/Control</controlURL>
</service>
</serviceList>
</device>
</root>
""".trimIndent()
val desc = DeviceDescription.parse(xml, base)
assertNotNull(desc)
assertEquals("Living Room", desc.friendlyName)
assertEquals("Sonos, Inc.", desc.manufacturer)
assertEquals("Sonos One", desc.modelName)
assertEquals("uuid:RINCON_ABC", desc.udn)
assertEquals(
"http://192.168.1.50:1400/MediaRenderer/AVTransport/Control",
desc.avTransportControlUrl.toString(),
)
assertEquals(
"http://192.168.1.50:1400/MediaRenderer/RenderingControl/Control",
desc.renderingControlUrl?.toString(),
)
}
@Test
fun `drops device with no AVTransport service`() {
val xml = """
<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<friendlyName>Stub TV</friendlyName>
<UDN>uuid:STUB</UDN>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
<controlURL>/cm</controlURL>
</service>
</serviceList>
</device>
</root>
""".trimIndent()
assertNull(DeviceDescription.parse(xml, base))
}
@Test
fun `handles missing optional fields`() {
val xml = """
<?xml version="1.0"?>
<root>
<device>
<UDN>uuid:MIN</UDN>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
<controlURL>/avt</controlURL>
</service>
</serviceList>
</device>
</root>
""".trimIndent()
val desc = DeviceDescription.parse(xml, base)
assertNotNull(desc)
assertEquals("", desc.friendlyName)
assertEquals("", desc.manufacturer)
assertEquals("", desc.modelName)
assertNull(desc.renderingControlUrl)
}
}
@@ -0,0 +1,146 @@
package com.fabledsword.minstrel.player.output.upnp
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* Unit tests for SoapClient. Uses MockWebServer to capture the
* outgoing SOAP envelope + verify the SOAPACTION header shape, and
* to drive the `<s:Fault>` response path.
*
* kxml2 is on the test classpath (Task 4), so
* `XmlPullParserFactory.newInstance()` resolves on the JVM.
*/
class SoapClientTest {
private lateinit var server: MockWebServer
private lateinit var client: SoapClient
@BeforeEach
fun setup() {
server = MockWebServer().apply { start() }
client = SoapClient(OkHttpClient())
}
@AfterEach
fun teardown() {
server.shutdown()
}
@Test
fun `SetAVTransportURI envelope sent with correct SOAPACTION header`() = runTest {
server.enqueue(
MockResponse()
.setBody(setUriResponseBody())
.setHeader("Content-Type", "text/xml"),
)
client.call(
controlUrl = server.url("/avt/Control"),
serviceType = "urn:schemas-upnp-org:service:AVTransport:1",
action = "SetAVTransportURI",
args = mapOf(
"InstanceID" to "0",
"CurrentURI" to "http://server/api/tracks/x/stream?token=t&exp=1",
"CurrentURIMetaData" to "",
),
)
val recorded = server.takeRequest()
assertEquals(
"\"urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI\"",
recorded.getHeader("SOAPACTION"),
)
val body = recorded.body.readUtf8()
assertTrue(
body.contains("<u:SetAVTransportURI"),
"envelope missing <u:SetAVTransportURI: $body",
)
assertTrue(
body.contains(
"<CurrentURI>http://server/api/tracks/x/stream?token=t&amp;exp=1</CurrentURI>",
),
"envelope missing escaped CurrentURI: $body",
)
}
@Test
fun `XML special chars in args are escaped`() = runTest {
server.enqueue(
MockResponse()
.setBody(setUriResponseBody())
.setHeader("Content-Type", "text/xml"),
)
client.call(
controlUrl = server.url("/avt/Control"),
serviceType = "urn:schemas-upnp-org:service:AVTransport:1",
action = "SetAVTransportURI",
args = mapOf("CurrentURI" to "a&b<c>\"d'e"),
)
val recorded = server.takeRequest()
val body = recorded.body.readUtf8()
assertTrue(
body.contains("a&amp;b&lt;c&gt;&quot;d&apos;e"),
"expected all five XML escapes in body, got: $body",
)
}
@Test
fun `SOAP fault becomes SoapFaultException`() = runTest {
server.enqueue(
MockResponse()
.setResponseCode(500)
.setBody(faultResponseBody())
.setHeader("Content-Type", "text/xml"),
)
val ex = assertFailsWith<SoapFaultException> {
client.call(
controlUrl = server.url("/avt/Control"),
serviceType = "urn:schemas-upnp-org:service:AVTransport:1",
action = "Play",
args = mapOf("InstanceID" to "0", "Speed" to "1"),
)
}
assertEquals("402", ex.code)
}
private fun setUriResponseBody(): String = """
<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:SetAVTransportURIResponse
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
</s:Body>
</s:Envelope>
""".trimIndent()
private fun faultResponseBody(): String = """
<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<s:Fault>
<faultcode>s:Client</faultcode>
<faultstring>UPnPError</faultstring>
<detail>
<UPnPError xmlns="urn:schemas-upnp-org:control-1-0">
<errorCode>402</errorCode>
<errorDescription>Invalid Args</errorDescription>
</UPnPError>
</detail>
</s:Fault>
</s:Body>
</s:Envelope>
""".trimIndent()
}
+4
View File
@@ -22,6 +22,7 @@ kotlinx-coroutines = "1.9.0"
kotlinx-datetime = "0.6.1"
kotlinx-serialization-converter = "1.0.0"
media3 = "1.10.1"
mediarouter = "1.7.0"
coil = "3.0.0-rc02"
palette = "1.0.0"
timber = "5.0.1"
@@ -33,6 +34,7 @@ mockk = "1.13.13"
compose-test = "1.7.5"
ktlint-gradle = "12.1.1"
detekt = "2.0.0-alpha.3"
kxml2 = "2.3.0"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.13.1" }
@@ -71,6 +73,7 @@ media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref =
media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" }
media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" }
media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
mediarouter = { module = "androidx.mediarouter:mediarouter", version.ref = "mediarouter" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
androidx-palette = { module = "androidx.palette:palette-ktx", version.ref = "palette" }
@@ -87,6 +90,7 @@ turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
compose-ui-test = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
kxml2 = { module = "net.sf.kxml:kxml2", version.ref = "kxml2" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
+1
View File
@@ -245,6 +245,7 @@ func run() error {
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg, scheduler)
srv.Bus = bus
srv.PlaylistScheduler = playlistScheduler
srv.StreamSecret = cfg.StreamSecret
httpServer := &http.Server{
Addr: cfg.Server.Address,
Handler: srv.Router(),
+29 -2
View File
@@ -28,7 +28,7 @@ import (
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler) {
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
rng := rand.New(rand.NewSource(rand.Int63()))
h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg,
@@ -47,6 +47,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
mailer: sender,
eventbus: bus,
playlistScheduler: playlistScheduler,
streamSecret: streamSecret,
}
r.Route("/api", func(api chi.Router) {
@@ -54,6 +55,17 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
api.Post("/auth/register", h.handleRegister)
api.Post("/auth/forgot-password", h.handleForgotPassword)
api.Post("/auth/reset-password", h.handleResetPassword)
// Stream lives outside authed.Group so it can accept EITHER a
// session (resolved by the OptionalUser middleware) OR a signed
// query token (UPnP / Sonos path; see streamAuthOk). The
// middleware attaches user to context when a valid cookie /
// bearer is present but does NOT 401 on absence; the handler's
// own streamAuthOk performs the actual auth check. See the
// design at
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream", h.handleGetStream)
api.Group(func(authed chi.Router) {
authed.Use(auth.RequireUser(pool))
authed.Post("/auth/logout", h.handleLogout)
@@ -77,7 +89,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/library/albums", h.handleListLibraryAlbums)
authed.Get("/library/sync", h.handleLibrarySync)
authed.Get("/tracks/{id}", h.handleGetTrack)
authed.Get("/tracks/{id}/stream", h.handleGetStream)
// /tracks/{id}/stream is mounted above with OptionalUser so
// it can accept either a session or a signed token.
authed.Get("/search", h.handleSearch)
authed.Get("/radio", h.handleRadio)
authed.Get("/discover/suggestions", h.handleListSuggestions)
@@ -85,6 +98,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/home/index", h.handleGetHomeIndex)
authed.Post("/events", h.handleEvents)
authed.Get("/events/stream", h.handleEventsStream)
// UPnP / Sonos cast slice: issue a short-lived HMAC stream URL
// the speaker can fetch without the user's session. See the
// design at
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
authed.Post("/cast/stream-token", h.handleCastStreamToken)
authed.Post("/likes/tracks/{id}", h.handleLikeTrack)
authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack)
authed.Post("/likes/albums/{id}", h.handleLikeAlbum)
@@ -205,4 +223,13 @@ type handlers struct {
mailer mailer.Sender
eventbus *eventbus.Bus
playlistScheduler *playlists.Scheduler
// streamSecret is the HMAC key used by SignStreamToken /
// VerifyStreamToken to authenticate the UPnP-speaker stream path
// (see internal/api/stream_token.go and the design at
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md).
// nil in slice 1; slice 2 wires the env-var-with-app_preferences-
// fallback loader. A nil secret leaves the cookie path intact and
// makes the token path unreachable (HMAC of empty key won't match
// anything a client mints), which is the desired slice-1 default.
streamSecret []byte
}
+97
View File
@@ -0,0 +1,97 @@
package api
import (
"net/http"
"strconv"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
)
const (
castTokenMinExpSeconds = 60
castTokenMaxExpSeconds = 86400 // 24h
castTokenDefaultExp = 21600 // 6h
)
type castTokenRequest struct {
TrackID string `json:"trackId"`
ExpSeconds int `json:"expSeconds,omitempty"`
}
type castTokenResponse struct {
Token string `json:"token"`
Exp int64 `json:"exp"`
URL string `json:"url"`
}
// handleCastStreamToken issues a short-lived HMAC stream token for the
// given trackId. Authenticated via the standard session cookie / bearer.
//
// The returned URL is a fully-formed stream URL (token + exp embedded
// as query params) that the client passes verbatim to a UPnP / Sonos
// device's AVTransport.SetAVTransportURI call — those devices cannot
// carry the user's session, so the signed query string is the only way
// they can fetch the bytes.
//
// expSeconds is clamped to [60, 86400]; default 21600 (6h) — long enough
// to play through any typical track without re-minting mid-playback.
//
// Part of the output-picker UPnP slice. See
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) {
if _, ok := requireUser(w, r); !ok {
return
}
var req castTokenRequest
if !decodeBody(w, r, &req) {
return
}
trackUUID, ok := parseUUID(req.TrackID)
if !ok || !trackUUID.Valid {
writeErr(w, apierror.BadRequest("invalid_track_id", "trackId must be a UUID"))
return
}
expSec := clampExpSeconds(req.ExpSeconds)
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
token := SignStreamToken(h.streamSecret, req.TrackID, exp)
// Behind a TLS-terminating reverse proxy, r.TLS is nil even though
// the public-facing URL is https://. UPnP devices (Sonos especially)
// reject SetAVTransportURI with error 714 (IllegalMimeType) when
// they hit an http:// URL that immediately 301s to https:// — the
// MIME probe fails to find an audio body. Honor X-Forwarded-Proto +
// X-Forwarded-Host first so the URL we hand to the speaker reaches
// it on the same scheme/host the client used. Falls back to
// r.TLS-based detection for direct (no-proxy) deployments.
scheme := "http"
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
} else if r.TLS != nil {
scheme = "https"
}
host := r.Host
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
host = h
}
url := scheme + "://" + host + "/api/tracks/" + req.TrackID +
"/stream?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
writeJSON(w, http.StatusOK, castTokenResponse{Token: token, Exp: exp, URL: url})
}
// clampExpSeconds applies the [60, 86400] window with a 6h default for
// non-positive inputs. Extracted so it doesn't bloat the handler's
// detekt-equivalent line count and so the test can exercise edges directly.
func clampExpSeconds(v int) int {
if v <= 0 {
return castTokenDefaultExp
}
if v < castTokenMinExpSeconds {
return castTokenMinExpSeconds
}
if v > castTokenMaxExpSeconds {
return castTokenMaxExpSeconds
}
return v
}
+152
View File
@@ -0,0 +1,152 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
const testTrackUUID = "11111111-1111-1111-1111-111111111111"
func TestCastStreamToken_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
h.streamSecret = []byte("cast-token-test-secret")
body, err := json.Marshal(castTokenRequest{
TrackID: testTrackUUID,
ExpSeconds: 3600,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, user)
w := httptest.NewRecorder()
h.handleCastStreamToken(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var resp castTokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Token == "" || resp.Exp == 0 || resp.URL == "" {
t.Fatalf("empty fields in response: %+v", resp)
}
if !strings.Contains(resp.URL, "token="+resp.Token) {
t.Fatalf("URL missing token query: %s", resp.URL)
}
if !strings.Contains(resp.URL, "/api/tracks/"+testTrackUUID+"/stream") {
t.Fatalf("URL missing stream path: %s", resp.URL)
}
if !VerifyStreamToken(h.streamSecret, testTrackUUID, resp.Exp, resp.Token) {
t.Fatal("returned token does not verify")
}
}
func TestCastStreamToken_RejectsBadUUID(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
h.streamSecret = []byte("cast-token-test-secret")
body, err := json.Marshal(castTokenRequest{TrackID: "not-a-uuid"})
if err != nil {
t.Fatalf("marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, user)
w := httptest.NewRecorder()
h.handleCastStreamToken(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String())
}
}
func TestCastStreamToken_RejectsUnauthenticated(t *testing.T) {
h, _ := testHandlers(t)
h.streamSecret = []byte("cast-token-test-secret")
body, err := json.Marshal(castTokenRequest{TrackID: testTrackUUID})
if err != nil {
t.Fatalf("marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// NO withUser — handler must reject via requireUser.
w := httptest.NewRecorder()
h.handleCastStreamToken(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401; body=%s", w.Code, w.Body.String())
}
}
func TestCastStreamToken_ClampsExpSeconds(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
h.streamSecret = []byte("cast-token-test-secret")
// Request 1 second (below min 60), expect clamp to 60s.
body, err := json.Marshal(castTokenRequest{
TrackID: testTrackUUID,
ExpSeconds: 1,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
before := time.Now().Unix()
req := httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, user)
w := httptest.NewRecorder()
h.handleCastStreamToken(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var resp castTokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
// Allow a small jitter window around the clamp target (60s).
delta := resp.Exp - before
if delta < 55 || delta > 70 {
t.Fatalf("expected ~60s expiry after clamp, got delta=%ds", delta)
}
}
func TestClampExpSeconds(t *testing.T) {
cases := []struct {
name string
in int
want int
}{
{"zero defaults to 6h", 0, castTokenDefaultExp},
{"negative defaults to 6h", -1, castTokenDefaultExp},
{"below min clamps up", 30, castTokenMinExpSeconds},
{"exact min passes through", castTokenMinExpSeconds, castTokenMinExpSeconds},
{"mid-range passes through", 3600, 3600},
{"exact max passes through", castTokenMaxExpSeconds, castTokenMaxExpSeconds},
{"above max clamps down", castTokenMaxExpSeconds + 1, castTokenMaxExpSeconds},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := clampExpSeconds(tc.in); got != tc.want {
t.Fatalf("clampExpSeconds(%d) = %d, want %d", tc.in, got, tc.want)
}
})
}
}
+24 -3
View File
@@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
@@ -21,7 +22,15 @@ import (
// newLibraryRouter builds a test-only chi router with the library handlers
// mounted at their path-style routes. Tests hit this router rather than
// calling handler methods directly so chi URL params populate correctly.
// RequireUser is NOT applied — library handlers don't read user context.
// RequireUser is NOT applied to most routes — library handlers don't read
// user context.
//
// The stream route is wrapped with a synthetic-user middleware because
// handleGetStream now enforces its own auth check (streamAuthOk) after the
// UPnP slice moved it out of the authed.Group. Real traffic carries either
// a session cookie (resolved by auth.OptionalUser) or a signed query
// token; tests get a fake user-in-context so the cookie path of
// streamAuthOk succeeds without seeding a real session row.
func newLibraryRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/artists", h.handleListArtists)
@@ -29,11 +38,23 @@ func newLibraryRouter(h *handlers) chi.Router {
r.Get("/api/albums/{id}", h.handleGetAlbum)
r.Get("/api/albums/{id}/cover", h.handleGetCover)
r.Get("/api/tracks/{id}", h.handleGetTrack)
r.Get("/api/tracks/{id}/stream", h.handleGetStream)
r.With(injectFakeUserForTest).Get("/api/tracks/{id}/stream", h.handleGetStream)
r.Get("/api/search", h.handleSearch)
return r
}
// injectFakeUserForTest attaches an empty dbq.User to request context via
// auth.UserCtxKeyForTest(), letting handleGetStream's streamAuthOk succeed
// on the session path without the test having to seed a real session row.
// Production traffic uses auth.OptionalUser instead; this is the test-only
// equivalent that bypasses the DB lookup.
func injectFakeUserForTest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), auth.UserCtxKeyForTest(), dbq.User{})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func TestHandleGetTrack_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
@@ -444,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil, nil)
paths := []string{
"/api/artists",
+50
View File
@@ -11,9 +11,13 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
@@ -121,10 +125,56 @@ func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
}
// streamAuthOk returns true if the request is authorized to fetch the
// stream — either via the standard session path (cookie OR bearer token
// resolved to a user-in-context by auth.OptionalUser, the middleware
// the route is wrapped with in api.go) OR via a valid short-lived HMAC
// token in the ?token=&exp= query (UPnP / Sonos path, new for the
// output-picker UPnP slice).
//
// The token path lets network speakers fetch the stream URL without
// carrying the user's session cookie — they cannot. See the design at
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
//
// When h.streamSecret is nil (slice-1 default, until slice 2 wires the
// loader), the token path always rejects because the HMAC of an empty
// key won't match any token a client could mint. The session path keeps
// working.
func (h *handlers) streamAuthOk(r *http.Request, trackID string) bool {
if _, ok := auth.UserFromContext(r.Context()); ok {
return true
}
tok := r.URL.Query().Get("token")
expStr := r.URL.Query().Get("exp")
if tok == "" || expStr == "" {
return false
}
exp, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
return false
}
return VerifyStreamToken(h.streamSecret, trackID, exp, tok)
}
// handleGetStream implements GET /api/tracks/{id}/stream. Opens the file on
// disk and delegates byte-serving to http.ServeContent, which handles Range,
// If-Modified-Since, and ETag based on the file's mod time.
//
// The route lives outside the authed.Group so this handler can accept
// EITHER a session-resolved user (attached by auth.OptionalUser, the
// permissive middleware the route is wrapped with) OR a signed token
// (UPnP slice). streamAuthOk gates both paths.
func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
// Auth check before the DB lookup: a 404 ahead of the auth check
// would let unauth callers probe which track IDs exist via the
// 404/401 response differential. streamAuthOk is keyed on the
// path's id directly (the HMAC token is signed over the same id
// string, so we don't need the resolved row yet).
rawID := chi.URLParam(r, "id")
if !h.streamAuthOk(r, rawID) {
writeErr(w, apierror.ErrUnauthorized)
return
}
track, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetTrackByID, "track")
if apiErr != nil {
writeErr(w, apiErr)
+39
View File
@@ -0,0 +1,39 @@
package api
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
)
// SignStreamToken returns the HMAC-SHA256 hex of "<trackID>|<exp>" keyed
// by secret. The token is constant-time-comparable via VerifyStreamToken
// so it's safe to expose the verification function in handlers.
//
// trackID is the canonical track UUID. exp is the Unix time after which
// the token is invalid; the handler checks exp before serving.
//
// The secret comes from MINSTREL_STREAM_SECRET (or its auto-generated +
// persisted fallback in app_preferences). The loader lands in slice 2
// (POST /api/cast/stream-token); this file is the primitive both the
// loader and the stream handler share.
func SignStreamToken(secret []byte, trackID string, exp int64) string {
mac := hmac.New(sha256.New, secret)
// hash.Hash.Write is documented as never returning an error.
_, _ = fmt.Fprintf(mac, "%s|%d", trackID, exp)
return hex.EncodeToString(mac.Sum(nil))
}
// VerifyStreamToken returns true iff token is a valid HMAC for
// (trackID, exp) AND exp has not yet passed. Wall-clock comparison —
// callers must trust the server's clock. Uses hmac.Equal for
// constant-time compare so a timing oracle can't bias token guessing.
func VerifyStreamToken(secret []byte, trackID string, exp int64, token string) bool {
if time.Now().Unix() > exp {
return false
}
expected := SignStreamToken(secret, trackID, exp)
return hmac.Equal([]byte(expected), []byte(token))
}
+78
View File
@@ -0,0 +1,78 @@
package api
import (
"testing"
"time"
)
func TestSignStreamToken_RoundTrip(t *testing.T) {
secret := []byte("test-secret-not-real")
trackID := "abc-123"
exp := time.Now().Unix() + 3600
token := SignStreamToken(secret, trackID, exp)
if token == "" {
t.Fatal("got empty token")
}
if !VerifyStreamToken(secret, trackID, exp, token) {
t.Fatal("round-trip verify failed")
}
}
func TestVerifyStreamToken_TamperedTokenRejected(t *testing.T) {
secret := []byte("test-secret")
trackID := "abc-123"
exp := time.Now().Unix() + 3600
token := SignStreamToken(secret, trackID, exp)
// Flip a hex digit anywhere in the middle.
mid := len(token) / 2
tampered := token[:mid] + flipHexDigit(token[mid:mid+1]) + token[mid+1:]
if VerifyStreamToken(secret, trackID, exp, tampered) {
t.Fatal("verify accepted tampered token")
}
}
func TestVerifyStreamToken_ExpiredRejected(t *testing.T) {
secret := []byte("test-secret")
trackID := "abc-123"
exp := time.Now().Unix() - 1 // already expired
token := SignStreamToken(secret, trackID, exp)
if VerifyStreamToken(secret, trackID, exp, token) {
t.Fatal("verify accepted expired token")
}
}
func TestVerifyStreamToken_WrongTrackIDRejected(t *testing.T) {
secret := []byte("test-secret")
exp := time.Now().Unix() + 3600
token := SignStreamToken(secret, "track-a", exp)
if VerifyStreamToken(secret, "track-b", exp, token) {
t.Fatal("verify accepted token for different track")
}
}
func TestVerifyStreamToken_WrongSecretRejected(t *testing.T) {
trackID := "abc-123"
exp := time.Now().Unix() + 3600
token := SignStreamToken([]byte("secret-a"), trackID, exp)
if VerifyStreamToken([]byte("secret-b"), trackID, exp, token) {
t.Fatal("verify accepted token signed with different secret")
}
}
func flipHexDigit(s string) string {
if s == "" {
return s
}
switch s[0] {
case '0':
return "1"
default:
return "0"
}
}
+46
View File
@@ -112,6 +112,52 @@ func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler {
// middleware. Do not use this outside _test.go files.
func UserCtxKeyForTest() any { return userCtxKey }
// OptionalUser is RequireUser's permissive sibling: it resolves the caller
// from the session cookie or bearer header and attaches the user to context
// when present + valid, but does NOT 401 on absence. The downstream handler
// runs unconditionally and is responsible for its own auth check via
// UserFromContext (or its own bespoke path — see /api/tracks/{id}/stream's
// streamAuthOk, which accepts EITHER a user-in-context OR a signed query
// token for UPnP / Sonos speakers that don't carry the user's cookie).
//
// Invalid tokens (stale session row, deleted user) silently drop through
// without attaching the user. The handler treats "no user in context" as
// "not authenticated" the same way it treats a missing cookie.
//
// Database lookup failures fall through too — a transient DB blip should not
// 5xx a stream request that may have a perfectly valid signed token. The
// error is logged so the operator can correlate.
func OptionalUser(pool *pgxpool.Pool, logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := sessionTokenFromRequest(r)
if token == "" || pool == nil {
next.ServeHTTP(w, r)
return
}
q := dbq.New(pool)
sess, err := q.GetSessionByTokenHash(r.Context(), HashSessionToken(token))
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) && logger != nil {
logger.Warn("api: optional session lookup failed", "err", err)
}
next.ServeHTTP(w, r)
return
}
user, err := q.GetUserByID(r.Context(), sess.UserID)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) && logger != nil {
logger.Warn("api: optional user lookup failed", "err", err)
}
next.ServeHTTP(w, r)
return
}
ctx := context.WithValue(r.Context(), userCtxKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func sessionTokenFromRequest(r *http.Request) string {
if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" {
return c.Value
+86
View File
@@ -1,8 +1,12 @@
package config
import (
"crypto/rand"
"encoding/base64"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
@@ -19,6 +23,14 @@ type Config struct {
Events EventsConfig `yaml:"events"`
Recommendation RecommendationConfig `yaml:"recommendation"`
Branding BrandingConfig `yaml:"branding"`
// StreamSecret is the base64-decoded HMAC key used to sign UPnP /
// Sonos stream URLs (see internal/api/stream_token.go and
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md).
// Sourced from MINSTREL_STREAM_SECRET (base64-encoded), with a
// per-machine fallback persisted at <Storage.DataDir>/stream_secret.
// Not serialized to YAML — operator-machine-scoped runtime state, not
// a user-facing setting. Never log the contents.
StreamSecret []byte `yaml:"-"`
}
type ServerConfig struct {
@@ -137,9 +149,83 @@ func Load(path string) (Config, error) {
}
}
applyEnv(&cfg)
if err := resolveStreamSecret(&cfg); err != nil {
return cfg, err
}
return cfg, nil
}
// streamSecretBytes is the raw HMAC-key length; 64 bytes gives 512 bits
// of entropy, more than enough for HMAC-SHA256, and lands at a 86-char
// base64-url-no-padding string that fits cleanly in a single .env line.
const streamSecretBytes = 64
// streamSecretFile is the basename for the per-machine persisted fallback
// under <Storage.DataDir>. Kept as a constant so tests assert against the
// exact path and operators can find it during backup planning.
const streamSecretFile = "stream_secret"
// resolveStreamSecret populates cfg.StreamSecret using, in order:
// 1. MINSTREL_STREAM_SECRET env var (operator-supplied, base64-url
// encoded — accepts either padded or raw form).
// 2. <Storage.DataDir>/stream_secret if present (auto-generated on a
// previous boot; survives restarts so signed URLs stay valid).
// 3. Auto-generate streamSecretBytes random bytes, persist them to that
// file at 0600, then use them.
//
// Logs (only) when an auto-generation happens so the operator can spot
// it during first-boot. Never logs the contents.
//
// The loader runs before slog is initialized in cmd/minstrel/main.go;
// log.Printf is the project's pre-logger convention.
func resolveStreamSecret(cfg *Config) error {
if raw := strings.TrimSpace(os.Getenv("MINSTREL_STREAM_SECRET")); raw != "" {
decoded, err := decodeBase64Secret(raw)
if err != nil {
return fmt.Errorf("decode MINSTREL_STREAM_SECRET: %w", err)
}
cfg.StreamSecret = decoded
return nil
}
if cfg.Storage.DataDir == "" {
return fmt.Errorf("stream secret: empty storage.data_dir; " +
"set MINSTREL_STREAM_SECRET or storage.data_dir")
}
path := filepath.Join(cfg.Storage.DataDir, streamSecretFile)
if buf, err := os.ReadFile(path); err == nil && len(buf) > 0 {
decoded, err := decodeBase64Secret(strings.TrimSpace(string(buf)))
if err != nil {
return fmt.Errorf("decode %s: %w", path, err)
}
cfg.StreamSecret = decoded
return nil
}
raw := make([]byte, streamSecretBytes)
if _, err := rand.Read(raw); err != nil {
return fmt.Errorf("auto-gen stream secret: %w", err)
}
encoded := base64.RawURLEncoding.EncodeToString(raw)
if err := os.MkdirAll(cfg.Storage.DataDir, 0o755); err != nil {
return fmt.Errorf("ensure data_dir for stream secret: %w", err)
}
if err := os.WriteFile(path, []byte(encoded), 0o600); err != nil {
return fmt.Errorf("persist stream secret: %w", err)
}
log.Printf("config: auto-generated MINSTREL_STREAM_SECRET (persisted to %s)", path)
cfg.StreamSecret = raw
return nil
}
// decodeBase64Secret accepts base64-url with OR without padding; the
// stream-secret bytes are opaque so callers shouldn't have to know which
// encoder produced their string. Returns an error on any other shape.
func decodeBase64Secret(s string) ([]byte, error) {
if buf, err := base64.RawURLEncoding.DecodeString(s); err == nil {
return buf, nil
}
return base64.URLEncoding.DecodeString(s)
}
func applyEnv(cfg *Config) {
if v, ok := os.LookupEnv("MINSTREL_SERVER_ADDRESS"); ok {
cfg.Server.Address = v
+93
View File
@@ -1,11 +1,23 @@
package config
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)
// stubStreamSecret keeps existing tests focused on the fields they
// assert against by short-circuiting the auto-generation path (which
// would otherwise write ./data/stream_secret in the test workspace).
// New tests that exercise the resolver directly do NOT use this.
func stubStreamSecret(t *testing.T) {
t.Helper()
t.Setenv("MINSTREL_STREAM_SECRET",
base64.RawURLEncoding.EncodeToString([]byte("test-stream-secret-stub-1234567890")))
}
func TestDefault(t *testing.T) {
cfg := Default()
if cfg.Server.Address != ":4533" {
@@ -17,6 +29,7 @@ func TestDefault(t *testing.T) {
}
func TestLoadYAML(t *testing.T) {
stubStreamSecret(t)
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
content := []byte(`server:
@@ -46,6 +59,7 @@ log:
}
func TestLoadMissingFileReturnsDefaults(t *testing.T) {
stubStreamSecret(t)
cfg, err := Load(filepath.Join(t.TempDir(), "does-not-exist.yaml"))
if err != nil {
t.Fatalf("Load: %v", err)
@@ -56,6 +70,7 @@ func TestLoadMissingFileReturnsDefaults(t *testing.T) {
}
func TestEnvOverrides(t *testing.T) {
stubStreamSecret(t)
t.Setenv("MINSTREL_SERVER_ADDRESS", ":8080")
t.Setenv("MINSTREL_DATABASE_URL", "postgres://env")
t.Setenv("MINSTREL_LOG_LEVEL", "WARN")
@@ -80,6 +95,7 @@ func TestEnvOverrides(t *testing.T) {
}
func TestLibraryEnvOverrides(t *testing.T) {
stubStreamSecret(t)
t.Setenv("MINSTREL_LIBRARY_SCAN_PATHS", "/music:/other::/third")
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "true")
@@ -102,6 +118,7 @@ func TestLibraryEnvOverrides(t *testing.T) {
}
func TestSubsonicEnvOverride(t *testing.T) {
stubStreamSecret(t)
t.Setenv("MINSTREL_SUBSONIC_ALLOW_PLAINTEXT_PASSWORD", "true")
cfg, err := Load("")
if err != nil {
@@ -113,6 +130,7 @@ func TestSubsonicEnvOverride(t *testing.T) {
}
func TestLibraryYAMLLoads(t *testing.T) {
stubStreamSecret(t)
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
content := []byte(`library:
@@ -147,6 +165,7 @@ func TestBrandingDefaults(t *testing.T) {
}
func TestBrandingYAMLOverride(t *testing.T) {
stubStreamSecret(t)
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
content := []byte(`branding:
@@ -168,7 +187,81 @@ func TestBrandingYAMLOverride(t *testing.T) {
}
}
func TestStreamSecret_EnvOverride(t *testing.T) {
want := []byte("hello-stream-secret-from-env-32b")
t.Setenv("MINSTREL_STREAM_SECRET", base64.RawURLEncoding.EncodeToString(want))
t.Setenv("MINSTREL_STORAGE_DATA_DIR", t.TempDir())
cfg, err := Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
if string(cfg.StreamSecret) != string(want) {
t.Fatalf("StreamSecret mismatch: got %q want %q", cfg.StreamSecret, want)
}
}
func TestStreamSecret_AutoGenPersistsToDataDir(t *testing.T) {
_ = os.Unsetenv("MINSTREL_STREAM_SECRET")
dataDir := t.TempDir()
t.Setenv("MINSTREL_STORAGE_DATA_DIR", dataDir)
cfg, err := Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.StreamSecret) != streamSecretBytes {
t.Fatalf("StreamSecret len = %d, want %d", len(cfg.StreamSecret), streamSecretBytes)
}
path := filepath.Join(dataDir, streamSecretFile)
buf, err := os.ReadFile(path)
if err != nil {
t.Fatalf("expected persisted secret at %s: %v", path, err)
}
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(buf)))
if err != nil {
t.Fatalf("persisted secret is not raw-url-base64: %v", err)
}
if string(decoded) != string(cfg.StreamSecret) {
t.Fatal("persisted file does not match returned secret")
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
// 0600 — never let other users read the HMAC key.
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("perm = %o, want 0600", perm)
}
}
func TestStreamSecret_LoadsPersistedOnSecondBoot(t *testing.T) {
_ = os.Unsetenv("MINSTREL_STREAM_SECRET")
dataDir := t.TempDir()
t.Setenv("MINSTREL_STORAGE_DATA_DIR", dataDir)
first, err := Load("")
if err != nil {
t.Fatalf("Load #1: %v", err)
}
second, err := Load("")
if err != nil {
t.Fatalf("Load #2: %v", err)
}
if string(first.StreamSecret) != string(second.StreamSecret) {
t.Fatal("second Load got a different secret — file fallback didn't fire")
}
}
func TestStreamSecret_RejectsMalformedEnvValue(t *testing.T) {
t.Setenv("MINSTREL_STREAM_SECRET", "not!base64!!!")
t.Setenv("MINSTREL_STORAGE_DATA_DIR", t.TempDir())
if _, err := Load(""); err == nil {
t.Fatal("expected error on malformed MINSTREL_STREAM_SECRET")
}
}
func TestBrandingEnvOverride(t *testing.T) {
stubStreamSecret(t)
t.Setenv("MINSTREL_BRANDING_APP_NAME", "Office Music")
t.Setenv("MINSTREL_BRANDING_DESCRIPTION", "Office tunes.")
cfg, err := Load("")
+17 -10
View File
@@ -278,16 +278,23 @@ func RefreshableSystemKind(key string) bool {
// here (plus its candidate query). Order is the materialize order;
// it has no functional effect (atomic replace + per-playlist
// collage are order-independent).
var systemPlaylistRegistry = []systemPlaylistKind{
{Key: "for_you", Singleton: true, Produce: produceForYou},
{Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes},
{Key: "discover", Singleton: true, Produce: produceDiscover},
{Key: "deep_cuts", Singleton: true, Produce: produceDeepCuts},
{Key: "rediscover", Singleton: true, Produce: produceRediscover},
{Key: "new_for_you", Singleton: true, Produce: produceNewForYou},
{Key: "on_this_day", Singleton: true, Produce: produceOnThisDay},
{Key: "first_listens", Singleton: true, Produce: produceFirstListens},
}
var systemPlaylistRegistry = func() []systemPlaylistKind {
out := []systemPlaylistKind{
{Key: "for_you", Singleton: true, Produce: produceForYou},
{Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes},
{Key: "discover", Singleton: true, Produce: produceDiscover},
}
// The five discovery mixes share one Produce closure factory keyed
// by a per-mix spec; spec list + factory live in system_mixes.go.
for _, spec := range discoveryMixSpecs {
out = append(out, systemPlaylistKind{
Key: spec.variant,
Singleton: true,
Produce: produceDiscoveryMix(spec),
})
}
return out
}()
// systemForYouSourceLimits is a deeper candidate pool than the radio
// default. On a self-hosted library without ListenBrainz similarity
+221 -108
View File
@@ -3,6 +3,7 @@ package playlists
import (
"context"
"log/slog"
"math/rand"
"time"
"github.com/jackc/pgx/v5/pgtype"
@@ -10,29 +11,206 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Discovery mixes (#419-423). Each is one candidate query + a thin
// producer registered in systemPlaylistRegistry. They follow the
// Discover model: a SQL query returns ordered (id, album_id,
// artist_id) rows; we optionally diversity-cap, truncate, and emit
// rankedCandidate (Score unused — SQL gave the ranking). All are
// singleton kinds, so the generic by-kind refresh/shuffle endpoints
// and per-tile refresh affordance work with zero client changes.
// Discovery mixes (#419-423). One generic producer + per-mix spec.
// Replaces the five near-identical produceXxx functions that all
// fetched ranked (id, album_id, artist_id) rows, diversified,
// truncated, and emitted a single playlist.
//
// Day-keying is a per-mix property captured by `dailyRotate`:
//
// - DeepCuts / OnThisDay — SQL already day-keys via
// ORDER BY md5(t.id::text || $2::text), so the Go producer keeps
// SQL order. `dailyRotate: false`.
//
// - Rediscover / NewForYou / FirstListens — SQL accepts only $1
// user_id and produces deterministic ordering. `dailyRotate:
// true` applies a daily-deterministic rotate-left of the pool
// BEFORE diversify+truncate so each day's top-100 surfaces a
// different slice while contiguous-block ordering within each
// slice is preserved (matters for FirstListens / NewForYou which
// are album-coherent — rotation walks the album boundary cleanly
// rather than scrambling within an album).
//
// Diversity is `true` for every mix: per-album <= 2 / per-artist <= 3.
// On thin libraries where the cap would chop the pool below 100,
// finishMix tops up from the uncapped raw pool so the mix still
// ships a full-length playlist — see topUpFromRaw.
// discoveryMixLen caps each mix at the same depth as For-You /
// Discover so shuffle-on-play has a varied pool within a day.
const discoveryMixLen = 100
// finishMix caps (per-album<=2 / per-artist<=3) when diversify is
// set, truncates to discoveryMixLen, and converts to the insert
// type. Album-coherent mixes (New for you, First Listens) pass
// diversify=false so whole albums survive.
func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
pool := rows
if diversify {
pool = capByAlbumAndArtist(pool)
// discoveryMixSpec describes one discovery mix. The unified producer
// reads the spec and runs a single code path for all variants.
type discoveryMixSpec struct {
name string
variant string
diversify bool
// dailyRotate, when true, applies a daily-deterministic offset
// rotation to the candidate pool BEFORE diversify+truncate so the
// top discoveryMixLen rotates day-over-day. Set on variants whose
// SQL ORDER is invariant to dateStr (Rediscover, FirstListens).
// Leave false when the SQL already day-keys (DeepCuts, OnThisDay)
// or when day-over-day stability is the intended UX (NewForYou).
dailyRotate bool
// fetch returns the raw ranked rows. dateStr is supplied for
// queries that accept it (passed as the second positional arg
// historically); queries that don't accept it ignore the param.
fetch func(context.Context, *dbq.Queries, pgtype.UUID, string) ([]discoverTrack, error)
}
// produceDiscoveryMix returns a systemPlaylistKind.Produce closure
// bound to the given spec. Registered in systemPlaylistRegistry; see
// the discoveryMixSpecs slice below for the concrete instances.
func produceDiscoveryMix(spec discoveryMixSpec) systemPlaylistProducer {
return func(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, dateStr string, _ time.Time,
) ([]builtPlaylist, error) {
rows, err := spec.fetch(ctx, q, userID, dateStr)
if err != nil {
logger.Warn("system playlist: "+spec.variant+" query failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return nil, nil
}
pool := rows
if spec.dailyRotate {
pool = rotateForDay(pool, userID, dateStr)
}
return emit(spec.name, spec.variant, finishMix(pool, spec.diversify)), nil
}
if len(pool) > discoveryMixLen {
pool = pool[:discoveryMixLen]
}
// rotateForDay rotates pool left by a daily-deterministic offset so
// each day's downstream truncate-to-N surfaces a different slice of
// the pool while contiguous-block ordering inside the slice is
// preserved. Empty / single-element pools pass through unchanged.
func rotateForDay(pool []discoverTrack, userID pgtype.UUID, dateStr string) []discoverTrack {
n := len(pool)
if n <= 1 {
return pool
}
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
offset := rng.Intn(n)
rotated := make([]discoverTrack, 0, n)
rotated = append(rotated, pool[offset:]...)
rotated = append(rotated, pool[:offset]...)
return rotated
}
// discoveryMixSpecs is the concrete spec list used by the registry in
// system.go. Adding a new mix = one entry here + the candidate query.
//
// Order has no functional effect (insert-time atomic replace is order
// independent); listed in the same order as the historical registry.
var discoveryMixSpecs = []discoveryMixSpec{
{
name: "Deep Cuts", variant: "deep_cuts",
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{
UserID: uid, Column2: ds,
})
if err != nil {
return nil, err
}
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return out, nil
},
},
{
name: "Rediscover", variant: "rediscover",
diversify: true, dailyRotate: true, // SQL has no date arg
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
rows, err := q.ListRediscoverTracks(ctx, uid)
if err != nil {
return nil, err
}
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return out, nil
},
},
{
name: "New for you", variant: "new_for_you",
diversify: true, dailyRotate: true, // operator wants daily rotation on all deterministic mixes
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
rows, err := q.ListNewForYouTracks(ctx, uid)
if err != nil {
return nil, err
}
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return out, nil
},
},
{
name: "On this day", variant: "on_this_day",
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{
UserID: uid, Column2: ds,
})
if err != nil {
return nil, err
}
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return out, nil
},
},
{
name: "First listens", variant: "first_listens",
diversify: true, dailyRotate: true, // SQL has no date arg; daily rotate + diversity top-up
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
rows, err := q.ListFirstListensTracks(ctx, uid)
if err != nil {
return nil, err
}
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return out, nil
},
},
}
// finishMix applies diversity caps (per-album <= 2 / per-artist <= 3)
// when diversify is set, with a top-up fallback when caps strip the
// pool below discoveryMixLen: the capped result is filled out with
// non-capped tracks (preserving original SQL order) until the target
// is hit or the raw pool runs out.
//
// The fallback matters on small / album-heavy libraries — the cap
// can chop a 200-row pool down to 40, and we'd rather ship a partly-
// diversified 100 than a strictly-diversified 40. On rich libraries
// the cap yields >= 100 and the top-up path never runs.
func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
var pool []discoverTrack
if diversify {
capped := capByAlbumAndArtist(rows)
if len(capped) >= discoveryMixLen {
pool = capped[:discoveryMixLen]
} else {
pool = topUpFromRaw(capped, rows, discoveryMixLen)
}
} else {
pool = rows
if len(pool) > discoveryMixLen {
pool = pool[:discoveryMixLen]
}
}
if len(pool) == 0 {
return nil
@@ -44,6 +222,32 @@ func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
return tracks
}
// topUpFromRaw appends non-capped tracks from raw (in their original
// order) onto capped, skipping any already present, until the result
// reaches target or raw is exhausted. Preserves SQL ranking semantics
// for the non-diverse fill so the topped-up tail still trends best-
// first within each album.
func topUpFromRaw(capped, raw []discoverTrack, target int) []discoverTrack {
if len(capped) >= target {
return capped[:target]
}
seen := make(map[pgtype.UUID]struct{}, len(capped))
for _, t := range capped {
seen[t.ID] = struct{}{}
}
pool := capped
for _, t := range raw {
if _, in := seen[t.ID]; in {
continue
}
pool = append(pool, t)
if len(pool) >= target {
break
}
}
return pool
}
// emit wraps the finished track list in a single builtPlaylist (the
// discovery mixes are all singletons). nil tracks → no playlist.
func emit(name, variant string, tracks []rankedCandidate) []builtPlaylist {
@@ -52,94 +256,3 @@ func emit(name, variant string, tracks []rankedCandidate) []builtPlaylist {
}
return []builtPlaylist{{Name: name, Variant: variant, Tracks: tracks}}
}
func produceDeepCuts(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, dateStr string, _ time.Time,
) ([]builtPlaylist, error) {
rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{
UserID: userID, Column2: dateStr,
})
if err != nil {
logger.Warn("system playlist: deep-cuts query failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return nil, nil
}
dt := make([]discoverTrack, len(rows))
for i, r := range rows {
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return emit("Deep Cuts", "deep_cuts", finishMix(dt, true)), nil
}
func produceRediscover(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, _ string, _ time.Time,
) ([]builtPlaylist, error) {
rows, err := q.ListRediscoverTracks(ctx, userID)
if err != nil {
logger.Warn("system playlist: rediscover query failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return nil, nil
}
dt := make([]discoverTrack, len(rows))
for i, r := range rows {
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return emit("Rediscover", "rediscover", finishMix(dt, true)), nil
}
func produceNewForYou(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, _ string, _ time.Time,
) ([]builtPlaylist, error) {
rows, err := q.ListNewForYouTracks(ctx, userID)
if err != nil {
logger.Warn("system playlist: new-for-you query failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return nil, nil
}
dt := make([]discoverTrack, len(rows))
for i, r := range rows {
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
// Album-coherent: no diversity cap so whole new albums survive.
return emit("New for you", "new_for_you", finishMix(dt, false)), nil
}
func produceOnThisDay(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, dateStr string, _ time.Time,
) ([]builtPlaylist, error) {
rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{
UserID: userID, Column2: dateStr,
})
if err != nil {
logger.Warn("system playlist: on-this-day query failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return nil, nil
}
dt := make([]discoverTrack, len(rows))
for i, r := range rows {
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return emit("On this day", "on_this_day", finishMix(dt, true)), nil
}
func produceFirstListens(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, _ string, _ time.Time,
) ([]builtPlaylist, error) {
rows, err := q.ListFirstListensTracks(ctx, userID)
if err != nil {
logger.Warn("system playlist: first-listens query failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return nil, nil
}
dt := make([]discoverTrack, len(rows))
for i, r := range rows {
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
// Album-coherent (tiered by liked/played artist in SQL): no cap.
return emit("First listens", "first_listens", finishMix(dt, false)), nil
}
+7 -1
View File
@@ -89,6 +89,12 @@ type Server struct {
// PUT /api/me/timezone and POST /api/auth/register can call
// Refresh synchronously.
PlaylistScheduler *playlists.Scheduler
// StreamSecret is the HMAC key used by /api/cast/stream-token to
// mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream
// to verify them. Sourced from config.Config.StreamSecret. Tests that
// leave it nil leave the cookie path intact and reject all signed
// tokens (HMAC of empty key matches nothing a client could mint).
StreamSecret []byte
}
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler) *Server {
@@ -138,7 +144,7 @@ func (s *Server) Router() http.Handler {
if bus == nil {
bus = eventbus.New()
}
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus, s.PlaylistScheduler)
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
// /api/admin/scan is the only admin route owned by the server package
// (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second