diff --git a/.gitignore b/.gitignore index 14921e1..a921dee 100644 --- a/.gitignore +++ b/.gitignore @@ -58,5 +58,12 @@ backend/data_seed/ *.log build_log*.txt +# Rust +target/ + # Backup Files *_backup.* +nul +*.pid +frontend.pid +backend.pid diff --git a/Dockerfile b/Dockerfile index 461e14e..b2a6496 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,70 +1,52 @@ - -# --------------------------- -# 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-alpine AS backend-builder +WORKDIR /app/backend + +RUN apk add --no-cache musl-dev openssl-dev perl + +COPY backend-rust/Cargo.toml backend-rust/Cargo.lock ./ +RUN cargo fetch + +COPY backend-rust/ ./ +RUN cargo build --release --bin backend-rust + +# Stage 3: Final Runtime +FROM python:3.11-slim-bookworm + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + ffmpeg \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install yt-dlp +RUN pip install --no-cache-dir -U "yt-dlp[default]" + +# Copy backend binary +COPY --from=backend-builder /app/backend/target/release/backend-rust /app/server + +# Copy frontend build +COPY --from=frontend-builder /app/frontend/dist /app/static + +# Create cache directories +RUN mkdir -p /tmp/spotify-clone-cache /tmp/spotify-clone-downloads && chmod 777 /tmp/spotify-clone-cache /tmp/spotify-clone-downloads + +ENV PORT=8080 +ENV RUST_LOG=release + +EXPOSE 8080 + +CMD ["/app/server"] diff --git a/README.md b/README.md index cb27b0b..0addbb9 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,126 @@ # 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. - -![Preview](https://opengraph.githubassets.com/1/vndangkhoa/spotify-clone) +A fully functional Spotify-like web player built with **React (Vite)**, **Rust (Axum)**, and **TailwindCSS**. Features include real-time lyrics, custom playlists, and YouTube Music integration. --- ## 🚀 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 Forgejo Registry ```bash -docker run -p 3000:3000 -p 8000:8000 vndangkhoa/spotify-clone:latest +docker run -p 3110:8080 git.khoavo.myds.me/vndangkhoa/spotify-clone:v3 ``` -Open **[http://localhost:3000](http://localhost:3000)**. +Open **[http://localhost:3110](http://localhost:3110)**. ### 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 -p 3110:8080 spotify-clone:v3 ``` --- +## 🐳 Docker Deployment + +### Image Details +- **Registry**: `git.khoavo.myds.me/vndangkhoa/spotify-clone` +- **Tag**: `v3` +- **Architecture**: `linux/amd64` +- **Ports**: + - `8080` (Backend API) + +### docker-compose.yml +```yaml +services: + spotify-clone: + image: git.khoavo.myds.me/vndangkhoa/spotify-clone:v3 + container_name: spotify-clone + restart: unless-stopped + ports: + - "3110:8080" + environment: + - PORT=8080 + volumes: + - ./data:/tmp/spotify-clone-downloads + - ./cache:/tmp/spotify-clone-cache + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" +``` + +--- + +## 🖥️ Synology NAS Deployment (Container Manager) + +### Method A: Using Container Manager UI + +1. Open **Container Manager** (or Docker on older DSM). +2. Go to **Registry** → **Add** → Enter: + - Registry: `git.khoavo.myds.me` +3. Search for `spotify-clone` and download the `v3` tag. +4. Go to **Image** → Select the image → **Run**. +5. Configure: + - **Container Name**: `spotify-clone` + - **Network**: Bridge + - **Port Settings**: Local Port `3110` → Container Port `8080` + - **Volume Settings**: + - Create folder `/docker/spotify-clone/data` → `/tmp/spotify-clone-downloads` + - Create folder `/docker/spotify-clone/cache` → `/tmp/spotify-clone-cache` +6. Click **Run**. + +### Method B: Using Docker Compose + +1. Create folder: `/volume1/docker/spotify-clone` +2. Save the `docker-compose.yml` above to that folder. +3. Open Container Manager → **Project** → **Create**. +4. Select the folder path. +5. The container will start automatically. +6. Access at `http://YOUR_NAS_IP:3110` + +--- + +## 🔄 Auto-Refresh + +The backend automatically fetches trending content from YouTube Music every 5 minutes. No additional setup required. + +--- + ## 🛠️ 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 +### Backend (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`. -### 2. Frontend Setup +### Frontend ```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! 🚀 --- ## ✨ Features -- **Real-Time Lyrics**: Fetch and sync lyrics from multiple sources (YouTube, LRCLIB). -- **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. -- **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. +- **YouTube Music Integration**: Search and play from YouTube Music +- **Trending Auto-Fetch**: 15+ categories updated every 5 minutes +- **Real-Time Lyrics**: Sync lyrics from multiple sources +- **Custom Playlists**: Create, save, and manage playlists (IndexedDB) +- **PWA Support**: Works offline with cached content +- **Responsive Design**: Mobile-first with dark theme + +--- ## 📝 License + MIT License diff --git a/backend-rust/Cargo.lock b/backend-rust/Cargo.lock new file mode 100644 index 0000000..c40d3a8 --- /dev/null +++ b/backend-rust/Cargo.lock @@ -0,0 +1,715 @@ +# 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 = "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", + "futures", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tower", + "tower-http", + "urlencoding", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[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 = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[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 = "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", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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 = "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", +] + +[[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" + +[[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", +] + +[[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-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", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "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 = "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 = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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 = "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..df56744 --- /dev/null +++ b/backend-rust/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "backend-rust" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "backend-rust" +path = "src/main.rs" + +[dependencies] +axum = "0.8.8" +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"] } +urlencoding = "2.1.3" +futures = "0.3" diff --git a/backend-rust/src/api.rs b/backend-rust/src/api.rs new file mode 100644 index 0000000..f3815a8 --- /dev/null +++ b/backend-rust/src/api.rs @@ -0,0 +1,174 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::spotdl::SpotdlService; + +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"}))); + } + + // Check cache first + { + let cache = state.spotdl.search_cache.read().await; + if let Some(cached) = cache.get(query) { + if let Some(track) = cached.tracks.first() { + if !track.cover_url.is_empty() { + return (StatusCode::OK, Json(serde_json::json!({"image": track.cover_url}))); + } + } + } + } + + // Return placeholder image immediately - no yt-dlp needed + // Using UI-Avatars for professional-looking artist initials + let image_url = format!( + "https://ui-avatars.com/api/?name={}&background=random&color=fff&size=200&rounded=true&bold=true&font-size=0.33", + urlencoding::encode(&query) + ); + + (StatusCode::OK, Json(serde_json::json!({"image": image_url}))) +} + +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())) +} + +#[derive(Deserialize)] +pub struct RecommendationsQuery { + pub seed: String, + #[serde(default)] + pub seed_type: String, // "track", "album", "playlist", "artist" + #[serde(default = "default_limit")] + pub limit: usize, +} + +fn default_limit() -> usize { + 10 +} + +#[derive(Serialize)] +pub struct Recommendations { + pub tracks: Vec, + pub albums: Vec, + pub playlists: Vec, + pub artists: Vec, +} + +#[derive(Serialize)] +pub struct AlbumSuggestion { + pub id: String, + pub title: String, + pub artist: String, + pub cover_url: String, +} + +#[derive(Serialize)] +pub struct PlaylistSuggestion { + pub id: String, + pub title: String, + pub cover_url: String, + pub track_count: usize, +} + +#[derive(Serialize)] +pub struct ArtistSuggestion { + pub id: String, + pub name: String, + pub photo_url: String, +} + +pub async fn recommendations_handler( + State(state): State>, + Query(params): Query, +) -> impl IntoResponse { + let seed = params.seed.trim(); + if seed.is_empty() { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Seed required"}))); + } + + let seed_type = if params.seed_type.is_empty() { + // Try to infer type from seed + if seed.contains("album") || seed.contains("Album") { + "album" + } else if seed.contains("playlist") || seed.contains("Playlist") { + "playlist" + } else { + "track" + } + } else { + ¶ms.seed_type + }; + + let limit = params.limit.min(50); // Cap at 50 + + match state.spotdl.get_recommendations(seed, seed_type, limit).await { + Ok(recommendations) => { + match serde_json::to_value(recommendations) { + Ok(value) => (StatusCode::OK, Json(value)), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Serialization failed"}))), + } + }, + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e}))), + } +} diff --git a/backend-rust/src/main.rs b/backend-rust/src/main.rs new file mode 100644 index 0000000..8c7c680 --- /dev/null +++ b/backend-rust/src/main.rs @@ -0,0 +1,42 @@ +pub mod api; +pub 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)) + .route("/api/recommendations", get(api::recommendations_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..7c177a7 --- /dev/null +++ b/backend-rust/src/spotdl.rs @@ -0,0 +1,614 @@ +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 futures::future::join_all; + +use crate::models::{Track, YTResult, StaticPlaylist}; + +pub struct CacheItem { + pub tracks: Vec, + pub timestamp: Instant, +} + +#[derive(Clone)] +pub struct SpotdlService { + download_dir: PathBuf, + pub 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); + + // Ensure node is in PATH for yt-dlp + let _ = Self::js_runtime_args(); + + Self { + download_dir, + search_cache: Arc::new(RwLock::new(HashMap::new())), + browse_cache: Arc::new(RwLock::new(HashMap::new())), + } + } + + fn get_placeholder_image(&self, seed: &str) -> String { + let initials = seed.chars().take(2).collect::().to_uppercase(); + let colors = vec!["1DB954", "FF6B6B", "4ECDC4", "45B7D1", "6C5CE7", "FDCB6E"]; + + let mut hash = 0u32; + for c in seed.chars() { + hash = c as u32 + hash.wrapping_shl(5).wrapping_sub(hash); + } + let color = colors[(hash as usize) % colors.len()]; + + format!("https://placehold.co/400x400/{}/FFFFFF?text={}", color, initials) + } + + fn yt_dlp_path() -> String { + // Use the updated binary we downloaded + let updated_path = "/tmp/yt-dlp"; + if Path::new(updated_path).exists() { + return updated_path.to_string(); + } + + // Windows: Check user Scripts folder + if cfg!(windows) { + if let Ok(home) = env::var("APPDATA") { + let win_path = Path::new(&home).join("Python").join("Python312").join("Scripts").join("yt-dlp.exe"); + if win_path.exists() { + return win_path.to_string_lossy().into_owned(); + } + } + } + + "yt-dlp".to_string() + } + + fn js_runtime_args() -> Vec { + Vec::new() + } + + pub fn start_background_preload(&self) { + let cache_arc = self.browse_cache.clone(); + let refresh_cache = self.browse_cache.clone(); + + tokio::spawn(async move { + println!("Background preloader started... fetching Top Albums & Playlists"); + Self::fetch_browse_content(&cache_arc).await; + }); + + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(300)).await; + println!("Periodic refresh: updating browse content..."); + Self::fetch_browse_content(&refresh_cache).await; + } + }); + } + + async fn fetch_browse_content(cache_arc: &Arc>>>) { + let queries = vec![ + ("Top Albums", "ytsearch50:Top Albums Vietnam audio"), + ("Viral Hits Vietnam", "ytsearch30:Viral Hits Vietnam audio"), + ("Lofi Chill Vietnam", "ytsearch30:Lofi Chill Vietnam audio"), + ("US UK Top Hits", "ytsearch30:US UK Billboard Hot 100 audio"), + ("K-Pop ON!", "ytsearch30:K-Pop Top Hits audio"), + ("Rap Viet", "ytsearch30:Rap Viet Mix audio"), + ("Indie Vietnam", "ytsearch30:Indie Vietnam audio"), + ("V-Pop Rising", "ytsearch30:V-Pop Rising audio"), + ("Trending Music", "ytsearch30:Trending Music 2024 audio"), + ("Acoustic Thu Gian", "ytsearch30:Acoustic Thu Gian audio"), + ("Workout Energy", "ytsearch30:Workout Energy Mix audio"), + ("Sleep Sounds", "ytsearch30:Sleep Sounds music audio"), + ("Party Anthems", "ytsearch30:Party Anthems Mix audio"), + ("Piano Focus", "ytsearch30:Piano Focus music audio"), + ("Gaming Music", "ytsearch30:Gaming Music Mix 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(&["--js-runtimes", "node", &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 = "ytmusicsearch30:V-Pop Official Channel"; + if let Ok(o) = Command::new(&path) + .args(&["--js-runtimes", "node", &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(&["--js-runtimes", "node", &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(&["--js-runtimes", "node", "-f", "bestaudio/best", "--output", &format!("{}.%(ext)s", video_id), &target_url]) + .output() { + Ok(o) => o, + Err(e) => { + println!("[Stream] yt-dlp spawn error: {}", e); + return Err(format!("Download spawn failed: {}", e)); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + println!("[Stream] yt-dlp download failed: {}", stderr); + return Err(format!("Download failed. stderr: {}", 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 async fn search_artist(&self, query: &str) -> Result { + // Check cache first for quick response + { + let cache = self.search_cache.read().await; + if let Some(cached) = cache.get(query) { + if let Some(track) = cached.tracks.first() { + if !track.cover_url.is_empty() { + return Ok(track.cover_url.clone()); + } + } + } + } + + // Try to fetch actual artist photo from YouTube + let path = Self::yt_dlp_path(); + let search_query = format!("ytsearch5:{} artist", query); + + let output = Command::new(&path) + .args(&[&search_query, "--dump-json", "--flat-playlist"]) + .output(); + + if let Ok(o) = output { + let stdout = String::from_utf8_lossy(&o.stdout); + for line in stdout.lines() { + if line.trim().is_empty() { + continue; + } + if let Ok(res) = serde_json::from_str::(line) { + // Get the video thumbnail which often has the artist + if let Some(thumb) = res.thumbnails.last() { + if !thumb.url.is_empty() { + // Convert to higher quality thumbnail + let high_quality = thumb.url.replace("hqdefault", "maxresdefault"); + return Ok(high_quality); + } + } + } + } + } + + // Fallback to placeholder if no real photo found + Ok(self.get_placeholder_image(query)) + } + + fn extract_id(url: &str) -> String { + // If URL contains v= parameter, extract from there first + if url.contains("v=") { + let parts: Vec<&str> = url.split("v=").collect(); + if parts.len() > 1 { + let video_part = parts[1].split('&').next().unwrap_or(""); + + // Check if the extracted part is a discovery ID + if video_part.starts_with("discovery-") || video_part.starts_with("artist-") { + // Extract actual video ID from the discovery ID + let sub_parts: Vec<&str> = video_part.split('-').collect(); + + // Look for the last part that looks like a YouTube video ID (11 chars) + for part in sub_parts.iter().rev() { + if part.len() == 11 && part.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') { + return part.to_string(); + } + } + + // If no 11-char ID found, return the last part + if let Some(last_part) = sub_parts.last() { + return last_part.to_string(); + } + } + + return video_part.to_string(); + } + } + + // Handle discovery-album-* format IDs (frontend sends full ID, video ID is at end) + if url.starts_with("discovery-") || url.starts_with("artist-") { + // Video ID is the last segment that matches YouTube video ID format + // It could be 11 chars (e.g., "abc123ABC45") or could be split + let parts: Vec<&str> = url.split('-').collect(); + + // First, try to find a single 11-char YouTube ID + for part in parts.iter().rev() { + if part.len() == 11 && part.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') { + return part.to_string(); + } + } + + // If not found, try combining last two parts (in case ID was split) + if parts.len() >= 2 { + let last = parts.last().unwrap(); + let second_last = parts.get(parts.len() - 2).unwrap(); + let combined = format!("{}-{}", second_last, last); + if combined.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') { + return combined; + } + } + + // Fallback: just use the last part + if let Some(last_part) = parts.last() { + return last_part.to_string(); + } + } + + url.to_string() + } + + pub async fn get_recommendations( + &self, + seed: &str, + seed_type: &str, + limit: usize, + ) -> Result { + // Generate recommendations based on seed type + let mut tracks = Vec::new(); + let mut albums = Vec::new(); + let mut playlists = Vec::new(); + let mut artists = Vec::new(); + + // Extract artist name from seed for related searches + let artist_name = if seed_type == "track" { + // Try to extract artist from track title (format: "Artist - Title") + if seed.contains(" - ") { + seed.split(" - ").next().unwrap_or(seed).to_string() + } else { + seed.to_string() + } + } else { + seed.to_string() + }; + + // Search for related tracks + let search_query = if seed_type == "track" { + format!("{} similar", artist_name) + } else if seed_type == "album" { + format!("{} album similar", artist_name) + } else if seed_type == "playlist" { + format!("{} playlist mix", artist_name) + } else { + format!("{} music similar", artist_name) + }; + + // Get tracks from search - use more specific queries for similar artists + let search_queries = if seed_type == "artist" { + vec![ + format!("similar artists to {}", artist_name), + format!("like {}", artist_name), + format!("fans of {}", artist_name), + ] + } else { + vec![search_query] + }; + + // PARALLEL SEARCH - Run all queries concurrently + let search_results = join_all( + search_queries.iter().map(|q| self.search_tracks(q)) + ).await; + + for result in search_results { + if tracks.len() >= limit { + break; + } + if let Ok(search_tracks) = result { + for track in search_tracks { + if tracks.len() >= limit { + break; + } + // For artist type, skip tracks by the same artist + if seed_type == "artist" && + track.artist.to_lowercase() == artist_name.to_lowercase() { + continue; + } + // Skip exact duplicates + if !tracks.iter().any(|t: &crate::models::Track| t.id == track.id) { + tracks.push(track); + } + } + } + } + + // If still no tracks, try a broader search + if tracks.is_empty() { + if let Ok(search_tracks) = self.search_tracks(&artist_name).await { + for track in search_tracks.iter().take(5) { + if !track.artist.to_lowercase().contains(&artist_name.to_lowercase()) { + tracks.push(track.clone()); + } + } + } + } + + // Generate album suggestions from track data + let mut seen_albums = std::collections::HashSet::new(); + for track in &tracks { + if albums.len() >= 10 { + break; + } + let album_key = format!("{}:{}", track.artist, track.album); + if !seen_albums.contains(&album_key) && !track.album.is_empty() { + seen_albums.insert(album_key); + albums.push(crate::api::AlbumSuggestion { + id: format!("discovery-album-{}-{}", + track.album.replace(|c: char| !c.is_alphanumeric() && c != ' ', "-"), + track.id), + title: track.album.clone(), + artist: track.artist.clone(), + cover_url: track.cover_url.clone(), + }); + } + } + + // Generate playlist suggestions - PARALLEL + let playlist_queries = vec![ + format!("{} Mix", artist_name), + format!("{} Radio", artist_name), + format!("{} Top Hits", artist_name), + ]; + + let playlist_results = join_all( + playlist_queries.iter().map(|q| self.search_tracks(q)) + ).await; + + for (query, result) in playlist_queries.iter().zip(playlist_results) { + if playlists.len() >= 10 { + break; + } + if let Ok(results) = result { + if let Some(track) = results.first() { + playlists.push(crate::api::PlaylistSuggestion { + id: format!("discovery-playlist-{}-{}", + query.replace(|c: char| !c.is_alphanumeric() && c != ' ', "-"), + track.id), + title: query.clone(), + cover_url: track.cover_url.clone(), + track_count: results.len().min(20), + }); + } + } + } + + // Generate artist suggestions from track data + // Use placeholder images directly - YouTube thumbnails are video covers, not artist photos + let mut seen_artists = std::collections::HashSet::new(); + for track in &tracks { + if artists.len() >= 10 { + break; + } + if !seen_artists.contains(&track.artist) && !track.artist.is_empty() { + seen_artists.insert(track.artist.clone()); + // Use placeholder image - instant and always works + let photo_url = self.get_placeholder_image(&track.artist); + artists.push(crate::api::ArtistSuggestion { + id: format!("artist-{}", track.artist.replace(|c: char| !c.is_alphanumeric() && c != ' ', "-")), + name: track.artist.clone(), + photo_url, + }); + } + } + + Ok(crate::api::Recommendations { + tracks, + albums, + playlists, + artists, + }) + } +} diff --git a/backend-rust/test-artist.json b/backend-rust/test-artist.json new file mode 100644 index 0000000..361dce1 --- /dev/null +++ b/backend-rust/test-artist.json @@ -0,0 +1,5 @@ +{"_type": "url", "ie_key": "Youtube", "id": "Hqmbo0ROBQw", "url": "https://www.youtube.com/watch?v=Hqmbo0ROBQw", "title": "\u0110en - V\u1ecb nh\u00e0 (M/V)", "description": null, "duration": 315.0, "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "uploader": "\u0110en V\u00e2u Official", "uploader_id": null, "uploader_url": null, "thumbnails": [{"url": "https://i.ytimg.com/vi/Hqmbo0ROBQw/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLD7cv1L-whoLTjjprPvA6HecCOhOQ", "height": 202, "width": 360}, {"url": "https://i.ytimg.com/vi/Hqmbo0ROBQw/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA9lSyi9M9k24HQhgobFPMEgQEKVA", "height": 404, "width": 720}], "timestamp": null, "release_timestamp": null, "availability": null, "view_count": 22853006, "live_status": null, "channel_is_verified": true, "__x_forwarded_for_ip": null, "webpage_url": "https://www.youtube.com/watch?v=Hqmbo0ROBQw", "original_url": "https://www.youtube.com/watch?v=Hqmbo0ROBQw", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube", "extractor_key": "Youtube", "playlist_count": 5, "playlist": "\u0110en V\u00e2u channel", "playlist_id": "\u0110en V\u00e2u channel", "playlist_title": "\u0110en V\u00e2u channel", "playlist_uploader": null, "playlist_uploader_id": null, "playlist_channel": null, "playlist_channel_id": null, "playlist_webpage_url": "ytsearch5:\u0110en V\u00e2u channel", "n_entries": 5, "playlist_index": 1, "__last_playlist_index": 5, "playlist_autonumber": 1, "epoch": 1773501405, "duration_string": "5:15", "release_year": null, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}} +{"_type": "url", "ie_key": "Youtube", "id": "vTJdVE_gjI0", "url": "https://www.youtube.com/watch?v=vTJdVE_gjI0", "title": "\u0110en x JustaTee - \u0110i V\u1ec1 Nh\u00e0 (M/V)", "description": null, "duration": 206.0, "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "uploader": "\u0110en V\u00e2u Official", "uploader_id": null, "uploader_url": null, "thumbnails": [{"url": "https://i.ytimg.com/vi/vTJdVE_gjI0/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAIqVJmdwcWnFH1AESeFj1vXy6FIg", "height": 202, "width": 360}, {"url": "https://i.ytimg.com/vi/vTJdVE_gjI0/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBuK37jXALeyRM5CgZ_iBbpCu-0Ww", "height": 404, "width": 720}], "timestamp": null, "release_timestamp": null, "availability": null, "view_count": 205403243, "live_status": null, "channel_is_verified": true, "__x_forwarded_for_ip": null, "webpage_url": "https://www.youtube.com/watch?v=vTJdVE_gjI0", "original_url": "https://www.youtube.com/watch?v=vTJdVE_gjI0", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube", "extractor_key": "Youtube", "playlist_count": 5, "playlist": "\u0110en V\u00e2u channel", "playlist_id": "\u0110en V\u00e2u channel", "playlist_title": "\u0110en V\u00e2u channel", "playlist_uploader": null, "playlist_uploader_id": null, "playlist_channel": null, "playlist_channel_id": null, "playlist_webpage_url": "ytsearch5:\u0110en V\u00e2u channel", "n_entries": 5, "playlist_index": 2, "__last_playlist_index": 5, "playlist_autonumber": 2, "epoch": 1773501405, "duration_string": "3:26", "release_year": null, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}} +{"_type": "url", "ie_key": "Youtube", "id": "2bUScL5ojlY", "url": "https://www.youtube.com/watch?v=2bUScL5ojlY", "title": "\u0110en x Chi Pu x Lynk Lee - N\u1ebfu M\u00ecnh G\u1ea7n Nhau (Audio)", "description": null, "duration": 191.0, "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "uploader": "\u0110en V\u00e2u Official", "uploader_id": null, "uploader_url": null, "thumbnails": [{"url": "https://i.ytimg.com/vi/2bUScL5ojlY/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAYOjHpiYUG6Prge3e62O2mcPCN5A", "height": 202, "width": 360}, {"url": "https://i.ytimg.com/vi/2bUScL5ojlY/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBymxV0EPQ4gyNzwsoCy4quAWGa1Q", "height": 404, "width": 720}], "timestamp": null, "release_timestamp": null, "availability": null, "view_count": 2867844, "live_status": null, "channel_is_verified": true, "__x_forwarded_for_ip": null, "webpage_url": "https://www.youtube.com/watch?v=2bUScL5ojlY", "original_url": "https://www.youtube.com/watch?v=2bUScL5ojlY", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube", "extractor_key": "Youtube", "playlist_count": 5, "playlist": "\u0110en V\u00e2u channel", "playlist_id": "\u0110en V\u00e2u channel", "playlist_title": "\u0110en V\u00e2u channel", "playlist_uploader": null, "playlist_uploader_id": null, "playlist_channel": null, "playlist_channel_id": null, "playlist_webpage_url": "ytsearch5:\u0110en V\u00e2u channel", "n_entries": 5, "playlist_index": 3, "__last_playlist_index": 5, "playlist_autonumber": 3, "epoch": 1773501405, "duration_string": "3:11", "release_year": null, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}} +{"_type": "url", "ie_key": "Youtube", "id": "ArexdEMWRlA", "url": "https://www.youtube.com/watch?v=ArexdEMWRlA", "title": "\u0110en - Tr\u1eddi \u01a1i con ch\u01b0a mu\u1ed1n ch\u1ebft (Prod. by Tantu Beats)", "description": null, "duration": 162.0, "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "uploader": "\u0110en V\u00e2u Official", "uploader_id": null, "uploader_url": null, "thumbnails": [{"url": "https://i.ytimg.com/vi/ArexdEMWRlA/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDhCNm1yQB8WUaqIo9Kw7wweMhTzw", "height": 202, "width": 360}, {"url": "https://i.ytimg.com/vi/ArexdEMWRlA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCMgC-38NlV10mjB4rFqZ5jFwSsNw", "height": 404, "width": 720}], "timestamp": null, "release_timestamp": null, "availability": null, "view_count": 17788226, "live_status": null, "channel_is_verified": true, "__x_forwarded_for_ip": null, "webpage_url": "https://www.youtube.com/watch?v=ArexdEMWRlA", "original_url": "https://www.youtube.com/watch?v=ArexdEMWRlA", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube", "extractor_key": "Youtube", "playlist_count": 5, "playlist": "\u0110en V\u00e2u channel", "playlist_id": "\u0110en V\u00e2u channel", "playlist_title": "\u0110en V\u00e2u channel", "playlist_uploader": null, "playlist_uploader_id": null, "playlist_channel": null, "playlist_channel_id": null, "playlist_webpage_url": "ytsearch5:\u0110en V\u00e2u channel", "n_entries": 5, "playlist_index": 4, "__last_playlist_index": 5, "playlist_autonumber": 4, "epoch": 1773501405, "duration_string": "2:42", "release_year": null, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}} +{"_type": "url", "ie_key": "Youtube", "id": "KKc_RMln5UY", "url": "https://www.youtube.com/watch?v=KKc_RMln5UY", "title": "\u0110en - L\u1ed1i Nh\u1ecf ft. Ph\u01b0\u01a1ng Anh \u0110\u00e0o (M/V)", "description": null, "duration": 297.0, "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "uploader": "\u0110en V\u00e2u Official", "uploader_id": null, "uploader_url": null, "thumbnails": [{"url": "https://i.ytimg.com/vi/KKc_RMln5UY/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAE0jKnPS0v1E_YHdg3UPiEvG0j1A", "height": 202, "width": 360}, {"url": "https://i.ytimg.com/vi/KKc_RMln5UY/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAOcBa3yyAadGHbRtHNv7cimOpbfA", "height": 404, "width": 720}], "timestamp": null, "release_timestamp": null, "availability": null, "view_count": 180009882, "live_status": null, "channel_is_verified": true, "__x_forwarded_for_ip": null, "webpage_url": "https://www.youtube.com/watch?v=KKc_RMln5UY", "original_url": "https://www.youtube.com/watch?v=KKc_RMln5UY", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube", "extractor_key": "Youtube", "playlist_count": 5, "playlist": "\u0110en V\u00e2u channel", "playlist_id": "\u0110en V\u00e2u channel", "playlist_title": "\u0110en V\u00e2u channel", "playlist_uploader": null, "playlist_uploader_id": null, "playlist_channel": null, "playlist_channel_id": null, "playlist_webpage_url": "ytsearch5:\u0110en V\u00e2u channel", "n_entries": 5, "playlist_index": 5, "__last_playlist_index": 5, "playlist_autonumber": 5, "epoch": 1773501405, "duration_string": "4:57", "release_year": null, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}} diff --git a/backend-rust/test-channel.json b/backend-rust/test-channel.json new file mode 100644 index 0000000..f5360fc --- /dev/null +++ b/backend-rust/test-channel.json @@ -0,0 +1 @@ +{"id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "title": "\u0110en V\u00e2u Official", "availability": null, "channel_follower_count": 5210000, "description": "\u0110en V\u00e2u\nM\u1ed9t ng\u01b0\u1eddi Vi\u1ec7t ch\u01a1i nh\u1ea1c Rap.\nA Vietnamese boy who plays Rap. \n\n\n\n", "tags": ["den", "den vau", "\u0111en", "\u0111en v\u00e2u", "rapper den", "rapper \u0111en", "rapper \u0111en v\u00e2u", "mang ti\u1ec1n v\u1ec1 cho m\u1eb9", "ai mu\u1ed1n nghe kh\u00f4ng", "l\u1ed1i nh\u1ecf", "hip hop rap", "\u0111i v\u1ec1 nh\u00e0", "b\u00e0i n\u00e0y chill ph\u1ebft", "trong r\u1eebng c\u00f3 nai c\u00f3 th\u1ecf", "nh\u1ea1c rap", "friendship", "friendship karaoke", "friendship lyrics", "lu\u00f4n y\u00eau \u0111\u1eddi", "tri\u1ec7u \u0111i\u1ec1u nh\u1ecf x\u00edu xi\u00eau l\u00f2ng", "xi\u00eau l\u00f2ng", "nh\u1ecf x\u00edu xi\u00eau l\u00f2ng", "\u0111\u01b0\u1eddng bi\u00ean h\u00f2a", "kim long", "\u0111en ft kim long", "\u0111en v\u00e0 kim long", "l\u00e3ng \u0111\u00e3ng", "lang dang", "l\u00e3ng \u0111\u00e3ng \u0111en v\u00e2u", "vi\u1ec7c l\u1edbn", "vi\u1ec7c l\u1edbn \u0111en v\u00e2u"], "thumbnails": [{"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 175, "width": 1060, "preference": -10, "id": "0", "resolution": "1060x175"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w1138-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 188, "width": 1138, "preference": -10, "id": "1", "resolution": "1138x188"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w1707-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 283, "width": 1707, "preference": -10, "id": "2", "resolution": "1707x283"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w2120-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 351, "width": 2120, "preference": -10, "id": "3", "resolution": "2120x351"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w2276-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 377, "width": 2276, "preference": -10, "id": "4", "resolution": "2276x377"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 424, "width": 2560, "preference": -10, "id": "5", "resolution": "2560x424"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=s0", "id": "banner_uncropped", "preference": -5}, {"url": "https://yt3.googleusercontent.com/4KreeefY-ytOPUKbpbDQnIqzi-b2qOP8ILqlNDThbk5T8kYJIT2EKvj9uQnv8FHi15quYN5L=s900-c-k-c0x00ffffff-no-rj", "height": 900, "width": 900, "id": "7", "resolution": "900x900"}, {"url": "https://yt3.googleusercontent.com/4KreeefY-ytOPUKbpbDQnIqzi-b2qOP8ILqlNDThbk5T8kYJIT2EKvj9uQnv8FHi15quYN5L=s0", "id": "avatar_uncropped", "preference": 1}], "uploader_id": "@DenVau1305", "uploader_url": "https://www.youtube.com/@DenVau1305", "modified_date": null, "view_count": null, "playlist_count": 2, "uploader": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "_type": "playlist", "entries": [], "webpage_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "original_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "webpage_url_basename": "UCWu91J5KWEj1bQhCBuGeJxw", "webpage_url_domain": "youtube.com", "extractor": "youtube:tab", "extractor_key": "YoutubeTab", "release_year": null, "requested_entries": [], "epoch": 1773501485, "__files_to_move": {}, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}} diff --git a/docker-compose.yml b/docker-compose.yml index 1bea826..2f53c9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,17 @@ -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: + - "3110:8080" + environment: + - PORT=8080 + volumes: + - ./data:/tmp/spotify-clone-downloads + - ./cache:/tmp/spotify-clone-cache + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/frontend-vite/src/components/CoverImage.tsx b/frontend-vite/src/components/CoverImage.tsx index 6941650..3eb1df9 100644 --- a/frontend-vite/src/components/CoverImage.tsx +++ b/frontend-vite/src/components/CoverImage.tsx @@ -7,15 +7,14 @@ interface CoverImageProps { fallbackText?: string; } -export default function CoverImage({ src, alt, className = "", fallbackText = "♪" }: CoverImageProps) { +export default function CoverImage({ src, alt, className = "", fallbackText = "♪♪" }: CoverImageProps) { const [error, setError] = useState(false); const [loaded, setLoaded] = useState(false); if (!src || error) { - // Fallback placeholder with gradient return (
{fallbackText} @@ -24,7 +23,7 @@ export default function CoverImage({ src, alt, className = "", fallbackText = " } return ( -
+
{!loaded && (
)} diff --git a/frontend-vite/src/components/PlayerBar.tsx b/frontend-vite/src/components/PlayerBar.tsx index 280fac6..273b4ac 100644 --- a/frontend-vite/src/components/PlayerBar.tsx +++ b/frontend-vite/src/components/PlayerBar.tsx @@ -1,11 +1,12 @@ 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'; import QueueModal from './QueueModal'; +import Recommendations from './Recommendations'; import { useDominantColor } from '../hooks/useDominantColor'; import { useLyrics } from '../hooks/useLyrics'; @@ -13,7 +14,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 +56,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 +177,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 +192,7 @@ export default function PlayerBar() { if ('mediaSession' in navigator) navigator.mediaSession.playbackState = "paused"; } } - }, [isPlaying]); + }, [isPlaying, playerMode]); // Volume Effect useEffect(() => { @@ -134,16 +201,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 +270,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 +313,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); }} /> @@ -404,7 +462,7 @@ export default function PlayerBar() { className="w-full h-1 bg-[#4d4d4d] rounded-lg appearance-none cursor-pointer accent-white group-hover:accent-green-500" />
-
@@ -413,143 +471,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 +633,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 +649,9 @@ export default function PlayerBar() {
- )} + )} - {/* Modals */} + {/* Modals */} setIsQueueOpen(false)} diff --git a/frontend-vite/src/components/QueueModal.tsx b/frontend-vite/src/components/QueueModal.tsx index 5f0dcc1..20c7740 100644 --- a/frontend-vite/src/components/QueueModal.tsx +++ b/frontend-vite/src/components/QueueModal.tsx @@ -1,5 +1,7 @@ import { X, Play, Pause } from 'lucide-react'; import { usePlayer } from '../context/PlayerContext'; +import { useEffect, useState } from 'react'; +import { libraryService } from '../services/library'; import CoverImage from './CoverImage'; import { Track } from '../types'; @@ -10,6 +12,30 @@ interface QueueModalProps { export default function QueueModal({ isOpen, onClose }: QueueModalProps) { const { queue, currentTrack, playTrack, isPlaying, togglePlay } = usePlayer(); + const [recommendations, setRecommendations] = useState([]); + const [loadingRecs, setLoadingRecs] = useState(false); + + // Fetch recommendations when current track changes + useEffect(() => { + if (!currentTrack || !isOpen) return; + + const fetchRecommendations = async () => { + setLoadingRecs(true); + try { + const result = await libraryService.getRelatedContent(currentTrack.artist || currentTrack.title, 'track', 5); + // Filter out current track + const filtered = result.tracks.filter(t => t.id !== currentTrack.id); + setRecommendations(filtered.slice(0, 5)); + } catch (error) { + console.error('Failed to fetch recommendations:', error); + setRecommendations([]); + } finally { + setLoadingRecs(false); + } + }; + + fetchRecommendations(); + }, [currentTrack, isOpen]); if (!isOpen) return null; @@ -48,8 +74,6 @@ export default function QueueModal({ isOpen, onClose }: QueueModalProps) { // Actually queue usually contains the current track. // Let's filter out current track visually or just show whole queue? // Spotify shows "Next In Queue". - if (track.id === currentTrack?.id) return null; - return ( + + {/* Recommendations Section */} +
+

Recommended for You

+ {loadingRecs ? ( +
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : recommendations.length === 0 ? ( +
No recommendations available
+ ) : ( +
+ {recommendations.map((track) => ( +
playTrack(track, [...queue, ...recommendations])} + className="flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition cursor-pointer group" + > +
+ +
+ +
+
+
+

{track.title}

+

{track.artist}

+
+
+ ))} +
+ )} +
diff --git a/frontend-vite/src/components/Recommendations.tsx b/frontend-vite/src/components/Recommendations.tsx new file mode 100644 index 0000000..cddacb2 --- /dev/null +++ b/frontend-vite/src/components/Recommendations.tsx @@ -0,0 +1,209 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { libraryService } from '../services/library'; +import { usePlayer } from '../context/PlayerContext'; +import { Play } from 'lucide-react'; +import { Track } from '../types'; +import CoverImage from './CoverImage'; + +interface RecommendationData { + tracks: Track[]; + albums: Array<{ id: string; title: string; artist: string; cover_url: string }>; + playlists: Array<{ id: string; title: string; cover_url: string; track_count: number }>; + artists: Array<{ id: string; name: string; photo_url: string; cover_url?: string }>; +} + +interface RecommendationsProps { + seed: string; + seedType?: string; + limit?: number; + title?: string; + showTracks?: boolean; + showAlbums?: boolean; + showPlaylists?: boolean; + showArtists?: boolean; +} + +export default function Recommendations({ + seed, + seedType = 'track', + limit = 10, + title = 'You might also like', + showTracks = true, + showAlbums = true, + showPlaylists = true, + showArtists = true +}: RecommendationsProps) { + const navigate = useNavigate(); + const { playTrack } = usePlayer(); + const [data, setData] = useState({ + tracks: [], + albums: [], + playlists: [], + artists: [] + }); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!seed) return; + + const fetchRecommendations = async () => { + try { + const result = await libraryService.getRelatedContent(seed, seedType, limit); + + const artistsWithPhotos = await Promise.all( + result.artists.map(async (artist) => { + try { + const tracks = await libraryService.search(artist.name); + if (tracks.length > 0) { + return { ...artist, cover_url: tracks[0].cover_url }; + } + } catch (e) {} + try { + const info = await libraryService.getArtistInfo(artist.name); + if (info.photo) { + return { ...artist, cover_url: info.photo }; + } + } catch (e) {} + return artist; + }) + ); + + setData({ ...result, artists: artistsWithPhotos }); + } catch (error) { + console.error('Failed to fetch recommendations:', error); + } + }; + + fetchRecommendations(); + }, [seed, seedType, limit]); + + const hasContent = (showTracks && data.tracks.length > 0) || + (showAlbums && data.albums.length > 0) || + (showPlaylists && data.playlists.length > 0) || + (showArtists && data.artists.length > 0); + + const hasAnyContent = hasContent || loading; + + if (!hasAnyContent) return null; + + const isLoading = !hasContent; + + return ( +
+
+

{title}

+
+ + {isLoading && ( +
+ {[1, 2, 3, 4, 5].map(i => ( +
+
+
+
+
+ ))} +
+ )} + +
+ {/* Tracks */} + {showTracks && data.tracks.slice(0, 8).map((track) => ( +
playTrack(track, data.tracks)} + > +
+ +
+
+ +
+
+
+

{track.title}

+

{track.artist}

+
+ ))} + + {/* Albums */} + {showAlbums && data.albums.slice(0, 8).map((album) => ( + +
+
+ +
+
+ +
+
+
+

{album.title}

+

{album.artist}

+
+ + ))} + + {/* Playlists */} + {showPlaylists && data.playlists.slice(0, 8).map((playlist) => ( + +
+
+ +
+
+ +
+
+
+

{playlist.title}

+

{playlist.track_count} songs

+
+ + ))} + + {/* Artists */} + {showArtists && data.artists.slice(0, 8).map((artist) => ( + +
+
+ +
+
+ +
+
+
+

{artist.name}

+

Artist

+
+ + ))} +
+
+ ); +} diff --git a/frontend-vite/src/components/SettingsModal.tsx b/frontend-vite/src/components/SettingsModal.tsx index 441111b..c9c568c 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_v8'); + 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/components/Sidebar.tsx b/frontend-vite/src/components/Sidebar.tsx index 91acb50..543c128 100644 --- a/frontend-vite/src/components/Sidebar.tsx +++ b/frontend-vite/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { Home, Search, Library, Plus, Heart, Settings } from "lucide-react"; +import { Home, Search, Library, Plus, Heart } from "lucide-react"; import { Link, useLocation } from "react-router-dom"; import { usePlayer } from "../context/PlayerContext"; import { useLibrary } from "../context/LibraryContext"; @@ -7,14 +7,11 @@ import CreatePlaylistModal from "./CreatePlaylistModal"; import { dbService } from "../services/db"; import Logo from "./Logo"; import CoverImage from "./CoverImage"; -import SettingsModal from "./SettingsModal"; export default function Sidebar() { const { likedTracks } = usePlayer(); const { userPlaylists, libraryItems, refreshLibrary, activeFilter, setActiveFilter } = useLibrary(); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - - const [isSettingsOpen, setIsSettingsOpen] = useState(false); const location = useLocation(); const isActive = (path: string) => location.pathname === path; @@ -121,7 +118,7 @@ export default function Sidebar() {

{playlist.title}

@@ -147,7 +144,7 @@ export default function Sidebar() {

{playlist.title}

@@ -164,7 +161,7 @@ export default function Sidebar() {
@@ -182,7 +179,7 @@ export default function Sidebar() {
@@ -195,28 +192,11 @@ export default function Sidebar() {
- {/* Settings Section */} -
- -
- setIsCreateModalOpen(false)} onCreate={handleCreatePlaylist} /> - - setIsSettingsOpen(false)} - /> ); } 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..742d0eb 100644 --- a/frontend-vite/src/pages/Album.tsx +++ b/frontend-vite/src/pages/Album.tsx @@ -1,37 +1,68 @@ 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'; import { Track } from '../types'; +import Recommendations from '../components/Recommendations'; 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) { + // Normalize track IDs - extract YouTube video ID from discovery-* IDs + const normalizedTracks = album.tracks.map((track) => { + let videoId = track.id; + if (track.id.includes('discovery-') || track.id.includes('artist-')) { + const parts = track.id.split('-'); + for (const part of parts) { + if (part.length === 11 && /^[a-zA-Z0-9_-]+$/.test(part)) { + videoId = part; + break; + } + } + } + return { ...track, id: videoId, url: `/api/stream/${videoId}` }; + }); + setTracks(normalizedTracks); + 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(normalizedTracks.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 +80,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 +187,53 @@ 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}

+
+ ))} +
+
+ )} + + {/* Related Content Recommendations */} + {albumInfo && ( + + )}
); } diff --git a/frontend-vite/src/pages/Artist.tsx b/frontend-vite/src/pages/Artist.tsx index f87001d..d246e36 100644 --- a/frontend-vite/src/pages/Artist.tsx +++ b/frontend-vite/src/pages/Artist.tsx @@ -4,6 +4,7 @@ import { libraryService } from '../services/library'; import { usePlayer } from '../context/PlayerContext'; import { Play, Shuffle, Heart, Disc, Music } from 'lucide-react'; import { Track } from '../types'; +import Recommendations from '../components/Recommendations'; import { GENERATED_CONTENT } from '../data/seed_data'; interface ArtistData { @@ -17,10 +18,11 @@ 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); + const [songsLoading, setSongsLoading] = useState(true); // YouTube Music uses name-based IDs or channel IDs. // Our 'id' might be a name if clicked from Home. @@ -52,6 +54,7 @@ export default function Artist() { } const fetchData = async () => { + setSongsLoading(true); // Fetch info (Background) // If we already have photo from seed, maybe skip or update? // libraryService.getArtistInfo might find a better photo or same. @@ -62,14 +65,19 @@ export default function Artist() { libraryService.search(artistName) ]); + setSongsLoading(false); + const finalPhoto = (info.status === 'fulfilled' && info.value?.photo) ? info.value.photo : seedArtist?.cover_url; + + // Ensure we always have a photo - if somehow still empty, use UI-Avatars + const safePhoto = finalPhoto || `https://ui-avatars.com/api/?name=${encodeURIComponent(artistName)}&background=random&color=fff&size=200&rounded=true&bold=true&font-size=0.33`; let topSongs = (songs.status === 'fulfilled') ? songs.value : []; - if (topSongs.length > 5) topSongs = topSongs.slice(0, 5); + if (topSongs.length > 20) topSongs = topSongs.slice(0, 20); setArtist({ name: artistName, - photo: finalPhoto, + photo: safePhoto, topSongs, albums: [], singles: [] @@ -104,7 +112,11 @@ export default function Artist() {
); diff --git a/frontend-vite/src/pages/Home.tsx b/frontend-vite/src/pages/Home.tsx index 57d2839..363d092 100644 --- a/frontend-vite/src/pages/Home.tsx +++ b/frontend-vite/src/pages/Home.tsx @@ -15,7 +15,8 @@ export default function Home() { const [loading, setLoading] = useState(true); const [sortBy, setSortBy] = useState('recent'); const [showSortMenu, setShowSortMenu] = useState(false); - const { playTrack, playHistory } = usePlayer(); + const [heroPlaylist, setHeroPlaylist] = useState(null); + const { playTrack, playHistory, setIsFullScreenOpen, currentTrack } = usePlayer(); useEffect(() => { const hour = new Date().getHours(); @@ -24,24 +25,40 @@ export default function Home() { else setTimeOfDay("Good evening"); // Cache First Strategy for "Super Fast" loading - const cached = localStorage.getItem('ytm_browse_cache_v4'); + const cached = localStorage.getItem('ytm_browse_cache_v8'); if (cached) { setBrowseData(JSON.parse(cached)); setLoading(false); } - setLoading(true); - libraryService.getBrowseContent() - .then(data => { - setBrowseData(data); - setLoading(false); - // Update Cache - localStorage.setItem('ytm_browse_cache_v4', JSON.stringify(data)); - }) - .catch(err => { - console.error("Error fetching browse:", err); - setLoading(false); - }); + const fetchBrowseData = () => { + setLoading(true); + libraryService.getBrowseContent() + .then(data => { + setBrowseData(data); + setLoading(false); + // Update Cache + localStorage.setItem('ytm_browse_cache_v8', JSON.stringify(data)); + + // Pick a random playlist for the hero section + const allPlaylists = Object.values(data).flat().filter(p => p.type === 'Playlist'); + if (allPlaylists.length > 0) { + const randomIdx = Math.floor(Math.random() * allPlaylists.length); + setHeroPlaylist(allPlaylists[randomIdx]); + } + }) + .catch(err => { + console.error("Error fetching browse:", err); + setLoading(false); + }); + }; + + fetchBrowseData(); + + // Auto-refresh every 5 minutes + const refreshInterval = setInterval(fetchBrowseData, 300000); + + return () => clearInterval(refreshInterval); }, []); const sortPlaylists = (playlists: StaticPlaylist[]) => { @@ -59,9 +76,6 @@ export default function Home() { } }; - const firstCategory = Object.keys(browseData)[0]; - const heroPlaylist = firstCategory && browseData[firstCategory].length > 0 ? browseData[firstCategory][0] : null; - const sortOptions = [ { value: 'recent', label: 'Recently Added', icon: Clock }, { value: 'alpha-asc', label: 'Alphabetical (A-Z)', icon: ArrowUpDown }, @@ -129,9 +143,9 @@ export default function Home() { fallbackText="VB" />
-
+
Featured Playlist -

{heroPlaylist.title}

+

{heroPlaylist.title}

{heroPlaylist.description}

@@ -166,23 +180,37 @@ export default function Home() { ))}
- ) : browseData["Top Albums"] && browseData["Top Albums"].length > 0 && ( + ) : browseData["Top Albums"] && browseData["Top Albums"].length > 0 && (() => { + const seen = new Set(); + const uniqueAlbums = (browseData["Top Albums"] as any[]).filter(a => { + if (seen.has(a.id)) return false; + seen.add(a.id); + return true; + }); + return (

Top Albums

-
- {browseData["Top Albums"].slice(0, 15).map((album) => ( +
+ {uniqueAlbums.slice(0, 15).map((album) => (
- -
+ +
{ + e.preventDefault(); + e.stopPropagation(); + playTrack(album as any); + }} + className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl cursor-pointer" + >
@@ -195,7 +223,8 @@ export default function Home() { ))}
- )} + ); + })()} {/* Browse Lists */} {loading ? ( @@ -217,8 +246,15 @@ export default function Home() {
) : Object.keys(browseData).length > 0 ? ( Object.entries(browseData) - .filter(([category]) => category !== "Top Albums") // Filter out albums since we showed them above - .map(([category, playlists]) => ( + .filter(([category, items]) => category !== "Top Albums" && (items as any[]).length > 0) + .map(([category, playlists]) => { + const seen = new Set(); + const uniquePlaylists = (playlists as any[]).filter(p => { + if (seen.has(p.id)) return false; + seen.add(p.id); + return true; + }); + return (

{category}

@@ -228,18 +264,25 @@ export default function Home() {
{/* USER REQUEST: Bigger Grid, Smaller Text, Smaller Gap */} -
- {sortPlaylists(playlists).slice(0, 15).map((playlist) => ( +
+ {sortPlaylists(uniquePlaylists).slice(0, 15).map((playlist) => (
-
+
{ + e.preventDefault(); + e.stopPropagation(); + playTrack(playlist as any); + }} + className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl cursor-pointer" + >
@@ -252,7 +295,8 @@ export default function Home() { ))}
- )) + ); + }) ) : (

Ready to explore?

@@ -265,6 +309,7 @@ export default function Home() { // Recently Listened Section function RecentlyListenedSection({ playHistory, playTrack }: { playHistory: Track[], playTrack: (track: Track, queue?: Track[]) => void }) { + const { setIsFullScreenOpen, currentTrack } = usePlayer(); if (playHistory.length === 0) return null; return ( @@ -278,14 +323,16 @@ function RecentlyListenedSection({ playHistory, playTrack }: { playHistory: Trac {playHistory.slice(0, 10).map((track, i) => (
playTrack(track, playHistory)} + onClick={() => { + playTrack(track, playHistory); + }} className="flex-shrink-0 w-40 bg-spotify-card rounded-xl overflow-hidden hover:bg-spotify-card-hover transition duration-300 group cursor-pointer" >
@@ -307,7 +354,7 @@ function RecentlyListenedSection({ playHistory, playTrack }: { playHistory: Trac // Made For You Section function MadeForYouSection() { - const { playHistory, playTrack } = usePlayer(); + const { playHistory, playTrack, setIsFullScreenOpen, currentTrack } = usePlayer(); const [recommendations, setRecommendations] = useState([]); const [seedTrack, setSeedTrack] = useState(null); const [loading, setLoading] = useState(false); @@ -351,14 +398,16 @@ function MadeForYouSection() { ))}
) : ( -
+
{recommendations.slice(0, 10).map((track, i) => ( -
playTrack(track, recommendations)} className="bg-transparent md:bg-spotify-card p-0 md:p-4 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col"> +
{ + playTrack(track, recommendations); + }} className="bg-transparent md:bg-spotify-card p-0 md:p-4 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col">
@@ -417,35 +466,50 @@ function ArtistVietnamSection() { // 2. Load Photos (Cache First Strategy) const loadPhotos = async () => { - // v3: Progressive Loading + Smaller Thumbnails - const cacheKey = 'artist_photos_cache_v3'; + // v5: Force refresh for authentic channel avatars + const cacheKey = 'artist_photos_cache_v6'; const cached = JSON.parse(localStorage.getItem(cacheKey) || '{}'); - // Initialize with cache immediately + // Apply cache to state first setArtistPhotos(cached); - setLoading(false); // Show names immediately - + // Identify missing photos const missing = targetArtists.filter(name => !cached[name]); if (missing.length > 0) { - // Fetch missing incrementally - for (const name of missing) { + // Fetch all missing photos in parallel + const fetchPromises = missing.map(async (name) => { try { - // Fetch one by one and update state immediately - // This prevents "batch waiting" feeling - libraryService.getArtistInfo(name).then(data => { - if (data.photo) { - setArtistPhotos(prev => { - const next: Record = { ...prev, [name]: data.photo || "" }; - localStorage.setItem(cacheKey, JSON.stringify(next)); - return next; - }); - } - }); + const data = await libraryService.getArtistInfo(name); + if (data.photo) { + return { name, photo: data.photo }; + } } catch { /* ignore */ } + return null; + }); + + // Wait for ALL fetches to complete + const results = await Promise.all(fetchPromises); + + // Update state with all results at once + const updates: Record = {}; + results.forEach(result => { + if (result) { + updates[result.name] = result.photo; + } + }); + + if (Object.keys(updates).length > 0) { + setArtistPhotos(prev => { + const next: Record = { ...prev, ...updates }; + localStorage.setItem(cacheKey, JSON.stringify(next)); + return next; + }); } } + + // Only set loading false AFTER all photos are loaded + setLoading(false); }; loadPhotos(); @@ -459,11 +523,11 @@ function ArtistVietnamSection() {

Based on your recent listening

-
+
{artists.length === 0 && loading ? ( [1, 2, 3, 4, 5, 6].map(i => (
- +
)) @@ -475,12 +539,12 @@ function ArtistVietnamSection() { -
-
- +
+
+
diff --git a/frontend-vite/src/pages/Library.tsx b/frontend-vite/src/pages/Library.tsx index 20945db..8593ed9 100644 --- a/frontend-vite/src/pages/Library.tsx +++ b/frontend-vite/src/pages/Library.tsx @@ -144,7 +144,7 @@ export default function Library() {
@@ -187,7 +187,7 @@ export default function Library() {
diff --git a/frontend-vite/src/pages/Playlist.tsx b/frontend-vite/src/pages/Playlist.tsx index f837f0b..a67e3a8 100644 --- a/frontend-vite/src/pages/Playlist.tsx +++ b/frontend-vite/src/pages/Playlist.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { useParams, Link } from 'react-router-dom'; +import { useNavigate, useParams, Link } from 'react-router-dom'; import { Play, Pause, Clock, Heart, PlusCircle, Shuffle, Trash2, ArrowLeft } from 'lucide-react'; import { usePlayer } from '../context/PlayerContext'; import { useLibrary } from '../context/LibraryContext'; @@ -9,19 +9,22 @@ import { Track, StaticPlaylist } from '../types'; import CoverImage from '../components/CoverImage'; import AddToPlaylistModal from '../components/AddToPlaylistModal'; import Skeleton from '../components/Skeleton'; +import Recommendations from '../components/Recommendations'; import { GENERATED_CONTENT } from '../data/seed_data'; type PlaylistData = PlaylistType | StaticPlaylist; export default function Playlist() { const { id: playlistId } = useParams<{ id: string }>(); + const navigate = useNavigate(); const [playlist, setPlaylist] = useState(null); const [loading, setLoading] = useState(true); // Full page loading const [loadingTracks, setLoadingTracks] = useState(false); // background track loading const [selectedTrack, setSelectedTrack] = useState(null); const [isUserPlaylist, setIsUserPlaylist] = useState(false); + const [moreLikeThis, setMoreLikeThis] = useState([]); - const { playTrack, currentTrack, isPlaying, togglePlay, likedTracks, toggleLike } = usePlayer(); + const { playTrack, currentTrack, isPlaying, togglePlay, likedTracks, toggleLike, setIsFullScreenOpen } = usePlayer(); const { libraryItems, userPlaylists, refreshLibrary } = useLibrary(); useEffect(() => { @@ -74,15 +77,50 @@ export default function Playlist() { setIsUserPlaylist(true); setLoading(false); setLoadingTracks(false); + + // Fetch suggestions for user playlists too + try { + const recs = await libraryService.search(dbPlaylist.title); + setMoreLikeThis(recs.slice(0, 10)); + } catch (e) { } } else { // Check API / Library Service (Hydration happens here) console.log("Fetching from Library Service (Hydrating)..."); const apiPlaylist = await libraryService.getPlaylist(playlistId); - if (apiPlaylist) { - setPlaylist(apiPlaylist); + if (apiPlaylist && apiPlaylist.tracks.length > 0) { + // Normalize track IDs - extract YouTube video ID from discovery-* IDs + const normalizedTracks = apiPlaylist.tracks.map((track: Track) => { + let videoId = track.id; + // If ID contains "discovery-" or "artist-", extract the YouTube video ID + if (track.id.includes('discovery-') || track.id.includes('artist-')) { + const parts = track.id.split('-'); + // Find 11-char YouTube ID + for (const part of parts) { + if (part.length === 11 && /^[a-zA-Z0-9_-]+$/.test(part)) { + videoId = part; + break; + } + } + } + return { ...track, id: videoId, url: `/api/stream/${videoId}` }; + }); + const normalizedPlaylist = { ...apiPlaylist, tracks: normalizedTracks }; + setPlaylist(normalizedPlaylist); setIsUserPlaylist(false); + setLoading(false); + + // Fetch suggestions + try { + const query = apiPlaylist.title.replace(' Mix', ''); + const recs = await libraryService.search(query); + const currentIds = new Set(normalizedTracks.map((t: Track) => t.id)); + setMoreLikeThis(recs.filter(t => !currentIds.has(t.id)).slice(0, 10)); + } catch (e) { } + } else { + // Hydration failed or found no tracks - redirect home to avoid broken page + console.warn("Hydration failed for", playlistId); + navigate('/', { replace: true }); } - setLoading(false); setLoadingTracks(false); } } catch (e) { @@ -105,7 +143,7 @@ export default function Playlist() { if (!playlist || !isUserPlaylist) return; await dbService.removeFromPlaylist(playlist.id, trackId); await refreshLibrary(); - setPlaylist(prev => prev ? { ...prev, tracks: prev.tracks.filter(t => t.id !== trackId) } : null); + setPlaylist((prev: PlaylistData | null) => prev ? { ...prev, tracks: prev.tracks.filter((t: Track) => t.id !== trackId) } : null); }; const formatDuration = (seconds?: number) => { @@ -115,7 +153,7 @@ export default function Playlist() { return `${mins}:${secs.toString().padStart(2, '0')}`; }; - const totalDuration = playlist?.tracks.reduce((acc, t) => acc + (t.duration || 0), 0) || 0; + const totalDuration = playlist?.tracks.reduce((acc: number, t: Track) => acc + (t.duration || 0), 0) || 0; // FULL PAGE SPINNER (Only if we have NO metadata at all) if (loading) { @@ -145,29 +183,63 @@ export default function Playlist() { } return ( -
- {/* Hero Header (Always visible if playlist exists) */} -
+
+ {/* Banner Background */} + {playlist.cover_url && ( +
+ )} + + {/* Hero Header */} +
- -
-

Playlist

-

{playlist.title}

- {'description' in playlist && playlist.description && ( -

{playlist.description}

- )} -

- {/* Show 'Loading...' if caching tracks, otherwise count */} - {loadingTracks ? 'Updating...' : `${playlist.tracks.length} songs`} - {totalDuration > 0 && ` • ${Math.floor(totalDuration / 60)} min`} -

+
{ + if (playlist && playlist.tracks.length > 0) { + playTrack(playlist.tracks[0], playlist.tracks); + setIsFullScreenOpen(true); + } + }} + > + +
+ +
+
+
+ Playlist +

{playlist.title}

+
+ {'description' in playlist && playlist.description && ( + {playlist.description} + )} + + + {loadingTracks ? 'Updating...' : `${playlist.tracks.length} songs`} + + {totalDuration > 0 && ( + <> + + {Math.floor(totalDuration / 60)} min + + )} +
@@ -252,7 +324,7 @@ export default function Playlist() {
@@ -296,6 +368,50 @@ export default function Playlist() { )}
+ {/* Suggestions / More like this */} + {moreLikeThis.length > 0 && ( +
+
+

More like this

+
+
+ {moreLikeThis.map((track) => ( +
{ + playTrack(track, moreLikeThis); + }} + > +
+ +
+
+ +
+
+
+

{track.title}

+

{track.artist}

+
+ ))} +
+
+ )} + + {/* Related Content Recommendations */} + {playlist && ( + + )} + {/* Add to Playlist Modal */} {selectedTrack && ( (
- +

{album.name}

Album

@@ -207,7 +207,7 @@ export default function Search() {
- +

{track.title}

{track.artist}

diff --git a/frontend-vite/src/pages/Section.tsx b/frontend-vite/src/pages/Section.tsx index dc4e815..c0cb21f 100644 --- a/frontend-vite/src/pages/Section.tsx +++ b/frontend-vite/src/pages/Section.tsx @@ -57,7 +57,7 @@ export default function Section() {
diff --git a/frontend-vite/src/services/db.ts b/frontend-vite/src/services/db.ts index 7661479..47043e3 100644 --- a/frontend-vite/src/services/db.ts +++ b/frontend-vite/src/services/db.ts @@ -68,14 +68,14 @@ export const dbService = { // ALWAYS use fallback if API returned 0 tracks if (allTracks.length === 0) { console.log("Using Mock Data for DB Seeding"); - const highResTracks = [ + allTracks = [ { id: "fb-1", title: "Shape of You (Demo)", artist: "Ed Sheeran", album: "Divide", duration: 233, - cover_url: "https://images.unsplash.com/photo-1614613535308-eb5fbd3d2c17?w=800&q=80", // High Res Green/Abstract + cover_url: "https://placehold.co/800x800/1DB954/191414?text=Shape+of+You", url: "https://cdn.pixabay.com/download/audio/2022/05/27/audio_1808fbf07a.mp3?filename=twilight-120000.mp3" }, { @@ -84,7 +84,7 @@ export const dbService = { artist: "The Weeknd", album: "After Hours", duration: 200, - cover_url: "https://images.unsplash.com/photo-1493225255756-d9584f8606e9?w=800&q=80", // High Res City/Red + cover_url: "https://placehold.co/800x800/ff0000/ffffff?text=Blinding+Lights", url: "https://cdn.pixabay.com/download/audio/2022/03/15/audio_1669466e3b.mp3?filename=relaxed-vlog-131746.mp3" }, { @@ -93,7 +93,7 @@ export const dbService = { artist: "Dua Lipa", album: "Future Nostalgia", duration: 203, - cover_url: "https://images.unsplash.com/photo-1494232410401-ad00d5433cfa?w=800&q=80", // High Res Music/Vibe + cover_url: "https://placehold.co/800x800/ff00ff/ffffff?text=Levitating", url: "https://cdn.pixabay.com/download/audio/2022/01/18/audio_d0a13f69d2.mp3?filename=sexy-fashion-beats-11176.mp3" }, { @@ -102,7 +102,7 @@ export const dbService = { artist: 'The Kid LAROI', album: 'F*CK LOVE 3', duration: 141, - cover_url: "https://images.unsplash.com/photo-1514525253440-b393452e8d26?w=800&q=80", // High Res Neon + cover_url: "https://placehold.co/800x800/800080/ffffff?text=Stay", url: 'https://cdn.pixabay.com/download/audio/2022/10/25/audio_5145a278d6.mp3?filename=summer-party-12461.mp3' }, { @@ -111,7 +111,7 @@ export const dbService = { artist: 'Lil Nas X', album: 'Montero', duration: 137, - cover_url: "https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=800&q=80", // High Res Party + cover_url: "https://placehold.co/800x800/ffa500/ffffff?text=Montero", url: 'https://cdn.pixabay.com/download/audio/2023/04/12/audio_496677f54c.mp3?filename=hip-hop-trap-145638.mp3' } ]; diff --git a/frontend-vite/src/services/library.ts b/frontend-vite/src/services/library.ts index 2d22464..ff87871 100644 --- a/frontend-vite/src/services/library.ts +++ b/frontend-vite/src/services/library.ts @@ -27,23 +27,43 @@ export const libraryService = { async search(query: string): Promise { const data = await apiFetch(`/search?q=${encodeURIComponent(query)}`); if (data?.tracks && data.tracks.length > 0) { - return data.tracks; + // Normalize track IDs - extract YouTube video ID from discovery-* IDs + return data.tracks.map((track: Track) => { + let videoId = track.id; + if (track.id.includes('discovery-') || track.id.includes('artist-') || track.id.includes('album-')) { + const parts = track.id.split('-'); + for (const part of parts) { + if (part.length === 11 && /^[a-zA-Z0-9_-]+$/.test(part)) { + videoId = part; + break; + } + } + } + return { ...track, id: videoId, url: `/api/stream/${videoId}` }; + }); } return []; }, async getBrowseContent(): Promise> { - // Return structured content from Seed Data - // We simulate a "fetch" but it's instant + // Fetch dynamic preloaded content from backend + try { + const data = await apiFetch('/browse'); + if (data && Object.keys(data).length > 0) { + return data; + } + } catch (e) { + console.error("Failed to load dynamic browse content", e); + } + // Fallback to mock data if discover returns empty (e.g. backend offline or still loading) const playlists = Object.values(GENERATED_CONTENT).filter(p => p.type === 'Playlist'); const albums = Object.values(GENERATED_CONTENT).filter(p => p.type === 'Album'); const artists = Object.values(GENERATED_CONTENT).filter(p => p.type === 'Artist'); - return { - 'Top Playlists': playlists.slice(0, 100), - 'Top Albums': albums.slice(0, 100), - 'Popular Artists': artists.slice(0, 100) + 'Top Playlists': playlists.slice(0, 50), + 'Top Albums': albums.slice(0, 50), + 'Popular Artists': artists.slice(0, 50) }; }, @@ -71,46 +91,86 @@ export const libraryService = { return tracks.slice(0, 20); }, + async getRelatedContent(seed: string, seedType: string = 'track', limit: number = 10): Promise<{ + tracks: Track[], + albums: Array<{ id: string, title: string, artist: string, cover_url: string }>, + playlists: Array<{ id: string, title: string, cover_url: string, track_count: number }>, + artists: Array<{ id: string, name: string, photo_url: string }> + }> { + try { + const data = await apiFetch(`/recommendations?seed=${encodeURIComponent(seed)}&seed_type=${seedType}&limit=${limit}`); + if (data) { + return { + tracks: data.tracks || [], + albums: data.albums || [], + playlists: data.playlists || [], + artists: data.artists || [] + }; + } + } catch (e) { + console.error('Failed to get related content:', e); + } + + // Fallback to search-based recommendations + const fallbackTracks = await this.search(seed); + return { + tracks: fallbackTracks.slice(0, limit), + albums: [], + playlists: [], + artists: [] + }; + }, + async getPlaylist(id: string): Promise { // 1. Try to find in GENERATED_CONTENT first (Fast/Instant) - // Extract base ID if needed or check directly? - // Our seed data keys are "Name" but values have IDs "playlist-Name" or "album-Name". - // We need to find by ID. const found = Object.values(GENERATED_CONTENT).find(p => p.id === id); if (found) { - // Found metadata! Return it immediately. - // If tracks are empty, we might want to lazy-load them via search? - // Yes, let's fire off a search to fill tracks if empty. if (found.tracks.length === 0) { - // Try to fetch tracks in background. - // We use multiple fallbacks to ensure we get results. const queries = [ - found.title, // Exact title - `${found.title} songs`, // Explicit songs - `${found.title} playlist`, // Might find mixes - "Vietnam Top Hits" // Ultimate fallback + found.title, + `${found.title} songs`, + `${found.title} playlist`, + "Vietnam Top Hits" ]; for (const q of queries) { try { - console.log(`[Hydration] Searching: ${q}`); const tracks = await this.search(q); if (tracks.length > 0) { - console.log(`[Hydration] Found ${tracks.length} tracks for ${found.title}`); found.tracks = tracks; return { ...found, tracks }; } } catch (e) { - console.error("Hydration search failed for", q, e); } } } return found; } - // 2. Fallback: Search by ID string parsing (Slow/Legacy) - const query = id.replace('playlist-', '').replace(/-/g, ' '); + // 2. Try to find in dynamic backend browse cache + try { + const browseData = await apiFetch('/browse'); + for (const category in browseData) { + const plist = browseData[category].find((p: any) => p.id === id); + if (plist) { + if (!plist.tracks || plist.tracks.length === 0) { + try { + const tracks = await this.search(`${plist.title} playlist`); + plist.tracks = tracks.length > 0 ? tracks : await this.search(plist.title); + return { ...plist, tracks: plist.tracks }; + } catch (e) { } + } + return plist; + } + } + } catch (e) { + console.error("Browse cache lookup failed", e); + } + + // 3. Fallback: Search by ID string parsing (Slow/Legacy) + const cleanId = id.replace(/^discovery-(playlist|album|artist)-/, ''); + const query = cleanId.replace(/-/g, ' '); const tracks = await this.search(query); if (tracks.length > 0) { return { @@ -142,7 +202,25 @@ export const libraryService = { return found; } - const query = id.replace('album-', '').replace(/-/g, ' '); + try { + const browseData = await apiFetch('/browse'); + for (const category in browseData) { + const plist = browseData[category].find((p: any) => p.id === id); + if (plist) { + if (!plist.tracks || plist.tracks.length === 0) { + try { + const tracks = await this.search(`${plist.title} album`); + plist.tracks = tracks.length > 0 ? tracks : await this.search(plist.title); + return { ...plist, tracks: plist.tracks }; + } catch (e) { } + } + return plist; + } + } + } catch (e) { } + + const cleanId = id.replace(/^discovery-(playlist|album|artist)-/, ''); + const query = cleanId.replace(/-/g, ' '); const tracks = await this.search(query); if (tracks.length > 0) { return { @@ -158,25 +236,33 @@ export const libraryService = { }, async getArtistInfo(artistName: string): Promise<{ bio?: string; photo?: string }> { - // Try specific API for image + // Method 1: Try backend API for real YouTube channel photo try { - const res = await apiFetch(`/artist-image?q=${encodeURIComponent(artistName)}`); - if (res && res.url) { - return { photo: res.url }; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout + + const res = await fetch(`/api/artist/info?q=${encodeURIComponent(artistName)}`, { + signal: controller.signal + }); + clearTimeout(timeoutId); + + if (res.ok) { + const data = await res.json(); + console.log(`[ArtistInfo] ${artistName}:`, data); + if (data.image) { + return { photo: data.image }; + } } } catch (e) { - // fall through + console.error(`[ArtistInfo] Error for ${artistName}:`, e); + // Fall through to next method } - // Fallback to track cover - try { - const tracks = await this.search(artistName); - if (tracks.length > 0 && tracks[0]?.cover_url) { - return { photo: tracks[0].cover_url }; - } - } catch (e) { } - - return { photo: getUnsplashImage(artistName) }; + // Method 2: Use UI-Avatars API (instant, always works) + // Using smaller size (128) for faster loading + const encodedName = encodeURIComponent(artistName); + const avatarUrl = `https://ui-avatars.com/api/?name=${encodedName}&background=random&color=fff&size=128&rounded=true&bold=true&font-size=0.33`; + return { photo: avatarUrl }; }, async getLyrics(track: string, artist: string): Promise<{ plainLyrics?: string; syncedLyrics?: string; } | null> { @@ -209,7 +295,7 @@ export const libraryService = { if (t.album && !seen.has(`album-${t.album}`)) { seen.add(`album-${t.album}`); results.push({ - id: `discovery-album-${Math.random().toString(36).substr(2, 9)}`, + id: `discovery-album-${t.album.replace(/\s+/g, '-')}`, title: t.album, creator: t.artist, cover_url: t.cover_url, @@ -224,10 +310,10 @@ export const libraryService = { if (t.artist && !seen.has(`artist-${t.artist}`)) { seen.add(`artist-${t.artist}`); results.push({ - id: `discovery-artist-${Math.random().toString(36).substr(2, 9)}`, + id: `discovery-artist-${t.artist.replace(/\s+/g, '-')}`, title: t.artist, creator: 'Artist', - cover_url: t.cover_url, // Ideally fetch artist image, but track cover is okay fallback + cover_url: t.cover_url, type: 'Artist' }); } @@ -235,10 +321,9 @@ export const libraryService = { } if (type === 'playlists' || type === 'all') { - // Generate some "Mix" playlists from the tracks if (tracks.length > 5) { results.push({ - id: `discovery-playlist-${Math.random().toString(36).substr(2, 9)}`, + id: `discovery-playlist-${randomQuery.replace(/\s+/g, '-')}-Mix`, title: `${randomQuery} Mix`, creator: 'Spotify Clone', cover_url: tracks[0].cover_url, @@ -257,25 +342,16 @@ export const libraryService = { } }; -// Pool of high-quality artist/music abstract images -const ARTIST_IMAGES = [ - "photo-1511671782779-c97d3d27a1d4", // Microphone - "photo-1493225255756-d9584f8606e9", // Vinyl - "photo-1514525253440-b393452e8d26", // Neon - "photo-1470225620780-dba8ba36b745", // DJ - "photo-1511379938547-c1f69419868d", // Piano - "photo-1501612780327-45045538702b", // Guitar - "photo-1459749411177-287ce327a395", // Concert - "photo-1510915362694-bdddb0292f2d", // Stage - "photo-1544785135-3ef2b2b1fb28", // Singer - "photo-1460723237483-7a6dc9d0b212", // Band -]; - +// Dynamic Placeholders for artists/covers without an image function getUnsplashImage(seed: string): string { + const initials = seed.substring(0, 2).toUpperCase(); + const colors = ["1DB954", "FF6B6B", "4ECDC4", "45B7D1", "6C5CE7", "FDCB6E"]; + let hash = 0; for (let i = 0; i < seed.length; i++) { hash = seed.charCodeAt(i) + ((hash << 5) - hash); } - const index = Math.abs(hash) % ARTIST_IMAGES.length; - return `https://images.unsplash.com/${ARTIST_IMAGES[index]}?w=400&h=400&fit=crop&q=80`; + const color = colors[Math.abs(hash) % colors.length]; + + return `https://placehold.co/400x400/${color}/FFFFFF?text=${encodeURIComponent(initials)}`; }