diff --git a/core/repair_jobs/library_retag.py b/core/repair_jobs/library_retag.py index 6219e6c0..9bd0b463 100644 --- a/core/repair_jobs/library_retag.py +++ b/core/repair_jobs/library_retag.py @@ -45,7 +45,33 @@ def _read_current_tags(file_path): return {} -def apply_track_plans(track_plans, cover_action=None, cover_url=None) -> dict: +def _run_full_enrich(file_path, full_meta) -> bool: + """'full' depth: run the same multi-source enrichment a fresh download gets + (MusicBrainz/Deezer/AudioDB/Tidal/… via embed_source_ids), ADDITIVELY — it + adds rich frames without clearing existing tags. Slow + API-heavy per track. + """ + if not full_meta: + return False + try: + from core.metadata.common import get_mutagen_symbols + from core.metadata.source import embed_source_ids + symbols = get_mutagen_symbols() + if not symbols: + return False + audio = symbols.File(file_path) + if audio is None: + return False + if getattr(audio, 'tags', None) is None and hasattr(audio, 'add_tags'): + audio.add_tags() + embed_source_ids(audio, full_meta, context=None, runtime=None) + audio.save() + return True + except Exception as e: + logger.warning("full enrich failed for %s: %s", file_path, e) + return False + + +def apply_track_plans(track_plans, cover_action=None, cover_url=None, full=False) -> dict: """Write each plan's tags in place (+ optionally embed/refresh cover art), reusing tag_writer.write_tags_to_file. ``file_path`` on each plan must be a real, reachable path (caller resolves Docker paths). Shared by the dry-run= @@ -76,6 +102,8 @@ def apply_track_plans(track_plans, cover_action=None, cover_url=None) -> dict: if res.get('success'): result['written'] += 1 last_dir = _os.path.dirname(fp) + if full and tp.get('full_meta'): + _run_full_enrich(fp, tp['full_meta']) else: result['failed'] += 1 except Exception as e: @@ -114,6 +142,34 @@ def _add_source_ids(db_data, source, album_source_id, source_track): db_data[track_key] = tid +_FULL_META_ID_KEYS = ( + 'spotify_album_id', 'spotify_track_id', + 'itunes_album_id', 'itunes_track_id', + 'musicbrainz_release_id', 'musicbrainz_recording_id', + 'deezer_id', +) + + +def _build_full_meta(db_data, src, album_title, artist_name, lib_title): + """Metadata dict for the 'full' depth enrichment cascade. Carries the matched + source's ids so embed_source_ids resolves the right entity instead of guessing + by name.""" + src_title = None + for k in ('name', 'title', 'track_name'): + v = src.get(k) if isinstance(src, dict) else getattr(src, k, None) + if v: + src_title = v + break + meta = { + 'title': src_title or lib_title, + 'album': album_title, + 'album_artist': artist_name, + 'artist': artist_name, + } + meta.update({k: db_data[k] for k in _FULL_META_ID_KEYS if db_data.get(k)}) + return meta + + def _track_list(result): """Normalize a get_album_tracks result into a plain list of track items.""" if result is None: @@ -144,6 +200,10 @@ class LibraryRetagJob(RepairJob): 'comes from. Each finding lists every tag that would change (old -> new) per ' 'track so you can review before applying — nothing is written until you do.\n\n' 'Settings:\n' + '- Depth: "light" writes the core tags + the matched source\'s ids (fast, ' + 'additive). "full" also runs the same multi-source enrichment a fresh ' + 'download gets (MusicBrainz / Deezer / AudioDB / Tidal / etc. — BPM, ISRC, ' + 'lyrics, moods, …); much richer but slower and API-heavy on a big library.\n' '- Dry run (default ON): only create findings to review; nothing is written. ' 'Turn it off to auto-apply on scan.\n' '- Mode: "overwrite" rewrites every field the source provides; "fill_missing" ' @@ -156,14 +216,16 @@ class LibraryRetagJob(RepairJob): default_interval_hours = 168 default_settings = { 'dry_run': True, + 'depth': 'light', 'mode': MODE_OVERWRITE, 'cover_art': 'replace', - 'source': '', + 'source': 'auto', } setting_options = { + 'depth': ['light', 'full'], 'mode': [MODE_OVERWRITE, MODE_FILL_MISSING], 'cover_art': ['replace', 'fill_missing', 'skip'], - 'source': ['', 'spotify', 'itunes', 'deezer', 'musicbrainz'], + 'source': ['auto', 'spotify', 'itunes', 'deezer', 'musicbrainz'], } auto_fix = True @@ -186,6 +248,7 @@ class LibraryRetagJob(RepairJob): mode = settings.get('mode', MODE_OVERWRITE) cover_mode = settings.get('cover_art', 'replace') dry_run = settings.get('dry_run', True) + depth = settings.get('depth', 'light') source_order = self._source_order(settings) if not source_order: logger.warning("Library re-tag: no usable metadata sources configured") @@ -233,7 +296,7 @@ class LibraryRetagJob(RepairJob): try: self._scan_album(context, result, album_id, album_title, artist_name, - source, album_source_id, mode, cover_mode, dry_run) + source, album_source_id, mode, cover_mode, dry_run, depth) except Exception as e: logger.debug("Library re-tag: album %s failed: %s", album_id, e) result.errors += 1 @@ -245,7 +308,7 @@ class LibraryRetagJob(RepairJob): return result def _scan_album(self, context, result, album_id, album_title, artist_name, - source, album_source_id, mode, cover_mode, dry_run=True): + source, album_source_id, mode, cover_mode, dry_run=True, depth='light'): # Local tracks for this album. with context.db._get_connection() as conn: cur = conn.cursor() @@ -297,13 +360,17 @@ class LibraryRetagJob(RepairJob): if plan['changes'] or cover_action: db_data = plan['db_data'] _add_source_ids(db_data, source, album_source_id, src) - track_plans.append({ + tp = { 'file_path': lib['file_path'], 'track_id': lib['id'], 'title': lib['title'], 'changes': plan['changes'], 'db_data': db_data, - }) + } + if depth == 'full': + tp['full_meta'] = _build_full_meta( + db_data, src, album_title, artist_name, lib['title']) + track_plans.append(tp) tag_change_tracks = sum(1 for tp in track_plans if tp['changes']) if not tag_change_tracks and not cover_action: @@ -313,7 +380,7 @@ class LibraryRetagJob(RepairJob): # 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. if not dry_run: - res = apply_track_plans(track_plans, cover_action, cover_url) + res = apply_track_plans(track_plans, cover_action, cover_url, full=(depth == 'full')) if res['written'] or res['cover_written']: result.auto_fixed += 1 else: @@ -326,6 +393,8 @@ class LibraryRetagJob(RepairJob): summary_bits.append(f"{tag_change_tracks} track(s), {total_changes} tag change(s)") if cover_action: summary_bits.append(f"cover art ({cover_action})") + if depth == 'full': + summary_bits.append("full multi-source enrichment") desc = (f'Album "{album_title}" by {artist_name or "Unknown"} would be re-tagged from ' f'{source} ({", ".join(summary_bits)}).') if unmatched: @@ -347,6 +416,7 @@ class LibraryRetagJob(RepairJob): 'artist': artist_name, 'source': source, 'album_source_id': album_source_id, + 'depth': depth, 'mode': mode, 'cover_mode': cover_mode, 'cover_url': cover_url, diff --git a/core/repair_worker.py b/core/repair_worker.py index 2ddca93d..74d3a634 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -1375,10 +1375,14 @@ class RepairWorker: continue rp = _resolve_file_path(raw, self.transfer_folder, download_folder, config_manager=self._config_manager) or raw - resolved_plans.append({'file_path': rp, 'db_data': t.get('db_data') or {}}) + plan = {'file_path': rp, 'db_data': t.get('db_data') or {}} + if t.get('full_meta'): + plan['full_meta'] = t['full_meta'] + resolved_plans.append(plan) 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')) if res['written'] == 0 and not res['cover_written']: return {'success': False, diff --git a/tests/test_library_retag_job.py b/tests/test_library_retag_job.py index 58a88960..46f8456e 100644 --- a/tests/test_library_retag_job.py +++ b/tests/test_library_retag_job.py @@ -111,6 +111,74 @@ def test_scan_dry_run_off_auto_applies_no_finding(tmp_path, monkeypatch): assert writes and writes[0]['title'] == 'Real Title' # actually wrote +def test_scan_full_depth_attaches_full_meta_to_finding(tmp_path, monkeypatch): + """depth=full: each track plan carries a full_meta dict (title/album/artist + + source ids) for the enrichment cascade, and details record the depth.""" + track = tmp_path / 'track.flac'; track.write_bytes(b'') + conn = _db_with_album(str(tmp_path / 'm.db'), str(track), current_title='Old Title') + ctx = _context(conn, {'mode': 'overwrite', 'cover_art': 'skip', 'source': 'spotify', 'depth': 'full'}) + _patch_source(monkeypatch, { + 'title': 'Old Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock', 'track_number': 1, 'disc_number': 1, + }) + + result = lr.LibraryRetagJob().scan(ctx) + + assert result.findings_created == 1 + d = ctx.findings[0]['details'] + assert d['depth'] == 'full' + fm = d['tracks'][0]['full_meta'] + assert fm['title'] == 'Real Title' + assert fm['album'] == 'Real Album' + assert fm['album_artist'] == 'Real Artist' + assert fm['spotify_album_id'] == 'sp_alb' + assert fm['spotify_track_id'] == 'sp_trk' + + +def test_scan_full_depth_auto_apply_runs_enrich(tmp_path, monkeypatch): + """depth=full + dry_run off: after the light write, the full enrichment + cascade runs once per written track.""" + track = tmp_path / 'track.flac'; track.write_bytes(b'') + conn = _db_with_album(str(tmp_path / 'm.db'), str(track), current_title='Old Title') + ctx = _context(conn, {'mode': 'overwrite', 'cover_art': 'skip', 'source': 'spotify', + 'depth': 'full', 'dry_run': False}) + _patch_source(monkeypatch, { + 'title': 'Old Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock', 'track_number': 1, 'disc_number': 1, + }) + monkeypatch.setattr('core.tag_writer.write_tags_to_file', + lambda fp, db_data, **k: {'success': True}) + enriched = [] + monkeypatch.setattr(lr, '_run_full_enrich', + lambda fp, meta: enriched.append((fp, meta)) or True) + + result = lr.LibraryRetagJob().scan(ctx) + + assert result.auto_fixed == 1 + assert len(enriched) == 1 + assert enriched[0][1]['spotify_track_id'] == 'sp_trk' + + +def test_scan_light_depth_does_not_run_enrich(tmp_path, monkeypatch): + """depth=light (default): no full_meta, enrichment cascade never invoked.""" + track = tmp_path / 'track.flac'; track.write_bytes(b'') + conn = _db_with_album(str(tmp_path / 'm.db'), str(track), current_title='Old Title') + ctx = _context(conn, {'mode': 'overwrite', 'cover_art': 'skip', 'source': 'spotify', + 'dry_run': False}) # depth defaults to light + _patch_source(monkeypatch, { + 'title': 'Old Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock', 'track_number': 1, 'disc_number': 1, + }) + monkeypatch.setattr('core.tag_writer.write_tags_to_file', + lambda fp, db_data, **k: {'success': True}) + enriched = [] + monkeypatch.setattr(lr, '_run_full_enrich', + lambda fp, meta: enriched.append(fp) or True) + + lr.LibraryRetagJob().scan(ctx) + assert enriched == [] + + def test_scan_skips_album_already_correct(tmp_path, monkeypatch): track = tmp_path / 'track.flac'; track.write_bytes(b'') conn = _db_with_album(str(tmp_path / 'm.db'), str(track), current_title='Real Title') diff --git a/webui/static/style.css b/webui/static/style.css index 8c521124..36d9919f 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -52587,6 +52587,13 @@ tr.tag-diff-same { accent-color: var(--accent-color, #6366f1); } +/* Native