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:
BoulderBadgeDad 2026-06-08 23:32:42 -07:00
parent 319e90dead
commit a79816ad69
7 changed files with 102 additions and 16 deletions

View file

@ -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).

View file

@ -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).

View file

@ -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')

View file

@ -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]:

View file

@ -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

View file

@ -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

View file

@ -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;