clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

Give Jellyfin and other XMLTV readers everything the guide knows

FelineStateMachine committed 2bfb8c6
One conversion for antenna and library channels: cast with roles, first
aired date or year, type words players file by (Movie, Series, Sports,
News, Children) ahead of genres, running time, an absolute picture for
antenna programs too, xmltv_ns, onscreen and dd_progid episode numbers
(made up for library programs, so each episode is its own), HD, repeats
with when they first aired, premieres, new, live, the rating with its
system, stars and a series ID.
commit
2bfb8c61c376ae5f166359c42abb93f088bf5325
parent
a2588d9a50700d8c33a35bbe8356f87f04d6af32
author
FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
kind
feat risk score 2.3 of 3, Jev's estimate
3 files changed, +328 -77
Minternal/service/hdhr.go296++++++++++++++++++++++++------
Minternal/service/service.go16+--
Minternal/service/service_test.go93+++++++++-
M internal/service/hdhr.go +236 -60
@@ -5,10 +5,12 @@ import (
55 "context"66 "encoding/xml"77 "fmt"8+ "hash/fnv"89 "io"910 "log"1011 "net/url"1112 "slices"13+ "strconv"1214 "strings"1315 "time"1416
@@ -128,32 +130,236 @@ type xmltvIcon struct {
128130 Src string `xml:"src,attr"`129131 }130132 133+// xmltvProgramme is one airing, its elements in the order XMLTV's DTD134+// has them, with series-id and live (newer than the DTD) last.131135 type xmltvProgramme struct {132- Start string `xml:"start,attr"`133- Stop string `xml:"stop,attr"`134- Channel string `xml:"channel,attr"`135- Title string `xml:"title"`136- SubTitle string `xml:"sub-title,omitempty"`137- Desc string `xml:"desc,omitempty"`138- Date string `xml:"date,omitempty"`139- Category []string `xml:"category,omitempty"`140- Episode string `xml:"episode-num,omitempty"`141- Icon *xmltvIcon `xml:"icon,omitempty"`142- New *struct{} `xml:"new,omitempty"`143- // Subtitles are "teletext" for closed captions, "onscreen" for144- // subtitles burned into the picture.145- Subtitles *xmltvSubtitles `xml:"subtitles,omitempty"`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+}158+159+// xmltvCredits are plain names: Jellyfin 10.11's reader fails a channel160+// whose credits hold anything else.161+type xmltvCredits struct {162+ Actors []xmltvActor `xml:"actor"`163+}164+165+type xmltvActor struct {166+ Role string `xml:"role,attr,omitempty"`167+ Name string `xml:",chardata"`168+}169+170+type xmltvLength struct {171+ Units string `xml:"units,attr"`172+ Value int `xml:",chardata"`173+}174+175+type xmltvSystem struct {176+ System string `xml:"system,attr"`177+ Value string `xml:",chardata"`178+}179+180+type xmltvVideo struct {181+ Quality string `xml:"quality"`182+}183+184+type xmltvPrevious struct {185+ Start string `xml:"start,attr,omitempty"`146186 }147187 148188 type xmltvSubtitles struct {149189 Type string `xml:"type,attr"`150190 }151191 192+type xmltvRating struct {193+ System string `xml:"system,attr,omitempty"`194+ Value string `xml:"value"`195+}196+152197 const xmltvTime = "20060102150405 -0700"153198 154-// XMLTV implements hdhr.Backend. A custom channel's call sign is one of155-// its display names, and its category is that of each of its programmes156-// that has none of its own ("Other" says nothing, so it's left out).199+// customProgram is a custom channel's program as the guide has it.200+func 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+}217+218+// typeWords are the guide's genres (its own filter keys, any case) that219+// xmltvCategories says with a type word.220+var typeWords = []string{"movie", "movies", "sports", "sports event", "news", "kids", "children"}221+222+// 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.225+func 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+}258+259+// 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).263+func 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+}358+359+// 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.157363 func (b hdhrBackend) XMLTV(ctx context.Context, w io.Writer, abs func(path string) string) error {158364 entries, err := b.Lineup(ctx)159365 if err != nil {
@@ -167,8 +373,6 @@ func (b hdhrBackend) XMLTV(ctx context.Context, w io.Writer, abs func(path strin
167373 for _, v := range b.s.customChannels() {168374 custom[v.Number()] = v169375 }170- // As far as the listings go: two days of Gracenote's grid, two weeks171- // from Schedules Direct.172376 from, to := time.Now().Add(-time.Hour), time.Now().Add(sd.Days*24*time.Hour)173377 doc := xmltvDoc{Generator: "Airwaves"}174378 for _, e := range entries {
@@ -181,6 +385,7 @@ func (b hdhrBackend) XMLTV(ctx context.Context, w io.Writer, abs func(path strin
181385 names = append(names, d.CallSign)182386 }183387 }388+ // The number last: Jellyfin matches channels by it.184389 ch := xmltvChannel{ID: id, Names: append(names, e.Number), LCN: e.Number}185390 if e.Logo != "" {186391 src := e.Logo
@@ -193,37 +398,21 @@ func (b hdhrBackend) XMLTV(ctx context.Context, w io.Writer, abs func(path strin
193398 194399 if v != nil {195400 for _, p := range v.Programs(from, to) {196- x := xmltvProgramme{197- Start: p.Start.Format(xmltvTime), Stop: p.End.Format(xmltvTime), Channel: id,198- Title: p.Title, SubTitle: p.Subtitle, Desc: p.Description, Date: p.Date,199- }200- if p.Category != "" {201- x.Category = []string{p.Category}202- } else if d.Category != "" && d.Category != "Other" {203- x.Category = []string{d.Category}204- }205- if p.Season != "" && p.Episode != "" {206- x.Episode = fmt.Sprintf("S%sE%s", p.Season, p.Episode)207- }208- if p.Image != "" {209- src := p.Image210- if strings.HasPrefix(src, "/") {211- src = abs(src)212- }213- x.Icon = &xmltvIcon{Src: src}214- }215- if p.New {216- x.New = &struct{}{}217- }401+ // Captions for sound not in English are burned in for402+ // HDHomeRun clients, which can't be told to show them.403+ captions := ""218404 if p.Captions {219- // Subtitles for sound not in English are burned in for220- // HDHomeRun clients, which can't be told to show captions.221- x.Subtitles = &xmltvSubtitles{Type: "teletext"}405+ captions = "teletext"222406 if cc.Foreign(p.AudioLang) {223- x.Subtitles.Type = "onscreen"407+ captions = "onscreen"224408 }225409 }226- doc.Programmes = append(doc.Programmes, x)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))227416 }228417 continue229418 }
@@ -235,20 +424,7 @@ func (b hdhrBackend) XMLTV(ctx context.Context, w io.Writer, abs func(path strin
235424 if p.End.Before(from) || p.Start.After(to) {236425 continue237426 }238- x := xmltvProgramme{239- Start: p.Start.Format(xmltvTime), Stop: p.End.Format(xmltvTime), Channel: id,240- Title: p.Title, SubTitle: p.EpisodeTitle, Desc: p.Description, Category: p.Genres,241- }242- if p.Season != "" && p.Episode != "" {243- x.Episode = fmt.Sprintf("S%sE%s", p.Season, p.Episode)244- }245- if p.Image != "" {246- x.Icon = &xmltvIcon{Src: p.Image}247- }248- if slices.Contains(p.Flags, "New") {249- x.New = &struct{}{}250- }251- doc.Programmes = append(doc.Programmes, x)427+ doc.Programmes = append(doc.Programmes, xmltvProgram(p, id, abs, ""))252428 }253429 }254430 if _, err := io.WriteString(w, xml.Header); err != nil {
M internal/service/service.go +1 -15
M internal/service/service_test.go +91 -2
@@ -14,6 +14,7 @@ import (
1414 "testing"1515 "time"1616 17+ "airwaves/internal/guide"1718 "airwaves/internal/hdhr"1819 "airwaves/internal/jellyfin/jellyfintest"1920 "airwaves/internal/vchan"
@@ -109,7 +110,14 @@ type xmltvOut struct {
109110 Programmes []struct {110111 Channel string `xml:"channel,attr"`111112 Title string `xml:"title"`113+ SubTitle string `xml:"sub-title"`112114 Category []string `xml:"category"`115+ Date string `xml:"date"`116+ Episodes []struct {117+ System string `xml:"system,attr"`118+ Value string `xml:",chardata"`119+ } `xml:"episode-num"`120+ Previously *struct{} `xml:"previously-shown"`113121 } `xml:"programme"`114122 }115123
@@ -198,14 +206,32 @@ func TestHDHomeRunCustomChannels(t *testing.T) {
198206 }199207 }200208 kinds := map[string]bool{}209+ ids := map[string]string{} // dd_progid: episode201210 for _, p := range doc.Programmes {211+ eps := map[string]string{}212+ for _, e := range p.Episodes {213+ eps[e.System] = e.Value214+ }202215 switch {203216 case p.Channel == "airwaves.1.1" && slices.Equal(p.Category, []string{"Weather"}):204- case p.Channel == "airwaves.1.4" && p.Title == "The Iron Giant" && slices.Equal(p.Category, []string{"Movie"}):205- case p.Channel == "airwaves.1.4" && p.Title == "Futurama" && slices.Equal(p.Category, []string{"Kids"}):217+ case p.Channel == "airwaves.1.4" && p.Title == "The Iron Giant" && slices.Equal(p.Category, []string{"Movie"}) && p.Date == "1999":218+ case p.Channel == "airwaves.1.4" && p.Title == "Futurama" && slices.Equal(p.Category, []string{"Series", "Children"}):219+ want := map[string][2]string{"Space Pilot 3000": {"0.0.", "S01E01"}, "The Series Has Landed": {"0.1.", "S01E02"}}[p.SubTitle]220+ if eps["xmltv_ns"] != want[0] || eps["onscreen"] != want[1] {221+ t.Errorf("%s episode-num: %v", p.SubTitle, eps)222+ }206223 default:207224 t.Errorf("programme: %+v", p)208225 }226+ // Players tell programs apart, and repeats, by dd_progid.227+ if id := eps["dd_progid"]; id == "" || ids[id] != "" && ids[id] != p.SubTitle+p.Title {228+ t.Errorf("%s %s dd_progid %q, also %q's", p.Title, p.SubTitle, id, ids[id])229+ } else {230+ ids[id] = p.SubTitle + p.Title231+ }232+ if p.Previously == nil {233+ t.Errorf("%s %s: not a repeat", p.Title, p.SubTitle)234+ }209235 kinds[p.Channel+" "+p.Title] = true210236 }211237 if len(kinds) != 3 {
@@ -219,3 +245,66 @@ func TestHDHomeRunCustomChannels(t *testing.T) {
219245 t.Errorf("lineup with both off: %s", raw)220246 }221247 }248+249+func TestXMLTVProgram(t *testing.T) {250+ start := time.Date(2026, 10, 12, 19, 0, 0, 0, time.FixedZone("MDT", -6*3600))251+ abs := func(p string) string { return "http://nas:8089" + p }252+ for _, c := range []struct {253+ name string254+ p guide.Program255+ want []string256+ not []string257+ }{{258+ name: "episode",259+ p: guide.Program{Start: start, End: start.Add(30 * time.Minute), Title: "Bluey", EpisodeTitle: "Keepy Uppy",260+ Season: "1", Episode: "4", Rating: "TV-Y", Flags: []string{"New"}, Tags: []string{"CC", "HD"}, Genres: []string{"kids", "Animated"},261+ ProgramID: "EP012345670004", SeriesID: "SH01234567", Image: "/guide-art/a.jpg", OriginalAirDate: "2026-10-12",262+ Cast: []string{"Dave McCormack (Bandit)", "Melanie Zanetti"}},263+ want: []string{264+ ` start="20261012190000 -0600" stop="20261012193000 -0600" channel="airwaves.9.1">`,265+ `<credits><actor role="Bandit">Dave McCormack</actor><actor>Melanie Zanetti</actor></credits>`,266+ `<date>20261012</date>`,267+ `<category>Series</category><category>Children</category><category>Animated</category>`,268+ `<icon src="http://nas:8089/guide-art/a.jpg"></icon>`,269+ `<episode-num system="xmltv_ns">0.3.</episode-num><episode-num system="onscreen">S01E04</episode-num><episode-num system="dd_progid">EP01234567.0004</episode-num>`,270+ `<video><quality>HDTV</quality></video><new></new><subtitles type="teletext"></subtitles>`,271+ `<rating system="VCHIP"><value>TV-Y</value></rating>`,272+ `<series-id system="airwaves">SH01234567</series-id>`,273+ },274+ not: []string{"previously-shown", "<live>", "kids"},275+ }, {276+ name: "repeat",277+ p: guide.Program{Start: start, End: start.Add(time.Hour), Title: "Frasier", EpisodeTitle: "The Good Son",278+ ProgramID: "EP000001230001", OriginalAirDate: "1993-09-16"},279+ want: []string{`<previously-shown start="19930916"></previously-shown>`, `<date>19930916</date>`},280+ not: []string{"<new>"},281+ }, {282+ name: "movie",283+ p: guide.Program{Start: start, End: start.Add(2 * time.Hour), Title: "Heat", Year: "1995", Rating: "R", Runtime: 170, Stars: 3.5,284+ ProgramID: "MV000123450000", Genres: []string{"movie", "Crime drama"}, OriginalAirDate: "2001-01-01"},285+ want: []string{`<date>1995</date><category>Movie</category><category>Crime drama</category><length units="minutes">170</length>`,286+ `<previously-shown start="20010101"></previously-shown>`, `<rating system="MPAA"><value>R</value></rating><star-rating><value>3.5/4</value></star-rating>`},287+ not: []string{"Series", "series-id"},288+ }, {289+ name: "game",290+ p: guide.Program{Start: start, End: start.Add(3 * time.Hour), Title: "NFL Football", ProgramID: "SP000456780000",291+ Flags: []string{"Live"}, Genres: []string{"sports", "Football"}},292+ want: []string{`<category>Sports event</category><category>Sports</category><category>Football</category>`, `<live></live>`},293+ not: []string{"previously-shown", "<new>"},294+ }} {295+ raw, err := xml.Marshal(xmltvProgram(c.p, "airwaves.9.1", abs, ""))296+ if err != nil {297+ t.Fatal(err)298+ }299+ for _, w := range c.want {300+ if !strings.Contains(string(raw), w) {301+ t.Errorf("%s: no %s in\n%s", c.name, w, raw)302+ }303+ }304+ for _, n := range c.not {305+ if strings.Contains(string(raw), n) {306+ t.Errorf("%s: %s in\n%s", c.name, n, raw)307+ }308+ }309+ }310+}