// These tests run the real yt-dlp against a real video. They exist to catch // yt-dlp version drift in the few places VidArchive depends on its behaviour: // the -J JSON field names, --flat-playlist printing, and the output layout // produced by -P/-o plus --write-info-json. // // They are opt-in because they need network access and take time. Run them with: // // VIDARCHIVE_ONLINE_TESTS=1 go test ./internal/service -run Live -v // // Override the video with VIDARCHIVE_TEST_VIDEO_URL. package service import ( "context" "net/url" "os" "os/exec" "path/filepath" "testing" "time" "vidarchive/internal/models" ) // defaultLiveVideoURL is a 19 second public video, small enough to download. const defaultLiveVideoURL = "https://www.youtube.com/watch?v=jNQXAC9IVRw" // liveEnv returns an execEnv wired to the real yt-dlp, the test video URL, and // the video id expected for it. It skips the test unless the online tests are // enabled and yt-dlp is installed. func liveEnv(t *testing.T) (*execEnv, string, string) { t.Helper() if os.Getenv("VIDARCHIVE_ONLINE_TESTS") != "1" { t.Skip("online test: set VIDARCHIVE_ONLINE_TESTS=1 to run it") } ytdlp, err := exec.LookPath("yt-dlp") if err != nil { t.Skipf("online test: yt-dlp not found on PATH: %v", err) } videoURL := os.Getenv("VIDARCHIVE_TEST_VIDEO_URL") if videoURL == "" { videoURL = defaultLiveVideoURL } // The id comes from the URL, so an override still knows what to expect. parsed, err := url.Parse(videoURL) if err != nil { t.Fatalf("parse test video URL %q: %v", videoURL, err) } videoID := parsed.Query().Get("v") if videoID == "" { t.Fatalf("test video URL %q has no v parameter", videoURL) } e := newExecEnv(t) e.cfg.YTDLPPath = ytdlp return e, videoURL, videoID } // Checks the -J JSON field names the format picker reads. func TestLiveListFormats(t *testing.T) { e, videoURL, _ := liveEnv(t) formats, entry, err := e.svc.ListFormats(t.Context(), videoURL) if err != nil { t.Fatalf("ListFormats: %v", err) } if entry != "" { t.Errorf("single video URL reported playlist entry %q", entry) } if len(formats) == 0 { t.Fatal("ListFormats returned no formats") } for _, f := range formats { if f.ID == "" || f.Ext == "" { t.Fatalf("format with empty id or ext: %+v", f) } } } // Checks --flat-playlist --print %(id)s, which subscription pruning relies on. func TestLiveEnumeratePlaylistIDs(t *testing.T) { e, videoURL, videoID := liveEnv(t) ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() ids, err := e.svc.enumeratePlaylistIDs(ctx, videoURL) if err != nil { t.Fatalf("enumeratePlaylistIDs: %v", err) } if !ids[videoID] { t.Errorf("ids = %v, want them to contain %q", ids, videoID) } } // Checks the full download path: --newline, -P/-o with %(autonumber), the // info.json sidecar, and the import that turns it into a library item. func TestLiveExecuteDownload(t *testing.T) { e, videoURL, videoID := liveEnv(t) preset := &models.Preset{Name: "Live", WriteInfoJSON: true} if err := e.svc.presetSvc.Save(preset); err != nil { t.Fatalf("create preset: %v", err) } // "wa/w" is worst audio, else worst overall: the smallest thing yt-dlp can // give us, so the test stays cheap. d := e.queue(t, &models.Download{ URL: videoURL, PresetID: sqlNullInt64(preset.ID), FormatOverride: "wa/w", }) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() processed, err := e.svc.ExecuteDownload(ctx, d) if err != nil { t.Fatalf("ExecuteDownload: %v", err) } if !processed { t.Error("processed = false, want true") } if got := e.status(t, d.ID); got.Status != "completed" { t.Fatalf("status = %q, want completed; logs:\n%s", got.Status, got.Logs.String) } names := libraryEntries(t, e.cfg.LibraryDir) if len(names) != 1 { t.Fatalf("library = %v, want exactly one item", names) } itemDir := filepath.Join(e.cfg.LibraryDir, names[0]) if _, err := os.Stat(filepath.Join(itemDir, itemMarkerName)); err != nil { t.Errorf("item marker missing: %v", err) } infoPath := filepath.Join(itemDir, "info.json") if _, err := os.Stat(infoPath); err != nil { t.Fatalf("info.json missing: %v", err) } info := readInfoJSON(infoPath) if info.ID != videoID { t.Errorf("info.json id = %q, want %q", info.ID, videoID) } if info.Title == "" { t.Error("info.json title is empty") } if info.WebpageURL == "" { t.Error("info.json webpage_url is empty") } }