diff --git a/.gitignore b/.gitignore index a921dee..159bec2 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,10 @@ wheels/ .idea/ .vscode/ +# Rust +backend-rust/target/ +backend-go/target/ + # Project Specific backend/data/*.json !backend/data/browse_playlists.json diff --git a/backend-go/Dockerfile b/backend-go/Dockerfile deleted file mode 100644 index 0d3e259..0000000 --- a/backend-go/Dockerfile +++ /dev/null @@ -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"] diff --git a/backend-go/cmd/server/main.go b/backend-go/cmd/server/main.go deleted file mode 100644 index 39f05d7..0000000 --- a/backend-go/cmd/server/main.go +++ /dev/null @@ -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) - } -} diff --git a/backend-go/go.mod b/backend-go/go.mod deleted file mode 100644 index 15b0a89..0000000 --- a/backend-go/go.mod +++ /dev/null @@ -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 -) diff --git a/backend-go/go.sum b/backend-go/go.sum deleted file mode 100644 index abb0845..0000000 --- a/backend-go/go.sum +++ /dev/null @@ -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= diff --git a/backend-go/internal/api/api_test.go b/backend-go/internal/api/api_test.go deleted file mode 100644 index 8ec21c1..0000000 --- a/backend-go/internal/api/api_test.go +++ /dev/null @@ -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) - } -} diff --git a/backend-go/internal/api/handlers.go b/backend-go/internal/api/handlers.go deleted file mode 100644 index 85f11d7..0000000 --- a/backend-go/internal/api/handlers.go +++ /dev/null @@ -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}) -} diff --git a/backend-go/internal/api/lyrics.go b/backend-go/internal/api/lyrics.go deleted file mode 100644 index 8a8df9f..0000000 --- a/backend-go/internal/api/lyrics.go +++ /dev/null @@ -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) -} diff --git a/backend-go/internal/api/router.go b/backend-go/internal/api/router.go deleted file mode 100644 index 277e589..0000000 --- a/backend-go/internal/api/router.go +++ /dev/null @@ -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) - }) -} diff --git a/backend-go/internal/models/models.go b/backend-go/internal/models/models.go deleted file mode 100644 index 4a6b48c..0000000 --- a/backend-go/internal/models/models.go +++ /dev/null @@ -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"` -} diff --git a/backend-go/internal/spotdl/client.go b/backend-go/internal/spotdl/client.go deleted file mode 100644 index de7bac5..0000000 --- a/backend-go/internal/spotdl/client.go +++ /dev/null @@ -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:" --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 .%(ext)s - // 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) -} diff --git a/backend-go/server-linux-amd64 b/backend-go/server-linux-amd64 deleted file mode 100644 index 7208f28..0000000 Binary files a/backend-go/server-linux-amd64 and /dev/null differ diff --git a/backend-go/server.exe b/backend-go/server.exe deleted file mode 100644 index c82cfe2..0000000 Binary files a/backend-go/server.exe and /dev/null differ diff --git a/deploy.bat b/deploy.bat deleted file mode 100644 index fc10e5d..0000000 --- a/deploy.bat +++ /dev/null @@ -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 diff --git a/restart_app.bat b/restart_app.bat deleted file mode 100644 index 86e4caa..0000000 --- a/restart_app.bat +++ /dev/null @@ -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 diff --git a/scripts/start.ps1 b/scripts/start.ps1 index 5fb0c69..fdb77ec 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -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 diff --git a/yt-dlp.exe b/yt-dlp.exe deleted file mode 100644 index 8788f61..0000000 Binary files a/yt-dlp.exe and /dev/null differ