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 / podcast / podcast_test.go
podcast_test.go 15.3K
1package podcast23import (4 "context"5 "encoding/json"6 "errors"7 "fmt"8 "io"9 "net/http"10 "os"11 "path/filepath"12 "slices"13 "strings"14 "testing"15 "time"1617 "airwaves/internal/youtube"18)1920// TestMain lets the test binary stand in for yt-dlp: run with21// AIRWAVES_FAKE_YTDLP set, it answers like yt-dlp would.22func TestMain(m *testing.M) {23 if os.Getenv("AIRWAVES_FAKE_YTDLP") != "" {24 os.Exit(fakeYtDlp(os.Args[1:]))25 }26 lookupGap = 027 os.Exit(m.Run())28}2930// fakeYtDlp answers yt-dlp's command line, noting each run's arguments in31// the file AIRWAVES_FAKE_YTDLP names. Show A (@showa) has three episodes,32// a clip and a premiere to come in its videos (and a fourth episode, new,33// with AIRWAVES_FAKE_NEW set), and a past and a current stream in its34// streams; Show B is a playlist with a private video. Any35// video's details give it an hour, but "feedclip" lasts 200 seconds; with36// a format asked for, an H.264 picture and AAC sound six hours long.37func fakeYtDlp(args []string) int {38 if len(args) == 1 && args[0] == "--version" {39 fmt.Println("2026.08.19")40 return 041 }42 if f, err := os.OpenFile(os.Getenv("AIRWAVES_FAKE_YTDLP"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {43 fmt.Fprintln(f, strings.Join(args, " "))44 f.Close()45 }46 u := args[len(args)-1]47 now := time.Now().Unix()48 day := int64(86400)49 var out any50 switch {51 case u == "https://www.youtube.com/@showa/videos":52 out = map[string]any{"channel": "Show A", "channel_id": "UCa", "uploader_id": "@showa",53 "thumbnails": []map[string]any{{"url": "https://yt3.example/a=s900-c-k", "width": 900, "height": 900}},54 "entries": []map[string]any{55 {"id": "ep3", "title": "Episode 3", "duration": 3600, "timestamp": now - day},56 {"id": "clip1", "title": "A clip", "duration": 300, "timestamp": now - day},57 {"id": "ep2", "title": "Episode 2", "duration": 3000, "timestamp": now - 2*day},58 {"id": "up1", "title": "Premiere", "live_status": "is_upcoming"},59 {"id": "ep1", "title": "Episode 1", "duration": 4000, "timestamp": now - 3*day},60 }}61 if os.Getenv("AIRWAVES_FAKE_NEW") != "" {62 out.(map[string]any)["entries"] = append([]map[string]any{{"id": "ep4", "title": "Episode 4", "duration": 3600, "timestamp": now}},63 out.(map[string]any)["entries"].([]map[string]any)...)64 }65 case u == "https://www.youtube.com/@showa/streams":66 out = map[string]any{"channel": "Show A", "channel_id": "UCa", "entries": []map[string]any{67 {"id": "now1", "title": "Live now", "live_status": "is_live"},68 {"id": "live1", "title": "Last night's show", "duration": 7200, "timestamp": now - day, "live_status": "was_live"},69 }}70 case strings.Contains(u, "playlist?list=PLtest00000001"):71 out = map[string]any{"_type": "playlist", "id": "PLtest00000001", "title": "Show B", "channel": "B", "channel_id": "UCb",72 "entries": []map[string]any{73 {"id": "pb1", "title": "B 1", "duration": 3600, "timestamp": now - 5*day},74 {"id": "pbx", "title": "[Private video]"},75 {"id": "pb2", "title": "B 2", "duration": 3600, "timestamp": now - day},76 }}77 case strings.Contains(u, "playlist?list=PLtest00000002"):78 out = map[string]any{"_type": "playlist", "id": "PLtest00000002", "title": "Every Episode", "channel": "TheShowC", "channel_id": "UCc",79 "entries": []map[string]any{80 {"id": "pc0"},81 {"id": "pc1", "title": "C 1", "duration": 3600, "timestamp": now - day},82 }}83 case strings.Contains(u, "results?search_query="):84 out = map[string]any{"entries": []map[string]any{{"channel_id": "UCa", "channel": "Show A", "uploader_id": "@showa",85 "channel_follower_count": 1000, "description": "A show."}}}86 case strings.Contains(u, "watch?v="):87 id := u[strings.Index(u, "v=")+2:]88 info := map[string]any{"id": id, "title": "Looked up " + id, "duration": 3600, "timestamp": now - 3600,89 "description": "About " + id + "\n\nhttps://example.com/sponsor", "live_status": "not_live", "media_type": "video"}90 if id == "feedclip" {91 info["duration"] = 20092 }93 if id == "ep2" {94 info["automatic_captions"] = map[string]any{"en": []map[string]any{{"ext": "vtt", "url": "https://example.com/ep2.en.vtt"}}}95 }96 if slices.Contains(args, "-f") {97 exp := now + 6*360098 info["requested_formats"] = []map[string]any{99 {"url": fmt.Sprintf("https://example.com/%s.mp4?expire=%d", id, exp), "vcodec": "avc1.640028", "acodec": "none", "height": 1080},100 {"url": fmt.Sprintf("https://example.com/%s.m4a?expire=%d", id, exp), "vcodec": "none", "acodec": "mp4a.40.2"},101 }102 }103 out = info104 default:105 fmt.Fprintln(os.Stderr, "ERROR: fake yt-dlp doesn't know "+u)106 return 1107 }108 json.NewEncoder(os.Stdout).Encode(out)109 return 0110}111112// youtubeStub stands in for YouTube's feeds and pictures: Show A's feed has a new113// upload, a Short and a clip, and an episode it has; only ep3 has a114// maxresdefault picture.115type youtubeStub struct{}116117func (youtubeStub) RoundTrip(r *http.Request) (*http.Response, error) {118 body, code := "", http.StatusNotFound119 switch u := r.URL.String(); {120 case u == youtube.FeedURL("UCa"):121 at := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)122 body, code = `<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015" xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">123<entry><yt:videoId>feednew</yt:videoId><title>New one</title><link href="https://www.youtube.com/watch?v=feednew"/><published>`+at+`</published><media:group><media:description>Fresh</media:description></media:group></entry>124<entry><yt:videoId>feedshort</yt:videoId><title>A Short</title><link href="https://www.youtube.com/shorts/feedshort"/><published>`+at+`</published></entry>125<entry><yt:videoId>feedclip</yt:videoId><title>A clip</title><link href="https://www.youtube.com/watch?v=feedclip"/><published>`+at+`</published></entry>126<entry><yt:videoId>ep3</yt:videoId><title>Episode 3</title><link href="https://www.youtube.com/watch?v=ep3"/><published>`+at+`</published><media:group><media:description>From the feed</media:description></media:group></entry>127</feed>`, http.StatusOK128 case u == "https://i.ytimg.com/vi/ep3/maxresdefault.jpg":129 code = http.StatusOK130 case u == "https://example.com/ep2.en.vtt":131 body, code = "WEBVTT\n\n00:09:58.000 --> 00:10:03.000\nbefore and after\n\n00:10:30.000 --> 00:10:32.000\nlater on\n", http.StatusOK132 }133 return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil134}135136// library is a library in a test's folder, running the fake yt-dlp, whose137// runs are logged to the file it returns.138func library(t *testing.T) (*Library, string) {139 t.Helper()140 dir := t.TempDir()141 calls := filepath.Join(dir, "yt-dlp.log")142 t.Setenv("AIRWAVES_FAKE_YTDLP", calls)143 return &Library{Path: filepath.Join(dir, "podcasts.json"),144 YtDlp: &youtube.YtDlp{Path: os.Args[0], HTTP: &http.Client{Transport: youtubeStub{}}}}, calls145}146147func ids(eps []Episode) []string {148 out := []string{}149 for _, e := range eps {150 out = append(out, e.ID)151 }152 return out153}154155func TestAddChannel(t *testing.T) {156 l, calls := library(t)157 s, err := l.Add(context.Background(), "https://www.youtube.com/@showa", 0)158 if err != nil {159 t.Fatal(err)160 }161 if s.Title != "Show A" || s.URL != "https://www.youtube.com/@showa/videos" || s.MinMinutes != DefaultMinMinutes ||162 s.Thumbnail != "https://yt3.example/a=s176-c-k" {163 t.Errorf("show: %+v", s)164 }165 // The clip is too short, the premiere not yet on.166 if got := ids(s.Episodes); !slices.Equal(got, []string{"ep3", "ep2", "ep1"}) {167 t.Errorf("episodes: %v", got)168 }169 if e := s.Episodes[1]; e.ShowID != s.ID || e.Duration != 3000 || e.Thumbnail != youtube.Thumbnail("ep2") || e.Published.IsZero() {170 t.Errorf("episode: %+v", e)171 }172 raw, _ := os.ReadFile(calls)173 if !strings.Contains(string(raw), "youtubetab:approximate_date -I 1:40 https://www.youtube.com/@showa/videos") {174 t.Errorf("listing: %s", raw)175 }176 // Again, by handle: the same show.177 again, err := l.Add(context.Background(), "@showa", 20)178 if err != nil || again.ID != s.ID || len(l.State().Shows) != 1 {179 t.Errorf("again: %+v %v", again, err)180 }181}182183func TestAddStreamsAndPlaylist(t *testing.T) {184 l, _ := library(t)185 s, err := l.Add(context.Background(), "youtube.com/@showa/streams/", 30)186 if err != nil {187 t.Fatal(err)188 }189 if got := ids(s.Episodes); s.URL != "https://www.youtube.com/@showa/streams" || !slices.Equal(got, []string{"live1"}) {190 t.Errorf("streams: %s %v", s.URL, got)191 }192 p, err := l.Add(context.Background(), "https://www.youtube.com/playlist?list=PLtest00000001", 0)193 if err != nil {194 t.Fatal(err)195 }196 // Newest first, without the private video.197 if got := ids(p.Episodes); p.Title != "Show B" || !slices.Equal(got, []string{"pb2", "pb1"}) || p.Thumbnail != youtube.Thumbnail("pb1") {198 t.Errorf("playlist: %+v %v", p, got)199 }200 if st := l.State(); len(st.Shows) != 2 || st.Shows[0].ID != s.ID {201 t.Errorf("state: %+v", st)202 }203 // A playlist titled for what it holds is named for its channel; a video204 // without a title yet isn't an episode.205 c, err := l.Add(context.Background(), "https://www.youtube.com/playlist?list=PLtest00000002", 0)206 if err != nil {207 t.Fatal(err)208 }209 if got := ids(c.Episodes); c.Title != "The Show C" || !slices.Equal(got, []string{"pc1"}) {210 t.Errorf("generic playlist: %q %v", c.Title, got)211 }212}213214func TestBadShows(t *testing.T) {215 l, _ := library(t)216 for _, raw := range []string{"", "https://www.youtube.com/watch?v=abc", "https://youtu.be/abc", "https://www.youtube.com/@showa/podcasts", "https://example.com/feed"} {217 if _, err := l.Add(context.Background(), raw, 0); !errors.Is(err, ErrBadShow) {218 t.Errorf("%q: %v", raw, err)219 }220 }221}222223// An update reads the channel's feed: its new upload joins, its Short and224// clip don't, and the episode it has gets the feed's date. New videos are225// looked up, and the newest unwatched episode found ahead.226func TestUpdate(t *testing.T) {227 l, calls := library(t)228 s, err := l.Add(context.Background(), "@showa", 0)229 if err != nil {230 t.Fatal(err)231 }232 l.update(context.Background())233 got := l.State().Shows[0]234 if ids := ids(got.Episodes); !slices.Equal(ids, []string{"feednew", "ep3", "ep2", "ep1"}) {235 t.Fatalf("episodes: %v", ids)236 }237 fresh, ep3 := got.Episodes[0], got.Episodes[1]238 if fresh.Title != "Looked up feednew" || fresh.Duration != 3600 || fresh.Description != "About feednew" || fresh.Thumbnail != youtube.Thumbnail("feednew") {239 t.Errorf("new upload: %+v", fresh)240 }241 if ep3.Thumbnail != "https://i.ytimg.com/vi/ep3/maxresdefault.jpg" {242 t.Errorf("ep3: %+v", ep3)243 }244 raw, _ := os.ReadFile(calls)245 if strings.Contains(string(raw), "watch?v=feedshort") {246 t.Errorf("the Short was looked up: %s", raw)247 }248 if !strings.Contains(string(raw), "-f "+youtube.FormatSpec(maxHeight)+" https://www.youtube.com/watch?v=feednew") {249 t.Errorf("not found ahead: %s", raw)250 }251 // The clip was looked up and left out for good.252 l.mu.Lock()253 skipped := slices.Clone(l.find(s.ID).Skipped)254 l.mu.Unlock()255 if !slices.Contains(skipped, "feedclip") || !slices.Contains(skipped, "feedshort") || !slices.Contains(skipped, "clip1") {256 t.Errorf("skipped: %v", skipped)257 }258}259260// Refresh lists every show again at once, a new upload joining, and261// neither it nor Add waits behind yt-dlp's other listing jobs, which can262// take minutes.263func TestRefresh(t *testing.T) {264 l, _ := library(t)265 if _, err := l.Add(context.Background(), "@showa", 0); err != nil {266 t.Fatal(err)267 }268 if _, err := l.Add(context.Background(), "https://www.youtube.com/playlist?list=PLtest00000001", 0); err != nil {269 t.Fatal(err)270 }271 before := l.State()272 if before.Listed.IsZero() {273 t.Fatal("never listed")274 }275 t.Setenv("AIRWAVES_FAKE_NEW", "1")276 l.YtDlp.Jobs.Lock() // a channel's whole catalog, being listed277 defer l.YtDlp.Jobs.Unlock()278 done := make(chan State)279 go func() {280 st := l.Refresh(context.Background())281 if _, err := l.Add(context.Background(), "https://www.youtube.com/playlist?list=PLtest00000002", 0); err != nil {282 t.Error(err)283 }284 done <- st285 }()286 var st State287 select {288 case st = <-done:289 case <-time.After(30 * time.Second):290 t.Fatal("waited behind the listing job")291 }292 if got := ids(st.Shows[0].Episodes); !slices.Equal(got, []string{"ep4", "ep3", "ep2", "ep1"}) {293 t.Errorf("episodes: %v", got)294 }295 if !st.Listed.After(before.Listed) || st.Updating {296 t.Errorf("listed %v, was %v; updating %v", st.Listed, before.Listed, st.Updating)297 }298 if n := len(l.State().Shows); n != 3 {299 t.Errorf("%d shows", n)300 }301}302303func TestProgressPersists(t *testing.T) {304 l, _ := library(t)305 if _, err := l.Add(context.Background(), "@showa", 0); err != nil {306 t.Fatal(err)307 }308 if err := l.Progress("ep2", 120, 0, false); err != nil {309 t.Fatal(err)310 }311 if err := l.Progress("nope", 1, 1, false); !errors.Is(err, ErrNotFound) {312 t.Errorf("unknown episode: %v", err)313 }314 again := &Library{Path: l.Path, YtDlp: l.YtDlp}315 e := again.State().Shows[0].Episodes[1]316 if e.ID != "ep2" || e.Position != 120 || e.Watched {317 t.Errorf("after reopening: %+v", e)318 }319 // Within a minute of the end is watched.320 if err := again.Progress("ep2", 2950, 3000, false); err != nil {321 t.Fatal(err)322 }323 if e := again.State().Shows[0].Episodes[1]; !e.Watched {324 t.Errorf("near the end: %+v", e)325 }326 if err := again.Progress("ep2", 0, 0, false); err != nil || again.State().Shows[0].Episodes[1].Watched {327 t.Errorf("marked unwatched: %v", err)328 }329}330331// Playing an episode streams its H.264 picture and AAC sound from YouTube332// from where it's asked to, copied.333func TestInput(t *testing.T) {334 l, _ := library(t)335 if _, err := l.Add(context.Background(), "@showa", 0); err != nil {336 t.Fatal(err)337 }338 in, err := l.Input(context.Background(), "ep2", 600)339 if err != nil {340 t.Fatal(err)341 }342 args := strings.Join(in.Args, " ")343 if !strings.Contains(args, "-ss 600.000 -i https://example.com/ep2.mp4") || !strings.Contains(args, "-ss 600.000 -i https://example.com/ep2.m4a") {344 t.Errorf("args: %s", args)345 }346 if !in.VOD || !in.Copy || in.Offset != 600 || in.Video != "0:v:0" || len(in.Audio) != 1 || in.Audio[0].Map != "1:a:0" {347 t.Errorf("input: %+v", in)348 }349 // Its captions, timed from where it starts: the one showing then is350 // clipped there.351 if in.Subtitles == nil {352 t.Fatal("no captions")353 }354 cues := in.Subtitles.Between(0, time.Hour)355 if len(cues) != 2 || cues[0].Start != 0 || cues[0].End != 3*time.Second || cues[1].Start != 30*time.Second || cues[1].Text != "later on" {356 t.Errorf("captions: %+v", cues)357 }358 if in, err := l.Input(context.Background(), "ep2", 3); err != nil || in.Offset != 0 || strings.Contains(strings.Join(in.Args, " "), "-ss") {359 t.Errorf("from the start: %+v %v", in, err)360 }361 if _, err := l.Input(context.Background(), "nope", 0); !errors.Is(err, ErrNotFound) {362 t.Errorf("unknown episode: %v", err)363 }364}365366func TestSearchAndRemove(t *testing.T) {367 l, _ := library(t)368 found, err := l.Search(context.Background(), "show a")369 if err != nil || len(found) != 1 || found[0].ID != "UCa" || found[0].URL != "https://www.youtube.com/@showa" || found[0].Subscribers != 1000 {370 t.Errorf("search: %+v %v", found, err)371 }372 if _, err := l.Search(context.Background(), " "); !errors.Is(err, ErrBadShow) {373 t.Errorf("empty search: %v", err)374 }375 s, err := l.Add(context.Background(), found[0].URL, 0)376 if err != nil {377 t.Fatal(err)378 }379 if err := l.Remove(s.ID); err != nil || len(l.State().Shows) != 0 {380 t.Errorf("remove: %v", err)381 }382 if err := l.Remove(s.ID); !errors.Is(err, ErrNotFound) {383 t.Errorf("remove again: %v", err)384 }385}386387func TestSpaced(t *testing.T) {388 for in, want := range map[string]string{"TheStandupPod": "The Standup Pod", "Sad Boyz": "Sad Boyz", "mkbhd": "mkbhd", "nerd-snipe": "nerd-snipe", "SMN": "SMN"} {389 if got := spaced(in); got != want {390 t.Errorf("spaced(%q) = %q, want %q", in, got, want)391 }392 }393}