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 / vchan / jellyfin_test.go
jellyfin_test.go 20.0K
1package vchan23import (4 "bytes"5 "cmp"6 "context"7 "encoding/json"8 "fmt"9 "log"10 "os"11 "os/exec"12 "path/filepath"13 "reflect"14 "slices"15 "strings"16 "sync"17 "testing"18 "time"1920 "airwaves/internal/jellyfin"21 "airwaves/internal/jellyfin/jellyfintest"22)2324func jellyfinLibrary() []jellyfintest.Item {25 return []jellyfintest.Item{26 {ID: "lib-shows", Type: "CollectionFolder", Name: "Shows"},27 {ID: "fut", Type: "Series", Name: "Futurama", Parent: "lib-shows", Year: 1999, Image: true},28 {ID: "fut-1", Type: "Season", Name: "Season 1", Parent: "fut"},29 {ID: "fut-1-1", Type: "Episode", Name: "Space Pilot 3000", Parent: "fut-1", Season: 1, Episode: 1, Premiere: "1999-03-28",30 Runtime: 22 * time.Minute, Image: true, Overview: "Fry is frozen\nfor a thousand years.\n\nSpoilers follow."},31 {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},32 {ID: "fut-1-9", Type: "Episode", Name: "Not Downloaded", Parent: "fut-1", Season: 1, Episode: 9, Missing: true},33 {ID: "btas", Type: "Series", Name: "Batman: The Animated Series", Parent: "lib-shows", Year: 1992},34 {ID: "btas-1-1", Type: "Episode", Name: "On Leather Wings", Parent: "btas", Season: 1, Episode: 1, Premiere: "1992-09-06", Runtime: 22 * time.Minute},35 {ID: "btas-1-2", Type: "Episode", Name: "Christmas with the Joker", Parent: "btas", Season: 1, Episode: 2, Premiere: "1992-11-13", Runtime: 22 * time.Minute},36 {ID: "btas-sp", Type: "Episode", Name: "Behind the Cowl", Parent: "btas", Premiere: "1993-01-01", Runtime: 22 * time.Minute},37 {ID: "giant", Type: "Movie", Name: "The Iron Giant", Year: 1999, Premiere: "1999-08-06", Runtime: 86 * time.Minute, Image: true},38 {ID: "thing82", Type: "Movie", Name: "The Thing", Year: 1982, Runtime: 109 * time.Minute},39 {ID: "thing11", Type: "Movie", Name: "The Thing", Year: 2011, Runtime: 103 * time.Minute},40 {ID: "bumper", Type: "Movie", Name: "Bumper", Runtime: 500 * time.Millisecond},41 {ID: "sat", Type: "BoxSet", Name: "Saturday Morning", Children: []string{"btas", "giant", "bumper"}},42 }43}4445func writeFile(t *testing.T, path, body string) {46 t.Helper()47 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {48 t.Fatal(err)49 }50 if err := os.WriteFile(path, []byte(body), 0o600); err != nil {51 t.Fatal(err)52 }53}5455// jellyfinChannel writes a channel folder ("1.5 Cartoons") holding config56// as jellyfin.json, as the library would find it.57func newJellyfinChannel(t *testing.T, root, folder, config string) *Jellyfin {58 path := filepath.Join(root, folder, jellyfinConfig)59 writeFile(t, path, config)60 num, name, _ := strings.Cut(folder, " ")61 return &Jellyfin{Num: num, Title: name, Config: path, FFmpeg: "ffmpeg"}62}6364// captureLogs collects log output for the rest of the test.65func captureLogs(t *testing.T) *lockedBuffer {66 b := &lockedBuffer{}67 log.SetOutput(b)68 t.Cleanup(func() { log.SetOutput(os.Stderr) })69 return b70}7172type lockedBuffer struct {73 mu sync.Mutex74 b bytes.Buffer75}7677func (l *lockedBuffer) Write(p []byte) (int, error) {78 l.mu.Lock()79 defer l.mu.Unlock()80 return l.b.Write(p)81}8283func (l *lockedBuffer) String() string {84 l.mu.Lock()85 defer l.mu.Unlock()86 return l.b.String()87}8889func TestJellyfinChannelResolvesNames(t *testing.T) {90 logs := captureLogs(t)91 srv := jellyfintest.New(t, "viewer", "s3cret-pw", jellyfinLibrary()...)92 root := t.TempDir()93 writeFile(t, filepath.Join(root, jellyfinAccount), fmt.Sprintf(`{"server": %q, "user": "viewer", "password": "s3cret-pw"}`, srv.URL))94 cartoons := newJellyfinChannel(t, root, "1.5 Saturday Cartoons", `{95 "collections": ["saturday morning"],96 "series": ["Futurama (1999)", "Nope Show"],97 "movies": ["The Thing (1982)", "Spider-Man"]98 }`)99 if cartoons.Empty() {100 t.Fatal("channel reads as empty")101 }102 // Batman's three, the Iron Giant (not the one-second bumper), Futurama's103 // two on file and the 1982 Thing.104 n, unmatched, err := cartoons.Status()105 if n != 7 || err != nil || !slices.Equal(unmatched, []string{"Nope Show", "Spider-Man"}) {106 t.Errorf("status = %d videos, unmatched %q, %v", n, unmatched, err)107 }108 for _, want := range []string{`no series named "Nope Show"`, `no movie named "Spider-Man"`} {109 if !strings.Contains(logs.String(), want) {110 t.Errorf("logs lack %q:\n%s", want, logs)111 }112 }113114 from := time.Now()115 progs := cartoons.Programs(from, from.Add(6*time.Hour))116 titles := map[string]bool{}117 for _, p := range progs {118 titles[p.Title] = true119 }120 for _, want := range []string{"Futurama", "Batman: The Animated Series", "The Iron Giant", "The Thing"} {121 if !titles[want] {122 t.Errorf("guide lacks %q: %v", want, titles)123 }124 }125126 // The same videos keep the same schedule.127 cartoons.refresh()128 if again := cartoons.Programs(from, from.Add(6*time.Hour)); !reflect.DeepEqual(progs, again) {129 t.Error("schedule changed on refresh")130 }131132 // Another channel on the account shares the sign-in.133 movies := newJellyfinChannel(t, root, "1.6 Movies", `{"movies": ["the thing"]}`)134 if n, _, err := movies.Status(); movies.Empty() || err != nil {135 t.Errorf("movies channel: %d, %v", n, err)136 }137 if n := srv.Logins(); n != 1 {138 t.Errorf("signed in %d times for two channels, want once", n)139 }140 if strings.Contains(logs.String(), "s3cret-pw") {141 t.Errorf("password in logs:\n%s", logs)142 }143}144145func TestLibraryFindsJellyfinChannels(t *testing.T) {146 srv := jellyfintest.New(t, "lineup", "pw", jellyfinLibrary()...)147 root := t.TempDir()148 writeFile(t, filepath.Join(root, jellyfinAccount), fmt.Sprintf(`{"server": %q, "user": "lineup", "password": "pw"}`, srv.URL))149 writeFile(t, filepath.Join(root, "1.5 Cartoons", jellyfinConfig), `{"series": ["Futurama"]}`)150 writeFile(t, filepath.Join(root, "1.6 Nothing", jellyfinConfig), `{"series": ["Nope"]}`)151 l := &Library{Root: root}152 var got []string153 for _, c := range l.Channels() {154 got = append(got, c.Number()+" "+c.Name())155 }156 if !slices.Equal(got, []string{"1.5 Cartoons"}) {157 t.Errorf("lineup = %q", got)158 }159 if n := srv.Logins(); n != 1 {160 t.Errorf("signed in %d times, want once", n)161 }162}163164func TestJellyfinChannelReauthenticates(t *testing.T) {165 srv := jellyfintest.New(t, "renew", "pw", jellyfinLibrary()...)166 root := t.TempDir()167 j := newJellyfinChannel(t, root, "1.5 Cartoons", fmt.Sprintf(`{"server": %q, "user": "renew", "password": "pw", "series": ["Futurama"]}`, srv.URL))168 if j.Empty() {169 t.Fatal("empty")170 }171 srv.Revoke()172 j.refresh()173 if n, _, err := j.Status(); n != 2 || err != nil {174 t.Errorf("after the token was revoked: %d videos, %v", n, err)175 }176 if n := srv.Logins(); n != 2 {177 t.Errorf("signed in %d times, want twice", n)178 }179}180181func TestJellyfinGuide(t *testing.T) {182 srv := jellyfintest.New(t, "guide", "pw", jellyfinLibrary()...)183 root := t.TempDir()184 j := newJellyfinChannel(t, root, "1.5 Cartoons", fmt.Sprintf(`{"server": %q, "user": "guide", "password": "pw",185 "series": ["Futurama"], "movies": ["The Iron Giant"], "order": "aired"}`, srv.URL))186 from := time.Now()187 progs := j.Programs(from, from.Add(5*time.Hour))188 find := func(title, subtitle string) Program {189 for _, p := range progs {190 if p.Title == title && p.Subtitle == subtitle {191 return p192 }193 }194 t.Fatalf("no %q %q in %+v", title, subtitle, progs)195 return Program{}196 }197 ep := find("Futurama", "Space Pilot 3000")198 if ep.Season != "1" || ep.Episode != "1" || ep.Description != "Fry is frozen for a thousand years." ||199 ep.Image != srv.URL+"/Items/fut-1-1/Images/Primary?maxWidth=640&tag=tag-fut-1-1" || ep.End.Sub(ep.Start) != 22*time.Minute {200 t.Errorf("episode = %+v", ep)201 }202 // An episode without a still shows its series' poster.203 if p := find("Futurama", "The Series Has Landed"); p.Image != srv.URL+"/Items/fut/Images/Primary?maxWidth=640&tag=tag-fut" {204 t.Errorf("episode without a still: %q", p.Image)205 }206 mv := find("The Iron Giant", "")207 if mv.Category != "Movie" || mv.Season != "" || mv.End.Sub(mv.Start) != 86*time.Minute {208 t.Errorf("movie = %+v", mv)209 }210 // Aired order: the 1999 season, then the movie from that August.211 for i := range progs {212 if progs[i].Subtitle == "Space Pilot 3000" && i+2 < len(progs) {213 if progs[i+1].Subtitle != "The Series Has Landed" || progs[i+2].Title != "The Iron Giant" {214 t.Errorf("order after the pilot: %q, %q", progs[i+1].Subtitle, progs[i+2].Title)215 }216 break217 }218 }219 for _, p := range progs {220 if s := fmt.Sprint(p); strings.Contains(s, "session-") {221 t.Errorf("secret in guide: %s", s)222 }223 }224}225226func TestJellyfinShuffle(t *testing.T) {227 var vs []jellyfin.Video228 for i := range 30 {229 vs = append(vs, jellyfin.Video{ID: fmt.Sprintf("v%02d", i)})230 }231 ids := func(vs []jellyfin.Video) []string {232 var out []string233 for _, v := range vs {234 out = append(out, v.ID)235 }236 return out237 }238 a := slices.Clone(vs)239 shuffle(a, "1.5\x00Cartoons")240 b := slices.Clone(vs)241 slices.Reverse(b)242 shuffle(b, "1.5\x00Cartoons")243 if !slices.Equal(ids(a), ids(b)) {244 t.Error("same videos, different order")245 }246 if slices.Equal(ids(a), ids(vs)) {247 t.Error("not shuffled")248 }249 c := slices.Clone(vs)250 shuffle(c, "1.6\x00Movies")251 if slices.Equal(ids(a), ids(c)) {252 t.Error("two channels share an order")253 }254 // A new video goes in somewhere; the rest keep their order.255 d := append(slices.Clone(vs), jellyfin.Video{ID: "new"})256 shuffle(d, "1.5\x00Cartoons")257 if got := slices.DeleteFunc(ids(d), func(id string) bool { return id == "new" }); !slices.Equal(got, ids(a)) {258 t.Error("adding a video reordered the others")259 }260}261262func TestJellyfinAiredOrder(t *testing.T) {263 vs := []jellyfin.Video{264 {ID: "fut-1-2", Type: "Episode", SeriesID: "fut", Season: 1, Episode: 2, Premiere: "1999-04-04"},265 {ID: "giant", Type: "Movie", Premiere: "1999-08-06"},266 {ID: "undated", Type: "Movie"},267 {ID: "btas-special", Type: "Episode", SeriesID: "btas", Premiere: "1993-01-01"},268 {ID: "fut-1-1", Type: "Episode", SeriesID: "fut", Season: 1, Episode: 1, Premiere: "1999-03-28"},269 {ID: "btas-2-1", Type: "Episode", SeriesID: "btas", Season: 2, Episode: 1, Premiere: "1994-05-01"},270 {ID: "btas-1-2", Type: "Episode", SeriesID: "btas", Season: 1, Episode: 2, Premiere: "1992-11-13"},271 {ID: "btas-1-1", Type: "Episode", SeriesID: "btas", Season: 1, Episode: 1, Premiere: "1992-09-06"},272 {ID: "metropolis", Type: "Movie", Year: 1927},273 }274 airedOrder(vs)275 var got []string276 for _, v := range vs {277 got = append(got, v.ID)278 }279 want := []string{"metropolis", "btas-1-1", "btas-1-2", "btas-2-1", "btas-special", "fut-1-1", "fut-1-2", "giant", "undated"}280 if !slices.Equal(got, want) {281 t.Errorf("got %q\nwant %q", got, want)282 }283}284285func TestJellyfinInput(t *testing.T) {286 srv := jellyfintest.New(t, "input", "pw", jellyfinLibrary()...)287 c := jellyfin.New(jellyfin.Account{Server: srv.URL, User: "input", Password: "pw"}, "dev", nil)288 if _, err := c.Series(context.Background()); err != nil {289 t.Fatal(err)290 }291 v := jellyfin.Video{ID: "fut-1-1", Source: "src-fut-1-1", Type: "Episode", Series: "Futurama", Runtime: 22 * time.Minute, Audio: true}292 if _, ok := jellyfinItem(c, jellyfin.Video{ID: "x", Runtime: 900 * time.Millisecond}, 0); ok {293 t.Error("scheduled a video under a second")294 }295 at := func(args []string, s string) int { return slices.Index(args, s) }296 for _, mbps := range []float64{0, 4} {297 it, ok := jellyfinItem(c, v, mbps)298 if !ok {299 t.Fatal("episode not scheduled")300 }301 args := decodeArgs(it, 90*loopFPS) // 90 s in302 in := at(args, "-i")303 h := at(args, "-headers")304 // Not read in real time: the stream is paced on its way to the305 // encoder.306 if in < 1 || slices.Contains(args, "-re") || h < 0 || h > in {307 t.Fatalf("maxBitrate %v: args %q", mbps, args)308 }309 if hdr := args[h+1]; !strings.HasPrefix(hdr, "Authorization: MediaBrowser ") || !strings.Contains(hdr, `Token="session-1"`) || !strings.HasSuffix(hdr, "\r\n") {310 t.Errorf("maxBitrate %v: header %q", mbps, hdr)311 }312 u := args[in+1]313 if strings.Contains(u, "session-") || strings.Contains(u, "Token") || strings.Contains(u, "api_key") {314 t.Errorf("maxBitrate %v: token in URL %s", mbps, u)315 }316 if !slices.Contains(args, "-reconnect") {317 t.Errorf("maxBitrate %v: no reconnect", mbps)318 }319 ss := at(args, "-ss")320 switch mbps {321 case 0:322 if ss < 0 || ss > in || args[ss+1] != "90.000" || !strings.Contains(u, "/Videos/fut-1-1/stream?") || !strings.Contains(u, "static=true") {323 t.Errorf("original file: seek %d, URL %s", ss, u)324 }325 default:326 if ss >= 0 || !strings.Contains(u, "startTimeTicks=900000000") || !strings.Contains(u, "videoBitRate=3872000") || !strings.Contains(u, "maxHeight=720") {327 t.Errorf("transcode: seek %d, URL %s", ss, u)328 }329 }330 if from0 := decodeArgs(it, 0); slices.Contains(from0, "-ss") || strings.Contains(from0[at(from0, "-i")+1], "startTimeTicks") {331 t.Errorf("maxBitrate %v: seeks from the start: %q", mbps, from0)332 }333 }334}335336func TestJellyfinAccountFile(t *testing.T) {337 logs := captureLogs(t)338 srv := jellyfintest.New(t, "friend", "right-pw", jellyfinLibrary()...)339 root := t.TempDir()340 account := filepath.Join(root, jellyfinAccount)341 writeFile(t, account, fmt.Sprintf(`{"server": %q, "user": "friend", "password": "wrong-pw"}`, srv.URL))342 j := newJellyfinChannel(t, root, "1.5 Cartoons", `{"series": ["Futurama"]}`)343 if !j.Empty() {344 t.Fatal("played with a refused password")345 }346 _, _, err := j.Status()347 if err == nil || !strings.Contains(err.Error(), "refused") {348 t.Fatalf("status error = %v", err)349 }350351 // A channel's own account wins over the shared one.352 own := newJellyfinChannel(t, root, "1.6 Own", fmt.Sprintf(`{"server": %q, "user": "friend", "password": "right-pw", "series": ["Futurama"]}`, srv.URL))353 if own.Empty() {354 t.Error("the channel's own account wasn't used")355 }356357 // Fixing the account file is noticed without the channel changing.358 writeFile(t, account, fmt.Sprintf(`{"server": %q, "user": "friend", "password": "right-pw"}`, srv.URL))359 later := time.Now().Add(time.Minute)360 if err := os.Chtimes(account, later, later); err != nil {361 t.Fatal(err)362 }363 j.mu.Lock()364 j.checked = time.Time{} // as if rescanEvery had passed365 j.mu.Unlock()366 deadline := time.Now().Add(5 * time.Second)367 for {368 n, _, err := j.Status()369 if n == 2 && err == nil {370 break371 }372 if time.Now().After(deadline) {373 t.Fatalf("account change not picked up: %d videos, %v", n, err)374 }375 time.Sleep(20 * time.Millisecond)376 }377 if strings.Contains(logs.String(), "wrong-pw") || strings.Contains(logs.String(), "right-pw") {378 t.Errorf("password in logs:\n%s", logs)379 }380}381382func TestJellyfinQuickConnectAccount(t *testing.T) {383 srv := jellyfintest.New(t, "coded", "unused", jellyfinLibrary()...)384 srv.QuickConnect = true385 ctx := context.Background()386 qc, err := jellyfin.StartQuickConnect(ctx, srv.URL, "airwaves-admin", nil)387 if err != nil || !srv.Approve(qc.Code) {388 t.Fatalf("quick connect: %v", err)389 }390 acct, err := jellyfin.FinishQuickConnect(ctx, srv.URL, qc.Secret, "airwaves-admin", nil)391 if err != nil {392 t.Fatal(err)393 }394 raw, _ := json.Marshal(acct)395 root := t.TempDir()396 writeFile(t, filepath.Join(root, jellyfinAccount), string(raw))397 // The fake server only takes the token from the device it was issued to.398 j := newJellyfinChannel(t, root, "1.5 Cartoons", `{"series": ["Futurama"]}`)399 if n, _, err := j.Status(); j.Empty() || err != nil {400 t.Errorf("with a Quick Connect account: %d videos, %v", n, err)401 }402 if srv.Logins() != 0 {403 t.Error("signed in with a password")404 }405}406407func TestJellyfinBadConfig(t *testing.T) {408 root := t.TempDir()409 srv := jellyfintest.New(t, "x", "y")410 url := srv.URL411 srv.Close()412 for config, want := range map[string]string{413 `{"series": ["Futurama"]}`: "no Jellyfin server",414 fmt.Sprintf(`{"server": %q, "user": "x", "password": "hidden-pw", "order": "random"}`, url): `order "random"`,415 fmt.Sprintf(`{"server": %q, "user": "x", "password": "hidden-pw", "series": ["Futurama"]}`, url): "jellyfin: ",416 `{"series": [`: "unexpected end",417 } {418 j := newJellyfinChannel(t, root, "1.5 Bad", config)419 if !j.Empty() {420 t.Errorf("%s: not empty", config)421 }422 _, _, err := j.Status()423 if err == nil || !strings.Contains(err.Error(), want) || strings.Contains(err.Error(), "hidden-pw") {424 t.Errorf("%s: err = %v, want %q", config, err, want)425 }426 }427}428429// TestJellyfinDemo plays from Jellyfin's public demo server in both modes.430// It needs the internet, so it only runs with AIRWAVES_JELLYFIN_DEMO=1.431func TestJellyfinDemo(t *testing.T) {432 if os.Getenv("AIRWAVES_JELLYFIN_DEMO") == "" {433 t.Skip("set AIRWAVES_JELLYFIN_DEMO=1 to play from demo.jellyfin.org")434 }435 ffmpeg, err := exec.LookPath("ffmpeg")436 if err != nil {437 t.Skip("no ffmpeg")438 }439 ffprobe, err := exec.LookPath("ffprobe")440 if err != nil {441 t.Skip("no ffprobe")442 }443 root := t.TempDir()444 writeFile(t, filepath.Join(root, jellyfinAccount), `{"server": "https://demo.jellyfin.org/stable", "user": "demo", "password": ""}`)445 for i, mbps := range []float64{0, 2} {446 t.Run(fmt.Sprintf("maxBitrate=%v", mbps), func(t *testing.T) {447 j := newJellyfinChannel(t, root, fmt.Sprintf("2.%d Demo", i+1), fmt.Sprintf(`{"series": ["Pioneer One"],448 "movies": ["Caminandes: Llama Drama", "Night of the Living Dead (1968)"], "order": "aired", "maxBitrate": %v}`, mbps))449 j.FFmpeg = ffmpeg450 now := time.Now()451 progs := j.Programs(now, now.Add(4*time.Hour))452 if len(progs) == 0 {453 _, _, err := j.Status()454 t.Fatalf("no programs: %v", err)455 }456 for _, p := range progs {457 t.Logf("%s-%s %s | %s | S%sE%s | %s", p.Start.Format("15:04:05"), p.End.Format("15:04:05"), p.Title, p.Subtitle, p.Season, p.Episode, p.Image)458 }459 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)460 defer cancel()461 var out bytes.Buffer462 if err := j.Stream(ctx, &out); err != nil {463 t.Fatal(err)464 }465 ts := filepath.Join(t.TempDir(), "out.ts")466 if err := os.WriteFile(ts, out.Bytes(), 0o644); err != nil {467 t.Fatal(err)468 }469 probe, err := exec.Command(ffprobe, "-v", "error", "-show_entries", "stream=codec_name,width,height:format=duration", "-of", "compact", ts).CombinedOutput()470 if err != nil {471 t.Fatalf("ffprobe %d bytes: %v: %s", out.Len(), err, probe)472 }473 t.Logf("%d bytes:\n%s", out.Len(), probe)474 for _, want := range []string{"codec_name=h264|width=1280|height=720", "codec_name=aac"} {475 if !strings.Contains(string(probe), want) {476 t.Errorf("stream lacks %q", want)477 }478 }479 })480 }481}482483// TestJellyfinSchedule: a Jellyfin channel in aired order on a schedule484// from channel.json beside jellyfin.json, one a night from the second.485func TestJellyfinSchedule(t *testing.T) {486 srv := jellyfintest.New(t, "sched", "pw", jellyfinLibrary()...)487 root := t.TempDir()488 j := newJellyfinChannel(t, root, "1.5 Cartoons", fmt.Sprintf(`{"server": %q, "user": "sched", "password": "pw",489 "series": ["Futurama"], "movies": ["The Iron Giant"], "order": "aired"}`, srv.URL))490 details := filepath.Join(root, "1.5 Cartoons", DetailsFile)491 writeFile(t, details, `{"schedule": {"start": "2026-10-04", "first": 2, "blocks": [{"at": "20:00", "count": 1}]}}`)492 at := func(day, h, m int) time.Time { return time.Date(2026, 10, day, h, m, 0, 0, time.Local) }493494 seq, ok := j.Sequence()495 var names []string496 for _, s := range seq {497 names = append(names, cmp.Or(s.Subtitle, s.Title))498 }499 if !ok || !slices.Equal(names, []string{"Space Pilot 3000", "The Series Has Landed", "The Iron Giant"}) || seq[0].Season != "1" || seq[2].Length != 86*time.Minute {500 t.Fatalf("sequence: %+v", seq)501 }502503 // Sunday the second episode, Monday the movie, Tuesday back to the first.504 progs := j.Programs(at(5, 12, 0), at(6, 12, 0))505 if len(progs) != 3 || !progs[0].OffAir || progs[1].Title != "The Iron Giant" || !progs[1].Start.Equal(at(5, 20, 0)) || !progs[2].OffAir {506 t.Fatalf("guide: %+v", progs)507 }508 if d := progs[0].Description; d != "Back at 8:00 PM with The Iron Giant." {509 t.Errorf("afternoon: %q", d)510 }511 if d := progs[2].Description; d != "Back Tue, Oct 6 at 8:00 PM with Futurama, S1 E1." {512 t.Errorf("overnight: %q", d)513 }514 if j.Empty() {515 t.Error("empty while off the air")516 }517518 // The cue plays the movie from where it is.519 it, skip, err := scheduled("test", j.lineup)(at(5, 20, 30), true)520 if err != nil || it.Title != "The Iron Giant" || skip != 30*60*loopFPS {521 t.Errorf("cue: %q %d %v", it.Title, skip, err)522 }523524 // Taking the schedule out of channel.json loops on the clock again.525 writeFile(t, details, `{"callSign": "TOON"}`)526 for _, p := range j.Programs(at(5, 12, 0), at(6, 12, 0)) {527 if p.OffAir {528 t.Fatalf("off the air without a schedule: %+v", p)529 }530 }531}