From a79816ad696d75dfd6252dddc962f1bc4f76a50f Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Mon, 8 Jun 2026 23:32:42 -0700 Subject: [PATCH] Full release dates: store + write yyyy-mm-dd end to end (#824 part 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/itunes_worker.py | 8 +++++ core/spotify_worker.py | 8 +++++ core/tag_writer.py | 29 ++++++++++------- database/music_database.py | 14 +++++++- tests/test_tag_writer_placeholder_guard.py | 38 ++++++++++++++++++++++ web_server.py | 9 ++--- webui/static/library.js | 12 +++++++ 7 files changed, 102 insertions(+), 16 deletions(-) diff --git a/core/itunes_worker.py b/core/itunes_worker.py index 0d0e10a7..7e3e8a5d 100644 --- a/core/itunes_worker.py +++ b/core/itunes_worker.py @@ -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). diff --git a/core/spotify_worker.py b/core/spotify_worker.py index aa780495..2f2180c1 100644 --- a/core/spotify_worker.py +++ b/core/spotify_worker.py @@ -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). diff --git a/core/tag_writer.py b/core/tag_writer.py index 10993806..b0d4c6fa 100644 --- a/core/tag_writer.py +++ b/core/tag_writer.py @@ -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_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: - 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 + # 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) + 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 # (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_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') diff --git a/database/music_database.py b/database/music_database.py index 1e7c3a6b..5acbf0af 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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]: diff --git a/tests/test_tag_writer_placeholder_guard.py b/tests/test_tag_writer_placeholder_guard.py index 482804aa..cd82917c 100644 --- a/tests/test_tag_writer_placeholder_guard.py +++ b/tests/test_tag_writer_placeholder_guard.py @@ -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 diff --git a/web_server.py b/web_server.py index 8534cae3..b4c537a2 100644 --- a/web_server.py +++ b/web_server.py @@ -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 diff --git a/webui/static/library.js b/webui/static/library.js index 77e6d2ad..eb8dc6b0 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -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;