From 4a6a3300cec8d12c26363111aab3042f7498757a Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Sun, 30 Aug 2026 05:19:02 +0800 Subject: [PATCH] feat: support Plex Web direct play --- CHANGELOG.en.md | 5 + CHANGELOG.md | 4 + README.en.md | 18 +- README.md | 13 +- app.env.example | 5 + cmd/plex-gateway/main.go | 2 + docs/architecture.md | 37 +++- docs/performance-matrix.md | 4 + docs/roadmap.md | 12 +- internal/config/config.go | 6 + internal/config/config_test.go | 8 + internal/gateway/gateway.go | 12 ++ internal/gateway/plex_web.go | 231 +++++++++++++++++++++++ internal/gateway/plex_web_test.go | 304 ++++++++++++++++++++++++++++++ 14 files changed, 637 insertions(+), 24 deletions(-) create mode 100644 internal/gateway/plex_web.go create mode 100644 internal/gateway/plex_web_test.go diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index e94e616..8491770 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Plex Web can Direct Play browser-native STRM media while video remains a + browser-to-CDN 302 path. + ## [0.1.2] - 2026-08-30 ### Added diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b90369..c5be0f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ ## [未发布] +### 新增 + +- 支持 Plex Web 直接播放浏览器原生兼容的 STRM,视频仍由浏览器通过 302 直连 CDN。 + ## [0.1.2] - 2026-08-30 ### 新增 diff --git a/README.en.md b/README.en.md index e4d4966..37c3a06 100644 --- a/README.en.md +++ b/README.en.md @@ -46,11 +46,16 @@ Part path, and starts no additional probe. Apple TV hardware supports Profile so the veto remains disabled by default. Native clients that accept a raw media redirect on those routes can use this -path. Plex Web cloud playback is not supported: its browser player fetches -`start.mpd` as a DASH manifest, while the redirect target is the original media -file and may not permit cross-origin browser requests. Adding CORS headers to -the gateway's redirect cannot change the final media origin's response. Local -media in Plex Web remains ordinary proxied Plex traffic. +path directly. The Plex Web compatibility layer is enabled by default. The +gateway loads one versioned script in the Plex Web shell and removes +`crossorigin` only from same-origin `/library/parts/.../file` or `.strm` media +elements that have the shape of STRM Parts. When the +browser natively supports the container and codecs, the element can follow the +302 and read the CDN directly even when the final origin has no CORS header; +media bytes still bypass the gateway. Sources that require DASH, remuxing, or +transcoding remain unsupported. The gateway does not synthesize manifests or +proxy video streams. Setting `PLEX_WEB_DIRECT_PLAY_ENABLED=false` restores a +fully transparent Plex Web shell. ## Compatibility status @@ -60,7 +65,7 @@ media in Plex Web remains ordinary proxied Plex traffic. | Infuse Direct Play | Direct Play verified | Uses the Plex Part redirect path. | | Plex iOS ExperimentalPlayer | Direct Play verified | STRM uses the universal decision/start redirect path. | | Plex for Apple TV | Direct Play verified | HDR STRM uses the universal decision/start redirect path. Apple TV 4K supports Dolby Vision Profile 5 Direct Play, while Plex version, container, and source combinations may still affect compatibility; the gateway does not reject it by default. | -| Plex Web cloud playback | Unsupported | The browser expects a DASH manifest and enforces final-origin CORS. | +| Plex Web cloud Direct Play | Direct Play verified | MP4/H.264/AAC was verified for initial playback, pause, resume, and seek with CDN-direct delivery. Other containers and codecs depend on native browser support; sources that require DASH, remuxing, or transcoding remain unsupported. | This is an independent community project. It is not affiliated with, endorsed by, or sponsored by Plex, MediaVault, Infuse, or their respective owners. @@ -123,6 +128,7 @@ Important environment variables: | `PART_PROBE_TIMEOUT` | `15s` | Timeout for the bodyless Plex Part authorization probe. | | `CLOUD_EXTENSIONS` | `.strm` | Comma-separated cloud control-file extensions. | | `TRACE_ENABLED` | `true` | Enable sanitized Plex request-order tracing. | +| `PLEX_WEB_DIRECT_PLAY_ENABLED` | `true` | Load the Plex Web Direct Play helper for same-origin STRM Part media elements without proxying media bytes. | | `METADATA_ANALYSIS_FILTER_ENABLED` | `true` | Remove `asyncAugmentMetadata` from detailed metadata reads to avoid Plex background analysis while preserving `checkFiles`. | | `METADATA_GUARD_ENABLED` | `true` | Limit single-item detailed metadata requests before they enter Plex. | | `METADATA_GUARD_GLOBAL_CONCURRENCY` | `16` | Shared detailed metadata concurrency limit across all clients. | diff --git a/README.md b/README.md index c61748f..778f50e 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,12 @@ Plex 已返回同一 STRM Part 的 Direct Play decision、且本次 MediaInfo 额外探测。Apple TV 硬件支持 Profile 5,实际兼容性还受 Plex 版本、容器和片源影响, 因此 veto 默认关闭。 -能够在这些路径上接受原始媒体重定向的原生客户端可以使用此链路。Plex Web 不支持 -云端播放:其浏览器播放器会将 `start.mpd` 作为 DASH manifest 获取,而重定向目标 -是原始媒体文件,且可能不允许跨源浏览器请求。为 Gateway 的重定向添加 CORS header -无法改变最终媒体源的响应。Plex Web 中的本地媒体仍按普通 Plex 流量代理。 +能够在这些路径上接受原始媒体重定向的原生客户端可以直接使用此链路。Plex Web +兼容层默认开启:Gateway 只在 Plex Web shell 中加载一个版本化脚本,并只对同源、 +呈 STRM Part 形态的 `/library/parts/.../file` 或 `.strm` 媒体元素移除 `crossorigin`。浏览器原生支持该容器和编码时,可以按 +普通媒体元素跟随 302 直接读取 CDN,即使最终源站没有 CORS header;媒体字节仍不经过 +Gateway。需要 DASH、转封装或转码的片源仍不支持,Gateway 不合成 manifest,也不代理 +视频流。关闭 `PLEX_WEB_DIRECT_PLAY_ENABLED` 后,Plex Web 页面恢复完全透明代理。 ## 兼容性状态 @@ -47,7 +49,7 @@ Plex 已返回同一 STRM Part 的 Direct Play decision、且本次 MediaInfo | Infuse Direct Play | 已验证 Direct Play | 使用 Plex Part 重定向路径。 | | Plex iOS ExperimentalPlayer | 已验证 Direct Play | STRM 使用通用 decision/start 重定向链路。 | | Plex for Apple TV | 已验证 Direct Play | HDR STRM 使用通用 decision/start 重定向链路。Apple TV 4K 支持 DV Profile 5 Direct Play,但 Plex 版本、容器和片源组合仍可能影响兼容性;Gateway 默认不拒绝。 | -| Plex Web 云端播放 | 不支持 | 浏览器需要 DASH manifest,并受最终源站 CORS 限制。 | +| Plex Web 云端 Direct Play | 已验证 Direct Play | MP4/H.264/AAC 样本已验证首播、暂停、继续和 seek,并保持 CDN 直达;其他容器与编码取决于浏览器原生能力,需要 DASH、转封装或转码的片源不支持。 | 本项目是独立的社区项目,与 Plex、MediaVault、Infuse 或其各自所有者没有关联, 也未获得其认可或赞助。 @@ -107,6 +109,7 @@ go run ./cmd/plex-gateway | `PART_PROBE_TIMEOUT` | `15s` | 无 body 的 Plex Part 授权 probe 超时时间。 | | `CLOUD_EXTENSIONS` | `.strm` | 以逗号分隔的云端控制文件扩展名。 | | `TRACE_ENABLED` | `true` | 启用经过脱敏的 Plex 请求顺序追踪。 | +| `PLEX_WEB_DIRECT_PLAY_ENABLED` | `true` | 为 Plex Web shell 加载仅作用于同源 STRM Part 媒体元素的 Direct Play 兼容脚本;不代理媒体字节。 | | `METADATA_ANALYSIS_FILTER_ENABLED` | `true` | 从详细 metadata 读取中移除会触发 Plex 后台分析的 `asyncAugmentMetadata`;保留 `checkFiles`。 | | `METADATA_GUARD_ENABLED` | `true` | 限制单项详细 metadata 请求进入 Plex 的并发量。 | | `METADATA_GUARD_GLOBAL_CONCURRENCY` | `16` | 所有客户端共享的详细 metadata 并发上限。 | diff --git a/app.env.example b/app.env.example index 957d076..77980a0 100644 --- a/app.env.example +++ b/app.env.example @@ -23,6 +23,11 @@ LOG_LEVEL=info # 是否记录脱敏后的 Plex 请求顺序。默认开启,便于识别不同客户端的实际请求链路。 TRACE_ENABLED=true +# 是否为 Plex Web 注入浏览器 Direct Play 兼容脚本。默认开启;只处理同源、呈 STRM +# Part 形态的 /library/parts/.../file 或 .strm 媒体元素,不代理视频字节,也不能让 +# 浏览器播放本身不支持的容器或编码。 +PLEX_WEB_DIRECT_PLAY_ENABLED=true + # 识别为云端控制文件的扩展名,多个值以逗号分隔。 CLOUD_EXTENSIONS=.strm diff --git a/cmd/plex-gateway/main.go b/cmd/plex-gateway/main.go index 9d45b54..cdcfb28 100644 --- a/cmd/plex-gateway/main.go +++ b/cmd/plex-gateway/main.go @@ -132,6 +132,7 @@ func run() error { MediaInfoPrewarmer: prewarmService, MediaInfoPrewarmStatus: prewarmStatus, PlaybackVeto: cfg.PlaybackVeto, + PlexWebDirectPlay: cfg.PlexWebDirectPlay, }) handler := newMaintenanceHandler(gatewayHandler, mediaInfoService, cfg.DatabasePath, logger) server := &http.Server{ @@ -163,6 +164,7 @@ func run() error { "mediainfo_reason", mediaInfoReason, "mediainfo_prewarm_available", prewarmService != nil, "mediainfo_prewarm_reason", prewarmReason, + "plex_web_direct_play", cfg.PlexWebDirectPlay, "mediainfo_prewarm_before", cfg.MediaInfo.PrewarmBefore, "mediainfo_prewarm_after", cfg.MediaInfo.PrewarmAfter, "mediainfo_background_interval_ms", cfg.MediaInfo.BackgroundInterval.Milliseconds(), diff --git a/docs/architecture.md b/docs/architecture.md index bb06805..3906fe2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,6 +75,9 @@ gateway; the isolated analysis worker may read a bounded amount for probing. traffic. - Timeline, scrobble, sessions, playlists, subtitles, images, transcode segments, and every unrecognized endpoint remain ordinary Plex traffic. +- When Plex Web compatibility is enabled, only successful `GET /web/` and + `GET /web/index.html` HTML responses are eligible for the bounded shell + helper injection. Every API response and Plex Web static asset bypasses it. ## Code ownership @@ -262,13 +265,33 @@ The redirect plane requires a client that can consume the final raw media URL. Native players are not subject to browser cross-origin fetch enforcement and may accept redirects from either Part or eligible universal start routes. -Plex Web requests `start.mpd` as a DASH manifest. A redirect-only gateway -cannot make the final media origin emit browser CORS headers, and an ordinary -MP4 or MKV response is not a DASH manifest. The gateway therefore does not -claim cloud STRM support for Plex Web. Proxying media bytes, repackaging media, -or modifying the Plex Web client would violate or expand the playback-plane -boundary; none is enabled by this project. Local media remains transparently -proxied and retains Plex's normal Web compatibility. +Plex Web sets `crossorigin=anonymous` on its HTML media element. When a cloud +Part redirects from the Gateway origin to a CDN without CORS headers, the +browser rejects an otherwise natively playable file before Plex Web falls back +to `start.mpd`. `PLEX_WEB_DIRECT_PLAY_ENABLED=true` modifies only the small Plex +Web shell and loads a versioned same-origin helper. The helper removes +`crossorigin` only when an audio or video source points at the Gateway origin's +`/library/parts/.../file` or `.strm` path. Those are the Part key shapes Plex +uses for STRM control files; ordinary local Parts retain their media extension +and Plex Web behavior. Cloud Parts may follow the existing 302 as ordinary +no-CORS media requests. + +The helper is loaded before Plex's application bundles, has no token or media +state, and performs no network request of its own. The shell response is +requested from Plex with identity encoding, buffered up to 2 MiB, and modified +only for a successful HTML response containing ``. Unsupported response +shapes are returned unchanged. Changed responses discard upstream validators +and byte-range metadata and use `no-cache`; the versioned helper itself is +immutable-cacheable. Disabling the feature removes both the shell modification +and helper endpoint. + +This compatibility path does not expand the media data plane. The browser still +downloads bytes directly from the final CDN. It only supports containers and +codecs the browser can Direct Play. If Plex Web needs DASH, remuxing, or video +transcoding, an ordinary MP4 or MKV response is not a manifest and this helper +cannot make that path playable. The gateway does not proxy media, package +segments, or synthesize manifests. Local media retains Plex's normal Web +compatibility. ## Playback veto contract diff --git a/docs/performance-matrix.md b/docs/performance-matrix.md index ec9158e..6517d8c 100644 --- a/docs/performance-matrix.md +++ b/docs/performance-matrix.md @@ -26,6 +26,8 @@ - P1/P2 在 worker 中先检查 SQLite。只有 L1 和 SQLite 都 miss 的任务才受全局远程启动 间隔限制;SQLite 热命中不能因 CDN 风控节流额外等待 5 秒。 - 302 响应只包含控制流。媒体字节始终由客户端直接从 CDN 读取。 +- Plex Web 兼容层只缓冲最多 2 MiB 的 Web shell,并加载可长期缓存的静态脚本;Part、 + 302、本地媒体和其他 Plex API 不缓冲响应,也不新增媒体数据面 I/O。 ## 当前基准 @@ -92,6 +94,8 @@ Guard 尾部等待和客户端结果共同支持批量默认值 4。 | 本地媒体透明代理 | Plex | 直连 Plex 与经 Gateway 的 p50/p95/p99、错误率、上游请求数 | 0 功能错误;Gateway 增量 p95 <= 20 ms、p99 <= 50 ms,且相对增加 <= 10%。 | | STRM Part 302 | Plex Part 授权、MediaVault redirect | 302 p50/p95/p99、Plex/MV 次数、响应字节 | 100% 健康样本返回 302;p95 < 2 s、p99 < 5 s;Gateway 不传输媒体字节。 | | universal start | Plex Part 授权、MediaVault redirect | grant 命中、上游次数、302 延迟 | 不读取 metadata、STRM 或 MediaInfo;无 grant 时透明回退。 | +| Plex Web shell | Plex HTML、Gateway 静态脚本 | shell body、注入次数、首屏延迟、脚本缓存 | shell <= 2 MiB 时只修改一次;脚本命中长期缓存;其他 Web 静态资源和 API 不变。 | +| Plex Web 云端 Direct Play | Plex decision/Part 授权、MediaVault redirect、CDN | 首帧、seek、Part 302、Gateway 媒体响应字节 | 浏览器原生支持的样本正常播放和 seek;Gateway 媒体响应保持 302 且不传输视频字节。 | | Decision,Infuse | Plex metadata 和 decision | decision 延迟、veto 结果 | veto 始终放弃判断;不增加缓存、MediaVault 或 CDN 请求。 | | Decision,L1 热缓存 | Plex metadata 和 decision,L1 | p50/p95/p99、SQLite/MV/CDN 次数 | 0 SQLite、0 MV、0 CDN;p95 <= 1 s、p99 <= 2 s。 | | SQLite 启动恢复 | 启动期 SQLite,播放期 L1 | 恢复耗时、首个 decision 延迟 | 首个请求 0 ffprobe、0 请求期 SQLite。 | diff --git a/docs/roadmap.md b/docs/roadmap.md index ee5b13b..ff4543c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -62,12 +62,12 @@ selection. Production playback has successfully followed this redirect path with Plex iOS ExperimentalPlayer and Plex for Apple TV. Other official-client playback paths are not supported by the current compatibility scope. -Plex Web remains outside the cloud-playback compatibility gate. Its browser -player fetches `start.mpd` as a DASH manifest; a redirect to the original media -file can be rejected by browser CORS policy and is not itself a DASH manifest. -Supporting that path would require a media proxy, packaging service, or client -modification, each outside the redirect-only data plane. Local Plex Web media, -manifest segments, and every genuine transcode request remain Plex-owned. +Plex Web Direct Play is included for media the browser can decode natively. A +small, default-enabled shell helper removes `crossorigin` only from same-origin +Plex Part media elements, allowing the existing Part 302 to remain CDN-direct +when the final origin does not emit CORS headers. The helper does not handle +DASH, remuxing, or transcoding and does not proxy media bytes. Local Plex Web +media, manifest segments, and every genuine transcode request remain Plex-owned. ## MediaInfo fallback and projection diff --git a/internal/config/config.go b/internal/config/config.go index d797282..06909f1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,6 +76,7 @@ type Config struct { MetadataCoalesce MetadataCoalesceConfig MediaInfo MediaInfoConfig PlaybackVeto bool + PlexWebDirectPlay bool LogLevel string TraceEnabled bool ReadHeaderTimeout time.Duration @@ -359,6 +360,10 @@ func Load() (Config, error) { if err != nil { return Config{}, err } + plexWebDirectPlay, err := envBool("PLEX_WEB_DIRECT_PLAY_ENABLED", true) + if err != nil { + return Config{}, err + } readHeaderTimeout, err := envDuration("READ_HEADER_TIMEOUT", defaultReadHeaderTimeout) if err != nil { @@ -423,6 +428,7 @@ func Load() (Config, error) { PrewarmBefore: mediaInfoPrewarmBefore, PrewarmAfter: mediaInfoPrewarmAfter, }, PlaybackVeto: playbackVeto, + PlexWebDirectPlay: plexWebDirectPlay, LogLevel: logLevel, TraceEnabled: traceEnabled, ReadHeaderTimeout: readHeaderTimeout, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8511d91..59a7e68 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -26,6 +26,7 @@ func TestLoadDefaults(t *testing.T) { t.Setenv("TRACE_ENABLED", "") t.Setenv("METADATA_ANALYSIS_FILTER_ENABLED", "") t.Setenv("METADATA_COALESCE_ENABLED", "") + t.Setenv("PLEX_WEB_DIRECT_PLAY_ENABLED", "") got, err := Load() if err != nil { @@ -66,6 +67,9 @@ func TestLoadDefaults(t *testing.T) { if got.PlaybackVeto { t.Fatal("PlaybackVeto = true") } + if !got.PlexWebDirectPlay { + t.Fatal("PlexWebDirectPlay = false") + } if got.MediaVaultURL != nil || len(got.PathMappings) != 0 { t.Fatalf("cloud redirect unexpectedly enabled: %#v", got) } @@ -124,6 +128,7 @@ func TestLoadCloudConfiguration(t *testing.T) { t.Setenv("MEDIAINFO_ENRICHMENT_CONCURRENCY", "3") t.Setenv("MEDIAINFO_PREWARM_BEFORE", "4") t.Setenv("MEDIAINFO_PREWARM_AFTER", "6") + t.Setenv("PLEX_WEB_DIRECT_PLAY_ENABLED", "false") got, err := Load() if err != nil { @@ -135,6 +140,9 @@ func TestLoadCloudConfiguration(t *testing.T) { if got.PlexToken != "management-token" { t.Fatalf("PlexToken = %q", got.PlexToken) } + if got.PlexWebDirectPlay { + t.Fatal("PlexWebDirectPlay = true") + } if !reflect.DeepEqual(got.CloudExtensions, []string{".strm"}) { t.Fatalf("CloudExtensions = %v", got.CloudExtensions) } diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index b3a0e90..b499cb0 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -51,6 +51,7 @@ type Options struct { MediaInfoPrewarmer cloudRedirectPrewarmer MediaInfoPrewarmStatus func() prewarm.Status PlaybackVeto bool + PlexWebDirectPlay bool } type cloudRedirectPrewarmer interface { @@ -99,11 +100,15 @@ func New(options Options) http.Handler { }) } + plexWeb := newPlexWebCompatibility(options.PlexWebDirectPlay, logger) proxy := &httputil.ReverseProxy{ Rewrite: func(request *httputil.ProxyRequest) { request.SetURL(options.Upstream) request.Out.Host = options.Upstream.Host request.SetXForwarded() + if options.PlexWebDirectPlay { + plexWeb.prepareProxyRequest(request) + } }, Transport: transport, FlushInterval: -1, @@ -114,6 +119,9 @@ func New(options Options) http.Handler { http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway) }, } + if options.PlexWebDirectPlay { + proxy.ModifyResponse = plexWeb.modifyResponse + } plex := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { registry.IncPlexRequests() proxy.ServeHTTP(w, r) @@ -199,6 +207,10 @@ func New(options Options) http.Handler { }) }) mux.Handle("GET /metrics", registry.Handler()) + if options.PlexWebDirectPlay { + mux.HandleFunc("GET "+plexWebScriptPath, plexWeb.serveScript) + mux.HandleFunc("HEAD "+plexWebScriptPath, plexWeb.serveScript) + } mux.Handle("GET /video/:/transcode/universal/decision", decision) mux.Handle("HEAD /video/:/transcode/universal/decision", decision) for _, route := range []string{ diff --git a/internal/gateway/plex_web.go b/internal/gateway/plex_web.go new file mode 100644 index 0000000..a31594a --- /dev/null +++ b/internal/gateway/plex_web.go @@ -0,0 +1,231 @@ +package gateway + +import ( + "bytes" + "io" + "log/slog" + "mime" + "net/http" + "net/http/httputil" + "strconv" + "strings" +) + +const ( + plexWebScriptPath = "/:/plex-gateway/web-direct-play-v2.js" + plexWebBodyMaxBytes = 2 << 20 +) + +var ( + plexWebScriptTag = []byte(``) + plexWebScript = []byte(`(() => { + "use strict"; + + const marker = "__plexGatewayDirectPlayV2"; + if (window[marker]) return; + Object.defineProperty(window, marker, { value: true }); + + const mediaPrototype = window.HTMLMediaElement && window.HTMLMediaElement.prototype; + if (!mediaPrototype) return; + + const source = Object.getOwnPropertyDescriptor(mediaPrototype, "src"); + const crossOrigin = Object.getOwnPropertyDescriptor(mediaPrototype, "crossOrigin"); + const setAttribute = window.Element.prototype.setAttribute; + const removeAttribute = window.Element.prototype.removeAttribute; + const preservedCrossOrigin = new WeakMap(); + + const isGatewayCloudPart = value => { + if (!value) return false; + try { + const target = new URL(String(value), document.baseURI); + const path = target.pathname.toLowerCase(); + return target.origin === window.location.origin && + path.startsWith("/library/parts/") && + (path.endsWith("/file") || path.endsWith(".strm")); + } catch (_) { + return false; + } + }; + + const currentSource = element => { + const attribute = element.getAttribute("src"); + if (attribute) return attribute; + return source && source.get ? source.get.call(element) : ""; + }; + + const suppressPartCrossOrigin = (element, candidate) => { + if (!isGatewayCloudPart(candidate)) return false; + if (!preservedCrossOrigin.has(element)) { + preservedCrossOrigin.set(element, element.getAttribute("crossorigin")); + } + removeAttribute.call(element, "crossorigin"); + return true; + }; + + const restorePartCrossOrigin = element => { + if (!preservedCrossOrigin.has(element)) return; + const value = preservedCrossOrigin.get(element); + preservedCrossOrigin.delete(element); + if (value === null) { + removeAttribute.call(element, "crossorigin"); + } else { + setAttribute.call(element, "crossorigin", value); + } + }; + + if (source && source.configurable && source.get && source.set) { + Object.defineProperty(mediaPrototype, "src", { + configurable: true, + enumerable: source.enumerable, + get: source.get, + set(value) { + if (!suppressPartCrossOrigin(this, value)) { + restorePartCrossOrigin(this); + } + source.set.call(this, value); + } + }); + } + + if (crossOrigin && crossOrigin.configurable && crossOrigin.get && crossOrigin.set) { + Object.defineProperty(mediaPrototype, "crossOrigin", { + configurable: true, + enumerable: crossOrigin.enumerable, + get: crossOrigin.get, + set(value) { + if (isGatewayCloudPart(currentSource(this))) { + preservedCrossOrigin.set(this, value == null ? null : String(value)); + removeAttribute.call(this, "crossorigin"); + return; + } + preservedCrossOrigin.delete(this); + crossOrigin.set.call(this, value); + } + }); + } + +})(); +`) +) + +// plexWebCompatibility changes only the Plex Web shell and its versioned helper +// script. Media requests remain ordinary Part requests, so cloud bytes still +// travel directly from the final media origin to the browser after the 302. +type plexWebCompatibility struct { + enabled bool + logger *slog.Logger + maxBodyBytes int64 +} + +func newPlexWebCompatibility(enabled bool, logger *slog.Logger) *plexWebCompatibility { + return &plexWebCompatibility{enabled: enabled, logger: logger, maxBodyBytes: plexWebBodyMaxBytes} +} + +func (compatibility *plexWebCompatibility) prepareProxyRequest(request *httputil.ProxyRequest) { + if compatibility == nil || !compatibility.enabled || request == nil || !isPlexWebShellRequest(request.In) { + return + } + // The shell is small and modified once. Requesting identity avoids buffering + // browser-selected Brotli or Zstandard encodings that the Go standard library + // cannot rewrite without adding another codec dependency. + request.Out.Header.Set("Accept-Encoding", "identity") + for _, name := range []string{"If-Match", "If-Modified-Since", "If-None-Match", "If-Unmodified-Since", "Range"} { + request.Out.Header.Del(name) + } +} + +func (compatibility *plexWebCompatibility) modifyResponse(response *http.Response) error { + if compatibility == nil || !compatibility.enabled || !isPlexWebShellResponse(response) { + return nil + } + + original := response.Body + body, err := io.ReadAll(io.LimitReader(original, compatibility.maxBodyBytes+1)) + if err != nil || int64(len(body)) > compatibility.maxBodyBytes { + response.Body = &replayReadCloser{Reader: io.MultiReader(bytes.NewReader(body), original), Closer: original} + compatibility.logSkip(response, "body_unavailable") + return nil + } + _ = original.Close() + + if bytes.Contains(body, plexWebScriptTag) { + response.Body = io.NopCloser(bytes.NewReader(body)) + return nil + } + headEnd := bytes.Index(bytes.ToLower(body), []byte("")) + if headEnd < 0 { + response.Body = io.NopCloser(bytes.NewReader(body)) + compatibility.logSkip(response, "head_missing") + return nil + } + + modified := make([]byte, 0, len(body)+len(plexWebScriptTag)+1) + modified = append(modified, body[:headEnd]...) + modified = append(modified, plexWebScriptTag...) + modified = append(modified, '\n') + modified = append(modified, body[headEnd:]...) + + for _, name := range []string{ + "Accept-Ranges", "Content-Length", "Content-MD5", "Content-Range", "Digest", "ETag", "Last-Modified", "Trailer", + } { + response.Header.Del(name) + } + cachePolicy := strings.Join(response.Header.Values("Cache-Control"), ", ") + if !hasCacheDirective(cachePolicy, "no-store") && !hasCacheDirective(cachePolicy, "no-cache") { + if strings.TrimSpace(cachePolicy) == "" { + cachePolicy = "no-cache" + } else { + cachePolicy += ", no-cache" + } + response.Header.Set("Cache-Control", cachePolicy) + } + response.Header.Set("Content-Length", strconv.Itoa(len(modified))) + response.Header.Set("X-Plex-Gateway-Web-Compat", "direct-play-v2") + response.ContentLength = int64(len(modified)) + response.Trailer = nil + response.Body = io.NopCloser(bytes.NewReader(modified)) + return nil +} + +func (compatibility *plexWebCompatibility) serveScript(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + writer.Header().Set("Content-Length", strconv.Itoa(len(plexWebScript))) + writer.Header().Set("Content-Type", "text/javascript; charset=utf-8") + writer.Header().Set("X-Content-Type-Options", "nosniff") + writer.WriteHeader(http.StatusOK) + if request.Method != http.MethodHead { + _, _ = writer.Write(plexWebScript) + } +} + +func (compatibility *plexWebCompatibility) logSkip(response *http.Response, reason string) { + if compatibility.logger == nil || response == nil || response.Request == nil || response.Request.URL == nil { + return + } + compatibility.logger.Warn("plex_web_compat_skipped", "path", response.Request.URL.EscapedPath(), "reason", reason) +} + +func isPlexWebShellRequest(request *http.Request) bool { + if request == nil || request.Method != http.MethodGet || request.URL == nil { + return false + } + return request.URL.Path == "/web/" || request.URL.Path == "/web/index.html" +} + +func isPlexWebShellResponse(response *http.Response) bool { + if response == nil || response.Request == nil || !isPlexWebShellRequest(response.Request) || + response.StatusCode != http.StatusOK || response.Body == nil { + return false + } + encoding := strings.TrimSpace(response.Header.Get("Content-Encoding")) + if encoding != "" && !strings.EqualFold(encoding, "identity") { + return false + } + mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + return err == nil && strings.EqualFold(mediaType, "text/html") +} + +type replayReadCloser struct { + io.Reader + io.Closer +} diff --git a/internal/gateway/plex_web_test.go b/internal/gateway/plex_web_test.go new file mode 100644 index 0000000..13ca9ee --- /dev/null +++ b/internal/gateway/plex_web_test.go @@ -0,0 +1,304 @@ +package gateway + +import ( + "io" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "strconv" + "strings" + "testing" +) + +func TestPlexWebCompatibilityInjectsBeforePlexScripts(t *testing.T) { + var upstreamEncoding string + var upstreamValidator string + upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + upstreamEncoding = request.Header.Get("Accept-Encoding") + upstreamValidator = request.Header.Get("If-None-Match") + body := `Plex` + writer.Header().Set("Content-Type", "text/html") + writer.Header().Set("ETag", `"plex-shell"`) + writer.Header().Set("Accept-Ranges", "bytes") + writer.Header().Set("Content-Range", "bytes 0-10/100") + writer.Header().Set("Trailer", "Digest") + writer.Header().Set("Content-Length", strconv.Itoa(len(body))) + _, _ = io.WriteString(writer, body) + })) + defer upstream.Close() + + request := httptest.NewRequest(http.MethodGet, "http://gateway/web/index.html", nil) + request.Header.Set("Accept-Encoding", "gzip, br") + request.Header.Set("If-None-Match", `"plex-shell"`) + response := httptest.NewRecorder() + New(Options{Upstream: mustParseGatewayTestURL(t, upstream.URL), PlexWebDirectPlay: true}).ServeHTTP(response, request) + + if upstreamEncoding != "identity" || upstreamValidator != "" { + t.Fatalf("upstream headers: Accept-Encoding=%q If-None-Match=%q", upstreamEncoding, upstreamValidator) + } + body := response.Body.String() + tag := string(plexWebScriptTag) + if strings.Count(body, tag) != 1 || strings.Index(body, tag) > strings.Index(body, ``) { + t.Fatalf("compatibility script was not injected before : %q", body) + } + if response.Header().Get("X-Plex-Gateway-Web-Compat") != "direct-play-v2" || + response.Header().Get("Content-Length") != strconv.Itoa(len(body)) { + t.Fatalf("unexpected response headers: %#v", response.Header()) + } + for _, name := range []string{"Accept-Ranges", "Content-Range", "ETag", "Last-Modified", "Trailer"} { + if value := response.Header().Get(name); value != "" { + t.Fatalf("%s = %q", name, value) + } + } +} + +func TestPlexWebCompatibilityScriptIsVersionedAndScopedToPartMedia(t *testing.T) { + upstream := httptest.NewServer(http.NotFoundHandler()) + defer upstream.Close() + handler := New(Options{Upstream: mustParseGatewayTestURL(t, upstream.URL), PlexWebDirectPlay: true}) + + for _, method := range []string{http.MethodGet, http.MethodHead} { + request := httptest.NewRequest(method, "http://gateway"+plexWebScriptPath, nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + + if response.Code != http.StatusOK || response.Header().Get("Content-Type") != "text/javascript; charset=utf-8" || + !strings.Contains(response.Header().Get("Cache-Control"), "immutable") { + t.Fatalf("%s response: status=%d headers=%#v", method, response.Code, response.Header()) + } + if method == http.MethodHead && response.Body.Len() != 0 { + t.Fatalf("HEAD body length = %d", response.Body.Len()) + } + } + + script := string(plexWebScript) + if !strings.HasSuffix(plexWebScriptPath, "web-direct-play-v2.js") || + !strings.Contains(script, `const marker = "__plexGatewayDirectPlayV2"`) { + t.Fatalf("helper cache identity was not advanced with its behavior: path=%q", plexWebScriptPath) + } + for _, contract := range []string{`path.startsWith("/library/parts/")`, `path.endsWith("/file")`, `path.endsWith(".strm")`, `target.origin === window.location.origin`, `const preservedCrossOrigin = new WeakMap()`, `preservedCrossOrigin.set(this, value == null ? null : String(value))`, `restorePartCrossOrigin(this)`, `removeAttribute.call(element, "crossorigin")`} { + if !strings.Contains(script, contract) { + t.Fatalf("script is missing contract %q", contract) + } + } + if !strings.Contains(script, `if (!suppressPartCrossOrigin(this, value)) { + restorePartCrossOrigin(this); + } + source.set.call(this, value);`) { + t.Fatal("helper does not restore the preserved CORS mode before assigning a non-cloud source") + } + if strings.Contains(script, "elementPrototype.setAttribute") || strings.Contains(script, "elementPrototype.setAttributeNS") { + t.Fatal("script changes the global Element attribute methods") + } +} + +func TestPlexWebCompatibilityPreservesNoStore(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "http://gateway/web/index.html", nil) + response := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/html"}, "Cache-Control": []string{"private, no-store"}}, + Body: io.NopCloser(strings.NewReader(``)), + Request: request, + } + + if err := newPlexWebCompatibility(true, nil).modifyResponse(response); err != nil { + t.Fatal(err) + } + if got := response.Header.Get("Cache-Control"); got != "private, no-store" { + t.Fatalf("Cache-Control = %q", got) + } +} + +func TestPlexWebCompatibilityPreservesUpstreamCacheRestrictions(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "private policy", in: "private, max-age=600", want: "private, max-age=600, no-cache"}, + {name: "existing no cache", in: "private, no-cache", want: "private, no-cache"}, + {name: "empty policy", want: "no-cache"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "http://gateway/web/index.html", nil) + response := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/html"}}, + Body: io.NopCloser(strings.NewReader(``)), + Request: request, + } + if test.in != "" { + response.Header.Set("Cache-Control", test.in) + } + + if err := newPlexWebCompatibility(true, nil).modifyResponse(response); err != nil { + t.Fatal(err) + } + if got := response.Header.Get("Cache-Control"); got != test.want { + t.Fatalf("Cache-Control = %q, want %q", got, test.want) + } + }) + } +} + +func TestPlexWebCompatibilityDisabledIsTransparent(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/html") + writer.WriteHeader(http.StatusTeapot) + _, _ = io.WriteString(writer, request.URL.Path) + })) + defer upstream.Close() + handler := New(Options{Upstream: mustParseGatewayTestURL(t, upstream.URL)}) + + for _, path := range []string{"/web/index.html", plexWebScriptPath} { + request := httptest.NewRequest(http.MethodGet, "http://gateway"+path, nil) + request.Header.Set("Accept-Encoding", "br") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusTeapot || response.Body.String() != path || response.Header().Get("X-Plex-Gateway-Web-Compat") != "" { + t.Fatalf("%s was not transparent: status=%d body=%q headers=%#v", path, response.Code, response.Body.String(), response.Header()) + } + } +} + +func TestPlexWebCompatibilityEnabledLeavesNonShellTrafficTransparent(t *testing.T) { + compatibility := newPlexWebCompatibility(true, nil) + paths := []string{ + "/", + "/library/metadata/1", + "/library/parts/123/456/file", + "/library/parts/123/456/file.mp4", + "/video/:/transcode/universal/decision", + "/video/:/transcode/universal/start.mpd", + } + + for _, path := range paths { + t.Run(path, func(t *testing.T) { + in := httptest.NewRequest(http.MethodGet, "http://gateway"+path, nil) + in.Header.Set("Accept-Encoding", "br") + in.Header.Set("If-None-Match", `"client-validator"`) + out := in.Clone(in.Context()) + compatibility.prepareProxyRequest(&httputil.ProxyRequest{In: in, Out: out}) + if got := out.Header.Get("Accept-Encoding"); got != "br" { + t.Fatalf("Accept-Encoding = %q", got) + } + if got := out.Header.Get("If-None-Match"); got != `"client-validator"` { + t.Fatalf("If-None-Match = %q", got) + } + + body := "upstream:" + path + response := &http.Response{ + StatusCode: http.StatusPartialContent, + Header: http.Header{ + "Cache-Control": []string{"private, max-age=600"}, + "Content-Range": []string{"bytes 0-9/100"}, + "Content-Type": []string{"application/octet-stream"}, + "Etag": []string{`"upstream-validator"`}, + }, + Body: io.NopCloser(strings.NewReader(body)), + Request: in, + } + if err := compatibility.modifyResponse(response); err != nil { + t.Fatal(err) + } + gotBody, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusPartialContent || string(gotBody) != body || + response.Header.Get("Cache-Control") != "private, max-age=600" || + response.Header.Get("Content-Range") != "bytes 0-9/100" || + response.Header.Get("ETag") != `"upstream-validator"` || + response.Header.Get("X-Plex-Gateway-Web-Compat") != "" { + t.Fatalf("non-shell response changed: status=%d headers=%#v body=%q", response.StatusCode, response.Header, gotBody) + } + }) + } +} + +func TestPlexWebCompatibilityFailsOpenForUnsupportedRepresentation(t *testing.T) { + original := `` + strings.Repeat("x", 32) + `` + request := httptest.NewRequest(http.MethodGet, "http://gateway/web/index.html", nil) + + tests := []struct { + name string + encoding string + bodyLimit int64 + contentType string + }{ + {name: "unsupported encoding", encoding: "br", bodyLimit: plexWebBodyMaxBytes, contentType: "text/html"}, + {name: "oversized body", bodyLimit: 16, contentType: "text/html"}, + {name: "non HTML", bodyLimit: plexWebBodyMaxBytes, contentType: "application/json"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{test.contentType}, "Content-Encoding": []string{test.encoding}}, + Body: io.NopCloser(strings.NewReader(original)), + Request: request, + } + compatibility := newPlexWebCompatibility(true, nil) + compatibility.maxBodyBytes = test.bodyLimit + if err := compatibility.modifyResponse(response); err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if string(body) != original || bytesContain(body, plexWebScriptTag) { + t.Fatalf("response changed: %q", body) + } + }) + } +} + +func mustParseGatewayTestURL(t *testing.T, raw string) *url.URL { + t.Helper() + parsed, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + return parsed +} + +func bytesContain(body, fragment []byte) bool { + return strings.Contains(string(body), string(fragment)) +} + +func BenchmarkPlexWebCompatibility(b *testing.B) { + compatibility := newPlexWebCompatibility(true, nil) + webRequest := httptest.NewRequest(http.MethodGet, "http://gateway/web/index.html", nil) + apiRequest := httptest.NewRequest(http.MethodGet, "http://gateway/library/metadata/1", nil) + webBody := `Plex` + strings.Repeat("x", 30<<10) + `` + + b.Run("non web response", func(b *testing.B) { + response := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Request: apiRequest} + b.ReportAllocs() + for b.Loop() { + if err := compatibility.modifyResponse(response); err != nil { + b.Fatal(err) + } + } + }) + + b.Run("30 KiB web shell", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + response := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/html"}}, + Body: io.NopCloser(strings.NewReader(webBody)), + Request: webRequest, + } + if err := compatibility.modifyResponse(response); err != nil { + b.Fatal(err) + } + _ = response.Body.Close() + } + }) +}