From 13c66338b59c867cefcccd431383c4fbe1bad637 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Thu, 3 Sep 2026 06:14:44 +0800 Subject: [PATCH 1/3] feat: add ShimWeave media control protocol --- docs/architecture.md | 27 +- internal/gateway/decision_capture_test.go | 2 +- internal/gateway/gateway.go | 10 +- internal/gateway/shimweave_control.go | 368 +++++++++++++++++++++ internal/gateway/shimweave_control_test.go | 289 ++++++++++++++++ internal/gateway/universal_start.go | 18 + internal/playback/grant.go | 10 +- internal/playback/service.go | 37 ++- internal/resolver/resolver.go | 6 +- 9 files changed, 742 insertions(+), 25 deletions(-) create mode 100644 internal/gateway/shimweave_control.go create mode 100644 internal/gateway/shimweave_control_test.go diff --git a/docs/architecture.md b/docs/architecture.md index f9a203a..87f0188 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,6 +73,15 @@ gateway; the isolated analysis worker may read a bounded amount for probing. to the final CDN URL. The start path does not repeat the metadata lookup or STRM read. Local media and every uncertain selection remain ordinary Plex traffic. +- A `start.mpd` request that declares ShimWeave `control-v1` is terminated with + a bodyless control descriptor only after the same Grant and Plex Part + authorization succeed. The descriptor separates stable content identity, a + fixed same-origin control path, and a high-entropy bearer carried only in a + request header. Each controlled Range request may + overlay only its single `Range` header onto the immutable client context, + resolves one ephemeral MediaVault URL, and receives that URL in a bodyless + response header. The browser then creates a separate credential-free CDN + request. The gateway never follows the URL or handles media bytes. - Timeline, scrobble, sessions, playlists, subtitles, images, transcode segments, and every unrecognized endpoint remain ordinary Plex traffic. - When Plex Web compatibility is enabled, only successful `GET /web/` and @@ -107,7 +116,8 @@ control-file and metadata readers. | Technical MediaInfo | A validated producer record, or Gateway SQLite for fallback-probed media; Plex fields and response enrichment are projections. Plex DB production writes are not enabled in this release. | Source or STRM fingerprint change, schema change, or freshness policy. | | Part cache | Derived by metadata observation or an authenticated playback selection, keyed by `Part.id`. | Configured TTL or process restart. | | Direct Play grant | Published only after a complete Plex decision explicitly accepts the exact Part; a new related decision revokes the previous grant first. | Five-minute TTL, bounded eviction, or explicit revocation. | -| MediaVault direct URL | Produced for the active authorized request and copied only to `Location`. | Never persisted or reused by the gateway. | +| ShimWeave control ticket | In-memory bearer issued only after a live Direct Play grant and repeated Plex Part authorization. The attempt key is stored as a digest and retained client headers are bounded. | Thirty-minute idle TTL, 24-hour absolute TTL, bounded eviction, or process restart. | +| MediaVault direct URL | Produced for the active authorized request and copied to `Location`, or to the bodyless `control-v1` response header. | Never persisted or reused by the gateway. | ## Multi-volume path mapping @@ -151,14 +161,17 @@ an HTTP(S) `Location` becomes one `302 Found` response from the gateway. The full redirect URL, query values, Plex tokens, cookies, and API keys are never logged. -The complete client request header set is cloned onto every MediaVault +The complete client request header set is cloned onto the initial MediaVault `/redirect` request, including bounded internal redirect hops. Query-carried `X-Plex-*` context is promoted to headers when the client did not already send -the corresponding header. Direct-link resolution is always an internal `GET` -control request, so a client `HEAD` can still receive the same gateway 302 even -when MediaVault does not implement `HEAD`. Because Plex credentials and cookies -are included, the configured MediaVault origin is a trusted upstream and must -not point to an untrusted service. +the corresponding header. A ShimWeave control ticket retains that authorized +snapshot within a 64 KiB limit; later calls may replace only one non-empty +`Range` header and cannot replace Plex Token, Cookie, Authorization, or other +identity context. Direct-link resolution is always an internal `GET` control +request, so a client `HEAD` can still receive the same result when MediaVault +does not implement `HEAD`. Because Plex credentials and cookies are included, +the configured MediaVault origin is a trusted upstream and must not point to an +untrusted service. ## Failure policy diff --git a/internal/gateway/decision_capture_test.go b/internal/gateway/decision_capture_test.go index a0c04fe..c4d738c 100644 --- a/internal/gateway/decision_capture_test.go +++ b/internal/gateway/decision_capture_test.go @@ -68,7 +68,7 @@ func TestNormalizePlaybackAttemptUsesStableSessionIdentity(t *testing.T) { setGrantTestQuery(start, "session", "auxiliary-start") startAttempt, _, ok := normalizePlaybackAttempt(start) if !ok || startAttempt != attempt { - t.Fatal("stable playback identity did not survive auxiliary session changes") + t.Fatal("stable request identity did not survive auxiliary session changes") } conflict := grantTestRequest("query-session") diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index b499cb0..893a8f2 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -176,7 +176,13 @@ func New(options Options) http.Handler { maxBytes: defaultDecisionMetadataMaxBytes, }, } - universalStart := &universalStartHandler{grants: decision.grants, playback: partPlayback} + controlTickets := newControlTicketStore(0, 0, 0) + universalStart := &universalStartHandler{ + grants: decision.grants, playback: partPlayback, tickets: controlTickets, + } + shimWeaveControl := &shimWeaveControlHandler{ + store: controlTickets, service: cloudPlayback, metrics: registry, logger: logger, + } mux := http.NewServeMux() mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { @@ -221,6 +227,8 @@ func New(options Options) http.Handler { mux.Handle("GET "+route, universalStart) mux.Handle("HEAD "+route, universalStart) } + mux.Handle("GET "+shimWeaveControlPath, shimWeaveControl) + mux.Handle("HEAD "+shimWeaveControlPath, shimWeaveControl) mux.Handle("GET /library/parts/{partID}/{rest...}", partPlayback) mux.Handle("HEAD /library/parts/{partID}/{rest...}", partPlayback) metadataCoalesce := options.MetadataCoalesce diff --git a/internal/gateway/shimweave_control.go b/internal/gateway/shimweave_control.go new file mode 100644 index 0000000..a9d354f --- /dev/null +++ b/internal/gateway/shimweave_control.go @@ -0,0 +1,368 @@ +package gateway + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "errors" + "log/slog" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/InfinityPacer/plex-gateway/internal/metrics" + "github.com/InfinityPacer/plex-gateway/internal/playback" + "github.com/InfinityPacer/plex-gateway/internal/resolver" +) + +const ( + shimWeaveProtocol = "control-v1" + shimWeaveAcceptHeader = "X-ShimWeave-Accept" + shimWeaveProtocolHeader = "X-ShimWeave-Protocol" + shimWeaveSourceIDHeader = "X-ShimWeave-Source-Id" + shimWeaveControlURLHeader = "X-ShimWeave-Control-Url" + shimWeaveControlTokenHeader = "X-ShimWeave-Control-Token" + shimWeaveMediaURLHeader = "X-ShimWeave-Media-Url" + shimWeaveControlPath = "/_shimweave/control/v1/range" + defaultControlTicketIdleTTL = 30 * time.Minute + defaultControlTicketMaxTTL = 24 * time.Hour + defaultControlTicketStoreSize = 4096 + maxControlRequestHeaderBytes = 64 << 10 +) + +type shimWeaveDescriptor struct { + SourceID string + ControlPath string + ControlToken string +} + +type controlTicket struct { + Token string + SourceID string + Part playback.PreparedPart + Request resolver.PlaybackRequest + AttemptKey [sha256.Size]byte + ExpiresAt time.Time + MaxExpiresAt time.Time +} + +// controlTicketStore owns short-lived bearer capabilities for one authorized +// Plex decision/start attempt. Reads renew only the idle deadline and never +// extend the absolute lifetime, so a long active stream survives the decision +// Grant without becoming a permanent credential. +type controlTicketStore struct { + mu sync.Mutex + idleTTL time.Duration + maxTTL time.Duration + limit int + entries map[string]controlTicket + byAttempt map[[sha256.Size]byte]string + now func() time.Time + random func(int) (string, error) +} + +func newControlTicketStore(idleTTL, maxTTL time.Duration, limit int) *controlTicketStore { + if idleTTL <= 0 { + idleTTL = defaultControlTicketIdleTTL + } + if maxTTL <= 0 { + maxTTL = defaultControlTicketMaxTTL + } + if maxTTL < idleTTL { + maxTTL = idleTTL + } + if limit <= 0 { + limit = defaultControlTicketStoreSize + } + return &controlTicketStore{ + idleTTL: idleTTL, + maxTTL: maxTTL, + limit: limit, + entries: make(map[string]controlTicket), + byAttempt: make(map[[sha256.Size]byte]string), + now: time.Now, + random: randomControlID, + } +} + +func (s *controlTicketStore) Issue(attempt playback.Attempt, part playback.PreparedPart, request resolver.PlaybackRequest) (shimWeaveDescriptor, error) { + if s == nil || !attempt.Correlatable() || part.Part.ID == "" || part.Part.Key == "" || part.Target == "" { + return shimWeaveDescriptor{}, errors.New("control ticket input is incomplete") + } + if playbackRequestHeaderBytes(request) > maxControlRequestHeaderBytes { + return shimWeaveDescriptor{}, errors.New("control ticket request headers exceed the limit") + } + now := s.now() + sourceID := shimWeaveSourceID(part) + attemptKey := shimWeaveAttemptKey(attempt) + s.mu.Lock() + defer s.mu.Unlock() + s.pruneLocked(now) + if token := s.byAttempt[attemptKey]; token != "" { + if ticket, found := s.entries[token]; found && ticket.SourceID == sourceID { + ticket.Request = clonePlaybackRequest(request) + ticket.ExpiresAt = minTime(now.Add(s.idleTTL), ticket.MaxExpiresAt) + s.entries[token] = ticket + return descriptorForTicket(ticket), nil + } + s.deleteLocked(token) + } + if len(s.entries) >= s.limit { + s.evictLocked() + } + var token string + for range 4 { + candidate, err := s.random(32) + if err != nil { + return shimWeaveDescriptor{}, err + } + if _, exists := s.entries[candidate]; !exists { + token = candidate + break + } + } + if token == "" { + return shimWeaveDescriptor{}, errors.New("control ticket ID collision") + } + maximum := now.Add(s.maxTTL) + ticket := controlTicket{ + Token: token, + SourceID: sourceID, + Part: part, + Request: clonePlaybackRequest(request), + AttemptKey: attemptKey, + ExpiresAt: minTime(now.Add(s.idleTTL), maximum), + MaxExpiresAt: maximum, + } + s.entries[token] = ticket + s.byAttempt[attemptKey] = token + return descriptorForTicket(ticket), nil +} + +func (s *controlTicketStore) Lease(token string) (controlTicket, bool) { + if s == nil || token == "" { + return controlTicket{}, false + } + now := s.now() + s.mu.Lock() + defer s.mu.Unlock() + ticket, found := s.entries[token] + if !found || !ticket.ExpiresAt.After(now) || !ticket.MaxExpiresAt.After(now) { + if found { + s.deleteLocked(token) + } + return controlTicket{}, false + } + ticket.ExpiresAt = minTime(now.Add(s.idleTTL), ticket.MaxExpiresAt) + s.entries[token] = ticket + ticket.Request = clonePlaybackRequest(ticket.Request) + return ticket, true +} + +func (s *controlTicketStore) pruneLocked(now time.Time) { + for token, ticket := range s.entries { + if !ticket.ExpiresAt.After(now) || !ticket.MaxExpiresAt.After(now) { + s.deleteLocked(token) + } + } +} + +func (s *controlTicketStore) evictLocked() { + var oldestToken string + var oldestExpiry time.Time + for token, ticket := range s.entries { + if oldestExpiry.IsZero() || ticket.ExpiresAt.Before(oldestExpiry) { + oldestToken = token + oldestExpiry = ticket.ExpiresAt + } + } + if oldestToken != "" { + s.deleteLocked(oldestToken) + } +} + +func (s *controlTicketStore) deleteLocked(token string) { + ticket, found := s.entries[token] + if !found { + return + } + delete(s.entries, token) + if s.byAttempt[ticket.AttemptKey] == token { + delete(s.byAttempt, ticket.AttemptKey) + } +} + +type shimWeaveControlHandler struct { + store *controlTicketStore + service *playback.Service + metrics *metrics.Metrics + logger *slog.Logger +} + +// ServeHTTP resolves one Range control request into an ephemeral CDN URL. The +// Gateway returns no media body and never follows or persists the signed URL; +// the browser starts a separate credential-free request to the media origin. +func (h *shimWeaveControlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + token, ok := readControlToken(r) + if !ok { + http.NotFound(w, r) + return + } + ticket, found := h.store.Lease(token) + if !found { + http.NotFound(w, r) + return + } + request, err := mergeControlRequest(ticket.Request, r) + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + started := time.Now() + result, err := h.service.Resolve(r.Context(), ticket.Part, request) + if err != nil { + if requestCanceled(r, err) { + return + } + if h.metrics != nil { + h.metrics.IncRedirectFailure() + h.metrics.ObserveResolverLatency(result.ResolverLatency) + } + if h.logger != nil { + h.logger.Warn("shimweave_control_failed", "part", ticket.Part.Part.ID, "error_kind", errorKind(err)) + } + http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway) + return + } + if requestFinished(r) { + return + } + if h.metrics != nil { + h.metrics.IncRedirectSuccess() + h.metrics.ObserveResolverLatency(result.ResolverLatency) + h.metrics.ObserveRedirectLatency(time.Since(started)) + } + w.Header().Set(shimWeaveMediaURLHeader, result.DirectURL.String()) + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNoContent) +} + +func acceptsShimWeaveControl(request *http.Request) bool { + if request == nil { + return false + } + for _, value := range request.Header.Values(shimWeaveAcceptHeader) { + for _, item := range strings.Split(value, ",") { + if strings.TrimSpace(item) == shimWeaveProtocol { + return true + } + } + } + return false +} + +func writeShimWeaveDescriptor(w http.ResponseWriter, descriptor shimWeaveDescriptor) { + w.Header().Set(shimWeaveProtocolHeader, shimWeaveProtocol) + w.Header().Set(shimWeaveSourceIDHeader, descriptor.SourceID) + w.Header().Set(shimWeaveControlURLHeader, descriptor.ControlPath) + w.Header().Set(shimWeaveControlTokenHeader, descriptor.ControlToken) + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNoContent) +} + +func shimWeaveSourceID(part playback.PreparedPart) string { + hash := sha256.New() + for _, field := range []string{part.Part.ID, part.Part.Key, part.Target} { + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write([]byte(field)) + } + return hex.EncodeToString(hash.Sum(nil)) +} + +func descriptorForTicket(ticket controlTicket) shimWeaveDescriptor { + return shimWeaveDescriptor{ + SourceID: ticket.SourceID, + ControlPath: shimWeaveControlPath, + ControlToken: ticket.Token, + } +} + +func clonePlaybackRequest(request resolver.PlaybackRequest) resolver.PlaybackRequest { + return resolver.PlaybackRequest{Method: request.Method, Header: request.Header.Clone()} +} + +func mergeControlRequest(base resolver.PlaybackRequest, current *http.Request) (resolver.PlaybackRequest, error) { + merged := clonePlaybackRequest(base) + if merged.Header == nil { + merged.Header = make(http.Header) + } + ranges := current.Header.Values("Range") + if len(ranges) != 1 || strings.TrimSpace(ranges[0]) == "" { + return resolver.PlaybackRequest{}, errors.New("control request must contain one Range header") + } + merged.Header.Set("Range", ranges[0]) + merged.Method = http.MethodGet + return merged, nil +} + +func readControlToken(request *http.Request) (string, bool) { + if request == nil { + return "", false + } + values := request.Header.Values(shimWeaveControlTokenHeader) + if len(values) != 1 { + return "", false + } + token := strings.TrimSpace(values[0]) + decoded, err := base64.RawURLEncoding.DecodeString(token) + return token, err == nil && len(decoded) == 32 && base64.RawURLEncoding.EncodeToString(decoded) == token +} + +func randomControlID(bytes int) (string, error) { + buffer := make([]byte, bytes) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buffer), nil +} + +func minTime(first, second time.Time) time.Time { + if first.Before(second) { + return first + } + return second +} + +func shimWeaveAttemptKey(attempt playback.Attempt) [sha256.Size]byte { + hash := sha256.New() + var length [8]byte + for _, field := range []string{ + attempt.MetadataPath, + strconv.Itoa(attempt.MediaIndex), + strconv.Itoa(attempt.PartIndex), + attempt.Session.Name, + attempt.Session.Value, + } { + binary.BigEndian.PutUint64(length[:], uint64(len(field))) + _, _ = hash.Write(length[:]) + _, _ = hash.Write([]byte(field)) + } + var result [sha256.Size]byte + copy(result[:], hash.Sum(nil)) + return result +} + +func playbackRequestHeaderBytes(request resolver.PlaybackRequest) int { + total := len(request.Method) + for name, values := range request.Header { + total += len(name) + for _, value := range values { + total += len(value) + } + } + return total +} diff --git a/internal/gateway/shimweave_control_test.go b/internal/gateway/shimweave_control_test.go new file mode 100644 index 0000000..e9587f4 --- /dev/null +++ b/internal/gateway/shimweave_control_test.go @@ -0,0 +1,289 @@ +package gateway + +import ( + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/InfinityPacer/plex-gateway/internal/pathmap" + "github.com/InfinityPacer/plex-gateway/internal/playback" + "github.com/InfinityPacer/plex-gateway/internal/plexmeta" + "github.com/InfinityPacer/plex-gateway/internal/resolver" +) + +func TestShimWeaveControlNegotiationKeepsMediaBytesOffGateway(t *testing.T) { + localRoot := writeTranscodeSTRM(t) + var mediaVaultRequests, partRequests atomic.Int64 + mediaVault := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mediaVaultRequests.Add(1) + if !strings.HasPrefix(r.Header.Get("Range"), "bytes=") || r.Header.Get("User-Agent") != "ShimWeave Test Browser" { + t.Fatalf("MediaVault headers = %#v", r.Header) + } + if r.Header.Get("X-Plex-Token") != "header-token" || + r.Header.Get("Cookie") != "plex=session" || + r.Header.Get("Authorization") != "Bearer client-credential" { + t.Fatalf("Plex context was not retained: %#v", r.Header) + } + if r.Header.Get(shimWeaveControlTokenHeader) != "" { + t.Fatal("control bearer was forwarded to MediaVault") + } + w.Header().Set("Location", "https://cdn.invalid/D.mkv?signature=private") + w.WriteHeader(http.StatusFound) + })) + defer mediaVault.Close() + + plex := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case transcodeMetadataPath: + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write([]byte(``)) + case "/video/:/transcode/universal/decision": + writeDirectPlayDecision(w) + case transcodePartPath: + partRequests.Add(1) + w.Header().Set("Location", transcodeControlURL) + w.WriteHeader(http.StatusMovedPermanently) + default: + http.NotFound(w, r) + } + })) + defer plex.Close() + + handler, _, _ := newCloudHandler(t, plex.URL, mediaVault.URL, []pathmap.Mapping{{ + PlexPrefix: "/media/cloud", LocalPrefix: localRoot, + }}) + query := transcodeQuery() + performCloudDecision(t, handler, query, http.StatusOK) + + start := newTranscodeRequest(http.MethodGet, "/video/:/transcode/universal/start.mpd", query) + start.Header.Set(shimWeaveAcceptHeader, shimWeaveProtocol) + start.Header.Set("User-Agent", "ShimWeave Test Browser") + response := httptest.NewRecorder() + handler.ServeHTTP(response, start) + + if response.Code != http.StatusNoContent { + t.Fatalf("start status = %d", response.Code) + } + if response.Header().Get(shimWeaveProtocolHeader) != shimWeaveProtocol { + t.Fatalf("protocol = %q", response.Header().Get(shimWeaveProtocolHeader)) + } + controlPath := response.Header().Get(shimWeaveControlURLHeader) + controlToken := response.Header().Get(shimWeaveControlTokenHeader) + if controlPath != shimWeaveControlPath || controlToken == "" || + response.Header().Get(shimWeaveSourceIDHeader) == "" { + t.Fatalf("descriptor headers = %#v", response.Header()) + } + if strings.Contains(controlPath, controlToken) { + t.Fatal("bearer control token leaked into the request path") + } + if mediaVaultRequests.Load() != 0 || partRequests.Load() != 1 { + t.Fatalf("MediaVault=%d Part=%d", mediaVaultRequests.Load(), partRequests.Load()) + } + + for offset := range 20 { + rangeRequest := httptest.NewRequest(http.MethodGet, controlPath, nil) + rangeRequest.Header.Set("Range", "bytes="+strconv.Itoa(offset)+"-524287") + rangeRequest.Header.Set("User-Agent", "ShimWeave Test Browser") + rangeRequest.Header.Set(shimWeaveControlTokenHeader, controlToken) + rangeRequest.Header.Set("X-Plex-Token", "forged-token") + rangeRequest.Header.Set("Cookie", "forged=session") + rangeRequest.Header.Set("Authorization", "Bearer forged") + rangeResponse := httptest.NewRecorder() + handler.ServeHTTP(rangeResponse, rangeRequest) + if rangeResponse.Code != http.StatusNoContent || rangeResponse.Header().Get(shimWeaveMediaURLHeader) != "https://cdn.invalid/D.mkv?signature=private" || rangeResponse.Header().Get("Location") != "" || rangeResponse.Body.Len() != 0 { + t.Fatalf("range status=%d mediaURL=%q Location=%q body=%d", rangeResponse.Code, rangeResponse.Header().Get(shimWeaveMediaURLHeader), rangeResponse.Header().Get("Location"), rangeResponse.Body.Len()) + } + } + if mediaVaultRequests.Load() != 20 || partRequests.Load() != 1 { + t.Fatalf("MediaVault=%d Part=%d", mediaVaultRequests.Load(), partRequests.Load()) + } + + repeated := httptest.NewRecorder() + handler.ServeHTTP(repeated, start.Clone(start.Context())) + if repeated.Header().Get(shimWeaveControlURLHeader) != controlPath || + repeated.Header().Get(shimWeaveControlTokenHeader) != controlToken { + t.Fatalf("repeated descriptor changed: first=%#v repeated=%#v", response.Header(), repeated.Header()) + } +} + +func TestControlTicketRenewsIdleLifetimeButNotAbsoluteLifetime(t *testing.T) { + store := newControlTicketStore(time.Minute, 3*time.Minute, 2) + now := time.Unix(1_000, 0) + store.now = func() time.Time { return now } + sequence := 0 + store.random = func(_ int) (string, error) { + sequence++ + return strings.Repeat(string(rune('a'+sequence)), 24), nil + } + attempt := playback.Attempt{ + MetadataPath: "/library/metadata/42", MediaIndex: 0, PartIndex: 0, + Session: playback.SessionIdentity{Name: "X-Plex-Playback-Session-Id", Value: "playback"}, + } + descriptor, err := store.Issue(attempt, controlTestPart(), resolver.PlaybackRequest{ + Method: http.MethodGet, Header: http.Header{"User-Agent": {"browser"}}, + }) + if err != nil { + t.Fatal(err) + } + token := descriptor.ControlToken + + now = now.Add(50 * time.Second) + if _, found := store.Lease(token); !found { + t.Fatal("live ticket was not renewed") + } + now = now.Add(50 * time.Second) + if _, found := store.Lease(token); !found { + t.Fatal("renewed ticket expired at its original idle deadline") + } + now = time.Unix(1_000, 0).Add(3*time.Minute + time.Nanosecond) + if _, found := store.Lease(token); found { + t.Fatal("ticket exceeded its absolute lifetime") + } +} + +func TestControlTicketSurvivesTheShortDecisionGrantWindow(t *testing.T) { + store := newControlTicketStore(30*time.Minute, time.Hour, 1) + now := time.Unix(2_000, 0) + store.now = func() time.Time { return now } + sequence := 0 + store.random = func(_ int) (string, error) { + sequence++ + return strings.Repeat(string(rune('k'+sequence)), 24), nil + } + attempt := playback.Attempt{ + MetadataPath: "/library/metadata/42", MediaIndex: 0, PartIndex: 0, + Session: playback.SessionIdentity{Name: "X-Plex-Playback-Session-Id", Value: "pause-test"}, + } + descriptor, err := store.Issue(attempt, controlTestPart(), resolver.PlaybackRequest{}) + if err != nil { + t.Fatal(err) + } + now = now.Add(6 * time.Minute) + token := descriptor.ControlToken + if _, found := store.Lease(token); !found { + t.Fatal("active playback capability expired with the five-minute decision Grant") + } +} + +func TestControlTicketCoalescesOneAttemptAndSeparatesNormalizedAttempts(t *testing.T) { + store := newControlTicketStore(time.Minute, time.Hour, 4) + firstAttempt := playback.Attempt{ + MetadataPath: "/library/metadata/42", MediaIndex: 0, PartIndex: 0, + Session: playback.SessionIdentity{Name: "X-Plex-Playback-Session-Id", Value: "first"}, + } + first, err := store.Issue(firstAttempt, controlTestPart(), resolver.PlaybackRequest{}) + if err != nil { + t.Fatal(err) + } + repeated, err := store.Issue(firstAttempt, controlTestPart(), resolver.PlaybackRequest{}) + if err != nil { + t.Fatal(err) + } + if repeated.ControlToken != first.ControlToken { + t.Fatal("one Plex playback attempt did not reuse its control identity") + } + + secondAttempt := firstAttempt + secondAttempt.Session.Value = "second" + second, err := store.Issue(secondAttempt, controlTestPart(), resolver.PlaybackRequest{}) + if err != nil { + t.Fatal(err) + } + if second.ControlToken == first.ControlToken { + t.Fatal("a different normalized Plex attempt reused the previous control identity") + } +} + +func TestShimWeaveHeaderDoesNotChangeAnUnrecognizedStartRequest(t *testing.T) { + var upstreamRequests atomic.Int64 + plex := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamRequests.Add(1) + if r.Header.Get(shimWeaveAcceptHeader) != shimWeaveProtocol { + t.Fatalf("negotiation header was not transparently forwarded") + } + w.Header().Set("Content-Type", "application/dash+xml") + w.WriteHeader(http.StatusAccepted) + })) + defer plex.Close() + + handler, _, _ := newCloudHandler(t, plex.URL, "http://mediavault.invalid:7811", nil) + request := httptest.NewRequest(http.MethodGet, "/video/:/transcode/universal/start.mpd?path=%2Flibrary%2Fmetadata%2F42&mediaIndex=0&partIndex=0", nil) + request.Header.Set(shimWeaveAcceptHeader, shimWeaveProtocol) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + + if response.Code != http.StatusAccepted || response.Header().Get(shimWeaveProtocolHeader) != "" || upstreamRequests.Load() != 1 { + t.Fatalf("status=%d protocol=%q upstream=%d", response.Code, response.Header().Get(shimWeaveProtocolHeader), upstreamRequests.Load()) + } +} + +func controlTestPart() playback.PreparedPart { + return playback.PreparedPart{ + Part: plexmeta.Part{ + ID: "21", Key: "/library/parts/21/1/file", File: "/media/cloud/D.strm", + }, + Target: "http://mediavault.invalid/redirect/pickcode/D.mkv", + } +} + +func TestControlTicketSourceIdentityChangesWithMaterializedPart(t *testing.T) { + base := playback.PreparedPart{ + Part: plexmeta.Part{ID: "21", Key: "/library/parts/21/1/file", File: "/media/D.strm"}, + Target: "https://media.invalid/redirect/first", + } + changedKey := base + changedKey.Part.Key = "/library/parts/21/2/file" + changedTarget := base + changedTarget.Target = "https://media.invalid/redirect/second" + if shimWeaveSourceID(base) == shimWeaveSourceID(changedKey) || + shimWeaveSourceID(base) == shimWeaveSourceID(changedTarget) { + t.Fatal("source identity did not include Part change stamp and STRM target") + } +} + +func TestControlTicketRejectsOversizedAuthorizationContext(t *testing.T) { + store := newControlTicketStore(time.Minute, time.Hour, 1) + attempt := playback.Attempt{ + MetadataPath: "/library/metadata/42", MediaIndex: 0, PartIndex: 0, + Session: playback.SessionIdentity{Name: "X-Plex-Playback-Session-Id", Value: "playback"}, + } + header := make(http.Header) + header.Set("X-Large", strings.Repeat("x", maxControlRequestHeaderBytes)) + if _, err := store.Issue(attempt, controlTestPart(), resolver.PlaybackRequest{Header: header}); err == nil { + t.Fatal("oversized request headers were retained by the ticket store") + } +} + +func TestControlEndpointRequiresHeaderTokenAndRange(t *testing.T) { + store := newControlTicketStore(time.Minute, time.Hour, 1) + store.random = func(bytes int) (string, error) { + return randomControlID(bytes) + } + attempt := playback.Attempt{ + MetadataPath: "/library/metadata/42", MediaIndex: 0, PartIndex: 0, + Session: playback.SessionIdentity{Name: "X-Plex-Playback-Session-Id", Value: "playback"}, + } + descriptor, err := store.Issue(attempt, controlTestPart(), resolver.PlaybackRequest{}) + if err != nil { + t.Fatal(err) + } + handler := &shimWeaveControlHandler{store: store, service: &playback.Service{}} + + withoutToken := httptest.NewRecorder() + handler.ServeHTTP(withoutToken, httptest.NewRequest(http.MethodGet, descriptor.ControlPath, nil)) + if withoutToken.Code != http.StatusNotFound { + t.Fatalf("missing token status = %d", withoutToken.Code) + } + + withoutRangeRequest := httptest.NewRequest(http.MethodGet, descriptor.ControlPath, nil) + withoutRangeRequest.Header.Set(shimWeaveControlTokenHeader, descriptor.ControlToken) + withoutRange := httptest.NewRecorder() + handler.ServeHTTP(withoutRange, withoutRangeRequest) + if withoutRange.Code != http.StatusBadRequest { + t.Fatalf("missing Range status = %d", withoutRange.Code) + } +} diff --git a/internal/gateway/universal_start.go b/internal/gateway/universal_start.go index ebe4d20..1ae784f 100644 --- a/internal/gateway/universal_start.go +++ b/internal/gateway/universal_start.go @@ -13,6 +13,7 @@ import ( type universalStartHandler struct { grants *playback.GrantStore playback *playbackHandler + tickets *controlTicketStore } func (h *universalStartHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -23,5 +24,22 @@ func (h *universalStartHandler) ServeHTTP(w http.ResponseWriter, r *http.Request h.playback.plex.ServeHTTP(w, r) return } + if acceptsShimWeaveControl(r) { + err := h.playback.service.Authorize(playback.PlayInput{ + Request: r, Part: grant.Part, PartReference: grant.Part.Part.Key, + RefreshCacheOnAuthorize: true, + }) + if err != nil { + h.playback.fallback(w, r, grant.Part.Part.ID, "shimweave_authorization") + return + } + descriptor, err := h.tickets.Issue(attempt, grant.Part, playback.ResolverRequest(r)) + if err != nil { + h.playback.fallback(w, r, grant.Part.Part.ID, "shimweave_ticket") + return + } + writeShimWeaveDescriptor(w, descriptor) + return + } h.playback.servePrepared(w, r, grant.Part, grant.Part.Part.Key, true, started, "universal_start") } diff --git a/internal/playback/grant.go b/internal/playback/grant.go index e66edbd..784bbbc 100644 --- a/internal/playback/grant.go +++ b/internal/playback/grant.go @@ -8,16 +8,16 @@ import ( "time" ) -// SessionIdentity is the first stable Plex playback-session identifier shared -// by a decision and its corresponding universal start request. +// SessionIdentity is the first stable Plex request identifier shared by one +// decision and its corresponding universal start request. type SessionIdentity struct { Name string Value string } -// Attempt is the normalized identity of one Plex Media/Part playback attempt. -// Client-specific omission rules belong in the protocol adapter that constructs -// this value, not in grant storage. +// Attempt correlates one Plex decision/start pair for an exact Media/Part. It +// does not represent the user's complete playback intent because Plex may use +// another session value for a Direct Stream or Transcode fallback. type Attempt struct { MetadataPath string MediaIndex int diff --git a/internal/playback/service.go b/internal/playback/service.go index 026253f..1522c3f 100644 --- a/internal/playback/service.go +++ b/internal/playback/service.go @@ -3,6 +3,7 @@ package playback import ( + "context" "errors" "net/http" "path" @@ -205,19 +206,38 @@ func (s *Service) Remember(part PreparedPart) { // Play authorizes the prepared target through Plex, then resolves it through // MediaVault. It never follows or downloads the returned media URL. func (s *Service) Play(input PlayInput) (PlayResult, error) { - if s == nil || s.authorizePart == nil || s.resolver == nil || input.Request == nil { - return PlayResult{}, &Failure{Kind: FailureAuthorization, Err: errors.New("playback service is unavailable")} + if err := s.Authorize(input); err != nil { + return PlayResult{}, err + } + return s.Resolve(input.Request.Context(), input.Part, ResolverRequest(input.Request)) +} + +// Authorize validates that one client may access the exact Plex Part and STRM +// target before any direct URL is resolved or reusable control capability is +// created for that playback. +func (s *Service) Authorize(input PlayInput) error { + if s == nil || s.authorizePart == nil || input.Request == nil { + return &Failure{Kind: FailureAuthorization, Err: errors.New("playback service is unavailable")} } authorized, err := s.authorizePart(input.Request, input.PartReference, input.Part.Target) if err != nil || !authorized { - return PlayResult{}, &Failure{Kind: FailureAuthorization, Err: err} + return &Failure{Kind: FailureAuthorization, Err: err} } if input.RefreshCacheOnAuthorize { s.Remember(input.Part) } + return nil +} +// Resolve exchanges one already-authorized STRM control target for a direct +// URL. Callers must keep the returned URL ephemeral and must not proxy media +// bytes through the Gateway. +func (s *Service) Resolve(ctx context.Context, part PreparedPart, request resolver.PlaybackRequest) (PlayResult, error) { + if s == nil || s.resolver == nil { + return PlayResult{}, &Failure{Kind: FailureResolver, Err: errors.New("playback service is unavailable")} + } started := time.Now() - directURL, err := s.resolver.ResolveTarget(input.Request.Context(), input.Part.Target, resolverRequest(input.Request)) + directURL, err := s.resolver.ResolveTarget(ctx, part.Target, request) result := PlayResult{DirectURL: directURL, ResolverLatency: time.Since(started)} if err != nil { return result, &Failure{Kind: FailureResolver, Err: err} @@ -233,10 +253,11 @@ func (s *Service) enabled() bool { s.preparer.resolver != nil && s.resolver != nil && s.authorizePart != nil } -// resolverRequest preserves every client header and promotes query-carried -// Plex context for the trusted MediaVault control request. Resolving a direct -// link is always GET, including for client HEAD requests. -func resolverRequest(request *http.Request) resolver.PlaybackRequest { +// ResolverRequest snapshots the active client semantics for a trusted control +// request, including query-carried Plex context. The returned headers are +// independent from the caller and may be retained for a bounded playback +// capability; direct-link resolution always uses GET. +func ResolverRequest(request *http.Request) resolver.PlaybackRequest { header := request.Header.Clone() for name, values := range request.URL.Query() { headerName, ok := plexContextHeaderName(name) diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 9f36bd6..9649597 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -44,15 +44,15 @@ var ( errUnsupportedTargetURL = errors.New("STRM target URL scheme is unsupported") ) -// DirectURL is the final URL a Plex client should play directly. +// DirectURL is the final URL a client should play directly. // // It intentionally contains no response metadata. A caller must not use the -// gateway as a media proxy; the URL is only copied to an HTTP Location header. +// Gateway as a media proxy or retain the URL beyond a bounded control exchange. type DirectURL struct { URL string } -// String returns the URL in the same form that should be sent in Location. +// String returns the URL in the form supplied by the trusted resolver. func (u DirectURL) String() string { return u.URL } From f5f70178535ad7032b2dd62c8a40093c30b70346 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Thu, 3 Sep 2026 06:32:49 +0800 Subject: [PATCH 2/3] docs: define ShimWeave control performance --- README.md | 23 ++++--- docs/performance-matrix.md | 15 ++++- docs/roadmap.md | 20 +++--- .../shimweave_control_benchmark_test.go | 63 +++++++++++++++++++ 4 files changed, 102 insertions(+), 19 deletions(-) create mode 100644 internal/gateway/shimweave_control_benchmark_test.go diff --git a/README.md b/README.md index d943d3a..ddaec7a 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,10 @@ > **状态:** 实验性。透明代理和 STRM 重定向链路已经实现,但客户端兼容性仍然有限,必须针对每个 Plex 客户端和播放模式分别验证。 `plex-gateway` 是一个面向 STRM 云端媒体 Direct Play 的故障回退型 Plex -协议适配器。Plex 仍然是元数据、身份认证、媒体库和观看状态的权威来源。Gateway -观察 Plex 元数据,通过 MediaVault 解析符合条件的 STRM,并返回一次 HTTP 302, -让客户端直接从云端 CDN 下载媒体。 +协议适配器。Plex 仍然是元数据、身份认证、媒体库和观看状态的权威来源。普通客户端播放时, +Gateway 观察 Plex 元数据,通过 MediaVault 解析符合条件的 STRM,并返回 HTTP 302,让客户端 +直接从云端 CDN 下载媒体。支持 `control-v1` 的浏览器扩展可改用无媒体 body 的控制交换;媒体 +仍由浏览器直接从 CDN 读取。 本地 Plex 媒体不会被重写。缓存未命中、路径映射失败、STRM 文件无效、MediaVault 失败以及所有不支持的 endpoint 都会无条件回退到原始 Plex 请求。 @@ -24,8 +25,9 @@ Part。Part 缓存未命中时,请求会原样发送给 Plex;Gateway 不会 响应的前提下,请求 Plex 将该播放选择为 Direct Play。只有当该完整响应明确将同一个 Part 标记为 Direct Play 后,Gateway 才会创建短期授权。如果客户端随后请求通用的 `start`、`start.mpd` 或 `start.m3u8`,Gateway 会要求该播放会话及精确 Media/Part -对应的授权,再次为 Part 请求授权,并返回同一个 CDN 重定向,而不会启动 Plex -Transcoder 或合成 Plex 元数据。 +对应的授权,再次为 Part 请求授权。普通客户端收到 CDN 重定向;声明 `control-v1` 的请求收到 +固定控制端点和短期 bearer,浏览器随后为每个 Range 换取临时 CDN URL。两条路径都不会启动 +Plex Transcoder、合成 Plex 元数据或让媒体字节经过 Gateway。 Gateway 对所有客户端使用同一套 MediaInfo 投影规则。默认关闭的实验性播放 veto 只在 Plex 已返回同一 STRM Part 的 Direct Play decision、且本次 MediaInfo 增强已经取得新鲜 @@ -34,12 +36,14 @@ Plex 已返回同一 STRM Part 的 Direct Play decision、且本次 MediaInfo 额外探测。Apple TV 硬件支持 Profile 5,实际兼容性还受 Plex 版本、容器和片源影响, 因此 veto 默认关闭。 -能够在这些路径上接受原始媒体重定向的原生客户端可以直接使用此链路。Plex Web -兼容层默认开启:Gateway 只在 Plex Web shell 中加载一个版本化脚本,并只对同源、 +能够在这些路径上接受原始媒体重定向的原生客户端可以直接使用此链路。未安装 ShimWeave 时, +默认启用的 Plex Web 兼容层只在 Plex Web shell 中加载一个版本化脚本,并只对同源、 呈 STRM Part 形态的 `/library/parts/.../file` 或 `.strm` 媒体元素移除 `crossorigin`。浏览器原生支持该容器和编码时,可以按 普通媒体元素跟随 302 直接读取 CDN,即使最终源站没有 CORS header;媒体字节仍不经过 -Gateway。需要 DASH、转封装或转码的片源仍不支持,Gateway 不合成 manifest,也不代理 -视频流。关闭 `PLEX_WEB_DIRECT_PLAY_ENABLED` 后,Plex Web 页面恢复完全透明代理。 +Gateway。需要 DASH、转封装或音频转码的片源仍无法由该内置薄层处理。ShimWeave 开发预览版 +可通过 `control-v1` 接管这类播放,并在浏览器内完成转封装或必要的音频转码;Gateway 仍不合成 +manifest、不代理视频流。关闭 `PLEX_WEB_DIRECT_PLAY_ENABLED` 后,内置薄层恢复完全透明代理, +不影响扩展的独立协商协议。 ## 兼容性状态 @@ -50,6 +54,7 @@ Gateway。需要 DASH、转封装或转码的片源仍不支持,Gateway 不合 | Plex iOS ExperimentalPlayer | 已验证 Direct Play | STRM 使用通用 decision/start 重定向链路。Gateway 描述性 Stream 可补充 4K、HEVC、HDR/DV 等技术字段;Plex 尚未物化原生 Stream 行时,音轨状态或播放页徽标仍可能不完整。 | | Plex for Apple TV | 已验证 Direct Play | HDR STRM 使用通用 decision/start 重定向链路。Apple TV 4K 支持 DV Profile 5 Direct Play,但 Plex 版本、容器和片源组合仍可能影响兼容性;Gateway 默认不拒绝。 | | Plex Web 云端 Direct Play | 已验证 Direct Play | MP4/H.264/AAC 样本已验证首播、暂停、继续和 seek,并保持 CDN 直达;其他容器与编码取决于浏览器原生能力,需要 DASH、转封装或转码的片源不支持。 | +| Plex Web + ShimWeave | 开发预览 | 通过 `control-v1` 终止原始 manifest 请求,媒体由扩展从 CDN 读取并在浏览器内转封装或进行必要的音频转码;真实 MKV、4K、HDR/DV 和长片链路仍需验收。 | 本项目是独立的社区项目,与 Plex、MediaVault、Infuse 或其各自所有者没有关联, 也未获得其认可或赞助。 diff --git a/docs/performance-matrix.md b/docs/performance-matrix.md index ee8089e..0e88c49 100644 --- a/docs/performance-matrix.md +++ b/docs/performance-matrix.md @@ -27,7 +27,7 @@ 上限;P0 实际播放优先于 P1/P2 排队任务,但不能抢占正在运行的后台 probe。 - P1/P2 在 worker 中先检查 SQLite。只有 L1 和 SQLite 都 miss 的任务才受全局远程启动 间隔限制;SQLite 热命中不能因 CDN 风控节流额外等待 5 秒。 -- 302 响应只包含控制流。媒体字节始终由客户端直接从 CDN 读取。 +- 302 与 `control-v1` 的 204 响应只包含控制流。媒体字节始终由客户端直接从 CDN 读取。 - Plex Web 兼容层只缓冲最多 2 MiB 的 Web shell,并加载可长期缓存的静态脚本;Part、 302、本地媒体和其他 Plex API 不缓冲响应,也不新增媒体数据面 I/O。 @@ -51,6 +51,9 @@ | Metadata 分析过滤器,移除目标参数 | 377.3-386.3 ns/op | 768 B, 7 allocs | 克隆请求 URL,其他 query 字节不变。 | | 32 项 metadata 直接内存 handler | 76.87-78.02 us/op | 约 219.5 KiB, 929 allocs | 仅作为微批代码增量对照,不包含 Plex 网络或数据库。 | | 32 项 metadata 微批、拆分与 fan-out | 422.9-448.4 us/op | 约 577.1 KiB, 4856-4857 allocs | 相对直接 handler 增加约 0.35-0.37 ms,包含 goroutine、JSON 拆分及 32 个响应。 | +| control ticket 同 attempt 复用 | 689.7-729.7 ns/op | 688 B, 8 allocs | 只衡量内存 ticket 更新,不包含 Plex 或 MediaVault。 | +| control ticket 单次 lease | 289.3-293.7 ns/op | 432 B, 3 allocs | 包含 idle TTL 续期和请求上下文复制。 | +| control ticket 并行 lease | 413.7-417.3 ns/op | 432 B, 3 allocs | 同一 ticket 的并行锁竞争下限,不包含 HTTP 或解析。 | 真实 MediaVault/CDN 抽样为 5/5 成功:MediaVault redirect p50 141 ms、范围 109-533 ms;受限 CDN ffprobe p50 601 ms、范围 403-674 ms;合计 p50 761 ms、范围 @@ -95,10 +98,13 @@ Guard 尾部等待和客户端结果共同支持批量默认值 4。 | --- | --- | --- | --- | | 本地媒体透明代理 | Plex | 直连 Plex 与经 Gateway 的 p50/p95/p99、错误率、上游请求数 | 0 功能错误;Gateway 增量 p95 <= 20 ms、p99 <= 50 ms,且相对增加 <= 10%。 | | STRM Part 302 | Plex Part 授权、MediaVault redirect | 302 p50/p95/p99、Plex/MV 次数、响应字节 | 100% 健康样本返回 302;p95 < 2 s、p99 < 5 s;Gateway 不传输媒体字节。 | -| universal start | Plex Part 授权、MediaVault redirect | grant 命中、上游次数、302 延迟 | 不读取 metadata、STRM 或 MediaInfo;无 grant 时透明回退。 | +| 普通 universal start | Plex Part 授权、MediaVault redirect | grant 命中、上游次数、302 延迟 | 不读取 metadata、STRM 或 MediaInfo;无 grant 时透明回退。 | +| ShimWeave control-v1 start | Plex Part 授权 | 204 descriptor、Plex/MV 次数、响应字节 | 原始 manifest 请求止于 204;创建描述时 0 MV、0 媒体字节;无 grant 或协商无效时透明回退。 | +| ShimWeave control-v1 Range | MediaVault redirect | 控制 p50/p95/p99、Range/控制/MV 次数、错误率 | 正常路径每个未命中 Range 解析一次临时 URL;CDN 403 恢复最多再解析一次。Gateway 只返回 204 header,不跟随 CDN、不传媒体字节。 | | Plex Web shell | Plex HTML、Gateway 静态脚本 | shell body、注入次数、首屏延迟、脚本缓存 | shell <= 2 MiB 时只修改一次;脚本命中长期缓存;其他 Web 静态资源和 API 不变。 | | Plex Web 云端 Direct Play | Plex decision/Part 授权、MediaVault redirect、CDN | 首帧、seek、Part 302、Gateway 媒体响应字节 | 浏览器原生支持的样本正常播放和 seek;Gateway 媒体响应保持 302 且不传输视频字节。 | -| Plex Web 需要 DASH/转码 | Plex | decision、start manifest、Gateway redirect | 明确保持不支持;不得把原始 MP4/MKV 作为 `start.mpd` manifest 宣称成功,也不得让媒体字节进入 Gateway。 | +| Plex Web 需要 DASH/转码且无 ShimWeave | Plex | decision、start manifest、Gateway redirect | 内置薄层明确保持不支持;不得把原始 MP4/MKV 作为 `start.mpd` manifest 宣称成功,也不得让媒体字节进入 Gateway。 | +| Plex Web + ShimWeave 转封装 | Plex、MediaVault、CDN | 首帧、Seek、持续缓冲、Range/控制/MV 次数 | manifest 请求止于 control-v1;转封装在浏览器完成;Gateway 媒体响应字节为 0。 | | Plex 已物化 STRM MediaInfo | Plex | response 字节、STRM/L1/SQLite/probe 次数 | metadata 与 decision 字节级透传;0 STRM、0 L1、0 SQLite、0 ffprobe。 | | Decision,Infuse | Plex metadata 和 decision | decision 延迟、veto 结果 | veto 始终放弃判断;不增加缓存、MediaVault 或 CDN 请求。 | | Decision,L1 热缓存 | Plex metadata 和 decision,L1 | p50/p95/p99、SQLite/MV/CDN 次数 | 0 SQLite、0 MV、0 CDN;p95 <= 1 s、p99 <= 2 s。 | @@ -131,6 +137,9 @@ go test -run '^$' -bench '^BenchmarkMetadataAnalysisFilter$' \ go test -run '^$' -bench '^BenchmarkMetadataCoalescerBurst32$' \ -benchmem -count=5 ./internal/gateway + +go test -run '^$' -bench '^BenchmarkControlTicketStore$' \ + -benchmem -count=5 ./internal/gateway ``` 真实 NAS 验收必须使用候选镜像、固定样本和串行 A/B,记录版本、配置、样本数、分位数、 diff --git a/docs/roadmap.md b/docs/roadmap.md index 1da75d6..f7b4bc9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -56,18 +56,24 @@ The compatibility adapter therefore: Official mobile and Plex Web traces then demonstrated that a successful Direct Play decision can still be followed by `start.mpd` or `start.m3u8` because STRM metadata has no media streams. The start adapter selects and authorizes the -exact Part captured by the same session's confirmed Direct Play decision grant, -resolves MediaVault, and returns the final CDN 302 without repeating metadata -selection. Production playback has successfully followed this redirect path -with Plex iOS ExperimentalPlayer and Plex for Apple TV. Other official-client -playback paths are not supported by the current compatibility scope. +exact Part captured by the same session's confirmed Direct Play decision grant +without repeating metadata selection. Ordinary clients receive the final CDN +302. A request that declares ShimWeave `control-v1` instead receives a bodyless +descriptor and uses the fixed control endpoint to resolve one ephemeral CDN URL +per uncached Range on the normal success path. A CDN 403 may repeat that exchange +once for the affected Range. Production playback has successfully followed the +redirect path with Plex iOS ExperimentalPlayer and Plex for Apple TV; the browser +control path remains under end-to-end validation. Plex Web Direct Play is included for media the browser can decode natively. A small, default-enabled shell helper removes `crossorigin` only from same-origin Plex Part media elements, allowing the existing Part 302 to remain CDN-direct when the final origin does not emit CORS headers. The helper does not handle -DASH, remuxing, or transcoding and does not proxy media bytes. Local Plex Web -media, manifest segments, and every genuine transcode request remain Plex-owned. +DASH, remuxing, or transcoding and does not proxy media bytes. The separate +ShimWeave protocol may terminate an eligible manifest request and perform remux +or required audio transcoding in the browser while media bytes remain CDN-direct. +Local Plex Web media and every request without a valid negotiation remain +Plex-owned. ## MediaInfo fallback and projection diff --git a/internal/gateway/shimweave_control_benchmark_test.go b/internal/gateway/shimweave_control_benchmark_test.go new file mode 100644 index 0000000..a606532 --- /dev/null +++ b/internal/gateway/shimweave_control_benchmark_test.go @@ -0,0 +1,63 @@ +package gateway + +import ( + "net/http" + "testing" + "time" + + "github.com/InfinityPacer/plex-gateway/internal/playback" + "github.com/InfinityPacer/plex-gateway/internal/resolver" +) + +func BenchmarkControlTicketStore(b *testing.B) { + store := newControlTicketStore(time.Hour, 24*time.Hour, 1024) + attempt := playback.Attempt{ + MetadataPath: "/library/metadata/42", + MediaIndex: 0, + PartIndex: 0, + Session: playback.SessionIdentity{ + Name: "X-Plex-Playback-Session-Id", + Value: "benchmark-playback", + }, + } + request := resolver.PlaybackRequest{ + Method: http.MethodGet, + Header: http.Header{ + "User-Agent": {"ShimWeave Benchmark"}, + "X-Plex-Token": {"benchmark-token"}, + }, + } + descriptor, err := store.Issue(attempt, controlTestPart(), request) + if err != nil { + b.Fatal(err) + } + + b.Run("issue-reuse", func(b *testing.B) { + b.ReportAllocs() + for range b.N { + if _, err := store.Issue(attempt, controlTestPart(), request); err != nil { + b.Fatal(err) + } + } + }) + + b.Run("lease", func(b *testing.B) { + b.ReportAllocs() + for range b.N { + if _, found := store.Lease(descriptor.ControlToken); !found { + b.Fatal("control ticket expired during benchmark") + } + } + }) + + b.Run("lease-parallel", func(b *testing.B) { + b.ReportAllocs() + b.RunParallel(func(parallel *testing.PB) { + for parallel.Next() { + if _, found := store.Lease(descriptor.ControlToken); !found { + b.Fatal("control ticket expired during benchmark") + } + } + }) + }) +} From eec4330c8cb480aea46d4a64e8c99993b2bc6748 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Fri, 4 Sep 2026 17:10:54 +0800 Subject: [PATCH 3/3] feat: release plex-gateway v0.1.4 --- CHANGELOG.en.md | 30 +++++++++++++++++++++++++++++- CHANGELOG.md | 24 +++++++++++++++++++++++- README.en.md | 36 ++++++++++++++++++++++++++++++------ README.md | 17 ++++++++++++++--- VERSION | 2 +- docs/architecture.md | 8 ++++++++ docs/performance-matrix.md | 10 ++++++++-- docs/roadmap.md | 7 +++++-- 8 files changed, 118 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index 63b3272..8005b62 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.4] - 2026-09-04 + +### Added + +- Plex Web can now work with + [ShimWeave](https://github.com/InfinityPacer/ShimWeave) for more STRM sources + that need browser-side remuxing or audio compatibility handling. Media still + travels directly from the CDN to the browser without consuming NAS media + bandwidth. + +### Improved + +- Initial playback, long seeks, and clean shutdown were verified with MKV, + HEVC, and AAC. Local media, ordinary clients, and requests without ShimWeave + keep their existing Plex behavior. +- Temporary direct URLs can be refreshed during playback, so long sessions and + seeks do not depend on an expired CDN URL. + +### Known limitations + +- Actual format support depends on ShimWeave, the browser, the operating system, + and hardware decoding. This release does not transcode on the Gateway or NAS + and does not promise every MKV, 4K, HDR, Dolby Vision, or audio combination. +- In the acceptance environment, one 4K HEVC Main 10 with EAC3 source is still + explicitly reported as unsupported by ShimWeave 0.0.1. Similar sources must + be verified against the actual browser and hardware. + ## [0.1.3] - 2026-08-30 ### Added @@ -85,7 +112,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 gateway redirects playback. - Logs and metrics exclude Plex tokens and complete signed media URLs. -[Unreleased]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.3...HEAD +[Unreleased]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.4...HEAD +[0.1.4]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.3...v0.1.4 [0.1.3]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.2...v0.1.3 [0.1.2]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.0...v0.1.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fe00ce..7a30ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ ## [未发布] +## [0.1.4] - 2026-09-04 + +### 新增 + +- Plex Web 可与 [ShimWeave](https://github.com/InfinityPacer/ShimWeave) 配合播放更多需要 + 浏览器端转封装或音频兼容处理的 STRM,媒体仍由 CDN 直接发送到浏览器,不占用 NAS + 媒体带宽。 + +### 改进 + +- 已验证 MKV、HEVC、AAC 的起播、长进度拖动和关闭播放。本地媒体、普通客户端及未启用 + ShimWeave 的请求继续保持原有 Plex 行为。 +- 播放期间可按需刷新临时直链,避免长时间播放或拖动进度依赖已经过期的 CDN 地址。 + +### 已知限制 + +- 实际格式支持取决于 ShimWeave、浏览器、操作系统和硬件解码能力。本版本不在 Gateway + 或 NAS 上转码,也不承诺支持所有 MKV、4K、HDR、Dolby Vision 或音频组合。 +- 当前验收环境中的一组 4K HEVC Main 10 + EAC3 片源仍由 ShimWeave 0.0.1 明确报告为 + 不支持格式;其他同类片源仍需按实际浏览器和硬件验证。 + ## [0.1.3] - 2026-08-30 ### 新增 @@ -71,7 +92,8 @@ - Gateway 返回云端播放重定向前,由 Plex 使用当前客户端凭据授权对应 Part。 - 日志和指标不包含 Plex Token 或完整的签名媒体 URL。 -[未发布]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.3...HEAD +[未发布]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.4...HEAD +[0.1.4]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.3...v0.1.4 [0.1.3]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.2...v0.1.3 [0.1.2]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.0...v0.1.1 diff --git a/README.en.md b/README.en.md index d9497ad..f91881e 100644 --- a/README.en.md +++ b/README.en.md @@ -13,7 +13,9 @@ STRM-backed cloud media. Plex remains the metadata, authentication, library, and watch-state authority. The gateway observes Plex metadata, resolves an eligible STRM through MediaVault, and returns one HTTP 302 response so the -client downloads media directly from the cloud CDN. +client downloads media directly from the cloud CDN. A browser extension that +negotiates `control-v1` can instead use bodyless control exchanges while media +still travels directly from the CDN to the browser. Local Plex media is never rewritten. Cache misses, mapping failures, invalid STRM files, MediaVault failures, and every unsupported endpoint fall back to @@ -32,8 +34,9 @@ short-lived grant is created only after Plex explicitly marks the same Part as Direct Play. If the client then requests universal `start`, `start.mpd`, or `start.m3u8`, the gateway requires that grant from the same playback session and exact Media/Part, authorizes the Part again, and returns -the same CDN redirect without starting Plex Transcoder or synthesizing Plex -metadata. +control information to ShimWeave or the CDN redirect to an ordinary client. +Neither path starts Plex Transcoder, synthesizes Plex metadata, or sends media +bytes through the gateway. The gateway applies the same MediaInfo projection rules to every client. A disabled-by-default experimental playback veto checks the Plex for Apple TV, @@ -53,9 +56,13 @@ elements that have the shape of STRM Parts. When the browser natively supports the container and codecs, the element can follow the 302 and read the CDN directly even when the final origin has no CORS header; media bytes still bypass the gateway. Sources that require DASH, remuxing, or -transcoding remain unsupported. The gateway does not synthesize manifests or -proxy video streams. Setting `PLEX_WEB_DIRECT_PLAY_ENABLED=false` restores a -fully transparent Plex Web shell. +audio transcoding remain unsupported by the built-in helper. +[ShimWeave](https://github.com/InfinityPacer/ShimWeave) can negotiate +`control-v1` for these sources and perform browser-side remuxing or necessary +audio compatibility handling. The gateway still does not synthesize manifests +or proxy video streams. Setting `PLEX_WEB_DIRECT_PLAY_ENABLED=false` restores a +fully transparent Plex Web shell without disabling the separate extension +protocol. ## Compatibility status @@ -66,10 +73,27 @@ fully transparent Plex Web shell. | Plex iOS ExperimentalPlayer | Direct Play verified | STRM uses the universal decision/start redirect path. Descriptive Gateway Streams can provide 4K, HEVC, HDR/DV, and related technical fields; audio state or playback badges may remain incomplete until Plex has materialized native Stream rows. | | Plex for Apple TV | Direct Play verified | HDR STRM uses the universal decision/start redirect path. Apple TV 4K supports Dolby Vision Profile 5 Direct Play, while Plex version, container, and source combinations may still affect compatibility; the gateway does not reject it by default. | | Plex Web cloud Direct Play | Direct Play verified | MP4/H.264/AAC was verified for initial playback, pause, resume, and seek with CDN-direct delivery. Other containers and codecs depend on native browser support; sources that require DASH, remuxing, or transcoding remain unsupported. | +| Plex Web with ShimWeave | Basic integration verified | Initial playback and long seeks were verified with MKV, HEVC, and AAC. The extension reads media from the CDN and handles it in the browser. One tested 4K HEVC Main 10 with EAC3 source remains explicitly unsupported; additional 4K, HDR/DV, and audio combinations depend on ShimWeave, the browser, operating system, and hardware. | This is an independent community project. It is not affiliated with, endorsed by, or sponsored by Plex, MediaVault, Infuse, or their respective owners. +## Using ShimWeave + +`plex-gateway 0.1.4` and later include the ShimWeave `control-v1` protocol with +no additional environment variable. After installing +[ShimWeave 0.0.1 or later](https://github.com/InfinityPacer/ShimWeave/releases), +the extension takes over only when Plex Web cannot play the source natively, +Plex has authorized the exact STRM Part for Direct Play, and both sides complete +protocol negotiation. Existing Plex behavior remains unchanged without the +extension, after a failed negotiation, for local media, and for browser-native +sources. + +The gateway returns only control information and the current temporary media +address. The browser requests Range data directly from the CDN, so media bytes +never traverse the gateway or NAS. See the ShimWeave README for installation, +supported formats, and current limitations. + ## Container image Release images use diff --git a/README.md b/README.md index ddaec7a..2bb87fe 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ Plex 已返回同一 STRM Part 的 Direct Play decision、且本次 MediaInfo 默认启用的 Plex Web 兼容层只在 Plex Web shell 中加载一个版本化脚本,并只对同源、 呈 STRM Part 形态的 `/library/parts/.../file` 或 `.strm` 媒体元素移除 `crossorigin`。浏览器原生支持该容器和编码时,可以按 普通媒体元素跟随 302 直接读取 CDN,即使最终源站没有 CORS header;媒体字节仍不经过 -Gateway。需要 DASH、转封装或音频转码的片源仍无法由该内置薄层处理。ShimWeave 开发预览版 -可通过 `control-v1` 接管这类播放,并在浏览器内完成转封装或必要的音频转码;Gateway 仍不合成 +Gateway。需要 DASH、转封装或音频转码的片源仍无法由该内置薄层处理。[ShimWeave](https://github.com/InfinityPacer/ShimWeave) +可通过 `control-v1` 接管这类播放,并在浏览器内完成转封装或必要的音频兼容处理;Gateway 仍不合成 manifest、不代理视频流。关闭 `PLEX_WEB_DIRECT_PLAY_ENABLED` 后,内置薄层恢复完全透明代理, 不影响扩展的独立协商协议。 @@ -54,11 +54,22 @@ manifest、不代理视频流。关闭 `PLEX_WEB_DIRECT_PLAY_ENABLED` 后,内 | Plex iOS ExperimentalPlayer | 已验证 Direct Play | STRM 使用通用 decision/start 重定向链路。Gateway 描述性 Stream 可补充 4K、HEVC、HDR/DV 等技术字段;Plex 尚未物化原生 Stream 行时,音轨状态或播放页徽标仍可能不完整。 | | Plex for Apple TV | 已验证 Direct Play | HDR STRM 使用通用 decision/start 重定向链路。Apple TV 4K 支持 DV Profile 5 Direct Play,但 Plex 版本、容器和片源组合仍可能影响兼容性;Gateway 默认不拒绝。 | | Plex Web 云端 Direct Play | 已验证 Direct Play | MP4/H.264/AAC 样本已验证首播、暂停、继续和 seek,并保持 CDN 直达;其他容器与编码取决于浏览器原生能力,需要 DASH、转封装或转码的片源不支持。 | -| Plex Web + ShimWeave | 开发预览 | 通过 `control-v1` 终止原始 manifest 请求,媒体由扩展从 CDN 读取并在浏览器内转封装或进行必要的音频转码;真实 MKV、4K、HDR/DV 和长片链路仍需验收。 | +| Plex Web + ShimWeave | 已验证基础联动 | MKV、HEVC、AAC 已验证起播和长进度拖动。媒体由扩展从 CDN 读取并在浏览器内处理;当前一组 4K HEVC Main 10 + EAC3 样本仍明确不支持,更多 4K、HDR/DV 和音频组合取决于 ShimWeave、浏览器、系统及硬件能力。 | 本项目是独立的社区项目,与 Plex、MediaVault、Infuse 或其各自所有者没有关联, 也未获得其认可或赞助。 +## 与 ShimWeave 配合 + +`plex-gateway 0.1.4` 起内置 ShimWeave `control-v1` 协议,无需增加环境变量。安装 +[ShimWeave 0.0.1 或更高版本](https://github.com/InfinityPacer/ShimWeave/releases) 后, +扩展只会在 Plex Web 无法原生播放、Plex 已为准确 STRM Part 建立 Direct Play 授权且双方完成 +协议协商时接管播放。未安装扩展、协商失败、本地媒体或浏览器原生可播内容继续使用原有 Plex +路径。 + +Gateway 只返回控制信息和当前有效的临时媒体地址。媒体 Range 数据始终由浏览器直接请求 CDN, +不会经过 Gateway 或 NAS。ShimWeave 的安装方法、支持格式和已知限制以其项目 README 为准。 + ## 容器镜像 发布后的镜像将使用: diff --git a/VERSION b/VERSION index b1e80bb..845639e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.3 +0.1.4 diff --git a/docs/architecture.md b/docs/architecture.md index 87f0188..e063823 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -297,6 +297,14 @@ The redirect plane requires a client that can consume the final raw media URL. Native players are not subject to browser cross-origin fetch enforcement and may accept redirects from either Part or eligible universal start routes. +ShimWeave integration is negotiated per request and remains outside the normal +redirect path. The gateway owns Plex authorization and temporary URL exchange; +the extension owns browser capability checks, media reads, remuxing, and audio +compatibility handling. Requests without a valid `control-v1` negotiation, +including local media and browser-native playback, retain Plex's original +behavior. Gateway protocol support therefore does not imply that every media +format is playable in a particular browser or device. + Plex Web sets `crossorigin=anonymous` on its HTML media element. When a cloud Part redirects from the Gateway origin to a CDN without CORS headers, the browser rejects an otherwise natively playable file before Plex Web falls back diff --git a/docs/performance-matrix.md b/docs/performance-matrix.md index 0e88c49..11f1fc4 100644 --- a/docs/performance-matrix.md +++ b/docs/performance-matrix.md @@ -85,6 +85,11 @@ Guard 尾部等待和客户端结果共同支持批量默认值 4。 单批最多 32 项。合成 batch 占用批量池,异常回退占用单项池。是否继续调整只依据候选 版本的 Plex 延迟、错误率和真实客户端请求矩阵,不与未来 Plex MediaInfo 写入方案绑定。 +2026-09-04 的 `control-v1` 候选验收使用 ShimWeave 0.0.1。MKV、HEVC、AAC 样本已完成 +从头播放、跳转到 600 秒后继续播放和关闭播放;本地媒体保持 Plex 原路径。观测到的 617 次 +Gateway 控制请求全部返回预期的 `204`,处理失败为 0。该结果验证的是 Gateway 控制协议和 +CDN 直达边界,不代表浏览器可解码所有 4K、HDR、Dolby Vision 或音频组合。 + 2026-08-30 的 NAS 阻塞 A/B 将旧候选判定为不可接受:同一冷 metadata 请求直连 Plex 约 `0.52s`,经旧候选约 `5.15s`,Gateway 热缓存约 `0.15s`。同期实际 CDN ffprobe 平均 约 `0.54s`、最大约 `0.84s`,且没有 probe 失败。约五秒的增量来自 metadata 请求同步等待 @@ -119,8 +124,9 @@ Guard 尾部等待和客户端结果共同支持批量默认值 4。 | 长剧集分析参数过滤 A/B | Plex、L1 | 过滤计数、Scanner 进程、`accessible/exists`、metadata p50/p95/p99 | `asyncAugmentMetadata` 不到达 Plex;`checkFiles` 和对应字段保留;Scanner 为 0。 | 表中的验收条件是候选版本的门槛,不代表本机微基准已经替代端到端验收。当前已有证据 -包括本机代码基准和 5/5 成功的 MediaVault/CDN 探测抽样;本地透明代理、302 热冷路径、 -决策热冷路径的 p95/p99、并发矩阵和快速切换矩阵,仍需使用候选版本在目标 NAS 上串行采集。 +包括本机代码基准、5/5 成功的 MediaVault/CDN 探测抽样、长剧集和本地媒体 A/B,以及 +ShimWeave `control-v1` 的目标 NAS 真实播放。尚未覆盖的格式或环境必须继续单独验收,不能从 +一个成功样本外推为普遍兼容。 客户端首次画面必须单独测量。Gateway 302 时间、客户端跟随 Location、CDN 首字节和 播放器首帧是不同阶段,不能用 302 延迟代替起播性能。 diff --git a/docs/roadmap.md b/docs/roadmap.md index f7b4bc9..977bf3a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -62,8 +62,11 @@ without repeating metadata selection. Ordinary clients receive the final CDN descriptor and uses the fixed control endpoint to resolve one ephemeral CDN URL per uncached Range on the normal success path. A CDN 403 may repeat that exchange once for the affected Range. Production playback has successfully followed the -redirect path with Plex iOS ExperimentalPlayer and Plex for Apple TV; the browser -control path remains under end-to-end validation. +redirect path with Plex iOS ExperimentalPlayer and Plex for Apple TV. The +browser control path has passed its initial end-to-end gate with ShimWeave +0.0.1: MKV/HEVC/AAC playback, a long seek, playback shutdown, and local-media +bypass were all verified. Additional format combinations remain ShimWeave and +browser compatibility work rather than Gateway protocol scope. Plex Web Direct Play is included for media the browser can decode natively. A small, default-enabled shell helper removes `crossorigin` only from same-origin