soulsync/tests/playlists/test_explorer.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

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'