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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
7 changes: 5 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,使其能够为相同客户端上下文
Expand Down
63 changes: 43 additions & 20 deletions cmd/plex-gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
}
Expand All @@ -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,
Expand Down Expand Up @@ -143,41 +151,46 @@ 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,
AnalyzeDuration: cfg.AnalyzeDuration, OutputLimit: cfg.OutputMaxBytes,
})
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 {
Expand All @@ -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{
Expand All @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions cmd/plex-gateway/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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)
}
4 changes: 4 additions & 0 deletions docker-compose.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
15 changes: 9 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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. |
Expand Down
32 changes: 26 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,13 +38,17 @@ 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
// optional STRM redirect handling. Credentials remain request-scoped and are
// never loaded into this structure.
type Config struct {
ListenAddr string
DatabasePath string
PlexURL *url.URL
MediaVaultURL *url.URL
PathMappings []pathmap.Mapping
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -276,6 +294,7 @@ func Load() (Config, error) {

return Config{
ListenAddr: listenAddr,
DatabasePath: databasePath,
PlexURL: plexURL,
MediaVaultURL: mediaVaultURL,
PathMappings: pathMappings,
Expand All @@ -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,
Expand Down
Loading