from contextlib import contextmanager from unittest.mock import patch from core.wishlist import processing # --------------------------------------------------------------------------- # _resolve_album_bundle_threshold — config-driven wishlist bundle threshold # --------------------------------------------------------------------------- def test_resolve_album_bundle_threshold_uses_default_when_config_missing(): """Default 2 matches the bumped ``min_tracks_per_album`` floor in ``group_wishlist_tracks_by_album`` — single-track wishlist items take the per-track path, multi-track items take the bundle path.""" with patch('config.settings.config_manager') as cm: cm.get.return_value = processing._DEFAULT_ALBUM_BUNDLE_MIN_TRACKS assert processing._resolve_album_bundle_threshold() == 2 def test_resolve_album_bundle_threshold_honors_config_override(): """Power users who want bundle for every wishlist track (old behaviour) can set the key to 1. Users who want bundle only on bigger gaps can set higher.""" with patch('config.settings.config_manager') as cm: cm.get.return_value = 1 assert processing._resolve_album_bundle_threshold() == 1 cm.get.return_value = 5 assert processing._resolve_album_bundle_threshold() == 5 def test_resolve_album_bundle_threshold_falls_back_on_garbage(): """Non-numeric / non-positive / config-manager-raise all fall back to the safe default. Same defensive shape as get_poll_interval / get_transient_miss_threshold.""" with patch('config.settings.config_manager') as cm: cm.get.return_value = 'oops' assert processing._resolve_album_bundle_threshold() == 2 cm.get.return_value = 0 assert processing._resolve_album_bundle_threshold() == 2 cm.get.return_value = -3 assert processing._resolve_album_bundle_threshold() == 2 cm.get.side_effect = RuntimeError('boom') assert processing._resolve_album_bundle_threshold() == 2 class _FakeLogger: def __init__(self): self.errors = [] self.infos = [] self.warnings = [] def error(self, msg): self.errors.append(msg) def info(self, msg): self.infos.append(msg) def warning(self, msg): self.warnings.append(msg) class _FakeAutomationEngine: def __init__(self): self.events = [] def emit(self, name, payload): self.events.append((name, payload)) class _FakeCursor: def __init__(self): self.calls = [] def execute(self, sql, params=None): self.calls.append((sql, params)) class _FakeConnection: def __init__(self): self.cursor_obj = _FakeCursor() self.committed = False def cursor(self): return self.cursor_obj def commit(self): self.committed = True class _FakeDB: def __init__(self): self.connection = _FakeConnection() @contextmanager def _get_connection(self): yield self.connection class _FakeLock: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def test_remove_completed_tracks_from_wishlist_calls_remover(): batch = {"queue": ["a", "b"]} download_tasks = { "a": {"status": "completed", "track_info": {"name": "Song A"}}, "b": {"status": "failed", "track_info": {"name": "Song B"}}, } calls = [] removed = processing.remove_completed_tracks_from_wishlist( batch, download_tasks, lambda context: calls.append(context), logger=_FakeLogger(), ) assert removed == 1 assert calls == [{"track_info": {"name": "Song A"}, "original_search_result": {"name": "Song A"}}] def test_add_cancelled_tracks_to_failed_tracks_builds_entries(): batch = {"queue": ["a"], "cancelled_tracks": {1}} download_tasks = { "a": { "status": "cancelled", "track_index": 1, "track_info": {"name": "Song A", "artist": "Artist A", "artists": [{"name": "Artist A"}]}, "cached_candidates": [{"title": "candidate"}], } } failed = [] processed = processing.add_cancelled_tracks_to_failed_tracks( batch, download_tasks, failed, logger=_FakeLogger(), ) assert processed == 1 assert failed[0]["track_name"] == "Song A" assert failed[0]["artist_name"] == "Artist A" assert failed[0]["failure_reason"] == "Download cancelled" def test_resolve_wishlist_source_type_for_batch_returns_album_for_album_batch(): assert processing.resolve_wishlist_source_type_for_batch({"is_album_download": True}) == "album" def test_resolve_wishlist_source_type_for_batch_returns_playlist_otherwise(): assert processing.resolve_wishlist_source_type_for_batch({}) == "playlist" assert processing.resolve_wishlist_source_type_for_batch({"is_album_download": False}) == "playlist" def test_build_wishlist_source_context_minimal_batch_skips_album_provenance(): """Non-album batches must not carry album_context (would mislead the requeue logic into routing single-track sync failures through album_path).""" batch = { "playlist_name": "My Mix", "playlist_id": "pl-99", } context = processing.build_wishlist_source_context(batch) assert context["playlist_name"] == "My Mix" assert context["playlist_id"] == "pl-99" assert context["added_from"] == "webui_modal" assert "is_album_download" not in context assert "album_context" not in context assert "artist_context" not in context def test_build_wishlist_source_context_uses_source_playlist_ref_for_organize_batches(): batch = { "playlist_name": "Summer Mix", "playlist_id": "42", "source_playlist_ref": "spotifyPlaylistId123", "mirrored_playlist_id": 42, "organize_by_playlist": True, } context = processing.build_wishlist_source_context(batch) assert context["playlist_id"] == "spotifyPlaylistId123" assert context["mirrored_playlist_id"] == 42 assert context["organize_by_playlist"] is True def test_build_wishlist_source_context_preserves_album_context_for_album_batches(): """Album batches must carry album_context/artist_context through to the wishlist row so a later requeue has authoritative routing data instead of having to reconstruct it from per-track album dicts.""" batch = { "playlist_name": "Album: GNX", "playlist_id": "library_redownload_abc", "is_album_download": True, "album_context": {"id": "alb-1", "name": "GNX", "total_tracks": 12}, "artist_context": {"id": "art-1", "name": "Kendrick Lamar"}, } context = processing.build_wishlist_source_context(batch) assert context["is_album_download"] is True assert context["album_context"] == {"id": "alb-1", "name": "GNX", "total_tracks": 12} assert context["artist_context"] == {"id": "art-1", "name": "Kendrick Lamar"} def test_recover_uncaptured_failed_tracks_builds_entries(): batch = {"queue": ["a"]} download_tasks = { "a": { "status": "failed", "track_index": 2, "track_info": {"name": "Song B", "artist": "Artist B", "artists": [{"name": "Artist B"}]}, "retry_count": 3, "error_message": "boom", "cached_candidates": [], } } failed = [] recovered = processing.recover_uncaptured_failed_tracks( batch, download_tasks, failed, logger=_FakeLogger(), ) assert recovered == 1 assert failed[0]["track_name"] == "Song B" assert failed[0]["retry_count"] == 3 assert failed[0]["failure_reason"] == "boom" def test_finalize_auto_wishlist_completion_defers_toggle_when_siblings_active(): """When the completing batch shares a ``wishlist_run_id`` with siblings still in pre-terminal phases, finalize must NOT toggle the cycle yet — that only happens when the LAST sibling done. Pinned to prevent the regression where every sub-batch's completion fired its own cycle toggle (Phase 1c.2.1 split path).""" db = _FakeDB() automation_engine = _FakeAutomationEngine() resets = [] activities = [] summary = {"tracks_added": 1, "total_failed": 1, "errors": 0} # Two sub-batches share the same run id. The first finishes, # the second is still 'analysis'. download_batches = { "batch-A": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "complete"}, "batch-B": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "analysis"}, } processing.finalize_auto_wishlist_completion( "batch-A", summary, download_batches=download_batches, tasks_lock=_FakeLock(), reset_processing_state=lambda: resets.append(True), add_activity_item=lambda *args: activities.append(args), automation_engine=automation_engine, db_factory=lambda: db, logger=_FakeLogger(), ) # Activity log still fires (it's a per-batch record), but cycle # toggle + state reset + automation emit are deferred. assert activities == [("", "Wishlist Updated", "1 failed tracks added to wishlist", "Now")] assert resets == [] # NOT reset yet — siblings still active assert automation_engine.events == [] # NOT emitted yet assert db.connection.cursor_obj.calls == [] # DB cycle-toggle NOT written def test_finalize_auto_wishlist_completion_toggles_when_last_sibling_done(): """When all siblings of the same run are in terminal phases (or don't exist), the completing batch IS the last → cycle toggles + state resets + automation event fires.""" db = _FakeDB() automation_engine = _FakeAutomationEngine() resets = [] summary = {"tracks_added": 1, "total_failed": 1, "errors": 0} download_batches = { # Both siblings already terminal — current batch is the last. "batch-A": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "complete"}, "batch-B": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "complete"}, "batch-C": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "analysis"}, # the completing one } processing.finalize_auto_wishlist_completion( "batch-C", summary, download_batches=download_batches, tasks_lock=_FakeLock(), reset_processing_state=lambda: resets.append(True), add_activity_item=lambda *_a: None, automation_engine=automation_engine, db_factory=lambda: db, logger=_FakeLogger(), ) assert resets == [True] assert db.connection.committed is True assert db.connection.cursor_obj.calls[0][1] == ("singles",) assert automation_engine.events # event emitted def test_finalize_auto_wishlist_completion_legacy_no_run_id_toggles_immediately(): """Back-compat: a batch with NO ``wishlist_run_id`` (legacy single-batch run from before Phase 1c.2.1) should keep firing the toggle on its own completion regardless of any unrelated batches in the dict.""" db = _FakeDB() automation_engine = _FakeAutomationEngine() resets = [] summary = {"tracks_added": 0, "total_failed": 0, "errors": 0} download_batches = { "batch-legacy": {"current_cycle": "albums"}, # no wishlist_run_id # Even with another unrelated batch active, legacy should toggle. "unrelated": {"current_cycle": "singles", "phase": "analysis"}, } processing.finalize_auto_wishlist_completion( "batch-legacy", summary, download_batches=download_batches, tasks_lock=_FakeLock(), reset_processing_state=lambda: resets.append(True), add_activity_item=lambda *_a: None, automation_engine=automation_engine, db_factory=lambda: db, logger=_FakeLogger(), ) assert resets == [True] assert db.connection.cursor_obj.calls[0][1] == ("singles",) def test_finalize_auto_wishlist_completion_toggles_cycle_and_resets_state(): db = _FakeDB() automation_engine = _FakeAutomationEngine() resets = [] activities = [] summary = {"tracks_added": 2, "total_failed": 5, "errors": 0} result = processing.finalize_auto_wishlist_completion( "batch-1", summary, download_batches={"batch-1": {"current_cycle": "albums"}}, tasks_lock=_FakeLock(), reset_processing_state=lambda: resets.append(True), add_activity_item=lambda *args: activities.append(args), automation_engine=automation_engine, db_factory=lambda: db, logger=_FakeLogger(), ) assert result is summary assert resets == [True] assert activities == [("", "Wishlist Updated", "2 failed tracks added to wishlist", "Now")] assert automation_engine.events == [ ( "wishlist_processing_completed", { "tracks_processed": "5", "tracks_found": "2", "tracks_failed": "3", }, ) ] assert db.connection.committed is True assert db.connection.cursor_obj.calls[0][1] == ("singles",) def test_automatic_wishlist_cleanup_after_db_update_removes_library_matches(): class _CleanupWishlistService: def __init__(self, tracks): self.tracks = tracks self.removed = [] def get_wishlist_tracks_for_download(self, profile_id=1): return list(self.tracks) def mark_track_download_result(self, spotify_track_id, success, error_message=None, profile_id=1): self.removed.append((spotify_track_id, success, error_message, profile_id)) return True class _CleanupProfilesDatabase: def get_all_profiles(self): return [{"id": 1}] class _CleanupMusicDatabase: def check_track_exists(self, track_name, artist_name, confidence_threshold=0.7, server_source=None, album=None): if track_name == "Song A" and artist_name == "Artist A": return {"id": "db-track"}, 0.9 return None, 0.0 wishlist_service = _CleanupWishlistService( [ { "name": "Song A", "artists": [{"name": "Artist A"}], "spotify_track_id": "sp-1", "id": "sp-1", "album": {"name": "Album A"}, }, { "name": "Song B", "artists": [{"name": "Artist B"}], "spotify_track_id": "sp-2", "id": "sp-2", "album": {"name": "Album B"}, }, ] ) removed = processing.automatic_wishlist_cleanup_after_db_update( wishlist_service=wishlist_service, profiles_database=_CleanupProfilesDatabase(), music_database=_CleanupMusicDatabase(), active_server="navidrome", logger=_FakeLogger(), ) assert removed == 1 assert wishlist_service.removed == [("sp-1", True, None, 1)] def test_automatic_wishlist_cleanup_after_db_update_removes_manual_matches(monkeypatch): wishlist_service = _CleanupWishlistService( [ { "name": "Manual Song", "artists": [{"name": "Artist A"}], "spotify_track_id": "sp-manual", "id": "sp-manual", "provider": "spotify", "album": {"name": "Album A"}, }, ] ) music_db = _CleanupMusicDatabase() monkeypatch.setattr( "core.library.manual_library_match.get_match_for_track", lambda *_args, **_kwargs: {"id": 1, "library_track_id": 42}, ) removed = processing.automatic_wishlist_cleanup_after_db_update( wishlist_service=wishlist_service, profiles_database=_CleanupProfilesDatabase(), music_database=music_db, active_server="navidrome", logger=_FakeLogger(), ) assert removed == 1 assert wishlist_service.removed == [("sp-manual", True, None, 1)] assert music_db.track_checks == [] class _CleanupProfilesDatabase: def get_all_profiles(self): return [{"id": 1}] class _CleanupWishlistService: def __init__(self, tracks): self._tracks = list(tracks) self.removed = [] def get_wishlist_tracks_for_download(self, profile_id=1): return list(self._tracks) def mark_track_download_result(self, spotify_track_id, success, error_message=None, profile_id=1): self.removed.append((spotify_track_id, success, error_message, profile_id)) return True class _CleanupMusicDatabase: def __init__(self): self.track_checks = [] def check_track_exists(self, track_name, artist_name, confidence_threshold=0.7, server_source=None, album=None): self.track_checks.append((track_name, artist_name, server_source, album)) if track_name == "Owned Song" and artist_name == "Artist A": return {"id": "db-track"}, 0.9 if track_name == "Broken Song": raise RuntimeError("boom") return None, 0.0 def test_remove_tracks_already_in_library_skips_enhance_tracks_and_handles_errors(): wishlist_service = _CleanupWishlistService( [ { "name": "Owned Song", "artists": ["Artist A"], "spotify_track_id": "sp-1", "album": {"name": "Album A"}, }, { "name": "Enhance Song", "artists": [{"name": "Artist B"}], "spotify_track_id": "sp-2", "source_type": "enhance", "album": {"name": "Album B"}, }, { "name": "Broken Song", "artists": [{"name": "Artist C"}], "spotify_track_id": "sp-3", "album": {"name": "Album C"}, }, ] ) music_db = _CleanupMusicDatabase() removed = processing.remove_tracks_already_in_library( wishlist_service, _CleanupProfilesDatabase(), music_db, "navidrome", logger=_FakeLogger(), skip_track_fn=lambda track: track.get("source_type") == "enhance", ) assert removed == 1 assert wishlist_service.removed == [("sp-1", True, None, 1)] assert music_db.track_checks == [ ("Owned Song", "Artist A", "navidrome", "Album A"), ("Broken Song", "Artist C", "navidrome", "Album C"), ] def test_finalize_auto_wishlist_completion_with_no_tracks_added_still_resets_state(): db = _FakeDB() automation_engine = _FakeAutomationEngine() resets = [] activities = [] summary = {"tracks_added": 0, "total_failed": 5, "errors": 0} result = processing.finalize_auto_wishlist_completion( "batch-2", summary, download_batches={"batch-2": {"current_cycle": "singles"}}, tasks_lock=_FakeLock(), reset_processing_state=lambda: resets.append(True), add_activity_item=lambda *args: activities.append(args), automation_engine=automation_engine, db_factory=lambda: db, logger=_FakeLogger(), ) assert result is summary assert resets == [True] assert activities == [] assert automation_engine.events == [ ( "wishlist_processing_completed", { "tracks_processed": "5", "tracks_found": "0", "tracks_failed": "5", }, ) ] assert db.connection.committed is True