diff --git a/Dockerfile b/Dockerfile
index f18b548..e6d1348 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -22,6 +22,6 @@ WORKDIR /app_data
USER gateway
EXPOSE 32400
ENV LISTEN_ADDR=:32400
-ENV MEDIAINFO_DB_PATH=/app_data/mediainfo.db
+ENV DATABASE_PATH=/app_data/plex-gateway.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 a730a05..ad4a5f9 100644
--- a/README.en.md
+++ b/README.en.md
@@ -113,9 +113,12 @@ 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_ENABLED` | `true` | Enable the MediaInfo cache, bounded probes, and single-item metadata enrichment; initialization failures do not affect transparent proxying. |
+| `DATABASE_PATH` | `./data/plex-gateway.db` | Gateway SQLite database path; the container image defaults to `/app_data/plex-gateway.db`. |
| `MEDIAINFO_USER_AGENT` | `Infuse-Library/8.4.4` | Fallback User-Agent for background probes without an active client context. |
+| `MEDIAINFO_COLD_WAIT` | `5s` | Cold-cache wait ceiling for one metadata item; timeout returns the original Plex response while probing continues. |
+| `MEDIAINFO_RESPONSE_MAX_BYTES` | `8388608` | Maximum size of one Plex metadata response buffered for enrichment. |
+| `MEDIAINFO_ENRICHMENT_CONCURRENCY` | `4` | Maximum single-item metadata responses buffered and waiting for MediaInfo concurrently. |
Plex tokens are not stored in configuration. Ordinary Plex requests remain
transparent. For cloud playback, all client request headers are forwarded to
diff --git a/README.md b/README.md
index 5d84e25..ac33f42 100644
--- a/README.md
+++ b/README.md
@@ -101,9 +101,12 @@ 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_ENABLED` | `true` | 启用 MediaInfo 缓存、受限探测和单项 metadata 增强;初始化失败不影响透明代理。 |
+| `DATABASE_PATH` | `./data/plex-gateway.db` | Gateway SQLite 持久数据库路径;容器镜像默认使用 `/app_data/plex-gateway.db`。 |
| `MEDIAINFO_USER_AGENT` | `Infuse-Library/8.4.4` | 没有活动客户端上下文时,后台探测使用的 fallback User-Agent。 |
+| `MEDIAINFO_COLD_WAIT` | `5s` | 单项 metadata 冷缓存等待上限;超时返回原 Plex 响应,探测继续。 |
+| `MEDIAINFO_RESPONSE_MAX_BYTES` | `8388608` | 可以缓冲并尝试增强的单个 Plex metadata 响应上限。 |
+| `MEDIAINFO_ENRICHMENT_CONCURRENCY` | `4` | 同时缓冲和等待 MediaInfo 的单项 metadata 响应上限。 |
Plex Token 不会保存到配置中。普通 Plex 请求保持透明转发。对于云端播放,客户端的
所有请求 header 都会转发到配置的 MediaVault origin,使其能够为相同客户端上下文
diff --git a/cmd/plex-gateway/main.go b/cmd/plex-gateway/main.go
index e1b180b..d57e6ad 100644
--- a/cmd/plex-gateway/main.go
+++ b/cmd/plex-gateway/main.go
@@ -15,6 +15,7 @@ import (
"time"
"github.com/InfinityPacer/plex-gateway/internal/config"
+ "github.com/InfinityPacer/plex-gateway/internal/database"
"github.com/InfinityPacer/plex-gateway/internal/gateway"
"github.com/InfinityPacer/plex-gateway/internal/mediainfo"
"github.com/InfinityPacer/plex-gateway/internal/metrics"
@@ -66,10 +67,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,
+ databaseStore, databaseReason := initializeDatabase(
+ context.Background(), cfg.DatabasePath, cfg.MediaInfo.Enabled && strmResolver != nil, logger,
)
- defer closeMediaInfo(mediaInfoService, mediaInfoStore, cfg.ShutdownTimeout, logger)
+ mediaInfoService, mediaInfoReason := initializeMediaInfo(
+ context.Background(), databaseStore, databaseReason, cfg.MediaInfo, cfg.PlexURL, cfg.PartProbeTimeout, strmResolver, registry, logger,
+ )
+ defer closeRuntime(mediaInfoService, databaseStore, cfg.ShutdownTimeout, logger)
mediaInfoStatus := func() mediainfo.Status {
return mediaInfoService.Status()
}
@@ -92,8 +96,12 @@ func run() error {
BatchConcurrency: cfg.MetadataGuard.BatchConcurrency,
QueueTimeout: cfg.MetadataGuard.QueueTimeout,
},
- MediaInfoEnabled: cfg.MediaInfo.Enabled,
- MediaInfoStatus: mediaInfoStatus,
+ MediaInfoEnabled: cfg.MediaInfo.Enabled,
+ MediaInfoStatus: mediaInfoStatus,
+ MediaInfoService: mediaInfoService,
+ MediaInfoColdWait: cfg.MediaInfo.ColdWait,
+ MediaInfoResponseMaxBytes: cfg.MediaInfo.ResponseMaxBytes,
+ MediaInfoEnrichmentConcurrency: cfg.MediaInfo.EnrichmentWaiters,
})
server := &http.Server{
Addr: cfg.ListenAddr,
@@ -143,23 +151,28 @@ func run() error {
func initializeMediaInfo(
ctx context.Context,
+ databaseStore *database.SQLite,
+ databaseReason string,
cfg config.MediaInfoConfig,
plexURL *url.URL,
identityTimeout time.Duration,
strmResolver resolver.ControlResolver,
registry *metrics.Metrics,
logger *slog.Logger,
-) (*mediainfo.Service, *mediainfo.SQLiteStore, string) {
+) (*mediainfo.Service, string) {
if !cfg.Enabled {
- return nil, nil, "disabled"
+ return nil, "disabled"
}
if strmResolver == nil {
- return nil, nil, "cloud_unavailable"
+ return nil, "cloud_unavailable"
+ }
+ if databaseStore == nil {
+ return nil, databaseReason
}
plexServerID, err := gateway.ReadPlexServerIdentity(ctx, plexURL, identityTimeout)
if err != nil {
logger.Warn("mediainfo_unavailable", "reason", "plex_identity")
- return nil, nil, "plex_identity_unavailable"
+ return nil, "plex_identity_unavailable"
}
prober, err := mediainfo.NewFFProber(mediainfo.FFProbeOptions{
Binary: cfg.FFProbePath, Timeout: cfg.ProbeTimeout, ProbeSize: cfg.ProbeSize,
@@ -167,17 +180,17 @@ func initializeMediaInfo(
})
if err != nil {
logger.Warn("mediainfo_unavailable", "reason", "ffprobe")
- return nil, nil, "ffprobe_unavailable"
+ return nil, "ffprobe_unavailable"
}
provider, err := mediainfo.NewMediaVaultFFProbeProvider(strmResolver, prober)
if err != nil {
logger.Warn("mediainfo_unavailable", "reason", "provider")
- return nil, nil, "provider_unavailable"
+ return nil, "provider_unavailable"
}
- store, err := mediainfo.OpenSQLite(ctx, cfg.DatabasePath)
+ store, err := mediainfo.NewSQLiteStore(ctx, databaseStore)
if err != nil {
- logger.Warn("mediainfo_unavailable", "reason", "storage")
- return nil, nil, "storage_unavailable"
+ logger.Warn("mediainfo_unavailable", "reason", "migration")
+ return nil, "migration_failed"
}
now := time.Now().UTC()
if _, err := store.DeleteUnretained(ctx, now); err != nil {
@@ -186,8 +199,7 @@ func initializeMediaInfo(
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"
+ return nil, "restore_failed"
}
cache := mediainfo.NewCacheWithLimit(records, now, cfg.L1MaxEntries)
service, err := mediainfo.NewService(mediainfo.ServiceOptions{
@@ -200,13 +212,24 @@ func initializeMediaInfo(
})
if err != nil {
logger.Warn("mediainfo_unavailable", "reason", "worker")
- _ = store.Close()
- return nil, nil, "worker_unavailable"
+ return nil, "worker_unavailable"
+ }
+ return service, "ready"
+}
+
+func initializeDatabase(ctx context.Context, path string, required bool, logger *slog.Logger) (*database.SQLite, string) {
+ if !required {
+ return nil, "unused"
+ }
+ store, err := database.OpenSQLite(ctx, path)
+ if err != nil {
+ logger.Warn("database_unavailable", "reason", "storage")
+ return nil, "storage_unavailable"
}
- return service, store, "ready"
+ return store, "ready"
}
-func closeMediaInfo(service *mediainfo.Service, store *mediainfo.SQLiteStore, timeout time.Duration, logger *slog.Logger) {
+func closeRuntime(service *mediainfo.Service, store *database.SQLite, timeout time.Duration, logger *slog.Logger) {
serviceClosed := true
if service != nil {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
diff --git a/cmd/plex-gateway/main_test.go b/cmd/plex-gateway/main_test.go
index dabc455..2b5ba3a 100644
--- a/cmd/plex-gateway/main_test.go
+++ b/cmd/plex-gateway/main_test.go
@@ -59,9 +59,9 @@ func TestInitializeMediaInfoDoesNotProbeWithoutTask(t *testing.T) {
}
control := &countingControlResolver{}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- service, store, reason := initializeMediaInfo(t.Context(), config.MediaInfoConfig{
+ databaseStore, databaseReason := initializeDatabase(t.Context(), filepath.Join(directory, "plex-gateway.db"), true, logger)
+ service, reason := initializeMediaInfo(t.Context(), databaseStore, databaseReason, config.MediaInfoConfig{
Enabled: true,
- DatabasePath: filepath.Join(directory, "mediainfo.db"),
FFProbePath: ffprobePath,
ProbeTimeout: time.Second,
ProbeSize: 1 << 20,
@@ -76,8 +76,8 @@ func TestInitializeMediaInfoDoesNotProbeWithoutTask(t *testing.T) {
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 service == nil || databaseStore == nil || reason != "ready" {
+ t.Fatalf("initializeMediaInfo() service=%v store=%v reason=%q", service != nil, databaseStore != nil, reason)
}
if status := service.Status(); !status.Available || status.ActiveProbes != 0 ||
status.InteractiveQueued != 0 || status.BackgroundQueued != 0 {
@@ -94,5 +94,5 @@ func TestInitializeMediaInfoDoesNotProbeWithoutTask(t *testing.T) {
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)
+ closeRuntime(service, databaseStore, time.Second, logger)
}
diff --git a/docker-compose.example.yml b/docker-compose.example.yml
index ea90bbd..6df49dd 100644
--- a/docker-compose.example.yml
+++ b/docker-compose.example.yml
@@ -12,6 +12,7 @@ services:
PATH_MAPPINGS: >-
[{"plex_prefix":"/media/cloud","local_prefix":"/strm"},{"plex_prefix":"/media/archive","local_prefix":"/archive-strm"}]
LISTEN_ADDR: ":32400"
+ DATABASE_PATH: "/app_data/plex-gateway.db"
LOG_LEVEL: "info"
TRACE_ENABLED: "false"
PART_CACHE_TTL: "24h"
@@ -27,6 +28,9 @@ services:
MEDIAINFO_ENABLED: "true"
# Background probes use this only when no active client request supplies a User-Agent.
MEDIAINFO_USER_AGENT: "Infuse-Library/8.4.4"
+ MEDIAINFO_COLD_WAIT: "5s"
+ MEDIAINFO_RESPONSE_MAX_BYTES: "8388608"
+ MEDIAINFO_ENRICHMENT_CONCURRENCY: "4"
READ_HEADER_TIMEOUT: "15s"
IDLE_TIMEOUT: "90s"
SHUTDOWN_TIMEOUT: "15s"
diff --git a/docs/architecture.md b/docs/architecture.md
index 2c763d4..7c10033 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -18,11 +18,12 @@ gateway; the isolated analysis worker may read a bounded amount for probing.
consume interactive metadata slots. Neither pool caches or rewrites Plex
responses. Library listings, metadata mutations, playback paths, timeline,
and watch-state traffic do not enter these pools.
-- Successful XML or JSON Plex responses are currently observed without changing
- their bytes. Any `Media.Part` values found in those responses populate an
- in-memory cache keyed by `Part.id`. Phase D may enrich a successful single-item
- metadata response when explicitly enabled; unsupported or failed transforms
- preserve the original Plex response.
+- Successful XML or JSON Plex responses are observed to populate the in-memory
+ `Part.id` cache without changing their bytes. When MediaInfo is available, an
+ authenticated single-item metadata response may also fill missing whitelisted
+ technical fields from the exact STRM fingerprint. The transform is bounded,
+ never creates Stream identities, and preserves the original Plex response on
+ timeout, ambiguity, unsupported encoding, or any other failure.
- `GET` and `HEAD` requests below `/library/parts/{partID}/...` are eligible for
interception only when the cached `Part.file` has a configured cloud
extension and maps to a readable local STRM file.
@@ -50,9 +51,11 @@ gateway; the isolated analysis worker may read a bounded amount for probing.
| Package | Responsibility |
| --- | --- |
| `cmd/plex-gateway` | Configuration, dependency assembly, and process lifecycle. |
+| `internal/database` | Gateway SQLite connection lifecycle and independent per-module migrations. |
| `internal/gateway` | Plex routing, transparent proxying, protocol probes, response capture, 302/fallback output, and request metrics. |
+| `internal/mediainfo` | Bounded analysis scheduling, L1/persistent cache policy, provider contracts, and technical-media records. |
| `internal/playback` | Shared STRM preparation, normalized playback attempts, Plex authorization/MediaVault resolution sequencing, and decision-grant state. |
-| `internal/plexmeta` | Side-effect-free XML and JSON Part/decision readers. |
+| `internal/plexmeta` | XML and JSON Part/decision readers plus whitelisted metadata projection. |
| `internal/partcache` | Derived, expiring `Part.id` index; never an authority for access. |
| `internal/pathmap` | Lexical Plex-to-container path mapping. |
| `internal/resolver` | STRM target validation and the MediaVault `/redirect` protocol. |
diff --git a/internal/config/config.go b/internal/config/config.go
index 45a166c..62d16fd 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -26,7 +26,7 @@ const (
defaultMetadataPerClient = 4
defaultMetadataBatch = 3
defaultMetadataQueueWait = 10 * time.Second
- defaultMediaInfoDatabase = "./data/mediainfo.db"
+ defaultDatabasePath = "./data/plex-gateway.db"
defaultMediaInfoProbe = 20 * time.Second
defaultMediaInfoSize = 8 << 20
defaultMediaInfoAnalyze = 5 * time.Second
@@ -38,6 +38,9 @@ const (
defaultMediaInfoL1Entries = 10_000
defaultMediaInfoNegative = 15 * time.Minute
defaultMediaInfoAgent = "Infuse-Library/8.4.4"
+ defaultMediaInfoColdWait = 5 * time.Second
+ defaultMediaInfoBodyLimit = 8 << 20
+ defaultMediaInfoWaiters = 4
)
// Config contains process-level settings for transparent Plex proxying and
@@ -45,6 +48,7 @@ const (
// never loaded into this structure.
type Config struct {
ListenAddr string
+ DatabasePath string
PlexURL *url.URL
MediaVaultURL *url.URL
PathMappings []pathmap.Mapping
@@ -66,7 +70,6 @@ type Config struct {
// but capability initialization remains fail-open for transparent Plex proxying.
type MediaInfoConfig struct {
Enabled bool
- DatabasePath string
FFProbePath string
ProbeTimeout time.Duration
ProbeSize int64
@@ -80,6 +83,9 @@ type MediaInfoConfig struct {
L1MaxEntries int
NegativeTTL time.Duration
UserAgent string
+ ColdWait time.Duration
+ ResponseMaxBytes int64
+ EnrichmentWaiters int
}
// MetadataGuardConfig bounds detailed metadata fan-out before requests enter
@@ -186,9 +192,9 @@ func Load() (Config, error) {
if err != nil {
return Config{}, err
}
- mediaInfoDatabase := strings.TrimSpace(os.Getenv("MEDIAINFO_DB_PATH"))
- if mediaInfoDatabase == "" {
- mediaInfoDatabase = defaultMediaInfoDatabase
+ databasePath := strings.TrimSpace(os.Getenv("DATABASE_PATH"))
+ if databasePath == "" {
+ databasePath = defaultDatabasePath
}
mediaInfoFFProbe := strings.TrimSpace(os.Getenv("MEDIAINFO_FFPROBE_PATH"))
if mediaInfoFFProbe == "" {
@@ -248,6 +254,18 @@ func Load() (Config, error) {
if strings.ContainsAny(mediaInfoUserAgent, "\r\n") {
return Config{}, errors.New("MEDIAINFO_USER_AGENT must not contain line breaks")
}
+ mediaInfoColdWait, err := envDuration("MEDIAINFO_COLD_WAIT", defaultMediaInfoColdWait)
+ if err != nil {
+ return Config{}, err
+ }
+ mediaInfoBodyLimit, err := envPositiveInt64("MEDIAINFO_RESPONSE_MAX_BYTES", defaultMediaInfoBodyLimit)
+ if err != nil {
+ return Config{}, err
+ }
+ mediaInfoWaiters, err := envPositiveInt("MEDIAINFO_ENRICHMENT_CONCURRENCY", defaultMediaInfoWaiters)
+ if err != nil {
+ return Config{}, err
+ }
readHeaderTimeout, err := envDuration("READ_HEADER_TIMEOUT", defaultReadHeaderTimeout)
if err != nil {
@@ -276,6 +294,7 @@ func Load() (Config, error) {
return Config{
ListenAddr: listenAddr,
+ DatabasePath: databasePath,
PlexURL: plexURL,
MediaVaultURL: mediaVaultURL,
PathMappings: pathMappings,
@@ -293,13 +312,14 @@ func Load() (Config, error) {
QueueTimeout: metadataQueueWait,
},
MediaInfo: MediaInfoConfig{
- Enabled: mediaInfoEnabled, DatabasePath: mediaInfoDatabase, FFProbePath: mediaInfoFFProbe,
+ Enabled: mediaInfoEnabled, 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,
+ ColdWait: mediaInfoColdWait, ResponseMaxBytes: mediaInfoBodyLimit, EnrichmentWaiters: mediaInfoWaiters,
},
LogLevel: logLevel,
TraceEnabled: traceEnabled,
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 5a0931c..3b617a3 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -40,7 +40,10 @@ 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" {
+ if got.DatabasePath != "./data/plex-gateway.db" {
+ t.Fatalf("DatabasePath = %q", got.DatabasePath)
+ }
+ if !got.MediaInfo.Enabled || got.MediaInfo.ProbeTimeout != 20*time.Second || got.MediaInfo.Concurrency != 1 || got.MediaInfo.UserAgent != "Infuse-Library/8.4.4" || got.MediaInfo.ColdWait != 5*time.Second || got.MediaInfo.ResponseMaxBytes != 8<<20 || got.MediaInfo.EnrichmentWaiters != 4 {
t.Fatalf("unexpected MediaInfo defaults: %#v", got.MediaInfo)
}
if got.MediaVaultURL != nil || len(got.PathMappings) != 0 {
@@ -73,7 +76,7 @@ func TestLoadCloudConfiguration(t *testing.T) {
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("DATABASE_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")
@@ -87,6 +90,9 @@ func TestLoadCloudConfiguration(t *testing.T) {
t.Setenv("MEDIAINFO_L1_MAX_ENTRIES", "5000")
t.Setenv("MEDIAINFO_NEGATIVE_TTL", "10m")
t.Setenv("MEDIAINFO_USER_AGENT", "plex-gateway-test")
+ t.Setenv("MEDIAINFO_COLD_WAIT", "4s")
+ t.Setenv("MEDIAINFO_RESPONSE_MAX_BYTES", "2097152")
+ t.Setenv("MEDIAINFO_ENRICHMENT_CONCURRENCY", "3")
got, err := Load()
if err != nil {
@@ -104,7 +110,10 @@ 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" {
+ if got.DatabasePath != "/app_data/test.db" {
+ t.Fatalf("DatabasePath = %q", got.DatabasePath)
+ }
+ if !got.MediaInfo.Enabled || got.MediaInfo.FFProbePath != "/usr/bin/ffprobe" || got.MediaInfo.ProbeTimeout != 12*time.Second || got.MediaInfo.ProbeSize != 4194304 || got.MediaInfo.AnalyzeDuration != 3*time.Second || got.MediaInfo.OutputMaxBytes != 1048576 || got.MediaInfo.Concurrency != 2 || got.MediaInfo.InteractiveQueueSize != 64 || got.MediaInfo.BackgroundQueueSize != 128 || got.MediaInfo.RecordTTL != 168*time.Hour || got.MediaInfo.RecordRetention != 720*time.Hour || got.MediaInfo.L1MaxEntries != 5000 || got.MediaInfo.NegativeTTL != 10*time.Minute || got.MediaInfo.UserAgent != "plex-gateway-test" || got.MediaInfo.ColdWait != 4*time.Second || got.MediaInfo.ResponseMaxBytes != 2097152 || got.MediaInfo.EnrichmentWaiters != 3 {
t.Fatalf("MediaInfo configuration = %#v", got.MediaInfo)
}
}
diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go
new file mode 100644
index 0000000..74a8f6d
--- /dev/null
+++ b/internal/database/sqlite.go
@@ -0,0 +1,159 @@
+// Package database owns the Gateway SQLite connection and cross-domain
+// migration registry. Feature packages own their tables and submit migrations
+// through this package.
+package database
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+const migrationRegistrySchema = `
+CREATE TABLE IF NOT EXISTS gateway_schema_migrations (
+ module TEXT NOT NULL,
+ version INTEGER NOT NULL,
+ applied_at_ms INTEGER NOT NULL,
+ PRIMARY KEY (module, version)
+);
+`
+
+// Migration is one ordered schema transition owned by a feature module.
+// Versions must start at one and remain contiguous within that module.
+type Migration struct {
+ Version int
+ SQL string
+}
+
+// SQLite owns the process-wide Gateway database connection.
+type SQLite struct {
+ db *sql.DB
+}
+
+// OpenSQLite opens the Gateway database and establishes connection-wide
+// durability and contention settings. The path must be a filesystem path, not
+// a caller-controlled SQLite URI.
+func OpenSQLite(ctx context.Context, path string) (*SQLite, error) {
+ path = strings.TrimSpace(path)
+ if path == "" || strings.IndexByte(path, 0) >= 0 || strings.HasPrefix(path, "file:") {
+ return nil, errors.New("Gateway database path is invalid")
+ }
+ cleanPath := filepath.Clean(path)
+ if err := os.MkdirAll(filepath.Dir(cleanPath), 0o750); err != nil {
+ return nil, fmt.Errorf("create Gateway database directory: %w", err)
+ }
+ file, err := os.OpenFile(cleanPath, os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return nil, fmt.Errorf("create Gateway database: %w", err)
+ }
+ if err := file.Close(); err != nil {
+ return nil, fmt.Errorf("close Gateway database file: %w", err)
+ }
+
+ db, err := sql.Open("sqlite", cleanPath)
+ if err != nil {
+ return nil, fmt.Errorf("open Gateway database: %w", err)
+ }
+ db.SetMaxOpenConns(1)
+ db.SetMaxIdleConns(1)
+ store := &SQLite{db: db}
+ if err := store.initialize(ctx); err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+ return store, nil
+}
+
+func (store *SQLite) 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 Gateway database: %w", err)
+ }
+ }
+ if _, err := store.db.ExecContext(ctx, migrationRegistrySchema); err != nil {
+ return fmt.Errorf("initialize Gateway migration registry: %w", err)
+ }
+ return nil
+}
+
+// ApplyMigrations advances one feature module without changing the schema
+// version of any other module. A newer on-disk version is rejected so an older
+// binary cannot silently operate on an unknown schema.
+func (store *SQLite) ApplyMigrations(ctx context.Context, module string, migrations []Migration) error {
+ if store == nil || store.db == nil {
+ return errors.New("Gateway database is unavailable")
+ }
+ module = strings.TrimSpace(module)
+ if module == "" {
+ return errors.New("Gateway migration module is required")
+ }
+ for index, migration := range migrations {
+ if migration.Version != index+1 || strings.TrimSpace(migration.SQL) == "" {
+ return fmt.Errorf("Gateway migration %s version sequence is invalid", module)
+ }
+ }
+
+ transaction, err := store.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("begin Gateway database migration: %w", err)
+ }
+ defer transaction.Rollback()
+
+ var applied, current int
+ if err := transaction.QueryRowContext(ctx, `
+ SELECT COUNT(*), COALESCE(MAX(version), 0)
+ FROM gateway_schema_migrations
+ WHERE module = ?
+ `, module).Scan(&applied, ¤t); err != nil {
+ return fmt.Errorf("read %s schema version: %w", module, err)
+ }
+ if applied != current {
+ return fmt.Errorf("%s schema migration history is not contiguous", module)
+ }
+ if current > len(migrations) {
+ return fmt.Errorf("%s schema version %d is newer than supported version %d", module, current, len(migrations))
+ }
+ for _, migration := range migrations[current:] {
+ if _, err := transaction.ExecContext(ctx, migration.SQL); err != nil {
+ return fmt.Errorf("apply %s schema v%d: %w", module, migration.Version, err)
+ }
+ if _, err := transaction.ExecContext(ctx, `
+ INSERT INTO gateway_schema_migrations (module, version, applied_at_ms)
+ VALUES (?, ?, ?)
+ `, module, migration.Version, time.Now().UTC().UnixMilli()); err != nil {
+ return fmt.Errorf("record %s schema v%d: %w", module, migration.Version, err)
+ }
+ }
+ if err := transaction.Commit(); err != nil {
+ return fmt.Errorf("commit Gateway database migration: %w", err)
+ }
+ return nil
+}
+
+// SQLDB returns the shared handle for feature stores. Callers must not close it.
+func (store *SQLite) SQLDB() *sql.DB {
+ if store == nil {
+ return nil
+ }
+ return store.db
+}
+
+// Close flushes and closes the process-wide Gateway database.
+func (store *SQLite) Close() error {
+ if store == nil || store.db == nil {
+ return nil
+ }
+ return store.db.Close()
+}
diff --git a/internal/database/sqlite_test.go b/internal/database/sqlite_test.go
new file mode 100644
index 0000000..438d1ce
--- /dev/null
+++ b/internal/database/sqlite_test.go
@@ -0,0 +1,61 @@
+package database
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestSQLiteTracksModuleMigrationsIndependently(t *testing.T) {
+ store, err := OpenSQLite(t.Context(), filepath.Join(t.TempDir(), "plex-gateway.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+
+ if err := store.ApplyMigrations(t.Context(), "media", []Migration{{Version: 1, SQL: "CREATE TABLE media_records (id TEXT PRIMARY KEY)"}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.ApplyMigrations(t.Context(), "jobs", []Migration{{Version: 1, SQL: "CREATE TABLE jobs (id TEXT PRIMARY KEY)"}}); err != nil {
+ t.Fatal(err)
+ }
+ rows, err := store.db.QueryContext(t.Context(), "SELECT module, version FROM gateway_schema_migrations ORDER BY module")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+ var got []string
+ for rows.Next() {
+ var module string
+ var version int
+ if err := rows.Scan(&module, &version); err != nil {
+ t.Fatal(err)
+ }
+ got = append(got, module)
+ }
+ if strings.Join(got, ",") != "jobs,media" {
+ t.Fatalf("migration modules = %v", got)
+ }
+}
+
+func TestSQLiteRejectsNewerModuleWithoutAffectingOthers(t *testing.T) {
+ store, err := OpenSQLite(t.Context(), filepath.Join(t.TempDir(), "plex-gateway.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if _, err := store.db.ExecContext(t.Context(), `
+ INSERT INTO gateway_schema_migrations (module, version, applied_at_ms)
+ VALUES ('mediainfo', 1, 0), ('mediainfo', 2, 0), ('jobs', 1, 0)
+ `); err != nil {
+ t.Fatal(err)
+ }
+
+ err = store.ApplyMigrations(t.Context(), "mediainfo", []Migration{{Version: 1, SQL: "SELECT 1"}})
+ if err == nil || !strings.Contains(err.Error(), "newer than supported") {
+ t.Fatalf("ApplyMigrations() error = %v", err)
+ }
+ if err := store.ApplyMigrations(t.Context(), "jobs", []Migration{{Version: 1, SQL: "SELECT 1"}}); err != nil {
+ t.Fatalf("unrelated module migration failed: %v", err)
+ }
+}
diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go
index 12cdc7a..3490f85 100644
--- a/internal/gateway/gateway.go
+++ b/internal/gateway/gateway.go
@@ -27,23 +27,28 @@ const defaultPartProbeTimeout = 15 * time.Second
// Options describes the optional cloud playback plane around the transparent
// Plex proxy. Nil cloud components keep the handler in proxy-only mode.
type Options struct {
- Upstream *url.URL
- Logger *slog.Logger
- Tracer *trace.Tracer
- PartCache *partcache.Cache
- PathMapper *pathmap.Mapper
- Resolver resolver.ControlResolver
- Metrics *metrics.Metrics
- CloudExtensions []string
- ObserveMaxBytes int64
- PartProbeTimeout time.Duration
- MetadataGuard MetadataGuardOptions
- MediaInfoEnabled bool
- MediaInfoStatus func() mediainfo.Status
+ Upstream *url.URL
+ Logger *slog.Logger
+ Tracer *trace.Tracer
+ PartCache *partcache.Cache
+ PathMapper *pathmap.Mapper
+ Resolver resolver.ControlResolver
+ Metrics *metrics.Metrics
+ CloudExtensions []string
+ ObserveMaxBytes int64
+ PartProbeTimeout time.Duration
+ MetadataGuard MetadataGuardOptions
+ MediaInfoEnabled bool
+ MediaInfoStatus func() mediainfo.Status
+ MediaInfoService *mediainfo.Service
+ MediaInfoColdWait time.Duration
+ MediaInfoResponseMaxBytes int64
+ MediaInfoEnrichmentConcurrency int
}
// New builds the fail-open Plex proxy and optional Direct Play interceptor.
-// Metadata observation never mutates the Plex response body.
+// Observation only populates caches; optional MediaInfo response enrichment is
+// bounded and returns the original Plex response whenever it cannot act safely.
func New(options Options) http.Handler {
logger := options.Logger
if logger == nil {
@@ -180,7 +185,14 @@ func New(options Options) http.Handler {
}
mux.Handle("GET /library/parts/{partID}/{rest...}", partPlayback)
mux.Handle("HEAD /library/parts/{partID}/{rest...}", partPlayback)
- mux.Handle("/", newMetadataGuard(options.MetadataGuard, plex, registry, logger))
+ metadata := newMetadataGuard(options.MetadataGuard, plex, registry, logger)
+ metadata = newMetadataEnrichmentHandler(metadataEnrichmentOptions{
+ Service: options.MediaInfoService, Mapper: options.PathMapper, Resolver: options.Resolver,
+ CloudExtensions: options.CloudExtensions, ColdWait: options.MediaInfoColdWait,
+ ResponseLimit: options.MediaInfoResponseMaxBytes, Concurrency: options.MediaInfoEnrichmentConcurrency,
+ Metrics: registry,
+ }, metadata)
+ mux.Handle("/", metadata)
withActiveRequests := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
release := registry.BeginRequest()
diff --git a/internal/gateway/metadata_enrichment.go b/internal/gateway/metadata_enrichment.go
new file mode 100644
index 0000000..77de5b9
--- /dev/null
+++ b/internal/gateway/metadata_enrichment.go
@@ -0,0 +1,354 @@
+package gateway
+
+import (
+ "bytes"
+ "compress/gzip"
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "path"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/InfinityPacer/plex-gateway/internal/mediainfo"
+ "github.com/InfinityPacer/plex-gateway/internal/metrics"
+ "github.com/InfinityPacer/plex-gateway/internal/pathmap"
+ "github.com/InfinityPacer/plex-gateway/internal/plexmeta"
+ "github.com/InfinityPacer/plex-gateway/internal/resolver"
+)
+
+const (
+ defaultMediaInfoColdWait = 5 * time.Second
+ defaultMediaInfoResponseLimit = 8 << 20
+ defaultMediaInfoEnrichmentSlots = 4
+)
+
+type mediaInfoEnsurer interface {
+ Ensure(context.Context, mediainfo.Request) (mediainfo.Record, error)
+}
+
+// metadataEnrichmentOptions keeps analysis collaborators outside the playback
+// handlers. A missing collaborator disables enrichment without changing Plex
+// proxy behavior.
+type metadataEnrichmentOptions struct {
+ Service mediaInfoEnsurer
+ Mapper *pathmap.Mapper
+ Resolver resolver.ControlResolver
+ CloudExtensions []string
+ ColdWait time.Duration
+ ResponseLimit int64
+ Concurrency int
+ Metrics *metrics.Metrics
+}
+
+type metadataEnrichmentHandler struct {
+ next http.Handler
+ service mediaInfoEnsurer
+ mapper *pathmap.Mapper
+ resolver resolver.ControlResolver
+ cloudExtensions map[string]struct{}
+ coldWait time.Duration
+ responseLimit int64
+ waiters chan struct{}
+ metrics *metrics.Metrics
+}
+
+func newMetadataEnrichmentHandler(options metadataEnrichmentOptions, next http.Handler) http.Handler {
+ if next == nil || options.Service == nil || options.Mapper == nil || options.Resolver == nil {
+ return next
+ }
+ coldWait := options.ColdWait
+ if coldWait <= 0 {
+ coldWait = defaultMediaInfoColdWait
+ }
+ responseLimit := options.ResponseLimit
+ if responseLimit <= 0 {
+ responseLimit = defaultMediaInfoResponseLimit
+ }
+ concurrency := options.Concurrency
+ if concurrency <= 0 {
+ concurrency = defaultMediaInfoEnrichmentSlots
+ }
+ registry := options.Metrics
+ if registry == nil {
+ registry = metrics.New()
+ }
+ return &metadataEnrichmentHandler{
+ next: next, service: options.Service, mapper: options.Mapper, resolver: options.Resolver,
+ cloudExtensions: normalizedExtensionSet(options.CloudExtensions), coldWait: coldWait,
+ responseLimit: responseLimit, waiters: make(chan struct{}, concurrency), metrics: registry,
+ }
+}
+
+func (handler *metadataEnrichmentHandler) ServeHTTP(w http.ResponseWriter, request *http.Request) {
+ ratingKey, eligible := mediaInfoMetadataRequest(request)
+ if !eligible {
+ handler.next.ServeHTTP(w, request)
+ return
+ }
+ select {
+ case handler.waiters <- struct{}{}:
+ handler.metrics.IncMediaInfoWaitActive()
+ defer func() {
+ <-handler.waiters
+ handler.metrics.DecMediaInfoWaitActive()
+ }()
+ default:
+ handler.metrics.IncMediaInfoWaitRejected()
+ handler.next.ServeHTTP(w, request)
+ return
+ }
+
+ capture := newBoundedResponseCapture(w, handler.responseLimit)
+ handler.next.ServeHTTP(capture, request)
+ if capture.passthrough {
+ return
+ }
+ rawBody := capture.body.Bytes()
+ decoded, encoding, err := decodeMetadataBody(rawBody, capture.header.Get("Content-Encoding"), handler.responseLimit)
+ if err != nil {
+ handler.failOpen(capture, rawBody)
+ return
+ }
+ target, err := plexmeta.SelectEnrichmentTarget(decoded, capture.header.Get("Content-Type"), ratingKey)
+ if err != nil {
+ handler.failOpen(capture, rawBody)
+ return
+ }
+ if !target.NeedsEnrichment || !handler.cloudPart(target.Part) {
+ handler.replay(capture, rawBody)
+ return
+ }
+ localPath, err := handler.mapper.Resolve(target.Part.File)
+ if err != nil {
+ handler.failOpen(capture, rawBody)
+ return
+ }
+ strmTarget, err := handler.resolver.ReadTarget(localPath)
+ if err != nil {
+ handler.failOpen(capture, rawBody)
+ return
+ }
+ fingerprint, err := mediainfo.FingerprintSTRMTarget(strmTarget)
+ if err != nil {
+ handler.failOpen(capture, rawBody)
+ return
+ }
+ waitContext, cancel := context.WithTimeout(request.Context(), handler.coldWait)
+ record, err := handler.service.Ensure(waitContext, mediainfo.Request{
+ Key: mediainfo.Key{PartID: target.Part.ID, STRMFingerprint: fingerprint},
+ RatingKey: ratingKey, Target: strmTarget, Priority: mediainfo.PriorityInteractive,
+ ClientUserAgent: request.UserAgent(),
+ })
+ cancel()
+ if err != nil {
+ handler.failOpen(capture, rawBody)
+ return
+ }
+ currentTarget, err := handler.resolver.ReadTarget(localPath)
+ if err != nil {
+ handler.failOpen(capture, rawBody)
+ return
+ }
+ currentFingerprint, err := mediainfo.FingerprintSTRMTarget(currentTarget)
+ if err != nil || currentFingerprint != fingerprint {
+ handler.failOpen(capture, rawBody)
+ return
+ }
+ enriched, changed, err := plexmeta.EnrichMetadata(decoded, capture.header.Get("Content-Type"), ratingKey, record.Media)
+ if err != nil || !changed {
+ handler.failOpen(capture, rawBody)
+ return
+ }
+ wireBody, err := encodeMetadataBody(enriched, encoding)
+ if err != nil {
+ handler.failOpen(capture, rawBody)
+ return
+ }
+ headers := capture.header.Clone()
+ for _, name := range []string{"ETag", "Last-Modified", "Content-MD5", "Digest"} {
+ headers.Del(name)
+ }
+ if !hasCacheDirective(headers.Get("Cache-Control"), "no-store") {
+ headers.Set("Cache-Control", "private, no-cache")
+ }
+ headers.Set("Content-Length", strconv.Itoa(len(wireBody)))
+ writeCapturedResponse(w, headers, capture.statusCode(), wireBody)
+ handler.metrics.IncMediaInfoEnriched()
+}
+
+func (handler *metadataEnrichmentHandler) cloudPart(part plexmeta.Part) bool {
+ _, ok := handler.cloudExtensions[strings.ToLower(path.Ext(part.File))]
+ return ok
+}
+
+func (handler *metadataEnrichmentHandler) failOpen(capture *boundedResponseCapture, rawBody []byte) {
+ handler.replay(capture, rawBody)
+ handler.metrics.IncMediaInfoFailOpen()
+}
+
+func (handler *metadataEnrichmentHandler) replay(capture *boundedResponseCapture, rawBody []byte) {
+ writeCapturedResponse(capture.destination, capture.header, capture.statusCode(), rawBody)
+}
+
+func mediaInfoMetadataRequest(request *http.Request) (string, bool) {
+ if request == nil || request.URL == nil || request.Method != http.MethodGet || request.Header.Get("Range") != "" {
+ return "", false
+ }
+ _, tokenPresent, tokenValid := requestIdentity(request, "X-Plex-Token")
+ if !tokenPresent || !tokenValid || !isMetadataItemPath(request.URL.Path) {
+ return "", false
+ }
+ return strings.TrimPrefix(request.URL.Path, "/library/metadata/"), true
+}
+
+func normalizedExtensionSet(extensions []string) map[string]struct{} {
+ if len(extensions) == 0 {
+ extensions = []string{".strm"}
+ }
+ result := make(map[string]struct{}, len(extensions))
+ for _, extension := range extensions {
+ extension = strings.ToLower(strings.TrimSpace(extension))
+ if extension != "" {
+ result[extension] = struct{}{}
+ }
+ }
+ return result
+}
+
+func hasCacheDirective(value, directive string) bool {
+ for _, component := range strings.Split(value, ",") {
+ name, _, _ := strings.Cut(strings.TrimSpace(component), "=")
+ if strings.EqualFold(name, directive) {
+ return true
+ }
+ }
+ return false
+}
+
+func decodeMetadataBody(raw []byte, rawEncoding string, limit int64) ([]byte, string, error) {
+ encoding := strings.ToLower(strings.TrimSpace(rawEncoding))
+ switch encoding {
+ case "", "identity":
+ return raw, encoding, nil
+ case "gzip":
+ reader, err := gzip.NewReader(bytes.NewReader(raw))
+ if err != nil {
+ return nil, "", err
+ }
+ defer reader.Close()
+ decoded, err := io.ReadAll(io.LimitReader(reader, limit+1))
+ if err != nil || int64(len(decoded)) > limit {
+ return nil, "", errors.New("metadata gzip body exceeds limit")
+ }
+ return decoded, encoding, nil
+ default:
+ return nil, "", errors.New("metadata content encoding is unsupported")
+ }
+}
+
+func encodeMetadataBody(decoded []byte, encoding string) ([]byte, error) {
+ if encoding != "gzip" {
+ return decoded, nil
+ }
+ var output bytes.Buffer
+ writer := gzip.NewWriter(&output)
+ if _, err := writer.Write(decoded); err != nil {
+ return nil, err
+ }
+ if err := writer.Close(); err != nil {
+ return nil, err
+ }
+ return output.Bytes(), nil
+}
+
+type boundedResponseCapture struct {
+ destination http.ResponseWriter
+ header http.Header
+ body bytes.Buffer
+ status int
+ limit int64
+ passthrough bool
+}
+
+func newBoundedResponseCapture(destination http.ResponseWriter, limit int64) *boundedResponseCapture {
+ return &boundedResponseCapture{destination: destination, header: make(http.Header), limit: limit}
+}
+
+func (capture *boundedResponseCapture) Header() http.Header { return capture.header }
+
+func (capture *boundedResponseCapture) WriteHeader(status int) {
+ if capture.status != 0 {
+ return
+ }
+ capture.status = status
+ if status != http.StatusOK || capture.header.Get("Trailer") != "" || capture.header.Get("Content-Range") != "" {
+ capture.startPassthrough()
+ }
+}
+
+func (capture *boundedResponseCapture) Write(value []byte) (int, error) {
+ if capture.status == 0 {
+ capture.WriteHeader(http.StatusOK)
+ }
+ if capture.passthrough {
+ return capture.destination.Write(value)
+ }
+ if int64(capture.body.Len())+int64(len(value)) > capture.limit {
+ if err := capture.startPassthrough(); err != nil {
+ return 0, err
+ }
+ return capture.destination.Write(value)
+ }
+ return capture.body.Write(value)
+}
+
+// Flush is intentionally deferred while a bounded metadata body is captured.
+// A passthrough response retains the downstream flusher behavior.
+func (capture *boundedResponseCapture) Flush() {
+ if !capture.passthrough {
+ return
+ }
+ if flusher, ok := capture.destination.(http.Flusher); ok {
+ flusher.Flush()
+ }
+}
+
+func (capture *boundedResponseCapture) statusCode() int {
+ if capture.status == 0 {
+ return http.StatusOK
+ }
+ return capture.status
+}
+
+func (capture *boundedResponseCapture) startPassthrough() error {
+ if capture.passthrough {
+ return nil
+ }
+ capture.passthrough = true
+ writeHeader(capture.destination.Header(), capture.header)
+ capture.destination.WriteHeader(capture.statusCode())
+ if capture.body.Len() == 0 {
+ return nil
+ }
+ _, err := capture.destination.Write(capture.body.Bytes())
+ capture.body.Reset()
+ return err
+}
+
+func writeCapturedResponse(destination http.ResponseWriter, header http.Header, status int, body []byte) {
+ writeHeader(destination.Header(), header)
+ destination.WriteHeader(status)
+ _, _ = destination.Write(body)
+}
+
+func writeHeader(destination, source http.Header) {
+ for name := range destination {
+ destination.Del(name)
+ }
+ for name, values := range source {
+ destination[name] = append([]string(nil), values...)
+ }
+}
diff --git a/internal/gateway/metadata_enrichment_integration_test.go b/internal/gateway/metadata_enrichment_integration_test.go
new file mode 100644
index 0000000..edf5e26
--- /dev/null
+++ b/internal/gateway/metadata_enrichment_integration_test.go
@@ -0,0 +1,196 @@
+package gateway
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/InfinityPacer/plex-gateway/internal/database"
+ "github.com/InfinityPacer/plex-gateway/internal/mediainfo"
+ "github.com/InfinityPacer/plex-gateway/internal/metrics"
+ "github.com/InfinityPacer/plex-gateway/internal/partcache"
+ "github.com/InfinityPacer/plex-gateway/internal/pathmap"
+ "github.com/InfinityPacer/plex-gateway/internal/resolver"
+)
+
+type delayedIntegrationProvider struct {
+ calls atomic.Int64
+ once sync.Once
+ started chan struct{}
+ release chan struct{}
+}
+
+func (*delayedIntegrationProvider) Descriptor() mediainfo.ProviderDescriptor {
+ return mediainfo.ProviderDescriptor{Name: "integration", Revision: "v1"}
+}
+
+func (provider *delayedIntegrationProvider) Probe(ctx context.Context, _ mediainfo.ProviderRequest) (mediainfo.ProviderResult, error) {
+ provider.calls.Add(1)
+ provider.once.Do(func() { close(provider.started) })
+ select {
+ case <-ctx.Done():
+ return mediainfo.ProviderResult{}, ctx.Err()
+ case <-provider.release:
+ }
+ return mediainfo.ProviderResult{Media: completeProjectionMedia()}, nil
+}
+
+func TestGatewayColdMetadataTimeoutContinuesProbeAndWarmsNextResponse(t *testing.T) {
+ raw := []byte(``)
+ gzippedRaw := gzipBytes(t, raw)
+ plex := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
+ if request.URL.Path != "/library/metadata/42" {
+ http.NotFound(w, request)
+ return
+ }
+ w.Header().Set("Content-Type", "application/xml")
+ w.Header().Set("ETag", `"plex-etag"`)
+ body := raw
+ if strings.Contains(request.Header.Get("Accept-Encoding"), "gzip") {
+ w.Header().Set("Content-Encoding", "gzip")
+ body = gzippedRaw
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(body)
+ }))
+ defer plex.Close()
+
+ directory := t.TempDir()
+ strmTarget := "http://mediavault:7811/redirect/pick/episode.mkv"
+ if err := os.WriteFile(filepath.Join(directory, "episode.strm"), []byte(strmTarget+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ mapper, err := pathmap.New([]pathmap.Mapping{{PlexPrefix: "/media/cloud", LocalPrefix: directory}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ controlResolver, err := resolver.NewMediaVaultSTRMResolver("http://mediavault:7811", nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ gatewayDB, err := database.OpenSQLite(t.Context(), filepath.Join(directory, "plex-gateway.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ store, err := mediainfo.NewSQLiteStore(t.Context(), gatewayDB)
+ if err != nil {
+ _ = gatewayDB.Close()
+ t.Fatal(err)
+ }
+ provider := &delayedIntegrationProvider{started: make(chan struct{}), release: make(chan struct{})}
+ service, err := mediainfo.NewService(mediainfo.ServiceOptions{
+ Cache: mediainfo.NewCache(nil, time.Now()), Store: store, Provider: provider,
+ PlexServerID: "integration-server", Concurrency: 1,
+ InteractiveQueueSize: 4, BackgroundQueueSize: 4,
+ ProbeTimeout: time.Second, RecordTTL: time.Hour, RecordRetention: 24 * time.Hour,
+ BackgroundUserAgent: "integration-background",
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ })
+ if err != nil {
+ _ = gatewayDB.Close()
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := service.Close(ctx); err != nil {
+ t.Error(err)
+ }
+ if err := gatewayDB.Close(); err != nil {
+ t.Error(err)
+ }
+ })
+
+ plexURL, err := url.Parse(plex.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ registry := metrics.New()
+ handler := New(Options{
+ Upstream: plexURL, PartCache: partcache.New(time.Hour), PathMapper: mapper,
+ Resolver: controlResolver, Metrics: registry, CloudExtensions: []string{".strm"},
+ MediaInfoEnabled: true, MediaInfoStatus: service.Status, MediaInfoService: service,
+ MediaInfoColdWait: 20 * time.Millisecond, MediaInfoResponseMaxBytes: 1 << 20,
+ MediaInfoEnrichmentConcurrency: 1,
+ })
+
+ first := httptest.NewRecorder()
+ startedAt := time.Now()
+ handler.ServeHTTP(first, authenticatedMetadataRequest(http.MethodGet))
+ if elapsed := time.Since(startedAt); elapsed > 500*time.Millisecond {
+ t.Fatalf("cold fail-open took %s", elapsed)
+ }
+ if first.Code != http.StatusOK || !bytes.Equal(first.Body.Bytes(), raw) || first.Header().Get("ETag") != `"plex-etag"` {
+ t.Fatalf("cold response status=%d headers=%#v body=%s", first.Code, first.Header(), first.Body.Bytes())
+ }
+ select {
+ case <-provider.started:
+ default:
+ t.Fatal("cold request did not start the background probe")
+ }
+ close(provider.release)
+
+ fingerprint, err := mediainfo.FingerprintSTRMTarget(strmTarget)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key := mediainfo.Key{PlexServerID: "integration-server", PartID: "9", STRMFingerprint: fingerprint}
+ waitForPersistedMediaInfo(t, store, key)
+
+ second := httptest.NewRecorder()
+ warmRequest := authenticatedMetadataRequest(http.MethodGet)
+ warmRequest.Header.Set("Accept-Encoding", "gzip")
+ handler.ServeHTTP(second, warmRequest)
+ if second.Code != http.StatusOK || bytes.Equal(second.Body.Bytes(), raw) {
+ t.Fatalf("warm response status=%d body=%s", second.Code, second.Body.Bytes())
+ }
+ decodedWarm := gunzipBytes(t, second.Body.Bytes())
+ if second.Header().Get("Content-Encoding") != "gzip" || second.Header().Get("ETag") != "" || !bytes.Contains(decodedWarm, []byte(`container="mkv"`)) {
+ t.Fatalf("warm response headers=%#v body=%s", second.Header(), second.Body.Bytes())
+ }
+ if provider.calls.Load() != 1 {
+ t.Fatalf("provider calls = %d, want 1", provider.calls.Load())
+ }
+ snapshot := registry.Snapshot()
+ if snapshot.MediaInfoFailOpenTotal != 1 || snapshot.MediaInfoEnrichedTotal != 1 {
+ t.Fatalf("metrics = %#v", snapshot)
+ }
+
+ for _, requestPath := range []string{
+ "/library/parts/999/1/file.strm",
+ "/video/:/transcode/universal/decision",
+ "/video/:/transcode/universal/start.mpd",
+ "/:/timeline",
+ } {
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, authenticatedRequest(http.MethodGet, requestPath))
+ }
+ if provider.calls.Load() != 1 {
+ t.Fatalf("non-metadata routes triggered MediaInfo provider: calls=%d", provider.calls.Load())
+ }
+}
+
+func waitForPersistedMediaInfo(t *testing.T, store *mediainfo.SQLiteStore, key mediainfo.Key) {
+ t.Helper()
+ deadline := time.Now().Add(time.Second)
+ for time.Now().Before(deadline) {
+ if record, found, err := store.Get(t.Context(), key); err != nil {
+ t.Fatal(err)
+ } else if found && record.Media.Complete {
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ t.Fatal("MediaInfo worker did not persist the completed probe")
+}
diff --git a/internal/gateway/metadata_enrichment_test.go b/internal/gateway/metadata_enrichment_test.go
new file mode 100644
index 0000000..99910a5
--- /dev/null
+++ b/internal/gateway/metadata_enrichment_test.go
@@ -0,0 +1,418 @@
+package gateway
+
+import (
+ "bytes"
+ "compress/gzip"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/InfinityPacer/plex-gateway/internal/mediainfo"
+ "github.com/InfinityPacer/plex-gateway/internal/metrics"
+ "github.com/InfinityPacer/plex-gateway/internal/pathmap"
+ "github.com/InfinityPacer/plex-gateway/internal/resolver"
+)
+
+type fakeMediaInfoEnsurer struct {
+ calls atomic.Int64
+ mu sync.Mutex
+ seen []mediainfo.Request
+ fn func(context.Context, mediainfo.Request) (mediainfo.Record, error)
+}
+
+func (ensurer *fakeMediaInfoEnsurer) Ensure(ctx context.Context, request mediainfo.Request) (mediainfo.Record, error) {
+ ensurer.calls.Add(1)
+ ensurer.mu.Lock()
+ ensurer.seen = append(ensurer.seen, request)
+ ensurer.mu.Unlock()
+ return ensurer.fn(ctx, request)
+}
+
+func (ensurer *fakeMediaInfoEnsurer) requests() []mediainfo.Request {
+ ensurer.mu.Lock()
+ defer ensurer.mu.Unlock()
+ return append([]mediainfo.Request(nil), ensurer.seen...)
+}
+
+func TestMetadataEnrichmentAddsMissingXMLFields(t *testing.T) {
+ fixture := newMetadataEnrichmentFixture(t)
+ raw := []byte(``)
+ registry := metrics.New()
+ ensurer := &fakeMediaInfoEnsurer{fn: func(_ context.Context, request mediainfo.Request) (mediainfo.Record, error) {
+ return mediainfo.Record{Key: request.Key, Media: completeProjectionMedia()}, nil
+ }}
+ handler := fixture.handler(ensurer, registry, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.Header().Set("Content-Length", strconv.Itoa(len(raw)))
+ w.Header().Set("ETag", `"plex-etag"`)
+ w.Header().Set("Last-Modified", "Wed, 01 Jan 2025 00:00:00 GMT")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(raw)
+ }))
+
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, authenticatedMetadataRequest(http.MethodGet))
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d", response.Code)
+ }
+ if bytes.Equal(response.Body.Bytes(), raw) {
+ t.Fatal("metadata body was not enriched")
+ }
+ if !strings.Contains(response.Body.String(), `container="mkv"`) || !strings.Contains(response.Body.String(), `codec="hevc"`) {
+ t.Fatalf("enriched XML missing expected fields: %s", response.Body.String())
+ }
+ if response.Header().Get("ETag") != "" || response.Header().Get("Last-Modified") != "" {
+ t.Fatalf("stale validators retained: %#v", response.Header())
+ }
+ if response.Header().Get("Cache-Control") != "private, no-cache" || response.Header().Get("Content-Length") != strconv.Itoa(response.Body.Len()) {
+ t.Fatalf("mutated headers = %#v", response.Header())
+ }
+ requests := ensurer.requests()
+ if len(requests) != 1 || requests[0].Key.PartID != "9" || len(requests[0].Key.STRMFingerprint) != 64 || requests[0].Target != fixture.target || requests[0].ClientUserAgent != "Infuse-Test/1.0" {
+ t.Fatalf("MediaInfo request = %#v", requests)
+ }
+ snapshot := registry.Snapshot()
+ if snapshot.MediaInfoEnrichedTotal != 1 || snapshot.MediaInfoWaitActive != 0 || snapshot.MediaInfoFailOpenTotal != 0 {
+ t.Fatalf("metrics = %#v", snapshot)
+ }
+}
+
+func TestMetadataEnrichmentPreservesGzipJSON(t *testing.T) {
+ fixture := newMetadataEnrichmentFixture(t)
+ rawJSON := []byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"42","type":"episode","Media":[{"Part":[{"id":"9","key":"/library/parts/9/1/file.strm","file":"/media/cloud/episode.strm","Stream":[{"id":"101","streamType":1,"index":0}]}]}]}]}}`)
+ wire := gzipBytes(t, rawJSON)
+ ensurer := &fakeMediaInfoEnsurer{fn: func(_ context.Context, request mediainfo.Request) (mediainfo.Record, error) {
+ return mediainfo.Record{Key: request.Key, Media: completeProjectionMedia()}, nil
+ }}
+ handler := fixture.handler(ensurer, metrics.New(), http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Content-Encoding", "gzip")
+ w.Header().Set("Content-Length", strconv.Itoa(len(wire)))
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(wire)
+ }))
+
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, authenticatedMetadataRequest(http.MethodGet))
+ if response.Header().Get("Content-Encoding") != "gzip" || response.Header().Get("Content-Length") != strconv.Itoa(response.Body.Len()) {
+ t.Fatalf("gzip headers = %#v", response.Header())
+ }
+ decoded := gunzipBytes(t, response.Body.Bytes())
+ var value map[string]any
+ if err := json.Unmarshal(decoded, &value); err != nil {
+ t.Fatalf("decode enriched JSON: %v", err)
+ }
+ if !bytes.Contains(decoded, []byte(`"container":"mkv"`)) || !bytes.Contains(decoded, []byte(`"codec":"hevc"`)) {
+ t.Fatalf("enriched JSON missing expected fields: %s", decoded)
+ }
+}
+
+func TestMetadataEnrichmentTimeoutReturnsOriginalResponse(t *testing.T) {
+ fixture := newMetadataEnrichmentFixture(t)
+ raw := fixture.xmlBody()
+ ensurer := &fakeMediaInfoEnsurer{fn: func(ctx context.Context, _ mediainfo.Request) (mediainfo.Record, error) {
+ <-ctx.Done()
+ return mediainfo.Record{}, ctx.Err()
+ }}
+ handler := newMetadataEnrichmentHandler(metadataEnrichmentOptions{
+ Service: ensurer, Mapper: fixture.mapper, Resolver: fixture.resolver,
+ CloudExtensions: []string{".strm"}, ColdWait: 10 * time.Millisecond,
+ ResponseLimit: 1 << 20, Concurrency: 1, Metrics: metrics.New(),
+ }, metadataBodyHandler(raw, "application/xml"))
+
+ response := httptest.NewRecorder()
+ started := time.Now()
+ handler.ServeHTTP(response, authenticatedMetadataRequest(http.MethodGet))
+ if elapsed := time.Since(started); elapsed > 500*time.Millisecond {
+ t.Fatalf("cold timeout took %s", elapsed)
+ }
+ if !bytes.Equal(response.Body.Bytes(), raw) || response.Header().Get("ETag") != `"plex-etag"` {
+ t.Fatalf("fail-open response changed: headers=%#v body=%s", response.Header(), response.Body.Bytes())
+ }
+}
+
+func TestMetadataEnrichmentRejectsFingerprintChange(t *testing.T) {
+ fixture := newMetadataEnrichmentFixture(t)
+ raw := fixture.xmlBody()
+ ensurer := &fakeMediaInfoEnsurer{fn: func(_ context.Context, request mediainfo.Request) (mediainfo.Record, error) {
+ if err := os.WriteFile(fixture.strmPath, []byte("http://mediavault:7811/redirect/changed/file.mkv\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return mediainfo.Record{Key: request.Key, Media: completeProjectionMedia()}, nil
+ }}
+ handler := fixture.handler(ensurer, metrics.New(), metadataBodyHandler(raw, "application/xml"))
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, authenticatedMetadataRequest(http.MethodGet))
+ if !bytes.Equal(response.Body.Bytes(), raw) {
+ t.Fatalf("response changed after STRM identity changed: %s", response.Body.Bytes())
+ }
+}
+
+func TestMetadataEnrichmentPoolSaturationSkipsWaiting(t *testing.T) {
+ fixture := newMetadataEnrichmentFixture(t)
+ raw := fixture.xmlBody()
+ started := make(chan struct{})
+ release := make(chan struct{})
+ ensurer := &fakeMediaInfoEnsurer{fn: func(_ context.Context, request mediainfo.Request) (mediainfo.Record, error) {
+ close(started)
+ <-release
+ return mediainfo.Record{Key: request.Key, Media: completeProjectionMedia()}, nil
+ }}
+ registry := metrics.New()
+ handler := newMetadataEnrichmentHandler(metadataEnrichmentOptions{
+ Service: ensurer, Mapper: fixture.mapper, Resolver: fixture.resolver,
+ CloudExtensions: []string{".strm"}, ColdWait: time.Second,
+ ResponseLimit: 1 << 20, Concurrency: 1, Metrics: registry,
+ }, metadataBodyHandler(raw, "application/xml"))
+
+ firstDone := make(chan *httptest.ResponseRecorder, 1)
+ go func() {
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, authenticatedMetadataRequest(http.MethodGet))
+ firstDone <- response
+ }()
+ select {
+ case <-started:
+ case <-time.After(time.Second):
+ t.Fatal("first enrichment did not start")
+ }
+ second := httptest.NewRecorder()
+ handler.ServeHTTP(second, authenticatedMetadataRequest(http.MethodGet))
+ if !bytes.Equal(second.Body.Bytes(), raw) || ensurer.calls.Load() != 1 {
+ t.Fatalf("saturated response=%s calls=%d", second.Body.Bytes(), ensurer.calls.Load())
+ }
+ close(release)
+ select {
+ case first := <-firstDone:
+ if bytes.Equal(first.Body.Bytes(), raw) {
+ t.Fatal("first response was not enriched")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("first enrichment did not finish")
+ }
+ if registry.Snapshot().MediaInfoWaitRejectedTotal != 1 {
+ t.Fatalf("metrics = %#v", registry.Snapshot())
+ }
+}
+
+func TestMetadataEnrichmentBypassesIneligibleAndUnbufferableResponses(t *testing.T) {
+ fixture := newMetadataEnrichmentFixture(t)
+ raw := fixture.xmlBody()
+ oversized := bytes.Repeat([]byte("x"), 1025)
+ ensurer := &fakeMediaInfoEnsurer{fn: func(_ context.Context, _ mediainfo.Request) (mediainfo.Record, error) {
+ return mediainfo.Record{}, errors.New("unexpected Ensure")
+ }}
+ tests := []struct {
+ name string
+ request *http.Request
+ next http.Handler
+ wantStatus int
+ wantBody []byte
+ }{
+ {name: "HEAD", request: authenticatedMetadataRequest(http.MethodHead), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK},
+ {name: "batch", request: authenticatedRequest(http.MethodGet, "/library/metadata/42,43"), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK, wantBody: raw},
+ {name: "children", request: authenticatedRequest(http.MethodGet, "/library/metadata/42/children"), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK, wantBody: raw},
+ {name: "Part playback", request: authenticatedRequest(http.MethodGet, "/library/parts/9/1/file.strm"), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK, wantBody: raw},
+ {name: "decision", request: authenticatedRequest(http.MethodGet, "/video/:/transcode/universal/decision"), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK, wantBody: raw},
+ {name: "universal start", request: authenticatedRequest(http.MethodGet, "/video/:/transcode/universal/start.mpd"), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK, wantBody: raw},
+ {name: "timeline", request: authenticatedRequest(http.MethodGet, "/:/timeline"), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK, wantBody: raw},
+ {name: "missing token", request: httptest.NewRequest(http.MethodGet, "/library/metadata/42", nil), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK, wantBody: raw},
+ {name: "conflicting token", request: conflictingTokenRequest(), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK, wantBody: raw},
+ {name: "range", request: rangeMetadataRequest(), next: metadataBodyHandler(raw, "application/xml"), wantStatus: http.StatusOK, wantBody: raw},
+ {name: "unsupported encoding", request: authenticatedMetadataRequest(http.MethodGet), next: encodedMetadataHandler(raw, "br"), wantStatus: http.StatusOK, wantBody: raw},
+ {name: "oversized", request: authenticatedMetadataRequest(http.MethodGet), next: metadataBodyHandler(oversized, "application/xml"), wantStatus: http.StatusOK, wantBody: oversized},
+ {name: "not modified", request: authenticatedMetadataRequest(http.MethodGet), next: metadataStatusHandler(http.StatusNotModified, nil), wantStatus: http.StatusNotModified},
+ {name: "partial content", request: authenticatedMetadataRequest(http.MethodGet), next: metadataStatusHandler(http.StatusPartialContent, []byte("partial")), wantStatus: http.StatusPartialContent, wantBody: []byte("partial")},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ handler := newMetadataEnrichmentHandler(metadataEnrichmentOptions{
+ Service: ensurer, Mapper: fixture.mapper, Resolver: fixture.resolver,
+ CloudExtensions: []string{".strm"}, ColdWait: time.Second,
+ ResponseLimit: 1024, Concurrency: 1, Metrics: metrics.New(),
+ }, test.next)
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, test.request)
+ if response.Code != test.wantStatus || !bytes.Equal(response.Body.Bytes(), test.wantBody) {
+ t.Fatalf("response status=%d body=%q, want status=%d body=%q", response.Code, response.Body.Bytes(), test.wantStatus, test.wantBody)
+ }
+ })
+ }
+ if ensurer.calls.Load() != 0 {
+ t.Fatalf("ineligible responses called Ensure %d times", ensurer.calls.Load())
+ }
+}
+
+func TestMetadataEnrichmentSkipsLocalAndCompleteCloudParts(t *testing.T) {
+ fixture := newMetadataEnrichmentFixture(t)
+ ensurer := &fakeMediaInfoEnsurer{fn: func(_ context.Context, _ mediainfo.Request) (mediainfo.Record, error) {
+ return mediainfo.Record{}, errors.New("unexpected Ensure")
+ }}
+ tests := []struct {
+ name string
+ body []byte
+ }{
+ {
+ name: "local Part",
+ body: []byte(``),
+ },
+ {
+ name: "complete cloud Part",
+ body: []byte(``),
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ handler := fixture.handler(ensurer, metrics.New(), metadataBodyHandler(test.body, "application/xml"))
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, authenticatedMetadataRequest(http.MethodGet))
+ if !bytes.Equal(response.Body.Bytes(), test.body) || response.Header().Get("ETag") != `"plex-etag"` {
+ t.Fatalf("response changed: headers=%#v body=%s", response.Header(), response.Body.Bytes())
+ }
+ })
+ }
+ if ensurer.calls.Load() != 0 {
+ t.Fatalf("skipped Parts called Ensure %d times", ensurer.calls.Load())
+ }
+}
+
+type metadataEnrichmentFixture struct {
+ mapper *pathmap.Mapper
+ resolver resolver.ControlResolver
+ strmPath string
+ target string
+}
+
+func newMetadataEnrichmentFixture(t *testing.T) metadataEnrichmentFixture {
+ t.Helper()
+ directory := t.TempDir()
+ strmPath := filepath.Join(directory, "episode.strm")
+ target := "http://mediavault:7811/redirect/pick/episode.mkv"
+ if err := os.WriteFile(strmPath, []byte(target+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ mapper, err := pathmap.New([]pathmap.Mapping{{PlexPrefix: "/media/cloud", LocalPrefix: directory}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ strmResolver, err := resolver.NewMediaVaultSTRMResolver("http://mediavault:7811", nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return metadataEnrichmentFixture{mapper: mapper, resolver: strmResolver, strmPath: strmPath, target: target}
+}
+
+func (fixture metadataEnrichmentFixture) handler(ensurer mediaInfoEnsurer, registry *metrics.Metrics, next http.Handler) http.Handler {
+ return newMetadataEnrichmentHandler(metadataEnrichmentOptions{
+ Service: ensurer, Mapper: fixture.mapper, Resolver: fixture.resolver,
+ CloudExtensions: []string{".strm"}, ColdWait: time.Second,
+ ResponseLimit: 1 << 20, Concurrency: 2, Metrics: registry,
+ }, next)
+}
+
+func (fixture metadataEnrichmentFixture) xmlBody() []byte {
+ return []byte(``)
+}
+
+func completeProjectionMedia() mediainfo.Media {
+ return mediainfo.Media{
+ Complete: true, Container: "mkv", DurationMS: 60_000, Size: 1_000_000,
+ Bitrate: 8_000, VideoCodec: "hevc", Width: 3840, Height: 2160,
+ Streams: []mediainfo.Stream{{Index: 0, Type: "video", Codec: "hevc", Width: 3840, Height: 2160, HDRFormat: "dolby_vision"}},
+ }
+}
+
+func authenticatedMetadataRequest(method string) *http.Request {
+ return authenticatedRequest(method, "/library/metadata/42")
+}
+
+func authenticatedRequest(method, path string) *http.Request {
+ request := httptest.NewRequest(method, path, nil)
+ request.Header.Set("X-Plex-Token", "client-token")
+ request.Header.Set("User-Agent", "Infuse-Test/1.0")
+ return request
+}
+
+func conflictingTokenRequest() *http.Request {
+ request := authenticatedRequest(http.MethodGet, "/library/metadata/42?X-Plex-Token=query-token")
+ request.Header.Set("X-Plex-Token", "header-token")
+ return request
+}
+
+func rangeMetadataRequest() *http.Request {
+ request := authenticatedMetadataRequest(http.MethodGet)
+ request.Header.Set("Range", "bytes=0-100")
+ return request
+}
+
+func metadataBodyHandler(body []byte, contentType string) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
+ w.Header().Set("ETag", `"plex-etag"`)
+ w.WriteHeader(http.StatusOK)
+ if request.Method != http.MethodHead {
+ _, _ = w.Write(body)
+ }
+ })
+}
+
+func encodedMetadataHandler(body []byte, encoding string) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.Header().Set("Content-Encoding", encoding)
+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(body)
+ })
+}
+
+func metadataStatusHandler(status int, body []byte) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
+ if status == http.StatusPartialContent {
+ w.Header().Set("Content-Range", "bytes 0-6/100")
+ }
+ w.WriteHeader(status)
+ _, _ = w.Write(body)
+ })
+}
+
+func gzipBytes(t *testing.T, body []byte) []byte {
+ t.Helper()
+ var output bytes.Buffer
+ writer := gzip.NewWriter(&output)
+ if _, err := writer.Write(body); err != nil {
+ t.Fatal(err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return output.Bytes()
+}
+
+func gunzipBytes(t *testing.T, body []byte) []byte {
+ t.Helper()
+ reader, err := gzip.NewReader(bytes.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reader.Close()
+ decoded, err := io.ReadAll(reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return decoded
+}
diff --git a/internal/mediainfo/benchmark_test.go b/internal/mediainfo/benchmark_test.go
index a5e3f57..bcbebef 100644
--- a/internal/mediainfo/benchmark_test.go
+++ b/internal/mediainfo/benchmark_test.go
@@ -46,12 +46,9 @@ func BenchmarkMediaInfoL1PutAtCapacity(b *testing.B) {
func BenchmarkMediaInfoSQLiteGet(b *testing.B) {
now := time.Now()
record := completeRecord(now)
- store, err := OpenSQLite(context.Background(), filepath.Join(b.TempDir(), "mediainfo.db"))
- if err != nil {
- b.Fatal(err)
- }
+ store, gatewayDB := openTestSQLiteStore(b, filepath.Join(b.TempDir(), "plex-gateway.db"))
b.Cleanup(func() {
- if err := store.Close(); err != nil {
+ if err := gatewayDB.Close(); err != nil {
b.Error(err)
}
})
diff --git a/internal/mediainfo/model.go b/internal/mediainfo/model.go
index 5164732..5ebda19 100644
--- a/internal/mediainfo/model.go
+++ b/internal/mediainfo/model.go
@@ -233,12 +233,7 @@ func FingerprintSTRM(path string) (string, error) {
if target == "" {
continue
}
- normalized, err := normalizeSTRMTarget(target)
- if err != nil {
- return "", err
- }
- digest := sha256.Sum256([]byte(normalized))
- return hex.EncodeToString(digest[:]), nil
+ return FingerprintSTRMTarget(target)
}
if err := scanner.Err(); err != nil {
return "", errors.New("STRM control target exceeds fingerprint limit")
@@ -246,6 +241,18 @@ func FingerprintSTRM(path string) (string, error) {
return "", errors.New("STRM control file is empty")
}
+// FingerprintSTRMTarget hashes a target already read and validated from a STRM
+// file. Callers that also need the target can avoid a second filesystem read,
+// keeping the analysis identity bound to the exact control value being probed.
+func FingerprintSTRMTarget(target string) (string, error) {
+ normalized, err := normalizeSTRMTarget(strings.TrimSpace(target))
+ if err != nil {
+ return "", err
+ }
+ digest := sha256.Sum256([]byte(normalized))
+ return hex.EncodeToString(digest[:]), nil
+}
+
func normalizeSTRMTarget(target string) (string, error) {
if strings.HasPrefix(target, "/") {
return "path:" + target, nil
diff --git a/internal/mediainfo/model_test.go b/internal/mediainfo/model_test.go
index d6739e6..b4ba0bd 100644
--- a/internal/mediainfo/model_test.go
+++ b/internal/mediainfo/model_test.go
@@ -58,6 +58,25 @@ func TestFingerprintSTRMNormalizesFormattingAndMediaVaultOrigin(t *testing.T) {
}
}
+func TestFingerprintSTRMTargetMatchesFileFingerprint(t *testing.T) {
+ target := "http://mediavault:7811/redirect/example/file.mkv"
+ path := filepath.Join(t.TempDir(), "sample.strm")
+ if err := os.WriteFile(path, []byte("\n"+target+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ fromFile, err := FingerprintSTRM(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fromTarget, err := FingerprintSTRMTarget(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fromTarget != fromFile {
+ t.Fatalf("target fingerprint = %q, file fingerprint = %q", fromTarget, fromFile)
+ }
+}
+
func TestCacheReturnsDetachedFreshRecord(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
record := completeRecord(now)
diff --git a/internal/mediainfo/negative_cache_test.go b/internal/mediainfo/negative_cache_test.go
new file mode 100644
index 0000000..bf13146
--- /dev/null
+++ b/internal/mediainfo/negative_cache_test.go
@@ -0,0 +1,317 @@
+package mediainfo
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+type scriptedMediaProber struct {
+ calls atomic.Int64
+ errors []error
+ userAgents chan string
+}
+
+type proberFunc func(context.Context, string, string) (Media, error)
+
+func (probe proberFunc) Probe(ctx context.Context, target, userAgent string) (Media, error) {
+ return probe(ctx, target, userAgent)
+}
+
+type synchronizedBuffer struct {
+ mu sync.Mutex
+ buffer bytes.Buffer
+}
+
+type observedDoneContext struct {
+ context.Context
+ once sync.Once
+ observed chan struct{}
+}
+
+func (ctx *observedDoneContext) Done() <-chan struct{} {
+ ctx.once.Do(func() { close(ctx.observed) })
+ return ctx.Context.Done()
+}
+
+func (buffer *synchronizedBuffer) Write(value []byte) (int, error) {
+ buffer.mu.Lock()
+ defer buffer.mu.Unlock()
+ return buffer.buffer.Write(value)
+}
+
+func (buffer *synchronizedBuffer) String() string {
+ buffer.mu.Lock()
+ defer buffer.mu.Unlock()
+ return buffer.buffer.String()
+}
+
+func (prober *scriptedMediaProber) Probe(_ context.Context, _ string, userAgent string) (Media, error) {
+ index := int(prober.calls.Add(1)) - 1
+ if prober.userAgents != nil {
+ prober.userAgents <- userAgent
+ }
+ if index < len(prober.errors) && prober.errors[index] != nil {
+ return Media{}, fmt.Errorf("probe failed for %s: %w", userAgent, prober.errors[index])
+ }
+ return Media{
+ Complete: true, Container: "mkv", DurationMS: 60_000,
+ Streams: []Stream{{Index: 0, Type: "video", Codec: "hevc", Width: 1920, Height: 1080}},
+ }, nil
+}
+
+func TestServiceNegativeCacheSeparatesUserAgents(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0).UTC()
+ probeErr := errors.New("probe unavailable")
+ prober := &scriptedMediaProber{errors: []error{probeErr, probeErr}}
+ provider := &fakeProvider{prober: prober}
+ logBuffer := new(synchronizedBuffer)
+ service := newServiceForTest(t, ServiceOptions{
+ Cache: NewCache(nil, now), Store: &fakeRecordStore{}, Provider: provider,
+ PlexServerID: "server", NegativeTTL: time.Hour,
+ Logger: slog.New(slog.NewTextHandler(logBuffer, nil)), Now: func() time.Time { return now },
+ })
+ requestA := testRequest("negative-ua", PriorityInteractive)
+ requestA.ClientUserAgent = "client-agent-a"
+ requestB := requestA
+ requestB.ClientUserAgent = "client-agent-b"
+
+ if _, err := service.Ensure(t.Context(), requestA); !errors.Is(err, probeErr) {
+ t.Fatalf("first User-Agent error = %v", err)
+ }
+ if _, err := service.Ensure(t.Context(), requestA); !errors.Is(err, ErrNegativeCache) {
+ t.Fatalf("same User-Agent retry error = %v", err)
+ }
+ if _, err := service.Ensure(t.Context(), requestB); !errors.Is(err, probeErr) {
+ t.Fatalf("different User-Agent error = %v", err)
+ }
+ if _, err := service.Ensure(t.Context(), requestB); !errors.Is(err, ErrNegativeCache) {
+ t.Fatalf("different User-Agent retry error = %v", err)
+ }
+ if got := provider.calls.Load(); got != 2 {
+ t.Fatalf("provider calls = %d, want 2", got)
+ }
+
+ service.mu.Lock()
+ if len(service.negative) != 2 {
+ service.mu.Unlock()
+ t.Fatalf("negative cache entries = %d, want 2", len(service.negative))
+ }
+ for key := range service.negative {
+ if strings.Contains(key, requestA.ClientUserAgent) || strings.Contains(key, requestB.ClientUserAgent) {
+ service.mu.Unlock()
+ t.Fatalf("negative cache key contains raw User-Agent: %q", key)
+ }
+ }
+ service.mu.Unlock()
+ logs := logBuffer.String()
+ if strings.Contains(logs, requestA.ClientUserAgent) || strings.Contains(logs, requestB.ClientUserAgent) {
+ t.Fatalf("failure logs contain raw User-Agent: %s", logs)
+ }
+}
+
+func TestServiceNegativeCacheUsesBackgroundUserAgentForEmptyRequest(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0).UTC()
+ backgroundUserAgent := "background-agent"
+ probeErr := errors.New("probe unavailable")
+ prober := &scriptedMediaProber{
+ errors: []error{probeErr},
+ userAgents: make(chan string, 1),
+ }
+ provider := &fakeProvider{prober: prober}
+ service := newServiceForTest(t, ServiceOptions{
+ Cache: NewCache(nil, now), Store: &fakeRecordStore{}, Provider: provider,
+ PlexServerID: "server", BackgroundUserAgent: backgroundUserAgent,
+ NegativeTTL: time.Hour, Now: func() time.Time { return now },
+ })
+ request := testRequest("negative-background-ua", PriorityInteractive)
+ request.ClientUserAgent = ""
+ if _, err := service.Ensure(t.Context(), request); !errors.Is(err, probeErr) {
+ t.Fatalf("empty User-Agent error = %v", err)
+ }
+ select {
+ case got := <-prober.userAgents:
+ if got != backgroundUserAgent {
+ t.Fatalf("effective User-Agent = %q, want %q", got, backgroundUserAgent)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("probe User-Agent was not captured")
+ }
+
+ explicit := request
+ explicit.ClientUserAgent = backgroundUserAgent
+ if _, err := service.Ensure(t.Context(), explicit); !errors.Is(err, ErrNegativeCache) {
+ t.Fatalf("explicit background User-Agent retry error = %v", err)
+ }
+ if got := provider.calls.Load(); got != 1 {
+ t.Fatalf("provider calls = %d, want 1", got)
+ }
+ service.mu.Lock()
+ for key := range service.negative {
+ if strings.Contains(key, backgroundUserAgent) {
+ service.mu.Unlock()
+ t.Fatalf("negative cache key contains raw background User-Agent: %q", key)
+ }
+ }
+ service.mu.Unlock()
+}
+
+func TestServiceDoesNotNegativeCacheCanceledProbe(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0).UTC()
+ prober := &scriptedMediaProber{errors: []error{fmt.Errorf("probe canceled: %w", context.Canceled), nil}}
+ provider := &fakeProvider{prober: prober}
+ service := newServiceForTest(t, ServiceOptions{
+ Cache: NewCache(nil, now), Store: &fakeRecordStore{}, Provider: provider,
+ PlexServerID: "server", NegativeTTL: time.Hour, Now: func() time.Time { return now },
+ })
+ request := testRequest("canceled-probe", PriorityInteractive)
+ if _, err := service.Ensure(t.Context(), request); !errors.Is(err, context.Canceled) {
+ t.Fatalf("canceled probe error = %v", err)
+ }
+ if _, err := service.Ensure(t.Context(), request); err != nil {
+ t.Fatalf("retry after cancellation error = %v", err)
+ }
+ if got := provider.calls.Load(); got != 2 {
+ t.Fatalf("provider calls = %d, want 2", got)
+ }
+ service.mu.Lock()
+ negativeEntries := len(service.negative)
+ service.mu.Unlock()
+ if negativeEntries != 0 {
+ t.Fatalf("negative cache entries after cancellation = %d", negativeEntries)
+ }
+}
+
+func TestServiceSingleflightsSuccessfulProbesAcrossUserAgents(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0).UTC()
+ prober := &blockingProber{started: make(chan string, 1), release: make(chan struct{})}
+ provider := &fakeProvider{prober: prober}
+ service := newServiceForTest(t, ServiceOptions{
+ Cache: NewCache(nil, now), Store: &fakeRecordStore{}, Provider: provider,
+ PlexServerID: "server", Now: func() time.Time { return now },
+ })
+ requestA := testRequest("singleflight-ua", PriorityInteractive)
+ requestA.ClientUserAgent = "client-agent-a"
+ requestB := requestA
+ requestB.ClientUserAgent = "client-agent-b"
+
+ flight, err := service.begin(requestA)
+ if err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-prober.started:
+ case <-time.After(time.Second):
+ t.Fatal("probe did not start")
+ }
+ sameFlight, err := service.begin(requestB)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if sameFlight != flight {
+ t.Fatal("different User-Agent did not join the existing Key flight")
+ }
+ close(prober.release)
+ select {
+ case <-flight.done:
+ if flight.err != nil {
+ t.Fatalf("singleflight error = %v", flight.err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("singleflight did not finish")
+ }
+ if got := provider.calls.Load(); got != 1 {
+ t.Fatalf("provider calls = %d, want 1", got)
+ }
+ if _, err := service.Ensure(t.Context(), requestB); err != nil {
+ t.Fatalf("successful record lookup with different User-Agent error = %v", err)
+ }
+ if got := provider.calls.Load(); got != 1 {
+ t.Fatalf("provider calls after cached lookup = %d, want 1", got)
+ }
+}
+
+func TestServiceRetriesFailedSharedFlightWithWaitingUserAgent(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0).UTC()
+ releaseFirst := make(chan struct{})
+ userAgents := make(chan string, 2)
+ var calls atomic.Int64
+ prober := proberFunc(func(ctx context.Context, _ string, userAgent string) (Media, error) {
+ call := calls.Add(1)
+ userAgents <- userAgent
+ if call == 1 {
+ select {
+ case <-ctx.Done():
+ return Media{}, ctx.Err()
+ case <-releaseFirst:
+ return Media{}, errors.New("first User-Agent rejected")
+ }
+ }
+ return Media{
+ Complete: true, Container: "mkv", DurationMS: 60_000,
+ Streams: []Stream{{Index: 0, Type: "video", Codec: "hevc", Width: 1920, Height: 1080}},
+ }, nil
+ })
+ service := newServiceForTest(t, ServiceOptions{
+ Cache: NewCache(nil, now), Store: &fakeRecordStore{}, Provider: &fakeProvider{prober: prober},
+ PlexServerID: "server", NegativeTTL: time.Hour, Now: func() time.Time { return now },
+ })
+ requestA := testRequest("concurrent-failed-ua", PriorityInteractive)
+ requestA.ClientUserAgent = "client-agent-a"
+ requestB := requestA
+ requestB.ClientUserAgent = "client-agent-b"
+
+ resultA := make(chan error, 1)
+ go func() {
+ _, err := service.Ensure(t.Context(), requestA)
+ resultA <- err
+ }()
+ select {
+ case got := <-userAgents:
+ if got != requestA.ClientUserAgent {
+ t.Fatalf("first probe User-Agent = %q", got)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("first probe did not start")
+ }
+
+ waitObserved := make(chan struct{})
+ waitContext, cancel := context.WithTimeout(t.Context(), time.Second)
+ defer cancel()
+ resultB := make(chan error, 1)
+ go func() {
+ _, err := service.Ensure(&observedDoneContext{Context: waitContext, observed: waitObserved}, requestB)
+ resultB <- err
+ }()
+ select {
+ case <-waitObserved:
+ case <-time.After(time.Second):
+ t.Fatal("second User-Agent did not join the shared flight")
+ }
+ close(releaseFirst)
+
+ if err := <-resultA; err == nil || !strings.Contains(err.Error(), "first User-Agent rejected") {
+ t.Fatalf("first User-Agent error = %v", err)
+ }
+ select {
+ case got := <-userAgents:
+ if got != requestB.ClientUserAgent {
+ t.Fatalf("retry probe User-Agent = %q", got)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("waiting User-Agent retry did not start")
+ }
+ if err := <-resultB; err != nil {
+ t.Fatalf("waiting User-Agent retry error = %v", err)
+ }
+ if got := calls.Load(); got != 2 {
+ t.Fatalf("probe calls = %d, want 2", got)
+ }
+}
diff --git a/internal/mediainfo/service.go b/internal/mediainfo/service.go
index 7bd56c3..63c3ef7 100644
--- a/internal/mediainfo/service.go
+++ b/internal/mediainfo/service.go
@@ -2,6 +2,7 @@ package mediainfo
import (
"context"
+ "crypto/sha256"
"errors"
"log/slog"
"strings"
@@ -162,10 +163,11 @@ type recordTouch struct {
}
type flight struct {
- done chan struct{}
- job *job
- record Record
- err error
+ done chan struct{}
+ job *job
+ userAgent string
+ record Record
+ err error
}
type job struct {
@@ -313,15 +315,26 @@ func (service *Service) Ensure(ctx context.Context, request Request) (Record, er
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
+ for {
+ if err := ctx.Err(); err != nil {
+ return Record{}, err
+ }
+ flight, err := service.begin(request)
+ if err != nil {
+ return Record{}, err
+ }
+ select {
+ case <-ctx.Done():
+ return Record{}, ctx.Err()
+ case <-flight.done:
+ if flight.err != nil && flight.userAgent != request.ClientUserAgent {
+ // Successful technical metadata is UA-independent and remains
+ // shared. A failed provider attempt is UA-sensitive, so a waiter
+ // with another client identity may retry within its own deadline.
+ continue
+ }
+ return cloneRecord(flight.record), flight.err
+ }
}
}
@@ -352,6 +365,10 @@ func (service *Service) normalizeRequest(request Request) (Request, error) {
if request.Key.PlexServerID != service.plexServerID {
return Request{}, errors.New("MediaInfo request belongs to another Plex server")
}
+ request.ClientUserAgent = strings.TrimSpace(request.ClientUserAgent)
+ if request.ClientUserAgent == "" {
+ request.ClientUserAgent = service.backgroundUserAgent
+ }
if err := request.validate(); err != nil {
return Request{}, err
}
@@ -420,18 +437,19 @@ func (service *Service) begin(request Request) (*flight, error) {
return nil, err
}
key := request.Key.cacheKey()
+ negativeKey := service.negativeKey(request)
now := service.now()
service.mu.Lock()
if service.closed {
service.mu.Unlock()
return nil, ErrServiceUnavailable
}
- if until, exists := service.negative[key]; exists {
+ if until, exists := service.negative[negativeKey]; exists {
if now.Before(until) {
service.mu.Unlock()
return nil, ErrNegativeCache
}
- delete(service.negative, key)
+ delete(service.negative, negativeKey)
}
if existing, exists := service.flights[key]; exists {
if request.Priority == PriorityInteractive {
@@ -440,7 +458,7 @@ func (service *Service) begin(request Request) (*flight, error) {
service.mu.Unlock()
return existing, nil
}
- flight := &flight{done: make(chan struct{})}
+ flight := &flight{done: make(chan struct{}), userAgent: request.ClientUserAgent}
queued := &job{request: request, flight: flight}
flight.job = queued
if !service.enqueueLocked(queued) {
@@ -626,7 +644,7 @@ func (service *Service) finish(queued *job, record Record, err error) {
}
delete(service.flights, key)
if err != nil && !errors.Is(err, context.Canceled) {
- service.rememberNegativeLocked(key, service.now())
+ service.rememberNegativeLocked(service.negativeKey(queued.request), service.now())
}
flight.record = record
flight.err = err
@@ -640,6 +658,11 @@ func (service *Service) finish(queued *job, record Record, err error) {
service.metric(func(metrics ServiceMetrics) { metrics.IncMediaInfoProbeSuccess() })
}
+func (service *Service) negativeKey(request Request) string {
+ digest := sha256.Sum256([]byte(request.ClientUserAgent))
+ return request.Key.cacheKey() + "\x00" + string(digest[:])
+}
+
func (service *Service) rememberNegativeLocked(key string, now time.Time) {
for candidate, until := range service.negative {
if !now.Before(until) {
diff --git a/internal/mediainfo/sqlite.go b/internal/mediainfo/sqlite.go
index 7d94a7a..a32d605 100644
--- a/internal/mediainfo/sqlite.go
+++ b/internal/mediainfo/sqlite.go
@@ -6,18 +6,16 @@ import (
"encoding/json"
"errors"
"fmt"
- "os"
- "path/filepath"
"strings"
"time"
- _ "modernc.org/sqlite"
+ "github.com/InfinityPacer/plex-gateway/internal/database"
)
const (
- sqliteDatabaseVersion = 1
- sqliteSchemaV1 = `
-CREATE TABLE IF NOT EXISTS media_info_records (
+ mediaInfoMigrationModule = "mediainfo"
+ mediaInfoSchemaV1 = `
+ CREATE TABLE IF NOT EXISTS media_info_records (
plex_server_id TEXT NOT NULL,
part_id TEXT NOT NULL,
strm_fingerprint TEXT NOT NULL,
@@ -39,87 +37,24 @@ CREATE INDEX IF NOT EXISTS media_info_records_retain_until
`
)
-// SQLiteStore persists successful MediaInfo results. It uses one database
-// connection because SQLite is the single-instance writer in this design.
+// SQLiteStore persists successful MediaInfo results in the shared Gateway
+// database without owning the connection lifecycle.
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()
+// NewSQLiteStore attaches the MediaInfo domain to an open Gateway database and
+// applies only the migrations owned by this domain.
+func NewSQLiteStore(ctx context.Context, gatewayDB *database.SQLite) (*SQLiteStore, error) {
+ if gatewayDB == nil || gatewayDB.SQLDB() == nil {
+ return nil, errors.New("Gateway database is unavailable")
+ }
+ if err := gatewayDB.ApplyMigrations(ctx, mediaInfoMigrationModule, []database.Migration{
+ {Version: 1, SQL: mediaInfoSchemaV1},
+ }); err != nil {
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
+ return &SQLiteStore{db: gatewayDB.SQLDB()}, nil
}
// Put inserts or refreshes one complete record. A late older probe cannot
@@ -306,14 +241,6 @@ WHERE plex_server_id = ? AND part_id = ? AND strm_fingerprint = ?
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
}
diff --git a/internal/mediainfo/sqlite_test.go b/internal/mediainfo/sqlite_test.go
index 22d92fa..b977037 100644
--- a/internal/mediainfo/sqlite_test.go
+++ b/internal/mediainfo/sqlite_test.go
@@ -2,37 +2,44 @@ package mediainfo
import (
"context"
- "database/sql"
"path/filepath"
"strings"
"testing"
"time"
- _ "modernc.org/sqlite"
+ "github.com/InfinityPacer/plex-gateway/internal/database"
)
+func openTestSQLiteStore(tb testing.TB, path string) (*SQLiteStore, *database.SQLite) {
+ tb.Helper()
+ gatewayDB, err := database.OpenSQLite(context.Background(), path)
+ if err != nil {
+ tb.Fatal(err)
+ }
+ store, err := NewSQLiteStore(context.Background(), gatewayDB)
+ if err != nil {
+ _ = gatewayDB.Close()
+ tb.Fatal(err)
+ }
+ return store, gatewayDB
+}
+
func TestSQLiteStorePersistsRetainedRecordAcrossReopen(t *testing.T) {
ctx := context.Background()
- path := filepath.Join(t.TempDir(), "mediainfo.db")
+ path := filepath.Join(t.TempDir(), "plex-gateway.db")
now := time.Unix(1_800_000_000, 0).UTC()
record := completeRecord(now)
- store, err := OpenSQLite(ctx, path)
- if err != nil {
- t.Fatal(err)
- }
+ store, gatewayDB := openTestSQLiteStore(t, path)
if err := store.Put(ctx, record); err != nil {
t.Fatal(err)
}
- if err := store.Close(); err != nil {
+ if err := gatewayDB.Close(); err != nil {
t.Fatal(err)
}
- store, err = OpenSQLite(ctx, path)
- if err != nil {
- t.Fatal(err)
- }
- defer store.Close()
+ store, gatewayDB = openTestSQLiteStore(t, path)
+ defer gatewayDB.Close()
loaded, err := store.LoadCompatibleRetained(ctx, now, 100, testProviderDescriptor())
if err != nil {
t.Fatal(err)
@@ -43,45 +50,50 @@ func TestSQLiteStorePersistsRetainedRecordAcrossReopen(t *testing.T) {
}
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()
+ store, gatewayDB := openTestSQLiteStore(t, filepath.Join(t.TempDir(), "plex-gateway.db"))
+ defer gatewayDB.Close()
var version int
- if err := store.db.QueryRowContext(t.Context(), "PRAGMA user_version").Scan(&version); err != nil {
+ if err := store.db.QueryRowContext(t.Context(), `
+ SELECT MAX(version) FROM gateway_schema_migrations WHERE module = ?
+ `, mediaInfoMigrationModule).Scan(&version); err != nil {
t.Fatal(err)
}
- if version != sqliteDatabaseVersion {
+ if version != 1 {
t.Fatalf("schema version = %d", version)
}
}
func TestSQLiteStoreRejectsNewerSchema(t *testing.T) {
- path := filepath.Join(t.TempDir(), "mediainfo.db")
- database, err := sql.Open("sqlite", path)
+ path := filepath.Join(t.TempDir(), "plex-gateway.db")
+ gatewayDB, err := database.OpenSQLite(t.Context(), path)
if err != nil {
t.Fatal(err)
}
- if _, err := database.ExecContext(t.Context(), "PRAGMA user_version = 999"); err != nil {
+ if _, err := gatewayDB.SQLDB().ExecContext(t.Context(), `
+ INSERT INTO gateway_schema_migrations (module, version, applied_at_ms)
+ VALUES (?, 1, ?), (?, 2, ?)
+ `, mediaInfoMigrationModule, time.Now().UTC().UnixMilli(),
+ mediaInfoMigrationModule, time.Now().UTC().UnixMilli()); err != nil {
t.Fatal(err)
}
- if err := database.Close(); err != nil {
+ if err := gatewayDB.Close(); err != nil {
t.Fatal(err)
}
- _, err = OpenSQLite(t.Context(), path)
+ gatewayDB, err = database.OpenSQLite(t.Context(), path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer gatewayDB.Close()
+ _, err = NewSQLiteStore(t.Context(), gatewayDB)
if err == nil || !strings.Contains(err.Error(), "newer than supported") {
- t.Fatalf("OpenSQLite() error = %v", err)
+ t.Fatalf("NewSQLiteStore() 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()
+ store, gatewayDB := openTestSQLiteStore(t, filepath.Join(t.TempDir(), "plex-gateway.db"))
+ defer gatewayDB.Close()
now := time.Unix(1_800_000_000, 0).UTC()
newer := completeRecord(now)
@@ -105,11 +117,8 @@ func TestSQLiteStoreRejectsLateOlderProbe(t *testing.T) {
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()
+ store, gatewayDB := openTestSQLiteStore(t, filepath.Join(t.TempDir(), "plex-gateway.db"))
+ defer gatewayDB.Close()
now := time.Unix(1_800_000_000, 0).UTC()
unretained := completeRecord(now.Add(-200 * 24 * time.Hour))
@@ -141,11 +150,8 @@ func TestSQLiteStoreDeletesUnretainedWithoutAffectingStaleOrFresh(t *testing.T)
}
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()
+ store, gatewayDB := openTestSQLiteStore(t, filepath.Join(t.TempDir(), "plex-gateway.db"))
+ defer gatewayDB.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))
@@ -168,11 +174,8 @@ func testProviderDescriptor() ProviderDescriptor {
}
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()
+ store, gatewayDB := openTestSQLiteStore(t, filepath.Join(t.TempDir(), "plex-gateway.db"))
+ defer gatewayDB.Close()
now := time.Unix(1_800_000_000, 0).UTC()
if err := store.Put(t.Context(), completeRecord(now)); err != nil {
t.Fatal(err)
@@ -189,11 +192,8 @@ func TestSQLiteStoreDoesNotRestoreOtherProviderRevision(t *testing.T) {
}
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()
+ store, gatewayDB := openTestSQLiteStore(t, filepath.Join(t.TempDir(), "plex-gateway.db"))
+ defer gatewayDB.Close()
now := time.Unix(1_800_000_000, 0).UTC()
record := completeRecord(now)
if err := store.Put(t.Context(), record); err != nil {
diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go
index 7ad54df..ec44d39 100644
--- a/internal/metrics/metrics.go
+++ b/internal/metrics/metrics.go
@@ -37,6 +37,10 @@ type Metrics struct {
mediaInfoProbeFailure atomic.Uint64
mediaInfoStoreFailure atomic.Uint64
mediaInfoProbeActive atomic.Int64
+ mediaInfoEnriched atomic.Uint64
+ mediaInfoFailOpen atomic.Uint64
+ mediaInfoWaitActive atomic.Int64
+ mediaInfoWaitRejected atomic.Uint64
resolverLatency latencyMetrics
redirectLatency latencyMetrics
mediaInfoProbeLatency latencyMetrics
@@ -75,6 +79,10 @@ type Snapshot struct {
MediaInfoProbeFailureTotal uint64 `json:"mediainfo_probe_failure_total"`
MediaInfoStoreFailureTotal uint64 `json:"mediainfo_store_failure_total"`
MediaInfoProbeActive int64 `json:"mediainfo_probe_active"`
+ MediaInfoEnrichedTotal uint64 `json:"mediainfo_enriched_total"`
+ MediaInfoFailOpenTotal uint64 `json:"mediainfo_fail_open_total"`
+ MediaInfoWaitActive int64 `json:"mediainfo_wait_active"`
+ MediaInfoWaitRejectedTotal uint64 `json:"mediainfo_wait_rejected_total"`
ResolverLatencyMSTotal uint64 `json:"resolver_latency_ms_total"`
ResolverLatencySamples uint64 `json:"resolver_latency_samples"`
@@ -157,6 +165,25 @@ func (m *Metrics) ObserveMediaInfoProbeLatency(duration time.Duration) {
m.mediaInfoProbeLatency.observe(duration)
}
+// IncMediaInfoEnriched records a Plex metadata response that gained technical
+// fields from an identity-matched MediaInfo record.
+func (m *Metrics) IncMediaInfoEnriched() { m.mediaInfoEnriched.Add(1) }
+
+// IncMediaInfoFailOpen records an eligible response returned unchanged after
+// enrichment could not complete safely.
+func (m *Metrics) IncMediaInfoFailOpen() { m.mediaInfoFailOpen.Add(1) }
+
+// IncMediaInfoWaitActive marks one buffered metadata response waiting for an
+// identity-matched record.
+func (m *Metrics) IncMediaInfoWaitActive() { m.mediaInfoWaitActive.Add(1) }
+
+// DecMediaInfoWaitActive releases one metadata enrichment waiter.
+func (m *Metrics) DecMediaInfoWaitActive() { decrementGauge(&m.mediaInfoWaitActive) }
+
+// IncMediaInfoWaitRejected records an eligible response that skipped waiting
+// because the bounded enrichment pool was full.
+func (m *Metrics) IncMediaInfoWaitRejected() { m.mediaInfoWaitRejected.Add(1) }
+
// IncMetadataGuardAdmitted records a detailed metadata request admitted to
// Plex after both client and global limits were acquired.
func (m *Metrics) IncMetadataGuardAdmitted() {
@@ -313,6 +340,10 @@ func (m *Metrics) Snapshot() Snapshot {
MediaInfoProbeFailureTotal: m.mediaInfoProbeFailure.Load(),
MediaInfoStoreFailureTotal: m.mediaInfoStoreFailure.Load(),
MediaInfoProbeActive: m.mediaInfoProbeActive.Load(),
+ MediaInfoEnrichedTotal: m.mediaInfoEnriched.Load(),
+ MediaInfoFailOpenTotal: m.mediaInfoFailOpen.Load(),
+ MediaInfoWaitActive: m.mediaInfoWaitActive.Load(),
+ MediaInfoWaitRejectedTotal: m.mediaInfoWaitRejected.Load(),
ResolverLatencyMSTotal: resolverTotal,
ResolverLatencySamples: resolverSamples,
diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go
index 1fd5b4f..b3365ba 100644
--- a/internal/metrics/metrics_test.go
+++ b/internal/metrics/metrics_test.go
@@ -34,6 +34,10 @@ func TestSnapshotAndJSONHandler(t *testing.T) {
registry.IncMediaInfoProbeFailure()
registry.IncMediaInfoStoreFailure()
registry.IncMediaInfoProbeActive()
+ registry.IncMediaInfoEnriched()
+ registry.IncMediaInfoFailOpen()
+ registry.IncMediaInfoWaitActive()
+ registry.IncMediaInfoWaitRejected()
registry.ObserveMediaInfoProbeLatency(44 * time.Millisecond)
registry.ObserveResolverLatency(12 * time.Millisecond)
registry.ObserveResolverLatency(7 * time.Millisecond)
@@ -78,6 +82,10 @@ func TestSnapshotAndJSONHandler(t *testing.T) {
MediaInfoProbeFailureTotal: 1,
MediaInfoStoreFailureTotal: 1,
MediaInfoProbeActive: 1,
+ MediaInfoEnrichedTotal: 1,
+ MediaInfoFailOpenTotal: 1,
+ MediaInfoWaitActive: 1,
+ MediaInfoWaitRejectedTotal: 1,
ResolverLatencyMSTotal: 19,
ResolverLatencySamples: 2,
@@ -103,6 +111,7 @@ func TestSnapshotAndJSONHandler(t *testing.T) {
registry.DecMetadataBatchGuardActive()
registry.DecMetadataBatchGuardQueued()
registry.DecMediaInfoProbeActive()
+ registry.DecMediaInfoWaitActive()
if got := registry.Snapshot().ActiveRequests; got != 0 {
t.Fatalf("active requests after release = %d, want 0", got)
}
@@ -155,6 +164,7 @@ func TestActiveRequestReleaseIsIdempotentAndClamped(t *testing.T) {
registry.DecMetadataBatchGuardActive()
registry.DecMetadataBatchGuardQueued()
registry.DecMediaInfoProbeActive()
+ registry.DecMediaInfoWaitActive()
if got := registry.Snapshot().ActiveRequests; got != 0 {
t.Fatalf("clamped active requests = %d, want 0", got)
}
@@ -202,6 +212,11 @@ func TestCountersAreSafeForConcurrentUpdates(t *testing.T) {
registry.IncMediaInfoStoreFailure()
registry.IncMediaInfoProbeActive()
registry.DecMediaInfoProbeActive()
+ registry.IncMediaInfoEnriched()
+ registry.IncMediaInfoFailOpen()
+ registry.IncMediaInfoWaitActive()
+ registry.DecMediaInfoWaitActive()
+ registry.IncMediaInfoWaitRejected()
registry.ObserveMediaInfoProbeLatency(3 * time.Millisecond)
registry.ObserveResolverLatency(time.Millisecond)
registry.ObserveRedirectLatency(2 * time.Millisecond)
@@ -222,6 +237,7 @@ func TestCountersAreSafeForConcurrentUpdates(t *testing.T) {
got.MediaInfoCacheHitsTotal != want || got.MediaInfoCacheMissesTotal != want ||
got.MediaInfoProbeQueuedTotal != want || got.MediaInfoProbeSuccessTotal != want ||
got.MediaInfoProbeFailureTotal != want || got.MediaInfoStoreFailureTotal != want || got.MediaInfoProbeActive != 0 ||
+ got.MediaInfoEnrichedTotal != want || got.MediaInfoFailOpenTotal != want || got.MediaInfoWaitActive != 0 || got.MediaInfoWaitRejectedTotal != want ||
got.ResolverLatencySamples != want || got.ResolverLatencyMSTotal != want ||
got.RedirectLatencySamples != want || got.RedirectLatencyMSTotal != 2*want ||
got.MediaInfoProbeLatencySamples != want || got.MediaInfoProbeLatencyMSTotal != 3*want {
diff --git a/internal/plexmeta/projection.go b/internal/plexmeta/projection.go
new file mode 100644
index 0000000..55b4e8a
--- /dev/null
+++ b/internal/plexmeta/projection.go
@@ -0,0 +1,1236 @@
+package plexmeta
+
+import (
+ "bytes"
+ "encoding/json"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+
+ "github.com/InfinityPacer/plex-gateway/internal/mediainfo"
+)
+
+// Target identifies the single Plex item and reports whether the response is
+// missing a whitelisted technical field that a MediaInfo record can project.
+// A missing Stream is not itself a reason to enrich: projection never creates
+// Stream elements because their Plex identity is not known to the gateway.
+type Target struct {
+ Part Part
+ // NeedsEnrichment is true when a whitelisted field is absent from the
+ // selected Media, Part, or matchable existing Stream elements.
+ NeedsEnrichment bool
+}
+
+// SelectEnrichmentTarget strictly selects one ratingKey item and its only
+// Media/Part pair. It performs no response mutation and is suitable for
+// deciding whether a probe is needed before loading MediaInfo.
+func SelectEnrichmentTarget(body []byte, contentType, ratingKey string) (Target, error) {
+ _, selection, err := parseProjection(body, contentType, ratingKey)
+ if err != nil {
+ return Target{}, err
+ }
+ return selection.target(), nil
+}
+
+// SelectEnrichmentPart is the strict Part-only form of
+// SelectEnrichmentTarget. The response must contain exactly one requested
+// ratingKey item, one Media, and one Part.
+func SelectEnrichmentPart(body []byte, contentType, ratingKey string) (Part, error) {
+ target, err := SelectEnrichmentTarget(body, contentType, ratingKey)
+ if err != nil {
+ return Part{}, err
+ }
+ return target.Part, nil
+}
+
+// EnrichMetadata fills only absent whitelisted Media, Part, and already
+// present Stream attributes. SelectEnrichmentTarget supplies the exact Part
+// to callers that need to build a probe request before this function runs.
+// The input body is never modified in place. When no field is filled, the
+// original bytes are returned unchanged.
+func EnrichMetadata(body []byte, contentType, ratingKey string, media mediainfo.Media) ([]byte, bool, error) {
+ document, selection, err := parseProjection(body, contentType, ratingKey)
+ if err != nil {
+ return nil, false, err
+ }
+ changed, err := selection.enrich(media)
+ if err != nil {
+ return nil, false, err
+ }
+ if !changed {
+ return append([]byte(nil), body...), false, nil
+ }
+ result, err := document.encode()
+ if err != nil {
+ return nil, false, err
+ }
+ return result, true, nil
+}
+
+type projectionDocument interface {
+ selectTarget(string) (projectionSelection, error)
+ encode() ([]byte, error)
+}
+
+type projectionSelection interface {
+ target() Target
+ enrich(mediainfo.Media) (bool, error)
+}
+
+func parseProjection(body []byte, contentType, ratingKey string) (projectionDocument, projectionSelection, error) {
+ ratingKey, err := normalizeProjectionRatingKey(ratingKey)
+ if err != nil {
+ return nil, nil, err
+ }
+ trimmed := trimProjectionBody(body)
+ if len(trimmed) == 0 {
+ return nil, nil, errors.New("Plex metadata response is empty")
+ }
+ if strings.Contains(strings.ToLower(contentType), "json") || trimmed[0] == '{' || trimmed[0] == '[' {
+ document, err := parseJSONProjectionDocument(body)
+ if err != nil {
+ return nil, nil, err
+ }
+ selection, err := document.selectTarget(ratingKey)
+ if err != nil {
+ return nil, nil, err
+ }
+ return document, selection, nil
+ }
+ document, err := parseXMLProjectionDocument(body)
+ if err != nil {
+ return nil, nil, err
+ }
+ selection, err := document.selectTarget(ratingKey)
+ if err != nil {
+ return nil, nil, err
+ }
+ return document, selection, nil
+}
+
+func normalizeProjectionRatingKey(value string) (string, error) {
+ value = strings.TrimSpace(value)
+ if value == "" || strings.ContainsAny(value, "/?#\x00\r\n") {
+ return "", errors.New("rating key must be a single path segment")
+ }
+ return value, nil
+}
+
+func trimProjectionBody(body []byte) []byte {
+ trimmed := bytes.TrimSpace(body)
+ trimmed = bytes.TrimPrefix(trimmed, []byte{0xef, 0xbb, 0xbf})
+ return bytes.TrimSpace(trimmed)
+}
+
+// projectionAttribute is deliberately narrower than mediainfo.Media. The
+// whitelist contains only descriptive technical Plex fields; identifiers and
+// playback-selection fields are never generated.
+type projectionAttribute struct {
+ name string
+ value string
+ number bool
+}
+
+func textProjectionAttribute(name, value string) projectionAttribute {
+ return projectionAttribute{name: name, value: value}
+}
+
+func integerProjectionAttribute(name string, value int64) projectionAttribute {
+ return projectionAttribute{name: name, value: strconv.FormatInt(value, 10), number: true}
+}
+
+func mediaProjectionAttributes(media mediainfo.Media) []projectionAttribute {
+ attributes := make([]projectionAttribute, 0, 14)
+ if media.Container != "" {
+ attributes = append(attributes, textProjectionAttribute("container", media.Container))
+ }
+ if media.DurationMS > 0 {
+ attributes = append(attributes, integerProjectionAttribute("duration", media.DurationMS))
+ }
+ if media.Bitrate > 0 {
+ attributes = append(attributes, integerProjectionAttribute("bitrate", media.Bitrate))
+ }
+ if media.Width > 0 {
+ attributes = append(attributes, integerProjectionAttribute("width", int64(media.Width)))
+ }
+ if media.Height > 0 {
+ attributes = append(attributes, integerProjectionAttribute("height", int64(media.Height)))
+ }
+ if media.AspectRatio != "" {
+ attributes = append(attributes, textProjectionAttribute("aspectRatio", media.AspectRatio))
+ }
+ if media.AudioChannels > 0 {
+ attributes = append(attributes, integerProjectionAttribute("audioChannels", int64(media.AudioChannels)))
+ }
+ if media.AudioCodec != "" {
+ attributes = append(attributes, textProjectionAttribute("audioCodec", media.AudioCodec))
+ }
+ if media.VideoCodec != "" {
+ attributes = append(attributes, textProjectionAttribute("videoCodec", media.VideoCodec))
+ }
+ if media.VideoResolution != "" {
+ attributes = append(attributes, textProjectionAttribute("videoResolution", media.VideoResolution))
+ }
+ if media.FrameRate != "" {
+ attributes = append(attributes, textProjectionAttribute("videoFrameRate", media.FrameRate))
+ }
+ if media.VideoProfile != "" {
+ attributes = append(attributes, textProjectionAttribute("videoProfile", media.VideoProfile))
+ }
+ if media.AudioProfile != "" {
+ attributes = append(attributes, textProjectionAttribute("audioProfile", media.AudioProfile))
+ }
+ return attributes
+}
+
+func partProjectionAttributes(media mediainfo.Media) []projectionAttribute {
+ attributes := make([]projectionAttribute, 0, 5)
+ if media.DurationMS > 0 {
+ attributes = append(attributes, integerProjectionAttribute("duration", media.DurationMS))
+ }
+ if media.Size > 0 {
+ attributes = append(attributes, integerProjectionAttribute("size", media.Size))
+ }
+ if media.Container != "" {
+ attributes = append(attributes, textProjectionAttribute("container", media.Container))
+ }
+ if media.VideoProfile != "" {
+ attributes = append(attributes, textProjectionAttribute("videoProfile", media.VideoProfile))
+ }
+ if media.AudioProfile != "" {
+ attributes = append(attributes, textProjectionAttribute("audioProfile", media.AudioProfile))
+ }
+ return attributes
+}
+
+func streamProjectionAttributes(stream mediainfo.Stream) []projectionAttribute {
+ attributes := make([]projectionAttribute, 0, 20)
+ if stream.Codec != "" {
+ attributes = append(attributes, textProjectionAttribute("codec", stream.Codec))
+ }
+ if stream.Profile != "" {
+ attributes = append(attributes, textProjectionAttribute("profile", stream.Profile))
+ }
+ if stream.Level > 0 {
+ attributes = append(attributes, integerProjectionAttribute("level", int64(stream.Level)))
+ }
+ if stream.Bitrate > 0 {
+ attributes = append(attributes, integerProjectionAttribute("bitrate", stream.Bitrate))
+ }
+ if stream.Width > 0 {
+ attributes = append(attributes, integerProjectionAttribute("width", int64(stream.Width)))
+ }
+ if stream.Height > 0 {
+ attributes = append(attributes, integerProjectionAttribute("height", int64(stream.Height)))
+ }
+ frameRate := firstProjectionString(stream.FrameRate, stream.AverageFrameRate)
+ if frameRate != "" {
+ attributes = append(attributes, textProjectionAttribute("frameRate", frameRate))
+ }
+ if stream.ReferenceFrames > 0 {
+ attributes = append(attributes, integerProjectionAttribute("refFrames", int64(stream.ReferenceFrames)))
+ }
+ if stream.PixelFormat != "" {
+ attributes = append(attributes, textProjectionAttribute("pixelFormat", stream.PixelFormat))
+ }
+ if stream.BitDepth > 0 {
+ attributes = append(attributes, integerProjectionAttribute("bitDepth", int64(stream.BitDepth)))
+ }
+ if stream.ColorSpace != "" {
+ attributes = append(attributes, textProjectionAttribute("colorSpace", stream.ColorSpace))
+ }
+ if stream.ColorRange != "" {
+ attributes = append(attributes, textProjectionAttribute("colorRange", stream.ColorRange))
+ }
+ if stream.ColorPrimaries != "" {
+ attributes = append(attributes, textProjectionAttribute("colorPrimaries", stream.ColorPrimaries))
+ }
+ if stream.ColorTransfer != "" {
+ attributes = append(attributes, textProjectionAttribute("colorTrc", stream.ColorTransfer))
+ }
+ if stream.ChromaLocation != "" {
+ attributes = append(attributes, textProjectionAttribute("chromaLocation", stream.ChromaLocation))
+ }
+ if stream.SampleAspectRatio != "" {
+ attributes = append(attributes, textProjectionAttribute("sampleAspectRatio", stream.SampleAspectRatio))
+ }
+ if stream.DisplayAspectRatio != "" {
+ attributes = append(attributes, textProjectionAttribute("displayAspectRatio", stream.DisplayAspectRatio))
+ }
+ if stream.SampleRate > 0 {
+ attributes = append(attributes, integerProjectionAttribute("samplingRate", int64(stream.SampleRate)))
+ }
+ if stream.Channels > 0 {
+ attributes = append(attributes, integerProjectionAttribute("channels", int64(stream.Channels)))
+ }
+ if stream.ChannelLayout != "" {
+ attributes = append(attributes, textProjectionAttribute("channelLayout", stream.ChannelLayout))
+ }
+ if stream.Language != "" {
+ attributes = append(attributes, textProjectionAttribute("language", stream.Language))
+ }
+ if stream.Title != "" {
+ attributes = append(attributes, textProjectionAttribute("title", stream.Title))
+ }
+ return attributes
+}
+
+func firstProjectionString(values ...string) string {
+ for _, value := range values {
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+var mediaProjectionAttributeNames = []string{
+ "container", "duration", "bitrate", "width", "height", "aspectRatio",
+ "audioChannels", "audioCodec", "videoCodec", "videoResolution", "videoFrameRate",
+ "videoProfile", "audioProfile",
+}
+
+var partProjectionAttributeNames = []string{"duration", "size", "container", "videoProfile", "audioProfile"}
+
+var commonStreamProjectionAttributeNames = []string{
+ "codec", "profile", "level", "bitrate", "language", "title",
+}
+
+var videoStreamProjectionAttributeNames = []string{
+ "width", "height", "frameRate", "refFrames", "pixelFormat", "bitDepth",
+ "colorSpace", "colorRange", "colorPrimaries", "colorTrc", "chromaLocation",
+ "sampleAspectRatio", "displayAspectRatio",
+}
+
+var audioStreamProjectionAttributeNames = []string{"samplingRate", "channels", "channelLayout"}
+
+type streamKey struct {
+ typeName string
+ index int
+}
+
+type streamProjection struct {
+ key streamKey
+ attributes []projectionAttribute
+}
+
+func buildStreamProjections(media mediainfo.Media) ([]streamProjection, error) {
+ projections := make([]streamProjection, 0, len(media.Streams))
+ seen := make(map[streamKey]struct{}, len(media.Streams))
+ for _, stream := range media.Streams {
+ key, ok := mediaStreamKey(stream.Type, stream.Index)
+ if !ok {
+ continue
+ }
+ if _, exists := seen[key]; exists {
+ return nil, fmt.Errorf("MediaInfo contains duplicate stream identity %s/%d", key.typeName, key.index)
+ }
+ seen[key] = struct{}{}
+ projections = append(projections, streamProjection{
+ key: key,
+ attributes: streamProjectionAttributes(stream),
+ })
+ }
+ return projections, nil
+}
+
+func mediaStreamKey(typeName string, index int) (streamKey, bool) {
+ if index < 0 {
+ return streamKey{}, false
+ }
+ typeName = canonicalStreamType(typeName)
+ if typeName == "" {
+ return streamKey{}, false
+ }
+ return streamKey{typeName: typeName, index: index}, true
+}
+
+func canonicalStreamType(value string) string {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "1", "video":
+ return "1"
+ case "2", "audio":
+ return "2"
+ case "3", "subtitle", "subtitles":
+ return "3"
+ default:
+ return strings.ToLower(strings.TrimSpace(value))
+ }
+}
+
+func streamProjectionAttributeNames(typeName string) []string {
+ names := append([]string(nil), commonStreamProjectionAttributeNames...)
+ switch typeName {
+ case "1":
+ names = append(names, videoStreamProjectionAttributeNames...)
+ case "2":
+ names = append(names, audioStreamProjectionAttributeNames...)
+ }
+ return names
+}
+
+// XML projection -----------------------------------------------------------
+
+type xmlProjectionDocument struct {
+ nodes []*xmlProjectionNode
+}
+
+type xmlProjectionNode struct {
+ element *xmlProjectionElement
+ token xml.Token
+}
+
+type xmlProjectionElement struct {
+ start xml.StartElement
+ children []*xmlProjectionNode
+}
+
+func parseXMLProjectionDocument(body []byte) (*xmlProjectionDocument, error) {
+ decoder := xml.NewDecoder(bytes.NewReader(bytes.TrimPrefix(body, []byte{0xef, 0xbb, 0xbf})))
+ document := &xmlProjectionDocument{}
+ stack := make([]*xmlProjectionElement, 0, 8)
+ for {
+ token, err := decoder.Token()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ return nil, fmt.Errorf("parse Plex XML metadata: %w", err)
+ }
+ switch typed := token.(type) {
+ case xml.StartElement:
+ element := &xmlProjectionElement{start: copyXMLStartElement(typed)}
+ node := &xmlProjectionNode{element: element}
+ appendXMLProjectionNode(document, stack, node)
+ stack = append(stack, element)
+ case xml.EndElement:
+ if len(stack) == 0 {
+ return nil, errors.New("Plex XML metadata has an unexpected closing element")
+ }
+ stack = stack[:len(stack)-1]
+ case xml.CharData:
+ data := append(xml.CharData(nil), typed...)
+ if len(stack) == 0 && len(bytes.TrimSpace(data)) != 0 {
+ return nil, errors.New("Plex XML metadata has non-whitespace text outside the root")
+ }
+ appendXMLProjectionNode(document, stack, &xmlProjectionNode{token: data})
+ case xml.Comment:
+ appendXMLProjectionNode(document, stack, &xmlProjectionNode{token: append(xml.Comment(nil), typed...)})
+ case xml.Directive:
+ appendXMLProjectionNode(document, stack, &xmlProjectionNode{token: append(xml.Directive(nil), typed...)})
+ case xml.ProcInst:
+ appendXMLProjectionNode(document, stack, &xmlProjectionNode{token: xml.ProcInst{
+ Target: typed.Target,
+ Inst: append([]byte(nil), typed.Inst...),
+ }})
+ }
+ }
+ if len(stack) != 0 {
+ return nil, errors.New("Plex XML metadata has an unterminated element")
+ }
+ if len(xmlProjectionElements(document.nodes)) != 1 {
+ return nil, errors.New("Plex XML metadata must have one root element")
+ }
+ return document, nil
+}
+
+func copyXMLStartElement(start xml.StartElement) xml.StartElement {
+ attributes := make([]xml.Attr, len(start.Attr))
+ copy(attributes, start.Attr)
+ return xml.StartElement{Name: start.Name, Attr: attributes}
+}
+
+func appendXMLProjectionNode(document *xmlProjectionDocument, stack []*xmlProjectionElement, node *xmlProjectionNode) {
+ if len(stack) == 0 {
+ document.nodes = append(document.nodes, node)
+ return
+ }
+ parent := stack[len(stack)-1]
+ parent.children = append(parent.children, node)
+}
+
+func xmlProjectionElements(nodes []*xmlProjectionNode) []*xmlProjectionElement {
+ result := make([]*xmlProjectionElement, 0, len(nodes))
+ for _, node := range nodes {
+ if node.element != nil {
+ result = append(result, node.element)
+ }
+ }
+ return result
+}
+
+func directXMLElements(parent *xmlProjectionElement, localName string) []*xmlProjectionElement {
+ result := make([]*xmlProjectionElement, 0)
+ for _, node := range parent.children {
+ if node.element != nil && node.element.start.Name.Local == localName {
+ result = append(result, node.element)
+ }
+ }
+ return result
+}
+
+func (document *xmlProjectionDocument) selectTarget(ratingKey string) (projectionSelection, error) {
+ roots := xmlProjectionElements(document.nodes)
+ if len(roots) != 1 || roots[0].start.Name.Local != "MediaContainer" {
+ return nil, errors.New("Plex XML metadata has no unique MediaContainer root")
+ }
+ videos := directXMLElements(roots[0], "Video")
+ if len(videos) != 1 {
+ return nil, errors.New("Plex XML metadata must contain one Video item")
+ }
+ item := videos[0]
+ itemRatingKey, present, err := xmlAttribute(item.start.Attr, "ratingKey")
+ if err != nil {
+ return nil, err
+ }
+ if !present || itemRatingKey == "" || itemRatingKey != ratingKey {
+ return nil, errors.New("Plex XML metadata ratingKey does not match the requested item")
+ }
+ mediaItems := directXMLElements(item, "Media")
+ if len(mediaItems) != 1 {
+ return nil, errors.New("Plex XML metadata must contain one Media")
+ }
+ parts := directXMLElements(mediaItems[0], "Part")
+ if len(parts) != 1 {
+ return nil, errors.New("Plex XML metadata must contain one Part")
+ }
+ part, err := partFromXML(parts[0])
+ if err != nil {
+ return nil, err
+ }
+ needs, err := xmlTargetNeedsEnrichment(mediaItems[0], parts[0])
+ if err != nil {
+ return nil, err
+ }
+ return &xmlProjectionSelection{
+ value: Target{Part: part, NeedsEnrichment: needs},
+ media: mediaItems[0],
+ part: parts[0],
+ }, nil
+}
+
+type xmlProjectionSelection struct {
+ value Target
+ media *xmlProjectionElement
+ part *xmlProjectionElement
+}
+
+func (selection *xmlProjectionSelection) target() Target {
+ return selection.value
+}
+
+func (selection *xmlProjectionSelection) enrich(media mediainfo.Media) (bool, error) {
+ streamProjections, err := buildStreamProjections(media)
+ if err != nil {
+ return false, err
+ }
+ changed := addMissingXMLAttributes(selection.media, mediaProjectionAttributes(media))
+ changed = addMissingXMLAttributes(selection.part, partProjectionAttributes(media)) || changed
+ streamElements := directXMLElements(selection.part, "Stream")
+ streamIndex, err := indexXMLStreams(streamElements)
+ if err != nil {
+ return false, err
+ }
+ for _, projection := range streamProjections {
+ stream, ok := streamIndex[projection.key]
+ if !ok {
+ continue
+ }
+ changed = addMissingXMLAttributes(stream, projection.attributes) || changed
+ }
+ return changed, nil
+}
+
+func partFromXML(element *xmlProjectionElement) (Part, error) {
+ id, present, err := xmlAttribute(element.start.Attr, "id")
+ if err != nil {
+ return Part{}, err
+ }
+ if !present || id == "" {
+ return Part{}, errors.New("selected Plex Part has no id")
+ }
+ key, _, err := xmlAttribute(element.start.Attr, "key")
+ if err != nil {
+ return Part{}, err
+ }
+ file, present, err := xmlAttribute(element.start.Attr, "file")
+ if err != nil {
+ return Part{}, err
+ }
+ if !present || file == "" {
+ return Part{}, errors.New("selected Plex Part has no file")
+ }
+ return Part{ID: id, Key: key, File: file}, nil
+}
+
+func xmlAttribute(attributes []xml.Attr, localName string) (string, bool, error) {
+ value := ""
+ present := false
+ for _, attribute := range attributes {
+ if attribute.Name.Local != localName {
+ continue
+ }
+ if present {
+ return "", false, fmt.Errorf("Plex XML metadata has duplicate %s attributes", localName)
+ }
+ value = attribute.Value
+ present = true
+ }
+ return value, present, nil
+}
+
+func xmlTargetNeedsEnrichment(media, part *xmlProjectionElement) (bool, error) {
+ needs := false
+ for _, name := range mediaProjectionAttributeNames {
+ _, present, err := xmlAttribute(media.start.Attr, name)
+ if err != nil {
+ return false, err
+ }
+ if !present {
+ needs = true
+ }
+ }
+ for _, name := range partProjectionAttributeNames {
+ _, present, err := xmlAttribute(part.start.Attr, name)
+ if err != nil {
+ return false, err
+ }
+ if !present {
+ needs = true
+ }
+ }
+ streams := directXMLElements(part, "Stream")
+ if _, err := indexXMLStreams(streams); err != nil {
+ return false, err
+ }
+ for _, stream := range streams {
+ key, present, err := xmlStreamIdentity(stream)
+ if err != nil {
+ return false, err
+ }
+ if !present {
+ continue
+ }
+ for _, name := range streamProjectionAttributeNames(key.typeName) {
+ _, present, err := xmlAttribute(stream.start.Attr, name)
+ if err != nil {
+ return false, err
+ }
+ if !present {
+ needs = true
+ }
+ }
+ }
+ return needs, nil
+}
+
+func indexXMLStreams(streams []*xmlProjectionElement) (map[streamKey]*xmlProjectionElement, error) {
+ result := make(map[streamKey]*xmlProjectionElement, len(streams))
+ for _, stream := range streams {
+ key, present, err := xmlStreamIdentity(stream)
+ if err != nil {
+ return nil, err
+ }
+ if !present {
+ continue
+ }
+ if _, exists := result[key]; exists {
+ return nil, fmt.Errorf("Plex metadata has duplicate Stream identity %s/%d", key.typeName, key.index)
+ }
+ result[key] = stream
+ }
+ return result, nil
+}
+
+func xmlStreamIdentity(stream *xmlProjectionElement) (streamKey, bool, error) {
+ typeName, typePresent, err := xmlAttribute(stream.start.Attr, "streamType")
+ if err != nil {
+ return streamKey{}, false, err
+ }
+ indexValue, indexPresent, err := xmlAttribute(stream.start.Attr, "index")
+ if err != nil {
+ return streamKey{}, false, err
+ }
+ if !typePresent || !indexPresent {
+ return streamKey{}, false, nil
+ }
+ if strings.TrimSpace(typeName) == "" {
+ return streamKey{}, false, errors.New("Plex metadata Stream has an empty streamType")
+ }
+ index, err := strconv.Atoi(strings.TrimSpace(indexValue))
+ if err != nil || index < 0 {
+ return streamKey{}, false, errors.New("Plex metadata Stream has an invalid index")
+ }
+ key, ok := mediaStreamKey(typeName, index)
+ if !ok {
+ return streamKey{}, false, errors.New("Plex metadata Stream has an invalid identity")
+ }
+ return key, true, nil
+}
+
+func addMissingXMLAttributes(element *xmlProjectionElement, attributes []projectionAttribute) bool {
+ changed := false
+ for _, attribute := range attributes {
+ _, present, err := xmlAttribute(element.start.Attr, attribute.name)
+ if err != nil {
+ // The target validation inspects all whitelisted attributes before
+ // mutation. This branch is unreachable for a valid selection.
+ continue
+ }
+ if present {
+ continue
+ }
+ element.start.Attr = append(element.start.Attr, xml.Attr{
+ Name: xml.Name{Local: attribute.name},
+ Value: attribute.value,
+ })
+ changed = true
+ }
+ return changed
+}
+
+func (document *xmlProjectionDocument) encode() ([]byte, error) {
+ var buffer bytes.Buffer
+ encoder := xml.NewEncoder(&buffer)
+ for _, node := range document.nodes {
+ if node.element != nil {
+ if err := encodeXMLProjectionElement(encoder, node.element); err != nil {
+ return nil, fmt.Errorf("encode Plex XML metadata: %w", err)
+ }
+ continue
+ }
+ if err := encoder.EncodeToken(node.token); err != nil {
+ return nil, fmt.Errorf("encode Plex XML metadata: %w", err)
+ }
+ }
+ if err := encoder.Flush(); err != nil {
+ return nil, fmt.Errorf("flush Plex XML metadata: %w", err)
+ }
+ return buffer.Bytes(), nil
+}
+
+func encodeXMLProjectionElement(encoder *xml.Encoder, element *xmlProjectionElement) error {
+ if err := encoder.EncodeToken(element.start); err != nil {
+ return err
+ }
+ for _, node := range element.children {
+ if node.element != nil {
+ if err := encodeXMLProjectionElement(encoder, node.element); err != nil {
+ return err
+ }
+ continue
+ }
+ if err := encoder.EncodeToken(node.token); err != nil {
+ return err
+ }
+ }
+ return encoder.EncodeToken(element.start.End())
+}
+
+// JSON projection ----------------------------------------------------------
+
+type jsonProjectionDocument struct {
+ root jsonObject
+ prefix []byte
+ suffix []byte
+}
+
+type jsonObject map[string]json.RawMessage
+
+func parseJSONProjectionDocument(body []byte) (*jsonProjectionDocument, error) {
+ core, prefix, suffix, err := splitJSONProjectionBody(body)
+ if err != nil {
+ return nil, err
+ }
+ root, err := decodeJSONProjectionObject(core)
+ if err != nil {
+ return nil, fmt.Errorf("parse Plex JSON metadata: %w", err)
+ }
+ return &jsonProjectionDocument{root: root, prefix: prefix, suffix: suffix}, nil
+}
+
+func splitJSONProjectionBody(body []byte) ([]byte, []byte, []byte, error) {
+ leading := len(body) - len(bytes.TrimLeft(body, " \t\r\n"))
+ start := leading
+ if bytes.HasPrefix(body[start:], []byte{0xef, 0xbb, 0xbf}) {
+ start += 3
+ }
+ withoutBOM := body[start:]
+ left := len(withoutBOM) - len(bytes.TrimLeft(withoutBOM, " \t\r\n"))
+ coreWithSuffix := withoutBOM[left:]
+ right := len(coreWithSuffix) - len(bytes.TrimRight(coreWithSuffix, " \t\r\n"))
+ if len(coreWithSuffix)-right == 0 {
+ return nil, nil, nil, errors.New("Plex JSON metadata response is empty")
+ }
+ prefix := append([]byte(nil), body[:start+left]...)
+ suffix := append([]byte(nil), coreWithSuffix[len(coreWithSuffix)-right:]...)
+ return coreWithSuffix[:len(coreWithSuffix)-right], prefix, suffix, nil
+}
+
+func decodeJSONProjectionObject(raw []byte) (jsonObject, error) {
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.UseNumber()
+ token, err := decoder.Token()
+ if err != nil {
+ return nil, err
+ }
+ if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
+ return nil, errors.New("Plex JSON metadata value is not an object")
+ }
+ result := make(jsonObject)
+ for decoder.More() {
+ keyToken, err := decoder.Token()
+ if err != nil {
+ return nil, err
+ }
+ key, ok := keyToken.(string)
+ if !ok {
+ return nil, errors.New("Plex JSON metadata object has an invalid key")
+ }
+ if _, exists := result[key]; exists {
+ return nil, fmt.Errorf("Plex JSON metadata has duplicate %s fields", key)
+ }
+ var value json.RawMessage
+ if err := decoder.Decode(&value); err != nil {
+ return nil, err
+ }
+ result[key] = append(json.RawMessage(nil), value...)
+ }
+ if token, err := decoder.Token(); err != nil {
+ return nil, err
+ } else if delimiter, ok := token.(json.Delim); !ok || delimiter != '}' {
+ return nil, errors.New("Plex JSON metadata object is not closed")
+ }
+ if token, err := decoder.Token(); !errors.Is(err, io.EOF) || token != nil {
+ return nil, errors.New("Plex JSON metadata has trailing values")
+ }
+ return result, nil
+}
+
+func decodeJSONProjectionArray(raw []byte) ([]json.RawMessage, error) {
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.UseNumber()
+ token, err := decoder.Token()
+ if err != nil {
+ return nil, err
+ }
+ if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
+ return nil, errors.New("Plex JSON metadata value is not an array")
+ }
+ result := make([]json.RawMessage, 0)
+ for decoder.More() {
+ var value json.RawMessage
+ if err := decoder.Decode(&value); err != nil {
+ return nil, err
+ }
+ result = append(result, append(json.RawMessage(nil), value...))
+ }
+ if token, err := decoder.Token(); err != nil {
+ return nil, err
+ } else if delimiter, ok := token.(json.Delim); !ok || delimiter != ']' {
+ return nil, errors.New("Plex JSON metadata array is not closed")
+ }
+ if token, err := decoder.Token(); !errors.Is(err, io.EOF) || token != nil {
+ return nil, errors.New("Plex JSON metadata has trailing values")
+ }
+ return result, nil
+}
+
+type jsonProjectionList struct {
+ items []*jsonObject
+ array bool
+}
+
+func decodeJSONProjectionList(raw json.RawMessage, name string) (jsonProjectionList, error) {
+ trimmed := bytes.TrimSpace(raw)
+ if len(trimmed) == 0 {
+ return jsonProjectionList{}, fmt.Errorf("Plex JSON metadata %s is empty", name)
+ }
+ if trimmed[0] == '{' {
+ object, err := decodeJSONProjectionObject(trimmed)
+ if err != nil {
+ return jsonProjectionList{}, fmt.Errorf("parse Plex JSON %s: %w", name, err)
+ }
+ return jsonProjectionList{items: []*jsonObject{&object}}, nil
+ }
+ if trimmed[0] != '[' {
+ return jsonProjectionList{}, fmt.Errorf("Plex JSON metadata %s is neither an object nor an array", name)
+ }
+ values, err := decodeJSONProjectionArray(trimmed)
+ if err != nil {
+ return jsonProjectionList{}, fmt.Errorf("parse Plex JSON %s: %w", name, err)
+ }
+ result := jsonProjectionList{items: make([]*jsonObject, 0, len(values)), array: true}
+ for _, value := range values {
+ object, err := decodeJSONProjectionObject(value)
+ if err != nil {
+ return jsonProjectionList{}, fmt.Errorf("Plex JSON metadata %s contains a non-object item: %w", name, err)
+ }
+ result.items = append(result.items, &object)
+ }
+ return result, nil
+}
+
+func encodeJSONProjectionList(list jsonProjectionList) (json.RawMessage, error) {
+ if list.array {
+ values := make([]jsonObject, 0, len(list.items))
+ for _, item := range list.items {
+ values = append(values, *item)
+ }
+ return json.Marshal(values)
+ }
+ if len(list.items) != 1 {
+ return nil, errors.New("Plex JSON metadata object list is not unique")
+ }
+ return json.Marshal(*list.items[0])
+}
+
+func (document *jsonProjectionDocument) selectTarget(ratingKey string) (projectionSelection, error) {
+ containerRaw, ok := document.root["MediaContainer"]
+ if !ok {
+ return nil, errors.New("Plex JSON metadata has no MediaContainer")
+ }
+ container, err := decodeJSONProjectionObject(bytes.TrimSpace(containerRaw))
+ if err != nil {
+ return nil, fmt.Errorf("parse Plex JSON MediaContainer: %w", err)
+ }
+ metadataRaw, ok := container["Metadata"]
+ if !ok {
+ return nil, errors.New("Plex JSON metadata has no Metadata item")
+ }
+ metadata, err := decodeJSONProjectionList(metadataRaw, "Metadata")
+ if err != nil {
+ return nil, err
+ }
+ if len(metadata.items) != 1 {
+ return nil, errors.New("Plex JSON metadata must contain one Metadata item")
+ }
+ item := metadata.items[0]
+ itemRatingKey, err := requiredJSONScalarString(*item, "ratingKey")
+ if err != nil {
+ return nil, err
+ }
+ if itemRatingKey != ratingKey {
+ return nil, errors.New("Plex JSON metadata ratingKey does not match the requested item")
+ }
+ mediaRaw, ok := (*item)["Media"]
+ if !ok {
+ return nil, errors.New("Plex JSON metadata has no Media")
+ }
+ mediaItems, err := decodeJSONProjectionList(mediaRaw, "Media")
+ if err != nil {
+ return nil, err
+ }
+ if len(mediaItems.items) != 1 {
+ return nil, errors.New("Plex JSON metadata must contain one Media")
+ }
+ mediaItem := mediaItems.items[0]
+ partRaw, ok := (*mediaItem)["Part"]
+ if !ok {
+ return nil, errors.New("Plex JSON metadata has no Part")
+ }
+ parts, err := decodeJSONProjectionList(partRaw, "Part")
+ if err != nil {
+ return nil, err
+ }
+ if len(parts.items) != 1 {
+ return nil, errors.New("Plex JSON metadata must contain one Part")
+ }
+ partItem := parts.items[0]
+ part, err := partFromJSON(partItem)
+ if err != nil {
+ return nil, err
+ }
+ needs, err := jsonTargetNeedsEnrichment(mediaItem, partItem)
+ if err != nil {
+ return nil, err
+ }
+ return &jsonProjectionSelection{
+ value: Target{Part: part, NeedsEnrichment: needs},
+ document: document,
+ container: container,
+ metadata: metadata,
+ media: mediaItems,
+ parts: parts,
+ mediaItem: mediaItem,
+ part: partItem,
+ }, nil
+}
+
+type jsonProjectionSelection struct {
+ value Target
+ document *jsonProjectionDocument
+ container jsonObject
+ metadata jsonProjectionList
+ media jsonProjectionList
+ parts jsonProjectionList
+ mediaItem *jsonObject
+ part *jsonObject
+}
+
+func (selection *jsonProjectionSelection) target() Target {
+ return selection.value
+}
+
+func (selection *jsonProjectionSelection) enrich(media mediainfo.Media) (bool, error) {
+ streamProjections, err := buildStreamProjections(media)
+ if err != nil {
+ return false, err
+ }
+ changed, err := addMissingJSONAttributes(*selection.mediaItem, mediaProjectionAttributes(media))
+ if err != nil {
+ return false, err
+ }
+ partChanged, err := addMissingJSONAttributes(*selection.part, partProjectionAttributes(media))
+ if err != nil {
+ return false, err
+ }
+ changed = changed || partChanged
+ streamRaw, exists := (*selection.part)["Stream"]
+ if !exists {
+ return changed, nil
+ }
+ streams, err := decodeJSONProjectionList(streamRaw, "Stream")
+ if err != nil {
+ return false, err
+ }
+ streamIndex, err := indexJSONStreams(streams.items)
+ if err != nil {
+ return false, err
+ }
+ for _, projection := range streamProjections {
+ stream, ok := streamIndex[projection.key]
+ if !ok {
+ continue
+ }
+ streamChanged, err := addMissingJSONAttributes(*stream, projection.attributes)
+ if err != nil {
+ return false, err
+ }
+ changed = changed || streamChanged
+ }
+ if changed {
+ encoded, err := encodeJSONProjectionList(streams)
+ if err != nil {
+ return false, err
+ }
+ (*selection.part)["Stream"] = encoded
+ }
+ if changed {
+ if err := selection.commit(); err != nil {
+ return false, err
+ }
+ }
+ return changed, nil
+}
+
+func (selection *jsonProjectionSelection) commit() error {
+ parts, err := encodeJSONProjectionList(selection.parts)
+ if err != nil {
+ return err
+ }
+ (*selection.mediaItem)["Part"] = parts
+ media, err := encodeJSONProjectionList(selection.media)
+ if err != nil {
+ return err
+ }
+ // The metadata list has exactly one item by selection contract.
+ (*selection.metadata.items[0])["Media"] = media
+ metadata, err := encodeJSONProjectionList(selection.metadata)
+ if err != nil {
+ return err
+ }
+ selection.container["Metadata"] = metadata
+ container, err := json.Marshal(selection.container)
+ if err != nil {
+ return err
+ }
+ selection.document.root["MediaContainer"] = container
+ return nil
+}
+
+func partFromJSON(object *jsonObject) (Part, error) {
+ id, err := requiredJSONScalarString(*object, "id")
+ if err != nil {
+ return Part{}, err
+ }
+ file, err := requiredJSONString(*object, "file")
+ if err != nil {
+ return Part{}, err
+ }
+ key, err := optionalJSONString(*object, "key")
+ if err != nil {
+ return Part{}, err
+ }
+ return Part{ID: id, Key: key, File: file}, nil
+}
+
+func requiredJSONScalarString(object jsonObject, name string) (string, error) {
+ raw, ok := object[name]
+ if !ok {
+ return "", fmt.Errorf("Plex JSON metadata has no %s", name)
+ }
+ value, ok := jsonRawScalarString(raw)
+ if !ok || value == "" {
+ return "", fmt.Errorf("Plex JSON metadata %s is not a non-empty scalar", name)
+ }
+ return value, nil
+}
+
+func requiredJSONString(object jsonObject, name string) (string, error) {
+ raw, ok := object[name]
+ if !ok {
+ return "", fmt.Errorf("Plex JSON metadata has no %s", name)
+ }
+ var value string
+ if err := json.Unmarshal(raw, &value); err != nil || value == "" {
+ return "", fmt.Errorf("Plex JSON metadata %s is not a non-empty string", name)
+ }
+ return value, nil
+}
+
+func optionalJSONString(object jsonObject, name string) (string, error) {
+ raw, ok := object[name]
+ if !ok {
+ return "", nil
+ }
+ var value string
+ if err := json.Unmarshal(raw, &value); err != nil {
+ return "", fmt.Errorf("Plex JSON metadata %s is not a string", name)
+ }
+ return value, nil
+}
+
+func jsonRawScalarString(raw json.RawMessage) (string, bool) {
+ var value any
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.UseNumber()
+ if err := decoder.Decode(&value); err != nil {
+ return "", false
+ }
+ var trailing any
+ if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
+ return "", false
+ }
+ return stringValue(value)
+}
+
+func jsonTargetNeedsEnrichment(media, part *jsonObject) (bool, error) {
+ needs := false
+ for _, name := range mediaProjectionAttributeNames {
+ if _, present := (*media)[name]; !present {
+ needs = true
+ }
+ }
+ for _, name := range partProjectionAttributeNames {
+ if _, present := (*part)[name]; !present {
+ needs = true
+ }
+ }
+ streamRaw, exists := (*part)["Stream"]
+ if !exists {
+ return needs, nil
+ }
+ streams, err := decodeJSONProjectionList(streamRaw, "Stream")
+ if err != nil {
+ return false, err
+ }
+ if _, err := indexJSONStreams(streams.items); err != nil {
+ return false, err
+ }
+ for _, stream := range streams.items {
+ key, present, err := jsonStreamIdentity(*stream)
+ if err != nil {
+ return false, err
+ }
+ if !present {
+ continue
+ }
+ for _, name := range streamProjectionAttributeNames(key.typeName) {
+ if _, present := (*stream)[name]; !present {
+ needs = true
+ }
+ }
+ }
+ return needs, nil
+}
+
+func indexJSONStreams(streams []*jsonObject) (map[streamKey]*jsonObject, error) {
+ result := make(map[streamKey]*jsonObject, len(streams))
+ for _, stream := range streams {
+ key, present, err := jsonStreamIdentity(*stream)
+ if err != nil {
+ return nil, err
+ }
+ if !present {
+ continue
+ }
+ if _, exists := result[key]; exists {
+ return nil, fmt.Errorf("Plex metadata has duplicate Stream identity %s/%d", key.typeName, key.index)
+ }
+ result[key] = stream
+ }
+ return result, nil
+}
+
+func jsonStreamIdentity(object jsonObject) (streamKey, bool, error) {
+ typeRaw, typePresent := object["streamType"]
+ indexRaw, indexPresent := object["index"]
+ if !typePresent || !indexPresent {
+ return streamKey{}, false, nil
+ }
+ typeName, ok := jsonRawScalarString(typeRaw)
+ if !ok || strings.TrimSpace(typeName) == "" {
+ return streamKey{}, false, errors.New("Plex JSON metadata Stream has an invalid streamType")
+ }
+ indexValue, ok := jsonRawScalarString(indexRaw)
+ if !ok {
+ return streamKey{}, false, errors.New("Plex JSON metadata Stream has an invalid index")
+ }
+ index, err := strconv.Atoi(strings.TrimSpace(indexValue))
+ if err != nil || index < 0 {
+ return streamKey{}, false, errors.New("Plex JSON metadata Stream has an invalid index")
+ }
+ key, ok := mediaStreamKey(typeName, index)
+ if !ok {
+ return streamKey{}, false, errors.New("Plex JSON metadata Stream has an invalid identity")
+ }
+ return key, true, nil
+}
+
+func addMissingJSONAttributes(object jsonObject, attributes []projectionAttribute) (bool, error) {
+ changed := false
+ for _, attribute := range attributes {
+ if _, present := object[attribute.name]; present {
+ continue
+ }
+ var raw []byte
+ var err error
+ if attribute.number {
+ raw = []byte(attribute.value)
+ } else {
+ raw, err = json.Marshal(attribute.value)
+ if err != nil {
+ return false, err
+ }
+ }
+ object[attribute.name] = append(json.RawMessage(nil), raw...)
+ changed = true
+ }
+ return changed, nil
+}
+
+func (document *jsonProjectionDocument) encode() ([]byte, error) {
+ encoded, err := json.Marshal(document.root)
+ if err != nil {
+ return nil, fmt.Errorf("encode Plex JSON metadata: %w", err)
+ }
+ result := make([]byte, 0, len(document.prefix)+len(encoded)+len(document.suffix))
+ result = append(result, document.prefix...)
+ result = append(result, encoded...)
+ result = append(result, document.suffix...)
+ return result, nil
+}
diff --git a/internal/plexmeta/projection_test.go b/internal/plexmeta/projection_test.go
new file mode 100644
index 0000000..d23a7d9
--- /dev/null
+++ b/internal/plexmeta/projection_test.go
@@ -0,0 +1,349 @@
+package plexmeta
+
+import (
+ "bytes"
+ "encoding/json"
+ "encoding/xml"
+ "io"
+ "reflect"
+ "strconv"
+ "testing"
+
+ "github.com/InfinityPacer/plex-gateway/internal/mediainfo"
+)
+
+func TestSelectEnrichmentTargetStrictlySelectsXMLItem(t *testing.T) {
+ body := []byte(``)
+ want := Part{ID: "7", Key: "/library/parts/7/1/file", File: "/cloud/movie.strm"}
+
+ target, err := SelectEnrichmentTarget(body, "application/xml", "42")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(target.Part, want) || !target.NeedsEnrichment {
+ t.Fatalf("target = %#v, want part %#v and enrichment", target, want)
+ }
+ part, err := SelectEnrichmentPart(body, "application/xml", "42")
+ if err != nil || !reflect.DeepEqual(part, want) {
+ t.Fatalf("part = %#v, err = %v", part, err)
+ }
+}
+
+func TestSelectEnrichmentTargetStrictlySelectsJSONItem(t *testing.T) {
+ body := []byte(`{"MediaContainer":{"rootExtra":"preserve","Metadata":[{"ratingKey":42,"itemExtra":"preserve","Media":[{"mediaExtra":"preserve","Part":[{"id":7,"key":"/library/parts/7/1/file","file":"/cloud/movie.strm","partExtra":"preserve"}]}]}]}}`)
+ target, err := SelectEnrichmentTarget(body, "application/json", "42")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := Part{ID: "7", Key: "/library/parts/7/1/file", File: "/cloud/movie.strm"}
+ if !reflect.DeepEqual(target.Part, want) || !target.NeedsEnrichment {
+ t.Fatalf("target = %#v, want part %#v and enrichment", target, want)
+ }
+}
+
+func TestEnrichMetadataXMLFillsOnlyMissingSafeFields(t *testing.T) {
+ body := []byte(``)
+ original := append([]byte(nil), body...)
+
+ result, changed, err := EnrichMetadata(body, "application/xml", "42", projectionTestMedia())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !changed {
+ t.Fatal("EnrichMetadata reported no change")
+ }
+ if !bytes.Equal(body, original) {
+ t.Fatal("EnrichMetadata modified its input body")
+ }
+
+ attrs := xmlProjectionAttributeSets(t, result)
+ media := attrs["Media"]
+ part := attrs["Part"]
+ videoStream := attrs["Stream#1"]
+ audioStream := attrs["Stream#2"]
+ if media["container"] != "plex-container" || media["videoCodec"] != "plex-codec" || media["videoResolution"] != "4k" || media["duration"] != "123000" {
+ t.Fatalf("Media attributes = %#v", media)
+ }
+ if part["id"] != "7" || part["file"] != "/cloud/movie.strm" || part["duration"] != "999" || part["size"] != "987654321" || part["container"] != "mkv" {
+ t.Fatalf("Part attributes = %#v", part)
+ }
+ if videoStream["id"] != "plex-stream-id" || videoStream["codec"] != "plex-stream-codec" || videoStream["profile"] != "Main 10" || videoStream["width"] != "3840" || videoStream["bitDepth"] != "10" {
+ t.Fatalf("video Stream attributes = %#v", videoStream)
+ }
+ if audioStream["codec"] != "aac" || audioStream["channels"] != "6" || audioStream["samplingRate"] != "48000" {
+ t.Fatalf("audio Stream attributes = %#v", audioStream)
+ }
+ for _, name := range []string{"selected", "default", "decision"} {
+ if videoStream[name] != "1" && name != "decision" {
+ t.Fatalf("forbidden Stream attribute %q changed: %#v", name, videoStream)
+ }
+ }
+ if videoStream["decision"] != "directplay" || videoStream["streamExtra"] != "preserve" || audioStream["streamExtra"] != "audio" {
+ t.Fatalf("existing or unknown Stream attributes were not preserved: video=%#v audio=%#v", videoStream, audioStream)
+ }
+ if !bytes.Contains(result, []byte(`rootExtra="preserve"`)) || !bytes.Contains(result, []byte(`UnknownNode unknownAttr="keep"`)) || !bytes.Contains(result, []byte(`UnknownChild`)) {
+ t.Fatalf("unknown XML attributes/nodes were not preserved: %s", result)
+ }
+}
+
+func TestEnrichMetadataJSONFillsOnlyMissingSafeFields(t *testing.T) {
+ body := []byte(`{"MediaContainer":{"rootExtra":"preserve","Metadata":[{"ratingKey":"42","itemExtra":"preserve","Media":[{"mediaExtra":"preserve","container":"plex-container","videoCodec":"plex-codec","Part":[{"id":7,"key":"/library/parts/7/1/file","file":"/cloud/movie.strm","partExtra":"preserve","duration":999,"Stream":[{"id":"plex-stream-id","streamType":1,"index":0,"codec":"plex-stream-codec","selected":1,"default":1,"decision":"directplay","streamExtra":"preserve"},{"streamType":2,"index":1,"streamExtra":"audio"}]}]}]}]}}`)
+ original := append([]byte(nil), body...)
+
+ result, changed, err := EnrichMetadata(body, "application/json", "42", projectionTestMedia())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !changed {
+ t.Fatal("EnrichMetadata reported no change")
+ }
+ if !bytes.Equal(body, original) {
+ t.Fatal("EnrichMetadata modified its input body")
+ }
+
+ var document map[string]any
+ if err := json.Unmarshal(result, &document); err != nil {
+ t.Fatal(err)
+ }
+ container := document["MediaContainer"].(map[string]any)
+ item := container["Metadata"].([]any)[0].(map[string]any)
+ media := item["Media"].([]any)[0].(map[string]any)
+ part := media["Part"].([]any)[0].(map[string]any)
+ streams := part["Stream"].([]any)
+ videoStream := streams[0].(map[string]any)
+ audioStream := streams[1].(map[string]any)
+ if media["container"] != "plex-container" || media["videoCodec"] != "plex-codec" || media["videoResolution"] != "4k" || media["duration"] != float64(123000) {
+ t.Fatalf("Media fields = %#v", media)
+ }
+ if part["id"] != float64(7) || part["file"] != "/cloud/movie.strm" || part["duration"] != float64(999) || part["size"] != float64(987654321) || part["container"] != "mkv" {
+ t.Fatalf("Part fields = %#v", part)
+ }
+ if videoStream["id"] != "plex-stream-id" || videoStream["codec"] != "plex-stream-codec" || videoStream["profile"] != "Main 10" || videoStream["width"] != float64(3840) || videoStream["bitDepth"] != float64(10) {
+ t.Fatalf("video Stream fields = %#v", videoStream)
+ }
+ if audioStream["codec"] != "aac" || audioStream["channels"] != float64(6) || audioStream["samplingRate"] != float64(48000) {
+ t.Fatalf("audio Stream fields = %#v", audioStream)
+ }
+ if videoStream["selected"] != float64(1) || videoStream["default"] != float64(1) || videoStream["decision"] != "directplay" || videoStream["streamExtra"] != "preserve" || audioStream["streamExtra"] != "audio" {
+ t.Fatalf("existing or forbidden Stream fields changed: video=%#v audio=%#v", videoStream, audioStream)
+ }
+ if container["rootExtra"] != "preserve" || item["itemExtra"] != "preserve" || media["mediaExtra"] != "preserve" || part["partExtra"] != "preserve" {
+ t.Fatalf("unknown JSON fields were not preserved: %#v", document)
+ }
+}
+
+func TestEnrichMetadataDoesNotCreateStreams(t *testing.T) {
+ body := []byte(``)
+ result, changed, err := EnrichMetadata(body, "application/xml", "42", projectionTestMedia())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !changed || bytes.Contains(result, []byte("`},
+ {name: "XML rating mismatch", contentType: "application/xml", body: ``},
+ {name: "XML multiple media", contentType: "application/xml", body: ``},
+ {name: "XML multiple parts", contentType: "application/xml", body: ``},
+ {name: "JSON multiple items", contentType: "application/json", body: `{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"Part":[{"id":7,"file":"/a.strm"}]}]},{"ratingKey":"43","Media":[{"Part":[{"id":8,"file":"/b.strm"}]}]}]}}`},
+ {name: "JSON multiple media", contentType: "application/json", body: `{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"Part":[{"id":7,"file":"/a.strm"}]},{"Part":[{"id":8,"file":"/b.strm"}]}]}]}}`},
+ {name: "JSON multiple parts", contentType: "application/json", body: `{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"Part":[{"id":7,"file":"/a.strm"},{"id":8,"file":"/b.strm"}]}]}]}}`},
+ {name: "JSON duplicate path field", contentType: "application/json", body: `{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"Part":[{"id":7,"id":8,"file":"/a.strm"}]}]}]}}`},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ body := []byte(test.body)
+ original := append([]byte(nil), body...)
+ if _, err := SelectEnrichmentPart(body, test.contentType, "42"); err == nil {
+ t.Fatal("SelectEnrichmentPart unexpectedly succeeded")
+ }
+ if !bytes.Equal(body, original) {
+ t.Fatal("SelectEnrichmentPart modified its input body")
+ }
+ if result, changed, err := EnrichMetadata(body, test.contentType, "42", projectionTestMedia()); err == nil || result != nil || changed {
+ t.Fatalf("EnrichMetadata result=%q changed=%v err=%v", result, changed, err)
+ }
+ if !bytes.Equal(body, original) {
+ t.Fatal("EnrichMetadata modified its input body after an error")
+ }
+ })
+ }
+}
+
+func TestEnrichMetadataRejectsDuplicateStreamIdentity(t *testing.T) {
+ tests := []struct {
+ name string
+ contentType string
+ body string
+ }{
+ {name: "XML", contentType: "application/xml", body: ``},
+ {name: "JSON", contentType: "application/json", body: `{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"Part":[{"id":7,"file":"/a.strm","Stream":[{"streamType":1,"index":0},{"streamType":"video","index":0}]}]}]}]}}`},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ body := []byte(test.body)
+ original := append([]byte(nil), body...)
+ if result, changed, err := EnrichMetadata(body, test.contentType, "42", projectionTestMedia()); err == nil || result != nil || changed {
+ t.Fatalf("EnrichMetadata result=%q changed=%v err=%v", result, changed, err)
+ }
+ if !bytes.Equal(body, original) {
+ t.Fatal("EnrichMetadata modified its input body after an error")
+ }
+ })
+ }
+}
+
+func TestEnrichMetadataRejectsMalformedStreamIdentity(t *testing.T) {
+ tests := []struct {
+ name string
+ contentType string
+ body string
+ }{
+ {name: "XML", contentType: "application/xml", body: ``},
+ {name: "JSON", contentType: "application/json", body: `{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"Part":[{"id":7,"file":"/a.strm","Stream":[{"streamType":1,"index":"not-an-index"}]}]}]}]}}`},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ body := []byte(test.body)
+ original := append([]byte(nil), body...)
+ if _, err := SelectEnrichmentTarget(body, test.contentType, "42"); err == nil {
+ t.Fatal("SelectEnrichmentTarget unexpectedly accepted malformed Stream identity")
+ }
+ if !bytes.Equal(body, original) {
+ t.Fatal("SelectEnrichmentTarget modified its input body")
+ }
+ })
+ }
+}
+
+func TestEnrichMetadataSkipsUnmatchedStreams(t *testing.T) {
+ body := []byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"Part":[{"id":7,"file":"/a.strm","Stream":[{"streamType":1,"index":99,"codec":"keep"}]}]}]}]}}`)
+ result, changed, err := EnrichMetadata(body, "application/json", "42", projectionTestMedia())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !changed {
+ t.Fatal("Media/Part fields should still have been projected")
+ }
+ var document map[string]any
+ if err := json.Unmarshal(result, &document); err != nil {
+ t.Fatal(err)
+ }
+ stream := document["MediaContainer"].(map[string]any)["Metadata"].([]any)[0].(map[string]any)["Media"].([]any)[0].(map[string]any)["Part"].([]any)[0].(map[string]any)["Stream"].([]any)[0].(map[string]any)
+ if len(stream) != 3 || stream["codec"] != "keep" || stream["streamType"] != float64(1) || stream["index"] != float64(99) {
+ t.Fatalf("unmatched Stream was changed or created: %#v", stream)
+ }
+}
+
+func projectionTestMedia() mediainfo.Media {
+ return mediainfo.Media{
+ Complete: true,
+ Container: "mkv",
+ DurationMS: 123000,
+ Size: 987654321,
+ Bitrate: 12000000,
+ VideoCodec: "hevc",
+ VideoProfile: "Main 10",
+ VideoResolution: "4k",
+ Width: 3840,
+ Height: 2160,
+ AspectRatio: "16:9",
+ FrameRate: "23.976",
+ AudioCodec: "aac",
+ AudioProfile: "LC",
+ AudioChannels: 6,
+ Streams: []mediainfo.Stream{
+ {
+ Index: 0, Type: "video", Codec: "hevc", Profile: "Main 10", Level: 153,
+ Bitrate: 12000000, Width: 3840, Height: 2160, FrameRate: "23.976",
+ ReferenceFrames: 4, PixelFormat: "yuv420p10le", BitDepth: 10,
+ ColorSpace: "bt2020nc", ColorRange: "tv", ColorPrimaries: "bt2020",
+ ColorTransfer: "smpte2084", ChromaLocation: "left",
+ SampleAspectRatio: "1:1", DisplayAspectRatio: "16:9", Language: "eng", Title: "Video",
+ },
+ {
+ Index: 1, Type: "audio", Codec: "aac", Profile: "LC", Level: 1,
+ Bitrate: 640000, SampleRate: 48000, Channels: 6, ChannelLayout: "5.1",
+ Language: "eng", Title: "English",
+ },
+ },
+ }
+}
+
+func xmlProjectionAttributeSets(t *testing.T, body []byte) map[string]map[string]string {
+ t.Helper()
+ decoder := xml.NewDecoder(bytes.NewReader(body))
+ result := make(map[string]map[string]string)
+ streamCount := 0
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ if err == io.EOF {
+ break
+ }
+ t.Fatal(err)
+ }
+ start, ok := token.(xml.StartElement)
+ if !ok {
+ continue
+ }
+ name := start.Name.Local
+ if name == "Stream" {
+ streamCount++
+ name += "#" + strconv.Itoa(streamCount)
+ }
+ attributes := make(map[string]string, len(start.Attr))
+ for _, attribute := range start.Attr {
+ attributes[attribute.Name.Local] = attribute.Value
+ }
+ if _, exists := result[name]; !exists {
+ result[name] = attributes
+ }
+ }
+ return result
+}
+
+func completeProjectionXML() string {
+ return ``
+}
+
+func completeProjectionJSON() string {
+ return `{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"container":"mkv","duration":123000,"bitrate":12000000,"width":3840,"height":2160,"aspectRatio":"16:9","audioChannels":6,"audioCodec":"aac","videoCodec":"hevc","videoResolution":"4k","videoFrameRate":"23.976","videoProfile":"Main 10","audioProfile":"LC","Part":[{"id":7,"key":"/library/parts/7/1/file","file":"/cloud/movie.strm","duration":123000,"size":987654321,"container":"mkv","videoProfile":"Main 10","audioProfile":"LC","Stream":[{"streamType":1,"index":0,"codec":"hevc","profile":"Main 10","level":153,"bitrate":12000000,"width":3840,"height":2160,"frameRate":"23.976","refFrames":4,"pixelFormat":"yuv420p10le","bitDepth":10,"colorSpace":"bt2020nc","colorRange":"tv","colorPrimaries":"bt2020","colorTrc":"smpte2084","chromaLocation":"left","sampleAspectRatio":"1:1","displayAspectRatio":"16:9","language":"eng","title":"Video"},{"streamType":2,"index":1,"codec":"aac","profile":"LC","level":1,"bitrate":640000,"language":"eng","title":"English","samplingRate":48000,"channels":6,"channelLayout":"5.1"}]}]}]}]}}`
+}