Skip to content

Commit a0f1f1c

Browse files
committed
feat: add batch download mode subcommand
1 parent 6f6c5a3 commit a0f1f1c

6 files changed

Lines changed: 677 additions & 0 deletions

File tree

cmd/batch.go

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"os"
8+
"os/signal"
9+
"strings"
10+
11+
"github.com/spf13/cobra"
12+
"github.com/tanq16/danzo/internal/display"
13+
"github.com/tanq16/danzo/internal/highway"
14+
ghreleasejob "github.com/tanq16/danzo/internal/jobs/github-release"
15+
httpjob "github.com/tanq16/danzo/internal/jobs/http"
16+
m3u8job "github.com/tanq16/danzo/internal/jobs/live-stream"
17+
s3job "github.com/tanq16/danzo/internal/jobs/s3"
18+
torrentjob "github.com/tanq16/danzo/internal/jobs/torrent"
19+
ytdlpjob "github.com/tanq16/danzo/internal/jobs/ytdlp"
20+
"github.com/tanq16/danzo/utils"
21+
"go.yaml.in/yaml/v4"
22+
)
23+
24+
// YAMLJob represents a single job's configuration parsed from YAML/JSON
25+
type YAMLJob struct {
26+
URL string `yaml:"url" json:"url"`
27+
Output string `yaml:"output" json:"output"`
28+
Type string `yaml:"type" json:"type"`
29+
Connections int `yaml:"connections" json:"connections"`
30+
Cookies string `yaml:"cookies" json:"cookies"`
31+
CookiesFromBrowser string `yaml:"cookies_from_browser" json:"cookies_from_browser"`
32+
Profile string `yaml:"profile" json:"profile"`
33+
Manual *bool `yaml:"manual" json:"manual"`
34+
Extract string `yaml:"extract" json:"extract"`
35+
}
36+
37+
var batchFlags struct {
38+
cookies string
39+
cookiesFromBrowser string
40+
s3Profile string
41+
extract string
42+
manual bool
43+
}
44+
45+
var batchCmd = &cobra.Command{
46+
Use: "batch [FILE]",
47+
Short: "Download multiple jobs in batch from a file or stdin",
48+
Args: cobra.MaximumNArgs(1),
49+
Run: func(cmd *cobra.Command, args []string) {
50+
filePath := ""
51+
if len(args) > 0 {
52+
filePath = args[0]
53+
}
54+
55+
jobConfigs, err := parseBatchInput(filePath)
56+
if err != nil {
57+
utils.PrintFatal("Failed to parse batch input", err)
58+
}
59+
60+
if len(jobConfigs) == 0 {
61+
fmt.Println("No jobs found in batch input.")
62+
return
63+
}
64+
65+
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
66+
defer cancel()
67+
68+
hw := newHighway()
69+
disp := display.New(display.DefaultConfig())
70+
71+
var submittedJobs []highway.Job
72+
for _, cfg := range jobConfigs {
73+
job, err := buildJob(cfg)
74+
if err != nil {
75+
utils.PrintFatal("Failed to configure job", err)
76+
}
77+
disp.RegisterJob(job.ID())
78+
hw.Submit(job)
79+
submittedJobs = append(submittedJobs, job)
80+
}
81+
82+
disp.Start(hw.Progress())
83+
runErr := hw.Run(ctx)
84+
disp.Stop()
85+
86+
if runErr != nil {
87+
utils.PrintFatal("Batch execution finished with failures", runErr)
88+
}
89+
},
90+
}
91+
92+
func parseBatchInput(filePath string) ([]YAMLJob, error) {
93+
var r io.Reader
94+
if filePath == "" || filePath == "-" {
95+
stat, _ := os.Stdin.Stat()
96+
if (stat.Mode() & os.ModeCharDevice) != 0 {
97+
return nil, fmt.Errorf("no input file specified and stdin is not a pipe/redirect")
98+
}
99+
r = os.Stdin
100+
} else {
101+
f, err := os.Open(filePath)
102+
if err != nil {
103+
return nil, fmt.Errorf("failed to open file %s: %w", filePath, err)
104+
}
105+
defer f.Close()
106+
r = f
107+
}
108+
109+
data, err := io.ReadAll(r)
110+
if err != nil {
111+
return nil, fmt.Errorf("failed to read batch content: %w", err)
112+
}
113+
114+
isYAML := false
115+
if filePath != "" && (strings.HasSuffix(filePath, ".yaml") || strings.HasSuffix(filePath, ".yml") || strings.HasSuffix(filePath, ".json")) {
116+
isYAML = true
117+
} else {
118+
trimmed := strings.TrimSpace(string(data))
119+
if strings.HasPrefix(trimmed, "-") || strings.HasPrefix(trimmed, "[") {
120+
isYAML = true
121+
}
122+
}
123+
124+
if isYAML {
125+
var jobs []YAMLJob
126+
if err := yaml.Unmarshal(data, &jobs); err == nil {
127+
return jobs, nil
128+
} else {
129+
return nil, fmt.Errorf("failed to parse YAML/JSON batch file: %w", err)
130+
}
131+
}
132+
133+
// Plain-text parser
134+
var jobs []YAMLJob
135+
lines := strings.Split(string(data), "\n")
136+
for _, line := range lines {
137+
line = strings.TrimSpace(line)
138+
if line == "" || strings.HasPrefix(line, "#") {
139+
continue
140+
}
141+
parts := splitLine(line)
142+
if len(parts) == 0 {
143+
continue
144+
}
145+
urlStr := parts[0]
146+
output := ""
147+
if len(parts) > 1 {
148+
output = parts[1]
149+
}
150+
jobs = append(jobs, YAMLJob{
151+
URL: urlStr,
152+
Output: output,
153+
})
154+
}
155+
156+
return jobs, nil
157+
}
158+
159+
func splitLine(line string) []string {
160+
var parts []string
161+
var current strings.Builder
162+
inQuotes := false
163+
for i := 0; i < len(line); i++ {
164+
c := line[i]
165+
if c == '"' {
166+
inQuotes = !inQuotes
167+
continue
168+
}
169+
if c == ' ' || c == '\t' {
170+
if inQuotes {
171+
current.WriteByte(c)
172+
} else if current.Len() > 0 {
173+
parts = append(parts, current.String())
174+
current.Reset()
175+
}
176+
} else {
177+
current.WriteByte(c)
178+
}
179+
}
180+
if current.Len() > 0 {
181+
parts = append(parts, current.String())
182+
}
183+
return parts
184+
}
185+
186+
func parsePrefix(rawURL string) (string, string) {
187+
parts := strings.SplitN(rawURL, "::", 2)
188+
if len(parts) == 2 {
189+
prefix := strings.ToLower(strings.TrimSpace(parts[0]))
190+
actualURL := strings.TrimSpace(parts[1])
191+
return prefix, actualURL
192+
}
193+
return "", rawURL
194+
}
195+
196+
func getJobType(prefix, rawURL, overrideType string) string {
197+
if overrideType != "" {
198+
return strings.ToLower(overrideType)
199+
}
200+
if prefix != "" {
201+
switch prefix {
202+
case "http", "https":
203+
return "http"
204+
case "hls", "m3u8", "livestream", "live-stream", "stream":
205+
return "live-stream"
206+
case "ghr", "github-release", "ghrelease":
207+
return "github-release"
208+
case "s3":
209+
return "s3"
210+
case "ytdlp", "yt-dlp", "youtube-dl", "ytdl":
211+
return "ytdlp"
212+
case "torrent":
213+
return "torrent"
214+
}
215+
}
216+
// Dynamic fallbacks
217+
if strings.HasPrefix(rawURL, "s3://") {
218+
return "s3"
219+
}
220+
if strings.HasPrefix(rawURL, "magnet:") || strings.HasSuffix(rawURL, ".torrent") {
221+
return "torrent"
222+
}
223+
if strings.Contains(rawURL, ".m3u8") {
224+
return "live-stream"
225+
}
226+
return "http"
227+
}
228+
229+
func buildJob(cfg YAMLJob) (highway.Job, error) {
230+
prefix, actualURL := parsePrefix(cfg.URL)
231+
jobType := getJobType(prefix, actualURL, cfg.Type)
232+
233+
conns := connections // inherited global connections flag
234+
if cfg.Connections > 0 {
235+
conns = cfg.Connections
236+
}
237+
238+
switch jobType {
239+
case "http":
240+
return httpjob.New(actualURL, cfg.Output, conns, globalHTTPConfig), nil
241+
242+
case "live-stream":
243+
extract := cfg.Extract
244+
if extract == "" {
245+
extract = batchFlags.extract
246+
}
247+
if extract == "" {
248+
if strings.Contains(actualURL, "dailymotion.com") || strings.Contains(actualURL, "dai.ly") {
249+
extract = "dailymotion"
250+
} else if strings.Contains(actualURL, "rumble.com") {
251+
extract = "rumble"
252+
}
253+
}
254+
return m3u8job.New(actualURL, cfg.Output, conns, extract, globalHTTPConfig), nil
255+
256+
case "github-release":
257+
man := batchFlags.manual
258+
if cfg.Manual != nil {
259+
man = *cfg.Manual
260+
}
261+
return ghreleasejob.New(actualURL, cfg.Output, man, globalHTTPConfig), nil
262+
263+
case "s3":
264+
prof := cfg.Profile
265+
if prof == "" {
266+
prof = batchFlags.s3Profile
267+
}
268+
if prof == "" {
269+
prof = "default"
270+
}
271+
return s3job.New(actualURL, cfg.Output, conns, prof), nil
272+
273+
case "ytdlp":
274+
cookies := cfg.Cookies
275+
if cookies == "" {
276+
cookies = batchFlags.cookies
277+
}
278+
cookiesFromBrowser := cfg.CookiesFromBrowser
279+
if cookiesFromBrowser == "" {
280+
cookiesFromBrowser = batchFlags.cookiesFromBrowser
281+
}
282+
return ytdlpjob.New(actualURL, cfg.Output, cookies, cookiesFromBrowser, globalHTTPConfig), nil
283+
284+
case "torrent":
285+
return torrentjob.New(actualURL, cfg.Output, conns, globalHTTPConfig), nil
286+
287+
default:
288+
return nil, fmt.Errorf("unsupported job type: %s", jobType)
289+
}
290+
}
291+
292+
func newBatchCmd() *cobra.Command {
293+
return batchCmd
294+
}
295+
296+
func init() {
297+
batchCmd.Flags().StringVar(&batchFlags.cookies, "cookies", "", "File name to read cookies from for yt-dlp")
298+
batchCmd.Flags().StringVar(&batchFlags.cookiesFromBrowser, "cookies-from-browser", "", "Browser name to load cookies from for yt-dlp")
299+
batchCmd.Flags().StringVar(&batchFlags.s3Profile, "s3-profile", "default", "AWS profile for S3 downloads")
300+
batchCmd.Flags().StringVarP(&batchFlags.extract, "extract", "e", "", "Site-specific extractor for live streams")
301+
batchCmd.Flags().BoolVar(&batchFlags.manual, "manual", false, "Manually select release version and asset for GitHub Releases")
302+
}

0 commit comments

Comments
 (0)