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 / sd / source.go
source.go 8.4K
1// Package sd keeps a ZIP code's over-the-air listings from Schedules Direct2// (schedulesdirect.org), the licensed source of Gracenote's TV data for3// personal use: two weeks of schedules with descriptions, cast, original4// air dates, ratings, movie runtimes and star ratings, and sports teams.5//6// It is a careful client. It logs in only when its token runs out, asks for7// what changed since its last update and nothing else, updates once a day,8// and stops trying altogether when the account is refused, until the9// account is changed: Schedules Direct blocks clients that keep failing.10package sd1112import (13 "context"14 "errors"15 "log"16 "math/rand/v2"17 "net/http"18 "path/filepath"19 "sync"20 "time"2122 "airwaves/internal/guide"23)2425// Days is how many days of listings are kept.26const Days = 142728// Status is how the listings stand, for the admin page.29type Status struct {30 // User is the account's user name; "" with no account set.31 User string `json:"user,omitempty"`32 // OK is set when the last update worked.33 OK bool `json:"ok"`34 // Stopped says why updating stopped until the account is changed:35 // a refused login, an expired or locked account.36 Stopped string `json:"stopped,omitempty"`37 // Error is the last update's failure, when it failed.38 Error string `json:"error,omitempty"`39 // Expires is when the Schedules Direct account runs out.40 Expires time.Time `json:"expires,omitzero"`41 // Lineup is the account's antenna lineup, "USA-OTA-80302".42 Lineup string `json:"lineup,omitempty"`43 Stations int `json:"stations,omitempty"`44 Programs int `json:"programs,omitempty"`45 // Updated is when the listings were last brought up to date, Through46 // how far ahead they reach, and Next when they're next updated.47 Updated time.Time `json:"updated,omitzero"`48 Through time.Time `json:"through,omitzero"`49 Next time.Time `json:"next,omitzero"`50 // Updating is set while an update runs.51 Updating bool `json:"updating,omitempty"`52}5354// Source keeps the listings of ZIP's antenna lineup up to date.55type Source struct {56 // Dir keeps the account and what was fetched.57 Dir string58 // ZIP is the antenna's.59 ZIP string60 // HTTP makes the requests; UserAgent names this program and its61 // version, as Schedules Direct requires.62 HTTP *http.Client63 UserAgent string64 // BaseURL is the API's; API by default.65 BaseURL string66 // OnUpdate, when set, is told of new listings.67 OnUpdate func()6869 once sync.Once70 updates chan struct{}7172 mu sync.Mutex73 status Status74 st *state75 g *guide.Guide76}7778func (s *Source) accountPath() string { return filepath.Join(s.Dir, "account.json") }79func (s *Source) statePath() string { return filepath.Join(s.Dir, "listings.json") }8081func (s *Source) init() {82 s.once.Do(func() {83 s.updates = make(chan struct{}, 1)84 a, err := LoadAccount(s.accountPath())85 if err != nil {86 log.Printf("listings: %v", err)87 }88 st, err := loadState(s.statePath())89 if err != nil {90 log.Printf("listings: %v; starting afresh", err)91 }92 if st.oldLogos() {93 st.LineupModified = "" // read the lineup again, for its logos94 st.Updated = time.Time{}95 }96 if len(st.Programs) > 0 && len(st.ArtChecked) == 0 {97 st.Updated = time.Time{} // kept before pictures were: fetch them98 }99 s.mu.Lock()100 defer s.mu.Unlock()101 s.st = st102 s.status.User = a.User103 s.status.Stopped = st.Stopped104 if st.Updated.After(time.Time{}) {105 s.status.OK = st.Stopped == ""106 s.status.Updated, s.status.Lineup = st.Updated, st.Lineup107 s.status.Expires = st.Expires108 }109 s.g = st.guide(s.ZIP, time.Now())110 s.count()111 })112}113114// count notes the listings' size in the status. Called with s.mu held.115func (s *Source) count() {116 s.status.Stations, s.status.Programs, s.status.Through = 0, 0, time.Time{}117 if s.g == nil {118 return119 }120 s.status.Stations = len(s.g.Channels)121 for _, ps := range s.g.Programs {122 s.status.Programs += len(ps)123 if n := len(ps); n > 0 && ps[n-1].End.After(s.status.Through) {124 s.status.Through = ps[n-1].End125 }126 }127}128129// Status reports how the listings stand.130func (s *Source) Status() Status {131 s.init()132 s.mu.Lock()133 defer s.mu.Unlock()134 return s.status135}136137// Guide is the listings, nil while there are none: no account, or none138// fetched yet.139func (s *Source) Guide() *guide.Guide {140 s.init()141 s.mu.Lock()142 defer s.mu.Unlock()143 if s.status.User == "" {144 return nil145 }146 return s.g147}148149// SetAccount keeps a new login and updates with it at once; an empty user150// removes the account and its listings. An empty password keeps the saved151// one for the same user.152func (s *Source) SetAccount(user, password string) error {153 s.init()154 old, _ := LoadAccount(s.accountPath())155 a := Account{User: user, PasswordSHA1: HashPassword(password)}156 switch {157 case user == "":158 a = Account{}159 case password == "" && user == old.User:160 a.PasswordSHA1 = old.PasswordSHA1161 case password == "":162 return errors.New("enter the Schedules Direct password")163 }164 if err := SaveAccount(s.accountPath(), a); err != nil {165 return err166 }167 s.mu.Lock()168 // A new login: try again, with a token of its own, and pictures too169 // (a subscription lifts a trial's cap on them).170 s.st.Stopped, s.st.Token, s.st.TokenExpires = "", "", time.Time{}171 s.st.ImagesPaused = time.Time{}172 s.status = Status{User: a.User}173 if a.User == "" {174 s.st = &state{}175 s.g = nil176 }177 err := s.st.save(s.statePath())178 s.mu.Unlock()179 if a.Set() {180 s.Kick()181 } else if s.OnUpdate != nil {182 s.OnUpdate() // back to the public grid183 }184 return err185}186187// Kick asks for an update now.188func (s *Source) Kick() {189 s.init()190 select {191 case s.updates <- struct{}{}:192 default:193 }194}195196// Run keeps the listings up to date until ctx ends: about once a day, at197// a minute of its own (Schedules Direct asks clients not to all come at198// once), and on Kick. After a failure it tries again in an hour; a refused account199// stops it until the account changes.200func (s *Source) Run(ctx context.Context) {201 s.init()202 // The same minute of the day every day, picked once per server.203 offset := time.Duration(rand.IntN(24*60)) * time.Minute204 for {205 a, _ := LoadAccount(s.accountPath())206 next := time.Time{}207 if a.Set() {208 s.mu.Lock()209 stopped, updated := s.st.Stopped, s.st.Updated210 s.mu.Unlock()211 if stopped == "" {212 // The server's own minute of the day, at least half a day213 // after the last update: a day apart, give or take.214 soonest := updated.Add(12 * time.Hour)215 if next = soonest.Truncate(24 * time.Hour).Add(offset); next.Before(soonest) {216 next = next.Add(24 * time.Hour)217 }218 if updated.IsZero() || !time.Now().Before(next) {219 s.update(ctx, a)220 continue221 }222 }223 }224 s.mu.Lock()225 s.status.Next = next226 s.mu.Unlock()227 var wake <-chan time.Time228 if !next.IsZero() {229 wake = time.After(time.Until(next))230 }231 select {232 case <-ctx.Done():233 return234 case <-s.updates:235 s.mu.Lock()236 stopped := s.st.Stopped237 s.mu.Unlock()238 // A stopped account waits for SetAccount, which clears it.239 if a, _ := LoadAccount(s.accountPath()); a.Set() && stopped == "" {240 s.update(ctx, a)241 }242 case <-wake:243 }244 }245}246247// update brings the listings up to date with a, and notes how it went.248// After a failure that isn't the account's, it waits an hour.249func (s *Source) update(ctx context.Context, a Account) {250 // This update answers any Kick waiting.251 select {252 case <-s.updates:253 default:254 }255 s.mu.Lock()256 s.status.Updating = true257 st := s.st.clone()258 s.mu.Unlock()259260 c := &client{base: s.BaseURL, http: s.HTTP, ua: s.UserAgent, account: a, st: st}261 err := c.update(ctx, s.ZIP, filepath.Join(s.Dir, "images"), time.Now())262 var stop *StopError263 if errors.As(err, &stop) {264 st.Stopped = stop.Error()265 }266 if err == nil {267 st.Updated = time.Now()268 }269 if serr := st.save(s.statePath()); serr != nil {270 log.Printf("listings: %v", serr)271 }272273 s.mu.Lock()274 s.st = st275 s.status.Updating = false276 s.status.Stopped, s.status.Lineup, s.status.Expires = st.Stopped, st.Lineup, st.Expires277 if err == nil {278 s.status.OK, s.status.Error, s.status.Updated = true, "", st.Updated279 s.g = st.guide(s.ZIP, time.Now())280 s.count()281 } else {282 s.status.OK, s.status.Error = false, err.Error()283 }284 s.mu.Unlock()285286 if err == nil && s.OnUpdate != nil {287 s.OnUpdate()288 }289 switch {290 case err == nil:291 log.Printf("listings: up to date from Schedules Direct, %d programs on %d stations", s.status.Programs, s.status.Stations)292 case stop != nil:293 log.Printf("listings: stopped until the account is changed: %v", err)294 default:295 log.Printf("listings: %v; trying again in an hour", err)296 select {297 case <-ctx.Done():298 case <-time.After(time.Hour):299 case <-s.updates:300 }301 }302}