Merge pull request #621 from Nezreka/codex/slskd-album-preflight
Improve Soulseek album source selection
This commit is contained in:
commit
d734f92e19
4 changed files with 485 additions and 28 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue