diff --git a/core/repair_jobs/__init__.py b/core/repair_jobs/__init__.py index 4760e35d..159dc7bc 100644 --- a/core/repair_jobs/__init__.py +++ b/core/repair_jobs/__init__.py @@ -42,6 +42,7 @@ _JOB_MODULES = [ 'core.repair_jobs.lossy_converter', 'core.repair_jobs.album_tag_consistency', 'core.repair_jobs.live_commentary_cleaner', + 'core.repair_jobs.unknown_artist_fixer', ] diff --git a/core/repair_jobs/unknown_artist_fixer.py b/core/repair_jobs/unknown_artist_fixer.py new file mode 100644 index 00000000..353039fa --- /dev/null +++ b/core/repair_jobs/unknown_artist_fixer.py @@ -0,0 +1,496 @@ +"""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 time + +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', ''} + +# 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_track_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 + from core.repair_worker import _resolve_file_path + resolved = _resolve_file_path(file_path, transfer) + 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'): + from core.repair_jobs.library_reorganize import _build_path_from_template, _get_audio_quality + quality = _get_audio_quality(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 = _build_path_from_template(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: + 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', ''), + } + ) + result.findings_created += 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 '' + + # 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 + source_id = (track.get('spotify_track_id') or track.get('deezer_track_id') + or track.get('itunes_track_id')) + if source_id and context.spotify_client: + try: + details = context.spotify_client.get_track_details(str(source_id)) + if details and details.get('primary_artist'): + artist = details['primary_artist'] + if artist.lower() not in _UNKNOWN_NAMES: + album = details.get('album', {}) + album_name = album.get('name', '') if isinstance(album, dict) else str(album) + return { + 'artist': artist, + 'album': album_name, + 'title': details.get('name', title), + 'track_number': details.get('track_number'), + 'disc_number': details.get('disc_number', 1), + 'year': (album.get('release_date', '') or '')[:4] if isinstance(album, dict) else '', + 'image_url': album.get('images', [{}])[0].get('url', '') if isinstance(album, dict) and album.get('images') else '', + 'source': 'track_id_lookup', + 'confidence': 0.95, + } + except Exception as e: + logger.debug(f"Track ID lookup failed for {source_id}: {e}") + + # Priority 3: Search by title + if title and context.spotify_client: + try: + results = context.spotify_client.search_tracks(title, limit=5) + if results: + # Score candidates + from difflib import SequenceMatcher + best = None + best_score = 0 + for r in results: + name_sim = SequenceMatcher(None, title.lower(), r.name.lower()).ratio() + # Boost if album matches + album_name = r.album if hasattr(r, 'album') else '' + if album_name and track.get('album_title'): + album_sim = SequenceMatcher(None, track['album_title'].lower(), album_name.lower()).ratio() + name_sim = (name_sim * 0.7) + (album_sim * 0.3) + if name_sim > best_score: + best_score = name_sim + best = r + + if best and best_score >= 0.7: + artist = best.artists[0] if best.artists else '' + if artist and artist.lower() not in _UNKNOWN_NAMES: + # Get full details for track_number + full_details = None + try: + full_details = context.spotify_client.get_track_details(best.id) + except Exception: + pass + album_data = full_details.get('album', {}) if full_details else {} + return { + 'artist': artist, + 'album': best.album if hasattr(best, 'album') else '', + 'title': best.name, + 'track_number': full_details.get('track_number') if full_details else None, + 'disc_number': full_details.get('disc_number', 1) if full_details else 1, + 'year': (album_data.get('release_date', '') or '')[:4] if isinstance(album_data, dict) else '', + 'image_url': getattr(best, 'image_url', '') or '', + 'source': 'title_search', + 'confidence': round(best_score, 3), + } + except Exception as e: + logger.debug(f"Title search failed for '{title}': {e}") + # Rate limit courtesy + time.sleep(0.2) + + return None + + 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: + pass + + # 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: + pass + + # 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: + cursor.execute("INSERT INTO artists (name) VALUES (?)", (corrected_artist,)) + new_artist_id = cursor.lastrowid + + # 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) diff --git a/core/repair_worker.py b/core/repair_worker.py index 2b75f763..eb3e3c53 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -816,6 +816,7 @@ class RepairWorker: 'path_mismatch': self._fix_path_mismatch, 'missing_lossy_copy': self._fix_missing_lossy_copy, 'unwanted_content': self._fix_unwanted_content, + 'unknown_artist': self._fix_unknown_artist, } handler = handlers.get(finding_type) if not handler: @@ -1382,6 +1383,109 @@ class RepairWorker: msg += ' (file deleted)' return {'success': True, 'action': 'removed_content', 'message': msg} + def _fix_unknown_artist(self, entity_type, entity_id, file_path, details): + """Fix an Unknown Artist track — re-tag, move to correct path, update DB.""" + track_id = details.get('track_id') + corrected_artist = details.get('corrected_artist', '') + corrected_album = details.get('corrected_album', '') + corrected_title = details.get('corrected_title', '') + corrected_track_number = details.get('corrected_track_number') + corrected_year = details.get('corrected_year', '') + cover_url = details.get('cover_url', '') + expected_path = details.get('expected_path', '') + + if not corrected_artist or not track_id: + return {'success': False, 'error': 'Missing corrected artist or track ID'} + + # Resolve file + download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else '' + resolved = _resolve_file_path(file_path, self.transfer_folder, download_folder) if file_path else None + if not resolved or not os.path.exists(resolved): + return {'success': False, 'error': f'File not found: {file_path}'} + + # Step 1: Re-tag file + try: + from core.tag_writer import write_tags_to_file + db_data = { + 'title': corrected_title, + 'artist_name': corrected_artist, + 'album_title': corrected_album, + 'year': corrected_year, + 'track_number': corrected_track_number, + } + write_tags_to_file(resolved, db_data, embed_cover=bool(cover_url), cover_url=cover_url or None) + except Exception as e: + logger.warning(f"Tag write failed during unknown artist fix: {e}") + + # Step 2: Move file if expected path differs + final_path = resolved + if expected_path: + expected_abs = os.path.normpath(os.path.join(self.transfer_folder, expected_path)) + if os.path.normpath(resolved).lower() != expected_abs.lower(): + try: + os.makedirs(os.path.dirname(expected_abs), exist_ok=True) + if sys.platform in ('win32', 'darwin') and os.path.exists(expected_abs): + tmp = expected_abs + '.tmp_rename' + shutil.move(resolved, tmp) + shutil.move(tmp, expected_abs) + else: + shutil.move(resolved, expected_abs) + final_path = expected_abs + + # Move sidecars + src_dir = os.path.dirname(resolved) + dst_dir = os.path.dirname(expected_abs) + src_stem = os.path.splitext(os.path.basename(resolved))[0] + dst_stem = os.path.splitext(os.path.basename(expected_abs))[0] + for ext in ('.lrc', '.jpg', '.jpeg', '.png', '.txt'): + s = os.path.join(src_dir, src_stem + ext) + if os.path.isfile(s): + d = os.path.join(dst_dir, dst_stem + ext) + if not os.path.exists(d): + try: + shutil.move(s, d) + except Exception: + pass + + # Clean up empty dirs + self._cleanup_empty_parents(resolved) + except Exception as e: + logger.error(f"File move failed: {e}") + + # Step 3: Update DB + try: + conn = self.db._get_connection() + try: + cursor = conn.cursor() + # Find or create artist + cursor.execute("SELECT id FROM artists WHERE LOWER(name) = LOWER(?)", (corrected_artist,)) + row = cursor.fetchone() + new_artist_id = row[0] if row else None + if not new_artist_id: + cursor.execute("INSERT INTO artists (name) VALUES (?)", (corrected_artist,)) + new_artist_id = cursor.lastrowid + + cursor.execute("UPDATE tracks SET artist_id = ?, file_path = ? WHERE id = ?", + (new_artist_id, final_path, track_id)) + if corrected_track_number: + cursor.execute("UPDATE tracks SET track_number = ? WHERE id = ?", + (corrected_track_number, track_id)) + album_id = details.get('album_id') + if album_id: + if corrected_album: + cursor.execute("UPDATE albums SET title = ? WHERE id = ?", (corrected_album, album_id)) + if corrected_year and corrected_year.isdigit(): + cursor.execute("UPDATE albums SET year = ? WHERE id = ?", (int(corrected_year), album_id)) + cursor.execute("UPDATE albums SET artist_id = ? WHERE id = ?", (new_artist_id, album_id)) + conn.commit() + finally: + conn.close() + except Exception as e: + return {'success': False, 'error': f'DB update failed: {e}'} + + return {'success': True, 'action': 'fixed_unknown_artist', + 'message': f'Fixed: {corrected_artist} - {corrected_title}'} + def _fix_mbid_mismatch(self, entity_type, entity_id, file_path, details): """Remove the mismatched MusicBrainz recording ID from the audio file.""" if not file_path: