Files
ytdlp_navidrome/internal/ytdlp/ytdlp.go
T
2026-07-04 01:56:10 +03:00

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
}