One sidecar per language, enforced in the schema #222

Closed
opened 2026-08-25 02:51:24 +01:00 by naps62-yolo · 1 comment
Owner

Decided by the operator; DESIGN.md §15 amended in ab001b5. Implement that.

No filename segment for forced or SDH. A language is satisfied by exactly one
sidecar.
Where a plain subtitle exists for a language, a forced one is
ignored rather than kept beside it. .mt remains the only optional segment.

That settles the constraint this issue was blocked on.

What to do

  1. Migration 0029 — this number is yours; #220 takes 0028. Add
    UNIQUE (media_file_id, language) over sidecar rows — the ones with a path,
    i.e. WHERE path IS NOT NULL. Embedded rows keep their existing
    (media_file_id, language, forced, sdh) key: they describe tracks inside
    the video, not files, and several can legitimately coexist.
  2. Existing databases may already hold a duplicate from a manual grab that beat
    the path check. The migration must resolve them rather than fail: keep the
    non-machine-translation row, and of two of the same kind the newest, and
    delete the rest. Leave the files on disk — deleting a viewer's subtitle
    during a migration is worse than an orphan, and #218's delete path can clean
    up afterwards.
  3. With the constraint in place, claim_path is no longer the thing preventing
    duplicates. Keep it — it still stops two rows racing for one filename — but
    the 409 on a second grab for a satisfied language should now come from the
    invariant, with a message saying the language already has a subtitle and
    naming the manual delete as the way to replace it (#223 ships that button).
  4. Check the reconcile loop agrees: a forced candidate is already rejected by
    ranking (§15, #185), so nothing should be trying to write one. Add a test
    proving a forced candidate never produces a sidecar row.

Depends on: #186, #203, #218, #223

**Decided by the operator; DESIGN.md §15 amended in ab001b5. Implement that.** No filename segment for forced or SDH. **A language is satisfied by exactly one sidecar.** Where a plain subtitle exists for a language, a forced one is ignored rather than kept beside it. `.mt` remains the only optional segment. That settles the constraint this issue was blocked on. ## What to do 1. **Migration `0029`** — this number is yours; #220 takes `0028`. Add `UNIQUE (media_file_id, language)` over sidecar rows — the ones with a path, i.e. `WHERE path IS NOT NULL`. Embedded rows keep their existing `(media_file_id, language, forced, sdh)` key: they describe tracks inside the video, not files, and several can legitimately coexist. 2. Existing databases may already hold a duplicate from a manual grab that beat the path check. The migration must resolve them rather than fail: keep the non-machine-translation row, and of two of the same kind the newest, and delete the rest. Leave the files on disk — deleting a viewer's subtitle during a migration is worse than an orphan, and #218's delete path can clean up afterwards. 3. With the constraint in place, `claim_path` is no longer the thing preventing duplicates. Keep it — it still stops two rows racing for one filename — but the 409 on a second grab for a satisfied language should now come from the invariant, with a message saying the language already has a subtitle and naming the manual delete as the way to replace it (#223 ships that button). 4. Check the reconcile loop agrees: a forced candidate is already rejected by ranking (§15, #185), so nothing should be trying to write one. Add a test proving a forced candidate never produces a sidecar row. Depends on: #186, #203, #218, #223
naps62-yolo added this to the Subtitles milestone 2026-08-25 02:51:24 +01:00
naps62-yolo added the area/dbphase/9-subtitlestype/choredifficulty/moderate labels 2026-08-25 02:51:24 +01:00
naps62-yolo changed title from Settle forced/SDH sidecar naming and constrain it to One sidecar per language, enforced in the schema 2026-08-25 08:18:05 +01:00
Author
Owner

Implemented on subtitles/222-one-sidecar. just ci passed locally (712 tests).

Migration 0029_one_sidecar_per_language.sql adds UNIQUE (media_file_id, language) WHERE path IS NOT NULL, leaving the embedded key (media_file_id, language, forced, sdh) alone. Before creating it, the migration resolves duplicates an older database may hold: per (media file, language) it keeps a real subtitle over a machine translation, and of two of the same kind the newest, deleting only the rows — the files stay on disk for the manual delete to clean up. record_file no longer swallows every conflict; it names the two conflict targets that mean "arr already knows this file", so the one-sidecar violation raises instead of resolving to some other row's id. The API gained claim_language, called by both grab and translate before anything is downloaded or written: it returns 409 naming the existing sidecar and saying to delete it first. That covers the case claim_path cannot see — a language held by a .mt.srt while a provider fetch would write .srt. claim_path stays, as the issue asks, for two media_files rows deriving one filename from different ids.

Tests added: the schema refuses a second sidecar per language while embedded tracks still share one; sidecar_for answers with the file and not the track; migration 0029's own SQL, pulled from MIGRATOR, resolves a seeded duplicate the way the rule says; the API refuses a grab for a language a translation already satisfies and writes nothing to disk; and the reconcile loop turns a hash-matched, heavily-downloaded forced candidate into no sidecar row and no file, landing on unavailable.

Findings

  • crates/arr-api/src/subtitles.rs still accepts forced: true on POST /api/media-files/{id}/subtitles/grab, and writes it to <video>.<lang>.srt — the same name a plain subtitle uses. With §15 amended so forced gets no sidecar of its own and never satisfies a want, a forced manual grab now either takes the only sidecar slot a language has or is refused by the new invariant. The flag and the test a_forced_grab_is_recorded_without_satisfying_the_language predate the amendment. Worth deciding whether the API should still offer it at all; I left it alone rather than widen this issue.
  • subtitle_files.path is the row's identity on disk but nothing keeps it in step with a move: §7.4 renames on import and library moves rewrite media_files.path, and the sidecar rows keep pointing at the old folder. Not new here, and not something this issue touches.
Implemented on `subtitles/222-one-sidecar`. `just ci` passed locally (712 tests). Migration `0029_one_sidecar_per_language.sql` adds `UNIQUE (media_file_id, language) WHERE path IS NOT NULL`, leaving the embedded key `(media_file_id, language, forced, sdh)` alone. Before creating it, the migration resolves duplicates an older database may hold: per (media file, language) it keeps a real subtitle over a machine translation, and of two of the same kind the newest, deleting only the rows — the files stay on disk for the manual delete to clean up. `record_file` no longer swallows every conflict; it names the two conflict targets that mean "arr already knows this file", so the one-sidecar violation raises instead of resolving to some other row's id. The API gained `claim_language`, called by both grab and translate before anything is downloaded or written: it returns 409 naming the existing sidecar and saying to delete it first. That covers the case `claim_path` cannot see — a language held by a `.mt.srt` while a provider fetch would write `.srt`. `claim_path` stays, as the issue asks, for two `media_files` rows deriving one filename from different ids. Tests added: the schema refuses a second sidecar per language while embedded tracks still share one; `sidecar_for` answers with the file and not the track; migration `0029`'s own SQL, pulled from `MIGRATOR`, resolves a seeded duplicate the way the rule says; the API refuses a grab for a language a translation already satisfies and writes nothing to disk; and the reconcile loop turns a hash-matched, heavily-downloaded forced candidate into no sidecar row and no file, landing on `unavailable`. ## Findings - `crates/arr-api/src/subtitles.rs` still accepts `forced: true` on `POST /api/media-files/{id}/subtitles/grab`, and writes it to `<video>.<lang>.srt` — the same name a plain subtitle uses. With §15 amended so forced gets no sidecar of its own and never satisfies a want, a forced manual grab now either takes the only sidecar slot a language has or is refused by the new invariant. The flag and the test `a_forced_grab_is_recorded_without_satisfying_the_language` predate the amendment. Worth deciding whether the API should still offer it at all; I left it alone rather than widen this issue. - `subtitle_files.path` is the row's identity on disk but nothing keeps it in step with a move: §7.4 renames on import and library moves rewrite `media_files.path`, and the sidecar rows keep pointing at the old folder. Not new here, and not something this issue touches.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: yolo/arr#222