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 / ytfeed_test.go
ytfeed_test.go 7.6K
1package vchan23import (4 "bytes"5 "encoding/json"6 "fmt"7 "io"8 "log"9 "net/http"10 "os"11 "path/filepath"12 "slices"13 "strings"14 "sync"15 "testing"16 "time"1718 "airwaves/internal/youtube"19)2021// TestMain lets the test binary stand in for yt-dlp: run with22// AIRWAVES_FAKE_YTDLP set, it answers like yt-dlp would.23func TestMain(m *testing.M) {24 if os.Getenv("AIRWAVES_FAKE_YTDLP") != "" {25 os.Exit(fakeYtDlp(os.Args[1:]))26 }27 // Channels find videos ahead only in the tests of that, so the rest28 // see only the yt-dlp runs they cause.29 ytWarm = false30 os.Exit(m.Run())31}3233// fakeYtDlp answers yt-dlp's command line, noting each run's arguments in34// the file AIRWAVES_FAKE_YTDLP names. A channel tab's newest uploads are a35// new 15-minute upload, "fresh000001", then the channel's v000 to v013;36// any video's details give it 15 minutes, and with a format asked for, an37// address that expires in six hours and English captions, but a38// "Premiere…" is upcoming.39// A playlist is testdata/yt_playlist.json, with AIRWAVES_FAKE_PLAYLIST's40// videos added to the end when set.41func fakeYtDlp(args []string) int {42 if len(args) == 1 && args[0] == "--version" {43 fmt.Println("2026.08.19")44 return 045 }46 if f, err := os.OpenFile(os.Getenv("AIRWAVES_FAKE_YTDLP"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {47 fmt.Fprintln(f, strings.Join(args, " "))48 f.Close()49 }50 u := args[len(args)-1]51 now := time.Now().Unix()52 var out any53 switch {54 case strings.HasSuffix(u, "/videos") && slices.Contains(args, "--playlist-end"):55 entries := []map[string]any{{"id": "fresh000001", "title": "A fresh upload (New Game)", "duration": 900, "timestamp": now - 3600}}56 for i := range 14 {57 entries = append(entries, map[string]any{"id": fmt.Sprintf("v%03d", i), "title": fmt.Sprintf("Video %d", i),58 "duration": 1200, "timestamp": now - int64(i+1)*86400})59 }60 out = map[string]any{"channel": "A", "channel_id": "UCa", "entries": entries}61 case strings.Contains(u, "watch?v="):62 id := u[strings.Index(u, "v=")+2:]63 out = map[string]any{"id": id, "title": "Looked up " + id, "duration": 900, "timestamp": now - 3600,64 "description": "Details of " + id, "live_status": "not_live", "media_type": "video"}65 if strings.HasPrefix(id, "Premiere") {66 out = map[string]any{"id": id, "title": "Looked up " + id, "live_status": "is_upcoming", "media_type": "video"}67 } else if slices.Contains(args, "-f") {68 // Where to stream it from, for six hours, and its captions.69 out.(map[string]any)["url"] = fmt.Sprintf("https://example.com/%s.mp4?expire=%d", id, now+6*3600)70 out.(map[string]any)["vcodec"], out.(map[string]any)["acodec"] = "avc1.4d401f", "mp4a.40.2"71 out.(map[string]any)["automatic_captions"] = map[string]any{"en": []map[string]any{{"ext": "json3", "url": "https://example.com/captions/" + id}}}72 }73 case strings.Contains(u, "playlist?list="):74 var p map[string]any75 raw, _ := os.ReadFile(filepath.Join("testdata", "yt_playlist.json"))76 if err := json.Unmarshal(raw, &p); err != nil {77 fmt.Fprintln(os.Stderr, "ERROR: "+err.Error())78 return 179 }80 for id := range strings.FieldsSeq(os.Getenv("AIRWAVES_FAKE_PLAYLIST")) {81 p["entries"] = append(p["entries"].([]any), map[string]any{"id": id, "title": "Added " + id, "duration": 600, "channel": "CrashCourse"})82 }83 out = p84 default:85 fmt.Fprintln(os.Stderr, "ERROR: fake yt-dlp doesn't know "+u)86 return 187 }88 json.NewEncoder(os.Stdout).Encode(out)89 return 090}9192// feedStub serves a channel's feed, or fails with HTTP 404 while down,93// and captions, counting them.94type feedStub struct {95 mu sync.Mutex96 down bool97 feed []byte98 captions int99}100101func (f *feedStub) RoundTrip(r *http.Request) (*http.Response, error) {102 f.mu.Lock()103 defer f.mu.Unlock()104 if strings.HasPrefix(r.URL.String(), "https://example.com/captions/") {105 f.captions++106 caps := `{"events":[{"tStartMs":1000,"dDurationMs":2000,"segs":[{"utf8":"Hello"}]}]}`107 return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(caps)), Request: r}, nil108 }109 if f.down || !strings.HasPrefix(r.URL.String(), "https://www.youtube.com/feeds/videos.xml?channel_id=UCa") {110 return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), Request: r}, nil111 }112 return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(f.feed)), Request: r}, nil113}114115// TestYtFeedFallback: while a channel's feed fails, its newest uploads116// are listed with yt-dlp instead, every quarter hour like the feed, and a117// new upload found that way gets a first run. Switching to the stand-in118// and back are each logged once.119func TestYtFeedFallback(t *testing.T) {120 dir := t.TempDir()121 calls := filepath.Join(dir, "yt-dlp.log")122 t.Setenv("AIRWAVES_FAKE_YTDLP", calls)123 var logged bytes.Buffer124 log.SetOutput(&logged)125 defer log.SetOutput(os.Stderr)126 gap := ytLookupGap127 ytLookupGap = 0128 defer func() { ytLookupGap = gap }()129130 clock := time.Now().UTC()131 if err := os.WriteFile(filepath.Join(dir, youtubeConfig), []byte(`{"channels": ["@a"]}`), 0o644); err != nil {132 t.Fatal(err)133 }134 s := &ytSource{URL: youtube.VideosURL("@a"), ID: "UCa", Name: "A", Listed: clock, Dated: true}135 for i := range 100 {136 s.Videos = append(s.Videos, &ytVideo{ID: fmt.Sprintf("v%03d", i), Title: fmt.Sprintf("Video %d", i),137 Seconds: 1200, Published: clock.AddDate(0, 0, -i-1), Approx: true, Looked: clock})138 }139 feed := &feedStub{down: true, feed: []byte(strings.ReplaceAll(string(readTestdata(t, "yt_feed.xml")), "NewUpload01", "fromfeed001"))}140 y := &YouTube{Num: "1.8", Title: "A", Dir: dir, now: func() time.Time { return clock },141 YtDlp: &youtube.YtDlp{Path: os.Args[0], HTTP: &http.Client{Transport: feed}}}142 y.save(ytCatalogFile, ytCatalog{Sources: []*ytSource{s}})143 refresh := func() {144 y.mu.Lock()145 if !y.loaded {146 y.load()147 }148 y.replan(clock)149 y.busy = true150 y.mu.Unlock()151 y.refresh()152 }153 fallbacks := func() int {154 raw, _ := os.ReadFile(calls)155 return strings.Count(string(raw), "--playlist-end 15")156 }157158 refresh()159 if n := fallbacks(); n != 1 {160 t.Fatalf("%d runs listing the newest uploads, want 1", n)161 }162 y.mu.Lock()163 fresh := y.videos["fresh000001"]164 var first ytAiring165 for _, a := range y.plan.Airings {166 if a.First {167 first = a168 }169 }170 lock := clock.Add(time.Duration(y.cfg.LockHours * float64(time.Hour)))171 y.mu.Unlock()172 if fresh == nil || fresh.Seconds != 900 || fresh.Found != clock {173 t.Fatalf("the new upload: %+v", fresh)174 }175 if first.ID != "fresh000001" || first.Start.Before(lock) || first.Start.After(lock.Add(time.Hour)) {176 t.Errorf("first run %+v, want the new upload's right after %v", first, lock)177 }178179 // Within the quarter hour: nothing more. After it: the stand-in again,180 // without saying so again.181 clock = clock.Add(10 * time.Minute)182 refresh()183 if n := fallbacks(); n != 1 {184 t.Errorf("listed the newest uploads %d times within a quarter hour", n)185 }186 clock = clock.Add(6 * time.Minute)187 refresh()188 if n := fallbacks(); n != 2 {189 t.Errorf("%d runs listing the newest uploads after a quarter hour, want 2", n)190 }191 if n := strings.Count(logged.String(), "until it's back"); n != 1 {192 t.Errorf("the switch to yt-dlp was logged %d times:\n%s", n, logged.String())193 }194195 // The feed is back.196 feed.mu.Lock()197 feed.down = false198 feed.mu.Unlock()199 clock = clock.Add(16 * time.Minute)200 refresh()201 if n := fallbacks(); n != 2 {202 t.Errorf("listed the newest uploads with the feed working")203 }204 y.mu.Lock()205 fromFeed := y.videos["fromfeed001"]206 y.mu.Unlock()207 if fromFeed == nil || !fromFeed.New {208 t.Errorf("the feed's new upload: %+v", fromFeed)209 }210 if n := strings.Count(logged.String(), "works again"); n != 1 {211 t.Errorf("the feed's return was logged %d times:\n%s", n, logged.String())212 }213}