add workflow to build
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user