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.
202 lines
7.5 KiB
Python
202 lines
7.5 KiB
Python
"""Tests for core/streaming/prepare.py — stream-prep worker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
from core.streaming import prepare as sp
|
|
|
|
|
|
class _FakeSoulseek:
|
|
"""Minimal download_orchestrator stub for the stream-prep worker."""
|
|
|
|
def __init__(self, *, download_id='dl-1', all_downloads=None):
|
|
self._download_id = download_id
|
|
self._all_downloads = all_downloads if all_downloads is not None else []
|
|
|
|
async def download(self, username, filename, size):
|
|
return self._download_id
|
|
|
|
async def get_all_downloads(self):
|
|
return self._all_downloads
|
|
|
|
async def signal_download_completion(self, download_id, username, remove=True):
|
|
return True
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_sleep(monkeypatch):
|
|
"""Keep stream-prep tests fast while still exercising the polling branches."""
|
|
monkeypatch.setattr(sp.time, 'sleep', lambda *_args, **_kwargs: None)
|
|
|
|
|
|
def _build_deps(
|
|
*,
|
|
state=None,
|
|
soulseek=None,
|
|
project_root='/tmp/proj',
|
|
find_streaming_result=None,
|
|
find_downloaded_result=None,
|
|
):
|
|
state = state if state is not None else {}
|
|
deps = sp.PrepareStreamDeps(
|
|
config_manager=type('C', (), {'get': lambda self, k, d=None: d})(),
|
|
download_orchestrator=soulseek or _FakeSoulseek(),
|
|
stream_lock=threading.Lock(),
|
|
project_root=project_root,
|
|
docker_resolve_path=lambda p: p,
|
|
find_streaming_download_in_all_downloads=lambda all_dl, td: find_streaming_result,
|
|
find_downloaded_file=lambda dl_path, td: find_downloaded_result,
|
|
extract_filename=lambda fp: __import__('os').path.basename(fp),
|
|
cleanup_empty_directories=lambda dl_path, found_file: None,
|
|
_get_stream_state=lambda: state,
|
|
_set_stream_state=lambda v: state.clear() or state.update(v),
|
|
)
|
|
deps._state = state
|
|
return deps
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Initial state setup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_state_starts_loading_with_track_info(tmp_path):
|
|
"""First action sets state to 'loading' with the track_info."""
|
|
sk = _FakeSoulseek(download_id=None) # forces an early "Failed to initiate" exit
|
|
deps = _build_deps(soulseek=sk, project_root=str(tmp_path))
|
|
|
|
track_data = {'username': 'u', 'filename': 'song.flac', 'size': 1000}
|
|
sp.prepare_stream_task(track_data, deps)
|
|
|
|
# First mutation set status='loading', track_info=track_data
|
|
# Then early exit because download() returned None — state ends up 'error'
|
|
assert deps._state['status'] == 'error'
|
|
assert 'Failed to initiate' in deps._state['error_message']
|
|
|
|
|
|
def test_stream_folder_created(tmp_path):
|
|
"""Stream/ subfolder is created under project_root."""
|
|
sk = _FakeSoulseek(download_id=None)
|
|
deps = _build_deps(soulseek=sk, project_root=str(tmp_path))
|
|
|
|
sp.prepare_stream_task({'username': 'u', 'filename': 'x', 'size': 0}, deps)
|
|
|
|
assert (tmp_path / 'Stream').is_dir()
|
|
|
|
|
|
def test_stream_folder_cleared_before_download(tmp_path):
|
|
"""Existing files in Stream/ are removed before each prepare."""
|
|
stream_dir = tmp_path / 'Stream'
|
|
stream_dir.mkdir()
|
|
old_file = stream_dir / 'old.flac'
|
|
old_file.write_bytes(b'old data')
|
|
assert old_file.exists()
|
|
|
|
sk = _FakeSoulseek(download_id=None)
|
|
deps = _build_deps(soulseek=sk, project_root=str(tmp_path))
|
|
sp.prepare_stream_task({'username': 'u', 'filename': 'x', 'size': 0}, deps)
|
|
|
|
# Old file gone (cleared at start of prep)
|
|
assert not old_file.exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Download initiation failure
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_download_returns_none_marks_error(tmp_path):
|
|
"""download_orchestrator.download() returning None → state.error."""
|
|
sk = _FakeSoulseek(download_id=None)
|
|
deps = _build_deps(soulseek=sk, project_root=str(tmp_path))
|
|
|
|
sp.prepare_stream_task({'username': 'u', 'filename': 'x', 'size': 0}, deps)
|
|
|
|
assert deps._state['status'] == 'error'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Successful completion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_completed_download_moves_to_stream_and_marks_ready(tmp_path):
|
|
"""When the polled status reports succeeded + bytes match, file moved + state ready."""
|
|
download_path = tmp_path / 'downloads'
|
|
download_path.mkdir()
|
|
src_file = download_path / 'song.flac'
|
|
src_file.write_bytes(b'audio')
|
|
|
|
download_status = {
|
|
'id': 'dl-99',
|
|
'state': 'Succeeded',
|
|
'percentComplete': 100,
|
|
'size': 5,
|
|
'bytesTransferred': 5,
|
|
}
|
|
sk = _FakeSoulseek(download_id='dl-99', all_downloads=['stub'])
|
|
deps = _build_deps(
|
|
soulseek=sk,
|
|
project_root=str(tmp_path),
|
|
find_streaming_result=download_status,
|
|
find_downloaded_result=str(src_file),
|
|
)
|
|
deps.config_manager = type('C', (), {
|
|
'get': lambda self, k, d=None: str(download_path) if k == 'soulseek.download_path' else d,
|
|
})()
|
|
|
|
sp.prepare_stream_task(
|
|
{'username': 'u', 'filename': 'song.flac', 'size': 5},
|
|
deps,
|
|
)
|
|
|
|
assert deps._state['status'] == 'ready'
|
|
assert deps._state['progress'] == 100
|
|
assert (tmp_path / 'Stream' / 'song.flac').exists()
|
|
assert deps._state['file_path'] == str(tmp_path / 'Stream' / 'song.flac')
|
|
|
|
|
|
def test_succeeded_state_with_partial_bytes_keeps_polling(tmp_path):
|
|
"""If state is 'Succeeded' but bytes < size, marks _incomplete_warned and continues."""
|
|
download_status = {
|
|
'id': 'dl-99',
|
|
'state': 'Succeeded',
|
|
'percentComplete': 100,
|
|
'size': 100,
|
|
'bytesTransferred': 50, # incomplete
|
|
}
|
|
sk = _FakeSoulseek(download_id='dl-99', all_downloads=['stub'])
|
|
deps = _build_deps(
|
|
soulseek=sk,
|
|
project_root=str(tmp_path),
|
|
find_streaming_result=download_status,
|
|
)
|
|
|
|
# Force quick exit by capping the loop with no further state change
|
|
# Worker times out via max_wait_time in real code — we just verify state didn't go ready
|
|
sp.prepare_stream_task({'username': 'u', 'filename': 'x', 'size': 100}, deps)
|
|
|
|
# Should NOT have gone to 'ready' because bytes were incomplete
|
|
assert deps._state['status'] != 'ready'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Real StreamSession compatibility (player-revamp Phase 3 wiring)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_worker_drives_a_real_stream_session(tmp_path):
|
|
"""web_server.py now binds stream_state to a StreamStateStore session
|
|
(not a bare dict). Prove the prepare worker drives that real object
|
|
correctly end-to-end through the deps proxy — the actual production type."""
|
|
from core.streaming.state import StreamStateStore
|
|
|
|
session = StreamStateStore().get() # the real production object
|
|
sk = _FakeSoulseek(download_id=None) # early error exit is enough to mutate state
|
|
deps = _build_deps(soulseek=sk, project_root=str(tmp_path), state=session)
|
|
|
|
sp.prepare_stream_task({'username': 'u', 'filename': 'song.flac', 'size': 1}, deps)
|
|
|
|
# Worker mutated the SAME session via update()/[k]= — proves dict-compat.
|
|
assert session['status'] == 'error'
|
|
assert 'Failed to initiate' in session['error_message']
|
|
assert session['track_info'] == {'username': 'u', 'filename': 'song.flac', 'size': 1}
|