mirror of
https://github.com/photoprism/photoprism.git
synced 2026-01-22 18:18:39 +00:00
CLI: Add "photoprism video" subcommands
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
572b6e7311
commit
4b8c41b96d
12 changed files with 1745 additions and 0 deletions
|
|
@ -56,6 +56,7 @@ var PhotoPrism = []*cli.Command{
|
|||
StatusCommand,
|
||||
IndexCommand,
|
||||
FindCommand,
|
||||
VideoCommands,
|
||||
ImportCommand,
|
||||
CopyCommand,
|
||||
DownloadCommand,
|
||||
|
|
|
|||
49
internal/commands/video.go
Normal file
49
internal/commands/video.go
Normal file
|
|
@ -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",
|
||||
}
|
||||
331
internal/commands/video_helpers.go
Normal file
331
internal/commands/video_helpers.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
79
internal/commands/video_helpers_test.go
Normal file
79
internal/commands/video_helpers_test.go
Normal file
|
|
@ -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"])
|
||||
})
|
||||
}
|
||||
34
internal/commands/video_index.go
Normal file
34
internal/commands/video_index.go
Normal file
|
|
@ -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
|
||||
}
|
||||
217
internal/commands/video_info.go
Normal file
217
internal/commands/video_info.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
76
internal/commands/video_ls.go
Normal file
76
internal/commands/video_ls.go
Normal file
|
|
@ -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
|
||||
})
|
||||
}
|
||||
303
internal/commands/video_remux.go
Normal file
303
internal/commands/video_remux.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
82
internal/commands/video_search.go
Normal file
82
internal/commands/video_search.go
Normal file
|
|
@ -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
|
||||
}
|
||||
48
internal/commands/video_storage.go
Normal file
48
internal/commands/video_storage.go
Normal file
|
|
@ -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
|
||||
}
|
||||
209
internal/commands/video_transcode.go
Normal file
209
internal/commands/video_transcode.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
316
internal/commands/video_trim.go
Normal file
316
internal/commands/video_trim.go
Normal file
|
|
@ -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]... <duration>",
|
||||
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...)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue