Subtitle Sync I Can Trust: Checking Bazarr's Arabic Shifts Against English and the Audio
A Bazarr match score says nothing about timing. How I shift Arabic subtitles only when English text and the audio agree, the eight tests behind it, and a scoring fault it exposed.
HomeLab, Media stack. Updated . 5 min read.
One Arabic subtitle in my library needed a 31.85-second shift, and nothing in Bazarr's score could have warned me. That's not a bug. The score measures how well the subtitle's metadata matches the video file, and it never looks at timing. If you want subtitles you can trust, you need a second opinion that does.
TL;DR: my library runs an Arabic + English profile on all 27 series and 31 movies. Bazarr's own automatic sync is off. Instead, a custom post-processing script shifts an Arabic file only when an English reference and the audio both agree on the correction, in every third of the video, within half a second. Anything less, and the Arabic file stays exactly as it was. Eight unit tests pin that behaviour, and building it exposed a scoring fault in Bazarr 1.6.0 that costs one provider almost 50 points out of 360.
Why Arabic needs a guard
Most Arabic candidates for my library were timed against a different version of the video than the one I have. When I checked the 21 episodes still missing Arabic, every one had candidates, and the best displayed scores were 86-87%. My episode cutoff is 90%, so all of them were rejected. Lowering the cutoff would fill the gaps and bring timing errors with them.
So I let one test episode per series through by hand and synced them. The offsets tell the story:
| Sample | Offset applied | Cues |
|---|---|---|
| A | -31.85 s | 298 |
| B | +10.07 s | 558 |
| C | +0.70 s | 712 |
All three sync jobs reported a frame-rate scale of 1.00, and Jellyfin picked up each file. What that table can't show is whether one constant shift was right for the whole episode. Sync tools pick the best offset they can find. They don't tell you when the file was timed for a different edit and no single offset is right.
The design
The script, english_reference.py, runs as Bazarr's custom post-processing command. It uses only the libraries Bazarr already bundles (numpy, pysubs2, ffsubsync), so it doesn't patch Bazarr or add a service:
python3 -B /config/english-reference/english_reference.py --series {{series_id}} --item {{episode_id}} --lang {{subtitles_language_code2}} --score {{score}} --downloaded {{subtitles}} --applyIt needs two references before it moves anything.
English text. Full embedded English from the same video comes first, excluding forced and commentary tracks. Otherwise an external English SRT, but only with a recorded score of at least 99%. The audio. The video needs a tagged English dialogue track, and commentary doesn't count.
Every subtitle becomes a speech mask at 10 ms resolution, and ffsubsync's FFT aligner finds the offset between two masks. The check runs over the whole file and then over each third on its own. If the thirds disagree by more than 0.5 s, the file was timed for a different cut and the script stops. The decision itself is short and fails closed at every step:
def decision(english, arabic, audio):
en_audio = offsets(audio, english)
if abs(en_audio) > AGREEMENT:
raise Skip("English is not already aligned to the audio")
change = offsets(english, arabic)
if abs(change) < MIN_CHANGE:
raise Skip("Arabic is already aligned within 0.5 seconds")
ar_audio = offsets(audio, arabic)
if abs(change - ar_audio) > AGREEMENT:
raise Skip("audio and English disagree about the Arabic correction")
...After that, the shifted Arabic has to overlap English better than before, and it has to overlap the audio better than before. Two independent improvements, or no change.
Some other limits:
- Each subtitle needs at least 80 cues and has to cover at least 60% of the video.
- The offset search stops at 60 seconds, and each run has a 10-minute limit.
- Only external
.ar.srtand.ar.hi.srtfiles can change. English is never touched, and timing is never stretched. - Shifts under 0.5 s are left alone.
Writing the file is its own careful step. Before a replacement, the script saves the exact original bytes under backups/: the directory name is the SHA-256 of the Arabic path, and the file name is the SHA-256 of its contents. It records the file's device, inode, size, and mtime before the check and compares them again right before an atomic replace, keeping owner and mode. If someone edited the file in the meantime, the script skips. If the backup fails, the original stays.
Eight tests
They run inside the live container, against the same bundled libraries:
docker exec -u 99:100 -w /config/english-reference bazarr python3 -B -m unittest -v test_english_referenceThe timing tests build a synthetic 900-second voice track from a seeded random generator:
- A known 1.2 s delay comes back with the right sign and units.
- Arabic that is already aligned is not rewritten.
- Bad English doesn't damage good Arabic.
- A file whose second half has a different offset is rejected ("timing differs").
- A sparse or forced reference is rejected ("not enough speech").
The file tests cover the write path:
- The backup is exact, and a rerun doesn't overwrite it.
- A change made during the check is preserved.
- A backup failure leaves the original untouched.
Test 3 matters most. The natural bug in a tool like this is trusting the reference. If English is off, a naive aligner happily "fixes" perfect Arabic to match it.
What the live runs did
English collection went on the same day and filled 33 episode gaps and the one movie gap. The final API check found English on all 448 episodes and 31 movies, embedded tracks included.
The dry runs on the samples were the interesting part. Sample B was skipped because its Arabic already aligned. Sample A, the one that needed -31.85 s, fails the guard: timing differs across the video. No single constant shift is right for that file, whatever the sync job reported.
The first live change shifted one episode by +0.76 s. Its backup matched the original byte for byte, and the text was unchanged. A repeated dry run skipped it, because now it aligned. No command errors appeared.
The scoring fault
One episode stayed stuck, and chasing it showed why. The best English candidate came from Subf2m and should have been a near-perfect match. Bazarr's download path scores it 311/360, 86.39%.
The cause is in Bazarr 1.6.0 itself. Two download paths use the matches already stored on a subtitle (s.matches) and never call get_matches(). Subf2m computes part of its matches inside get_matches(), so those points never make it into the score. A read-only reproduction with the real video file name and the subtitle's own source label scores 311/360 before full matching and 359/360 after, 86.39% versus 99.72%.
The knock-on effect: instead, Bazarr's automatic pick for that episode was a SubDL English file at 93.89%. My guard wants 99% from an external reference, so it rejected that one. The Arabic for that episode stays unchanged.
That's the right failure. The guard lost an opportunity, not a file. I didn't patch Bazarr, and I didn't lower the cutoff to hide the fault. The note next to the config says to rerun the eight tests and that reproduction after every Bazarr update. What it taught me: a subtitle score belongs to the code path that computed it. Any threshold I build on top of it inherits that path's bugs.
Questions
- Does a high Bazarr score mean the subtitle is in sync?
- No. The score measures how well the subtitle's metadata matches your video file. It says nothing about timing. A 90% match can still be seconds off, and the score itself depends on which code path computed it.
- Why not use Bazarr's automatic sync for every subtitle?
- Automatic sync picks the best shift it can find and applies it. When the subtitle was timed for a different edit, one constant shift is wrong for part of the file. I turned it off and shift only when two independent references agree.
- How can I check a subtitle shift without watching the episode?
- Align the new subtitle against a trusted English track and against the audio, over the whole file and over each third. Apply the shift only when all the offsets agree within half a second and both comparisons improve.
- What is the Bazarr get_matches scoring issue with Subf2m?
- In Bazarr 1.6.0, two download paths use the matches already stored on a subtitle without calling get_matches(). Subf2m adds its matches inside get_matches(), so those points can be missing. One reproduction scored 311/360 instead of 359/360.