Files
arr/crates/arr-db/migrations/0005_series.sql
T
naps62-yolo ac6ed8b798
ci / rust (push) Successful in 1m36s
ci / web (push) Successful in 34s
e2e / e2e (push) Successful in 1m41s
feat(db): add series, season and episode model (#71)
2026-08-22 22:04:27 +01:00

120 lines
5.4 KiB
SQL

-- TV side of the separate aggregates in DESIGN.md §4. This migration is
-- additive: the movie tables stay concrete and unchanged.
CREATE TABLE series (
id INTEGER PRIMARY KEY,
tmdb_id INTEGER NOT NULL UNIQUE,
title TEXT NOT NULL,
year INTEGER,
original_language TEXT,
root_id INTEGER NOT NULL REFERENCES roots (id),
auto_track INTEGER NOT NULL DEFAULT 0 CHECK (auto_track IN (0, 1)),
overrides TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(overrides)),
-- §4.2 needs upstream completion to derive `ended` rather than store it.
upstream_ended INTEGER NOT NULL DEFAULT 0 CHECK (upstream_ended IN (0, 1)),
blocked INTEGER NOT NULL DEFAULT 0 CHECK (blocked IN (0, 1)),
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 series_root ON series (root_id);
-- Before TV roots existed, the roots foreign key was sufficient to keep a
-- movie on a movie root. Preserve that invariant now that both kinds exist.
CREATE TRIGGER movies_require_movie_root
BEFORE INSERT ON movies
WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'movie')
BEGIN
SELECT RAISE(ABORT, 'movies require a movie root');
END;
CREATE TRIGGER movies_require_movie_root_on_update
BEFORE UPDATE OF root_id ON movies
WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'movie')
BEGIN
SELECT RAISE(ABORT, 'movies require a movie root');
END;
CREATE TRIGGER series_require_tv_root
BEFORE INSERT ON series
WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'tv')
BEGIN
SELECT RAISE(ABORT, 'series require a TV root');
END;
CREATE TRIGGER series_require_tv_root_on_update
BEFORE UPDATE OF root_id ON series
WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'tv')
BEGIN
SELECT RAISE(ABORT, 'series require a TV root');
END;
CREATE TABLE seasons (
id INTEGER PRIMARY KEY,
series_id INTEGER NOT NULL REFERENCES series (id) ON DELETE CASCADE,
number INTEGER NOT NULL CHECK (number >= 0),
tracked INTEGER NOT NULL DEFAULT 0 CHECK (tracked IN (0, 1)),
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 (series_id, number)
) STRICT;
CREATE TABLE episodes (
id INTEGER PRIMARY KEY,
season_id INTEGER NOT NULL REFERENCES seasons (id) ON DELETE CASCADE,
number INTEGER NOT NULL CHECK (number >= 0),
title TEXT NOT NULL,
air_date TEXT,
wanted INTEGER NOT NULL DEFAULT 0 CHECK (wanted IN (0, 1)),
-- Mirrors arr-core's canonical MediaState vocabulary. The older movies
-- table uses legacy names; its additive correction is tracked in #73.
state TEXT NOT NULL DEFAULT 'missing'
CHECK (state IN ('missing', 'downloading', 'available')),
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')),
UNIQUE (season_id, number)
) STRICT;
CREATE INDEX episodes_pending_search
ON episodes (last_searched_at)
WHERE wanted = 1 AND state = 'missing';
CREATE INDEX episodes_state ON episodes (state);
INSERT INTO policies (
name, required_audio, dub_blacklist, hdr_rules, size_bands,
resolution_pref, source_weights, score_weights
) VALUES (
'TV — main',
json('{"require":"original_language","allow_english_fallback":false}'),
json('["pt-BR"]'),
json('{"dv_profile_allow":["8.1"],"dv_profile_reject":["5","7"],"allow_hdr10":true,"allow_hdr10plus":true,"allow_sdr":true}'),
json('{"2160p":{"floor_gib":8,"target_gib":22,"penalty_points_per_gib_over":60},"1080p":{"floor_gib":3,"target_gib":8,"penalty_points_per_gib_over":60}}'),
json('["2160p","1080p"]'),
-- §5.5 deliberately keeps source tier as Remux > BluRay > WEB-DL.
json('{"Remux":4,"BluRay":3,"WEB-DL":2,"WEBRip":1,"HDTV":0}'),
json('{"size_at_target":1000,"source_tier":25,"seeder_doubling":8}')
);
INSERT INTO policies (
name, required_audio, dub_blacklist, hdr_rules, size_bands,
resolution_pref, source_weights, score_weights
) VALUES (
'TV — kids',
json('{"require":"any_of","langs":["pt-PT"],"or_original_when_portuguese":true,"allow_english_fallback":false}'),
json('["pt-BR"]'),
json('{"dv_profile_allow":["8.1"],"dv_profile_reject":["5","7"],"allow_hdr10":true,"allow_hdr10plus":true,"allow_sdr":true}'),
json('{"2160p":{"floor_gib":8,"target_gib":22,"penalty_points_per_gib_over":60},"1080p":{"floor_gib":3,"target_gib":8,"penalty_points_per_gib_over":60}}'),
json('["2160p","1080p"]'),
-- §5.2: pt-PT dubs are concentrated in streaming releases.
json('{"WEB-DL":4,"WEBRip":2,"BluRay":1,"Remux":1,"HDTV":0}'),
json('{"size_at_target":1000,"source_tier":25,"seeder_doubling":8}')
);
INSERT INTO roots (kind, audience, path, policy_id)
SELECT 'tv', 'main', '/mnt/media/tv/main', id FROM policies WHERE name = 'TV — main';
INSERT INTO roots (kind, audience, path, policy_id)
SELECT 'tv', 'kids', '/mnt/media/tv/kids', id FROM policies WHERE name = 'TV — kids';