172 lines
4.6 KiB
Go
172 lines
4.6 KiB
Go
package ytdlp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// SearchResult is a single entry from yt-dlp flat playlist output.
|
|
type SearchResult struct {
|
|
ID string
|
|
Title string
|
|
Duration string
|
|
Channel string
|
|
}
|
|
|
|
// RunSearch executes yt-dlp with a search query and returns parsed results.
|
|
func RunSearch(ctx context.Context, ytdlpPath, query string, limit int, proxyURL string, timeoutSeconds int) ([]SearchResult, error) {
|
|
searchQuery := fmt.Sprintf("ytsearch%d:%s", limit, query)
|
|
|
|
args := []string{
|
|
searchQuery,
|
|
"--flat-playlist",
|
|
"--print", "%(id)s\t%(title)s\t%(duration_string)s\t%(channel)s",
|
|
}
|
|
if proxyURL != "" {
|
|
args = append(args, "--proxy", proxyURL)
|
|
}
|
|
|
|
cmdCtx, cancel := context.WithTimeout(ctx, toDuration(timeoutSeconds))
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(cmdCtx, ytdlpPath, args...)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
if cmdCtx.Err() != nil {
|
|
return nil, fmt.Errorf("search timed out after %ds: %s", timeoutSeconds, stderr.String())
|
|
}
|
|
return nil, fmt.Errorf("yt-dlp search failed: %w\nstderr: %s", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
|
|
return parseSearchOutput(stdout.String()), nil
|
|
}
|
|
|
|
func parseSearchOutput(output string) []SearchResult {
|
|
lines := strings.Split(strings.TrimSpace(output), "\n")
|
|
var results []SearchResult
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "\t", 4)
|
|
if len(parts) < 3 {
|
|
continue
|
|
}
|
|
r := SearchResult{
|
|
ID: parts[0],
|
|
Title: parts[1],
|
|
Duration: parts[2],
|
|
}
|
|
if len(parts) >= 4 {
|
|
r.Channel = parts[3]
|
|
}
|
|
results = append(results, r)
|
|
}
|
|
return results
|
|
}
|
|
|
|
// DownloadPreview downloads a single track as audio preview (lower quality) to tmpDir.
|
|
// Returns the path to the downloaded file.
|
|
func DownloadPreview(ctx context.Context, ytdlpPath, videoID, tmpDir, audioFormat string, quality, timeoutSeconds int, proxyURL string) (string, error) {
|
|
outputTmpl := fmt.Sprintf("%s/%s.%%%%(ext)s", tmpDir, videoID)
|
|
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
|
|
|
args := []string{
|
|
url,
|
|
"-x",
|
|
"--audio-format", audioFormat,
|
|
"--audio-quality", fmt.Sprintf("%d", quality),
|
|
"--output", outputTmpl,
|
|
"--no-embed-thumbnail",
|
|
"--no-add-metadata",
|
|
}
|
|
if proxyURL != "" {
|
|
args = append(args, "--proxy", proxyURL)
|
|
}
|
|
|
|
cmdCtx, cancel := context.WithTimeout(ctx, toDuration(timeoutSeconds))
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(cmdCtx, ytdlpPath, args...)
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
if cmdCtx.Err() != nil {
|
|
return "", fmt.Errorf("preview download timed out after %ds", timeoutSeconds)
|
|
}
|
|
return "", fmt.Errorf("yt-dlp preview failed: %w\nstderr: %s", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
|
|
// yt-dlp outputs to {tmpDir}/{videoID}.{ext}
|
|
ext := audioFormat
|
|
if ext == "opus" {
|
|
ext = "opus"
|
|
}
|
|
// Try common extensions
|
|
for _, e := range []string{audioFormat, "opus", "m4a", "webm"} {
|
|
path := fmt.Sprintf("%s/%s.%s", tmpDir, videoID, e)
|
|
if fileExists(path) {
|
|
return path, nil
|
|
}
|
|
}
|
|
|
|
return fmt.Sprintf("%s/%s.%s", tmpDir, videoID, audioFormat), nil
|
|
}
|
|
|
|
// DownloadFinal downloads a track at best quality with metadata into musicDir.
|
|
// Returns the final file path.
|
|
func DownloadFinal(ctx context.Context, ytdlpPath, videoID, musicDir, audioFormat string, quality, timeoutSeconds int, proxyURL string) (string, error) {
|
|
outputTmpl := fmt.Sprintf("%s/%%%%(artist)s - %%%%(title)s.%%%%(ext)s", musicDir)
|
|
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
|
|
|
args := []string{
|
|
url,
|
|
"-x",
|
|
"--audio-format", audioFormat,
|
|
"--audio-quality", fmt.Sprintf("%d", quality),
|
|
"--embed-thumbnail",
|
|
"--add-metadata",
|
|
"--output", outputTmpl,
|
|
"--no-playlist",
|
|
}
|
|
if proxyURL != "" {
|
|
args = append(args, "--proxy", proxyURL)
|
|
}
|
|
|
|
cmdCtx, cancel := context.WithTimeout(ctx, toDuration(timeoutSeconds))
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(cmdCtx, ytdlpPath, args...)
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
if cmdCtx.Err() != nil {
|
|
return "", fmt.Errorf("final download timed out after %ds", timeoutSeconds)
|
|
}
|
|
return "", fmt.Errorf("yt-dlp final download failed: %w\nstderr: %s", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
|
|
// Return the output template pattern — caller will need to find the actual file
|
|
return outputTmpl, nil
|
|
}
|
|
|
|
func toDuration(seconds int) time.Duration {
|
|
return time.Duration(seconds) * time.Second
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|