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 / dvr / dvr.go
dvr.go 22.6K
1// Package dvr records over-the-air TV. The user's rules (one airing, or a2// series by Gracenote series ID on one channel) pick airings from the3// guide; Airwaves records them itself, on the tuners it shares with4// viewers, where a recording comes first. Recordings are files in the5// recordings folder, described in the same state file as the rules.6package dvr78import (9 "cmp"10 "context"11 "crypto/rand"12 "encoding/hex"13 "encoding/json"14 "errors"15 "fmt"16 "log"17 "os"18 "path/filepath"19 "slices"20 "strconv"21 "strings"22 "sync"23 "time"2425 "airwaves/internal/guide"26 "airwaves/internal/lineup"27 "airwaves/internal/store"28 "airwaves/internal/tuner"29)3031// Kind is the type of rule.32type Kind string3334// Rule kinds.35const (36 Once Kind = "once"37 Series Kind = "series"38)3940// Statuses of a recording, as Item shows them.41const (42 // Scheduled is an airing a rule wants that hasn't started.43 Scheduled = "scheduled"44 // Conflict is one that won't get a tuner: others record from every45 // tuner then.46 Conflict = "conflict"47 // Unavailable is one on a channel the tuner doesn't have.48 Unavailable = "unavailable"49 Recording = "recording"50 Completed = "completed"51 Failed = "failed"52)5354// Padding around an airing: a minute early, three late, and half an hour55// late for sports, which overrun.56const (57 early = time.Minute58 late = 3 * time.Minute59 lateSports = 30 * time.Minute60)6162// Request asks to record an airing picked in the guide.63type Request struct {64 Kind Kind `json:"kind"`65 Channel string `json:"channel"` // virtual channel number66 CallSign string `json:"callSign"`67 Start time.Time `json:"start"`68 // NewOnly limits a series rule to first-run episodes.69 NewOnly bool `json:"newOnly"`70}7172// Rule is something the user asked to record.73type Rule struct {74 ID string `json:"id"`75 Kind Kind `json:"kind"`76 Title string `json:"title"`77 Channel string `json:"channel"`78 CallSign string `json:"callSign"`79 GuideID string `json:"guideId"`80 SeriesID string `json:"seriesId,omitempty"`81 NewOnly bool `json:"newOnly,omitempty"`82 Image string `json:"image,omitempty"`83 Created time.Time `json:"created"`8485 // Once rules carry the airing itself.86 Start time.Time `json:"start,omitzero"`87 End time.Time `json:"end,omitzero"`88 Subtitle string `json:"subtitle,omitempty"`89 Description string `json:"description,omitempty"`90 ProgramID string `json:"programId,omitempty"`91 Sports bool `json:"sports,omitempty"`9293 // Skip lists airing keys removed from a series by the user.94 Skip []string `json:"skip,omitempty"`95 // Keep, for series, is how many recordings to keep (newest first);96 // zero keeps them all.97 Keep int `json:"keep,omitempty"`98}99100// Rec is a recording Airwaves made or is making: an airing, and the file101// it went to.102type Rec struct {103 ID string `json:"id"`104 Key string `json:"key"` // the airing's, "7.1@1791058239"105 RuleID string `json:"ruleId,omitempty"`106 Title string `json:"title"`107 Subtitle string `json:"subtitle,omitempty"`108 Description string `json:"description,omitempty"`109 Image string `json:"image,omitempty"`110 ProgramID string `json:"programId,omitempty"`111 Channel string `json:"channel"`112 CallSign string `json:"callSign,omitempty"`113 // Start and End are the airing's; From and Until what is recorded,114 // with padding. Until moves when the recording is extended or stopped.115 Start time.Time `json:"start"`116 End time.Time `json:"end"`117 From time.Time `json:"from"`118 Until time.Time `json:"until"`119 // File is where it is, in the recordings folder.120 File string `json:"file"`121 Status string `json:"status"` // Recording, Completed or Failed122 // Detail says what's missing or what went wrong.123 Detail string `json:"detail,omitempty"`124 // Began and Ended are when data first and last arrived; Covered how125 // much of the time between them was recorded.126 Began time.Time `json:"began,omitzero"`127 Ended time.Time `json:"ended,omitzero"`128 Covered time.Duration `json:"covered,omitempty"`129 Size int64 `json:"size,omitempty"`130}131132// Item is a recording as shown to the user.133type Item struct {134 // ID is a recording's own; for an airing not started, its key.135 ID string `json:"id"`136 Key string `json:"key"`137 RuleID string `json:"ruleId,omitempty"`138 Title string `json:"title"`139 Subtitle string `json:"subtitle,omitempty"`140 Description string `json:"description,omitempty"`141 Image string `json:"image,omitempty"`142 Channel string `json:"channel"`143 CallSign string `json:"callSign,omitempty"`144 Start time.Time `json:"start"`145 End time.Time `json:"end"`146 // Status is Scheduled, Conflict, Unavailable, Recording, Completed147 // or Failed.148 Status string `json:"status"`149 Detail string `json:"detail,omitempty"`150 // Until is when a recording in progress stops: the airing's end with151 // its padding, later when extended.152 Until time.Time `json:"until,omitzero"`153 SizeBytes int64 `json:"sizeBytes,omitempty"`154 // Duration of what's recorded so far, Position the resume point and155 // Watched whether it has been seen.156 Duration float64 `json:"duration,omitempty"`157 Position float64 `json:"position,omitempty"`158 Watched bool `json:"watched,omitempty"`159}160161// Progress is how far a recording has been watched.162type Progress struct {163 Position float64 `json:"position"`164 Watched bool `json:"watched"`165 Updated time.Time `json:"updated"`166}167168// Prefs are library-wide settings.169type Prefs struct {170 // DeleteWatchedAfterDays removes watched recordings this many days after171 // they were finished; zero keeps them.172 DeleteWatchedAfterDays int `json:"deleteWatchedAfterDays"`173}174175// State is everything the DVR view needs.176type State struct {177 Available bool `json:"available"`178 Reason string `json:"reason,omitempty"`179 Rules []Rule `json:"rules"`180 Upcoming []Item `json:"upcoming"`181 Recorded []Item `json:"recorded"`182 Prefs Prefs `json:"prefs"`183}184185type fileState struct {186 Rules []Rule `json:"rules"`187 Recordings []Rec `json:"recordings"`188 Progress map[string]Progress `json:"progress"` // by recording ID189 Prefs Prefs `json:"prefs"`190}191192// Tuners is where recordings get their tuner.193type Tuners interface {194 Open(ctx context.Context, r tuner.Request) (*tuner.Sub, error)195 Count() int196}197198// Locate finds where a channel is broadcast: its RF channel and program.199type Locate func(ctx context.Context, number string) (freqHz int64, program int, err error)200201// Listings supplies the guide and the tuner's channels, for the rules.202type Listings func(ctx context.Context) (*guide.Guide, []lineup.Channel, error)203204// Manager owns the rules and the recordings.205type Manager struct {206 path string207 dir string208 tuners Tuners209 locate Locate210211 ctx context.Context // until Close, for the recordings212 cancel context.CancelCauseFunc213214 mu sync.Mutex215 st fileState216 jobs map[string]*job // recordings in progress, by ID217}218219// Open loads the rules and recordings kept at path (created on first220// save), recording into dir. A file that can't be read is set aside and221// its backup read instead (store.ReadFile); with none, the DVR starts222// empty rather than not at all.223func Open(path, dir string, tuners Tuners, locate Locate) (*Manager, error) {224 m := &Manager{path: path, dir: dir, tuners: tuners, locate: locate, jobs: map[string]*job{},225 st: fileState{Progress: map[string]Progress{}}}226 m.ctx, m.cancel = context.WithCancelCause(context.Background())227 err := store.ReadFile(path, func(raw []byte) error {228 var st fileState229 if err := json.Unmarshal(raw, &st); err != nil {230 return err231 }232 m.st = st233 return nil234 })235 switch {236 case err == nil, errors.Is(err, os.ErrNotExist):237 case errors.Is(err, store.ErrCorrupt):238 log.Printf("recordings: %v; starting empty", err)239 default:240 return nil, err241 }242 if m.st.Progress == nil {243 m.st.Progress = map[string]Progress{}244 }245 return m, nil246}247248// save writes the state. Called with m.mu held.249func (m *Manager) save() error {250 raw, err := json.MarshalIndent(m.st, "", " ")251 if err != nil {252 return err253 }254 return store.WriteFile(m.path, raw, 0o644)255}256257// saveLogged saves, logging a failure. Called with m.mu held.258func (m *Manager) saveLogged() {259 if err := m.save(); err != nil {260 log.Printf("recordings: %v", err)261 }262}263264// Add creates a rule from a guide selection. An airing that's on now265// starts recording at once.266func (m *Manager) Add(ctx context.Context, req Request, g *guide.Guide, chans []lineup.Channel) (*Rule, error) {267 ch := findChannel(chans, req.Channel, req.CallSign)268 if ch == nil || ch.GuideID == "" || g == nil {269 return nil, fmt.Errorf("no listings for channel %s", req.Channel)270 }271 var p *guide.Program272 for i, x := range g.Programs[ch.GuideID] {273 if x.Start.Equal(req.Start) {274 p = &g.Programs[ch.GuideID][i]275 break276 }277 }278 if p == nil {279 return nil, fmt.Errorf("nothing starts at %s on %s", req.Start.Format(time.Kitchen), req.Channel)280 }281 r := Rule{282 ID: newID(), Kind: req.Kind, Title: p.Title, Channel: ch.Number, CallSign: ch.CallSign,283 GuideID: ch.GuideID, Image: p.Image, Created: time.Now(),284 }285 switch req.Kind {286 case Series:287 if p.SeriesID == "" {288 return nil, fmt.Errorf("%q has no series information; record it once instead", p.Title)289 }290 r.SeriesID, r.NewOnly = p.SeriesID, req.NewOnly291 case Once:292 r.Start, r.End = p.Start, p.End293 r.Subtitle, r.Description, r.ProgramID = p.EpisodeTitle, p.Description, p.ProgramID294 r.Sports = slices.Contains(p.Genres, "sports")295 default:296 return nil, fmt.Errorf("unknown rule kind %q", req.Kind)297 }298299 m.mu.Lock()300 for _, x := range m.st.Rules {301 if x.Kind == r.Kind && x.Channel == r.Channel && (r.Kind == Series && x.SeriesID == r.SeriesID || r.Kind == Once && x.Start.Equal(r.Start)) {302 m.mu.Unlock()303 return &x, nil304 }305 }306 m.st.Rules = append(m.st.Rules, r)307 err := m.save()308 m.mu.Unlock()309 if err != nil {310 return nil, err311 }312 m.Start(g, chans, time.Now())313 return &r, nil314}315316// DeleteRule removes a rule. Its recordings in progress carry on.317func (m *Manager) DeleteRule(_ context.Context, id string) error {318 m.mu.Lock()319 defer m.mu.Unlock()320 i := slices.IndexFunc(m.st.Rules, func(r Rule) bool { return r.ID == id })321 if i < 0 {322 return fmt.Errorf("no rule %s", id)323 }324 m.st.Rules = slices.Delete(m.st.Rules, i, i+1)325 return m.save()326}327328// DeleteEntry removes one recording, by its ID or, for an airing not329// started, its key: an airing to come is skipped (a once rule for it330// goes), one recording now is stopped and discarded, and a finished one331// is deleted with its file. A series never records an airing deleted332// from it again.333func (m *Manager) DeleteEntry(_ context.Context, id string) error {334 m.mu.Lock()335 i := slices.IndexFunc(m.st.Recordings, func(r Rec) bool { return r.ID == id })336 if i < 0 {337 // An airing to come.338 defer m.mu.Unlock()339 m.skip(id)340 return m.save()341 }342 rec := m.st.Recordings[i]343 j := m.jobs[id]344 m.mu.Unlock()345 if j != nil {346 j.cancel(errDiscard)347 <-j.done348 }349 m.mu.Lock()350 defer m.mu.Unlock()351 m.skip(rec.Key)352 m.remove(rec.ID)353 return m.save()354}355356// skip keeps the airing key from being recorded: a once rule for it goes,357// and series rules on its channel skip it. Called with m.mu held.358func (m *Manager) skip(key string) {359 channel, _, _ := strings.Cut(key, "@")360 m.st.Rules = slices.DeleteFunc(m.st.Rules, func(r Rule) bool { return r.Kind == Once && airingKey(r.Channel, r.Start) == key })361 for i := range m.st.Rules {362 if r := &m.st.Rules[i]; r.Kind == Series && r.Channel == channel && !slices.Contains(r.Skip, key) {363 r.Skip = append(r.Skip, key)364 }365 }366}367368// remove deletes a finished recording and its file. Called with m.mu held.369func (m *Manager) remove(id string) {370 i := slices.IndexFunc(m.st.Recordings, func(r Rec) bool { return r.ID == id })371 if i < 0 {372 return373 }374 if f := m.st.Recordings[i].File; f != "" {375 path := filepath.Join(m.dir, f)376 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {377 log.Printf("recordings: %v", err)378 }379 _ = os.Remove(filepath.Dir(path)) // the show's folder, once empty380 }381 m.st.Recordings = slices.Delete(m.st.Recordings, i, i+1)382 delete(m.st.Progress, id)383}384385type airing struct {386 key string387 rule *Rule388 p guide.Program389 ch lineup.Channel390}391392// window is what's recorded of the airing: from a minute before it to a393// few minutes after, half an hour for sports.394func (a airing) window() (from, until time.Time) {395 after := late396 if slices.Contains(a.p.Genres, "sports") {397 after = lateSports398 }399 return a.p.Start.Add(-early), a.p.End.Add(after)400}401402// desired lists the airings the rules call for that haven't ended,403// earliest first. recorded holds the episodes already recorded.404// Called with m.mu held.405func (m *Manager) desired(g *guide.Guide, chans []lineup.Channel, now time.Time, recorded map[string]bool) []airing {406 var out []airing407 seen := make(map[string]bool) // episode IDs already claimed408 for i := range m.st.Rules {409 r := &m.st.Rules[i]410 switch r.Kind {411 case Once:412 p := guide.Program{413 Start: r.Start, End: r.End, Title: r.Title, EpisodeTitle: r.Subtitle,414 Description: r.Description, Image: r.Image, ProgramID: r.ProgramID,415 }416 if r.Sports {417 p.Genres = []string{"sports"}418 }419 a := airing{key: airingKey(r.Channel, r.Start), rule: r, p: p, ch: lineup.Channel{Number: r.Channel, CallSign: r.CallSign, GuideID: r.GuideID}}420 if _, until := a.window(); until.After(now) && !slices.Contains(r.Skip, a.key) {421 out = append(out, a)422 }423 case Series:424 if g == nil {425 continue426 }427 ch := findChannel(chans, r.Channel, r.CallSign)428 if ch == nil {429 continue430 }431 for _, p := range g.Programs[ch.GuideID] {432 if p.SeriesID != r.SeriesID || !p.End.After(now) {433 continue434 }435 if r.NewOnly && !slices.Contains(p.Flags, "New") {436 continue437 }438 key := airingKey(ch.Number, p.Start)439 if slices.Contains(r.Skip, key) {440 continue441 }442 if episodeID(p.ProgramID) && (recorded[p.ProgramID] || seen[p.ProgramID]) {443 continue // already have, or will get, this episode444 }445 seen[p.ProgramID] = true446 out = append(out, airing{key: key, rule: r, p: p, ch: *ch})447 }448 }449 }450 slices.SortStableFunc(out, func(a, b airing) int { return a.p.Start.Compare(b.p.Start) })451 return out452}453454// recorded lists the episodes recorded, or being recorded, by program ID.455// Called with m.mu held.456func (m *Manager) recorded() map[string]bool {457 out := map[string]bool{}458 for _, r := range m.st.Recordings {459 if r.Status != Failed && episodeID(r.ProgramID) {460 out[r.ProgramID] = true461 }462 }463 return out464}465466// has reports whether the airing key has a recording, made or being made.467// Called with m.mu held.468func (m *Manager) has(key string) bool {469 return slices.ContainsFunc(m.st.Recordings, func(r Rec) bool { return r.Key == key })470}471472// State describes rules, schedule and library for the UI.473func (m *Manager) State(ctx context.Context, g *guide.Guide, chans []lineup.Channel) (*State, error) {474 now := time.Now()475 m.mu.Lock()476 st := &State{Available: true, Rules: slices.Clone(m.st.Rules), Prefs: m.st.Prefs, Upcoming: []Item{}, Recorded: []Item{}}477 want := m.desired(g, chans, now, m.recorded())478 var active []Rec479 for _, r := range m.st.Recordings {480 if r.Status == Recording {481 active = append(active, r)482 st.Upcoming = append(st.Upcoming, m.item(r, now))483 } else {484 st.Recorded = append(st.Recorded, m.item(r, now))485 }486 }487 var todo []airing488 for _, a := range want {489 if !m.has(a.key) {490 todo = append(todo, a)491 }492 }493 m.mu.Unlock()494495 for _, p := range m.plan(ctx, active, todo) {496 a := p.airing497 st.Upcoming = append(st.Upcoming, Item{498 ID: a.key, Key: a.key, RuleID: a.rule.ID, Title: a.p.Title, Subtitle: a.p.EpisodeTitle,499 Description: a.p.Description, Image: a.p.Image, Channel: a.ch.Number, CallSign: a.ch.CallSign,500 Start: a.p.Start, End: a.p.End, Status: p.status, Detail: p.detail,501 })502 }503 slices.SortStableFunc(st.Upcoming, func(a, b Item) int { return a.Start.Compare(b.Start) })504 slices.SortStableFunc(st.Recorded, func(a, b Item) int { return b.Start.Compare(a.Start) })505 return st, nil506}507508// planned is an airing to come and whether it will get a tuner.509type planned struct {510 airing511 status, detail string512}513514// plan says which airings to come will get a tuner: each RF channel515// takes one, whatever is recorded from it, and earlier airings (then516// older rules) come first. Recordings in progress hold theirs.517func (m *Manager) plan(ctx context.Context, active []Rec, todo []airing) []planned {518 type slot struct {519 from, until time.Time520 freq int64521 title string522 }523 tuners := m.tuners.Count()524 var taken []slot525 for _, r := range active {526 if freq, _, err := m.locate(ctx, r.Channel); err == nil {527 taken = append(taken, slot{r.From, r.Until, freq, r.Title})528 }529 }530 out := make([]planned, 0, len(todo))531 for _, a := range todo {532 p := planned{airing: a, status: Scheduled}533 freq, _, err := m.locate(ctx, a.ch.Number)534 if err != nil {535 p.status, p.detail = Unavailable, err.Error()536 out = append(out, p)537 continue538 }539 from, until := a.window()540 // The moments the tuners in use could change within the window.541 moments := []time.Time{from}542 for _, s := range taken {543 if s.from.After(from) && s.from.Before(until) {544 moments = append(moments, s.from)545 }546 }547 for _, t := range moments {548 freqs := map[int64]bool{}549 var titles []string550 for _, s := range taken {551 if !s.from.After(t) && s.until.After(t) {552 freqs[s.freq] = true553 titles = append(titles, s.title)554 }555 }556 if !freqs[freq] && len(freqs) >= tuners {557 p.status = Conflict558 slices.Sort(titles)559 p.detail = "Every tuner is recording then: " + strings.Join(slices.Compact(titles), ", ")560 break561 }562 }563 if p.status == Scheduled {564 taken = append(taken, slot{from, until, freq, a.p.Title})565 }566 out = append(out, p)567 }568 return out569}570571// item shows a recording. Called with m.mu held.572func (m *Manager) item(r Rec, now time.Time) Item {573 it := Item{574 ID: r.ID, Key: r.Key, RuleID: r.RuleID, Title: r.Title, Subtitle: r.Subtitle, Description: r.Description,575 Image: r.Image, Channel: r.Channel, CallSign: r.CallSign, Start: r.Start, End: r.End,576 Status: r.Status, Detail: r.Detail, SizeBytes: r.Size, Duration: r.Duration(now).Seconds(),577 }578 if r.Status == Recording {579 it.Until = r.Until580 if j := m.jobs[r.ID]; j != nil {581 it.SizeBytes, it.Detail = j.size(), cmp.Or(j.problem(), r.Detail)582 }583 }584 if p, ok := m.st.Progress[r.ID]; ok {585 it.Position, it.Watched = p.Position, p.Watched586 }587 return it588}589590// Duration is how long the recording runs: from its first data to its591// last, or to now while it records.592func (r Rec) Duration(now time.Time) time.Duration {593 switch {594 case r.Began.IsZero():595 return 0596 case r.Status == Recording:597 return now.Sub(r.Began)598 case r.Ended.After(r.Began):599 return r.Ended.Sub(r.Began)600 }601 return 0602}603604// Get returns a recording and the path of its file.605func (m *Manager) Get(id string) (Rec, string, bool) {606 m.mu.Lock()607 defer m.mu.Unlock()608 for _, r := range m.st.Recordings {609 if r.ID == id {610 return r, filepath.Join(m.dir, r.File), true611 }612 }613 return Rec{}, "", false614}615616// Prune drops watch progress of recordings that no longer exist.617func (m *Manager) Prune(context.Context) error {618 m.mu.Lock()619 defer m.mu.Unlock()620 for id := range m.st.Progress {621 if !slices.ContainsFunc(m.st.Recordings, func(r Rec) bool { return r.ID == id }) {622 delete(m.st.Progress, id)623 }624 }625 return m.save()626}627628// SaveProgress records how far a recording has been watched. Reaching the629// last two minutes (or 95%) marks it watched.630func (m *Manager) SaveProgress(id string, position, duration float64) error {631 m.mu.Lock()632 defer m.mu.Unlock()633 p := m.st.Progress[id]634 p.Position, p.Updated = max(position, 0), time.Now()635 if duration > 0 && (position >= duration-120 || position >= duration*0.95) {636 p.Watched = true637 }638 m.st.Progress[id] = p639 return m.save()640}641642// MarkWatched sets or clears a recording's watched state; clearing it also643// resets the resume point.644func (m *Manager) MarkWatched(id string, watched bool) error {645 m.mu.Lock()646 defer m.mu.Unlock()647 p := m.st.Progress[id]648 p.Watched, p.Updated = watched, time.Now()649 if !watched {650 p.Position = 0651 }652 m.st.Progress[id] = p653 return m.save()654}655656// RuleUpdate changes a series rule.657type RuleUpdate struct {658 Keep int `json:"keep"`659 NewOnly bool `json:"newOnly"`660}661662// UpdateRule changes how many recordings a series keeps and whether it663// records new episodes only.664func (m *Manager) UpdateRule(id string, u RuleUpdate) (*Rule, error) {665 m.mu.Lock()666 defer m.mu.Unlock()667 for i := range m.st.Rules {668 if m.st.Rules[i].ID == id {669 m.st.Rules[i].Keep, m.st.Rules[i].NewOnly = max(u.Keep, 0), u.NewOnly670 r := m.st.Rules[i]671 return &r, m.save()672 }673 }674 return nil, fmt.Errorf("no rule %s", id)675}676677// SetPrefs changes library-wide settings.678func (m *Manager) SetPrefs(p Prefs) (Prefs, error) {679 m.mu.Lock()680 defer m.mu.Unlock()681 p.DeleteWatchedAfterDays = max(p.DeleteWatchedAfterDays, 0)682 m.st.Prefs = p683 return p, m.save()684}685686// Retain deletes recordings beyond a series' Keep count (oldest first) and687// watched recordings older than the library's limit.688func (m *Manager) Retain(context.Context) error {689 m.mu.Lock()690 defer m.mu.Unlock()691 keep := make(map[string]int)692 for _, r := range m.st.Rules {693 if r.Kind == Series && r.Keep > 0 {694 keep[r.ID] = r.Keep695 }696 }697 days := m.st.Prefs.DeleteWatchedAfterDays698 done := slices.DeleteFunc(slices.Clone(m.st.Recordings), func(r Rec) bool { return r.Status == Recording })699 slices.SortFunc(done, func(a, b Rec) int { return b.Start.Compare(a.Start) })700 var remove []string701 seen := make(map[string]int)702 for _, r := range done {703 if keep[r.RuleID] > 0 {704 if seen[r.RuleID]++; seen[r.RuleID] > keep[r.RuleID] {705 remove = append(remove, r.ID)706 continue707 }708 }709 if p := m.st.Progress[r.ID]; days > 0 && p.Watched && time.Since(r.End) > time.Duration(days)*24*time.Hour {710 remove = append(remove, r.ID)711 }712 }713 if len(remove) == 0 {714 return nil715 }716 for _, id := range remove {717 m.remove(id)718 }719 return m.save()720}721722// AiringKey identifies an airing: "7.1@1791058239".723func airingKey(channel string, start time.Time) string {724 return channel + "@" + strconv.FormatInt(start.Unix(), 10)725}726727// episodeID reports whether a Gracenote ID names one specific episode or728// movie (EP..., MV...), as opposed to a generic show (SH...) or event.729func episodeID(id string) bool { return strings.HasPrefix(id, "EP") || strings.HasPrefix(id, "MV") }730731func findChannel(chans []lineup.Channel, number, callSign string) *lineup.Channel {732 var fallback *lineup.Channel733 for i := range chans {734 if chans[i].Number != number || chans[i].GuideID == "" {735 continue736 }737 if chans[i].CallSign == callSign {738 return &chans[i]739 }740 if fallback == nil {741 fallback = &chans[i]742 }743 }744 return fallback745}746747func newID() string {748 b := make([]byte, 6)749 _, _ = rand.Read(b)750 return hex.EncodeToString(b)751}