Adds yt-dlp as a third remote-download provider alongside aria2 and qBittorrent (#88). yt-dlp is a CLI tool, so the provider spawns one process per task, parses progress from stdout, and enumerates output files for the transfer phase. - pkg/downloader/ytdlp: process-backed Downloader implementation. Deliberately not exec.CommandContext - the queue cancels each iteration's ctx on suspend, which would kill the process. Lifetime is managed via Cancel/exit; "--" guards URL args; "output" is a pseudo-key mapping to the -o template; option slices repeat flags. - inventory/types: YtdlpSetting (binary/options/temp_path), provider constant, NodeSetting wiring; cluster dispatch in NewDownloader. - remote_download: yt-dlp case in buildDownloadOptions mapping per-task filename/username/password/headers to yt-dlp flags. - Admin node UI: provider select entry, binary path, JSON options, temp path; en-US/zh-CN strings. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>pull/3580/head
parent
cadee5c220
commit
16659195fe
@ -0,0 +1,387 @@
|
|||||||
|
package ytdlp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/downloader"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// YtDlpTempFolder is the sub-directory under the node's temp path holding
|
||||||
|
// per-task download directories.
|
||||||
|
YtDlpTempFolder = "ytdlp"
|
||||||
|
defaultBinary = "yt-dlp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// [download] 12.3% of ~10.50MiB at 1.23MiB/s ETA 00:08
|
||||||
|
progressRe = regexp.MustCompile(`\[download\]\s+([\d.]+)%\s+of\s+~?([\d.]+\s*(?:[KMGTP]i?B|B))\s+at\s+([\d.]+\s*(?:[KMGTP]i?B|B))/s`)
|
||||||
|
// [download] 100% of 10.50MiB in 00:08
|
||||||
|
completeRe = regexp.MustCompile(`\[download\]\s+100%`)
|
||||||
|
sizeRe = regexp.MustCompile(`^([\d.]+)\s*([KMGTP]i?B|B)$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
ytdlpClient struct {
|
||||||
|
l logging.Logger
|
||||||
|
settings setting.Provider
|
||||||
|
options *types.YtdlpSetting
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
procs map[string]*ytdlpProcess
|
||||||
|
}
|
||||||
|
|
||||||
|
ytdlpProcess struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
dir string
|
||||||
|
status *downloader.TaskStatus
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
done chan struct{}
|
||||||
|
errTail []string
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// New creates a yt-dlp based Downloader. yt-dlp is a CLI tool, so the client
|
||||||
|
// manages spawned processes and parses their progress output.
|
||||||
|
func New(l logging.Logger, settings setting.Provider, options *types.YtdlpSetting) downloader.Downloader {
|
||||||
|
return &ytdlpClient{
|
||||||
|
l: l,
|
||||||
|
settings: settings,
|
||||||
|
options: options,
|
||||||
|
procs: map[string]*ytdlpProcess{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ytdlpClient) binary() string {
|
||||||
|
if c.options != nil && c.options.Binary != "" {
|
||||||
|
return c.options.Binary
|
||||||
|
}
|
||||||
|
return defaultBinary
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ytdlpClient) tempPath(ctx context.Context) string {
|
||||||
|
base := ""
|
||||||
|
if c.options != nil {
|
||||||
|
base = util.RelativePath(c.options.TempPath)
|
||||||
|
}
|
||||||
|
if base == "" && c.settings != nil {
|
||||||
|
base = util.DataPath(c.settings.TempPath(ctx))
|
||||||
|
}
|
||||||
|
guid, _ := uuid.NewV4()
|
||||||
|
return filepath.Join(base, YtDlpTempFolder, guid.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionArgs converts the settings+per-task option maps into yt-dlp CLI flags.
|
||||||
|
// Keys are used verbatim: "format" -> "--format <v>", "no_playlist" (bool) ->
|
||||||
|
// "--no-playlist". This mirrors how aria2 options map to RPC fields.
|
||||||
|
func optionArgs(options map[string]any) []string {
|
||||||
|
keys := lo.Keys(options)
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
args := make([]string, 0, len(options)*2)
|
||||||
|
for _, k := range keys {
|
||||||
|
v := options[k]
|
||||||
|
flag := "--" + strings.ReplaceAll(k, "_", "-")
|
||||||
|
switch val := v.(type) {
|
||||||
|
case bool:
|
||||||
|
if val {
|
||||||
|
args = append(args, flag)
|
||||||
|
}
|
||||||
|
case nil:
|
||||||
|
case []string:
|
||||||
|
for _, item := range val {
|
||||||
|
args = append(args, flag, item)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, item := range val {
|
||||||
|
args = append(args, flag, fmt.Sprintf("%v", item))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
args = append(args, flag, fmt.Sprintf("%v", v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ytdlpClient) CreateTask(ctx context.Context, url string, options map[string]interface{}) (*downloader.TaskHandle, error) {
|
||||||
|
dir := c.tempPath(ctx)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := map[string]any{}
|
||||||
|
if c.options != nil {
|
||||||
|
for k, v := range c.options.Options {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range options {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// "output" is a pseudo-key: it replaces the -o template rather than
|
||||||
|
// producing a "--output" flag (which yt-dlp does not have).
|
||||||
|
output := "%(title)s.%(ext)s"
|
||||||
|
if v, ok := merged["output"]; ok {
|
||||||
|
output = fmt.Sprintf("%v", v)
|
||||||
|
delete(merged, "output")
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"--newline", "--no-colors",
|
||||||
|
"-P", dir,
|
||||||
|
"-o", output,
|
||||||
|
}
|
||||||
|
args = append(args, optionArgs(merged)...)
|
||||||
|
// "--" guards against URLs that could be mistaken for flags.
|
||||||
|
args = append(args, "--", url)
|
||||||
|
|
||||||
|
c.l.Info("Creating yt-dlp task: %s %s", c.binary(), strings.Join(args, " "))
|
||||||
|
// Deliberately not CommandContext: the queue ctx is canceled when each
|
||||||
|
// task iteration returns, which would kill the process as soon as the
|
||||||
|
// create phase suspends. Lifetime is managed via Cancel/exit instead.
|
||||||
|
cmd := exec.Command(c.binary(), args...)
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open yt-dlp stdout: %w", err)
|
||||||
|
}
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open yt-dlp stderr: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start yt-dlp: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &ytdlpProcess{
|
||||||
|
cmd: cmd,
|
||||||
|
dir: dir,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
status: &downloader.TaskStatus{
|
||||||
|
Name: url,
|
||||||
|
State: downloader.StatusDownloading,
|
||||||
|
SavePath: filepath.ToSlash(dir),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
go p.run(stdout, stderr)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.procs[p.id()] = p
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return &downloader.TaskHandle{ID: p.id(), Hash: dir}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ytdlpProcess) id() string { return fmt.Sprintf("%d|%s", p.cmd.Process.Pid, p.dir) }
|
||||||
|
|
||||||
|
// run consumes the process output until exit and records terminal state.
|
||||||
|
func (p *ytdlpProcess) run(stdout, stderr io.Reader) {
|
||||||
|
defer close(p.done)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
p.scanProgress(stdout)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
p.scanErrors(stderr)
|
||||||
|
}()
|
||||||
|
|
||||||
|
err := p.cmd.Wait()
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
p.status.State = downloader.StatusError
|
||||||
|
if len(p.errTail) > 0 {
|
||||||
|
p.status.ErrorMessage = p.errTail[len(p.errTail)-1]
|
||||||
|
} else {
|
||||||
|
p.status.ErrorMessage = err.Error()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.status.State = downloader.StatusCompleted
|
||||||
|
if p.status.Total == 0 {
|
||||||
|
p.status.Total = p.status.Downloaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ytdlpProcess) scanProgress(r io.Reader) {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if m := progressRe.FindStringSubmatch(line); m != nil {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.status.Total = parseSize(m[2])
|
||||||
|
p.status.DownloadSpeed = parseSize(m[3])
|
||||||
|
if p.status.Total > 0 {
|
||||||
|
pct, _ := strconv.ParseFloat(m[1], 64)
|
||||||
|
p.status.Downloaded = int64(pct / 100 * float64(p.status.Total))
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if completeRe.MatchString(line) {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.status.Downloaded = p.status.Total
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ytdlpProcess) scanErrors(r io.Reader) {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
p.errTail = append(p.errTail, line)
|
||||||
|
if len(p.errTail) > 8 {
|
||||||
|
p.errTail = p.errTail[len(p.errTail)-8:]
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSize converts yt-dlp human sizes ("10.5MiB", "2 GiB") to bytes.
|
||||||
|
func parseSize(s string) int64 {
|
||||||
|
m := sizeRe.FindStringSubmatch(strings.TrimSpace(s))
|
||||||
|
if m == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
num, err := strconv.ParseFloat(m[1], 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
units := map[string]float64{
|
||||||
|
"B": 1, "KB": 1e3, "MB": 1e6, "GB": 1e9, "TB": 1e12, "PB": 1e15,
|
||||||
|
"KiB": 1 << 10, "MiB": 1 << 20, "GiB": 1 << 30, "TiB": 1 << 40, "PiB": 1 << 50,
|
||||||
|
}
|
||||||
|
mult, ok := units[m[2]]
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int64(num * mult)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ytdlpClient) getProcess(id string) (*ytdlpProcess, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if p, ok := c.procs[id]; ok {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
return nil, downloader.ErrTaskNotFount
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ytdlpClient) Info(ctx context.Context, handle *downloader.TaskHandle) (*downloader.TaskStatus, error) {
|
||||||
|
p, err := c.getProcess(handle.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
status := *p.status
|
||||||
|
terminal := status.State == downloader.StatusCompleted || status.State == downloader.StatusError
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// Enumerate downloaded files for the transfer phase.
|
||||||
|
files, err := listDirFiles(p.dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status.Files = files
|
||||||
|
|
||||||
|
if terminal {
|
||||||
|
// Terminal status is never queried again (monitor moves to the
|
||||||
|
// transfer phase) - release the process entry.
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.procs, handle.ID)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
return &status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listDirFiles returns downloaded files relative to dir, mimicking the aria2
|
||||||
|
// file list shape the transfer phase consumes.
|
||||||
|
func listDirFiles(dir string) ([]downloader.TaskFile, error) {
|
||||||
|
files := []downloader.TaskFile{}
|
||||||
|
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(dir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
files = append(files, downloader.TaskFile{
|
||||||
|
Index: len(files),
|
||||||
|
Name: filepath.ToSlash(rel),
|
||||||
|
Size: info.Size(),
|
||||||
|
Progress: 1,
|
||||||
|
Selected: true,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return files, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ytdlpClient) Cancel(ctx context.Context, handle *downloader.TaskHandle) error {
|
||||||
|
p, err := c.getProcess(handle.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil // already gone
|
||||||
|
}
|
||||||
|
if p.cmd.Process != nil {
|
||||||
|
_ = p.cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
for k, v := range c.procs {
|
||||||
|
if v == p {
|
||||||
|
delete(c.procs, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFilesToDownload is a no-op: yt-dlp resolves its own file list from the URL.
|
||||||
|
func (c *ytdlpClient) SetFilesToDownload(ctx context.Context, handle *downloader.TaskHandle, args ...*downloader.SetFileToDownloadArgs) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ytdlpClient) Test(ctx context.Context) (string, error) {
|
||||||
|
out, err := exec.CommandContext(ctx, c.binary(), "--version").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("yt-dlp test failed: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("yt-dlp %s", strings.TrimSpace(string(out))), nil
|
||||||
|
}
|
||||||
@ -0,0 +1,171 @@
|
|||||||
|
package ytdlp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/downloader"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stubSettingProvider struct {
|
||||||
|
setting.Provider
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSize(t *testing.T) {
|
||||||
|
cases := map[string]int64{
|
||||||
|
"10.50MiB": 10.5 * (1 << 20),
|
||||||
|
"2 GiB": 2 << 30,
|
||||||
|
"500KB": 500 * 1e3,
|
||||||
|
"1.5B": 1,
|
||||||
|
"": 0,
|
||||||
|
"10.50ZiB": 0,
|
||||||
|
"notasize": 0,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
require.Equal(t, want, parseSize(in), "input %q", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptionArgs(t *testing.T) {
|
||||||
|
args := optionArgs(map[string]any{
|
||||||
|
"no_playlist": true,
|
||||||
|
"ignore_errors": false,
|
||||||
|
"format": "best",
|
||||||
|
"retries": 3,
|
||||||
|
"nil_key": nil,
|
||||||
|
"add_headers": []string{"A: 1", "B: 2"},
|
||||||
|
})
|
||||||
|
require.Equal(t, []string{
|
||||||
|
"--add-headers", "A: 1", "--add-headers", "B: 2",
|
||||||
|
"--format", "best",
|
||||||
|
"--no-playlist",
|
||||||
|
"--retries", "3",
|
||||||
|
}, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeYtdlp writes a shell script emulating yt-dlp's progress output and file
|
||||||
|
// creation under the -P directory, then returns its path.
|
||||||
|
func fakeYtdlp(t *testing.T, script string) string {
|
||||||
|
t.Helper()
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("shell-script stub requires a POSIX shell")
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "yt-dlp")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"+script), 0o755))
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestClient(binary, temp string) downloader.Downloader {
|
||||||
|
return New(
|
||||||
|
logging.NewConsoleLogger(logging.LevelError),
|
||||||
|
&stubSettingProvider{},
|
||||||
|
&types.YtdlpSetting{Binary: binary, TempPath: temp},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func awaitInfo(t *testing.T, d downloader.Downloader, h *downloader.TaskHandle, want downloader.Status) *downloader.TaskStatus {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
var last *downloader.TaskStatus
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
s, err := d.Info(context.Background(), h)
|
||||||
|
if err != nil {
|
||||||
|
// Terminal entries are released after being reported once.
|
||||||
|
if want == downloader.StatusCompleted || want == downloader.StatusError {
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
t.Fatalf("info failed: %v", err)
|
||||||
|
}
|
||||||
|
last = s
|
||||||
|
if s.State == want {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("state %q not reached, last: %+v", want, last)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTaskCompletes(t *testing.T) {
|
||||||
|
binary := fakeYtdlp(t, `
|
||||||
|
dir=""
|
||||||
|
prev=""
|
||||||
|
for a in "$@"; do
|
||||||
|
if [ "$prev" = "-P" ]; then dir="$a"; fi
|
||||||
|
prev="$a"
|
||||||
|
done
|
||||||
|
echo "[download] 50.0% of ~10.00MiB at 1.00MiB/s ETA 00:05"
|
||||||
|
echo "[download] 100% of 10.00MiB in 00:01"
|
||||||
|
echo data > "$dir/video.mp4"
|
||||||
|
`)
|
||||||
|
d := newTestClient(binary, t.TempDir())
|
||||||
|
|
||||||
|
handle, err := d.CreateTask(context.Background(), "https://example.com/v", map[string]interface{}{
|
||||||
|
"no_playlist": true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, handle.ID)
|
||||||
|
|
||||||
|
status := awaitInfo(t, d, handle, downloader.StatusCompleted)
|
||||||
|
require.NotNil(t, status)
|
||||||
|
require.Equal(t, int64(10<<20), status.Total)
|
||||||
|
require.Len(t, status.Files, 1)
|
||||||
|
require.Equal(t, "video.mp4", status.Files[0].Name)
|
||||||
|
require.True(t, status.Files[0].Selected)
|
||||||
|
require.Equal(t, filepath.ToSlash(handle.Hash), status.SavePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTaskError(t *testing.T) {
|
||||||
|
binary := fakeYtdlp(t, `echo "ERROR: Video unavailable" >&2; exit 1`)
|
||||||
|
d := newTestClient(binary, t.TempDir())
|
||||||
|
|
||||||
|
handle, err := d.CreateTask(context.Background(), "https://example.com/v", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
status := awaitInfo(t, d, handle, downloader.StatusError)
|
||||||
|
require.NotNil(t, status)
|
||||||
|
require.Contains(t, status.ErrorMessage, "Video unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancel(t *testing.T) {
|
||||||
|
binary := fakeYtdlp(t, `sleep 30`)
|
||||||
|
d := newTestClient(binary, t.TempDir())
|
||||||
|
|
||||||
|
handle, err := d.CreateTask(context.Background(), "https://example.com/v", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, d.Cancel(context.Background(), handle))
|
||||||
|
|
||||||
|
_, err = d.Info(context.Background(), handle)
|
||||||
|
require.ErrorIs(t, err, downloader.ErrTaskNotFount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomOutputTemplate(t *testing.T) {
|
||||||
|
binary := fakeYtdlp(t, `
|
||||||
|
dir=""
|
||||||
|
tmpl=""
|
||||||
|
prev=""
|
||||||
|
for a in "$@"; do
|
||||||
|
if [ "$prev" = "-P" ]; then dir="$a"; fi
|
||||||
|
if [ "$prev" = "-o" ]; then tmpl="$a"; fi
|
||||||
|
prev="$a"
|
||||||
|
done
|
||||||
|
echo data > "$dir/$tmpl"
|
||||||
|
`)
|
||||||
|
d := newTestClient(binary, t.TempDir())
|
||||||
|
handle, err := d.CreateTask(context.Background(), "https://example.com/v", map[string]interface{}{
|
||||||
|
"output": "custom-name.mp4",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
status := awaitInfo(t, d, handle, downloader.StatusCompleted)
|
||||||
|
require.NotNil(t, status)
|
||||||
|
require.Len(t, status.Files, 1)
|
||||||
|
require.Equal(t, "custom-name.mp4", status.Files[0].Name)
|
||||||
|
}
|
||||||
Loading…
Reference in new issue