No description
  • Python 99.1%
  • Shell 0.6%
  • Dockerfile 0.2%
Find a file
Tobias Hertel 37faba8cae
All checks were successful
Build Docker Images / build (push) Successful in 1m37s
fix(playback): bypass rewrites for live tv
2026-11-07 09:58:29 +01:00
.forgejo/workflows ci(docker): build consolidated deployment images 2026-09-21 21:02:42 +02:00
deploy fix(recommendations): compose cross-library home rows 2026-10-18 03:55:53 +02:00
migrations feat(recommendations): add personalized acquisition suggestions 2026-09-25 09:00:19 +02:00
src/plex_middleware fix(playback): bypass rewrites for live tv 2026-11-07 09:58:29 +01:00
tests fix(playback): bypass rewrites for live tv 2026-11-07 09:58:29 +01:00
.dockerignore feat(proxy): add nginx TLS compose edge 2026-09-20 12:27:39 +02:00
.env.example fix(security): use Plex loopback TLS identity 2026-10-11 22:09:06 +02:00
.gitignore feat(proxy): add nginx TLS compose edge 2026-09-20 12:27:39 +02:00
alembic.ini feat(clients): add persistent capability registry 2026-09-20 10:35:45 +02:00
ARCHITECTURE.md feat(recommendations): add themed and streaming discovery rows 2026-10-24 10:16:42 +02:00
CHANGELOG.md fix(playback): bypass rewrites for live tv 2026-11-07 09:58:29 +01:00
config.example.yml feat(recommendations): add themed and streaming discovery rows 2026-10-24 10:16:42 +02:00
docker-compose.ula.yml feat(proxy): support LAN-routed Plex upstream 2026-09-20 16:21:06 +02:00
docker-compose.yml fix(security): use Plex loopback TLS identity 2026-10-11 22:09:06 +02:00
Dockerfile feat(clients): add persistent capability registry 2026-09-20 10:35:45 +02:00
LICENSE feat(observer): add passive Plex session observer 2026-09-20 10:14:16 +02:00
pyproject.toml feat(clients): add persistent capability registry 2026-09-20 10:35:45 +02:00
README.md feat(recommendations): add themed and streaming discovery rows 2026-10-24 10:16:42 +02:00
ROADMAP.md feat(recommendations): add themed and streaming discovery rows 2026-10-24 10:16:42 +02:00
uv.lock feat(clients): add persistent capability registry 2026-09-20 10:35:45 +02:00

plex-middleware

plex-middleware is an intelligent playback middleware for Plex. Development is intentionally incremental. The implementation covers Phase 1A through Phase 1K, shared homepage rows through Phase 2B, and the first admin-scoped Phase 2C discovery surface: observation, capability evidence, media analysis, explainable dry-run decisions, guarded enforcement, version/audio/subtitle selection, network policy, failure learning, and the minimal operational API. All playback-changing features remain opt-in.

Current capabilities

  • Reads active playback from Plex /status/sessions.
  • Normalizes the user, client, item, media, selected streams, and playback decisions.
  • Uses Plex notifications as an experimental refresh signal with polling as the reliable fallback.
  • Emits structured JSON session lifecycle logs without Plex tokens.
  • Persists clients, exact playback observations, and provenance-aware capability evidence in SQLite.
  • Keeps reported identity, observed evidence, and manual overrides distinct; manual overrides have highest priority.
  • Normalizes every playable media-version/part pair and its video, audio, and subtitle streams into Plex-independent models.
  • Preserves media and part indexes while deriving conservative codec, HDR, Dolby Vision, spatial audio, language, forced-subtitle, and SDH fields.
  • Generates every part-safe video/audio/subtitle candidate, evaluates compatibility before scoring, and emits structured reason codes for every score.
  • Persists dry-run Plex chose X / middleware would choose Y comparisons with confidence and a safe preserve_plex result for unknown clients.
  • Transparently forwards the exact Universal Transcoder decision endpoint, preserving unknown request data and changing only an explicit high-confidence media/part/audio/subtitle selection.
  • Scores subtitle-off, forced, preferred-language, and SDH choices while strongly penalizing any subtitle that would require video transcoding.
  • Resolves LAN, WAN, and mobile policies with combined bitrate ceilings and configurable highest-quality or low-bandwidth preferences.
  • Learns exact client-specific candidate failures from repeated stalls, expires their penalties, and bounds retry eligibility without forcibly restarting clients.
  • Exposes GET /health, GET /api/sessions, GET /api/clients, and GET /api/clients/{identifier} for debugging. GET /api/media/{rating_key} returns normalized media candidates without file-system paths. GET /api/decisions and GET /api/decisions/{decision_id} expose explainable decisions. GET /api/failures and GET /api/config expose failure evidence and sanitized configuration.
  • Compares and optionally reconciles documented native Plex Home/Recommended rows from validated YAML, with dry-run drift available at GET /api/recommendations.
  • Records meaningful completed playback sessions and builds explainable shared rows for Top 10, Trending, Popular, Critically Acclaimed, Highly Rated & Unwatched, Hidden Gems, duration, decade, and Binge-Worthy policies. Generated membership is previewed before enforcement.
  • Provides a read-only /debug inspector and Prometheus-compatible /metrics counters.
  • Provides an admin-token-protected /debug/suggestions page with per-Plex-user missing-title recommendations and confirmed, idempotent Radarr/Sonarr additions.
  • Atomically reloads validated playback policy, scoring, client overrides, logging, network, and enforcement settings while pinning active sessions to their original configuration generation.

Observations are evidence, not universal device-model assumptions.

The decision engine does not infer unsupported capabilities from absence in observed evidence. Successful observed values are positive/open evidence and may drive a decision when every required codec and container for that exact candidate has been proven. Unobserved values remain unknown; only manual overrides and configured device-rule assumptions are treated as closed capability sets. This lets a learned client select a proven alternate stream without making a device-model-wide assumption, while a new or incompletely observed client safely preserves Plex's selection.

Experimental audio control requires both playback.enforcement_enabled: true and audio.client_control_enabled: true. The service also verifies that Plex currently exposes the target client as present and remotely controllable. It never changes the media item's persistent audio-stream selection; a missing or ambiguous client simply preserves Plex behavior.

The decision proxy has two independent switches. proxy.decision_interception_enabled observes and persists request-time decisions without changing the request. proxy.request_enforcement_enabled permits explicit parameter changes only when global enforcement is also enabled. Keep both false until dry-run results for your clients are trustworthy.

The operational API and /debug expose playback metadata and should be bound to a trusted network or protected by the edge proxy. The Suggestions API additionally requires a separate admin bearer token. No endpoint returns Plex, TMDB, Radarr, Sonarr, or admin credentials.

Local development

Python 3.13 and uv are recommended.

cp config.example.yml config.yml
export PLEX_TOKEN='your-token'
uv sync --extra dev
uv run plex-middleware

Run the quality gates:

uv run pytest
uv run ruff check .
uv run ruff format --check .
uv run mypy

Schema migrations run automatically at startup. They can also be run explicitly:

uv run alembic upgrade head

Manual overrides are keyed by the Plex Client Identifier. Each capability may be a scalar or a list:

clients:
  overrides:
    C672FAC8-CC00-4EA6-9862-6D993048946A:
      model: Living Room Audio Path
      capabilities:
        audio.codec: [eac3, aac]
        audio.max_channels: 6
        video.dolby_vision: false

Overrides affect the registry's effective view but never delete the original reported or observed evidence.

Capabilities used for confident dry-run decisions can be configured without device-specific code:

clients:
  overrides:
    your-plex-client-identifier:
      capabilities:
        video.codec: [hevc, h264]
        video.profile: [main, main 10]
        video.hdr_format: [sdr, hdr10, dolby_vision]
        video.max_width: 3840
        video.max_height: 2160
        audio.codec: [aac, eac3, dts_hd_ma]
        audio.max_channels: 8
        media.container: [mkv, mp4]

Plex tokens are secrets. Use the environment or an uncommitted .env; never place a real token in tracked configuration. The observer authenticates ordinary API requests via headers. The isolated experimental WebSocket adapter uses the token query mechanism established by Python-PlexAPI, but never logs its URL or exception details.

Configuration reload

The service watches config.yml by default. A complete, valid edit is loaded atomically and applies to new sessions; active sessions retain the settings generation they started with, so current Direct Play streams and transcodes are not reselected. Invalid or partial writes retain the last-known-good settings and appear in structured logs plus the runtime object returned by GET /api/config.

Recommendation changes are independent of playback session policy. A valid reload schedules their fail-isolated reconciliation immediately after the new configuration generation is published.

Plex connection settings (including TLS verification), the API listener, storage URL, upstream timeout, and nginx proxy.force_https require a container restart. These changes are rejected during hot reload with only the affected field names logged. Configure the watcher with:

config_reload:
  enabled: true
  interval_seconds: 1

Native Plex homepage rows

Phase 2A manages existing Plex recommendation hubs and can optionally compose Plex Web's separate pinned-source Home responses. Set recommendations.enabled: true to inspect configured libraries in dry-run mode through /api/recommendations. Only recommendations.enforcement_enabled: true writes visibility and ordering changes to Plex.

Configured rows are ordered first in YAML order. Rows not listed remain unchanged and keep their relative order. The disabled-by-default example places Plex's built-in Recently Added hub first in each movie/TV library. Plex Web fetches pinned libraries separately, so native PMS moves alone cannot produce an absolute cross-library order. The library must be favorited/pinned in a client before its rows can appear there, and Plex clients may need a refresh after a change.

For Plex Web, home_order_sections and home_order opt into exact cross-library composition:

recommendations:
  enabled: true
  home_order_sections: [1, 2]
  home_order:
    - movie.recentlyadded
    - tv.recentlyadded
    - movie.topunwatched
    - custom.collection.1.47917
    - tv.startwatching

The first section becomes the managed response. Matching hubs from every listed section are moved there in configured order and removed from later source responses. Prefix matching supports PMS's suffixed hub identifiers. Before changing anything, the adapter fetches every listed source and requires every selector to match exactly once; otherwise it returns Plex's original response. It activates only for Home requests whose pinnedContentDirectoryID contains every configured section, so library Recommended pages retain normal Plex behavior. These settings are hot-reloaded.

An optional non-negative per-library order gives managed rows an explicit position independent of YAML list order. Rows with order are placed first from lowest to highest; rows without it follow in their existing YAML order.

Generated shared homepage rows

Phase 2B creates regular, explicitly ordered Plex collections for rows that PMS does not provide natively. Enable recommendations.generated_rows_enabled first while leaving recommendation enforcement disabled. /api/recommendations and /debug then show every candidate title, score, and structured reason without writing to Plex.

Writing requires all four switches:

recommendations:
  enabled: true
  generated_rows_enabled: true
  enforcement_enabled: true
  experimental_collection_creation_enabled: true

Collection creation is marked experimental because PMS documents collection membership and custom hub endpoints, but does not publish the creation request used by Plex Web and Python-PlexAPI. The adapter verifies creation immediately, records the resulting collection as middleware-owned, and will never adopt or overwrite an existing title collision. Existing collection membership is snapshotted and restored after a partial update failure. Rows with fewer than minimum_items keep their last good collection unchanged.

Periodic reconciliation is idempotent: one recommendation-hub snapshot is reused for every generated row in a library, and Plex receives no visibility or membership write when the row is already synchronized. This avoids unnecessarily invalidating PMS's homepage hub cache. The nginx edge supports HTTP/2 for parallel homepage metadata and artwork requests and records token-safe upstream timing fields for diagnosing slow hub generation. Authenticated homepage responses are never proxy-cached because Continue Watching and other hubs are user-specific.

smart_studio rows are the exception to materialized membership. They create a native Plex smart collection containing an OR-filter over exact Plex studio names and sort=random. PMS evaluates that filter whenever the homepage hub is requested, so the order changes on every reload and newly added matching titles become eligible without waiting for the middleware reconciler. The hourly reconciler only verifies the filter definition, title, and homepage visibility. For example:

- key: disney-pixar-classics
  title: Disney & Pixar Klassiker
  algorithm: smart_studio
  source: plex
  media_type: movie
  studios:
    - Pixar
    - Walt Disney Animation Studios
    - Walt Disney Feature Animation
    - Walt Disney Pictures
    - Walt Disney Productions
  limit: 20
  minimum_items: 5

Smart-collection creation and filter updates use Plex's unpublished filter-URI contract, remain behind experimental_collection_creation_enabled, and are verified after every write. Existing unmanaged collections and regular generated rows are never converted.

catalog_filter rows combine normalized Plex metadata without title matching. Supported criteria include any/excluded genres, exact studios, content ratings, countries, duration, maximum episode or season counts, minimum rating, and owner-unwatched state. active_months hides only the hub outside its season and keeps the last good collection intact. This supports rows such as Kinderfilme, Kinderserien, Deutsches Kino, short episodes, one-season shows, Halloween, Christmas, and summer holidays.

Activity-based rows begin empty and become useful after the observer has recorded real playback. Top 10 counts unique users and completions to prevent one person's repeated starts dominating the ranking. Trending measures recent activity against the preceding window; Popular uses longer-term activity. Unwatched metadata currently reflects the server-owner token's Plex view state, so those rows must not be described as personalized for shared users.

Continue Watching, Next Episode, and Recently Added remain Plex-native. Picked for You, Because You Watched, Watch Again, favorite genres, and other genuinely personalized membership are deferred to Phase 2C because a published Plex collection has one server-wide membership list.

TMDB-backed shared rows

TMDB can optionally provide the ordering for Trending, Popular, and Top Rated rows while Plex continues to provide the playable library items and their displayed metadata. Put the read-access token only in the uncommitted deployment .env:

TMDB_READ_ACCESS_TOKEN=

Then enable the provider and choose it on individual generated rows:

recommendations:
  tmdb:
    enabled: true
    preferred_languages: [de-DE, en-US]
    region: DE
    cache_ttl_seconds: 28800
    stale_cache_seconds: 604800
    max_pages: 3
  libraries:
    - section_id: 1
      generated_rows:
        - key: top-10-movies-today
          title: Top 10 Filme heute
          algorithm: top_10
          source: tmdb_trending
          tmdb_time_window: day
          media_type: movie
          limit: 10
          minimum_items: 3

Available external sources are tmdb_trending, tmdb_popular, and tmdb_top_rated. TMDB is queried in the configured language order, so this example prefers German and falls back to English. Native Plex collections have one server-wide title and membership list: their row title comes from YAML, and each poster's displayed title remains the localized title in the Plex library. Per-client row-language switching would require rewriting client hub responses and is not part of this safe native-collection implementation.

tmdb_watch_provider resolves provider names from TMDB's current catalog for the configured region and filters by flatrate, free, ads, rent, or buy; numeric provider IDs are never hardcoded. tmdb_discover accepts reviewed TMDB keyword IDs for award and seasonal rows. Both use the same persistent eight-hour cache, stale fallback, localized-title fallback, and exact TMDB-ID matching as other external rows. Watch-provider data is supplied by JustWatch through TMDB.

- key: netflix-de
  title: Jetzt bei Netflix
  algorithm: popular
  source: tmdb_watch_provider
  tmdb_watch_provider: Netflix
  tmdb_monetization_types: [flatrate]
  media_type: movie
  limit: 20
  minimum_items: 3

Matching is fail-closed. The middleware requests Plex GUIDs and accepts only an exact TMDB ID match; equal title/year text is never enough for an enforced row. Unavailable or unmatched TMDB results are skipped. Every TMDB GET response is cached in SQLite for eight hours and survives container restarts. A bounded stale response may be reused during an outage, and a provider failure never empties the last good Plex collection. This product uses the TMDB API but is not endorsed or certified by TMDB.

Personalized missing-title suggestions

Phase 2C begins with a separate admin surface rather than rewriting Plex client hub responses. Native Plex collections have global membership, so /debug/suggestions selects a Plex account and uses that account's imported PMS history plus middleware-observed playback as recommendation seeds. Titles already present in Plex are excluded. Profiles with no usable seeds fall back to localized TMDB trending results, and every result carries structured ranking reasons.

Enable it only after setting a strong admin token. Arr credentials remain environment-only:

PLEX_MIDDLEWARE_ADMIN_TOKEN=
RADARR_API_KEY=
SONARR_API_KEY=
suggestions:
  enabled: true
  movie_section_id: 1
  show_section_id: 2
  localization:
    default_locale: de-DE
    fallback_locale: en-US
    supported_locales: [de-DE, en-US]
  radarr:
    enabled: true
    base_url: http://172.31.0.234:7878
    quality_profile_id: 1
    root_folder_path: /movies
  sonarr:
    enabled: true
    base_url: http://172.31.0.233:8989
    quality_profile_id: 1
    root_folder_path: /tv

The browser retains the admin token only in session storage and sends it as a bearer token. Adding a title always requires confirmation, validates the configured root/profile, checks for an existing entry, adds it as monitored, and asks Radarr or Sonarr to search immediately. A failed discovery or Arr request cannot affect Plex playback or shared homepage reconciliation.

Streaming-provider discovery

/debug/streaming is a separate authenticated admin page for titles offered by configured German streaming providers but missing from Plex. Netflix, Disney+, Prime Video, Apple TV+, Joyn, and WOW are included by default. The page switches between films and series, localizes German with the configured English fallback, excludes exact TMDB IDs already in Plex, displays Radarr/Sonarr state, and requires explicit confirmation before every add-and-search action.

It reuses PLEX_MIDDLEWARE_ADMIN_TOKEN; TMDB and Arr credentials are never returned. Provider display names, exact TMDB names, and monetization types are configured under suggestions.streaming_providers. Joyn defaults to free plus ads; subscription providers use flatrate.

Client playback rules

Ordered client rules apply policy by Plex client identifier, device, model, platform, or product. Matchers are case-insensitive globs, all populated match fields must match, and the first enabled rule wins. For example, the shipped configuration prefers an existing compatible 1080p version for Plex Web:

clients:
  observed_failure_threshold: 3
  rules:
    - name: plex-web-1080p
      match:
        product: [Plex Web]
      preferred_video_height: 1080
      override_explicit_media_selection: true
      capabilities:
        video.codec: [hevc, h264]
        video.profile: [main, high]
        video.hdr_format: [sdr]
        video.max_width: 1920
        video.max_height: 1080
        audio.codec: [aac]
        audio.max_channels: 2
        media.container: [mp4]
        subtitle.codec: [srt]

The rule is a configurable score contribution with reason code CLIENT_RULE_PREFERRED_RESOLUTION; Direct Play compatibility still dominates an incompatible version. Rule capability assumptions close otherwise unknown device-class capability sets so a decision can be confident; identifier-specific manual overrides retain higher priority. A container not listed by the rule can still Direct Stream when its video/audio codecs are supported.

A non-negative mediaIndex in a Plex decision request represents a concrete client-selected media version; a missing value or -1 delegates selection to the server. Explicit selections are authoritative by default, while audio and subtitle policy may still choose compatible streams inside that version. A client rule can set override_explicit_media_selection: true when forced automatic version policy is intentionally more important than the client's concrete choice. The shipped Plex Web rule opts in because its purpose is to enforce 1080p; compatibility rules for clients such as Samsung may opt in for the same reason. Do not enable this flag for Apple TV, VIDAA, or other clients where Play Version must remain authoritative.

Rules apply to new sessions after a configuration reload. Request-time enforcement also requires the client to have a known registry identity, the normal playback/proxy enforcement safety switches, and an intercepted request. Each stored decision reports source: dry_run or source: interceptor; only the latter can modify a request. Unknown clients preserve Plex behavior. Successful observations remain open positive evidence. After observed_failure_threshold failure-only observations, only that exact value becomes unsupported; a failed 7.1/8-channel path does not imply that 5.1 or the codec generally cannot work. Manual overrides always win.

Docker

cp config.example.yml config.yml
cp .env.example .env
# Edit Plex paths, token, LAN address, and hostname in .env.
docker compose -f docker-compose.yml -f docker-compose.ula.yml up --build -d

The production topology uses three containers but only two project images: nginx, the Python application, and the upstream LinuxServer Plex image. Plex and the application join nginx's network namespace. nginx alone owns the LAN macvlan/ipvlan address (for example 172.31.0.5), making it the single ingress path while Plex remains reachable internally at 127.0.0.1:32400.

Inbound port 32400 accepts both Plex HTTP and HTTPS. A narrowly scoped NAT rule redirects only packets arriving on the configured LAN interface and address to nginx. nginx detects TLS without decrypting at the stream layer, preserves the socket source through PROXY protocol, then terminates TLS using the machine-specific certificate extracted from Plex's cert-v2.p12. This allows Plex's normal https://<address>.<machine-id>.plex.direct:32400 LAN discovery URL to pass through the middleware without split DNS. Plain HTTP follows the same path. Plex cannot be reached directly on the LAN address because the ingress redirect runs before its listening socket.

nginx routes the Universal Transcoder decision endpoint, the control portion of matching start.* requests, and session-correlated direct-file requests through Python. A compatibility-enabled client that keeps its stale pre-decision URI can be handed to the selected part using an nginx internal redirect. Ordinary requests and all media bytes still go directly from Plex through nginx; Python never streams the file. Middleware 5xx responses fail open to Plex, and a Plex 4xx after a rewritten decision is retried once with the original request.

A mismatching direct-file request is treated as authoritative client intent by default. Serving byte ranges from a different media file can invalidate the player's offsets and cause permanent buffering. The old stale-part replacement is therefore available only through a matching client rule with direct_part_rewrite: true, for clients proven to ignore an enforced Media Decision. This byte-path compatibility option is separate from override_explicit_media_selection; the former repairs a stale direct-file URI after a selected decision, while the latter authorizes overriding a concrete client media index.

The nginx container also prepares the shared ${PLEX_TRANSCODE_PATH} with the configured Plex PUID/PGID before Plex starts. This prevents remux and transcode failures caused by an unwritable /transcode/temp directory without adding another container.

nginx sends the standard forwarding headers:

proxy_set_header X-Real-IP       $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

Plex does not consistently use those headers for Dashboard addresses. In the shared namespace, nginx therefore also transparently binds its loopback PMS connection to $remote_addr; a dedicated source-port policy route returns Plex's response through that socket. Direct LAN and port-forwarded sessions consequently reach PMS with the actual TCP client address. The middleware's decision requests use a loopback-only nginx gateway carrying a sanitized original-address header, so rewritten requests behave identically. This requires only NET_ADMIN/NET_RAW on nginx and does not add a relay or route-agent container.

If a known NetBird or other reverse proxy is in front, list only its source CIDR in PLEX_TRUSTED_PROXY_CIDRS; nginx will then recover the last untrusted forwarded address. Never use an unrestricted trusted range.

Audio selection on Universal Transcoder requests remains an isolated experimental adapter behavior: PMS documents mediaIndex and partIndex there, but documents audio selection only on the user-scoped part-selection endpoint. The middleware supplies the selected audioStreamID together with the documented DirectPlayStreamSelection profile setting on both decision and start requests. PMS may still ignore this for a client/version; playback then safely continues with Plex's stream. The middleware compares the decision response with its requested audio ID and emits PLEX_AUDIO_SELECTION_IGNORED when PMS declined it. It intentionally does not fall back to Plex's user-scoped part-selection mutation by default. The explicit proxy.temporary_audio_selection_enabled mode serializes changes per playback user and part, selects the stream only while PMS handles decision/start, and restores the previous selection in a finally block. Failures preserve Plex's normal behavior. This mode is experimental because a process or host crash during the short mutation window can prevent restoration. Structured logs expose only the selected indexes and stream IDs for verification.

Samsung and VIDAA clients may omit audioStreamID after a manual audio change. For these requests, the middleware resolves the selected stream with the playback user's token before scoring. The exact stream is retained on the same version; a forced version change retains the language and selects the best matching track in the destination version. Tokens are never logged, and lookup failure leaves normal Plex scoring and fail-open behavior intact.

Set PLEX_PROXY_LAN_IP to the former Plex LAN address and let Plex rediscover the address from the shared namespace. The normal machine-specific plex.direct URL then remains valid. Add the public or explicit hostname on port 8443 under Plex Settings > Server > Network > Custom server access URLs when that path is also required. Verify nginx logs contain a decision request with route=middleware_decision, followed by a start.* request with route=plex_start_stream. Keep proxy.force_https: false while testing.

TLS certificate selection for the explicit hostname is automatic and persistent inside nginx:

  1. certificates/manual/fullchain.pem plus certificates/manual/privkey.pem, when both are valid for PLEX_PROXY_HOSTNAME.
  2. A Certbot DNS-01 lineage obtained through environment-configured hooks or a custom authenticator plugin when ACME is enabled and succeeds.
  3. A generated self-signed certificate for initial/test deployments.

The self-signed fallback keeps the explicit hostname available but is not automatically trusted by clients. The separate Plex-managed certificate is extracted locally from the mounted Plex configuration and is never logged. See deploy/README.md for migration, certificate sources, DNS-01 plugins, checks, and rollback.

Connection security status

The debug page includes traffic-light security cards for playback connections observed during the last 24 hours. A green client card means nginx negotiated HTTPS and includes the observed TLS version and cipher. A red card means that client reached nginx over HTTP. The middleware trusts these fields only when nginx delivered them from a configured trusted ingress address; clients cannot assert their own secure status.

The two internal hops are reported separately. The compatible defaults use http://127.0.0.1:39004 from the application to nginx and HTTP from nginx to Plex; both are confined to the shared network namespace, but neither is encrypted. After Plex has created its machine certificate, enable verified TLS on both hops in .env:

PLEX_MIDDLEWARE_PLEX_BASE_URL=https://127-0-0-1.<CertificateUUID>.plex.direct:39005
PLEX_SECURE_PMS_UPSTREAM=true

Replace <CertificateUUID> with Plex's value. This Plex-managed hostname resolves naturally to 127.0.0.1, so it reaches the loopback-only nginx TLS gateway without split DNS or a Docker host mapping. The gateway presents Plex's machine certificate. nginx derives the same hostname from Plex metadata and verifies Plex's certificate on the second hop. Do not set plex.verify_tls: false; that encrypts traffic without authenticating either endpoint. Enabling secure PMS upstream is fail-closed if the Plex certificate identity is unavailable.

Open the TLS-protected status page at https://<PLEX_PROXY_HOSTNAME>:18082/debug in the Unraid/macvlan deployment, or at https://<host>:<MIDDLEWARE_HTTPS_PORT>/debug with ordinary Compose publishing. Restrict both debug ports to a trusted LAN/VPN or firewall; the status API is read-only but exposes operational metadata. Plex recommends leaving Secure connections at Preferred for compatibility, because Required can exclude unsupported apps. See Plex's Network settings and secure connections guide.