-- ============================================================================ -- 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, server_source TEXT, -- 'plex' | 'jellyfin' (NULL = not on a server yet, e.g. wishlist) server_id TEXT, -- media server native id (Plex ratingKey / Jellyfin Item Id) tmdb_id INTEGER, -- not unique: same film can sit in >1 library imdb_id TEXT, tmdb_match_status TEXT, -- enrichment: NULL=pending | matched | not_found | error tmdb_last_attempted 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); -- Upsert/stale-removal key: the server's native id. Multiple NULLs are allowed -- (wishlist items not yet on a server), so this never blocks non-server rows. CREATE UNIQUE INDEX IF NOT EXISTS ux_movies_server ON movies(server_source, server_id); -- ── Content: TV (shows → seasons → episodes) ──────────────────────────────── CREATE TABLE IF NOT EXISTS shows ( id INTEGER PRIMARY KEY, server_source TEXT, -- 'plex' | 'jellyfin' (NULL = not on a server yet) server_id TEXT, -- media server native id tvdb_id INTEGER, -- not unique (same series can sit in >1 library) tmdb_id INTEGER, imdb_id TEXT, tmdb_match_status TEXT, -- enrichment match state per source tmdb_last_attempted TEXT, tvdb_match_status TEXT, tvdb_last_attempted 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 UNIQUE INDEX IF NOT EXISTS ux_shows_server ON shows(server_source, server_id); CREATE TABLE IF NOT EXISTS seasons ( id INTEGER PRIMARY KEY, show_id INTEGER NOT NULL REFERENCES shows(id) ON DELETE CASCADE, server_id TEXT, -- media server native id (for reference/refresh) 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, server_source TEXT, -- 'plex' | 'jellyfin' server_id TEXT, -- media server native id 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;