From f4f40c161bc5ae60886a0b22a7c112291368eca8 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 17 Jun 2026 00:56:06 -0700 Subject: [PATCH] Fix: video DB fails to initialize on upgrade (no such column: source_id) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two new partial indexes (idx_video_wishlist_video/_channel on source_id / parent_source_id) were in video_schema.sql, which runs via executescript() BEFORE the column ALTERs. On a fresh DB the CREATE TABLE includes the columns so it's fine, but on an existing (pre-bridge) DB the table already exists without them, so 'CREATE INDEX ... ON video_wishlist(source_id)' threw 'no such column' and the whole init rolled back — the video side wouldn't load at all. Move those two indexes out of the schema into VideoDatabase._ensure_indexes(), run AFTER _ensure_columns(). Regression test simulates a pre-source DB and asserts the in-place upgrade + the youtube path both work. 81 DB tests green. --- database/video_database.py | 17 ++++++++++++++++ database/video_schema.sql | 8 ++++---- tests/test_video_database.py | 38 ++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/database/video_database.py b/database/video_database.py index bc30bdf9..18859685 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -166,6 +166,7 @@ class VideoDatabase: try: conn.executescript(schema) self._ensure_columns(conn) + self._ensure_indexes(conn) conn.execute(f"PRAGMA user_version = {int(SCHEMA_VERSION)}") conn.commit() logger.info( @@ -179,6 +180,22 @@ class VideoDatabase: finally: conn.close() + # Partial indexes that reference migration-added columns. They MUST run after + # _ensure_columns (the schema executescript runs first, before the ALTERs, so + # these would fail with "no such column" on an upgraded DB if placed there). + _POST_INDEXES = ( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_video_wishlist_video " + "ON video_wishlist(source_id) WHERE kind = 'video'", + "CREATE INDEX IF NOT EXISTS idx_video_wishlist_channel " + "ON video_wishlist(parent_source_id) WHERE kind = 'video'", + ) + + @classmethod + def _ensure_indexes(cls, conn) -> None: + """Create indexes that depend on migration-added columns (after columns exist).""" + for stmt in cls._POST_INDEXES: + conn.execute(stmt) + @staticmethod def _ensure_columns(conn) -> None: """Add any new columns to an existing DB (idempotent ALTER TABLE).""" diff --git a/database/video_schema.sql b/database/video_schema.sql index a02530a2..e5528bd7 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -392,10 +392,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_video_wishlist_movie CREATE UNIQUE INDEX IF NOT EXISTS idx_video_wishlist_episode ON video_wishlist(tmdb_id, season_number, episode_number) WHERE kind = 'episode'; CREATE INDEX IF NOT EXISTS idx_video_wishlist_show ON video_wishlist(tmdb_id) WHERE kind = 'episode'; -CREATE UNIQUE INDEX IF NOT EXISTS idx_video_wishlist_video - ON video_wishlist(source_id) WHERE kind = 'video'; -CREATE INDEX IF NOT EXISTS idx_video_wishlist_channel - ON video_wishlist(parent_source_id) WHERE kind = 'video'; +-- NOTE: the source_id / parent_source_id partial indexes are created in code +-- (VideoDatabase._ensure_indexes) AFTER the column migrations run — they can't +-- live here because this script runs via executescript() BEFORE the ALTERs, so +-- on an upgraded DB the columns wouldn't exist yet. -- ── Derived views: Watchlist / Wishlist / Calendar ────────────────────────── -- WATCHLIST = things you follow for NEW content: monitored shows + channels. diff --git a/tests/test_video_database.py b/tests/test_video_database.py index a983d393..aa56235b 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -1088,3 +1088,41 @@ def test_youtube_rows_do_not_disturb_tmdb_counts(db): # youtube counts live on their own surface assert db.youtube_wishlist_counts() == {"channel": 1, "video": 1} assert db.list_watchlist_channels()[0]["video_count"] == 1 + + +def test_upgrade_from_pre_source_schema(tmp_path): + """Regression: an existing pre-bridge DB (no source/source_id/parent_source_id + columns) must upgrade cleanly. The source_id partial indexes can't live in the + schema executescript (it runs BEFORE the column ALTERs), or init blows up with + 'no such column: source_id' and the whole video DB fails to initialize.""" + path = str(tmp_path / "video_library.db") + conn = sqlite3.connect(path) + conn.executescript( + """ + CREATE TABLE video_watchlist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, tmdb_id INTEGER NOT NULL, + title TEXT NOT NULL, poster_url TEXT, library_id INTEGER, + state TEXT NOT NULL DEFAULT 'follow', date_added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(kind, tmdb_id)); + CREATE TABLE video_wishlist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, tmdb_id INTEGER NOT NULL, + title TEXT NOT NULL, poster_url TEXT, year INTEGER, season_number INTEGER, + episode_number INTEGER, episode_title TEXT, still_url TEXT, episode_overview TEXT, + season_poster_url TEXT, air_date TEXT, status TEXT NOT NULL DEFAULT 'wanted', + library_id INTEGER, server_source TEXT, date_added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP); + """) + conn.commit() + conn.close() + + db = VideoDatabase(database_path=path) # must upgrade in place, no raise + with db.connect() as c: + cols = {r[1] for r in c.execute("PRAGMA table_info(video_wishlist)")} + assert {"source", "source_id", "parent_source_id"} <= cols + wcols = {r[1] for r in c.execute("PRAGMA table_info(video_watchlist)")} + assert {"source", "source_id"} <= wcols + idx = {r[0] for r in c.execute("SELECT name FROM sqlite_master WHERE type='index'")} + assert "idx_video_wishlist_video" in idx and "idx_video_wishlist_channel" in idx + # the youtube path actually works on the upgraded DB + assert db.add_videos_to_wishlist({"youtube_id": "UCx", "title": "Chan"}, + [{"youtube_id": "x1", "title": "X1"}]) == 1 + assert db.youtube_wishlist_counts() == {"channel": 1, "video": 1}