Resolve conflicts: keep Rust backend version
This commit is contained in:
commit
7fe5b955e8
17 changed files with 14 additions and 1086 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -47,6 +47,10 @@ wheels/
|
|||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Rust
|
||||
backend-rust/target/
|
||||
backend-go/target/
|
||||
|
||||
# Project Specific
|
||||
backend/data/*.json
|
||||
!backend/data/browse_playlists.json
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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.
41
deploy.bat
41
deploy.bat
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
BIN
yt-dlp.exe
BIN
yt-dlp.exe
Binary file not shown.
Loading…
Reference in a new issue