Skip to content

Latest commit

 

History

1,043 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

subflux

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

A fast, small subtitle search, download, and sync engine for Sonarr and Radarr. A Go-based Bazarr alternative that ships as a ~14 MB container.

⚠️ Alpha software

Subflux is pre-1.0 and under active development. It is functional and runs a 52,000-episode library in production today, but rough edges remain. Any update can introduce a breaking change. The config format, the on-disk state, the API, and behavior can all change between releases. Pin a specific image tag instead of latest, and check the release notes before upgrading. See ROADMAP.md for the path to 1.0.

What it does

Subflux finds, scores, downloads, and time-syncs subtitles for your Sonarr/Radarr library. It watches the *arr import history (default 30 s poll) so new downloads get subtitles within moments of importing, and runs scheduled full-library scans (default 24 h) to fill gaps and upgrade what's already there. Every result passes an identity check and a release-quality score; the best one is downloaded, synced against the video's own timing, cleaned up, and saved next to the media file. A web UI shows per-show coverage at a glance and lets you search, pick, and visually sync subtitles by hand when you want control.

Why subflux

Subflux was born from debugging Bazarr consuming 15-20 GB of RAM on a 52,000-episode library (a CPython allocator fragmentation problem, architectural rather than fixable). The answer was a rewrite with resource discipline as a design goal rather than an optimization pass:

  • ~14 MB compressed image, ffmpeg included. Distroless base, one static Go binary, no Python, no runtime dependencies.
  • The library that broke Bazarr runs in a 1 GB container limit. Arr responses are batch-fetched then iterated (the largest payload, 4,360 movies, decodes to 24 MB); goroutine pools are bounded; media probing streams instead of buffering.
  • A purpose-built ffmpeg (~5 MB, plus ~2 MB ffprobe): decoders for every mainstream video, audio, and subtitle codec, a single x264 encoder for the 360p preview, statically linked, no network support compiled in. It does track detection, subtitle/audio extraction, and the sync editor's live preview.
  • One file of state. Pure-Go bbolt (no SQLite, no CGO): crash-durable on commit, hot-backed-up on schedule, and reconciled against the filesystem so it heals itself after manual file changes.
  • amd64 + arm64 images, cosign-signed, with SBOM attestations.

Features

Search and scoring

  • Eight online providers (OpenSubtitles, Gestdown, SubSource, SubDL, BetaSeries, AnimeTosho, YIFY Subtitles, HDBits) behind one interface, plus always-on embedded-track detection (local ffprobe inspection, not a provider).
  • Two-phase scoring: a hard identity gate (IMDB/TVDB/TMDB id, season/episode, title validation) before a 0-100 release-quality score used for ranking and upgrades. Upgrades replace an existing subtitle only when a strictly better release shows up.
  • Language rules keyed on the audio track: map detected audio languages to subtitle targets (Japanese audio can want different subs than English audio), with standard/forced/hi variants and per-target provider or min-score overrides.
  • Anime-aware numbering: searches run with aired, scene (TheXEM), and absolute (TVDB) numbering and merge the results, so long-running shows with weird episode orders still match.
  • Embedded-subtitle awareness: coverage counts text and bitmap tracks already in the container (SRT/ASS, PGS, VobSub, DVB), with per-codec ignore settings (top-level embedded_subtitles config section) so an unwanted PGS track doesn't stop the search for a text alternative.
  • Adaptive backoff: per-provider exponential backoff for no-result media, season-level early termination, and per-provider timeouts, so failing or empty providers don't get hammered.
  • Manual override with locks: manual downloads are saved as numbered siblings (movie.fr.1.srt) and lock the item from automation; locks clear automatically when the files are deleted or the video is replaced.

The sync engine

Downloaded subtitles rarely match your exact file, so subflux syncs every download before it reaches the disk. The engine is a from-scratch Go port of alass plus subflux's own additions: split-aware and framerate-aware alignment, audio sync via a re-tuned voice-activity detector cross-correlated with the subtitle's dialogue signal, and cross-language anchor matching (a French subtitle can sync against the English track embedded in the file). The strategies run concurrently and vote; the winner is applied only above a confidence threshold, so a sync that is not confident does not happen.

Hash-matched and same-release downloads skip sync (their timing is already right), and forced subtitles skip it (too few cues to align reliably). Every saved file is post-processed: encoding normalization to UTF-8, hearing-impaired annotation removal, tag stripping, and whitespace cleanup. Auto-downloads sync against an embedded subtitle reference when the file has one (audio-based sync as an automatic fallback is opt-in); audio sync and manual offset adjustment are always available from the sync dialog.

The web UI

A single-page app served by the same binary: framework-free TypeScript compiled to ~380 KB of first-party JS, loaded as native ES modules, updating live over SSE.

  • Coverage table: every series and movie against your language rules, with per-target have/total badges, embedded-track counts, a missing-only filter, and text search.
  • Visual sync editor: subflux transcodes the actual video to a 360p stream on the fly (fMP4 over MSE, using the bundled ffmpeg) and renders the subtitle as a live caption track. Scrub the offset with a timecode control and the captions reload in place, so you verify timing with your own eyes; or run any sync strategy (embedded reference, external file, audio VAD) and preview its computed result before a byte is written.
  • Manual search: query all providers for any item, see each result's score breakdown and tier, and grab a specific pick. Downloading anything other than the top pick locks the item from automation until you release it.
  • Schema-driven settings: the entire config renders from a server-generated schema with tooltips; saves are validated, hot-reload the engine without a restart, and never echo secrets back to the browser.
  • History: every download and search attempt, filterable by type, language, and provider.

First run lands in unconfigured mode: the settings dialog auto-opens and the instance serves nothing else until a valid config is saved.

Auth, API, and operations

  • Multi-user auth (optional): local passwords (Argon2id), passkeys/WebAuthn, OIDC with PKCE, per-user API keys, and login rate limiting.
  • API and CLI: the UI drives a JSON API you can use too; the CLI's read and trigger subcommands run against a live instance (subflux search, scan, status, locks, backoff, score, ...) authenticated via SUBFLUX_URL / SUBFLUX_API_KEY. The account bootstrap commands (reset-password, generate-api-key) use a private Unix socket, so run them inside the container.
  • Operations: Prometheus metrics at /metrics, structured slog logging (UTC), a distroless file-marker healthcheck, graceful shutdown, scheduled bbolt hot backups with a staleness metric, database reconciliation before each scan, and scan resume after a restart.

Quick start

Images are published to both ghcr.io/cplieger/subflux and docker.io/cplieger/subflux; use whichever registry you prefer.

# compose.yaml
services:
  subflux:
    image: ghcr.io/cplieger/subflux:latest
    container_name: subflux
    restart: unless-stopped
    # Override with PUID/PGID in .env; defaults to 1000:1000.
    user: "${PUID:-1000}:${PGID:-1000}"  # match your host user
    ports:
      - "8374:8374"
    volumes:
      - "/opt/appdata/subflux:/config"  # config.yaml + bbolt state
      - "/path/to/media:/media"         # must NOT be read-only; subflux writes subtitle files

First run

Open http://localhost:8374. Every first boot runs ONE guided flow: create the admin account, then walk the setup wizard (Sonarr/Radarr, media roots, providers, languages, and tunable defaults), then an optional passkey enrollment. The wizard adapts to what it finds:

  • From scratch: subflux wrote a placeholder config on first boot, so the wizard walks every step with sensible defaults prefilled.
  • Pre-authored config.yaml: every step the file already answers is prefilled and collapsed (saved secrets show as present without exposing values); a fully valid config fast-forwards straight to a review screen with a "Finish" button. Collapsed steps stay reviewable and editable before finishing.

The Sonarr and Radarr step carries a Test connection button that checks the URL and API key from the server side and reports the answer at the field. It is optional — leave it alone and Next moves on — but using it catches a wrong URL or a rejected key on the first step rather than at the final save. The same button is on both sections of the settings dialog afterwards; where a key is already saved, testing uses the stored one, so there is nothing to retype. The check runs from subflux rather than from your browser on purpose: url is subflux's own address for the service (the default is a Docker service name, which is why public_url exists separately for browser links), so subflux is the only party that can answer whether the scanner will reach it.

Reloading the page mid-setup returns you to the step you were on rather than dropping you into the app.

Finishing saves the config and activates everything in place (providers, arr clients, background scans, and auth capabilities) with no restart. The same holds for later edits in the settings dialog: saving a valid config hot-activates it, including WebAuthn/OIDC and logging changes.

Configuration reference

All settings are editable in the web UI (schema-driven form) and persist to config.yaml. To pre-author the file instead, copy the annotated config.example.yaml. The CLI's subcommands, including manual search, run against a running instance via the SUBFLUX_URL env var; set SUBFLUX_API_KEY (created in the web UI, or with subflux generate-api-key inside the container) to authenticate them when auth is enabled.

Environment variables in config.yaml

String values in config.yaml can reference environment variables with the braced ${VAR} form, so secrets stay in the container environment while the file holds the structure:

some_api_key: ${SUBFLUX_SOME_KEY}

Expansion is allowlisted: SUBFLUX_* names plus the common deployment vars CONFIG_ROOT, MEDIA_FOLDER, PUID, PGID, TZ, LAN_IP, and HOSTNAME. Any other name, and the unbraced $VAR form, stays literal. Referencing an allowlisted var that is unset logs a startup warning naming the variable and keeps the literal ${VAR} text (a set-but-empty var substitutes the empty string). Expansion runs after YAML parsing, on string values only, so an environment value can never alter the document structure.

Running behind a reverse proxy

When subflux runs behind a reverse proxy (nginx, Caddy, Traefik, HAProxy, ...), the network peer subflux sees is the proxy, not the browser. Set trusted_proxies to the proxy's IP or CIDR so the real client IP, resolved from a trusted X-Forwarded-For header, is used for the audit log, the login rate limiter, the session IPAddress, and the request access log, instead of the proxy's address:

trusted_proxies:
  - 10.0.0.0/8
  - 192.168.0.0/16

Entries are CIDR ranges; write a single proxy as a /32 (IPv4) or /128 (IPv6). Only when the direct peer is one of these ranges is X-Forwarded-For consulted (walked right-to-left, spoof-safe); invalid CIDRs are rejected at config load. Leave trusted_proxies empty (the default) when subflux is directly exposed: the socket peer is used and X-Forwarded-For is ignored.

Blocking DNS rebinding

allowed_hosts lists the exact hostnames or IPs subflux answers for. A request whose Host header is not on the list is rejected with 403 before it reaches any route:

allowed_hosts:
  - subflux.example.com
  - 192.0.2.5

This closes a gap the cross-origin (CSRF) check alone leaves open: a DNS-rebinding attack makes a malicious page's hostname resolve to subflux's address, so the browser's same-origin request carries the attacker's name in both Origin and Host; they agree, so the CSRF check admits it. Only an exact-match Host check breaks that chain. Requests from localhost (the container healthcheck) always pass regardless of the list. Leave allowed_hosts empty (the default) to accept any Host, matching prior behavior.

Alerting

subflux exposes Prometheus metrics on /metrics. Scrape it and evaluate these with Prometheus or the Mimir ruler; delivery is through your Alertmanager.

groups:
  - name: subflux
    rules:
      # The floor under every other rule here: all of them read a subflux metric,
      # so all of them go quiet together when subflux stops being scraped.
      #
      # Two arms, because neither covers the other. `up == 0` catches a target
      # that is configured and failing, and keeps its labels. `absent(up{...})`
      # catches a target that stopped EXISTING — a dropped scrape target, a
      # removed scrape config, a deleted Kubernetes pod or ServiceMonitor —
      # where `up` has no series and `up == 0` cannot match. Use an EXACT job
      # matcher: a regex absent() form asks whether ANY matching target is up,
      # so one healthy replica masks every failed one, and its synthetic result
      # carries no job label to route on.
      - alert: SubfluxTargetDown
        expr: up{job="subflux"} == 0
        for: 15m
        labels:
          severity: warning
        annotations:
          summary: "subflux is not being scraped successfully"
          description: >
            No successful scrape of subflux for 15m, so every rule in this group
            is blind. Either the scrape is failing (container down, wrong port,
            network) or the target is gone from service discovery entirely. Set
            the job matcher to whatever your scrape config calls subflux.
      - alert: SubfluxTargetAbsent
        expr: absent(up{job="subflux"})
        for: 15m
        labels:
          severity: warning
        annotations:
          summary: "subflux has no scrape target at all"
          description: >
            There is no up{job="subflux"} series, so subflux is not merely failing
            to scrape but is no longer a configured target: a dropped scrape
            target, a removed scrape config, or a deleted Kubernetes pod or
            ServiceMonitor. Every other rule in this group is blind.
      # The deadman for the scan loop. subflux can be up, scraped and answering
      # HTTP while its scheduled scan has been wedged for days, and nothing else
      # here notices.
      #
      # The uptime guard is load-bearing, not decoration. A range selector does
      # NOT require the series to have existed for the whole range: after a
      # restart, increase(subflux_scans_total[26h]) reads 0 as soon as there are
      # two samples, so without the guard this fires about `for:` after every
      # restart rather than 26h after the last scan. The guard says the process
      # has actually been up long enough for the window to mean anything.
      # subflux_configured keeps it quiet in unconfigured mode, where there is
      # no engine and no scan to miss.
      - alert: SubfluxScanStalled
        expr: >
          subflux_configured == 1
          and increase(subflux_scans_total[26h]) == 0
          and (time() - process_start_time_seconds) > 93600
        for: 2h
        labels:
          severity: warning
        annotations:
          summary: "subflux has not completed a scan in over a day"
          description: >
            subflux is configured, has been up for more than 26h, and no
            scheduled full scan has completed in that window (scan_interval
            defaults to 24h). The scheduler may be stalled, a scan may be stuck
            mid-run, or the arrs may be unreachable. Check the subflux logs and
            /api/activity. A scan that legitimately runs longer than the window
            will also trip this; widen the range and the uptime guard together
            if that is your normal.
      - alert: SubfluxHTTP5xx
        expr: sum(increase(subflux_http_requests_total{status=~"5.."}[10m])) > 5
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "subflux is returning HTTP 5xx"
          description: >
            subflux returned more than 5 server errors in 10m. Check upstream
            connectivity, provider config, and the subflux logs.
      - alert: SubfluxBackupStale
        expr: >
          subflux_backup_last_success_timestamp > 0
          and (time() - subflux_backup_last_success_timestamp) > 172800
        for: 1h
        labels:
          severity: warning
        annotations:
          summary: "subflux backup is stale"
          description: >
            No successful subflux backup recorded in over 48h. Check the backup
            task and the /config volume.

Thresholds are starting points; add your scrape job label to the selectors if you run more than one instance, and route by whatever labels your Alertmanager uses.

Healthcheck

The container probe runs subflux health, which reports healthy while the marker file /tmp/.healthy exists. Subflux creates it once the HTTP server is listening, in unconfigured mode as well as configured mode, and removes it on shutdown. So unhealthy means the process is gone or never finished starting, not that a scan failed or a provider is down: a bad provider key leaves the container healthy and serving the web UI so you can correct it. Docker checks every 30s after a 15s grace period and restarts the container after 3 consecutive failures. GET /api/health is a separate, narrower signal for a load balancer, answering whether the server is ready to serve requests.

Security

Distroless gcr.io/distroless/static-debian13:nonroot (UID 65532, no shell). Provider URLs are validated against SSRF before every fetch; secrets are redacted from config API responses; archive extraction is zip-bomb-guarded; all external input is size-capped and validated. Images are published with cosign signatures and SBOM attestations.

Known limitations

  • The media volume must be writable. Subflux saves subtitle files next to the media, so mounting /media read-only silently prevents downloads from being saved.
  • Cloudflare-protected providers (subf2m, AvistaZ, CinemaZ) are not implemented.
  • Long-running anime with colliding aired/absolute numbering has a rare false-positive window: results matched by a stable ID skip title validation, so an aired SxxEyy that collides with another episode's absolute number can slip through.

Credits

  • alass by @kaegi: the subtitle alignment algorithm (constant-offset rating and split-aware DP) that subflux ports to Go.
  • The WebRTC project: the GMM voice-activity detector that subflux ports and re-tunes for film audio.
  • Bazarr: the project that defined this category; subflux is an original engine, not a fork, but Bazarr set the bar for what it has to do.

Contributing

Issues and pull requests are welcome; please open an issue first for larger changes so the approach can be discussed. Architecture notes and local build/test instructions are in CONTRIBUTING.md; operational runbooks live in docs/OPERABILITY.md.

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, GPT, and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.

License

AGPL-3.0-or-later. See LICENSE.

About

Subtitle search and download engine for Sonarr/Radarr — multi-provider, with built-in subtitle sync

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

4 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages