From 607bb2a990361096a9c2ded92a64f60be168b3bd Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Sat, 29 Aug 2026 01:57:52 +0800 Subject: [PATCH 1/2] ci: restore Go module caching --- .github/workflows/ci.yml | 3 ++- .github/workflows/release.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f464c1..5d5d1f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,8 @@ jobs: uses: actions/setup-go@v6 with: go-version-file: go.mod - cache: false + cache: true + cache-dependency-path: go.sum - name: Verify run: go test -race -count=1 ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9411360..3405de0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,8 @@ jobs: uses: actions/setup-go@v6 with: go-version-file: go.mod - cache: false + cache: true + cache-dependency-path: go.sum - name: Validate release id: release From 050b985fa0748837533c14c80ea550045fffa616 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Sat, 29 Aug 2026 03:12:18 +0800 Subject: [PATCH 2/2] feat: add MediaInfo fallback foundation --- Dockerfile | 11 +- README.en.md | 6 +- README.md | 5 +- cmd/plex-gateway/main.go | 96 +++ cmd/plex-gateway/main_test.go | 98 +++ docker-compose.example.yml | 5 + docs/media-lifecycle-architecture.md | 76 ++- docs/roadmap.md | 7 +- go.mod | 17 + go.sum | 50 ++ internal/config/config.go | 132 +++- internal/config/config_test.go | 32 + internal/gateway/gateway.go | 24 +- internal/gateway/identity.go | 78 +++ internal/gateway/trust_test.go | 34 + internal/mediainfo/benchmark_test.go | 110 ++++ internal/mediainfo/cache.go | 212 ++++++ internal/mediainfo/ffprobe.go | 574 ++++++++++++++++ internal/mediainfo/ffprobe_test.go | 205 ++++++ internal/mediainfo/model.go | 269 ++++++++ internal/mediainfo/model_test.go | 176 +++++ internal/mediainfo/provider.go | 93 +++ internal/mediainfo/provider_test.go | 63 ++ internal/mediainfo/service.go | 733 +++++++++++++++++++++ internal/mediainfo/service_test.go | 492 ++++++++++++++ internal/mediainfo/sqlite.go | 340 ++++++++++ internal/mediainfo/sqlite_test.go | 214 ++++++ internal/mediainfo/touch_lifecycle_test.go | 237 +++++++ internal/metrics/metrics.go | 62 ++ internal/metrics/metrics_test.go | 37 +- 30 files changed, 4434 insertions(+), 54 deletions(-) create mode 100644 cmd/plex-gateway/main_test.go create mode 100644 go.sum create mode 100644 internal/gateway/identity.go create mode 100644 internal/mediainfo/benchmark_test.go create mode 100644 internal/mediainfo/cache.go create mode 100644 internal/mediainfo/ffprobe.go create mode 100644 internal/mediainfo/ffprobe_test.go create mode 100644 internal/mediainfo/model.go create mode 100644 internal/mediainfo/model_test.go create mode 100644 internal/mediainfo/provider.go create mode 100644 internal/mediainfo/provider_test.go create mode 100644 internal/mediainfo/service.go create mode 100644 internal/mediainfo/service_test.go create mode 100644 internal/mediainfo/sqlite.go create mode 100644 internal/mediainfo/sqlite_test.go create mode 100644 internal/mediainfo/touch_lifecycle_test.go diff --git a/Dockerfile b/Dockerfile index 67d3e19..f18b548 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,8 @@ FROM golang:1.25-alpine AS build WORKDIR /src -COPY go.mod ./ +COPY go.mod go.sum ./ +RUN go mod download COPY VERSION ./ COPY cmd/plex-gateway ./cmd/plex-gateway COPY internal ./internal @@ -12,11 +13,15 @@ RUN release_version="$(tr -d '[:space:]' < VERSION)" \ -o /plex-gateway ./cmd/plex-gateway FROM alpine:3.22 -RUN apk add --no-cache ca-certificates \ - && adduser -D -H -s /sbin/nologin gateway +RUN apk add --no-cache ca-certificates ffmpeg \ + && addgroup -g 1000 gateway \ + && 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 +WORKDIR /app_data USER gateway EXPOSE 32400 ENV LISTEN_ADDR=:32400 +ENV MEDIAINFO_DB_PATH=/app_data/mediainfo.db HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["/usr/local/bin/plex-gateway", "healthcheck"] ENTRYPOINT ["/usr/local/bin/plex-gateway"] diff --git a/README.en.md b/README.en.md index 45f3818..a730a05 100644 --- a/README.en.md +++ b/README.en.md @@ -113,6 +113,9 @@ Important environment variables: | `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. | | `METADATA_GUARD_QUEUE_TIMEOUT` | `10s` | Maximum admission wait before returning `429`. | +| `MEDIAINFO_ENABLED` | `true` | Enable the optional MediaInfo cache and bounded probe subsystem; initialization failures do not affect transparent proxying. | +| `MEDIAINFO_DB_PATH` | `./data/mediainfo.db` | MediaInfo SQLite cache path; the container image defaults to `/app_data/mediainfo.db`. | +| `MEDIAINFO_USER_AGENT` | `Infuse-Library/8.4.4` | Fallback User-Agent for background probes without an active client context. | Plex tokens are not stored in configuration. Ordinary Plex requests remain transparent. For cloud playback, all client request headers are forwarded to @@ -229,7 +232,8 @@ That topology is outside the exclusive-gateway deployment contract. ## Endpoints -- `GET /health` returns process health. +- `GET /health` returns process health plus a credential-free MediaInfo + availability, cache, and queue summary. - `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 diff --git a/README.md b/README.md index b75de06..5d84e25 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,9 @@ go run ./cmd/plex-gateway | `METADATA_GUARD_BATCH_ENABLED` | `false` | 限制逗号分隔的批量 metadata 读取进入 Plex 的并发量。 | | `METADATA_GUARD_BATCH_CONCURRENCY` | `3` | 所有客户端共享的批量 metadata 并发上限。 | | `METADATA_GUARD_QUEUE_TIMEOUT` | `10s` | 等待准入的最长时间,超时返回 `429`。 | +| `MEDIAINFO_ENABLED` | `true` | 启用可选 MediaInfo 缓存与受限探测子系统;初始化失败不影响透明代理。 | +| `MEDIAINFO_DB_PATH` | `./data/mediainfo.db` | MediaInfo SQLite 持久缓存路径;容器镜像默认使用 `/app_data/mediainfo.db`。 | +| `MEDIAINFO_USER_AGENT` | `Infuse-Library/8.4.4` | 没有活动客户端上下文时,后台探测使用的 fallback User-Agent。 | Plex Token 不会保存到配置中。普通 Plex 请求保持透明转发。对于云端播放,客户端的 所有请求 header 都会转发到配置的 MediaVault origin,使其能够为相同客户端上下文 @@ -196,7 +199,7 @@ Gateway 重新执行检查。 ## Endpoints -- `GET /health` 返回进程健康状态。 +- `GET /health` 返回进程健康状态,以及不含媒体身份和凭据的 MediaInfo 可用性、缓存和队列摘要。 - `GET /metrics` 返回固定结构的 JSON 计数器,以及 resolver 和完整重定向链路的 延迟总计、样本数、最近值和最大值。启用 metadata 保护时还会返回准入、超时、 活动和排队计数,所有指标都不包含请求标签或凭据。 diff --git a/cmd/plex-gateway/main.go b/cmd/plex-gateway/main.go index fb0a719..e1b180b 100644 --- a/cmd/plex-gateway/main.go +++ b/cmd/plex-gateway/main.go @@ -7,6 +7,7 @@ import ( "log/slog" "net" "net/http" + "net/url" "os" "os/signal" "strings" @@ -15,6 +16,7 @@ import ( "github.com/InfinityPacer/plex-gateway/internal/config" "github.com/InfinityPacer/plex-gateway/internal/gateway" + "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" @@ -64,6 +66,13 @@ func run() error { return fmt.Errorf("create MediaVault resolver: %w", err) } } + mediaInfoService, mediaInfoStore, mediaInfoReason := initializeMediaInfo( + context.Background(), cfg.MediaInfo, cfg.PlexURL, cfg.PartProbeTimeout, strmResolver, registry, logger, + ) + defer closeMediaInfo(mediaInfoService, mediaInfoStore, cfg.ShutdownTimeout, logger) + mediaInfoStatus := func() mediainfo.Status { + return mediaInfoService.Status() + } handler := gateway.New(gateway.Options{ Upstream: cfg.PlexURL, Logger: logger, @@ -83,6 +92,8 @@ func run() error { BatchConcurrency: cfg.MetadataGuard.BatchConcurrency, QueueTimeout: cfg.MetadataGuard.QueueTimeout, }, + MediaInfoEnabled: cfg.MediaInfo.Enabled, + MediaInfoStatus: mediaInfoStatus, }) server := &http.Server{ Addr: cfg.ListenAddr, @@ -104,6 +115,9 @@ func run() error { "trace", cfg.TraceEnabled, "metadata_guard", cfg.MetadataGuard.Enabled, "metadata_batch_guard", cfg.MetadataGuard.BatchEnabled, + "mediainfo_enabled", cfg.MediaInfo.Enabled, + "mediainfo_available", mediaInfoService != nil, + "mediainfo_reason", mediaInfoReason, ) serveErrors <- server.ListenAndServe() }() @@ -127,6 +141,88 @@ func run() error { return nil } +func initializeMediaInfo( + ctx context.Context, + cfg config.MediaInfoConfig, + plexURL *url.URL, + identityTimeout time.Duration, + strmResolver resolver.ControlResolver, + registry *metrics.Metrics, + logger *slog.Logger, +) (*mediainfo.Service, *mediainfo.SQLiteStore, string) { + if !cfg.Enabled { + return nil, nil, "disabled" + } + if strmResolver == nil { + return nil, nil, "cloud_unavailable" + } + plexServerID, err := gateway.ReadPlexServerIdentity(ctx, plexURL, identityTimeout) + if err != nil { + logger.Warn("mediainfo_unavailable", "reason", "plex_identity") + return nil, nil, "plex_identity_unavailable" + } + prober, err := mediainfo.NewFFProber(mediainfo.FFProbeOptions{ + Binary: cfg.FFProbePath, Timeout: cfg.ProbeTimeout, ProbeSize: cfg.ProbeSize, + AnalyzeDuration: cfg.AnalyzeDuration, OutputLimit: cfg.OutputMaxBytes, + }) + if err != nil { + logger.Warn("mediainfo_unavailable", "reason", "ffprobe") + return nil, nil, "ffprobe_unavailable" + } + provider, err := mediainfo.NewMediaVaultFFProbeProvider(strmResolver, prober) + if err != nil { + logger.Warn("mediainfo_unavailable", "reason", "provider") + return nil, nil, "provider_unavailable" + } + store, err := mediainfo.OpenSQLite(ctx, cfg.DatabasePath) + if err != nil { + logger.Warn("mediainfo_unavailable", "reason", "storage") + return nil, nil, "storage_unavailable" + } + now := time.Now().UTC() + if _, err := store.DeleteUnretained(ctx, now); err != nil { + logger.Warn("mediainfo_store_gc_failed", "error_kind", "storage") + } + records, err := store.LoadCompatibleRetained(ctx, now, cfg.L1MaxEntries, provider.Descriptor()) + if err != nil { + logger.Warn("mediainfo_unavailable", "reason", "restore") + _ = store.Close() + return nil, nil, "restore_failed" + } + cache := mediainfo.NewCacheWithLimit(records, now, cfg.L1MaxEntries) + 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, + RecordRetention: cfg.RecordRetention, NegativeTTL: cfg.NegativeTTL, + BackgroundUserAgent: cfg.UserAgent, + }) + if err != nil { + logger.Warn("mediainfo_unavailable", "reason", "worker") + _ = store.Close() + return nil, nil, "worker_unavailable" + } + return service, store, "ready" +} + +func closeMediaInfo(service *mediainfo.Service, store *mediainfo.SQLiteStore, timeout time.Duration, logger *slog.Logger) { + serviceClosed := true + if service != nil { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + if err := service.Close(ctx); err != nil { + serviceClosed = false + logger.Warn("mediainfo_shutdown_incomplete", "error_kind", "timeout") + } + cancel() + } + if store != nil && serviceClosed { + if err := store.Close(); err != nil { + logger.Warn("mediainfo_store_close_failed", "error_kind", "storage") + } + } +} + func newLogger(level string) *slog.Logger { var slogLevel slog.Level switch level { diff --git a/cmd/plex-gateway/main_test.go b/cmd/plex-gateway/main_test.go new file mode 100644 index 0000000..dabc455 --- /dev/null +++ b/cmd/plex-gateway/main_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/InfinityPacer/plex-gateway/internal/config" + "github.com/InfinityPacer/plex-gateway/internal/metrics" + "github.com/InfinityPacer/plex-gateway/internal/resolver" +) + +type countingControlResolver struct { + calls atomic.Int64 +} + +func (control *countingControlResolver) ReadTarget(string) (string, error) { + control.calls.Add(1) + return "", errors.New("unexpected STRM read") +} + +func (control *countingControlResolver) ResolveTarget(context.Context, string, resolver.PlaybackRequest) (resolver.DirectURL, error) { + control.calls.Add(1) + return resolver.DirectURL{}, errors.New("unexpected MediaVault resolve") +} + +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" { + select { + case unexpectedPlexPath <- r.URL.Path: + default: + } + http.NotFound(w, r) + return + } + _, _ = io.WriteString(w, ``) + })) + defer plex.Close() + plexURL, err := url.Parse(plex.URL) + if err != nil { + t.Fatal(err) + } + + directory := t.TempDir() + ffprobePath := filepath.Join(directory, "ffprobe") + if err := os.WriteFile(ffprobePath, []byte("#!/bin/sh\n: > \"$0.called\"\nexit 99\n"), 0o700); err != nil { + t.Fatal(err) + } + control := &countingControlResolver{} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + service, store, reason := initializeMediaInfo(t.Context(), config.MediaInfoConfig{ + Enabled: true, + DatabasePath: filepath.Join(directory, "mediainfo.db"), + 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", + }, plexURL, time.Second, control, metrics.New(), logger) + if service == nil || store == nil || reason != "ready" { + t.Fatalf("initializeMediaInfo() service=%v store=%v reason=%q", service != nil, store != nil, reason) + } + if status := service.Status(); !status.Available || status.ActiveProbes != 0 || + status.InteractiveQueued != 0 || status.BackgroundQueued != 0 { + t.Fatalf("unexpected MediaInfo status %#v", status) + } + if got := control.calls.Load(); got != 0 { + t.Fatalf("resolver calls = %d", got) + } + select { + case path := <-unexpectedPlexPath: + t.Fatalf("unexpected Plex path %q", path) + default: + } + if _, err := os.Stat(ffprobePath + ".called"); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("ffprobe process unexpectedly started: %v", err) + } + closeMediaInfo(service, store, time.Second, logger) +} diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 1792d38..ea90bbd 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -24,12 +24,17 @@ services: 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" READ_HEADER_TIMEOUT: "15s" IDLE_TIMEOUT: "90s" SHUTDOWN_TIMEOUT: "15s" volumes: - ./strm:/strm:ro - ./archive-strm:/archive-strm:ro + # The image runs as UID/GID 1000; this directory must be writable by it. + - ./app_data:/app_data networks: - media diff --git a/docs/media-lifecycle-architecture.md b/docs/media-lifecycle-architecture.md index 61bd29c..28477bf 100644 --- a/docs/media-lifecycle-architecture.md +++ b/docs/media-lifecycle-architecture.md @@ -418,7 +418,7 @@ MoviePilot 本地下载路径应在真实文件仍在本地时执行 ffprobe。 `MediaInfoRecord` 必须是版本化公开契约,至少包含: -- `schema_version`、记录身份、上游关联和内容 fingerprint; +- `schema_version`、记录身份、provider revision、上游关联和内容 fingerprint; - container、duration、size; - 视频、音频和字幕 stream; - provider、`probed_at`、`ready|stale|negative` 状态; @@ -467,7 +467,7 @@ Plex Part / configured STRM root Gateway discovery and prewarm queue │ ├── L1 immutable snapshot - ├── SQLite WAL result and job store + ├── SQLite WAL result store └── bounded MediaInfo worker │ ▼ @@ -483,33 +483,43 @@ identity、ratingKey、PartID、STRM fingerprint、可用时的 provider/content MediaInfo schema 版本和探测实现版本。探测失败不得覆盖已知良好记录;相同指纹使用 singleflight、负缓存与退避。 +Gateway fallback 默认将成功记录视为 `30d` fresh,并将最近使用记录保留 `180d`。 +fresh 到期后可以立即返回仍在 retention 内的已知良好记录,同时在后台低优先级复验; +活跃访问会按限频机制续租 retention,而不是每次播放执行完整 ffprobe。超过 retention 且 +长期未访问的记录才允许由 GC 删除。 + Gateway 支持以下范围: | 范围 | 触发方式 | 约束 | | --- | --- | --- | | 单 Part | metadata miss/stale 或成功播放 | 自动、高优先级、去重 | -| 下一集 | 当前集成功播放后的低优先预取 | 必须精确识别 Part | -| 单季 | 显式预热或计划任务 | 分页、checkpoint | -| 整剧 | 显式后台任务 | 按季分页,不由一次播放自动扩散 | -| STRM 目录 | 管理员扫描或 reconciliation | 限制到配置 mapping root,可取消和恢复 | - -播放触发任务优先于批量任务。远程 probe 初始并发候选值为 `1`,经 CDN、CPU、Plex 和 -MediaVault 压测后再决定是否提高。每次播放可以触发 freshness 判断,但不等于每次执行 -完整 ffprobe;未过期记录只更新访问时间,过期记录优先异步校验,可靠指纹变化才重新 -探测。 +| 下一集 | 当前集确认播放后的低优先预取 | 当前实现范围;必须精确识别 Part,只向前一步 | +| 单季 | 显式预热或计划任务 | 后续批量能力;分页、checkpoint | +| 整剧 | 显式后台任务 | 后续批量能力;按季分页,不由一次播放自动扩散 | +| STRM 目录 | 管理员扫描或 reconciliation | 后续批量能力;限制到配置 mapping root,可取消和恢复 | + +当前实现不执行启动全库、单季、整剧或目录批量预热。精确当前项始终高于下一集预取; +下一集只能在当前集确认播放后进入低优先队列,快速切换产生的新当前项可以提升同一 +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`。解析直链和 -ffprobe 必须使用一致的确定性 User-Agent,否则当前 CDN 样本会拒绝请求。该样本只证明 -方案值得实现,仍需覆盖 HDR、Dolby Vision、多音轨、字幕、大型 MKV、尾部索引容器、 -高延迟和失败源。 +MediaVault redirect 与 ffprobe 必须在同一次任务中使用相同 User-Agent,否则当前 CDN +样本会拒绝请求。交互探测使用触发 metadata 请求的真实客户端 User-Agent;没有活动 +客户端上下文的后台任务使用可配置的稳定 fallback User-Agent。该样本只证明方案值得 +实现,仍需覆盖 HDR、Dolby Vision、多音轨、字幕、大型 MKV、尾部索引容器、高延迟和 +失败源。 首次冷缓存长期返回空 MediaInfo 不可作为正常体验,metadata 请求一直无响应同样不可 -接受。处理顺序为:Gateway 启动时从 SQLite 恢复 L1;使用 Plex 管理 Token 主动发现和 -预热;精确冷 miss 时创建最高优先级任务,并允许当前单项 metadata response 在可配置的 -硬截止时间内等待。当前 PoC 候选上限为 `2s`;窗口内完成就补充当前 response,超时或 -失败必须立即返回原始 Plex response,任务继续在后台完成。提高首次完整率应优先扩大 -预热覆盖,而不是无限增加请求等待。 +接受。处理顺序为:Gateway 启动时从 SQLite 恢复 L1;精确冷 miss 时创建最高优先级 +任务,并允许当前单项 metadata response 在可配置的硬截止时间内等待。当前 PoC 候选 +上限为 `5s`;窗口内完成就补充当前 response,超时或失败必须立即返回原始 Plex +response,任务继续在后台完成。当前集确认播放后,管理面只发现并低优先预热下一集, +不扩散为季、整剧或目录扫描。提高首次完整率应优先使用准确的一步预取,而不是无限增加 +请求等待或启动无界批量任务。 ## 11. Plex MediaInfo 投影 @@ -587,8 +597,9 @@ Gateway 不应负责: - 在播放 decision、Part、universal start 或 302 请求中执行 ffprobe 或数据库访问; - 写 Plex 数据库。 -Gateway analysis worker 可以执行有界的单 Part、单季、整剧和 STRM 目录探测,但这些 -任务不拥有下载、做种或清理决策,也不能由一次播放无界扩散到整库。 +Gateway analysis worker 当前只执行精确单 Part 和一步下一集探测。单季、整剧和 STRM +目录探测保留为后续有界批量能力;这些任务不拥有下载、做种或清理决策,也不能由一次 +播放无界扩散到整库。 ### 12.1 Plex 管理凭据与管理面 @@ -598,13 +609,15 @@ Gateway 保存一个可选 `PLEX_TOKEN`,用于后台枚举、Part 发现、预 `env_file: ./app.env` 注入。`.env` 保留给 Compose 默认插值语义。 Token 缺失或失效时,透明代理和客户端播放继续工作,只有后台发现与预热降级。启动时 -验证 Token 和 Plex server identity;日志、metrics、任务、CLI 和管理响应都不得输出 -Token。管理 Token 只允许发送到配置的 Plex origin;MediaVault、CDN 和 ffprobe 请求使用 -独立构造的干净 header,禁止继承 `X-Plex-Token`、Cookie 或 Authorization。 +验证 Token;稳定 Plex server identity 从无需管理 Token 的 PMS `/identity` 获取。日志、 +metrics、任务、CLI 和管理响应都不得输出 Token 或 machine identifier。管理 Token 只允许 +发送到配置的 Plex origin;MediaVault、CDN 和 ffprobe 请求使用独立构造的干净 header, +禁止继承 `X-Plex-Token`、Cookie 或 Authorization。 -Gateway 当前没有后台页面。首版优先提供 CLI 创建单 Part、单季、整剧和目录任务,查询 -进度、取消任务和清理 stale/negative record。以后若增加 HTTP API 或页面,使用默认关闭 -的独立 admin listener 或 Unix socket 和独立认证,不能挂到客户端可访问的 Plex listener。 +Gateway 当前没有后台页面。当前管理面只需要查询状态、验证 Token 和控制一步下一集 +预热;创建单季、整剧和目录任务、批任务 checkpoint 与恢复留到后续。以后若增加 HTTP +API 或页面,使用默认关闭的独立 admin listener 或 Unix socket 和独立认证,不能挂到 +客户端可访问的 Plex listener。 ### 12.2 云项目分类契约 @@ -731,9 +744,10 @@ provider、目标 backing、decision revision、人工确认或策略依据, - 优先实现 Gateway L1、SQLite、受限 ffprobe、`PLEX_TOKEN` 主动预热和 response enrichment; -- 实现单 Part、下一集、单季、整剧和 STRM 目录任务,以及优先级、singleflight、取消、 - checkpoint、重启恢复、负缓存和已知良好保护; -- 验证 `2s` 冷等待候选上限、首次 metadata 完整率和 metadata p50/p95/p99; +- 实现精确单 Part 和确认播放后的一步下一集任务,以及优先级、singleflight、推测任务 + 抢占、负缓存和已知良好保护; +- 将单季、整剧、STRM 目录任务、批任务 checkpoint 和恢复留到后续批量预热能力; +- 验证 `5s` 冷等待候选上限、首次 metadata 完整率和 metadata p50/p95/p99; - 验证 decision、Part、universal start 和 302 不执行分析 I/O,单项 metadata 的有界等待 单独计入和验收; - 比较 Plex 官方 API、其他受支持 PMS 接口和独立 Plex Helper,不提前固定写入方案; @@ -767,7 +781,7 @@ provider、目标 backing、decision revision、人工确认或策略依据, ## 17. 待评估决策 -1. `2s` 冷等待能否覆盖 HDR、Dolby Vision、大型 MKV 和高延迟 CDN 的 p95/p99; +1. `5s` 冷等待能否覆盖 HDR、Dolby Vision、大型 MKV 和高延迟 CDN 的 p95/p99; 2. L1 启动恢复和 Plex 管理 Token 主动预热能覆盖多少首次请求; 3. MediaInfo 公开契约使用原子 sidecar、只读 API,还是同时提供; 4. MediaVault 是否愿意提供版本化的分享任务和 MediaInfo API; diff --git a/docs/roadmap.md b/docs/roadmap.md index 7ef72df..6327b6b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -80,10 +80,11 @@ 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 `2s` cold metadata wait +3. evaluate bounded remote `ffprobe`, with an initial `5s` cold metadata wait ceiling based on the current sample; -4. support single Part, next episode, season, show, and configured STRM-root - tasks without blocking decision, Part, universal start, or 302; +4. support exact single-Part analysis and one-step next-episode prefetch after + confirmed playback without blocking decision, Part, universal start, or 302; + 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; diff --git a/go.mod b/go.mod index 4660428..0cdddb8 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,20 @@ module github.com/InfinityPacer/plex-gateway go 1.25.0 + +require ( + github.com/hashicorp/golang-lru/v2 v2.0.7 + modernc.org/sqlite v1.57.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3efffe8 --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/config/config.go b/internal/config/config.go index ea44743..45a166c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,18 +14,30 @@ 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 + 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 + defaultMediaInfoDatabase = "./data/mediainfo.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" ) // Config contains process-level settings for transparent Plex proxying and @@ -42,6 +54,7 @@ type Config struct { ObserveMaxBytes int64 PartProbeTimeout time.Duration MetadataGuard MetadataGuardConfig + MediaInfo MediaInfoConfig LogLevel string TraceEnabled bool ReadHeaderTimeout time.Duration @@ -49,6 +62,26 @@ type Config struct { 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 + DatabasePath string + 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 +} + // MetadataGuardConfig bounds detailed metadata fan-out before requests enter // Plex. The protection is opt-in because client behavior and Plex capacity vary // across deployments. @@ -149,6 +182,72 @@ func Load() (Config, error) { if err != nil { return Config{}, err } + mediaInfoEnabled, err := envBool("MEDIAINFO_ENABLED", true) + if err != nil { + return Config{}, err + } + mediaInfoDatabase := strings.TrimSpace(os.Getenv("MEDIAINFO_DB_PATH")) + if mediaInfoDatabase == "" { + mediaInfoDatabase = defaultMediaInfoDatabase + } + mediaInfoFFProbe := strings.TrimSpace(os.Getenv("MEDIAINFO_FFPROBE_PATH")) + if mediaInfoFFProbe == "" { + mediaInfoFFProbe = "ffprobe" + } + mediaInfoProbeTimeout, err := envDuration("MEDIAINFO_PROBE_TIMEOUT", defaultMediaInfoProbe) + if err != nil { + return Config{}, err + } + mediaInfoProbeSize, err := envPositiveInt64("MEDIAINFO_PROBE_SIZE", defaultMediaInfoSize) + if err != nil { + return Config{}, err + } + mediaInfoAnalyze, err := envDuration("MEDIAINFO_ANALYZE_DURATION", defaultMediaInfoAnalyze) + if err != nil { + return Config{}, err + } + mediaInfoOutput, err := envPositiveInt64("MEDIAINFO_OUTPUT_MAX_BYTES", defaultMediaInfoOutput) + if err != nil { + return Config{}, err + } + mediaInfoWorkers, err := envPositiveInt("MEDIAINFO_CONCURRENCY", defaultMediaInfoWorkers) + if err != nil { + return Config{}, err + } + mediaInfoInteractiveQueue, err := envPositiveInt("MEDIAINFO_INTERACTIVE_QUEUE_SIZE", defaultMediaInfoQueue) + if err != nil { + return Config{}, err + } + mediaInfoBackgroundQueue, err := envPositiveInt("MEDIAINFO_BACKGROUND_QUEUE_SIZE", defaultMediaInfoQueue) + if err != nil { + return Config{}, err + } + mediaInfoTTL, err := envDuration("MEDIAINFO_RECORD_TTL", defaultMediaInfoTTL) + if err != nil { + return Config{}, err + } + mediaInfoRetention, err := envDuration("MEDIAINFO_RECORD_RETENTION", defaultMediaInfoRetention) + if err != nil { + return Config{}, err + } + if mediaInfoRetention < mediaInfoTTL { + return Config{}, errors.New("MEDIAINFO_RECORD_RETENTION must not be shorter than MEDIAINFO_RECORD_TTL") + } + mediaInfoL1Entries, err := envPositiveInt("MEDIAINFO_L1_MAX_ENTRIES", defaultMediaInfoL1Entries) + if err != nil { + return Config{}, err + } + mediaInfoNegativeTTL, err := envDuration("MEDIAINFO_NEGATIVE_TTL", defaultMediaInfoNegative) + if err != nil { + return Config{}, err + } + mediaInfoUserAgent := strings.TrimSpace(os.Getenv("MEDIAINFO_USER_AGENT")) + if mediaInfoUserAgent == "" { + mediaInfoUserAgent = defaultMediaInfoAgent + } + if strings.ContainsAny(mediaInfoUserAgent, "\r\n") { + return Config{}, errors.New("MEDIAINFO_USER_AGENT must not contain line breaks") + } readHeaderTimeout, err := envDuration("READ_HEADER_TIMEOUT", defaultReadHeaderTimeout) if err != nil { @@ -193,6 +292,15 @@ func Load() (Config, error) { BatchConcurrency: metadataBatch, QueueTimeout: metadataQueueWait, }, + MediaInfo: MediaInfoConfig{ + Enabled: mediaInfoEnabled, DatabasePath: mediaInfoDatabase, FFProbePath: mediaInfoFFProbe, + ProbeTimeout: mediaInfoProbeTimeout, + ProbeSize: mediaInfoProbeSize, AnalyzeDuration: mediaInfoAnalyze, OutputMaxBytes: mediaInfoOutput, + Concurrency: mediaInfoWorkers, InteractiveQueueSize: mediaInfoInteractiveQueue, + BackgroundQueueSize: mediaInfoBackgroundQueue, + RecordTTL: mediaInfoTTL, RecordRetention: mediaInfoRetention, L1MaxEntries: mediaInfoL1Entries, + NegativeTTL: mediaInfoNegativeTTL, UserAgent: mediaInfoUserAgent, + }, LogLevel: logLevel, TraceEnabled: traceEnabled, ReadHeaderTimeout: readHeaderTimeout, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 00c9344..5a0931c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -40,6 +40,9 @@ func TestLoadDefaults(t *testing.T) { 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 { t.Fatalf("unexpected metadata guard defaults: %#v", got.MetadataGuard) } + if !got.MediaInfo.Enabled || got.MediaInfo.ProbeTimeout != 20*time.Second || got.MediaInfo.Concurrency != 1 || got.MediaInfo.DatabasePath != "./data/mediainfo.db" || got.MediaInfo.UserAgent != "Infuse-Library/8.4.4" { + t.Fatalf("unexpected MediaInfo defaults: %#v", got.MediaInfo) + } if got.MediaVaultURL != nil || len(got.PathMappings) != 0 { t.Fatalf("cloud redirect unexpectedly enabled: %#v", got) } @@ -69,6 +72,21 @@ func TestLoadCloudConfiguration(t *testing.T) { t.Setenv("METADATA_GUARD_BATCH_ENABLED", "true") t.Setenv("METADATA_GUARD_BATCH_CONCURRENCY", "2") t.Setenv("METADATA_GUARD_QUEUE_TIMEOUT", "5s") + t.Setenv("MEDIAINFO_ENABLED", "true") + t.Setenv("MEDIAINFO_DB_PATH", "/app_data/test.db") + t.Setenv("MEDIAINFO_FFPROBE_PATH", "/usr/bin/ffprobe") + t.Setenv("MEDIAINFO_PROBE_TIMEOUT", "12s") + t.Setenv("MEDIAINFO_PROBE_SIZE", "4194304") + 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_RECORD_TTL", "168h") + t.Setenv("MEDIAINFO_RECORD_RETENTION", "720h") + t.Setenv("MEDIAINFO_L1_MAX_ENTRIES", "5000") + t.Setenv("MEDIAINFO_NEGATIVE_TTL", "10m") + t.Setenv("MEDIAINFO_USER_AGENT", "plex-gateway-test") got, err := Load() if err != nil { @@ -86,6 +104,20 @@ 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.MediaInfo.Enabled || got.MediaInfo.DatabasePath != "/app_data/test.db" || 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" { + t.Fatalf("MediaInfo configuration = %#v", got.MediaInfo) + } +} + +func TestLoadRejectsMediaInfoRetentionBelowFreshTTL(t *testing.T) { + t.Setenv("PLEX_URL", "http://plex:32400") + t.Setenv("MEDIAINFO_RECORD_TTL", "720h") + t.Setenv("MEDIAINFO_RECORD_RETENTION", "168h") + + _, err := Load() + if err == nil || !strings.Contains(err.Error(), "must not be shorter") { + t.Fatalf("Load() error = %v", err) + } } func TestLoadRejectsMetadataClientLimitAboveGlobalLimit(t *testing.T) { diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index da9c505..12cdc7a 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -2,6 +2,7 @@ package gateway import ( "context" + "encoding/json" "errors" "log/slog" "net" @@ -11,6 +12,7 @@ import ( "strings" "time" + "github.com/InfinityPacer/plex-gateway/internal/mediainfo" "github.com/InfinityPacer/plex-gateway/internal/metrics" "github.com/InfinityPacer/plex-gateway/internal/observe" "github.com/InfinityPacer/plex-gateway/internal/partcache" @@ -36,6 +38,8 @@ type Options struct { ObserveMaxBytes int64 PartProbeTimeout time.Duration MetadataGuard MetadataGuardOptions + MediaInfoEnabled bool + MediaInfoStatus func() mediainfo.Status } // New builds the fail-open Plex proxy and optional Direct Play interceptor. @@ -143,9 +147,25 @@ func New(options Options) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") + status := mediainfo.Status{} + if options.MediaInfoStatus != nil { + status = options.MediaInfoStatus() + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("{\"status\":\"ok\"}\n")) + _ = json.NewEncoder(w).Encode(struct { + Status string `json:"status"` + MediaInfo struct { + Enabled bool `json:"enabled"` + mediainfo.Status + } `json:"mediainfo"` + }{ + Status: "ok", + MediaInfo: struct { + Enabled bool `json:"enabled"` + mediainfo.Status + }{Enabled: options.MediaInfoEnabled, Status: status}, + }) }) mux.Handle("GET /metrics", registry.Handler()) mux.Handle("GET /video/:/transcode/universal/decision", decision) diff --git a/internal/gateway/identity.go b/internal/gateway/identity.go new file mode 100644 index 0000000..1a551e9 --- /dev/null +++ b/internal/gateway/identity.go @@ -0,0 +1,78 @@ +package gateway + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + plexIdentityMaxBytes = 64 << 10 + plexIdentityMaxID = 256 +) + +type plexIdentityResponse struct { + MachineIdentifier string `xml:"machineIdentifier,attr"` +} + +// ReadPlexServerIdentity obtains the stable PMS machine identifier without a +// management Token. Failure disables only consumers that require stable cache +// ownership; it does not weaken the proxy authentication boundary. +func ReadPlexServerIdentity(ctx context.Context, upstream *url.URL, timeout time.Duration) (string, error) { + if upstream == nil { + return "", errors.New("Plex identity probe requires an upstream") + } + if timeout <= 0 { + timeout = defaultPartProbeTimeout + } + target := *upstream + target.Path = joinURLPath(target.Path, "/identity") + target.RawPath = "" + target.RawQuery = "" + target.Fragment = "" + request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) + if err != nil { + return "", fmt.Errorf("create Plex identity probe: %w", err) + } + request.Header.Set("Accept", "application/xml") + request.Header.Set("Accept-Encoding", "identity") + request.Host = upstream.Host + client := &http.Client{ + Transport: newTransport(), + Timeout: timeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + response, err := client.Do(request) + if err != nil { + return "", fmt.Errorf("run Plex identity probe: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("Plex identity probe returned HTTP %d", response.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(response.Body, plexIdentityMaxBytes+1)) + if err != nil { + return "", errors.New("read Plex identity response") + } + if len(body) > plexIdentityMaxBytes { + return "", errors.New("Plex identity response exceeds the limit") + } + var identity plexIdentityResponse + if err := xml.Unmarshal(body, &identity); err != nil { + return "", errors.New("parse Plex identity response") + } + machineIdentifier := strings.TrimSpace(identity.MachineIdentifier) + if machineIdentifier == "" || len(machineIdentifier) > plexIdentityMaxID || + strings.ContainsAny(machineIdentifier, "\x00\r\n") { + return "", errors.New("Plex identity response has no valid machine identifier") + } + return machineIdentifier, nil +} diff --git a/internal/gateway/trust_test.go b/internal/gateway/trust_test.go index 06a265f..93a883c 100644 --- a/internal/gateway/trust_test.go +++ b/internal/gateway/trust_test.go @@ -53,3 +53,37 @@ func TestPlexTrustBoundaryProbeDoesNotUseEnvironmentProxy(t *testing.T) { t.Fatal(err) } } + +func TestReadPlexServerIdentity(t *testing.T) { + plex := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/identity" || r.URL.RawQuery != "" || r.Header.Get("X-Plex-Token") != "" || + r.Header.Get("Accept-Encoding") != "identity" { + t.Fatalf("unexpected identity request: %#v", r) + } + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write([]byte(``)) + })) + defer plex.Close() + upstream, _ := url.Parse(plex.URL) + identity, err := ReadPlexServerIdentity(t.Context(), upstream, time.Second) + if err != nil { + t.Fatal(err) + } + if identity != "server-123" { + t.Fatalf("identity = %q", identity) + } +} + +func TestReadPlexServerIdentityRejectsInvalidResponse(t *testing.T) { + for _, body := range []string{``, ` remaining { + if remaining > 0 { + _, _ = buffer.buffer.Write(value[:remaining]) + buffer.written += remaining + } + buffer.exceeded = true + if buffer.cancel != nil { + buffer.cancel() + } + return int(remaining), ErrProbeOutput + } + written, err := buffer.buffer.Write(value) + buffer.written += int64(written) + return written, err +} + +func (buffer *limitedBuffer) Bytes() []byte { + return buffer.buffer.Bytes() +} + +type scalar string + +func (value *scalar) UnmarshalJSON(data []byte) error { + if bytes.Equal(data, []byte("null")) { + *value = "" + return nil + } + var stringValue string + if len(data) > 0 && data[0] == '"' { + if err := json.Unmarshal(data, &stringValue); err != nil { + return err + } + *value = scalar(stringValue) + return nil + } + *value = scalar(string(data)) + return nil +} + +func (value scalar) string() string { + return strings.TrimSpace(string(value)) +} + +func (value scalar) integer() int64 { + parsed, _ := strconv.ParseInt(value.string(), 10, 64) + return parsed +} + +func (value scalar) milliseconds() int64 { + parsed, _ := strconv.ParseFloat(value.string(), 64) + return int64(parsed*1000 + 0.5) +} + +type ffprobeOutput struct { + Streams []ffprobeStream `json:"streams"` + Format ffprobeFormat `json:"format"` +} + +type ffprobeFormat struct { + FormatName string `json:"format_name"` + FormatLongName string `json:"format_long_name"` + StartTime scalar `json:"start_time"` + Duration scalar `json:"duration"` + Size scalar `json:"size"` + Bitrate scalar `json:"bit_rate"` +} + +type ffprobeStream struct { + Index int `json:"index"` + CodecName string `json:"codec_name"` + CodecLongName string `json:"codec_long_name"` + Profile string `json:"profile"` + CodecType string `json:"codec_type"` + CodecTagString string `json:"codec_tag_string"` + CodecTag string `json:"codec_tag"` + Level int `json:"level"` + TimeBase string `json:"time_base"` + StartTime scalar `json:"start_time"` + Duration scalar `json:"duration"` + Bitrate scalar `json:"bit_rate"` + FrameCount scalar `json:"nb_frames"` + Width int `json:"width"` + Height int `json:"height"` + SampleAspectRatio string `json:"sample_aspect_ratio"` + DisplayAspectRatio string `json:"display_aspect_ratio"` + FrameRate string `json:"r_frame_rate"` + AverageFrameRate string `json:"avg_frame_rate"` + FieldOrder string `json:"field_order"` + ReferenceFrames int `json:"refs"` + PixelFormat string `json:"pix_fmt"` + BitsPerSample int `json:"bits_per_sample"` + BitsPerRawSample scalar `json:"bits_per_raw_sample"` + ColorSpace string `json:"color_space"` + ColorRange string `json:"color_range"` + ColorPrimaries string `json:"color_primaries"` + ColorTransfer string `json:"color_transfer"` + ChromaLocation string `json:"chroma_location"` + SampleFormat string `json:"sample_fmt"` + SampleRate scalar `json:"sample_rate"` + Channels int `json:"channels"` + ChannelLayout string `json:"channel_layout"` + AudioServiceType string `json:"audio_service_type"` + Tags map[string]string `json:"tags"` + Disposition ffprobeDisposition `json:"disposition"` + SideDataList []map[string]any `json:"side_data_list"` +} + +type ffprobeDisposition struct { + Default int `json:"default"` + Dub int `json:"dub"` + Original int `json:"original"` + Comment int `json:"comment"` + Lyrics int `json:"lyrics"` + Karaoke int `json:"karaoke"` + Forced int `json:"forced"` + HearingImpaired int `json:"hearing_impaired"` + VisualImpaired int `json:"visual_impaired"` + CleanEffects int `json:"clean_effects"` + AttachedPicture int `json:"attached_pic"` + TimedThumbnails int `json:"timed_thumbnails"` + Captions int `json:"captions"` + Descriptions int `json:"descriptions"` + Metadata int `json:"metadata"` + Dependent int `json:"dependent"` + StillImage int `json:"still_image"` +} + +func parseFFProbe(body []byte) (Media, error) { + var output ffprobeOutput + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + if err := decoder.Decode(&output); err != nil { + return Media{}, ErrProbeFailed + } + media := Media{ + Container: normalizeContainer(output.Format.FormatName), + FormatName: output.Format.FormatName, + FormatLongName: output.Format.FormatLongName, + StartTimeMS: output.Format.StartTime.milliseconds(), + DurationMS: output.Format.Duration.milliseconds(), + Size: output.Format.Size.integer(), + Bitrate: output.Format.Bitrate.integer(), + Streams: make([]Stream, 0, len(output.Streams)), + } + for _, raw := range output.Streams { + stream := normalizeStream(raw) + media.Streams = append(media.Streams, stream) + applyMediaSummary(&media, stream) + } + media.Complete = mediaComplete(media) + if !media.Complete { + return Media{}, ErrProbeIncomplete + } + return media, nil +} + +func normalizeStream(raw ffprobeStream) Stream { + bitDepth := raw.BitsPerSample + if raw.BitsPerRawSample.integer() > int64(bitDepth) { + bitDepth = int(raw.BitsPerRawSample.integer()) + } + stream := Stream{ + Index: raw.Index, + Type: strings.ToLower(raw.CodecType), + Codec: strings.ToLower(raw.CodecName), + CodecLongName: raw.CodecLongName, + CodecTag: firstNonEmpty(raw.CodecTagString, raw.CodecTag), + Profile: raw.Profile, + Level: raw.Level, + TimeBase: raw.TimeBase, + StartTimeMS: raw.StartTime.milliseconds(), + DurationMS: raw.Duration.milliseconds(), + Bitrate: raw.Bitrate.integer(), + FrameCount: raw.FrameCount.integer(), + Width: raw.Width, + Height: raw.Height, + SampleAspectRatio: raw.SampleAspectRatio, + DisplayAspectRatio: raw.DisplayAspectRatio, + FrameRate: raw.FrameRate, + AverageFrameRate: raw.AverageFrameRate, + FieldOrder: raw.FieldOrder, + ReferenceFrames: raw.ReferenceFrames, + PixelFormat: raw.PixelFormat, + BitDepth: bitDepth, + ColorSpace: raw.ColorSpace, + ColorRange: raw.ColorRange, + ColorPrimaries: raw.ColorPrimaries, + ColorTransfer: raw.ColorTransfer, + ChromaLocation: raw.ChromaLocation, + SampleFormat: raw.SampleFormat, + SampleRate: int(raw.SampleRate.integer()), + Channels: raw.Channels, + ChannelLayout: raw.ChannelLayout, + AudioServiceType: raw.AudioServiceType, + Language: raw.Tags["language"], + Title: raw.Tags["title"], + Disposition: normalizeDisposition(raw.Disposition), + } + stream.DolbyVision, stream.MasteringDisplay, stream.ContentLightLevel = normalizeSideData(raw.SideDataList) + stream.HDRFormat = hdrFormat(stream) + searchableAudio := strings.ToLower(strings.Join([]string{stream.Codec, stream.CodecLongName, stream.Profile, stream.Title}, " ")) + stream.Atmos = strings.Contains(searchableAudio, "atmos") + stream.DTSX = strings.Contains(searchableAudio, "dts:x") || strings.Contains(searchableAudio, "dts-x") + return stream +} + +func normalizeDisposition(raw ffprobeDisposition) Disposition { + return Disposition{ + Default: raw.Default != 0, Dub: raw.Dub != 0, Original: raw.Original != 0, + Comment: raw.Comment != 0, Lyrics: raw.Lyrics != 0, Karaoke: raw.Karaoke != 0, + Forced: raw.Forced != 0, HearingImpaired: raw.HearingImpaired != 0, + VisualImpaired: raw.VisualImpaired != 0, CleanEffects: raw.CleanEffects != 0, + AttachedPicture: raw.AttachedPicture != 0, TimedThumbnails: raw.TimedThumbnails != 0, + Captions: raw.Captions != 0, Descriptions: raw.Descriptions != 0, + Metadata: raw.Metadata != 0, Dependent: raw.Dependent != 0, StillImage: raw.StillImage != 0, + } +} + +func normalizeSideData(items []map[string]any) (*DolbyVision, *MasteringDisplay, *ContentLightLevel) { + var dolbyVision *DolbyVision + var mastering *MasteringDisplay + var contentLight *ContentLightLevel + for _, item := range items { + typeName := strings.ToLower(anyString(item["side_data_type"])) + switch { + case strings.Contains(typeName, "dovi"): + dolbyVision = &DolbyVision{ + VersionMajor: anyInt(item["dv_version_major"]), VersionMinor: anyInt(item["dv_version_minor"]), + Profile: anyInt(item["dv_profile"]), Level: anyInt(item["dv_level"]), + RPUPresent: anyInt(item["rpu_present_flag"]), ELPresent: anyInt(item["el_present_flag"]), + BLPresent: anyInt(item["bl_present_flag"]), BLCompatID: anyInt(item["dv_bl_signal_compatibility_id"]), + } + case strings.Contains(typeName, "mastering display"): + mastering = &MasteringDisplay{ + RedX: anyString(item["red_x"]), RedY: anyString(item["red_y"]), + GreenX: anyString(item["green_x"]), GreenY: anyString(item["green_y"]), + BlueX: anyString(item["blue_x"]), BlueY: anyString(item["blue_y"]), + WhitePointX: anyString(item["white_point_x"]), WhitePointY: anyString(item["white_point_y"]), + MinLuminance: anyString(item["min_luminance"]), MaxLuminance: anyString(item["max_luminance"]), + } + case strings.Contains(typeName, "content light"): + contentLight = &ContentLightLevel{ + MaxContent: anyInt(item["max_content"]), MaxAverage: anyInt(item["max_average"]), + } + } + } + return dolbyVision, mastering, contentLight +} + +func hdrFormat(stream Stream) string { + if stream.DolbyVision != nil { + return "dolby_vision" + } + switch strings.ToLower(stream.ColorTransfer) { + case "smpte2084": + return "hdr10" + case "arib-std-b67": + return "hlg" + default: + return "" + } +} + +func applyMediaSummary(media *Media, stream Stream) { + if media == nil { + return + } + switch stream.Type { + case "video": + if media.VideoCodec == "" || stream.Disposition.Default { + media.VideoCodec = stream.Codec + media.VideoProfile = stream.Profile + media.Width = stream.Width + media.Height = stream.Height + media.VideoResolution = videoResolution(stream.Width, stream.Height) + media.AspectRatio = stream.DisplayAspectRatio + media.FrameRate = firstNonEmpty(stream.AverageFrameRate, stream.FrameRate) + } + case "audio": + if media.AudioCodec == "" || stream.Disposition.Default { + media.AudioCodec = stream.Codec + media.AudioProfile = stream.Profile + media.AudioChannels = stream.Channels + } + } +} + +func mediaComplete(media Media) bool { + if media.Container == "" || media.DurationMS <= 0 || len(media.Streams) == 0 { + return false + } + mediaStreams := 0 + for _, stream := range media.Streams { + switch stream.Type { + case "video": + mediaStreams++ + if stream.Codec == "" || stream.Width <= 0 || stream.Height <= 0 { + return false + } + case "audio": + mediaStreams++ + if stream.Codec == "" || stream.Channels <= 0 { + return false + } + case "subtitle": + if stream.Codec == "" { + return false + } + } + } + return mediaStreams > 0 +} + +func normalizeContainer(formatName string) string { + formats := strings.Split(strings.ToLower(formatName), ",") + for _, format := range formats { + switch strings.TrimSpace(format) { + case "matroska": + return "mkv" + case "mov", "mp4": + return "mp4" + case "mpegts": + return "mpegts" + case "avi", "flv", "webm", "ogg", "wav", "mp3", "flac": + return strings.TrimSpace(format) + } + } + if len(formats) > 0 { + return strings.TrimSpace(formats[0]) + } + return "" +} + +func videoResolution(width, height int) string { + switch { + case width >= 3800 || height >= 2100: + return "4k" + case height >= 1400: + return "2k" + case height >= 1000: + return "1080" + case height >= 700: + return "720" + case height > 0: + return "sd" + default: + return "" + } +} + +func anyString(value any) string { + switch typed := value.(type) { + case string: + return typed + case json.Number: + return typed.String() + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + default: + return "" + } +} + +func anyInt(value any) int { + parsed, _ := strconv.Atoi(anyString(value)) + return parsed +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func probeErrorKind(err error) string { + switch { + case errors.Is(err, ErrProbeUnavailable): + return "unavailable" + case errors.Is(err, ErrProbeTarget): + return "target" + case errors.Is(err, ErrProbeTimeout): + return "timeout" + case errors.Is(err, ErrProbeOutput): + return "output_limit" + case errors.Is(err, ErrProbeIncomplete): + return "incomplete" + default: + return "failed" + } +} + +func (prober *FFProber) String() string { + if prober == nil { + return "ffprobe(unavailable)" + } + return fmt.Sprintf("ffprobe(timeout=%s,probesize=%d,analyzeduration=%s)", prober.timeout, prober.probeSize, prober.analyzeDuration) +} diff --git a/internal/mediainfo/ffprobe_test.go b/internal/mediainfo/ffprobe_test.go new file mode 100644 index 0000000..cd91b5f --- /dev/null +++ b/internal/mediainfo/ffprobe_test.go @@ -0,0 +1,205 @@ +package mediainfo + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestParseFFProbePreservesPlexRelevantFields(t *testing.T) { + body := []byte(`{ + "streams": [ + { + "index": 0, + "codec_name": "hevc", + "codec_long_name": "H.265 / HEVC", + "profile": "Main 10", + "codec_type": "video", + "codec_tag_string": "[0][0][0][0]", + "width": 3840, + "height": 2160, + "sample_aspect_ratio": "1:1", + "display_aspect_ratio": "16:9", + "pix_fmt": "yuv420p10le", + "level": 153, + "color_range": "tv", + "color_space": "bt2020nc", + "color_transfer": "smpte2084", + "color_primaries": "bt2020", + "chroma_location": "topleft", + "r_frame_rate": "24000/1001", + "avg_frame_rate": "24000/1001", + "time_base": "1/1000", + "duration": "7200.125", + "bit_rate": "45000000", + "bits_per_raw_sample": "10", + "disposition": {"default": 1}, + "side_data_list": [ + {"side_data_type":"DOVI configuration record","dv_version_major":1,"dv_version_minor":0,"dv_profile":8,"dv_level":6,"rpu_present_flag":1,"el_present_flag":0,"bl_present_flag":1,"dv_bl_signal_compatibility_id":1}, + {"side_data_type":"Content light level metadata","max_content":1000,"max_average":400} + ] + }, + { + "index": 1, + "codec_name": "truehd", + "codec_long_name": "TrueHD", + "profile": "Dolby TrueHD + Dolby Atmos", + "codec_type": "audio", + "sample_fmt": "s32", + "sample_rate": "48000", + "channels": 8, + "channel_layout": "7.1", + "bit_rate": "5000000", + "tags": {"language":"eng","title":"Dolby Atmos"}, + "disposition": {"default": 1} + }, + { + "index": 2, + "codec_name": "hdmv_pgs_subtitle", + "codec_type": "subtitle", + "tags": {"language":"zho","title":"Chinese"}, + "disposition": {"forced": 1} + } + ], + "format": { + "format_name":"matroska,webm", + "format_long_name":"Matroska / WebM", + "start_time":"0.000000", + "duration":"7200.125000", + "size":"42949672960", + "bit_rate":"47721858" + } +}`) + + media, err := parseFFProbe(body) + if err != nil { + t.Fatal(err) + } + if !media.Complete || media.Container != "mkv" || media.DurationMS != 7_200_125 || media.Size != 42_949_672_960 { + t.Fatalf("media summary = %#v", media) + } + if media.VideoCodec != "hevc" || media.VideoResolution != "4k" || media.Width != 3840 || media.Height != 2160 { + t.Fatalf("video summary = %#v", media) + } + video := media.Streams[0] + if video.BitDepth != 10 || video.HDRFormat != "dolby_vision" || video.DolbyVision == nil || video.DolbyVision.Profile != 8 || video.ContentLightLevel == nil || video.ContentLightLevel.MaxContent != 1000 { + t.Fatalf("video stream = %#v", video) + } + audio := media.Streams[1] + if !audio.Atmos || audio.Channels != 8 || audio.Language != "eng" || !audio.Disposition.Default { + t.Fatalf("audio stream = %#v", audio) + } + subtitle := media.Streams[2] + if subtitle.Language != "zho" || !subtitle.Disposition.Forced { + t.Fatalf("subtitle stream = %#v", subtitle) + } +} + +func TestParseFFProbeRejectsIncompleteVideo(t *testing.T) { + _, err := parseFFProbe([]byte(`{ + "streams":[{"index":0,"codec_type":"video","codec_name":"hevc"}], + "format":{"format_name":"matroska","duration":"60"} + }`)) + if !errors.Is(err, ErrProbeIncomplete) { + t.Fatalf("parseFFProbe() error = %v", err) + } +} + +func TestFFProberRejectsCredentialedTarget(t *testing.T) { + prober := &FFProber{binary: "ffprobe", timeout: defaultProbeTimeout, probeSize: defaultProbeSize, analyzeDuration: defaultAnalyzeDuration, outputLimit: defaultProbeOutputLimit} + _, err := prober.Probe(t.Context(), "https://user:secret@example.test/movie.mkv", "Infuse-Library/8.4.4") + if !errors.Is(err, ErrProbeTarget) { + t.Fatalf("Probe() error = %v", err) + } +} + +func TestFFProberAgainstHTTPMedia(t *testing.T) { + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + t.Skip("ffmpeg is unavailable") + } + ffprobe, err := exec.LookPath("ffprobe") + if err != nil { + t.Skip("ffprobe is unavailable") + } + + directory := t.TempDir() + mediaPath := filepath.Join(directory, "sample.mkv") + command := exec.CommandContext(t.Context(), ffmpeg, + "-v", "error", + "-f", "lavfi", "-i", "color=c=black:s=320x180:r=24:d=1", + "-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:duration=1", + "-c:v", "mpeg4", "-c:a", "aac", "-shortest", mediaPath, + ) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("generate media: %v: %s", err, output) + } + + server := httptest.NewServer(http.FileServer(http.Dir(directory))) + defer server.Close() + prober, err := NewFFProber(FFProbeOptions{ + Binary: ffprobe, Timeout: 5 * time.Second, ProbeSize: 2 << 20, + AnalyzeDuration: time.Second, OutputLimit: 1 << 20, + }) + if err != nil { + t.Fatal(err) + } + media, err := prober.Probe(t.Context(), server.URL+"/sample.mkv", "Infuse-Library/8.4.4") + if err != nil { + t.Fatal(err) + } + if !media.Complete || media.Container != "mkv" || media.DurationMS <= 0 { + t.Fatalf("media summary = %#v", media) + } + if media.VideoCodec != "mpeg4" || media.Width != 320 || media.Height != 180 || media.AudioCodec != "aac" { + t.Fatalf("stream summary = %#v", media) + } +} + +func TestFFProberPreservesCancellation(t *testing.T) { + prober := newScriptProber(t, "exec sleep 5", 5*time.Second, defaultProbeOutputLimit) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err := prober.Probe(ctx, "https://example.test/movie.mkv", "Infuse-Library/8.4.4") + if !errors.Is(err, context.Canceled) { + t.Fatalf("Probe() error = %v", err) + } +} + +func TestFFProberReportsTimeout(t *testing.T) { + prober := newScriptProber(t, "exec sleep 5", 20*time.Millisecond, defaultProbeOutputLimit) + _, err := prober.Probe(t.Context(), "https://example.test/movie.mkv", "Infuse-Library/8.4.4") + if !errors.Is(err, ErrProbeTimeout) { + t.Fatalf("Probe() error = %v", err) + } +} + +func TestFFProberBoundsProcessOutput(t *testing.T) { + prober := newScriptProber(t, "while true; do echo 0123456789; done", 5*time.Second, 1) + _, err := prober.Probe(t.Context(), "https://example.test/movie.mkv", "Infuse-Library/8.4.4") + if !errors.Is(err, ErrProbeOutput) { + t.Fatalf("Probe() error = %v", err) + } +} + +func newScriptProber(t *testing.T, body string, timeout time.Duration, outputLimit int64) *FFProber { + t.Helper() + path := filepath.Join(t.TempDir(), "ffprobe-test") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o700); err != nil { + t.Fatal(err) + } + prober, err := NewFFProber(FFProbeOptions{ + Binary: path, Timeout: timeout, ProbeSize: defaultProbeSize, + AnalyzeDuration: defaultAnalyzeDuration, OutputLimit: outputLimit, + }) + if err != nil { + t.Fatal(err) + } + return prober +} diff --git a/internal/mediainfo/model.go b/internal/mediainfo/model.go new file mode 100644 index 0000000..5164732 --- /dev/null +++ b/internal/mediainfo/model.go @@ -0,0 +1,269 @@ +// Package mediainfo owns the gateway's rebuildable technical-media records and +// the bounded analysis lifecycle that produces them. +package mediainfo + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/url" + "os" + "strings" + "time" +) + +const ( + // SchemaVersion changes when the durable record contract is incompatible. + SchemaVersion = 1 + // ProviderMediaVaultFFProbe identifies the redirect plus bounded ffprobe path. + ProviderMediaVaultFFProbe = "mediavault-ffprobe" + // ProviderRevisionFFProbeJSONV1 identifies the normalized ffprobe contract. + ProviderRevisionFFProbeJSONV1 = "ffprobe-json-v1" + maxSTRMFingerprintBytes = 64 << 10 +) + +// Key identifies one analysis result without depending on a short-lived CDN +// URL. A changed STRM control file creates a new key for the same Plex Part. +type Key struct { + PlexServerID string `json:"plex_server_id"` + PartID string `json:"part_id"` + STRMFingerprint string `json:"strm_fingerprint"` +} + +// Validate rejects keys that cannot safely participate in persistent cache +// identity or singleflight deduplication. +func (key Key) Validate() error { + if strings.TrimSpace(key.PlexServerID) == "" { + return errors.New("Plex server identity is required") + } + if strings.TrimSpace(key.PartID) == "" { + return errors.New("Plex Part identity is required") + } + if len(key.STRMFingerprint) != sha256.Size*2 { + return errors.New("STRM fingerprint must be a SHA-256 digest") + } + if _, err := hex.DecodeString(key.STRMFingerprint); err != nil { + return errors.New("STRM fingerprint must be hexadecimal") + } + return nil +} + +func (key Key) cacheKey() string { + return key.PlexServerID + "\x00" + key.PartID + "\x00" + key.STRMFingerprint +} + +// Record is the durable, rebuildable result for one exact Plex Part and STRM +// fingerprint. Signed media URLs and Plex credentials are never retained. +type Record struct { + Key Key `json:"key"` + RatingKey string `json:"rating_key,omitempty"` + Provider string `json:"provider"` + ContentFingerprint string `json:"content_fingerprint,omitempty"` + SchemaVersion int `json:"schema_version"` + ProviderRevision string `json:"provider_revision"` + Media Media `json:"media"` + ProbedAt time.Time `json:"probed_at"` + ExpiresAt time.Time `json:"expires_at"` + LastAccessedAt time.Time `json:"last_accessed_at"` + RetainUntil time.Time `json:"retain_until"` +} + +// Retained reports whether an identity-matched known-good record remains +// usable as stale fallback after its freshness window expires. +func (record Record) Retained(now time.Time) bool { + return record.Key.Validate() == nil && + strings.TrimSpace(record.Provider) != "" && + strings.TrimSpace(record.ProviderRevision) != "" && + record.SchemaVersion == SchemaVersion && + record.Media.Complete && + !record.ProbedAt.IsZero() && + record.ExpiresAt.After(record.ProbedAt) && + !record.LastAccessedAt.IsZero() && + !record.RetainUntil.Before(record.ExpiresAt) && + record.RetainUntil.After(record.LastAccessedAt) && + now.Before(record.RetainUntil) +} + +// Fresh reports whether a retained record can satisfy a request without +// scheduling conservative background revalidation. +func (record Record) Fresh(now time.Time) bool { + return record.Retained(now) && now.Before(record.ExpiresAt) +} + +// Media contains the technical fields needed for Plex Media, Part, and Stream +// projection. Complete means the bounded probe obtained a trustworthy minimum +// record; incomplete output is never stored as a successful result. +type Media struct { + Complete bool `json:"complete"` + Container string `json:"container,omitempty"` + FormatName string `json:"format_name,omitempty"` + FormatLongName string `json:"format_long_name,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` + StartTimeMS int64 `json:"start_time_ms,omitempty"` + Size int64 `json:"size,omitempty"` + Bitrate int64 `json:"bitrate,omitempty"` + VideoCodec string `json:"video_codec,omitempty"` + VideoProfile string `json:"video_profile,omitempty"` + VideoResolution string `json:"video_resolution,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + FrameRate string `json:"frame_rate,omitempty"` + AudioCodec string `json:"audio_codec,omitempty"` + AudioProfile string `json:"audio_profile,omitempty"` + AudioChannels int `json:"audio_channels,omitempty"` + Streams []Stream `json:"streams"` +} + +// Stream is a normalized ffprobe stream. Fields remain codec-neutral so new +// formats can be cached without a schema migration before Plex mapping exists. +type Stream struct { + Index int `json:"index"` + Type string `json:"type"` + Codec string `json:"codec,omitempty"` + CodecLongName string `json:"codec_long_name,omitempty"` + CodecTag string `json:"codec_tag,omitempty"` + Profile string `json:"profile,omitempty"` + Level int `json:"level,omitempty"` + TimeBase string `json:"time_base,omitempty"` + StartTimeMS int64 `json:"start_time_ms,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` + Bitrate int64 `json:"bitrate,omitempty"` + FrameCount int64 `json:"frame_count,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + SampleAspectRatio string `json:"sample_aspect_ratio,omitempty"` + DisplayAspectRatio string `json:"display_aspect_ratio,omitempty"` + FrameRate string `json:"frame_rate,omitempty"` + AverageFrameRate string `json:"average_frame_rate,omitempty"` + FieldOrder string `json:"field_order,omitempty"` + ReferenceFrames int `json:"reference_frames,omitempty"` + PixelFormat string `json:"pixel_format,omitempty"` + BitDepth int `json:"bit_depth,omitempty"` + ColorSpace string `json:"color_space,omitempty"` + ColorRange string `json:"color_range,omitempty"` + ColorPrimaries string `json:"color_primaries,omitempty"` + ColorTransfer string `json:"color_transfer,omitempty"` + ChromaLocation string `json:"chroma_location,omitempty"` + HDRFormat string `json:"hdr_format,omitempty"` + DolbyVision *DolbyVision `json:"dolby_vision,omitempty"` + MasteringDisplay *MasteringDisplay `json:"mastering_display,omitempty"` + ContentLightLevel *ContentLightLevel `json:"content_light_level,omitempty"` + SampleFormat string `json:"sample_format,omitempty"` + SampleRate int `json:"sample_rate,omitempty"` + Channels int `json:"channels,omitempty"` + ChannelLayout string `json:"channel_layout,omitempty"` + AudioServiceType string `json:"audio_service_type,omitempty"` + Atmos bool `json:"atmos,omitempty"` + DTSX bool `json:"dtsx,omitempty"` + Language string `json:"language,omitempty"` + Title string `json:"title,omitempty"` + Disposition Disposition `json:"disposition"` +} + +// DolbyVision preserves the DOVI configuration needed for later Plex mapping. +type DolbyVision struct { + VersionMajor int `json:"version_major,omitempty"` + VersionMinor int `json:"version_minor,omitempty"` + Profile int `json:"profile,omitempty"` + Level int `json:"level,omitempty"` + RPUPresent int `json:"rpu_present,omitempty"` + ELPresent int `json:"el_present,omitempty"` + BLPresent int `json:"bl_present,omitempty"` + BLCompatID int `json:"bl_compat_id,omitempty"` +} + +// MasteringDisplay retains ffprobe's mastering-display values without lossy +// floating-point conversion. Plex mapping can consume them when supported. +type MasteringDisplay struct { + RedX string `json:"red_x,omitempty"` + RedY string `json:"red_y,omitempty"` + GreenX string `json:"green_x,omitempty"` + GreenY string `json:"green_y,omitempty"` + BlueX string `json:"blue_x,omitempty"` + BlueY string `json:"blue_y,omitempty"` + WhitePointX string `json:"white_point_x,omitempty"` + WhitePointY string `json:"white_point_y,omitempty"` + MinLuminance string `json:"min_luminance,omitempty"` + MaxLuminance string `json:"max_luminance,omitempty"` +} + +// ContentLightLevel contains HDR MaxCLL and MaxFALL values when present. +type ContentLightLevel struct { + MaxContent int `json:"max_content,omitempty"` + MaxAverage int `json:"max_average,omitempty"` +} + +// Disposition mirrors stable ffprobe stream flags used by Plex clients. +type Disposition struct { + Default bool `json:"default,omitempty"` + Dub bool `json:"dub,omitempty"` + Original bool `json:"original,omitempty"` + Comment bool `json:"comment,omitempty"` + Lyrics bool `json:"lyrics,omitempty"` + Karaoke bool `json:"karaoke,omitempty"` + Forced bool `json:"forced,omitempty"` + HearingImpaired bool `json:"hearing_impaired,omitempty"` + VisualImpaired bool `json:"visual_impaired,omitempty"` + CleanEffects bool `json:"clean_effects,omitempty"` + AttachedPicture bool `json:"attached_picture,omitempty"` + TimedThumbnails bool `json:"timed_thumbnails,omitempty"` + Captions bool `json:"captions,omitempty"` + Descriptions bool `json:"descriptions,omitempty"` + Metadata bool `json:"metadata,omitempty"` + Dependent bool `json:"dependent,omitempty"` + StillImage bool `json:"still_image,omitempty"` +} + +// FingerprintSTRM hashes the normalized first control target. Formatting and a +// MediaVault redirect origin change do not invalidate the same media pointer. +func FingerprintSTRM(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("open STRM control file: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 4096), maxSTRMFingerprintBytes) + for scanner.Scan() { + target := strings.TrimSpace(scanner.Text()) + if target == "" { + continue + } + normalized, err := normalizeSTRMTarget(target) + if err != nil { + return "", err + } + digest := sha256.Sum256([]byte(normalized)) + return hex.EncodeToString(digest[:]), nil + } + if err := scanner.Err(); err != nil { + return "", errors.New("STRM control target exceeds fingerprint limit") + } + return "", errors.New("STRM control file is empty") +} + +func normalizeSTRMTarget(target string) (string, error) { + if strings.HasPrefix(target, "/") { + return "path:" + target, nil + } + parsed, err := url.Parse(target) + if err != nil || parsed == nil || parsed.Host == "" || parsed.User != nil || parsed.Opaque != "" { + return "", errors.New("STRM control target is invalid") + } + parsed.Scheme = strings.ToLower(parsed.Scheme) + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", errors.New("STRM control target must use HTTP(S)") + } + parsed.Fragment = "" + parsed.RawQuery = parsed.Query().Encode() + if parsed.Path == "/redirect" || strings.HasPrefix(parsed.Path, "/redirect/") { + parsed.Scheme = "mediavault" + parsed.Host = "redirect" + parsed.RawPath = "" + } + return parsed.String(), nil +} diff --git a/internal/mediainfo/model_test.go b/internal/mediainfo/model_test.go new file mode 100644 index 0000000..d6739e6 --- /dev/null +++ b/internal/mediainfo/model_test.go @@ -0,0 +1,176 @@ +package mediainfo + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestFingerprintSTRMIsStableAndContentSensitive(t *testing.T) { + path := filepath.Join(t.TempDir(), "movie.strm") + if err := os.WriteFile(path, []byte("https://example.test/redirect/abc/movie.mkv\n"), 0o600); err != nil { + t.Fatal(err) + } + first, err := FingerprintSTRM(path) + if err != nil { + t.Fatal(err) + } + second, err := FingerprintSTRM(path) + if err != nil { + t.Fatal(err) + } + if first != second || len(first) != 64 { + t.Fatalf("fingerprints = %q, %q", first, second) + } + if err := os.WriteFile(path, []byte("https://example.test/redirect/def/movie.mkv\n"), 0o600); err != nil { + t.Fatal(err) + } + changed, err := FingerprintSTRM(path) + if err != nil { + t.Fatal(err) + } + if changed == first { + t.Fatal("fingerprint did not change with STRM content") + } +} + +func TestFingerprintSTRMNormalizesFormattingAndMediaVaultOrigin(t *testing.T) { + directory := t.TempDir() + firstPath := filepath.Join(directory, "first.strm") + secondPath := filepath.Join(directory, "second.strm") + if err := os.WriteFile(firstPath, []byte("\r\nhttps://old.example.test/redirect/pick/movie.mkv?path=%2Fa&pickcode=abc\r\nignored\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(secondPath, []byte("https://new.example.test/redirect/pick/movie.mkv?pickcode=abc&path=%2Fa\n"), 0o600); err != nil { + t.Fatal(err) + } + first, err := FingerprintSTRM(firstPath) + if err != nil { + t.Fatal(err) + } + second, err := FingerprintSTRM(secondPath) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Fatalf("normalized fingerprints differ: %q != %q", first, second) + } +} + +func TestCacheReturnsDetachedFreshRecord(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + record := completeRecord(now) + cache := NewCache([]Record{record}, now) + + got, ok := cache.Get(record.Key, now) + if !ok { + t.Fatal("cache miss") + } + got.Media.Streams[0].Codec = "changed" + got.Media.Streams[0].DolbyVision.Profile = 5 + got.Media.Streams[0].MasteringDisplay.MaxLuminance = "changed" + got.Media.Streams[0].ContentLightLevel.MaxContent = 1 + again, ok := cache.Get(record.Key, now) + if !ok || again.Media.Streams[0].Codec != "hevc" || + again.Media.Streams[0].DolbyVision.Profile != 8 || + again.Media.Streams[0].MasteringDisplay.MaxLuminance != "1000/1" || + again.Media.Streams[0].ContentLightLevel.MaxContent != 1000 { + t.Fatalf("cached record was mutated: %#v", again) + } + if _, ok := cache.Get(record.Key, record.RetainUntil); ok { + t.Fatal("expired record returned") + } +} + +func TestCacheBoundsEntriesAndEvictsLeastRecentlyUsed(t *testing.T) { + now := time.Unix(1_800_000_000, 0).UTC() + first := completeRecord(now.Add(-2 * time.Hour)) + first.Key.PartID = "first" + second := completeRecord(now.Add(-time.Hour)) + second.Key.PartID = "second" + cache := NewCacheWithLimit([]Record{first, second}, now, 2) + if _, ok := cache.Touch(first.Key, now, now.Add(180*24*time.Hour)); !ok { + t.Fatal("failed to touch first record") + } + third := completeRecord(now) + third.Key.PartID = "third" + if !cache.Put(third, now) || cache.Len() != 2 { + t.Fatalf("cache size = %d", cache.Len()) + } + if _, ok := cache.GetKnown(second.Key, now); ok { + t.Fatal("least recently used record was not evicted") + } + if _, ok := cache.GetKnown(first.Key, now); !ok { + t.Fatal("recently touched record was evicted") + } +} + +func TestCacheTouchExtendsRetentionWithoutRefreshingMedia(t *testing.T) { + now := time.Unix(1_800_000_000, 0).UTC() + record := completeRecord(now.Add(-179 * 24 * time.Hour)) + record.ExpiresAt = now.Add(-149 * 24 * time.Hour) + record.RetainUntil = now.Add(24 * time.Hour) + cache := NewCache([]Record{record}, now) + touched, ok := cache.Touch(record.Key, now, now.Add(180*24*time.Hour)) + if !ok || !touched.RetainUntil.Equal(now.Add(180*24*time.Hour)) { + t.Fatalf("Touch() = %#v, %t", touched, ok) + } + if touched.Fresh(now) { + t.Fatal("retention touch incorrectly refreshed probe freshness") + } +} + +func TestCacheDoesNotPromoteUnretainedEntry(t *testing.T) { + createdAt := time.Unix(1_800_000_000, 0).UTC() + expired := completeRecord(createdAt) + expired.Key.PartID = "expired" + expired.ExpiresAt = createdAt.Add(time.Hour) + expired.RetainUntil = createdAt.Add(2 * time.Hour) + retained := completeRecord(createdAt) + retained.Key.PartID = "retained" + retained.LastAccessedAt = createdAt.Add(time.Hour) + cache := NewCacheWithLimit([]Record{expired, retained}, createdAt, 2) + + later := createdAt.Add(3 * time.Hour) + if _, ok := cache.GetKnown(expired.Key, later); ok { + t.Fatal("unretained record returned") + } + inserted := completeRecord(later) + inserted.Key.PartID = "inserted" + if !cache.Put(inserted, later) { + t.Fatal("cache rejected retained record") + } + if _, ok := cache.GetKnown(retained.Key, later); !ok { + t.Fatal("invalid lookup promoted an unretained entry over a retained entry") + } +} + +func completeRecord(now time.Time) Record { + return Record{ + Key: Key{ + PlexServerID: "server-1", + PartID: "123", + STRMFingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, + RatingKey: "456", + Provider: ProviderMediaVaultFFProbe, + ProviderRevision: ProviderRevisionFFProbeJSONV1, + SchemaVersion: SchemaVersion, + Media: Media{ + Complete: true, + Container: "matroska", + DurationMS: 90_000, + Streams: []Stream{{ + Index: 0, Type: "video", Codec: "hevc", Width: 3840, Height: 2160, + DolbyVision: &DolbyVision{Profile: 8}, + MasteringDisplay: &MasteringDisplay{MaxLuminance: "1000/1"}, + ContentLightLevel: &ContentLightLevel{MaxContent: 1000}, + }}, + }, + ProbedAt: now, + ExpiresAt: now.Add(30 * 24 * time.Hour), + LastAccessedAt: now, + RetainUntil: now.Add(180 * 24 * time.Hour), + } +} diff --git a/internal/mediainfo/provider.go b/internal/mediainfo/provider.go new file mode 100644 index 0000000..ea28d20 --- /dev/null +++ b/internal/mediainfo/provider.go @@ -0,0 +1,93 @@ +package mediainfo + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/InfinityPacer/plex-gateway/internal/resolver" +) + +// ProviderRequest contains the ephemeral transport context needed to analyze +// one STRM target. UserAgent is not persisted in the resulting record. +type ProviderRequest struct { + Target string + UserAgent string +} + +// ProviderResult carries normalized media data and optional stronger content +// identity supplied by the backing provider. +type ProviderResult struct { + ContentFingerprint string + Media Media +} + +// ProviderDescriptor identifies one provider output contract. A revision +// change invalidates records whose normalized interpretation is incompatible. +type ProviderDescriptor struct { + Name string + Revision string +} + +func (descriptor ProviderDescriptor) validate() error { + if strings.TrimSpace(descriptor.Name) == "" || strings.TrimSpace(descriptor.Revision) == "" { + return errors.New("MediaInfo provider descriptor is invalid") + } + return nil +} + +// Provider is the replaceable source boundary for MediaInfo. Queueing, +// persistence, freshness, and Plex projection do not depend on its transport. +type Provider interface { + Descriptor() ProviderDescriptor + Probe(context.Context, ProviderRequest) (ProviderResult, error) +} + +// MediaVaultFFProbeProvider resolves a MediaVault STRM control target and probes +// the resulting CDN URL with the same User-Agent. +type MediaVaultFFProbeProvider struct { + resolver resolver.ControlResolver + prober Prober +} + +// NewMediaVaultFFProbeProvider builds the default fallback provider. +func NewMediaVaultFFProbeProvider(controlResolver resolver.ControlResolver, prober Prober) (*MediaVaultFFProbeProvider, error) { + if controlResolver == nil || prober == nil { + return nil, ErrServiceUnavailable + } + return &MediaVaultFFProbeProvider{resolver: controlResolver, prober: prober}, nil +} + +// Descriptor returns the durable compatibility identity for this provider. +func (*MediaVaultFFProbeProvider) Descriptor() ProviderDescriptor { + return ProviderDescriptor{Name: ProviderMediaVaultFFProbe, Revision: ProviderRevisionFFProbeJSONV1} +} + +// Probe forwards only the transport headers required for deterministic +// resolution and probing. Plex credentials never reach the CDN probe. +func (provider *MediaVaultFFProbeProvider) Probe(ctx context.Context, request ProviderRequest) (ProviderResult, error) { + if provider == nil || provider.resolver == nil || provider.prober == nil { + return ProviderResult{}, ErrServiceUnavailable + } + request.Target = strings.TrimSpace(request.Target) + request.UserAgent = strings.TrimSpace(request.UserAgent) + if request.Target == "" || request.UserAgent == "" || strings.ContainsAny(request.UserAgent, "\r\n") { + return ProviderResult{}, errors.New("MediaInfo provider request is invalid") + } + header := make(http.Header) + header.Set("User-Agent", request.UserAgent) + header.Set("Accept-Encoding", "identity") + directURL, err := provider.resolver.ResolveTarget(ctx, request.Target, resolver.PlaybackRequest{ + Method: http.MethodGet, + Header: header, + }) + if err != nil { + return ProviderResult{}, errors.New("resolve MediaInfo target") + } + media, err := provider.prober.Probe(ctx, directURL.String(), request.UserAgent) + if err != nil { + return ProviderResult{}, err + } + return ProviderResult{Media: media}, nil +} diff --git a/internal/mediainfo/provider_test.go b/internal/mediainfo/provider_test.go new file mode 100644 index 0000000..9e804d4 --- /dev/null +++ b/internal/mediainfo/provider_test.go @@ -0,0 +1,63 @@ +package mediainfo + +import ( + "context" + "net/http" + "testing" + + "github.com/InfinityPacer/plex-gateway/internal/resolver" +) + +type capturingResolver struct { + playback resolver.PlaybackRequest +} + +func (capture *capturingResolver) ReadTarget(string) (string, error) { return "", nil } + +func (capture *capturingResolver) ResolveTarget(_ context.Context, _ string, playback resolver.PlaybackRequest) (resolver.DirectURL, error) { + capture.playback = playback + return resolver.DirectURL{URL: "https://cdn.example.test/movie.mkv"}, nil +} + +type capturingProber struct { + target string + userAgent string +} + +func (capture *capturingProber) Probe(_ context.Context, target, userAgent string) (Media, error) { + capture.target = target + capture.userAgent = userAgent + return Media{ + Complete: true, Container: "mkv", DurationMS: 60_000, + Streams: []Stream{{Index: 0, Type: "video", Codec: "hevc", Width: 1920, Height: 1080}}, + }, nil +} + +func TestMediaVaultProviderUsesSameClientUserAgentForRedirectAndProbe(t *testing.T) { + control := &capturingResolver{} + probe := &capturingProber{} + provider, err := NewMediaVaultFFProbeProvider(control, probe) + if err != nil { + t.Fatal(err) + } + result, err := provider.Probe(t.Context(), ProviderRequest{ + Target: "pickcode", UserAgent: "Infuse-Library/8.4.4", + }) + if err != nil { + t.Fatal(err) + } + if control.playback.Method != http.MethodGet || + control.playback.Header.Get("User-Agent") != "Infuse-Library/8.4.4" || + control.playback.Header.Get("Accept-Encoding") != "identity" { + t.Fatalf("resolver request = %#v", control.playback) + } + if len(control.playback.Header) != 2 { + t.Fatalf("unexpected resolver headers = %#v", control.playback.Header) + } + 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 { + t.Fatalf("provider result = %#v", result) + } +} diff --git a/internal/mediainfo/service.go b/internal/mediainfo/service.go new file mode 100644 index 0000000..7bd56c3 --- /dev/null +++ b/internal/mediainfo/service.go @@ -0,0 +1,733 @@ +package mediainfo + +import ( + "context" + "errors" + "log/slog" + "strings" + "sync" + "sync/atomic" + "time" +) + +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 +) + +var ( + ErrServiceUnavailable = errors.New("MediaInfo service is unavailable") + ErrQueueFull = errors.New("MediaInfo queue is full") + ErrNegativeCache = errors.New("MediaInfo probe is in backoff") +) + +// Priority separates interactive misses from administrator prewarming. A +// playback-related metadata request must not wait behind season or root scans. +type Priority uint8 + +const ( + PriorityBackground Priority = iota + PriorityInteractive +) + +// Request describes one exact STRM control target. Target is used only for the +// active job and is never persisted in the MediaInfo record. +type Request struct { + Key Key + RatingKey string + Target string + Priority Priority + ClientUserAgent string +} + +func (request Request) validate() error { + if err := request.Key.Validate(); err != nil { + return err + } + 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") + } + return nil +} + +// RecordStore is the successful-result persistence boundary. +type RecordStore interface { + Put(context.Context, Record) error + Get(context.Context, Key) (Record, bool, error) + Touch(context.Context, Key, time.Time, time.Time) 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 { + DeleteUnretained(context.Context, time.Time) (int64, error) +} + +// Prober produces normalized technical metadata for one resolved media URL. +type Prober interface { + Probe(context.Context, string, string) (Media, error) +} + +// ServiceMetrics receives bounded, label-free analysis events. +type ServiceMetrics interface { + IncMediaInfoCacheHits() + IncMediaInfoCacheMisses() + IncMediaInfoProbeQueued() + IncMediaInfoProbeSuccess() + IncMediaInfoProbeFailure() + IncMediaInfoStoreFailure() + IncMediaInfoProbeActive() + DecMediaInfoProbeActive() + ObserveMediaInfoProbeLatency(time.Duration) +} + +// 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 + 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 +} + +// Service schedules bounded MediaInfo work independently from client request +// cancellation. Request waiters may leave while the shared job finishes. +type Service struct { + cache *Cache + store RecordStore + janitor RecordJanitor + provider Provider + providerDescriptor ProviderDescriptor + plexServerID string + logger *slog.Logger + metrics ServiceMetrics + probeTimeout time.Duration + recordTTL time.Duration + recordRetention time.Duration + negativeTTL time.Duration + negativeLimit int + backgroundUserAgent string + gcInterval time.Duration + touchInterval time.Duration + now func() time.Time + + ctx context.Context + cancel context.CancelFunc + workers sync.WaitGroup + workerDone chan struct{} + touches chan recordTouch + + 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 +} + +type recordTouch struct { + key Key + accessedAt time.Time + retainUntil time.Time + reservation touchReservation +} + +type flight struct { + done chan struct{} + job *job + record Record + err error +} + +type job struct { + request Request + flight *flight + claimed bool +} + +// NewService starts the configured worker count after validating every +// required boundary. The caller owns the store lifecycle separately. +func NewService(options ServiceOptions) (*Service, error) { + if options.Cache == nil || options.Store == nil || options.Provider == nil { + return nil, ErrServiceUnavailable + } + concurrency := options.Concurrency + if concurrency <= 0 { + concurrency = defaultWorkerCount + } + interactiveSize := options.InteractiveQueueSize + if interactiveSize <= 0 { + interactiveSize = defaultWorkerQueueSize + } + backgroundSize := options.BackgroundQueueSize + if backgroundSize <= 0 { + backgroundSize = defaultWorkerQueueSize + } + probeTimeout := options.ProbeTimeout + if probeTimeout <= 0 { + probeTimeout = defaultProbeTimeout + } + recordTTL := options.RecordTTL + if recordTTL <= 0 { + recordTTL = defaultRecordTTL + } + negativeTTL := options.NegativeTTL + if negativeTTL <= 0 { + negativeTTL = defaultNegativeTTL + } + recordRetention := options.RecordRetention + if recordRetention <= 0 { + recordRetention = defaultRecordRetention + } + if recordRetention < recordTTL { + return nil, errors.New("MediaInfo record retention must not be shorter than freshness TTL") + } + backgroundUserAgent := strings.TrimSpace(options.BackgroundUserAgent) + if backgroundUserAgent == "" || strings.ContainsAny(backgroundUserAgent, "\r\n") { + return nil, errors.New("MediaInfo background User-Agent is invalid") + } + gcInterval := options.GCInterval + if gcInterval <= 0 { + gcInterval = defaultGCInterval + } + touchInterval := options.TouchInterval + if touchInterval <= 0 { + touchInterval = defaultTouchInterval + } + now := options.Now + if now == nil { + now = time.Now + } + logger := options.Logger + if logger == nil { + logger = slog.Default() + } + providerDescriptor := options.Provider.Descriptor() + if err := providerDescriptor.validate(); err != nil { + return nil, err + } + plexServerID := strings.TrimSpace(options.PlexServerID) + if plexServerID == "" || strings.ContainsAny(plexServerID, "\x00\r\n") { + return nil, errors.New("MediaInfo Plex server identity is invalid") + } + ctx, cancel := context.WithCancel(context.Background()) + service := &Service{ + cache: options.Cache, store: options.Store, janitor: options.Janitor, + provider: options.Provider, providerDescriptor: providerDescriptor, plexServerID: plexServerID, + logger: logger, metrics: options.Metrics, probeTimeout: probeTimeout, + recordTTL: recordTTL, recordRetention: recordRetention, + negativeTTL: negativeTTL, negativeLimit: defaultNegativeLimit, + 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, + flights: make(map[string]*flight), negative: make(map[string]time.Time), + } + service.condition = sync.NewCond(&service.mu) + for index := 0; index < concurrency; index++ { + service.workers.Add(1) + go service.worker() + } + service.workers.Add(1) + go service.touchWorker() + if service.janitor != nil { + service.workers.Add(1) + go service.janitorWorker() + } + go func() { + service.workers.Wait() + close(service.workerDone) + }() + return service, nil +} + +// Get returns one exact fresh L1 record and renews its retention asynchronously. +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) + if ok { + record = service.touchCached(record, now) + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoCacheHits() }) + } else { + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoCacheMisses() }) + } + return record, ok +} + +// 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. +func (service *Service) Ensure(ctx context.Context, request Request) (Record, error) { + if service == nil { + return Record{}, ErrServiceUnavailable + } + if ctx == nil { + ctx = context.Background() + } + var err error + request, err = service.normalizeRequest(request) + if err != nil { + return Record{}, err + } + record, found, fresh := service.lookup(ctx, request.Key) + if found { + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoCacheHits() }) + if !fresh { + revalidation := request + revalidation.Priority = PriorityBackground + _, _ = service.begin(revalidation) + } + return record, nil + } + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoCacheMisses() }) + flight, err := service.begin(request) + if err != nil { + return Record{}, err + } + select { + case <-ctx.Done(): + return Record{}, ctx.Err() + case <-flight.done: + return cloneRecord(flight.record), flight.err + } +} + +// Submit schedules work without creating a request-bound waiter. +func (service *Service) Submit(request Request) error { + if service == nil { + return ErrServiceUnavailable + } + request, err := service.normalizeRequest(request) + if err != nil { + return err + } + _, found, fresh := service.lookup(service.ctx, request.Key) + if found && fresh { + return nil + } + if found { + request.Priority = PriorityBackground + } + _, err = service.begin(request) + return err +} + +func (service *Service) normalizeRequest(request Request) (Request, error) { + if strings.TrimSpace(request.Key.PlexServerID) == "" { + request.Key.PlexServerID = service.plexServerID + } + if request.Key.PlexServerID != service.plexServerID { + return Request{}, errors.New("MediaInfo request belongs to another Plex server") + } + if err := request.validate(); err != nil { + return Request{}, err + } + return request, nil +} + +func (service *Service) lookup(ctx context.Context, key Key) (Record, bool, bool) { + now := service.now().UTC() + if record, ok := service.cache.GetKnown(key, now); ok && service.compatible(record) { + record = service.touchCached(record, now) + return record, true, record.Fresh(now) + } + record, found, err := service.store.Get(ctx, key) + if err != nil { + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoStoreFailure() }) + service.logger.Warn("mediainfo_store_read_failed", "part", key.PartID, "error_kind", "storage") + return Record{}, false, false + } + if !found || !record.Retained(now) || !service.compatible(record) { + return Record{}, false, false + } + service.cache.Put(record, now) + record = service.touchCached(record, now) + return record, true, record.Fresh(now) +} + +func (service *Service) compatible(record Record) bool { + return record.Provider == service.providerDescriptor.Name && + record.ProviderRevision == service.providerDescriptor.Revision +} + +func (service *Service) touchCached(record Record, now time.Time) Record { + retainUntil := now.Add(service.recordRetention) + touched, ok := service.cache.Touch(record.Key, now, retainUntil) + if !ok { + return record + } + reservation, persist := service.cache.reserveTouch(record.Key, now, service.touchInterval) + if !persist { + return touched + } + service.mu.Lock() + if service.closed { + service.mu.Unlock() + reservation.release() + return touched + } + select { + case service.touches <- recordTouch{ + key: record.Key, accessedAt: now, retainUntil: retainUntil, reservation: reservation, + }: + default: + reservation.release() + } + service.mu.Unlock() + return touched +} + +func (service *Service) begin(request Request) (*flight, error) { + if service == nil { + return nil, ErrServiceUnavailable + } + var err error + request, err = service.normalizeRequest(request) + if err != nil { + return nil, err + } + key := request.Key.cacheKey() + now := service.now() + service.mu.Lock() + if service.closed { + service.mu.Unlock() + return nil, ErrServiceUnavailable + } + if until, exists := service.negative[key]; exists { + if now.Before(until) { + service.mu.Unlock() + return nil, ErrNegativeCache + } + delete(service.negative, key) + } + if existing, exists := service.flights[key]; exists { + if request.Priority == PriorityInteractive { + service.promoteLocked(existing.job) + } + service.mu.Unlock() + return existing, nil + } + flight := &flight{done: make(chan struct{})} + queued := &job{request: request, flight: flight} + flight.job = queued + if !service.enqueueLocked(queued) { + service.mu.Unlock() + return nil, ErrQueueFull + } + service.flights[key] = flight + service.condition.Signal() + service.mu.Unlock() + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeQueued() }) + return flight, nil +} + +func (service *Service) enqueueLocked(queued *job) bool { + if queued.request.Priority == PriorityInteractive { + if len(service.interactive) >= service.interactiveLimit { + return false + } + service.interactive = append(service.interactive, queued) + return true + } + if len(service.background) >= service.backgroundLimit { + 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 + } + for index, candidate := range service.background { + if candidate != queued { + 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() + return + } +} + +func (service *Service) worker() { + defer service.workers.Done() + for { + queued, ok := service.nextJob() + if !ok { + return + } + service.run(queued) + } +} + +func (service *Service) touchWorker() { + defer service.workers.Done() + for { + select { + case <-service.ctx.Done(): + return + case touch := <-service.touches: + ctx, cancel := context.WithTimeout(service.ctx, defaultStoreTimeout) + err := service.store.Touch(ctx, touch.key, touch.accessedAt, touch.retainUntil) + cancel() + if err != nil { + touch.reservation.release() + } + if err != nil && !errors.Is(err, context.Canceled) { + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoStoreFailure() }) + service.logger.Warn("mediainfo_store_touch_failed", "part", touch.key.PartID, "error_kind", "storage") + } + } + } +} + +func (service *Service) janitorWorker() { + defer service.workers.Done() + ticker := time.NewTicker(service.gcInterval) + defer ticker.Stop() + for { + select { + case <-service.ctx.Done(): + return + case now := <-ticker.C: + ctx, cancel := context.WithTimeout(service.ctx, defaultStoreTimeout) + _, err := service.janitor.DeleteUnretained(ctx, now.UTC()) + cancel() + if err != nil && !errors.Is(err, context.Canceled) { + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoStoreFailure() }) + service.logger.Warn("mediainfo_store_gc_failed", "error_kind", "storage") + } + } + } +} + +func (service *Service) nextJob() (*job, bool) { + service.mu.Lock() + defer service.mu.Unlock() + for !service.closed && len(service.interactive) == 0 && len(service.background) == 0 { + service.condition.Wait() + } + if service.closed { + return nil, false + } + 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:] + } + queued.claimed = true + return queued, true +} + +func (service *Service) run(queued *job) { + started := service.now() + service.active.Add(1) + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeActive() }) + defer func() { + service.active.Add(-1) + service.metric(func(metrics ServiceMetrics) { metrics.DecMediaInfoProbeActive() }) + service.metric(func(metrics ServiceMetrics) { metrics.ObserveMediaInfoProbeLatency(service.now().Sub(started)) }) + }() + + probeCtx, cancel := context.WithTimeout(service.ctx, service.probeTimeout) + defer cancel() + userAgent := strings.TrimSpace(queued.request.ClientUserAgent) + if userAgent == "" { + userAgent = service.backgroundUserAgent + } + result, err := service.provider.Probe(probeCtx, ProviderRequest{ + Target: queued.request.Target, UserAgent: userAgent, + }) + if err != nil { + service.finish(queued, Record{}, err) + return + } + if err := probeCtx.Err(); err != nil { + service.finish(queued, Record{}, err) + return + } + if !result.Media.Complete { + service.finish(queued, Record{}, ErrProbeIncomplete) + return + } + now := service.now().UTC() + record := Record{ + Key: queued.request.Key, RatingKey: queued.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), + LastAccessedAt: now, RetainUntil: now.Add(service.recordRetention), + } + if !record.Retained(now) { + service.finish(queued, Record{}, ErrProbeIncomplete) + return + } + storeCtx, cancelStore := context.WithTimeout(service.ctx, defaultStoreTimeout) + if err := service.store.Put(storeCtx, record); err != nil { + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoStoreFailure() }) + service.logger.Warn("mediainfo_store_failed", "part", queued.request.Key.PartID, "error_kind", "storage") + } + cancelStore() + service.cache.Put(record, now) + service.finish(queued, record, nil) +} + +func (service *Service) finish(queued *job, record Record, err error) { + key := queued.request.Key.cacheKey() + service.mu.Lock() + flight := service.flights[key] + if flight != queued.flight { + service.mu.Unlock() + return + } + delete(service.flights, key) + if err != nil && !errors.Is(err, context.Canceled) { + service.rememberNegativeLocked(key, service.now()) + } + flight.record = record + flight.err = err + close(flight.done) + service.mu.Unlock() + 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)) + return + } + service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeSuccess() }) +} + +func (service *Service) rememberNegativeLocked(key string, now time.Time) { + for candidate, until := range service.negative { + if !now.Before(until) { + delete(service.negative, candidate) + } + } + if len(service.negative) >= service.negativeLimit { + var earliestKey string + var earliest time.Time + for candidate, until := range service.negative { + if earliestKey == "" || until.Before(earliest) { + earliestKey = candidate + earliest = until + } + } + delete(service.negative, earliestKey) + } + service.negative[key] = now.Add(service.negativeTTL) +} + +// Status is a credential-free operational snapshot. +type Status struct { + Available bool `json:"available"` + CacheEntries int `json:"cache_entries"` + ActiveProbes int64 `json:"active_probes"` + InteractiveQueued int `json:"interactive_queued"` + BackgroundQueued int `json:"background_queued"` + NegativeEntries int `json:"negative_entries"` +} + +// Status returns bounded queue and cache counts without media identities. +func (service *Service) Status() Status { + if service == nil { + return Status{} + } + service.mu.Lock() + 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), + } + service.mu.Unlock() + return status +} + +// Close cancels active processes and waits for workers without closing the +// caller-owned persistent store. +func (service *Service) Close(ctx context.Context) error { + if service == nil { + return nil + } + service.mu.Lock() + if !service.closed { + service.closed = true + service.cancel() + for { + select { + case touch := <-service.touches: + touch.reservation.release() + default: + goto touchesReleased + } + } + touchesReleased: + for key, flight := range service.flights { + delete(service.flights, key) + flight.err = ErrServiceUnavailable + close(flight.done) + } + service.interactive = nil + service.background = nil + service.condition.Broadcast() + } + service.mu.Unlock() + if ctx == nil { + ctx = context.Background() + } + select { + case <-service.workerDone: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (service *Service) metric(call func(ServiceMetrics)) { + if service != nil && service.metrics != nil { + call(service.metrics) + } +} diff --git a/internal/mediainfo/service_test.go b/internal/mediainfo/service_test.go new file mode 100644 index 0000000..4e77706 --- /dev/null +++ b/internal/mediainfo/service_test.go @@ -0,0 +1,492 @@ +package mediainfo + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" +) + +type fakeRecordStore struct { + mu sync.Mutex + records map[string]Record + touches atomic.Int64 +} + +func (store *fakeRecordStore) Put(_ context.Context, record Record) error { + store.mu.Lock() + defer store.mu.Unlock() + if store.records == nil { + store.records = make(map[string]Record) + } + store.records[record.Key.cacheKey()] = cloneRecord(record) + return nil +} + +func (store *fakeRecordStore) Get(_ context.Context, key Key) (Record, bool, error) { + store.mu.Lock() + defer store.mu.Unlock() + record, ok := store.records[key.cacheKey()] + return cloneRecord(record), ok, nil +} + +func (store *fakeRecordStore) Touch(_ context.Context, key Key, accessedAt, retainUntil time.Time) error { + store.touches.Add(1) + store.mu.Lock() + defer store.mu.Unlock() + record, ok := store.records[key.cacheKey()] + if !ok { + return nil + } + if accessedAt.After(record.LastAccessedAt) { + record.LastAccessedAt = accessedAt + record.RetainUntil = retainUntil + store.records[key.cacheKey()] = record + } + return nil +} + +type fakeProvider struct { + calls atomic.Int64 + prober Prober + userAgents chan string +} + +func (*fakeProvider) Descriptor() ProviderDescriptor { + return ProviderDescriptor{Name: ProviderMediaVaultFFProbe, Revision: ProviderRevisionFFProbeJSONV1} +} + +func (fake *fakeProvider) Probe(ctx context.Context, request ProviderRequest) (ProviderResult, error) { + fake.calls.Add(1) + if fake.userAgents != nil { + fake.userAgents <- request.UserAgent + } + media, err := fake.prober.Probe(ctx, "https://cdn.example.test/"+request.Target, request.UserAgent) + if err != nil { + return ProviderResult{}, err + } + return ProviderResult{Media: media}, nil +} + +type blockingProber struct { + calls atomic.Int64 + started chan string + release chan struct{} +} + +func (prober *blockingProber) Probe(ctx context.Context, target, _ string) (Media, error) { + prober.calls.Add(1) + if prober.started != nil { + prober.started <- target + } + if prober.release != nil { + select { + case <-ctx.Done(): + return Media{}, ctx.Err() + case <-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 TestServiceSingleflightsMultipleClientsForSamePart(t *testing.T) { + prober := &blockingProber{release: make(chan struct{})} + service := newTestService(t, prober, 1) + request := testRequest("same", PriorityInteractive) + + results := make(chan error, 2) + for range 2 { + go func() { + _, err := service.Ensure(t.Context(), request) + results <- err + }() + } + waitFor(t, func() bool { return prober.calls.Load() == 1 }) + close(prober.release) + for range 2 { + if err := <-results; err != nil { + t.Fatal(err) + } + } + if prober.calls.Load() != 1 { + t.Fatalf("probe calls = %d", prober.calls.Load()) + } +} + +func TestServiceOwnsPlexServerIdentity(t *testing.T) { + provider := &fakeProvider{prober: &blockingProber{}} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, Provider: provider, + PlexServerID: "server", + }) + request := testRequest("identity", PriorityInteractive) + request.Key.PlexServerID = "" + record, err := service.Ensure(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if record.Key.PlexServerID != "server" { + t.Fatalf("record Plex server identity = %q", record.Key.PlexServerID) + } + + request.Key.PlexServerID = "other-server" + if _, err := service.Ensure(t.Context(), request); err == nil { + t.Fatal("request for another Plex server was accepted") + } + if provider.calls.Load() != 1 { + t.Fatalf("provider calls = %d", provider.calls.Load()) + } +} + +func TestServiceClientWaitCancellationDoesNotCancelBackgroundJob(t *testing.T) { + prober := &blockingProber{release: make(chan struct{})} + service := newTestService(t, prober, 1) + request := testRequest("detached", PriorityInteractive) + + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + if _, err := service.Ensure(ctx, request); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Ensure() error = %v", err) + } + close(prober.release) + waitFor(t, func() bool { + _, ok := service.Get(request.Key) + return ok + }) +} + +func TestServicePrioritizesRapidInteractiveSwitchesOverPrewarm(t *testing.T) { + prober := &blockingProber{started: make(chan string, 4), release: make(chan struct{}, 4)} + service := newTestService(t, prober, 1) + if err := service.Submit(testRequest("A", PriorityBackground)); err != nil { + t.Fatal(err) + } + if got := <-prober.started; got != "https://cdn.example.test/A" { + t.Fatalf("first target = %q", got) + } + if err := service.Submit(testRequest("prewarm", PriorityBackground)); err != nil { + t.Fatal(err) + } + if err := service.Submit(testRequest("B", PriorityInteractive)); err != nil { + t.Fatal(err) + } + prober.release <- struct{}{} + if got := <-prober.started; got != "https://cdn.example.test/B" { + t.Fatalf("next target = %q, want interactive B", got) + } + prober.release <- struct{}{} + if got := <-prober.started; got != "https://cdn.example.test/prewarm" { + t.Fatalf("third target = %q", got) + } + prober.release <- struct{}{} +} + +func TestServicePromotesBackgroundJobWithoutLeavingDuplicate(t *testing.T) { + prober := &blockingProber{started: make(chan string, 2), release: make(chan struct{}, 2)} + service := newTestService(t, prober, 1) + if err := service.Submit(testRequest("active", PriorityBackground)); err != nil { + t.Fatal(err) + } + <-prober.started + background := testRequest("promoted", PriorityBackground) + if err := service.Submit(background); err != nil { + t.Fatal(err) + } + interactive := background + interactive.Priority = PriorityInteractive + flight, err := service.begin(interactive) + if err != nil { + t.Fatal(err) + } + status := service.Status() + if status.InteractiveQueued != 1 || status.BackgroundQueued != 0 { + t.Fatalf("queue status after promotion = %#v", status) + } + + prober.release <- struct{}{} + if got := <-prober.started; got != "https://cdn.example.test/promoted" { + t.Fatalf("promoted target = %q", got) + } + prober.release <- struct{}{} + select { + case <-flight.done: + case <-time.After(time.Second): + t.Fatal("promoted flight did not finish") + } + waitFor(t, func() bool { + status := service.Status() + return status.InteractiveQueued == 0 && status.BackgroundQueued == 0 + }) +} + +func TestServiceCloseCompletesQueuedFlights(t *testing.T) { + prober := &blockingProber{started: make(chan string, 1), release: make(chan struct{})} + service := newTestService(t, prober, 1) + if err := service.Submit(testRequest("active", PriorityInteractive)); err != nil { + t.Fatal(err) + } + <-prober.started + flight, err := service.begin(testRequest("queued", PriorityBackground)) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + if err := service.Close(ctx); err != nil { + t.Fatal(err) + } + select { + case <-flight.done: + if !errors.Is(flight.err, ErrServiceUnavailable) { + t.Fatalf("queued flight error = %v", flight.err) + } + default: + t.Fatal("queued flight was not completed during close") + } +} + +func TestServiceRejectsWorkWhenPriorityQueueIsFull(t *testing.T) { + prober := &blockingProber{started: make(chan string, 1), release: make(chan struct{})} + service := newTestServiceWithQueues(t, prober, 1, 1, 1) + if err := service.Submit(testRequest("active", PriorityBackground)); err != nil { + t.Fatal(err) + } + <-prober.started + if err := service.Submit(testRequest("queued", PriorityBackground)); err != nil { + t.Fatal(err) + } + if err := service.Submit(testRequest("overflow", PriorityBackground)); !errors.Is(err, ErrQueueFull) { + t.Fatalf("Submit() error = %v", err) + } +} + +func TestServiceBoundsAndSweepsNegativeCache(t *testing.T) { + service := newTestService(t, &blockingProber{}, 1) + now := time.Unix(1_800_000_000, 0) + service.mu.Lock() + for index := 0; index < defaultNegativeLimit+100; index++ { + service.rememberNegativeLocked(fmt.Sprintf("key-%d", index), now) + } + if len(service.negative) != defaultNegativeLimit { + service.mu.Unlock() + t.Fatalf("negative cache size = %d", len(service.negative)) + } + service.rememberNegativeLocked("after-expiry", now.Add(service.negativeTTL+time.Second)) + remaining := len(service.negative) + service.mu.Unlock() + if remaining != 1 { + t.Fatalf("negative cache size after sweep = %d", remaining) + } +} + +func TestServiceRestoresFreshL2RecordWithoutProbe(t *testing.T) { + now := time.Unix(1_800_000_000, 0).UTC() + request := testRequest("persisted", PriorityInteractive) + 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, Now: func() time.Time { return now }, + }) + + got, err := service.Ensure(t.Context(), request) + if err != nil || got.Media.VideoCodec != record.Media.VideoCodec { + t.Fatalf("Ensure() = %#v, %v", got, err) + } + if provider.calls.Load() != 0 { + t.Fatalf("provider calls = %d", provider.calls.Load()) + } +} + +func TestServiceReprobesIncompatibleProviderRevision(t *testing.T) { + now := time.Unix(1_800_000_000, 0).UTC() + request := testRequest("old-provider", PriorityInteractive) + record := completeRecord(now) + record.Key = request.Key + record.ProviderRevision = "ffprobe-json-v0" + 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, Now: func() time.Time { return now }, + }) + + got, err := service.Ensure(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if got.ProviderRevision != ProviderRevisionFFProbeJSONV1 || provider.calls.Load() != 1 { + t.Fatalf("Ensure() revision=%q provider_calls=%d", got.ProviderRevision, provider.calls.Load()) + } +} + +func TestServiceReturnsStaleKnownGoodAndRevalidatesInBackground(t *testing.T) { + now := time.Unix(1_800_000_000, 0).UTC() + request := testRequest("stale", PriorityInteractive) + record := completeRecord(now.Add(-31 * 24 * time.Hour)) + record.Key = request.Key + record.RatingKey = request.RatingKey + store := &fakeRecordStore{records: map[string]Record{record.Key.cacheKey(): record}} + prober := &blockingProber{started: make(chan string, 1), release: make(chan struct{})} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, now), Store: store, Provider: &fakeProvider{prober: prober}, + Now: func() time.Time { return now }, + }) + + got, err := service.Ensure(t.Context(), request) + if err != nil || got.Fresh(now) || !got.Retained(now) { + t.Fatalf("Ensure() = %#v, %v", got, err) + } + select { + case <-prober.started: + case <-time.After(time.Second): + t.Fatal("stale record did not schedule background revalidation") + } + close(prober.release) + waitFor(t, func() bool { + fresh, ok := service.Get(request.Key) + return ok && fresh.Fresh(now) + }) +} + +func TestServicePersistsHotRecordTouchAcrossIntervals(t *testing.T) { + createdAt := time.Unix(1_800_000_000, 0).UTC() + record := completeRecord(createdAt) + service := &Service{ + cache: NewCache([]Record{record}, createdAt), recordRetention: 180 * 24 * time.Hour, + touchInterval: time.Hour, touches: make(chan recordTouch, 2), + } + + for step := 1; step <= 12; step++ { + service.touchCached(record, createdAt.Add(time.Duration(step)*10*time.Minute)) + } + if got := len(service.touches); got != 2 { + t.Fatalf("scheduled persistent touches = %d, want 2", got) + } + first := <-service.touches + second := <-service.touches + if !first.accessedAt.Equal(createdAt.Add(time.Hour)) || !second.accessedAt.Equal(createdAt.Add(2*time.Hour)) { + t.Fatalf("touch schedule = %v, %v", first.accessedAt, second.accessedAt) + } +} + +func TestServiceRetriesTouchImmediatelyAfterQueueFull(t *testing.T) { + createdAt := time.Unix(1_800_000_000, 0).UTC() + record := completeRecord(createdAt) + service := &Service{ + cache: NewCache([]Record{record}, createdAt), recordRetention: 180 * 24 * time.Hour, + touchInterval: time.Hour, touches: make(chan recordTouch, 1), + } + service.touches <- recordTouch{} + due := createdAt.Add(time.Hour) + service.touchCached(record, due) + <-service.touches + service.touchCached(record, due) + if got := len(service.touches); got != 1 { + t.Fatalf("retry queue length = %d, want 1", got) + } +} + +func TestServiceUsesClientUserAgentAndBackgroundFallback(t *testing.T) { + provider := &fakeProvider{prober: &blockingProber{}, userAgents: make(chan string, 2)} + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, Provider: provider, + }) + interactive := testRequest("interactive-ua", PriorityInteractive) + interactive.ClientUserAgent = "Infuse-Library/9.0" + if _, err := service.Ensure(t.Context(), interactive); err != nil { + t.Fatal(err) + } + background := testRequest("background-ua", PriorityBackground) + background.ClientUserAgent = "" + if err := service.Submit(background); err != nil { + t.Fatal(err) + } + if got := <-provider.userAgents; got != "Infuse-Library/9.0" { + t.Fatalf("interactive User-Agent = %q", got) + } + if got := <-provider.userAgents; got != "Infuse-Library/8.4.4" { + t.Fatalf("background User-Agent = %q", got) + } +} + +func newTestService(t *testing.T, prober Prober, concurrency int) *Service { + return newTestServiceWithQueues(t, prober, concurrency, 8, 8) +} + +func newTestServiceWithQueues(t *testing.T, prober Prober, concurrency, interactiveQueueSize, backgroundQueueSize int) *Service { + t.Helper() + return newServiceForTest(t, ServiceOptions{ + Cache: NewCache(nil, time.Now()), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: prober}, + Concurrency: concurrency, InteractiveQueueSize: interactiveQueueSize, BackgroundQueueSize: backgroundQueueSize, + ProbeTimeout: time.Second, RecordTTL: time.Hour, RecordRetention: 24 * time.Hour, + NegativeTTL: time.Minute, BackgroundUserAgent: "Infuse-Library/8.4.4", + }) +} + +func newServiceForTest(t *testing.T, options ServiceOptions) *Service { + t.Helper() + if options.Concurrency == 0 { + options.Concurrency = 1 + } + if options.ProbeTimeout == 0 { + options.ProbeTimeout = time.Second + } + if options.RecordTTL == 0 { + options.RecordTTL = time.Hour + } + if options.RecordRetention == 0 { + options.RecordRetention = 24 * time.Hour + } + if options.NegativeTTL == 0 { + options.NegativeTTL = time.Minute + } + if options.BackgroundUserAgent == "" { + options.BackgroundUserAgent = "Infuse-Library/8.4.4" + } + if options.PlexServerID == "" { + options.PlexServerID = "server" + } + 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 { + t.Errorf("Close() error = %v", err) + } + }) + return service +} + +func testRequest(partID string, priority Priority) Request { + return Request{ + Key: Key{ + PlexServerID: "server", PartID: partID, + STRMFingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, + RatingKey: partID + "-rating", Target: partID, Priority: priority, + ClientUserAgent: "Infuse-Library/8.4.4", + } +} + +func waitFor(t *testing.T, condition func() bool) { + t.Helper() + deadline := time.Now().Add(time.Second) + for !condition() { + if time.Now().After(deadline) { + t.Fatal("condition not met") + } + time.Sleep(time.Millisecond) + } +} diff --git a/internal/mediainfo/sqlite.go b/internal/mediainfo/sqlite.go new file mode 100644 index 0000000..7d94a7a --- /dev/null +++ b/internal/mediainfo/sqlite.go @@ -0,0 +1,340 @@ +package mediainfo + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +const ( + sqliteDatabaseVersion = 1 + sqliteSchemaV1 = ` +CREATE TABLE IF NOT EXISTS media_info_records ( + plex_server_id TEXT NOT NULL, + part_id TEXT NOT NULL, + strm_fingerprint TEXT NOT NULL, + rating_key TEXT NOT NULL DEFAULT '', + provider TEXT NOT NULL, + provider_revision TEXT NOT NULL, + content_fingerprint TEXT NOT NULL DEFAULT '', + schema_version INTEGER NOT NULL, + media_json BLOB NOT NULL, + probed_at_ms INTEGER NOT NULL, + expires_at_ms INTEGER NOT NULL, + last_accessed_at_ms INTEGER NOT NULL, + retain_until_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + PRIMARY KEY (plex_server_id, part_id, strm_fingerprint) +); +CREATE INDEX IF NOT EXISTS media_info_records_retain_until + ON media_info_records (retain_until_ms); +` +) + +// SQLiteStore persists successful MediaInfo results. It uses one database +// connection because SQLite is the single-instance writer in this design. +type SQLiteStore struct { + db *sql.DB +} + +// OpenSQLite creates or opens a private SQLite database, enables WAL mode, and +// applies versioned migrations. The path is a filesystem path, not a +// caller-controlled SQLite URI. +func OpenSQLite(ctx context.Context, path string) (*SQLiteStore, error) { + path = strings.TrimSpace(path) + if path == "" || strings.IndexByte(path, 0) >= 0 || strings.HasPrefix(path, "file:") { + return nil, errors.New("MediaInfo database path is invalid") + } + cleanPath := filepath.Clean(path) + directory := filepath.Dir(cleanPath) + if err := os.MkdirAll(directory, 0o750); err != nil { + return nil, fmt.Errorf("create MediaInfo database directory: %w", err) + } + file, err := os.OpenFile(cleanPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("create MediaInfo database: %w", err) + } + if err := file.Close(); err != nil { + return nil, fmt.Errorf("close MediaInfo database file: %w", err) + } + + db, err := sql.Open("sqlite", cleanPath) + if err != nil { + return nil, fmt.Errorf("open MediaInfo database: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + store := &SQLiteStore{db: db} + if err := store.initialize(ctx); err != nil { + _ = db.Close() + return nil, err + } + return store, nil +} + +func (store *SQLiteStore) initialize(ctx context.Context) error { + for _, statement := range []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA synchronous=NORMAL", + "PRAGMA foreign_keys=ON", + "PRAGMA busy_timeout=5000", + } { + if _, err := store.db.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("configure MediaInfo database: %w", err) + } + } + return store.migrate(ctx) +} + +func (store *SQLiteStore) migrate(ctx context.Context) error { + transaction, err := store.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin MediaInfo database migration: %w", err) + } + defer transaction.Rollback() + + var version int + if err := transaction.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { + return fmt.Errorf("read MediaInfo database version: %w", err) + } + if version > sqliteDatabaseVersion { + return fmt.Errorf("MediaInfo database version %d is newer than supported version %d", version, sqliteDatabaseVersion) + } + if version < 1 { + if _, err := transaction.ExecContext(ctx, sqliteSchemaV1); err != nil { + return fmt.Errorf("apply MediaInfo database schema v1: %w", err) + } + if _, err := transaction.ExecContext(ctx, "PRAGMA user_version = 1"); err != nil { + return fmt.Errorf("record MediaInfo database schema v1: %w", err) + } + } + if err := transaction.Commit(); err != nil { + return fmt.Errorf("commit MediaInfo database migration: %w", err) + } + return nil +} + +// Put inserts or refreshes one complete record. A late older probe cannot +// replace a newer result for the same stable key. +func (store *SQLiteStore) Put(ctx context.Context, record Record) error { + if store == nil || store.db == nil { + return errors.New("MediaInfo store is unavailable") + } + if err := record.Key.Validate(); err != nil { + return err + } + if strings.TrimSpace(record.Provider) == "" || !record.Media.Complete || + strings.TrimSpace(record.ProviderRevision) == "" || record.SchemaVersion != SchemaVersion { + return errors.New("MediaInfo record is incomplete or incompatible") + } + if record.ProbedAt.IsZero() || record.LastAccessedAt.IsZero() || + !record.ExpiresAt.After(record.ProbedAt) || record.RetainUntil.Before(record.ExpiresAt) || + !record.RetainUntil.After(record.LastAccessedAt) { + return errors.New("MediaInfo record timestamps are invalid") + } + mediaJSON, err := json.Marshal(record.Media) + if err != nil { + return fmt.Errorf("encode MediaInfo record: %w", err) + } + now := time.Now().UTC().UnixMilli() + _, err = store.db.ExecContext(ctx, ` +INSERT INTO media_info_records ( + plex_server_id, part_id, strm_fingerprint, rating_key, provider, provider_revision, + content_fingerprint, schema_version, media_json, + probed_at_ms, expires_at_ms, last_accessed_at_ms, retain_until_ms, updated_at_ms +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (plex_server_id, part_id, strm_fingerprint) DO UPDATE SET + rating_key = excluded.rating_key, + provider = excluded.provider, + provider_revision = excluded.provider_revision, + content_fingerprint = excluded.content_fingerprint, + schema_version = excluded.schema_version, + media_json = excluded.media_json, + probed_at_ms = excluded.probed_at_ms, + expires_at_ms = excluded.expires_at_ms, + last_accessed_at_ms = excluded.last_accessed_at_ms, + retain_until_ms = excluded.retain_until_ms, + updated_at_ms = excluded.updated_at_ms +WHERE excluded.probed_at_ms >= media_info_records.probed_at_ms + `, record.Key.PlexServerID, record.Key.PartID, record.Key.STRMFingerprint, + record.RatingKey, record.Provider, record.ProviderRevision, + record.ContentFingerprint, record.SchemaVersion, mediaJSON, + record.ProbedAt.UTC().UnixMilli(), record.ExpiresAt.UTC().UnixMilli(), + record.LastAccessedAt.UTC().UnixMilli(), record.RetainUntil.UTC().UnixMilli(), now) + if err != nil { + return fmt.Errorf("write MediaInfo record: %w", err) + } + return nil +} + +// Get returns the exact persisted record even when it is expired. Callers use +// Record.Fresh to decide whether it can satisfy a current request. +func (store *SQLiteStore) Get(ctx context.Context, key Key) (Record, bool, error) { + if store == nil || store.db == nil { + return Record{}, false, errors.New("MediaInfo store is unavailable") + } + if err := key.Validate(); err != nil { + return Record{}, false, err + } + row := store.db.QueryRowContext(ctx, ` + SELECT rating_key, provider, provider_revision, content_fingerprint, schema_version, media_json, + probed_at_ms, expires_at_ms, last_accessed_at_ms, retain_until_ms +FROM media_info_records +WHERE plex_server_id = ? AND part_id = ? AND strm_fingerprint = ? +`, key.PlexServerID, key.PartID, key.STRMFingerprint) + record, err := scanRecord(row, key) + if errors.Is(err, sql.ErrNoRows) { + return Record{}, false, nil + } + if err != nil { + return Record{}, false, err + } + return record, true, nil +} + +// LoadCompatibleRetained restores fresh and stale records for one provider +// contract so obsolete revisions cannot consume the bounded L1 capacity. +func (store *SQLiteStore) LoadCompatibleRetained( + ctx context.Context, + now time.Time, + limit int, + provider ProviderDescriptor, +) ([]Record, error) { + if store == nil || store.db == nil { + return nil, errors.New("MediaInfo store is unavailable") + } + if limit <= 0 { + return nil, errors.New("MediaInfo restore limit must be positive") + } + if err := provider.validate(); err != nil { + return nil, err + } + rows, err := store.db.QueryContext(ctx, ` + SELECT plex_server_id, part_id, strm_fingerprint, rating_key, provider, provider_revision, + content_fingerprint, schema_version, media_json, probed_at_ms, expires_at_ms, + last_accessed_at_ms, retain_until_ms + FROM media_info_records + WHERE retain_until_ms > ? AND schema_version = ? AND provider = ? AND provider_revision = ? + ORDER BY last_accessed_at_ms DESC + LIMIT ? + `, now.UTC().UnixMilli(), SchemaVersion, provider.Name, provider.Revision, limit) + if err != nil { + return nil, fmt.Errorf("load MediaInfo records: %w", err) + } + defer rows.Close() + + var records []Record + for rows.Next() { + var key Key + var record Record + var mediaJSON []byte + var probedAtMS, expiresAtMS, lastAccessedAtMS, retainUntilMS int64 + if err := rows.Scan( + &key.PlexServerID, &key.PartID, &key.STRMFingerprint, &record.RatingKey, + &record.Provider, &record.ProviderRevision, &record.ContentFingerprint, + &record.SchemaVersion, &mediaJSON, &probedAtMS, &expiresAtMS, + &lastAccessedAtMS, &retainUntilMS, + ); err != nil { + return nil, fmt.Errorf("scan MediaInfo record: %w", err) + } + record.Key = key + record.ProbedAt = time.UnixMilli(probedAtMS).UTC() + record.ExpiresAt = time.UnixMilli(expiresAtMS).UTC() + record.LastAccessedAt = time.UnixMilli(lastAccessedAtMS).UTC() + record.RetainUntil = time.UnixMilli(retainUntilMS).UTC() + if err := json.Unmarshal(mediaJSON, &record.Media); err != nil { + return nil, fmt.Errorf("decode MediaInfo record: %w", err) + } + if record.Retained(now) { + records = append(records, record) + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate MediaInfo records: %w", err) + } + return records, nil +} + +// DeleteUnretained removes records beyond their stale fallback window. +func (store *SQLiteStore) DeleteUnretained(ctx context.Context, now time.Time) (int64, error) { + if store == nil || store.db == nil { + return 0, errors.New("MediaInfo store is unavailable") + } + result, err := store.db.ExecContext(ctx, "DELETE FROM media_info_records WHERE retain_until_ms <= ?", now.UTC().UnixMilli()) + if err != nil { + return 0, fmt.Errorf("delete expired MediaInfo records: %w", err) + } + deleted, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("read deleted MediaInfo count: %w", err) + } + return deleted, 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 { + return errors.New("MediaInfo store is unavailable") + } + if err := key.Validate(); err != nil { + return err + } + if accessedAt.IsZero() || !retainUntil.After(accessedAt) { + return errors.New("MediaInfo access timestamps are invalid") + } + now := time.Now().UTC().UnixMilli() + _, err := store.db.ExecContext(ctx, ` +UPDATE media_info_records +SET last_accessed_at_ms = MAX(last_accessed_at_ms, ?), + retain_until_ms = MAX(retain_until_ms, ?), + updated_at_ms = ? +WHERE plex_server_id = ? AND part_id = ? AND strm_fingerprint = ? + AND last_accessed_at_ms <= ? +`, accessedAt.UTC().UnixMilli(), retainUntil.UTC().UnixMilli(), now, + key.PlexServerID, key.PartID, key.STRMFingerprint, accessedAt.UTC().UnixMilli()) + if err != nil { + return fmt.Errorf("touch MediaInfo record: %w", err) + } + return nil +} + +// Close flushes and closes the SQLite database. +func (store *SQLiteStore) Close() error { + if store == nil || store.db == nil { + return nil + } + return store.db.Close() +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanRecord(row rowScanner, key Key) (Record, error) { + record := Record{Key: key} + var mediaJSON []byte + var probedAtMS, expiresAtMS, lastAccessedAtMS, retainUntilMS int64 + if err := row.Scan( + &record.RatingKey, &record.Provider, &record.ProviderRevision, + &record.ContentFingerprint, &record.SchemaVersion, + &mediaJSON, &probedAtMS, &expiresAtMS, &lastAccessedAtMS, &retainUntilMS, + ); err != nil { + return Record{}, err + } + record.ProbedAt = time.UnixMilli(probedAtMS).UTC() + record.ExpiresAt = time.UnixMilli(expiresAtMS).UTC() + record.LastAccessedAt = time.UnixMilli(lastAccessedAtMS).UTC() + record.RetainUntil = time.UnixMilli(retainUntilMS).UTC() + if err := json.Unmarshal(mediaJSON, &record.Media); err != nil { + return Record{}, fmt.Errorf("decode MediaInfo record: %w", err) + } + return record, nil +} diff --git a/internal/mediainfo/sqlite_test.go b/internal/mediainfo/sqlite_test.go new file mode 100644 index 0000000..22d92fa --- /dev/null +++ b/internal/mediainfo/sqlite_test.go @@ -0,0 +1,214 @@ +package mediainfo + +import ( + "context" + "database/sql" + "path/filepath" + "strings" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +func TestSQLiteStorePersistsRetainedRecordAcrossReopen(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "mediainfo.db") + now := time.Unix(1_800_000_000, 0).UTC() + record := completeRecord(now) + + store, err := OpenSQLite(ctx, path) + if err != nil { + t.Fatal(err) + } + if err := store.Put(ctx, record); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + + store, err = OpenSQLite(ctx, path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + loaded, err := store.LoadCompatibleRetained(ctx, now, 100, testProviderDescriptor()) + if err != nil { + t.Fatal(err) + } + if len(loaded) != 1 || loaded[0].Key != record.Key || loaded[0].Media.Streams[0].Codec != "hevc" { + t.Fatalf("loaded records = %#v", loaded) + } +} + +func TestSQLiteStoreRecordsSchemaVersion(t *testing.T) { + store, err := OpenSQLite(t.Context(), filepath.Join(t.TempDir(), "mediainfo.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + var version int + if err := store.db.QueryRowContext(t.Context(), "PRAGMA user_version").Scan(&version); err != nil { + t.Fatal(err) + } + if version != sqliteDatabaseVersion { + t.Fatalf("schema version = %d", version) + } +} + +func TestSQLiteStoreRejectsNewerSchema(t *testing.T) { + path := filepath.Join(t.TempDir(), "mediainfo.db") + database, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := database.ExecContext(t.Context(), "PRAGMA user_version = 999"); err != nil { + t.Fatal(err) + } + if err := database.Close(); err != nil { + t.Fatal(err) + } + _, err = OpenSQLite(t.Context(), path) + if err == nil || !strings.Contains(err.Error(), "newer than supported") { + t.Fatalf("OpenSQLite() error = %v", err) + } +} + +func TestSQLiteStoreRejectsLateOlderProbe(t *testing.T) { + ctx := context.Background() + store, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "mediainfo.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + now := time.Unix(1_800_000_000, 0).UTC() + newer := completeRecord(now) + newer.Media.Streams[0].Codec = "hevc" + older := completeRecord(now.Add(-time.Hour)) + older.Media.Streams[0].Codec = "h264" + if err := store.Put(ctx, newer); err != nil { + t.Fatal(err) + } + if err := store.Put(ctx, older); err != nil { + t.Fatal(err) + } + got, ok, err := store.Get(ctx, newer.Key) + if err != nil || !ok { + t.Fatalf("Get() = %#v, %v, %v", got, ok, err) + } + if got.Media.Streams[0].Codec != "hevc" { + t.Fatalf("late older probe overwrote record: %#v", got) + } +} + +func TestSQLiteStoreDeletesUnretainedWithoutAffectingStaleOrFresh(t *testing.T) { + ctx := context.Background() + store, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "mediainfo.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + now := time.Unix(1_800_000_000, 0).UTC() + unretained := completeRecord(now.Add(-200 * 24 * time.Hour)) + unretained.ExpiresAt = now.Add(-170 * 24 * time.Hour) + unretained.LastAccessedAt = now.Add(-200 * 24 * time.Hour) + unretained.RetainUntil = now.Add(-20 * 24 * time.Hour) + stale := completeRecord(now.Add(-48 * 24 * time.Hour)) + stale.Key.PartID = "stale" + stale.ExpiresAt = now.Add(-18 * 24 * time.Hour) + fresh := completeRecord(now) + fresh.Key.PartID = "fresh" + if err := store.Put(ctx, unretained); err != nil { + t.Fatal(err) + } + if err := store.Put(ctx, stale); err != nil { + t.Fatal(err) + } + if err := store.Put(ctx, fresh); err != nil { + t.Fatal(err) + } + deleted, err := store.DeleteUnretained(ctx, now) + if err != nil || deleted != 1 { + t.Fatalf("DeleteExpired() = %d, %v", deleted, err) + } + loaded, err := store.LoadCompatibleRetained(ctx, now, 100, testProviderDescriptor()) + if err != nil || len(loaded) != 2 { + t.Fatalf("LoadCompatibleRetained() = %#v, %v", loaded, err) + } +} + +func TestSQLiteStoreLoadsMostRecentlyAccessedWithinLimit(t *testing.T) { + store, err := OpenSQLite(t.Context(), filepath.Join(t.TempDir(), "mediainfo.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + now := time.Unix(1_800_000_000, 0).UTC() + for index, age := range []time.Duration{3 * time.Hour, 2 * time.Hour, time.Hour} { + record := completeRecord(now.Add(-age)) + record.Key.PartID = string(rune('A' + index)) + if err := store.Put(t.Context(), record); err != nil { + t.Fatal(err) + } + } + loaded, err := store.LoadCompatibleRetained(t.Context(), now, 2, testProviderDescriptor()) + if err != nil { + t.Fatal(err) + } + if len(loaded) != 2 || loaded[0].Key.PartID != "C" || loaded[1].Key.PartID != "B" { + t.Fatalf("loaded records = %#v", loaded) + } +} + +func testProviderDescriptor() ProviderDescriptor { + return ProviderDescriptor{Name: ProviderMediaVaultFFProbe, Revision: ProviderRevisionFFProbeJSONV1} +} + +func TestSQLiteStoreDoesNotRestoreOtherProviderRevision(t *testing.T) { + store, err := OpenSQLite(t.Context(), filepath.Join(t.TempDir(), "mediainfo.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + now := time.Unix(1_800_000_000, 0).UTC() + if err := store.Put(t.Context(), completeRecord(now)); err != nil { + t.Fatal(err) + } + loaded, err := store.LoadCompatibleRetained(t.Context(), now, 100, ProviderDescriptor{ + Name: ProviderMediaVaultFFProbe, Revision: "ffprobe-json-v2", + }) + if err != nil { + t.Fatal(err) + } + if len(loaded) != 0 { + t.Fatalf("incompatible records restored = %d", len(loaded)) + } +} + +func TestSQLiteStoreTouchExtendsRetentionMonotonically(t *testing.T) { + store, err := OpenSQLite(t.Context(), filepath.Join(t.TempDir(), "mediainfo.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + now := time.Unix(1_800_000_000, 0).UTC() + record := completeRecord(now) + if err := store.Put(t.Context(), record); err != nil { + t.Fatal(err) + } + accessedAt := now.Add(24 * time.Hour) + retainUntil := accessedAt.Add(180 * 24 * time.Hour) + if err := store.Touch(t.Context(), record.Key, accessedAt, retainUntil); err != nil { + t.Fatal(err) + } + if err := store.Touch(t.Context(), record.Key, now, now.Add(31*24*time.Hour)); err != nil { + t.Fatal(err) + } + got, ok, err := store.Get(t.Context(), record.Key) + if err != nil || !ok || !got.LastAccessedAt.Equal(accessedAt) || !got.RetainUntil.Equal(retainUntil) { + t.Fatalf("Get() = %#v, %t, %v", got, ok, err) + } +} diff --git a/internal/mediainfo/touch_lifecycle_test.go b/internal/mediainfo/touch_lifecycle_test.go new file mode 100644 index 0000000..7359039 --- /dev/null +++ b/internal/mediainfo/touch_lifecycle_test.go @@ -0,0 +1,237 @@ +package mediainfo + +import ( + "context" + "errors" + "runtime" + "testing" + "time" +) + +type scriptedTouchStore struct { + fakeRecordStore + started chan Key + results chan error +} + +func (store *scriptedTouchStore) Touch(ctx context.Context, key Key, accessedAt, retainUntil time.Time) error { + if store.started != nil { + select { + case store.started <- key: + case <-ctx.Done(): + return ctx.Err() + } + } + if store.results != nil { + select { + case err := <-store.results: + if err != nil { + return err + } + case <-ctx.Done(): + return ctx.Err() + } + } + return store.fakeRecordStore.Touch(ctx, key, accessedAt, retainUntil) +} + +type cancelAwareTouchStore struct { + fakeRecordStore + started chan struct{} + canceled chan struct{} +} + +func (store *cancelAwareTouchStore) Touch(ctx context.Context, _ Key, _, _ time.Time) error { + if store.started != nil { + select { + case store.started <- struct{}{}: + case <-ctx.Done(): + return ctx.Err() + } + } + <-ctx.Done() + if store.canceled != nil { + select { + case store.canceled <- struct{}{}: + default: + } + } + return ctx.Err() +} + +func TestServiceRequeuesTouchAfterStoreError(t *testing.T) { + const touchInterval = time.Hour + now := time.Unix(1_800_000_000, 0).UTC() + request := testRequest("touch-error", PriorityInteractive) + record := completeRecord(now.Add(-2 * touchInterval)) + record.Key = request.Key + store := &scriptedTouchStore{ + started: make(chan Key, 2), + results: make(chan error, 2), + } + store.results <- errors.New("touch unavailable") + store.results <- nil + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache([]Record{record}, now), Store: store, + Provider: &fakeProvider{prober: &blockingProber{}}, PlexServerID: "server", + RecordRetention: 24 * time.Hour, TouchInterval: touchInterval, + Now: func() time.Time { return now }, + }) + + if _, ok := service.Get(request.Key); !ok { + t.Fatal("first cache lookup missed") + } + select { + case got := <-store.started: + if got != request.Key { + t.Fatalf("first touch key = %#v", got) + } + case <-time.After(time.Second): + t.Fatal("first touch was not attempted") + } + waitForTouchSchedule(t, service.cache, request.Key, record.LastAccessedAt.UnixNano()) + + if _, ok := service.Get(request.Key); !ok { + t.Fatal("second cache lookup missed") + } + select { + case got := <-store.started: + if got != request.Key { + t.Fatalf("second touch key = %#v", got) + } + case <-time.After(time.Second): + t.Fatal("touch was not requeued after Store.Touch failed") + } +} + +func TestCacheTouchReservationReleaseDoesNotResetReplacedEntry(t *testing.T) { + const touchInterval = time.Hour + now := time.Unix(1_800_000_000, 0).UTC() + record := completeRecord(now) + cache := NewCache([]Record{record}, now) + scheduled := now.Add(touchInterval) + reservation, ok := cache.reserveTouch(record.Key, scheduled, touchInterval) + if !ok { + t.Fatal("failed to reserve original entry") + } + + replacement := record + replacement.LastAccessedAt = scheduled + replacement.RetainUntil = scheduled.Add(180 * 24 * time.Hour) + if !cache.Put(replacement, scheduled) { + t.Fatal("failed to replace retained entry") + } + reservation.release() + + tooSoon := scheduled.Add(touchInterval - time.Nanosecond) + if _, ok := cache.reserveTouch(record.Key, tooSoon, touchInterval); ok { + t.Fatal("old reservation reset the replacement entry") + } + due := scheduled.Add(touchInterval) + newReservation, ok := cache.reserveTouch(record.Key, due, touchInterval) + if !ok { + t.Fatal("replacement entry could not reserve its next touch") + } + newReservation.release() +} + +func TestCacheTouchReservationReleaseAfterEvictionDoesNotResetReinsertedEntry(t *testing.T) { + const touchInterval = time.Hour + now := time.Unix(1_800_000_000, 0).UTC() + record := completeRecord(now) + cache := NewCacheWithLimit([]Record{record}, now, 1) + scheduled := now.Add(touchInterval) + reservation, ok := cache.reserveTouch(record.Key, scheduled, touchInterval) + if !ok { + t.Fatal("failed to reserve evicted entry") + } + + other := completeRecord(scheduled) + other.Key.PartID = "other" + if !cache.Put(other, scheduled) { + t.Fatal("failed to insert eviction candidate") + } + if _, ok := cache.GetKnown(record.Key, scheduled); ok { + t.Fatal("reserved entry was not evicted") + } + + reinserted := record + reinserted.LastAccessedAt = scheduled + reinserted.RetainUntil = scheduled.Add(180 * 24 * time.Hour) + if !cache.Put(reinserted, scheduled) { + t.Fatal("failed to reinsert evicted entry") + } + reservation.release() + + tooSoon := scheduled.Add(touchInterval - time.Nanosecond) + if _, ok := cache.reserveTouch(record.Key, tooSoon, touchInterval); ok { + t.Fatal("old reservation reset the reinserted entry") + } + due := scheduled.Add(touchInterval) + newReservation, ok := cache.reserveTouch(record.Key, due, touchInterval) + if !ok { + t.Fatal("reinserted entry could not reserve its next touch") + } + newReservation.release() +} + +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.PartID = "close-first" + second := completeRecord(now.Add(-2 * touchInterval)) + second.Key.PartID = "close-second" + store := &cancelAwareTouchStore{ + started: make(chan struct{}, 2), + canceled: make(chan struct{}, 1), + } + service := newServiceForTest(t, ServiceOptions{ + Cache: NewCache([]Record{first, second}, now), Store: store, + Provider: &fakeProvider{prober: &blockingProber{}}, PlexServerID: "server", + RecordRetention: 24 * time.Hour, TouchInterval: touchInterval, + Now: func() time.Time { return now }, + }) + if _, ok := service.Get(first.Key); !ok { + t.Fatal("first cache lookup missed") + } + select { + case <-store.started: + case <-time.After(time.Second): + t.Fatal("first touch was not started") + } + if _, ok := service.Get(second.Key); !ok { + t.Fatal("second cache lookup missed") + } + if got := len(service.touches); got != 1 { + t.Fatalf("pending touch queue length = %d, want 1", got) + } + + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + if err := service.Close(ctx); err != nil { + t.Fatal(err) + } + select { + case <-store.canceled: + case <-time.After(time.Second): + t.Fatal("Store.Touch did not observe service cancellation") + } + waitForTouchSchedule(t, service.cache, first.Key, first.LastAccessedAt.UnixNano()) + waitForTouchSchedule(t, service.cache, second.Key, second.LastAccessedAt.UnixNano()) +} + +func waitForTouchSchedule(t *testing.T, cache *Cache, key Key, expected int64) { + t.Helper() + deadline := time.Now().Add(time.Second) + for { + _, entry, _, ok := cache.peek(key) + if ok && entry.lastTouchScheduled.Load() == expected { + return + } + if time.Now().After(deadline) { + t.Fatalf("touch reservation was not released: got entry=%t", ok) + } + runtime.Gosched() + } +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 7623f9d..7ad54df 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -30,8 +30,16 @@ type Metrics struct { 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 resolverLatency latencyMetrics redirectLatency latencyMetrics + mediaInfoProbeLatency latencyMetrics } type latencyMetrics struct { @@ -60,6 +68,13 @@ type Snapshot struct { 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"` ResolverLatencyMSTotal uint64 `json:"resolver_latency_ms_total"` ResolverLatencySamples uint64 `json:"resolver_latency_samples"` @@ -70,6 +85,11 @@ type Snapshot struct { RedirectLatencySamples uint64 `json:"redirect_latency_samples"` RedirectLatencyMSLast uint64 `json:"redirect_latency_ms_last"` RedirectLatencyMSMax uint64 `json:"redirect_latency_ms_max"` + + MediaInfoProbeLatencyMSTotal uint64 `json:"mediainfo_probe_latency_ms_total"` + MediaInfoProbeLatencySamples uint64 `json:"mediainfo_probe_latency_samples"` + MediaInfoProbeLatencyMSLast uint64 `json:"mediainfo_probe_latency_ms_last"` + MediaInfoProbeLatencyMSMax uint64 `json:"mediainfo_probe_latency_ms_max"` } // New creates an empty process-local metrics registry. @@ -108,6 +128,35 @@ func (m *Metrics) IncPlexFallback() { m.plexFallbackTotal.Add(1) } +// IncMediaInfoCacheHits records a fresh L1 result. +func (m *Metrics) IncMediaInfoCacheHits() { m.mediaInfoCacheHits.Add(1) } + +// IncMediaInfoCacheMisses records an exact key not satisfied by L1. +func (m *Metrics) IncMediaInfoCacheMisses() { m.mediaInfoCacheMisses.Add(1) } + +// IncMediaInfoProbeQueued records a newly scheduled singleflight job. +func (m *Metrics) IncMediaInfoProbeQueued() { m.mediaInfoProbeQueued.Add(1) } + +// IncMediaInfoProbeSuccess records a complete normalized probe result. +func (m *Metrics) IncMediaInfoProbeSuccess() { m.mediaInfoProbeSuccess.Add(1) } + +// IncMediaInfoProbeFailure records a bounded resolver or probe failure. +func (m *Metrics) IncMediaInfoProbeFailure() { m.mediaInfoProbeFailure.Add(1) } + +// IncMediaInfoStoreFailure records a result available in L1 but not persisted. +func (m *Metrics) IncMediaInfoStoreFailure() { m.mediaInfoStoreFailure.Add(1) } + +// IncMediaInfoProbeActive marks one worker as executing a job. +func (m *Metrics) IncMediaInfoProbeActive() { m.mediaInfoProbeActive.Add(1) } + +// DecMediaInfoProbeActive marks one worker job complete. +func (m *Metrics) DecMediaInfoProbeActive() { decrementGauge(&m.mediaInfoProbeActive) } + +// ObserveMediaInfoProbeLatency records the complete resolver and ffprobe job. +func (m *Metrics) ObserveMediaInfoProbeLatency(duration time.Duration) { + m.mediaInfoProbeLatency.observe(duration) +} + // IncMetadataGuardAdmitted records a detailed metadata request admitted to // Plex after both client and global limits were acquired. func (m *Metrics) IncMetadataGuardAdmitted() { @@ -239,6 +288,7 @@ func (m *Metrics) BeginRequest() func() { func (m *Metrics) Snapshot() Snapshot { resolverTotal, resolverSamples, resolverLast, resolverMax := m.resolverLatency.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(), @@ -256,6 +306,13 @@ func (m *Metrics) Snapshot() Snapshot { 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(), ResolverLatencyMSTotal: resolverTotal, ResolverLatencySamples: resolverSamples, @@ -266,6 +323,11 @@ func (m *Metrics) Snapshot() Snapshot { RedirectLatencySamples: redirectSamples, RedirectLatencyMSLast: redirectLast, RedirectLatencyMSMax: redirectMax, + + MediaInfoProbeLatencyMSTotal: mediaInfoTotal, + MediaInfoProbeLatencySamples: mediaInfoSamples, + MediaInfoProbeLatencyMSLast: mediaInfoLast, + MediaInfoProbeLatencyMSMax: mediaInfoMax, } } diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go index ebee32a..1fd5b4f 100644 --- a/internal/metrics/metrics_test.go +++ b/internal/metrics/metrics_test.go @@ -27,6 +27,14 @@ func TestSnapshotAndJSONHandler(t *testing.T) { registry.IncMetadataBatchGuardTimeouts() registry.IncMetadataBatchGuardActive() registry.IncMetadataBatchGuardQueued() + registry.IncMediaInfoCacheHits() + registry.IncMediaInfoCacheMisses() + registry.IncMediaInfoProbeQueued() + registry.IncMediaInfoProbeSuccess() + registry.IncMediaInfoProbeFailure() + registry.IncMediaInfoStoreFailure() + registry.IncMediaInfoProbeActive() + registry.ObserveMediaInfoProbeLatency(44 * time.Millisecond) registry.ObserveResolverLatency(12 * time.Millisecond) registry.ObserveResolverLatency(7 * time.Millisecond) registry.ObserveRedirectLatency(30 * time.Millisecond) @@ -63,6 +71,13 @@ func TestSnapshotAndJSONHandler(t *testing.T) { MetadataBatchGuardTimeoutsTotal: 1, MetadataBatchGuardActive: 1, MetadataBatchGuardQueued: 1, + MediaInfoCacheHitsTotal: 1, + MediaInfoCacheMissesTotal: 1, + MediaInfoProbeQueuedTotal: 1, + MediaInfoProbeSuccessTotal: 1, + MediaInfoProbeFailureTotal: 1, + MediaInfoStoreFailureTotal: 1, + MediaInfoProbeActive: 1, ResolverLatencyMSTotal: 19, ResolverLatencySamples: 2, @@ -73,6 +88,11 @@ func TestSnapshotAndJSONHandler(t *testing.T) { RedirectLatencySamples: 1, RedirectLatencyMSLast: 30, RedirectLatencyMSMax: 30, + + MediaInfoProbeLatencyMSTotal: 44, + MediaInfoProbeLatencySamples: 1, + MediaInfoProbeLatencyMSLast: 44, + MediaInfoProbeLatencyMSMax: 44, } if !reflect.DeepEqual(got, want) { t.Fatalf("snapshot = %#v, want %#v", got, want) @@ -82,6 +102,7 @@ func TestSnapshotAndJSONHandler(t *testing.T) { registry.DecMetadataGuardQueued() registry.DecMetadataBatchGuardActive() registry.DecMetadataBatchGuardQueued() + registry.DecMediaInfoProbeActive() if got := registry.Snapshot().ActiveRequests; got != 0 { t.Fatalf("active requests after release = %d, want 0", got) } @@ -133,6 +154,7 @@ func TestActiveRequestReleaseIsIdempotentAndClamped(t *testing.T) { registry.DecMetadataGuardQueued() registry.DecMetadataBatchGuardActive() registry.DecMetadataBatchGuardQueued() + registry.DecMediaInfoProbeActive() if got := registry.Snapshot().ActiveRequests; got != 0 { t.Fatalf("clamped active requests = %d, want 0", got) } @@ -172,6 +194,15 @@ func TestCountersAreSafeForConcurrentUpdates(t *testing.T) { registry.DecMetadataBatchGuardActive() registry.IncMetadataBatchGuardQueued() registry.DecMetadataBatchGuardQueued() + registry.IncMediaInfoCacheHits() + registry.IncMediaInfoCacheMisses() + registry.IncMediaInfoProbeQueued() + registry.IncMediaInfoProbeSuccess() + registry.IncMediaInfoProbeFailure() + registry.IncMediaInfoStoreFailure() + registry.IncMediaInfoProbeActive() + registry.DecMediaInfoProbeActive() + registry.ObserveMediaInfoProbeLatency(3 * time.Millisecond) registry.ObserveResolverLatency(time.Millisecond) registry.ObserveRedirectLatency(2 * time.Millisecond) release := registry.BeginRequest() @@ -188,8 +219,12 @@ func TestCountersAreSafeForConcurrentUpdates(t *testing.T) { got.MetadataGuardActive != 0 || got.MetadataGuardQueued != 0 || got.MetadataBatchGuardAdmittedTotal != want || got.MetadataBatchGuardTimeoutsTotal != want || got.MetadataBatchGuardActive != 0 || got.MetadataBatchGuardQueued != 0 || + got.MediaInfoCacheHitsTotal != want || got.MediaInfoCacheMissesTotal != want || + got.MediaInfoProbeQueuedTotal != want || got.MediaInfoProbeSuccessTotal != want || + got.MediaInfoProbeFailureTotal != want || got.MediaInfoStoreFailureTotal != want || got.MediaInfoProbeActive != 0 || got.ResolverLatencySamples != want || got.ResolverLatencyMSTotal != want || - got.RedirectLatencySamples != want || got.RedirectLatencyMSTotal != 2*want { + got.RedirectLatencySamples != want || got.RedirectLatencyMSTotal != 2*want || + got.MediaInfoProbeLatencySamples != want || got.MediaInfoProbeLatencyMSTotal != 3*want { t.Fatalf("concurrent snapshot = %#v, want all counters %d and no active requests", got, want) } }