//go:build yt package commands import ( "os" "path/filepath" "runtime" "strings" "testing" "time" "github.com/photoprism/photoprism/internal/photoprism/dl" "github.com/photoprism/photoprism/internal/photoprism/get" ) // createFakeYtDlp writes a small script that: // - prints JSON when --dump-single-json (metadata) // - parses --output TEMPLATE and on --print creates a dummy file at TEMPLATE // with %(id)s -> abc and %(ext)s -> mp4, then prints the path func createFakeYtDlp(t *testing.T) string { t.Helper() // Prefer the app's TempPath to avoid CI environments where OS /tmp is mounted noexec. base := "" if c := get.Config(); c != nil { base = c.TempPath() } if base == "" { base = t.TempDir() } else { if err := os.MkdirAll(base, 0o755); err != nil { t.Fatalf("failed to create base temp dir: %v", err) } } dir, derr := os.MkdirTemp(base, "ydlp_") if derr != nil { t.Fatalf("failed to create temp dir: %v", derr) } path := filepath.Join(dir, "yt-dlp") if runtime.GOOS == "windows" { // Not needed in CI/dev container. Keep simple stub. content := "@echo off\r\n" + "if not \"%YTDLP_ARGS_LOG%\"==\"\" echo %* >> %YTDLP_ARGS_LOG%\r\n" + "for %%A in (%*) do (\r\n" + " if \"%%~A\"==\"--version\" ( echo 2025.09.23 & goto :eof )\r\n" + " if \"%%~A\"==\"--dump-single-json\" ( echo {\"id\":\"abc\",\"title\":\"Test\",\"url\":\"http://example.com\",\"_type\":\"video\"} & goto :eof )\r\n" + ")\r\n" if err := os.WriteFile(path, []byte(content), 0o755); err != nil { t.Fatalf("failed to write fake yt-dlp: %v", err) } return path } var b strings.Builder b.WriteString("#!/usr/bin/env bash\n") b.WriteString("set -euo pipefail\n") b.WriteString("ARGS_LOG=\"${YTDLP_ARGS_LOG:-}\"\n") b.WriteString("if [[ -n \"$ARGS_LOG\" ]]; then echo \"$*\" >> \"$ARGS_LOG\"; fi\n") b.WriteString("for a in \"$@\"; do if [[ \"$a\" == \"--version\" ]]; then echo '2025.09.23'; exit 0; fi; done\n") b.WriteString("OUT_TPL=\"\"\n") b.WriteString("i=0; while [[ $i -lt $# ]]; do i=$((i+1)); arg=\"${!i}\"; if [[ \"$arg\" == \"--dump-single-json\" ]]; then echo '{\"id\":\"abc\",\"title\":\"Test\",\"url\":\"http://example.com\",\"_type\":\"video\"}'; exit 0; fi; if [[ \"$arg\" == \"--output\" ]]; then i=$((i+1)); OUT_TPL=\"${!i}\"; fi; done\n") b.WriteString("if [[ $* == *'--print '* ]]; then OUT=\"$OUT_TPL\"; OUT=${OUT//%(id)s/abc}; OUT=${OUT//%(ext)s/mp4}; mkdir -p \"$(dirname \"$OUT\")\"; CONTENT=\"${YTDLP_DUMMY_CONTENT:-dummy}\"; echo \"$CONTENT\" > \"$OUT\"; echo \"$OUT\"; exit 0; fi\n") if err := os.WriteFile(path, []byte(b.String()), 0o755); err != nil { t.Fatalf("failed to write fake yt-dlp: %v", err) } return path } func TestDownloadImpl_FileMethod_AutoSkipsRemux(t *testing.T) { // Ensure our fake script runs via shell even on noexec mounts. t.Setenv("YTDLP_FORCE_SHELL", "1") // Prefer using in-process fake to avoid exec restrictions. t.Setenv("YTDLP_FAKE", "1") dl.ResetVersionWarningForTest() fake := createFakeYtDlp(t) orig := dl.YtDlpBin defer func() { dl.YtDlpBin = orig }() dest := "dl-e2e" // Force ffmpeg to an invalid path; with remux=auto the remux should be skipped for mp4 if c := get.Config(); c != nil { c.Options().FFmpegBin = "/bin/false" // Disable convert (thumb generation) to avoid ffmpeg dependency in test s := c.Settings() s.Index.Convert = false } conf := get.Config() if conf == nil { t.Fatalf("missing test config") } // Ensure DB is initialized and registered (bypassing CLI InitConfig) conf.RegisterDb() // Override yt-dlp after config init (config may set dl.YtDlpBin) dl.YtDlpBin = fake t.Logf("using yt-dlp binary: %s", dl.YtDlpBin) // Execute the implementation core directly err := runDownload(conf, DownloadOpts{ Dest: dest, Method: "file", FileRemux: "auto", }, []string{"https://example.com/video"}) if err != nil { t.Fatalf("runDownload failed (auto should skip remux): %v", err) } // Cleanup destination folder (best effort) if c := get.Config(); c != nil { outDir := filepath.Join(c.OriginalsPath(), dest) _ = os.RemoveAll(outDir) } } func TestDownloadImpl_FileMethod_Skip_NoRemux(t *testing.T) { // Ensure our fake script runs via shell even on noexec mounts. t.Setenv("YTDLP_FORCE_SHELL", "1") // Prefer using in-process fake to avoid exec restrictions. t.Setenv("YTDLP_FAKE", "1") dl.ResetVersionWarningForTest() fake := createFakeYtDlp(t) orig := dl.YtDlpBin defer func() { dl.YtDlpBin = orig }() dest := "dl-e2e-skip" // Ensure different file content so duplicate detection won't collapse into prior test's file t.Setenv("YTDLP_DUMMY_CONTENT", "dummy2") if c := get.Config(); c != nil { c.Options().FFmpegBin = "/bin/false" // would fail if remux attempted s := c.Settings() s.Index.Convert = false } conf := get.Config() if conf == nil { t.Fatalf("missing test config") } conf.RegisterDb() dl.YtDlpBin = fake if err := runDownload(conf, DownloadOpts{ Dest: dest, Method: "file", FileRemux: "skip", }, []string{"https://example.com/video"}); err != nil { t.Fatalf("runDownload failed with skip remux: %v", err) } // Verify an mp4 exists under Originals/dest. On some filesystems (e.g., // Windows/CI or slow containers) directory listings can lag slightly after // moves. Poll briefly to avoid flakes. c := get.Config() outDir := filepath.Join(c.OriginalsPath(), dest) var found bool deadline := time.Now().Add(2 * time.Second) for !found && time.Now().Before(deadline) { _ = filepath.WalkDir(outDir, func(path string, d os.DirEntry, err error) error { if err != nil || d == nil { return nil } if !d.IsDir() && strings.HasSuffix(strings.ToLower(d.Name()), ".mp4") { found = true return filepath.SkipDir } return nil }) if !found { time.Sleep(50 * time.Millisecond) } } if !found { // Help debugging by listing the directory tree. var listing []string _ = filepath.WalkDir(outDir, func(path string, d os.DirEntry, err error) error { if err == nil && d != nil { rel, _ := filepath.Rel(outDir, path) if rel == "." { rel = d.Name() } listing = append(listing, rel) } return nil }) t.Fatalf("expected at least one mp4 in %s; found: %v", outDir, listing) } _ = os.RemoveAll(outDir) } func TestDownloadImpl_FileMethod_Always_RemuxFails(t *testing.T) { // Ensure our fake script runs via shell even on noexec mounts. t.Setenv("YTDLP_FORCE_SHELL", "1") // Prefer using in-process fake to avoid exec restrictions. t.Setenv("YTDLP_FAKE", "1") dl.ResetVersionWarningForTest() fake := createFakeYtDlp(t) orig := dl.YtDlpBin defer func() { dl.YtDlpBin = orig }() dest := "dl-e2e-always" if c := get.Config(); c != nil { c.Options().FFmpegBin = "/bin/false" // force remux failure when called s := c.Settings() s.Index.Convert = false } conf := get.Config() if conf == nil { t.Fatalf("missing test config") } conf.RegisterDb() dl.YtDlpBin = fake err := runDownload(conf, DownloadOpts{ Dest: dest, Method: "file", FileRemux: "always", }, []string{"https://example.com/video"}) if err == nil { t.Fatalf("expected failure when remux is required but ffmpeg is unavailable") } // Cleanup destination folder if anything was created c := get.Config() outDir := filepath.Join(c.OriginalsPath(), dest) _ = os.RemoveAll(outDir) }