ytdlp_live_test.go
| 1 | // These tests run the real yt-dlp against a real video. They exist to catch |
| 2 | // yt-dlp version drift in the few places VidArchive depends on its behaviour: |
| 3 | // the -J JSON field names, --flat-playlist printing, and the output layout |
| 4 | // produced by -P/-o plus --write-info-json. |
| 5 | // |
| 6 | // They are opt-in because they need network access and take time. Run them with: |
| 7 | // |
| 8 | // VIDARCHIVE_ONLINE_TESTS=1 go test ./internal/service -run Live -v |
| 9 | // |
| 10 | // Override the video with VIDARCHIVE_TEST_VIDEO_URL. |
| 11 | |
| 12 | package service |
| 13 | |
| 14 | import ( |
| 15 | "context" |
| 16 | "net/url" |
| 17 | "os" |
| 18 | "os/exec" |
| 19 | "path/filepath" |
| 20 | "testing" |
| 21 | "time" |
| 22 | |
| 23 | "vidarchive/internal/models" |
| 24 | ) |
| 25 | |
| 26 | // defaultLiveVideoURL is a 19 second public video, small enough to download. |
| 27 | const defaultLiveVideoURL = "https://www.youtube.com/watch?v=jNQXAC9IVRw" |
| 28 | |
| 29 | // liveEnv returns an execEnv wired to the real yt-dlp, the test video URL, and |
| 30 | // the video id expected for it. It skips the test unless the online tests are |
| 31 | // enabled and yt-dlp is installed. |
| 32 | func liveEnv(t *testing.T) (*execEnv, string, string) { |
| 33 | t.Helper() |
| 34 | |
| 35 | if os.Getenv("VIDARCHIVE_ONLINE_TESTS") != "1" { |
| 36 | t.Skip("online test: set VIDARCHIVE_ONLINE_TESTS=1 to run it") |
| 37 | } |
| 38 | ytdlp, err := exec.LookPath("yt-dlp") |
| 39 | if err != nil { |
| 40 | t.Skipf("online test: yt-dlp not found on PATH: %v", err) |
| 41 | } |
| 42 | |
| 43 | videoURL := os.Getenv("VIDARCHIVE_TEST_VIDEO_URL") |
| 44 | if videoURL == "" { |
| 45 | videoURL = defaultLiveVideoURL |
| 46 | } |
| 47 | // The id comes from the URL, so an override still knows what to expect. |
| 48 | parsed, err := url.Parse(videoURL) |
| 49 | if err != nil { |
| 50 | t.Fatalf("parse test video URL %q: %v", videoURL, err) |
| 51 | } |
| 52 | videoID := parsed.Query().Get("v") |
| 53 | if videoID == "" { |
| 54 | t.Fatalf("test video URL %q has no v parameter", videoURL) |
| 55 | } |
| 56 | |
| 57 | e := newExecEnv(t) |
| 58 | e.cfg.YTDLPPath = ytdlp |
| 59 | return e, videoURL, videoID |
| 60 | } |
| 61 | |
| 62 | // Checks the -J JSON field names the format picker reads. |
| 63 | func TestLiveListFormats(t *testing.T) { |
| 64 | e, videoURL, _ := liveEnv(t) |
| 65 | |
| 66 | formats, err := e.svc.ListFormats(t.Context(), videoURL) |
| 67 | if err != nil { |
| 68 | t.Fatalf("ListFormats: %v", err) |
| 69 | } |
| 70 | if len(formats) == 0 { |
| 71 | t.Fatal("ListFormats returned no formats") |
| 72 | } |
| 73 | for _, f := range formats { |
| 74 | if f.ID == "" || f.Ext == "" { |
| 75 | t.Fatalf("format with empty id or ext: %+v", f) |
| 76 | } |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // Checks --flat-playlist --print %(id)s, which subscription pruning relies on. |
| 81 | func TestLiveEnumeratePlaylistIDs(t *testing.T) { |
| 82 | e, videoURL, videoID := liveEnv(t) |
| 83 | |
| 84 | ctx, cancel := context.WithTimeout(context.Background(), time.Minute) |
| 85 | defer cancel() |
| 86 | |
| 87 | ids, err := e.svc.enumeratePlaylistIDs(ctx, videoURL) |
| 88 | if err != nil { |
| 89 | t.Fatalf("enumeratePlaylistIDs: %v", err) |
| 90 | } |
| 91 | if !ids[videoID] { |
| 92 | t.Errorf("ids = %v, want them to contain %q", ids, videoID) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | // Checks the full download path: --newline, -P/-o with %(autonumber), the |
| 97 | // info.json sidecar, and the import that turns it into a library item. |
| 98 | func TestLiveExecuteDownload(t *testing.T) { |
| 99 | e, videoURL, videoID := liveEnv(t) |
| 100 | |
| 101 | preset := &models.Preset{Name: "Live", WriteInfoJSON: true} |
| 102 | if err := e.svc.presetSvc.Create(preset); err != nil { |
| 103 | t.Fatalf("create preset: %v", err) |
| 104 | } |
| 105 | |
| 106 | // "wa/w" is worst audio, else worst overall: the smallest thing yt-dlp can |
| 107 | // give us, so the test stays cheap. |
| 108 | d := e.queue(t, &models.Download{ |
| 109 | URL: videoURL, |
| 110 | PresetID: sqlNullInt64(preset.ID), |
| 111 | FormatOverride: "wa/w", |
| 112 | }) |
| 113 | |
| 114 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) |
| 115 | defer cancel() |
| 116 | |
| 117 | processed, err := e.svc.ExecuteDownload(ctx, d) |
| 118 | if err != nil { |
| 119 | t.Fatalf("ExecuteDownload: %v", err) |
| 120 | } |
| 121 | if !processed { |
| 122 | t.Error("processed = false, want true") |
| 123 | } |
| 124 | if got := e.status(t, d.ID); got.Status != "completed" { |
| 125 | t.Fatalf("status = %q, want completed; logs:\n%s", got.Status, got.Logs.String) |
| 126 | } |
| 127 | |
| 128 | names := libraryEntries(t, e.cfg.LibraryDir) |
| 129 | if len(names) != 1 { |
| 130 | t.Fatalf("library = %v, want exactly one item", names) |
| 131 | } |
| 132 | itemDir := filepath.Join(e.cfg.LibraryDir, names[0]) |
| 133 | |
| 134 | if _, err := os.Stat(filepath.Join(itemDir, itemMarkerName)); err != nil { |
| 135 | t.Errorf("item marker missing: %v", err) |
| 136 | } |
| 137 | infoPath := filepath.Join(itemDir, "info.json") |
| 138 | if _, err := os.Stat(infoPath); err != nil { |
| 139 | t.Fatalf("info.json missing: %v", err) |
| 140 | } |
| 141 | |
| 142 | info := readInfoJSON(infoPath) |
| 143 | if info.ID != videoID { |
| 144 | t.Errorf("info.json id = %q, want %q", info.ID, videoID) |
| 145 | } |
| 146 | if info.Title == "" { |
| 147 | t.Error("info.json title is empty") |
| 148 | } |
| 149 | if info.WebpageURL == "" { |
| 150 | t.Error("info.json webpage_url is empty") |
| 151 | } |
| 152 | } |
| 153 |