A desktop batch queue for subtitle synchronization, driving four engines - ffsubsync, alass, lapse and autosubsync - and built for the job they are actually good at: taking a folder of video files and a folder of loosely-named subtitle files, working out which belongs to which, letting you check that before anything runs, and then syncing the lot.
Bazarr already handles subtitle sync for a Sonarr/Radarr-managed library, and does it well. It cannot help before a library exists - with a pile of loose files whose names do not line up and no episode records behind them. That gap is what this fills, so the interesting part here is the matching and review, not the syncing.
- Download the archive for your OS from GitHub Releases: Windows x64, Linux x64, macOS Apple Silicon (arm64) or macOS Intel (x64).
- Unpack it anywhere and run
SubSyncr(SubSyncr.exeon Windows,SubSyncr.appon macOS). Nothing is installed system-wide. - For an audio reference you also need
ffmpegandffprobe, which are not bundled. Get them from ffmpeg.org (orbrew install ffmpegon macOS), then either put them onPATHor point Settings at their folder. Syncing against a subtitle file needs neither.
macOS: the app is ad-hoc signed, without Apple Developer ID signing or notarization, so Gatekeeper can block the first launch. If you trust the download, try to open it once, then allow it under System Settings → Privacy & Security → Open Anyway. See Apple's instructions.
- Open Match folders, choose your video and subtitle folders, and click Scan.
- Review each proposed pair. Uncheck unwanted rows or double-click to correct a match.
- Queue the selected pairs and click Start queue. By default, results appear
beside their inputs as
.synced.srt(or the original supported text format). - Check the result and play a sample before using Overwrite. If backup creation fails, the app refuses to overwrite. Failed and low-quality results keep existing files intact.
Help > Copy diagnostics copies versions and platform details for a bug report, without media paths or job history. Help > Report a problem opens the issue form.
Example review using sample filenames. The app also has a dark theme:
- Review-first batch matching. Episode identifiers, then exact normalized names, then RapidFuzz similarity. Every proposal shows its method, its confidence, and why - including a warning when the runner-up was close.
- One video, many subtitles.
ep.en.srt,ep.es.srtandep.en.forced.srtall pair with the same video instead of competing for it, and all sync from a single audio extraction. - A real queue. Parallel by default, per-job cancellation, retry, crash-recoverable pending work, one GUI per data folder, and searchable history with offsets and scores.
- Four engines with a fallback chain. Pick ffsubsync, alass, lapse or autosubsync per job, and set in Settings which engines to retry with, in order, when one fails or returns a low-quality verdict. alass and lapse are standalone binaries, downloaded on first use and verified against a pinned SHA-256 before they ever run.
- Embedded references. Use a text subtitle track already inside the video as the reference instead of its audio - comparing two lists of timestamps rather than decoding the film - or a PGS (Blu-ray image) track's display timings with ffsubsync, picked by language when set to Auto. When the files you drop need an engine other than the chosen one, the job says so and a capable engine from the chain runs instead.
- The full ffsubsync option surface - VAD backend, piecewise
--split-penaltyalignment, segment sampling, reference stream selection, and the quality gate. - Speech reuse between sessions, so a language added later skips extraction.
- Fast mode for long references: sample segments instead of decoding in full.
- Subtitle-to-subtitle alignment. Align against a known-good subtitle instead of audio: no ffmpeg, and four episodes in well under a second.
- Drag and drop. Drop folders, or a full set of files, on the window to open a pre-filled review; drop a single video or a single subtitle and Add job opens with that file already filled in.
- Light and dark, following the OS or pinned in Settings, with icons and status colours that re-tint live rather than staying stuck at whatever the scheme was on launch.
- Safe file publication. Every engine writes to a temporary file first.
Requested backups must succeed before an in-place overwrite; an existing
.bakis never replaced. Conflicting queue destinations are rejected, and video-based names retain language/forced/SDH tags. - A log file with its level and retention set in Settings, and an Open log folder button there and on error dialogs. Sync workers forward their log records to the main process, so one file covers the whole run.
- A headless CLI over the same core, for scripted batches.
- alass publishes no macOS build, so SubSyncr cannot download it there; the app
says so and suggests
brew install alass. lapse covers Windows, Linux and macOS on both x64 and arm64. - PGS references come from a fork of ffsubsync carrying a fix that is pending upstream (smacke/ffsubsync#237). The pin goes away once a release includes it; until then SubSyncr ships as release downloads only, not on PyPI.
Needs Python 3.12 or 3.13 and uv, plus
ffmpeg/ffprobe as above.
uv sync --group dev
uv run subsyncr # GUI
uv run subsyncr doctor # report paths, ffmpeg status and versionsOn Windows the release executable is a windowed GUI app. Use the source installation below for terminal CLI commands. Linux and macOS executables support both modes.
uv run subsyncr match D:\media\Season01 # propose pairings only
uv run subsyncr match D:\media\Season01 --json
uv run subsyncr batch D:\media\Season01 --auto-match 90 --workers 2
uv run subsyncr batch D:\movies --fast # sample, do not read in full
uv run subsyncr batch D:\good-subs D:\bad-subs --subs-as-reference
uv run subsyncr sync episode.mkv episode.en.srt
uv run subsyncr history --limit 20match and batch take an optional second folder when the subtitles live
somewhere other than beside the video files.
core/ scanner, matcher, queue, subprocess engine, store, config (no Qt)
cli/ headless frontend over core (no Qt)
qt/ PySide6 frontend; bridge.py is the only thread seam
Nothing under core/ or cli/ may import PySide6, and tests/test_boundary.py
enforces that rather than trusting it. The CLI exists partly to keep the boundary
honest: anything it cannot reach is logic that leaked into the GUI.
Every engine runs in a worker process, whether it is a Python library loaded
inside the worker (ffsubsync, autosubsync) or an external binary the worker
launches (alass, lapse). The reason started with ffsubsync: it calls
logging.basicConfig() at import and reconfigures the root logger, and offers no
cancellation hook. A subprocess fixes both, makes the alignment score - which
ffsubsync only logs and never returns - cleanly recoverable, and returns every
byte the sync allocated the moment it exits (measured: ~104 MB peak, 0 MB after).
The same process boundary gives every engine one way to be cancelled and one
way to report.
The child speaks newline-delimited JSON on stdout and takes exclusive ownership of that descriptor, so nothing a dependency prints can corrupt the channel.
Extracting speech from the reference dominates the cost; aligning a parsed subtitle against an already-extracted reference is almost free. So one worker process handles a whole group - one video and every subtitle queued against it
- building the reference pipeline once with
make_reference_pipeand reusing it acrosstry_synccalls.
Measured on 24 jobs across 8 videos, cold cache:
| 1 worker | 2 workers | 4 workers | |
|---|---|---|---|
| one process per subtitle | 11.95 s | 7.03 s | 4.30 s |
| one process per video | 5.79 s | 3.04 s | 1.66 s |
Two effects compound: 8 process startups instead of 24 (~0.37 s each from source, more when frozen), and 8 audio extractions instead of 24. A sibling subtitle costs about 0.03 s instead of 0.45 s.
This also removes a scheduling problem rather than solving it. When siblings were separate jobs, one had to build the shared speech array while the others either stalled or duplicated the work. Owning the group makes that race impossible.
The on-disk speech cache still earns its place, but only across runs: sync English today and Spanish next week and the second run skips the audio pass. The arrays are tiny - 8 videos came to 12 KB.
Concurrency defaults to half the logical cores, capped at four. Raise or lower it in Settings; on a spinning disk fewer parallel reads may well be faster.
Light/dark is Qt's own QStyleHints.setColorScheme() on top of the Fusion
style, so Qt repaints every standard widget itself and tracks the OS setting
live when the choice is Automatic.
No stylesheet is set anywhere, deliberately: under Fusion a QSS on a container
makes its children resolve colours from the stylesheet defaults rather than the
palette, which inverts enabled and disabled text. tests/qt/test_widgets.py
and test_gui.py assert that no widget carries one.
That leaves the colours Qt cannot know about. Icons are Phosphor glyphs from
qtawesome, rendered at a fixed colour, so qt/theme.py's ThemeManager keeps a
weak registry of every icon owner and re-tints them from QPalette.WindowText
whenever the scheme changes - guarded with shiboken6.isValid, because a PySide
wrapper can outlive its C++ object. Semantic colours (qt/palette.py) come in
light/dark pairs and are resolved per paint. Qt's dark scheme also derives
AlternateBase from the Windows accent colour, which lands on a navy that reads
as a selected row, so row stripes are computed from Base instead.
Fast mode is --multi-segment-sync: ffsubsync samples a handful of short
segments spread across the reference rather than decoding it end to end. On a
40-minute reference that measured 2.57 s to 1.40 s for the same answer
(-2.96 s vs -2.97 s). It is a per-batch toggle in the match dialog, and --fast
on the CLI, because it pays off on long films and saves little on a short episode.
Two neighbouring ffsubsync options are deliberately not wired up:
--max-duration-secondstruncates the reference, and returned a confidently wrong-11.67 swhere the correct offset was-2.96 s.--skip-intro-outromoved the answer to+7.28 sin testing. It only avoids sampling ~90 seconds of a multi-hour file, so the risk is not worth the saving.
retvalstays0even when an alignment fails. Success needsretval == 0andsync_was_successfuland the output file existing.--skip-sync-on-low-qualitywrites the original subtitles to its output path. SubSyncr keeps that output temporary and discards it; those jobs report Low quality and leave the final destination untouched.
uv run pytest
uv run pytest -m "not slow" # skip tests that spawn real ffsubsync
uv run ruff check .
uv run ruff format --check .
uv run --group build basedpyrightTests need neither ffmpeg nor a display: the integration tests use subtitle files
as the reference, and the Qt tests run offscreen. Set SUBSYNCR_DATA_DIR to
keep config, database and cache out of the real user data directory.
PyInstaller cannot cross-compile, so run this on each target OS:
uv run --group build python build.pyThe build also verifies a real subtitle-to-subtitle sync through the packaged
worker, including the macOS .app executable. It needs no ffmpeg or network.
The onedir bundle lands in dist/SubSyncr. The executable wears three hats and
dispatches on its first argument: --sync-worker for the child process, a
subcommand for the CLI, and anything else for the GUI. ffmpeg is deliberately not
bundled. On macOS the same folder is also wrapped as dist/SubSyncr.app and
ad-hoc signed by PyInstaller. Signing or signature verification failures stop the
build. Release CI also verifies the extracted ZIP's signature and runs its worker.
The window and executable icons are rendered from the mark embedded in
qt/icons.py, so nothing has to be found on disk at runtime. After changing the
artwork, regenerate the .ico and .icns PyInstaller needs at build time with
uv run python packaging/make_icon.py.
subsyncr doctor prints the exact paths. Config is JSON, the queue and history
are SQLite, and cached speech arrays and downloaded engine binaries live under
the platform cache directory. The log file sits in the platform log directory
(on Windows, %LOCALAPPDATA%\SubSyncr\SubSyncr\Logs\subsyncr.log) and
rotates at midnight. Set SUBSYNCR_DATA_DIR to move all of it.
See the changelog, third-party notices, and the release checklist. Release downloads include a SHA-256 manifest. The build collects dependency versions and license files into the bundle.
On Linux/macOS, creating a new output or backup safely requires a filesystem with hard-link support; unsupported filesystems fail without replacing existing files. Use a local data folder for the application queue and settings.

