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 / lineup / lineup.go
lineup.go 14.1K
1// Package lineup combines transmitter data, ATSC 3.0 hosting and guide2// listings into one view of what is on the air at a location: with3// terrain-based reception estimates for planning (otascan), or matched to4// what a tuner actually receives (Match) for the app.5package lineup67import (8 "cmp"9 "context"10 "fmt"11 "net/http"12 "slices"13 "strconv"14 "sync"15 "time"1617 "golang.org/x/sync/errgroup"1819 "airwaves/internal/atsc3"20 "airwaves/internal/fcc"21 "airwaves/internal/geo"22 "airwaves/internal/guide"23 "airwaves/internal/reception"24 "airwaves/internal/store"25 "airwaves/internal/terrain"26)2728// Station is a transmitter, with its predicted reception when the report29// was built with terrain.30type Station struct {31 fcc.Facility32 Band string `json:"band"`33 // Signal is the estimate for each antenna preset, keyed by preset name;34 // none without terrain.35 Signal map[string]reception.Estimate `json:"signal,omitempty"`36 ATSC3 bool `json:"atsc3"`37 // Carries lists the virtual channels this transmitter is matched to.38 Carries []string `json:"carries"`39}4041// Carriage describes where a channel is broadcast in ATSC 3.0.42type Carriage struct {43 HostCall string `json:"hostCall"`44 FacilityID int `json:"facilityId"`45 RF string `json:"rf"`46 Display string `json:"display"`47 Tier map[string]reception.Tier `json:"tier"`48}4950// Channel is a virtual channel a viewer can tune.51type Channel struct {52 Number string `json:"number"`53 Major int `json:"major"`54 Minor int `json:"minor"`55 GuideID string `json:"guideId,omitempty"`56 CallSign string `json:"callSign"`57 BaseCall string `json:"baseCall"`58 Network string `json:"network"`59 Logo string `json:"logo,omitempty"`60 // FacilityID is the transmitter carrying the ATSC 1.0 signal, picked as61 // the best-received facility for the station; 0 when none was found.62 FacilityID int `json:"facilityId"`63 // Via names the host when another station's transmitter carries this64 // channel's ATSC 1.0 signal (common after an ATSC 3.0 conversion).65 Via string `json:"via,omitempty"`66 // Tier is the predicted ATSC 1.0 reception per preset. For channels that67 // only exist in ATSC 3.0 it is the 3.0 prediction.68 Tier map[string]reception.Tier `json:"tier"`69 ATSC3 *Carriage `json:"atsc3,omitempty"`70 ATSC3Only bool `json:"atsc3Only,omitempty"`71}7273// Source credits a data provider.74type Source struct {75 Name string `json:"name"`76 URL string `json:"url"`77 Use string `json:"use"`78}7980// Report is everything known about over-the-air TV at a location.81type Report struct {82 Generated time.Time `json:"generated"`83 Place geo.Place `json:"place"`84 Point geo.Point `json:"point"`85 RadiusKm float64 `json:"radiusKm"`86 Presets []reception.Preset `json:"presets,omitempty"` // with estimates only87 Stations []Station `json:"stations"`88 Channels []Channel `json:"channels"`89 ATSC3 []atsc3.Host `json:"atsc3"`90 Sources []Source `json:"sources"`91 Warnings []string `json:"warnings"`92}9394// Options select the location and freshness of a build.95type Options struct {96 ZIP string97 Point *geo.Point // overrides the ZIP centroid98 RadiusKm float6499 GuideHours int100 Refresh bool // bypass cached transmitter, ATSC 3.0 and guide data101}102103// Builder fetches and merges the data sources.104type Builder struct {105 HTTP *http.Client106 Cache *store.Cache107 // Terrain, when set, has every transmitter's reception estimated from108 // the terrain between it and the location. Without it the report is109 // the records alone, and its stations are ordered by distance.110 Terrain *terrain.Source111 // Listings, when it has any, are the guide's listings in place of112 // Gracenote's public grid: Schedules Direct's, kept by the server.113 Listings func() *guide.Guide114 Progress func(string)115}116117func (b *Builder) progress(format string, args ...any) {118 if b.Progress != nil {119 b.Progress(fmt.Sprintf(format, args...))120 }121}122123// Build produces a report and the guide it was built from. Failures of124// optional sources (terrain, ATSC 3.0 list, listings) become warnings.125func (b *Builder) Build(ctx context.Context, opt Options) (*Report, *guide.Guide, error) {126 rep := &Report{Generated: time.Now(), RadiusKm: opt.RadiusKm}127 for _, src := range sources {128 if b.Terrain != nil || src.Name != terrainSource {129 rep.Sources = append(rep.Sources, src)130 }131 }132 if b.Terrain != nil {133 rep.Presets = reception.Presets134 }135136 b.progress("Locating ZIP %s", opt.ZIP)137 place, err := store.Load(ctx, b.Cache, "zip-"+opt.ZIP, 365*24*time.Hour, false, func(ctx context.Context) (geo.Place, error) {138 return geo.LookupZIP(ctx, b.HTTP, opt.ZIP)139 })140 if err != nil {141 return nil, nil, err142 }143 rep.Place, rep.Point = place, place.Point144 if opt.Point != nil {145 rep.Point = *opt.Point146 }147148 b.progress("Querying FCC for transmitters within %.0f km", opt.RadiusKm)149 facKey := fmt.Sprintf("fcc-%s-%.0f", coordKey(rep.Point), opt.RadiusKm)150 facilities, err := store.Load(ctx, b.Cache, facKey, 7*24*time.Hour, opt.Refresh, func(ctx context.Context) ([]fcc.Facility, error) {151 return fcc.Query(ctx, b.HTTP, rep.Point, opt.RadiusKm)152 })153 if err != nil {154 return nil, nil, err155 }156157 rep.Stations = b.predict(ctx, rep, facilities)158159 b.progress("Loading the ATSC 3.0 station list")160 hosts, err := store.Load(ctx, b.Cache, "atsc3-list", 3*24*time.Hour, opt.Refresh, func(ctx context.Context) ([]atsc3.Host, error) {161 return atsc3.Fetch(ctx, b.HTTP)162 })163 if err != nil {164 rep.Warnings = append(rep.Warnings, "ATSC 3.0 list unavailable: "+err.Error())165 }166 rep.ATSC3 = relevantHosts(hosts, rep.Stations)167168 var g *guide.Guide169 if b.Listings != nil {170 g = b.Listings()171 }172 if g != nil {173 b.progress("Listings from Schedules Direct, lineup %s", g.Lineup)174 rep.Sources = slices.DeleteFunc(rep.Sources, func(s Source) bool { return s.Name == gracenoteSource })175 rep.Sources = append(rep.Sources, Source{"Schedules Direct", "https://www.schedulesdirect.org/", "Over-the-air lineup and program guide (Gracenote data)"})176 } else {177 b.progress("Loading listings for lineup %s", guide.LineupID(opt.ZIP))178 guideKey := fmt.Sprintf("guide-%s-%dh", opt.ZIP, opt.GuideHours)179 g, err = store.Load(ctx, b.Cache, guideKey, 4*time.Hour, opt.Refresh, func(ctx context.Context) (*guide.Guide, error) {180 return guide.Fetch(ctx, b.HTTP, opt.ZIP, time.Now().Add(-30*time.Minute), opt.GuideHours)181 })182 if err != nil {183 rep.Warnings = append(rep.Warnings, "Listings unavailable: "+err.Error())184 g = nil185 }186 }187188 rep.Channels = buildChannels(rep, g)189 b.progress("Found %d channels from %d transmitters", len(rep.Channels), len(rep.Stations))190 return rep, g, nil191}192193const (194 terrainSource = "AWS Terrain Tiles"195 gracenoteSource = "Gracenote TV Listings"196)197198var sources = []Source{199 {"FCC TV Query", "https://www.fcc.gov/media/television/tv-query", "Licensed transmitters, power, height, location"},200 {terrainSource, "https://registry.opendata.aws/terrain-tiles/", "Terrain profiles for reception estimates"},201 {"RabbitEars.Info", "https://www.rabbitears.info/market.php?request=atsc3", "ATSC 3.0 (NextGen TV) hosting"},202 {gracenoteSource, "https://tvlistings.gracenote.com/", "Over-the-air lineup and program guide"},203 {"Zippopotam.us", "https://zippopotam.us/", "Location lookup"},204}205206func coordKey(p geo.Point) string {207 return strconv.FormatFloat(p.Lat, 'f', 4, 64) + "_" + strconv.FormatFloat(p.Lon, 'f', 4, 64)208}209210// predict fetches a terrain profile per tower site and evaluates every211// facility against each antenna preset. Without terrain it lists the212// facilities, nearest first.213func (b *Builder) predict(ctx context.Context, rep *Report, facilities []fcc.Facility) []Station {214 if b.Terrain == nil {215 stations := make([]Station, 0, len(facilities))216 for _, f := range facilities {217 stations = append(stations, Station{Facility: f, Band: fcc.Band(f.RFChannel), Signal: map[string]reception.Estimate{}})218 }219 slices.SortStableFunc(stations, func(a, b Station) int { return cmp.Compare(a.DistanceKm, b.DistanceKm) })220 return stations221 }222 b.progress("Sampling terrain to %d transmitters", len(facilities))223 var mu sync.Mutex224 profiles := make(map[geo.Point]terrain.Profile)225 var failed int226 var eg errgroup.Group227 eg.SetLimit(8)228 sites := make(map[geo.Point]bool)229 for _, f := range facilities {230 if sites[f.Point] {231 continue232 }233 sites[f.Point] = true234 eg.Go(func() error {235 prof, err := b.Terrain.Profile(ctx, rep.Point, f.Point)236 mu.Lock()237 defer mu.Unlock()238 if err != nil {239 failed++240 return nil241 }242 profiles[f.Point] = prof243 return nil244 })245 }246 _ = eg.Wait()247 if failed > 0 {248 rep.Warnings = append(rep.Warnings, fmt.Sprintf("Terrain unavailable for %d tower sites; their reception is unknown", failed))249 }250251 stations := make([]Station, 0, len(facilities))252 for _, f := range facilities {253 s := Station{Facility: f, Band: fcc.Band(f.RFChannel), Signal: make(map[string]reception.Estimate)}254 prof, ok := profiles[f.Point]255 for _, p := range reception.Presets {256 if ok {257 s.Signal[p.Name] = reception.Predict(reception.FromFacility(f), prof, p)258 } else {259 s.Signal[p.Name] = reception.Estimate{Preset: p.Name, Tier: reception.Unknown}260 }261 }262 stations = append(stations, s)263 }264 slices.SortFunc(stations, func(a, b Station) int {265 return cmp.Compare(b.Signal["rooftop"].NoiseMarginDB, a.Signal["rooftop"].NoiseMarginDB)266 })267 return stations268}269270// relevantHosts keeps ATSC 3.0 hosts that are among the nearby271// transmitters, and flags those stations.272func relevantHosts(hosts []atsc3.Host, stations []Station) []atsc3.Host {273 byID := make(map[int][]int)274 for i, s := range stations {275 byID[s.FacilityID] = append(byID[s.FacilityID], i)276 }277 var out []atsc3.Host278 for _, h := range hosts {279 idx, ok := byID[h.FacilityID]280 if !ok {281 continue282 }283 for _, i := range idx {284 stations[i].ATSC3 = true285 }286 out = append(out, h)287 }288 return out289}290291func buildChannels(rep *Report, g *guide.Guide) []Channel {292 byBase := make(map[string][]*Station)293 byID := make(map[int]*Station)294 for i := range rep.Stations {295 s := &rep.Stations[i]296 byBase[s.BaseCall] = append(byBase[s.BaseCall], s)297 // Stations are sorted best first, so keep the first per facility.298 if _, ok := byID[s.FacilityID]; !ok {299 byID[s.FacilityID] = s300 }301 }302303 type svcRef struct {304 host *atsc3.Host305 svc atsc3.Service306 }307 var services []svcRef308 for i := range rep.ATSC3 {309 for _, s := range rep.ATSC3[i].Services {310 services = append(services, svcRef{&rep.ATSC3[i], s})311 }312 }313 matched := make([]bool, len(services))314315 carriage := func(r svcRef) *Carriage {316 c := &Carriage{HostCall: r.host.CallSign, FacilityID: r.host.FacilityID, RF: r.host.RF, Display: r.svc.Display}317 if s := byID[r.host.FacilityID]; s != nil {318 c.Tier = tiers(s)319 s.Carries = append(s.Carries, fmt.Sprintf("%d.%d (3.0)", r.svc.Major, r.svc.Minor))320 }321 return c322 }323324 var channels []Channel325 if g != nil {326 for _, gc := range g.Channels {327 major, minor := guide.SplitNumber(gc.Number)328 ch := Channel{329 Number: gc.Number, Major: major, Minor: minor, GuideID: gc.ID,330 CallSign: gc.CallSign, BaseCall: guide.BaseCall(gc.CallSign),331 Network: gc.Network, Logo: gc.Logo,332 }333 txBase := ch.BaseCall334 for i, r := range services {335 if fcc.BaseCall(r.svc.Name) != ch.BaseCall || r.svc.Major != major {336 continue337 }338 // The 3.0 list names the 1.0 host for the main channel; the339 // station's other subchannels usually move with it.340 if r.svc.ATSC1Call != "" && fcc.BaseCall(r.svc.ATSC1Call) != ch.BaseCall {341 txBase = fcc.BaseCall(r.svc.ATSC1Call)342 ch.Via = r.svc.ATSC1Call343 }344 if r.svc.Minor == minor {345 ch.ATSC3 = carriage(r)346 matched[i] = true347 }348 }349 best := bestStation(byBase[txBase])350 if best == nil && txBase != ch.BaseCall {351 best = bestStation(byBase[ch.BaseCall])352 ch.Via = ""353 }354 if best != nil {355 ch.FacilityID = best.FacilityID356 ch.Tier = tiers(best)357 best.Carries = append(best.Carries, ch.Number)358 } else {359 ch.Tier = unknownTiers()360 }361 channels = append(channels, ch)362 }363 } else {364 channels = channelsFromStations(rep.Stations)365 }366367 for i, r := range services {368 if matched[i] {369 continue370 }371 c := carriage(r)372 ch := Channel{373 Number: fmt.Sprintf("%d.%d", r.svc.Major, r.svc.Minor), Major: r.svc.Major, Minor: r.svc.Minor,374 CallSign: r.svc.Name, BaseCall: fcc.BaseCall(r.svc.Name), Network: r.svc.Network,375 FacilityID: r.host.FacilityID, Tier: c.Tier, ATSC3: c, ATSC3Only: true,376 }377 if ch.Tier == nil {378 ch.Tier = unknownTiers()379 }380 channels = append(channels, ch)381 }382383 slices.SortStableFunc(channels, func(a, b Channel) int {384 if c := cmp.Compare(a.Major, b.Major); c != 0 {385 return c386 }387 if c := cmp.Compare(a.Minor, b.Minor); c != 0 {388 return c389 }390 return cmp.Compare(rank(b.Tier["rooftop"]), rank(a.Tier["rooftop"]))391 })392 return channels393}394395// channelsFromStations lists one main channel per station when listings are396// unavailable.397func channelsFromStations(stations []Station) []Channel {398 seen := make(map[string]bool)399 var out []Channel400 for i := range stations {401 s := &stations[i]402 if s.VirtualChannel == 0 || seen[s.BaseCall] {403 continue404 }405 seen[s.BaseCall] = true406 n := fmt.Sprintf("%d.1", s.VirtualChannel)407 s.Carries = append(s.Carries, n)408 out = append(out, Channel{409 Number: n, Major: s.VirtualChannel, Minor: 1, CallSign: s.CallSign, BaseCall: s.BaseCall,410 FacilityID: s.FacilityID, Tier: tiers(s),411 })412 }413 return out414}415416func bestStation(cands []*Station) *Station {417 var best *Station418 for _, s := range cands {419 if best == nil || s.Signal["rooftop"].NoiseMarginDB > best.Signal["rooftop"].NoiseMarginDB {420 best = s421 }422 }423 return best424}425426func tiers(s *Station) map[string]reception.Tier {427 m := make(map[string]reception.Tier, len(s.Signal))428 for k, e := range s.Signal {429 m[k] = e.Tier430 }431 return m432}433434func unknownTiers() map[string]reception.Tier {435 m := make(map[string]reception.Tier, len(reception.Presets))436 for _, p := range reception.Presets {437 m[p.Name] = reception.Unknown438 }439 return m440}441442var tierRank = map[reception.Tier]int{443 reception.Strong: 5, reception.Good: 4, reception.Fair: 3, reception.Weak: 2, reception.Unlikely: 1,444}445446func rank(t reception.Tier) int { return tierRank[t] }