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 }