package dto import ( "encoding/json" "github.com/mithral/vellum/internal/library" "slices" "testing" "github.com/mithral/vellum/internal/catalog" ) // These tests pin the WIRE contract. Real Jellyfin apps parse this JSON and // there is no client in this repository that would fail if a field name // changed, so every test here compares against a literal, not against a value // recomputed by the same helpers the production code uses. // jsonKeys returns the sorted top-level object keys of v, so a test can assert // both the exact set and (with the golden tests) the exact rendering. func jsonKeys(t *testing.T, v any) []string { t.Helper() b, err := json.Marshal(v) if err != nil { t.Fatalf("marshal: %v", err) } var m map[string]any if err := json.Unmarshal(b, &m); err != nil { t.Fatalf("unmarshal %s: %v", b, err) } out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } slices.Sort(out) return out } // sampleView is a fully populated ItemView: every optional field populated, so // the golden JSON below contains every key BaseItemDto can emit. func sampleView() ItemView { return ItemView{ Item: catalog.Item{ ID: 42, LibraryID: 7, Kind: "video", Path: "The Movie (2001).mp4", Title: "The Movie (2001)", Year: 2001, }, File: catalog.File{ ID: 100, Path: "The Movie (2001).mp4", Size: 1000, Container: "mp4", DurationMS: 5000, VCodec: "h264", ACodec: "aac", Width: 640, Height: 360, }, SortTitle: "the movie (2001)", ParentID: 7, HasCover: true, PositionMS: 2500, Percent: 0.5, PlayCount: 3, Favorite: true, } } // TestToBaseItemDtoGoldenJSON pins every JSON field name and value of the // browse DTO. RunTimeTicks is the load-bearing one: Jellyfin counts 100ns // ticks and Vellum stores milliseconds, so 5000ms must be 50,000,000 ticks. // Getting this wrong makes every client seek to the wrong offset. func TestToBaseItemDtoGoldenJSON(t *testing.T) { got, err := json.Marshal(ToBaseItemDto(sampleView(), false, "")) if err != nil { t.Fatalf("marshal: %v", err) } want := `{"Id":"0000000000000000000000000000002a",` + `"Name":"The Movie (2001)","Type":"Movie","MediaType":"Video",` + `"Container":"mp4","RunTimeTicks":50000000,"ProductionYear":2001,` + `"ParentId":"00000000000000000000000000000007","IsFolder":false,` + `"SortName":"the movie (2001)","Path":"The Movie (2001).mp4",` + `"ImageTags":{"Primary":"0000000000000000000000000000002a"},` + `"UserData":{"PlaybackPositionTicks":25000000,"PlayedPercentage":50,` + `"PlayCount":3,"IsFavorite":true,"Played":false}}` if string(got) != want { t.Errorf("BaseItemDto JSON changed.\n got: %s\nwant: %s", got, want) } } // TestToBaseItemDtoExactKeys asserts the key set, so adding or dropping a field // fails even if the golden above is regenerated. func TestToBaseItemDtoExactKeys(t *testing.T) { want := []string{ "Container", "Id", "ImageTags", "IsFolder", "MediaType", "Name", "ParentId", "Path", "ProductionYear", "RunTimeTicks", "SortName", "Type", "UserData", } got := jsonKeys(t, ToBaseItemDto(sampleView(), false, "")) if !slices.Equal(got, want) { t.Errorf("BaseItemDto keys = %v\nwant %v", got, want) } } // TestToBaseItemDtoOmitsEmptyOptionals pins which keys are absent rather than // zero-valued. A client that switches on the presence of MediaSources must not // see an empty array where "not requested" is meant. func TestToBaseItemDtoOmitsEmptyOptionals(t *testing.T) { v := ItemView{Item: catalog.Item{ID: 1, Kind: "video", Title: "T"}} got := jsonKeys(t, ToBaseItemDto(v, false, "")) // MediaType is present because the kind is known; the omitted keys are the // ones that are genuinely empty (Container, Path, ProductionYear, // RunTimeTicks, ImageTags, ParentId). want := []string{"Id", "IsFolder", "MediaType", "Name", "Type", "UserData"} if !slices.Equal(got, want) { t.Errorf("minimal keys = %v\nwant %v", got, want) } if keys := jsonKeys(t, ToBaseItemDto(v, true, "k")); !slices.Contains(keys, "MediaSources") { t.Errorf("includeMediaSources=true must emit MediaSources; keys = %v", keys) } } // TestToBaseItemDtoUserDataTicks pins the tick conversion on the per-user // overlay: Vellum stores a resume position in ms, Jellyfin clients send and // expect 100ns ticks. func TestToBaseItemDtoUserDataTicks(t *testing.T) { v := sampleView() v.PositionMS = 1 // 1ms must be 10000 ticks, not 1 d := ToBaseItemDto(v, false, "") if d.UserData.PlaybackPositionTicks != 10_000 { t.Errorf("PlaybackPositionTicks = %d, want 10000", d.UserData.PlaybackPositionTicks) } if got := TicksToMS(d.UserData.PlaybackPositionTicks); got != 1 { t.Errorf("TicksToMS round-trip = %d, want 1", got) } if d.RunTimeTicks != 50_000_000 { t.Errorf("RunTimeTicks = %d, want 50000000", d.RunTimeTicks) } if MsToTicks(0) != 0 || MsToTicks(1) != 10_000 { t.Errorf("MsToTicks broken: %d %d", MsToTicks(0), MsToTicks(1)) } } // TestToBaseItemDtoParentIDZero pins that ParentId is omitted, not sent as the // zero id, when an item has no parent. func TestToBaseItemDtoParentIDZero(t *testing.T) { v := sampleView() v.ParentID = 0 d := ToBaseItemDto(v, false, "") if d.ParentId != "" { t.Errorf("ParentId = %q, want empty for ParentID 0", d.ParentId) } if slices.Contains(jsonKeys(t, d), "ParentId") { t.Error("ParentId key must be omitted when there is no parent") } } // TestImageTagsOnlyWithCover: ImageTags is what makes a client fetch // /Images/Primary. Emitting it for an item with no artwork produces a request // that 404s on every browse. func TestImageTagsOnlyWithCover(t *testing.T) { v := sampleView() v.HasCover = true d := ToBaseItemDto(v, false, "") want := map[string]string{"Primary": ItemID(42)} if !jsonEq(want, d.ImageTags) { t.Errorf("ImageTags = %v, want %v", d.ImageTags, want) } v.HasCover = false d = ToBaseItemDto(v, false, "") if d.ImageTags != nil { t.Errorf("ImageTags = %v, want nil without a cover", d.ImageTags) } if slices.Contains(jsonKeys(t, d), "ImageTags") { t.Error("ImageTags key must be absent without a cover") } } // TestMediaSourceNeverTranscodes is the contract that matters most on a // headless, GPU-less host: Vellum cannot transcode, and a client told // transcoding is available will wait forever for a stream that never starts. // The refusal happens in the handler (ErrorCode), but the source it is built // from must never advertise transcoding. func TestMediaSourceNeverTranscodes(t *testing.T) { ms := BuildMediaSource(sampleView().Item, sampleView().File, "tok123") if ms.SupportsTranscoding { t.Error("SupportsTranscoding = true: Vellum never transcodes") } if !ms.SupportsDirectPlay { t.Error("SupportsDirectPlay = false, want true") } if !ms.SupportsDirectStream { t.Error("SupportsDirectStream = false, want true") } if ms.Protocol != "File" || ms.Type != "Default" || ms.IsRemote { t.Errorf("protocol/type/remote = %q/%q/%v, want File/Default/false", ms.Protocol, ms.Type, ms.IsRemote) } } // TestDirectStreamURLCarriesAPIKey: media elements cannot set request headers, // so the token has to be in the URL or playback fails with a 401 mid-stream. // This is the single most common Jellyfin integration break. func TestDirectStreamURLCarriesAPIKey(t *testing.T) { item := catalog.Item{ID: 42, Kind: "video", Title: "T"} if got, want := BuildMediaSource(item, catalog.File{}, "abc123").DirectStreamUrl, "/Videos/"+ItemID(42)+"/stream?api_key=abc123"; got != want { t.Errorf("video DirectStreamUrl = %q, want %q", got, want) } item.Kind = "audiobooks" if got, want := BuildMediaSource(item, catalog.File{}, "abc123").DirectStreamUrl, "/Audio/"+ItemID(42)+"/stream?api_key=abc123"; got != want { t.Errorf("non-video DirectStreamUrl = %q, want %q", got, want) } item.Kind = "video" if got, want := BuildMediaSource(item, catalog.File{}, "").DirectStreamUrl, "/Videos/"+ItemID(42)+"/stream"; got != want { t.Errorf("no-key DirectStreamUrl = %q, want %q", got, want) } } // TestMediaStreamsFromProbeFacts: the codec facts are what let a CLIENT decide // whether it can direct-play, which matters precisely because Vellum will not // help it otherwise. func TestMediaStreamsFromProbeFacts(t *testing.T) { ms := BuildMediaSource(sampleView().Item, sampleView().File, "") if len(ms.MediaStreams) != 2 { t.Fatalf("MediaStreams = %d, want 2", len(ms.MediaStreams)) } if ms.MediaStreams[0] != (MediaStream{Type: "Video", Codec: "h264", Index: 0, IsDefault: true, Width: 640, Height: 360}) { t.Errorf("video stream = %+v", ms.MediaStreams[0]) } if ms.MediaStreams[1] != (MediaStream{Type: "Audio", Codec: "aac", Index: 1, IsDefault: true}) { t.Errorf("audio stream = %+v", ms.MediaStreams[1]) } // A file with no probed codecs yields no streams rather than empty entries. ms = BuildMediaSource(catalog.Item{ID: 1}, catalog.File{Container: "mp3"}, "") if ms.MediaStreams != nil { t.Errorf("MediaStreams = %v, want nil without probe facts", ms.MediaStreams) } if ms.Size != 0 || ms.RunTimeTicks != 0 { t.Errorf("zero File must not invent facts: %+v", ms) } } // TestMediaSourceGoldenJSON pins the source shape clients parse. func TestMediaSourceGoldenJSON(t *testing.T) { v := sampleView() got, err := json.Marshal(BuildMediaSource(v.Item, v.File, "abc123")) if err != nil { t.Fatalf("marshal: %v", err) } want := `{"Id":"0000000000000000000000000000002a","Name":"The Movie (2001)",` + `"Path":"The Movie (2001).mp4","Container":"mp4","RunTimeTicks":50000000,` + `"Size":1000,"SupportsDirectPlay":true,"SupportsDirectStream":true,` + `"SupportsTranscoding":false,` + `"DirectStreamUrl":"/Videos/0000000000000000000000000000002a/stream?api_key=abc123",` + `"Protocol":"File","Type":"Default","IsRemote":false,` + `"MediaStreams":[` + `{"Type":"Video","Codec":"h264","Index":0,"IsDefault":true,"Width":640,"Height":360},` + `{"Type":"Audio","Codec":"aac","Index":1,"IsDefault":true}]}` if string(got) != want { t.Errorf("MediaSourceInfo JSON changed.\n got: %s\nwant: %s", got, want) } } // TestKindMappings pins the kind-to-type table. A mis-mapped kind does not // crash: the client just renders an empty library view. // // ItemType takes the whole item, not the kind: an episode IS kind 'video', so // the television cases carry an episode number and the plain 'video' case must // keep rendering as a Movie. func TestKindMappings(t *testing.T) { cases := []struct { kind string episode int item string media string collection string }{ {kind: "video", item: "Movie", media: "Video", collection: "movies"}, {kind: "video", episode: 3, item: "Episode", media: "Video", collection: "movies"}, {kind: "series", item: "Series", media: "Video", collection: "tvshows"}, {kind: "season", item: "Season", media: "Video", collection: ""}, {kind: "audiobooks", item: "Audio", media: "Audio", collection: "music"}, {kind: "music", item: "Audio", media: "Audio", collection: "music"}, {kind: "photos", item: "Photo", media: "Photo", collection: "homevideos"}, {kind: "ebooks", item: "Book", media: "Book", collection: "books"}, {kind: "comics", item: "Book", media: "Book", collection: "books"}, {kind: "games", item: "Folder", media: "", collection: ""}, {kind: "", item: "Folder", media: "", collection: ""}, } for _, c := range cases { it := catalog.Item{Kind: c.kind, EpisodeNumber: c.episode} if got := ItemType(it); got != c.item { t.Errorf("ItemType(%q, episode %d) = %q, want %q", c.kind, c.episode, got, c.item) } if got := MediaType(c.kind); got != c.media { t.Errorf("MediaType(%q) = %q, want %q", c.kind, got, c.media) } if got := CollectionType(c.kind); got != c.collection { t.Errorf("CollectionType(%q) = %q, want %q", c.kind, got, c.collection) } } } // TestKindsForTypes pins the inverse map, including that an unknown Jellyfin // type maps to NOTHING rather than to everything: "show me everything I did not // ask for" is a data leak, not a convenience. func TestKindsForTypes(t *testing.T) { if got := KindsForTypes(nil); got != nil { t.Errorf("KindsForTypes(nil) = %v, want nil", got) } if got := KindsForTypes([]string{"Episode", "Nonsense"}); !slices.Equal(got, []string{"video"}) { t.Errorf("Episode = %v, want [video]", got) } if got, want := KindsForTypes([]string{"Movie"}), []string{"video"}; !slices.Equal(got, want) { t.Errorf("Movie = %v, want %v", got, want) } if got, want := KindsForTypes([]string{"Audio"}), []string{"audiobooks", "music"}; !slices.Equal(got, want) { t.Errorf("Audio = %v, want %v", got, want) } // The standard television query a client sends: series, seasons, episodes // and films together. Movie and Episode are the same kind, so it appears once. if got, want := KindsForTypes([]string{"Series", "Season", "Episode", "Movie"}), []string{"series", "season", "video"}; !slices.Equal(got, want) { t.Errorf("TV query = %v, want %v", got, want) } } // TestEpisodesOnly pins the one thing the kind cannot express: an episode and a // film are both kind 'video', so "Episodes but not Movies" has to be decided // from the requested types before the query is built. func TestEpisodesOnly(t *testing.T) { cases := []struct { types []string want bool }{ {[]string{"Episode"}, true}, {[]string{"Series", "Season", "Episode"}, true}, // A client browsing a mixed library asks for both and wants both. {[]string{"Episode", "Movie"}, false}, {[]string{"Movie"}, false}, {[]string{"Series"}, false}, {nil, false}, } for _, c := range cases { if got := EpisodesOnly(c.types); got != c.want { t.Errorf("EpisodesOnly(%v) = %v, want %v", c.types, got, c.want) } } } // TestTVShapes pins the three television renderings. A client tells them apart // by Type alone, so these fields are the contract: a series must be a folder // with no file facts, and an episode must carry the numbers and the ParentId it // is browsed under. func TestTVShapes(t *testing.T) { series := SeriesDto(ItemView{ Item: catalog.Item{ID: 1, Kind: "series", Title: "The Wire"}, SortTitle: "the wire", HasCover: true, }) if series.Type != "Series" || !series.IsFolder || series.CollectionType != "tvshows" { t.Errorf("series dto = %+v", series) } if series.Id != ItemID(1) || series.ImageTags["Primary"] != ItemID(1) { t.Errorf("series id/cover = %+v", series) } // A folder carries no playable facts, so a client never asks to stream one. for _, k := range []string{"RunTimeTicks", "MediaSources", "Container", "UserData"} { if slices.Contains(jsonKeys(t, series), k) { t.Errorf("series DTO must not carry %q", k) } } season := SeasonDto(ItemView{ Item: catalog.Item{ID: 2, Kind: "season", Title: "Season 1", SeasonNumber: 1}, SortTitle: "season 1", ParentID: 1, }) if season.Type != "Season" || !season.IsFolder || season.IndexNumber != 1 { t.Errorf("season dto = %+v", season) } if season.ParentId != ItemID(1) { t.Errorf("season ParentId = %q, want the series", season.ParentId) } if season.CollectionType != "" { t.Errorf("season CollectionType = %q, want empty (only a series is a collection)", season.CollectionType) } ep := EpisodeDto(ItemView{ Item: catalog.Item{ID: 3, Kind: "video", Title: "Pilot", SeasonNumber: 1, EpisodeNumber: 2}, File: catalog.File{Path: "The Wire/Season 1/S01E02.mkv", Container: "mkv", DurationMS: 5000}, ParentID: 2, Percent: 0.5, }, false, "") if ep.Type != "Episode" || ep.IsFolder { t.Errorf("episode dto = %+v", ep) } if ep.IndexNumber != 2 || ep.ParentIndexNumber != 1 { t.Errorf("episode numbers = %d/%d, want 2/1", ep.IndexNumber, ep.ParentIndexNumber) } if ep.ParentId != ItemID(2) { t.Errorf("episode ParentId = %q, want the season", ep.ParentId) } if ep.RunTimeTicks != 50_000_000 || ep.Container != "mkv" { t.Errorf("episode file facts lost: %+v", ep) } if ep.UserData == nil || ep.UserData.PlayedPercentage != 50 { t.Errorf("episode UserData = %+v", ep.UserData) } } // ToBaseItemDto dispatches on the television kinds, so a list response and a // single-item response cannot render a series differently. func TestToBaseItemDtoDispatchesTV(t *testing.T) { for _, c := range []struct { kind string episode int season int want string }{ {"series", 0, 0, "Series"}, {"season", 0, 1, "Season"}, {"video", 4, 1, "Episode"}, {"video", 0, 0, "Movie"}, } { v := ItemView{Item: catalog.Item{ID: 9, Kind: c.kind, EpisodeNumber: c.episode, SeasonNumber: c.season}} if got := ToBaseItemDto(v, false, ""); got.Type != c.want { t.Errorf("ToBaseItemDto(%q, episode %d).Type = %q, want %q", c.kind, c.episode, got.Type, c.want) } } } // TestLibraryDto: a library is a folder, and its CollectionType is what tells a // client which view to render. A library rendered without CollectionType shows // up as an empty generic folder. func TestLibraryDto(t *testing.T) { d := LibraryDto(libraryFixture()) if d.Id != LibraryID(7) { t.Errorf("Id = %q, want %q", d.Id, LibraryID(7)) } if d.Type != "CollectionFolder" || !d.IsFolder { t.Errorf("Type/IsFolder = %q/%v, want CollectionFolder/true", d.Type, d.IsFolder) } if d.CollectionType != "movies" || d.Name != "Movies" || d.SortName != "Movies" { t.Errorf("library dto = %+v", d) } // A folder carries no media facts. keys := jsonKeys(t, d) for _, k := range []string{"Container", "RunTimeTicks", "MediaSources", "Path"} { if slices.Contains(keys, k) { t.Errorf("folder DTO must not carry %q; keys = %v", k, keys) } } } // jsonEq compares two string maps without depending on map iteration order. // libraryFixture is the library the LibraryDto test maps. CollectionType comes // from the library's kind, so "video" must render as a movies collection. func libraryFixture() library.Library { return library.Library{ ID: 7, Name: "Movies", Kind: "video", RootPath: "/media/movies", } } func jsonEq(want, got map[string]string) bool { if len(want) != len(got) { return false } for k, v := range want { if got[k] != v { return false } } return true }