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
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)
}
}