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.
This commit is contained in:
parent
dbe35fc023
commit
402a1fec50
3 changed files with 592 additions and 0 deletions
141
database/video_database.py
Normal file
141
database/video_database.py
Normal file
|
|
@ -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()
|
||||
288
database/video_schema.sql
Normal file
288
database/video_schema.sql
Normal file
|
|
@ -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;
|
||||
163
tests/test_video_database.py
Normal file
163
tests/test_video_database.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue