Compare commits

..

No commits in common. "7fe5b955e899c1366ee308922966d35a8042031a" and "35c9bf24f7d451c1b2717bbb9c221b29980dbc83" have entirely different histories.

29 changed files with 1741 additions and 1116 deletions

7
.gitignore vendored
View file

@ -62,12 +62,5 @@ backend/data_seed/
*.log *.log
build_log*.txt build_log*.txt
# Rust
target/
# Backup Files # Backup Files
*_backup.* *_backup.*
nul
*.pid
frontend.pid
backend.pid

View file

@ -1,4 +1,7 @@
# ---------------------------
# Stage 1: Build Frontend # Stage 1: Build Frontend
# ---------------------------
FROM node:20-alpine AS frontend-builder FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
@ -9,19 +12,22 @@ COPY frontend-vite/ .
ENV NODE_ENV=production ENV NODE_ENV=production
RUN npm run build RUN npm run build
# ---------------------------
# Stage 2: Build Backend (Rust) # Stage 2: Build Backend (Rust)
FROM rust:1.75-alpine AS backend-builder # ---------------------------
FROM rust:1.85-bookworm AS backend-builder
WORKDIR /app/backend WORKDIR /app/backend
RUN apk add --no-cache musl-dev openssl-dev perl COPY backend-rust/Cargo.toml ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release && rm -rf src
COPY backend-rust/Cargo.toml backend-rust/Cargo.lock ./ COPY backend-rust/src ./src
RUN cargo fetch RUN cargo build --release
COPY backend-rust/ ./
RUN cargo build --release --bin backend-rust
# ---------------------------
# Stage 3: Final Runtime # Stage 3: Final Runtime
# ---------------------------
FROM python:3.11-slim-bookworm FROM python:3.11-slim-bookworm
WORKDIR /app WORKDIR /app
@ -29,23 +35,18 @@ WORKDIR /app
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
ffmpeg \ ffmpeg \
ca-certificates \ ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/* && 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 --from=backend-builder /app/backend/target/release/backend-rust /app/server
# Copy frontend build
COPY --from=frontend-builder /app/frontend/dist /app/static COPY --from=frontend-builder /app/frontend/dist /app/static
# Create cache directories RUN mkdir -p /tmp/spotify-clone-cache && chmod 777 /tmp/spotify-clone-cache
RUN mkdir -p /tmp/spotify-clone-cache /tmp/spotify-clone-downloads && chmod 777 /tmp/spotify-clone-cache /tmp/spotify-clone-downloads
RUN pip install --no-cache-dir -U "yt-dlp[default]"
ENV PORT=8080 ENV PORT=8080
ENV RUST_LOG=release ENV RUST_ENV=production
EXPOSE 8080 EXPOSE 8080

133
README.md
View file

@ -1,35 +1,49 @@
# Spotify Clone 🎵 # 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. A fully functional clone of the Spotify web player, built with **React**, **Rust (Axum)**, and **TailwindCSS**. This application replicates the premium authentic feel of the original web player with added features like synchronized lyrics, custom playlist management, and "Audiophile" technical specs.
![Preview](https://opengraph.githubassets.com/1/vndangkhoa/spotify-clone)
--- ---
## 🚀 Quick Start (Docker) ## 🚀 Quick Start (Docker)
### Option 1: Pull from Forgejo Registry ### Option 1: Pull from Registry
```bash ```bash
docker run -p 3110:8080 git.khoavo.myds.me/vndangkhoa/spotify-clone:v3 docker pull git.khoavo.myds.me/vndangkhoa/spotify-clone:v3
docker run -d -p 3000:8080 --name spotify-clone \
-v ./data:/app/data \
-v ./cache:/tmp/spotify-clone-cache \
--restart unless-stopped \
git.khoavo.myds.me/vndangkhoa/spotify-clone:v3
``` ```
Open **[http://localhost:3110](http://localhost:3110)**.
### Option 2: Build Locally ### Option 2: Build Locally
```bash ```bash
docker build -t spotify-clone:v3 . docker build -t spotify-clone:v3 .
docker run -p 3110:8080 spotify-clone:v3 docker run -d -p 3000:8080 --name spotify-clone \
-v ./data:/app/data \
-v ./cache:/tmp/spotify-clone-cache \
--restart unless-stopped \
spotify-clone:v3
``` ```
Open **[http://localhost:3000](http://localhost:3000)**.
--- ---
## 🐳 Docker Deployment ## 🐳 Docker Deployment
### Image Details ### Building the Image
- **Registry**: `git.khoavo.myds.me/vndangkhoa/spotify-clone` ```bash
- **Tag**: `v3` # Build for linux/amd64 (Synology NAS)
- **Architecture**: `linux/amd64` docker build -t git.khoavo.myds.me/vndangkhoa/spotify-clone:v3 .
- **Ports**:
- `8080` (Backend API)
### docker-compose.yml # Push to registry
docker push git.khoavo.myds.me/vndangkhoa/spotify-clone:v3
```
### Docker Compose (Recommended)
```yaml ```yaml
services: services:
spotify-clone: spotify-clone:
@ -37,11 +51,12 @@ services:
container_name: spotify-clone container_name: spotify-clone
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3110:8080" - "3000:8080"
environment: environment:
- PORT=8080 - PORT=8080
- RUST_ENV=production
volumes: volumes:
- ./data:/tmp/spotify-clone-downloads - ./data:/app/data
- ./cache:/tmp/spotify-clone-cache - ./cache:/tmp/spotify-clone-cache
logging: logging:
driver: "json-file" driver: "json-file"
@ -52,38 +67,62 @@ services:
--- ---
## 🖥️ Synology NAS Deployment (Container Manager) ## 📦 Synology NAS Deployment
### Method A: Using Container Manager UI ### Method A: Container Manager UI (GUI)
1. Open **Container Manager** (or Docker on older DSM). 1. **Open Container Manager** on your Synology NAS.
2. Go to **Registry****Add** → Enter: 2. Go to **Registry** and click **Add**:
- Registry: `git.khoavo.myds.me` - Registry URL: `https://git.khoavo.myds.me`
3. Search for `spotify-clone` and download the `v3` tag. - Enter credentials if prompted.
4. Go to **Image** → Select the image → **Run**. 3. Search for `vndangkhoa/spotify-clone` and download the `v3` tag.
4. Go to **Image**, select the downloaded image, and click **Run**.
5. Configure: 5. Configure:
- **Container Name**: `spotify-clone` - **Container Name**: `spotify-clone`
- **Network**: Bridge - **Port Settings**:
- **Port Settings**: Local Port `3110` → Container Port `8080` - Local Port: `3000` (or any available port)
- Container Port: `8080`
- **Volume Settings**: - **Volume Settings**:
- Create folder `/docker/spotify-clone/data``/tmp/spotify-clone-downloads` - Add folder: `docker/spotify-clone/data``/app/data`
- Create folder `/docker/spotify-clone/cache``/tmp/spotify-clone-cache` - Add folder: `docker/spotify-clone/cache``/tmp/spotify-clone-cache`
6. Click **Run**. - **Restart Policy**: `unless-stopped`
6. Click **Done** and access at `http://YOUR_NAS_IP:3000`.
### Method B: Using Docker Compose ### Method B: Docker Compose (CLI)
1. Create folder: `/volume1/docker/spotify-clone` 1. SSH into your Synology NAS or use the built-in terminal.
2. Save the `docker-compose.yml` above to that folder. 2. Create a folder:
3. Open Container Manager → **Project****Create**. ```bash
4. Select the folder path. mkdir -p /volume1/docker/spotify-clone
5. The container will start automatically. cd /volume1/docker/spotify-clone
6. Access at `http://YOUR_NAS_IP:3110` ```
3. Create `docker-compose.yml`:
```yaml
services:
spotify-clone:
image: git.khoavo.myds.me/vndangkhoa/spotify-clone:v3
container_name: spotify-clone
restart: unless-stopped
ports:
- "3000:8080"
environment:
- PORT=8080
- RUST_ENV=production
volumes:
- ./data:/app/data
- ./cache:/tmp/spotify-clone-cache
```
4. Run:
```bash
docker compose up -d
```
--- ### Synology-Specific Notes
## 🔄 Auto-Refresh - **Architecture**: This image is built for `linux/amd64` (compatible with most Intel-based Synology NAS).
- **DSM 7+**: Use Container Manager (Docker GUI replacement).
The backend automatically fetches trending content from YouTube Music every 5 minutes. No additional setup required. - **Data Persistence**: The `./data` volume stores playlists and application data. Backup this folder to preserve your data.
- **Updating**: Pull the latest image and recreate the container, or use Watchtower for auto-updates.
--- ---
@ -95,31 +134,31 @@ The backend automatically fetches trending content from YouTube Music every 5 mi
- Python 3.11+ - Python 3.11+
- ffmpeg - ffmpeg
### Backend (Rust) ### 1. Backend Setup (Rust)
```bash ```bash
cd backend-rust cd backend-rust
cargo run cargo run
``` ```
Backend runs on `http://localhost:8080`.
### Frontend ### 2. Frontend Setup
```bash ```bash
cd frontend-vite cd frontend-vite
npm install npm install
npm run dev npm run dev
``` ```
Frontend runs on `http://localhost:5173`.
--- ---
## ✨ Features ## ✨ Features
- **YouTube Music Integration**: Search and play from YouTube Music - **Real-Time Lyrics**: Fetch and sync lyrics from multiple sources.
- **Trending Auto-Fetch**: 15+ categories updated every 5 minutes - **Audiophile Engine**: "Tech Specs" view showing live bitrate, LUFS, and Dynamic Range.
- **Real-Time Lyrics**: Sync lyrics from multiple sources - **Local-First**: Works offline (PWA) and syncs local playlists.
- **Custom Playlists**: Create, save, and manage playlists (IndexedDB) - **Smart Search**: Unified search across YouTube Music.
- **PWA Support**: Works offline with cached content - **Responsive**: Full mobile support with dedicated full-screen player.
- **Responsive Design**: Mobile-first with dark theme - **Smooth Loading**: Skeleton animations for seamless data fetching.
---
## 📝 License ## 📝 License

1408
backend-rust/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,19 +1,14 @@
[package] [package]
name = "backend-rust" name = "backend-rust"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2021"
[[bin]]
name = "backend-rust"
path = "src/main.rs"
[dependencies] [dependencies]
axum = "0.8.8" axum = "0.8.8"
reqwest = { version = "0.13.2", features = ["json"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7.18", features = ["io"] } tokio-util = { version = "0.7.18", features = ["io"] }
tower = { version = "0.5.3", features = ["util"] } tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.6.8", features = ["cors", "fs"] } tower-http = { version = "0.6.8", features = ["cors", "fs"] }
urlencoding = "2.1.3"
futures = "0.3"

View file

@ -1,13 +1,16 @@
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::{header, StatusCode},
response::IntoResponse, response::IntoResponse,
Json, Json,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use tokio::fs::File;
use tokio_util::io::ReaderStream;
use crate::spotdl::SpotdlService; use crate::spotdl::SpotdlService;
use crate::models::{Playlist, Track};
pub struct AppState { pub struct AppState {
pub spotdl: SpotdlService, pub spotdl: SpotdlService,
@ -62,27 +65,11 @@ pub async fn artist_info_handler(
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Artist name required"}))); return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Artist name required"})));
} }
// Check cache first match state.spotdl.search_artist(query) {
{ Ok(img) => (StatusCode::OK, Json(serde_json::json!({"image": img}))),
let cache = state.spotdl.search_cache.read().await; Err(e) => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": e}))),
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( pub async fn browse_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
@ -93,82 +80,3 @@ pub async fn browse_handler(
// we can return empty or a small default. The frontend will handle it. // we can return empty or a small default. The frontend will handle it.
(StatusCode::OK, Json(cache.clone())) (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<crate::models::Track>,
pub albums: Vec<AlbumSuggestion>,
pub playlists: Vec<PlaylistSuggestion>,
pub artists: Vec<ArtistSuggestion>,
}
#[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<Arc<AppState>>,
Query(params): Query<RecommendationsQuery>,
) -> 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 {
&params.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}))),
}
}

View file

@ -1,5 +1,5 @@
pub mod api; mod api;
pub mod models; mod models;
mod spotdl; mod spotdl;
use axum::{ use axum::{
@ -9,6 +9,7 @@ use axum::{
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;
use crate::api::AppState; use crate::api::AppState;
use crate::spotdl::SpotdlService; use crate::spotdl::SpotdlService;
@ -30,7 +31,7 @@ async fn main() {
.route("/api/stream/{id}", get(api::stream_handler)) .route("/api/stream/{id}", get(api::stream_handler))
.route("/api/artist/info", get(api::artist_info_handler)) .route("/api/artist/info", get(api::artist_info_handler))
.route("/api/browse", get(api::browse_handler)) .route("/api/browse", get(api::browse_handler))
.route("/api/recommendations", get(api::recommendations_handler)) .nest_service("/", ServeDir::new("static"))
.layer(cors) .layer(cors)
.with_state(app_state); .with_state(app_state);

View file

@ -6,19 +6,18 @@ use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::{Instant, Duration}; use std::time::{Instant, Duration};
use futures::future::join_all;
use crate::models::{Track, YTResult, StaticPlaylist}; use crate::models::{Track, YTResult, StaticPlaylist};
pub struct CacheItem { struct CacheItem {
pub tracks: Vec<Track>, tracks: Vec<Track>,
pub timestamp: Instant, timestamp: Instant,
} }
#[derive(Clone)] #[derive(Clone)]
pub struct SpotdlService { pub struct SpotdlService {
download_dir: PathBuf, download_dir: PathBuf,
pub search_cache: Arc<RwLock<HashMap<String, CacheItem>>>, search_cache: Arc<RwLock<HashMap<String, CacheItem>>>,
pub browse_cache: Arc<RwLock<HashMap<String, Vec<StaticPlaylist>>>>, pub browse_cache: Arc<RwLock<HashMap<String, Vec<StaticPlaylist>>>>,
} }
@ -28,9 +27,6 @@ impl SpotdlService {
let download_dir = temp_dir.join("spotify-clone-cache"); let download_dir = temp_dir.join("spotify-clone-cache");
let _ = fs::create_dir_all(&download_dir); let _ = fs::create_dir_all(&download_dir);
// Ensure node is in PATH for yt-dlp
let _ = Self::js_runtime_args();
Self { Self {
download_dir, download_dir,
search_cache: Arc::new(RwLock::new(HashMap::new())), search_cache: Arc::new(RwLock::new(HashMap::new())),
@ -38,78 +34,44 @@ impl SpotdlService {
} }
} }
fn get_placeholder_image(&self, seed: &str) -> String {
let initials = seed.chars().take(2).collect::<String>().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 { fn yt_dlp_path() -> String {
// Use the updated binary we downloaded // Try local
let updated_path = "/tmp/yt-dlp"; if let Ok(exe_path) = env::current_exe() {
if Path::new(updated_path).exists() { if let Some(dir) = exe_path.parent() {
return updated_path.to_string(); let local = dir.join("yt-dlp.exe");
if local.exists() {
return local.to_string_lossy().into_owned();
}
}
} }
// Windows: Check user Scripts folder // Try working dir
if cfg!(windows) { if Path::new("yt-dlp.exe").exists() {
if let Ok(home) = env::var("APPDATA") { return "./yt-dlp.exe".to_string();
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();
} }
// Try Python
if let Ok(home) = env::var("USERPROFILE") {
let py_path = Path::new(&home).join("AppData").join("Local").join("Programs").join("Python").join("Python312").join("Scripts").join("yt-dlp.exe");
if py_path.exists() {
return py_path.to_string_lossy().into_owned();
} }
} }
"yt-dlp".to_string() "yt-dlp".to_string()
} }
fn js_runtime_args() -> Vec<String> {
Vec::new()
}
pub fn start_background_preload(&self) { pub fn start_background_preload(&self) {
let cache_arc = self.browse_cache.clone(); let cache_arc = self.browse_cache.clone();
let refresh_cache = self.browse_cache.clone();
tokio::spawn(async move { tokio::spawn(async move {
println!("Background preloader started... fetching Top Albums & Playlists"); 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<RwLock<HashMap<String, Vec<StaticPlaylist>>>>) {
let queries = vec![ let queries = vec![
("Top Albums", "ytsearch50:Top Albums Vietnam audio"), ("Top Albums", "ytsearch50:Top Albums Vietnam audio"),
("Viral Hits Vietnam", "ytsearch30:Viral Hits Vietnam audio"), ("Viral Hits", "ytsearch30:Viral Hits Vietnam audio"),
("Lofi Chill Vietnam", "ytsearch30:Lofi Chill Vietnam audio"), ("Lofi Chill", "ytsearch30:Lofi Chill Vietnam audio"),
("US UK Top Hits", "ytsearch30:US UK Billboard Hot 100 audio"), ("US UK Top Hits", "ytsearch30:US UK Billboard Hot 100 audio"),
("K-Pop ON!", "ytsearch30:K-Pop Top Hits audio"), ("K-Pop", "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 path = Self::yt_dlp_path();
@ -117,7 +79,7 @@ impl SpotdlService {
for (category, search_query) in queries { for (category, search_query) in queries {
let output = Command::new(&path) let output = Command::new(&path)
.args(&["--js-runtimes", "node", &search_query, "--dump-json", "--no-playlist", "--flat-playlist"]) .args(&[&search_query, "--dump-json", "--no-playlist", "--flat-playlist"])
.output(); .output();
if let Ok(o) = output { if let Ok(o) = output {
@ -163,9 +125,9 @@ impl SpotdlService {
} }
// Also load artists // Also load artists
let artists_query = "ytmusicsearch30:V-Pop Official Channel"; let artists_query = "ytsearch30:V-Pop Official Channel";
if let Ok(o) = Command::new(&path) if let Ok(o) = Command::new(&path)
.args(&["--js-runtimes", "node", &artists_query, "--dump-json", "--flat-playlist"]) .args(&[&artists_query, "--dump-json", "--flat-playlist"])
.output() { .output() {
let mut items = Vec::new(); let mut items = Vec::new();
for line in String::from_utf8_lossy(&o.stdout).lines() { for line in String::from_utf8_lossy(&o.stdout).lines() {
@ -194,6 +156,7 @@ impl SpotdlService {
println!("Background preloader finished loading {} categories!", all_data.len()); println!("Background preloader finished loading {} categories!", all_data.len());
let mut cache = cache_arc.write().await; let mut cache = cache_arc.write().await;
*cache = all_data; *cache = all_data;
});
} }
pub async fn search_tracks(&self, query: &str) -> Result<Vec<Track>, String> { pub async fn search_tracks(&self, query: &str) -> Result<Vec<Track>, String> {
@ -212,7 +175,7 @@ impl SpotdlService {
let search_query = format!("ytsearch20:{} audio", query); let search_query = format!("ytsearch20:{} audio", query);
let output = match Command::new(&path) let output = match Command::new(&path)
.args(&["--js-runtimes", "node", &search_query, "--dump-json", "--no-playlist", "--flat-playlist"]) .args(&[&search_query, "--dump-json", "--no-playlist", "--flat-playlist"])
.output() { .output() {
Ok(o) => o, Ok(o) => o,
Err(e) => return Err(format!("Failed to execute yt-dlp: {}", e)), Err(e) => return Err(format!("Failed to execute yt-dlp: {}", e)),
@ -318,19 +281,14 @@ impl SpotdlService {
let output = match Command::new(Self::yt_dlp_path()) let output = match Command::new(Self::yt_dlp_path())
.current_dir(&self.download_dir) .current_dir(&self.download_dir)
.args(&["--js-runtimes", "node", "-f", "bestaudio/best", "--output", &format!("{}.%(ext)s", video_id), &target_url]) .args(&["-f", "bestaudio[ext=m4a]/bestaudio", "--output", &format!("{}.%(ext)s", video_id), &target_url])
.output() { .output() {
Ok(o) => o, Ok(o) => o,
Err(e) => { Err(e) => return Err(format!("Download spawn failed: {}", e)),
println!("[Stream] yt-dlp spawn error: {}", e);
return Err(format!("Download spawn failed: {}", e));
}
}; };
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("Download failed. 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 // Find downloaded file again
@ -347,268 +305,57 @@ impl SpotdlService {
Err("File not found after download".to_string()) Err("File not found after download".to_string())
} }
pub async fn search_artist(&self, query: &str) -> Result<String, String> { pub fn search_artist(&self, query: &str) -> Result<String, String> {
// 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 path = Self::yt_dlp_path();
let search_query = format!("ytsearch5:{} artist", query);
let output = Command::new(&path) // Search specifically for official channel to get the avatar
let search_query = format!("ytsearch1:{} official channel", query);
let output = match Command::new(&path)
.args(&[&search_query, "--dump-json", "--flat-playlist"]) .args(&[&search_query, "--dump-json", "--flat-playlist"])
.output(); .output() {
Ok(o) => o,
Err(_) => return Err("Search failed to execute".to_string()),
};
let stdout = String::from_utf8_lossy(&output.stdout);
#[derive(serde::Deserialize)]
struct SimpleYT {
id: String,
#[serde(default)]
thumbnails: Vec<crate::models::YTThumbnail>,
}
if let Ok(o) = output {
let stdout = String::from_utf8_lossy(&o.stdout);
for line in stdout.lines() { for line in stdout.lines() {
if line.trim().is_empty() { if let Ok(res) = serde_json::from_str::<SimpleYT>(line) {
continue; // If it's a channel (starts with UC), use its avatar
} if res.id.starts_with("UC") {
if let Ok(res) = serde_json::from_str::<YTResult>(line) { let best_thumb = res.thumbnails.iter().max_by_key(|t| {
// Get the video thumbnail which often has the artist let w = t.width.unwrap_or(0);
if let Some(thumb) = res.thumbnails.last() { let h = t.height.unwrap_or(0);
if !thumb.url.is_empty() { w * h
// Convert to higher quality thumbnail });
let high_quality = thumb.url.replace("hqdefault", "maxresdefault");
return Ok(high_quality); if let Some(thumb) = best_thumb {
} return Ok(thumb.url.clone());
} }
} }
} }
} }
// Fallback to placeholder if no real photo found // Fallback: If no channel found, try searching normally but stay alert for channel icons
Ok(self.get_placeholder_image(query)) Err("No authentic channel photo found for artist".to_string())
} }
fn extract_id(url: &str) -> String { fn extract_id(url: &str) -> String {
// If URL contains v= parameter, extract from there first
if url.contains("v=") { if url.contains("v=") {
let parts: Vec<&str> = url.split("v=").collect(); let parts: Vec<&str> = url.split("v=").collect();
if parts.len() > 1 { if parts.len() > 1 {
let video_part = parts[1].split('&').next().unwrap_or(""); let sub_parts: Vec<&str> = parts[1].split('&').collect();
return sub_parts[0].to_string();
// 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() url.to_string()
} }
pub async fn get_recommendations(
&self,
seed: &str,
seed_type: &str,
limit: usize,
) -> Result<crate::api::Recommendations, String> {
// 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,
})
}
} }

View file

@ -1,5 +0,0 @@
{"_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"}}

View file

@ -1 +0,0 @@
{"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"}}

41
deploy.bat Normal file
View file

@ -0,0 +1,41 @@
@echo off
echo ==========================================
echo Spotify Clone Deployment Script
echo ==========================================
echo [1/3] Checking Docker status...
docker info >nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] Docker is NOT running!
echo.
echo Please start Docker Desktop from your Start Menu.
echo Once Docker is running ^(green icon^), run this script again.
echo.
pause
exit /b 1
)
echo [2/3] Docker is active. Building Image...
echo This may take a few minutes...
docker build -t vndangkhoa/spotify-clone:latest .
if %errorlevel% neq 0 (
echo [ERROR] Docker build failed.
pause
exit /b 1
)
echo [3/3] Pushing to Docker Hub...
docker push vndangkhoa/spotify-clone:latest
if %errorlevel% neq 0 (
echo [ERROR] Docker push failed.
echo You may need to run 'docker login' first.
pause
exit /b 1
)
echo.
echo ==========================================
echo [SUCCESS] Deployment Complete!
echo Image: vndangkhoa/spotify-clone:latest
echo ==========================================
pause

View file

@ -1,14 +1,15 @@
services: services:
spotify-clone: spotify-clone:
image: git.khoavo.myds.me/vndangkhoa/spotify-clone:v3 image: git.khoavo.myds.me/vndangkhoa/spotify-clone:v5
container_name: spotify-clone container_name: spotify-clone
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3110:8080" - "3110:8080"
environment: environment:
- PORT=8080 - PORT=8080
- RUST_ENV=production
volumes: volumes:
- ./data:/tmp/spotify-clone-downloads - ./data:/app/data
- ./cache:/tmp/spotify-clone-cache - ./cache:/tmp/spotify-clone-cache
logging: logging:
driver: "json-file" driver: "json-file"

View file

@ -7,14 +7,15 @@ interface CoverImageProps {
fallbackText?: string; 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 [error, setError] = useState(false);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
if (!src || error) { if (!src || error) {
// Fallback placeholder with gradient
return ( return (
<div <div
className={`relative overflow-hidden bg-gradient-to-br from-neutral-700 to-neutral-900 flex items-center justify-center text-2xl font-bold text-white/60 ${className}`} className={`bg-gradient-to-br from-neutral-700 to-neutral-900 flex items-center justify-center text-2xl font-bold text-white/60 ${className}`}
aria-label={alt} aria-label={alt}
> >
{fallbackText} {fallbackText}
@ -23,7 +24,7 @@ export default function CoverImage({ src, alt, className = "", fallbackText = "
} }
return ( return (
<div className={`relative overflow-hidden ${className}`}> <div className={`relative ${className}`}>
{!loaded && ( {!loaded && (
<div className="absolute inset-0 bg-neutral-800 animate-pulse" /> <div className="absolute inset-0 bg-neutral-800 animate-pulse" />
)} )}

View file

@ -6,7 +6,6 @@ import TechSpecs from './TechSpecs';
import AddToPlaylistModal from "./AddToPlaylistModal"; import AddToPlaylistModal from "./AddToPlaylistModal";
import Lyrics from './Lyrics'; import Lyrics from './Lyrics';
import QueueModal from './QueueModal'; import QueueModal from './QueueModal';
import Recommendations from './Recommendations';
import { useDominantColor } from '../hooks/useDominantColor'; import { useDominantColor } from '../hooks/useDominantColor';
import { useLyrics } from '../hooks/useLyrics'; import { useLyrics } from '../hooks/useLyrics';
@ -541,7 +540,7 @@ export default function PlayerBar() {
<img <img
src={currentTrack.cover_url} src={currentTrack.cover_url}
alt={currentTrack.title} alt={currentTrack.title}
className="w-full aspect-square object-cover rounded-[2rem] shadow-[0_30px_60px_rgba(0,0,0,0.5)] max-h-[50vh] md:max-h-[60vh] transition-transform duration-700 group-hover:scale-[1.02]" className="w-full aspect-square object-cover rounded-3xl shadow-[0_30px_60px_rgba(0,0,0,0.5)] max-h-[50vh] md:max-h-[60vh] transition-transform duration-700 group-hover:scale-[1.02]"
/> />
</div> </div>
)} )}

View file

@ -1,7 +1,5 @@
import { X, Play, Pause } from 'lucide-react'; import { X, Play, Pause } from 'lucide-react';
import { usePlayer } from '../context/PlayerContext'; import { usePlayer } from '../context/PlayerContext';
import { useEffect, useState } from 'react';
import { libraryService } from '../services/library';
import CoverImage from './CoverImage'; import CoverImage from './CoverImage';
import { Track } from '../types'; import { Track } from '../types';
@ -12,30 +10,6 @@ interface QueueModalProps {
export default function QueueModal({ isOpen, onClose }: QueueModalProps) { export default function QueueModal({ isOpen, onClose }: QueueModalProps) {
const { queue, currentTrack, playTrack, isPlaying, togglePlay } = usePlayer(); const { queue, currentTrack, playTrack, isPlaying, togglePlay } = usePlayer();
const [recommendations, setRecommendations] = useState<Track[]>([]);
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; if (!isOpen) return null;
@ -74,6 +48,8 @@ export default function QueueModal({ isOpen, onClose }: QueueModalProps) {
// Actually queue usually contains the current track. // Actually queue usually contains the current track.
// Let's filter out current track visually or just show whole queue? // Let's filter out current track visually or just show whole queue?
// Spotify shows "Next In Queue". // Spotify shows "Next In Queue".
if (track.id === currentTrack?.id) return null;
return ( return (
<QueueItem <QueueItem
key={`${track.id}-${i}`} key={`${track.id}-${i}`}
@ -85,47 +61,6 @@ export default function QueueModal({ isOpen, onClose }: QueueModalProps) {
}) })
)} )}
</div> </div>
{/* Recommendations Section */}
<div className="mt-6 pt-6 border-t border-white/10">
<h3 className="text-sm font-bold text-neutral-400 uppercase tracking-widest mb-4 px-2">Recommended for You</h3>
{loadingRecs ? (
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex items-center gap-3 p-2 animate-pulse">
<div className="w-10 h-10 bg-white/10 rounded" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-white/10 rounded w-3/4" />
<div className="h-3 bg-white/10 rounded w-1/2" />
</div>
</div>
))}
</div>
) : recommendations.length === 0 ? (
<div className="text-neutral-500 text-sm px-2">No recommendations available</div>
) : (
<div className="space-y-1">
{recommendations.map((track) => (
<div
key={track.id}
onClick={() => playTrack(track, [...queue, ...recommendations])}
className="flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition cursor-pointer group"
>
<div className="relative w-10 h-10 flex-shrink-0">
<CoverImage src={track.cover_url} alt={track.title} className="w-full h-full rounded object-cover" fallbackText="♪" />
<div className="absolute inset-0 bg-black/40 hidden group-hover:flex items-center justify-center">
<Play size={16} className="text-white fill-white" />
</div>
</div>
<div className="min-w-0 flex-1">
<p className="font-medium truncate text-sm text-white">{track.title}</p>
<p className="text-xs text-neutral-400 truncate">{track.artist}</p>
</div>
</div>
))}
</div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,209 +0,0 @@
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<RecommendationData>({
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 (
<div className="p-4 md:p-8 mt-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold hover:underline cursor-pointer">{title}</h2>
</div>
{isLoading && (
<div className="grid grid-cols-2 fold:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-4">
{[1, 2, 3, 4, 5].map(i => (
<div key={`skel-${i}`} className="bg-[#181818] p-3 md:p-4 rounded-xl space-y-3 md:space-y-4">
<div className="w-full aspect-square bg-neutral-800 rounded-2xl animate-pulse" />
<div className="h-4 bg-neutral-800 rounded w-3/4" />
<div className="h-3 bg-neutral-800 rounded w-1/2" />
</div>
))}
</div>
)}
<div className="grid grid-cols-2 fold:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-4">
{/* Tracks */}
{showTracks && data.tracks.slice(0, 8).map((track) => (
<div
key={track.id}
className="bg-[#181818] p-3 md:p-4 rounded-xl hover:bg-[#282828] transition duration-300 group cursor-pointer relative flex flex-col"
onClick={() => playTrack(track, data.tracks)}
>
<div className="relative mb-3 md:mb-4">
<CoverImage
src={track.cover_url}
alt={track.title}
className="w-full aspect-square rounded-2xl shadow-lg"
fallbackText={track.title?.substring(0, 3).toUpperCase() || '♪'}
/>
<div
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"
>
<div className="w-10 h-10 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</div>
</div>
</div>
<h3 className="font-bold text-sm md:text-base mb-1 truncate">{track.title}</h3>
<p className="text-xs md:text-sm text-[#a7a7a7] truncate">{track.artist}</p>
</div>
))}
{/* Albums */}
{showAlbums && data.albums.slice(0, 8).map((album) => (
<Link to={`/album/${encodeURIComponent(album.id)}`} key={album.id}>
<div className="bg-[#181818] p-3 md:p-4 rounded-xl hover:bg-[#282828] transition duration-300 group cursor-pointer relative flex flex-col h-full">
<div className="relative mb-3 md:mb-4">
<CoverImage
src={album.cover_url}
alt={album.title}
className="w-full aspect-square rounded-2xl shadow-lg"
fallbackText={album.title?.substring(0, 3).toUpperCase() || '♪'}
/>
<div 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">
<div className="w-10 h-10 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</div>
</div>
</div>
<h3 className="font-bold text-sm md:text-base mb-1 truncate">{album.title}</h3>
<p className="text-xs md:text-sm text-[#a7a7a7] truncate">{album.artist}</p>
</div>
</Link>
))}
{/* Playlists */}
{showPlaylists && data.playlists.slice(0, 8).map((playlist) => (
<Link to={`/playlist/${encodeURIComponent(playlist.id)}`} key={playlist.id}>
<div className="bg-[#181818] p-3 md:p-4 rounded-xl hover:bg-[#282828] transition duration-300 group cursor-pointer relative flex flex-col h-full">
<div className="relative mb-3 md:mb-4">
<CoverImage
src={playlist.cover_url}
alt={playlist.title}
className="w-full aspect-square rounded-2xl shadow-lg"
fallbackText={playlist.title?.substring(0, 3).toUpperCase() || '♪'}
/>
<div 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">
<div className="w-10 h-10 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</div>
</div>
</div>
<h3 className="font-bold text-sm md:text-base mb-1 truncate">{playlist.title}</h3>
<p className="text-xs md:text-sm text-[#a7a7a7] truncate">{playlist.track_count} songs</p>
</div>
</Link>
))}
{/* Artists */}
{showArtists && data.artists.slice(0, 8).map((artist) => (
<Link to={`/artist/${encodeURIComponent(artist.name)}`} key={artist.id}>
<div className="bg-[#181818] p-3 md:p-4 rounded-xl hover:bg-[#282828] transition duration-300 group cursor-pointer relative flex flex-col h-full">
<div className="relative mb-3 md:mb-4">
<CoverImage
src={artist.cover_url || artist.photo_url}
alt={artist.name}
className="w-full aspect-square rounded-full shadow-lg"
fallbackText={artist.name?.substring(0, 3).toUpperCase() || '♪'}
/>
<div 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">
<div className="w-10 h-10 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</div>
</div>
</div>
<h3 className="font-bold text-sm md:text-base mb-1 truncate text-center">{artist.name}</h3>
<p className="text-xs md:text-sm text-[#a7a7a7] text-center">Artist</p>
</div>
</Link>
))}
</div>
</div>
);
}

View file

@ -47,7 +47,7 @@ export default function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const handleClearCache = () => { const handleClearCache = () => {
setIsClearingCache(true); setIsClearingCache(true);
// Wipe common caches // Wipe common caches
localStorage.removeItem('ytm_browse_cache_v8'); localStorage.removeItem('ytm_browse_cache_v6');
localStorage.removeItem('ytm_browse_cache_v7'); localStorage.removeItem('ytm_browse_cache_v7');
localStorage.removeItem('artist_photos_cache_v5'); localStorage.removeItem('artist_photos_cache_v5');
localStorage.removeItem('artist_photos_cache_v6'); localStorage.removeItem('artist_photos_cache_v6');

View file

@ -1,4 +1,4 @@
import { Home, Search, Library, Plus, Heart } from "lucide-react"; import { Home, Search, Library, Plus, Heart, Settings } from "lucide-react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { usePlayer } from "../context/PlayerContext"; import { usePlayer } from "../context/PlayerContext";
import { useLibrary } from "../context/LibraryContext"; import { useLibrary } from "../context/LibraryContext";
@ -7,11 +7,14 @@ import CreatePlaylistModal from "./CreatePlaylistModal";
import { dbService } from "../services/db"; import { dbService } from "../services/db";
import Logo from "./Logo"; import Logo from "./Logo";
import CoverImage from "./CoverImage"; import CoverImage from "./CoverImage";
import SettingsModal from "./SettingsModal";
export default function Sidebar() { export default function Sidebar() {
const { likedTracks } = usePlayer(); const { likedTracks } = usePlayer();
const { userPlaylists, libraryItems, refreshLibrary, activeFilter, setActiveFilter } = useLibrary(); const { userPlaylists, libraryItems, refreshLibrary, activeFilter, setActiveFilter } = useLibrary();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const location = useLocation(); const location = useLocation();
const isActive = (path: string) => location.pathname === path; const isActive = (path: string) => location.pathname === path;
@ -118,7 +121,7 @@ export default function Sidebar() {
<CoverImage <CoverImage
src={playlist.cover_url} src={playlist.cover_url}
alt={playlist.title || ''} alt={playlist.title || ''}
className="w-12 h-12 rounded-xl" className="w-12 h-12 rounded"
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-white font-medium truncate">{playlist.title}</h3> <h3 className="text-white font-medium truncate">{playlist.title}</h3>
@ -144,7 +147,7 @@ export default function Sidebar() {
<CoverImage <CoverImage
src={playlist.cover_url} src={playlist.cover_url}
alt={playlist.title || ''} alt={playlist.title || ''}
className="w-12 h-12 rounded-xl" className="w-12 h-12 rounded"
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-white font-medium truncate">{playlist.title}</h3> <h3 className="text-white font-medium truncate">{playlist.title}</h3>
@ -161,7 +164,7 @@ export default function Sidebar() {
<CoverImage <CoverImage
src={artist.cover_url} src={artist.cover_url}
alt={artist.title} alt={artist.title}
className="w-12 h-12 rounded-full object-cover" className="w-12 h-12 rounded-md"
fallbackText={artist.title?.substring(0, 2).toUpperCase()} fallbackText={artist.title?.substring(0, 2).toUpperCase()}
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@ -179,7 +182,7 @@ export default function Sidebar() {
<CoverImage <CoverImage
src={album.cover_url} src={album.cover_url}
alt={album.title} alt={album.title}
className="w-12 h-12 rounded-xl" className="w-12 h-12 rounded"
fallbackText="💿" fallbackText="💿"
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@ -192,11 +195,28 @@ export default function Sidebar() {
</div> </div>
</div> </div>
{/* Settings Section */}
<div className="bg-spotify-card rounded-lg p-2 mt-auto">
<button
onClick={() => setIsSettingsOpen(true)}
className="w-full flex items-center gap-3 p-3 rounded-md transition-all duration-300 text-neutral-400 hover:text-white hover:bg-spotify-card-hover"
title="Settings"
>
<Settings className="w-5 h-5" />
<span className="text-sm font-bold">Settings</span>
</button>
</div>
<CreatePlaylistModal <CreatePlaylistModal
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)} onClose={() => setIsCreateModalOpen(false)}
onCreate={handleCreatePlaylist} onCreate={handleCreatePlaylist}
/> />
<SettingsModal
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
/>
</aside> </aside>
); );
} }

View file

@ -4,7 +4,6 @@ import { libraryService } from '../services/library';
import { usePlayer } from '../context/PlayerContext'; import { usePlayer } from '../context/PlayerContext';
import { Play, Shuffle, Heart, Clock, ListPlus, Download } from 'lucide-react'; import { Play, Shuffle, Heart, Clock, ListPlus, Download } from 'lucide-react';
import { Track } from '../types'; import { Track } from '../types';
import Recommendations from '../components/Recommendations';
export default function Album() { export default function Album() {
const { id } = useParams(); const { id } = useParams();
@ -24,21 +23,7 @@ export default function Album() {
const album = await libraryService.getAlbum(queryId); const album = await libraryService.getAlbum(queryId);
if (album) { if (album) {
// Normalize track IDs - extract YouTube video ID from discovery-* IDs setTracks(album.tracks);
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({ setAlbumInfo({
title: album.title, title: album.title,
artist: album.creator || "Unknown Artist", artist: album.creator || "Unknown Artist",
@ -50,7 +35,7 @@ export default function Album() {
try { try {
const artistQuery = album.creator || "Unknown Artist"; const artistQuery = album.creator || "Unknown Artist";
const suggestions = await libraryService.search(artistQuery); const suggestions = await libraryService.search(artistQuery);
const currentIds = new Set(normalizedTracks.map(t => t.id)); const currentIds = new Set(album.tracks.map(t => t.id));
setMoreByArtist(suggestions.filter(t => !currentIds.has(t.id)).slice(0, 10)); setMoreByArtist(suggestions.filter(t => !currentIds.has(t.id)).slice(0, 10));
} catch (e) { } } catch (e) { }
} else { } else {
@ -98,7 +83,7 @@ export default function Album() {
<div className="relative z-10 flex flex-col md:flex-row gap-4 md:gap-8 p-4 md:p-12 items-center md:items-end pt-16 md:pt-16"> <div className="relative z-10 flex flex-col md:flex-row gap-4 md:gap-8 p-4 md:p-12 items-center md:items-end pt-16 md:pt-16">
{/* Cover */} {/* Cover */}
<div <div
className="w-48 h-48 md:w-64 md:h-64 shadow-[0_20px_50px_rgba(0,0,0,0.5)] rounded-2xl overflow-hidden shrink-0 mt-8 md:mt-0 cursor-pointer group/cover relative" className="w-48 h-48 md:w-64 md:h-64 shadow-[0_20px_50px_rgba(0,0,0,0.5)] rounded-lg overflow-hidden shrink-0 mt-8 md:mt-0 cursor-pointer group/cover relative"
onClick={() => { onClick={() => {
if (tracks.length > 0) { if (tracks.length > 0) {
playTrack(tracks[0], tracks); playTrack(tracks[0], tracks);
@ -207,7 +192,7 @@ export default function Album() {
}} }}
> >
<div className="relative mb-3 md:mb-4"> <div className="relative mb-3 md:mb-4">
<img src={track.cover_url} className="w-full aspect-square rounded-2xl shadow-lg object-cover" /> <img src={track.cover_url} className="w-full aspect-square rounded-md shadow-lg object-cover" />
<div 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"> <div 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">
<div className="w-10 h-10 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105"> <div className="w-10 h-10 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" /> <Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
@ -221,19 +206,6 @@ export default function Album() {
</div> </div>
</div> </div>
)} )}
{/* Related Content Recommendations */}
{albumInfo && (
<Recommendations
seed={albumInfo.artist}
seedType="album"
limit={10}
title="You might also like"
showTracks={true}
showAlbums={true}
showPlaylists={true}
/>
)}
</div> </div>
); );
} }

View file

@ -4,7 +4,6 @@ import { libraryService } from '../services/library';
import { usePlayer } from '../context/PlayerContext'; import { usePlayer } from '../context/PlayerContext';
import { Play, Shuffle, Heart, Disc, Music } from 'lucide-react'; import { Play, Shuffle, Heart, Disc, Music } from 'lucide-react';
import { Track } from '../types'; import { Track } from '../types';
import Recommendations from '../components/Recommendations';
import { GENERATED_CONTENT } from '../data/seed_data'; import { GENERATED_CONTENT } from '../data/seed_data';
interface ArtistData { interface ArtistData {
@ -22,7 +21,6 @@ export default function Artist() {
const [artist, setArtist] = useState<ArtistData | null>(null); const [artist, setArtist] = useState<ArtistData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [songsLoading, setSongsLoading] = useState(true);
// YouTube Music uses name-based IDs or channel IDs. // YouTube Music uses name-based IDs or channel IDs.
// Our 'id' might be a name if clicked from Home. // Our 'id' might be a name if clicked from Home.
@ -54,7 +52,6 @@ export default function Artist() {
} }
const fetchData = async () => { const fetchData = async () => {
setSongsLoading(true);
// Fetch info (Background) // Fetch info (Background)
// If we already have photo from seed, maybe skip or update? // If we already have photo from seed, maybe skip or update?
// libraryService.getArtistInfo might find a better photo or same. // libraryService.getArtistInfo might find a better photo or same.
@ -65,19 +62,14 @@ export default function Artist() {
libraryService.search(artistName) libraryService.search(artistName)
]); ]);
setSongsLoading(false);
const finalPhoto = (info.status === 'fulfilled' && info.value?.photo) ? info.value.photo : seedArtist?.cover_url; 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 : []; let topSongs = (songs.status === 'fulfilled') ? songs.value : [];
if (topSongs.length > 20) topSongs = topSongs.slice(0, 20); if (topSongs.length > 5) topSongs = topSongs.slice(0, 5);
setArtist({ setArtist({
name: artistName, name: artistName,
photo: safePhoto, photo: finalPhoto,
topSongs, topSongs,
albums: [], albums: [],
singles: [] singles: []
@ -139,7 +131,7 @@ export default function Artist() {
<section> <section>
<h2 className="text-2xl font-bold mb-6">Top Songs</h2> <h2 className="text-2xl font-bold mb-6">Top Songs</h2>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{songsLoading ? ( {artist.topSongs.length === 0 ? (
// Skeleton Loading for Songs // Skeleton Loading for Songs
[...Array(5)].map((_, i) => ( [...Array(5)].map((_, i) => (
<div key={i} className="flex items-center p-3 gap-4 animate-pulse"> <div key={i} className="flex items-center p-3 gap-4 animate-pulse">
@ -161,7 +153,7 @@ export default function Artist() {
<span className="w-8 text-center text-neutral-500 font-medium group-hover:hidden">{i + 1}</span> <span className="w-8 text-center text-neutral-500 font-medium group-hover:hidden">{i + 1}</span>
<Play size={16} className="w-8 hidden group-hover:block fill-white" /> <Play size={16} className="w-8 hidden group-hover:block fill-white" />
<img src={track.cover_url} alt="Cover" className="w-12 h-12 rounded-lg mx-4 object-cover" /> <img src={track.cover_url} alt="Cover" className="w-12 h-12 rounded mx-4 object-cover" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-white truncate">{track.title}</div> <div className="font-medium text-white truncate">{track.title}</div>
@ -199,7 +191,7 @@ export default function Artist() {
playTrack(track, [track]); playTrack(track, [track]);
}} }}
> >
<div className="aspect-square bg-neutral-900 rounded-2xl overflow-hidden mb-3 relative"> <div className="aspect-square bg-neutral-900 rounded-lg overflow-hidden mb-3 relative">
<img src={track.cover_url} className="w-full h-full object-cover transition duration-300 group-hover:scale-105" /> <img src={track.cover_url} className="w-full h-full object-cover transition duration-300 group-hover:scale-105" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition flex items-center justify-center"> <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition flex items-center justify-center">
<div className="bg-white text-black p-3 rounded-full hover:scale-110 transition"> <div className="bg-white text-black p-3 rounded-full hover:scale-110 transition">
@ -229,7 +221,7 @@ export default function Artist() {
playTrack(track, [track]); playTrack(track, [track]);
}} }}
> >
<div className="aspect-square bg-neutral-900 rounded-2xl overflow-hidden mb-3 relative border-2 border-neutral-800"> <div className="aspect-square bg-neutral-900 rounded-xl overflow-hidden mb-3 relative border-2 border-neutral-800">
<img src={track.cover_url} className="w-full h-full object-cover transition duration-300 group-hover:scale-105" /> <img src={track.cover_url} className="w-full h-full object-cover transition duration-300 group-hover:scale-105" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition flex items-center justify-center"> <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition flex items-center justify-center">
<div className="bg-white text-black p-3 rounded-full hover:scale-110 transition"> <div className="bg-white text-black p-3 rounded-full hover:scale-110 transition">
@ -246,18 +238,6 @@ export default function Artist() {
))} ))}
</div> </div>
</section> </section>
{/* Related Artists & Content */}
<Recommendations
seed={artist.name}
seedType="artist"
limit={20}
title="Fans also like"
showTracks={true}
showAlbums={true}
showPlaylists={true}
showArtists={true}
/>
</div> </div>
</div> </div>
); );

View file

@ -25,20 +25,19 @@ export default function Home() {
else setTimeOfDay("Good evening"); else setTimeOfDay("Good evening");
// Cache First Strategy for "Super Fast" loading // Cache First Strategy for "Super Fast" loading
const cached = localStorage.getItem('ytm_browse_cache_v8'); const cached = localStorage.getItem('ytm_browse_cache_v6');
if (cached) { if (cached) {
setBrowseData(JSON.parse(cached)); setBrowseData(JSON.parse(cached));
setLoading(false); setLoading(false);
} }
const fetchBrowseData = () => {
setLoading(true); setLoading(true);
libraryService.getBrowseContent() libraryService.getBrowseContent()
.then(data => { .then(data => {
setBrowseData(data); setBrowseData(data);
setLoading(false); setLoading(false);
// Update Cache // Update Cache
localStorage.setItem('ytm_browse_cache_v8', JSON.stringify(data)); localStorage.setItem('ytm_browse_cache_v6', JSON.stringify(data));
// Pick a random playlist for the hero section // Pick a random playlist for the hero section
const allPlaylists = Object.values(data).flat().filter(p => p.type === 'Playlist'); const allPlaylists = Object.values(data).flat().filter(p => p.type === 'Playlist');
@ -51,14 +50,6 @@ export default function Home() {
console.error("Error fetching browse:", err); console.error("Error fetching browse:", err);
setLoading(false); setLoading(false);
}); });
};
fetchBrowseData();
// Auto-refresh every 5 minutes
const refreshInterval = setInterval(fetchBrowseData, 300000);
return () => clearInterval(refreshInterval);
}, []); }, []);
const sortPlaylists = (playlists: StaticPlaylist[]) => { const sortPlaylists = (playlists: StaticPlaylist[]) => {
@ -192,7 +183,7 @@ export default function Home() {
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold capitalize hover:underline cursor-pointer">Top Albums</h2> <h2 className="text-2xl font-bold capitalize hover:underline cursor-pointer">Top Albums</h2>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1 md:gap-2"> <div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-4">
{uniqueAlbums.slice(0, 15).map((album) => ( {uniqueAlbums.slice(0, 15).map((album) => (
<Link to={`/album/${album.id}`} key={album.id}> <Link to={`/album/${album.id}`} key={album.id}>
<div className="bg-transparent md:bg-spotify-card p-0 md:p-3 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col"> <div className="bg-transparent md:bg-spotify-card p-0 md:p-3 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col">
@ -200,7 +191,7 @@ export default function Home() {
<CoverImage <CoverImage
src={album.cover_url} src={album.cover_url}
alt={album.title} alt={album.title}
className="w-full aspect-square rounded-2xl shadow-lg" className="w-full aspect-square rounded-xl shadow-lg"
fallbackText={album.title?.substring(0, 2).toUpperCase()} fallbackText={album.title?.substring(0, 2).toUpperCase()}
/> />
<div <div
@ -264,7 +255,7 @@ export default function Home() {
</div> </div>
{/* USER REQUEST: Bigger Grid, Smaller Text, Smaller Gap */} {/* USER REQUEST: Bigger Grid, Smaller Text, Smaller Gap */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1 md:gap-2"> <div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-6">
{sortPlaylists(uniquePlaylists).slice(0, 15).map((playlist) => ( {sortPlaylists(uniquePlaylists).slice(0, 15).map((playlist) => (
<Link to={`/playlist/${playlist.id}`} key={playlist.id}> <Link to={`/playlist/${playlist.id}`} key={playlist.id}>
<div 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"> <div 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">
@ -272,7 +263,7 @@ export default function Home() {
<CoverImage <CoverImage
src={playlist.cover_url} src={playlist.cover_url}
alt={playlist.title} alt={playlist.title}
className="w-full aspect-square rounded-2xl shadow-lg" className="w-full aspect-square rounded-xl shadow-lg"
fallbackText={playlist.title?.substring(0, 2).toUpperCase()} fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
/> />
<div <div
@ -332,7 +323,7 @@ function RecentlyListenedSection({ playHistory, playTrack }: { playHistory: Trac
<CoverImage <CoverImage
src={track.cover_url} src={track.cover_url}
alt={track.title} alt={track.title}
className="w-40 h-40 rounded-2xl" className="w-40 h-40"
fallbackText={track.title?.substring(0, 2).toUpperCase()} fallbackText={track.title?.substring(0, 2).toUpperCase()}
/> />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition flex items-center justify-center"> <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition flex items-center justify-center">
@ -398,7 +389,7 @@ function MadeForYouSection() {
))} ))}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1 md:gap-2"> <div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-6">
{recommendations.slice(0, 10).map((track, i) => ( {recommendations.slice(0, 10).map((track, i) => (
<div key={i} onClick={() => { <div key={i} onClick={() => {
playTrack(track, recommendations); playTrack(track, recommendations);
@ -407,7 +398,7 @@ function MadeForYouSection() {
<CoverImage <CoverImage
src={track.cover_url} src={track.cover_url}
alt={track.title} alt={track.title}
className="w-full aspect-square rounded-2xl shadow-lg" className="w-full aspect-square rounded-xl shadow-lg"
fallbackText={track.title?.substring(0, 2).toUpperCase()} fallbackText={track.title?.substring(0, 2).toUpperCase()}
/> />
<div 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"> <div 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">
@ -470,46 +461,31 @@ function ArtistVietnamSection() {
const cacheKey = 'artist_photos_cache_v6'; const cacheKey = 'artist_photos_cache_v6';
const cached = JSON.parse(localStorage.getItem(cacheKey) || '{}'); const cached = JSON.parse(localStorage.getItem(cacheKey) || '{}');
// Apply cache to state first // Initialize with cache immediately
setArtistPhotos(cached); setArtistPhotos(cached);
setLoading(false); // Show names immediately
// Identify missing photos // Identify missing photos
const missing = targetArtists.filter(name => !cached[name]); const missing = targetArtists.filter(name => !cached[name]);
if (missing.length > 0) { if (missing.length > 0) {
// Fetch all missing photos in parallel // Fetch missing incrementally
const fetchPromises = missing.map(async (name) => { for (const name of missing) {
try { try {
const data = await libraryService.getArtistInfo(name); // Fetch one by one and update state immediately
// This prevents "batch waiting" feeling
libraryService.getArtistInfo(name).then(data => {
if (data.photo) { 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<string, string> = {};
results.forEach(result => {
if (result) {
updates[result.name] = result.photo;
}
});
if (Object.keys(updates).length > 0) {
setArtistPhotos(prev => { setArtistPhotos(prev => {
const next: Record<string, string> = { ...prev, ...updates }; const next: Record<string, string> = { ...prev, [name]: data.photo || "" };
localStorage.setItem(cacheKey, JSON.stringify(next)); localStorage.setItem(cacheKey, JSON.stringify(next));
return next; return next;
}); });
} }
});
} catch { /* ignore */ }
}
} }
// Only set loading false AFTER all photos are loaded
setLoading(false);
}; };
loadPhotos(); loadPhotos();
@ -523,11 +499,11 @@ function ArtistVietnamSection() {
</div> </div>
<p className="text-sm text-[#a7a7a7] mb-4">Based on your recent listening</p> <p className="text-sm text-[#a7a7a7] mb-4">Based on your recent listening</p>
<div className="flex gap-3 overflow-x-auto pb-4 no-scrollbar"> <div className="flex gap-4 overflow-x-auto pb-4 no-scrollbar">
{artists.length === 0 && loading ? ( {artists.length === 0 && loading ? (
[1, 2, 3, 4, 5, 6].map(i => ( [1, 2, 3, 4, 5, 6].map(i => (
<div key={i} className="flex-shrink-0 w-36 text-center space-y-3"> <div key={i} className="flex-shrink-0 w-36 text-center space-y-3">
<Skeleton className="w-36 h-36 rounded-full" /> <Skeleton className="w-36 h-36 rounded-xl" />
<Skeleton className="h-4 w-3/4 mx-auto" /> <Skeleton className="h-4 w-3/4 mx-auto" />
</div> </div>
)) ))
@ -539,12 +515,12 @@ function ArtistVietnamSection() {
<CoverImage <CoverImage
src={artistPhotos[name]} src={artistPhotos[name]}
alt={name} alt={name}
className="w-36 h-36 rounded-full shadow-lg group-hover:shadow-xl transition object-cover" className="w-36 h-36 rounded-xl shadow-lg group-hover:shadow-xl transition object-cover"
fallbackText={name.substring(0, 2).toUpperCase()} fallbackText={name.substring(0, 2).toUpperCase()}
/> />
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition rounded-full flex items-center justify-center"> <div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition rounded-xl flex items-center justify-center">
<div className="w-10 h-10 bg-[#1DB954] rounded-full flex items-center justify-center shadow-lg transform scale-90 group-hover:scale-100 transition"> <div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center shadow-lg transform scale-90 group-hover:scale-100 transition">
<Play className="fill-black text-black ml-0.5 w-4 h-4" /> <Play className="fill-black text-black ml-1 w-5 h-5" />
</div> </div>
</div> </div>
</div> </div>

View file

@ -144,7 +144,7 @@ export default function Library() {
<CoverImage <CoverImage
src={playlist.cover_url} src={playlist.cover_url}
alt={playlist.title} alt={playlist.title}
className="w-full aspect-square rounded-2xl shadow-lg" className="w-full aspect-square rounded-xl shadow-lg"
fallbackText={playlist.title?.substring(0, 2).toUpperCase()} fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
/> />
<div className="absolute bottom-2 right-2 translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition"> <div className="absolute bottom-2 right-2 translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition">
@ -187,7 +187,7 @@ export default function Library() {
<CoverImage <CoverImage
src={item.cover_url} src={item.cover_url}
alt={item.title} alt={item.title}
className={`w-full aspect-square shadow-lg rounded-2xl`} className={`w-full aspect-square shadow-lg rounded-xl`}
fallbackText={item.title?.substring(0, 2).toUpperCase()} fallbackText={item.title?.substring(0, 2).toUpperCase()}
/> />
<div className="absolute bottom-2 right-2 translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition"> <div className="absolute bottom-2 right-2 translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition">

View file

@ -9,7 +9,6 @@ import { Track, StaticPlaylist } from '../types';
import CoverImage from '../components/CoverImage'; import CoverImage from '../components/CoverImage';
import AddToPlaylistModal from '../components/AddToPlaylistModal'; import AddToPlaylistModal from '../components/AddToPlaylistModal';
import Skeleton from '../components/Skeleton'; import Skeleton from '../components/Skeleton';
import Recommendations from '../components/Recommendations';
import { GENERATED_CONTENT } from '../data/seed_data'; import { GENERATED_CONTENT } from '../data/seed_data';
type PlaylistData = PlaylistType | StaticPlaylist; type PlaylistData = PlaylistType | StaticPlaylist;
@ -88,24 +87,7 @@ export default function Playlist() {
console.log("Fetching from Library Service (Hydrating)..."); console.log("Fetching from Library Service (Hydrating)...");
const apiPlaylist = await libraryService.getPlaylist(playlistId); const apiPlaylist = await libraryService.getPlaylist(playlistId);
if (apiPlaylist && apiPlaylist.tracks.length > 0) { if (apiPlaylist && apiPlaylist.tracks.length > 0) {
// Normalize track IDs - extract YouTube video ID from discovery-* IDs setPlaylist(apiPlaylist);
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); setIsUserPlaylist(false);
setLoading(false); setLoading(false);
@ -113,7 +95,7 @@ export default function Playlist() {
try { try {
const query = apiPlaylist.title.replace(' Mix', ''); const query = apiPlaylist.title.replace(' Mix', '');
const recs = await libraryService.search(query); const recs = await libraryService.search(query);
const currentIds = new Set(normalizedTracks.map((t: Track) => t.id)); const currentIds = new Set(apiPlaylist.tracks.map(t => t.id));
setMoreLikeThis(recs.filter(t => !currentIds.has(t.id)).slice(0, 10)); setMoreLikeThis(recs.filter(t => !currentIds.has(t.id)).slice(0, 10));
} catch (e) { } } catch (e) { }
} else { } else {
@ -204,7 +186,7 @@ export default function Playlist() {
<ArrowLeft className="w-6 h-6" /> <ArrowLeft className="w-6 h-6" />
</Link> </Link>
<div <div
className="w-48 h-48 md:w-64 md:h-64 shadow-[0_20px_50px_rgba(0,0,0,0.5)] rounded-2xl overflow-hidden shrink-0 mt-8 md:mt-0 cursor-pointer group/cover relative" className="w-48 h-48 md:w-64 md:h-64 shadow-[0_20px_50px_rgba(0,0,0,0.5)] rounded-lg overflow-hidden shrink-0 mt-8 md:mt-0 cursor-pointer group/cover relative"
onClick={() => { onClick={() => {
if (playlist && playlist.tracks.length > 0) { if (playlist && playlist.tracks.length > 0) {
playTrack(playlist.tracks[0], playlist.tracks); playTrack(playlist.tracks[0], playlist.tracks);
@ -324,7 +306,7 @@ export default function Playlist() {
<CoverImage <CoverImage
src={track.cover_url} src={track.cover_url}
alt={track.title} alt={track.title}
className="w-10 h-10 rounded-lg flex-shrink-0" className="w-10 h-10 rounded flex-shrink-0"
fallbackText="♪" fallbackText="♪"
/> />
<div className="min-w-0"> <div className="min-w-0">
@ -384,7 +366,7 @@ export default function Playlist() {
}} }}
> >
<div className="relative mb-3 md:mb-4"> <div className="relative mb-3 md:mb-4">
<img src={track.cover_url} className="w-full aspect-square rounded-2xl shadow-lg object-cover" /> <img src={track.cover_url} className="w-full aspect-square rounded-md shadow-lg object-cover" />
<div 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"> <div 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">
<div className="w-10 h-10 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105"> <div className="w-10 h-10 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" /> <Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
@ -399,19 +381,6 @@ export default function Playlist() {
</div> </div>
)} )}
{/* Related Content Recommendations */}
{playlist && (
<Recommendations
seed={playlist.title}
seedType="playlist"
limit={10}
title="Related Playlists & Songs"
showTracks={true}
showAlbums={true}
showPlaylists={true}
/>
)}
{/* Add to Playlist Modal */} {/* Add to Playlist Modal */}
{selectedTrack && ( {selectedTrack && (
<AddToPlaylistModal <AddToPlaylistModal

View file

@ -183,7 +183,7 @@ export default function Search() {
{relatedAlbums.map(album => ( {relatedAlbums.map(album => (
<Link to={`/album/search-${encodeURIComponent(album.name)}`} key={album.id} className="flex-shrink-0 w-32 md:w-40 group"> <Link to={`/album/search-${encodeURIComponent(album.name)}`} key={album.id} className="flex-shrink-0 w-32 md:w-40 group">
<div className="relative mb-2"> <div className="relative mb-2">
<CoverImage src={album.image} alt={album.name} className="w-32 h-32 md:w-40 md:h-40 rounded-2xl shadow-lg object-cover group-hover:shadow-xl transition" fallbackText={album.name[0]} /> <CoverImage src={album.image} alt={album.name} className="w-32 h-32 md:w-40 md:h-40 rounded-md shadow-lg object-cover group-hover:shadow-xl transition" fallbackText={album.name[0]} />
</div> </div>
<p className="font-bold truncate text-[11px] md:text-base">{album.name}</p> <p className="font-bold truncate text-[11px] md:text-base">{album.name}</p>
<p className="text-[10px] md:text-sm text-[#a7a7a7]">Album</p> <p className="text-[10px] md:text-sm text-[#a7a7a7]">Album</p>
@ -207,7 +207,7 @@ export default function Search() {
<div className="w-8 hidden group-hover:flex items-center justify-center text-white"> <div className="w-8 hidden group-hover:flex items-center justify-center text-white">
<Play className="w-4 h-4 fill-current" /> <Play className="w-4 h-4 fill-current" />
</div> </div>
<CoverImage src={track.cover_url} alt={track.title} className="w-10 h-10 rounded-lg" fallbackText="♪" /> <CoverImage src={track.cover_url} alt={track.title} className="w-10 h-10 rounded" fallbackText="♪" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.title}</p> <p className="font-medium truncate">{track.title}</p>
<p className="text-sm text-neutral-400 truncate">{track.artist}</p> <p className="text-sm text-neutral-400 truncate">{track.artist}</p>

View file

@ -57,7 +57,7 @@ export default function Section() {
<CoverImage <CoverImage
src={playlist.cover_url} src={playlist.cover_url}
alt={playlist.title} alt={playlist.title}
className="w-full aspect-square rounded-2xl shadow-lg" className="w-full aspect-square rounded-md shadow-lg"
fallbackText={playlist.title?.substring(0, 2).toUpperCase()} fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
/> />
<div 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"> <div 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">

View file

@ -27,20 +27,7 @@ export const libraryService = {
async search(query: string): Promise<Track[]> { async search(query: string): Promise<Track[]> {
const data = await apiFetch(`/search?q=${encodeURIComponent(query)}`); const data = await apiFetch(`/search?q=${encodeURIComponent(query)}`);
if (data?.tracks && data.tracks.length > 0) { if (data?.tracks && data.tracks.length > 0) {
// Normalize track IDs - extract YouTube video ID from discovery-* IDs return data.tracks;
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 []; return [];
}, },
@ -91,36 +78,6 @@ export const libraryService = {
return tracks.slice(0, 20); 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<StaticPlaylist | null> { async getPlaylist(id: string): Promise<StaticPlaylist | null> {
// 1. Try to find in GENERATED_CONTENT first (Fast/Instant) // 1. Try to find in GENERATED_CONTENT first (Fast/Instant)
const found = Object.values(GENERATED_CONTENT).find(p => p.id === id); const found = Object.values(GENERATED_CONTENT).find(p => p.id === id);
@ -236,33 +193,25 @@ export const libraryService = {
}, },
async getArtistInfo(artistName: string): Promise<{ bio?: string; photo?: string }> { async getArtistInfo(artistName: string): Promise<{ bio?: string; photo?: string }> {
// Method 1: Try backend API for real YouTube channel photo // Try specific API for image
try { try {
const controller = new AbortController(); const res = await apiFetch(`/artist/info?q=${encodeURIComponent(artistName)}`);
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout if (res && res.image) {
return { photo: res.image };
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) { } catch (e) {
console.error(`[ArtistInfo] Error for ${artistName}:`, e); // fall through
// Fall through to next method
} }
// Method 2: Use UI-Avatars API (instant, always works) // Fallback to track cover
// Using smaller size (128) for faster loading try {
const encodedName = encodeURIComponent(artistName); const tracks = await this.search(artistName);
const avatarUrl = `https://ui-avatars.com/api/?name=${encodedName}&background=random&color=fff&size=128&rounded=true&bold=true&font-size=0.33`; if (tracks.length > 0 && tracks[0]?.cover_url) {
return { photo: avatarUrl }; return { photo: tracks[0].cover_url };
}
} catch (e) { }
return { photo: getUnsplashImage(artistName) };
}, },
async getLyrics(track: string, artist: string): Promise<{ plainLyrics?: string; syncedLyrics?: string; } | null> { async getLyrics(track: string, artist: string): Promise<{ plainLyrics?: string; syncedLyrics?: string; } | null> {

17
restart_app.bat Normal file
View file

@ -0,0 +1,17 @@
@echo off
echo Stopping existing processes...
taskkill /F /IM server.exe >nul 2>&1
taskkill /F /IM node.exe >nul 2>&1
echo Starting Backend...
cd backend-go
start /B server.exe
cd ..
echo Starting Frontend...
cd frontend-vite
start npm run dev
cd ..
echo App Restarted! Backend on :8080, Frontend on :5173

View file

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

BIN
yt-dlp.exe Normal file

Binary file not shown.