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 / service / hdhr.go
hdhr.go 14.5K
1package service23import (4 "cmp"5 "context"6 "encoding/xml"7 "fmt"8 "hash/fnv"9 "io"10 "log"11 "net/url"12 "slices"13 "strconv"14 "strings"15 "time"1617 "airwaves/internal/cc"18 "airwaves/internal/geo"19 "airwaves/internal/guide"20 "airwaves/internal/hdhr"21 "airwaves/internal/lineup"22 "airwaves/internal/sd"23 "airwaves/internal/tuner"24 "airwaves/internal/vchan"25)2627// HDHR adapts the service to the emulated HDHomeRun: the tuner's channels,28// as the app has them, plus the custom channels.29func (s *Service) HDHR() hdhr.Backend { return hdhrBackend{s} }3031// TunerCount is what the emulated HDHomeRun reports: the server's real32// tuners (two until they're found). Custom channels need no tuner.33func (s *Service) TunerCount() int {34 if s.tuners == nil {35 return 236 }37 return cmp.Or(s.tuners.Count(), 2)38}3940type hdhrBackend struct{ s *Service }4142// antenna is the tuner's channels described from the records, by number,43// and the listings.44func (b hdhrBackend) antenna(ctx context.Context) (map[string]lineup.TunerChannel, *guide.Guide, error) {45 b.s.mu.Lock()46 rep, g := b.s.report, b.s.guide47 b.s.mu.Unlock()48 chans, _, err := b.s.matched(ctx, rep, g)49 if err != nil {50 return nil, nil, err51 }52 out := make(map[string]lineup.TunerChannel, len(chans))53 for _, c := range chans {54 out[c.Number] = c55 }56 return out, g, nil57}5859// Lineup implements hdhr.Backend. Custom channels keep the group60// "Airwaves" rather than their categories, so players list them together,61// apart from the antenna's; the categories are in the guide.62func (b hdhrBackend) Lineup(ctx context.Context) ([]hdhr.Entry, error) {63 var out []hdhr.Entry64 custom := map[string]bool{}65 for _, v := range b.s.customChannels() {66 d := v.Details()67 custom[vchan.NumberKey(d.Number)] = true68 out = append(out, hdhr.Entry{Number: d.Number, Name: d.Name, HD: true, Group: "Airwaves", Logo: logoPath(d)})69 }70 chans, _, err := b.antenna(ctx)71 if err != nil {72 return nil, err73 }74 for _, c := range chans {75 if custom[vchan.NumberKey(c.Number)] {76 continue77 }78 // Players match guide data by the listings' call signs, one per79 // subchannel ("KTVDDT2"); without listings, the broadcast's name.80 out = append(out, hdhr.Entry{Number: c.Number, Name: cmp.Or(c.GuideCallSign, c.Name), HD: true, Group: "Antenna", Logo: c.Logo})81 }82 slices.SortStableFunc(out, func(a, b hdhr.Entry) int { return guide.CompareNumbers(a.Number, b.Number) })83 return out, nil84}8586// Stream implements hdhr.Backend: a custom channel, or an antenna channel87// passed through as broadcast, on a tuner shared like the app's.88func (b hdhrBackend) Stream(ctx context.Context, number string, w io.Writer) error {89 if v := b.s.customChannel(number); v != nil {90 return v.Stream(ctx, w)91 }92 if b.s.tuners == nil {93 return fmt.Errorf("no channel %s: %w", number, ErrNoAntenna)94 }95 freq, program, err := b.s.tuning(ctx, number)96 if err != nil {97 return err98 }99 sub, err := b.s.tuners.Open(ctx, tuner.Request{FrequencyHz: freq, Program: program, Priority: tuner.Watching, Who: number + " for an HDHomeRun app"})100 if err != nil {101 return err102 }103 defer sub.Close()104 wait, cancel := context.WithTimeout(ctx, b.s.timing.noLock)105 _, err = sub.Wait(wait)106 cancel()107 if err != nil {108 return err109 }110 log.Printf("hdhr: streaming %s", number)111 defer log.Printf("hdhr: stopped streaming %s", number)112 return sub.Copy(ctx, w)113}114115type xmltvDoc struct {116 XMLName xml.Name `xml:"tv"`117 Generator string `xml:"generator-info-name,attr"`118 Channels []xmltvChannel `xml:"channel"`119 Programmes []xmltvProgramme `xml:"programme"`120}121122type xmltvChannel struct {123 ID string `xml:"id,attr"`124 Names []string `xml:"display-name"`125 Icon *xmltvIcon `xml:"icon,omitempty"`126 LCN string `xml:"lcn,omitempty"`127}128129type xmltvIcon struct {130 Src string `xml:"src,attr"`131}132133// xmltvProgramme is one airing, its elements in the order XMLTV's DTD134// has them, with series-id and live (newer than the DTD) last.135type xmltvProgramme struct {136 Start string `xml:"start,attr"`137 Stop string `xml:"stop,attr"`138 Channel string `xml:"channel,attr"`139 Title string `xml:"title"`140 SubTitle string `xml:"sub-title,omitempty"`141 Desc string `xml:"desc,omitempty"`142 Credits *xmltvCredits `xml:"credits,omitempty"`143 Date string `xml:"date,omitempty"`144 Category []string `xml:"category,omitempty"`145 Length *xmltvLength `xml:"length,omitempty"`146 Icon *xmltvIcon `xml:"icon,omitempty"`147 Episodes []xmltvSystem `xml:"episode-num,omitempty"`148 Video *xmltvVideo `xml:"video,omitempty"`149 Previously *xmltvPrevious `xml:"previously-shown,omitempty"`150 Premiere *struct{} `xml:"premiere,omitempty"`151 New *struct{} `xml:"new,omitempty"`152 Subtitles *xmltvSubtitles `xml:"subtitles,omitempty"`153 Rating *xmltvRating `xml:"rating,omitempty"`154 Stars *xmltvRating `xml:"star-rating,omitempty"`155 SeriesID *xmltvSystem `xml:"series-id,omitempty"`156 Live *struct{} `xml:"live,omitempty"`157}158159// xmltvCredits are plain names: Jellyfin 10.11's reader fails a channel160// whose credits hold anything else.161type xmltvCredits struct {162 Actors []xmltvActor `xml:"actor"`163}164165type xmltvActor struct {166 Role string `xml:"role,attr,omitempty"`167 Name string `xml:",chardata"`168}169170type xmltvLength struct {171 Units string `xml:"units,attr"`172 Value int `xml:",chardata"`173}174175type xmltvSystem struct {176 System string `xml:"system,attr"`177 Value string `xml:",chardata"`178}179180type xmltvVideo struct {181 Quality string `xml:"quality"`182}183184type xmltvPrevious struct {185 Start string `xml:"start,attr,omitempty"`186}187188type xmltvSubtitles struct {189 Type string `xml:"type,attr"`190}191192type xmltvRating struct {193 System string `xml:"system,attr,omitempty"`194 Value string `xml:"value"`195}196197const xmltvTime = "20060102150405 -0700"198199// customProgram is a custom channel's program as the guide has it.200func customProgram(p vchan.Program) guide.Program {201 gp := guide.Program{202 Start: p.Start, End: p.End, Title: p.Title, EpisodeTitle: p.Subtitle, Description: p.Description,203 Season: p.Season, Episode: p.Episode, Image: p.Image, AudioLang: p.AudioLang,204 OriginalAirDate: p.Aired, Cast: p.Cast, Rating: p.Rating, Genres: slices.Clone(p.Genres),205 }206 if len(p.Date) >= 4 {207 gp.Year = p.Date[:4]208 }209 if p.New {210 gp.Flags = []string{"New"}211 }212 if p.Captions {213 gp.Tags = []string{"CC"}214 }215 return gp216}217218// typeWords are the guide's genres (its own filter keys, any case) that219// xmltvCategories says with a type word.220var typeWords = []string{"movie", "movies", "sports", "sports event", "news", "kids", "children"}221222// xmltvCategories leads with the words players file by (Jellyfin's223// defaults, and Channels DVR's): Movie, Series, Sports event and Sports,224// News, Children; then the program's own genres.225func xmltvCategories(p guide.Program) []string {226 has := func(g string) bool {227 return slices.ContainsFunc(p.Genres, func(x string) bool { return strings.EqualFold(x, g) })228 }229 movie := has("movie") || has("movies") || strings.HasPrefix(p.ProgramID, "MV")230 var out []string231 if movie {232 out = append(out, "Movie")233 } else if p.Season != "" || p.EpisodeTitle != "" || strings.HasPrefix(p.ProgramID, "EP") {234 out = append(out, "Series")235 }236 if has("sports") || has("sports event") || strings.HasPrefix(p.ProgramID, "SP") {237 out = append(out, "Sports event", "Sports")238 }239 if has("news") {240 out = append(out, "News")241 }242 if has("kids") || has("children") {243 out = append(out, "Children")244 }245 for _, g := range p.Genres {246 if g == "" || slices.Contains(typeWords, strings.ToLower(g)) {247 continue248 }249 if g == strings.ToLower(g) {250 g = strings.ToUpper(g[:1]) + g[1:] // a filter key, as a genre251 }252 if !slices.ContainsFunc(out, func(x string) bool { return strings.EqualFold(x, g) }) {253 out = append(out, g)254 }255 }256 return out257}258259// xmltvProgram is one airing for the XMLTV guide, with all that players260// take from it; abs makes a path on this server an address, and261// captions says how a custom channel's captions come ("" for an antenna262// channel's, from its tags).263func xmltvProgram(p guide.Program, channel string, abs func(string) string, captions string) xmltvProgramme {264 x := xmltvProgramme{265 Start: p.Start.Format(xmltvTime), Stop: p.End.Format(xmltvTime), Channel: channel,266 Title: p.Title, SubTitle: p.EpisodeTitle, Desc: p.Description, Category: xmltvCategories(p),267 }268 if len(p.Cast) > 0 {269 x.Credits = &xmltvCredits{}270 for _, c := range p.Cast {271 name, role := c, ""272 if i := strings.LastIndex(c, " ("); i > 0 && strings.HasSuffix(c, ")") {273 name, role = c[:i], c[i+2:len(c)-1]274 }275 x.Credits.Actors = append(x.Credits.Actors, xmltvActor{Name: name, Role: role})276 }277 }278 movie := slices.Contains(x.Category, "Movie")279 switch {280 case p.OriginalAirDate != "" && !movie:281 x.Date = strings.ReplaceAll(p.OriginalAirDate, "-", "")282 case p.Year != "":283 x.Date = p.Year284 }285 if p.Runtime > 0 {286 x.Length = &xmltvLength{Units: "minutes", Value: p.Runtime}287 }288 if src := p.Image; src != "" {289 if strings.HasPrefix(src, "/") && abs != nil {290 src = abs(src)291 }292 x.Icon = &xmltvIcon{Src: src}293 }294 if p.Season != "" && p.Episode != "" {295 s, _ := strconv.Atoi(p.Season)296 e, _ := strconv.Atoi(p.Episode)297 if s > 0 && e > 0 {298 x.Episodes = append(x.Episodes,299 xmltvSystem{System: "xmltv_ns", Value: fmt.Sprintf("%d.%d.", s-1, e-1)},300 xmltvSystem{System: "onscreen", Value: fmt.Sprintf("S%02dE%02d", s, e)})301 }302 }303 // One ID per program, so players tell episodes apart (and an airing304 // of the same one twice is a repeat): Gracenote's, or one made from305 // what the program is.306 if id := p.ProgramID; len(id) == 14 {307 x.Episodes = append(x.Episodes, xmltvSystem{System: "dd_progid", Value: id[:10] + "." + id[10:]})308 } else {309 h := fnv.New64a()310 fmt.Fprintf(h, "%s\x00%s\x00%s\x00%s", p.Title, p.EpisodeTitle, p.Season, p.Episode)311 x.Episodes = append(x.Episodes, xmltvSystem{System: "dd_progid", Value: fmt.Sprintf("AW%08x.%04x", h.Sum64()>>32, h.Sum64()&0xffff)})312 }313 if slices.Contains(p.Tags, "HD") {314 x.Video = &xmltvVideo{Quality: "HDTV"}315 }316 isNew, live := slices.Contains(p.Flags, "New"), slices.Contains(p.Flags, "Live")317 if !isNew && !live {318 // A repeat, with when it first aired when that's another day.319 x.Previously = &xmltvPrevious{}320 if d := strings.ReplaceAll(p.OriginalAirDate, "-", ""); d != "" && d != p.Start.Format("20060102") {321 x.Previously.Start = d322 }323 }324 if slices.Contains(p.Flags, "Premiere") {325 x.Premiere = &struct{}{}326 }327 if isNew {328 x.New = &struct{}{}329 }330 switch {331 case captions != "":332 x.Subtitles = &xmltvSubtitles{Type: captions}333 case slices.Contains(p.Tags, "CC"):334 x.Subtitles = &xmltvSubtitles{Type: "teletext"}335 }336 if p.Rating != "" {337 sys := "MPAA" // a film's, as TV's start "TV-"338 if strings.HasPrefix(p.Rating, "TV-") {339 sys = "VCHIP"340 }341 x.Rating = &xmltvRating{System: sys, Value: p.Rating}342 }343 if p.Stars > 0 {344 x.Stars = &xmltvRating{Value: strconv.FormatFloat(p.Stars, 'f', -1, 64) + "/4"}345 }346 if p.SeriesID != "" {347 x.SeriesID = &xmltvSystem{System: "airwaves", Value: p.SeriesID}348 } else if !movie && (p.EpisodeTitle != "" || p.Season != "") {349 h := fnv.New32a()350 h.Write([]byte(p.Title))351 x.SeriesID = &xmltvSystem{System: "airwaves", Value: fmt.Sprintf("AW%08x", h.Sum32())}352 }353 if live {354 x.Live = &struct{}{}355 }356 return x357}358359// XMLTV implements hdhr.Backend: every channel, with its programs as far360// as the listings go (two days of Gracenote's grid, two weeks from361// Schedules Direct), each with all that players take from it. A custom362// channel's call sign is one of its display names.363func (b hdhrBackend) XMLTV(ctx context.Context, w io.Writer, abs func(path string) string) error {364 entries, err := b.Lineup(ctx)365 if err != nil {366 return err367 }368 antenna, g, err := b.antenna(ctx)369 if err != nil {370 return err371 }372 custom := map[string]vchan.Channel{}373 for _, v := range b.s.customChannels() {374 custom[v.Number()] = v375 }376 from, to := time.Now().Add(-time.Hour), time.Now().Add(sd.Days*24*time.Hour)377 doc := xmltvDoc{Generator: "Airwaves"}378 for _, e := range entries {379 id := hdhr.ChannelID(e.Number)380 v := custom[e.Number]381 var d vchan.Details382 names := []string{e.Number + " " + e.Name, e.Name}383 if v != nil {384 if d = v.Details(); d.CallSign != "" && d.CallSign != e.Name {385 names = append(names, d.CallSign)386 }387 }388 // The number last: Jellyfin matches channels by it.389 ch := xmltvChannel{ID: id, Names: append(names, e.Number), LCN: e.Number}390 if e.Logo != "" {391 src := e.Logo392 if strings.HasPrefix(src, "/") && abs != nil {393 src = abs(src)394 }395 ch.Icon = &xmltvIcon{Src: src}396 }397 doc.Channels = append(doc.Channels, ch)398399 if v != nil {400 for _, p := range v.Programs(from, to) {401 // Captions for sound not in English are burned in for402 // HDHomeRun clients, which can't be told to show them.403 captions := ""404 if p.Captions {405 captions = "teletext"406 if cc.Foreign(p.AudioLang) {407 captions = "onscreen"408 }409 }410 // Filed under the program's category, else the channel's.411 gp := customProgram(p)412 if c := cmp.Or(p.Category, d.Category); c != "" && c != "Other" {413 gp.Genres = append([]string{c}, gp.Genres...)414 }415 doc.Programmes = append(doc.Programmes, xmltvProgram(gp, id, abs, captions))416 }417 continue418 }419 ac, ok := antenna[e.Number]420 if !ok || ac.GuideID == "" || g == nil {421 continue422 }423 for _, p := range g.Programs[ac.GuideID] {424 if p.End.Before(from) || p.Start.After(to) {425 continue426 }427 doc.Programmes = append(doc.Programmes, xmltvProgram(p, id, abs, ""))428 }429 }430 if _, err := io.WriteString(w, xml.Header); err != nil {431 return err432 }433 enc := xml.NewEncoder(w)434 enc.Indent("", " ")435 return enc.Encode(doc)436}437438// weatherStarRev versions the branded WeatherStar display (see439// deploy/weatherstar).440const weatherStarRev = "3"441442// weatherStarURL is the WeatherStar 4000+ kiosk URL for the server's443// location, on the display at base ("http://host:port").444func (s *Service) weatherStarURL(ctx context.Context, base string, music bool) (string, error) {445 snap, err := s.snapshot(ctx, false)446 if err != nil {447 return "", err448 }449 p := snap.Report.Point450 if p == (geo.Point{}) {451 return "", ErrNoLocation452 }453 q := []string{454 "latLon=" + url.QueryEscape(fmt.Sprintf(`{"lat":%.4f,"lon":%.4f}`, p.Lat, p.Lon)),455 "kiosk=true", "wide=true",456 // Changes when the branded display changes, so web views that457 // cached an older page load the new one.458 "airwaves=" + weatherStarRev,459 }460 // The display remembers its music setting in the browser, so say "off"461 // outright rather than leaving it out.462 if music {463 q = append(q, "mediaPlaying=true", "mediaVolume=0.6")464 } else {465 q = append(q, "mediaPlaying=false")466 }467 return base + "/?" + strings.Join(q, "&"), nil468}