soulsync/tests/downloads/test_downloads_cleanup.py
Broque Thomas 039f152f31 PR4c: lift _automatic_wishlist_cleanup_after_db_update to core/downloads/cleanup.py
Third sub-PR in the download orchestrator series. Strict 1:1 lift —
zero behavior change.

What moved:
- _automatic_wishlist_cleanup_after_db_update → cleanup_wishlist_after_db_update

The lifted fn takes config_manager as an arg (so core/downloads/cleanup.py
doesn't need to import web_server). Other deps (wishlist_service,
MusicDatabase, get_database) stay as in-function imports — matches the
original deferred-import pattern.

The single caller in web_server.py (missing_download_executor.submit at
L18028) keeps using the same wrapper name with no signature change.

Behavior parity:
- Same per-profile iteration via get_all_profiles()
- Same essential-field skip (no name / no artists / no spotify_track_id)
- Same artist normalization (string / dict / fallback to str())
- Same 0.7 confidence threshold for db match
- Same break-on-first-artist-match semantics
- Same album extraction (dict.name vs string passthrough)
- Same active_server pulled via config_manager.get_active_media_server()
- Same per-track exception swallowing inside the loops
- Same top-level exception swallow with traceback.print_exc()
- Same logger messages (exact text match for "[Auto Cleanup]" prefix)

Tests: 13 new under tests/downloads/test_downloads_cleanup.py covering
empty wishlist short-circuit, found-in-db removal, missed track stays,
low-confidence skip, missing-fields skip, dict + string artist formats,
break-on-first-match, multi-profile walk, album dict/string handling,
db check failure continuing to next artist, top-level exception swallow,
active server propagation.

Full suite: 934 passing (was 921). Ruff clean.
2026-04-27 22:23:41 -07:00

266 lines
9.6 KiB
Python

"""Tests for core/downloads/cleanup.py — automatic wishlist cleanup after DB updates."""
from __future__ import annotations
import pytest
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _FakeWishlistService:
def __init__(self, tracks_per_profile=None, mark_results=None):
# tracks_per_profile: {profile_id: [track_dict, ...]}
self._tracks = tracks_per_profile or {}
# mark_results: {spotify_track_id: True/False} — whether mark_track_download_result returns True
self._mark = mark_results or {}
self.mark_calls = []
def get_wishlist_tracks_for_download(self, profile_id):
return list(self._tracks.get(profile_id, []))
def mark_track_download_result(self, spotify_track_id, success):
self.mark_calls.append((spotify_track_id, success))
return self._mark.get(spotify_track_id, True)
class _FakeMusicDB:
def __init__(self, hits=None):
# hits: {(track_name, artist_name): (db_track_obj, confidence)}
self._hits = hits or {}
self.check_calls = []
def check_track_exists(self, track_name, artist_name, confidence_threshold=0.7,
server_source=None, album=None):
self.check_calls.append((track_name, artist_name, server_source, album))
return self._hits.get((track_name, artist_name), (None, 0.0))
class _FakeProfileDB:
def __init__(self, profiles):
self._profiles = profiles
def get_all_profiles(self):
return list(self._profiles)
class _FakeConfig:
def __init__(self, server='plex'):
self._server = server
def get_active_media_server(self):
return self._server
# ---------------------------------------------------------------------------
# monkeypatch helper — wires fakes into core.downloads.cleanup imports
# ---------------------------------------------------------------------------
@pytest.fixture
def install(monkeypatch):
def _install(profiles, tracks_per_profile, hits, mark_results=None):
ws = _FakeWishlistService(tracks_per_profile, mark_results)
mdb = _FakeMusicDB(hits)
pdb = _FakeProfileDB(profiles)
# Patch the in-function imports
import core.wishlist_service as wls_mod
import database.music_database as mdb_mod
monkeypatch.setattr(wls_mod, 'get_wishlist_service', lambda: ws)
monkeypatch.setattr(mdb_mod, 'MusicDatabase', lambda: mdb)
monkeypatch.setattr(mdb_mod, 'get_database', lambda: pdb)
return ws, mdb, pdb
return _install
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_no_wishlist_tracks_returns_early_with_no_marks(install):
from core.downloads.cleanup import cleanup_wishlist_after_db_update
ws, _, _ = install(profiles=[{'id': 1}], tracks_per_profile={1: []}, hits={})
cleanup_wishlist_after_db_update(_FakeConfig())
assert ws.mark_calls == []
def test_track_found_in_db_gets_removed(install):
from core.downloads.cleanup import cleanup_wishlist_after_db_update
track = {
'spotify_track_id': 'sp-1',
'name': 'Money',
'artists': ['Pink Floyd'],
'album': {'name': 'DSOTM'},
}
ws, mdb, _ = install(
profiles=[{'id': 1}],
tracks_per_profile={1: [track]},
hits={('Money', 'Pink Floyd'): (object(), 0.95)},
)
cleanup_wishlist_after_db_update(_FakeConfig())
assert ws.mark_calls == [('sp-1', True)]
def test_track_not_in_db_stays_in_wishlist(install):
from core.downloads.cleanup import cleanup_wishlist_after_db_update
track = {
'spotify_track_id': 'sp-1',
'name': 'Phantom',
'artists': ['Nobody'],
'album': 'NoAlbum',
}
ws, _, _ = install(
profiles=[{'id': 1}],
tracks_per_profile={1: [track]},
hits={},
)
cleanup_wishlist_after_db_update(_FakeConfig())
assert ws.mark_calls == []
def test_low_confidence_match_does_not_remove(install):
from core.downloads.cleanup import cleanup_wishlist_after_db_update
track = {'spotify_track_id': 'sp-1', 'name': 'Money', 'artists': ['Pink Floyd']}
# Confidence below 0.7 threshold
ws, _, _ = install(
profiles=[{'id': 1}],
tracks_per_profile={1: [track]},
hits={('Money', 'Pink Floyd'): (object(), 0.5)},
)
cleanup_wishlist_after_db_update(_FakeConfig())
assert ws.mark_calls == []
def test_skip_when_missing_essential_fields(install):
from core.downloads.cleanup import cleanup_wishlist_after_db_update
tracks = [
{'spotify_track_id': 'sp-1', 'name': '', 'artists': ['A']}, # no name
{'spotify_track_id': 'sp-2', 'name': 'X', 'artists': []}, # no artists
{'name': 'Y', 'artists': ['B']}, # no track_id
]
ws, mdb, _ = install(profiles=[{'id': 1}], tracks_per_profile={1: tracks}, hits={})
cleanup_wishlist_after_db_update(_FakeConfig())
assert mdb.check_calls == []
assert ws.mark_calls == []
def test_artist_dict_format_normalized(install):
from core.downloads.cleanup import cleanup_wishlist_after_db_update
track = {
'spotify_track_id': 'sp-1',
'name': 'X',
'artists': [{'name': 'Aretha'}], # dict format
}
ws, mdb, _ = install(
profiles=[{'id': 1}],
tracks_per_profile={1: [track]},
hits={('X', 'Aretha'): (object(), 0.9)},
)
cleanup_wishlist_after_db_update(_FakeConfig())
assert ws.mark_calls == [('sp-1', True)]
def test_breaks_on_first_artist_match(install):
"""Multi-artist track stops checking after first hit."""
from core.downloads.cleanup import cleanup_wishlist_after_db_update
track = {
'spotify_track_id': 'sp-1',
'name': 'X',
'artists': ['First', 'Second'],
}
ws, mdb, _ = install(
profiles=[{'id': 1}],
tracks_per_profile={1: [track]},
hits={('X', 'First'): (object(), 0.9)},
)
cleanup_wishlist_after_db_update(_FakeConfig())
assert ws.mark_calls == [('sp-1', True)]
# Only first artist checked
assert mdb.check_calls == [('X', 'First', 'plex', None)]
def test_walks_all_profiles(install):
from core.downloads.cleanup import cleanup_wishlist_after_db_update
track_p1 = {'spotify_track_id': 'sp-p1', 'name': 'A', 'artists': ['X']}
track_p2 = {'spotify_track_id': 'sp-p2', 'name': 'B', 'artists': ['Y']}
ws, mdb, _ = install(
profiles=[{'id': 1}, {'id': 2}],
tracks_per_profile={1: [track_p1], 2: [track_p2]},
hits={('A', 'X'): (object(), 0.9), ('B', 'Y'): (object(), 0.9)},
)
cleanup_wishlist_after_db_update(_FakeConfig())
marked = {c[0] for c in ws.mark_calls}
assert marked == {'sp-p1', 'sp-p2'}
def test_album_string_form_passed_through(install):
from core.downloads.cleanup import cleanup_wishlist_after_db_update
track = {
'spotify_track_id': 'sp-1', 'name': 'X', 'artists': ['A'],
'album': 'StringAlbumName', # not a dict
}
ws, mdb, _ = install(profiles=[{'id': 1}], tracks_per_profile={1: [track]}, hits={})
cleanup_wishlist_after_db_update(_FakeConfig())
# check_track_exists should receive the string album
assert mdb.check_calls[0] == ('X', 'A', 'plex', 'StringAlbumName')
def test_album_dict_form_uses_name(install):
from core.downloads.cleanup import cleanup_wishlist_after_db_update
track = {
'spotify_track_id': 'sp-1', 'name': 'X', 'artists': ['A'],
'album': {'name': 'DSOTM'},
}
_, mdb, _ = install(profiles=[{'id': 1}], tracks_per_profile={1: [track]}, hits={})
cleanup_wishlist_after_db_update(_FakeConfig())
assert mdb.check_calls[0] == ('X', 'A', 'plex', 'DSOTM')
def test_db_check_failure_continues_to_next_artist(install, monkeypatch):
from core.downloads.cleanup import cleanup_wishlist_after_db_update
track = {'spotify_track_id': 'sp-1', 'name': 'X', 'artists': ['Bad', 'Good']}
class _ExplodingDB:
def __init__(self):
self.calls = 0
def check_track_exists(self, track_name, artist_name, **kw):
self.calls += 1
if artist_name == 'Bad':
raise RuntimeError("db boom")
return (object(), 0.9)
edb = _ExplodingDB()
import database.music_database as mdb_mod
import core.wishlist_service as wls_mod
ws = _FakeWishlistService({1: [track]})
monkeypatch.setattr(wls_mod, 'get_wishlist_service', lambda: ws)
monkeypatch.setattr(mdb_mod, 'MusicDatabase', lambda: edb)
monkeypatch.setattr(mdb_mod, 'get_database', lambda: _FakeProfileDB([{'id': 1}]))
cleanup_wishlist_after_db_update(_FakeConfig())
# Both artists tried; second match removed track
assert edb.calls == 2
assert ws.mark_calls == [('sp-1', True)]
def test_top_level_exception_swallowed(monkeypatch):
from core.downloads.cleanup import cleanup_wishlist_after_db_update
import core.wishlist_service as wls_mod
def boom():
raise RuntimeError("service init dead")
monkeypatch.setattr(wls_mod, 'get_wishlist_service', boom)
# Must not raise
cleanup_wishlist_after_db_update(_FakeConfig())
def test_uses_active_server_from_config(install):
from core.downloads.cleanup import cleanup_wishlist_after_db_update
track = {'spotify_track_id': 'sp-1', 'name': 'X', 'artists': ['A']}
_, mdb, _ = install(profiles=[{'id': 1}], tracks_per_profile={1: [track]}, hits={})
cleanup_wishlist_after_db_update(_FakeConfig(server='jellyfin'))
assert mdb.check_calls[0][2] == 'jellyfin'