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 / tuner.go
tuner.go 8.1K
1package lineup23import (4 "cmp"5 "fmt"6 "regexp"7 "slices"8 "strings"910 "airwaves/internal/fcc"11 "airwaves/internal/guide"12)1314// Scanned is a channel a tuner's scan found.15type Scanned struct {16 Number string // "20.1"17 // Name is the broadcast's own name for it (its PSIP short name, as18 // the HDHomeRun's lineup gives it): "KTVD-HD", "H & I".19 Name string20 // RF is the RF channel of the multiplex it came in on; 0 when unknown.21 RF int22}2324// TunerChannel is a channel the tuner receives, with what the FCC records,25// the listings and the ATSC 3.0 list say about it. Those only describe it:26// every TunerChannel is one the tuner found.27type TunerChannel struct {28 Number string `json:"number"`29 Major int `json:"major"`30 Minor int `json:"minor"`31 // Name is the broadcast's own name for the channel (its PSIP short32 // name): "KTVD-HD", "H & I", "StartTV".33 Name string `json:"name"`34 // CallSign is the station's call sign to show, "KWGN", the same for35 // all its subchannels: from the listings, else the station whose36 // virtual channel it is, else the broadcast's name. BaseCall is the37 // same call sign as a key ("" when the name isn't a call sign).38 CallSign string `json:"callSign"`39 BaseCall string `json:"baseCall"`40 // GuideID, GuideCallSign ("KWGNDT", "KCNCDT2"), Network ("CW",41 // "Start TV"), Logo and LogoDark (for a dark background) come from the42 // listings, when they have the channel.43 GuideID string `json:"guideId,omitempty"`44 GuideCallSign string `json:"guideCallSign,omitempty"`45 Network string `json:"network"`46 Logo string `json:"logo,omitempty"`47 LogoDark string `json:"logoDark,omitempty"`48 // RF is the RF channel the tuner receives it on; 0 when unknown.49 RF int `json:"rf"`50 // FacilityID and Transmitter are the licensed transmitter on RF that51 // the channel comes from: 0 and "" when none in the FCC's records52 // within the search radius is on RF.53 FacilityID int `json:"facilityId"`54 Transmitter string `json:"transmitter,omitempty"`55 // Via is the transmitter's call sign when it is another station's56 // (common after a station moves to ATSC 3.0 and another hosts its57 // ATSC 1.0 signal).58 Via string `json:"via,omitempty"`59 // NextGen is where the station also broadcasts this channel in ATSC60 // 3.0, a fact about the station; nil when it doesn't.61 NextGen *NextGen `json:"atsc3,omitempty"`62}6364// NextGen is a channel's ATSC 3.0 broadcast.65type NextGen struct {66 HostCall string `json:"hostCall"`67 FacilityID int `json:"facilityId"`68 RF string `json:"rf"`69 Display string `json:"display"` // "02-1"70}7172// nameBase is a broadcast name's call sign base, for names that are call73// signs: "KTVD-HD" and "KRMADT1" give "KTVD" and "KRMA"; "" for names that74// aren't ("H & I", "StartTV").75func nameBase(name string) string {76 b := guide.BaseCall(fcc.BaseCall(strings.TrimSpace(name)))77 if !callSign.MatchString(b) {78 return ""79 }80 return b81}8283// callSign matches a US broadcast call sign's base: "KWGN", "WGN", and a84// low-power station's "K14QW".85var callSign = regexp.MustCompile(`^(?:[KW][A-Z]{2,3}|[KW]\d{2}[A-Z]{2})$`)8687// Match describes the tuner's channels from rep's transmitters and ATSC 3.088// hosts and from the listings g (nil without them), in channel order.89func Match(rep *Report, g *guide.Guide, found []Scanned) []TunerChannel {90 // What each multiplex carries decides which transmitter it is: the91 // one on its RF channel whose virtual channel or call sign is there.92 majors := map[int]map[int]bool{}93 bases := map[int]map[string]bool{}94 note := func(rf int, major int, base string) {95 if majors[rf] == nil {96 majors[rf], bases[rf] = map[int]bool{}, map[string]bool{}97 }98 majors[rf][major] = true99 if base != "" {100 bases[rf][base] = true101 }102 }103 listed := map[string][]guide.Channel{}104 if g != nil {105 for _, gc := range g.Channels {106 listed[gc.Number] = append(listed[gc.Number], gc)107 }108 }109 for _, f := range found {110 major, _ := guide.SplitNumber(f.Number)111 note(f.RF, major, nameBase(f.Name))112 for _, gc := range listed[f.Number] {113 note(f.RF, major, guide.BaseCall(gc.CallSign))114 }115 }116 tx := map[int]*Station{}117 for rf := range majors {118 var best *Station119 score := func(s *Station) int {120 n := 0121 if majors[rf][s.VirtualChannel] {122 n += 2123 }124 if bases[rf][s.BaseCall] {125 n++126 }127 return n128 }129 for i := range rep.Stations {130 s := &rep.Stations[i]131 if rf == 0 || s.RFChannel != rf {132 continue133 }134 if best == nil || score(s) > score(best) || score(s) == score(best) && s.DistanceKm < best.DistanceKm {135 best = s136 }137 }138 tx[rf] = best139 }140 // A station by its virtual channel, for channels the listings lack.141 byVirtual := map[int]*Station{}142 for i := range rep.Stations {143 s := &rep.Stations[i]144 if o := byVirtual[s.VirtualChannel]; s.VirtualChannel > 0 && (o == nil || s.DistanceKm < o.DistanceKm) {145 byVirtual[s.VirtualChannel] = s146 }147 }148149 out := make([]TunerChannel, 0, len(found))150 for _, f := range found {151 major, minor := guide.SplitNumber(f.Number)152 ch := TunerChannel{Number: f.Number, Major: major, Minor: minor, Name: strings.TrimSpace(f.Name), RF: f.RF}153 t := tx[f.RF]154 if t != nil {155 ch.FacilityID, ch.Transmitter = t.FacilityID, t.CallSign156 }157 if gc, ok := pickListing(listed[f.Number], nameBase(f.Name), t); ok {158 ch.GuideID, ch.GuideCallSign, ch.BaseCall, ch.Network, ch.Logo = gc.ID, gc.CallSign, guide.BaseCall(gc.CallSign), gc.Network, gc.Logo159 ch.LogoDark = gc.LogoDark160 } else if t != nil && t.VirtualChannel == major {161 ch.BaseCall = t.BaseCall162 } else if s := byVirtual[major]; s != nil {163 ch.BaseCall = s.BaseCall164 } else {165 ch.BaseCall = nameBase(f.Name)166 }167 ch.CallSign = cmp.Or(ch.BaseCall, ch.Name)168 if t != nil && t.BaseCall != ch.BaseCall && t.VirtualChannel != major {169 ch.Via = t.CallSign170 }171 ch.NextGen = nextGen(rep, ch, t)172 out = append(out, ch)173 }174 slices.SortStableFunc(out, func(a, b TunerChannel) int {175 return cmp.Or(cmp.Compare(a.Major, b.Major), cmp.Compare(a.Minor, b.Minor))176 })177 return out178}179180// pickListing chooses among the listings' channels with one number: the181// one named like the broadcast or its transmitter, else the first.182func pickListing(cands []guide.Channel, base string, t *Station) (guide.Channel, bool) {183 if len(cands) == 0 {184 return guide.Channel{}, false185 }186 for _, gc := range cands {187 b := guide.BaseCall(gc.CallSign)188 if b == base || t != nil && b == t.BaseCall {189 return gc, true190 }191 }192 return cands[0], true193}194195// nextGen finds the channel among the ATSC 3.0 hosts' services: the same196// number, from the same station.197func nextGen(rep *Report, ch TunerChannel, t *Station) *NextGen {198 for _, h := range rep.ATSC3 {199 for _, svc := range h.Services {200 if svc.Major != ch.Major || svc.Minor != ch.Minor {201 continue202 }203 same := fcc.BaseCall(svc.Name) == ch.BaseCall ||204 t != nil && svc.ATSC1Call != "" && fcc.BaseCall(svc.ATSC1Call) == t.BaseCall205 if same {206 return &NextGen{HostCall: h.CallSign, FacilityID: h.FacilityID, RF: h.RF, Display: svc.Display}207 }208 }209 }210 return nil211}212213// ForTuner is rep for an app whose lineup is the tuner's: the same214// records, with no estimates (no presets, and no station's Signal), each215// station's Carries the channels the tuner receives from it and the ATSC216// 3.0 services it hosts, and no Channels (the caller has them).217func ForTuner(rep *Report, chans []TunerChannel) *Report {218 out := *rep219 out.Presets = nil220 out.Channels = []Channel{}221 carried := map[[2]int][]string{}222 for _, c := range chans {223 if c.FacilityID != 0 {224 k := [2]int{c.FacilityID, c.RF}225 carried[k] = append(carried[k], c.Number)226 }227 }228 hosted := map[int][]string{}229 for _, h := range rep.ATSC3 {230 for _, svc := range h.Services {231 hosted[h.FacilityID] = append(hosted[h.FacilityID], fmt.Sprintf("%d.%d (3.0)", svc.Major, svc.Minor))232 }233 }234 out.Stations = make([]Station, len(rep.Stations))235 for i, s := range rep.Stations {236 s.Signal = nil237 s.Carries = append(slices.Clone(carried[[2]int{s.FacilityID, s.RFChannel}]), hosted[s.FacilityID]...)238 if s.Carries == nil {239 s.Carries = []string{}240 }241 out.Stations[i] = s242 }243 slices.SortStableFunc(out.Stations, func(a, b Station) int { return cmp.Compare(a.DistanceKm, b.DistanceKm) })244 return &out245}