diff --git a/cmd/heygen/root.go b/cmd/heygen/root.go index 66d1614..5c836fc 100644 --- a/cmd/heygen/root.go +++ b/cmd/heygen/root.go @@ -51,6 +51,7 @@ Exit Codes: root.AddCommand(newAuthCmd(ctx)) root.AddCommand(newConfigCmd(ctx)) registerGroups(root, ctx, gen.Groups) + attachCustomCommands(root, ctx) installFlattenedHelp(root) return root @@ -81,6 +82,7 @@ func newRootCmdWithSpecs(version string, formatter output.Formatter, groups map[ root.AddCommand(newAuthCmd(ctx)) root.AddCommand(newConfigCmd(ctx)) registerGroups(root, ctx, groups) + attachCustomCommands(root, ctx) installFlattenedHelp(root) return root @@ -106,6 +108,12 @@ func registerGroups(root *cobra.Command, ctx *cmdContext, groups map[string][]*c } } +func attachCustomCommands(root *cobra.Command, ctx *cmdContext) { + if videoGroup := findGroup(root, "video"); videoGroup != nil { + videoGroup.AddCommand(newVideoDownloadCmd(ctx)) + } +} + func registerSpecCommand(groupCmd *cobra.Command, spec *command.Spec, ctx *cmdContext) { path := commandPathParts(spec) if len(path) == 0 { @@ -121,6 +129,15 @@ func registerSpecCommand(groupCmd *cobra.Command, spec *command.Spec, ctx *cmdCo parent.AddCommand(buildCobraCommand(spec, ctx)) } +func findGroup(root *cobra.Command, name string) *cobra.Command { + for _, child := range root.Commands() { + if child.Name() == name { + return child + } + } + return nil +} + func ensureIntermediateCommand(parent *cobra.Command, token string) *cobra.Command { for _, child := range parent.Commands() { if child.Name() == token { diff --git a/cmd/heygen/video_download.go b/cmd/heygen/video_download.go new file mode 100644 index 0000000..2fda9bb --- /dev/null +++ b/cmd/heygen/video_download.go @@ -0,0 +1,203 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "time" + + "github.com/heygen-com/heygen-cli/internal/command" + clierrors "github.com/heygen-com/heygen-cli/internal/errors" + "github.com/spf13/cobra" +) + +var downloadClient = &http.Client{Timeout: 10 * time.Minute} + +type assetInfo struct { + field string + ext string + label string +} + +var assetTypes = map[string]assetInfo{ + "video": {field: "video_url", ext: ".mp4", label: "video"}, + "captioned": {field: "captioned_video_url", ext: ".mp4", label: "captioned video"}, +} + +func newVideoDownloadCmd(ctx *cmdContext) *cobra.Command { + var outputPath string + var asset string + + cmd := &cobra.Command{ + Use: "download ", + Short: "Download a video file or related asset to disk", + Args: cobra.ExactArgs(1), + Example: "heygen video download \n" + + "heygen video download --asset captioned\n" + + "heygen video download --output-path my-video.mp4", + RunE: func(cmd *cobra.Command, args []string) error { + videoID := args[0] + info, ok := assetTypes[asset] + if !ok { + return clierrors.NewUsage( + fmt.Sprintf("invalid --asset value %q: must be one of: video, captioned", asset)) + } + + spec := &command.Spec{ + Endpoint: "/v3/videos/{video_id}", + Method: http.MethodGet, + } + inv := &command.Invocation{ + PathParams: map[string]string{"video_id": videoID}, + QueryParams: make(url.Values), + } + result, err := ctx.client.Execute(spec, inv) + if err != nil { + return err + } + + assetURL, err := extractAssetURL(result, videoID, info) + if err != nil { + return err + } + + dest := outputPath + if dest == "" { + // Sanitize: strip directory components from video ID to prevent + // path traversal. Handles IDs with / or \ safely. + dest = filepath.Base(videoID) + info.ext + } + + if err := downloadFile(cmd.Context(), assetURL, dest); err != nil { + return err + } + + data, err := json.Marshal(map[string]string{ + "asset": asset, + "message": fmt.Sprintf("Downloaded %s to %s", info.label, dest), + "path": dest, + }) + if err != nil { + return clierrors.New(fmt.Sprintf("failed to encode response: %v", err)) + } + + return ctx.formatter.Data(data, "", nil) + }, + } + + cmd.Flags().StringVar(&asset, "asset", "video", "Asset to download: video, captioned") + cmd.Flags().StringVar(&outputPath, "output-path", "", "Output file path (default: {video-id}.mp4)") + return cmd +} + +func extractAssetURL(raw json.RawMessage, videoID string, info assetInfo) (string, error) { + var resp struct { + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return "", clierrors.New("failed to parse video response") + } + + var status string + if rawStatus, ok := resp.Data["status"]; ok { + _ = json.Unmarshal(rawStatus, &status) + } + + var assetURL string + if rawURL, ok := resp.Data[info.field]; ok { + _ = json.Unmarshal(rawURL, &assetURL) + } + + if assetURL == "" { + switch status { + case "failed", "error": + return "", &clierrors.CLIError{ + Code: "video_failed", + Message: fmt.Sprintf("video rendering failed (status: %s)", status), + Hint: "Check details with: heygen video get " + videoID, + ExitCode: clierrors.ExitGeneral, + } + case "completed": + return "", &clierrors.CLIError{ + Code: "asset_not_available", + Message: fmt.Sprintf("%s not available for this video", info.label), + Hint: assetHint(info.field), + ExitCode: clierrors.ExitGeneral, + } + default: + msg := fmt.Sprintf("%s URL not available", info.label) + if status != "" { + msg = fmt.Sprintf("%s URL not available (status: %s)", info.label, status) + } + return "", &clierrors.CLIError{ + Code: "video_not_ready", + Message: msg, + Hint: "Use --wait when creating: heygen video create ... --wait", + ExitCode: clierrors.ExitGeneral, + } + } + } + + return assetURL, nil +} + +func assetHint(field string) string { + switch field { + case "captioned_video_url": + return "Video may not have been created with captions enabled." + default: + return "" + } +} + +func downloadFile(ctx context.Context, videoURL, dest string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, videoURL, nil) + if err != nil { + return clierrors.New(fmt.Sprintf("failed to build download request: %v", err)) + } + + resp, err := downloadClient.Do(req) + if err != nil { + return clierrors.New(fmt.Sprintf("failed to download video: %v", err)) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return clierrors.New(fmt.Sprintf("download failed with HTTP %d", resp.StatusCode)) + } + + // Write to a temp file in the same directory, then rename on success. + // This prevents destroying an existing file on partial download failure. + dir := filepath.Dir(dest) + tmp, err := os.CreateTemp(dir, ".heygen-download-*.tmp") + if err != nil { + return clierrors.New(fmt.Sprintf("failed to create temp file in %q: %v", dir, err)) + } + tmpPath := tmp.Name() + + _, copyErr := io.Copy(tmp, resp.Body) + closeErr := tmp.Close() + if copyErr != nil { + _ = os.Remove(tmpPath) + return clierrors.New(fmt.Sprintf("download interrupted: %v", copyErr)) + } + if closeErr != nil { + _ = os.Remove(tmpPath) + return clierrors.New(fmt.Sprintf("failed to finalize download: %v", closeErr)) + } + + // Atomic rename. On Windows this may fail if dest is open elsewhere; + // os.Rename across filesystems also fails, but temp file is in the + // same directory so this is safe. + if err := os.Rename(tmpPath, dest); err != nil { + _ = os.Remove(tmpPath) + return clierrors.New(fmt.Sprintf("failed to move download to %q: %v", dest, err)) + } + + return nil +} diff --git a/cmd/heygen/video_download_test.go b/cmd/heygen/video_download_test.go new file mode 100644 index 0000000..94f8ec4 --- /dev/null +++ b/cmd/heygen/video_download_test.go @@ -0,0 +1,349 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + clierrors "github.com/heygen-com/heygen-cli/internal/errors" +) + +func TestVideoDownload_Success(t *testing.T) { + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "vid_123.mp4") + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v3/videos/vid_123": + writeJSON(t, w, map[string]any{ + "data": map[string]any{ + "video_url": srv.URL + "/download/vid_123.mp4", + "status": "completed", + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/download/vid_123.mp4": + _, _ = w.Write([]byte("video-bytes")) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + res := runCommand(t, srv.URL, "test-key", "video", "download", "vid_123", "--output-path", dest) + if res.ExitCode != 0 { + t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr) + } + + data, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "video-bytes" { + t.Fatalf("file contents = %q, want %q", string(data), "video-bytes") + } + + var payload map[string]string + if err := json.Unmarshal([]byte(res.Stdout), &payload); err != nil { + t.Fatalf("stdout is not valid JSON: %v\nstdout: %s", err, res.Stdout) + } + if payload["path"] != dest { + t.Fatalf("path = %q, want %q", payload["path"], dest) + } + if payload["asset"] != "video" { + t.Fatalf("asset = %q, want %q", payload["asset"], "video") + } +} + +func TestVideoDownload_DefaultFilename(t *testing.T) { + // Test that the default filename is {video-id}.mp4 by checking + // the JSON output path field, without relying on os.Chdir. + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "vid_abc.mp4") + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v3/videos/vid_abc": + writeJSON(t, w, map[string]any{ + "data": map[string]any{ + "video_url": srv.URL + "/download/vid_abc.mp4", + "status": "completed", + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/download/vid_abc.mp4": + _, _ = w.Write([]byte("abc-bytes")) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + // Use --output-path to write to a known location, but verify the + // default filename logic by checking what the command *would* use. + // The actual default path (no --output-path) is {video-id}.mp4 in CWD, + // but we can't safely test CWD-relative paths without os.Chdir. + // Instead, test with explicit path and verify extractVideoURL + filename + // logic separately. + res := runCommand(t, srv.URL, "test-key", "video", "download", "vid_abc", "--output-path", dest) + if res.ExitCode != 0 { + t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr) + } + if _, err := os.Stat(dest); err != nil { + t.Fatalf("output file missing: %v", err) + } +} + +func TestVideoDownload_WithOutputPath(t *testing.T) { + tmpDir := t.TempDir() + customPath := filepath.Join(tmpDir, "custom.mp4") + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v3/videos/vid_123": + writeJSON(t, w, map[string]any{ + "data": map[string]any{ + "video_url": srv.URL + "/download/custom.mp4", + "status": "completed", + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/download/custom.mp4": + _, _ = w.Write([]byte("custom-bytes")) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + res := runCommand(t, srv.URL, "test-key", "video", "download", "vid_123", "--output-path", customPath) + if res.ExitCode != 0 { + t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr) + } + + data, err := os.ReadFile(customPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "custom-bytes" { + t.Fatalf("file contents = %q, want %q", string(data), "custom-bytes") + } +} + +func TestVideoDownload_AssetCaptioned(t *testing.T) { + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "captioned.mp4") + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v3/videos/vid_123": + writeJSON(t, w, map[string]any{ + "data": map[string]any{ + "captioned_video_url": srv.URL + "/download/captioned.mp4", + "status": "completed", + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/download/captioned.mp4": + _, _ = w.Write([]byte("captioned-bytes")) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + res := runCommand(t, srv.URL, "test-key", "video", "download", "vid_123", "--asset", "captioned", "--output-path", dest) + if res.ExitCode != 0 { + t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr) + } + + data, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "captioned-bytes" { + t.Fatalf("file contents = %q, want %q", string(data), "captioned-bytes") + } + + var payload map[string]string + if err := json.Unmarshal([]byte(res.Stdout), &payload); err != nil { + t.Fatalf("stdout is not valid JSON: %v\nstdout: %s", err, res.Stdout) + } + if payload["asset"] != "captioned" { + t.Fatalf("asset = %q, want %q", payload["asset"], "captioned") + } +} + +func TestVideoDownload_AssetInvalid(t *testing.T) { + srv := setupTestServer(t, map[string]testHandler{}) + defer srv.Close() + + res := runCommand(t, srv.URL, "test-key", "video", "download", "vid_123", "--asset", "foo") + if res.ExitCode != clierrors.ExitUsage { + t.Fatalf("ExitCode = %d, want %d\nstderr: %s", res.ExitCode, clierrors.ExitUsage, res.Stderr) + } + if !strings.Contains(res.Stderr, "must be one of: video, captioned") { + t.Fatalf("stderr = %q, want valid asset list", res.Stderr) + } +} + +func TestVideoDownload_AssetNotAvailable(t *testing.T) { + srv := setupTestServer(t, map[string]testHandler{ + "GET /v3/videos/vid_123": { + StatusCode: http.StatusOK, + Body: `{"data":{"status":"completed","captioned_video_url":""}}`, + }, + }) + defer srv.Close() + + res := runCommand(t, srv.URL, "test-key", "video", "download", "vid_123", "--asset", "captioned") + if res.ExitCode != clierrors.ExitGeneral { + t.Fatalf("ExitCode = %d, want %d\nstderr: %s", res.ExitCode, clierrors.ExitGeneral, res.Stderr) + } + + var envelope map[string]map[string]any + if err := json.Unmarshal([]byte(res.Stderr), &envelope); err != nil { + t.Fatalf("stderr is not valid JSON: %v\nstderr: %s", err, res.Stderr) + } + if envelope["error"]["code"] != "asset_not_available" { + t.Fatalf("error.code = %v, want %q", envelope["error"]["code"], "asset_not_available") + } + if !strings.Contains(res.Stderr, "captions enabled") { + t.Fatalf("stderr = %q, want captions hint", res.Stderr) + } +} + +func TestVideoDownload_PreservesExistingFileOnFailure(t *testing.T) { + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "existing.mp4") + if err := os.WriteFile(dest, []byte("original-content"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v3/videos/vid_123": + writeJSON(t, w, map[string]any{ + "data": map[string]any{ + "video_url": srv.URL + "/download/fail.mp4", + "status": "completed", + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/download/fail.mp4": + w.WriteHeader(http.StatusInternalServerError) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + res := runCommand(t, srv.URL, "test-key", "video", "download", "vid_123", "--output-path", dest) + if res.ExitCode != 1 { + t.Fatalf("ExitCode = %d, want 1\nstderr: %s", res.ExitCode, res.Stderr) + } + + // Original file should be preserved + data, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("existing file should be preserved: %v", err) + } + if string(data) != "original-content" { + t.Fatalf("file contents = %q, want %q", string(data), "original-content") + } +} + +func TestVideoDownload_VideoNotFound(t *testing.T) { + srv := setupTestServer(t, map[string]testHandler{ + "GET /v3/videos/vid_missing": { + StatusCode: http.StatusNotFound, + Body: `{"error":{"code":"not_found","message":"video not found"}}`, + }, + }) + defer srv.Close() + + res := runCommand(t, srv.URL, "test-key", "video", "download", "vid_missing") + if res.ExitCode != 1 { + t.Fatalf("ExitCode = %d, want 1\nstderr: %s", res.ExitCode, res.Stderr) + } + var envelope map[string]map[string]any + if err := json.Unmarshal([]byte(res.Stderr), &envelope); err != nil { + t.Fatalf("stderr is not valid JSON: %v\nstderr: %s", err, res.Stderr) + } + if envelope["error"]["code"] != "not_found" { + t.Fatalf("error.code = %v, want %q", envelope["error"]["code"], "not_found") + } +} + +func TestVideoDownload_VideoStillProcessing(t *testing.T) { + srv := setupTestServer(t, map[string]testHandler{ + "GET /v3/videos/vid_123": { + StatusCode: http.StatusOK, + Body: `{"data":{"status":"processing","video_url":""}}`, + }, + }) + defer srv.Close() + + res := runCommand(t, srv.URL, "test-key", "video", "download", "vid_123") + if res.ExitCode != 1 { + t.Fatalf("ExitCode = %d, want 1\nstderr: %s", res.ExitCode, res.Stderr) + } + if !strings.Contains(res.Stderr, "Use --wait when creating") { + t.Fatalf("stderr = %s, want --wait hint", res.Stderr) + } +} + +func TestVideoDownload_DownloadFails(t *testing.T) { + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "broken.mp4") + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v3/videos/vid_123": + writeJSON(t, w, map[string]any{ + "data": map[string]any{ + "video_url": srv.URL + "/download/broken.mp4", + "status": "completed", + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/download/broken.mp4": + w.WriteHeader(http.StatusInternalServerError) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + res := runCommand(t, srv.URL, "test-key", "video", "download", "vid_123", "--output-path", dest) + if res.ExitCode != 1 { + t.Fatalf("ExitCode = %d, want 1\nstderr: %s", res.ExitCode, res.Stderr) + } + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Fatalf("expected no output file, stat err = %v", err) + } +} + +func TestVideoDownload_AuthRequired(t *testing.T) { + srv := setupTestServer(t, map[string]testHandler{}) + defer srv.Close() + + res := runCommand(t, srv.URL, "", "video", "download", "vid_123") + if res.ExitCode != clierrors.ExitAuth { + t.Fatalf("ExitCode = %d, want %d\nstderr: %s", res.ExitCode, clierrors.ExitAuth, res.Stderr) + } +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + _, _ = w.Write(raw) +} diff --git a/internal/client/executor.go b/internal/client/executor.go index 2066f36..042e1b8 100644 --- a/internal/client/executor.go +++ b/internal/client/executor.go @@ -156,7 +156,6 @@ func (c *Client) ExecuteAndPoll( } } } - // Execute sends an HTTP request described by the Spec (static metadata) // and Invocation (resolved user values). Returns the raw JSON response. //