
Part of Shoal, a family of Jellyfin plugins that work together: Shoal Ingest files new media into your libraries, Shoal Subtitles finds, checks and synchronises subtitles, and Shoal AI gives both optional AI help. Each works on its own; installed together, they help each other.
A Jellyfin plugin that finds subtitles for videos that are missing them, checks every candidate against what is actually said in the audio, and fixes the timing, so the subtitles you get are the right ones and in sync.
Status: alpha. Checking and fixing existing subtitles, finding missing ones, as a last resort generating them from a full transcript, and comparing doubtful subtitles line by line with a full transcript work with the built-in speech-to-text, a local service, or a paid service within your monthly limit. The design is in docs/DESIGN.md.
From the Shoal plugin repository (recommended): in Dashboard → Plugins → Repositories, add
https://raw.githubusercontent.com/chrisgrulau/jellyfin-shoal/main/manifest.json, install Shoal Subtitles from
the catalogue and restart Jellyfin. Jellyfin installs updates from a repository automatically (daily, and at start-up)
unless you switch that off for the plugin under My Plugins.
By hand: download jellyfin-plugin-subtitles.zip from the releases,
check it against SHA256SUMS (and, if you like, its build provenance with
gh attestation verify jellyfin-plugin-subtitles.zip --repo chrisgrulau/jellyfin-subtitles), and put
Jellyfin.Plugin.Subtitles.dll in <jellyfin data>/plugins/Subtitles_<version>/, then restart Jellyfin.
Nothing is checked, changed or downloaded until you have saved the settings page once. For speech-to-text, either allow the built-in one on the plugin page (it downloads about 90 MB the first time), or point Local service address at an OpenAI-compatible service (for example a faster-whisper server); then press Test.
For each video missing subtitles (or holding suspect ones):
find candidates → score them → check the best against the audio → synchronise → verify → file (or ask you)
Three uses, each switched on or off separately and each with its own provider and model:
| Use | What | Default |
|---|---|---|
| Check and synchronise | A few short snippets per video | On |
| Context for AI decisions | A slightly longer excerpt, when the AI plugin is installed. Also used for the short transcripts Ingest may ask for (Let Ingest ask for short transcripts, off by default) to tell which episode a new video is | Off |
| Full transcript | The whole video, to generate subtitles when none can be found (see Generated subtitles) to check doubtful subtitles line by line (see Whole-file check) and to fix subtitles made for a different cut (see Subtitles made for a different cut). Its own service and model, so for example Deepgram can check and synchronise while full transcripts stay free on the built-in one. Transcripts are kept, so a video is never transcribed twice with the same service and model | Off |
Providers:
| Provider | Setup | Cost |
|---|---|---|
| Built-in (default) | One click to allow it: the plugin then downloads a checksum-verified Whisper program (whisper.cpp, built by this project for Linux x64/arm64, Windows x64 and macOS) and a model, about 90 MB (base, the default) or 200 MB (small), the first time it's needed | Free; CPU, slower |
| Local service | A local speech-to-text service (Whisper), with a guided one-line setup on the plugin page | Free; fast with a GPU |
| Cloud (Deepgram, OpenAI …) | Paste an API key | Per minute of audio |
When a service fails: a call that fails for a passing reason (no connection, a timeout, a server error, a short rate limit) is tried again up to 5 times, about 2, 4, 8 and 16 seconds apart; a refused key, a rejected request or a used-up allowance isn't. If it still fails, a check falls back to a free service on the server: the local service, then the built-in one (only if allowed and already downloaded); never to a paid service (If the chosen service fails, fall back to a free local one, on by default). A failing local service falls back to built-in and a failing built-in one to the local service. The result says so and offers Rerun with … the service you chose, when that service can be used. If nothing is left and the free line-start stage couldn't decide on its own, no verdict is recorded: the result reads "Couldn't check yet" and it is checked again on the next run. A built-in program that can't start, or keeps crashing, has its files checked against their checksums and is downloaded again if they're damaged. Every failure is listed under Advanced → Recent speech errors; only a problem that keeps happening (5 failures in a day, half the calls failing, or failures on 3 runs in a row; cleared after 3 successes in a row) shows as a banner and in Jellyfin's Activity log.
A daily task (Scheduled Tasks → Shoal → Check and sync subtitles, or Check now on the plugin page) checks the text subtitle files beside your films and episodes. Each one's timing is compared with the audio, first for free by matching where lines start against where speech starts, then, if that isn't clear-cut, by transcribing a few minutes with a free local speech-to-text service and matching the words. Corrections (a shift, and a frame-rate change if the subtitle was made for a PAL release) are applied or held for your review, and every change can be undone from the plugin page. With many results, tick the ones to act on (or select every result matching the filter) and apply, decline, undo or check them again together; Apply all waiting for review… does the first for the whole filter after confirming the count. The work runs in the background with the same safety as each row's own buttons, and files changed since are skipped and listed.
A second daily task (Find missing subtitles, or Find missing now) searches your subtitle providers, such as the OpenSubtitles plugin, for films and episodes that have no subtitle in your languages. The best candidates are downloaded one at a time and checked against the audio the same way; one is added only if it clearly fits, with its timing corrected. Existing subtitle files are never replaced, downloads are capped per day, and Undo removes an added subtitle.
A third daily task (Generate missing subtitles and check whole files, off until you switch on Generate subtitles when none can be found, Check whole file for doubtful subtitles or Fix subtitles made for a different cut, or pick a subtitle with Check whole file or Try fixing timing by section) makes subtitles from a full transcript for videos the search found nothing for, compares doubtful subtitles with a full transcript, and fixes subtitles made for a different cut section by section; see below.
New videos don't wait for the night: a few minutes after films or episodes are added (10 by default, counted from the last one, so a whole season is handled together), their subtitles are checked and missing ones searched for, within the same limits (Handle new videos soon after they're added, on by default). A subtitle file that appears beside a video is checked the same way. Generating subtitles stays nightly.
All three tasks work on every film and show library unless you untick some under Libraries on the settings page (for example anime or children's libraries). The subtitle languages are those under Subtitle languages; left empty, each library uses its own subtitle download languages from Jellyfin's library settings (then the server's preferred metadata language, then English), and the page shows what is in effect for each library.
Every language works the same way, as long as the subtitle is in the language that is spoken:
JOSÉ:, ДИМА:), the full-width brackets of Chinese and
Japanese sound descriptions ((笑)), and credit lines in the languages subtitles are most often shared in.When the search found nothing that fits a video in one of your languages, and Generate subtitles when none can be found is on, the whole video is transcribed and a subtitle is made from what was said.
<video name>.<language>.generated.srt beside the video, for example Film (2020).en.generated.srt.
Jellyfin reads the language from the name and takes the word generated as the track's title, so players list it as
generated - English - SRT (the exact wording depends on the client). Nothing is added to the subtitle text itself.A subtitle that looks doubtful can be compared, line by line, with a full transcript of its video. Off by default: Check whole file for doubtful subtitles.
Some subtitles were made for another version of the video: a scene added or cut, a recap or cold open, ad breaks trimmed differently, sometimes a different frame rate as well. They start in time and then jump or drift part-way, so no single shift fixes them, and the timing check leaves them unclear. Off by default: Fix subtitles made for a different cut.
Dashboard → Plugins → Subtitles. The results come first: each row shows the video as "Series S01E05" (the episode title beneath) or "Title (Year)", the outcome, the changes as small icons with counts (⏱ timing shift, ↔ line timing tidied, 🔈 sound descriptions removed, ✂ lines removed, 💬 wording changed, ➕ lines to add, 🔤 text that didn't decode cleanly, ⏳ queued; hover for words, dashed when waiting for review), one sentence about what happened and when. ▸ opens the row's details: the files, what changed, lines to review and the numbers behind it ("Nerd stats"). The list shows 15 at a time (Show more), filtered and searched on the server; on a phone the rows become cards.
The settings are grouped in sections that open and close (General, Finding and checking, Clean-up, Speech-to-text, Spending limit). Basic settings cover the key decisions in plain language; an Advanced settings section holds finer controls (costs, how much is checked per run, clean-up details, AI checks) with warnings where a change could make results worse. "Let agreement between sources settle disagreements" is shown there as coming later; it has no effect yet.
Stored in the plugin's data folder (<jellyfin data>/plugins/Jellyfin.Plugin.Subtitles/): keys.json (owner-only),
results.json (a result per subtitle: paths, video names, what was changed; kept while the file exists),
originals/ (the original of every file it changed, for Undo), spend.json and rates.json (spending),
downloads.json (the day's download count), transcripts/ (full transcripts, compressed, at most 200 MB) and
calibration.json (counts per speech-to-text model for tuning confidence), speech-errors.jsonl (the last 500
speech-to-text failures of the last 30 days: time, service, kind and a short message with keys removed) and
speech-calls.json (calls per service per hour and per run, for telling a lasting problem from a passing one). The built-in speech-to-text lives in <jellyfin data>/shoal-subtitles/.
Sent:
| To | When | What |
|---|---|---|
| Your subtitle providers (through Jellyfin), SubDL | Finding missing subtitles | The video's title, year, season and episode, as Jellyfin's own search does |
| A cloud speech-to-text service | Only if you chose one | A few one-minute audio snippets per checked file; for generated subtitles and whole-file checks, only if you chose a cloud service for full transcripts, the whole video's audio in ten-minute parts (the built-in and local services keep audio on the server) |
| Shoal AI → your AI provider | Only if installed and allowing Subtitles | The subtitle language, a few minutes of heard phrases and the subtitle lines around them; for a whole-file check, the lines flagged for their wording and what was heard for them |
Needs write access to your media folders: corrections are written beside the video.
Upgrades keep settings, results and originals. Before uninstalling, use Restore all originals… (under the results on
the plugin page) to reverse everything the plugin did, or Undo on single changes: once the plugin is gone, its
originals are no longer linked to their files. Restore all puts back the original of every file it changed and removes
every subtitle it added or generated; files changed since by you or another program are left alone and listed. Then
untick Enabled, save, and uninstall. Left behind: the plugin's data folder (keys,
results, originals) and <jellyfin data>/shoal-subtitles/ (the built-in speech-to-text). Jellyfin's own "Download
missing subtitles" task uses the same OpenSubtitles allowance, so you may want only one of them searching.
The shared source (jellyfin-plugin-common) is a git
submodule, so clone with --recurse-submodules, or fetch it in an existing clone; the build fails without
external/common:
git submodule update --init --recursive
dotnet build -c Release
Any .NET 10 SDK builds it (global.json sets the floor, so Linux distribution packages work), and package versions are locked in packages.lock.json. The Jellyfin
packages are pinned to the server version in build.yaml's targetAbi; bump them together.
The output Jellyfin.Plugin.Subtitles.dll goes in <jellyfin data>/plugins/Subtitles_<version>/.
This repository is public. No secrets are committed. API keys you enter are stored in their own file,
keys.json in the plugin's data folder, readable only by Jellyfin's account (never in the plugin configuration, and
never shown again or logged). See SECURITY.md.
GPL-3.0, in line with Jellyfin's official plugins (the server itself is GPL-2.0).

Part of Shoal, a family of Jellyfin plugins that work together: Shoal Ingest files new media into your libraries, Shoal Subtitles finds, checks and synchronises subtitles, and Shoal AI gives both optional AI help. Each works on its own; installed together, they help each other.
A Jellyfin plugin that finds subtitles for videos that are missing them, checks every candidate against what is actually said in the audio, and fixes the timing, so the subtitles you get are the right ones and in sync.
Status: alpha. Checking and fixing existing subtitles, finding missing ones, as a last resort generating them from a full transcript, and comparing doubtful subtitles line by line with a full transcript work with the built-in speech-to-text, a local service, or a paid service within your monthly limit. The design is in docs/DESIGN.md.
From the Shoal plugin repository (recommended): in Dashboard → Plugins → Repositories, add
https://raw.githubusercontent.com/chrisgrulau/jellyfin-shoal/main/manifest.json, install Shoal Subtitles from
the catalogue and restart Jellyfin. Jellyfin installs updates from a repository automatically (daily, and at start-up)
unless you switch that off for the plugin under My Plugins.
By hand: download jellyfin-plugin-subtitles.zip from the releases,
check it against SHA256SUMS (and, if you like, its build provenance with
gh attestation verify jellyfin-plugin-subtitles.zip --repo chrisgrulau/jellyfin-subtitles), and put
Jellyfin.Plugin.Subtitles.dll in <jellyfin data>/plugins/Subtitles_<version>/, then restart Jellyfin.
Nothing is checked, changed or downloaded until you have saved the settings page once. For speech-to-text, either allow the built-in one on the plugin page (it downloads about 90 MB the first time), or point Local service address at an OpenAI-compatible service (for example a faster-whisper server); then press Test.
For each video missing subtitles (or holding suspect ones):
find candidates → score them → check the best against the audio → synchronise → verify → file (or ask you)
Three uses, each switched on or off separately and each with its own provider and model:
| Use | What | Default |
|---|---|---|
| Check and synchronise | A few short snippets per video | On |
| Context for AI decisions | A slightly longer excerpt, when the AI plugin is installed. Also used for the short transcripts Ingest may ask for (Let Ingest ask for short transcripts, off by default) to tell which episode a new video is | Off |
| Full transcript | The whole video, to generate subtitles when none can be found (see Generated subtitles) to check doubtful subtitles line by line (see Whole-file check) and to fix subtitles made for a different cut (see Subtitles made for a different cut). Its own service and model, so for example Deepgram can check and synchronise while full transcripts stay free on the built-in one. Transcripts are kept, so a video is never transcribed twice with the same service and model | Off |
Providers:
| Provider | Setup | Cost |
|---|---|---|
| Built-in (default) | One click to allow it: the plugin then downloads a checksum-verified Whisper program (whisper.cpp, built by this project for Linux x64/arm64, Windows x64 and macOS) and a model, about 90 MB (base, the default) or 200 MB (small), the first time it's needed | Free; CPU, slower |
| Local service | A local speech-to-text service (Whisper), with a guided one-line setup on the plugin page | Free; fast with a GPU |
| Cloud (Deepgram, OpenAI …) | Paste an API key | Per minute of audio |
When a service fails: a call that fails for a passing reason (no connection, a timeout, a server error, a short rate limit) is tried again up to 5 times, about 2, 4, 8 and 16 seconds apart; a refused key, a rejected request or a used-up allowance isn't. If it still fails, a check falls back to a free service on the server: the local service, then the built-in one (only if allowed and already downloaded); never to a paid service (If the chosen service fails, fall back to a free local one, on by default). A failing local service falls back to built-in and a failing built-in one to the local service. The result says so and offers Rerun with … the service you chose, when that service can be used. If nothing is left and the free line-start stage couldn't decide on its own, no verdict is recorded: the result reads "Couldn't check yet" and it is checked again on the next run. A built-in program that can't start, or keeps crashing, has its files checked against their checksums and is downloaded again if they're damaged. Every failure is listed under Advanced → Recent speech errors; only a problem that keeps happening (5 failures in a day, half the calls failing, or failures on 3 runs in a row; cleared after 3 successes in a row) shows as a banner and in Jellyfin's Activity log.
A daily task (Scheduled Tasks → Shoal → Check and sync subtitles, or Check now on the plugin page) checks the text subtitle files beside your films and episodes. Each one's timing is compared with the audio, first for free by matching where lines start against where speech starts, then, if that isn't clear-cut, by transcribing a few minutes with a free local speech-to-text service and matching the words. Corrections (a shift, and a frame-rate change if the subtitle was made for a PAL release) are applied or held for your review, and every change can be undone from the plugin page. With many results, tick the ones to act on (or select every result matching the filter) and apply, decline, undo or check them again together; Apply all waiting for review… does the first for the whole filter after confirming the count. The work runs in the background with the same safety as each row's own buttons, and files changed since are skipped and listed.
A second daily task (Find missing subtitles, or Find missing now) searches your subtitle providers, such as the OpenSubtitles plugin, for films and episodes that have no subtitle in your languages. The best candidates are downloaded one at a time and checked against the audio the same way; one is added only if it clearly fits, with its timing corrected. Existing subtitle files are never replaced, downloads are capped per day, and Undo removes an added subtitle.
A third daily task (Generate missing subtitles and check whole files, off until you switch on Generate subtitles when none can be found, Check whole file for doubtful subtitles or Fix subtitles made for a different cut, or pick a subtitle with Check whole file or Try fixing timing by section) makes subtitles from a full transcript for videos the search found nothing for, compares doubtful subtitles with a full transcript, and fixes subtitles made for a different cut section by section; see below.
New videos don't wait for the night: a few minutes after films or episodes are added (10 by default, counted from the last one, so a whole season is handled together), their subtitles are checked and missing ones searched for, within the same limits (Handle new videos soon after they're added, on by default). A subtitle file that appears beside a video is checked the same way. Generating subtitles stays nightly.
All three tasks work on every film and show library unless you untick some under Libraries on the settings page (for example anime or children's libraries). The subtitle languages are those under Subtitle languages; left empty, each library uses its own subtitle download languages from Jellyfin's library settings (then the server's preferred metadata language, then English), and the page shows what is in effect for each library.
Every language works the same way, as long as the subtitle is in the language that is spoken:
JOSÉ:, ДИМА:), the full-width brackets of Chinese and
Japanese sound descriptions ((笑)), and credit lines in the languages subtitles are most often shared in.When the search found nothing that fits a video in one of your languages, and Generate subtitles when none can be found is on, the whole video is transcribed and a subtitle is made from what was said.
<video name>.<language>.generated.srt beside the video, for example Film (2020).en.generated.srt.
Jellyfin reads the language from the name and takes the word generated as the track's title, so players list it as
generated - English - SRT (the exact wording depends on the client). Nothing is added to the subtitle text itself.A subtitle that looks doubtful can be compared, line by line, with a full transcript of its video. Off by default: Check whole file for doubtful subtitles.
Some subtitles were made for another version of the video: a scene added or cut, a recap or cold open, ad breaks trimmed differently, sometimes a different frame rate as well. They start in time and then jump or drift part-way, so no single shift fixes them, and the timing check leaves them unclear. Off by default: Fix subtitles made for a different cut.
Dashboard → Plugins → Subtitles. The results come first: each row shows the video as "Series S01E05" (the episode title beneath) or "Title (Year)", the outcome, the changes as small icons with counts (⏱ timing shift, ↔ line timing tidied, 🔈 sound descriptions removed, ✂ lines removed, 💬 wording changed, ➕ lines to add, 🔤 text that didn't decode cleanly, ⏳ queued; hover for words, dashed when waiting for review), one sentence about what happened and when. ▸ opens the row's details: the files, what changed, lines to review and the numbers behind it ("Nerd stats"). The list shows 15 at a time (Show more), filtered and searched on the server; on a phone the rows become cards.
The settings are grouped in sections that open and close (General, Finding and checking, Clean-up, Speech-to-text, Spending limit). Basic settings cover the key decisions in plain language; an Advanced settings section holds finer controls (costs, how much is checked per run, clean-up details, AI checks) with warnings where a change could make results worse. "Let agreement between sources settle disagreements" is shown there as coming later; it has no effect yet.
Stored in the plugin's data folder (<jellyfin data>/plugins/Jellyfin.Plugin.Subtitles/): keys.json (owner-only),
results.json (a result per subtitle: paths, video names, what was changed; kept while the file exists),
originals/ (the original of every file it changed, for Undo), spend.json and rates.json (spending),
downloads.json (the day's download count), transcripts/ (full transcripts, compressed, at most 200 MB) and
calibration.json (counts per speech-to-text model for tuning confidence), speech-errors.jsonl (the last 500
speech-to-text failures of the last 30 days: time, service, kind and a short message with keys removed) and
speech-calls.json (calls per service per hour and per run, for telling a lasting problem from a passing one). The built-in speech-to-text lives in <jellyfin data>/shoal-subtitles/.
Sent:
| To | When | What |
|---|---|---|
| Your subtitle providers (through Jellyfin), SubDL | Finding missing subtitles | The video's title, year, season and episode, as Jellyfin's own search does |
| A cloud speech-to-text service | Only if you chose one | A few one-minute audio snippets per checked file; for generated subtitles and whole-file checks, only if you chose a cloud service for full transcripts, the whole video's audio in ten-minute parts (the built-in and local services keep audio on the server) |
| Shoal AI → your AI provider | Only if installed and allowing Subtitles | The subtitle language, a few minutes of heard phrases and the subtitle lines around them; for a whole-file check, the lines flagged for their wording and what was heard for them |
Needs write access to your media folders: corrections are written beside the video.
Upgrades keep settings, results and originals. Before uninstalling, use Restore all originals… (under the results on
the plugin page) to reverse everything the plugin did, or Undo on single changes: once the plugin is gone, its
originals are no longer linked to their files. Restore all puts back the original of every file it changed and removes
every subtitle it added or generated; files changed since by you or another program are left alone and listed. Then
untick Enabled, save, and uninstall. Left behind: the plugin's data folder (keys,
results, originals) and <jellyfin data>/shoal-subtitles/ (the built-in speech-to-text). Jellyfin's own "Download
missing subtitles" task uses the same OpenSubtitles allowance, so you may want only one of them searching.
The shared source (jellyfin-plugin-common) is a git
submodule, so clone with --recurse-submodules, or fetch it in an existing clone; the build fails without
external/common:
git submodule update --init --recursive
dotnet build -c Release
Any .NET 10 SDK builds it (global.json sets the floor, so Linux distribution packages work), and package versions are locked in packages.lock.json. The Jellyfin
packages are pinned to the server version in build.yaml's targetAbi; bump them together.
The output Jellyfin.Plugin.Subtitles.dll goes in <jellyfin data>/plugins/Subtitles_<version>/.
This repository is public. No secrets are committed. API keys you enter are stored in their own file,
keys.json in the plugin's data folder, readable only by Jellyfin's account (never in the plugin configuration, and
never shown again or logged). See SECURITY.md.
GPL-3.0, in line with Jellyfin's official plugins (the server itself is GPL-2.0).