Lyrics: add a "Lyrics Filler" maintenance job + lyrics option in the Re-tag tool (Sokhi)
The lyrics sibling of the Cover Art Filler, plus retag integration — reusing
the existing LyricsClient (LRClib) the import pipeline already uses.
- lyrics_client: extracted the LRClib fetch (exact-match-with-duration →
search fallback) into a shared _fetch_remote_lyrics, used by both
create_lrc_file (unchanged behavior) and a new check-only has_remote_lyrics.
- MissingLyricsJob (core/repair_jobs/missing_lyrics.py): scans tracks with no
.lrc sidecar and — Option A — only flags ones LRClib actually has lyrics
for, so instrumentals/interludes are never surfaced or re-flagged. Registered
in the job list; default OFF; respects the lrclib_enabled toggle.
- _fix_missing_lyrics (repair_worker): applies a finding by fetching + writing
the .lrc and embedding lyrics via create_lrc_file.
- Re-tag tool: new 'lyrics' setting ('fetch'|'skip', default skip). When
'fetch', apply_track_plans now also fetches/refreshes the .lrc per track
(fetch-if-missing, re-embed-if-exists) — threaded through scan gates, finding
details, the auto-apply path, and the manual fix handler. Settings UI
auto-renders the dropdown from setting_options; no markup needed.
- Frontend: type/action/result labels for missing_lyrics + a finding detail
render case.
Tests: 12 — has_remote_lyrics truth table, sidecar detection, scan (only-
fixable / skip-existing / lrclib-disabled), the apply handler, and retag
lyrics_action on/off. 694 repair/lyrics/cover/retag tests pass.
This commit is contained in:
parent
4e3241bfad
commit
1051ef2402
8 changed files with 543 additions and 56 deletions
|
|
@ -28,6 +28,44 @@ class LyricsClient:
|
||||||
logger.error(f"Error initializing LRClib API: {e}")
|
logger.error(f"Error initializing LRClib API: {e}")
|
||||||
self.api = None
|
self.api = None
|
||||||
|
|
||||||
|
def _fetch_remote_lyrics(self, track_name: str, artist_name: str,
|
||||||
|
album_name: str = None, duration_seconds: int = None):
|
||||||
|
"""LRClib fetch — exact match (with duration) then search fallback.
|
||||||
|
Returns the lyrics_data object or None. Shared by create_lrc_file and
|
||||||
|
has_remote_lyrics so the fetch strategy lives in one place."""
|
||||||
|
if not self.api:
|
||||||
|
return None
|
||||||
|
lyrics_data = None
|
||||||
|
# Strategy 1: Exact match with duration (most accurate)
|
||||||
|
if duration_seconds and album_name:
|
||||||
|
try:
|
||||||
|
lyrics_data = self.api.get_lyrics(
|
||||||
|
track_name=track_name, artist_name=artist_name,
|
||||||
|
album_name=album_name, duration=duration_seconds)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Exact match failed: {e}")
|
||||||
|
# Strategy 2: Search without duration
|
||||||
|
if not lyrics_data:
|
||||||
|
try:
|
||||||
|
search_results = self.api.search_lyrics(
|
||||||
|
track_name=track_name, artist_name=artist_name)
|
||||||
|
if search_results:
|
||||||
|
lyrics_data = search_results[0]
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Search fallback failed: {e}")
|
||||||
|
return lyrics_data
|
||||||
|
|
||||||
|
def has_remote_lyrics(self, track_name: str, artist_name: str,
|
||||||
|
album_name: str = None, duration_seconds: int = None) -> bool:
|
||||||
|
"""True if LRClib has (synced OR plain) lyrics for this track, without
|
||||||
|
writing anything. Powers the Missing Lyrics maintenance job's scan so
|
||||||
|
it only surfaces tracks that are actually fixable (instrumentals return
|
||||||
|
nothing → never flagged)."""
|
||||||
|
data = self._fetch_remote_lyrics(track_name, artist_name, album_name, duration_seconds)
|
||||||
|
if not data:
|
||||||
|
return False
|
||||||
|
return bool(getattr(data, 'synced_lyrics', None) or getattr(data, 'plain_lyrics', None))
|
||||||
|
|
||||||
def create_lrc_file(self, audio_file_path: str, track_name: str, artist_name: str,
|
def create_lrc_file(self, audio_file_path: str, track_name: str, artist_name: str,
|
||||||
album_name: str = None, duration_seconds: int = None) -> bool:
|
album_name: str = None, duration_seconds: int = None) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
@ -79,37 +117,8 @@ class LyricsClient:
|
||||||
|
|
||||||
# Fetch lyrics from LRClib
|
# Fetch lyrics from LRClib
|
||||||
logger.debug(f"Fetching lyrics for: {artist_name} - {track_name}")
|
logger.debug(f"Fetching lyrics for: {artist_name} - {track_name}")
|
||||||
|
lyrics_data = self._fetch_remote_lyrics(
|
||||||
lyrics_data = None
|
track_name, artist_name, album_name, duration_seconds)
|
||||||
|
|
||||||
# Strategy 1: Exact match with duration (most accurate)
|
|
||||||
if duration_seconds and album_name:
|
|
||||||
try:
|
|
||||||
logger.debug(f"Trying exact match: {track_name} by {artist_name} from {album_name} ({duration_seconds}s)")
|
|
||||||
lyrics_data = self.api.get_lyrics(
|
|
||||||
track_name=track_name,
|
|
||||||
artist_name=artist_name,
|
|
||||||
album_name=album_name,
|
|
||||||
duration=duration_seconds
|
|
||||||
)
|
|
||||||
if lyrics_data:
|
|
||||||
logger.debug("Exact match found!")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Exact match failed: {e}")
|
|
||||||
|
|
||||||
# Strategy 2: Search without duration
|
|
||||||
if not lyrics_data:
|
|
||||||
try:
|
|
||||||
logger.debug(f"Trying search: {track_name} by {artist_name}")
|
|
||||||
search_results = self.api.search_lyrics(
|
|
||||||
track_name=track_name,
|
|
||||||
artist_name=artist_name
|
|
||||||
)
|
|
||||||
if search_results:
|
|
||||||
lyrics_data = search_results[0] # Take first result
|
|
||||||
logger.debug(f"Search found {len(search_results)} results, using first")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Search fallback failed: {e}")
|
|
||||||
|
|
||||||
# No lyrics found
|
# No lyrics found
|
||||||
if not lyrics_data:
|
if not lyrics_data:
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ _JOB_MODULES = [
|
||||||
'core.repair_jobs.duplicate_detector',
|
'core.repair_jobs.duplicate_detector',
|
||||||
'core.repair_jobs.acoustid_scanner',
|
'core.repair_jobs.acoustid_scanner',
|
||||||
'core.repair_jobs.missing_cover_art',
|
'core.repair_jobs.missing_cover_art',
|
||||||
|
'core.repair_jobs.missing_lyrics',
|
||||||
'core.repair_jobs.metadata_gap_filler',
|
'core.repair_jobs.metadata_gap_filler',
|
||||||
'core.repair_jobs.album_completeness',
|
'core.repair_jobs.album_completeness',
|
||||||
'core.repair_jobs.fake_lossless_detector',
|
'core.repair_jobs.fake_lossless_detector',
|
||||||
|
|
|
||||||
|
|
@ -72,14 +72,19 @@ def _run_full_enrich(file_path, full_meta) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def apply_track_plans(track_plans, cover_action=None, cover_url=None, full=False) -> dict:
|
def apply_track_plans(track_plans, cover_action=None, cover_url=None, full=False,
|
||||||
"""Write each plan's tags in place (+ optionally embed/refresh cover art),
|
lyrics_action=False) -> dict:
|
||||||
reusing tag_writer.write_tags_to_file. ``file_path`` on each plan must be a
|
"""Write each plan's tags in place (+ optionally embed/refresh cover art,
|
||||||
real, reachable path (caller resolves Docker paths). Shared by the dry-run=
|
+ optionally fetch/refresh .lrc lyrics), reusing tag_writer.write_tags_to_file.
|
||||||
False auto-apply and the repair_worker fix handler. Never raises.
|
``file_path`` on each plan must be a real, reachable path (caller resolves
|
||||||
"""
|
Docker paths). Shared by the dry-run=False auto-apply and the repair_worker
|
||||||
|
fix handler. Never raises.
|
||||||
|
|
||||||
|
``lyrics_action`` (Sokhi): when True, after a track's tags are written, fetch
|
||||||
|
+ write its .lrc and embed the lyrics — the same LyricsClient the import
|
||||||
|
pipeline uses (fetch if missing, re-embed if a sidecar already exists)."""
|
||||||
import os as _os
|
import os as _os
|
||||||
result = {'written': 0, 'failed': 0, 'skipped': 0, 'cover_written': False}
|
result = {'written': 0, 'failed': 0, 'skipped': 0, 'cover_written': False, 'lyrics_written': 0}
|
||||||
embed_cover = bool(cover_action and cover_url)
|
embed_cover = bool(cover_action and cover_url)
|
||||||
cover_data = None
|
cover_data = None
|
||||||
if embed_cover:
|
if embed_cover:
|
||||||
|
|
@ -90,12 +95,20 @@ def apply_track_plans(track_plans, cover_action=None, cover_url=None, full=False
|
||||||
logger.debug("retag cover download failed: %s", e)
|
logger.debug("retag cover download failed: %s", e)
|
||||||
embed_cover = embed_cover and cover_data is not None
|
embed_cover = embed_cover and cover_data is not None
|
||||||
|
|
||||||
|
_lyrics_client = None
|
||||||
|
if lyrics_action:
|
||||||
|
try:
|
||||||
|
from core.lyrics_client import lyrics_client as _lyrics_client
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("retag lyrics client unavailable: %s", e)
|
||||||
|
_lyrics_client = None
|
||||||
|
|
||||||
from core.tag_writer import write_tags_to_file
|
from core.tag_writer import write_tags_to_file
|
||||||
last_dir = None
|
last_dir = None
|
||||||
for tp in track_plans or []:
|
for tp in track_plans or []:
|
||||||
fp = tp.get('file_path')
|
fp = tp.get('file_path')
|
||||||
db_data = tp.get('db_data') or {}
|
db_data = tp.get('db_data') or {}
|
||||||
if not fp or not _os.path.isfile(fp) or (not db_data and not embed_cover):
|
if not fp or not _os.path.isfile(fp) or (not db_data and not embed_cover and not _lyrics_client):
|
||||||
result['skipped'] += 1
|
result['skipped'] += 1
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
|
@ -111,6 +124,22 @@ def apply_track_plans(track_plans, cover_action=None, cover_url=None, full=False
|
||||||
logger.warning("retag write failed for %s: %s", fp, e)
|
logger.warning("retag write failed for %s: %s", fp, e)
|
||||||
result['failed'] += 1
|
result['failed'] += 1
|
||||||
|
|
||||||
|
# Lyrics: fetch/refresh the .lrc for this track (independent of tag write
|
||||||
|
# success — a track with no tag changes may still be missing lyrics).
|
||||||
|
if _lyrics_client:
|
||||||
|
try:
|
||||||
|
dur = db_data.get('duration') or db_data.get('duration_seconds')
|
||||||
|
if lyrics_client_wrote := _lyrics_client.create_lrc_file(
|
||||||
|
fp,
|
||||||
|
db_data.get('title') or db_data.get('track_title') or '',
|
||||||
|
db_data.get('artist') or db_data.get('artist_name') or '',
|
||||||
|
album_name=db_data.get('album') or db_data.get('album_title'),
|
||||||
|
duration_seconds=int(dur) if dur else None,
|
||||||
|
):
|
||||||
|
result['lyrics_written'] += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("retag lyrics fetch failed for %s: %s", fp, e)
|
||||||
|
|
||||||
if cover_action and cover_data and last_dir:
|
if cover_action and cover_data and last_dir:
|
||||||
try:
|
try:
|
||||||
cover_path = _os.path.join(last_dir, 'cover.jpg')
|
cover_path = _os.path.join(last_dir, 'cover.jpg')
|
||||||
|
|
@ -224,12 +253,14 @@ class LibraryRetagJob(RepairJob):
|
||||||
'depth': 'light',
|
'depth': 'light',
|
||||||
'mode': MODE_OVERWRITE,
|
'mode': MODE_OVERWRITE,
|
||||||
'cover_art': 'replace',
|
'cover_art': 'replace',
|
||||||
|
'lyrics': 'skip',
|
||||||
'source': 'auto',
|
'source': 'auto',
|
||||||
}
|
}
|
||||||
setting_options = {
|
setting_options = {
|
||||||
'depth': ['light', 'full'],
|
'depth': ['light', 'full'],
|
||||||
'mode': [MODE_OVERWRITE, MODE_FILL_MISSING],
|
'mode': [MODE_OVERWRITE, MODE_FILL_MISSING],
|
||||||
'cover_art': ['replace', 'fill_missing', 'skip'],
|
'cover_art': ['replace', 'fill_missing', 'skip'],
|
||||||
|
'lyrics': ['fetch', 'skip'],
|
||||||
'source': ['auto', 'spotify', 'itunes', 'deezer', 'musicbrainz'],
|
'source': ['auto', 'spotify', 'itunes', 'deezer', 'musicbrainz'],
|
||||||
}
|
}
|
||||||
auto_fix = True
|
auto_fix = True
|
||||||
|
|
@ -252,6 +283,7 @@ class LibraryRetagJob(RepairJob):
|
||||||
settings = self._get_settings(context)
|
settings = self._get_settings(context)
|
||||||
mode = settings.get('mode', MODE_OVERWRITE)
|
mode = settings.get('mode', MODE_OVERWRITE)
|
||||||
cover_mode = settings.get('cover_art', 'replace')
|
cover_mode = settings.get('cover_art', 'replace')
|
||||||
|
lyrics_action = (settings.get('lyrics', 'skip') or 'skip').lower() == 'fetch'
|
||||||
dry_run = settings.get('dry_run', True)
|
dry_run = settings.get('dry_run', True)
|
||||||
depth = settings.get('depth', 'light')
|
depth = settings.get('depth', 'light')
|
||||||
source_order = self._source_order(settings)
|
source_order = self._source_order(settings)
|
||||||
|
|
@ -301,7 +333,8 @@ class LibraryRetagJob(RepairJob):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._scan_album(context, result, album_id, album_title, artist_name,
|
self._scan_album(context, result, album_id, album_title, artist_name,
|
||||||
source, album_source_id, mode, cover_mode, dry_run, depth)
|
source, album_source_id, mode, cover_mode, dry_run, depth,
|
||||||
|
lyrics_action=lyrics_action)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Library re-tag: album %s failed: %s", album_id, e)
|
logger.debug("Library re-tag: album %s failed: %s", album_id, e)
|
||||||
result.errors += 1
|
result.errors += 1
|
||||||
|
|
@ -313,7 +346,8 @@ class LibraryRetagJob(RepairJob):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _scan_album(self, context, result, album_id, album_title, artist_name,
|
def _scan_album(self, context, result, album_id, album_title, artist_name,
|
||||||
source, album_source_id, mode, cover_mode, dry_run=True, depth='light'):
|
source, album_source_id, mode, cover_mode, dry_run=True, depth='light',
|
||||||
|
lyrics_action=False):
|
||||||
# Local tracks for this album.
|
# Local tracks for this album.
|
||||||
with context.db._get_connection() as conn:
|
with context.db._get_connection() as conn:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
@ -389,23 +423,27 @@ class LibraryRetagJob(RepairJob):
|
||||||
continue # genuinely unreachable from this process
|
continue # genuinely unreachable from this process
|
||||||
if src is None:
|
if src is None:
|
||||||
unmatched.append(lib['title'] or os.path.basename(lib['file_path']))
|
unmatched.append(lib['title'] or os.path.basename(lib['file_path']))
|
||||||
# No source match means no re-tag — but album cover art still
|
# No source match means no re-tag — but album cover art and/or
|
||||||
# applies to the file, so cover modes include an art-only plan
|
# lyrics still apply to the file, so those modes include an
|
||||||
# (empty db_data: apply embeds art and writes no tags).
|
# art/lyrics-only plan (empty db_data → apply writes no tags).
|
||||||
if cover_action:
|
if cover_action or lyrics_action:
|
||||||
track_plans.append({
|
track_plans.append({
|
||||||
'file_path': rp,
|
'file_path': rp,
|
||||||
'track_id': lib['id'],
|
'track_id': lib['id'],
|
||||||
'title': lib['title'],
|
'title': lib['title'],
|
||||||
'changes': [],
|
'changes': [],
|
||||||
'db_data': {},
|
# Carry the library title/artist so lyrics fetch has a query
|
||||||
|
# even when there's no source match to build db_data from.
|
||||||
|
'db_data': ({'title': lib.get('title'), 'artist': artist_name}
|
||||||
|
if lyrics_action else {}),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
current = _read_current_tags(rp)
|
current = _read_current_tags(rp)
|
||||||
plan = plan_track(current, src, album_meta, mode=mode)
|
plan = plan_track(current, src, album_meta, mode=mode)
|
||||||
# Include a track when its tags change, OR when there's a cover action
|
# Include a track when its tags change, OR there's a cover action,
|
||||||
# to apply to it (db_data may be empty — apply embeds art either way).
|
# OR lyrics are being fetched (db_data may be empty — apply still
|
||||||
if plan['changes'] or cover_action:
|
# embeds art / writes the .lrc).
|
||||||
|
if plan['changes'] or cover_action or lyrics_action:
|
||||||
db_data = plan['db_data']
|
db_data = plan['db_data']
|
||||||
_add_source_ids(db_data, source, album_source_id, src)
|
_add_source_ids(db_data, source, album_source_id, src)
|
||||||
tp = {
|
tp = {
|
||||||
|
|
@ -421,7 +459,7 @@ class LibraryRetagJob(RepairJob):
|
||||||
track_plans.append(tp)
|
track_plans.append(tp)
|
||||||
|
|
||||||
tag_change_tracks = sum(1 for tp in track_plans if tp['changes'])
|
tag_change_tracks = sum(1 for tp in track_plans if tp['changes'])
|
||||||
if (not tag_change_tracks and not cover_action) or not track_plans:
|
if (not tag_change_tracks and not cover_action and not lyrics_action) or not track_plans:
|
||||||
# Nothing actionable. The second clause covers cover-action albums
|
# Nothing actionable. The second clause covers cover-action albums
|
||||||
# where no track is reachable/included — creating a finding there
|
# where no track is reachable/included — creating a finding there
|
||||||
# gives an unappliable "(0 track(s))" entry.
|
# gives an unappliable "(0 track(s))" entry.
|
||||||
|
|
@ -435,8 +473,9 @@ class LibraryRetagJob(RepairJob):
|
||||||
# Not dry-run: apply the tags in place now (the track paths were already
|
# Not dry-run: apply the tags in place now (the track paths were already
|
||||||
# isfile-checked above) and count it as an auto-fix — no finding.
|
# isfile-checked above) and count it as an auto-fix — no finding.
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
res = apply_track_plans(track_plans, cover_action, cover_url, full=(depth == 'full'))
|
res = apply_track_plans(track_plans, cover_action, cover_url, full=(depth == 'full'),
|
||||||
if res['written'] or res['cover_written']:
|
lyrics_action=lyrics_action)
|
||||||
|
if res['written'] or res['cover_written'] or res.get('lyrics_written'):
|
||||||
result.auto_fixed += 1
|
result.auto_fixed += 1
|
||||||
else:
|
else:
|
||||||
result.errors += 1
|
result.errors += 1
|
||||||
|
|
@ -483,6 +522,7 @@ class LibraryRetagJob(RepairJob):
|
||||||
'cover_mode': cover_mode,
|
'cover_mode': cover_mode,
|
||||||
'cover_url': cover_url,
|
'cover_url': cover_url,
|
||||||
'cover_action': cover_action,
|
'cover_action': cover_action,
|
||||||
|
'lyrics_action': lyrics_action,
|
||||||
'tracks': track_plans, # each carries its db_data for a deterministic apply
|
'tracks': track_plans, # each carries its db_data for a deterministic apply
|
||||||
'unmatched': unmatched,
|
'unmatched': unmatched,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
192
core/repair_jobs/missing_lyrics.py
Normal file
192
core/repair_jobs/missing_lyrics.py
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
"""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).
|
||||||
|
try:
|
||||||
|
duration_s = int(duration) if duration else 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()
|
||||||
|
|
@ -963,6 +963,7 @@ class RepairWorker:
|
||||||
'orphan_file': self._fix_orphan_file,
|
'orphan_file': self._fix_orphan_file,
|
||||||
'track_number_mismatch': self._fix_track_number,
|
'track_number_mismatch': self._fix_track_number,
|
||||||
'missing_cover_art': self._fix_missing_cover_art,
|
'missing_cover_art': self._fix_missing_cover_art,
|
||||||
|
'missing_lyrics': self._fix_missing_lyrics,
|
||||||
'metadata_gap': self._fix_metadata_gap,
|
'metadata_gap': self._fix_metadata_gap,
|
||||||
'duplicate_tracks': self._fix_duplicates,
|
'duplicate_tracks': self._fix_duplicates,
|
||||||
'single_album_redundant': self._fix_single_album_redundant,
|
'single_album_redundant': self._fix_single_album_redundant,
|
||||||
|
|
@ -1414,6 +1415,35 @@ class RepairWorker:
|
||||||
msg += ' + applied artist image'
|
msg += ' + applied artist image'
|
||||||
return {'success': True, 'action': 'applied_cover_art', 'message': msg, 'art_result': art_result}
|
return {'success': True, 'action': 'applied_cover_art', 'message': msg, 'art_result': art_result}
|
||||||
|
|
||||||
|
def _fix_missing_lyrics(self, entity_type, entity_id, file_path, details):
|
||||||
|
"""Apply a missing-lyrics finding: fetch + write the .lrc sidecar and
|
||||||
|
embed the lyrics, via the same LyricsClient the import pipeline uses."""
|
||||||
|
raw_path = details.get('file_path') or file_path
|
||||||
|
if not raw_path:
|
||||||
|
return {'success': False, 'error': 'No file path in finding'}
|
||||||
|
download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else None
|
||||||
|
resolved = _resolve_file_path(raw_path, self.transfer_folder, download_folder,
|
||||||
|
config_manager=self._config_manager) or raw_path
|
||||||
|
if not os.path.isfile(resolved):
|
||||||
|
return {'success': False, 'error': f'File not found on disk: {os.path.basename(raw_path)}'}
|
||||||
|
try:
|
||||||
|
from core.lyrics_client import lyrics_client
|
||||||
|
duration = details.get('duration')
|
||||||
|
ok = lyrics_client.create_lrc_file(
|
||||||
|
resolved,
|
||||||
|
details.get('track_title') or '',
|
||||||
|
details.get('artist') or '',
|
||||||
|
album_name=details.get('album_title'),
|
||||||
|
duration_seconds=int(duration) if duration else None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Lyrics fix failed for %s: %s", os.path.basename(raw_path), e)
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
if not ok:
|
||||||
|
# Lyrics vanished between scan and apply (rare) — report, don't crash.
|
||||||
|
return {'success': False, 'error': 'Could not fetch lyrics (no longer available?)'}
|
||||||
|
return {'success': True, 'action': 'applied_lyrics', 'message': 'Wrote lyrics (.lrc) + embedded'}
|
||||||
|
|
||||||
def _fix_library_retag(self, entity_type, entity_id, file_path, details):
|
def _fix_library_retag(self, entity_type, entity_id, file_path, details):
|
||||||
"""Apply a library re-tag finding: write each track's planned tags in
|
"""Apply a library re-tag finding: write each track's planned tags in
|
||||||
place (core.tag_writer.write_tags_to_file) + optionally embed/refresh
|
place (core.tag_writer.write_tags_to_file) + optionally embed/refresh
|
||||||
|
|
@ -1439,9 +1469,10 @@ class RepairWorker:
|
||||||
|
|
||||||
from core.repair_jobs.library_retag import apply_track_plans
|
from core.repair_jobs.library_retag import apply_track_plans
|
||||||
res = apply_track_plans(resolved_plans, details.get('cover_action'), details.get('cover_url'),
|
res = apply_track_plans(resolved_plans, details.get('cover_action'), details.get('cover_url'),
|
||||||
full=(details.get('depth') == 'full'))
|
full=(details.get('depth') == 'full'),
|
||||||
|
lyrics_action=details.get('lyrics_action', False))
|
||||||
|
|
||||||
if res['written'] == 0 and not res['cover_written']:
|
if res['written'] == 0 and not res['cover_written'] and not res.get('lyrics_written'):
|
||||||
return {'success': False,
|
return {'success': False,
|
||||||
'error': 'Nothing could be written — files unreachable or read-only?'}
|
'error': 'Nothing could be written — files unreachable or read-only?'}
|
||||||
msg = f"Re-tagged {res['written']} track(s)"
|
msg = f"Re-tagged {res['written']} track(s)"
|
||||||
|
|
@ -3145,7 +3176,7 @@ class RepairWorker:
|
||||||
|
|
||||||
# Build query for pending fixable findings
|
# Build query for pending fixable findings
|
||||||
fixable_types = ('dead_file', 'orphan_file', 'track_number_mismatch',
|
fixable_types = ('dead_file', 'orphan_file', 'track_number_mismatch',
|
||||||
'missing_cover_art', 'metadata_gap', 'duplicate_tracks',
|
'missing_cover_art', 'missing_lyrics', 'metadata_gap', 'duplicate_tracks',
|
||||||
'single_album_redundant', 'mbid_mismatch',
|
'single_album_redundant', 'mbid_mismatch',
|
||||||
'album_mbid_mismatch',
|
'album_mbid_mismatch',
|
||||||
'album_tag_inconsistency',
|
'album_tag_inconsistency',
|
||||||
|
|
|
||||||
|
|
@ -392,4 +392,5 @@ def test_apply_art_only_plan_skips_when_cover_download_fails(tmp_path, monkeypat
|
||||||
cover_action='replace', cover_url='http://art/cover.jpg',
|
cover_action='replace', cover_url='http://art/cover.jpg',
|
||||||
)
|
)
|
||||||
|
|
||||||
assert res == {'written': 0, 'failed': 0, 'skipped': 1, 'cover_written': False}
|
assert res == {'written': 0, 'failed': 0, 'skipped': 1, 'cover_written': False,
|
||||||
|
'lyrics_written': 0}
|
||||||
|
|
|
||||||
204
tests/test_missing_lyrics_job.py
Normal file
204
tests/test_missing_lyrics_job.py
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
"""Missing Lyrics maintenance job + lyrics_client check-only seam (Sokhi).
|
||||||
|
|
||||||
|
Mirrors the Cover Art Filler: scan only flags tracks LRClib actually has
|
||||||
|
lyrics for (Option A — instrumentals never flagged), and applying writes the
|
||||||
|
.lrc via the shared LyricsClient.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.lyrics_client import LyricsClient
|
||||||
|
from core.repair_jobs.missing_lyrics import MissingLyricsJob, _has_lrc_sidecar
|
||||||
|
|
||||||
|
|
||||||
|
# ── lyrics_client.has_remote_lyrics (check-only seam) ────────────────────────
|
||||||
|
|
||||||
|
def _client_with_api(api):
|
||||||
|
c = LyricsClient.__new__(LyricsClient)
|
||||||
|
c.api = api
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_remote_lyrics_true_when_synced():
|
||||||
|
api = MagicMock()
|
||||||
|
api.get_lyrics.return_value = SimpleNamespace(synced_lyrics="[00:01]hi", plain_lyrics=None)
|
||||||
|
c = _client_with_api(api)
|
||||||
|
assert c.has_remote_lyrics("Song", "Artist", "Album", 200) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_remote_lyrics_true_when_plain_only_via_search():
|
||||||
|
api = MagicMock()
|
||||||
|
api.get_lyrics.return_value = None
|
||||||
|
api.search_lyrics.return_value = [SimpleNamespace(synced_lyrics=None, plain_lyrics="words")]
|
||||||
|
c = _client_with_api(api)
|
||||||
|
assert c.has_remote_lyrics("Song", "Artist") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_remote_lyrics_false_when_none():
|
||||||
|
api = MagicMock()
|
||||||
|
api.get_lyrics.return_value = None
|
||||||
|
api.search_lyrics.return_value = []
|
||||||
|
assert _client_with_api(api).has_remote_lyrics("Instrumental", "Artist") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_remote_lyrics_false_when_no_api():
|
||||||
|
c = LyricsClient.__new__(LyricsClient)
|
||||||
|
c.api = None
|
||||||
|
assert c.has_remote_lyrics("Song", "Artist") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── sidecar detection ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_has_lrc_sidecar(tmp_path):
|
||||||
|
audio = tmp_path / "track.flac"
|
||||||
|
audio.write_bytes(b"x")
|
||||||
|
assert _has_lrc_sidecar(str(audio)) is False
|
||||||
|
(tmp_path / "track.lrc").write_text("[00:01]hi")
|
||||||
|
assert _has_lrc_sidecar(str(audio)) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── the scan (Option A: only flag fixable tracks) ────────────────────────────
|
||||||
|
|
||||||
|
class _DB:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def _get_connection(self):
|
||||||
|
cur = MagicMock()
|
||||||
|
cur.execute.return_value = None
|
||||||
|
cur.fetchone.return_value = [len(self._rows)]
|
||||||
|
cur.fetchall.return_value = self._rows
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.cursor.return_value = cur
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(db, findings):
|
||||||
|
return SimpleNamespace(
|
||||||
|
db=db,
|
||||||
|
config_manager=SimpleNamespace(get=lambda k, d=None: d),
|
||||||
|
check_stop=lambda: False, wait_if_paused=lambda: False,
|
||||||
|
update_progress=lambda *a, **k: None, report_progress=lambda *a, **k: None,
|
||||||
|
create_finding=lambda **kw: (findings.append(kw) or True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_flags_only_tracks_with_available_lyrics(tmp_path, monkeypatch):
|
||||||
|
# Two tracks, neither has a .lrc. LRClib has lyrics for the first, not the second.
|
||||||
|
t1 = tmp_path / "song.flac"; t1.write_bytes(b"x")
|
||||||
|
t2 = tmp_path / "instrumental.flac"; t2.write_bytes(b"x")
|
||||||
|
rows = [
|
||||||
|
(1, "Song", "Artist", "Album", str(t1), 200),
|
||||||
|
(2, "Interlude", "Artist", "Album", str(t2), 60),
|
||||||
|
]
|
||||||
|
fake_client = SimpleNamespace(
|
||||||
|
api=object(),
|
||||||
|
has_remote_lyrics=lambda title, artist, album, dur: title == "Song",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("core.lyrics_client.lyrics_client", fake_client)
|
||||||
|
|
||||||
|
findings = []
|
||||||
|
result = MissingLyricsJob().scan(_ctx(_DB(rows), findings))
|
||||||
|
|
||||||
|
assert result.findings_created == 1
|
||||||
|
assert findings[0]["entity_type"] == "track"
|
||||||
|
assert findings[0]["finding_type"] == "missing_lyrics"
|
||||||
|
assert findings[0]["details"]["track_title"] == "Song" # the instrumental was skipped
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_skips_tracks_that_already_have_lrc(tmp_path, monkeypatch):
|
||||||
|
t1 = tmp_path / "song.flac"; t1.write_bytes(b"x")
|
||||||
|
(tmp_path / "song.lrc").write_text("[00:01]hi") # already has lyrics
|
||||||
|
rows = [(1, "Song", "Artist", "Album", str(t1), 200)]
|
||||||
|
fake_client = SimpleNamespace(api=object(),
|
||||||
|
has_remote_lyrics=lambda *a, **k: True)
|
||||||
|
monkeypatch.setattr("core.lyrics_client.lyrics_client", fake_client)
|
||||||
|
|
||||||
|
findings = []
|
||||||
|
result = MissingLyricsJob().scan(_ctx(_DB(rows), findings))
|
||||||
|
assert result.findings_created == 0
|
||||||
|
assert findings == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_noops_when_lrclib_disabled(monkeypatch):
|
||||||
|
db = _DB([(1, "Song", "Artist", "Album", "/x.flac", 200)])
|
||||||
|
ctx = _ctx(db, [])
|
||||||
|
ctx.config_manager = SimpleNamespace(
|
||||||
|
get=lambda k, d=None: False if k == 'metadata_enhancement.lrclib_enabled' else d)
|
||||||
|
result = MissingLyricsJob().scan(ctx)
|
||||||
|
assert result.scanned == 0 and result.findings_created == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── _fix_missing_lyrics apply handler ────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_fix_missing_lyrics_calls_create_lrc(tmp_path, monkeypatch):
|
||||||
|
from core.repair_worker import RepairWorker
|
||||||
|
audio = tmp_path / "song.flac"; audio.write_bytes(b"x")
|
||||||
|
|
||||||
|
w = RepairWorker.__new__(RepairWorker)
|
||||||
|
w.transfer_folder = str(tmp_path)
|
||||||
|
w._config_manager = SimpleNamespace(get=lambda k, d=None: d)
|
||||||
|
|
||||||
|
calls = {}
|
||||||
|
fake_client = SimpleNamespace(
|
||||||
|
create_lrc_file=lambda path, title, artist, album_name=None, duration_seconds=None:
|
||||||
|
calls.update(path=path, title=title, artist=artist) or True)
|
||||||
|
monkeypatch.setattr("core.lyrics_client.lyrics_client", fake_client)
|
||||||
|
# _resolve_file_path: the file is already real, so identity is fine.
|
||||||
|
monkeypatch.setattr("core.repair_worker._resolve_file_path",
|
||||||
|
lambda raw, *a, **k: raw)
|
||||||
|
|
||||||
|
res = w._fix_missing_lyrics("track", "1", None, {
|
||||||
|
"file_path": str(audio), "track_title": "Song", "artist": "Artist",
|
||||||
|
"album_title": "Album", "duration": 200})
|
||||||
|
assert res["success"] is True and res["action"] == "applied_lyrics"
|
||||||
|
assert calls["title"] == "Song" and calls["path"] == str(audio)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fix_missing_lyrics_missing_file(tmp_path, monkeypatch):
|
||||||
|
from core.repair_worker import RepairWorker
|
||||||
|
w = RepairWorker.__new__(RepairWorker)
|
||||||
|
w.transfer_folder = str(tmp_path)
|
||||||
|
w._config_manager = SimpleNamespace(get=lambda k, d=None: d)
|
||||||
|
monkeypatch.setattr("core.repair_worker._resolve_file_path", lambda raw, *a, **k: raw)
|
||||||
|
res = w._fix_missing_lyrics("track", "1", None, {"file_path": str(tmp_path / "gone.flac")})
|
||||||
|
assert res["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── retag apply_track_plans lyrics_action ────────────────────────────────────
|
||||||
|
|
||||||
|
def test_apply_track_plans_lyrics_action(tmp_path, monkeypatch):
|
||||||
|
from core.repair_jobs import library_retag
|
||||||
|
audio = tmp_path / "t.flac"; audio.write_bytes(b"x")
|
||||||
|
|
||||||
|
monkeypatch.setattr(library_retag, "write_tags_to_file",
|
||||||
|
lambda *a, **k: {"success": True}, raising=False)
|
||||||
|
seen = {}
|
||||||
|
fake_client = SimpleNamespace(
|
||||||
|
create_lrc_file=lambda path, title, artist, album_name=None, duration_seconds=None:
|
||||||
|
seen.update(title=title) or True)
|
||||||
|
monkeypatch.setattr("core.lyrics_client.lyrics_client", fake_client)
|
||||||
|
|
||||||
|
plans = [{"file_path": str(audio), "db_data": {"title": "Song", "artist": "Artist"}}]
|
||||||
|
res = library_retag.apply_track_plans(plans, lyrics_action=True)
|
||||||
|
assert res["lyrics_written"] == 1 and seen["title"] == "Song"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_track_plans_no_lyrics_when_disabled(tmp_path, monkeypatch):
|
||||||
|
from core.repair_jobs import library_retag
|
||||||
|
audio = tmp_path / "t.flac"; audio.write_bytes(b"x")
|
||||||
|
monkeypatch.setattr(library_retag, "write_tags_to_file",
|
||||||
|
lambda *a, **k: {"success": True}, raising=False)
|
||||||
|
called = []
|
||||||
|
fake_client = SimpleNamespace(create_lrc_file=lambda *a, **k: called.append(1) or True)
|
||||||
|
monkeypatch.setattr("core.lyrics_client.lyrics_client", fake_client)
|
||||||
|
|
||||||
|
plans = [{"file_path": str(audio), "db_data": {"title": "Song"}}]
|
||||||
|
res = library_retag.apply_track_plans(plans, lyrics_action=False)
|
||||||
|
assert res["lyrics_written"] == 0 and called == []
|
||||||
|
|
@ -2734,6 +2734,7 @@ async function loadRepairFindings() {
|
||||||
duplicate_tracks: 'Duplicate', incomplete_album: 'Incomplete',
|
duplicate_tracks: 'Duplicate', incomplete_album: 'Incomplete',
|
||||||
path_mismatch: 'Path Mismatch', metadata_gap: 'Missing Metadata',
|
path_mismatch: 'Path Mismatch', metadata_gap: 'Missing Metadata',
|
||||||
missing_cover_art: 'Missing Art', track_number_mismatch: 'Track Number',
|
missing_cover_art: 'Missing Art', track_number_mismatch: 'Track Number',
|
||||||
|
missing_lyrics: 'Missing Lyrics',
|
||||||
missing_lossy_copy: 'No Lossy Copy', library_retag: 'Re-tag'
|
missing_lossy_copy: 'No Lossy Copy', library_retag: 'Re-tag'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2743,6 +2744,7 @@ async function loadRepairFindings() {
|
||||||
orphan_file: 'Resolve',
|
orphan_file: 'Resolve',
|
||||||
track_number_mismatch: 'Fix',
|
track_number_mismatch: 'Fix',
|
||||||
missing_cover_art: 'Apply Art',
|
missing_cover_art: 'Apply Art',
|
||||||
|
missing_lyrics: 'Apply Lyrics',
|
||||||
metadata_gap: 'Apply',
|
metadata_gap: 'Apply',
|
||||||
duplicate_tracks: 'Keep Best',
|
duplicate_tracks: 'Keep Best',
|
||||||
incomplete_album: 'Auto-Fill',
|
incomplete_album: 'Auto-Fill',
|
||||||
|
|
@ -2759,6 +2761,7 @@ async function loadRepairFindings() {
|
||||||
removed_db_entry: 'Entry Removed', added_to_wishlist: 'Wishlisted', deleted_file: 'File Deleted',
|
removed_db_entry: 'Entry Removed', added_to_wishlist: 'Wishlisted', deleted_file: 'File Deleted',
|
||||||
already_gone: 'Already Gone', fixed_track_number: 'Track # Fixed',
|
already_gone: 'Already Gone', fixed_track_number: 'Track # Fixed',
|
||||||
applied_cover_art: 'Art Applied', applied_metadata: 'Metadata Applied',
|
applied_cover_art: 'Art Applied', applied_metadata: 'Metadata Applied',
|
||||||
|
applied_lyrics: 'Lyrics Applied',
|
||||||
removed_duplicates: 'Duplicates Removed',
|
removed_duplicates: 'Duplicates Removed',
|
||||||
};
|
};
|
||||||
let statusBadge = '';
|
let statusBadge = '';
|
||||||
|
|
@ -3127,6 +3130,12 @@ function _renderFindingDetail(f) {
|
||||||
artHtml += _gridRows(rows);
|
artHtml += _gridRows(rows);
|
||||||
return artHtml;
|
return artHtml;
|
||||||
|
|
||||||
|
case 'missing_lyrics':
|
||||||
|
if (d.track_title) rows.push(['Track', d.track_title]);
|
||||||
|
if (d.artist) rows.push(['Artist', d.artist]);
|
||||||
|
if (d.album_title) rows.push(['Album', d.album_title]);
|
||||||
|
return _gridRows(rows);
|
||||||
|
|
||||||
case 'track_number_mismatch':
|
case 'track_number_mismatch':
|
||||||
if (d.album_title) rows.push(['Album', d.album_title]);
|
if (d.album_title) rows.push(['Album', d.album_title]);
|
||||||
if (d.artist_name) rows.push(['Artist', d.artist_name]);
|
if (d.artist_name) rows.push(['Artist', d.artist_name]);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue