Skip to content

cplieger/plex-language-sync

plex-language-sync

Image Size Platforms base: Distroless Test coverage Mutation OpenSSF Best Practices OpenSSF Scorecard SBOM

Set your preferred audio and subtitle languages per show — Plex applies them to every new episode automatically.

What it does

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)

Why this design

  • 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 distroless container that does one job well.
  • Rootless and minimal attack surface. Runs as nonroot (UID 65534) on gcr.io/distroless/static with no shell, no package manager, and no inbound network listener. The only outbound connections are to your Plex server and plex.tv.

Quick start

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:
      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_INTERVAL: "24h"  # deep-analysis cadence (Go duration); off/disabled/0 disables

    volumes:
      - /path/to/plex-language-sync/config:/config

Configuration reference

Environment variables

Variable Description Default Required
PLEX_URL Full URL of your Plex Media Server including scheme and port (e.g. http://192.0.2.100: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_INTERVAL Cadence of the daily deep-analysis safety net, a Go duration (e.g. 24h, 12h). off/disabled/0 disables it (the app then runs WebSocket-only). 24h 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
IGNORE_LABELS Comma-separated Plex labels that exclude a show from language sync (a show carrying any listed label is skipped) PAL_IGNORE,PLS_IGNORE No
IGNORE_LIBRARIES Comma-separated Plex library names to exclude from language sync entirely unset No
DEBUG Enable debug-level logging (true/1/yes/on). Raises log verbosity for troubleshooting; leave unset for normal INFO-level output false No

TLS / certificate setup

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.

Volumes

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.

Graceful shutdown

On SIGTERM/SIGINT the container first drains its background loops (user-token refresh + daily scheduler) for up to 10 seconds, then writes /config/cache.json and exits. Give the container a stop grace period comfortably longer than that drain window so the final save is never truncated by SIGKILL — for example stop_grace_period: 20s on the compose service (Docker's default is only 10s, which leaves no headroom for the save once the drain budget is spent). A truncated save is recoverable — the cache is rebuilt from Plex over time and the daily deep-analysis scheduler re-learns profiles — but a clean save preserves your latest preferences immediately.

Alerting

plex-language-sync has no metrics endpoint; its operational state is in its logs. Ship the container's logs to Loki (Grafana Alloy's Docker log discovery does this with no configuration) and evaluate this rule with Loki's ruler; firing alerts deliver through your Alertmanager exactly like Prometheus metric alerts.

groups:
  - name: plex-language-sync
    rules:
      - alert: PlexLanguageSyncErrorLog
        expr: |
          sum by (container) (count_over_time(
            {container="plex-language-sync"} |= `level=ERROR` [15m]
          )) >= 3
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "plex-language-sync emitting repeated ERROR logs"
          description: >
            plex-language-sync logged 3 or more ERROR lines in 15m
            (sustained 5m). At ERROR level the app reports only its hard
            failures: an unreachable Plex server or a bad PLEX_TOKEN at
            startup (it logs the error and exits, so a misconfigured
            container crash-loops), and a WebSocket connection that keeps
            failing to reconnect once the outage is sustained. The
            healthcheck deliberately ignores WebSocket state (the listener
            auto-reconnects with backoff), so this alert is the only
            signal that the container is up but silently processing
            nothing. The known-benign "failed to refresh shared user
            tokens" is logged at WARN, so filtering on level=ERROR
            excludes it.

The threshold, window, and severity label are starting points; adjust the container selector to your deployment and route by whatever labels your Alertmanager uses.

Healthcheck

The container includes a built-in CLI health probe (/plex-language-sync health) that checks for a marker file written at /tmp/.healthy. It requires no shell, HTTP client, or open port.

Startup distinguishes fatal from transient failures. A fatal misconfiguration — a bad PLEX_TOKEN (401/403), a wrong-server URL (404), or a TLS/certificate error — exits non-zero, so the container never goes healthy and the problem stays loud. A transient failure — Plex unreachable or still starting at boot (connection refused, DNS/timeout, or a 5xx) — starts the container healthy in a degraded state and keeps retrying the initial connection with capped exponential backoff (1s→30s) instead of crash-looping under the restart policy; the marker is set as soon as the failure is classified transient, and normal operation begins once Plex answers and the admin user is resolved. WebSocket disconnects after the initial connection never cause unhealthy status either — the listener reconnects with the same backoff.

Security

No vulnerabilities found. All scans clean across all CI security 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).

Dependencies

All dependencies are updated automatically via Renovate and pinned by digest or version for reproducibility.

Dependency Source
golang Go
gcr.io/distroless/static:nonroot Distroless

Credits

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

Contributing

Issues and pull requests are welcome. Please open an issue first for larger changes so the approach can be discussed before implementation.

Disclaimer

This project is built with care and follows security best practices, but it is intended for personal / self-hosted 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.

License

This project is licensed under the GNU General Public License v3.0.

About

Set preferred audio and subtitle languages per show — every new episode follows automatically

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages