Remote workers and hosted providers¶
By default WhisperSubs transcribes on the Jellyfin server itself, one job at a time. The worker pool lets you spread that work across other machines, or hand it to a hosted API.
What the worker pool is¶
WhisperSubs always extracts the audio locally with FFmpeg. A worker is any endpoint that implements the OpenAI audio API, POST /v1/audio/transcriptions and POST /v1/audio/translations, and that the plugin can send the audio to over HTTP. A CrispASR server also works, with its own dialect.
The pool is off by default. With no workers configured, the plugin behaves exactly as it always has: the host's own whisper, one job at a time. Adding workers is additive.
Two rules govern dispatch:
- Free before paid. Each worker has a cost weight.
0means free (a local box, your own hardware) and is always preferred. A worker with a weight above0is only used to burst when every free worker is busy. - Concurrency is per worker. whisper.cpp runs one transcription per GPU context, so a single-GPU worker should stay at max concurrency 1. To do more in parallel, add more workers.
You want the pool when you have a second machine with a real GPU, when the Jellyfin host is a NAS too weak to transcribe (turn Also use this server as a worker off and offload entirely), or when you want a backlog to clear in parallel across several boxes.
Note
The scheduled task runs in two phases and both dispatch across the pool. It first drains queued requests, then sweeps the library. Each phase leases a worker slot per item and starts the next item without waiting for the previous one to finish, so up to the pool's total concurrency runs at once. Manual Generate and Generate All feed the same dispatcher. Extra workers speed up all of it, including an overnight sweep.
Running the worker container¶
The repo ships a ready-to-run worker image: whisper.cpp's whisper-server built with Vulkan (so it accelerates on Intel and AMD GPUs), behind a small Python adapter that speaks the exact HTTP dialect the plugin expects.
The adapter exists because whisper-server is not fully OpenAI-compatible on its own. It serves a single inference route, selects translation with a translate=true form field rather than a separate path, and defaults language to en, which would break auto-detection. The adapter maps /v1/audio/translations onto the single route by injecting that field into the streamed body, and starts the backend with --language auto.
One-time GPU setup¶
The container runs as a non-root user, so it must join the host group that owns /dev/dri/renderD128:
Put that number under group_add: in worker/docker-compose.example.yml. It ships as "993", which is a common default and is not guaranteed to match your host. Use the numeric GID, not the name render — the name resolves inside the image but its GID will not match the host's device owner, so the GPU open fails silently and whisper falls back to very slow CPU.
Start it¶
docker compose -f worker/docker-compose.example.yml up -d
docker logs -f whisper-subs-worker # first boot downloads the model
The compose file publishes 9010:8080 and pins a semver image tag:
services:
whisper-subs-worker:
image: drumsergio/whisper-subs-worker:0.1.6
ports:
- "9010:8080"
environment:
WHISPER_MODEL: large-v3-turbo-q5_0
volumes:
- whisper-models:/models
The model is served per worker: whisper-server loads exactly one model, fixed at startup by WHISPER_MODEL. The plugin's per-worker Model field is sent but ignored by this backend, so choose the model here.
Settings worth knowing:
| Variable | Default | What it does |
|---|---|---|
WHISPER_MODEL |
large-v3-turbo-q5_0 |
GGML model, downloaded into /models on first run |
API_KEY |
(empty) | When set, requires Authorization: Bearer <key> on the POST routes |
WHISPER_MAX_LEN |
(empty = unlimited) | Max characters per cue (-ml), paired with -sow. The worker-side twin of the plugin's Maximum subtitle line length — a worker segments its own output, so set it in both places |
WHISPER_THREADS |
(whisper default) | CPU threads; set to your core count on CPU-only workers |
WHISPER_VAD |
true |
Native Silero VAD, so cue starts snap to real speech onset |
WHISPER_IDLE_MINUTES |
0 |
Frees the model after this many minutes without a request and loads it again on the next one. 0 keeps it loaded from boot. 10 is a good value on an iGPU or other shared-memory host, where the model's GPU buffers are host RAM (large-v3 about 3 GB). The cost is a cold load on the first request after an unload, about 6 s for large-v3 on an Intel UHD 770. Max 1440. At boot the worker loads the model once and frees it, so a missing model or a bad WHISPER_EXTRA_ARGS flag fails at start-up. While unloaded, /health answers 200 {"status":"idle"}, or 503 {"status":"model failed to load"} after a load on demand failed |
Warning
With no API_KEY the worker is an unauthenticated transcription endpoint on 0.0.0.0 — anyone who can reach the port can drive your GPU. Run it on a trusted network, or set API_KEY and put TLS in front of it. Do not port-forward it.
Confirm it is up¶
It returns {"status":"ok"} once the model is loaded, or {"status":"idle"} while WHISPER_IDLE_MINUTES has it unloaded (503 {"status":"model failed to load"} if the last load on demand failed). The endpoint is unauthenticated by design so Docker healthchecks work.
Other backends work too. Speaches is a good choice for CPU-only boxes and NVIDIA GPUs; it is built on faster-whisper/CTranslate2, which has no Vulkan path, so it will not accelerate on an Intel iGPU — that case is exactly why the whisper.cpp + Vulkan image exists.
Pointing the plugin at a worker¶
Open Dashboard → Plugins → WhisperSubs and expand Worker Pool (Optional / Advanced).
- Leave Also use this server as a worker on to keep transcribing on the Jellyfin host too, or turn it off to offload entirely.
- Click + Add worker for each endpoint.
- Use the row's Test button before saving. It calls
POST /Workers/TestConnectionand runs the same format negotiation a real job would.
Each row has these fields:
| Field | Meaning |
|---|---|
| Endpoint URL | Base URL only, no /v1 suffix. The plugin appends /v1/audio/transcriptions itself |
| API key | Optional bearer token for this worker |
| Model | Model name to request; empty means the worker's own default. Ignored by the bundled worker image |
| Max concurrency | Requests the worker gets at once. Keep 1 per single GPU |
| Cost weight | 0 = free, preferred. Above 0 = paid, used only to burst |
| Max upload size (MB) | What this endpoint accepts. 0 (default) = unlimited |
| Upload format | WAV (default), FLAC (lossless, ~half) or Opus 24k (~a tenth) |
| Dialect | OpenAI-compatible (default) for whisper-server, OpenAI, Groq and OpenRouter. CrispASR server (Canary) for a CrispASR server with the Canary model. CrispASR server (Qwen3-ASR, transcribe only) for a Qwen3-ASR server. akou server for an akou server |
| akou job priority | akou rows only. -10 to 10, default -5, below akou's own clients at 0 |
| Seconds of audio per akou job | akou rows only. 60 to 7200, blank for 600 |
| Languages akou may detect | akou rows only. ISO codes such as es, en sent as akou's languages[], which bounds the language akou picks when the job names none. Blank leaves it to the server's own list |
| Translation targets | Comma-separated languages this worker translates into. en is Whisper translation and needs /v1/audio/translations on an OpenAI-compatible worker. Canary codes such as nl or de need the CrispASR dialect. Leave it blank for a transcribe-only worker |
Titles per remote request slot (above the rows, default 1) lets the pool give each remote worker more titles than its Max concurrency. Every title spends minutes on this server before a worker sees it: the audio is extracted, and for the forced-subtitle pass each chunk's language is detected. With 1 the worker waits through all of that. With 2 the next title is prepared here while the worker transcribes the current one. The worker still receives at most Max concurrency requests at a time; a prepared title waits on this server, and its timeout only starts once the worker takes it. This server does more work at once, so raise it only when this server has CPU to spare. Memory stays bounded whatever this is set to: this server runs one engine process with a full model at a time (Whisper, Qwen3-ASR or Canary, each about 6 GB with a GPU), plus one language-detection run on the small detection model, and every other title waits its turn.
Three timeout settings bound a remote call, so a slow-but-working pass is never cut off and a dead endpoint still fails: Job timeout — real-time factor (default 6, multiplied by the audio length), minimum seconds (default 60) and maximum hours (default 12).
Qwen3-ASR server workers¶
A CrispASR server running Qwen3-ASR can take whole titles the same way the local Qwen3-ASR engine does, on another machine. On an Apple Silicon Mac the server runs on Metal at 10 to 14 times realtime with the model kept loaded, which is what makes it worth a row during a large catch-up.
crispasr --server --host 0.0.0.0 --port 9020 --backend qwen3 -m qwen3-asr-1.7b-q8_0.gguf \
-vm ggml-silero-v6.2.0.bin -am canary-ctc-aligner-q4_k.gguf --force-aligner -sp -ml 42 -sow \
--lid-backend off --cache-dir <dir> --api-keys <key>
The files are the ones the plugin's own Qwen3-ASR panel downloads: the model, the Silero VAD and the word aligner. --lid-backend off matters: without it the server fetches Whisper tiny for language identification and that step named Norwegian for English and Spanish test clips.
Then add a worker row with Dialect set to CrispASR server (Qwen3-ASR, transcribe only), the base URL (no /v1), the key, and Translation targets blank. The plugin treats that row differently from the other two dialects:
- It never sends a translation there. Qwen3-ASR has no translate task, so English translation stays on Whisper and the Canary targets on Canary workers. When a title with English audio needs its foreign lines translated for a forced subtitle, this server's Whisper translates those lines and the row keeps the rest.
- It asks for
verbose_jsonfirst and cuts cues from the word timestamps in the answer, at Maximum subtitle line length characters (42 when unset), at sentence ends, and at pauses of a second or more. The server's own SRT is one cue per 30-second slice, which is not a subtitle. - Language detection for titles without tags, and the per-chunk detection of the forced-subtitle pass, run on this server's Whisper, not on the worker. The server reports
autoas its language, so a row that detected there would guess.
The server keeps one request at a time per loaded model, so Max concurrency stays at 1.
akou server workers¶
An akou server transcribes with Qwen3-ASR (its best preset) and serves other clients from the same queue. A row with Dialect akou server sends each request as a file job at the row's akou job priority, -5 by default, below akou's own default of 0, so the server's other clients go first. A running job is never stopped for another, so a title reaches akou in windows of Seconds of audio per akou job, 600 by default: a client that arrives while a subtitle job runs waits one window of audio at most, and an interrupted title resumes from its last window. Once the transcript is read the plugin deletes the job, which removes the uploaded audio and the result from the server; it deletes it on a failure or a timeout too. If Jellyfin stops in the middle, akou still deletes the audio when the job ends and drops the transcript after its retention days. The job's timeout starts when akou starts it: the wait behind akou's other clients is bounded only by maximum hours.
The Connect an akou server section at the top of the settings page sets up this row: it takes the URL and the key, Test checks the server, and Save fills the first akou row (or adds one). By hand, set the base URL (no /v1), a key with the jobs scope (akou keys create --name whisper-subs --scope jobs), Model best (the default when blank), and leave Translation targets blank.
Test reads GET /v1/server and GET /v1/keys/me and says what is wrong in words: not an akou server, a host name akou refuses (403 bad_host: use exactly its server.public_host), no file jobs, the best preset not pulled (akou models pull best), no key, a key akou refuses, or a key without the jobs scope.
What the row does depends on whether this server also has the local Whisper engine (the whisper-cli binary and a model):
| With the local Whisper engine | akou alone | |
|---|---|---|
| Language of untagged titles | This server's Whisper | akou names it from up to four 30-second slices spread over the first window, and the first language two slices agree on wins; silent slices count for nothing. Each job lists Qwen3-ASR's 30 codes, or the row's languages, so a server bound to one language still names the title's own. akou can only answer with a listed language, so a title in another language is named as the nearest of them and transcribed as that, usually a translation: tag its audio track. When no two slices agree, the title is skipped and remembered like the row below |
| A tagged language Qwen3-ASR does not cover | This server's Whisper transcribes it | Another worker in the pool that is not an akou or Qwen3-ASR row takes the title. With none, it is skipped with a note in the log, not retried until the title or the setup changes |
| English translation | akou, when its server offers the translate task; else this server's Whisper | akou, when its server offers the translate task; else skipped, with one warning a run |
| Forced subtitles | As before | Skipped: Full and Forced runs as Full, Forced Only makes nothing |
The plugin reads akou's own result (format=json), whose words carry their times. Where akou gives word times (Qwen3-ASR with akou's word-time aligner, or Parakeet), cues are cut at them, at most one line long (the Subtitle line length setting, 42 characters when unset), breaking at sentence ends and at pauses of a second or more, the same way as a Qwen3-ASR server's aligned words. Where it gives none (an akou without the aligner, a piece the aligner could not time, a translation), the plugin splits akou's segments into cues of at most 84 characters (two lines) and 7 seconds, at sentence ends first, and shares each segment's time among its pieces by length. Without that split, cues on real titles ran up to 28 seconds. A result can mix both: each segment is cut the way its words allow, and akou's warning about missing word times is written to the log.
At the start of each run the plugin reads the server's GET /v1/server. When it lists capabilities.translate, the row also makes English subtitles: a job with task=translate and the source language, which akou runs on Canary for the 25 languages Canary reads and on Whisper large-v3 for the rest. With an older akou the row makes no English itself, exactly as before. Test says "translates into English" when the server offers it.
Without any local engine, this server's own worker stays out of the pool while an akou row exists, so every title goes to akou, and the plugin downloads neither the Silero VAD model nor the small detection model. Installing the engine later brings all of it back; the skip cache notices the change and the next sweep looks at skipped titles again.
CrispASR server workers¶
A CrispASR server running NVIDIA Canary can make the extra target languages for English audio on another machine. Start it with the model and the Silero VAD model:
<gguf> is a Canary GGUF file such as canary-1b-v2-q8_0.gguf, <silero> is a Silero VAD ggml file such as the plugin's own ggml-silero-*.bin, and <dir> is where the server keeps anything it fetches for itself.
Then add a worker row with Dialect set to CrispASR server and list the languages it should serve in Translation targets, for example nl, de. The plugin sends a target only to a worker that lists it, and prefers the item's own worker when that worker lists it.
Leave en out to use the server only for the extra languages. With en listed, it also takes whole titles, including their English translation, and Canary's translation into English was weaker than Whisper's in testing.
Two traps:
- There is no translations route. A CrispASR server answers
404on/v1/audio/translations. That is why the row needs the CrispASR dialect: the plugin then sends every request to/v1/audio/transcriptions, withtranslate=truefor English, and withsource_lang=enandtarget_langfor a Canary target. - Give the VAD model at startup with
-vm. A server started without it downloads the Silero model on the spot when a request asks for VAD, which fails on a worker without internet access. With-vmit runs VAD on every request, and without VAD Canary returns the whole file as one cue.
A target the server cannot serve comes back as HTTP 200 with an empty body. The plugin fails that language with an error that names it, and writes no file.
Hosted providers¶
Base URLs¶
Enter only the base. The plugin appends the path, so a URL that already ends in /v1 becomes /v1/v1/... and returns 404.
| Provider | Enter this |
|---|---|
| Groq | https://api.groq.com/openai |
| OpenRouter | https://openrouter.ai/api |
| OpenAI | https://api.openai.com |
Model names¶
- Groq:
whisper-large-v3. - OpenRouter: a namespaced slug such as
openai/whisper-large-v3-turbo. A barewhisper-1or a Groq-style name returns400. - OpenAI:
whisper-1.
Which response formats carry timestamps¶
A subtitle needs timings, so this decides whether a provider can be used at all.
| Format | Carries timestamps | Used by WhisperSubs |
|---|---|---|
srt |
Yes | First choice. Kept for every server that accepts it |
verbose_json |
Yes, per segment | Negotiated automatically when srt is rejected |
json |
No — transcript text only | Never. It cannot be synchronized |
text |
No | Never |
WhisperSubs asks for srt first. If the endpoint rejects it, the plugin retries once with verbose_json, validates the returned segments, converts them to SRT, and caches that choice for the endpoint so the retry does not repeat on every job. If a provider ignores the requested format and answers with untimed JSON, the plugin retries with the other format rather than accepting it.
The plugin deliberately does not send timestamp_granularities[]. The field is optional, and segment is already the default for verbose_json. Segment is also the only granularity WhisperSubs reads. Support for it varies by model and provider, and some combinations answer 400: OpenRouter does so unless your chosen model routes to an OpenAI-compatible backend. Omitting the field works everywhere.
Provider-specific limits:
- OpenAI: on
/v1/audio/transcriptions, SRT and timestamps exist onwhisper-1only.gpt-4o-transcribeandgpt-4o-mini-transcribeacceptjsonandtextonly, so they carry no timings and cannot be used for subtitles; asking either forverbose_jsonfails withresponse_format 'verbose_json' is not compatible with model 'gpt-4o-transcribe'. The separategpt-4o-transcribe-diarizemodel answers withdiarized_json, which WhisperSubs neither requests nor parses. - OpenRouter: has no
/v1/audio/translationsendpoint, so leave Translation targets blank for it. Timestamps are only available on its OpenAI-compatible models; models that return text without timings (Chirp, Deepgram, Nova) cannot produce synchronized subtitles at all.
Upload size, in minutes of audio¶
WhisperSubs extracts 16 kHz mono s16le PCM WAV. That is 32,000 bytes per second of audio, or 1.92 MB per minute. The arithmetic decides everything about hosted providers:
| Audio length | WAV (default) | FLAC | Opus 24k |
|---|---|---|---|
| 13 minutes | ~25 MB | ~13 MB | ~2 MB |
| 40 minutes | 76.8 MB | ~39 MB | ~7 MB |
| 2 hours | 219.7 MB | ~116 MB | ~20 MB |
A 25 MB cap therefore accepts only about 13 minutes of the default WAV upload.
| Provider | Direct upload cap |
|---|---|
| OpenAI | 25 MB |
| Groq | 25 MB on both tiers |
Groq's published 100 MB figure applies only to their url parameter, which WhisperSubs does not use. Setting 100 MB here would let a 77 MB upload through the plugin's own check and fail upstream anyway.
Two per-worker settings deal with this:
Max upload size (MB) tells the plugin what the endpoint accepts. 0 (the default) means unlimited, which is correct for every self-hosted worker. There is deliberately no non-zero default, because real caps differ by orders of magnitude. When set, an oversized title fails immediately — nothing is uploaded, so no bandwidth or provider quota is wasted — with a message naming the audio size, the cap and roughly how many minutes fit.
Upload format re-encodes the audio for the wire only. The extracted WAV that the local whisper path uses is untouched.
FLACis lossless and about half the size. It is encoded with-sample_fmt s16; without that flag ffmpeg defaults to 24-bit and produces a file larger than the 16-bit PCM source.Opus 24kis about a tenth. A 40-minute title lands near 7 MB and a full 2-hour film near 20 MB, comfortably inside a 25 MB cap.
For a 40-minute title on Groq, FLAC alone is not enough — ~39 MB still exceeds 25 MB. Use Opus.
Warning
Keep WAV for self-hosted workers. whisper.cpp's whisper-server decodes WAV only, and the bundled worker image ships without ffmpeg. Choose a compressed format only on a hosted endpoint documented to accept it. Groq accepts FLAC and OGG; OpenAI's documented list is mp3, mp4, mpeg, mpga, m4a, wav and webm.
When a compressed upload is prepared, the log shows a line like Prepared opus upload: 6.7 MB (from 76.8 MB) before the job. If the re-encode cannot run — no ffmpeg, the encode failed, or the result was not actually smaller — the plugin falls back to the original WAV, says why, and labels the upload as a WAV so the provider is not handed a mislabelled body.
Troubleshooting¶
Every upstream failure now carries the provider's own explanation (sanitized, with API keys and signed URLs redacted) plus one line of advice. The advice comes first, because the queue's error banner truncates long messages.
HTTP 413 — request entity too large¶
The upload exceeded what the endpoint accepts. In order of preference:
- Set Max upload size (MB) on this worker (
25for OpenAI and Groq) so the next oversized title fails instantly instead of after the upload. - Set Upload format to
Opus 24k. That is roughly a tenth of the WAV size and fits a 2-hour film under a 25 MB cap. - Use a self-hosted worker, which has no limit.
If you already have Opus selected and still hit the cap, the title is simply too long for that endpoint.
A 502 or a dropped connection on a long title is usually the same problem: an oversized body gets discarded at the provider's edge before it can return a tidy 413. Check the audio length against the table above before assuming the provider is down.
The provider returns text without timestamps¶
The error reads:
Remote Whisper API returned JSON without timestamped segments.
Use a model/provider that supports response_format=verbose_json;
plain json text cannot be synchronized.
No client-side change fixes this. The model returned a transcript with no timings, and there is nothing to synchronize to. Switch that worker to a model that supports verbose_json with segments — whisper-1 on OpenAI, whisper-large-v3 on Groq, an openai/... slug on OpenRouter — or point the worker at self-hosted whisper.cpp instead.
Other status codes¶
| Status | What it means and what to change |
|---|---|
301/302/307/308 |
The endpoint redirected instead of answering. WhisperSubs never follows a redirect with your audio. Usually an SSO gateway or an http→https rewrite; point the worker at the API's final address |
400 |
On OpenRouter, almost always the model name. Use a namespaced slug, not a bare whisper-1 |
401 |
Key rejected. Check it is pasted in full, belongs to this provider and has not expired |
403 |
Key accepted, account not allowed. Check model access and billing on the provider's dashboard |
404 |
No transcription endpoint at that address. The base URL must not end in /v1 |
405/501 |
The address exists but does not accept audio uploads — probably a web page or a chat-only API |
429 |
Rate limited. Lower this worker's max concurrency, or raise its cost weight so a local worker is preferred |
5xx |
The endpoint failed on its own side. Retry later or check the provider's status page |
If the endpoint sits behind a proxy that answers failures with an HTML page, the plugin reports "the endpoint returned a web page, not an API response" rather than echoing the markup.
Worker output timing looks different from local output¶
Worker-generated subtitles can use the same speech-onset correction as local whisper-cli output, but it is opt-in for provider-owned timestamps. Enable Align subtitles to speech and Also align VAD / worker timestamps to speech.