Fix: video DB fails to initialize on upgrade (no such column: source_id)

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.
This commit is contained in:
BoulderBadgeDad 2026-06-17 00:56:06 -07:00
parent b440f2bcdc
commit f4f40c161b
3 changed files with 59 additions and 4 deletions

View file

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

View file

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

View file

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