Skip to content

Commit 829d429

Browse files
committed
CLI: Improve photoprism dl command with additional flags photoprism#5261
Signed-off-by: Michael Mayer <michael@photoprism.app>
1 parent f3deeee commit 829d429

File tree

5 files changed

+108
-68
lines changed

5 files changed

+108
-68
lines changed

internal/commands/download.go

Lines changed: 74 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ var downloadExamples = `
2929
Usage examples:
3030
3131
photoprism dl --cookies cookies.txt \
32-
--add-header 'Authorization: Bearer <token>' \
33-
--dl-method file --file-remux auto -- \
32+
--header 'Authorization: Bearer <token>' \
33+
--method file --remux auto -- \
3434
https://example.com/a.mp4 https://example.com/b.jpg
3535
3636
photoprism dl -a 'Authorization: Bearer <token>' \
@@ -50,31 +50,37 @@ var DownloadCommand = &cli.Command{
5050
Usage: "relative originals `PATH` in which new files should be imported",
5151
},
5252
&cli.StringFlag{
53-
Name: "cookies",
54-
Aliases: []string{"c"},
55-
Usage: "use Netscape-format cookies.txt `FILE` for HTTP authentication",
56-
},
57-
&cli.StringSliceFlag{
58-
Name: "add-header",
59-
Aliases: []string{"a"},
60-
Usage: "add HTTP request `HEADER` in the form 'Name: Value' (repeatable)",
53+
Name: "impersonate",
54+
Aliases: []string{"i"},
55+
Usage: "impersonate browser `IDENTITY` (e.g. chrome, edge or safari; 'none' to disable)",
56+
Value: "firefox",
6157
},
6258
&cli.StringFlag{
63-
Name: "dl-method",
59+
Name: "method",
6460
Aliases: []string{"m"},
6561
Value: "pipe",
6662
Usage: "download `METHOD` when using external commands: pipe (stdio stream) or file (temporary files)",
6763
},
6864
&cli.StringFlag{
69-
Name: "file-remux",
65+
Name: "remux",
7066
Aliases: []string{"r"},
7167
Value: "auto",
72-
Usage: "remux `POLICY` for videos when using --dl-method file: auto (skip if MP4), always, or skip",
68+
Usage: "remux `POLICY` for videos when using --method file: auto (skip if MP4), always, or skip",
7369
},
7470
&cli.StringFlag{
75-
Name: "format-sort",
71+
Name: "sort",
7672
Aliases: []string{"s"},
77-
Usage: "custom FORMAT sort expression passed to yt-dlp",
73+
Usage: "custom `FORMAT` sort expression, e.g. 'quality,res,fps,codec:avc:m4a,size,br,asr,proto,ext,hasaud,source,id'",
74+
},
75+
&cli.StringFlag{
76+
Name: "cookies",
77+
Aliases: []string{"c"},
78+
Usage: "use Netscape-format cookies.txt `FILE` for HTTP authentication",
79+
},
80+
&cli.StringSliceFlag{
81+
Name: "header",
82+
Aliases: []string{"a"},
83+
Usage: "add HTTP request `HEADER` in the form 'Name: Value' (repeatable)",
7884
},
7985
},
8086
Action: downloadAction,
@@ -148,29 +154,47 @@ func downloadAction(ctx *cli.Context) error {
148154

149155
// Flags for yt-dlp auth and headers
150156
cookies := strings.TrimSpace(ctx.String("cookies"))
157+
151158
// cookiesFromBrowser := strings.TrimSpace(ctx.String("cookies-from-browser"))
152-
addHeaders := ctx.StringSlice("add-header")
159+
addHeaders := ctx.StringSlice("header")
160+
161+
impersonate := strings.ToLower(strings.TrimSpace(ctx.String("impersonate")))
162+
163+
if impersonate == "" {
164+
impersonate = "firefox"
165+
} else if impersonate == "none" {
166+
impersonate = ""
167+
}
168+
153169
flagMethod := ""
154-
if ctx.IsSet("dl-method") {
155-
flagMethod = ctx.String("dl-method")
170+
171+
if ctx.IsSet("method") {
172+
flagMethod = ctx.String("method")
156173
}
174+
157175
method, _, err := resolveDownloadMethod(flagMethod)
176+
158177
if err != nil {
159178
return err
160179
}
161-
formatSort := strings.TrimSpace(ctx.String("format-sort"))
180+
181+
formatSort := strings.TrimSpace(ctx.String("sort"))
162182
sortingFormat := formatSort
183+
163184
if sortingFormat == "" && method == "pipe" {
164185
sortingFormat = pipeSortingFormat
165186
}
166-
fileRemux := strings.ToLower(strings.TrimSpace(ctx.String("file-remux")))
187+
188+
fileRemux := strings.ToLower(strings.TrimSpace(ctx.String("remux")))
189+
167190
if fileRemux == "" {
168191
fileRemux = "auto"
169192
}
193+
170194
switch fileRemux {
171195
case "always", "auto", "skip":
172196
default:
173-
return fmt.Errorf("invalid --file-remux: %s (expected 'always', 'auto', or 'skip')", fileRemux)
197+
return fmt.Errorf("invalid --remux: %s (expected 'always', 'auto', or 'skip')", fileRemux)
174198
}
175199

176200
// Process inputs sequentially (Phase 1)
@@ -210,27 +234,34 @@ func downloadAction(ctx *cli.Context) error {
210234
log.Infof("downloading %s from %s", mt, clean.Log(u.String()))
211235

212236
opt := dl.Options{
213-
MergeOutputFormat: fs.VideoMp4.String(),
214-
RemuxVideo: fs.VideoMp4.String(),
215-
SortingFormat: sortingFormat,
216-
Cookies: cookies,
217-
AddHeaders: addHeaders,
237+
SortingFormat: sortingFormat,
238+
Cookies: cookies,
239+
AddHeaders: addHeaders,
240+
Impersonate: impersonate,
241+
}
242+
ytRemux := method != "pipe"
243+
if ytRemux {
244+
opt.MergeOutputFormat = fs.VideoMp4.String()
245+
opt.RemuxVideo = fs.VideoMp4.String()
218246
}
219247

220-
result, err := dl.NewMetadata(context.Background(), u.String(), opt)
221-
if err != nil {
222-
log.Errorf("metadata failed: %v", err)
223-
if hint, ok := missingFormatsHint(err); ok {
248+
result, metaErr := dl.NewMetadata(context.Background(), u.String(), opt)
249+
250+
if metaErr != nil {
251+
log.Errorf("metadata failed: %v", metaErr)
252+
if hint, ok := missingFormatsHint(metaErr); ok {
224253
log.Info(hint)
225254
}
226255
failures++
227256
continue
228257
}
229258

230259
// Best-effort creation time for file method when not remuxing locally.
231-
if created := dl.CreatedFromInfo(result.Info); !created.IsZero() {
232-
// Apply via yt-dlp ffmpeg post-processor so creation_time exists even without our remux.
233-
result.Options.FFmpegPostArgs = "-metadata creation_time=" + created.UTC().Format(time.RFC3339)
260+
if ytRemux {
261+
if created := dl.CreatedFromInfo(result.Info); !created.IsZero() {
262+
// Apply via yt-dlp ffmpeg post-processor so creation_time exists even without our remux.
263+
result.Options.FFmpegPostArgs = "-metadata creation_time=" + created.UTC().Format(time.RFC3339)
264+
}
234265
}
235266

236267
// Base filename for pipe method
@@ -243,17 +274,9 @@ func downloadAction(ctx *cli.Context) error {
243274

244275
if method == "pipe" {
245276
// Stream to stdout
246-
downloadResult, err := result.DownloadWithOptions(context.Background(), dl.DownloadOptions{
247-
Filter: "best",
248-
DownloadAudioOnly: false,
249-
EmbedMetadata: true,
250-
EmbedSubs: false,
251-
ForceOverwrites: false,
252-
DisableCaching: false,
253-
PlaylistIndex: 1,
254-
})
255-
if err != nil {
256-
log.Errorf("download failed: %v", err)
277+
downloadResult, dlErr := dl.Download(context.Background(), u.String(), opt, "best")
278+
if dlErr != nil {
279+
log.Errorf("download failed: %v", dlErr)
257280
failures++
258281
continue
259282
}
@@ -285,7 +308,7 @@ func downloadAction(ctx *cli.Context) error {
285308
// file method
286309
// Deterministic output template within the session temp dir
287310
outTpl := filepath.Join(downloadPath, "ppdl_%(id)s.%(ext)s")
288-
files, err := result.DownloadToFileWithOptions(context.Background(), dl.DownloadOptions{
311+
files, dlErr := result.DownloadToFileWithOptions(context.Background(), dl.DownloadOptions{
289312
Filter: "best",
290313
DownloadAudioOnly: false,
291314
EmbedMetadata: true,
@@ -295,10 +318,12 @@ func downloadAction(ctx *cli.Context) error {
295318
PlaylistIndex: 1,
296319
Output: outTpl,
297320
})
298-
if err != nil {
299-
log.Errorf("download failed: %v", err)
321+
322+
if dlErr != nil {
323+
log.Errorf("download failed: %v", dlErr)
300324
// even on error, any completed files returned will be imported
301325
}
326+
302327
// Ensure container/metadata per remux policy for file method
303328
if fileRemux != "skip" {
304329
for _, fp := range files {
@@ -325,6 +350,7 @@ func downloadAction(ctx *cli.Context) error {
325350
w.Start(opt)
326351

327352
elapsed := time.Since(start)
353+
328354
if failures > 0 {
329355
log.Warnf("completed with %d error(s) in %s", failures, elapsed)
330356
} else {
@@ -334,5 +360,6 @@ func downloadAction(ctx *cli.Context) error {
334360
if failures > 0 {
335361
return fmt.Errorf("some downloads failed: %d", failures)
336362
}
363+
337364
return nil
338365
}

internal/commands/download_format_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ func TestRunDownload_FileMethod_WithFormatSort(t *testing.T) {
146146
FileRemux: "skip",
147147
FormatSort: "res,fps,size",
148148
}, []string{"https://example.com/video"}); err != nil {
149-
t.Fatalf("runDownload failed with custom format-sort: %v", err)
149+
t.Fatalf("runDownload failed with custom sort: %v", err)
150150
}
151151

152152
t.Cleanup(func() {

internal/commands/download_help_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ func TestDownloadCommand_HelpFlagsAndArgs(t *testing.T) {
1010
}
1111
// Verify new flags are present by name
1212
want := map[string]bool{
13-
"cookies": false,
14-
"add-header": false,
15-
"dl-method": false,
16-
"file-remux": false,
17-
"format-sort": false,
13+
"cookies": false,
14+
"header": false,
15+
"method": false,
16+
"remux": false,
17+
"sort": false,
1818
}
1919
for _, f := range DownloadCommand.Flags {
2020
name := f.Names()[0]

internal/commands/download_impl.go

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type DownloadOpts struct {
3030
Cookies string
3131
CookiesFromBrowser string
3232
AddHeaders []string
33+
Impersonate string
3334
Method string // pipe|file
3435
FileRemux string // always|auto|skip
3536
FormatSort string
@@ -75,19 +76,33 @@ func runDownload(conf *config.Config, opts DownloadOpts, inputURLs []string) err
7576
}
7677

7778
sortingFormat := strings.TrimSpace(opts.FormatSort)
79+
7880
if sortingFormat == "" && method == "pipe" {
7981
sortingFormat = pipeSortingFormat
8082
}
83+
8184
fileRemux := strings.ToLower(strings.TrimSpace(opts.FileRemux))
85+
8286
if fileRemux == "" {
8387
fileRemux = "auto"
8488
}
89+
8590
switch fileRemux {
8691
case "always", "auto", "skip":
8792
default:
8893
return fmt.Errorf("invalid file remux policy: %s", fileRemux)
8994
}
9095

96+
impersonate := strings.TrimSpace(opts.Impersonate)
97+
if impersonate == "" {
98+
impersonate = "firefox"
99+
}
100+
if strings.EqualFold(impersonate, "none") {
101+
impersonate = ""
102+
} else {
103+
impersonate = strings.ToLower(impersonate)
104+
}
105+
91106
// Process inputs sequentially
92107
var failures int
93108
for _, raw := range inputURLs {
@@ -125,12 +140,16 @@ func runDownload(conf *config.Config, opts DownloadOpts, inputURLs []string) err
125140
mt = media.Video
126141
log.Infof("downloading %s from %s", mt, clean.Log(u.String()))
127142
opt := dl.Options{
128-
MergeOutputFormat: fs.VideoMp4.String(),
129-
RemuxVideo: fs.VideoMp4.String(),
130143
SortingFormat: sortingFormat,
131144
Cookies: opts.Cookies,
132145
CookiesFromBrowser: opts.CookiesFromBrowser,
133146
AddHeaders: opts.AddHeaders,
147+
Impersonate: impersonate,
148+
}
149+
ytRemux := method != "pipe"
150+
if ytRemux {
151+
opt.MergeOutputFormat = fs.VideoMp4.String()
152+
opt.RemuxVideo = fs.VideoMp4.String()
134153
}
135154
result, err := dl.NewMetadata(context.Background(), u.String(), opt)
136155
if err != nil {
@@ -143,9 +162,11 @@ func runDownload(conf *config.Config, opts DownloadOpts, inputURLs []string) err
143162
}
144163

145164
// Best-effort creation time for file method when not remuxing locally.
146-
if created := dl.CreatedFromInfo(result.Info); !created.IsZero() {
147-
// Apply via yt-dlp ffmpeg post-processor so creation_time exists even without our remux.
148-
result.Options.FFmpegPostArgs = "-metadata creation_time=" + created.UTC().Format(time.RFC3339)
165+
if ytRemux {
166+
if created := dl.CreatedFromInfo(result.Info); !created.IsZero() {
167+
// Apply via yt-dlp ffmpeg post-processor so creation_time exists even without our remux.
168+
result.Options.FFmpegPostArgs = "-metadata creation_time=" + created.UTC().Format(time.RFC3339)
169+
}
149170
}
150171
if dlName := clean.DlName(result.Info.Title); dlName != "" {
151172
downloadFile = dlName + fs.ExtMp4
@@ -155,15 +176,7 @@ func runDownload(conf *config.Config, opts DownloadOpts, inputURLs []string) err
155176
downloadFilePath := filepath.Join(downloadPath, downloadFile)
156177

157178
if method == "pipe" {
158-
downloadResult, err := result.DownloadWithOptions(context.Background(), dl.DownloadOptions{
159-
Filter: "best",
160-
DownloadAudioOnly: false,
161-
EmbedMetadata: true,
162-
EmbedSubs: false,
163-
ForceOverwrites: false,
164-
DisableCaching: false,
165-
PlaylistIndex: 1,
166-
})
179+
downloadResult, err := dl.Download(context.Background(), u.String(), opt, "best")
167180
if err != nil {
168181
log.Errorf("download failed: %v", err)
169182
failures++

internal/meta/json_exiftool.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
238238
} else if mt, ok := data.json["MIMEType"]; ok && data.TakenAtLocal.IsZero() && (mt == MimeVideoMp4 || mt == MimeQuicktime) {
239239
// Assume default time zone for MP4 & Quicktime videos is UTC.
240240
// see https://exiftool.org/TagNames/QuickTime.html
241-
log.Debugf("metadata: default time zone for %s is UTC (%s)", logName, clean.Log(mt))
241+
log.Tracef("metadata: default time zone for %s is UTC (%s)", logName, clean.Log(mt))
242242
data.TimeZone = tz.UTC
243243
data.TakenAt = data.TakenAt.UTC()
244244
data.TakenAtLocal = time.Time{}

0 commit comments

Comments
 (0)