Skip to content

fix: make write_smil strictly additive, add broken-SMIL scanner - #9

Merged
yidakra merged 5 commits into
mainfrom
fix/smil-additive-only
Jul 15, 2026
Merged

fix: make write_smil strictly additive, add broken-SMIL scanner#9
yidakra merged 5 commits into
mainfrom
fix/smil-additive-only

Conversation

@yidakra

@yidakra yidakra commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes the production incident of 2026-07-13 where transcriber-written SMIL manifests broke adaptive playback (single unnamed 1080p stream, malformed CODECS="avc1,mp4a" in the Wowza playlist).

Root cause: when a video's .smil was missing or failed XML parsing, write_smil created/regenerated it from scratch with a single <video> node — the one variant we transcribed — plus bare videoCodecId/audioCodecId params. This stacked on a second failure: the transcoder-side SMIL generator had lost write permission to storage, so a growing set of videos had no original SMIL for us to update.

Changes

write_smil is now strictly additive — it only adds <textstream> entries to an existing, valid, transcoder-generated SMIL:

  • never creates a SMIL when none exists (skips with a warning, returns False)
  • never regenerates on parse error
  • never adds or modifies <video> nodes
  • refuses to proceed if the pre-modification backup fails
  • writes atomically (tmp + rename)

scripts/find_broken_smils.py — stdlib-only remediation tool for a host with storage access (e.g. prod12):

  • BROKEN_HAS_BACKUP → auto-restore from the oldest valid .smil.bak.* (--restore-bak --apply, dry-run by default)
  • BROKEN_TRANSCRIBER_CREATED, MISSING_SMIL (video has _1080p.mp4 but no SMIL at all), UNPARSEABLE → written to smils_to_regenerate.txt for the transcoder-side generator
  • JSONL report of every non-healthy SMIL

Tests rewritten to encode the additive-only contract — the old tests asserted the harmful create-from-scratch behavior.

Test plan

  • uv run pytest tests/ — 101 passed
  • Scanner verified on a synthetic tree covering all verdict classes (healthy, transcriber-created, restorable-from-bak, missing-SMIL, _audio.smil skipped)
  • Run scanner on prod12: 144,399 SMILs scanned — 110,506 OK with subtitles, 33,817 untouched, 76 broken (0.05%, listed in smils_to_regenerate.txt for the transcoder-side generator), 0 restorable from backup

🤖 Generated with Claude Code

https://claude.ai/code/session_01GPhtp4PREZcRfm2WXL5NF7

Incident 2026-07-13: when a video's SMIL manifest was missing or failed
XML parsing, write_smil created/regenerated it from scratch with a single
<video> node (the one variant we transcribed) and bare codec params. Wowza
then served a one-stream playlist with malformed CODECS="avc1,mp4a",
breaking adaptive playback on production videos.

write_smil now only adds <textstream> entries to an existing, valid,
transcoder-generated SMIL:
- never creates a SMIL when none exists
- never regenerates on parse error
- never adds or modifies <video> nodes
- refuses to proceed if the backup copy fails
- writes atomically (tmp + rename)
- returns bool so callers can tell the update was skipped

scripts/find_broken_smils.py (stdlib-only, run on a host with storage
access) scans the archive and classifies every video:
- BROKEN_HAS_BACKUP: restore from oldest valid .bak (--restore-bak --apply)
- BROKEN_TRANSCRIBER_CREATED / MISSING_SMIL / UNPARSEABLE: written to a
  regen list for the transcoder-side SMIL generator
Tests rewritten to encode the additive-only contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GPhtp4PREZcRfm2WXL5NF7
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@yidakra, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 56 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a441e411-0159-4701-8afd-90a8410bf2f4

📥 Commits

Reviewing files that changed from the base of the PR and between 985bc09 and 78059a0.

📒 Files selected for processing (1)
  • src/python/tools/archive_transcriber.py
📝 Walkthrough

Walkthrough

The PR adds a standard-library SMIL integrity scanner with reporting and optional backup restoration. It also changes subtitle association to update only existing valid manifests atomically, with processing prechecks and expanded tests for preservation, idempotency, backups, and subtitle handling.

Changes

SMIL integrity workflows

Layer / File(s) Summary
SMIL discovery and classification
scripts/find_broken_smils.py
Discovers SMIL targets, detects missing manifests, parses XML, identifies corruption fingerprints, and classifies verdicts.
Reporting and backup restoration
scripts/find_broken_smils.py
Generates JSONL reports and regeneration lists, and optionally restores the oldest valid backup with dry-run or apply behavior.
SMIL precheck and skip handling
src/python/tools/archive_transcriber.py
Validates SMIL structure before processing and records skipped jobs when manifests are missing or invalid.
Additive subtitle association
src/python/tools/archive_transcriber.py, tests/test_smil_generation.py
write_smil requires an existing valid manifest, preserves video variants, creates backups, performs atomic replacement, and is covered by subtitle-association tests.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ProcessingPhase
  participant SMILPrecheck
  participant Manifest
  participant WriteSMIL
  participant SMILFile
  ProcessingPhase->>SMILPrecheck: validate SMIL
  SMILPrecheck-->>ProcessingPhase: valid or invalid reason
  ProcessingPhase->>Manifest: record skipped job when invalid
  ProcessingPhase->>WriteSMIL: associate subtitles when valid
  WriteSMIL->>SMILFile: create backup and atomically update SMIL
Loading

Possibly related PRs

  • yidakra/livevtt#5: Both changes modify SMIL subtitle <textstream> handling and related source-format expectations.

Suggested reviewers: claude

Poem

I hopped through SMILs, both whole and cracked,
Found missing files and backups stacked.
Subtitles now join with careful grace,
While video streams stay in place.
Atomic hops make updates bright!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: stricter write_smil behavior and a broken-SMIL scanner.
Description check ✅ Passed The description is directly related to the changeset and accurately describes the additive SMIL flow, scanner, and tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
scripts/find_broken_smils.py (1)

52-56: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Ensure subprocess cleanup in iter_paths generator.

proc.wait() is only reached if the generator is fully consumed. If iteration stops early, the find process is left running. Use try/finally to guarantee cleanup.

♻️ Proposed fix
         proc = subprocess.Popen(find_args, stdout=subprocess.PIPE, text=True)
-        assert proc.stdout is not None
-        for line in proc.stdout:
-            yield Path(line.rstrip("\n"))
-        proc.wait()
+        try:
+            assert proc.stdout is not None
+            for line in proc.stdout:
+                yield Path(line.rstrip("\n"))
+        finally:
+            proc.wait()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/find_broken_smils.py` around lines 52 - 56, Update the iter_paths
generator to wrap iteration over proc.stdout in a try/finally block, ensuring
proc is terminated or otherwise cleaned up and waited on when iteration stops
early as well as when fully consumed. Preserve the existing yielded Path values
and subprocess invocation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/find_broken_smils.py`:
- Around line 84-89: Update the exception handling around ET.parse in classify
to catch OSError alongside ET.ParseError, preserving the existing UNPARSEABLE
verdict and error recording so unreadable or missing files do not abort the
scan.
- Around line 195-196: Update the restore loop in the args.apply branch to wrap
shutil.copy2(bak, rec["smil"]) in a targeted try/except for I/O or filesystem
errors, report the failure consistently, and continue processing subsequent
records so the regen-list rewrite still executes.
- Around line 102-104: Extract the duplicated codec-parameter fingerprint logic
into a shared helper in scripts/find_broken_smils.py, then update both classify
and oldest_valid_bak to call it instead of defining their own any(...) checks.
Preserve the existing detection for videoCodecId and audioCodecId parameters and
use the helper consistently in both callers.

In `@src/python/tools/archive_transcriber.py`:
- Around line 666-667: Update the SMIL subtitle update flow to track whether any
textstream was added; when all requested VTT or TTML files are missing, skip the
write and return False instead of reporting success. Preserve the existing
successful write path when a subtitle is added, and update
test_smil_missing_vtt_warning to assert the False result.
- Around line 787-790: Update the atomic SMIL write flow around job.smil and
tmp_path to preserve the original file metadata: seed the temporary file with
shutil.copy2(job.smil, tmp_path) before tree.write, then replace the original as
currently done. Add or update tests to verify the replaced SMIL retains the
original permissions.

---

Nitpick comments:
In `@scripts/find_broken_smils.py`:
- Around line 52-56: Update the iter_paths generator to wrap iteration over
proc.stdout in a try/finally block, ensuring proc is terminated or otherwise
cleaned up and waited on when iteration stops early as well as when fully
consumed. Preserve the existing yielded Path values and subprocess invocation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f0fed3f9-605e-48eb-a5c8-16d6c3399e8f

📥 Commits

Reviewing files that changed from the base of the PR and between 14dc548 and 3409447.

📒 Files selected for processing (3)
  • scripts/find_broken_smils.py
  • src/python/tools/archive_transcriber.py
  • tests/test_smil_generation.py

Comment thread scripts/find_broken_smils.py
Comment thread scripts/find_broken_smils.py Outdated
Comment thread scripts/find_broken_smils.py Outdated
Comment thread src/python/tools/archive_transcriber.py
Comment thread src/python/tools/archive_transcriber.py Outdated
yidakra and others added 3 commits July 13, 2026 18:40
Early versions of write_smil (before timestamped backups) wrote a static
"<name>.smil.bak". The scanner now lists it as the oldest backup so
--restore-bak recovers the pristine original for videos processed by
those runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GPhtp4PREZcRfm2WXL5NF7
Pre-flight check requested by the transcoder team: if a video's SMIL is
missing, unparseable, or has no <video> entries, skip the video entirely
(no GPU time spent) and append a manifest record with status="skipped",
error_type="invalid_smil" and the reason. This flags generator failures
while the trail is fresh instead of papering over them, and the video is
retried automatically once a valid SMIL appears.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GPhtp4PREZcRfm2WXL5NF7

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/python/tools/archive_transcriber.py`:
- Around line 655-658: Update the exception handlers around ET.parse in
smil_precheck (src/python/tools/archive_transcriber.py#L655-L658) and write_smil
(src/python/tools/archive_transcriber.py#L736-L741) to catch both ET.ParseError
and OSError, preserving each function’s existing graceful error or skip
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ea9e16e8-7fd2-4b61-85f6-5513479baa2b

📥 Commits

Reviewing files that changed from the base of the PR and between 3409447 and 985bc09.

📒 Files selected for processing (3)
  • scripts/find_broken_smils.py
  • src/python/tools/archive_transcriber.py
  • tests/test_smil_generation.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • scripts/find_broken_smils.py

Comment thread src/python/tools/archive_transcriber.py
@yidakra yidakra self-assigned this Jul 14, 2026
- catch OSError alongside ET.ParseError when parsing SMILs (precheck,
  write_smil, scanner classify) so an unreadable file can't crash a run
- write_smil returns False and leaves the SMIL (and no backup) untouched
  when no textstream was actually added
- preserve the original SMIL's permission bits across the atomic replace
- scanner: extract shared has_transcriber_fingerprint helper, survive
  copy2 failures in the restore loop (counted and reported)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GPhtp4PREZcRfm2WXL5NF7
@yidakra
yidakra merged commit 68ac72d into main Jul 15, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant