Skip to content

Bazarr+ v2.2.0 (Synapse)

Choose a tag to compare

@LavX LavX released this 13 May 13:27
· 257 commits to master since this release
a29916f

Bazarr+ v2.2.0 - Synapse

Bazarr+ v2.2.0 (Synapse)

Bazarr+ becomes the synapse: a self-hosted subtitle backend that media players and apps query directly. v2.2 ships an OpenSubtitles-compatible External Integration REST endpoint, two first-party clients (Jellyfin plugin, VLSub Bazarr+), API key encryption at rest for every sensitive credential, automatic Jellyfin library refresh after subtitle downloads, the OMDB refiner revived, TVDB v4 episode resolution, and the SQLite busy timeout bumped to 60s for slow disks.

208 non-merge commits since v2.1.0 (LiveWire). Roughly 91,000 lines added across the new compat package, encryption-at-rest secret store, Jellyfin integration, the rebuilt subtitle editor preview, the May 2026 upstream sync, and their test suites.


Headline: External Integration (the Synapse)

Bazarr+ now exposes a stable, OpenSubtitles-compatible REST API at /compat/*, designed so that any subtitle client written against OpenSubtitles can talk to your Bazarr+ instance and use your configured providers, your hashes, your priority order, your filters.

Enable it under Settings → External Integration. Bazarr+ generates JWT/HMAC secrets on first enable, mints an admin token, and shows a prominent restart-required banner. After restart, the endpoint is live.

What it does:

  • OpenSubtitles-compatible search and download: /compat/login, /compat/search, /compat/download, /compat/infos/languages, /compat/comments, /compat/ratings, plus a signed /compat/stream/<token> for the actual subtitle bytes.
  • Real provider fanout under the hood: every /compat/search runs your configured providers in parallel via a dedicated bounded thread pool, with dogpile coalescing so identical concurrent searches share a single backend call.
  • Provider response normalization: uniform Jellyfin/VLSub-shaped result rows with download counts, ratings, FPS, HD flag, AI/MT markers, hearing-impaired flag, hash-match indicator, uploader, language code/name, and upload date. The mapper enforces field discipline so a third-party plugin gets exactly the shape it expects.
  • JWT auth with revocation: short-lived bearer tokens carry a jti claim; logout revokes the jti via an in-memory denylist, and the /compat/download route is rate-limited per jti (sliding window).
  • Signed stream tokens: download responses return a one-shot HMAC-signed stream URL with a TTL, not a raw provider URL. The stream endpoint validates token, jti, and rate-limit before serving bytes.
  • SSRF guard with DNS rebinding protection: every outbound URL the compat layer touches goes through a guard that blocks loopback, RFC1918, link-local, and any IP that fails revalidation post-DNS resolution.
  • TVDB v4 + OMDB enrichment: given an IMDB id, the compat layer hydrates season/episode and TVDB series id so providers like Gestdown that key on TVDB just work. The OMDB refiner (broken since the Python 3 migration upstream) is revived and accepts an API key from a new field on the External Integration settings page.
  • Best-effort virtual-video enrichment: when the request comes in with only an IMDB id and language, Bazarr+ builds a synthetic Video object enriched with TVDB/OMDB metadata so providers that demand a real file path or guessit-quality release name still get usable input.

CI gates the compat package: a dedicated pytest tests/compat/ step runs unit, integration, and contract tests on every push (auth, rate limiter, response mapper, SSRF guard, TVDB v4, OMDB, fanout, JWT denylist, file-id store, build-video, plus a VLSub contract assertion).


Companion Plugins (ship-day)

Two first-party clients are already published and target this release.

Jellyfin plugin: Bazarr+ Subtitles

Jellyfin 10.11+ subtitle provider plugin. Search and download subtitles from inside Jellyfin, served by your Bazarr+ providers.

  • Repository: LavX/jellyfin-plugin-bazarr-plus
  • Manifest URL: https://LavX.github.io/jellyfin-plugin-bazarr-plus/manifest.json
  • Latest: 1.0.0 (2026-04-22)
  • Install: Dashboard → Plugins → Repositories → + → paste the manifest URL → install from Catalog → restart Jellyfin → enable per-library under Subtitle Downloaders.
  • Configure with your Bazarr+ URL and the External Integration token.

VLC extension: VLSub Bazarr+

VLC 3.0+ Lua extension. Hash-based and title-based subtitle search inside VLC, downloads saved next to the video and loaded into the current playback.

  • Repository: LavX/vlsub-bazarr-plus
  • Latest: v1.2.7-260424 (2026-04-24)
  • Install: one-liner installer for Linux/macOS or PowerShell for Windows. Detects native, Flatpak, and Snap VLC installs.
  • Configure with your Bazarr+ URL and the External Integration token. Up to 3 languages per search.
# Linux / macOS
curl -sSL https://raw.githubusercontent.com/LavX/vlsub-bazarr-plus/main/scripts/install.sh | bash
# Windows
iwr -useb https://raw.githubusercontent.com/LavX/vlsub-bazarr-plus/main/scripts/install.ps1 | iex

Both plugins talk to Bazarr+ exclusively over the External Integration REST endpoint introduced in this release. They are hard forks for the Bazarr+ compat surface, not tracking any upstream OpenSubtitles client.


API Key Encryption at Rest

Every sensitive credential Bazarr+ stores on disk is now AES-encrypted under a master key derived per-instance.

What's protected:

  • Provider API keys for every provider that takes one (OpenSubtitles.com, Addic7ed, Subscene, OMDB, etc.).
  • Sonarr / Radarr API keys.
  • Plex token (previously encrypted under a separate scheme; now unified under the shared master key).
  • OpenRouter API key for the AI Subtitle Translator integration.
  • External Integration admin token (the one Jellyfin and VLSub plugins authenticate with).
  • Auth password hash is no longer ever returned to the UI; the field shows blank and only writes when changed.

How it works:

  • A central secret_store module owns crypto. Every sensitive field is registered up front (no field is encrypted by accident, no field is left in cleartext by accident).
  • On first boot after upgrade, Bazarr+ auto-migrates existing cleartext values into the encrypted store. A force-migrate path covers edge cases where the registry expanded between versions.
  • The settings API masks SYSTEM_SECRETS in /api/system/settings responses so the frontend never sees raw secrets, only a sentinel.
  • The supervisor process decrypts the at-rest API key just before injecting it into index.html for the bootstrapping frontend, so the encrypted-at-rest invariant holds end-to-end except for the necessary in-memory window.
  • Key rotation is supported with a roundtrip-tested migration path. End-to-end tests cover: rotation, masking, migration, decrypt-on-read, encrypt-on-write, force-migrate, and the supervisor injection.

The previous v2.0/v2.1 "API key encryption" only applied to keys in transit between Bazarr+ and the AI Subtitle Translator microservice. v2.2 extends encryption to the disk surface as well.


Jellyfin Library Refresh

The base Jellyfin integration was cherry-picked from upstream's development branch (commit 4755a0d64); it has not shipped in any released upstream version. Bazarr+ ships it polished and hardened:

  • HTTPS support with explicit verify_ssl toggle so self-signed Jellyfin instances work
  • "Refresh now" Maintenance card on the Settings page to verify connectivity without doing a real download
  • Humanised empty / loading / error states in the LibrarySelector dropdown
  • Atmospheric Dark conventions applied to the Jellyfin Settings page
  • Hardening: API key kept out of URL strings (header only), secret redaction in logs, response cap to prevent runaway downloads, TLS verification, ID validation on incoming ProviderIds, streamed response closed on read failure, override-cache key fingerprinting

Pairs with External Integration to make the Bazarr+ ↔ Jellyfin loop fully symmetric: library refresh is Bazarr+ → Jellyfin (push); the integration endpoint is Jellyfin → Bazarr+ (pull).


Subtitle Editor

The in-browser subtitle editor's video preview is rebuilt around HLS instead of the prior ffmpeg byte-pipe approach. Playback is responsive on large .mkv files, seeking is correct, and the editor no longer fights the encoder for control of the playhead.

  • HLS-based preview: switched from a single ffmpeg byte-pipe to a chunked HLS playlist consumed by hls.js. Source media is no longer downloaded in full; ffmpeg only remuxes (no re-encode) for default audio tracks, and only transcodes when an alternate audio track is selected.
  • Hardened retry path: stream failures retry without orphaning the encoder, the stream key is preserved across retries (was being reset on media change and causing visible jumps), and the encoder wall-clock timeout was dropped so long preview sessions no longer get killed mid-segment.
  • Seek correctness: HLS startTime is computed precisely so seeks land on the correct timecode, and the seek bar reflects true total duration. Audio/video drift fixed in remux paths, silent-audio edge case fixed, and small Codex-flagged P1/P2 follow-ups on the encoder addressed.
  • Audio quality recalibrated: visualizer-only audio quality lowered after the HLS move, since the editor preview no longer needs broadcast-grade audio.

The bulk of this rebuild lands via PR #98 by @Zmegolaz, with HLS migration and Codex P1/P2 follow-ups on top.

Net effect: the editor handles long episodes and movies without the previous "wait for the file to download before scrubbing" workflow.


Provider Improvements

  • OpenSubtitles.com hash fallback broadened to cover episode-by-series queries, not just episode-by-episode.
  • Supersubtitles title matching tightened.
  • OMDB refiner revived (broken since Python 3 migration upstream) and exposed via a new API-key field on the External Integration settings page. Direct IMDB-id lookup bypasses stock-refiner early-exit.
  • TVDB v4 client added in subliminal_patch. Unblocks episode-imdb resolution for providers that key on TVDB ids (notably Gestdown).
  • Provider score and ratings now surface real download_count, ratings, and FPS from OpenSubtitles.com responses through the scraper layer; provider ratings take precedence over scraped popularity.
  • Multi-region language map for compat language codes; zh-CN normalised to bare zho for provider routing; zh-TW preserved through the pipeline.
  • Embedded provider correctly skipped when not relevant.
  • Multi-language search loop fixed (PR #86 by @Zmegolaz): the search no longer stops as soon as one language is satisfied; it keeps querying providers until every requested language has been found or the search budget is exhausted.
  • Search time budget (PR #87 by @Zmegolaz): new "give up after X seconds" setting bounds how long a single search may run, with a clean cancel path.
  • OpenSubtitles-scraper integration (PR #85 by @Zmegolaz): season and episode metadata are now passed to the scraper for episode requests, fixing wrong-episode matches.
  • New providers from upstream sync: Pipocas.tv (Portuguese, with credentials) and SubClub.eu (Estonian, no auth required). Pipocas credentials are registered in the fork's at-rest secret store like every other provider login pair.
  • Whisper fallback (opt-in): when no provider returns a match for a title, Bazarr+ can fall back to the existing whisperai provider for transcription. Disabled by default; toggle under Subtitles settings. Ships with a country-code lookup fix on the Pipocas language map (('por','BR') was missing because babelfish's Country object never matched the alpha2 string keys).
  • ensubtitles.com reset_token now correctly clears the stale Authorization header on the session after auth failure, instead of only evicting the cached token. Stops revoked bearer tokens being replayed on subsequent requests.

Reliability

  • SQLite busy_timeout bumped from 5s to 60s so slow-disk and heavily-loaded instances no longer surface database is locked errors during concurrent writes.
  • SQLite runtime maintenance added behind a version guard: PRAGMA optimize runs against the engine, and the SQLite runtime version is logged at startup so support reports include it without asking. The PRAGMA is gated to SQLite 3.46+ via sqlite3.sqlite_version_info, so older libsqlite3 builds bundled with some distros don't blow up at startup.
  • Compat fanout pool bounded with cancel-on-timeout. Queued provider work is cancelled when the parent search hits its deadline, freeing pool slots immediately instead of waiting for slow providers to finish.
  • Per-jti rate limiter uses a sliding window so a misbehaving client can't eat the whole pool with a burst.
  • Search timeout enforcement auto-discards slow providers within a single search, so one stuck provider doesn't drag the response time of the entire fanout.
  • Settings cache invalidation on save closes a class of "stale settings until restart" bugs.
  • Cleaner shutdown (PR #88 by @Zmegolaz): worker threads and scheduler jobs drain on SIGTERM instead of being yanked, so in-flight DB writes finish and connections close cleanly.
  • Event throttling (PR #91 by @Zmegolaz): WebSocket events for a single job are capped at four per second, eliminating the UI-flood / browser-lag pattern that surfaced when many providers reported progress simultaneously.
  • Duplicate job entries prevention (upstream #3322): the jobs queue now refuses to re-enqueue a task that is already pending or running with the same module/func/args/kwargs. Also fixes a status-string mismatch ('queued' vs 'pending') in the wait loop.
  • Movie sync optimization (upstream): update_movie now only triggers store_subtitles_movie when the movie's path or movie_file_id actually changed, not on every metadata refresh. Cuts redundant subtitle reindexing on Radarr title/tag/monitored toggles.
  • Settings change detection on save (upstream): credential rotation now correctly evicts cached auth tokens and cookies (oscom_token, titlovi_token, addic7ed_data, legendasdivx_cookies2). Fixes a bug where the comparison was key != settings.x.password (always true) instead of value != ..., causing every save to wipe credentials. Also corrects a settings.radarr.excluded_tags key typo.

UI / UX

  • External Integration Settings tab with a TokenField (copy-to-clipboard, masked-by-default), a RegenerateDialog for rotating the admin token, and a prominent restart-required banner (role="alert", filled orange) that only appears after the toggle is persisted.
  • Settings UI hides stored credentials. Password fields are write-only, the auth.password hash is never injected into the form, and storeable secrets render as •••••••• with a "Set new value" reveal.
  • Releases page rewritten to use react-markdown. Hand-rolled parser is gone; release bodies render correctly with images, code fences, lists, and links.
  • Status page rebrand to Bazarr+ branding, credits consolidated, Discord server link added.
  • Atmospheric Dark conventions applied to the new Jellyfin Settings page.
  • Wiki link added on the Status page (with Prettier fix).

Security & Hardening

  • Sonarr / Radarr authentication moved from query string to X-Api-Key header, so keys no longer leak into proxy logs.
  • Loop tag stripping in subtitle QC fixed (could enter an unstable state on adversarial input).
  • follow-redirects forced to ≥1.16.0 via npm override (closes a transitive Dependabot alert).
  • X-Forwarded headers overwrite at the trusted-proxy boundary so a downstream client can't spoof them.
  • file_id store reset on key rotation so old signed URLs become unusable immediately.
  • Post-download SSRF revalidation so a provider that resolved to a safe IP at search time can't redirect to an unsafe IP at download time.
  • Waitress trusted_proxy wired up correctly, with UnsafeURLError caught before ValueError in the stream handler.
  • X-Forwarded-Proto preserved from a TLS proxy through to the supervisor, so HTTPS-only redirects fire correctly behind a reverse proxy.
  • OMDB endpoint forced to HTTPS.
  • Cache and file_id TTLs clamped to safe upper bounds.
  • Frontend npm audit clean: axios bumped to ^1.16.0, covering prototype pollution (GHSA-pf86-5x62-jrwf, GHSA-q8qp-cvcw-x6jj), header injection (GHSA-6chq-wfr3-2hj9), NO_PROXY SSRF bypass (GHSA-pmwg-cvhr-8vh7, GHSA-m7pr-hjqh-92cm), CRLF injection in multipart uploads (GHSA-445q-vr5w-6q77), DoS via unbounded recursion (GHSA-62hf-57xw-28j9), and maxBodyLength / maxContentLength bypass (GHSA-5c9x-8gcm-mpgx, GHSA-vf2m-468p-8v99). Transitive fast-uri, postcss, and @babel/plugin-transform-modules-systemjs upgraded to patched versions. npm audit now reports zero vulnerabilities.
  • Auth bypass on UI helper routes fixed (cherry-picked from upstream): download_log, series_images, movies_images, backup_download, and the legacy /test proxy were registered with the unwrapped function due to a Flask decorator-ordering bug. Five routes were reachable without authentication. The download_log endpoint in particular leaked API keys via the request log. The fork-only proxy_service (Sonarr/Radarr connection tester) had the identical bug and is fixed in the same change.
  • Backup filename validation + Zip Slip closed (cherry-picked from upstream): delete_backup_file and prepare_restore now whitelist filenames against ^bazarr_backup_v[\w.\-]+\.zip$ and reject any path with directory components, blocking the authenticated path-traversal that flowed user input directly into os.remove and ZipFile. The restore step now uses a _safe_extract helper that resolves each archive member's real path against the destination, closing the Zip Slip vector that previously affected extractall.
  • HLS editor path sanitisation, CodeQL-friendly: the per-session target path in bazarr/api/editor/editor.py is now the os.path.realpath result of the join, validated against the HLS cache root via startswith before any file operation, and reused for every downstream os.path.isfile / open / send_file. The strict filename regex stays as defence-in-depth, and the rewrite closes nine py/path-injection alerts at once.
  • PostgreSQL connect log redacted at source: the connect-time log message is built from the individual env-var components (username, host, port, database) instead of going through the URL object that carries the password. SQLAlchemy's render_as_string(hide_password=True) does mask the password at render time, but it isn't a recognised sanitiser for CodeQL's py/clear-text-logging-sensitive-data, and the data-flow check was flagging the call. The new form has no password value reaching the logger at all.
  • Vendored PySocks UDP relay binds to loopback: libs/socks.py now binds the SOCKS5 UDP relay socket to 127.0.0.1 instead of "" (INADDR_ANY). Upstream PySocks 1.7.1 has been unmaintained since 2019, and Bazarr+ only reaches this module via urllib3's TCP socks contrib, so the UDP-relay path is dead code in practice. Loopback is the safest default if it ever fires, and the change closes py/bind-socket-all-network-interfaces.

Upstream Sync (April / May 2026)

Two sync windows from morpheus65535/bazarr@development. Each commit was reviewed before pick.

April window (through 2026-04-26):

  • Custom variable expansion in notifications: notification templates now support a richer set of variables (release name, provider, score, language, episode metadata).
  • The fork-local versions stay in custom_libs/ per the existing fork-convention registry.

May window (2026-04-27 to 2026-05-09): committee-reviewed 14 candidate commits with three independent agent perspectives (code, security, fork compatibility). 9 picked, 1 rejected (fork's password verification is already PBKDF2-SHA256 with hmac.compare_digest, strictly stronger than upstream's MD5-only constant-time fix), 1 deferred (subtitle upgrade safeguards #3235, the fork has restructured the target query and the patch needs manual port). The 9 picks are reflected in the Provider Improvements, Reliability, and Security & Hardening sections above and shipped in PR #104.


CI / Docker

  • :latest Docker tag is now pinned to versioned releases only. Pushes to development no longer overwrite :latest, so users on :latest track stable releases.
  • Docker build ignores site/ and other doc-only paths, so wiki edits don't trigger a 30-minute multi-arch rebuild.
  • pytest tests/compat/ added as a dedicated CI step.
  • docker/metadata-action 5 → 6, @fortawesome/react-fontawesome bumped via Dependabot.
  • Dependabot now monitors the pip ecosystem in addition to npm, GitHub Actions, and Docker.
  • actions/attest-build-provenance v2 → v4 in the build-docker workflows.
  • Docker supervisor now proxies /system/backup/download/ to the Flask backend. Without this, the backup-download endpoint fell through to the SPA static handler and returned 404 inside the Bazarr+ image, even when the backup zip existed under /config/backup. Regression test (tests/bazarr/test_supervisor_proxy.py) covers the exact path.
  • Unraid PUID=99 / PGID=100 supported: the entrypoint's usermod and groupmod calls now use -o (non-unique), so the bazarr account can take a UID or GID that already exists inside the container. Without this, Unraid users hit groupmod: GID '100' already exists because GID 100 is the host users group, which is also present inside Debian. Closes issue #108.

Base image and Python dependencies

  • Base image bumped from Debian bookworm to Debian trixie (python:3.14-slim-bookwormpython:3.14-slim-trixie). Trixie ships ffmpeg 7.1, libsqlite3 3.46, and current libxml2 / libxslt with their CVE backports.
  • unrar apt package and the non-free repo enable are gone. RAR archives are extracted via the system 7z, since trixie's p7zip-full resolves to upstream 7-Zip 25 with native RAR / RAR5 support. Bazarr's binaries.json portable unrar download remains as a second extraction path. No non-free codec or repo is required.
  • Python dependency floors raised to current latest, with security-driven bumps on the security-relevant packages:
    • lxml >= 6.1.0 (XXE fix in iterparse and ETCompatXMLParser)
    • PyJWT >= 2.12.1 (CVE-2026-32597, unknown crit headers on the JWT validate path)
    • cryptography >= 48.0.0 (OpenSSL 3.5.5, AEAD hardening on the Fernet and AESGCM paths)
    • Pillow >= 12.2.0 (covers the 10.x line Image.open CVE patches)
    • Hygiene bumps for aiohttp, cachetools, webrtcvad-wheels, setuptools, and pywin32.
  • numpy capped at <2.4.0: the NumPy 2.4 series raised the x86-64 wheel baseline to v2 (SSE4 / POPCNT), which crashes pre-2009 CPUs with SIGILL on import numpy. The cap protects users on older NAS and server hardware.

Community Contributions

Big thanks to @Zmegolaz for landing six PRs in this cycle, covering some of the most user-visible improvements:

  • #85 Send season and episode info to opensubtitles-scraper.
  • #86 Don't stop querying providers until all requested languages are found.
  • #87 Add a configurable "give up after X seconds" search time budget.
  • #88 Cleaner shutdown so in-flight work is allowed to drain.
  • #91 Throttle event broadcasts to max four per job per second.
  • #98 The video-preview rebuild that anchors the new Subtitle Editor experience: remux instead of full download, retry path, seek bar accuracy, audio quality, and stream-key persistence across media changes.

These PRs touch the parts of Bazarr+ users actually feel day to day, multi-language searches, long-running searches that no longer hang the queue, restarts that don't lose work, the editor that actually scrubs. Cheers, Zmegolaz.


Migration from v2.1.0

Drop-in. Pull the new image, restart.

docker pull ghcr.io/lavx/bazarr:v2.2.0
# or :latest for rolling

On first start, Bazarr+ auto-migrates your existing provider/Sonarr/Radarr/Plex API keys into the encrypted-at-rest secret store. The migration is transparent and idempotent. Existing translator settings, language profiles, and provider configurations carry over.

If you want the External Integration endpoint:

  1. Settings → External Integration → toggle on.
  2. Click Restart Bazarr+ when the banner appears.
  3. After restart, return to the page, copy the API Token.
  4. Paste the token into the Jellyfin plugin or VLSub configuration.

The OMDB refiner is opt-in via the new API-key field on the same page.


Docker

docker pull ghcr.io/lavx/bazarr:v2.2.0
docker pull ghcr.io/lavx/bazarr:latest

Docker Compose:

git clone --recursive https://github.com/LavX/bazarr.git
cd bazarr
docker compose up -d

Full Changelog: v2.1.0...v2.2.0