The wrapper `timeout ...; rc=$?` on the verify block (line 80) was a standalone command under `set -e` (line 7). When glossary_verify.py ran past VERIFY_TIMEOUT (1200s), GNU timeout returned 124 and `set -e` exited the shell -- the rc-classified "verify TIMED OUT ... (continuing)" message that was already written to handle exactly this case never got a chance to run because the shell had already exited. PID 1 was gen_loop.sh, so the container exited with 124, and docker's unless-stopped policy then auto-restarted it. Measured on vm102 across the 14h ending 2026-09-05 14:00 EDT: every container restart in the window exited with code 124 (journal-confirmed via `journalctl -u docker ... exitCode=124`), and VERIFY_TIMEOUT firing on One Pace (the WATCH_QUEUE_PIN show, run first) lands within ~2s of every measured exit. The pre-existing exitCode=0 hypothesis from the handoff was wrong; docker's `State.ExitCode` reports 0 while the container is RUNNING, which masked the real 124 from prior exits. Fix: change `timeout ...; rc=$?` to `timeout ... && rc=0 || rc=$?` (the same pattern generate.py uses at line 96 to keep crash-resume alive). The compound's last subcommand is always 0, so `set -e` cannot trip on the timeout's 124, and `rc` still holds the real exit code for the existing rc-classified message. Also quotes $rc in the [ -eq N ] checks to clear shellcheck SC2086 and match the quoting style already in use at line 99. The ACQUIRE call (line 64) was already wrapped with `|| echo ...`, so it was never the trigger; a cross-reference comment is added so a future reader doesn't "fix" the asymmetry by removing the ACQUIRE guard. docs: tests/test_gen_loop_set_e.py pins the wrapping so a future edit cannot reintroduce the bug; CHANGELOG.md [Unreleased] records the measured justification. Build dubtitle-builder:0.1.1 and pin in docker-stacks/dubtitle-builder/compose.yaml to deploy. |
||
|---|---|---|
| .agents/rules | ||
| .claude | ||
| .clinerules | ||
| .cursor/rules | ||
| .github | ||
| .kilo/rules | ||
| .kilocode/rules | ||
| .kiro/steering | ||
| .procoder | ||
| .qoder/rules | ||
| .roo/rules | ||
| .windsurf/rules | ||
| data | ||
| deploy | ||
| docs | ||
| glossaries | ||
| scripts | ||
| shell | ||
| skills/procoder | ||
| specs | ||
| tests | ||
| tools | ||
| .gitignore | ||
| .prettierignore | ||
| .prettierrc | ||
| acquire_cache.py | ||
| anime_order.txt | ||
| card_split.py | ||
| CHANGELOG.md | ||
| common.py | ||
| common_words.txt | ||
| conftest.py | ||
| container_run.sh | ||
| decisions.py | ||
| Dockerfile | ||
| Dockerfile.builder | ||
| dub_signs_merge.py | ||
| gen_loop.sh | ||
| generate.py | ||
| glossary.py | ||
| glossary_acquire.py | ||
| glossary_verify.py | ||
| hallucination.py | ||
| IMPROVEMENTS.md | ||
| ISSUE-phonetic-name-guard.md | ||
| LICENSE | ||
| merge_pass.sh | ||
| mine_glossary.py | ||
| mux.py | ||
| ordering.py | ||
| plex_refresh.py | ||
| punctuation.py | ||
| pyproject.toml | ||
| qc.py | ||
| README.md | ||
| recreate_srt.py | ||
| reflow.py | ||
| repair.py | ||
| REVIEW.md | ||
| review_apply.py | ||
| review_server.py | ||
| SECURITY.md | ||
| unresolved.py | ||
| uv.lock | ||
| watch_queue.py | ||
DubTitlerr
Automatic English-dub "dubtitles" for your whole anime library — and the "Signs & Songs" track on screen at the same time.
DubTitlerr is a self-hosted, *-arr-style service that watches your anime library and, for every show with an English dub, transcribes the dub into accurate captions ("dubtitles"), repairs them with a local LLM, and merges the on-screen signs & song lyrics into the same subtitle track — so one track shows everything. It runs as one container, grows a per-show name dictionary automatically, and refreshes Plex per-episode.
Beta status. The code is general, but One Pace is the only configuration validated so far — everything else is "try it, and file an issue if something looks wrong." Version stamps may be invalidated without notice during the beta; pin your image tag, and if you've already pulled a newer image but aren't ready to re-transcribe, stop the container until you are.
Use it on media you own for personal viewing. This tool transcribes dub audio and merges fansub-derived signs/songs typography you already have — it doesn't fetch, host, or distribute video, audio, or subtitles for anyone else.
The original
dub-signs-mergewas just the signs+dub merge step (documented below); the project has grown into the full pipeline (transcribe → repair → merge → mux) with additive per-show dictionaries. See the Wiki for setup & usage.
The signs+dub merge, in detail (click to expand)
If you watch anime with the English dub but still want the on-screen signs and song lyrics translated, most players (Plex included) only display one subtitle track at a time — so you get dub captions or signs, not both. This merges the two into a single subtitle file.
What's actually going on (click to expand)
Anime releases (e.g. One Pace) usually ship with:
- a "Signs and Songs" subtitle track — only on-screen text and song lyrics, carefully positioned near where they appear (it's an
.assfile with placement tags), no dialogue; and - (in this setup) a "dubtitles" sidecar — a
.srtof the English dub dialogue, generated by Whisper/subgen.
They're complementary — one is bottom-of-screen dialogue, the other is positioned signs — and they share the same timeline, so they just combine. This tool extracts the signs track from the video, appends the dub dialogue under a clean bottom style, and writes one .ass that renders both at once.
What it does
DubTitlerr runs as one restart-safe container that watches your anime library and, for every show with an English dub, runs a full pipeline per episode:
- Transcribe — pick the English-dub audio and run Whisper (large-v3-turbo; build with
--build-arg WHISPER_MODEL=large-v3for the better transcript — measured 18.9% word error rate against 20.8% for turbo on a 6GB GTX 1060, and it fits there at the defaultint8, so 6GB is enough for either. Whisper beat every Parakeet, Canary and Qwen3-ASR entrant on both cards tested; see Choosing an ASR model). Upgrading from a build that used the oldlarge-v3default changes the decoder without bumpingTRANSCRIBE_VERSION, so existing episodes keep their stamps and are not re-transcribed: old episodes stay large-v3 while new ones use turbo. Re-transcribe deliberately if you want one decoder across the library. Then reflow the words into clean, well-timed cards (sentence-split, ≤2 lines/≤42 chars, ~17 cps, never shown before they're spoken). - Name correction — fix proper nouns against a per-show glossary (curated
hard_fixes+ a guarded fuzzy that won't touch real English words). The glossary is auto-built by mining the embedded subs and wiki-verified (canonical, dub-preferred spellings — see below). - LLM repair — a local model (
qwen3-4b-instructby default) fixes mid-/low-confidence and name-suspect lines, anchored on the embedded fansub dialogue when present. If your copies of a show have no English subtitles for the Japanese audio there is nothing to anchor on, and by default every line is left alone — glossary-only repair invents names, so the gate is shut unless a show opts in. Set"unanchored_repair": truein that show's glossary to open it. Repairs then run from the glossary alone, which fixes names nothing else can reach (Dothamingo→Doflamingo) at the cost of a wider guess. A pass that would skip every line on an episode whose last run shipped repairs refuses that episode instead of rewriting it, so a misconfigured run cannot quietly revert work you already have. - Hallucination gate — drop music/silence/blocklist lines and within-card loops, collapse runaway repeat runs, flag the merely-uncertain.
- Signs & songs merge — lift the on-screen signs/song-lyric events into the same subtitle.
- Mux + fonts — embed the result with the MKV's fonts as a default "Dubtitles" track so signs render in their real typeface (mp4 episodes are remuxed to mkv); English audio set default.
Idempotent (a version-stamped .dubtitles.done sidecar makes re-runs safe), incremental, and
per-episode (Plex refreshes as each finishes). The glossary wiki-verifier is reusable on its
own — it makes any mined or community-submitted glossary as accurate as a hand-curated one.
Regenerating episodes you've already dubbed
The pipeline never reads its own previous output as context — every stage that looks at an
embedded subtitle skips the track named Dubtitles, so an improved run is anchored on the human
fansub (or on nothing) rather than on last version's mistakes. And the mux replaces the old
Dubtitles track in the same pass that adds the new one, so re-running is safe and there is no
separate strip step.
Regeneration is opt-in and library-wide: bump PIPELINE_VERSION in common.py. Every episode
whose stamp records an older version is then re-transcribed and re-muxed in place on the next
sweep (any leftover sidecar from that older version is discarded first, so the bump can't be
satisfied with stale work); a failed run leaves the old track alone (no sidecar is written, so
the mux never fires).
Quick start
The full pipeline (transcribe → repair → merge → mux) builds from Dockerfile.builder
and runs as one long-lived, restart-safe container (root, so it can rewrite/chown
sidecars) — no cron needed, it loops on its own:
# build
docker build -f Dockerfile.builder -t dubtitle-builder:latest .
# run continuously against your media (env vars configure roots/models/Plex — see the Wiki)
docker run --rm -u 0 --gpus all -v "/path/to/your/media:/media" -v "/path/to/config:/config" \
-p 8842:8842 \
-e ANIME_ROOT="/media/Anime Library" dubtitle-builder:latest
Dockerfile (signs+dub merge only, no transcribe/repair) is deprecated — see the comment
at its top. It builds a plain image whose only content is dub_signs_merge.py, which walks
MERGE_ROOTS (or takes explicit .srt paths as arguments) with no flags needed:
docker build -t dub-signs-merge .
docker run --rm -v /path/to/media:/media -e MERGE_ROOTS=/media dub-signs-merge python3 dub_signs_merge.py
Dockerfile.builder above is the recommended path for everyone else.
Make it yours — settings
Everything is an env var, so nothing host-specific is baked in:
| Var | What | Default |
|---|---|---|
MERGE_ROOTS |
colon-separated folders to scan (inside the container) | /data/Media/Anime Library:/data/Media/Anime Movie Library |
DUB_SUFFIX |
the sidecar suffix to look for | .eng.dubtitles.srt |
MEDIA_UID / MEDIA_GID |
ownership for the written .ass |
1000 / 100 |
MEDIA_ROOT (wrapper) |
host path mounted to /data |
— |
PLEX_URL / PLEX_TOKEN / PLEX_SECTION (wrapper) |
optional Plex rescan after a merge | unset = skip |
- Point
MERGE_ROOTSat your own library folders. - If your dubtitle sidecars use a different name, change
DUB_SUFFIX. - The signs track is matched by its title containing "sign" or "song" — adjust
SIGNS_KEYWORDSin the script if your releases label it differently.
Notes & gotchas
- Plex reads
.asssidecars and renders the styling/positioning on direct-play clients; transcoding clients burn them in. - If you use subgen with
SKIP_IF_EXTERNAL_SUBTITLES_EXIST, it already treats.assas an external subtitle, so replacing the.srtwith the merged.asswon't trigger a re-transcribe loop. - The merged file keeps the same
…eng.dubtitles.*name, so it still shows up as your "Dubtitles" track — just now with signs included. - Upgrading from a build older than the version stamp: the
.dubtitles.donestamp is now the only "already done" signal — an embeddedDubtitlestrack by itself no longer counts. Episodes muxed before stamps existed would therefore be regenerated on the first sweep. Run the one-time migration first (dry run by default) to stamp them as v1:
It only writes sidecar stamps — media is never touched — and it never overwrites an existing one.python3 scripts/migrate_write_v1_stamps.py "/media/Anime Library" # show what it'd do python3 scripts/migrate_write_v1_stamps.py --apply "/media/Anime Library" - Track order: the new
Dubtitlestrack is appended last, so if an old one sat mid-list the relative order of the other subtitle tracks is unchanged but the dubtitle moves to the end. Players pick it up via the default-track flag; index-based scripts may see a different position.
Reviewing what the repair stage changed
The LLM repair pass fixes real ASR errors, and it also produces changes that pass every
mechanical gate while destroying the meaning — We're looking for a factory. became
a needle., It's a VIVRA card? became a Vivi card?. Nothing in the pipeline can
detect those, so accepted repairs are queued for a human instead.
The page is at http://<host>:8842. On first start the container logs a token; paste it
into the box at the top once and the browser remembers it. REVIEW_TOKEN sets it
explicitly; leaving it unset generates one (the server runs as root and its write routes
rewrite subtitles, so "unset" cannot mean "no auth"). Setting REVIEW_TOKEN= to an
explicitly empty value disables auth entirely — only do this on a network you fully
trust; the server logs a warning at startup if you do this while also bound to 0.0.0.0
(the default), since that combination means anything on the LAN can rewrite your subtitles.
See SECURITY.md for the full auth model.
The index groups episodes by show and season and leads with the ones holding admitted repairs — changes that already shipped with nothing checking their meaning. Refusals, where the guard blocked the repair and the ASR text shipped, are the safe outcome and are hidden behind a toggle.
An episode's queue is ordered worst first, because most of what the stage does is harmless. Measured over the 682 admitted repairs of one 48-episode run: 78% changed no word at all, only punctuation or capitalisation. So each entry is classified by what the repair actually did — a word added or dropped, a word swapped, or punctuation only — and shown in that order, admitted repairs before refusals. Every regression found in a hand-read of 45 lines changed a word; none of the punctuation-only ones could.
Each entry carries the start time of the card it is on, so you can seek to the line and hear what was actually said. Times are derived from the transcript, so they are as accurate as the ASR word timings — close enough to find the line, not frame-accurate.
Verdicts are accept, reject, correct (supply your own text) and force (apply a
repair the guard refused); each entry offers only the ones that make sense for it. Pick them
with the radio buttons as you read — nothing is submitted until you hit Save, which
hands the whole episode back in one request. Verdicts are stored per show, so the same
decision applies the next time that line is transcribed.
A verdict settles the line, show-wide — not the one queue row that raised it. Judge the opening song on the first episode and it disappears from every other episode's queue, and the page says how many it hid. The shared lines link lists every repair that appears in more than one episode, once, with its episode count, so the repeated ones can be cleared in a single pass before you touch anything episode-specific. Measured on one 48-episode run: 665 open questions, 487 distinct lines — 27% of the reading was a question already answered.
Grouping is on the exact text pair, which is the same identity the decision store uses. The same sung line transcribed two ways stays two decisions; that is deliberate, since a reviewer who read one has not read the other.
The page walks the whole media tree to find episodes and reads each one's queue. On a large
library over a network mount that is slow — measured at 297s for the walk alone across 989
episodes — so the container does it at startup, on a background thread, and logs
cache warm — N episodes in Ns when it finishes. The port opens immediately; a page opened
before the warm completes waits for it.
Both the episode list and the per-episode queues are then held until the next walk, whose
timing is a multiple of what the last one cost (REVIEW_STEMS_TTL is the floor,
REVIEW_STEMS_TTL_FACTOR the multiplier). Nothing is re-checked per request — on a mount
like this, a stat costs what a read costs, so validating a cache is not cheaper than filling
it. A newly generated episode therefore appears at the next walk, or immediately on restart.
Verdicts are never cached. The decision store is one small file read on every request, so a decision shows up on every other episode the moment it is saved, and saving re-reads only the episode written to.
Apply decisions to this episode is the separate, expensive step: it rewrites the subtitle and drops the stamp so the merge loop re-muxes the file. Only an episode that has already been muxed needs it — for anything still queued, saving the verdicts is enough, because the next repair run reads them.
Requirements
ffmpeg/ffprobe, pysubs2, and
jellyfish — all baked into Dockerfile.builder,
the image the Quick start above builds. The deprecated Dockerfile only needs
ffmpeg/pysubs2, since it doesn't run the phonetic name-matching that jellyfish backs.
Roadmap
-
Web UI — a small dashboard to watch the rollout (per-show / per-episode progress, GPU status, live logs), queue or reorder shows, kick off a re-scan, and edit per-show glossaries — instead of tailing logs over SSH.
-
Per-show glossary editor — manage the name/spelling glossaries from the UI.
-
Community glossary repo — XenaRathon/DubTitlerr-glossaries is the shared repository of per-show glossaries; what is still on the roadmap is the pipeline fetching it on startup and submitting mined dictionaries back automatically. Today it is a
git cloneinto yourGLOSSARY_DIRand agit pullto update, whichdecisions.pyalready documents as the intended flow.This repository's
glossaries/directory is a snapshot, used by the tests and by the examples in these docs. The pipeline reads whatever is mounted atGLOSSARY_DIRand never the copy in its own source tree, so the shared repository is the one to install.
License
GPL-3.0 — see LICENSE.
Built with the help of Claude (Anthropic — Claude Opus 4.8).