Set your preferred audio and subtitle languages per show — Plex applies them to every new episode automatically.
Plex lets you choose which audio track and subtitle language to use when watching a show — but that choice only applies to the episode you're currently watching. If you start a series in Japanese audio with English subtitles, you have to manually set that on every single episode, and again when new episodes arrive.
plex-language-sync eliminates that friction. It watches your Plex playback in real time and automatically propagates your audio and subtitle language choices to every other episode in the same show. Set your preference once on any episode, and the rest of the series follows — like Netflix does natively.
It also learns your habits. If you always watch anime in Japanese with English subtitles, brand new shows that arrive (via Sonarr or manual import) get those settings applied before you even press play.
Key features:
- Real-time WebSocket listener for play and library scan events
- Per-show language propagation with scored stream matching (language, codec, channel layout, title, forced, hearing impaired, visual impaired, descriptive track filtering)
- Language profiles — learns your audio→subtitle preferences from playback and applies them to brand new shows that have no watch history yet
- Subtitle codec preference — when multiple subtitle tracks match the same language, prefers ASS over image-based (PGS) over plain text (SRT)
- Configurable scope: entire show or current season only
- Configurable range: all episodes or future episodes only
- Ignore specific shows via Plex labels or entire libraries
- Scheduled daily deep analysis as a safety net
- Persistent JSON cache survives container restarts
- Multi-user support — automatically fetches shared user tokens from plex.tv, each user gets independent language preferences
- Docker secrets support (
PLEX_TOKEN_FILE)
- Single binary, one dependency. Written in Go with only one external library (
coder/websocket). No Python runtime, no YAML config files, no notification frameworks — just a ~8 MB distroless container that does one job well. - Rootless and minimal attack surface. Runs as
nonroot(UID 65534) ongcr.io/distroless/staticwith no shell, no package manager, and no inbound network listener. The only outbound connections are to your Plex server and plex.tv. - Learns, not just copies. Language profiles close the gap that upstream tools leave open: new shows get correct subtitles from day one, without requiring you to watch an episode first.
- Resilient by default. WebSocket reconnects with exponential backoff, a daily scheduler catches missed events, and a persistent cache survives restarts — so your preferences are never lost.
Images are published to both ghcr.io/cplieger/plex-language-sync and docker.io/cplieger/plex-language-sync — use whichever registry you prefer.
services:
plex-language-sync:
image: ghcr.io/cplieger/plex-language-sync:latest
container_name: plex-language-sync
restart: unless-stopped
user: "1000:1000" # match your host user
environment:
TZ: "Europe/Paris"
PLEX_URL: "http://plex:32400" # full URL including scheme and port
PLEX_TOKEN: "your-plex-token" # admin token from Plex Web settings
UPDATE_LEVEL: "show" # show = entire show, season = current season only
UPDATE_STRATEGY: "all" # all = every episode, next = future episodes only
TRIGGER_ON_PLAY: "true"
TRIGGER_ON_SCAN: "true"
LANGUAGE_PROFILES: "true" # learn and apply audio→subtitle pairs for new shows
SCHEDULER_ENABLE: "true"
SCHEDULER_SCHEDULE_TIME: "02:00"
volumes:
- /opt/appdata/plex-language-sync:/config| Variable | Description | Default | Required |
|---|---|---|---|
TZ |
Container timezone | Europe/Paris |
No |
PLEX_URL |
Full URL of your Plex Media Server including scheme and port (e.g. http://192.0.2.100:32400) |
http://plex:32400 |
Yes |
PLEX_TOKEN |
Plex authentication token for the server administrator. Get it from Plex Web → Settings → XML view → myPlexAccessToken. Also supports Docker secrets via PLEX_TOKEN_FILE |
- | Yes |
UPDATE_LEVEL |
Scope of language propagation. show applies to all episodes in the show. season applies only to the current season |
show |
No |
UPDATE_STRATEGY |
Which episodes to update. all updates every episode in scope. next updates only episodes after the one being played |
all |
No |
TRIGGER_ON_PLAY |
React to playback events — when you play an episode, propagate its language settings | true |
No |
TRIGGER_ON_SCAN |
React to library scan events — when new episodes are added, apply language settings from the show's history | true |
No |
LANGUAGE_PROFILES |
Learn audio→subtitle language pairs from playback and apply them to brand new shows that have no watch history | true |
No |
SCHEDULER_ENABLE |
Run a daily deep analysis that processes recent play history and newly added episodes as a safety net for missed real-time events | true |
No |
SCHEDULER_SCHEDULE_TIME |
Time of day (HH:MM, 24-hour) to run the daily deep analysis | 02:00 |
No |
PLEX_CA_CERT_PATH |
Path to a PEM file containing your Plex server's CA certificate. When set, that CA is added to the TLS RootCAs pool — TLS verification stays on, pinned to your CA. Required only when (a) your PLEX_URL uses https:// and (b) the cert isn't trusted by the OS bundle (i.e. you signed it yourself or with a private CA). Plain http:// URLs and Plex's official *.plex.direct HTTPS URLs need no TLS env var. |
unset | No |
Pick the configuration that matches your Plex server:
Your PLEX_URL looks like |
What to do |
|---|---|
http://plex:32400 (Docker network, LAN, etc.) |
nothing — TLS isn't in use |
https://<hash>.plex.direct:32400 (Plex's official cert) |
nothing — Let's Encrypt is trusted by default |
https://192.0.2.100:32400 or https://plex.local (self-signed / private CA) |
set PLEX_CA_CERT_PATH to the PEM file of the CA that signed your Plex cert. Mount it into the container and point the env var at the in-container path. |
| Mount | Description |
|---|---|
/config |
Persistent cache storage. Contains cache.json with processed episode tracking, learned language profiles, and scheduler state. Mount a named volume or host path to preserve data across container restarts. |
The container includes a built-in CLI health probe (/plex-language-sync health) that checks for a marker file written at /tmp/.healthy once the initial Plex connection succeeds and the admin user is verified. It requires no shell, HTTP client, or open port. The probe reports unhealthy only if the initial connection to Plex fails or the admin user cannot be resolved — WebSocket disconnects do not cause unhealthy status because the tool automatically reconnects with exponential backoff (1s→30s).
| Metric | Value |
|---|---|
| Test Coverage | 60.3% |
| Tests | 376 |
| Cyclomatic Complexity (avg) | 3.7 |
| Cognitive Complexity (avg) | 3.4 |
| Mutation Efficacy | 90.1% (59 runs) |
| Test Framework | Property-based (rapid) + table-driven |
Tests cover stream matching and scoring (audio/subtitle selection
with comprehensive input combinations), subtitle codec preference
ranking, language profile learning and application, episode
filtering, cache lifecycle with boundary tests, config loading and
validation (including Docker secrets via _FILE suffix), multi-user
token management, handler dispatch for play and scan events, XML
parsing for Plex shared server responses, WebSocket disconnect
classification with stable reason labels, backoff math with stable-
connection reset semantics, and the shared-reference cost-collapse
invariant that pins the ~93% reduction in per-episode HTTP calls as
a regression guard. Property-based tests verify scoring invariants
and panic-freedom on arbitrary input.
Not tested: WebSocket connection management, HTTP API calls to Plex, the main event loop, scheduler tick loop, and cache file I/O — these are I/O-bound runtime paths that can't be meaningfully unit tested, validated instead by Docker healthchecks and structured logging in production.
No vulnerabilities found. All scans clean across 7 tools.
| Tool | Result |
|---|---|
| govulncheck | No vulnerabilities in call graph |
| golangci-lint (gosec, gocritic) | 0 issues |
| trivy | 0 vulnerabilities (distroless base) |
| grype | 0 vulnerabilities |
| gitleaks | No secrets detected |
| semgrep | 2 info (false positives) |
| hadolint | Clean |
No inbound network listener; connects outbound to Plex and
plex.tv only. Supports Docker secrets via PLEX_TOKEN_FILE.
The Plex token is never logged or written to the cache file.
Runs as nonroot on a distroless base image with no shell.
Details for advanced users: Response bodies capped at 10 MB
via io.LimitReader. WebSocket read limit 1 MB. Cache writes
use atomic temp-file + rename. Rating keys validated as numeric
before URL construction. Explicit MinVersion: tls.VersionTLS12
set on TLS config. Shared user tokens are cached in
cache.json for offline restart; protect the /config volume
accordingly. Semgrep flags the /tmp/.healthy marker and the
opt-in TLS skip (both intentional).
All dependencies are updated automatically via Renovate and pinned by digest or version for reproducibility.
| Dependency | Version | Source |
|---|---|---|
| golang | 1.26-alpine |
Go |
| gcr.io/distroless/static-debian13 | nonroot |
Distroless |
This is an original tool that builds upon Plex-Auto-Languages.
- Plex-Auto-Languages by @RemiRigal — the original Python project that pioneered per-show language automation for Plex. The stream matching algorithm and event-driven architecture in this rewrite are directly inspired by the original design.
- Plex-Auto-Languages by @JourneyDocker — the actively maintained fork that added improved stream scoring, visual impaired track handling, and memory management fixes
- Plex Media Server API — the official API documentation
- coder/websocket — Go WebSocket implementation
Issues and pull requests are welcome. Please open an issue first for larger changes so the approach can be discussed before implementation.
These images are built with care and follow security best practices, but they are intended for homelab use. No guarantees of fitness for production environments. Use at your own risk.
This project was built with AI-assisted tooling using Claude Opus and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.
This project is licensed under the GNU General Public License v3.0.