Persist subtitle files and per-language attempts #186

Closed
opened 2026-08-24 21:28:32 +01:00 by naps62-yolo · 1 comment
Owner

Add the migration and queries that let arr remember what subtitles exist and
what it has tried, per DESIGN.md §15 (added by #183).

Two shapes:

  • Subtitle files. One row per subtitle arr knows about for a media file:
    the owning MediaFile, language, where it came from (embedded, extracted
    from an embedded track, fetched from a named provider, or machine
    translated), the provider and candidate id where applicable, the translation
    engine where applicable, forced and SDH flags, whether alass synced it and
    whether that sync was rejected, and the path on disk for anything that is a
    sidecar. Embedded tracks that were never extracted have no path — they are
    recorded because they satisfy a language, not because there is a file.
  • Per-language attempts. One row per (media file, wanted language): state,
    attempt count, last attempt, and the reason the last attempt failed. This is
    what the reconcile loop reads to decide whether a language is a gap, what
    backs off a failing provider, and what the missing-subtitles queue renders.

Follow the existing migration conventions in crates/arr-db/migrations, keep
queries sqlx compile-time-checked, and keep checksums stable — see the
0021/0022 pair and the fix in a3897df for what that costs when it goes
wrong.

Non-goals: any settings rows (that is #198), any budget accounting (#197).

Depends on: #183

Add the migration and queries that let arr remember what subtitles exist and what it has tried, per DESIGN.md §15 (added by #183). Two shapes: - **Subtitle files.** One row per subtitle arr knows about for a media file: the owning `MediaFile`, language, where it came from (embedded, extracted from an embedded track, fetched from a named provider, or machine translated), the provider and candidate id where applicable, the translation engine where applicable, forced and SDH flags, whether `alass` synced it and whether that sync was rejected, and the path on disk for anything that is a sidecar. Embedded tracks that were never extracted have no path — they are recorded because they satisfy a language, not because there is a file. - **Per-language attempts.** One row per (media file, wanted language): state, attempt count, last attempt, and the reason the last attempt failed. This is what the reconcile loop reads to decide whether a language is a gap, what backs off a failing provider, and what the missing-subtitles queue renders. Follow the existing migration conventions in `crates/arr-db/migrations`, keep queries `sqlx` compile-time-checked, and keep checksums stable — see the `0021`/`0022` pair and the fix in `a3897df` for what that costs when it goes wrong. Non-goals: any settings rows (that is #198), any budget accounting (#197). Depends on: #183
naps62-yolo added this to the Subtitles milestone 2026-08-24 21:28:32 +01:00
naps62-yolo added the area/dbtype/featuredifficulty/moderatephase/9-subtitles labels 2026-08-24 21:28:32 +01:00
naps62-yolo added a new dependency 2026-08-24 21:29:27 +01:00
naps62-yolo added a new dependency 2026-08-24 21:29:27 +01:00
naps62-yolo added a new dependency 2026-08-24 21:29:27 +01:00
naps62-yolo added a new dependency 2026-08-24 21:29:28 +01:00
naps62-yolo added a new dependency 2026-08-24 21:29:28 +01:00
Author
Owner

Implemented in crates/arr-db: migration 0024_subtitles.sql adds two STRICT
tables and a query module crates/arr-db/src/subtitles.rs on top of them.
subtitle_files is one row per subtitle arr knows about — owning media file,
language, origin (embedded, extracted, provider, translated), provider
and candidate id, translation engine, forced and SDH flags, the two alass
flags, and the sidecar path. CHECK constraints tie each optional column to the
origin, so an embedded row has no path, a provider row names its provider, a
translated row names its engine, and a subtitle can never be both synced and
sync-rejected. subtitle_attempts is one row per (media file, wanted language)
with state, attempt count, last attempt and last failure reason. Recording a
subtitle is idempotent on both keys the schema carries — the sidecar path, and
the language an embedded track satisfies — so a second probe or a re-import
converges instead of duplicating. pending() is the loop's work list: every
language that is not satisfied, newest import first, least recently attempted
within an import. Both tables cascade from media_files. Nine unit tests cover
the constraints, idempotency, backoff counting and the work-list ordering; the
offline query data in .sqlx is regenerated and a build with no DATABASE_URL
works.

Branch: subtitles/186-subs-db (based on blitz/subtitles). No PR opened.

just ci passed through the shared gate — 476 tests, fmt, clippy, machete and
the web checks.

Findings

  • The subtitle row does not carry the embedded track's codec. §15 splits
    text formats (subrip, ass, mov_text) from image formats (PGS, VobSub),
    and #189 already scopes teaching arr-probe to report it. Right now that fact
    would live only in media_files.probed, so anything asking "is there a text
    source to translate from" has to re-read the probe JSON rather than the
    subtitle rows. If #189 wants the rows to answer it, that is one nullable
    column and one migration — worth deciding when #189 lands rather than after.
  • An embedded track is keyed by what it satisfies, not by a stream index.
    The unique index is (media_file_id, language, forced, sdh) WHERE origin = 'embedded', so a file with two English SDH tracks records one row. That
    matches §15 — the row exists because it satisfies a language — but #189 should
    know it will not get one row per stream.
  • subtitle_attempts.state already allows capped. No budget accounting is
    here (that is #197), but the state is in the CHECK list so #197 does not have
    to rebuild the table to add it. SQLite cannot alter a CHECK in place.
Implemented in `crates/arr-db`: migration `0024_subtitles.sql` adds two STRICT tables and a query module `crates/arr-db/src/subtitles.rs` on top of them. `subtitle_files` is one row per subtitle arr knows about — owning media file, language, origin (`embedded`, `extracted`, `provider`, `translated`), provider and candidate id, translation engine, forced and SDH flags, the two `alass` flags, and the sidecar path. `CHECK` constraints tie each optional column to the origin, so an embedded row has no path, a provider row names its provider, a translated row names its engine, and a subtitle can never be both synced and sync-rejected. `subtitle_attempts` is one row per (media file, wanted language) with state, attempt count, last attempt and last failure reason. Recording a subtitle is idempotent on both keys the schema carries — the sidecar path, and the language an embedded track satisfies — so a second probe or a re-import converges instead of duplicating. `pending()` is the loop's work list: every language that is not satisfied, newest import first, least recently attempted within an import. Both tables cascade from `media_files`. Nine unit tests cover the constraints, idempotency, backoff counting and the work-list ordering; the offline query data in `.sqlx` is regenerated and a build with no `DATABASE_URL` works. Branch: `subtitles/186-subs-db` (based on `blitz/subtitles`). No PR opened. `just ci` passed through the shared gate — 476 tests, fmt, clippy, machete and the web checks. ## Findings - **The subtitle row does not carry the embedded track's codec.** §15 splits text formats (`subrip`, `ass`, `mov_text`) from image formats (PGS, VobSub), and #189 already scopes teaching `arr-probe` to report it. Right now that fact would live only in `media_files.probed`, so anything asking "is there a text source to translate from" has to re-read the probe JSON rather than the subtitle rows. If #189 wants the rows to answer it, that is one nullable column and one migration — worth deciding when #189 lands rather than after. - **An embedded track is keyed by what it satisfies, not by a stream index.** The unique index is `(media_file_id, language, forced, sdh) WHERE origin = 'embedded'`, so a file with two English SDH tracks records one row. That matches §15 — the row exists because it satisfies a language — but #189 should know it will not get one row per stream. - **`subtitle_attempts.state` already allows `capped`.** No budget accounting is here (that is #197), but the state is in the `CHECK` list so #197 does not have to rebuild the table to add it. SQLite cannot alter a `CHECK` in place.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Reference: yolo/arr#186