Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions cmd/heygen/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
203 changes: 203 additions & 0 deletions cmd/heygen/video_download.go
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's potentially different files to download here i.e. captioned and non captioned videos

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I didn't notice that API response includes both video_url and captioned_video_url.

We can add flags to choose which file to download. Let's discuss this during our pod sync up.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a flag @jrusso1020

var outputPath string
var asset string

cmd := &cobra.Command{
Use: "download <video-id>",
Short: "Download a video file or related asset to disk",
Args: cobra.ExactArgs(1),
Example: "heygen video download <video-id>\n" +
"heygen video download <video-id> --asset captioned\n" +
"heygen video download <video-id> --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
}
Loading
Loading