Library re-tag (2/3 + 3/3): the repair job + in-place apply handler

New 'Library Re-tag' repair job (default-OFF, opt-in; weekly when enabled):
- Scans every source-matched album (spotify/itunes/deezer/musicbrainz album id),
  pulls fresh metadata + tracklist from that source, reads each local track's
  current tags, and uses the planner to compute per-field diffs.
- Dry-run by design: scan only CREATES findings — nothing touches a file. Each
  finding is highly detailed: per-track old->new for every changed field, the
  source used, the mode, a cover-art action, and any unmatched tracks, plus a
  summary description. Settings: mode (overwrite | fill_missing), cover_art
  (replace | fill_missing | skip), source override.
- Apply handler (_fix_library_retag in repair_worker): writes each track's
  planned tags in place via tag_writer.write_tags_to_file (+ batch-embeds cover,
  refreshes cover.jpg). Only adds/overwrites planned fields — no moves/renames/
  re-match. Resolves Docker paths; read-only/unreachable files counted, never
  crash. Media-server-only / unreachable tracks are skipped.

Registered in the job list + fix dispatch. The old per-download Retag Tool is
left untouched alongside this for now.
This commit is contained in:
BoulderBadgeDad 2026-06-04 08:55:38 -07:00
parent b0c78c8674
commit f5c905be86
3 changed files with 374 additions and 0 deletions

View file

@ -45,6 +45,7 @@ _JOB_MODULES = [
'core.repair_jobs.unknown_artist_fixer',
'core.repair_jobs.discography_backfill',
'core.repair_jobs.canonical_version_resolve',
'core.repair_jobs.library_retag',
]

View file

@ -0,0 +1,301 @@
"""Library Re-tag Job — rewrite audio tags from a fresh metadata-source pull.
Re-does ONLY the tagging part of post-processing (tags + cover art), IN PLACE:
no file moves, no renames, no re-matching, no library reorganization. Only
albums that are matched to a metadata source are eligible the album's stored
source id is used to pull fresh data to write.
Dry-run by design: scan creates detailed, per-track findings (old -> new for
every field that would change); nothing touches a file until you apply a
finding. The apply handler lives in repair_worker (_fix_library_retag).
"""
import os
from core.library.retag_planner import (
MODE_FILL_MISSING,
MODE_OVERWRITE,
match_source_tracks,
plan_track,
)
from core.metadata.album_tracks import get_album_for_source, get_album_tracks_for_source
from core.metadata_service import get_primary_source, get_source_priority
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_job.library_retag")
# (source, albums-table column) in resolution-preference order is decided at
# runtime from the configured source priority; this maps source -> column.
_ALBUM_SOURCE_COLUMNS = {
'spotify': 'spotify_album_id',
'itunes': 'itunes_album_id',
'deezer': 'deezer_id',
'musicbrainz': 'musicbrainz_release_id',
}
def _read_current_tags(file_path):
try:
from core.soulsync_client import _read_tags
return _read_tags(file_path) or {}
except Exception as exc:
logger.debug("read tags failed for %s: %s", file_path, exc)
return {}
def _track_list(result):
"""Normalize a get_album_tracks result into a plain list of track items."""
if result is None:
return []
if isinstance(result, list):
return result
if isinstance(result, dict):
for key in ('tracks', 'items', 'data'):
v = result.get(key)
if isinstance(v, list):
return v
v = getattr(result, 'tracks', None)
return v if isinstance(v, list) else []
@register_job
class LibraryRetagJob(RepairJob):
job_id = 'library_retag'
display_name = 'Library Re-tag'
description = 'Rewrites tags + cover art from a fresh metadata-source pull, in place'
help_text = (
'Re-tags albums in your library using a fresh pull from the metadata source '
'they are matched to — writing the tags (and optionally cover art) directly '
'into the files. It only does the tagging step: files are NOT moved, renamed, '
're-matched, or reorganized.\n\n'
'Only albums matched to a metadata source (Spotify / iTunes / Deezer / '
'MusicBrainz album id) are eligible, since the source is where the fresh data '
'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'
'- Mode: "overwrite" rewrites every field the source provides; "fill_missing" '
'only fills blank tags (keeps your existing values).\n'
'- Cover art: replace / fill-missing / skip.\n'
'- Source: which matched source to pull from (default: your source priority).'
)
icon = 'repair-icon-retag'
default_enabled = False
default_interval_hours = 168
default_settings = {
'mode': MODE_OVERWRITE,
'cover_art': 'replace',
'source': '',
}
setting_options = {
'mode': [MODE_OVERWRITE, MODE_FILL_MISSING],
'cover_art': ['replace', 'fill_missing', 'skip'],
'source': ['', 'spotify', 'itunes', 'deezer', 'musicbrainz'],
}
auto_fix = False
def _get_settings(self, context: JobContext) -> dict:
merged = dict(self.default_settings)
if context.config_manager:
cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {}) or {}
merged.update(cfg)
return merged
def _source_order(self, settings) -> list:
override = (settings.get('source') or '').strip()
if override in _ALBUM_SOURCE_COLUMNS:
return [override]
return [s for s in get_source_priority(get_primary_source()) if s in _ALBUM_SOURCE_COLUMNS]
def scan(self, context: JobContext) -> JobResult:
result = JobResult()
settings = self._get_settings(context)
mode = settings.get('mode', MODE_OVERWRITE)
cover_mode = settings.get('cover_art', 'replace')
source_order = self._source_order(settings)
if not source_order:
logger.warning("Library re-tag: no usable metadata sources configured")
return result
# Albums that carry at least one usable source id.
cols = ', '.join(f'al.{c}' for c in _ALBUM_SOURCE_COLUMNS.values())
try:
with context.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(f"""
SELECT al.id, al.title, ar.name, {cols}
FROM albums al
LEFT JOIN artists ar ON ar.id = al.artist_id
WHERE al.title IS NOT NULL AND al.title != ''
""")
albums = cursor.fetchall()
except Exception as e:
logger.error("Library re-tag: album query failed: %s", e, exc_info=True)
result.errors += 1
return result
total = len(albums)
if context.update_progress:
context.update_progress(0, total)
if context.report_progress:
context.report_progress(phase=f'Checking {total} albums for tag drift...', total=total)
for i, row in enumerate(albums):
if context.check_stop():
return result
if i % 5 == 0 and context.wait_if_paused():
return result
result.scanned += 1
if context.update_progress and (i + 1) % 5 == 0:
context.update_progress(i + 1, total)
album_id, album_title, artist_name = row[0], row[1], row[2]
source_ids = {src: row[3 + idx] for idx, src in enumerate(_ALBUM_SOURCE_COLUMNS)}
source = next((s for s in source_order if source_ids.get(s)), None)
if not source:
continue # not matched to a usable source — skip
album_source_id = str(source_ids[source])
try:
self._scan_album(context, result, album_id, album_title, artist_name,
source, album_source_id, mode, cover_mode)
except Exception as e:
logger.debug("Library re-tag: album %s failed: %s", album_id, e)
result.errors += 1
if context.update_progress:
context.update_progress(total, total)
logger.info("Library re-tag scan: %d albums checked, %d findings",
result.scanned, result.findings_created)
return result
def _scan_album(self, context, result, album_id, album_title, artist_name,
source, album_source_id, mode, cover_mode):
# Local tracks for this album.
with context.db._get_connection() as conn:
cur = conn.cursor()
cur.execute("""
SELECT id, title, track_number, disc_number, file_path
FROM tracks
WHERE album_id = ? AND file_path IS NOT NULL AND file_path != ''
ORDER BY disc_number, track_number
""", (album_id,))
library_tracks = [
{'id': r[0], 'title': r[1], 'track_number': r[2],
'disc_number': r[3], 'file_path': r[4]}
for r in cur.fetchall()
]
if not library_tracks:
return
album_meta = get_album_for_source(source, album_source_id)
source_tracks = _track_list(get_album_tracks_for_source(source, album_source_id))
if not album_meta or not source_tracks:
logger.debug("Library re-tag: no source data for album %s (%s)", album_id, source)
return
cover_url = None
for k in ('image_url', 'album_image_url', 'cover_url', 'thumb_url'):
v = album_meta.get(k) if isinstance(album_meta, dict) else getattr(album_meta, k, None)
if v:
cover_url = v
break
# Cover action (album-level), independent of tag changes. Decided first
# so cover-only albums (tags fine, art missing) still include their
# tracks for the apply to embed art into.
cover_action = self._cover_action(cover_mode, cover_url, library_tracks)
pairs = match_source_tracks(source_tracks, library_tracks)
track_plans = []
unmatched = []
for lib, src in pairs:
if src is None:
unmatched.append(lib['title'] or os.path.basename(lib['file_path']))
continue
if not os.path.isfile(lib['file_path']):
continue # not reachable at the stored path — skip (apply resolves paths)
current = _read_current_tags(lib['file_path'])
plan = plan_track(current, src, album_meta, mode=mode)
# Include a track when its tags change, OR when there's a cover action
# to apply to it (db_data may be empty — apply embeds art either way).
if plan['changes'] or cover_action:
track_plans.append({
'file_path': lib['file_path'],
'track_id': lib['id'],
'title': lib['title'],
'changes': plan['changes'],
'db_data': plan['db_data'],
})
tag_change_tracks = sum(1 for tp in track_plans if tp['changes'])
if not tag_change_tracks and not cover_action:
result.skipped += 1
return
total_changes = sum(len(tp['changes']) for tp in track_plans)
summary_bits = []
if tag_change_tracks:
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})")
desc = (f'Album "{album_title}" by {artist_name or "Unknown"} would be re-tagged from '
f'{source} ({", ".join(summary_bits)}).')
if unmatched:
desc += f' {len(unmatched)} track(s) could not be matched to the source and are left untouched.'
if context.create_finding:
inserted = context.create_finding(
job_id=self.job_id,
finding_type='library_retag',
severity='info',
entity_type='album',
entity_id=str(album_id),
file_path=None,
title=f'Re-tag: {album_title or "Unknown"} ({tag_change_tracks} track(s))',
description=desc,
details={
'album_id': album_id,
'album_title': album_title,
'artist': artist_name,
'source': source,
'album_source_id': album_source_id,
'mode': mode,
'cover_mode': cover_mode,
'cover_url': cover_url,
'cover_action': cover_action,
'tracks': track_plans, # each carries its db_data for a deterministic apply
'unmatched': unmatched,
},
)
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
@staticmethod
def _cover_action(cover_mode, cover_url, library_tracks):
"""Return 'replace' / 'fill' / None for the album's cover under the mode."""
if cover_mode == 'skip' or not cover_url:
return None
if cover_mode == 'replace':
return 'replace'
# fill_missing — only if the album has no art on disk
try:
from core.metadata.art_apply import album_has_art_on_disk
rep = library_tracks[0]['file_path'] if library_tracks else ''
return None if album_has_art_on_disk(rep) else 'fill'
except Exception:
return None
def estimate_scope(self, context: JobContext) -> int:
try:
with context.db._get_connection() as conn:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM albums WHERE title IS NOT NULL AND title != ''")
row = cur.fetchone()
return row[0] if row else 0
except Exception:
return 0

View file

@ -976,6 +976,7 @@ class RepairWorker:
'unknown_artist': self._fix_unknown_artist,
'acoustid_mismatch': self._fix_acoustid_mismatch,
'missing_discography_track': self._fix_discography_backfill,
'library_retag': self._fix_library_retag,
}
handler = handlers.get(finding_type)
if not handler:
@ -1356,6 +1357,77 @@ class RepairWorker:
msg = 'Updated database thumbnail, but could not write art to files (read-only?)'
return {'success': True, 'action': 'applied_cover_art', 'message': msg, 'art_result': art_result}
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
place (core.tag_writer.write_tags_to_file) + optionally embed/refresh
cover art. Only ADDS/overwrites the planned fields no moves/renames."""
tracks = details.get('tracks') or []
if not tracks:
return {'success': False, 'error': 'No tracks to re-tag in finding'}
cover_action = details.get('cover_action')
cover_url = details.get('cover_url')
embed_cover = bool(cover_action and cover_url)
download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else None
# Download the cover once for the whole album (batch embed).
cover_data = None
if embed_cover:
try:
from core.tag_writer import download_cover_art
cover_data = download_cover_art(cover_url)
except Exception as e:
logger.debug("library_retag: cover download failed: %s", e)
embed_cover = embed_cover and cover_data is not None
from core.tag_writer import write_tags_to_file
written = failed = skipped = 0
last_dir = None
for t in tracks:
raw = t.get('file_path')
db_data = t.get('db_data') or {}
if not raw or (not db_data and not embed_cover):
skipped += 1
continue
resolved = _resolve_file_path(raw, self.transfer_folder, download_folder,
config_manager=self._config_manager) or raw
if not os.path.isfile(resolved):
skipped += 1
continue
try:
res = write_tags_to_file(resolved, db_data, embed_cover=embed_cover, cover_data=cover_data)
if res.get('success'):
written += 1
last_dir = os.path.dirname(resolved)
else:
failed += 1
except Exception as e:
logger.warning("library_retag write failed for %s: %s", resolved, e)
failed += 1
# Refresh the cover.jpg sidecar to match (replace, or fill when missing).
cover_written = False
if cover_action and cover_data and last_dir:
try:
cover_path = os.path.join(last_dir, 'cover.jpg')
if cover_action == 'replace' or not os.path.exists(cover_path):
with open(cover_path, 'wb') as fh:
fh.write(cover_data[0])
cover_written = True
except Exception as e:
logger.debug("library_retag: cover.jpg write failed: %s", e)
if written == 0 and not cover_written:
return {'success': False,
'error': 'Nothing could be written — files unreachable or read-only?'}
msg = f'Re-tagged {written} track(s)'
if failed:
msg += f' ({failed} failed)'
if cover_written:
msg += ' + refreshed cover.jpg'
return {'success': True, 'action': 'library_retag', 'message': msg,
'written': written, 'failed': failed, 'skipped': skipped}
def _fix_metadata_gap(self, entity_type, entity_id, file_path, details):
"""Apply found metadata fields to the track."""
found_fields = details.get('found_fields')