- Routing (step 5): organize-by-playlist tracks no longer set the per-track _playlist_folder_mode flag, so they import NORMALLY into Artist/Album — exactly what a normal download does. _playlist_name provenance is kept (origin.py). - Triggers (step 4): build the playlist folder from the batch's own payload at both end-of-flow points — the all-owned path in master.py (no downloads, so the lifecycle never runs) and the batch-complete hook in lifecycle.py (after downloads). Both gated on playlist_folder_mode, both non-fatal. Works for the all-owned case (the smack test that did nothing before) and for mixed owned/downloaded, with no source-ID or mirrored-playlist dependency. The materialized folder uses the default ./Playlists root + symlink mode until the Settings UI is added. Updated the master test to assert the new contract (provenance kept, routing flag gone). 979 tests pass.
1198 lines
43 KiB
Python
1198 lines
43 KiB
Python
"""Tests for core/downloads/master.py — full missing-tracks master worker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from core.downloads import master as mw
|
|
from core.runtime_state import download_batches, download_tasks, tasks_lock
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures + fakes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_state():
|
|
download_tasks.clear()
|
|
download_batches.clear()
|
|
yield
|
|
download_tasks.clear()
|
|
download_batches.clear()
|
|
|
|
|
|
class _FakeConfig:
|
|
def __init__(self, values=None):
|
|
self._v = values or {}
|
|
|
|
def get(self, key, default=None):
|
|
return self._v.get(key, default)
|
|
|
|
def get_active_media_server(self):
|
|
return self._v.get('_active_server', 'plex')
|
|
|
|
|
|
class _FakeDB:
|
|
def __init__(self, found_tracks=None, album=None, album_tracks=None, album_confidence=0.95):
|
|
self.found_tracks = found_tracks or {} # (title_lower, artist_lower) -> confidence
|
|
self.album = album
|
|
self.album_tracks = album_tracks or []
|
|
self.album_confidence = album_confidence
|
|
self.sync_history_calls = []
|
|
self.track_results_calls = []
|
|
self.manual_matches = []
|
|
|
|
def check_track_exists(self, title, artist, confidence_threshold=0.7, server_source=None, album=None):
|
|
key = (title.lower().strip(), artist.lower().strip())
|
|
if key in self.found_tracks:
|
|
conf = self.found_tracks[key]
|
|
return (object(), conf) # (DatabaseTrack-ish, confidence)
|
|
return (None, 0.0)
|
|
|
|
def check_album_exists_with_editions(self, title, artist, confidence_threshold=0.7,
|
|
expected_track_count=None, server_source=None):
|
|
return (self.album, self.album_confidence)
|
|
|
|
def get_tracks_by_album(self, album_id):
|
|
return self.album_tracks
|
|
|
|
def _string_similarity(self, a, b):
|
|
if a == b:
|
|
return 1.0
|
|
if a in b or b in a:
|
|
return 0.85
|
|
return 0.0
|
|
|
|
def update_sync_history_completion(self, batch_id, **kwargs):
|
|
self.sync_history_calls.append((batch_id, kwargs))
|
|
|
|
def update_sync_history_track_results(self, batch_id, results_json):
|
|
self.track_results_calls.append((batch_id, results_json))
|
|
|
|
def get_manual_library_match(self, profile_id, source, source_track_id, server_source=''):
|
|
for match in self.manual_matches:
|
|
if (
|
|
match["profile_id"] == profile_id
|
|
and match["source"] == source
|
|
and match["source_track_id"] == source_track_id
|
|
and match.get("server_source", "") == server_source
|
|
):
|
|
return match
|
|
return None
|
|
|
|
def find_manual_library_match_by_source_track_id(self, profile_id, source_track_id, server_source=''):
|
|
for match in self.manual_matches:
|
|
if match["profile_id"] == profile_id and match["source_track_id"] == source_track_id:
|
|
return match
|
|
return None
|
|
|
|
|
|
class _DBTrack:
|
|
def __init__(self, title):
|
|
self.title = title
|
|
|
|
|
|
class _DBAlbum:
|
|
def __init__(self, id_, title):
|
|
self.id = id_
|
|
self.title = title
|
|
|
|
|
|
class _FakeSoulseek:
|
|
def __init__(self, album_results=None, track_results=None, browse_files=None, parsed_tracks=None):
|
|
self._album_results = album_results or []
|
|
self._track_results = track_results or []
|
|
self._browse_files = browse_files
|
|
self._parsed_tracks = parsed_tracks or []
|
|
self.search_calls = []
|
|
|
|
async def search(self, query, timeout=30):
|
|
self.search_calls.append(query)
|
|
return (self._track_results, self._album_results)
|
|
|
|
def filter_results_by_quality_preference(self, tracks):
|
|
return tracks # no-op, accept all
|
|
|
|
async def browse_user_directory(self, username, path):
|
|
return self._browse_files
|
|
|
|
def parse_browse_results_to_tracks(self, username, browse_files, directory):
|
|
return self._parsed_tracks
|
|
|
|
|
|
class _FakeSoulseekWrapper:
|
|
"""Wraps a soulseek client at .soulseek attribute (matches web_server pattern)."""
|
|
def __init__(self, inner):
|
|
self.soulseek = inner
|
|
|
|
def client(self, name):
|
|
return self.soulseek if name == 'soulseek' else None
|
|
|
|
|
|
class _FakePluginWrapper:
|
|
def __init__(self, plugins):
|
|
self._plugins = dict(plugins)
|
|
|
|
def client(self, name):
|
|
return self._plugins.get(name)
|
|
|
|
|
|
class _FakeAlbumBundleSoulseek:
|
|
def __init__(self, outcome=None):
|
|
self.calls = []
|
|
self.outcome = outcome or {'success': True, 'files': ['/tmp/a.flac']}
|
|
|
|
def download_album_to_staging(self, album, artist, staging, emit, **kwargs):
|
|
self.calls.append((album, artist, staging, kwargs))
|
|
emit({'state': 'staged', 'count': len(self.outcome.get('files', []))})
|
|
return self.outcome
|
|
|
|
|
|
class _FakePreflightAlbumBundleSoulseek(_FakeSoulseek):
|
|
def __init__(self, *args, outcome=None, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.calls = []
|
|
self.outcome = outcome or {'success': True, 'files': ['/tmp/a.flac']}
|
|
|
|
def download_album_to_staging(self, album, artist, staging, emit, **kwargs):
|
|
self.calls.append((album, artist, staging, kwargs))
|
|
emit({'state': 'staged', 'count': len(self.outcome.get('files', []))})
|
|
return self.outcome
|
|
|
|
|
|
class _FakeMonitor:
|
|
def __init__(self):
|
|
self.started = []
|
|
|
|
def start_monitoring(self, batch_id):
|
|
self.started.append(batch_id)
|
|
|
|
|
|
class _FakeExecutor:
|
|
def __init__(self):
|
|
self.submitted = []
|
|
|
|
def submit(self, fn, *args):
|
|
self.submitted.append((fn, args))
|
|
|
|
|
|
class _FakeMBSvc:
|
|
pass
|
|
|
|
|
|
class _FakeMBWorker:
|
|
def __init__(self, svc=None):
|
|
self.mb_service = svc
|
|
|
|
|
|
def _run_async_sync(coro):
|
|
"""Synchronously run a coroutine for tests."""
|
|
import asyncio
|
|
return asyncio.get_event_loop().run_until_complete(coro) if not asyncio.iscoroutine(coro) else asyncio.new_event_loop().run_until_complete(coro)
|
|
|
|
|
|
def _make_run_async():
|
|
import asyncio
|
|
def _runner(coro):
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|
|
return _runner
|
|
|
|
|
|
def _build_deps(
|
|
*,
|
|
config=None,
|
|
soulseek=None,
|
|
run_async=None,
|
|
mb_worker=None,
|
|
mb_release_cache=None,
|
|
mb_release_cache_lock=None,
|
|
mb_release_detail_cache=None,
|
|
mb_release_detail_cache_lock=None,
|
|
normalize_album_cache_key=None,
|
|
wishlist_remove=None,
|
|
is_explicit_blocked=None,
|
|
yt_states=None,
|
|
tidal_states=None,
|
|
deezer_states=None,
|
|
spotify_states=None,
|
|
executor=None,
|
|
process_failed_auto=None,
|
|
source_reuse_logger=None,
|
|
monitor=None,
|
|
start_next_batch=None,
|
|
reset_wishlist_auto=None,
|
|
):
|
|
return mw.MasterDeps(
|
|
config_manager=config or _FakeConfig(),
|
|
download_orchestrator=soulseek or _FakeSoulseekWrapper(_FakeSoulseek()),
|
|
run_async=run_async or _make_run_async(),
|
|
mb_worker=mb_worker,
|
|
mb_release_cache=mb_release_cache if mb_release_cache is not None else {},
|
|
mb_release_cache_lock=mb_release_cache_lock or threading.Lock(),
|
|
mb_release_detail_cache=mb_release_detail_cache if mb_release_detail_cache is not None else {},
|
|
mb_release_detail_cache_lock=mb_release_detail_cache_lock or threading.Lock(),
|
|
normalize_album_cache_key=normalize_album_cache_key or (lambda s: s.lower().strip()),
|
|
check_and_remove_track_from_wishlist_by_metadata=wishlist_remove or (lambda td: None),
|
|
is_explicit_blocked=is_explicit_blocked or (lambda td: False),
|
|
youtube_playlist_states=yt_states if yt_states is not None else {},
|
|
tidal_discovery_states=tidal_states if tidal_states is not None else {},
|
|
deezer_discovery_states=deezer_states if deezer_states is not None else {},
|
|
spotify_public_discovery_states=spotify_states if spotify_states is not None else {},
|
|
missing_download_executor=executor or _FakeExecutor(),
|
|
process_failed_tracks_to_wishlist_exact_with_auto_completion=process_failed_auto or (lambda bid: None),
|
|
source_reuse_logger=source_reuse_logger or _StubLogger(),
|
|
download_monitor=monitor or _FakeMonitor(),
|
|
start_next_batch_of_downloads=start_next_batch or (lambda bid: None),
|
|
reset_wishlist_auto_processing=reset_wishlist_auto or (lambda: None),
|
|
)
|
|
|
|
|
|
class _StubLogger:
|
|
def info(self, *a, **kw): pass
|
|
def warning(self, *a, **kw): pass
|
|
def error(self, *a, **kw): pass
|
|
def debug(self, *a, **kw): pass
|
|
|
|
|
|
def _seed_batch(batch_id, **overrides):
|
|
base = {
|
|
'phase': 'queued',
|
|
'queue': [],
|
|
'analysis_total': 0,
|
|
'analysis_processed': 0,
|
|
'analysis_results': [],
|
|
}
|
|
base.update(overrides)
|
|
download_batches[batch_id] = base
|
|
|
|
|
|
def _slsk_track(title, number, folder='Artist/Test Album'):
|
|
return SimpleNamespace(
|
|
username='peer',
|
|
filename=f'{folder}/{number:02d} - {title}.flac',
|
|
title=title,
|
|
track_number=number,
|
|
quality='flac',
|
|
bitrate=None,
|
|
duration=180000,
|
|
size=20_000_000,
|
|
free_upload_slots=1,
|
|
upload_speed=2_000_000,
|
|
queue_length=0,
|
|
quality_score=1.0,
|
|
)
|
|
|
|
|
|
def _album_result(username, path, title, tracks, *, artist='Artist', year='2020',
|
|
quality_score=0.9):
|
|
return SimpleNamespace(
|
|
username=username,
|
|
album_path=path,
|
|
album_title=title,
|
|
artist=artist,
|
|
year=year,
|
|
track_count=len(tracks),
|
|
tracks=tracks,
|
|
dominant_quality='flac',
|
|
quality_score=quality_score,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PHASE 1: analysis
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_analysis_phase_sets_state(monkeypatch):
|
|
"""Analysis phase marks batch counters; phase moves to 'downloading' when there are missing tracks."""
|
|
db = _FakeDB() # found_tracks empty → every track marked missing
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
_seed_batch('B1')
|
|
deps = _build_deps()
|
|
tracks = [{'name': 'T1', 'artists': ['A']}]
|
|
|
|
mw.run_full_missing_tracks_process('B1', 'P1', tracks, deps)
|
|
|
|
# Track was missing → progressed to 'downloading' phase
|
|
assert download_batches['B1']['phase'] == 'downloading'
|
|
assert download_batches['B1']['analysis_processed'] == 1
|
|
assert len(download_batches['B1']['analysis_results']) == 1
|
|
|
|
|
|
def test_force_download_treats_all_as_missing(monkeypatch):
|
|
"""force_download_all skips DB check — every track marked missing."""
|
|
db = _FakeDB(found_tracks={('t1', 'a'): 1.0, ('t2', 'a'): 1.0}) # would otherwise be found
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
_seed_batch('B2', force_download_all=True)
|
|
deps = _build_deps()
|
|
tracks = [
|
|
{'name': 'T1', 'artists': ['A']},
|
|
{'name': 'T2', 'artists': ['A']},
|
|
]
|
|
|
|
mw.run_full_missing_tracks_process('B2', 'playlist1', tracks, deps)
|
|
|
|
# All 2 tracks should produce queue tasks (treated as missing)
|
|
assert len(download_batches['B2']['queue']) == 2
|
|
assert download_batches['B2']['phase'] == 'downloading'
|
|
|
|
|
|
def test_manual_match_overrides_internal_force_download(monkeypatch):
|
|
"""Internal wishlist force mode still honors explicit manual library matches."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
monkeypatch.setattr(
|
|
'core.library.manual_library_match.get_match_for_track',
|
|
lambda *_args, **_kwargs: {'id': 1, 'library_track_id': 42},
|
|
)
|
|
|
|
removed = []
|
|
_seed_batch(
|
|
'B2a',
|
|
force_download_all=True,
|
|
ignore_manual_matches=False,
|
|
profile_id=1,
|
|
batch_source='spotify',
|
|
)
|
|
deps = _build_deps(wishlist_remove=lambda td: removed.append(td.get('name')))
|
|
tracks = [{'id': 'spotify-track-1', 'name': 'T1', 'artists': ['A']}]
|
|
|
|
mw.run_full_missing_tracks_process('B2a', 'wishlist', tracks, deps)
|
|
|
|
assert download_batches['B2a']['queue'] == []
|
|
assert download_batches['B2a']['analysis_results'][0]['found'] is True
|
|
assert download_batches['B2a']['analysis_results'][0]['match_reason'] == 'manual_library_match'
|
|
assert removed == ['T1']
|
|
|
|
|
|
def test_manual_match_saved_under_mirrored_source_overrides_wishlist_batch(monkeypatch):
|
|
"""Wishlist batches honor matches saved from mirrored sync history."""
|
|
db = _FakeDB()
|
|
db.manual_matches.append({
|
|
"id": 1,
|
|
"profile_id": 1,
|
|
"source": "mirrored",
|
|
"source_track_id": "track-abc",
|
|
"server_source": "",
|
|
"library_track_id": 42,
|
|
})
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
removed = []
|
|
_seed_batch(
|
|
'B2m',
|
|
force_download_all=True,
|
|
ignore_manual_matches=False,
|
|
profile_id=1,
|
|
batch_source='wishlist',
|
|
)
|
|
deps = _build_deps(wishlist_remove=lambda td: removed.append(td.get('name')))
|
|
tracks = [{'id': 'track-abc', 'name': 'Coffee Break', 'artists': [{'name': 'Zeds Dead'}], 'provider': 'wishlist'}]
|
|
|
|
mw.run_full_missing_tracks_process('B2m', 'wishlist', tracks, deps)
|
|
|
|
assert download_batches['B2m']['queue'] == []
|
|
assert download_batches['B2m']['analysis_results'][0]['found'] is True
|
|
assert download_batches['B2m']['analysis_results'][0]['match_reason'] == 'manual_library_match'
|
|
assert removed == ['Coffee Break']
|
|
|
|
|
|
def test_explicit_force_download_ignores_manual_match(monkeypatch):
|
|
"""User-facing Force Download All can intentionally bypass manual matches."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
'core.library.manual_library_match.get_match_for_track',
|
|
lambda *_args, **_kwargs: calls.append(True) or {'id': 1, 'library_track_id': 42},
|
|
)
|
|
|
|
_seed_batch(
|
|
'B2b',
|
|
force_download_all=True,
|
|
ignore_manual_matches=True,
|
|
profile_id=1,
|
|
batch_source='spotify',
|
|
)
|
|
deps = _build_deps()
|
|
tracks = [{'id': 'spotify-track-1', 'name': 'T1', 'artists': ['A']}]
|
|
|
|
mw.run_full_missing_tracks_process('B2b', 'playlist1', tracks, deps)
|
|
|
|
assert calls == []
|
|
assert len(download_batches['B2b']['queue']) == 1
|
|
assert download_batches['B2b']['analysis_results'][0]['found'] is False
|
|
|
|
|
|
def test_found_tracks_trigger_wishlist_removal(monkeypatch):
|
|
"""When DB lookup succeeds, master worker invokes wishlist removal callback."""
|
|
db = _FakeDB(found_tracks={('t1', 'a'): 0.9})
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
removed = []
|
|
deps = _build_deps(wishlist_remove=lambda td: removed.append(td.get('name')))
|
|
|
|
_seed_batch('B3')
|
|
tracks = [{'name': 'T1', 'artists': ['A']}]
|
|
|
|
mw.run_full_missing_tracks_process('B3', 'P1', tracks, deps)
|
|
|
|
assert removed == ['T1']
|
|
|
|
|
|
def test_explicit_filter_removes_blocked_tracks(monkeypatch):
|
|
"""When content_filter.allow_explicit=False, blocked tracks dropped from missing set."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
config = _FakeConfig({'content_filter.allow_explicit': False})
|
|
deps = _build_deps(
|
|
config=config,
|
|
is_explicit_blocked=lambda td: td.get('name') == 'BLOCKED',
|
|
)
|
|
|
|
_seed_batch('B4')
|
|
tracks = [
|
|
{'name': 'CLEAN', 'artists': ['A']},
|
|
{'name': 'BLOCKED', 'artists': ['A']},
|
|
]
|
|
|
|
mw.run_full_missing_tracks_process('B4', 'P1', tracks, deps)
|
|
|
|
# only CLEAN survives the filter
|
|
assert len(download_batches['B4']['queue']) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PHASE 2: no missing -> complete + state updates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_no_missing_marks_batch_complete(monkeypatch):
|
|
"""If every track found in DB, batch transitions directly to complete."""
|
|
db = _FakeDB(found_tracks={('t1', 'a'): 0.9, ('t2', 'a'): 0.9})
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
deps = _build_deps()
|
|
_seed_batch('B5')
|
|
tracks = [
|
|
{'name': 'T1', 'artists': ['A']},
|
|
{'name': 'T2', 'artists': ['A']},
|
|
]
|
|
|
|
mw.run_full_missing_tracks_process('B5', 'P1', tracks, deps)
|
|
|
|
assert download_batches['B5']['phase'] == 'complete'
|
|
assert 'completion_time' in download_batches['B5']
|
|
assert db.sync_history_calls # sync history written
|
|
|
|
|
|
def test_no_missing_updates_youtube_playlist_state(monkeypatch):
|
|
"""YouTube playlist phase set to 'download_complete' on no-missing."""
|
|
db = _FakeDB(found_tracks={('t1', 'a'): 0.9})
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
yt_states = {'abc123': {'phase': 'discovered'}}
|
|
deps = _build_deps(yt_states=yt_states)
|
|
|
|
_seed_batch('B6')
|
|
mw.run_full_missing_tracks_process('B6', 'youtube_abc123', [{'name': 'T1', 'artists': ['A']}], deps)
|
|
|
|
assert yt_states['abc123']['phase'] == 'download_complete'
|
|
|
|
|
|
def test_no_missing_with_auto_wishlist_submits_completion(monkeypatch):
|
|
"""auto_initiated wishlist batch with no missing tracks submits auto-completion handler."""
|
|
db = _FakeDB(found_tracks={('t1', 'a'): 0.9})
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
executor = _FakeExecutor()
|
|
auto_called = []
|
|
deps = _build_deps(executor=executor, process_failed_auto=lambda bid: auto_called.append(bid))
|
|
|
|
_seed_batch('B7', auto_initiated=True)
|
|
mw.run_full_missing_tracks_process('B7', 'wishlist', [{'name': 'T1', 'artists': ['A']}], deps)
|
|
|
|
assert len(executor.submitted) == 1
|
|
fn, args = executor.submitted[0]
|
|
assert args == ('B7',)
|
|
|
|
|
|
def test_no_missing_album_does_not_dispatch_torrent_bundle(monkeypatch):
|
|
"""Release-level sources must wait until analysis confirms missing tracks."""
|
|
album = _DBAlbum(id_=42, title='Test Album')
|
|
db = _FakeDB(album=album, album_tracks=[_DBTrack('T1')])
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
plugin = _FakeAlbumBundleSoulseek()
|
|
deps = _build_deps(
|
|
config=_FakeConfig({'download_source.mode': 'torrent'}),
|
|
soulseek=_FakePluginWrapper({'torrent': plugin}),
|
|
)
|
|
_seed_batch(
|
|
'B7a',
|
|
is_album_download=True,
|
|
album_context={'name': 'Test Album', 'total_tracks': 1},
|
|
artist_context={'name': 'Artist'},
|
|
)
|
|
|
|
mw.run_full_missing_tracks_process(
|
|
'B7a',
|
|
'album:1',
|
|
[{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}],
|
|
deps,
|
|
)
|
|
|
|
assert plugin.calls == []
|
|
assert download_batches['B7a']['phase'] == 'complete'
|
|
assert 'album_bundle_source' not in download_batches['B7a']
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Album fast path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_album_fast_path_direct_match(monkeypatch):
|
|
"""Album lookup + direct title match → track marked found, no queue entry."""
|
|
album = _DBAlbum(id_=42, title='Test Album')
|
|
album_tracks = [_DBTrack('T1'), _DBTrack('T2')]
|
|
db = _FakeDB(album=album, album_tracks=album_tracks)
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
deps = _build_deps()
|
|
_seed_batch('B8',
|
|
is_album_download=True,
|
|
album_context={'name': 'Test Album', 'total_tracks': 2},
|
|
artist_context={'name': 'Artist'})
|
|
|
|
tracks = [{'name': 'T1', 'artists': ['Artist']}, {'name': 'T2', 'artists': ['Artist']}]
|
|
mw.run_full_missing_tracks_process('B8', 'album:1', tracks, deps)
|
|
|
|
assert download_batches['B8']['phase'] == 'complete' # all matched
|
|
|
|
|
|
def test_album_fast_path_misses_fall_through_to_global(monkeypatch):
|
|
"""Album lookup with track not in album → fuzzy fallback or per-track global search."""
|
|
album = _DBAlbum(id_=42, title='Test Album')
|
|
album_tracks = [_DBTrack('Existing')]
|
|
db = _FakeDB(
|
|
album=album,
|
|
album_tracks=album_tracks,
|
|
found_tracks={}, # global search finds nothing for Other
|
|
)
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
deps = _build_deps()
|
|
_seed_batch('B9',
|
|
is_album_download=True,
|
|
album_context={'name': 'Test Album', 'total_tracks': 2},
|
|
artist_context={'name': 'Artist'})
|
|
|
|
# 'Other' is not in album, allow_duplicates default True → marked missing without global search
|
|
tracks = [{'name': 'Other', 'artists': ['Artist']}]
|
|
mw.run_full_missing_tracks_process('B9', 'album:1', tracks, deps)
|
|
|
|
assert len(download_batches['B9']['queue']) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MB release preflight
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_mb_release_preflight_caches_mbid(monkeypatch):
|
|
"""MB preflight caches release MBID under both normalized and exact keys."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
fake_release = {'id': 'mbid-xyz', 'title': 'Test Album'}
|
|
|
|
def fake_find_best_release(album, artist, count, svc):
|
|
return fake_release
|
|
|
|
import core.album_consistency as ac
|
|
monkeypatch.setattr(ac, '_find_best_release', fake_find_best_release)
|
|
|
|
cache = {}
|
|
detail_cache = {}
|
|
deps = _build_deps(
|
|
mb_worker=_FakeMBWorker(svc=_FakeMBSvc()),
|
|
mb_release_cache=cache,
|
|
mb_release_detail_cache=detail_cache,
|
|
)
|
|
_seed_batch('B10',
|
|
is_album_download=True,
|
|
album_context={'name': 'Test Album', 'total_tracks': 1},
|
|
artist_context={'name': 'Artist'})
|
|
|
|
mw.run_full_missing_tracks_process('B10', 'album:1', [{'name': 'T1', 'artists': ['Artist']}], deps)
|
|
|
|
# Should have cached under both normalized and exact-lower keys
|
|
assert ('test album', 'artist') in cache
|
|
assert cache[('test album', 'artist')] == 'mbid-xyz'
|
|
assert detail_cache['mbid-xyz'] == fake_release
|
|
|
|
|
|
def test_mb_release_preflight_skipped_when_no_mb_worker(monkeypatch):
|
|
"""Without mb_worker, preflight quietly skips."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
cache = {}
|
|
deps = _build_deps(mb_worker=None, mb_release_cache=cache)
|
|
_seed_batch('B11',
|
|
is_album_download=True,
|
|
album_context={'name': 'Album', 'total_tracks': 1},
|
|
artist_context={'name': 'Artist'})
|
|
|
|
mw.run_full_missing_tracks_process('B11', 'album:1', [{'name': 'T1', 'artists': ['Artist']}], deps)
|
|
|
|
assert cache == {} # nothing cached
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Soulseek album preflight
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_soulseek_album_preflight_scores_release_folder_over_larger_wrong_edition(monkeypatch):
|
|
"""Album preflight chooses the folder whose tracklist matches the target release."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
target_tracks = [
|
|
{'name': f'Track {i}', 'artists': ['Artist'], 'track_number': i}
|
|
for i in range(1, 11)
|
|
]
|
|
correct_tracks = [_slsk_track(f'Track {i}', i, folder='Artist/Test Album (2020)') for i in range(1, 11)]
|
|
wrong_tracks = [
|
|
_slsk_track(f'Remix {i}', i, folder='Artist/Test Album Deluxe Remixes (2020)')
|
|
for i in range(1, 13)
|
|
]
|
|
wrong = _album_result(
|
|
'slow-peer',
|
|
'Artist/Test Album Deluxe Remixes (2020)',
|
|
'Test Album Deluxe Remixes',
|
|
wrong_tracks,
|
|
quality_score=1.0,
|
|
)
|
|
correct = _album_result(
|
|
'good-peer',
|
|
'Artist/Test Album (2020)',
|
|
'Test Album',
|
|
correct_tracks,
|
|
quality_score=0.8,
|
|
)
|
|
slsk = _FakeSoulseek(album_results=[wrong, correct], browse_files=[{'filename': 'x.flac'}],
|
|
parsed_tracks=correct_tracks)
|
|
deps = _build_deps(
|
|
config=_FakeConfig({'download_source.mode': 'soulseek'}),
|
|
soulseek=_FakeSoulseekWrapper(slsk),
|
|
)
|
|
_seed_batch(
|
|
'B22',
|
|
is_album_download=True,
|
|
album_context={'name': 'Test Album', 'total_tracks': 10, 'release_date': '2020-01-01'},
|
|
artist_context={'name': 'Artist'},
|
|
)
|
|
|
|
mw.run_full_missing_tracks_process('B22', 'album:1', target_tracks, deps)
|
|
|
|
assert download_batches['B22']['last_good_source'] == {
|
|
'username': 'good-peer',
|
|
'folder_path': 'Artist/Test Album (2020)',
|
|
}
|
|
assert download_batches['B22']['source_folder_tracks'] == correct_tracks
|
|
|
|
|
|
def test_soulseek_album_preflight_runs_when_soulseek_is_hybrid_primary(monkeypatch):
|
|
"""Album preflight runs for hybrid album downloads when Soulseek is first."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}]
|
|
folder_tracks = [_slsk_track('T1', 1)]
|
|
album = _album_result('peer', 'Artist/Test Album', 'Test Album', folder_tracks)
|
|
slsk = _FakeSoulseek(album_results=[album], browse_files=None, parsed_tracks=folder_tracks)
|
|
config = _FakeConfig({
|
|
'download_source.mode': 'hybrid',
|
|
'download_source.hybrid_order': ['soulseek', 'hifi'],
|
|
})
|
|
deps = _build_deps(config=config, soulseek=_FakeSoulseekWrapper(slsk))
|
|
_seed_batch(
|
|
'B23',
|
|
is_album_download=True,
|
|
album_context={'name': 'Test Album', 'total_tracks': 1},
|
|
artist_context={'name': 'Artist'},
|
|
)
|
|
|
|
mw.run_full_missing_tracks_process('B23', 'album:1', tracks, deps)
|
|
|
|
assert slsk.search_calls
|
|
assert download_batches['B23']['last_good_source']['username'] == 'peer'
|
|
|
|
|
|
def test_soulseek_album_preflight_does_not_jump_ahead_of_hybrid_primary(monkeypatch):
|
|
"""If Soulseek is only a fallback source, album preflight does not preempt source order."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}]
|
|
folder_tracks = [_slsk_track('T1', 1)]
|
|
album = _album_result('peer', 'Artist/Test Album', 'Test Album', folder_tracks)
|
|
slsk = _FakeSoulseek(album_results=[album], browse_files=None, parsed_tracks=folder_tracks)
|
|
config = _FakeConfig({
|
|
'download_source.mode': 'hybrid',
|
|
'download_source.hybrid_order': ['deezer_dl', 'soulseek'],
|
|
})
|
|
deps = _build_deps(config=config, soulseek=_FakeSoulseekWrapper(slsk))
|
|
_seed_batch(
|
|
'B24',
|
|
is_album_download=True,
|
|
album_context={'name': 'Test Album', 'total_tracks': 1},
|
|
artist_context={'name': 'Artist'},
|
|
)
|
|
|
|
mw.run_full_missing_tracks_process('B24', 'album:1', tracks, deps)
|
|
|
|
assert slsk.search_calls == []
|
|
assert 'last_good_source' not in download_batches['B24']
|
|
|
|
|
|
def test_soulseek_album_bundle_runs_after_missing_analysis(monkeypatch):
|
|
"""Soulseek whole-folder bundles should engage only after analysis
|
|
has confirmed there is something missing."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
plugin = _FakeAlbumBundleSoulseek()
|
|
deps = _build_deps(
|
|
config=_FakeConfig({'download_source.mode': 'soulseek'}),
|
|
soulseek=_FakeSoulseekWrapper(plugin),
|
|
)
|
|
_seed_batch(
|
|
'B25',
|
|
is_album_download=True,
|
|
album_context={'name': 'Test Album', 'total_tracks': 1},
|
|
artist_context={'name': 'Artist'},
|
|
)
|
|
tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}]
|
|
|
|
mw.run_full_missing_tracks_process('B25', 'album:1', tracks, deps)
|
|
|
|
assert len(plugin.calls) == 1
|
|
album, artist, staging, kwargs = plugin.calls[0]
|
|
assert (album, artist) == ('Test Album', 'Artist')
|
|
assert staging.replace('\\', '/').endswith('storage/album_bundle_staging/B25')
|
|
assert kwargs == {}
|
|
assert download_batches['B25']['album_bundle_source'] == 'soulseek'
|
|
assert download_batches['B25']['album_bundle_private_staging'] is True
|
|
assert download_batches['B25']['album_bundle_state'] == 'staged'
|
|
assert 'last_good_source' not in download_batches['B25']
|
|
|
|
|
|
def test_hybrid_first_soulseek_uses_album_bundle(monkeypatch):
|
|
"""Hybrid keeps fallback semantics, but the first source can own
|
|
album-bundle downloads when it supports them."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
plugin = _FakeAlbumBundleSoulseek()
|
|
deps = _build_deps(
|
|
config=_FakeConfig({
|
|
'download_source.mode': 'hybrid',
|
|
'download_source.hybrid_order': ['soulseek', 'hifi'],
|
|
}),
|
|
soulseek=_FakeSoulseekWrapper(plugin),
|
|
)
|
|
_seed_batch(
|
|
'B26',
|
|
is_album_download=True,
|
|
album_context={'name': 'Test Album', 'total_tracks': 1},
|
|
artist_context={'name': 'Artist'},
|
|
)
|
|
tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}]
|
|
|
|
mw.run_full_missing_tracks_process('B26', 'album:1', tracks, deps)
|
|
|
|
assert len(plugin.calls) == 1
|
|
assert download_batches['B26']['album_bundle_source'] == 'soulseek'
|
|
assert download_batches['B26']['album_bundle_private_staging'] is True
|
|
|
|
|
|
def test_soulseek_album_bundle_uses_preflight_source_without_preloading_reuse(monkeypatch):
|
|
"""When the bundle path stages files, workers must claim staging
|
|
before any Soulseek source-reuse attempt can fire."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}]
|
|
folder_tracks = [_slsk_track('T1', 1, folder='Artist/Test Album')]
|
|
album = _album_result('peer', 'Artist/Test Album', 'Test Album', folder_tracks)
|
|
slsk = _FakePreflightAlbumBundleSoulseek(
|
|
album_results=[album],
|
|
browse_files=None,
|
|
parsed_tracks=folder_tracks,
|
|
)
|
|
deps = _build_deps(
|
|
config=_FakeConfig({'download_source.mode': 'soulseek'}),
|
|
soulseek=_FakeSoulseekWrapper(slsk),
|
|
)
|
|
_seed_batch(
|
|
'B28',
|
|
is_album_download=True,
|
|
album_context={'name': 'Test Album', 'total_tracks': 1},
|
|
artist_context={'name': 'Artist'},
|
|
)
|
|
|
|
mw.run_full_missing_tracks_process('B28', 'album:1', tracks, deps)
|
|
|
|
assert len(slsk.calls) == 1
|
|
assert slsk.calls[0][3] == {
|
|
'preferred_source': {
|
|
'username': 'peer',
|
|
'folder_path': 'Artist/Test Album',
|
|
},
|
|
'preferred_tracks': folder_tracks,
|
|
}
|
|
assert download_batches['B28']['album_bundle_private_staging'] is True
|
|
assert 'last_good_source' not in download_batches['B28']
|
|
assert 'source_folder_tracks' not in download_batches['B28']
|
|
|
|
|
|
def test_hybrid_first_torrent_uses_album_bundle_before_per_track(monkeypatch):
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
plugin = _FakeAlbumBundleSoulseek()
|
|
deps = _build_deps(
|
|
config=_FakeConfig({
|
|
'download_source.mode': 'hybrid',
|
|
'download_source.hybrid_order': ['torrent', 'soulseek'],
|
|
}),
|
|
soulseek=_FakePluginWrapper({'torrent': plugin}),
|
|
)
|
|
_seed_batch(
|
|
'B27',
|
|
is_album_download=True,
|
|
album_context={'name': 'Test Album', 'total_tracks': 1},
|
|
artist_context={'name': 'Artist'},
|
|
)
|
|
tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}]
|
|
|
|
mw.run_full_missing_tracks_process('B27', 'album:1', tracks, deps)
|
|
|
|
assert len(plugin.calls) == 1
|
|
assert download_batches['B27']['album_bundle_source'] == 'torrent'
|
|
|
|
|
|
def test_album_bundle_fallback_clears_private_staging(monkeypatch):
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
plugin = _FakeAlbumBundleSoulseek({
|
|
'success': False,
|
|
'fallback': True,
|
|
'error': 'No release passed validation',
|
|
})
|
|
deps = _build_deps(
|
|
config=_FakeConfig({'download_source.mode': 'torrent'}),
|
|
soulseek=_FakePluginWrapper({'torrent': plugin}),
|
|
)
|
|
_seed_batch(
|
|
'B29',
|
|
is_album_download=True,
|
|
album_context={'name': 'Test Album', 'total_tracks': 1},
|
|
artist_context={'name': 'Artist'},
|
|
)
|
|
tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}]
|
|
|
|
mw.run_full_missing_tracks_process('B29', 'album:1', tracks, deps)
|
|
|
|
assert len(plugin.calls) == 1
|
|
assert download_batches['B29']['album_bundle_state'] == 'fallback'
|
|
assert download_batches['B29']['album_bundle_private_staging'] is False
|
|
assert download_batches['B29']['album_bundle_staging_path'] is None
|
|
assert len(download_batches['B29']['queue']) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Task creation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_missing_tracks_create_queue_tasks(monkeypatch):
|
|
"""Missing tracks produce download_tasks + are appended to batch queue."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
deps = _build_deps()
|
|
_seed_batch('B12')
|
|
|
|
tracks = [{'name': 'T1', 'artists': ['A']}, {'name': 'T2', 'artists': ['A']}]
|
|
mw.run_full_missing_tracks_process('B12', 'P1', tracks, deps)
|
|
|
|
assert len(download_batches['B12']['queue']) == 2
|
|
for task_id in download_batches['B12']['queue']:
|
|
assert task_id in download_tasks
|
|
assert download_tasks[task_id]['status'] == 'pending'
|
|
assert download_tasks[task_id]['batch_id'] == 'B12'
|
|
|
|
|
|
def test_album_download_injects_explicit_context(monkeypatch):
|
|
"""Album downloads embed _explicit_album_context + _explicit_artist_context per task."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
deps = _build_deps()
|
|
album_ctx = {'name': 'Album', 'total_tracks': 1}
|
|
artist_ctx = {'name': 'Artist'}
|
|
_seed_batch('B13',
|
|
is_album_download=True,
|
|
album_context=album_ctx,
|
|
artist_context=artist_ctx)
|
|
|
|
mw.run_full_missing_tracks_process('B13', 'album:1', [{'name': 'T1', 'artists': ['Artist']}], deps)
|
|
|
|
assert len(download_batches['B13']['queue']) == 1
|
|
task_id = download_batches['B13']['queue'][0]
|
|
info = download_tasks[task_id]['track_info']
|
|
assert info['_explicit_album_context'] == album_ctx
|
|
assert info['_explicit_artist_context'] == artist_ctx
|
|
assert info['_is_explicit_album_download'] is True
|
|
|
|
|
|
def test_wishlist_album_grouping_resolves_artist(monkeypatch):
|
|
"""Wishlist tracks sharing an album_id all get the same artist context."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
deps = _build_deps()
|
|
_seed_batch('B14')
|
|
|
|
# Two tracks on same album with different track-level artists — wishlist grouping
|
|
# should resolve ONE artist for the album (first track wins).
|
|
tracks = [
|
|
{
|
|
'name': 'T1', 'artists': [{'name': 'Track Artist 1'}],
|
|
'spotify_data': {
|
|
'album': {'id': 'A1', 'name': 'Test Album', 'artists': [{'name': 'Album Artist'}]},
|
|
'artists': [{'name': 'Track Artist 1'}],
|
|
},
|
|
},
|
|
{
|
|
'name': 'T2', 'artists': [{'name': 'Track Artist 2'}],
|
|
'spotify_data': {
|
|
'album': {'id': 'A1', 'name': 'Test Album', 'artists': [{'name': 'Album Artist'}]},
|
|
'artists': [{'name': 'Track Artist 2'}],
|
|
},
|
|
},
|
|
]
|
|
mw.run_full_missing_tracks_process('B14', 'wishlist', tracks, deps)
|
|
|
|
assert len(download_batches['B14']['queue']) == 2
|
|
artist_names = set()
|
|
for tid in download_batches['B14']['queue']:
|
|
info = download_tasks[tid]['track_info']
|
|
artist_names.add(info['_explicit_artist_context']['name'])
|
|
|
|
# Both tracks should resolve to the same album-level artist
|
|
assert len(artist_names) == 1
|
|
assert 'Album Artist' in artist_names
|
|
|
|
|
|
def test_wishlist_album_grouping_uses_shared_rich_album_context(monkeypatch):
|
|
"""Tracks in one wishlist album reuse the richest album context to avoid year folder splits."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
deps = _build_deps()
|
|
_seed_batch('B14b')
|
|
|
|
tracks = [
|
|
{
|
|
'name': 'T1', 'artists': [{'name': 'Artist'}],
|
|
'spotify_data': {
|
|
'album': {
|
|
'id': 'A1', 'name': 'Test Album', 'artists': [{'name': 'Album Artist'}],
|
|
'release_date': '2024-05-05', 'total_tracks': 2,
|
|
'album_type': 'album', 'images': [{'url': 'http://img'}],
|
|
},
|
|
'artists': [{'name': 'Artist'}],
|
|
},
|
|
},
|
|
{
|
|
'name': 'T2', 'artists': [{'name': 'Artist'}],
|
|
'spotify_data': {
|
|
'album': {'id': 'A1', 'name': 'Test Album'},
|
|
'artists': [{'name': 'Artist'}],
|
|
},
|
|
},
|
|
]
|
|
|
|
mw.run_full_missing_tracks_process('B14b', 'wishlist', tracks, deps)
|
|
|
|
release_dates = set()
|
|
images = set()
|
|
for tid in download_batches['B14b']['queue']:
|
|
album_ctx = download_tasks[tid]['track_info']['_explicit_album_context']
|
|
release_dates.add(album_ctx['release_date'])
|
|
images.add(album_ctx['images'][0]['url'])
|
|
|
|
assert release_dates == {'2024-05-05'}
|
|
assert images == {'http://img'}
|
|
|
|
|
|
def test_organize_by_playlist_keeps_provenance_not_routing(monkeypatch):
|
|
"""Organize-by-playlist no longer routes the file per-track. Each track imports
|
|
NORMALLY into Artist/Album (what a normal download does); the playlist folder
|
|
is built as links AFTER the batch. So the old routing flag
|
|
`_playlist_folder_mode` is NOT set, but `_playlist_name` provenance IS kept
|
|
(core/downloads/origin.py)."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
deps = _build_deps()
|
|
_seed_batch('B15',
|
|
playlist_folder_mode=True,
|
|
playlist_name='My Mix')
|
|
|
|
mw.run_full_missing_tracks_process('B15', 'P1', [{'name': 'T1', 'artists': ['A']}], deps)
|
|
|
|
task_id = download_batches['B15']['queue'][0]
|
|
info = download_tasks[task_id]['track_info']
|
|
assert '_playlist_folder_mode' not in info # routing retired → normal import
|
|
assert info['_playlist_name'] == 'My Mix' # provenance preserved
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hand-off to monitor + start_next_batch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_handoff_starts_monitor_and_next_batch(monkeypatch):
|
|
"""After task creation, master worker starts monitor + next batch."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
monitor = _FakeMonitor()
|
|
started_next = []
|
|
deps = _build_deps(monitor=monitor, start_next_batch=lambda bid: started_next.append(bid))
|
|
|
|
_seed_batch('B16')
|
|
mw.run_full_missing_tracks_process('B16', 'P1', [{'name': 'T1', 'artists': ['A']}], deps)
|
|
|
|
assert monitor.started == ['B16']
|
|
assert started_next == ['B16']
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Multi-disc album_context
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_multi_disc_total_discs_computed(monkeypatch):
|
|
"""For album downloads, total_discs computed from max(disc_number) across all tracks."""
|
|
db = _FakeDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
deps = _build_deps()
|
|
album_ctx = {'name': 'Album', 'total_tracks': 3}
|
|
_seed_batch('B17',
|
|
is_album_download=True,
|
|
album_context=album_ctx,
|
|
artist_context={'name': 'Artist'})
|
|
|
|
tracks = [
|
|
{'name': 'T1', 'artists': ['Artist'], 'disc_number': 1},
|
|
{'name': 'T2', 'artists': ['Artist'], 'disc_number': 2},
|
|
{'name': 'T3', 'artists': ['Artist'], 'disc_number': 2},
|
|
]
|
|
mw.run_full_missing_tracks_process('B17', 'album:1', tracks, deps)
|
|
|
|
assert album_ctx['total_discs'] == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error handler
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_error_handler_marks_batch_error(monkeypatch):
|
|
"""Exception during analysis → batch.phase=error, batch.error=str(exception)."""
|
|
def boom():
|
|
raise RuntimeError("DB exploded")
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', boom)
|
|
|
|
deps = _build_deps()
|
|
_seed_batch('B18')
|
|
|
|
mw.run_full_missing_tracks_process('B18', 'P1', [{'name': 'T1', 'artists': ['A']}], deps)
|
|
|
|
assert download_batches['B18']['phase'] == 'error'
|
|
assert 'DB exploded' in download_batches['B18']['error']
|
|
|
|
|
|
def test_error_handler_resets_youtube_phase(monkeypatch):
|
|
"""Error on a youtube_<hash> playlist resets that playlist's phase to 'discovered'."""
|
|
def boom():
|
|
raise RuntimeError("kaboom")
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', boom)
|
|
|
|
yt_states = {'abc': {'phase': 'downloading'}}
|
|
deps = _build_deps(yt_states=yt_states)
|
|
_seed_batch('B19')
|
|
|
|
mw.run_full_missing_tracks_process('B19', 'youtube_abc', [{'name': 'T1', 'artists': ['A']}], deps)
|
|
|
|
assert yt_states['abc']['phase'] == 'discovered'
|
|
|
|
|
|
def test_error_handler_resets_auto_wishlist(monkeypatch):
|
|
"""Auto-initiated wishlist error invokes reset_wishlist_auto_processing callback."""
|
|
def boom():
|
|
raise RuntimeError("oops")
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', boom)
|
|
|
|
reset_called = []
|
|
deps = _build_deps(reset_wishlist_auto=lambda: reset_called.append(True))
|
|
_seed_batch('B20', auto_initiated=True)
|
|
|
|
mw.run_full_missing_tracks_process('B20', 'wishlist', [{'name': 'T1', 'artists': ['A']}], deps)
|
|
|
|
assert reset_called == [True]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Batch removed mid-flight
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_batch_removed_before_phase_two_returns_cleanly(monkeypatch):
|
|
"""If batch is deleted between analysis and download phase, function returns without crashing."""
|
|
db = _FakeDB(found_tracks={('t1', 'a'): 0.9}) # marks T1 found → wishlist_remove fires
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
|
|
|
monitor = _FakeMonitor()
|
|
next_batch_calls = []
|
|
|
|
# Wishlist removal callback deletes the batch mid-analysis to simulate cancel.
|
|
# T1 will be analyzed as 'found' → callback fires → batch deleted.
|
|
def kill_batch(td):
|
|
download_batches.pop('B21', None)
|
|
|
|
deps = _build_deps(
|
|
wishlist_remove=kill_batch,
|
|
monitor=monitor,
|
|
start_next_batch=lambda bid: next_batch_calls.append(bid),
|
|
)
|
|
_seed_batch('B21')
|
|
|
|
# Should not raise even though batch vanishes during analysis loop
|
|
mw.run_full_missing_tracks_process('B21', 'P1', [{'name': 'T1', 'artists': ['A']}], deps)
|
|
|
|
# All tracks were 'found' → no missing → no monitor/next_batch calls
|
|
# (batch was deleted, so phase=complete update silently no-ops)
|
|
assert monitor.started == []
|
|
assert next_batch_calls == []
|