1package sd23import (4 "cmp"5 "encoding/json"6 "errors"7 "fmt"8 "io/fs"9 "maps"10 "os"11 "slices"12 "strconv"13 "strings"14 "time"1516 "airwaves/internal/guide"17)1819// state is what an update leaves for the next: the token, the lineup, and20// the listings by station and day with the checksums Schedules Direct21// gave them, so only what changed is fetched again.22type state struct {23 Token string `json:"token,omitempty"`24 TokenExpires time.Time `json:"tokenExpires,omitzero"`25 // Stopped says why updating stopped until the account changes.26 Stopped string `json:"stopped,omitempty"`27 Expires time.Time `json:"expires,omitzero"` // the account28 Updated time.Time `json:"updated,omitzero"`2930 Lineup string `json:"lineup,omitempty"`31 LineupModified string `json:"lineupModified,omitempty"`32 Stations []station `json:"stations,omitempty"`33 // Days holds each station's schedule by date ("2026-10-12", UTC).34 Days map[string]map[string]day `json:"days,omitempty"`35 // Programs are the programs aired, by program ID.36 Programs map[string]program `json:"programs,omitempty"`37 // Art is the picture kept for each series or program, by artKey: its38 // file in the images folder. ArtWanted are those picked and not yet39 // downloaded (their URIs), ArtChecked when each key was last looked40 // up, BadArt URIs Schedules Direct hasn't got, and ImagesPaused when41 // downloads may start again after the daily cap.42 Art map[string]string `json:"art,omitempty"`43 ArtWanted map[string]string `json:"artWanted,omitempty"`44 ArtChecked map[string]time.Time `json:"artChecked,omitempty"`45 BadArt map[string]bool `json:"badArt,omitempty"`46 ImagesPaused time.Time `json:"imagesPaused,omitzero"`47}4849type station struct {50 ID string `json:"id"`51 Number string `json:"number"` // "2.1"52 CallSign string `json:"callSign"`53 Name string `json:"name,omitempty"`54 Affiliate string `json:"affiliate,omitempty"`55 // Logos are the station's logo by the background it's drawn for:56 // "light" (the colored one), "gray", "dark" and "white" (white ones);57 // Logo is the one Schedules Direct gives without saying.58 Logos map[string]string `json:"logos,omitempty"`59 Logo string `json:"logo,omitempty"`60}6162// logoDark is the station's logo drawn for a dark background: white.63func (s station) logoDark() string { return cmp.Or(s.Logos["dark"], s.Logos["white"]) }6465// logo is the station's logo for a light background: the colored one,66// else gray, else any.67func (s station) logo() string {68 for _, c := range []string{"light", "gray"} {69 if u := s.Logos[c]; u != "" {70 return u71 }72 }73 if s.Logo != "" {74 return s.Logo75 }76 for _, c := range []string{"white", "dark"} {77 if u := s.Logos[c]; u != "" {78 return u79 }80 }81 return ""82}8384// oldLogos reports whether the stations were kept before their logos'85// variants were, so the lineup should be read again for them.86func (st *state) oldLogos() bool {87 for _, s := range st.Stations {88 if len(s.Logos) > 0 {89 return false90 }91 }92 return len(st.Stations) > 093}9495type day struct {96 MD5 string `json:"md5"`97 Airings []airing `json:"airings"`98}99100type airing struct {101 ProgramID string `json:"programId"`102 Start time.Time `json:"start"`103 Seconds int `json:"seconds"`104 MD5 string `json:"md5"`105 New bool `json:"new,omitempty"`106 Live bool `json:"live,omitempty"`107 Premiere bool `json:"premiere,omitempty"`108 Finale bool `json:"finale,omitempty"`109 Tags []string `json:"tags,omitempty"` // CC, Stereo, DVS...110 Rating string `json:"rating,omitempty"`111}112113type program struct {114 MD5 string `json:"md5"`115 Title string `json:"title"`116 EpisodeTitle string `json:"episodeTitle,omitempty"`117 Description string `json:"description,omitempty"`118 OriginalAirDate string `json:"originalAirDate,omitempty"`119 Season int `json:"season,omitempty"`120 Episode int `json:"episode,omitempty"`121 Genres []string `json:"genres,omitempty"`122 Kind string `json:"kind,omitempty"` // Show, Episode, Sports, Movie123 Cast []string `json:"cast,omitempty"`124 Year int `json:"year,omitempty"`125 Runtime int `json:"runtime,omitempty"` // minutes126 Stars float64 `json:"stars,omitempty"`127 Rating string `json:"rating,omitempty"`128 HomeTeam string `json:"homeTeam,omitempty"`129 AwayTeam string `json:"awayTeam,omitempty"`130 Art bool `json:"art,omitempty"` // Schedules Direct has pictures of it131}132133const stateVersion = 1134135type stateFile struct {136 Version int `json:"version"`137 *state138}139140// loadState reads the state kept at path; a missing or unreadable file is141// a fresh start (an unreadable one is set aside as path.bad).142func loadState(path string) (*state, error) {143 f := stateFile{state: &state{}}144 raw, err := os.ReadFile(path)145 if errors.Is(err, fs.ErrNotExist) {146 return f.state, nil147 }148 if err == nil {149 err = json.Unmarshal(raw, &f)150 }151 if err != nil || f.Version != stateVersion {152 _ = os.Rename(path, path+".bad")153 if err == nil {154 err = fmt.Errorf("listings saved by another version")155 }156 return &state{}, fmt.Errorf("%s: %w", path, err)157 }158 return f.state, nil159}160161func (st *state) save(path string) error {162 raw, err := json.Marshal(stateFile{Version: stateVersion, state: st})163 if err != nil {164 return err165 }166 return writeFile(path, raw, 0o600)167}168169// clone copies st for an update to change, so a failed update leaves the170// listings as they were.171func (st *state) clone() *state {172 c := *st173 c.Stations = slices.Clone(st.Stations)174 c.Days = make(map[string]map[string]day, len(st.Days))175 for id, days := range st.Days {176 c.Days[id] = maps.Clone(days)177 }178 c.Programs = maps.Clone(st.Programs)179 c.Art, c.ArtWanted, c.ArtChecked, c.BadArt = maps.Clone(st.Art), maps.Clone(st.ArtWanted), maps.Clone(st.ArtChecked), maps.Clone(st.BadArt)180 if c.Programs == nil {181 c.Programs = map[string]program{}182 }183 if c.Art == nil {184 c.Art = map[string]string{}185 }186 if c.ArtWanted == nil {187 c.ArtWanted = map[string]string{}188 }189 return &c190}191192// prune drops days before yesterday, and programs and pictures nothing193// airs any more.194func (st *state) prune(now time.Time) {195 oldest := now.UTC().AddDate(0, 0, -1).Format(time.DateOnly)196 used := map[string]bool{}197 for id, days := range st.Days {198 for date, d := range days {199 if date < oldest {200 delete(days, date)201 continue202 }203 for _, a := range d.Airings {204 used[a.ProgramID] = true205 }206 }207 if len(days) == 0 {208 delete(st.Days, id)209 }210 }211 for id := range st.Programs {212 if !used[id] {213 delete(st.Programs, id)214 }215 }216 // Pictures are kept (they're files, and a series comes back); what217 // was only being looked up for programs gone is dropped.218 keys := map[string]bool{}219 for id := range used {220 keys[artKey(id)] = true221 }222 for k := range st.ArtWanted {223 if !keys[k] {224 delete(st.ArtWanted, k)225 }226 }227 for k, t := range st.ArtChecked {228 if !keys[k] && now.Sub(t) > artRecheck {229 delete(st.ArtChecked, k)230 }231 }232}233234// SeriesID is a program's series as the guide's grid gives it, so series235// recording rules carry over: "SH" and the series number, for episodes and236// shows; "" for movies and the like.237func SeriesID(programID string) string {238 if len(programID) < 10 || !strings.HasPrefix(programID, "EP") && !strings.HasPrefix(programID, "SH") {239 return ""240 }241 return "SH" + programID[2:10]242}243244// guide is the listings as the guide has them, from a day before now on;245// nil while there are none.246func (st *state) guide(zip string, now time.Time) *guide.Guide {247 if len(st.Stations) == 0 || len(st.Days) == 0 {248 return nil249 }250 g := &guide.Guide{Lineup: st.Lineup, Fetched: st.Updated, Programs: map[string][]guide.Program{}}251 from := now.Add(-24 * time.Hour)252 for _, sta := range st.Stations {253 g.Channels = append(g.Channels, guide.Channel{254 ID: sta.ID, Number: sta.Number, CallSign: sta.CallSign, Network: guide.NetworkName(sta.Affiliate), Logo: sta.logo(), LogoDark: sta.logoDark(),255 })256 for _, d := range st.Days[sta.ID] {257 for _, a := range d.Airings {258 end := a.Start.Add(time.Duration(a.Seconds) * time.Second)259 if !end.After(from) {260 continue261 }262 g.Programs[sta.ID] = append(g.Programs[sta.ID], st.program(a, end))263 }264 }265 }266 slices.SortFunc(g.Channels, func(a, b guide.Channel) int { return guide.CompareNumbers(a.Number, b.Number) })267 for id, ps := range g.Programs {268 slices.SortFunc(ps, func(a, b guide.Program) int { return a.Start.Compare(b.Start) })269 // Days overlap at midnight: an airing listed on both stays once.270 g.Programs[id] = slices.CompactFunc(ps, func(a, b guide.Program) bool { return a.Start.Equal(b.Start) })271 if n := len(ps); n > 0 {272 g.Start = cmp.Or(minTime(g.Start, ps[0].Start), ps[0].Start)273 g.End = maxTime(g.End, ps[n-1].End)274 }275 }276 return g277}278279func (st *state) program(a airing, end time.Time) guide.Program {280 p := st.Programs[a.ProgramID]281 out := guide.Program{282 Start: a.Start, End: end, Title: cmp.Or(p.Title, "To be announced"), EpisodeTitle: p.EpisodeTitle,283 Description: p.Description, Rating: cmp.Or(a.Rating, p.Rating), Tags: a.Tags,284 ProgramID: a.ProgramID, SeriesID: SeriesID(a.ProgramID), OriginalAirDate: p.OriginalAirDate, Cast: p.Cast,285 Runtime: p.Runtime, Stars: p.Stars, HomeTeam: p.HomeTeam, AwayTeam: p.AwayTeam, Genres: genres(a.ProgramID, p),286 }287 if p.Season > 0 && p.Episode > 0 {288 out.Season, out.Episode = strconv.Itoa(p.Season), strconv.Itoa(p.Episode)289 }290 if p.Year > 0 {291 out.Year = strconv.Itoa(p.Year)292 }293 for flag, on := range map[string]bool{"New": a.New, "Live": a.Live, "Premiere": a.Premiere, "Finale": a.Finale} {294 if on {295 out.Flags = append(out.Flags, flag)296 }297 }298 slices.Sort(out.Flags)299 if img := st.Art[artKey(a.ProgramID)]; img != "" {300 out.Image = ArtPath + img301 }302 return out303}304305// filterGenres maps Schedules Direct's genres to the guide's filters.306var filterGenres = map[string][]string{307 "news": {"news"}, "sports event": {"sports"}, "sports non-event": {"sports"}, "sports talk": {"sports"},308 "children": {"kids", "family"}, "family": {"family"}, "animated": {"family"}, "talk": {"talk"},309 "documentary": {"documentary"}, "music": {"music"}, "musical": {"music"},310}311312// genres are a program's genres: the guide's filter keys ("sports",313// "movie", "news"...), then the rest as Schedules Direct names them.314func genres(id string, p program) []string {315 var out []string316 seen := map[string]bool{}317 add := func(g string) {318 if k := strings.ToLower(g); g != "" && !seen[k] {319 seen[k] = true320 out = append(out, g)321 }322 }323 if strings.HasPrefix(id, "MV") || p.Kind == "Movie" {324 add("movie")325 }326 if strings.HasPrefix(id, "SP") || p.Kind == "Sports" {327 add("sports")328 }329 for _, g := range p.Genres {330 for _, k := range filterGenres[strings.ToLower(g)] {331 add(k)332 }333 }334 for _, g := range p.Genres {335 add(g)336 }337 return out338}339340func minTime(a, b time.Time) time.Time {341 if a.IsZero() || b.Before(a) {342 return b343 }344 return a345}346347func maxTime(a, b time.Time) time.Time {348 if b.After(a) {349 return b350 }351 return a352}