V2: Models, Ops & Remaining Polish (llama.cpp backend, phonetic match, CUDA-gating fix, signs/font audit) #2

Merged
xenarathon merged 45 commits from feat/v2-models-ops into main 2026-07-24 18:58:29 -04:00
Owner

Summary

Implements the V2: Models, Ops & Remaining Polish spec (specs/v2-models-ops/) — the ~35
review items deferred from V1. Executed and reviewed unit-by-unit (6 units, one whole-branch review),
grouped by the plan's Phase A–D. Depends on V1 (merged to main first).

Phase A — Models & Accuracy (repair.py, glossary.py, generate.py)

  • llm() now dispatches on REPAIR_BACKEND (ollama | llamacpp); a llamacpp backend posts to
    REPAIR_LLAMACPP_URL (/completion schema). Split connect/read timeouts (REPAIR_TIMEOUT_CONNECT
    /_READ) via stdlib http.client; per-line latency_ms in the repair CSV; repair-summary.json.
  • Two-pass repair via REPAIR_MODEL_SECONDARY (no-op when equal to primary).
  • Tier-4 phonetic glossary matching (jellyfish.metaphone), gated on non-English tokens, degrades
    gracefully if jellyfish is absent.
  • Per-word word_probs in dubtitles.conf.json (optional) + a low-prob-word targeting gate in is_target().
  • WHISPER_AUDIO_FILTER highpass+compand pre-filter in extract_wav(). See "⚠️ Deploy decision" below.
  • A9 (large-v3-turbo) deferred — needs a real GTX 1060 bench; WHISPER_MODEL is env-configurable,
    default stays large-v3.

Phase B — Shell & Ops (shell scripts, data/, common.py, plex_refresh.py)

  • Deprecation headers on the legacy orchestrators; set -e in gen_loop.sh; removed the self-healing
    apt-get from merge_pass.sh (fail loud instead).
  • Consolidated the EXTRA_DIRS exclusion list into a single data/extras.txt, read by both
    common.load_extras() and shell/lib.sh::extras_grep_pattern (all 4 consumers), with inline fallbacks.
  • .gitignore pipeline artifacts; plex_refresh.py env hardening (also cleared the last 2 tree-wide ruff errors).

Phase C — Python Polish (mux.py, repair.py, generate.py, glossary_verify.py, ordering.py, reflow.py, …)

  • mux: partners() inode cache, explicit HL_ROOTS default, dropped redundant samefile(), removed
    the verify() half-size heuristic (duration-tolerance is the sole truncation canary — a truncation
    test was added), identify() reuse.
  • repair: prompt-injection XML-wrap of the fansub reference; whole-term glossary-cap (no mid-name truncation).
  • generate: per-show lastrun.json; CUDA error gating fixed — gates on isinstance(RuntimeError)
    instead of substring-matching "cuda", so a non-GPU error no longer permanently poisons an episode
    (it clears .fail and logs crash.json for retry).
  • glossary_verify.adjudicate() parallelized (VERIFY_WORKERS, default 4); ordering.read_start()
    default fixed; anime_library.sh --dry-run; COMMON/BLOCKLIST moved to data/ with inline fallback;
    cross-file os.chown-failure logging; Authorization sections filled in the older specs; reflow readability.

Phase D — Signs/songs + font audit (dub_signs_merge.py, mux.py)

  • Diagnostic logging: style-name collisions, WrapStyle differences, resolution mismatch (WARN-ONLY —
    no track drop / no coordinate transform, deferred to V3), and a forced ScaledBorderAndShadow: yes.
  • mux.verify() font-attachment audit — compares source vs muxed font counts (returns
    font-count-mismatch), strictly additive (never a false "ok").

Notable decisions

  • ordering.read_start() (C4) — the literal task said "no priority file → return 0", but that would
    have silently dropped the existing SEASON_START env override. Kept SEASON_START working and made
    the "watch-order disabled" log fire only when the result truly resolves to 0. (User-adjudicated.)
  • verify() half-size removal (C16) — confirmed the duration-tolerance check runs unconditionally on
    the sole success path before removing the size proxy; added a truncated-output regression test.
  • D2 font audit — the task's literal code targeted the wrong mkvmerge -J field (tracks w/
    type=="attachments"); corrected to the real top-level attachments array (else it'd be a permanent no-op).
  • Fixed a pre-existing V1 bug: Dockerfile.builder never COPY'd common.py (added in V1) → the
    container would ImportError at startup. Now copies common.py, data/, and shell/. A rebuild of
    the builder image is required on deploy regardless of this PR.

⚠️ Deploy decision required — WHISPER_AUDIO_FILTER

The A8 default applies a highpass=f=80,compand=... filter to every new transcription going forward
(already-stamped episodes are untouched). This is the spec's intent but is unverified on real audio.
Please make a go/no-go before rebuilding. To opt out with zero code change, set WHISPER_AUDIO_FILTER="".

Deploy notes

  • Rebuild Dockerfile.builder (closes the V1 common.py ImportError; ships data/+shell/+jellyfish).
  • New env vars (all default to prior behavior except WHISPER_AUDIO_FILTER) need DockHand per-stack
    wiring only if you want to change defaults: REPAIR_BACKEND, REPAIR_LLAMACPP_URL,
    REPAIR_MODEL_SECONDARY, REPAIR_TIMEOUT_CONNECT/READ, VERIFY_WORKERS, WHISPER_AUDIO_FILTER,
    SEASON_PRIORITY_FILE, HARDLINK_ROOTS.
  • Hardware-gated items PENDING manual verification (not tested here): A9 large-v3-turbo bench, the live
    llama.cpp backend (192.168.1.232:8080), and a real container_run.sh end-to-end sweep.

Test plan

  • pytest -q193 passed (baseline 122 at V2 start; +71 across the 6 units). Run via
    rtk proxy python -m pytest tests/ (this shell's RTK hook otherwise mangles pytest output).
  • ruff check .0 errors whole-tree (V1 had left 2 in plex_refresh.py; B11 cleared them).
  • LLM/HTTP paths tested with mocked transport (routing + parsing); verify() font/truncation paths and
    the CUDA-gating retry-vs-poison branches have dedicated tests.

Known minor follow-ups (non-blocking)

mux.partners()/HL_ROOTS/DELETE_BROKEN are dead code on the current hardlink-safe path (polished but
unused); llm_llamacpp ignores its model arg, so two-pass under REPAIR_BACKEND=llamacpp re-runs the same
model (wasted compute, not incorrect). Both documented for a later pass.

🤖 Generated with Claude Code

## Summary Implements the **V2: Models, Ops & Remaining Polish** spec (`specs/v2-models-ops/`) — the ~35 review items deferred from V1. Executed and reviewed unit-by-unit (6 units, one whole-branch review), grouped by the plan's Phase A–D. Depends on V1 (merged to main first). **Phase A — Models & Accuracy** (repair.py, glossary.py, generate.py) - `llm()` now dispatches on `REPAIR_BACKEND` (`ollama` | `llamacpp`); a `llamacpp` backend posts to `REPAIR_LLAMACPP_URL` (`/completion` schema). Split connect/read timeouts (`REPAIR_TIMEOUT_CONNECT` /`_READ`) via stdlib `http.client`; per-line `latency_ms` in the repair CSV; `repair-summary.json`. - Two-pass repair via `REPAIR_MODEL_SECONDARY` (no-op when equal to primary). - Tier-4 phonetic glossary matching (`jellyfish.metaphone`), gated on non-English tokens, degrades gracefully if `jellyfish` is absent. - Per-word `word_probs` in `dubtitles.conf.json` (optional) + a low-prob-word targeting gate in `is_target()`. - `WHISPER_AUDIO_FILTER` highpass+compand pre-filter in `extract_wav()`. **See "⚠️ Deploy decision" below.** - A9 (large-v3-turbo) **deferred** — needs a real GTX 1060 bench; `WHISPER_MODEL` is env-configurable, default stays `large-v3`. **Phase B — Shell & Ops** (shell scripts, data/, common.py, plex_refresh.py) - Deprecation headers on the legacy orchestrators; `set -e` in `gen_loop.sh`; removed the self-healing `apt-get` from `merge_pass.sh` (fail loud instead). - Consolidated the EXTRA_DIRS exclusion list into a single `data/extras.txt`, read by both `common.load_extras()` and `shell/lib.sh::extras_grep_pattern` (all 4 consumers), with inline fallbacks. - `.gitignore` pipeline artifacts; `plex_refresh.py` env hardening (also cleared the last 2 tree-wide ruff errors). **Phase C — Python Polish** (mux.py, repair.py, generate.py, glossary_verify.py, ordering.py, reflow.py, …) - mux: `partners()` inode cache, explicit `HL_ROOTS` default, dropped redundant `samefile()`, removed the `verify()` half-size heuristic (duration-tolerance is the sole truncation canary — a truncation test was added), identify() reuse. - repair: prompt-injection XML-wrap of the fansub reference; whole-term glossary-cap (no mid-name truncation). - generate: per-show `lastrun.json`; **CUDA error gating fixed** — gates on `isinstance(RuntimeError)` instead of substring-matching `"cuda"`, so a non-GPU error no longer permanently poisons an episode (it clears `.fail` and logs `crash.json` for retry). - `glossary_verify.adjudicate()` parallelized (`VERIFY_WORKERS`, default 4); `ordering.read_start()` default fixed; `anime_library.sh --dry-run`; COMMON/BLOCKLIST moved to `data/` with inline fallback; cross-file `os.chown`-failure logging; Authorization sections filled in the older specs; reflow readability. **Phase D — Signs/songs + font audit** (dub_signs_merge.py, mux.py) - Diagnostic logging: style-name collisions, `WrapStyle` differences, resolution mismatch (WARN-ONLY — no track drop / no coordinate transform, deferred to V3), and a forced `ScaledBorderAndShadow: yes`. - `mux.verify()` font-attachment audit — compares source vs muxed font counts (returns `font-count-mismatch`), strictly additive (never a false `"ok"`). ## Notable decisions - **`ordering.read_start()` (C4)** — the literal task said "no priority file → return 0", but that would have silently dropped the existing `SEASON_START` env override. Kept `SEASON_START` working and made the "watch-order disabled" log fire only when the result truly resolves to 0. (User-adjudicated.) - **`verify()` half-size removal (C16)** — confirmed the duration-tolerance check runs unconditionally on the sole success path before removing the size proxy; added a truncated-output regression test. - **D2 font audit** — the task's literal code targeted the wrong `mkvmerge -J` field (`tracks` w/ `type=="attachments"`); corrected to the real top-level `attachments` array (else it'd be a permanent no-op). - **Fixed a pre-existing V1 bug**: `Dockerfile.builder` never `COPY`'d `common.py` (added in V1) → the container would `ImportError` at startup. Now copies `common.py`, `data/`, and `shell/`. **A rebuild of the builder image is required on deploy regardless of this PR.** ## ⚠️ Deploy decision required — WHISPER_AUDIO_FILTER The A8 default applies a `highpass=f=80,compand=...` filter to **every new transcription** going forward (already-stamped episodes are untouched). This is the spec's intent but is **unverified on real audio**. **Please make a go/no-go before rebuilding.** To opt out with zero code change, set `WHISPER_AUDIO_FILTER=""`. ## Deploy notes - **Rebuild `Dockerfile.builder`** (closes the V1 `common.py` ImportError; ships `data/`+`shell/`+`jellyfish`). - **New env vars** (all default to prior behavior except WHISPER_AUDIO_FILTER) need DockHand per-stack wiring only if you want to change defaults: `REPAIR_BACKEND`, `REPAIR_LLAMACPP_URL`, `REPAIR_MODEL_SECONDARY`, `REPAIR_TIMEOUT_CONNECT/READ`, `VERIFY_WORKERS`, `WHISPER_AUDIO_FILTER`, `SEASON_PRIORITY_FILE`, `HARDLINK_ROOTS`. - Hardware-gated items **PENDING manual verification** (not tested here): A9 large-v3-turbo bench, the live llama.cpp backend (192.168.1.232:8080), and a real `container_run.sh` end-to-end sweep. ## Test plan - `pytest -q` → **193 passed** (baseline 122 at V2 start; +71 across the 6 units). Run via `rtk proxy python -m pytest tests/` (this shell's RTK hook otherwise mangles pytest output). - `ruff check .` → **0 errors whole-tree** (V1 had left 2 in `plex_refresh.py`; B11 cleared them). - LLM/HTTP paths tested with mocked transport (routing + parsing); `verify()` font/truncation paths and the CUDA-gating retry-vs-poison branches have dedicated tests. ## Known minor follow-ups (non-blocking) `mux.partners()`/`HL_ROOTS`/`DELETE_BROKEN` are dead code on the current hardlink-safe path (polished but unused); `llm_llamacpp` ignores its model arg, so two-pass under `REPAIR_BACKEND=llamacpp` re-runs the same model (wasted compute, not incorrect). Both documented for a later pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Renames the Ollama call to llm_ollama(prompt, model=None) (unchanged request
shape/response parsing), adds llm_llamacpp(prompt, model) for llama.cpp's
/completion schema, and makes llm(prompt, model=None) dispatch between them
on REPAIR_BACKEND (default ollama, byte-for-byte the old behavior). New
REPAIR_LLAMACPP_URL env var. Live llama.cpp integration not reachable from
this environment; covered here with mocked urllib.request.urlopen only.
Adds REPAIR_TIMEOUT_CONNECT (default 10) and REPAIR_TIMEOUT_READ (default
120). urllib.request.urlopen only exposes one timeout for connect+every
read, so the transport moves to a small http.client-based _post_json():
the connect timeout is set on the connection, the read timeout on the
socket right after connect(). llm_ollama/llm_llamacpp now go through it.

process() times each LLM call with time.monotonic() and adds a latency_ms
column to dubtitles.repair.csv.
Adds REPAIR_MODEL_SECONDARY (default REPAIR_MODEL, no-op when equal). After
the first LLM pass produces an accepted repair, _needs_secondary_check()
decides whether to re-send the same prompt to the secondary model: length
ratio < 0.6 or > 1.5, or the output contains a glossary name not present in
the original line. Per the corrected spec, the name-appeared condition is
expected to fire on ~every successful name repair (re-verify all name
changes with the stronger model), not just a rare-case fraction.
process() now writes <stem>.dubtitles.repair-summary.json (via out_for(),
same as the srt/csv) with targets, repaired, skipped_no_ref, mean/p95
latency (nearest-rank _p95(), no numpy), model, model_secondary and a
repaired_lines detail list. skipped_no_ref counts targets with no fansub
anchor (previously silently dropped).
_phonetic_match(token, names) fires after the guarded-fuzzy tier when a
non-English token's Metaphone code equals a glossary name's code (e.g.
"spondum" -> "Spandam", SPNTM==SPNTM, which the fuzzy cutoff rejects at
~0.71 similarity). Import is wrapped in try/except ImportError so a
missing jellyfish degrades to the existing 3-tier behavior.

Reuses the fuzzy tier's _one_indel() exclusion: a one-char insert/delete
mishear (e.g. "spandm"/"Spandam") also Metaphones identically but is the
same risky-edit case the fuzzy tier already defers to the LLM repair
stage -- tier 4 must not bypass that guard via a different matching path
(caught by the existing test_correct_guarded_fuzzy_refuses_one_char_indel).
jellyfish>=1.0 backs the A4 phonetic-match tier in glossary.py. Pure
Python (~50KB), no C deps -- added to project dependencies and to
Dockerfile.builder's pip install line alongside pysubs2.
_card_word_probs(card, words) joins a card's [start, end] window against
the full per-episode word list (collected via getattr(w, "probability",
1.0) in the whisper-adaptation loop) by time overlap, since reflow's Card
doesn't retain which whisper words it was built from. Computed on each
kept card's PRE-collapse boundaries so a later hallucination.collapse_runs()
merge (which keeps run[0]'s text verbatim) also keeps the word_probs that
actually correspond to that text, rather than the widened merged window.

Optional/backward-compatible: only added to a conf.json row when non-empty,
same pattern as the existing "flag" field.
has_low_prob_word(c) returns True if any value in c.get("word_probs", [])
(V2 A6) is below LOW_WORD_PROB (0.25) -- catches a single badly-mis-heard
word hiding inside a card whose avg_logprob otherwise looks fine. Added
as an OR condition in is_target() alongside the existing avg_logprob
check. Missing/empty word_probs (conf.json predating A6) -> False,
backward-compatible with the existing avg_logprob/name_suspect gate.
A8: extract_wav() appends -af "$WHISPER_AUDIO_FILTER" to the ffmpeg
command. Default is a highpass(80Hz) + compand filter (spec's Data
contracts value), tuned to clean up noisy/quiet dub audio before
transcription. Empty string disables it, reproducing the exact pre-A8
ffmpeg command (backward-compat opt-out).

BEHAVIORAL NOTE: since extract_wav() previously applied no filter at all,
this default changes audio extraction for every NEW transcription from
here on (already-generated .srt/.conf.json sidecars are untouched --
process()'s idempotency stamps mean only not-yet-transcribed episodes see
it). This is the spec's stated intent for the accuracy phase, not an
accident; flagged here per the task brief instead of silently shipped.

A9: WHISPER_MODEL was already env-configurable (no code change needed).
large-v3-turbo has NOT been bench-tested in this dev environment (no GPU
available here) -- recorded as PENDING manual test on the real GTX 1060
6GB server in both the module docstring and an inline comment. Default
stays large-v3; no turbo benchmark result is claimed or fabricated.
anime_library.sh/all_seasons.sh reload the Whisper model per show; merge_watcher.sh
spins up a fresh container every interval just to run merge_pass.sh. container_run.sh
does both more efficiently (resident model, in-process merge loop). No functional
change — comment-only, scripts retained for reference.
The old Dockerfile only builds the signs+dub merge step; Dockerfile.builder is the
full transcribe+repair+merge+mux pipeline and is the maintained path. README quick
start now builds/runs the builder image instead of dub-signs-merge.
mine/verify already used `|| echo` fallthroughs; the generate.py call did not, so a
crash there would previously just fall through to rc=$? and the stall-detection
logic. Under set -e that same crash would abort the whole container before rc=$?
ever ran. Switch it to `&& rc=0 || rc=$?` (verified with a standalone repro) so the
real exit code is still captured and crash-resume keeps working, while any other,
un-tolerated failure now fails loud instead of silently limping on.
The image should already have ffmpeg/mkvmerge/pysubs2 baked in (Dockerfile.builder);
silently apt-get-installing them on every pass masked a misbuilt image and added
unnecessary network calls to a hot path. Now exits 1 with a clear FATAL message
naming the missing tool.
Reproduces the 9 dir names currently hardcoded in common.py::EXTRA_DIRS exactly.
Feeds both common.load_extras() (Python, next commit) and shell/lib.sh's
extras_grep_pattern() so generate.py, mine_glossary.py, merge_pass.sh, and
post_show.sh all read the same list instead of 4 independent copies.
load_extras(path="data/extras.txt") reads the consolidated data file into a
lowercased set; falls back to the pre-consolidation hardcoded set on OSError so
dev checkouts / older images without the data file still work. common.EXTRA_DIRS
now comes from load_extras() at import time -- verified equal to the old hardcoded
set (same 9 entries).
Reads data/extras.txt and emits a grep -iE alternation pattern. Verified
byte-for-byte identical to the pre-consolidation inline regex in merge_pass.sh/
post_show.sh: '(Behind The Scenes|Deleted Scenes|Featurettes|Interviews|Scenes|
Shorts|Trailers|Other|Extras)'. Missing/unreadable file degrades to '()' (an
always-non-matching pattern); callers (B9) supply their own inline fallback.
Both previously imported the hardcoded common.EXTRA_DIRS constant directly; now both
call load_extras() themselves (still via common.py, still falling back to the same
hardcoded set) so all Python consumers agree on data/extras.txt as the source. Split
into a plain `from common import load_extras` + separate `EXTRA_DIRS = load_extras()`
assignment rather than a semicolon-joined one-liner -- the latter tripped ruff's I001
isort check with no auto-fixable diff. Verified: mine_glossary.EXTRA_DIRS ==
common.EXTRA_DIRS == generate.EXTRA_DIRS (module import deferred here since
generate.py needs faster_whisper, which the test suite stubs); 156 tests still pass.
Both scripts had their own copy of the inline title-cased alternation regex. Now both
source shell/lib.sh and call extras_grep_pattern() against data/extras.txt, falling
back to the literal old regex if sourcing fails or the data file is missing/unreadable
(extras_grep_pattern signals that case by returning 1 with no stdout, so the `||
echo fallback` idiom captures cleanly instead of concatenating garbage).

merge_pass.sh uses its existing $APP variable (APP_DIR, default /scripts, set to /app
by container_run.sh) rather than the tasks.md snippet's hardcoded /app, since the
script is also still reachable via the deprecated merge_watcher.sh's /scripts mount.
post_show.sh has no such variable and already hardcodes /scripts everywhere else, so
its new lines match that (its only caller, anime_library.sh, is deprecated as of B1
and always mounts to /scripts, never /app).

Verified equivalent to the old inline regex with a standalone repro (fake dir tree
with a Behind The Scenes + an Extras subfolder): old regex, new success path, and
new fallback path all produce the identical filtered file list.
Sidecars/stamps/logs the pipeline writes into the media tree (or, during local
testing, next to the repo) shouldn't ever land in git status as untracked.
Verified: touching one file per pattern produces no untracked entries.
Bare os.environ["PLEX_URL"]/["PLEX_TOKEN"] raised an opaque KeyError if unset.
Now os.environ.get(...) + sys.exit("PLEX_URL not set") / "PLEX_TOKEN not set".
Also splits the combined `import os, sys, urllib.parse, urllib.request` line into
one-per-line (already alphabetical, so no reordering needed) -- these were the
last 2 ruff errors in the tree; `ruff check .` is now 0 errors whole-tree.
common.py was never added to Dockerfile.builder's COPY list when V1 introduced it --
every module doing `from common import ...` (generate.py, mine_glossary.py, mux.py,
repair.py, dub_signs_merge.py) would ImportError at container start. Found while
verifying the B7/B9 EXTRA_DIRS consolidation needs data/extras.txt and shell/lib.sh
in the image too (referenced via $APP/data/extras.txt and $APP/shell/lib.sh, where
$APP defaults to /app under container_run.sh). Added all three; used separate COPY
<dir>/ /app/<dir>/ instructions for data/ and shell/ so their contents land in a
subdirectory rather than being flattened into /app/ (Docker's COPY semantics for a
directory source copy contents, not the directory itself).
Missed staging this in the B9 commit. Without this, a missing/unreadable
data/extras.txt would make extras_grep_pattern print the literal string "()" and
still exit 0 -- callers' `PATTERN=$(extras_grep_pattern ... || echo fallback)`
would then use "()" instead of falling back, and grep -ivE "()" matches (and thus
excludes) every line, since an empty alternation matches the empty string
anywhere. Verified: missing-file case now returns 1 with no stdout, so the `||`
fallback triggers cleanly.
Read HARDLINK_ROOTS once into a local instead of calling
os.environ.get() twice for the same var.
Matching (st_ino, st_dev) already establishes hardlink identity;
the extra samefile() stat-and-compare was dead weight.
A full HL_ROOTS tree walk per file is expensive; module-level
_partners_cache memoizes the result for the process lifetime
(one mux sweep), since hardlink partners don't change mid-sweep.
The size gate false-positived on compact muxes where mkvmerge
shrinks the CUES or drops a large embedded .ass. Verified first
that verify() has exactly one success path and the duration-tolerance
check already runs unconditionally on it -- that's the real
truncation canary, so no extra guard is needed once the size
proxy is gone. Adds a regression test asserting a truncated
(short-duration) output still fails verify().
The reference text comes from an untrusted third-party fansub file.
Wrapping it in <official_subtitle_reference> tags makes the model
read it as quoted data rather than instructions -- a prompt-injection
guard. Adds test_build_prompt_wraps_reference_in_xml_tag.
A raw [:1000] slice on the joined string could cut a name in half
mid-word, feeding the model a garbled "canonical spelling" fragment.
Now accumulates whole terms until the next would exceed 1000 chars.
After all episodes in a --root/explicit-file run finish, persist elapsed_s,
episode/card/drop/collapse/flag totals plus model + glossary revision info
to GLOSS_DIR/<show>.lastrun.json for ops visibility across runs.
verify() previously ran one blocking Ollama HTTP call per pending term,
serially. Add VERIFY_WORKERS (env, default 4) and run adjudicate() calls
concurrently via ThreadPoolExecutor.map, which preserves per-term result
pairing/ordering regardless of completion order.
Default path is None; resolves only from SEASON_PRIORITY_FILE (drops the
hardcoded /config/season_priority.txt silent fallback). No path configured
now logs "watch-order disabled" instead of silently probing a default file.
A malformed "Show:NN" value logs a warning instead of silently returning 0.
SEASON_START env fallback and file-beats-env precedence are unchanged.
Walks the show list and classifies each video by sidecar/stamp presence
(no .done/.ass/.srt/.fail -> generate; .srt no .ass -> repair; .ass no
.done -> mux), prints "would generate N, repair M, mux K", exits 0. No
containers launched. Verified via bash -n/sh -n plus a manual fixture
walk (mixed episode states, empty show dir, missing show, default LIST
resolution) -- real container sweep not runnable in this dev environment.
mine_glossary.COMMON -> data/common_proper_noun_deny.txt (one word per line);
hallucination.BLOCKLIST -> data/hallucination_blocklist.txt (one regex
alternative per line, joined with "|" and compiled with re.I). Both load at
module import and fall back to the exact original inline list/pattern if the
data file is missing/unreadable, verified byte-identical in tests.
generate.py::process(), repair.py::process(), and dub_signs_merge.py::
process_one() each had a bare "except OSError: pass" after os.chown --
replaced with log(f"chown failed for {p}: {e}") in all three so a
permission/ownership problem shows up in the run log instead of
disappearing. mux.py's os.chown is already covered by process()'s outer
except-and-log handler -- no bare-pass instance to fix there.
Adds "Who can execute" / "Behavior without permission" to a1-reflow-timing,
b1-hallucination-gate, c1-glossary-precision, d1-mux-fonts, and
glossary-wiki-verify. Notes one pre-existing gap in glossary_verify.py
(fetch_titles()'s os.makedirs() isn't exception-guarded) as out of scope
for this docs-only task.
Replace the (max_len, text) tuple packed into `fallback` with two named
variables, best_max_len and fallback_text -- readability only, behavior
identical (test_reflow.py unchanged and green).
The exception handler around transcription used to match "cuda"/"out of
memory"/etc. as a case-insensitive substring of str(e) -- a plain
ValueError or ZeroDivisionError that happens to mention "cuda" in its
message/stacktrace would falsely poison the episode and exit(3), even
though it has nothing to do with the GPU context.

Replace with isinstance(e, RuntimeError) -- what faster-whisper/
ctranslate2 actually raise for real GPU errors (OOM, device ordinal,
cuBLAS). RuntimeError still poisons (.fail kept, exit 3, fresh-context
restart). Any other exception type now clears the .fail marker so the
episode retries next sweep, and persists a small JSON retry log
(<stem>.dubtitles.crash.json: path/exc_type/msg/time) for later triage.

Two new tests prove both branches: a RuntimeError keeps .fail and
exits 3; a ValueError mentioning "cuda" in its message (the exact case
the old substring gate would have mishandled) clears .fail and lets the
show continue.
read_start() honors SEASON_START as a fallback when no SEASON_PRIORITY_FILE is
configured (unchanged, correct), but logged "watch-order disabled" even when a
non-zero SEASON_START meant reordering would actually happen. Now the disabled
log only fires when the resolved start is genuinely 0; a non-zero SEASON_START
gets an accurate "using SEASON_START=<n> (no priority file)" log instead. Also
corrects the docstrings to describe SEASON_START as a legitimate global
fallback rather than a hidden deviation.
build()'s style-merge loop already kept the first track's definition
when two tracks reuse a style name; it now also logs when the two
definitions disagree on fontname/fontsize, so a silent mismatch is
visible in run logs instead of just quietly winning.
Each source .ass track's WrapStyle is now compared against the base
track's value; a mismatch logs a warning naming both values. The base
track's WrapStyle still wins — this is diagnostic only, no output
events or styles change.
Sets base.info["ScaledBorderAndShadow"] = "yes" once the base canvas
is established, overriding whatever the source track had, so
outlines/shadows render consistently across Plex, mpv and VLC.
Collects (PlayResX, PlayResY) from every source track and logs a loud
warning if they differ, since positioned signs are only correct
relative to the resolution they were authored at. WARN ONLY -- no
coordinate transform and no track is skipped or dropped; the actual
resolution normalization is deferred to V3 (spec "Decisions taken").
mkvmerge -J reports embedded fonts as a top-level "attachments" array
(sibling of "tracks"), not as track entries -- confirmed against
mkvmerge's own JSON identification schema and mux.py's existing
tracks-by-type usage, which never sees an "attachments" type. The
task's originally-specified tracks-filter approach would have been a
permanent no-op against real mkvmerge output.

verify() now compares identify(orig)["attachments"] vs the muxed
output's, after the existing av/dubtitles-track/duration checks, and
returns "font-count-mismatch" on a count mismatch -- strictly adding a
new non-ok path, never changing an existing "ok" result. .get(...,
[]) treats a fontless file (key absent) as 0 so equal-zero still
verifies "ok". Bonus: logs (doesn't fail) when an attachment's
content_type is the generic application/octet-stream.
spec.md's D5 acceptance said 'skips the mismatched track', contradicting
tasks.md/the Decisions table (warn-only, transform deferred to V3). Skipping a
track would drop its signs entirely — worse than a warning. Aligned to warn-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(shell): POSIX '.' instead of bash 'source' for shell/lib.sh
Some checks failed
CI / test (push) Failing after 1m23s
CI / test (pull_request) Failing after 1m20s
tests / test (push) Failing after 1m30s
tests / test (pull_request) Failing after 1m26s
eb76b64e11
merge_pass.sh/post_show.sh are #!/bin/sh (dash in the subgen container), where
'source' is undefined — the B7/B9 EXTRA_DIRS consolidation silently fell through
to the inline fallback in-container. '.' is POSIX and works in dash and bash, so
the shared data/extras.txt is actually used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
xenarathon/DubTitlerr!2
No description provided.