1package vchan23import (4 "bytes"5 "context"6 "errors"7 "fmt"8 "io"9 "os"10 "os/exec"11 "path/filepath"12 "reflect"13 "slices"14 "strconv"15 "strings"16 "testing"17 "time"1819 "airwaves/internal/youtube"20)2122func readTestdata(t *testing.T, name string) []byte {23 t.Helper()24 raw, err := os.ReadFile(filepath.Join("testdata", name))25 if err != nil {26 t.Fatal(err)27 }28 return raw29}3031func TestYtCatalogFromListingAndFeed(t *testing.T) {32 l, err := parseYtListing(readTestdata(t, "yt_listing.json"))33 if err != nil {34 t.Fatal(err)35 }36 if l.ID != "UC_O58Rr2DOskJvs9bArpLkQ" || l.Name != "The Library of Letourneau" || len(l.Videos) != 7 {37 t.Fatalf("listing: %q %q, %d videos", l.ID, l.Name, len(l.Videos))38 }39 if v := l.Videos[0]; v.ID != "9KaMhx-S0jo" || v.Seconds != 594 || v.Title != "Streamers go to Jupiter to get more STUPIDER" {40 t.Errorf("first video: %+v", v)41 }42 if v := l.Videos[2]; v.ID != "PremiereXyz" || v.Skip != "upcoming" || v.Seconds != 0 {43 t.Errorf("premiere: %+v", v)44 }4546 cfg := ytConfig{MinMinutes: 3, MaxMinutes: 240}47 var playable []string48 for _, v := range l.Videos {49 if cfg.playable(v) {50 playable = append(playable, v.ID)51 }52 }53 // The premiere has no length yet, and uUNKsYaCyXg is under three54 // minutes.55 if want := []string{"9KaMhx-S0jo", "D0PHjmJUG0w", "aNauO_0Y9RA", "velAP60VACY", "8JE8devZIZQ"}; !slices.Equal(playable, want) {56 t.Errorf("playable %v, want %v", playable, want)57 }5859 now := time.Date(2026, 10, 3, 15, 0, 0, 0, time.UTC)60 s := &ytSource{URL: youtube.VideosURL("@TheLibraryofLetourneau")}61 if added := s.mergeListing(l, now); added != 0 {62 t.Errorf("the first listing found %d new videos; it should take them all as the back catalog", added)63 }64 for _, v := range s.Videos {65 if v.New || !v.Found.IsZero() {66 t.Errorf("back catalog video %s marked new", v.ID)67 }68 }6970 entries, err := youtube.ParseFeed(readTestdata(t, "yt_feed.xml"))71 if err != nil {72 t.Fatal(err)73 }74 if len(entries) != 4 || entries[0].ID != "NewUpload01" || entries[0].Published.IsZero() ||75 !strings.Contains(entries[3].Link.Href, "/shorts/") {76 t.Fatalf("feed: %+v", entries)77 }78 added, changed := s.mergeFeed(entries, now)79 if added != 1 || !changed {80 t.Errorf("feed added %d new uploads (changed %v), want 1", added, changed)81 }82 byID := map[string]*ytVideo{}83 for _, v := range s.Videos {84 byID[v.ID] = v85 }86 if v := byID["NewUpload01"]; v == nil || !v.New || v.Seconds != 0 || v.Skip != "" || v.Found != now ||87 v.Description != "BUT YOURE ALREADY REHEATING THE WEDGES SO WHY NOT?? YKNOW WHATEVER MAN" {88 t.Errorf("new upload: %+v", v)89 }90 if v := byID[entries[3].ID]; v == nil || v.Skip != "short" || v.New {91 t.Errorf("Short: %+v", v)92 }93 if v := byID["9KaMhx-S0jo"]; v.Published.IsZero() || v.Description != "if you were on youtube shorts these would be so much easier ryan" {94 t.Errorf("known video not filled in from the feed: %+v", v)95 }96 if s.Videos[0].ID != "NewUpload01" {97 t.Errorf("feed uploads should go first, got %s", s.Videos[0].ID)98 }99100 // Its lookup gives the length; it's playable and still due a first run.101 info, err := youtube.ParseInfo(readTestdata(t, "yt_video.json"))102 if err != nil {103 t.Fatal(err)104 }105 byID["NewUpload01"].learn(info, now)106 if v := byID["NewUpload01"]; v.Seconds != 593 || !v.New || !cfg.playable(v) || v.Looked != now {107 t.Errorf("after lookup: %+v", v)108 }109110 // A later listing with an upload at the top marks it new; one the111 // listing lacks that was found lately stays.112 l.Videos = append([]*ytVideo{{ID: "Fresh000001", Title: "Fresh", Seconds: 900}}, l.Videos...)113 if added := s.mergeListing(l, now.Add(time.Hour)); added != 1 {114 t.Errorf("second listing added %d", added)115 }116 if !slices.ContainsFunc(s.Videos, func(v *ytVideo) bool { return v.ID == "Fresh000001" && v.New }) ||117 !slices.ContainsFunc(s.Videos, func(v *ytVideo) bool { return v.ID == "NewUpload01" && v.Seconds == 593 }) {118 t.Errorf("second listing lost videos: %d", len(s.Videos))119 }120}121122func TestYtInfoInputs(t *testing.T) {123 info, err := youtube.ParseInfo(readTestdata(t, "yt_video.json"))124 if err != nil {125 t.Fatal(err)126 }127 in := info.Inputs()128 if len(in) != 2 || in[0].VCodec == "none" || in[1].ACodec == "none" || in[0].Height != 720 {129 t.Fatalf("inputs: %+v", in)130 }131 args, audio := youtube.InputArgs(in, 90*time.Second, true)132 if audio != "1:a:0" || countInputs(args) != 2 || args[0] != "-xerror" {133 t.Errorf("audio %q, %d inputs: %q", audio, countInputs(args), args)134 }135 joined := strings.Join(args, " ")136 for _, want := range []string{"-ss 90.000 -i https://rr1---sn-test.googlevideo.com/videoplayback?itag=298",137 "-request_size 10485760", "User-Agent: Mozilla/5.0", "\r\n"} {138 if !strings.Contains(joined, want) {139 t.Errorf("args lack %q:\n%s", want, joined)140 }141 }142 // One file with picture and sound.143 single := youtube.Info{ID: "x", Format: youtube.Format{URL: "https://example.com/v.mp4", VCodec: "avc1", ACodec: "mp4a.40.2"}}144 if args, audio := youtube.InputArgs(single.Inputs(), 0, false); audio != "0:a:0" || countInputs(args) != 1 ||145 slices.Contains(args, "-ss") || slices.Contains(args, "-request_size") {146 t.Errorf("single file: %q %q", args, audio)147 }148 if got := youtube.FormatSpec(480); got != "bv*[height<=480][vcodec^=avc1]+ba[ext=m4a][language^=en]/bv*[height<=480][vcodec^=avc1]+ba[ext=m4a]/b[height<=480]" {149 t.Errorf("format %s", got)150 }151}152153func TestYtDescription(t *testing.T) {154 for in, want := range map[string]string{155 "Batomon Showdown on Steam: https://store.steampowered.com/app/1/\n\nThis video came from my stream on September 29th, 2026. Catch me live here: http://twitch.tv/Northernlion\n\nIf you enjoyed the video...\n-----------------\nSubscribe: http://bit.ly/x": "This video came from my stream on September 29th, 2026. Catch me live here",156 "if you were on youtube shorts\n=============\nGames: fermi.gg": "if you were on youtube shorts",157 "Lego Party on Steam: https://x.com\n\nApollo: http://twitch.tv/a\nJustin: http://twitch.tv/b\n\nFun with friends.": "Fun with friends.",158 "https://only.links/here": "",159 } {160 if got := youtube.Description(in); got != want {161 t.Errorf("youtube.Description(%q) = %q, want %q", in, got, want)162 }163 }164}165166func TestSeries(t *testing.T) {167 for title, want := range map[string]string{168 "Do you do anything or what (Slay the Spire 2)": "slay the spire 2",169 "The Sounds Of Revelry | Spelunky 2 (Episode 15)": "spelunky 2",170 "The Binding of Isaac: Rebirth - Let's Play - Episode 458 [Crystal]": "the binding of isaac: rebirth",171 "Northernlion Plays - Hitman - Episode 21 [Twitch VOD]": "hitman",172 "El Dorado - Europa Universalis IV Multiplayer - Episode 53": "europa universalis iv multiplayer",173 "XCOM: Long War - Season 2 - Episode 9 [Bridge]": "xcom: long war",174 "modern day seneca - Bits and Banter [09/30/2026]": "bits and banter",175 "The Northernlion Live Super Show! [February 19th, 2014] (1/2)": "the northernlion live super show!",176 "When Rolling Out Goes Wrong | Fall Guys Season 2 #23": "fall guys season 2",177 "Playing online chess for the first time in 15 years": "playing online chess for the first time in 15 years",178 "Cities: Skylines [Re-Do] - Northernlion Plays - Episode 3": "cities: skylines",179 "Ultimate Chicken Horse with Friends - Episode 13 - Crypt": "ultimate chicken horse with friends",180 "Giving The Doubters Some Hope | Repentance on Stream (Episode 226)": "repentance on stream",181 "Let's Play - The Binding of Isaac - Episode 404 [Dentist's Appointment]": "the binding of isaac",182 "Darkest Dungeon: The Color of Madness - Northernlion Plays - Episode 32 [x]": "darkest dungeon: the color of madness",183 } {184 if got := series(title); got != want {185 t.Errorf("series(%q) = %q, want %q", title, got, want)186 }187 }188}189190func TestYtVideosURL(t *testing.T) {191 for in, want := range map[string]string{192 "@Northernlion": "https://www.youtube.com/@Northernlion/videos",193 "https://www.youtube.com/@Northernlion": "https://www.youtube.com/@Northernlion/videos",194 "https://www.youtube.com/@Northernlion/": "https://www.youtube.com/@Northernlion/videos",195 "youtube.com/@Northernlion/streams": "https://youtube.com/@Northernlion/videos",196 "UC3tNpTOHsTnkmbwztCs30sA": "https://www.youtube.com/channel/UC3tNpTOHsTnkmbwztCs30sA/videos",197 "https://www.youtube.com/@Northernlion/videos": "https://www.youtube.com/@Northernlion/videos",198 } {199 if got := youtube.VideosURL(in); got != want {200 t.Errorf("youtube.VideosURL(%q) = %q, want %q", in, got, want)201 }202 }203}204205// testPool makes two channels of videos n minutes long, cycling through206// lengths.207func testPool(per int, repeat time.Duration, minutes ...int) *ytPool {208 p := &ytPool{Repeat: repeat, Lock: 2 * time.Hour, Seed: 7, Basis: "a,b"}209 for c, ch := range []string{"UCa", "UCb"} {210 s := ytPoolSource{ID: ch}211 for i := range per {212 s.Videos = append(s.Videos, &ytVideo{213 ID: fmt.Sprintf("%s-%03d", ch, i), Title: fmt.Sprintf("Video %d (Game %d)", i, (i+c)%7),214 Seconds: minutes[i%len(minutes)] * 60,215 })216 }217 p.Sources = append(p.Sources, s)218 }219 return p220}221222// checkSchedule checks the airings are back to back and that no video223// repeats within repeat.224func checkSchedule(t *testing.T, as []ytAiring, repeat time.Duration) {225 t.Helper()226 last := map[string]time.Time{}227 for i, a := range as {228 if i > 0 && !a.Start.Equal(as[i-1].End) {229 t.Fatalf("airing %d starts %v, after %v", i, a.Start, as[i-1].End)230 }231 if t0, ok := last[a.ID]; ok && a.Start.Sub(t0) < repeat {232 t.Fatalf("%s repeats after %v", a.ID, a.Start.Sub(t0))233 }234 last[a.ID] = a.Start235 }236}237238func TestYtPlanAlternatesAndGuardsRepeats(t *testing.T) {239 now := time.Date(2026, 10, 3, 18, 7, 30, 0, time.UTC)240 pool := testPool(200, 7*24*time.Hour, 10, 25, 40)241 var p ytPlayout242 if !p.update(pool, now) {243 t.Fatal("no schedule made")244 }245 if p.Airings[0].Start != now.Truncate(time.Minute) || p.end(now).Before(now.Add(ytPlanAhead)) {246 t.Fatalf("schedule spans %v to %v", p.Airings[0].Start, p.end(now))247 }248 checkSchedule(t, p.Airings, pool.Repeat)249 for i := 1; i < len(p.Airings); i++ {250 if p.Airings[i].Channel == p.Airings[i-1].Channel {251 t.Fatalf("airings %d and %d are both from %s", i-1, i, p.Airings[i].Channel)252 }253 if series(p.Airings[i].Title) == series(p.Airings[i-1].Title) {254 t.Errorf("airings %d and %d are both %q", i-1, i, series(p.Airings[i].Title))255 }256 }257 // Nothing to do an hour later, and the same inputs plan the same.258 before := slices.Clone(p.Airings)259 if p.update(pool, now.Add(30*time.Minute)) {260 t.Error("a fresh schedule changed half an hour later")261 }262 var q ytPlayout263 q.update(testPool(200, 7*24*time.Hour, 10, 25, 40), now)264 if !sameAirings(q.Airings, before) {265 t.Error("the same inputs planned differently")266 }267}268269func TestYtFirstRunsOnlyReplaceUnlockedReruns(t *testing.T) {270 now := time.Date(2026, 10, 3, 18, 0, 0, 0, time.UTC)271 pool := testPool(200, 7*24*time.Hour, 20, 30)272 var p ytPlayout273 p.update(pool, now)274 before := slices.Clone(p.Airings)275276 // Two uploads arrive an hour later.277 later := now.Add(time.Hour)278 older := &ytVideo{ID: "UCb-new-1", Title: "Older upload", Seconds: 600, Published: later.Add(-2 * time.Hour), New: true}279 newer := &ytVideo{ID: "UCa-new-2", Title: "Newer upload", Seconds: 1500, Published: later.Add(-time.Hour), New: true}280 pool.Sources[0].Videos = append(pool.Sources[0].Videos, newer)281 pool.Sources[1].Videos = append(pool.Sources[1].Videos, older)282 pool.Firsts = []ytPick{{older, "UCb"}, {newer, "UCa"}}283 slices.SortFunc(pool.Firsts, newestFirst)284 pool.ids = nil285 if !p.update(pool, later) {286 t.Fatal("first runs didn't change the schedule")287 }288 lock := later.Add(pool.Lock)289 k := slices.IndexFunc(before, func(a ytAiring) bool { return !a.Start.Before(lock) })290 if !sameAirings(p.Airings[:k], before[:k]) {291 t.Fatal("airings within the lock changed")292 }293 if a := p.Airings[k]; a.ID != "UCa-new-2" || !a.First || !a.Start.Equal(before[k-1].End) {294 t.Errorf("first unlocked airing %+v, want the newer upload's first run", a)295 }296 if a := p.Airings[k+1]; a.ID != "UCb-new-1" || !a.First {297 t.Errorf("second unlocked airing %+v, want the older upload's first run", a)298 }299 // The reruns planned there follow, in the same order.300 var was, now2 []string301 for _, a := range before[k:] {302 was = append(was, a.ID)303 }304 for _, a := range p.Airings[k+2:] {305 now2 = append(now2, a.ID)306 }307 if len(now2) < len(was) || !slices.Equal(now2[:len(was)], was) {308 t.Errorf("reruns after the first runs were reshuffled or dropped")309 }310 checkSchedule(t, p.Airings, pool.Repeat)311 // Planned once: another update leaves them be.312 if p.update(pool, later.Add(time.Minute)) {313 t.Error("first runs were planned again")314 }315 // Once within the lock, a first run stays put like the rest, even316 // when more uploads arrive.317 first := p.Airings[k]318 newest := &ytVideo{ID: "UCb-new-3", Title: "Newest upload", Seconds: 900, Published: lock, New: true}319 pool.Sources[1].Videos = append(pool.Sources[1].Videos, newest)320 pool.Firsts = append([]ytPick{{newest, "UCb"}}, pool.Firsts...)321 pool.ids = nil322 p.update(pool, first.Start.Add(-time.Hour))323 if i := slices.IndexFunc(p.Airings, func(a ytAiring) bool { return a.ID == first.ID }); i < 0 || !sameAirings(p.Airings[i:i+1], []ytAiring{first}) {324 t.Error("a locked first run moved")325 }326 if !slices.ContainsFunc(p.Airings, func(a ytAiring) bool { return a.ID == "UCb-new-3" && a.First }) {327 t.Error("the newest upload wasn't planned")328 }329 checkSchedule(t, p.Airings, pool.Repeat)330}331332func TestYtRepeatGuardAcrossRestarts(t *testing.T) {333 dir := t.TempDir()334 repeat := 4 * 24 * time.Hour335 now := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)336 y := &YouTube{Num: "1.8", Dir: dir}337 var p ytPlayout338 // Six days of restarts every 20 hours: each loads the schedule, extends339 // it and saves it.340 for day := range 8 {341 at := now.Add(time.Duration(day) * 20 * time.Hour)342 if day > 0 {343 var err error344 if p, err = readYtPlayout(filepath.Join(dir, ytPlayoutFile)); err != nil {345 t.Fatal(err)346 }347 }348 p.update(testPool(150, repeat, 20, 30), at)349 y.save(ytPlayoutFile, p)350 }351 loaded, err := readYtPlayout(filepath.Join(dir, ytPlayoutFile))352 if err != nil {353 t.Fatal(err)354 }355 if !sameAirings(loaded.Airings, p.Airings) || loaded.Basis != p.Basis {356 t.Fatal("the schedule didn't survive a save and load")357 }358 if loaded.Airings[0].Start.After(now.Add(7*20*time.Hour - repeat)) {359 t.Errorf("history starts %v; the repeat guard needs %v back", loaded.Airings[0].Start, repeat)360 }361 checkSchedule(t, loaded.Airings, repeat)362}363364func TestYtSmallPoolRepeatsLeastRecent(t *testing.T) {365 now := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)366 pool := testPool(5, 30*24*time.Hour, 60) // ten hours of video for two days367 var p ytPlayout368 p.update(pool, now)369 // Each channel cycles through its five videos in the same order.370 for i := 10; i < len(p.Airings); i++ {371 if p.Airings[i].ID != p.Airings[i-10].ID {372 t.Fatalf("airing %d is %s, want %s again", i, p.Airings[i].ID, p.Airings[i-10].ID)373 }374 }375 seen := map[string]bool{}376 for _, a := range p.Airings[:10] {377 seen[a.ID] = true378 }379 if len(seen) != 10 {380 t.Errorf("a video repeated before all had aired: %d distinct", len(seen))381 }382}383384// TestYtCueKeepsToTheSchedule walks the stream's cues through a video that385// ends early: it's reopened where it should be, twice, then a slate fills386// its time, and the next video starts on time.387func TestYtCueKeepsToTheSchedule(t *testing.T) {388 t0 := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)389 y := &YouTube{Num: "1.8", Dir: t.TempDir(), loaded: true, now: func() time.Time { return t0 },390 pool: &ytPool{Basis: "test", Lock: time.Hour}, plan: ytPlayout{Basis: "test", Airings: []ytAiring{391 {ID: "aaaaaaaaaaa", Channel: "UCa", Title: "A", Start: t0, End: t0.Add(time.Minute)},392 {ID: "bbbbbbbbbbb", Channel: "UCb", Title: "B", Start: t0.Add(time.Minute), End: t0.Add(3 * time.Minute)},393 }}}394 cue := y.cue()395 for _, c := range []struct {396 at time.Duration397 id string398 slate bool399 frames,400 skip int64401 }{402 {10 * time.Second, "aaaaaaaaaaa", false, 60 * loopFPS, 10 * loopFPS}, // tuned in403 {20 * time.Second, "aaaaaaaaaaa", false, 60 * loopFPS, 20 * loopFPS}, // ended early: reopened404 {25 * time.Second, "aaaaaaaaaaa", false, 60 * loopFPS, 25 * loopFPS}, // and once more405 {30 * time.Second, "", true, 30 * loopFPS, 0}, // then a slate to the end406 {59*time.Second + 800*time.Millisecond, "bbbbbbbbbbb", false, 120 * loopFPS, 0},407 {80 * time.Second, "bbbbbbbbbbb", false, 120 * loopFPS, 20 * loopFPS}, // failed, reopened408 {150 * time.Second, "bbbbbbbbbbb", false, 120 * loopFPS, 90 * loopFPS}, // played a while, failed again409 {155 * time.Second, "bbbbbbbbbbb", false, 120 * loopFPS, 95 * loopFPS},410 {3*time.Minute + 5*time.Second, "", true, 10 * loopFPS, 0}, // off the end of the schedule411 } {412 it, skip, err := cue(t0.Add(c.at), true)413 if err != nil {414 t.Fatal(err)415 }416 if it.Path != c.id || it.Slate != c.slate || it.Frames != c.frames || skip != c.skip {417 t.Errorf("at %v: got %q slate=%v %d frames from %d; want %q slate=%v %d from %d",418 c.at, it.Path, it.Slate, it.Frames, skip, c.id, c.slate, c.frames, c.skip)419 }420 if !c.slate && it.Open == nil {421 t.Errorf("at %v: no Open", c.at)422 }423 }424}425426func TestYouTubeGuide(t *testing.T) {427 dir := t.TempDir()428 now := time.Now().UTC()429 write := func(name, s string) {430 if err := os.WriteFile(filepath.Join(dir, name), []byte(s), 0o644); err != nil {431 t.Fatal(err)432 }433 }434 write(youtubeConfig, `{"channels": ["@Northernlion", "https://www.youtube.com/@TheLibraryofLetourneau"], "minMinutes": 5}`)435 cat := ytCatalog{}436 for i, name := range []string{"Northernlion", "The Library of Letourneau"} {437 s := &ytSource{URL: youtube.VideosURL([]string{"@Northernlion", "@TheLibraryofLetourneau"}[i]),438 ID: fmt.Sprintf("UC%d", i), Name: name, Listed: now, Checked: now}439 for j := range 40 {440 s.Videos = append(s.Videos, &ytVideo{ID: fmt.Sprintf("v%d-%02d", i, j), Title: fmt.Sprintf("%s video %d", name, j),441 Seconds: 60 * (2 + j%30), Description: "About video " + fmt.Sprint(j), Looked: now})442 }443 cat.Sources = append(cat.Sources, s)444 }445 y := &YouTube{Num: "1.8", Title: "Northernlion", Dir: dir}446 y.save(ytCatalogFile, cat)447448 if y.Empty() {449 t.Fatal("channel is empty")450 }451 progs := y.Programs(now, now.Add(12*time.Hour))452 if len(progs) < 12 {453 t.Fatalf("%d programs in 12 hours", len(progs))454 }455 for i, p := range progs {456 if p.Subtitle != "Northernlion" && p.Subtitle != "The Library of Letourneau" ||457 !strings.HasPrefix(p.Title, p.Subtitle+" video ") || !strings.HasPrefix(p.Description, "About video") ||458 !strings.HasPrefix(p.Image, "https://i.ytimg.com/vi/v") || p.End.Sub(p.Start) < 5*time.Minute {459 t.Fatalf("program %d: %+v", i, p)460 }461 }462 if _, err := os.Stat(filepath.Join(dir, ytPlayoutFile)); err != nil {463 t.Errorf("schedule not saved: %v", err)464 }465 // A restart reads the same schedule.466 again := &YouTube{Num: "1.8", Title: "Northernlion", Dir: dir}467 if got := again.Programs(now, now.Add(12*time.Hour)); len(got) != len(progs) || !reflect.DeepEqual(got[5], progs[5]) {468 t.Error("schedule differs after a restart")469 }470}471472// TestOpenFailureKeepsTheSchedule plays three two-second items whose473// sources open per play: the first fails to open after a while, the second474// opens separate picture and sound inputs, the third fails at once. Slates475// fill the failures' time, so each item is cued on schedule by the476// stream's clock, and the stream is a single H.264 and AAC stream as long477// as the time it ran and its lead.478func TestOpenFailureKeepsTheSchedule(t *testing.T) {479 ffmpeg, err := exec.LookPath("ffmpeg")480 if err != nil {481 t.Skip("no ffmpeg")482 }483 ffprobe, err := exec.LookPath("ffprobe")484 if err != nil {485 t.Skip("no ffprobe")486 }487 dir := t.TempDir()488 picture, sound := filepath.Join(dir, "v.mp4"), filepath.Join(dir, "a.m4a")489 for out, args := range map[string][]string{490 picture: {"-f", "lavfi", "-i", "testsrc2=s=640x360:r=25:d=3", "-c:v", "libx264"},491 sound: {"-f", "lavfi", "-i", "sine=f=440:d=3", "-c:a", "aac"},492 } {493 cmd := exec.Command(ffmpeg, append(append([]string{"-hide_banner", "-loglevel", "error"}, args...), out)...)494 if b, err := cmd.CombinedOutput(); err != nil {495 t.Fatalf("make %s: %v: %s", out, err, b)496 }497 }498 var cuedAt []time.Duration // by the stream's clock499 items := []item{500 {Path: "fails-slowly", Frames: 2 * loopFPS, Open: func(ctx context.Context, _ time.Duration) (opened, error) {501 time.Sleep(400 * time.Millisecond)502 return opened{}, errors.New("yt-dlp: Sign in to confirm you're not a bot")503 }},504 {Path: "plays", Frames: 2 * loopFPS, Open: func(ctx context.Context, _ time.Duration) (opened, error) {505 return opened{Args: []string{"-i", picture, "-i", sound}, Audio: "1:a:0"}, nil506 }},507 {Path: "fails", Frames: 2 * loopFPS, Open: func(ctx context.Context, _ time.Duration) (opened, error) {508 return opened{}, errors.New("no stream")509 }},510 }511 n := 0512 var start time.Time513 cue := func(now time.Time, _ bool) (item, int64, error) {514 if n == 0 {515 start = now516 }517 cuedAt = append(cuedAt, now.Sub(start))518 if n == len(items) {519 return item{Slate: true, Frames: 10 * loopFPS}, 0, nil520 }521 n++522 return items[n-1], 0, nil523 }524 ctx, cancel := context.WithTimeout(context.Background(), 5500*time.Millisecond)525 defer cancel()526 var out bytes.Buffer527 if err := playCues(ctx, &out, ffmpeg, "test", cue); err != nil {528 t.Fatal(err)529 }530 // The time the first spent failing to open is the stream's lead's: it531 // played nothing.532 for i, want := range []time.Duration{0, 2 * time.Second, 4 * time.Second, 6 * time.Second} {533 if i >= len(cuedAt) {534 t.Fatalf("cued %d times, want %d", len(cuedAt), len(items)+1)535 }536 if d := cuedAt[i] - want; d < -time.Second/loopFPS || d > time.Second/loopFPS {537 t.Errorf("item %d cued %v in, want %v", i, cuedAt[i], want)538 }539 }540 ts := filepath.Join(dir, "out.ts")541 if err := os.WriteFile(ts, out.Bytes(), 0o644); err != nil {542 t.Fatal(err)543 }544 probe, err := exec.Command(ffprobe, "-v", "error", "-show_entries", "stream=codec_name,width,height:format=duration", "-of", "compact", ts).CombinedOutput()545 if err != nil {546 t.Fatalf("ffprobe %d bytes: %v: %s", out.Len(), err, probe)547 }548 got := string(probe)549 if i := strings.Index(got, "duration="); i < 0 {550 t.Errorf("no duration:\n%s", got)551 } else if d, _ := strconv.ParseFloat(strings.TrimSpace(got[i+len("duration="):]), 64); d < 5.5+lead.Seconds()-2.5 || d > 5.5+lead.Seconds()+0.5 {552 // Its lead ahead of the clock, less the time it took to start and553 // what the encoder held when it stopped.554 t.Errorf("played %.2fs in 5.5s with a %v lead", d, lead)555 }556 for _, want := range []string{"codec_name=h264|width=1280|height=720", "codec_name=aac"} {557 if !strings.Contains(got, want) {558 t.Errorf("stream lacks %q:\n%s", want, got)559 }560 }561}562563// TestYouTubeLive builds a real YouTube channel in the folder564// $AIRWAVES_YOUTUBE_LIVE (listing both channels takes minutes the first565// time), prints its guide, and streams 30 seconds of it to out.ts there.566// $AIRWAVES_YTDLP names the yt-dlp to use; without it the latest release567// is installed in the folder, as airwavesd does.568func TestYouTubeLive(t *testing.T) {569 root := os.Getenv("AIRWAVES_YOUTUBE_LIVE")570 if root == "" {571 t.Skip("set AIRWAVES_YOUTUBE_LIVE to a scratch folder to run against YouTube")572 }573 ffmpeg, err := exec.LookPath("ffmpeg")574 if err != nil {575 t.Skip("no ffmpeg")576 }577 dir := filepath.Join(root, "1.8 Northernlion")578 if err := os.MkdirAll(dir, 0o755); err != nil {579 t.Fatal(err)580 }581 cfg := `{"channels": ["https://www.youtube.com/@Northernlion", "https://www.youtube.com/@TheLibraryofLetourneau"],582 "repeatDays": 30, "maxHeight": 720, "minMinutes": 3, "maxMinutes": 240, "rerunMix": "recent"}`583 if err := os.WriteFile(filepath.Join(dir, youtubeConfig), []byte(cfg), 0o644); err != nil {584 t.Fatal(err)585 }586 tool := &youtube.YtDlp{Path: os.Getenv("AIRWAVES_YTDLP")}587 if tool.Path == "" {588 tool.Path, tool.Update = filepath.Join(root, "tools", "yt-dlp"), true589 }590 y := &YouTube{Num: "1.8", Title: "Northernlion", Dir: dir, FFmpeg: ffmpeg, YtDlp: tool}591 y.mu.Lock()592 y.load()593 y.busy = true594 y.mu.Unlock()595 began := time.Now()596 y.refresh()597 t.Logf("refresh took %v", time.Since(began).Round(time.Second))598 y.mu.Lock()599 for _, s := range y.cat.Sources {600 n := 0601 for _, v := range s.Videos {602 if y.cfg.playable(v) {603 n++604 }605 }606 t.Logf("%s (%s): %d videos, %d playable", s.Name, s.ID, len(s.Videos), n)607 }608 y.mu.Unlock()609610 now := time.Now()611 progs := y.Programs(now, now.Add(24*time.Hour))612 if len(progs) < 12 {613 t.Fatalf("%d programs", len(progs))614 }615 for _, p := range progs[:12] {616 new := ""617 if p.New {618 new = " (new)"619 }620 t.Logf("%s-%s %s | %s%s | %.60q | %s", p.Start.Format("15:04:05"), p.End.Format("15:04:05"),621 p.Title, p.Subtitle, new, p.Description, p.Image)622 }623624 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)625 defer cancel()626 f, err := os.Create(filepath.Join(root, "out.ts"))627 if err != nil {628 t.Fatal(err)629 }630 defer f.Close()631 w := &firstWrite{w: f, start: time.Now()}632 if err := y.Stream(ctx, w); err != nil {633 t.Fatal(err)634 }635 t.Logf("first bytes after %v", w.first.Round(10*time.Millisecond))636}637638type firstWrite struct {639 w io.Writer640 start time.Time641 first time.Duration642}643644func (f *firstWrite) Write(p []byte) (int, error) {645 if f.first == 0 {646 f.first = time.Since(f.start)647 }648 return f.w.Write(p)649}