Use metadata source priority in track number repair job
Use the shared metadata source priority when resolving album IDs, album searches, and tracklists in track number repair. Keeps Deezer and iTunes ahead of Spotify where configured, while still allowing the job to fall back through other supported sources.
This commit is contained in:
parent
6dca19ca1e
commit
e7faa9f02f
2 changed files with 242 additions and 106 deletions
|
|
@ -1,8 +1,9 @@
|
||||||
"""Track Number Repair Job — fixes embedded track number tags and filename prefixes.
|
"""Track Number Repair Job — fixes embedded track number tags and filename prefixes.
|
||||||
|
|
||||||
Detects albums where 3+ files share the same track number (the "all tracks = 01"
|
Detects albums where 3+ files share the same track number (the "all tracks = 01"
|
||||||
bug pattern), then uses cascading API lookups (Spotify → iTunes → MusicBrainz →
|
bug pattern), then uses cascading API lookups in metadata-source priority order
|
||||||
AudioDB) to resolve the correct tracklist and repair each file.
|
before falling back to MusicBrainz and AudioDB to resolve the correct tracklist
|
||||||
|
and repair each file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
@ -10,6 +11,12 @@ import re
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from core.metadata_service import (
|
||||||
|
get_album_tracks_for_source,
|
||||||
|
get_client_for_source,
|
||||||
|
get_primary_source,
|
||||||
|
get_source_priority,
|
||||||
|
)
|
||||||
from core.repair_jobs import register_job
|
from core.repair_jobs import register_job
|
||||||
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
|
|
@ -24,6 +31,14 @@ _PLACEHOLDER_IDS = {
|
||||||
'unknown', 'none', 'null', '',
|
'unknown', 'none', 'null', '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_SOURCE_ALBUM_ID_COLUMNS = (
|
||||||
|
('spotify', 'spotify_album_id'),
|
||||||
|
('itunes', 'itunes_album_id'),
|
||||||
|
('deezer', 'deezer_id'),
|
||||||
|
('discogs', 'discogs_id'),
|
||||||
|
('hydrabase', 'soul_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@register_job
|
@register_job
|
||||||
class TrackNumberRepairJob(RepairJob):
|
class TrackNumberRepairJob(RepairJob):
|
||||||
|
|
@ -32,8 +47,9 @@ class TrackNumberRepairJob(RepairJob):
|
||||||
description = 'Detects mismatched track numbers using API lookups (dry run by default)'
|
description = 'Detects mismatched track numbers using API lookups (dry run by default)'
|
||||||
help_text = (
|
help_text = (
|
||||||
'Scans album folders and compares each file\'s track number against the correct '
|
'Scans album folders and compares each file\'s track number against the correct '
|
||||||
'tracklist from Spotify or iTunes. If a file\'s embedded track number doesn\'t match '
|
'tracklist from the configured metadata sources. If a file\'s embedded track '
|
||||||
'the API data, the job creates a finding showing what needs to change.\n\n'
|
'number doesn\'t match the API data, the job creates a finding showing what '
|
||||||
|
'needs to change.\n\n'
|
||||||
'In dry run mode (default), no files are modified — you review each proposed change '
|
'In dry run mode (default), no files are modified — you review each proposed change '
|
||||||
'in the Findings tab and decide what to approve. Disable dry run in settings to let '
|
'in the Findings tab and decide what to approve. Disable dry run in settings to let '
|
||||||
'the job automatically rename and re-number files.\n\n'
|
'the job automatically rename and re-number files.\n\n'
|
||||||
|
|
@ -187,7 +203,7 @@ class TrackNumberRepairJob(RepairJob):
|
||||||
logger.info("Anomaly detected in %s — %d files share track number(s): %s",
|
logger.info("Anomaly detected in %s — %d files share track number(s): %s",
|
||||||
os.path.basename(folder_path), sum(duped.values()), duped)
|
os.path.basename(folder_path), sum(duped.values()), duped)
|
||||||
|
|
||||||
# Resolve album tracklist via cascading fallbacks
|
# Resolve album tracklist via source-aware cascading fallbacks
|
||||||
api_tracks = self._resolve_album_tracklist(file_track_data, folder_path, context, scan_state)
|
api_tracks = self._resolve_album_tracklist(file_track_data, folder_path, context, scan_state)
|
||||||
if not api_tracks:
|
if not api_tracks:
|
||||||
result.skipped += len(filenames)
|
result.skipped += len(filenames)
|
||||||
|
|
@ -253,38 +269,24 @@ class TrackNumberRepairJob(RepairJob):
|
||||||
|
|
||||||
cache = scan_state['album_tracks_cache']
|
cache = scan_state['album_tracks_cache']
|
||||||
folder_name = os.path.basename(folder_path)
|
folder_name = os.path.basename(folder_path)
|
||||||
|
primary_source = get_primary_source()
|
||||||
|
source_priority = get_source_priority(primary_source)
|
||||||
|
|
||||||
# Fallback 0: Check DB first — if these files are tracked and their album
|
# Fallback 0: Check DB first. If any tracked file already has source IDs,
|
||||||
# has a spotify_album_id, use that directly without reading file tags
|
# prefer the configured source order and use the first available album ID.
|
||||||
db_album_id, db_spotify_album_id, db_itunes_album_id = _lookup_album_ids_from_db(
|
source_album_ids = _lookup_album_ids_from_db(file_track_data, context)
|
||||||
file_track_data, context
|
|
||||||
)
|
|
||||||
if db_spotify_album_id and _is_valid_album_id(db_spotify_album_id):
|
|
||||||
tracks = _get_album_tracklist(db_spotify_album_id, context, cache)
|
|
||||||
if tracks:
|
|
||||||
logger.info("[Repair] %s — resolved via DB spotify_album_id: %s", folder_name, db_spotify_album_id)
|
|
||||||
return tracks
|
|
||||||
if db_itunes_album_id and _is_valid_album_id(db_itunes_album_id):
|
|
||||||
tracks = _get_album_tracklist(db_itunes_album_id, context, cache)
|
|
||||||
if tracks:
|
|
||||||
logger.info("[Repair] %s — resolved via DB itunes_album_id: %s", folder_name, db_itunes_album_id)
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
# Collect available IDs from file tags (fallback when DB has no IDs)
|
# Collect available IDs from file tags (fallback when DB has no IDs)
|
||||||
spotify_album_id = None
|
|
||||||
itunes_album_id = None
|
|
||||||
spotify_track_id = None
|
spotify_track_id = None
|
||||||
mb_album_id = None
|
mb_album_id = None
|
||||||
album_name = None
|
album_name = None
|
||||||
artist_name = None
|
artist_name = None
|
||||||
|
|
||||||
for fpath, fname, _ in file_track_data:
|
for fpath, fname, _ in file_track_data:
|
||||||
if not spotify_album_id or not itunes_album_id:
|
if 'spotify' not in source_album_ids or 'itunes' not in source_album_ids:
|
||||||
aid, source = _read_album_id_from_file(fpath)
|
aid, source = _read_album_id_from_file(fpath)
|
||||||
if aid and source == 'spotify' and not spotify_album_id:
|
if aid and source in ('spotify', 'itunes') and source not in source_album_ids:
|
||||||
spotify_album_id = aid
|
source_album_ids[source] = aid
|
||||||
elif aid and source == 'itunes' and not itunes_album_id:
|
|
||||||
itunes_album_id = aid
|
|
||||||
|
|
||||||
if not spotify_track_id:
|
if not spotify_track_id:
|
||||||
spotify_track_id = _read_spotify_track_id_from_file(fpath)
|
spotify_track_id = _read_spotify_track_id_from_file(fpath)
|
||||||
|
|
@ -295,31 +297,27 @@ class TrackNumberRepairJob(RepairJob):
|
||||||
if not album_name:
|
if not album_name:
|
||||||
album_name, artist_name = _read_album_artist_from_file(fpath)
|
album_name, artist_name = _read_album_artist_from_file(fpath)
|
||||||
|
|
||||||
if spotify_album_id and itunes_album_id and spotify_track_id and mb_album_id and album_name:
|
if source_album_ids and spotify_track_id and mb_album_id and album_name:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Fallback 1: Spotify album ID from file tags
|
# Fallback 1: Album IDs from DB / file tags, using source priority
|
||||||
if spotify_album_id and _is_valid_album_id(spotify_album_id):
|
for source in source_priority:
|
||||||
tracks = _get_album_tracklist(spotify_album_id, context, cache)
|
album_id = source_album_ids.get(source)
|
||||||
if tracks:
|
if album_id and _is_valid_album_id(album_id):
|
||||||
logger.info("[Repair] %s — resolved via Spotify album ID: %s", folder_name, spotify_album_id)
|
tracks = _get_album_tracklist(source, album_id, cache)
|
||||||
return tracks
|
if tracks:
|
||||||
|
logger.info("[Repair] %s — resolved via %s album ID: %s",
|
||||||
|
folder_name, source, album_id)
|
||||||
|
return tracks
|
||||||
|
|
||||||
# Fallback 2: iTunes album ID
|
# Fallback 2: Spotify track ID → discover album ID
|
||||||
if itunes_album_id and _is_valid_album_id(itunes_album_id):
|
client = get_client_for_source('spotify')
|
||||||
tracks = _get_album_tracklist(itunes_album_id, context, cache)
|
if spotify_track_id and client:
|
||||||
if tracks:
|
|
||||||
logger.info("[Repair] %s — resolved via iTunes album ID: %s", folder_name, itunes_album_id)
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
# Fallback 3: Spotify track ID → discover album ID
|
|
||||||
client = context.spotify_client
|
|
||||||
if spotify_track_id and client and client.is_spotify_authenticated() and not context.is_spotify_rate_limited():
|
|
||||||
try:
|
try:
|
||||||
track_details = client.get_track_details(spotify_track_id)
|
track_details = client.get_track_details(spotify_track_id)
|
||||||
if track_details and track_details.get('album', {}).get('id'):
|
if track_details and track_details.get('album', {}).get('id'):
|
||||||
real_album_id = track_details['album']['id']
|
real_album_id = track_details['album']['id']
|
||||||
tracks = _get_album_tracklist(real_album_id, context, cache)
|
tracks = _get_album_tracklist('spotify', real_album_id, cache)
|
||||||
if tracks:
|
if tracks:
|
||||||
logger.info("[Repair] %s — resolved via Spotify track ID %s → album %s",
|
logger.info("[Repair] %s — resolved via Spotify track ID %s → album %s",
|
||||||
folder_name, spotify_track_id, real_album_id)
|
folder_name, spotify_track_id, real_album_id)
|
||||||
|
|
@ -327,29 +325,35 @@ class TrackNumberRepairJob(RepairJob):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Spotify track lookup failed for %s: %s", spotify_track_id, e)
|
logger.debug("Spotify track lookup failed for %s: %s", spotify_track_id, e)
|
||||||
|
|
||||||
# Fallback 4: Search Spotify/iTunes by album name + artist
|
# Fallback 3: Search metadata sources by album name + artist
|
||||||
if album_name and client and not context.is_spotify_rate_limited():
|
if album_name:
|
||||||
try:
|
query = f"{artist_name} {album_name}" if artist_name else album_name
|
||||||
query = f"{artist_name} {album_name}" if artist_name else album_name
|
for source in source_priority:
|
||||||
results = client.search_albums(query, limit=5)
|
client = get_client_for_source(source)
|
||||||
if results:
|
if not client or not hasattr(client, 'search_albums'):
|
||||||
best = results[0]
|
continue
|
||||||
tracks = _get_album_tracklist(best.id, context, cache)
|
try:
|
||||||
if tracks:
|
results = client.search_albums(query, limit=5)
|
||||||
logger.info("[Repair] %s — resolved via album search: '%s' → %s",
|
if results:
|
||||||
folder_name, query, best.id)
|
best = results[0]
|
||||||
return tracks
|
best_album_id = getattr(best, 'id', None) if not isinstance(best, dict) else best.get('id')
|
||||||
except Exception as e:
|
if best_album_id:
|
||||||
logger.debug("Album search failed for '%s': %s", album_name, e)
|
tracks = _get_album_tracklist(source, str(best_album_id), cache)
|
||||||
|
if tracks:
|
||||||
|
logger.info("[Repair] %s — resolved via %s album search: '%s' → %s",
|
||||||
|
folder_name, source, query, best_album_id)
|
||||||
|
return tracks
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("%s album search failed for '%s': %s", source.capitalize(), album_name, e)
|
||||||
|
|
||||||
# Fallback 5: MusicBrainz album ID from tags
|
# Fallback 4: MusicBrainz album ID from tags
|
||||||
if mb_album_id:
|
if mb_album_id:
|
||||||
tracks = _get_tracklist_from_musicbrainz(mb_album_id, context, cache)
|
tracks = _get_tracklist_from_musicbrainz(mb_album_id, context, cache)
|
||||||
if tracks:
|
if tracks:
|
||||||
logger.info("[Repair] %s — resolved via MusicBrainz album ID: %s", folder_name, mb_album_id)
|
logger.info("[Repair] %s — resolved via MusicBrainz album ID: %s", folder_name, mb_album_id)
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
# Fallback 6: AudioDB → MusicBrainz
|
# Fallback 5: AudioDB → MusicBrainz
|
||||||
if album_name and artist_name:
|
if album_name and artist_name:
|
||||||
adb_mb_id = _get_musicbrainz_id_via_audiodb(artist_name, album_name, context)
|
adb_mb_id = _get_musicbrainz_id_via_audiodb(artist_name, album_name, context)
|
||||||
if adb_mb_id and adb_mb_id != mb_album_id:
|
if adb_mb_id and adb_mb_id != mb_album_id:
|
||||||
|
|
@ -800,25 +804,36 @@ def _update_db_file_path(db, old_path: str, new_path: str):
|
||||||
|
|
||||||
|
|
||||||
def _lookup_album_ids_from_db(file_track_data: List[Tuple[str, str, Any]],
|
def _lookup_album_ids_from_db(file_track_data: List[Tuple[str, str, Any]],
|
||||||
context: JobContext) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
context: JobContext) -> Dict[str, Optional[str]]:
|
||||||
"""Look up album IDs from the database using file paths.
|
"""Look up album IDs from the database using file paths.
|
||||||
|
|
||||||
Checks if any of the files in this folder are tracked in the DB, and if so,
|
Checks if any of the files in this folder are tracked in the DB, and if so,
|
||||||
returns the album's (album_id, spotify_album_id, itunes_album_id).
|
returns a mapping of metadata source -> album ID.
|
||||||
This avoids expensive file tag reads and API calls when the DB already knows.
|
This avoids expensive file tag reads and API calls when the DB already knows.
|
||||||
"""
|
"""
|
||||||
if not context.db:
|
if not context.db:
|
||||||
return None, None, None
|
return {}
|
||||||
|
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = context.db._get_connection()
|
conn = context.db._get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("PRAGMA table_info(albums)")
|
||||||
|
album_columns = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
selected_sources = [
|
||||||
|
(source, column)
|
||||||
|
for source, column in _SOURCE_ALBUM_ID_COLUMNS
|
||||||
|
if column in album_columns
|
||||||
|
]
|
||||||
|
if not selected_sources:
|
||||||
|
return {}
|
||||||
|
|
||||||
# Try each file path until we find one tracked in the DB
|
# Try each file path until we find one tracked in the DB
|
||||||
for fpath, _, _ in file_track_data:
|
for fpath, _, _ in file_track_data:
|
||||||
cursor.execute("""
|
select_cols = ", ".join(f"al.{column}" for _source, column in selected_sources)
|
||||||
SELECT t.album_id, al.spotify_album_id, al.itunes_album_id
|
cursor.execute(f"""
|
||||||
|
SELECT {select_cols}
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums al ON al.id = t.album_id
|
JOIN albums al ON al.id = t.album_id
|
||||||
WHERE t.file_path = ?
|
WHERE t.file_path = ?
|
||||||
|
|
@ -826,7 +841,11 @@ def _lookup_album_ids_from_db(file_track_data: List[Tuple[str, str, Any]],
|
||||||
""", (fpath,))
|
""", (fpath,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row:
|
if row:
|
||||||
return row[0], row[1], row[2]
|
return {
|
||||||
|
source: str(row[idx])
|
||||||
|
for idx, (source, _column) in enumerate(selected_sources)
|
||||||
|
if row[idx]
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Error looking up album IDs from DB: %s", e)
|
logger.debug("Error looking up album IDs from DB: %s", e)
|
||||||
|
|
@ -834,7 +853,7 @@ def _lookup_album_ids_from_db(file_track_data: List[Tuple[str, str, Any]],
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
return None, None, None
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _lookup_album_artist_art(file_track_data: List[Tuple[str, str, Any]],
|
def _lookup_album_artist_art(file_track_data: List[Tuple[str, str, Any]],
|
||||||
|
|
@ -1018,52 +1037,53 @@ def _repair_single_track(file_path: str, filename: str, api_tracks: List[Dict],
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _get_album_tracklist(album_id: str, context: JobContext,
|
def _normalize_album_track_items(data) -> List[Dict[str, Any]]:
|
||||||
cache: dict) -> Optional[List[Dict]]:
|
"""Normalize album track payloads to a list of dicts."""
|
||||||
"""Fetch an album tracklist from Spotify or iTunes, with per-scan caching.
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return []
|
||||||
|
items = data.get('items')
|
||||||
|
if isinstance(items, list):
|
||||||
|
return items
|
||||||
|
tracks = data.get('tracks')
|
||||||
|
if isinstance(tracks, list):
|
||||||
|
return tracks
|
||||||
|
if isinstance(tracks, dict):
|
||||||
|
nested_items = tracks.get('items')
|
||||||
|
if isinstance(nested_items, list):
|
||||||
|
return nested_items
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _get_album_tracklist(source: str, album_id: str, cache: dict) -> Optional[List[Dict]]:
|
||||||
|
"""Fetch an album tracklist from a specific source, with per-scan caching.
|
||||||
|
|
||||||
Returns a list of dicts with at least 'name' and 'track_number' keys,
|
Returns a list of dicts with at least 'name' and 'track_number' keys,
|
||||||
or None if lookup fails.
|
or None if lookup fails.
|
||||||
"""
|
"""
|
||||||
if album_id in cache:
|
cache_key = f"{source}:{album_id}"
|
||||||
return cache[album_id]
|
if cache_key in cache:
|
||||||
|
return cache[cache_key]
|
||||||
|
|
||||||
result = None
|
result = None
|
||||||
|
|
||||||
# Try Spotify first
|
try:
|
||||||
client = context.spotify_client
|
data = get_album_tracks_for_source(source, album_id)
|
||||||
if client and client.is_spotify_authenticated() and not context.is_spotify_rate_limited():
|
items = _normalize_album_track_items(data)
|
||||||
try:
|
if items:
|
||||||
data = client.get_album_tracks(album_id)
|
result = [
|
||||||
if data and 'items' in data and data['items']:
|
{
|
||||||
result = [
|
'name': item.get('name', '') if isinstance(item, dict) else getattr(item, 'name', ''),
|
||||||
{
|
'track_number': item.get('track_number') if isinstance(item, dict) else getattr(item, 'track_number', None),
|
||||||
'name': item.get('name', ''),
|
'disc_number': item.get('disc_number', 1) if isinstance(item, dict) else getattr(item, 'disc_number', 1),
|
||||||
'track_number': item.get('track_number'),
|
}
|
||||||
'disc_number': item.get('disc_number', 1),
|
for item in items
|
||||||
}
|
]
|
||||||
for item in data['items']
|
except Exception as e:
|
||||||
]
|
logger.debug("%s get_album_tracks failed for %s: %s", source.capitalize(), album_id, e)
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Spotify get_album_tracks failed for %s: %s", album_id, e)
|
|
||||||
|
|
||||||
# Fallback to iTunes
|
cache[cache_key] = result
|
||||||
if not result and context.itunes_client:
|
|
||||||
try:
|
|
||||||
data = context.itunes_client.get_album_tracks(album_id)
|
|
||||||
if data and 'items' in data and data['items']:
|
|
||||||
result = [
|
|
||||||
{
|
|
||||||
'name': item.get('name', ''),
|
|
||||||
'track_number': item.get('track_number'),
|
|
||||||
'disc_number': item.get('disc_number', 1),
|
|
||||||
}
|
|
||||||
for item in data['items']
|
|
||||||
]
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("iTunes get_album_tracks failed for %s: %s", album_id, e)
|
|
||||||
|
|
||||||
cache[album_id] = result
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
116
tests/test_track_number_repair.py
Normal file
116
tests/test_track_number_repair.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
# Stub optional Spotify dependency so metadata_service can import in tests.
|
||||||
|
if 'spotipy' not in sys.modules:
|
||||||
|
spotipy = types.ModuleType('spotipy')
|
||||||
|
oauth2 = types.ModuleType('spotipy.oauth2')
|
||||||
|
|
||||||
|
class _DummySpotify:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class _DummyOAuth:
|
||||||
|
pass
|
||||||
|
|
||||||
|
spotipy.Spotify = _DummySpotify
|
||||||
|
oauth2.SpotifyOAuth = _DummyOAuth
|
||||||
|
oauth2.SpotifyClientCredentials = _DummyOAuth
|
||||||
|
spotipy.oauth2 = oauth2
|
||||||
|
sys.modules['spotipy'] = spotipy
|
||||||
|
sys.modules['spotipy.oauth2'] = oauth2
|
||||||
|
|
||||||
|
if 'config.settings' not in sys.modules:
|
||||||
|
config_mod = types.ModuleType('config')
|
||||||
|
settings_mod = types.ModuleType('config.settings')
|
||||||
|
|
||||||
|
class _DummyConfigManager:
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
|
settings_mod.config_manager = _DummyConfigManager()
|
||||||
|
config_mod.settings = settings_mod
|
||||||
|
sys.modules['config'] = config_mod
|
||||||
|
sys.modules['config.settings'] = settings_mod
|
||||||
|
|
||||||
|
from core.repair_jobs import track_number_repair as tnr
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSearchClient:
|
||||||
|
def __init__(self, search_results=None):
|
||||||
|
self.search_results = search_results or []
|
||||||
|
self.search_calls = []
|
||||||
|
|
||||||
|
def search_albums(self, query, limit=10):
|
||||||
|
self.search_calls.append((query, limit))
|
||||||
|
return self.search_results
|
||||||
|
|
||||||
|
|
||||||
|
def _make_context():
|
||||||
|
return SimpleNamespace(db=None, mb_client=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_album_tracklist_prefers_primary_source_album_id(monkeypatch):
|
||||||
|
context = _make_context()
|
||||||
|
file_track_data = [('/music/test.flac', '01 - Track.flac', None)]
|
||||||
|
cache = {'album_tracks_cache': {}, 'title_similarity': 0.8}
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(tnr, 'get_primary_source', lambda: 'deezer')
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tnr,
|
||||||
|
'_lookup_album_ids_from_db',
|
||||||
|
lambda *_args, **_kwargs: {'spotify': 'sp-album', 'itunes': 'it-album', 'deezer': 'dz-album'},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(tnr, '_read_album_id_from_file', lambda *_args, **_kwargs: (None, None))
|
||||||
|
monkeypatch.setattr(tnr, '_read_spotify_track_id_from_file', lambda *_args, **_kwargs: None)
|
||||||
|
monkeypatch.setattr(tnr, '_read_musicbrainz_album_id_from_file', lambda *_args, **_kwargs: None)
|
||||||
|
monkeypatch.setattr(tnr, '_read_album_artist_from_file', lambda *_args, **_kwargs: (None, None))
|
||||||
|
|
||||||
|
def fake_get_album_tracks_for_source(source, album_id):
|
||||||
|
calls.append((source, album_id))
|
||||||
|
return {'items': [{'name': f'{source} track', 'track_number': 1, 'disc_number': 1}]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(tnr, 'get_album_tracks_for_source', fake_get_album_tracks_for_source)
|
||||||
|
|
||||||
|
result = tnr.TrackNumberRepairJob()._resolve_album_tracklist(file_track_data, '/music', context, cache)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result[0]['name'] == 'deezer track'
|
||||||
|
assert calls == [('deezer', 'dz-album')]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_album_tracklist_uses_source_priority_for_search(monkeypatch):
|
||||||
|
context = _make_context()
|
||||||
|
file_track_data = [('/music/test.flac', 'Track.flac', None)]
|
||||||
|
cache = {'album_tracks_cache': {}, 'title_similarity': 0.8}
|
||||||
|
track_calls = []
|
||||||
|
|
||||||
|
discogs_client = _FakeSearchClient([SimpleNamespace(id='dg-album')])
|
||||||
|
spotify_client = _FakeSearchClient([SimpleNamespace(id='sp-album')])
|
||||||
|
|
||||||
|
monkeypatch.setattr(tnr, 'get_primary_source', lambda: 'discogs')
|
||||||
|
monkeypatch.setattr(tnr, '_lookup_album_ids_from_db', lambda *_args, **_kwargs: {})
|
||||||
|
monkeypatch.setattr(tnr, '_read_album_id_from_file', lambda *_args, **_kwargs: (None, None))
|
||||||
|
monkeypatch.setattr(tnr, '_read_spotify_track_id_from_file', lambda *_args, **_kwargs: None)
|
||||||
|
monkeypatch.setattr(tnr, '_read_musicbrainz_album_id_from_file', lambda *_args, **_kwargs: None)
|
||||||
|
monkeypatch.setattr(tnr, '_read_album_artist_from_file', lambda *_args, **_kwargs: ('Album Title', 'Artist Name'))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tnr,
|
||||||
|
'get_client_for_source',
|
||||||
|
lambda source: {'discogs': discogs_client, 'spotify': spotify_client}.get(source),
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_get_album_tracks_for_source(source, album_id):
|
||||||
|
track_calls.append((source, album_id))
|
||||||
|
return {'items': [{'name': f'{source} track', 'track_number': 1, 'disc_number': 1}]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(tnr, 'get_album_tracks_for_source', fake_get_album_tracks_for_source)
|
||||||
|
|
||||||
|
result = tnr.TrackNumberRepairJob()._resolve_album_tracklist(file_track_data, '/music', context, cache)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result[0]['name'] == 'discogs track'
|
||||||
|
assert discogs_client.search_calls == [('Artist Name Album Title', 5)]
|
||||||
|
assert spotify_client.search_calls == []
|
||||||
|
assert track_calls == [('discogs', 'dg-album')]
|
||||||
Loading…
Reference in a new issue