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