diff --git a/database/video_database.py b/database/video_database.py new file mode 100644 index 00000000..507dd7b9 --- /dev/null +++ b/database/video_database.py @@ -0,0 +1,141 @@ +"""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() diff --git a/database/video_schema.sql b/database/video_schema.sql new file mode 100644 index 00000000..f996f8f0 --- /dev/null +++ b/database/video_schema.sql @@ -0,0 +1,288 @@ +-- ============================================================================ +-- SoulSync — VIDEO side schema (database/video_library.db) +-- +-- ISOLATION: this is a SEPARATE SQLite file from the music library. The video +-- code owns it exclusively; music never opens it and it never references music +-- tables. A bug, migration, or reset here cannot touch music data, and the two +-- never contend for the same write lock. +-- +-- DESIGN PRINCIPLES (deliberately avoiding the music DB's known pain points): +-- * No polymorphic (entity_type, entity_id) keys. Where a row can belong to a +-- movie OR an episode OR a youtube video, we use separate nullable FKs with +-- a CHECK that exactly one is set — real foreign keys, real cascades. +-- * No "source id" blob / naming spaghetti. External ids are a few explicit, +-- well-named, indexed columns (tmdb_id, tvdb_id, imdb_id, youtube_id). +-- * No metadata dumping-ground column. Structured config that is genuinely a +-- list (quality profile contents) is small, ordered JSON; everything else +-- is a real column. +-- * Watchlist / Wishlist / Calendar are DERIVED VIEWS over monitored + file +-- state, not standalone tables — so they can't drift out of sync with the +-- library the way a duplicated table does. (See note in design summary.) +-- +-- Run order matters (FKs reference earlier tables). The init module executes +-- this whole file inside one transaction with foreign_keys ON. +-- ============================================================================ + +-- ── Meta ──────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +-- ── Configuration ─────────────────────────────────────────────────────────── +-- Root folders: where each kind of library content is stored on disk. +CREATE TABLE IF NOT EXISTS root_folders ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + content_kind TEXT NOT NULL CHECK (content_kind IN ('movie', 'show', 'youtube')), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Quality profiles: an ordered, named acceptance ladder (Radarr/Sonarr-style). +-- `items` is a small JSON array of allowed quality names, best-first; `cutoff` +-- is the name we stop upgrading at. JSON is appropriate here — it is genuinely +-- an ordered list of config, not a metadata grab-bag. +CREATE TABLE IF NOT EXISTS quality_profiles ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + cutoff TEXT, + items TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Video-side settings. KEY/VALUE for now; at the end-of-branch settings.db +-- consolidation these migrate into the shared config store. Value is JSON. +CREATE TABLE IF NOT EXISTS video_settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- ── Content: Movies ───────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS movies ( + id INTEGER PRIMARY KEY, + tmdb_id INTEGER UNIQUE, + imdb_id TEXT, + title TEXT NOT NULL, + sort_title TEXT, + year INTEGER, + overview TEXT, + runtime_minutes INTEGER, + status TEXT, -- announced | in_production | released + release_date TEXT, -- primary/theatrical (ISO date) + digital_release_date TEXT, + studio TEXT, + content_rating TEXT, -- e.g. PG-13 + poster_url TEXT, + backdrop_url TEXT, + monitored INTEGER NOT NULL DEFAULT 1, -- tracked for acquisition + has_file INTEGER NOT NULL DEFAULT 0, -- owned? (denormalized) + quality_profile_id INTEGER REFERENCES quality_profiles(id) ON DELETE SET NULL, + root_folder_id INTEGER REFERENCES root_folders(id) ON DELETE SET NULL, + path TEXT, -- folder on disk once owned + added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_movies_tmdb ON movies(tmdb_id); +CREATE INDEX IF NOT EXISTS idx_movies_monitored ON movies(monitored, has_file); +CREATE INDEX IF NOT EXISTS idx_movies_release ON movies(release_date); + +-- ── Content: TV (shows → seasons → episodes) ──────────────────────────────── +CREATE TABLE IF NOT EXISTS shows ( + id INTEGER PRIMARY KEY, + tvdb_id INTEGER UNIQUE, + tmdb_id INTEGER, + imdb_id TEXT, + title TEXT NOT NULL, + sort_title TEXT, + year INTEGER, + overview TEXT, + status TEXT, -- continuing | ended | upcoming + network TEXT, + runtime_minutes INTEGER, + content_rating TEXT, + poster_url TEXT, + backdrop_url TEXT, + monitored INTEGER NOT NULL DEFAULT 1, -- "following" (watchlist) + quality_profile_id INTEGER REFERENCES quality_profiles(id) ON DELETE SET NULL, + root_folder_id INTEGER REFERENCES root_folders(id) ON DELETE SET NULL, + path TEXT, + added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_shows_tvdb ON shows(tvdb_id); +CREATE INDEX IF NOT EXISTS idx_shows_monitored ON shows(monitored); + +CREATE TABLE IF NOT EXISTS seasons ( + id INTEGER PRIMARY KEY, + show_id INTEGER NOT NULL REFERENCES shows(id) ON DELETE CASCADE, + season_number INTEGER NOT NULL, + title TEXT, + overview TEXT, + poster_url TEXT, + monitored INTEGER NOT NULL DEFAULT 1, + UNIQUE (show_id, season_number) +); +CREATE INDEX IF NOT EXISTS idx_seasons_show ON seasons(show_id); + +CREATE TABLE IF NOT EXISTS episodes ( + id INTEGER PRIMARY KEY, + show_id INTEGER NOT NULL REFERENCES shows(id) ON DELETE CASCADE, + season_id INTEGER NOT NULL REFERENCES seasons(id) ON DELETE CASCADE, + season_number INTEGER NOT NULL, + episode_number INTEGER NOT NULL, + title TEXT, + overview TEXT, + air_date TEXT, -- ISO date — drives the Calendar + runtime_minutes INTEGER, + tvdb_id INTEGER, + monitored INTEGER NOT NULL DEFAULT 1, + has_file INTEGER NOT NULL DEFAULT 0, + UNIQUE (show_id, season_number, episode_number) +); +CREATE INDEX IF NOT EXISTS idx_episodes_show ON episodes(show_id); +CREATE INDEX IF NOT EXISTS idx_episodes_air ON episodes(air_date); +CREATE INDEX IF NOT EXISTS idx_episodes_wanted ON episodes(monitored, has_file); + +-- ── Content: YouTube (channels → videos) ──────────────────────────────────── +CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY, + youtube_id TEXT NOT NULL UNIQUE, -- channel id + title TEXT NOT NULL, + handle TEXT, -- @handle + description TEXT, + avatar_url TEXT, + banner_url TEXT, + monitored INTEGER NOT NULL DEFAULT 1, -- "subscribed" (watchlist) + quality_profile_id INTEGER REFERENCES quality_profiles(id) ON DELETE SET NULL, + root_folder_id INTEGER REFERENCES root_folders(id) ON DELETE SET NULL, + path TEXT, + added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_channels_monitored ON channels(monitored); + +CREATE TABLE IF NOT EXISTS channel_videos ( + id INTEGER PRIMARY KEY, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + youtube_id TEXT NOT NULL UNIQUE, -- video id + title TEXT NOT NULL, + description TEXT, + published_at TEXT, -- ISO datetime — drives feed/Calendar + duration_seconds INTEGER, + thumbnail_url TEXT, + monitored INTEGER NOT NULL DEFAULT 1, + has_file INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_channel_videos_channel ON channel_videos(channel_id); +CREATE INDEX IF NOT EXISTS idx_channel_videos_published ON channel_videos(published_at); +CREATE INDEX IF NOT EXISTS idx_channel_videos_wanted ON channel_videos(monitored, has_file); + +-- ── Owned media files (the Library = content that has a file) ──────────────── +-- Exactly one owner FK is set (no polymorphic id). 1 row per physical file; +-- usually 1:1 with its content, but the table allows history/extras. +CREATE TABLE IF NOT EXISTS media_files ( + id INTEGER PRIMARY KEY, + movie_id INTEGER REFERENCES movies(id) ON DELETE CASCADE, + episode_id INTEGER REFERENCES episodes(id) ON DELETE CASCADE, + video_id INTEGER REFERENCES channel_videos(id) ON DELETE CASCADE, + relative_path TEXT NOT NULL, + size_bytes INTEGER, + resolution TEXT, -- 480p | 720p | 1080p | 2160p + video_codec TEXT, + audio_codec TEXT, + release_source TEXT, -- bluray | web-dl | webrip | hdtv | youtube + quality TEXT, -- resolved quality name + runtime_seconds INTEGER, + added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK ((movie_id IS NOT NULL) + (episode_id IS NOT NULL) + (video_id IS NOT NULL) = 1) +); +CREATE INDEX IF NOT EXISTS idx_media_files_movie ON media_files(movie_id); +CREATE INDEX IF NOT EXISTS idx_media_files_episode ON media_files(episode_id); +CREATE INDEX IF NOT EXISTS idx_media_files_video ON media_files(video_id); + +-- ── Downloads (active queue + history) ────────────────────────────────────── +-- One target per row: a movie, a single episode, a whole season (pack), or a +-- youtube video. Exactly one FK set (CHECK), no polymorphic id. +CREATE TABLE IF NOT EXISTS downloads ( + id INTEGER PRIMARY KEY, + movie_id INTEGER REFERENCES movies(id) ON DELETE SET NULL, + episode_id INTEGER REFERENCES episodes(id) ON DELETE SET NULL, + season_id INTEGER REFERENCES seasons(id) ON DELETE SET NULL, + video_id INTEGER REFERENCES channel_videos(id) ON DELETE SET NULL, + title TEXT NOT NULL, -- display label + release_title TEXT, -- actual release / nzb / torrent name + source TEXT, -- torrent | usenet | youtube + client TEXT, -- qbittorrent | sabnzbd | yt-dlp ... + client_download_id TEXT, -- hash / nzo_id to poll the client + indexer TEXT, + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued','downloading','importing', + 'completed','failed','paused')), + quality TEXT, + size_bytes INTEGER, + downloaded_bytes INTEGER NOT NULL DEFAULT 0, + progress REAL NOT NULL DEFAULT 0, -- 0..100 + download_speed_bps INTEGER NOT NULL DEFAULT 0, + eta_seconds INTEGER, + error_message TEXT, + added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + started_at TEXT, + completed_at TEXT, + CHECK ((movie_id IS NOT NULL) + (episode_id IS NOT NULL) + + (season_id IS NOT NULL) + (video_id IS NOT NULL) = 1) +); +CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status); +CREATE INDEX IF NOT EXISTS idx_downloads_completed ON downloads(completed_at); + +-- ── Activity feed (dashboard "Recent Activity") ───────────────────────────── +CREATE TABLE IF NOT EXISTS activity ( + id INTEGER PRIMARY KEY, + event_type TEXT NOT NULL, -- added | grabbed | imported | failed | renamed + message TEXT NOT NULL, + movie_id INTEGER REFERENCES movies(id) ON DELETE SET NULL, + episode_id INTEGER REFERENCES episodes(id) ON DELETE SET NULL, + video_id INTEGER REFERENCES channel_videos(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_activity_created ON activity(created_at); + +-- ── Derived views: Watchlist / Wishlist / Calendar ────────────────────────── +-- WATCHLIST = things you follow for NEW content: monitored shows + channels. +CREATE VIEW IF NOT EXISTS v_watchlist AS + SELECT 'show' AS kind, id, title, status, poster_url, monitored + FROM shows WHERE monitored = 1 + UNION ALL + SELECT 'channel' AS kind, id, title, NULL AS status, avatar_url AS poster_url, monitored + FROM channels WHERE monitored = 1; + +-- WISHLIST = wanted-but-missing: monitored movies without a file + monitored +-- episodes that have aired but aren't owned. +CREATE VIEW IF NOT EXISTS v_wishlist AS + SELECT 'movie' AS kind, m.id AS ref_id, m.title AS title, + NULL AS parent_title, m.release_date AS due_date + FROM movies m + WHERE m.monitored = 1 AND m.has_file = 0 + UNION ALL + SELECT 'episode' AS kind, e.id AS ref_id, + e.title AS title, s.title AS parent_title, e.air_date AS due_date + FROM episodes e + JOIN shows s ON s.id = e.show_id + WHERE e.monitored = 1 AND e.has_file = 0 + AND e.air_date IS NOT NULL AND e.air_date <= date('now'); + +-- CALENDAR = dated items (episode air dates, movie releases, channel uploads). +CREATE VIEW IF NOT EXISTS v_calendar AS + SELECT 'episode' AS kind, e.id AS ref_id, e.air_date AS date, + e.title AS title, s.title AS parent_title + FROM episodes e JOIN shows s ON s.id = e.show_id + WHERE e.air_date IS NOT NULL + UNION ALL + SELECT 'movie' AS kind, m.id AS ref_id, m.release_date AS date, + m.title AS title, NULL AS parent_title + FROM movies m WHERE m.release_date IS NOT NULL + UNION ALL + SELECT 'video' AS kind, v.id AS ref_id, v.published_at AS date, + v.title AS title, c.title AS parent_title + FROM channel_videos v JOIN channels c ON c.id = v.channel_id + WHERE v.published_at IS NOT NULL; diff --git a/tests/test_video_database.py b/tests/test_video_database.py new file mode 100644 index 00000000..88c71804 --- /dev/null +++ b/tests/test_video_database.py @@ -0,0 +1,163 @@ +"""Seam tests for the isolated video database (experimental branch). + +These pin the contract that matters: the schema builds with all tables/views, +the no-polymorphic-id CHECK constraints actually reject bad rows, cascades fire, +the derived Watchlist/Wishlist/Calendar views return the right membership, the +settings KV roundtrips — and, critically, that the video DB layer imports +NOTHING from the music database (isolation). +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest + +from database.video_database import VideoDatabase, SCHEMA_VERSION + +_MODULE = Path(__file__).resolve().parent.parent / "database" / "video_database.py" + +EXPECTED_TABLES = { + "meta", "root_folders", "quality_profiles", "video_settings", + "movies", "shows", "seasons", "episodes", "channels", "channel_videos", + "media_files", "downloads", "activity", +} +EXPECTED_VIEWS = {"v_watchlist", "v_wishlist", "v_calendar"} + + +@pytest.fixture() +def db(tmp_path): + return VideoDatabase(database_path=str(tmp_path / "video_library.db")) + + +# ── schema ────────────────────────────────────────────────────────────────── + +def test_schema_builds_with_all_tables_and_views(db): + with db.connect() as conn: + tables = {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'")} + views = {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='view'")} + assert EXPECTED_TABLES <= tables, f"missing tables: {EXPECTED_TABLES - tables}" + assert views == EXPECTED_VIEWS + + +def test_schema_version_recorded(db): + assert db.schema_version == SCHEMA_VERSION + + +def test_init_is_idempotent(tmp_path): + path = str(tmp_path / "video_library.db") + VideoDatabase(database_path=path) + # Second construction on the same path must not raise or wipe data. + db2 = VideoDatabase(database_path=path) + assert db2.health_check() + + +def test_health_check_ok(db): + assert db.health_check() is True + + +# ── no-polymorphic-id CHECK constraints ────────────────────────────────────── + +def test_media_file_requires_exactly_one_owner(db): + with db.connect() as conn: + conn.execute("INSERT INTO movies(id,title) VALUES (1,'M')") + conn.execute("INSERT INTO shows(id,title) VALUES (1,'S')") + conn.execute("INSERT INTO seasons(id,show_id,season_number) VALUES (1,1,1)") + conn.execute("INSERT INTO episodes(id,show_id,season_id,season_number,episode_number) " + "VALUES (1,1,1,1,1)") + # zero owners -> reject + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO media_files(relative_path) VALUES ('x.mkv')") + # two owners -> reject + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO media_files(movie_id,episode_id,relative_path) " + "VALUES (1,1,'x.mkv')") + # exactly one -> ok + conn.execute("INSERT INTO media_files(movie_id,relative_path) VALUES (1,'x.mkv')") + + +def test_download_requires_exactly_one_target(db): + with db.connect() as conn: + conn.execute("INSERT INTO movies(id,title) VALUES (1,'M')") + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO downloads(title) VALUES ('no target')") + conn.execute("INSERT INTO downloads(movie_id,title) VALUES (1,'ok')") + + +def test_download_status_is_constrained(db): + with db.connect() as conn: + conn.execute("INSERT INTO movies(id,title) VALUES (1,'M')") + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO downloads(movie_id,title,status) VALUES (1,'x','bogus')") + + +# ── cascades ────────────────────────────────────────────────────────────────── + +def test_deleting_show_cascades_to_seasons_and_episodes(db): + with db.connect() as conn: + conn.execute("INSERT INTO shows(id,title) VALUES (1,'S')") + conn.execute("INSERT INTO seasons(id,show_id,season_number) VALUES (1,1,1)") + conn.execute("INSERT INTO episodes(id,show_id,season_id,season_number,episode_number) " + "VALUES (1,1,1,1,1)") + conn.execute("DELETE FROM shows WHERE id=1") + assert conn.execute("SELECT COUNT(*) FROM seasons").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM episodes").fetchone()[0] == 0 + + +# ── derived views ───────────────────────────────────────────────────────────── + +def test_watchlist_view_is_monitored_shows_and_channels(db): + with db.connect() as conn: + conn.execute("INSERT INTO shows(id,title,monitored) VALUES (1,'Followed',1)") + conn.execute("INSERT INTO shows(id,title,monitored) VALUES (2,'Unfollowed',0)") + conn.execute("INSERT INTO channels(id,youtube_id,title,monitored) VALUES (1,'yt1','Chan',1)") + rows = {(r["kind"], r["title"]) for r in conn.execute("SELECT kind,title FROM v_watchlist")} + assert rows == {("show", "Followed"), ("channel", "Chan")} + + +def test_wishlist_view_is_wanted_but_missing(db): + with db.connect() as conn: + # wanted movie (monitored, no file) -> in wishlist + conn.execute("INSERT INTO movies(id,title,monitored,has_file) VALUES (1,'Want',1,0)") + # owned movie -> not in wishlist + conn.execute("INSERT INTO movies(id,title,monitored,has_file) VALUES (2,'Own',1,1)") + # aired, monitored, missing episode -> in wishlist; future episode -> not + conn.execute("INSERT INTO shows(id,title) VALUES (1,'S')") + conn.execute("INSERT INTO seasons(id,show_id,season_number) VALUES (1,1,1)") + conn.execute("INSERT INTO episodes(id,show_id,season_id,season_number,episode_number," + "monitored,has_file,air_date) VALUES (1,1,1,1,1,1,0,'2000-01-01')") + conn.execute("INSERT INTO episodes(id,show_id,season_id,season_number,episode_number," + "monitored,has_file,air_date) VALUES (2,1,1,1,2,1,0,'2999-01-01')") + rows = [(r["kind"], r["ref_id"]) for r in conn.execute("SELECT kind,ref_id FROM v_wishlist")] + assert ("movie", 1) in rows # wanted movie present + assert ("movie", 2) not in rows # owned movie absent + assert ("episode", 1) in rows # aired+missing episode present + assert ("episode", 2) not in rows # future episode absent + + +def test_settings_kv_roundtrip(db): + assert db.get_setting("download_dir", "unset") == "unset" + db.set_setting("download_dir", "/data/video") + assert db.get_setting("download_dir") == "/data/video" + db.set_setting("download_dir", "/data/video2") # upsert + assert db.get_setting("download_dir") == "/data/video2" + + +# ── isolation: the video DB imports nothing from music ─────────────────────── + +def test_video_db_module_imports_nothing_from_music(): + src = _MODULE.read_text(encoding="utf-8") + for line in src.splitlines(): + stripped = line.strip() + if stripped.startswith("import ") or stripped.startswith("from "): + assert "music" not in stripped.lower(), f"music import leaked in: {stripped!r}" + + +def test_video_db_uses_distinct_default_path_and_env(): + src = _MODULE.read_text(encoding="utf-8") + assert "video_library.db" in src + assert "VIDEO_DATABASE_PATH" in src # distinct env var from music's DATABASE_PATH + assert "music_library.db" not in src