The scanner builds library -> series -> season -> episode; this makes it
reachable. Three reads, all grant-checked through catalog.Store (ADR-005):
a series' seasons, a season's episodes, and the next unwatched episode.
Each loads the parent through ItemFor first, so forbidden and absent are the
same ErrNotFound, and the child query is scoped to the parent's own
library_id so a corrupt parent_id cannot cross a library boundary. Verified
red-green: swapping ItemFor for Item returns two rows to an ungranted
caller.
Episodes come back in episode order, not insertion order, and the ordering
is verified red-green too - dropping the clause yields [3 1 10 2].
Routes stay inside the existing family: /api/v1/items/{id}/seasons,
/episodes and /next. No parallel route surface.
Jellyfin gains SeriesDto, SeasonDto and EpisodeDto. Jellyfin's wire type is
BaseItemDto, so these carry the folder/file split plus IndexNumber and
ParentIndexNumber. ItemType now takes the item rather than the kind, because
an episode IS kind video - that is the one signature change, and the dto
tests were updated with it.
Verified end-to-end against a running server: a library holding S01E02,
1x02, a bare-number file and a film produced two series, three seasons and
six episodes, with the film left alone; the routes returned the seasons in
order, the episodes in episode order, and the next unwatched episode.
Note for later: Subsonic and the web UI are untouched, so a series browses
there as it did before. The scanner also has no separate 'tv' kind, so a
video library mixing films and shows builds both shapes side by side.
485 lines
18 KiB
Go
485 lines
18 KiB
Go
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
|
|
}
|