"""Missing Lyrics maintenance job (Sokhi) — the lyrics sibling of the Cover Art Filler. Scans the library for tracks that have no ``.lrc`` sidecar, asks LRClib whether lyrics actually exist for them (so instrumentals/interludes that genuinely have no lyrics are never flagged — Option A), and creates a finding for each fixable track. Applying a finding fetches + writes the ``.lrc`` and embeds the lyrics, reusing the same LyricsClient the import pipeline uses. Mirrors MissingCoverArtJob's "only surface actionable findings" design. """ from __future__ import annotations import os 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_jobs.missing_lyrics") def _has_lrc_sidecar(file_path: str) -> bool: """True if a .lrc (or .txt lyrics) sidecar already sits next to the file.""" if not file_path: return False base = os.path.splitext(file_path)[0] return os.path.exists(base + '.lrc') or os.path.exists(base + '.txt') @register_job class MissingLyricsJob(RepairJob): job_id = 'missing_lyrics' display_name = 'Lyrics Filler' description = 'Finds tracks with no .lrc lyrics and fetches synced lyrics from LRClib' help_text = ( 'Scans your library for tracks that have no .lrc lyrics file next to them. ' 'For each one it asks LRClib whether lyrics actually exist — tracks with no ' 'lyrics available (instrumentals, interludes) are skipped, so only fixable ' 'tracks are surfaced.\n\n' 'When lyrics are found, a finding is created so you can review and apply it. ' 'Applying writes a synced .lrc sidecar (or plain text if no synced version ' 'exists) and embeds the lyrics in the file — the same way the import pipeline ' 'and the Library Re-tag tool do.\n\n' 'Requires LRClib to be enabled (Settings > Metadata Enhancement).' ) icon = 'repair-icon-lyrics' default_enabled = False default_interval_hours = 48 default_settings = {} auto_fix = False def scan(self, context: JobContext) -> JobResult: result = JobResult() # Respect the same LRClib master toggle the import pipeline uses. if context.config_manager and context.config_manager.get( 'metadata_enhancement.lrclib_enabled', True) is False: logger.info("[Lyrics Filler] LRClib disabled in settings — skipping scan") return result try: from core.lyrics_client import lyrics_client except Exception as e: logger.warning("[Lyrics Filler] lyrics client unavailable: %s", e) return result if not getattr(lyrics_client, 'api', None): logger.info("[Lyrics Filler] LRClib API not available — skipping scan") return result rows = [] conn = None try: conn = context.db._get_connection() cursor = conn.cursor() cursor.execute(""" SELECT t.id, t.title, ar.name, al.title, t.file_path, t.duration FROM tracks t LEFT JOIN albums al ON al.id = t.album_id LEFT JOIN artists ar ON ar.id = t.artist_id WHERE t.file_path IS NOT NULL AND t.file_path != '' AND t.title IS NOT NULL AND t.title != '' """) rows = cursor.fetchall() except Exception as e: logger.error("[Lyrics Filler] Error reading tracks: %s", e, exc_info=True) result.errors += 1 return result finally: if conn: conn.close() total = len(rows) if context.update_progress: context.update_progress(0, total) if context.report_progress: context.report_progress(phase=f'Checking lyrics for {total} tracks...', total=total) for i, row in enumerate(rows): if context.check_stop(): return result if i % 10 == 0 and context.wait_if_paused(): return result track_id, title, artist_name, album_title, file_path, duration = row[:6] result.scanned += 1 # Already has a sidecar on disk → nothing to do. if _has_lrc_sidecar(file_path): result.skipped += 1 continue # Option A: only flag tracks LRClib actually has lyrics for. An # instrumental returns nothing here and is silently skipped (never # re-flagged on future scans). # tracks.duration is stored in MILLISECONDS (schema) — LRClib's # exact-match wants SECONDS, so convert (else exact-match never # hits and it silently falls back to a fuzzier search). try: duration_s = int(int(duration) / 1000) if duration else None if duration_s is not None and duration_s <= 0: duration_s = None except (TypeError, ValueError): duration_s = None try: available = lyrics_client.has_remote_lyrics( title, artist_name or '', album_title, duration_s) except Exception as e: logger.debug("[Lyrics Filler] availability check failed for '%s': %s", title, e) available = False if not available: result.skipped += 1 if context.update_progress and (i + 1) % 10 == 0: context.update_progress(i + 1, total) continue if context.report_progress: context.report_progress( scanned=i + 1, total=total, log_line=f'Found lyrics: {title} — {artist_name or "Unknown"}', log_type='success') if context.create_finding: try: inserted = context.create_finding( job_id=self.job_id, finding_type='missing_lyrics', severity='info', entity_type='track', entity_id=str(track_id), file_path=file_path, title=f'Missing lyrics: {title or "Unknown"}', description=f'"{title}" by {artist_name or "Unknown"} has no .lrc — lyrics found on LRClib.', details={ 'track_id': track_id, 'track_title': title, 'artist': artist_name, 'album_title': album_title, 'file_path': file_path, 'duration': duration_s, }) if inserted: result.findings_created += 1 else: result.findings_skipped_dedup += 1 except Exception as e: logger.debug("[Lyrics Filler] create finding failed for track %s: %s", track_id, e) result.errors += 1 if context.update_progress and (i + 1) % 5 == 0: context.update_progress(i + 1, total) if context.update_progress: context.update_progress(total, total) logger.info("[Lyrics Filler] %d tracks checked, %d with lyrics found, %d skipped", result.scanned, result.findings_created, result.skipped) return result def estimate_scope(self, context: JobContext) -> int: conn = None try: conn = context.db._get_connection() cursor = conn.cursor() cursor.execute(""" SELECT COUNT(*) FROM tracks WHERE file_path IS NOT NULL AND file_path != '' AND title IS NOT NULL AND title != '' """) row = cursor.fetchone() return row[0] if row else 0 except Exception: return 0 finally: if conn: conn.close()