diff --git a/internal/jellyfin/dto/dto_test.go b/internal/jellyfin/dto/dto_test.go new file mode 100644 index 0000000..6167f89 --- /dev/null +++ b/internal/jellyfin/dto/dto_test.go @@ -0,0 +1,362 @@ +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. +func TestKindMappings(t *testing.T) { + cases := []struct{ kind, item, media, collection string }{ + {"video", "Movie", "Video", "movies"}, + {"audiobooks", "Audio", "Audio", "music"}, + {"music", "Audio", "Audio", "music"}, + {"photos", "Photo", "Photo", "homevideos"}, + {"ebooks", "Book", "Book", "books"}, + {"comics", "Book", "Book", "books"}, + {"games", "Folder", "", ""}, + {"", "Folder", "", ""}, + } + for _, c := range cases { + if got := ItemType(c.kind); got != c.item { + t.Errorf("ItemType(%q) = %q, want %q", c.kind, 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"}); got != nil { + t.Errorf("unknown types = %v, want nil", 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) + } +} + +// 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 +} diff --git a/internal/jellyfin/jellyfin_test.go b/internal/jellyfin/jellyfin_test.go new file mode 100644 index 0000000..b59e9fb --- /dev/null +++ b/internal/jellyfin/jellyfin_test.go @@ -0,0 +1,375 @@ +package jellyfin + +import ( + "context" + "database/sql" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mithral/vellum/internal/apitoken" + "github.com/mithral/vellum/internal/auth" + "github.com/mithral/vellum/internal/core" + "github.com/mithral/vellum/internal/jellyfin/dto" + "github.com/mithral/vellum/migrations" +) + +// These tests exist because internal/jellyfin is the most client-visible surface +// in the tree — real Jellyfin apps parse its JSON — and it had no tests at all. +// The two that matter most are the grant check (a wrong one leaks another +// user's library) and the transcode flag (a client told transcoding is +// available waits forever for a stream that never starts). + +type fixture struct { + srv *httptest.Server + db *sql.DB + itemID int64 + fileID int64 + libID int64 + admin string // token + granted string // token, non-admin, granted the library + none string // token, non-admin, no grants +} + +func testDB(t *testing.T) *sql.DB { + t.Helper() + db, err := core.OpenDB(filepath.Join(t.TempDir(), "jellyfin.db")) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil { + t.Fatalf("Migrate: %v", err) + } + return db +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + db := testDB(t) + ctx := context.Background() + + // A real file on disk: the stream handler opens it, so a row alone is not + // enough to prove the streaming path. + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "movie.mp4"), []byte("0123456789abcdefghij"), 0o644); err != nil { + t.Fatalf("write media: %v", err) + } + + libID := mustExec(t, db, `INSERT INTO libraries (kind, name, root_path) VALUES ('video', 'Movies', ?)`, root) + itemID := mustExec(t, db, ` + INSERT INTO media_items (library_id, kind, path, title, sort_title, year) + VALUES (?, 'video', 'movie.mp4', 'A Movie', 'a movie', 1999)`, libID) + fileID := mustExec(t, db, ` + INSERT INTO media_files (item_id, path, size, mtime, container, duration_ms, + vcodec, acodec, width, height) + VALUES (?, 'movie.mp4', 20, '1700000000000000000', 'mp4', 5000, 'h264', 'aac', 640, 360)`, itemID) + + // Three users: an admin, a non-admin granted the library, and a non-admin + // with no grants at all. + hash, err := auth.HashPassword("pw") + if err != nil { + t.Fatalf("hash: %v", err) + } + adminID := mustExec(t, db, `INSERT INTO users (username, display_name, password_hash, is_admin) VALUES ('admin','Admin',?,1)`, hash) + grantedID := mustExec(t, db, `INSERT INTO users (username, display_name, password_hash, is_admin) VALUES ('granted','Granted',?,0)`, hash) + noneID := mustExec(t, db, `INSERT INTO users (username, display_name, password_hash, is_admin) VALUES ('none','None',?,0)`, hash) + mustExec(t, db, `INSERT INTO library_grants (user_id, library_id, can_read) VALUES (?, ?, 1)`, grantedID, libID) + + token := func(userID int64) string { + secret, err := apitoken.Mint(ctx, db, userID, apitoken.KindJellyfin, "test") + if err != nil { + t.Fatalf("mint: %v", err) + } + return secret + } + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + authn := auth.NewAuthenticator(db, "vellum_session", time.Hour, false, log) + mux := http.NewServeMux() + NewHandlers(db, log, authn).Register(mux) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + return &fixture{ + srv: srv, db: db, itemID: itemID, fileID: fileID, libID: libID, + admin: token(adminID), granted: token(grantedID), none: token(noneID), + } +} + +func mustExec(t *testing.T, db *sql.DB, q string, args ...any) int64 { + t.Helper() + res, err := db.Exec(q, args...) + if err != nil { + t.Fatalf("exec %s: %v", q, err) + } + id, _ := res.LastInsertId() + return id +} + +// do issues a request with an optional token supplied the given way. +func (f *fixture) do(t *testing.T, method, path, headerName, token string) (*http.Response, string) { + t.Helper() + req, err := http.NewRequest(method, f.srv.URL+path, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + if headerName != "" && token != "" { + if headerName == "query" { + req.URL.RawQuery = "api_key=" + token + } else { + req.Header.Set(headerName, token) + } + } + resp, err := f.srv.Client().Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + return resp, string(body) +} + +// ── auth surfaces ─────────────────────────────────────────────────────── + +// All three credential forms must work. The query form matters most: