Skip to content

Commit 4b8c41b

Browse files
committed
CLI: Add "photoprism video" subcommands
Signed-off-by: Michael Mayer <[email protected]>
1 parent 572b6e7 commit 4b8c41b

File tree

12 files changed

+1745
-0
lines changed

12 files changed

+1745
-0
lines changed

internal/commands/commands.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ var PhotoPrism = []*cli.Command{
5656
StatusCommand,
5757
IndexCommand,
5858
FindCommand,
59+
VideoCommands,
5960
ImportCommand,
6061
CopyCommand,
6162
DownloadCommand,

internal/commands/video.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package commands
2+
3+
import "github.com/urfave/cli/v2"
4+
5+
// VideoCommands configures the CLI subcommands for working with indexed videos.
6+
var VideoCommands = &cli.Command{
7+
Name: "video",
8+
Usage: "Video subcommands",
9+
Subcommands: []*cli.Command{
10+
VideoListCommand,
11+
VideoTrimCommand,
12+
VideoRemuxCommand,
13+
VideoTranscodeCommand,
14+
VideoInfoCommand,
15+
},
16+
}
17+
18+
// videoCountFlag limits the number of results returned by video commands.
19+
var videoCountFlag = &cli.UintFlag{
20+
Name: "count",
21+
Aliases: []string{"n"},
22+
Usage: "maximum `NUMBER` of results",
23+
Value: 10000,
24+
}
25+
26+
// videoIncludeSidecarFlag includes sidecar video files in list output.
27+
var videoIncludeSidecarFlag = &cli.BoolFlag{
28+
Name: "include-sidecar",
29+
Usage: "include sidecar video files in results",
30+
}
31+
32+
// videoForceFlag allows overwriting existing output files for remux/transcode.
33+
var videoForceFlag = &cli.BoolFlag{
34+
Name: "force",
35+
Aliases: []string{"f"},
36+
Usage: "replace existing output files",
37+
}
38+
39+
// videoNoBackupFlag skips creating .backup files for in-place mutations.
40+
var videoNoBackupFlag = &cli.BoolFlag{
41+
Name: "no-backup",
42+
Usage: "do not keep a .backup copy of original files",
43+
}
44+
45+
// videoVerboseFlag adds raw metadata to video info output.
46+
var videoVerboseFlag = &cli.BoolFlag{
47+
Name: "verbose",
48+
Usage: "include raw metadata output",
49+
}

internal/commands/video_helpers.go

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
package commands
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strconv"
9+
"strings"
10+
"time"
11+
12+
"github.com/dustin/go-humanize"
13+
14+
"github.com/photoprism/photoprism/internal/entity/search"
15+
"github.com/photoprism/photoprism/pkg/txt/report"
16+
)
17+
18+
// videoNormalizeFilter converts CLI args into a search query, mapping bare tokens to name/filename filters.
19+
func videoNormalizeFilter(args []string) string {
20+
parts := make([]string, 0, len(args))
21+
22+
for _, arg := range args {
23+
token := strings.TrimSpace(arg)
24+
if token == "" {
25+
continue
26+
}
27+
28+
if strings.Contains(token, ":") {
29+
parts = append(parts, token)
30+
continue
31+
}
32+
33+
if strings.Contains(token, "/") {
34+
parts = append(parts, fmt.Sprintf("filename:%s", token))
35+
} else {
36+
parts = append(parts, fmt.Sprintf("name:%s", token))
37+
}
38+
}
39+
40+
return strings.TrimSpace(strings.Join(parts, " "))
41+
}
42+
43+
// videoSplitTrimArgs separates filter args from the trailing trim duration argument.
44+
func videoSplitTrimArgs(args []string) ([]string, string, error) {
45+
if len(args) == 0 {
46+
return nil, "", fmt.Errorf("missing duration argument")
47+
}
48+
49+
filterArgs := make([]string, len(args)-1)
50+
copy(filterArgs, args[:len(args)-1])
51+
52+
durationArg := strings.TrimSpace(args[len(args)-1])
53+
if durationArg == "" {
54+
return nil, "", fmt.Errorf("missing duration argument")
55+
}
56+
57+
return filterArgs, durationArg, nil
58+
}
59+
60+
// videoParseTrimDuration parses the trim duration string with the precedence and rules from the spec.
61+
func videoParseTrimDuration(value string) (time.Duration, error) {
62+
raw := strings.TrimSpace(value)
63+
if raw == "" {
64+
return 0, fmt.Errorf("duration is empty")
65+
}
66+
67+
sign := 1
68+
if strings.HasPrefix(raw, "-") {
69+
sign = -1
70+
raw = strings.TrimSpace(strings.TrimPrefix(raw, "-"))
71+
}
72+
73+
if raw == "" {
74+
return 0, fmt.Errorf("duration is empty")
75+
}
76+
77+
if isDigits(raw) {
78+
secs, err := strconv.ParseInt(raw, 10, 64)
79+
if err != nil {
80+
return 0, fmt.Errorf("invalid duration %q", value)
81+
}
82+
if secs == 0 {
83+
return 0, fmt.Errorf("duration must be non-zero")
84+
}
85+
return time.Duration(sign) * time.Duration(secs) * time.Second, nil
86+
}
87+
88+
if strings.Contains(raw, ":") {
89+
if strings.ContainsAny(raw, "hms") {
90+
return 0, fmt.Errorf("invalid duration %q", value)
91+
}
92+
93+
parts := strings.Split(raw, ":")
94+
if len(parts) != 2 && len(parts) != 3 {
95+
return 0, fmt.Errorf("invalid duration %q", value)
96+
}
97+
98+
for _, p := range parts {
99+
if !isDigits(p) {
100+
return 0, fmt.Errorf("invalid duration %q", value)
101+
}
102+
}
103+
104+
if len(parts) == 2 && len(parts[1]) != 2 {
105+
return 0, fmt.Errorf("invalid duration %q", value)
106+
}
107+
108+
if len(parts) == 3 && (len(parts[1]) != 2 || len(parts[2]) != 2) {
109+
return 0, fmt.Errorf("invalid duration %q", value)
110+
}
111+
112+
var hours, minutes, seconds int64
113+
114+
if len(parts) == 2 {
115+
minutes, _ = strconv.ParseInt(parts[0], 10, 64)
116+
seconds, _ = strconv.ParseInt(parts[1], 10, 64)
117+
} else {
118+
hours, _ = strconv.ParseInt(parts[0], 10, 64)
119+
minutes, _ = strconv.ParseInt(parts[1], 10, 64)
120+
seconds, _ = strconv.ParseInt(parts[2], 10, 64)
121+
}
122+
123+
total := time.Duration(hours)*time.Hour + time.Duration(minutes)*time.Minute + time.Duration(seconds)*time.Second
124+
if total == 0 {
125+
return 0, fmt.Errorf("duration must be non-zero")
126+
}
127+
128+
return time.Duration(sign) * total, nil
129+
}
130+
131+
parsed, err := time.ParseDuration(applySign(raw, sign))
132+
if err != nil {
133+
return 0, fmt.Errorf("invalid duration %q", value)
134+
}
135+
136+
if parsed == 0 {
137+
return 0, fmt.Errorf("duration must be non-zero")
138+
}
139+
140+
return parsed, nil
141+
}
142+
143+
// videoListColumns returns the ordered column list for the video ls output.
144+
func videoListColumns(includeSidecar bool) []string {
145+
cols := []string{"Name", "Root"}
146+
if includeSidecar {
147+
cols = append(cols, "Sidecar")
148+
}
149+
return append(cols, "Duration", "Codec", "Mime", "Width", "Height", "FPS", "Frames", "Size", "Hash")
150+
}
151+
152+
// videoListRow renders a search result row for table outputs with human-friendly values.
153+
func videoListRow(found search.Photo, includeSidecar bool) []string {
154+
row := []string{found.FileName, found.FileRoot}
155+
if includeSidecar {
156+
row = append(row, strconv.FormatBool(found.FileSidecar))
157+
}
158+
159+
row = append(row,
160+
videoHumanDuration(found.FileDuration),
161+
found.FileCodec,
162+
found.FileMime,
163+
videoHumanInt(found.FileWidth),
164+
videoHumanInt(found.FileHeight),
165+
videoHumanFloat(found.FileFPS),
166+
videoHumanInt(found.FileFrames),
167+
videoHumanSize(found.FileSize),
168+
found.FileHash,
169+
)
170+
171+
return row
172+
}
173+
174+
// videoListJSONRow renders a search result row for JSON output with raw numeric values.
175+
func videoListJSONRow(found search.Photo, includeSidecar bool) map[string]interface{} {
176+
data := map[string]interface{}{
177+
"name": found.FileName,
178+
"root": found.FileRoot,
179+
"duration": found.FileDuration.Nanoseconds(),
180+
"codec": found.FileCodec,
181+
"mime": found.FileMime,
182+
"width": found.FileWidth,
183+
"height": found.FileHeight,
184+
"fps": found.FileFPS,
185+
"frames": found.FileFrames,
186+
"size": videoNonNegativeSize(found.FileSize),
187+
"hash": found.FileHash,
188+
}
189+
190+
if includeSidecar {
191+
data["sidecar"] = found.FileSidecar
192+
}
193+
194+
return data
195+
}
196+
197+
// videoListJSON marshals a list of JSON rows using the canonical keys for each column.
198+
func videoListJSON(rows []map[string]interface{}, cols []string) (string, error) {
199+
canon := make([]string, len(cols))
200+
for i, col := range cols {
201+
canon[i] = report.CanonKey(col)
202+
}
203+
204+
payload := make([]map[string]interface{}, 0, len(rows))
205+
206+
for _, row := range rows {
207+
item := make(map[string]interface{}, len(canon))
208+
for _, key := range canon {
209+
item[key] = row[key]
210+
}
211+
payload = append(payload, item)
212+
}
213+
214+
data, err := json.Marshal(payload)
215+
if err != nil {
216+
return "", err
217+
}
218+
219+
return string(data), nil
220+
}
221+
222+
// videoHumanDuration formats a duration for human-readable tables.
223+
func videoHumanDuration(d time.Duration) string {
224+
if d <= 0 {
225+
return ""
226+
}
227+
228+
return d.String()
229+
}
230+
231+
// videoHumanInt formats non-zero integers for human-readable tables.
232+
func videoHumanInt(value int) string {
233+
if value <= 0 {
234+
return ""
235+
}
236+
237+
return strconv.Itoa(value)
238+
}
239+
240+
// videoHumanFloat formats non-zero floats without unnecessary trailing zeros.
241+
func videoHumanFloat(value float64) string {
242+
if value <= 0 {
243+
return ""
244+
}
245+
246+
return strconv.FormatFloat(value, 'f', -1, 64)
247+
}
248+
249+
// videoHumanSize formats file sizes with human-readable units.
250+
func videoHumanSize(size int64) string {
251+
return humanize.Bytes(uint64(videoNonNegativeSize(size))) //nolint:gosec // size is bounded to non-negative values
252+
}
253+
254+
// videoNonNegativeSize clamps negative sizes to zero before formatting.
255+
func videoNonNegativeSize(size int64) int64 {
256+
if size < 0 {
257+
return 0
258+
}
259+
260+
return size
261+
}
262+
263+
// videoTempPath creates a temporary file path in the destination directory.
264+
func videoTempPath(dir, pattern string) (string, error) {
265+
if dir == "" {
266+
return "", fmt.Errorf("temp directory is empty")
267+
}
268+
269+
tmpFile, err := os.CreateTemp(dir, pattern)
270+
if err != nil {
271+
return "", err
272+
}
273+
274+
if err = tmpFile.Close(); err != nil {
275+
return "", err
276+
}
277+
278+
if err = os.Remove(tmpFile.Name()); err != nil {
279+
return "", err
280+
}
281+
282+
return tmpFile.Name(), nil
283+
}
284+
285+
// videoFFmpegSeconds converts a duration into an ffmpeg-friendly seconds string.
286+
func videoFFmpegSeconds(d time.Duration) string {
287+
seconds := d.Seconds()
288+
return strconv.FormatFloat(seconds, 'f', 3, 64)
289+
}
290+
291+
// isDigits reports whether the string contains only decimal digits.
292+
func isDigits(value string) bool {
293+
if value == "" {
294+
return false
295+
}
296+
297+
for _, r := range value {
298+
if r < '0' || r > '9' {
299+
return false
300+
}
301+
}
302+
303+
return true
304+
}
305+
306+
// applySign applies a numeric sign to a duration string for parsing.
307+
func applySign(value string, sign int) string {
308+
if sign >= 0 {
309+
return value
310+
}
311+
312+
return "-" + value
313+
}
314+
315+
// videoSidecarPath builds the sidecar destination path for an originals file without creating directories.
316+
func videoSidecarPath(srcName, originalsPath, sidecarPath string) string {
317+
src := filepath.ToSlash(srcName)
318+
orig := filepath.ToSlash(originalsPath)
319+
320+
if orig != "" {
321+
orig = strings.TrimSuffix(orig, "/") + "/"
322+
}
323+
324+
rel := strings.TrimPrefix(src, orig)
325+
if rel == src {
326+
rel = filepath.Base(srcName)
327+
}
328+
329+
rel = strings.TrimPrefix(rel, "/")
330+
return filepath.Join(sidecarPath, filepath.FromSlash(rel))
331+
}

0 commit comments

Comments
 (0)