clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / internal / jellyfin / jellyfin_test.go
jellyfin_test.go 20.4K
1package jellyfin_test23import (4 "context"5 "errors"6 "io/fs"7 "os"8 "path/filepath"9 "slices"10 "strings"11 "testing"12 "time"1314 "airwaves/internal/jellyfin"15 "airwaves/internal/jellyfin/jellyfintest"16)1718// library is a small Jellyfin library: two series (one in a collection),19// same-named movies, and a playlist.20func library() []jellyfintest.Item {21 return []jellyfintest.Item{22 {ID: "lib-shows", Type: "CollectionFolder", Name: "Shows"},23 {ID: "lib-movies", Type: "CollectionFolder", Name: "Movies"},24 {ID: "fut", Type: "Series", Name: "Futurama", Parent: "lib-shows", Year: 1999, Genres: []string{"Animation"}, Overview: "Pizza delivery in the year 3000.", Image: true},25 {ID: "fut-1", Type: "Season", Name: "Season 1", Parent: "fut"},26 {ID: "fut-1-1", Type: "Episode", Name: "Space Pilot 3000", Parent: "fut-1", Season: 1, Episode: 1, Premiere: "1999-03-28", Runtime: 22 * time.Minute, Image: true, Overview: "Fry is frozen."},27 {ID: "fut-1-2", Type: "Episode", Name: "The Series Has Landed", Parent: "fut-1", Season: 1, Episode: 2, Premiere: "1999-04-04", Runtime: 22 * time.Minute, Silent: true},28 {ID: "fut-1-9", Type: "Episode", Name: "Not Downloaded", Parent: "fut-1", Season: 1, Episode: 9, Missing: true},29 {ID: "btas", Type: "Series", Name: "Batman: The Animated Series", Parent: "lib-shows", Year: 1992},30 {ID: "btas-1-1", Type: "Episode", Name: "On Leather Wings", Parent: "btas", Season: 1, Episode: 1, Runtime: 22 * time.Minute},31 {ID: "thing82", Type: "Movie", Name: "The Thing", Parent: "lib-movies", Year: 1982, Runtime: 109 * time.Minute, Image: true,32 Rating: "R", Genres: []string{"Horror"}, Cast: []string{"Kurt Russell/MacReady", "Wilford Brimley/Blair", "Keith David/"}},33 {ID: "thing11", Type: "Movie", Name: "The Thing", Parent: "lib-movies", Year: 2011, Runtime: 103 * time.Minute},34 {ID: "giant", Type: "Movie", Name: "The Iron Giant", Parent: "lib-movies", Year: 1999, Runtime: 86 * time.Minute},35 {ID: "sat", Type: "BoxSet", Name: "Saturday Morning", Children: []string{"btas", "giant"}},36 {ID: "mix", Type: "Playlist", Name: "Mix", Children: []string{"fut-1-1", "thing82"}},37 {ID: "songs", Type: "Playlist", Name: "Songs", MediaType: "Audio"},38 }39}4041func TestLoginIsSharedAndRenewed(t *testing.T) {42 srv := jellyfintest.New(t, "shared", "hunter2", library()...)43 acct := jellyfin.Account{Server: srv.URL + "/", User: "shared", Password: "hunter2"}44 ctx := context.Background()45 a, b := jellyfin.New(acct, "device-a", nil), jellyfin.New(acct, "device-b", nil)46 if _, err := a.Series(ctx); err != nil {47 t.Fatal(err)48 }49 if _, err := b.Movies(ctx); err != nil {50 t.Fatal(err)51 }52 if n := srv.Logins(); n != 1 {53 t.Errorf("two clients of one account signed in %d times, want once", n)54 }55 srv.Revoke()56 if _, err := b.Collections(ctx); err != nil {57 t.Fatalf("after the token was revoked: %v", err)58 }59 if n := srv.Logins(); n != 2 {60 t.Errorf("signed in %d times, want a second sign-in after the token was revoked", n)61 }62 for _, r := range srv.Requests() {63 if !strings.Contains(r.Authorization, `DeviceId="device-a"`) {64 t.Errorf("%s %s: authorization %q, want the first client's device", r.Method, r.URL, r.Authorization)65 }66 if strings.Contains(r.URL, "session-") || strings.Contains(r.URL, "hunter2") {67 t.Errorf("secret in URL %s", r.URL)68 }69 }70 if h := a.Authorization(); !strings.Contains(h, `Token="session-2"`) || !strings.HasPrefix(h, "MediaBrowser ") {71 t.Errorf("Authorization() = %q", h)72 }73}7475func TestRefusedPasswordIsNotRetried(t *testing.T) {76 srv := jellyfintest.New(t, "careful", "right", library()...)77 c := jellyfin.New(jellyfin.Account{Server: srv.URL, User: "careful", Password: "wrong-pw"}, "dev", nil)78 ctx := context.Background()79 _, err := c.Series(ctx)80 if err == nil || strings.Contains(err.Error(), "wrong-pw") {81 t.Fatalf("err = %v, want a refusal that doesn't repeat the password", err)82 }83 before := time.Now()84 for range 3 {85 if _, err := c.Movies(ctx); err == nil {86 t.Fatal("a refused password worked")87 }88 }89 c.Retry(before.Add(-time.Hour)) // nothing changed since90 _, _ = c.Movies(ctx)91 if n := srv.Logins(); n != 1 {92 t.Fatalf("tried a refused password %d times, want once", n)93 }94 c.Retry(time.Now().Add(time.Second)) // the account file changed95 _, _ = c.Movies(ctx)96 if n := srv.Logins(); n != 2 {97 t.Errorf("after Retry: %d sign-ins, want 2", n)98 }99 if err := c.Login(ctx); err == nil || srv.Logins() != 3 {100 t.Errorf("Login: %v after %d sign-ins, want a third, refused", err, srv.Logins())101 }102}103104func TestAPIKey(t *testing.T) {105 srv := jellyfintest.New(t, "keyed", "pw", library()...)106 srv.APIKey = "api-key-1"107 ctx := context.Background()108 // Without a user, an API key sees the server's libraries.109 c := jellyfin.New(jellyfin.Account{Server: srv.URL, Token: "api-key-1"}, "dev", nil)110 libs, err := c.Libraries(ctx)111 if err != nil || len(libs) != 2 {112 t.Fatalf("libraries = %v, %v", libs, err)113 }114 // With one, its own.115 u := jellyfin.New(jellyfin.Account{Server: srv.URL, User: "KEYED", Token: "api-key-1"}, "dev", nil)116 if _, err := u.Libraries(ctx); err != nil {117 t.Fatal(err)118 }119 reqs := srv.Requests()120 if last := reqs[len(reqs)-1]; !strings.HasPrefix(last.URL, "/UserViews?userId=user-1") {121 t.Errorf("libraries for a user: %s", last.URL)122 }123 if srv.Logins() != 0 {124 t.Error("signed in with a password despite the token")125 }126 bad := jellyfin.New(jellyfin.Account{Server: srv.URL, Token: "revoked-key"}, "dev", nil)127 if _, err := bad.Series(ctx); err == nil || !strings.Contains(err.Error(), "refused the token") {128 t.Errorf("bad token: %v", err)129 }130}131132func TestListings(t *testing.T) {133 srv := jellyfintest.New(t, "lister", "pw", library()...)134 c := jellyfin.New(jellyfin.Account{Server: srv.URL, User: "lister", Password: "pw"}, "dev", nil)135 ctx := context.Background()136 series, err := c.Series(ctx)137 if err != nil {138 t.Fatal(err)139 }140 if len(series) != 2 {141 t.Fatalf("series = %+v", series)142 }143 fut := series[1]144 if fut.Name != "Futurama" || fut.Year != 1999 || fut.Count != 3 || !fut.HasImage ||145 !slices.Equal(fut.Genres, []string{"Animation"}) || fut.Overview == "" || fut.Key() != "Futurama (1999)" {146 t.Errorf("Futurama = %+v, key %q", fut, fut.Key())147 }148 movies, err := c.Movies(ctx)149 if err != nil {150 t.Fatal(err)151 }152 var keys []string153 for _, m := range movies {154 keys = append(keys, m.Key())155 }156 if want := []string{"The Iron Giant (1999)", "The Thing (1982)", "The Thing (2011)"}; !slices.Equal(keys, want) {157 t.Errorf("movies = %q, want %q", keys, want)158 }159 if movies[0].Runtime != 86*time.Minute {160 t.Errorf("runtime = %v", movies[0].Runtime)161 }162 cols, err := c.Collections(ctx)163 if err != nil || len(cols) != 1 || cols[0].Count != 2 || cols[0].Type != "BoxSet" {164 t.Errorf("collections = %+v, %v", cols, err)165 }166 lists, err := c.Playlists(ctx)167 if err != nil || len(lists) != 1 || lists[0].Name != "Mix" {168 t.Errorf("playlists = %+v, %v", lists, err)169 }170 if (jellyfin.Item{Name: "Mix"}).Key() != "Mix" {171 t.Error("Key without a year")172 }173}174175func TestFind(t *testing.T) {176 srv := jellyfintest.New(t, "finder", "pw", library()...)177 c := jellyfin.New(jellyfin.Account{Server: srv.URL, User: "finder", Password: "pw"}, "dev", nil)178 ctx := context.Background()179 found, missing, err := c.Find(ctx, "Movie", []string{"the thing (1982)", "The Iron Giant", "Spider-Man", "THE THING"})180 if err != nil {181 t.Fatal(err)182 }183 var ids []string184 for _, it := range found {185 ids = append(ids, it.ID)186 }187 if want := []string{"thing82", "giant", "thing82", "thing11"}; !slices.Equal(ids, want) {188 t.Errorf("found %q, want %q", ids, want)189 }190 if !slices.Equal(missing, []string{"Spider-Man"}) {191 t.Errorf("unmatched = %q", missing)192 }193 found, missing, err = c.Find(ctx, "Library", []string{"movies", "Music"})194 if err != nil || len(found) != 1 || found[0].ID != "lib-movies" || !slices.Equal(missing, []string{"Music"}) {195 t.Errorf("libraries: %+v %q %v", found, missing, err)196 }197}198199func TestVideos(t *testing.T) {200 srv := jellyfintest.New(t, "viewer", "pw", library()...)201 c := jellyfin.New(jellyfin.Account{Server: srv.URL, User: "viewer", Password: "pw"}, "dev", nil)202 ctx := context.Background()203 from := []jellyfin.Item{204 {ID: "sat", Type: "BoxSet"}, // a series and a movie205 {ID: "mix", Type: "Playlist"}, // an episode and a movie206 {ID: "fut", Type: "Series"}, // overlaps the playlist207 {ID: "thing11", Type: "Movie"}, // by itself208 {ID: "giant", Type: "Movie"}, // again209 {ID: "lib-none", Type: "Library"},210 }211 vs, err := c.Videos(ctx, from)212 if err != nil {213 t.Fatal(err)214 }215 got := map[string]jellyfin.Video{}216 var ids []string217 for _, v := range vs {218 got[v.ID] = v219 ids = append(ids, v.ID)220 }221 slices.Sort(ids)222 if want := []string{"btas-1-1", "fut-1-1", "fut-1-2", "giant", "thing11", "thing82"}; !slices.Equal(ids, want) {223 t.Fatalf("videos %q, want %q", ids, want)224 }225 ep := got["fut-1-1"]226 if ep.Series != "Futurama" || ep.SeriesID != "fut" || ep.Season != 1 || ep.Episode != 1 || ep.Premiere != "1999-03-28" ||227 ep.Runtime != 22*time.Minute || !ep.Audio || ep.Source != "src-fut-1-1" || ep.Overview != "Fry is frozen." {228 t.Errorf("episode = %+v", ep)229 }230 if ep.Image != srv.URL+"/Items/fut-1-1/Images/Primary?maxWidth=640&tag=tag-fut-1-1" {231 t.Errorf("episode image = %q", ep.Image)232 }233 if v := got["fut-1-2"]; v.Audio || !strings.Contains(v.Image, "/Items/fut/Images/Primary") {234 t.Errorf("silent episode without a still = %+v", v)235 }236 if v := got["thing82"]; v.Type != "Movie" || v.Year != 1982 || !strings.Contains(v.Image, "/Items/thing82/Images/Primary") ||237 v.Rating != "R" || !slices.Equal(v.Genres, []string{"Horror"}) ||238 !slices.Equal(v.Cast, []string{"Kurt Russell (MacReady)", "Wilford Brimley (Blair)", "Keith David"}) {239 t.Errorf("movie = %+v", v)240 }241 if v := got["giant"]; v.Image != "" {242 t.Errorf("movie without art has image %q", v.Image)243 }244}245246func TestStreamURLs(t *testing.T) {247 c := jellyfin.New(jellyfin.Account{Server: "jellyfin.example.com/", User: "u", Password: "p"}, "dev", nil)248 v := jellyfin.Video{ID: "abc", Source: "src-abc"}249 if u := c.FileURL(v); u != "https://jellyfin.example.com/Videos/abc/stream?mediaSourceId=src-abc&static=true" {250 t.Errorf("FileURL = %s", u)251 }252 u := c.TranscodeURL(v, 90*time.Second, jellyfin.Transcode{Bitrate: 4_000_000, MaxWidth: 1280, MaxHeight: 720})253 for _, want := range []string{254 "https://jellyfin.example.com/Videos/abc/stream.ts?", "mediaSourceId=src-abc", "startTimeTicks=900000000",255 "videoCodec=h264", "audioCodec=aac", "videoBitRate=3872000", "audioBitRate=128000", "maxWidth=1280", "maxHeight=720",256 } {257 if !strings.Contains(u, want) {258 t.Errorf("TranscodeURL lacks %q: %s", want, u)259 }260 }261 if u := c.TranscodeURL(v, 0, jellyfin.Transcode{Bitrate: 2_000_000}); strings.Contains(u, "startTimeTicks") {262 t.Errorf("from the start: %s", u)263 }264}265266func TestImage(t *testing.T) {267 srv := jellyfintest.New(t, "pictures", "pw", library()...)268 c := jellyfin.New(jellyfin.Account{Server: srv.URL, User: "pictures", Password: "pw"}, "dev", nil)269 b, typ, err := c.Image(context.Background(), "fut", 300)270 if err != nil || typ != "image/jpeg" || string(b) != "\xff\xd8\xff\xe0jpeg fut Primary 300" {271 t.Errorf("image = %q, %q, %v", b, typ, err)272 }273 if _, _, err := c.Image(context.Background(), "btas", 300); err == nil {274 t.Error("an item without art gave an image")275 }276}277278func TestQuickConnect(t *testing.T) {279 srv := jellyfintest.New(t, "coded", "unused", library()...)280 ctx := context.Background()281 if on, err := jellyfin.QuickConnectEnabled(ctx, srv.URL, nil); err != nil || on {282 t.Errorf("enabled = %v, %v; want off", on, err)283 }284 if _, err := jellyfin.StartQuickConnect(ctx, srv.URL, "airwaves-admin", nil); err == nil {285 t.Error("started Quick Connect while it's off")286 }287 srv.QuickConnect = true288 if on, err := jellyfin.QuickConnectEnabled(ctx, srv.URL, nil); err != nil || !on {289 t.Errorf("enabled = %v, %v; want on", on, err)290 }291 qc, err := jellyfin.StartQuickConnect(ctx, srv.URL, "airwaves-admin", nil)292 if err != nil || qc.Code == "" || qc.Secret == "" {293 t.Fatalf("start = %+v, %v", qc, err)294 }295 if ok, err := jellyfin.CheckQuickConnect(ctx, srv.URL, qc.Secret, nil); ok || err != nil {296 t.Errorf("before approval: %v, %v", ok, err)297 }298 if _, err := jellyfin.FinishQuickConnect(ctx, srv.URL, qc.Secret, "airwaves-admin", nil); err == nil {299 t.Error("finished before approval")300 }301 if !srv.Approve(qc.Code) {302 t.Fatal("unknown code")303 }304 if ok, err := jellyfin.CheckQuickConnect(ctx, srv.URL, qc.Secret, nil); !ok || err != nil {305 t.Errorf("after approval: %v, %v", ok, err)306 }307 acct, err := jellyfin.FinishQuickConnect(ctx, srv.URL, qc.Secret, "airwaves-admin", nil)308 if err != nil {309 t.Fatal(err)310 }311 if acct.Server != srv.URL || acct.User != "coded" || acct.UserID != "user-1" || acct.Token == "" || acct.DeviceID != "airwaves-admin" || acct.Password != "" {312 t.Errorf("account = %+v", acct)313 }314 // The token works from the device it was issued to, whatever device the315 // client would otherwise be, without a password sign-in.316 c := jellyfin.New(acct, "airwavesd-other", nil)317 if _, err := c.Libraries(ctx); err != nil {318 t.Fatal(err)319 }320 if srv.Logins() != 0 {321 t.Error("signed in with a password")322 }323 for _, r := range srv.Requests() {324 if strings.HasPrefix(r.URL, "/Users/Me") {325 t.Error("looked up the user Quick Connect named")326 }327 }328 if _, err := jellyfin.CheckQuickConnect(ctx, srv.URL, "no-such-secret", nil); err == nil || !strings.Contains(err.Error(), "expired") {329 t.Errorf("unknown secret: %v", err)330 }331}332333func TestErrorsLeaveSecretsOut(t *testing.T) {334 srv := jellyfintest.New(t, "x", "y")335 url := srv.URL336 srv.Close()337 ctx := context.Background()338 _, err := jellyfin.CheckQuickConnect(ctx, url, "the-secret", nil)339 if err == nil || strings.Contains(err.Error(), "the-secret") {340 t.Errorf("err = %v", err)341 }342 c := jellyfin.New(jellyfin.Account{Server: url, User: "x", Password: "pa55word"}, "dev", nil)343 if _, err := c.Series(ctx); err == nil || strings.Contains(err.Error(), "pa55word") {344 t.Errorf("err = %v", err)345 }346}347348func TestLoadAccount(t *testing.T) {349 dir := t.TempDir()350 path := filepath.Join(dir, ".jellyfin.json")351 if _, err := jellyfin.LoadAccount(path); !errors.Is(err, fs.ErrNotExist) {352 t.Errorf("missing file: %v", err)353 }354 if err := os.WriteFile(path, []byte(`{"server": "https://jf.example.com", "user": "viewer", "password": "pw"}`), 0o600); err != nil {355 t.Fatal(err)356 }357 a, err := jellyfin.LoadAccount(path)358 if err != nil || a != (jellyfin.Account{Server: "https://jf.example.com", User: "viewer", Password: "pw"}) {359 t.Errorf("account = %+v, %v", a, err)360 }361}362363func TestStreamsAndSubtitles(t *testing.T) {364 const full = "1\n00:00:01,000 --> 00:00:02,000\nHello.\n"365 srv := jellyfintest.New(t, "subs", "pw",366 jellyfintest.Item{ID: "lib", Type: "CollectionFolder", Name: "Movies"},367 jellyfintest.Item{ID: "akira", Type: "Movie", Name: "Akira", Parent: "lib", Runtime: 124 * time.Minute, Streams: []jellyfintest.Stream{368 {Type: "Audio", Language: "jpn", Codec: "flac", Default: true},369 {Type: "Audio", Language: "eng", Codec: "ac3", Title: "Commentary"},370 {Type: "Audio", Language: "eng", Codec: "ac3", Title: "English Dub"},371 {Type: "Subtitle", Language: "eng", Codec: "pgssub"},372 {Type: "Subtitle", Language: "eng", Codec: "subrip", Forced: true, SRT: "forced"},373 {Type: "Subtitle", Language: "eng", Codec: "subrip", Title: "English SDH", SRT: "sdh"},374 {Type: "Subtitle", Language: "fre", Codec: "subrip", SRT: "français"},375 {Type: "Subtitle", Language: "eng", Codec: "subrip", External: true, SRT: full},376 }},377 )378 c := jellyfin.New(jellyfin.Account{Server: srv.URL, User: "subs", Password: "pw"}, "dev", nil)379 ctx := context.Background()380 vs, err := c.Videos(ctx, []jellyfin.Item{{ID: "akira", Type: "Movie"}})381 if err != nil || len(vs) != 1 {382 t.Fatalf("videos = %+v, %v", vs, err)383 }384 v := vs[0]385 if len(v.AudioStreams) != 3 || len(v.Subtitles) != 5 {386 t.Fatalf("audio %+v, subtitles %+v", v.AudioStreams, v.Subtitles)387 }388 if a := v.AudioStreams[2]; a.Index != 3 || a.Position != 2 || a.Language != "eng" || a.Title != "English Dub" {389 t.Errorf("third audio stream = %+v", a)390 }391 if s := v.Subtitles[0]; s.Text || s.Position != 0 {392 t.Errorf("PGS subtitle = %+v", s)393 }394 if s := v.Subtitles[1]; !s.Forced || !s.Text || s.Position != 1 {395 t.Errorf("forced subtitle = %+v", s)396 }397 if s := v.Subtitles[4]; !s.External || s.Position != -1 || s.Index != 8 {398 t.Errorf("external subtitle = %+v", s)399 }400 if a, ok := v.PreferredAudio(); !ok || a.Index != 3 {401 t.Errorf("preferred audio = %+v, %v; want the English dub", a, ok)402 }403 sub, ok := v.EnglishSubtitle(true)404 if !ok || sub.Index != 8 {405 t.Fatalf("subtitle = %+v, %v; want the full English one", sub, ok)406 }407 b, err := c.Subtitle(ctx, v, sub)408 if err != nil || string(b) != full {409 t.Errorf("subtitle = %q, %v", b, err)410 }411 reqs := srv.Requests()412 last := reqs[len(reqs)-1]413 if last.URL != "/Videos/akira/src-akira/Subtitles/8/0/Stream.srt" || !strings.Contains(last.Authorization, `Token="session-`) {414 t.Errorf("fetched %s with %q", last.URL, last.Authorization)415 }416 if _, err := c.Subtitle(ctx, v, v.Subtitles[0]); err == nil {417 t.Error("fetched a picture subtitle as text")418 }419 if _, err := c.Subtitle(ctx, v, jellyfin.Stream{Index: 99, Text: true}); err == nil {420 t.Error("fetched a subtitle that isn't there")421 }422 if u := c.TranscodeURL(v, 0, jellyfin.Transcode{Bitrate: 4_000_000}); !strings.Contains(u, "audioStreamIndex=3") {423 t.Errorf("transcode doesn't pick the English sound: %s", u)424 }425}426427func TestPreferredAudio(t *testing.T) {428 audio := func(list ...jellyfin.Stream) jellyfin.Video { return jellyfin.Video{AudioStreams: list} }429 for _, c := range []struct {430 name string431 v jellyfin.Video432 want int // Index; -1 for none433 }{434 {"none", audio(), -1},435 {"one", audio(jellyfin.Stream{Index: 1, Language: "jpn"}), 1},436 {"English over the default", audio(jellyfin.Stream{Index: 1, Language: "jpn", Default: true}, jellyfin.Stream{Index: 2, Language: "eng"}), 2},437 {"default English", audio(jellyfin.Stream{Index: 1, Language: "en"}, jellyfin.Stream{Index: 2, Language: "eng", Default: true}), 2},438 {"no English: the default", audio(jellyfin.Stream{Index: 1, Language: "spa"}, jellyfin.Stream{Index: 2, Language: "fre", Default: true}), 2},439 {"no English or default: the first", audio(jellyfin.Stream{Index: 1, Language: "spa"}, jellyfin.Stream{Index: 2, Language: "fre"}), 1},440 {"not the English commentary", audio(jellyfin.Stream{Index: 1, Language: "jpn", Default: true}, jellyfin.Stream{Index: 2, Language: "eng", Title: "Director's Commentary"}), 1},441 {"not described video", audio(jellyfin.Stream{Index: 1, Language: "eng", Title: "Audio Description"}, jellyfin.Stream{Index: 2, Language: "eng"}), 2},442 } {443 a, ok := c.v.PreferredAudio()444 if got := map[bool]int{true: a.Index, false: -1}[ok]; got != c.want {445 t.Errorf("%s: stream %d, want %d", c.name, got, c.want)446 }447 }448}449450func TestEnglishSubtitle(t *testing.T) {451 sub := func(i int, lang string, f func(*jellyfin.Stream)) jellyfin.Stream {452 s := jellyfin.Stream{Index: i, Language: lang, Codec: "subrip", Text: true}453 if f != nil {454 f(&s)455 }456 return s457 }458 forced := func(s *jellyfin.Stream) { s.Forced = true }459 sdh := func(s *jellyfin.Stream) { s.HearingImpaired = true }460 picture := func(s *jellyfin.Stream) { s.Text, s.Codec = false, "pgssub" }461 for _, c := range []struct {462 name string463 subs []jellyfin.Stream464 english int // pick for English sound, Index or -1465 other int // for sound in another language466 }{467 {"none", nil, -1, -1},468 {"full over SDH", []jellyfin.Stream{sub(1, "eng", sdh), sub(2, "eng", nil)}, 2, 2},469 {"SDH by title", []jellyfin.Stream{sub(1, "eng", func(s *jellyfin.Stream) { s.Title = "English (SDH)" }), sub(2, "eng", nil)}, 2, 2},470 {"SDH when that's all", []jellyfin.Stream{sub(1, "fre", nil), sub(2, "eng", sdh)}, 2, 2},471 {"forced only for other sound", []jellyfin.Stream{sub(1, "eng", forced)}, -1, 1},472 {"forced by title", []jellyfin.Stream{sub(1, "eng", func(s *jellyfin.Stream) { s.Title = "Signs & Songs" }), sub(2, "spa", nil)}, -1, 1},473 {"full over forced", []jellyfin.Stream{sub(1, "eng", forced), sub(2, "eng", nil)}, 2, 2},474 {"no pictures", []jellyfin.Stream{sub(1, "eng", picture)}, -1, -1},475 {"untagged", []jellyfin.Stream{sub(1, "", nil), sub(2, "ger", nil)}, 1, 1},476 {"English over untagged", []jellyfin.Stream{sub(1, "und", nil), sub(2, "en", sdh)}, 2, 2},477 {"the default first", []jellyfin.Stream{sub(1, "eng", nil), sub(2, "eng", func(s *jellyfin.Stream) { s.Default = true })}, 2, 2},478 } {479 v := jellyfin.Video{Subtitles: c.subs}480 for _, f := range []struct {481 forced bool482 want int483 }{{false, c.english}, {true, c.other}} {484 s, ok := v.EnglishSubtitle(f.forced)485 if got := map[bool]int{true: s.Index, false: -1}[ok]; got != f.want {486 t.Errorf("%s (forced %v): subtitle %d, want %d", c.name, f.forced, got, f.want)487 }488 }489 }490}