diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index fd0123e..e94e616 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -1,7 +1,5 @@ # Changelog -[简体中文](CHANGELOG.md) - All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), @@ -9,18 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.2] - 2026-08-30 + +### Added + +- Plex for Apple TV can now identify HDR and Dolby Vision information for STRM + media. The gateway fills missing MediaInfo while preserving Plex-owned media + fields and playback decisions. +- STRM video, audio, and subtitle details are detected and cached automatically. + Playback prioritizes the current item, while an optional Plex management token + enables nearby-episode prewarming. +- Added a disabled-by-default experimental Apple TV Dolby Vision Profile 5 + playback veto. +- Added a live MediaInfo cache-reset command that backs up the Gateway database + before clearing the cache without stopping the container. + +### Changed + +- Long-season browsing is faster. The gateway combines duplicate media-detail + requests, protects Plex Server, and returns Plex results before filling a cold + MediaInfo cache in the background. +- MediaInfo, request protection, and sanitized tracing are enabled by default. + The official image includes ffprobe, stores data under `/app_data`, and loads + runtime configuration from `app.env`. +- Infuse, Plex iOS, and Plex for Apple TV Direct Play are verified. This release + does not write to the Plex database, and local media remains transparent. + +### Fixed + +- Fixed repeated waits and unnecessary Plex requests when remote probing is + canceled or combined metadata requests fail. + ## [0.1.1] - 2026-08-28 ### Added -- Configurable concurrency protection for individual and batch Plex metadata - requests to limit native-client metadata fan-out against Plex Media Server. -- Metadata Guard metrics for admitted, queued, active, and timed-out requests. +- Added Plex metadata request protection and metrics so opening many items at + once does not overwhelm Plex Media Server. ### Fixed -- Apple TV Plex client Direct Play decisions and playback negotiation for STRM - media while preserving Plex authorization of client credentials and Parts. +- Fixed Apple TV Plex playback sessions failing to start for some STRM items. + The gateway enters 302 playback only after Plex confirms the current user, + media selection, and Direct Play decision. Other requests remain transparent. ## [0.1.0] - 2026-08-27 @@ -41,6 +70,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 gateway redirects playback. - Logs and metrics exclude Plex tokens and complete signed media URLs. -[Unreleased]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.1...HEAD +[Unreleased]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.2...HEAD +[0.1.2]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/InfinityPacer/plex-gateway/releases/tag/v0.1.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6223497..1b90369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,5 @@ # 更新日志 -[English](CHANGELOG.en.md) - 本文件记录项目的所有重要变更。 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), @@ -9,18 +7,41 @@ ## [未发布] +## [0.1.2] - 2026-08-30 + +### 新增 + +- 支持 Plex for Apple TV 正确识别 STRM 的 HDR 和 Dolby Vision 信息。Gateway 会补齐 + Plex 缺失的 MediaInfo,同时保留 Plex 自己已有的媒体信息和播放决策。 +- 自动探测并缓存 STRM 的视频、音频和字幕信息。开始播放时优先处理当前影片,配置 Plex + 管理 Token 后还会预热相邻剧集,减少连续观看时的等待。 +- 增加默认关闭的实验性 Apple TV Dolby Vision Profile 5 播放拒绝开关。 +- 增加 MediaInfo 缓存热清理命令,清理前自动备份 Gateway 数据库,无需停止容器。 + +### 变更 + +- 优化长剧集页面加载。Gateway 会合并重复的媒体详情请求并保护 Plex Server,首次访问 + 没有缓存时先返回 Plex 原始结果,再在后台补齐 MediaInfo。 +- MediaInfo、请求保护和脱敏追踪默认开启。官方镜像内置 ffprobe,使用 `/app_data` + 保存数据,并通过 `app.env` 管理运行配置。 +- 已验证 Infuse、Plex iOS 和 Plex for Apple TV 的 Direct Play。本版本不写 Plex 数据库, + 本地媒体继续透明转发。 + +### 修复 + +- 修复远程探测、请求取消或请求合并失败时可能造成的重复等待和额外 Plex 请求。 + ## [0.1.1] - 2026-08-28 ### 新增 -- 为单项和批量 Plex metadata 请求提供可配置的并发保护,限制原生客户端 - metadata 扇出对 Plex Server 的压力。 -- 提供 Metadata Guard 的准入、排队、活动和超时指标。 +- 增加 Plex 媒体详情请求并发保护和监控指标,避免原生客户端一次打开大量媒体时压垮 + Plex Server。 ### 修复 -- 支持 Apple TV Plex 客户端对 STRM 媒体的 Direct Play 决策和播放协商, - 同时保留 Plex 对客户端凭据和媒体 Part 的授权。 +- 修复 Apple TV Plex 客户端无法为部分 STRM 创建播放会话的问题。Gateway 只在 Plex + 已确认当前用户、媒体和 Direct Play 决策后才进入 302 播放,其他请求继续透明转发。 ## [0.1.0] - 2026-08-27 @@ -38,6 +59,7 @@ - Gateway 返回云端播放重定向前,由 Plex 使用当前客户端凭据授权对应 Part。 - 日志和指标不包含 Plex Token 或完整的签名媒体 URL。 -[未发布]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.1...HEAD +[未发布]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.2...HEAD +[0.1.2]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/InfinityPacer/plex-gateway/releases/tag/v0.1.0 diff --git a/Dockerfile b/Dockerfile index e6d1348..243b333 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ RUN apk add --no-cache ca-certificates ffmpeg \ && adduser -D -H -u 1000 -G gateway -s /sbin/nologin gateway \ && install -d -o gateway -g gateway /app_data COPY --from=build /plex-gateway /usr/local/bin/plex-gateway +COPY --chmod=755 scripts/reset-mediainfo-cache.sh /usr/local/bin/plex-gateway-reset-mediainfo-cache WORKDIR /app_data USER gateway EXPOSE 32400 diff --git a/README.en.md b/README.en.md index ad4a5f9..e4d4966 100644 --- a/README.en.md +++ b/README.en.md @@ -25,15 +25,26 @@ to Plex unchanged; the gateway never reads a media prefix to infer STRM state. Some official and third-party clients begin with a universal playback decision instead of requesting the Part directly. For an authenticated, exactly selected -STRM Part, the gateway opts that request into Plex Direct Play while preserving -the complete client profile and Plex's own decision response. A short-lived -grant is created only after that complete response explicitly marks the same -Part as Direct Play. If the client then requests universal `start`, +STRM Part, the gateway preserves the complete client profile and asks Plex to +produce its full Direct Play decision response. The response may then receive +the same fail-open technical MediaInfo projection used for metadata. A +short-lived grant is created only after Plex explicitly marks the same Part as +Direct Play. If the client then requests universal `start`, `start.mpd`, or `start.m3u8`, the gateway requires that grant from the same playback session and exact Media/Part, authorizes the Part again, and returns the same CDN redirect without starting Plex Transcoder or synthesizing Plex metadata. +The gateway applies the same MediaInfo projection rules to every client. A +disabled-by-default experimental playback veto checks the Plex for Apple TV, +tvOS, Dolby Vision Profile 5 combination only after Plex returns Direct Play +for the same STRM Part and MediaInfo enrichment already has a fresh record. A +match returns a Plex-compatible unsupported decision; every other case keeps +the existing playback path. The veto stores no verdict, does not intercept the +Part path, and starts no additional probe. Apple TV hardware supports Profile +5, while compatibility still varies with Plex version, container, and source, +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 @@ -48,7 +59,7 @@ media in Plex Web remains ordinary proxied Plex traffic. | Local Plex media | Supported by transparent proxy | The gateway does not rewrite local Parts. | | 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 | STRM uses the universal decision/start redirect path; local media remains transparent proxy traffic. | +| 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. | This is an independent community project. It is not affiliated with, endorsed @@ -83,6 +94,10 @@ The gateway remains a transparent proxy when `MEDIAVAULT_URL` and `PATH_MAPPINGS` are both unset. Configure them together to enable cloud redirect handling. +Before using `docker-compose.example.yml`, copy `app.env.example` to `app.env` +and set the deployment addresses, path mappings, and optional token. The +Compose example reads it through `env_file: ./app.env`. + ```sh PLEX_URL=http://plex:32400 \ MEDIAVAULT_URL=http://mediavault:7811 \ @@ -98,6 +113,7 @@ Important environment variables: | Variable | Default | Purpose | | --- | --- | --- | | `PLEX_URL` | required | Internal Plex origin; credentials are rejected. | +| `PLEX_TOKEN` | disabled | Optional Plex management token used only for startup validation and nearby-item discovery. Current-item prewarming remains available without it. | | `MEDIAVAULT_URL` | disabled | Internal MediaVault HTTP(S) origin. | | `PATH_MAPPINGS` | disabled | JSON array of Plex-to-container STRM prefixes. | | `LISTEN_ADDR` | `:32400` | Gateway listener. | @@ -106,22 +122,42 @@ Important environment variables: | `OBSERVE_MAX_BYTES` | `8388608` | Maximum metadata body copied for observation. | | `PART_PROBE_TIMEOUT` | `15s` | Timeout for the bodyless Plex Part authorization probe. | | `CLOUD_EXTENSIONS` | `.strm` | Comma-separated cloud control-file extensions. | -| `TRACE_ENABLED` | `false` | Enable sanitized Plex request-order tracing. | -| `METADATA_GUARD_ENABLED` | `false` | Limit single-item detailed metadata requests before they enter Plex. | -| `METADATA_GUARD_GLOBAL_CONCURRENCY` | `8` | Shared detailed metadata concurrency limit across all clients. | -| `METADATA_GUARD_CLIENT_CONCURRENCY` | `4` | Detailed metadata concurrency limit for each Plex client identifier. | -| `METADATA_GUARD_BATCH_ENABLED` | `false` | Limit comma-separated batch metadata reads before they enter Plex. | -| `METADATA_GUARD_BATCH_CONCURRENCY` | `3` | Shared batch metadata concurrency limit across all clients. | +| `TRACE_ENABLED` | `true` | Enable sanitized Plex request-order tracing. | +| `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. | +| `METADATA_GUARD_CLIENT_CONCURRENCY` | `16` | Detailed metadata concurrency limit for each Plex client identifier. | +| `METADATA_GUARD_BATCH_ENABLED` | `true` | Limit comma-separated batch metadata reads before they enter Plex. | +| `METADATA_GUARD_BATCH_CONCURRENCY` | `4` | Shared batch metadata concurrency limit across all clients. | | `METADATA_GUARD_QUEUE_TIMEOUT` | `10s` | Maximum admission wait before returning `429`. | +| `METADATA_COALESCE_ENABLED` | `true` | Combine equivalent authenticated single-item metadata GETs into native Plex batches. | +| `METADATA_COALESCE_WINDOW` | `20ms` | Micro-batch collection window, up to `100ms`. | +| `METADATA_COALESCE_MAX_ITEMS` | `32` | Unique ratingKey limit per synthesized batch, from `2` to `32`. | +| `METADATA_COALESCE_TIMEOUT` | `5s` | Total timeout for the Plex batch request and response split. | | `MEDIAINFO_ENABLED` | `true` | Enable the MediaInfo cache, bounded probes, and single-item metadata enrichment; initialization failures do not affect transparent proxying. | | `DATABASE_PATH` | `./data/plex-gateway.db` | Gateway SQLite database path; the container image defaults to `/app_data/plex-gateway.db`. | -| `MEDIAINFO_USER_AGENT` | `Infuse-Library/8.4.4` | Fallback User-Agent for background probes without an active client context. | -| `MEDIAINFO_COLD_WAIT` | `5s` | Cold-cache wait ceiling for one metadata item; timeout returns the original Plex response while probing continues. | +| `MEDIAINFO_PLAYBACK_QUEUE_SIZE` | `16` | Independent P0 queue capacity for actual playback work. | +| `MEDIAINFO_NEIGHBOR_QUEUE_SIZE` | `50` | P1 queue capacity for nearby-item prewarming. | +| `MEDIAINFO_METADATA_QUEUE_SIZE` | `50` | P2 queue capacity for foreground metadata misses. | +| `MEDIAINFO_PENDING_TTL` | `5m` | Lifetime of P1/P2 work that has not started. | +| `MEDIAINFO_BACKGROUND_INTERVAL` | `5s` | Global minimum interval between P1/P2 remote probes after L1 and SQLite miss. | +| `MEDIAINFO_USER_AGENT` | `Infuse-Library/8.5.1` | Fallback User-Agent for background probes without an active client context. | +| `MEDIAINFO_COLD_WAIT` | `5s` | Cold-cache wait ceiling for an actual playback decision; metadata browsing misses never use this wait. | +| `PLAYBACK_VETO_ENABLED` | `false` | Enable the experimental Apple TV Plex Dolby Vision Profile 5 veto; it only reuses fresh MediaInfo already obtained by the decision path. | | `MEDIAINFO_RESPONSE_MAX_BYTES` | `8388608` | Maximum size of one Plex metadata response buffered for enrichment. | -| `MEDIAINFO_ENRICHMENT_CONCURRENCY` | `4` | Maximum single-item metadata responses buffered and waiting for MediaInfo concurrently. | - -Plex tokens are not stored in configuration. Ordinary Plex requests remain -transparent. For cloud playback, all client request headers are forwarded to +| `MEDIAINFO_ENRICHMENT_CONCURRENCY` | `8` | Maximum single-item metadata responses buffered for concurrent L1 projection. | +| `MEDIAINFO_PREWARM_BEFORE` | `2` | Number of nearby items before the current item to prewarm. | +| `MEDIAINFO_PREWARM_AFTER` | `3` | Number of nearby items after the current item to prewarm. | + +Ordinary Plex requests remain transparent, while cloud playback sends client +headers to the trusted MediaVault origin under the contract below. The optional +management `PLEX_TOKEN` is injected through the environment and sent only to the +configured Plex origin for nearby-item discovery. It is not written to the +database, logs, or tasks and is never sent to MediaVault, the CDN, or ffprobe. +Compose deployments can place +it in an ignored `app.env`; see [app.env.example](app.env.example). + +For cloud playback, all client request headers are forwarded to the configured MediaVault origin so it can generate a direct URL for the same client context. The internal lookup always uses `GET`, allowing a client `HEAD` request to receive the same 302 even when MediaVault does not support `HEAD`. @@ -129,10 +165,107 @@ Deploy MediaVault as a trusted upstream and keep resolver requests and headers out of logs. Clients connect to the gateway as they would connect to Plex and authenticate -through Plex. The gateway does not need a Plex username, password, or static -token. MediaVault's API key for `/api/v1` integrations is also not required for +through Plex. The gateway does not need a Plex username or password, and +playback does not depend on the management token. MediaVault's API key for `/api/v1` integrations is also not required for the STRM `/redirect` playback contract. +### Playback veto + +With `PLAYBACK_VETO_ENABLED=false`, no veto function is created and the decision +path performs only one nil check. When enabled, the gateway returns an +unsupported decision only for complete MediaInfo matching Plex for Apple TV, +tvOS, Dolby Vision Profile 5, and BL compatibility ID 0. It reuses the fresh +record already obtained by MediaInfo enrichment, performs no additional cache, +SQLite, MediaVault, or CDN work, stores no state, and never enters Part or +universal start handling. Missing, stale, conflicting, or unmatched evidence +always fails open. + +This is an experimental switch for deployments with a known source +compatibility issue, not a claim that Apple TV generally lacks Dolby Vision +Profile 5 support. See +[docs/architecture.md](docs/architecture.md) and +[docs/performance-matrix.md](docs/performance-matrix.md). + +### MediaInfo response enrichment + +For an authenticated single-item STRM metadata request, the gateway can fill +missing Media, Part, and Stream technical fields from its L1 or SQLite record. +When a Plex Part has no Stream elements, ffprobe stream types and source indexes +are used to create descriptive video, audio, and subtitle Streams with fields +such as HDR10, Dolby Vision, bit depth, codec, codecID, bitrate, channel layout, and +language code. Generated Streams never contain Plex Stream IDs or playback +selection fields such as `selected`, `default`, or `decision`. When Plex already +has Streams, only missing fields on identity-matched Streams are filled. Missing +sibling Streams are not created and existing Plex values are not overwritten. +This release writes fallback probe results only to the gateway's SQLite store and +does not write to the Plex DB. Production Plex DB writes are a next-stage +evaluation after coverage, compatibility, backup, and rollback validation. + +The L1 hot path returns without synchronously reading SQLite for the request. +Access renewal may touch SQLite asynchronously, so persistence I/O stays out of +the request critical path. Hot-cache projection remains concurrent. A metadata +browsing miss immediately preserves the Plex response and only offers bounded P2 +work in memory; it never waits for SQLite, MediaVault, or the CDN. Successful +background work later persists the result to L1 and SQLite. Actual playback +decisions retain an independent P0 cold-probe budget so playback negotiation can +receive technical fields without blocking home or season screens. + +When ffprobe cannot report the total media size, the gateway sends one +`Range: bytes=0-0` request to the same temporary direct URL with the same +User-Agent used for that MediaVault resolution and ffprobe task. The request is +capped at two seconds, accepts only a `206` response +with a valid `Content-Range` total, and never reads the media response body. +Timeouts, redirects, full `200` responses, and malformed headers are ignored +without discarding otherwise valid MediaInfo or delaying the 302 playback path. +The recovered size is persisted with the MediaInfo record, so fresh cache hits +do not repeat the CDN request. + +Background library synchronization can enumerate an entire library one item at +a time. Requests with `skipRefresh` whose product ends in `-Library` may consume +an existing MediaInfo cache record but never admit a cold probe. Ordinary +single-item cold misses also return the original response immediately and only +offer bounded P2 work; successful cloud 302 and nearby-window work retain +their existing boundaries. Browsing a library therefore cannot expand without +bound into full-library CDN ffprobe work. Any cache miss, timeout, unsupported +structure, or projection failure returns the original Plex response. + +### Nearby-item MediaInfo prewarming + +The gateway performs one bounded in-memory enqueue only after Plex has +authorized the cloud Part, MediaVault has returned the final direct URL, and the +gateway has written the 302. This signal means that the cloud redirect is ready; +it does not claim that the client followed the redirect or actually started +playback. Current-item prewarming does not require a management token. A valid +`PLEX_TOKEN` additionally enables nearby-item discovery. + +The current item enters the P0 actual-playback queue. The background +coordinator prefers explicit Plex playQueue order and permits movie, cross-show, +and multi-Media/Part entries. Every candidate is stored under its own PartID and +STRM fingerprint. The current item can only be prioritized in the queue and +cannot preempt a probe already running. Without a usable queue, Plex episode and +season response order is used, including specials and entries with missing +indices. + +The default window is two items before and three after, with following items +submitted first. The combined configurable window is capped at 50. Prepared +neighbors are offered to P1 immediately without blocking. Only genuine L1 and +SQLite misses are subject to the global five-second minimum remote-start +interval, while the MediaInfo worker defaults to one concurrent probe to avoid +MediaVault/CDN bursts. A rapid A/B/C switch submits each new current item to P0 +immediately and cancels only old-window entries that were not offered. Offered +or running work retains its own identity and is deduplicated by singleflight. A +running background probe is not preempted by a current item. + +The gateway persists parsed MediaInfo, not short-lived CDN URLs or raw head/tail +bytes. MediaVault upload precaching applies only to files uploaded through +MediaVault. A future stable MediaInfo or precache API can become the preferred +provider, with CDN ffprobe as fallback. `MEDIAINFO_NEGATIVE_TTL` suppresses +repeated failures; the coordinator does not perform blind retries without the +final probe error class. Plex discovery, STRM reads, fingerprinting, and SQLite +access all run after the 302 path. A missing or invalid token disables only +nearby-item discovery, not current-item prewarming, transparent proxying, +metadata enrichment, or playback redirects. + ### Metadata concurrency protection Some clients fan out many `GET/HEAD /library/metadata/{ratingKey}` requests @@ -154,6 +287,11 @@ Batch reads do not consume the global or per-client slots reserved for interactive single-item metadata requests, and metadata mutations do not enter the batch pool. +The current Guard defaults are global 16 for single-item metadata, per-client +16, and batch 4. These values were selected from bounded NAS load tests and +real Apple TV long-season traces; raising them remains an evidence-driven +operator decision. + ## Advertise the gateway through Plex In Plex Web, open the server's **Settings > Network > Custom server access @@ -235,15 +373,46 @@ That topology is outside the exclusive-gateway deployment contract. ## Endpoints -- `GET /health` returns process health plus a credential-free MediaInfo - availability, cache, and queue summary. +- `GET /health` returns process health plus credential-free MediaInfo cache, + probe-queue, and nearby-item prewarm summaries. - `GET /metrics` returns fixed-shape JSON counters plus resolver and complete redirect-path latency totals, samples, last values, and maxima. When metadata protection is enabled, it also reports admission, timeout, active, and queued - counts. No metric contains request labels or credentials. + counts. MediaInfo scheduler outcomes are additionally split into fixed + P0/P1/P2 sections. No metric contains request labels or credentials. - Every other endpoint follows the Plex proxy or Direct Play interception rules described in [docs/architecture.md](docs/architecture.md). +### Resetting the MediaInfo hot and cold caches + +Run this command from the Compose project directory: + +```sh +./scripts/reset-mediainfo-cache.sh +``` + +The script hot-clears the cache through a container-loopback maintenance +endpoint, so the Gateway and Plex proxy remain online. It first backs up the +complete SQLite database under `app_data/backups/`, then clears L1, the current +MediaInfo queues, and the rebuildable `media_info_records`. The database, +migration history, and tables owned by other modules are preserved. + +Set the Compose service name when it is not `plex-gateway`: + +```sh +PLEX_GATEWAY_COMPOSE_SERVICE=gateway ./scripts/reset-mediainfo-cache.sh +``` + +The image also contains `/usr/local/bin/plex-gateway-reset-mediainfo-cache`, so +it can be invoked directly: + +```sh +docker exec plex-gateway plex-gateway-reset-mediainfo-cache +``` + +The maintenance endpoint accepts only container-local loopback requests. It is +not exposed through the public Gateway endpoint or reverse proxy. + ## Probe a Plex item `plex-probe` records Part identifiers and paths for one metadata item. Keep the diff --git a/README.md b/README.md index ac33f42..c61748f 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,20 @@ Part。Part 缓存未命中时,请求会原样发送给 Plex;Gateway 不会 推断媒体是否为 STRM。 部分官方和第三方客户端会先发起通用播放决策,而不是直接请求 Part。对于已认证且 -明确选中的 STRM Part,Gateway 会在保留完整客户端 profile 和 Plex 自身决策响应的 -前提下,请求 Plex 将该播放选择为 Direct Play。只有当该完整响应明确将同一个 +明确选中的 STRM Part,Gateway 会在保留完整客户端 profile、并由 Plex 生成完整决策 +响应的前提下,请求 Plex 将该播放选择为 Direct Play。只有当该完整响应明确将同一个 Part 标记为 Direct Play 后,Gateway 才会创建短期授权。如果客户端随后请求通用的 `start`、`start.mpd` 或 `start.m3u8`,Gateway 会要求该播放会话及精确 Media/Part 对应的授权,再次为 Part 请求授权,并返回同一个 CDN 重定向,而不会启动 Plex Transcoder 或合成 Plex 元数据。 +Gateway 对所有客户端使用同一套 MediaInfo 投影规则。默认关闭的实验性播放 veto 只在 +Plex 已返回同一 STRM Part 的 Direct Play decision、且本次 MediaInfo 增强已经取得新鲜 +记录时检查 Apple TV Plex 与 Dolby Vision Profile 5 组合。命中时返回 Plex 兼容的 +不支持决策,其他情况保持原播放链路。该判断不保存 verdict、不拦截 Part,也不会触发 +额外探测。Apple TV 硬件支持 Profile 5,实际兼容性还受 Plex 版本、容器和片源影响, +因此 veto 默认关闭。 + 能够在这些路径上接受原始媒体重定向的原生客户端可以使用此链路。Plex Web 不支持 云端播放:其浏览器播放器会将 `start.mpd` 作为 DASH manifest 获取,而重定向目标 是原始媒体文件,且可能不允许跨源浏览器请求。为 Gateway 的重定向添加 CORS header @@ -39,7 +46,7 @@ Transcoder 或合成 Plex 元数据。 | Plex 本地媒体 | 透明代理支持 | Gateway 不会重写本地 Part。 | | Infuse Direct Play | 已验证 Direct Play | 使用 Plex Part 重定向路径。 | | Plex iOS ExperimentalPlayer | 已验证 Direct Play | STRM 使用通用 decision/start 重定向链路。 | -| Plex for Apple TV | 已验证 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、MediaVault、Infuse 或其各自所有者没有关联, @@ -71,6 +78,10 @@ MediaVault URL 输入会被重写为配置的内部 MediaVault origin。同源 当 `MEDIAVAULT_URL` 和 `PATH_MAPPINGS` 都未设置时,Gateway 仍作为透明代理运行。 要启用云端重定向处理,请同时配置这两个变量。 +使用 `docker-compose.example.yml` 前,先将 `app.env.example` 复制为 `app.env`, +再按部署环境填写地址、路径映射和可选 Token。Compose 示例会通过 +`env_file: ./app.env` 读取该文件。 + ```sh PLEX_URL=http://plex:32400 \ MEDIAVAULT_URL=http://mediavault:7811 \ @@ -86,6 +97,7 @@ go run ./cmd/plex-gateway | 变量 | 默认值 | 用途 | | --- | --- | --- | | `PLEX_URL` | 必填 | Plex 内部 origin;配置中的凭据会被拒绝。 | +| `PLEX_TOKEN` | 禁用 | 可选 Plex 管理 Token,仅用于启动验证和邻近媒体发现。缺失时仍预热当前播放项。 | | `MEDIAVAULT_URL` | 禁用 | MediaVault 内部 HTTP(S) origin。 | | `PATH_MAPPINGS` | 禁用 | Plex 到容器内 STRM 路径的 JSON 映射数组。 | | `LISTEN_ADDR` | `:32400` | Gateway 监听地址。 | @@ -94,45 +106,154 @@ go run ./cmd/plex-gateway | `OBSERVE_MAX_BYTES` | `8388608` | 观察解析时复制的最大元数据 body 大小。 | | `PART_PROBE_TIMEOUT` | `15s` | 无 body 的 Plex Part 授权 probe 超时时间。 | | `CLOUD_EXTENSIONS` | `.strm` | 以逗号分隔的云端控制文件扩展名。 | -| `TRACE_ENABLED` | `false` | 启用经过脱敏的 Plex 请求顺序追踪。 | -| `METADATA_GUARD_ENABLED` | `false` | 限制单项详细 metadata 请求进入 Plex 的并发量。 | -| `METADATA_GUARD_GLOBAL_CONCURRENCY` | `8` | 所有客户端共享的详细 metadata 并发上限。 | -| `METADATA_GUARD_CLIENT_CONCURRENCY` | `4` | 每个 Plex 客户端标识的详细 metadata 并发上限。 | -| `METADATA_GUARD_BATCH_ENABLED` | `false` | 限制逗号分隔的批量 metadata 读取进入 Plex 的并发量。 | -| `METADATA_GUARD_BATCH_CONCURRENCY` | `3` | 所有客户端共享的批量 metadata 并发上限。 | +| `TRACE_ENABLED` | `true` | 启用经过脱敏的 Plex 请求顺序追踪。 | +| `METADATA_ANALYSIS_FILTER_ENABLED` | `true` | 从详细 metadata 读取中移除会触发 Plex 后台分析的 `asyncAugmentMetadata`;保留 `checkFiles`。 | +| `METADATA_GUARD_ENABLED` | `true` | 限制单项详细 metadata 请求进入 Plex 的并发量。 | +| `METADATA_GUARD_GLOBAL_CONCURRENCY` | `16` | 所有客户端共享的详细 metadata 并发上限。 | +| `METADATA_GUARD_CLIENT_CONCURRENCY` | `16` | 每个 Plex 客户端标识的详细 metadata 并发上限。 | +| `METADATA_GUARD_BATCH_ENABLED` | `true` | 限制逗号分隔的批量 metadata 读取进入 Plex 的并发量。 | +| `METADATA_GUARD_BATCH_CONCURRENCY` | `4` | 所有客户端共享的批量 metadata 并发上限。 | | `METADATA_GUARD_QUEUE_TIMEOUT` | `10s` | 等待准入的最长时间,超时返回 `429`。 | +| `METADATA_COALESCE_ENABLED` | `true` | 将认证和表示上下文完全一致的单项 metadata GET 合并为 Plex 原生 batch。 | +| `METADATA_COALESCE_WINDOW` | `20ms` | 同组请求的微批聚合窗口,最大 `100ms`。 | +| `METADATA_COALESCE_MAX_ITEMS` | `32` | 单个合成 batch 的唯一 ratingKey 上限,允许 `2-32`。 | +| `METADATA_COALESCE_TIMEOUT` | `5s` | 合成 batch 请求 Plex 并完成拆分的总超时。 | | `MEDIAINFO_ENABLED` | `true` | 启用 MediaInfo 缓存、受限探测和单项 metadata 增强;初始化失败不影响透明代理。 | | `DATABASE_PATH` | `./data/plex-gateway.db` | Gateway SQLite 持久数据库路径;容器镜像默认使用 `/app_data/plex-gateway.db`。 | -| `MEDIAINFO_USER_AGENT` | `Infuse-Library/8.4.4` | 没有活动客户端上下文时,后台探测使用的 fallback User-Agent。 | -| `MEDIAINFO_COLD_WAIT` | `5s` | 单项 metadata 冷缓存等待上限;超时返回原 Plex 响应,探测继续。 | +| `MEDIAINFO_PLAYBACK_QUEUE_SIZE` | `16` | P0 实际播放任务的独立队列容量。 | +| `MEDIAINFO_NEIGHBOR_QUEUE_SIZE` | `50` | P1 邻近项预热的队列容量。 | +| `MEDIAINFO_METADATA_QUEUE_SIZE` | `50` | 前台 metadata 冷 miss 的 P2 队列容量。 | +| `MEDIAINFO_PENDING_TTL` | `5m` | P1/P2 未开始任务的保留时间。 | +| `MEDIAINFO_BACKGROUND_INTERVAL` | `5s` | L1/SQLite miss 后 P1/P2 远程探测的全局最小启动间隔。 | +| `MEDIAINFO_USER_AGENT` | `Infuse-Library/8.5.1` | 没有活动客户端上下文时,后台探测使用的 fallback User-Agent。 | +| `MEDIAINFO_COLD_WAIT` | `5s` | 实际播放 decision 的冷缓存等待上限;metadata 浏览冷 miss 不使用该等待。 | +| `PLAYBACK_VETO_ENABLED` | `false` | 启用实验性 Apple TV Plex Dolby Vision Profile 5 veto;只复用 decision 已取得的新鲜 MediaInfo,不发起额外探测。 | | `MEDIAINFO_RESPONSE_MAX_BYTES` | `8388608` | 可以缓冲并尝试增强的单个 Plex metadata 响应上限。 | -| `MEDIAINFO_ENRICHMENT_CONCURRENCY` | `4` | 同时缓冲和等待 MediaInfo 的单项 metadata 响应上限。 | - -Plex Token 不会保存到配置中。普通 Plex 请求保持透明转发。对于云端播放,客户端的 +| `MEDIAINFO_ENRICHMENT_CONCURRENCY` | `8` | 同时缓冲并尝试 L1 投影的单项 metadata 响应上限。 | +| `MEDIAINFO_PREWARM_BEFORE` | `2` | 当前项之前的邻近媒体预热数量。 | +| `MEDIAINFO_PREWARM_AFTER` | `3` | 当前项之后的邻近媒体预热数量。 | + +普通 Plex 请求保持透明转发,云端播放请求按下文契约将客户端 header 发送给可信 +MediaVault。可选管理 `PLEX_TOKEN` 通过环境变量注入,只发送到配置的 Plex origin, +用于邻近媒体发现;不会写入数据库、日志或任务,也不会发送给 MediaVault、CDN 或 +ffprobe。Compose 部署可将它放在不纳入 +Git 的 `app.env` 中。推荐让 Compose 只通过 `env_file: ./app.env` 加载运行配置,完整的 +中文变量说明和默认值参考 [app.env.example](app.env.example)。 + +对于云端播放,客户端的 所有请求 header 都会转发到配置的 MediaVault origin,使其能够为相同客户端上下文 生成直链。内部查找始终使用 `GET`,因此即使 MediaVault 不支持 `HEAD`,客户端的 `HEAD` 请求也能收到相同的 302。请将 MediaVault 部署为可信上游,并避免将解析请求 及其 header 写入日志。 客户端像连接 Plex 一样连接 Gateway,并通过 Plex 完成身份认证。Gateway 不需要 -单独配置 Plex 用户名、密码或静态 Token。STRM `/redirect` 播放协议也不需要 +单独配置 Plex 用户名或密码,播放也不依赖管理 Token。STRM `/redirect` 播放协议不需要 MediaVault 用于 `/api/v1` 集成的 API key。 -### Metadata 并发保护 +### 播放 veto + +`PLAYBACK_VETO_ENABLED=false` 时不创建 veto 函数,decision 主线路只经过一次 nil 判断。 +显式开启后,Gateway 只对 Apple TV Plex、tvOS、Dolby Vision Profile 5 且 BL +compatibility ID 为 0 的完整 MediaInfo 返回不支持 decision。它只读取 MediaInfo 增强 +已经取得的新鲜记录,不新增缓存读取、SQLite、MediaVault 或 CDN 请求,不创建状态, +也不进入 Part 和 universal start 路径。证据缺失、过期、冲突或不匹配一律 fail-open。 + +这是用于已知片源兼容问题的实验性开关,不代表 Apple TV 普遍不支持 Dolby Vision +Profile 5。配置和性能验收见 +[docs/architecture.md](docs/architecture.md) 与 +[docs/performance-matrix.md](docs/performance-matrix.md)。 + +### MediaInfo 响应增强 + +对于已认证的单项 STRM metadata 请求,Gateway 可以从 L1 或 SQLite 记录补充缺失的 +Media、Part 和 Stream 技术字段。若 Plex 的 Part 完全没有 Stream,Gateway 会使用 +ffprobe 的流类型和源索引创建描述性的视频、音频和字幕 Stream,并投影 HDR10、Dolby +Vision、bit depth、codec、codecID、bitrate、声道布局和语言码等字段。合成 Stream 不包含 Plex +Stream ID,也不生成 `selected`、`default` 或 `decision` 等播放选择字段。Plex 已经存在 +Stream 时只补充身份匹配 Stream 的缺失字段,不创建缺少的其他流,也不覆盖 Plex 值。 +当前版本只将兜底探测结果写入 Gateway SQLite,不写入 Plex DB。Plex DB 生产写入属于下一 +阶段评估内容,需先完成覆盖率、兼容性、备份和回滚验证。 + +L1 热路径直接返回,不会为了本次请求同步读取 SQLite。访问续期可能异步 touch SQLite, +不会把持久化 I/O 放进请求关键路径。热缓存投影可以并发执行;metadata 浏览冷 miss 立即 +返回原始 Plex 响应,只通过内存投递有界 P2,不等待 SQLite、MediaVault 或 CDN。后台任务 +成功后才写入 L1 和 SQLite。实际播放 decision 保留独立的 P0 冷探测预算,因此技术字段可 +在播放协商时补齐,而首屏和季页面不会被远程分析阻塞。 + +当 ffprobe 无法返回媒体总大小时,Gateway 会使用与本次 MediaVault 解析和 ffprobe 相同的 +User-Agent 对同一临时直链发送一次 `Range: bytes=0-0`。该请求最多等待 `2s`,只接受包含有效 +`Content-Range` 总大小的 `206` 响应,并且不读取媒体响应体。超时、重定向、完整 `200` +响应或字段格式异常都会忽略,不影响已经成功取得的其他 MediaInfo,也不会阻塞 302 +播放路径。成功解析后的大小随 MediaInfo 一起进入 L1 和 SQLite,fresh cache hit 不会 +重复请求 CDN。 + +Infuse 等客户端的后台媒体库同步可能逐项请求整个库。带 `skipRefresh` 且产品名以 +`-Library` 结尾的后台同步请求只读取现有 MediaInfo 缓存,不创建冷探测。普通单项访问 +的冷 miss 也立即返回原始响应,仅非阻塞投递有界 P2;成功的云端 302 和邻近窗口继续按 +既定边界提交。因此浏览媒体库不会无界 +扩散成全库 CDN ffprobe。任何缓存 miss、超时、结构不支持或投影失败都会返回原始 Plex +response。 + +### 邻近媒体 MediaInfo 预热 + +Gateway 在云端 Part 已通过 Plex 授权、MediaVault 已返回最终直链且 Gateway 已写出 +302 后,只向有界内存协调器投递一次事件。该信号表示“云端重定向已就绪”,不宣称 +客户端已经跟随 302 或真实起播。当前项预热不需要管理 Token;配置有效 +`PLEX_TOKEN` 后才会额外发现邻近媒体。 + +当前项会进入 P0 实际播放队列。后台优先使用明确的 Plex playQueue 顺序,并允许其中的 +电影、跨剧条目和多 Media/Part;候选始终采用自身的 PartID 与 STRM fingerprint 落库, +不会写入当前项。当前项只能优先排队,不能抢占正在运行的探测。没有可靠 playQueue 时, +按 Plex 返回的剧集和季顺序查找邻近项,包括 S00 和缺失索引的条目。 + +默认窗口为前 `2`、后 `3`,后续项先提交;两个方向合计最多配置 `50`。邻近项发现和准备 +完成后立即非阻塞投递 P1;只有 L1 和 SQLite 都未命中时,远程探测才受全局最小 `5s` +启动间隔约束。MediaInfo worker 默认并发为 `1`,用于避免 MediaVault/CDN 请求突发。 +快速切换 A、B、C 时,新当前项立即进入 P0,旧窗口中尚未投递的候选会被取消;已经投递 +或正在运行的任务仍按自己的身份完成并由 singleflight 去重。正在运行的后台探测不会被 +当前项抢占。整个发现、STRM 读取、指纹计算和 SQLite 查询均在 302 响应之后执行。 + +Gateway 只持久化解析后的 MediaInfo,不缓存短期 CDN URL 或原始文件头尾。MediaVault 的 +上传预缓存只覆盖经其上传的新文件;若未来提供稳定 MediaInfo/预缓存读取 API,可作为优先 +Provider,未命中时再回退 CDN ffprobe。探测失败由 `MEDIAINFO_NEGATIVE_TTL` 抑制重复请求; +协调器不执行无法感知最终错误类型的盲目重试。Token 缺失或失效只禁用邻近媒体发现, +不影响当前项预热、透明代理、metadata 增强或播放重定向。 + +### Metadata 微批与并发保护 部分客户端会在打开大型媒体库时并发请求大量 `GET/HEAD /library/metadata/{ratingKey}`。启用 `METADATA_GUARD_ENABLED` 后, Gateway 会在这些单项详细 metadata 请求进入 Plex 前应用全局和单客户端并发上限。 客户端标识只用于进程内限流,并以摘要形式保存,不会写入日志或 metrics。 +`METADATA_COALESCE_ENABLED` 默认将短窗口内认证、原始 query、完整客户端 Header、内容 +协商和远端身份完全一致的单项 `GET` 合成为 Plex 原生逗号 batch,默认最多包含 `32` 个唯一 +ratingKey,可配置上限同样为 `32`。Gateway 按 ratingKey 拆分 XML 或 JSON 响应,并为每个 +原请求重新计算 `Content-Length`;gzip 会解压、拆分后重新编码。相同 ratingKey 的重复请求 +共享同一 batch 结果。Token、Cookie、Authorization、客户端 profile、query 或身份不同的 +请求绝不合批。 + +微批不缓存 metadata,也不处理 `HEAD`、Range、条件请求、请求体、播放路径、图片、时间线 +或写请求。Plex batch 超时、返回非 200、结构异常、缺项、编码不支持或超过 body 上限时, +每个唯一 ratingKey 最多经过单项 Guard 回退一次,重复请求共享该结果;不同条目的回退仍受 +Guard 并发与排队上限保护。同组请求会短暂停止再次合批,所有调用者取消后,上游 batch 也会取消。 + +`METADATA_ANALYSIS_FILTER_ENABLED` 默认从单项和逗号分隔的详细 metadata 读取中只移除 +`asyncAugmentMetadata`,避免浏览突发反复调度 Plex 后台分析。`checkFiles` 保持透传,因为 +它会影响 Plex 返回的 `Part.accessible` 与 `Part.exists`。过滤器保留其他 query 的原始编码, +不修改 Header、响应或 Plex 数据库,也可以独立关闭用于兼容性回归。 + 该保护不处理媒体库列表、时间线、观看状态、播放决策、`/library/parts` 或其他 Plex 路径。请求最多排队 `METADATA_GUARD_QUEUE_TIMEOUT`,超时后返回 `429`,不会绕过保护 -继续请求 Plex。此功能不缓存 metadata,也不改变 Plex 响应。 +继续请求 Plex。Guard 不缓存 metadata;微批仅在可证明等价的成功响应上进行协议拆分。 `METADATA_GUARD_BATCH_ENABLED` 使用独立的全局并发池保护 `GET/HEAD /library/metadata/1,2,...`。批量读取不会占用交互式单项 metadata 的全局或 单客户端槽位,metadata PUT 等修改请求也不会进入批量池。 +当前默认 Guard 为单项全局 16、单客户端 16、批量 4。合成 batch 使用批量池,异常回退使用 +单项全局与单客户端池。提高或关闭 Guard 必须以候选版本的真实客户端压测为依据。 + ## 通过 Plex 发布 Gateway 地址 在 Plex Web 中打开服务器的 **Settings > Network > Custom server access URLs**, @@ -202,13 +323,41 @@ Gateway 重新执行检查。 ## Endpoints -- `GET /health` 返回进程健康状态,以及不含媒体身份和凭据的 MediaInfo 可用性、缓存和队列摘要。 +- `GET /health` 返回进程健康状态,以及不含媒体身份和凭据的 MediaInfo 缓存、探测队列和邻近媒体预热摘要。 - `GET /metrics` 返回固定结构的 JSON 计数器,以及 resolver 和完整重定向链路的 - 延迟总计、样本数、最近值和最大值。启用 metadata 保护时还会返回准入、超时、 - 活动和排队计数,所有指标都不包含请求标签或凭据。 + 延迟总计、样本数、最近值和最大值。`metadata_analysis_params_removed_total` 记录已过滤 + 的详细 metadata 请求;启用 metadata 保护时还会返回准入、超时、 + 活动和排队计数;微批另记录 offered、batch、唯一 item、fallback 和 active;MediaInfo + 调度器按固定 P0/P1/P2 结构返回准入和丢弃结果。所有指标都不包含请求标签或凭据。 - 其他所有 endpoint 都遵循 [docs/architecture.md](docs/architecture.md) 中说明 的 Plex 代理或 Direct Play 拦截规则。 +### 清空 MediaInfo 冷热缓存 + +在 Compose 项目目录执行: + +```sh +./scripts/reset-mediainfo-cache.sh +``` + +脚本通过容器内 loopback 维护接口热清理缓存,Gateway 和 Plex 代理不会停止。清理前 +会把完整 SQLite 数据库备份到 `app_data/backups/`,然后同步清空 L1、当前 MediaInfo +队列和可重建的 `media_info_records`。数据库、迁移记录和其他模块表不会被删除。 + +Compose 服务名不是 `plex-gateway` 时,可以通过环境变量指定: + +```sh +PLEX_GATEWAY_COMPOSE_SERVICE=gateway ./scripts/reset-mediainfo-cache.sh +``` + +镜像内同时提供 `/usr/local/bin/plex-gateway-reset-mediainfo-cache`,因此也可以直接执行: + +```sh +docker exec plex-gateway plex-gateway-reset-mediainfo-cache +``` + +维护接口只接受容器本机 loopback 请求,不通过公开 Gateway endpoint 或反向代理开放。 + ## 探测 Plex 媒体项 `plex-probe` 会记录一个元数据项的 Part 标识符和路径。请将 Token 放在环境变量中, diff --git a/VERSION b/VERSION index 17e51c3..d917d3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.1 +0.1.2 diff --git a/app.env.example b/app.env.example new file mode 100644 index 0000000..957d076 --- /dev/null +++ b/app.env.example @@ -0,0 +1,152 @@ +# Plex 内部地址,必填。必须指向 Gateway 可直连的 Plex origin,不能填写 Gateway 自己。 +PLEX_URL=http://plex:32400 + +# Plex 管理 Token。仅用于启动校验和邻近媒体发现;留空仍会预热当前播放项。 +# 不会写入日志、数据库或转发给 MediaVault/CDN。 +PLEX_TOKEN= + +# MediaVault 内部地址。启用 STRM 302 时必须与 PATH_MAPPINGS 同时配置。 +MEDIAVAULT_URL=http://mediavault:7811 + +# Plex 媒体路径到 Gateway 容器内 STRM 路径的 JSON 映射。多个映射写在同一行。 +PATH_MAPPINGS=[{"plex_prefix":"/media/cloud","local_prefix":"/strm"},{"plex_prefix":"/media/archive","local_prefix":"/archive-strm"}] + +# Gateway 监听地址。容器内通常保持 :32400。 +LISTEN_ADDR=:32400 + +# Gateway SQLite 数据库路径。容器部署应放在持久化的 /app_data 中。 +DATABASE_PATH=/app_data/plex-gateway.db + +# 日志级别,可选 debug、info、warn、error。 +LOG_LEVEL=info + +# 是否记录脱敏后的 Plex 请求顺序。默认开启,便于识别不同客户端的实际请求链路。 +TRACE_ENABLED=true + +# 识别为云端控制文件的扩展名,多个值以逗号分隔。 +CLOUD_EXTENSIONS=.strm + +# Part 内存缓存有效期。 +PART_CACHE_TTL=24h + +# MediaVault 直链解析的端到端超时。 +RESOLVER_TIMEOUT=15s + +# 观察 Plex metadata 响应时允许缓冲的最大字节数。 +OBSERVE_MAX_BYTES=8388608 + +# 无响应体 Part 授权请求和 Plex 管理请求的超时。 +PART_PROBE_TIMEOUT=15s + +# 是否移除详细 metadata 请求中的 asyncAugmentMetadata。 +# 默认开启,避免浏览请求调度 Plex 后台分析;checkFiles 仍透传以保留 accessible/exists。 +METADATA_ANALYSIS_FILTER_ENABLED=true + +# 是否限制单项详细 metadata 请求进入 Plex 的并发量。 +METADATA_GUARD_ENABLED=true + +# 所有客户端共享的单项详细 metadata 并发上限。 +METADATA_GUARD_GLOBAL_CONCURRENCY=16 + +# 单个 Plex 客户端的单项详细 metadata 并发上限。 +METADATA_GUARD_CLIENT_CONCURRENCY=16 + +# 是否为逗号分隔的批量 metadata 请求启用独立并发保护。 +METADATA_GUARD_BATCH_ENABLED=true + +# 批量 metadata 请求的全局并发上限。 +METADATA_GUARD_BATCH_CONCURRENCY=4 + +# metadata 并发槽位的最长等待时间,超时返回 429。 +METADATA_GUARD_QUEUE_TIMEOUT=10s + +# 是否将认证、查询和客户端上下文完全一致的单项 metadata GET 合并为 Plex 原生批量读取。 +METADATA_COALESCE_ENABLED=true + +# 等待同组请求到达的微批窗口。允许范围不超过 100ms。 +METADATA_COALESCE_WINDOW=20ms + +# 单个原生 Plex batch 最多包含的唯一 ratingKey,允许范围为 2-32。 +METADATA_COALESCE_MAX_ITEMS=32 + +# 单个合成 batch 请求 Plex 并完成拆分的总超时。 +METADATA_COALESCE_TIMEOUT=5s + +# 是否启用 MediaInfo 缓存、远程探测和 Plex metadata 增强。 +MEDIAINFO_ENABLED=true + +# ffprobe 可执行文件路径。官方镜像中可直接使用 ffprobe。 +MEDIAINFO_FFPROBE_PATH=ffprobe + +# 单次 MediaInfo 远程探测的总超时。 +MEDIAINFO_PROBE_TIMEOUT=20s + +# ffprobe 的分析窗口参数,不是 HTTP 总下载量硬上限;应结合超时、并发和实际流量调优。 +MEDIAINFO_PROBE_SIZE=8388608 + +# ffprobe 分析时长上限。 +MEDIAINFO_ANALYZE_DURATION=5s + +# ffprobe JSON 输出允许读取的最大字节数。 +MEDIAINFO_OUTPUT_MAX_BYTES=2097152 + +# MediaInfo worker 并发数。CDN 风控敏感环境建议保持 1。 +MEDIAINFO_CONCURRENCY=1 + +# P0 实际播放任务的独立内存队列上限,不与后台任务共享容量。 +MEDIAINFO_PLAYBACK_QUEUE_SIZE=16 + +# P1 当前播放项前后邻近预热任务的内存队列上限。 +MEDIAINFO_NEIGHBOR_QUEUE_SIZE=50 + +# 前台 metadata 冷 miss 的 P2 内存队列上限。 +MEDIAINFO_METADATA_QUEUE_SIZE=50 + +# P1/P2 未开始任务的保留时间。过期任务不会访问 MediaVault 或 CDN。 +MEDIAINFO_PENDING_TTL=5m + +# P1/P2 在确认 L1 和 SQLite 都未命中后,实际启动远程探测的最小间隔。 +MEDIAINFO_BACKGROUND_INTERVAL=5s + +# 已知 MediaInfo 结果的新鲜期。过期后仍可返回旧值并在后台刷新。 +MEDIAINFO_RECORD_TTL=720h + +# 长期未访问记录的保留期,不能短于新鲜期。 +MEDIAINFO_RECORD_RETENTION=4320h + +# L1 内存缓存最多保存的 MediaInfo 记录数。 +MEDIAINFO_L1_MAX_ENTRIES=10000 + +# 同一 Part、STRM fingerprint 和 User-Agent 探测失败后的抑制时间。 +MEDIAINFO_NEGATIVE_TTL=15m + +# 没有活动客户端上下文时,后台探测使用的 fallback User-Agent。 +MEDIAINFO_USER_AGENT=Infuse-Library/8.5.1 + +# 实际播放 decision 冷缓存最多等待多久。metadata 浏览冷 miss 不等待,直接投递 P2 并返回。 +MEDIAINFO_COLD_WAIT=5s + +# 是否启用实验性 Apple TV Plex Dolby Vision Profile 5 播放 veto。默认关闭; +# 仅复用 decision 已取得的新鲜 MediaInfo,不发起额外探测,也不影响 Part 302。 +PLAYBACK_VETO_ENABLED=false + +# 单个 Plex metadata 响应允许缓冲并尝试增强的最大字节数。 +MEDIAINFO_RESPONSE_MAX_BYTES=8388608 + +# 同时缓冲并尝试 L1 投影的单项 metadata 响应数量。 +MEDIAINFO_ENRICHMENT_CONCURRENCY=8 + +# 当前播放项之前预热的邻近媒体数量。前后都设为 0 时仅提交当前项。 +MEDIAINFO_PREWARM_BEFORE=2 + +# 当前播放项之后预热的邻近媒体数量。前后合计不能超过 50。 +MEDIAINFO_PREWARM_AFTER=3 + +# HTTP 请求头读取超时。 +READ_HEADER_TIMEOUT=15s + +# HTTP keep-alive 空闲超时。 +IDLE_TIMEOUT=90s + +# 进程停止时等待 HTTP、预热和 MediaInfo worker 收尾的最长时间。 +SHUTDOWN_TIMEOUT=15s diff --git a/cmd/plex-gateway/main.go b/cmd/plex-gateway/main.go index d57e6ad..9d45b54 100644 --- a/cmd/plex-gateway/main.go +++ b/cmd/plex-gateway/main.go @@ -21,22 +21,32 @@ import ( "github.com/InfinityPacer/plex-gateway/internal/metrics" "github.com/InfinityPacer/plex-gateway/internal/partcache" "github.com/InfinityPacer/plex-gateway/internal/pathmap" + "github.com/InfinityPacer/plex-gateway/internal/playback" + "github.com/InfinityPacer/plex-gateway/internal/prewarm" "github.com/InfinityPacer/plex-gateway/internal/resolver" "github.com/InfinityPacer/plex-gateway/internal/trace" "github.com/InfinityPacer/plex-gateway/internal/version" ) func main() { - if len(os.Args) > 1 && os.Args[1] == "version" { - fmt.Println(version.String()) - return - } - if len(os.Args) > 1 && os.Args[1] == "healthcheck" { - if err := healthcheck(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) + if len(os.Args) > 1 { + switch os.Args[1] { + case "version": + fmt.Println(version.String()) + return + case "healthcheck": + if err := healthcheck(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + case "cache-reset": + if err := requestMediaInfoCacheReset(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return } - return } if err := run(); err != nil { fmt.Fprintln(os.Stderr, err) @@ -73,21 +83,31 @@ func run() error { mediaInfoService, mediaInfoReason := initializeMediaInfo( context.Background(), databaseStore, databaseReason, cfg.MediaInfo, cfg.PlexURL, cfg.PartProbeTimeout, strmResolver, registry, logger, ) - defer closeRuntime(mediaInfoService, databaseStore, cfg.ShutdownTimeout, logger) + prewarmService, prewarmReason := initializePrewarm( + context.Background(), cfg.PlexToken, cfg.PlexURL, cfg.PartProbeTimeout, + playback.NewPartPreparer(mapper, strmResolver, cfg.CloudExtensions), + mediaInfoService, cfg.MediaInfo.PrewarmBefore, cfg.MediaInfo.PrewarmAfter, + registry, logger, + ) + defer closeRuntime(prewarmService, mediaInfoService, databaseStore, cfg.ShutdownTimeout, logger) mediaInfoStatus := func() mediainfo.Status { return mediaInfoService.Status() } - handler := gateway.New(gateway.Options{ - Upstream: cfg.PlexURL, - Logger: logger, - Tracer: trace.New(cfg.TraceEnabled, logger), - PartCache: cache, - PathMapper: mapper, - Resolver: strmResolver, - Metrics: registry, - CloudExtensions: cfg.CloudExtensions, - ObserveMaxBytes: cfg.ObserveMaxBytes, - PartProbeTimeout: cfg.PartProbeTimeout, + prewarmStatus := func() prewarm.Status { + return prewarmService.Status() + } + gatewayHandler := gateway.New(gateway.Options{ + Upstream: cfg.PlexURL, + Logger: logger, + Tracer: trace.New(cfg.TraceEnabled, logger), + PartCache: cache, + PathMapper: mapper, + Resolver: strmResolver, + Metrics: registry, + CloudExtensions: cfg.CloudExtensions, + ObserveMaxBytes: cfg.ObserveMaxBytes, + PartProbeTimeout: cfg.PartProbeTimeout, + MetadataAnalysisFilter: cfg.MetadataAnalysisFilter, MetadataGuard: gateway.MetadataGuardOptions{ Enabled: cfg.MetadataGuard.Enabled, GlobalConcurrency: cfg.MetadataGuard.GlobalConcurrency, @@ -96,13 +116,24 @@ func run() error { BatchConcurrency: cfg.MetadataGuard.BatchConcurrency, QueueTimeout: cfg.MetadataGuard.QueueTimeout, }, + MetadataCoalesce: gateway.MetadataCoalesceOptions{ + Enabled: cfg.MetadataCoalesce.Enabled, + Trace: cfg.TraceEnabled, + Window: cfg.MetadataCoalesce.Window, + MaxItems: cfg.MetadataCoalesce.MaxItems, + Timeout: cfg.MetadataCoalesce.Timeout, + }, MediaInfoEnabled: cfg.MediaInfo.Enabled, MediaInfoStatus: mediaInfoStatus, MediaInfoService: mediaInfoService, MediaInfoColdWait: cfg.MediaInfo.ColdWait, MediaInfoResponseMaxBytes: cfg.MediaInfo.ResponseMaxBytes, MediaInfoEnrichmentConcurrency: cfg.MediaInfo.EnrichmentWaiters, + MediaInfoPrewarmer: prewarmService, + MediaInfoPrewarmStatus: prewarmStatus, + PlaybackVeto: cfg.PlaybackVeto, }) + handler := newMaintenanceHandler(gatewayHandler, mediaInfoService, cfg.DatabasePath, logger) server := &http.Server{ Addr: cfg.ListenAddr, Handler: handler, @@ -121,11 +152,21 @@ func run() error { "cloud_redirect", cfg.MediaVaultURL != nil, "path_mappings", len(cfg.PathMappings), "trace", cfg.TraceEnabled, + "metadata_analysis_filter", cfg.MetadataAnalysisFilter, "metadata_guard", cfg.MetadataGuard.Enabled, "metadata_batch_guard", cfg.MetadataGuard.BatchEnabled, + "metadata_coalesce", cfg.MetadataCoalesce.Enabled, + "metadata_coalesce_window_ms", cfg.MetadataCoalesce.Window.Milliseconds(), + "metadata_coalesce_max_items", cfg.MetadataCoalesce.MaxItems, "mediainfo_enabled", cfg.MediaInfo.Enabled, "mediainfo_available", mediaInfoService != nil, "mediainfo_reason", mediaInfoReason, + "mediainfo_prewarm_available", prewarmService != nil, + "mediainfo_prewarm_reason", prewarmReason, + "mediainfo_prewarm_before", cfg.MediaInfo.PrewarmBefore, + "mediainfo_prewarm_after", cfg.MediaInfo.PrewarmAfter, + "mediainfo_background_interval_ms", cfg.MediaInfo.BackgroundInterval.Milliseconds(), + "playback_veto_enabled", cfg.PlaybackVeto, ) serveErrors <- server.ListenAndServe() }() @@ -149,6 +190,65 @@ func run() error { return nil } +func initializePrewarm( + ctx context.Context, + token string, + plexURL *url.URL, + timeout time.Duration, + partPreparer *playback.PartPreparer, + mediaInfoService *mediainfo.Service, + beforeCount int, + afterCount int, + registry *metrics.Metrics, + logger *slog.Logger, +) (*prewarm.Service, string) { + if mediaInfoService == nil || partPreparer == nil { + return nil, "mediainfo_unavailable" + } + var discovery *prewarm.Discovery + reason := "ready" + if beforeCount+afterCount == 0 { + reason = "current_only_window_disabled" + } else if strings.TrimSpace(token) == "" { + reason = "current_only_token_unconfigured" + } else { + client := &http.Client{Timeout: timeout} + candidate, err := prewarm.NewDiscovery(prewarm.DiscoveryOptions{ + BaseURL: plexURL, Token: token, Client: client, + UserAgent: "plex-gateway/" + version.String(), + }) + if err != nil { + reason = "current_only_configuration_invalid" + } else { + validateContext, cancel := context.WithTimeout(ctx, timeout) + err = candidate.Validate(validateContext) + cancel() + if err != nil { + reason = "current_only_plex_token_invalid" + } else { + discovery = candidate + } + } + if discovery == nil { + logger.Warn("mediainfo_neighbor_prewarm_unavailable", "reason", reason) + } + } + serviceOptions := prewarm.ServiceOptions{ + Playback: partPreparer, MediaInfo: mediaInfoService, + Logger: logger, Metrics: registry, DiscoveryTimeout: timeout, + BeforeCount: beforeCount, AfterCount: afterCount, + } + if discovery != nil { + serviceOptions.Discovery = discovery + } + service, err := prewarm.NewService(serviceOptions) + if err != nil { + logger.Warn("mediainfo_prewarm_unavailable", "reason", "coordinator") + return nil, "coordinator_unavailable" + } + return service, reason +} + func initializeMediaInfo( ctx context.Context, databaseStore *database.SQLite, @@ -205,8 +305,10 @@ func initializeMediaInfo( service, err := mediainfo.NewService(mediainfo.ServiceOptions{ Cache: cache, Store: store, Janitor: store, Provider: provider, PlexServerID: plexServerID, Logger: logger, Metrics: registry, Concurrency: cfg.Concurrency, - InteractiveQueueSize: cfg.InteractiveQueueSize, BackgroundQueueSize: cfg.BackgroundQueueSize, - ProbeTimeout: cfg.ProbeTimeout, RecordTTL: cfg.RecordTTL, + PlaybackQueueSize: cfg.PlaybackQueueSize, NeighborQueueSize: cfg.NeighborQueueSize, + MetadataQueueSize: cfg.MetadataQueueSize, PendingTTL: cfg.PendingTTL, + BackgroundInterval: cfg.BackgroundInterval, + ProbeTimeout: cfg.ProbeTimeout, RecordTTL: cfg.RecordTTL, RecordRetention: cfg.RecordRetention, NegativeTTL: cfg.NegativeTTL, BackgroundUserAgent: cfg.UserAgent, }) @@ -229,7 +331,14 @@ func initializeDatabase(ctx context.Context, path string, required bool, logger return store, "ready" } -func closeRuntime(service *mediainfo.Service, store *database.SQLite, timeout time.Duration, logger *slog.Logger) { +func closeRuntime(prewarmService *prewarm.Service, service *mediainfo.Service, store *database.SQLite, timeout time.Duration, logger *slog.Logger) { + if prewarmService != nil { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + if err := prewarmService.Close(ctx); err != nil { + logger.Warn("mediainfo_prewarm_shutdown_incomplete", "error_kind", "timeout") + } + cancel() + } serviceClosed := true if service != nil { ctx, cancel := context.WithTimeout(context.Background(), timeout) diff --git a/cmd/plex-gateway/main_test.go b/cmd/plex-gateway/main_test.go index 2b5ba3a..f9113e4 100644 --- a/cmd/plex-gateway/main_test.go +++ b/cmd/plex-gateway/main_test.go @@ -16,6 +16,8 @@ import ( "github.com/InfinityPacer/plex-gateway/internal/config" "github.com/InfinityPacer/plex-gateway/internal/metrics" + "github.com/InfinityPacer/plex-gateway/internal/pathmap" + "github.com/InfinityPacer/plex-gateway/internal/playback" "github.com/InfinityPacer/plex-gateway/internal/resolver" ) @@ -36,15 +38,23 @@ func (control *countingControlResolver) ResolveTarget(context.Context, string, r func TestInitializeMediaInfoDoesNotProbeWithoutTask(t *testing.T) { unexpectedPlexPath := make(chan string, 1) plex := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/identity" { + switch r.URL.Path { + case "/identity": + _, _ = io.WriteString(w, ``) + case "/library/sections": + if r.Header.Get("X-Plex-Token") != "management-token" { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"MediaContainer":{"Metadata":[]}}`) + default: select { case unexpectedPlexPath <- r.URL.Path: default: } http.NotFound(w, r) - return } - _, _ = io.WriteString(w, ``) })) defer plex.Close() plexURL, err := url.Parse(plex.URL) @@ -61,20 +71,23 @@ func TestInitializeMediaInfoDoesNotProbeWithoutTask(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) databaseStore, databaseReason := initializeDatabase(t.Context(), filepath.Join(directory, "plex-gateway.db"), true, logger) service, reason := initializeMediaInfo(t.Context(), databaseStore, databaseReason, config.MediaInfoConfig{ - Enabled: true, - FFProbePath: ffprobePath, - ProbeTimeout: time.Second, - ProbeSize: 1 << 20, - AnalyzeDuration: time.Second, - OutputMaxBytes: 1 << 20, - Concurrency: 1, - InteractiveQueueSize: 2, - BackgroundQueueSize: 2, - RecordTTL: 24 * time.Hour, - RecordRetention: 48 * time.Hour, - L1MaxEntries: 10, - NegativeTTL: time.Minute, - UserAgent: "plex-gateway-test", + Enabled: true, + FFProbePath: ffprobePath, + ProbeTimeout: time.Second, + ProbeSize: 1 << 20, + AnalyzeDuration: time.Second, + OutputMaxBytes: 1 << 20, + Concurrency: 1, + PlaybackQueueSize: 2, + NeighborQueueSize: 2, + MetadataQueueSize: 2, + PendingTTL: time.Minute, + BackgroundInterval: time.Millisecond, + RecordTTL: 24 * time.Hour, + RecordRetention: 48 * time.Hour, + L1MaxEntries: 10, + NegativeTTL: time.Minute, + UserAgent: "plex-gateway-test", }, plexURL, time.Second, control, metrics.New(), logger) if service == nil || databaseStore == nil || reason != "ready" { t.Fatalf("initializeMediaInfo() service=%v store=%v reason=%q", service != nil, databaseStore != nil, reason) @@ -94,5 +107,31 @@ func TestInitializeMediaInfoDoesNotProbeWithoutTask(t *testing.T) { if _, err := os.Stat(ffprobePath + ".called"); !errors.Is(err, os.ErrNotExist) { t.Fatalf("ffprobe process unexpectedly started: %v", err) } - closeRuntime(service, databaseStore, time.Second, logger) + mapper, err := pathmap.New([]pathmap.Mapping{{PlexPrefix: "/media/cloud", LocalPrefix: directory}}) + if err != nil { + t.Fatal(err) + } + partPreparer := playback.NewPartPreparer(mapper, control, []string{".strm"}) + invalidTokenService, reason := initializePrewarm( + t.Context(), "invalid-token", plexURL, time.Second, partPreparer, service, + 2, 3, metrics.New(), logger, + ) + if invalidTokenService == nil || reason != "current_only_plex_token_invalid" || + !invalidTokenService.Status().Available || invalidTokenService.Status().NeighborAvailable { + t.Fatalf("invalid token prewarm service=%v reason=%q status=%#v", invalidTokenService != nil, reason, invalidTokenService.Status()) + } + closeContext, closeCancel := context.WithTimeout(t.Context(), time.Second) + if err := invalidTokenService.Close(closeContext); err != nil { + closeCancel() + t.Fatal(err) + } + closeCancel() + prewarmService, reason := initializePrewarm( + t.Context(), "management-token", plexURL, time.Second, partPreparer, service, + 2, 3, metrics.New(), logger, + ) + if prewarmService == nil || reason != "ready" || !prewarmService.Status().Available { + t.Fatalf("prewarm service=%v reason=%q", prewarmService != nil, reason) + } + closeRuntime(prewarmService, service, databaseStore, time.Second, logger) } diff --git a/cmd/plex-gateway/maintenance.go b/cmd/plex-gateway/maintenance.go new file mode 100644 index 0000000..67c9dc2 --- /dev/null +++ b/cmd/plex-gateway/maintenance.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/InfinityPacer/plex-gateway/internal/mediainfo" +) + +const mediaInfoCacheResetPath = "/_plex-gateway/maintenance/mediainfo-cache/reset" + +type cacheResetResponse struct { + Status string `json:"status"` + DeletedRecords int64 `json:"deleted_records"` + Backup string `json:"backup"` +} + +// newMaintenanceHandler exposes container-local maintenance without widening +// the public Gateway API. Remote callers receive no proxy fallback for the +// reserved path. +func newMaintenanceHandler( + next http.Handler, + service *mediainfo.Service, + databasePath string, + logger *slog.Logger, +) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST "+mediaInfoCacheResetPath, func(w http.ResponseWriter, request *http.Request) { + if !loopbackRemote(request.RemoteAddr) { + http.NotFound(w, request) + return + } + if service == nil { + http.Error(w, "MediaInfo service unavailable", http.StatusServiceUnavailable) + return + } + ctx, cancel := context.WithTimeout(request.Context(), 30*time.Second) + defer cancel() + result, err := service.ResetCache(ctx, filepath.Join(filepath.Dir(databasePath), "backups")) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, mediainfo.ErrCacheResetInProgress) { + status = http.StatusConflict + } + logger.Warn("mediainfo_cache_reset_failed", "error_kind", "maintenance") + http.Error(w, "MediaInfo cache reset failed", status) + return + } + logger.Info("mediainfo_cache_reset", "deleted_records", result.DeletedRecords) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(cacheResetResponse{ + Status: "ok", DeletedRecords: result.DeletedRecords, Backup: filepath.Base(result.BackupPath), + }) + }) + mux.Handle("/", next) + return mux +} + +func loopbackRemote(remoteAddress string) bool { + host, _, err := net.SplitHostPort(strings.TrimSpace(remoteAddress)) + if err != nil { + return false + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func requestMediaInfoCacheReset() error { + endpoint, err := localGatewayEndpoint(mediaInfoCacheResetPath) + if err != nil { + return err + } + request, err := http.NewRequest(http.MethodPost, endpoint, nil) + if err != nil { + return fmt.Errorf("create MediaInfo cache reset request: %w", err) + } + client := &http.Client{Timeout: 35 * time.Second} + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("request MediaInfo cache reset: %w", err) + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, 64<<10)) + if err != nil { + return fmt.Errorf("read MediaInfo cache reset response: %w", err) + } + if response.StatusCode != http.StatusOK { + return fmt.Errorf("MediaInfo cache reset returned HTTP %d", response.StatusCode) + } + var result cacheResetResponse + if err := json.Unmarshal(body, &result); err != nil || result.Status != "ok" { + return errors.New("MediaInfo cache reset returned an invalid response") + } + fmt.Printf("mediainfo_cache_reset deleted=%d backup=%s\n", result.DeletedRecords, result.Backup) + return nil +} + +func localGatewayEndpoint(path string) (string, error) { + listenAddr := strings.TrimSpace(os.Getenv("LISTEN_ADDR")) + if listenAddr == "" { + listenAddr = ":32400" + } + host, port, err := net.SplitHostPort(listenAddr) + if err != nil { + return "", fmt.Errorf("parse LISTEN_ADDR: %w", err) + } + if host == "" || host == "0.0.0.0" || host == "::" { + host = "127.0.0.1" + } + return "http://" + net.JoinHostPort(host, port) + path, nil +} diff --git a/cmd/plex-gateway/maintenance_test.go b/cmd/plex-gateway/maintenance_test.go new file mode 100644 index 0000000..2c627bb --- /dev/null +++ b/cmd/plex-gateway/maintenance_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/InfinityPacer/plex-gateway/internal/mediainfo" +) + +type maintenanceStore struct { + resets atomic.Int64 +} + +func (*maintenanceStore) Put(context.Context, mediainfo.Record) error { return nil } + +func (*maintenanceStore) Get(context.Context, mediainfo.Key) (mediainfo.Record, bool, error) { + return mediainfo.Record{}, false, nil +} + +func (*maintenanceStore) Touch(context.Context, mediainfo.Key, time.Time, time.Time) error { + return nil +} + +func (store *maintenanceStore) BackupAndDeleteAll( + _ context.Context, + backupDir string, + _ time.Time, +) (mediainfo.ResetResult, error) { + store.resets.Add(1) + return mediainfo.ResetResult{DeletedRecords: 3, BackupPath: filepath.Join(backupDir, "backup.db")}, nil +} + +type maintenanceProvider struct{} + +func (maintenanceProvider) Descriptor() mediainfo.ProviderDescriptor { + return mediainfo.ProviderDescriptor{ + Name: mediainfo.ProviderMediaVaultFFProbe, Revision: mediainfo.ProviderRevisionFFProbeJSONV3, + } +} + +func (maintenanceProvider) Probe(context.Context, mediainfo.ProviderRequest) (mediainfo.ProviderResult, error) { + return mediainfo.ProviderResult{}, nil +} + +func TestMaintenanceHandlerHotResetsFromLoopbackOnly(t *testing.T) { + store := &maintenanceStore{} + service, err := mediainfo.NewService(mediainfo.ServiceOptions{ + Cache: mediainfo.NewCache(nil, time.Now()), Store: store, Provider: maintenanceProvider{}, + PlexServerID: "server", BackgroundUserAgent: "Infuse/1.0", + }) + if err != nil { + t.Fatal(err) + } + defer service.Close(t.Context()) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := newMaintenanceHandler(http.NotFoundHandler(), service, "/app_data/plex-gateway.db", logger) + + remoteRequest := httptest.NewRequest(http.MethodPost, mediaInfoCacheResetPath, nil) + remoteRequest.RemoteAddr = "203.0.113.10:1234" + remoteResponse := httptest.NewRecorder() + handler.ServeHTTP(remoteResponse, remoteRequest) + if remoteResponse.Code != http.StatusNotFound || store.resets.Load() != 0 { + t.Fatalf("remote response=%d resets=%d", remoteResponse.Code, store.resets.Load()) + } + + localRequest := httptest.NewRequest(http.MethodPost, mediaInfoCacheResetPath, nil) + localRequest.RemoteAddr = "127.0.0.1:1234" + localResponse := httptest.NewRecorder() + handler.ServeHTTP(localResponse, localRequest) + if localResponse.Code != http.StatusOK || store.resets.Load() != 1 { + t.Fatalf("local response=%d resets=%d body=%s", localResponse.Code, store.resets.Load(), localResponse.Body.String()) + } + var result cacheResetResponse + if err := json.Unmarshal(localResponse.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.Status != "ok" || result.DeletedRecords != 3 || result.Backup != "backup.db" { + t.Fatalf("response = %#v", result) + } +} + +func TestRequestMediaInfoCacheResetUsesLocalListener(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost || request.URL.Path != mediaInfoCacheResetPath { + t.Fatalf("request = %s %s", request.Method, request.URL.Path) + } + _ = json.NewEncoder(w).Encode(cacheResetResponse{Status: "ok", DeletedRecords: 2, Backup: "backup.db"}) + })) + defer server.Close() + parsed, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + t.Setenv("LISTEN_ADDR", parsed.Host) + if err := requestMediaInfoCacheReset(); err != nil { + t.Fatal(err) + } +} + +func TestLoopbackRemoteRejectsForwardedOrMalformedAddresses(t *testing.T) { + for _, address := range []string{"127.0.0.1:1", "[::1]:1"} { + if !loopbackRemote(address) { + t.Fatalf("loopback address rejected: %s", address) + } + } + for _, address := range []string{"203.0.113.1:1", "127.0.0.1", strings.Repeat("x", 100)} { + if loopbackRemote(address) { + t.Fatalf("non-loopback address accepted: %s", address) + } + } +} diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 6df49dd..621888d 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -6,34 +6,8 @@ services: restart: unless-stopped ports: - "32400:32400" - environment: - PLEX_URL: "http://plex:32400" - MEDIAVAULT_URL: "http://mediavault:7811" - PATH_MAPPINGS: >- - [{"plex_prefix":"/media/cloud","local_prefix":"/strm"},{"plex_prefix":"/media/archive","local_prefix":"/archive-strm"}] - LISTEN_ADDR: ":32400" - DATABASE_PATH: "/app_data/plex-gateway.db" - LOG_LEVEL: "info" - TRACE_ENABLED: "false" - PART_CACHE_TTL: "24h" - RESOLVER_TIMEOUT: "15s" - OBSERVE_MAX_BYTES: "8388608" - PART_PROBE_TIMEOUT: "15s" - METADATA_GUARD_ENABLED: "false" - METADATA_GUARD_GLOBAL_CONCURRENCY: "8" - METADATA_GUARD_CLIENT_CONCURRENCY: "4" - METADATA_GUARD_BATCH_ENABLED: "false" - METADATA_GUARD_BATCH_CONCURRENCY: "3" - METADATA_GUARD_QUEUE_TIMEOUT: "10s" - MEDIAINFO_ENABLED: "true" - # Background probes use this only when no active client request supplies a User-Agent. - MEDIAINFO_USER_AGENT: "Infuse-Library/8.4.4" - MEDIAINFO_COLD_WAIT: "5s" - MEDIAINFO_RESPONSE_MAX_BYTES: "8388608" - MEDIAINFO_ENRICHMENT_CONCURRENCY: "4" - READ_HEADER_TIMEOUT: "15s" - IDLE_TIMEOUT: "90s" - SHUTDOWN_TIMEOUT: "15s" + env_file: + - ./app.env volumes: - ./strm:/strm:ro - ./archive-strm:/archive-strm:ro diff --git a/docs/architecture.md b/docs/architecture.md index 7c10033..bb06805 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,19 +11,43 @@ gateway; the isolated analysis worker may read a bounded amount for probing. ## Request ownership - All requests are transparently proxied to Plex by default. -- Optional admission guards limit single-item `GET` and `HEAD` - `/library/metadata/{ratingKey}` requests and comma-separated batch metadata - reads before they enter Plex. Single-item requests use global and per-client - limits; batch reads use a separate global pool so maintenance traffic cannot - consume interactive metadata slots. Neither pool caches or rewrites Plex - responses. Library listings, metadata mutations, playback paths, timeline, - and watch-state traffic do not enter these pools. +- Equivalent authenticated single-item metadata `GET` requests can be combined + within a 20-millisecond window into Plex's native comma-separated endpoint. + A group requires identical raw query, complete request headers, representation + context, host, protocol, and remote identity. The default batch contains at + most 32 unique rating keys and is split + by rating key into individual XML, JSON, or gzip responses. This is a bounded, + non-caching protocol transform. `HEAD`, + Range, conditional requests, bodies, ambiguous credentials, and every + non-metadata path bypass it. +- Optional admission guards limit direct or fallback single-item `GET` and + `HEAD` `/library/metadata/{ratingKey}` requests and comma-separated batch + metadata reads before they enter Plex. Single-item requests use global and + per-client limits; explicit and synthesized batch reads use a separate global + pool. A batch failure issues at most one fallback per unique rating key through + the single-item pool and fans the result out to duplicate callers. Different + keys remain bounded by the Guard, and batching is temporarily suspended for + the same request context. The current Guard defaults are global 16, per-client + 16, and batch 4. +- The metadata chain is analysis filter, per-item MediaInfo enrichment, + coalescer, Guard, and Plex. This ordering lets each original request preserve + its response projection while one synthesized batch consumes one batch Guard + slot. Library listings, metadata mutations, playback paths, timeline, and + watch-state traffic do not enter the coalescer or Guard pools. - Successful XML or JSON Plex responses are observed to populate the in-memory `Part.id` cache without changing their bytes. When MediaInfo is available, an authenticated single-item metadata response may also fill missing whitelisted - technical fields from the exact STRM fingerprint. The transform is bounded, - never creates Stream identities, and preserves the original Plex response on - timeout, ambiguity, unsupported encoding, or any other failure. + technical fields from the exact STRM fingerprint. A Part with no Stream + elements may receive descriptive Streams identified only by ffprobe stream + type and source index. The gateway never invents Plex Stream IDs or playback + selection state, and an existing Stream set is never expanded. The transform + is bounded and preserves the original Plex response on timeout, ambiguity, + unsupported encoding, or any other failure. Background library crawlers + identified by `skipRefresh` and a `-Library` product consume cache only. Hot + records remain concurrently projectable. An eligible browsing miss preserves + the Plex response and may only offer bounded P2 work in memory; the request + does not wait for SQLite, MediaVault, or CDN work. A successful background + probe persists to L1 and SQLite for later responses. - `GET` and `HEAD` requests below `/library/parts/{partID}/...` are eligible for interception only when the cached `Part.file` has a configured cloud extension and maps to a readable local STRM file. @@ -32,11 +56,17 @@ gateway; the isolated analysis worker may read a bounded amount for probing. only when its `Location` matches the validated local STRM control target. - A Plex universal playback decision is adapted only after an authenticated metadata read identifies the exact `Media[mediaIndex].Part[partIndex]` as a - mapped STRM Part. The gateway changes only `directPlay=1` and - `directStream=1`; all client profile parameters, credentials, headers, and - Plex's response remain intact. The complete XML or JSON decision response is - observed without rewriting it; a start grant exists only when the same Part - is explicitly marked `directplay`. + mapped STRM Part. The gateway changes only `directPlay=1`, `directStream=1`, + and `hasMDE=1`; all unrelated client profile parameters, credentials, and + headers remain intact. Plex produces the complete XML or JSON decision + response. The gateway may then apply the same fail-open, whitelisted technical + MediaInfo projection used for metadata responses. A start grant exists only + when Plex explicitly marks the same Part `directplay`. +- After Plex accepts the exact STRM Part for Direct Play, an optional playback + veto may inspect the fresh MediaInfo record already obtained for response + enrichment. The built-in veto covers only Plex for Apple TV on tvOS with + Dolby Vision Profile 5 and BL compatibility ID 0. A match returns a + Plex-compatible unsupported envelope and creates no Direct Play grant. - Eligible universal `start`, `start.mpd`, and `start.m3u8` requests repeat the Part authorization using the exact selection captured by a short-lived Direct Play decision grant for the same client session and Media/Part, then resolve @@ -55,7 +85,7 @@ gateway; the isolated analysis worker may read a bounded amount for probing. | `internal/gateway` | Plex routing, transparent proxying, protocol probes, response capture, 302/fallback output, and request metrics. | | `internal/mediainfo` | Bounded analysis scheduling, L1/persistent cache policy, provider contracts, and technical-media records. | | `internal/playback` | Shared STRM preparation, normalized playback attempts, Plex authorization/MediaVault resolution sequencing, and decision-grant state. | -| `internal/plexmeta` | XML and JSON Part/decision readers plus whitelisted metadata projection. | +| `internal/plexmeta` | XML and JSON Part/decision readers, native-batch splitting, and whitelisted metadata projection. | | `internal/partcache` | Derived, expiring `Part.id` index; never an authority for access. | | `internal/pathmap` | Lexical Plex-to-container path mapping. | | `internal/resolver` | STRM target validation and the MediaVault `/redirect` protocol. | @@ -71,7 +101,7 @@ control-file and metadata readers. | State | Authority and writer | Invalidation | | --- | --- | --- | | Plex library, descriptive metadata, and watch state | Plex only; the gateway forwards timeline/scrobble traffic unchanged. | Plex lifecycle. | -| Technical MediaInfo | A validated producer record, or Gateway SQLite for fallback-probed media; Plex fields and response enrichment are projections. | Source or STRM fingerprint change, schema change, or freshness policy. | +| Technical MediaInfo | A validated producer record, or Gateway SQLite for fallback-probed media; Plex fields and response enrichment are projections. Plex DB production writes are not enabled in this release. | Source or STRM fingerprint change, schema change, or freshness policy. | | Part cache | Derived by metadata observation or an authenticated playback selection, keyed by `Part.id`. | Configured TTL or process restart. | | Direct Play grant | Published only after a complete Plex decision explicitly accepts the exact Part; a new related decision revokes the previous grant first. | Five-minute TTL, bounded eviction, or explicit revocation. | | MediaVault direct URL | Produced for the active authorized request and copied only to `Location`. | Never persisted or reused by the gateway. | @@ -143,6 +173,16 @@ applicable limits within its queue timeout receives `429` instead of bypassing protection and increasing Plex load. This protects Plex control-plane availability and does not change cloud redirect fallback behavior. +Metadata coalescing remains fail-open through the Guard rather than directly to +Plex. A non-200 batch, missing or duplicate item, unsupported encoding, +oversized body, timeout, or malformed envelope is never interpreted as a +single-item result. Each unique rating key performs at most one fallback with +its original filtered request, and duplicate callers share the captured result; +canceled callers are discarded. When every caller leaves, the upstream batch is +canceled. Synthesized responses remove batch validators, +recompute `Content-Length`, and use private no-cache semantics because their +wire representation differs from the batch response. + Cloud STRM support is Direct Play only. The decision adapter opts an eligible Part into Plex's own Direct Play decision path; it does not synthesize a decision, create a transcode session, or send a cloud URL to Plex Transcoder. @@ -150,6 +190,72 @@ Universal start routes are redirected only after the same STRM selection and authorization checks; manifest segments and genuine transcode traffic remain Plex-owned. +All clients use the same MediaInfo projection rules. Part and universal-start +redirects never wait for MediaInfo. An L1 hit does not synchronously read SQLite +for the request; access renewal may touch SQLite asynchronously. A playback +decision L1 miss may use the persistent record or one bounded P0 probe, and +timeout or failure preserves the existing Direct Play path. Metadata browsing +never waits for that work: an eligible foreground miss may only be offered to +the bounded P2 scheduler while the original Plex response is returned +immediately. Successful worker results populate L1 and SQLite for later +responses. + +## MediaInfo scheduling contract + +The in-memory scheduler owns all remote-probe admission. It has three fixed +priority classes: + +1. P0 represents the item that reached an actual playback decision or successful + cloud redirect. Its queue reserves 16 entries and is never displaced by + background work. +2. P1 represents the configured two previous and three following neighbors of an + actual playback item. Its queue admits at most 50 entries. +3. P2 represents an eligible foreground metadata miss. Its queue admits at most + 50 entries, while the HTTP request returns the original Plex response. + +P1 and P2 pending entries expire after five minutes and share one global +five-second remote start interval after both L1 and SQLite miss. Fresh SQLite +records complete without waiting for that interval. P0 bypasses the interval and +runs before all pending background work, but it does not cancel a probe that has +already started. Expired, rejected, or superseded work never reaches MediaVault +or the CDN and does not create negative-cache state. + +The exact task identity is Plex server ID, Part ID, and STRM fingerprint. A +queued or running identity owns one flight across all clients and priorities. +When a higher-priority request observes the same queued identity, the existing +job is promoted in place rather than duplicated. Promotion to P0 refreshes the +unclaimed job with the active playback User-Agent and request identity and +removes its background expiry. A P0 arrival joins an already running flight; if +that attempt fails with a different User-Agent, at most one P0 retry may be +retained. P2 never registers User-Agent fallback probes. + +The HTTP metadata path may only offer a P2 task through bounded in-memory work. +SQLite lookup, MediaVault resolution, ffprobe, persistence, and retry execution +belong to workers. Background synchronization identified by `-Library` plus +`skipRefresh` remains cache-only and cannot populate P2. Queue state is not +persisted and is discarded on cache reset, shutdown, or process restart. + +Detailed numeric metadata reads pass through an independent analysis-parameter +filter before enrichment, coalescing, and admission control. When enabled, it +removes only `asyncAugmentMetadata`, preserving all retained raw query segments, +headers, and `checkFiles`. Plex therefore retains ownership of synchronous +`Part.accessible` and `Part.exists` fields while browse bursts cannot schedule +asynchronous augmentation. The filter, coalescer, and Guard have separate +switches for compatibility and capacity tests. + +`/metrics` keeps aggregate scheduler counters and a fixed, label-free P0/P1/P2 +breakdown for offers, admissions, joins, promotions, capacity drops, expiry, and +fingerprint supersession. `/health` reports the current queue depth for each +class. Neither surface contains Part identities, URLs, credentials, or full +User-Agent values. + +`PLAYBACK_VETO_ENABLED=false` leaves a nil hook in the decision handler. The +experimental hook is disabled by default. When enabled, it only consumes the +fresh MediaInfo record already obtained by that request and checks the built-in +Apple TV Plex Dolby Vision Profile 5 combination. It has no cache, persistence, +Part interception, remote I/O, or positive allow semantics. Missing or +conflicting facts preserve Plex's Direct Play response. + ## Client compatibility boundary The redirect plane requires a client that can consume the final raw media URL. @@ -164,29 +270,56 @@ 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. -## Analysis-plane boundary +## Playback veto contract -MediaVault's documented ffprobe assistance is an internal recognition and -renaming feature, and MediaInfoKeeper is Emby-specific. Neither is treated as a -public MediaInfo API. The gateway does not inspect MediaVault internals or -duplicate those features in the playback plane. +The veto is a best-effort compatibility override, not an authorization or +capability engine. It runs only after Plex has accepted the exact Part for +Direct Play and MediaInfo enrichment already has a complete, fresh record. +Returning a veto replaces that decision response and prevents creation of its +short-lived start grant. It does not block a direct Part request and therefore +must not be treated as a security boundary. -MediaInfo Phase D begins with a provider boundary, L1 and SQLite persistence, -bounded remote probing, proactive prewarming, and metadata response enrichment. +## Analysis-plane boundary + +MediaVault's documented ffprobe assistance and upload-time head/tail precache +are internal recognition and scan optimizations, and MediaInfoKeeper is +Emby-specific. None is treated as a public MediaInfo API. The precache covers +only files uploaded through MediaVault and cannot be consumed by the gateway +without a stable interface. The gateway does not inspect MediaVault internals, +mount its private cache, or duplicate those features in the playback plane. + +The MediaInfo fallback begins with a provider boundary, L1 and SQLite +persistence, bounded remote probing, proactive prewarming, and metadata response +enrichment. +A provider revision separates records whenever normalized probe semantics +change, preventing an old interpretation from being projected after an upgrade. +A successful ffprobe result that omits format size may perform one same-UA +`bytes=0-0` request against the same direct URL. Only a valid partial response +is accepted, the body is not consumed, and every error preserves the other +probe fields. This fallback remains inside the MediaInfo worker and never enters +the synchronous 302 path. A stable MediaVault or MoviePilot provider can replace remote probing without -changing playback. Plex persistence remains a feasibility question across an -official API, another supported PMS interface, and an isolated database helper. -Intro and credits work starts with an isolated Plex marker API probe. See +changing playback. Plex DB production writing is a next-stage evaluation only, +covering an official API, another supported PMS interface, and an isolated +database helper with explicit coverage, backup, and rollback tests. This release +does not select or enable a Plex DB write path. Intro and credits work starts +with an isolated Plex marker API probe. See [media-lifecycle-architecture.md](media-lifecycle-architecture.md). +Performance acceptance and reproducible microbenchmarks are defined in +[performance-matrix.md](performance-matrix.md). Local proxying and the 302 path +remain higher priority than analysis coverage; a feature that moves MediaInfo, +SQLite, or CDN I/O into Part or universal-start handling is an architecture +regression. + ## Cache lifecycle The Part cache is in memory and has a configurable TTL. Metadata responses refresh entries by stable `Part.id`; the full `Part.key` is retained only as observed metadata because its change stamp may vary. A restart begins with an -empty cache; normal metadata, decision, start, and play queue traffic repopulate -it. A direct cold-start Part request falls back to Plex until one of those -authenticated metadata paths observes the Part. +empty cache; normal metadata, decision, and play queue traffic repopulate it. A +direct cold-start Part request falls back to Plex until one of those authenticated +metadata paths observes the Part. Direct URLs are intentionally not persisted. They are short-lived credentials owned by MediaVault and the cloud provider. @@ -198,9 +331,9 @@ The gateway requires: - network access to Plex and MediaVault; - read-only mounts for each configured STRM tree; - no Plex database, MediaVault database, 115 cookie, or cloud-provider API - access. Playback requires no static Plex Token; MediaInfo discovery and - prewarming may optionally use `PLEX_TOKEN` injected from an ignored - deployment `app.env`. + access. Playback and current-item MediaInfo prewarming require no static Plex + Token; nearby-item discovery may optionally use `PLEX_TOKEN` injected from an + ignored deployment `app.env`. Before listening, the process verifies that a fresh invalid Plex Token is rejected by the internal Plex origin. Plex **Allowed Networks** must not include @@ -211,9 +344,9 @@ playback failures after startup remain fail-open to authenticated Plex traffic. The MediaVault API key documented for `/api/v1` integrations is not required by the STRM `/redirect` contract and is not a gateway configuration value. Active playback authorization comes only from the client request; the optional -management Token is used only for background discovery, prewarming, and -reconciliation. Users do not provide their Plex username or password to the -gateway. +management Token is used only to validate and discover nearby Plex items. +Current-item prewarming does not require it. Users do not provide their Plex +username or password to the gateway. Client-provided Plex authentication headers and query parameters are forwarded unchanged to Plex. Cloud playback headers are additionally forwarded to diff --git a/docs/media-lifecycle-architecture.md b/docs/media-lifecycle-architecture.md index 28477bf..1d7c31a 100644 --- a/docs/media-lifecycle-architecture.md +++ b/docs/media-lifecycle-architecture.md @@ -411,12 +411,14 @@ MoviePilot 本地下载路径应在真实文件仍在本地时执行 ffprobe。 3. 用户可控的受限远程 ffprobe provider; 4. 标记缺失,保留 Gateway 保护,不反复无界探测。 -远程探测必须限制 Range、probe size、timeout、并发、重试和负缓存。分享挂载失效时应 -标记 source unavailable,不应持续重试拖垮 Plex 或 115。 +远程探测必须限制 ffprobe 的 `probesize`、`analyzeduration`、timeout、并发和负缓存, +并监测实际网络读取量。`probesize` 是分析窗口,不是 HTTP 总下载量硬上限;Gateway 不能 +仅凭该参数承诺固定 Range 字节数。分享挂载失效时应标记 source unavailable,不应持续 +重试拖垮 Plex 或 115。 ### 10.2 记录契约、存储与投影 -`MediaInfoRecord` 必须是版本化公开契约,至少包含: +统一的 `MediaInfoRecord` 目标契约必须版本化,至少包含: - `schema_version`、记录身份、provider revision、上游关联和内容 fingerprint; - container、duration、size; @@ -424,6 +426,9 @@ MoviePilot 本地下载路径应在真实文件仍在本地时执行 ffprobe。 - provider、`probed_at`、`ready|stale|negative` 状态; - 负缓存期限和生成该记录时的 backing fingerprint。 +当前 Gateway SQLite 只持久化成功记录,负缓存状态及期限保存在有界内存中;因此当前表 +结构不是上述跨生产者公开契约的完整实现。 + 发布方式优先使用原子 sidecar 或稳定只读 API。消费者不得直接读取 MoviePilot 插件表。 内容或 STRM fingerprint 变化时记录立即失效;provider schema 不兼容时消费者必须拒绝 使用而不是猜测字段。 @@ -461,7 +466,7 @@ Gateway fallback 是当前优先实现的兜底能力,不要求媒体先经过 MediaVault 尚未公开的 MediaInfo API: ```text -Plex Part / configured STRM root +Plex Part / cloud redirect │ ▼ Gateway discovery and prewarm queue @@ -481,7 +486,7 @@ singleflight 或短租约时加入;PostgreSQL 仅在 SQLite 单写者成为实 探测任务使用稳定身份和内容指纹,不使用短期 CDN URL 作为缓存键。至少关联 Plex server identity、ratingKey、PartID、STRM fingerprint、可用时的 provider/content fingerprint、 MediaInfo schema 版本和探测实现版本。探测失败不得覆盖已知良好记录;相同指纹使用 -singleflight、负缓存与退避。 +singleflight 和负缓存抑制重复失败。当前不执行无法识别最终错误类型的自动重试或退避。 Gateway fallback 默认将成功记录视为 `30d` fresh,并将最近使用记录保留 `180d`。 fresh 到期后可以立即返回仍在 retention 内的已知良好记录,同时在后台低优先级复验; @@ -493,17 +498,18 @@ Gateway 支持以下范围: | 范围 | 触发方式 | 约束 | | --- | --- | --- | | 单 Part | metadata miss/stale 或成功播放 | 自动、高优先级、去重 | -| 下一集 | 当前集确认播放后的低优先预取 | 当前实现范围;必须精确识别 Part,只向前一步 | +| 邻近媒体 | 当前项云端重定向就绪后的 P1 预取 | 当前实现范围;默认前 2 后 3,可配置双向窗口,远程启动统一限速 | | 单季 | 显式预热或计划任务 | 后续批量能力;分页、checkpoint | | 整剧 | 显式后台任务 | 后续批量能力;按季分页,不由一次播放自动扩散 | | STRM 目录 | 管理员扫描或 reconciliation | 后续批量能力;限制到配置 mapping root,可取消和恢复 | -当前实现不执行启动全库、单季、整剧或目录批量预热。精确当前项始终高于下一集预取; -下一集只能在当前集确认播放后进入低优先队列,快速切换产生的新当前项可以提升同一 -singleflight 或抢占尚未完成的纯推测任务。远程 probe 初始并发候选值为 `1`,经 CDN、 -CPU、Plex 和 MediaVault 压测后再决定是否提高。每次播放可以触发 freshness 判断,但 -不等于每次执行完整 ffprobe;未过期记录只更新访问时间,过期记录优先异步校验,可靠 -指纹变化才重新探测。 +当前实现不执行启动全库、单季、整剧或目录批量预热。当前项在 302 写出后立即投递 P0; +邻近项默认按后 3、前 2 的顺序准备并非阻塞投递 P1。只有 L1 和 SQLite 都未命中的 P1/P2 +才共享 `5s` 远程启动间隔。该信号只表示云端重定向就绪,不代表客户端已跟随 302 或真正 +起播。快速切换产生的新当前项优先于旧窗口中尚未投递的候选;已经投递的任务保留自身 +PartID 与 STRM fingerprint,并由 singleflight 去重。远程 probe 初始并发候选值为 `1`,经 CDN、CPU、Plex 和 MediaVault +压测后再决定是否提高。每次播放可以触发 freshness 判断,但不等于每次执行完整 ffprobe; +未过期记录只更新访问时间,过期记录优先异步校验,可靠指纹变化才重新探测。 一次真实 MediaVault/115 环境的匿名初始样本中,六个文件的 `/redirect` 耗时为 `0.018–0.477s`,受限 ffprobe 耗时为 `0.33–0.46s`,组合约为 `0.46–0.89s`。解析直链和 @@ -513,13 +519,25 @@ MediaVault redirect 与 ffprobe 必须在同一次任务中使用相同 User-Age 实现,仍需覆盖 HDR、Dolby Vision、多音轨、字幕、大型 MKV、尾部索引容器、高延迟和 失败源。 +ffprobe 成功但未返回 format size 时,Gateway 可以在同一 worker 中使用相同 User-Agent +对同一直链发起一次 `Range: bytes=0-0`。该请求使用独立 `2s` 上限,只接受有效的 +`206 Content-Range` 总大小,不读取响应体,也不跟随重定向。失败只保留 size 为空,不能 +丢弃其他已验证字段或延长 302 路径。成功结果随当前 Provider revision 持久化,缓存命中 +在 fresh 期间不重复访问 CDN,fresh 到期后的 retained 记录仍可触发后台复验。 + 首次冷缓存长期返回空 MediaInfo 不可作为正常体验,metadata 请求一直无响应同样不可 -接受。处理顺序为:Gateway 启动时从 SQLite 恢复 L1;精确冷 miss 时创建最高优先级 -任务,并允许当前单项 metadata response 在可配置的硬截止时间内等待。当前 PoC 候选 -上限为 `5s`;窗口内完成就补充当前 response,超时或失败必须立即返回原始 Plex -response,任务继续在后台完成。当前集确认播放后,管理面只发现并低优先预热下一集, -不扩散为季、整剧或目录扫描。提高首次完整率应优先使用准确的一步预取,而不是无限增加 -请求等待或启动无界批量任务。 +接受。处理顺序为:Gateway 启动时按需从 SQLite 回填 L1;单项 metadata 精确冷 miss +立即返回原始 Plex response,只在内存中投递有界 P2,由 worker 异步查询 SQLite 并在 +必要时探测 MediaVault/CDN。实际播放 decision 的 P0 冷探测可以使用独立、可配置的硬 +截止时间,当前候选上限为 `5s`。当前项云端重定向已就绪后,管理面只发现并限速预热 +可配置邻近窗口,不扩散为季、整剧或目录扫描。提高首次完整率应优先使用有界后台窗口, +而不是增加浏览请求等待或启动无界批量任务。 + +Gateway 不缓存原始文件头尾。MediaVault 的上传预缓存利用上传时仍在本地的数据,适合 +减少其后续扫描对 115 的请求,但只覆盖经 MediaVault 上传的新文件;Gateway 若从 CDN +下载相同头尾再缓存,并不能降低首次探测风险。Gateway 只持久化解析后的 MediaInfo。 +未来若 MediaVault 提供稳定的 MediaInfo 或预缓存读取 API,可作为优先 Provider,未命中 +时再回退 CDN ffprobe。 ## 11. Plex MediaInfo 投影 @@ -552,9 +570,14 @@ Gateway response enrichment 先用于验证 Plex 字段映射和客户端消费 打开 Plex 数据库。如果不启用 helper,Gateway 仍需保留 metadata 请求准入、缓存与 熔断,并可在已确认的 STRM 项目上做客户端响应 enrichment。 -response enrichment 只修改当前已认证 Plex response 中已经存在的精确 Media 和 Part, -Plex 已有字段不覆盖。首版只处理成功的单项 `GET /library/metadata/{ratingKey}`;XML、 -JSON、gzip、body 大小或结构不支持,以及转换失败时,都必须原样返回 Plex response。 +response enrichment 只修改当前已认证 Plex response 中的精确 Media 和 Part,Plex 已有 +字段不覆盖。Part 完全没有 Stream 时,可以按 ffprobe 的流类型和源索引创建描述性 +Stream,但不生成 Plex Stream ID、`selected`、`default`、`decision` 等播放选择状态。 +Plex 已有 Stream 时只补充身份匹配项的缺失字段,不创建缺少的其他流。首版只处理成功的 +单项 `GET /library/metadata/{ratingKey}`;XML、JSON、gzip、body 大小或结构不支持,以及 +转换失败时,都必须原样返回 Plex response。带 `skipRefresh` 且产品名以 `-Library` +结尾的后台媒体库同步只消费现有缓存,不准入冷探测,避免一次媒体库同步扩散为全库 CDN +ffprobe。 修改成功后同步更新 Content-Length,并移除失效的 ETag、Content-MD5 或 Digest。HDR 和 Dolby Vision 映射必须使用同一片源的本地文件与 STRM Plex response 建立 fixture,不能 仅按 ffprobe 字段名猜测。 @@ -578,10 +601,10 @@ Gateway 应负责: - 透明转发 Plex API 和观看状态; - 只对已确认 STRM Part 进行 Direct Play/302 适配; - 对所有未知和已知项目的详细 metadata 请求执行通用有界准入,保护冷缓存和首次浏览; -- 对相同认证范围、ratingKey、query profile、内容协商和 Gateway 版本做 singleflight - 与短缓存,禁止跨 Plex 用户或 token 权限域复用; -- 过载时只对非关键详细 metadata 返回合规的缓存响应或明确 `429/503`,不能 fail-open - 绕过保护;时间线、观看状态和本地播放请求不进入该限流池; +- 对认证范围、完整 query、客户端 Header、内容协商和远端身份一致的单项读取做短窗口 + 微批,不缓存 metadata,禁止跨 Plex 用户、Token、Cookie 或 Authorization 权限域复用; +- 过载时只对非关键详细 metadata 返回明确 `429`,不使用未验证的缓存响应,也不能 + fail-open 绕过保护;时间线、观看状态和本地播放请求不进入该限流池; - 云项目定向策略只能使用独立、稳定、只读的分类索引,不能依赖 MoviePilot 私有表, 也不能只依赖 Plex 响应返回后才建立的 PartCache; - 本地播放和未纳入保护策略的未知 endpoint 继续透明代理;详细 metadata 过载是明确例外; @@ -594,28 +617,33 @@ Gateway 不应负责: - 自动把借用分享晋升为自有副本; - 扫描 MoviePilot 下载历史; - 维护做种或本地清理状态; -- 在播放 decision、Part、universal start 或 302 请求中执行 ffprobe 或数据库访问; +- 在 Part、universal start 或 302 请求中执行 ffprobe 或数据库访问; - 写 Plex 数据库。 -Gateway analysis worker 当前只执行精确单 Part 和一步下一集探测。单季、整剧和 STRM +Playback decision 是请求路径中唯一允许使用分析面的例外。它只能在配置的冷等待预算内 +读取持久记录或等待一次有界 P0 探测;超时或失败必须保留 Plex 原始决策。 + +Gateway analysis worker 当前只执行精确当前 Part 和可配置邻近窗口探测。单季、整剧和 STRM 目录探测保留为后续有界批量能力;这些任务不拥有下载、做种或清理决策,也不能由一次 播放无界扩散到整库。 ### 12.1 Plex 管理凭据与管理面 -Gateway 保存一个可选 `PLEX_TOKEN`,用于后台枚举、Part 发现、预热和 reconciliation。 +Gateway 保存一个可选 `PLEX_TOKEN`,当前用于启动校验、Part 发现和邻近媒体预热。 它与活动请求中的客户端 Plex Token 分离,不替代当前用户的播放授权,也不复制到任务 记录。Go 程序只读取环境变量;Docker Compose 部署使用不纳入 Git 的 `app.env`,通过 `env_file: ./app.env` 注入。`.env` 保留给 Compose 默认插值语义。 -Token 缺失或失效时,透明代理和客户端播放继续工作,只有后台发现与预热降级。启动时 -验证 Token;稳定 Plex server identity 从无需管理 Token 的 PMS `/identity` 获取。日志、 +Token 缺失或失效时,透明代理、客户端播放和当前项预热继续工作,只有邻近媒体发现降级。 +启动时验证 Token;稳定 Plex server identity 从无需管理 Token 的 PMS `/identity` 获取。日志、 metrics、任务、CLI 和管理响应都不得输出 Token 或 machine identifier。管理 Token 只允许 -发送到配置的 Plex origin;MediaVault、CDN 和 ffprobe 请求使用独立构造的干净 header, -禁止继承 `X-Plex-Token`、Cookie 或 Authorization。 +发送到配置的 Plex origin,不能注入客户端播放请求或 MediaInfo 任务。播放链路继续按既有 +契约向可信 MediaVault 透传客户端 header;CDN ffprobe 只复用探测任务选定的客户端或 +fallback User-Agent,不继承管理 Token。 -Gateway 当前没有后台页面。当前管理面只需要查询状态、验证 Token 和控制一步下一集 -预热;创建单季、整剧和目录任务、批任务 checkpoint 与恢复留到后续。以后若增加 HTTP +Gateway 当前没有后台页面。当前管理面只提供状态查询和启动时 Token 验证,当前项及邻近 +窗口预热由成功 302 自动触发;创建显式控制接口、单季、整剧和目录任务、批任务 checkpoint +与恢复留到后续。以后若增加 HTTP API 或页面,使用默认关闭的独立 admin listener 或 Unix socket 和独立认证,不能挂到 客户端可访问的 Plex listener。 @@ -686,9 +714,9 @@ provider、目标 backing、decision revision、人工确认或策略依据, | MediaVault 缺少需要的 API | 标记 capability unavailable/manual,选择可控 provider 或人工检查点 | | STRM 半写入 | 生产者必须临时文件写完后原子 rename;Gateway 读取失败则 fallback Plex | | Plex 未扫描到 Part | 延迟重试定向刷新,不猜测 PartID,不写数据库 | -| MediaInfo 冷探测超时 | 在硬截止时间返回原始 Plex metadata,后台继续任务,不影响播放和观看状态 | +| MediaInfo 播放冷探测超时 | 在 decision 硬截止时间返回 Plex 原始结果,后台任务可继续,不影响观看状态;metadata 浏览不等待冷探测 | | MediaInfo 探测失败 | 保留已知良好记录并进入负缓存;没有旧记录时保持 Plex 原始字段 | -| Plex 管理 Token 失效 | 停止主动发现和预热,客户端请求继续使用自己的 Token | +| Plex 管理 Token 失效 | 停止邻近媒体发现;当前项预热和客户端请求继续工作 | | Plex DB helper 拒绝写入 | 保留权威 MediaInfo,播放按 Gateway 能力降级,不删除本地文件 | | 分享挂载失效 | 标记 borrowed source unavailable,保留观看状态,等待换源、转存或人工退休 | | 分享转存部分成功 | 逐项验证自有 backing,未成功项不得标记为 `verified` | @@ -698,8 +726,8 @@ provider、目标 backing、decision revision、人工确认或策略依据, ## 15. 安全与隐私 - MoviePilot 到 MediaVault 的管理调用只使用 Header API Key,不使用 query API Key; -- Gateway 可以通过部署专用 `app.env` 注入 `PLEX_TOKEN`,仅用于后台发现、预热和 - reconciliation; +- Gateway 可以通过部署专用 `app.env` 注入 `PLEX_TOKEN`,当前仅用于启动校验和邻近 + 媒体发现; - 客户端 Plex Token 只属于当前请求,不转换为后台管理凭据; - Gateway 不保存 115 Cookie、分享提取码或 MediaVault 管理 API Key; - 分享引用、云盘 file ID、下载 hash 和本地路径只在必要的私有状态中保存; @@ -723,7 +751,7 @@ provider、目标 backing、decision revision、人工确认或策略依据, ### Phase B:Gateway 请求保护 - 对详细 metadata 建立通用 admission control; -- 使用认证隔离的 singleflight 和短期缓存; +- 使用认证隔离的精确去重和短窗口 Plex 原生 metadata 微批,不缓存 metadata; - 明确禁止缓存的响应和过载返回策略; - 建立独立只读的云项目分类索引契约; - 完成 Apple TV 大媒体库浏览压测。 @@ -742,14 +770,14 @@ provider、目标 backing、decision revision、人工确认或策略依据, ### Phase D:MediaInfo 兜底与投影 PoC -- 优先实现 Gateway L1、SQLite、受限 ffprobe、`PLEX_TOKEN` 主动预热和 response +- 优先实现 Gateway L1、SQLite、受限 ffprobe、`PLEX_TOKEN` 邻近发现和 response enrichment; -- 实现精确单 Part 和确认播放后的一步下一集任务,以及优先级、singleflight、推测任务 - 抢占、负缓存和已知良好保护; +- 实现精确当前 Part 和重定向就绪后的双向邻近窗口,以及优先级、限速提交、singleflight、 + 排队提升、容量让位、负缓存和已知良好保护;P0 不打断已经运行的探测; - 将单季、整剧、STRM 目录任务、批任务 checkpoint 和恢复留到后续批量预热能力; -- 验证 `5s` 冷等待候选上限、首次 metadata 完整率和 metadata p50/p95/p99; -- 验证 decision、Part、universal start 和 302 不执行分析 I/O,单项 metadata 的有界等待 - 单独计入和验收; +- 验证 decision 的 `5s` 冷等待候选上限、首次 metadata 完整率和 metadata p50/p95/p99; +- 验证 Part、universal start 和 302 不执行分析 I/O,单项 metadata 冷 miss 立即透传且 + 只进行非阻塞 P2 投递; - 比较 Plex 官方 API、其他受支持 PMS 接口和独立 Plex Helper,不提前固定写入方案; - Helper 候选只在 Plex 停止或经过验证的维护窗口执行; - 测试库验证一致性备份与 restore、schema allowlist、CAS、写入和 API 回读; @@ -782,7 +810,7 @@ provider、目标 backing、decision revision、人工确认或策略依据, ## 17. 待评估决策 1. `5s` 冷等待能否覆盖 HDR、Dolby Vision、大型 MKV 和高延迟 CDN 的 p95/p99; -2. L1 启动恢复和 Plex 管理 Token 主动预热能覆盖多少首次请求; +2. L1 启动恢复、当前项预热和 Plex 管理 Token 邻近发现能覆盖多少首次请求; 3. MediaInfo 公开契约使用原子 sidecar、只读 API,还是同时提供; 4. MediaVault 是否愿意提供版本化的分享任务和 MediaInfo API; 5. 未提供接口时,自有 115 provider 的允许范围与维护成本; diff --git a/docs/performance-matrix.md b/docs/performance-matrix.md new file mode 100644 index 0000000..ec9158e --- /dev/null +++ b/docs/performance-matrix.md @@ -0,0 +1,131 @@ +# 性能矩阵 + +性能是 `plex-gateway` 的首要验收条件。功能覆盖不能以增加本地媒体延迟、把分析 I/O +放入 302 路径,或扩大 Plex、MediaVault 和 CDN 请求风暴为代价。 + +本文区分三类数据:本机微基准、脱敏真实链路样本和 NAS 候选版本端到端验收。微基准 +只能衡量代码增量,不能替代客户端首帧、Plex 响应或 CDN 网络结果。 + +## 不可破坏的边界 + +- 本地 Part 在识别为非云媒体后立即交给 Plex,不读取 MediaInfo、不访问 SQLite、不执行 + veto,也不请求 MediaVault 或 CDN。 +- 普通 STRM Part 和 universal start 不等待 MediaInfo,也不执行 veto。 +- L1 命中直接返回,不因本次请求同步读取 SQLite。访问续期可能异步 touch SQLite, + 因此持久化 I/O 不得阻塞热路径。 +- 单项 metadata 冷 miss 只允许内存级 P2 投递并立即返回 Plex 原响应,不同步读取 SQLite、 + 请求 MediaVault 或等待 CDN probe。 +- 详细 metadata 请求默认只移除 `asyncAugmentMetadata`,避免浏览突发调度 Plex 后台分析; + `checkFiles` 继续透传以保留 `Part.accessible` 与 `Part.exists`。 +- 通用 metadata 微批只合并认证、query、Header、表示和远端身份完全一致的单项 GET。 + 它不缓存响应;HEAD、Range、条件请求和任何不确定身份直接透传。 +- 一个 universal decision 最多等待一次 `MEDIAINFO_COLD_WAIT`。veto 只复用响应增强已经 + 取得的新鲜记录,不增加缓存读取或远程探测。 +- 同一 MediaInfo key 的并发请求必须共享 singleflight。并发数不能突破配置的 worker + 上限;P0 实际播放优先于 P1/P2 排队任务,但不能抢占正在运行的后台 probe。 +- P1/P2 在 worker 中先检查 SQLite。只有 L1 和 SQLite 都 miss 的任务才受全局远程启动 + 间隔限制;SQLite 热命中不能因 CDN 风控节流额外等待 5 秒。 +- 302 响应只包含控制流。媒体字节始终由客户端直接从 CDN 读取。 + +## 当前基准 + +基准采集于 2026-08-29 至 2026-08-30。代码运行于 `darwin/arm64`、Apple M3 Max、Go 1.26.5。 +每项运行三到五轮;下表给出观测范围。工作站数据只用于发现代码回退,换架构或 Go +版本后必须重新采集。 + +| 操作 | 结果 | 分配 | 说明 | +| --- | ---: | ---: | --- | +| MediaInfo L1 cache `Get` | 469.0-476.0 ns/op | 960 B, 7 allocs | 纯缓存结构基准。 | +| MediaInfo service `GetMemory` | 1.356-1.423 us/op | 2880 B, 21 allocs | 包含记录防御性复制、兼容性校验和访问续期判断。 | +| MediaInfo service fresh `Offer` | 1.410-1.453 us/op | 2912 B, 22 allocs | P0/P1/P2 非阻塞投递前的 L1 热命中路径。 | +| MediaInfo service exact-key join `Offer` | 521.0-550.1 ns/op | 512 B, 7 allocs | 已有 flight 的内存去重,不读取 SQLite。 | +| STRM target fingerprint | 382.0-387.4 ns/op | 448 B, 6 allocs | MediaVault redirect URL 规范化和 SHA-256。 | +| SQLite 单记录 `Get` | 14.60-15.37 us/op | 4016 B, 74 allocs | 独立本机 SQLite 读取;生产启动会先恢复到 L1。 | +| 本机 HTTP 合成媒体 ffprobe | 22.48-33.36 ms/op | 约 119-125 KiB, 232-234 allocs | 只验证 worker 代码,不代表 NAS/CDN。 | +| Metadata 分析过滤器,非 metadata | 6.247-6.429 ns/op | 0 B, 0 allocs | 普通代理路径仅执行方法和前缀判断。 | +| Metadata 分析过滤器,无目标参数 | 75.36-77.49 ns/op | 32 B, 2 allocs | 保留原请求和完整 raw query。 | +| Metadata 分析过滤器,移除目标参数 | 377.3-386.3 ns/op | 768 B, 7 allocs | 克隆请求 URL,其他 query 字节不变。 | +| 32 项 metadata 直接内存 handler | 76.87-78.02 us/op | 约 219.5 KiB, 929 allocs | 仅作为微批代码增量对照,不包含 Plex 网络或数据库。 | +| 32 项 metadata 微批、拆分与 fan-out | 422.9-448.4 us/op | 约 577.1 KiB, 4856-4857 allocs | 相对直接 handler 增加约 0.35-0.37 ms,包含 goroutine、JSON 拆分及 32 个响应。 | + +真实 MediaVault/CDN 抽样为 5/5 成功:MediaVault redirect p50 141 ms、范围 +109-533 ms;受限 CDN ffprobe p50 601 ms、范围 403-674 ms;合计 p50 761 ms、范围 +543-955 ms。每次读取约 1.22 MiB 并发生 2 次 seek。样本量较小,只证明当前探测通常能在 +默认 5 秒冷等待内完成,不能作为 CDN SLA。 + +真实 Plex 控制面 A/B 中,直连 Plex 与 Gateway 透明代理单项 metadata p50 均约 +`46-47ms`。Plex 原生 batch 的 16、32、64、100 项响应约为 `50ms`、`94ms`、`182ms`、 +`285ms`。Apple TV 长剧集样本曾在约一秒内发出 1010 个单项请求,其中 581 个唯一条目, +峰值约 180 请求/秒;固定并发 Guard 会将这种突发转换为多轮排队。32 项约为 +340 item/s,64 项约为 352 item/s,吞吐只增加约 3.5%,但单批延迟和失败回退范围接近 +翻倍,因此生产上限固定为 32,不开放 64 项实验值。 + +2026-08-30 的真实 Apple TV 长剧集 A/B 使用 20ms 窗口和 32 项 batch。批量 Guard 为 3 时, +54 个 batch 承载 1127 个条目,Guard 等待 p50/p95/max 为 `0/349/902ms`,Plex 处理为 +`317/941/1466ms`,总耗时为 `418/1043/1518ms`。批量 Guard 调为 4 后,57 个 batch 承载 +1163 个条目,Guard 等待降为 `0/44/537ms`,Plex 处理为 `140/927/1174ms`,总耗时为 +`208/1037/1199ms`。两轮均为 0 fallback、0 Guard timeout;客户端复测体感接近本地媒体。 +A/B 顺序没有清理 Plex 内部缓存,因此不能把 Plex p50 的全部下降归因于并发调整,但 +Guard 尾部等待和客户端结果共同支持批量默认值 4。 + +同日使用 128 个本地 episode 的固定 metadata burst 做了独立开关 A/B。微批关闭的三轮 +耗时为 `2579-2888ms`,开启后的三轮为 `2214-2470ms`,改善约 `335-674ms`,即 +`13%-23%`。开启组累计 512 个原请求合成 18 个 batch,0 fallback、0 Guard timeout。 +这说明通用微批没有为 STRM 牺牲本地媒体,但几百毫秒的 API 改善未必能被页面渲染体感 +稳定识别。 + +当前 Metadata Guard 默认值为单项全局 16、单客户端 16、批量 4;微批默认窗口 20ms, +单批最多 32 项。合成 batch 占用批量池,异常回退占用单项池。是否继续调整只依据候选 +版本的 Plex 延迟、错误率和真实客户端请求矩阵,不与未来 Plex MediaInfo 写入方案绑定。 + +2026-08-30 的 NAS 阻塞 A/B 将旧候选判定为不可接受:同一冷 metadata 请求直连 Plex +约 `0.52s`,经旧候选约 `5.15s`,Gateway 热缓存约 `0.15s`。同期实际 CDN ffprobe 平均 +约 `0.54s`、最大约 `0.84s`,且没有 probe 失败。约五秒的增量来自 metadata 请求同步等待 +后台调度窗口并持有 Guard 准入,不是 ffprobe 执行时间。该结果确立了“浏览 miss 立即透传, +仅实际播放 decision 可使用冷等待预算”的当前门槛;修正版已经通过上述 NAS 长剧集与 +本地媒体 A/B。 + +## 端到端验收矩阵 + +| 场景 | 请求期允许的 I/O | 必须采集 | 验收条件 | +| --- | --- | --- | --- | +| 本地媒体透明代理 | 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 时透明回退。 | +| 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。 | +| Decision,真实冷探测 | Plex、MediaVault、CDN、SQLite 写入 | resolver/probe/总延迟、读取字节、seek、fail-open | 成功样本 p95 < 2 s、p99 <= 5 s;超过冷等待返回可用的 fail-open 响应。 | +| Apple TV DV5,veto 开启 | Plex metadata、decision 和既有 MediaInfo 流程 | reject 延迟、grant、额外 I/O | 新鲜完整记录命中时 reject 且不创建 grant;相对 veto 关闭增加 0 次缓存、MV、CDN 请求。 | +| 同一 Part 并发 2/4/8/16 | 一个共享 probe | 唯一 probe 数、延迟、队列、active | 每个 key 最多 1 次 probe,无重复 CDN 读取。 | +| 不同 Part 并发 2/4/8 | 受 worker 上限约束 | active、队列、CPU、内存、错误率 | 不突破 `MEDIAINFO_CONCURRENCY`,不拖慢本地或 302 路径。 | +| A/B/C 快速切换 | 当前项 P0 probe、P1 邻近预热 | 每项延迟、身份对应、替换/队列指标 | 新当前项优先排队;不能抢占正在运行的 probe;无跨项 MediaInfo、grant 或 redirect。 | +| 长剧集 metadata 突发 | Plex、L1;P2 后台可查 SQLite/探测 | 前台响应、P2 队列/过期/远程启动次数 | 前台不等待 P2;唯一 pending P2 <= 50;远程启动间隔 >= 5 s;过期任务 0 MV/CDN。 | +| 长剧集 metadata 微批 | Plex、L1 | 原始单项数、合成 batch 数、唯一 item、fallback、p50/p95/p99 | 同组 batch <= 32;身份不跨组;正常响应 0 fallback;相对透明代理显著降低上游请求并改善页面完成时间。 | +| 长剧集分析参数过滤 A/B | Plex、L1 | 过滤计数、Scanner 进程、`accessible/exists`、metadata p50/p95/p99 | `asyncAugmentMetadata` 不到达 Plex;`checkFiles` 和对应字段保留;Scanner 为 0。 | + +表中的验收条件是候选版本的门槛,不代表本机微基准已经替代端到端验收。当前已有证据 +包括本机代码基准和 5/5 成功的 MediaVault/CDN 探测抽样;本地透明代理、302 热冷路径、 +决策热冷路径的 p95/p99、并发矩阵和快速切换矩阵,仍需使用候选版本在目标 NAS 上串行采集。 + +客户端首次画面必须单独测量。Gateway 302 时间、客户端跟随 Location、CDN 首字节和 +播放器首帧是不同阶段,不能用 302 延迟代替起播性能。 + +## 复现命令 + +```sh +go test -run '^$' \ + -bench 'Benchmark(MediaInfoL1Get|MediaInfoServiceGetMemory|MediaInfoServiceOfferFresh|MediaInfoServiceOfferJoin|FingerprintSTRMTarget|MediaInfoSQLiteGet|MediaInfoHTTPFFProbe)$' \ + -benchmem -count=5 ./internal/mediainfo + +go test -run '^$' -bench '^BenchmarkMetadataAnalysisFilter$' \ + -benchmem -count=5 ./internal/gateway + +go test -run '^$' -bench '^BenchmarkMetadataCoalescerBurst32$' \ + -benchmem -count=5 ./internal/gateway +``` + +真实 NAS 验收必须使用候选镜像、固定样本和串行 A/B,记录版本、配置、样本数、分位数、 +错误率和聚合 I/O。公开结果不得包含真实域名、地址、媒体标题、PartID、ratingKey、Token、 +Cookie、签名 URL、完整 User-Agent 或原始 trace。候选代码、Go 版本、Plex/MediaVault +版本、配置或网络路径变化都会使旧结果失效。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 6327b6b..ee5b13b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -41,13 +41,14 @@ Runtime evidence from Plex Web and the official mobile client established a separate compatibility leaf: their original universal decision requested neither Direct Play nor Direct Stream and Plex rejected an STRM Part before the client reached `/library/parts`. With the same complete client profile, changing -only those two flags produced Plex's normal Direct Play decision and Part key. +those playback flags plus `hasMDE` produced Plex's normal Direct Play decision +and Part key. The compatibility adapter therefore: - performs an authenticated, bounded metadata read using the active client; - selects `Metadata -> Media[mediaIndex] -> Part[partIndex]` without flattening; -- rewrites only `directPlay` and `directStream` for a mapped STRM Part; +- rewrites `directPlay`, `directStream`, and `hasMDE` for a mapped STRM Part; - keeps Plex responsible for the decision response and playback session; - fails open for local media, ambiguous indices, authorization failures, and every unrecognized request. @@ -68,29 +69,58 @@ 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. -## MediaInfo Phase D - -MediaInfo is not an active dependency of redirect playback. The production Plex -snapshot currently exposes STRM Parts without container, codec, resolution, or -stream elements; the current playback plane does not synthesize those fields. - -MediaInfo remains isolated from redirect playback. Phase D prioritizes the -Gateway fallback while preserving the broader lifecycle design: +## MediaInfo fallback and projection + +MediaInfo is not a dependency of redirect playback. Eligible single-item +metadata and Direct Play decision responses can be enriched from the fail-open +L1 and SQLite cache. A cold decision may wait up to `MEDIAINFO_COLD_WAIT` for an +interactive probe; missing, slow, or failed probes preserve the original Plex +response. Part and universal-start redirects never wait for MediaInfo. All +clients use the same projection rules. An L1 hit does not synchronously read +SQLite for the request, although access renewal may touch SQLite asynchronously. +A metadata browsing miss returns the original Plex response immediately and may +only offer bounded P2 work in memory. It never waits for SQLite, MediaVault, or +the CDN. Background work writes L1 and SQLite only after a successful probe. +A default-disabled experimental veto may reuse an already available fresh record +for the Apple TV Plex DV Profile 5 combination, but it performs no additional +probe and owns no Part state. + +MediaInfo probing and persistence remain isolated from redirect playback. Phase +D prioritizes the Gateway fallback while preserving the broader lifecycle design: 1. use L1 and SQLite for single-instance fallback storage; 2. accept optional `PLEX_TOKEN` from an ignored deployment `app.env` for - discovery and prewarming; -3. evaluate bounded remote `ffprobe`, with an initial `5s` cold metadata wait - ceiling based on the current sample; -4. support exact single-Part analysis and one-step next-episode prefetch after - confirmed playback without blocking decision, Part, universal start, or 302; + nearby-item discovery; current-item prewarming remains token-independent; +3. use bounded remote `ffprobe` with a default `5s` cold playback-decision wait + ceiling; metadata browsing does not consume that budget; +4. support exact current-Part analysis and a configurable nearby-item window + after a cloud redirect is ready. P0 actual playback, P1 nearby items, and P2 + foreground metadata misses share one exact-key scheduler. P1/P2 remote starts + are rate-limited without blocking Part, universal start, or 302. Queue + priority cannot preempt a probe already running; defer season, show, and configured STRM-root batch tasks; -5. enrich authorized Plex metadata responses first, then evaluate an official - Plex API, other supported PMS interfaces, and an isolated database helper - without selecting a persistence path in advance; +5. enrich authorized Plex metadata responses first. This release writes fallback + records only to Gateway SQLite. Production Plex DB writing is a next-stage + evaluation across an official Plex API, another supported PMS interface, and + an isolated database helper, with coverage, backup, and rollback tests before + any path is selected. Parts without Plex Streams may receive descriptive + ffprobe-indexed Streams, while existing Stream sets are never expanded and + Plex IDs or playback-selection state are never synthesized. Background + `-Library` synchronization remains cache-only. Foreground fan-out can only + offer bounded, expiring P2 work and never waits for that background queue; 6. add Redis or replace SQLite with PostgreSQL only after measured multi-instance or capacity requirements exist. +The performance matrix in [performance-matrix.md](performance-matrix.md) is a +delivery gate for this work. Local media must remain transparent, Part and +universal-start routes must remain free of analysis I/O, an L1 hit must not +block on SQLite, and one decision may consume at most one configured cold-wait +budget. The current Guard defaults are global 16, per-client 16, and batch 4. +Equivalent single-item metadata GET requests may use the bounded native Plex +micro-batch adapter before Guard admission; invalid batches return through the +single-item limits. Guard changes remain evidence-driven rather than tied to a +future Plex MediaInfo write strategy. + The complete MoviePilot, MediaVault, 115 share, ownership, seeding, cleanup, MediaInfo, and projection design is documented in [media-lifecycle-architecture.md](media-lifecycle-architecture.md). Gateway diff --git a/internal/config/config.go b/internal/config/config.go index 62d16fd..d797282 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,83 +14,107 @@ import ( ) const ( - defaultListenAddr = ":32400" - defaultReadHeaderTimeout = 15 * time.Second - defaultIdleTimeout = 90 * time.Second - defaultShutdownTimeout = 15 * time.Second - defaultPartTTL = 24 * time.Hour - defaultResolverTimeout = 15 * time.Second - defaultObserveMaxBytes = 8 << 20 - defaultPartProbeTimeout = 15 * time.Second - defaultMetadataGlobal = 8 - defaultMetadataPerClient = 4 - defaultMetadataBatch = 3 - defaultMetadataQueueWait = 10 * time.Second - defaultDatabasePath = "./data/plex-gateway.db" - defaultMediaInfoProbe = 20 * time.Second - defaultMediaInfoSize = 8 << 20 - defaultMediaInfoAnalyze = 5 * time.Second - defaultMediaInfoOutput = 2 << 20 - defaultMediaInfoWorkers = 1 - defaultMediaInfoQueue = 256 - defaultMediaInfoTTL = 30 * 24 * time.Hour - defaultMediaInfoRetention = 180 * 24 * time.Hour - defaultMediaInfoL1Entries = 10_000 - defaultMediaInfoNegative = 15 * time.Minute - defaultMediaInfoAgent = "Infuse-Library/8.4.4" - defaultMediaInfoColdWait = 5 * time.Second - defaultMediaInfoBodyLimit = 8 << 20 - defaultMediaInfoWaiters = 4 + defaultListenAddr = ":32400" + defaultReadHeaderTimeout = 15 * time.Second + defaultIdleTimeout = 90 * time.Second + defaultShutdownTimeout = 15 * time.Second + defaultPartTTL = 24 * time.Hour + defaultResolverTimeout = 15 * time.Second + defaultObserveMaxBytes = 8 << 20 + defaultPartProbeTimeout = 15 * time.Second + defaultMetadataGlobal = 16 + defaultMetadataPerClient = 16 + defaultMetadataBatch = 4 + defaultMetadataQueueWait = 10 * time.Second + defaultMetadataCoalesceWindow = 20 * time.Millisecond + defaultMetadataCoalesceMaxItems = 32 + defaultMetadataCoalesceTimeout = 5 * time.Second + maxMetadataCoalesceWindow = 100 * time.Millisecond + maxMetadataCoalesceItems = 32 + defaultDatabasePath = "./data/plex-gateway.db" + defaultMediaInfoProbe = 20 * time.Second + defaultMediaInfoSize = 8 << 20 + defaultMediaInfoAnalyze = 5 * time.Second + defaultMediaInfoOutput = 2 << 20 + defaultMediaInfoWorkers = 1 + defaultPlaybackQueue = 16 + defaultNeighborQueue = 50 + defaultMetadataQueue = 50 + defaultPendingTTL = 5 * time.Minute + defaultBackgroundInterval = 5 * time.Second + defaultMediaInfoTTL = 30 * 24 * time.Hour + defaultMediaInfoRetention = 180 * 24 * time.Hour + defaultMediaInfoL1Entries = 10_000 + defaultMediaInfoNegative = 15 * time.Minute + defaultMediaInfoAgent = "Infuse-Library/8.5.1" + defaultMediaInfoColdWait = 5 * time.Second + defaultMediaInfoBodyLimit = 8 << 20 + defaultMediaInfoWaiters = 8 + defaultPrewarmBefore = 2 + defaultPrewarmAfter = 3 + maxPrewarmWindow = 50 ) // Config contains process-level settings for transparent Plex proxying and -// optional STRM redirect handling. Credentials remain request-scoped and are -// never loaded into this structure. +// optional STRM redirect handling. PlexToken is an isolated management +// credential for nearby-item discovery and is never mixed into client playback +// context. type Config struct { - ListenAddr string - DatabasePath string - PlexURL *url.URL - MediaVaultURL *url.URL - PathMappings []pathmap.Mapping - CloudExtensions []string - PartTTL time.Duration - ResolverTimeout time.Duration - ObserveMaxBytes int64 - PartProbeTimeout time.Duration - MetadataGuard MetadataGuardConfig - MediaInfo MediaInfoConfig - LogLevel string - TraceEnabled bool - ReadHeaderTimeout time.Duration - IdleTimeout time.Duration - ShutdownTimeout time.Duration + ListenAddr string + DatabasePath string + PlexURL *url.URL + PlexToken string + MediaVaultURL *url.URL + PathMappings []pathmap.Mapping + CloudExtensions []string + PartTTL time.Duration + ResolverTimeout time.Duration + ObserveMaxBytes int64 + PartProbeTimeout time.Duration + MetadataAnalysisFilter bool + MetadataGuard MetadataGuardConfig + MetadataCoalesce MetadataCoalesceConfig + MediaInfo MediaInfoConfig + PlaybackVeto bool + LogLevel string + TraceEnabled bool + ReadHeaderTimeout time.Duration + IdleTimeout time.Duration + ShutdownTimeout time.Duration } // MediaInfoConfig bounds the optional analysis plane. It is enabled by default // but capability initialization remains fail-open for transparent Plex proxying. type MediaInfoConfig struct { - Enabled bool - FFProbePath string - ProbeTimeout time.Duration - ProbeSize int64 - AnalyzeDuration time.Duration - OutputMaxBytes int64 - Concurrency int - InteractiveQueueSize int - BackgroundQueueSize int - RecordTTL time.Duration - RecordRetention time.Duration - L1MaxEntries int - NegativeTTL time.Duration - UserAgent string - ColdWait time.Duration - ResponseMaxBytes int64 - EnrichmentWaiters int + Enabled bool + FFProbePath string + ProbeTimeout time.Duration + ProbeSize int64 + AnalyzeDuration time.Duration + OutputMaxBytes int64 + Concurrency int + PlaybackQueueSize int + NeighborQueueSize int + MetadataQueueSize int + PendingTTL time.Duration + BackgroundInterval time.Duration + RecordTTL time.Duration + RecordRetention time.Duration + L1MaxEntries int + NegativeTTL time.Duration + UserAgent string + ColdWait time.Duration + ResponseMaxBytes int64 + EnrichmentWaiters int + // PrewarmBefore and PrewarmAfter bound speculative neighbors around a + // cloud redirect. Remote starts remain governed by BackgroundInterval. + PrewarmBefore int + PrewarmAfter int } // MetadataGuardConfig bounds detailed metadata fan-out before requests enter -// Plex. The protection is opt-in because client behavior and Plex capacity vary -// across deployments. +// Plex. It remains independently configurable from metadata analysis filtering +// so operators can validate Plex capacity without restoring file inspection. type MetadataGuardConfig struct { Enabled bool GlobalConcurrency int @@ -100,6 +124,15 @@ type MetadataGuardConfig struct { QueueTimeout time.Duration } +// MetadataCoalesceConfig bounds transparent aggregation of equivalent +// single-item reads into Plex's native comma-separated metadata endpoint. +type MetadataCoalesceConfig struct { + Enabled bool + Window time.Duration + MaxItems int + Timeout time.Duration +} + // Load reads environment configuration and rejects values that would make the // proxy ambiguous or accidentally embed credentials in durable configuration. func Load() (Config, error) { @@ -123,6 +156,10 @@ func Load() (Config, error) { } plexURL.RawQuery = "" plexURL.Fragment = "" + plexToken := strings.TrimSpace(os.Getenv("PLEX_TOKEN")) + if strings.ContainsAny(plexToken, "\r\n") { + return Config{}, errors.New("PLEX_TOKEN must not contain line breaks") + } mediaVaultURL, err := optionalHTTPURL("MEDIAVAULT_URL") if err != nil { @@ -157,11 +194,15 @@ func Load() (Config, error) { return Config{}, err } - traceEnabled, err := envBool("TRACE_ENABLED", false) + traceEnabled, err := envBool("TRACE_ENABLED", true) + if err != nil { + return Config{}, err + } + metadataGuardEnabled, err := envBool("METADATA_GUARD_ENABLED", true) if err != nil { return Config{}, err } - metadataGuardEnabled, err := envBool("METADATA_GUARD_ENABLED", false) + metadataAnalysisFilter, err := envBool("METADATA_ANALYSIS_FILTER_ENABLED", true) if err != nil { return Config{}, err } @@ -176,7 +217,7 @@ func Load() (Config, error) { if metadataPerClient > metadataGlobal { return Config{}, errors.New("METADATA_GUARD_CLIENT_CONCURRENCY must not exceed METADATA_GUARD_GLOBAL_CONCURRENCY") } - metadataBatchEnabled, err := envBool("METADATA_GUARD_BATCH_ENABLED", false) + metadataBatchEnabled, err := envBool("METADATA_GUARD_BATCH_ENABLED", true) if err != nil { return Config{}, err } @@ -188,6 +229,31 @@ func Load() (Config, error) { if err != nil { return Config{}, err } + metadataCoalesceEnabled, err := envBool("METADATA_COALESCE_ENABLED", true) + if err != nil { + return Config{}, err + } + metadataCoalesceWindow, err := envDuration("METADATA_COALESCE_WINDOW", defaultMetadataCoalesceWindow) + if err != nil { + return Config{}, err + } + metadataCoalesceMaxItems, err := envPositiveInt("METADATA_COALESCE_MAX_ITEMS", defaultMetadataCoalesceMaxItems) + if err != nil { + return Config{}, err + } + metadataCoalesceTimeout, err := envDuration("METADATA_COALESCE_TIMEOUT", defaultMetadataCoalesceTimeout) + if err != nil { + return Config{}, err + } + if metadataCoalesceWindow > maxMetadataCoalesceWindow { + return Config{}, fmt.Errorf("METADATA_COALESCE_WINDOW must not exceed %s", maxMetadataCoalesceWindow) + } + if metadataCoalesceMaxItems < 2 { + return Config{}, errors.New("METADATA_COALESCE_MAX_ITEMS must be at least 2") + } + if metadataCoalesceMaxItems > maxMetadataCoalesceItems { + return Config{}, fmt.Errorf("METADATA_COALESCE_MAX_ITEMS must not exceed %d", maxMetadataCoalesceItems) + } mediaInfoEnabled, err := envBool("MEDIAINFO_ENABLED", true) if err != nil { return Config{}, err @@ -220,11 +286,23 @@ func Load() (Config, error) { if err != nil { return Config{}, err } - mediaInfoInteractiveQueue, err := envPositiveInt("MEDIAINFO_INTERACTIVE_QUEUE_SIZE", defaultMediaInfoQueue) + mediaInfoPlaybackQueue, err := envPositiveInt("MEDIAINFO_PLAYBACK_QUEUE_SIZE", defaultPlaybackQueue) + if err != nil { + return Config{}, err + } + mediaInfoNeighborQueue, err := envPositiveInt("MEDIAINFO_NEIGHBOR_QUEUE_SIZE", defaultNeighborQueue) + if err != nil { + return Config{}, err + } + mediaInfoMetadataQueue, err := envPositiveInt("MEDIAINFO_METADATA_QUEUE_SIZE", defaultMetadataQueue) + if err != nil { + return Config{}, err + } + mediaInfoPendingTTL, err := envDuration("MEDIAINFO_PENDING_TTL", defaultPendingTTL) if err != nil { return Config{}, err } - mediaInfoBackgroundQueue, err := envPositiveInt("MEDIAINFO_BACKGROUND_QUEUE_SIZE", defaultMediaInfoQueue) + mediaInfoBackgroundInterval, err := envDuration("MEDIAINFO_BACKGROUND_INTERVAL", defaultBackgroundInterval) if err != nil { return Config{}, err } @@ -266,6 +344,21 @@ func Load() (Config, error) { if err != nil { return Config{}, err } + mediaInfoPrewarmBefore, err := envNonNegativeInt("MEDIAINFO_PREWARM_BEFORE", defaultPrewarmBefore) + if err != nil { + return Config{}, err + } + mediaInfoPrewarmAfter, err := envNonNegativeInt("MEDIAINFO_PREWARM_AFTER", defaultPrewarmAfter) + if err != nil { + return Config{}, err + } + if mediaInfoPrewarmBefore+mediaInfoPrewarmAfter > maxPrewarmWindow { + return Config{}, fmt.Errorf("MEDIAINFO_PREWARM_BEFORE and MEDIAINFO_PREWARM_AFTER must total at most %d", maxPrewarmWindow) + } + playbackVeto, err := envBool("PLAYBACK_VETO_ENABLED", false) + if err != nil { + return Config{}, err + } readHeaderTimeout, err := envDuration("READ_HEADER_TIMEOUT", defaultReadHeaderTimeout) if err != nil { @@ -293,16 +386,18 @@ func Load() (Config, error) { } return Config{ - ListenAddr: listenAddr, - DatabasePath: databasePath, - PlexURL: plexURL, - MediaVaultURL: mediaVaultURL, - PathMappings: pathMappings, - CloudExtensions: cloudExtensions, - PartTTL: partTTL, - ResolverTimeout: resolverTimeout, - ObserveMaxBytes: observeMaxBytes, - PartProbeTimeout: partProbeTimeout, + ListenAddr: listenAddr, + DatabasePath: databasePath, + PlexURL: plexURL, + PlexToken: plexToken, + MediaVaultURL: mediaVaultURL, + PathMappings: pathMappings, + CloudExtensions: cloudExtensions, + PartTTL: partTTL, + ResolverTimeout: resolverTimeout, + ObserveMaxBytes: observeMaxBytes, + PartProbeTimeout: partProbeTimeout, + MetadataAnalysisFilter: metadataAnalysisFilter, MetadataGuard: MetadataGuardConfig{ Enabled: metadataGuardEnabled, GlobalConcurrency: metadataGlobal, @@ -311,16 +406,23 @@ func Load() (Config, error) { BatchConcurrency: metadataBatch, QueueTimeout: metadataQueueWait, }, + MetadataCoalesce: MetadataCoalesceConfig{ + Enabled: metadataCoalesceEnabled, Window: metadataCoalesceWindow, + MaxItems: metadataCoalesceMaxItems, Timeout: metadataCoalesceTimeout, + }, MediaInfo: MediaInfoConfig{ Enabled: mediaInfoEnabled, FFProbePath: mediaInfoFFProbe, ProbeTimeout: mediaInfoProbeTimeout, ProbeSize: mediaInfoProbeSize, AnalyzeDuration: mediaInfoAnalyze, OutputMaxBytes: mediaInfoOutput, - Concurrency: mediaInfoWorkers, InteractiveQueueSize: mediaInfoInteractiveQueue, - BackgroundQueueSize: mediaInfoBackgroundQueue, - RecordTTL: mediaInfoTTL, RecordRetention: mediaInfoRetention, L1MaxEntries: mediaInfoL1Entries, + Concurrency: mediaInfoWorkers, PlaybackQueueSize: mediaInfoPlaybackQueue, + NeighborQueueSize: mediaInfoNeighborQueue, MetadataQueueSize: mediaInfoMetadataQueue, + PendingTTL: mediaInfoPendingTTL, BackgroundInterval: mediaInfoBackgroundInterval, + RecordTTL: mediaInfoTTL, RecordRetention: mediaInfoRetention, L1MaxEntries: mediaInfoL1Entries, NegativeTTL: mediaInfoNegativeTTL, UserAgent: mediaInfoUserAgent, ColdWait: mediaInfoColdWait, ResponseMaxBytes: mediaInfoBodyLimit, EnrichmentWaiters: mediaInfoWaiters, + PrewarmBefore: mediaInfoPrewarmBefore, PrewarmAfter: mediaInfoPrewarmAfter, }, + PlaybackVeto: playbackVeto, LogLevel: logLevel, TraceEnabled: traceEnabled, ReadHeaderTimeout: readHeaderTimeout, @@ -444,3 +546,18 @@ func envPositiveInt(name string, fallback int) (int, error) { } return value, nil } + +func envNonNegativeInt(name string, fallback int) (int, error) { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback, nil + } + value, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("parse %s: %w", name, err) + } + if value < 0 { + return 0, fmt.Errorf("%s must not be negative", name) + } + return value, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3b617a3..8511d91 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -18,11 +18,14 @@ func TestLoadRejectsCredentialInPlexURL(t *testing.T) { func TestLoadDefaults(t *testing.T) { t.Setenv("PLEX_URL", "http://plex:32400") + t.Setenv("PLEX_TOKEN", "") t.Setenv("MEDIAVAULT_URL", "") t.Setenv("PATH_MAPPINGS", "") t.Setenv("LISTEN_ADDR", "") t.Setenv("LOG_LEVEL", "") t.Setenv("TRACE_ENABLED", "") + t.Setenv("METADATA_ANALYSIS_FILTER_ENABLED", "") + t.Setenv("METADATA_COALESCE_ENABLED", "") got, err := Load() if err != nil { @@ -34,18 +37,35 @@ func TestLoadDefaults(t *testing.T) { if got.PlexURL.String() != "http://plex:32400" { t.Fatalf("PlexURL = %q", got.PlexURL) } - if got.TraceEnabled { - t.Fatal("TraceEnabled = true") + if got.PlexToken != "" { + t.Fatal("PlexToken unexpectedly configured") } - if got.MetadataGuard.Enabled || got.MetadataGuard.GlobalConcurrency != 8 || got.MetadataGuard.PerClientConcurrency != 4 || got.MetadataGuard.BatchEnabled || got.MetadataGuard.BatchConcurrency != 3 || got.MetadataGuard.QueueTimeout != 10*time.Second { + if !got.TraceEnabled { + t.Fatal("TraceEnabled = false") + } + if !got.MetadataGuard.Enabled || got.MetadataGuard.GlobalConcurrency != 16 || got.MetadataGuard.PerClientConcurrency != 16 || !got.MetadataGuard.BatchEnabled || got.MetadataGuard.BatchConcurrency != 4 || got.MetadataGuard.QueueTimeout != 10*time.Second { t.Fatalf("unexpected metadata guard defaults: %#v", got.MetadataGuard) } + if !got.MetadataAnalysisFilter { + t.Fatal("MetadataAnalysisFilter = false") + } + if !got.MetadataCoalesce.Enabled || got.MetadataCoalesce.Window != 20*time.Millisecond || got.MetadataCoalesce.MaxItems != 32 || got.MetadataCoalesce.Timeout != 5*time.Second { + t.Fatalf("unexpected metadata coalesce defaults: %#v", got.MetadataCoalesce) + } if got.DatabasePath != "./data/plex-gateway.db" { t.Fatalf("DatabasePath = %q", got.DatabasePath) } - if !got.MediaInfo.Enabled || got.MediaInfo.ProbeTimeout != 20*time.Second || got.MediaInfo.Concurrency != 1 || got.MediaInfo.UserAgent != "Infuse-Library/8.4.4" || got.MediaInfo.ColdWait != 5*time.Second || got.MediaInfo.ResponseMaxBytes != 8<<20 || got.MediaInfo.EnrichmentWaiters != 4 { + if !got.MediaInfo.Enabled || got.MediaInfo.ProbeTimeout != 20*time.Second || got.MediaInfo.Concurrency != 1 || + got.MediaInfo.PlaybackQueueSize != 16 || got.MediaInfo.NeighborQueueSize != 50 || + got.MediaInfo.MetadataQueueSize != 50 || got.MediaInfo.PendingTTL != 5*time.Minute || + got.MediaInfo.BackgroundInterval != 5*time.Second || got.MediaInfo.UserAgent != "Infuse-Library/8.5.1" || + got.MediaInfo.ColdWait != 5*time.Second || got.MediaInfo.ResponseMaxBytes != 8<<20 || + got.MediaInfo.EnrichmentWaiters != 8 || got.MediaInfo.PrewarmBefore != 2 || got.MediaInfo.PrewarmAfter != 3 { t.Fatalf("unexpected MediaInfo defaults: %#v", got.MediaInfo) } + if got.PlaybackVeto { + t.Fatal("PlaybackVeto = true") + } if got.MediaVaultURL != nil || len(got.PathMappings) != 0 { t.Fatalf("cloud redirect unexpectedly enabled: %#v", got) } @@ -59,6 +79,7 @@ func TestLoadDefaults(t *testing.T) { func TestLoadCloudConfiguration(t *testing.T) { t.Setenv("PLEX_URL", "http://plex:32400") + t.Setenv("PLEX_TOKEN", "management-token") t.Setenv("MEDIAVAULT_URL", "http://mediavault:7811") t.Setenv("PATH_MAPPINGS", `[ {"plex_prefix":"/media/cloud","local_prefix":"/media/cloud"}, @@ -70,11 +91,16 @@ func TestLoadCloudConfiguration(t *testing.T) { t.Setenv("OBSERVE_MAX_BYTES", "4096") t.Setenv("PART_PROBE_TIMEOUT", "3s") t.Setenv("METADATA_GUARD_ENABLED", "true") + t.Setenv("METADATA_ANALYSIS_FILTER_ENABLED", "false") t.Setenv("METADATA_GUARD_GLOBAL_CONCURRENCY", "6") t.Setenv("METADATA_GUARD_CLIENT_CONCURRENCY", "3") t.Setenv("METADATA_GUARD_BATCH_ENABLED", "true") t.Setenv("METADATA_GUARD_BATCH_CONCURRENCY", "2") t.Setenv("METADATA_GUARD_QUEUE_TIMEOUT", "5s") + t.Setenv("METADATA_COALESCE_ENABLED", "false") + t.Setenv("METADATA_COALESCE_WINDOW", "7ms") + t.Setenv("METADATA_COALESCE_MAX_ITEMS", "24") + t.Setenv("METADATA_COALESCE_TIMEOUT", "4s") t.Setenv("MEDIAINFO_ENABLED", "true") t.Setenv("DATABASE_PATH", "/app_data/test.db") t.Setenv("MEDIAINFO_FFPROBE_PATH", "/usr/bin/ffprobe") @@ -83,8 +109,11 @@ func TestLoadCloudConfiguration(t *testing.T) { t.Setenv("MEDIAINFO_ANALYZE_DURATION", "3s") t.Setenv("MEDIAINFO_OUTPUT_MAX_BYTES", "1048576") t.Setenv("MEDIAINFO_CONCURRENCY", "2") - t.Setenv("MEDIAINFO_INTERACTIVE_QUEUE_SIZE", "64") - t.Setenv("MEDIAINFO_BACKGROUND_QUEUE_SIZE", "128") + t.Setenv("MEDIAINFO_PLAYBACK_QUEUE_SIZE", "12") + t.Setenv("MEDIAINFO_NEIGHBOR_QUEUE_SIZE", "64") + t.Setenv("MEDIAINFO_METADATA_QUEUE_SIZE", "128") + t.Setenv("MEDIAINFO_PENDING_TTL", "7m") + t.Setenv("MEDIAINFO_BACKGROUND_INTERVAL", "9s") t.Setenv("MEDIAINFO_RECORD_TTL", "168h") t.Setenv("MEDIAINFO_RECORD_RETENTION", "720h") t.Setenv("MEDIAINFO_L1_MAX_ENTRIES", "5000") @@ -93,6 +122,8 @@ func TestLoadCloudConfiguration(t *testing.T) { t.Setenv("MEDIAINFO_COLD_WAIT", "4s") t.Setenv("MEDIAINFO_RESPONSE_MAX_BYTES", "2097152") t.Setenv("MEDIAINFO_ENRICHMENT_CONCURRENCY", "3") + t.Setenv("MEDIAINFO_PREWARM_BEFORE", "4") + t.Setenv("MEDIAINFO_PREWARM_AFTER", "6") got, err := Load() if err != nil { @@ -101,6 +132,9 @@ func TestLoadCloudConfiguration(t *testing.T) { if got.MediaVaultURL.String() != "http://mediavault:7811" || len(got.PathMappings) != 2 { t.Fatalf("cloud configuration = %#v", got) } + if got.PlexToken != "management-token" { + t.Fatalf("PlexToken = %q", got.PlexToken) + } if !reflect.DeepEqual(got.CloudExtensions, []string{".strm"}) { t.Fatalf("CloudExtensions = %v", got.CloudExtensions) } @@ -110,14 +144,39 @@ func TestLoadCloudConfiguration(t *testing.T) { if !got.MetadataGuard.Enabled || got.MetadataGuard.GlobalConcurrency != 6 || got.MetadataGuard.PerClientConcurrency != 3 || !got.MetadataGuard.BatchEnabled || got.MetadataGuard.BatchConcurrency != 2 || got.MetadataGuard.QueueTimeout != 5*time.Second { t.Fatalf("metadata guard = %#v", got.MetadataGuard) } + if got.MetadataAnalysisFilter { + t.Fatal("MetadataAnalysisFilter = true") + } + if got.MetadataCoalesce.Enabled || got.MetadataCoalesce.Window != 7*time.Millisecond || got.MetadataCoalesce.MaxItems != 24 || got.MetadataCoalesce.Timeout != 4*time.Second { + t.Fatalf("metadata coalesce = %#v", got.MetadataCoalesce) + } if got.DatabasePath != "/app_data/test.db" { t.Fatalf("DatabasePath = %q", got.DatabasePath) } - if !got.MediaInfo.Enabled || got.MediaInfo.FFProbePath != "/usr/bin/ffprobe" || got.MediaInfo.ProbeTimeout != 12*time.Second || got.MediaInfo.ProbeSize != 4194304 || got.MediaInfo.AnalyzeDuration != 3*time.Second || got.MediaInfo.OutputMaxBytes != 1048576 || got.MediaInfo.Concurrency != 2 || got.MediaInfo.InteractiveQueueSize != 64 || got.MediaInfo.BackgroundQueueSize != 128 || got.MediaInfo.RecordTTL != 168*time.Hour || got.MediaInfo.RecordRetention != 720*time.Hour || got.MediaInfo.L1MaxEntries != 5000 || got.MediaInfo.NegativeTTL != 10*time.Minute || got.MediaInfo.UserAgent != "plex-gateway-test" || got.MediaInfo.ColdWait != 4*time.Second || got.MediaInfo.ResponseMaxBytes != 2097152 || got.MediaInfo.EnrichmentWaiters != 3 { + if !got.MediaInfo.Enabled || got.MediaInfo.FFProbePath != "/usr/bin/ffprobe" || got.MediaInfo.ProbeTimeout != 12*time.Second || + got.MediaInfo.ProbeSize != 4194304 || got.MediaInfo.AnalyzeDuration != 3*time.Second || + got.MediaInfo.OutputMaxBytes != 1048576 || got.MediaInfo.Concurrency != 2 || + got.MediaInfo.PlaybackQueueSize != 12 || got.MediaInfo.NeighborQueueSize != 64 || + got.MediaInfo.MetadataQueueSize != 128 || got.MediaInfo.PendingTTL != 7*time.Minute || + got.MediaInfo.BackgroundInterval != 9*time.Second || got.MediaInfo.RecordTTL != 168*time.Hour || + got.MediaInfo.RecordRetention != 720*time.Hour || got.MediaInfo.L1MaxEntries != 5000 || + got.MediaInfo.NegativeTTL != 10*time.Minute || got.MediaInfo.UserAgent != "plex-gateway-test" || + got.MediaInfo.ColdWait != 4*time.Second || got.MediaInfo.ResponseMaxBytes != 2097152 || + got.MediaInfo.EnrichmentWaiters != 3 || got.MediaInfo.PrewarmBefore != 4 || got.MediaInfo.PrewarmAfter != 6 { t.Fatalf("MediaInfo configuration = %#v", got.MediaInfo) } } +func TestLoadRejectsPlexTokenWithLineBreak(t *testing.T) { + t.Setenv("PLEX_URL", "http://plex:32400") + t.Setenv("PLEX_TOKEN", "secret\nvalue") + + _, err := Load() + if err == nil || !strings.Contains(err.Error(), "line breaks") { + t.Fatalf("Load() error = %v", err) + } +} + func TestLoadRejectsMediaInfoRetentionBelowFreshTTL(t *testing.T) { t.Setenv("PLEX_URL", "http://plex:32400") t.Setenv("MEDIAINFO_RECORD_TTL", "720h") @@ -140,6 +199,29 @@ func TestLoadRejectsMetadataClientLimitAboveGlobalLimit(t *testing.T) { } } +func TestLoadRejectsUnsafeMetadataCoalesceBounds(t *testing.T) { + tests := []struct { + name string + env string + value string + }{ + {name: "window", env: "METADATA_COALESCE_WINDOW", value: "101ms"}, + {name: "too_few_items", env: "METADATA_COALESCE_MAX_ITEMS", value: "1"}, + {name: "items", env: "METADATA_COALESCE_MAX_ITEMS", value: "33"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv("PLEX_URL", "http://plex:32400") + t.Setenv(test.env, test.value) + + _, err := Load() + if err == nil || !strings.Contains(err.Error(), "must") { + t.Fatalf("Load() error = %v", err) + } + }) + } +} + func TestLoadRequiresCompleteCloudConfiguration(t *testing.T) { t.Setenv("PLEX_URL", "http://plex:32400") t.Setenv("MEDIAVAULT_URL", "http://mediavault:7811") @@ -150,3 +232,41 @@ func TestLoadRequiresCompleteCloudConfiguration(t *testing.T) { t.Fatalf("Load() error = %v", err) } } + +func TestLoadRejectsOversizedPrewarmWindow(t *testing.T) { + t.Setenv("PLEX_URL", "http://plex:32400") + t.Setenv("MEDIAINFO_PREWARM_BEFORE", "25") + t.Setenv("MEDIAINFO_PREWARM_AFTER", "26") + + _, err := Load() + if err == nil || !strings.Contains(err.Error(), "total at most 50") { + t.Fatalf("Load() error = %v", err) + } +} + +func TestLoadAllowsCurrentOnlyPrewarm(t *testing.T) { + t.Setenv("PLEX_URL", "http://plex:32400") + t.Setenv("MEDIAINFO_PREWARM_BEFORE", "0") + t.Setenv("MEDIAINFO_PREWARM_AFTER", "0") + + got, err := Load() + if err != nil { + t.Fatal(err) + } + if got.MediaInfo.PrewarmBefore != 0 || got.MediaInfo.PrewarmAfter != 0 { + t.Fatalf("prewarm window = %d/%d", got.MediaInfo.PrewarmBefore, got.MediaInfo.PrewarmAfter) + } +} + +func TestLoadPlaybackVetoCanBeEnabled(t *testing.T) { + t.Setenv("PLEX_URL", "http://plex:32400") + t.Setenv("PLAYBACK_VETO_ENABLED", "true") + + got, err := Load() + if err != nil { + t.Fatal(err) + } + if !got.PlaybackVeto { + t.Fatal("PlaybackVeto = false") + } +} diff --git a/internal/gateway/decision.go b/internal/gateway/decision.go index 3ca1e20..9ac0b93 100644 --- a/internal/gateway/decision.go +++ b/internal/gateway/decision.go @@ -1,6 +1,7 @@ package gateway import ( + "context" "errors" "io" "log/slog" @@ -11,25 +12,37 @@ import ( "strings" "time" + "github.com/InfinityPacer/plex-gateway/internal/mediainfo" + "github.com/InfinityPacer/plex-gateway/internal/metrics" "github.com/InfinityPacer/plex-gateway/internal/playback" "github.com/InfinityPacer/plex-gateway/internal/plexmeta" ) const ( - defaultDecisionMetadataMaxBytes = 4 << 20 - defaultDecisionGrantTTL = 5 * time.Minute - defaultDecisionGrantLimit = 4096 + defaultDecisionMetadataMaxBytes = 4 << 20 + defaultDecisionGrantTTL = 5 * time.Minute + defaultDecisionGrantLimit = 4096 + defaultDecisionMediaInfoColdWait = 5 * time.Second ) // decisionHandler opts an eligible STRM Part into Plex Direct Play while // leaving Plex responsible for the decision response and session semantics. // Any uncertainty preserves the original request unchanged. type decisionHandler struct { - plex http.Handler - probe *decisionMetadataProbe - service *playback.Service - grants *playback.GrantStore - logger *slog.Logger + plex http.Handler + probe *decisionMetadataProbe + service *playback.Service + mediaInfo decisionMediaInfoService + coldWait time.Duration + grants *playback.GrantStore + veto playbackVeto + logger *slog.Logger + metrics *metrics.Metrics +} + +type decisionMediaInfoService interface { + GetMemory(mediainfo.Key) (mediainfo.Record, bool) + Ensure(context.Context, mediainfo.Request) (mediainfo.Record, error) } func (h *decisionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -46,7 +59,6 @@ func (h *decisionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.plex.ServeHTTP(w, r) return } - h.service.Remember(selection) request := r.Clone(r.Context()) requestURL := *r.URL @@ -59,14 +71,76 @@ func (h *decisionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { capture := newDecisionResponseCapture(w, defaultDecisionMetadataMaxBytes) h.plex.ServeHTTP(capture, request) granted := false - if capture.successful() && plexmeta.IsDirectPlayDecision(capture.body(), capture.Header().Get("Content-Type"), selection.Part) { - granted = h.grants.Put(attempt, selection) + if capture.successful() { + body := capture.body() + if plexmeta.IsDirectPlayDecision(body, capture.Header().Get("Content-Type"), selection.Part) { + if record, found := h.mediaInfoForDecision(r, selection); found { + enriched, changed, err := plexmeta.EnrichDecision(body, capture.Header().Get("Content-Type"), selection.Part, record.Media) + if err != nil { + h.logger.Warn("decision_mediainfo_fail_open", "part", selection.Part.ID, "error_kind", errorKind(err)) + } else if changed { + if h.veto != nil && record.Fresh(time.Now().UTC()) { + if reason, reject := h.veto(r, record.Media); reject { + h.logger.Info("playback_veto", "part", selection.Part.ID, "reason", reason) + writeIncompatibleDecision(w, r) + return + } + } + if err := capture.replaceDecodedBody(enriched); err != nil { + h.logger.Warn("decision_mediainfo_fail_open", "part", selection.Part.ID, "error_kind", errorKind(err)) + } else if h.metrics != nil { + h.metrics.IncMediaInfoEnriched() + } + } + } + granted = h.grants.Put(attempt, selection) + } } if err := capture.commit(); err != nil && granted { h.grants.Delete(attempt) } } +func writeIncompatibleDecision(w http.ResponseWriter, r *http.Request) { + contentType, body := plexmeta.IncompatibleDecision(r.Header.Get("Accept")) + resetDecisionBodyHeaders(w.Header(), len(body)) + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + if r.Method != http.MethodHead { + _, _ = w.Write(body) + } +} + +// mediaInfoForDecision reuses an exact cached STRM identity and admits at most +// one bounded interactive probe on a cold playback. Failure leaves Plex's +// original decision response usable and the shared worker may still warm a +// later request after the client's wait expires. +func (h *decisionHandler) mediaInfoForDecision(request *http.Request, part playback.PreparedPart) (mediainfo.Record, bool) { + if h == nil || h.mediaInfo == nil || request == nil { + return mediainfo.Record{}, false + } + fingerprint, err := mediainfo.FingerprintSTRMTarget(part.Target) + if err != nil { + return mediainfo.Record{}, false + } + key := mediainfo.Key{PartID: part.Part.ID, STRMFingerprint: fingerprint} + if record, found := h.mediaInfo.GetMemory(key); found { + return record, true + } + wait := h.coldWait + if wait <= 0 { + wait = defaultDecisionMediaInfoColdWait + } + ctx, cancel := context.WithTimeout(request.Context(), wait) + defer cancel() + record, err := h.mediaInfo.Ensure(ctx, mediainfo.Request{ + Key: key, RatingKey: part.RatingKey, Target: part.Target, + Priority: mediainfo.PriorityPlayback, ClientUserAgent: request.UserAgent(), + }) + return record, err == nil && record.Media.Complete +} + func (h *decisionHandler) selectCloudPart(r *http.Request, attempt playback.Attempt, query url.Values) (playback.PreparedPart, decisionSelectionState) { if h.probe == nil || h.service == nil { return playback.PreparedPart{}, decisionSelectionUnauthenticated @@ -92,6 +166,7 @@ func (h *decisionHandler) selectCloudPart(r *http.Request, attempt playback.Atte if preparation.State != playback.PreparationReady { return playback.PreparedPart{}, decisionSelectionPassthrough } + preparation.Part.RatingKey = strings.TrimPrefix(attempt.MetadataPath, "/library/metadata/") return preparation.Part, decisionSelectionCloud } diff --git a/internal/gateway/decision_capture.go b/internal/gateway/decision_capture.go index d1c27d6..c9995d6 100644 --- a/internal/gateway/decision_capture.go +++ b/internal/gateway/decision_capture.go @@ -3,8 +3,10 @@ package gateway import ( "bytes" "compress/gzip" + "errors" "io" "net/http" + "strconv" "strings" ) @@ -134,4 +136,42 @@ func (w *decisionResponseCapture) body() []byte { return decoded } +// replaceDecodedBody swaps one complete bounded decision response before it is +// exposed to the client. The replacement is emitted without upstream content +// coding because the response representation has changed. +func (w *decisionResponseCapture) replaceDecodedBody(body []byte) error { + if !w.successful() || w.committed || w.passthrough { + return errors.New("decision response is not replaceable") + } + if int64(len(body)) > w.limit { + return errors.New("enriched decision response exceeds the limit") + } + w.captured.Reset() + _, _ = w.captured.Write(body) + resetDecisionBodyHeaders(w.Header(), len(body)) + return nil +} + +// resetDecisionBodyHeaders removes representation metadata that describes the +// upstream body before installing a replacement. Validators and transfer +// framing are no longer valid once the decision document has changed. +func resetDecisionBodyHeaders(header http.Header, bodyLength int) { + for _, name := range []string{ + "Accept-Ranges", + "Content-Encoding", + "Content-Length", + "Content-MD5", + "Content-Range", + "Digest", + "ETag", + "Last-Modified", + "Trailer", + "Transfer-Encoding", + "Vary", + } { + header.Del(name) + } + header.Set("Content-Length", strconv.Itoa(bodyLength)) +} + var _ interface{ Unwrap() http.ResponseWriter } = (*decisionResponseCapture)(nil) diff --git a/internal/gateway/decision_capture_test.go b/internal/gateway/decision_capture_test.go index c089d55..a0c04fe 100644 --- a/internal/gateway/decision_capture_test.go +++ b/internal/gateway/decision_capture_test.go @@ -6,9 +6,54 @@ import ( "net/http" "net/http/httptest" "net/url" + "strconv" "testing" ) +func TestDecisionCaptureReplacesGzipBodyAndRepresentationHeaders(t *testing.T) { + destination := httptest.NewRecorder() + capture := newDecisionResponseCapture(destination, 1<<20) + capture.Header().Set("Content-Encoding", "gzip") + capture.Header().Set("Content-Length", "1") + for _, name := range []string{ + "Accept-Ranges", "Content-MD5", "Content-Range", "Digest", "ETag", + "Last-Modified", "Trailer", "Transfer-Encoding", "Vary", + } { + capture.Header().Set(name, "stale") + } + capture.Header().Set("Content-Type", "application/xml") + capture.WriteHeader(http.StatusOK) + + var original bytes.Buffer + writer := gzip.NewWriter(&original) + _, _ = writer.Write([]byte("original")) + if err := writer.Close(); err != nil { + t.Fatal(err) + } + _, _ = capture.Write(original.Bytes()) + + if err := capture.replaceDecodedBody([]byte("enriched")); err != nil { + t.Fatal(err) + } + if err := capture.commit(); err != nil { + t.Fatal(err) + } + if destination.Body.String() != "enriched" || destination.Header().Get("Content-Encoding") != "" || destination.Header().Get("Content-Length") != strconv.Itoa(len("enriched")) { + t.Fatalf("headers=%#v body=%q", destination.Header(), destination.Body.String()) + } + if destination.Header().Get("Content-Type") != "application/xml" { + t.Fatalf("Content-Type = %q", destination.Header().Get("Content-Type")) + } + for _, name := range []string{ + "Accept-Ranges", "Content-MD5", "Content-Range", "Digest", "ETag", + "Last-Modified", "Trailer", "Transfer-Encoding", "Vary", + } { + if got := destination.Header().Get(name); got != "" { + t.Errorf("%s = %q, want empty", name, got) + } + } +} + func TestNormalizePlaybackAttemptUsesStableSessionIdentity(t *testing.T) { decision := grantTestRequest("") decision.Header.Set("X-Plex-Playback-Session-Id", "header-session") diff --git a/internal/gateway/decision_test.go b/internal/gateway/decision_test.go index eadee9d..5eb5c23 100644 --- a/internal/gateway/decision_test.go +++ b/internal/gateway/decision_test.go @@ -1,17 +1,424 @@ package gateway import ( + "bytes" + "compress/gzip" + "context" + "io" + "log/slog" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" + "strconv" "strings" "testing" + "time" + "github.com/InfinityPacer/plex-gateway/internal/mediainfo" + "github.com/InfinityPacer/plex-gateway/internal/metrics" + "github.com/InfinityPacer/plex-gateway/internal/partcache" "github.com/InfinityPacer/plex-gateway/internal/pathmap" + "github.com/InfinityPacer/plex-gateway/internal/playback" + "github.com/InfinityPacer/plex-gateway/internal/plexmeta" + "github.com/InfinityPacer/plex-gateway/internal/resolver" ) +type decisionMediaInfoStub struct { + record mediainfo.Record + memoryHit bool + waitForCancel bool + ensureCalls int + memoryCalls int + request mediainfo.Request +} + +func (stub *decisionMediaInfoStub) GetMemory(mediainfo.Key) (mediainfo.Record, bool) { + stub.memoryCalls++ + return stub.record, stub.memoryHit +} + +func (stub *decisionMediaInfoStub) Ensure(ctx context.Context, request mediainfo.Request) (mediainfo.Record, error) { + stub.ensureCalls++ + stub.request = request + if stub.waitForCancel { + <-ctx.Done() + return mediainfo.Record{}, ctx.Err() + } + return stub.record, nil +} + +func TestCloudDecisionProjectsMediaInfoWithoutClientSpecificPolicy(t *testing.T) { + clients := []struct { + name string + product string + platform string + userAgent string + }{ + {name: "Infuse", product: "Infuse-Direct", platform: "iOS", userAgent: "Infuse-Direct/8.5.3"}, + {name: "Plex iOS", product: "Plex for iOS", platform: "iOS", userAgent: "PlexMobile/8.0"}, + {name: "Plex Apple TV", product: "Plex for Apple TV", platform: "tvOS", userAgent: "PlexTV/8.0"}, + } + var projectedBody string + for _, client := range clients { + t.Run(client.name, func(t *testing.T) { + media := completeProjectionMedia() + media.Size = 987654321 + mediaInfo := &decisionMediaInfoStub{record: mediainfo.Record{Media: media}} + handler := newDecisionProjectionHandler(t, mediaInfo, 100*time.Millisecond, nil) + request := decisionProjectionRequest() + request.Header.Set("User-Agent", client.userAgent) + request.Header.Set("X-Plex-Product", client.product) + request.Header.Set("X-Plex-Platform", client.platform) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `decision="directplay"`) || + !strings.Contains(response.Body.String(), `size="987654321"`) || + !strings.Contains(response.Body.String(), `container="mkv"`) || + !strings.Contains(response.Body.String(), ` time.Second { + t.Fatalf("decision fail-open took %s", elapsed) + } + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `size="301"`) || strings.Contains(response.Body.String(), `container="mkv"`) { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + if mediaInfo.ensureCalls != 1 { + t.Fatalf("Ensure calls = %d", mediaInfo.ensureCalls) + } +} + +func TestWriteIncompatibleDecisionClearsRepresentationHeadersAndHonorsHEAD(t *testing.T) { + for _, test := range []struct { + name string + method string + accept string + }{ + {name: "XML GET", method: http.MethodGet, accept: "application/xml"}, + {name: "JSON GET", method: http.MethodGet, accept: "application/json"}, + {name: "XML HEAD", method: http.MethodHead, accept: "application/xml"}, + {name: "JSON HEAD", method: http.MethodHead, accept: "application/json"}, + } { + t.Run(test.name, func(t *testing.T) { + response := httptest.NewRecorder() + for _, name := range []string{ + "Accept-Ranges", "Content-Encoding", "Content-MD5", "Content-Range", "Digest", + "ETag", "Last-Modified", "Trailer", "Transfer-Encoding", "Vary", + } { + response.Header().Set(name, "stale") + } + request := httptest.NewRequest(test.method, "/video/:/transcode/universal/decision", nil) + request.Header.Set("Accept", test.accept) + + writeIncompatibleDecision(response, request) + + contentType, body := plexmeta.IncompatibleDecision(test.accept) + if response.Code != http.StatusOK || response.Header().Get("Content-Type") != contentType || + response.Header().Get("Content-Length") != strconv.Itoa(len(body)) || response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("status=%d headers=%#v", response.Code, response.Header()) + } + wantBody := string(body) + if test.method == http.MethodHead { + wantBody = "" + } + if response.Body.String() != wantBody { + t.Fatalf("body=%q want=%q", response.Body.String(), wantBody) + } + for _, name := range []string{ + "Accept-Ranges", "Content-Encoding", "Content-MD5", "Content-Range", "Digest", + "ETag", "Last-Modified", "Trailer", "Transfer-Encoding", "Vary", + } { + if got := response.Header().Get(name); got != "" { + t.Errorf("%s = %q, want empty", name, got) + } + } + }) + } +} + +func TestCloudDecisionJSONProjectionClearsRepresentationHeaders(t *testing.T) { + media := completeProjectionMedia() + mediaInfo := &decisionMediaInfoStub{record: freshDecisionRecord(media), memoryHit: true} + handler := newDecisionProjectionHandler(t, mediaInfo, 100*time.Millisecond, nil).(*decisionHandler) + handler.plex = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + for _, name := range []string{ + "Accept-Ranges", "Content-MD5", "Content-Range", "Digest", "ETag", "Last-Modified", + "Trailer", "Transfer-Encoding", "Vary", + } { + w.Header().Set(name, "stale") + } + w.Header().Set("Content-Length", "1") + _, _ = w.Write([]byte(`{"MediaContainer":{"Metadata":[{"Media":[{"decision":"directplay","Part":[{"id":9,"key":"/library/parts/9/1/file","size":301,"decision":"directplay"}]}]}]}}`)) + }) + response := httptest.NewRecorder() + handler.ServeHTTP(response, decisionProjectionRequest()) + + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"container":"mkv"`) { + t.Fatalf("status=%d headers=%#v body=%s", response.Code, response.Header(), response.Body.String()) + } + if response.Header().Get("Content-Length") != strconv.Itoa(response.Body.Len()) || response.Header().Get("Content-Type") != "application/json" { + t.Fatalf("body headers=%#v body_len=%d", response.Header(), response.Body.Len()) + } + for _, name := range []string{ + "Accept-Ranges", "Content-Encoding", "Content-MD5", "Content-Range", "Digest", "ETag", + "Last-Modified", "Trailer", "Transfer-Encoding", "Vary", + } { + if got := response.Header().Get(name); got != "" { + t.Errorf("%s = %q, want empty", name, got) + } + } +} + +func TestCloudDecisionVetoReplacesGzipResponseWithIdentityBody(t *testing.T) { + media := completeProjectionMedia() + media.Streams[0].HDRFormat = "dolby_vision" + media.Streams[0].DolbyVision = &mediainfo.DolbyVision{Profile: 5, BLCompatID: 0} + mediaInfo := &decisionMediaInfoStub{record: freshDecisionRecord(media), memoryHit: true} + handler := newDecisionProjectionHandler(t, mediaInfo, 100*time.Millisecond, newPlaybackVeto(true)).(*decisionHandler) + handler.plex = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/xml") + w.Header().Set("Content-Encoding", "gzip") + for _, name := range []string{ + "Accept-Ranges", "Content-MD5", "Content-Range", "Digest", "ETag", "Last-Modified", + "Trailer", "Transfer-Encoding", "Vary", + } { + w.Header().Set(name, "stale") + } + var compressed bytes.Buffer + writer := gzip.NewWriter(&compressed) + _, _ = writer.Write([]byte(``)) + if err := writer.Close(); err != nil { + t.Fatal(err) + } + w.Header().Set("Content-Length", strconv.Itoa(compressed.Len())) + _, _ = w.Write(compressed.Bytes()) + }) + request := decisionProjectionRequest() + request.Header.Set("Accept", "application/xml") + request.Header.Set("X-Plex-Product", "Plex for Apple TV") + request.Header.Set("X-Plex-Platform", "tvOS") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `transcodeDecisionCode="4005"`) || response.Header().Get("Content-Encoding") != "" { + t.Fatalf("status=%d headers=%#v body=%s", response.Code, response.Header(), response.Body.String()) + } + if response.Header().Get("Content-Length") != strconv.Itoa(response.Body.Len()) { + t.Fatalf("Content-Length=%q body_len=%d", response.Header().Get("Content-Length"), response.Body.Len()) + } + for _, name := range []string{ + "Accept-Ranges", "Content-MD5", "Content-Range", "Digest", "ETag", "Last-Modified", + "Trailer", "Transfer-Encoding", "Vary", + } { + if got := response.Header().Get(name); got != "" { + t.Errorf("%s = %q, want empty", name, got) + } + } +} + +func TestCloudDecisionOptionalVetoUsesExistingFreshMediaInfo(t *testing.T) { + media := completeProjectionMedia() + media.Streams[0].HDRFormat = "dolby_vision" + media.Streams[0].DolbyVision = &mediainfo.DolbyVision{Profile: 5, BLCompatID: 0} + mediaInfo := &decisionMediaInfoStub{record: freshDecisionRecord(media), memoryHit: true} + handler := newDecisionProjectionHandler(t, mediaInfo, 100*time.Millisecond, newPlaybackVeto(true)) + request := decisionProjectionRequest() + request.Header.Set("X-Plex-Product", "Plex for Apple TV") + request.Header.Set("X-Plex-Platform", "tvOS") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `transcodeDecisionCode="4005"`) || strings.Contains(response.Body.String(), `decision="directplay"`) { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + if mediaInfo.memoryCalls != 1 || mediaInfo.ensureCalls != 0 { + t.Fatalf("GetMemory calls=%d Ensure calls=%d", mediaInfo.memoryCalls, mediaInfo.ensureCalls) + } +} + +func TestCloudDecisionDisabledVetoPreservesDV5WithSameMediaInfoIO(t *testing.T) { + media := completeProjectionMedia() + media.Streams[0].HDRFormat = "dolby_vision" + media.Streams[0].DolbyVision = &mediainfo.DolbyVision{Profile: 5, BLCompatID: 0} + mediaInfo := &decisionMediaInfoStub{record: freshDecisionRecord(media), memoryHit: true} + handler := newDecisionProjectionHandler(t, mediaInfo, 100*time.Millisecond, nil) + request := decisionProjectionRequest() + request.Header.Set("X-Plex-Product", "Plex for Apple TV") + request.Header.Set("X-Plex-Platform", "tvOS") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `decision="directplay"`) { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + if mediaInfo.memoryCalls != 1 || mediaInfo.ensureCalls != 0 { + t.Fatalf("GetMemory calls=%d Ensure calls=%d", mediaInfo.memoryCalls, mediaInfo.ensureCalls) + } +} + +func TestCloudDecisionOptionalVetoAbstainsOnStaleMediaInfo(t *testing.T) { + media := completeProjectionMedia() + media.Streams[0].HDRFormat = "dolby_vision" + media.Streams[0].DolbyVision = &mediainfo.DolbyVision{Profile: 5, BLCompatID: 0} + now := time.Now().UTC() + record := freshDecisionRecord(media) + record.ProbedAt = now.Add(-2 * time.Hour) + record.ExpiresAt = now.Add(-time.Hour) + mediaInfo := &decisionMediaInfoStub{record: record, memoryHit: true} + handler := newDecisionProjectionHandler(t, mediaInfo, 100*time.Millisecond, newPlaybackVeto(true)) + request := decisionProjectionRequest() + request.Header.Set("X-Plex-Product", "Plex for Apple TV") + request.Header.Set("X-Plex-Platform", "tvOS") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `decision="directplay"`) { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } +} + +func TestCloudDecisionVetoDoesNotOverrideCompletePlexDecision(t *testing.T) { + media := completeProjectionMedia() + media.Streams[0].HDRFormat = "dolby_vision" + media.Streams[0].DolbyVision = &mediainfo.DolbyVision{Profile: 5, BLCompatID: 0} + mediaInfo := &decisionMediaInfoStub{record: freshDecisionRecord(media), memoryHit: true} + handler := newDecisionProjectionHandler(t, mediaInfo, 100*time.Millisecond, newPlaybackVeto(true)).(*decisionHandler) + base := []byte(``) + complete, changed, err := plexmeta.EnrichDecision(base, "application/xml", plexmeta.Part{ID: "9", Key: "/library/parts/9/1/file"}, media) + if err != nil || !changed { + t.Fatalf("prepare complete decision changed=%v err=%v", changed, err) + } + handler.plex = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write(complete) + }) + request := decisionProjectionRequest() + request.Header.Set("X-Plex-Product", "Plex for Apple TV") + request.Header.Set("X-Plex-Platform", "tvOS") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `decision="directplay"`) || strings.Contains(response.Body.String(), `transcodeDecisionCode="4005"`) { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } +} + +func freshDecisionRecord(media mediainfo.Media) mediainfo.Record { + now := time.Now().UTC() + return mediainfo.Record{ + Key: mediainfo.Key{PlexServerID: "plex", PartID: "9", STRMFingerprint: strings.Repeat("a", 64)}, + Provider: mediainfo.ProviderMediaVaultFFProbe, ProviderRevision: mediainfo.ProviderRevisionFFProbeJSONV3, + SchemaVersion: mediainfo.SchemaVersion, Media: media, + ProbedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour), + LastAccessedAt: now, RetainUntil: now.Add(24 * time.Hour), + } +} + +func newDecisionProjectionHandler( + t *testing.T, + mediaInfo decisionMediaInfoService, + coldWait time.Duration, + veto playbackVeto, +) http.Handler { + t.Helper() + localRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(localRoot, "episode.strm"), []byte("http://mediavault.invalid/redirect/pick/episode.mkv\n"), 0o600); err != nil { + t.Fatal(err) + } + mapper, err := pathmap.New([]pathmap.Mapping{{PlexPrefix: "/media/cloud", LocalPrefix: localRoot}}) + if err != nil { + t.Fatal(err) + } + controlResolver, err := resolver.NewMediaVaultSTRMResolver("http://mediavault.invalid", nil, 0) + if err != nil { + t.Fatal(err) + } + metadataServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/library/metadata/42" { + http.NotFound(w, request) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"MediaContainer":{"Metadata":[{"Media":[{"Part":[{"id":9,"key":"/library/parts/9/1/file","file":"/media/cloud/episode.strm"}]}]}]}}`)) + })) + t.Cleanup(metadataServer.Close) + upstream, err := url.Parse(metadataServer.URL) + if err != nil { + t.Fatal(err) + } + cloudPlayback := playback.New(playback.Options{ + Cache: partcache.New(time.Hour), Mapper: mapper, Resolver: controlResolver, + AuthorizePart: func(*http.Request, string, string) (bool, error) { return true, nil }, + CloudExtensions: []string{".strm"}, + }) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + return &decisionHandler{ + plex: http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/video/:/transcode/universal/decision" || request.URL.Query().Get("directPlay") != "1" { + http.NotFound(w, request) + return + } + w.Header().Set("Content-Type", "application/xml") + w.Header().Set("Content-Length", "1") + w.Header().Set("ETag", `"stale"`) + _, _ = w.Write([]byte(``)) + }), + probe: &decisionMetadataProbe{ + upstream: upstream, + client: &http.Client{Timeout: time.Second}, + maxBytes: 1 << 20, + }, + service: cloudPlayback, mediaInfo: mediaInfo, coldWait: coldWait, veto: veto, + grants: playback.NewGrantStore(time.Minute, 16), logger: logger, metrics: metrics.New(), + } +} + +func decisionProjectionRequest() *http.Request { + query := url.Values{ + "path": {"/library/metadata/42"}, + "mediaIndex": {"0"}, + "partIndex": {"0"}, + "directPlay": {"0"}, + "directStream": {"0"}, + "X-Plex-Playback-Session-Id": {"decision-projection-session"}, + } + request := httptest.NewRequest(http.MethodGet, "/video/:/transcode/universal/decision?"+query.Encode(), nil) + request.Header.Set("X-Plex-Token", "client-token") + return request +} + func TestCloudDecisionForcesDirectPlayAndContinuesThroughPartRedirect(t *testing.T) { localRoot := t.TempDir() if err := os.WriteFile(filepath.Join(localRoot, "D.strm"), []byte("http://public.invalid/redirect/pickcode/D.mkv\n"), 0o600); err != nil { diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 3490f85..b3a0e90 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -18,6 +18,8 @@ import ( "github.com/InfinityPacer/plex-gateway/internal/partcache" "github.com/InfinityPacer/plex-gateway/internal/pathmap" "github.com/InfinityPacer/plex-gateway/internal/playback" + "github.com/InfinityPacer/plex-gateway/internal/plexmeta" + "github.com/InfinityPacer/plex-gateway/internal/prewarm" "github.com/InfinityPacer/plex-gateway/internal/resolver" "github.com/InfinityPacer/plex-gateway/internal/trace" ) @@ -37,13 +39,22 @@ type Options struct { CloudExtensions []string ObserveMaxBytes int64 PartProbeTimeout time.Duration + MetadataAnalysisFilter bool MetadataGuard MetadataGuardOptions + MetadataCoalesce MetadataCoalesceOptions MediaInfoEnabled bool MediaInfoStatus func() mediainfo.Status MediaInfoService *mediainfo.Service MediaInfoColdWait time.Duration MediaInfoResponseMaxBytes int64 MediaInfoEnrichmentConcurrency int + MediaInfoPrewarmer cloudRedirectPrewarmer + MediaInfoPrewarmStatus func() prewarm.Status + PlaybackVeto bool +} + +type cloudRedirectPrewarmer interface { + TryEnqueue(prewarm.PlaybackContext) bool } // New builds the fail-open Plex proxy and optional Direct Play interceptor. @@ -64,6 +75,13 @@ func New(options Options) http.Handler { } var cloudPlayback *playback.Service + rememberParts := func(ratingKey string, parts []plexmeta.Part) { + for _, part := range parts { + if cloudPlayback != nil { + cloudPlayback.Remember(playback.PreparedPart{Part: part, RatingKey: ratingKey}) + } + } + } transport := http.RoundTripper(newTransport()) if options.PartCache != nil { transport = observe.NewRoundTripper(transport, observe.Config{ @@ -74,12 +92,9 @@ func New(options Options) http.Handler { "/playQueues", }, MaxBodyBytes: options.ObserveMaxBytes, + SkipRequest: isMetadataCoalesceUpstreamRequest, OnParts: func(observation observe.Observation) { - for _, part := range observation.Parts { - if cloudPlayback != nil { - cloudPlayback.Remember(playback.PreparedPart{Part: part}) - } - } + rememberParts(observedRatingKey(observation.Request), observation.Parts) }, }) } @@ -126,16 +141,21 @@ func New(options Options) http.Handler { CloudExtensions: options.CloudExtensions, }) partPlayback := &playbackHandler{ - service: cloudPlayback, - plex: plex, - logger: logger, - metrics: registry, + service: cloudPlayback, + plex: plex, + logger: logger, + metrics: registry, + prewarmer: options.MediaInfoPrewarmer, } decision := &decisionHandler{ - plex: plex, - service: cloudPlayback, - logger: logger, - grants: playback.NewGrantStore(defaultDecisionGrantTTL, defaultDecisionGrantLimit), + plex: plex, + service: cloudPlayback, + mediaInfo: options.MediaInfoService, + coldWait: options.MediaInfoColdWait, + logger: logger, + metrics: registry, + grants: playback.NewGrantStore(defaultDecisionGrantTTL, defaultDecisionGrantLimit), + veto: newPlaybackVeto(options.PlaybackVeto), probe: &decisionMetadataProbe{ upstream: options.Upstream, client: &http.Client{ @@ -153,9 +173,13 @@ func New(options Options) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { status := mediainfo.Status{} + prewarmStatus := prewarm.Status{} if options.MediaInfoStatus != nil { status = options.MediaInfoStatus() } + if options.MediaInfoPrewarmStatus != nil { + prewarmStatus = options.MediaInfoPrewarmStatus() + } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(struct { @@ -163,13 +187,15 @@ func New(options Options) http.Handler { MediaInfo struct { Enabled bool `json:"enabled"` mediainfo.Status + Prewarm prewarm.Status `json:"prewarm"` } `json:"mediainfo"` }{ Status: "ok", MediaInfo: struct { Enabled bool `json:"enabled"` mediainfo.Status - }{Enabled: options.MediaInfoEnabled, Status: status}, + Prewarm prewarm.Status `json:"prewarm"` + }{Enabled: options.MediaInfoEnabled, Status: status, Prewarm: prewarmStatus}, }) }) mux.Handle("GET /metrics", registry.Handler()) @@ -185,13 +211,23 @@ func New(options Options) http.Handler { } mux.Handle("GET /library/parts/{partID}/{rest...}", partPlayback) mux.Handle("HEAD /library/parts/{partID}/{rest...}", partPlayback) - metadata := newMetadataGuard(options.MetadataGuard, plex, registry, logger) - metadata = newMetadataEnrichmentHandler(metadataEnrichmentOptions{ + metadataCoalesce := options.MetadataCoalesce + metadataCoalesce.OnMetadata = func(request *http.Request, body []byte, contentType string) { + parts, err := plexmeta.ParseParts(body, contentType) + if err != nil { + return + } + rememberParts(observedRatingKey(request), parts) + } + metadataSource := newMetadataGuard(options.MetadataGuard, plex, registry, logger) + metadataSource = newMetadataCoalescer(metadataCoalesce, metadataSource, registry, logger) + metadata := newMetadataEnrichmentHandler(metadataEnrichmentOptions{ Service: options.MediaInfoService, Mapper: options.PathMapper, Resolver: options.Resolver, - CloudExtensions: options.CloudExtensions, ColdWait: options.MediaInfoColdWait, - ResponseLimit: options.MediaInfoResponseMaxBytes, Concurrency: options.MediaInfoEnrichmentConcurrency, + CloudExtensions: options.CloudExtensions, + ResponseLimit: options.MediaInfoResponseMaxBytes, Concurrency: options.MediaInfoEnrichmentConcurrency, Metrics: registry, - }, metadata) + }, metadataSource) + metadata = newMetadataAnalysisFilter(options.MetadataAnalysisFilter, metadata, registry) mux.Handle("/", metadata) withActiveRequests := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -203,10 +239,11 @@ func New(options Options) http.Handler { } type playbackHandler struct { - service *playback.Service - plex http.Handler - logger *slog.Logger - metrics *metrics.Metrics + service *playback.Service + plex http.Handler + logger *slog.Logger + metrics *metrics.Metrics + prewarmer cloudRedirectPrewarmer } func (h *playbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -279,7 +316,7 @@ func (h *playbackHandler) servePrepared( return } h.metrics.ObserveResolverLatency(result.ResolverLatency) - h.redirect(w, part.Part.ID, result.DirectURL, started, source) + h.redirect(w, r, part, result.DirectURL, started, source) } // requestCanceled separates downstream disconnects from failures in Plex or @@ -297,15 +334,24 @@ func requestFinished(r *http.Request) bool { return r != nil && r.Context().Err() != nil } -func (h *playbackHandler) redirect(w http.ResponseWriter, partID string, directURL resolver.DirectURL, started time.Time, source string) { +func (h *playbackHandler) redirect(w http.ResponseWriter, request *http.Request, part playback.PreparedPart, directURL resolver.DirectURL, started time.Time, source string) { latency := time.Since(started) h.metrics.IncRedirectSuccess() h.metrics.ObserveRedirectLatency(latency) w.Header().Set("Location", directURL.String()) w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusFound) + if h.prewarmer != nil { + h.prewarmer.TryEnqueue(prewarm.PlaybackContext{ + RatingKey: part.RatingKey, PartID: part.Part.ID, Target: part.Target, + WindowKey: playbackWindowKey(request), + PlayQueueID: playbackRequestValue(request, "playQueueID"), + PlayQueueItemID: playbackRequestValue(request, "playQueueItemID"), + UserAgent: request.UserAgent(), + }) + } h.logger.Info("play", - "part", partID, + "part", part.Part.ID, "source", source, "resolver", "mediavault", "result", http.StatusFound, @@ -313,6 +359,45 @@ func (h *playbackHandler) redirect(w http.ResponseWriter, partID string, directU ) } +func observedRatingKey(request *http.Request) string { + if request == nil || request.URL == nil || !isMetadataItemPath(request.URL.Path) { + return "" + } + return strings.TrimPrefix(request.URL.Path, "/library/metadata/") +} + +func playbackRequestValue(request *http.Request, name string) string { + if request == nil || request.URL == nil { + return "" + } + value, ok := singleQueryValue(request.URL.Query(), name) + if !ok { + return "" + } + return value +} + +// playbackWindowKey chooses a stable non-credential client identity so rapid +// item changes replace only that client's speculative window. Ambiguous values +// are ignored and the coordinator assigns an isolated anonymous window. +func playbackWindowKey(request *http.Request) string { + if request == nil || request.URL == nil { + return "" + } + for _, name := range []string{ + "X-Plex-Client-Identifier", + "X-Plex-Session-Identifier", + "X-Plex-Session-Id", + "X-Plex-Playback-Session-Id", + } { + value, present, valid := requestIdentity(request, name) + if valid && present { + return name + "\x00" + value + } + } + return "" +} + func (h *playbackHandler) fallback(w http.ResponseWriter, r *http.Request, partID, reason string) { h.metrics.IncPlexFallback() h.logger.Info("play_fallback", "part", partID, "reason", reason) diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 9c03a04..650ffdf 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -19,6 +19,7 @@ import ( "github.com/InfinityPacer/plex-gateway/internal/metrics" "github.com/InfinityPacer/plex-gateway/internal/partcache" "github.com/InfinityPacer/plex-gateway/internal/pathmap" + "github.com/InfinityPacer/plex-gateway/internal/prewarm" "github.com/InfinityPacer/plex-gateway/internal/resolver" "github.com/InfinityPacer/plex-gateway/internal/trace" ) @@ -66,6 +67,43 @@ func TestTransparentProxyPreservesRequestAndResponse(t *testing.T) { } } +func TestMetadataAnalysisFilterIsAppliedBeforePlexProxy(t *testing.T) { + registry := metrics.New() + upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if got := request.URL.RawQuery; got != "checkFiles=1&X-Plex-Token=a%2Bb&includeMarkers=1" { + t.Fatalf("upstream query = %q", got) + } + if got := request.Header.Get("X-Plex-Client-Identifier"); got != "client-id" { + t.Fatalf("client identifier = %q", got) + } + writer.WriteHeader(http.StatusNoContent) + })) + defer upstream.Close() + upstreamURL, err := url.Parse(upstream.URL) + if err != nil { + t.Fatal(err) + } + handler := New(Options{ + Upstream: upstreamURL, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + Tracer: trace.New(false, nil), + Metrics: registry, + MetadataAnalysisFilter: true, + }) + request := httptest.NewRequest(http.MethodGet, "/library/metadata/42?checkFiles=1&X-Plex-Token=a%2Bb&asyncAugmentMetadata=1&includeMarkers=1", nil) + request.Header.Set("X-Plex-Client-Identifier", "client-id") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent) + } + if got := registry.Snapshot().MetadataAnalysisParamsRemovedTotal; got != 1 { + t.Fatalf("removed total = %d, want 1", got) + } +} + func TestHealthDoesNotReachPlex(t *testing.T) { reached := false upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { @@ -143,12 +181,15 @@ func TestMetadataObservationEnablesCloudPartRedirect(t *testing.T) { })) defer plex.Close() - handler, registry, _ := newCloudHandler(t, plex.URL, mediaVault.URL, []pathmap.Mapping{{PlexPrefix: plexRoot, LocalPrefix: localRoot}}) + handler, registry, cache := newCloudHandler(t, plex.URL, mediaVault.URL, []pathmap.Mapping{{PlexPrefix: plexRoot, LocalPrefix: localRoot}}) metadata := httptest.NewRecorder() handler.ServeHTTP(metadata, httptest.NewRequest(http.MethodGet, "/library/metadata/42", nil)) if metadata.Code != http.StatusOK { t.Fatalf("metadata status = %d", metadata.Code) } + if observed, ok := cache.Get("123"); !ok || observed.RatingKey != "42" { + t.Fatalf("observed Part = %#v, found = %v", observed, ok) + } playback := httptest.NewRecorder() playbackRequest := httptest.NewRequest(http.MethodGet, "/library/parts/123/7/file", nil) @@ -176,6 +217,63 @@ func TestMetadataObservationEnablesCloudPartRedirect(t *testing.T) { } } +func TestSuccessfulCloudRedirectEnqueuesCredentialFreePlaybackContext(t *testing.T) { + localRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(localRoot, "Episode.strm"), []byte("http://public.invalid/redirect/pickcode\n"), 0o600); err != nil { + t.Fatal(err) + } + plex := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "http://public.invalid/redirect/pickcode") + w.WriteHeader(http.StatusMovedPermanently) + })) + defer plex.Close() + mediaVault := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "https://cdn.invalid/Episode.mkv") + w.WriteHeader(http.StatusFound) + })) + defer mediaVault.Close() + upstream, _ := url.Parse(plex.URL) + mapper, err := pathmap.New([]pathmap.Mapping{{PlexPrefix: "/media/cloud", LocalPrefix: localRoot}}) + if err != nil { + t.Fatal(err) + } + control, err := resolver.NewMediaVaultSTRMResolver(mediaVault.URL, nil, 0) + if err != nil { + t.Fatal(err) + } + cache := partcache.New(time.Hour) + cache.Put(partcache.PartInfo{ + PartID: "123", RatingKey: "42", PlexFilePath: "/media/cloud/Episode.strm", + PartKey: "/library/parts/123/7/file", + }) + events := make(chan prewarm.PlaybackContext, 1) + handler := New(Options{ + Upstream: upstream, PartCache: cache, PathMapper: mapper, Resolver: control, + CloudExtensions: []string{".strm"}, MediaInfoPrewarmer: recordingPrewarmer{events: events}, + }) + request := httptest.NewRequest(http.MethodGet, "/library/parts/123/7/file?playQueueID=5&playQueueItemID=99&X-Plex-Token=client-secret", nil) + request.Header.Set("User-Agent", "Client-Test/1.0") + request.Header.Set("X-Plex-Client-Identifier", "client-device") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusFound { + t.Fatalf("status = %d", response.Code) + } + select { + case event := <-events: + want := prewarm.PlaybackContext{ + RatingKey: "42", PartID: "123", Target: "http://public.invalid/redirect/pickcode", + WindowKey: "X-Plex-Client-Identifier\x00client-device", + PlayQueueID: "5", PlayQueueItemID: "99", UserAgent: "Client-Test/1.0", + } + if event != want { + t.Fatalf("event = %#v, want %#v", event, want) + } + case <-time.After(time.Second): + t.Fatal("prewarm event was not enqueued") + } +} + func TestAuthorizedPlexSTRMRedirectEnablesCloudPartRedirect(t *testing.T) { localRoot := t.TempDir() if err := os.WriteFile(filepath.Join(localRoot, "Movie.strm"), []byte("http://public.invalid/redirect/pickcode\n"), 0o600); err != nil { @@ -220,6 +318,52 @@ func TestAuthorizedPlexSTRMRedirectEnablesCloudPartRedirect(t *testing.T) { } } +func TestCloudPartRedirectDoesNotApplyPlaybackVeto(t *testing.T) { + localRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(localRoot, "Movie.strm"), []byte("http://public.invalid/redirect/pickcode\n"), 0o600); err != nil { + t.Fatal(err) + } + + mediaVault := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "https://cdn.invalid/Movie.mkv?signature=private") + w.WriteHeader(http.StatusFound) + })) + defer mediaVault.Close() + plex := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "http://public.invalid/redirect/pickcode") + w.WriteHeader(http.StatusMovedPermanently) + })) + defer plex.Close() + + handler, _, cache := newCloudHandlerWithVeto(t, plex.URL, mediaVault.URL, []pathmap.Mapping{{ + PlexPrefix: "/media/cloud", LocalPrefix: localRoot, + }}, true) + cache.Put(partcache.PartInfo{PartID: "123", PlexFilePath: "/media/cloud/Movie.strm"}) + clients := []struct { + product string + platform string + userAgent string + }{ + {product: "Infuse-Library", platform: "iOS", userAgent: "Infuse-Library/8.5.1"}, + {product: "Plex for iOS", platform: "iOS", userAgent: "PlexMobile/8.0"}, + {product: "Plex for Apple TV", platform: "tvOS", userAgent: "PlexTV/8.45"}, + } + for _, client := range clients { + request := httptest.NewRequest(http.MethodGet, "/library/parts/123/7/file", nil) + request.Header.Set("X-Plex-Token", "valid-token") + request.Header.Set("X-Plex-Product", client.product) + request.Header.Set("X-Plex-Platform", client.platform) + request.Header.Set("User-Agent", client.userAgent) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusFound || response.Header().Get("Location") != "https://cdn.invalid/Movie.mkv?signature=private" { + t.Fatalf("client %q status=%d Location=%q", client.product, response.Code, response.Header().Get("Location")) + } + } +} + func TestCloudPartHeadUsesMediaVaultGet(t *testing.T) { localRoot := t.TempDir() const controlTarget = "http://public.invalid/redirect/pickcode/Movie.mkv" @@ -851,6 +995,10 @@ func TestMetricsEndpointIsLocalAndStable(t *testing.T) { } func newCloudHandler(t *testing.T, plexURL, mediaVaultURL string, mappings []pathmap.Mapping) (http.Handler, *metrics.Metrics, *partcache.Cache) { + return newCloudHandlerWithVeto(t, plexURL, mediaVaultURL, mappings, false) +} + +func newCloudHandlerWithVeto(t *testing.T, plexURL, mediaVaultURL string, mappings []pathmap.Mapping, playbackVeto bool) (http.Handler, *metrics.Metrics, *partcache.Cache) { t.Helper() upstream, err := url.Parse(plexURL) if err != nil { @@ -876,6 +1024,7 @@ func newCloudHandler(t *testing.T, plexURL, mediaVaultURL string, mappings []pat Resolver: strmResolver, Metrics: registry, CloudExtensions: []string{".strm"}, + PlaybackVeto: playbackVeto, }), registry, cache } @@ -883,6 +1032,15 @@ type cancelingResolver struct { cancel context.CancelFunc } +type recordingPrewarmer struct { + events chan<- prewarm.PlaybackContext +} + +func (prewarmer recordingPrewarmer) TryEnqueue(event prewarm.PlaybackContext) bool { + prewarmer.events <- event + return true +} + func (r cancelingResolver) ReadTarget(string) (string, error) { return "http://public.invalid/redirect/pickcode", nil } diff --git a/internal/gateway/metadata_analysis_filter.go b/internal/gateway/metadata_analysis_filter.go new file mode 100644 index 0000000..0ad192e --- /dev/null +++ b/internal/gateway/metadata_analysis_filter.go @@ -0,0 +1,71 @@ +package gateway + +import ( + "net/http" + "net/url" + "strings" + + "github.com/InfinityPacer/plex-gateway/internal/metrics" +) + +var metadataAnalysisParameters = map[string]struct{}{ + "asyncAugmentMetadata": {}, +} + +// newMetadataAnalysisFilter prevents detailed metadata reads from scheduling +// asynchronous Plex analysis. Synchronous file accessibility fields remain +// Plex-owned and are therefore preserved. +func newMetadataAnalysisFilter(enabled bool, next http.Handler, registry *metrics.Metrics) http.Handler { + if !enabled { + return next + } + if registry == nil { + registry = metrics.New() + } + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if classifyMetadataRequest(request) == metadataRequestNone || request.URL.RawQuery == "" { + next.ServeHTTP(writer, request) + return + } + + rawQuery, changed := removeRawQueryParameters(request.URL.RawQuery, metadataAnalysisParameters) + if !changed { + next.ServeHTTP(writer, request) + return + } + + filtered := request.Clone(request.Context()) + requestURL := *request.URL + requestURL.RawQuery = rawQuery + filtered.URL = &requestURL + registry.IncMetadataAnalysisParamsRemoved() + next.ServeHTTP(writer, filtered) + }) +} + +// removeRawQueryParameters preserves every retained query segment byte for +// byte. Plex clients use several equivalent escaping forms, and filtering two +// control parameters must not normalize credentials or client context. +func removeRawQueryParameters(rawQuery string, blocked map[string]struct{}) (string, bool) { + segments := strings.Split(rawQuery, "&") + kept := make([]string, 0, len(segments)) + changed := false + for _, segment := range segments { + key := segment + if separator := strings.IndexByte(key, '='); separator >= 0 { + key = key[:separator] + } + decoded, err := url.QueryUnescape(key) + if err == nil { + if _, remove := blocked[decoded]; remove { + changed = true + continue + } + } + kept = append(kept, segment) + } + if !changed { + return rawQuery, false + } + return strings.Join(kept, "&"), true +} diff --git a/internal/gateway/metadata_analysis_filter_test.go b/internal/gateway/metadata_analysis_filter_test.go new file mode 100644 index 0000000..5291fce --- /dev/null +++ b/internal/gateway/metadata_analysis_filter_test.go @@ -0,0 +1,113 @@ +package gateway + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/InfinityPacer/plex-gateway/internal/metrics" +) + +func TestMetadataAnalysisFilterRemovesOnlyAnalysisParameters(t *testing.T) { + registry := metrics.New() + var received string + handler := newMetadataAnalysisFilter(true, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + received = request.URL.RawQuery + writer.WriteHeader(http.StatusNoContent) + }), registry) + originalQuery := "z=last&checkFiles=1&X-Plex-Token=a%2Bb&asyncAugmentMetadata=1&a=first+value" + request := httptest.NewRequest(http.MethodGet, "/library/metadata/42?"+originalQuery, nil) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent) + } + if received != "z=last&checkFiles=1&X-Plex-Token=a%2Bb&a=first+value" { + t.Fatalf("filtered query = %q", received) + } + if request.URL.RawQuery != originalQuery { + t.Fatalf("original request query mutated to %q", request.URL.RawQuery) + } + if got := registry.Snapshot().MetadataAnalysisParamsRemovedTotal; got != 1 { + t.Fatalf("removed total = %d, want 1", got) + } +} + +func TestMetadataAnalysisFilterAppliesToSupportedReads(t *testing.T) { + tests := []struct { + name string + method string + path string + want string + }{ + {name: "head", method: http.MethodHead, path: "/library/metadata/42?asyncAugmentMetadata=1&checkFiles=1&keep=yes", want: "checkFiles=1&keep=yes"}, + {name: "batch", method: http.MethodGet, path: "/library/metadata/42,43?asyncAugmentMetadata&keep=yes", want: "keep=yes"}, + {name: "children", method: http.MethodGet, path: "/library/metadata/42/children?asyncAugmentMetadata=1", want: "asyncAugmentMetadata=1"}, + {name: "write", method: http.MethodPut, path: "/library/metadata/42?asyncAugmentMetadata=1", want: "asyncAugmentMetadata=1"}, + {name: "non_numeric", method: http.MethodGet, path: "/library/metadata/abc?asyncAugmentMetadata=1", want: "asyncAugmentMetadata=1"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var received string + handler := newMetadataAnalysisFilter(true, http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { + received = request.URL.RawQuery + }), metrics.New()) + + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(test.method, test.path, nil)) + + if received != test.want { + t.Fatalf("query = %q, want %q", received, test.want) + } + }) + } +} + +func TestMetadataAnalysisFilterPreservesMalformedAndDisabledQueries(t *testing.T) { + tests := []struct { + name string + enabled bool + query string + }{ + {name: "disabled", enabled: false, query: "asyncAugmentMetadata=1&keep=yes"}, + {name: "malformed_key", enabled: true, query: "asyncAugmentMetadata%ZZ=1&keep=yes"}, + {name: "unrelated", enabled: true, query: "asyncAugmentMetadataExtra=1&keep=yes"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var received string + handler := newMetadataAnalysisFilter(test.enabled, http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { + received = request.URL.RawQuery + }), metrics.New()) + + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/library/metadata/42?"+test.query, nil)) + + if received != test.query { + t.Fatalf("query = %q, want %q", received, test.query) + } + }) + } +} + +func BenchmarkMetadataAnalysisFilter(b *testing.B) { + tests := []struct { + name string + path string + }{ + {name: "non_metadata", path: "/library/sections/49/all?type=4"}, + {name: "metadata_clean", path: "/library/metadata/42?includeMarkers=1"}, + {name: "metadata_filtered", path: "/library/metadata/42?checkFiles=1&asyncAugmentMetadata=1&includeMarkers=1"}, + } + for _, test := range tests { + b.Run(test.name, func(b *testing.B) { + handler := newMetadataAnalysisFilter(true, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), metrics.New()) + request := httptest.NewRequest(http.MethodGet, test.path, nil) + response := httptest.NewRecorder() + b.ReportAllocs() + for b.Loop() { + handler.ServeHTTP(response, request) + } + }) + } +} diff --git a/internal/gateway/metadata_coalesce.go b/internal/gateway/metadata_coalesce.go new file mode 100644 index 0000000..63501f6 --- /dev/null +++ b/internal/gateway/metadata_coalesce.go @@ -0,0 +1,727 @@ +package gateway + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "hash" + "log/slog" + "net" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/InfinityPacer/plex-gateway/internal/metrics" + "github.com/InfinityPacer/plex-gateway/internal/plexmeta" +) + +const ( + defaultMetadataCoalesceWindow = 20 * time.Millisecond + defaultMetadataCoalesceMaxItems = 32 + defaultMetadataCoalesceTimeout = 5 * time.Second + defaultMetadataCoalesceResponseLimit = 8 << 20 + defaultMetadataCoalesceMaxGroups = 256 + defaultMetadataCoalesceMaxWaiters = 128 + defaultMetadataCoalesceCooldown = time.Second + maximumMetadataCoalesceWindow = 100 * time.Millisecond + maximumMetadataCoalesceItems = 32 +) + +// MetadataCoalesceOptions controls a bounded aggregation window for equivalent +// single-item metadata reads. Every unsafe or ambiguous response falls back to +// the original requests through the same upstream admission guard. +type MetadataCoalesceOptions struct { + Enabled bool + Trace bool + Window time.Duration + MaxItems int + Timeout time.Duration + OnMetadata func(*http.Request, []byte, string) +} + +type metadataCoalescer struct { + next http.Handler + window time.Duration + maxItems int + timeout time.Duration + bodyLimit int64 + maxGroups int + maxWaiters int + onMetadata func(*http.Request, []byte, string) + trace bool + metrics *metrics.Metrics + logger *slog.Logger + + mu sync.Mutex + groups map[[sha256.Size]byte]*metadataCoalesceGroup + failed map[[sha256.Size]byte]time.Time +} + +type metadataCoalesceGroup struct { + key [sha256.Size]byte + representative *http.Request + createdAt time.Time + items map[string][]*metadataCoalesceCall + order []string + timer *time.Timer + waiters int + + lifecycleMu sync.Mutex + cancel context.CancelFunc +} + +type metadataCoalesceCall struct { + writer http.ResponseWriter + request *http.Request + result chan metadataCoalesceResult + + mu sync.Mutex + active bool + group *metadataCoalesceGroup +} + +type metadataCoalesceResult struct { + direct bool + followers []*metadataCoalesceCall + headers http.Header + status int + body []byte +} + +type metadataCoalesceContextKey struct{} +type metadataCoalesceTimingContextKey struct{} + +// metadataCoalesceTiming separates admission delay from Plex work without +// exposing the batch identity. The synthetic batch stays on one request stack, +// so the guard and coalescer update this recorder sequentially. +type metadataCoalesceTiming struct { + guardWait time.Duration + plex time.Duration + split time.Duration + guardSeen bool +} + +func newMetadataCoalescer(options MetadataCoalesceOptions, next http.Handler, registry *metrics.Metrics, logger *slog.Logger) http.Handler { + if next == nil || !options.Enabled { + return next + } + window := options.Window + if window <= 0 { + window = defaultMetadataCoalesceWindow + } else if window > maximumMetadataCoalesceWindow { + window = maximumMetadataCoalesceWindow + } + maxItems := options.MaxItems + if maxItems == 1 { + return next + } + if maxItems <= 0 { + maxItems = defaultMetadataCoalesceMaxItems + } else if maxItems > maximumMetadataCoalesceItems { + maxItems = maximumMetadataCoalesceItems + } + timeout := options.Timeout + if timeout <= 0 { + timeout = defaultMetadataCoalesceTimeout + } + if registry == nil { + registry = metrics.New() + } + if logger == nil { + logger = slog.Default() + } + return &metadataCoalescer{ + next: next, window: window, maxItems: maxItems, timeout: timeout, + bodyLimit: defaultMetadataCoalesceResponseLimit, onMetadata: options.OnMetadata, + maxGroups: defaultMetadataCoalesceMaxGroups, maxWaiters: defaultMetadataCoalesceMaxWaiters, + trace: options.Trace, metrics: registry, logger: logger, groups: make(map[[sha256.Size]byte]*metadataCoalesceGroup), + failed: make(map[[sha256.Size]byte]time.Time), + } +} + +func (coalescer *metadataCoalescer) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + ratingKey, eligible := metadataCoalesceRatingKey(request) + if !eligible { + coalescer.next.ServeHTTP(writer, request) + return + } + + call := &metadataCoalesceCall{ + writer: writer, request: request, result: make(chan metadataCoalesceResult, 1), active: true, + } + coalescer.metrics.IncMetadataCoalesceOffered() + if !coalescer.enqueue(metadataCoalesceSignature(request), ratingKey, call) { + coalescer.next.ServeHTTP(writer, request) + return + } + + var result metadataCoalesceResult + select { + case result = <-call.result: + case <-request.Context().Done(): + if call.cancel() { + return + } + result = <-call.result + } + coalescer.serveResult(call, result) +} + +func (coalescer *metadataCoalescer) enqueue(key [sha256.Size]byte, ratingKey string, call *metadataCoalesceCall) bool { + coalescer.mu.Lock() + if deadline, blocked := coalescer.failed[key]; blocked { + if time.Now().Before(deadline) { + coalescer.mu.Unlock() + return false + } + delete(coalescer.failed, key) + } + group := coalescer.groups[key] + if group == nil { + if len(coalescer.groups) >= coalescer.maxGroups { + coalescer.mu.Unlock() + return false + } + group = &metadataCoalesceGroup{ + key: key, + representative: call.request, + createdAt: time.Now(), + items: make(map[string][]*metadataCoalesceCall), + } + coalescer.groups[key] = group + group.timer = time.AfterFunc(coalescer.window, func() { + coalescer.flush(key, group) + }) + } + if group.waiters >= coalescer.maxWaiters { + coalescer.mu.Unlock() + return false + } + call.group = group + group.waiters++ + if _, exists := group.items[ratingKey]; !exists { + group.order = append(group.order, ratingKey) + } + group.items[ratingKey] = append(group.items[ratingKey], call) + full := len(group.order) >= coalescer.maxItems + if full { + delete(coalescer.groups, key) + group.timer.Stop() + } + coalescer.mu.Unlock() + + if full { + go coalescer.execute(group) + } + return true +} + +func (coalescer *metadataCoalescer) flush(key [sha256.Size]byte, group *metadataCoalesceGroup) { + coalescer.mu.Lock() + if coalescer.groups[key] != group { + coalescer.mu.Unlock() + return + } + delete(coalescer.groups, key) + coalescer.mu.Unlock() + coalescer.execute(group) +} + +func (coalescer *metadataCoalescer) execute(group *metadataCoalesceGroup) { + started := time.Now() + order, items, representative, liveCalls := liveMetadataCoalesceItems(group) + if liveCalls == 0 { + return + } + if liveCalls == 1 { + for _, ratingKey := range order { + items[ratingKey][0].dispatch(metadataCoalesceResult{direct: true}) + return + } + } + + baseContext := context.WithoutCancel(representative.Context()) + ctx, cancel := context.WithTimeout(baseContext, coalescer.timeout) + ctx = context.WithValue(ctx, metadataCoalesceContextKey{}, true) + timing := &metadataCoalesceTiming{} + ctx = context.WithValue(ctx, metadataCoalesceTimingContextKey{}, timing) + group.installCancel(cancel) + defer group.clearCancel() + defer cancel() + if !group.anyActive() { + return + } + + coalescer.metrics.IncMetadataCoalesceBatch(len(order)) + coalescer.metrics.IncMetadataCoalesceActive() + responses, headers, status, err := coalescer.requestBatch(ctx, representative, order, timing) + coalescer.metrics.DecMetadataCoalesceActive() + if err != nil { + if !group.anyActive() { + return + } + coalescer.metrics.IncMetadataCoalesceFallback() + coalescer.markFailed(group.key) + coalescer.logger.Warn("metadata_coalesce_fallback", "reason", metadataCoalesceErrorKind(err)) + coalescer.fallback(order, items) + return + } + + dispatchStarted := time.Now() + wireBodies := make(map[string][]byte, len(order)) + responseHeaders := make(map[string]http.Header, len(order)) + for _, ratingKey := range order { + decoded := responses[ratingKey] + wireBody, encodeErr := encodeMetadataBody(decoded, headers.Get("Content-Encoding")) + if encodeErr != nil { + coalescer.metrics.IncMetadataCoalesceFallback() + coalescer.markFailed(group.key) + coalescer.logger.Warn("metadata_coalesce_fallback", "reason", "encode") + coalescer.fallback(order, items) + return + } + wireBodies[ratingKey] = wireBody + responseHeaders[ratingKey] = metadataCoalesceResponseHeaders(headers, len(wireBody)) + } + for _, ratingKey := range order { + decoded := responses[ratingKey] + if coalescer.onMetadata != nil { + coalescer.observe(items[ratingKey][0].request, decoded, responseHeaders[ratingKey].Get("Content-Type")) + } + for _, call := range items[ratingKey] { + call.dispatch(metadataCoalesceResult{ + headers: responseHeaders[ratingKey], status: status, body: wireBodies[ratingKey], + }) + } + } + if coalescer.trace { + coalescer.logger.Info("metadata_coalesce_batch", + "items", len(order), + "waiters", liveCalls, + "coalesce_wait_ms", started.Sub(group.createdAt).Milliseconds(), + "guard_wait_ms", timing.guardWait.Milliseconds(), + "plex_ms", timing.plex.Milliseconds(), + "split_ms", timing.split.Milliseconds(), + "dispatch_ms", time.Since(dispatchStarted).Milliseconds(), + "total_ms", time.Since(group.createdAt).Milliseconds(), + ) + } +} + +func liveMetadataCoalesceItems(group *metadataCoalesceGroup) ([]string, map[string][]*metadataCoalesceCall, *http.Request, int) { + order := make([]string, 0, len(group.order)) + items := make(map[string][]*metadataCoalesceCall, len(group.items)) + var representative *http.Request + liveCalls := 0 + for _, ratingKey := range group.order { + for _, call := range group.items[ratingKey] { + if !call.isActive() { + continue + } + if representative == nil { + representative = call.request + } + items[ratingKey] = append(items[ratingKey], call) + liveCalls++ + } + if len(items[ratingKey]) != 0 { + order = append(order, ratingKey) + } + } + return order, items, representative, liveCalls +} + +func (coalescer *metadataCoalescer) requestBatch( + ctx context.Context, + request *http.Request, + ratingKeys []string, + timing *metadataCoalesceTiming, +) (responses map[string][]byte, headers http.Header, status int, err error) { + defer func() { + recovered := recover() + if recovered == nil { + return + } + if recovered == http.ErrAbortHandler { + responses, headers, status = nil, nil, 0 + err = errors.New("metadata coalesce upstream body aborted") + return + } + panic(recovered) + }() + if request == nil || len(ratingKeys) == 0 { + return nil, nil, 0, errors.New("metadata coalesce request is empty") + } + batchRequest := request.Clone(ctx) + batchRequest.URL.Path = "/library/metadata/" + strings.Join(ratingKeys, ",") + batchRequest.URL.RawPath = "" + batchRequest.RequestURI = batchRequest.URL.RequestURI() + + capture := newMetadataCoalesceCapture(coalescer.bodyLimit) + handlerStarted := time.Now() + coalescer.next.ServeHTTP(capture, batchRequest) + if timing != nil && !timing.guardSeen { + timing.plex = time.Since(handlerStarted) + } + if err := ctx.Err(); err != nil { + return nil, nil, 0, err + } + if capture.overflow { + return nil, nil, 0, errors.New("metadata coalesce response exceeds limit") + } + if capture.statusCode() != http.StatusOK || capture.header.Get("Trailer") != "" || capture.header.Get("Content-Range") != "" { + return nil, nil, 0, fmt.Errorf("metadata coalesce upstream status %d", capture.statusCode()) + } + splitStarted := time.Now() + decoded, encoding, err := decodeMetadataBody(capture.body.Bytes(), capture.header.Get("Content-Encoding"), coalescer.bodyLimit) + if err != nil { + if timing != nil { + timing.split = time.Since(splitStarted) + } + return nil, nil, 0, fmt.Errorf("decode metadata coalesce response: %w", err) + } + responses, err = plexmeta.SplitMetadataBatch(decoded, capture.header.Get("Content-Type"), ratingKeys) + if timing != nil { + timing.split = time.Since(splitStarted) + } + if err != nil { + return nil, nil, 0, fmt.Errorf("split metadata coalesce response: %w", err) + } + headers = capture.header.Clone() + headers.Set("Content-Encoding", encoding) + if encoding == "" { + headers.Del("Content-Encoding") + } + return responses, headers, capture.statusCode(), nil +} + +func (coalescer *metadataCoalescer) fallback(order []string, items map[string][]*metadataCoalesceCall) { + for _, ratingKey := range order { + calls := items[ratingKey] + for index, leader := range calls { + followers := make([]*metadataCoalesceCall, 0, len(calls)-1) + followers = append(followers, calls[:index]...) + followers = append(followers, calls[index+1:]...) + if leader.dispatch(metadataCoalesceResult{direct: true, followers: followers}) { + break + } + } + } +} + +func (coalescer *metadataCoalescer) markFailed(key [sha256.Size]byte) { + now := time.Now() + coalescer.mu.Lock() + if len(coalescer.failed) >= coalescer.maxGroups { + for candidate, deadline := range coalescer.failed { + if !now.Before(deadline) { + delete(coalescer.failed, candidate) + } + } + } + if len(coalescer.failed) < coalescer.maxGroups { + coalescer.failed[key] = now.Add(defaultMetadataCoalesceCooldown) + } + coalescer.mu.Unlock() +} + +func (coalescer *metadataCoalescer) observe(request *http.Request, body []byte, contentType string) { + defer func() { _ = recover() }() + coalescer.onMetadata(request, body, contentType) +} + +func metadataCoalesceRatingKey(request *http.Request) (string, bool) { + if request == nil || request.URL == nil || request.Method != http.MethodGet || request.ContentLength > 0 || + request.URL.RawPath != "" || request.Body != nil && request.Body != http.NoBody || len(request.TransferEncoding) != 0 { + return "", false + } + if classifyMetadataRequest(request) != metadataRequestSingle { + return "", false + } + for _, header := range []string{"Range", "If-Match", "If-None-Match", "If-Modified-Since", "If-Unmodified-Since", "If-Range"} { + if request.Header.Get(header) != "" { + return "", false + } + } + _, tokenPresent, tokenValid := requestIdentity(request, "X-Plex-Token") + if !tokenPresent || !tokenValid { + return "", false + } + return strings.TrimPrefix(request.URL.Path, "/library/metadata/"), true +} + +func metadataCoalesceSignature(request *http.Request) [sha256.Size]byte { + digest := sha256.New() + writeMetadataCoalesceValue(digest, request.Method) + writeMetadataCoalesceValue(digest, request.Proto) + writeMetadataCoalesceValue(digest, request.Host) + writeMetadataCoalesceValue(digest, request.URL.RawQuery) + writeMetadataCoalesceValue(digest, metadataCoalesceRemoteIP(request.RemoteAddr)) + headerNames := make([]string, 0, len(request.Header)) + for name := range request.Header { + headerNames = append(headerNames, http.CanonicalHeaderKey(name)) + } + sort.Strings(headerNames) + for _, name := range headerNames { + writeMetadataCoalesceValue(digest, name) + for _, value := range request.Header.Values(name) { + writeMetadataCoalesceValue(digest, value) + } + } + var result [sha256.Size]byte + copy(result[:], digest.Sum(nil)) + return result +} + +func writeMetadataCoalesceValue(destination hash.Hash, value string) { + _, _ = destination.Write([]byte(strconv.Itoa(len(value)))) + _, _ = destination.Write([]byte{':'}) + _, _ = destination.Write([]byte(value)) +} + +func metadataCoalesceRemoteIP(remoteAddr string) string { + host, _, err := net.SplitHostPort(remoteAddr) + if err == nil { + return host + } + return remoteAddr +} + +func metadataCoalesceResponseHeaders(source http.Header, contentLength int) http.Header { + headers := source.Clone() + for _, name := range []string{"Content-Length", "ETag", "Last-Modified", "Content-MD5", "Digest", "Content-Digest", "Repr-Digest", "Content-Range", "Trailer"} { + headers.Del(name) + } + if !hasCacheDirective(headers.Get("Cache-Control"), "no-store") { + headers.Set("Cache-Control", "private, no-cache") + } + headers.Set("Content-Length", strconv.Itoa(contentLength)) + return headers +} + +func metadataCoalesceErrorKind(err error) string { + if err == nil { + return "none" + } + if errors.Is(err, context.DeadlineExceeded) { + return "timeout" + } + message := err.Error() + switch { + case strings.Contains(message, "status"): + return "status" + case strings.Contains(message, "exceeds"): + return "limit" + case strings.Contains(message, "decode"): + return "decode" + case strings.Contains(message, "split"): + return "split" + default: + return "upstream" + } +} + +func isMetadataCoalesceUpstreamRequest(request *http.Request) bool { + return request != nil && request.Context().Value(metadataCoalesceContextKey{}) == true +} + +func (call *metadataCoalesceCall) isActive() bool { + call.mu.Lock() + defer call.mu.Unlock() + return call.active +} + +func (call *metadataCoalesceCall) cancel() bool { + call.mu.Lock() + if !call.active { + call.mu.Unlock() + return false + } + call.active = false + group := call.group + call.mu.Unlock() + if group != nil { + group.cancelIfIdle() + } + return true +} + +func (call *metadataCoalesceCall) dispatch(result metadataCoalesceResult) bool { + call.mu.Lock() + defer call.mu.Unlock() + if !call.active { + return false + } + call.active = false + call.result <- result + return true +} + +func (coalescer *metadataCoalescer) serveResult(call *metadataCoalesceCall, result metadataCoalesceResult) { + if !result.direct { + writeCapturedResponse(call.writer, result.headers, result.status, result.body) + return + } + if len(result.followers) == 0 { + coalescer.next.ServeHTTP(call.writer, call.request) + return + } + + capture := newMetadataCoalesceTee(call.writer, coalescer.bodyLimit) + panicked := true + defer func() { + if panicked || !capture.reusable() { + coalescer.dispatchFallbackError(result.followers) + return + } + response := metadataCoalesceResult{ + headers: capture.Header().Clone(), status: capture.statusCode(), body: capture.body.Bytes(), + } + for _, follower := range result.followers { + follower.dispatch(response) + } + }() + coalescer.next.ServeHTTP(capture, call.request) + panicked = false +} + +func (coalescer *metadataCoalescer) dispatchFallbackError(calls []*metadataCoalesceCall) { + body := []byte(http.StatusText(http.StatusBadGateway) + "\n") + result := metadataCoalesceResult{ + headers: http.Header{ + "Content-Type": []string{"text/plain; charset=utf-8"}, + "Content-Length": []string{strconv.Itoa(len(body))}, + }, + status: http.StatusBadGateway, + body: body, + } + for _, call := range calls { + call.dispatch(result) + } +} + +func (group *metadataCoalesceGroup) anyActive() bool { + for _, calls := range group.items { + for _, call := range calls { + if call.isActive() { + return true + } + } + } + return false +} + +func (group *metadataCoalesceGroup) installCancel(cancel context.CancelFunc) { + group.lifecycleMu.Lock() + group.cancel = cancel + if !group.anyActive() { + cancel() + } + group.lifecycleMu.Unlock() +} + +func (group *metadataCoalesceGroup) clearCancel() { + group.lifecycleMu.Lock() + group.cancel = nil + group.lifecycleMu.Unlock() +} + +func (group *metadataCoalesceGroup) cancelIfIdle() { + group.lifecycleMu.Lock() + if group.cancel != nil && !group.anyActive() { + group.cancel() + } + group.lifecycleMu.Unlock() +} + +type metadataCoalesceCapture struct { + header http.Header + body bytes.Buffer + status int + limit int64 + overflow bool +} + +type metadataCoalesceTee struct { + destination http.ResponseWriter + body bytes.Buffer + status int + limit int64 + overflow bool + failed bool +} + +func newMetadataCoalesceTee(destination http.ResponseWriter, limit int64) *metadataCoalesceTee { + return &metadataCoalesceTee{destination: destination, limit: limit} +} + +func (capture *metadataCoalesceTee) Header() http.Header { return capture.destination.Header() } + +func (capture *metadataCoalesceTee) WriteHeader(status int) { + if capture.status == 0 { + capture.status = status + } + capture.destination.WriteHeader(status) +} + +func (capture *metadataCoalesceTee) Write(value []byte) (int, error) { + if capture.status == 0 { + capture.WriteHeader(http.StatusOK) + } + count, err := capture.destination.Write(value) + if err != nil || count != len(value) { + capture.failed = true + } + if !capture.overflow && int64(capture.body.Len())+int64(count) <= capture.limit { + _, _ = capture.body.Write(value[:count]) + } else { + capture.overflow = true + } + return count, err +} + +func (capture *metadataCoalesceTee) statusCode() int { + if capture.status == 0 { + return http.StatusOK + } + return capture.status +} + +func (capture *metadataCoalesceTee) reusable() bool { + return !capture.failed && !capture.overflow && capture.Header().Get("Trailer") == "" +} + +func newMetadataCoalesceCapture(limit int64) *metadataCoalesceCapture { + return &metadataCoalesceCapture{header: make(http.Header), limit: limit} +} + +func (capture *metadataCoalesceCapture) Header() http.Header { return capture.header } + +func (capture *metadataCoalesceCapture) WriteHeader(status int) { + if capture.status == 0 { + capture.status = status + } +} + +func (capture *metadataCoalesceCapture) Write(value []byte) (int, error) { + if capture.status == 0 { + capture.status = http.StatusOK + } + if capture.overflow || int64(capture.body.Len())+int64(len(value)) > capture.limit { + capture.overflow = true + return len(value), nil + } + return capture.body.Write(value) +} + +func (capture *metadataCoalesceCapture) statusCode() int { + if capture.status == 0 { + return http.StatusOK + } + return capture.status +} diff --git a/internal/gateway/metadata_coalesce_test.go b/internal/gateway/metadata_coalesce_test.go new file mode 100644 index 0000000..9201706 --- /dev/null +++ b/internal/gateway/metadata_coalesce_test.go @@ -0,0 +1,1144 @@ +package gateway + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "encoding/xml" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/InfinityPacer/plex-gateway/internal/metrics" + "github.com/InfinityPacer/plex-gateway/internal/partcache" + "github.com/InfinityPacer/plex-gateway/internal/trace" +) + +type metadataCoalesceObservedRequest struct { + Method string + Path string + RawQuery string + RemoteAddr string + Header http.Header + Batch bool +} + +type metadataCoalesceTestUpstream struct { + mu sync.Mutex + requests []metadataCoalesceObservedRequest + serve func(http.ResponseWriter, *http.Request) +} + +type metadataCoalesceSynchronizedBuffer struct { + mu sync.Mutex + buffer bytes.Buffer +} + +func (buffer *metadataCoalesceSynchronizedBuffer) Write(value []byte) (int, error) { + buffer.mu.Lock() + defer buffer.mu.Unlock() + return buffer.buffer.Write(value) +} + +func (buffer *metadataCoalesceSynchronizedBuffer) String() string { + buffer.mu.Lock() + defer buffer.mu.Unlock() + return buffer.buffer.String() +} + +func (upstream *metadataCoalesceTestUpstream) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + upstream.mu.Lock() + upstream.requests = append(upstream.requests, metadataCoalesceObservedRequest{ + Method: request.Method, Path: request.URL.Path, RawQuery: request.URL.RawQuery, + RemoteAddr: request.RemoteAddr, Header: request.Header.Clone(), + Batch: isMetadataCoalesceUpstreamRequest(request), + }) + upstream.mu.Unlock() + upstream.serve(writer, request) +} + +func (upstream *metadataCoalesceTestUpstream) snapshot() []metadataCoalesceObservedRequest { + upstream.mu.Lock() + defer upstream.mu.Unlock() + result := make([]metadataCoalesceObservedRequest, len(upstream.requests)) + copy(result, upstream.requests) + return result +} + +func newMetadataCoalesceTestHandler( + upstream *metadataCoalesceTestUpstream, + window, timeout time.Duration, +) (http.Handler, *metrics.Metrics) { + registry := metrics.New() + return newMetadataCoalescer(MetadataCoalesceOptions{ + Enabled: true, Window: window, MaxItems: 8, Timeout: timeout, + }, upstream, registry, slog.New(slog.NewTextHandler(io.Discard, nil))), registry +} + +func newMetadataCoalesceTestRequest(method, ratingKey string) *http.Request { + request := httptest.NewRequest(method, "http://gateway.test/library/metadata/"+ratingKey, nil) + request.Header.Set("X-Plex-Token", "token-a") + request.Header.Set("User-Agent", "metadata-client/1") + request.RemoteAddr = "198.51.100.10:4000" + return request +} + +func runMetadataCoalesceCalls(t *testing.T, handler http.Handler, requests ...*http.Request) []*httptest.ResponseRecorder { + t.Helper() + start := make(chan struct{}) + type result struct { + index int + response *httptest.ResponseRecorder + } + results := make(chan result, len(requests)) + for index, request := range requests { + go func(index int, request *http.Request) { + <-start + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + results <- result{index: index, response: response} + }(index, request) + } + close(start) + responses := make([]*httptest.ResponseRecorder, len(requests)) + deadline := time.After(2 * time.Second) + for range requests { + select { + case result := <-results: + responses[result.index] = result.response + case <-deadline: + t.Fatal("metadata coalescer request did not complete") + } + } + return responses +} + +func runMetadataCoalesceOrderedCalls(t *testing.T, coalescer *metadataCoalescer, handler http.Handler, requests ...*http.Request) []*httptest.ResponseRecorder { + t.Helper() + type result struct { + index int + response *httptest.ResponseRecorder + } + results := make(chan result, len(requests)) + for index, request := range requests { + go func(index int, request *http.Request) { + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + results <- result{index: index, response: response} + }(index, request) + waitForMetadataCoalesceWaiters(t, coalescer, index+1) + } + responses := make([]*httptest.ResponseRecorder, len(requests)) + deadline := time.After(2 * time.Second) + for range requests { + select { + case result := <-results: + responses[result.index] = result.response + case <-deadline: + t.Fatal("ordered metadata coalescer request did not complete") + } + } + return responses +} + +func metadataCoalesceTestKeys(request *http.Request) []string { + identifier := strings.TrimPrefix(request.URL.Path, "/library/metadata/") + return strings.Split(identifier, ",") +} + +func metadataCoalesceJSONBody(keys []string) []byte { + items := make([]string, 0, len(keys)) + for _, key := range keys { + items = append(items, `{"ratingKey":"`+key+`","title":"title-`+key+`","unknownItemField":"keep"}`) + } + return []byte(`{"MediaContainer":{"size":` + strconv.Itoa(len(keys)) + `,"unknownContainerField":{"keep":true},"Metadata":[` + strings.Join(items, ",") + `]}}`) +} + +func metadataCoalesceXMLBody(keys []string) []byte { + items := make([]string, 0, len(keys)) + for _, key := range keys { + items = append(items, ``) + ensurer := &fakeMediaInfoEnsurer{getFn: func(mediainfo.Key) (mediainfo.Record, bool) { + return mediainfo.Record{}, false }} + handler := fixture.handler(ensurer, metrics.New(), metadataBodyHandler(raw, "application/xml")) + response := httptest.NewRecorder() + handler.ServeHTTP(response, authenticatedRequest(http.MethodGet, "/library/metadata/7/children")) + + if response.Code != http.StatusOK || !bytes.Equal(response.Body.Bytes(), raw) { + t.Fatalf("response = %d %q", response.Code, response.Body.Bytes()) + } + if ensurer.gets.Load() != 1 { + t.Fatalf("Get calls = %d", ensurer.gets.Load()) + } +} + +func TestMediaInfoCacheOnlyRequestRequiresLibraryProductAndSkipRefresh(t *testing.T) { + tests := []struct { + name string + path string + product string + want bool + }{ + {name: "background library sync", path: "/library/metadata/42?skipRefresh=1", product: "Infuse-Library", want: true}, + {name: "case insensitive product", path: "/library/metadata/42?skipRefresh=1", product: "INFUSE-LIBRARY", want: true}, + {name: "interactive library request", path: "/library/metadata/42", product: "Infuse-Library", want: false}, + {name: "ordinary client with skip refresh", path: "/library/metadata/42?skipRefresh=1", product: "Plex for iOS", want: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, test.path, nil) + request.Header.Set("X-Plex-Product", test.product) + if got := mediaInfoCacheOnlyRequest(request); got != test.want { + t.Fatalf("mediaInfoCacheOnlyRequest() = %v, want %v", got, test.want) + } + }) + } +} + +func TestMetadataEnrichmentColdMissReturnsOriginalAndOffersP2(t *testing.T) { + fixture := newMetadataEnrichmentFixture(t) + raw := fixture.xmlBody() + ensurer := &fakeMediaInfoEnsurer{} handler := newMetadataEnrichmentHandler(metadataEnrichmentOptions{ Service: ensurer, Mapper: fixture.mapper, Resolver: fixture.resolver, - CloudExtensions: []string{".strm"}, ColdWait: 10 * time.Millisecond, - ResponseLimit: 1 << 20, Concurrency: 1, Metrics: metrics.New(), + CloudExtensions: []string{".strm"}, + ResponseLimit: 1 << 20, Concurrency: 1, Metrics: metrics.New(), }, metadataBodyHandler(raw, "application/xml")) response := httptest.NewRecorder() started := time.Now() handler.ServeHTTP(response, authenticatedMetadataRequest(http.MethodGet)) if elapsed := time.Since(started); elapsed > 500*time.Millisecond { - t.Fatalf("cold timeout took %s", elapsed) + t.Fatalf("cold miss took %s", elapsed) } if !bytes.Equal(response.Body.Bytes(), raw) || response.Header().Get("ETag") != `"plex-etag"` { t.Fatalf("fail-open response changed: headers=%#v body=%s", response.Header(), response.Body.Bytes()) } + offered := ensurer.offers() + if len(offered) != 1 || offered[0].Priority != mediainfo.PriorityMetadata || offered[0].Key.PartID != "9" { + t.Fatalf("P2 offers = %#v", offered) + } } func TestMetadataEnrichmentRejectsFingerprintChange(t *testing.T) { fixture := newMetadataEnrichmentFixture(t) raw := fixture.xmlBody() - ensurer := &fakeMediaInfoEnsurer{fn: func(_ context.Context, request mediainfo.Request) (mediainfo.Record, error) { + ensurer := &fakeMediaInfoEnsurer{memoryFn: func(key mediainfo.Key) (mediainfo.Record, bool) { if err := os.WriteFile(fixture.strmPath, []byte("http://mediavault:7811/redirect/changed/file.mkv\n"), 0o600); err != nil { t.Fatal(err) } - return mediainfo.Record{Key: request.Key, Media: completeProjectionMedia()}, nil + return mediainfo.Record{Key: key, Media: completeProjectionMedia()}, true }} handler := fixture.handler(ensurer, metrics.New(), metadataBodyHandler(raw, "application/xml")) response := httptest.NewRecorder() @@ -164,17 +312,22 @@ func TestMetadataEnrichmentPoolSaturationSkipsWaiting(t *testing.T) { raw := fixture.xmlBody() started := make(chan struct{}) release := make(chan struct{}) - ensurer := &fakeMediaInfoEnsurer{fn: func(_ context.Context, request mediainfo.Request) (mediainfo.Record, error) { - close(started) - <-release - return mediainfo.Record{Key: request.Key, Media: completeProjectionMedia()}, nil + ensurer := &fakeMediaInfoEnsurer{memoryFn: func(key mediainfo.Key) (mediainfo.Record, bool) { + return mediainfo.Record{Key: key, Media: completeProjectionMedia()}, true }} registry := metrics.New() + var upstreamCalls atomic.Int64 handler := newMetadataEnrichmentHandler(metadataEnrichmentOptions{ Service: ensurer, Mapper: fixture.mapper, Resolver: fixture.resolver, - CloudExtensions: []string{".strm"}, ColdWait: time.Second, - ResponseLimit: 1 << 20, Concurrency: 1, Metrics: registry, - }, metadataBodyHandler(raw, "application/xml")) + CloudExtensions: []string{".strm"}, + ResponseLimit: 1 << 20, Concurrency: 1, Metrics: registry, + }, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if upstreamCalls.Add(1) == 1 { + close(started) + <-release + } + metadataBodyHandler(raw, "application/xml").ServeHTTP(w, request) + })) firstDone := make(chan *httptest.ResponseRecorder, 1) go func() { @@ -189,8 +342,8 @@ func TestMetadataEnrichmentPoolSaturationSkipsWaiting(t *testing.T) { } second := httptest.NewRecorder() handler.ServeHTTP(second, authenticatedMetadataRequest(http.MethodGet)) - if !bytes.Equal(second.Body.Bytes(), raw) || ensurer.calls.Load() != 1 { - t.Fatalf("saturated response=%s calls=%d", second.Body.Bytes(), ensurer.calls.Load()) + if !bytes.Equal(second.Body.Bytes(), raw) { + t.Fatalf("saturated response=%s", second.Body.Bytes()) } close(release) select { @@ -206,13 +359,99 @@ func TestMetadataEnrichmentPoolSaturationSkipsWaiting(t *testing.T) { } } +func TestMetadataEnrichmentUsesL1RecordAfterColdOffer(t *testing.T) { + fixture := newMetadataEnrichmentFixture(t) + raw := fixture.xmlBody() + var lookups atomic.Int64 + ensurer := &fakeMediaInfoEnsurer{ + memoryFn: func(key mediainfo.Key) (mediainfo.Record, bool) { + if lookups.Add(1) == 1 { + return mediainfo.Record{}, false + } + return mediainfo.Record{Key: key, Media: completeProjectionMedia()}, true + }, + } + handler := newMetadataEnrichmentHandler(metadataEnrichmentOptions{ + Service: ensurer, Mapper: fixture.mapper, Resolver: fixture.resolver, + CloudExtensions: []string{".strm"}, + ResponseLimit: 1 << 20, Concurrency: 2, Metrics: metrics.New(), + }, metadataBodyHandler(raw, "application/xml")) + + first := httptest.NewRecorder() + handler.ServeHTTP(first, authenticatedMetadataRequest(http.MethodGet)) + if !bytes.Equal(first.Body.Bytes(), raw) { + t.Fatalf("cold response = %s", first.Body.Bytes()) + } + + second := httptest.NewRecorder() + handler.ServeHTTP(second, authenticatedMetadataRequest(http.MethodGet)) + if !strings.Contains(second.Body.String(), `container="mkv"`) { + t.Fatalf("cached response was not enriched: %s", second.Body.String()) + } + if offered := ensurer.offers(); len(offered) != 1 || offered[0].Priority != mediainfo.PriorityMetadata { + t.Fatalf("offers = %#v", offered) + } +} + +func TestMetadataEnrichmentColdBurstReturnsWithoutSynchronousProbe(t *testing.T) { + fixture := newMetadataEnrichmentFixture(t) + raw := fixture.xmlBody() + ensurer := &fakeMediaInfoEnsurer{} + registry := metrics.New() + enrichment := newMetadataEnrichmentHandler(metadataEnrichmentOptions{ + Service: ensurer, Mapper: fixture.mapper, Resolver: fixture.resolver, + CloudExtensions: []string{".strm"}, + ResponseLimit: 1 << 20, Concurrency: 64, Metrics: registry, + }, metadataBodyHandler(raw, "application/xml")) + handler := newMetadataGuard(MetadataGuardOptions{ + Enabled: true, GlobalConcurrency: 16, PerClientConcurrency: 4, + QueueTimeout: 500 * time.Millisecond, + }, enrichment, registry, nil) + + const burst = 64 + responses := make(chan *httptest.ResponseRecorder, burst) + var group sync.WaitGroup + group.Add(burst) + for range burst { + go func() { + defer group.Done() + response := httptest.NewRecorder() + handler.ServeHTTP(response, authenticatedMetadataRequest(http.MethodGet)) + responses <- response + }() + } + burstDone := make(chan struct{}) + go func() { + group.Wait() + close(burstDone) + }() + select { + case <-burstDone: + case <-time.After(time.Second): + t.Fatal("cold metadata burst remained queued") + } + close(responses) + for response := range responses { + if response.Code != http.StatusOK || !bytes.Equal(response.Body.Bytes(), raw) { + t.Fatalf("burst response = %d %q", response.Code, response.Body.Bytes()) + } + } + if offered := ensurer.offers(); len(offered) != burst { + t.Fatalf("background offers = %d, want %d", len(offered), burst) + } + if got := ensurer.gets.Load(); got != 0 { + t.Fatalf("cold burst performed %d durable-capable lookups", got) + } + if snapshot := registry.Snapshot(); snapshot.MetadataGuardTimeoutsTotal != 0 || snapshot.MediaInfoWaitRejectedTotal != 0 { + t.Fatalf("metrics = %#v", snapshot) + } +} + func TestMetadataEnrichmentBypassesIneligibleAndUnbufferableResponses(t *testing.T) { fixture := newMetadataEnrichmentFixture(t) raw := fixture.xmlBody() oversized := bytes.Repeat([]byte("x"), 1025) - ensurer := &fakeMediaInfoEnsurer{fn: func(_ context.Context, _ mediainfo.Request) (mediainfo.Record, error) { - return mediainfo.Record{}, errors.New("unexpected Ensure") - }} + ensurer := &fakeMediaInfoEnsurer{} tests := []struct { name string request *http.Request @@ -223,6 +462,9 @@ func TestMetadataEnrichmentBypassesIneligibleAndUnbufferableResponses(t *testing {name: "HEAD", request: authenticatedMetadataRequest(http.MethodHead), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK}, {name: "batch", request: authenticatedRequest(http.MethodGet, "/library/metadata/42,43"), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK, wantBody: raw}, {name: "children", request: authenticatedRequest(http.MethodGet, "/library/metadata/42/children"), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK, wantBody: raw}, + {name: "HEAD children", request: authenticatedRequest(http.MethodHead, "/library/metadata/42/children"), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK}, + {name: "range children", request: rangeChildrenRequest(), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK, wantBody: raw}, + {name: "missing token children", request: httptest.NewRequest(http.MethodGet, "/library/metadata/42/children", nil), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK, wantBody: raw}, {name: "Part playback", request: authenticatedRequest(http.MethodGet, "/library/parts/9/1/file.strm"), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK, wantBody: raw}, {name: "decision", request: authenticatedRequest(http.MethodGet, "/video/:/transcode/universal/decision"), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK, wantBody: raw}, {name: "universal start", request: authenticatedRequest(http.MethodGet, "/video/:/transcode/universal/start.mpd"), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK, wantBody: raw}, @@ -239,8 +481,8 @@ func TestMetadataEnrichmentBypassesIneligibleAndUnbufferableResponses(t *testing t.Run(test.name, func(t *testing.T) { handler := newMetadataEnrichmentHandler(metadataEnrichmentOptions{ Service: ensurer, Mapper: fixture.mapper, Resolver: fixture.resolver, - CloudExtensions: []string{".strm"}, ColdWait: time.Second, - ResponseLimit: 1024, Concurrency: 1, Metrics: metrics.New(), + CloudExtensions: []string{".strm"}, + ResponseLimit: 1024, Concurrency: 1, Metrics: metrics.New(), }, test.next) response := httptest.NewRecorder() handler.ServeHTTP(response, test.request) @@ -249,16 +491,66 @@ func TestMetadataEnrichmentBypassesIneligibleAndUnbufferableResponses(t *testing } }) } - if ensurer.calls.Load() != 0 { - t.Fatalf("ineligible responses called Ensure %d times", ensurer.calls.Load()) + if offered := ensurer.offers(); len(offered) != 0 { + t.Fatalf("ineligible responses offered %d probes", len(offered)) + } +} + +func TestMetadataEnrichmentAdmitsExistingStreamHDRAndSTRMControlSize(t *testing.T) { + fixture := newMetadataEnrichmentFixture(t) + media := completeProjectionMedia() + media.Size = 987654321 + media.Bitrate = 29430119 + media.Streams[0] = mediainfo.Stream{ + Index: 0, Type: "video", Codec: "hevc", Profile: "Main 10", Bitrate: 27510000, + Width: 3840, Height: 2160, FrameRate: "24000/1001", PixelFormat: "yuv420p10le", BitDepth: 10, + ColorSpace: "bt2020nc", ColorRange: "tv", ColorPrimaries: "bt2020", ColorTransfer: "smpte2084", + HDRFormat: "dolby_vision", + DolbyVision: &mediainfo.DolbyVision{ + VersionMajor: 1, Profile: 7, Level: 6, RPUPresent: 1, + ELPresent: 1, BLPresent: 1, BLCompatID: 6, + }, + } + tests := []struct { + name string + contentType string + body []byte + want [][]byte + }{ + { + name: "XML", contentType: "application/xml", + body: []byte(``), + want: [][]byte{[]byte(`size="987654321"`), []byte(`displayTitle="4K DoVi/HDR10"`), []byte(`DOVIPresent="1"`)}, + }, + { + name: "JSON", contentType: "application/json", + body: []byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"container":"mkv","duration":60000,"bitrate":8000,"width":3840,"height":2160,"aspectRatio":"16:9","audioChannels":6,"audioCodec":"aac","videoCodec":"hevc","videoResolution":"4k","videoFrameRate":"23.976","videoProfile":"Main 10","audioProfile":"LC","Part":[{"id":9,"file":"/media/cloud/episode.strm","duration":60000,"size":301,"container":"mkv","videoProfile":"Main 10","audioProfile":"LC","Stream":[{"id":101,"streamType":1,"index":0,"codec":"hevc","profile":"Main 10","level":153,"bitrate":8000,"languageCode":"eng","title":"Video","width":3840,"height":2160,"frameRate":23.976,"refFrames":4,"pixelFormat":"yuv420p10le","bitDepth":10,"colorSpace":"bt2020nc","colorRange":"tv","colorPrimaries":"bt2020","colorTrc":"smpte2084","chromaLocation":"left","sampleAspectRatio":"1:1","displayAspectRatio":"16:9"}]}]}]}]}}`), + want: [][]byte{[]byte(`"size":987654321`), []byte(`"displayTitle":"4K DoVi/HDR10"`), []byte(`"DOVIPresent":true`)}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ensurer := &fakeMediaInfoEnsurer{memoryFn: func(key mediainfo.Key) (mediainfo.Record, bool) { + return mediainfo.Record{Key: key, Media: media}, true + }} + handler := fixture.handler(ensurer, metrics.New(), metadataBodyHandler(test.body, test.contentType)) + response := httptest.NewRecorder() + handler.ServeHTTP(response, authenticatedMetadataRequest(http.MethodGet)) + if ensurer.memoryGets.Load() != 1 || len(ensurer.offers()) != 0 { + t.Fatalf("memory gets=%d offers=%d", ensurer.memoryGets.Load(), len(ensurer.offers())) + } + for _, expected := range test.want { + if !bytes.Contains(response.Body.Bytes(), expected) { + t.Fatalf("response missing %q: %s", expected, response.Body.Bytes()) + } + } + }) } } func TestMetadataEnrichmentSkipsLocalAndCompleteCloudParts(t *testing.T) { fixture := newMetadataEnrichmentFixture(t) - ensurer := &fakeMediaInfoEnsurer{fn: func(_ context.Context, _ mediainfo.Request) (mediainfo.Record, error) { - return mediainfo.Record{}, errors.New("unexpected Ensure") - }} + ensurer := &fakeMediaInfoEnsurer{} tests := []struct { name string body []byte @@ -269,7 +561,7 @@ func TestMetadataEnrichmentSkipsLocalAndCompleteCloudParts(t *testing.T) { }, { name: "complete cloud Part", - body: []byte(``), + body: []byte(``), }, } for _, test := range tests { @@ -282,8 +574,8 @@ func TestMetadataEnrichmentSkipsLocalAndCompleteCloudParts(t *testing.T) { } }) } - if ensurer.calls.Load() != 0 { - t.Fatalf("skipped Parts called Ensure %d times", ensurer.calls.Load()) + if ensurer.memoryGets.Load() != 0 || len(ensurer.offers()) != 0 { + t.Fatalf("skipped Parts memory gets=%d offers=%d", ensurer.memoryGets.Load(), len(ensurer.offers())) } } @@ -316,8 +608,8 @@ func newMetadataEnrichmentFixture(t *testing.T) metadataEnrichmentFixture { func (fixture metadataEnrichmentFixture) handler(ensurer mediaInfoEnsurer, registry *metrics.Metrics, next http.Handler) http.Handler { return newMetadataEnrichmentHandler(metadataEnrichmentOptions{ Service: ensurer, Mapper: fixture.mapper, Resolver: fixture.resolver, - CloudExtensions: []string{".strm"}, ColdWait: time.Second, - ResponseLimit: 1 << 20, Concurrency: 2, Metrics: registry, + CloudExtensions: []string{".strm"}, + ResponseLimit: 1 << 20, Concurrency: 2, Metrics: registry, }, next) } @@ -356,6 +648,12 @@ func rangeMetadataRequest() *http.Request { return request } +func rangeChildrenRequest() *http.Request { + request := authenticatedRequest(http.MethodGet, "/library/metadata/42/children") + request.Header.Set("Range", "bytes=0-100") + return request +} + func metadataBodyHandler(body []byte, contentType string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { w.Header().Set("Content-Type", contentType) diff --git a/internal/gateway/metadata_guard.go b/internal/gateway/metadata_guard.go index bffe546..3458d67 100644 --- a/internal/gateway/metadata_guard.go +++ b/internal/gateway/metadata_guard.go @@ -13,9 +13,9 @@ import ( ) const ( - defaultMetadataGlobalConcurrency = 8 - defaultMetadataPerClientConcurrency = 4 - defaultMetadataBatchConcurrency = 3 + defaultMetadataGlobalConcurrency = 16 + defaultMetadataPerClientConcurrency = 16 + defaultMetadataBatchConcurrency = 4 defaultMetadataQueueTimeout = 10 * time.Second ) @@ -98,6 +98,7 @@ func (g *metadataGuard) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + queuedAt := time.Now() ctx, cancel := context.WithTimeout(r.Context(), g.queueTimeout) defer cancel() var release func() @@ -124,6 +125,15 @@ func (g *metadataGuard) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } defer release() + timing, _ := r.Context().Value(metadataCoalesceTimingContextKey{}).(*metadataCoalesceTiming) + if kind == metadataRequestBatch && timing != nil { + timing.guardWait = time.Since(queuedAt) + plexStarted := time.Now() + g.next.ServeHTTP(w, r) + timing.plex = time.Since(plexStarted) + timing.guardSeen = true + return + } g.next.ServeHTTP(w, r) } diff --git a/internal/gateway/playback_veto.go b/internal/gateway/playback_veto.go new file mode 100644 index 0000000..eead558 --- /dev/null +++ b/internal/gateway/playback_veto.go @@ -0,0 +1,69 @@ +package gateway + +import ( + "net/http" + "strings" + + "github.com/InfinityPacer/plex-gateway/internal/mediainfo" +) + +type playbackVeto func(*http.Request, mediainfo.Media) (reason string, reject bool) + +func newPlaybackVeto(enabled bool) playbackVeto { + if !enabled { + return nil + } + return vetoAppleTVDolbyVisionProfile5 +} + +// vetoAppleTVDolbyVisionProfile5 is an optional compatibility override for +// deployments where this exact client and media combination is known to fail. +// Unknown, incomplete, or conflicting facts always preserve Plex's decision. +func vetoAppleTVDolbyVisionProfile5(request *http.Request, media mediainfo.Media) (string, bool) { + if !media.Complete || request == nil || request.URL == nil { + return "", false + } + product, productOK := plexRequestFact(request, "X-Plex-Product") + platform, platformOK := plexRequestFact(request, "X-Plex-Platform") + if !productOK || !platformOK || !strings.EqualFold(product, "Plex for Apple TV") || !strings.EqualFold(platform, "tvOS") { + return "", false + } + var video *mediainfo.Stream + for index := range media.Streams { + if !strings.EqualFold(strings.TrimSpace(media.Streams[index].Type), "video") { + continue + } + if video != nil { + return "", false + } + video = &media.Streams[index] + } + if video == nil || !strings.EqualFold(strings.TrimSpace(video.HDRFormat), "dolby_vision") || video.DolbyVision == nil { + return "", false + } + if video.DolbyVision.Profile != 5 || video.DolbyVision.BLCompatID != 0 { + return "", false + } + return "dolby_vision_profile_5", true +} + +func plexRequestFact(request *http.Request, name string) (string, bool) { + queryValues, queryPresent := request.URL.Query()[name] + headerValues := request.Header.Values(name) + if queryPresent && (len(queryValues) != 1 || strings.TrimSpace(queryValues[0]) == "") { + return "", false + } + if len(headerValues) > 1 || len(headerValues) == 1 && strings.TrimSpace(headerValues[0]) == "" { + return "", false + } + if queryPresent && len(headerValues) == 1 && queryValues[0] != headerValues[0] { + return "", false + } + if queryPresent { + return strings.TrimSpace(queryValues[0]), true + } + if len(headerValues) == 1 { + return strings.TrimSpace(headerValues[0]), true + } + return "", false +} diff --git a/internal/gateway/playback_veto_test.go b/internal/gateway/playback_veto_test.go new file mode 100644 index 0000000..3f2f9cd --- /dev/null +++ b/internal/gateway/playback_veto_test.go @@ -0,0 +1,53 @@ +package gateway + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/InfinityPacer/plex-gateway/internal/mediainfo" +) + +func TestPlaybackVetoIsAbsentWhenDisabled(t *testing.T) { + if veto := newPlaybackVeto(false); veto != nil { + t.Fatal("disabled playback veto is not nil") + } +} + +func TestPlaybackVetoRejectsOnlyExactAppleTVDolbyVisionProfile5(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/decision", nil) + request.Header.Set("X-Plex-Product", "Plex for Apple TV") + request.Header.Set("X-Plex-Platform", "tvOS") + media := mediainfo.Media{ + Complete: true, + Streams: []mediainfo.Stream{{ + Type: "video", HDRFormat: "dolby_vision", + DolbyVision: &mediainfo.DolbyVision{Profile: 5, BLCompatID: 0}, + }}, + } + veto := newPlaybackVeto(true) + if reason, reject := veto(request, media); !reject || reason != "dolby_vision_profile_5" { + t.Fatalf("veto() = %q, %v", reason, reject) + } + + request.Header.Set("X-Plex-Product", "Infuse") + if reason, reject := veto(request, media); reject || reason != "" { + t.Fatalf("Infuse veto() = %q, %v", reason, reject) + } +} + +func TestPlaybackVetoAbstainsOnConflictingClientFacts(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/decision?X-Plex-Platform=tvOS", nil) + request.Header.Set("X-Plex-Product", "Plex for Apple TV") + request.Header.Set("X-Plex-Platform", "iOS") + media := mediainfo.Media{ + Complete: true, + Streams: []mediainfo.Stream{{ + Type: "video", HDRFormat: "dolby_vision", + DolbyVision: &mediainfo.DolbyVision{Profile: 5, BLCompatID: 0}, + }}, + } + if reason, reject := newPlaybackVeto(true)(request, media); reject || reason != "" { + t.Fatalf("veto() = %q, %v", reason, reject) + } +} diff --git a/internal/gateway/universal_start_test.go b/internal/gateway/universal_start_test.go index e928cc0..d5572e6 100644 --- a/internal/gateway/universal_start_test.go +++ b/internal/gateway/universal_start_test.go @@ -100,10 +100,10 @@ func TestGrantedCloudTranscodeStartRedirectsToDirectURL(t *testing.T) { })) defer plex.Close() - handler, registry, cache := newCloudHandler(t, plex.URL, mediaVault.URL, []pathmap.Mapping{{ + handler, registry, cache := newCloudHandlerWithVeto(t, plex.URL, mediaVault.URL, []pathmap.Mapping{{ PlexPrefix: "/media/cloud", LocalPrefix: localRoot, - }}) + }}, true) performCloudDecision(t, handler, query, http.StatusOK) response := httptest.NewRecorder() @@ -622,10 +622,10 @@ func TestLocalTranscodeStartRemainsUnchanged(t *testing.T) { })) defer plex.Close() - handler, _, _ := newCloudHandler(t, plex.URL, "http://mediavault.invalid:7811", []pathmap.Mapping{{ + handler, _, _ := newCloudHandlerWithVeto(t, plex.URL, "http://mediavault.invalid:7811", []pathmap.Mapping{{ PlexPrefix: "/media/cloud", LocalPrefix: t.TempDir(), - }}) + }}, true) response := httptest.NewRecorder() handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/video/:/transcode/universal/start.mpd?"+originalQuery, nil)) if response.Code != http.StatusAccepted || startQuery != originalQuery { diff --git a/internal/mediainfo/benchmark_test.go b/internal/mediainfo/benchmark_test.go index bcbebef..c11298b 100644 --- a/internal/mediainfo/benchmark_test.go +++ b/internal/mediainfo/benchmark_test.go @@ -24,6 +24,105 @@ func BenchmarkMediaInfoL1Get(b *testing.B) { } } +func BenchmarkMediaInfoServiceGetMemory(b *testing.B) { + now := time.Now().UTC() + record := completeRecord(now) + service, err := NewService(ServiceOptions{ + Cache: NewCache([]Record{record}, now), Store: &fakeRecordStore{}, + Provider: &fakeProvider{}, PlexServerID: record.Key.PlexServerID, + BackgroundUserAgent: "Infuse-Library/8.5.1", Now: func() time.Time { return now }, + }) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := service.Close(ctx); err != nil { + b.Error(err) + } + }) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, ok := service.GetMemory(record.Key); !ok { + b.Fatal("service L1 miss") + } + } +} + +func BenchmarkMediaInfoServiceOfferFresh(b *testing.B) { + now := time.Now().UTC() + record := completeRecord(now) + request := Request{ + Key: record.Key, RatingKey: record.RatingKey, Target: "https://control.example.test/fresh", + Priority: PriorityPlayback, ClientUserAgent: "Infuse-Library/8.5.1", + } + service, err := NewService(ServiceOptions{ + Cache: NewCache([]Record{record}, now), Store: &fakeRecordStore{}, + Provider: &fakeProvider{}, PlexServerID: record.Key.PlexServerID, + BackgroundUserAgent: "Infuse-Library/8.5.1", Now: func() time.Time { return now }, + }) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := service.Close(ctx); err != nil { + b.Error(err) + } + }) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if result := service.Offer(request); result.Disposition != SubmitFreshCache || result.Err != nil { + b.Fatalf("Offer() = %#v", result) + } + } +} + +func BenchmarkMediaInfoServiceOfferJoin(b *testing.B) { + prober := &blockingProber{started: make(chan string, 1), release: make(chan struct{})} + service, err := NewService(ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, + Provider: &fakeProvider{prober: prober}, PlexServerID: "server", + BackgroundUserAgent: "Infuse-Library/8.5.1", BackgroundInterval: time.Millisecond, + }) + if err != nil { + b.Fatal(err) + } + request := testRequest("benchmark-join", PriorityPlayback) + if result := service.Offer(request); result.Err != nil { + b.Fatal(result.Err) + } + <-prober.started + b.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := service.Close(ctx); err != nil { + b.Error(err) + } + }) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if result := service.Offer(request); result.Disposition != SubmitJoinedExistingFlight || result.Err != nil { + b.Fatalf("Offer() = %#v", result) + } + } +} + +func BenchmarkFingerprintSTRMTarget(b *testing.B) { + const target = "http://mediavault:7811/redirect/pickcode/movie.mkv" + b.ReportAllocs() + for range b.N { + if _, err := FingerprintSTRMTarget(target); err != nil { + b.Fatal(err) + } + } +} + func BenchmarkMediaInfoL1PutAtCapacity(b *testing.B) { now := time.Now() records := make([]Record, defaultCacheEntries) diff --git a/internal/mediainfo/cache.go b/internal/mediainfo/cache.go index b694441..4781890 100644 --- a/internal/mediainfo/cache.go +++ b/internal/mediainfo/cache.go @@ -191,6 +191,14 @@ func (cache *Cache) Len() int { return cache.records.Len() } +// Purge removes every in-memory record. Durable records are managed by the +// store so maintenance can reset both cache tiers under one service boundary. +func (cache *Cache) Purge() { + if cache != nil { + cache.records.Purge() + } +} + func cloneRecord(record Record) Record { cloned := record cloned.Media.Streams = append([]Stream(nil), record.Media.Streams...) diff --git a/internal/mediainfo/ffprobe.go b/internal/mediainfo/ffprobe.go index e4105ff..80563e3 100644 --- a/internal/mediainfo/ffprobe.go +++ b/internal/mediainfo/ffprobe.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "net/url" "os/exec" "strconv" @@ -18,6 +19,7 @@ const ( defaultProbeSize = 8 << 20 defaultAnalyzeDuration = 5 * time.Second defaultProbeOutputLimit = 2 << 20 + defaultSizeProbeTimeout = 2 * time.Second ) var ( @@ -49,6 +51,7 @@ type FFProber struct { probeSize int64 analyzeDuration time.Duration outputLimit int64 + sizeClient *http.Client } // NewFFProber resolves the ffprobe binary and validates all resource limits. @@ -83,6 +86,7 @@ func NewFFProber(options FFProbeOptions) (*FFProber, error) { probeSize: probeSize, analyzeDuration: analyzeDuration, outputLimit: outputLimit, + sizeClient: newSizeProbeClient(), }, nil } @@ -111,7 +115,8 @@ func (prober *FFProber) Probe(ctx context.Context, directURL, userAgent string) stdout := &limitedBuffer{limit: prober.outputLimit, cancel: cancelCommand} stderr := &limitedBuffer{limit: 64 << 10, cancel: cancelCommand} - command := exec.CommandContext(commandCtx, prober.binary, prober.arguments(parsed.String(), userAgent)...) + target := parsed.String() + command := exec.CommandContext(commandCtx, prober.binary, prober.arguments(target, userAgent)...) command.Stdout = stdout command.Stderr = stderr if err := command.Run(); err != nil { @@ -133,9 +138,96 @@ func (prober *FFProber) Probe(ctx context.Context, directURL, userAgent string) if err != nil { return Media{}, err } + if media.Size <= 0 { + if size, ok := prober.probeRemoteSize(probeCtx, target, userAgent); ok { + media.Size = size + } + } return media, nil } +func newSizeProbeClient() *http.Client { + return &http.Client{ + Timeout: defaultSizeProbeTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +// probeRemoteSize asks only for the first byte so a format with missing +// ffprobe size can still expose its authoritative Content-Range total. The +// body is deliberately never read because the media path must stay outside +// the gateway. +func (prober *FFProber) probeRemoteSize(ctx context.Context, directURL, userAgent string) (int64, bool) { + if ctx == nil { + ctx = context.Background() + } + sizeCtx, cancel := context.WithTimeout(ctx, defaultSizeProbeTimeout) + defer cancel() + request, err := http.NewRequestWithContext(sizeCtx, http.MethodGet, directURL, nil) + if err != nil { + return 0, false + } + request.Header.Set("Range", "bytes=0-0") + request.Header.Set("User-Agent", userAgent) + + client := prober.sizeClient + if client == nil { + client = newSizeProbeClient() + } + response, err := client.Do(request) + if err != nil || response == nil || response.Body == nil { + return 0, false + } + defer response.Body.Close() + if response.StatusCode != http.StatusPartialContent { + return 0, false + } + return contentRangeTotal(response.Header.Get("Content-Range")) +} + +func contentRangeTotal(value string) (int64, bool) { + fields := strings.Fields(value) + if len(fields) != 2 || !strings.EqualFold(fields[0], "bytes") { + return 0, false + } + rangeAndTotal := strings.Split(fields[1], "/") + if len(rangeAndTotal) != 2 || rangeAndTotal[1] == "*" { + return 0, false + } + bounds := strings.Split(rangeAndTotal[0], "-") + if len(bounds) != 2 { + return 0, false + } + start, ok := nonNegativeInt64(bounds[0]) + if !ok { + return 0, false + } + end, ok := nonNegativeInt64(bounds[1]) + if !ok { + return 0, false + } + total, ok := nonNegativeInt64(rangeAndTotal[1]) + if !ok || total == 0 || start > end || end >= total { + return 0, false + } + return total, true +} + +func nonNegativeInt64(value string) (int64, bool) { + if value == "" { + return 0, false + } + for index := 0; index < len(value); index++ { + if !isASCIIDigit(value[index]) { + return 0, false + } + } + parsed, err := strconv.ParseInt(value, 10, 64) + return parsed, err == nil +} + func (prober *FFProber) arguments(directURL, userAgent string) []string { showEntries := strings.Join([]string{ "format=format_name,format_long_name,start_time,duration,size,bit_rate", @@ -333,6 +425,9 @@ func normalizeStream(raw ffprobeStream) Stream { if raw.BitsPerRawSample.integer() > int64(bitDepth) { bitDepth = int(raw.BitsPerRawSample.integer()) } + if bitDepth == 0 { + bitDepth = pixelFormatBitDepth(raw.PixelFormat) + } stream := Stream{ Index: raw.Index, Type: strings.ToLower(raw.CodecType), @@ -378,6 +473,60 @@ func normalizeStream(raw ffprobeStream) Stream { return stream } +// pixelFormatBitDepth recovers the nominal component depth from common +// ffmpeg pixel-format spellings when ffprobe omits both bits_per_sample and +// bits_per_raw_sample. It intentionally returns zero for ambiguous formats. +func pixelFormatBitDepth(value string) int { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return 0 + } + if strings.HasPrefix(value, "gray") { + if depth := pixelFormatDigits(value[len("gray"):]); depth > 0 { + return depth + } + } + for index := 0; index < len(value); index++ { + if value[index] != 'p' { + continue + } + if index+1 == len(value) { + if strings.HasPrefix(value, "yuv") || strings.HasPrefix(value, "gbr") || strings.HasPrefix(value, "rgb") { + return 8 + } + continue + } + if value[index+1] == '0' && index+3 < len(value) && isASCIIDigit(value[index+2]) && isASCIIDigit(value[index+3]) { + if depth := pixelFormatDigits(value[index+1 : index+4]); depth > 0 { + return depth + } + } + if depth := pixelFormatDigits(value[index+1:]); depth > 0 { + return depth + } + } + return 0 +} + +func pixelFormatDigits(value string) int { + end := 0 + for end < len(value) && isASCIIDigit(value[end]) { + end++ + } + if end == 0 { + return 0 + } + depth, err := strconv.Atoi(value[:end]) + if err != nil || depth < 8 || depth > 32 { + return 0 + } + return depth +} + +func isASCIIDigit(value byte) bool { + return value >= '0' && value <= '9' +} + func normalizeDisposition(raw ffprobeDisposition) Disposition { return Disposition{ Default: raw.Default != 0, Dub: raw.Dub != 0, Original: raw.Original != 0, @@ -436,7 +585,7 @@ func hdrFormat(stream Stream) string { } func applyMediaSummary(media *Media, stream Stream) { - if media == nil { + if media == nil || !primaryStream(stream) { return } switch stream.Type { @@ -465,6 +614,9 @@ func mediaComplete(media Media) bool { } mediaStreams := 0 for _, stream := range media.Streams { + if !primaryStream(stream) { + continue + } switch stream.Type { case "video": mediaStreams++ @@ -485,6 +637,18 @@ func mediaComplete(media Media) bool { return mediaStreams > 0 } +func primaryStream(stream Stream) bool { + if stream.Disposition.AttachedPicture { + return false + } + switch stream.Type { + case "video", "audio", "subtitle": + return true + default: + return false + } +} + func normalizeContainer(formatName string) string { formats := strings.Split(strings.ToLower(formatName), ",") for _, format := range formats { diff --git a/internal/mediainfo/ffprobe_test.go b/internal/mediainfo/ffprobe_test.go index cd91b5f..e399731 100644 --- a/internal/mediainfo/ffprobe_test.go +++ b/internal/mediainfo/ffprobe_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "sync/atomic" "testing" "time" ) @@ -101,6 +102,60 @@ func TestParseFFProbePreservesPlexRelevantFields(t *testing.T) { } } +func TestParseFFProbeDoesNotInferCodecFieldsFromDolbyVisionProfile(t *testing.T) { + body := []byte(`{ + "streams": [ + { + "index": 0, + "codec_name": "hevc", + "codec_type": "video", + "width": 3840, + "height": 2160, + "side_data_list": [ + {"side_data_type":"DOVI configuration record","dv_version_major":1,"dv_version_minor":0,"dv_profile":5,"dv_level":6,"rpu_present_flag":1,"el_present_flag":0,"bl_present_flag":1,"dv_bl_signal_compatibility_id":0} + ] + } + ], + "format":{"format_name":"matroska","duration":"60"} +}`) + + media, err := parseFFProbe(body) + if err != nil { + t.Fatal(err) + } + if len(media.Streams) != 1 { + t.Fatalf("streams = %#v", media.Streams) + } + video := media.Streams[0] + if video.DolbyVision == nil || video.DolbyVision.Profile != 5 || video.HDRFormat != "dolby_vision" { + t.Fatalf("Dolby Vision fields = %#v", video) + } + if video.Profile != "" || video.BitDepth != 0 || video.PixelFormat != "" || + video.ColorSpace != "" || video.ColorRange != "" || + video.ColorPrimaries != "" || video.ColorTransfer != "" { + t.Fatalf("codec or color fields were inferred from Dolby Vision profile = %#v", video) + } +} + +func TestParseFFProbeInfersBitDepthFromPixelFormat(t *testing.T) { + body := []byte(`{ + "streams": [ + {"index":0,"codec_name":"hevc","codec_type":"video","width":3840,"height":2160,"pix_fmt":"yuv420p10le"}, + {"index":1,"codec_name":"hevc","codec_type":"video","width":1920,"height":1080,"pix_fmt":"p010le"}, + {"index":2,"codec_name":"h264","codec_type":"video","width":1280,"height":720,"pix_fmt":"yuv420p"} + ], + "format":{"format_name":"matroska","duration":"60"} +}`) + + media, err := parseFFProbe(body) + if err != nil { + t.Fatal(err) + } + if len(media.Streams) != 3 || media.Streams[0].BitDepth != 10 || media.Streams[1].BitDepth != 10 || media.Streams[2].BitDepth != 8 { + t.Fatalf("inferred bit depths = %#v", media.Streams) + } +} + func TestParseFFProbeRejectsIncompleteVideo(t *testing.T) { _, err := parseFFProbe([]byte(`{ "streams":[{"index":0,"codec_type":"video","codec_name":"hevc"}], @@ -162,6 +217,90 @@ func TestFFProberAgainstHTTPMedia(t *testing.T) { } } +func TestFFProberFillsMissingSizeFromContentRange(t *testing.T) { + transport := &rangeProbeTransport{status: http.StatusPartialContent, contentRange: "bytes 0-0/123456"} + prober := newOutputScriptProber(t, completeFFProbeJSONWithoutSize, &http.Client{Transport: transport}) + + media, err := prober.Probe(t.Context(), "https://cdn.example.test/movie.mkv", "Infuse-Library/8.4.4") + if err != nil { + t.Fatal(err) + } + if media.Size != 123456 { + t.Fatalf("media size = %d", media.Size) + } + if transport.calls.Load() != 1 || transport.request == nil { + t.Fatalf("range requests = %d request=%#v", transport.calls.Load(), transport.request) + } + if transport.request.Method != http.MethodGet || + transport.request.Header.Get("Range") != "bytes=0-0" || + transport.request.Header.Get("User-Agent") != "Infuse-Library/8.4.4" { + t.Fatalf("range request = %#v", transport.request) + } +} + +func TestFFProberSizeFallbackFailsOpenForInvalidResponses(t *testing.T) { + tests := []struct { + name string + status int + contentRange string + }{ + {name: "full response", status: http.StatusOK, contentRange: "bytes 0-0/123456"}, + {name: "missing total", status: http.StatusPartialContent, contentRange: "bytes 0-0/*"}, + {name: "invalid unit", status: http.StatusPartialContent, contentRange: "octets 0-0/123456"}, + {name: "invalid range", status: http.StatusPartialContent, contentRange: "bytes 0-0"}, + {name: "zero total", status: http.StatusPartialContent, contentRange: "bytes 0-0/0"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + transport := &rangeProbeTransport{status: test.status, contentRange: test.contentRange} + prober := newOutputScriptProber(t, completeFFProbeJSONWithoutSize, &http.Client{Transport: transport}) + media, err := prober.Probe(t.Context(), "https://cdn.example.test/movie.mkv", "Infuse-Library/8.4.4") + if err != nil { + t.Fatal(err) + } + if media.Size != 0 { + t.Fatalf("invalid size was accepted = %d", media.Size) + } + if transport.calls.Load() != 1 { + t.Fatalf("range requests = %d", transport.calls.Load()) + } + }) + } +} + +func TestFFProberSizeFallbackTimeoutFailsOpen(t *testing.T) { + transport := &deadlineErrorTransport{} + prober := newOutputScriptProber(t, completeFFProbeJSONWithoutSize, &http.Client{Transport: transport}) + media, err := prober.Probe(t.Context(), "https://cdn.example.test/movie.mkv", "Infuse-Library/8.4.4") + if err != nil { + t.Fatal(err) + } + if media.Size != 0 { + t.Fatalf("timeout changed size = %d", media.Size) + } + if transport.calls.Load() != 1 || transport.deadline.IsZero() { + t.Fatalf("timeout request calls=%d deadline=%s", transport.calls.Load(), transport.deadline) + } + if transport.deadline.After(transport.observedAt.Add(defaultSizeProbeTimeout + 100*time.Millisecond)) { + t.Fatalf("size probe deadline = %s, exceeds %s cap", transport.deadline.Sub(transport.observedAt), defaultSizeProbeTimeout) + } +} + +func TestFFProberDoesNotRangeProbeExistingSize(t *testing.T) { + transport := &rangeProbeTransport{status: http.StatusPartialContent, contentRange: "bytes 0-0/123456"} + prober := newOutputScriptProber(t, completeFFProbeJSONWithSize, &http.Client{Transport: transport}) + media, err := prober.Probe(t.Context(), "https://cdn.example.test/movie.mkv", "Infuse-Library/8.4.4") + if err != nil { + t.Fatal(err) + } + if media.Size != 987654 { + t.Fatalf("existing size changed = %d", media.Size) + } + if transport.calls.Load() != 0 { + t.Fatalf("unexpected range requests = %d", transport.calls.Load()) + } +} + func TestFFProberPreservesCancellation(t *testing.T) { prober := newScriptProber(t, "exec sleep 5", 5*time.Second, defaultProbeOutputLimit) ctx, cancel := context.WithCancel(t.Context()) @@ -203,3 +342,69 @@ func newScriptProber(t *testing.T, body string, timeout time.Duration, outputLim } return prober } + +const ( + completeFFProbeJSONWithoutSize = `{"streams":[{"index":0,"codec_name":"hevc","codec_type":"video","width":1920,"height":1080}],"format":{"format_name":"matroska","duration":"60"}}` + completeFFProbeJSONWithSize = `{"streams":[{"index":0,"codec_name":"hevc","codec_type":"video","width":1920,"height":1080}],"format":{"format_name":"matroska","duration":"60","size":"987654"}}` +) + +func newOutputScriptProber(t *testing.T, output string, client *http.Client) *FFProber { + t.Helper() + path := filepath.Join(t.TempDir(), "ffprobe-output-test") + script := "#!/bin/sh\nprintf '%s' '" + output + "'\n" + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + prober, err := NewFFProber(FFProbeOptions{ + Binary: path, Timeout: defaultProbeTimeout, ProbeSize: defaultProbeSize, + AnalyzeDuration: defaultAnalyzeDuration, OutputLimit: defaultProbeOutputLimit, + }) + if err != nil { + t.Fatal(err) + } + prober.sizeClient = client + return prober +} + +type rangeProbeTransport struct { + calls atomic.Int64 + request *http.Request + status int + contentRange string +} + +func (transport *rangeProbeTransport) RoundTrip(request *http.Request) (*http.Response, error) { + transport.calls.Add(1) + transport.request = request.Clone(request.Context()) + header := make(http.Header) + if transport.contentRange != "" { + header.Set("Content-Range", transport.contentRange) + } + return &http.Response{ + StatusCode: transport.status, + Header: header, + Body: unreadableResponseBody{}, + Request: request, + }, nil +} + +type deadlineErrorTransport struct { + calls atomic.Int64 + deadline time.Time + observedAt time.Time +} + +func (transport *deadlineErrorTransport) RoundTrip(request *http.Request) (*http.Response, error) { + transport.calls.Add(1) + transport.observedAt = time.Now() + transport.deadline, _ = request.Context().Deadline() + return nil, context.DeadlineExceeded +} + +type unreadableResponseBody struct{} + +func (unreadableResponseBody) Read([]byte) (int, error) { + panic("media response body was read") +} + +func (unreadableResponseBody) Close() error { return nil } diff --git a/internal/mediainfo/model.go b/internal/mediainfo/model.go index 5ebda19..10ad575 100644 --- a/internal/mediainfo/model.go +++ b/internal/mediainfo/model.go @@ -19,8 +19,10 @@ const ( SchemaVersion = 1 // ProviderMediaVaultFFProbe identifies the redirect plus bounded ffprobe path. ProviderMediaVaultFFProbe = "mediavault-ffprobe" - // ProviderRevisionFFProbeJSONV1 identifies the normalized ffprobe contract. - ProviderRevisionFFProbeJSONV1 = "ffprobe-json-v1" + // ProviderRevisionFFProbeJSONV3 identifies the normalized ffprobe contract. + // It includes the bounded Content-Range size fallback and therefore must not + // reuse records produced by the earlier interpretation. + ProviderRevisionFFProbeJSONV3 = "ffprobe-json-v3" maxSTRMFingerprintBytes = 64 << 10 ) diff --git a/internal/mediainfo/model_test.go b/internal/mediainfo/model_test.go index b4ba0bd..1ac2f50 100644 --- a/internal/mediainfo/model_test.go +++ b/internal/mediainfo/model_test.go @@ -174,7 +174,7 @@ func completeRecord(now time.Time) Record { }, RatingKey: "456", Provider: ProviderMediaVaultFFProbe, - ProviderRevision: ProviderRevisionFFProbeJSONV1, + ProviderRevision: ProviderRevisionFFProbeJSONV3, SchemaVersion: SchemaVersion, Media: Media{ Complete: true, diff --git a/internal/mediainfo/provider.go b/internal/mediainfo/provider.go index ea28d20..c19498d 100644 --- a/internal/mediainfo/provider.go +++ b/internal/mediainfo/provider.go @@ -61,7 +61,7 @@ func NewMediaVaultFFProbeProvider(controlResolver resolver.ControlResolver, prob // Descriptor returns the durable compatibility identity for this provider. func (*MediaVaultFFProbeProvider) Descriptor() ProviderDescriptor { - return ProviderDescriptor{Name: ProviderMediaVaultFFProbe, Revision: ProviderRevisionFFProbeJSONV1} + return ProviderDescriptor{Name: ProviderMediaVaultFFProbe, Revision: ProviderRevisionFFProbeJSONV3} } // Probe forwards only the transport headers required for deterministic diff --git a/internal/mediainfo/provider_test.go b/internal/mediainfo/provider_test.go index 9e804d4..1462ec3 100644 --- a/internal/mediainfo/provider_test.go +++ b/internal/mediainfo/provider_test.go @@ -57,7 +57,7 @@ func TestMediaVaultProviderUsesSameClientUserAgentForRedirectAndProbe(t *testing if probe.target != "https://cdn.example.test/movie.mkv" || probe.userAgent != "Infuse-Library/8.4.4" { t.Fatalf("probe target=%q User-Agent=%q", probe.target, probe.userAgent) } - if provider.Descriptor() != (ProviderDescriptor{Name: ProviderMediaVaultFFProbe, Revision: ProviderRevisionFFProbeJSONV1}) || !result.Media.Complete { + if provider.Descriptor() != (ProviderDescriptor{Name: ProviderMediaVaultFFProbe, Revision: ProviderRevisionFFProbeJSONV3}) || !result.Media.Complete { t.Fatalf("provider result = %#v", result) } } diff --git a/internal/mediainfo/scheduler_contract_test.go b/internal/mediainfo/scheduler_contract_test.go new file mode 100644 index 0000000..6056bec --- /dev/null +++ b/internal/mediainfo/scheduler_contract_test.go @@ -0,0 +1,750 @@ +package mediainfo + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + gatewaymetrics "github.com/InfinityPacer/plex-gateway/internal/metrics" +) + +type atomicClock struct { + nanos atomic.Int64 +} + +type blockingGetStore struct { + *fakeRecordStore + started chan struct{} + release chan struct{} + once sync.Once +} + +func (store *blockingGetStore) Get(ctx context.Context, key Key) (Record, bool, error) { + store.once.Do(func() { close(store.started) }) + select { + case <-ctx.Done(): + return Record{}, false, ctx.Err() + case <-store.release: + return store.fakeRecordStore.Get(ctx, key) + } +} + +func newAtomicClock(now time.Time) *atomicClock { + clock := &atomicClock{} + clock.nanos.Store(now.UnixNano()) + return clock +} + +func (clock *atomicClock) Now() time.Time { + return time.Unix(0, clock.nanos.Load()).UTC() +} + +func (clock *atomicClock) Advance(duration time.Duration) { + clock.nanos.Add(duration.Nanoseconds()) +} + +func TestServiceOfferIsMemoryOnly(t *testing.T) { + store := &fakeRecordStore{} + prober := &blockingProber{started: make(chan string, 2), release: make(chan struct{}, 2)} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: store, + Provider: &fakeProvider{prober: prober}, PlexServerID: "server", + }) + + if result := service.Offer(testRequest("offer-blocker", PriorityPlayback)); result.Err != nil { + t.Fatal(result.Err) + } + <-prober.started + store.gets.Store(0) + result := service.Offer(testRequest("offer-memory-only", PriorityMetadata)) + if result.Disposition != SubmitNewlyQueued || result.Err != nil { + t.Fatalf("Offer() result = %#v", result) + } + if got := store.gets.Load(); got != 0 { + t.Fatalf("Offer() performed %d durable reads", got) + } + prober.release <- struct{}{} +} + +func TestServicePromotesP2ToP0InPlaceAndUsesPlaybackRequest(t *testing.T) { + prober := &blockingProber{started: make(chan string, 3), release: make(chan struct{}, 3)} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: prober}, + PlexServerID: "server", Concurrency: 1, PlaybackQueueSize: 2, + NeighborQueueSize: 2, MetadataQueueSize: 2, BackgroundInterval: time.Millisecond, + }) + active := testRequest("promotion-blocker", PriorityPlayback) + if result := service.Offer(active); result.Err != nil { + t.Fatal(result.Err) + } + if got := <-prober.started; got != "https://cdn.example.test/promotion-blocker" { + t.Fatalf("active target = %q", got) + } + + metadata := testRequest("promotion-p2", PriorityMetadata) + metadata.ClientUserAgent = "metadata-agent" + if result := service.Offer(metadata); result.Disposition != SubmitNewlyQueued || result.Err != nil { + t.Fatalf("P2 offer = %#v", result) + } + playback := metadata + playback.Priority = PriorityPlayback + playback.RatingKey = "playback-rating" + playback.Target = "playback-target" + playback.ClientUserAgent = "playback-agent" + if result := service.Offer(playback); result.Disposition != SubmitJoinedExistingFlight || result.Err != nil { + t.Fatalf("P0 promotion = %#v", result) + } + status := service.Status() + if status.PlaybackQueued != 1 || status.MetadataQueued != 0 { + t.Fatalf("status after promotion = %#v", status) + } + + prober.release <- struct{}{} + if got := <-prober.started; got != "https://cdn.example.test/playback-target" { + t.Fatalf("promoted target = %q", got) + } + prober.release <- struct{}{} +} + +func TestServicePromotesP1ToP0WithoutAddingAFlight(t *testing.T) { + prober := &blockingProber{started: make(chan string, 3), release: make(chan struct{}, 3)} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: prober}, + PlexServerID: "server", Concurrency: 1, PlaybackQueueSize: 1, + NeighborQueueSize: 2, MetadataQueueSize: 2, BackgroundInterval: time.Millisecond, + }) + if result := service.Offer(testRequest("p1-blocker", PriorityPlayback)); result.Err != nil { + t.Fatal(result.Err) + } + <-prober.started + neighbor := testRequest("promotion-p1", PriorityNeighbor) + if result := service.Offer(neighbor); result.Err != nil { + t.Fatal(result.Err) + } + if result := service.Offer(Request{ + Key: neighbor.Key, RatingKey: "new-rating", Target: "new-target", + Priority: PriorityPlayback, ClientUserAgent: "playback-agent", + }); result.Disposition != SubmitJoinedExistingFlight || result.Err != nil { + t.Fatalf("P1 promotion = %#v", result) + } + if got := service.Status().PlaybackQueued; got != 1 { + t.Fatalf("playback queue = %d, want 1", got) + } + prober.release <- struct{}{} + if got := <-prober.started; got != "https://cdn.example.test/new-target" { + t.Fatalf("promoted target = %q", got) + } + prober.release <- struct{}{} +} + +func TestServiceP0PromotionDuringPreflightDoesNotRetrySameUserAgent(t *testing.T) { + store := &blockingGetStore{ + fakeRecordStore: &fakeRecordStore{}, started: make(chan struct{}), release: make(chan struct{}), + } + started := make(chan string, 2) + var calls atomic.Int64 + probe := proberFunc(func(_ context.Context, _ string, userAgent string) (Media, error) { + calls.Add(1) + started <- userAgent + return Media{}, errors.New("probe rejected") + }) + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: store, Provider: &fakeProvider{prober: probe}, + PlexServerID: "server", Concurrency: 1, BackgroundInterval: time.Millisecond, + }) + request := testRequest("preflight-promotion", PriorityMetadata) + request.ClientUserAgent = "metadata-agent" + if result := service.Offer(request); result.Err != nil { + t.Fatal(result.Err) + } + select { + case <-store.started: + case <-time.After(time.Second): + t.Fatal("background preflight did not start") + } + request.Priority = PriorityPlayback + request.ClientUserAgent = "playback-agent" + if result := service.Offer(request); result.Disposition != SubmitJoinedExistingFlight || result.Err != nil { + t.Fatalf("P0 promotion = %#v", result) + } + close(store.release) + select { + case got := <-started: + if got != "playback-agent" { + t.Fatalf("probe User-Agent = %q", got) + } + case <-time.After(time.Second): + t.Fatal("promoted probe did not start") + } + waitFor(t, func() bool { return service.Status().ActiveProbes == 0 }) + select { + case got := <-started: + t.Fatalf("unexpected same-User-Agent retry with %q", got) + case <-time.After(50 * time.Millisecond): + } + if got := calls.Load(); got != 1 { + t.Fatalf("probe calls = %d, want 1", got) + } +} + +func TestServicePendingTTLExpiresBeforeProviderAndDoesNotNegativeCache(t *testing.T) { + clock := newAtomicClock(time.Unix(1_800_000_000, 0)) + prober := &blockingProber{started: make(chan string, 2), release: make(chan struct{}, 2)} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, clock.Now()), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: prober}, + PlexServerID: "server", Concurrency: 1, PendingTTL: 5 * time.Minute, + BackgroundInterval: time.Millisecond, Now: clock.Now, + }) + if result := service.Offer(testRequest("ttl-blocker", PriorityPlayback)); result.Err != nil { + t.Fatal(result.Err) + } + <-prober.started + pending := testRequest("ttl-expired", PriorityMetadata) + flight, err := service.begin(pending) + if err != nil { + t.Fatal(err) + } + clock.Advance(6 * time.Minute) + prober.release <- struct{}{} + select { + case <-flight.done: + case <-time.After(time.Second): + t.Fatal("expired flight did not finish") + } + if !errors.Is(flight.err, ErrPendingExpired) { + t.Fatalf("expired flight error = %v", flight.err) + } + if got := service.provider.(*fakeProvider).calls.Load(); got != 1 { + t.Fatalf("provider calls = %d, want blocker only", got) + } + service.mu.Lock() + negative := len(service.negative) + service.mu.Unlock() + if negative != 0 { + t.Fatalf("negative entries after expiration = %d", negative) + } +} + +func TestServiceP0WakesWorkerWaitingForBackgroundInterval(t *testing.T) { + prober := &blockingProber{started: make(chan string, 4), release: make(chan struct{}, 4)} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: prober}, + PlexServerID: "server", Concurrency: 1, BackgroundInterval: 200 * time.Millisecond, + }) + if result := service.Offer(testRequest("interval-first", PriorityNeighbor)); result.Err != nil { + t.Fatal(result.Err) + } + if got := <-prober.started; got != "https://cdn.example.test/interval-first" { + t.Fatalf("first target = %q", got) + } + if result := service.Offer(testRequest("interval-waiting", PriorityNeighbor)); result.Err != nil { + t.Fatal(result.Err) + } + prober.release <- struct{}{} + + select { + case got := <-prober.started: + t.Fatalf("background started early with %q", got) + case <-time.After(30 * time.Millisecond): + } + urgent := testRequest("interval-playback", PriorityPlayback) + if result := service.Offer(urgent); result.Err != nil { + t.Fatal(result.Err) + } + select { + case got := <-prober.started: + if got != "https://cdn.example.test/interval-playback" { + t.Fatalf("urgent target = %q", got) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("P0 did not wake the worker") + } + prober.release <- struct{}{} + select { + case got := <-prober.started: + if got != "https://cdn.example.test/interval-waiting" { + t.Fatalf("waiting target = %q", got) + } + case <-time.After(time.Second): + t.Fatal("background job did not resume after interval") + } + prober.release <- struct{}{} +} + +func TestServiceP0AdmissionIsIndependentFromBackgroundCapacity(t *testing.T) { + prober := &blockingProber{started: make(chan string, 4), release: make(chan struct{}, 4)} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: prober}, + PlexServerID: "server", Concurrency: 1, PlaybackQueueSize: 1, + NeighborQueueSize: 1, MetadataQueueSize: 1, BackgroundInterval: time.Hour, + }) + if result := service.Offer(testRequest("capacity-blocker", PriorityPlayback)); result.Err != nil { + t.Fatal(result.Err) + } + <-prober.started + if result := service.Offer(testRequest("capacity-neighbor", PriorityNeighbor)); result.Err != nil { + t.Fatal(result.Err) + } + if result := service.Offer(testRequest("capacity-metadata", PriorityMetadata)); result.Err != nil { + t.Fatal(result.Err) + } + if result := service.Offer(testRequest("capacity-playback", PriorityPlayback)); result.Err != nil { + t.Fatalf("P0 was rejected while background queues were full: %#v", result) + } + if got := service.Status().PlaybackQueued; got != 1 { + t.Fatalf("playback queue = %d, want 1", got) + } + prober.release <- struct{}{} +} + +func TestServiceActivePlaybackJoinKeepsLatestUserAgentForOneFallback(t *testing.T) { + started := make(chan string, 2) + releaseFailure := make(chan struct{}) + var calls atomic.Int64 + probe := proberFunc(func(ctx context.Context, _, userAgent string) (Media, error) { + call := calls.Add(1) + started <- userAgent + if call == 1 { + select { + case <-ctx.Done(): + return Media{}, ctx.Err() + case <-releaseFailure: + } + return Media{}, errors.New("playback User-Agent rejected") + } + return Media{ + Complete: true, Container: "mkv", DurationMS: 60_000, + Streams: []Stream{{Index: 0, Type: "video", Codec: "hevc", Width: 1920, Height: 1080}}, + }, nil + }) + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: probe}, + PlexServerID: "server", BackgroundInterval: time.Millisecond, + }) + first := testRequest("active-playback-fallback", PriorityPlayback) + first.ClientUserAgent = "playback-a" + if result := service.Offer(first); result.Err != nil { + t.Fatal(result.Err) + } + if got := <-started; got != "playback-a" { + t.Fatalf("first User-Agent = %q", got) + } + latest := first + latest.ClientUserAgent = "playback-b" + latest.Target = "latest-target" + if result := service.Offer(latest); result.Disposition != SubmitJoinedExistingFlight || result.Err != nil { + t.Fatalf("active join = %#v", result) + } + close(releaseFailure) + if got := <-started; got != "playback-b" { + t.Fatalf("fallback User-Agent = %q", got) + } + waitFor(t, func() bool { return calls.Load() == 2 && service.Status().ActiveProbes == 0 }) +} + +func TestServiceMetadataJoinDoesNotRegisterUserAgentFallback(t *testing.T) { + started := make(chan string, 1) + releaseFailure := make(chan struct{}) + var calls atomic.Int64 + probe := proberFunc(func(ctx context.Context, _, userAgent string) (Media, error) { + calls.Add(1) + started <- userAgent + select { + case <-ctx.Done(): + return Media{}, ctx.Err() + case <-releaseFailure: + return Media{}, errors.New("metadata probe rejected") + } + }) + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: probe}, + PlexServerID: "server", BackgroundInterval: time.Millisecond, + }) + first := testRequest("metadata-no-fallback", PriorityMetadata) + first.ClientUserAgent = "metadata-a" + if result := service.Offer(first); result.Err != nil { + t.Fatal(result.Err) + } + if got := <-started; got != "metadata-a" { + t.Fatalf("first User-Agent = %q", got) + } + joined := first + joined.ClientUserAgent = "metadata-b" + if result := service.Offer(joined); result.Disposition != SubmitJoinedExistingFlight || result.Err != nil { + t.Fatalf("metadata join = %#v", result) + } + close(releaseFailure) + waitFor(t, func() bool { return service.Status().ActiveProbes == 0 }) + if got := calls.Load(); got != 1 { + t.Fatalf("metadata probe calls = %d, want 1", got) + } +} + +func TestServiceWorkerSkipsFreshDurableRecord(t *testing.T) { + prober := &blockingProber{started: make(chan string, 2), release: make(chan struct{}, 2)} + store := &fakeRecordStore{} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: store, Provider: &fakeProvider{prober: prober}, + PlexServerID: "server", BackgroundInterval: time.Millisecond, + }) + blocker := testRequest("l2-blocker", PriorityPlayback) + if result := service.Offer(blocker); result.Err != nil { + t.Fatal(result.Err) + } + <-prober.started + pending := testRequest("l2-hit", PriorityMetadata) + now := time.Now().UTC() + record := completeRecord(now) + record.Key = pending.Key + record.RatingKey = pending.RatingKey + record.ExpiresAt = now.Add(time.Hour) + record.RetainUntil = now.Add(24 * time.Hour) + record.LastAccessedAt = now + store.mu.Lock() + store.records = map[string]Record{pending.Key.cacheKey(): record} + store.mu.Unlock() + flight, err := service.begin(pending) + if err != nil { + t.Fatal(err) + } + prober.release <- struct{}{} + select { + case <-flight.done: + case <-time.After(time.Second): + t.Fatal("L2 hit flight did not finish") + } + if flight.err != nil || prober.calls.Load() != 1 { + t.Fatalf("L2 hit err=%v provider_calls=%d", flight.err, prober.calls.Load()) + } +} + +func TestServiceFreshOfferRenewsAccessWithoutDurableRead(t *testing.T) { + createdAt := time.Unix(1_800_000_000, 0).UTC() + clock := newAtomicClock(createdAt.Add(2 * time.Hour)) + request := testRequest("offer-touch", PriorityPlayback) + record := completeRecord(createdAt) + record.Key = request.Key + record.RatingKey = request.RatingKey + store := &fakeRecordStore{records: map[string]Record{record.Key.cacheKey(): record}} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache([]Record{record}, createdAt), Store: store, + Provider: &fakeProvider{prober: &blockingProber{}}, PlexServerID: "server", + Now: clock.Now, TouchInterval: time.Hour, + }) + + result := service.Offer(request) + if result.Disposition != SubmitFreshCache || result.Err != nil { + t.Fatalf("Offer() = %#v", result) + } + if got := store.gets.Load(); got != 0 { + t.Fatalf("durable reads = %d", got) + } + waitFor(t, func() bool { return store.touches.Load() == 1 }) + got, found := service.cache.GetKnown(request.Key, clock.Now()) + if !found || !got.LastAccessedAt.Equal(clock.Now()) { + t.Fatalf("renewed record found=%v last_access=%s", found, got.LastAccessedAt) + } +} + +func TestServiceBackgroundColdMissReadsDurableStoreOnce(t *testing.T) { + prober := &blockingProber{started: make(chan string, 1), release: make(chan struct{}, 1)} + store := &fakeRecordStore{} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: store, Provider: &fakeProvider{prober: prober}, + PlexServerID: "server", BackgroundInterval: time.Millisecond, + }) + if result := service.Offer(testRequest("one-l2-read", PriorityNeighbor)); result.Err != nil { + t.Fatal(result.Err) + } + select { + case <-prober.started: + case <-time.After(time.Second): + t.Fatal("background provider did not start") + } + if got := store.gets.Load(); got != 1 { + t.Fatalf("durable reads = %d, want 1", got) + } + prober.release <- struct{}{} +} + +func TestServiceFreshDurableHitBypassesBackgroundInterval(t *testing.T) { + now := time.Now().UTC() + request := testRequest("l2-no-rate-limit", PriorityMetadata) + record := completeRecord(now) + record.Key = request.Key + record.RatingKey = request.RatingKey + store := &fakeRecordStore{records: map[string]Record{record.Key.cacheKey(): record}} + provider := &fakeProvider{prober: &blockingProber{}} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, now), Store: store, Provider: provider, + PlexServerID: "server", BackgroundInterval: time.Hour, + }) + flight, _, err := service.beginDetailed(request, false) + if err != nil { + t.Fatal(err) + } + select { + case <-flight.done: + case <-time.After(100 * time.Millisecond): + t.Fatal("fresh L2 hit waited for the remote background interval") + } + if flight.err != nil || provider.calls.Load() != 0 || store.gets.Load() != 1 { + t.Fatalf("flight_err=%v provider_calls=%d store_gets=%d", flight.err, provider.calls.Load(), store.gets.Load()) + } +} + +func TestServiceP0BurstWakesConfiguredWorkers(t *testing.T) { + prober := &blockingProber{started: make(chan string, 3), release: make(chan struct{}, 3)} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: prober}, + PlexServerID: "server", Concurrency: 3, PlaybackQueueSize: 3, + }) + for _, partID := range []string{"burst-a", "burst-b", "burst-c"} { + if result := service.Offer(testRequest(partID, PriorityPlayback)); result.Err != nil { + t.Fatal(result.Err) + } + } + for range 3 { + select { + case <-prober.started: + case <-time.After(200 * time.Millisecond): + t.Fatal("P0 burst did not use all configured workers") + } + } + for range 3 { + prober.release <- struct{}{} + } +} + +func TestServiceP0PromotionFailsOpenWhenP0QueueIsFull(t *testing.T) { + prober := &blockingProber{started: make(chan string, 1), release: make(chan struct{}, 1)} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: prober}, + PlexServerID: "server", Concurrency: 1, PlaybackQueueSize: 1, + NeighborQueueSize: 1, MetadataQueueSize: 1, BackgroundInterval: time.Hour, + }) + if result := service.Offer(testRequest("promotion-full-active", PriorityPlayback)); result.Err != nil { + t.Fatal(result.Err) + } + <-prober.started + if result := service.Offer(testRequest("promotion-full-p0", PriorityPlayback)); result.Err != nil { + t.Fatal(result.Err) + } + metadata := testRequest("promotion-full-target", PriorityMetadata) + if result := service.Offer(metadata); result.Err != nil { + t.Fatal(result.Err) + } + metadata.Priority = PriorityPlayback + result := service.Offer(metadata) + if result.Disposition != SubmitRejected || !errors.Is(result.Err, ErrQueueFull) { + t.Fatalf("promotion result = %#v", result) + } + if status := service.Status(); status.PlaybackQueued != 1 || status.MetadataQueued != 1 { + t.Fatalf("status after rejected promotion = %#v", status) + } + prober.release <- struct{}{} +} + +func TestServicePlaybackRetryChainAllowsOnlyOneFallback(t *testing.T) { + started := make(chan string, 3) + releases := make(chan struct{}, 2) + var calls atomic.Int64 + probe := proberFunc(func(ctx context.Context, _, userAgent string) (Media, error) { + calls.Add(1) + started <- userAgent + select { + case <-ctx.Done(): + return Media{}, ctx.Err() + case <-releases: + return Media{}, errors.New("User-Agent rejected") + } + }) + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, + Provider: &fakeProvider{prober: probe}, PlexServerID: "server", + }) + request := testRequest("retry-chain", PriorityPlayback) + request.ClientUserAgent = "playback-a" + if result := service.Offer(request); result.Err != nil { + t.Fatal(result.Err) + } + <-started + request.ClientUserAgent = "playback-b" + if result := service.Offer(request); result.Err != nil { + t.Fatal(result.Err) + } + releases <- struct{}{} + if got := <-started; got != "playback-b" { + t.Fatalf("fallback User-Agent = %q", got) + } + request.ClientUserAgent = "playback-c" + if result := service.Offer(request); result.Err != nil { + t.Fatal(result.Err) + } + releases <- struct{}{} + waitFor(t, func() bool { return service.Status().ActiveProbes == 0 }) + select { + case got := <-started: + t.Fatalf("unexpected third retry with User-Agent %q", got) + case <-time.After(50 * time.Millisecond): + } + if got := calls.Load(); got != 2 { + t.Fatalf("probe calls = %d, want 2", got) + } +} + +func TestServiceCountsRetryDroppedByFullPriorityQueue(t *testing.T) { + started := make(chan string, 2) + releaseFirst := make(chan struct{}) + var calls atomic.Int64 + probe := proberFunc(func(ctx context.Context, target, _ string) (Media, error) { + if calls.Add(1) == 1 { + started <- target + select { + case <-ctx.Done(): + return Media{}, ctx.Err() + case <-releaseFirst: + return Media{}, errors.New("first User-Agent rejected") + } + } + started <- target + return Media{ + Complete: true, Container: "mkv", DurationMS: 60_000, + Streams: []Stream{{Index: 0, Type: "video", Codec: "hevc", Width: 1920, Height: 1080}}, + }, nil + }) + registry := gatewaymetrics.New() + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, + Provider: &fakeProvider{prober: probe}, PlexServerID: "server", + Concurrency: 1, PlaybackQueueSize: 1, Metrics: registry, + }) + request := testRequest("retry-full", PriorityPlayback) + request.ClientUserAgent = "playback-a" + if result := service.Offer(request); result.Err != nil { + t.Fatal(result.Err) + } + <-started + request.ClientUserAgent = "playback-b" + if result := service.Offer(request); result.Err != nil { + t.Fatal(result.Err) + } + if result := service.Offer(testRequest("retry-full-blocker", PriorityPlayback)); result.Err != nil { + t.Fatal(result.Err) + } + close(releaseFirst) + waitFor(t, func() bool { + return registry.Snapshot().MediaInfoProbeDroppedFullTotal == 1 + }) + if got := registry.Snapshot().MediaInfoProbeDroppedFullTotal; got != 1 { + t.Fatalf("dropped full = %d, want 1", got) + } +} + +func TestServiceSupersedesQueuedBackgroundWithNewFingerprint(t *testing.T) { + for _, priority := range []Priority{PriorityNeighbor, PriorityMetadata} { + t.Run(priorityName(priority), func(t *testing.T) { + prober := &blockingProber{started: make(chan string, 3), release: make(chan struct{}, 3)} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: prober}, + PlexServerID: "server", Concurrency: 1, NeighborQueueSize: 4, + MetadataQueueSize: 4, BackgroundInterval: time.Millisecond, + }) + if result := service.Offer(testRequest("fingerprint-blocker", PriorityPlayback)); result.Err != nil { + t.Fatal(result.Err) + } + <-prober.started + old := testRequest("fingerprint-same-part", priority) + old.Key.STRMFingerprint = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + old.Target = "old-target" + oldFlight, err := service.begin(old) + if err != nil { + t.Fatal(err) + } + newer := old + newer.Key.STRMFingerprint = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + newer.Target = "new-target" + newFlight, err := service.begin(newer) + if err != nil { + t.Fatal(err) + } + select { + case <-oldFlight.done: + if !errors.Is(oldFlight.err, ErrSuperseded) { + t.Fatalf("old fingerprint error = %v", oldFlight.err) + } + case <-time.After(time.Second): + t.Fatal("old fingerprint was not superseded") + } + prober.release <- struct{}{} + if got := <-prober.started; got != "https://cdn.example.test/new-target" { + t.Fatalf("new fingerprint target = %q", got) + } + prober.release <- struct{}{} + select { + case <-newFlight.done: + case <-time.After(time.Second): + t.Fatal("new fingerprint flight did not finish") + } + }) + } +} + +func priorityName(priority Priority) string { + if priority == PriorityNeighbor { + return "neighbor" + } + return "metadata" +} + +func TestServiceTrySubmitAliasesOffer(t *testing.T) { + service := newTestService(t, &blockingProber{}, 1) + request := testRequest("try-submit", PriorityMetadata) + offered := service.Offer(request) + tried := service.TrySubmit(request) + if offered.Disposition != SubmitNewlyQueued || offered.Err != nil { + t.Fatalf("Offer() = %#v", offered) + } + if tried.Disposition != SubmitJoinedExistingFlight || tried.Err != nil { + t.Fatalf("TrySubmit() = %#v", tried) + } +} + +func TestServiceCloseCompletesAllPriorityQueues(t *testing.T) { + prober := &blockingProber{started: make(chan string, 1), release: make(chan struct{})} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: prober}, + PlexServerID: "server", Concurrency: 1, PlaybackQueueSize: 2, + NeighborQueueSize: 2, MetadataQueueSize: 2, + }) + if result := service.Offer(testRequest("close-active", PriorityPlayback)); result.Err != nil { + t.Fatal(result.Err) + } + <-prober.started + flights := make([]*flight, 0, 3) + for _, priority := range []Priority{PriorityPlayback, PriorityNeighbor, PriorityMetadata} { + flight, err := service.begin(testRequest("close-"+string(rune('a'+len(flights))), priority)) + if err != nil { + t.Fatal(err) + } + flights = append(flights, flight) + } + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + if err := service.Close(ctx); err != nil { + t.Fatal(err) + } + for _, flight := range flights { + select { + case <-flight.done: + if !errors.Is(flight.err, ErrServiceUnavailable) { + t.Fatalf("closed flight error = %v", flight.err) + } + default: + t.Fatal("queued flight was not completed during close") + } + } +} diff --git a/internal/mediainfo/service.go b/internal/mediainfo/service.go index 63c3ef7..ef4527d 100644 --- a/internal/mediainfo/service.go +++ b/internal/mediainfo/service.go @@ -12,31 +12,49 @@ import ( ) const ( - defaultRecordTTL = 30 * 24 * time.Hour - defaultRecordRetention = 180 * 24 * time.Hour - defaultNegativeTTL = 15 * time.Minute - defaultWorkerQueueSize = 256 - defaultWorkerCount = 1 - defaultNegativeLimit = 4096 - defaultTouchQueueSize = 1024 - defaultGCInterval = 24 * time.Hour - defaultStoreTimeout = 5 * time.Second - defaultTouchInterval = time.Hour + defaultRecordTTL = 30 * 24 * time.Hour + defaultRecordRetention = 180 * 24 * time.Hour + defaultNegativeTTL = 15 * time.Minute + defaultPlaybackQueueSize = 16 + defaultNeighborQueueSize = 50 + defaultMetadataQueueSize = 50 + defaultPendingTTL = 5 * time.Minute + defaultBackgroundInterval = 5 * time.Second + defaultWorkerCount = 1 + defaultNegativeLimit = 4096 + defaultRetryLimit = 256 + defaultTouchQueueSize = 1024 + defaultGCInterval = 24 * time.Hour + defaultStoreTimeout = 5 * time.Second + defaultTouchInterval = time.Hour + maxClientUserAgentBytes = 4 << 10 ) var ( - ErrServiceUnavailable = errors.New("MediaInfo service is unavailable") - ErrQueueFull = errors.New("MediaInfo queue is full") - ErrNegativeCache = errors.New("MediaInfo probe is in backoff") + ErrServiceUnavailable = errors.New("MediaInfo service is unavailable") + ErrQueueFull = errors.New("MediaInfo queue is full") + ErrRetryRegistrationFull = errors.New("MediaInfo retry registration is full") + ErrNegativeCache = errors.New("MediaInfo probe is in backoff") + ErrCacheResetInProgress = errors.New("MediaInfo cache reset is in progress") + ErrPendingExpired = errors.New("MediaInfo pending request expired") + ErrSuperseded = errors.New("MediaInfo request was superseded") ) -// Priority separates interactive misses from administrator prewarming. A -// playback-related metadata request must not wait behind season or root scans. +// Priority orders MediaInfo work by user intent. P0 playback work can bypass +// the shared background start interval; P1 and P2 are bounded background work. type Priority uint8 const ( - PriorityBackground Priority = iota - PriorityInteractive + PriorityPlayback Priority = iota + PriorityNeighbor + PriorityMetadata +) + +const ( + // Deprecated: use PriorityPlayback. + PriorityInteractive = PriorityPlayback + // Deprecated: use PriorityNeighbor. + PriorityBackground = PriorityNeighbor ) // Request describes one exact STRM control target. Target is used only for the @@ -49,16 +67,42 @@ type Request struct { ClientUserAgent string } +// SubmitDisposition explains how a non-blocking Submit request was handled. +// A joined request may be retried later with its own User-Agent when the +// shared flight fails. +type SubmitDisposition string + +const ( + SubmitFreshCache SubmitDisposition = "fresh_cache" + SubmitJoinedExistingFlight SubmitDisposition = "joined_existing_flight" + SubmitNewlyQueued SubmitDisposition = "newly_queued" + SubmitRejected SubmitDisposition = "rejected" +) + +// SubmitResult is the detailed outcome of SubmitDetailed. Err is non-nil only +// for a rejected request; accepted cache hits, joins, and queue admissions +// have a nil Err. +type SubmitResult struct { + Disposition SubmitDisposition + Err error +} + func (request Request) validate() error { if err := request.Key.Validate(); err != nil { return err } + if request.Priority > PriorityMetadata { + return errors.New("MediaInfo request priority is invalid") + } if request.Target == "" { return errors.New("STRM target is required") } if strings.ContainsAny(request.ClientUserAgent, "\r\n") { return errors.New("client User-Agent is invalid") } + if len(request.ClientUserAgent) > maxClientUserAgentBytes { + return errors.New("client User-Agent is too large") + } return nil } @@ -69,6 +113,16 @@ type RecordStore interface { Touch(context.Context, Key, time.Time, time.Time) error } +// ResetResult reports the durable effect of one hot cache reset. +type ResetResult struct { + DeletedRecords int64 + BackupPath string +} + +type recordResetStore interface { + BackupAndDeleteAll(context.Context, string, time.Time) (ResetResult, error) +} + // RecordJanitor removes records after their retention window. It is separate // from request-time storage so alternate stores need not expose SQLite details. type RecordJanitor interface { @@ -84,7 +138,13 @@ type Prober interface { type ServiceMetrics interface { IncMediaInfoCacheHits() IncMediaInfoCacheMisses() - IncMediaInfoProbeQueued() + IncMediaInfoProbeOffered(uint8) + IncMediaInfoProbeQueued(uint8) + IncMediaInfoProbeJoined(uint8) + IncMediaInfoProbePromoted(uint8) + IncMediaInfoProbeDroppedFull(uint8) + IncMediaInfoProbeDroppedExpired(uint8) + IncMediaInfoProbeSuperseded(uint8) IncMediaInfoProbeSuccess() IncMediaInfoProbeFailure() IncMediaInfoStoreFailure() @@ -96,24 +156,31 @@ type ServiceMetrics interface { // ServiceOptions owns the background analysis lifecycle. Resolver requests use // only UserAgent and identity encoding; client credentials are never inherited. type ServiceOptions struct { - Cache *Cache - Store RecordStore - Janitor RecordJanitor - Provider Provider - PlexServerID string - Logger *slog.Logger - Metrics ServiceMetrics - Concurrency int + Cache *Cache + Store RecordStore + Janitor RecordJanitor + Provider Provider + PlexServerID string + Logger *slog.Logger + Metrics ServiceMetrics + Concurrency int + // Deprecated: use PlaybackQueueSize. InteractiveQueueSize int - BackgroundQueueSize int - ProbeTimeout time.Duration - RecordTTL time.Duration - RecordRetention time.Duration - NegativeTTL time.Duration - BackgroundUserAgent string - GCInterval time.Duration - TouchInterval time.Duration - Now func() time.Time + // Deprecated: use NeighborQueueSize. + BackgroundQueueSize int + ProbeTimeout time.Duration + RecordTTL time.Duration + RecordRetention time.Duration + NegativeTTL time.Duration + PendingTTL time.Duration + BackgroundInterval time.Duration + PlaybackQueueSize int + NeighborQueueSize int + MetadataQueueSize int + BackgroundUserAgent string + GCInterval time.Duration + TouchInterval time.Duration + Now func() time.Time } // Service schedules bounded MediaInfo work independently from client request @@ -121,6 +188,7 @@ type ServiceOptions struct { type Service struct { cache *Cache store RecordStore + resetStore recordResetStore janitor RecordJanitor provider Provider providerDescriptor ProviderDescriptor @@ -131,6 +199,8 @@ type Service struct { recordTTL time.Duration recordRetention time.Duration negativeTTL time.Duration + pendingTTL time.Duration + backgroundInterval time.Duration negativeLimit int backgroundUserAgent string gcInterval time.Duration @@ -142,17 +212,29 @@ type Service struct { workers sync.WaitGroup workerDone chan struct{} touches chan recordTouch + wake chan struct{} - mu sync.Mutex - condition *sync.Cond - interactive []*job - background []*job - interactiveLimit int - backgroundLimit int - flights map[string]*flight - negative map[string]time.Time - closed bool - active atomic.Int64 + mu sync.Mutex + playback []*job + neighbor []*job + metadata []*job + retryPlayback []*job + retryNeighbor []*job + retryMetadata []*job + playbackLimit int + neighborLimit int + metadataLimit int + nextBackgroundAt time.Time + flights map[string]*flight + negative map[string]time.Time + retryRegistrations int + retryLimit int + retrySequence uint64 + closed bool + active atomic.Int64 + generation atomic.Uint64 + resetting atomic.Bool + resetMu sync.RWMutex } type recordTouch struct { @@ -163,17 +245,30 @@ type recordTouch struct { } type flight struct { - done chan struct{} - job *job - userAgent string - record Record - err error + done chan struct{} + job *job + request Request + priority Priority + userAgent string + retryWaiters []retryRegistration + retryDepth uint8 + record Record + err error +} + +type retryRegistration struct { + request Request + userAgentKey string + sequence uint64 } type job struct { - request Request - flight *flight - claimed bool + request Request + flight *flight + generation uint64 + expiresAt time.Time + cacheChecked bool + claimed bool } // NewService starts the configured worker count after validating every @@ -186,13 +281,31 @@ func NewService(options ServiceOptions) (*Service, error) { if concurrency <= 0 { concurrency = defaultWorkerCount } - interactiveSize := options.InteractiveQueueSize - if interactiveSize <= 0 { - interactiveSize = defaultWorkerQueueSize + playbackSize := options.PlaybackQueueSize + if playbackSize <= 0 { + playbackSize = options.InteractiveQueueSize + } + if playbackSize <= 0 { + playbackSize = defaultPlaybackQueueSize + } + neighborSize := options.NeighborQueueSize + if neighborSize <= 0 { + neighborSize = options.BackgroundQueueSize + } + if neighborSize <= 0 { + neighborSize = defaultNeighborQueueSize + } + metadataSize := options.MetadataQueueSize + if metadataSize <= 0 { + metadataSize = defaultMetadataQueueSize + } + pendingTTL := options.PendingTTL + if pendingTTL <= 0 { + pendingTTL = defaultPendingTTL } - backgroundSize := options.BackgroundQueueSize - if backgroundSize <= 0 { - backgroundSize = defaultWorkerQueueSize + backgroundInterval := options.BackgroundInterval + if backgroundInterval <= 0 { + backgroundInterval = defaultBackgroundInterval } probeTimeout := options.ProbeTimeout if probeTimeout <= 0 { @@ -217,6 +330,9 @@ func NewService(options ServiceOptions) (*Service, error) { if backgroundUserAgent == "" || strings.ContainsAny(backgroundUserAgent, "\r\n") { return nil, errors.New("MediaInfo background User-Agent is invalid") } + if len(backgroundUserAgent) > maxClientUserAgentBytes { + return nil, errors.New("MediaInfo background User-Agent is too large") + } gcInterval := options.GCInterval if gcInterval <= 0 { gcInterval = defaultGCInterval @@ -247,15 +363,17 @@ func NewService(options ServiceOptions) (*Service, error) { provider: options.Provider, providerDescriptor: providerDescriptor, plexServerID: plexServerID, logger: logger, metrics: options.Metrics, probeTimeout: probeTimeout, recordTTL: recordTTL, recordRetention: recordRetention, - negativeTTL: negativeTTL, negativeLimit: defaultNegativeLimit, + negativeTTL: negativeTTL, pendingTTL: pendingTTL, + backgroundInterval: backgroundInterval, negativeLimit: defaultNegativeLimit, + retryLimit: defaultRetryLimit, backgroundUserAgent: backgroundUserAgent, gcInterval: gcInterval, touchInterval: touchInterval, now: now, ctx: ctx, cancel: cancel, workerDone: make(chan struct{}), - touches: make(chan recordTouch, defaultTouchQueueSize), - interactiveLimit: interactiveSize, backgroundLimit: backgroundSize, + touches: make(chan recordTouch, defaultTouchQueueSize), wake: make(chan struct{}, 1), + playbackLimit: playbackSize, neighborLimit: neighborSize, metadataLimit: metadataSize, flights: make(map[string]*flight), negative: make(map[string]time.Time), } - service.condition = sync.NewCond(&service.mu) + service.resetStore, _ = options.Store.(recordResetStore) for index := 0; index < concurrency; index++ { service.workers.Add(1) go service.worker() @@ -273,15 +391,33 @@ func NewService(options ServiceOptions) (*Service, error) { return service, nil } -// Get returns one exact fresh L1 record and renews its retention asynchronously. +// Get returns one exact retained record from L1 or the durable store without +// scheduling analysis or revalidation. Cache-only consumers can therefore use +// known-good stale data without turning enumeration into remote probe work. func (service *Service) Get(key Key) (Record, bool) { if service == nil { return Record{}, false } - now := service.now().UTC() - record, ok := service.cache.Get(key, now) + return service.GetContext(service.ctx, key) +} + +// GetContext performs a cache-only lookup bounded by the caller's response +// deadline and never schedules remote analysis. +func (service *Service) GetContext(ctx context.Context, key Key) (Record, bool) { + if service == nil { + return Record{}, false + } + if ctx == nil { + ctx = context.Background() + } + if strings.TrimSpace(key.PlexServerID) == "" { + key.PlexServerID = service.plexServerID + } + if key.PlexServerID != service.plexServerID { + return Record{}, false + } + record, ok, _ := service.lookup(ctx, key) if ok { - record = service.touchCached(record, now) service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoCacheHits() }) } else { service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoCacheMisses() }) @@ -289,6 +425,32 @@ func (service *Service) Get(key Key) (Record, bool) { return record, ok } +// GetMemory returns one exact retained L1 record without durable storage I/O or +// remote work. Playback-policy callers use it to keep decision and Part paths +// independent from SQLite latency. +func (service *Service) GetMemory(key Key) (Record, bool) { + if service == nil || service.resetting.Load() { + return Record{}, false + } + if strings.TrimSpace(key.PlexServerID) == "" { + key.PlexServerID = service.plexServerID + } + if key.PlexServerID != service.plexServerID { + return Record{}, false + } + service.resetMu.RLock() + defer service.resetMu.RUnlock() + if service.resetting.Load() { + return Record{}, false + } + now := service.now().UTC() + record, found := service.cache.GetKnown(key, now) + if !found || !service.compatible(record) { + return Record{}, false + } + return service.touchCached(record, now), true +} + // Ensure returns a cached result or waits for a shared job until ctx ends. The // worker uses the service context, so a client disconnect or five-second wait // expiry does not cancel work needed by another client or a later request. @@ -309,12 +471,13 @@ func (service *Service) Ensure(ctx context.Context, request Request) (Record, er service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoCacheHits() }) if !fresh { revalidation := request - revalidation.Priority = PriorityBackground + revalidation.Priority = PriorityNeighbor _, _ = service.begin(revalidation) } return record, nil } service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoCacheMisses() }) + retries := 0 for { if err := ctx.Err(); err != nil { return Record{}, err @@ -327,10 +490,11 @@ func (service *Service) Ensure(ctx context.Context, request Request) (Record, er case <-ctx.Done(): return Record{}, ctx.Err() case <-flight.done: - if flight.err != nil && flight.userAgent != request.ClientUserAgent { + if flight.err != nil && flight.userAgent != request.ClientUserAgent && retries == 0 { // Successful technical metadata is UA-independent and remains // shared. A failed provider attempt is UA-sensitive, so a waiter - // with another client identity may retry within its own deadline. + // with another client identity may make one bounded retry. + retries++ continue } return cloneRecord(flight.record), flight.err @@ -338,24 +502,80 @@ func (service *Service) Ensure(ctx context.Context, request Request) (Record, er } } -// Submit schedules work without creating a request-bound waiter. +// Submit schedules work without creating a request-bound waiter. It retains +// the original error-only contract; use SubmitDetailed when the admission +// disposition is needed. func (service *Service) Submit(request Request) error { + return service.SubmitDetailed(request).Err +} + +// Offer attempts a best-effort admission using only in-memory state. It is +// safe for response paths because it never reads the durable store or waits +// for a worker; a worker performs the authoritative L2 check before probing. +func (service *Service) Offer(request Request) SubmitResult { if service == nil { - return ErrServiceUnavailable + return SubmitResult{Disposition: SubmitRejected, Err: ErrServiceUnavailable} } request, err := service.normalizeRequest(request) if err != nil { - return err + return SubmitResult{Disposition: SubmitRejected, Err: err} + } + if _, ok := service.memoryFresh(request.Key); ok { + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoCacheHits() }) + return SubmitResult{Disposition: SubmitFreshCache} + } + _, disposition, err := service.beginDetailed(request, true) + if err != nil { + return SubmitResult{Disposition: SubmitRejected, Err: err} + } + return SubmitResult{Disposition: disposition} +} + +// TrySubmit is the concise alias used by non-blocking callers. +func (service *Service) TrySubmit(request Request) SubmitResult { + return service.Offer(request) +} + +// SubmitDetailed schedules work and reports whether the request used a fresh +// cache record, joined an existing flight, was newly queued, or was rejected. +// A request that joins a different-User-Agent flight is registered once, with +// a bounded per-service budget, so a provider failure can hand the flight to +// that User-Agent without probing blindly with the same identity. +func (service *Service) SubmitDetailed(request Request) SubmitResult { + if service == nil { + return SubmitResult{Disposition: SubmitRejected, Err: ErrServiceUnavailable} + } + request, err := service.normalizeRequest(request) + if err != nil { + return SubmitResult{Disposition: SubmitRejected, Err: err} } _, found, fresh := service.lookup(service.ctx, request.Key) if found && fresh { - return nil + return SubmitResult{Disposition: SubmitFreshCache} } - if found { - request.Priority = PriorityBackground + _, disposition, err := service.beginDetailed(request, true) + if err != nil { + return SubmitResult{Disposition: SubmitRejected, Err: err} } - _, err = service.begin(request) - return err + return SubmitResult{Disposition: disposition} +} + +func (service *Service) memoryFresh(key Key) (Record, bool) { + if service == nil || service.resetting.Load() { + return Record{}, false + } + service.resetMu.RLock() + if service.resetting.Load() { + service.resetMu.RUnlock() + return Record{}, false + } + now := service.now().UTC() + record, ok := service.cache.Get(key, now) + service.resetMu.RUnlock() + if !ok || !service.compatible(record) { + return Record{}, false + } + return service.touchCached(record, now), true } func (service *Service) normalizeRequest(request Request) (Request, error) { @@ -376,6 +596,14 @@ func (service *Service) normalizeRequest(request Request) (Request, error) { } func (service *Service) lookup(ctx context.Context, key Key) (Record, bool, bool) { + if service.resetting.Load() { + return Record{}, false, false + } + service.resetMu.RLock() + defer service.resetMu.RUnlock() + if service.resetting.Load() { + return Record{}, false, false + } now := service.now().UTC() if record, ok := service.cache.GetKnown(key, now); ok && service.compatible(record) { record = service.touchCached(record, now) @@ -428,82 +656,298 @@ func (service *Service) touchCached(record Record, now time.Time) Record { } func (service *Service) begin(request Request) (*flight, error) { + flight, _, err := service.beginDetailed(request, false) + return flight, err +} + +func (service *Service) beginDetailed(request Request, registerRetry bool) (*flight, SubmitDisposition, error) { if service == nil { - return nil, ErrServiceUnavailable + return nil, SubmitRejected, ErrServiceUnavailable + } + if service.resetting.Load() { + return nil, SubmitRejected, ErrCacheResetInProgress } var err error request, err = service.normalizeRequest(request) if err != nil { - return nil, err + return nil, SubmitRejected, err } + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeOffered(uint8(request.Priority)) }) key := request.Key.cacheKey() negativeKey := service.negativeKey(request) now := service.now() service.mu.Lock() if service.closed { service.mu.Unlock() - return nil, ErrServiceUnavailable + return nil, SubmitRejected, ErrServiceUnavailable } if until, exists := service.negative[negativeKey]; exists { if now.Before(until) { service.mu.Unlock() - return nil, ErrNegativeCache + return nil, SubmitRejected, ErrNegativeCache } delete(service.negative, negativeKey) } if existing, exists := service.flights[key]; exists { - if request.Priority == PriorityInteractive { - service.promoteLocked(existing.job) + accepted, promoted := service.updateExistingLocked(existing, request) + if !accepted { + service.mu.Unlock() + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeDroppedFull(uint8(request.Priority)) }) + return nil, SubmitRejected, ErrQueueFull + } + if registerRetry && existing.job.claimed && request.ClientUserAgent != existing.userAgent { + if err := service.registerRetryLocked(existing, request); err != nil { + service.mu.Unlock() + return nil, SubmitRejected, err + } } service.mu.Unlock() - return existing, nil + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeJoined(uint8(request.Priority)) }) + if promoted { + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbePromoted(uint8(request.Priority)) }) + } + return existing, SubmitJoinedExistingFlight, nil + } + service.supersedeQueuedBackgroundLocked(request) + flight := &flight{ + done: make(chan struct{}), request: request, priority: request.Priority, + userAgent: request.ClientUserAgent, + } + queued := &job{ + request: request, flight: flight, generation: service.generation.Load(), + expiresAt: service.pendingExpiry(request.Priority, now), } - flight := &flight{done: make(chan struct{}), userAgent: request.ClientUserAgent} - queued := &job{request: request, flight: flight} flight.job = queued if !service.enqueueLocked(queued) { service.mu.Unlock() - return nil, ErrQueueFull + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeDroppedFull(uint8(request.Priority)) }) + return nil, SubmitRejected, ErrQueueFull } service.flights[key] = flight - service.condition.Signal() + service.signalWakeLocked() service.mu.Unlock() - service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeQueued() }) - return flight, nil + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeQueued(uint8(request.Priority)) }) + return flight, SubmitNewlyQueued, nil +} + +func (service *Service) pendingExpiry(priority Priority, now time.Time) time.Time { + if priority == PriorityPlayback { + return time.Time{} + } + return now.Add(service.pendingTTL) +} + +func (service *Service) updateExistingLocked(existing *flight, request Request) (bool, bool) { + if existing == nil || existing.job == nil { + return false, false + } + if request.Priority < existing.priority { + if existing.job.claimed { + existing.priority = request.Priority + existing.request = request + service.signalWakeLocked() + } else if service.promoteLocked(existing.job, request) { + existing.priority = request.Priority + existing.request = request + } else { + return false, false + } + return true, true + } + if request.Priority != existing.priority { + return true, false + } + // The latest same-priority playback/neighbor request carries the current + // target and identity metadata. A queued job may also replace its transport + // User-Agent because no provider call has started yet. + existing.request = request + if !existing.job.claimed { + existing.job.request = request + existing.userAgent = request.ClientUserAgent + } + return true, false +} + +// registerRetryLocked records one distinct waiting User-Agent for an active +// flight. The caller must hold service.mu. Registration is intentionally +// bounded because User-Agent is untrusted request input. +func (service *Service) registerRetryLocked(existing *flight, request Request) error { + if existing == nil || existing.job == nil || existing.retryDepth >= 1 || + request.Priority > PriorityNeighbor || request.Priority > existing.priority { + return nil + } + userAgentKey := retryUserAgentKey(request.ClientUserAgent) + for _, registration := range existing.retryWaiters { + if registration.userAgentKey == userAgentKey { + return nil + } + } + if service.retryRegistrations >= service.retryLimit { + return ErrRetryRegistrationFull + } + existing.retryWaiters = append(existing.retryWaiters, retryRegistration{ + request: request, userAgentKey: userAgentKey, sequence: service.retrySequence, + }) + service.retrySequence++ + service.retryRegistrations++ + return nil } func (service *Service) enqueueLocked(queued *job) bool { - if queued.request.Priority == PriorityInteractive { - if len(service.interactive) >= service.interactiveLimit { + if queued == nil { + return false + } + switch queued.request.Priority { + case PriorityPlayback: + if service.playbackCountLocked() >= service.playbackLimit { return false } - service.interactive = append(service.interactive, queued) - return true - } - if len(service.background) >= service.backgroundLimit { + service.playback = append(service.playback, queued) + case PriorityNeighbor: + if service.neighborCountLocked() >= service.neighborLimit { + return false + } + service.neighbor = append(service.neighbor, queued) + case PriorityMetadata: + if service.metadataCountLocked() >= service.metadataLimit { + return false + } + service.metadata = append(service.metadata, queued) + default: return false } - service.background = append(service.background, queued) return true } -func (service *Service) promoteLocked(queued *job) { - if queued == nil || queued.claimed || queued.request.Priority == PriorityInteractive || - len(service.interactive) >= service.interactiveLimit { - return +func (service *Service) promoteLocked(queued *job, request Request) bool { + if queued == nil || queued.claimed || request.Priority >= queued.request.Priority { + return false + } + if !service.priorityCapacityAvailableLocked(request.Priority) { + return false + } + if !service.removeFromQueueLocked(queued) { + return false } - for index, candidate := range service.background { - if candidate != queued { + queued.request = request + queued.expiresAt = service.pendingExpiry(request.Priority, service.now()) + queued.flight.userAgent = request.ClientUserAgent + service.prependToQueueLocked(queued) + service.signalWakeLocked() + return true +} + +func removeQueuedJob(queue *[]*job, target *job) bool { + for index, candidate := range *queue { + if candidate != target { continue } - copy(service.background[index:], service.background[index+1:]) - service.background[len(service.background)-1] = nil - service.background = service.background[:len(service.background)-1] - queued.request.Priority = PriorityInteractive - service.interactive = append(service.interactive, queued) - service.condition.Signal() + copy((*queue)[index:], (*queue)[index+1:]) + last := len(*queue) - 1 + (*queue)[last] = nil + *queue = (*queue)[:last] + return true + } + return false +} + +func (service *Service) removeFromQueueLocked(target *job) bool { + for _, queue := range service.queuedQueuesLocked() { + if removeQueuedJob(queue, target) { + return true + } + } + return false +} + +func (service *Service) prependToQueueLocked(queued *job) { + switch queued.request.Priority { + case PriorityPlayback: + service.playback = append([]*job{queued}, service.playback...) + case PriorityNeighbor: + service.neighbor = append([]*job{queued}, service.neighbor...) + case PriorityMetadata: + service.metadata = append([]*job{queued}, service.metadata...) + } +} + +func (service *Service) queuedQueuesLocked() []*[]*job { + return []*[]*job{ + &service.playback, &service.neighbor, &service.metadata, + &service.retryPlayback, &service.retryNeighbor, &service.retryMetadata, + } +} + +func (service *Service) priorityCapacityAvailableLocked(priority Priority) bool { + switch priority { + case PriorityPlayback: + return service.playbackCountLocked() < service.playbackLimit + case PriorityNeighbor: + return service.neighborCountLocked() < service.neighborLimit + case PriorityMetadata: + return service.metadataCountLocked() < service.metadataLimit + default: + return false + } +} + +func (service *Service) playbackCountLocked() int { + return len(service.playback) + len(service.retryPlayback) +} + +func (service *Service) neighborCountLocked() int { + return len(service.neighbor) + len(service.retryNeighbor) +} + +func (service *Service) metadataCountLocked() int { + return len(service.metadata) + len(service.retryMetadata) +} + +func (service *Service) supersedeQueuedBackgroundLocked(request Request) { + for _, queue := range []*[]*job{ + &service.neighbor, &service.metadata, &service.retryNeighbor, &service.retryMetadata, + } { + kept := (*queue)[:0] + for _, queued := range *queue { + if queued.request.Key.PlexServerID == request.Key.PlexServerID && + queued.request.Key.PartID == request.Key.PartID && + queued.request.Key.STRMFingerprint != request.Key.STRMFingerprint { + service.cancelQueuedLocked(queued, ErrSuperseded) + continue + } + kept = append(kept, queued) + } + *queue = kept + } +} + +func (service *Service) cancelQueuedLocked(queued *job, err error) { + if queued == nil || queued.claimed || queued.flight == nil { return } + key := queued.request.Key.cacheKey() + if current, ok := service.flights[key]; !ok || current != queued.flight { + return + } + delete(service.flights, key) + service.releaseRetryRegistrationsLocked(queued.flight) + queued.flight.err = err + close(queued.flight.done) + switch { + case errors.Is(err, ErrQueueFull): + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeDroppedFull(uint8(queued.request.Priority)) }) + case errors.Is(err, ErrPendingExpired): + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeDroppedExpired(uint8(queued.request.Priority)) }) + case errors.Is(err, ErrSuperseded): + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeSuperseded(uint8(queued.request.Priority)) }) + } +} + +func (service *Service) signalWakeLocked() { + select { + case service.wake <- struct{}{}: + default: + } } func (service *Service) worker() { @@ -559,26 +1003,230 @@ func (service *Service) janitorWorker() { } func (service *Service) nextJob() (*job, bool) { + for { + service.mu.Lock() + if service.closed { + service.mu.Unlock() + return nil, false + } + service.expireQueuedLocked(service.now()) + if queued := popQueue(&service.playback, &service.retryPlayback); queued != nil { + queued.claimed = true + if service.playbackCountLocked() > 0 { + service.signalWakeLocked() + } + service.mu.Unlock() + return queued, true + } + backgroundQueued := service.backgroundJobsLocked() + now := service.now() + if backgroundQueued > 0 && (service.nextBackgroundAt.IsZero() || !now.Before(service.nextBackgroundAt)) { + queued := service.popPreparedBackgroundLocked() + if queued != nil { + queued.claimed = true + service.nextBackgroundAt = now.Add(service.backgroundInterval) + service.mu.Unlock() + return queued, true + } + } + if queued := service.popUnpreparedBackgroundLocked(); queued != nil { + queued.claimed = true + if service.backgroundJobsLocked() > 0 { + service.signalWakeLocked() + } + service.mu.Unlock() + service.preflight(queued) + continue + } + + wait := time.Duration(-1) + if backgroundQueued > 0 && !service.nextBackgroundAt.IsZero() { + wait = service.nextBackgroundAt.Sub(now) + if wait < 0 { + wait = 0 + } + } + service.mu.Unlock() + + if wait < 0 { + select { + case <-service.wake: + continue + case <-service.ctx.Done(): + return nil, false + } + } + timer := time.NewTimer(wait) + select { + case <-service.wake: + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + case <-timer.C: + case <-service.ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return nil, false + } + } +} + +func (service *Service) queuedJobsLocked() int { + return len(service.retryPlayback) + len(service.playback) + + len(service.retryNeighbor) + len(service.neighbor) + + len(service.retryMetadata) + len(service.metadata) +} + +func (service *Service) backgroundJobsLocked() int { + return len(service.retryNeighbor) + len(service.neighbor) + + len(service.retryMetadata) + len(service.metadata) +} + +func popQueue(primary, retry *[]*job) *job { + if len(*primary) > 0 { + queued := (*primary)[0] + (*primary)[0] = nil + *primary = (*primary)[1:] + return queued + } + if len(*retry) > 0 { + queued := (*retry)[0] + (*retry)[0] = nil + *retry = (*retry)[1:] + return queued + } + return nil +} + +func popUnprepared(queue *[]*job) *job { + return popBackgroundByPreparation(queue, false) +} + +func popPrepared(queue *[]*job) *job { + return popBackgroundByPreparation(queue, true) +} + +func popBackgroundByPreparation(queue *[]*job, prepared bool) *job { + for index, queued := range *queue { + if queued == nil || queued.cacheChecked != prepared { + continue + } + copy((*queue)[index:], (*queue)[index+1:]) + last := len(*queue) - 1 + (*queue)[last] = nil + *queue = (*queue)[:last] + return queued + } + return nil +} + +func (service *Service) popPreparedBackgroundLocked() *job { + for _, queue := range []*[]*job{ + &service.neighbor, &service.retryNeighbor, &service.metadata, &service.retryMetadata, + } { + if queued := popPrepared(queue); queued != nil { + return queued + } + } + return nil +} + +func (service *Service) popUnpreparedBackgroundLocked() *job { + for _, queue := range []*[]*job{ + &service.neighbor, &service.retryNeighbor, &service.metadata, &service.retryMetadata, + } { + if queued := popUnprepared(queue); queued != nil { + return queued + } + } + return nil +} + +func (service *Service) enqueueRetryLocked(queued *job) bool { + if queued == nil { + return false + } + switch queued.request.Priority { + case PriorityPlayback: + if service.playbackCountLocked() >= service.playbackLimit { + return false + } + service.retryPlayback = append(service.retryPlayback, queued) + case PriorityNeighbor: + if service.neighborCountLocked() >= service.neighborLimit { + return false + } + service.retryNeighbor = append(service.retryNeighbor, queued) + case PriorityMetadata: + if service.metadataCountLocked() >= service.metadataLimit { + return false + } + service.retryMetadata = append(service.retryMetadata, queued) + default: + return false + } + return true +} + +// preflight completes fresh L2 hits before the remote-start limiter. A durable +// miss is returned to its priority queue without extending its pending TTL. +func (service *Service) preflight(queued *job) { + if !queued.expiresAt.IsZero() && !service.now().Before(queued.expiresAt) { + service.finish(queued, Record{}, ErrPendingExpired) + return + } + if record, found, fresh := service.lookup(service.ctx, queued.request.Key); found && fresh { + service.finishCached(queued, service.recordForFlight(queued, record)) + return + } + service.mu.Lock() - defer service.mu.Unlock() - for !service.closed && len(service.interactive) == 0 && len(service.background) == 0 { - service.condition.Wait() + key := queued.request.Key.cacheKey() + flight := service.flights[key] + if service.closed || flight != queued.flight { + service.mu.Unlock() + return } - if service.closed { - return nil, false + queued.claimed = false + queued.cacheChecked = true + queued.request = flight.request + queued.request.Priority = flight.priority + flight.userAgent = queued.request.ClientUserAgent + if flight.priority == PriorityPlayback { + queued.expiresAt = time.Time{} } - var queued *job - if len(service.interactive) > 0 { - queued = service.interactive[0] - service.interactive[0] = nil - service.interactive = service.interactive[1:] - } else { - queued = service.background[0] - service.background[0] = nil - service.background = service.background[1:] + if !service.priorityCapacityAvailableLocked(queued.request.Priority) { + service.cancelQueuedLocked(queued, ErrQueueFull) + service.mu.Unlock() + return + } + service.prependToQueueLocked(queued) + service.signalWakeLocked() + service.mu.Unlock() +} + +func (service *Service) expireQueuedLocked(now time.Time) { + for _, queue := range []*[]*job{ + &service.playback, &service.neighbor, &service.metadata, + &service.retryPlayback, &service.retryNeighbor, &service.retryMetadata, + } { + kept := (*queue)[:0] + for _, queued := range *queue { + if queued != nil && !queued.expiresAt.IsZero() && !now.Before(queued.expiresAt) { + service.cancelQueuedLocked(queued, ErrPendingExpired) + continue + } + kept = append(kept, queued) + } + *queue = kept } - queued.claimed = true - return queued, true } func (service *Service) run(queued *job) { @@ -590,6 +1238,25 @@ func (service *Service) run(queued *job) { service.metric(func(metrics ServiceMetrics) { metrics.DecMediaInfoProbeActive() }) service.metric(func(metrics ServiceMetrics) { metrics.ObserveMediaInfoProbeLatency(service.now().Sub(started)) }) }() + if !queued.expiresAt.IsZero() && !service.now().Before(queued.expiresAt) { + service.finish(queued, Record{}, ErrPendingExpired) + return + } + // P0 reaches the worker without preflight and checks both cache tiers. A + // preflighted background miss only rechecks L1 so remote work does not repeat + // the same SQLite read. + var record Record + var found, fresh bool + if queued.cacheChecked { + record, found = service.memoryFresh(queued.request.Key) + fresh = found + } else { + record, found, fresh = service.lookup(service.ctx, queued.request.Key) + } + if found && fresh { + service.finishCached(queued, service.recordForFlight(queued, record)) + return + } probeCtx, cancel := context.WithTimeout(service.ctx, service.probeTimeout) defer cancel() @@ -604,7 +1271,10 @@ func (service *Service) run(queued *job) { service.finish(queued, Record{}, err) return } - if err := probeCtx.Err(); err != nil { + // A provider can return a complete result while an optional best-effort + // enrichment consumes the final instant of the probe deadline. Preserve that + // result unless the service lifecycle itself has already been canceled. + if err := service.ctx.Err(); err != nil { service.finish(queued, Record{}, err) return } @@ -613,8 +1283,9 @@ func (service *Service) run(queued *job) { return } now := service.now().UTC() - record := Record{ - Key: queued.request.Key, RatingKey: queued.request.RatingKey, + request := service.requestForFlight(queued) + record = Record{ + Key: queued.request.Key, RatingKey: request.RatingKey, Provider: service.providerDescriptor.Name, ProviderRevision: service.providerDescriptor.Revision, ContentFingerprint: result.ContentFingerprint, SchemaVersion: SchemaVersion, Media: result.Media, ProbedAt: now, ExpiresAt: now.Add(service.recordTTL), @@ -624,6 +1295,12 @@ func (service *Service) run(queued *job) { service.finish(queued, Record{}, ErrProbeIncomplete) return } + service.resetMu.RLock() + if queued.generation != service.generation.Load() || service.resetting.Load() { + service.resetMu.RUnlock() + service.finish(queued, Record{}, ErrCacheResetInProgress) + return + } storeCtx, cancelStore := context.WithTimeout(service.ctx, defaultStoreTimeout) if err := service.store.Put(storeCtx, record); err != nil { service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoStoreFailure() }) @@ -631,11 +1308,48 @@ func (service *Service) run(queued *job) { } cancelStore() service.cache.Put(record, now) + service.resetMu.RUnlock() service.finish(queued, record, nil) } +func (service *Service) requestForFlight(queued *job) Request { + service.mu.Lock() + defer service.mu.Unlock() + if queued != nil && queued.flight != nil && queued.flight.request.Key.cacheKey() == queued.request.Key.cacheKey() { + return queued.flight.request + } + if queued == nil { + return Request{} + } + return queued.request +} + +func (service *Service) recordForFlight(queued *job, record Record) Record { + request := service.requestForFlight(queued) + if request.RatingKey != "" { + record.RatingKey = request.RatingKey + } + return record +} + +func (service *Service) finishCached(queued *job, record Record) { + key := queued.request.Key.cacheKey() + service.mu.Lock() + flight := service.flights[key] + if flight != queued.flight { + service.mu.Unlock() + return + } + delete(service.flights, key) + service.releaseRetryRegistrationsLocked(flight) + flight.record = record + close(flight.done) + service.mu.Unlock() +} + func (service *Service) finish(queued *job, record Record, err error) { key := queued.request.Key.cacheKey() + var retry *flight service.mu.Lock() flight := service.flights[key] if flight != queued.flight { @@ -643,13 +1357,24 @@ func (service *Service) finish(queued *job, record Record, err error) { return } delete(service.flights, key) - if err != nil && !errors.Is(err, context.Canceled) { - service.rememberNegativeLocked(service.negativeKey(queued.request), service.now()) + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, ErrPendingExpired) && !errors.Is(err, ErrSuperseded) { + now := service.now() + service.rememberNegativeLocked(service.negativeKey(queued.request), now) + retry = service.scheduleRetryLocked(flight, flight.userAgent, now) + } else { + service.releaseRetryRegistrationsLocked(flight) } flight.record = record flight.err = err close(flight.done) service.mu.Unlock() + if retry != nil { + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeQueued(uint8(retry.priority)) }) + } + if errors.Is(err, ErrPendingExpired) { + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeDroppedExpired(uint8(queued.request.Priority)) }) + return + } if err != nil { service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeFailure() }) service.logger.Info("mediainfo_probe_failed", "part", queued.request.Key.PartID, "error_kind", probeErrorKind(err)) @@ -658,11 +1383,87 @@ func (service *Service) finish(queued *job, record Record, err error) { service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeSuccess() }) } +// scheduleRetryLocked selects at most one highest-priority, most-recent +// User-Agent registration. The caller must hold service.mu. A failed +// User-Agent is already in negative backoff and cannot be retried immediately. +func (service *Service) scheduleRetryLocked(failed *flight, failedUserAgent string, now time.Time) *flight { + if failed.retryDepth >= 1 { + service.releaseRetryRegistrationsLocked(failed) + return nil + } + var selected *retryRegistration + for index := range failed.retryWaiters { + registration := failed.retryWaiters[index] + service.retryRegistrations-- + if registration.request.ClientUserAgent == failedUserAgent || + registration.request.Priority > PriorityNeighbor || service.negativeActiveLocked(registration.request, now) { + continue + } + if selected == nil || registration.request.Priority < selected.request.Priority || + (registration.request.Priority == selected.request.Priority && registration.sequence > selected.sequence) { + candidate := registration + selected = &candidate + } + } + failed.retryWaiters = nil + if selected == nil { + return nil + } + + next := &flight{ + done: make(chan struct{}), request: selected.request, + priority: selected.request.Priority, userAgent: selected.request.ClientUserAgent, + retryDepth: failed.retryDepth + 1, + } + next.job = &job{ + request: selected.request, flight: next, generation: failed.job.generation, + expiresAt: service.pendingExpiry(selected.request.Priority, now), cacheChecked: true, + } + next.job.flight = next + if !service.enqueueRetryLocked(next.job) { + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeDroppedFull(uint8(next.priority)) }) + return nil + } + service.flights[next.job.request.Key.cacheKey()] = next + service.signalWakeLocked() + return next +} + +func (service *Service) releaseRetryRegistrationsLocked(flight *flight) { + service.retryRegistrations -= len(flight.retryWaiters) + if service.retryRegistrations < 0 { + service.retryRegistrations = 0 + } + flight.retryWaiters = nil +} + +func (service *Service) negativeActiveLocked(request Request, now time.Time) bool { + key := service.negativeKey(request) + until, exists := service.negative[key] + if !exists { + return false + } + if now.Before(until) { + return true + } + delete(service.negative, key) + return false +} + func (service *Service) negativeKey(request Request) string { - digest := sha256.Sum256([]byte(request.ClientUserAgent)) + digest := userAgentDigest(request.ClientUserAgent) return request.Key.cacheKey() + "\x00" + string(digest[:]) } +func retryUserAgentKey(userAgent string) string { + digest := userAgentDigest(userAgent) + return string(digest[:]) +} + +func userAgentDigest(userAgent string) [sha256.Size]byte { + return sha256.Sum256([]byte(userAgent)) +} + func (service *Service) rememberNegativeLocked(key string, now time.Time) { for candidate, until := range service.negative { if !now.Before(until) { @@ -688,6 +1489,9 @@ type Status struct { Available bool `json:"available"` CacheEntries int `json:"cache_entries"` ActiveProbes int64 `json:"active_probes"` + PlaybackQueued int `json:"playback_queued"` + NeighborQueued int `json:"neighbor_queued"` + MetadataQueued int `json:"metadata_queued"` InteractiveQueued int `json:"interactive_queued"` BackgroundQueued int `json:"background_queued"` NegativeEntries int `json:"negative_entries"` @@ -702,13 +1506,73 @@ func (service *Service) Status() Status { available := !service.closed status := Status{ Available: available, CacheEntries: service.cache.Len(), ActiveProbes: service.active.Load(), - InteractiveQueued: len(service.interactive), BackgroundQueued: len(service.background), - NegativeEntries: len(service.negative), + PlaybackQueued: service.playbackCountLocked(), + NeighborQueued: service.neighborCountLocked(), + MetadataQueued: service.metadataCountLocked(), + InteractiveQueued: service.playbackCountLocked(), + BackgroundQueued: service.neighborCountLocked() + service.metadataCountLocked(), + NegativeEntries: len(service.negative), } service.mu.Unlock() return status } +// ResetCache hot-clears rebuildable MediaInfo state while the proxy remains +// available. Requests arriving after the reset may populate new records. +func (service *Service) ResetCache(ctx context.Context, backupDir string) (ResetResult, error) { + if service == nil || service.resetStore == nil { + return ResetResult{}, ErrServiceUnavailable + } + if ctx == nil { + ctx = context.Background() + } + if !service.resetting.CompareAndSwap(false, true) { + return ResetResult{}, ErrCacheResetInProgress + } + defer service.resetting.Store(false) + service.generation.Add(1) + + service.mu.Lock() + if service.closed { + service.mu.Unlock() + return ResetResult{}, ErrServiceUnavailable + } + for key, flight := range service.flights { + delete(service.flights, key) + flight.err = ErrCacheResetInProgress + service.releaseRetryRegistrationsLocked(flight) + close(flight.done) + } + service.playback = nil + service.neighbor = nil + service.metadata = nil + service.retryPlayback = nil + service.retryNeighbor = nil + service.retryMetadata = nil + service.negative = make(map[string]time.Time) + service.nextBackgroundAt = time.Time{} + for { + select { + case touch := <-service.touches: + touch.reservation.release() + default: + service.signalWakeLocked() + service.mu.Unlock() + goto queuesCleared + } + } + +queuesCleared: + service.resetMu.Lock() + defer service.resetMu.Unlock() + result, err := service.resetStore.BackupAndDeleteAll(ctx, backupDir, service.now().UTC()) + if err != nil { + return ResetResult{}, err + } + service.cache.Purge() + return result, nil +} + // Close cancels active processes and waits for workers without closing the // caller-owned persistent store. func (service *Service) Close(ctx context.Context) error { @@ -731,11 +1595,16 @@ func (service *Service) Close(ctx context.Context) error { for key, flight := range service.flights { delete(service.flights, key) flight.err = ErrServiceUnavailable + service.releaseRetryRegistrationsLocked(flight) close(flight.done) } - service.interactive = nil - service.background = nil - service.condition.Broadcast() + service.playback = nil + service.neighbor = nil + service.metadata = nil + service.retryPlayback = nil + service.retryNeighbor = nil + service.retryMetadata = nil + service.signalWakeLocked() } service.mu.Unlock() if ctx == nil { diff --git a/internal/mediainfo/service_test.go b/internal/mediainfo/service_test.go index 4e77706..b2aaf4d 100644 --- a/internal/mediainfo/service_test.go +++ b/internal/mediainfo/service_test.go @@ -14,6 +14,7 @@ type fakeRecordStore struct { mu sync.Mutex records map[string]Record touches atomic.Int64 + gets atomic.Int64 } func (store *fakeRecordStore) Put(_ context.Context, record Record) error { @@ -27,12 +28,32 @@ func (store *fakeRecordStore) Put(_ context.Context, record Record) error { } func (store *fakeRecordStore) Get(_ context.Context, key Key) (Record, bool, error) { + store.gets.Add(1) store.mu.Lock() defer store.mu.Unlock() record, ok := store.records[key.cacheKey()] return cloneRecord(record), ok, nil } +func TestServiceGetMemoryNeverLoadsDurableStorage(t *testing.T) { + now := time.Date(2026, 8, 29, 8, 0, 0, 0, time.UTC) + record := completeRecord(now) + store := &fakeRecordStore{records: map[string]Record{record.Key.cacheKey(): record}} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, now), Store: store, + Provider: &fakeProvider{prober: &blockingProber{}}, PlexServerID: record.Key.PlexServerID, + Now: func() time.Time { return now }, + }) + if _, found := service.GetMemory(record.Key); found || store.gets.Load() != 0 { + t.Fatalf("cold GetMemory found=%v store_gets=%d", found, store.gets.Load()) + } + service.cache.Put(record, now) + got, found := service.GetMemory(record.Key) + if !found || got.Key != record.Key || store.gets.Load() != 0 { + t.Fatalf("warm GetMemory found=%v key=%#v store_gets=%d", found, got.Key, store.gets.Load()) + } +} + func (store *fakeRecordStore) Touch(_ context.Context, key Key, accessedAt, retainUntil time.Time) error { store.touches.Add(1) store.mu.Lock() @@ -49,6 +70,14 @@ func (store *fakeRecordStore) Touch(_ context.Context, key Key, accessedAt, reta return nil } +func (store *fakeRecordStore) BackupAndDeleteAll(_ context.Context, backupDir string, _ time.Time) (ResetResult, error) { + store.mu.Lock() + defer store.mu.Unlock() + deleted := int64(len(store.records)) + store.records = make(map[string]Record) + return ResetResult{DeletedRecords: deleted, BackupPath: backupDir + "/backup.db"}, nil +} + type fakeProvider struct { calls atomic.Int64 prober Prober @@ -56,7 +85,7 @@ type fakeProvider struct { } func (*fakeProvider) Descriptor() ProviderDescriptor { - return ProviderDescriptor{Name: ProviderMediaVaultFFProbe, Revision: ProviderRevisionFFProbeJSONV1} + return ProviderDescriptor{Name: ProviderMediaVaultFFProbe, Revision: ProviderRevisionFFProbeJSONV3} } func (fake *fakeProvider) Probe(ctx context.Context, request ProviderRequest) (ProviderResult, error) { @@ -77,6 +106,25 @@ type blockingProber struct { release chan struct{} } +type deadlineResultProber struct { + deadlineObserved chan struct{} + release chan struct{} +} + +func (prober deadlineResultProber) Probe(ctx context.Context, _, _ string) (Media, error) { + <-ctx.Done() + if prober.deadlineObserved != nil { + close(prober.deadlineObserved) + } + if prober.release != nil { + <-prober.release + } + return Media{ + Complete: true, Container: "mkv", DurationMS: 60_000, + Streams: []Stream{{Index: 0, Type: "video", Codec: "hevc", Width: 1920, Height: 1080}}, + }, nil +} + func (prober *blockingProber) Probe(ctx context.Context, target, _ string) (Media, error) { prober.calls.Add(1) if prober.started != nil { @@ -144,6 +192,26 @@ func TestServiceOwnsPlexServerIdentity(t *testing.T) { } } +func TestServiceResetCacheClearsL1AndDurableRecords(t *testing.T) { + now := time.Now().UTC() + record := completeRecord(now) + store := &fakeRecordStore{records: map[string]Record{record.Key.cacheKey(): record}} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache([]Record{record}, now), Store: store, + Provider: &fakeProvider{prober: &blockingProber{}}, PlexServerID: record.Key.PlexServerID, + }) + result, err := service.ResetCache(t.Context(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + if result.DeletedRecords != 1 || service.Status().CacheEntries != 0 { + t.Fatalf("reset result=%#v status=%#v", result, service.Status()) + } + if _, found := service.Get(record.Key); found { + t.Fatal("reset record remained readable") + } +} + func TestServiceClientWaitCancellationDoesNotCancelBackgroundJob(t *testing.T) { prober := &blockingProber{release: make(chan struct{})} service := newTestService(t, prober, 1) @@ -161,6 +229,59 @@ func TestServiceClientWaitCancellationDoesNotCancelBackgroundJob(t *testing.T) { }) } +func TestServicePreservesSuccessfulResultReturnedAtProbeDeadline(t *testing.T) { + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, + Provider: &fakeProvider{prober: deadlineResultProber{}}, + PlexServerID: "server", ProbeTimeout: 20 * time.Millisecond, + }) + + record, err := service.Ensure(t.Context(), testRequest("deadline-success", PriorityInteractive)) + if err != nil { + t.Fatal(err) + } + if !record.Media.Complete || len(record.Media.Streams) != 1 || record.Media.Streams[0].Codec != "hevc" { + t.Fatalf("record = %#v", record) + } +} + +func TestServiceRejectsSuccessfulResultReturnedAfterShutdownCancellation(t *testing.T) { + prober := deadlineResultProber{ + deadlineObserved: make(chan struct{}), + release: make(chan struct{}), + } + store := &fakeRecordStore{} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: store, + Provider: &fakeProvider{prober: prober}, + PlexServerID: "server", ProbeTimeout: 20 * time.Millisecond, + }) + request := testRequest("shutdown-canceled", PriorityBackground) + if err := service.Submit(request); err != nil { + t.Fatal(err) + } + <-prober.deadlineObserved + + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + closed := make(chan error, 1) + go func() { closed <- service.Close(ctx) }() + <-service.ctx.Done() + close(prober.release) + if err := <-closed; err != nil { + t.Fatal(err) + } + if _, ok := service.cache.GetKnown(request.Key, time.Now()); ok { + t.Fatal("result returned after shutdown cancellation was written to L1") + } + store.mu.Lock() + _, persisted := store.records[request.Key.cacheKey()] + store.mu.Unlock() + if persisted { + t.Fatal("result returned after shutdown cancellation was persisted") + } +} + func TestServicePrioritizesRapidInteractiveSwitchesOverPrewarm(t *testing.T) { prober := &blockingProber{started: make(chan string, 4), release: make(chan struct{}, 4)} service := newTestService(t, prober, 1) @@ -323,11 +444,39 @@ func TestServiceReprobesIncompatibleProviderRevision(t *testing.T) { if err != nil { t.Fatal(err) } - if got.ProviderRevision != ProviderRevisionFFProbeJSONV1 || provider.calls.Load() != 1 { + if got.ProviderRevision != ProviderRevisionFFProbeJSONV3 || provider.calls.Load() != 1 { t.Fatalf("Ensure() revision=%q provider_calls=%d", got.ProviderRevision, provider.calls.Load()) } } +func TestServiceGetNormalizesPlexServerIdentity(t *testing.T) { + now := time.Date(2026, 8, 29, 8, 0, 0, 0, time.UTC) + record := completeRecord(now.Add(-31 * 24 * time.Hour)) + record.Key.PlexServerID = "server" + store := &fakeRecordStore{records: map[string]Record{record.Key.cacheKey(): record}} + provider := &fakeProvider{prober: &blockingProber{}} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, now), Store: store, + Provider: provider, + PlexServerID: "server", Now: func() time.Time { return now }, + }) + + key := record.Key + key.PlexServerID = "" + got, found := service.Get(key) + if !found || got.Key != record.Key || got.Fresh(now) { + t.Fatalf("Get() found=%v key=%#v, want %#v", found, got.Key, record.Key) + } + if provider.calls.Load() != 0 { + t.Fatalf("cache-only Get scheduled %d provider calls", provider.calls.Load()) + } + + key.PlexServerID = "other-server" + if _, found := service.Get(key); found { + t.Fatal("Get() returned a record for another Plex server") + } +} + func TestServiceReturnsStaleKnownGoodAndRevalidatesInBackground(t *testing.T) { now := time.Unix(1_800_000_000, 0).UTC() request := testRequest("stale", PriorityInteractive) @@ -449,6 +598,9 @@ func newServiceForTest(t *testing.T, options ServiceOptions) *Service { if options.NegativeTTL == 0 { options.NegativeTTL = time.Minute } + if options.BackgroundInterval == 0 { + options.BackgroundInterval = time.Millisecond + } if options.BackgroundUserAgent == "" { options.BackgroundUserAgent = "Infuse-Library/8.4.4" } diff --git a/internal/mediainfo/sqlite.go b/internal/mediainfo/sqlite.go index a32d605..106d420 100644 --- a/internal/mediainfo/sqlite.go +++ b/internal/mediainfo/sqlite.go @@ -6,6 +6,8 @@ import ( "encoding/json" "errors" "fmt" + "os" + "path/filepath" "strings" "time" @@ -214,6 +216,69 @@ func (store *SQLiteStore) DeleteUnretained(ctx context.Context, now time.Time) ( return deleted, nil } +// BackupAndDeleteAll creates a transactionally consistent full-database +// backup, then removes only rebuildable MediaInfo rows while retaining the +// shared schema registry and tables owned by other modules. +func (store *SQLiteStore) BackupAndDeleteAll( + ctx context.Context, + backupDir string, + now time.Time, +) (ResetResult, error) { + if store == nil || store.db == nil { + return ResetResult{}, errors.New("MediaInfo store is unavailable") + } + backupDir = filepath.Clean(backupDir) + if err := os.MkdirAll(backupDir, 0o700); err != nil { + return ResetResult{}, fmt.Errorf("create Gateway backup directory: %w", err) + } + if err := os.Chmod(backupDir, 0o700); err != nil { + return ResetResult{}, fmt.Errorf("protect Gateway backup directory: %w", err) + } + if now.IsZero() { + now = time.Now().UTC() + } else { + now = now.UTC() + } + backupPath := filepath.Join( + backupDir, + "plex-gateway-before-mediainfo-reset-"+now.Format("20060102T150405.000000000Z")+".db", + ) + + connection, err := store.db.Conn(ctx) + if err != nil { + return ResetResult{}, fmt.Errorf("reserve Gateway database connection: %w", err) + } + defer connection.Close() + var quotedPath string + if err := connection.QueryRowContext(ctx, "SELECT quote(?)", backupPath).Scan("edPath); err != nil { + return ResetResult{}, fmt.Errorf("quote Gateway backup path: %w", err) + } + if _, err := connection.ExecContext(ctx, "VACUUM INTO "+quotedPath); err != nil { + return ResetResult{}, fmt.Errorf("backup Gateway database: %w", err) + } + if err := os.Chmod(backupPath, 0o600); err != nil { + return ResetResult{}, fmt.Errorf("protect Gateway database backup: %w", err) + } + + transaction, err := connection.BeginTx(ctx, nil) + if err != nil { + return ResetResult{}, fmt.Errorf("begin MediaInfo cache reset: %w", err) + } + defer transaction.Rollback() + result, err := transaction.ExecContext(ctx, "DELETE FROM media_info_records") + if err != nil { + return ResetResult{}, fmt.Errorf("delete all MediaInfo records: %w", err) + } + deleted, err := result.RowsAffected() + if err != nil { + return ResetResult{}, fmt.Errorf("read deleted MediaInfo count: %w", err) + } + if err := transaction.Commit(); err != nil { + return ResetResult{}, fmt.Errorf("commit MediaInfo cache reset: %w", err) + } + return ResetResult{DeletedRecords: deleted, BackupPath: backupPath}, nil +} + // Touch extends retention without rewriting the MediaInfo JSON payload. func (store *SQLiteStore) Touch(ctx context.Context, key Key, accessedAt, retainUntil time.Time) error { if store == nil || store.db == nil { diff --git a/internal/mediainfo/sqlite_test.go b/internal/mediainfo/sqlite_test.go index b977037..593179e 100644 --- a/internal/mediainfo/sqlite_test.go +++ b/internal/mediainfo/sqlite_test.go @@ -2,6 +2,7 @@ package mediainfo import ( "context" + "os" "path/filepath" "strings" "testing" @@ -170,7 +171,7 @@ func TestSQLiteStoreLoadsMostRecentlyAccessedWithinLimit(t *testing.T) { } func testProviderDescriptor() ProviderDescriptor { - return ProviderDescriptor{Name: ProviderMediaVaultFFProbe, Revision: ProviderRevisionFFProbeJSONV1} + return ProviderDescriptor{Name: ProviderMediaVaultFFProbe, Revision: ProviderRevisionFFProbeJSONV3} } func TestSQLiteStoreDoesNotRestoreOtherProviderRevision(t *testing.T) { @@ -181,7 +182,7 @@ func TestSQLiteStoreDoesNotRestoreOtherProviderRevision(t *testing.T) { t.Fatal(err) } loaded, err := store.LoadCompatibleRetained(t.Context(), now, 100, ProviderDescriptor{ - Name: ProviderMediaVaultFFProbe, Revision: "ffprobe-json-v2", + Name: ProviderMediaVaultFFProbe, Revision: "ffprobe-json-v1", }) if err != nil { t.Fatal(err) @@ -212,3 +213,66 @@ func TestSQLiteStoreTouchExtendsRetentionMonotonically(t *testing.T) { t.Fatalf("Get() = %#v, %t, %v", got, ok, err) } } + +func TestSQLiteStoreBackupAndDeleteAllPreservesSharedDatabase(t *testing.T) { + databasePath := filepath.Join(t.TempDir(), "plex-gateway.db") + store, gatewayDB := openTestSQLiteStore(t, databasePath) + now := time.Unix(1_800_000_000, 123_456_789).UTC() + record := completeRecord(now) + if err := store.Put(t.Context(), record); err != nil { + t.Fatal(err) + } + if _, err := gatewayDB.SQLDB().ExecContext(t.Context(), ` + CREATE TABLE other_module_records (id TEXT PRIMARY KEY); + INSERT INTO other_module_records (id) VALUES ('preserve'); + `); err != nil { + t.Fatal(err) + } + result, err := store.BackupAndDeleteAll(t.Context(), filepath.Join(filepath.Dir(databasePath), "backups"), now) + if err != nil { + t.Fatal(err) + } + if result.DeletedRecords != 1 { + t.Fatalf("deleted records = %d", result.DeletedRecords) + } + backupInfo, err := os.Stat(result.BackupPath) + if err != nil { + t.Fatal(err) + } + if backupInfo.Mode().Perm() != 0o600 { + t.Fatalf("backup mode = %o", backupInfo.Mode().Perm()) + } + assertSQLiteCounts(t, gatewayDB, 0, 1, 1) + if err := gatewayDB.Close(); err != nil { + t.Fatal(err) + } + + backupDB, err := database.OpenSQLite(t.Context(), result.BackupPath) + if err != nil { + t.Fatal(err) + } + defer backupDB.Close() + assertSQLiteCounts(t, backupDB, 1, 1, 1) +} + +func assertSQLiteCounts(t *testing.T, gatewayDB *database.SQLite, mediaInfo, otherModule, migrations int) { + t.Helper() + queries := []struct { + name string + sql string + want int + }{ + {name: "MediaInfo", sql: "SELECT COUNT(*) FROM media_info_records", want: mediaInfo}, + {name: "other module", sql: "SELECT COUNT(*) FROM other_module_records", want: otherModule}, + {name: "migration", sql: "SELECT COUNT(*) FROM gateway_schema_migrations WHERE module = 'mediainfo'", want: migrations}, + } + for _, query := range queries { + var count int + if err := gatewayDB.SQLDB().QueryRowContext(t.Context(), query.sql).Scan(&count); err != nil { + t.Fatalf("read %s count: %v", query.name, err) + } + if count != query.want { + t.Fatalf("%s count = %d, want %d", query.name, count, query.want) + } + } +} diff --git a/internal/mediainfo/submit_test.go b/internal/mediainfo/submit_test.go new file mode 100644 index 0000000..c17fe05 --- /dev/null +++ b/internal/mediainfo/submit_test.go @@ -0,0 +1,271 @@ +package mediainfo + +import ( + "context" + "errors" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestServiceSubmitDetailedReportsAdmissionDisposition(t *testing.T) { + now := time.Unix(1_800_000_000, 0).UTC() + prober := &blockingProber{started: make(chan string, 1), release: make(chan struct{})} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, now), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: prober}, + PlexServerID: "server", Now: func() time.Time { return now }, + }) + + fresh := completeRecord(now) + fresh.Key.PlexServerID = "server" + fresh.Key.PartID = "fresh" + service.cache.Put(fresh, now) + freshResult := service.SubmitDetailed(testRequest("fresh", PriorityBackground)) + if freshResult.Disposition != SubmitFreshCache || freshResult.Err != nil { + t.Fatalf("fresh cache result = %#v", freshResult) + } + + queuedRequest := testRequest("queued", PriorityBackground) + queuedResult := service.SubmitDetailed(queuedRequest) + if queuedResult.Disposition != SubmitNewlyQueued || queuedResult.Err != nil { + t.Fatalf("new queue result = %#v", queuedResult) + } + select { + case <-prober.started: + case <-time.After(time.Second): + t.Fatal("queued probe did not start") + } + + joined := testRequest("queued", PriorityBackground) + joined.ClientUserAgent = "different-client" + joinedResult := service.SubmitDetailed(joined) + if joinedResult.Disposition != SubmitJoinedExistingFlight || joinedResult.Err != nil { + t.Fatalf("joined result = %#v", joinedResult) + } + + close(prober.release) + waitFor(t, func() bool { + _, ok := service.Get(queuedRequest.Key) + return ok + }) +} + +func TestServiceSubmitDetailedReportsRejection(t *testing.T) { + prober := &blockingProber{started: make(chan string, 1), release: make(chan struct{})} + service := newTestServiceWithQueues(t, prober, 1, 1, 1) + if result := service.SubmitDetailed(testRequest("active", PriorityBackground)); result.Disposition != SubmitNewlyQueued || result.Err != nil { + t.Fatalf("active result = %#v", result) + } + select { + case <-prober.started: + case <-time.After(time.Second): + t.Fatal("active probe did not start") + } + if result := service.SubmitDetailed(testRequest("queued", PriorityBackground)); result.Disposition != SubmitNewlyQueued || result.Err != nil { + t.Fatalf("queued result = %#v", result) + } + result := service.SubmitDetailed(testRequest("overflow", PriorityBackground)) + if result.Disposition != SubmitRejected || !errors.Is(result.Err, ErrQueueFull) { + t.Fatalf("overflow result = %#v", result) + } +} + +func TestServiceRejectsOversizedClientUserAgent(t *testing.T) { + service := newTestService(t, &blockingProber{}, 1) + request := testRequest("oversized-user-agent", PriorityInteractive) + request.ClientUserAgent = strings.Repeat("x", maxClientUserAgentBytes+1) + result := service.SubmitDetailed(request) + if result.Disposition != SubmitRejected || result.Err == nil { + t.Fatalf("oversized User-Agent result = %#v", result) + } +} + +func TestServiceSubmitRequeuesJoinedUserAgentAfterFailure(t *testing.T) { + now := time.Unix(1_800_000_000, 0).UTC() + started := make(chan string, 2) + releaseFailure := make(chan struct{}) + var calls atomic.Int64 + prober := proberFunc(func(ctx context.Context, _ string, userAgent string) (Media, error) { + call := calls.Add(1) + started <- userAgent + if call == 1 { + select { + case <-ctx.Done(): + return Media{}, ctx.Err() + case <-releaseFailure: + return Media{}, errors.New("first User-Agent rejected") + } + } + return Media{ + Complete: true, Container: "mkv", DurationMS: 60_000, + Streams: []Stream{{Index: 0, Type: "video", Codec: "hevc", Width: 1920, Height: 1080}}, + }, nil + }) + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, now), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: prober}, + PlexServerID: "server", NegativeTTL: time.Hour, Now: func() time.Time { return now }, + }) + first := testRequest("submit-cross-ua", PriorityInteractive) + first.ClientUserAgent = "client-agent-a" + second := first + second.ClientUserAgent = "client-agent-b" + if result := service.SubmitDetailed(first); result.Disposition != SubmitNewlyQueued || result.Err != nil { + t.Fatalf("first submit result = %#v", result) + } + if got := <-started; got != first.ClientUserAgent { + t.Fatalf("first probe User-Agent = %q", got) + } + if result := service.SubmitDetailed(second); result.Disposition != SubmitJoinedExistingFlight || result.Err != nil { + t.Fatalf("second submit result = %#v", result) + } + + close(releaseFailure) + if got := <-started; got != second.ClientUserAgent { + t.Fatalf("retry probe User-Agent = %q", got) + } + waitFor(t, func() bool { + _, ok := service.Get(first.Key) + return ok + }) + if got := calls.Load(); got != 2 { + t.Fatalf("probe calls = %d, want 2", got) + } +} + +func TestServicePromotesPendingUserAgentRetryForCurrentPlayback(t *testing.T) { + started := make(chan string, 8) + releases := map[string]chan struct{}{ + "first": make(chan struct{}), + "blocker": make(chan struct{}), + "retry": make(chan struct{}), + "other": make(chan struct{}), + } + prober := proberFunc(func(ctx context.Context, target, _ string) (Media, error) { + target = strings.TrimPrefix(target, "https://cdn.example.test/") + started <- target + select { + case <-ctx.Done(): + return Media{}, ctx.Err() + case <-releases[target]: + } + if target == "first" { + return Media{}, errors.New("first User-Agent rejected") + } + return Media{ + Complete: true, Container: "mkv", DurationMS: 60_000, + Streams: []Stream{{Index: 0, Type: "video", Codec: "hevc", Width: 1920, Height: 1080}}, + }, nil + }) + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: prober}, + PlexServerID: "server", Concurrency: 1, InteractiveQueueSize: 8, BackgroundQueueSize: 8, + }) + first := testRequest("first", PriorityBackground) + first.ClientUserAgent = "client-a" + retry := first + retry.Target = "retry" + retry.ClientUserAgent = "client-b" + if result := service.SubmitDetailed(first); result.Disposition != SubmitNewlyQueued { + t.Fatalf("first result = %#v", result) + } + if got := <-started; got != "first" { + t.Fatalf("first target = %q", got) + } + if result := service.SubmitDetailed(retry); result.Disposition != SubmitJoinedExistingFlight { + t.Fatalf("retry registration = %#v", result) + } + if result := service.SubmitDetailed(testRequest("blocker", PriorityInteractive)); result.Disposition != SubmitNewlyQueued { + t.Fatalf("blocker result = %#v", result) + } + close(releases["first"]) + if got := <-started; got != "blocker" { + t.Fatalf("second target = %q", got) + } + if result := service.SubmitDetailed(testRequest("other", PriorityInteractive)); result.Disposition != SubmitNewlyQueued { + t.Fatalf("other result = %#v", result) + } + current := retry + current.Priority = PriorityInteractive + if result := service.SubmitDetailed(current); result.Disposition != SubmitJoinedExistingFlight { + t.Fatalf("current join result = %#v", result) + } + close(releases["blocker"]) + if got := <-started; got != "retry" { + t.Fatalf("promoted target = %q, want retry", got) + } + close(releases["retry"]) + if got := <-started; got != "other" { + t.Fatalf("following target = %q, want other", got) + } + close(releases["other"]) +} + +func TestServiceSubmitDoesNotRequeueSameUserAgent(t *testing.T) { + now := time.Unix(1_800_000_000, 0).UTC() + started := make(chan string, 1) + releaseFailure := make(chan struct{}) + var calls atomic.Int64 + prober := proberFunc(func(ctx context.Context, _ string, userAgent string) (Media, error) { + calls.Add(1) + started <- userAgent + select { + case <-ctx.Done(): + return Media{}, ctx.Err() + case <-releaseFailure: + return Media{}, errors.New("probe rejected") + } + }) + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, now), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: prober}, + PlexServerID: "server", NegativeTTL: time.Hour, Now: func() time.Time { return now }, + }) + request := testRequest("submit-same-ua", PriorityBackground) + request.ClientUserAgent = "same-client" + if result := service.SubmitDetailed(request); result.Disposition != SubmitNewlyQueued || result.Err != nil { + t.Fatalf("first submit result = %#v", result) + } + <-started + if result := service.SubmitDetailed(request); result.Disposition != SubmitJoinedExistingFlight || result.Err != nil { + t.Fatalf("same User-Agent join result = %#v", result) + } + close(releaseFailure) + waitFor(t, func() bool { return calls.Load() == 1 && service.Status().ActiveProbes == 0 }) + select { + case got := <-started: + t.Fatalf("same User-Agent was requeued as %q", got) + default: + } +} + +func TestServiceSubmitBoundsRetryRegistrations(t *testing.T) { + prober := &blockingProber{started: make(chan string, 1), release: make(chan struct{})} + service := newTestService(t, prober, 1) + first := testRequest("submit-registration-limit", PriorityBackground) + first.ClientUserAgent = "owner" + if result := service.SubmitDetailed(first); result.Err != nil { + t.Fatalf("first submit result = %#v", result) + } + <-prober.started + for index := 0; index < defaultRetryLimit+32; index++ { + request := first + request.ClientUserAgent = fmt.Sprintf("client-%d", index) + result := service.SubmitDetailed(request) + if index < defaultRetryLimit { + if result.Disposition != SubmitJoinedExistingFlight || result.Err != nil { + t.Fatalf("registration %d result = %#v", index, result) + } + continue + } + if result.Disposition != SubmitRejected || !errors.Is(result.Err, ErrRetryRegistrationFull) { + t.Fatalf("overflow registration %d result = %#v", index, result) + } + } + service.mu.Lock() + registrations := service.retryRegistrations + service.mu.Unlock() + if registrations != defaultRetryLimit { + t.Fatalf("retry registrations = %d, want %d", registrations, defaultRetryLimit) + } +} diff --git a/internal/mediainfo/touch_lifecycle_test.go b/internal/mediainfo/touch_lifecycle_test.go index 7359039..b85ab0c 100644 --- a/internal/mediainfo/touch_lifecycle_test.go +++ b/internal/mediainfo/touch_lifecycle_test.go @@ -179,8 +179,10 @@ func TestServiceCloseCancelsPendingTouches(t *testing.T) { const touchInterval = time.Hour now := time.Unix(1_800_000_000, 0).UTC() first := completeRecord(now.Add(-2 * touchInterval)) + first.Key.PlexServerID = "server" first.Key.PartID = "close-first" second := completeRecord(now.Add(-2 * touchInterval)) + second.Key.PlexServerID = "server" second.Key.PartID = "close-second" store := &cancelAwareTouchStore{ started: make(chan struct{}, 2), diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index ec44d39..2c96316 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -15,35 +15,69 @@ import ( // surface bounded and prevents request credentials or media URLs from being // copied into an operational endpoint. type Metrics struct { - plexRequestsTotal atomic.Uint64 - cloudPartHits atomic.Uint64 - cloudPartMisses atomic.Uint64 - redirectSuccess atomic.Uint64 - redirectFailure atomic.Uint64 - plexFallbackTotal atomic.Uint64 - activeRequests atomic.Int64 - metadataAdmitted atomic.Uint64 - metadataTimeouts atomic.Uint64 - metadataActive atomic.Int64 - metadataQueued atomic.Int64 - metadataBatchAdmitted atomic.Uint64 - metadataBatchTimeouts atomic.Uint64 - metadataBatchActive atomic.Int64 - metadataBatchQueued atomic.Int64 - mediaInfoCacheHits atomic.Uint64 - mediaInfoCacheMisses atomic.Uint64 - mediaInfoProbeQueued atomic.Uint64 - mediaInfoProbeSuccess atomic.Uint64 - mediaInfoProbeFailure atomic.Uint64 - mediaInfoStoreFailure atomic.Uint64 - mediaInfoProbeActive atomic.Int64 - mediaInfoEnriched atomic.Uint64 - mediaInfoFailOpen atomic.Uint64 - mediaInfoWaitActive atomic.Int64 - mediaInfoWaitRejected atomic.Uint64 - resolverLatency latencyMetrics - redirectLatency latencyMetrics - mediaInfoProbeLatency latencyMetrics + plexRequestsTotal atomic.Uint64 + cloudPartHits atomic.Uint64 + cloudPartMisses atomic.Uint64 + redirectSuccess atomic.Uint64 + redirectFailure atomic.Uint64 + plexFallbackTotal atomic.Uint64 + activeRequests atomic.Int64 + metadataAnalysisParamsRemoved atomic.Uint64 + metadataAdmitted atomic.Uint64 + metadataTimeouts atomic.Uint64 + metadataActive atomic.Int64 + metadataQueued atomic.Int64 + metadataBatchAdmitted atomic.Uint64 + metadataBatchTimeouts atomic.Uint64 + metadataBatchActive atomic.Int64 + metadataBatchQueued atomic.Int64 + metadataCoalesceOffered atomic.Uint64 + metadataCoalesceBatches atomic.Uint64 + metadataCoalesceItems atomic.Uint64 + metadataCoalesceFallbacks atomic.Uint64 + metadataCoalesceActive atomic.Int64 + mediaInfoCacheHits atomic.Uint64 + mediaInfoCacheMisses atomic.Uint64 + mediaInfoProbeOffered atomic.Uint64 + mediaInfoProbeQueued atomic.Uint64 + mediaInfoProbeJoined atomic.Uint64 + mediaInfoProbePromoted atomic.Uint64 + mediaInfoProbeDroppedFull atomic.Uint64 + mediaInfoProbeDroppedExpired atomic.Uint64 + mediaInfoProbeSuperseded atomic.Uint64 + mediaInfoProbeSuccess atomic.Uint64 + mediaInfoProbeFailure atomic.Uint64 + mediaInfoStoreFailure atomic.Uint64 + mediaInfoProbeActive atomic.Int64 + mediaInfoEnriched atomic.Uint64 + mediaInfoFailOpen atomic.Uint64 + mediaInfoWaitActive atomic.Int64 + mediaInfoWaitRejected atomic.Uint64 + mediaInfoPrewarmTriggered atomic.Uint64 + mediaInfoPrewarmReplaced atomic.Uint64 + mediaInfoPrewarmDiscoverySuccess atomic.Uint64 + mediaInfoPrewarmDiscoveryFailure atomic.Uint64 + mediaInfoPrewarmFreshCache atomic.Uint64 + mediaInfoPrewarmJoinedFlight atomic.Uint64 + mediaInfoPrewarmQueued atomic.Uint64 + mediaInfoPrewarmRejected atomic.Uint64 + mediaInfoPrewarmSkipped atomic.Uint64 + resolverLatency latencyMetrics + redirectLatency latencyMetrics + mediaInfoProbeLatency latencyMetrics + mediaInfoScheduler schedulerMetrics +} + +const mediaInfoPriorityCount = 3 + +type schedulerMetrics struct { + offered [mediaInfoPriorityCount]atomic.Uint64 + queued [mediaInfoPriorityCount]atomic.Uint64 + joined [mediaInfoPriorityCount]atomic.Uint64 + promoted [mediaInfoPriorityCount]atomic.Uint64 + droppedFull [mediaInfoPriorityCount]atomic.Uint64 + droppedExpired [mediaInfoPriorityCount]atomic.Uint64 + superseded [mediaInfoPriorityCount]atomic.Uint64 } type latencyMetrics struct { @@ -56,33 +90,55 @@ type latencyMetrics struct { // Snapshot is the stable JSON representation of the process counters. // Counter names match the operational metric names used by the gateway. type Snapshot struct { - PlexRequestsTotal uint64 `json:"plex_requests_total"` - CloudPartHits uint64 `json:"cloud_part_hits"` - CloudPartMisses uint64 `json:"cloud_part_misses"` - RedirectSuccess uint64 `json:"redirect_success"` - RedirectFailure uint64 `json:"redirect_failure"` - PlexFallbackTotal uint64 `json:"plex_fallback_total"` - ActiveRequests int64 `json:"active_requests"` - - MetadataGuardAdmittedTotal uint64 `json:"metadata_guard_admitted_total"` - MetadataGuardTimeoutsTotal uint64 `json:"metadata_guard_timeouts_total"` - MetadataGuardActive int64 `json:"metadata_guard_active"` - MetadataGuardQueued int64 `json:"metadata_guard_queued"` - MetadataBatchGuardAdmittedTotal uint64 `json:"metadata_batch_guard_admitted_total"` - MetadataBatchGuardTimeoutsTotal uint64 `json:"metadata_batch_guard_timeouts_total"` - MetadataBatchGuardActive int64 `json:"metadata_batch_guard_active"` - MetadataBatchGuardQueued int64 `json:"metadata_batch_guard_queued"` - MediaInfoCacheHitsTotal uint64 `json:"mediainfo_cache_hits_total"` - MediaInfoCacheMissesTotal uint64 `json:"mediainfo_cache_misses_total"` - MediaInfoProbeQueuedTotal uint64 `json:"mediainfo_probe_queued_total"` - MediaInfoProbeSuccessTotal uint64 `json:"mediainfo_probe_success_total"` - MediaInfoProbeFailureTotal uint64 `json:"mediainfo_probe_failure_total"` - MediaInfoStoreFailureTotal uint64 `json:"mediainfo_store_failure_total"` - MediaInfoProbeActive int64 `json:"mediainfo_probe_active"` - MediaInfoEnrichedTotal uint64 `json:"mediainfo_enriched_total"` - MediaInfoFailOpenTotal uint64 `json:"mediainfo_fail_open_total"` - MediaInfoWaitActive int64 `json:"mediainfo_wait_active"` - MediaInfoWaitRejectedTotal uint64 `json:"mediainfo_wait_rejected_total"` + PlexRequestsTotal uint64 `json:"plex_requests_total"` + CloudPartHits uint64 `json:"cloud_part_hits"` + CloudPartMisses uint64 `json:"cloud_part_misses"` + RedirectSuccess uint64 `json:"redirect_success"` + RedirectFailure uint64 `json:"redirect_failure"` + PlexFallbackTotal uint64 `json:"plex_fallback_total"` + ActiveRequests int64 `json:"active_requests"` + MetadataAnalysisParamsRemovedTotal uint64 `json:"metadata_analysis_params_removed_total"` + + MetadataGuardAdmittedTotal uint64 `json:"metadata_guard_admitted_total"` + MetadataGuardTimeoutsTotal uint64 `json:"metadata_guard_timeouts_total"` + MetadataGuardActive int64 `json:"metadata_guard_active"` + MetadataGuardQueued int64 `json:"metadata_guard_queued"` + MetadataBatchGuardAdmittedTotal uint64 `json:"metadata_batch_guard_admitted_total"` + MetadataBatchGuardTimeoutsTotal uint64 `json:"metadata_batch_guard_timeouts_total"` + MetadataBatchGuardActive int64 `json:"metadata_batch_guard_active"` + MetadataBatchGuardQueued int64 `json:"metadata_batch_guard_queued"` + MetadataCoalesceOfferedTotal uint64 `json:"metadata_coalesce_offered_total"` + MetadataCoalesceBatchesTotal uint64 `json:"metadata_coalesce_batches_total"` + MetadataCoalesceItemsTotal uint64 `json:"metadata_coalesce_items_total"` + MetadataCoalesceFallbacksTotal uint64 `json:"metadata_coalesce_fallbacks_total"` + MetadataCoalesceActive int64 `json:"metadata_coalesce_active"` + MediaInfoCacheHitsTotal uint64 `json:"mediainfo_cache_hits_total"` + MediaInfoCacheMissesTotal uint64 `json:"mediainfo_cache_misses_total"` + MediaInfoProbeOfferedTotal uint64 `json:"mediainfo_probe_offered_total"` + MediaInfoProbeQueuedTotal uint64 `json:"mediainfo_probe_queued_total"` + MediaInfoProbeJoinedTotal uint64 `json:"mediainfo_probe_joined_total"` + MediaInfoProbePromotedTotal uint64 `json:"mediainfo_probe_promoted_total"` + MediaInfoProbeDroppedFullTotal uint64 `json:"mediainfo_probe_dropped_full_total"` + MediaInfoProbeDroppedExpiredTotal uint64 `json:"mediainfo_probe_dropped_expired_total"` + MediaInfoProbeSupersededTotal uint64 `json:"mediainfo_probe_superseded_total"` + MediaInfoProbeSuccessTotal uint64 `json:"mediainfo_probe_success_total"` + MediaInfoProbeFailureTotal uint64 `json:"mediainfo_probe_failure_total"` + MediaInfoStoreFailureTotal uint64 `json:"mediainfo_store_failure_total"` + MediaInfoProbeActive int64 `json:"mediainfo_probe_active"` + MediaInfoScheduler SchedulerSnapshot `json:"mediainfo_scheduler"` + MediaInfoEnrichedTotal uint64 `json:"mediainfo_enriched_total"` + MediaInfoFailOpenTotal uint64 `json:"mediainfo_fail_open_total"` + MediaInfoWaitActive int64 `json:"mediainfo_wait_active"` + MediaInfoWaitRejectedTotal uint64 `json:"mediainfo_wait_rejected_total"` + MediaInfoPrewarmTriggeredTotal uint64 `json:"mediainfo_prewarm_triggered_total"` + MediaInfoPrewarmReplacedTotal uint64 `json:"mediainfo_prewarm_replaced_total"` + MediaInfoPrewarmDiscoverySuccessTotal uint64 `json:"mediainfo_prewarm_discovery_success_total"` + MediaInfoPrewarmDiscoveryFailureTotal uint64 `json:"mediainfo_prewarm_discovery_failure_total"` + MediaInfoPrewarmFreshCacheTotal uint64 `json:"mediainfo_prewarm_fresh_cache_total"` + MediaInfoPrewarmJoinedFlightTotal uint64 `json:"mediainfo_prewarm_joined_flight_total"` + MediaInfoPrewarmQueuedTotal uint64 `json:"mediainfo_prewarm_queued_total"` + MediaInfoPrewarmRejectedTotal uint64 `json:"mediainfo_prewarm_rejected_total"` + MediaInfoPrewarmSkippedTotal uint64 `json:"mediainfo_prewarm_skipped_total"` ResolverLatencyMSTotal uint64 `json:"resolver_latency_ms_total"` ResolverLatencySamples uint64 `json:"resolver_latency_samples"` @@ -100,6 +156,25 @@ type Snapshot struct { MediaInfoProbeLatencyMSMax uint64 `json:"mediainfo_probe_latency_ms_max"` } +// SchedulerPrioritySnapshot contains one fixed MediaInfo priority class. The +// shape is intentionally label-free so request identities cannot expand it. +type SchedulerPrioritySnapshot struct { + OfferedTotal uint64 `json:"offered_total"` + QueuedTotal uint64 `json:"queued_total"` + JoinedTotal uint64 `json:"joined_total"` + PromotedTotal uint64 `json:"promoted_total"` + DroppedFullTotal uint64 `json:"dropped_full_total"` + DroppedExpiredTotal uint64 `json:"dropped_expired_total"` + SupersededTotal uint64 `json:"superseded_total"` +} + +// SchedulerSnapshot exposes only the fixed P0, P1, and P2 classes. +type SchedulerSnapshot struct { + P0 SchedulerPrioritySnapshot `json:"p0"` + P1 SchedulerPrioritySnapshot `json:"p1"` + P2 SchedulerPrioritySnapshot `json:"p2"` +} + // New creates an empty process-local metrics registry. func New() *Metrics { return &Metrics{} @@ -136,14 +211,60 @@ func (m *Metrics) IncPlexFallback() { m.plexFallbackTotal.Add(1) } -// IncMediaInfoCacheHits records a fresh L1 result. +// IncMetadataAnalysisParamsRemoved records one metadata request whose +// on-demand file-analysis controls were removed before proxying to Plex. +func (m *Metrics) IncMetadataAnalysisParamsRemoved() { + m.metadataAnalysisParamsRemoved.Add(1) +} + +// IncMediaInfoCacheHits records an exact key satisfied by L1 or SQLite, +// including a retained stale result returned while refresh runs separately. func (m *Metrics) IncMediaInfoCacheHits() { m.mediaInfoCacheHits.Add(1) } -// IncMediaInfoCacheMisses records an exact key not satisfied by L1. +// IncMediaInfoCacheMisses records an exact key not satisfied by L1 or SQLite. func (m *Metrics) IncMediaInfoCacheMisses() { m.mediaInfoCacheMisses.Add(1) } +// IncMediaInfoProbeOffered records one valid admission attempt at the scheduler. +func (m *Metrics) IncMediaInfoProbeOffered(priority uint8) { + m.mediaInfoProbeOffered.Add(1) + incrementPriority(&m.mediaInfoScheduler.offered, priority) +} + // IncMediaInfoProbeQueued records a newly scheduled singleflight job. -func (m *Metrics) IncMediaInfoProbeQueued() { m.mediaInfoProbeQueued.Add(1) } +func (m *Metrics) IncMediaInfoProbeQueued(priority uint8) { + m.mediaInfoProbeQueued.Add(1) + incrementPriority(&m.mediaInfoScheduler.queued, priority) +} + +// IncMediaInfoProbeJoined records a signal merged into an exact-key flight. +func (m *Metrics) IncMediaInfoProbeJoined(priority uint8) { + m.mediaInfoProbeJoined.Add(1) + incrementPriority(&m.mediaInfoScheduler.joined, priority) +} + +// IncMediaInfoProbePromoted records an exact-key job raised to a higher class. +func (m *Metrics) IncMediaInfoProbePromoted(priority uint8) { + m.mediaInfoProbePromoted.Add(1) + incrementPriority(&m.mediaInfoScheduler.promoted, priority) +} + +// IncMediaInfoProbeDroppedFull records work rejected by its bounded queue. +func (m *Metrics) IncMediaInfoProbeDroppedFull(priority uint8) { + m.mediaInfoProbeDroppedFull.Add(1) + incrementPriority(&m.mediaInfoScheduler.droppedFull, priority) +} + +// IncMediaInfoProbeDroppedExpired records background work discarded before I/O. +func (m *Metrics) IncMediaInfoProbeDroppedExpired(priority uint8) { + m.mediaInfoProbeDroppedExpired.Add(1) + incrementPriority(&m.mediaInfoScheduler.droppedExpired, priority) +} + +// IncMediaInfoProbeSuperseded records stale fingerprint work replaced in queue. +func (m *Metrics) IncMediaInfoProbeSuperseded(priority uint8) { + m.mediaInfoProbeSuperseded.Add(1) + incrementPriority(&m.mediaInfoScheduler.superseded, priority) +} // IncMediaInfoProbeSuccess records a complete normalized probe result. func (m *Metrics) IncMediaInfoProbeSuccess() { m.mediaInfoProbeSuccess.Add(1) } @@ -184,6 +305,39 @@ func (m *Metrics) DecMediaInfoWaitActive() { decrementGauge(&m.mediaInfoWaitActi // because the bounded enrichment pool was full. func (m *Metrics) IncMediaInfoWaitRejected() { m.mediaInfoWaitRejected.Add(1) } +// IncMediaInfoPrewarmTriggered records an accepted in-memory redirect event. +func (m *Metrics) IncMediaInfoPrewarmTriggered() { m.mediaInfoPrewarmTriggered.Add(1) } + +// IncMediaInfoPrewarmReplaced records a pending or active nearby-item window +// replaced by a newer playback in the same client session. +func (m *Metrics) IncMediaInfoPrewarmReplaced() { m.mediaInfoPrewarmReplaced.Add(1) } + +// IncMediaInfoPrewarmDiscoverySuccess records a usable nearby-item window. +func (m *Metrics) IncMediaInfoPrewarmDiscoverySuccess() { + m.mediaInfoPrewarmDiscoverySuccess.Add(1) +} + +// IncMediaInfoPrewarmDiscoveryFailure records a bounded Plex discovery error. +func (m *Metrics) IncMediaInfoPrewarmDiscoveryFailure() { + m.mediaInfoPrewarmDiscoveryFailure.Add(1) +} + +// IncMediaInfoPrewarmFreshCache records a prewarm satisfied by fresh MediaInfo. +func (m *Metrics) IncMediaInfoPrewarmFreshCache() { m.mediaInfoPrewarmFreshCache.Add(1) } + +// IncMediaInfoPrewarmJoinedFlight records a prewarm joined to active work. +func (m *Metrics) IncMediaInfoPrewarmJoinedFlight() { m.mediaInfoPrewarmJoinedFlight.Add(1) } + +// IncMediaInfoPrewarmQueued records a newly queued MediaInfo probe. +func (m *Metrics) IncMediaInfoPrewarmQueued() { m.mediaInfoPrewarmQueued.Add(1) } + +// IncMediaInfoPrewarmRejected records a MediaInfo admission rejection. +func (m *Metrics) IncMediaInfoPrewarmRejected() { m.mediaInfoPrewarmRejected.Add(1) } + +// IncMediaInfoPrewarmSkipped records a duplicate, unsafe, unavailable, or +// otherwise non-actionable speculative event. +func (m *Metrics) IncMediaInfoPrewarmSkipped() { m.mediaInfoPrewarmSkipped.Add(1) } + // IncMetadataGuardAdmitted records a detailed metadata request admitted to // Plex after both client and global limits were acquired. func (m *Metrics) IncMetadataGuardAdmitted() { @@ -248,6 +402,29 @@ func (m *Metrics) DecMetadataBatchGuardQueued() { decrementGauge(&m.metadataBatchQueued) } +// IncMetadataCoalesceOffered records one eligible single-item read entering +// the bounded aggregation window. +func (m *Metrics) IncMetadataCoalesceOffered() { m.metadataCoalesceOffered.Add(1) } + +// IncMetadataCoalesceBatch records one native Plex batch and its unique item +// count. Duplicate waiters do not inflate the item count. +func (m *Metrics) IncMetadataCoalesceBatch(items int) { + m.metadataCoalesceBatches.Add(1) + if items > 0 { + m.metadataCoalesceItems.Add(uint64(items)) + } +} + +// IncMetadataCoalesceFallback records a batch that returned through bounded +// single-item Plex requests because its response was not safely splittable. +func (m *Metrics) IncMetadataCoalesceFallback() { m.metadataCoalesceFallbacks.Add(1) } + +// IncMetadataCoalesceActive marks one native Plex batch in flight. +func (m *Metrics) IncMetadataCoalesceActive() { m.metadataCoalesceActive.Add(1) } + +// DecMetadataCoalesceActive marks one native Plex batch complete. +func (m *Metrics) DecMetadataCoalesceActive() { decrementGauge(&m.metadataCoalesceActive) } + // ObserveResolverLatency records one MediaVault direct-link lookup without // attaching request, client, Part, or URL labels. func (m *Metrics) ObserveResolverLatency(duration time.Duration) { @@ -317,33 +494,55 @@ func (m *Metrics) Snapshot() Snapshot { redirectTotal, redirectSamples, redirectLast, redirectMax := m.redirectLatency.snapshot() mediaInfoTotal, mediaInfoSamples, mediaInfoLast, mediaInfoMax := m.mediaInfoProbeLatency.snapshot() return Snapshot{ - PlexRequestsTotal: m.plexRequestsTotal.Load(), - CloudPartHits: m.cloudPartHits.Load(), - CloudPartMisses: m.cloudPartMisses.Load(), - RedirectSuccess: m.redirectSuccess.Load(), - RedirectFailure: m.redirectFailure.Load(), - PlexFallbackTotal: m.plexFallbackTotal.Load(), - ActiveRequests: m.activeRequests.Load(), - - MetadataGuardAdmittedTotal: m.metadataAdmitted.Load(), - MetadataGuardTimeoutsTotal: m.metadataTimeouts.Load(), - MetadataGuardActive: m.metadataActive.Load(), - MetadataGuardQueued: m.metadataQueued.Load(), - MetadataBatchGuardAdmittedTotal: m.metadataBatchAdmitted.Load(), - MetadataBatchGuardTimeoutsTotal: m.metadataBatchTimeouts.Load(), - MetadataBatchGuardActive: m.metadataBatchActive.Load(), - MetadataBatchGuardQueued: m.metadataBatchQueued.Load(), - MediaInfoCacheHitsTotal: m.mediaInfoCacheHits.Load(), - MediaInfoCacheMissesTotal: m.mediaInfoCacheMisses.Load(), - MediaInfoProbeQueuedTotal: m.mediaInfoProbeQueued.Load(), - MediaInfoProbeSuccessTotal: m.mediaInfoProbeSuccess.Load(), - MediaInfoProbeFailureTotal: m.mediaInfoProbeFailure.Load(), - MediaInfoStoreFailureTotal: m.mediaInfoStoreFailure.Load(), - MediaInfoProbeActive: m.mediaInfoProbeActive.Load(), - MediaInfoEnrichedTotal: m.mediaInfoEnriched.Load(), - MediaInfoFailOpenTotal: m.mediaInfoFailOpen.Load(), - MediaInfoWaitActive: m.mediaInfoWaitActive.Load(), - MediaInfoWaitRejectedTotal: m.mediaInfoWaitRejected.Load(), + PlexRequestsTotal: m.plexRequestsTotal.Load(), + CloudPartHits: m.cloudPartHits.Load(), + CloudPartMisses: m.cloudPartMisses.Load(), + RedirectSuccess: m.redirectSuccess.Load(), + RedirectFailure: m.redirectFailure.Load(), + PlexFallbackTotal: m.plexFallbackTotal.Load(), + ActiveRequests: m.activeRequests.Load(), + MetadataAnalysisParamsRemovedTotal: m.metadataAnalysisParamsRemoved.Load(), + + MetadataGuardAdmittedTotal: m.metadataAdmitted.Load(), + MetadataGuardTimeoutsTotal: m.metadataTimeouts.Load(), + MetadataGuardActive: m.metadataActive.Load(), + MetadataGuardQueued: m.metadataQueued.Load(), + MetadataBatchGuardAdmittedTotal: m.metadataBatchAdmitted.Load(), + MetadataBatchGuardTimeoutsTotal: m.metadataBatchTimeouts.Load(), + MetadataBatchGuardActive: m.metadataBatchActive.Load(), + MetadataBatchGuardQueued: m.metadataBatchQueued.Load(), + MetadataCoalesceOfferedTotal: m.metadataCoalesceOffered.Load(), + MetadataCoalesceBatchesTotal: m.metadataCoalesceBatches.Load(), + MetadataCoalesceItemsTotal: m.metadataCoalesceItems.Load(), + MetadataCoalesceFallbacksTotal: m.metadataCoalesceFallbacks.Load(), + MetadataCoalesceActive: m.metadataCoalesceActive.Load(), + MediaInfoCacheHitsTotal: m.mediaInfoCacheHits.Load(), + MediaInfoCacheMissesTotal: m.mediaInfoCacheMisses.Load(), + MediaInfoProbeOfferedTotal: m.mediaInfoProbeOffered.Load(), + MediaInfoProbeQueuedTotal: m.mediaInfoProbeQueued.Load(), + MediaInfoProbeJoinedTotal: m.mediaInfoProbeJoined.Load(), + MediaInfoProbePromotedTotal: m.mediaInfoProbePromoted.Load(), + MediaInfoProbeDroppedFullTotal: m.mediaInfoProbeDroppedFull.Load(), + MediaInfoProbeDroppedExpiredTotal: m.mediaInfoProbeDroppedExpired.Load(), + MediaInfoProbeSupersededTotal: m.mediaInfoProbeSuperseded.Load(), + MediaInfoProbeSuccessTotal: m.mediaInfoProbeSuccess.Load(), + MediaInfoProbeFailureTotal: m.mediaInfoProbeFailure.Load(), + MediaInfoStoreFailureTotal: m.mediaInfoStoreFailure.Load(), + MediaInfoProbeActive: m.mediaInfoProbeActive.Load(), + MediaInfoScheduler: m.mediaInfoScheduler.snapshot(), + MediaInfoEnrichedTotal: m.mediaInfoEnriched.Load(), + MediaInfoFailOpenTotal: m.mediaInfoFailOpen.Load(), + MediaInfoWaitActive: m.mediaInfoWaitActive.Load(), + MediaInfoWaitRejectedTotal: m.mediaInfoWaitRejected.Load(), + MediaInfoPrewarmTriggeredTotal: m.mediaInfoPrewarmTriggered.Load(), + MediaInfoPrewarmReplacedTotal: m.mediaInfoPrewarmReplaced.Load(), + MediaInfoPrewarmDiscoverySuccessTotal: m.mediaInfoPrewarmDiscoverySuccess.Load(), + MediaInfoPrewarmDiscoveryFailureTotal: m.mediaInfoPrewarmDiscoveryFailure.Load(), + MediaInfoPrewarmFreshCacheTotal: m.mediaInfoPrewarmFreshCache.Load(), + MediaInfoPrewarmJoinedFlightTotal: m.mediaInfoPrewarmJoinedFlight.Load(), + MediaInfoPrewarmQueuedTotal: m.mediaInfoPrewarmQueued.Load(), + MediaInfoPrewarmRejectedTotal: m.mediaInfoPrewarmRejected.Load(), + MediaInfoPrewarmSkippedTotal: m.mediaInfoPrewarmSkipped.Load(), ResolverLatencyMSTotal: resolverTotal, ResolverLatencySamples: resolverSamples, @@ -362,6 +561,32 @@ func (m *Metrics) Snapshot() Snapshot { } } +func incrementPriority(counters *[mediaInfoPriorityCount]atomic.Uint64, priority uint8) { + if priority < mediaInfoPriorityCount { + counters[priority].Add(1) + } +} + +func (metrics *schedulerMetrics) snapshot() SchedulerSnapshot { + return SchedulerSnapshot{ + P0: metrics.prioritySnapshot(0), + P1: metrics.prioritySnapshot(1), + P2: metrics.prioritySnapshot(2), + } +} + +func (metrics *schedulerMetrics) prioritySnapshot(priority int) SchedulerPrioritySnapshot { + return SchedulerPrioritySnapshot{ + OfferedTotal: metrics.offered[priority].Load(), + QueuedTotal: metrics.queued[priority].Load(), + JoinedTotal: metrics.joined[priority].Load(), + PromotedTotal: metrics.promoted[priority].Load(), + DroppedFullTotal: metrics.droppedFull[priority].Load(), + DroppedExpiredTotal: metrics.droppedExpired[priority].Load(), + SupersededTotal: metrics.superseded[priority].Load(), + } +} + func decrementGauge(gauge *atomic.Int64) { for { current := gauge.Load() diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go index b3365ba..d84af56 100644 --- a/internal/metrics/metrics_test.go +++ b/internal/metrics/metrics_test.go @@ -19,6 +19,7 @@ func TestSnapshotAndJSONHandler(t *testing.T) { registry.IncRedirectSuccess() registry.IncRedirectFailure() registry.IncPlexFallback() + registry.IncMetadataAnalysisParamsRemoved() registry.IncMetadataGuardAdmitted() registry.IncMetadataGuardTimeouts() registry.IncMetadataGuardActive() @@ -27,9 +28,19 @@ func TestSnapshotAndJSONHandler(t *testing.T) { registry.IncMetadataBatchGuardTimeouts() registry.IncMetadataBatchGuardActive() registry.IncMetadataBatchGuardQueued() + registry.IncMetadataCoalesceOffered() + registry.IncMetadataCoalesceBatch(3) + registry.IncMetadataCoalesceFallback() + registry.IncMetadataCoalesceActive() registry.IncMediaInfoCacheHits() registry.IncMediaInfoCacheMisses() - registry.IncMediaInfoProbeQueued() + registry.IncMediaInfoProbeOffered(0) + registry.IncMediaInfoProbeQueued(0) + registry.IncMediaInfoProbeJoined(0) + registry.IncMediaInfoProbePromoted(0) + registry.IncMediaInfoProbeDroppedFull(0) + registry.IncMediaInfoProbeDroppedExpired(0) + registry.IncMediaInfoProbeSuperseded(0) registry.IncMediaInfoProbeSuccess() registry.IncMediaInfoProbeFailure() registry.IncMediaInfoStoreFailure() @@ -38,6 +49,15 @@ func TestSnapshotAndJSONHandler(t *testing.T) { registry.IncMediaInfoFailOpen() registry.IncMediaInfoWaitActive() registry.IncMediaInfoWaitRejected() + registry.IncMediaInfoPrewarmTriggered() + registry.IncMediaInfoPrewarmReplaced() + registry.IncMediaInfoPrewarmDiscoverySuccess() + registry.IncMediaInfoPrewarmDiscoveryFailure() + registry.IncMediaInfoPrewarmFreshCache() + registry.IncMediaInfoPrewarmJoinedFlight() + registry.IncMediaInfoPrewarmQueued() + registry.IncMediaInfoPrewarmRejected() + registry.IncMediaInfoPrewarmSkipped() registry.ObserveMediaInfoProbeLatency(44 * time.Millisecond) registry.ObserveResolverLatency(12 * time.Millisecond) registry.ObserveResolverLatency(7 * time.Millisecond) @@ -59,33 +79,58 @@ func TestSnapshotAndJSONHandler(t *testing.T) { t.Fatalf("decode JSON: %v", err) } want := Snapshot{ - PlexRequestsTotal: 1, - CloudPartHits: 1, - CloudPartMisses: 1, - RedirectSuccess: 1, - RedirectFailure: 1, - PlexFallbackTotal: 1, - ActiveRequests: 1, + PlexRequestsTotal: 1, + CloudPartHits: 1, + CloudPartMisses: 1, + RedirectSuccess: 1, + RedirectFailure: 1, + PlexFallbackTotal: 1, + ActiveRequests: 1, + MetadataAnalysisParamsRemovedTotal: 1, - MetadataGuardAdmittedTotal: 1, - MetadataGuardTimeoutsTotal: 1, - MetadataGuardActive: 1, - MetadataGuardQueued: 1, - MetadataBatchGuardAdmittedTotal: 1, - MetadataBatchGuardTimeoutsTotal: 1, - MetadataBatchGuardActive: 1, - MetadataBatchGuardQueued: 1, - MediaInfoCacheHitsTotal: 1, - MediaInfoCacheMissesTotal: 1, - MediaInfoProbeQueuedTotal: 1, - MediaInfoProbeSuccessTotal: 1, - MediaInfoProbeFailureTotal: 1, - MediaInfoStoreFailureTotal: 1, - MediaInfoProbeActive: 1, - MediaInfoEnrichedTotal: 1, - MediaInfoFailOpenTotal: 1, - MediaInfoWaitActive: 1, - MediaInfoWaitRejectedTotal: 1, + MetadataGuardAdmittedTotal: 1, + MetadataGuardTimeoutsTotal: 1, + MetadataGuardActive: 1, + MetadataGuardQueued: 1, + MetadataBatchGuardAdmittedTotal: 1, + MetadataBatchGuardTimeoutsTotal: 1, + MetadataBatchGuardActive: 1, + MetadataBatchGuardQueued: 1, + MetadataCoalesceOfferedTotal: 1, + MetadataCoalesceBatchesTotal: 1, + MetadataCoalesceItemsTotal: 3, + MetadataCoalesceFallbacksTotal: 1, + MetadataCoalesceActive: 1, + MediaInfoCacheHitsTotal: 1, + MediaInfoCacheMissesTotal: 1, + MediaInfoProbeOfferedTotal: 1, + MediaInfoProbeQueuedTotal: 1, + MediaInfoProbeJoinedTotal: 1, + MediaInfoProbePromotedTotal: 1, + MediaInfoProbeDroppedFullTotal: 1, + MediaInfoProbeDroppedExpiredTotal: 1, + MediaInfoProbeSupersededTotal: 1, + MediaInfoProbeSuccessTotal: 1, + MediaInfoProbeFailureTotal: 1, + MediaInfoStoreFailureTotal: 1, + MediaInfoProbeActive: 1, + MediaInfoScheduler: SchedulerSnapshot{P0: SchedulerPrioritySnapshot{ + OfferedTotal: 1, QueuedTotal: 1, JoinedTotal: 1, PromotedTotal: 1, + DroppedFullTotal: 1, DroppedExpiredTotal: 1, SupersededTotal: 1, + }}, + MediaInfoEnrichedTotal: 1, + MediaInfoFailOpenTotal: 1, + MediaInfoWaitActive: 1, + MediaInfoWaitRejectedTotal: 1, + MediaInfoPrewarmTriggeredTotal: 1, + MediaInfoPrewarmReplacedTotal: 1, + MediaInfoPrewarmDiscoverySuccessTotal: 1, + MediaInfoPrewarmDiscoveryFailureTotal: 1, + MediaInfoPrewarmFreshCacheTotal: 1, + MediaInfoPrewarmJoinedFlightTotal: 1, + MediaInfoPrewarmQueuedTotal: 1, + MediaInfoPrewarmRejectedTotal: 1, + MediaInfoPrewarmSkippedTotal: 1, ResolverLatencyMSTotal: 19, ResolverLatencySamples: 2, @@ -110,6 +155,7 @@ func TestSnapshotAndJSONHandler(t *testing.T) { registry.DecMetadataGuardQueued() registry.DecMetadataBatchGuardActive() registry.DecMetadataBatchGuardQueued() + registry.DecMetadataCoalesceActive() registry.DecMediaInfoProbeActive() registry.DecMediaInfoWaitActive() if got := registry.Snapshot().ActiveRequests; got != 0 { @@ -163,6 +209,7 @@ func TestActiveRequestReleaseIsIdempotentAndClamped(t *testing.T) { registry.DecMetadataGuardQueued() registry.DecMetadataBatchGuardActive() registry.DecMetadataBatchGuardQueued() + registry.DecMetadataCoalesceActive() registry.DecMediaInfoProbeActive() registry.DecMediaInfoWaitActive() if got := registry.Snapshot().ActiveRequests; got != 0 { @@ -192,6 +239,7 @@ func TestCountersAreSafeForConcurrentUpdates(t *testing.T) { registry.IncRedirectSuccess() registry.IncRedirectFailure() registry.IncPlexFallback() + registry.IncMetadataAnalysisParamsRemoved() registry.IncMetadataGuardAdmitted() registry.IncMetadataGuardTimeouts() registry.IncMetadataGuardActive() @@ -204,9 +252,20 @@ func TestCountersAreSafeForConcurrentUpdates(t *testing.T) { registry.DecMetadataBatchGuardActive() registry.IncMetadataBatchGuardQueued() registry.DecMetadataBatchGuardQueued() + registry.IncMetadataCoalesceOffered() + registry.IncMetadataCoalesceBatch(2) + registry.IncMetadataCoalesceFallback() + registry.IncMetadataCoalesceActive() + registry.DecMetadataCoalesceActive() registry.IncMediaInfoCacheHits() registry.IncMediaInfoCacheMisses() - registry.IncMediaInfoProbeQueued() + registry.IncMediaInfoProbeOffered(0) + registry.IncMediaInfoProbeQueued(1) + registry.IncMediaInfoProbeJoined(2) + registry.IncMediaInfoProbePromoted(0) + registry.IncMediaInfoProbeDroppedFull(1) + registry.IncMediaInfoProbeDroppedExpired(2) + registry.IncMediaInfoProbeSuperseded(0) registry.IncMediaInfoProbeSuccess() registry.IncMediaInfoProbeFailure() registry.IncMediaInfoStoreFailure() @@ -217,6 +276,15 @@ func TestCountersAreSafeForConcurrentUpdates(t *testing.T) { registry.IncMediaInfoWaitActive() registry.DecMediaInfoWaitActive() registry.IncMediaInfoWaitRejected() + registry.IncMediaInfoPrewarmTriggered() + registry.IncMediaInfoPrewarmReplaced() + registry.IncMediaInfoPrewarmDiscoverySuccess() + registry.IncMediaInfoPrewarmDiscoveryFailure() + registry.IncMediaInfoPrewarmFreshCache() + registry.IncMediaInfoPrewarmJoinedFlight() + registry.IncMediaInfoPrewarmQueued() + registry.IncMediaInfoPrewarmRejected() + registry.IncMediaInfoPrewarmSkipped() registry.ObserveMediaInfoProbeLatency(3 * time.Millisecond) registry.ObserveResolverLatency(time.Millisecond) registry.ObserveRedirectLatency(2 * time.Millisecond) @@ -230,14 +298,24 @@ func TestCountersAreSafeForConcurrentUpdates(t *testing.T) { got := registry.Snapshot() if got.PlexRequestsTotal != want || got.CloudPartHits != want || got.CloudPartMisses != want || got.RedirectSuccess != want || got.RedirectFailure != want || got.PlexFallbackTotal != want || got.ActiveRequests != 0 || + got.MetadataAnalysisParamsRemovedTotal != want || got.MetadataGuardAdmittedTotal != want || got.MetadataGuardTimeoutsTotal != want || got.MetadataGuardActive != 0 || got.MetadataGuardQueued != 0 || got.MetadataBatchGuardAdmittedTotal != want || got.MetadataBatchGuardTimeoutsTotal != want || got.MetadataBatchGuardActive != 0 || got.MetadataBatchGuardQueued != 0 || + got.MetadataCoalesceOfferedTotal != want || got.MetadataCoalesceBatchesTotal != want || + got.MetadataCoalesceItemsTotal != 2*want || got.MetadataCoalesceFallbacksTotal != want || got.MetadataCoalesceActive != 0 || got.MediaInfoCacheHitsTotal != want || got.MediaInfoCacheMissesTotal != want || - got.MediaInfoProbeQueuedTotal != want || got.MediaInfoProbeSuccessTotal != want || + got.MediaInfoProbeOfferedTotal != want || got.MediaInfoProbeQueuedTotal != want || got.MediaInfoProbeJoinedTotal != want || + got.MediaInfoProbePromotedTotal != want || got.MediaInfoProbeDroppedFullTotal != want || + got.MediaInfoProbeDroppedExpiredTotal != want || got.MediaInfoProbeSupersededTotal != want || + got.MediaInfoProbeSuccessTotal != want || got.MediaInfoProbeFailureTotal != want || got.MediaInfoStoreFailureTotal != want || got.MediaInfoProbeActive != 0 || got.MediaInfoEnrichedTotal != want || got.MediaInfoFailOpenTotal != want || got.MediaInfoWaitActive != 0 || got.MediaInfoWaitRejectedTotal != want || + got.MediaInfoPrewarmTriggeredTotal != want || got.MediaInfoPrewarmReplacedTotal != want || + got.MediaInfoPrewarmDiscoverySuccessTotal != want || got.MediaInfoPrewarmDiscoveryFailureTotal != want || + got.MediaInfoPrewarmFreshCacheTotal != want || got.MediaInfoPrewarmJoinedFlightTotal != want || + got.MediaInfoPrewarmQueuedTotal != want || got.MediaInfoPrewarmRejectedTotal != want || got.MediaInfoPrewarmSkippedTotal != want || got.ResolverLatencySamples != want || got.ResolverLatencyMSTotal != want || got.RedirectLatencySamples != want || got.RedirectLatencyMSTotal != 2*want || got.MediaInfoProbeLatencySamples != want || got.MediaInfoProbeLatencyMSTotal != 3*want { diff --git a/internal/observe/observe.go b/internal/observe/observe.go index 7386344..dc41b21 100644 --- a/internal/observe/observe.go +++ b/internal/observe/observe.go @@ -20,10 +20,12 @@ const defaultMaxBodyBytes int64 = 4 << 20 // Config controls which Plex responses are eligible for observation. // MetadataPaths are path prefixes; a trailing * is accepted for readability // and matches the remainder of that path. MaxBodyBytes bounds both the copied -// wire body and the decompressed gzip body. +// wire body and the decompressed gzip body. SkipRequest prevents redundant +// observation when another layer owns parsing of a synthetic request. type Config struct { MetadataPaths []string MaxBodyBytes int64 + SkipRequest func(*http.Request) bool OnParts func(Observation) } @@ -42,6 +44,7 @@ type RoundTripper struct { base http.RoundTripper metadataPaths []string maxBodyBytes int64 + skipRequest func(*http.Request) bool onParts func(Observation) } @@ -59,6 +62,7 @@ func NewRoundTripper(base http.RoundTripper, config Config) *RoundTripper { base: base, metadataPaths: append([]string(nil), config.MetadataPaths...), maxBodyBytes: maxBodyBytes, + skipRequest: config.SkipRequest, onParts: config.OnParts, } } @@ -70,7 +74,8 @@ func (t *RoundTripper) RoundTrip(request *http.Request) (*http.Response, error) if err != nil || response == nil || response.Body == nil || t.onParts == nil { return response, err } - if !matchesMetadataPath(request.URL.Path, t.metadataPaths) || !successfulMetadataResponse(response) { + if (t.skipRequest != nil && t.skipRequest(request)) || + !matchesMetadataPath(request.URL.Path, t.metadataPaths) || !successfulMetadataResponse(response) { return response, nil } response.Body = &observedBody{ diff --git a/internal/observe/observe_test.go b/internal/observe/observe_test.go index cbcb329..144c256 100644 --- a/internal/observe/observe_test.go +++ b/internal/observe/observe_test.go @@ -205,6 +205,38 @@ func TestRoundTripperFailsOpenOnEarlyClose(t *testing.T) { } } +func TestRoundTripperSkipsObservationBeforeWrappingBody(t *testing.T) { + raw := []byte(``) + called := false + transport := NewRoundTripper(roundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/xml"}}, + Body: io.NopCloser(bytes.NewReader(raw)), + Request: request, + }, nil + }), Config{ + MetadataPaths: []string{"/library/metadata/*"}, + SkipRequest: func(*http.Request) bool { return true }, + OnParts: func(Observation) { + called = true + }, + }) + + response, err := transport.RoundTrip(httptest.NewRequest(http.MethodGet, "http://plex.test/library/metadata/42", nil)) + if err != nil { + t.Fatal(err) + } + if _, wrapped := response.Body.(*observedBody); wrapped { + t.Fatal("skipped request body was wrapped for observation") + } + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + if called { + t.Fatal("observer callback ran for a skipped request") + } +} + func TestRoundTripperSkipsUnconfiguredOrUnsuccessfulResponses(t *testing.T) { for _, test := range []struct { name string diff --git a/internal/partcache/partcache.go b/internal/partcache/partcache.go index 7573968..8d1eea1 100644 --- a/internal/partcache/partcache.go +++ b/internal/partcache/partcache.go @@ -12,6 +12,7 @@ import ( // stamp may vary after a library refresh. type PartInfo struct { PartID string + RatingKey string PlexFilePath string PartKey string UpdatedAt time.Time @@ -60,6 +61,11 @@ func (c *Cache) Put(info PartInfo) { c.mu.Lock() defer c.mu.Unlock() c.sweepIfDue(now) + if info.RatingKey == "" { + if current, exists := c.entries[info.PartID]; exists && now.Before(current.expiresAt) { + info.RatingKey = current.info.RatingKey + } + } c.entries[info.PartID] = entry{ info: info, expiresAt: now.Add(c.ttl), diff --git a/internal/partcache/partcache_test.go b/internal/partcache/partcache_test.go index 6d2a6e8..b16e7ae 100644 --- a/internal/partcache/partcache_test.go +++ b/internal/partcache/partcache_test.go @@ -60,6 +60,17 @@ func TestPutSweepsExpiredUnrelatedEntries(t *testing.T) { } } +func TestObservationWithoutRatingKeyDoesNotEraseKnownAssociation(t *testing.T) { + cache := New(time.Minute) + cache.Put(PartInfo{PartID: "123", RatingKey: "42", PlexFilePath: "/cloud/episode.strm"}) + cache.Put(PartInfo{PartID: "123", PlexFilePath: "/cloud/episode.strm", PartKey: "/library/parts/123/file"}) + + got, ok := cache.Get("123") + if !ok || got.RatingKey != "42" || got.PartKey != "/library/parts/123/file" { + t.Fatalf("PartInfo = %#v, found = %v", got, ok) + } +} + func TestEmptyPartIDIsIgnored(t *testing.T) { cache := New(time.Minute) cache.Put(PartInfo{PlexFilePath: "/media/cloud/empty.strm"}) diff --git a/internal/playback/service.go b/internal/playback/service.go index 7358215..026253f 100644 --- a/internal/playback/service.go +++ b/internal/playback/service.go @@ -29,8 +29,9 @@ const ( // PreparedPart binds the Plex identity used for authorization to the exact // control target read from its mapped STRM file. type PreparedPart struct { - Part plexmeta.Part - Target string + Part plexmeta.Part + RatingKey string + Target string } // Preparation is a typed result so HTTP adapters can preserve transparent @@ -104,22 +105,35 @@ type Options struct { // Service prepares, authorizes, and resolves cloud Parts. It never writes an // HTTP response; protocol adapters remain responsible for redirect or fallback. type Service struct { - cache *partcache.Cache + cache *partcache.Cache + resolver resolver.ControlResolver + authorizePart AuthorizePartFunc + preparer *PartPreparer +} + +// PartPreparer owns the pure cloud-file classification, path mapping, and STRM +// target read shared by live playback and speculative analysis. +type PartPreparer struct { mapper *pathmap.Mapper resolver resolver.ControlResolver - authorizePart AuthorizePartFunc cloudExtensions map[string]struct{} } +// NewPartPreparer creates the side-effect-bounded preparation boundary. It +// never contacts Plex, MediaVault, or the media origin. +func NewPartPreparer(mapper *pathmap.Mapper, controlResolver resolver.ControlResolver, cloudExtensions []string) *PartPreparer { + return &PartPreparer{ + mapper: mapper, resolver: controlResolver, + cloudExtensions: normalizeExtensions(cloudExtensions), + } +} + // New creates a playback service. Missing cloud collaborators intentionally // produce PreparationUnavailable so a proxy-only deployment remains valid. func New(options Options) *Service { return &Service{ - cache: options.Cache, - mapper: options.Mapper, - resolver: options.Resolver, - authorizePart: options.AuthorizePart, - cloudExtensions: normalizeExtensions(options.CloudExtensions), + cache: options.Cache, resolver: options.Resolver, authorizePart: options.AuthorizePart, + preparer: NewPartPreparer(options.Mapper, options.Resolver, options.CloudExtensions), } } @@ -133,27 +147,38 @@ func (s *Service) PrepareCached(partID string) Preparation { if !found { return Preparation{State: PreparationMissing, Reason: "cache_miss"} } - return s.Prepare(plexmeta.Part{ID: info.PartID, Key: info.PartKey, File: info.PlexFilePath}) + preparation := s.Prepare(plexmeta.Part{ID: info.PartID, Key: info.PartKey, File: info.PlexFilePath}) + preparation.Part.RatingKey = info.RatingKey + return preparation } // Prepare maps a cloud Part to its local STRM file and reads one validated // control target without contacting Plex, MediaVault, or the media origin. func (s *Service) Prepare(partInfo plexmeta.Part) Preparation { - if !s.enabled() { + if s == nil || s.preparer == nil { + return Preparation{State: PreparationUnavailable, Reason: "cloud_disabled"} + } + return s.preparer.Prepare(partInfo) +} + +// Prepare applies cloud Part rules without playback authorization or direct +// URL resolution. +func (preparer *PartPreparer) Prepare(partInfo plexmeta.Part) Preparation { + if preparer == nil || preparer.mapper == nil || preparer.resolver == nil { return Preparation{State: PreparationUnavailable, Reason: "cloud_disabled"} } prepared := PreparedPart{Part: partInfo} if partInfo.ID == "" || partInfo.File == "" { return Preparation{State: PreparationFailed, Part: prepared, Reason: "invalid_part"} } - if _, ok := s.cloudExtensions[strings.ToLower(path.Ext(partInfo.File))]; !ok { + if _, ok := preparer.cloudExtensions[strings.ToLower(path.Ext(partInfo.File))]; !ok { return Preparation{State: PreparationLocal, Part: prepared} } - localPath, err := s.mapper.Resolve(partInfo.File) + localPath, err := preparer.mapper.Resolve(partInfo.File) if err != nil { return Preparation{State: PreparationFailed, Part: prepared, Reason: "path_mapping", Cloud: true} } - target, err := s.resolver.ReadTarget(localPath) + target, err := preparer.resolver.ReadTarget(localPath) if err != nil { return Preparation{State: PreparationFailed, Part: prepared, Reason: "strm_target", Cloud: true} } @@ -170,6 +195,7 @@ func (s *Service) Remember(part PreparedPart) { } s.cache.Put(partcache.PartInfo{ PartID: partInfo.ID, + RatingKey: part.RatingKey, PlexFilePath: partInfo.File, PartKey: partInfo.Key, UpdatedAt: time.Now().UTC(), @@ -203,7 +229,8 @@ func (s *Service) Play(input PlayInput) (PlayResult, error) { } func (s *Service) enabled() bool { - return s != nil && s.cache != nil && s.mapper != nil && s.resolver != nil && s.authorizePart != nil + return s != nil && s.cache != nil && s.preparer != nil && s.preparer.mapper != nil && + s.preparer.resolver != nil && s.resolver != nil && s.authorizePart != nil } // resolverRequest preserves every client header and promotes query-carried diff --git a/internal/playback/service_test.go b/internal/playback/service_test.go index 7fdf25c..c45b363 100644 --- a/internal/playback/service_test.go +++ b/internal/playback/service_test.go @@ -39,11 +39,12 @@ func TestServicePreparationOwnsCloudClassificationAndTargetRead(t *testing.T) { } cache.Put(partcache.PartInfo{ PartID: "cloud", + RatingKey: "42", PartKey: "/library/parts/cloud/1/file", PlexFilePath: "/media/cloud/Movie.strm", }) got := service.PrepareCached("cloud") - if got.State != PreparationReady || got.Part.Target != control.target { + if got.State != PreparationReady || got.Part.Target != control.target || got.Part.RatingKey != "42" { t.Fatalf("cloud preparation = %#v", got) } if control.readPath != filepath.Join(localRoot, "Movie.strm") { @@ -105,7 +106,7 @@ func TestServicePlayPreservesClientContextAndRefreshesCache(t *testing.T) { control.playback.Header.Get("Range") != "bytes=100-200" { t.Fatalf("resolver request = %#v", control.playback) } - if cached, ok := cache.Get(part.Part.ID); !ok || cached.PartKey != part.Part.Key { + if cached, ok := cache.Get(part.Part.ID); !ok || cached.PartKey != part.Part.Key || cached.RatingKey != part.RatingKey { t.Fatalf("refreshed cache = %#v, found = %v", cached, ok) } } diff --git a/internal/plexmeta/batch_split.go b/internal/plexmeta/batch_split.go new file mode 100644 index 0000000..55a7f6f --- /dev/null +++ b/internal/plexmeta/batch_split.go @@ -0,0 +1,601 @@ +package plexmeta + +import ( + "bytes" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "strings" +) + +// SplitMetadataBatch splits one Plex metadata collection into one response +// per requested rating key. The collection must contain each requested item +// exactly once. Unknown container fields are retained, while every output +// contains only its selected item and reports size one. +// +// Plex's JSON shape stores items directly in MediaContainer.Metadata. Its XML +// shape stores items as direct MediaContainer children. XML children without a +// ratingKey are treated as container-level extension elements and copied to +// every output; a child with a ratingKey is always an item candidate. +func SplitMetadataBatch(body []byte, contentType string, ratingKeys []string) (map[string][]byte, error) { + wanted, err := normalizeBatchRatingKeys(ratingKeys) + if err != nil { + return nil, err + } + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 { + return nil, errors.New("Plex metadata batch response is empty") + } + if hasUTF8BOM(trimmed) { + trimmed = bytes.TrimSpace(trimmed[len([]byte{0xef, 0xbb, 0xbf}):]) + } + if len(trimmed) == 0 { + return nil, errors.New("Plex metadata batch response is empty") + } + + if strings.Contains(strings.ToLower(contentType), "json") || trimmed[0] == '{' || trimmed[0] == '[' { + return splitJSONMetadataBatch(body, wanted) + } + return splitXMLMetadataBatch(body, wanted) +} + +func normalizeBatchRatingKeys(values []string) (map[string]struct{}, error) { + if len(values) == 0 { + return nil, errors.New("Plex metadata batch has no requested rating keys") + } + wanted := make(map[string]struct{}, len(values)) + for _, value := range values { + normalized, err := normalizeBatchRatingKey(value) + if err != nil { + return nil, err + } + if _, exists := wanted[normalized]; exists { + return nil, fmt.Errorf("Plex metadata batch has duplicate requested rating key %q", normalized) + } + wanted[normalized] = struct{}{} + } + return wanted, nil +} + +func normalizeBatchRatingKey(value string) (string, error) { + if value == "" || value != strings.TrimSpace(value) || strings.ContainsAny(value, ",/?#\x00\r\n") { + return "", fmt.Errorf("invalid Plex rating key %q", value) + } + return value, nil +} + +func splitJSONMetadataBatch(body []byte, wanted map[string]struct{}) (map[string][]byte, error) { + hasBOM, leading, document, trailing := splitJSONEnvelope(body) + if len(document) == 0 { + return nil, errors.New("Plex JSON metadata batch response is empty") + } + + root, err := decodeJSONProjectionObject(document) + if err != nil { + return nil, fmt.Errorf("parse Plex JSON metadata batch: %w", err) + } + + containerRaw, ok := root["MediaContainer"] + if !ok { + return nil, errors.New("Plex JSON metadata batch has no MediaContainer") + } + container, err := decodeJSONProjectionObject(bytes.TrimSpace(containerRaw)) + if err != nil { + return nil, fmt.Errorf("Plex JSON metadata batch has invalid MediaContainer: %w", err) + } + + metadataRaw, ok := container["Metadata"] + if !ok { + return nil, errors.New("Plex JSON metadata batch has no Metadata") + } + items, metadataAsObject, err := decodeJSONBatchItems(metadataRaw) + if err != nil { + return nil, err + } + itemByKey := make(map[string]json.RawMessage, len(items)) + for _, item := range items { + ratingKey, err := jsonBatchRatingKey(item) + if err != nil { + return nil, err + } + if _, exists := itemByKey[ratingKey]; exists { + return nil, fmt.Errorf("Plex JSON metadata batch has duplicate rating key %q", ratingKey) + } + itemByKey[ratingKey] = item + } + if err := ensureBatchKeysPresent(itemByKey, wanted); err != nil { + return nil, err + } + + result := make(map[string][]byte, len(wanted)) + for ratingKey := range wanted { + output, err := encodeJSONBatchItem(root, container, metadataAsObject, itemByKey[ratingKey], hasBOM, leading, trailing) + if err != nil { + return nil, err + } + result[ratingKey] = output + } + return result, nil +} + +func decodeJSONBatchItems(raw json.RawMessage) ([]json.RawMessage, bool, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 { + return nil, false, errors.New("Plex JSON metadata batch has empty Metadata") + } + if trimmed[0] == '[' { + var items []json.RawMessage + if err := json.Unmarshal(trimmed, &items); err != nil { + return nil, false, fmt.Errorf("Plex JSON metadata batch has invalid Metadata: %w", err) + } + for _, item := range items { + if !isJSONObject(item) { + return nil, false, errors.New("Plex JSON metadata batch contains a non-object Metadata item") + } + } + return items, false, nil + } + if trimmed[0] == '{' { + if !isJSONObject(trimmed) { + return nil, false, errors.New("Plex JSON metadata batch contains an invalid Metadata item") + } + return []json.RawMessage{append(json.RawMessage(nil), trimmed...)}, true, nil + } + return nil, false, errors.New("Plex JSON metadata batch Metadata is neither an array nor an object") +} + +func isJSONObject(raw []byte) bool { + trimmed := bytes.TrimSpace(raw) + return len(trimmed) >= 2 && trimmed[0] == '{' && trimmed[len(trimmed)-1] == '}' +} + +func jsonBatchRatingKey(item json.RawMessage) (string, error) { + object, err := decodeJSONProjectionObject(bytes.TrimSpace(item)) + if err != nil { + return "", fmt.Errorf("parse Plex JSON metadata item: %w", err) + } + raw, ok := object["ratingKey"] + if !ok { + return "", errors.New("Plex JSON metadata item has no ratingKey") + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + var number json.Number + if numberErr := json.Unmarshal(raw, &number); numberErr != nil { + return "", errors.New("Plex JSON metadata item has an invalid ratingKey") + } + value = number.String() + } + normalized, err := normalizeBatchRatingKey(value) + if err != nil { + return "", err + } + return normalized, nil +} + +func ensureBatchKeysPresent(items map[string]json.RawMessage, wanted map[string]struct{}) error { + if len(items) != len(wanted) { + return errors.New("Plex metadata batch returned an unexpected item set") + } + for ratingKey := range wanted { + if _, ok := items[ratingKey]; !ok { + return fmt.Errorf("Plex metadata batch is missing requested rating key %q", ratingKey) + } + } + return nil +} + +func encodeJSONBatchItem( + root map[string]json.RawMessage, + container map[string]json.RawMessage, + metadataAsObject bool, + item json.RawMessage, + hasBOM bool, + leading, trailing []byte, +) ([]byte, error) { + containerCopy := cloneJSONRawMap(container) + containerCopy["size"] = json.RawMessage("1") + if metadataAsObject { + containerCopy["Metadata"] = append(json.RawMessage(nil), item...) + } else { + metadata, err := json.Marshal([]json.RawMessage{item}) + if err != nil { + return nil, fmt.Errorf("encode Plex JSON metadata item: %w", err) + } + containerCopy["Metadata"] = metadata + } + rootCopy := cloneJSONRawMap(root) + containerRaw, err := json.Marshal(containerCopy) + if err != nil { + return nil, fmt.Errorf("encode Plex JSON MediaContainer: %w", err) + } + rootCopy["MediaContainer"] = containerRaw + encoded, err := json.Marshal(rootCopy) + if err != nil { + return nil, fmt.Errorf("encode Plex JSON metadata batch: %w", err) + } + result := make([]byte, 0, len(encoded)+len(leading)+len(trailing)+3) + if hasBOM { + result = append(result, 0xef, 0xbb, 0xbf) + } + result = append(result, leading...) + result = append(result, encoded...) + result = append(result, trailing...) + return result, nil +} + +func cloneJSONRawMap(source map[string]json.RawMessage) map[string]json.RawMessage { + clone := make(map[string]json.RawMessage, len(source)) + for key, value := range source { + clone[key] = append(json.RawMessage(nil), value...) + } + return clone +} + +func splitJSONEnvelope(body []byte) (hasBOM bool, leading, document, trailing []byte) { + value := body + if bytes.HasPrefix(value, []byte{0xef, 0xbb, 0xbf}) { + hasBOM = true + value = value[3:] + } + leadingLength := len(value) - len(bytes.TrimLeft(value, " \t\r\n")) + leading = append([]byte(nil), value[:leadingLength]...) + value = value[leadingLength:] + trailingLength := len(value) - len(bytes.TrimRight(value, " \t\r\n")) + document = append([]byte(nil), value[:len(value)-trailingLength]...) + trailing = append([]byte(nil), value[len(value)-trailingLength:]...) + return hasBOM, leading, document, trailing +} + +type batchXMLContentKind uint8 + +const ( + batchXMLText batchXMLContentKind = iota + batchXMLElement + batchXMLComment + batchXMLDirective + batchXMLProcInst +) + +type batchXMLContent struct { + kind batchXMLContentKind + text []byte + element *batchXMLNode + comment []byte + directive []byte + procInst xml.ProcInst +} + +type batchXMLNode struct { + start xml.StartElement + children []batchXMLContent +} + +type batchXMLDocument struct { + leading []batchXMLContent + root *batchXMLNode + trailing []batchXMLContent + hasBOM bool +} + +func splitXMLMetadataBatch(body []byte, wanted map[string]struct{}) (map[string][]byte, error) { + document, err := parseBatchXMLDocument(body) + if err != nil { + return nil, err + } + if document.root == nil || document.root.start.Name.Local != "MediaContainer" { + return nil, errors.New("Plex XML metadata batch has no MediaContainer root") + } + + items := make(map[string]*batchXMLNode, len(document.root.children)) + for _, child := range document.root.children { + if child.kind != batchXMLElement { + if child.kind == batchXMLText && len(bytes.TrimSpace(child.text)) != 0 { + return nil, errors.New("Plex XML metadata batch has unexpected container text") + } + continue + } + ratingKey, present, err := xmlBatchRatingKey(child.element.start.Attr) + if err != nil { + return nil, err + } + if !present { + continue + } + if _, exists := items[ratingKey]; exists { + return nil, fmt.Errorf("Plex XML metadata batch has duplicate rating key %q", ratingKey) + } + items[ratingKey] = child.element + } + if err := ensureXMLBatchKeysPresent(items, wanted); err != nil { + return nil, err + } + + result := make(map[string][]byte, len(wanted)) + for ratingKey := range wanted { + outputDocument := document.cloneFor(ratingKey, items[ratingKey]) + encoded, err := encodeBatchXMLDocument(outputDocument) + if err != nil { + return nil, err + } + result[ratingKey] = encoded + } + return result, nil +} + +func ensureXMLBatchKeysPresent(items map[string]*batchXMLNode, wanted map[string]struct{}) error { + if len(items) != len(wanted) { + return errors.New("Plex XML metadata batch returned an unexpected item set") + } + for ratingKey := range wanted { + if _, ok := items[ratingKey]; !ok { + return fmt.Errorf("Plex XML metadata batch is missing requested rating key %q", ratingKey) + } + } + return nil +} + +func xmlBatchRatingKey(attributes []xml.Attr) (string, bool, error) { + found := false + value := "" + for _, attribute := range attributes { + if attribute.Name.Local != "ratingKey" || attribute.Name.Space != "" { + continue + } + if found { + return "", true, errors.New("Plex XML metadata item has duplicate ratingKey attributes") + } + found = true + value = attribute.Value + } + if !found { + return "", false, nil + } + if value == "" { + return "", true, errors.New("Plex XML metadata item has an empty ratingKey") + } + ratingKey, err := normalizeBatchRatingKey(value) + if err != nil { + return "", true, err + } + return ratingKey, true, nil +} + +func parseBatchXMLDocument(body []byte) (batchXMLDocument, error) { + var document batchXMLDocument + value := body + if bytes.HasPrefix(value, []byte{0xef, 0xbb, 0xbf}) { + document.hasBOM = true + value = value[3:] + } + decoder := xml.NewDecoder(bytes.NewReader(value)) + seenRoot := false + for { + token, err := decoder.Token() + if errors.Is(err, io.EOF) { + if !seenRoot { + return batchXMLDocument{}, errors.New("Plex XML metadata batch response is empty") + } + return document, nil + } + if err != nil { + return batchXMLDocument{}, fmt.Errorf("parse Plex XML metadata batch: %w", err) + } + if !seenRoot { + switch typed := token.(type) { + case xml.StartElement: + node := &batchXMLNode{} + if err := decoder.DecodeElement(node, &typed); err != nil { + return batchXMLDocument{}, fmt.Errorf("parse Plex XML metadata batch: %w", err) + } + document.root = node + seenRoot = true + default: + content, err := batchXMLContentFromToken(token) + if err != nil { + return batchXMLDocument{}, err + } + if content.kind == batchXMLText && len(bytes.TrimSpace(content.text)) != 0 { + return batchXMLDocument{}, errors.New("Plex XML metadata batch has leading non-whitespace text") + } + document.leading = append(document.leading, content) + } + continue + } + content, err := batchXMLContentFromToken(token) + if err != nil { + return batchXMLDocument{}, err + } + if content.kind == batchXMLText && len(bytes.TrimSpace(content.text)) != 0 { + return batchXMLDocument{}, errors.New("Plex XML metadata batch has trailing non-whitespace text") + } + document.trailing = append(document.trailing, content) + } +} + +func (node *batchXMLNode) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement) error { + node.start = cloneXMLStartElement(start) + for { + token, err := decoder.Token() + if err != nil { + return err + } + switch typed := token.(type) { + case xml.StartElement: + child := &batchXMLNode{} + if err := decoder.DecodeElement(child, &typed); err != nil { + return err + } + node.children = append(node.children, batchXMLContent{kind: batchXMLElement, element: child}) + case xml.EndElement: + if typed.Name != start.Name { + return errors.New("Plex XML metadata batch has mismatched element") + } + return nil + default: + content, err := batchXMLContentFromToken(token) + if err != nil { + return err + } + node.children = append(node.children, content) + } + } +} + +func batchXMLContentFromToken(token xml.Token) (batchXMLContent, error) { + switch typed := token.(type) { + case xml.CharData: + return batchXMLContent{kind: batchXMLText, text: append([]byte(nil), typed...)}, nil + case xml.Comment: + return batchXMLContent{kind: batchXMLComment, comment: append([]byte(nil), typed...)}, nil + case xml.Directive: + return batchXMLContent{kind: batchXMLDirective, directive: append([]byte(nil), typed...)}, nil + case xml.ProcInst: + return batchXMLContent{kind: batchXMLProcInst, procInst: xml.ProcInst{Target: typed.Target, Inst: append([]byte(nil), typed.Inst...)}}, nil + default: + return batchXMLContent{}, fmt.Errorf("Plex XML metadata batch has unsupported token %T", token) + } +} + +func (document batchXMLDocument) cloneFor(ratingKey string, item *batchXMLNode) batchXMLDocument { + clone := batchXMLDocument{hasBOM: document.hasBOM} + clone.leading = cloneXMLContents(document.leading) + clone.trailing = cloneXMLContents(document.trailing) + clone.root = cloneXMLNode(document.root) + clone.root.start.Attr = setXMLSizeAttribute(clone.root.start.Attr) + children := make([]batchXMLContent, 0, len(clone.root.children)) + for _, child := range clone.root.children { + if child.kind != batchXMLElement { + children = append(children, child) + continue + } + childRatingKey, present, _ := xmlBatchRatingKey(child.element.start.Attr) + if present && childRatingKey != ratingKey { + continue + } + children = append(children, child) + } + clone.root.children = children + return clone +} + +func cloneXMLContents(contents []batchXMLContent) []batchXMLContent { + clone := make([]batchXMLContent, 0, len(contents)) + for _, content := range contents { + copyContent := content + copyContent.text = append([]byte(nil), content.text...) + copyContent.comment = append([]byte(nil), content.comment...) + copyContent.directive = append([]byte(nil), content.directive...) + copyContent.procInst.Inst = append([]byte(nil), content.procInst.Inst...) + if content.element != nil { + copyContent.element = cloneXMLNode(content.element) + } + clone = append(clone, copyContent) + } + return clone +} + +func cloneXMLNode(node *batchXMLNode) *batchXMLNode { + if node == nil { + return nil + } + return &batchXMLNode{start: cloneXMLStartElement(node.start), children: cloneXMLContents(node.children)} +} + +func cloneXMLStartElement(start xml.StartElement) xml.StartElement { + clone := start + clone.Attr = append([]xml.Attr(nil), start.Attr...) + return clone +} + +func setXMLSizeAttribute(attributes []xml.Attr) []xml.Attr { + for index := range attributes { + if attributes[index].Name.Local == "size" { + attributes[index].Value = "1" + return attributes + } + } + return append(attributes, xml.Attr{Name: xml.Name{Local: "size"}, Value: "1"}) +} + +func encodeBatchXMLDocument(document batchXMLDocument) ([]byte, error) { + var buffer bytes.Buffer + if document.hasBOM { + buffer.Write([]byte{0xef, 0xbb, 0xbf}) + } + encoder := xml.NewEncoder(&buffer) + declaration := -1 + for index, content := range document.leading { + if content.kind == batchXMLProcInst && strings.EqualFold(content.procInst.Target, "xml") { + declaration = index + break + } + } + if declaration >= 0 { + if err := encodeBatchXMLContent(encoder, document.leading[declaration]); err != nil { + return nil, fmt.Errorf("encode Plex XML metadata batch: %w", err) + } + } + for index, content := range document.leading { + if index == declaration { + continue + } + if err := encodeBatchXMLContent(encoder, content); err != nil { + return nil, fmt.Errorf("encode Plex XML metadata batch: %w", err) + } + } + if document.root == nil { + return nil, errors.New("Plex XML metadata batch has no root") + } + if err := encodeBatchXMLNode(encoder, document.root); err != nil { + return nil, fmt.Errorf("encode Plex XML metadata batch: %w", err) + } + for _, content := range document.trailing { + if err := encodeBatchXMLContent(encoder, content); err != nil { + return nil, fmt.Errorf("encode Plex XML metadata batch: %w", err) + } + } + if err := encoder.Flush(); err != nil { + return nil, fmt.Errorf("encode Plex XML metadata batch: %w", err) + } + return buffer.Bytes(), nil +} + +func encodeBatchXMLNode(encoder *xml.Encoder, node *batchXMLNode) error { + if err := encoder.EncodeToken(node.start); err != nil { + return err + } + for _, content := range node.children { + if err := encodeBatchXMLContent(encoder, content); err != nil { + return err + } + } + return encoder.EncodeToken(nodeEndElement(node.start)) +} + +func encodeBatchXMLContent(encoder *xml.Encoder, content batchXMLContent) error { + switch content.kind { + case batchXMLText: + return encoder.EncodeToken(xml.CharData(content.text)) + case batchXMLElement: + return encodeBatchXMLNode(encoder, content.element) + case batchXMLComment: + return encoder.EncodeToken(xml.Comment(content.comment)) + case batchXMLDirective: + return encoder.EncodeToken(xml.Directive(content.directive)) + case batchXMLProcInst: + return encoder.EncodeToken(content.procInst) + default: + return fmt.Errorf("unsupported XML content kind %d", content.kind) + } +} + +func nodeEndElement(start xml.StartElement) xml.EndElement { + return xml.EndElement{Name: start.Name} +} + +func hasUTF8BOM(value []byte) bool { + return bytes.HasPrefix(value, []byte{0xef, 0xbb, 0xbf}) +} diff --git a/internal/plexmeta/batch_split_test.go b/internal/plexmeta/batch_split_test.go new file mode 100644 index 0000000..194d32e --- /dev/null +++ b/internal/plexmeta/batch_split_test.go @@ -0,0 +1,261 @@ +package plexmeta + +import ( + "bytes" + "encoding/json" + "encoding/xml" + "strings" + "testing" +) + +func TestSplitMetadataBatchJSONKeepsContainerAndOnlySelectedItem(t *testing.T) { + body := []byte("\ufeff \n{" + + "\"rootExtra\":\"keep\"," + + "\"MediaContainer\":{" + + "\"size\":2,\"containerExtra\":{\"nested\":true}," + + "\"Metadata\":[" + + "{\"ratingKey\":\"20\",\"title\":\"second\",\"itemExtra\":\"keep-20\"}," + + "{\"ratingKey\":10,\"title\":\"first\",\"itemExtra\":\"keep-10\"}" + + "]}} \n") + + result, err := SplitMetadataBatch(body, "application/json", []string{"10", "20"}) + if err != nil { + t.Fatal(err) + } + if len(result) != 2 { + t.Fatalf("result count = %d, want 2", len(result)) + } + for _, key := range []string{"10", "20"} { + output := result[key] + if !bytes.HasPrefix(output, []byte("\ufeff \n")) || !bytes.HasSuffix(output, []byte(" \n")) { + t.Fatalf("JSON envelope whitespace/BOM was not retained for %s: %q", key, output[:min(len(output), 16)]) + } + var root map[string]json.RawMessage + if err := json.Unmarshal(bytes.TrimSpace(bytes.TrimPrefix(output, []byte{0xef, 0xbb, 0xbf})), &root); err != nil { + t.Fatalf("decode %s: %v", key, err) + } + var container map[string]json.RawMessage + if err := json.Unmarshal(root["MediaContainer"], &container); err != nil { + t.Fatal(err) + } + var size int + if err := json.Unmarshal(container["size"], &size); err != nil || size != 1 { + t.Fatalf("size[%s] = %s, err=%v", key, container["size"], err) + } + var items []map[string]json.RawMessage + if err := json.Unmarshal(container["Metadata"], &items); err != nil || len(items) != 1 { + t.Fatalf("Metadata[%s] = %s, err=%v", key, container["Metadata"], err) + } + var ratingKey string + if err := json.Unmarshal(items[0]["ratingKey"], &ratingKey); err != nil { + var numeric json.Number + if err := json.Unmarshal(items[0]["ratingKey"], &numeric); err != nil { + t.Fatal(err) + } + ratingKey = numeric.String() + } + if ratingKey != key { + t.Fatalf("selected ratingKey = %q, want %q", ratingKey, key) + } + if string(container["containerExtra"]) != `{"nested":true}` || string(items[0]["itemExtra"]) != `"keep-`+key+`"` { + t.Fatalf("unknown fields not preserved in %s: container=%s item=%s", key, container["containerExtra"], items[0]["itemExtra"]) + } + } +} + +func TestSplitMetadataBatchJSONSupportsSingleMetadataObject(t *testing.T) { + body := []byte(`{"MediaContainer":{"size":9,"Metadata":{"ratingKey":"42","title":"one"}}}`) + result, err := SplitMetadataBatch(body, "application/json", []string{"42"}) + if err != nil { + t.Fatal(err) + } + var root map[string]json.RawMessage + if err := json.Unmarshal(result["42"], &root); err != nil { + t.Fatal(err) + } + var container map[string]json.RawMessage + if err := json.Unmarshal(root["MediaContainer"], &container); err != nil { + t.Fatal(err) + } + if string(container["Metadata"]) != `{"ratingKey":"42","title":"one"}` { + t.Fatalf("single Metadata object changed shape: %s", container["Metadata"]) + } +} + +func TestSplitMetadataBatchXMLKeepsNonItemChildrenAndSelectedItem(t *testing.T) { + body := []byte("\ufeff \n" + + "\n" + + " \n" + + " \n") + + result, err := SplitMetadataBatch(body, "application/xml", []string{"10", "20"}) + if err != nil { + t.Fatal(err) + } + if len(result) != 2 { + t.Fatalf("result count = %d, want 2", len(result)) + } + for _, key := range []string{"10", "20"} { + output := result[key] + if !bytes.HasPrefix(output, []byte{0xef, 0xbb, 0xbf}) || !bytes.Contains(output, []byte("Context")) || !bytes.Contains(output, []byte(`containerExtra="keep"`)) { + t.Fatalf("XML envelope or non-item child not preserved for %s: %s", key, output) + } + var root batchXMLNode + decoder := xml.NewDecoder(bytes.NewReader(bytes.TrimPrefix(output, []byte{0xef, 0xbb, 0xbf}))) + for { + token, err := decoder.Token() + if err != nil { + t.Fatal(err) + } + if start, ok := token.(xml.StartElement); ok { + if err := decoder.DecodeElement(&root, &start); err != nil { + t.Fatal(err) + } + break + } + } + if got := xmlAttributeValue(root.start.Attr, "size"); got != "1" { + t.Fatalf("XML size[%s] = %q", key, got) + } + var itemCount int + var selected string + var nonItemCount int + for _, child := range root.children { + if child.kind != batchXMLElement { + continue + } + ratingKey, present, err := xmlBatchRatingKey(child.element.start.Attr) + if err != nil { + t.Fatal(err) + } + if !present { + nonItemCount++ + continue + } + itemCount++ + selected = ratingKey + } + if itemCount != 1 || selected != key || nonItemCount != 1 { + t.Fatalf("XML children[%s] itemCount=%d selected=%q nonItemCount=%d", key, itemCount, selected, nonItemCount) + } + } +} + +func TestSplitMetadataBatchRejectsMissingDuplicateAndInvalidShapes(t *testing.T) { + tests := []struct { + name string + contentType string + body string + keys []string + }{ + { + name: "JSON missing requested item", + contentType: "application/json", + body: `{"MediaContainer":{"Metadata":[{"ratingKey":"1"}]}}`, + keys: []string{"1", "2"}, + }, + { + name: "JSON duplicate response item", + contentType: "application/json", + body: `{"MediaContainer":{"Metadata":[{"ratingKey":"1"},{"ratingKey":1}]}}`, + keys: []string{"1"}, + }, + { + name: "JSON unexpected response item", + contentType: "application/json", + body: `{"MediaContainer":{"Metadata":[{"ratingKey":"1"},{"ratingKey":"2"}]}}`, + keys: []string{"1"}, + }, + { + name: "JSON duplicate ratingKey field", + contentType: "application/json", + body: `{"MediaContainer":{"Metadata":[{"ratingKey":"1","ratingKey":"1"}]}}`, + keys: []string{"1"}, + }, + { + name: "JSON item without ratingKey", + contentType: "application/json", + body: `{"MediaContainer":{"Metadata":[{"title":"unknown"}]}}`, + keys: []string{"1"}, + }, + { + name: "XML missing requested item", + contentType: "application/xml", + body: ``, + keys: []string{"1", "2"}, + }, + { + name: "XML duplicate response item", + contentType: "application/xml", + body: ``, + keys: []string{"1"}, + }, + { + name: "XML unexpected response item", + contentType: "application/xml", + body: ``, + keys: []string{"1"}, + }, + { + name: "XML duplicate ratingKey attributes", + contentType: "application/xml", + body: ``, + keys: []string{"1"}, + }, + { + name: "XML unexpected text", + contentType: "application/xml", + body: `unexpected`, + keys: []string{"1"}, + }, + { + name: "malformed XML", + contentType: "application/xml", + body: ``, + keys: []string{"1"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := SplitMetadataBatch([]byte(test.body), test.contentType, test.keys) + if err == nil || result != nil { + t.Fatalf("result=%v err=%v, want an atomic error", result, err) + } + }) + } + + for _, keys := range [][]string{nil, {}, {""}, {"1", "1"}, {"1/2"}, {"1,2"}, {" 1"}, {"1\n"}} { + if result, err := SplitMetadataBatch([]byte(``), "application/xml", keys); err == nil || result != nil { + t.Fatalf("keys=%q result=%v err=%v, want invalid-key error", keys, result, err) + } + } +} + +func TestSplitMetadataBatchIgnoresNonItemXMLChildren(t *testing.T) { + body := []byte(``) + result, err := SplitMetadataBatch(body, "application/xml", []string{"1"}) + if err != nil { + t.Fatalf("non-item child should be retained, err=%v", err) + } + if !strings.Contains(string(result["1"]), `Context name="preserve"`) || !strings.Contains(string(result["1"]), `Extension value="keep"`) { + t.Fatalf("unexpected XML children in output: %s", result["1"]) + } +} + +func xmlAttributeValue(attributes []xml.Attr, name string) string { + for _, attribute := range attributes { + if attribute.Name.Local == name { + return attribute.Value + } + } + return "" +} + +func min(left, right int) int { + if left < right { + return left + } + return right +} diff --git a/internal/plexmeta/decision_projection.go b/internal/plexmeta/decision_projection.go new file mode 100644 index 0000000..7c510a7 --- /dev/null +++ b/internal/plexmeta/decision_projection.go @@ -0,0 +1,240 @@ +package plexmeta + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/InfinityPacer/plex-gateway/internal/mediainfo" +) + +// EnrichDecision fills descriptive Media, Part, and Stream fields for the one +// Direct Play Part already selected by Plex. It preserves every Plex-owned +// decision, identifier, session, and unknown field. A missing, ambiguous, or +// non-Direct-Play Part is rejected without modifying the input bytes. +func EnrichDecision(body []byte, contentType string, expected Part, media mediainfo.Media) ([]byte, bool, error) { + if expected.ID == "" && expected.Key == "" { + return nil, false, errors.New("expected Plex Part identity is empty") + } + document, err := parseProjectionDocument(body, contentType) + if err != nil { + return nil, false, err + } + + var selection projectionSelection + var sizeChanged bool + switch typed := document.(type) { + case *xmlProjectionDocument: + xmlSelection, err := typed.selectDecisionPart(expected) + if err != nil { + return nil, false, err + } + selection = xmlSelection + sizeChanged, err = replaceDecisionPartSizeXML(xmlSelection.part, media.Size) + if err != nil { + return nil, false, err + } + case *jsonProjectionDocument: + jsonSelection, err := typed.selectDecisionPart(expected) + if err != nil { + return nil, false, err + } + selection = jsonSelection + sizeChanged, err = replaceDecisionPartSizeJSON(*jsonSelection.part, media.Size) + if err != nil { + return nil, false, err + } + default: + return nil, false, errors.New("unsupported Plex decision document") + } + + changed, err := selection.enrich(media) + if err != nil { + return nil, false, err + } + if sizeChanged && !changed { + if jsonSelection, ok := selection.(*jsonProjectionSelection); ok { + if err := jsonSelection.commit(); err != nil { + return nil, false, err + } + } + } + if !changed && !sizeChanged { + return append([]byte(nil), body...), false, nil + } + result, err := document.encode() + if err != nil { + return nil, false, err + } + return result, true, nil +} + +func (document *xmlProjectionDocument) selectDecisionPart(expected Part) (*xmlProjectionSelection, error) { + roots := xmlProjectionElements(document.nodes) + if len(roots) != 1 || roots[0].start.Name.Local != "MediaContainer" { + return nil, errors.New("Plex XML decision has no unique MediaContainer root") + } + var selected *xmlProjectionSelection + for _, video := range directXMLElements(roots[0], "Video") { + for _, media := range directXMLElements(video, "Media") { + for _, part := range directXMLElements(media, "Part") { + decision, _, err := xmlAttribute(part.start.Attr, "decision") + if err != nil { + return nil, err + } + id, _, err := xmlAttribute(part.start.Attr, "id") + if err != nil { + return nil, err + } + key, _, err := xmlAttribute(part.start.Attr, "key") + if err != nil { + return nil, err + } + if !strings.EqualFold(decision, "directplay") || !decisionPartMatches(id, key, expected) { + continue + } + if selected != nil { + return nil, errors.New("Plex XML decision has an ambiguous Direct Play Part") + } + selected = &xmlProjectionSelection{ + value: Target{Part: expected, NeedsEnrichment: true}, + media: media, + part: part, + } + } + } + } + if selected == nil { + return nil, errors.New("Plex XML decision has no matching Direct Play Part") + } + return selected, nil +} + +func (document *jsonProjectionDocument) selectDecisionPart(expected Part) (*jsonProjectionSelection, error) { + container, metadata, err := document.directMetadata() + if err != nil { + return nil, err + } + var selected *jsonProjectionSelection + for _, item := range metadata.items { + mediaRaw, ok := (*item)["Media"] + if !ok { + continue + } + mediaItems, err := decodeJSONProjectionList(mediaRaw, "Media") + if err != nil { + return nil, err + } + for _, mediaItem := range mediaItems.items { + partRaw, ok := (*mediaItem)["Part"] + if !ok { + continue + } + parts, err := decodeJSONProjectionList(partRaw, "Part") + if err != nil { + return nil, err + } + for _, part := range parts.items { + decision, err := optionalJSONDecisionString(*part, "decision") + if err != nil { + return nil, err + } + id, err := optionalJSONDecisionScalar(*part, "id") + if err != nil { + return nil, err + } + key, err := optionalJSONDecisionString(*part, "key") + if err != nil { + return nil, err + } + if !strings.EqualFold(decision, "directplay") || !decisionPartMatches(id, key, expected) { + continue + } + if selected != nil { + return nil, errors.New("Plex JSON decision has an ambiguous Direct Play Part") + } + selected = &jsonProjectionSelection{ + value: Target{Part: expected, NeedsEnrichment: true}, + document: document, + container: container, + metadata: metadata, + metadataItem: item, + media: mediaItems, + parts: parts, + mediaItem: mediaItem, + part: part, + } + } + } + } + if selected == nil { + return nil, errors.New("Plex JSON decision has no matching Direct Play Part") + } + return selected, nil +} + +func optionalJSONDecisionString(object jsonObject, name string) (string, error) { + raw, ok := object[name] + if !ok { + return "", nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", fmt.Errorf("Plex JSON decision %s is not a string", name) + } + return value, nil +} + +func optionalJSONDecisionScalar(object jsonObject, name string) (string, error) { + raw, ok := object[name] + if !ok { + return "", nil + } + value, ok := jsonRawScalarString(raw) + if !ok { + return "", fmt.Errorf("Plex JSON decision %s is not a scalar", name) + } + return value, nil +} + +func replaceDecisionPartSizeXML(part *xmlProjectionElement, mediaSize int64) (bool, error) { + if mediaSize <= strmControlSizeThreshold { + return false, nil + } + for index := range part.start.Attr { + if part.start.Attr[index].Name.Local != "size" { + continue + } + current, err := strconv.ParseInt(strings.TrimSpace(part.start.Attr[index].Value), 10, 64) + if err != nil { + return false, errors.New("Plex XML decision Part has an invalid size") + } + if current < 0 || current > strmControlSizeThreshold || mediaSize <= current { + return false, nil + } + part.start.Attr[index].Value = strconv.FormatInt(mediaSize, 10) + return true, nil + } + return false, nil +} + +func replaceDecisionPartSizeJSON(part jsonObject, mediaSize int64) (bool, error) { + if mediaSize <= strmControlSizeThreshold { + return false, nil + } + raw, present := part["size"] + if !present { + return false, nil + } + current, ok := jsonRawInt64(raw) + if !ok { + return false, errors.New("Plex JSON decision Part has an invalid size") + } + if current < 0 || current > strmControlSizeThreshold || mediaSize <= current { + return false, nil + } + part["size"] = json.RawMessage(strconv.FormatInt(mediaSize, 10)) + return true, nil +} diff --git a/internal/plexmeta/decision_projection_test.go b/internal/plexmeta/decision_projection_test.go new file mode 100644 index 0000000..52e5f42 --- /dev/null +++ b/internal/plexmeta/decision_projection_test.go @@ -0,0 +1,120 @@ +package plexmeta + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestEnrichDecisionProjectsMatchingDirectPlayPart(t *testing.T) { + media := projectionTestMedia() + tests := []struct { + name string + contentType string + body []byte + assert func(*testing.T, []byte) + }{ + { + name: "XML", + contentType: "application/xml", + body: []byte(``), + assert: func(t *testing.T, result []byte) { + attributes := xmlProjectionAttributeSets(t, result) + if attributes["Media"]["decision"] != "directplay" || attributes["Media"]["container"] != "mkv" || attributes["Media"]["bitrate"] != "12000" { + t.Fatalf("Media attributes = %#v", attributes["Media"]) + } + if attributes["Part"]["decision"] != "directplay" || attributes["Part"]["size"] != "987654321" || attributes["Part"]["container"] != "mkv" { + t.Fatalf("Part attributes = %#v", attributes["Part"]) + } + if attributes["Stream#1"]["codec"] != "hevc" || attributes["Stream#1"]["bitDepth"] != "10" || attributes["Stream#2"]["codec"] != "aac" { + t.Fatalf("Stream attributes = %#v", attributes) + } + for _, fragment := range [][]byte{[]byte(`decisionExtra="preserve"`), []byte(`videoExtra="preserve"`), []byte(`mediaExtra="preserve"`), []byte(`partExtra="preserve"`)} { + if !bytes.Contains(result, fragment) { + t.Fatalf("decision field %q was not preserved: %s", fragment, result) + } + } + }, + }, + { + name: "JSON", + contentType: "application/json", + body: []byte(`{"MediaContainer":{"decisionExtra":"preserve","Metadata":[{"videoExtra":"preserve","Media":[{"id":117278,"decision":"directplay","mediaExtra":"preserve","Part":[{"id":118649,"key":"/library/parts/118649/1/file","size":301,"decision":"directplay","partExtra":"preserve"}]}]}]}}`), + assert: func(t *testing.T, result []byte) { + var document map[string]any + if err := json.Unmarshal(result, &document); err != nil { + t.Fatal(err) + } + container := document["MediaContainer"].(map[string]any) + item := container["Metadata"].([]any)[0].(map[string]any) + mediaObject := item["Media"].([]any)[0].(map[string]any) + part := mediaObject["Part"].([]any)[0].(map[string]any) + streams := part["Stream"].([]any) + if mediaObject["decision"] != "directplay" || mediaObject["container"] != "mkv" || mediaObject["bitrate"] != float64(12000) { + t.Fatalf("Media fields = %#v", mediaObject) + } + if part["decision"] != "directplay" || part["size"] != float64(987654321) || part["container"] != "mkv" { + t.Fatalf("Part fields = %#v", part) + } + if len(streams) != 2 || streams[0].(map[string]any)["codec"] != "hevc" || streams[1].(map[string]any)["codec"] != "aac" { + t.Fatalf("Stream fields = %#v", streams) + } + if container["decisionExtra"] != "preserve" || item["videoExtra"] != "preserve" || mediaObject["mediaExtra"] != "preserve" || part["partExtra"] != "preserve" { + t.Fatalf("unknown decision fields were not preserved: %#v", document) + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + original := append([]byte(nil), test.body...) + result, changed, err := EnrichDecision(test.body, test.contentType, Part{ + ID: "118649", Key: "/library/parts/118649/1/file", File: "/cloud/movie.strm", + }, media) + if err != nil || !changed { + t.Fatalf("EnrichDecision changed=%v err=%v", changed, err) + } + if !bytes.Equal(test.body, original) { + t.Fatal("EnrichDecision modified its input body") + } + test.assert(t, result) + }) + } +} + +func TestEnrichDecisionRejectsNonMatchingOrAmbiguousPart(t *testing.T) { + tests := []struct { + name string + contentType string + body []byte + }{ + { + name: "XML transcode", + contentType: "application/xml", + body: []byte(``), + }, + { + name: "XML ambiguous", + contentType: "application/xml", + body: []byte(``), + }, + { + name: "JSON wrong Part", + contentType: "application/json", + body: []byte(`{"MediaContainer":{"Metadata":[{"Media":[{"Part":[{"id":8,"key":"/library/parts/8/1/file","decision":"directplay"}]}]}]}}`), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + original := append([]byte(nil), test.body...) + result, changed, err := EnrichDecision(test.body, test.contentType, Part{ID: "7", Key: "/library/parts/7/1/file"}, projectionTestMedia()) + if err == nil || result != nil || changed { + t.Fatalf("result=%q changed=%v err=%v", result, changed, err) + } + if !bytes.Equal(test.body, original) { + t.Fatal("failed decision projection modified its input") + } + }) + } +} diff --git a/internal/plexmeta/decision_reject.go b/internal/plexmeta/decision_reject.go new file mode 100644 index 0000000..dd26201 --- /dev/null +++ b/internal/plexmeta/decision_reject.go @@ -0,0 +1,34 @@ +package plexmeta + +import ( + "encoding/json" + "strings" +) + +const ( + incompatibleDirectPlayDecisionCode = 1000 + incompatibleTranscodeDecisionCode = 4005 + incompatibleGeneralDecisionCode = 2000 +) + +// IncompatibleDecision builds the minimal Plex decision envelope observed for +// a source that cannot be Direct Played or transcoded. It intentionally omits +// Video, Media, Part, and session identities so a reject cannot expose or start +// another playback path. +func IncompatibleDecision(accept string) (contentType string, body []byte) { + if strings.Contains(strings.ToLower(accept), "json") { + body, _ = json.Marshal(map[string]any{ + "MediaContainer": map[string]any{ + "size": 0, + "directPlayDecisionCode": incompatibleDirectPlayDecisionCode, + "transcodeDecisionCode": incompatibleTranscodeDecisionCode, + "generalDecisionCode": incompatibleGeneralDecisionCode, + }, + }) + return "application/json; charset=utf-8", body + } + return "application/xml; charset=utf-8", []byte( + `` + + ``, + ) +} diff --git a/internal/plexmeta/decision_reject_test.go b/internal/plexmeta/decision_reject_test.go new file mode 100644 index 0000000..118dc91 --- /dev/null +++ b/internal/plexmeta/decision_reject_test.go @@ -0,0 +1,33 @@ +package plexmeta + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestIncompatibleDecisionBuildsXMLAndJSONEnvelopes(t *testing.T) { + contentType, body := IncompatibleDecision("application/xml") + if contentType != "application/xml; charset=utf-8" || !strings.Contains(string(body), `transcodeDecisionCode="4005"`) || strings.Contains(string(body), "= 0 { + seasonIndex = *item.ParentIndex + } + episodeIndex := -1 + if item.Index != nil && *item.Index >= 0 { + episodeIndex = *item.Index + } + episode := Episode{ + RatingKey: item.RatingKey, ParentRatingKey: item.ParentRatingKey, + GrandparentRatingKey: item.GrandparentRatingKey, + SeasonIndex: seasonIndex, EpisodeIndex: episodeIndex, + PlayQueueItemID: item.PlayQueueItemID, + } + for _, mediaItem := range item.Media { + episode.Parts = append(episode.Parts, mediaItem.Parts...) + } + for _, part := range episode.Parts { + if part.ID != "" && part.File != "" { + episode.Part = part + break + } + } + if len(item.Media) == 1 && len(item.Media[0].Parts) == 1 { + episode.UniquePart = item.Media[0].Parts[0].ID != "" && item.Media[0].Parts[0].File != "" + } + episodes = append(episodes, episode) + } + return episodes, nil +} + +func parseJSONEpisodes(body []byte) ([]Episode, error) { + root, err := parseJSONMediaContainer(body) + if err != nil { + return nil, err + } + items := objectList(root["Metadata"]) + episodes := make([]Episode, 0, len(items)) + for _, item := range items { + typeName, _ := stringValue(item["type"]) + ratingKey, _ := stringValue(item["ratingKey"]) + seasonIndex, seasonOK := nonNegativeJSONInt(item["parentIndex"]) + episodeIndex, episodeOK := nonNegativeJSONInt(item["index"]) + if typeName != "episode" { + continue + } + if ratingKey == "" { + continue + } + if !seasonOK { + seasonIndex = -1 + } + if !episodeOK { + episodeIndex = -1 + } + parentRatingKey, _ := stringValue(item["parentRatingKey"]) + grandparentRatingKey, _ := stringValue(item["grandparentRatingKey"]) + playQueueItemID, _ := stringValue(item["playQueueItemID"]) + episode := Episode{ + RatingKey: ratingKey, ParentRatingKey: parentRatingKey, + GrandparentRatingKey: grandparentRatingKey, + SeasonIndex: seasonIndex, EpisodeIndex: episodeIndex, + PlayQueueItemID: playQueueItemID, + } + media := objectList(item["Media"]) + for _, mediaItem := range media { + for _, rawPart := range objectList(mediaItem["Part"]) { + if part, ok := partFromMap(rawPart); ok { + episode.Parts = append(episode.Parts, part) + } + } + } + for _, part := range episode.Parts { + if part.ID != "" && part.File != "" { + episode.Part = part + break + } + } + if len(media) == 1 && len(objectList(media[0]["Part"])) == 1 { + episode.UniquePart = episode.Part.ID != "" && episode.Part.File != "" + } + episodes = append(episodes, episode) + } + return episodes, nil +} + +func parseXMLSeasons(body []byte) ([]Season, error) { + type directory struct { + Type string `xml:"type,attr"` + RatingKey string `xml:"ratingKey,attr"` + Index *int `xml:"index,attr"` + } + type container struct { + Directories []directory `xml:"Directory"` + } + var value container + if err := xml.Unmarshal(body, &value); err != nil { + return nil, fmt.Errorf("parse Plex XML seasons: %w", err) + } + seasons := make([]Season, 0, len(value.Directories)) + for _, item := range value.Directories { + if item.Type != "season" { + continue + } + if item.RatingKey == "" { + continue + } + index := -1 + if item.Index != nil && *item.Index >= 0 { + index = *item.Index + } + seasons = append(seasons, Season{RatingKey: item.RatingKey, Index: index}) + } + return seasons, nil +} + +func parseJSONSeasons(body []byte) ([]Season, error) { + root, err := parseJSONMediaContainer(body) + if err != nil { + return nil, err + } + items := objectList(root["Metadata"]) + seasons := make([]Season, 0, len(items)) + for _, item := range items { + typeName, _ := stringValue(item["type"]) + ratingKey, _ := stringValue(item["ratingKey"]) + index, indexOK := nonNegativeJSONInt(item["index"]) + if typeName != "season" { + continue + } + if ratingKey == "" { + continue + } + if !indexOK { + index = -1 + } + seasons = append(seasons, Season{RatingKey: ratingKey, Index: index}) + } + return seasons, nil +} + +func parseJSONMediaContainer(body []byte) (map[string]any, error) { + var value any + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil { + return nil, fmt.Errorf("parse Plex JSON hierarchy: %w", err) + } + root, ok := value.(map[string]any) + if !ok { + return nil, errors.New("Plex JSON hierarchy has no object root") + } + container, ok := root["MediaContainer"].(map[string]any) + if !ok { + return nil, errors.New("Plex JSON hierarchy has no MediaContainer") + } + return container, nil +} + +func nonNegativeJSONInt(value any) (int, bool) { + text, ok := stringValue(value) + if !ok { + return 0, false + } + parsed, err := strconv.Atoi(text) + return parsed, err == nil && parsed >= 0 +} diff --git a/internal/plexmeta/episode_test.go b/internal/plexmeta/episode_test.go new file mode 100644 index 0000000..481c2c1 --- /dev/null +++ b/internal/plexmeta/episode_test.go @@ -0,0 +1,103 @@ +package plexmeta + +import "testing" + +func TestParseEpisodesXMLAndJSON(t *testing.T) { + fixtures := []struct { + name string + contentType string + body string + }{ + { + name: "XML", contentType: "application/xml", + body: ``, + }, + { + name: "JSON", contentType: "application/json", + body: `{"MediaContainer":{"Metadata":[{"type":"episode","ratingKey":42,"parentRatingKey":7,"grandparentRatingKey":3,"parentIndex":1,"index":2,"playQueueItemID":99,"Media":[{"Part":[{"id":9,"key":"/library/parts/9/file","file":"/cloud/e02.strm"}]}]}]}}`, + }, + } + for _, fixture := range fixtures { + t.Run(fixture.name, func(t *testing.T) { + episodes, err := ParseEpisodes([]byte(fixture.body), fixture.contentType) + if err != nil { + t.Fatal(err) + } + if len(episodes) != 1 { + t.Fatalf("episodes = %#v", episodes) + } + got := episodes[0] + if got.RatingKey != "42" || got.ParentRatingKey != "7" || got.GrandparentRatingKey != "3" || + got.SeasonIndex != 1 || got.EpisodeIndex != 2 || got.PlayQueueItemID != "99" || + !got.UniquePart || got.Part.ID != "9" || got.Part.File != "/cloud/e02.strm" { + t.Fatalf("episode = %#v", got) + } + }) + } +} + +func TestParseEpisodesRejectsAmbiguousPart(t *testing.T) { + episodes, err := ParseEpisodes([]byte(``), "application/xml") + if err != nil { + t.Fatal(err) + } + if len(episodes) != 1 || episodes[0].UniquePart { + t.Fatalf("episodes = %#v", episodes) + } +} + +func TestParseEpisodesPreservesIncompleteHierarchy(t *testing.T) { + for _, fixture := range []struct { + contentType string + body string + }{ + {"application/xml", ``}, + {"application/json", `{"MediaContainer":{"Metadata":[{"type":"episode","ratingKey":"42","index":2}]}}`}, + } { + episodes, err := ParseEpisodes([]byte(fixture.body), fixture.contentType) + if err != nil { + t.Fatal(err) + } + if len(episodes) != 1 || episodes[0].RatingKey != "42" || + episodes[0].SeasonIndex != -1 || episodes[0].EpisodeIndex != 2 { + t.Fatalf("ParseEpisodes(%s) = %#v", fixture.contentType, episodes) + } + } +} + +func TestParseSeasonsXMLAndJSON(t *testing.T) { + for _, fixture := range []struct { + contentType string + body string + }{ + {"application/xml", ``}, + {"application/json", `{"MediaContainer":{"Metadata":[{"type":"season","ratingKey":7,"index":1},{"type":"show","ratingKey":3,"index":9}]}}`}, + } { + seasons, err := ParseSeasons([]byte(fixture.body), fixture.contentType) + if err != nil { + t.Fatal(err) + } + if len(seasons) != 1 || seasons[0] != (Season{RatingKey: "7", Index: 1}) { + t.Fatalf("seasons = %#v", seasons) + } + } +} + +func TestParsePlayQueuePreservesInterveningTypes(t *testing.T) { + for _, fixture := range []struct { + contentType string + body string + }{ + {"application/xml", ``}, + {"application/json", `{"MediaContainer":{"Metadata":[{"type":"episode","ratingKey":42,"grandparentRatingKey":3,"playQueueItemID":99},{"type":"movie","ratingKey":50,"playQueueItemID":100}]}}`}, + } { + items, err := ParsePlayQueue([]byte(fixture.body), fixture.contentType) + if err != nil { + t.Fatal(err) + } + if len(items) != 2 || items[0].RatingKey != "42" || items[0].PlayQueueItemID != "99" || + items[1].Type != "movie" || items[1].RatingKey != "50" { + t.Fatalf("items = %#v", items) + } + } +} diff --git a/internal/plexmeta/projection.go b/internal/plexmeta/projection.go index 55b4e8a..7aa6029 100644 --- a/internal/plexmeta/projection.go +++ b/internal/plexmeta/projection.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "math" "strconv" "strings" @@ -15,10 +16,11 @@ import ( // Target identifies the single Plex item and reports whether the response is // missing a whitelisted technical field that a MediaInfo record can project. -// A missing Stream is not itself a reason to enrich: projection never creates -// Stream elements because their Plex identity is not known to the gateway. +// A Part with no Stream elements is eligible for descriptive Stream creation +// because ffprobe supplies the stable stream type and source index. type Target struct { - Part Part + RatingKey string + Part Part // NeedsEnrichment is true when a whitelisted field is absent from the // selected Media, Part, or matchable existing Stream elements. NeedsEnrichment bool @@ -46,10 +48,10 @@ func SelectEnrichmentPart(body []byte, contentType, ratingKey string) (Part, err return target.Part, nil } -// EnrichMetadata fills only absent whitelisted Media, Part, and already -// present Stream attributes. SelectEnrichmentTarget supplies the exact Part -// to callers that need to build a probe request before this function runs. -// The input body is never modified in place. When no field is filled, the +// EnrichMetadata fills only absent whitelisted Media, Part, and matching +// Stream attributes. When a Part has no Stream elements, it creates +// descriptive video, audio, and subtitle elements from the probe record. The +// input body is never modified in place. When no field is filled, the // original bytes are returned unchanged. func EnrichMetadata(body []byte, contentType, ratingKey string, media mediainfo.Media) ([]byte, bool, error) { document, selection, err := parseProjection(body, contentType, ratingKey) @@ -70,8 +72,63 @@ func EnrichMetadata(body []byte, contentType, ratingKey string, media mediainfo. return result, true, nil } +// SelectEnrichmentTargets returns independently projectable items from a Plex +// collection response. Items with multiple Media versions or Parts are left +// untouched because a cache record cannot be mapped to them without guessing. +func SelectEnrichmentTargets(body []byte, contentType string) ([]Target, error) { + document, err := parseProjectionDocument(body, contentType) + if err != nil { + return nil, err + } + selections, err := document.selectTargets() + if err != nil { + return nil, err + } + targets := make([]Target, 0, len(selections)) + for _, selection := range selections { + targets = append(targets, selection.target()) + } + return targets, nil +} + +// EnrichMetadataTargets projects cached MediaInfo into matching items of one +// collection response. Missing records leave their items unchanged. +func EnrichMetadataTargets(body []byte, contentType string, records map[string]mediainfo.Media) ([]byte, int, error) { + document, err := parseProjectionDocument(body, contentType) + if err != nil { + return nil, 0, err + } + selections, err := document.selectTargets() + if err != nil { + return nil, 0, err + } + changedItems := 0 + for _, selection := range selections { + media, ok := records[selection.target().RatingKey] + if !ok { + continue + } + changed, err := selection.enrich(media) + if err != nil { + return nil, 0, err + } + if changed { + changedItems++ + } + } + if changedItems == 0 { + return append([]byte(nil), body...), 0, nil + } + result, err := document.encode() + if err != nil { + return nil, 0, err + } + return result, changedItems, nil +} + type projectionDocument interface { selectTarget(string) (projectionSelection, error) + selectTargets() ([]projectionSelection, error) encode() ([]byte, error) } @@ -85,30 +142,34 @@ func parseProjection(body []byte, contentType, ratingKey string) (projectionDocu if err != nil { return nil, nil, err } + document, err := parseProjectionDocument(body, contentType) + if err != nil { + return nil, nil, err + } + selection, err := document.selectTarget(ratingKey) + if err != nil { + return nil, nil, err + } + return document, selection, nil +} + +func parseProjectionDocument(body []byte, contentType string) (projectionDocument, error) { trimmed := trimProjectionBody(body) if len(trimmed) == 0 { - return nil, nil, errors.New("Plex metadata response is empty") + return nil, errors.New("Plex metadata response is empty") } if strings.Contains(strings.ToLower(contentType), "json") || trimmed[0] == '{' || trimmed[0] == '[' { document, err := parseJSONProjectionDocument(body) if err != nil { - return nil, nil, err - } - selection, err := document.selectTarget(ratingKey) - if err != nil { - return nil, nil, err + return nil, err } - return document, selection, nil + return document, nil } document, err := parseXMLProjectionDocument(body) if err != nil { - return nil, nil, err - } - selection, err := document.selectTarget(ratingKey) - if err != nil { - return nil, nil, err + return nil, err } - return document, selection, nil + return document, nil } func normalizeProjectionRatingKey(value string) (string, error) { @@ -129,9 +190,10 @@ func trimProjectionBody(body []byte) []byte { // whitelist contains only descriptive technical Plex fields; identifiers and // playback-selection fields are never generated. type projectionAttribute struct { - name string - value string - number bool + name string + value string + number bool + boolean bool } func textProjectionAttribute(name, value string) projectionAttribute { @@ -142,6 +204,29 @@ func integerProjectionAttribute(name string, value int64) projectionAttribute { return projectionAttribute{name: name, value: strconv.FormatInt(value, 10), number: true} } +func booleanProjectionAttribute(name string, value bool) projectionAttribute { + if value { + return projectionAttribute{name: name, value: "1", boolean: true} + } + return projectionAttribute{name: name, value: "0", boolean: true} +} + +const strmControlSizeThreshold int64 = 1 << 20 + +// bitrateKbps converts ffprobe's bits-per-second value to Plex's integer +// kilobits-per-second representation. A positive sub-kilobit value remains +// visible as one rather than being rounded down to zero. +func bitrateKbps(value int64) int64 { + if value <= 0 { + return 0 + } + value /= 1000 + if value == 0 { + return 1 + } + return value +} + func mediaProjectionAttributes(media mediainfo.Media) []projectionAttribute { attributes := make([]projectionAttribute, 0, 14) if media.Container != "" { @@ -151,7 +236,7 @@ func mediaProjectionAttributes(media mediainfo.Media) []projectionAttribute { attributes = append(attributes, integerProjectionAttribute("duration", media.DurationMS)) } if media.Bitrate > 0 { - attributes = append(attributes, integerProjectionAttribute("bitrate", media.Bitrate)) + attributes = append(attributes, integerProjectionAttribute("bitrate", bitrateKbps(media.Bitrate))) } if media.Width > 0 { attributes = append(attributes, integerProjectionAttribute("width", int64(media.Width))) @@ -174,8 +259,8 @@ func mediaProjectionAttributes(media mediainfo.Media) []projectionAttribute { if media.VideoResolution != "" { attributes = append(attributes, textProjectionAttribute("videoResolution", media.VideoResolution)) } - if media.FrameRate != "" { - attributes = append(attributes, textProjectionAttribute("videoFrameRate", media.FrameRate)) + if frameRate := plexMediaFrameRate(media.FrameRate); frameRate != "" { + attributes = append(attributes, textProjectionAttribute("videoFrameRate", frameRate)) } if media.VideoProfile != "" { attributes = append(attributes, textProjectionAttribute("videoProfile", media.VideoProfile)) @@ -207,9 +292,12 @@ func partProjectionAttributes(media mediainfo.Media) []projectionAttribute { } func streamProjectionAttributes(stream mediainfo.Stream) []projectionAttribute { - attributes := make([]projectionAttribute, 0, 20) + attributes := make([]projectionAttribute, 0, 35) if stream.Codec != "" { - attributes = append(attributes, textProjectionAttribute("codec", stream.Codec)) + attributes = append(attributes, textProjectionAttribute("codec", plexCodec(stream.Codec))) + } + if codecID := plexCodecID(stream.CodecTag); codecID != "" { + attributes = append(attributes, textProjectionAttribute("codecID", codecID)) } if stream.Profile != "" { attributes = append(attributes, textProjectionAttribute("profile", stream.Profile)) @@ -218,7 +306,7 @@ func streamProjectionAttributes(stream mediainfo.Stream) []projectionAttribute { attributes = append(attributes, integerProjectionAttribute("level", int64(stream.Level))) } if stream.Bitrate > 0 { - attributes = append(attributes, integerProjectionAttribute("bitrate", stream.Bitrate)) + attributes = append(attributes, integerProjectionAttribute("bitrate", bitrateKbps(stream.Bitrate))) } if stream.Width > 0 { attributes = append(attributes, integerProjectionAttribute("width", int64(stream.Width))) @@ -226,9 +314,9 @@ func streamProjectionAttributes(stream mediainfo.Stream) []projectionAttribute { if stream.Height > 0 { attributes = append(attributes, integerProjectionAttribute("height", int64(stream.Height))) } - frameRate := firstProjectionString(stream.FrameRate, stream.AverageFrameRate) + frameRate := plexFrameRate(firstProjectionString(stream.FrameRate, stream.AverageFrameRate)) if frameRate != "" { - attributes = append(attributes, textProjectionAttribute("frameRate", frameRate)) + attributes = append(attributes, projectionAttribute{name: "frameRate", value: frameRate, number: true}) } if stream.ReferenceFrames > 0 { attributes = append(attributes, integerProjectionAttribute("refFrames", int64(stream.ReferenceFrames))) @@ -266,15 +354,216 @@ func streamProjectionAttributes(stream mediainfo.Stream) []projectionAttribute { if stream.Channels > 0 { attributes = append(attributes, integerProjectionAttribute("channels", int64(stream.Channels))) } - if stream.ChannelLayout != "" { - attributes = append(attributes, textProjectionAttribute("channelLayout", stream.ChannelLayout)) + if stream.ChannelLayout != "" && canonicalStreamType(stream.Type) == "2" { + attributes = append(attributes, textProjectionAttribute("audioChannelLayout", stream.ChannelLayout)) } - if stream.Language != "" { - attributes = append(attributes, textProjectionAttribute("language", stream.Language)) + if code, ok := plexLanguageCode(stream.Language); ok { + attributes = append(attributes, textProjectionAttribute("languageCode", code)) } if stream.Title != "" { attributes = append(attributes, textProjectionAttribute("title", stream.Title)) } + if canonicalStreamType(stream.Type) == "1" { + if displayTitle, extendedDisplayTitle := videoDisplayTitles(stream); displayTitle != "" { + attributes = append(attributes, textProjectionAttribute("displayTitle", displayTitle)) + if extendedDisplayTitle != "" { + attributes = append(attributes, textProjectionAttribute("extendedDisplayTitle", extendedDisplayTitle)) + } + } + } + if stream.DolbyVision != nil && canonicalStreamType(stream.Type) == "1" { + attributes = append(attributes, dolbyVisionProjectionAttributes(stream.DolbyVision)...) + } + attributes = append(attributes, dispositionProjectionAttributes(stream.Disposition)...) + return attributes +} + +func plexCodecID(value string) string { + value = strings.TrimSpace(value) + if value == "" || strings.Contains(value, "[0]") || strings.EqualFold(value, "0x0000") { + return "" + } + for index := 0; index < len(value); index++ { + if value[index] < 0x21 || value[index] > 0x7e { + return "" + } + } + return value +} + +// plexCodec keeps the normalized codec vocabulary expected by Plex clients. +// ffprobe may expose a container-specific subtitle codec name that Plex +// represents using its short format name. +func plexCodec(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + switch value { + case "hdmv_pgs_subtitle", "pgs": + return "pgs" + case "subrip": + return "srt" + default: + return value + } +} + +func plexLanguageCode(value string) (string, bool) { + value = strings.TrimSpace(value) + if len(value) != 2 && len(value) != 3 { + return "", false + } + for index := 0; index < len(value); index++ { + if (value[index] < 'a' || value[index] > 'z') && (value[index] < 'A' || value[index] > 'Z') { + return "", false + } + } + return strings.ToLower(value), true +} + +func videoDisplayTitles(stream mediainfo.Stream) (string, string) { + label := "" + switch strings.ToLower(strings.TrimSpace(stream.HDRFormat)) { + case "dolby_vision", "dolbyvision", "dovi", "dv": + label = "DoVi" + if stream.DolbyVision != nil && dolbyVisionHDR10Compatible(stream.DolbyVision.BLCompatID) { + label = "DoVi/HDR10" + } + case "hdr10": + label = "HDR10" + case "hlg": + label = "HLG" + } + resolution := plexVideoResolution(stream) + displayTitle := strings.TrimSpace(strings.Join([]string{resolution, label}, " ")) + if displayTitle == "" { + return "", "" + } + codec := plexDisplayCodec(stream.Codec) + codecAndProfile := strings.TrimSpace(strings.Join([]string{codec, strings.TrimSpace(stream.Profile)}, " ")) + if codecAndProfile == "" { + return displayTitle, "" + } + return displayTitle, displayTitle + " (" + codecAndProfile + ")" +} + +func dolbyVisionHDR10Compatible(compatibilityID int) bool { + // Plex labels the two observed HDR10-compatible signal identifiers as a + // combined DoVi/HDR10 presentation. Other identifiers may describe SDR or + // HLG compatibility and must not be promoted to HDR10. + return compatibilityID == 1 || compatibilityID == 6 +} + +// plexFrameRate converts ffprobe ratios to the decimal representation emitted +// by Plex Stream metadata while preserving already-decimal values. +func plexFrameRate(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + numerator, denominator, fraction := strings.Cut(value, "/") + if !fraction { + parsed, err := strconv.ParseFloat(value, 64) + if err != nil || parsed <= 0 { + return "" + } + return strings.TrimRight(strings.TrimRight(strconv.FormatFloat(parsed, 'f', 3, 64), "0"), ".") + } + n, err := strconv.ParseFloat(strings.TrimSpace(numerator), 64) + if err != nil || n <= 0 { + return "" + } + d, err := strconv.ParseFloat(strings.TrimSpace(denominator), 64) + if err != nil || d <= 0 { + return "" + } + return strings.TrimRight(strings.TrimRight(strconv.FormatFloat(n/d, 'f', 3, 64), "0"), ".") +} + +// plexMediaFrameRate converts ffprobe's measured frame rate to Plex's nominal +// media classification. Stream metadata retains the precise decimal value so +// clients can distinguish a 23.976 stream from the Media-level 24p label. +func plexMediaFrameRate(value string) string { + decimal := plexFrameRate(value) + if decimal == "" { + return "" + } + fps, err := strconv.ParseFloat(decimal, 64) + if err != nil || fps <= 0 || math.IsNaN(fps) || math.IsInf(fps, 0) { + return "" + } + nominal := math.Round(fps) + if nominal <= 0 || math.IsInf(nominal, 0) { + return "" + } + return strconv.FormatFloat(nominal, 'f', 0, 64) + "p" +} + +func plexVideoResolution(stream mediainfo.Stream) string { + switch { + case stream.Width >= 3800 || stream.Height >= 2100: + return "4K" + case stream.Height >= 1400: + return "2K" + case stream.Height >= 1000: + return "1080p" + case stream.Height >= 700: + return "720p" + case stream.Height > 0: + return strconv.Itoa(stream.Height) + "p" + } + return "" +} + +func plexDisplayCodec(value string) string { + switch plexCodec(value) { + case "hevc": + return "HEVC" + case "h264": + return "H.264" + case "mpeg4": + return "MPEG-4" + case "av1": + return "AV1" + case "vp9": + return "VP9" + default: + return strings.ToUpper(plexCodec(value)) + } +} + +func dolbyVisionProjectionAttributes(dolby *mediainfo.DolbyVision) []projectionAttribute { + if dolby == nil { + return nil + } + attributes := []projectionAttribute{ + integerProjectionAttribute("DOVIBLCompatID", int64(dolby.BLCompatID)), + booleanProjectionAttribute("DOVIBLPresent", dolby.BLPresent != 0), + booleanProjectionAttribute("DOVIELPresent", dolby.ELPresent != 0), + booleanProjectionAttribute("DOVIPresent", true), + booleanProjectionAttribute("DOVIRPUPresent", dolby.RPUPresent != 0), + } + if dolby.Level > 0 { + attributes = append(attributes, integerProjectionAttribute("DOVILevel", int64(dolby.Level))) + } + if dolby.Profile > 0 { + attributes = append(attributes, integerProjectionAttribute("DOVIProfile", int64(dolby.Profile))) + } + if dolby.VersionMajor > 0 || dolby.VersionMinor > 0 { + attributes = append(attributes, textProjectionAttribute("DOVIVersion", fmt.Sprintf("%d.%d", dolby.VersionMajor, dolby.VersionMinor))) + } + return attributes +} + +func dispositionProjectionAttributes(disposition mediainfo.Disposition) []projectionAttribute { + attributes := make([]projectionAttribute, 0, 3) + if disposition.Forced { + attributes = append(attributes, booleanProjectionAttribute("forced", true)) + } + if disposition.HearingImpaired { + attributes = append(attributes, booleanProjectionAttribute("hearingImpaired", true)) + } + if disposition.VisualImpaired { + attributes = append(attributes, booleanProjectionAttribute("visualImpaired", true)) + } return attributes } @@ -296,16 +585,21 @@ var mediaProjectionAttributeNames = []string{ var partProjectionAttributeNames = []string{"duration", "size", "container", "videoProfile", "audioProfile"} var commonStreamProjectionAttributeNames = []string{ - "codec", "profile", "level", "bitrate", "language", "title", + "codec", "profile", "level", "bitrate", "languageCode", "title", } var videoStreamProjectionAttributeNames = []string{ "width", "height", "frameRate", "refFrames", "pixelFormat", "bitDepth", "colorSpace", "colorRange", "colorPrimaries", "colorTrc", "chromaLocation", - "sampleAspectRatio", "displayAspectRatio", + "sampleAspectRatio", "displayAspectRatio", "displayTitle", "extendedDisplayTitle", +} + +var dolbyVisionProjectionAttributeNames = []string{ + "DOVIBLCompatID", "DOVIBLPresent", "DOVIELPresent", "DOVILevel", + "DOVIPresent", "DOVIProfile", "DOVIRPUPresent", "DOVIVersion", } -var audioStreamProjectionAttributeNames = []string{"samplingRate", "channels", "channelLayout"} +var audioStreamProjectionAttributeNames = []string{"samplingRate", "channels", "audioChannelLayout"} type streamKey struct { typeName string @@ -321,6 +615,9 @@ func buildStreamProjections(media mediainfo.Media) ([]streamProjection, error) { projections := make([]streamProjection, 0, len(media.Streams)) seen := make(map[streamKey]struct{}, len(media.Streams)) for _, stream := range media.Streams { + if !projectableStream(stream) { + continue + } key, ok := mediaStreamKey(stream.Type, stream.Index) if !ok { continue @@ -337,6 +634,18 @@ func buildStreamProjections(media mediainfo.Media) ([]streamProjection, error) { return projections, nil } +func projectableStream(stream mediainfo.Stream) bool { + if stream.Disposition.AttachedPicture { + return false + } + switch canonicalStreamType(stream.Type) { + case "1", "2", "3": + return true + default: + return false + } +} + func mediaStreamKey(typeName string, index int) (streamKey, bool) { if index < 0 { return streamKey{}, false @@ -481,14 +790,46 @@ func (document *xmlProjectionDocument) selectTarget(ratingKey string) (projectio if len(videos) != 1 { return nil, errors.New("Plex XML metadata must contain one Video item") } - item := videos[0] - itemRatingKey, present, err := xmlAttribute(item.start.Attr, "ratingKey") + selection, err := xmlProjectionSelectionFromItem(videos[0]) if err != nil { return nil, err } - if !present || itemRatingKey == "" || itemRatingKey != ratingKey { + if selection.value.RatingKey != ratingKey { return nil, errors.New("Plex XML metadata ratingKey does not match the requested item") } + return selection, nil +} + +func (document *xmlProjectionDocument) selectTargets() ([]projectionSelection, error) { + roots := xmlProjectionElements(document.nodes) + if len(roots) != 1 || roots[0].start.Name.Local != "MediaContainer" { + return nil, errors.New("Plex XML metadata has no unique MediaContainer root") + } + videos := directXMLElements(roots[0], "Video") + selections := make([]projectionSelection, 0, len(videos)) + seen := make(map[string]struct{}, len(videos)) + for _, item := range videos { + selection, err := xmlProjectionSelectionFromItem(item) + if err != nil { + continue + } + if _, duplicate := seen[selection.value.RatingKey]; duplicate { + return nil, errors.New("Plex XML metadata has duplicate ratingKey items") + } + seen[selection.value.RatingKey] = struct{}{} + selections = append(selections, selection) + } + return selections, nil +} + +func xmlProjectionSelectionFromItem(item *xmlProjectionElement) (*xmlProjectionSelection, error) { + itemRatingKey, present, err := xmlAttribute(item.start.Attr, "ratingKey") + if err != nil { + return nil, err + } + if !present || itemRatingKey == "" { + return nil, errors.New("Plex XML metadata item has no ratingKey") + } mediaItems := directXMLElements(item, "Media") if len(mediaItems) != 1 { return nil, errors.New("Plex XML metadata must contain one Media") @@ -506,7 +847,7 @@ func (document *xmlProjectionDocument) selectTarget(ratingKey string) (projectio return nil, err } return &xmlProjectionSelection{ - value: Target{Part: part, NeedsEnrichment: needs}, + value: Target{RatingKey: itemRatingKey, Part: part, NeedsEnrichment: needs}, media: mediaItems[0], part: parts[0], }, nil @@ -529,11 +870,19 @@ func (selection *xmlProjectionSelection) enrich(media mediainfo.Media) (bool, er } changed := addMissingXMLAttributes(selection.media, mediaProjectionAttributes(media)) changed = addMissingXMLAttributes(selection.part, partProjectionAttributes(media)) || changed + changed = replaceSTRMPartSizeXML(selection.part, media.Size) || changed streamElements := directXMLElements(selection.part, "Stream") streamIndex, err := indexXMLStreams(streamElements) if err != nil { return false, err } + if len(streamElements) == 0 { + for _, projection := range streamProjections { + selection.part.children = append(selection.part.children, &xmlProjectionNode{element: newXMLStream(projection)}) + changed = true + } + return changed, nil + } for _, projection := range streamProjections { stream, ok := streamIndex[projection.key] if !ok { @@ -544,6 +893,44 @@ func (selection *xmlProjectionSelection) enrich(media mediainfo.Media) (bool, er return changed, nil } +func newXMLStream(projection streamProjection) *xmlProjectionElement { + attributes := make([]xml.Attr, 0, len(projection.attributes)+2) + attributes = append(attributes, + xml.Attr{Name: xml.Name{Local: "streamType"}, Value: projection.key.typeName}, + xml.Attr{Name: xml.Name{Local: "index"}, Value: strconv.Itoa(projection.key.index)}, + ) + for _, attribute := range projection.attributes { + attributes = append(attributes, xml.Attr{Name: xml.Name{Local: attribute.name}, Value: attribute.value}) + } + return &xmlProjectionElement{start: xml.StartElement{Name: xml.Name{Local: "Stream"}, Attr: attributes}} +} + +func replaceSTRMPartSizeXML(part *xmlProjectionElement, mediaSize int64) bool { + if mediaSize <= strmControlSizeThreshold { + return false + } + file, present, err := xmlAttribute(part.start.Attr, "file") + if err != nil || !present || !isSTRMPath(file) { + return false + } + for index := range part.start.Attr { + if part.start.Attr[index].Name.Local != "size" { + continue + } + current, err := strconv.ParseInt(strings.TrimSpace(part.start.Attr[index].Value), 10, 64) + if err != nil || current < 0 || current > strmControlSizeThreshold || mediaSize <= current { + return false + } + part.start.Attr[index].Value = strconv.FormatInt(mediaSize, 10) + return true + } + return false +} + +func isSTRMPath(value string) bool { + return strings.HasSuffix(strings.ToLower(strings.TrimSpace(value)), ".strm") +} + func partFromXML(element *xmlProjectionElement) (Part, error) { id, present, err := xmlAttribute(element.start.Attr, "id") if err != nil { @@ -602,10 +989,18 @@ func xmlTargetNeedsEnrichment(media, part *xmlProjectionElement) (bool, error) { needs = true } } + if correction, err := xmlSTRMPartSizeNeedsCorrection(part); err != nil { + return false, err + } else if correction { + needs = true + } streams := directXMLElements(part, "Stream") if _, err := indexXMLStreams(streams); err != nil { return false, err } + if len(streams) == 0 { + return true, nil + } for _, stream := range streams { key, present, err := xmlStreamIdentity(stream) if err != nil { @@ -615,7 +1010,7 @@ func xmlTargetNeedsEnrichment(media, part *xmlProjectionElement) (bool, error) { continue } for _, name := range streamProjectionAttributeNames(key.typeName) { - _, present, err := xmlAttribute(stream.start.Attr, name) + present, err := xmlProjectionAttributePresent(stream, name) if err != nil { return false, err } @@ -623,10 +1018,80 @@ func xmlTargetNeedsEnrichment(media, part *xmlProjectionElement) (bool, error) { needs = true } } + if key.typeName == "1" { + incomplete, err := xmlDolbyVisionFieldsIncomplete(stream) + if err != nil { + return false, err + } + needs = needs || incomplete + } } return needs, nil } +func xmlSTRMPartSizeNeedsCorrection(part *xmlProjectionElement) (bool, error) { + file, present, err := xmlAttribute(part.start.Attr, "file") + if err != nil || !present || !isSTRMPath(file) { + return false, err + } + size, present, err := xmlAttribute(part.start.Attr, "size") + if err != nil || !present { + return false, err + } + value, err := strconv.ParseInt(strings.TrimSpace(size), 10, 64) + return err == nil && value >= 0 && value <= strmControlSizeThreshold, nil +} + +func xmlDolbyVisionFieldsIncomplete(stream *xmlProjectionElement) (bool, error) { + displayTitle, present, err := xmlAttribute(stream.start.Attr, "displayTitle") + if err != nil { + return false, err + } + dolbyKnown := present && strings.Contains(strings.ToLower(displayTitle), "dovi") + for _, name := range dolbyVisionProjectionAttributeNames { + _, fieldPresent, err := xmlAttribute(stream.start.Attr, name) + if err != nil { + return false, err + } + dolbyKnown = dolbyKnown || fieldPresent + } + if !dolbyKnown { + return false, nil + } + for _, name := range dolbyVisionProjectionAttributeNames { + _, fieldPresent, err := xmlAttribute(stream.start.Attr, name) + if err != nil || !fieldPresent { + return !fieldPresent, err + } + } + return false, nil +} + +func xmlProjectionAttributePresent(stream *xmlProjectionElement, name string) (bool, error) { + _, present, err := xmlAttribute(stream.start.Attr, name) + if err != nil || present { + return present, err + } + for _, alias := range projectionAttributeAliases(name) { + _, present, err = xmlAttribute(stream.start.Attr, alias) + if err != nil || present { + return present, err + } + } + return false, nil +} + +func projectionAttributeAliases(name string) []string { + switch name { + case "languageCode": + return []string{"language"} + case "audioChannelLayout": + return []string{"channelLayout"} + default: + return nil + } +} + func indexXMLStreams(streams []*xmlProjectionElement) (map[streamKey]*xmlProjectionElement, error) { result := make(map[streamKey]*xmlProjectionElement, len(streams)) for _, stream := range streams { @@ -889,33 +1354,74 @@ func encodeJSONProjectionList(list jsonProjectionList) (json.RawMessage, error) } func (document *jsonProjectionDocument) selectTarget(ratingKey string) (projectionSelection, error) { + container, metadata, err := document.directMetadata() + if err != nil { + return nil, err + } + if len(metadata.items) != 1 { + return nil, errors.New("Plex JSON metadata must contain one Metadata item") + } + selection, err := jsonProjectionSelectionFromItem(document, container, metadata, metadata.items[0]) + if err != nil { + return nil, err + } + if selection.value.RatingKey != ratingKey { + return nil, errors.New("Plex JSON metadata ratingKey does not match the requested item") + } + return selection, nil +} + +func (document *jsonProjectionDocument) selectTargets() ([]projectionSelection, error) { + container, metadata, err := document.directMetadata() + if err != nil { + return nil, err + } + selections := make([]projectionSelection, 0, len(metadata.items)) + seen := make(map[string]struct{}, len(metadata.items)) + for _, item := range metadata.items { + selection, err := jsonProjectionSelectionFromItem(document, container, metadata, item) + if err != nil { + continue + } + if _, duplicate := seen[selection.value.RatingKey]; duplicate { + return nil, errors.New("Plex JSON metadata has duplicate ratingKey items") + } + seen[selection.value.RatingKey] = struct{}{} + selections = append(selections, selection) + } + return selections, nil +} + +func (document *jsonProjectionDocument) directMetadata() (jsonObject, jsonProjectionList, error) { containerRaw, ok := document.root["MediaContainer"] if !ok { - return nil, errors.New("Plex JSON metadata has no MediaContainer") + return nil, jsonProjectionList{}, errors.New("Plex JSON metadata has no MediaContainer") } container, err := decodeJSONProjectionObject(bytes.TrimSpace(containerRaw)) if err != nil { - return nil, fmt.Errorf("parse Plex JSON MediaContainer: %w", err) + return nil, jsonProjectionList{}, fmt.Errorf("parse Plex JSON MediaContainer: %w", err) } metadataRaw, ok := container["Metadata"] if !ok { - return nil, errors.New("Plex JSON metadata has no Metadata item") + return nil, jsonProjectionList{}, errors.New("Plex JSON metadata has no Metadata item") } metadata, err := decodeJSONProjectionList(metadataRaw, "Metadata") if err != nil { - return nil, err + return nil, jsonProjectionList{}, err } - if len(metadata.items) != 1 { - return nil, errors.New("Plex JSON metadata must contain one Metadata item") - } - item := metadata.items[0] + return container, metadata, nil +} + +func jsonProjectionSelectionFromItem( + document *jsonProjectionDocument, + container jsonObject, + metadata jsonProjectionList, + item *jsonObject, +) (*jsonProjectionSelection, error) { itemRatingKey, err := requiredJSONScalarString(*item, "ratingKey") if err != nil { return nil, err } - if itemRatingKey != ratingKey { - return nil, errors.New("Plex JSON metadata ratingKey does not match the requested item") - } mediaRaw, ok := (*item)["Media"] if !ok { return nil, errors.New("Plex JSON metadata has no Media") @@ -949,26 +1455,28 @@ func (document *jsonProjectionDocument) selectTarget(ratingKey string) (projecti return nil, err } return &jsonProjectionSelection{ - value: Target{Part: part, NeedsEnrichment: needs}, - document: document, - container: container, - metadata: metadata, - media: mediaItems, - parts: parts, - mediaItem: mediaItem, - part: partItem, + value: Target{RatingKey: itemRatingKey, Part: part, NeedsEnrichment: needs}, + document: document, + container: container, + metadata: metadata, + metadataItem: item, + media: mediaItems, + parts: parts, + mediaItem: mediaItem, + part: partItem, }, nil } type jsonProjectionSelection struct { - value Target - document *jsonProjectionDocument - container jsonObject - metadata jsonProjectionList - media jsonProjectionList - parts jsonProjectionList - mediaItem *jsonObject - part *jsonObject + value Target + document *jsonProjectionDocument + container jsonObject + metadata jsonProjectionList + metadataItem *jsonObject + media jsonProjectionList + parts jsonProjectionList + mediaItem *jsonObject + part *jsonObject } func (selection *jsonProjectionSelection) target() Target { @@ -989,35 +1497,55 @@ func (selection *jsonProjectionSelection) enrich(media mediainfo.Media) (bool, e return false, err } changed = changed || partChanged - streamRaw, exists := (*selection.part)["Stream"] - if !exists { - return changed, nil - } - streams, err := decodeJSONProjectionList(streamRaw, "Stream") - if err != nil { - return false, err - } - streamIndex, err := indexJSONStreams(streams.items) + partSizeChanged, err := replaceSTRMPartSizeJSON(*selection.part, media.Size) if err != nil { return false, err } - for _, projection := range streamProjections { - stream, ok := streamIndex[projection.key] - if !ok { - continue + changed = changed || partSizeChanged + streamRaw, exists := (*selection.part)["Stream"] + if !exists { + if len(streamProjections) > 0 { + if err := setGeneratedJSONStreams(*selection.part, streamProjections); err != nil { + return false, err + } + changed = true } - streamChanged, err := addMissingJSONAttributes(*stream, projection.attributes) + } else { + streams, err := decodeJSONProjectionList(streamRaw, "Stream") if err != nil { return false, err } - changed = changed || streamChanged - } - if changed { - encoded, err := encodeJSONProjectionList(streams) - if err != nil { - return false, err + if len(streams.items) == 0 { + if len(streamProjections) > 0 { + if err := setGeneratedJSONStreams(*selection.part, streamProjections); err != nil { + return false, err + } + changed = true + } + } else { + streamIndex, err := indexJSONStreams(streams.items) + if err != nil { + return false, err + } + for _, projection := range streamProjections { + stream, ok := streamIndex[projection.key] + if !ok { + continue + } + streamChanged, err := addMissingJSONAttributes(*stream, projection.attributes) + if err != nil { + return false, err + } + changed = changed || streamChanged + } + if changed { + encoded, err := encodeJSONProjectionList(streams) + if err != nil { + return false, err + } + (*selection.part)["Stream"] = encoded + } } - (*selection.part)["Stream"] = encoded } if changed { if err := selection.commit(); err != nil { @@ -1027,6 +1555,74 @@ func (selection *jsonProjectionSelection) enrich(media mediainfo.Media) (bool, e return changed, nil } +func setGeneratedJSONStreams(part jsonObject, projections []streamProjection) error { + streams := jsonProjectionList{items: make([]*jsonObject, 0, len(projections)), array: true} + for _, projection := range projections { + stream := make(jsonObject) + if _, err := addMissingJSONAttributes(stream, generatedStreamAttributes(projection)); err != nil { + return err + } + streams.items = append(streams.items, &stream) + } + encoded, err := encodeJSONProjectionList(streams) + if err != nil { + return err + } + part["Stream"] = encoded + return nil +} + +func generatedStreamAttributes(projection streamProjection) []projectionAttribute { + attributes := make([]projectionAttribute, 0, len(projection.attributes)+2) + attributes = append(attributes, + integerProjectionAttribute("streamType", int64(streamTypeNumber(projection.key.typeName))), + integerProjectionAttribute("index", int64(projection.key.index)), + ) + return append(attributes, projection.attributes...) +} + +func streamTypeNumber(typeName string) int { + switch typeName { + case "1": + return 1 + case "2": + return 2 + case "3": + return 3 + default: + return 0 + } +} + +func replaceSTRMPartSizeJSON(part jsonObject, mediaSize int64) (bool, error) { + if mediaSize <= strmControlSizeThreshold { + return false, nil + } + file, err := requiredJSONString(part, "file") + if err != nil || !isSTRMPath(file) { + return false, nil + } + raw, present := part["size"] + if !present { + return false, nil + } + current, ok := jsonRawInt64(raw) + if !ok || current < 0 || current > strmControlSizeThreshold || mediaSize <= current { + return false, nil + } + part["size"] = json.RawMessage(strconv.FormatInt(mediaSize, 10)) + return true, nil +} + +func jsonRawInt64(raw json.RawMessage) (int64, bool) { + value, ok := jsonRawScalarString(raw) + if !ok { + return 0, false + } + parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + return parsed, err == nil +} + func (selection *jsonProjectionSelection) commit() error { parts, err := encodeJSONProjectionList(selection.parts) if err != nil { @@ -1037,8 +1633,7 @@ func (selection *jsonProjectionSelection) commit() error { if err != nil { return err } - // The metadata list has exactly one item by selection contract. - (*selection.metadata.items[0])["Media"] = media + (*selection.metadataItem)["Media"] = media metadata, err := encodeJSONProjectionList(selection.metadata) if err != nil { return err @@ -1130,14 +1725,20 @@ func jsonTargetNeedsEnrichment(media, part *jsonObject) (bool, error) { needs = true } } + if jsonSTRMPartSizeNeedsCorrection(*part) { + needs = true + } streamRaw, exists := (*part)["Stream"] if !exists { - return needs, nil + return true, nil } streams, err := decodeJSONProjectionList(streamRaw, "Stream") if err != nil { return false, err } + if len(streams.items) == 0 { + return true, nil + } if _, err := indexJSONStreams(streams.items); err != nil { return false, err } @@ -1150,14 +1751,64 @@ func jsonTargetNeedsEnrichment(media, part *jsonObject) (bool, error) { continue } for _, name := range streamProjectionAttributeNames(key.typeName) { - if _, present := (*stream)[name]; !present { + if !jsonProjectionAttributePresent(*stream, name) { needs = true } } + if key.typeName == "1" && jsonDolbyVisionFieldsIncomplete(*stream) { + needs = true + } } return needs, nil } +func jsonSTRMPartSizeNeedsCorrection(part jsonObject) bool { + file, err := requiredJSONString(part, "file") + if err != nil || !isSTRMPath(file) { + return false + } + raw, present := part["size"] + if !present { + return false + } + value, ok := jsonRawInt64(raw) + return ok && value >= 0 && value <= strmControlSizeThreshold +} + +func jsonDolbyVisionFieldsIncomplete(stream jsonObject) bool { + dolbyKnown := false + if raw, present := stream["displayTitle"]; present { + if value, ok := jsonRawScalarString(raw); ok { + dolbyKnown = strings.Contains(strings.ToLower(value), "dovi") + } + } + for _, name := range dolbyVisionProjectionAttributeNames { + _, present := stream[name] + dolbyKnown = dolbyKnown || present + } + if !dolbyKnown { + return false + } + for _, name := range dolbyVisionProjectionAttributeNames { + if _, present := stream[name]; !present { + return true + } + } + return false +} + +func jsonProjectionAttributePresent(stream jsonObject, name string) bool { + if _, present := stream[name]; present { + return true + } + for _, alias := range projectionAttributeAliases(name) { + if _, present := stream[alias]; present { + return true + } + } + return false +} + func indexJSONStreams(streams []*jsonObject) (map[streamKey]*jsonObject, error) { result := make(map[streamKey]*jsonObject, len(streams)) for _, stream := range streams { @@ -1209,7 +1860,13 @@ func addMissingJSONAttributes(object jsonObject, attributes []projectionAttribut } var raw []byte var err error - if attribute.number { + if attribute.boolean { + if attribute.value == "1" { + raw = []byte("true") + } else { + raw = []byte("false") + } + } else if attribute.number { raw = []byte(attribute.value) } else { raw, err = json.Marshal(attribute.value) diff --git a/internal/plexmeta/projection_test.go b/internal/plexmeta/projection_test.go index d23a7d9..4a409d3 100644 --- a/internal/plexmeta/projection_test.go +++ b/internal/plexmeta/projection_test.go @@ -41,6 +41,136 @@ func TestSelectEnrichmentTargetStrictlySelectsJSONItem(t *testing.T) { } } +func TestEnrichMetadataTargetsProjectsOnlyMappedCollectionItems(t *testing.T) { + media := projectionStreamMedia() + tests := []struct { + name string + contentType string + body []byte + wantCloud []byte + wantLocal []byte + }{ + { + name: "XML", + contentType: "application/xml", + body: []byte(``), + wantCloud: []byte(`ratingKey="42">`), + }, + { + name: "JSON", + contentType: "application/json", + body: []byte(`{"MediaContainer":{"Metadata":[` + + `{"ratingKey":"42","Media":[{"Part":[{"id":7,"file":"/cloud/a.strm"}]}]},` + + `{"ratingKey":"43","Media":[{"Part":[{"id":8,"file":"/cloud/b.strm"}]},{"Part":[{"id":9,"file":"/cloud/c.strm"}]}]},` + + `{"ratingKey":"44","Media":[{"Part":[{"id":10,"file":"/cloud/d.strm"},{"id":11,"file":"/cloud/e.strm"}]}]},` + + `{"ratingKey":"45","Media":[{}]}]}}`), + }, + } { + t.Run(test.name, func(t *testing.T) { + original := append([]byte(nil), test.body...) + targets, err := SelectEnrichmentTargets(test.body, test.contentType) + if err != nil { + t.Fatal(err) + } + if len(targets) != 1 || targets[0].RatingKey != "42" || targets[0].Part.ID != "7" { + t.Fatalf("targets = %#v", targets) + } + enriched, changed, err := EnrichMetadataTargets(test.body, test.contentType, map[string]mediainfo.Media{"42": media}) + if err != nil { + t.Fatal(err) + } + if changed != 1 || bytes.Count(enriched, []byte("HDR10")) != 2 { + t.Fatalf("changed=%d body=%s", changed, enriched) + } + if bytes.Contains(enriched, []byte(`ratingKey="43"`)) && bytes.Contains(enriched, []byte(`ratingKey="43">`), + }, + { + name: "JSON", + contentType: "application/json", + body: []byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"Part":[{"id":7,"file":"/cloud/a.strm"}]}]},{"ratingKey":"42","Media":[{"Part":[{"id":8,"file":"/cloud/b.strm"}]}]}]}}`), + }, + } { + t.Run(test.name, func(t *testing.T) { + original := append([]byte(nil), test.body...) + if _, err := SelectEnrichmentTargets(test.body, test.contentType); err == nil { + t.Fatal("duplicate ratingKey collection unexpectedly succeeded") + } + if result, changed, err := EnrichMetadataTargets(test.body, test.contentType, map[string]mediainfo.Media{"42": projectionStreamMedia()}); err == nil || result != nil || changed != 0 { + t.Fatalf("result=%q changed=%d err=%v", result, changed, err) + } + if !bytes.Equal(test.body, original) { + t.Fatal("EnrichMetadataTargets modified its input body after an error") + } + }) + } +} + func TestEnrichMetadataXMLFillsOnlyMissingSafeFields(t *testing.T) { body := []byte(``) original := append([]byte(nil), body...) @@ -132,14 +262,338 @@ func TestEnrichMetadataJSONFillsOnlyMissingSafeFields(t *testing.T) { } } -func TestEnrichMetadataDoesNotCreateStreams(t *testing.T) { +func TestEnrichMetadataCreatesStreamsWhenPartHasNone(t *testing.T) { + media := projectionStreamMedia() + tests := []struct { + name string + contentType string + body []byte + }{ + { + name: "XML", + contentType: "application/xml", + body: []byte(``), + }, + { + name: "JSON", + contentType: "application/json", + body: []byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"Part":[{"id":7,"file":"/cloud/movie.strm"}]}]}]}}`), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, changed, err := EnrichMetadata(test.body, test.contentType, "42", media) + if err != nil { + t.Fatal(err) + } + if !changed || !bytes.Contains(result, []byte("Stream")) { + t.Fatalf("result = %s, want generated Stream elements", result) + } + if test.contentType == "application/xml" { + attrs := xmlProjectionAttributeSets(t, result) + if attrs["Media"]["bitrate"] != "29430" || attrs["Part"]["size"] != "987654321" { + t.Fatalf("projected XML units = media=%#v part=%#v", attrs["Media"], attrs["Part"]) + } + if attrs["Stream#1"]["streamType"] != "1" || attrs["Stream#1"]["index"] != "0" || + attrs["Stream#2"]["streamType"] != "2" || attrs["Stream#2"]["index"] != "1" || + attrs["Stream#3"]["streamType"] != "3" || attrs["Stream#3"]["index"] != "2" { + t.Fatalf("generated XML stream identities = %#v", attrs) + } + if attrs["Stream#1"]["bitrate"] != "29430" || attrs["Stream#1"]["frameRate"] != "23.976" || + attrs["Stream#1"]["displayTitle"] != "4K HDR10" || + attrs["Stream#1"]["extendedDisplayTitle"] != "4K HDR10 (HEVC Main 10)" || + attrs["Stream#1"]["colorTrc"] != "smpte2084" { + t.Fatalf("generated HDR10 attributes = %#v", attrs["Stream#1"]) + } + if attrs["Stream#2"]["bitrate"] != "5000" || attrs["Stream#2"]["audioChannelLayout"] != "7.1" { + t.Fatalf("generated audio attributes = %#v", attrs["Stream#2"]) + } + for _, name := range []string{"id", "selected", "default", "decision"} { + for _, streamName := range []string{"Stream#1", "Stream#2", "Stream#3"} { + if _, present := attrs[streamName][name]; present { + t.Fatalf("generated XML Stream contains forbidden field %q: %#v", name, attrs[streamName]) + } + } + } + if attrs["Stream#3"]["codec"] != "pgs" || attrs["Stream#3"]["languageCode"] != "zho" || attrs["Stream#3"]["forced"] != "1" { + t.Fatalf("generated subtitle attributes = %#v", attrs["Stream#3"]) + } + return + } + var document map[string]any + if err := json.Unmarshal(result, &document); err != nil { + t.Fatal(err) + } + part := document["MediaContainer"].(map[string]any)["Metadata"].([]any)[0].(map[string]any)["Media"].([]any)[0].(map[string]any)["Part"].([]any)[0].(map[string]any) + mediaObject := document["MediaContainer"].(map[string]any)["Metadata"].([]any)[0].(map[string]any)["Media"].([]any)[0].(map[string]any) + if mediaObject["bitrate"] != float64(29430) || part["size"] != float64(987654321) { + t.Fatalf("projected JSON units = media=%#v part=%#v", mediaObject, part) + } + streams := part["Stream"].([]any) + if len(streams) != 3 { + t.Fatalf("generated JSON streams = %#v", streams) + } + for _, rawStream := range streams { + stream := rawStream.(map[string]any) + for _, name := range []string{"id", "selected", "default", "decision"} { + if _, present := stream[name]; present { + t.Fatalf("generated JSON Stream contains forbidden field %q: %#v", name, stream) + } + } + } + if streams[0].(map[string]any)["streamType"] != float64(1) || streams[0].(map[string]any)["index"] != float64(0) || + streams[1].(map[string]any)["streamType"] != float64(2) || streams[1].(map[string]any)["index"] != float64(1) || + streams[2].(map[string]any)["streamType"] != float64(3) || streams[2].(map[string]any)["index"] != float64(2) { + t.Fatalf("generated JSON stream identities = %#v", streams) + } + if streams[2].(map[string]any)["codec"] != "pgs" || streams[2].(map[string]any)["languageCode"] != "zho" || streams[2].(map[string]any)["forced"] != true { + t.Fatalf("generated JSON subtitle attributes = %#v", streams[2]) + } + if streams[0].(map[string]any)["bitrate"] != float64(29430) || streams[0].(map[string]any)["frameRate"] != 23.976 || + streams[0].(map[string]any)["displayTitle"] != "4K HDR10" || + streams[0].(map[string]any)["extendedDisplayTitle"] != "4K HDR10 (HEVC Main 10)" || + streams[0].(map[string]any)["colorTrc"] != "smpte2084" { + t.Fatalf("generated JSON HDR10 attributes = %#v", streams[0]) + } + if streams[1].(map[string]any)["bitrate"] != float64(5000) || streams[1].(map[string]any)["audioChannelLayout"] != "7.1" { + t.Fatalf("generated JSON audio attributes = %#v", streams[1]) + } + }) + } +} + +func TestEnrichMetadataUsesDolbyVisionPlexFields(t *testing.T) { + media := projectionStreamMedia() + media.Streams[0].HDRFormat = "dolby_vision" + media.Streams[0].CodecTag = "dvh1" + media.Streams[0].DolbyVision = &mediainfo.DolbyVision{ + VersionMajor: 1, VersionMinor: 0, Profile: 7, Level: 6, + RPUPresent: 1, ELPresent: 1, BLPresent: 1, BLCompatID: 6, + } + for _, test := range []struct { + name string + contentType string + body []byte + }{ + { + name: "XML", + contentType: "application/xml", + body: []byte(``), + }, + { + name: "JSON", + contentType: "application/json", + body: []byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"Part":[{"id":7,"file":"/cloud/movie.strm"}]}]}]}}`), + }, + } { + t.Run(test.name, func(t *testing.T) { + result, changed, err := EnrichMetadata(test.body, test.contentType, "42", media) + if err != nil || !changed { + t.Fatalf("EnrichMetadata changed=%v err=%v", changed, err) + } + if test.contentType == "application/xml" { + attrs := xmlProjectionAttributeSets(t, result)["Stream#1"] + assertDolbyVisionAttributes(t, attrs) + if attrs["codecID"] != "dvh1" { + t.Fatalf("Dolby Vision codecID = %q", attrs["codecID"]) + } + return + } + var document map[string]any + if err := json.Unmarshal(result, &document); err != nil { + t.Fatal(err) + } + part := document["MediaContainer"].(map[string]any)["Metadata"].([]any)[0].(map[string]any)["Media"].([]any)[0].(map[string]any)["Part"].([]any)[0].(map[string]any) + attrs := part["Stream"].([]any)[0].(map[string]any) + if attrs["codecID"] != "dvh1" || attrs["displayTitle"] != "4K DoVi/HDR10" || attrs["extendedDisplayTitle"] != "4K DoVi/HDR10 (HEVC Main 10)" || + attrs["DOVIBLCompatID"] != float64(6) || attrs["DOVIBLPresent"] != true || + attrs["DOVIELPresent"] != true || attrs["DOVILevel"] != float64(6) || + attrs["DOVIPresent"] != true || attrs["DOVIProfile"] != float64(7) || + attrs["DOVIRPUPresent"] != true || attrs["DOVIVersion"] != "1.0" { + t.Fatalf("Dolby Vision JSON attributes = %#v", attrs) + } + }) + } +} + +func TestPlexCodecIDRejectsFFProbePlaceholderTags(t *testing.T) { + for value, want := range map[string]string{ + "dvh1": "dvh1", + " hvc1 ": "hvc1", + "[0][0][0][0]": "", + "0x0000": "", + "bad\nvalue": "", + } { + if got := plexCodecID(value); got != want { + t.Fatalf("plexCodecID(%q) = %q, want %q", value, got, want) + } + } +} + +func TestEnrichMetadataProjectsPlexMediaFrameRateAndPreciseStreamRate(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {name: "film rate", input: "24000/1001", want: "24p"}, + {name: "ntsc rate", input: "30000/1001", want: "30p"}, + {name: "pal rate", input: "25/1", want: "25p"}, + } + for _, format := range []struct { + name string + contentType string + body []byte + }{ + { + name: "XML", + contentType: "application/xml", + body: []byte(``), + }, + { + name: "JSON", + contentType: "application/json", + body: []byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"Part":[{"id":7,"file":"/cloud/movie.strm","Stream":[{"streamType":1,"index":0}]}]}]}]}}`), + }, + } { + for _, test := range tests { + t.Run(format.name+"/"+test.name, func(t *testing.T) { + media := projectionStreamMedia() + media.FrameRate = test.input + result, changed, err := EnrichMetadata(format.body, format.contentType, "42", media) + if err != nil || !changed { + t.Fatalf("EnrichMetadata changed=%v err=%v", changed, err) + } + + if format.contentType == "application/xml" { + attrs := xmlProjectionAttributeSets(t, result) + if attrs["Media"]["videoFrameRate"] != test.want || attrs["Stream#1"]["frameRate"] != "23.976" { + t.Fatalf("projected XML frame rates = media=%q stream=%q", attrs["Media"]["videoFrameRate"], attrs["Stream#1"]["frameRate"]) + } + return + } + + var document map[string]any + if err := json.Unmarshal(result, &document); err != nil { + t.Fatal(err) + } + mediaObject := document["MediaContainer"].(map[string]any)["Metadata"].([]any)[0].(map[string]any)["Media"].([]any)[0].(map[string]any) + part := mediaObject["Part"].([]any)[0].(map[string]any) + stream := part["Stream"].([]any)[0].(map[string]any) + if mediaObject["videoFrameRate"] != test.want || stream["frameRate"] != 23.976 { + t.Fatalf("projected JSON frame rates = media=%#v stream=%#v", mediaObject["videoFrameRate"], stream["frameRate"]) + } + }) + } + } +} + +func assertDolbyVisionAttributes(t *testing.T, attrs map[string]string) { + t.Helper() + if attrs["displayTitle"] != "4K DoVi/HDR10" || attrs["extendedDisplayTitle"] != "4K DoVi/HDR10 (HEVC Main 10)" || + attrs["DOVIBLCompatID"] != "6" || attrs["DOVIBLPresent"] != "1" || attrs["DOVIELPresent"] != "1" || + attrs["DOVILevel"] != "6" || attrs["DOVIPresent"] != "1" || attrs["DOVIProfile"] != "7" || + attrs["DOVIRPUPresent"] != "1" || attrs["DOVIVersion"] != "1.0" { + t.Fatalf("Dolby Vision XML attributes = %#v", attrs) + } +} + +func TestDolbyVisionDisplayTitleDoesNotAssumeHDR10FromBaseLayer(t *testing.T) { + stream := mediainfo.Stream{ + Type: "video", Codec: "hevc", Profile: "Main 10", Width: 3840, Height: 2160, + HDRFormat: "dolby_vision", + DolbyVision: &mediainfo.DolbyVision{ + VersionMajor: 1, Profile: 5, Level: 6, RPUPresent: 1, BLPresent: 1, + }, + } + displayTitle, extendedDisplayTitle := videoDisplayTitles(stream) + if displayTitle != "4K DoVi" || extendedDisplayTitle != "4K DoVi (HEVC Main 10)" { + t.Fatalf("Dolby Vision-only titles = %q, %q", displayTitle, extendedDisplayTitle) + } +} + +func TestEnrichMetadataDoesNotProjectUnknownCodecFieldsFromDolbyVisionProfile(t *testing.T) { + media := mediainfo.Media{ + Complete: true, Container: "mkv", DurationMS: 60000, + VideoCodec: "hevc", VideoResolution: "4k", Width: 3840, Height: 2160, + Streams: []mediainfo.Stream{ + { + Index: 0, Type: "video", Codec: "hevc", Width: 3840, Height: 2160, + HDRFormat: "dolby_vision", + DolbyVision: &mediainfo.DolbyVision{ + VersionMajor: 1, Profile: 5, Level: 6, RPUPresent: 1, BLPresent: 1, + }, + }, + }, + } body := []byte(``) - result, changed, err := EnrichMetadata(body, "application/xml", "42", projectionTestMedia()) - if err != nil { - t.Fatal(err) + + result, changed, err := EnrichMetadata(body, "application/xml", "42", media) + if err != nil || !changed { + t.Fatalf("EnrichMetadata changed=%v err=%v", changed, err) + } + attrs := xmlProjectionAttributeSets(t, result)["Stream#1"] + if attrs["DOVIProfile"] != "5" || attrs["DOVIPresent"] != "1" || attrs["displayTitle"] != "4K DoVi" { + t.Fatalf("Dolby Vision projection = %#v", attrs) + } + for _, name := range []string{"profile", "bitDepth", "pixelFormat", "colorSpace", "colorRange", "colorPrimaries", "colorTrc"} { + if value, present := attrs[name]; present { + t.Fatalf("unknown field %q was projected as %q: %#v", name, value, attrs) + } + } +} + +func TestEnrichMetadataReplacesOnlyObviousSTRMPartSize(t *testing.T) { + media := projectionTestMedia() + media.Size = 987654321 + for _, test := range []struct { + name string + body string + want string + }{ + { + name: "XML control size", + body: ``, + want: `size="987654321"`, + }, + { + name: "XML non-control size", + body: ``, + want: `size="2097152"`, + }, + } { + t.Run(test.name, func(t *testing.T) { + result, changed, err := EnrichMetadata([]byte(test.body), "application/xml", "42", media) + if err != nil || !changed || !bytes.Contains(result, []byte(test.want)) { + t.Fatalf("result=%s changed=%v err=%v", result, changed, err) + } + }) } - if !changed || bytes.Contains(result, []byte("`) + result, changed, err := EnrichMetadata(body, "application/xml", "42", media) + if err != nil || !changed || !bytes.Contains(result, []byte(`size="301"`)) { + t.Fatalf("threshold result=%s changed=%v err=%v", result, changed, err) + } +} + +func TestEnrichMetadataDoesNotCreateMissingStreamsWhenPlexHasOne(t *testing.T) { + media := projectionStreamMedia() + body := []byte(``) + result, changed, err := EnrichMetadata(body, "application/xml", "42", media) + if err != nil || !changed { + t.Fatalf("EnrichMetadata changed=%v err=%v", changed, err) + } + attrs := xmlProjectionAttributeSets(t, result) + if _, exists := attrs["Stream#2"]; exists { + t.Fatalf("missing audio stream was created: %#v", attrs) + } + if _, exists := attrs["Stream#3"]; exists { + t.Fatalf("missing streams were created: %#v", attrs) + } + if attrs["Stream#1"]["codec"] != "existing" || attrs["Stream#1"]["displayTitle"] != "4K HDR10" { + t.Fatalf("existing stream was not conservatively enriched: %#v", attrs["Stream#1"]) } } @@ -307,6 +761,40 @@ func projectionTestMedia() mediainfo.Media { } } +func projectionStreamMedia() mediainfo.Media { + return mediainfo.Media{ + Complete: true, + Container: "mkv", + DurationMS: 123000, + Size: 987654321, + Bitrate: 29430119, + Streams: []mediainfo.Stream{ + { + Index: 0, Type: "video", Codec: "hevc", Profile: "Main 10", Bitrate: 29430119, + Width: 3840, Height: 2160, PixelFormat: "yuv420p10le", BitDepth: 10, + ColorSpace: "bt2020nc", ColorRange: "tv", ColorPrimaries: "bt2020", + ColorTransfer: "smpte2084", HDRFormat: "hdr10", FrameRate: "24000/1001", + }, + { + Index: 1, Type: "audio", Codec: "truehd", Profile: "Dolby TrueHD + Dolby Atmos", + Bitrate: 5000000, SampleRate: 48000, Channels: 8, ChannelLayout: "7.1", + Language: "eng", Title: "Dolby Atmos", + }, + { + Index: 2, Type: "subtitle", Codec: "hdmv_pgs_subtitle", Language: "zho", Title: "Chinese", + Disposition: mediainfo.Disposition{Forced: true}, + }, + { + Index: 3, Type: "video", Codec: "mjpeg", Width: 100, Height: 100, + Disposition: mediainfo.Disposition{AttachedPicture: true}, + }, + { + Index: 4, Type: "data", Codec: "bin_data", + }, + }, + } +} + func xmlProjectionAttributeSets(t *testing.T, body []byte) map[string]map[string]string { t.Helper() decoder := xml.NewDecoder(bytes.NewReader(body)) @@ -341,9 +829,9 @@ func xmlProjectionAttributeSets(t *testing.T, body []byte) map[string]map[string } func completeProjectionXML() string { - return `` + return `` } func completeProjectionJSON() string { - return `{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"container":"mkv","duration":123000,"bitrate":12000000,"width":3840,"height":2160,"aspectRatio":"16:9","audioChannels":6,"audioCodec":"aac","videoCodec":"hevc","videoResolution":"4k","videoFrameRate":"23.976","videoProfile":"Main 10","audioProfile":"LC","Part":[{"id":7,"key":"/library/parts/7/1/file","file":"/cloud/movie.strm","duration":123000,"size":987654321,"container":"mkv","videoProfile":"Main 10","audioProfile":"LC","Stream":[{"streamType":1,"index":0,"codec":"hevc","profile":"Main 10","level":153,"bitrate":12000000,"width":3840,"height":2160,"frameRate":"23.976","refFrames":4,"pixelFormat":"yuv420p10le","bitDepth":10,"colorSpace":"bt2020nc","colorRange":"tv","colorPrimaries":"bt2020","colorTrc":"smpte2084","chromaLocation":"left","sampleAspectRatio":"1:1","displayAspectRatio":"16:9","language":"eng","title":"Video"},{"streamType":2,"index":1,"codec":"aac","profile":"LC","level":1,"bitrate":640000,"language":"eng","title":"English","samplingRate":48000,"channels":6,"channelLayout":"5.1"}]}]}]}]}}` + return `{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"container":"mkv","duration":123000,"bitrate":12000,"width":3840,"height":2160,"aspectRatio":"16:9","audioChannels":6,"audioCodec":"aac","videoCodec":"hevc","videoResolution":"4k","videoFrameRate":"23.976","videoProfile":"Main 10","audioProfile":"LC","Part":[{"id":7,"key":"/library/parts/7/1/file","file":"/cloud/movie.strm","duration":123000,"size":987654321,"container":"mkv","videoProfile":"Main 10","audioProfile":"LC","Stream":[{"streamType":1,"index":0,"codec":"hevc","profile":"Main 10","level":153,"bitrate":12000,"width":3840,"height":2160,"frameRate":23.976,"refFrames":4,"pixelFormat":"yuv420p10le","bitDepth":10,"colorSpace":"bt2020nc","colorRange":"tv","colorPrimaries":"bt2020","colorTrc":"smpte2084","chromaLocation":"left","sampleAspectRatio":"1:1","displayAspectRatio":"16:9","languageCode":"eng","title":"Video","displayTitle":"4K HDR10","extendedDisplayTitle":"4K HDR10 (HEVC Main 10)"},{"streamType":2,"index":1,"codec":"aac","profile":"LC","level":1,"bitrate":640,"languageCode":"eng","title":"English","samplingRate":48000,"channels":6,"audioChannelLayout":"5.1"}]}]}]}]}}` } diff --git a/internal/prewarm/discovery.go b/internal/prewarm/discovery.go new file mode 100644 index 0000000..0aaa60f --- /dev/null +++ b/internal/prewarm/discovery.go @@ -0,0 +1,447 @@ +// Package prewarm discovers and schedules nearby speculative MediaInfo records +// after a cloud redirect is ready. +package prewarm + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/InfinityPacer/plex-gateway/internal/plexmeta" +) + +const defaultPlexResponseLimit int64 = 8 << 20 + +var ( + ErrNoCandidates = errors.New("no nearby candidates") + ErrUntrustedCurrent = errors.New("current media identity is ambiguous") + errPlayQueueUnavailable = errors.New("play queue is unavailable") +) + +// PlaybackContext carries the exact authorized Part and STRM target captured +// when a cloud redirect is ready. Target is transient analysis input: it is +// never logged, persisted, or combined with the Plex management credential. +type PlaybackContext struct { + RatingKey string + PartID string + Target string + // WindowKey identifies one client playback window. It should remain stable + // while the client moves between items and must differ across clients. + WindowKey string + PlayQueueID string + PlayQueueItemID string + UserAgent string +} + +// Candidate is one nearby speculative Plex item and its first usable Part. +type Candidate struct { + RatingKey string + Part plexmeta.Part +} + +// Discovery reads only the configured Plex origin with a management token. +// The token is held by the client and never copied into playback events. +type Discovery struct { + baseURL *url.URL + token string + client *http.Client + maxBytes int64 + userAgent string +} + +// DiscoveryOptions defines the isolated Plex management client. +type DiscoveryOptions struct { + BaseURL *url.URL + Token string + Client *http.Client + MaxBytes int64 + UserAgent string +} + +// NewDiscovery rejects credentials that cannot safely be represented in an +// HTTP header and pins every request to one configured Plex origin. +func NewDiscovery(options DiscoveryOptions) (*Discovery, error) { + if options.BaseURL == nil || (options.BaseURL.Scheme != "http" && options.BaseURL.Scheme != "https") || + options.BaseURL.Host == "" || options.BaseURL.User != nil { + return nil, errors.New("Plex management origin is invalid") + } + token := strings.TrimSpace(options.Token) + if token == "" || strings.ContainsAny(token, "\r\n") { + return nil, errors.New("Plex management token is invalid") + } + defaultTransport := http.DefaultTransport.(*http.Transport).Clone() + defaultTransport.Proxy = nil + transport := http.RoundTripper(defaultTransport) + timeout := 15 * time.Second + if options.Client != nil { + if options.Client.Transport != nil { + transport = options.Client.Transport + } + if options.Client.Timeout > 0 { + timeout = options.Client.Timeout + } + } + client := &http.Client{ + Transport: transport, + Timeout: timeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + maxBytes := options.MaxBytes + if maxBytes <= 0 { + maxBytes = defaultPlexResponseLimit + } + userAgent := strings.TrimSpace(options.UserAgent) + if strings.ContainsAny(userAgent, "\r\n") { + return nil, errors.New("Plex management User-Agent is invalid") + } + baseURL := *options.BaseURL + baseURL.RawQuery = "" + baseURL.Fragment = "" + baseURL.User = nil + return &Discovery{baseURL: &baseURL, token: token, client: client, maxBytes: maxBytes, userAgent: userAgent}, nil +} + +// Validate confirms that the management token can read an authenticated Plex +// library endpoint. Failure disables prewarming without affecting proxying. +func (discovery *Discovery) Validate(ctx context.Context) error { + _, _, err := discovery.get(ctx, "/library/sections", url.Values{ + "X-Plex-Container-Start": {"0"}, + "X-Plex-Container-Size": {"1"}, + }) + return err +} + +// Neighbors verifies that the current metadata contains the active Part, then +// prefers the explicit play-queue order and falls back to the episode hierarchy. +// Returned candidates are ordered with following items first because continuous +// playback is more likely than backward navigation. +func (discovery *Discovery) Neighbors(ctx context.Context, current PlaybackContext, before, after int) ([]Candidate, error) { + if !numericIdentity(current.RatingKey) || !numericIdentity(current.PartID) { + return nil, ErrUntrustedCurrent + } + if before < 0 || after < 0 || before+after == 0 { + return nil, ErrNoCandidates + } + body, contentType, err := discovery.get(ctx, "/library/metadata/"+current.RatingKey, nil) + if err != nil || !partsContainID(body, contentType, current.PartID) { + return nil, ErrUntrustedCurrent + } + currentEpisode, hasEpisode := exactEpisode(body, contentType, current.RatingKey) + if numericIdentity(current.PlayQueueID) && numericIdentity(current.PlayQueueItemID) { + candidates, queueErr := discovery.neighborsFromPlayQueue(ctx, current, before, after) + if queueErr == nil && len(candidates) > 0 { + return candidates, nil + } + } + if !hasEpisode || currentEpisode.ParentRatingKey == "" || currentEpisode.GrandparentRatingKey == "" { + return nil, ErrNoCandidates + } + return discovery.neighborsFromLibrary(ctx, currentEpisode, before, after) +} + +func (discovery *Discovery) neighborsFromPlayQueue(ctx context.Context, playback PlaybackContext, before, after int) ([]Candidate, error) { + body, contentType, err := discovery.get(ctx, "/playQueues/"+playback.PlayQueueID, nil) + if err != nil { + return nil, err + } + items, err := plexmeta.ParsePlayQueue(body, contentType) + if err != nil { + return nil, err + } + position := -1 + for index, item := range items { + if item.PlayQueueItemID != playback.PlayQueueItemID { + continue + } + if position >= 0 || item.RatingKey != playback.RatingKey { + return nil, errPlayQueueUnavailable + } + position = index + } + if position < 0 { + return nil, errPlayQueueUnavailable + } + ratingKeys := make([]string, 0, before+after) + for index := position + 1; index < len(items) && len(ratingKeys) < after; index++ { + if numericIdentity(items[index].RatingKey) { + ratingKeys = append(ratingKeys, items[index].RatingKey) + } + } + previous := 0 + for index := position - 1; index >= 0 && previous < before; index-- { + if numericIdentity(items[index].RatingKey) { + ratingKeys = append(ratingKeys, items[index].RatingKey) + previous++ + } + } + return discovery.confirmCandidates(ctx, ratingKeys) +} + +func (discovery *Discovery) neighborsFromLibrary(ctx context.Context, current plexmeta.Episode, before, after int) ([]Candidate, error) { + body, contentType, err := discovery.get(ctx, "/library/metadata/"+current.ParentRatingKey+"/children", nil) + if err != nil { + return nil, err + } + episodes, err := plexmeta.ParseEpisodes(body, contentType) + if err != nil { + return nil, err + } + previousRefs, nextRefs, positioned := splitEpisodeNeighbors(episodes, current) + if !positioned { + return nil, ErrNoCandidates + } + previousRefs = takeFirst(previousRefs, before) + nextRefs = takeFirst(nextRefs, after) + if len(previousRefs) < before || len(nextRefs) < after { + body, contentType, err = discovery.get(ctx, "/library/metadata/"+current.GrandparentRatingKey+"/children", nil) + if err == nil { + seasons, parseErr := plexmeta.ParseSeasons(body, contentType) + if parseErr == nil { + previousRefs, nextRefs = discovery.extendAcrossSeasons( + ctx, seasons, current, previousRefs, nextRefs, before, after, + ) + } + } + } + ratingKeys := make([]string, 0, len(previousRefs)+len(nextRefs)) + ratingKeys = append(ratingKeys, nextRefs...) + ratingKeys = append(ratingKeys, previousRefs...) + return discovery.confirmCandidates(ctx, ratingKeys) +} + +func (discovery *Discovery) extendAcrossSeasons( + ctx context.Context, + seasons []plexmeta.Season, + current plexmeta.Episode, + previousRefs, nextRefs []string, + before, after int, +) ([]string, []string) { + position := seasonPosition(seasons, current.ParentRatingKey, current.SeasonIndex) + if position < 0 { + return previousRefs, nextRefs + } + for index := position + 1; index < len(seasons) && len(nextRefs) < after; index++ { + if ctx.Err() != nil { + break + } + episodes, err := discovery.readSeasonEpisodes(ctx, seasons[index].RatingKey) + if err != nil { + continue + } + for _, episode := range episodes { + if usableEpisodeReference(episode) { + nextRefs = append(nextRefs, episode.RatingKey) + if len(nextRefs) == after { + break + } + } + } + } + for index := position - 1; index >= 0 && len(previousRefs) < before; index-- { + if ctx.Err() != nil { + break + } + episodes, err := discovery.readSeasonEpisodes(ctx, seasons[index].RatingKey) + if err != nil { + continue + } + for episodeIndex := len(episodes) - 1; episodeIndex >= 0; episodeIndex-- { + if usableEpisodeReference(episodes[episodeIndex]) { + previousRefs = append(previousRefs, episodes[episodeIndex].RatingKey) + if len(previousRefs) == before { + break + } + } + } + } + return previousRefs, nextRefs +} + +func (discovery *Discovery) readSeasonEpisodes(ctx context.Context, ratingKey string) ([]plexmeta.Episode, error) { + if !numericIdentity(ratingKey) { + return nil, ErrNoCandidates + } + body, contentType, err := discovery.get(ctx, "/library/metadata/"+ratingKey+"/children", nil) + if err != nil { + return nil, err + } + return plexmeta.ParseEpisodes(body, contentType) +} + +func (discovery *Discovery) confirmCandidates(ctx context.Context, ratingKeys []string) ([]Candidate, error) { + candidates := make([]Candidate, 0, len(ratingKeys)) + seen := make(map[string]struct{}, len(ratingKeys)) + for _, ratingKey := range ratingKeys { + if ctx.Err() != nil { + break + } + if _, exists := seen[ratingKey]; exists || !numericIdentity(ratingKey) { + continue + } + seen[ratingKey] = struct{}{} + candidate, err := discovery.confirmCandidate(ctx, ratingKey) + if err == nil { + candidates = append(candidates, candidate) + } + } + if len(candidates) == 0 { + return nil, ErrNoCandidates + } + return candidates, nil +} + +func (discovery *Discovery) confirmCandidate(ctx context.Context, ratingKey string) (Candidate, error) { + body, contentType, err := discovery.get(ctx, "/library/metadata/"+ratingKey, nil) + if err != nil { + return Candidate{}, ErrNoCandidates + } + parts, err := plexmeta.ParseParts(body, contentType) + if err != nil { + return Candidate{}, ErrNoCandidates + } + for _, part := range parts { + if part.ID != "" && part.File != "" { + return Candidate{RatingKey: ratingKey, Part: part}, nil + } + } + return Candidate{}, ErrNoCandidates +} + +func exactEpisode(body []byte, contentType, ratingKey string) (plexmeta.Episode, bool) { + episodes, err := plexmeta.ParseEpisodes(body, contentType) + if err != nil || len(episodes) != 1 || episodes[0].RatingKey != ratingKey { + return plexmeta.Episode{}, false + } + return episodes[0], true +} + +func (discovery *Discovery) get(ctx context.Context, endpointPath string, query url.Values) ([]byte, string, error) { + if discovery == nil || discovery.baseURL == nil || discovery.client == nil { + return nil, "", errors.New("Plex management client is unavailable") + } + target := *discovery.baseURL + target.Path = strings.TrimRight(target.Path, "/") + endpointPath + target.RawPath = "" + target.RawQuery = query.Encode() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) + if err != nil { + return nil, "", errors.New("create Plex management request") + } + request.Header.Set("X-Plex-Token", discovery.token) + request.Header.Set("Accept", "application/json") + request.Header.Set("Accept-Encoding", "identity") + if discovery.userAgent != "" { + request.Header.Set("User-Agent", discovery.userAgent) + } + request.Host = discovery.baseURL.Host + response, err := discovery.client.Do(request) + if err != nil { + return nil, "", errors.New("Plex management request failed") + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return nil, "", fmt.Errorf("Plex management request returned HTTP %d", response.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(response.Body, discovery.maxBytes+1)) + if err != nil || int64(len(body)) > discovery.maxBytes { + return nil, "", errors.New("Plex management response exceeds the limit") + } + return body, response.Header.Get("Content-Type"), nil +} + +func splitEpisodeNeighbors(episodes []plexmeta.Episode, current plexmeta.Episode) ([]string, []string, bool) { + currentPosition := -1 + for index, episode := range episodes { + if episode.RatingKey == current.RatingKey { + currentPosition = index + break + } + } + if currentPosition < 0 && current.EpisodeIndex >= 0 { + for index, episode := range episodes { + if episode.EpisodeIndex >= current.EpisodeIndex { + currentPosition = index + break + } + } + } + if currentPosition < 0 { + return nil, nil, false + } + previous := make([]string, 0, currentPosition) + for index := currentPosition - 1; index >= 0; index-- { + if usableEpisodeReference(episodes[index]) { + previous = append(previous, episodes[index].RatingKey) + } + } + next := make([]string, 0, len(episodes)-currentPosition-1) + start := currentPosition + 1 + if episodes[currentPosition].RatingKey != current.RatingKey { + start = currentPosition + } + for index := start; index < len(episodes); index++ { + if usableEpisodeReference(episodes[index]) { + next = append(next, episodes[index].RatingKey) + } + } + return previous, next, true +} + +func seasonPosition(seasons []plexmeta.Season, ratingKey string, index int) int { + for position, season := range seasons { + if season.RatingKey == ratingKey { + return position + } + } + if index >= 0 { + for position, season := range seasons { + if season.Index == index { + return position + } + } + } + return -1 +} + +func takeFirst(values []string, limit int) []string { + if limit <= 0 { + return nil + } + if len(values) <= limit { + return values + } + return values[:limit] +} + +func usableEpisodeReference(episode plexmeta.Episode) bool { + return numericIdentity(episode.RatingKey) +} + +func partsContainID(body []byte, contentType, partID string) bool { + parts, err := plexmeta.ParseParts(body, contentType) + if err != nil { + return false + } + for _, part := range parts { + if part.ID == partID { + return true + } + } + return false +} + +func numericIdentity(value string) bool { + if value == "" { + return false + } + parsed, err := strconv.ParseUint(value, 10, 64) + return err == nil && parsed > 0 +} diff --git a/internal/prewarm/discovery_test.go b/internal/prewarm/discovery_test.go new file mode 100644 index 0000000..ae66ea5 --- /dev/null +++ b/internal/prewarm/discovery_test.go @@ -0,0 +1,230 @@ +package prewarm + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strconv" + "testing" + "time" +) + +func TestDiscoveryUsesBidirectionalPlayQueueAndKeepsTokenAtPlex(t *testing.T) { + var paths []string + plex := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Plex-Token"); got != "management-secret" { + t.Fatalf("token = %q", got) + } + if r.URL.Query().Get("X-Plex-Token") != "" { + t.Fatal("token leaked into query") + } + paths = append(paths, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/library/metadata/42": + _, _ = io.WriteString(w, episodeJSON("42", "7", "3", 1, 2, "9", "/cloud/e02.strm")) + case "/playQueues/5": + _, _ = io.WriteString(w, `{"MediaContainer":{"Metadata":[`+ + `{"type":"movie","ratingKey":"40","playQueueItemID":"98"},`+ + `{"type":"episode","ratingKey":"42","playQueueItemID":"99"},`+ + `{"type":"movie","ratingKey":"70","playQueueItemID":"100"},`+ + `{"type":"episode","ratingKey":"44","playQueueItemID":"101"}]}}`) + case "/library/metadata/40": + _, _ = io.WriteString(w, itemJSON("8", "/cloud/previous.strm")) + case "/library/metadata/70": + _, _ = io.WriteString(w, itemJSON("11", "/cloud/movie.strm")) + case "/library/metadata/44": + _, _ = io.WriteString(w, itemJSON("12", "/cloud/e04.strm")) + default: + http.NotFound(w, r) + } + })) + defer plex.Close() + + discovery := newTestDiscovery(t, plex.URL) + candidates, err := discovery.Neighbors(t.Context(), PlaybackContext{ + RatingKey: "42", PartID: "9", PlayQueueID: "5", PlayQueueItemID: "99", + }, 2, 3) + if err != nil { + t.Fatal(err) + } + if got := candidateIDs(candidates); !reflect.DeepEqual(got, []string{"11", "12", "8"}) { + t.Fatalf("candidate parts = %v", got) + } + wantPaths := []string{ + "/library/metadata/42", "/playQueues/5", "/library/metadata/70", + "/library/metadata/44", "/library/metadata/40", + } + if !reflect.DeepEqual(paths, wantPaths) { + t.Fatalf("paths = %v, want %v", paths, wantPaths) + } +} + +func TestDiscoveryBuildsLibraryWindowAcrossSpecialAndRegularSeasons(t *testing.T) { + var paths []string + plex := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/library/metadata/42": + _, _ = io.WriteString(w, episodeJSON("42", "7", "3", 1, 2, "9", "/cloud/e02.strm")) + case "/library/metadata/7/children": + _, _ = io.WriteString(w, episodeListJSON( + episodeRefJSON("41", "7", "3", 1, 1), + episodeRefJSON("42", "7", "3", 1, 2), + episodeRefJSON("43", "7", "3", 1, 3), + )) + case "/library/metadata/3/children": + _, _ = io.WriteString(w, `{"MediaContainer":{"Metadata":[`+ + `{"type":"season","ratingKey":"6","index":0},`+ + `{"type":"season","ratingKey":"7","index":1},`+ + `{"type":"season","ratingKey":"8","index":2}]}}`) + case "/library/metadata/6/children": + _, _ = io.WriteString(w, episodeListJSON(episodeRefJSON("5", "6", "3", 0, 1))) + case "/library/metadata/8/children": + _, _ = io.WriteString(w, episodeListJSON( + episodeRefJSON("50", "8", "3", 2, 1), + episodeRefJSON("51", "8", "3", 2, 2), + )) + case "/library/metadata/5": + _, _ = io.WriteString(w, itemJSON("5", "/cloud/special.strm")) + case "/library/metadata/41": + _, _ = io.WriteString(w, itemJSON("8", "/cloud/e01.strm")) + case "/library/metadata/43": + _, _ = io.WriteString(w, itemJSON("10", "/cloud/e03.strm")) + case "/library/metadata/50": + _, _ = io.WriteString(w, itemJSON("12", "/cloud/s02e01.strm")) + case "/library/metadata/51": + _, _ = io.WriteString(w, itemJSON("13", "/cloud/s02e02.strm")) + default: + http.NotFound(w, r) + } + })) + defer plex.Close() + + discovery := newTestDiscovery(t, plex.URL) + candidates, err := discovery.Neighbors(t.Context(), PlaybackContext{RatingKey: "42", PartID: "9"}, 2, 3) + if err != nil { + t.Fatal(err) + } + if got := candidateIDs(candidates); !reflect.DeepEqual(got, []string{"10", "12", "13", "8", "5"}) { + t.Fatalf("candidate parts = %v", got) + } + if len(paths) != 10 { + t.Fatalf("paths = %v", paths) + } +} + +func TestDiscoveryAcceptsActivePartAmongMultipleAndChoosesFirstCandidatePart(t *testing.T) { + plex := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/library/metadata/42": + _, _ = io.WriteString(w, `{"MediaContainer":{"Metadata":[{"type":"episode","ratingKey":"42","parentRatingKey":"7","grandparentRatingKey":"3","parentIndex":1,"index":2,"Media":[{"Part":[{"id":"8","file":"/a.strm"},{"id":"9","file":"/b.strm"}]}]}]}}`) + case "/playQueues/5": + _, _ = io.WriteString(w, `{"MediaContainer":{"Metadata":[{"ratingKey":"42","playQueueItemID":"99"},{"ratingKey":"44","playQueueItemID":"100"}]}}`) + case "/library/metadata/44": + _, _ = io.WriteString(w, `{"MediaContainer":{"Metadata":[{"ratingKey":"44","Media":[{"Part":[{"id":"11","file":"/first.strm"},{"id":"12","file":"/second.strm"}]}]}]}}`) + default: + http.NotFound(w, r) + } + })) + defer plex.Close() + discovery := newTestDiscovery(t, plex.URL) + candidates, err := discovery.Neighbors(t.Context(), PlaybackContext{ + RatingKey: "42", PartID: "9", PlayQueueID: "5", PlayQueueItemID: "99", + }, 0, 1) + if err != nil { + t.Fatal(err) + } + if len(candidates) != 1 || candidates[0].Part.ID != "11" { + t.Fatalf("candidates = %#v", candidates) + } +} + +func TestDiscoveryRejectsCurrentMetadataWithoutActivePart(t *testing.T) { + plex := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, itemJSON("8", "/cloud/other.strm")) + })) + defer plex.Close() + discovery := newTestDiscovery(t, plex.URL) + _, err := discovery.Neighbors(t.Context(), PlaybackContext{RatingKey: "42", PartID: "9"}, 2, 3) + if err != ErrUntrustedCurrent { + t.Fatalf("error = %v", err) + } +} + +func TestDiscoveryDoesNotFollowRedirectWithManagementToken(t *testing.T) { + received := make(chan http.Header, 1) + target := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + received <- r.Header.Clone() + })) + defer target.Close() + plex := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", target.URL) + w.WriteHeader(http.StatusFound) + })) + defer plex.Close() + discovery := newTestDiscovery(t, plex.URL) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + if err := discovery.Validate(ctx); err == nil { + t.Fatal("Validate() accepted redirect") + } + select { + case header := <-received: + t.Fatalf("redirect target received headers %#v", header) + default: + } +} + +func newTestDiscovery(t *testing.T, rawURL string) *Discovery { + t.Helper() + baseURL, err := url.Parse(rawURL) + if err != nil { + t.Fatal(err) + } + discovery, err := NewDiscovery(DiscoveryOptions{ + BaseURL: baseURL, Token: "management-secret", Client: &http.Client{Timeout: time.Second}, + }) + if err != nil { + t.Fatal(err) + } + return discovery +} + +func candidateIDs(candidates []Candidate) []string { + result := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + result = append(result, candidate.Part.ID) + } + return result +} + +func itemJSON(partID, file string) string { + return `{"MediaContainer":{"Metadata":[{"Media":[{"Part":[{"id":"` + partID + `","file":"` + file + `"}]}]}]}}` +} + +func episodeListJSON(items ...string) string { + result := `{"MediaContainer":{"Metadata":[` + for index, item := range items { + if index > 0 { + result += "," + } + result += item + } + return result + `]}}` +} + +func episodeRefJSON(ratingKey, parentRatingKey, grandparentRatingKey string, season, episode int) string { + return `{"type":"episode","ratingKey":"` + ratingKey + `","parentRatingKey":"` + parentRatingKey + `","grandparentRatingKey":"` + grandparentRatingKey + `","parentIndex":` + strconv.Itoa(season) + `,"index":` + strconv.Itoa(episode) + `}` +} + +func episodeJSON(ratingKey, parentRatingKey, grandparentRatingKey string, season, episode int, partID, file string) string { + return `{"MediaContainer":{"Metadata":[{"type":"episode","ratingKey":"` + ratingKey + `","parentRatingKey":"` + parentRatingKey + `","grandparentRatingKey":"` + grandparentRatingKey + `","parentIndex":` + + strconv.Itoa(season) + `,"index":` + strconv.Itoa(episode) + `,"Media":[{"Part":[{"id":"` + partID + `","key":"/library/parts/` + partID + `/file","file":"` + file + `"}]}]}]}}` +} diff --git a/internal/prewarm/service.go b/internal/prewarm/service.go new file mode 100644 index 0000000..6d4507b --- /dev/null +++ b/internal/prewarm/service.go @@ -0,0 +1,569 @@ +package prewarm + +import ( + "context" + "crypto/sha256" + "errors" + "log/slog" + "strconv" + "strings" + "sync" + "time" + + "github.com/InfinityPacer/plex-gateway/internal/mediainfo" + "github.com/InfinityPacer/plex-gateway/internal/playback" + "github.com/InfinityPacer/plex-gateway/internal/plexmeta" +) + +const ( + defaultDiscoveryTimeout = 15 * time.Second + defaultTriggerCooldown = 5 * time.Minute + defaultRecentLimit = 4096 + defaultCurrentQueueSize = 64 + defaultWindowGroupLimit = 8 +) + +type neighborDiscovery interface { + Neighbors(context.Context, PlaybackContext, int, int) ([]Candidate, error) +} + +type partPreparer interface { + Prepare(plexmeta.Part) playback.Preparation +} + +type mediaInfoSubmitter interface { + Offer(mediainfo.Request) mediainfo.SubmitResult +} + +// Metrics receives fixed-cardinality prewarm lifecycle events. +type Metrics interface { + IncMediaInfoPrewarmTriggered() + IncMediaInfoPrewarmReplaced() + IncMediaInfoPrewarmDiscoverySuccess() + IncMediaInfoPrewarmDiscoveryFailure() + IncMediaInfoPrewarmFreshCache() + IncMediaInfoPrewarmJoinedFlight() + IncMediaInfoPrewarmQueued() + IncMediaInfoPrewarmRejected() + IncMediaInfoPrewarmSkipped() +} + +// ServiceOptions binds the discovery, STRM preparation, and MediaInfo queues. +// TryEnqueue only changes bounded in-memory state; all collaborators run on a +// worker after the redirect response has been written. +type ServiceOptions struct { + Discovery neighborDiscovery + Playback partPreparer + MediaInfo mediaInfoSubmitter + Logger *slog.Logger + Metrics Metrics + DiscoveryTimeout time.Duration + TriggerCooldown time.Duration + BeforeCount int + AfterCount int + CurrentQueueSize int + Now func() time.Time +} + +type currentJob struct { + playback PlaybackContext + key string +} + +// windowState is the latest-wins queue for one client/session window. The +// state is protected by Service.mu; its worker is counted in Service.workers. +type windowState struct { + key string + pending *PlaybackContext + pendingKey string + activeKey string + activeCancel context.CancelFunc + wake chan struct{} +} + +// Service schedules immediate current-item analysis independently from +// speculative nearby-item discovery. Current events are FIFO and durable only +// in memory; each nearby window is latest-wins within its own bounded group. +type Service struct { + discovery neighborDiscovery + playback partPreparer + mediaInfo mediaInfoSubmitter + logger *slog.Logger + metrics Metrics + discoveryTimeout time.Duration + cooldown time.Duration + beforeCount int + afterCount int + now func() time.Time + + ctx context.Context + cancel context.CancelFunc + done chan struct{} + workers sync.WaitGroup + + currentQueue chan currentJob + + mu sync.Mutex + currentQueued int + currentActive int + currentRecent map[string]time.Time + windowRecent map[string]time.Time + windows map[string]*windowState + windowSeq uint64 + closed bool +} + +// NewService starts the bounded current-item worker. Nearby workers are +// created only for admitted session windows and never exceed the fixed group +// limit. +func NewService(options ServiceOptions) (*Service, error) { + if options.Playback == nil || options.MediaInfo == nil { + return nil, errors.New("prewarm dependencies are unavailable") + } + logger := options.Logger + if logger == nil { + logger = slog.Default() + } + discoveryTimeout := options.DiscoveryTimeout + if discoveryTimeout <= 0 { + discoveryTimeout = defaultDiscoveryTimeout + } + cooldown := options.TriggerCooldown + if cooldown <= 0 { + cooldown = defaultTriggerCooldown + } + beforeCount := options.BeforeCount + if beforeCount < 0 { + return nil, errors.New("prewarm before count is invalid") + } + afterCount := options.AfterCount + if afterCount < 0 { + return nil, errors.New("prewarm after count is invalid") + } + queueSize := options.CurrentQueueSize + if queueSize < 0 { + return nil, errors.New("prewarm current queue size is invalid") + } + if queueSize == 0 { + queueSize = defaultCurrentQueueSize + } + now := options.Now + if now == nil { + now = time.Now + } + ctx, cancel := context.WithCancel(context.Background()) + service := &Service{ + discovery: options.Discovery, playback: options.Playback, mediaInfo: options.MediaInfo, + logger: logger, metrics: options.Metrics, discoveryTimeout: discoveryTimeout, + cooldown: cooldown, beforeCount: beforeCount, afterCount: afterCount, + now: now, ctx: ctx, cancel: cancel, + done: make(chan struct{}), currentQueue: make(chan currentJob, queueSize), + currentRecent: make(map[string]time.Time), windowRecent: make(map[string]time.Time), + windows: make(map[string]*windowState), + } + service.workers.Add(1) + go service.currentWorker() + go func() { + service.workers.Wait() + close(service.done) + }() + return service, nil +} + +// TryEnqueue performs validation and bounded memory operations only. It never +// contacts Plex, reads a STRM file, invokes MediaInfo, or opens SQLite/CDN. +// The current item is admitted independently of RatingKey and of nearby +// discovery, so an incomplete Plex metadata response cannot lose playback. +func (service *Service) TryEnqueue(playbackContext PlaybackContext) bool { + if service == nil || !validPlaybackContext(playbackContext) { + return false + } + now := service.now() + currentKey := currentContextKey(playbackContext) + var startWindow *windowState + currentAccepted := false + windowAccepted := false + replaced := false + skipped := false + + service.mu.Lock() + if service.closed { + service.mu.Unlock() + return false + } + service.expireRecentLocked(service.currentRecent, now) + service.expireRecentLocked(service.windowRecent, now) + + if until, exists := service.currentRecent[currentKey]; exists && now.Before(until) { + skipped = true + } else { + job := currentJob{playback: playbackContext, key: currentKey} + select { + case service.currentQueue <- job: + service.currentQueued++ + service.rememberRecentLocked(service.currentRecent, currentKey, now) + currentAccepted = true + default: + // The queue is deliberately non-blocking so a redirect cannot wait on + // a slow MediaInfo backend. The current request remains fail-open. + skipped = true + } + } + + if service.discovery != nil && numericIdentity(playbackContext.RatingKey) && service.beforeCount+service.afterCount > 0 { + groupKey := service.windowGroupKeyLocked(playbackContext) + eventKey := groupKey + "\x00" + playbackContextKey(playbackContext) + if until, exists := service.windowRecent[eventKey]; exists && now.Before(until) { + skipped = true + } else if window := service.windows[groupKey]; window != nil { + if window.activeKey == eventKey || window.pendingKey == eventKey { + skipped = true + } else { + if window.pending != nil || window.activeCancel != nil { + replaced = true + } + copyOfContext := playbackContext + window.pending = ©OfContext + window.pendingKey = eventKey + service.rememberRecentLocked(service.windowRecent, eventKey, now) + if window.activeCancel != nil { + window.activeCancel() + } + select { + case window.wake <- struct{}{}: + default: + } + windowAccepted = true + } + } else if len(service.windows) >= defaultWindowGroupLimit { + skipped = true + } else { + copyOfContext := playbackContext + window := &windowState{ + key: groupKey, pending: ©OfContext, pendingKey: eventKey, + wake: make(chan struct{}, 1), + } + service.windows[groupKey] = window + service.rememberRecentLocked(service.windowRecent, eventKey, now) + service.workers.Add(1) + startWindow = window + windowAccepted = true + } + } + service.mu.Unlock() + + if startWindow != nil { + go service.windowWorker(startWindow) + } + if currentAccepted || windowAccepted { + service.metric(func(metrics Metrics) { metrics.IncMediaInfoPrewarmTriggered() }) + } + if replaced { + service.metric(func(metrics Metrics) { metrics.IncMediaInfoPrewarmReplaced() }) + } + if skipped { + service.metric(func(metrics Metrics) { metrics.IncMediaInfoPrewarmSkipped() }) + } + return currentAccepted || windowAccepted +} + +func (service *Service) currentWorker() { + defer service.workers.Done() + for { + select { + case <-service.ctx.Done(): + return + case job := <-service.currentQueue: + service.mu.Lock() + if service.currentQueued > 0 { + service.currentQueued-- + } + if service.closed { + service.mu.Unlock() + return + } + service.currentActive++ + service.mu.Unlock() + + submitted := service.submit(service.ctx, Candidate{ + RatingKey: job.playback.RatingKey, + Part: plexmeta.Part{ID: job.playback.PartID}, + }, job.playback.Target, job.playback.UserAgent, mediainfo.PriorityPlayback, "current") + + service.mu.Lock() + if !submitted { + delete(service.currentRecent, job.key) + } + if service.currentActive > 0 { + service.currentActive-- + } + service.mu.Unlock() + } + } +} + +func (service *Service) windowWorker(window *windowState) { + defer service.workers.Done() + for { + service.mu.Lock() + if service.closed || service.windows[window.key] != window { + service.mu.Unlock() + return + } + if window.pending != nil { + current := *window.pending + eventKey := window.pendingKey + window.pending = nil + window.pendingKey = "" + jobContext, cancel := context.WithCancel(service.ctx) + window.activeKey = eventKey + window.activeCancel = cancel + service.mu.Unlock() + + service.runWindow(jobContext, current) + cancel() + + service.mu.Lock() + if window.activeKey == eventKey { + window.activeKey = "" + window.activeCancel = nil + } + closed := service.closed + service.mu.Unlock() + if closed { + return + } + continue + } + if service.windows[window.key] == window && window.pending == nil && window.activeCancel == nil { + delete(service.windows, window.key) + } + service.mu.Unlock() + return + } +} + +func (service *Service) runWindow(ctx context.Context, current PlaybackContext) { + if service.beforeCount+service.afterCount == 0 || ctx.Err() != nil { + return + } + discoveryContext, cancel := context.WithTimeout(ctx, service.discoveryTimeout) + candidates, err := service.discovery.Neighbors( + discoveryContext, current, service.beforeCount, service.afterCount, + ) + cancel() + if err != nil { + if ctx.Err() == nil { + service.metric(func(metrics Metrics) { metrics.IncMediaInfoPrewarmDiscoveryFailure() }) + service.logger.Info("mediainfo_prewarm_skipped", "part", current.PartID, "reason", discoveryErrorKind(err)) + } + service.metric(func(metrics Metrics) { metrics.IncMediaInfoPrewarmSkipped() }) + return + } + service.metric(func(metrics Metrics) { metrics.IncMediaInfoPrewarmDiscoverySuccess() }) + for _, candidate := range candidates { + if ctx.Err() != nil { + return + } + preparation := service.playback.Prepare(candidate.Part) + if ctx.Err() != nil { + return + } + if preparation.State != playback.PreparationReady { + service.metric(func(metrics Metrics) { metrics.IncMediaInfoPrewarmSkipped() }) + continue + } + service.submit( + ctx, candidate, preparation.Part.Target, current.UserAgent, + mediainfo.PriorityNeighbor, "neighbor", + ) + } +} + +func (service *Service) submit( + ctx context.Context, + candidate Candidate, + target, userAgent string, + priority mediainfo.Priority, + kind string, +) bool { + if ctx.Err() != nil { + return false + } + fingerprint, err := mediainfo.FingerprintSTRMTarget(target) + if err != nil { + service.metric(func(metrics Metrics) { metrics.IncMediaInfoPrewarmSkipped() }) + return false + } + result := service.mediaInfo.Offer(mediainfo.Request{ + Key: mediainfo.Key{PartID: candidate.Part.ID, STRMFingerprint: fingerprint}, + RatingKey: candidate.RatingKey, Target: target, + Priority: priority, ClientUserAgent: userAgent, + }) + switch result.Disposition { + case mediainfo.SubmitFreshCache: + service.metric(func(metrics Metrics) { metrics.IncMediaInfoPrewarmFreshCache() }) + case mediainfo.SubmitJoinedExistingFlight: + service.metric(func(metrics Metrics) { metrics.IncMediaInfoPrewarmJoinedFlight() }) + case mediainfo.SubmitNewlyQueued: + service.metric(func(metrics Metrics) { metrics.IncMediaInfoPrewarmQueued() }) + service.logger.Info("mediainfo_prewarm_queued", "part", candidate.Part.ID, "kind", kind) + case mediainfo.SubmitRejected: + service.metric(func(metrics Metrics) { metrics.IncMediaInfoPrewarmRejected() }) + return false + default: + service.metric(func(metrics Metrics) { metrics.IncMediaInfoPrewarmRejected() }) + return false + } + return true +} + +// Status is a credential-free coordinator snapshot. +type Status struct { + Available bool `json:"available"` + NeighborAvailable bool `json:"neighbor_available"` + Active bool `json:"active"` + Queued int `json:"queued"` + Before int `json:"before"` + After int `json:"after"` +} + +// Status reports bounded current and window queue state without media +// identity. A window remains counted only while its worker is alive. +func (service *Service) Status() Status { + if service == nil { + return Status{} + } + service.mu.Lock() + defer service.mu.Unlock() + queued := service.currentQueued + active := service.currentActive > 0 + for _, window := range service.windows { + if window.pending != nil { + queued++ + } + if window.activeCancel != nil { + active = true + } + } + return Status{ + Available: !service.closed, NeighborAvailable: !service.closed && service.discovery != nil, + Active: active, Queued: queued, + Before: service.beforeCount, After: service.afterCount, + } +} + +// Close cancels current and nearby workers and waits for every worker that was +// admitted before the close. The MediaInfo service owns already submitted +// jobs; this coordinator never closes that collaborator. +func (service *Service) Close(ctx context.Context) error { + if service == nil { + return nil + } + service.mu.Lock() + if !service.closed { + service.closed = true + for _, window := range service.windows { + window.pending = nil + window.pendingKey = "" + if window.activeCancel != nil { + window.activeCancel() + } + } + service.cancel() + } + service.mu.Unlock() + if ctx == nil { + ctx = context.Background() + } + select { + case <-service.done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (service *Service) expireRecentLocked(recent map[string]time.Time, now time.Time) { + for key, until := range recent { + if !now.Before(until) { + delete(recent, key) + } + } +} + +func (service *Service) rememberRecentLocked(recent map[string]time.Time, key string, now time.Time) { + service.expireRecentLocked(recent, now) + if len(recent) >= defaultRecentLimit { + var earliestKey string + var earliest time.Time + for candidate, until := range recent { + if earliestKey == "" || until.Before(earliest) { + earliestKey, earliest = candidate, until + } + } + delete(recent, earliestKey) + } + recent[key] = now.Add(service.cooldown) +} + +func (service *Service) metric(call func(Metrics)) { + if service != nil && service.metrics != nil { + call(service.metrics) + } +} + +// windowGroupKeyLocked chooses an explicit playback window first. A PlayQueue +// ID is a useful fallback for clients that expose it. Without either value we +// allocate a private anonymous group instead of allowing unrelated clients to +// cancel one another; callers that want latest-wins across episode changes +// should set WindowKey to a stable client playback-session identity. +func (service *Service) windowGroupKeyLocked(playbackContext PlaybackContext) string { + if windowKey := strings.TrimSpace(playbackContext.WindowKey); windowKey != "" { + digest := sha256.Sum256([]byte("window\x00" + windowKey)) + return string(digest[:]) + } + if playQueueID := strings.TrimSpace(playbackContext.PlayQueueID); playQueueID != "" { + digest := sha256.Sum256([]byte("queue\x00" + playQueueID)) + return string(digest[:]) + } + service.windowSeq++ + return "anonymous\x00" + strconv.FormatUint(service.windowSeq, 10) +} + +func validPlaybackContext(playbackContext PlaybackContext) bool { + return numericIdentity(playbackContext.PartID) && + (playbackContext.RatingKey == "" || numericIdentity(playbackContext.RatingKey)) && + strings.TrimSpace(playbackContext.Target) != "" && !strings.ContainsAny(playbackContext.Target, "\r\n") && + !strings.ContainsAny(playbackContext.UserAgent, "\r\n") && + !strings.ContainsAny(playbackContext.WindowKey, "\r\n") && + (playbackContext.PlayQueueID == "" || numericIdentity(playbackContext.PlayQueueID)) && + (playbackContext.PlayQueueItemID == "" || numericIdentity(playbackContext.PlayQueueItemID)) +} + +func currentContextKey(playbackContext PlaybackContext) string { + digest := sha256.Sum256([]byte(playbackContext.PartID + "\x00" + playbackContext.Target + "\x00" + playbackContext.UserAgent)) + return string(digest[:]) +} + +func playbackContextKey(playbackContext PlaybackContext) string { + targetDigest := sha256.Sum256([]byte(playbackContext.Target)) + userAgentDigest := sha256.Sum256([]byte(playbackContext.UserAgent)) + windowDigest := sha256.Sum256([]byte(playbackContext.WindowKey)) + return playbackContext.RatingKey + "\x00" + playbackContext.PartID + "\x00" + + playbackContext.PlayQueueID + "\x00" + playbackContext.PlayQueueItemID + "\x00" + + string(windowDigest[:]) + "\x00" + string(targetDigest[:]) + "\x00" + string(userAgentDigest[:]) +} + +func discoveryErrorKind(err error) string { + switch { + case errors.Is(err, ErrNoCandidates): + return "no_candidates" + case errors.Is(err, ErrUntrustedCurrent): + return "identity" + case errors.Is(err, context.DeadlineExceeded): + return "timeout" + default: + return "plex" + } +} diff --git a/internal/prewarm/service_test.go b/internal/prewarm/service_test.go new file mode 100644 index 0000000..474fa70 --- /dev/null +++ b/internal/prewarm/service_test.go @@ -0,0 +1,510 @@ +package prewarm + +import ( + "context" + "errors" + "io" + "log/slog" + "sync" + "testing" + "time" + + "github.com/InfinityPacer/plex-gateway/internal/mediainfo" + "github.com/InfinityPacer/plex-gateway/internal/playback" + "github.com/InfinityPacer/plex-gateway/internal/plexmeta" +) + +type discoveryFunc func(context.Context, PlaybackContext, int, int) ([]Candidate, error) + +func (function discoveryFunc) Neighbors(ctx context.Context, current PlaybackContext, before, after int) ([]Candidate, error) { + return function(ctx, current, before, after) +} + +type preparerFunc func(plexmeta.Part) playback.Preparation + +func (function preparerFunc) Prepare(part plexmeta.Part) playback.Preparation { return function(part) } + +type submitterFunc func(mediainfo.Request) error + +func (function submitterFunc) Offer(request mediainfo.Request) mediainfo.SubmitResult { + if err := function(request); err != nil { + return mediainfo.SubmitResult{Disposition: mediainfo.SubmitRejected, Err: err} + } + return mediainfo.SubmitResult{Disposition: mediainfo.SubmitNewlyQueued} +} + +type detailedSubmitterFunc func(mediainfo.Request) mediainfo.SubmitResult + +func (function detailedSubmitterFunc) Offer(request mediainfo.Request) mediainfo.SubmitResult { + return function(request) +} + +func TestServiceTryEnqueueDoesNoIOInline(t *testing.T) { + discoveryStarted := make(chan struct{}) + releaseDiscovery := make(chan struct{}) + submitted := make(chan mediainfo.Request, 1) + service := newTestService(t, + discoveryFunc(func(context.Context, PlaybackContext, int, int) ([]Candidate, error) { + close(discoveryStarted) + <-releaseDiscovery + return nil, ErrNoCandidates + }), + preparerFunc(func(plexmeta.Part) playback.Preparation { + t.Fatal("Prepare ran inline") + return playback.Preparation{} + }), + submitterFunc(func(request mediainfo.Request) error { + submitted <- request + return nil + }), + ) + started := time.Now() + if !service.TryEnqueue(testPlayback("42", "9", "current")) { + t.Fatal("TryEnqueue() = false") + } + if elapsed := time.Since(started); elapsed > 50*time.Millisecond { + t.Fatalf("TryEnqueue blocked for %s", elapsed) + } + select { + case request := <-submitted: + if request.Priority != mediainfo.PriorityPlayback || request.Key.PartID != "9" { + t.Fatalf("current request = %#v", request) + } + case <-time.After(time.Second): + t.Fatal("current item was not submitted") + } + select { + case <-discoveryStarted: + case <-time.After(time.Second): + t.Fatal("background discovery did not start") + } + close(releaseDiscovery) +} + +func TestServiceOffersCurrentAndNeighborsWithoutLocalRateLimit(t *testing.T) { + type observed struct { + request mediainfo.Request + at time.Time + } + submitted := make(chan observed, 3) + service := newConfiguredTestService(t, ServiceOptions{ + Discovery: discoveryFunc(func(_ context.Context, _ PlaybackContext, before, after int) ([]Candidate, error) { + if before != 2 || after != 3 { + t.Fatalf("window = %d/%d", before, after) + } + return []Candidate{ + {RatingKey: "43", Part: plexmeta.Part{ID: "10", File: "/cloud/e03.strm"}}, + {RatingKey: "41", Part: plexmeta.Part{ID: "8", File: "/cloud/e01.strm"}}, + }, nil + }), + Playback: preparerFunc(func(part plexmeta.Part) playback.Preparation { + return playback.Preparation{State: playback.PreparationReady, Part: playback.PreparedPart{ + Part: part, Target: "http://mediavault/redirect/" + part.ID, + }} + }), + MediaInfo: submitterFunc(func(request mediainfo.Request) error { + submitted <- observed{request: request, at: time.Now()} + return nil + }), + BeforeCount: 2, AfterCount: 3, + }) + started := time.Now() + service.TryEnqueue(testPlayback("42", "9", "current")) + observedByPart := make(map[string]observed, 3) + for range 3 { + item := <-submitted + observedByPart[item.request.Key.PartID] = item + } + if observedByPart["9"].request.Priority != mediainfo.PriorityPlayback || + observedByPart["10"].request.Priority != mediainfo.PriorityNeighbor || + observedByPart["8"].request.Priority != mediainfo.PriorityNeighbor { + t.Fatalf("submissions = %#v", observedByPart) + } + for partID, item := range observedByPart { + if delay := item.at.Sub(started); delay > 100*time.Millisecond { + t.Fatalf("part %s offer delay = %s", partID, delay) + } + } +} + +func TestServiceLatestPlaybackCancelsOlderNeighborDiscovery(t *testing.T) { + started := make(chan string, 3) + submitted := make(chan mediainfo.Request, 8) + service := newTestService(t, + discoveryFunc(func(ctx context.Context, current PlaybackContext, _, _ int) ([]Candidate, error) { + started <- current.RatingKey + if current.RatingKey != "44" { + <-ctx.Done() + return nil, ctx.Err() + } + return []Candidate{{RatingKey: "45", Part: plexmeta.Part{ID: "12", File: "/cloud/e05.strm"}}}, nil + }), + preparerFunc(func(part plexmeta.Part) playback.Preparation { + return playback.Preparation{State: playback.PreparationReady, Part: playback.PreparedPart{ + Part: part, Target: "http://mediavault/redirect/next", + }} + }), + submitterFunc(func(request mediainfo.Request) error { + submitted <- request + return nil + }), + ) + service.TryEnqueue(testPlayback("42", "9", "a")) + if got := <-started; got != "42" { + t.Fatalf("first discovery = %q", got) + } + service.TryEnqueue(testPlayback("43", "10", "b")) + service.TryEnqueue(testPlayback("44", "11", "c")) + + deadline := time.After(time.Second) + seenCurrent := map[string]bool{} + seenCurrentC := false + seenNeighbor := false + for !seenCurrentC || !seenNeighbor { + select { + case request := <-submitted: + if request.Priority == mediainfo.PriorityPlayback { + seenCurrent[request.Key.PartID] = true + seenCurrentC = request.Key.PartID == "11" || seenCurrentC + } + if request.Key.PartID == "12" && request.RatingKey == "45" && request.ClientUserAgent == "Client/c" { + seenNeighbor = true + } + case <-deadline: + t.Fatalf("currentC=%v neighbor=%v", seenCurrentC, seenNeighbor) + } + } + for _, partID := range []string{"9", "10", "11"} { + if !seenCurrent[partID] { + t.Fatalf("current item %s was not submitted", partID) + } + } +} + +func TestServiceKeepsNeighborWindowsIndependentAcrossClients(t *testing.T) { + started := make(chan string, 2) + release := make(chan struct{}) + service := newTestService(t, + discoveryFunc(func(ctx context.Context, current PlaybackContext, _, _ int) ([]Candidate, error) { + started <- current.WindowKey + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-release: + return nil, ErrNoCandidates + } + }), + preparerFunc(func(plexmeta.Part) playback.Preparation { return playback.Preparation{} }), + submitterFunc(func(mediainfo.Request) error { return nil }), + ) + first := testPlayback("42", "9", "first") + first.WindowKey = "client-a" + second := testPlayback("43", "10", "second") + second.WindowKey = "client-b" + service.TryEnqueue(first) + service.TryEnqueue(second) + seen := map[string]bool{} + for len(seen) < 2 { + select { + case key := <-started: + seen[key] = true + case <-time.After(time.Second): + t.Fatalf("started windows = %#v", seen) + } + } + close(release) +} + +func TestServiceRetiresCompletedNeighborWindow(t *testing.T) { + done := make(chan struct{}, 1) + service := newTestService(t, + discoveryFunc(func(context.Context, PlaybackContext, int, int) ([]Candidate, error) { + done <- struct{}{} + return nil, ErrNoCandidates + }), + preparerFunc(func(plexmeta.Part) playback.Preparation { return playback.Preparation{} }), + submitterFunc(func(mediainfo.Request) error { return nil }), + ) + service.TryEnqueue(testPlayback("42", "9", "retire")) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("discovery did not run") + } + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + service.mu.Lock() + windowCount := len(service.windows) + service.mu.Unlock() + if windowCount == 0 { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("completed neighbor window was not retired") +} + +func TestServiceSubmitsCurrentWithoutRatingKey(t *testing.T) { + submitted := make(chan mediainfo.Request, 1) + service := newTestService(t, + discoveryFunc(func(context.Context, PlaybackContext, int, int) ([]Candidate, error) { + t.Fatal("neighbor discovery must not run without a rating key") + return nil, nil + }), + preparerFunc(func(plexmeta.Part) playback.Preparation { return playback.Preparation{} }), + submitterFunc(func(request mediainfo.Request) error { + submitted <- request + return nil + }), + ) + current := testPlayback("", "9", "no-rating-key") + if !service.TryEnqueue(current) { + t.Fatal("TryEnqueue() = false") + } + select { + case request := <-submitted: + if request.Key.PartID != "9" || request.RatingKey != "" { + t.Fatalf("request = %#v", request) + } + case <-time.After(time.Second): + t.Fatal("current item was not submitted") + } +} + +func TestServiceRecordsDetailedSubmitDisposition(t *testing.T) { + metrics := &recordingMetrics{} + results := []mediainfo.SubmitDisposition{ + mediainfo.SubmitFreshCache, + mediainfo.SubmitJoinedExistingFlight, + mediainfo.SubmitNewlyQueued, + mediainfo.SubmitRejected, + } + index := 0 + service := newConfiguredTestService(t, ServiceOptions{ + Discovery: discoveryFunc(func(context.Context, PlaybackContext, int, int) ([]Candidate, error) { + return nil, ErrNoCandidates + }), + Playback: preparerFunc(func(plexmeta.Part) playback.Preparation { return playback.Preparation{} }), + MediaInfo: detailedSubmitterFunc(func(mediainfo.Request) mediainfo.SubmitResult { + result := mediainfo.SubmitResult{Disposition: results[index]} + index++ + return result + }), + Metrics: metrics, BeforeCount: 0, AfterCount: 0, + }) + for _, partID := range []string{"1", "2", "3", "4"} { + service.TryEnqueue(testPlayback("42", partID, "result-"+partID)) + } + deadline := time.Now().Add(time.Second) + for metrics.totalSubmits() < len(results) && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if metrics.fresh != 1 || metrics.joined != 1 || metrics.queued != 1 || metrics.rejected != 1 { + t.Fatalf("metrics = %#v", metrics) + } +} + +func TestServiceRetriesCurrentAfterAdmissionRejection(t *testing.T) { + requests := make(chan mediainfo.Request, 2) + attempt := 0 + service := newConfiguredTestService(t, ServiceOptions{ + Playback: preparerFunc(func(plexmeta.Part) playback.Preparation { return playback.Preparation{} }), + MediaInfo: detailedSubmitterFunc(func(request mediainfo.Request) mediainfo.SubmitResult { + requests <- request + attempt++ + if attempt == 1 { + return mediainfo.SubmitResult{Disposition: mediainfo.SubmitRejected, Err: mediainfo.ErrQueueFull} + } + return mediainfo.SubmitResult{Disposition: mediainfo.SubmitNewlyQueued} + }), + BeforeCount: 0, AfterCount: 0, + }) + current := testPlayback("42", "9", "retry") + service.TryEnqueue(current) + select { + case <-requests: + case <-time.After(time.Second): + t.Fatal("first submission did not run") + } + deadline := time.Now().Add(time.Second) + for service.Status().Active && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if !service.TryEnqueue(current) { + t.Fatal("second TryEnqueue() = false") + } + select { + case <-requests: + case <-time.After(time.Second): + t.Fatal("second submission did not run") + } +} + +type recordingMetrics struct { + mu sync.Mutex + fresh, joined, queued, rejected, skipped int +} + +func (*recordingMetrics) IncMediaInfoPrewarmTriggered() {} +func (*recordingMetrics) IncMediaInfoPrewarmReplaced() {} +func (*recordingMetrics) IncMediaInfoPrewarmDiscoverySuccess() {} +func (*recordingMetrics) IncMediaInfoPrewarmDiscoveryFailure() {} +func (metrics *recordingMetrics) IncMediaInfoPrewarmFreshCache() { + metrics.mu.Lock() + defer metrics.mu.Unlock() + metrics.fresh++ +} +func (metrics *recordingMetrics) IncMediaInfoPrewarmJoinedFlight() { + metrics.mu.Lock() + defer metrics.mu.Unlock() + metrics.joined++ +} +func (metrics *recordingMetrics) IncMediaInfoPrewarmQueued() { + metrics.mu.Lock() + defer metrics.mu.Unlock() + metrics.queued++ +} +func (metrics *recordingMetrics) IncMediaInfoPrewarmRejected() { + metrics.mu.Lock() + defer metrics.mu.Unlock() + metrics.rejected++ +} +func (metrics *recordingMetrics) IncMediaInfoPrewarmSkipped() { + metrics.mu.Lock() + defer metrics.mu.Unlock() + metrics.skipped++ +} +func (metrics *recordingMetrics) totalSubmits() int { + metrics.mu.Lock() + defer metrics.mu.Unlock() + return metrics.fresh + metrics.joined + metrics.queued + metrics.rejected +} + +func TestServiceDeduplicatesRecentPlayback(t *testing.T) { + var mu sync.Mutex + calls := 0 + done := make(chan struct{}, 1) + service := newTestService(t, + discoveryFunc(func(context.Context, PlaybackContext, int, int) ([]Candidate, error) { + mu.Lock() + calls++ + mu.Unlock() + done <- struct{}{} + return nil, ErrNoCandidates + }), + preparerFunc(func(plexmeta.Part) playback.Preparation { return playback.Preparation{} }), + submitterFunc(func(mediainfo.Request) error { return nil }), + ) + current := testPlayback("42", "9", "current") + service.TryEnqueue(current) + <-done + for service.Status().Active { + time.Sleep(time.Millisecond) + } + service.TryEnqueue(current) + time.Sleep(20 * time.Millisecond) + mu.Lock() + defer mu.Unlock() + if calls != 1 { + t.Fatalf("discovery calls = %d", calls) + } +} + +func TestServiceDoesNotDeduplicateChangedTargetOrUserAgent(t *testing.T) { + called := make(chan PlaybackContext, 3) + service := newTestService(t, + discoveryFunc(func(_ context.Context, current PlaybackContext, _, _ int) ([]Candidate, error) { + called <- current + return nil, ErrNoCandidates + }), + preparerFunc(func(plexmeta.Part) playback.Preparation { return playback.Preparation{} }), + submitterFunc(func(mediainfo.Request) error { return nil }), + ) + base := testPlayback("42", "9", "base") + service.TryEnqueue(base) + <-called + for service.Status().Active { + time.Sleep(time.Millisecond) + } + changedTarget := base + changedTarget.Target += "?revision=2" + service.TryEnqueue(changedTarget) + <-called + for service.Status().Active { + time.Sleep(time.Millisecond) + } + changedAgent := changedTarget + changedAgent.UserAgent = "Client/other" + service.TryEnqueue(changedAgent) + <-called +} + +func TestServiceAllowsCurrentOnlyWindow(t *testing.T) { + submitted := make(chan mediainfo.Request, 1) + service := newConfiguredTestService(t, ServiceOptions{ + Discovery: discoveryFunc(func(context.Context, PlaybackContext, int, int) ([]Candidate, error) { + t.Fatal("discovery must not run for a zero window") + return nil, nil + }), + Playback: preparerFunc(func(plexmeta.Part) playback.Preparation { + t.Fatal("neighbor preparation must not run for a zero window") + return playback.Preparation{} + }), + MediaInfo: submitterFunc(func(request mediainfo.Request) error { + submitted <- request + return nil + }), + BeforeCount: 0, AfterCount: 0, + }) + service.TryEnqueue(testPlayback("42", "9", "current-only")) + select { + case request := <-submitted: + if request.Key.PartID != "9" || request.Priority != mediainfo.PriorityPlayback { + t.Fatalf("request = %#v", request) + } + case <-time.After(time.Second): + t.Fatal("current item was not submitted") + } +} + +func newTestService(t *testing.T, discovery neighborDiscovery, preparer partPreparer, submitter mediaInfoSubmitter) *Service { + t.Helper() + return newConfiguredTestService(t, ServiceOptions{ + Discovery: discovery, Playback: preparer, MediaInfo: submitter, + BeforeCount: 2, AfterCount: 3, + }) +} + +func newConfiguredTestService(t *testing.T, options ServiceOptions) *Service { + t.Helper() + if options.Logger == nil { + options.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + if options.DiscoveryTimeout == 0 { + options.DiscoveryTimeout = time.Second + } + if options.TriggerCooldown == 0 { + options.TriggerCooldown = time.Minute + } + service, err := NewService(options) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := service.Close(ctx); err != nil && !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + }) + return service +} + +func testPlayback(ratingKey, partID, suffix string) PlaybackContext { + return PlaybackContext{ + RatingKey: ratingKey, PartID: partID, + Target: "http://mediavault/redirect/" + suffix, + WindowKey: "test-session", + UserAgent: "Client/" + suffix, + } +} diff --git a/scripts/reset-mediainfo-cache.sh b/scripts/reset-mediainfo-cache.sh new file mode 100755 index 0000000..e483a4f --- /dev/null +++ b/scripts/reset-mediainfo-cache.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +if [ -x /usr/local/bin/plex-gateway ]; then + exec /usr/local/bin/plex-gateway cache-reset "$@" +fi + +service=${PLEX_GATEWAY_COMPOSE_SERVICE:-plex-gateway} +exec docker compose exec -T "$service" /usr/local/bin/plex-gateway-reset-mediainfo-cache "$@"