from contextlib import contextmanager from types import SimpleNamespace from core.wishlist import processing from core.wishlist.processing import WishlistAutoProcessingRuntime, process_wishlist_automatically class _FakeLogger: def __init__(self): self.info_messages = [] self.warning_messages = [] self.error_messages = [] self.debug_messages = [] def info(self, msg): self.info_messages.append(msg) def warning(self, msg): self.warning_messages.append(msg) def error(self, msg): self.error_messages.append(msg) def debug(self, msg): self.debug_messages.append(msg) class _FakeProfilesDatabase: def __init__(self, profiles): self._profiles = profiles def get_all_profiles(self): return list(self._profiles) class _FakeWishlistService: def __init__(self, tracks, count=None): self._tracks = tracks self._count = count if count is not None else len(tracks) def get_wishlist_count(self, profile_id=1): return self._count 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): return True class _FakeCursor: def __init__(self, db): self.db = db self.calls = [] self._last_sql = "" def execute(self, sql, params=None): self.calls.append((sql, params)) self._last_sql = sql if "INSERT OR REPLACE INTO metadata" in sql and params: self.db.cycle_value = params[0] def fetchone(self): if "SELECT value FROM metadata WHERE key = 'wishlist_cycle'" in self._last_sql: return {"value": self.db.cycle_value} return None class _FakeMusicDatabase: def __init__(self, cycle_value="albums"): self.cycle_value = cycle_value self.cursor_obj = _FakeCursor(self) self.commits = 0 self.duplicate_removals = [] self.track_checks = [] def _get_connection(self): class _Conn: def __init__(self, outer): self.outer = outer def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def cursor(self): return self.outer.cursor_obj def commit(self): self.outer.commits += 1 return _Conn(self) def remove_wishlist_duplicates(self, profile_id=1): self.duplicate_removals.append(profile_id) return 0 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, confidence_threshold, server_source, album)) return None, 0.0 class _FakeExecutor: def __init__(self): self.submissions = [] def submit(self, fn, *args, **kwargs): self.submissions.append((fn, args, kwargs)) return SimpleNamespace() class _FakeLock: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def _build_runtime( *, tracks, cycle_value="albums", count=None, profiles=None, active_server="navidrome", progress_calls=None, guard_events=None, batch_map=None, guard_acquired=True, is_actually_processing=False, album_bundle_executor=None, ): if progress_calls is None: progress_calls = [] if guard_events is None: guard_events = [] if batch_map is None: batch_map = {} wishlist_service = _FakeWishlistService(tracks, count=count) processing.get_wishlist_service = lambda: wishlist_service profiles_db = _FakeProfilesDatabase(profiles or [{"id": 1}]) music_db = _FakeMusicDatabase(cycle_value=cycle_value) executor = _FakeExecutor() logger = _FakeLogger() @contextmanager def guard(): guard_events.append("enter") try: yield guard_acquired finally: guard_events.append("exit") @contextmanager def app_context(): yield def progress_callback(*args, **kwargs): progress_calls.append((args, kwargs)) runtime = WishlistAutoProcessingRuntime( processing_guard=guard, app_context_factory=app_context, get_profiles_database=lambda: profiles_db, get_music_database=lambda: music_db, download_batches=batch_map, tasks_lock=_FakeLock(), update_automation_progress=progress_callback, automation_engine=None, missing_download_executor=executor, album_bundle_executor=album_bundle_executor, run_full_missing_tracks_process=lambda *args, **kwargs: None, get_batch_max_concurrent=lambda: 4, get_active_server=lambda: active_server, logger=logger, current_time_fn=lambda: 123.0, is_actually_processing=lambda: is_actually_processing, profile_id=1, ) return runtime, wishlist_service, profiles_db, music_db, executor, logger, progress_calls, guard_events def test_process_wishlist_automatically_toggles_cycle_when_no_tracks_match_current_cycle(): runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime( tracks=[ { "name": "Single Track", "artists": [{"name": "Artist A"}], "spotify_data": {"album": {"album_type": "single"}}, } ], cycle_value="albums", count=1, ) process_wishlist_automatically(runtime, automation_id="auto-1") assert executor.submissions == [] assert music_db.cycle_value == "singles" assert music_db.commits == 1 assert any("No albums tracks in wishlist" in msg for msg in logger.warning_messages) assert guard_events == ["enter", "exit"] assert [kwargs.get("progress") for _args, kwargs in progress_calls if "progress" in kwargs] == [10, 25, 40] def test_process_wishlist_automatically_creates_batch_for_matching_tracks(): batch_map = {} runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime( tracks=[ { "name": "Album Track", "artists": [{"name": "Artist A"}], "spotify_data": {"album": {"album_type": "album"}}, } ], cycle_value="albums", count=1, batch_map=batch_map, ) process_wishlist_automatically(runtime, automation_id="auto-2") assert len(executor.submissions) == 1 submitted_fn, submitted_args, submitted_kwargs = executor.submissions[0] assert submitted_args[1] == "wishlist" assert submitted_args[2][0]["_original_index"] == 0 assert len(batch_map) == 1 batch = next(iter(batch_map.values())) # ``queued`` is the initial state — the master worker flips it # to ``analysis`` as its first action when the executor picks # the batch up. Without this, wishlist runs with N > 3 # sub-batches all rendered "Analyzing..." simultaneously even # though only 3 workers were running (UI lie). assert batch["phase"] == "queued" assert batch["playlist_name"] == "Wishlist (Auto - Albums)" assert batch["analysis_total"] == 1 assert any(kwargs.get("progress") == 50 for _args, kwargs in progress_calls) assert guard_events == ["enter", "exit"] # Track has no album id/name → falls to residual batch path assert any("Starting wishlist residual batch" in msg for msg in logger.info_messages) def test_wishlist_albums_cycle_splits_into_per_album_batches(): """Multi-album wishlist run: each album with at least the threshold of missing tracks (default 2) emits its own sub-batch with ``is_album_download=True`` + populated album/artist context. Single-track-per-album items fall to residual and take the per-track path. Pinned so the album-bundle dispatch gate (which keys on those fields) engages per album instead of falling through to per-track on a single mixed batch.""" batch_map = {} runtime, _service, _profiles_db, music_db, executor, _logger, _progress, _guards = _build_runtime( tracks=[ # Album one: 2 missing tracks → promotes to album-bundle. { "name": "Song A1", "artists": [{"name": "Artist 1"}], "spotify_data": { "album": {"id": "alb1", "name": "Album One", "album_type": "album"}, "artists": [{"name": "Artist 1"}], }, }, { "name": "Song A2", "artists": [{"name": "Artist 1"}], "spotify_data": { "album": {"id": "alb1", "name": "Album One", "album_type": "album"}, "artists": [{"name": "Artist 1"}], }, }, # Album two: 3 missing tracks → also promotes. { "name": "Song B1", "artists": [{"name": "Artist 2"}], "spotify_data": { "album": {"id": "alb2", "name": "Album Two", "album_type": "album"}, "artists": [{"name": "Artist 2"}], }, }, { "name": "Song B2", "artists": [{"name": "Artist 2"}], "spotify_data": { "album": {"id": "alb2", "name": "Album Two", "album_type": "album"}, "artists": [{"name": "Artist 2"}], }, }, { "name": "Song B3", "artists": [{"name": "Artist 2"}], "spotify_data": { "album": {"id": "alb2", "name": "Album Two", "album_type": "album"}, "artists": [{"name": "Artist 2"}], }, }, ], cycle_value="albums", count=5, batch_map=batch_map, ) process_wishlist_automatically(runtime, automation_id="auto-multi-album") # Two album groups → two sub-batches submitted (no residual). assert len(executor.submissions) == 2 assert len(batch_map) == 2 # Each sub-batch must carry album-bundle dispatch context. for batch in batch_map.values(): assert batch.get("is_album_download") is True assert batch.get("album_context", {}).get("name") in {"Album One", "Album Two"} assert batch.get("artist_context", {}).get("name") in {"Artist 1", "Artist 2"} submitted_track_lists = [submitted_args[2] for _fn, submitted_args, _kw in executor.submissions] track_counts = sorted(len(tracks) for tracks in submitted_track_lists) assert track_counts == [2, 3] # All sub-batches of one wishlist invocation share a single # ``wishlist_run_id`` so the completion handler can gate the # cycle toggle on "all siblings done". run_ids = {batch.get("wishlist_run_id") for batch in batch_map.values()} assert len(run_ids) == 1 assert next(iter(run_ids)) # non-empty string def test_wishlist_albums_cycle_residual_for_orphan_tracks(): """Tracks without resolvable album metadata fall to the classic per-track residual batch (no ``is_album_download`` flag), while sibling tracks with valid album info AND enough missing tracks to clear the bundle threshold still get their own album-bundle sub-batch. With the default threshold bumped to 2, the album side needs at least two tracks from the same album to promote.""" batch_map = {} runtime, _service, _profiles_db, music_db, executor, _logger, _progress, _guards = _build_runtime( tracks=[ # Album side: two missing tracks from Album One → promotes. { "name": "Real Album Track 1", "artists": [{"name": "Artist 1"}], "spotify_data": { "album": {"id": "alb1", "name": "Album One", "album_type": "album"}, "artists": [{"name": "Artist 1"}], }, }, { "name": "Real Album Track 2", "artists": [{"name": "Artist 1"}], "spotify_data": { "album": {"id": "alb1", "name": "Album One", "album_type": "album"}, "artists": [{"name": "Artist 1"}], }, }, # Orphan: no album id, no album name → residual. { "name": "Orphan", "artists": [{"name": "X"}], "spotify_data": {"album": {"album_type": "album"}, "artists": [{"name": "X"}]}, }, ], cycle_value="albums", count=3, batch_map=batch_map, ) process_wishlist_automatically(runtime, automation_id="auto-mixed") assert len(executor.submissions) == 2 # 1 album batch + 1 residual album_batches = [b for b in batch_map.values() if b.get("is_album_download")] residual_batches = [b for b in batch_map.values() if not b.get("is_album_download")] assert len(album_batches) == 1 assert len(residual_batches) == 1 assert album_batches[0]["album_context"]["name"] == "Album One" assert album_batches[0]["analysis_total"] == 2 assert residual_batches[0]["analysis_total"] == 1 def test_process_wishlist_automatically_returns_early_when_already_processing(): runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime( tracks=[ { "name": "Album Track", "artists": [{"name": "Artist A"}], "spotify_data": {"album": {"album_type": "album"}}, } ], cycle_value="albums", count=1, is_actually_processing=True, ) process_wishlist_automatically(runtime, automation_id="auto-3") assert executor.submissions == [] assert music_db.duplicate_removals == [] assert progress_calls == [] assert guard_events == [] assert any("Already processing" in msg for msg in logger.info_messages) def test_process_wishlist_automatically_returns_early_when_guard_not_acquired(): runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime( tracks=[ { "name": "Album Track", "artists": [{"name": "Artist A"}], "spotify_data": {"album": {"album_type": "album"}}, } ], cycle_value="albums", count=1, guard_acquired=False, ) process_wishlist_automatically(runtime, automation_id="auto-4") assert executor.submissions == [] assert music_db.duplicate_removals == [] assert progress_calls == [] assert guard_events == ["enter", "exit"] assert any("race condition check" in msg for msg in logger.info_messages) def test_process_wishlist_automatically_returns_early_when_no_tracks_are_available(): runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime( tracks=[], cycle_value="albums", count=0, ) process_wishlist_automatically(runtime, automation_id="auto-5") assert executor.submissions == [] assert music_db.duplicate_removals == [] assert guard_events == ["enter", "exit"] assert [kwargs.get("progress") for _args, kwargs in progress_calls if "progress" in kwargs] == [10] assert any("No tracks in wishlist for auto-processing" in msg for msg in logger.warning_messages) def test_process_wishlist_automatically_skips_when_wishlist_batch_is_already_active(): batch_map = { "batch-1": { "playlist_id": "wishlist", "phase": "analysis", } } runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime( tracks=[ { "name": "Album Track", "artists": [{"name": "Artist A"}], "spotify_data": {"album": {"album_type": "album"}}, } ], cycle_value="albums", count=1, batch_map=batch_map, ) process_wishlist_automatically(runtime, automation_id="auto-6") assert executor.submissions == [] assert music_db.duplicate_removals == [] assert guard_events == ["enter", "exit"] assert [kwargs.get("progress") for _args, kwargs in progress_calls if "progress" in kwargs] == [10] assert any("already active in another batch" in msg for msg in logger.info_messages) # --- #740: album-bundle batches must route to the dedicated pool ------------ def _two_album_tracks_plus_orphan(): """2 missing tracks from one album (→ album-bundle batch) + 1 orphan track with no album metadata (→ residual per-track batch).""" return [ { "name": "Album Song 1", "artists": [{"name": "Artist 1"}], "spotify_data": { "album": {"id": "alb1", "name": "Album One", "album_type": "album"}, "artists": [{"name": "Artist 1"}], }, }, { "name": "Album Song 2", "artists": [{"name": "Artist 1"}], "spotify_data": { "album": {"id": "alb1", "name": "Album One", "album_type": "album"}, "artists": [{"name": "Artist 1"}], }, }, { "name": "Orphan", "artists": [{"name": "X"}], "spotify_data": {"album": {"album_type": "album"}, "artists": [{"name": "X"}]}, }, ] def test_album_subbatches_route_to_dedicated_album_pool(): """#740: per-album bundle batches (which block their worker thread for the whole search+download) must be submitted to the dedicated album_bundle_executor, NOT the shared missing_download_executor — so a burst of album batches can't starve the per-track flow / manual wishlist. The residual per-track batch still goes to the shared pool.""" album_executor = _FakeExecutor() batch_map = {} runtime, _s, _p, _db, shared_executor, _l, _pr, _g = _build_runtime( tracks=_two_album_tracks_plus_orphan(), cycle_value="albums", count=3, batch_map=batch_map, album_bundle_executor=album_executor, ) process_wishlist_automatically(runtime, automation_id="auto-pool") # Album sub-batch → dedicated album pool; residual → shared pool. assert len(album_executor.submissions) == 1 assert len(shared_executor.submissions) == 1 album_batch_id = album_executor.submissions[0][1][0] assert batch_map[album_batch_id]["is_album_download"] is True assert batch_map[album_batch_id]["album_context"]["name"] == "Album One" residual_batch_id = shared_executor.submissions[0][1][0] assert batch_map[residual_batch_id].get("is_album_download") is not True def test_album_subbatches_fall_back_to_shared_pool_when_no_album_pool(): """Bulletproofing: if no dedicated album pool is wired (older callers / tests), album batches fall back to the shared executor — i.e. exactly the pre-fix behavior, so nothing breaks.""" batch_map = {} runtime, _s, _p, _db, shared_executor, _l, _pr, _g = _build_runtime( tracks=_two_album_tracks_plus_orphan(), cycle_value="albums", count=3, batch_map=batch_map, album_bundle_executor=None, # not wired ) process_wishlist_automatically(runtime, automation_id="auto-fallback") # Both the album sub-batch and the residual land on the shared pool. assert len(shared_executor.submissions) == 2 assert any( batch_map[args[0]].get("is_album_download") is True for _fn, args, _kw in shared_executor.submissions )