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 / ytplaylist_test.go
ytplaylist_test.go 15.7K
1package vchan23import (4 "encoding/json"5 "fmt"6 "net/http"7 "os"8 "path/filepath"9 "slices"10 "strings"11 "testing"12 "time"1314 "airwaves/internal/youtube"15)1617const csList = "PL8dPuuaLjXtNlUrzyH5r6jN9ulIgZBpdo"1819func TestYouTubePlaylistLinks(t *testing.T) {20 page := "https://www.youtube.com/playlist?list=" + csList21 for _, in := range []string{22 page,23 " youtube.com/playlist?list=" + csList + "&si=x1y2 ",24 "https://www.youtube.com/watch?v=O5nskjZ_GoI&list=" + csList + "&index=2",25 "https://youtu.be/O5nskjZ_GoI?list=" + csList,26 "https://m.youtube.com/playlist?list=" + csList,27 "https://music.youtube.com/playlist?list=" + csList,28 "http://www.youtube.com/embed/videoseries?list=" + csList,29 csList,30 } {31 if got, err := youtube.Playlist(in); got != page || err != nil {32 t.Errorf("youtube.Playlist(%q) = %q, %v", in, got, err)33 }34 }35 if got, err := youtube.Playlist("UU3tNpTOHsTnkmbwztCs30sA"); got != "https://www.youtube.com/playlist?list=UU3tNpTOHsTnkmbwztCs30sA" || err != nil {36 t.Errorf("uploads playlist: %q, %v", got, err)37 }38 for in, want := range map[string]string{39 "https://www.youtube.com/watch?v=dQw4w9WgXcQ": "link to a video",40 "https://youtu.be/dQw4w9WgXcQ": "link to a video",41 "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=RDdQw4w9WgXcQ": "mixes",42 "https://www.youtube.com/@crashcourse": "isn't a YouTube playlist",43 "@crashcourse": "isn't a YouTube playlist",44 "https://vimeo.com/showcase/1?list=" + csList: "isn't a YouTube playlist",45 "crash course": "isn't a YouTube playlist",46 "https://www.youtube.com/playlist?list=PL<script>": "isn't a YouTube playlist",47 } {48 if got, err := youtube.Playlist(in); err == nil || !strings.Contains(err.Error(), want) {49 t.Errorf("youtube.Playlist(%q) = %q, %v; want an error saying %q", in, got, err, want)50 }51 }52}5354func TestYtConfigPlaylist(t *testing.T) {55 dir := t.TempDir()56 path := filepath.Join(dir, youtubeConfig)57 for cfg, want := range map[string]string{58 `{"playlist": "` + csList + `", "maxHeight": 480, "minMinutes": 20}`: "",59 `{"playlist": "` + csList + `", "channels": ["@crashcourse"]}`: "both channels and a playlist",60 `{"playlist": "", "channels": []}`: "no channels or playlist",61 `{"playlist": "https://youtu.be/dQw4w9WgXcQ"}`: "link to a video",62 } {63 if err := os.WriteFile(path, []byte(cfg), 0o644); err != nil {64 t.Fatal(err)65 }66 c, err := readYtConfig(path)67 switch {68 case want == "" && (err != nil || c.Playlist != csList || c.MaxHeight != 480):69 t.Errorf("%s: %+v %v", cfg, c, err)70 case want != "" && (err == nil || !strings.Contains(err.Error(), want)):71 t.Errorf("%s: %v, want an error saying %q", cfg, err, want)72 }73 }74}7576func TestYtPlaylistListing(t *testing.T) {77 l, err := parseYtPlaylist(readTestdata(t, "yt_playlist.json"))78 if err != nil {79 t.Fatal(err)80 }81 if l.ID != csList || l.Name != "Computer Science" || l.Owner != "CrashCourse" || l.OwnerID != "UCX6b17PVsYBQ0ip5gyeme-Q" {82 t.Errorf("playlist: %q %q by %q %q", l.ID, l.Name, l.Owner, l.OwnerID)83 }84 // In order, once each, without the private, deleted and members-only85 // videos; live and upcoming ones marked.86 var got []string87 for _, v := range l.Videos {88 got = append(got, v.ID+":"+v.Skip)89 }90 want := []string{"tpIctyqH29Q:", "O5nskjZ_GoI:", "LN0ucKNX0hc:", "gI-qXk7XojA:", "1GSjbWt0c9M:", "NoLength001:", "Premiere001:upcoming",91 "LiveNow0001:live", "StreamVod01:"}92 if !slices.Equal(got, want) {93 t.Errorf("videos %v,\nwant %v", got, want)94 }95 if v := l.Videos[1]; v.Seconds != 713 || v.Channel != "CrashCourse" || v.Title != "Early Computing: Crash Course Computer Science #1" {96 t.Errorf("a video: %+v", v)97 }98 if _, err := parseYtPlaylist(readTestdata(t, "yt_listing.json")); err == nil {99 t.Error("a channel's tab read as a playlist")100 }101102 // Listed again: the playlist's new order, what's known kept, a video103 // taken out gone, and one found unplayable still so.104 now := time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)105 s := &ytSource{URL: youtube.PlaylistURL(csList)}106 if added := s.mergePlaylist(l, now); added != 0 || s.Name != "Computer Science" || s.Owner != "CrashCourse" || len(s.Videos) != 9 {107 t.Fatalf("first listing: %d added, %+v", added, s)108 }109 s.Videos[0].Description, s.Videos[0].Loudness = "The preview.", &loudness{}110 s.Videos[2].Skip = "gone"111 again := ytListing{ID: csList, Name: "Computer Science (all)", Owner: "CrashCourse", Videos: []*ytVideo{112 {ID: "O5nskjZ_GoI", Title: "Early Computing", Seconds: 713},113 {ID: "Inserted001", Title: "New", Seconds: 300},114 {ID: "tpIctyqH29Q", Title: "Preview", Seconds: 0},115 {ID: "LN0ucKNX0hc", Title: "Electronic Computing", Seconds: 644},116 {ID: "Premiere001", Title: "Coming soon: #41", Seconds: 690},117 }}118 if added := s.mergePlaylist(again, now.Add(time.Hour)); added != 1 {119 t.Errorf("added %d", added)120 }121 got = nil122 for _, v := range s.Videos {123 got = append(got, v.ID+":"+v.Skip)124 }125 if want := []string{"O5nskjZ_GoI:", "Inserted001:", "tpIctyqH29Q:", "LN0ucKNX0hc:gone", "Premiere001:"}; !slices.Equal(got, want) {126 t.Errorf("relisted %v, want %v", got, want)127 }128 if v := s.Videos[2]; v.Seconds != 165 || v.Description != "The preview." || v.Loudness == nil || v.Title != "Preview" {129 t.Errorf("a known video: %+v", v)130 }131 if v := s.Videos[1]; !v.Found.Equal(now.Add(time.Hour)) || v.New {132 t.Errorf("a new video: %+v", v)133 }134 if s.Name != "Computer Science (all)" {135 t.Errorf("title %q", s.Name)136 }137}138139// testPlaylist makes a channel playing a playlist of five videos, 10, 20,140// 30, 40 and 50 minutes long, with an upcoming one among them, listed at141// clock; with a schedule, as channel.json would have it.142func testPlaylist(t *testing.T, clock time.Time, schedule string) *YouTube {143 t.Helper()144 dir := t.TempDir()145 t.Setenv("AIRWAVES_FAKE_YTDLP", filepath.Join(t.TempDir(), "yt-dlp.log"))146 write := func(name, s string) {147 if err := os.WriteFile(filepath.Join(dir, name), []byte(s), 0o644); err != nil {148 t.Fatal(err)149 }150 }151 write(youtubeConfig, `{"playlist": "PLtestplaylist01"}`)152 if schedule != "" {153 write(DetailsFile, `{"schedule": `+schedule+`}`)154 }155 s := &ytSource{URL: youtube.PlaylistURL("PLtestplaylist01"), ID: "PLtestplaylist01", Name: "A course", Owner: "Teacher",156 Listed: clock, Checked: clock, Dated: true}157 for i := range 5 {158 s.Videos = append(s.Videos, &ytVideo{ID: fmt.Sprintf("part%07d", i+1), Title: fmt.Sprintf("Part %d", i+1),159 Seconds: (i + 1) * 600, Description: "About part " + fmt.Sprint(i+1), Looked: clock})160 if i == 2 {161 s.Videos = append(s.Videos, &ytVideo{ID: "Premiere001", Title: "Bonus", Skip: "upcoming", Looked: clock})162 }163 }164 s.Videos[1].Channel = "Guest"165 y := &YouTube{Num: "1.9", Title: "Course", Dir: dir, now: func() time.Time { return clock },166 YtDlp: &youtube.YtDlp{Path: os.Args[0], HTTP: &http.Client{Transport: &feedStub{down: true}}}}167 y.save(ytCatalogFile, ytCatalog{Sources: []*ytSource{s}})168 return y169}170171// TestYtPlaylistAroundTheClock: without a schedule, the playlist loops on172// the clock from the epoch, as folder channels do.173func TestYtPlaylistAroundTheClock(t *testing.T) {174 const loop = 9000 * time.Second // the five videos175 at := scheduleEpoch.Add(100*loop + 700*time.Second) // 100 s into part 2176 y := testPlaylist(t, at, "")177 if y.Empty() {178 t.Fatal("empty")179 }180 progs := y.Programs(at, at.Add(3*time.Hour))181 var titles []string182 for i, p := range progs {183 titles = append(titles, p.Title)184 if i > 0 && !p.Start.Equal(progs[i-1].End) {185 t.Errorf("program %d starts %v, after %v", i, p.Start, progs[i-1].End)186 }187 }188 if want := []string{"Part 2", "Part 3", "Part 4", "Part 5", "Part 1", "Part 2", "Part 3"}; !slices.Equal(titles, want) {189 t.Errorf("programs %v, want %v", titles, want)190 }191 if p := progs[0]; !p.Start.Equal(scheduleEpoch.Add(100*loop+600*time.Second)) || p.Subtitle != "Guest" ||192 p.Description != "About part 2" || p.Image != youtube.Thumbnail("part0000002") {193 t.Errorf("part 2: %+v", p)194 }195 if p := progs[1]; p.Subtitle != "Teacher" {196 t.Errorf("a video without a channel of its own: %+v", p)197 }198 it, skip, err := y.cue()(at, true)199 if err != nil || it.Path != "part0000002" || it.Slate || it.Open == nil || it.Frames != 1200*loopFPS || skip != 100*loopFPS {200 t.Errorf("cue: %+v from %d, %v", it, skip, err)201 }202 items, ok := y.Sequence()203 if !ok || len(items) != 5 || items[2].Title != "Part 3" || items[2].Length != 30*time.Minute || items[1].Subtitle != "Guest" {204 t.Errorf("sequence: %+v %v", items, ok)205 }206 if m, n := y.Leveled(); m != 0 || n != 5 {207 t.Errorf("leveled %d of %d", m, n)208 }209 if _, err := os.Stat(filepath.Join(y.Dir, ytPlayoutFile)); !os.IsNotExist(err) {210 t.Errorf("a playlist made a schedule file: %v", err)211 }212213 // Uploads play in no order.214 up := &YouTube{Num: "1.8", Dir: t.TempDir(), loaded: true, cfg: ytConfig{Channels: []string{"@a"}}}215 if items, ok := up.Sequence(); ok || items != nil {216 t.Errorf("uploads' sequence: %v %v", items, ok)217 }218}219220// TestYtPlaylistOnASchedule: two videos a night at 8 PM from today, off221// the air between, as the guide and the stream both have it.222func TestYtPlaylistOnASchedule(t *testing.T) {223 day := func(d, h, m int) time.Time { return time.Date(2026, 10, 4+d, h, m, 0, 0, time.Local) } // a Sunday224 y := testPlaylist(t, day(0, 15, 0), `{"start": "2026-10-04", "blocks": [{"at": "20:00", "count": 2}]}`)225 if y.Empty() {226 t.Fatal("empty while off the air")227 }228 progs := y.Programs(day(0, 15, 0), day(1, 15, 0))229 type entry struct {230 start, end time.Time231 title, desc string232 }233 want := []entry{234 {day(0, 15, 0), day(0, 20, 0), "Off air", "Starts at 8:00 PM with Part 1."},235 {day(0, 20, 0), day(0, 20, 10), "Part 1", "About part 1"},236 {day(0, 20, 10), day(0, 20, 30), "Part 2", "About part 2"},237 {day(0, 20, 30), day(1, 20, 0), "Off air", "Back Mon, Oct 5 at 8:00 PM with Part 3."},238 }239 if len(progs) != len(want) {240 t.Fatalf("programs: %+v", progs)241 }242 for i, w := range want {243 if p := progs[i]; !p.Start.Equal(w.start) || !p.End.Equal(w.end) || p.Title != w.title || p.Description != w.desc {244 t.Errorf("program %d: %v-%v %q %q, want %+v", i, p.Start, p.End, p.Title, p.Description, w)245 }246 }247 // The next night picks up at part 3; the night after loops round.248 progs = y.Programs(day(2, 0, 0), day(3, 0, 0))249 var titles []string250 for _, p := range progs {251 titles = append(titles, p.Title)252 }253 if !slices.Equal(titles, []string{"Off air", "Part 5", "Part 1", "Off air"}) {254 t.Errorf("Tuesday: %v", titles)255 }256257 cue := y.cue()258 for _, c := range []struct {259 at time.Time260 path string261 slate string262 frames,263 skip int64264 }{265 {day(0, 15, 0), "", "Starts at 8:00 PM with Part 1", 60 * loopFPS, 0},266 {day(0, 20, 5), "part0000001", "", 600 * loopFPS, 300 * loopFPS},267 {day(0, 20, 10), "part0000002", "", 1200 * loopFPS, 0},268 {day(0, 20, 31), "", "Back Mon, Oct 5 at 8:00 PM with Part 3", 60 * loopFPS, 0},269 {day(1, 19, 1).Add(-30 * time.Second), "", "Back at 8:00 PM with Part 3", 60 * loopFPS, 0},270 } {271 it, skip, err := cue(c.at, true)272 if err != nil || it.Path != c.path || it.Slate != (c.slate != "") || c.slate != "" && it.Title != c.slate || it.Frames != c.frames || skip != c.skip {273 t.Errorf("at %v: %q slate=%v %q, %d frames from %d; want %q %q, %d from %d", c.at, it.Path, it.Slate, it.Title, it.Frames, skip,274 c.path, c.slate, c.frames, c.skip)275 }276 }277 // Found ahead: what airs after the night's last video is the next278 // night's first.279 y.mu.Lock()280 next := y.after(ytAiring{ID: "part0000002", Start: day(0, 20, 10), End: day(0, 20, 30)})281 y.mu.Unlock()282 if next.ID != "part0000003" || !next.Start.Equal(day(1, 20, 0)) {283 t.Errorf("after the night: %+v", next)284 }285}286287// TestYtPlaylistListedWithYtDlp lists a playlist with the fake yt-dlp: the288// catalog keeps its videos in order, a video without a length is looked289// up and joins, and editing youtube.json lists it again, taking in a video290// added to the playlist.291func TestYtPlaylistListedWithYtDlp(t *testing.T) {292 dir := t.TempDir()293 calls := filepath.Join(t.TempDir(), "yt-dlp.log")294 t.Setenv("AIRWAVES_FAKE_YTDLP", calls)295 gap := ytLookupGap296 ytLookupGap = 0297 defer func() { ytLookupGap = gap }()298 config := filepath.Join(dir, youtubeConfig)299 given := "https://www.youtube.com/watch?v=O5nskjZ_GoI&list=" + csList + "&index=2"300 if err := os.WriteFile(config, []byte(`{"playlist": "`+given+`"}`), 0o644); err != nil {301 t.Fatal(err)302 }303 clock := time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)304 y := &YouTube{Num: "1.9", Title: "CS", Dir: dir, now: func() time.Time { return clock },305 YtDlp: &youtube.YtDlp{Path: os.Args[0], HTTP: &http.Client{Transport: &feedStub{down: true}}}}306 refresh := func() {307 y.mu.Lock()308 y.tend()309 for y.busy { // tend's own310 y.mu.Unlock()311 time.Sleep(10 * time.Millisecond)312 y.mu.Lock()313 }314 y.busy = true315 y.mu.Unlock()316 y.refresh()317 }318319 if !y.Empty() {320 t.Error("on the air before it's listed")321 }322 refresh()323 raw, _ := os.ReadFile(calls)324 if !strings.Contains(string(raw), "--flat-playlist -J https://www.youtube.com/playlist?list="+csList+"\n") {325 t.Errorf("yt-dlp ran as:\n%s", raw)326 }327 cat, err := readYtCatalog(filepath.Join(dir, ytCatalogFile))328 if err != nil || len(cat.Sources) != 1 {329 t.Fatalf("catalog: %+v %v", cat, err)330 }331 if s := cat.Sources[0]; s.ID != csList || s.Name != "Computer Science" || s.Owner != "CrashCourse" || len(s.Videos) != 9 || s.Videos[0].ID != "tpIctyqH29Q" {332 t.Errorf("catalog's source: %+v", s)333 }334 ids := func() []string {335 y.mu.Lock()336 defer y.mu.Unlock()337 seq, _ := y.sequence()338 var ids []string339 for _, v := range seq {340 ids = append(ids, v.ID)341 }342 return ids343 }344 // The video without a length joins once looked up; the premiere waits.345 if got, want := ids(), []string{"tpIctyqH29Q", "O5nskjZ_GoI", "LN0ucKNX0hc", "gI-qXk7XojA", "1GSjbWt0c9M", "NoLength001", "StreamVod01"}; !slices.Equal(got, want) {346 t.Errorf("sequence %v, want %v", got, want)347 }348 items, ok := y.Sequence()349 if !ok || len(items) != 7 || items[5].Title != "Looked up NoLength001" || items[5].Length != 15*time.Minute || items[5].Subtitle != "Some Other Channel" {350 t.Errorf("sequence: %+v %v", items, ok)351 }352 st := y.Status()353 if len(st.Sources) != 1 || st.Config.Playlist != given {354 t.Fatalf("status: %+v", st)355 }356 if s := st.Sources[0]; s.Channel != given || s.URL != youtube.PlaylistURL(csList) || s.Name != "Computer Science" || s.Owner != "CrashCourse" ||357 s.Videos != 9 || s.Playable != 7 || s.Listing || !s.Listed.Equal(clock) || s.Image != youtube.Thumbnail("tpIctyqH29Q") {358 t.Errorf("status: %+v", s)359 }360 if y.Empty() {361 t.Error("empty once listed")362 }363364 // Nothing's due until the quarter hour's lookups; the listing is a day365 // off.366 y.mu.Lock()367 due, soon := y.due(clock.Add(time.Minute)), y.due(clock.Add(16*time.Minute))368 y.mu.Unlock()369 if due || !soon {370 t.Errorf("due a minute on %v, a quarter hour on %v", due, soon)371 }372373 // Edited: listed again at once, with what was added to the playlist.374 t.Setenv("AIRWAVES_FAKE_PLAYLIST", "Added000001")375 clock = clock.Add(5 * time.Minute)376 if err := os.WriteFile(config, []byte(`{"playlist": "`+csList+`", "maxHeight": 1080}`), 0o644); err != nil {377 t.Fatal(err)378 }379 os.Chtimes(config, clock, clock)380 if st := y.Status(); !st.Sources[0].Listing {381 t.Errorf("not listing after the edit: %+v", st.Sources[0])382 }383 refresh()384 if got := ids(); len(got) != 8 || got[7] != "Added000001" {385 t.Errorf("after the edit: %v", got)386 }387 if items, _ = y.Sequence(); items[5].Length != 15*time.Minute {388 t.Errorf("the looked-up length was lost: %+v", items[5])389 }390 if n := strings.Count(func() string { raw, _ := os.ReadFile(calls); return string(raw) }(), "--flat-playlist -J https://www.youtube.com/playlist"); n != 2 {391 t.Errorf("listed %d times", n)392 }393 var saved ytCatalog394 raw, _ = os.ReadFile(filepath.Join(dir, ytCatalogFile))395 if err := json.Unmarshal(raw, &saved); err != nil || len(saved.Sources[0].Videos) != 10 {396 t.Errorf("saved catalog: %v", err)397 }398}