English | 한국어
Repairs subtitles whose timing drifts in pieces: one offset before the opening and another after it, a commercial break, or a different cut of the video. A single global shift cannot fix those. subsync-repair aligns the subtitle with alass to a reference in another language, checks that the correction is believable, and only then writes it, after backing up the original.
It is made for a media library whose subtitles sit next to the videos (Jellyfin, Plex, Bazarr), in any of 20 languages.
flowchart LR
B[Bazarr post-processing] --> C[subsync-repair.py]
U[you] --> C
X[browser extension] -->|HTTP on 127.0.0.1| S[subsync-repair.py serve]
C --> R[check and repair]
S --> R
R --> A[alass]
R --> F[ffmpeg, ffprobe]
R -.->|sibling references only| FS[ffsubsync]
- Find a reference in another language than the subtitle: a full dialogue text track inside the video, or else a subtitle file next to the video. A file next to the video can drift itself, so it is used only after ffsubsync finds it in sync with the audio.
- Align the subtitle to the reference with alass and compare every cue's start time before and after. The shifts form pieces: runs of cues that moved by about the same amount.
- Judge the correction. No cue moving more than 0.6 s means the subtitle is in sync. A correction that is too large or too erratic is refused (see below).
- Write it: in place after a backup (
--run), or to a new file (--json --out, or the server'sfixjob).
Supported files are .srt, .smi (SAMI) and .ass / .ssa, named <video>.<language>[.<flag>...].<ext> next to
their video, such as Movie.ko.srt or Show S01E01.en.sdh.srt. Forced, signs, songs and commentary tracks are left
alone.
- A
.smiis written as an.srtand the.smigoes to the backup folder. Each other language a multi-language.smiholds becomes its own<video>.<language>.srt; when one of them cannot be made, the.smistays. - An
.assis repaired by shifting its dialogue events. When an.srtin the same language shares its timing, the.srtis repaired and the.assis shifted along with it.
A track inside the video serves as the reference when it has at least 50 cues. A file next to the video serves when it runs no more than 60 s past the end of the video and ffsubsync finds an offset of at most 1 s, a scale within 0.1 % and a match with the audio. Without a usable reference the subtitle is skipped.
The correction is then checked before anything is written (the constants are in subsync_repair/engine/policy.py):
| Check | Refused when |
|---|---|
| Too few cues | the subtitle has fewer than 20 cues |
| Text | alass changed the text of a cue (whitespace aside) |
| Size | some cue would move more than 180 s |
| Many pieces | more than 6 pieces and some cue would move more than 5 s |
| Unstable start | 3 or more pieces start within 30 s of the first cue, unless they are steps of at most 1 s in one direction (a frame rate drift) |
| Another episode or cut | some cue would move more than 30 s, no piece holds half of the cues and the shift does not grow steadily (reported as mismatch) |
| Cue order | the fix adds cues out of order |
| Time zero | the fix pushes more than 3 more cues to 0:00 |
| Stacked cues | the fix adds 2 or more cues that share a start time with an earlier cue |
| End of the video | the corrected subtitle runs more than 30 s past the end of the video |
What the original already had (cues out of order, at 0:00 or stacked) is not blamed on the fix. Writes are atomic, a file that changed while it was checked is skipped, and backups keep the full path and are never overwritten.
- Python 3.10 or newer. Only the standard library is used.
ffmpegandffprobe.alass-clifrom alass:cargo install alass-cli, or a release binary thattools.alasspoints to.- Optional: a Python interpreter with ffsubsync installed. Without it, subtitle files next to the video are never used as references, only tracks inside the video.
git clone https://github.com/wonjoonSeol/subsync-repair.git
cd subsync-repair
cp config.example.json config.json
Edit config.json: at least target_language and libraries. The tool reads and writes only inside the library
folders.
| Key | Default | Meaning |
|---|---|---|
target_language |
required | Language of the subtitles a library scan repairs: en, ja, ko, zh, es, fr, de, it, pt, ru, vi, th, id, ar, hi, tr, pl, nl, sv or uk |
libraries |
required | Absolute folder paths |
skip_dirs |
none | Folder names (any letter case) a library scan leaves out |
embedded_reference_languages |
["en", "*"] |
Languages of a track inside the video that may be the reference, best first; "*" is any other language |
reference_languages |
["en", "*"] |
The same for a subtitle file next to the video |
tools.alass, tools.ffmpeg, tools.ffprobe |
alass-cli, ffmpeg, ffprobe |
A program on PATH, or a path (a relative path starts at the folder of config.json) |
tools.ffsubsync_python |
none | An interpreter that can import ffsubsync |
tools.ffsubsync_libs |
none | Folders added to that interpreter's import path |
tools.data_dir |
see below | Where backups and state are kept |
sync.port |
8778 |
Port of the sync server |
sync.extension_ids |
none | Browser extensions the sync server answers: Chrome extension ids, Firefox extension UUIDs, or "*" for any |
A subtitle is never its own reference: its own language is skipped in both reference lists.
The data folder holds the backups (backup/repair-<time>/), the cache of earlier verdicts, the lock that keeps two
runs apart, the hook queue and the server's record of its copies. It defaults to %PROGRAMDATA%\subsync-repair on
Windows, ~/Library/Application Support/subsync-repair on macOS and $XDG_DATA_HOME/subsync-repair (or
~/.local/share/subsync-repair) elsewhere. In a container, set tools.data_dir to a folder on a mounted volume. On
Windows a new data folder lets every local user modify what it holds, so that a service account and you can share it,
and an existing one owned by another account that is no administrator is refused.
python subsync-repair.py dry run over the libraries (subtitles in target_language)
python subsync-repair.py --run repair them in place; every original is backed up first
python subsync-repair.py [--run] SUBTITLE ... only these files, in any language, one line of output each
python subsync-repair.py --json SUBTITLE check one subtitle and print the result as JSON; nothing is written
python subsync-repair.py --json --out NEW.srt SUBTITLE the same, and write the corrected subtitle to a new file
python subsync-repair.py --no-cache ... check again what an earlier run already judged
python subsync-repair.py serve [--port PORT] the sync server for a browser extension
python -m subsync_repair does the same from the repository folder.
Every result has a status: ok, would-fix, fixed, skip, mismatch or error. A library scan prints only what
needs attention and a summary line; a file named on the command line always gets one line,
<status>: <path> <note>. The exit status is 1 when any check ended in error.
A verdict is cached until the subtitle, its video or the other subtitles of that video change, or the reference
settings, the tools or this program do. An error is tried again on the next run, unless it is a failed write that
could not be undone.
In Bazarr, under Settings > Subtitles > Processing > Custom Post-Processing, set the command to:
python /path/to/subsync-repair.py --hook --log /path/to/subsync-repair.log "{{subtitles}}"
--hook implies --run: each downloaded subtitle is checked and repaired in place, and the outcome is appended to the
log. When another run holds the lock for more than 30 s, the file is queued for that run instead, so that Bazarr's
download job is not held up. The hook never converts a .smi; a library scan or a manual run does. Add
--config /path/to/config.json when the settings are not next to subsync-repair.py.
python subsync-repair.py serve starts a small HTTP API on 127.0.0.1 for a browser extension's "Fix sync" button.
| Request | Answer |
|---|---|
GET /health |
{"ok", "alass", "ffmpeg", "ffprobe", "worker_alive"} |
POST /jobs with {"action": "measure" or "fix", "path": "<absolute subtitle path>"} |
202 {"id"} |
GET /jobs/<id> |
{"state": "queued", "running" or "done", "action", "path", "result"} |
fix writes the corrected subtitle next to the original as <subtitle name>.synced.srt and never touches the original.
It may replace only a copy it wrote itself. Jobs run one at a time, at most 50 wait or run, and more are refused with
429.
The server listens on 127.0.0.1 only and answers a loopback Host only. Requests must carry no Origin (a local
program) or the origin of an extension listed in sync.extension_ids. It has no password: anything running on this
machine can call it.
python -m unittest discover -s tests
Tests that need ffmpeg or alass-cli skip themselves when the program is missing. GitHub Actions runs the suite on
Windows, Linux and macOS with Python 3.10 and 3.13.
| Folder | What it holds |
|---|---|
subsync_repair/ |
The command (cli.py), the settings (config.py), the output lines (report.py) and the sync server (server.py) |
subsync_repair/engine/ |
Finding a reference, judging alass's correction (policy.py), writing it, and the verdict cache |
subsync_repair/formats/ |
SRT, ASS/SSA and SAMI, subtitle file names, languages and text decoding |
subsync_repair/external/ |
ffmpeg and ffprobe, alass, and ffsubsync (ffsubsync_check.py runs in ffsubsync's own interpreter) |
subsync_repair/storage/ |
Crash-safe writes and the lock, the data folder, the hook queue and the library folders |
tests/ |
The test suite; support.py holds the shared fakes and fixtures |
Like this? I make other small open source tools too: Android apps, a Claude chat game, Claude add-ons and more. See them all at wonjoonseol.github.io.