1// Package guide loads TV listings for a ZIP code's over-the-air lineup from2// the Gracenote grid service that backs tvlistings.gracenote.com.3//4// The endpoint is public but undocumented and intended for personal use.5// Schedules Direct is the supported alternative for anything more.6package guide78import (9 "context"10 "encoding/json"11 "fmt"12 "net/http"13 "net/url"14 "slices"15 "strconv"16 "strings"17 "sync"18 "time"1920 "golang.org/x/sync/errgroup"2122 "airwaves/internal/web"23)2425const (26 gridURL = "https://tvlistings.gracenote.com/api/grid"27 // chunk is the largest timespan the grid endpoint accepts per request.28 chunk = 6 * time.Hour29)3031// Channel is one virtual channel in the lineup.32type Channel struct {33 ID string `json:"id"` // Gracenote station ID34 Number string `json:"number"` // "2.1"35 CallSign string `json:"callSign"` // "KWGNDT"36 Network string `json:"network"`37 Logo string `json:"logo"`38 // LogoDark is the logo drawn for a dark background (white), when the39 // listings have one: Schedules Direct's do.40 LogoDark string `json:"logoDark,omitempty"`41}4243// Program is one airing.44type Program struct {45 Start time.Time `json:"start"`46 End time.Time `json:"end"`47 Title string `json:"title"`48 EpisodeTitle string `json:"episodeTitle,omitempty"`49 Description string `json:"description,omitempty"`50 Season string `json:"season,omitempty"`51 Episode string `json:"episode,omitempty"`52 Rating string `json:"rating,omitempty"`53 Year string `json:"year,omitempty"`54 Flags []string `json:"flags,omitempty"` // New, Live, Premiere, Finale55 Tags []string `json:"tags,omitempty"` // CC, Stereo, HD...56 Genres []string `json:"genres,omitempty"`57 Image string `json:"image,omitempty"`58 ProgramID string `json:"programId,omitempty"`59 SeriesID string `json:"seriesId,omitempty"`60 // AudioLang is the sound's language as tagged ("eng", "jpn") where a61 // custom channel knows it.62 AudioLang string `json:"audioLang,omitempty"`6364 // From Schedules Direct's fuller listings: when the episode first65 // aired ("2019-03-05"), the cast as "Name (Character)", a movie's66 // running time in minutes and Gracenote's star rating (1 to 4), and a67 // game's teams.68 OriginalAirDate string `json:"originalAirDate,omitempty"`69 Cast []string `json:"cast,omitempty"`70 Runtime int `json:"runtime,omitempty"`71 Stars float64 `json:"stars,omitempty"`72 HomeTeam string `json:"homeTeam,omitempty"`73 AwayTeam string `json:"awayTeam,omitempty"`74}7576// Guide is a lineup with listings, keyed by channel ID.77type Guide struct {78 Lineup string `json:"lineup"`79 Start time.Time `json:"start"`80 End time.Time `json:"end"`81 Fetched time.Time `json:"fetched"`82 Channels []Channel `json:"channels"`83 Programs map[string][]Program `json:"programs"`84}8586// LineupID is the Gracenote over-the-air lineup for a ZIP code.87func LineupID(zip string) string { return "USA-OTA" + zip + "-DEFAULT" }8889// Fetch loads listings for zip from start for the given number of hours.90func Fetch(ctx context.Context, c *http.Client, zip string, start time.Time, hours int) (*Guide, error) {91 start = start.Truncate(30 * time.Minute)92 end := start.Add(time.Duration(hours) * time.Hour)93 g := &Guide{94 Lineup: LineupID(zip),95 Start: start,96 End: end,97 Fetched: time.Now(),98 Programs: make(map[string][]Program),99 }100101 var mu sync.Mutex102 seen := make(map[string]bool)103 eg, ctx := errgroup.WithContext(ctx)104 eg.SetLimit(4)105 for t := start; t.Before(end); t = t.Add(chunk) {106 eg.Go(func() error {107 resp, err := fetchChunk(ctx, c, zip, t)108 if err != nil {109 return err110 }111 mu.Lock()112 defer mu.Unlock()113 g.merge(resp, seen)114 return nil115 })116 }117 if err := eg.Wait(); err != nil {118 return nil, err119 }120121 slices.SortFunc(g.Channels, func(a, b Channel) int { return CompareNumbers(a.Number, b.Number) })122 for id := range g.Programs {123 slices.SortFunc(g.Programs[id], func(a, b Program) int { return a.Start.Compare(b.Start) })124 }125 return g, nil126}127128type gridResponse struct {129 Channels []struct {130 CallSign string `json:"callSign"`131 AffiliateName string `json:"affiliateName"`132 ChannelID string `json:"channelId"`133 ChannelNo string `json:"channelNo"`134 Thumbnail string `json:"thumbnail"`135 Events []gridEvent `json:"events"`136 } `json:"channels"`137}138139type gridEvent struct {140 StartTime time.Time `json:"startTime"`141 EndTime time.Time `json:"endTime"`142 Thumbnail string `json:"thumbnail"`143 Filter []string `json:"filter"`144 Flag []string `json:"flag"`145 Tags []string `json:"tags"`146 Rating flexString `json:"rating"`147 SeriesID string `json:"seriesId"`148 Program struct {149 Title string `json:"title"`150 ID string `json:"id"`151 ShortDesc flexString `json:"shortDesc"`152 Season flexString `json:"season"`153 Episode flexString `json:"episode"`154 EpisodeTitle flexString `json:"episodeTitle"`155 ReleaseYear flexString `json:"releaseYear"`156 } `json:"program"`157}158159// flexString accepts JSON strings, numbers or null.160type flexString string161162func (f *flexString) UnmarshalJSON(b []byte) error {163 if string(b) == "null" {164 *f = ""165 return nil166 }167 var s string168 if err := json.Unmarshal(b, &s); err == nil {169 *f = flexString(s)170 return nil171 }172 *f = flexString(strings.Trim(string(b), `"`))173 return nil174}175176func fetchChunk(ctx context.Context, c *http.Client, zip string, t time.Time) (*gridResponse, error) {177 q := url.Values{178 "lineupId": {LineupID(zip)},179 "timespan": {strconv.Itoa(int(chunk.Hours()))},180 "headendId": {"lineupId"},181 "country": {"USA"},182 "timezone": {""},183 "device": {"-"},184 "postalCode": {zip},185 "isOverride": {"true"},186 "time": {strconv.FormatInt(t.Unix(), 10)},187 "pref": {"16,128"},188 "userId": {"-"},189 "aid": {"orbebb"},190 "languagecode": {"en-us"},191 }192 body, err := web.Get(ctx, c, gridURL+"?"+q.Encode())193 if err != nil {194 return nil, fmt.Errorf("gracenote grid: %w", err)195 }196 var resp gridResponse197 if err := json.Unmarshal(body, &resp); err != nil {198 return nil, fmt.Errorf("decode gracenote grid: %w", err)199 }200 return &resp, nil201}202203func (g *Guide) merge(resp *gridResponse, seen map[string]bool) {204 for _, ch := range resp.Channels {205 if !seen[ch.ChannelID] {206 seen[ch.ChannelID] = true207 g.Channels = append(g.Channels, Channel{208 ID: ch.ChannelID,209 Number: ch.ChannelNo,210 CallSign: ch.CallSign,211 Network: NetworkName(ch.AffiliateName),212 Logo: imageURL(ch.Thumbnail),213 })214 }215 for _, e := range ch.Events {216 key := ch.ChannelID + "@" + e.StartTime.Format(time.RFC3339)217 if seen[key] {218 continue219 }220 seen[key] = true221 p := Program{222 Start: e.StartTime,223 End: e.EndTime,224 Title: e.Program.Title,225 EpisodeTitle: string(e.Program.EpisodeTitle),226 Description: string(e.Program.ShortDesc),227 Season: string(e.Program.Season),228 Episode: string(e.Program.Episode),229 Rating: string(e.Rating),230 Year: string(e.Program.ReleaseYear),231 Flags: e.Flag,232 Tags: e.Tags,233 ProgramID: e.Program.ID,234 SeriesID: e.SeriesID,235 }236 for _, f := range e.Filter {237 p.Genres = append(p.Genres, strings.TrimPrefix(f, "filter-"))238 }239 if e.Thumbnail != "" {240 p.Image = "https://zpmc.tmsimg.com/assets/" + e.Thumbnail + ".jpg?w=360"241 }242 g.Programs[ch.ChannelID] = append(g.Programs[ch.ChannelID], p)243 }244 }245}246247func imageURL(u string) string {248 if strings.HasPrefix(u, "//") {249 u = "https:" + u250 }251 return strings.Replace(u, "?w=55", "?w=110", 1)252}253254// networkNames shortens Gracenote's formal affiliate names.255var networkNames = map[string]string{256 "AMERICAN BROADCASTING COMPANY": "ABC",257 "CBS TELEVISION NETWORK": "CBS",258 "NATIONAL BROADCASTING COMPANY": "NBC",259 "FOX ENTERTAINMENT": "FOX",260 "THE CW TELEVISION NETWORK": "The CW",261 "PUBLIC BROADCASTING SERVICE": "PBS",262 "ION: INDEPENDENT TELEVISION": "ION",263 "MYNETWORKTV": "MyNetworkTV",264 "TRINITY BROADCASTING NETWORK": "TBN",265 "ETERNAL WORD TELEVISION NETWORK": "EWTN",266 "ME TV NETWORK": "MeTV",267 "START TV NETWORK": "Start TV",268 "DABL NETWORK": "Dabl",269 "HEROES & ICONS NETWORK": "Heroes & Icons",270 "HOME SHOPPING NETWORK": "HSN",271 "DAYSTAR TELEVISION NETWORK": "Daystar",272 "CHRISTIAN TELEVISION NETWORK": "CTN",273 "SONLIFE BROADCASTING NETWORK": "SonLife",274}275276// acronyms stay upper case when title-casing affiliate names.277var acronyms = map[string]bool{278 "TV": true, "HD": true, "QVC": true, "QVC2": true, "HSN": true, "HSN2": true, "ION": true,279 "PBS": true, "LATV": true, "MTN": true, "CTN": true, "TBN": true, "JTV": true, "NHK": true,280 "LC": true, "LAFF": true, "ABC": true, "CBS": true, "NBC": true, "FOX": true, "CW": true,281}282283// NetworkName is an affiliate's name as the guide shows it: "NBC" for284// "NATIONAL BROADCASTING COMPANY", "Start TV" for "START TV NETWORK".285func NetworkName(affiliate string) string {286 // Gracenote's grid writes them in capitals, Schedules Direct not287 // always.288 if n, ok := networkNames[strings.ToUpper(affiliate)]; ok {289 return n290 }291 if strings.HasPrefix(affiliate, "METV") {292 affiliate = "MeTV" + strings.TrimPrefix(affiliate, "METV")293 }294 words := strings.Fields(affiliate)295 for i, w := range words {296 if !acronyms[w] && w != "MeTV" {297 words[i] = w[:1] + strings.ToLower(w[1:])298 }299 }300 return strings.Join(words, " ")301}302303// CompareNumbers orders virtual channel numbers like "2.1" < "2.10" < "11".304func CompareNumbers(a, b string) int {305 am, an := SplitNumber(a)306 bm, bn := SplitNumber(b)307 if am != bm {308 return am - bm309 }310 return an - bn311}312313// SplitNumber parses "7.2" into (7, 2) and "11" into (11, 0).314func SplitNumber(s string) (major, minor int) {315 ma, mi, _ := strings.Cut(s, ".")316 major, _ = strconv.Atoi(ma)317 minor, _ = strconv.Atoi(mi)318 return major, minor319}320321// BaseCall reduces a Gracenote station call sign to the FCC base call:322// "KWGNDT2" → "KWGN", "KZDND10" → "KZDN", "K48MND" → "K48MN".323func BaseCall(call string) string {324 s := strings.TrimRight(strings.ToUpper(call), "0123456789")325 for _, suf := range []string{"DT", "LD", "CD", "LP", "CA", "D"} {326 if rest, ok := strings.CutSuffix(s, suf); ok && len(rest) >= 4 {327 return rest328 }329 }330 return s331}