CLI: Add "photoprism dl --format-sort" flag and dl-method env variable

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-09-26 15:51:50 +02:00
parent cbda4abe7a
commit 44f561b7d6
10 changed files with 470 additions and 46 deletions

View File

@@ -71,6 +71,11 @@ var DownloadCommand = &cli.Command{
Value: "auto",
Usage: "remux `POLICY` for videos when using --dl-method file: auto (skip if MP4), always, or skip",
},
&cli.StringFlag{
Name: "format-sort",
Aliases: []string{"s"},
Usage: "custom FORMAT sort expression passed to yt-dlp",
},
},
Action: downloadAction,
}
@@ -145,12 +150,18 @@ func downloadAction(ctx *cli.Context) error {
cookies := strings.TrimSpace(ctx.String("cookies"))
// cookiesFromBrowser := strings.TrimSpace(ctx.String("cookies-from-browser"))
addHeaders := ctx.StringSlice("add-header")
method := strings.ToLower(strings.TrimSpace(ctx.String("dl-method")))
if method == "" {
method = "pipe"
flagMethod := ""
if ctx.IsSet("dl-method") {
flagMethod = ctx.String("dl-method")
}
if method != "pipe" && method != "file" {
return fmt.Errorf("invalid --dl-method: %s (expected 'pipe' or 'file')", method)
method, _, err := resolveDownloadMethod(flagMethod)
if err != nil {
return err
}
formatSort := strings.TrimSpace(ctx.String("format-sort"))
sortingFormat := formatSort
if sortingFormat == "" && method == "pipe" {
sortingFormat = pipeSortingFormat
}
fileRemux := strings.ToLower(strings.TrimSpace(ctx.String("file-remux")))
if fileRemux == "" {
@@ -201,7 +212,7 @@ func downloadAction(ctx *cli.Context) error {
opt := dl.Options{
MergeOutputFormat: fs.VideoMp4.String(),
RemuxVideo: fs.VideoMp4.String(),
SortingFormat: "lang,quality,res,fps,codec:avc:m4a,channels,size,br,asr,proto,ext,hasaud,source,id",
SortingFormat: sortingFormat,
Cookies: cookies,
AddHeaders: addHeaders,
}
@@ -209,6 +220,9 @@ func downloadAction(ctx *cli.Context) error {
result, err := dl.NewMetadata(context.Background(), u.String(), opt)
if err != nil {
log.Errorf("metadata failed: %v", err)
if hint, ok := missingFormatsHint(err); ok {
log.Info(hint)
}
failures++
continue
}

View File

@@ -40,7 +40,9 @@ func createFakeYtDlp(t *testing.T) string {
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 {
@@ -51,6 +53,9 @@ func createFakeYtDlp(t *testing.T) string {
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")
@@ -65,6 +70,7 @@ func TestDownloadImpl_FileMethod_AutoSkipsRemux(t *testing.T) {
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 }()
@@ -110,6 +116,7 @@ func TestDownloadImpl_FileMethod_Skip_NoRemux(t *testing.T) {
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 }()
@@ -182,6 +189,7 @@ func TestDownloadImpl_FileMethod_Always_RemuxFails(t *testing.T) {
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 }()

View File

@@ -0,0 +1,171 @@
package commands
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/photoprism/photoprism/internal/photoprism/dl"
"github.com/photoprism/photoprism/internal/photoprism/get"
)
func createArgsLoggingYtDlp(t *testing.T) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "yt-dlp")
if runtime.GOOS == "windows" {
var b strings.Builder
b.WriteString("@echo off\r\n")
b.WriteString("if not \"%YTDLP_ARGS_LOG%\"==\"\" echo %* >> %YTDLP_ARGS_LOG%\r\n")
b.WriteString("for %%A in (%*) do (\r\n")
b.WriteString(" if \"%%~A\"==\"--version\" ( echo 2025.09.23 & goto :eof )\r\n")
b.WriteString(" if \"%%~A\"==\"--dump-single-json\" ( echo {\"id\":\"abc\",\"title\":\"Test\",\"url\":\"http://example.com\",\"_type\":\"video\"} & goto :eof )\r\n")
b.WriteString(")\r\n")
if err := os.WriteFile(path, []byte(b.String()), 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\" == \"--output\" ]]; then i=$((i+1)); OUT_TPL=\"${!i}\"; fi; done\n")
b.WriteString("for a in \"$@\"; do if [[ \"$a\" == \"--dump-single-json\" ]]; then echo '{\"id\":\"abc\",\"title\":\"Test\",\"url\":\"http://example.com\",\"_type\":\"video\"}'; exit 0; fi; done\n")
b.WriteString("for a in \"$@\"; do if [[ \"$a\" == \"--print\" ]]; then OUT=\"$OUT_TPL\"; OUT=${OUT//%(id)s/abc}; OUT=${OUT//%(ext)s/mp4}; mkdir -p \"$(dirname \"$OUT\")\"; CONTENT=\"${YTDLP_DUMMY_CONTENT:-dummy}\"; printf \"%s\" \"$CONTENT\" > \"$OUT\"; echo \"$OUT\"; exit 0; fi; done\n")
b.WriteString("echo '[download]' 1>&2\n")
b.WriteString("echo 'DATA'\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 TestRunDownload_FileMethod_OmitsFormatSort(t *testing.T) {
t.Setenv("YTDLP_FORCE_SHELL", "1")
argsLog := filepath.Join(t.TempDir(), "args.log")
t.Setenv("YTDLP_ARGS_LOG", argsLog)
outDir := t.TempDir()
outFile := filepath.Join(outDir, "ppdl_test.mp4")
t.Setenv("YTDLP_OUTPUT_FILE", outFile)
t.Setenv("YTDLP_DUMMY_CONTENT", "quality-test")
dl.ResetVersionWarningForTest()
fake := createArgsLoggingYtDlp(t)
origBin := dl.YtDlpBin
dl.YtDlpBin = fake
defer func() { dl.YtDlpBin = origBin }()
conf := get.Config()
if conf == nil {
t.Fatalf("missing test config")
}
conf.RegisterDb()
// Avoid background ffmpeg work that could interfere with the test environment.
opt := conf.Options()
origFFmpeg := opt.FFmpegBin
opt.FFmpegBin = "/bin/false"
settings := conf.Settings()
origConvert := settings.Index.Convert
settings.Index.Convert = false
dest := "dl-quality"
if err := runDownload(conf, DownloadOpts{
Dest: dest,
Method: "file",
FileRemux: "skip",
}, []string{"https://example.com/video"}); err != nil {
t.Fatalf("runDownload failed: %v", err)
}
// Ensure Originals cleanup so subsequent tests stay isolated.
t.Cleanup(func() {
_ = os.RemoveAll(filepath.Join(conf.OriginalsPath(), dest))
settings.Index.Convert = origConvert
opt.FFmpegBin = origFFmpeg
})
// Give the logging script a moment to flush in slower environments.
time.Sleep(20 * time.Millisecond)
data, err := os.ReadFile(argsLog)
if err != nil {
t.Fatalf("reading args log failed: %v", err)
}
content := string(data)
if !strings.Contains(content, "--print") {
t.Fatalf("expected file download invocation to be logged, got: %q", content)
}
if strings.Contains(content, "--format-sort") {
t.Fatalf("file method should not pass --format-sort; args: %q", content)
}
}
func TestRunDownload_FileMethod_WithFormatSort(t *testing.T) {
t.Setenv("YTDLP_FORCE_SHELL", "1")
argsLog := filepath.Join(t.TempDir(), "args.log")
t.Setenv("YTDLP_ARGS_LOG", argsLog)
outDir := t.TempDir()
outFile := filepath.Join(outDir, "ppdl_fmt.mp4")
t.Setenv("YTDLP_OUTPUT_FILE", outFile)
t.Setenv("YTDLP_DUMMY_CONTENT", "quality-test-override")
dl.ResetVersionWarningForTest()
fake := createArgsLoggingYtDlp(t)
origBin := dl.YtDlpBin
dl.YtDlpBin = fake
defer func() { dl.YtDlpBin = origBin }()
conf := get.Config()
if conf == nil {
t.Fatalf("missing test config")
}
conf.RegisterDb()
opt := conf.Options()
origFFmpeg := opt.FFmpegBin
opt.FFmpegBin = "/bin/false"
settings := conf.Settings()
origConvert := settings.Index.Convert
settings.Index.Convert = false
dest := "dl-format-sort"
if err := runDownload(conf, DownloadOpts{
Dest: dest,
Method: "file",
FileRemux: "skip",
FormatSort: "res,fps,size",
}, []string{"https://example.com/video"}); err != nil {
t.Fatalf("runDownload failed with custom format-sort: %v", err)
}
t.Cleanup(func() {
_ = os.RemoveAll(filepath.Join(conf.OriginalsPath(), dest))
settings.Index.Convert = origConvert
opt.FFmpegBin = origFFmpeg
})
time.Sleep(20 * time.Millisecond)
data, err := os.ReadFile(argsLog)
if err != nil {
t.Fatalf("reading args log failed: %v", err)
}
content := string(data)
if !strings.Contains(content, "--format-sort") {
t.Fatalf("expected --format-sort to be passed; args: %q", content)
}
if !strings.Contains(content, "res,fps,size") {
t.Fatalf("expected custom format-sort expression to be present; args: %q", content)
}
}

View File

@@ -10,10 +10,11 @@ func TestDownloadCommand_HelpFlagsAndArgs(t *testing.T) {
}
// Verify new flags are present by name
want := map[string]bool{
"cookies": false,
"add-header": false,
"dl-method": false,
"file-remux": false,
"cookies": false,
"add-header": false,
"dl-method": false,
"file-remux": false,
"format-sort": false,
}
for _, f := range DownloadCommand.Flags {
name := f.Names()[0]

View File

@@ -22,6 +22,8 @@ import (
"github.com/photoprism/photoprism/pkg/service/http/scheme"
)
const pipeSortingFormat = "lang,quality,res,fps,codec:avc:m4a,channels,size,br,asr,proto,ext,hasaud,source,id"
// DownloadOpts contains the command options used by runDownload.
type DownloadOpts struct {
Dest string
@@ -30,6 +32,7 @@ type DownloadOpts struct {
AddHeaders []string
Method string // pipe|file
FileRemux string // always|auto|skip
FormatSort string
}
// runDownload executes the download/import flow for the given inputs and options.
@@ -46,6 +49,10 @@ func runDownload(conf *config.Config, opts DownloadOpts, inputURLs []string) err
return fmt.Errorf("no download URLs provided")
}
if msg, ok := dl.VersionWarning(); ok {
log.Info(msg)
}
// Resolve destination folder
destFolder := opts.Dest
if destFolder == "" {
@@ -62,12 +69,14 @@ func runDownload(conf *config.Config, opts DownloadOpts, inputURLs []string) err
defer os.RemoveAll(downloadPath)
// Normalize method/remux policy
method := strings.ToLower(strings.TrimSpace(opts.Method))
if method == "" {
method = "pipe"
method, _, err := resolveDownloadMethod(opts.Method)
if err != nil {
return err
}
if method != "pipe" && method != "file" {
return fmt.Errorf("invalid method: %s", method)
sortingFormat := strings.TrimSpace(opts.FormatSort)
if sortingFormat == "" && method == "pipe" {
sortingFormat = pipeSortingFormat
}
fileRemux := strings.ToLower(strings.TrimSpace(opts.FileRemux))
if fileRemux == "" {
@@ -118,7 +127,7 @@ func runDownload(conf *config.Config, opts DownloadOpts, inputURLs []string) err
opt := dl.Options{
MergeOutputFormat: fs.VideoMp4.String(),
RemuxVideo: fs.VideoMp4.String(),
SortingFormat: "lang,quality,res,fps,codec:avc:m4a,channels,size,br,asr,proto,ext,hasaud,source,id",
SortingFormat: sortingFormat,
Cookies: opts.Cookies,
CookiesFromBrowser: opts.CookiesFromBrowser,
AddHeaders: opts.AddHeaders,
@@ -126,6 +135,9 @@ func runDownload(conf *config.Config, opts DownloadOpts, inputURLs []string) err
result, err := dl.NewMetadata(context.Background(), u.String(), opt)
if err != nil {
log.Errorf("metadata failed: %v", err)
if hint, ok := missingFormatsHint(err); ok {
log.Info(hint)
}
failures++
continue
}
@@ -225,3 +237,16 @@ func runDownload(conf *config.Config, opts DownloadOpts, inputURLs []string) err
log.Infof("completed in %s", elapsed)
return nil
}
func missingFormatsHint(err error) (string, bool) {
if err == nil {
return "", false
}
lower := strings.ToLower(err.Error())
if strings.Contains(lower, "requested format is not available") {
return "yt-dlp did not receive playable formats. Try downloading via yt-dlp --list-formats, or pass authenticated cookies with --cookies <file> so YouTube exposes video/audio streams.", true
}
return "", false
}

View File

@@ -0,0 +1,56 @@
package commands
import (
"testing"
"github.com/photoprism/photoprism/internal/photoprism/dl"
)
func TestMissingFormatsHint(t *testing.T) {
hint, ok := missingFormatsHint(dl.YoutubedlError("Requested format is not available. Use --list-formats for a list of available formats"))
if !ok {
t.Fatalf("expected hint for missing formats error")
}
if hint == "" {
t.Fatalf("hint should not be empty")
}
if _, ok := missingFormatsHint(dl.YoutubedlError("some other error")); ok {
t.Fatalf("unexpected hint for unrelated error")
}
}
func TestResolveDownloadMethodEnv(t *testing.T) {
t.Setenv(downloadMethodEnv, "FILE")
method, fromEnv, err := resolveDownloadMethod("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if method != "file" {
t.Fatalf("expected file, got %s", method)
}
if !fromEnv {
t.Fatalf("expected value to originate from env")
}
}
func TestResolveDownloadMethodInvalidEnv(t *testing.T) {
t.Setenv(downloadMethodEnv, "weird")
if _, _, err := resolveDownloadMethod(""); err == nil {
t.Fatalf("expected error for invalid env method")
}
}
func TestResolveDownloadMethodFlagTakesPriority(t *testing.T) {
t.Setenv(downloadMethodEnv, "file")
method, fromEnv, err := resolveDownloadMethod("pipe")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if method != "pipe" {
t.Fatalf("expected pipe, got %s", method)
}
if fromEnv {
t.Fatalf("did not expect env to be used when flag provided")
}
}

View File

@@ -0,0 +1,41 @@
package commands
import (
"fmt"
"os"
"strings"
)
const downloadMethodEnv = "PHOTOPRISM_DL_METHOD"
// resolveDownloadMethod normalizes the download method by honoring the explicit flag
// value first, then the environment variable PHOTOPRISM_DL_METHOD, and finally
// defaulting to "pipe". It returns the resolved method, a boolean indicating whether
// the value originated from the environment, or an error when the input is invalid.
func resolveDownloadMethod(flagValue string) (string, bool, error) {
trimmed := strings.TrimSpace(flagValue)
method := strings.ToLower(trimmed)
fromEnv := false
if method == "" {
envValue := strings.TrimSpace(os.Getenv(downloadMethodEnv))
if envValue != "" {
method = strings.ToLower(envValue)
trimmed = envValue
fromEnv = true
}
}
if method == "" {
return "pipe", false, nil
}
if method != "pipe" && method != "file" {
if fromEnv {
return "", true, fmt.Errorf("invalid %s value: %s (expected 'pipe' or 'file')", downloadMethodEnv, trimmed)
}
return "", false, fmt.Errorf("invalid download method: %s (expected 'pipe' or 'file')", trimmed)
}
return method, fromEnv, nil
}

View File

@@ -45,3 +45,5 @@ It currently supports two invocation methods:
- Prefer the file method for sources with separate audio/video streams; the pipe method cannot always merge in that case.
- When the CLIs `--file-remux=auto` is used, the final ffmpeg remux is skipped for MP4 outputs that already include metadata.
- Keep `yt-dlp` updated. Releases older than `2025.09.23` are known to miss YouTube video formats (SABR gating); the CLI now logs a warning when it detects an outdated build.
- Users who favor one approach can set `PHOTOPRISM_DL_METHOD=file` (or `pipe`) in the environment to change the default without touching CLI flags.

View File

@@ -1,19 +1,91 @@
package dl
import (
"context"
"bytes"
"os"
"os/exec"
"strings"
"sync"
"time"
)
// Version of youtube-dl.
// Might be a good idea to call at start to assert that youtube-dl can be found.
func Version(ctx context.Context) (string, error) {
cmd := exec.CommandContext(ctx, FindYtDlpBin(), "--version")
versionBytes, cmdErr := cmd.Output()
if cmdErr != nil {
return "", cmdErr
const minYtDlpVersion = "2025.09.23"
var (
versionOnce sync.Once
versionWarning string
)
// VersionWarning returns a warning message when the detected yt-dlp version is older
// than the minimum recommended release. The check runs at most once per process.
func VersionWarning() (string, bool) {
versionOnce.Do(func() {
if os.Getenv("YTDLP_FAKE") == "1" {
return
}
bin := FindYtDlpBin()
if bin == "" {
return
}
cmd := exec.Command(bin, "--version")
cmd.Env = os.Environ()
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return
}
first := strings.Fields(out.String())
if len(first) == 0 {
return
}
ver := first[0]
if olderThan(ver, minYtDlpVersion) {
versionWarning = "Detected yt-dlp " + ver + ". Please update to " + minYtDlpVersion + " or newer so YouTube videos expose playable formats."
}
})
if versionWarning == "" {
return "", false
}
return versionWarning, true
}
func olderThan(current, minimum string) bool {
if current == "" {
return false
}
return strings.TrimSpace(string(versionBytes)), nil
c := truncateVersion(current)
m := truncateVersion(minimum)
if len(c) != len("2006.01.02") || len(m) != len("2006.01.02") {
return false
}
ct, err1 := time.Parse("2006.01.02", c)
mt, err2 := time.Parse("2006.01.02", m)
if err1 != nil || err2 != nil {
// fall back to lexical comparison which works for yyyy.mm.dd
return c < m
}
return ct.Before(mt)
}
func truncateVersion(v string) string {
if len(v) >= len("2006.01.02") {
return v[:len("2006.01.02")]
}
return v
}
// ResetVersionWarningForTest resets the cached version warning; intended for tests only.
func ResetVersionWarningForTest() {
versionOnce = sync.Once{}
versionWarning = ""
}

View File

@@ -1,32 +1,66 @@
package dl
import (
"context"
"regexp"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestVersion(t *testing.T) {
t.Run("Success", func(t *testing.T) {
versionRe := regexp.MustCompile(`^\d{4}\.\d{2}.\d{2}.*$`)
version, versionErr := Version(context.Background())
func TestVersionWarning_OldVersion(t *testing.T) {
ResetVersionWarningForTest()
if versionErr != nil {
t.Fatalf("err: %s", versionErr)
}
bin := writeVersionScript(t, "2025.09.05")
orig := YtDlpBin
YtDlpBin = bin
defer func() { YtDlpBin = orig }()
if !versionRe.MatchString(version) {
t.Errorf("version %q does not match %q", version, versionRe)
}
})
t.Run("InvalidBin", func(t *testing.T) {
defer func(orig string) { YtDlpBin = orig }(YtDlpBin)
YtDlpBin = "/non-existing"
os.Unsetenv("YTDLP_FAKE")
_, versionErr := Version(context.Background())
if versionErr == nil || !strings.Contains(versionErr.Error(), "no such file or directory") {
t.Fatalf("err should be nil 'no such file or directory': %v", versionErr)
}
})
msg, ok := VersionWarning()
if !ok {
t.Fatalf("expected warning for old version")
}
if !strings.Contains(msg, minYtDlpVersion) {
t.Fatalf("warning missing minimum version: %s", msg)
}
}
func TestVersionWarning_NewEnough(t *testing.T) {
ResetVersionWarningForTest()
bin := writeVersionScript(t, "2025.09.23")
orig := YtDlpBin
YtDlpBin = bin
defer func() { YtDlpBin = orig }()
os.Unsetenv("YTDLP_FAKE")
if _, ok := VersionWarning(); ok {
t.Fatalf("did not expect warning for up-to-date version")
}
}
func writeVersionScript(t *testing.T, version string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "yt-dlp")
if runtime.GOOS == "windows" {
content := "@echo off\r\n" +
"echo " + version + "\r\n"
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatalf("failed to write fake yt-dlp: %v", err)
}
return path
}
content := "#!/usr/bin/env bash\n" +
"set -euo pipefail\n" +
"echo '" + version + "'\n"
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatalf("failed to write fake yt-dlp: %v", err)
}
return path
}