1package vchan23import (4 "bytes"5 "cmp"6 "context"7 "fmt"8 "os"9 "os/exec"10 "path/filepath"11 "reflect"12 "slices"13 "strconv"14 "strings"15 "testing"16 "time"17)1819func TestPlayingLoopsOnTheClock(t *testing.T) {20 items := []item{{Frames: 60 * loopFPS}, {Frames: 30 * loopFPS}} // a 90 s loop21 for _, c := range []struct {22 at time.Duration23 i int24 intoSecs int6425 }{26 {0, 0, 0},27 {59 * time.Second, 0, 59},28 {60 * time.Second, 1, 0},29 {100 * time.Second, 0, 10},30 {-10 * time.Second, 1, 20},31 } {32 i, into := playing(items, scheduleEpoch.Add(c.at))33 if i != c.i || into != c.intoSecs*loopFPS {34 t.Errorf("at %v: got item %d, %d frames in; want %d, %d", c.at, i, into, c.i, c.intoSecs*loopFPS)35 }36 }37 if i, _ := playing(nil, time.Now()); i != -1 {38 t.Errorf("empty folder: got item %d", i)39 }40}4142func TestProgramsCoverTheWindow(t *testing.T) {43 f := &Folder{scanned: time.Now(), items: []item{44 {Frames: 20 * 60 * loopFPS, Title: "Birds"},45 {Frames: 45 * 60 * loopFPS, Title: "Fish"},46 }}47 from := time.Now()48 to := from.Add(6 * time.Hour)49 progs := f.Programs(from, to)50 if len(progs) == 0 || progs[0].Start.After(from) || progs[len(progs)-1].End.Before(to) {51 t.Fatalf("programs don't cover the window: %+v", progs)52 }53 for i := 1; i < len(progs); i++ {54 if !progs[i].Start.Equal(progs[i-1].End) {55 t.Fatalf("gap between %d and %d", i-1, i)56 }57 if progs[i].Title == progs[i-1].Title {58 t.Fatalf("videos should alternate: %q twice", progs[i].Title)59 }60 }61}6263func TestDescribeUsesYtDlpInfo(t *testing.T) {64 dir := t.TempDir()65 plain := filepath.Join(dir, "Mouse hunt for cats [dQw4w9WgXcQ].mp4")66 if title, desc, _ := describe(plain); title != "Mouse hunt for cats" || desc != "" {67 t.Errorf("without info: got %q, %q", title, desc)68 }69 withInfo := filepath.Join(dir, "birds [abcdefghijk].webm")70 info := `{"title": "Birds for Cats 8 Hours", "description": "Relaxing birds at the feeder.\nNo ads.\n\nSubscribe: https://example.com"}`71 if err := os.WriteFile(filepath.Join(dir, "birds [abcdefghijk].info.json"), []byte(info), 0o644); err != nil {72 t.Fatal(err)73 }74 if title, desc, _ := describe(withInfo); title != "Birds for Cats 8 Hours" || desc != "Relaxing birds at the feeder. No ads." {75 t.Errorf("with info: got %q, %q", title, desc)76 }77}7879func TestLibraryNumbersFolders(t *testing.T) {80 root := t.TempDir()81 for _, d := range []string{"1.2 Cat Sensory", "Cat Calming", "1.1 Clash", ".hidden"} {82 if err := os.Mkdir(filepath.Join(root, d), 0o755); err != nil {83 t.Fatal(err)84 }85 }86 l := &Library{Root: root, Reserved: func() []string { return []string{"1.1"} }}87 var got []string88 for _, e := range l.scan() {89 got = append(got, e.Channel.Number()+" "+e.Channel.Name())90 }91 want := "1.2 Cat Sensory|1.3 1.1 Clash|1.4 Cat Calming"92 if strings.Join(got, "|") != want {93 t.Errorf("got %q, want %q", strings.Join(got, "|"), want)94 }95}9697func TestPartialDownloadsAreSkipped(t *testing.T) {98 for name, skip := range map[string]bool{99 "a [x].mp4": false,100 "a [x].mp4.part": true,101 "a [x].f137.mp4": true,102 "a [x].f251.webm": true,103 "a [x].temp.mp4": true,104 "a [x].mp4.ytdl": true,105 "Episode 4.5.mkv": false,106 "cats.fun.mp4": false,107 } {108 if partial.MatchString(name) != skip {109 t.Errorf("%s: skip=%v", name, !skip)110 }111 }112}113114// TestFolderStream plays across a video boundary, with videos of different115// sizes, codecs and one without sound, and checks the result is a single116// H.264 and AAC stream.117func TestFolderStream(t *testing.T) {118 ffmpeg, err := exec.LookPath("ffmpeg")119 if err != nil {120 t.Skip("no ffmpeg")121 }122 ffprobe, err := exec.LookPath("ffprobe")123 if err != nil {124 t.Skip("no ffprobe")125 }126 dir := t.TempDir()127 for name, args := range map[string][]string{128 "a.mp4": {"-f", "lavfi", "-i", "testsrc2=s=1920x1080:r=25:d=2", "-f", "lavfi", "-i", "sine=f=440:d=2", "-c:v", "libx264", "-c:a", "aac"},129 "b.mkv": {"-f", "lavfi", "-i", "testsrc=s=480x640:r=24:d=2", "-c:v", "mpeg4"},130 } {131 cmd := exec.Command(ffmpeg, append(append([]string{"-hide_banner", "-loglevel", "error"}, args...), filepath.Join(dir, name))...)132 if out, err := cmd.CombinedOutput(); err != nil {133 t.Fatalf("make %s: %v: %s", name, err, out)134 }135 }136 f := &Folder{Num: "1.9", Title: "Test", Dir: dir, FFmpeg: ffmpeg, FFprobe: ffprobe}137 if f.Empty() {138 t.Fatal("folder reads as empty")139 }140 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)141 defer cancel()142 var out bytes.Buffer143 if err := f.Stream(ctx, &out); err != nil {144 t.Fatal(err)145 }146 ts := filepath.Join(dir, "out.ts")147 if err := os.WriteFile(ts, out.Bytes(), 0o644); err != nil {148 t.Fatal(err)149 }150 probe, err := exec.Command(ffprobe, "-v", "error", "-show_entries", "stream=codec_name,width,height:format=duration", "-of", "compact", ts).CombinedOutput()151 if err != nil {152 t.Fatalf("ffprobe %d bytes: %v: %s", out.Len(), err, probe)153 }154 got := string(probe)155 // Five seconds of real time plays past the first two-second video.156 if i := strings.Index(got, "duration="); i < 0 {157 t.Errorf("no duration:\n%s", got)158 } else if d, _ := strconv.ParseFloat(strings.TrimSpace(got[i+len("duration="):]), 64); d < 3 {159 t.Errorf("played %.1fs, want past the first video", d)160 }161 for _, want := range []string{"codec_name=h264|width=1280|height=720", "codec_name=aac"} {162 if !strings.Contains(got, want) {163 t.Errorf("stream lacks %q:\n%s", want, got)164 }165 }166}167168// scheduledFolder is a folder channel of three hour-long episodes whose169// channel.json says schedule.170func scheduledFolder(t *testing.T, schedule string) *Folder {171 t.Helper()172 dir := t.TempDir()173 writeFile(t, filepath.Join(dir, DetailsFile), `{"callSign": "HOTD", "schedule": `+schedule+`}`)174 var items []item175 for i := range 3 {176 items = append(items, item{Frames: 60 * 60 * loopFPS, Title: "House of the Dragon", Subtitle: fmt.Sprintf("Episode %d", i+1),177 Season: "1", Episode: strconv.Itoa(i + 1)})178 }179 return &Folder{Num: "1.7", Title: "Dragons", Dir: dir, scanned: time.Now(), items: items}180}181182// TestScheduledFolderGuide: two episodes nightly at 8 PM from Oct 4, with183// the guide off the air between, saying what's next and when.184func TestScheduledFolderGuide(t *testing.T) {185 f := scheduledFolder(t, `{"start": "2026-10-04", "blocks": [{"at": "20:00", "count": 2}]}`)186 at := func(day, h, m int) time.Time { return time.Date(2026, 10, day, h, m, 0, 0, time.Local) }187188 // The day before: nothing yet, starting tomorrow with the first.189 progs := f.Programs(at(3, 12, 0), at(4, 12, 0))190 if len(progs) != 1 || !progs[0].OffAir || progs[0].Title != "Off air" || !progs[0].End.Equal(at(4, 20, 0)) ||191 progs[0].Description != "Starts Sun, Oct 4 at 8:00 PM with House of the Dragon, S1 E1." {192 t.Fatalf("before the start: %+v", progs)193 }194195 // The second day: episode 3, then the first again, off the air between.196 progs = f.Programs(at(5, 12, 0), at(6, 12, 0))197 var got []string198 for _, p := range progs {199 got = append(got, p.Start.Format("Jan 2 15:04")+" "+cmp.Or(p.Subtitle, p.Title))200 }201 want := []string{"Oct 5 12:00 Off air", "Oct 5 20:00 Episode 3", "Oct 5 21:00 Episode 1", "Oct 5 22:00 Off air"}202 if !slices.Equal(got, want) {203 t.Fatalf("guide:\n%q\nwant\n%q", got, want)204 }205 if p := progs[0]; p.Description != "Back at 8:00 PM with House of the Dragon, S1 E3." || p.Season != "" {206 t.Errorf("afternoon: %+v", p)207 }208 if p := progs[3]; !p.End.Equal(at(6, 20, 0)) || p.Description != "Back Tue, Oct 6 at 8:00 PM with House of the Dragon, S1 E2." {209 t.Errorf("overnight: %+v", p)210 }211 if p := progs[1]; p.OffAir || p.Season != "1" || p.Episode != "3" || p.End.Sub(p.Start) != time.Hour {212 t.Errorf("episode: %+v", p)213 }214215 // Edits to channel.json apply at once: around the clock from the third.216 writeFile(t, filepath.Join(f.Dir, DetailsFile), `{"schedule": {"start": "2026-10-04T09:00", "first": 3}}`)217 if progs := f.Programs(at(4, 9, 30), at(4, 12, 0)); len(progs) != 3 || progs[0].Subtitle != "Episode 3" || progs[1].Subtitle != "Episode 1" {218 t.Errorf("around the clock: %+v", progs)219 }220 // Without a schedule, the folder loops on the clock as ever.221 writeFile(t, filepath.Join(f.Dir, DetailsFile), `{"callSign": "HOTD"}`)222 if progs := f.Programs(at(4, 9, 30), at(4, 12, 0)); !reflect.DeepEqual(progs, programs(f.items, at(4, 9, 30), at(4, 12, 0))) {223 t.Errorf("without a schedule: %+v", progs)224 }225 // One that can't be read is logged and left out.226 logs := captureLogs(t)227 writeFile(t, filepath.Join(f.Dir, DetailsFile), `{"schedule": {"start": "October"}}`)228 if progs := f.Programs(at(4, 9, 30), at(4, 12, 0)); len(progs) == 0 || progs[0].OffAir {229 t.Errorf("with a bad schedule: %+v", progs)230 }231 if !strings.Contains(logs.String(), "airing around the clock") {232 t.Errorf("logs: %s", logs)233 }234235 seq, ok := f.Sequence()236 if !ok || len(seq) != 3 || seq[2].Episode != "3" || seq[0].Length != time.Hour || seq[1].Subtitle != "Episode 2" {237 t.Errorf("sequence: %+v", seq)238 }239}240241// TestScheduledCue: off the air a slate says when the channel is back, a242// minute at a time; on the air the episode on plays from where it is, and243// each picks up where the last ended, however early its decoder finished.244func TestScheduledCue(t *testing.T) {245 f := scheduledFolder(t, `{"start": "2026-10-04", "blocks": [{"at": "20:00", "count": 2}]}`)246 at := func(day, h, m, s int) time.Time { return time.Date(2026, 10, day, h, m, s, 0, time.Local) }247 type cued struct {248 slate bool249 title string250 secs int64 // how long it plays251 into int64 // seconds in252 }253 run := func(cue cueFunc, now time.Time, ok bool) cued {254 t.Helper()255 it, skip, err := cue(now, ok)256 if err != nil {257 t.Fatalf("at %v: %v", now, err)258 }259 title := it.Title260 if !it.Slate {261 title = it.Subtitle262 }263 return cued{it.Slate, title, (it.Frames - skip) / loopFPS, skip / loopFPS}264 }265266 cue := scheduled("test", f.lineup)267 if c := run(cue, at(4, 15, 0, 0), true); c != (cued{true, "Starts at 8:00 PM with House of the Dragon, S1 E1", 60, 0}) {268 t.Errorf("afternoon: %+v", c)269 }270 cue = scheduled("test", f.lineup)271 if c := run(cue, at(4, 19, 59, 30), true); c != (cued{true, "Starts at 8:00 PM with House of the Dragon, S1 E1", 30, 0}) {272 t.Errorf("just before: %+v", c)273 }274 // The slate ended a little early; the episode starts at its top.275 if c := run(cue, at(4, 19, 59, 59), true); c != (cued{false, "Episode 1", 3600, 0}) {276 t.Errorf("at eight: %+v", c)277 }278 if c := run(cue, at(4, 20, 59, 59), true); c != (cued{false, "Episode 2", 3600, 0}) {279 t.Errorf("at nine: %+v", c)280 }281 if c := run(cue, at(4, 22, 0, 1), true); c != (cued{true, "Back Mon, Oct 5 at 8:00 PM with House of the Dragon, S1 E3", 60, 0}) {282 t.Errorf("after the block: %+v", c)283 }284285 // Running late, the next episode starts at its top and loses its end;286 // much later, it's joined part way through.287 cue = scheduled("test", f.lineup)288 run(cue, at(5, 20, 0, 0), true)289 if c := run(cue, at(5, 21, 1, 30), true); c != (cued{false, "Episode 1", 3510, 0}) {290 t.Errorf("late: %+v", c)291 }292 cue = scheduled("test", f.lineup)293 run(cue, at(5, 20, 0, 0), true)294 if c := run(cue, at(5, 21, 5, 0), true); c != (cued{false, "Episode 1", 3300, 300}) {295 t.Errorf("very late: %+v", c)296 }297298 // Tuning in part way through.299 cue = scheduled("test", f.lineup)300 if c := run(cue, at(5, 20, 10, 0), true); c != (cued{false, "Episode 3", 3000, 600}) {301 t.Errorf("joining: %+v", c)302 }303 // A failed episode stands by for the rest of its time, not tried again.304 if c := run(cue, at(5, 20, 12, 0), false); c != (cued{true, standBy, 60, 0}) {305 t.Errorf("after a failure: %+v", c)306 }307 if c := run(cue, at(5, 20, 13, 0), true); c != (cued{true, standBy, 60, 0}) {308 t.Errorf("still standing by: %+v", c)309 }310 // As many failures in a row as there are videos end the stream.311 if c := run(cue, at(5, 21, 0, 0), true); c.title != "Episode 1" {312 t.Errorf("the next episode: %+v", c)313 }314 if c := run(cue, at(5, 21, 1, 0), false); c.title != standBy {315 t.Errorf("a second failure: %+v", c)316 }317 if _, _, err := cue(at(5, 21, 2, 0), false); err == nil || !strings.Contains(err.Error(), "no video would play") {318 t.Errorf("after three failures in a row, the slate's too: %v", err)319 }320321 // A schedule removed while the stream plays: the loop on the clock.322 writeFile(t, filepath.Join(f.Dir, DetailsFile), `{}`)323 cue = scheduled("test", f.lineup)324 it, skip, err := cue(at(5, 15, 0, 0), true)325 i, into := playing(f.items, at(5, 15, 0, 0))326 if err != nil || it.Slate || it.Subtitle != f.items[i].Subtitle || skip != into {327 t.Errorf("without a schedule: %+v %d, want %d %d", it.Subtitle, skip, i, into)328 }329}330331func TestSlateText(t *testing.T) {332 if got := slateText("Back Mon, Oct 5 at 8:00 PM with Bob's Burgers, S1 E3."); got != `Back Mon, Oct 5 at 8\:00 PM with Bobs Burgers, S1 E3.` {333 t.Errorf("got %q", got)334 }335}336337// TestScheduledFolderStream plays a scheduled folder off the air, then on.338func TestScheduledFolderStream(t *testing.T) {339 ffmpeg, err := exec.LookPath("ffmpeg")340 if err != nil {341 t.Skip("no ffmpeg")342 }343 ffprobe, err := exec.LookPath("ffprobe")344 if err != nil {345 t.Skip("no ffprobe")346 }347 dir := t.TempDir()348 cmd := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=s=640x360:r=25:d=4",349 "-f", "lavfi", "-i", "sine=f=440:d=4", "-c:v", "libx264", "-c:a", "aac", filepath.Join(dir, "a.mp4"))350 if out, err := cmd.CombinedOutput(); err != nil {351 t.Fatalf("make a.mp4: %v: %s", err, out)352 }353 f := &Folder{Num: "1.9", Title: "Test", Dir: dir, FFmpeg: ffmpeg, FFprobe: ffprobe}354 for name, start := range map[string]time.Time{"off the air": time.Now().Add(48 * time.Hour), "on the air": time.Now().Add(-time.Second)} {355 writeFile(t, filepath.Join(dir, DetailsFile), fmt.Sprintf(`{"schedule": {"start": %q}}`, start.Format("2006-01-02T15:04")))356 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)357 var out bytes.Buffer358 err := f.Stream(ctx, &out)359 cancel()360 if err != nil {361 t.Fatalf("%s: %v", name, err)362 }363 ts := filepath.Join(t.TempDir(), "out.ts")364 if err := os.WriteFile(ts, out.Bytes(), 0o644); err != nil {365 t.Fatal(err)366 }367 probe, err := exec.Command(ffprobe, "-v", "error", "-show_entries", "stream=codec_name,width,height", "-of", "compact", ts).CombinedOutput()368 if err != nil {369 t.Fatalf("%s: ffprobe %d bytes: %v: %s", name, out.Len(), err, probe)370 }371 for _, want := range []string{"codec_name=h264|width=1280|height=720", "codec_name=aac"} {372 if !strings.Contains(string(probe), want) {373 t.Errorf("%s: stream lacks %q:\n%s", name, want, probe)374 }375 }376 }377}