From f2f4f8cceea3240592066a90f956686e1562154b Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 23 Jun 2026 11:03:24 -0700 Subject: [PATCH] #910: add the per-track 'year' column the Full Refresh insert needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full Refresh INSERTs a per-track year (from file tags) into tracks.year, but that column was only ever in the live INSERT — never in CREATE TABLE and never in a migration. So on EVERY db (old and current — verified the shipped music_library.db lacks it too) every Full Refresh track insert hard-failed with 'table tracks has no column named year', importing 0 tracks while artists/albums succeeded. Fix (additive + nullable, nothing reads it but the writer): - add year INTEGER to the tracks CREATE TABLE (new DBs) - ALTER it onto existing tracks tables in _ensure_core_media_schema_columns (the repair backstop that already runs every init), right beside the file_size repair Tests (tests/test_tracks_year_migration.py): fresh-DB has it, nullable, idempotent, ALTERs onto an old year-less table, and a regression that the exact Full Refresh insert fails before the repair and succeeds after. --- database/music_database.py | 9 +++ tests/test_tracks_year_migration.py | 104 ++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 tests/test_tracks_year_migration.py diff --git a/database/music_database.py b/database/music_database.py index 1b140874..2eb1ed80 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -301,6 +301,7 @@ class MusicDatabase: file_path TEXT, bitrate INTEGER, file_size INTEGER, -- bytes; populated by deep scan from media-server API + year INTEGER, -- per-track release year from file tags (albums.year is canonical) created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE, @@ -1156,6 +1157,14 @@ class MusicDatabase: if track_cols and 'file_size' not in track_cols: cursor.execute("ALTER TABLE tracks ADD COLUMN file_size INTEGER") logger.info("Repaired missing file_size column on tracks table") + # #910 — Full Refresh writes a per-track `year` (from file tags), but the column + # was only ever in the live INSERT, never in CREATE TABLE or a migration. On any + # DB that predates this fix, every Full Refresh track insert hard-fails with + # "table tracks has no column named year". Additive + nullable; nothing reads it + # except the writer, so this is safe to backfill on every existing DB. + if track_cols and 'year' not in track_cols: + cursor.execute("ALTER TABLE tracks ADD COLUMN year INTEGER") + logger.info("Repaired missing year column on tracks table (#910)") cursor.execute("PRAGMA table_info(albums)") album_cols = {c[1] for c in cursor.fetchall()} diff --git a/tests/test_tracks_year_migration.py b/tests/test_tracks_year_migration.py new file mode 100644 index 00000000..8aedff45 --- /dev/null +++ b/tests/test_tracks_year_migration.py @@ -0,0 +1,104 @@ +"""Migration + regression test for the per-track ``year`` column (#910). + +Full Refresh INSERTs a per-track ``year`` (read from file tags), but the column was +only ever present in that live INSERT — never in CREATE TABLE and never in a +migration. So on every DB (old *and* current) the Full Refresh track insert +hard-failed with ``table tracks has no column named year``, importing 0 tracks +while artists/albums imported fine. + +The repair backstop (``_ensure_core_media_schema_columns``) must ALTER ``year`` +onto any tracks table that lacks it. Additive + nullable; nothing reads it except +the writer, so backfilling it on every existing DB is safe. +""" + +from __future__ import annotations + +import sqlite3 + +import pytest + +from database.music_database import MusicDatabase + + +def _track_cols(cur): + cur.execute("PRAGMA table_info(tracks)") + return {c[1] for c in cur.fetchall()} + + +# An upgraded tracks table that has every Full Refresh insert column EXCEPT year +# (mirrors the #910 reporter exactly: "64 columns, only year absent"). Text ids +# match the live schema after the id->TEXT migration. +_OLD_TRACKS = ( + "CREATE TABLE tracks (id TEXT PRIMARY KEY, album_id TEXT, artist_id TEXT, " + "title TEXT, track_number INTEGER, disc_number INTEGER, duration INTEGER, " + "file_path TEXT, bitrate INTEGER, server_source TEXT, " + "created_at TIMESTAMP, updated_at TIMESTAMP)" +) + +# The exact Full Refresh insert from web_server.py (the statement that failed). +_FULL_REFRESH_INSERT = ( + "INSERT OR IGNORE INTO tracks (id, album_id, artist_id, title, track_number, disc_number, " + "duration, file_path, bitrate, year, server_source, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'soulsync', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" +) +_ROW = ('t1::soulsync', 'a1', 'ar1', 'Song', 1, 1, 200000, '/music/song.flac', 1000, 2009) + + +def test_fresh_db_has_year_column(tmp_path): + db = MusicDatabase(str(tmp_path / "m.db")) + cur = db._get_connection().cursor() + assert 'year' in _track_cols(cur) + + +def test_year_column_is_nullable(tmp_path): + db = MusicDatabase(str(tmp_path / "m.db")) + cur = db._get_connection().cursor() + cur.execute("PRAGMA table_info(tracks)") + info = {c[1]: c for c in cur.fetchall()} # name -> (cid, name, type, notnull, dflt, pk) + assert info['year'][3] == 0 # nullable -> safe to add to a populated table + + +def test_migration_is_idempotent(tmp_path): + db = MusicDatabase(str(tmp_path / "m.db")) + cur = db._get_connection().cursor() + before = _track_cols(cur) + # Re-running must not raise (the PRAGMA guard skips the existing column). + db._ensure_core_media_schema_columns(cur) + db._ensure_core_media_schema_columns(cur) + assert _track_cols(cur) == before + assert 'year' in _track_cols(cur) + + +def test_migration_adds_year_to_old_tracks_table(tmp_path): + conn = sqlite3.connect(str(tmp_path / "old.db")) + conn.execute(_OLD_TRACKS) + conn.commit() + cur = conn.cursor() + assert 'year' not in _track_cols(cur) + + db = MusicDatabase(str(tmp_path / "scratch.db")) + db._ensure_core_media_schema_columns(cur) + conn.commit() + + assert 'year' in _track_cols(cur) + + +def test_full_refresh_insert_fails_before_repair_and_succeeds_after(tmp_path): + """Regression for #910: the real Full Refresh track insert hard-fails on a + year-less tracks table, then succeeds once the repair has added the column.""" + conn = sqlite3.connect(str(tmp_path / "old.db")) + conn.execute(_OLD_TRACKS) + conn.commit() + cur = conn.cursor() + + # Before the fix: the live insert blows up exactly as the issue reports. + with pytest.raises(sqlite3.OperationalError, match="no column named year"): + cur.execute(_FULL_REFRESH_INSERT, _ROW) + + # Apply the repair backstop, then the same insert must succeed and persist year. + db = MusicDatabase(str(tmp_path / "scratch.db")) + db._ensure_core_media_schema_columns(cur) + cur.execute(_FULL_REFRESH_INSERT, _ROW) + conn.commit() + cur.execute("SELECT year FROM tracks WHERE id = 't1::soulsync'") + assert cur.fetchone()[0] == 2009