SubtitleExtractor extracts hardcoded (burned-in) subtitles and can drop the
result next to your source video as a sidecar .srt (or .ass / .vtt)
file. Media servers (Plex, Jellyfin) then index that sidecar as a selectable
subtitle track — no re-mux, no library re-org.
The integrations glue that flow to the rest of your media stack. Every one of them is opt-in and off by default; nothing here fires until you configure it.
Integrations point one of two ways:
- Inbound — an external event triggers an extraction. Something imports a new file, SubtitleExtractor enqueues a sidecar job for it.
- Outbound — after a sidecar job succeeds, SubtitleExtractor pokes a media server so it rescans and picks up the freshly-written subtitle file.
Which direction makes sense depends on what the app actually manages:
| App | Direction | Why |
|---|---|---|
| Sonarr | Inbound | Manages TV video — tell us when it imports an episode so we extract subs. |
| Radarr | Inbound | Manages movie video — tell us when it imports a movie file. |
| Plex | Outbound refresh | Indexes subtitle sidecars — refresh it so the new .srt appears. |
| Jellyfin | Outbound refresh | Same as Plex. |
| Bazarr | Outbound refresh | Manages subtitles — nudge it to re-scan disk for the sidecar. |
Note on Sonarr/Radarr as outbound targets: they manage video, not subtitle sidecars, so refreshing them does nothing useful for subtitles. The connector types exist for completeness (see the caveat below), but the intended use of Sonarr/Radarr is inbound — as triggers.
| Integration | Direction | What to configure | Requires bind-mount? |
|---|---|---|---|
| Sidecar files | (output) | Per-job sidecar + sourcePath params (set for you by the watch folder and *arr endpoint) |
Worker must be able to write the source dir |
| Watch folder | Inbound | Admin › Settings: folder, owner, interval, source, language | Yes — API reads, worker writes |
| *arr import endpoint | Inbound | Sonarr/Radarr webhook + a personal API token | Yes — API must read the media path |
| Completion webhook | Outbound | Admin › Settings: URL + optional signing secret | No |
| Plex / Jellyfin / Bazarr refresh | Outbound | Admin › Integrations: type, base URL, API key (+ section id for Plex) | No |
The sidecar is the shared output that the rest of the integrations orbit. When a
job carries params.sidecar truthy and a params.sourcePath (an absolute path
the worker can see), the worker writes the produced subtitle file(s) next to
the source video after the normal result upload has already succeeded.
Naming (worker/subextractor/sidecar.py):
<video-basename>.<iso639-1>[.forced][.sdh].<ext>
<iso639-1>— 2-letter language code derived from the job's detected or configured language (3-letter and BCP-47 likefr-FRare accepted and mapped; an unknown/empty language drops the segment so players treat it as "undetermined")..forced— added when the job marks a forced-narrative track..sdh— added for hard-of-hearing (thehialias is also accepted).<ext>— one of the supported formats:srt,ass,vtt.
Example: Movie (2024).en.srt, or Show S01E02.fr.forced.srt.
Behavior & guarantees:
- The file is written to the same directory as
sourcePath. That directory must exist and be writable by the worker process, otherwise the sidecar is skipped (warn + no-op). - The name is deterministic from (video, language, flags). On a re-run the
worker overwrites its own sidecar rather than piling up
.1.srtvariants — a re-run refreshes the file. - The sidecar is a bonus artifact: writing it can never fail the job. Any error is caught and logged; the downloadable result in object storage is unaffected.
An admin-configured background scanner that polls a host folder and enqueues a
sidecar extraction for every new video file it finds
(api/internal/httpapi/watch.go).
Setup — Admin › Settings, "Watch folder":
- Enable it (off by default).
- Folder — absolute path inside the API container to scan.
- Owner — the email of the user who will own the enqueued jobs (their storage quota applies).
- Interval — seconds between scans. Floored to 60s if set below 5s, so a misconfiguration can't hammer the disk.
- Source —
ocroraudio(passed through to the job). - Language — optional; sets the sidecar language segment.
How it behaves:
- Only regular files with an allowed video extension are considered; entries are scanned in a deterministic order.
- Dedup is by
(path, mtime): a file is ingested once, and only re-ingested if its modification time changes. The reservation is race-safe (a unique index), so two scans — or two API instances — never double-enqueue the same version. - A per-tick cap of 20 files bounds how many new files one scan enqueues; the rest are picked up on the next scan. A folder suddenly filled with hundreds of videos can't flood the queue in one pass.
- Storage quotas are respected: if enabled and ingesting a file would push the owner over their limit, the ingest is rolled back and retried once space frees.
- Settings are re-read every tick, so enable/disable, folder, owner and interval changes take effect without a restart.
Bind-mount requirement. The folder must be visible to both the API (to read and enqueue) and the worker (to write the sidecar back). Mount the same host path into each:
# docker-compose.yml — API side (reads the folder)
services:
api:
volumes:
- blobs:/data/blobs
- /srv/media:/media:ro # API only needs to readThe worker runs on the host (./worker/run-macos.sh) or via the NVIDIA overlay;
it must see the same absolute path (/media/...) with write access so it
can drop the sidecar next to the source. If the worker runs in a container, mount
/srv/media:/media (read-write) there too.
A single admin-configured URL that receives a JSON POST every time a job reaches
a terminal state (api/internal/webhook/webhook.go).
Setup — Admin › Settings, "Completion webhook":
- URL — where to POST. Empty = disabled.
- Signing secret (optional) — keys an HMAC-SHA256 signature on each delivery. The secret is write-only: it is redacted from settings reads (you see a "set" indicator, not the value). Leaving it blank on save keeps the stored secret; there is a distinct "clear" action to remove it.
Delivery: fire-and-forget in the background with up to 3 attempts and
exponential backoff (1s → 2s) on transport errors or non-2xx responses. A failing
webhook is logged and never blocks or fails the job. Each request carries
Content-Type: application/json and User-Agent: SubtitleExtractor-Webhook/1.
Payload shape:
{
"jobId": "…",
"status": "succeeded", // "succeeded" | "failed"
"filename": "Movie (2024).mkv",
"source": "ocr", // "ocr" | "audio"; "" if unset
"language": "en", // "" if unset
"resultKinds": ["srt", "vtt"],
"error": "…" // present only when status == "failed"
}Signature. When a secret is set, each request includes:
X-Subtitle-Signature: sha256=<hex HMAC-SHA256 of the raw body>
Verify by computing HMAC-SHA256(secret, rawBody) and comparing the hex digest
against the value after the sha256= prefix.
Sonarr and Radarr call this when they import a file, and SubtitleExtractor
enqueues a sidecar job for each imported video
(api/internal/httpapi/handlers_integrations.go).
POST /api/integrations/arr/import
Authorization: Bearer <personal-api-token>
Content-Type: application/json
Authentication. The endpoint is authenticated with a personal API token
(create one under your profile → API tokens). The token identifies the owner
of the jobs it creates (their storage quota applies). *arr apps send no Origin
header, so bearer-authenticated requests are exempted from the same-origin CSRF
guard.
What it does with the payload:
- An
eventType: "Test"(the app's connectivity check) is acknowledged with200 {"ok": true}and creates no job. - Import/upgrade events carry absolute file paths — Sonarr uses
episodeFile.pathand/orepisodeFiles[].path; Radarr usesmovieFile.path. For each path the API must be able tostat/open it (same bind-mount requirement as the watch folder — the media path has to be readable inside the API container). Each readable path is enqueued as a sidecar job identical to a watch-folder job. - Unreadable paths, directories, and quota-exceeded ingests are skipped with a logged reason rather than failing the whole request.
Response:
{
"ok": true,
"enqueued": ["<jobId>", "…"],
"skipped": [
{ "path": "/media/…", "reason": "path not readable by the API (check the bind-mount)" }
]
}- Settings → Connect → + → Webhook.
- Triggers: enable On Import and On Upgrade.
- URL:
https://<your-host>/api/integrations/arr/import - Method:
POST. - Add a header:
Authorization: Bearer <your-personal-api-token>(in Sonarr's Webhook connection, add it under the custom headers / advanced settings for the connection). - Test — it should return OK (the
Testevent is acknowledged without a job).
Identical, with Radarr's own menus:
- Settings → Connect → + → Webhook.
- Triggers: On Import and On Upgrade.
- URL:
https://<your-host>/api/integrations/arr/import, MethodPOST. - Header:
Authorization: Bearer <your-personal-api-token>. - Test to confirm connectivity.
The media path in the payload (.../Movie (2024)/Movie (2024).mkv) must resolve
to the same file inside the API container. If Radarr/Sonarr see /media/...,
mount the same host path at /media in the API service (read is enough for the
API; the worker still needs write to drop the sidecar).
Managed in Admin › Integrations (backed by the integrations table). After a
sidecar job succeeds, SubtitleExtractor fans out a best-effort "library
changed" hint to every enabled connector so the new sidecar gets indexed
(api/internal/integrations/notify.go). The fan-out runs asynchronously with its
own timeout and never blocks or affects the job.
Adding a connector:
- Type — one of
plex,jellyfin,sonarr,radarr,bazarr. - Base URL — the server's base URL (e.g.
http://plex:32400). - API key — the server's token/key (see per-app notes). Stored write-only:
it is redacted on read (an
apiKeySetflag is returned instead), and leaving it blank on update keeps the stored value. - Section id — Plex only (the library section to scan).
- Enabled — only enabled connectors are notified.
- Call:
PUT /library/sections/{id}/refreshwith headerX-Plex-Token: <token>. When a section id is set, the scan is scoped to the changed directory via?path=<dir>. With no section id, it refreshes all sections (/library/sections/all/refresh). - Token (
X-Plex-Token): the quickest way to obtain it is to open any item in the Plex web app, click ⋯ → Get Info → View XML, and copy theX-Plex-Tokenvalue from the resulting URL. - Section id: browse to the library in Plex web; the URL contains
.../section/<id>, or queryGET /library/sections?X-Plex-Token=...and read thekeyof the section you want.
- Call:
POST /Library/Refreshwith headerX-Emby-Token: <api-key>. This is a full library scan — Jellyfin exposes no documented path-scoped trigger via this endpoint. - API key: Dashboard → Advanced → API Keys → + to create one, then use it as the connector's API key.
- Call: best-effort disk re-scan via Bazarr's API with header
X-API-KEY: <key>. - Important — SubtitleExtractor is not a Bazarr provider. Bazarr's subtitle
providers are bundled modules; a third-party service cannot register as one. The
integration here is sidecar + refresh only: SubtitleExtractor writes the
.srtnext to the video and nudges Bazarr to re-scan disk so it notices it.
For completeness the connector types accept sonarr/radarr and issue a command
via POST /api/v3/command with header X-Api-Key (RefreshSeries for Sonarr;
RefreshMonitoredDownloads for Radarr, since a per-movie rescan needs a movie id
not available at this point). As noted above, these apps don't index subtitle
sidecars, so this is rarely useful — prefer using them inbound.
The exact refresh call for each connector is built from each app's documented API but has not been validated against live servers in this codebase. Treat every one as "reasonable + best-effort" and verify against your own instance — in particular Radarr's global-refresh command (there is no single documented "rescan every movie file" command) and Bazarr's endpoint (its API is sparsely documented and the exact route/verb can differ by version).
- Connector API keys are admin-only and write-only. Only admins manage
connectors; the stored key is never echoed back (reads return an
apiKeySetboolean), and an empty key on update keeps the existing secret. The same write-only model applies to the completion-webhook signing secret. - *The inbound arr endpoint needs a personal API token. The token owns the jobs it creates; there is no unauthenticated ingest path.
- Bind-mounts are required for the file-driven inbound integrations (watch folder and *arr import): the API must read the media path, and the worker must write the sidecar back into the source directory. Mount the same host path into both.
- Everything is opt-in and off by default — the watch folder, the completion webhook, and every connector must be explicitly configured before it does anything.