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",
|
Value: "auto",
|
||||||
Usage: "remux `POLICY` for videos when using --dl-method file: auto (skip if MP4), always, or skip",
|
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,
|
Action: downloadAction,
|
||||||
}
|
}
|
||||||
@@ -145,12 +150,18 @@ func downloadAction(ctx *cli.Context) error {
|
|||||||
cookies := strings.TrimSpace(ctx.String("cookies"))
|
cookies := strings.TrimSpace(ctx.String("cookies"))
|
||||||
// cookiesFromBrowser := strings.TrimSpace(ctx.String("cookies-from-browser"))
|
// cookiesFromBrowser := strings.TrimSpace(ctx.String("cookies-from-browser"))
|
||||||
addHeaders := ctx.StringSlice("add-header")
|
addHeaders := ctx.StringSlice("add-header")
|
||||||
method := strings.ToLower(strings.TrimSpace(ctx.String("dl-method")))
|
flagMethod := ""
|
||||||
if method == "" {
|
if ctx.IsSet("dl-method") {
|
||||||
method = "pipe"
|
flagMethod = ctx.String("dl-method")
|
||||||
}
|
}
|
||||||
if method != "pipe" && method != "file" {
|
method, _, err := resolveDownloadMethod(flagMethod)
|
||||||
return fmt.Errorf("invalid --dl-method: %s (expected 'pipe' or 'file')", method)
|
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")))
|
fileRemux := strings.ToLower(strings.TrimSpace(ctx.String("file-remux")))
|
||||||
if fileRemux == "" {
|
if fileRemux == "" {
|
||||||
@@ -201,7 +212,7 @@ func downloadAction(ctx *cli.Context) error {
|
|||||||
opt := dl.Options{
|
opt := dl.Options{
|
||||||
MergeOutputFormat: fs.VideoMp4.String(),
|
MergeOutputFormat: fs.VideoMp4.String(),
|
||||||
RemuxVideo: 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,
|
Cookies: cookies,
|
||||||
AddHeaders: addHeaders,
|
AddHeaders: addHeaders,
|
||||||
}
|
}
|
||||||
@@ -209,6 +220,9 @@ func downloadAction(ctx *cli.Context) error {
|
|||||||
result, err := dl.NewMetadata(context.Background(), u.String(), opt)
|
result, err := dl.NewMetadata(context.Background(), u.String(), opt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("metadata failed: %v", err)
|
log.Errorf("metadata failed: %v", err)
|
||||||
|
if hint, ok := missingFormatsHint(err); ok {
|
||||||
|
log.Info(hint)
|
||||||
|
}
|
||||||
failures++
|
failures++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ func createFakeYtDlp(t *testing.T) string {
|
|||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
// Not needed in CI/dev container. Keep simple stub.
|
// Not needed in CI/dev container. Keep simple stub.
|
||||||
content := "@echo off\r\n" +
|
content := "@echo off\r\n" +
|
||||||
|
"if not \"%YTDLP_ARGS_LOG%\"==\"\" echo %* >> %YTDLP_ARGS_LOG%\r\n" +
|
||||||
"for %%A in (%*) do (\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" +
|
" if \"%%~A\"==\"--dump-single-json\" ( echo {\"id\":\"abc\",\"title\":\"Test\",\"url\":\"http://example.com\",\"_type\":\"video\"} & goto :eof )\r\n" +
|
||||||
")\r\n"
|
")\r\n"
|
||||||
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
||||||
@@ -51,6 +53,9 @@ func createFakeYtDlp(t *testing.T) string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("#!/usr/bin/env bash\n")
|
b.WriteString("#!/usr/bin/env bash\n")
|
||||||
b.WriteString("set -euo pipefail\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("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("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")
|
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")
|
t.Setenv("YTDLP_FORCE_SHELL", "1")
|
||||||
// Prefer using in-process fake to avoid exec restrictions.
|
// Prefer using in-process fake to avoid exec restrictions.
|
||||||
t.Setenv("YTDLP_FAKE", "1")
|
t.Setenv("YTDLP_FAKE", "1")
|
||||||
|
dl.ResetVersionWarningForTest()
|
||||||
fake := createFakeYtDlp(t)
|
fake := createFakeYtDlp(t)
|
||||||
orig := dl.YtDlpBin
|
orig := dl.YtDlpBin
|
||||||
defer func() { dl.YtDlpBin = orig }()
|
defer func() { dl.YtDlpBin = orig }()
|
||||||
@@ -110,6 +116,7 @@ func TestDownloadImpl_FileMethod_Skip_NoRemux(t *testing.T) {
|
|||||||
t.Setenv("YTDLP_FORCE_SHELL", "1")
|
t.Setenv("YTDLP_FORCE_SHELL", "1")
|
||||||
// Prefer using in-process fake to avoid exec restrictions.
|
// Prefer using in-process fake to avoid exec restrictions.
|
||||||
t.Setenv("YTDLP_FAKE", "1")
|
t.Setenv("YTDLP_FAKE", "1")
|
||||||
|
dl.ResetVersionWarningForTest()
|
||||||
fake := createFakeYtDlp(t)
|
fake := createFakeYtDlp(t)
|
||||||
orig := dl.YtDlpBin
|
orig := dl.YtDlpBin
|
||||||
defer func() { dl.YtDlpBin = orig }()
|
defer func() { dl.YtDlpBin = orig }()
|
||||||
@@ -182,6 +189,7 @@ func TestDownloadImpl_FileMethod_Always_RemuxFails(t *testing.T) {
|
|||||||
t.Setenv("YTDLP_FORCE_SHELL", "1")
|
t.Setenv("YTDLP_FORCE_SHELL", "1")
|
||||||
// Prefer using in-process fake to avoid exec restrictions.
|
// Prefer using in-process fake to avoid exec restrictions.
|
||||||
t.Setenv("YTDLP_FAKE", "1")
|
t.Setenv("YTDLP_FAKE", "1")
|
||||||
|
dl.ResetVersionWarningForTest()
|
||||||
fake := createFakeYtDlp(t)
|
fake := createFakeYtDlp(t)
|
||||||
orig := dl.YtDlpBin
|
orig := dl.YtDlpBin
|
||||||
defer func() { dl.YtDlpBin = orig }()
|
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
|
// Verify new flags are present by name
|
||||||
want := map[string]bool{
|
want := map[string]bool{
|
||||||
"cookies": false,
|
"cookies": false,
|
||||||
"add-header": false,
|
"add-header": false,
|
||||||
"dl-method": false,
|
"dl-method": false,
|
||||||
"file-remux": false,
|
"file-remux": false,
|
||||||
|
"format-sort": false,
|
||||||
}
|
}
|
||||||
for _, f := range DownloadCommand.Flags {
|
for _, f := range DownloadCommand.Flags {
|
||||||
name := f.Names()[0]
|
name := f.Names()[0]
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import (
|
|||||||
"github.com/photoprism/photoprism/pkg/service/http/scheme"
|
"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.
|
// DownloadOpts contains the command options used by runDownload.
|
||||||
type DownloadOpts struct {
|
type DownloadOpts struct {
|
||||||
Dest string
|
Dest string
|
||||||
@@ -30,6 +32,7 @@ type DownloadOpts struct {
|
|||||||
AddHeaders []string
|
AddHeaders []string
|
||||||
Method string // pipe|file
|
Method string // pipe|file
|
||||||
FileRemux string // always|auto|skip
|
FileRemux string // always|auto|skip
|
||||||
|
FormatSort string
|
||||||
}
|
}
|
||||||
|
|
||||||
// runDownload executes the download/import flow for the given inputs and options.
|
// 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")
|
return fmt.Errorf("no download URLs provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if msg, ok := dl.VersionWarning(); ok {
|
||||||
|
log.Info(msg)
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve destination folder
|
// Resolve destination folder
|
||||||
destFolder := opts.Dest
|
destFolder := opts.Dest
|
||||||
if destFolder == "" {
|
if destFolder == "" {
|
||||||
@@ -62,12 +69,14 @@ func runDownload(conf *config.Config, opts DownloadOpts, inputURLs []string) err
|
|||||||
defer os.RemoveAll(downloadPath)
|
defer os.RemoveAll(downloadPath)
|
||||||
|
|
||||||
// Normalize method/remux policy
|
// Normalize method/remux policy
|
||||||
method := strings.ToLower(strings.TrimSpace(opts.Method))
|
method, _, err := resolveDownloadMethod(opts.Method)
|
||||||
if method == "" {
|
if err != nil {
|
||||||
method = "pipe"
|
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))
|
fileRemux := strings.ToLower(strings.TrimSpace(opts.FileRemux))
|
||||||
if fileRemux == "" {
|
if fileRemux == "" {
|
||||||
@@ -118,7 +127,7 @@ func runDownload(conf *config.Config, opts DownloadOpts, inputURLs []string) err
|
|||||||
opt := dl.Options{
|
opt := dl.Options{
|
||||||
MergeOutputFormat: fs.VideoMp4.String(),
|
MergeOutputFormat: fs.VideoMp4.String(),
|
||||||
RemuxVideo: 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,
|
Cookies: opts.Cookies,
|
||||||
CookiesFromBrowser: opts.CookiesFromBrowser,
|
CookiesFromBrowser: opts.CookiesFromBrowser,
|
||||||
AddHeaders: opts.AddHeaders,
|
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)
|
result, err := dl.NewMetadata(context.Background(), u.String(), opt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("metadata failed: %v", err)
|
log.Errorf("metadata failed: %v", err)
|
||||||
|
if hint, ok := missingFormatsHint(err); ok {
|
||||||
|
log.Info(hint)
|
||||||
|
}
|
||||||
failures++
|
failures++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -225,3 +237,16 @@ func runDownload(conf *config.Config, opts DownloadOpts, inputURLs []string) err
|
|||||||
log.Infof("completed in %s", elapsed)
|
log.Infof("completed in %s", elapsed)
|
||||||
return nil
|
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.
|
- 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.
|
- 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
|
package dl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"bytes"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version of youtube-dl.
|
const minYtDlpVersion = "2025.09.23"
|
||||||
// Might be a good idea to call at start to assert that youtube-dl can be found.
|
|
||||||
func Version(ctx context.Context) (string, error) {
|
var (
|
||||||
cmd := exec.CommandContext(ctx, FindYtDlpBin(), "--version")
|
versionOnce sync.Once
|
||||||
versionBytes, cmdErr := cmd.Output()
|
versionWarning string
|
||||||
if cmdErr != nil {
|
)
|
||||||
return "", cmdErr
|
|
||||||
|
// 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
|
package dl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"os"
|
||||||
"regexp"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestVersion(t *testing.T) {
|
func TestVersionWarning_OldVersion(t *testing.T) {
|
||||||
t.Run("Success", func(t *testing.T) {
|
ResetVersionWarningForTest()
|
||||||
versionRe := regexp.MustCompile(`^\d{4}\.\d{2}.\d{2}.*$`)
|
|
||||||
version, versionErr := Version(context.Background())
|
|
||||||
|
|
||||||
if versionErr != nil {
|
bin := writeVersionScript(t, "2025.09.05")
|
||||||
t.Fatalf("err: %s", versionErr)
|
orig := YtDlpBin
|
||||||
}
|
YtDlpBin = bin
|
||||||
|
defer func() { YtDlpBin = orig }()
|
||||||
|
|
||||||
if !versionRe.MatchString(version) {
|
os.Unsetenv("YTDLP_FAKE")
|
||||||
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"
|
|
||||||
|
|
||||||
_, versionErr := Version(context.Background())
|
msg, ok := VersionWarning()
|
||||||
if versionErr == nil || !strings.Contains(versionErr.Error(), "no such file or directory") {
|
if !ok {
|
||||||
t.Fatalf("err should be nil 'no such file or directory': %v", versionErr)
|
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