mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-27 05:08:13 +08:00
228 lines
6.8 KiB
Go
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
|
|
}
|