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 / ytahead_test.go
ytahead_test.go 4.8K
1package vchan23import (4 "context"5 "net/http"6 "os"7 "path/filepath"8 "strconv"9 "strings"10 "testing"11 "time"1213 "airwaves/internal/phase"14 "airwaves/internal/youtube"15)1617// TestYouTubeFindsAhead: the videos on now and next are found ahead,18// once each, with their captions, so tuning in uses what was found19// without yt-dlp; addresses near their expiry are found again, and a20// video opened again is found anew.21func TestYouTubeFindsAhead(t *testing.T) {22 calls := filepath.Join(t.TempDir(), "yt-dlp.log")23 t.Setenv("AIRWAVES_FAKE_YTDLP", calls)24 runs := func() int {25 raw, _ := os.ReadFile(calls)26 return strings.Count(string(raw), " -f ")27 }28 t0 := time.Now().UTC().Truncate(time.Second)29 stub := &feedStub{down: true}30 y := &YouTube{Num: "1.8", Dir: t.TempDir(), loaded: true, now: func() time.Time { return t0 },31 YtDlp: &youtube.YtDlp{Path: os.Args[0], HTTP: &http.Client{Transport: stub}},32 cfg: ytConfig{MaxHeight: 720},33 pool: &ytPool{Basis: "test", Lock: time.Hour}, plan: ytPlayout{Basis: "test", Airings: []ytAiring{34 {ID: "aaaaaaaaaaa", Channel: "UCa", Title: "A", Start: t0.Add(-time.Minute), End: t0.Add(10 * time.Minute)},35 {ID: "bbbbbbbbbbb", Channel: "UCb", Title: "B", Start: t0.Add(10 * time.Minute), End: t0.Add(30 * time.Minute)},36 {ID: "ccccccccccc", Channel: "UCa", Title: "C", Start: t0.Add(30 * time.Minute), End: t0.Add(40 * time.Minute)},37 }},38 resolved: map[string]ytResolved{}, ahead: map[string]*ytAhead{}, missed: map[string]time.Time{},39 }40 due := func() []string {41 y.mu.Lock()42 defer y.mu.Unlock()43 return y.warmDue(time.Now())44 }4546 ids := due()47 if strings.Join(ids, " ") != "aaaaaaaaaaa bbbbbbbbbbb" {48 t.Fatalf("due %q, want what's on and next", ids)49 }50 for _, id := range ids {51 y.lookAhead(id)52 }53 if n := runs(); n != 2 {54 t.Fatalf("%d yt-dlp runs, want 2", n)55 }56 if ids := due(); len(ids) != 0 {57 t.Errorf("due %q once found", ids)58 }5960 // Tuning in uses what was found.61 timer := phase.New("tune", "youtube")62 r, err := y.resolve(phase.NewContext(context.Background(), timer), "aaaaaaaaaaa", false)63 if err != nil || len(r.info.Inputs()) == 0 {64 t.Fatalf("resolve: %v %+v", err, r)65 }66 if n := runs(); n != 2 || !timer.Has("found ahead") {67 t.Errorf("tuning in ran yt-dlp (%d runs; steps %s)", n, timer)68 }69 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)70 defer cancel()71 if r.caps == nil {72 t.Fatal("no captions")73 } else if cues, err := r.caps.get(ctx); err != nil || len(cues) != 1 {74 t.Errorf("captions: %v %+v", err, cues)75 }76 // And again: what's found is kept for the next tune.77 if again, err := y.resolve(context.Background(), "aaaaaaaaaaa", false); err != nil || runs() != 2 || again.caps != r.caps {78 t.Errorf("a second tune ran yt-dlp, or fetched the captions again: %v, %d runs", err, runs())79 }80 y.mu.Lock()81 bcaps := y.resolved["bbbbbbbbbbb"].caps82 y.mu.Unlock()83 if bcaps == nil {84 t.Fatal("no captions for the next video")85 }86 _, _ = bcaps.get(ctx)87 stub.mu.Lock()88 if stub.captions != 2 {89 t.Errorf("fetched captions %d times, want once for each video", stub.captions)90 }91 stub.mu.Unlock()9293 // Addresses about to expire are found again.94 y.mu.Lock()95 b := y.resolved["bbbbbbbbbbb"]96 b.until = time.Now().Add(ytMinLeft / 2)97 y.resolved["bbbbbbbbbbb"] = b98 y.mu.Unlock()99 if ids := due(); strings.Join(ids, " ") != "bbbbbbbbbbb" {100 t.Errorf("due %q with B's addresses expiring", ids)101 }102103 // A video opened again is found anew.104 if _, err := y.resolve(context.Background(), "aaaaaaaaaaa", true); err != nil || runs() != 3 {105 t.Errorf("a fresh resolve: %v, %d runs", err, runs())106 }107108 // Once the next video is on, the one after it is found.109 t0 = t0.Add(11 * time.Minute)110 if ids := due(); strings.Join(ids, " ") != "bbbbbbbbbbb ccccccccccc" {111 t.Errorf("due %q after A, want B (expiring) and C", ids)112 }113}114115func TestAddressesUntil(t *testing.T) {116 at := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)117 exp := func(d time.Duration) string {118 return "https://rr1.example/videoplayback?expire=" + strconv.FormatInt(at.Add(d).Unix(), 10) + "&sig=x"119 }120 two := youtube.Info{RequestedFormats: []youtube.Format{{URL: exp(6 * time.Hour), VCodec: "avc1"}, {URL: exp(5 * time.Hour), VCodec: "none", ACodec: "mp4a"}}}121 if got, want := youtube.AddressesUntil(two, at), at.Add(5*time.Hour-10*time.Minute); !got.Equal(want) {122 t.Errorf("two addresses: until %v, want %v", got, want)123 }124 none := youtube.Info{Format: youtube.Format{URL: "https://example.com/v.mp4", VCodec: "avc1", ACodec: "mp4a"}}125 if got, want := youtube.AddressesUntil(none, at), at.Add(time.Hour); !got.Equal(want) {126 t.Errorf("no expiry: until %v, want %v", got, want)127 }128 far := youtube.Info{Format: youtube.Format{URL: exp(48 * time.Hour), VCodec: "avc1", ACodec: "mp4a"}}129 if got, want := youtube.AddressesUntil(far, at), at.Add(6*time.Hour); !got.Equal(want) {130 t.Errorf("far expiry: until %v, want %v", got, want)131 }132}