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.
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
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Serve the subtitle API — the surface every screen in this milestone is a client
of, per §9.1.
(embedded, extracted, provider, machine), provider or engine, forced and SDH
flags, sync state, path.
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.
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.
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
Implemented on
subtitles/199-subs-api(based onblitz/subtitles, not merged, no PR).Seven handlers in a new
arr-api::subtitlesmodule, 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 theproviders_enabledsetting, filters to the requested language, ranks througharr_core::subs::rank(#185) and returns every candidate with §9.3's vocabulary —verdictofeligible/rejectedplusrejected_rule, so a rejected row names the rule that killed it exactly as the release deck does. A provider that cannot answer is reported inprovider_errorsbeside 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 towantedwhen 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
forcedandsdhfacts the search reported, because the server does not remember a search.just cipassed through the gate: 581 tests, clippy clean,cargo macheteclean, web checks clean. Offline sqlx query data was regenerated and committed.Findings
alasssync (#194) is not merged intoblitz/subtitles, so a manual grab records its sidecar unsynced. The issue names syncing as part of manual grab. The seam is a single call inarr_api::subtitles::grabonce #194 lands; every fetched row currently readssync: "not_run".arr_core::layout::subtitle_nameand 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, andsubtitle_files.pathis 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.srt_textinarr-api/src/subtitles.rsshould convert instead of refusing.POST …/translatecannot succeed on a real deployment.AppState::with_translation_backendsis 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_passwordinarr-daemon's config are read but unusable.arr_subs::Podnapisihas no credential surface —PodnapisiBuildertakes only a base URL and a timeout. Either the provider grows authentication or the two config fields should go.media_fileshas no release name, so ranking's exact-name tier is best-effort. It is recovered by joininggrabs→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.naps62-yolo referenced this issue2026-08-25 02:51:24 +01:00