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 = ?
|
||||
WHERE id = ? AND (year IS NULL OR year = '' OR year = '0')
|
||||
""", (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
|
||||
# Completeness repair job (see set_album_api_track_count docstring).
|
||||
|
|
|
|||
|
|
@ -929,6 +929,14 @@ class SpotifyWorker:
|
|||
UPDATE albums SET year = ?
|
||||
WHERE id = ? AND (year IS NULL OR year = '' OR year = '0')
|
||||
""", (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
|
||||
# Completeness repair job (see set_album_api_track_count docstring).
|
||||
|
|
|
|||
|
|
@ -210,14 +210,18 @@ 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_val = db_str if db_str else None
|
||||
|
||||
# Special: year — DB stores int, file stores string
|
||||
if db_key == 'year' and db_val is not None:
|
||||
# Special: year / release date (#824). Prefer the full release_date when
|
||||
# the DB has one — it's authoritative, compare it directly. Otherwise use
|
||||
# the year int, for which a MORE-specific file date with the same year is
|
||||
# preserved (not flagged as a change). DB year is int, file is string.
|
||||
if db_key == 'year':
|
||||
release_date = db_data.get('release_date')
|
||||
if release_date:
|
||||
db_val = str(release_date)
|
||||
db_str = str(release_date).strip()
|
||||
elif db_val is not None:
|
||||
db_str = str(db_val)
|
||||
db_val = str(db_val)
|
||||
# Don't flag a full file date (2023-11-03) → year (2023) as a change
|
||||
# when the years already match: the writer preserves the full date,
|
||||
# so it isn't actually changing (#824). Only a different year is a
|
||||
# real change.
|
||||
if file_str and file_str[:4] == db_str:
|
||||
file_str = db_str
|
||||
|
||||
|
|
@ -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_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')
|
||||
track_num = db_data.get('track_number')
|
||||
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")
|
||||
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:
|
||||
# a NULL canonical means "unresolved" and every tool falls back to
|
||||
# today's behavior, so this is safe to ship dormant. Columns are
|
||||
|
|
@ -1352,6 +1361,7 @@ class MusicDatabase:
|
|||
artist_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
year INTEGER,
|
||||
release_date TEXT,
|
||||
thumb_url TEXT,
|
||||
genres TEXT,
|
||||
track_count INTEGER,
|
||||
|
|
@ -10507,6 +10517,7 @@ class MusicDatabase:
|
|||
MIN(a.id) as id,
|
||||
a.title,
|
||||
a.year,
|
||||
MAX(a.release_date) as release_date,
|
||||
SUM(a.track_count) as track_count,
|
||||
MAX(a.thumb_url) as thumb_url,
|
||||
MAX(a.musicbrainz_release_id) as musicbrainz_release_id,
|
||||
|
|
@ -10566,6 +10577,7 @@ class MusicDatabase:
|
|||
'id': album_row['id'],
|
||||
'title': album_row['title'],
|
||||
'year': album_row['year'],
|
||||
'release_date': album_row['release_date'],
|
||||
'image_url': album_row['thumb_url'],
|
||||
'owned': True, # All albums in our DB are owned
|
||||
'track_count': album_row['track_count'],
|
||||
|
|
@ -10648,7 +10660,7 @@ class MusicDatabase:
|
|||
|
||||
# Field whitelists for safe updates
|
||||
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'}
|
||||
|
||||
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)
|
||||
|
||||
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'),
|
||||
'album_title': track_data.get('album_title'),
|
||||
'year': track_data.get('year'),
|
||||
'release_date': track_data.get('release_date'), # #824: full date wins over year when present
|
||||
'genres': album_genres,
|
||||
'track_number': track_data.get('track_number'),
|
||||
'disc_number': track_data.get('disc_number'),
|
||||
|
|
@ -9613,7 +9614,7 @@ def get_track_tag_preview(track_id):
|
|||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
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
|
||||
FROM tracks t
|
||||
JOIN artists a ON t.artist_id = a.id
|
||||
|
|
@ -9689,7 +9690,7 @@ def get_batch_tag_preview():
|
|||
placeholders = ','.join('?' for _ in track_ids)
|
||||
cursor.execute(f"""
|
||||
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
|
||||
FROM tracks t
|
||||
JOIN artists a ON t.artist_id = a.id
|
||||
|
|
@ -9774,7 +9775,7 @@ def write_track_tags(track_id):
|
|||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
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
|
||||
FROM tracks t
|
||||
JOIN artists a ON t.artist_id = a.id
|
||||
|
|
@ -9868,7 +9869,7 @@ def write_tracks_tags_batch():
|
|||
placeholders = ','.join('?' * len(track_ids))
|
||||
cursor.execute(f"""
|
||||
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
|
||||
FROM tracks t
|
||||
JOIN artists a ON t.artist_id = a.id
|
||||
|
|
|
|||
|
|
@ -3754,6 +3754,7 @@ function renderAlbumMetaRow(album) {
|
|||
const fields = [
|
||||
{ key: 'title', label: 'Title', value: album.title || '' },
|
||||
{ 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: 'label', label: 'Label', value: album.label || '' },
|
||||
{ key: 'style', label: 'Style', value: album.style || '' },
|
||||
|
|
@ -3774,6 +3775,7 @@ function renderAlbumMetaRow(album) {
|
|||
const input = document.createElement('input');
|
||||
input.className = 'enhanced-album-meta-input';
|
||||
input.type = f.type || 'text';
|
||||
if (f.placeholder) input.placeholder = f.placeholder;
|
||||
input.dataset.albumId = album.id;
|
||||
input.dataset.field = f.key;
|
||||
input.value = String(f.value);
|
||||
|
|
@ -5952,6 +5954,7 @@ async function saveAlbumMetadata(albumId) {
|
|||
|
||||
const inputs = metaRow.querySelectorAll('.enhanced-album-meta-input');
|
||||
const updates = {};
|
||||
let invalidDate = false;
|
||||
|
||||
inputs.forEach(input => {
|
||||
const field = input.dataset.field;
|
||||
|
|
@ -5965,11 +5968,20 @@ async function saveAlbumMetadata(albumId) {
|
|||
} else if (field === 'year' || field === 'explicit' || field === 'track_count') {
|
||||
const numVal = value !== '' ? parseInt(value) : null;
|
||||
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 {
|
||||
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) {
|
||||
showToast('No album changes to save', 'error');
|
||||
return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue