1package dvr23import (4 "context"5 "fmt"6 "os"7 "path/filepath"8 "strings"9 "testing"10 "time"1112 "airwaves/internal/guide"13 "airwaves/internal/hdhr"14 "airwaves/internal/hdhr/hdhrtest"15 "airwaves/internal/lineup"16 "airwaves/internal/ts"17 "airwaves/internal/ts/tstest"18 "airwaves/internal/tuner"19)2021// Where the test's channels are: 9.1 and 9.2 on one RF channel, 4.1 and22// 2.1 on others; 7.1 isn't in the tuner's lineup.23var where = map[string][2]int64{24 "9.1": {189_000_000, 3},25 "9.2": {189_000_000, 4},26 "4.1": {557_000_000, 1},27 "2.1": {605_000_000, 6},28}2930func mux(prog ...int) []byte {31 var ps []tstest.Program32 for i, n := range prog {33 pid := 0x30 + i*0x1034 ps = append(ps, tstest.Program{Number: n, PMTPID: pid, VideoPID: pid + 1, Audio: []tstest.Audio{{PID: pid + 4, Lang: "eng"}}})35 }36 return tstest.Mux{TSID: prog[0], Programs: ps}.Packets(400)37}3839func locate(_ context.Context, number string) (int64, int, error) {40 if w, ok := where[number]; ok {41 return w[0], int(w[1]), nil42 }43 return 0, 0, fmt.Errorf("channel %s isn't in the tuner's lineup", number)44}4546type env struct {47 m *Manager48 srv *hdhrtest.Server49 g *guide.Guide50 chans []lineup.Channel51 base time.Time52}5354func setup(t *testing.T) *env {55 t.Helper()56 muxes := map[int64][]byte{189_000_000: mux(3, 4), 557_000_000: mux(1), 605_000_000: mux(6)}57 srv := hdhrtest.New(t, hdhrtest.Device{Mux: func(f int64) []byte { return muxes[f] }})58 dev, err := hdhr.Open(context.Background(), srv.URL(), nil)59 if err != nil {60 t.Fatal(err)61 }62 tuners := tuner.NewManager()63 tuners.Linger = 064 tuners.SetDevice(dev, dev.Tuners)65 dir := t.TempDir()66 m, err := Open(filepath.Join(dir, "dvr.json"), filepath.Join(dir, "recordings"), tuners, locate)67 if err != nil {68 t.Fatal(err)69 }70 t.Cleanup(m.Close)71 base := time.Now().Truncate(time.Hour).Add(2 * time.Hour)72 ep := func(h int, id string, flags ...string) guide.Program {73 s := base.Add(time.Duration(h) * time.Hour)74 return guide.Program{Start: s, End: s.Add(time.Hour), Title: "Nature", SeriesID: "SH1", ProgramID: id, Flags: flags}75 }76 g := &guide.Guide{Programs: map[string][]guide.Program{77 "g9": {ep(0, "EP1", "New"), ep(1, "EP1"), ep(2, "EP2"), {Start: base.Add(3 * time.Hour), End: base.Add(4 * time.Hour), Title: "News", SeriesID: "SH2", ProgramID: "EP9"}},78 "g92": {{Start: base, End: base.Add(time.Hour), Title: "Western", SeriesID: "SH4", ProgramID: "MV1"}},79 "g4": {{Start: base, End: base.Add(time.Hour), Title: "Golf", SeriesID: "SH3", ProgramID: "SP1", Genres: []string{"sports"}}},80 "g2": {{Start: base, End: base.Add(time.Hour), Title: "Cartoons", SeriesID: "SH5", ProgramID: "EP5"}},81 "g7": {{Start: base, End: base.Add(time.Hour), Title: "Quiz", SeriesID: "SH6", ProgramID: "EP6"}},82 }}83 chans := []lineup.Channel{84 {Number: "9.1", CallSign: "KUSADT", GuideID: "g9"}, {Number: "9.2", CallSign: "KUSADT2", GuideID: "g92"},85 {Number: "4.1", CallSign: "KCNCDT", GuideID: "g4"}, {Number: "2.1", CallSign: "KWGNDT", GuideID: "g2"},86 {Number: "7.1", CallSign: "KMGHDT", GuideID: "g7"},87 }88 return &env{m: m, srv: srv, g: g, chans: chans, base: base}89}9091func (e *env) add(t *testing.T, kind Kind, channel string, start time.Time) *Rule {92 t.Helper()93 call := ""94 for _, c := range e.chans {95 if c.Number == channel {96 call = c.CallSign97 }98 }99 r, err := e.m.Add(context.Background(), Request{Kind: kind, Channel: channel, CallSign: call, Start: start}, e.g, e.chans)100 if err != nil {101 t.Fatal(err)102 }103 return r104}105106func (e *env) upcoming(t *testing.T) []Item {107 t.Helper()108 st, err := e.m.State(context.Background(), e.g, e.chans)109 if err != nil {110 t.Fatal(err)111 }112 return st.Upcoming113}114115func titles(items []Item) string {116 var out []string117 for _, it := range items {118 out = append(out, fmt.Sprintf("%s@%s:%s", it.Title, it.Start.Sub(it.Start.Truncate(24*time.Hour)), it.Status))119 }120 return strings.Join(out, " ")121}122123func TestSeriesSkipsRepeatsAndOtherShows(t *testing.T) {124 e := setup(t)125 e.add(t, Series, "9.1", e.base)126 up := e.upcoming(t)127 // EP1 airs twice; only the first airing is recorded. News is not part of the series.128 if len(up) != 2 || up[0].Start != e.base || up[1].Start != e.base.Add(2*time.Hour) || strings.Contains(titles(up), "News") {129 t.Fatalf("upcoming %s, want the first EP1 airing and EP2", titles(up))130 }131}132133func TestNewOnly(t *testing.T) {134 e := setup(t)135 if _, err := e.m.Add(context.Background(), Request{Kind: Series, Channel: "9.1", CallSign: "KUSADT", Start: e.base, NewOnly: true}, e.g, e.chans); err != nil {136 t.Fatal(err)137 }138 if up := e.upcoming(t); len(up) != 1 {139 t.Fatalf("upcoming %s, want only the new one", titles(up))140 }141}142143// TestSkipAndDeleteRule: an airing removed from a series stays removed144// (its episode is recorded at a repeat), and a deleted rule records145// nothing more.146func TestSkipAndDeleteRule(t *testing.T) {147 e := setup(t)148 rule := e.add(t, Series, "9.1", e.base)149 up := e.upcoming(t)150 if up[0].ID != up[0].Key || up[0].Status != Scheduled {151 t.Fatalf("an airing to come is %+v, want its key as its ID", up[0])152 }153 if err := e.m.DeleteEntry(context.Background(), up[0].ID); err != nil {154 t.Fatal(err)155 }156 // The episode is recorded at its repeat instead.157 if up := e.upcoming(t); len(up) != 2 || !up[0].Start.Equal(e.base.Add(time.Hour)) {158 t.Fatalf("after skipping the first: %s", titles(up))159 }160 if err := e.m.DeleteRule(context.Background(), rule.ID); err != nil {161 t.Fatal(err)162 }163 if up := e.upcoming(t); len(up) != 0 {164 t.Fatalf("deleting the rule left %s", titles(up))165 }166}167168func TestUntunedChannelIsUnavailable(t *testing.T) {169 e := setup(t)170 e.add(t, Once, "7.1", e.base)171 up := e.upcoming(t)172 if len(up) != 1 || up[0].Status != Unavailable || up[0].Title != "Quiz" || !strings.Contains(up[0].Detail, "lineup") {173 t.Fatalf("upcoming = %+v, want Quiz marked unavailable", up)174 }175}176177// TestConflicts: two tuners record two RF channels at once; a third RF178// channel then conflicts, while another program of an RF channel being179// recorded shares its tuner.180func TestConflicts(t *testing.T) {181 e := setup(t)182 e.add(t, Once, "9.1", e.base)183 e.add(t, Once, "4.1", e.base)184 e.add(t, Once, "9.2", e.base)185 e.add(t, Once, "2.1", e.base)186 got := map[string]Item{}187 for _, it := range e.upcoming(t) {188 got[it.Title] = it189 }190 for title, want := range map[string]string{"Nature": Scheduled, "Golf": Scheduled, "Western": Scheduled, "Cartoons": Conflict} {191 if got[title].Status != want {192 t.Errorf("%s is %s (%s), want %s", title, got[title].Status, got[title].Detail, want)193 }194 }195 if d := got["Cartoons"].Detail; !strings.Contains(d, "Golf") || !strings.Contains(d, "Nature") {196 t.Errorf("conflict detail %q doesn't say what records then", d)197 }198}199200// recordNow records an airing on now: 9.2's, which began 10 minutes ago.201func recordNow(t *testing.T, e *env) Item {202 t.Helper()203 now := time.Now()204 e.g.Programs["g92"] = []guide.Program{{Start: now.Add(-10 * time.Minute), End: now.Add(50 * time.Minute), Title: "Western", ProgramID: "MV1"}}205 e.add(t, Once, "9.2", now.Add(-10*time.Minute))206 var it Item207 for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(20 * time.Millisecond) {208 up := e.upcoming(t)209 if len(up) == 1 && up[0].Status == Recording && up[0].SizeBytes > 0 {210 it = up[0]211 break212 }213 if time.Now().After(deadline) {214 t.Fatalf("not recording: %s", titles(up))215 }216 }217 return it218}219220// TestRecordStopKeeps: an airing on now records at once, into its own221// file holding its program alone; stopping it keeps what it recorded,222// and says what's missing.223func TestRecordStopKeeps(t *testing.T) {224 e := setup(t)225 it := recordNow(t, e)226 if until := it.Until; until.Before(time.Now().Add(50 * time.Minute)) {227 t.Fatalf("records until %s, want the end with padding", until)228 }229 if err := e.m.Stop(it.ID); err != nil {230 t.Fatal(err)231 }232 st, _ := e.m.State(context.Background(), e.g, e.chans)233 if len(st.Recorded) != 1 || len(st.Upcoming) != 0 {234 t.Fatalf("after stopping: recorded %s, upcoming %s", titles(st.Recorded), titles(st.Upcoming))235 }236 done := st.Recorded[0]237 if done.Status != Completed || !strings.Contains(done.Detail, "Began 10 minutes into the program") || !strings.Contains(done.Detail, "Stopped 50 minutes before the end") {238 t.Fatalf("recorded = %+v", done)239 }240 r, path, ok := e.m.Get(done.ID)241 if !ok || !strings.HasPrefix(r.File, "Western/") {242 t.Fatalf("file %q", r.File)243 }244 raw, err := os.ReadFile(path)245 if err != nil || int64(len(raw)) != done.SizeBytes {246 t.Fatalf("file has %d bytes (%v), the recording says %d", len(raw), err, done.SizeBytes)247 }248 d := ts.NewDemux()249 var split ts.Splitter250 split.Write(raw, d.Packet)251 if ps := d.Programs(); len(ps) != 1 || ps[0].Number != 4 {252 t.Fatalf("the file carries %+v, want program 4 alone", ps)253 }254}255256// TestExtend moves a recording's end while it records.257func TestExtend(t *testing.T) {258 e := setup(t)259 it := recordNow(t, e)260 until, err := e.m.Extend(it.ID, 30*time.Minute)261 if err != nil || !until.Equal(it.Until.Add(30*time.Minute)) {262 t.Fatalf("extended to %s (%v), want %s", until, err, it.Until.Add(30*time.Minute))263 }264 if up := e.upcoming(t); !up[0].Until.Equal(until) {265 t.Fatalf("shown until %s", up[0].Until)266 }267}268269// TestDeleteWhileRecording discards the recording, and never records270// that airing again.271func TestDeleteWhileRecording(t *testing.T) {272 e := setup(t)273 it := recordNow(t, e)274 _, path, _ := e.m.Get(it.ID)275 if err := e.m.DeleteEntry(context.Background(), it.ID); err != nil {276 t.Fatal(err)277 }278 if _, err := os.Stat(path); !os.IsNotExist(err) {279 t.Fatalf("the file is still there: %v", err)280 }281 e.m.Start(e.g, e.chans, time.Now())282 st, _ := e.m.State(context.Background(), e.g, e.chans)283 if len(st.Recorded)+len(st.Upcoming) != 0 {284 t.Fatalf("after deleting: recorded %s, upcoming %s", titles(st.Recorded), titles(st.Upcoming))285 }286}287288// TestResume: a recording the server was making when it stopped carries289// on after a restart if its time isn't up, and is finished otherwise.290func TestResume(t *testing.T) {291 e := setup(t)292 it := recordNow(t, e)293 e.m.Close()294 path := e.m.path295 r, file, _ := e.m.Get(it.ID)296 if r.Status != Recording {297 t.Fatalf("after a shutdown the recording is %s", r.Status)298 }299 size := r.Size300301 again, err := Open(path, e.m.dir, e.m.tuners, locate)302 if err != nil {303 t.Fatal(err)304 }305 t.Cleanup(again.Close)306 again.resume(time.Now())307 for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(20 * time.Millisecond) {308 if fi, err := os.Stat(file); err == nil && fi.Size() > size {309 break310 }311 if time.Now().After(deadline) {312 t.Fatal("the recording didn't carry on")313 }314 }315 again.Close()316317 later, err := Open(path, e.m.dir, e.m.tuners, locate)318 if err != nil {319 t.Fatal(err)320 }321 later.resume(time.Now().Add(2 * time.Hour))322 if r, _, _ := later.Get(it.ID); r.Status != Completed || !strings.Contains(r.Detail, "the server stopped") {323 t.Fatalf("a recording whose time ran out while the server was down is %s: %q", r.Status, r.Detail)324 }325}326327func TestPersistence(t *testing.T) {328 e := setup(t)329 e.add(t, Series, "9.1", e.base)330 again, err := Open(e.m.path, e.m.dir, e.m.tuners, locate)331 if err != nil {332 t.Fatal(err)333 }334 if len(again.st.Rules) != 1 || again.st.Rules[0].SeriesID != "SH1" {335 t.Fatalf("reloaded rules %+v", again.st.Rules)336 }337338 // Damaged, the file is read from its backup.339 e.m.mu.Lock()340 e.m.st.Prefs.DeleteWatchedAfterDays = 7341 err = e.m.save()342 e.m.mu.Unlock()343 if err != nil {344 t.Fatal(err)345 }346 if err := os.WriteFile(e.m.path, []byte(`{"rules": [`), 0o644); err != nil {347 t.Fatal(err)348 }349 restored, err := Open(e.m.path, e.m.dir, e.m.tuners, locate)350 if err != nil {351 t.Fatal(err)352 }353 if len(restored.st.Rules) != 1 || restored.st.Prefs.DeleteWatchedAfterDays != 0 {354 t.Fatalf("from the backup: rules %+v, prefs %+v", restored.st.Rules, restored.st.Prefs)355 }356}357358// TestCorruptFile: a state file that can't be read is set aside, and the359// DVR starts empty instead of not at all.360func TestCorruptFile(t *testing.T) {361 dir := t.TempDir()362 path := filepath.Join(dir, "dvr.json")363 if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {364 t.Fatal(err)365 }366 m, err := Open(path, dir, tuner.NewManager(), locate)367 if err != nil {368 t.Fatal(err)369 }370 defer m.Close()371 if len(m.st.Rules) != 0 {372 t.Fatal("rules from a corrupt file")373 }374 aside, _ := filepath.Glob(path + ".corrupt-*")375 if len(aside) != 1 {376 t.Fatalf("the corrupt file wasn't kept aside: %v", aside)377 }378 if raw, err := os.ReadFile(aside[0]); err != nil || string(raw) != "{not json" {379 t.Fatalf("the corrupt file wasn't kept aside: %v", err)380 }381}382383func TestRetainKeepsNewestPerSeries(t *testing.T) {384 e := setup(t)385 rule := e.add(t, Series, "9.1", e.base)386 old := time.Now().Add(-30 * 24 * time.Hour)387 e.m.mu.Lock()388 for i, id := range []string{"f1", "f2", "f3"} {389 s := old.Add(time.Duration(i) * 24 * time.Hour)390 e.m.st.Recordings = append(e.m.st.Recordings, Rec{ID: id, RuleID: rule.ID, Title: "Nature", Start: s, End: s.Add(time.Hour), Status: Completed, File: "Nature/" + id + ".ts"})391 }392 e.m.st.Recordings = append(e.m.st.Recordings, Rec{ID: "other", Title: "Movie", Start: old, End: old.Add(2 * time.Hour), Status: Completed, File: "Movie/other.ts"})393 e.m.mu.Unlock()394 for _, f := range []string{"Nature/f1.ts", "Nature/f2.ts", "Nature/f3.ts", "Movie/other.ts"} {395 p := filepath.Join(e.m.dir, f)396 _ = os.MkdirAll(filepath.Dir(p), 0o755)397 _ = os.WriteFile(p, []byte("x"), 0o644)398 }399400 if _, err := e.m.UpdateRule(rule.ID, RuleUpdate{Keep: 2}); err != nil {401 t.Fatal(err)402 }403 if err := e.m.Retain(context.Background()); err != nil {404 t.Fatal(err)405 }406 if _, _, ok := e.m.Get("f1"); ok || len(e.m.st.Recordings) != 3 {407 t.Fatalf("after keep=2: %d left, want f1 (oldest) removed and the movie kept", len(e.m.st.Recordings))408 }409 if _, err := os.Stat(filepath.Join(e.m.dir, "Nature/f1.ts")); !os.IsNotExist(err) {410 t.Fatal("f1's file is still there")411 }412413 // Watched recordings go after the configured number of days.414 if err := e.m.MarkWatched("other", true); err != nil {415 t.Fatal(err)416 }417 if _, err := e.m.SetPrefs(Prefs{DeleteWatchedAfterDays: 7}); err != nil {418 t.Fatal(err)419 }420 if err := e.m.Retain(context.Background()); err != nil {421 t.Fatal(err)422 }423 if _, _, ok := e.m.Get("other"); ok {424 t.Fatal("watched recording older than 7 days was kept")425 }426 if _, err := os.Stat(filepath.Join(e.m.dir, "Movie")); !os.IsNotExist(err) {427 t.Fatal("the show's emptied folder is still there")428 }429}430431func TestProgressMarksWatchedNearTheEnd(t *testing.T) {432 e := setup(t)433 m := e.m434 if err := m.SaveProgress("e1", 600, 3600); err != nil {435 t.Fatal(err)436 }437 if p := m.st.Progress["e1"]; p.Watched || p.Position != 600 {438 t.Fatalf("mid-way progress = %+v", p)439 }440 if err := m.SaveProgress("e1", 3500, 3600); err != nil {441 t.Fatal(err)442 }443 if !m.st.Progress["e1"].Watched {444 t.Fatal("within the last two minutes should count as watched")445 }446 if err := m.MarkWatched("e1", false); err != nil {447 t.Fatal(err)448 }449 if p := m.st.Progress["e1"]; p.Watched || p.Position != 0 {450 t.Fatalf("unwatched = %+v, want reset", p)451 }452}453454func TestFileNames(t *testing.T) {455 for in, want := range map[string]string{456 "AC/DC: Live": "AC-DC: Live",457 "..hidden": "hidden",458 "tab\there": "tabhere",459 strings.Repeat("é", 60): strings.Repeat("é", 40),460 } {461 if got := clean(in); got != want {462 t.Errorf("clean(%q) = %q, want %q", in, got, want)463 }464 }465}