Final lift in the web_server.py extraction effort. Pulls two route handlers + one background worker out of `web_server.py` into new focused packages: - `core/streaming/prepare.py` — 258-line stream-prep worker that downloads a track to the local Stream/ folder for the browser audio player. - `core/playlists/explorer.py` — 305-line route handler for `POST /api/playlist-explorer/build-tree` that streams an NDJSON discography tree from a mirrored playlist. What `prepare_stream_task` does: 1. Reset stream state to 'loading' with the new track info. 2. Clear any prior file from Stream/ (only one stream lives there). 3. Spin up a fresh asyncio event loop and `soulseek_client.download()`. 4. Poll progress every 1.5s. Queue timeout 15s; overall 60s. 5. On succeeded + bytes-match: find the file with retry, move into Stream/, signal slskd completion, mark state 'ready' with file_path. 6. On error/timeout/cancel: state goes to 'error' or 'stopped'. 7. Finally: tear down the event loop cleanly. What `playlist_explorer_build_tree` does: 1. Validate request, load playlist + tracks from DB. 2. Pick active metadata source (Spotify if authed, else fallback). 3. Group tracks by artist using discovered matched_data when the provider matches the active source. 4. Stream NDJSON: meta line → one artist line per group → complete line. 5. Per artist: cache check → resolve discography → tag releases with `in_playlist` flag based on title-similarity match → filter by mode (`albums` = only matches; `discographies` = full disco). 6. Mark playlist as explored on completion. Strict 1:1 byte parity: Both functions exposed their dependencies through proxy patterns established in earlier lifts (PR4–PR8). For prepare_stream_task, `stream_state` is a deps property; for the explorer, Flask `request` / `jsonify` / `Response` are injected via deps so the lifted body keeps its native syntax. Both lifts verified ZERO diff against the original after `deps.X` → global X normalization. 258 lines orig = 258 lines lifted (prepare_stream_task). 305 lines orig = 305 lines lifted (explorer). Bonus cleanup: web_server.py's module-level `import shutil` and `import glob` were now unused (only `_prepare_stream_task` used them at module scope; every other reference is via inline `import shutil` in respective function bodies). Removed both module-level imports — ruff caught the F811 redefinitions and confirmed they're truly redundant. Dependencies for `PrepareStreamDeps` (11 fields): config_manager, soulseek_client, stream_lock, project_root, docker_resolve_path, find_streaming_download_in_all_downloads, find_downloaded_file, extract_filename, cleanup_empty_directories, plus 2 stream_state property delegates. Dependencies for `PlaylistExplorerDeps` (9 fields): Flask request/Response/jsonify, spotify_client, get_database, get_active_discovery_source, get_metadata_fallback_client, get_metadata_fallback_source, get_metadata_cache. Tests: 6 new under tests/streaming/test_prepare.py (state init, Stream/ folder creation + clearing, download-init failure, completed + moved + ready state, partial-bytes incomplete-warning path) plus 9 new under tests/playlists/test_explorer.py (5 validation early-exit paths, streaming response shape with meta/complete lines, mark- explored side effect, discovered-artist grouping using matched_data, provider mismatch falling back to raw artist name). Full suite: 1355 passing (was 1340). Ruff clean. End of the web_server.py extraction effort. Started at ~45,000 lines across PR4–PR8 + this commit; finished around 35,000 lines with the heavy worker + route logic now living in domain-cohesive packages under core/. The remaining bulk in web_server.py is route handlers, service initialization, and the deferred 1530-line `_register_automation_handlers` (startup-only, marginal lift value).
174 lines
6.1 KiB
Python
174 lines
6.1 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 soulseek_client 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
|
|
|
|
|
|
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})(),
|
|
soulseek_client=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):
|
|
"""soulseek_client.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'
|