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 / ytplayout_test.go
ytplayout_test.go 20.4K
1package vchan23import (4 "fmt"5 "os"6 "path/filepath"7 "slices"8 "strings"9 "testing"10 "time"1112 "airwaves/internal/cc"13 "airwaves/internal/youtube"14)1516func TestYtConfigDefaultsAndMix(t *testing.T) {17 dir := t.TempDir()18 path := filepath.Join(dir, youtubeConfig)19 write := func(s string) {20 if err := os.WriteFile(path, []byte(s), 0o644); err != nil {21 t.Fatal(err)22 }23 }24 write(`{"channels": ["@Northernlion"]}`)25 c, err := readYtConfig(path)26 if err != nil || c.RerunMix != "balanced" || c.LockHours != 2 || c.RepeatDays != 30 {27 t.Errorf("defaults: %+v, %v", c, err)28 }29 write(`{"channels": ["@Northernlion"], "rerunMix": "recent", "lockHours": 0.5}`)30 if c, err := readYtConfig(path); err != nil || c.RerunMix != "recent" || c.LockHours != 0.5 {31 t.Errorf("set: %+v, %v", c, err)32 }33 write(`{"channels": ["@Northernlion"], "rerunMix": "newest"}`)34 if _, err := readYtConfig(path); err == nil || !strings.Contains(err.Error(), `"balanced"`) {35 t.Errorf("an unknown mix should be refused, got %v", err)36 }37}3839func TestYtListingDates(t *testing.T) {40 l, err := parseYtListing(readTestdata(t, "yt_listing_dated.json"))41 if err != nil {42 t.Fatal(err)43 }44 want := []string{"2026-10-03", "2026-09-04", "2026-02-04", "2025-10-04", "2023-10-04", "2022-10-04"}45 for i, v := range l.Videos {46 if got := v.Published.Format(time.DateOnly); got != want[i] || !v.Approx {47 t.Errorf("video %d (%s): published %s approx %v, want %s", i, v.ID, got, v.Approx, want[i])48 }49 }5051 // A listing updates approximate dates and leaves known ones be, and52 // marks the catalog as dated so it isn't listed again early.53 now := time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC)54 exact := time.Date(2026, 10, 3, 14, 0, 20, 0, time.UTC)55 s := &ytSource{URL: "u", Listed: now.Add(-time.Hour), Videos: []*ytVideo{56 {ID: "9KaMhx-S0jo", Published: exact},57 {ID: "ZAPEKtIcxsc", Published: now.AddDate(-1, 0, 0), Approx: true},58 }}59 y := &YouTube{tried: map[string]time.Time{}}60 if !y.listDue(s, now) {61 t.Error("a catalog listed without dates isn't due a listing")62 }63 s.mergeListing(l, now)64 if v := s.Videos[0]; !v.Published.Equal(exact) || v.Approx {65 t.Errorf("exact date replaced: %+v", v)66 }67 if v := s.Videos[1]; v.Published.Format(time.DateOnly) != "2026-09-04" || !v.Approx {68 t.Errorf("approximate date not updated: %+v", v)69 }70 if !s.Dated || y.listDue(s, now.Add(time.Hour)) {71 t.Errorf("dated %v, still due a listing", s.Dated)72 }73}7475func TestYtDated(t *testing.T) {76 now := time.Date(2026, 10, 3, 18, 0, 0, 0, time.UTC)77 for _, c := range []struct {78 v ytVideo79 subtitle, day string80 }{81 {ytVideo{Published: time.Date(2014, 3, 4, 12, 0, 0, 0, time.Local)}, "Northernlion, Mar 4, 2014", "20140304"},82 {ytVideo{Published: time.Date(2023, 10, 4, 0, 0, 0, 0, time.UTC), Approx: true}, "Northernlion, 2023", "2023"},83 {ytVideo{Published: time.Date(2025, 10, 3, 0, 0, 0, 0, time.UTC), Approx: true}, "Northernlion, 2025", "2025"},84 {ytVideo{Published: time.Date(2026, 3, 4, 0, 0, 0, 0, time.UTC), Approx: true}, "Northernlion, March 2026", "202603"},85 {ytVideo{}, "Northernlion", ""},86 } {87 if sub, day := ytDated("Northernlion", &c.v, now); sub != c.subtitle || day != c.day {88 t.Errorf("%v (approx %v): got %q %q, want %q %q", c.v.Published, c.v.Approx, sub, day, c.subtitle, c.day)89 }90 }91}9293func TestYtByAge(t *testing.T) {94 now := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)95 ago := func(days int) time.Time { return now.AddDate(0, 0, -days) }96 vs := []*ytVideo{97 {ID: "a", Published: ago(10)},98 {ID: "b"}, // between a and c: the newer99 {ID: "c", Published: ago(400)},100 {ID: "d"}, // nearest dated: c, then e101 {ID: "e"}, // nearest dated: f102 {ID: "f", Published: ago(1000)},103 {ID: "g"}, // f104 }105 half, two := ytByAge(vs, now)106 ids := func(vs []*ytVideo) (s string) {107 for _, v := range vs {108 s += v.ID109 }110 return s111 }112 if ids(half) != "ab" || ids(two) != "abcd" {113 t.Errorf("half a year %q, two years %q; want ab and abcd", ids(half), ids(two))114 }115 // Without any dates, rank stands in.116 var undated []*ytVideo117 for i := range 100 {118 undated = append(undated, &ytVideo{ID: fmt.Sprint(i)})119 }120 if half, two := ytByAge(undated, now); len(half) != 3 || len(two) != 12 || half[0].ID != "0" {121 t.Errorf("by rank: %d and %d", len(half), len(two))122 }123}124125// agedPool is one channel of 100 videos from the last six months, 100126// from the 18 months before, and 800 older.127func agedPool(now time.Time, mix string) *ytPool {128 s := ytPoolSource{ID: "UCa"}129 for i := range 1000 {130 days := 1 + i*90/100 // ages 1 to 90 days131 switch {132 case i >= 200:133 days = 800 + i134 case i >= 100:135 days = 200 + (i-100)*5136 }137 s.Videos = append(s.Videos, &ytVideo{ID: fmt.Sprintf("v%04d", i), Title: fmt.Sprintf("Video %d", i),138 Seconds: 1200, Published: now.AddDate(0, 0, -days), Approx: true})139 }140 s.HalfYear, s.TwoYears = ytByAge(s.Videos, now)141 return &ytPool{Sources: []ytPoolSource{s}, Mix: ytRerunMixes[mix], Seed: 3, Lock: 2 * time.Hour}142}143144// shares picks n reruns and counts how many come from the last six145// months, the 18 months before, and earlier.146func shares(g *ytGuard, now time.Time, n int, aired map[string]bool) (out [3]float64) {147 for i := range n {148 f, _ := g.pick(now.Add(time.Duration(i) * time.Minute))149 if aired[f.Video.ID] {150 return [3]float64{-1, -1, -1}151 }152 age := now.Sub(f.Video.Published)153 switch {154 case age <= ytHalfYear:155 out[0]++156 case age <= ytTwoYears:157 out[1]++158 default:159 out[2]++160 }161 }162 for i := range out {163 out[i] /= float64(n)164 }165 return out166}167168func TestYtRerunMixWeights(t *testing.T) {169 now := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)170 // The last two years hold the last six months, and any time holds171 // both: with "recent", 70% + 20%*100/200 + 10%*100/1000 are from the172 // last six months.173 for mix, want := range map[string][3]float64{174 "recent": {0.81, 0.11, 0.08},175 "balanced": {0.58, 0.18, 0.24},176 "any": {0.10, 0.10, 0.80},177 } {178 got := shares(newYtGuard(nil, agedPool(now, mix)), now, 6000, nil)179 t.Logf("%s: %.3f", mix, got)180 for i := range got {181 if got[i] < want[i]-0.025 || got[i] > want[i]+0.025 {182 t.Errorf("%s: shares %.3f, want about %.2f", mix, got, want)183 break184 }185 }186 }187}188189func TestYtRerunMixFallsBack(t *testing.T) {190 now := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)191 pool := agedPool(now, "recent")192 pool.Repeat = 30 * 24 * time.Hour193 // Everything from the last six months aired yesterday: picks meant194 // for them go to the last two years instead.195 var history []ytAiring196 aired := map[string]bool{}197 for i, v := range pool.Sources[0].HalfYear {198 start := now.Add(-24*time.Hour + time.Duration(i)*time.Minute)199 history = append(history, ytAiring{ID: v.ID, Channel: "UCa", Title: v.Title, Start: start, End: start.Add(time.Minute)})200 aired[v.ID] = true201 }202 got := shares(newYtGuard(history, pool), now, 300, aired)203 if got[0] != 0 || got[1] < 0.8 {204 t.Errorf("shares %.2f; want none from the last six months, most from the 18 months before", got)205 }206 // Nothing recent at all: every pick comes from the rest.207 old := agedPool(now, "recent")208 old.Sources[0].Videos = old.Sources[0].Videos[200:]209 old.Sources[0].HalfYear, old.Sources[0].TwoYears = ytByAge(old.Sources[0].Videos, now)210 if got := shares(newYtGuard(nil, old), now, 100, nil); got[2] != 1 {211 t.Errorf("shares %.2f from a channel with only old videos", got)212 }213}214215// TestYtSettingsChangeReplansFromNow: changing the channel's settings216// replaces what's on at once and everything after, keeping what aired for217// the repeat guard.218func TestYtSettingsChangeReplansFromNow(t *testing.T) {219 now := time.Date(2026, 10, 3, 18, 0, 0, 0, time.UTC)220 pool := testPool(200, 7*24*time.Hour, 20, 30)221 var p ytPlayout222 p.update(pool, now)223 before := slices.Clone(p.Airings)224225 edit := now.Add(50*time.Minute + 30*time.Second)226 on, _, _ := p.at(edit)227 pool.Basis = "a,b|recent"228 if !p.update(pool, edit) {229 t.Fatal("a settings change didn't change the schedule")230 }231 i := slices.IndexFunc(p.Airings, func(a ytAiring) bool { return a.ID == on.ID && a.Start.Equal(on.Start) })232 if i < 0 || !p.Airings[i].End.Equal(edit) {233 t.Fatalf("the airing on at the edit wasn't cut short there: %+v", p.Airings[i])234 }235 if !sameAirings(p.Airings[:i], before[:i]) {236 t.Error("what aired before the edit changed")237 }238 now2, _, ok := p.at(edit)239 if !ok || !now2.Start.Equal(edit) || now2.ID == on.ID {240 t.Errorf("on at the edit: %+v, want a new airing from then", now2)241 }242 if p.Basis != "a,b|recent" || p.end(edit).Before(edit.Add(ytPlanAhead)) {243 t.Errorf("basis %q, schedule to %v", p.Basis, p.end(edit))244 }245 checkSchedule(t, p.Airings, pool.Repeat)246 if p.update(pool, edit.Add(time.Minute)) {247 t.Error("planned again without a change")248 }249}250251// TestYtNewUploadLeavesTheLockAlone: a new upload changes nothing within252// the lock, what's on included, and goes in right after it.253func TestYtNewUploadLeavesTheLockAlone(t *testing.T) {254 now := time.Date(2026, 10, 3, 18, 0, 0, 0, time.UTC)255 pool := testPool(200, 7*24*time.Hour, 20, 30)256 var p ytPlayout257 p.update(pool, now)258 before := slices.Clone(p.Airings)259260 later := now.Add(50 * time.Minute)261 v := &ytVideo{ID: "UCa-new", Title: "New upload", Seconds: 600, Published: later, New: true}262 pool.Sources[0].Videos = append(pool.Sources[0].Videos, v)263 pool.Firsts, pool.ids = []ytPick{{v, "UCa"}}, nil264 p.update(pool, later)265 k := slices.IndexFunc(before, func(a ytAiring) bool { return !a.Start.Before(later.Add(pool.Lock)) })266 if !sameAirings(p.Airings[:k], before[:k]) {267 t.Fatal("airings within the lock changed")268 }269 if a := p.Airings[k]; a.ID != v.ID || !a.First || !a.Start.Equal(before[k-1].End) {270 t.Errorf("after the lock: %+v, want the new upload's first run", a)271 }272}273274// TestYouTubeTakesInEdits: editing youtube.json replans the running275// channel from now.276func TestYouTubeTakesInEdits(t *testing.T) {277 dir := t.TempDir()278 now := time.Now().UTC()279 cfg := filepath.Join(dir, youtubeConfig)280 if err := os.WriteFile(cfg, []byte(`{"channels": ["@a"]}`), 0o644); err != nil {281 t.Fatal(err)282 }283 s := &ytSource{URL: youtube.VideosURL("@a"), ID: "UCa", Name: "A", Listed: now, Checked: now, Dated: true}284 for j := range 300 {285 s.Videos = append(s.Videos, &ytVideo{ID: fmt.Sprintf("v%03d", j), Title: fmt.Sprintf("Video %d", j),286 Seconds: 3600, Published: now.AddDate(0, 0, -3*j), Approx: true, Looked: now})287 }288 y := &YouTube{Num: "1.8", Title: "A", Dir: dir}289 y.save(ytCatalogFile, ytCatalog{Sources: []*ytSource{s}})290 first := y.Programs(now, now.Add(time.Hour))291 if len(first) == 0 || !strings.HasPrefix(first[0].Subtitle, "A, ") || first[0].Date == "" {292 t.Fatalf("guide: %+v", first)293 }294295 if err := os.WriteFile(cfg, []byte(`{"channels": ["@a"], "rerunMix": "recent"}`), 0o644); err != nil {296 t.Fatal(err)297 }298 if err := os.Chtimes(cfg, now.Add(time.Minute), now.Add(time.Minute)); err != nil {299 t.Fatal(err)300 }301 at := time.Now()302 after := y.Programs(at, at.Add(time.Hour))303 if len(after) == 0 || after[0].Start.Before(at.Add(-2*time.Second)) || after[0].Start.After(at) {304 t.Fatalf("after the edit, on now: %+v, want an airing from the edit", after)305 }306 plan, err := readYtPlayout(filepath.Join(dir, ytPlayoutFile))307 if err != nil || !strings.HasSuffix(plan.Basis, "|recent") {308 t.Errorf("saved schedule's basis %q, %v", plan.Basis, err)309 }310}311312// maxAgeChannel is a channel whose one source has, newest first: an313// upload dated exactly 10 days ago, an undated one beside it, one roughly314// 20 days ago, one exactly 40 days ago, an undated one beside that, and315// 50 roughly a year old or more. It reads its settings from cfg.316func maxAgeChannel(t *testing.T, now time.Time, cfg string) *YouTube {317 t.Helper()318 dir := t.TempDir()319 if err := os.WriteFile(filepath.Join(dir, youtubeConfig), []byte(cfg), 0o644); err != nil {320 t.Fatal(err)321 }322 s := &ytSource{URL: youtube.VideosURL("@a"), ID: "UCa", Name: "A", Listed: now, Checked: now, Dated: true}323 add := func(id string, published time.Time, approx bool) {324 s.Videos = append(s.Videos, &ytVideo{ID: id, Title: "Video " + id, Seconds: 1800,325 Published: published, Approx: approx, Looked: now})326 }327 add("exact10", now.AddDate(0, 0, -10), false)328 add("undated1", time.Time{}, false)329 add("approx20", now.AddDate(0, 0, -20), true)330 add("exact40", now.AddDate(0, 0, -40), false)331 add("undated2", time.Time{}, false)332 for i := range 50 {333 add(fmt.Sprintf("old%02d", i), now.AddDate(-1, 0, -i), true)334 }335 y := &YouTube{Num: "1.8", Title: "A", Dir: dir, now: func() time.Time { return now }}336 y.save(ytCatalogFile, ytCatalog{Sources: []*ytSource{s}})337 y.mu.Lock()338 y.load()339 y.busy = true // no refreshes: nothing to fetch in tests340 y.mu.Unlock()341 return y342}343344func poolIDs(y *YouTube) []string {345 y.mu.Lock()346 defer y.mu.Unlock()347 var ids []string348 for _, v := range y.currentPool().Sources[0].Videos {349 ids = append(ids, v.ID)350 }351 return ids352}353354func TestYtMaxAgeDays(t *testing.T) {355 now := time.Date(2026, 10, 3, 18, 0, 0, 0, time.UTC)356 y := maxAgeChannel(t, now, `{"channels": ["@a"], "maxAgeDays": 30, "lockHours": 2}`)357 // The undated videos take their nearest dated neighbour's age:358 // undated1 is beside exact10, undated2 beside exact40 (the newer of359 // two as near).360 if got, want := poolIDs(y), []string{"exact10", "undated1", "approx20"}; !slices.Equal(got, want) {361 t.Errorf("videos within 30 days: %v, want %v", got, want)362 }363 if st := y.Status(); st.Sources[0].Playable != 3 || st.Sources[0].Videos != 55 || st.Config.MaxAgeDays != 30 {364 t.Errorf("status: %+v", st.Sources[0])365 }366 progs := y.Programs(now, now.Add(48*time.Hour))367 if len(progs) == 0 {368 t.Fatal("nothing scheduled")369 }370 for _, p := range progs {371 if !slices.Contains([]string{"Video exact10", "Video undated1", "Video approx20"}, p.Title) {372 t.Fatalf("scheduled %q, older than 30 days", p.Title)373 }374 }375376 // Twelve days on, approx20 (now 32 days old) ages out: it isn't picked377 // any more, but what's planned stays.378 y.mu.Lock()379 before := slices.Clone(y.plan.Airings)380 y.mu.Unlock()381 later := now.Add(12 * 24 * time.Hour)382 y.now = func() time.Time { return later }383 if got, want := poolIDs(y), []string{"exact10", "undated1"}; !slices.Equal(got, want) {384 t.Errorf("videos within 30 days, 12 days on: %v, want %v", got, want)385 }386 y.mu.Lock()387 has := y.currentPool().has("approx20")388 y.mu.Unlock()389 if !has {390 t.Error("an aged-out video may no longer stay on the schedule")391 }392 // The schedule then is the old one's last airings plus new ones; none393 // of the new picks is approx20.394 y.Programs(later, later.Add(time.Hour))395 y.mu.Lock()396 after := slices.Clone(y.plan.Airings)397 y.mu.Unlock()398 for _, a := range after {399 if a.Start.After(before[len(before)-1].Start) && a.ID == "approx20" {400 t.Fatalf("picked approx20 at %v after it aged out", a.Start)401 }402 }403}404405func TestYtMaxAgeBasis(t *testing.T) {406 now := time.Date(2026, 10, 3, 18, 0, 0, 0, time.UTC)407 basis := func(cfg string) string {408 y := maxAgeChannel(t, now, cfg)409 y.mu.Lock()410 defer y.mu.Unlock()411 return y.currentPool().Basis412 }413 none, zero, month := basis(`{"channels": ["@a"]}`), basis(`{"channels": ["@a"], "maxAgeDays": 0}`), basis(`{"channels": ["@a"], "maxAgeDays": 30}`)414 if none != zero || month == none || !strings.HasSuffix(month, "|30d") {415 t.Errorf("bases %q, %q, %q: no limit should plan as before, and a limit differently", none, zero, month)416 }417}418419// TestYtNothingNewEnough: when no video is new enough, nothing is420// scheduled and the channel leaves the lineup, like a folder without421// videos, until a new upload arrives.422func TestYtNothingNewEnough(t *testing.T) {423 now := time.Date(2026, 10, 3, 18, 0, 0, 0, time.UTC)424 y := maxAgeChannel(t, now, `{"channels": ["@a"], "maxAgeDays": 5}`)425 if !y.Empty() || len(y.Programs(now, now.Add(48*time.Hour))) != 0 || y.Status().Sources[0].Playable != 0 {426 t.Fatal("a channel with nothing new enough should be empty")427 }428 y.mu.Lock()429 src := y.cat.Sources[0]430 src.Videos = append([]*ytVideo{{ID: "brandnew", Title: "Brand new", Seconds: 900, Published: now.Add(-time.Hour),431 Found: now, New: true, Looked: now}}, src.Videos...)432 y.changed()433 y.mu.Unlock()434 if y.Empty() {435 t.Fatal("a new upload should put the channel back on the air")436 }437 progs := y.Programs(now, now.Add(48*time.Hour))438 if len(progs) == 0 || progs[0].Title != "Brand new" || !progs[0].New {439 t.Errorf("guide: %+v", progs)440 }441}442443// TestYtDeadAir: with dead air, a channel whose one video has aired sits444// out its turns, and once everything has aired nothing more is planned;445// without it, the reruns cycle.446func TestYtDeadAir(t *testing.T) {447 now := time.Date(2026, 10, 3, 18, 0, 0, 0, time.UTC)448 pool := func(deadAir bool) *ytPool {449 p := testPool(10, 30*24*time.Hour, 20)450 p.Sources[0].Videos = p.Sources[0].Videos[:1]451 p.DeadAir = deadAir452 return p453 }454 var cycle ytPlayout455 cycle.update(pool(false), now)456 if n := countID(cycle.Airings, "UCa-000"); n < 5 {457 t.Errorf("cycling: UCa's one video aired %d times in two days", n)458 }459460 var dead ytPlayout461 dead.update(pool(true), now)462 checkSchedule(t, dead.Airings, 30*24*time.Hour)463 if len(dead.Airings) != 11 || countID(dead.Airings, "UCa-000") != 1 {464 t.Fatalf("dead air: %d airings; want each of the 11 videos once", len(dead.Airings))465 }466 if dead.update(pool(true), now.Add(5*time.Hour)) {467 t.Error("planned more with nothing rested")468 }469}470471func countID(as []ytAiring, id string) (n int) {472 for _, a := range as {473 if a.ID == id {474 n++475 }476 }477 return n478}479480// TestYtDeadAirChannel: a channel with dead air stays in the lineup once481// it runs out, standing by, and its guide says it's off the air.482func TestYtDeadAirChannel(t *testing.T) {483 now := time.Date(2026, 10, 3, 18, 0, 0, 0, time.UTC)484 y := maxAgeChannel(t, now, `{"channels": ["@a"], "maxAgeDays": 15, "deadAir": true}`)485 progs := y.Programs(now, now.Add(48*time.Hour))486 if len(progs) != 3 || progs[2].Title != "Off air" || !progs[2].Start.Equal(now.Add(time.Hour)) ||487 !progs[2].End.Equal(now.Add(48*time.Hour)) {488 t.Fatalf("guide: %+v; want the two recent videos, then off air", progs)489 }490 later := now.Add(3 * time.Hour)491 y.now = func() time.Time { return later }492 if y.Empty() {493 t.Error("a channel with dead air left the lineup")494 }495 if progs := y.Programs(later, later.Add(time.Hour)); len(progs) != 1 || progs[0].Title != "Off air" {496 t.Errorf("guide while off the air: %+v", progs)497 }498499 cycling := maxAgeChannel(t, now, `{"channels": ["@a"], "maxAgeDays": 15}`)500 for _, p := range cycling.Programs(now, now.Add(48*time.Hour)) {501 if p.Title == "Off air" {502 t.Fatal("a cycling channel went off the air")503 }504 }505}506507// TestYtAgingOutKeepsThePlan: a planned video that ages out still airs;508// only new picks leave it out.509func TestYtAgingOutKeepsThePlan(t *testing.T) {510 now := time.Date(2026, 10, 3, 18, 0, 0, 0, time.UTC)511 y := maxAgeChannel(t, now, `{"channels": ["@a"], "maxAgeDays": 20.25, "repeatDays": 0}`)512 y.Programs(now, now.Add(time.Hour))513 y.mu.Lock()514 before := slices.Clone(y.plan.Airings)515 y.mu.Unlock()516 if !slices.ContainsFunc(before, func(a ytAiring) bool { return a.ID == "approx20" && a.Start.After(now.Add(12*time.Hour)) }) {517 t.Fatal("approx20 should be planned later than 12 hours on")518 }519 later := now.Add(13 * time.Hour) // approx20 is 20.5 days old520 y.now = func() time.Time { return later }521 y.Programs(later, later.Add(time.Hour))522 y.mu.Lock()523 after := slices.Clone(y.plan.Airings)524 y.mu.Unlock()525 if len(after) < len(before) || !sameAirings(after[:len(before)], before) {526 t.Error("the schedule changed when a planned video aged out")527 }528 for _, a := range after[len(before):] {529 if a.ID == "approx20" {530 t.Errorf("picked approx20 at %v, after it aged out", a.Start)531 }532 }533}534535// TestYtAudioLangAndCaptions: a lookup tells the sound's language and536// whether there are English captions, and the guide carries them, so the537// app turns captions on for a Spanish video.538func TestYtAudioLangAndCaptions(t *testing.T) {539 now := time.Date(2026, 10, 3, 18, 0, 0, 0, time.UTC)540 es, err := youtube.ParseInfo(readTestdata(t, "yt_video_es.json"))541 if err != nil {542 t.Fatal(err)543 }544 en, err := youtube.ParseInfo(readTestdata(t, "yt_video.json"))545 if err != nil {546 t.Fatal(err)547 }548 var spanish, plain ytVideo549 spanish.learn(es, now)550 plain.learn(en, now)551 if spanish.AudioLang != "es" || !spanish.Captions || !cc.Foreign(spanish.AudioLang) {552 t.Errorf("Spanish video: %q, captions %v", spanish.AudioLang, spanish.Captions)553 }554 if plain.AudioLang != "" || plain.Captions || cc.Foreign(plain.AudioLang) {555 t.Errorf("video without either: %q, captions %v", plain.AudioLang, plain.Captions)556 }557558 y := maxAgeChannel(t, now, `{"channels": ["@a"], "maxAgeDays": 15}`)559 y.mu.Lock()560 for _, v := range y.cat.Sources[0].Videos {561 v.AudioLang, v.Captions = "es-419", true562 }563 y.mu.Unlock()564 progs := y.Programs(now, now.Add(time.Hour))565 if len(progs) == 0 || progs[0].AudioLang != "es-419" || !progs[0].Captions {566 t.Errorf("guide: %+v", progs)567 }568}