From 36390e90620f224c9601116a036ff149080e7641 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Sun, 30 Aug 2026 19:29:55 +0800 Subject: [PATCH 1/4] fix: trust Plex materialized media info --- README.en.md | 12 ++- README.md | 7 +- docs/architecture.md | 10 ++ internal/gateway/decision.go | 35 ++++--- internal/gateway/decision_test.go | 20 ++++ internal/gateway/metadata_enrichment_test.go | 40 +++++++- internal/plexmeta/decision_projection.go | 99 +++++++++++++------ internal/plexmeta/decision_projection_test.go | 51 ++++++++++ internal/plexmeta/projection.go | 58 +++++++++++ internal/plexmeta/projection_test.go | 55 +++++++++++ 10 files changed, 338 insertions(+), 49 deletions(-) diff --git a/README.en.md b/README.en.md index 37c3a06..5eff3e9 100644 --- a/README.en.md +++ b/README.en.md @@ -201,8 +201,16 @@ are used to create descriptive video, audio, and subtitle Streams with fields such as HDR10, Dolby Vision, bit depth, codec, codecID, bitrate, channel layout, and language code. Generated Streams never contain Plex Stream IDs or playback selection fields such as `selected`, `default`, or `decision`. When Plex already -has Streams, only missing fields on identity-matched Streams are filled. Missing -sibling Streams are not created and existing Plex values are not overwritten. +has descriptive Streams that are not materialized in its database, only missing +fields on identity-matched Streams are filled. Missing sibling Streams are not +created and existing Plex values are not overwritten. When a STRM Media/Part +shape is complete, the Part carries the real media size, and every Stream has a +positive ID assigned by the Plex data model, the Plex response is technically +authoritative. Metadata and Direct Play decisions are returned byte-for-byte +without reading the STRM, consulting the MediaInfo cache, or admitting ffprobe. +Missing optional descriptions such as `language` or `title` do not revoke that +authority. Non-STRM local Parts always remain outside this decision and are +passed through unchanged. This release writes fallback probe results only to the gateway's SQLite store and does not write to the Plex DB. Production Plex DB writes are a next-stage evaluation after coverage, compatibility, backup, and rollback validation. diff --git a/README.md b/README.md index 778f50e..b1cca7e 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,12 @@ Media、Part 和 Stream 技术字段。若 Plex 的 Part 完全没有 Stream,G ffprobe 的流类型和源索引创建描述性的视频、音频和字幕 Stream,并投影 HDR10、Dolby Vision、bit depth、codec、codecID、bitrate、声道布局和语言码等字段。合成 Stream 不包含 Plex Stream ID,也不生成 `selected`、`default` 或 `decision` 等播放选择字段。Plex 已经存在 -Stream 时只补充身份匹配 Stream 的缺失字段,不创建缺少的其他流,也不覆盖 Plex 值。 +但尚未物化到数据库的描述性 Stream 时,只补充身份匹配 Stream 的缺失字段,不创建缺少 +的其他流,也不覆盖 Plex 值。若 STRM 的 Media/Part 技术字段完整、Part 已具有真实媒体 +大小,且所有 Stream 都带有 Plex 数据模型分配的正数 ID,Plex 响应即为技术权威;Gateway +会原样返回 metadata 和 Direct Play decision,不读取 STRM、不查询 MediaInfo 缓存,也不 +发起 ffprobe。`language`、`title` 等可选描述字段缺失不会解除该权威。本地非 STRM Part +始终完全透传,不进入上述判断。 当前版本只将兜底探测结果写入 Gateway SQLite,不写入 Plex DB。Plex DB 生产写入属于下一 阶段评估内容,需先完成覆盖率、兼容性、备份和回滚验证。 diff --git a/docs/architecture.md b/docs/architecture.md index 3906fe2..61167ce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -203,6 +203,16 @@ the bounded P2 scheduler while the original Plex response is returned immediately. Successful worker results populate L1 and SQLite for later responses. +Projection authority is derived only from the current Plex response, not from +the identity of a database writer. Local non-STRM Parts bypass MediaInfo logic. +For a STRM Part, complete Media/Part technical fields, a real media size, and +positive IDs on every Stream prove that the Stream rows are materialized in the +Plex data model. Such item metadata and Direct Play decisions are preserved +byte-for-byte and do not read the STRM, cache, resolver, or probe worker. +Gateway-generated descriptive Streams intentionally omit Plex IDs, so they +remain distinguishable from that authoritative shape. Optional descriptions +such as language and title are not authority requirements. + ## MediaInfo scheduling contract The in-memory scheduler owns all remote-probe admission. It has three fixed diff --git a/internal/gateway/decision.go b/internal/gateway/decision.go index 9ac0b93..aed494b 100644 --- a/internal/gateway/decision.go +++ b/internal/gateway/decision.go @@ -74,22 +74,27 @@ func (h *decisionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if capture.successful() { body := capture.body() if plexmeta.IsDirectPlayDecision(body, capture.Header().Get("Content-Type"), selection.Part) { - if record, found := h.mediaInfoForDecision(r, selection); found { - enriched, changed, err := plexmeta.EnrichDecision(body, capture.Header().Get("Content-Type"), selection.Part, record.Media) - if err != nil { - h.logger.Warn("decision_mediainfo_fail_open", "part", selection.Part.ID, "error_kind", errorKind(err)) - } else if changed { - if h.veto != nil && record.Fresh(time.Now().UTC()) { - if reason, reject := h.veto(r, record.Media); reject { - h.logger.Info("playback_veto", "part", selection.Part.ID, "reason", reason) - writeIncompatibleDecision(w, r) - return - } - } - if err := capture.replaceDecodedBody(enriched); err != nil { + needsEnrichment, err := plexmeta.DecisionNeedsEnrichment(body, capture.Header().Get("Content-Type"), selection.Part) + if err != nil { + h.logger.Warn("decision_mediainfo_fail_open", "part", selection.Part.ID, "error_kind", errorKind(err)) + } else if needsEnrichment { + if record, found := h.mediaInfoForDecision(r, selection); found { + enriched, changed, err := plexmeta.EnrichDecision(body, capture.Header().Get("Content-Type"), selection.Part, record.Media) + if err != nil { h.logger.Warn("decision_mediainfo_fail_open", "part", selection.Part.ID, "error_kind", errorKind(err)) - } else if h.metrics != nil { - h.metrics.IncMediaInfoEnriched() + } else if changed { + if h.veto != nil && record.Fresh(time.Now().UTC()) { + if reason, reject := h.veto(r, record.Media); reject { + h.logger.Info("playback_veto", "part", selection.Part.ID, "reason", reason) + writeIncompatibleDecision(w, r) + return + } + } + if err := capture.replaceDecodedBody(enriched); err != nil { + h.logger.Warn("decision_mediainfo_fail_open", "part", selection.Part.ID, "error_kind", errorKind(err)) + } else if h.metrics != nil { + h.metrics.IncMediaInfoEnriched() + } } } } diff --git a/internal/gateway/decision_test.go b/internal/gateway/decision_test.go index 5eb5c23..da50357 100644 --- a/internal/gateway/decision_test.go +++ b/internal/gateway/decision_test.go @@ -115,6 +115,26 @@ func TestCloudDecisionMediaInfoTimeoutFailsOpen(t *testing.T) { } } +func TestCloudDecisionPassesThroughPlexMaterializedMediaInfoWithoutCacheIO(t *testing.T) { + mediaInfo := &decisionMediaInfoStub{} + handler := newDecisionProjectionHandler(t, mediaInfo, 100*time.Millisecond, nil).(*decisionHandler) + body := []byte(``) + handler.plex = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/xml") + w.Header().Set("ETag", `"plex-native"`) + _, _ = w.Write(body) + }) + response := httptest.NewRecorder() + handler.ServeHTTP(response, decisionProjectionRequest()) + + if response.Code != http.StatusOK || !bytes.Equal(response.Body.Bytes(), body) || response.Header().Get("ETag") != `"plex-native"` { + t.Fatalf("status=%d headers=%#v body=%s", response.Code, response.Header(), response.Body.Bytes()) + } + if mediaInfo.memoryCalls != 0 || mediaInfo.ensureCalls != 0 { + t.Fatalf("materialized decision touched MediaInfo: memory=%d ensure=%d", mediaInfo.memoryCalls, mediaInfo.ensureCalls) + } +} + func TestWriteIncompatibleDecisionClearsRepresentationHeadersAndHonorsHEAD(t *testing.T) { for _, test := range []struct { name string diff --git a/internal/gateway/metadata_enrichment_test.go b/internal/gateway/metadata_enrichment_test.go index bacabc6..4f19212 100644 --- a/internal/gateway/metadata_enrichment_test.go +++ b/internal/gateway/metadata_enrichment_test.go @@ -603,7 +603,7 @@ func TestMetadataEnrichmentSkipsLocalAndCompleteCloudParts(t *testing.T) { } } -func TestMetadataEnrichmentProjectsIncompleteItemStreamsWithCompleteMediaAndPart(t *testing.T) { +func TestMetadataEnrichmentProjectsGeneratedItemStreamsWithCompleteMediaAndPart(t *testing.T) { fixture := newMetadataEnrichmentFixture(t) tests := []struct { name string @@ -614,13 +614,13 @@ func TestMetadataEnrichmentProjectsIncompleteItemStreamsWithCompleteMediaAndPart { name: "XML", contentType: "application/xml", - body: []byte(``), + body: []byte(``), want: []byte(`codec="hevc"`), }, { name: "JSON", contentType: "application/json", - body: []byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"container":"mkv","duration":60000,"bitrate":8000,"width":3840,"height":2160,"aspectRatio":1.78,"audioChannels":6,"audioCodec":"aac","videoCodec":"hevc","videoResolution":"4k","videoFrameRate":"24p","videoProfile":"main 10","audioProfile":"lc","Part":[{"id":9,"file":"/media/cloud/episode.strm","duration":60000,"size":2000000,"container":"mkv","videoProfile":"main 10","audioProfile":"lc","Stream":[{"id":101,"streamType":1,"index":0}]}]}]}]}}`), + body: []byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"container":"mkv","duration":60000,"bitrate":8000,"width":3840,"height":2160,"aspectRatio":1.78,"audioChannels":6,"audioCodec":"aac","videoCodec":"hevc","videoResolution":"4k","videoFrameRate":"24p","videoProfile":"main 10","audioProfile":"lc","Part":[{"id":9,"file":"/media/cloud/episode.strm","duration":60000,"size":2000000,"container":"mkv","videoProfile":"main 10","audioProfile":"lc","Stream":[{"streamType":1,"index":0}]}]}]}]}}`), want: []byte(`"codec":"hevc"`), }, } @@ -642,6 +642,40 @@ func TestMetadataEnrichmentProjectsIncompleteItemStreamsWithCompleteMediaAndPart } } +func TestMetadataEnrichmentTrustsPlexMaterializedStreamsWithOptionalFieldsAbsent(t *testing.T) { + fixture := newMetadataEnrichmentFixture(t) + ensurer := &fakeMediaInfoEnsurer{} + tests := []struct { + name string + contentType string + body []byte + }{ + { + name: "XML", + contentType: "application/xml", + body: []byte(``), + }, + { + name: "JSON", + contentType: "application/json", + body: []byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"container":"mp4","duration":1414016,"bitrate":3730,"width":3840,"height":2160,"aspectRatio":1.78,"audioChannels":2,"audioCodec":"aac","videoCodec":"hevc","videoResolution":"4k","videoFrameRate":"PAL","videoProfile":"main","audioProfile":"lc","Part":[{"id":9,"file":"/media/cloud/episode.strm","duration":1414016,"size":659294933,"container":"mp4","videoProfile":"main","audioProfile":"lc","Stream":[{"id":101,"streamType":1,"index":0,"codec":"hevc","profile":"main","level":150,"bitrate":3598,"width":3840,"height":2160,"frameRate":25.0,"bitDepth":8,"default":true,"streamIdentifier":"1"},{"id":102,"streamType":2,"index":1,"codec":"aac","profile":"lc","bitrate":125,"channels":2,"default":true,"streamIdentifier":"2"}]}]}]}]}}`), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + handler := fixture.handler(ensurer, metrics.New(), metadataBodyHandler(test.body, test.contentType)) + response := httptest.NewRecorder() + handler.ServeHTTP(response, authenticatedMetadataRequest(http.MethodGet)) + if response.Code != http.StatusOK || !bytes.Equal(response.Body.Bytes(), test.body) || response.Header().Get("ETag") != `"plex-etag"` { + t.Fatalf("response changed: status=%d headers=%#v body=%s", response.Code, response.Header(), response.Body.Bytes()) + } + }) + } + if ensurer.memoryGets.Load() != 0 || ensurer.gets.Load() != 0 || len(ensurer.offers()) != 0 { + t.Fatalf("native streams touched MediaInfo cache: memory gets=%d store gets=%d offers=%d", ensurer.memoryGets.Load(), ensurer.gets.Load(), len(ensurer.offers())) + } +} + func TestMetadataEnrichmentCollectionIgnoresIncompleteNativeStreams(t *testing.T) { fixture := newMetadataEnrichmentFixture(t) ensurer := &fakeMediaInfoEnsurer{} diff --git a/internal/plexmeta/decision_projection.go b/internal/plexmeta/decision_projection.go index 085e499..d1f51b1 100644 --- a/internal/plexmeta/decision_projection.go +++ b/internal/plexmeta/decision_projection.go @@ -10,44 +10,50 @@ import ( "github.com/InfinityPacer/plex-gateway/internal/mediainfo" ) +// DecisionNeedsEnrichment reports whether the matching Direct Play Part lacks +// the same MediaInfo authority required by item metadata projection. The +// decision remains untouched when Plex returns materialized Stream rows and a +// complete Media/Part shape. +func DecisionNeedsEnrichment(body []byte, contentType string, expected Part) (bool, error) { + _, selection, err := selectDecisionProjection(body, contentType, expected) + if err != nil { + return false, err + } + target, err := enrichmentTarget(selection) + if err != nil { + return false, err + } + return target.NeedsEnrichment, nil +} + // EnrichDecision fills descriptive Media, Part, and Stream fields for the one // Direct Play Part already selected by Plex. It preserves every Plex-owned // decision, identifier, session, and unknown field. A missing, ambiguous, or // non-Direct-Play Part is rejected without modifying the input bytes. func EnrichDecision(body []byte, contentType string, expected Part, media mediainfo.Media) ([]byte, bool, error) { - if expected.ID == "" && expected.Key == "" { - return nil, false, errors.New("expected Plex Part identity is empty") + document, selection, err := selectDecisionProjection(body, contentType, expected) + if err != nil { + return nil, false, err } - document, err := parseProjectionDocument(body, contentType) + target, err := enrichmentTarget(selection) if err != nil { return nil, false, err } + if !target.NeedsEnrichment { + return append([]byte(nil), body...), false, nil + } - var selection projectionSelection var sizeChanged bool - switch typed := document.(type) { - case *xmlProjectionDocument: - xmlSelection, err := typed.selectDecisionPart(expected) - if err != nil { - return nil, false, err - } - selection = xmlSelection - sizeChanged, err = replaceDecisionPartSizeXML(xmlSelection.part, media.Size) - if err != nil { - return nil, false, err - } - case *jsonProjectionDocument: - jsonSelection, err := typed.selectDecisionPart(expected) - if err != nil { - return nil, false, err - } - selection = jsonSelection - sizeChanged, err = replaceDecisionPartSizeJSON(*jsonSelection.part, media.Size) - if err != nil { - return nil, false, err - } + switch typed := selection.(type) { + case *xmlProjectionSelection: + sizeChanged, err = replaceDecisionPartSizeXML(typed.part, media.Size) + case *jsonProjectionSelection: + sizeChanged, err = replaceDecisionPartSizeJSON(*typed.part, media.Size) default: - return nil, false, errors.New("unsupported Plex decision document") + return nil, false, errors.New("unsupported Plex decision selection") + } + if err != nil { + return nil, false, err } changed, err := selection.enrich(media, true) @@ -71,6 +77,35 @@ func EnrichDecision(body []byte, contentType string, expected Part, media mediai return result, true, nil } +func selectDecisionProjection(body []byte, contentType string, expected Part) (projectionDocument, projectionSelection, error) { + if expected.ID == "" && expected.Key == "" { + return nil, nil, errors.New("expected Plex Part identity is empty") + } + document, err := parseProjectionDocument(body, contentType) + if err != nil { + return nil, nil, err + } + + var selection projectionSelection + switch typed := document.(type) { + case *xmlProjectionDocument: + xmlSelection, err := typed.selectDecisionPart(expected) + if err != nil { + return nil, nil, err + } + selection = xmlSelection + case *jsonProjectionDocument: + jsonSelection, err := typed.selectDecisionPart(expected) + if err != nil { + return nil, nil, err + } + selection = jsonSelection + default: + return nil, nil, errors.New("unsupported Plex decision document") + } + return document, selection, nil +} + func (document *xmlProjectionDocument) selectDecisionPart(expected Part) (*xmlProjectionSelection, error) { roots := xmlProjectionElements(document.nodes) if len(roots) != 1 || roots[0].start.Name.Local != "MediaContainer" { @@ -98,8 +133,12 @@ func (document *xmlProjectionDocument) selectDecisionPart(expected Part) (*xmlPr if selected != nil { return nil, errors.New("Plex XML decision has an ambiguous Direct Play Part") } + needs, err := xmlMediaPartNeedsEnrichment(media, part) + if err != nil { + return nil, err + } selected = &xmlProjectionSelection{ - value: Target{Part: expected, NeedsEnrichment: true}, + value: Target{Part: expected, NeedsEnrichment: needs}, media: media, part: part, } @@ -155,8 +194,12 @@ func (document *jsonProjectionDocument) selectDecisionPart(expected Part) (*json if selected != nil { return nil, errors.New("Plex JSON decision has an ambiguous Direct Play Part") } + needs, err := jsonMediaPartNeedsEnrichment(mediaItem, part) + if err != nil { + return nil, err + } selected = &jsonProjectionSelection{ - value: Target{Part: expected, NeedsEnrichment: true}, + value: Target{Part: expected, NeedsEnrichment: needs}, document: document, container: container, metadata: metadata, diff --git a/internal/plexmeta/decision_projection_test.go b/internal/plexmeta/decision_projection_test.go index 52e5f42..254e36d 100644 --- a/internal/plexmeta/decision_projection_test.go +++ b/internal/plexmeta/decision_projection_test.go @@ -118,3 +118,54 @@ func TestEnrichDecisionRejectsNonMatchingOrAmbiguousPart(t *testing.T) { }) } } + +func TestDecisionNeedsEnrichmentTrustsMaterializedStreams(t *testing.T) { + tests := []struct { + name string + contentType string + body string + want bool + }{ + { + name: "XML materialized", + contentType: "application/xml", + body: ``, + }, + { + name: "JSON materialized", + contentType: "application/json", + body: `{"MediaContainer":{"Metadata":[{"Media":[{"container":"mp4","duration":1414016,"bitrate":3730,"width":3840,"height":2160,"aspectRatio":1.78,"audioChannels":2,"audioCodec":"aac","videoCodec":"hevc","videoResolution":"4k","videoFrameRate":"PAL","videoProfile":"main","audioProfile":"lc","decision":"directplay","Part":[{"id":9,"key":"/library/parts/9/1/file","file":"/media/cloud/episode.strm","duration":1414016,"size":659294933,"container":"mp4","videoProfile":"main","audioProfile":"lc","decision":"directplay","Stream":[{"id":101,"streamType":1,"index":0,"codec":"hevc"},{"id":102,"streamType":2,"index":1,"codec":"aac"}]}]}]}]}}`, + }, + { + name: "XML projected", + contentType: "application/xml", + body: ``, + want: true, + }, + { + name: "JSON projected", + contentType: "application/json", + body: `{"MediaContainer":{"Metadata":[{"Media":[{"container":"mp4","duration":1414016,"bitrate":3730,"width":3840,"height":2160,"aspectRatio":1.78,"audioChannels":2,"audioCodec":"aac","videoCodec":"hevc","videoResolution":"4k","videoFrameRate":"PAL","videoProfile":"main","audioProfile":"lc","decision":"directplay","Part":[{"id":9,"key":"/library/parts/9/1/file","file":"/media/cloud/episode.strm","duration":1414016,"size":659294933,"container":"mp4","videoProfile":"main","audioProfile":"lc","decision":"directplay","Stream":[{"streamType":1,"index":0,"codec":"hevc"}]}]}]}]}}`, + want: true, + }, + } + expected := Part{ID: "9", Key: "/library/parts/9/1/file", File: "/media/cloud/episode.strm"} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := DecisionNeedsEnrichment([]byte(test.body), test.contentType, expected) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("DecisionNeedsEnrichment=%v, want %v", got, test.want) + } + result, changed, err := EnrichDecision([]byte(test.body), test.contentType, expected, projectionTestMedia()) + if err != nil { + t.Fatal(err) + } + if !test.want && (changed || !bytes.Equal(result, []byte(test.body))) { + t.Fatalf("materialized decision changed=%v result=%s", changed, result) + } + }) + } +} diff --git a/internal/plexmeta/projection.go b/internal/plexmeta/projection.go index 671e6ca..725429d 100644 --- a/internal/plexmeta/projection.go +++ b/internal/plexmeta/projection.go @@ -34,6 +34,10 @@ func SelectEnrichmentTarget(body []byte, contentType, ratingKey string) (Target, if err != nil { return Target{}, err } + return enrichmentTarget(selection) +} + +func enrichmentTarget(selection projectionSelection) (Target, error) { target := selection.target() streamNeedsEnrichment, err := selection.streamsNeedEnrichment() if err != nil { @@ -1061,6 +1065,13 @@ func xmlStreamsNeedEnrichment(part *xmlProjectionElement) (bool, error) { if len(streams) == 0 { return true, nil } + plexMaterialized, err := xmlStreamsArePlexMaterialized(streams) + if err != nil { + return false, err + } + if plexMaterialized { + return false, nil + } for _, stream := range streams { key, present, err := xmlStreamIdentity(stream) if err != nil { @@ -1091,6 +1102,29 @@ func xmlStreamsNeedEnrichment(part *xmlProjectionElement) (bool, error) { return false, nil } +// xmlStreamsArePlexMaterialized recognizes Stream rows backed by the Plex data +// model. Positive database IDs are authority markers; optional descriptive +// fields such as language and title may legitimately be absent and must not +// trigger a second STRM probe. +func xmlStreamsArePlexMaterialized(streams []*xmlProjectionElement) (bool, error) { + for _, stream := range streams { + if _, present, err := xmlStreamIdentity(stream); err != nil { + return false, err + } else if !present { + return false, nil + } + id, present, err := xmlAttribute(stream.start.Attr, "id") + if err != nil { + return false, err + } + parsed, parseErr := strconv.ParseInt(strings.TrimSpace(id), 10, 64) + if !present || parseErr != nil || parsed <= 0 { + return false, nil + } + } + return true, nil +} + func xmlSTRMPartSizeNeedsCorrection(part *xmlProjectionElement) (bool, error) { file, present, err := xmlAttribute(part.start.Attr, "file") if err != nil || !present || !isSTRMPath(file) { @@ -1820,6 +1854,9 @@ func jsonStreamsNeedEnrichment(part *jsonObject) (bool, error) { if _, err := indexJSONStreams(streams.items); err != nil { return false, err } + if jsonStreamsArePlexMaterialized(streams.items) { + return false, nil + } for _, stream := range streams.items { key, present, err := jsonStreamIdentity(*stream) if err != nil { @@ -1840,6 +1877,27 @@ func jsonStreamsNeedEnrichment(part *jsonObject) (bool, error) { return false, nil } +// jsonStreamsArePlexMaterialized is the JSON counterpart of +// xmlStreamsArePlexMaterialized. Generated Gateway Stream projections +// deliberately omit Plex IDs, so they remain eligible for later cache-backed +// enrichment. +func jsonStreamsArePlexMaterialized(streams []*jsonObject) bool { + for _, stream := range streams { + if _, present, err := jsonStreamIdentity(*stream); err != nil || !present { + return false + } + rawID, present := (*stream)["id"] + if !present { + return false + } + id, valid := jsonRawInt64(rawID) + if !valid || id <= 0 { + return false + } + } + return true +} + func jsonSTRMPartSizeNeedsCorrection(part jsonObject) bool { file, err := requiredJSONString(part, "file") if err != nil || !isSTRMPath(file) { diff --git a/internal/plexmeta/projection_test.go b/internal/plexmeta/projection_test.go index be6e6b1..1d7c1aa 100644 --- a/internal/plexmeta/projection_test.go +++ b/internal/plexmeta/projection_test.go @@ -694,6 +694,61 @@ func TestEnrichMetadataNoOpReturnsOriginalBytes(t *testing.T) { } } +func TestSelectEnrichmentTargetDistinguishesMaterializedAndProjectedStreams(t *testing.T) { + tests := []struct { + name string + contentType string + body string + want bool + }{ + { + name: "XML materialized streams allow optional omissions", + contentType: "application/xml", + body: ``, + }, + { + name: "JSON materialized streams allow optional omissions", + contentType: "application/json", + body: `{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"container":"mp4","duration":1414016,"bitrate":3730,"width":3840,"height":2160,"aspectRatio":1.78,"audioChannels":2,"audioCodec":"aac","videoCodec":"hevc","videoResolution":"4k","videoFrameRate":"PAL","videoProfile":"main","audioProfile":"lc","Part":[{"id":9,"file":"/media/cloud/episode.strm","duration":1414016,"size":659294933,"container":"mp4","videoProfile":"main","audioProfile":"lc","Stream":[{"id":101,"streamType":1,"index":0,"codec":"hevc"},{"id":102,"streamType":2,"index":1,"codec":"aac"}]}]}]}]}}`, + }, + { + name: "XML projected stream remains eligible", + contentType: "application/xml", + body: ``, + want: true, + }, + { + name: "JSON projected stream remains eligible", + contentType: "application/json", + body: `{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"container":"mp4","duration":1414016,"bitrate":3730,"width":3840,"height":2160,"aspectRatio":1.78,"audioChannels":2,"audioCodec":"aac","videoCodec":"hevc","videoResolution":"4k","videoFrameRate":"PAL","videoProfile":"main","audioProfile":"lc","Part":[{"id":9,"file":"/media/cloud/episode.strm","duration":1414016,"size":659294933,"container":"mp4","videoProfile":"main","audioProfile":"lc","Stream":[{"streamType":1,"index":0,"codec":"hevc"}]}]}]}]}}`, + want: true, + }, + { + name: "XML materialized stream does not hide missing Media core", + contentType: "application/xml", + body: ``, + want: true, + }, + { + name: "JSON materialized stream does not hide missing Media core", + contentType: "application/json", + body: `{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"container":"mp4","duration":1414016,"bitrate":3730,"width":3840,"height":2160,"aspectRatio":1.78,"audioChannels":2,"audioCodec":"aac","videoResolution":"4k","videoFrameRate":"PAL","videoProfile":"main","audioProfile":"lc","Part":[{"id":9,"file":"/media/cloud/episode.strm","duration":1414016,"size":659294933,"container":"mp4","videoProfile":"main","audioProfile":"lc","Stream":[{"id":101,"streamType":1,"index":0,"codec":"hevc"}]}]}]}]}}`, + want: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + target, err := SelectEnrichmentTarget([]byte(test.body), test.contentType, "42") + if err != nil { + t.Fatal(err) + } + if target.NeedsEnrichment != test.want { + t.Fatalf("NeedsEnrichment=%v, want %v", target.NeedsEnrichment, test.want) + } + }) + } +} + func TestEnrichMetadataRejectsAmbiguousStructureWithoutInputMutation(t *testing.T) { tests := []struct { name string From ecc597dd3d0ed18c9802ece465df0442d0db4c86 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Sun, 30 Aug 2026 20:21:29 +0800 Subject: [PATCH 2/4] fix: match Plex technical labels --- internal/gateway/metadata_enrichment_test.go | 4 +- internal/plexmeta/decision_projection.go | 2 +- internal/plexmeta/decision_projection_test.go | 9 +- internal/plexmeta/projection.go | 95 ++++++++++++++-- internal/plexmeta/projection_test.go | 105 ++++++++++++++++-- 5 files changed, 188 insertions(+), 27 deletions(-) diff --git a/internal/gateway/metadata_enrichment_test.go b/internal/gateway/metadata_enrichment_test.go index 4f19212..f67ba23 100644 --- a/internal/gateway/metadata_enrichment_test.go +++ b/internal/gateway/metadata_enrichment_test.go @@ -534,12 +534,12 @@ func TestMetadataEnrichmentAdmitsExistingStreamHDRAndSTRMControlSize(t *testing. { name: "XML", contentType: "application/xml", body: []byte(``), - want: [][]byte{[]byte(`size="987654321"`), []byte(`displayTitle="4K DoVi/HDR10"`), []byte(`DOVIPresent="1"`)}, + want: [][]byte{[]byte(`size="987654321"`), []byte(`displayTitle="4K DoVi/HDR10 (HEVC Main 10)"`), []byte(`DOVIPresent="1"`)}, }, { name: "JSON", contentType: "application/json", body: []byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"container":"mkv","duration":60000,"bitrate":8000,"width":3840,"height":2160,"aspectRatio":"16:9","audioChannels":6,"audioCodec":"aac","videoCodec":"hevc","videoResolution":"4k","videoFrameRate":"23.976","videoProfile":"Main 10","audioProfile":"LC","Part":[{"id":9,"file":"/media/cloud/episode.strm","duration":60000,"size":301,"container":"mkv","videoProfile":"Main 10","audioProfile":"LC","Stream":[{"id":101,"streamType":1,"index":0,"codec":"hevc","profile":"Main 10","level":153,"bitrate":8000,"languageCode":"eng","title":"Video","width":3840,"height":2160,"frameRate":23.976,"refFrames":4,"pixelFormat":"yuv420p10le","bitDepth":10,"colorSpace":"bt2020nc","colorRange":"tv","colorPrimaries":"bt2020","colorTrc":"smpte2084","chromaLocation":"left","sampleAspectRatio":"1:1","displayAspectRatio":"16:9"}]}]}]}]}}`), - want: [][]byte{[]byte(`"size":987654321`), []byte(`"displayTitle":"4K DoVi/HDR10"`), []byte(`"DOVIPresent":true`)}, + want: [][]byte{[]byte(`"size":987654321`), []byte(`"displayTitle":"4K DoVi/HDR10 (HEVC Main 10)"`), []byte(`"DOVIPresent":true`)}, }, } for _, test := range tests { diff --git a/internal/plexmeta/decision_projection.go b/internal/plexmeta/decision_projection.go index d1f51b1..d2a77e0 100644 --- a/internal/plexmeta/decision_projection.go +++ b/internal/plexmeta/decision_projection.go @@ -56,7 +56,7 @@ func EnrichDecision(body []byte, contentType string, expected Part, media mediai return nil, false, err } - changed, err := selection.enrich(media, true) + changed, err := selection.enrich(media, true, streamProjectionDecision) if err != nil { return nil, false, err } diff --git a/internal/plexmeta/decision_projection_test.go b/internal/plexmeta/decision_projection_test.go index 254e36d..b1e5ef7 100644 --- a/internal/plexmeta/decision_projection_test.go +++ b/internal/plexmeta/decision_projection_test.go @@ -26,7 +26,9 @@ func TestEnrichDecisionProjectsMatchingDirectPlayPart(t *testing.T) { if attributes["Part"]["decision"] != "directplay" || attributes["Part"]["size"] != "987654321" || attributes["Part"]["container"] != "mkv" { t.Fatalf("Part attributes = %#v", attributes["Part"]) } - if attributes["Stream#1"]["codec"] != "hevc" || attributes["Stream#1"]["bitDepth"] != "10" || attributes["Stream#2"]["codec"] != "aac" { + if attributes["Stream#1"]["codec"] != "hevc" || attributes["Stream#1"]["bitDepth"] != "10" || + attributes["Stream#1"]["displayTitle"] != "4K" || + attributes["Stream#2"]["codec"] != "aac" || attributes["Stream#2"]["displayTitle"] != "English (AAC 5.1)" { t.Fatalf("Stream attributes = %#v", attributes) } for _, fragment := range [][]byte{[]byte(`decisionExtra="preserve"`), []byte(`videoExtra="preserve"`), []byte(`mediaExtra="preserve"`), []byte(`partExtra="preserve"`)} { @@ -56,7 +58,10 @@ func TestEnrichDecisionProjectsMatchingDirectPlayPart(t *testing.T) { if part["decision"] != "directplay" || part["size"] != float64(987654321) || part["container"] != "mkv" { t.Fatalf("Part fields = %#v", part) } - if len(streams) != 2 || streams[0].(map[string]any)["codec"] != "hevc" || streams[1].(map[string]any)["codec"] != "aac" { + if len(streams) != 2 || streams[0].(map[string]any)["codec"] != "hevc" || + streams[0].(map[string]any)["displayTitle"] != "4K" || + streams[1].(map[string]any)["codec"] != "aac" || + streams[1].(map[string]any)["displayTitle"] != "English (AAC 5.1)" { t.Fatalf("Stream fields = %#v", streams) } if container["decisionExtra"] != "preserve" || item["videoExtra"] != "preserve" || mediaObject["mediaExtra"] != "preserve" || part["partExtra"] != "preserve" { diff --git a/internal/plexmeta/projection.go b/internal/plexmeta/projection.go index 725429d..790cf49 100644 --- a/internal/plexmeta/projection.go +++ b/internal/plexmeta/projection.go @@ -68,7 +68,7 @@ func EnrichMetadata(body []byte, contentType, ratingKey string, media mediainfo. if err != nil { return nil, false, err } - changed, err := selection.enrich(media, true) + changed, err := selection.enrich(media, true, streamProjectionMetadata) if err != nil { return nil, false, err } @@ -120,7 +120,7 @@ func EnrichMetadataTargets(body []byte, contentType string, records map[string]m if !ok { continue } - changed, err := selection.enrich(media, false) + changed, err := selection.enrich(media, false, streamProjectionMetadata) if err != nil { return nil, 0, err } @@ -147,9 +147,20 @@ type projectionDocument interface { type projectionSelection interface { target() Target streamsNeedEnrichment() (bool, error) - enrich(mediainfo.Media, bool) (bool, error) + enrich(mediainfo.Media, bool, streamProjectionContext) (bool, error) } +type streamProjectionContext uint8 + +// Plex item metadata exposes codec details in displayTitle, while playback +// decisions keep displayTitle concise and put codec details in the extended +// title. Preserving both wire shapes avoids turning presentation fields into +// client-specific policy. +const ( + streamProjectionMetadata streamProjectionContext = iota + streamProjectionDecision +) + func parseProjection(body []byte, contentType, ratingKey string) (projectionDocument, projectionSelection, error) { ratingKey, err := normalizeProjectionRatingKey(ratingKey) if err != nil { @@ -308,7 +319,7 @@ func partProjectionAttributes(media mediainfo.Media) []projectionAttribute { return attributes } -func streamProjectionAttributes(stream mediainfo.Stream) []projectionAttribute { +func streamProjectionAttributes(stream mediainfo.Stream, context streamProjectionContext) []projectionAttribute { attributes := make([]projectionAttribute, 0, 35) if stream.Codec != "" { attributes = append(attributes, textProjectionAttribute("codec", plexCodec(stream.Codec))) @@ -380,13 +391,24 @@ func streamProjectionAttributes(stream mediainfo.Stream) []projectionAttribute { if stream.Title != "" { attributes = append(attributes, textProjectionAttribute("title", stream.Title)) } - if canonicalStreamType(stream.Type) == "1" { + switch canonicalStreamType(stream.Type) { + case "1": if displayTitle, extendedDisplayTitle := videoDisplayTitles(stream); displayTitle != "" { + if context == streamProjectionMetadata && extendedDisplayTitle != "" { + displayTitle = extendedDisplayTitle + } attributes = append(attributes, textProjectionAttribute("displayTitle", displayTitle)) if extendedDisplayTitle != "" { attributes = append(attributes, textProjectionAttribute("extendedDisplayTitle", extendedDisplayTitle)) } } + case "2": + if displayTitle := audioDisplayTitle(stream); displayTitle != "" { + attributes = append(attributes, + textProjectionAttribute("displayTitle", displayTitle), + textProjectionAttribute("extendedDisplayTitle", displayTitle), + ) + } } if stream.DolbyVision != nil && canonicalStreamType(stream.Type) == "1" { attributes = append(attributes, dolbyVisionProjectionAttributes(stream.DolbyVision)...) @@ -458,6 +480,9 @@ func plexCodec(value string) string { func plexLanguageCode(value string) (string, bool) { value = strings.TrimSpace(value) + if strings.EqualFold(value, "und") { + return "", false + } if len(value) != 2 && len(value) != 3 { return "", false } @@ -495,6 +520,50 @@ func videoDisplayTitles(stream mediainfo.Stream) (string, string) { return displayTitle, displayTitle + " (" + codecAndProfile + ")" } +func audioDisplayTitle(stream mediainfo.Stream) string { + codec := plexDisplayCodec(stream.Codec) + if codec == "" { + return "" + } + name := strings.TrimSpace(stream.Title) + if name == "" { + if code, ok := plexLanguageCode(stream.Language); ok { + name = strings.ToUpper(code) + } else { + name = "Unknown" + } + } + channel := plexAudioChannelTitle(stream.Channels, stream.ChannelLayout) + detail := strings.TrimSpace(strings.Join([]string{codec, channel}, " ")) + if detail == "" { + return name + } + return name + " (" + detail + ")" +} + +func plexAudioChannelTitle(channels int, layout string) string { + switch strings.ToLower(strings.TrimSpace(layout)) { + case "mono": + return "Mono" + case "stereo": + return "Stereo" + } + switch channels { + case 1: + return "Mono" + case 2: + return "Stereo" + case 6: + return "5.1" + case 8: + return "7.1" + case 0: + return "" + default: + return strconv.Itoa(channels) + " ch" + } +} + func dolbyVisionHDR10Compatible(compatibilityID int) bool { // Plex labels the two observed HDR10-compatible signal identifiers as a // combined DoVi/HDR10 presentation. Other identifiers may describe SDR or @@ -652,7 +721,9 @@ var dolbyVisionProjectionAttributeNames = []string{ "DOVIPresent", "DOVIProfile", "DOVIRPUPresent", "DOVIVersion", } -var audioStreamProjectionAttributeNames = []string{"samplingRate", "channels", "audioChannelLayout"} +var audioStreamProjectionAttributeNames = []string{ + "samplingRate", "channels", "audioChannelLayout", "displayTitle", "extendedDisplayTitle", +} type streamKey struct { typeName string @@ -664,7 +735,7 @@ type streamProjection struct { attributes []projectionAttribute } -func buildStreamProjections(media mediainfo.Media) ([]streamProjection, error) { +func buildStreamProjections(media mediainfo.Media, context streamProjectionContext) ([]streamProjection, error) { projections := make([]streamProjection, 0, len(media.Streams)) seen := make(map[streamKey]struct{}, len(media.Streams)) for _, stream := range media.Streams { @@ -681,7 +752,7 @@ func buildStreamProjections(media mediainfo.Media) ([]streamProjection, error) { seen[key] = struct{}{} projections = append(projections, streamProjection{ key: key, - attributes: streamProjectionAttributes(stream), + attributes: streamProjectionAttributes(stream, context), }) } return projections, nil @@ -920,7 +991,7 @@ func (selection *xmlProjectionSelection) streamsNeedEnrichment() (bool, error) { return xmlStreamsNeedEnrichment(selection.part) } -func (selection *xmlProjectionSelection) enrich(media mediainfo.Media, projectStreams bool) (bool, error) { +func (selection *xmlProjectionSelection) enrich(media mediainfo.Media, projectStreams bool, context streamProjectionContext) (bool, error) { changed := addMissingXMLAttributes(selection.media, mediaProjectionAttributes(media)) changed = addMissingXMLAttributes(selection.part, partProjectionAttributes(media)) || changed changed = replaceSTRMPartSizeXML(selection.part, media.Size) || changed @@ -928,7 +999,7 @@ func (selection *xmlProjectionSelection) enrich(media mediainfo.Media, projectSt return changed, nil } streamElements := directXMLElements(selection.part, "Stream") - streamProjections, err := buildStreamProjections(media) + streamProjections, err := buildStreamProjections(media, context) if err != nil { return false, err } @@ -1583,7 +1654,7 @@ func (selection *jsonProjectionSelection) streamsNeedEnrichment() (bool, error) return jsonStreamsNeedEnrichment(selection.part) } -func (selection *jsonProjectionSelection) enrich(media mediainfo.Media, projectStreams bool) (bool, error) { +func (selection *jsonProjectionSelection) enrich(media mediainfo.Media, projectStreams bool, context streamProjectionContext) (bool, error) { changed, err := addMissingJSONAttributes(*selection.mediaItem, mediaProjectionAttributes(media)) if err != nil { return false, err @@ -1607,7 +1678,7 @@ func (selection *jsonProjectionSelection) enrich(media mediainfo.Media, projectS return changed, nil } streamRaw, exists := (*selection.part)["Stream"] - streamProjections, err := buildStreamProjections(media) + streamProjections, err := buildStreamProjections(media, context) if err != nil { return false, err } diff --git a/internal/plexmeta/projection_test.go b/internal/plexmeta/projection_test.go index 1d7c1aa..222981d 100644 --- a/internal/plexmeta/projection_test.go +++ b/internal/plexmeta/projection_test.go @@ -373,10 +373,10 @@ func TestEnrichMetadataCreatesDescriptiveStreamsWithoutPlexIdentity(t *testing.T attrs["Part"]["size"] != "987654321" || attrs["Part"]["videoProfile"] != "main 10" { t.Fatalf("projected XML units = media=%#v part=%#v", attrs["Media"], attrs["Part"]) } - if video := attrs["Stream#1"]; video["streamType"] != "1" || video["index"] != "0" || video["codec"] != "hevc" || video["width"] != "3840" { + if video := attrs["Stream#1"]; video["streamType"] != "1" || video["index"] != "0" || video["codec"] != "hevc" || video["width"] != "3840" || video["displayTitle"] != "4K (HEVC Main 10)" { t.Fatalf("generated XML video Stream = %#v", video) } - if audio := attrs["Stream#2"]; audio["streamType"] != "2" || audio["index"] != "1" || audio["codec"] != "aac" || audio["channels"] != "6" { + if audio := attrs["Stream#2"]; audio["streamType"] != "2" || audio["index"] != "1" || audio["codec"] != "aac" || audio["channels"] != "6" || audio["displayTitle"] != "English (AAC 5.1)" { t.Fatalf("generated XML audio Stream = %#v", audio) } if subtitle := attrs["Stream#3"]; subtitle["streamType"] != "3" || subtitle["index"] != "2" || subtitle["codec"] != "pgs" || subtitle["languageCode"] != "zho" || subtitle["forced"] != "1" { @@ -409,10 +409,10 @@ func TestEnrichMetadataCreatesDescriptiveStreamsWithoutPlexIdentity(t *testing.T video := streams[0].(map[string]any) audio := streams[1].(map[string]any) subtitle := streams[2].(map[string]any) - if video["streamType"] != float64(1) || video["index"] != float64(0) || video["codec"] != "hevc" || video["width"] != float64(3840) { + if video["streamType"] != float64(1) || video["index"] != float64(0) || video["codec"] != "hevc" || video["width"] != float64(3840) || video["displayTitle"] != "4K (HEVC Main 10)" { t.Fatalf("generated JSON video Stream = %#v", video) } - if audio["streamType"] != float64(2) || audio["index"] != float64(1) || audio["codec"] != "aac" || audio["channels"] != float64(6) { + if audio["streamType"] != float64(2) || audio["index"] != float64(1) || audio["codec"] != "aac" || audio["channels"] != float64(6) || audio["displayTitle"] != "English (AAC 5.1)" { t.Fatalf("generated JSON audio Stream = %#v", audio) } if subtitle["streamType"] != float64(3) || subtitle["index"] != float64(2) || subtitle["codec"] != "pgs" || subtitle["languageCode"] != "zho" || subtitle["forced"] != true { @@ -473,7 +473,7 @@ func TestEnrichMetadataUsesDolbyVisionPlexFields(t *testing.T) { } part := document["MediaContainer"].(map[string]any)["Metadata"].([]any)[0].(map[string]any)["Media"].([]any)[0].(map[string]any)["Part"].([]any)[0].(map[string]any) attrs := part["Stream"].([]any)[0].(map[string]any) - if attrs["codecID"] != "dvh1" || attrs["displayTitle"] != "4K DoVi/HDR10" || attrs["extendedDisplayTitle"] != "4K DoVi/HDR10 (HEVC Main 10)" || + if attrs["codecID"] != "dvh1" || attrs["displayTitle"] != "4K DoVi/HDR10 (HEVC Main 10)" || attrs["extendedDisplayTitle"] != "4K DoVi/HDR10 (HEVC Main 10)" || attrs["DOVIBLCompatID"] != float64(6) || attrs["DOVIBLPresent"] != true || attrs["DOVIELPresent"] != true || attrs["DOVILevel"] != float64(6) || attrs["DOVIPresent"] != true || attrs["DOVIProfile"] != float64(7) || @@ -558,7 +558,7 @@ func TestEnrichMetadataProjectsPlexMediaFrameRateAndPreciseStreamRate(t *testing func assertDolbyVisionAttributes(t *testing.T, attrs map[string]string) { t.Helper() - if attrs["displayTitle"] != "4K DoVi/HDR10" || attrs["extendedDisplayTitle"] != "4K DoVi/HDR10 (HEVC Main 10)" || + if attrs["displayTitle"] != "4K DoVi/HDR10 (HEVC Main 10)" || attrs["extendedDisplayTitle"] != "4K DoVi/HDR10 (HEVC Main 10)" || attrs["DOVIBLCompatID"] != "6" || attrs["DOVIBLPresent"] != "1" || attrs["DOVIELPresent"] != "1" || attrs["DOVILevel"] != "6" || attrs["DOVIPresent"] != "1" || attrs["DOVIProfile"] != "7" || attrs["DOVIRPUPresent"] != "1" || attrs["DOVIVersion"] != "1.0" { @@ -580,6 +580,91 @@ func TestDolbyVisionDisplayTitleDoesNotAssumeHDR10FromBaseLayer(t *testing.T) { } } +func TestAudioProjectionUsesPlexUnknownTitleWithoutUndefinedLanguage(t *testing.T) { + stream := mediainfo.Stream{ + Index: 1, Type: "audio", Codec: "aac", Profile: "LC", + Channels: 2, ChannelLayout: "stereo", Language: "und", + } + attributes := streamProjectionAttributes(stream, streamProjectionMetadata) + projected := make(map[string]string, len(attributes)) + for _, attribute := range attributes { + projected[attribute.name] = attribute.value + } + if projected["displayTitle"] != "Unknown (AAC Stereo)" || + projected["extendedDisplayTitle"] != "Unknown (AAC Stereo)" { + t.Fatalf("audio titles = %#v", projected) + } + if _, exists := projected["languageCode"]; exists { + t.Fatalf("undefined language was projected: %#v", projected) + } + for _, name := range []string{"id", "default", "selected", "streamIdentifier"} { + if _, exists := projected[name]; exists { + t.Fatalf("audio projection contains Plex-owned %s: %#v", name, projected) + } + } +} + +func TestEnrichMetadataPreservesNativeAudioTitlesAcrossMultipleTracks(t *testing.T) { + media := projectionTestMedia() + media.Streams = []mediainfo.Stream{ + {Index: 1, Type: "audio", Codec: "aac", Profile: "LC", Channels: 2, ChannelLayout: "stereo", Language: "und"}, + {Index: 2, Type: "audio", Codec: "eac3", Channels: 6, ChannelLayout: "5.1", Language: "zho", Title: "Chinese"}, + } + tests := []struct { + name string + contentType string + body []byte + assert func(*testing.T, []byte) + }{ + { + name: "XML", + contentType: "application/xml", + body: []byte(``), + assert: func(t *testing.T, result []byte) { + attributes := xmlProjectionAttributeSets(t, result) + if first := attributes["Stream#1"]; first["displayTitle"] != "Unknown (AAC Stereo)" || first["languageCode"] != "" { + t.Fatalf("undefined XML audio = %#v", first) + } + if second := attributes["Stream#2"]; second["displayTitle"] != "Plex Native" || second["extendedDisplayTitle"] != "Chinese (EAC3 5.1)" || second["languageCode"] != "zho" { + t.Fatalf("native XML audio = %#v", second) + } + }, + }, + { + name: "JSON", + contentType: "application/json", + body: []byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"Part":[{"id":7,"file":"/cloud/movie.strm","Stream":[{"streamType":2,"index":1},{"streamType":2,"index":2,"displayTitle":"Plex Native"}]}]}]}]}}`), + assert: func(t *testing.T, result []byte) { + var document map[string]any + if err := json.Unmarshal(result, &document); err != nil { + t.Fatal(err) + } + part := document["MediaContainer"].(map[string]any)["Metadata"].([]any)[0].(map[string]any)["Media"].([]any)[0].(map[string]any)["Part"].([]any)[0].(map[string]any) + streams := part["Stream"].([]any) + first := streams[0].(map[string]any) + second := streams[1].(map[string]any) + if first["displayTitle"] != "Unknown (AAC Stereo)" || first["languageCode"] != nil { + t.Fatalf("undefined JSON audio = %#v", first) + } + if second["displayTitle"] != "Plex Native" || second["extendedDisplayTitle"] != "Chinese (EAC3 5.1)" || second["languageCode"] != "zho" { + t.Fatalf("native JSON audio = %#v", second) + } + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, changed, err := EnrichMetadata(test.body, test.contentType, "42", media) + if err != nil || !changed { + t.Fatalf("EnrichMetadata changed=%v err=%v", changed, err) + } + test.assert(t, result) + }) + } +} + func TestEnrichMetadataDoesNotProjectUnknownCodecFieldsFromDolbyVisionProfile(t *testing.T) { media := mediainfo.Media{ Complete: true, Container: "mkv", DurationMS: 60000, @@ -601,7 +686,7 @@ func TestEnrichMetadataDoesNotProjectUnknownCodecFieldsFromDolbyVisionProfile(t t.Fatalf("EnrichMetadata changed=%v err=%v", changed, err) } attrs := xmlProjectionAttributeSets(t, result)["Stream#1"] - if attrs["DOVIProfile"] != "5" || attrs["DOVIPresent"] != "1" || attrs["displayTitle"] != "4K DoVi" { + if attrs["DOVIProfile"] != "5" || attrs["DOVIPresent"] != "1" || attrs["displayTitle"] != "4K DoVi (HEVC)" { t.Fatalf("Dolby Vision projection = %#v", attrs) } for _, name := range []string{"profile", "bitDepth", "pixelFormat", "colorSpace", "colorRange", "colorPrimaries", "colorTrc"} { @@ -660,7 +745,7 @@ func TestEnrichMetadataDoesNotCreateMissingStreamsWhenPlexHasOne(t *testing.T) { if _, exists := attrs["Stream#3"]; exists { t.Fatalf("missing streams were created: %#v", attrs) } - if attrs["Stream#1"]["codec"] != "existing" || attrs["Stream#1"]["displayTitle"] != "4K HDR10" { + if attrs["Stream#1"]["codec"] != "existing" || attrs["Stream#1"]["displayTitle"] != "4K HDR10 (HEVC Main 10)" { t.Fatalf("existing stream was not conservatively enriched: %#v", attrs["Stream#1"]) } } @@ -952,9 +1037,9 @@ func xmlProjectionAttributeSets(t *testing.T, body []byte) map[string]map[string } func completeProjectionXML() string { - return `` + return `` } func completeProjectionJSON() string { - return `{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"container":"mkv","duration":123000,"bitrate":12000,"width":3840,"height":2160,"aspectRatio":"16:9","audioChannels":6,"audioCodec":"aac","videoCodec":"hevc","videoResolution":"4k","videoFrameRate":"23.976","videoProfile":"Main 10","audioProfile":"LC","Part":[{"id":7,"key":"/library/parts/7/1/file","file":"/cloud/movie.strm","duration":123000,"size":987654321,"container":"mkv","videoProfile":"Main 10","audioProfile":"LC","Stream":[{"streamType":1,"index":0,"codec":"hevc","profile":"Main 10","level":153,"bitrate":12000,"width":3840,"height":2160,"frameRate":23.976,"refFrames":4,"pixelFormat":"yuv420p10le","bitDepth":10,"colorSpace":"bt2020nc","colorRange":"tv","colorPrimaries":"bt2020","colorTrc":"smpte2084","chromaLocation":"left","sampleAspectRatio":"1:1","displayAspectRatio":"16:9","languageCode":"eng","title":"Video","displayTitle":"4K HDR10","extendedDisplayTitle":"4K HDR10 (HEVC Main 10)"},{"streamType":2,"index":1,"codec":"aac","profile":"LC","level":1,"bitrate":640,"languageCode":"eng","title":"English","samplingRate":48000,"channels":6,"audioChannelLayout":"5.1"}]}]}]}]}}` + return `{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"container":"mkv","duration":123000,"bitrate":12000,"width":3840,"height":2160,"aspectRatio":"16:9","audioChannels":6,"audioCodec":"aac","videoCodec":"hevc","videoResolution":"4k","videoFrameRate":"23.976","videoProfile":"Main 10","audioProfile":"LC","Part":[{"id":7,"key":"/library/parts/7/1/file","file":"/cloud/movie.strm","duration":123000,"size":987654321,"container":"mkv","videoProfile":"Main 10","audioProfile":"LC","Stream":[{"streamType":1,"index":0,"codec":"hevc","profile":"Main 10","level":153,"bitrate":12000,"width":3840,"height":2160,"frameRate":23.976,"refFrames":4,"pixelFormat":"yuv420p10le","bitDepth":10,"colorSpace":"bt2020nc","colorRange":"tv","colorPrimaries":"bt2020","colorTrc":"smpte2084","chromaLocation":"left","sampleAspectRatio":"1:1","displayAspectRatio":"16:9","languageCode":"eng","title":"Video","displayTitle":"4K HDR10 (HEVC Main 10)","extendedDisplayTitle":"4K HDR10 (HEVC Main 10)"},{"streamType":2,"index":1,"codec":"aac","profile":"LC","level":1,"bitrate":640,"languageCode":"eng","title":"English","samplingRate":48000,"channels":6,"audioChannelLayout":"5.1","displayTitle":"English (AAC 5.1)","extendedDisplayTitle":"English (AAC 5.1)"}]}]}]}]}}` } From 75de25c1cbcd70b03b4ba1df77d150196f256e31 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Sun, 30 Aug 2026 21:16:16 +0800 Subject: [PATCH 3/4] chore(release): prepare v0.1.3 --- CHANGELOG.en.md | 17 ++++++++-- CHANGELOG.md | 13 ++++++-- README.en.md | 34 ++++++++++++-------- README.md | 24 ++++++++++----- VERSION | 2 +- docs/architecture.md | 46 ++++++++++++++++++---------- docs/media-lifecycle-architecture.md | 10 ++++++ docs/performance-matrix.md | 4 +++ docs/roadmap.md | 8 +++++ 9 files changed, 116 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index 8491770..63b3272 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -7,10 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.3] - 2026-08-30 + ### Added -- Plex Web can Direct Play browser-native STRM media while video remains a - browser-to-CDN 302 path. +- Plex Web can now Direct Play browser-native STRM media. Playback, pause, + resume, and seek are verified. Sources that require DASH, remuxing, or + audio/video transcoding remain unsupported. + +### Improved + +- Improved Plex iOS stability when opening STRM libraries, seasons, and item + details, and removed an incorrect main-feature label. +- Improved 4K, HEVC, HDR, Dolby Vision, and audio technical labels for STRM + media while preserving information already provided by Plex. ## [0.1.2] - 2026-08-30 @@ -75,7 +85,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.2...HEAD +[Unreleased]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.3...HEAD +[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 [0.1.0]: https://github.com/InfinityPacer/plex-gateway/releases/tag/v0.1.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index c5be0f6..4fe00ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,17 @@ ## [未发布] +## [0.1.3] - 2026-08-30 + ### 新增 -- 支持 Plex Web 直接播放浏览器原生兼容的 STRM,视频仍由浏览器通过 302 直连 CDN。 +- 支持 Plex Web 直接播放浏览器原生兼容的 STRM,已验证播放、暂停、继续和拖动进度。 + 需要 DASH、转封装或音视频转码的片源仍不支持。 + +### 改进 + +- 改善 Plex iOS 打开 STRM 媒体库、剧集和详情页的稳定性,修复错误的“正片”标识。 +- 改善 STRM 的 4K、HEVC、HDR、Dolby Vision 和音频技术标签显示,并保留 Plex 已有信息。 ## [0.1.2] - 2026-08-30 @@ -63,7 +71,8 @@ - Gateway 返回云端播放重定向前,由 Plex 使用当前客户端凭据授权对应 Part。 - 日志和指标不包含 Plex Token 或完整的签名媒体 URL。 -[未发布]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.2...HEAD +[未发布]: https://github.com/InfinityPacer/plex-gateway/compare/v0.1.3...HEAD +[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 [0.1.0]: https://github.com/InfinityPacer/plex-gateway/releases/tag/v0.1.0 diff --git a/README.en.md b/README.en.md index 5eff3e9..d9497ad 100644 --- a/README.en.md +++ b/README.en.md @@ -63,7 +63,7 @@ fully transparent Plex Web shell. | --- | --- | --- | | Local Plex media | Supported by transparent proxy | The gateway does not rewrite local Parts. | | Infuse Direct Play | Direct Play verified | Uses the Plex Part redirect path. | -| Plex iOS ExperimentalPlayer | Direct Play verified | STRM uses the universal decision/start redirect path. | +| 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. | @@ -203,17 +203,27 @@ language code. Generated Streams never contain Plex Stream IDs or playback selection fields such as `selected`, `default`, or `decision`. When Plex already has descriptive Streams that are not materialized in its database, only missing fields on identity-matched Streams are filled. Missing sibling Streams are not -created and existing Plex values are not overwritten. When a STRM Media/Part -shape is complete, the Part carries the real media size, and every Stream has a -positive ID assigned by the Plex data model, the Plex response is technically -authoritative. Metadata and Direct Play decisions are returned byte-for-byte -without reading the STRM, consulting the MediaInfo cache, or admitting ffprobe. -Missing optional descriptions such as `language` or `title` do not revoke that -authority. Non-STRM local Parts always remain outside this decision and are -passed through unchanged. -This release writes fallback probe results only to the gateway's SQLite store and -does not write to the Plex DB. Production Plex DB writes are a next-stage -evaluation after coverage, compatibility, backup, and rollback validation. +created and existing Plex values are not overwritten. + +Descriptive Streams provide technical labels but cannot replace native Stream +rows in the Plex data model. Real-client A/B testing confirmed that the official +iOS client can display fields such as 4K and HEVC without Plex Stream IDs and +playback-selection state, while audio may still appear absent and playback-page +technical badges may remain incomplete. Synthesizing IDs is unsafe because +clients may use them to select tracks. When a STRM Media/Part shape is complete, +the Part carries the real media size, and every Stream has a positive ID assigned +by Plex, no further Gateway projection is required. Metadata and Direct Play +decisions are returned byte-for-byte without reading the STRM, consulting the +MediaInfo cache, or admitting ffprobe. Missing optional descriptions such as +`language` or `title` do not change that decision. Non-STRM local Parts always +remain outside this path and are passed through unchanged. + +This release writes fallback probe results only to the gateway's SQLite store +and does not include a Plex DB writer. A single-item offline proof of concept +confirmed that native Plex Stream rows restore complete audio and technical +presentation in the official iOS client and automatically select the Gateway's +byte-for-byte passthrough path. Production writes still require coverage, +PMS/schema compatibility, backup, and rollback validation. The L1 hot path returns without synchronously reading SQLite for the request. Access renewal may touch SQLite asynchronously, so persistence I/O stays out of diff --git a/README.md b/README.md index b1cca7e..d943d3a 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Gateway。需要 DASH、转封装或转码的片源仍不支持,Gateway 不合 | --- | --- | --- | | Plex 本地媒体 | 透明代理支持 | Gateway 不会重写本地 Part。 | | Infuse Direct Play | 已验证 Direct Play | 使用 Plex Part 重定向路径。 | -| Plex iOS ExperimentalPlayer | 已验证 Direct Play | STRM 使用通用 decision/start 重定向链路。 | +| 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、转封装或转码的片源不支持。 | @@ -174,13 +174,21 @@ ffprobe 的流类型和源索引创建描述性的视频、音频和字幕 Strea Vision、bit depth、codec、codecID、bitrate、声道布局和语言码等字段。合成 Stream 不包含 Plex Stream ID,也不生成 `selected`、`default` 或 `decision` 等播放选择字段。Plex 已经存在 但尚未物化到数据库的描述性 Stream 时,只补充身份匹配 Stream 的缺失字段,不创建缺少 -的其他流,也不覆盖 Plex 值。若 STRM 的 Media/Part 技术字段完整、Part 已具有真实媒体 -大小,且所有 Stream 都带有 Plex 数据模型分配的正数 ID,Plex 响应即为技术权威;Gateway -会原样返回 metadata 和 Direct Play decision,不读取 STRM、不查询 MediaInfo 缓存,也不 -发起 ffprobe。`language`、`title` 等可选描述字段缺失不会解除该权威。本地非 STRM Part -始终完全透传,不进入上述判断。 -当前版本只将兜底探测结果写入 Gateway SQLite,不写入 Plex DB。Plex DB 生产写入属于下一 -阶段评估内容,需先完成覆盖率、兼容性、备份和回滚验证。 +的其他流,也不覆盖 Plex 值。 + +描述性 Stream 能提供技术标签,但不能代替 Plex 数据模型中的原生 Stream 行。真实客户端 +A/B 已确认:没有 Plex Stream ID 和播放选择状态时,官方 iOS 客户端可以显示 4K、HEVC 等 +部分标签,但音频可能显示为“无”,播放页技术徽标也可能缺失;这不是继续伪造 ID 能安全 +解决的问题,因为客户端会使用这些 ID 选择音轨。若 STRM 的 Media/Part 技术字段完整、Part +已具有真实媒体大小,且所有 Stream 都带有 Plex 数据模型分配的正数 ID,则无需继续执行 +Gateway 投影。metadata 和 Direct Play decision 会字节级原样返回,不读取 STRM、不查询 +MediaInfo 缓存,也不发起 ffprobe;`language`、`title` 等可选描述字段缺失不会改变该判断。 +本地非 STRM Part 始终完全透传,不进入上述判断。 + +当前版本只将兜底探测结果写入 Gateway SQLite,不写入 Plex DB。单样本离线 PoC 已确认, +Plex 持有原生 Stream 行后,官方 iOS 客户端可恢复完整音轨和技术展示,Gateway 也会自动 +进入上述纯透传路径。生产写入仍需完成覆盖率、PMS/schema 兼容、备份和回滚验证,本版本 +不包含 Plex DB 写入器。 L1 热路径直接返回,不会为了本次请求同步读取 SQLite。访问续期可能异步 touch SQLite, 不会把持久化 I/O 放进请求关键路径。热缓存投影可以并发执行;metadata 浏览冷 miss 立即 diff --git a/VERSION b/VERSION index d917d3e..b1e80bb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.2 +0.1.3 diff --git a/docs/architecture.md b/docs/architecture.md index 61167ce..f9a203a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -203,15 +203,24 @@ the bounded P2 scheduler while the original Plex response is returned immediately. Successful worker results populate L1 and SQLite for later responses. -Projection authority is derived only from the current Plex response, not from -the identity of a database writer. Local non-STRM Parts bypass MediaInfo logic. -For a STRM Part, complete Media/Part technical fields, a real media size, and -positive IDs on every Stream prove that the Stream rows are materialized in the -Plex data model. Such item metadata and Direct Play decisions are preserved -byte-for-byte and do not read the STRM, cache, resolver, or probe worker. -Gateway-generated descriptive Streams intentionally omit Plex IDs, so they -remain distinguishable from that authoritative shape. Optional descriptions -such as language and title are not authority requirements. +Projection bypass is derived only from the current Plex response, not from the +identity of a database writer. Local non-STRM Parts bypass MediaInfo logic. For a +STRM Part, complete Media/Part technical fields, a real media size, and positive +IDs on every Stream prove that the Stream rows are materialized in the Plex data +model. Such item metadata and Direct Play decisions are preserved byte-for-byte +and do not read the STRM, cache, resolver, or probe worker. Optional descriptions +such as language and title are not bypass requirements. + +Gateway-generated descriptive Streams intentionally omit Plex IDs and playback +selection state. They can project codec, resolution, HDR/Dolby Vision, bitrate, +channel, and language facts, but they are not equivalent to materialized Plex +Stream rows. Real-client A/B showed that the official iOS client may display the +Media-level 4K and HEVC labels while still showing no audio or omitting a +playback-page technical badge. IDs are not synthesized because clients may use +them for track selection. A single-item offline Plex DB proof of concept restored +the native Stream shape and made the Gateway choose byte-for-byte passthrough; +this is evidence for a future isolated writer, not a production write path in +this release. ## MediaInfo scheduling contract @@ -298,8 +307,11 @@ and helper endpoint. This compatibility path does not expand the media data plane. The browser still downloads bytes directly from the final CDN. It only supports containers and codecs the browser can Direct Play. If Plex Web needs DASH, remuxing, or video -transcoding, an ordinary MP4 or MKV response is not a manifest and this helper -cannot make that path playable. The gateway does not proxy media, package +or audio transcoding, an ordinary MP4 or MKV response is not a manifest and this +helper cannot make that path playable. A real A/B confirmed this boundary: Plex +served a local item through DASH with copied video and transcoded audio, while a +STRM `start.mpd` redirect exposed the original MP4 where the browser expected a +DASH manifest and ended with `s1002`. The gateway does not proxy media, package segments, or synthesize manifests. Local media retains Plex's normal Web compatibility. @@ -332,11 +344,13 @@ is accepted, the body is not consumed, and every error preserves the other probe fields. This fallback remains inside the MediaInfo worker and never enters the synchronous 302 path. A stable MediaVault or MoviePilot provider can replace remote probing without -changing playback. Plex DB production writing is a next-stage evaluation only, -covering an official API, another supported PMS interface, and an isolated -database helper with explicit coverage, backup, and rollback tests. This release -does not select or enable a Plex DB write path. Intro and credits work starts -with an isolated Plex marker API probe. See +changing playback. A production Plex projection writer remains an explicit +evaluation across an official API, another supported PMS interface, and an +isolated database helper with coverage, version/schema allowlists, consistent +backup, rollback, compare-and-set identity checks, and API readback. The +single-item offline database proof of concept establishes client value and the +Gateway bypass contract only; this release does not select or enable a writer. +Intro and credits work starts with an isolated Plex marker API probe. See [media-lifecycle-architecture.md](media-lifecycle-architecture.md). Performance acceptance and reproducible microbenchmarks are defined in diff --git a/docs/media-lifecycle-architecture.md b/docs/media-lifecycle-architecture.md index 1d7c31a..b53318d 100644 --- a/docs/media-lifecycle-architecture.md +++ b/docs/media-lifecycle-architecture.md @@ -582,6 +582,14 @@ ffprobe。 Dolby Vision 映射必须使用同一片源的本地文件与 STRM Plex response 建立 fixture,不能 仅按 ffprobe 字段名猜测。 +描述性 Stream 与 Plex 原生 Stream 行不是同一能力。真实客户端 A/B 已确认,只有 +Gateway 描述性字段时,官方 iOS 客户端可以消费 Media 层的 4K、HEVC 等标签,但音轨状态 +和播放页技术徽标仍可能不完整。Gateway 不能伪造 Stream ID,因为客户端可能使用它选择 +音轨。当前 Plex response 已包含完整 Media/Part、真实文件大小,并且每个 Stream 都有 +正数 ID 时,Gateway 必须将其视为“无需继续投影”,字节级透传并跳过 STRM、缓存和探测。 +单样本离线写库 PoC 已验证原生 Stream 行可以恢复官方客户端完整展示,并触发该纯透传 +路径;这只证明方案价值,不代表生产 Helper 已完成。 + ## 12. Gateway 边界 Gateway 对所有来源使用同一播放契约: @@ -781,6 +789,8 @@ provider、目标 backing、decision revision、人工确认或策略依据, - 比较 Plex 官方 API、其他受支持 PMS 接口和独立 Plex Helper,不提前固定写入方案; - Helper 候选只在 Plex 停止或经过验证的维护窗口执行; - 测试库验证一致性备份与 restore、schema allowlist、CAS、写入和 API 回读; +- 保留单样本离线写库 PoC 结论:Plex 原生 Stream 行恢复官方客户端音轨和技术展示, + Gateway 自动停止重复投影和探测;不得把该结论扩大为生产写入已交付; - 验证刷新、Analyze、PMS 重启和升级后的保持/恢复; - 验证 Apple TV 和其他目标客户端是否消费 Gateway enrichment 或 Plex 持久字段。 diff --git a/docs/performance-matrix.md b/docs/performance-matrix.md index 6517d8c..ee8089e 100644 --- a/docs/performance-matrix.md +++ b/docs/performance-matrix.md @@ -15,6 +15,8 @@ 因此持久化 I/O 不得阻塞热路径。 - 单项 metadata 冷 miss 只允许内存级 P2 投递并立即返回 Plex 原响应,不同步读取 SQLite、 请求 MediaVault 或等待 CDN probe。 +- STRM response 已包含完整 Media/Part、真实媒体大小和带正数 Plex ID 的 Stream 行时, + 必须字节级透传,不读取 STRM、不查询 L1/SQLite,也不投递 ffprobe。 - 详细 metadata 请求默认只移除 `asyncAugmentMetadata`,避免浏览突发调度 Plex 后台分析; `checkFiles` 继续透传以保留 `Part.accessible` 与 `Part.exists`。 - 通用 metadata 微批只合并认证、query、Header、表示和远端身份完全一致的单项 GET。 @@ -96,6 +98,8 @@ Guard 尾部等待和客户端结果共同支持批量默认值 4。 | universal start | Plex Part 授权、MediaVault redirect | grant 命中、上游次数、302 延迟 | 不读取 metadata、STRM 或 MediaInfo;无 grant 时透明回退。 | | 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 已物化 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。 | | SQLite 启动恢复 | 启动期 SQLite,播放期 L1 | 恢复耗时、首个 decision 延迟 | 首个请求 0 ffprobe、0 请求期 SQLite。 | diff --git a/docs/roadmap.md b/docs/roadmap.md index ff4543c..1da75d6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -111,6 +111,14 @@ D prioritizes the Gateway fallback while preserving the broader lifecycle design 6. add Redis or replace SQLite with PostgreSQL only after measured multi-instance or capacity requirements exist. +Response projection and its bypass contract are now validated. Descriptive +Gateway Streams improve technical labels but do not replace native Plex Stream +IDs or track-selection state. A single-item offline Plex DB proof of concept +restored the official iOS audio and technical presentation; the resulting Plex +response was passed through byte-for-byte without another STRM/cache/probe path. +This evidence keeps an isolated production writer on the roadmap, but no writer +is included or enabled in this release. + The performance matrix in [performance-matrix.md](performance-matrix.md) is a delivery gate for this work. Local media must remain transparent, Part and universal-start routes must remain free of analysis I/O, an L1 hit must not From 0a4b992b3f106fdba2d3a9cfb5334d36182cc8be Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Sun, 30 Aug 2026 21:22:00 +0800 Subject: [PATCH 4/4] docs: add repository contributor guidelines --- AGENTS.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9c981ee --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# Repository Guidelines + +## 项目结构与模块职责 + +- `cmd/plex-gateway/` 是生产入口和依赖装配层,`cmd/plex-probe/` 提供只读 Plex 行为探测。 +- `internal/gateway/` 负责 HTTP 路由、Plex 反向代理、Direct Play 适配和响应处理。 +- `internal/mediainfo/`、`plexmeta/`、`playback/`、`resolver/`、`pathmap/`、`database/` 等包分别管理分析、协议、播放、解析、路径和持久化职责。 +- 测试与源码同目录,统一使用 `*_test.go`。架构、兼容性、性能和发版文档位于 `docs/`。 +- 部署模板为 `docker-compose.example.yml` 和 `app.env.example`,运维工具位于 `scripts/`。 + +## 构建、测试与本地开发 + +```sh +go run ./cmd/plex-gateway # 使用环境变量启动 Gateway +go test ./... # 运行完整测试 +go test -race -count=1 ./... # 与 CI 一致的竞态检查 +go vet ./... # Go 静态检查 +go build ./cmd/plex-gateway # 构建二进制 +docker build -t plex-gateway:dev . +``` + +聚焦测试可使用 `go test ./internal/gateway -run TestName`。性能基准与验收条件见 `docs/performance-matrix.md`。 + +## 编码风格与命名 + +所有 Go 文件必须通过 `gofmt`。包名使用简短的小写职责名。导出标识符应有 Go doc;注释用于说明协议契约、不变量、兼容边界和失败策略,不复述代码。保持现有包边界,避免混合 Gateway、MediaInfo 和 Plex 协议职责。 + +## 测试要求 + +使用标准库 `testing`。测试命名为 `TestBehavior`,基准命名为 `BenchmarkOperation`。XML/JSON、客户端兼容和配置组合优先使用表驱动测试。协议改动应覆盖成功、异常输入、取消、超时和 fail-open;涉及授权、请求身份、缓存或并发时必须运行 race 测试。性能验收必须区分冷缓存、热缓存、本地媒体、STRM、快速切换和多客户端场景;请求成功不等于性能达标。 + +## 提交与 Pull Request + +提交遵循简洁的 Conventional Commits,例如 `feat: support Plex Web direct play`、`fix: trust Plex materialized media info`。在主题分支开发。PR 说明应包含用户可见结果、兼容性边界、验证结果和剩余限制;除非界面变化需要说明,否则不要求截图。 + +## 文档与配置 + +`README.md` 面向安装和使用者,只说明能力、配置、兼容性和限制;实现原理、取舍与性能证据放入 `docs/`。运行默认值以代码为准,`app.env.example` 提供完整中文说明,Compose 示例只保留必要挂载与 `env_file`,不得写入特定 NAS 路径、域名或私有拓扑。 + +## 变更日志与发版说明 + +`CHANGELOG.md` 是给用户看的中文发版说明,不是代码实现记录。按重要性描述新增能力、体验变化和已知限制,最重要的用户功能必须放在最前。不要堆砌 Stream ID、投影层级、缓存、队列或 ffprobe 等内部细节,除非它们直接影响用户配置或使用边界。 + +`CHANGELOG.en.md` 必须同步表达相同内容。发版时同时更新 `VERSION` 和两个 changelog 的带日期版本章节;普通开发不要提前修改版本号。发版前比较上一个版本 Tag 到当前 HEAD,确认所有用户可见变化均已覆盖,且没有把 roadmap 或未交付能力写成已发布功能。发布源只使用 `main` 上的 `VERSION`;手工 Release 仅用于按当前版本重建,不接受自定义版本或源码 ref。Push、PR、Tag、镜像和 Release 等外部动作必须遵守当前维护者授权边界。 + +## 架构、兼容性与性能边界 + +Plex 负责媒体库、认证和观看状态;Gateway 只适配控制流和 302,不代理媒体字节;MediaVault 负责 STRM 和直链解析。所有改动必须保持本地媒体透明代理,Part、universal start 和 302 路径不得同步执行 SQLite、ffprobe 或其他分析 I/O。 + +客户端兼容改动应先比较 Plex 对本地媒体与 STRM 的原始响应,并按协议语义适配。只有真实请求证明 Plex 本身存在客户端差异时,才允许增加客户端分支。Gateway 只补缺失的描述性 MediaInfo,不伪造 Plex Stream ID 或播放选择状态;Plex 已返回物化 Stream 行时必须原样透传并跳过重复探测。 + +实验能力必须通过独立开关隔离。关闭时不得创建额外状态、缓存、锁或远程 I/O。未来 Plex MediaInfo 持久投影必须作为隔离组件,具备 PMS/schema 检测、备份、回滚和 API 回读,不能进入 Gateway 热路径。不得提交真实凭据、私有地址、媒体名称或未脱敏抓包。