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.
126 lines
4.6 KiB
Python
126 lines
4.6 KiB
Python
"""Proves the StreamSession is a drop-in for the old stream_state dict.
|
|
|
|
web_server.py was swapped from a module-global ``dict`` + ``threading.Lock``
|
|
to ``StreamStateStore().get()`` (the default session) + that session's lock.
|
|
This exercises every access pattern the real call sites use, against the same
|
|
object web_server now binds, so the swap is verified without booting Flask.
|
|
|
|
The patterns mirrored here come verbatim from web_server.py:
|
|
- /api/library/play: with lock: state.update({... "is_library": True})
|
|
- /api/stream/start: with lock: state.update({"status": "loading", ...})
|
|
- /api/stream/status: with lock: read state["status"], state["progress"], ...
|
|
- /stream/audio: with lock: if state["status"] != "ready" or not state["file_path"]
|
|
- /api/stream/stop: with lock: state.get("is_library", False); state.update({... reset})
|
|
- prepare.py: state.update(...) and state["status"] = "queued"
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from core.streaming.state import StreamStateStore
|
|
|
|
|
|
def _server_like_state():
|
|
"""Reproduce exactly what web_server.py now binds."""
|
|
store = StreamStateStore()
|
|
state = store.get() # DEFAULT_SESSION
|
|
lock = state.lock
|
|
return state, lock
|
|
|
|
|
|
def test_library_play_pattern():
|
|
state, lock = _server_like_state()
|
|
with lock:
|
|
state.update({
|
|
"status": "ready",
|
|
"progress": 100,
|
|
"track_info": {"title": "T", "artist": "A", "album": "Al"},
|
|
"file_path": "/Stream/x.flac",
|
|
"is_library": True,
|
|
})
|
|
assert state["status"] == "ready"
|
|
assert state["file_path"] == "/Stream/x.flac"
|
|
assert state.get("is_library") is True
|
|
|
|
|
|
def test_stream_start_pattern():
|
|
state, lock = _server_like_state()
|
|
with lock:
|
|
state.update({
|
|
"status": "loading",
|
|
"progress": 0,
|
|
"track_info": {"title": "Song"},
|
|
"file_path": None,
|
|
"error_message": None,
|
|
})
|
|
assert state["status"] == "loading"
|
|
|
|
|
|
def test_stream_status_read_pattern():
|
|
state, lock = _server_like_state()
|
|
state.update({"status": "queued", "progress": 42,
|
|
"track_info": {"title": "Q"}, "error_message": None})
|
|
with lock:
|
|
payload = {
|
|
"status": state["status"],
|
|
"progress": state["progress"],
|
|
"track_info": state["track_info"],
|
|
"error_message": state["error_message"],
|
|
}
|
|
assert payload == {"status": "queued", "progress": 42,
|
|
"track_info": {"title": "Q"}, "error_message": None}
|
|
|
|
|
|
def test_stream_audio_guard_pattern():
|
|
state, lock = _server_like_state()
|
|
# Not ready → guard trips (would 404 in the route).
|
|
with lock:
|
|
not_ready = state["status"] != "ready" or not state["file_path"]
|
|
assert not_ready is True
|
|
|
|
state.update({"status": "ready", "file_path": "/Stream/y.flac"})
|
|
with lock:
|
|
not_ready = state["status"] != "ready" or not state["file_path"]
|
|
path = state["file_path"]
|
|
assert not_ready is False
|
|
assert path == "/Stream/y.flac"
|
|
|
|
|
|
def test_stream_stop_pattern():
|
|
state, lock = _server_like_state()
|
|
state.update({"status": "ready", "file_path": "/x", "is_library": True})
|
|
with lock:
|
|
is_library = state.get("is_library", False)
|
|
assert is_library is True
|
|
with lock:
|
|
state.update({
|
|
"status": "stopped", "progress": 0, "track_info": None,
|
|
"file_path": None, "error_message": None, "is_library": False,
|
|
})
|
|
assert state["status"] == "stopped"
|
|
assert state.get("is_library") is False
|
|
|
|
|
|
def test_prepare_worker_inplace_mutation_pattern():
|
|
state, _ = _server_like_state()
|
|
# prepare.py mutates in place via both update() and [k]=
|
|
state.update({"status": "loading", "progress": 0})
|
|
state["status"] = "queued"
|
|
state["progress"] = 10
|
|
state["status"] = "loading"
|
|
state["progress"] = 55
|
|
assert state["status"] == "loading"
|
|
assert state["progress"] == 55
|
|
|
|
|
|
def test_set_stream_state_replace_keeps_same_session_object():
|
|
"""web_server._set_stream_state now routes a reassignment through
|
|
replace() so the store's default session stays the live object. Verify a
|
|
'reassign' is reflected in the SAME session the store hands out."""
|
|
store = StreamStateStore()
|
|
state = store.get()
|
|
# simulate _set_stream_state(value): state.replace(dict(value))
|
|
state.replace({"status": "error", "error_message": "boom"})
|
|
# The store still hands back the same object, now carrying the new values.
|
|
assert store.get() is state
|
|
assert store.get()["status"] == "error"
|
|
assert store.get()["error_message"] == "boom"
|