Library Re-tag: add light/full depth setting, default source to active, fix dropdown CSS
- depth setting (light = core tags + matched source ids; full = same multi-source enrichment cascade a fresh download gets, run additively via embed_source_ids). Threaded through scan/finding/auto-apply and the repair_worker fix handler. - source now defaults to 'auto' (= your source priority / active source) instead of blank. - give native <option> popups a solid dark background (were white-on-white). - tests for full-depth full_meta payload + enrich invocation + light no-op.
This commit is contained in:
parent
0a4c3d7dc8
commit
adbdda7b0e
4 changed files with 159 additions and 10 deletions
|
|
@ -45,7 +45,33 @@ def _read_current_tags(file_path):
|
||||||
return {}
|
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),
|
"""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
|
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=
|
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'):
|
if res.get('success'):
|
||||||
result['written'] += 1
|
result['written'] += 1
|
||||||
last_dir = _os.path.dirname(fp)
|
last_dir = _os.path.dirname(fp)
|
||||||
|
if full and tp.get('full_meta'):
|
||||||
|
_run_full_enrich(fp, tp['full_meta'])
|
||||||
else:
|
else:
|
||||||
result['failed'] += 1
|
result['failed'] += 1
|
||||||
except Exception as e:
|
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
|
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):
|
def _track_list(result):
|
||||||
"""Normalize a get_album_tracks result into a plain list of track items."""
|
"""Normalize a get_album_tracks result into a plain list of track items."""
|
||||||
if result is None:
|
if result is None:
|
||||||
|
|
@ -144,6 +200,10 @@ class LibraryRetagJob(RepairJob):
|
||||||
'comes from. Each finding lists every tag that would change (old -> new) per '
|
'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'
|
'track so you can review before applying — nothing is written until you do.\n\n'
|
||||||
'Settings:\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. '
|
'- Dry run (default ON): only create findings to review; nothing is written. '
|
||||||
'Turn it off to auto-apply on scan.\n'
|
'Turn it off to auto-apply on scan.\n'
|
||||||
'- Mode: "overwrite" rewrites every field the source provides; "fill_missing" '
|
'- Mode: "overwrite" rewrites every field the source provides; "fill_missing" '
|
||||||
|
|
@ -156,14 +216,16 @@ class LibraryRetagJob(RepairJob):
|
||||||
default_interval_hours = 168
|
default_interval_hours = 168
|
||||||
default_settings = {
|
default_settings = {
|
||||||
'dry_run': True,
|
'dry_run': True,
|
||||||
|
'depth': 'light',
|
||||||
'mode': MODE_OVERWRITE,
|
'mode': MODE_OVERWRITE,
|
||||||
'cover_art': 'replace',
|
'cover_art': 'replace',
|
||||||
'source': '',
|
'source': 'auto',
|
||||||
}
|
}
|
||||||
setting_options = {
|
setting_options = {
|
||||||
|
'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'],
|
||||||
'source': ['', 'spotify', 'itunes', 'deezer', 'musicbrainz'],
|
'source': ['auto', 'spotify', 'itunes', 'deezer', 'musicbrainz'],
|
||||||
}
|
}
|
||||||
auto_fix = True
|
auto_fix = True
|
||||||
|
|
||||||
|
|
@ -186,6 +248,7 @@ class LibraryRetagJob(RepairJob):
|
||||||
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')
|
||||||
dry_run = settings.get('dry_run', True)
|
dry_run = settings.get('dry_run', True)
|
||||||
|
depth = settings.get('depth', 'light')
|
||||||
source_order = self._source_order(settings)
|
source_order = self._source_order(settings)
|
||||||
if not source_order:
|
if not source_order:
|
||||||
logger.warning("Library re-tag: no usable metadata sources configured")
|
logger.warning("Library re-tag: no usable metadata sources configured")
|
||||||
|
|
@ -233,7 +296,7 @@ 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)
|
source, album_source_id, mode, cover_mode, dry_run, depth)
|
||||||
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
|
||||||
|
|
@ -245,7 +308,7 @@ 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):
|
source, album_source_id, mode, cover_mode, dry_run=True, depth='light'):
|
||||||
# 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()
|
||||||
|
|
@ -297,13 +360,17 @@ class LibraryRetagJob(RepairJob):
|
||||||
if plan['changes'] or cover_action:
|
if plan['changes'] or cover_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)
|
||||||
track_plans.append({
|
tp = {
|
||||||
'file_path': lib['file_path'],
|
'file_path': lib['file_path'],
|
||||||
'track_id': lib['id'],
|
'track_id': lib['id'],
|
||||||
'title': lib['title'],
|
'title': lib['title'],
|
||||||
'changes': plan['changes'],
|
'changes': plan['changes'],
|
||||||
'db_data': db_data,
|
'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'])
|
tag_change_tracks = sum(1 for tp in track_plans if tp['changes'])
|
||||||
if not tag_change_tracks and not cover_action:
|
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
|
# 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)
|
res = apply_track_plans(track_plans, cover_action, cover_url, full=(depth == 'full'))
|
||||||
if res['written'] or res['cover_written']:
|
if res['written'] or res['cover_written']:
|
||||||
result.auto_fixed += 1
|
result.auto_fixed += 1
|
||||||
else:
|
else:
|
||||||
|
|
@ -326,6 +393,8 @@ class LibraryRetagJob(RepairJob):
|
||||||
summary_bits.append(f"{tag_change_tracks} track(s), {total_changes} tag change(s)")
|
summary_bits.append(f"{tag_change_tracks} track(s), {total_changes} tag change(s)")
|
||||||
if cover_action:
|
if cover_action:
|
||||||
summary_bits.append(f"cover art ({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 '
|
desc = (f'Album "{album_title}" by {artist_name or "Unknown"} would be re-tagged from '
|
||||||
f'{source} ({", ".join(summary_bits)}).')
|
f'{source} ({", ".join(summary_bits)}).')
|
||||||
if unmatched:
|
if unmatched:
|
||||||
|
|
@ -347,6 +416,7 @@ class LibraryRetagJob(RepairJob):
|
||||||
'artist': artist_name,
|
'artist': artist_name,
|
||||||
'source': source,
|
'source': source,
|
||||||
'album_source_id': album_source_id,
|
'album_source_id': album_source_id,
|
||||||
|
'depth': depth,
|
||||||
'mode': mode,
|
'mode': mode,
|
||||||
'cover_mode': cover_mode,
|
'cover_mode': cover_mode,
|
||||||
'cover_url': cover_url,
|
'cover_url': cover_url,
|
||||||
|
|
|
||||||
|
|
@ -1375,10 +1375,14 @@ class RepairWorker:
|
||||||
continue
|
continue
|
||||||
rp = _resolve_file_path(raw, self.transfer_folder, download_folder,
|
rp = _resolve_file_path(raw, self.transfer_folder, download_folder,
|
||||||
config_manager=self._config_manager) or raw
|
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
|
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']:
|
if res['written'] == 0 and not res['cover_written']:
|
||||||
return {'success': False,
|
return {'success': False,
|
||||||
|
|
|
||||||
|
|
@ -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
|
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):
|
def test_scan_skips_album_already_correct(tmp_path, monkeypatch):
|
||||||
track = tmp_path / 'track.flac'; track.write_bytes(b'')
|
track = tmp_path / 'track.flac'; track.write_bytes(b'')
|
||||||
conn = _db_with_album(str(tmp_path / 'm.db'), str(track), current_title='Real Title')
|
conn = _db_with_album(str(tmp_path / 'm.db'), str(track), current_title='Real Title')
|
||||||
|
|
|
||||||
|
|
@ -52587,6 +52587,13 @@ tr.tag-diff-same {
|
||||||
accent-color: var(--accent-color, #6366f1);
|
accent-color: var(--accent-color, #6366f1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Native <option> popups don't inherit the translucent select background, so
|
||||||
|
they rendered as white-on-white. Give the options a solid dark background. */
|
||||||
|
.repair-setting-input option {
|
||||||
|
background: #1e1e24;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.repair-save-settings-btn {
|
.repair-save-settings-btn {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
background: var(--accent-color, #6366f1);
|
background: var(--accent-color, #6366f1);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue