1package admin23import (4 "encoding/json"5 "fmt"6 "net/http"7 "net/url"8 "os"9 "path/filepath"10 "strings"11 "testing"12)1314// TestAdminYouTubePlaylist: a playlist looked up, then played by a new15// channel, listed in order; switched to uploads and back; and refused16// along with channels, or as a video's link.17func TestAdminYouTubePlaylist(t *testing.T) {18 h := newHarness(t)19 const page = "https://www.youtube.com/playlist?list=PLshowtime0001"20 link := "https://www.youtube.com/watch?v=episode0002&list=PLshowtime0001&index=2"2122 code, pl, raw := h.do("GET", "/admin/api/youtube/playlist?u="+url.QueryEscape(link), nil)23 if code != 200 || pl["id"] != "PLshowtime0001" || pl["url"] != page || pl["title"] != "The Show" || pl["owner"] != "Showrunner" ||24 pl["ownerUrl"] != "https://www.youtube.com/channel/UCshowsowner000000000001" || pl["videos"] != 13.0 ||25 pl["image"] != "https://i.ytimg.com/vi/episode0001/hqdefault.jpg" {26 t.Fatalf("lookup: %d %s", code, raw)27 }28 runs := h.ytRuns()29 if _, again, _ := h.do("GET", "/admin/api/youtube/playlist?u=PLshowtime0001", nil); again["title"] != "The Show" || h.ytRuns() != runs {30 t.Error("a repeat lookup ran yt-dlp again")31 }32 if code, _, raw := h.do("GET", "/admin/api/youtube/playlist?u=PLnosuch000000", nil); code != http.StatusBadGateway || !strings.Contains(raw, "The playlist does not exist.") {33 t.Errorf("no such playlist: %d %s", code, raw)34 }35 for u, want := range map[string]string{36 "https://youtu.be/dQw4w9WgXcQ": "link to a video",37 "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=RDdQw4w9WgXcQ": "mixes",38 "@boilerroom": "isn't a YouTube playlist",39 } {40 if code, _, raw := h.do("GET", "/admin/api/youtube/playlist?u="+url.QueryEscape(u), nil); code != http.StatusBadRequest || !strings.Contains(raw, want) {41 t.Errorf("lookup of %q: %d %s", u, code, raw)42 }43 }4445 // A new channel plays it, from a start to come, two a night.46 code, ch, raw := h.do("PUT", "/admin/api/channels/new", map[string]any{47 "kind": "youtube", "number": "1.9", "name": "The Show",48 "youtube": map[string]any{"playlist": link, "maxHeight": 480, "minMinutes": 20},49 "schedule": map[string]any{"start": "2099-01-01", "blocks": []any{map[string]any{"at": "20:00", "count": 2}}},50 })51 if code != 200 || ch["kind"] != "youtube" || ch["youtube"].(map[string]any)["playlist"] != page {52 t.Fatalf("create: %d %s", code, raw)53 }54 var file map[string]any55 data, _ := os.ReadFile(filepath.Join(h.root, "1.9 The Show", youtubeFile))56 if err := json.Unmarshal(data, &file); err != nil || file["playlist"] != page || file["channels"] != nil || file["maxHeight"] != 480.0 {57 t.Errorf("youtube.json: %s %v", data, err)58 }59 h.settle("1.9")60 show := h.channel("1.9")61 srcs, _ := show["sources"].([]any)62 if len(srcs) != 1 {63 t.Fatalf("sources: %v", show)64 }65 if s := srcs[0].(map[string]any); s["channel"] != page || s["url"] != page || s["id"] != "PLshowtime0001" || s["name"] != "The Show" ||66 s["owner"] != "Showrunner" || s["ownerUrl"] != "https://www.youtube.com/channel/UCshowsowner000000000001" ||67 s["image"] != "https://i.ytimg.com/vi/episode0001/hqdefault.jpg" || s["videos"] != 12.0 || s["playable"] != 12.0 ||68 s["listing"] != false || s["listed"] == nil {69 t.Errorf("source: %v", s)70 }71 if show["videos"] != 12.0 || show["items"] != 12.0 || show["listing"] != nil || show["schedule"] == nil {72 t.Errorf("channel: %v", show)73 }74 // Off the air until 2099, saying so, with its episodes in order.75 if now, _ := show["now"].(map[string]any); now == nil || now["title"] != "Off air" || !strings.Contains(now["subtitle"].(string), "with Episode 1") {76 t.Errorf("now: %v", show["now"])77 }78 _, items, raw := h.do("GET", "/admin/api/channels/1.9/items", nil)79 if list, _ := items["items"].([]any); len(list) != 12 || list[4].(map[string]any)["title"] != "Episode 5" ||80 list[4].(map[string]any)["subtitle"] != "Showrunner" || list[4].(map[string]any)["seconds"] != 3600.0 {81 t.Errorf("items: %s", raw)82 }8384 // Both, or a playlist as a channel, are refused; a schedule for uploads too.85 for _, c := range []struct {86 yt map[string]any87 more map[string]any88 want string89 }{90 {map[string]any{"playlist": page, "channels": []string{"@boilerroom"}}, nil, "not both"},91 {map[string]any{"playlist": "https://youtu.be/dQw4w9WgXcQ"}, nil, "link to a video"},92 {map[string]any{"playlist": "", "channels": []string{}}, nil, "at least one YouTube channel, or a playlist"},93 {map[string]any{"channels": []string{page}}, nil, "that's a playlist"},94 {map[string]any{"channels": []string{"@boilerroom"}}, map[string]any{"schedule": map[string]any{"start": "2099-01-01"}}, "uploads play in no order"},95 } {96 body := map[string]any{"kind": "youtube", "number": "1.9", "name": "The Show", "youtube": c.yt}97 for k, v := range c.more {98 body[k] = v99 }100 if code, _, raw := h.do("PUT", "/admin/api/channels/1.9", body); code != http.StatusBadRequest || !strings.Contains(raw, c.want) {101 t.Errorf("%v: %d %s", c.yt, code, raw)102 }103 }104105 // Channels take the playlist's place, and it theirs.106 code, ch, raw = h.do("PUT", "/admin/api/channels/1.9", map[string]any{"kind": "youtube", "number": "1.9", "name": "The Show",107 "youtube": map[string]any{"channels": []string{"@boilerroom"}}})108 if cfg := ch["youtube"].(map[string]any); code != 200 || cfg["playlist"] != nil || len(cfg["channels"].([]any)) != 1 {109 t.Fatalf("to uploads: %d %s", code, raw)110 }111 h.settle("1.9")112 data, _ = os.ReadFile(filepath.Join(h.root, "1.9 The Show", youtubeFile))113 if strings.Contains(string(data), "playlist") || !strings.Contains(string(data), "@boilerroom") || !strings.Contains(string(data), `"minMinutes": 20`) {114 t.Errorf("youtube.json for uploads: %s", data)115 }116 code, ch, raw = h.do("PUT", "/admin/api/channels/1.9", map[string]any{"kind": "youtube", "number": "1.9", "name": "The Show",117 "youtube": map[string]any{"playlist": "PLshowtime0001"}})118 if cfg := ch["youtube"].(map[string]any); code != 200 || cfg["playlist"] != page || len(cfg["channels"].([]any)) != 0 {119 t.Fatalf("back to the playlist: %d %s", code, raw)120 }121 h.settle("1.9")122 if code, _, raw := h.do("DELETE", "/admin/api/channels/1.9", nil); code != http.StatusNoContent {123 t.Errorf("delete: %d %s", code, raw)124 }125}126127func TestRestoreKnowsPlaylists(t *testing.T) {128 a := sourceOf(map[string][]byte{youtubeFile: []byte(`{"playlist": "https://www.youtube.com/watch?v=x&list=PLshowtime0001"}`)})129 b := sourceOf(map[string][]byte{youtubeFile: []byte(`{"playlist": "PLshowtime0001", "maxHeight": 480}`)})130 if a == "" || a != b || a == sourceOf(map[string][]byte{youtubeFile: []byte(`{"playlist": "PLSHOWTIME0001"}`)}) {131 t.Errorf("sources: %q %q", a, b)132 }133}134135// TestAgentYouTubePlaylist: agents make and change a channel playing a136// playlist, and see it as one.137func TestAgentYouTubePlaylist(t *testing.T) {138 h := newHarness(t)139 s := h.agent()140 const page = "https://www.youtube.com/playlist?list=PLshowtime0001"141142 c := ok(t, s, "create_youtube_channel", map[string]any{143 "number": "1.9", "name": "The Show", "playlist": "youtube.com/watch?v=episode0003&list=PLshowtime0001", "category": "Family",144 "schedule": map[string]any{"start": "2099-01-01", "blocks": []any{map[string]any{"at": "20:00", "count": 4}}},145 })146 if ch := c["channel"].(map[string]any); ch["youtube"].(map[string]any)["playlist"] != page || ch["category"] != "Family" || ch["schedule"] == nil {147 t.Fatalf("create: %v", c)148 }149 h.settle("1.9")150 g := ok(t, s, "get_channel", map[string]any{"number": "1.9"})151 if src := list(g["channel"].(map[string]any)["sources"]); len(src) != 1 || src[0].(map[string]any)["name"] != "The Show" {152 t.Errorf("get_channel's sources: %v", g["channel"])153 }154 if items := list(g["items"]); len(items) != 12 || items[2] != "3. Episode 3: Showrunner (60 min)" {155 t.Errorf("get_channel's items: %v", g["items"])156 }157 l := list(ok(t, s, "list_channels", nil)["channels"])158 if len(l) != 2 || l[1].(map[string]any)["plays"] != `the playlist "The Show" by Showrunner on YouTube, in order` {159 t.Errorf("list: %v", l)160 }161 fails(t, s, "create_youtube_channel", map[string]any{"number": "1.10", "name": "x", "playlist": page, "channels": []string{"@boilerroom"}}, "not both")162 fails(t, s, "create_youtube_channel", map[string]any{"number": "1.10", "name": "x"}, "at least one YouTube channel, or a playlist")163 fails(t, s, "update_youtube_channel", map[string]any{"number": "1.9", "playlist": "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=RDdQw4w9WgXcQ"}, "mixes")164165 c = ok(t, s, "update_youtube_channel", map[string]any{"number": "1.9", "maxHeight": 1080, "schedule": map[string]any{"start": "2098-06-01"}})166 if ch := c["channel"].(map[string]any); ch["youtube"].(map[string]any)["maxHeight"] != 1080.0 || ch["schedule"].(map[string]any)["start"] != "2098-06-01" ||167 !strings.Contains(fmt.Sprint(c["notes"]), "listed again") {168 t.Errorf("update: %v", c)169 }170 h.settle("1.9")171 c = ok(t, s, "update_youtube_channel", map[string]any{"number": "1.9", "addChannels": []string{"@boilerroom"}})172 if yt := c["channel"].(map[string]any)["youtube"].(map[string]any); yt["playlist"] != nil || fmt.Sprint(yt["channels"]) != "[https://www.youtube.com/@boilerroom]" {173 t.Errorf("to uploads: %v", yt)174 }175 h.settle("1.9")176 fails(t, s, "update_youtube_channel", map[string]any{"number": "1.9", "schedule": map[string]any{"start": "2099-01-01"}}, "uploads play in no order")177 ok(t, s, "delete_channel", map[string]any{"number": "1.9", "confirm": true})178}