"""Unknown Artist Fixer Job — finds tracks tagged as 'Unknown Artist' and corrects metadata. Resolves the correct artist/album/track metadata from file tags or metadata API, re-tags the audio file, moves it to the correct folder, and updates the database. """ import os import re import shutil import sys import uuid from core.metadata_service import get_client_for_source, get_primary_source, get_source_priority from core.repair_jobs import register_job from core.repair_jobs.base import JobContext, JobResult, RepairJob from utils.logging_config import get_logger logger = get_logger("repair_job.unknown_artist_fixer") _UNKNOWN_NAMES = {'unknown artist', 'unknown', ''} _TRACK_ID_SOURCES = {'spotify', 'deezer', 'itunes'} _TITLE_SEARCH_SOURCES = {'spotify', 'deezer', 'itunes', 'hydrabase'} # Sidecar extensions to move alongside audio files _SIDECAR_EXTS = {'.lrc', '.jpg', '.jpeg', '.png', '.nfo', '.txt', '.cue'} @register_job class UnknownArtistFixerJob(RepairJob): job_id = 'unknown_artist_fixer' display_name = 'Fix Unknown Artists' description = 'Finds tracks tagged as "Unknown Artist" and corrects metadata, tags, and file paths' help_text = ( 'Scans your library for tracks filed under "Unknown Artist" — a common result of ' 'incomplete metadata during playlist pipeline downloads.\n\n' 'For each affected track, the job resolves the correct artist, album, and track number by:\n' '1. Reading embedded file tags (if the file itself has correct metadata)\n' '2. Looking up the track by ID on your configured metadata source\n' '3. Searching by track title as a last resort\n\n' 'When a match is found, the job can re-tag the file, move it to the correct folder, ' 'and update the database.\n\n' 'Settings:\n' '- Dry Run: Preview changes without applying them (default: on)\n' '- Fix file tags: Write corrected metadata to audio file tags\n' '- Reorganize files: Move files to the correct folder structure' ) icon = 'repair-icon-artist' default_enabled = False default_interval_hours = 168 # Weekly default_settings = { 'dry_run': True, 'fix_tags': True, 'reorganize_files': True, } auto_fix = True def estimate_scope(self, context: JobContext) -> int: try: conn = context.db._get_connection() try: cursor = conn.cursor() cursor.execute(""" SELECT COUNT(*) FROM tracks t JOIN artists ar ON ar.id = t.artist_id WHERE LOWER(TRIM(ar.name)) IN ('unknown artist', 'unknown', '') AND t.file_path IS NOT NULL AND t.file_path != '' """) return cursor.fetchone()[0] finally: conn.close() except Exception: return 0 def scan(self, context: JobContext) -> JobResult: result = JobResult() settings = self._get_settings(context) dry_run = settings.get('dry_run', True) fix_tags = settings.get('fix_tags', True) reorganize_files = settings.get('reorganize_files', True) mode_label = 'DRY RUN' if dry_run else 'LIVE' if context.report_progress: context.report_progress(phase=f'Scanning ({mode_label})...', log_line=f'Mode: {mode_label}', log_type='info') # Query all tracks under Unknown Artist conn = context.db._get_connection() try: cursor = conn.cursor() cursor.execute(""" SELECT t.id, t.title, t.file_path, t.track_number, t.duration, ar.id as artist_id, ar.name as artist_name, al.id as album_id, al.title as album_title, al.year, al.thumb_url as album_thumb, t.spotify_track_id, t.itunes_track_id, t.deezer_id FROM tracks t JOIN artists ar ON ar.id = t.artist_id JOIN albums al ON al.id = t.album_id WHERE LOWER(TRIM(ar.name)) IN ('unknown artist', 'unknown', '') AND t.file_path IS NOT NULL AND t.file_path != '' ORDER BY al.title, t.track_number LIMIT 500 """) tracks = [dict(row) for row in cursor.fetchall()] finally: conn.close() total = len(tracks) if total == 0: if context.report_progress: context.report_progress(phase='No Unknown Artist tracks found', log_line='No tracks to fix', log_type='success') return result if context.report_progress: context.report_progress(phase=f'Found {total} Unknown Artist tracks', total=total, log_line=f'Processing {total} tracks...', log_type='info') # Get file path templates for reorganization transfer = context.transfer_folder templates = {} if context.config_manager: templates = context.config_manager.get('file_organization.templates', {}) album_template = templates.get('album_path', '$albumartist/$albumartist - $album/$track - $title') for i, track in enumerate(tracks): if context.check_stop(): return result if i % 20 == 0 and context.wait_if_paused(): return result result.scanned += 1 track_id = track['id'] title = track['title'] or '' file_path = track['file_path'] # Resolve actual file on disk via the shared library resolver # (picks up library.music_paths + Plex library locations). from core.library.path_resolver import resolve_library_file_path resolved = resolve_library_file_path( file_path, transfer_folder=transfer, config_manager=context.config_manager, ) if not resolved or not os.path.exists(resolved): result.skipped += 1 continue # Try to resolve correct metadata corrected = self._resolve_metadata(context, track, resolved) if not corrected: result.skipped += 1 if context.report_progress: context.report_progress( scanned=i + 1, total=total, log_line=f'Could not resolve: {title}', log_type='warning') continue # Compute expected file path expected_rel = None if reorganize_files and corrected.get('artist') and corrected.get('album'): # Issue #646: `core.repair_jobs.library_reorganize` was # rewritten in commit ca5c9316 ("Rewrite Library # Reorganize job to delegate to per-album planner") and # the private `_build_path_from_template` / # `_get_audio_quality` helpers moved out. They live in # the import pipeline now — same shape, different # module. Without this re-wire the Unknown Artist Fixer # crashes on first scan with `ImportError: cannot # import name '_build_path_from_template'`. from core.imports.paths import get_file_path_from_template_raw from core.imports.file_ops import get_audio_quality_string quality = get_audio_quality_string(resolved) tmpl_ctx = { 'artist': corrected['artist'], 'albumartist': corrected['artist'], 'album': corrected['album'], 'title': corrected.get('title', title), 'track_number': corrected.get('track_number', 1), 'disc_number': corrected.get('disc_number', 1), 'year': corrected.get('year', ''), 'quality': quality, 'albumtype': 'Album', } folder, fname_base = get_file_path_from_template_raw(album_template, tmpl_ctx) file_ext = os.path.splitext(resolved)[1] if quality and f'[{quality}]' not in fname_base: fname_base = f"{fname_base} [{quality}]" expected_rel = os.path.join(folder, fname_base + file_ext) if dry_run: # Create finding for review desc_parts = [f'Artist: Unknown Artist → {corrected["artist"]}'] if corrected.get('album'): desc_parts.append(f'Album: {track.get("album_title", "?")} → {corrected["album"]}') if corrected.get('track_number'): desc_parts.append(f'Track #: {track.get("track_number", "?")} → {corrected["track_number"]}') if expected_rel: desc_parts.append(f'Path: → {expected_rel}') if context.create_finding: inserted = context.create_finding( job_id=self.job_id, finding_type='unknown_artist', severity='warning', entity_type='track', entity_id=str(track_id), file_path=file_path, title=f'{corrected["artist"]} - {corrected.get("title", title)}', description='\n'.join(desc_parts), details={ 'track_id': track_id, 'artist_id': track['artist_id'], 'album_id': track['album_id'], 'current_artist': track['artist_name'], 'corrected_artist': corrected['artist'], 'corrected_album': corrected.get('album', ''), 'corrected_track_number': corrected.get('track_number'), 'corrected_year': corrected.get('year', ''), 'corrected_title': corrected.get('title', title), 'source': corrected.get('source', ''), 'confidence': corrected.get('confidence', 0), 'file_path': resolved, 'expected_path': expected_rel, 'album_thumb_url': corrected.get('image_url') or track.get('album_thumb'), 'cover_url': corrected.get('image_url', ''), } ) if inserted: result.findings_created += 1 else: result.findings_skipped_dedup += 1 else: # Live mode — apply fix try: fixed = self._apply_fix(context, track, corrected, resolved, expected_rel, transfer, fix_tags, reorganize_files) if fixed: result.auto_fixed += 1 else: result.errors += 1 except Exception as e: logger.error(f"Failed to fix track {track_id}: {e}") result.errors += 1 if context.report_progress: context.report_progress( scanned=i + 1, total=total, log_line=f'{"[Preview]" if dry_run else "[Fixed]"} {corrected["artist"]} - {corrected.get("title", title)}', log_type='info' if dry_run else 'success') if context.report_progress: if dry_run: context.report_progress( phase=f'Preview complete — {result.findings_created} fixable tracks', log_line=f'Done: {result.findings_created} can be fixed, {result.skipped} unresolvable', log_type='success') else: context.report_progress( phase=f'Fixed {result.auto_fixed} tracks', log_line=f'Done: {result.auto_fixed} fixed, {result.errors} errors, {result.skipped} skipped', log_type='success') return result def _resolve_metadata(self, context, track, resolved_path): """Try to resolve correct metadata for an Unknown Artist track. Returns dict with artist, album, track_number, year, etc. or None.""" title = track['title'] or '' primary_source = get_primary_source() # Priority 1: Read embedded file tags try: from core.tag_writer import read_file_tags tags = read_file_tags(resolved_path) tag_artist = tags.get('artist') or tags.get('album_artist') if tag_artist and tag_artist.strip().lower() not in _UNKNOWN_NAMES: return { 'artist': tag_artist.strip(), 'album': (tags.get('album') or '').strip() or track.get('album_title', ''), 'title': (tags.get('title') or '').strip() or title, 'track_number': tags.get('track_number') or track.get('track_number'), 'disc_number': tags.get('disc_number') or 1, 'year': (tags.get('year') or '').strip(), 'source': 'file_tags', 'confidence': 1.0, } except Exception as e: logger.debug(f"Failed to read tags from {resolved_path}: {e}") # Priority 2: Look up by source track ID for source, source_id in self._iter_source_track_ids(track, primary_source): client = get_client_for_source(source) if not client or not hasattr(client, 'get_track_details'): continue try: details = client.get_track_details(str(source_id)) corrected = self._build_corrected_metadata( details, fallback_title=title, source=f"{source}_track_id_lookup", confidence=0.95, ) if corrected: return corrected except Exception as e: logger.debug(f"Track ID lookup failed for {source} {source_id}: {e}") # Priority 3: Search by title if title: for source in self._iter_source_priority(primary_source, _TITLE_SEARCH_SOURCES): client = get_client_for_source(source) if not client or not hasattr(client, 'search_tracks'): continue try: results = client.search_tracks(title, limit=5) if not results: continue best, best_score = self._pick_best_track_candidate(title, track.get('album_title'), results) if not best or best_score < 0.7: continue full_details = None if hasattr(client, 'get_track_details') and getattr(best, 'id', None): try: full_details = client.get_track_details(str(best.id)) except Exception: full_details = None corrected = self._build_corrected_metadata( full_details or best, fallback_title=title, source=f"{source}_title_search", confidence=round(best_score, 3), ) if corrected: return corrected except Exception as e: logger.debug(f"Title search failed for '{title}' via {source}: {e}") # Rate limit courtesy if context.sleep_or_stop(0.2): return None return None @staticmethod def _get_track_value(payload, key, default=None): if isinstance(payload, dict): return payload.get(key, default) return getattr(payload, key, default) def _iter_source_track_ids(self, track: dict, primary_source: str): source_fields = { 'spotify': 'spotify_track_id', 'deezer': 'deezer_id', 'itunes': 'itunes_track_id', } ordered_sources = [source for source in self._iter_source_priority(primary_source, _TRACK_ID_SOURCES) if source in source_fields] for source in ordered_sources: source_id = track.get(source_fields[source]) if source_id: yield source, source_id @staticmethod def _iter_source_priority(primary_source: str, allowed_sources: set[str]): return [source for source in get_source_priority(primary_source) if source in allowed_sources] def _pick_best_track_candidate(self, title: str, album_title: str, results): from difflib import SequenceMatcher best = None best_score = 0.0 title_lower = title.lower() album_lower = album_title.lower() if album_title else '' for candidate in results: candidate_name = self._get_track_value(candidate, 'name', '') or '' if not candidate_name: continue name_sim = SequenceMatcher(None, title_lower, candidate_name.lower()).ratio() candidate_album = self._get_track_value(candidate, 'album', '') or '' if album_lower and candidate_album: if isinstance(candidate_album, dict): candidate_album = candidate_album.get('name') or candidate_album.get('title') or '' album_sim = SequenceMatcher(None, album_lower, str(candidate_album).lower()).ratio() name_sim = (name_sim * 0.7) + (album_sim * 0.3) if name_sim > best_score: best_score = name_sim best = candidate return best, best_score def _build_corrected_metadata(self, payload, fallback_title: str, source: str, confidence: float): if not payload: return None artist = self._get_track_value(payload, 'primary_artist', '') or '' artists = self._get_track_value(payload, 'artists', []) or [] if not artist and artists: if isinstance(artists, list): first_artist = artists[0] if isinstance(first_artist, dict): artist = first_artist.get('name', '') else: artist = str(first_artist) artist = (artist or '').strip() if not artist or artist.lower() in _UNKNOWN_NAMES: return None album = self._get_track_value(payload, 'album', {}) or {} if isinstance(album, dict): album_name = album.get('name', '') or album.get('title', '') or '' year = (album.get('release_date', '') or '')[:4] image_url = '' images = album.get('images') or [] if images: first_image = images[0] if isinstance(first_image, dict): image_url = first_image.get('url', '') or '' else: album_name = str(album) year = '' image_url = '' image_url = self._get_track_value(payload, 'image_url', image_url) or image_url title = self._get_track_value(payload, 'name', fallback_title) or fallback_title return { 'artist': artist, 'album': album_name, 'title': title, 'track_number': self._get_track_value(payload, 'track_number'), 'disc_number': self._get_track_value(payload, 'disc_number', 1) or 1, 'year': year, 'image_url': image_url, 'source': source, 'confidence': confidence, } def _apply_fix(self, context, track, corrected, resolved_path, expected_rel, transfer, fix_tags, reorganize_files): """Apply the fix: re-tag file, move to correct path, update DB.""" track_id = track['id'] # Step 1: Write corrected tags to file if fix_tags: try: from core.tag_writer import write_tags_to_file db_data = { 'title': corrected.get('title', track['title']), 'artist_name': corrected['artist'], 'album_title': corrected.get('album', ''), 'year': corrected.get('year', ''), 'track_number': corrected.get('track_number'), 'disc_number': corrected.get('disc_number', 1), } tag_result = write_tags_to_file( resolved_path, db_data, embed_cover=True, cover_url=corrected.get('image_url') or None ) if tag_result.get('success'): logger.info(f"Re-tagged: {corrected['artist']} - {corrected.get('title', track['title'])}") else: logger.warning(f"Tag write failed for track {track_id}: {tag_result.get('error')}") except Exception as e: logger.error(f"Tag write error for track {track_id}: {e}") # Step 2: Move file to correct location final_path = resolved_path if reorganize_files and expected_rel: expected_abs = os.path.normpath(os.path.join(transfer, expected_rel)) current_norm = os.path.normpath(resolved_path) if current_norm.lower() != expected_abs.lower(): try: os.makedirs(os.path.dirname(expected_abs), exist_ok=True) # Handle case rename on case-insensitive FS if sys.platform in ('win32', 'darwin') and os.path.exists(expected_abs): tmp = expected_abs + '.tmp_rename' shutil.move(current_norm, tmp) shutil.move(tmp, expected_abs) else: shutil.move(current_norm, expected_abs) final_path = expected_abs logger.info(f"Moved: {os.path.basename(current_norm)} → {expected_rel}") # Move sidecars src_dir = os.path.dirname(current_norm) dst_dir = os.path.dirname(expected_abs) src_stem = os.path.splitext(os.path.basename(current_norm))[0] dst_stem = os.path.splitext(os.path.basename(expected_abs))[0] for ext in _SIDECAR_EXTS: sidecar_src = os.path.join(src_dir, src_stem + ext) if os.path.isfile(sidecar_src): sidecar_dst = os.path.join(dst_dir, dst_stem + ext) if not os.path.exists(sidecar_dst): try: shutil.move(sidecar_src, sidecar_dst) except Exception as e: logger.debug("move sidecar file: %s", e) # Also move cover.jpg from old album folder cover_src = os.path.join(src_dir, 'cover.jpg') cover_dst = os.path.join(dst_dir, 'cover.jpg') if os.path.isfile(cover_src) and not os.path.exists(cover_dst): try: shutil.copy2(cover_src, cover_dst) except Exception as e: logger.debug("copy cover.jpg: %s", e) # Clean up empty directories parent = os.path.dirname(current_norm) transfer_norm = os.path.normpath(transfer) for _ in range(5): if (parent and os.path.isdir(parent) and os.path.normpath(parent) != transfer_norm and not os.listdir(parent)): os.rmdir(parent) parent = os.path.dirname(parent) else: break except Exception as e: logger.error(f"File move failed for track {track_id}: {e}") # Continue with DB update even if move failed # Step 3: Update database try: conn = context.db._get_connection() try: cursor = conn.cursor() # Find or create the correct artist corrected_artist = corrected['artist'] cursor.execute("SELECT id FROM artists WHERE LOWER(name) = LOWER(?)", (corrected_artist,)) artist_row = cursor.fetchone() if artist_row: new_artist_id = artist_row[0] else: safe_artist_name = re.sub( r'[^A-Za-z0-9_.-]+', '_', corrected_artist.strip() or 'unknown' ) new_artist_id = f"artist_local_{safe_artist_name}_{uuid.uuid4().hex[:8]}" cursor.execute( "INSERT INTO artists (id, name) VALUES (?, ?)", (new_artist_id, corrected_artist), ) # Update track's artist_id and file_path cursor.execute(""" UPDATE tracks SET artist_id = ?, file_path = ? WHERE id = ? """, (new_artist_id, final_path, track_id)) # Update track_number if we have it if corrected.get('track_number'): cursor.execute("UPDATE tracks SET track_number = ? WHERE id = ?", (corrected['track_number'], track_id)) # Update album title if corrected if corrected.get('album') and corrected['album'] != track.get('album_title'): cursor.execute("UPDATE albums SET title = ? WHERE id = ?", (corrected['album'], track['album_id'])) # Update album year if we have it if corrected.get('year') and corrected['year'].isdigit(): cursor.execute("UPDATE albums SET year = ? WHERE id = ?", (int(corrected['year']), track['album_id'])) # Update album artist_id to match cursor.execute("UPDATE albums SET artist_id = ? WHERE id = ?", (new_artist_id, track['album_id'])) conn.commit() logger.info(f"DB updated: track {track_id} → artist '{corrected_artist}'") finally: conn.close() except Exception as e: logger.error(f"DB update failed for track {track_id}: {e}") return False return True def _get_settings(self, context): if not context.config_manager: return self.default_settings.copy() cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {}) merged = self.default_settings.copy() if isinstance(cfg, dict): merged.update(cfg) return merged def _get_setting(self, context, key, default=None): return self._get_settings(context).get(key, default)