Skip to content

Latest commit

 

History

History
328 lines (256 loc) · 14.3 KB

File metadata and controls

328 lines (256 loc) · 14.3 KB

Integrations

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.

The two directions

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.

Summary table

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

Sidecar files

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 like fr-FR are 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 (the hi alias 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.srt variants — 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.

Watch folder (inbound)

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.
  • Sourceocr or audio (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 read

The 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.


Completion webhook (outbound)

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.


Inbound *arr import endpoint

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 with 200 {"ok": true} and creates no job.
  • Import/upgrade events carry absolute file paths — Sonarr uses episodeFile.path and/or episodeFiles[].path; Radarr uses movieFile.path. For each path the API must be able to stat/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)" }
  ]
}

Sonarr setup

  1. Settings → Connect → + → Webhook.
  2. Triggers: enable On Import and On Upgrade.
  3. URL: https://<your-host>/api/integrations/arr/import
  4. Method: POST.
  5. 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).
  6. Test — it should return OK (the Test event is acknowledged without a job).

Radarr setup

Identical, with Radarr's own menus:

  1. Settings → Connect → + → Webhook.
  2. Triggers: On Import and On Upgrade.
  3. URL: https://<your-host>/api/integrations/arr/import, Method POST.
  4. Header: Authorization: Bearer <your-personal-api-token>.
  5. 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).


Outbound refresh connectors

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 apiKeySet flag 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.

Plex

  • Call: PUT /library/sections/{id}/refresh with header X-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 the X-Plex-Token value from the resulting URL.
  • Section id: browse to the library in Plex web; the URL contains .../section/<id>, or query GET /library/sections?X-Plex-Token=... and read the key of the section you want.

Jellyfin

  • Call: POST /Library/Refresh with header X-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.

Bazarr

  • 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 .srt next to the video and nudges Bazarr to re-scan disk so it notices it.

Sonarr / Radarr (as connectors)

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.

⚠️ Validate outbound calls against your own instances

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).


Security & operations notes

  • Connector API keys are admin-only and write-only. Only admins manage connectors; the stored key is never echoed back (reads return an apiKeySet boolean), 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.