spotify-clone/backend-go/internal/spotdl/client.go

410 lines
11 KiB
Go

package spotdl
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"spotify-clone-backend/internal/models"
)
// ytDlpPath finds the yt-dlp executable
func ytDlpPath() string {
// Check for local yt-dlp.exe
exe, err := os.Executable()
if err == nil {
localPath := filepath.Join(filepath.Dir(exe), "yt-dlp.exe")
if _, err := os.Stat(localPath); err == nil {
return localPath
}
}
// Check in working directory
if _, err := os.Stat("yt-dlp.exe"); err == nil {
return "./yt-dlp.exe"
}
// Check Python Scripts directory
homeDir, err := os.UserHomeDir()
if err == nil {
pythonScriptsPath := filepath.Join(homeDir, "AppData", "Local", "Programs", "Python", "Python312", "Scripts", "yt-dlp.exe")
if _, err := os.Stat(pythonScriptsPath); err == nil {
return pythonScriptsPath
}
}
return "yt-dlp"
}
type CacheItem struct {
Tracks []models.Track
Timestamp time.Time
}
type Service struct {
downloadDir string
searchCache map[string]CacheItem
cacheMutex sync.RWMutex
}
func NewService() *Service {
downloadDir := filepath.Join(os.TempDir(), "spotify-clone-cache")
os.MkdirAll(downloadDir, 0755)
return &Service{
downloadDir: downloadDir,
searchCache: make(map[string]CacheItem),
}
}
// YTResult represents yt-dlp JSON output
type YTResult struct {
ID string `json:"id"`
Title string `json:"title"`
Uploader string `json:"uploader"`
Duration float64 `json:"duration"`
Webpage string `json:"webpage_url"`
Thumbnails []struct {
URL string `json:"url"`
Height int `json:"height"`
Width int `json:"width"`
} `json:"thumbnails"`
}
// SearchTracks uses yt-dlp to search YouTube
func (s *Service) SearchTracks(query string) ([]models.Track, error) {
// 1. Check Cache
s.cacheMutex.RLock()
if item, found := s.searchCache[query]; found {
if time.Since(item.Timestamp) < 1*time.Hour { // 1 Hour Cache
s.cacheMutex.RUnlock()
fmt.Printf("Cache Hit: %s\n", query)
return item.Tracks, nil
}
}
s.cacheMutex.RUnlock()
// yt-dlp "ytsearch20:<query>" --dump-json --no-playlist --flat-playlist
path := ytDlpPath()
searchQuery := fmt.Sprintf("ytsearch20:%s", query)
// Using --flat-playlist is fast but sometimes lacks thumbnails/details in some versions.
// However, the JSON output above showed thumbnails present even with --flat-playlist (or maybe I removed it in previous step? No I added it).
// Let's stick to the current command which provided results, just parse better.
cmd := exec.Command(path, searchQuery, "--dump-json", "--no-playlist", "--flat-playlist")
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
fmt.Printf("Executing: %s %s --dump-json\n", path, searchQuery)
err := cmd.Run()
if err != nil {
return nil, fmt.Errorf("search failed: %w, stderr: %s", err, stderr.String())
}
var tracks []models.Track
scanner := bufio.NewScanner(&stdout)
for scanner.Scan() {
line := scanner.Bytes()
var res YTResult
if err := json.Unmarshal(line, &res); err == nil {
// FILTER: Skip channels and playlists
// Channels usually start with UC, Playlists with PL (though IDs varies, channels are distinct)
// A safest check is duration > 0, channels have 0 duration in flat sort usually?
// Or check ID pattern.
if strings.HasPrefix(res.ID, "UC") || strings.HasPrefix(res.ID, "PL") || res.Duration == 0 {
continue
}
// Clean artist name (remove " - Topic")
artist := strings.Replace(res.Uploader, " - Topic", "", -1)
// Select best thumbnail (Highest resolution)
coverURL := ""
// maxArea := 0 // Removed unused variable
if len(res.Thumbnails) > 0 {
bestScore := -1.0
for _, thumb := range res.Thumbnails {
// Calculate score: Area * AspectRatioPenalty
// We want square (1.0).
// Penalty = 1 / (1 + abs(ratio - 1))
w := float64(thumb.Width)
h := float64(thumb.Height)
if w == 0 || h == 0 {
continue
}
ratio := w / h
diff := ratio - 1.0
if diff < 0 {
diff = -diff
}
// If strictly square (usually YTM), give huge bonus
// YouTube Music covers are often 1:1
score := w * h
if diff < 0.1 { // Close to square
score = score * 10 // Boost square images significantly
}
if score > bestScore {
bestScore = score
coverURL = thumb.URL
}
}
// If specific check failed (e.g. 0 dimensions), fallback to last
if coverURL == "" {
coverURL = res.Thumbnails[len(res.Thumbnails)-1].URL
}
} else {
// Fallback construction - favor maxres, but hq is safer.
// Let's use hqdefault which is effectively standard high quality.
coverURL = fmt.Sprintf("https://i.ytimg.com/vi/%s/hqdefault.jpg", res.ID)
}
tracks = append(tracks, models.Track{
ID: res.ID,
Title: res.Title,
Artist: artist,
Album: "YouTube Music",
Duration: int(res.Duration),
CoverURL: coverURL,
URL: fmt.Sprintf("/api/stream/%s", res.ID), // Use backend stream endpoint
})
}
}
// 2. Save to Cache
if len(tracks) > 0 {
s.cacheMutex.Lock()
s.searchCache[query] = CacheItem{
Tracks: tracks,
Timestamp: time.Now(),
}
s.cacheMutex.Unlock()
}
return tracks, nil
}
// SearchArtist searches for a channel/artist to get their thumbnail
func (s *Service) SearchArtist(query string) (string, error) {
// Search for the artist channel specifically
path := ytDlpPath()
// Increase to 3 results to increase chance of finding the actual channel if a video comes first
searchQuery := fmt.Sprintf("ytsearch3:%s official channel", query)
// Remove --no-playlist to allow channel results
cmd := exec.Command(path, searchQuery, "--dump-json", "--flat-playlist")
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return "", err
}
var bestThumbnail string
scanner := bufio.NewScanner(&stdout)
for scanner.Scan() {
line := scanner.Bytes()
var res struct {
ID string `json:"id"`
Thumbnails []struct {
URL string `json:"url"`
} `json:"thumbnails"`
ChannelThumbnail string `json:"channel_thumbnail"`
}
if err := json.Unmarshal(line, &res); err == nil {
// Check if this is a channel (ID starts with UC)
if strings.HasPrefix(res.ID, "UC") {
if len(res.Thumbnails) > 0 {
// Return immediately if we found a channel
// OPTIMIZATION: User requested faster loading/lower quality.
// Instead of taking the last (largest), find one that is "good enough" (>= 150px)
// Channels usually have s88, s176, s240, s800 etc. s176 is perfect for local 144px display.
selected := res.Thumbnails[len(res.Thumbnails)-1].URL // Default to largest
// Simple logic: If we have multiple, pick the second to last? Or just hardcode a preference?
// Let's assume the list is sorted size ascending.
if len(res.Thumbnails) >= 2 {
// Usually [small, medium, large, max].
// If > 3 items, pick the one at index 1 or 2.
// Let's aim for index 1 (usually ~176px or 300px)
idx := 1
if idx >= len(res.Thumbnails) {
idx = len(res.Thumbnails) - 1
}
selected = res.Thumbnails[idx].URL
}
return selected, nil
}
}
// Keep track of the first valid thumbnail as fallback
if bestThumbnail == "" && len(res.Thumbnails) > 0 {
// Same logic for fallback
idx := 1
if len(res.Thumbnails) < 2 {
idx = 0
}
bestThumbnail = res.Thumbnails[idx].URL
}
}
}
if bestThumbnail != "" {
return bestThumbnail, nil
}
return "", fmt.Errorf("artist not found")
}
// GetStreamURL downloads the track and returns the local file path
func (s *Service) GetStreamURL(videoURL string) (string, error) {
// If it's a Spotify URL, we can't handle it directly with yt-dlp in this mode easily
// without search. But the frontend now sends YouTube URLs (from SearchTracks).
// If ID is passed, construct YouTube URL.
var targetURL string
if strings.HasPrefix(videoURL, "http") {
targetURL = videoURL
} else {
// Assume ID
targetURL = "https://www.youtube.com/watch?v=" + videoURL
}
videoID := extractVideoID(targetURL)
// Check if already downloaded (check for any audio format)
// We prefer m4a or webm since we don't have ffmpeg for mp3 conversion
pattern := filepath.Join(s.downloadDir, videoID+".*")
matches, _ := filepath.Glob(pattern)
if len(matches) > 0 {
return matches[0], nil
}
// Download: yt-dlp -f "bestaudio[ext=m4a]/bestaudio" -o <id>.%(ext)s <url>
// This avoids needing ffmpeg for conversion
cmd := exec.Command(ytDlpPath(), "-f", "bestaudio[ext=m4a]/bestaudio", "--output", videoID+".%(ext)s", targetURL)
cmd.Dir = s.downloadDir
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("download failed: %w, stderr: %s", err, stderr.String())
}
// Find the downloaded file again
matches, _ = filepath.Glob(pattern)
if len(matches) > 0 {
return matches[0], nil
}
return "", fmt.Errorf("downloaded file not found")
}
// StreamAudioToWriter streams audio to http writer
func (s *Service) StreamAudioToWriter(id string, w io.Writer) error {
filePath, err := s.GetStreamURL(id)
if err != nil {
return err
}
file, err := os.Open(filePath)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(w, file)
return err
}
func (s *Service) DownloadTrack(url string) (string, error) {
return s.GetStreamURL(url)
}
func extractVideoID(url string) string {
// Basic extraction for https://www.youtube.com/watch?v=ID
if strings.Contains(url, "v=") {
parts := strings.Split(url, "v=")
if len(parts) > 1 {
return strings.Split(parts[1], "&")[0]
}
}
return url // fallback to assume it's an ID or full URL if unique enough
}
// UpdateBinary updates yt-dlp to the latest nightly version
func (s *Service) UpdateBinary() (string, error) {
path := ytDlpPath()
// Command: yt-dlp --update-to nightly
cmd := exec.Command(path, "--update-to", "nightly")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err == nil {
return stdout.String(), nil
}
errStr := stderr.String()
// 2. Handle Pip Install Error
// "ERROR: You installed yt-dlp with pip or using the wheel from PyPi; Use that to update"
if strings.Contains(errStr, "pip") || strings.Contains(errStr, "wheel") {
// Try to find pip in the same directory as yt-dlp
dir := filepath.Dir(path)
pipPath := filepath.Join(dir, "pip.exe")
// If not found, try "pip" from PATH? But earlier checkout failed.
// Let's rely on relative path first.
if _, statErr := os.Stat(pipPath); statErr != nil {
// Try "python -m pip" if python is there?
// Or maybe "pip3.exe"
pipPath = filepath.Join(dir, "pip3.exe")
}
if _, statErr := os.Stat(pipPath); statErr == nil {
// Found pip, try updating via pip
// pip install -U --pre "yt-dlp[default]"
// We use --pre to get nightly/pre-release builds which user requested
pipCmd := exec.Command(pipPath, "install", "--upgrade", "--pre", "yt-dlp[default]")
// Capture new output
pipStdout := &bytes.Buffer{}
pipStderr := &bytes.Buffer{}
pipCmd.Stdout = pipStdout
pipCmd.Stderr = pipStderr
if pipErr := pipCmd.Run(); pipErr == nil {
return fmt.Sprintf("Updated via pip (%s):\n%s", pipPath, pipStdout.String()), nil
} else {
// Pip failed too
return "", fmt.Errorf("pip update failed: %w, stderr: %s", pipErr, pipStderr.String())
}
}
}
return "", fmt.Errorf("update failed: %w, stderr: %s", err, errStr)
}