Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@

## [未发布]

### 新增

- 支持 Plex Web 直接播放浏览器原生兼容的 STRM,视频仍由浏览器通过 302 直连 CDN。

## [0.1.2] - 2026-08-30

### 新增
Expand Down
18 changes: 12 additions & 6 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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. |
Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 页面恢复完全透明代理。

## 兼容性状态

Expand All @@ -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 或其各自所有者没有关联,
也未获得其认可或赞助。
Expand Down Expand Up @@ -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 并发上限。 |
Expand Down
5 changes: 5 additions & 0 deletions app.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions cmd/plex-gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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(),
Expand Down
37 changes: 30 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 `</head>`. 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

Expand Down
4 changes: 4 additions & 0 deletions docs/performance-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

## 当前基准

Expand Down Expand Up @@ -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。 |
Expand Down
12 changes: 6 additions & 6 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ type Config struct {
MetadataCoalesce MetadataCoalesceConfig
MediaInfo MediaInfoConfig
PlaybackVeto bool
PlexWebDirectPlay bool
LogLevel string
TraceEnabled bool
ReadHeaderTimeout time.Duration
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -423,6 +428,7 @@ func Load() (Config, error) {
PrewarmBefore: mediaInfoPrewarmBefore, PrewarmAfter: mediaInfoPrewarmAfter,
},
PlaybackVeto: playbackVeto,
PlexWebDirectPlay: plexWebDirectPlay,
LogLevel: logLevel,
TraceEnabled: traceEnabled,
ReadHeaderTimeout: readHeaderTimeout,
Expand Down
8 changes: 8 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
12 changes: 12 additions & 0 deletions internal/gateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type Options struct {
MediaInfoPrewarmer cloudRedirectPrewarmer
MediaInfoPrewarmStatus func() prewarm.Status
PlaybackVeto bool
PlexWebDirectPlay bool
}

type cloudRedirectPrewarmer interface {
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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{
Expand Down
Loading