Commit Graph

1540 Commits

Author SHA1 Message Date
bvandeusen e6c3c959fa fix(android): handleZeroDurationIfNeeded ReturnCount within cap
android / Build + lint + test (push) Successful in 3m45s
2026-06-03 22:25:53 -04:00
bvandeusen e011b04e04 fix(android): remove pollLoop cursor sync -- races with queue load and SOAP
android / Build + lint + test (push) Has been cancelled
2026-06-03 22:24:34 -04:00
bvandeusen e2866795ef fix(android): skip zero-duration auto-advance during UPnP playback
android / Build + lint + test (push) Failing after 1m26s
2026-06-03 22:18:11 -04:00
bvandeusen e20d7b1438 fix(android): correct SOAPACTION assertions in next/previous tests
android / Build + lint + test (push) Successful in 3m58s
2026-06-03 22:06:54 -04:00
bvandeusen 2a098a78fe feat(android): Sonos queue mode -- ClearQueue + AddURIToQueue + x-rincon-queue
android / Build + lint + test (push) Failing after 9m9s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 21:55:03 -04:00
bvandeusen ece37e9a92 fix(android): remove pollLoop natural-advance -- races with selectUpnp and skip
android / Build + lint + test (push) Successful in 4m1s
Polling alone cannot distinguish Sonos auto-advancing via SetNextAVTransportURI
from URI changes we made ourselves via syncCurrentItemToRemote.  This produced
two races: (1) activation race -- first poll returns stale URI from prior
session, second returns new URI, false-positive fires and double-advances the
cursor; (2) user-skip race -- skip's syncCurrentItemToRemote changes the URI,
next poll sees the change and fires again.  Remove the detection block and
previousTrackUri capture from pollOnce entirely.  pollLoop is now a pure
state-tracker (position + transport state) plus the one-shot initial pre-queue
gate.  GENA event subscriptions to AVTransport LastChange are the correct fix;
deferred to its own slice (see parity-map).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 21:42:01 -04:00
bvandeusen 8a1203c4a1 fix(android): revert x-rincon-mp3radio Sonos URI -- plain https for music tracks
android / Build + lint + test (push) Successful in 4m13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 21:31:31 -04:00
bvandeusen 487d1bd430 fix(android): SOAP/UPnP parsers enable processNamespaces -- correct name extraction
android / Build + lint + test (push) Successful in 4m4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:58:59 -04:00
bvandeusen 5c99341b34 fix(android): test assertions use JUnit Jupiter for lazy-message Supplier<String>
android / Build + lint + test (push) Failing after 3m31s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:51:28 -04:00
bvandeusen 8fe3308afd fix(android): detekt -- parseHhMmSs ReturnCount/MagicNumber + readUntilEndTag jumps
android / Build + lint + test (push) Failing after 3m5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:45:00 -04:00
bvandeusen 96594ba52b fix(android): Sonos ZGT robust extraction -- escaped + nested element fallback + diag
android / Build + lint + test (push) Failing after 1m29s
2026-06-03 20:40:52 -04:00
bvandeusen 2c61d7a333 fix(android): Sonos UDN comparison strips _MR/_MS suffix -- coordinator routing
android / Build + lint + test (push) Failing after 2m41s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:29:08 -04:00
bvandeusen 8652b86f40 fix(android): Sonos x-rincon-mp3radio URI transform for SetAVTransportURI
android / Build + lint + test (push) Failing after 1m20s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:20:25 -04:00
bvandeusen 75132a2afe diag(android): log raw SOAP response body for first 3 UPnP polls
android / Build + lint + test (push) Failing after 1m22s
Add optional onRawResponse callback to SoapClient; loggingSoapClient
factory emits the first 6 GetPositionInfo/GetTransportInfo bodies
(3 poll cycles) at WARN so release logs capture them. Wire into
transportFor so every AVTransportClient for a new UPnP session logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:12:15 -04:00
bvandeusen 673f98487f fix(android): UPnP local pause via delegate -- no SOAP race
android / Build + lint + test (push) Failing after 1m30s
Move local ExoPlayer pause from OutputPickerController.selectUpnp
into MinstrelForwardingPlayer.onActiveChanged (handler.post { delegate.pause() }).
This guarantees the pause hits ExoPlayer before the holder is live, eliminating
the async race that caused SOAP fault 701 on Sonos when pause() was dispatched
via playerController after holder.active was already set.
Also adds per-poll Timber.w before initialPreQueueDone for diagnostics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 19:53:41 -04:00
bvandeusen c556388a6b fix(android): UPnP activation order -- pause local before holder.set; sync+preQueue sequential; diagnostic Timber.w
android / Build + lint + test (push) Failing after 1m28s
- OutputPickerController.selectUpnp: pause ExoPlayer BEFORE setting
  activeUpnpHolder so ForwardingPlayer.pause() routes to ExoPlayer,
  not SOAP; remove now-redundant playerController.pause() from inside
  runCatching; bump activation Timber.i -> Timber.w for release logcat
- MinstrelForwardingPlayer: remove Player.Listener onMediaItemTransition
  that raced with seekToNext/Prev override's syncCurrentItemToRemote;
  seekToNext/Prev now launch sync -> preQueueNext sequentially in one
  coroutine; remove early preQueueNext from onActiveChanged (raced with
  selectUpnp SOAP); move initial pre-queue to pollLoop, fires once
  trackUri lands confirming Sonos accepted SetAV+Play
- Extract pollOnce from pollLoop to stay within detekt LongMethod=60;
  natural-advance branch now calls preQueueNext explicitly (no listener)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 19:47:19 -04:00
bvandeusen edffdec2b2 fix(android): UPnP drop falls back to local; pollLoop detects natural advance
android / Build + lint + test (push) Failing after 1m10s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 18:07:20 -04:00
bvandeusen 1ab21d81ca feat(android): SetNextAVTransportURI pre-queue for gap-free UPnP advance
android / Build + lint + test (push) Failing after 1m35s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:53:10 -04:00
bvandeusen 81794e2475 fix(android): UPnP volume cache keyed to active route id
android / Build + lint + test (push) Has been cancelled
2026-06-03 17:52:00 -04:00
bvandeusen 29309d9bfb ui(android): drop snackbar + hardware volume keys when UPnP route active
android / Build + lint + test (push) Failing after 1m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:43:55 -04:00
bvandeusen 70b29567fb refactor(android): service holds Player not ExoPlayer for ForwardingPlayer wrap
android / Build + lint + test (push) Failing after 1m25s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:36:35 -04:00
bvandeusen 2f4d67d3c8 feat(android): PlayerFactory wraps ExoPlayer in ForwardingPlayer + drop events
android / Build + lint + test (push) Failing after 1m35s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:34:35 -04:00
bvandeusen b2bfe96559 ui(android): LazyColumn stable keys for in-place output picker row continuity
android / Build + lint + test (push) Failing after 1m21s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:33:01 -04:00
bvandeusen e9dd3e4d2a fix(android): OutputPicker -- local capture, selectUpnp mutex, shared dedup helper, suppress
android / Build + lint + test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:31:40 -04:00
bvandeusen b29875fd30 feat(android): UPnP selection state, disconnect, BuiltIn-pinned alphabetical sort
android / Build + lint + test (push) Failing after 1m21s
OutputPickerController now owns selection state for the UPnP leg and
runs the disconnect flow when the user picks a system route while a
renderer is active.

- Inject OkHttpClient + RemotePlayerState so we can build a
  RenderingControlClient at selection time and capture the last-known
  remote position on disconnect.
- selectUpnp publishes ActiveUpnp(routeId, routeName, avTransport,
  rendering) to ActiveUpnpHolder, marks the route id in
  selectedUpnpRouteIdInternal, and honors Sonos topology by routing
  through coordinatorRouteFor before SOAP.
- selectSystem now does the disconnect: AVTransport.Stop -> clear the
  holder -> seek local ExoPlayer to the remembered position -> resume
  if the remote was playing.
- routesState combines 4 sources (system, UPnP, Sonos topology,
  upnp-selected id). Non-coordinator Sonos members are filtered out
  of the visible list. current resolves from the merged list when a
  UPnP route is selected; otherwise from the system snapshot.
- sortRoutes drops the current-first rule -- BuiltIn "Phone speaker"
  pins to the top, everything else lowercase-alphabetical. Selection
  state moves to the radio-button indicator in the picker row.
- RemotePlayerState gets @Singleton + @Inject constructor() so Hilt
  can provide the shared instance to both the picker and the
  forthcoming MinstrelForwardingPlayer.
2026-06-03 17:24:08 -04:00
bvandeusen 85cea8d559 fix(android): UPnP forwarding -- route mint failures through drop, no double drop
android / Build + lint + test (push) Failing after 1m25s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:18:53 -04:00
bvandeusen ab6c3a1354 feat(android): MinstrelForwardingPlayer wraps ExoPlayer with UPnP branching
android / Build + lint + test (push) Failing after 1m25s
Task 7 of UPnP transport-parity slice. Introduces the central
ForwardingPlayer that branches between local ExoPlayer and the active
UPnP renderer:

- MinstrelForwardingPlayer wraps the delegate Player; play/pause/seek
  and the next/previous transport calls translate to AVTransport SOAP
  when an ActiveUpnp is set, otherwise forward to super. Position +
  isPlaying + duration + playbackState reads pull from
  RemotePlayerState while remote.
- 1Hz poll loop drives GetPositionInfo + GetTransportInfo, feeding
  RemotePlayerState; the rolling-3 failure heuristic fires onDrop on
  the looper for the factory to surface as a snackbar.
- StreamTokenProvider extracts the CastApi.create() Retrofit wiring
  into a Hilt singleton so the service-side player and the
  controller-side picker share one CastApi instance.
- OutputPickerController constructor swaps Retrofit for
  StreamTokenProvider + ActiveUpnpHolder (the holder is wired now for
  Task 8). selectUpnp now mints via streamTokens.mint(trackId).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 17:12:11 -04:00
bvandeusen 3aee2276bc feat(android): RemotePlayerState container for UPnP-synthesized player state
android / Build + lint + test (push) Failing after 1m28s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:08:04 -04:00
bvandeusen 9a7d3b2d30 feat(android): ActiveUpnpHolder singleton for picker -> player handoff
android / Build + lint + test (push) Failing after 1m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:06:33 -04:00
bvandeusen 9002cf5559 refactor(android): UPnP discovery cleanup -- bareUdn helper, Timber, suppress, kdoc
android / Build + lint + test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:05:31 -04:00
bvandeusen a5e4570f01 feat(android): UPnP in-place updates + Sonos topology aggregation
android / Build + lint + test (push) Failing after 1m14s
2026-06-03 16:59:31 -04:00
bvandeusen bfcb9c42a0 feat(android): Sonos ZoneGroupTopology client + parser
android / Build + lint + test (push) Failing after 1m27s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:53:29 -04:00
bvandeusen 799d50024c test(android): RenderingControl lower clamp + GetVolume request body checks
android / Build + lint + test (push) Failing after 1m17s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:51:07 -04:00
bvandeusen 8c0c4c8600 feat(android): RenderingControl get/set volume
android / Build + lint + test (push) Failing after 1m33s
2026-06-03 16:48:40 -04:00
bvandeusen 3cdb416f94 fix(android): AVTransport test assertions escape DIDL; consolidate xmlEscape
android / Build + lint + test (push) Failing after 1m18s
DIDL assertion now checks for XML-escaped form (&lt;dc:title&gt;) since
SoapClient.buildEnvelope escapes all arg values. Lifts xmlEscape to a
top-level internal fun in SoapClient.kt, removing the duplicate private
copy from AVTransportClient. Fixes @Suppress rationale (not Compose).
Renames seek test to reflect colon-separated format; adds unknown-state
getTransportInfo test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:47:18 -04:00
bvandeusen d62a3b8134 feat(android): AVTransport pause/seek/nextURI/positionInfo/transportInfo
android / Build + lint + test (push) Failing after 1m35s
- Add pause(), seek(positionMs), setNextAVTransportURI(uri, mime, title)
- Add getPositionInfo() -> PositionInfo, getTransportInfo() -> TransportInfo
- Extract buildDidlLite() helper; add formatHhMmSs / parseHhMmSs helpers
- Add PositionInfo, TransportState, TransportInfo top-level types
- Add AVTransportClientTest covering all four new call shapes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:42:04 -04:00
bvandeusen 574bf29a7e fix(android): PlayerController transport methods dispatch to controller thread
android / Build + lint + test (push) Successful in 4m17s
After DIDL fix, Sonos accepted SetAVTransportURI + Play, but
playerController.pause() threw IllegalStateException 'method is
called from a wrong thread' because selectUpnp runs the whole
UPnP-selection flow on Dispatchers.Default. UI tap handlers were
fine - they're already on Main - but the cross-thread background
call from OutputPickerController.selectUpnp hit the MediaController's
application-thread guard.

Same fix as the cold-boot resume one earlier today (commit e69a5204
wrapped setQueue): pause / play / seekTo / skipToNext / skipToPrevious
now route through runOnControllerThread, which is a no-op when
already on the application looper and Handler.post otherwise.

Logcat from on-device confirmed Sonos plays after this fix lands -
SetAVTransportURI -> 200, Play -> 200, then the IllegalStateException
was the last failure path.
2026-06-03 16:00:53 -04:00
bvandeusen 24b7c92abd fix(server+android): DIDL-Lite metadata for Sonos UPnP (error 1023)
test-go / test (push) Successful in 29s
android / Build + lint + test (push) Successful in 4m14s
test-go / integration (push) Failing after 9m12s
After the X-Forwarded-Proto fix Sonos now gets a clean https:// URL
but returns vendor error 1023 - empty CurrentURIMetaData. Sonos
requires DIDL-Lite metadata with at minimum <res protocolInfo>
carrying the audio MIME type so it can validate the source before
playback. The original spec said 'Sonos accepts empty DIDL; recoverable
if a device rejects' - that was wrong for Sonos.

Server (cast_token.go):
- Look up the track and return mime (from tracks.file_format) +
  title in the cast-token response. mimeForFormat covers the common
  formats - mp3, flac, m4a/aac, ogg, opus, wav - falling through to
  audio/mpeg for unknowns.
- Missing track returns 404 (apierror.NotFound) instead of letting the
  caller mint a token for nothing.

Client (CastApi.kt, AVTransportClient.kt, OutputPickerController.kt):
- StreamTokenResponse gains mime + title (defaulted so old contracts
  stay parseable).
- AVTransportClient.setAVTransportURIWithMetadata builds minimal Sonos-
  acceptable DIDL-Lite around the URL + MIME + title. xml-escaped.
- selectUpnp calls the new overload; Timber.i now logs the MIME so the
  next on-device test shows it.

Generic UPnP renderers tolerate the DIDL shape too - no downside to
sending it everywhere.
2026-06-03 15:54:56 -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 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