Repair stale media schema during refresh

Ensure upgraded databases have the tracks.file_size and albums.api_track_count columns after all legacy migrations run. Add defensive repair paths for Jellyfin track imports and album track-count caching so stale schemas self-heal instead of dropping full-refresh track imports.

Tests cover legacy schema repair and api_track_count self-repair.
pull/673/head
Broque Thomas 4 days ago
parent 8012f41ef7
commit f1d4f78e0e

@ -64,6 +64,17 @@ def set_album_api_track_count(cursor, album_id, count):
(count, album_id),
)
except Exception as e:
if "api_track_count" in str(e) and "no such column" in str(e).lower():
try:
cursor.execute("ALTER TABLE albums ADD COLUMN api_track_count INTEGER DEFAULT NULL")
cursor.execute(
"UPDATE albums SET api_track_count = ? WHERE id = ?",
(count, album_id),
)
logger.info("Repaired missing api_track_count column while caching album track count")
return
except Exception as repair_error:
e = repair_error
logger.warning(
"Failed to cache api_track_count for album %s: %s", album_id, e
)

@ -766,6 +766,8 @@ class MusicDatabase:
except Exception as ps_err:
logger.error(f"Personalized-playlist schema init failed: {ps_err}")
self._ensure_core_media_schema_columns(cursor)
conn.commit()
logger.info("Database initialized successfully")
@ -775,6 +777,29 @@ class MusicDatabase:
self._init_manual_library_match_table()
def _ensure_core_media_schema_columns(self, cursor):
"""Repair required media-library columns that older migrations may miss.
A few legacy migrations rebuild artists/albums/tracks in place. Newer
installs get these columns from CREATE TABLE, but upgraded databases can
occasionally miss one if a previous migration path failed or was marked
complete before the column existed.
"""
try:
cursor.execute("PRAGMA table_info(tracks)")
track_cols = {c[1] for c in cursor.fetchall()}
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")
cursor.execute("PRAGMA table_info(albums)")
album_cols = {c[1] for c in cursor.fetchall()}
if album_cols and 'api_track_count' not in album_cols:
cursor.execute("ALTER TABLE albums ADD COLUMN api_track_count INTEGER DEFAULT NULL")
logger.info("Repaired missing api_track_count column on albums table")
except Exception as e:
logger.error("Error repairing core media schema columns: %s", e)
def _add_mirrored_playlist_explored_column(self, cursor):
"""Add explored_at column to mirrored_playlists to persist explore badge."""
try:
@ -5499,6 +5524,25 @@ class MusicDatabase:
return True
except Exception as e:
error_text = str(e).lower()
if (
'file_size' in error_text
and ('no such column' in error_text or 'no column named' in error_text)
and retry_count < max_retries - 1
):
try:
repair_conn = conn if 'conn' in locals() else self._get_connection()
repair_cursor = repair_conn.cursor()
self._ensure_core_media_schema_columns(repair_cursor)
repair_conn.commit()
if repair_conn is not conn:
repair_conn.close()
retry_count += 1
logger.info("Repaired missing file_size column while importing media track; retrying")
continue
except Exception as schema_error:
logger.error("Failed to repair tracks.file_size during track import: %s", schema_error)
retry_count += 1
if "database is locked" in str(e).lower() and retry_count < max_retries:
logger.warning(f"Database locked on track '{getattr(track_obj, 'title', 'Unknown')}', retrying {retry_count}/{max_retries}...")

@ -69,6 +69,53 @@ def test_file_size_column_exists_after_init(db: MusicDatabase) -> None:
assert 'file_size' in cols
def test_legacy_media_schema_repairs_required_refresh_columns(tmp_path: Path) -> None:
"""Upgraded installs can have old library tables plus migration markers.
Startup must repair the columns full refresh writes later."""
db_path = tmp_path / 'legacy_missing_media_columns.db'
conn = sqlite3.connect(str(db_path))
cur = conn.cursor()
cur.execute("CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)")
cur.execute("INSERT INTO metadata (key, value) VALUES ('id_columns_migrated', 'true')")
cur.execute("""
CREATE TABLE artists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
)
""")
cur.execute("""
CREATE TABLE albums (
id TEXT PRIMARY KEY,
artist_id TEXT NOT NULL,
title TEXT NOT NULL
)
""")
cur.execute("""
CREATE TABLE tracks (
id TEXT PRIMARY KEY,
album_id TEXT NOT NULL,
artist_id TEXT NOT NULL,
title TEXT NOT NULL,
file_path TEXT,
bitrate INTEGER
)
""")
conn.commit()
conn.close()
repaired = MusicDatabase(database_path=str(db_path))
conn = repaired._get_connection()
cur = conn.cursor()
cur.execute("PRAGMA table_info(tracks)")
track_cols = {row[1] for row in cur.fetchall()}
cur.execute("PRAGMA table_info(albums)")
album_cols = {row[1] for row in cur.fetchall()}
conn.close()
assert 'file_size' in track_cols
assert 'api_track_count' in album_cols
def test_existing_tracks_have_null_file_size_after_migration(db: MusicDatabase) -> None:
"""Backward-compat: rows inserted via the OLD schema (no file_size)
must still be readable, and querying file_size returns NULL not

@ -1,6 +1,8 @@
"""Tests for `worker_utils.set_album_api_track_count` — the shared helper
enrichment workers call to cache authoritative track counts."""
import sqlite3
from core.worker_utils import set_album_api_track_count
@ -114,3 +116,21 @@ def test_swallows_cursor_execute_errors():
cursor = _BrokenCursor()
# Should not raise.
set_album_api_track_count(cursor, "album-z", 10)
def test_repairs_missing_api_track_count_column():
conn = sqlite3.connect(":memory:")
cursor = conn.cursor()
cursor.execute("CREATE TABLE albums (id TEXT PRIMARY KEY, title TEXT)")
cursor.execute("INSERT INTO albums (id, title) VALUES ('album-z', 'Album')")
set_album_api_track_count(cursor, "album-z", 10)
cursor.execute("PRAGMA table_info(albums)")
cols = {row[1] for row in cursor.fetchall()}
cursor.execute("SELECT api_track_count FROM albums WHERE id = 'album-z'")
row = cursor.fetchone()
conn.close()
assert 'api_track_count' in cols
assert row[0] == 10

Loading…
Cancel
Save