Skip to content

Commit e340b3a

Browse files
Tanq16cursoragent
andcommitted
fix ytdlp job, add tests, and manual integration scripts
Harden yt-dlp wrapper with stderr capture, single failure reporting, output-path collision handling, and focused unit tests. Document ytdlp in README, remove the planning doc, and add manual integration scripts for ghr (terraform) and a 500 MB HTTP download to USB test storage. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b3fc8d7 commit e340b3a

7 files changed

Lines changed: 563 additions & 359 deletions

File tree

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ The primary downloaders and their supported aliases are as follows:
2323
| `github-release` | `ghrelease`, `ghr` | Download a platform-correct release asset for a GitHub repo |
2424
| `google-drive` | `gdrive`, `gd`, `drive` | Download file/folder from Google drive with API key or OAuth flow authentication |
2525
| `s3` | - | Multi-threaded download for object, directory, or full AWS S3 bucket |
26+
| `ytdlp` | `yt-dlp`, `youtube-dl`, `ytdl` | Wraps the `yt-dlp` binary for sites Danzo doesn't natively support (YouTube, etc.) |
2627
| `resume` | - | Resume downloads from saved interrupted job state |
2728
| `clean` | - | Clear local cache for interrupted/incomplete downloads |
2829

@@ -63,6 +64,11 @@ Following are examples to get started with various flags:
6364
danzo git "github.com/tanq16/private" --token $(cat /secrets/ghtoken) # (use a PAT; auto-manages for different providers)
6465
danzo git "github.com/tanq16/private" --ssh "/secrets/gh-ssh.key" # (use an SSH key to authenticate)
6566
```
67+
- Download via `yt-dlp` (for sites Danzo doesn't natively support, like YouTube)
68+
```bash
69+
danzo ytdlp "https://www.youtube.com/watch?v=jNQXAC9IVRw" -o me-at-the-zoo.mp4
70+
danzo ytdlp "https://vimeo.com/22439234" # (default yt-dlp output template)
71+
```
6672

6773
## Installation
6874

@@ -130,6 +136,7 @@ Follow these links to quickly jump to the relevant provider:
130136
- [AWS S3 Downloads](#aws-s3-downloads)
131137
- [GitHub Release Downloads](#github-release-downloads)
132138
- [Git Repository Cloning](#git-repository-cloning)
139+
- [yt-dlp Downloads](#yt-dlp-downloads)
133140

134141
### HTTP(S) Downloads
135142

@@ -347,6 +354,33 @@ danzo gitclone github.com/tanq16/private --ssh "/secrets/gh-ssh.key"
347354
348355
</details>
349356

357+
### yt-dlp Downloads
358+
359+
<details>
360+
<summary>Unfold to read</summary>
361+
362+
For sites Danzo doesn't natively support (YouTube, Vimeo with audio, etc.), the `ytdlp` command wraps the `yt-dlp` binary and streams its progress into the Danzo TUI so it looks and behaves like every other Danzo job.
363+
364+
> ✎ Requires `yt-dlp` to be installed and available on `PATH`. Some downloads (e.g., separate video + audio streams) additionally require `ffmpeg` for the final merge step.
365+
366+
```bash
367+
danzo ytdlp "https://www.youtube.com/watch?v=jNQXAC9IVRw" -o me-at-the-zoo.mp4
368+
369+
# Without -o, yt-dlp picks its own filename via its default output template.
370+
danzo ytdlp "https://vimeo.com/22439234"
371+
```
372+
373+
The wrapper:
374+
375+
- Streams `yt-dlp`'s structured `JSON_PROGRESS:` lines into the highway display so percent/byte counters move in real time.
376+
- Switches the bar into a "Merging" sub-status when `yt-dlp` reaches the `[Merger]`/`Merging formats` phase (the bar would otherwise be misleading during the ffmpeg merge).
377+
- Surfaces the failing `ERROR:` line from `yt-dlp`'s stderr in the failure message, so things like a bad URL produce the actual reason rather than a bare `exit status 1`.
378+
- If the chosen output path already exists, falls back to `name-(1).ext` (same behavior as `http` / `git-clone`).
379+
380+
> ✎ This is intentionally a thin wrapper - any flags beyond `--output/-o` should be configured on the `yt-dlp` side (e.g., via its `--config-location`).
381+
382+
</details>
383+
350384
## Tips and Notes
351385

352386
- Use `--for-ai` when invoking Danzo from scripts or AI agents that need stable plain-text output.
@@ -365,6 +399,8 @@ Danzo uses issues for everything. Open an issue and I will add an appropriate ta
365399

366400
Danzo uses `ffmpeg` for merging M3U8 stream segments.
367401

402+
Danzo wraps `yt-dlp` for sites it doesn't natively support.
403+
368404
Danzo draws inspiration from [aria2](https://github.com/aria2/aria2).
369405

370406
Lastly, Danzo uses several Go packages referenced within `go.mod` that allow Danzo to be amazing.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/usr/bin/env bash
2+
# Manual integration test: GitHub release download (unauthenticated, public repo).
3+
# Downloads the latest Terraform release asset for the current OS/arch from hashicorp/terraform.
4+
set -euo pipefail
5+
6+
readonly DEST="${DANZO_TEST_DEST:-/mnt/usbdrive/danzo-tests}"
7+
readonly DANZO_BIN="${DANZO:-danzo}"
8+
9+
mkdir -p "$DEST"
10+
cd "$DEST"
11+
12+
echo "==> ghr integration: hashicorp/terraform -> $DEST"
13+
echo " (asset filename is chosen by danzo from the release, e.g. terraform_*_linux_amd64.zip)"
14+
"$DANZO_BIN" ghr "hashicorp/terraform"
15+
echo "==> done"

integration-tests/02-http-500mb.sh

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/usr/bin/env bash
2+
# Manual integration test: large HTTP download (unauthenticated).
3+
# Fetches a 500 MB test file; uses multi-chunk behavior depending on server and danzo flags.
4+
set -euo pipefail
5+
6+
readonly DEST="${DANZO_TEST_DEST:-/mnt/usbdrive/danzo-tests}"
7+
readonly DANZO_BIN="${DANZO:-danzo}"
8+
readonly URL="https://link.testfile.org/500MB"
9+
readonly OUT_NAME="500mb-testfile.bin"
10+
11+
mkdir -p "$DEST"
12+
13+
echo "==> http integration: 500 MB test file"
14+
echo " URL: $URL"
15+
echo " dest: $DEST/$OUT_NAME"
16+
echo " (this will take a while depending on bandwidth)"
17+
"$DANZO_BIN" http "$URL" -o "$DEST/$OUT_NAME"
18+
echo "==> done"

integration-tests/README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Integration tests (manual)
2+
3+
These are **not** Go unit tests. They are **optional, manually run** checks against live networks and real endpoints. Run them when you want to validate downloaders end-to-end on your machine.
4+
5+
## Requirements
6+
7+
- A built `danzo` binary on `PATH`, or set `DANZO` to the full path of the binary.
8+
- The destination directory must exist or be mountable. By default scripts use:
9+
10+
**`/mnt/usbdrive/danzo-tests`**
11+
12+
Override with `DANZO_TEST_DEST` if that path is wrong on your system.
13+
14+
- Enough free space on the destination (the 500 MB HTTP test alone needs ~500 MB).
15+
16+
## Unauthenticated downloads
17+
18+
| Script | What it exercises |
19+
|--------|-------------------|
20+
| [`01-ghr-terraform.sh`](./01-ghr-terraform.sh) | `danzo ghr` — latest **Hashicorp Terraform** release asset for this OS/arch (public GitHub API, no token). |
21+
| [`02-http-500mb.sh`](./02-http-500mb.sh) | `danzo http`**500 MB** file from `link.testfile.org`. |
22+
23+
### How to run
24+
25+
From the repo root:
26+
27+
```bash
28+
chmod +x integration-tests/*.sh # once
29+
./integration-tests/01-ghr-terraform.sh
30+
./integration-tests/02-http-500mb.sh
31+
```
32+
33+
Or with a custom binary or destination:
34+
35+
```bash
36+
DANZO=/path/to/danzo DANZO_TEST_DEST=/mnt/other/volume/tests ./integration-tests/01-ghr-terraform.sh
37+
```
38+
39+
## Notes
40+
41+
- Scripts use `set -euo pipefail` and abort on failure.
42+
- Outputs land under `DANZO_TEST_DEST` (default `/mnt/usbdrive/danzo-tests`). Clean old artifacts there yourself when done.

internal/jobs/ytdlp/job.go

Lines changed: 104 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ package ytdlpjob
22

33
import (
44
"bufio"
5+
"bytes"
56
"context"
67
"encoding/json"
8+
"fmt"
9+
"io"
10+
"os"
711
"os/exec"
812
"strconv"
913
"strings"
@@ -12,6 +16,13 @@ import (
1216
"github.com/tanq16/danzo/utils"
1317
)
1418

19+
const (
20+
progressPrefix = "JSON_PROGRESS: "
21+
progressTemplate = progressPrefix + `{"downloaded_bytes": "%(progress.downloaded_bytes)s", "total_bytes": "%(progress.total_bytes)s", "total_bytes_estimate": "%(progress.total_bytes_estimate)s", "status": "%(progress.status)s"}`
22+
)
23+
24+
var ytdlpBinary = "yt-dlp"
25+
1526
type YTDLPProgress struct {
1627
DownloadedBytes string `json:"downloaded_bytes"`
1728
TotalBytes string `json:"total_bytes"`
@@ -41,80 +52,130 @@ func (j *YTDLPJob) ID() string {
4152
func (j *YTDLPJob) Type() string { return "ytdlp" }
4253

4354
func (j *YTDLPJob) Run(ctx context.Context, prog chan<- highway.Progress) error {
44-
args := []string{j.URL, "--newline", "--progress-template", `JSON_PROGRESS: {"downloaded_bytes": "%(progress.downloaded_bytes)s", "total_bytes": "%(progress.total_bytes)s", "total_bytes_estimate": "%(progress.total_bytes_estimate)s", "status": "%(progress.status)s"}`}
55+
if j.OutputPath != "" && !strings.Contains(j.OutputPath, "%(") {
56+
if _, err := os.Stat(j.OutputPath); err == nil {
57+
j.OutputPath = utils.RenewOutputPath(j.OutputPath)
58+
}
59+
}
4560

61+
args := []string{j.URL, "--newline", "--progress-template", progressTemplate}
4662
if j.OutputPath != "" {
4763
args = append(args, "-o", j.OutputPath)
4864
}
4965

50-
cmd := exec.CommandContext(ctx, "yt-dlp", args...)
66+
cmd := exec.CommandContext(ctx, ytdlpBinary, args...)
5167

5268
stdout, err := cmd.StdoutPipe()
5369
if err != nil {
54-
return err
70+
return fmt.Errorf("error creating stdout pipe: %v", err)
5571
}
72+
var stderrBuf bytes.Buffer
73+
cmd.Stderr = &stderrBuf
5674

5775
if err := cmd.Start(); err != nil {
58-
return err
76+
return fmt.Errorf("error starting yt-dlp: %v", err)
77+
}
78+
79+
streamErr := streamOutput(j.ID(), stdout, prog)
80+
waitErr := cmd.Wait()
81+
82+
if waitErr != nil {
83+
return ytdlpError(waitErr, stderrBuf.String())
84+
}
85+
if streamErr != nil {
86+
return fmt.Errorf("error reading yt-dlp output: %v", streamErr)
87+
}
88+
89+
prog <- highway.Progress{JobID: j.ID(), Done: true}
90+
return nil
91+
}
92+
93+
func ytdlpError(waitErr error, stderr string) error {
94+
stderr = strings.TrimSpace(stderr)
95+
if stderr == "" {
96+
return fmt.Errorf("yt-dlp failed: %v", waitErr)
5997
}
98+
last := lastErrorLine(stderr)
99+
return fmt.Errorf("yt-dlp failed: %v: %s", waitErr, last)
100+
}
101+
102+
func lastErrorLine(stderr string) string {
103+
var last string
104+
for _, ln := range strings.Split(stderr, "\n") {
105+
ln = strings.TrimSpace(ln)
106+
if ln == "" {
107+
continue
108+
}
109+
last = ln
110+
if strings.HasPrefix(ln, "ERROR:") {
111+
return strings.TrimSpace(strings.TrimPrefix(ln, "ERROR:"))
112+
}
113+
}
114+
return last
115+
}
60116

61-
scanner := bufio.NewScanner(stdout)
117+
func streamOutput(jobID string, r io.Reader, prog chan<- highway.Progress) error {
118+
scanner := bufio.NewScanner(r)
119+
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
62120
currentPhase := "Downloading"
63121

64122
for scanner.Scan() {
65123
line := scanner.Text()
66124

67-
if strings.Contains(line, "[Merger]") || strings.Contains(line, "Merging formats") {
125+
if currentPhase != "Merging" && isMergingLine(line) {
68126
currentPhase = "Merging"
69127
prog <- highway.Progress{
70-
JobID: j.ID(),
128+
JobID: jobID,
71129
Type: highway.ProgressTypeSubStatus,
72130
Message: currentPhase,
73-
SubStatus: "ffmpeg consolidating streams...",
131+
SubStatus: "ffmpeg consolidating streams",
74132
}
75133
continue
76134
}
77135

78-
if strings.HasPrefix(line, "JSON_PROGRESS: ") {
79-
jsonStr := strings.TrimPrefix(line, "JSON_PROGRESS: ")
80-
var p YTDLPProgress
81-
if err := json.Unmarshal([]byte(jsonStr), &p); err != nil {
82-
continue
83-
}
84-
85-
if currentPhase == "Merging" {
86-
continue
87-
}
88-
89-
downBytes, _ := strconv.ParseInt(p.DownloadedBytes, 10, 64)
90-
var totalBytes int64
91-
if p.TotalBytes != "NA" {
92-
totalBytes, _ = strconv.ParseInt(p.TotalBytes, 10, 64)
93-
} else if p.TotalBytesEst != "NA" {
94-
totalBytes, _ = strconv.ParseInt(p.TotalBytesEst, 10, 64)
95-
}
136+
if !strings.HasPrefix(line, progressPrefix) {
137+
continue
138+
}
139+
if currentPhase == "Merging" {
140+
continue
141+
}
96142

97-
if totalBytes > 0 {
98-
prog <- highway.Progress{
99-
JobID: j.ID(),
100-
Type: highway.ProgressTypeProgress,
101-
Message: currentPhase,
102-
Current: downBytes,
103-
Total: totalBytes,
104-
Extra: utils.FormatBytes(uint64(downBytes)) + "/" + utils.FormatBytes(uint64(totalBytes)),
105-
}
106-
}
143+
current, total, ok := parseProgressJSON(strings.TrimPrefix(line, progressPrefix))
144+
if !ok {
145+
continue
146+
}
147+
prog <- highway.Progress{
148+
JobID: jobID,
149+
Type: highway.ProgressTypeProgress,
150+
Message: currentPhase,
151+
Current: current,
152+
Total: total,
153+
Extra: utils.FormatBytes(uint64(current)) + "/" + utils.FormatBytes(uint64(total)),
107154
}
108155
}
156+
return scanner.Err()
157+
}
109158

110-
err = cmd.Wait()
111-
if err != nil {
112-
prog <- highway.Progress{JobID: j.ID(), Done: true, Error: err, ErrMsg: err.Error()}
113-
return err
114-
}
159+
func isMergingLine(line string) bool {
160+
return strings.Contains(line, "[Merger]") || strings.Contains(line, "Merging formats")
161+
}
115162

116-
prog <- highway.Progress{JobID: j.ID(), Done: true}
117-
return nil
163+
func parseProgressJSON(s string) (current, total int64, ok bool) {
164+
var p YTDLPProgress
165+
if err := json.Unmarshal([]byte(s), &p); err != nil {
166+
return 0, 0, false
167+
}
168+
current, _ = strconv.ParseInt(p.DownloadedBytes, 10, 64)
169+
switch {
170+
case p.TotalBytes != "" && p.TotalBytes != "NA":
171+
total, _ = strconv.ParseInt(p.TotalBytes, 10, 64)
172+
case p.TotalBytesEst != "" && p.TotalBytesEst != "NA":
173+
total, _ = strconv.ParseInt(p.TotalBytesEst, 10, 64)
174+
}
175+
if total <= 0 {
176+
return 0, 0, false
177+
}
178+
return current, total, true
118179
}
119180

120181
type ytdlpJobState struct {

0 commit comments

Comments
 (0)