Serve the subtitle API #199

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

Serve the subtitle API — the surface every screen in this milestone is a client
of, per §9.1.

  • List the subtitles known for a title or a media file: language, origin
    (embedded, extracted, provider, machine), provider or engine, forced and SDH
    flags, sync state, path.
  • Manual search: run the enabled providers (#187, #188) for a media file and
    return ranked candidates with their verdicts from #185, including the rejected
    ones with the rule that killed each. Same vocabulary the release deck uses, so
    §9.3's treatment applies unchanged.
  • Manual grab: take a named candidate, sync it (#194), write it (#195).
  • Manual translate: given a source subtitle already on the file, a target
    language and an engine, run the translation (#190) and write the result. Any
    existing subtitle is a legal source — including one extracted from an
    embedded track, and including another machine translation.
  • Delete a subtitle: remove the sidecar and its row.

OpenAPI annotations on every handler, like the rest of arr-api.

Manual actions bypass the wanted-set logic entirely — the operator asking for a
Spanish subtitle gets a Spanish subtitle, and the reconcile loop does not then
treat it as a gap or remove it.

Depends on: #186, #187, #188, #190

Serve the subtitle API — the surface every screen in this milestone is a client of, per §9.1. - List the subtitles known for a title or a media file: language, origin (embedded, extracted, provider, machine), provider or engine, forced and SDH flags, sync state, path. - Manual search: run the enabled providers (#187, #188) for a media file and return ranked candidates with their verdicts from #185, including the rejected ones with the rule that killed each. Same vocabulary the release deck uses, so §9.3's treatment applies unchanged. - Manual grab: take a named candidate, sync it (#194), write it (#195). - Manual translate: given a source subtitle already on the file, a target language and an engine, run the translation (#190) and write the result. Any existing subtitle is a legal source — including one extracted from an embedded track, and including another machine translation. - Delete a subtitle: remove the sidecar and its row. OpenAPI annotations on every handler, like the rest of `arr-api`. Manual actions bypass the wanted-set logic entirely — the operator asking for a Spanish subtitle gets a Spanish subtitle, and the reconcile loop does not then treat it as a gap or remove it. Depends on: #186, #187, #188, #190
naps62-yolo added this to the Subtitles milestone 2026-08-24 21:28:40 +01:00
naps62-yolo added the area/apiphase/9-subtitlestype/featuredifficulty/moderate labels 2026-08-24 21:28:40 +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
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
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
naps62-yolo added a new dependency 2026-08-24 21:29:28 +01:00
Author
Owner

Implemented on subtitles/199-subs-api (based on blitz/subtitles, not merged, no PR).

Seven handlers in a new arr-api::subtitles module, all with #[utoipa::path]: list per media file, per movie and per episode; manual search; manual grab; manual translate; delete. Search runs the providers this deployment has credentials for, intersected with the providers_enabled setting, filters to the requested language, ranks through arr_core::subs::rank (#185) and returns every candidate with §9.3's vocabulary — verdict of eligible/rejected plus rejected_rule, so a rejected row names the rule that killed it exactly as the release deck does. A provider that cannot answer is reported in provider_errors beside the candidates rather than failing the search, since §15 configures two at once. Grab downloads the named candidate, writes the sidecar next to the video under §15's <video basename>.<lang>.srt, and records it; translate reads any existing subtitle on the file (including an extracted embedded track and including another machine translation), runs it through the #190 layer and writes <…>.<lang>.mt.srt. Delete removes the sidecar and the row, and puts the language back to wanted when nothing in it is left, because §15 reads satisfaction off the files.

The issue's "manual actions bypass the wanted-set logic" is enforced by every write marking its language satisfied whether or not it is in the wanted set — so a manually requested Spanish subtitle is never read as a gap and never replaced. A forced track is the one exception §15 names: recorded, satisfying nothing.

Unlike the release deck these handlers work inline and answer with the result rather than returning 202. A subtitle search is one or two HTTP calls and nothing persists its candidates, so there is nothing to poll for. The consequence a client has to know: a grab repeats the forced and sdh facts the search reported, because the server does not remember a search.

just ci passed through the gate: 581 tests, clippy clean, cargo machete clean, web checks clean. Offline sqlx query data was regenerated and committed.

Findings

  • alass sync (#194) is not merged into blitz/subtitles, so a manual grab records its sidecar unsynced. The issue names syncing as part of manual grab. The seam is a single call in arr_api::subtitles::grab once #194 lands; every fetched row currently reads sync: "not_run".
  • Sidecar naming has no room for a forced or SDH track. arr_core::layout::subtitle_name and DESIGN.md §15 give one name per (video, language, machine-made) triple, but a file can legitimately carry both a plain and a forced pt-PT subtitle, and subtitle_files.path is UNIQUE. Rather than invent a naming scheme the design document does not have, a grab whose target path is already taken returns 409 and the operator deletes first — which matches §15's "replacement is a manual action". A design decision is needed on whether forced/SDH sidecars get their own filename segment.
  • A provider serving anything but SRT is refused with 422. Sidecars are SRT (§15) and conversion is #213, which is not merged here. Once it is, srt_text in arr-api/src/subtitles.rs should convert instead of refusing.
  • No translation backend exists yet (#191–#193), so POST …/translate cannot succeed on a real deployment. AppState::with_translation_backends is the registry they plug into; until then the endpoint answers "engine is not compiled into this binary". Tested against a stub backend.
  • podnapisi_username / podnapisi_password in arr-daemon's config are read but unusable. arr_subs::Podnapisi has no credential surface — PodnapisiBuilder takes only a base URL and a timeout. Either the provider grows authentication or the two config fields should go.
  • media_files has no release name, so ranking's exact-name tier is best-effort. It is recovered by joining grabsreleases (falling back to the season's pack grab for an episode). A file imported before that grab record existed, or one imported by hand, ranks without the tier.
Implemented on `subtitles/199-subs-api` (based on `blitz/subtitles`, not merged, no PR). Seven handlers in a new `arr-api::subtitles` module, all with `#[utoipa::path]`: list per media file, per movie and per episode; manual search; manual grab; manual translate; delete. Search runs the providers this deployment has credentials for, intersected with the `providers_enabled` setting, filters to the requested language, ranks through `arr_core::subs::rank` (#185) and returns every candidate with §9.3's vocabulary — `verdict` of `eligible`/`rejected` plus `rejected_rule`, so a rejected row names the rule that killed it exactly as the release deck does. A provider that cannot answer is reported in `provider_errors` beside the candidates rather than failing the search, since §15 configures two at once. Grab downloads the named candidate, writes the sidecar next to the video under §15's `<video basename>.<lang>.srt`, and records it; translate reads any existing subtitle on the file (including an extracted embedded track and including another machine translation), runs it through the #190 layer and writes `<…>.<lang>.mt.srt`. Delete removes the sidecar and the row, and puts the language back to `wanted` when nothing in it is left, because §15 reads satisfaction off the files. The issue's "manual actions bypass the wanted-set logic" is enforced by every write marking its language satisfied whether or not it is in the wanted set — so a manually requested Spanish subtitle is never read as a gap and never replaced. A forced track is the one exception §15 names: recorded, satisfying nothing. Unlike the release deck these handlers work inline and answer with the result rather than returning 202. A subtitle search is one or two HTTP calls and nothing persists its candidates, so there is nothing to poll for. The consequence a client has to know: a grab repeats the `forced` and `sdh` facts the search reported, because the server does not remember a search. `just ci` passed through the gate: 581 tests, clippy clean, `cargo machete` clean, web checks clean. Offline sqlx query data was regenerated and committed. ## Findings - **`alass` sync (#194) is not merged into `blitz/subtitles`, so a manual grab records its sidecar unsynced.** The issue names syncing as part of manual grab. The seam is a single call in `arr_api::subtitles::grab` once #194 lands; every fetched row currently reads `sync: "not_run"`. - **Sidecar naming has no room for a forced or SDH track.** `arr_core::layout::subtitle_name` and DESIGN.md §15 give one name per (video, language, machine-made) triple, but a file can legitimately carry both a plain and a forced pt-PT subtitle, and `subtitle_files.path` is UNIQUE. Rather than invent a naming scheme the design document does not have, a grab whose target path is already taken returns 409 and the operator deletes first — which matches §15's "replacement is a manual action". A design decision is needed on whether forced/SDH sidecars get their own filename segment. - **A provider serving anything but SRT is refused with 422.** Sidecars are SRT (§15) and conversion is #213, which is not merged here. Once it is, `srt_text` in `arr-api/src/subtitles.rs` should convert instead of refusing. - **No translation backend exists yet (#191–#193), so `POST …/translate` cannot succeed on a real deployment.** `AppState::with_translation_backends` is the registry they plug into; until then the endpoint answers "engine is not compiled into this binary". Tested against a stub backend. - **`podnapisi_username` / `podnapisi_password` in `arr-daemon`'s config are read but unusable.** `arr_subs::Podnapisi` has no credential surface — `PodnapisiBuilder` takes only a base URL and a timeout. Either the provider grows authentication or the two config fields should go. - **`media_files` has no release name, so ranking's exact-name tier is best-effort.** It is recovered by joining `grabs` → `releases` (falling back to the season's pack grab for an episode). A file imported before that grab record existed, or one imported by hand, ranks without the tier.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Reference: yolo/arr#199