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 }