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 / service_test.go
service_test.go 12.4K
1package service23import (4 "encoding/json"5 "encoding/xml"6 "fmt"7 "io"8 "net/http"9 "net/http/httptest"10 "os"11 "path/filepath"12 "slices"13 "strings"14 "testing"15 "time"1617 "airwaves/internal/guide"18 "airwaves/internal/hdhr"19 "airwaves/internal/jellyfin/jellyfintest"20 "airwaves/internal/vchan"21)2223func writeFile(t *testing.T, path, body string) {24 t.Helper()25 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {26 t.Fatal(err)27 }28 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {29 t.Fatal(err)30 }31}3233// newCustomService is a service with a weather channel and a channels34// folder of Jellyfin channels, playing from a fake Jellyfin server:35// 1.4 Cartoons (TOON, Kids, with a logo) and 1.5 Off (turned off).36func newCustomService(t *testing.T) (*Service, string) {37 t.Helper()38 jf := jellyfintest.New(t, "viewer", "pw",39 jellyfintest.Item{ID: "lib", Type: "CollectionFolder", Name: "Shows"},40 jellyfintest.Item{ID: "fut", Type: "Series", Name: "Futurama", Parent: "lib", Year: 1999},41 jellyfintest.Item{ID: "fut-1-1", Type: "Episode", Name: "Space Pilot 3000", Parent: "fut", Season: 1, Episode: 1, Runtime: 22 * time.Minute},42 jellyfintest.Item{ID: "fut-1-2", Type: "Episode", Name: "The Series Has Landed", Parent: "fut", Season: 1, Episode: 2, Runtime: 22 * time.Minute},43 jellyfintest.Item{ID: "giant", Type: "Movie", Name: "The Iron Giant", Year: 1999, Runtime: 86 * time.Minute},44 )45 root := t.TempDir()46 writeFile(t, filepath.Join(root, ".jellyfin.json"), fmt.Sprintf(`{"server": %q, "user": "viewer", "password": "pw"}`, jf.URL))47 writeFile(t, filepath.Join(root, "1.4 Cartoons", "jellyfin.json"), `{"series": ["Futurama"], "movies": ["The Iron Giant"], "order": "aired"}`)48 writeFile(t, filepath.Join(root, "1.4 Cartoons", vchan.DetailsFile), `{"callSign": "TOON", "category": "Kids", "description": "Saturday mornings."}`)49 writeFile(t, filepath.Join(root, "1.4 Cartoons", "logo.png"), "\x89PNG\r\n\x1a\nlogo")50 writeFile(t, filepath.Join(root, "1.5 Off", "jellyfin.json"), `{"series": ["Futurama"]}`)51 writeFile(t, filepath.Join(root, "1.5 Off", vchan.DetailsFile), `{"enabled": false}`)5253 s := &Service{opt: Options{WeatherStarPort: 8080, ChannelsDir: root}, cfg: Config{ZIP: "80302"}.normalized()}54 s.wx = &vchan.Weather{Record: filepath.Join(root, vchan.WeatherFile)}55 s.wxStreams = true56 s.library = &vchan.Library{Root: root, FFmpeg: "ffmpeg", Reserved: s.reserved}57 return s, root58}5960func TestSnapshotCustomChannels(t *testing.T) {61 s, root := newCustomService(t)62 custom := s.custom()63 var got []string64 for _, c := range custom {65 got = append(got, c.Number+" "+c.Kind)66 }67 if !slices.Equal(got, []string{"1.1 weather", "1.4 jellyfin"}) {68 t.Fatalf("custom = %v", got)69 }70 wx, toon := custom[0], custom[1]71 if wx.Name != "Airwaves Weather" || wx.CallSign != "WX" || wx.Category != "Weather" || wx.Description == "" || wx.Logo != "" ||72 wx.Programs == nil || len(wx.Programs) != 0 {73 t.Errorf("weather: %+v", wx)74 }75 if toon.Name != "Cartoons" || toon.CallSign != "TOON" || toon.Category != "Kids" || toon.Description != "Saturday mornings." ||76 !strings.HasPrefix(toon.Logo, "/channel-logos/1.4?v=") || len(toon.Programs) == 0 || toon.Programs[0].Title == "" {77 t.Errorf("cartoons: %+v", toon)78 }79 raw, _ := json.Marshal(wx)80 if !strings.Contains(string(raw), `"kind":"weather"`) || !strings.Contains(string(raw), `"programs":[]`) {81 t.Errorf("weather JSON: %s", raw)82 }8384 // The weather channel's record moves it and can turn it off.85 writeFile(t, filepath.Join(root, vchan.WeatherFile), `{"number": "1.9", "callSign": "WTHR"}`)86 if c := s.custom(); len(c) != 2 || c[1].Number != "1.9" || c[1].CallSign != "WTHR" || c[1].Name != "Airwaves Weather" {87 t.Errorf("moved: %+v", c)88 }89 writeFile(t, filepath.Join(root, vchan.WeatherFile), `{"number": "1.9", "enabled": false}`)90 if c := s.custom(); len(c) != 1 || c[0].Number != "1.4" {91 t.Errorf("weather off: %+v", c)92 }93 // With none on, still a list: the app reads its absence as a server94 // from before the weather channel was listed here.95 writeFile(t, filepath.Join(root, "1.4 Cartoons", vchan.DetailsFile), `{"enabled": false}`)96 raw, _ = json.Marshal(&Snapshot{Custom: s.custom()})97 if !strings.Contains(string(raw), `"custom":[]`) {98 t.Errorf("none on: %s", raw)99 }100}101102type xmltvOut struct {103 Channels []struct {104 ID string `xml:"id,attr"`105 Names []string `xml:"display-name"`106 Icon struct {107 Src string `xml:"src,attr"`108 } `xml:"icon"`109 } `xml:"channel"`110 Programmes []struct {111 Channel string `xml:"channel,attr"`112 Title string `xml:"title"`113 SubTitle string `xml:"sub-title"`114 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"`121 } `xml:"programme"`122}123124func TestHDHomeRunCustomChannels(t *testing.T) {125 s, root := newCustomService(t)126 dev := &hdhr.Server{Backend: s.HDHR(), FriendlyName: "Airwaves", Tuners: func() int { return 2 }, Logos: s.LogoHandler()}127 srv := httptest.NewServer(dev.Handler())128 defer srv.Close()129 get := func(path string) (*http.Response, string) {130 t.Helper()131 resp, err := http.Get(srv.URL + path)132 if err != nil {133 t.Fatal(err)134 }135 defer resp.Body.Close()136 raw, _ := io.ReadAll(resp.Body)137 return resp, string(raw)138 }139140 _, raw := get("/lineup.json")141 var lineup []map[string]any142 if err := json.Unmarshal([]byte(raw), &lineup); err != nil {143 t.Fatal(err)144 }145 var numbers []string146 for _, e := range lineup {147 numbers = append(numbers, e["GuideNumber"].(string)+" "+e["GuideName"].(string))148 }149 if !slices.Equal(numbers, []string{"1.1 Airwaves Weather", "1.4 Cartoons"}) {150 t.Errorf("lineup: %v", numbers)151 }152153 // The playlist points at the logo on this server, and keeps custom154 // channels in their own group.155 _, m3u := get("/channels.m3u")156 logo := ""157 for _, line := range strings.Split(m3u, "\n") {158 if strings.Contains(line, `tvg-chno="1.4"`) {159 if !strings.Contains(line, `group-title="Airwaves"`) || !strings.Contains(line, `tvg-name="Cartoons"`) {160 t.Errorf("1.4: %s", line)161 }162 if i := strings.Index(line, `tvg-logo="`); i >= 0 {163 logo = line[i+len(`tvg-logo="`):]164 logo = logo[:strings.Index(logo, `"`)]165 }166 }167 if strings.Contains(line, `tvg-chno="1.1"`) && !strings.Contains(line, `tvg-logo=""`) {168 t.Errorf("weather has no logo yet: %s", line)169 }170 }171 if !strings.HasPrefix(logo, srv.URL+"/channel-logos/1.4?v=") {172 t.Fatalf("logo %q in:\n%s", logo, m3u)173 }174 resp, body := get(strings.TrimPrefix(logo, srv.URL))175 if resp.StatusCode != 200 || resp.Header.Get("Content-Type") != "image/png" || body != "\x89PNG\r\n\x1a\nlogo" ||176 !strings.Contains(resp.Header.Get("Cache-Control"), "immutable") {177 t.Errorf("logo: %d %v %q", resp.StatusCode, resp.Header, body)178 }179 if resp, _ := get("/channel-logos/1.1"); resp.StatusCode != 404 {180 t.Errorf("weather without a logo: %d", resp.StatusCode)181 }182 // Turned off, a channel's logo is still there for the admin page and183 // anything that had it.184 writeFile(t, filepath.Join(root, "1.5 Off", "logo.svg"), "<svg/>")185 if resp, _ := get("/channel-logos/1.5"); resp.StatusCode != 200 {186 t.Errorf("off channel's logo: %d", resp.StatusCode)187 }188189 _, raw = get("/xmltv.xml")190 var doc xmltvOut191 if err := xml.Unmarshal([]byte(raw), &doc); err != nil {192 t.Fatal(err)193 }194 for _, c := range doc.Channels {195 switch c.ID {196 case "airwaves.1.4":197 if !slices.Equal(c.Names, []string{"1.4 Cartoons", "Cartoons", "TOON", "1.4"}) || c.Icon.Src != logo {198 t.Errorf("1.4 channel: %+v", c)199 }200 case "airwaves.1.1":201 if !slices.Equal(c.Names, []string{"1.1 Airwaves Weather", "Airwaves Weather", "WX", "1.1"}) || c.Icon.Src != "" {202 t.Errorf("1.1 channel: %+v", c)203 }204 default:205 t.Errorf("channel %s", c.ID)206 }207 }208 kinds := map[string]bool{}209 ids := map[string]string{} // dd_progid: episode210 for _, p := range doc.Programmes {211 eps := map[string]string{}212 for _, e := range p.Episodes {213 eps[e.System] = e.Value214 }215 switch {216 case p.Channel == "airwaves.1.1" && slices.Equal(p.Category, []string{"Weather"}):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 }223 default:224 t.Errorf("programme: %+v", p)225 }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 }235 kinds[p.Channel+" "+p.Title] = true236 }237 if len(kinds) != 3 {238 t.Errorf("programmes: %v", kinds)239 }240241 // Off, or not rendered, a channel leaves the lineup.242 writeFile(t, filepath.Join(root, "1.4 Cartoons", vchan.DetailsFile), `{"enabled": false}`)243 s.wxStreams = false244 if _, raw := get("/lineup.json"); raw != "[]\n" {245 t.Errorf("lineup with both off: %s", raw)246 }247}248249func 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}