Foundation for multi-listener playback. Today web_server.py keeps ONE global
stream_state dict + one lock (web_server.py:747), so the whole server shares a
single 'currently playing' — every tab/device is a remote for the same
playback and two listeners collide. That global is woven through ~22 sites and
isn't unit-testable where it lives.
Lifted into core/streaming/state.py WITHOUT changing behavior:
- StreamSession: one playback's state, dict-compatible (s['k'], s.get,
s.update, 'k' in s) so existing call sites work unchanged, each with its
OWN RLock so distinct sessions never block/clobber each other.
- StreamStateStore: registry of named sessions; lazy + race-safe create;
DEFAULT session reproduces today's exact single-global behavior. Also
drop()/active_ids()/session_ids() for the eventual per-listener wiring.
web_server.py now binds (DEFAULT) and
. Drop-in: every .update()/[k]/.get()/ site behaves identically. _set_stream_state routes a reassign
through session.replace() so the store's session stays the live object (it's
effectively dead — prepare.py only mutates in place — but safe now).
Honest scope: this is the PROVABLE half of Phase 3. The remaining half (3b:
derive a per-browser session id, per-session Stream/ staging, executor
concurrency, disconnect cleanup) is browser-coupled and can't be verified
without driving 2+ live clients — deferred to a live session. The store API is
already shaped for it.
Tests (tests/streaming/, 33 total):
- test_stream_state_store.py (19): session dict-compat, isolation, lazy
create, drop rules, active_ids, concurrent-create race safety.
- test_stream_state_callsite_compat.py (7): every real web_server access
pattern (library/play, stream/start, status, audio guard, stop, prepare
in-place mutation, set->replace) against the exact object web_server binds.
- test_prepare.py +1: real prepare worker drives an actual StreamSession.
76 streaming+radio tests green; ruff clean; web_server.py parses.
3.5 KiB
3.5 KiB
Stream / Player / Radio Revamp — Plan
Goal: bring the audio stream + media-player + radio system to Spotify/Apple-level polish and feature set. Target stack: plain JS (webui/static/media-player.js), not the React migration. Intended architecture direction: multi-listener (final call deferred to Phase 3; Phases 0–2 stay compatible either way).
Rule for every phase: kettui standard — importable/testable logic, seam-level + differential tests, break nothing, ship one reviewable phase at a time.
Phase 0 — Make it provable (foundation, no user-visible change)
- 0a. Extract radio selection logic into testable
core/radio/. DONE (commitcbc001e2).core/radio/selection.pyowns parse_tags/merge_tags/same_artist_cap/build_like_conditions/RadioCollector; DB method delegates. 29 tests, refactor-equivalence proven (behavioral tests pass against old AND new). - 0b. Centralize frontend player state. ~10 scattered
np*globals inmedia-player.js→ onePlayerStateobject. Seam for every later frontend phase. No behavior change.
Phase 1 — Polish / feel (frontend)
- Persistent queue across refresh (localStorage first; server-side in P3)
- Drag-to-reorder queue; duration + art per queue item
- Seek tooltip (hover timestamp); smoother progress
- Crossfade via dual-
<audio>swap (honest approximation of gapless — true gapless impossible w/ single element) - Full Media Session API (lockscreen / hardware transport keys)
- Keyboard shortcut overlay + fuller bindings
Phase 2 — Smart radio (backend algorithm)
- Weighted ranking DONE. Each tier now fetches a random POOL (4x, floored) and
core/radio/selection.rank_candidatesorders it byscore_candidate: play_count + lastfm_playcount (log-damped), recently-played penalty, stable per-id jitter for run variety. Defensive column-probe → still works on a DB predating the play_count/lastfm migration. 43 radio tests; ranking math is deterministic-unit-proven; DB wiring shown via decoy-pool test (probabilistic by nature — documented). - Future (optional deepening): wire
_recently_playedfromlistening_history(column + scorer support already exist; not yet populated in the query), genre-adjacency graph (currently exact-genre LIKE only).
Phase 3 — Architecture (deepest, riskiest — multi-listener)
- 3a. Stream-state store extracted + wired (foundation). DONE.
core/streaming/state.py:StreamSession(dict-compatible, own RLock) +StreamStateStore(named-session registry, lazy create, race-safe).web_server.pynow bindsstream_stateto the store's DEFAULT session — behavior identical to the old single global (proven by call-site-compat + real-session worker tests). 33 streaming tests. This is the provable foundation multi-listener needs. - 3b. Per-listener session id (the unprovable-here part). Derive a session id per browser/device (cookie/header) and key
stream_state_store.get(session_id)off it in the stream routes; per-sessionStream/staging subdir; drop session on disconnect; bumpstream_executorpast max_workers=1. Needs live multi-client testing — do in a session where Boulder can drive 2+ clients. The store API (get(id),drop,active_ids, per-session staging) is already built for it. - Server-side persistent queue (resume across devices/refresh).
Order of execution
0a (radio extraction) → 2 (smart radio) first: highest visible upgrade, backend-only, cleanest to prove, zero playback risk. Then 0b → 1 (polish). Then 3 (architecture) last.