Resolve conflicts: keep Rust backend version

This commit is contained in:
Khoa Vo 2026-03-20 21:22:45 +07:00
commit 7fe5b955e8
17 changed files with 14 additions and 1086 deletions

4
.gitignore vendored
View file

@ -47,6 +47,10 @@ wheels/
.idea/
.vscode/
# Rust
backend-rust/target/
backend-go/target/
# Project Specific
backend/data/*.json
!backend/data/browse_playlists.json

View file

@ -1,25 +0,0 @@
# Build Stage
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
# Go mod tidy created go.sum? If not, we run it here.
# Copy source
COPY . .
# Build
RUN go mod tidy
RUN go build -o server cmd/server/main.go
# Runtime Stage
FROM python:3.11-alpine
# We need python for yt-dlp
WORKDIR /app
# Install dependencies (ffmpeg, yt-dlp)
RUN apk add --no-cache ffmpeg curl
# Install yt-dlp via pip (often fresher) or use the binary
RUN pip install yt-dlp
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]

View file

@ -1,24 +0,0 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"spotify-clone-backend/internal/api"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
router := api.NewRouter()
fmt.Printf("Server starting on port %s...\n", port)
if err := http.ListenAndServe(":"+port, router); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}

View file

@ -1,8 +0,0 @@
module spotify-clone-backend
go 1.21
require (
github.com/go-chi/chi/v5 v5.0.10
github.com/go-chi/cors v1.2.1
)

View file

@ -1,4 +0,0 @@
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=

View file

@ -1,52 +0,0 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthCheck(t *testing.T) {
// Create a request to pass to our handler.
req, err := http.NewRequest("GET", "/api/health", nil)
if err != nil {
t.Fatal(err)
}
// We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response.
rr := httptest.NewRecorder()
// Initialize Router
router := NewRouter()
router.ServeHTTP(rr, req)
// Check the status code is what we expect.
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
// Check the response body is what we expect.
expected := "ok"
if rr.Body.String() != expected {
t.Errorf("handler returned unexpected body: got %v want %v",
rr.Body.String(), expected)
}
}
func TestSearchValidation(t *testing.T) {
// Test missing query parameter
req, err := http.NewRequest("GET", "/api/search", nil)
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
handler := http.HandlerFunc(SearchTracks)
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusBadRequest {
t.Errorf("handler returned wrong status code for missing query: got %v want %v",
status, http.StatusBadRequest)
}
}

View file

@ -1,125 +0,0 @@
package api
import (
"bufio"
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"spotify-clone-backend/internal/spotdl"
"github.com/go-chi/chi/v5"
)
var spotdlService = spotdl.NewService()
func SearchTracks(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "query parameter required", http.StatusBadRequest)
return
}
tracks, err := spotdlService.SearchTracks(query)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"tracks": tracks})
}
func StreamAudio(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "id required", http.StatusBadRequest)
return
}
path, err := spotdlService.GetStreamURL(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Set headers for streaming based on extension
ext := filepath.Ext(path)
contentType := "audio/mpeg" // default
if ext == ".m4a" {
contentType = "audio/mp4"
} else if ext == ".webm" {
contentType = "audio/webm"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Transfer-Encoding", "chunked")
// Flush headers immediately
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// Now stream it
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
io.Copy(w, bufio.NewReader(f))
}
func DownloadTrack(w http.ResponseWriter, r *http.Request) {
var body struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if body.URL == "" {
http.Error(w, "url required", http.StatusBadRequest)
return
}
path, err := spotdlService.DownloadTrack(body.URL)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"path": path, "status": "downloaded"})
}
func GetArtistImage(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "query required", http.StatusBadRequest)
return
}
imageURL, err := spotdlService.SearchArtist(query)
if err != nil {
http.Error(w, "artist not found", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"url": imageURL})
}
func UpdateSimBinary(w http.ResponseWriter, r *http.Request) {
output, err := spotdlService.UpdateBinary()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "updated", "output": output})
}

View file

@ -1,263 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
type LyricsResponse struct {
ID int `json:"id"`
TrackName string `json:"trackName"`
ArtistName string `json:"artistName"`
PlainLyrics string `json:"plainLyrics"`
SyncedLyrics string `json:"syncedLyrics"` // Time-synced lyrics [mm:ss.xx] text
Duration float64 `json:"duration"`
}
type OvhResponse struct {
Lyrics string `json:"lyrics"`
}
var httpClient = &http.Client{Timeout: 5 * time.Second}
// cleanVideoTitle attempts to extract the actual song title from a YouTube video title
func cleanVideoTitle(videoTitle, artistName string) string {
// 1. If strict "Artist - Title" format matches, take the title part
// Case-insensitive check
lowerTitle := strings.ToLower(videoTitle)
lowerArtist := strings.ToLower(artistName)
if strings.Contains(lowerTitle, " - ") {
parts := strings.Split(videoTitle, " - ")
if len(parts) >= 2 {
// Check if first part is artist
if strings.Contains(strings.ToLower(parts[0]), lowerArtist) {
return cleanMetadata(parts[1])
}
// Check if second part is artist
if strings.Contains(strings.ToLower(parts[1]), lowerArtist) {
return cleanMetadata(parts[0])
}
}
}
// 2. Separator Strategy ( |, //, -, :, feat. )
// Normalize separators to |
simplified := videoTitle
for _, sep := range []string{"//", " - ", ":", "feat.", "ft.", "|"} {
simplified = strings.ReplaceAll(simplified, sep, "|")
}
if strings.Contains(simplified, "|") {
parts := strings.Split(simplified, "|")
// Filter parts
var candidates []string
for _, p := range parts {
p = strings.TrimSpace(p)
pLower := strings.ToLower(p)
if p == "" {
continue
}
// Skip "Official Video", "MV", "Artist Name"
if strings.Contains(pLower, "official") || strings.Contains(pLower, "mv") || strings.Contains(pLower, "music video") {
continue
}
// Skip if it is contained in artist name (e.g. "Min" in "Min Official")
if pLower == lowerArtist || strings.Contains(lowerArtist, pLower) || strings.Contains(pLower, lowerArtist) {
continue
}
candidates = append(candidates, p)
}
// Heuristic: The Title is usually the FIRST valid part remaining.
// However, if we have multiple, and one is very short (< 4 chars) and one is long, pick the long one?
// Actually, let's look for the one that looks most like a title.
// For now, if we have multiple candidates, let's pick the longest one if the first one is tiny.
if len(candidates) > 0 {
best := candidates[0]
// If first candidate is super short (e.g. "HD"), look for a better one
if len(best) < 4 && len(candidates) > 1 {
for _, c := range candidates[1:] {
if len(c) > len(best) {
best = c
}
}
}
return cleanMetadata(best)
}
}
return cleanMetadata(videoTitle)
}
func cleanMetadata(title string) string {
// Remove parenthetical noise like (feat. X), (Official)
// Also remove unparenthesized "feat. X" or "ft. X" at the end of the string
re := regexp.MustCompile(`(?i)(\(feat\..*?\)|\[feat\..*?\]|\(remaster.*?\)|- remaster.*| - live.*|\(official.*?\)|\[official.*?\]| - official.*|\sfeat\..*|\sft\..*)`)
clean := re.ReplaceAllString(title, "")
return strings.TrimSpace(clean)
}
func cleanArtist(artist string) string {
// Remove " - Topic", " Official", "VEVO"
re := regexp.MustCompile(`(?i)( - topic| official| channel| vevo)`)
return strings.TrimSpace(re.ReplaceAllString(artist, ""))
}
func fetchFromLRCLIB(artist, track string) (*LyricsResponse, error) {
// 1. Try Specific Get
targetURL := fmt.Sprintf("https://lrclib.net/api/get?artist_name=%s&track_name=%s", url.QueryEscape(artist), url.QueryEscape(track))
resp, err := httpClient.Get(targetURL)
if err == nil && resp.StatusCode == 200 {
var lyrics LyricsResponse
if err := json.NewDecoder(resp.Body).Decode(&lyrics); err == nil && (lyrics.PlainLyrics != "" || lyrics.SyncedLyrics != "") {
resp.Body.Close()
return &lyrics, nil
}
resp.Body.Close()
}
// 2. Try Search (Best Match)
searchURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s %s", url.QueryEscape(artist), url.QueryEscape(track))
resp2, err := httpClient.Get(searchURL)
if err == nil && resp2.StatusCode == 200 {
var results []LyricsResponse
if err := json.NewDecoder(resp2.Body).Decode(&results); err == nil && len(results) > 0 {
resp2.Body.Close()
return &results[0], nil
}
resp2.Body.Close()
}
return nil, fmt.Errorf("not found in lrclib")
}
func fetchFromOVH(artist, track string) (*LyricsResponse, error) {
// OVH API: https://api.lyrics.ovh/v1/artist/title
targetURL := fmt.Sprintf("https://api.lyrics.ovh/v1/%s/%s", url.QueryEscape(artist), url.QueryEscape(track))
resp, err := httpClient.Get(targetURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 200 {
var ovh OvhResponse
if err := json.NewDecoder(resp.Body).Decode(&ovh); err == nil && ovh.Lyrics != "" {
return &LyricsResponse{
TrackName: track,
ArtistName: artist,
PlainLyrics: ovh.Lyrics,
}, nil
}
}
return nil, fmt.Errorf("not found in ovh")
}
func fetchFromLyrist(track string) (*LyricsResponse, error) {
// API: https://lyrist.vercel.app/api/:query
// Simple free API wrapper
targetURL := fmt.Sprintf("https://lyrist.vercel.app/api/%s", url.QueryEscape(track))
resp, err := httpClient.Get(targetURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 200 {
var res struct {
Lyrics string `json:"lyrics"`
Title string `json:"title"`
Artist string `json:"artist"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err == nil && res.Lyrics != "" {
return &LyricsResponse{
TrackName: res.Title,
ArtistName: res.Artist,
PlainLyrics: res.Lyrics,
}, nil
}
}
return nil, fmt.Errorf("not found in lyrist")
}
func GetLyrics(w http.ResponseWriter, r *http.Request) {
// Allow CORS
w.Header().Set("Access-Control-Allow-Origin", "*")
rawArtist := r.URL.Query().Get("artist")
rawTrack := r.URL.Query().Get("track")
if rawTrack == "" {
http.Error(w, "track required", http.StatusBadRequest)
return
}
// 1. Clean Inputs
artist := cleanArtist(rawArtist)
smartTitle := cleanVideoTitle(rawTrack, artist) // Heuristic extraction
dumbTitle := cleanMetadata(rawTrack) // Simple regex cleaning
fmt.Printf("[Lyrics] Request: %s | %s\n", rawArtist, rawTrack)
fmt.Printf("[Lyrics] Cleaned: %s | %s\n", artist, smartTitle)
// Strategy 1: LRCLIB (Exact Smart)
if lyrics, err := fetchFromLRCLIB(artist, smartTitle); err == nil {
fmt.Println("[Lyrics] Strategy 1 (Exact Smart) Hit")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(lyrics)
return
}
// Strategy 2: LRCLIB (Exact Dumb) - Fallback if our smart extraction failed
if smartTitle != dumbTitle {
if lyrics, err := fetchFromLRCLIB(artist, dumbTitle); err == nil {
fmt.Println("[Lyrics] Strategy 2 (Exact Dumb) Hit")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(lyrics)
return
}
}
// Strategy 3: Lyrist (Smart Search)
if lyrics, err := fetchFromLyrist(fmt.Sprintf("%s %s", artist, smartTitle)); err == nil {
fmt.Println("[Lyrics] Strategy 3 (Lyrist) Hit")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(lyrics)
return
}
// Strategy 4: OVH (Last Resort)
if lyrics, err := fetchFromOVH(artist, smartTitle); err == nil {
fmt.Println("[Lyrics] Strategy 4 (OVH) Hit")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(lyrics)
return
}
// Strategy 5: Hail Mary Search (Raw-ish)
if lyrics, err := fetchFromLRCLIB("", fmt.Sprintf("%s %s", artist, smartTitle)); err == nil {
fmt.Println("[Lyrics] Strategy 5 (Hail Mary) Hit")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(lyrics)
return
}
// Strategy 6: Title Only (Ignore Artist)
// Sometimes artist name is completely different in DB
if lyrics, err := fetchFromLRCLIB("", smartTitle); err == nil {
fmt.Println("[Lyrics] Strategy 6 (Title Only) Hit")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(lyrics)
return
}
fmt.Println("[Lyrics] Failed to find lyrics")
http.Error(w, "lyrics not found", http.StatusNotFound)
}

View file

@ -1,84 +0,0 @@
package api
import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
func NewRouter() http.Handler {
r := chi.NewRouter()
// Middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
// CORS
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:5173"}, // Added Vite default port
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Route("/api", func(r chi.Router) {
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
r.Get("/search", SearchTracks)
r.Get("/stream/{id}", StreamAudio)
r.Get("/lyrics", GetLyrics)
r.Get("/artist-image", GetArtistImage)
r.Post("/download", DownloadTrack)
r.Post("/settings/update-ytdlp", UpdateSimBinary)
})
// Serve Static Files (SPA)
workDir, _ := os.Getwd()
filesDir := http.Dir(filepath.Join(workDir, "static"))
FileServer(r, "/", filesDir)
return r
}
// FileServer conveniently sets up a http.FileServer handler to serve
// static files from a http.FileSystem.
func FileServer(r chi.Router, path string, root http.FileSystem) {
if strings.ContainsAny(path, "{}*") {
panic("FileServer does not permit any URL parameters.")
}
if path != "/" && path[len(path)-1] != '/' {
r.Get(path, http.RedirectHandler(path+"/", 301).ServeHTTP)
path += "/"
}
path += "*"
r.Get(path, func(w http.ResponseWriter, r *http.Request) {
rctx := chi.RouteContext(r.Context())
pathPrefix := strings.TrimSuffix(rctx.RoutePattern(), "/*")
fs := http.StripPrefix(pathPrefix, http.FileServer(root))
// Check if file exists, otherwise serve index.html (SPA)
f, err := root.Open(r.URL.Path)
if err != nil && os.IsNotExist(err) {
http.ServeFile(w, r, filepath.Join("static", "index.html"))
return
}
if f != nil {
f.Close()
}
fs.ServeHTTP(w, r)
})
}

View file

@ -1,25 +0,0 @@
package models
type Track struct {
ID string `json:"id"`
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
Duration int `json:"duration"`
CoverURL string `json:"cover_url"`
URL string `json:"url"`
}
type Playlist struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Author string `json:"author"`
CoverURL string `json:"cover_url"`
Tracks []Track `json:"tracks"`
Type string `json:"type,omitempty"`
}
type SearchResponse struct {
Tracks []Track `json:"tracks"`
}

View file

@ -1,410 +0,0 @@
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)
}

Binary file not shown.

Binary file not shown.

View file

@ -1,41 +0,0 @@
@echo off
echo ==========================================
echo Spotify Clone Deployment Script
echo ==========================================
echo [1/3] Checking Docker status...
docker info >nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] Docker is NOT running!
echo.
echo Please start Docker Desktop from your Start Menu.
echo Once Docker is running ^(green icon^), run this script again.
echo.
pause
exit /b 1
)
echo [2/3] Docker is active. Building Image...
echo This may take a few minutes...
docker build -t vndangkhoa/spotify-clone:latest .
if %errorlevel% neq 0 (
echo [ERROR] Docker build failed.
pause
exit /b 1
)
echo [3/3] Pushing to Docker Hub...
docker push vndangkhoa/spotify-clone:latest
if %errorlevel% neq 0 (
echo [ERROR] Docker push failed.
echo You may need to run 'docker login' first.
pause
exit /b 1
)
echo.
echo ==========================================
echo [SUCCESS] Deployment Complete!
echo Image: vndangkhoa/spotify-clone:latest
echo ==========================================
pause

View file

@ -1,17 +0,0 @@
@echo off
echo Stopping existing processes...
taskkill /F /IM server.exe >nul 2>&1
taskkill /F /IM node.exe >nul 2>&1
echo Starting Backend...
cd backend-go
start /B server.exe
cd ..
echo Starting Frontend...
cd frontend-vite
start npm run dev
cd ..
echo App Restarted! Backend on :8080, Frontend on :5173

View file

@ -2,10 +2,12 @@ $ErrorActionPreference = "Stop"
$ScriptDir = $PSScriptRoot
$ProjectRoot = Split-Path $ScriptDir -Parent
# 1. Locate Go
$GoExe = "C:\Program Files\Go\bin\go.exe"
if (-not (Test-Path $GoExe)) {
Write-Host "Go binaries not found at expected location: $GoExe" -ForegroundColor Red
# 1. Locate Cargo (Rust)
$CargoExe = "cargo"
try {
cargo --version | Out-Null
} catch {
Write-Host "Cargo not found in PATH. Please install Rust." -ForegroundColor Red
Exit 1
}
@ -24,12 +26,12 @@ $env:PATH = "$NodeDir;$env:PATH"
Write-Host "Environment configured." -ForegroundColor Gray
# 4. Start Backend (in new window)
Write-Host "Starting Backend..." -ForegroundColor Green
$BackendDir = Join-Path $ProjectRoot "backend-go"
Start-Process -FilePath "cmd.exe" -ArgumentList "/k `"$GoExe`" run cmd\server\main.go" -WorkingDirectory $BackendDir
Write-Host "Starting Backend (Rust)..." -ForegroundColor Green
$BackendDir = Join-Path $ProjectRoot "backend-rust"
Start-Process -FilePath "cmd.exe" -ArgumentList "/k cargo run" -WorkingDirectory $BackendDir
# 5. Start Frontend (in new window)
Write-Host "Starting Frontend..." -ForegroundColor Green
Write-Host "Starting Frontend (Vite)..." -ForegroundColor Green
$FrontendDir = Join-Path $ProjectRoot "frontend-vite"
# Check if node_modules exists, otherwise install

Binary file not shown.