diff --git a/core/downloads/candidates.py b/core/downloads/candidates.py index edb1fdc1..b2e92fcb 100644 --- a/core/downloads/candidates.py +++ b/core/downloads/candidates.py @@ -63,8 +63,21 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None, Attempts to download with fallback candidate logic (matches GUI's retry_parallel_download_with_fallback). Returns True if successful, False if all candidates fail. """ - # Sort candidates by confidence (best first) - candidates.sort(key=lambda r: r.confidence, reverse=True) + # Sort candidates by match confidence first, then peer quality. Upstream + # Soulseek validation already considers peer speed/slots/queue when scores + # are close; preserve that signal here instead of flattening ties back to + # arbitrary slskd response order. + candidates.sort( + key=lambda r: ( + getattr(r, 'confidence', 0) or 0, + getattr(r, 'quality_score', 0) or 0, + getattr(r, 'upload_speed', 0) or 0, + -(getattr(r, 'queue_length', 0) or 0), + getattr(r, 'free_upload_slots', 0) or 0, + getattr(r, 'size', 0) or 0, + ), + reverse=True, + ) with tasks_lock: task = download_tasks.get(task_id) diff --git a/core/downloads/master.py b/core/downloads/master.py index 3e32cc65..ce3eb832 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -31,6 +31,8 @@ import re import time import uuid from dataclasses import dataclass +from difflib import SequenceMatcher +from pathlib import Path from typing import Any, Callable from core.runtime_state import download_batches, download_tasks, tasks_lock @@ -38,6 +40,210 @@ from core.runtime_state import download_batches, download_tasks, tasks_lock logger = logging.getLogger(__name__) +_ALBUM_PREFLIGHT_MIN_SCORE = 0.62 +_EDITION_WORDS = { + 'deluxe', 'expanded', 'anniversary', 'special', 'platinum', 'bonus', + 'remaster', 'remastered', 'edition', 'version', +} +_VARIANT_WORDS = { + 'remix', 'rmx', 'acapella', 'a cappella', 'instrumental', 'karaoke', + 'live', 'demo', 'extended', +} + + +def _norm_text(value: Any) -> str: + text = str(value or '').lower() + text = re.sub(r'[_./\\|()[\]{}:;,+]', ' ', text) + text = re.sub(r'[^a-z0-9\s-]', '', text) + text = re.sub(r'\s+', ' ', text).strip() + return text + + +def _similarity(left: Any, right: Any) -> float: + a = _norm_text(left) + b = _norm_text(right) + if not a or not b: + return 0.0 + if a == b: + return 1.0 + if a in b or b in a: + return min(len(a), len(b)) / max(len(a), len(b)) + return SequenceMatcher(None, a, b).ratio() + + +def _track_title_from_candidate(candidate: Any) -> str: + title = getattr(candidate, 'title', None) + if title: + return str(title) + filename = getattr(candidate, 'filename', '') or '' + stem = Path(filename.replace('\\', '/')).stem + stem = re.sub(r'^\s*(?:disc\s*)?\d+[-_.\s]+', '', stem, flags=re.IGNORECASE) + return stem + + +def _track_number_from_track(track_data: dict) -> int: + value = track_data.get('track_number') or track_data.get('trackNumber') or 0 + try: + return int(str(value).split('/')[0]) + except (TypeError, ValueError): + return 0 + + +def _track_number_from_candidate(candidate: Any) -> int: + value = getattr(candidate, 'track_number', None) or 0 + try: + return int(str(value).split('/')[0]) + except (TypeError, ValueError): + return 0 + + +def _folder_variant_penalty(expected_album_name: str, folder_text: str) -> float: + expected = _norm_text(expected_album_name) + folder = _norm_text(folder_text) + if not folder: + return 0.0 + + penalty = 0.0 + for word in _VARIANT_WORDS: + if word in folder and word not in expected: + penalty += 0.12 + for word in _EDITION_WORDS: + if word in folder and word not in expected: + penalty += 0.06 + return min(penalty, 0.30) + + +def _source_quality_score(source: Any) -> float: + score = getattr(source, 'quality_score', None) + if callable(score): + try: + return float(score()) + except Exception: + return 0.0 + try: + return float(score or 0.0) + except (TypeError, ValueError): + return 0.0 + + +def _album_context_richness(album_ctx: dict) -> int: + if not isinstance(album_ctx, dict): + return 0 + fields = ('id', 'name', 'release_date', 'total_tracks', 'album_type') + score = sum(1 for field in fields if album_ctx.get(field)) + images = album_ctx.get('images') + if images: + score += 1 + artists = album_ctx.get('artists') + if artists: + score += 1 + return score + + +def _score_album_folder(album_result: Any, album_context: dict, artist_context: dict, + tracks_json: list[dict], filtered_track_count: int) -> float: + """Score one slskd folder as a whole release, not as isolated tracks.""" + expected_album = str((album_context or {}).get('name') or '') + expected_artist = str((artist_context or {}).get('name') or '') + expected_count = int((album_context or {}).get('total_tracks') or len(tracks_json) or 0) + expected_year = str((album_context or {}).get('release_date') or '')[:4] + + folder_text = ' '.join( + str(getattr(album_result, attr, '') or '') + for attr in ('album_title', 'album_path') + ) + album_score = max( + _similarity(expected_album, getattr(album_result, 'album_title', '')), + _similarity(expected_album, getattr(album_result, 'album_path', '')), + ) + artist_score = max( + _similarity(expected_artist, getattr(album_result, 'artist', '')), + _similarity(expected_artist, getattr(album_result, 'album_path', '')), + ) + + actual_count = int(getattr(album_result, 'track_count', 0) or len(getattr(album_result, 'tracks', []) or [])) + if expected_count > 0 and actual_count > 0: + diff = abs(actual_count - expected_count) + if diff == 0: + count_score = 1.0 + elif diff <= 2: + count_score = 0.75 + elif diff <= 5: + count_score = 0.35 + else: + count_score = 0.0 + else: + count_score = 0.4 + + candidate_tracks = list(getattr(album_result, 'tracks', []) or []) + matched = 0 + expected_tracks = [ + (track_data, _norm_text(track_data.get('name', ''))) + for track_data in tracks_json + if track_data.get('name') + ] + for track_data, expected_title in expected_tracks: + expected_number = _track_number_from_track(track_data) + best = 0.0 + for candidate in candidate_tracks: + cand_title = _norm_text(_track_title_from_candidate(candidate)) + title_sim = _similarity(expected_title, cand_title) + cand_number = _track_number_from_candidate(candidate) + if expected_number and cand_number and expected_number == cand_number: + title_sim = min(1.0, title_sim + 0.12) + best = max(best, title_sim) + if best >= 0.72: + matched += 1 + coverage_score = matched / max(1, len(expected_tracks)) + + year_score = 0.5 + folder_year = str(getattr(album_result, 'year', '') or '') + if expected_year and folder_year: + year_score = 1.0 if expected_year == folder_year else 0.2 + elif expected_year and expected_year in _norm_text(folder_text): + year_score = 1.0 + + quality_count_score = min(1.0, filtered_track_count / max(1, expected_count or actual_count or 1)) + peer_score = _source_quality_score(album_result) + penalty = _folder_variant_penalty(expected_album, folder_text) + + score = ( + album_score * 0.24 + + artist_score * 0.16 + + count_score * 0.16 + + coverage_score * 0.28 + + year_score * 0.06 + + quality_count_score * 0.06 + + peer_score * 0.04 + - penalty + ) + return max(0.0, min(score, 1.0)) + + +def _resolve_soulseek_client(download_orchestrator: Any) -> Any: + if hasattr(download_orchestrator, 'client'): + try: + client = download_orchestrator.client('soulseek') + if client: + return client + except Exception as exc: + logger.debug("Soulseek client lookup through orchestrator failed: %s", exc) + return getattr(download_orchestrator, 'soulseek', download_orchestrator) + + +def _soulseek_album_preflight_enabled(config_manager: Any) -> bool: + mode = config_manager.get('download_source.mode', 'hybrid') + if mode == 'soulseek': + return True + if mode != 'hybrid': + return False + order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek']) + if order: + return order[0] == 'soulseek' + primary = config_manager.get('download_source.hybrid_primary', '') + return primary == 'soulseek' + + @dataclass class MasterDeps: """Bundle of cross-cutting deps the master worker needs.""" @@ -376,12 +582,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma # Only run pre-flight when Soulseek is the download source (or hybrid with soulseek) preflight_source = None preflight_tracks = None - dl_source_mode = deps.config_manager.get('download_source.mode', 'hybrid') - _dl_hybrid_order = deps.config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek']) - _dl_hybrid_first = _dl_hybrid_order[0] if _dl_hybrid_order else deps.config_manager.get('download_source.hybrid_primary', 'hifi') - soulseek_is_source = dl_source_mode == 'soulseek' or ( - dl_source_mode == 'hybrid' and _dl_hybrid_first == 'soulseek' - ) + soulseek_is_source = _soulseek_album_preflight_enabled(deps.config_manager) if batch_is_album and batch_album_context and batch_artist_context and soulseek_is_source: artist_name = batch_artist_context.get('name', '') album_name = batch_album_context.get('name', '') @@ -391,7 +592,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma _sr.info(f"[Album Pre-flight] Searching for '{artist_name} {album_name}'") logger.info(f"[Album Pre-flight] Searching Soulseek for complete album: '{artist_name} - {album_name}'") - slsk = deps.download_orchestrator.client('soulseek') if hasattr(deps.download_orchestrator, 'client') else deps.download_orchestrator + slsk = _resolve_soulseek_client(deps.download_orchestrator) # Try multiple query variations (banned keywords in artist/album name can return 0 results) album_queries = [f"{artist_name} {album_name}"] @@ -405,29 +606,57 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma album_results = [] track_results = [] + album_results_by_source = {} for aq in album_queries: _sr.info(f"[Album Pre-flight] Trying query: '{aq}'") track_results, album_results = deps.run_async(slsk.search(aq, timeout=30)) if album_results: _sr.info(f"[Album Pre-flight] Found {len(album_results)} album results with query: '{aq}'") - break - _sr.info(f"[Album Pre-flight] No album results for query: '{aq}'") + for ar in album_results: + key = (getattr(ar, 'username', ''), getattr(ar, 'album_path', '')) + if key[0] and key[1] and key not in album_results_by_source: + album_results_by_source[key] = ar + else: + _sr.info(f"[Album Pre-flight] No album results for query: '{aq}'") + album_results = list(album_results_by_source.values()) if album_results: - # Filter by quality preference - quality_filtered = [] + # Score complete folders as releases before falling back to per-track search. + scored_albums = [] for ar in album_results: filtered_tracks = slsk.filter_results_by_quality_preference(ar.tracks) if filtered_tracks: - quality_filtered.append((ar, len(filtered_tracks))) + folder_score = _score_album_folder( + ar, + batch_album_context, + batch_artist_context, + tracks_json, + len(filtered_tracks), + ) + scored_albums.append((ar, len(filtered_tracks), folder_score)) + _sr.info( + f"[Album Pre-flight] Candidate {ar.username}:{ar.album_path} " + f"score={folder_score:.3f}, tracks={ar.track_count}, " + f"quality_tracks={len(filtered_tracks)}" + ) - if quality_filtered: - # Sort by track count (most complete album first), then quality score - quality_filtered.sort(key=lambda x: (x[1], x[0].quality_score), reverse=True) - best_album = quality_filtered[0][0] + best_album = None + best_score = 0.0 + if scored_albums: + scored_albums.sort(key=lambda x: (x[2], x[1], x[0].quality_score), reverse=True) + best_album, _best_filtered_count, best_score = scored_albums[0] + if best_score < _ALBUM_PREFLIGHT_MIN_SCORE: + _sr.info( + f"[Album Pre-flight] Best folder score {best_score:.3f} below " + f"threshold {_ALBUM_PREFLIGHT_MIN_SCORE:.2f}; falling back" + ) + logger.warning("[Album Pre-flight] No Soulseek folder passed album-level validation") + best_album = None + + if best_album: _sr.info(f"[Album Pre-flight] Best album result: {best_album.username}:{best_album.album_path} " - f"({best_album.track_count} tracks, quality={best_album.dominant_quality})") + f"({best_album.track_count} tracks, quality={best_album.dominant_quality}, score={best_score:.3f})") logger.info(f"[Album Pre-flight] Found album folder: {best_album.username} — " f"{best_album.track_count} tracks ({best_album.dominant_quality})") @@ -456,7 +685,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma } preflight_tracks = best_album.tracks logger.info(f"[Album Pre-flight] Using {len(best_album.tracks)} tracks from search results (browse unavailable)") - else: + elif not scored_albums: _sr.info("[Album Pre-flight] No album results passed quality filter") logger.warning("[Album Pre-flight] No album results matched quality preferences") else: @@ -492,6 +721,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma # Wishlist tracks aren't batch_is_album but each track has disc_number in spotify_data wishlist_album_disc_counts = {} wishlist_album_artist_map = {} # album_id -> resolved artist context (consistent per album) + wishlist_album_context_map = {} # album_id -> richest shared album context if playlist_id == 'wishlist': import json as _json # First pass: collect disc_number and resolve ONE artist per album @@ -512,6 +742,10 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma wishlist_album_disc_counts[album_id] = max( wishlist_album_disc_counts.get(album_id, 1), disc_num ) + if isinstance(album_val, dict): + existing_album_ctx = wishlist_album_context_map.get(album_id, {}) + if _album_context_richness(album_val) > _album_context_richness(existing_album_ctx): + wishlist_album_context_map[album_id] = dict(album_val) # Resolve album-level artist once per album (first track wins) if album_id not in wishlist_album_artist_map: _wl_source = t.get('source_info') or {} @@ -602,17 +836,20 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma _fb_name = track_info.get('artist', '') artist_ctx = {'name': _fb_name or 'Unknown Artist'} - # Construct minimal album context - # Ensure images are preserved (important for artwork) + # Construct a shared album context from the richest track in + # this album group so release_date/year and artwork do not + # vary per track and split folders. album_id = s_album.get('id', 'wishlist_album') + shared_album = wishlist_album_context_map.get(album_id_for_lookup, s_album) album_ctx = { 'id': album_id, - 'name': s_album.get('name'), - 'release_date': s_album.get('release_date', ''), - 'total_tracks': s_album.get('total_tracks', 1), - 'total_discs': wishlist_album_disc_counts.get(album_id, 1), - 'album_type': s_album.get('album_type', 'album'), - 'images': s_album.get('images', []) # Pass images array directly + 'name': shared_album.get('name') or s_album.get('name'), + 'release_date': shared_album.get('release_date', ''), + 'total_tracks': shared_album.get('total_tracks') or s_album.get('total_tracks', 1), + 'total_discs': wishlist_album_disc_counts.get(album_id_for_lookup, 1), + 'album_type': shared_album.get('album_type') or s_album.get('album_type', 'album'), + 'images': shared_album.get('images') or s_album.get('images', []), + 'artists': shared_album.get('artists') or s_album.get('artists', []), } track_info['_explicit_album_context'] = album_ctx diff --git a/tests/downloads/test_downloads_candidates.py b/tests/downloads/test_downloads_candidates.py index 19471384..1de44b51 100644 --- a/tests/downloads/test_downloads_candidates.py +++ b/tests/downloads/test_downloads_candidates.py @@ -38,6 +38,10 @@ class _Candidate: title: str = "Song" artist: str = "Artist" album: str = "Album" + quality_score: float = 0.0 + upload_speed: int = 0 + queue_length: int = 0 + free_upload_slots: int = 0 @dataclass @@ -413,3 +417,20 @@ def test_candidates_with_equal_confidence_both_tried(): # First one wins — second never tried because download succeeded assert len(deps.download_orchestrator.download_calls) == 1 assert deps.download_orchestrator.download_calls[0][1] == "a.flac" + + +def test_equal_confidence_candidates_prefer_better_peer_quality(): + """Equal-confidence Soulseek candidates use peer quality as the tiebreaker.""" + deps = _build_deps() + _seed_task("t14") + candidates = [ + _Candidate(filename="slow.flac", confidence=0.9, quality_score=0.8, + upload_speed=100_000, queue_length=0, free_upload_slots=1), + _Candidate(filename="fast.flac", confidence=0.9, quality_score=1.0, + upload_speed=5_000_000, queue_length=0, free_upload_slots=1), + ] + track = _Track() + + dc.attempt_download_with_candidates("t14", candidates, track, batch_id=None, deps=deps) + + assert deps.download_orchestrator.download_calls[0][1] == "fast.flac" diff --git a/tests/downloads/test_downloads_master.py b/tests/downloads/test_downloads_master.py index e702e71e..f80360f1 100644 --- a/tests/downloads/test_downloads_master.py +++ b/tests/downloads/test_downloads_master.py @@ -3,6 +3,7 @@ from __future__ import annotations import threading +from types import SimpleNamespace import pytest @@ -109,6 +110,9 @@ class _FakeSoulseekWrapper: def __init__(self, inner): self.soulseek = inner + def client(self, name): + return self.soulseek if name == 'soulseek' else None + class _FakeMonitor: def __init__(self): @@ -220,6 +224,38 @@ def _seed_batch(batch_id, **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 # --------------------------------------------------------------------------- @@ -454,6 +490,114 @@ def test_mb_release_preflight_skipped_when_no_mb_worker(monkeypatch): 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'] + + # --------------------------------------------------------------------------- # Task creation # --------------------------------------------------------------------------- @@ -538,6 +682,48 @@ def test_wishlist_album_grouping_resolves_artist(monkeypatch): 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_playlist_folder_mode_propagates(monkeypatch): """Playlist folder mode flag carried through to track_info.""" db = _FakeDB()