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 / admin / details_test.go
details_test.go 22.0K
1package admin23import (4 "bytes"5 "context"6 "encoding/json"7 "fmt"8 "io"9 "mime/multipart"10 "net/http"11 "net/http/httptest"12 "os"13 "path/filepath"14 "strings"15 "testing"16 "time"1718 "airwaves/internal/vchan"19)2021// send makes a request with a body of the given type.22func (h *harness) send(method, path, kind string, body []byte) (int, map[string]any, string) {23 h.t.Helper()24 req, _ := http.NewRequest(method, h.url+path, bytes.NewReader(body))25 req.Header.Set("Content-Type", kind)26 resp, err := http.DefaultClient.Do(req)27 if err != nil {28 h.t.Fatal(err)29 }30 defer resp.Body.Close()31 raw, _ := io.ReadAll(resp.Body)32 var m map[string]any33 _ = json.Unmarshal(raw, &m)34 return resp.StatusCode, m, string(raw)35}3637// get fetches a path, returning the response with its body read.38func (h *harness) get(path string) (*http.Response, string) {39 h.t.Helper()40 resp, err := http.Get(h.url + path)41 if err != nil {42 h.t.Fatal(err)43 }44 defer resp.Body.Close()45 raw, _ := io.ReadAll(resp.Body)46 return resp, string(raw)47}4849// record reads a details file.50func record(t *testing.T, path string) map[string]any {51 t.Helper()52 raw, err := os.ReadFile(path)53 if err != nil {54 t.Fatal(err)55 }56 var m map[string]any57 if err := json.Unmarshal(raw, &m); err != nil {58 t.Fatalf("%s: %v", raw, err)59 }60 return m61}6263// withWeather gives the harness a weather channel, its record in the64// channels folder.65func (h *harness) withWeather() *vchan.Weather {66 wx := &vchan.Weather{Record: filepath.Join(h.root, vchan.WeatherFile)}67 h.admin.Weather = wx68 h.lib.Reserved = func() []string { return []string{wx.Number()} }69 h.lib.Rescan()70 return wx71}7273var pngLogo = append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{7}, 64)...)7475func TestAdminDetails(t *testing.T) {76 h := newHarness(t)77 _, state, _ := h.do("GET", "/admin/api/state", nil)78 if cats := state["categories"].([]any); len(cats) != len(vchan.Categories) || cats[0] != "Kids" || cats[len(cats)-1] != "Other" {79 t.Errorf("categories: %v", cats)80 }81 if c := h.channel("1.2"); c["callSign"] != "" || c["category"] != "Other" || c["description"] != "" || c["enabled"] != true || c["logo"] != nil {82 t.Errorf("defaults: %v", c)83 }8485 // A folder channel's details, and its number and name renaming the86 // folder.87 code, ch, raw := h.do("PUT", "/admin/api/channels/1.2", map[string]any{88 "kind": "folder", "number": " 1.3 ", "name": "Cats", "callSign": " cat tv ", "category": "pets",89 "description": " Birds\n and fish. ", "enabled": false,90 })91 if code != 200 || ch["number"] != "1.3" || ch["folder"] != "1.3 Cats" || ch["kind"] != "folder" || ch["callSign"] != "cat tv" ||92 ch["category"] != "Pets" || ch["description"] != "Birds and fish." || ch["enabled"] != false {93 t.Fatalf("folder channel: %d %s", code, raw)94 }95 path := filepath.Join(h.root, "1.3 Cats", vchan.DetailsFile)96 if m := record(t, path); m["callSign"] != "cat tv" || m["category"] != "Pets" || m["description"] != "Birds and fish." || m["enabled"] != false ||97 m["number"] != nil || m["name"] != nil {98 t.Errorf("channel.json: %v", m)99 }100 if _, err := os.Stat(filepath.Join(h.root, "1.2 Cat Calming")); !os.IsNotExist(err) {101 t.Error("the old folder is still there")102 }103104 // Details left out stay, as does anything else in the file; empty ones105 // go back to the defaults.106 if err := os.WriteFile(path, []byte(`{"callSign": "CATS", "category": "Pets", "description": "Birds.", "enabled": false, "mine": 1}`), 0o644); err != nil {107 t.Fatal(err)108 }109 code, ch, raw = h.do("PUT", "/admin/api/channels/1.3", map[string]any{"kind": "folder", "number": "1.3", "name": "Cats", "enabled": true, "category": ""})110 if code != 200 || ch["callSign"] != "CATS" || ch["category"] != "Other" || ch["enabled"] != true || ch["folder"] != "1.3 Cats" {111 t.Errorf("partial: %d %s", code, raw)112 }113 if m := record(t, path); m["mine"] != 1.0 || m["category"] != nil || m["description"] != "Birds." {114 t.Errorf("kept: %v", m)115 }116117 // A folder without a number keeps its name when only details change.118 if err := os.Mkdir(filepath.Join(h.root, "Fish"), 0o755); err != nil {119 t.Fatal(err)120 }121 h.lib.Rescan()122 fish := ""123 for _, e := range h.lib.Entries() {124 if e.Folder == "Fish" {125 fish = e.Channel.Number()126 }127 }128 if code, ch, raw := h.do("PUT", "/admin/api/channels/"+fish, map[string]any{"kind": "folder", "number": fish, "name": "Fish", "callSign": "FISH"}); code != 200 ||129 ch["folder"] != "Fish" || ch["callSign"] != "FISH" {130 t.Errorf("unnumbered folder: %d %s", code, raw)131 }132133 // A Jellyfin channel's details go beside its jellyfin.json.134 code, ch, raw = h.do("PUT", "/admin/api/channels/new", map[string]any{135 "number": "1.4", "name": "Cartoons", "series": []string{"Futurama (1999)"}, "callSign": "TOON", "category": "Kids",136 })137 if code != 200 || ch["callSign"] != "TOON" || ch["category"] != "Kids" || ch["enabled"] != true {138 t.Fatalf("jellyfin channel: %d %s", code, raw)139 }140 if m := record(t, filepath.Join(h.root, "1.4 Cartoons", vchan.DetailsFile)); m["callSign"] != "TOON" || m["category"] != "Kids" {141 t.Errorf("jellyfin channel.json: %v", m)142 }143 if _, err := os.Stat(filepath.Join(h.root, "1.4 Cartoons", channelFile)); err != nil {144 t.Error(err)145 }146147 // Numbers stay as written ("104.0"), and save again unchanged.148 if err := os.Mkdir(filepath.Join(h.root, "104.0 Toons"), 0o755); err != nil {149 t.Fatal(err)150 }151 h.lib.Rescan()152 if c := h.channel("104.0"); c["name"] != "Toons" || c["folder"] != "104.0 Toons" {153 t.Errorf("104.0: %v", c)154 }155 code, ch, raw = h.do("PUT", "/admin/api/channels/104.0", map[string]any{"kind": "folder", "number": "104.0", "name": "Toons", "callSign": "TOON"})156 if code != 200 || ch["number"] != "104.0" || ch["folder"] != "104.0 Toons" || ch["callSign"] != "TOON" {157 t.Errorf("104.0 saved: %d %s", code, raw)158 }159 code, ch, raw = h.do("PUT", "/admin/api/channels/104.0", map[string]any{"kind": "folder", "number": "105.0", "name": "Toons"})160 if code != 200 || ch["number"] != "105.0" || ch["folder"] != "105.0 Toons" {161 t.Errorf("105.0: %d %s", code, raw)162 }163164 for _, c := range []struct {165 body map[string]any166 code int167 want string168 }{169 {map[string]any{"number": "0.5", "name": "x"}, 400, "1 to 999, a dot, then 0 to 999"},170 {map[string]any{"number": "105.00", "name": "x"}, 409, "taken by Toons"},171 {map[string]any{"number": "01.3", "name": "x"}, 409, "taken by Cats"},172 {map[string]any{"number": "1000.1", "name": "x"}, 400, "then 0 to 999"},173 {map[string]any{"number": "1.1000", "name": "x"}, 400, "then 0 to 999"},174 {map[string]any{"number": "1.9", "name": "x", "callSign": "WAYTOOLONG"}, 400, "call sign"},175 {map[string]any{"number": "1.9", "name": "x", "callSign": "<b>"}, 400, "call sign"},176 {map[string]any{"number": "1.9", "name": "x", "category": "Cooking"}, 400, "category is one of Kids"},177 {map[string]any{"number": "1.9", "name": "x", "description": strings.Repeat("a", 501)}, 400, "500 characters"},178 {map[string]any{"kind": "folder", "number": "1.9", "name": "x"}, 400, "adding a folder"},179 {map[string]any{"kind": "weather", "number": "1.9", "name": "x"}, 404, "isn't the weather channel"},180 {map[string]any{"number": "1.3", "name": "x"}, 409, "taken by Cats"},181 } {182 if code, _, raw := h.do("PUT", "/admin/api/channels/new", c.body); code != c.code || !strings.Contains(raw, c.want) {183 t.Errorf("%v: %d %s", c.body, code, raw)184 }185 }186 if code, _, raw := h.do("PUT", "/admin/api/channels/1.3", map[string]any{"kind": "youtube", "number": "1.3", "name": "Cats",187 "youtube": map[string]any{"channels": []string{"@boilerroom"}}}); code != 400 || !strings.Contains(raw, "folder channel and can't become a YouTube one") {188 t.Errorf("folder to YouTube: %d %s", code, raw)189 }190}191192func TestAdminWeather(t *testing.T) {193 h := newHarness(t)194 h.withWeather()195 if err := os.Mkdir(filepath.Join(h.root, "1.5 Fish"), 0o755); err != nil {196 t.Fatal(err)197 }198 _, state, _ := h.do("GET", "/admin/api/state", nil)199 wx := state["channels"].([]any)[0].(map[string]any)200 if wx["kind"] != "weather" || wx["number"] != "1.1" || wx["name"] != "Airwaves Weather" || wx["callSign"] != "WX" ||201 wx["category"] != "Weather" || wx["enabled"] != true {202 t.Fatalf("weather: %v", wx)203 }204205 if code, _, raw := h.do("PUT", "/admin/api/channels/1.1", map[string]any{"kind": "weather", "number": "1.5", "name": "Weather"}); code != 409 ||206 !strings.Contains(raw, "taken by Fish") {207 t.Errorf("taken: %d %s", code, raw)208 }209 code, ch, raw := h.do("PUT", "/admin/api/channels/1.1", map[string]any{210 "kind": "weather", "number": "1.7", "name": "Weather Now", "callSign": "WTHR", "description": "The forecast.", "enabled": false,211 })212 if code != 200 || ch["number"] != "1.7" || ch["name"] != "Weather Now" || ch["callSign"] != "WTHR" || ch["category"] != "Weather" ||213 ch["description"] != "The forecast." || ch["enabled"] != false || ch["kind"] != "weather" {214 t.Fatalf("weather saved: %d %s", code, raw)215 }216 if m := record(t, filepath.Join(h.root, vchan.WeatherFile)); m["number"] != "1.7" || m["name"] != "Weather Now" || m["callSign"] != "WTHR" || m["enabled"] != false {217 t.Errorf(".weather.json: %v", m)218 }219220 // It's at its new number now, and other channels can't have it.221 if code, _, raw := h.do("PUT", "/admin/api/channels/new", map[string]any{"number": "1.7", "name": "Clash"}); code != 409 || !strings.Contains(raw, "taken by Weather Now") {222 t.Errorf("weather's number: %d %s", code, raw)223 }224 if code, _, raw := h.do("PUT", "/admin/api/channels/1.1", map[string]any{"kind": "weather", "number": "1.1", "name": "Weather"}); code != 404 {225 t.Errorf("old number: %d %s", code, raw)226 }227 _, sched, raw := h.do("GET", "/admin/api/channels/1.7/schedule", nil)228 if progs, _ := sched["programs"].([]any); len(progs) == 0 || progs[0].(map[string]any)["title"] != "Local Forecast" {229 t.Errorf("weather schedule: %s", raw)230 }231 // A folder named for its number gives way.232 if err := os.Mkdir(filepath.Join(h.root, "1.7 Clash"), 0o755); err != nil {233 t.Fatal(err)234 }235 h.lib.Rescan()236 for _, e := range h.lib.Entries() {237 if e.Folder == "1.7 Clash" && e.Channel.Number() == "1.7" {238 t.Error("a folder took the weather channel's number")239 }240 }241}242243func TestAdminAntennaCollisions(t *testing.T) {244 h := newHarness(t)245 h.admin.Antenna = func(context.Context) map[string]string {246 return map[string]string{"9.1": "KUSA", "1.2": "KTEST", "7.1": "KMGH"}247 }248 _, state, raw := h.do("GET", "/admin/api/state", nil)249 list := state["antenna"].([]any)250 if len(list) != 3 || list[0].(map[string]any)["number"] != "1.2" || list[2].(map[string]any)["name"] != "KUSA" {251 t.Errorf("antenna: %s", raw)252 }253 if c := h.channel("1.2"); c["antenna"] != "KTEST" {254 t.Errorf("collision: %v", c)255 }256 // Taking an antenna channel's number is allowed, with a warning.257 code, ch, raw := h.do("PUT", "/admin/api/channels/new", map[string]any{"number": "7.1", "name": "Cartoons"})258 if code != 200 || ch["antenna"] != "KMGH" {259 t.Errorf("save over an antenna channel: %d %s", code, raw)260 }261}262263func TestAdminLogos(t *testing.T) {264 h := newHarness(t)265 dir := filepath.Join(h.root, "1.2 Cat Calming")266 code, ch, raw := h.send("PUT", "/admin/api/channels/1.2/logo", "image/png", pngLogo)267 logo, _ := ch["logo"].(string)268 if code != 200 || !strings.HasPrefix(logo, "/admin/api/channels/1.2/logo?v=") {269 t.Fatalf("upload: %d %s", code, raw)270 }271 if got, _ := os.ReadFile(filepath.Join(dir, "logo.png")); !bytes.Equal(got, pngLogo) {272 t.Errorf("logo.png: %q", got)273 }274 if m := record(t, filepath.Join(dir, vchan.DetailsFile)); m["logo"] != "logo.png" {275 t.Errorf("channel.json: %v", m)276 }277 resp, body := h.get(logo)278 if resp.StatusCode != 200 || resp.Header.Get("Content-Type") != "image/png" || body != string(pngLogo) ||279 resp.Header.Get("ETag") == "" || !strings.Contains(resp.Header.Get("Cache-Control"), "immutable") {280 t.Errorf("served: %d %v", resp.StatusCode, resp.Header)281 }282283 // An SVG sent in a form takes its place.284 var form bytes.Buffer285 mw := multipart.NewWriter(&form)286 _ = mw.WriteField("note", "hi")287 fw, _ := mw.CreateFormFile("logo", "cat.svg")288 io.WriteString(fw, "<?xml version=\"1.0\"?>\n<!-- a cat -->\n<svg xmlns=\"http://www.w3.org/2000/svg\"><circle r=\"4\"/></svg>")289 mw.Close()290 code, ch, raw = h.send("POST", "/admin/api/channels/1.2/logo", mw.FormDataContentType(), form.Bytes())291 if code != 200 || ch["logo"] == logo {292 t.Fatalf("form upload: %d %s", code, raw)293 }294 if _, err := os.Stat(filepath.Join(dir, "logo.png")); !os.IsNotExist(err) {295 t.Error("the old logo is still there")296 }297 if m := record(t, filepath.Join(dir, vchan.DetailsFile)); m["logo"] != "logo.svg" {298 t.Errorf("channel.json: %v", m)299 }300 if resp, _ := h.get(ch["logo"].(string)); resp.Header.Get("Content-Type") != "image/svg+xml" {301 t.Errorf("svg: %v", resp.Header)302 }303304 // Not an image, too big, or no file.305 if code, _, raw := h.send("PUT", "/admin/api/channels/1.2/logo", "text/plain", []byte("hello")); code != http.StatusUnsupportedMediaType || !strings.Contains(raw, "PNG, JPEG, SVG or WebP") {306 t.Errorf("text: %d %s", code, raw)307 }308 big := append(slicesClone(pngLogo), make([]byte, maxLogo)...)309 if code, _, raw := h.send("PUT", "/admin/api/channels/1.2/logo", "image/png", big); code != http.StatusRequestEntityTooLarge {310 t.Errorf("too big: %d %s", code, raw)311 }312 form.Reset()313 mw = multipart.NewWriter(&form)314 _ = mw.WriteField("note", "no file")315 mw.Close()316 if code, _, raw := h.send("POST", "/admin/api/channels/1.2/logo", mw.FormDataContentType(), form.Bytes()); code != 400 || !strings.Contains(raw, "no image file") {317 t.Errorf("no file: %d %s", code, raw)318 }319 if code, _, _ := h.send("PUT", "/admin/api/channels/4.4/logo", "image/png", pngLogo); code != 404 {320 t.Errorf("no such channel: %d", code)321 }322323 // The logo moves with the folder.324 code, ch, raw = h.do("PUT", "/admin/api/channels/1.2", map[string]any{"kind": "folder", "number": "1.3", "name": "Cats"})325 if code != 200 || !strings.HasPrefix(ch["logo"].(string), "/admin/api/channels/1.3/logo?v=") {326 t.Fatalf("renamed: %d %s", code, raw)327 }328 if resp, _ := h.get("/admin/api/channels/1.3/logo"); resp.StatusCode != 200 {329 t.Errorf("after the rename: %d", resp.StatusCode)330 }331332 // Removing it.333 code, ch, raw = h.do("DELETE", "/admin/api/channels/1.3/logo", nil)334 if code != 200 || ch["logo"] != nil {335 t.Errorf("remove: %d %s", code, raw)336 }337 if left, _ := filepath.Glob(filepath.Join(h.root, "1.3 Cats", "logo.*")); len(left) != 0 {338 t.Errorf("left: %v", left)339 }340 if m := record(t, filepath.Join(h.root, "1.3 Cats", vchan.DetailsFile)); m["logo"] != nil {341 t.Errorf("channel.json: %v", m)342 }343 if resp, _ := h.get("/admin/api/channels/1.3/logo"); resp.StatusCode != 404 {344 t.Errorf("removed logo: %d", resp.StatusCode)345 }346347 // The weather channel's goes beside its record.348 h.withWeather()349 if code, ch, raw := h.send("PUT", "/admin/api/channels/1.1/logo", "image/png", pngLogo); code != 200 || ch["kind"] != "weather" || ch["logo"] == nil {350 t.Errorf("weather logo: %d %s", code, raw)351 }352 if _, err := os.Stat(filepath.Join(h.root, ".weather-logo.png")); err != nil {353 t.Error(err)354 }355 if m := record(t, filepath.Join(h.root, vchan.WeatherFile)); m["logo"] != ".weather-logo.png" {356 t.Errorf(".weather.json: %v", m)357 }358359 // Deleting a channel takes its details and logo with it.360 if code, _, raw := h.do("PUT", "/admin/api/channels/new", map[string]any{"number": "1.4", "name": "Cartoons", "callSign": "TOON"}); code != 200 {361 t.Fatalf("new: %d %s", code, raw)362 }363 if code, _, raw := h.send("PUT", "/admin/api/channels/1.4/logo", "image/png", pngLogo); code != 200 {364 t.Fatalf("logo: %d %s", code, raw)365 }366 if code, _, raw := h.do("DELETE", "/admin/api/channels/1.4", nil); code != http.StatusNoContent {367 t.Errorf("delete: %d %s", code, raw)368 }369 if left, err := os.ReadDir(filepath.Join(h.root, "1.4 Cartoons")); !os.IsNotExist(err) {370 t.Errorf("left behind: %v", left)371 }372}373374func slicesClone(b []byte) []byte { return append([]byte(nil), b...) }375376func TestAdminLogoFromSource(t *testing.T) {377 h := newHarness(t)378 if code, _, raw := h.do("PUT", "/admin/api/account", map[string]string{"server": h.jf.URL, "user": "viewer", "password": "s3cret-pw"}); code != 200 {379 t.Fatalf("account: %d %s", code, raw)380 }381 // Family Feud has no poster, so Futurama's is taken.382 if code, _, raw := h.do("PUT", "/admin/api/channels/new", map[string]any{"number": "1.4", "name": "Cartoons",383 "series": []string{"Family Feud (1976)", "Futurama (1999)"}}); code != 200 {384 t.Fatalf("new: %d %s", code, raw)385 }386 code, ch, raw := h.do("POST", "/admin/api/channels/1.4/logo/source", nil)387 if code != 200 || ch["logo"] == nil {388 t.Fatalf("from Jellyfin: %d %s", code, raw)389 }390 if got, _ := os.ReadFile(filepath.Join(h.root, "1.4 Cartoons", "logo.jpg")); string(got) != "\xff\xd8\xff\xe0jpeg fut Primary 400" {391 t.Errorf("logo.jpg: %q", got)392 }393394 // A YouTube channel's first channel's avatar, asked for bigger.395 seedYouTube(t, filepath.Join(h.root, "1.8 Northernlion"))396 h.lib.Rescan()397 var asked string398 img := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {399 asked = r.URL.Path400 w.Write(pngLogo)401 }))402 defer img.Close()403 h.admin.remember("https://www.youtube.com/@Northernlion", lookedUp{404 ch: ytChannelJSON{ID: "UC3tNpTOHsTnkmbwztCs30sA", URL: "https://www.youtube.com/@Northernlion", Image: img.URL + "/nl=s176-c-k-c0x00ffffff-no-rj"},405 at: time.Now(),406 })407 code, ch, raw = h.do("POST", "/admin/api/channels/1.8/logo/source", nil)408 if code != 200 || ch["logo"] == nil || asked != "/nl=s400-c-k-c0x00ffffff-no-rj" {409 t.Fatalf("from YouTube: %d %s (asked for %q)", code, raw, asked)410 }411 if got, _ := os.ReadFile(filepath.Join(h.root, "1.8 Northernlion", "logo.png")); !bytes.Equal(got, pngLogo) {412 t.Errorf("logo.png: %q", got)413 }414 if code, _, raw := h.do("POST", "/admin/api/channels/1.2/logo/source", nil); code != 400 {415 t.Errorf("folder channel: %d %s", code, raw)416 }417 h.settle("1.8")418}419420// TestAdminSchedule: a channel's schedule saves in its channel.json with421// the details, checked, kept when left out and taken away with null, and422// the guide shows the time off the air.423func TestAdminSchedule(t *testing.T) {424 h := newHarness(t)425 if code, _, raw := h.do("PUT", "/admin/api/account", map[string]string{"server": h.jf.URL, "user": "viewer", "password": "s3cret-pw"}); code != 200 {426 t.Fatalf("account: %s", raw)427 }428 path := filepath.Join(h.root, "1.2 Cat Calming", vchan.DetailsFile)429 put := func(number string, body map[string]any) (int, map[string]any, string) {430 h.t.Helper()431 return h.do("PUT", "/admin/api/channels/"+number, body)432 }433 folder := func(more map[string]any) map[string]any {434 b := map[string]any{"kind": "folder", "number": "1.2", "name": "Cat Calming"}435 for k, v := range more {436 b[k] = v437 }438 return b439 }440441 code, ch, raw := put("1.2", folder(map[string]any{"schedule": map[string]any{442 "start": " 2026-10-04 ", "first": 2, "blocks": []any{map[string]any{"days": []string{"Fri", " sat"}, "at": "20:00", "count": 4}},443 }}))444 if code != 200 {445 t.Fatalf("save: %d %s", code, raw)446 }447 want := `{"blocks":[{"at":"20:00","count":4,"days":["fri","sat"]}],"first":2,"start":"2026-10-04"}`448 if got, _ := json.Marshal(ch["schedule"]); string(got) != want {449 t.Errorf("schedule:\n%s\nwant\n%s", got, want)450 }451 if got, _ := json.Marshal(record(t, path)["schedule"]); string(got) != want {452 t.Errorf("channel.json: %s", got)453 }454455 // Details saved without one keep it.456 if code, ch, raw := put("1.2", folder(map[string]any{"callSign": "CATS"})); code != 200 || ch["schedule"] == nil || ch["callSign"] != "CATS" {457 t.Errorf("details alone: %d %s", code, raw)458 }459 for _, c := range []struct {460 schedule any461 want string462 }{463 {map[string]any{"start": "Oct 4"}, "the schedule: the start"},464 {map[string]any{"start": "2026-10-04", "blocks": []any{map[string]any{"at": "8pm", "count": 4}}}, "the schedule: block 1 starts at"},465 {map[string]any{"start": "2026-10-04", "blocks": []any{map[string]any{"at": "20:00"}}}, "needs a count or a time to stop"},466 {map[string]any{"start": "2026-10-04", "blocks": []any{map[string]any{"at": "20:00", "count": 101}}}, "airs 1 to 100 items"},467 {map[string]any{"start": "2026-10-04", "repeat": true}, `unknown field`},468 {"tonight", "the schedule should be"},469 } {470 if code, _, raw := put("1.2", folder(map[string]any{"schedule": c.schedule})); code != 400 || !strings.Contains(raw, c.want) {471 t.Errorf("%v: %d %s", c.schedule, code, raw)472 }473 }474 if m := record(t, path); m["schedule"] == nil || m["callSign"] != "CATS" {475 t.Errorf("after refusals: %v", m)476 }477478 // null takes it away.479 if code, ch, raw := put("1.2", folder(map[string]any{"schedule": nil})); code != 200 || ch["schedule"] != nil {480 t.Errorf("null: %d %s", code, raw)481 }482 if m := record(t, path); m["schedule"] != nil || m["callSign"] != "CATS" {483 t.Errorf("channel.json after null: %v", m)484 }485486 // A Jellyfin channel's videos in order, and its guide before it starts.487 code, ch, raw = put("new", map[string]any{"number": "1.4", "name": "Futurama", "series": []string{"Futurama (1999)"}, "order": "aired",488 "schedule": map[string]any{"start": "2027-01-01", "blocks": []any{map[string]any{"at": "20:00", "count": 1}}}})489 if code != 200 || ch["schedule"] == nil {490 t.Fatalf("jellyfin: %d %s", code, raw)491 }492 _, items, raw := h.do("GET", "/admin/api/channels/1.4/items", nil)493 if l, _ := items["items"].([]any); len(l) != 2 || fmt.Sprint(l[0]) != "map[episode:1 n:1 season:1 seconds:1320 subtitle:Space Pilot 3000 title:Futurama]" {494 t.Errorf("items: %s", raw)495 }496 _, sched, raw := h.do("GET", "/admin/api/channels/1.4/schedule", nil)497 if progs, _ := sched["programs"].([]any); len(progs) != 1 || progs[0].(map[string]any)["offAir"] != true ||498 progs[0].(map[string]any)["subtitle"] != "Starts Fri, Jan 1 at 8:00 PM with Futurama, S1 E1." {499 t.Errorf("schedule: %s", raw)500 }501 if now := h.channel("1.4")["now"].(map[string]any); now["title"] != "Off air" {502 t.Errorf("now: %v", now)503 }504505 if code, _, raw := h.do("GET", "/admin/api/channels/9.9/items", nil); code != 404 || !strings.Contains(raw, "no such channel") {506 t.Errorf("no channel: %d %s", code, raw)507 }508 wx := h.withWeather()509 if code, _, raw := put(wx.Number(), map[string]any{"kind": "weather", "number": wx.Number(), "name": "Weather",510 "schedule": map[string]any{"start": "2026-10-04"}}); code != 400 || !strings.Contains(raw, "airs around the clock") {511 t.Errorf("weather: %d %s", code, raw)512 }513 if code, _, raw := h.do("GET", "/admin/api/channels/"+wx.Number()+"/items", nil); code != 404 || !strings.Contains(raw, "doesn't play its videos in order") {514 t.Errorf("weather items: %d %s", code, raw)515 }516}