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 {}
|
||||
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -52587,6 +52587,13 @@ tr.tag-diff-same {
|
|||
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 {
|
||||
margin-top: 10px;
|
||||
background: var(--accent-color, #6366f1);
|
||||
|
|
|
|||
Loading…
Reference in a new issue