soulsync/tests/streaming/test_prepare.py
Broque Thomas 5c8b8b271a Lift _prepare_stream_task + playlist_explorer_build_tree to core/
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).
2026-04-29 11:26:07 -07:00

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'