1package admin23import (4 "bytes"5 "encoding/json"6 "errors"7 "fmt"8 "hash/crc32"9 "io"10 "net/http"11 "net/http/httptest"12 "net/url"13 "os"14 "path/filepath"15 "slices"16 "strconv"17 "strings"18 "testing"19 "time"2021 "airwaves/internal/jellyfin/jellyfintest"22 "airwaves/internal/vchan"23 "airwaves/internal/youtube"24)2526// TestMain lets the test binary stand in for yt-dlp: run with27// AIRWAVES_FAKE_YTDLP set, it answers like yt-dlp would, from testdata.28func TestMain(m *testing.M) {29 if os.Getenv("AIRWAVES_FAKE_YTDLP") != "" {30 os.Exit(fakeYtDlp(os.Args[1:]))31 }32 os.Exit(m.Run())33}3435// fakeYtDlp answers yt-dlp's command line, noting each address asked for36// in the file AIRWAVES_FAKE_YTDLP names. Channel listings hold 3037// one-minute videos, too short for the default settings, so nothing gets38// scheduled and looked up.39func fakeYtDlp(args []string) int {40 if len(args) == 1 && args[0] == "--version" {41 fmt.Println("2026.08.19")42 return 043 }44 u := args[len(args)-1]45 if f, err := os.OpenFile(os.Getenv("AIRWAVES_FAKE_YTDLP"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {46 fmt.Fprintln(f, u)47 f.Close()48 }49 fail := func(msg string) int {50 fmt.Fprintln(os.Stderr, "ERROR: "+msg)51 return 152 }53 file := func(name string) int {54 raw, err := os.ReadFile(filepath.Join("testdata", name))55 if err != nil {56 return fail(err.Error())57 }58 os.Stdout.Write(raw)59 return 060 }61 end := ""62 if i := slices.Index(args, "--playlist-end"); i >= 0 && i+1 < len(args) {63 end = args[i+1]64 }65 switch {66 case strings.Contains(u, "search_query="):67 p, _ := url.Parse(u)68 if p.Query().Get("search_query") == "broken" {69 return fail("[youtube:search_url] broken: Sign in to confirm you're not a bot. Use --cookies-from-browser")70 }71 return file("ytdlp-search.json")72 case strings.Contains(u, "list=UU"):73 fmt.Printf(`{"id": "UU%s", "playlist_count": 20000, "entries": []}`, strings.TrimPrefix(u, "https://www.youtube.com/playlist?list=UU"))74 return 075 case strings.Contains(u, "list=PLnosuch"):76 return fail("[youtube:tab] PLnosuch000000: The playlist does not exist.")77 case strings.Contains(u, "list=PL"):78 return fakePlaylist(u[strings.Index(u, "list=")+len("list="):], end)79 case strings.Contains(u, "@nosuch"):80 return fail("[youtube:tab] @nosuch/videos: Unable to download API page: HTTP Error 404: Not Found (caused by <HTTPError 404: Not Found>)")81 case strings.Contains(u, "@Northernlion/videos") && end == "1":82 return file("ytdlp-channel.json")83 case strings.HasSuffix(u, "/videos"):84 page := strings.TrimSuffix(u, "/videos")85 name := strings.TrimPrefix(page[strings.LastIndex(page, "/")+1:], "@")86 var entries []map[string]any87 for i := range 30 {88 entries = append(entries, map[string]any{"id": fmt.Sprintf("%s-%02d", name, i), "title": fmt.Sprintf("%s video %d", name, i), "duration": 60})89 }90 raw, _ := json.Marshal(map[string]any{"channel": name, "channel_id": fmt.Sprintf("UC%022d", crc32.ChecksumIEEE([]byte(name))), "entries": entries})91 os.Stdout.Write(raw)92 return 093 }94 return fail("fake yt-dlp doesn't know " + u)95}9697// fakePlaylist lists a playlist: twelve hour-long episodes by Showrunner,98// with a private video among them, up to end when given.99func fakePlaylist(id, end string) int {100 entries := []map[string]any{}101 for i := range 12 {102 entries = append(entries, map[string]any{"id": fmt.Sprintf("episode%04d", i+1), "title": fmt.Sprintf("Episode %d", i+1),103 "duration": 3600, "channel": "Showrunner"})104 if i == 3 {105 entries = append(entries, map[string]any{"id": "private0001", "title": "[Private video]"})106 }107 }108 if n, err := strconv.Atoi(end); err == nil {109 entries = entries[:n]110 }111 raw, _ := json.Marshal(map[string]any{"_type": "playlist", "id": id, "title": "The Show", "channel": "Showrunner",112 "channel_id": "UCshowsowner000000000001", "playlist_count": 13, "entries": entries})113 os.Stdout.Write(raw)114 return 0115}116117// noNetwork fails every request: the tests' YouTube channels read no118// feeds.119type noNetwork struct{}120121func (noNetwork) RoundTrip(*http.Request) (*http.Response, error) {122 return nil, errors.New("no network in tests")123}124125func library() []jellyfintest.Item {126 return []jellyfintest.Item{127 {ID: "lib-shows", Type: "CollectionFolder", Name: "Shows"},128 {ID: "fut", Type: "Series", Name: "Futurama", Parent: "lib-shows", Year: 1999, Image: true, Genres: []string{"Animation"}},129 {ID: "fut-1-1", Type: "Episode", Name: "Space Pilot 3000", Parent: "fut", Season: 1, Episode: 1, Runtime: 22 * time.Minute},130 {ID: "fut-1-2", Type: "Episode", Name: "The Series Has Landed", Parent: "fut", Season: 1, Episode: 2, Runtime: 22 * time.Minute},131 {ID: "feud", Type: "Series", Name: "Family Feud", Parent: "lib-shows", Year: 1976, Genres: []string{"Game Show"}},132 {ID: "feud-1-1", Type: "Episode", Name: "Episode 1", Parent: "feud", Season: 1, Episode: 1, Runtime: 22 * time.Minute},133 {ID: "giant", Type: "Movie", Name: "The Iron Giant", Year: 1999, Runtime: 86 * time.Minute, Image: true},134 }135}136137type harness struct {138 t *testing.T139 root string140 jf *jellyfintest.Server141 url string142 lib *vchan.Library143 admin *Server144 ytlog string // what the fake yt-dlp was asked for145}146147// newHarness starts the admin page on a channels folder holding a folder148// channel, 1.2 Cat Calming; opts set the server up further first.149func newHarness(t *testing.T, opts ...func(*Server)) *harness {150 jf := jellyfintest.New(t, "viewer", "s3cret-pw", library()...)151 jf.QuickConnect = true152 root := t.TempDir()153 if err := os.Mkdir(filepath.Join(root, "1.2 Cat Calming"), 0o755); err != nil {154 t.Fatal(err)155 }156 exe, err := os.Executable()157 if err != nil {158 t.Fatal(err)159 }160 ytlog := filepath.Join(t.TempDir(), "yt-dlp.log")161 t.Setenv("AIRWAVES_FAKE_YTDLP", ytlog)162 lib := &vchan.Library{Root: root, FFmpeg: "ffmpeg", FFprobe: "ffprobe", Reserved: func() []string { return []string{"1.1"} },163 YtDlp: &youtube.YtDlp{Path: exe, HTTP: &http.Client{Transport: noNetwork{}}}}164 a := &Server{Library: lib, HTTP: jf.Client()}165 for _, o := range opts {166 o(a)167 }168 srv := httptest.NewServer(a.Handler())169 t.Cleanup(srv.Close)170 t.Cleanup(a.stopBackups)171 return &harness{t: t, root: root, jf: jf, url: srv.URL, lib: lib, admin: a, ytlog: ytlog}172}173174// ytRuns counts the fake yt-dlp's runs.175func (h *harness) ytRuns() int {176 raw, _ := os.ReadFile(h.ytlog)177 return strings.Count(string(raw), "\n")178}179180// settle waits for a YouTube channel's refresh to end, so its files stay181// put.182func (h *harness) settle(number string) {183 h.t.Helper()184 for range 400 {185 busy := false186 for _, e := range h.lib.Entries() {187 if y, ok := e.Channel.(*vchan.YouTube); ok && y.Number() == number {188 busy = y.Status().Busy189 }190 }191 if !busy {192 return193 }194 time.Sleep(50 * time.Millisecond)195 }196 h.t.Fatalf("channel %s is still refreshing", number)197}198199// channel returns a channel from the state.200func (h *harness) channel(number string) map[string]any {201 h.t.Helper()202 _, state, raw := h.do("GET", "/admin/api/state", nil)203 for _, c := range state["channels"].([]any) {204 if c := c.(map[string]any); c["number"] == number {205 return c206 }207 }208 h.t.Fatalf("no channel %s in %s", number, raw)209 return nil210}211212func (h *harness) do(method, path string, body any) (int, map[string]any, string) {213 h.t.Helper()214 var rd io.Reader215 if body != nil {216 raw, _ := json.Marshal(body)217 rd = bytes.NewReader(raw)218 }219 req, _ := http.NewRequest(method, h.url+path, rd)220 resp, err := http.DefaultClient.Do(req)221 if err != nil {222 h.t.Fatal(err)223 }224 defer resp.Body.Close()225 raw, _ := io.ReadAll(resp.Body)226 var m map[string]any227 _ = json.Unmarshal(raw, &m)228 return resp.StatusCode, m, string(raw)229}230231func TestAdminChannelsAndAccount(t *testing.T) {232 h := newHarness(t)233234 _, state, _ := h.do("GET", "/admin/api/state", nil)235 if acct := state["account"].(map[string]any); acct["ok"] != false || acct["error"] == "" {236 t.Errorf("no account yet: %v", acct)237 }238 if chans := state["channels"].([]any); len(chans) != 1 || chans[0].(map[string]any)["kind"] != "folder" {239 t.Errorf("channels: %v", chans)240 }241242 code, acct, raw := h.do("PUT", "/admin/api/account", map[string]string{"server": h.jf.URL + "/", "user": "viewer", "password": "s3cret-pw"})243 if code != 200 || acct["ok"] != true || acct["hasPassword"] != true {244 t.Fatalf("account: %d %s", code, raw)245 }246 if strings.Contains(raw, "s3cret-pw") {247 t.Error("the password came back")248 }249 if info, err := os.Stat(filepath.Join(h.root, accountFile)); err != nil || info.Mode().Perm() != 0o600 {250 t.Errorf("account file: %v %v", info.Mode(), err)251 }252253 _, lib, _ := h.do("GET", "/admin/api/library/series", nil)254 items := lib["items"].([]any)255 if len(items) != 2 {256 t.Fatalf("series: %v", items)257 }258 fut := items[1].(map[string]any)259 if fut["key"] != "Futurama (1999)" || fut["image"] != "/admin/api/image/fut" {260 t.Errorf("futurama: %v", fut)261 }262263 code, ch, raw := h.do("PUT", "/admin/api/channels/new", map[string]any{264 "number": "1.4", "name": " Cartoons ", "series": []string{"Futurama (1999)"}, "movies": []string{"The Iron Giant (1999)"}, "order": "aired",265 })266 if code != 200 || ch["kind"] != "jellyfin" || ch["folder"] != "1.4 Cartoons" {267 t.Fatalf("create: %d %s", code, raw)268 }269 cfg, err := os.ReadFile(filepath.Join(h.root, "1.4 Cartoons", channelFile))270 if err != nil || !strings.Contains(string(cfg), `"Futurama (1999)"`) || !strings.Contains(string(cfg), `"aired"`) || strings.Contains(string(cfg), "collections") {271 t.Errorf("config: %s %v", cfg, err)272 }273274 if code, _, raw := h.do("PUT", "/admin/api/channels/new", map[string]any{"number": "1.2", "name": "Clash"}); code != http.StatusConflict {275 t.Errorf("taken number: %d %s", code, raw)276 }277 if code, _, raw := h.do("PUT", "/admin/api/channels/1.2", map[string]any{"number": "1.2", "name": "Cat Calming"}); code != http.StatusBadRequest {278 t.Errorf("editing a folder channel: %d %s", code, raw)279 }280 if code, _, _ := h.do("PUT", "/admin/api/channels/new", map[string]any{"number": "one", "name": "x"}); code != http.StatusBadRequest {281 t.Errorf("bad number: %d", code)282 }283284 code, ch, raw = h.do("PUT", "/admin/api/channels/1.4", map[string]any{"number": "1.5", "name": "Toons", "series": []string{"Futurama (1999)"}})285 if code != 200 || ch["folder"] != "1.5 Toons" {286 t.Fatalf("rename: %d %s", code, raw)287 }288 if _, err := os.Stat(filepath.Join(h.root, "1.4 Cartoons")); !os.IsNotExist(err) {289 t.Error("old folder is still there")290 }291292 _, sched, raw := h.do("GET", "/admin/api/channels/1.5/schedule", nil)293 if progs, _ := sched["programs"].([]any); len(progs) == 0 || progs[0].(map[string]any)["title"] != "Futurama" {294 t.Errorf("schedule: %s", raw)295 }296297 if code, _, _ := h.do("DELETE", "/admin/api/channels/1.5", nil); code != http.StatusNoContent {298 t.Errorf("delete: %d", code)299 }300 if _, err := os.Stat(filepath.Join(h.root, "1.5 Toons")); !os.IsNotExist(err) {301 t.Error("deleted channel's folder is still there")302 }303 if code, _, _ := h.do("DELETE", "/admin/api/channels/1.2", nil); code != http.StatusBadRequest {304 t.Errorf("deleting a folder channel: %d", code)305 }306}307308func TestAdminQuickConnect(t *testing.T) {309 h := newHarness(t)310 code, start, raw := h.do("POST", "/admin/api/account/quickconnect", map[string]string{"server": h.jf.URL})311 if code != 200 || start["code"] == "" || strings.Contains(raw, "secret") {312 t.Fatalf("start: %d %s", code, raw)313 }314 id := start["id"].(string)315 if _, st, _ := h.do("GET", "/admin/api/account/quickconnect/"+id, nil); st["state"] != "waiting" {316 t.Errorf("before approval: %v", st)317 }318 if !h.jf.Approve(start["code"].(string)) {319 t.Fatal("fake server doesn't know the code")320 }321 _, st, raw := h.do("GET", "/admin/api/account/quickconnect/"+id, nil)322 if st["state"] != "done" || st["account"].(map[string]any)["ok"] != true {323 t.Fatalf("after approval: %s", raw)324 }325 acct, err := os.ReadFile(filepath.Join(h.root, accountFile))326 if err != nil || !strings.Contains(string(acct), `"token"`) || strings.Contains(string(acct), "password") {327 t.Errorf("account file: %s %v", acct, err)328 }329 if _, st, _ := h.do("GET", "/admin/api/account/quickconnect/"+id, nil); st["state"] != "expired" {330 t.Errorf("a used code should be gone: %v", st)331 }332333 // The token signs in for browsing too.334 if _, lib, raw := h.do("GET", "/admin/api/library/movies", nil); len(lib["items"].([]any)) != 1 {335 t.Errorf("movies with the token: %s", raw)336 }337338 jf2 := jellyfintest.New(t, "x", "y")339 if code, _, _ := h.do("POST", "/admin/api/account/quickconnect", map[string]string{"server": jf2.URL}); code != http.StatusConflict {340 t.Errorf("quick connect off: %d", code)341 }342}343344// seedYouTube makes the live 1.8 Northernlion channel: its youtube.json and345// a catalog already listed, so nothing is due from yt-dlp. Each source's346// video j was uploaded j days ago.347func seedYouTube(t *testing.T, dir string) {348 t.Helper()349 if err := os.Mkdir(dir, 0o755); err != nil {350 t.Fatal(err)351 }352 cfg := `{"channels": ["https://www.youtube.com/@Northernlion", "https://www.youtube.com/@TheLibraryofLetourneau"], "repeatDays": 30, "maxHeight": 720, "lockHours": 3}`353 now := time.Now().UTC()354 type video struct {355 ID string `json:"id"`356 Title string `json:"title"`357 Seconds int `json:"seconds"`358 Looked time.Time `json:"looked"`359 Published time.Time `json:"published"`360 }361 type source struct {362 URL string `json:"url"`363 ID string `json:"id"`364 Name string `json:"name"`365 Listed time.Time `json:"listed"`366 Checked time.Time `json:"checked"`367 Dated bool `json:"dated"`368 Videos []video `json:"videos"`369 }370 var cat struct {371 Sources []source `json:"sources"`372 }373 for i, name := range []string{"Northernlion", "TheLibraryofLetourneau"} {374 s := source{URL: "https://www.youtube.com/@" + name + "/videos", ID: fmt.Sprintf("UC%022d", i), Name: name, Listed: now, Checked: now, Dated: true}375 for j := range 40 {376 // Every fourth is a Short, too short to air.377 s.Videos = append(s.Videos, video{fmt.Sprintf("%d-%02d", i, j), fmt.Sprintf("%s video %d", name, j), 60 + 540*min(j%4, 1), now,378 now.AddDate(0, 0, -j)})379 }380 cat.Sources = append(cat.Sources, s)381 }382 raw, _ := json.Marshal(cat)383 for name, data := range map[string][]byte{"youtube.json": []byte(cfg), ".catalog.json": raw} {384 if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {385 t.Fatal(err)386 }387 }388}389390func TestAdminYouTubeChannels(t *testing.T) {391 h := newHarness(t)392 seedYouTube(t, filepath.Join(h.root, "1.8 Northernlion"))393394 nl := h.channel("1.8")395 cfg, _ := nl["youtube"].(map[string]any)396 if nl["kind"] != "youtube" || cfg == nil || cfg["minMinutes"] != 3.0 || cfg["maxMinutes"] != 240.0 ||397 cfg["repeatDays"] != 30.0 || cfg["maxHeight"] != 720.0 || cfg["rerunMix"] != "balanced" || cfg["maxAgeDays"] != 0.0 ||398 cfg["deadAir"] != false || len(cfg["channels"].([]any)) != 2 {399 t.Fatalf("1.8: %v", nl)400 }401 srcs := nl["sources"].([]any)402 first := srcs[0].(map[string]any)403 if len(srcs) != 2 || first["name"] != "Northernlion" || first["handle"] != "@Northernlion" || first["url"] != "https://www.youtube.com/@Northernlion" ||404 first["videos"] != 40.0 || first["playable"] != 30.0 || first["listing"] != false || first["listed"] == nil || first["failed"] != nil {405 t.Errorf("sources: %v", srcs)406 }407 if nl["videos"] != 80.0 || nl["items"] != 60.0 || nl["listing"] != nil || nl["now"] == nil {408 t.Errorf("1.8 totals: %v", nl)409 }410 _, sched, raw := h.do("GET", "/admin/api/channels/1.8/schedule", nil)411 if progs, _ := sched["programs"].([]any); len(progs) == 0 || !strings.Contains(progs[0].(map[string]any)["title"].(string), " video ") {412 t.Errorf("schedule: %s", raw)413 }414415 // A new channel from two YouTube channels, one given twice.416 code, ch, raw := h.do("PUT", "/admin/api/channels/new", map[string]any{417 "kind": "youtube", "number": "1.9", "name": " DJ Sets ",418 "youtube": map[string]any{419 "channels": []string{"@boilerroom", "https://www.youtube.com/channel/UCJEKlziKdxoos1qbptjGgLg/videos?view=0", "youtube.com/@boilerroom/"},420 "minMinutes": 20, "maxMinutes": 240, "repeatDays": 14, "maxHeight": 1080, "rerunMix": "recent", "maxAgeDays": 90,421 "deadAir": true,422 },423 })424 if code != 200 || ch["kind"] != "youtube" || ch["folder"] != "1.9 DJ Sets" || len(ch["sources"].([]any)) != 2 {425 t.Fatalf("create: %d %s", code, raw)426 }427 var file map[string]any428 data, _ := os.ReadFile(filepath.Join(h.root, "1.9 DJ Sets", "youtube.json"))429 if err := json.Unmarshal(data, &file); err != nil {430 t.Fatal(err)431 }432 if want := []any{"https://www.youtube.com/@boilerroom", "https://www.youtube.com/channel/UCJEKlziKdxoos1qbptjGgLg"}; !slices.Equal(file["channels"].([]any), want) ||433 file["minMinutes"] != 20.0 || file["maxMinutes"] != 240.0 || file["repeatDays"] != 14.0 || file["maxHeight"] != 1080.0 || file["rerunMix"] != "recent" ||434 file["maxAgeDays"] != 90.0 || file["deadAir"] != true {435 t.Errorf("youtube.json: %s", data)436 }437 h.settle("1.9")438 dj := h.channel("1.9")439 for _, s := range dj["sources"].([]any) {440 if s := s.(map[string]any); s["videos"] != 30.0 || s["playable"] != 0.0 || s["listing"] != false || s["id"] == "" {441 t.Errorf("listed source: %v", s)442 }443 }444 if _, err := os.Stat(filepath.Join(h.root, "1.9 DJ Sets", ".catalog.json")); err != nil {445 t.Errorf("catalog not saved: %v", err)446 }447448 // Rename 1.8, change its sources and quality; the rest stays.449 code, ch, raw = h.do("PUT", "/admin/api/channels/1.8", map[string]any{450 "kind": "youtube", "number": "1.8", "name": "NL", "youtube": map[string]any{"channels": []string{"@Northernlion"}, "maxHeight": 480},451 })452 if code != 200 || ch["folder"] != "1.8 NL" || len(ch["sources"].([]any)) != 1 || ch["sources"].([]any)[0].(map[string]any)["videos"] != 40.0 {453 t.Fatalf("rename: %d %s", code, raw)454 }455 data, _ = os.ReadFile(filepath.Join(h.root, "1.8 NL", "youtube.json"))456 if !strings.Contains(string(data), `"lockHours": 3`) || !strings.Contains(string(data), `"maxHeight": 480`) ||457 !strings.Contains(string(data), `"repeatDays": 30`) || !strings.Contains(string(data), `"rerunMix": "balanced"`) {458 t.Errorf("edited youtube.json: %s", data)459 }460 if _, err := os.Stat(filepath.Join(h.root, "1.8 Northernlion")); !os.IsNotExist(err) {461 t.Error("old folder is still there")462 }463464 // A kind stays a kind.465 if code, _, raw := h.do("PUT", "/admin/api/channels/1.8", map[string]any{"number": "1.8", "name": "NL"}); code != http.StatusBadRequest ||466 !strings.Contains(raw, "can't become a Jellyfin one") {467 t.Errorf("YouTube to Jellyfin: %d %s", code, raw)468 }469 if code, _, raw := h.do("PUT", "/admin/api/channels/new", map[string]any{"number": "1.4", "name": "Cartoons"}); code != 200 {470 t.Fatalf("jellyfin channel: %d %s", code, raw)471 }472 if code, _, raw := h.do("PUT", "/admin/api/channels/1.4", map[string]any{"kind": "youtube", "number": "1.4", "name": "Cartoons",473 "youtube": map[string]any{"channels": []string{"@boilerroom"}}}); code != http.StatusBadRequest || !strings.Contains(raw, "can't become a YouTube one") {474 t.Errorf("Jellyfin to YouTube: %d %s", code, raw)475 }476 if code, _, _ := h.do("PUT", "/admin/api/channels/1.2", map[string]any{"kind": "youtube", "number": "1.2", "name": "Cat Calming",477 "youtube": map[string]any{"channels": []string{"@boilerroom"}}}); code != http.StatusBadRequest {478 t.Errorf("folder to YouTube: %d", code)479 }480 if code, _, _ := h.do("PUT", "/admin/api/channels/new", map[string]any{"kind": "youtube", "number": "1.9", "name": "Again",481 "youtube": map[string]any{"channels": []string{"@boilerroom"}}}); code != http.StatusConflict {482 t.Errorf("taken number: %d", code)483 }484485 // Delete both, hidden files and all.486 h.do("GET", "/admin/api/channels/1.8/schedule", nil)487 os.WriteFile(filepath.Join(h.root, "1.8 NL", ".playout.json.tmp"), []byte("{}"), 0o644)488 for _, f := range []string{".catalog.json", ".playout.json"} {489 if _, err := os.Stat(filepath.Join(h.root, "1.8 NL", f)); err != nil {490 t.Errorf("before deleting: %v", err)491 }492 }493 for _, n := range []string{"1.8", "1.9"} {494 h.settle(n)495 if code, _, raw := h.do("DELETE", "/admin/api/channels/"+n, nil); code != http.StatusNoContent {496 t.Errorf("delete %s: %d %s", n, code, raw)497 }498 }499 for _, f := range []string{"1.8 NL", "1.9 DJ Sets"} {500 if left, err := os.ReadDir(filepath.Join(h.root, f)); !os.IsNotExist(err) {501 t.Errorf("%s is still there: %v", f, left)502 }503 }504}505506// TestAdminYouTubeMaxAge: a maximum age narrows the videos that fit, as507// the sources' counts show, and with nothing new enough the channel goes508// off the air.509func TestAdminYouTubeMaxAge(t *testing.T) {510 h := newHarness(t)511 seedYouTube(t, filepath.Join(h.root, "1.8 Northernlion"))512 if first := h.channel("1.8")["sources"].([]any)[0].(map[string]any); first["playable"] != 30.0 {513 t.Fatalf("before: %v", first)514 }515 put := func(days float64) map[string]any {516 t.Helper()517 code, ch, raw := h.do("PUT", "/admin/api/channels/1.8", map[string]any{518 "kind": "youtube", "number": "1.8", "name": "Northernlion", "youtube": map[string]any{"maxAgeDays": days},519 })520 if code != 200 {521 t.Fatalf("maxAgeDays %v: %d %s", days, code, raw)522 }523 return ch524 }525 // Uploads 1 to 29 days old (the 30-day-old one is just past the limit526 // by now), less the Shorts (every fourth).527 ch := put(30)528 if cfg := ch["youtube"].(map[string]any); cfg["maxAgeDays"] != 30.0 {529 t.Errorf("settings: %v", cfg)530 }531 for _, s := range ch["sources"].([]any) {532 if s := s.(map[string]any); s["videos"] != 40.0 || s["playable"] != 22.0 {533 t.Errorf("source within 30 days: %v", s)534 }535 }536 if ch["items"] != 44.0 || ch["now"] == nil {537 t.Errorf("channel within 30 days: %v", ch)538 }539 data, _ := os.ReadFile(filepath.Join(h.root, "1.8 Northernlion", "youtube.json"))540 if !strings.Contains(string(data), `"maxAgeDays": 30`) || !strings.Contains(string(data), `"lockHours": 3`) {541 t.Errorf("youtube.json: %s", data)542 }543 // Nothing is half a day old.544 ch = put(0.5)545 if ch["items"] != 0.0 || ch["now"] != nil {546 t.Errorf("nothing new enough: items %v, now %v", ch["items"], ch["now"])547 }548 if code, sched, raw := h.do("GET", "/admin/api/channels/1.8/schedule", nil); code != 200 || len(sched["programs"].([]any)) != 0 {549 t.Errorf("schedule with nothing new enough: %d %s", code, raw)550 }551}552553func TestAdminYouTubeValidation(t *testing.T) {554 h := newHarness(t)555 eleven := make([]string, 11)556 for i := range eleven {557 eleven[i] = fmt.Sprintf("@channel%d", i)558 }559 for _, c := range []struct {560 yt map[string]any561 kind string562 want string563 }{564 {nil, "youtube", "at least one"},565 {map[string]any{"channels": []string{}}, "youtube", "at least one"},566 {map[string]any{"channels": eleven}, "youtube", "up to 10"},567 {map[string]any{"channels": []string{"https://vimeo.com/boilerroom"}}, "youtube", "isn't a YouTube channel"},568 {map[string]any{"channels": []string{"Boiler Room"}}, "youtube", "isn't a YouTube channel"},569 {map[string]any{"channels": []string{"https://youtu.be/dQw4w9WgXcQ"}}, "youtube", "link to a video"},570 {map[string]any{"channels": []string{"https://www.youtube.com/watch?v=dQw4w9WgXcQ"}}, "youtube", "link to a video"},571 {map[string]any{"channels": []string{"@boilerroom"}, "maxHeight": 900}, "youtube", "quality"},572 {map[string]any{"channels": []string{"@boilerroom"}, "repeatDays": 400}, "youtube", "repeat"},573 {map[string]any{"channels": []string{"@boilerroom"}, "minMinutes": -1}, "youtube", "shortest"},574 {map[string]any{"channels": []string{"@boilerroom"}, "minMinutes": 20, "maxMinutes": 10}, "youtube", "longest"},575 {map[string]any{"channels": []string{"@boilerroom"}, "rerunMix": "newest"}, "youtube", "reruns"},576 {map[string]any{"channels": []string{"@boilerroom"}, "maxAgeDays": -1}, "youtube", "3650 days"},577 {map[string]any{"channels": []string{"@boilerroom"}, "maxAgeDays": 4000}, "youtube", "3650 days"},578 {map[string]any{"channels": []string{"@boilerroom"}}, "vimeo", "kind"},579 } {580 code, _, raw := h.do("PUT", "/admin/api/channels/new", map[string]any{"kind": c.kind, "number": "1.9", "name": "DJ Sets", "youtube": c.yt})581 if code != http.StatusBadRequest || !strings.Contains(raw, c.want) {582 t.Errorf("%v: %d %s", c.yt, code, raw)583 }584 }585 if _, err := os.Stat(filepath.Join(h.root, "1.9 DJ Sets")); !os.IsNotExist(err) {586 t.Error("a refused channel made a folder")587 }588 for in, want := range map[string]string{589 "@Boiler.Room-1": "https://www.youtube.com/@Boiler.Room-1",590 "UCGBpxWJr9FNOcFYA5GkKrMg": "https://www.youtube.com/channel/UCGBpxWJr9FNOcFYA5GkKrMg",591 "https://m.youtube.com/@boilerroom/videos?x=1#top": "https://www.youtube.com/@boilerroom",592 "http://youtube.com/c/BoilerRoom/featured": "https://www.youtube.com/c/BoilerRoom",593 "www.youtube.com/user/boilerroomtv": "https://www.youtube.com/user/boilerroomtv",594 } {595 if got, err := youtubeChannel(in); got != want || err != nil {596 t.Errorf("youtubeChannel(%q) = %q, %v", in, got, err)597 }598 }599}600601func TestAdminYouTubeSearchAndLookup(t *testing.T) {602 h := newHarness(t)603 code, res, raw := h.do("GET", "/admin/api/youtube/search?q=boiler+room", nil)604 chans, _ := res["channels"].([]any)605 if code != 200 || len(chans) != 2 {606 t.Fatalf("search: %d %s", code, raw)607 }608 br := chans[0].(map[string]any)609 if br["id"] != "UCGBpxWJr9FNOcFYA5GkKrMg" || br["name"] != "Boiler Room" || br["handle"] != "@boilerroom" ||610 br["url"] != "https://www.youtube.com/@boilerroom" || br["subscribers"] != 5250000.0 ||611 !strings.HasPrefix(br["image"].(string), "https://yt3.ggpht.com/") || !strings.Contains(br["image"].(string), "=s176-") ||612 br["description"] != "Connecting club culture to the wider world, on screen and irl through parties, film and video." {613 t.Errorf("Boiler Room: %v", br)614 }615 // Search results come with descriptions cut short.616 if d := chans[1].(map[string]any)["description"].(string); !strings.HasSuffix(d, "במסחר…") {617 t.Errorf("cut short: %q", d)618 }619 if strings.Contains(raw, "- Topic") {620 t.Errorf("a Topic channel came back: %s", raw)621 }622 runs := h.ytRuns()623 if _, again, _ := h.do("GET", "/admin/api/youtube/search?q=Boiler%20%20Room", nil); len(again["channels"].([]any)) != 2 || h.ytRuns() != runs {624 t.Errorf("a repeat search ran yt-dlp again (%d runs, then %d)", runs, h.ytRuns())625 }626 if code, _, _ := h.do("GET", "/admin/api/youtube/search?q=+", nil); code != http.StatusBadRequest {627 t.Errorf("empty search: %d", code)628 }629 if code, _, raw := h.do("GET", "/admin/api/youtube/search?q=broken", nil); code != http.StatusBadGateway || !strings.Contains(raw, `"Sign in to confirm you're not a bot`) {630 t.Errorf("failed search: %d %s", code, raw)631 }632633 code, nl, raw := h.do("GET", "/admin/api/youtube/channel?u=%40Northernlion", nil)634 if code != 200 || nl["id"] != "UC3tNpTOHsTnkmbwztCs30sA" || nl["name"] != "Northernlion" || nl["handle"] != "@Northernlion" ||635 nl["url"] != "https://www.youtube.com/@Northernlion" || nl["subscribers"] != 1390000.0 || nl["videos"] != 20000.0 ||636 !strings.HasSuffix(nl["image"].(string), "=s176-c-k-c0x00ffffff-no-rj") || !strings.HasPrefix(nl["description"].(string), "Subscribe for new videos") {637 t.Fatalf("lookup: %d %s", code, raw)638 }639 runs = h.ytRuns()640 if _, again, _ := h.do("GET", "/admin/api/youtube/channel?u="+url.QueryEscape("https://www.youtube.com/@Northernlion/videos"), nil); again["id"] != nl["id"] || h.ytRuns() != runs {641 t.Error("a repeat lookup ran yt-dlp again")642 }643 if code, _, raw := h.do("GET", "/admin/api/youtube/channel?u=%40nosuch", nil); code != http.StatusBadGateway || !strings.Contains(raw, "YouTube has no such channel") {644 t.Errorf("no such channel: %d %s", code, raw)645 }646 for _, u := range []string{"https://youtu.be/dQw4w9WgXcQ", "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "boiler room"} {647 if code, _, _ := h.do("GET", "/admin/api/youtube/channel?u="+url.QueryEscape(u), nil); code != http.StatusBadRequest {648 t.Errorf("lookup of %q: %d", u, code)649 }650 }651652 // The state shows what lookups found.653 seedYouTube(t, filepath.Join(h.root, "1.8 Northernlion"))654 h.lib.Rescan()655 if s := h.channel("1.8")["sources"].([]any)[0].(map[string]any); s["image"] != nl["image"] {656 t.Errorf("source without the looked up avatar: %v", s)657 }658}659660func TestAdminYouTubeWithoutYtDlp(t *testing.T) {661 lib := &vchan.Library{Root: t.TempDir(), YtDlp: &youtube.YtDlp{Path: filepath.Join(t.TempDir(), "yt-dlp")}}662 srv := httptest.NewServer((&Server{Library: lib}).Handler())663 defer srv.Close()664 resp, err := http.Get(srv.URL + "/admin/api/youtube/search?q=dj+sets")665 if err != nil {666 t.Fatal(err)667 }668 raw, _ := io.ReadAll(resp.Body)669 resp.Body.Close()670 if resp.StatusCode != http.StatusServiceUnavailable || !strings.Contains(string(raw), "yt-dlp isn't installed") {671 t.Errorf("%d %s", resp.StatusCode, raw)672 }673}674675// TestAdminYouTubeLive searches YouTube and looks up a channel through676// the page's API with a real yt-dlp, when AIRWAVES_YTDLP names one.677func TestAdminYouTubeLive(t *testing.T) {678 tool := os.Getenv("AIRWAVES_YTDLP")679 if tool == "" {680 t.Skip("set AIRWAVES_YTDLP to a yt-dlp to search YouTube")681 }682 lib := &vchan.Library{Root: t.TempDir(), YtDlp: &youtube.YtDlp{Path: tool, Update: os.Getenv("AIRWAVES_YTDLP_UPDATE") != ""}}683 srv := httptest.NewServer((&Server{Library: lib}).Handler())684 defer srv.Close()685 get := func(path string) map[string]any {686 for {687 start := time.Now()688 resp, err := http.Get(srv.URL + path)689 if err != nil {690 t.Fatal(err)691 }692 raw, _ := io.ReadAll(resp.Body)693 resp.Body.Close()694 if resp.StatusCode == http.StatusServiceUnavailable && strings.Contains(string(raw), "being installed") {695 time.Sleep(2 * time.Second)696 continue697 }698 var m map[string]any699 json.Unmarshal(raw, &m)700 t.Logf("GET %s: %d in %s", path, resp.StatusCode, time.Since(start).Round(10*time.Millisecond))701 if resp.StatusCode != 200 {702 t.Fatalf("%s", raw)703 }704 return m705 }706 }707 show := func(c map[string]any) {708 t.Logf(" %-34s %-26v %10.0f subscribers videos %v\n %v\n %.90v", c["name"], c["handle"], c["subscribers"], c["videos"], c["image"], c["description"])709 }710 for _, q := range []string{"dj sets", "boiler room"} {711 res := get("/admin/api/youtube/search?q=" + url.QueryEscape(q))712 t.Logf("%q: %d channels", q, len(res["channels"].([]any)))713 for _, c := range res["channels"].([]any) {714 show(c.(map[string]any))715 }716 }717 for _, u := range []string{"@Northernlion", "https://www.youtube.com/@TheLibraryofLetourneau/videos", "UCGBpxWJr9FNOcFYA5GkKrMg"} {718 t.Logf("lookup %s:", u)719 show(get("/admin/api/youtube/channel?u=" + url.QueryEscape(u)))720 }721}