From 4b8c41b96d96023f0f1697848238177ebd832cbe Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Sun, 21 Dec 2025 17:36:57 +0100 Subject: [PATCH] CLI: Add "photoprism video" subcommands Signed-off-by: Michael Mayer --- internal/commands/commands.go | 1 + internal/commands/video.go | 49 ++++ internal/commands/video_helpers.go | 331 ++++++++++++++++++++++++ internal/commands/video_helpers_test.go | 79 ++++++ internal/commands/video_index.go | 34 +++ internal/commands/video_info.go | 217 ++++++++++++++++ internal/commands/video_ls.go | 76 ++++++ internal/commands/video_remux.go | 303 ++++++++++++++++++++++ internal/commands/video_search.go | 82 ++++++ internal/commands/video_storage.go | 48 ++++ internal/commands/video_transcode.go | 209 +++++++++++++++ internal/commands/video_trim.go | 316 ++++++++++++++++++++++ 12 files changed, 1745 insertions(+) create mode 100644 internal/commands/video.go create mode 100644 internal/commands/video_helpers.go create mode 100644 internal/commands/video_helpers_test.go create mode 100644 internal/commands/video_index.go create mode 100644 internal/commands/video_info.go create mode 100644 internal/commands/video_ls.go create mode 100644 internal/commands/video_remux.go create mode 100644 internal/commands/video_search.go create mode 100644 internal/commands/video_storage.go create mode 100644 internal/commands/video_transcode.go create mode 100644 internal/commands/video_trim.go diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 1c0b57999..b9243486f 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -56,6 +56,7 @@ var PhotoPrism = []*cli.Command{ StatusCommand, IndexCommand, FindCommand, + VideoCommands, ImportCommand, CopyCommand, DownloadCommand, diff --git a/internal/commands/video.go b/internal/commands/video.go new file mode 100644 index 000000000..0e49a313e --- /dev/null +++ b/internal/commands/video.go @@ -0,0 +1,49 @@ +package commands + +import "github.com/urfave/cli/v2" + +// VideoCommands configures the CLI subcommands for working with indexed videos. +var VideoCommands = &cli.Command{ + Name: "video", + Usage: "Video subcommands", + Subcommands: []*cli.Command{ + VideoListCommand, + VideoTrimCommand, + VideoRemuxCommand, + VideoTranscodeCommand, + VideoInfoCommand, + }, +} + +// videoCountFlag limits the number of results returned by video commands. +var videoCountFlag = &cli.UintFlag{ + Name: "count", + Aliases: []string{"n"}, + Usage: "maximum `NUMBER` of results", + Value: 10000, +} + +// videoIncludeSidecarFlag includes sidecar video files in list output. +var videoIncludeSidecarFlag = &cli.BoolFlag{ + Name: "include-sidecar", + Usage: "include sidecar video files in results", +} + +// videoForceFlag allows overwriting existing output files for remux/transcode. +var videoForceFlag = &cli.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Usage: "replace existing output files", +} + +// videoNoBackupFlag skips creating .backup files for in-place mutations. +var videoNoBackupFlag = &cli.BoolFlag{ + Name: "no-backup", + Usage: "do not keep a .backup copy of original files", +} + +// videoVerboseFlag adds raw metadata to video info output. +var videoVerboseFlag = &cli.BoolFlag{ + Name: "verbose", + Usage: "include raw metadata output", +} diff --git a/internal/commands/video_helpers.go b/internal/commands/video_helpers.go new file mode 100644 index 000000000..63b6dafb6 --- /dev/null +++ b/internal/commands/video_helpers.go @@ -0,0 +1,331 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/dustin/go-humanize" + + "github.com/photoprism/photoprism/internal/entity/search" + "github.com/photoprism/photoprism/pkg/txt/report" +) + +// videoNormalizeFilter converts CLI args into a search query, mapping bare tokens to name/filename filters. +func videoNormalizeFilter(args []string) string { + parts := make([]string, 0, len(args)) + + for _, arg := range args { + token := strings.TrimSpace(arg) + if token == "" { + continue + } + + if strings.Contains(token, ":") { + parts = append(parts, token) + continue + } + + if strings.Contains(token, "/") { + parts = append(parts, fmt.Sprintf("filename:%s", token)) + } else { + parts = append(parts, fmt.Sprintf("name:%s", token)) + } + } + + return strings.TrimSpace(strings.Join(parts, " ")) +} + +// videoSplitTrimArgs separates filter args from the trailing trim duration argument. +func videoSplitTrimArgs(args []string) ([]string, string, error) { + if len(args) == 0 { + return nil, "", fmt.Errorf("missing duration argument") + } + + filterArgs := make([]string, len(args)-1) + copy(filterArgs, args[:len(args)-1]) + + durationArg := strings.TrimSpace(args[len(args)-1]) + if durationArg == "" { + return nil, "", fmt.Errorf("missing duration argument") + } + + return filterArgs, durationArg, nil +} + +// videoParseTrimDuration parses the trim duration string with the precedence and rules from the spec. +func videoParseTrimDuration(value string) (time.Duration, error) { + raw := strings.TrimSpace(value) + if raw == "" { + return 0, fmt.Errorf("duration is empty") + } + + sign := 1 + if strings.HasPrefix(raw, "-") { + sign = -1 + raw = strings.TrimSpace(strings.TrimPrefix(raw, "-")) + } + + if raw == "" { + return 0, fmt.Errorf("duration is empty") + } + + if isDigits(raw) { + secs, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid duration %q", value) + } + if secs == 0 { + return 0, fmt.Errorf("duration must be non-zero") + } + return time.Duration(sign) * time.Duration(secs) * time.Second, nil + } + + if strings.Contains(raw, ":") { + if strings.ContainsAny(raw, "hms") { + return 0, fmt.Errorf("invalid duration %q", value) + } + + parts := strings.Split(raw, ":") + if len(parts) != 2 && len(parts) != 3 { + return 0, fmt.Errorf("invalid duration %q", value) + } + + for _, p := range parts { + if !isDigits(p) { + return 0, fmt.Errorf("invalid duration %q", value) + } + } + + if len(parts) == 2 && len(parts[1]) != 2 { + return 0, fmt.Errorf("invalid duration %q", value) + } + + if len(parts) == 3 && (len(parts[1]) != 2 || len(parts[2]) != 2) { + return 0, fmt.Errorf("invalid duration %q", value) + } + + var hours, minutes, seconds int64 + + if len(parts) == 2 { + minutes, _ = strconv.ParseInt(parts[0], 10, 64) + seconds, _ = strconv.ParseInt(parts[1], 10, 64) + } else { + hours, _ = strconv.ParseInt(parts[0], 10, 64) + minutes, _ = strconv.ParseInt(parts[1], 10, 64) + seconds, _ = strconv.ParseInt(parts[2], 10, 64) + } + + total := time.Duration(hours)*time.Hour + time.Duration(minutes)*time.Minute + time.Duration(seconds)*time.Second + if total == 0 { + return 0, fmt.Errorf("duration must be non-zero") + } + + return time.Duration(sign) * total, nil + } + + parsed, err := time.ParseDuration(applySign(raw, sign)) + if err != nil { + return 0, fmt.Errorf("invalid duration %q", value) + } + + if parsed == 0 { + return 0, fmt.Errorf("duration must be non-zero") + } + + return parsed, nil +} + +// videoListColumns returns the ordered column list for the video ls output. +func videoListColumns(includeSidecar bool) []string { + cols := []string{"Name", "Root"} + if includeSidecar { + cols = append(cols, "Sidecar") + } + return append(cols, "Duration", "Codec", "Mime", "Width", "Height", "FPS", "Frames", "Size", "Hash") +} + +// videoListRow renders a search result row for table outputs with human-friendly values. +func videoListRow(found search.Photo, includeSidecar bool) []string { + row := []string{found.FileName, found.FileRoot} + if includeSidecar { + row = append(row, strconv.FormatBool(found.FileSidecar)) + } + + row = append(row, + videoHumanDuration(found.FileDuration), + found.FileCodec, + found.FileMime, + videoHumanInt(found.FileWidth), + videoHumanInt(found.FileHeight), + videoHumanFloat(found.FileFPS), + videoHumanInt(found.FileFrames), + videoHumanSize(found.FileSize), + found.FileHash, + ) + + return row +} + +// videoListJSONRow renders a search result row for JSON output with raw numeric values. +func videoListJSONRow(found search.Photo, includeSidecar bool) map[string]interface{} { + data := map[string]interface{}{ + "name": found.FileName, + "root": found.FileRoot, + "duration": found.FileDuration.Nanoseconds(), + "codec": found.FileCodec, + "mime": found.FileMime, + "width": found.FileWidth, + "height": found.FileHeight, + "fps": found.FileFPS, + "frames": found.FileFrames, + "size": videoNonNegativeSize(found.FileSize), + "hash": found.FileHash, + } + + if includeSidecar { + data["sidecar"] = found.FileSidecar + } + + return data +} + +// videoListJSON marshals a list of JSON rows using the canonical keys for each column. +func videoListJSON(rows []map[string]interface{}, cols []string) (string, error) { + canon := make([]string, len(cols)) + for i, col := range cols { + canon[i] = report.CanonKey(col) + } + + payload := make([]map[string]interface{}, 0, len(rows)) + + for _, row := range rows { + item := make(map[string]interface{}, len(canon)) + for _, key := range canon { + item[key] = row[key] + } + payload = append(payload, item) + } + + data, err := json.Marshal(payload) + if err != nil { + return "", err + } + + return string(data), nil +} + +// videoHumanDuration formats a duration for human-readable tables. +func videoHumanDuration(d time.Duration) string { + if d <= 0 { + return "" + } + + return d.String() +} + +// videoHumanInt formats non-zero integers for human-readable tables. +func videoHumanInt(value int) string { + if value <= 0 { + return "" + } + + return strconv.Itoa(value) +} + +// videoHumanFloat formats non-zero floats without unnecessary trailing zeros. +func videoHumanFloat(value float64) string { + if value <= 0 { + return "" + } + + return strconv.FormatFloat(value, 'f', -1, 64) +} + +// videoHumanSize formats file sizes with human-readable units. +func videoHumanSize(size int64) string { + return humanize.Bytes(uint64(videoNonNegativeSize(size))) //nolint:gosec // size is bounded to non-negative values +} + +// videoNonNegativeSize clamps negative sizes to zero before formatting. +func videoNonNegativeSize(size int64) int64 { + if size < 0 { + return 0 + } + + return size +} + +// videoTempPath creates a temporary file path in the destination directory. +func videoTempPath(dir, pattern string) (string, error) { + if dir == "" { + return "", fmt.Errorf("temp directory is empty") + } + + tmpFile, err := os.CreateTemp(dir, pattern) + if err != nil { + return "", err + } + + if err = tmpFile.Close(); err != nil { + return "", err + } + + if err = os.Remove(tmpFile.Name()); err != nil { + return "", err + } + + return tmpFile.Name(), nil +} + +// videoFFmpegSeconds converts a duration into an ffmpeg-friendly seconds string. +func videoFFmpegSeconds(d time.Duration) string { + seconds := d.Seconds() + return strconv.FormatFloat(seconds, 'f', 3, 64) +} + +// isDigits reports whether the string contains only decimal digits. +func isDigits(value string) bool { + if value == "" { + return false + } + + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + + return true +} + +// applySign applies a numeric sign to a duration string for parsing. +func applySign(value string, sign int) string { + if sign >= 0 { + return value + } + + return "-" + value +} + +// videoSidecarPath builds the sidecar destination path for an originals file without creating directories. +func videoSidecarPath(srcName, originalsPath, sidecarPath string) string { + src := filepath.ToSlash(srcName) + orig := filepath.ToSlash(originalsPath) + + if orig != "" { + orig = strings.TrimSuffix(orig, "/") + "/" + } + + rel := strings.TrimPrefix(src, orig) + if rel == src { + rel = filepath.Base(srcName) + } + + rel = strings.TrimPrefix(rel, "/") + return filepath.Join(sidecarPath, filepath.FromSlash(rel)) +} diff --git a/internal/commands/video_helpers_test.go b/internal/commands/video_helpers_test.go new file mode 100644 index 000000000..929be1a3f --- /dev/null +++ b/internal/commands/video_helpers_test.go @@ -0,0 +1,79 @@ +package commands + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/entity/search" +) + +func TestVideoNormalizeFilter(t *testing.T) { + t.Run("NormalizeTokens", func(t *testing.T) { + args := []string{"foo", "2024/clip.mp4", "name:bar", "filename:2025/a.mov", ""} + expected := "name:foo filename:2024/clip.mp4 name:bar filename:2025/a.mov" + assert.Equal(t, expected, videoNormalizeFilter(args)) + }) +} + +func TestVideoParseTrimDuration(t *testing.T) { + t.Run("Seconds", func(t *testing.T) { + d, err := videoParseTrimDuration("5") + assert.NoError(t, err) + assert.Equal(t, 5*time.Second, d) + }) + + t.Run("NegativeSeconds", func(t *testing.T) { + d, err := videoParseTrimDuration("-10") + assert.NoError(t, err) + assert.Equal(t, -10*time.Second, d) + }) + + t.Run("MinutesSeconds", func(t *testing.T) { + d, err := videoParseTrimDuration("02:05") + assert.NoError(t, err) + assert.Equal(t, 2*time.Minute+5*time.Second, d) + }) + + t.Run("HoursMinutesSeconds", func(t *testing.T) { + d, err := videoParseTrimDuration("01:02:03") + assert.NoError(t, err) + assert.Equal(t, time.Hour+2*time.Minute+3*time.Second, d) + }) + + t.Run("GoDuration", func(t *testing.T) { + d, err := videoParseTrimDuration("2m5s") + assert.NoError(t, err) + assert.Equal(t, 2*time.Minute+5*time.Second, d) + }) + + t.Run("Invalid", func(t *testing.T) { + _, err := videoParseTrimDuration("1:30s") + assert.Error(t, err) + }) +} + +func TestVideoListJSONRow(t *testing.T) { + t.Run("NumericFields", func(t *testing.T) { + found := search.Photo{ + FileName: "clip.mp4", + FileRoot: "/", + FileDuration: 2 * time.Second, + FileCodec: "avc1", + FileMime: "video/mp4", + FileWidth: 1920, + FileHeight: 1080, + FileFPS: 29.97, + FileFrames: 120, + FileSize: 1234, + FileHash: "abc", + FileSidecar: true, + } + + row := videoListJSONRow(found, true) + assert.Equal(t, int64(2*time.Second), row["duration"]) + assert.Equal(t, int64(1234), row["size"]) + assert.Equal(t, true, row["sidecar"]) + }) +} diff --git a/internal/commands/video_index.go b/internal/commands/video_index.go new file mode 100644 index 000000000..841fac340 --- /dev/null +++ b/internal/commands/video_index.go @@ -0,0 +1,34 @@ +package commands + +import ( + "fmt" + + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/photoprism" + "github.com/photoprism/photoprism/internal/photoprism/get" +) + +// videoReindexRelated reindexes the related file group for the given main file. +func videoReindexRelated(conf *config.Config, fileName string) error { + if fileName == "" { + return fmt.Errorf("index: missing filename") + } + + mediaFile, err := photoprism.NewMediaFile(fileName) + if err != nil { + return err + } + + related, err := mediaFile.RelatedFiles(false) + if err != nil { + return err + } + + index := get.Index() + result := photoprism.IndexRelated(related, index, photoprism.IndexOptionsSingle(conf)) + if result.Err != nil { + return result.Err + } + + return nil +} diff --git a/internal/commands/video_info.go b/internal/commands/video_info.go new file mode 100644 index 000000000..7b50eec76 --- /dev/null +++ b/internal/commands/video_info.go @@ -0,0 +1,217 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/urfave/cli/v2" + + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/entity/search" + "github.com/photoprism/photoprism/internal/meta" + "github.com/photoprism/photoprism/internal/photoprism" + "github.com/photoprism/photoprism/pkg/clean" +) + +// VideoInfoCommand configures the command name, flags, and action. +var VideoInfoCommand = &cli.Command{ + Name: "info", + Usage: "Displays diagnostic information for indexed videos", + ArgsUsage: "[filter]...", + Flags: []cli.Flag{ + videoCountFlag, + OffsetFlag, + JsonFlag(), + videoVerboseFlag, + }, + Action: videoInfoAction, +} + +// videoInfoAction prints indexed, ExifTool, and ffprobe metadata for matching videos. +func videoInfoAction(ctx *cli.Context) error { + return CallWithDependencies(ctx, func(conf *config.Config) error { + filter := videoNormalizeFilter(ctx.Args().Slice()) + results, err := videoSearchResults(filter, ctx.Uint(videoCountFlag.Name), ctx.Int(OffsetFlag.Name), false) + if err != nil { + return err + } + + entries := make([]videoInfoEntry, 0, len(results)) + for _, found := range results { + entry, err := videoInfoEntryFor(conf, found, ctx.Bool(videoVerboseFlag.Name)) + if err != nil { + log.Warnf("info: %s", clean.Error(err)) + } + entries = append(entries, entry) + } + + if ctx.Bool("json") { + payload, err := json.Marshal(entries) + if err != nil { + return err + } + fmt.Println(string(payload)) + return nil + } + + for _, entry := range entries { + videoPrintInfo(entry) + } + + return nil + }) +} + +// videoInfoEntry describes all metadata sections for a single video. +type videoInfoEntry struct { + Index map[string]interface{} `json:"index"` + Exif interface{} `json:"exif,omitempty"` + FFprobe interface{} `json:"ffprobe,omitempty"` + Raw map[string]string `json:"raw,omitempty"` +} + +// videoInfoEntryFor collects indexed, ExifTool, and ffprobe metadata for a search result. +func videoInfoEntryFor(conf *config.Config, found search.Photo, verbose bool) (videoInfoEntry, error) { + entry := videoInfoEntry{ + Index: videoIndexSummary(found), + } + + filePath := photoprism.FileName(found.FileRoot, found.FileName) + mediaFile, err := photoprism.NewMediaFile(filePath) + if err != nil { + return entry, err + } + + if conf.DisableExifTool() { + entry.Exif = nil + } else { + exif := mediaFile.MetaData() + entry.Exif = exif + if verbose { + entry.ensureRaw() + entry.Raw["exif"] = videoPrettyJSON(exif) + } + } + + ffprobeBin := conf.FFprobeBin() + if ffprobeBin == "" { + entry.FFprobe = nil + } else if ffprobe, raw, err := videoRunFFprobe(ffprobeBin, filePath); err != nil { + entry.FFprobe = nil + if verbose { + entry.ensureRaw() + entry.Raw["ffprobe"] = raw + } + } else { + entry.FFprobe = ffprobe + if verbose { + entry.ensureRaw() + entry.Raw["ffprobe"] = raw + } + } + + return entry, nil +} + +// videoIndexSummary builds a concise map of indexed fields for diagnostics. +func videoIndexSummary(found search.Photo) map[string]interface{} { + return map[string]interface{}{ + "file_name": found.FileName, + "file_root": found.FileRoot, + "file_uid": found.FileUID, + "photo_uid": found.PhotoUID, + "media_type": found.MediaType, + "file_type": found.FileType, + "file_mime": found.FileMime, + "file_codec": found.FileCodec, + "file_hash": found.FileHash, + "file_size": found.FileSize, + "file_duration": found.FileDuration.Nanoseconds(), + "photo_duration": found.PhotoDuration.Nanoseconds(), + "file_frames": found.FileFrames, + "file_fps": found.FileFPS, + "file_width": found.FileWidth, + "file_height": found.FileHeight, + "file_sidecar": found.FileSidecar, + "file_missing": found.FileMissing, + "file_video": found.FileVideo, + "original_name": found.OriginalName, + "instance_id": found.InstanceID, + "photo_taken_at": found.TakenAt, + "photo_taken_src": found.TakenSrc, + } +} + +// videoRunFFprobe executes ffprobe and returns parsed JSON plus raw output. +func videoRunFFprobe(ffprobeBin, filePath string) (interface{}, string, error) { + cmd := exec.Command(ffprobeBin, "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath) //nolint:gosec // args are validated paths + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, strings.TrimSpace(stdout.String()), fmt.Errorf("ffprobe failed: %s", strings.TrimSpace(stderr.String())) + } + + raw := strings.TrimSpace(stdout.String()) + if raw == "" { + return nil, raw, nil + } + + var data interface{} + if err := json.Unmarshal([]byte(raw), &data); err != nil { + return nil, raw, nil + } + + return data, raw, nil +} + +// ensureRaw initializes the raw map for verbose output. +func (v *videoInfoEntry) ensureRaw() { + if v.Raw == nil { + v.Raw = make(map[string]string) + } +} + +// videoPrettyJSON returns indented JSON for human-readable output. +func videoPrettyJSON(value interface{}) string { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return "" + } + + return string(data) +} + +// videoPrintInfo prints a human-readable metadata summary to stdout. +func videoPrintInfo(entry videoInfoEntry) { + fmt.Println("Indexed Metadata:") + fmt.Println(videoPrettyJSON(entry.Index)) + + if entry.Exif == nil { + fmt.Println("ExifTool Metadata: disabled or unavailable") + } else if exifMap, ok := entry.Exif.(meta.Data); ok { + fmt.Println("ExifTool Metadata:") + fmt.Println(videoPrettyJSON(exifMap)) + } else { + fmt.Println("ExifTool Metadata:") + fmt.Println(videoPrettyJSON(entry.Exif)) + } + + if entry.FFprobe == nil { + fmt.Println("FFprobe Diagnostics: unavailable") + } else { + fmt.Println("FFprobe Diagnostics:") + fmt.Println(videoPrettyJSON(entry.FFprobe)) + } + + if len(entry.Raw) > 0 { + fmt.Println("Raw Metadata:") + fmt.Println(videoPrettyJSON(entry.Raw)) + } +} diff --git a/internal/commands/video_ls.go b/internal/commands/video_ls.go new file mode 100644 index 000000000..736620205 --- /dev/null +++ b/internal/commands/video_ls.go @@ -0,0 +1,76 @@ +package commands + +import ( + "fmt" + + "github.com/urfave/cli/v2" + + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/pkg/txt/report" +) + +// VideoListCommand configures the command name, flags, and action. +var VideoListCommand = &cli.Command{ + Name: "ls", + Usage: "Lists indexed video files matching the specified filters", + ArgsUsage: "[filter]...", + Flags: append(append([]cli.Flag{}, report.CliFlags...), + videoCountFlag, + OffsetFlag, + videoIncludeSidecarFlag, + ), + Action: videoListAction, +} + +// videoListAction renders a filtered list of indexed video files. +func videoListAction(ctx *cli.Context) error { + return CallWithDependencies(ctx, func(conf *config.Config) error { + // Ensure config is initialized before querying the index. + if conf == nil { + return fmt.Errorf("config is not available") + } + + format, err := report.CliFormatStrict(ctx) + if err != nil { + return err + } + + filter := videoNormalizeFilter(ctx.Args().Slice()) + includeSidecar := ctx.Bool(videoIncludeSidecarFlag.Name) + + results, err := videoSearchResults(filter, ctx.Uint(videoCountFlag.Name), ctx.Int(OffsetFlag.Name), includeSidecar) + if err != nil { + return err + } + + cols := videoListColumns(includeSidecar) + + if format == report.JSON { + rows := make([]map[string]interface{}, 0, len(results)) + for _, found := range results { + rows = append(rows, videoListJSONRow(found, includeSidecar)) + } + + payload, err := videoListJSON(rows, cols) + if err != nil { + return err + } + + fmt.Println(payload) + return nil + } + + rows := make([][]string, 0, len(results)) + for _, found := range results { + rows = append(rows, videoListRow(found, includeSidecar)) + } + + output, err := report.RenderFormat(rows, cols, format) + if err != nil { + return err + } + + fmt.Println(output) + return nil + }) +} diff --git a/internal/commands/video_remux.go b/internal/commands/video_remux.go new file mode 100644 index 000000000..99c6a977f --- /dev/null +++ b/internal/commands/video_remux.go @@ -0,0 +1,303 @@ +package commands + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/manifoldco/promptui" + "github.com/urfave/cli/v2" + + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/entity/search" + "github.com/photoprism/photoprism/internal/ffmpeg" + "github.com/photoprism/photoprism/internal/ffmpeg/encode" + "github.com/photoprism/photoprism/internal/photoprism" + "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/video" +) + +// VideoRemuxCommand configures the command name, flags, and action. +var VideoRemuxCommand = &cli.Command{ + Name: "remux", + Usage: "Remuxes AVC videos into an MP4 container", + ArgsUsage: "[filter]...", + Flags: []cli.Flag{ + videoCountFlag, + OffsetFlag, + videoForceFlag, + videoNoBackupFlag, + DryRunFlag("prints planned remux operations without writing files"), + YesFlag(), + }, + Action: videoRemuxAction, +} + +// videoRemuxAction remuxes matching AVC files into MP4 containers. +func videoRemuxAction(ctx *cli.Context) error { + return CallWithDependencies(ctx, func(conf *config.Config) error { + if conf.DisableFFmpeg() { + return fmt.Errorf("ffmpeg is disabled") + } + + filter := videoNormalizeFilter(ctx.Args().Slice()) + results, err := videoSearchResults(filter, ctx.Uint(videoCountFlag.Name), ctx.Int(OffsetFlag.Name), false) + if err != nil { + return err + } + + plans, preflight, err := videoBuildRemuxPlans(conf, results, ctx.Bool(videoForceFlag.Name)) + if err != nil { + return err + } + + if len(plans) == 0 { + log.Infof("remux: found no matching videos") + return nil + } + + if !ctx.Bool("dry-run") { + if err = videoCheckFreeSpace(preflight); err != nil { + return err + } + } + + if !ctx.Bool("dry-run") && !RunNonInteractively(ctx.Bool("yes")) { + prompt := promptui.Prompt{ + Label: fmt.Sprintf("Remux %d video files?", len(plans)), + IsConfirm: true, + } + if _, err = prompt.Run(); err != nil { + log.Info("remux: cancelled") + return nil + } + } + + var processed, skipped, failed int + convert := get.Convert() + + for _, plan := range plans { + if ctx.Bool("dry-run") { + log.Infof("remux: would remux %s to %s", clean.Log(plan.SrcPath), clean.Log(plan.DestPath)) + skipped++ + continue + } + + if err = videoRemuxFile(conf, convert, plan, ctx.Bool(videoForceFlag.Name), ctx.Bool(videoNoBackupFlag.Name)); err != nil { + log.Errorf("remux: %s", clean.Error(err)) + failed++ + continue + } + + processed++ + } + + log.Infof("remux: processed %d, skipped %d, failed %d", processed, skipped, failed) + + if failed > 0 { + return fmt.Errorf("remux: %d files failed", failed) + } + + return nil + }) +} + +// videoRemuxPlan holds a resolved remux operation for a single video file. +type videoRemuxPlan struct { + IndexPath string + SrcPath string + DestPath string + SizeBytes int64 + Sidecar bool +} + +// videoBuildRemuxPlans prepares remux operations and preflight size checks from search results. +func videoBuildRemuxPlans(conf *config.Config, results []search.Photo, force bool) ([]videoRemuxPlan, []videoOutputPlan, error) { + plans := make([]videoRemuxPlan, 0, len(results)) + preflight := make([]videoOutputPlan, 0, len(results)) + + for _, found := range results { + if found.FileSidecar { + log.Warnf("remux: skipping sidecar file %s", clean.Log(found.FileName)) + continue + } + + if found.MediaType == entity.MediaLive { + log.Warnf("remux: skipping live photo video %s", clean.Log(found.FileName)) + continue + } + + srcPath := photoprism.FileName(found.FileRoot, found.FileName) + if !fs.FileExistsNotEmpty(srcPath) { + log.Warnf("remux: missing file %s", clean.Log(srcPath)) + continue + } + + if !videoCodecIsAvc(found.FileCodec) && !force { + if !videoFallbackCodecAvc(srcPath) { + log.Warnf("remux: skipping non-AVC video %s", clean.Log(found.FileName)) + continue + } + } + + destPath := fs.StripKnownExt(srcPath) + fs.ExtMp4 + useSidecar := false + indexPath := destPath + + if conf.ReadOnly() || !fs.PathWritable(filepath.Dir(srcPath)) || !fs.Writable(srcPath) { + if !conf.SidecarWritable() || !fs.PathWritable(conf.SidecarPath()) { + return nil, nil, config.ErrReadOnly + } + + sidecarBase := videoSidecarPath(srcPath, conf.OriginalsPath(), conf.SidecarPath()) + destPath = fs.StripKnownExt(sidecarBase) + fs.ExtMp4 + useSidecar = true + indexPath = srcPath + } + + if destPath != srcPath && fs.FileExistsNotEmpty(destPath) && !force { + log.Warnf("remux: output already exists %s", clean.Log(destPath)) + continue + } + + plans = append(plans, videoRemuxPlan{ + IndexPath: indexPath, + SrcPath: srcPath, + DestPath: destPath, + SizeBytes: found.FileSize, + Sidecar: useSidecar, + }) + + preflight = append(preflight, videoOutputPlan{ + Destination: destPath, + SizeBytes: found.FileSize, + }) + } + + return plans, preflight, nil +} + +// videoRemuxFile runs ffmpeg remuxing and refreshes previews/thumbnails before reindexing. +func videoRemuxFile(conf *config.Config, convert *photoprism.Convert, plan videoRemuxPlan, force, noBackup bool) error { + tempDir := filepath.Dir(plan.DestPath) + tempPath, err := videoTempPath(tempDir, ".remux-*.mp4") + if err != nil { + return err + } + + opt := encode.NewRemuxOptions(conf.FFmpegBin(), fs.VideoMp4, true) + opt.Force = true + + if err = ffmpeg.RemuxFile(plan.SrcPath, tempPath, opt); err != nil { + return err + } + + if !fs.FileExistsNotEmpty(tempPath) { + _ = os.Remove(tempPath) + return fmt.Errorf("remux output missing for %s", clean.Log(plan.SrcPath)) + } + + if err = os.Chmod(tempPath, fs.ModeFile); err != nil { + return err + } + + if plan.Sidecar { + if fs.FileExists(plan.DestPath) && !force { + _ = os.Remove(tempPath) + return fmt.Errorf("output already exists %s", clean.Log(plan.DestPath)) + } + + if fs.FileExists(plan.DestPath) { + _ = os.Remove(plan.DestPath) + } + + if err = os.Rename(tempPath, plan.DestPath); err != nil { + _ = os.Remove(tempPath) + return err + } + } else { + if plan.DestPath != plan.SrcPath && fs.FileExists(plan.DestPath) && !force { + _ = os.Remove(tempPath) + return fmt.Errorf("output already exists %s", clean.Log(plan.DestPath)) + } + + if noBackup { + if plan.DestPath != plan.SrcPath { + _ = os.Remove(plan.DestPath) + } + } else { + backupPath := plan.SrcPath + ".backup" + if fs.FileExists(backupPath) { + _ = os.Remove(backupPath) + } + if err = os.Rename(plan.SrcPath, backupPath); err != nil { + _ = os.Remove(tempPath) + return err + } + _ = os.Chmod(backupPath, fs.ModeBackupFile) + } + + if plan.DestPath != plan.SrcPath && fs.FileExists(plan.DestPath) { + _ = os.Remove(plan.DestPath) + } + + if err = os.Rename(tempPath, plan.DestPath); err != nil { + _ = os.Remove(tempPath) + return err + } + } + + mediaFile, err := photoprism.NewMediaFile(plan.DestPath) + if err != nil { + return err + } + + if convert != nil { + if img, imgErr := convert.ToImage(mediaFile, true); imgErr != nil { + log.Warnf("remux: %s", clean.Error(imgErr)) + } else if img != nil { + if thumbsErr := img.GenerateThumbnails(conf.ThumbCachePath(), true); thumbsErr != nil { + log.Warnf("remux: %s", clean.Error(thumbsErr)) + } + } + } + + return videoReindexRelated(conf, plan.IndexPath) +} + +// videoCodecIsAvc reports whether a codec string maps to an AVC/H.264 variant. +func videoCodecIsAvc(codec string) bool { + value := strings.ToLower(strings.TrimSpace(codec)) + if value == "" { + return false + } + + if value == "h264" || value == "x264" { + return true + } + + switch video.Codecs[value] { + case video.CodecAvc1, video.CodecAvc2, video.CodecAvc3, video.CodecAvc4: + return true + default: + return false + } +} + +// videoFallbackCodecAvc probes codec metadata when the indexed codec is missing. +func videoFallbackCodecAvc(srcPath string) bool { + mediaFile, err := photoprism.NewMediaFile(srcPath) + if err != nil { + return false + } + + if info := mediaFile.VideoInfo(); info.VideoCodec != "" { + return videoCodecIsAvc(info.VideoCodec) + } + + return mediaFile.MetaData().CodecAvc() +} diff --git a/internal/commands/video_search.go b/internal/commands/video_search.go new file mode 100644 index 000000000..097ce2e05 --- /dev/null +++ b/internal/commands/video_search.go @@ -0,0 +1,82 @@ +package commands + +import ( + "github.com/photoprism/photoprism/internal/entity/search" + "github.com/photoprism/photoprism/internal/entity/sortby" + "github.com/photoprism/photoprism/internal/form" +) + +// videoSearchResults runs a video-only search and applies offset/count after sidecar filtering. +func videoSearchResults(query string, count uint, offset int, includeSidecar bool) ([]search.Photo, error) { + if offset < 0 { + offset = 0 + } + + if count == 0 { + return []search.Photo{}, nil + } + + frm := form.SearchPhotos{ + Query: query, + Primary: false, + Merged: false, + Video: true, + Order: sortby.Name, + } + + if includeSidecar { + frm.Count = int(count) + frm.Offset = offset + results, _, err := search.Photos(frm) + return results, err + } + + target := int(count) + offset + if target < 0 { + target = 0 + } + + collected := make([]search.Photo, 0, target) + searchOffset := 0 + batchSize := int(count) + if batchSize < 200 { + batchSize = 200 + } + + for len(collected) < target { + frm.Count = batchSize + frm.Offset = searchOffset + + results, _, err := search.Photos(frm) + if err != nil { + return nil, err + } + + if len(results) == 0 { + break + } + + for _, found := range results { + if found.FileSidecar { + continue + } + collected = append(collected, found) + } + + searchOffset += len(results) + if len(results) < batchSize { + break + } + } + + if offset >= len(collected) { + return []search.Photo{}, nil + } + + end := offset + int(count) + if end > len(collected) { + end = len(collected) + } + + return collected[offset:end], nil +} diff --git a/internal/commands/video_storage.go b/internal/commands/video_storage.go new file mode 100644 index 000000000..406515a87 --- /dev/null +++ b/internal/commands/video_storage.go @@ -0,0 +1,48 @@ +package commands + +import ( + "fmt" + "path/filepath" + + "github.com/dustin/go-humanize" + + "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/fs/duf" +) + +// videoOutputPlan describes a planned output file for preflight checks. +type videoOutputPlan struct { + Destination string + SizeBytes int64 +} + +// videoCheckFreeSpace validates that destination filesystems have enough free space for outputs. +func videoCheckFreeSpace(plans []videoOutputPlan) error { + required := make(map[string]uint64) + + for _, plan := range plans { + if plan.Destination == "" { + continue + } + + dir := filepath.Dir(plan.Destination) + required[dir] += uint64(videoNonNegativeSize(plan.SizeBytes)) //nolint:gosec // size is clamped to non-negative values + } + + for dir, size := range required { + mount, err := duf.PathInfo(dir) + if err != nil { + return err + } + + if mount.Free < size { + return fmt.Errorf("insufficient free space in %s: need %s, have %s", + clean.Log(dir), + humanize.Bytes(size), + humanize.Bytes(mount.Free), + ) + } + } + + return nil +} diff --git a/internal/commands/video_transcode.go b/internal/commands/video_transcode.go new file mode 100644 index 000000000..b89d77361 --- /dev/null +++ b/internal/commands/video_transcode.go @@ -0,0 +1,209 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/manifoldco/promptui" + "github.com/urfave/cli/v2" + + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/entity/search" + "github.com/photoprism/photoprism/internal/photoprism" + "github.com/photoprism/photoprism/internal/photoprism/get" + "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/fs" +) + +// VideoTranscodeCommand configures the command name, flags, and action. +var VideoTranscodeCommand = &cli.Command{ + Name: "transcode", + Usage: "Transcodes matching videos to AVC sidecar files", + ArgsUsage: "[filter]...", + Flags: []cli.Flag{ + videoCountFlag, + OffsetFlag, + videoForceFlag, + DryRunFlag("prints planned transcode operations without writing files"), + YesFlag(), + }, + Action: videoTranscodeAction, +} + +// videoTranscodeAction transcodes matching videos into sidecar AVC files. +func videoTranscodeAction(ctx *cli.Context) error { + return CallWithDependencies(ctx, func(conf *config.Config) error { + if conf.DisableFFmpeg() { + return fmt.Errorf("ffmpeg is disabled") + } + + filter := videoNormalizeFilter(ctx.Args().Slice()) + results, err := videoSearchResults(filter, ctx.Uint(videoCountFlag.Name), ctx.Int(OffsetFlag.Name), false) + if err != nil { + return err + } + + plans, preflight, err := videoBuildTranscodePlans(conf, results, ctx.Bool(videoForceFlag.Name)) + if err != nil { + return err + } + + if len(plans) == 0 { + log.Infof("transcode: found no matching videos") + return nil + } + + if !ctx.Bool("dry-run") { + if err = videoCheckFreeSpace(preflight); err != nil { + return err + } + } + + if !ctx.Bool("dry-run") && !RunNonInteractively(ctx.Bool("yes")) { + prompt := promptui.Prompt{ + Label: fmt.Sprintf("Transcode %d video files?", len(plans)), + IsConfirm: true, + } + if _, err = prompt.Run(); err != nil { + log.Info("transcode: cancelled") + return nil + } + } + + var processed, skipped, failed int + convert := get.Convert() + + for _, plan := range plans { + if ctx.Bool("dry-run") { + log.Infof("transcode: would transcode %s to %s", clean.Log(plan.SrcPath), clean.Log(plan.DestPath)) + skipped++ + continue + } + + file, err := videoTranscodeFile(conf, convert, plan, ctx.Bool(videoForceFlag.Name)) + if err != nil { + log.Errorf("transcode: %s", clean.Error(err)) + failed++ + continue + } + + if file != nil { + if chmodErr := os.Chmod(file.FileName(), fs.ModeFile); chmodErr != nil { + log.Warnf("transcode: %s", clean.Error(chmodErr)) + } + } + + if err = videoReindexRelated(conf, plan.IndexPath); err != nil { + log.Errorf("transcode: %s", clean.Error(err)) + failed++ + continue + } + + processed++ + } + + log.Infof("transcode: processed %d, skipped %d, failed %d", processed, skipped, failed) + + if failed > 0 { + return fmt.Errorf("transcode: %d files failed", failed) + } + + return nil + }) +} + +// videoTranscodePlan holds a resolved transcode operation for a single video file. +type videoTranscodePlan struct { + IndexPath string + SrcPath string + DestPath string + SizeBytes int64 +} + +// videoBuildTranscodePlans prepares transcode operations and preflight size checks from search results. +func videoBuildTranscodePlans(conf *config.Config, results []search.Photo, force bool) ([]videoTranscodePlan, []videoOutputPlan, error) { + plans := make([]videoTranscodePlan, 0, len(results)) + preflight := make([]videoOutputPlan, 0, len(results)) + + for _, found := range results { + if found.FileSidecar { + log.Warnf("transcode: skipping sidecar file %s", clean.Log(found.FileName)) + continue + } + + if found.MediaType == entity.MediaLive { + log.Warnf("transcode: skipping live photo video %s", clean.Log(found.FileName)) + continue + } + + srcPath := photoprism.FileName(found.FileRoot, found.FileName) + if !fs.FileExistsNotEmpty(srcPath) { + log.Warnf("transcode: missing file %s", clean.Log(srcPath)) + continue + } + + if !conf.SidecarWritable() || !fs.PathWritable(conf.SidecarPath()) { + return nil, nil, config.ErrReadOnly + } + + destPath, err := videoTranscodeTarget(conf, srcPath) + if err != nil { + log.Warnf("transcode: %s", clean.Error(err)) + continue + } + + if destPath == srcPath { + log.Warnf("transcode: skipping because output equals source %s", clean.Log(srcPath)) + continue + } + + if fs.FileExistsNotEmpty(destPath) && !force { + log.Warnf("transcode: output already exists %s", clean.Log(destPath)) + continue + } + + plans = append(plans, videoTranscodePlan{ + IndexPath: srcPath, + SrcPath: srcPath, + DestPath: destPath, + SizeBytes: found.FileSize, + }) + + preflight = append(preflight, videoOutputPlan{ + Destination: destPath, + SizeBytes: found.FileSize, + }) + } + + return plans, preflight, nil +} + +// videoTranscodeTarget computes the sidecar output path for an AVC transcode. +func videoTranscodeTarget(conf *config.Config, srcPath string) (string, error) { + mediaFile, err := photoprism.NewMediaFile(srcPath) + if err != nil { + return "", err + } + + base := videoSidecarPath(srcPath, conf.OriginalsPath(), conf.SidecarPath()) + if mediaFile.IsAnimatedImage() { + return fs.StripKnownExt(base) + fs.ExtMp4, nil + } + + return fs.StripKnownExt(base) + fs.ExtAvc, nil +} + +// videoTranscodeFile runs the transcode operation and returns the resulting media file. +func videoTranscodeFile(conf *config.Config, convert *photoprism.Convert, plan videoTranscodePlan, force bool) (*photoprism.MediaFile, error) { + if convert == nil { + return nil, fmt.Errorf("transcode: convert service unavailable") + } + + mediaFile, err := photoprism.NewMediaFile(plan.SrcPath) + if err != nil { + return nil, err + } + + return convert.ToAvc(mediaFile, conf.FFmpegEncoder(), false, force) +} diff --git a/internal/commands/video_trim.go b/internal/commands/video_trim.go new file mode 100644 index 000000000..d9f089f4f --- /dev/null +++ b/internal/commands/video_trim.go @@ -0,0 +1,316 @@ +package commands + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/manifoldco/promptui" + "github.com/urfave/cli/v2" + + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/entity/search" + "github.com/photoprism/photoprism/internal/photoprism" + "github.com/photoprism/photoprism/internal/photoprism/get" + "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/fs" +) + +// VideoTrimCommand configures the command name, flags, and action. +var VideoTrimCommand = &cli.Command{ + Name: "trim", + Usage: "Trims a duration from the start (positive) or end (negative) of matching videos", + ArgsUsage: "[filter]... ", + Flags: []cli.Flag{ + videoCountFlag, + OffsetFlag, + videoNoBackupFlag, + DryRunFlag("prints planned trim operations without writing files"), + YesFlag(), + }, + Action: videoTrimAction, +} + +// videoTrimAction trims matching video files in-place or to sidecar outputs when originals are read-only. +func videoTrimAction(ctx *cli.Context) error { + return CallWithDependencies(ctx, func(conf *config.Config) error { + if conf.DisableFFmpeg() { + return fmt.Errorf("ffmpeg is disabled") + } + + filterArgs, durationArg, err := videoSplitTrimArgs(ctx.Args().Slice()) + if err != nil { + return cli.Exit(err.Error(), 2) + } + + trimDuration, err := videoParseTrimDuration(durationArg) + if err != nil { + return cli.Exit(err.Error(), 2) + } + + filter := videoNormalizeFilter(filterArgs) + results, err := videoSearchResults(filter, ctx.Uint(videoCountFlag.Name), ctx.Int(OffsetFlag.Name), false) + if err != nil { + return err + } + + plans, preflight, err := videoBuildTrimPlans(conf, results, trimDuration) + if err != nil { + return err + } + + if len(plans) == 0 { + log.Infof("trim: found no matching videos") + return nil + } + + if !ctx.Bool("dry-run") { + if err = videoCheckFreeSpace(preflight); err != nil { + return err + } + } + + if !ctx.Bool("dry-run") && !RunNonInteractively(ctx.Bool("yes")) { + prompt := promptui.Prompt{ + Label: fmt.Sprintf("Trim %d video files?", len(plans)), + IsConfirm: true, + } + if _, err = prompt.Run(); err != nil { + log.Info("trim: cancelled") + return nil + } + } + + var processed, skipped, failed int + convert := get.Convert() + + for _, plan := range plans { + if ctx.Bool("dry-run") { + log.Infof("trim: would trim %s by %s", clean.Log(plan.IndexPath), trimDuration.String()) + skipped++ + continue + } + + if err = videoTrimFile(conf, convert, plan, trimDuration, ctx.Bool(videoNoBackupFlag.Name)); err != nil { + log.Errorf("trim: %s", clean.Error(err)) + failed++ + continue + } + + processed++ + } + + log.Infof("trim: processed %d, skipped %d, failed %d", processed, skipped, failed) + + if failed > 0 { + return fmt.Errorf("trim: %d files failed", failed) + } + + return nil + }) +} + +// videoTrimPlan holds a resolved trim operation for a single video file. +type videoTrimPlan struct { + IndexPath string + SrcPath string + DestPath string + Duration time.Duration + SizeBytes int64 + Sidecar bool +} + +// videoBuildTrimPlans prepares trim operations and preflight size checks from search results. +func videoBuildTrimPlans(conf *config.Config, results []search.Photo, trimDuration time.Duration) ([]videoTrimPlan, []videoOutputPlan, error) { + plans := make([]videoTrimPlan, 0, len(results)) + preflight := make([]videoOutputPlan, 0, len(results)) + + absTrim := trimDuration + if absTrim < 0 { + absTrim = -absTrim + } + + for _, found := range results { + if found.FileSidecar { + log.Warnf("trim: skipping sidecar file %s", clean.Log(found.FileName)) + continue + } + + if found.MediaType == entity.MediaLive { + log.Warnf("trim: skipping live photo video %s", clean.Log(found.FileName)) + continue + } + + if found.FileDuration <= 0 { + log.Warnf("trim: missing duration for %s", clean.Log(found.FileName)) + continue + } + + remaining := found.FileDuration - absTrim + if remaining < time.Second { + log.Errorf("trim: duration exceeds available length for %s", clean.Log(found.FileName)) + continue + } + + srcPath := photoprism.FileName(found.FileRoot, found.FileName) + if !fs.FileExistsNotEmpty(srcPath) { + log.Warnf("trim: missing file %s", clean.Log(srcPath)) + continue + } + + destPath := srcPath + useSidecar := false + + if conf.ReadOnly() || !fs.PathWritable(filepath.Dir(srcPath)) || !fs.Writable(srcPath) { + if !conf.SidecarWritable() || !fs.PathWritable(conf.SidecarPath()) { + return nil, nil, config.ErrReadOnly + } + + destPath = videoSidecarPath(srcPath, conf.OriginalsPath(), conf.SidecarPath()) + useSidecar = true + } + + if useSidecar && fs.FileExistsNotEmpty(destPath) { + log.Warnf("trim: output already exists %s", clean.Log(destPath)) + continue + } + + plans = append(plans, videoTrimPlan{ + IndexPath: srcPath, + SrcPath: srcPath, + DestPath: destPath, + Duration: found.FileDuration, + SizeBytes: found.FileSize, + Sidecar: useSidecar, + }) + + preflight = append(preflight, videoOutputPlan{ + Destination: destPath, + SizeBytes: found.FileSize, + }) + } + + return plans, preflight, nil +} + +// videoTrimFile executes the trim operation and refreshes previews/thumbnails before reindexing. +func videoTrimFile(conf *config.Config, convert *photoprism.Convert, plan videoTrimPlan, trimDuration time.Duration, noBackup bool) error { + start := time.Duration(0) + absTrim := trimDuration + if absTrim < 0 { + absTrim = -absTrim + } + + if trimDuration > 0 { + start = absTrim + } + + remaining := plan.Duration - absTrim + if remaining < time.Second { + return fmt.Errorf("remaining duration too short for %s", clean.Log(plan.SrcPath)) + } + + destDir := filepath.Dir(plan.DestPath) + tempPath, err := videoTempPath(destDir, ".trim-*.tmp") + if err != nil { + return err + } + + cmd := videoTrimCmd(conf.FFmpegBin(), plan.SrcPath, tempPath, start, remaining) + cmd.Env = append(cmd.Env, fmt.Sprintf("HOME=%s", conf.CmdCachePath())) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err = cmd.Run(); err != nil { + return fmt.Errorf("ffmpeg failed for %s: %s", clean.Log(plan.SrcPath), strings.TrimSpace(stderr.String())) + } + + if !fs.FileExistsNotEmpty(tempPath) { + _ = os.Remove(tempPath) + return fmt.Errorf("trim output missing for %s", clean.Log(plan.SrcPath)) + } + + if err = os.Chmod(tempPath, fs.ModeFile); err != nil { + return err + } + + if plan.Sidecar { + if fs.FileExists(plan.DestPath) { + _ = os.Remove(tempPath) + return fmt.Errorf("output already exists %s", clean.Log(plan.DestPath)) + } + + if err = os.Rename(tempPath, plan.DestPath); err != nil { + _ = os.Remove(tempPath) + return err + } + } else { + if noBackup { + _ = os.Remove(plan.DestPath) + } else { + backupPath := plan.DestPath + ".backup" + if fs.FileExists(backupPath) { + _ = os.Remove(backupPath) + } + if err = os.Rename(plan.DestPath, backupPath); err != nil { + _ = os.Remove(tempPath) + return err + } + _ = os.Chmod(backupPath, fs.ModeBackupFile) + } + + if err = os.Rename(tempPath, plan.DestPath); err != nil { + _ = os.Remove(tempPath) + return err + } + } + + mediaFile, err := photoprism.NewMediaFile(plan.DestPath) + if err != nil { + return err + } + + if convert != nil { + if img, imgErr := convert.ToImage(mediaFile, true); imgErr != nil { + log.Warnf("trim: %s", clean.Error(imgErr)) + } else if img != nil { + if thumbsErr := img.GenerateThumbnails(conf.ThumbCachePath(), true); thumbsErr != nil { + log.Warnf("trim: %s", clean.Error(thumbsErr)) + } + } + } + + return videoReindexRelated(conf, plan.IndexPath) +} + +// videoTrimCmd builds an ffmpeg command that trims a source file with stream copy. +func videoTrimCmd(ffmpegBin, srcName, destName string, start, duration time.Duration) *exec.Cmd { + args := []string{ + "-hide_banner", + "-y", + } + + if start > 0 { + args = append(args, "-ss", videoFFmpegSeconds(start)) + } + + args = append(args, + "-i", srcName, + "-t", videoFFmpegSeconds(duration), + "-map", "0", + "-dn", + "-ignore_unknown", + "-codec", "copy", + "-avoid_negative_ts", "make_zero", + destName, + ) + + // #nosec G204 -- arguments are built from validated inputs and config. + return exec.Command(ffmpegBin, args...) +}