soulsync/core/repair_worker.py
2026-02-28 14:47:49 -08:00

1140 lines
46 KiB
Python

import json
import os
import re
import threading
import time
from difflib import SequenceMatcher
from typing import Optional, Dict, Any, List, Tuple
from datetime import datetime, timedelta
from utils.logging_config import get_logger
from database.music_database import MusicDatabase
logger = get_logger("repair_worker")
AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav', '.wma', '.aiff', '.aif'}
class RepairWorker:
"""Background worker for scanning and repairing library files.
Currently supports:
- Track number repair: fixes embedded tracknumber tags and filename
prefixes using the album tracklist from Spotify/iTunes as the
authoritative source.
Designed to be extended with additional repair types over time.
"""
def __init__(self, database: MusicDatabase, transfer_folder: str = None):
self.db = database
# Initial transfer folder (re-read from DB each scan cycle)
self.transfer_folder = transfer_folder or './Transfer'
# Worker state
self.running = False
self.paused = True
self.should_stop = False
self.thread = None
# Current item being processed (for UI tooltip)
self.current_item = None
# Statistics
self.stats = {
'scanned': 0,
'repaired': 0,
'skipped': 0,
'errors': 0,
'pending': 0
}
# How often to re-scan the full library (hours)
self.rescan_interval_hours = 24
# Album tracks cache: album_id -> list of track dicts
self._album_tracks_cache: Dict[str, List[Dict]] = {}
# Title matching threshold
self.title_similarity_threshold = 0.80
# SpotifyClient (lazy-init to avoid circular imports)
self._spotify_client = None
# MusicBrainzClient (lazy-init)
self._mb_client = None
# AudioDBClient (lazy-init)
self._audiodb_client = None
# Per-batch folder queues: batch_id -> set of folder paths
self._batch_folders: Dict[str, set] = {}
self._batch_folders_lock = threading.Lock()
# Known placeholder album IDs that are not real API identifiers
self._placeholder_ids = {
'wishlist_album', 'explicit_album', 'explicit_artist',
'unknown', 'none', 'null', '',
}
logger.info("Repair worker initialized (transfer_folder=%s)", self.transfer_folder)
# ------------------------------------------------------------------
# Lazy SpotifyClient accessor
# ------------------------------------------------------------------
@property
def spotify_client(self):
if self._spotify_client is None:
try:
from core.spotify_client import SpotifyClient
self._spotify_client = SpotifyClient()
except Exception as e:
logger.error("Failed to initialize SpotifyClient: %s", e)
return self._spotify_client
# ------------------------------------------------------------------
# Lazy client accessors
# ------------------------------------------------------------------
@property
def mb_client(self):
if self._mb_client is None:
try:
from core.musicbrainz_client import MusicBrainzClient
self._mb_client = MusicBrainzClient()
except Exception as e:
logger.error("Failed to initialize MusicBrainzClient: %s", e)
return self._mb_client
@property
def audiodb_client(self):
if self._audiodb_client is None:
try:
from core.audiodb_client import AudioDBClient
self._audiodb_client = AudioDBClient()
except Exception as e:
logger.error("Failed to initialize AudioDBClient: %s", e)
return self._audiodb_client
# ------------------------------------------------------------------
# Lifecycle (identical to AudioDB worker)
# ------------------------------------------------------------------
def start(self):
if self.running:
logger.warning("Repair worker already running")
return
self.running = True
self.should_stop = False
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
logger.info("Repair worker started")
def stop(self):
if not self.running:
return
logger.info("Stopping repair worker...")
self.should_stop = True
self.running = False
if self.thread:
self.thread.join(timeout=5)
logger.info("Repair worker stopped")
def pause(self):
if not self.running:
logger.warning("Repair worker not running, cannot pause")
return
self.paused = True
logger.info("Repair worker paused")
def resume(self):
if not self.running:
logger.warning("Repair worker not running, start it first")
return
self.paused = False
logger.info("Repair worker resumed")
def get_stats(self) -> Dict[str, Any]:
is_actually_running = self.running and (self.thread is not None and self.thread.is_alive())
is_idle = (
is_actually_running
and not self.paused
and self.stats['pending'] == 0
and self.current_item is None
)
return {
'enabled': True,
'running': is_actually_running and not self.paused,
'paused': self.paused,
'idle': is_idle,
'current_item': self.current_item,
'stats': self.stats.copy(),
'progress': self._get_progress()
}
# ------------------------------------------------------------------
# Main loop
# ------------------------------------------------------------------
def _run(self):
logger.info("Repair worker thread started")
while not self.should_stop:
try:
if self.paused:
time.sleep(1)
continue
self.current_item = None
# Reset stats for a new scan pass
self.stats = {
'scanned': 0,
'repaired': 0,
'skipped': 0,
'errors': 0,
'pending': 0
}
self._album_tracks_cache.clear()
self._scan_library()
# Done scanning — go idle until next interval
self.current_item = None
self.stats['pending'] = 0
logger.info(
"Repair scan complete. Scanned=%d Repaired=%d Skipped=%d Errors=%d",
self.stats['scanned'], self.stats['repaired'],
self.stats['skipped'], self.stats['errors']
)
# Sleep until next scan (check should_stop / paused periodically)
# Also re-scan immediately if transfer path changes
sleep_until = time.time() + self.rescan_interval_hours * 3600
last_path = self.transfer_folder
while time.time() < sleep_until and not self.should_stop:
if self.paused:
time.sleep(1)
continue
# Check if transfer path changed in settings
current_path = self._resolve_path(self._get_transfer_path_from_db())
if current_path != last_path:
logger.info("Transfer path changed: %s -> %s — triggering rescan", last_path, current_path)
self.transfer_folder = current_path
break
time.sleep(10)
except Exception as e:
logger.error("Error in repair worker loop: %s", e, exc_info=True)
time.sleep(30)
logger.info("Repair worker thread finished")
# ------------------------------------------------------------------
# Library scanning
# ------------------------------------------------------------------
@staticmethod
def _resolve_path(path_str: str) -> str:
"""Resolve Docker path mapping if running in a container."""
if os.path.exists('/.dockerenv') and len(path_str) >= 3 and path_str[1] == ':' and path_str[0].isalpha():
drive_letter = path_str[0].lower()
rest_of_path = path_str[2:].replace('\\', '/')
return f"/host/mnt/{drive_letter}{rest_of_path}"
return path_str
def _get_transfer_path_from_db(self) -> str:
"""Read transfer path directly from the database app_config."""
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT value FROM metadata WHERE key = 'app_config'")
row = cursor.fetchone()
if row and row[0]:
config = json.loads(row[0])
return config.get('soulseek', {}).get('transfer_path', './Transfer')
except Exception as e:
logger.error("Error reading transfer path from DB: %s", e)
finally:
if conn:
conn.close()
return './Transfer'
def _scan_library(self):
"""Walk the transfer folder and process album folders."""
# Re-read transfer path from DB each scan so changes take effect without restart
raw = self._get_transfer_path_from_db()
self.transfer_folder = self._resolve_path(raw)
transfer = self.transfer_folder
if not os.path.isdir(transfer):
logger.warning("Transfer folder does not exist: %s", transfer)
return
# Collect album folders (directories containing audio files)
album_folders: Dict[str, List[str]] = {}
for root, _dirs, files in os.walk(transfer):
if self.should_stop:
return
for fname in files:
ext = os.path.splitext(fname)[1].lower()
if ext in AUDIO_EXTENSIONS:
album_folders.setdefault(root, []).append(fname)
self.stats['pending'] = len(album_folders)
logger.info("Found %d album folders to scan", len(album_folders))
for folder_path, filenames in album_folders.items():
if self.should_stop:
return
if self.paused:
while self.paused and not self.should_stop:
time.sleep(1)
if self.should_stop:
return
folder_name = os.path.basename(folder_path)
self.current_item = {'type': 'album', 'name': folder_name}
try:
self._repair_album_track_numbers(folder_path, filenames)
except Exception as e:
logger.error("Error processing album folder %s: %s", folder_path, e, exc_info=True)
self.stats['errors'] += 1
self.stats['pending'] -= 1
time.sleep(1) # Rate limit for API calls
# ------------------------------------------------------------------
# On-demand single-folder scan (called from post-processing)
# ------------------------------------------------------------------
def register_folder(self, batch_id: str, folder_path: str):
"""Register an album folder for repair scanning when its batch completes.
Called during post-processing for each track. The actual scan is
deferred until process_batch() is called at batch completion.
"""
if not folder_path:
return
with self._batch_folders_lock:
self._batch_folders.setdefault(batch_id, set()).add(folder_path)
def process_batch(self, batch_id: str):
"""Scan all folders registered for a completed batch.
Called when a download modal/batch finishes all its tracks.
Runs in a background thread to avoid blocking the caller.
"""
with self._batch_folders_lock:
folders = self._batch_folders.pop(batch_id, set())
if not folders:
return
def _do_scan():
for folder_path in folders:
try:
if not os.path.isdir(folder_path):
continue
filenames = [
f for f in os.listdir(folder_path)
if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS
]
if not filenames:
continue
logger.info("[Repair] Batch %s scan: %s (%d files)",
batch_id, os.path.basename(folder_path), len(filenames))
self._repair_album_track_numbers(folder_path, filenames)
except Exception as e:
logger.error("[Repair] Error scanning %s for batch %s: %s",
folder_path, batch_id, e, exc_info=True)
threading.Thread(target=_do_scan, daemon=True).start()
# ------------------------------------------------------------------
# Album-level track number repair
# ------------------------------------------------------------------
def _repair_album_track_numbers(self, folder_path: str, filenames: List[str]):
"""Repair track numbers for all files in an album folder.
Targeting logic:
1. Read the track number tag from every file in the folder.
2. Count how many files share the same track number.
3. If 3+ files have the same track number, the album is flagged
as broken (the "all tracks = 01" bug pattern).
Threshold is 3 (not 2) because lossy-copy mode can legitimately
produce two files with the same track number in different qualities.
4. Only then do we spend an API call to get the correct tracklist
and repair each file.
"""
from mutagen import File as MutagenFile
# --- Step 0: Anomaly detection — are track numbers broken? ---
track_num_counts: Dict[int, int] = {} # track_number -> count of files with that number
file_track_data: List[Tuple[str, str, Optional[int]]] = [] # (path, filename, track_num)
for fname in filenames:
fpath = os.path.join(folder_path, fname)
try:
audio = MutagenFile(fpath)
if audio is None:
file_track_data.append((fpath, fname, None))
continue
track_num, _ = self._read_track_number_tag(audio)
file_track_data.append((fpath, fname, track_num))
if track_num is not None:
track_num_counts[track_num] = track_num_counts.get(track_num, 0) + 1
except Exception:
file_track_data.append((fpath, fname, None))
# Check if any single track number appears on 3+ files
has_anomaly = any(count >= 3 for count in track_num_counts.values())
if not has_anomaly:
# Album looks fine — count as scanned, no repair needed
self.stats['scanned'] += len(filenames)
return
# Log which track number(s) are duplicated
duped = {num: cnt for num, cnt in track_num_counts.items() if cnt >= 3}
logger.info(
"Anomaly detected in %s%d files share track number(s): %s",
os.path.basename(folder_path), sum(duped.values()), duped
)
# --- Step 1-2: Resolve album tracklist via cascading fallbacks ---
api_tracks = self._resolve_album_tracklist(file_track_data, folder_path)
if not api_tracks:
self.stats['skipped'] += len(filenames)
self.stats['scanned'] += len(filenames)
return
# --- Step 3-5: Process each file ---
for fpath, fname, _ in file_track_data:
if self.should_stop:
return
self.stats['scanned'] += 1
try:
self._repair_single_track(fpath, fname, api_tracks)
except Exception as e:
logger.error("Error repairing %s: %s", fpath, e, exc_info=True)
self.stats['errors'] += 1
def _repair_single_track(self, file_path: str, filename: str, api_tracks: List[Dict]):
"""Check and repair a single track's track number."""
from mutagen import File as MutagenFile
from mutagen.id3 import ID3, TRCK
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen.mp4 import MP4
# Rigid check: file must exist
if not os.path.isfile(file_path):
logger.debug("File missing: %s", file_path)
self.stats['skipped'] += 1
return
# Rigid check: must be audio
ext = os.path.splitext(filename)[1].lower()
if ext not in AUDIO_EXTENSIONS:
self.stats['skipped'] += 1
return
# Read current metadata
audio = MutagenFile(file_path)
if audio is None:
logger.debug("Mutagen cannot open: %s", file_path)
self.stats['skipped'] += 1
return
current_title = self._read_title_tag(audio)
current_track_num, current_total = self._read_track_number_tag(audio)
filename_track_num = self._extract_track_number_from_filename(filename)
if not current_title:
logger.debug("No title tag in %s — skipping", filename)
self.stats['skipped'] += 1
return
# Match against API tracklist
matched_track = self._match_title_to_api_track(current_title, api_tracks)
if not matched_track:
logger.debug("No API match for title '%s' in %s — skipping", current_title, filename)
self.stats['skipped'] += 1
return
correct_track_num = matched_track.get('track_number')
if correct_track_num is None:
logger.debug("API track has no track_number for '%s' — skipping", current_title)
self.stats['skipped'] += 1
return
# Compare
metadata_wrong = (current_track_num != correct_track_num)
filename_wrong = (filename_track_num is not None and filename_track_num != correct_track_num)
if not metadata_wrong and not filename_wrong:
# Everything correct
return
# Determine total_tracks for the tag
total_tracks = current_total or len(api_tracks)
logger.info(
"Repairing track: %s — correct=#%d, current_tag=#%s, current_filename=#%s",
filename, correct_track_num, current_track_num, filename_track_num
)
# Step 5a: Fix metadata tag
if metadata_wrong:
self._fix_track_number_tag(file_path, audio, correct_track_num, total_tracks)
# Step 5b: Fix filename
if filename_wrong:
new_path = self._fix_filename_track_number(file_path, filename, correct_track_num)
if new_path:
# Update DB file_path if tracked
self._update_db_file_path(file_path, new_path)
self.stats['repaired'] += 1
# ------------------------------------------------------------------
# Tag reading helpers
# ------------------------------------------------------------------
def _read_album_id_from_file(self, file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""Read SPOTIFY_ALBUM_ID or ITUNES_ALBUM_ID from embedded tags.
Returns (album_id, source) where source is 'spotify' or 'itunes'."""
try:
from mutagen import File as MutagenFile
from mutagen.id3 import ID3
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen.mp4 import MP4
audio = MutagenFile(file_path)
if audio is None:
return None, None
# MP3 (ID3) — TXXX frames
if hasattr(audio, 'tags') and audio.tags is not None:
if isinstance(audio.tags, ID3):
for key in ['TXXX:SPOTIFY_ALBUM_ID', 'TXXX:spotify_album_id']:
frame = audio.tags.getall(key)
if frame and frame[0].text:
return str(frame[0].text[0]), 'spotify'
for key in ['TXXX:ITUNES_ALBUM_ID', 'TXXX:itunes_album_id']:
frame = audio.tags.getall(key)
if frame and frame[0].text:
return str(frame[0].text[0]), 'itunes'
# FLAC / OggVorbis — VorbisComment (lowercase keys)
elif isinstance(audio, (FLAC, OggVorbis)):
for key in ['spotify_album_id', 'SPOTIFY_ALBUM_ID']:
val = audio.get(key)
if val:
return str(val[0]), 'spotify'
for key in ['itunes_album_id', 'ITUNES_ALBUM_ID']:
val = audio.get(key)
if val:
return str(val[0]), 'itunes'
# MP4/M4A — freeform tags
elif isinstance(audio, MP4):
for key in ['----:com.apple.iTunes:SPOTIFY_ALBUM_ID',
'----:com.apple.iTunes:spotify_album_id']:
val = audio.tags.get(key)
if val:
raw = val[0]
return raw.decode('utf-8') if isinstance(raw, bytes) else str(raw), 'spotify'
for key in ['----:com.apple.iTunes:ITUNES_ALBUM_ID',
'----:com.apple.iTunes:itunes_album_id']:
val = audio.tags.get(key)
if val:
raw = val[0]
return raw.decode('utf-8') if isinstance(raw, bytes) else str(raw), 'itunes'
except Exception as e:
logger.debug("Error reading album ID from %s: %s", file_path, e)
return None, None
def _is_valid_album_id(self, album_id: Optional[str]) -> bool:
"""Check if an album ID is a real API identifier, not a placeholder."""
if not album_id:
return False
if album_id.strip().lower() in self._placeholder_ids:
return False
if len(album_id.strip()) < 5:
return False
return True
def _read_spotify_track_id_from_file(self, file_path: str) -> Optional[str]:
"""Read SPOTIFY_TRACK_ID from embedded tags."""
try:
from mutagen import File as MutagenFile
from mutagen.id3 import ID3
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen.mp4 import MP4
audio = MutagenFile(file_path)
if audio is None:
return None
if hasattr(audio, 'tags') and audio.tags is not None:
if isinstance(audio.tags, ID3):
for key in ['TXXX:SPOTIFY_TRACK_ID', 'TXXX:spotify_track_id']:
frame = audio.tags.getall(key)
if frame and frame[0].text:
return str(frame[0].text[0])
elif isinstance(audio, (FLAC, OggVorbis)):
for key in ['spotify_track_id', 'SPOTIFY_TRACK_ID']:
val = audio.get(key)
if val:
return str(val[0])
elif isinstance(audio, MP4):
for key in ['----:com.apple.iTunes:SPOTIFY_TRACK_ID',
'----:com.apple.iTunes:spotify_track_id']:
val = audio.tags.get(key)
if val:
raw = val[0]
return raw.decode('utf-8') if isinstance(raw, bytes) else str(raw)
except Exception as e:
logger.debug("Error reading Spotify track ID from %s: %s", file_path, e)
return None
def _read_musicbrainz_album_id_from_file(self, file_path: str) -> Optional[str]:
"""Read MusicBrainz Album Id (release MBID) from embedded tags."""
try:
from mutagen import File as MutagenFile
from mutagen.id3 import ID3
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen.mp4 import MP4
audio = MutagenFile(file_path)
if audio is None:
return None
if hasattr(audio, 'tags') and audio.tags is not None:
if isinstance(audio.tags, ID3):
for key in ['TXXX:MusicBrainz Album Id', 'TXXX:MUSICBRAINZ_ALBUMID',
'TXXX:musicbrainz_albumid']:
frame = audio.tags.getall(key)
if frame and frame[0].text:
return str(frame[0].text[0])
elif isinstance(audio, (FLAC, OggVorbis)):
for key in ['musicbrainz_albumid', 'MUSICBRAINZ_ALBUMID',
'MusicBrainz Album Id']:
val = audio.get(key)
if val:
return str(val[0])
elif isinstance(audio, MP4):
for key in ['----:com.apple.iTunes:MusicBrainz Album Id',
'----:com.apple.iTunes:MUSICBRAINZ_ALBUMID',
'----:com.apple.music.albums:MUSICBRAINZ_ALBUMID']:
val = audio.tags.get(key)
if val:
raw = val[0]
return raw.decode('utf-8') if isinstance(raw, bytes) else str(raw)
except Exception as e:
logger.debug("Error reading MusicBrainz album ID from %s: %s", file_path, e)
return None
def _read_album_artist_from_file(self, file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""Read album name and artist name from embedded tags.
Returns (album_name, artist_name)."""
try:
from mutagen import File as MutagenFile
from mutagen.id3 import ID3
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen.mp4 import MP4
audio = MutagenFile(file_path)
if audio is None:
return None, None
album_name = None
artist_name = None
if hasattr(audio, 'tags') and audio.tags is not None:
if isinstance(audio.tags, ID3):
frames = audio.tags.getall('TALB')
if frames and frames[0].text:
album_name = str(frames[0].text[0])
# Prefer album artist (TPE2), fall back to track artist (TPE1)
for tag in ['TPE2', 'TPE1']:
frames = audio.tags.getall(tag)
if frames and frames[0].text:
artist_name = str(frames[0].text[0])
break
elif isinstance(audio, (FLAC, OggVorbis)):
val = audio.get('album')
if val:
album_name = str(val[0])
for key in ['albumartist', 'artist']:
val = audio.get(key)
if val:
artist_name = str(val[0])
break
elif isinstance(audio, MP4):
val = audio.tags.get('\xa9alb')
if val:
album_name = str(val[0])
for key in ['aART', '\xa9ART']:
val = audio.tags.get(key)
if val:
artist_name = str(val[0])
break
return album_name, artist_name
except Exception as e:
logger.debug("Error reading album/artist from %s: %s", file_path, e)
return None, None
def _read_title_tag(self, audio) -> Optional[str]:
"""Read the title tag from an already-opened Mutagen file."""
from mutagen.id3 import ID3
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen.mp4 import MP4
try:
if hasattr(audio, 'tags') and audio.tags is not None:
if isinstance(audio.tags, ID3):
frames = audio.tags.getall('TIT2')
if frames and frames[0].text:
return str(frames[0].text[0])
elif isinstance(audio, (FLAC, OggVorbis)):
val = audio.get('title')
if val:
return str(val[0])
elif isinstance(audio, MP4):
val = audio.tags.get('\xa9nam')
if val:
return str(val[0])
except Exception as e:
logger.debug("Error reading title tag: %s", e)
return None
def _read_track_number_tag(self, audio) -> Tuple[Optional[int], Optional[int]]:
"""Read track number and total from tags. Returns (track_num, total)."""
from mutagen.id3 import ID3
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen.mp4 import MP4
try:
if hasattr(audio, 'tags') and audio.tags is not None:
if isinstance(audio.tags, ID3):
frames = audio.tags.getall('TRCK')
if frames and frames[0].text:
return self._parse_track_str(str(frames[0].text[0]))
elif isinstance(audio, (FLAC, OggVorbis)):
val = audio.get('tracknumber')
if val:
return self._parse_track_str(str(val[0]))
elif isinstance(audio, MP4):
val = audio.tags.get('trkn')
if val and val[0]:
t = val[0]
return (int(t[0]), int(t[1]) if t[1] else None)
except Exception as e:
logger.debug("Error reading track number tag: %s", e)
return None, None
@staticmethod
def _parse_track_str(s: str) -> Tuple[Optional[int], Optional[int]]:
"""Parse '5/12' or '5' into (track_num, total)."""
try:
if '/' in s:
parts = s.split('/')
return int(parts[0]), int(parts[1])
return int(s), None
except (ValueError, IndexError):
return None, None
@staticmethod
def _extract_track_number_from_filename(filename: str) -> Optional[int]:
"""Extract leading track number from filename like '01 - Song.flac'."""
basename = os.path.splitext(filename)[0]
match = re.match(r'^(\d{1,3})', basename.strip())
if match:
return int(match.group(1))
return None
# ------------------------------------------------------------------
# API lookup
# ------------------------------------------------------------------
def _get_album_tracklist(self, album_id: str) -> Optional[List[Dict]]:
"""Fetch album tracks from Spotify/iTunes API with caching."""
if album_id in self._album_tracks_cache:
return self._album_tracks_cache[album_id]
client = self.spotify_client
if not client:
logger.warning("No SpotifyClient available for album lookup")
return None
try:
result = client.get_album_tracks(album_id)
if not result or 'items' not in result:
logger.debug("No tracks returned for album %s", album_id)
self._album_tracks_cache[album_id] = None
return None
tracks = []
for item in result['items']:
tracks.append({
'name': item.get('name', ''),
'track_number': item.get('track_number'),
'disc_number': item.get('disc_number', 1),
})
self._album_tracks_cache[album_id] = tracks
return tracks
except Exception as e:
logger.error("Error fetching album tracks for %s: %s", album_id, e)
return None
def _get_tracklist_from_musicbrainz(self, mbid: str) -> Optional[List[Dict]]:
"""Fetch album tracklist from MusicBrainz by release MBID.
Returns list of dicts with {name, track_number, disc_number} matching
the Spotify tracklist format, or None on failure."""
cache_key = f"mb:{mbid}"
if cache_key in self._album_tracks_cache:
return self._album_tracks_cache[cache_key]
client = self.mb_client
if not client:
logger.warning("No MusicBrainzClient available for release lookup")
return None
try:
release = client.get_release(mbid, includes=['recordings'])
if not release or 'media' not in release:
logger.debug("No media returned for MusicBrainz release %s", mbid)
self._album_tracks_cache[cache_key] = None
return None
tracks = []
for medium in release['media']:
disc_num = medium.get('position', 1)
for track in medium.get('tracks', []):
tracks.append({
'name': track.get('title', ''),
'track_number': track.get('position'),
'disc_number': disc_num,
})
if not tracks:
self._album_tracks_cache[cache_key] = None
return None
self._album_tracks_cache[cache_key] = tracks
return tracks
except Exception as e:
logger.error("Error fetching MusicBrainz release %s: %s", mbid, e)
return None
def _get_musicbrainz_id_via_audiodb(self, artist_name: str, album_name: str) -> Optional[str]:
"""Search AudioDB for an album and extract its MusicBrainz release ID."""
client = self.audiodb_client
if not client:
return None
try:
result = client.search_album(artist_name, album_name)
if result:
mb_id = result.get('strMusicBrainzAlbumID')
if mb_id and mb_id.strip():
logger.debug("AudioDB returned MusicBrainz ID %s for '%s - %s'",
mb_id, artist_name, album_name)
return mb_id.strip()
except Exception as e:
logger.debug("AudioDB lookup failed for '%s - %s': %s", artist_name, album_name, e)
return None
def _resolve_album_tracklist(self, file_track_data: List[Tuple[str, str, Optional[int]]],
folder_path: str) -> Optional[List[Dict]]:
"""Cascading resolution to find the correct album tracklist.
Tries in order:
1. SPOTIFY_ALBUM_ID from tags (skip if placeholder)
2. ITUNES_ALBUM_ID from tags
3. SPOTIFY_TRACK_ID → get_track_details() → real album ID (requires Spotify auth)
4. Search Spotify/iTunes by album name + artist
5. MusicBrainz Album Id from tags → MusicBrainz release lookup
6. AudioDB search → MusicBrainz album ID → MusicBrainz release
"""
folder_name = os.path.basename(folder_path)
# --- Collect all available IDs from files in one pass ---
spotify_album_id = None
itunes_album_id = None
spotify_track_id = None
mb_album_id = None
album_name = None
artist_name = None
for fpath, fname, _ in file_track_data:
# Album IDs (split Spotify and iTunes)
if not spotify_album_id or not itunes_album_id:
aid, source = self._read_album_id_from_file(fpath)
if aid and source == 'spotify' and not spotify_album_id:
spotify_album_id = aid
elif aid and source == 'itunes' and not itunes_album_id:
itunes_album_id = aid
# Spotify track ID
if not spotify_track_id:
spotify_track_id = self._read_spotify_track_id_from_file(fpath)
# MusicBrainz album ID
if not mb_album_id:
mb_album_id = self._read_musicbrainz_album_id_from_file(fpath)
# Album name + artist
if not album_name:
album_name, artist_name = self._read_album_artist_from_file(fpath)
# Stop early if we have everything
if (spotify_album_id and itunes_album_id and spotify_track_id
and mb_album_id and album_name):
break
# --- Fallback 1: Spotify album ID ---
if spotify_album_id and self._is_valid_album_id(spotify_album_id):
tracks = self._get_album_tracklist(spotify_album_id)
if tracks:
logger.info("[Repair] %s — resolved via Spotify album ID: %s", folder_name, spotify_album_id)
return tracks
# --- Fallback 2: iTunes album ID ---
if itunes_album_id and self._is_valid_album_id(itunes_album_id):
tracks = self._get_album_tracklist(itunes_album_id)
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 (requires Spotify auth) ---
client = self.spotify_client
if spotify_track_id and client and client.is_spotify_authenticated():
try:
track_details = client.get_track_details(spotify_track_id)
if track_details and track_details.get('album', {}).get('id'):
real_album_id = track_details['album']['id']
tracks = self._get_album_tracklist(real_album_id)
if tracks:
logger.info("[Repair] %s — resolved via Spotify track ID %s → album %s",
folder_name, spotify_track_id, real_album_id)
return tracks
except Exception as e:
logger.debug("Spotify track lookup failed for %s: %s", spotify_track_id, e)
# --- Fallback 4: Search Spotify/iTunes by album name + artist ---
if album_name and client:
try:
query = f"{artist_name} {album_name}" if artist_name else album_name
results = client.search_albums(query, limit=5)
if results:
# Pick the first result (best match from API)
best = results[0]
tracks = self._get_album_tracklist(best.id)
if tracks:
logger.info("[Repair] %s — resolved via album search: '%s'%s",
folder_name, query, best.id)
return tracks
except Exception as e:
logger.debug("Album search failed for '%s': %s", album_name, e)
# --- Fallback 5: MusicBrainz album ID from tags ---
if mb_album_id:
tracks = self._get_tracklist_from_musicbrainz(mb_album_id)
if tracks:
logger.info("[Repair] %s — resolved via MusicBrainz album ID: %s", folder_name, mb_album_id)
return tracks
# --- Fallback 6: AudioDB → MusicBrainz ---
if album_name and artist_name:
adb_mb_id = self._get_musicbrainz_id_via_audiodb(artist_name, album_name)
if adb_mb_id and adb_mb_id != mb_album_id: # Don't retry same MBID
tracks = self._get_tracklist_from_musicbrainz(adb_mb_id)
if tracks:
logger.info("[Repair] %s — resolved via AudioDB → MusicBrainz: %s",
folder_name, adb_mb_id)
return tracks
logger.warning("[Repair] %s — all tracklist resolution strategies exhausted", folder_name)
return None
# ------------------------------------------------------------------
# Title matching
# ------------------------------------------------------------------
def _match_title_to_api_track(self, file_title: str, api_tracks: List[Dict]) -> Optional[Dict]:
"""Fuzzy-match a file title to an API track. Returns the best match or None."""
norm_file = self._normalize_title(file_title)
best_match = None
best_score = 0.0
for track in api_tracks:
api_name = track.get('name', '')
norm_api = self._normalize_title(api_name)
score = SequenceMatcher(None, norm_file, norm_api).ratio()
if score > best_score:
best_score = score
best_match = track
if best_score >= self.title_similarity_threshold:
return best_match
return None
@staticmethod
def _normalize_title(title: str) -> str:
"""Normalize a title for comparison: lowercase, strip parentheticals, punctuation."""
t = title.lower()
t = re.sub(r'\(.*?\)', '', t)
t = re.sub(r'\[.*?\]', '', t)
t = re.sub(r'[^a-z0-9 ]', '', t)
return t.strip()
# ------------------------------------------------------------------
# Repair actions
# ------------------------------------------------------------------
def _fix_track_number_tag(self, file_path: str, audio, correct_num: int, total: int):
"""Update ONLY the track number tag in the file. Touches nothing else."""
from mutagen import File as MutagenFile
from mutagen.id3 import ID3, TRCK
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen.mp4 import MP4
try:
# Re-open file fresh to avoid stale state
audio = MutagenFile(file_path)
if audio is None:
logger.error("Cannot re-open file for tag fix: %s", file_path)
return
track_str = f"{correct_num}/{total}"
if isinstance(audio.tags, ID3):
# Remove existing TRCK, add new one
audio.tags.delall('TRCK')
audio.tags.add(TRCK(encoding=3, text=[track_str]))
audio.save(v1=0, v2_version=4)
elif isinstance(audio, (FLAC, OggVorbis)):
audio['tracknumber'] = [track_str]
if isinstance(audio, FLAC):
audio.save(deleteid3=True)
else:
audio.save()
elif isinstance(audio, MP4):
audio['trkn'] = [(correct_num, total)]
audio.save()
logger.info("Fixed track tag: %s%s", os.path.basename(file_path), track_str)
except Exception as e:
logger.error("Error fixing track tag in %s: %s", file_path, e, exc_info=True)
self.stats['errors'] += 1
def _fix_filename_track_number(self, file_path: str, filename: str, correct_num: int) -> Optional[str]:
"""Fix the track number prefix in a filename. Returns new path or None."""
try:
basename = os.path.splitext(filename)[0]
ext = os.path.splitext(filename)[1]
# Replace leading digits
new_basename = re.sub(r'^\d{1,3}', f'{correct_num:02d}', basename)
if new_basename == basename:
# No change needed (shouldn't happen if filename_wrong was True)
return None
new_filename = new_basename + ext
parent_dir = os.path.dirname(file_path)
new_path = os.path.join(parent_dir, new_filename)
# Rigid checks
if not os.path.isfile(file_path):
logger.error("Source file disappeared before rename: %s", file_path)
return None
if os.path.exists(new_path):
logger.warning("Target path already exists, skipping rename: %s", new_path)
self.stats['skipped'] += 1
return None
os.rename(file_path, new_path)
logger.info("Renamed: %s%s", filename, new_filename)
# Rename associated .lrc file if it exists
lrc_path = os.path.join(parent_dir, basename + '.lrc')
if os.path.isfile(lrc_path):
new_lrc_path = os.path.join(parent_dir, new_basename + '.lrc')
if not os.path.exists(new_lrc_path):
os.rename(lrc_path, new_lrc_path)
logger.info("Renamed LRC: %s.lrc → %s.lrc", basename, new_basename)
return new_path
except Exception as e:
logger.error("Error renaming %s: %s", file_path, e, exc_info=True)
self.stats['errors'] += 1
return None
# ------------------------------------------------------------------
# DB helpers
# ------------------------------------------------------------------
def _update_db_file_path(self, old_path: str, new_path: str):
"""Update file_path in tracks table if this track is tracked."""
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute(
"UPDATE tracks SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE file_path = ?",
(new_path, old_path)
)
if cursor.rowcount > 0:
conn.commit()
logger.debug("Updated DB file_path: %s%s", old_path, new_path)
else:
conn.commit()
except Exception as e:
logger.debug("Error updating DB file_path: %s", e)
finally:
if conn:
conn.close()
# ------------------------------------------------------------------
# Progress
# ------------------------------------------------------------------
def _get_progress(self) -> Dict[str, Any]:
total = self.stats['scanned'] + self.stats['pending']
percent = round(self.stats['scanned'] / total * 100) if total > 0 else 0
return {
'tracks': {
'total': total,
'checked': self.stats['scanned'],
'repaired': self.stats['repaired'],
'ok': self.stats['scanned'] - self.stats['repaired'] - self.stats['skipped'] - self.stats['errors'],
'skipped': self.stats['skipped'],
'percent': percent
}
}