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).
327 lines
10 KiB
Python
327 lines
10 KiB
Python
"""Tests for core/playlists/explorer.py — playlist explorer build-tree route."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
|
|
import pytest
|
|
|
|
from core.playlists import explorer as ex
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fakes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _FakeRequest:
|
|
def __init__(self, payload):
|
|
self._payload = payload
|
|
|
|
def get_json(self):
|
|
return self._payload
|
|
|
|
|
|
class _FakeResponse:
|
|
"""Captures the streaming generator + headers."""
|
|
def __init__(self, generator, mimetype=None, headers=None):
|
|
self.generator = generator
|
|
self.mimetype = mimetype
|
|
self.headers = headers or {}
|
|
self.body_lines = list(generator)
|
|
|
|
|
|
def _fake_jsonify(payload):
|
|
"""Returns the payload dict as-is — the wrapper does the actual jsonify."""
|
|
return payload
|
|
|
|
|
|
@dataclass
|
|
class _FakeAlbum:
|
|
id: str
|
|
name: str
|
|
release_date: str = '2024-01-01'
|
|
image_url: str = ''
|
|
total_tracks: int = 10
|
|
album_type: str = 'album'
|
|
artist_ids: list = None
|
|
|
|
|
|
class _FakeArtistMeta:
|
|
def __init__(self, name='Artist', id='a-1'):
|
|
self.name = name
|
|
self.id = id
|
|
self.image_url = 'http://art-img'
|
|
|
|
|
|
class _FakeSpotify:
|
|
def __init__(self, *, authenticated=True, search_results=None, albums=None):
|
|
self._authenticated = authenticated
|
|
self._search_results = search_results or []
|
|
self._albums = albums or []
|
|
self.sp = object() # pretends to be spotify (skip_cache support)
|
|
|
|
def is_spotify_authenticated(self):
|
|
return self._authenticated
|
|
|
|
def search_artists(self, name, limit=5):
|
|
return self._search_results
|
|
|
|
def get_artist_albums(self, artist_id, album_type='album,single', skip_cache=False):
|
|
return self._albums
|
|
|
|
def get_artist(self, artist_id):
|
|
return None
|
|
|
|
|
|
class _FakeCursor:
|
|
def __init__(self):
|
|
self.queries = []
|
|
|
|
def execute(self, sql, params=None):
|
|
self.queries.append((sql, params))
|
|
|
|
def fetchall(self):
|
|
return []
|
|
|
|
|
|
class _FakeConn:
|
|
def __init__(self):
|
|
self.cur = _FakeCursor()
|
|
|
|
def cursor(self):
|
|
return self.cur
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
return False
|
|
|
|
|
|
class _FakeDB:
|
|
def __init__(self, playlist=None, tracks=None):
|
|
self._playlist = playlist
|
|
self._tracks = tracks or []
|
|
self.marked_explored = False
|
|
|
|
def get_mirrored_playlist(self, pid):
|
|
return self._playlist
|
|
|
|
def get_mirrored_playlist_tracks(self, pid):
|
|
return self._tracks
|
|
|
|
def mark_mirrored_playlist_explored(self, pid):
|
|
self.marked_explored = True
|
|
|
|
def _get_connection(self):
|
|
return _FakeConn()
|
|
|
|
|
|
class _FakeCache:
|
|
def __init__(self):
|
|
self.entries = {}
|
|
|
|
def get_entity(self, source, kind, key):
|
|
return self.entries.get((source, kind, key))
|
|
|
|
def store_entity(self, source, kind, key, value):
|
|
self.entries[(source, kind, key)] = value
|
|
|
|
|
|
def _build_deps(
|
|
*,
|
|
request_payload=None,
|
|
spotify=None,
|
|
db=None,
|
|
discovery_source='spotify',
|
|
fallback_source='itunes',
|
|
cache=None,
|
|
):
|
|
deps = ex.PlaylistExplorerDeps(
|
|
request=_FakeRequest(request_payload or {}),
|
|
flask_response=_FakeResponse,
|
|
flask_jsonify=_fake_jsonify,
|
|
spotify_client=spotify or _FakeSpotify(),
|
|
get_database=lambda: db or _FakeDB(),
|
|
get_active_discovery_source=lambda: discovery_source,
|
|
get_metadata_fallback_client=lambda: spotify or _FakeSpotify(),
|
|
get_metadata_fallback_source=lambda: fallback_source,
|
|
get_metadata_cache=lambda: cache or _FakeCache(),
|
|
)
|
|
return deps
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation early exits
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_no_data_returns_400():
|
|
"""Empty/None request body → 400."""
|
|
deps = _build_deps(request_payload=None)
|
|
deps.request = _FakeRequest(None)
|
|
result = ex.playlist_explorer_build_tree(deps)
|
|
payload, status = result
|
|
assert status == 400
|
|
assert payload == {"success": False, "error": "No data provided"}
|
|
|
|
|
|
def test_missing_playlist_id_returns_400():
|
|
"""Payload without playlist_id → 400."""
|
|
deps = _build_deps(request_payload={'mode': 'albums'})
|
|
payload, status = ex.playlist_explorer_build_tree(deps)
|
|
assert status == 400
|
|
assert 'playlist_id' in payload['error']
|
|
|
|
|
|
def test_invalid_mode_returns_400():
|
|
"""mode != 'albums'/'discographies' → 400."""
|
|
deps = _build_deps(request_payload={'playlist_id': '1', 'mode': 'invalid'})
|
|
payload, status = ex.playlist_explorer_build_tree(deps)
|
|
assert status == 400
|
|
assert "'albums' or 'discographies'" in payload['error']
|
|
|
|
|
|
def test_playlist_not_found_returns_404():
|
|
"""Database returns no playlist for the given ID → 404."""
|
|
db = _FakeDB(playlist=None)
|
|
deps = _build_deps(request_payload={'playlist_id': '99'}, db=db)
|
|
payload, status = ex.playlist_explorer_build_tree(deps)
|
|
assert status == 404
|
|
|
|
|
|
def test_playlist_with_no_tracks_returns_400():
|
|
"""Playlist found but has no tracks → 400."""
|
|
db = _FakeDB(playlist={'name': 'P', 'image_url': ''}, tracks=[])
|
|
deps = _build_deps(request_payload={'playlist_id': '1'}, db=db)
|
|
payload, status = ex.playlist_explorer_build_tree(deps)
|
|
assert status == 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Streaming response
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_success_returns_streaming_response_with_meta_line():
|
|
"""Successful build → Response wrapper with NDJSON generator that starts with 'meta'."""
|
|
db = _FakeDB(
|
|
playlist={'name': 'My Playlist', 'image_url': 'http://img'},
|
|
tracks=[{'track_name': 'T1', 'artist_name': 'Artist One', 'album_name': 'Album X', 'extra_data': None}],
|
|
)
|
|
spotify = _FakeSpotify(
|
|
search_results=[_FakeArtistMeta(name='Artist One', id='a-1')],
|
|
albums=[_FakeAlbum(id='alb-1', name='Album X', release_date='2024')],
|
|
)
|
|
deps = _build_deps(
|
|
request_payload={'playlist_id': '1', 'mode': 'discographies'},
|
|
spotify=spotify,
|
|
db=db,
|
|
)
|
|
|
|
response = ex.playlist_explorer_build_tree(deps)
|
|
|
|
# _FakeResponse exposes body_lines pre-collected from the generator
|
|
assert response.mimetype == 'application/x-ndjson'
|
|
lines = response.body_lines
|
|
assert len(lines) >= 2
|
|
|
|
# First line should be meta
|
|
first = json.loads(lines[0])
|
|
assert first['type'] == 'meta'
|
|
assert first['playlist_name'] == 'My Playlist'
|
|
assert first['source'] == 'spotify'
|
|
|
|
# Last line should be 'complete'
|
|
last = json.loads(lines[-1])
|
|
assert last['type'] == 'complete'
|
|
|
|
|
|
def test_marks_playlist_explored_at_end_of_stream():
|
|
"""When the streaming generator runs to completion, mark_mirrored_playlist_explored fires."""
|
|
db = _FakeDB(
|
|
playlist={'name': 'P', 'image_url': ''},
|
|
tracks=[{'track_name': 'T', 'artist_name': 'A', 'album_name': 'B', 'extra_data': None}],
|
|
)
|
|
spotify = _FakeSpotify(search_results=[_FakeArtistMeta(name='A')])
|
|
deps = _build_deps(
|
|
request_payload={'playlist_id': '1', 'mode': 'discographies'},
|
|
spotify=spotify,
|
|
db=db,
|
|
)
|
|
|
|
ex.playlist_explorer_build_tree(deps)
|
|
|
|
assert db.marked_explored is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Discovered-track grouping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_discovered_artist_grouping_uses_matched_data():
|
|
"""Tracks with matching-source extra_data → use matched_data['artists'][0]."""
|
|
db = _FakeDB(
|
|
playlist={'name': 'P', 'image_url': ''},
|
|
tracks=[
|
|
{
|
|
'track_name': 'T',
|
|
'artist_name': 'Local Artist Name', # raw
|
|
'album_name': 'Local Album',
|
|
'extra_data': json.dumps({
|
|
'discovered': True,
|
|
'provider': 'spotify',
|
|
'matched_data': {
|
|
'artists': [{'name': 'Discovered Artist', 'id': 'sp-aid'}],
|
|
'album': {'name': 'Discovered Album'},
|
|
},
|
|
}),
|
|
},
|
|
],
|
|
)
|
|
spotify = _FakeSpotify(
|
|
search_results=[_FakeArtistMeta(name='Discovered Artist')],
|
|
albums=[_FakeAlbum(id='alb-1', name='Discovered Album', release_date='2024')],
|
|
)
|
|
deps = _build_deps(
|
|
request_payload={'playlist_id': '1', 'mode': 'discographies'},
|
|
spotify=spotify, db=db,
|
|
)
|
|
|
|
response = ex.playlist_explorer_build_tree(deps)
|
|
|
|
artist_lines = [json.loads(line) for line in response.body_lines if json.loads(line).get('type') == 'artist']
|
|
assert len(artist_lines) == 1
|
|
assert artist_lines[0]['name'] == 'Discovered Artist'
|
|
assert artist_lines[0]['artist_id'] == 'sp-aid'
|
|
|
|
|
|
def test_provider_mismatch_falls_back_to_raw_track_name():
|
|
"""If discovered provider != active source, ignore matched_data, use raw artist_name."""
|
|
db = _FakeDB(
|
|
playlist={'name': 'P', 'image_url': ''},
|
|
tracks=[
|
|
{
|
|
'track_name': 'T',
|
|
'artist_name': 'Raw Artist',
|
|
'album_name': 'Raw Album',
|
|
'extra_data': json.dumps({
|
|
'discovered': True,
|
|
'provider': 'itunes', # mismatch
|
|
'matched_data': {
|
|
'artists': [{'name': 'iTunes Artist'}],
|
|
},
|
|
}),
|
|
},
|
|
],
|
|
)
|
|
# Active source is spotify (default)
|
|
spotify = _FakeSpotify(search_results=[_FakeArtistMeta(name='Raw Artist')])
|
|
deps = _build_deps(
|
|
request_payload={'playlist_id': '1', 'mode': 'discographies'},
|
|
spotify=spotify, db=db,
|
|
)
|
|
|
|
response = ex.playlist_explorer_build_tree(deps)
|
|
|
|
artist_lines = [json.loads(line) for line in response.body_lines if json.loads(line).get('type') == 'artist']
|
|
assert artist_lines[0]['name'] == 'Raw Artist' # NOT 'iTunes Artist'
|