Files
arr/crates/arr-db/migrations/0024_subtitles.sql
T
Miguel Palhas 151135b545 feat(arr): add subtitle tables
DESIGN.md §15 needs two shapes: what subtitles exist for a media file,
and what arr has tried per wanted language. An embedded track carries no
path — it is recorded because it satisfies a language, not because there
is a file — and the CHECK constraints tie provider, engine and path to
the origin so an impossible row cannot be written.
2026-08-24 21:46:04 +01:00

82 lines
4.4 KiB
SQL

-- #186. What subtitles exist for a media file, and what arr has tried per
-- wanted language (DESIGN.md §15). Settings rows (#198) and the budget
-- buckets (#197) are deliberately absent.
-- One row per subtitle arr knows about. An embedded track that was never
-- extracted has no `path`: it is recorded because it satisfies a language,
-- not because there is a file. Everything else is a sidecar next to the
-- video (§15, §7.4), so the path is the row's identity on disk.
CREATE TABLE subtitle_files (
id INTEGER PRIMARY KEY,
media_file_id INTEGER NOT NULL REFERENCES media_files (id) ON DELETE CASCADE,
-- As `arr_core::Language` spells it: `pt-PT`, `pt-BR`, `en`.
language TEXT NOT NULL,
origin TEXT NOT NULL
CHECK (origin IN ('embedded', 'extracted', 'provider', 'translated')),
-- §15. The named provider a fetch came from, and the candidate id it was
-- ranked as, so a re-fetch of the same candidate is recognisable.
provider TEXT,
candidate_id TEXT,
-- §15. The translation backend that produced it, when machine made.
engine TEXT,
-- §15. A forced track never satisfies a want; an SDH one does, ranked
-- below a plain subtitle. Both are facts about the file, so both are
-- stored and the ranking rule stays in `arr-core`.
forced INTEGER NOT NULL DEFAULT 0 CHECK (forced IN (0, 1)),
sdh INTEGER NOT NULL DEFAULT 0 CHECK (sdh IN (0, 1)),
-- §15. `alass` reports no confidence, so its output is accepted unless
-- implausible. `synced` is an accepted shift; `sync_rejected` means the
-- unsynced original was kept and this file is flagged. Never both.
synced INTEGER NOT NULL DEFAULT 0 CHECK (synced IN (0, 1)),
sync_rejected INTEGER NOT NULL DEFAULT 0 CHECK (sync_rejected IN (0, 1)),
path TEXT UNIQUE,
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')),
CHECK ((path IS NULL) = (origin = 'embedded')),
CHECK ((provider IS NOT NULL) = (origin = 'provider')),
CHECK (candidate_id IS NULL OR provider IS NOT NULL),
CHECK ((engine IS NOT NULL) = (origin = 'translated')),
CHECK (NOT (synced = 1 AND sync_rejected = 1))
) STRICT;
-- §15's satisfaction check is "is there a subtitle in this language", asked
-- per file per tick.
CREATE INDEX subtitle_files_media_file ON subtitle_files (media_file_id, language);
-- An embedded track is identified by what it satisfies, not by a file, so a
-- second probe of the same video re-records it rather than duplicating it.
CREATE UNIQUE INDEX subtitle_files_embedded
ON subtitle_files (media_file_id, language, forced, sdh)
WHERE origin = 'embedded';
-- One row per (media file, wanted language). What the reconcile loop reads to
-- decide whether a language is a gap, what backs a failing provider off, and
-- what the missing-subtitles queue renders.
--
-- `capped` is a state and not an error (§15): being at a daily allowance is
-- something the queue shows. The allowance itself is #197 and not here.
-- `unavailable` is the end of the line — no provider has it and there is no
-- text source to translate from, so retrying costs budget for nothing.
CREATE TABLE subtitle_attempts (
media_file_id INTEGER NOT NULL REFERENCES media_files (id) ON DELETE CASCADE,
language TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'wanted'
CHECK (state IN ('wanted', 'satisfied', 'failed', 'capped', 'unavailable')),
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
last_attempt_at TEXT,
-- Why the last attempt failed, for the queue and for backoff. Cleared
-- when an attempt succeeds.
last_failure 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')),
PRIMARY KEY (media_file_id, language),
CHECK ((attempts = 0) = (last_attempt_at IS NULL))
) STRICT;
-- §8, §15. The work list is every language that is not satisfied yet, least
-- recently attempted first within it; the ordering across files is
-- newest-import-first and comes from the join to `media_files`.
CREATE INDEX subtitle_attempts_pending
ON subtitle_attempts (state, last_attempt_at)
WHERE state <> 'satisfied';