soulsync/database/video_database.py
BoulderBadgeDad 402a1fec50 video side: video.db schema + isolated VideoDatabase
Separate SQLite file (database/video_library.db, env VIDEO_DATABASE_PATH),
fully disconnected from music — never imported by music, imports nothing from
music, no shared write lock.

Schema (database/video_schema.sql), designed to dodge the music DB's known
pain points:
- movies; shows->seasons->episodes; channels->channel_videos (YouTube as a
  first-class peer); media_files (the library); downloads (queue+history);
  activity feed; root_folders / quality_profiles / video_settings config.
- No polymorphic ids: media_files/downloads use separate nullable FKs + a CHECK
  that exactly one owner is set; real cascades.
- Explicit external-id columns (tmdb/tvdb/imdb/youtube), no source-id blob.
- Watchlist/Wishlist/Calendar are DERIVED VIEWS over monitored + file state
  (single source of truth, can't drift like music's wishlist table did).

VideoDatabase mirrors music's conventions (WAL, foreign_keys ON, 30s busy
timeout, Row factory, once-per-process init, user_version backstop) but is an
independent implementation. 13 seam tests: schema builds, CHECK constraints
reject bad rows, cascades fire, views return correct membership, KV roundtrips,
and a guard that the module imports nothing from music.
2026-06-13 22:30:55 -07:00

141 lines
5.7 KiB
Python

"""SoulSync — VIDEO side database (database/video_library.db).
ISOLATION CONTRACT: this module owns a SEPARATE SQLite file from the music
library and imports NOTHING from the music database layer. Music code never
imports this; this never imports music. A migration bug, corruption, or reset
here cannot touch music data, and the two never contend for the same write lock.
Conventions mirror database/music_database.py on purpose (so the two feel the
same operationally) — WAL journal, foreign keys ON, a 30s busy timeout, Row
factory, a once-per-process init guard, and PRAGMA user_version as a schema
backstop — but the implementations are independent.
The schema itself lives alongside this file in video_schema.sql and is executed
verbatim on first init.
"""
from __future__ import annotations
import os
import sqlite3
import threading
from pathlib import Path
from utils.logging_config import get_logger
logger = get_logger("video_database")
# Bump when video_schema.sql changes in a way worth recording. Stored in
# PRAGMA user_version as a backstop indicator (nothing gates on it yet).
SCHEMA_VERSION = 1
_DEFAULT_DB_PATH = "database/video_library.db"
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
# Init runs once per database path per process (same guard style as music).
_init_lock = threading.Lock()
_initialized_paths: set[str] = set()
class VideoDatabase:
"""Connection + schema manager for the isolated video library DB."""
def __init__(self, database_path: str | None = None):
# Honour the env override (Docker mounts) the same way music does, but
# under a DISTINCT variable so the two databases never collide.
if database_path is None or database_path == _DEFAULT_DB_PATH:
database_path = os.environ.get("VIDEO_DATABASE_PATH", _DEFAULT_DB_PATH)
self.database_path = Path(database_path)
self.database_path.parent.mkdir(parents=True, exist_ok=True)
self._initialize_once()
# ── connection ──────────────────────────────────────────────────────────
def _get_connection(self) -> sqlite3.Connection:
"""A fresh connection with the standard pragmas applied."""
conn = sqlite3.connect(str(self.database_path), timeout=30.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA busy_timeout = 30000") # 30s
return conn
def connect(self) -> sqlite3.Connection:
"""Public connection factory — caller owns closing it.
Prefer using ``with db.connect() as conn:`` so commits/rollbacks and
close happen automatically.
"""
return self._get_connection()
# ── init ────────────────────────────────────────────────────────────────
def _initialize_once(self) -> None:
key = str(self.database_path.resolve())
with _init_lock:
if key in _initialized_paths:
return
self._initialize_database()
_initialized_paths.add(key)
def _initialize_database(self) -> None:
schema = _SCHEMA_FILE.read_text(encoding="utf-8")
conn = self._get_connection()
try:
conn.executescript(schema)
conn.execute(f"PRAGMA user_version = {int(SCHEMA_VERSION)}")
conn.commit()
logger.info(
"Video database ready at %s (schema v%d)",
self.database_path, SCHEMA_VERSION,
)
except Exception:
conn.rollback()
logger.exception("Failed to initialize video database at %s", self.database_path)
raise
finally:
conn.close()
@property
def schema_version(self) -> int:
conn = self._get_connection()
try:
return int(conn.execute("PRAGMA user_version").fetchone()[0])
finally:
conn.close()
# ── video_settings KV (temporary home until the settings.db move) ────────
def get_setting(self, key: str, default=None):
conn = self._get_connection()
try:
row = conn.execute(
"SELECT value FROM video_settings WHERE key = ?", (key,)
).fetchone()
return row["value"] if row is not None else default
finally:
conn.close()
def set_setting(self, key: str, value: str) -> None:
conn = self._get_connection()
try:
conn.execute(
"INSERT INTO video_settings(key, value, updated_at) "
"VALUES (?, ?, CURRENT_TIMESTAMP) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value, "
"updated_at = CURRENT_TIMESTAMP",
(key, value),
)
conn.commit()
finally:
conn.close()
# ── health ───────────────────────────────────────────────────────────────
def health_check(self) -> bool:
"""True when the DB opens and passes a quick integrity check."""
conn = self._get_connection()
try:
row = conn.execute("PRAGMA quick_check").fetchone()
return bool(row) and row[0] == "ok"
except Exception:
logger.exception("Video database health check failed")
return False
finally:
conn.close()