Files
photoprism/internal/commands/download_impl.go
2025-09-20 14:36:41 +02:00

228 lines
6.8 KiB
Go

package commands
import (
"context"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/ffmpeg"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/photoprism/dl"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/service/http/scheme"
)
// DownloadOpts contains the command options used by runDownload.
type DownloadOpts struct {
Dest string
Cookies string
CookiesFromBrowser string
AddHeaders []string
Method string // pipe|file
FileRemux string // always|auto|skip
}
// runDownload executes the download/import flow for the given inputs and options.
// It is the testable core used by the CLI action.
func runDownload(conf *config.Config, opts DownloadOpts, inputURLs []string) error {
start := time.Now()
if conf == nil {
return fmt.Errorf("nil config")
}
if conf.ReadOnly() {
return config.ErrReadOnly
}
if len(inputURLs) == 0 {
return fmt.Errorf("no download URLs provided")
}
// Resolve destination folder
destFolder := opts.Dest
if destFolder == "" {
destFolder = conf.ImportDest()
} else {
destFolder = clean.UserPath(destFolder)
}
// Create session download directory
downloadPath := filepath.Join(conf.TempPath(), fs.DownloadDir+"_"+rnd.Base36(12))
if err := fs.MkdirAll(downloadPath); err != nil {
return err
}
defer os.RemoveAll(downloadPath)
// Normalize method/remux policy
method := strings.ToLower(strings.TrimSpace(opts.Method))
if method == "" {
method = "pipe"
}
if method != "pipe" && method != "file" {
return fmt.Errorf("invalid method: %s", method)
}
fileRemux := strings.ToLower(strings.TrimSpace(opts.FileRemux))
if fileRemux == "" {
fileRemux = "auto"
}
switch fileRemux {
case "always", "auto", "skip":
default:
return fmt.Errorf("invalid file remux policy: %s", fileRemux)
}
// Process inputs sequentially
var failures int
for _, raw := range inputURLs {
u, perr := url.Parse(strings.TrimSpace(raw))
if perr != nil {
log.Errorf("invalid URL: %s", clean.Log(raw))
failures++
continue
}
if u.Scheme != scheme.Http && u.Scheme != scheme.Https {
log.Errorf("invalid URL scheme %s: %s", clean.Log(u.Scheme), clean.Log(raw))
failures++
continue
}
mt := media.FromName(u.Path)
ext := fs.Ext(u.Path)
var downloadFile string
switch mt {
case media.Image, media.Vector, media.Raw, media.Document, media.Audio:
log.Infof("downloading %s from %s", mt, clean.Log(u.String()))
if dlName := clean.DlName(fs.BasePrefix(u.Path, true)); dlName != "" {
downloadFile = dlName + ext
} else {
downloadFile = time.Now().Format("20060102_150405") + ext
}
downloadFilePath := filepath.Join(downloadPath, downloadFile)
if downloadErr := fs.Download(downloadFilePath, u.String()); downloadErr != nil {
log.Errorf("download failed: %v", downloadErr)
failures++
continue
}
default:
mt = media.Video
log.Infof("downloading %s from %s", mt, clean.Log(u.String()))
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",
Cookies: opts.Cookies,
CookiesFromBrowser: opts.CookiesFromBrowser,
AddHeaders: opts.AddHeaders,
}
result, err := dl.NewMetadata(context.Background(), u.String(), opt)
if err != nil {
log.Errorf("metadata failed: %v", err)
failures++
continue
}
// Best-effort creation time for file method when not remuxing locally.
if created := dl.CreatedFromInfo(result.Info); !created.IsZero() {
// Apply via yt-dlp ffmpeg post-processor so creation_time exists even without our remux.
result.Options.FFmpegPostArgs = "-metadata creation_time=" + created.UTC().Format(time.RFC3339)
}
if dlName := clean.DlName(result.Info.Title); dlName != "" {
downloadFile = dlName + fs.ExtMp4
} else {
downloadFile = time.Now().Format("20060102_150405") + fs.ExtMp4
}
downloadFilePath := filepath.Join(downloadPath, downloadFile)
if method == "pipe" {
downloadResult, err := result.DownloadWithOptions(context.Background(), dl.DownloadOptions{
Filter: "best",
DownloadAudioOnly: false,
EmbedMetadata: true,
EmbedSubs: false,
ForceOverwrites: false,
DisableCaching: false,
PlaylistIndex: 1,
})
if err != nil {
log.Errorf("download failed: %v", err)
failures++
continue
}
func() {
defer downloadResult.Close()
f, ferr := os.Create(downloadFilePath)
if ferr != nil {
log.Errorf("create file failed: %v", ferr)
failures++
return
}
if _, cerr := io.Copy(f, downloadResult); cerr != nil {
_ = f.Close()
log.Errorf("write file failed: %v", cerr)
failures++
return
}
_ = f.Close()
}()
remuxOpt := dl.RemuxOptionsFromInfo(conf.FFmpegBin(), fs.VideoMp4, result.Info, u.String())
if remuxErr := ffmpeg.RemuxFile(downloadFilePath, "", remuxOpt); remuxErr != nil {
log.Errorf("remux failed: %v", remuxErr)
failures++
continue
}
} else {
outTpl := filepath.Join(downloadPath, "ppdl_%(id)s.%(ext)s")
files, err := result.DownloadToFileWithOptions(context.Background(), dl.DownloadOptions{
Filter: "best",
DownloadAudioOnly: false,
EmbedMetadata: true,
EmbedSubs: false,
ForceOverwrites: false,
DisableCaching: false,
PlaylistIndex: 1,
Output: outTpl,
})
if err != nil {
log.Errorf("download failed: %v", err)
}
if fileRemux != "skip" {
for _, fp := range files {
if fileRemux == "auto" && strings.EqualFold(filepath.Ext(fp), fs.ExtMp4) {
continue
}
remuxOpt := dl.RemuxOptionsFromInfo(conf.FFmpegBin(), fs.VideoMp4, result.Info, u.String())
if remuxErr := ffmpeg.RemuxFile(fp, "", remuxOpt); remuxErr != nil {
log.Errorf("remux failed: %v", remuxErr)
failures++
continue
}
}
}
}
}
}
log.Infof("importing downloads to %s", clean.Log(filepath.Join(conf.OriginalsPath(), destFolder)))
w := get.Import()
opt := photoprism.ImportOptionsMove(downloadPath, destFolder)
w.Start(opt)
elapsed := time.Since(start)
if failures > 0 {
log.Warnf("completed with %d error(s) in %s", failures, elapsed)
return fmt.Errorf("some downloads failed: %d", failures)
}
log.Infof("completed in %s", elapsed)
return nil
}