mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }()
|
||||
|
||||
171
internal/commands/download_format_test.go
Normal file
171
internal/commands/download_format_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
56
internal/commands/download_impl_test.go
Normal file
56
internal/commands/download_impl_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
41
internal/commands/download_method.go
Normal file
41
internal/commands/download_method.go
Normal 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
|
||||
}
|
||||
@@ -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 CLI’s `--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.
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user