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.
This commit is contained in:
parent
8012f41ef7
commit
f1d4f78e0e
4 changed files with 122 additions and 0 deletions
|
|
@ -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…
Reference in a new issue