diff --git a/internal/gateway/metadata_enrichment_test.go b/internal/gateway/metadata_enrichment_test.go index 2f06329..bacabc6 100644 --- a/internal/gateway/metadata_enrichment_test.go +++ b/internal/gateway/metadata_enrichment_test.go @@ -33,6 +33,20 @@ type fakeMediaInfoEnsurer struct { getContextFn func(context.Context, mediainfo.Key) (mediainfo.Record, bool) } +type countingControlResolver struct { + delegate resolver.ControlResolver + reads atomic.Int64 +} + +func (counting *countingControlResolver) ReadTarget(strmPath string) (string, error) { + counting.reads.Add(1) + return counting.delegate.ReadTarget(strmPath) +} + +func (counting *countingControlResolver) ResolveTarget(ctx context.Context, target string, playback resolver.PlaybackRequest) (resolver.DirectURL, error) { + return counting.delegate.ResolveTarget(ctx, target, playback) +} + func (ensurer *fakeMediaInfoEnsurer) GetMemory(key mediainfo.Key) (mediainfo.Record, bool) { ensurer.memoryGets.Add(1) if ensurer.memoryFn == nil { @@ -550,23 +564,33 @@ func TestMetadataEnrichmentAdmitsExistingStreamHDRAndSTRMControlSize(t *testing. func TestMetadataEnrichmentSkipsLocalAndCompleteCloudParts(t *testing.T) { fixture := newMetadataEnrichmentFixture(t) + countingResolver := &countingControlResolver{delegate: fixture.resolver} + fixture.resolver = countingResolver ensurer := &fakeMediaInfoEnsurer{} tests := []struct { - name string - body []byte + name string + contentType string + body []byte }{ { - name: "local Part", - body: []byte(``), + name: "local Part", + contentType: "application/xml", + body: []byte(``), + }, + { + name: "complete cloud XML", + contentType: "application/xml", + body: []byte(``), }, { - name: "complete cloud Part", - body: []byte(``), + name: "complete cloud 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,"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","displayTitle":"4K HDR10","extendedDisplayTitle":"4K HDR10 (HEVC Main 10)"}]}]}]}]}}`), }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - handler := fixture.handler(ensurer, metrics.New(), metadataBodyHandler(test.body, "application/xml")) + handler := fixture.handler(ensurer, metrics.New(), metadataBodyHandler(test.body, test.contentType)) response := httptest.NewRecorder() handler.ServeHTTP(response, authenticatedMetadataRequest(http.MethodGet)) if !bytes.Equal(response.Body.Bytes(), test.body) || response.Header().Get("ETag") != `"plex-etag"` { @@ -574,8 +598,81 @@ func TestMetadataEnrichmentSkipsLocalAndCompleteCloudParts(t *testing.T) { } }) } - if ensurer.memoryGets.Load() != 0 || len(ensurer.offers()) != 0 { - t.Fatalf("skipped Parts memory gets=%d offers=%d", ensurer.memoryGets.Load(), len(ensurer.offers())) + if countingResolver.reads.Load() != 0 || ensurer.memoryGets.Load() != 0 || ensurer.gets.Load() != 0 || len(ensurer.offers()) != 0 { + t.Fatalf("skipped Parts STRM reads=%d memory gets=%d store gets=%d offers=%d", countingResolver.reads.Load(), ensurer.memoryGets.Load(), ensurer.gets.Load(), len(ensurer.offers())) + } +} + +func TestMetadataEnrichmentProjectsIncompleteItemStreamsWithCompleteMediaAndPart(t *testing.T) { + fixture := newMetadataEnrichmentFixture(t) + tests := []struct { + name string + contentType string + body []byte + want []byte + }{ + { + name: "XML", + contentType: "application/xml", + 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}]}]}]}]}}`), + want: []byte(`"codec":"hevc"`), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ensurer := &fakeMediaInfoEnsurer{memoryFn: func(key mediainfo.Key) (mediainfo.Record, bool) { + return mediainfo.Record{Key: key, Media: completeProjectionMedia()}, true + }} + 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.Contains(response.Body.Bytes(), test.want) { + t.Fatalf("response = %d %s", response.Code, response.Body.Bytes()) + } + if ensurer.memoryGets.Load() != 1 || ensurer.gets.Load() != 0 || len(ensurer.offers()) != 0 { + t.Fatalf("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{} + 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":"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,"marker":"plex-native"}]}]}]}]}}`), + }, + } + 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, authenticatedRequest(http.MethodGet, "/library/metadata/41/children")) + 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("collection touched MediaInfo cache: memory gets=%d store gets=%d offers=%d", ensurer.memoryGets.Load(), ensurer.gets.Load(), len(ensurer.offers())) } } diff --git a/internal/plexmeta/decision_projection.go b/internal/plexmeta/decision_projection.go index 7c510a7..085e499 100644 --- a/internal/plexmeta/decision_projection.go +++ b/internal/plexmeta/decision_projection.go @@ -50,7 +50,7 @@ func EnrichDecision(body []byte, contentType string, expected Part, media mediai return nil, false, errors.New("unsupported Plex decision document") } - changed, err := selection.enrich(media) + changed, err := selection.enrich(media, true) if err != nil { return nil, false, err } diff --git a/internal/plexmeta/projection.go b/internal/plexmeta/projection.go index 7aa6029..671e6ca 100644 --- a/internal/plexmeta/projection.go +++ b/internal/plexmeta/projection.go @@ -14,15 +14,15 @@ import ( "github.com/InfinityPacer/plex-gateway/internal/mediainfo" ) -// Target identifies the single Plex item and reports whether the response is -// missing a whitelisted technical field that a MediaInfo record can project. -// A Part with no Stream elements is eligible for descriptive Stream creation -// because ffprobe supplies the stable stream type and source index. +// Target identifies one Plex item and reports whether the endpoint-specific +// native response shape is missing a whitelisted technical field. A missing +// Stream in item metadata is projectable, but generated Stream elements never +// claim Plex-owned IDs or playback-selection state. type Target struct { RatingKey string Part Part - // NeedsEnrichment is true when a whitelisted field is absent from the - // selected Media, Part, or matchable existing Stream elements. + // NeedsEnrichment follows the endpoint contract: collection targets consider + // Media/Part only, while item targets also consider descriptive Stream data. NeedsEnrichment bool } @@ -34,7 +34,13 @@ func SelectEnrichmentTarget(body []byte, contentType, ratingKey string) (Target, if err != nil { return Target{}, err } - return selection.target(), nil + target := selection.target() + streamNeedsEnrichment, err := selection.streamsNeedEnrichment() + if err != nil { + return Target{}, err + } + target.NeedsEnrichment = target.NeedsEnrichment || streamNeedsEnrichment + return target, nil } // SelectEnrichmentPart is the strict Part-only form of @@ -49,16 +55,16 @@ func SelectEnrichmentPart(body []byte, contentType, ratingKey string) (Part, err } // EnrichMetadata fills only absent whitelisted Media, Part, and matching -// Stream attributes. When a Part has no Stream elements, it creates -// descriptive video, audio, and subtitle elements from the probe record. The -// input body is never modified in place. When no field is filled, the +// Stream attributes. When item metadata has no Stream elements, descriptive +// elements are created without Plex-owned IDs or playback-selection state. +// The input body is never modified in place. When no field is filled, the // original bytes are returned unchanged. func EnrichMetadata(body []byte, contentType, ratingKey string, media mediainfo.Media) ([]byte, bool, error) { document, selection, err := parseProjection(body, contentType, ratingKey) if err != nil { return nil, false, err } - changed, err := selection.enrich(media) + changed, err := selection.enrich(media, true) if err != nil { return nil, false, err } @@ -91,8 +97,10 @@ func SelectEnrichmentTargets(body []byte, contentType string) ([]Target, error) return targets, nil } -// EnrichMetadataTargets projects cached MediaInfo into matching items of one -// collection response. Missing records leave their items unchanged. +// EnrichMetadataTargets projects cached MediaInfo summaries into matching +// items of one collection response. Plex collection responses normally omit +// Stream elements, so collection projection never reads, creates, or changes +// them. Existing Stream elements retain Plex's native representation. func EnrichMetadataTargets(body []byte, contentType string, records map[string]mediainfo.Media) ([]byte, int, error) { document, err := parseProjectionDocument(body, contentType) if err != nil { @@ -108,7 +116,7 @@ func EnrichMetadataTargets(body []byte, contentType string, records map[string]m if !ok { continue } - changed, err := selection.enrich(media) + changed, err := selection.enrich(media, false) if err != nil { return nil, 0, err } @@ -134,7 +142,8 @@ type projectionDocument interface { type projectionSelection interface { target() Target - enrich(mediainfo.Media) (bool, error) + streamsNeedEnrichment() (bool, error) + enrich(mediainfo.Media, bool) (bool, error) } func parseProjection(body []byte, contentType, ratingKey string) (projectionDocument, projectionSelection, error) { @@ -204,6 +213,10 @@ func integerProjectionAttribute(name string, value int64) projectionAttribute { return projectionAttribute{name: name, value: strconv.FormatInt(value, 10), number: true} } +func numberProjectionAttribute(name, value string) projectionAttribute { + return projectionAttribute{name: name, value: value, number: true} +} + func booleanProjectionAttribute(name string, value bool) projectionAttribute { if value { return projectionAttribute{name: name, value: "1", boolean: true} @@ -244,8 +257,8 @@ func mediaProjectionAttributes(media mediainfo.Media) []projectionAttribute { if media.Height > 0 { attributes = append(attributes, integerProjectionAttribute("height", int64(media.Height))) } - if media.AspectRatio != "" { - attributes = append(attributes, textProjectionAttribute("aspectRatio", media.AspectRatio)) + if aspectRatio := plexAspectRatio(media.AspectRatio); aspectRatio != "" { + attributes = append(attributes, numberProjectionAttribute("aspectRatio", aspectRatio)) } if media.AudioChannels > 0 { attributes = append(attributes, integerProjectionAttribute("audioChannels", int64(media.AudioChannels))) @@ -262,11 +275,11 @@ func mediaProjectionAttributes(media mediainfo.Media) []projectionAttribute { if frameRate := plexMediaFrameRate(media.FrameRate); frameRate != "" { attributes = append(attributes, textProjectionAttribute("videoFrameRate", frameRate)) } - if media.VideoProfile != "" { - attributes = append(attributes, textProjectionAttribute("videoProfile", media.VideoProfile)) + if profile := plexProfile(media.VideoProfile); profile != "" { + attributes = append(attributes, textProjectionAttribute("videoProfile", profile)) } - if media.AudioProfile != "" { - attributes = append(attributes, textProjectionAttribute("audioProfile", media.AudioProfile)) + if profile := plexProfile(media.AudioProfile); profile != "" { + attributes = append(attributes, textProjectionAttribute("audioProfile", profile)) } return attributes } @@ -282,11 +295,11 @@ func partProjectionAttributes(media mediainfo.Media) []projectionAttribute { if media.Container != "" { attributes = append(attributes, textProjectionAttribute("container", media.Container)) } - if media.VideoProfile != "" { - attributes = append(attributes, textProjectionAttribute("videoProfile", media.VideoProfile)) + if profile := plexProfile(media.VideoProfile); profile != "" { + attributes = append(attributes, textProjectionAttribute("videoProfile", profile)) } - if media.AudioProfile != "" { - attributes = append(attributes, textProjectionAttribute("audioProfile", media.AudioProfile)) + if profile := plexProfile(media.AudioProfile); profile != "" { + attributes = append(attributes, textProjectionAttribute("audioProfile", profile)) } return attributes } @@ -299,8 +312,8 @@ func streamProjectionAttributes(stream mediainfo.Stream) []projectionAttribute { if codecID := plexCodecID(stream.CodecTag); codecID != "" { attributes = append(attributes, textProjectionAttribute("codecID", codecID)) } - if stream.Profile != "" { - attributes = append(attributes, textProjectionAttribute("profile", stream.Profile)) + if profile := plexProfile(stream.Profile); profile != "" { + attributes = append(attributes, textProjectionAttribute("profile", profile)) } if stream.Level > 0 { attributes = append(attributes, integerProjectionAttribute("level", int64(stream.Level))) @@ -391,6 +404,39 @@ func plexCodecID(value string) string { return value } +// plexProfile follows the lowercase profile vocabulary emitted by Plex for +// Media, Part, and Stream technical fields. Display titles retain ffprobe's +// presentation casing independently. +func plexProfile(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +// plexAspectRatio converts ffprobe ratios to Plex's two-decimal numeric Media +// representation. Stream displayAspectRatio remains the original ratio text. +func plexAspectRatio(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + numerator, denominator, ratio := strings.Cut(value, ":") + if !ratio { + parsed, err := strconv.ParseFloat(value, 64) + if err != nil || parsed <= 0 || math.IsNaN(parsed) || math.IsInf(parsed, 0) { + return "" + } + return strconv.FormatFloat(parsed, 'f', 2, 64) + } + n, err := strconv.ParseFloat(strings.TrimSpace(numerator), 64) + if err != nil || n <= 0 { + return "" + } + d, err := strconv.ParseFloat(strings.TrimSpace(denominator), 64) + if err != nil || d <= 0 { + return "" + } + return strconv.FormatFloat(n/d, 'f', 2, 64) +} + // plexCodec keeps the normalized codec vocabulary expected by Plex clients. // ffprobe may expose a container-specific subtitle codec name that Plex // represents using its short format name. @@ -490,6 +536,9 @@ func plexMediaFrameRate(value string) string { if err != nil || fps <= 0 || math.IsNaN(fps) || math.IsInf(fps, 0) { return "" } + if math.Abs(fps-25) < 0.01 { + return "PAL" + } nominal := math.Round(fps) if nominal <= 0 || math.IsInf(nominal, 0) { return "" @@ -842,7 +891,7 @@ func xmlProjectionSelectionFromItem(item *xmlProjectionElement) (*xmlProjectionS if err != nil { return nil, err } - needs, err := xmlTargetNeedsEnrichment(mediaItems[0], parts[0]) + needs, err := xmlMediaPartNeedsEnrichment(mediaItems[0], parts[0]) if err != nil { return nil, err } @@ -863,15 +912,22 @@ func (selection *xmlProjectionSelection) target() Target { return selection.value } -func (selection *xmlProjectionSelection) enrich(media mediainfo.Media) (bool, error) { - streamProjections, err := buildStreamProjections(media) - if err != nil { - return false, err - } +func (selection *xmlProjectionSelection) streamsNeedEnrichment() (bool, error) { + return xmlStreamsNeedEnrichment(selection.part) +} + +func (selection *xmlProjectionSelection) enrich(media mediainfo.Media, projectStreams bool) (bool, error) { changed := addMissingXMLAttributes(selection.media, mediaProjectionAttributes(media)) changed = addMissingXMLAttributes(selection.part, partProjectionAttributes(media)) || changed changed = replaceSTRMPartSizeXML(selection.part, media.Size) || changed + if !projectStreams { + return changed, nil + } streamElements := directXMLElements(selection.part, "Stream") + streamProjections, err := buildStreamProjections(media) + if err != nil { + return false, err + } streamIndex, err := indexXMLStreams(streamElements) if err != nil { return false, err @@ -969,7 +1025,7 @@ func xmlAttribute(attributes []xml.Attr, localName string) (string, bool, error) return value, present, nil } -func xmlTargetNeedsEnrichment(media, part *xmlProjectionElement) (bool, error) { +func xmlMediaPartNeedsEnrichment(media, part *xmlProjectionElement) (bool, error) { needs := false for _, name := range mediaProjectionAttributeNames { _, present, err := xmlAttribute(media.start.Attr, name) @@ -994,6 +1050,10 @@ func xmlTargetNeedsEnrichment(media, part *xmlProjectionElement) (bool, error) { } else if correction { needs = true } + return needs, nil +} + +func xmlStreamsNeedEnrichment(part *xmlProjectionElement) (bool, error) { streams := directXMLElements(part, "Stream") if _, err := indexXMLStreams(streams); err != nil { return false, err @@ -1015,7 +1075,7 @@ func xmlTargetNeedsEnrichment(media, part *xmlProjectionElement) (bool, error) { return false, err } if !present { - needs = true + return true, nil } } if key.typeName == "1" { @@ -1023,10 +1083,12 @@ func xmlTargetNeedsEnrichment(media, part *xmlProjectionElement) (bool, error) { if err != nil { return false, err } - needs = needs || incomplete + if incomplete { + return true, nil + } } } - return needs, nil + return false, nil } func xmlSTRMPartSizeNeedsCorrection(part *xmlProjectionElement) (bool, error) { @@ -1450,7 +1512,7 @@ func jsonProjectionSelectionFromItem( if err != nil { return nil, err } - needs, err := jsonTargetNeedsEnrichment(mediaItem, partItem) + needs, err := jsonMediaPartNeedsEnrichment(mediaItem, partItem) if err != nil { return nil, err } @@ -1483,11 +1545,11 @@ func (selection *jsonProjectionSelection) target() Target { return selection.value } -func (selection *jsonProjectionSelection) enrich(media mediainfo.Media) (bool, error) { - streamProjections, err := buildStreamProjections(media) - if err != nil { - return false, err - } +func (selection *jsonProjectionSelection) streamsNeedEnrichment() (bool, error) { + return jsonStreamsNeedEnrichment(selection.part) +} + +func (selection *jsonProjectionSelection) enrich(media mediainfo.Media, projectStreams bool) (bool, error) { changed, err := addMissingJSONAttributes(*selection.mediaItem, mediaProjectionAttributes(media)) if err != nil { return false, err @@ -1502,7 +1564,19 @@ func (selection *jsonProjectionSelection) enrich(media mediainfo.Media) (bool, e return false, err } changed = changed || partSizeChanged + if !projectStreams { + if changed { + if err := selection.commit(); err != nil { + return false, err + } + } + return changed, nil + } streamRaw, exists := (*selection.part)["Stream"] + streamProjections, err := buildStreamProjections(media) + if err != nil { + return false, err + } if !exists { if len(streamProjections) > 0 { if err := setGeneratedJSONStreams(*selection.part, streamProjections); err != nil { @@ -1713,7 +1787,7 @@ func jsonRawScalarString(raw json.RawMessage) (string, bool) { return stringValue(value) } -func jsonTargetNeedsEnrichment(media, part *jsonObject) (bool, error) { +func jsonMediaPartNeedsEnrichment(media, part *jsonObject) (bool, error) { needs := false for _, name := range mediaProjectionAttributeNames { if _, present := (*media)[name]; !present { @@ -1728,6 +1802,10 @@ func jsonTargetNeedsEnrichment(media, part *jsonObject) (bool, error) { if jsonSTRMPartSizeNeedsCorrection(*part) { needs = true } + return needs, nil +} + +func jsonStreamsNeedEnrichment(part *jsonObject) (bool, error) { streamRaw, exists := (*part)["Stream"] if !exists { return true, nil @@ -1752,14 +1830,14 @@ func jsonTargetNeedsEnrichment(media, part *jsonObject) (bool, error) { } for _, name := range streamProjectionAttributeNames(key.typeName) { if !jsonProjectionAttributePresent(*stream, name) { - needs = true + return true, nil } } if key.typeName == "1" && jsonDolbyVisionFieldsIncomplete(*stream) { - needs = true + return true, nil } } - return needs, nil + return false, nil } func jsonSTRMPartSizeNeedsCorrection(part jsonObject) bool { diff --git a/internal/plexmeta/projection_test.go b/internal/plexmeta/projection_test.go index 4a409d3..be6e6b1 100644 --- a/internal/plexmeta/projection_test.go +++ b/internal/plexmeta/projection_test.go @@ -41,6 +41,18 @@ func TestSelectEnrichmentTargetStrictlySelectsJSONItem(t *testing.T) { } } +func TestSelectEnrichmentTargetTreatsMissingItemStreamsAsIncomplete(t *testing.T) { + body := []byte(``) + + target, err := SelectEnrichmentTarget(body, "application/xml", "42") + if err != nil { + t.Fatal(err) + } + if !target.NeedsEnrichment { + t.Fatal("item metadata without native Stream details unexpectedly bypassed enrichment") + } +} + func TestEnrichMetadataTargetsProjectsOnlyMappedCollectionItems(t *testing.T) { media := projectionStreamMedia() tests := []struct { @@ -81,8 +93,69 @@ func TestEnrichMetadataTargetsProjectsOnlyMappedCollectionItems(t *testing.T) { if changed != 1 || !bytes.Contains(enriched, test.wantCloud) || !bytes.Contains(enriched, test.wantLocal) { t.Fatalf("changed=%d body=%s", changed, enriched) } - if !bytes.Contains(enriched, []byte("HDR10")) || bytes.Count(enriched, []byte("HDR10")) != 2 { - t.Fatalf("HDR projection did not stay on the cached item: %s", enriched) + if bytes.Contains(enriched, []byte("Stream")) || bytes.Contains(enriched, []byte("HDR10")) { + t.Fatalf("collection projection created non-native Stream details: %s", enriched) + } + }) + } +} + +func TestCollectionProjectionIgnoresExistingStreams(t *testing.T) { + media := projectionStreamMedia() + tests := []struct { + name string + contentType string + completeBody []byte + partialBody []byte + }{ + { + name: "XML", + contentType: "application/xml", + completeBody: []byte(``), + partialBody: []byte(``), + }, + { + name: "JSON", + contentType: "application/json", + completeBody: []byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"container":"mkv","duration":123000,"bitrate":29430119,"width":3840,"height":2160,"aspectRatio":1.78,"audioChannels":8,"audioCodec":"truehd","videoCodec":"hevc","videoResolution":"4k","videoFrameRate":"24p","videoProfile":"main 10","audioProfile":"dolby truehd + dolby atmos","Part":[{"id":7,"file":"/cloud/a.strm","duration":123000,"size":987654321,"container":"mkv","videoProfile":"main 10","audioProfile":"dolby truehd + dolby atmos","Stream":[{"streamType":1,"index":0,"marker":"keep"}]}]}]}]}}`), + partialBody: []byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"Part":[{"id":7,"file":"/cloud/a.strm","size":301,"Stream":[{"streamType":1,"index":0,"marker":"keep"}]}]}]}]}}`), + }, + } + for _, test := range tests { + t.Run(test.name+" complete Media and Part", func(t *testing.T) { + targets, err := SelectEnrichmentTargets(test.completeBody, test.contentType) + if err != nil { + t.Fatal(err) + } + if len(targets) != 1 || targets[0].NeedsEnrichment { + t.Fatalf("targets = %#v", targets) + } + result, changed, err := EnrichMetadataTargets(test.completeBody, test.contentType, map[string]mediainfo.Media{"42": media}) + if err != nil || changed != 0 || !bytes.Equal(result, test.completeBody) { + t.Fatalf("changed=%d err=%v body=%s", changed, err, result) + } + }) + t.Run(test.name+" partial Media and Part", func(t *testing.T) { + result, changed, err := EnrichMetadataTargets(test.partialBody, test.contentType, map[string]mediainfo.Media{"42": media}) + if err != nil || changed != 1 { + t.Fatalf("changed=%d err=%v body=%s", changed, err, result) + } + if test.contentType == "application/xml" { + stream := xmlProjectionAttributeSets(t, result)["Stream#1"] + want := map[string]string{"streamType": "1", "index": "0", "marker": "keep"} + if !reflect.DeepEqual(stream, want) { + t.Fatalf("collection Stream changed: got=%#v want=%#v", stream, want) + } + return + } + var document map[string]any + if err := json.Unmarshal(result, &document); err != nil { + t.Fatal(err) + } + stream := document["MediaContainer"].(map[string]any)["Metadata"].([]any)[0].(map[string]any)["Media"].([]any)[0].(map[string]any)["Part"].([]any)[0].(map[string]any)["Stream"].([]any)[0].(map[string]any) + want := map[string]any{"streamType": float64(1), "index": float64(0), "marker": "keep"} + if !reflect.DeepEqual(stream, want) { + t.Fatalf("collection Stream changed: got=%#v want=%#v", stream, want) } }) } @@ -123,7 +196,7 @@ func TestEnrichMetadataTargetsSkipsAmbiguousCollectionItems(t *testing.T) { if err != nil { t.Fatal(err) } - if changed != 1 || bytes.Count(enriched, []byte("HDR10")) != 2 { + if changed != 1 || bytes.Contains(enriched, []byte("Stream")) || bytes.Contains(enriched, []byte("HDR10")) { t.Fatalf("changed=%d body=%s", changed, enriched) } if bytes.Contains(enriched, []byte(`ratingKey="43"`)) && bytes.Contains(enriched, []byte(`ratingKey="43">`), + body: []byte(``), }, { name: "JSON", contentType: "application/json", - body: []byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"Part":[{"id":7,"file":"/cloud/movie.strm"}]}]}]}}`), + body: []byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"42","Media":[{"Part":[{"id":7,"file":"/cloud/movie.strm","Stream":[{"id":101,"streamType":1,"index":0}]}]}]}]}}`), }, } { t.Run(test.name, func(t *testing.T) { @@ -438,7 +506,7 @@ func TestEnrichMetadataProjectsPlexMediaFrameRateAndPreciseStreamRate(t *testing }{ {name: "film rate", input: "24000/1001", want: "24p"}, {name: "ntsc rate", input: "30000/1001", want: "30p"}, - {name: "pal rate", input: "25/1", want: "25p"}, + {name: "pal rate", input: "25/1", want: "PAL"}, } for _, format := range []struct { name string @@ -526,7 +594,7 @@ func TestEnrichMetadataDoesNotProjectUnknownCodecFieldsFromDolbyVisionProfile(t }, }, } - body := []byte(``) + body := []byte(``) result, changed, err := EnrichMetadata(body, "application/xml", "42", media) if err != nil || !changed {