diff --git a/.gitignore b/.gitignore index 14921e1..edd0528 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/Dockerfile b/Dockerfile index 461e14e..cd8d289 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,70 +1,53 @@ - -# --------------------------- -# Stage 1: Build Frontend -# --------------------------- -FROM node:20-alpine AS frontend-builder -WORKDIR /app/frontend - -COPY frontend-vite/package*.json ./ -RUN npm ci - -COPY frontend-vite/ . -# Ensure production build -ENV NODE_ENV=production -RUN npm run build - -# --------------------------- -# Stage 2: Build Backend -# --------------------------- -FROM golang:1.24-alpine AS backend-builder -WORKDIR /app/backend - -# Install build deps if needed (e.g. gcc for cgo, though we try to avoid it) -RUN apk add --no-cache git - -COPY backend-go/go.mod backend-go/go.sum ./ -RUN go mod download - -COPY backend-go/ . -# Build static binary for linux/amd64 -ENV CGO_ENABLED=0 -ENV GOOS=linux -ENV GOARCH=amd64 -RUN go build -o server cmd/server/main.go - -# --------------------------- -# Stage 3: Final Runtime -# --------------------------- -# We use python:3.11-slim because yt-dlp requires Python. -# Alpine is smaller but Python/libc compatibility can be tricky for yt-dlp's dependencies. -# Slim-bookworm is a safe bet for a "linux/amd64" target. -FROM python:3.11-slim-bookworm - -WORKDIR /app - -# Install runtime dependencies for yt-dlp (ffmpeg is crucial) -RUN apt-get update && apt-get install -y \ - ffmpeg \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -# Copy backend binary -COPY --from=backend-builder /app/backend/server /app/server - -# Copy frontend build to 'static' folder (backend expects ./static) -COPY --from=frontend-builder /app/frontend/dist /app/static - -# Create cache directory for spotdl -RUN mkdir -p /tmp/spotify-clone-cache && chmod 777 /tmp/spotify-clone-cache - -# Install yt-dlp (and pip) -# We install it systematically via pip to ensure we have a managed version -RUN pip install --no-cache-dir -U "yt-dlp[default]" - -# Environment variables -ENV PORT=8080 -ENV GIN_MODE=release - -EXPOSE 8080 - -CMD ["/app/server"] + +# --------------------------- +# Stage 1: Build Frontend +# --------------------------- +FROM node:20-alpine AS frontend-builder +WORKDIR /app/frontend + +COPY frontend-vite/package*.json ./ +RUN npm ci + +COPY frontend-vite/ . +ENV NODE_ENV=production +RUN npm run build + +# --------------------------- +# Stage 2: Build Backend (Rust) +# --------------------------- +FROM rust:1.75-bookworm AS backend-builder +WORKDIR /app/backend + +COPY backend-rust/Cargo.toml ./ +RUN mkdir src && echo "fn main() {}" > src/main.rs +RUN cargo build --release && rm -rf src + +COPY backend-rust/src ./src +RUN cargo build --release + +# --------------------------- +# Stage 3: Final Runtime +# --------------------------- +FROM python:3.11-slim-bookworm + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + ffmpeg \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=backend-builder /app/backend/target/release/backend-rust /app/server + +COPY --from=frontend-builder /app/frontend/dist /app/static + +RUN mkdir -p /tmp/spotify-clone-cache && chmod 777 /tmp/spotify-clone-cache + +RUN pip install --no-cache-dir -U "yt-dlp[default]" + +ENV PORT=8080 +ENV RUST_ENV=production + +EXPOSE 8080 + +CMD ["/app/server"] diff --git a/README.md b/README.md index cb27b0b..8801226 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Spotify Clone 🎵 -A fully functional clone of the Spotify web player, built with a modern stack featuring **Next.js**, **FastAPI**, and **TailwindCSS**. This application replicates the premium authentic feel of the original web player with added features like synchronized lyrics, custom playlist management, and "Audiophile" technical specs. +A fully functional clone of the Spotify web player, built with **React**, **Rust (Axum)**, and **TailwindCSS**. This application replicates the premium authentic feel of the original web player with added features like synchronized lyrics, custom playlist management, and "Audiophile" technical specs. ![Preview](https://opengraph.githubassets.com/1/vndangkhoa/spotify-clone) @@ -8,130 +8,158 @@ A fully functional clone of the Spotify web player, built with a modern stack fe ## 🚀 Quick Start (Docker) -The easiest way to run the application is using Docker. - -### Option 1: Run from Docker Hub (Pre-built) +### Option 1: Pull from Registry ```bash -docker run -p 3000:3000 -p 8000:8000 vndangkhoa/spotify-clone:latest +docker pull git.khoavo.myds.me/vndangkhoa/spotify-clone:v3 +docker run -d -p 3000:8080 --name spotify-clone \ + -v ./data:/app/data \ + -v ./cache:/tmp/spotify-clone-cache \ + --restart unless-stopped \ + git.khoavo.myds.me/vndangkhoa/spotify-clone:v3 ``` -Open **[http://localhost:3000](http://localhost:3000)**. ### Option 2: Build Locally ```bash -docker build -t spotify-clone . -docker run -p 3000:3000 -p 8000:8000 spotify-clone +docker build -t spotify-clone:v3 . +docker run -d -p 3000:8080 --name spotify-clone \ + -v ./data:/app/data \ + -v ./cache:/tmp/spotify-clone-cache \ + --restart unless-stopped \ + spotify-clone:v3 ``` +Open **[http://localhost:3000](http://localhost:3000)**. + +--- + +## 🐳 Docker Deployment + +### Building the Image +```bash +# Build for linux/amd64 (Synology NAS) +docker build -t git.khoavo.myds.me/vndangkhoa/spotify-clone:v3 . + +# Push to registry +docker push git.khoavo.myds.me/vndangkhoa/spotify-clone:v3 +``` + +### Docker Compose (Recommended) +```yaml +services: + spotify-clone: + image: git.khoavo.myds.me/vndangkhoa/spotify-clone:v3 + container_name: spotify-clone + restart: unless-stopped + ports: + - "3000:8080" + environment: + - PORT=8080 + - RUST_ENV=production + volumes: + - ./data:/app/data + - ./cache:/tmp/spotify-clone-cache + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" +``` + +--- + +## 📦 Synology NAS Deployment + +### Method A: Container Manager UI (GUI) + +1. **Open Container Manager** on your Synology NAS. +2. Go to **Registry** and click **Add**: + - Registry URL: `https://git.khoavo.myds.me` + - Enter credentials if prompted. +3. Search for `vndangkhoa/spotify-clone` and download the `v3` tag. +4. Go to **Image**, select the downloaded image, and click **Run**. +5. Configure: + - **Container Name**: `spotify-clone` + - **Port Settings**: + - Local Port: `3000` (or any available port) + - Container Port: `8080` + - **Volume Settings**: + - Add folder: `docker/spotify-clone/data` → `/app/data` + - Add folder: `docker/spotify-clone/cache` → `/tmp/spotify-clone-cache` + - **Restart Policy**: `unless-stopped` +6. Click **Done** and access at `http://YOUR_NAS_IP:3000`. + +### Method B: Docker Compose (CLI) + +1. SSH into your Synology NAS or use the built-in terminal. +2. Create a folder: + ```bash + mkdir -p /volume1/docker/spotify-clone + cd /volume1/docker/spotify-clone + ``` +3. Create `docker-compose.yml`: + ```yaml + services: + spotify-clone: + image: git.khoavo.myds.me/vndangkhoa/spotify-clone:v3 + container_name: spotify-clone + restart: unless-stopped + ports: + - "3000:8080" + environment: + - PORT=8080 + - RUST_ENV=production + volumes: + - ./data:/app/data + - ./cache:/tmp/spotify-clone-cache + ``` +4. Run: + ```bash + docker compose up -d + ``` + +### Synology-Specific Notes + +- **Architecture**: This image is built for `linux/amd64` (compatible with most Intel-based Synology NAS). +- **DSM 7+**: Use Container Manager (Docker GUI replacement). +- **Data Persistence**: The `./data` volume stores playlists and application data. Backup this folder to preserve your data. +- **Updating**: Pull the latest image and recreate the container, or use Watchtower for auto-updates. + --- ## 🛠️ Local Development -If you want to contribute or modify the code: - ### Prerequisites -- Node.js 18+ +- Node.js 20+ +- Rust 1.75+ - Python 3.11+ -- ffmpeg (optional, for some audio features) +- ffmpeg -### 1. Backend Setup +### 1. Backend Setup (Rust) ```bash -cd backend -python -m venv venv -source venv/bin/activate # Windows: venv\Scripts\activate -pip install -r requirements.txt -python main.py +cd backend-rust +cargo run ``` -Backend runs on `http://localhost:8000`. +Backend runs on `http://localhost:8080`. ### 2. Frontend Setup ```bash -cd frontend +cd frontend-vite npm install npm run dev ``` -Frontend runs on `http://localhost:3000`. - ---- - -## 📦 Deployment Guide - -### 1. Deploy to GitHub -Initialize the repository (if not done) and push: -```bash -git init -git add . -git commit -m "Initial commit" -git branch -M main -git remote add origin https://github.com/YOUR_USERNAME/spotify-clone.git -git push -u origin main -``` - -### 2. Deploy to Docker Hub -To share your image with the world (or your NAS): -```bash -# 1. Login to Docker Hub -docker login - -# 2. Build the image (replace 'vndangkhoa' with your Docker Hub username) -docker build -t vndangkhoa/spotify-clone:latest . - -# 3. Push the image -docker push vndangkhoa/spotify-clone:latest -``` - -### 3. Deploy to Synology NAS (Container Manager) -This app runs perfectly on Synology NAS using **Container Manager** (formerly Docker). - -#### Method A: Using Container Manager UI (Easy) -1. Open **Container Manager**. -2. Go to **Registry** -> Search for `vndangkhoa/spotify-clone` (or your image). -3. Download the image. -4. Go to **Image** -> Select image -> **Run**. - - **Network**: Bridge (default). - - **Port Settings**: Map Local Port `3110` (or any) to Container Port `3000`. - - **Volume Settings** (Optional): Map a folder `/docker/spotify/data` to `/app/backend/data` to save playlists. -5. Done! Access at `http://YOUR_NAS_IP:3110`. - -#### Method B: Using Docker Compose (Recommended) -1. Create a folder on your NAS (e.g., `/volume1/docker/spotify`). -2. Create a file named `docker-compose.yml` inside it: - ```yaml - services: - spotify-clone: - image: vndangkhoa/spotify-clone:latest - container_name: spotify-clone - restart: always - network_mode: bridge - ports: - - "3110:3000" # Web UI Access Port - volumes: - - ./data:/app/backend/data - ``` -3. In Container Manager, go to **Project** -> **Create**. -4. Select the folder path, give it a name, and it will detect the compose file. -5. Click **Build** / **Run**. - -#### ✨ Auto-Update Enabled -When using the `docker-compose.yml` above, a **Watchtower** container is included. It will automatically: -- Check for updates to `vndangkhoa/spotify-clone:latest` every hour. -- Download the new image if available. -- Restart the application with the new version. -- Remove old image versions to save space. - -You don't need to do anything manually to keep it updated! 🚀 +Frontend runs on `http://localhost:5173`. --- ## ✨ Features -- **Real-Time Lyrics**: Fetch and sync lyrics from multiple sources (YouTube, LRCLIB). +- **Real-Time Lyrics**: Fetch and sync lyrics from multiple sources. - **Audiophile Engine**: "Tech Specs" view showing live bitrate, LUFS, and Dynamic Range. - **Local-First**: Works offline (PWA) and syncs local playlists. - **Smart Search**: Unified search across YouTube Music. -- **Responsive**: Full mobile support with a dedicated full-screen player. +- **Responsive**: Full mobile support with dedicated full-screen player. - **Smooth Loading**: Skeleton animations for seamless data fetching. -- **Infinite Experience**: "Show all" pages with infinite scrolling support. -- **Enhanced Mobile**: Optimized 3-column layouts and smart player visibility. ## 📝 License + MIT License 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/backend-rust/Cargo.lock b/backend-rust/Cargo.lock new file mode 100644 index 0000000..decc7db --- /dev/null +++ b/backend-rust/Cargo.lock @@ -0,0 +1,2013 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "aws-lc-rs" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backend-rust" +version = "0.1.0" +dependencies = [ + "axum", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tower", + "tower-http", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "iri-string", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/backend-rust/Cargo.toml b/backend-rust/Cargo.toml new file mode 100644 index 0000000..a53518d --- /dev/null +++ b/backend-rust/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "backend-rust" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = "0.8.8" +reqwest = { version = "0.13.2", features = ["json"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +tokio = { version = "1.50.0", features = ["full"] } +tokio-util = { version = "0.7.18", features = ["io"] } +tower = { version = "0.5.3", features = ["util"] } +tower-http = { version = "0.6.8", features = ["cors", "fs"] } diff --git a/backend-rust/src/api.rs b/backend-rust/src/api.rs new file mode 100644 index 0000000..61cbb02 --- /dev/null +++ b/backend-rust/src/api.rs @@ -0,0 +1,82 @@ +use axum::{ + extract::{Path, Query, State}, + http::{header, StatusCode}, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::fs::File; +use tokio_util::io::ReaderStream; + +use crate::spotdl::SpotdlService; +use crate::models::{Playlist, Track}; + +pub struct AppState { + pub spotdl: SpotdlService, +} + +#[derive(Deserialize)] +pub struct SearchQuery { + pub q: String, +} + +pub async fn search_handler( + State(state): State>, + Query(params): Query, +) -> impl IntoResponse { + let query = params.q.trim(); + if query.is_empty() { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Query required"}))); + } + + match state.spotdl.search_tracks(query).await { + Ok(tracks) => (StatusCode::OK, Json(serde_json::json!({"tracks": tracks}))), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e}))), + } +} + +pub async fn stream_handler( + State(state): State>, + Path(id): Path, + req: axum::extract::Request, +) -> impl IntoResponse { + // This blocks the async executor slightly, ideally spawn_blocking but it's okay for now + match state.spotdl.get_stream_url(&id) { + Ok(file_path) => { + let service = tower_http::services::ServeFile::new(&file_path); + match tower::ServiceExt::oneshot(service, req).await { + Ok(res) => res.into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Error serving file").into_response(), + } + }, + Err(e) => { + (StatusCode::INTERNAL_SERVER_ERROR, e).into_response() + } + } +} + +pub async fn artist_info_handler( + State(state): State>, + Query(params): Query, +) -> impl IntoResponse { + let query = params.q.trim(); + if query.is_empty() { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Artist name required"}))); + } + + match state.spotdl.search_artist(query) { + Ok(img) => (StatusCode::OK, Json(serde_json::json!({"image": img}))), + Err(e) => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": e}))), + } +} + +pub async fn browse_handler( + State(state): State>, +) -> impl IntoResponse { + let cache = state.spotdl.browse_cache.read().await; + + // If the cache is still empty (e.g., still preloading in background), + // we can return empty or a small default. The frontend will handle it. + (StatusCode::OK, Json(cache.clone())) +} diff --git a/backend-rust/src/main.rs b/backend-rust/src/main.rs new file mode 100644 index 0000000..64a121d --- /dev/null +++ b/backend-rust/src/main.rs @@ -0,0 +1,41 @@ +mod api; +mod models; +mod spotdl; + +use axum::{ + routing::get, + Router, +}; +use std::net::SocketAddr; +use std::sync::Arc; +use tower_http::cors::{Any, CorsLayer}; + +use crate::api::AppState; +use crate::spotdl::SpotdlService; + +#[tokio::main] +async fn main() { + let spotdl = SpotdlService::new(); + spotdl.start_background_preload(); + + let app_state = Arc::new(AppState { spotdl }); + + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/api/search", get(api::search_handler)) + .route("/api/stream/{id}", get(api::stream_handler)) + .route("/api/artist/info", get(api::artist_info_handler)) + .route("/api/browse", get(api::browse_handler)) + .layer(cors) + .with_state(app_state); + + let addr = SocketAddr::from(([0, 0, 0, 0], 8080)); + println!("Backend running on http://{}", addr); + + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/backend-rust/src/models.rs b/backend-rust/src/models.rs new file mode 100644 index 0000000..7e1b0e6 --- /dev/null +++ b/backend-rust/src/models.rs @@ -0,0 +1,54 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Track { + pub id: String, + pub title: String, + pub artist: String, + pub album: String, + pub duration: i32, + pub cover_url: String, + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Playlist { + pub id: String, + pub title: String, + pub cover_url: Option, + pub created_at: i64, + pub tracks: Vec, + + #[serde(rename = "type")] + pub playlist_type: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct StaticPlaylist { + pub id: String, + pub title: String, + pub description: Option, + pub cover_url: Option, + pub creator: Option, + pub tracks: Vec, + #[serde(rename = "type")] + pub playlist_type: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct YTResult { + pub id: String, + pub title: String, + pub uploader: String, + pub duration: Option, + pub webpage_url: Option, + #[serde(default)] + pub thumbnails: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct YTThumbnail { + pub url: String, + pub height: Option, + pub width: Option, +} diff --git a/backend-rust/src/spotdl.rs b/backend-rust/src/spotdl.rs new file mode 100644 index 0000000..6e3f717 --- /dev/null +++ b/backend-rust/src/spotdl.rs @@ -0,0 +1,361 @@ +use std::process::Command; +use std::path::{Path, PathBuf}; +use std::env; +use std::fs; +use std::sync::Arc; +use tokio::sync::RwLock; +use std::collections::HashMap; +use std::time::{Instant, Duration}; + +use crate::models::{Track, YTResult, StaticPlaylist}; + +struct CacheItem { + tracks: Vec, + timestamp: Instant, +} + +#[derive(Clone)] +pub struct SpotdlService { + download_dir: PathBuf, + search_cache: Arc>>, + pub browse_cache: Arc>>>, +} + +impl SpotdlService { + pub fn new() -> Self { + let temp_dir = env::temp_dir(); + let download_dir = temp_dir.join("spotify-clone-cache"); + let _ = fs::create_dir_all(&download_dir); + + Self { + download_dir, + search_cache: Arc::new(RwLock::new(HashMap::new())), + browse_cache: Arc::new(RwLock::new(HashMap::new())), + } + } + + fn yt_dlp_path() -> String { + // Try local + if let Ok(exe_path) = env::current_exe() { + if let Some(dir) = exe_path.parent() { + let local = dir.join("yt-dlp.exe"); + if local.exists() { + return local.to_string_lossy().into_owned(); + } + } + } + + // Try working dir + if Path::new("yt-dlp.exe").exists() { + return "./yt-dlp.exe".to_string(); + } + + // Try Python + if let Ok(home) = env::var("USERPROFILE") { + let py_path = Path::new(&home).join("AppData").join("Local").join("Programs").join("Python").join("Python312").join("Scripts").join("yt-dlp.exe"); + if py_path.exists() { + return py_path.to_string_lossy().into_owned(); + } + } + + "yt-dlp".to_string() + } + + pub fn start_background_preload(&self) { + let cache_arc = self.browse_cache.clone(); + + tokio::spawn(async move { + println!("Background preloader started... fetching Top Albums & Playlists"); + let queries = vec![ + ("Top Albums", "ytsearch50:Top Albums Vietnam audio"), + ("Viral Hits", "ytsearch30:Viral Hits Vietnam audio"), + ("Lofi Chill", "ytsearch30:Lofi Chill Vietnam audio"), + ("US UK Top Hits", "ytsearch30:US UK Billboard Hot 100 audio"), + ("K-Pop", "ytsearch30:K-Pop Top Hits audio"), + ]; + + let path = Self::yt_dlp_path(); + let mut all_data: HashMap> = HashMap::new(); + + for (category, search_query) in queries { + let output = Command::new(&path) + .args(&[&search_query, "--dump-json", "--no-playlist", "--flat-playlist"]) + .output(); + + if let Ok(o) = output { + let stdout = String::from_utf8_lossy(&o.stdout); + let mut items = Vec::new(); + + for line in stdout.lines() { + if let Ok(res) = serde_json::from_str::(line) { + let duration = res.duration.unwrap_or(0.0); + if res.id.starts_with("UC") || duration < 60.0 { continue; } + + let cover_url = if let Some(t) = res.thumbnails.last() { t.url.clone() } else { format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", res.id) }; + + let artist = res.uploader.replace(" - Topic", ""); + + // Decide if it's treated as Album or Playlist + let is_album = category == "Top Albums"; + let p_type = if is_album { "Album" } else { "Playlist" }; + let title = if is_album { + // Synthesize an album name or just use the title + res.title.clone() + } else { + format!("{} Mix", res.title.clone()) + }; + + let id_slug = res.title.replace(|c: char| !c.is_alphanumeric() && c != ' ', "").replace(' ', "-"); + items.push(StaticPlaylist { + id: format!("discovery-{}-{}-{}", p_type.to_lowercase(), id_slug, res.id), + title, + description: Some(if is_album { "Album".to_string() } else { format!("Made for you • {}", artist) }), + cover_url: Some(cover_url), + creator: Some(artist), + tracks: Vec::new(), + playlist_type: p_type.to_string(), + }); + } + } + + if !items.is_empty() { + all_data.insert(category.to_string(), items); + } + } + } + + // Also load artists + let artists_query = "ytsearch30:V-Pop Official Channel"; + if let Ok(o) = Command::new(&path) + .args(&[&artists_query, "--dump-json", "--flat-playlist"]) + .output() { + let mut items = Vec::new(); + for line in String::from_utf8_lossy(&o.stdout).lines() { + if let Ok(res) = serde_json::from_str::(line) { + if res.id.starts_with("UC") { + let cover_url = res.thumbnails.last().map(|t| t.url.clone()).unwrap_or_default(); + let artist = res.title.replace(" - Topic", ""); + let id_slug = artist.replace(|c: char| !c.is_alphanumeric() && c != ' ', "").replace(' ', "-"); + items.push(StaticPlaylist { + id: format!("discovery-artist-{}-{}", id_slug, res.id), + title: artist.clone(), + description: Some("Artist".to_string()), + cover_url: Some(cover_url), + creator: Some("Artist".to_string()), + tracks: Vec::new(), + playlist_type: "Artist".to_string(), + }); + } + } + } + if !items.is_empty() { + all_data.insert("Popular Artists".to_string(), items); + } + } + + println!("Background preloader finished loading {} categories!", all_data.len()); + let mut cache = cache_arc.write().await; + *cache = all_data; + }); + } + + pub async fn search_tracks(&self, query: &str) -> Result, String> { + // 1. Check Cache + { + let cache = self.search_cache.read().await; + if let Some(item) = cache.get(query) { + if item.timestamp.elapsed() < Duration::from_secs(3600) { + println!("Cache Hit: {}", query); + return Ok(item.tracks.clone()); + } + } + } + + let path = Self::yt_dlp_path(); + let search_query = format!("ytsearch20:{} audio", query); + + let output = match Command::new(&path) + .args(&[&search_query, "--dump-json", "--no-playlist", "--flat-playlist"]) + .output() { + Ok(o) => o, + Err(e) => return Err(format!("Failed to execute yt-dlp: {}", e)), + }; + + if !output.status.success() { + return Err(format!("Search failed. stderr: {}", String::from_utf8_lossy(&output.stderr))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut tracks = Vec::new(); + + for line in stdout.lines() { + if line.trim().is_empty() { + continue; + } + + if let Ok(res) = serde_json::from_str::(line) { + let duration = res.duration.unwrap_or(0.0); + + // FILTER: channel, playlist, short, long, or ZERO duration + if res.id.starts_with("UC") || res.id.starts_with("PL") || duration < 1.0 || duration > 1200.0 { + continue; + } + + let artist = res.uploader.replace(" - Topic", ""); + + // Select thumbnail + let mut cover_url = String::new(); + if !res.thumbnails.is_empty() { + let mut best_score = -1.0; + + for thumb in &res.thumbnails { + let w = thumb.width.unwrap_or(0) as f64; + let h = thumb.height.unwrap_or(0) as f64; + + if w == 0.0 || h == 0.0 { continue; } + + let ratio = w / h; + let diff = (ratio - 1.0).abs(); + let mut score = w * h; + + if diff < 0.1 { + score *= 10.0; + } + + if score > best_score { + best_score = score; + cover_url = thumb.url.clone(); + } + } + + if cover_url.is_empty() { + cover_url = res.thumbnails.last().unwrap().url.clone(); + } + } else { + cover_url = format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", res.id); + } + + tracks.push(Track { + id: res.id.clone(), + title: res.title.clone(), + artist, + album: "YouTube Music".to_string(), + duration: duration as i32, + cover_url, + url: format!("/api/stream/{}", res.id), + }); + } + } + + // 2. Save cache + if !tracks.is_empty() { + let mut cache = self.search_cache.write().await; + cache.insert(query.to_string(), CacheItem { + tracks: tracks.clone(), + timestamp: Instant::now(), + }); + } + + Ok(tracks) + } + + pub fn get_stream_url(&self, video_url: &str) -> Result { + let target_url = if video_url.starts_with("http") { + video_url.to_string() + } else { + format!("https://www.youtube.com/watch?v={}", video_url) + }; + + let video_id = Self::extract_id(&target_url); + + // Already downloaded? (just check if anything starts with id in temp dir) + if let Ok(entries) = fs::read_dir(&self.download_dir) { + for entry in entries.flatten() { + if let Some(file_name) = entry.file_name().to_str() { + if file_name.starts_with(&format!("{}.", video_id)) { + return Ok(entry.path().to_string_lossy().into_owned()); + } + } + } + } + + let output = match Command::new(Self::yt_dlp_path()) + .current_dir(&self.download_dir) + .args(&["-f", "bestaudio[ext=m4a]/bestaudio", "--output", &format!("{}.%(ext)s", video_id), &target_url]) + .output() { + Ok(o) => o, + Err(e) => return Err(format!("Download spawn failed: {}", e)), + }; + + if !output.status.success() { + return Err(format!("Download failed. stderr: {}", String::from_utf8_lossy(&output.stderr))); + } + + // Find downloaded file again + if let Ok(entries) = fs::read_dir(&self.download_dir) { + for entry in entries.flatten() { + if let Some(file_name) = entry.file_name().to_str() { + if file_name.starts_with(&format!("{}.", video_id)) { + return Ok(entry.path().to_string_lossy().into_owned()); + } + } + } + } + + Err("File not found after download".to_string()) + } + + pub fn search_artist(&self, query: &str) -> Result { + let path = Self::yt_dlp_path(); + + // Search specifically for official channel to get the avatar + let search_query = format!("ytsearch1:{} official channel", query); + + let output = match Command::new(&path) + .args(&[&search_query, "--dump-json", "--flat-playlist"]) + .output() { + Ok(o) => o, + Err(_) => return Err("Search failed to execute".to_string()), + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + + #[derive(serde::Deserialize)] + struct SimpleYT { + id: String, + #[serde(default)] + thumbnails: Vec, + } + + for line in stdout.lines() { + if let Ok(res) = serde_json::from_str::(line) { + // If it's a channel (starts with UC), use its avatar + if res.id.starts_with("UC") { + let best_thumb = res.thumbnails.iter().max_by_key(|t| { + let w = t.width.unwrap_or(0); + let h = t.height.unwrap_or(0); + w * h + }); + + if let Some(thumb) = best_thumb { + return Ok(thumb.url.clone()); + } + } + } + } + + // Fallback: If no channel found, try searching normally but stay alert for channel icons + Err("No authentic channel photo found for artist".to_string()) + } + + fn extract_id(url: &str) -> String { + if url.contains("v=") { + let parts: Vec<&str> = url.split("v=").collect(); + if parts.len() > 1 { + let sub_parts: Vec<&str> = parts[1].split('&').collect(); + return sub_parts[0].to_string(); + } + } + url.to_string() + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 1bea826..8e3af8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,18 @@ -services: - spotify-clone: - image: vndangkhoa/spotify-clone:latest - container_name: spotify-clone - restart: always - network_mode: bridge # Synology often prefers explicit bridge or host - ports: - - "3110:3000" # Web UI - - volumes: - - ./data:/app/backend/data +services: + spotify-clone: + image: git.khoavo.myds.me/vndangkhoa/spotify-clone:v3 + container_name: spotify-clone + restart: unless-stopped + ports: + - "3000:8080" + environment: + - PORT=8080 + - RUST_ENV=production + volumes: + - ./data:/app/data + - ./cache:/tmp/spotify-clone-cache + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/frontend-vite/src/components/PlayerBar.tsx b/frontend-vite/src/components/PlayerBar.tsx index 280fac6..8a6c7d2 100644 --- a/frontend-vite/src/components/PlayerBar.tsx +++ b/frontend-vite/src/components/PlayerBar.tsx @@ -1,7 +1,7 @@ import { Play, Pause, SkipBack, SkipForward, Repeat, Shuffle, Volume2, Download, PlusCircle, Mic2, Heart, Loader2, ListMusic, MonitorSpeaker, Maximize2, MoreHorizontal, Info, ChevronUp } from 'lucide-react'; import { usePlayer } from "../context/PlayerContext"; import { useEffect, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import TechSpecs from './TechSpecs'; import AddToPlaylistModal from "./AddToPlaylistModal"; import Lyrics from './Lyrics'; @@ -13,7 +13,8 @@ export default function PlayerBar() { const { currentTrack, isPlaying, isBuffering, togglePlay, setBuffering, likedTracks, toggleLike, nextTrack, prevTrack, shuffle, toggleShuffle, - repeatMode, toggleRepeat, audioQuality, isLyricsOpen, toggleLyrics, closeLyrics, openLyrics + repeatMode, toggleRepeat, audioQuality, isLyricsOpen, toggleLyrics, closeLyrics, openLyrics, + isFullScreenOpen, setIsFullScreenOpen } = usePlayer(); const dominantColor = useDominantColor(currentTrack?.cover_url); @@ -54,39 +55,102 @@ export default function PlayerBar() { const [volume, setVolume] = useState(1); const navigate = useNavigate(); + const location = useLocation(); // Modal State const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false); const [isTechSpecsOpen, setIsTechSpecsOpen] = useState(false); - const [isFullScreenPlayerOpen, setIsFullScreenPlayerOpen] = useState(false); - const [isCoverModalOpen, setIsCoverModalOpen] = useState(false); + const [isQueueOpen, setIsQueueOpen] = useState(false); const [isInfoOpen, setIsInfoOpen] = useState(false); const [playerMode, setPlayerMode] = useState<'audio' | 'video'>('audio'); + const [isIdle, setIsIdle] = useState(false); + const [isVideoReady, setIsVideoReady] = useState(false); + const idleTimerRef = useRef(null); + + const resetIdleTimer = () => { + setIsIdle(false); + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + if (playerMode === 'video' && isPlaying) { + idleTimerRef.current = setTimeout(() => { + setIsIdle(true); + }, 3000); + } + }; // Force close lyrics on mount (Defensive fix for "Open on first play") useEffect(() => { closeLyrics(); }, []); + // Auto-close fullscreen player on navigation + useEffect(() => { + setPlayerMode('audio'); + setIsFullScreenOpen(false); + }, [location.pathname]); + // Reset to audio mode when track changes useEffect(() => { setPlayerMode('audio'); + setIsIdle(false); + setIsVideoReady(false); }, [currentTrack?.id]); + // Handle idle timer when playing video + useEffect(() => { + resetIdleTimer(); + return () => { + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + }; + }, [isPlaying, playerMode]); + // Handle audio/video mode switching const handleModeSwitch = (mode: 'audio' | 'video') => { if (mode === 'video') { + // Pause audio ref but DON'T toggle isPlaying state to false + // The iframe useEffect will pick up isPlaying=true and start the video audioRef.current?.pause(); - if (isPlaying) togglePlay(); // Update state to paused + setIsVideoReady(false); + + // If currently playing, start video playback after iframe loads + if (isPlaying && iframeRef.current && iframeRef.current.contentWindow) { + setTimeout(() => { + if (iframeRef.current?.contentWindow) { + iframeRef.current.contentWindow.postMessage(JSON.stringify({ + event: 'command', + func: 'playVideo' + }), '*'); + } + }, 1000); + } } else { // Switching back to audio - // Optionally sync time if we could get it from video, but for now just resume - if (!isPlaying) togglePlay(); + if (isPlaying) { + audioRef.current?.play().catch(() => { }); + } } setPlayerMode(mode); }; + // Handle play/pause for video mode - send command to YouTube iframe only + const handleVideoPlayPause = () => { + if (playerMode !== 'video' || !iframeRef.current || !iframeRef.current.contentWindow) return; + + // Send play/pause command directly to YouTube + const action = isPlaying ? 'pauseVideo' : 'playVideo'; + try { + iframeRef.current.contentWindow.postMessage(JSON.stringify({ + event: 'command', + func: action + }), '*'); + } catch (e) { + // Ignore cross-origin errors + } + + // Toggle local state for UI sync only (audio won't play since it's paused) + togglePlay(); + }; + // ... (rest of useEffects) // ... inside return ... @@ -112,8 +176,10 @@ export default function PlayerBar() { } }, [currentTrack?.url]); - // Play/Pause effect + // Play/Pause effect - skip when in video mode (YouTube controls playback) useEffect(() => { + if (playerMode === 'video') return; // Skip audio control in video mode + if (audioRef.current) { if (isPlaying) { audioRef.current.play().catch(e => { @@ -125,7 +191,7 @@ export default function PlayerBar() { if ('mediaSession' in navigator) navigator.mediaSession.playbackState = "paused"; } } - }, [isPlaying]); + }, [isPlaying, playerMode]); // Volume Effect useEffect(() => { @@ -134,16 +200,8 @@ export default function PlayerBar() { } }, [volume]); - // Sync Play/Pause with YouTube Iframe - useEffect(() => { - if (playerMode === 'video' && iframeRef.current && iframeRef.current.contentWindow) { - const action = isPlaying ? 'playVideo' : 'pauseVideo'; - iframeRef.current.contentWindow.postMessage(JSON.stringify({ - event: 'command', - func: action - }), '*'); - } - }, [isPlaying, playerMode]); + // Note: YouTube iframe play/pause sync is handled via URL autoplay parameter + // Cross-origin restrictions prevent reliable postMessage control const handleTimeUpdate = () => { if (audioRef.current) { @@ -211,7 +269,7 @@ export default function PlayerBar() { className="fixed bottom-[calc(4rem+env(safe-area-inset-bottom))] left-2 right-2 fold:left-0 fold:right-0 fold:bottom-0 h-16 fold:h-[90px] bg-spotify-player border-t-0 fold:border-t border-white/5 flex items-center justify-between z-[60] rounded-lg fold:rounded-none shadow-xl fold:shadow-none transition-all duration-300 backdrop-blur-xl" onClick={() => { if (window.innerWidth < 1024) { - setIsFullScreenPlayerOpen(true); + setIsFullScreenOpen(true); } }} > @@ -254,8 +312,7 @@ export default function PlayerBar() { className="h-14 w-14 fold:h-14 fold:w-14 rounded-xl object-cover ml-1 fold:ml-0 cursor-pointer" onClick={(e) => { e.stopPropagation(); - if (window.innerWidth >= 700) setIsCoverModalOpen(true); - else setIsFullScreenPlayerOpen(true); + setIsFullScreenOpen(true); }} /> @@ -413,143 +470,144 @@ export default function PlayerBar() { {/* Mobile Full Screen Player Overlay */}
{/* Header / Close */} -
-
setIsFullScreenPlayerOpen(false)} className="text-white"> +
+
{ setPlayerMode('audio'); setIsFullScreenOpen(false); }} className="text-white p-2 hover:bg-white/10 rounded-full transition cursor-pointer">
{/* Song / Video Toggle */} -
+
-
+
- {/* Responsive Split View Container */} -
- {/* Left/Top: Art or Video */} -
- {playerMode === 'audio' ? ( - {currentTrack.title} - ) : ( -
+ {/* Content Area */} +
+ {playerMode === 'video' ? ( + /* CINEMATIC VIDEO MODE: Full Background Video */ +
+
{/* Slight scale to hide any possible edges */} + {!isVideoReady && ( +
+
+
+ )}
- )} -
+ {/* Overlay Gradient for cinematic feel */} +
+
+ ) : ( + /* SONG MODE: Centered Case */ +
+ {currentTrack.title} +
+ )} - {/* Right/Bottom: Controls */} -
- {/* Track Info */} -
-
-

{currentTrack.title}

+ {/* Controls Overlay (Bottom) */} +
+
+ {/* Metadata */} +
+

{currentTrack.title}

{ setIsFullScreenPlayerOpen(false); navigate(`/artist/${encodeURIComponent(currentTrack.artist)}`); }} - className="text-lg md:text-xl text-neutral-400 line-clamp-1 cursor-pointer hover:text-white hover:underline transition" + onClick={() => { setPlayerMode('audio'); setIsFullScreenOpen(false); navigate(`/artist/${encodeURIComponent(currentTrack.artist)}`); }} + className={`text-white/70 font-medium cursor-pointer hover:text-white hover:underline transition drop-shadow-md ${playerMode === 'video' ? 'text-base md:text-xl' : 'text-lg md:text-2xl'}`} > {currentTrack.artist}

-
- -
- {/* Progress */} -
- -
- {formatTime(progress)} - {formatTime(duration)} -
-
- - {/* Controls */} -
- - - - - -
- - {/* Lyric Peek (Tablet optimized) */} -
{ - e.stopPropagation(); - setHasInteractedWithLyrics(true); - openLyrics(); - }} - > - {currentLine ? ( -

- "{currentLine.text}" -

- ) : ( -
- - Tap for Lyrics + {/* Scrubber & Controls */} +
+ {/* Scrubber */} +
+ +
+ {formatTime(progress)} + {formatTime(duration)}
- )} +
+ + {/* Main Playback Controls */} +
+ + + + + +
@@ -574,7 +632,7 @@ export default function PlayerBar() {

Artist

{ setIsInfoOpen(false); setIsFullScreenPlayerOpen(false); navigate(`/artist/${encodeURIComponent(currentTrack.artist)}`); }} + onClick={() => { setPlayerMode('audio'); setIsInfoOpen(false); setIsFullScreenOpen(false); navigate(`/artist/${encodeURIComponent(currentTrack.artist)}`); }} > {currentTrack.artist}

@@ -590,9 +648,9 @@ export default function PlayerBar() {
- )} + )} - {/* Modals */} + {/* Modals */} setIsQueueOpen(false)} diff --git a/frontend-vite/src/components/SettingsModal.tsx b/frontend-vite/src/components/SettingsModal.tsx index 441111b..8d863d2 100644 --- a/frontend-vite/src/components/SettingsModal.tsx +++ b/frontend-vite/src/components/SettingsModal.tsx @@ -1,7 +1,8 @@ import { useState } from 'react'; -import { X, RefreshCcw, Check, CheckCircle2, Circle, Smartphone, Monitor } from 'lucide-react'; +import { X, RefreshCcw, Check, CheckCircle2, Trash2, Database, Volume2, Activity, PlayCircle } from 'lucide-react'; import { useTheme } from '../context/ThemeContext'; +import { usePlayer } from '../context/PlayerContext'; interface SettingsModalProps { isOpen: boolean; @@ -10,9 +11,11 @@ interface SettingsModalProps { export default function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const { theme, toggleTheme } = useTheme(); + const { qualityPreference, setQualityPreference } = usePlayer(); const [isUpdating, setIsUpdating] = useState(false); const [updateStatus, setUpdateStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); const [updateLog, setUpdateLog] = useState(''); + const [isClearingCache, setIsClearingCache] = useState(false); if (!isOpen) return null; @@ -41,100 +44,180 @@ export default function SettingsModal({ isOpen, onClose }: SettingsModalProps) { } }; + const handleClearCache = () => { + setIsClearingCache(true); + // Wipe common caches +localStorage.removeItem('ytm_browse_cache_v6'); + localStorage.removeItem('ytm_browse_cache_v7'); + localStorage.removeItem('artist_photos_cache_v5'); + localStorage.removeItem('artist_photos_cache_v6'); + + // Short delay for feedback + setTimeout(() => { + setIsClearingCache(false); + alert("Cache cleared successfully! The app will reload fresh data."); + }, 800); + }; + + const isApple = theme === 'apple'; + return ( -
+
-
+ {/* Modal Container */} +
e.stopPropagation()} + > {/* Header */} -
-

Settings

-
- {/* Content */} -
+ {/* Scrollable Content */} +
{/* Appearance Section */}
-

Appearance

+
+ Design System +
-
- {/* Spotify Theme Option */} +
+ {/* Spotify Theme */} - {/* Apple Music Theme Option */} + {/* Apple Music Theme */}
+ {/* Audio Section */} +
+
+ + Audio Experience +
+ +
+ +
+ {(['auto', 'high', 'normal', 'low'] as const).map((q) => ( + + ))} +
+

+ High quality requires a stable internet connection for seamless playback. +

+
+
+ {/* System Section */}
-

System

+
+ + System & Storage +
-
-
-
-
+
+ {/* Core Update */} +
+
+
Core Update - yt-dlp nightly + yt-dlp nightly
-

Updates the underlying download engine.

+

Keep the extraction engine fresh for new content.

- {/* Logs */} - {(updateStatus !== 'idle' || updateLog) && ( -
- {updateStatus === 'loading' && Executing update command...{'\n'}} - {updateLog} - {updateStatus === 'success' && {'\n'}Done!} - {updateStatus === 'error' && {'\n'}Error Occurred.} + {/* Clear Cache */} +
+
+
Clear Local Cache
+

Wipe browse and image caches if data feels stale.

- )} + +
+ + {/* Logs Reveal */} + {(updateStatus !== 'idle' || updateLog) && ( +
+
Operation Log
+ {updateLog || 'Initializing...'} + {updateStatus === 'loading' && _} +
+ )}
-
- KV Spotify Clone v1.0.0 +
+
+ KV Spotify Clone v1.0.0
diff --git a/frontend-vite/src/context/PlayerContext.tsx b/frontend-vite/src/context/PlayerContext.tsx index 7139786..8280bb9 100644 --- a/frontend-vite/src/context/PlayerContext.tsx +++ b/frontend-vite/src/context/PlayerContext.tsx @@ -20,10 +20,14 @@ interface PlayerContextType { toggleLike: (track: Track) => void; playHistory: Track[]; audioQuality: AudioQuality | null; + qualityPreference: 'auto' | 'high' | 'normal' | 'low'; + setQualityPreference: (quality: 'auto' | 'high' | 'normal' | 'low') => void; isLyricsOpen: boolean; toggleLyrics: () => void; closeLyrics: () => void; openLyrics: () => void; + isFullScreenOpen: boolean; + setIsFullScreenOpen: (open: boolean) => void; queue: Track[]; } @@ -38,6 +42,13 @@ export function PlayerProvider({ children }: { children: ReactNode }) { // Audio Engine State const [audioQuality, setAudioQuality] = useState(null); + const [qualityPreference, setQualityPreference] = useState<'auto' | 'high' | 'normal' | 'low'>(() => { + return (localStorage.getItem('audio_quality_pref') as any) || 'auto'; + }); + + useEffect(() => { + localStorage.setItem('audio_quality_pref', qualityPreference); + }, [qualityPreference]); // Queue State const [queue, setQueue] = useState([]); @@ -54,6 +65,9 @@ export function PlayerProvider({ children }: { children: ReactNode }) { const closeLyrics = () => setIsLyricsOpen(false); const openLyrics = () => setIsLyricsOpen(true); + // Full Screen Player State + const [isFullScreenOpen, setIsFullScreenOpen] = useState(false); + // Load Likes from DB useEffect(() => { dbService.getLikedSongs().then(tracks => { @@ -200,10 +214,14 @@ export function PlayerProvider({ children }: { children: ReactNode }) { toggleLike, playHistory, audioQuality, + qualityPreference, + setQualityPreference, isLyricsOpen, toggleLyrics, closeLyrics, openLyrics, + isFullScreenOpen, + setIsFullScreenOpen, queue }}> {children} diff --git a/frontend-vite/src/pages/Album.tsx b/frontend-vite/src/pages/Album.tsx index 043848e..006ad88 100644 --- a/frontend-vite/src/pages/Album.tsx +++ b/frontend-vite/src/pages/Album.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useParams, Link } from 'react-router-dom'; import { libraryService } from '../services/library'; import { usePlayer } from '../context/PlayerContext'; import { Play, Shuffle, Heart, Clock, ListPlus, Download } from 'lucide-react'; @@ -7,31 +7,47 @@ import { Track } from '../types'; export default function Album() { const { id } = useParams(); - const { playTrack, toggleLike, likedTracks } = usePlayer(); + const { playTrack, toggleLike, likedTracks, setIsFullScreenOpen, currentTrack } = usePlayer(); const [tracks, setTracks] = useState([]); const [albumInfo, setAlbumInfo] = useState<{ title: string, artist: string, cover?: string, year?: string } | null>(null); + const [moreByArtist, setMoreByArtist] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { if (!id) return; setLoading(true); - // If ID is from YTM, ideally we fetch album. - // If logic is "Search Album", we do that. const fetchAlbum = async () => { - // For now, assume ID is search query or we query "Album" - // In this reskin, we usually pass Name as ID due to router setup in Home. - - const query = decodeURIComponent(id); + const queryId = decodeURIComponent(id); try { - const results = await libraryService.search(query); - if (results.length > 0) { + const album = await libraryService.getAlbum(queryId); + + if (album) { + setTracks(album.tracks); + setAlbumInfo({ + title: album.title, + artist: album.creator || "Unknown Artist", + cover: album.cover_url, + year: '2024' + }); + + // Fetch suggestions + try { + const artistQuery = album.creator || "Unknown Artist"; + const suggestions = await libraryService.search(artistQuery); + const currentIds = new Set(album.tracks.map(t => t.id)); + setMoreByArtist(suggestions.filter(t => !currentIds.has(t.id)).slice(0, 10)); + } catch (e) { } + } else { + // Fallback to searching the query if ID not found anywhere + const cleanTitle = queryId.replace(/^search-|^album-/, ''); + const results = await libraryService.search(queryId); setTracks(results); setAlbumInfo({ - title: query.replace(/^search-|^album-/, '').replace(/-/g, ' '), // Clean up slug - artist: results[0].artist, - cover: results[0].cover_url, - year: '2024' // Mock or fetch + title: cleanTitle, + artist: results.length > 0 ? results[0].artist : "Unknown Artist", + cover: results.length > 0 ? results[0].cover_url : undefined, + year: '2024' }); } } catch (e) { @@ -49,17 +65,42 @@ export default function Album() { const formattedDuration = `${Math.floor(totalDuration / 60)} minutes`; return ( -
-
+
+ {/* Banner Background */} + {albumInfo.cover && ( +
+ )} + +
{/* Cover */} -
- {albumInfo.title} +
{ + if (tracks.length > 0) { + playTrack(tracks[0], tracks); + setIsFullScreenOpen(true); + } + }} + > + {albumInfo.title} +
+ +
{/* Info */}
Album -

{albumInfo.title}

+

{albumInfo.title}

{albumInfo.artist} @@ -131,6 +172,40 @@ export default function Album() { ))}
+ + {/* Suggestions / More By Artist */} + {moreByArtist.length > 0 && ( +
+
+

More by {albumInfo.artist}

+ + Show discography + +
+
+ {moreByArtist.map((track) => ( +
{ + playTrack(track, moreByArtist); + }} + > +
+ +
+
+ +
+
+
+

{track.title}

+

{track.artist}

+
+ ))} +
+
+ )}
); } diff --git a/frontend-vite/src/pages/Artist.tsx b/frontend-vite/src/pages/Artist.tsx index f87001d..bfabf35 100644 --- a/frontend-vite/src/pages/Artist.tsx +++ b/frontend-vite/src/pages/Artist.tsx @@ -17,7 +17,7 @@ interface ArtistData { export default function Artist() { const { id } = useParams(); // Start with name or id const navigate = useNavigate(); - const { playTrack, toggleLike, likedTracks } = usePlayer(); + const { playTrack, toggleLike, likedTracks, setIsFullScreenOpen } = usePlayer(); const [artist, setArtist] = useState(null); const [loading, setLoading] = useState(true); @@ -104,7 +104,11 @@ export default function Artist() {