Improve Soulseek album source selection

Add a conservative Soulseek album preflight scorer so album downloads choose a coherent slskd folder before per-track enqueue. The scorer compares album title, artist, year, track count, tracklist coverage, peer quality, and penalizes unexpected deluxe/remix/live-style folders.

Preserve hybrid source priority by only running Soulseek album preflight when Soulseek is the selected source or first in the hybrid order. If Soulseek is only a fallback behind another source, the normal hybrid flow is left alone.

Reuse the richest wishlist album context across tracks in the same album group so release date, artwork, album type, and album artist stay consistent for path generation. Also preserve peer-quality tie breakers when attempting equal-confidence candidates.

Tests cover correct-folder selection over larger wrong editions, Soulseek primary vs fallback hybrid behavior, shared wishlist album context, and peer-quality candidate ordering.
This commit is contained in:
Broque Thomas 2026-05-17 09:56:19 -07:00
parent 57cbdc2ed4
commit 076cf9e516
4 changed files with 485 additions and 28 deletions

View file

@ -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)

View file

@ -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

View file

@ -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"

View file

@ -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()