Files
arr/crates/arr-db/migrations/0001_movies.sql
T
naps62-yolo 84777169d0
ci / web (push) Successful in 7s
ci / rust (push) Successful in 35s
e2e / e2e (push) Successful in 47s
feat(db): SQLite schema and movie migrations (#48)
2026-08-22 19:36:39 +01:00

161 lines
7.3 KiB
SQL

-- Movie side of the domain model in DESIGN.md §4. Series, seasons and
-- episodes are issue #34 and are deliberately absent here.
--
-- Every table is STRICT: SQLite's default type affinity would happily store
-- a string in an INTEGER column, and this schema is the last place that
-- should be forgiving.
CREATE TABLE policies (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
-- §5.2. Which audio track a release must carry, expressed against the
-- title's original language rather than a fixed list.
required_audio TEXT NOT NULL CHECK (json_valid(required_audio)),
-- §5.2. Languages that never satisfy required_audio unless they are the
-- title's own original language. Subtitles are not filtered.
dub_blacklist TEXT NOT NULL CHECK (json_valid(dub_blacklist)),
-- §5.3. Dolby Vision profiles, knowable only from ffprobe.
hdr_rules TEXT NOT NULL CHECK (json_valid(hdr_rules)),
-- §5.5. Per resolution: floor, target and the penalty above target.
size_bands TEXT NOT NULL CHECK (json_valid(size_bands)),
resolution_pref TEXT NOT NULL CHECK (json_valid(resolution_pref)),
-- §5.5. Small tiebreaker, not the dominant scoring term.
source_weights TEXT NOT NULL CHECK (json_valid(source_weights)),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
) STRICT;
-- §5.1. One policy per root, two roots per media kind.
CREATE TABLE roots (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('movie', 'tv')),
audience TEXT NOT NULL CHECK (audience IN ('main', 'kids')),
path TEXT NOT NULL UNIQUE,
policy_id INTEGER NOT NULL REFERENCES policies (id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE (kind, audience)
) STRICT;
-- §4.1. `wanted` is the only intent. `state` is a cache of what reconcile
-- last observed, and arr-core (#7) owns the canonical enum this mirrors.
CREATE TABLE movies (
id INTEGER PRIMARY KEY,
tmdb_id INTEGER NOT NULL UNIQUE,
title TEXT NOT NULL,
year INTEGER,
-- §5.2. BCP-47 from TMDB. Nullable until the first metadata refresh.
original_language TEXT,
root_id INTEGER NOT NULL REFERENCES roots (id),
wanted INTEGER NOT NULL DEFAULT 1 CHECK (wanted IN (0, 1)),
-- §5.1. Per-title relaxations and tightenings of the root policy.
overrides TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(overrides)),
state TEXT NOT NULL DEFAULT 'missing'
CHECK (state IN ('missing', 'grabbed', 'imported')),
-- §6.3. Stops targeted search while leaving RSS matching on.
blocked INTEGER NOT NULL DEFAULT 0 CHECK (blocked IN (0, 1)),
search_attempts INTEGER NOT NULL DEFAULT 0,
last_searched_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
) STRICT;
-- §8. The reconcile loop's work list is "wanted, not blocked, least recently
-- searched first", so the partial index carries the sort column.
CREATE INDEX movies_pending_search
ON movies (last_searched_at)
WHERE wanted = 1 AND blocked = 0;
CREATE INDEX movies_state ON movies (state);
CREATE INDEX movies_root ON movies (root_id);
-- §4. Polymorphic owner so episodes (#34) reuse the table unchanged.
CREATE TABLE media_files (
id INTEGER PRIMARY KEY,
owner_kind TEXT NOT NULL CHECK (owner_kind IN ('movie', 'episode')),
owner_id INTEGER NOT NULL,
path TEXT NOT NULL UNIQUE,
size INTEGER NOT NULL,
-- §5.6. What ffprobe found: resolution, source, hdr, audio and sub tracks.
probed TEXT CHECK (probed IS NULL OR json_valid(probed)),
-- §5.7. Which rule was relaxed to allow this import, if any.
waiver TEXT CHECK (waiver IS NULL OR json_valid(waiver)),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
) STRICT;
CREATE INDEX media_files_owner ON media_files (owner_kind, owner_id);
-- §8. A file with no probe result yet is a pending import.
CREATE INDEX media_files_unprobed ON media_files (id) WHERE probed IS NULL;
CREATE TABLE releases (
id INTEGER PRIMARY KEY,
-- Prowlarr's indexer id. Not a foreign key: indexers live in Prowlarr.
indexer_id INTEGER NOT NULL,
guid TEXT NOT NULL,
name TEXT NOT NULL,
size INTEGER NOT NULL,
seeders INTEGER,
publish_date TEXT,
download_url TEXT NOT NULL,
-- §5.6. What the release name claims, before anything is downloaded.
parsed TEXT NOT NULL CHECK (json_valid(parsed)),
score REAL,
-- §9.3. Three buckets. `rejected_rule` names the rule that killed it so
-- an over-strict filter is visible without reading release names.
verdict TEXT CHECK (verdict IN ('eligible', 'waived', 'rejected')),
rejected_rule TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE (indexer_id, guid),
CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))
) STRICT;
CREATE INDEX releases_verdict ON releases (verdict, score);
-- §7.3. The torrent and the library entry are separate state machines; this
-- row is the torrent's. Download progress stays in memory.
CREATE TABLE grabs (
id INTEGER PRIMARY KEY,
release_id INTEGER NOT NULL REFERENCES releases (id),
target_kind TEXT NOT NULL CHECK (target_kind IN ('movie', 'episode')),
target_id INTEGER NOT NULL,
infohash TEXT NOT NULL UNIQUE,
state TEXT NOT NULL DEFAULT 'sent'
CHECK (state IN ('sent', 'downloaded', 'imported', 'failed')),
grabbed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
imported_at TEXT
) STRICT;
CREATE INDEX grabs_state ON grabs (state);
CREATE INDEX grabs_target ON grabs (target_kind, target_id);
-- §6.3. Keyed on infohash and on normalised name, because the same release
-- reappears under a different infohash.
CREATE TABLE blacklist (
id INTEGER PRIMARY KEY,
infohash TEXT UNIQUE,
normalised_name TEXT NOT NULL,
reason TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
) STRICT;
CREATE INDEX blacklist_name ON blacklist (normalised_name);
-- §4.3. A tag on titles, driving filtering and notification routing. Never a path.
CREATE TABLE owners (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
ntfy_topic TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
) STRICT;
CREATE TABLE title_owners (
title_kind TEXT NOT NULL CHECK (title_kind IN ('movie', 'series')),
title_id INTEGER NOT NULL,
owner_id INTEGER NOT NULL REFERENCES owners (id) ON DELETE CASCADE,
PRIMARY KEY (title_kind, title_id, owner_id)
) STRICT;
CREATE INDEX title_owners_owner ON title_owners (owner_id);