Skip to content

Multi-Engine Sync

Sublarr aligns subtitles to video through a multi-engine orchestrator rather than a single CLI call. Engines run in a configured order; the orchestrator early-exits on the first sane success and writes every attempt to an audit table.

EngineInstalled by defaultWhat it does
ffsubsync✅ bundled in the Docker imageSpeech-detection-based sync against the video’s audio track
alass❌ needs the alass binary on the hostReference-subtitle-based sync (uses another subtitle as the time base)

Check live status at Settings → Sync Engines or via:

Terminal window
curl -s -H "X-API-Key: $SUBLARR_API_KEY" \
http://<host>:5765/api/v1/sync/engines

The endpoint returns each engine’s name, available flag (is the binary installed?), configured timeout_s, and the global sanity_threshold_ms (default 45 000 ms = 45 s).

The SyncOrchestrator runs engines in order. An engine result is rejected and the next engine is tried when:

  1. is_available() returns False — the binary isn’t installed
  2. engine.sync() raises an exception
  3. The returned offset exceeds the sanity_threshold_ms — an obvious mis-sync result is treated as a failure
  4. The engine reports no success

What a sync changes, and when it is refused

Section titled “What a sync changes, and when it is refused”

A sync is a pure shift. Sublarr takes only the offset ffsubsync measured and applies it to the original subtitle; nothing else about the file changes, so long signs, karaoke and ED lines keep their full length. A sync is refused, and the subtitle left exactly as it was, when:

RefusalWhy
Shift above the thresholdThe measured shift exceeds sync_sanity_threshold_ms (default 45 s) — speech detection locked onto the wrong section, or the subtitle belongs to another episode or cut.
Runs past the video’s endThe subtitle’s cues end well past the end of the video (by more than the larger of 10 % and 2 minutes). Checked before ffsubsync runs; skipped when the video’s length is unknown.
Stretched timelineffsubsync decided the subtitle was timed for another frame rate and would stretch the whole timeline. This also applies to the manual sync engines.
No offset measuredffsubsync finished without reporting an offset. The shift is unknown, not 0 ms, so this is a failed sync, not a successful one.

When the automatic sync after a download refuses a subtitle for too large a shift or running past the video’s end, that subtitle most likely belongs to another episode. Sublarr then puts it on the blacklist for that episode only — it stays available for the episode it belongs to — moves the file to the subtitle trash, and searches the episode again at once, even if it was waiting out a long retry delay. The same file is caught again if it comes back later, even restored with its old date and size; a new subtitle saved for the episode is accepted. Manual uploads and subtitles you edited are never touched this way. Putting such a subtitle on the blacklist yourself blocks it for every episode.

Any other failed sync keeps the downloaded subtitle unsynced and is recorded in the audit trail below.

Every engine attempt (successful or not) writes a row to the sync_job_runs table:

ColumnMeaning
engineEngine name (ffsubsync, alass)
statusok, error, rejected (shift, length or stretch refusal), insanity_reject, skipped, failure
offset_msDetected shift in milliseconds (NULL when engine didn’t complete)
duration_msTime spent in that engine
subtitle_path / video_pathInputs
reasonShort error description (64 char max)
created_atTimestamp

Query recent runs:

Terminal window
curl -s -H "X-API-Key: $SUBLARR_API_KEY" \
"http://<host>:5765/api/v1/sync/runs?limit=50"

Download the static binary from alass releases and mount it into the container’s PATH, e.g. in docker-compose.yml:

services:
sublarr:
volumes:
- /path/to/alass:/usr/local/bin/alass:ro

After a container restart, GET /api/v1/sync/engines will show alass: available=true.

Successful sync fires the after_sync trigger of the post-processing pipeline — you can chain a Plex/Emby/Jellyfin refresh or a webhook notification right after the subtitle lands.

The orchestrator is deliberately engine-agnostic. Each engine lives in backend/services/sync_engines/ and exposes a common interface — is_available() and sync(). New engines append to the chain without any orchestrator changes.