From 151135b545735665bea9f9053b5f9c63a832cd46 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 21:46:04 +0100 Subject: [PATCH] feat(arr): add subtitle tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/arr-db/migrations/0024_subtitles.sql | 81 +++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 crates/arr-db/migrations/0024_subtitles.sql diff --git a/crates/arr-db/migrations/0024_subtitles.sql b/crates/arr-db/migrations/0024_subtitles.sql new file mode 100644 index 0000000..6bcab75 --- /dev/null +++ b/crates/arr-db/migrations/0024_subtitles.sql @@ -0,0 +1,81 @@ +-- #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';