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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: false
cache: true
cache-dependency-path: go.sum

- name: Verify
run: go test -race -count=1 ./...
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: false
cache: true
cache-dependency-path: go.sum

- name: Validate release
id: release
Expand Down
11 changes: 8 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY go.mod go.sum ./
RUN go mod download
COPY VERSION ./
COPY cmd/plex-gateway ./cmd/plex-gateway
COPY internal ./internal
Expand All @@ -12,11 +13,15 @@ RUN release_version="$(tr -d '[:space:]' < VERSION)" \
-o /plex-gateway ./cmd/plex-gateway

FROM alpine:3.22
RUN apk add --no-cache ca-certificates \
&& adduser -D -H -s /sbin/nologin gateway
RUN apk add --no-cache ca-certificates ffmpeg \
&& addgroup -g 1000 gateway \
&& adduser -D -H -u 1000 -G gateway -s /sbin/nologin gateway \
&& install -d -o gateway -g gateway /app_data
COPY --from=build /plex-gateway /usr/local/bin/plex-gateway
WORKDIR /app_data
USER gateway
EXPOSE 32400
ENV LISTEN_ADDR=:32400
ENV MEDIAINFO_DB_PATH=/app_data/mediainfo.db
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["/usr/local/bin/plex-gateway", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/plex-gateway"]
6 changes: 5 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ Important environment variables:
| `METADATA_GUARD_BATCH_ENABLED` | `false` | Limit comma-separated batch metadata reads before they enter Plex. |
| `METADATA_GUARD_BATCH_CONCURRENCY` | `3` | Shared batch metadata concurrency limit across all clients. |
| `METADATA_GUARD_QUEUE_TIMEOUT` | `10s` | Maximum admission wait before returning `429`. |
| `MEDIAINFO_ENABLED` | `true` | Enable the optional MediaInfo cache and bounded probe subsystem; initialization failures do not affect transparent proxying. |
| `MEDIAINFO_DB_PATH` | `./data/mediainfo.db` | MediaInfo SQLite cache path; the container image defaults to `/app_data/mediainfo.db`. |
| `MEDIAINFO_USER_AGENT` | `Infuse-Library/8.4.4` | Fallback User-Agent for background probes without an active client context. |

Plex tokens are not stored in configuration. Ordinary Plex requests remain
transparent. For cloud playback, all client request headers are forwarded to
Expand Down Expand Up @@ -229,7 +232,8 @@ That topology is outside the exclusive-gateway deployment contract.

## Endpoints

- `GET /health` returns process health.
- `GET /health` returns process health plus a credential-free MediaInfo
availability, cache, and queue summary.
- `GET /metrics` returns fixed-shape JSON counters plus resolver and complete
redirect-path latency totals, samples, last values, and maxima. When metadata
protection is enabled, it also reports admission, timeout, active, and queued
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ go run ./cmd/plex-gateway
| `METADATA_GUARD_BATCH_ENABLED` | `false` | 限制逗号分隔的批量 metadata 读取进入 Plex 的并发量。 |
| `METADATA_GUARD_BATCH_CONCURRENCY` | `3` | 所有客户端共享的批量 metadata 并发上限。 |
| `METADATA_GUARD_QUEUE_TIMEOUT` | `10s` | 等待准入的最长时间,超时返回 `429`。 |
| `MEDIAINFO_ENABLED` | `true` | 启用可选 MediaInfo 缓存与受限探测子系统;初始化失败不影响透明代理。 |
| `MEDIAINFO_DB_PATH` | `./data/mediainfo.db` | MediaInfo SQLite 持久缓存路径;容器镜像默认使用 `/app_data/mediainfo.db`。 |
| `MEDIAINFO_USER_AGENT` | `Infuse-Library/8.4.4` | 没有活动客户端上下文时,后台探测使用的 fallback User-Agent。 |

Plex Token 不会保存到配置中。普通 Plex 请求保持透明转发。对于云端播放,客户端的
所有请求 header 都会转发到配置的 MediaVault origin,使其能够为相同客户端上下文
Expand Down Expand Up @@ -196,7 +199,7 @@ Gateway 重新执行检查。

## Endpoints

- `GET /health` 返回进程健康状态。
- `GET /health` 返回进程健康状态,以及不含媒体身份和凭据的 MediaInfo 可用性、缓存和队列摘要。
- `GET /metrics` 返回固定结构的 JSON 计数器,以及 resolver 和完整重定向链路的
延迟总计、样本数、最近值和最大值。启用 metadata 保护时还会返回准入、超时、
活动和排队计数,所有指标都不包含请求标签或凭据。
Expand Down
96 changes: 96 additions & 0 deletions cmd/plex-gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
Expand All @@ -15,6 +16,7 @@ import (

"github.com/InfinityPacer/plex-gateway/internal/config"
"github.com/InfinityPacer/plex-gateway/internal/gateway"
"github.com/InfinityPacer/plex-gateway/internal/mediainfo"
"github.com/InfinityPacer/plex-gateway/internal/metrics"
"github.com/InfinityPacer/plex-gateway/internal/partcache"
"github.com/InfinityPacer/plex-gateway/internal/pathmap"
Expand Down Expand Up @@ -64,6 +66,13 @@ func run() error {
return fmt.Errorf("create MediaVault resolver: %w", err)
}
}
mediaInfoService, mediaInfoStore, mediaInfoReason := initializeMediaInfo(
context.Background(), cfg.MediaInfo, cfg.PlexURL, cfg.PartProbeTimeout, strmResolver, registry, logger,
)
defer closeMediaInfo(mediaInfoService, mediaInfoStore, cfg.ShutdownTimeout, logger)
mediaInfoStatus := func() mediainfo.Status {
return mediaInfoService.Status()
}
handler := gateway.New(gateway.Options{
Upstream: cfg.PlexURL,
Logger: logger,
Expand All @@ -83,6 +92,8 @@ func run() error {
BatchConcurrency: cfg.MetadataGuard.BatchConcurrency,
QueueTimeout: cfg.MetadataGuard.QueueTimeout,
},
MediaInfoEnabled: cfg.MediaInfo.Enabled,
MediaInfoStatus: mediaInfoStatus,
})
server := &http.Server{
Addr: cfg.ListenAddr,
Expand All @@ -104,6 +115,9 @@ func run() error {
"trace", cfg.TraceEnabled,
"metadata_guard", cfg.MetadataGuard.Enabled,
"metadata_batch_guard", cfg.MetadataGuard.BatchEnabled,
"mediainfo_enabled", cfg.MediaInfo.Enabled,
"mediainfo_available", mediaInfoService != nil,
"mediainfo_reason", mediaInfoReason,
)
serveErrors <- server.ListenAndServe()
}()
Expand All @@ -127,6 +141,88 @@ func run() error {
return nil
}

func initializeMediaInfo(
ctx context.Context,
cfg config.MediaInfoConfig,
plexURL *url.URL,
identityTimeout time.Duration,
strmResolver resolver.ControlResolver,
registry *metrics.Metrics,
logger *slog.Logger,
) (*mediainfo.Service, *mediainfo.SQLiteStore, string) {
if !cfg.Enabled {
return nil, nil, "disabled"
}
if strmResolver == nil {
return nil, nil, "cloud_unavailable"
}
plexServerID, err := gateway.ReadPlexServerIdentity(ctx, plexURL, identityTimeout)
if err != nil {
logger.Warn("mediainfo_unavailable", "reason", "plex_identity")
return nil, nil, "plex_identity_unavailable"
}
prober, err := mediainfo.NewFFProber(mediainfo.FFProbeOptions{
Binary: cfg.FFProbePath, Timeout: cfg.ProbeTimeout, ProbeSize: cfg.ProbeSize,
AnalyzeDuration: cfg.AnalyzeDuration, OutputLimit: cfg.OutputMaxBytes,
})
if err != nil {
logger.Warn("mediainfo_unavailable", "reason", "ffprobe")
return nil, nil, "ffprobe_unavailable"
}
provider, err := mediainfo.NewMediaVaultFFProbeProvider(strmResolver, prober)
if err != nil {
logger.Warn("mediainfo_unavailable", "reason", "provider")
return nil, nil, "provider_unavailable"
}
store, err := mediainfo.OpenSQLite(ctx, cfg.DatabasePath)
if err != nil {
logger.Warn("mediainfo_unavailable", "reason", "storage")
return nil, nil, "storage_unavailable"
}
now := time.Now().UTC()
if _, err := store.DeleteUnretained(ctx, now); err != nil {
logger.Warn("mediainfo_store_gc_failed", "error_kind", "storage")
}
records, err := store.LoadCompatibleRetained(ctx, now, cfg.L1MaxEntries, provider.Descriptor())
if err != nil {
logger.Warn("mediainfo_unavailable", "reason", "restore")
_ = store.Close()
return nil, nil, "restore_failed"
}
cache := mediainfo.NewCacheWithLimit(records, now, cfg.L1MaxEntries)
service, err := mediainfo.NewService(mediainfo.ServiceOptions{
Cache: cache, Store: store, Janitor: store, Provider: provider, PlexServerID: plexServerID,
Logger: logger, Metrics: registry, Concurrency: cfg.Concurrency,
InteractiveQueueSize: cfg.InteractiveQueueSize, BackgroundQueueSize: cfg.BackgroundQueueSize,
ProbeTimeout: cfg.ProbeTimeout, RecordTTL: cfg.RecordTTL,
RecordRetention: cfg.RecordRetention, NegativeTTL: cfg.NegativeTTL,
BackgroundUserAgent: cfg.UserAgent,
})
if err != nil {
logger.Warn("mediainfo_unavailable", "reason", "worker")
_ = store.Close()
return nil, nil, "worker_unavailable"
}
return service, store, "ready"
}

func closeMediaInfo(service *mediainfo.Service, store *mediainfo.SQLiteStore, timeout time.Duration, logger *slog.Logger) {
serviceClosed := true
if service != nil {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
if err := service.Close(ctx); err != nil {
serviceClosed = false
logger.Warn("mediainfo_shutdown_incomplete", "error_kind", "timeout")
}
cancel()
}
if store != nil && serviceClosed {
if err := store.Close(); err != nil {
logger.Warn("mediainfo_store_close_failed", "error_kind", "storage")
}
}
}

func newLogger(level string) *slog.Logger {
var slogLevel slog.Level
switch level {
Expand Down
98 changes: 98 additions & 0 deletions cmd/plex-gateway/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package main

import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"

"github.com/InfinityPacer/plex-gateway/internal/config"
"github.com/InfinityPacer/plex-gateway/internal/metrics"
"github.com/InfinityPacer/plex-gateway/internal/resolver"
)

type countingControlResolver struct {
calls atomic.Int64
}

func (control *countingControlResolver) ReadTarget(string) (string, error) {
control.calls.Add(1)
return "", errors.New("unexpected STRM read")
}

func (control *countingControlResolver) ResolveTarget(context.Context, string, resolver.PlaybackRequest) (resolver.DirectURL, error) {
control.calls.Add(1)
return resolver.DirectURL{}, errors.New("unexpected MediaVault resolve")
}

func TestInitializeMediaInfoDoesNotProbeWithoutTask(t *testing.T) {
unexpectedPlexPath := make(chan string, 1)
plex := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/identity" {
select {
case unexpectedPlexPath <- r.URL.Path:
default:
}
http.NotFound(w, r)
return
}
_, _ = io.WriteString(w, `<MediaContainer machineIdentifier="test-server"/>`)
}))
defer plex.Close()
plexURL, err := url.Parse(plex.URL)
if err != nil {
t.Fatal(err)
}

directory := t.TempDir()
ffprobePath := filepath.Join(directory, "ffprobe")
if err := os.WriteFile(ffprobePath, []byte("#!/bin/sh\n: > \"$0.called\"\nexit 99\n"), 0o700); err != nil {
t.Fatal(err)
}
control := &countingControlResolver{}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
service, store, reason := initializeMediaInfo(t.Context(), config.MediaInfoConfig{
Enabled: true,
DatabasePath: filepath.Join(directory, "mediainfo.db"),
FFProbePath: ffprobePath,
ProbeTimeout: time.Second,
ProbeSize: 1 << 20,
AnalyzeDuration: time.Second,
OutputMaxBytes: 1 << 20,
Concurrency: 1,
InteractiveQueueSize: 2,
BackgroundQueueSize: 2,
RecordTTL: 24 * time.Hour,
RecordRetention: 48 * time.Hour,
L1MaxEntries: 10,
NegativeTTL: time.Minute,
UserAgent: "plex-gateway-test",
}, plexURL, time.Second, control, metrics.New(), logger)
if service == nil || store == nil || reason != "ready" {
t.Fatalf("initializeMediaInfo() service=%v store=%v reason=%q", service != nil, store != nil, reason)
}
if status := service.Status(); !status.Available || status.ActiveProbes != 0 ||
status.InteractiveQueued != 0 || status.BackgroundQueued != 0 {
t.Fatalf("unexpected MediaInfo status %#v", status)
}
if got := control.calls.Load(); got != 0 {
t.Fatalf("resolver calls = %d", got)
}
select {
case path := <-unexpectedPlexPath:
t.Fatalf("unexpected Plex path %q", path)
default:
}
if _, err := os.Stat(ffprobePath + ".called"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("ffprobe process unexpectedly started: %v", err)
}
closeMediaInfo(service, store, time.Second, logger)
}
5 changes: 5 additions & 0 deletions docker-compose.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@ services:
METADATA_GUARD_BATCH_ENABLED: "false"
METADATA_GUARD_BATCH_CONCURRENCY: "3"
METADATA_GUARD_QUEUE_TIMEOUT: "10s"
MEDIAINFO_ENABLED: "true"
# Background probes use this only when no active client request supplies a User-Agent.
MEDIAINFO_USER_AGENT: "Infuse-Library/8.4.4"
READ_HEADER_TIMEOUT: "15s"
IDLE_TIMEOUT: "90s"
SHUTDOWN_TIMEOUT: "15s"
volumes:
- ./strm:/strm:ro
- ./archive-strm:/archive-strm:ro
# The image runs as UID/GID 1000; this directory must be writable by it.
- ./app_data:/app_data
networks:
- media

Expand Down
Loading