Skip to content

Commit 493d778

Browse files
authored
cmd: add video download command (PRINFRA-139) (#38)
## Description Adds `heygen video download <video-id>` — a hand-written command that fetches video metadata from the API, extracts the CDN URL, and streams the file to disk. Supports downloading the regular video or the captioned version via `--asset`. **Flow:** 1. `GET /v3/videos/{video_id}` via the authenticated API client → extracts the asset URL from the response 2. HTTP GET to the CDN URL via a dedicated client (pre-signed URL, no auth needed, 10-minute timeout) **`--asset` flag:** Selects which file to download from the API response. The video response includes multiple downloadable URLs (`video_url`, `captioned_video_url`, etc.). Phase 1 supports two values: - `video` (default) — `data.video_url`, the rendered video - `captioned` — `data.captioned_video_url`, the video with captions burned in Invalid values get exit code 2 listing valid options. If the video is completed but the requested asset URL is null (e.g., captioned version wasn't generated), exit 1 with an asset-specific hint. The `assetTypes` map is extensible — Phase 2 can add `subtitle`, `thumbnail`, `gif` as one-line entries. **Output path:** Defaults to `{video-id}.mp4` in the current directory. Override with `--output-path custom.mp4`. The video ID is sanitized with `filepath.Base()` to prevent path traversal. **Safe file writes:** Downloads to a temp file in the same directory, then atomic-renames on success. If the download fails mid-stream, any existing file at the destination is preserved — no data loss. **Status-aware errors:** - Video still processing → exit 1 with hint: "Use --wait when creating" - Video failed → exit 1 with hint: "Check details with: heygen video get {id}" - Video not found → exit 1 with standard API error **Success output:** JSON to stdout with `asset`, `message`, and `path` fields — agents know which asset was downloaded and where. **Command wiring:** Introduces `attachCustomCommands()` and `findGroup()` helpers in root.go for attaching hand-written commands to generated group nodes. Stacked on PR #34 (polling). Linear: PRINFRA-139 ## Testing 11 tests covering: success with file content verification, default filename, custom `--output-path`, existing file preserved on download failure, video not found (404), video still processing (hint to use --wait), CDN download failure (no partial file left), auth required (exit 3), `--asset captioned` downloads correct file, `--asset foo` returns exit 2 with valid options, captioned URL null on completed video returns exit 1 with hint. All tests use `t.TempDir()` with absolute paths — no `os.Chdir`, safe for `t.Parallel()`. All use `httptest.Server` — no real API calls.
1 parent 57e3b02 commit 493d778

4 files changed

Lines changed: 569 additions & 1 deletion

File tree

cmd/heygen/root.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Exit Codes:
5151
root.AddCommand(newAuthCmd(ctx))
5252
root.AddCommand(newConfigCmd(ctx))
5353
registerGroups(root, ctx, gen.Groups)
54+
attachCustomCommands(root, ctx)
5455
installFlattenedHelp(root)
5556

5657
return root
@@ -81,6 +82,7 @@ func newRootCmdWithSpecs(version string, formatter output.Formatter, groups map[
8182
root.AddCommand(newAuthCmd(ctx))
8283
root.AddCommand(newConfigCmd(ctx))
8384
registerGroups(root, ctx, groups)
85+
attachCustomCommands(root, ctx)
8486
installFlattenedHelp(root)
8587

8688
return root
@@ -106,6 +108,12 @@ func registerGroups(root *cobra.Command, ctx *cmdContext, groups map[string][]*c
106108
}
107109
}
108110

111+
func attachCustomCommands(root *cobra.Command, ctx *cmdContext) {
112+
if videoGroup := findGroup(root, "video"); videoGroup != nil {
113+
videoGroup.AddCommand(newVideoDownloadCmd(ctx))
114+
}
115+
}
116+
109117
func registerSpecCommand(groupCmd *cobra.Command, spec *command.Spec, ctx *cmdContext) {
110118
path := commandPathParts(spec)
111119
if len(path) == 0 {
@@ -121,6 +129,15 @@ func registerSpecCommand(groupCmd *cobra.Command, spec *command.Spec, ctx *cmdCo
121129
parent.AddCommand(buildCobraCommand(spec, ctx))
122130
}
123131

132+
func findGroup(root *cobra.Command, name string) *cobra.Command {
133+
for _, child := range root.Commands() {
134+
if child.Name() == name {
135+
return child
136+
}
137+
}
138+
return nil
139+
}
140+
124141
func ensureIntermediateCommand(parent *cobra.Command, token string) *cobra.Command {
125142
for _, child := range parent.Commands() {
126143
if child.Name() == token {

cmd/heygen/video_download.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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+
type assetInfo struct {
22+
field string
23+
ext string
24+
label string
25+
}
26+
27+
var assetTypes = map[string]assetInfo{
28+
"video": {field: "video_url", ext: ".mp4", label: "video"},
29+
"captioned": {field: "captioned_video_url", ext: ".mp4", label: "captioned video"},
30+
}
31+
32+
func newVideoDownloadCmd(ctx *cmdContext) *cobra.Command {
33+
var outputPath string
34+
var asset string
35+
36+
cmd := &cobra.Command{
37+
Use: "download <video-id>",
38+
Short: "Download a video file or related asset to disk",
39+
Args: cobra.ExactArgs(1),
40+
Example: "heygen video download <video-id>\n" +
41+
"heygen video download <video-id> --asset captioned\n" +
42+
"heygen video download <video-id> --output-path my-video.mp4",
43+
RunE: func(cmd *cobra.Command, args []string) error {
44+
videoID := args[0]
45+
info, ok := assetTypes[asset]
46+
if !ok {
47+
return clierrors.NewUsage(
48+
fmt.Sprintf("invalid --asset value %q: must be one of: video, captioned", asset))
49+
}
50+
51+
spec := &command.Spec{
52+
Endpoint: "/v3/videos/{video_id}",
53+
Method: http.MethodGet,
54+
}
55+
inv := &command.Invocation{
56+
PathParams: map[string]string{"video_id": videoID},
57+
QueryParams: make(url.Values),
58+
}
59+
result, err := ctx.client.Execute(spec, inv)
60+
if err != nil {
61+
return err
62+
}
63+
64+
assetURL, err := extractAssetURL(result, videoID, info)
65+
if err != nil {
66+
return err
67+
}
68+
69+
dest := outputPath
70+
if dest == "" {
71+
// Sanitize: strip directory components from video ID to prevent
72+
// path traversal. Handles IDs with / or \ safely.
73+
dest = filepath.Base(videoID) + info.ext
74+
}
75+
76+
if err := downloadFile(cmd.Context(), assetURL, dest); err != nil {
77+
return err
78+
}
79+
80+
data, err := json.Marshal(map[string]string{
81+
"asset": asset,
82+
"message": fmt.Sprintf("Downloaded %s to %s", info.label, dest),
83+
"path": dest,
84+
})
85+
if err != nil {
86+
return clierrors.New(fmt.Sprintf("failed to encode response: %v", err))
87+
}
88+
89+
return ctx.formatter.Data(data, "", nil)
90+
},
91+
}
92+
93+
cmd.Flags().StringVar(&asset, "asset", "video", "Asset to download: video, captioned")
94+
cmd.Flags().StringVar(&outputPath, "output-path", "", "Output file path (default: {video-id}.mp4)")
95+
return cmd
96+
}
97+
98+
func extractAssetURL(raw json.RawMessage, videoID string, info assetInfo) (string, error) {
99+
var resp struct {
100+
Data map[string]json.RawMessage `json:"data"`
101+
}
102+
if err := json.Unmarshal(raw, &resp); err != nil {
103+
return "", clierrors.New("failed to parse video response")
104+
}
105+
106+
var status string
107+
if rawStatus, ok := resp.Data["status"]; ok {
108+
_ = json.Unmarshal(rawStatus, &status)
109+
}
110+
111+
var assetURL string
112+
if rawURL, ok := resp.Data[info.field]; ok {
113+
_ = json.Unmarshal(rawURL, &assetURL)
114+
}
115+
116+
if assetURL == "" {
117+
switch status {
118+
case "failed", "error":
119+
return "", &clierrors.CLIError{
120+
Code: "video_failed",
121+
Message: fmt.Sprintf("video rendering failed (status: %s)", status),
122+
Hint: "Check details with: heygen video get " + videoID,
123+
ExitCode: clierrors.ExitGeneral,
124+
}
125+
case "completed":
126+
return "", &clierrors.CLIError{
127+
Code: "asset_not_available",
128+
Message: fmt.Sprintf("%s not available for this video", info.label),
129+
Hint: assetHint(info.field),
130+
ExitCode: clierrors.ExitGeneral,
131+
}
132+
default:
133+
msg := fmt.Sprintf("%s URL not available", info.label)
134+
if status != "" {
135+
msg = fmt.Sprintf("%s URL not available (status: %s)", info.label, status)
136+
}
137+
return "", &clierrors.CLIError{
138+
Code: "video_not_ready",
139+
Message: msg,
140+
Hint: "Use --wait when creating: heygen video create ... --wait",
141+
ExitCode: clierrors.ExitGeneral,
142+
}
143+
}
144+
}
145+
146+
return assetURL, nil
147+
}
148+
149+
func assetHint(field string) string {
150+
switch field {
151+
case "captioned_video_url":
152+
return "Video may not have been created with captions enabled."
153+
default:
154+
return ""
155+
}
156+
}
157+
158+
func downloadFile(ctx context.Context, videoURL, dest string) error {
159+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, videoURL, nil)
160+
if err != nil {
161+
return clierrors.New(fmt.Sprintf("failed to build download request: %v", err))
162+
}
163+
164+
resp, err := downloadClient.Do(req)
165+
if err != nil {
166+
return clierrors.New(fmt.Sprintf("failed to download video: %v", err))
167+
}
168+
defer resp.Body.Close()
169+
170+
if resp.StatusCode != http.StatusOK {
171+
return clierrors.New(fmt.Sprintf("download failed with HTTP %d", resp.StatusCode))
172+
}
173+
174+
// Write to a temp file in the same directory, then rename on success.
175+
// This prevents destroying an existing file on partial download failure.
176+
dir := filepath.Dir(dest)
177+
tmp, err := os.CreateTemp(dir, ".heygen-download-*.tmp")
178+
if err != nil {
179+
return clierrors.New(fmt.Sprintf("failed to create temp file in %q: %v", dir, err))
180+
}
181+
tmpPath := tmp.Name()
182+
183+
_, copyErr := io.Copy(tmp, resp.Body)
184+
closeErr := tmp.Close()
185+
if copyErr != nil {
186+
_ = os.Remove(tmpPath)
187+
return clierrors.New(fmt.Sprintf("download interrupted: %v", copyErr))
188+
}
189+
if closeErr != nil {
190+
_ = os.Remove(tmpPath)
191+
return clierrors.New(fmt.Sprintf("failed to finalize download: %v", closeErr))
192+
}
193+
194+
// Atomic rename. On Windows this may fail if dest is open elsewhere;
195+
// os.Rename across filesystems also fails, but temp file is in the
196+
// same directory so this is safe.
197+
if err := os.Rename(tmpPath, dest); err != nil {
198+
_ = os.Remove(tmpPath)
199+
return clierrors.New(fmt.Sprintf("failed to move download to %q: %v", dest, err))
200+
}
201+
202+
return nil
203+
}

0 commit comments

Comments
 (0)