Skip to content

Commit db5d3cb

Browse files
committed
cmd: add video download command (PRINFRA-139)
1 parent b8adbd7 commit db5d3cb

3 files changed

Lines changed: 440 additions & 0 deletions

File tree

cmd/heygen/root.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Use "heygen config list" to see all configuration settings and their sources.`,
4444
root.AddCommand(newAuthCmd(ctx))
4545
root.AddCommand(newConfigCmd(ctx))
4646
registerGroups(root, ctx, gen.Groups)
47+
attachCustomCommands(root, ctx)
4748
installFlattenedHelp(root)
4849

4950
return root
@@ -74,6 +75,7 @@ func newRootCmdWithSpecs(version string, formatter output.Formatter, groups map[
7475
root.AddCommand(newAuthCmd(ctx))
7576
root.AddCommand(newConfigCmd(ctx))
7677
registerGroups(root, ctx, groups)
78+
attachCustomCommands(root, ctx)
7779
installFlattenedHelp(root)
7880

7981
return root
@@ -99,6 +101,12 @@ func registerGroups(root *cobra.Command, ctx *cmdContext, groups map[string][]*c
99101
}
100102
}
101103

104+
func attachCustomCommands(root *cobra.Command, ctx *cmdContext) {
105+
if videoGroup := findGroup(root, "video"); videoGroup != nil {
106+
videoGroup.AddCommand(newVideoDownloadCmd(ctx))
107+
}
108+
}
109+
102110
func registerSpecCommand(groupCmd *cobra.Command, spec *command.Spec, ctx *cmdContext) {
103111
path := commandPathParts(spec)
104112
if len(path) == 0 {
@@ -114,6 +122,15 @@ func registerSpecCommand(groupCmd *cobra.Command, spec *command.Spec, ctx *cmdCo
114122
parent.AddCommand(buildCobraCommand(spec, ctx))
115123
}
116124

125+
func findGroup(root *cobra.Command, name string) *cobra.Command {
126+
for _, child := range root.Commands() {
127+
if child.Name() == name {
128+
return child
129+
}
130+
}
131+
return nil
132+
}
133+
117134
func ensureIntermediateCommand(parent *cobra.Command, token string) *cobra.Command {
118135
for _, child := range parent.Commands() {
119136
if child.Name() == token {

cmd/heygen/video_download.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"os"
11+
"path/filepath"
12+
"time"
13+
14+
"github.com/heygen-com/heygen-cli/internal/command"
15+
clierrors "github.com/heygen-com/heygen-cli/internal/errors"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
var downloadClient = &http.Client{Timeout: 10 * time.Minute}
20+
21+
func newVideoDownloadCmd(ctx *cmdContext) *cobra.Command {
22+
var outputPath string
23+
24+
cmd := &cobra.Command{
25+
Use: "download <video-id>",
26+
Short: "Download a video file to disk",
27+
Args: cobra.ExactArgs(1),
28+
Example: "heygen video download <video-id>\n" +
29+
"heygen video download <video-id> --output-path my-video.mp4",
30+
RunE: func(cmd *cobra.Command, args []string) error {
31+
videoID := args[0]
32+
33+
spec := &command.Spec{
34+
Endpoint: "/v3/videos/{video_id}",
35+
Method: http.MethodGet,
36+
}
37+
inv := &command.Invocation{
38+
PathParams: map[string]string{"video_id": videoID},
39+
QueryParams: make(url.Values),
40+
}
41+
result, err := ctx.client.Execute(spec, inv)
42+
if err != nil {
43+
return err
44+
}
45+
46+
videoURL, err := extractVideoURL(result, videoID)
47+
if err != nil {
48+
return err
49+
}
50+
51+
dest := outputPath
52+
if dest == "" {
53+
// Sanitize: strip directory components from video ID to prevent
54+
// path traversal. Handles IDs with / or \ safely.
55+
dest = filepath.Base(videoID) + ".mp4"
56+
}
57+
58+
if err := downloadFile(cmd.Context(), videoURL, dest); err != nil {
59+
return err
60+
}
61+
62+
data, err := json.Marshal(map[string]string{
63+
"message": "Downloaded to " + dest,
64+
"path": dest,
65+
})
66+
if err != nil {
67+
return clierrors.New(fmt.Sprintf("failed to encode response: %v", err))
68+
}
69+
70+
return ctx.formatter.Data(data, "", nil)
71+
},
72+
}
73+
74+
cmd.Flags().StringVar(&outputPath, "output-path", "", "Output file path (default: {video-id}.mp4)")
75+
return cmd
76+
}
77+
78+
func extractVideoURL(raw json.RawMessage, videoID string) (string, error) {
79+
var resp struct {
80+
Data struct {
81+
VideoURL string `json:"video_url"`
82+
Status string `json:"status"`
83+
} `json:"data"`
84+
}
85+
if err := json.Unmarshal(raw, &resp); err != nil {
86+
return "", clierrors.New("failed to parse video response")
87+
}
88+
89+
if resp.Data.VideoURL == "" {
90+
switch resp.Data.Status {
91+
case "failed", "error":
92+
return "", &clierrors.CLIError{
93+
Code: "video_failed",
94+
Message: fmt.Sprintf("video rendering failed (status: %s)", resp.Data.Status),
95+
Hint: "Check details with: heygen video get " + videoID,
96+
ExitCode: clierrors.ExitGeneral,
97+
}
98+
default:
99+
msg := "video URL not available"
100+
if resp.Data.Status != "" {
101+
msg = fmt.Sprintf("video URL not available (status: %s)", resp.Data.Status)
102+
}
103+
return "", &clierrors.CLIError{
104+
Code: "video_not_ready",
105+
Message: msg,
106+
Hint: "Use --wait when creating: heygen video create ... --wait",
107+
ExitCode: clierrors.ExitGeneral,
108+
}
109+
}
110+
}
111+
112+
return resp.Data.VideoURL, nil
113+
}
114+
115+
func downloadFile(ctx context.Context, videoURL, dest string) error {
116+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, videoURL, nil)
117+
if err != nil {
118+
return clierrors.New(fmt.Sprintf("failed to build download request: %v", err))
119+
}
120+
121+
resp, err := downloadClient.Do(req)
122+
if err != nil {
123+
return clierrors.New(fmt.Sprintf("failed to download video: %v", err))
124+
}
125+
defer resp.Body.Close()
126+
127+
if resp.StatusCode != http.StatusOK {
128+
return clierrors.New(fmt.Sprintf("download failed with HTTP %d", resp.StatusCode))
129+
}
130+
131+
// Write to a temp file in the same directory, then rename on success.
132+
// This prevents destroying an existing file on partial download failure.
133+
dir := filepath.Dir(dest)
134+
tmp, err := os.CreateTemp(dir, ".heygen-download-*.tmp")
135+
if err != nil {
136+
return clierrors.New(fmt.Sprintf("failed to create temp file in %q: %v", dir, err))
137+
}
138+
tmpPath := tmp.Name()
139+
140+
_, copyErr := io.Copy(tmp, resp.Body)
141+
closeErr := tmp.Close()
142+
if copyErr != nil {
143+
_ = os.Remove(tmpPath)
144+
return clierrors.New(fmt.Sprintf("download interrupted: %v", copyErr))
145+
}
146+
if closeErr != nil {
147+
_ = os.Remove(tmpPath)
148+
return clierrors.New(fmt.Sprintf("failed to finalize download: %v", closeErr))
149+
}
150+
151+
// Atomic rename. On Windows this may fail if dest is open elsewhere;
152+
// os.Rename across filesystems also fails, but temp file is in the
153+
// same directory so this is safe.
154+
if err := os.Rename(tmpPath, dest); err != nil {
155+
_ = os.Remove(tmpPath)
156+
return clierrors.New(fmt.Sprintf("failed to move download to %q: %v", dest, err))
157+
}
158+
159+
return nil
160+
}

0 commit comments

Comments
 (0)