Manual subtitle search and translation in the UI #203

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

Build the manual subtitle surface — the thing you reach for when the automatic
pick is wrong, and the piece the operator specifically asked for.

Manual search, given the §9.3 treatment: candidates as chips — provider,
hash match, release-name match, rating, downloads, SDH — with the release name
secondary and truncated. Eligible first, rejected collapsed to a count and
expandable, each rejected row naming the rule that killed it. One click grabs,
syncs and writes.

Manual translation, on the same surface: pick any subtitle already on the
file as the source — a fetched one, an extracted embedded track, even an
existing machine translation — pick a target language, pick an engine from the
ones the running binary has compiled in, run it. The result lands as a new
sidecar. Being able to translate from an embedded track is what Bazarr cannot
do and is the reason this exists.

Both run against #199. Neither changes the wanted set: a manually fetched
Spanish subtitle stays, and the loop does not treat it as a gap or a duplicate.

area/web: through the impeccable skill, and it shares its layout language
with the release deck rather than inventing a second one.

Depends on: #199, #201

Build the manual subtitle surface — the thing you reach for when the automatic pick is wrong, and the piece the operator specifically asked for. **Manual search**, given the §9.3 treatment: candidates as chips — provider, hash match, release-name match, rating, downloads, SDH — with the release name secondary and truncated. Eligible first, rejected collapsed to a count and expandable, each rejected row naming the rule that killed it. One click grabs, syncs and writes. **Manual translation**, on the same surface: pick any subtitle already on the file as the source — a fetched one, an extracted embedded track, even an existing machine translation — pick a target language, pick an engine from the ones the running binary has compiled in, run it. The result lands as a new sidecar. Being able to translate from an embedded track is what Bazarr cannot do and is the reason this exists. Both run against #199. Neither changes the wanted set: a manually fetched Spanish subtitle stays, and the loop does not treat it as a gap or a duplicate. `area/web`: through the `impeccable` skill, and it shares its layout language with the release deck rather than inventing a second one. Depends on: #199, #201
naps62-yolo added this to the Subtitles milestone 2026-08-24 21:28:40 +01:00
naps62-yolo added the difficulty/hardarea/webphase/9-subtitlestype/feature 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
Author
Owner

Integration note from the milestone driver — carried forward from #187, which is now merged.

Provider candidate ids are not durable across a restart. The OpenSubtitles provider records a candidate's language and format at search time so download can fill Fetched from an opaque id. Podnapisi encodes the language into the id itself ({pid}|{language}), so the two providers differ in how much a bare id carries.

Practical consequence: if a candidate id is held anywhere that outlives the search — a queue row, a UI selection the operator comes back to, anything surviving a daemon restart — it must be re-searched before download, or OpenSubtitles answers Error::NotFound. Do not persist a candidate id and treat it as a durable handle.

Also worth knowing: Candidate.rating and Candidate.download_count are always None for Podnapisi (no documented field), so ranking's tiebreakers do nothing for that provider. That is expected, not a bug to chase.

Integration note from the milestone driver — carried forward from #187, which is now merged. **Provider candidate ids are not durable across a restart.** The OpenSubtitles provider records a candidate's language and format at search time so `download` can fill `Fetched` from an opaque id. Podnapisi encodes the language into the id itself (`{pid}|{language}`), so the two providers differ in how much a bare id carries. Practical consequence: if a candidate id is held anywhere that outlives the search — a queue row, a UI selection the operator comes back to, anything surviving a daemon restart — it must be re-searched before download, or OpenSubtitles answers `Error::NotFound`. Do not persist a candidate id and treat it as a durable handle. Also worth knowing: `Candidate.rating` and `Candidate.download_count` are always `None` for Podnapisi (no documented field), so ranking's tiebreakers do nothing for that provider. That is expected, not a bug to chase.
Author
Owner

Implemented the manual subtitle surface as an inline panel on each media file's row — on movie detail and on every episode row of a series — opened by a subtitles control that sits at the end of the existing §201 chip line. It borrows the release deck's layout language rather than inventing a second one: deck-group / deck-head / colhead / fixed-width .cw-* chips / rel-line with the release name secondary and truncated, and the same show N bucket toggle. The fetch lane picks a language (wanted languages first, then pt-PT/pt-BR/en) and searches, showing candidates as chips — provider, hash match, release-name match, uploader rating, download count, SDH/forced — with eligible open and rejected collapsed to a count, each rejected row carrying a rejected · forced chip. One click grabs, and the result line says whether alass synced or was rejected and the unsynced original kept. The translate lane takes any subtitle already on the file as the source (a fetched one, an extracted embedded track, another machine translation), bars the source's own language as a target, and lists only the engines the running binary reports in available_engines. Neither lane touches the wanted set; both go through #199's endpoints unchanged. After a write, the page re-reads its own subtitle status and repaints the chips in place, so an open panel keeps its search.

One API addition was needed: SubtitleCandidate now carries release_match. §15's second ranking tier is the release-name match and the issue asks for it as a chip, but the file's own release name is not otherwise on the wire, so the UI could not derive it.

Branch: subtitles/203-manual-subs-ui

just ci passed through the gate — 670 tests, clippy clean, biome clean, check-tokens clean, tsc clean.

Visual pass was done with agent-browser against a throwaway stub of the API (populated results, provider failure, missing release name, rejected bucket, grab and translate round trips) at 1440px and 375px. Two defects found and fixed there: truncated column headers, and the panel forcing horizontal scroll at phone width because a flex item will not shrink below its min-content. At ≤40rem the rating and download columns are shed and the column header with them, so the remaining chips name themselves (hash, release) rather than reading match under a header that no longer aligns.

Findings

  • No /settings surface for subtitles. GET/PUT /api/settings/subtitles exist and carry wanted languages, per-provider enable, engine choice and the daily budgets, but nothing in web/ reads or writes them. §15 says these are database rows edited from /settings without a restart. This panel reads that endpoint for its language and engine choices, so it is the only consumer today.
  • A candidate id does not outlive its search, and nothing on the wire says so. Per the integration note on #187, OpenSubtitles answers NotFound for an id from an earlier search. The panel handles it by appending "search again" to the failure text when the detail matches not found, which is string matching on an upstream message. A typed error code, or a search token the grab must present, would be the real fix.
  • subtitle_files has no uniqueness on (media_file_id, language, path). A manual grab of a second pt-PT subtitle writes a second row; only the sidecar path collision (claim_path) stops a duplicate today, and that is a filesystem check, not a constraint.
  • No delete affordance for a subtitle. DELETE /api/subtitles/{id} exists and §15 says replacement is a manual action from the UI, but nothing in the panel or the chip line offers it. Replacing a machine translation with a real subtitle therefore still needs a curl.
Implemented the manual subtitle surface as an inline panel on each media file's row — on movie detail and on every episode row of a series — opened by a `subtitles` control that sits at the end of the existing §201 chip line. It borrows the release deck's layout language rather than inventing a second one: `deck-group` / `deck-head` / `colhead` / fixed-width `.cw-*` chips / `rel-line` with the release name secondary and truncated, and the same `show N` bucket toggle. The fetch lane picks a language (wanted languages first, then pt-PT/pt-BR/en) and searches, showing candidates as chips — provider, hash match, release-name match, uploader rating, download count, SDH/forced — with eligible open and rejected collapsed to a count, each rejected row carrying a `rejected · forced` chip. One click grabs, and the result line says whether `alass` synced or was rejected and the unsynced original kept. The translate lane takes any subtitle already on the file as the source (a fetched one, an extracted embedded track, another machine translation), bars the source's own language as a target, and lists only the engines the running binary reports in `available_engines`. Neither lane touches the wanted set; both go through #199's endpoints unchanged. After a write, the page re-reads its own subtitle status and repaints the chips in place, so an open panel keeps its search. One API addition was needed: `SubtitleCandidate` now carries `release_match`. §15's second ranking tier is the release-name match and the issue asks for it as a chip, but the file's own release name is not otherwise on the wire, so the UI could not derive it. Branch: `subtitles/203-manual-subs-ui` `just ci` passed through the gate — 670 tests, clippy clean, biome clean, `check-tokens` clean, `tsc` clean. Visual pass was done with `agent-browser` against a throwaway stub of the API (populated results, provider failure, missing release name, rejected bucket, grab and translate round trips) at 1440px and 375px. Two defects found and fixed there: truncated column headers, and the panel forcing horizontal scroll at phone width because a flex item will not shrink below its min-content. At ≤40rem the rating and download columns are shed and the column header with them, so the remaining chips name themselves (`hash`, `release`) rather than reading `match` under a header that no longer aligns. ## Findings - **No `/settings` surface for subtitles.** `GET`/`PUT /api/settings/subtitles` exist and carry wanted languages, per-provider enable, engine choice and the daily budgets, but nothing in `web/` reads or writes them. §15 says these are database rows edited from `/settings` without a restart. This panel reads that endpoint for its language and engine choices, so it is the only consumer today. - **A candidate id does not outlive its search, and nothing on the wire says so.** Per the integration note on #187, OpenSubtitles answers `NotFound` for an id from an earlier search. The panel handles it by appending "search again" to the failure text when the detail matches `not found`, which is string matching on an upstream message. A typed error code, or a search token the grab must present, would be the real fix. - **`subtitle_files` has no uniqueness on (media_file_id, language, path).** A manual grab of a second pt-PT subtitle writes a second row; only the sidecar path collision (`claim_path`) stops a duplicate today, and that is a filesystem check, not a constraint. - **No delete affordance for a subtitle.** `DELETE /api/subtitles/{id}` exists and §15 says replacement is a manual action from the UI, but nothing in the panel or the chip line offers it. Replacing a machine translation with a real subtitle therefore still needs a curl.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Reference: yolo/arr#203