Full release dates: store + write yyyy-mm-dd end to end (#824 part 2)
Part 1 stopped existing full dates being destroyed; this adds first-class support for full release dates so they can be set + persisted instead of truncated to a year at the DB layer. - Schema: new nullable `release_date TEXT` on the albums table (idempotent ALTER-ADD-COLUMN repair on startup + the live CREATE). NULL = year-only, every reader falls back to albums.year, so it ships safe/dormant. - Tag writer: write_tags_to_file + build_tag_diff prefer db_data['release_date'] (the full date) over the year int; _date_to_write writes the full date. When there's no release_date it's exactly Part-1 behavior (year, preserving an equally-specific existing file date). - Retag read path: SELECT al.release_date in the tag-preview/write queries and thread it into _build_library_tag_db_data. - Manual edit: release_date added to ALBUM_EDITABLE_FIELDS + a "Release Date" field (YYYY-MM-DD, validated client-side) in the album editor; the artist-album query returns it so existing values show. User-set dates are authoritative. - Enrichment: Spotify + iTunes workers store the source's full release_date (YYYY-MM / YYYY-MM-DD) when present, only when empty — never clobbering a manual value. Tests: writer uses release_date over year + overrides an existing file date; falls back to year when absent; diff compares the full date. Migration verified idempotent + enrichment no-clobber. 1435 tag/retag/db/library tests pass.
This commit is contained in:
parent
319e90dead
commit
a79816ad69
7 changed files with 102 additions and 16 deletions
|
|
@ -734,6 +734,14 @@ class iTunesWorker:
|
||||||
UPDATE albums SET year = ?
|
UPDATE albums SET year = ?
|
||||||
WHERE id = ? AND (year IS NULL OR year = '' OR year = '0')
|
WHERE id = ? AND (year IS NULL OR year = '' OR year = '0')
|
||||||
""", (year, album_id))
|
""", (year, album_id))
|
||||||
|
# #824: also store the FULL release date when iTunes has one
|
||||||
|
# (YYYY-MM or YYYY-MM-DD). Only when empty — never clobber a
|
||||||
|
# manually-set release_date.
|
||||||
|
if len(album_obj.release_date) > 4:
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE albums SET release_date = ?
|
||||||
|
WHERE id = ? AND (release_date IS NULL OR release_date = '')
|
||||||
|
""", (album_obj.release_date, album_id))
|
||||||
|
|
||||||
# Cache the authoritative expected track count for the Album
|
# Cache the authoritative expected track count for the Album
|
||||||
# Completeness repair job (see set_album_api_track_count docstring).
|
# Completeness repair job (see set_album_api_track_count docstring).
|
||||||
|
|
|
||||||
|
|
@ -929,6 +929,14 @@ class SpotifyWorker:
|
||||||
UPDATE albums SET year = ?
|
UPDATE albums SET year = ?
|
||||||
WHERE id = ? AND (year IS NULL OR year = '' OR year = '0')
|
WHERE id = ? AND (year IS NULL OR year = '' OR year = '0')
|
||||||
""", (year, album_id))
|
""", (year, album_id))
|
||||||
|
# #824: also store the FULL release date when Spotify has one
|
||||||
|
# (YYYY-MM or YYYY-MM-DD, not just a bare year). Only when empty —
|
||||||
|
# never clobber a manually-set release_date.
|
||||||
|
if len(album_obj.release_date) > 4:
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE albums SET release_date = ?
|
||||||
|
WHERE id = ? AND (release_date IS NULL OR release_date = '')
|
||||||
|
""", (album_obj.release_date, album_id))
|
||||||
|
|
||||||
# Cache the authoritative expected track count for the Album
|
# Cache the authoritative expected track count for the Album
|
||||||
# Completeness repair job (see set_album_api_track_count docstring).
|
# Completeness repair job (see set_album_api_track_count docstring).
|
||||||
|
|
|
||||||
|
|
@ -210,16 +210,20 @@ def build_tag_diff(file_tags: Dict[str, Any], db_data: Dict[str, Any]) -> List[D
|
||||||
db_str = ', '.join(db_val) if db_val else ''
|
db_str = ', '.join(db_val) if db_val else ''
|
||||||
db_val = db_str if db_str else None
|
db_val = db_str if db_str else None
|
||||||
|
|
||||||
# Special: year — DB stores int, file stores string
|
# Special: year / release date (#824). Prefer the full release_date when
|
||||||
if db_key == 'year' and db_val is not None:
|
# the DB has one — it's authoritative, compare it directly. Otherwise use
|
||||||
db_str = str(db_val)
|
# the year int, for which a MORE-specific file date with the same year is
|
||||||
db_val = str(db_val)
|
# preserved (not flagged as a change). DB year is int, file is string.
|
||||||
# Don't flag a full file date (2023-11-03) → year (2023) as a change
|
if db_key == 'year':
|
||||||
# when the years already match: the writer preserves the full date,
|
release_date = db_data.get('release_date')
|
||||||
# so it isn't actually changing (#824). Only a different year is a
|
if release_date:
|
||||||
# real change.
|
db_val = str(release_date)
|
||||||
if file_str and file_str[:4] == db_str:
|
db_str = str(release_date).strip()
|
||||||
file_str = db_str
|
elif db_val is not None:
|
||||||
|
db_str = str(db_val)
|
||||||
|
db_val = str(db_val)
|
||||||
|
if file_str and file_str[:4] == db_str:
|
||||||
|
file_str = db_str
|
||||||
|
|
||||||
# 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)
|
||||||
|
|
@ -325,7 +329,10 @@ def write_tags_to_file(file_path: str, db_data: Dict[str, Any],
|
||||||
album = guard_placeholder_overwrite(album, _current.get('album'))
|
album = guard_placeholder_overwrite(album, _current.get('album'))
|
||||||
album_artist = guard_placeholder_overwrite(album_artist, _current.get('album_artist'))
|
album_artist = guard_placeholder_overwrite(album_artist, _current.get('album_artist'))
|
||||||
|
|
||||||
year = db_data.get('year')
|
# Prefer the full release_date (e.g. 2023-09-01) when the DB has one;
|
||||||
|
# fall back to the year-only int. _date_to_write() then writes the full
|
||||||
|
# date and still preserves an equally-specific existing file date (#824).
|
||||||
|
year = db_data.get('release_date') or 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')
|
||||||
total_tracks = db_data.get('track_count')
|
total_tracks = db_data.get('track_count')
|
||||||
|
|
|
||||||
|
|
@ -1020,6 +1020,15 @@ class MusicDatabase:
|
||||||
cursor.execute("ALTER TABLE albums ADD COLUMN api_track_count INTEGER DEFAULT NULL")
|
cursor.execute("ALTER TABLE albums ADD COLUMN api_track_count INTEGER DEFAULT NULL")
|
||||||
logger.info("Repaired missing api_track_count column on albums table")
|
logger.info("Repaired missing api_track_count column on albums table")
|
||||||
|
|
||||||
|
# Full release date (#824). Additive + nullable: NULL means "only the
|
||||||
|
# year is known", and every reader falls back to albums.year, so this
|
||||||
|
# is safe to ship dormant. Populated by enrichment + manual edit;
|
||||||
|
# consumed by the tag writer to write the full date (e.g. 2023-09-01)
|
||||||
|
# instead of truncating it to the year.
|
||||||
|
if album_cols and 'release_date' not in album_cols:
|
||||||
|
cursor.execute("ALTER TABLE albums ADD COLUMN release_date TEXT DEFAULT NULL")
|
||||||
|
logger.info("Added release_date column to albums table (#824)")
|
||||||
|
|
||||||
# Canonical album version (#765 / #767-Bug2). Additive + nullable:
|
# Canonical album version (#765 / #767-Bug2). Additive + nullable:
|
||||||
# a NULL canonical means "unresolved" and every tool falls back to
|
# a NULL canonical means "unresolved" and every tool falls back to
|
||||||
# today's behavior, so this is safe to ship dormant. Columns are
|
# today's behavior, so this is safe to ship dormant. Columns are
|
||||||
|
|
@ -1352,6 +1361,7 @@ class MusicDatabase:
|
||||||
artist_id TEXT NOT NULL,
|
artist_id TEXT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
year INTEGER,
|
year INTEGER,
|
||||||
|
release_date TEXT,
|
||||||
thumb_url TEXT,
|
thumb_url TEXT,
|
||||||
genres TEXT,
|
genres TEXT,
|
||||||
track_count INTEGER,
|
track_count INTEGER,
|
||||||
|
|
@ -10507,6 +10517,7 @@ class MusicDatabase:
|
||||||
MIN(a.id) as id,
|
MIN(a.id) as id,
|
||||||
a.title,
|
a.title,
|
||||||
a.year,
|
a.year,
|
||||||
|
MAX(a.release_date) as release_date,
|
||||||
SUM(a.track_count) as track_count,
|
SUM(a.track_count) as track_count,
|
||||||
MAX(a.thumb_url) as thumb_url,
|
MAX(a.thumb_url) as thumb_url,
|
||||||
MAX(a.musicbrainz_release_id) as musicbrainz_release_id,
|
MAX(a.musicbrainz_release_id) as musicbrainz_release_id,
|
||||||
|
|
@ -10566,6 +10577,7 @@ class MusicDatabase:
|
||||||
'id': album_row['id'],
|
'id': album_row['id'],
|
||||||
'title': album_row['title'],
|
'title': album_row['title'],
|
||||||
'year': album_row['year'],
|
'year': album_row['year'],
|
||||||
|
'release_date': album_row['release_date'],
|
||||||
'image_url': album_row['thumb_url'],
|
'image_url': album_row['thumb_url'],
|
||||||
'owned': True, # All albums in our DB are owned
|
'owned': True, # All albums in our DB are owned
|
||||||
'track_count': album_row['track_count'],
|
'track_count': album_row['track_count'],
|
||||||
|
|
@ -10648,7 +10660,7 @@ class MusicDatabase:
|
||||||
|
|
||||||
# Field whitelists for safe updates
|
# Field whitelists for safe updates
|
||||||
ARTIST_EDITABLE_FIELDS = {'name', 'genres', 'summary', 'style', 'mood', 'label'}
|
ARTIST_EDITABLE_FIELDS = {'name', 'genres', 'summary', 'style', 'mood', 'label'}
|
||||||
ALBUM_EDITABLE_FIELDS = {'title', 'year', 'genres', 'style', 'mood', 'label', 'explicit', 'record_type', 'track_count'}
|
ALBUM_EDITABLE_FIELDS = {'title', 'year', 'release_date', 'genres', 'style', 'mood', 'label', 'explicit', 'record_type', 'track_count'}
|
||||||
TRACK_EDITABLE_FIELDS = {'title', 'track_number', 'bpm', 'explicit', 'style', 'mood'}
|
TRACK_EDITABLE_FIELDS = {'title', 'track_number', 'bpm', 'explicit', 'style', 'mood'}
|
||||||
|
|
||||||
def get_artist_full_detail(self, artist_id) -> Dict[str, Any]:
|
def get_artist_full_detail(self, artist_id) -> Dict[str, Any]:
|
||||||
|
|
|
||||||
|
|
@ -178,3 +178,41 @@ def test_write_corrects_year_when_it_actually_differs(flac_path):
|
||||||
write_tags_to_file(flac_path, {'year': 2023}, embed_cover=False)
|
write_tags_to_file(flac_path, {'year': 2023}, embed_cover=False)
|
||||||
|
|
||||||
assert FLAC(flac_path).get('date') == ['2023'] # wrong year still corrected
|
assert FLAC(flac_path).get('date') == ['2023'] # wrong year still corrected
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #824 Part 2 — DB release_date (full date) is written, and wins over year
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_write_uses_db_release_date_over_year(flac_path):
|
||||||
|
write_tags_to_file(flac_path, {'year': 2023, 'release_date': '2023-09-01'},
|
||||||
|
embed_cover=False)
|
||||||
|
assert FLAC(flac_path).get('date') == ['2023-09-01'] # full DB date written, not the year
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_db_release_date_overrides_existing_file_date(flac_path):
|
||||||
|
audio = FLAC(flac_path)
|
||||||
|
audio['date'] = ['2023-11-03'] # file has a different (but same-year) date
|
||||||
|
audio.save()
|
||||||
|
write_tags_to_file(flac_path, {'year': 2023, 'release_date': '2023-09-01'},
|
||||||
|
embed_cover=False)
|
||||||
|
# The DB's explicit full date is authoritative — it replaces the file's date.
|
||||||
|
assert FLAC(flac_path).get('date') == ['2023-09-01']
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_falls_back_to_year_when_no_release_date(flac_path):
|
||||||
|
write_tags_to_file(flac_path, {'year': 2023, 'release_date': None}, embed_cover=False)
|
||||||
|
assert FLAC(flac_path).get('date') == ['2023']
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_uses_release_date_when_present():
|
||||||
|
# File has a full date that differs from the DB's release_date → real change.
|
||||||
|
diff = {d['field']: d for d in build_tag_diff(
|
||||||
|
{'year': '2023-11-03'}, {'year': 2023, 'release_date': '2023-09-01'})}
|
||||||
|
assert diff['Year']['changed'] is True
|
||||||
|
assert diff['Year']['db_value'] == '2023-09-01'
|
||||||
|
|
||||||
|
# File already equals the DB release_date → no change.
|
||||||
|
diff = {d['field']: d for d in build_tag_diff(
|
||||||
|
{'year': '2023-09-01'}, {'year': 2023, 'release_date': '2023-09-01'})}
|
||||||
|
assert diff['Year']['changed'] is False
|
||||||
|
|
|
||||||
|
|
@ -9577,6 +9577,7 @@ def _build_library_tag_db_data(track_data, album_genres=None):
|
||||||
'track_artist': track_data.get('track_artist'),
|
'track_artist': track_data.get('track_artist'),
|
||||||
'album_title': track_data.get('album_title'),
|
'album_title': track_data.get('album_title'),
|
||||||
'year': track_data.get('year'),
|
'year': track_data.get('year'),
|
||||||
|
'release_date': track_data.get('release_date'), # #824: full date wins over year when present
|
||||||
'genres': album_genres,
|
'genres': album_genres,
|
||||||
'track_number': track_data.get('track_number'),
|
'track_number': track_data.get('track_number'),
|
||||||
'disc_number': track_data.get('disc_number'),
|
'disc_number': track_data.get('disc_number'),
|
||||||
|
|
@ -9613,7 +9614,7 @@ def get_track_tag_preview(track_id):
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT t.*, a.name as artist_name, al.title as album_title,
|
SELECT t.*, a.name as artist_name, al.title as album_title,
|
||||||
al.year, al.genres as album_genres, al.track_count,
|
al.year, al.release_date, al.genres as album_genres, al.track_count,
|
||||||
al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url
|
al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN artists a ON t.artist_id = a.id
|
JOIN artists a ON t.artist_id = a.id
|
||||||
|
|
@ -9689,7 +9690,7 @@ def get_batch_tag_preview():
|
||||||
placeholders = ','.join('?' for _ in track_ids)
|
placeholders = ','.join('?' for _ in track_ids)
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
SELECT t.*, a.name as artist_name, al.title as album_title,
|
SELECT t.*, a.name as artist_name, al.title as album_title,
|
||||||
al.year, al.genres as album_genres, al.track_count,
|
al.year, al.release_date, al.genres as album_genres, al.track_count,
|
||||||
al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url
|
al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN artists a ON t.artist_id = a.id
|
JOIN artists a ON t.artist_id = a.id
|
||||||
|
|
@ -9774,7 +9775,7 @@ def write_track_tags(track_id):
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT t.*, a.name as artist_name, al.title as album_title,
|
SELECT t.*, a.name as artist_name, al.title as album_title,
|
||||||
al.year, al.genres as album_genres, al.track_count,
|
al.year, al.release_date, al.genres as album_genres, al.track_count,
|
||||||
al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url
|
al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN artists a ON t.artist_id = a.id
|
JOIN artists a ON t.artist_id = a.id
|
||||||
|
|
@ -9868,7 +9869,7 @@ def write_tracks_tags_batch():
|
||||||
placeholders = ','.join('?' * len(track_ids))
|
placeholders = ','.join('?' * len(track_ids))
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
SELECT t.*, a.name as artist_name, al.title as album_title,
|
SELECT t.*, a.name as artist_name, al.title as album_title,
|
||||||
al.year, al.genres as album_genres, al.track_count,
|
al.year, al.release_date, al.genres as album_genres, al.track_count,
|
||||||
al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url
|
al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN artists a ON t.artist_id = a.id
|
JOIN artists a ON t.artist_id = a.id
|
||||||
|
|
|
||||||
|
|
@ -3754,6 +3754,7 @@ function renderAlbumMetaRow(album) {
|
||||||
const fields = [
|
const fields = [
|
||||||
{ key: 'title', label: 'Title', value: album.title || '' },
|
{ key: 'title', label: 'Title', value: album.title || '' },
|
||||||
{ key: 'year', label: 'Year', value: album.year || '', type: 'number' },
|
{ key: 'year', label: 'Year', value: album.year || '', type: 'number' },
|
||||||
|
{ key: 'release_date', label: 'Release Date', value: album.release_date || '', placeholder: 'YYYY-MM-DD' },
|
||||||
{ key: 'genres', label: 'Genres', value: Array.isArray(album.genres) ? album.genres.join(', ') : (album.genres || '') },
|
{ key: 'genres', label: 'Genres', value: Array.isArray(album.genres) ? album.genres.join(', ') : (album.genres || '') },
|
||||||
{ key: 'label', label: 'Label', value: album.label || '' },
|
{ key: 'label', label: 'Label', value: album.label || '' },
|
||||||
{ key: 'style', label: 'Style', value: album.style || '' },
|
{ key: 'style', label: 'Style', value: album.style || '' },
|
||||||
|
|
@ -3774,6 +3775,7 @@ function renderAlbumMetaRow(album) {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.className = 'enhanced-album-meta-input';
|
input.className = 'enhanced-album-meta-input';
|
||||||
input.type = f.type || 'text';
|
input.type = f.type || 'text';
|
||||||
|
if (f.placeholder) input.placeholder = f.placeholder;
|
||||||
input.dataset.albumId = album.id;
|
input.dataset.albumId = album.id;
|
||||||
input.dataset.field = f.key;
|
input.dataset.field = f.key;
|
||||||
input.value = String(f.value);
|
input.value = String(f.value);
|
||||||
|
|
@ -5952,6 +5954,7 @@ async function saveAlbumMetadata(albumId) {
|
||||||
|
|
||||||
const inputs = metaRow.querySelectorAll('.enhanced-album-meta-input');
|
const inputs = metaRow.querySelectorAll('.enhanced-album-meta-input');
|
||||||
const updates = {};
|
const updates = {};
|
||||||
|
let invalidDate = false;
|
||||||
|
|
||||||
inputs.forEach(input => {
|
inputs.forEach(input => {
|
||||||
const field = input.dataset.field;
|
const field = input.dataset.field;
|
||||||
|
|
@ -5965,11 +5968,20 @@ async function saveAlbumMetadata(albumId) {
|
||||||
} else if (field === 'year' || field === 'explicit' || field === 'track_count') {
|
} else if (field === 'year' || field === 'explicit' || field === 'track_count') {
|
||||||
const numVal = value !== '' ? parseInt(value) : null;
|
const numVal = value !== '' ? parseInt(value) : null;
|
||||||
if (numVal !== (album[field] || null)) updates[field] = numVal;
|
if (numVal !== (album[field] || null)) updates[field] = numVal;
|
||||||
|
} else if (field === 'release_date') {
|
||||||
|
// Accept empty, YYYY, YYYY-MM or YYYY-MM-DD (#824 full release dates).
|
||||||
|
if (value && !/^\d{4}(-\d{2}(-\d{2})?)?$/.test(value)) { invalidDate = true; return; }
|
||||||
|
if ((value || '') !== (album.release_date || '')) updates[field] = value || null;
|
||||||
} else {
|
} else {
|
||||||
if ((value || '') !== (album[field] || '')) updates[field] = value || null;
|
if ((value || '') !== (album[field] || '')) updates[field] = value || null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (invalidDate) {
|
||||||
|
showToast('Release Date must be YYYY-MM-DD (or just YYYY)', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
if (Object.keys(updates).length === 0) {
|
||||||
showToast('No album changes to save', 'error');
|
showToast('No album changes to save', 'error');
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue