Library re-tag (1/3): pure planner — match source tracklist + per-field tag diff
The testable core for the new library-wide re-tag job. Given a source album's metadata + tracklist and the library tracks' current file tags, it: - matches source tracks to library tracks (disc+track number, then title sim), - computes the per-field diff (old -> new) for the dry-run finding, - builds the minimal write_tags_to_file payload — only fields that actually change under the chosen mode (overwrite vs fill-missing), so applying never touches unrelated/unchanged tags. No IO/network/DB — 10 unit tests cover matching, both modes, blank-source fields, and the album-artist/track-count payload mapping.
This commit is contained in:
parent
83e3f6b660
commit
b0c78c8674
2 changed files with 332 additions and 0 deletions
218
core/library/retag_planner.py
Normal file
218
core/library/retag_planner.py
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
"""Pure planning logic for the library re-tag job.
|
||||||
|
|
||||||
|
Given a source album's metadata + tracklist and the library's tracks (with their
|
||||||
|
*current* file tags), this works out — per track — exactly which tags would
|
||||||
|
change (the dry-run diff the finding shows) and the ``db_data`` payload to feed
|
||||||
|
``core.tag_writer.write_tags_to_file`` at apply time.
|
||||||
|
|
||||||
|
No file IO, no network, no DB: the job feeds in current tags + fetched source
|
||||||
|
data, so all the matching/diff logic stays unit-testable. Tags are only ever
|
||||||
|
ADDED/overwritten per-field — never a full tag-block wipe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
# Fields this job manages. Keys are the internal/display names; the diff and the
|
||||||
|
# write payload are both built from these.
|
||||||
|
MANAGED_FIELDS = ('title', 'artist', 'album', 'year', 'genre', 'track_number', 'disc_number')
|
||||||
|
|
||||||
|
# Modes: overwrite everything the source provides, or only fill blanks.
|
||||||
|
MODE_OVERWRITE = 'overwrite'
|
||||||
|
MODE_FILL_MISSING = 'fill_missing'
|
||||||
|
|
||||||
|
|
||||||
|
def _get(obj: Any, *keys: str, default=None):
|
||||||
|
"""First non-empty value across keys, from a dict or an object."""
|
||||||
|
for k in keys:
|
||||||
|
v = obj.get(k) if isinstance(obj, dict) else getattr(obj, k, None)
|
||||||
|
if v not in (None, ''):
|
||||||
|
return v
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _first_artist(obj: Any) -> str:
|
||||||
|
arts = _get(obj, 'artists', 'artist', 'artist_name')
|
||||||
|
if isinstance(arts, list) and arts:
|
||||||
|
a0 = arts[0]
|
||||||
|
return ((a0.get('name') if isinstance(a0, dict) else str(a0)) or '').strip()
|
||||||
|
if isinstance(arts, dict):
|
||||||
|
return (arts.get('name') or '').strip()
|
||||||
|
return str(arts).strip() if arts else ''
|
||||||
|
|
||||||
|
|
||||||
|
def _genres_list(obj: Any) -> List[str]:
|
||||||
|
g = _get(obj, 'genres', 'genre')
|
||||||
|
if isinstance(g, list):
|
||||||
|
return [str(x).strip() for x in g if str(x).strip()]
|
||||||
|
if isinstance(g, str) and g.strip():
|
||||||
|
return [p.strip() for p in g.split(',') if p.strip()]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _year(obj: Any) -> str:
|
||||||
|
v = _get(obj, 'year', 'release_date', 'date')
|
||||||
|
if not v:
|
||||||
|
return ''
|
||||||
|
m = re.search(r'\d{4}', str(v))
|
||||||
|
return m.group(0) if m else ''
|
||||||
|
|
||||||
|
|
||||||
|
def _int_or_none(v) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_title(s: Any) -> str:
|
||||||
|
s = (s or '')
|
||||||
|
s = s.lower() if isinstance(s, str) else str(s).lower()
|
||||||
|
s = re.sub(r'[\(\[].*?[\)\]]', ' ', s)
|
||||||
|
s = re.sub(r'[^a-z0-9]+', ' ', s)
|
||||||
|
return ' '.join(s.split())
|
||||||
|
|
||||||
|
|
||||||
|
def match_source_tracks(
|
||||||
|
source_tracks: List[Any],
|
||||||
|
library_tracks: List[Dict[str, Any]],
|
||||||
|
title_threshold: float = 0.6,
|
||||||
|
) -> List[Tuple[Dict[str, Any], Optional[Any]]]:
|
||||||
|
"""Pair each library track to a source track.
|
||||||
|
|
||||||
|
Disc+track number is authoritative; falls back to title similarity. A source
|
||||||
|
track is consumed once. Returns ``[(library_track, source_track_or_None)]``
|
||||||
|
in library order, so unmatched library tracks surface as ``None``.
|
||||||
|
"""
|
||||||
|
by_pos: Dict[Tuple[int, int], int] = {}
|
||||||
|
for i, st in enumerate(source_tracks):
|
||||||
|
t = _int_or_none(_get(st, 'track_number'))
|
||||||
|
if t is None:
|
||||||
|
continue
|
||||||
|
d = _int_or_none(_get(st, 'disc_number', default=1)) or 1
|
||||||
|
by_pos.setdefault((d, t), i)
|
||||||
|
|
||||||
|
used: set = set()
|
||||||
|
pairs: List[Tuple[Dict[str, Any], Optional[Any]]] = []
|
||||||
|
for lt in library_tracks:
|
||||||
|
t = _int_or_none(lt.get('track_number'))
|
||||||
|
d = _int_or_none(lt.get('disc_number')) or 1
|
||||||
|
idx = by_pos.get((d, t)) if t is not None else None
|
||||||
|
if idx is not None and idx not in used:
|
||||||
|
used.add(idx)
|
||||||
|
pairs.append((lt, source_tracks[idx]))
|
||||||
|
continue
|
||||||
|
# Title-similarity fallback over still-unused source tracks.
|
||||||
|
lt_norm = _norm_title(lt.get('title'))
|
||||||
|
best_idx, best_score = None, 0.0
|
||||||
|
if lt_norm:
|
||||||
|
for i, st in enumerate(source_tracks):
|
||||||
|
if i in used:
|
||||||
|
continue
|
||||||
|
score = SequenceMatcher(None, lt_norm, _norm_title(_get(st, 'name', 'title', 'track_name'))).ratio()
|
||||||
|
if score > best_score:
|
||||||
|
best_score, best_idx = score, i
|
||||||
|
if best_idx is not None and best_score >= title_threshold:
|
||||||
|
used.add(best_idx)
|
||||||
|
pairs.append((lt, source_tracks[best_idx]))
|
||||||
|
else:
|
||||||
|
pairs.append((lt, None))
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def _target_for_track(source_track: Any, album_meta: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Normalized target tag values from the source for one track."""
|
||||||
|
album_artist = _first_artist(album_meta)
|
||||||
|
track_artist = _first_artist(source_track) or album_artist
|
||||||
|
return {
|
||||||
|
'title': (_get(source_track, 'name', 'title', 'track_name') or '').strip(),
|
||||||
|
'artist': album_artist, # album-level artist
|
||||||
|
'track_artist': track_artist, # per-track (may equal album artist)
|
||||||
|
'album': (_get(album_meta, 'name', 'title', 'album_name') or '').strip(),
|
||||||
|
'year': _year(album_meta),
|
||||||
|
'genre': _genres_list(album_meta), # list
|
||||||
|
'track_number': _int_or_none(_get(source_track, 'track_number')),
|
||||||
|
'disc_number': _int_or_none(_get(source_track, 'disc_number', default=1)) or 1,
|
||||||
|
'track_count': _int_or_none(_get(album_meta, 'total_tracks', 'track_count')),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _current_value(current_tags: Dict[str, Any], field: str):
|
||||||
|
if field == 'artist':
|
||||||
|
# _read_tags stores album_artist + artist; prefer album_artist for the album-level compare.
|
||||||
|
return current_tags.get('album_artist') or current_tags.get('artist') or ''
|
||||||
|
if field == 'genre':
|
||||||
|
return current_tags.get('genre') or ''
|
||||||
|
return current_tags.get(field)
|
||||||
|
|
||||||
|
|
||||||
|
def _display(value) -> str:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return ', '.join(str(v) for v in value)
|
||||||
|
return '' if value is None else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_empty(value) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return True
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip() == ''
|
||||||
|
if isinstance(value, list):
|
||||||
|
return len(value) == 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def plan_track(current_tags: Dict[str, Any], source_track: Any, album_meta: Dict[str, Any],
|
||||||
|
mode: str = MODE_OVERWRITE) -> Dict[str, Any]:
|
||||||
|
"""Diff one library track's current tags against the source target.
|
||||||
|
|
||||||
|
Returns ``{changes, db_data}`` where ``changes`` is ``{field: {old, new}}``
|
||||||
|
for display, and ``db_data`` is the (minimal) payload for
|
||||||
|
``write_tags_to_file`` — it contains ONLY the fields that should be written
|
||||||
|
under ``mode``, so applying never touches unrelated/unchanged tags.
|
||||||
|
"""
|
||||||
|
target = _target_for_track(source_track, album_meta)
|
||||||
|
changes: Dict[str, Dict[str, str]] = {}
|
||||||
|
db_data: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
for field in MANAGED_FIELDS:
|
||||||
|
new_val = target.get(field)
|
||||||
|
if _is_empty(new_val):
|
||||||
|
continue # source gave us nothing for this field — leave the file alone
|
||||||
|
old_val = _current_value(current_tags, field)
|
||||||
|
|
||||||
|
if mode == MODE_FILL_MISSING and not _is_empty(old_val):
|
||||||
|
continue # fill-missing only writes blanks
|
||||||
|
|
||||||
|
old_disp, new_disp = _display(old_val), _display(new_val)
|
||||||
|
if old_disp == new_disp:
|
||||||
|
continue # already correct — nothing to write
|
||||||
|
|
||||||
|
changes[field] = {'old': old_disp, 'new': new_disp}
|
||||||
|
# Map managed field → write_tags_to_file db_data key.
|
||||||
|
if field == 'title':
|
||||||
|
db_data['title'] = new_val
|
||||||
|
elif field == 'artist':
|
||||||
|
db_data['artist_name'] = new_val # = album artist for the writer
|
||||||
|
ta = target.get('track_artist')
|
||||||
|
if ta and ta != new_val:
|
||||||
|
db_data['track_artist'] = ta
|
||||||
|
elif field == 'album':
|
||||||
|
db_data['album_title'] = new_val
|
||||||
|
elif field == 'year':
|
||||||
|
db_data['year'] = new_val
|
||||||
|
elif field == 'genre':
|
||||||
|
db_data['genres'] = new_val # list
|
||||||
|
elif field == 'track_number':
|
||||||
|
db_data['track_number'] = new_val
|
||||||
|
elif field == 'disc_number':
|
||||||
|
db_data['disc_number'] = new_val
|
||||||
|
|
||||||
|
# Always carry track_count alongside a track_number write (writers want both).
|
||||||
|
if 'track_number' in db_data and target.get('track_count'):
|
||||||
|
db_data['track_count'] = target['track_count']
|
||||||
|
|
||||||
|
return {'changes': changes, 'db_data': db_data}
|
||||||
114
tests/test_retag_planner.py
Normal file
114
tests/test_retag_planner.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""Unit tests for the library re-tag planner (pure match + diff + payload)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from core.library import retag_planner as rp
|
||||||
|
|
||||||
|
|
||||||
|
# ── track matching ──
|
||||||
|
|
||||||
|
def test_match_by_disc_and_track_number():
|
||||||
|
src = [
|
||||||
|
{'name': 'A', 'track_number': 1, 'disc_number': 1},
|
||||||
|
{'name': 'B', 'track_number': 2, 'disc_number': 1},
|
||||||
|
]
|
||||||
|
lib = [
|
||||||
|
{'title': 'wrong title', 'track_number': 2, 'disc_number': 1},
|
||||||
|
{'title': 'whatever', 'track_number': 1, 'disc_number': 1},
|
||||||
|
]
|
||||||
|
pairs = rp.match_source_tracks(src, lib)
|
||||||
|
assert pairs[0][1]['name'] == 'B' # lib track #2 → source B
|
||||||
|
assert pairs[1][1]['name'] == 'A'
|
||||||
|
|
||||||
|
|
||||||
|
def test_match_by_title_when_no_track_number():
|
||||||
|
src = [{'name': 'Bohemian Rhapsody', 'track_number': 1, 'disc_number': 1}]
|
||||||
|
lib = [{'title': 'Bohemian Rhapsody (Remastered)', 'track_number': None, 'disc_number': 1}]
|
||||||
|
pairs = rp.match_source_tracks(src, lib)
|
||||||
|
assert pairs[0][1]['name'] == 'Bohemian Rhapsody'
|
||||||
|
|
||||||
|
|
||||||
|
def test_unmatched_library_track_is_none():
|
||||||
|
src = [{'name': 'A', 'track_number': 1, 'disc_number': 1}]
|
||||||
|
lib = [{'title': 'Completely Different', 'track_number': 9, 'disc_number': 1}]
|
||||||
|
pairs = rp.match_source_tracks(src, lib)
|
||||||
|
assert pairs[0][1] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_track_consumed_once():
|
||||||
|
src = [{'name': 'A', 'track_number': 1, 'disc_number': 1}]
|
||||||
|
lib = [
|
||||||
|
{'title': 'A', 'track_number': 1, 'disc_number': 1},
|
||||||
|
{'title': 'A again', 'track_number': 1, 'disc_number': 1},
|
||||||
|
]
|
||||||
|
pairs = rp.match_source_tracks(src, lib)
|
||||||
|
assert pairs[0][1] is not None
|
||||||
|
assert pairs[1][1] is None # the one source track was already used
|
||||||
|
|
||||||
|
|
||||||
|
# ── per-track diff (overwrite) ──
|
||||||
|
|
||||||
|
ALBUM = {'name': 'Real Album', 'artists': [{'name': 'Real Artist'}],
|
||||||
|
'year': '2021-05-01', 'genres': ['Rock', 'Indie'], 'total_tracks': 10}
|
||||||
|
SRC = {'name': 'Real Title', 'track_number': 3, 'disc_number': 1,
|
||||||
|
'artists': [{'name': 'Real Artist'}]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_overwrite_reports_changed_fields_only():
|
||||||
|
current = {'title': 'Old Title', 'album_artist': 'Real Artist',
|
||||||
|
'album': 'Real Album', 'year': '2021', 'genre': 'Rock, Indie',
|
||||||
|
'track_number': 3, 'disc_number': 1}
|
||||||
|
plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_OVERWRITE)
|
||||||
|
# Only the title differs; everything else already matches → single change.
|
||||||
|
assert set(plan['changes']) == {'title'}
|
||||||
|
assert plan['changes']['title'] == {'old': 'Old Title', 'new': 'Real Title'}
|
||||||
|
assert plan['db_data'].get('title') == 'Real Title'
|
||||||
|
# Unchanged fields must NOT be in the write payload.
|
||||||
|
assert 'album_title' not in plan['db_data']
|
||||||
|
|
||||||
|
|
||||||
|
def test_overwrite_writes_album_artist_via_artist_name_key():
|
||||||
|
current = {'title': 'Real Title', 'album_artist': 'WRONG Artist',
|
||||||
|
'album': 'Real Album', 'year': '2021', 'genre': 'Rock, Indie',
|
||||||
|
'track_number': 3, 'disc_number': 1}
|
||||||
|
plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_OVERWRITE)
|
||||||
|
assert plan['changes']['artist'] == {'old': 'WRONG Artist', 'new': 'Real Artist'}
|
||||||
|
assert plan['db_data']['artist_name'] == 'Real Artist' # writer uses artist_name = album artist
|
||||||
|
|
||||||
|
|
||||||
|
def test_track_number_write_carries_track_count():
|
||||||
|
current = {'title': 'Real Title', 'album_artist': 'Real Artist', 'album': 'Real Album',
|
||||||
|
'year': '2021', 'genre': 'Rock, Indie', 'track_number': 99, 'disc_number': 1}
|
||||||
|
plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_OVERWRITE)
|
||||||
|
assert plan['db_data']['track_number'] == 3
|
||||||
|
assert plan['db_data']['track_count'] == 10 # carried alongside
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_changes_when_everything_matches():
|
||||||
|
current = {'title': 'Real Title', 'album_artist': 'Real Artist', 'album': 'Real Album',
|
||||||
|
'year': '2021', 'genre': 'Rock, Indie', 'track_number': 3, 'disc_number': 1}
|
||||||
|
plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_OVERWRITE)
|
||||||
|
assert plan['changes'] == {}
|
||||||
|
assert plan['db_data'] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_blank_field_never_written():
|
||||||
|
album = {'name': 'Real Album', 'artists': [{'name': 'Real Artist'}]} # no year/genres
|
||||||
|
current = {'title': 'Real Title', 'album_artist': 'Real Artist', 'album': 'Real Album',
|
||||||
|
'year': '', 'genre': '', 'track_number': 3, 'disc_number': 1}
|
||||||
|
plan = rp.plan_track(current, SRC, album, mode=rp.MODE_OVERWRITE)
|
||||||
|
assert 'year' not in plan['changes'] and 'year' not in plan['db_data']
|
||||||
|
assert 'genres' not in plan['db_data']
|
||||||
|
|
||||||
|
|
||||||
|
# ── fill-missing mode ──
|
||||||
|
|
||||||
|
def test_fill_missing_only_writes_blanks():
|
||||||
|
current = {'title': 'Keep My Title', 'album_artist': '', 'album': 'Real Album',
|
||||||
|
'year': '', 'genre': 'Rock, Indie', 'track_number': 3, 'disc_number': 1}
|
||||||
|
plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_FILL_MISSING)
|
||||||
|
# title is present (kept), artist + year are blank (filled). genre present (kept).
|
||||||
|
assert set(plan['changes']) == {'artist', 'year'}
|
||||||
|
assert 'title' not in plan['db_data'] # not overwritten in fill-missing
|
||||||
|
assert plan['db_data']['artist_name'] == 'Real Artist'
|
||||||
|
assert plan['db_data']['year'] == '2021'
|
||||||
Loading…
Reference in a new issue