mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
608 lines
33 KiB
608 lines
33 KiB
-- ============================================================================
|
|
-- 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
|
|
tagline TEXT,
|
|
tmdb_collection_id INTEGER, -- TMDB belongs_to_collection id (franchise); for "complete your collections" gaps
|
|
tmdb_collection_name TEXT, -- collection display name (e.g. "The Matrix Collection")
|
|
rating REAL, -- TMDB audience score (0-10)
|
|
rating_critic REAL, -- critic score (0-100) when offered
|
|
imdb_rating REAL, -- IMDb (0-10, via OMDb)
|
|
rt_rating INTEGER, -- Rotten Tomatoes (0-100)
|
|
metacritic INTEGER, -- Metacritic (0-100)
|
|
ratings_synced INTEGER NOT NULL DEFAULT 0, -- OMDb ratings fetched?
|
|
poster_url TEXT,
|
|
backdrop_url TEXT,
|
|
logo_url TEXT, -- transparent title logo (clearlogo)
|
|
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);
|
|
-- NOTE: idx on tmdb_collection_id (a migration-added column) lives in _POST_INDEXES,
|
|
-- not here — this schema runs BEFORE the ALTER that adds the column on upgraded DBs.
|
|
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,
|
|
airs_time TEXT, -- TVDB show air time, e.g. "21:00" (network local)
|
|
runtime_minutes INTEGER,
|
|
content_rating TEXT,
|
|
tagline TEXT,
|
|
rating REAL, -- TMDB audience score (0-10)
|
|
imdb_rating REAL, -- IMDb (0-10, via OMDb)
|
|
rt_rating INTEGER, -- Rotten Tomatoes (0-100)
|
|
metacritic INTEGER, -- Metacritic (0-100)
|
|
ratings_synced INTEGER NOT NULL DEFAULT 0, -- OMDb ratings fetched?
|
|
first_air_date TEXT,
|
|
last_air_date TEXT,
|
|
poster_url TEXT,
|
|
backdrop_url TEXT,
|
|
logo_url TEXT, -- transparent title logo (clearlogo)
|
|
episodes_synced INTEGER NOT NULL DEFAULT 0, -- full episode list pulled from metadata?
|
|
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,
|
|
still_url TEXT, -- per-episode thumbnail (server image path)
|
|
rating REAL, -- audience score (0-10)
|
|
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);
|
|
|
|
-- ── Genres (normalised many-to-many; no comma-blob) ─────────────────────────
|
|
CREATE TABLE IF NOT EXISTS genres (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS movie_genres (
|
|
movie_id INTEGER NOT NULL REFERENCES movies(id) ON DELETE CASCADE,
|
|
genre_id INTEGER NOT NULL REFERENCES genres(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (movie_id, genre_id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS show_genres (
|
|
show_id INTEGER NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
|
|
genre_id INTEGER NOT NULL REFERENCES genres(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (show_id, genre_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_movie_genres_genre ON movie_genres(genre_id);
|
|
CREATE INDEX IF NOT EXISTS idx_show_genres_genre ON show_genres(genre_id);
|
|
|
|
-- ── People + credits (cast & crew; normalised, no blob) ─────────────────────
|
|
-- A person appears in many titles; deduped by their provider id. Each credit
|
|
-- belongs to exactly one movie OR show (separate nullable FKs + CHECK, no
|
|
-- polymorphic id), mirroring the media_files/downloads pattern.
|
|
CREATE TABLE IF NOT EXISTS people (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
tmdb_id INTEGER UNIQUE,
|
|
photo_url TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_people_name ON people(name);
|
|
|
|
CREATE TABLE IF NOT EXISTS credits (
|
|
id INTEGER PRIMARY KEY,
|
|
person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
|
|
movie_id INTEGER REFERENCES movies(id) ON DELETE CASCADE,
|
|
show_id INTEGER REFERENCES shows(id) ON DELETE CASCADE,
|
|
department TEXT NOT NULL, -- 'cast' | 'crew'
|
|
job TEXT, -- Director | Writer | Creator (crew); 'Actor' (cast)
|
|
character TEXT, -- the role played (cast)
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
CHECK ((movie_id IS NOT NULL) + (show_id IS NOT NULL) = 1)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_credits_movie ON credits(movie_id);
|
|
CREATE INDEX IF NOT EXISTS idx_credits_show ON credits(show_id);
|
|
CREATE INDEX IF NOT EXISTS idx_credits_person ON credits(person_id);
|
|
|
|
-- ── Discover: "Not interested" / ignore list ────────────────────────────────
|
|
-- Titles the user has hidden from Discover (movie/show level only — episodes can't be
|
|
-- individually ignored). Keyed by (kind, tmdb_id); a denormalised title/poster snapshot so
|
|
-- the manage-modal renders without a TMDB round-trip.
|
|
CREATE TABLE IF NOT EXISTS video_ignored (
|
|
kind TEXT NOT NULL, -- 'movie' | 'show'
|
|
tmdb_id INTEGER NOT NULL,
|
|
title TEXT,
|
|
year INTEGER,
|
|
poster_url TEXT,
|
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (kind, tmdb_id)
|
|
);
|
|
|
|
-- ── 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);
|
|
|
|
-- Cheap persistent cache of YouTube video upload dates (the flat listing omits
|
|
-- them). Filled from the channel RSS feed + any per-video metadata fetch, so the
|
|
-- channel page's year-seasons fill in over time without re-fetching. Standalone
|
|
-- (no channels FK) since the bridge stores channels in video_watchlist.
|
|
CREATE TABLE IF NOT EXISTS youtube_video_dates (
|
|
youtube_id TEXT PRIMARY KEY,
|
|
published_at TEXT,
|
|
cached_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Tracks which followed channels have had their full upload dates fetched (by the
|
|
-- background enricher) so we don't re-sweep them constantly.
|
|
CREATE TABLE IF NOT EXISTS youtube_channel_enrichment (
|
|
channel_id TEXT PRIMARY KEY,
|
|
enriched_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
date_count INTEGER NOT NULL DEFAULT 0,
|
|
method TEXT -- 'innertube' | 'fallback'; NULL = legacy → re-enrich once
|
|
);
|
|
|
|
-- Remembered per-channel catalog so re-opening a channel (especially a watchlisted
|
|
-- one) is instant: served cache-first, then a background re-stream refreshes it.
|
|
-- Upload dates stay in youtube_video_dates (merged on read); this holds the list.
|
|
CREATE TABLE IF NOT EXISTS youtube_channel_videos (
|
|
channel_id TEXT NOT NULL,
|
|
youtube_id TEXT NOT NULL,
|
|
title TEXT,
|
|
thumbnail_url TEXT,
|
|
duration TEXT, -- overlay badge, e.g. "12:34"
|
|
view_count INTEGER, -- approximate (parsed from "2.6M views")
|
|
cached_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (channel_id, youtube_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_ycv_channel ON youtube_channel_videos(channel_id);
|
|
|
|
-- Remembered channel metadata (avatar/subs/tags/banner) so the header renders
|
|
-- instantly on re-open without a yt-dlp re-fetch.
|
|
CREATE TABLE IF NOT EXISTS youtube_channel_meta (
|
|
channel_id TEXT PRIMARY KEY,
|
|
title TEXT,
|
|
handle TEXT,
|
|
description TEXT,
|
|
avatar_url TEXT,
|
|
banner_url TEXT,
|
|
subscriber_count INTEGER,
|
|
view_count INTEGER,
|
|
tags TEXT, -- JSON array
|
|
cached_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Per-video supplementary stats from the no-key YouTube enrichers (keyed by
|
|
-- youtube_id, NOT by channel, so a video shared across playlists is enriched
|
|
-- once). Merged onto the cached catalog on read.
|
|
-- ryd_* Return YouTube Dislike -> like/dislike estimates
|
|
-- sb_* SponsorBlock -> crowd-sourced segments (in youtube_video_segments)
|
|
-- status columns: NULL = pending, 'ok' | 'not_found' | 'error'.
|
|
CREATE TABLE IF NOT EXISTS youtube_video_stats (
|
|
youtube_id TEXT PRIMARY KEY,
|
|
like_count INTEGER,
|
|
dislike_count INTEGER,
|
|
ryd_status TEXT,
|
|
ryd_attempted TEXT,
|
|
sb_status TEXT,
|
|
sb_attempted TEXT,
|
|
dearrow_title TEXT, -- DeArrow crowd-sourced better title
|
|
dearrow_status TEXT,
|
|
dearrow_attempted TEXT
|
|
);
|
|
|
|
-- SponsorBlock crowd segments (sponsor/intro/outro/selfpromo/…) for a video.
|
|
CREATE TABLE IF NOT EXISTS youtube_video_segments (
|
|
youtube_id TEXT NOT NULL,
|
|
category TEXT NOT NULL, -- sponsor | intro | outro | selfpromo | interaction | music_offtopic | preview | filler | poi_highlight | chapter
|
|
start_sec REAL NOT NULL,
|
|
end_sec REAL NOT NULL,
|
|
votes INTEGER,
|
|
uuid TEXT NOT NULL,
|
|
PRIMARY KEY (youtube_id, uuid)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_yvseg_video ON youtube_video_segments(youtube_id);
|
|
|
|
-- ── 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);
|
|
|
|
-- ── User watchlist (curated follow-list: shows + people) ────────────────────
|
|
-- DISTINCT from the library-derived v_watchlist below: this is the user's
|
|
-- explicit follow-list and may include shows/people that are NOT in the library
|
|
-- yet (the whole point of following someone). Keyed on the stable cross-context
|
|
-- tmdb_id that both shows and people carry. The monitoring/discovery engine is a
|
|
-- later phase — this table just records membership + enough to render + link.
|
|
CREATE TABLE IF NOT EXISTS video_watchlist (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
kind TEXT NOT NULL, -- 'show' | 'person' | 'channel' (youtube)
|
|
tmdb_id INTEGER NOT NULL, -- tmdb id; for non-tmdb sources a stable surrogate of source_id
|
|
title TEXT NOT NULL, -- show title / person name / channel title
|
|
poster_url TEXT, -- poster (show) / photo (person) / avatar (channel)
|
|
library_id INTEGER, -- shows.id when owned (else NULL)
|
|
-- generic source bridge: 'tmdb' (default) or 'youtube'; source_id = native id
|
|
-- (channel youtube id) for non-tmdb rows. One table, both worlds.
|
|
source TEXT NOT NULL DEFAULT 'tmdb',
|
|
source_id TEXT,
|
|
-- 'follow' = explicit user follow. 'mute' = a TOMBSTONE: the user
|
|
-- un-followed something that is on the watchlist by default (an actively
|
|
-- airing library show), so the default must not re-add it. Library shows
|
|
-- that are still airing are watched by default WITHOUT a row here.
|
|
state TEXT NOT NULL DEFAULT 'follow',
|
|
date_added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(kind, tmdb_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_video_watchlist_kind ON video_watchlist(kind);
|
|
|
|
-- WISHLIST (curated 'get this') — atomic units are MOVIES and EPISODES. Adding a
|
|
-- whole show or a season just expands into episode rows. Upcoming (un-aired)
|
|
-- episodes do NOT live here; the watchlist/calendar promote them once they air,
|
|
-- so the wishlist only ever holds things you can actually acquire right now.
|
|
CREATE TABLE IF NOT EXISTS video_wishlist (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
kind TEXT NOT NULL, -- 'movie' | 'episode' | 'video' (youtube)
|
|
tmdb_id INTEGER NOT NULL, -- movie's tmdb id | the SHOW's tmdb id (episode) | channel surrogate (video)
|
|
title TEXT NOT NULL, -- movie title | show title | channel title (video rows)
|
|
poster_url TEXT, -- movie/show poster | channel avatar (video rows)
|
|
year INTEGER, -- movie year (movie rows)
|
|
season_number INTEGER, -- episode rows
|
|
episode_number INTEGER, -- episode rows
|
|
episode_title TEXT, -- episode rows | video title (video rows)
|
|
still_url TEXT, -- episode still | video thumbnail (video rows)
|
|
episode_overview TEXT, -- episode synopsis | video description (video rows)
|
|
season_poster_url TEXT, -- the episode's SEASON poster (episode rows)
|
|
air_date TEXT, -- episode air date | video published_at (video rows)
|
|
status TEXT NOT NULL DEFAULT 'wanted', -- wanted|searching|downloading|downloaded|failed
|
|
library_id INTEGER, -- owned movies.id/shows.id when re-downloading
|
|
server_source TEXT, -- server context that added it (informational)
|
|
-- generic source bridge (mirrors video_watchlist). For 'video' rows:
|
|
-- source='youtube', source_id=video youtube id, parent_source_id=channel youtube id.
|
|
source TEXT NOT NULL DEFAULT 'tmdb',
|
|
source_id TEXT,
|
|
parent_source_id TEXT, -- owning channel's youtube id (video rows)
|
|
date_added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
-- one row per movie, one per (show, season, episode), one per youtube video —
|
|
-- partial uniques so the shapes don't collide and re-adding is an idempotent upsert.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_video_wishlist_movie
|
|
ON video_wishlist(tmdb_id) WHERE kind = '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';
|
|
-- 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.
|
|
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;
|
|
|
|
-- DOWNLOADS — every grab initiated from the video side lands here (movies/tv/youtube).
|
|
-- The pipeline starts the download, watches it, and on completion moves the file to the
|
|
-- per-type library folder and marks it completed. Status: queued|downloading|completed|failed.
|
|
CREATE TABLE IF NOT EXISTS video_downloads (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
kind TEXT NOT NULL, -- movie | show | youtube
|
|
title TEXT, -- human title (e.g. the movie name)
|
|
release_title TEXT, -- the release/file being grabbed
|
|
source TEXT, -- soulseek | torrent | usenet
|
|
username TEXT, -- slskd uploader (for the grab + status)
|
|
filename TEXT, -- slskd remote filename (full path)
|
|
size_bytes INTEGER DEFAULT 0,
|
|
quality_label TEXT,
|
|
media_id TEXT, -- the movie/show id (for the detail-page link)
|
|
media_source TEXT, -- library | tmdb
|
|
year INTEGER,
|
|
poster_url TEXT, -- poster for the Downloads card
|
|
target_dir TEXT, -- destination library folder
|
|
dest_path TEXT, -- final moved path (set on completion)
|
|
status TEXT NOT NULL DEFAULT 'downloading',
|
|
progress REAL DEFAULT 0,
|
|
error TEXT,
|
|
candidates TEXT, -- JSON: remaining best-first hits to retry
|
|
search_ctx TEXT, -- JSON: {scope,title,year,season,episode}
|
|
tried_queries TEXT, -- JSON: slskd queries already searched
|
|
tried_files TEXT, -- JSON: release filenames already attempted
|
|
attempts INTEGER DEFAULT 0,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
completed_at TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_video_downloads_status ON video_downloads(status);
|
|
|
|
-- video_download_history — a PERMANENT record of every grab SoulSync completed
|
|
-- (movies + episodes). video_downloads is the transient working queue (cleaned when
|
|
-- finished); this is the archive that powers the Download History modal AND the
|
|
-- smart post-download scan (newest completed item per library → probe the server).
|
|
CREATE TABLE IF NOT EXISTS video_download_history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
download_id INTEGER, -- original video_downloads.id (transient)
|
|
kind TEXT NOT NULL, -- movie | show
|
|
media_type TEXT, -- movie | show (normalized; the scan scope)
|
|
title TEXT, -- movie/show title
|
|
year INTEGER,
|
|
season_number INTEGER, -- episodes only
|
|
episode_number INTEGER,
|
|
episode_title TEXT,
|
|
release_title TEXT, -- the release/file grabbed
|
|
source TEXT, -- soulseek | torrent | usenet
|
|
username TEXT, -- uploader (soulseek)
|
|
filename TEXT, -- remote filename grabbed
|
|
dest_path TEXT, -- final placed path
|
|
size_bytes INTEGER DEFAULT 0,
|
|
quality_label TEXT, -- e.g. "1080p", "2160p HDR"
|
|
resolution TEXT, -- parsed from the release name, best-effort
|
|
video_codec TEXT,
|
|
media_id TEXT, -- movie/show id for the detail deep-link
|
|
media_source TEXT, -- library | tmdb
|
|
poster_url TEXT,
|
|
outcome TEXT NOT NULL DEFAULT 'completed', -- completed | import_failed | failed | cancelled
|
|
error TEXT, -- failure reason (non-completed)
|
|
grabbed_at TEXT, -- when the download started
|
|
completed_at TEXT, -- terminal time
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_vdl_history_kind ON video_download_history(kind);
|
|
CREATE INDEX IF NOT EXISTS idx_vdl_history_completed ON video_download_history(completed_at);
|
|
-- one history row per terminal download (idempotent re-persist / restart-safe)
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_vdl_history_dedup
|
|
ON video_download_history(download_id, outcome, dest_path);
|