#800: Write Tags must not overwrite a real file value with a placeholder
A mis-grouped library track sits under a 'Various Artists' / '[Unknown Album]' record while the file itself is correctly tagged. Write Tags reads the DB and stamped that junk over the file — destroying the correct tags. (Rematching never helped: a match only stores a source-ID pointer, it never changes the local name/title the writer reads.) Guard: never replace a real file value with a placeholder. Added at both seams so preview and write agree: - build_tag_diff marks such a field protected/no-change (the preview no longer shows a wrong overwrite, and has_changes reflects reality), - write_tags_to_file reads the file's current values and skips the placeholder-over-real fields, preserving the file. Field-agnostic and direction-safe: the guard fires ONLY when the DB value is a placeholder AND the file holds a real one. A legitimate value still writes — including a genuine 'Various Artists' album artist on a real compilation, where the file has no conflicting real value, so the guard doesn't fire. Every write_tags_to_file caller writes DB->file as a correction, so blocking placeholder-over-real is correct for all of them (the download post-process uses a different path, embed_source_ids). 23 new tests (placeholder detection, the guard fn, build_tag_diff on the screenshot-#2 scenario, end-to-end FLAC write preserving real values and still overwriting with real ones). 113 existing repair/retag/tag tests pass.
This commit is contained in:
parent
79a30c055a
commit
4039ec3573
2 changed files with 210 additions and 1 deletions
|
|
@ -136,10 +136,49 @@ def read_file_tags(file_path: str) -> Dict[str, Any]:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Known placeholder / "we don't really know" metadata values. A match in the
|
||||||
|
# DB never warrants overwriting a real value already in the file (issue #800:
|
||||||
|
# a mis-grouped track sits under a "Various Artists" / "[Unknown Album]" record,
|
||||||
|
# and Write Tags would otherwise stamp that junk over the file's correct tags).
|
||||||
|
_PLACEHOLDER_META_VALUES = frozenset({
|
||||||
|
'various artists', 'various artist',
|
||||||
|
'unknown artist', 'unknown album', 'unknown',
|
||||||
|
'[unknown album]', '[unknown artist]', '[unknown]',
|
||||||
|
'untitled album',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def is_placeholder_meta(value: Any) -> bool:
|
||||||
|
"""True for empty or known-placeholder metadata strings (case-insensitive)."""
|
||||||
|
if value is None:
|
||||||
|
return True
|
||||||
|
s = str(value).strip().lower()
|
||||||
|
return s == '' or s in _PLACEHOLDER_META_VALUES
|
||||||
|
|
||||||
|
|
||||||
|
def guard_placeholder_overwrite(db_val: Any, file_val: Any) -> Any:
|
||||||
|
"""#800 guard: never replace a real file value with a placeholder.
|
||||||
|
|
||||||
|
Returns ``None`` (skip the write → preserve the file's value) ONLY when the
|
||||||
|
DB value is a placeholder/empty AND the file already holds a real,
|
||||||
|
non-placeholder value. Otherwise returns ``db_val`` unchanged — so a
|
||||||
|
legitimate value still writes, including a genuine ``Various Artists`` album
|
||||||
|
artist on a real compilation (there the file has no conflicting real value,
|
||||||
|
so the guard doesn't fire).
|
||||||
|
"""
|
||||||
|
if is_placeholder_meta(db_val) and not is_placeholder_meta(file_val):
|
||||||
|
return None
|
||||||
|
return db_val
|
||||||
|
|
||||||
|
|
||||||
def build_tag_diff(file_tags: Dict[str, Any], db_data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
def build_tag_diff(file_tags: Dict[str, Any], db_data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Compare file tags against DB metadata. Returns a list of diffs:
|
Compare file tags against DB metadata. Returns a list of diffs:
|
||||||
[{ field, file_value, db_value, changed }]
|
[{ field, file_value, db_value, changed, protected }]
|
||||||
|
|
||||||
|
``protected`` marks a field the #800 guard is holding back: the DB value is
|
||||||
|
a placeholder and the file's real value would be preserved, so it's shown
|
||||||
|
as no-change rather than a wrong overwrite.
|
||||||
"""
|
"""
|
||||||
fields = [
|
fields = [
|
||||||
('title', 'title', 'Title'),
|
('title', 'title', 'Title'),
|
||||||
|
|
@ -179,12 +218,22 @@ def build_tag_diff(file_tags: Dict[str, Any], db_data: Dict[str, Any]) -> List[D
|
||||||
# Only mark as changed if DB has a value AND it differs from file
|
# Only mark as changed if DB has a value AND it differs from file
|
||||||
# (writer skips fields where DB value is empty, so don't show them as diffs)
|
# (writer skips fields where DB value is empty, so don't show them as diffs)
|
||||||
changed = bool(db_str) and file_str != db_str
|
changed = bool(db_str) and file_str != db_str
|
||||||
|
|
||||||
|
# #800 — if the change would replace a real file value with a
|
||||||
|
# placeholder (Various Artists / [Unknown Album] / …), hold it back:
|
||||||
|
# the writer preserves the file's value, so show it as no-change.
|
||||||
|
protected = False
|
||||||
|
if changed and guard_placeholder_overwrite(db_val, file_val) is None:
|
||||||
|
changed = False
|
||||||
|
protected = True
|
||||||
|
|
||||||
diffs.append({
|
diffs.append({
|
||||||
'field': label,
|
'field': label,
|
||||||
'file_key': file_key,
|
'file_key': file_key,
|
||||||
'file_value': str(file_val) if file_val is not None else '',
|
'file_value': str(file_val) if file_val is not None else '',
|
||||||
'db_value': str(db_val) if db_val is not None else '',
|
'db_value': str(db_val) if db_val is not None else '',
|
||||||
'changed': changed,
|
'changed': changed,
|
||||||
|
'protected': protected,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Cover art — special row
|
# Cover art — special row
|
||||||
|
|
@ -253,6 +302,23 @@ def write_tags_to_file(file_path: str, db_data: Dict[str, Any],
|
||||||
artist = db_data.get('track_artist') or db_data.get('artist_name') # Per-track artist for compilations/DJ mixes
|
artist = db_data.get('track_artist') or db_data.get('artist_name') # Per-track artist for compilations/DJ mixes
|
||||||
album = db_data.get('album_title')
|
album = db_data.get('album_title')
|
||||||
album_artist = db_data.get('artist_name') # Album artist stays as the album-level artist
|
album_artist = db_data.get('artist_name') # Album artist stays as the album-level artist
|
||||||
|
|
||||||
|
# #800 — never overwrite a real value already in the file with a
|
||||||
|
# placeholder DB value (Various Artists / [Unknown Album] / …). A
|
||||||
|
# mis-grouped track sits under such a record; writing it would destroy
|
||||||
|
# the file's correct tags. Reads the file's current values and skips
|
||||||
|
# only the placeholder-over-real fields (legit values, incl. a genuine
|
||||||
|
# compilation's Various Artists, still write — see guard docstring).
|
||||||
|
try:
|
||||||
|
_current = read_file_tags(file_path)
|
||||||
|
except Exception:
|
||||||
|
_current = {}
|
||||||
|
if not _current.get('error'):
|
||||||
|
title = guard_placeholder_overwrite(title, _current.get('title'))
|
||||||
|
artist = guard_placeholder_overwrite(artist, _current.get('artist'))
|
||||||
|
album = guard_placeholder_overwrite(album, _current.get('album'))
|
||||||
|
album_artist = guard_placeholder_overwrite(album_artist, _current.get('album_artist'))
|
||||||
|
|
||||||
year = db_data.get('year')
|
year = db_data.get('year')
|
||||||
genres = db_data.get('genres')
|
genres = db_data.get('genres')
|
||||||
track_num = db_data.get('track_number')
|
track_num = db_data.get('track_number')
|
||||||
|
|
|
||||||
143
tests/test_tag_writer_placeholder_guard.py
Normal file
143
tests/test_tag_writer_placeholder_guard.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
"""#800 — Write Tags must never overwrite a real file value with a placeholder.
|
||||||
|
|
||||||
|
A mis-grouped track sits under a 'Various Artists' / '[Unknown Album]' record,
|
||||||
|
while the file itself is correctly tagged. Previously Write Tags stamped that
|
||||||
|
junk over the good file (data loss). These pin the guard at both seams: the
|
||||||
|
preview (build_tag_diff) and the actual write (write_tags_to_file).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from mutagen.flac import FLAC
|
||||||
|
|
||||||
|
from core.tag_writer import (
|
||||||
|
build_tag_diff,
|
||||||
|
guard_placeholder_overwrite,
|
||||||
|
is_placeholder_meta,
|
||||||
|
write_tags_to_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# pure helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('val', [
|
||||||
|
None, '', ' ', 'Various Artists', 'various artists', 'VARIOUS ARTIST',
|
||||||
|
'Unknown Artist', 'Unknown Album', '[Unknown Album]', 'Unknown', 'Untitled Album',
|
||||||
|
])
|
||||||
|
def test_placeholder_values_detected(val):
|
||||||
|
assert is_placeholder_meta(val) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('val', ['OneRepublic', 'Native (Deluxe)', 'Counting Stars', 'VA Movement'])
|
||||||
|
def test_real_values_not_placeholder(val):
|
||||||
|
assert is_placeholder_meta(val) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_blocks_placeholder_over_real():
|
||||||
|
assert guard_placeholder_overwrite('Various Artists', 'OneRepublic') is None
|
||||||
|
assert guard_placeholder_overwrite('[Unknown Album]', 'Native (Deluxe)') is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_allows_real_over_anything():
|
||||||
|
assert guard_placeholder_overwrite('OneRepublic', 'Old Wrong Name') == 'OneRepublic'
|
||||||
|
assert guard_placeholder_overwrite('OneRepublic', '') == 'OneRepublic'
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_allows_placeholder_when_file_has_no_real_value():
|
||||||
|
# Legit compilation: file album-artist empty → 'Various Artists' still writes.
|
||||||
|
assert guard_placeholder_overwrite('Various Artists', '') == 'Various Artists'
|
||||||
|
assert guard_placeholder_overwrite('Various Artists', 'Various Artists') == 'Various Artists'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_tag_diff — the preview (screenshot #2 scenario)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_diff_protects_placeholder_over_real():
|
||||||
|
file_tags = {'title': 'Counting Stars', 'artist': 'OneRepublic',
|
||||||
|
'album': 'Native (Deluxe)', 'album_artist': 'OneRepublic'}
|
||||||
|
db_data = {'title': 'Counting Stars', 'artist_name': 'Various Artists',
|
||||||
|
'album_title': '[Unknown Album]'}
|
||||||
|
diff = {d['field']: d for d in build_tag_diff(file_tags, db_data)}
|
||||||
|
|
||||||
|
assert diff['Title']['changed'] is False # already equal
|
||||||
|
for f in ('Artist', 'Album', 'Album Artist'):
|
||||||
|
assert diff[f]['changed'] is False, f # held back, not a wrong overwrite
|
||||||
|
assert diff[f]['protected'] is True, f
|
||||||
|
# Nothing real to write → no changes overall.
|
||||||
|
assert not any(d['changed'] for d in build_tag_diff(file_tags, db_data))
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_real_db_value_still_changes():
|
||||||
|
file_tags = {'artist': 'Old Wrong'}
|
||||||
|
db_data = {'artist_name': 'OneRepublic'}
|
||||||
|
diff = {d['field']: d for d in build_tag_diff(file_tags, db_data)}
|
||||||
|
assert diff['Artist']['changed'] is True
|
||||||
|
assert diff['Artist']['protected'] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_compilation_va_writes_when_file_empty():
|
||||||
|
file_tags = {'album_artist': ''} # no real value to protect
|
||||||
|
db_data = {'artist_name': 'Various Artists'}
|
||||||
|
diff = {d['field']: d for d in build_tag_diff(file_tags, db_data)}
|
||||||
|
assert diff['Album Artist']['changed'] is True
|
||||||
|
assert diff['Album Artist']['protected'] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# write_tags_to_file — end-to-end on a real FLAC
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_minimal_flac(path):
|
||||||
|
minimal = (
|
||||||
|
b'fLaC' + b'\x80\x00\x00\x22' + b'\x00\x10\x00\x10'
|
||||||
|
+ b'\x00\x00\x00\x00\x00\x00' + b'\x0a\xc4\x42\xf0\x00\x00\x00\x00' + b'\x00' * 16
|
||||||
|
)
|
||||||
|
with open(path, 'wb') as f:
|
||||||
|
f.write(minimal)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def flac_path():
|
||||||
|
fd, path = tempfile.mkstemp(suffix='.flac')
|
||||||
|
os.close(fd)
|
||||||
|
_make_minimal_flac(path)
|
||||||
|
yield path
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_preserves_real_value_against_placeholder(flac_path):
|
||||||
|
# 1) lay down correct tags (the file is correctly tagged)
|
||||||
|
write_tags_to_file(flac_path, {
|
||||||
|
'title': 'Counting Stars', 'artist_name': 'OneRepublic',
|
||||||
|
'album_title': 'Native (Deluxe)',
|
||||||
|
}, embed_cover=False)
|
||||||
|
|
||||||
|
# 2) attempt to write the mis-grouped DB junk over it
|
||||||
|
result = write_tags_to_file(flac_path, {
|
||||||
|
'title': 'Counting Stars', 'artist_name': 'Various Artists',
|
||||||
|
'album_title': '[Unknown Album]',
|
||||||
|
}, embed_cover=False)
|
||||||
|
assert result['success'] is True
|
||||||
|
|
||||||
|
audio = FLAC(flac_path)
|
||||||
|
assert audio.get('artist') == ['OneRepublic'] # preserved, not clobbered
|
||||||
|
assert audio.get('album') == ['Native (Deluxe)'] # preserved
|
||||||
|
assert audio.get('albumartist') == ['OneRepublic'] # preserved
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_real_value_still_overwrites(flac_path):
|
||||||
|
write_tags_to_file(flac_path, {'artist_name': 'OneRepublic'}, embed_cover=False)
|
||||||
|
# A real (non-placeholder) new value must still overwrite normally.
|
||||||
|
result = write_tags_to_file(flac_path, {'artist_name': 'Coldplay'}, embed_cover=False)
|
||||||
|
assert result['success'] is True
|
||||||
|
assert FLAC(flac_path).get('artist') == ['Coldplay']
|
||||||
Loading…
Reference in a new issue