Compare commits

...

2 commits

Author SHA1 Message Date
Khoa Vo
7fe5b955e8 Resolve conflicts: keep Rust backend version 2026-03-20 21:22:45 +07:00
Khoa Vo
36e18a3609 Migrate from Go to Rust backend, add auto-refresh, fix playback issues 2026-03-20 21:21:44 +07:00
29 changed files with 1119 additions and 1744 deletions

7
.gitignore vendored
View file

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

View file

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

133
README.md
View file

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

1408
backend-rust/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,16 +1,13 @@
use axum::{
extract::{Path, Query, State},
http::{header, StatusCode},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::fs::File;
use tokio_util::io::ReaderStream;
use crate::spotdl::SpotdlService;
use crate::models::{Playlist, Track};
pub struct AppState {
pub spotdl: SpotdlService,
@ -65,11 +62,27 @@ pub async fn artist_info_handler(
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Artist name required"})));
}
match state.spotdl.search_artist(query) {
Ok(img) => (StatusCode::OK, Json(serde_json::json!({"image": img}))),
Err(e) => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": e}))),
// Check cache first
{
let cache = state.spotdl.search_cache.read().await;
if let Some(cached) = cache.get(query) {
if let Some(track) = cached.tracks.first() {
if !track.cover_url.is_empty() {
return (StatusCode::OK, Json(serde_json::json!({"image": track.cover_url})));
}
}
}
}
// Return placeholder image immediately - no yt-dlp needed
// Using UI-Avatars for professional-looking artist initials
let image_url = format!(
"https://ui-avatars.com/api/?name={}&background=random&color=fff&size=200&rounded=true&bold=true&font-size=0.33",
urlencoding::encode(&query)
);
(StatusCode::OK, Json(serde_json::json!({"image": image_url})))
}
pub async fn browse_handler(
State(state): State<Arc<AppState>>,
@ -80,3 +93,82 @@ pub async fn browse_handler(
// we can return empty or a small default. The frontend will handle it.
(StatusCode::OK, Json(cache.clone()))
}
#[derive(Deserialize)]
pub struct RecommendationsQuery {
pub seed: String,
#[serde(default)]
pub seed_type: String, // "track", "album", "playlist", "artist"
#[serde(default = "default_limit")]
pub limit: usize,
}
fn default_limit() -> usize {
10
}
#[derive(Serialize)]
pub struct Recommendations {
pub tracks: Vec<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 @@
mod api;
mod models;
pub mod api;
pub mod models;
mod spotdl;
use axum::{
@ -9,7 +9,6 @@ use axum::{
use std::net::SocketAddr;
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;
use crate::api::AppState;
use crate::spotdl::SpotdlService;
@ -31,7 +30,7 @@ async fn main() {
.route("/api/stream/{id}", get(api::stream_handler))
.route("/api/artist/info", get(api::artist_info_handler))
.route("/api/browse", get(api::browse_handler))
.nest_service("/", ServeDir::new("static"))
.route("/api/recommendations", get(api::recommendations_handler))
.layer(cors)
.with_state(app_state);

View file

@ -6,18 +6,19 @@ use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::HashMap;
use std::time::{Instant, Duration};
use futures::future::join_all;
use crate::models::{Track, YTResult, StaticPlaylist};
struct CacheItem {
tracks: Vec<Track>,
timestamp: Instant,
pub struct CacheItem {
pub tracks: Vec<Track>,
pub timestamp: Instant,
}
#[derive(Clone)]
pub struct SpotdlService {
download_dir: PathBuf,
search_cache: Arc<RwLock<HashMap<String, CacheItem>>>,
pub search_cache: Arc<RwLock<HashMap<String, CacheItem>>>,
pub browse_cache: Arc<RwLock<HashMap<String, Vec<StaticPlaylist>>>>,
}
@ -27,6 +28,9 @@ impl SpotdlService {
let download_dir = temp_dir.join("spotify-clone-cache");
let _ = fs::create_dir_all(&download_dir);
// Ensure node is in PATH for yt-dlp
let _ = Self::js_runtime_args();
Self {
download_dir,
search_cache: Arc::new(RwLock::new(HashMap::new())),
@ -34,44 +38,78 @@ 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 {
// Try local
if let Ok(exe_path) = env::current_exe() {
if let Some(dir) = exe_path.parent() {
let local = dir.join("yt-dlp.exe");
if local.exists() {
return local.to_string_lossy().into_owned();
}
}
// Use the updated binary we downloaded
let updated_path = "/tmp/yt-dlp";
if Path::new(updated_path).exists() {
return updated_path.to_string();
}
// Try working dir
if Path::new("yt-dlp.exe").exists() {
return "./yt-dlp.exe".to_string();
// Windows: Check user Scripts folder
if cfg!(windows) {
if let Ok(home) = env::var("APPDATA") {
let win_path = Path::new(&home).join("Python").join("Python312").join("Scripts").join("yt-dlp.exe");
if win_path.exists() {
return win_path.to_string_lossy().into_owned();
}
// 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()
}
fn js_runtime_args() -> Vec<String> {
Vec::new()
}
pub fn start_background_preload(&self) {
let cache_arc = self.browse_cache.clone();
let refresh_cache = self.browse_cache.clone();
tokio::spawn(async move {
println!("Background preloader started... fetching Top Albums & Playlists");
Self::fetch_browse_content(&cache_arc).await;
});
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(300)).await;
println!("Periodic refresh: updating browse content...");
Self::fetch_browse_content(&refresh_cache).await;
}
});
}
async fn fetch_browse_content(cache_arc: &Arc<RwLock<HashMap<String, Vec<StaticPlaylist>>>>) {
let queries = vec![
("Top Albums", "ytsearch50:Top Albums Vietnam audio"),
("Viral Hits", "ytsearch30:Viral Hits Vietnam audio"),
("Lofi Chill", "ytsearch30:Lofi Chill Vietnam audio"),
("Viral Hits Vietnam", "ytsearch30:Viral Hits Vietnam audio"),
("Lofi Chill Vietnam", "ytsearch30:Lofi Chill Vietnam audio"),
("US UK Top Hits", "ytsearch30:US UK Billboard Hot 100 audio"),
("K-Pop", "ytsearch30:K-Pop Top Hits audio"),
("K-Pop ON!", "ytsearch30:K-Pop Top Hits audio"),
("Rap Viet", "ytsearch30:Rap Viet Mix audio"),
("Indie Vietnam", "ytsearch30:Indie Vietnam audio"),
("V-Pop Rising", "ytsearch30:V-Pop Rising audio"),
("Trending Music", "ytsearch30:Trending Music 2024 audio"),
("Acoustic Thu Gian", "ytsearch30:Acoustic Thu Gian audio"),
("Workout Energy", "ytsearch30:Workout Energy Mix audio"),
("Sleep Sounds", "ytsearch30:Sleep Sounds music audio"),
("Party Anthems", "ytsearch30:Party Anthems Mix audio"),
("Piano Focus", "ytsearch30:Piano Focus music audio"),
("Gaming Music", "ytsearch30:Gaming Music Mix audio"),
];
let path = Self::yt_dlp_path();
@ -79,7 +117,7 @@ impl SpotdlService {
for (category, search_query) in queries {
let output = Command::new(&path)
.args(&[&search_query, "--dump-json", "--no-playlist", "--flat-playlist"])
.args(&["--js-runtimes", "node", &search_query, "--dump-json", "--no-playlist", "--flat-playlist"])
.output();
if let Ok(o) = output {
@ -125,9 +163,9 @@ impl SpotdlService {
}
// Also load artists
let artists_query = "ytsearch30:V-Pop Official Channel";
let artists_query = "ytmusicsearch30:V-Pop Official Channel";
if let Ok(o) = Command::new(&path)
.args(&[&artists_query, "--dump-json", "--flat-playlist"])
.args(&["--js-runtimes", "node", &artists_query, "--dump-json", "--flat-playlist"])
.output() {
let mut items = Vec::new();
for line in String::from_utf8_lossy(&o.stdout).lines() {
@ -156,7 +194,6 @@ impl SpotdlService {
println!("Background preloader finished loading {} categories!", all_data.len());
let mut cache = cache_arc.write().await;
*cache = all_data;
});
}
pub async fn search_tracks(&self, query: &str) -> Result<Vec<Track>, String> {
@ -175,7 +212,7 @@ impl SpotdlService {
let search_query = format!("ytsearch20:{} audio", query);
let output = match Command::new(&path)
.args(&[&search_query, "--dump-json", "--no-playlist", "--flat-playlist"])
.args(&["--js-runtimes", "node", &search_query, "--dump-json", "--no-playlist", "--flat-playlist"])
.output() {
Ok(o) => o,
Err(e) => return Err(format!("Failed to execute yt-dlp: {}", e)),
@ -281,14 +318,19 @@ impl SpotdlService {
let output = match Command::new(Self::yt_dlp_path())
.current_dir(&self.download_dir)
.args(&["-f", "bestaudio[ext=m4a]/bestaudio", "--output", &format!("{}.%(ext)s", video_id), &target_url])
.args(&["--js-runtimes", "node", "-f", "bestaudio/best", "--output", &format!("{}.%(ext)s", video_id), &target_url])
.output() {
Ok(o) => o,
Err(e) => return Err(format!("Download spawn failed: {}", e)),
Err(e) => {
println!("[Stream] yt-dlp spawn error: {}", e);
return Err(format!("Download spawn failed: {}", e));
}
};
if !output.status.success() {
return Err(format!("Download failed. stderr: {}", String::from_utf8_lossy(&output.stderr)));
let stderr = String::from_utf8_lossy(&output.stderr);
println!("[Stream] yt-dlp download failed: {}", stderr);
return Err(format!("Download failed. stderr: {}", stderr));
}
// Find downloaded file again
@ -305,57 +347,268 @@ impl SpotdlService {
Err("File not found after download".to_string())
}
pub fn search_artist(&self, query: &str) -> Result<String, String> {
pub async 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 search_query = format!("ytsearch5:{} artist", query);
// Search specifically for official channel to get the avatar
let search_query = format!("ytsearch1:{} official channel", query);
let output = match Command::new(&path)
let output = Command::new(&path)
.args(&[&search_query, "--dump-json", "--flat-playlist"])
.output() {
Ok(o) => o,
Err(_) => return Err("Search failed to execute".to_string()),
};
let stdout = String::from_utf8_lossy(&output.stdout);
#[derive(serde::Deserialize)]
struct SimpleYT {
id: String,
#[serde(default)]
thumbnails: Vec<crate::models::YTThumbnail>,
}
.output();
if let Ok(o) = output {
let stdout = String::from_utf8_lossy(&o.stdout);
for line in stdout.lines() {
if let Ok(res) = serde_json::from_str::<SimpleYT>(line) {
// If it's a channel (starts with UC), use its avatar
if res.id.starts_with("UC") {
let best_thumb = res.thumbnails.iter().max_by_key(|t| {
let w = t.width.unwrap_or(0);
let h = t.height.unwrap_or(0);
w * h
});
if let Some(thumb) = best_thumb {
return Ok(thumb.url.clone());
if line.trim().is_empty() {
continue;
}
if let Ok(res) = serde_json::from_str::<YTResult>(line) {
// Get the video thumbnail which often has the artist
if let Some(thumb) = res.thumbnails.last() {
if !thumb.url.is_empty() {
// Convert to higher quality thumbnail
let high_quality = thumb.url.replace("hqdefault", "maxresdefault");
return Ok(high_quality);
}
}
}
}
}
// Fallback: If no channel found, try searching normally but stay alert for channel icons
Err("No authentic channel photo found for artist".to_string())
// Fallback to placeholder if no real photo found
Ok(self.get_placeholder_image(query))
}
fn extract_id(url: &str) -> String {
// If URL contains v= parameter, extract from there first
if url.contains("v=") {
let parts: Vec<&str> = url.split("v=").collect();
if parts.len() > 1 {
let sub_parts: Vec<&str> = parts[1].split('&').collect();
return sub_parts[0].to_string();
let video_part = parts[1].split('&').next().unwrap_or("");
// Check if the extracted part is a discovery ID
if video_part.starts_with("discovery-") || video_part.starts_with("artist-") {
// Extract actual video ID from the discovery ID
let sub_parts: Vec<&str> = video_part.split('-').collect();
// Look for the last part that looks like a YouTube video ID (11 chars)
for part in sub_parts.iter().rev() {
if part.len() == 11 && part.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
return part.to_string();
}
}
// If no 11-char ID found, return the last part
if let Some(last_part) = sub_parts.last() {
return last_part.to_string();
}
}
return video_part.to_string();
}
}
// Handle discovery-album-* format IDs (frontend sends full ID, video ID is at end)
if url.starts_with("discovery-") || url.starts_with("artist-") {
// Video ID is the last segment that matches YouTube video ID format
// It could be 11 chars (e.g., "abc123ABC45") or could be split
let parts: Vec<&str> = url.split('-').collect();
// First, try to find a single 11-char YouTube ID
for part in parts.iter().rev() {
if part.len() == 11 && part.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
return part.to_string();
}
}
// If not found, try combining last two parts (in case ID was split)
if parts.len() >= 2 {
let last = parts.last().unwrap();
let second_last = parts.get(parts.len() - 2).unwrap();
let combined = format!("{}-{}", second_last, last);
if combined.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
return combined;
}
}
// Fallback: just use the last part
if let Some(last_part) = parts.last() {
return last_part.to_string();
}
}
url.to_string()
}
pub async fn get_recommendations(
&self,
seed: &str,
seed_type: &str,
limit: usize,
) -> Result<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

@ -0,0 +1,5 @@
{"_type": "url", "ie_key": "Youtube", "id": "Hqmbo0ROBQw", "url": "https://www.youtube.com/watch?v=Hqmbo0ROBQw", "title": "\u0110en - V\u1ecb nh\u00e0 (M/V)", "description": null, "duration": 315.0, "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "uploader": "\u0110en V\u00e2u Official", "uploader_id": null, "uploader_url": null, "thumbnails": [{"url": "https://i.ytimg.com/vi/Hqmbo0ROBQw/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLD7cv1L-whoLTjjprPvA6HecCOhOQ", "height": 202, "width": 360}, {"url": "https://i.ytimg.com/vi/Hqmbo0ROBQw/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA9lSyi9M9k24HQhgobFPMEgQEKVA", "height": 404, "width": 720}], "timestamp": null, "release_timestamp": null, "availability": null, "view_count": 22853006, "live_status": null, "channel_is_verified": true, "__x_forwarded_for_ip": null, "webpage_url": "https://www.youtube.com/watch?v=Hqmbo0ROBQw", "original_url": "https://www.youtube.com/watch?v=Hqmbo0ROBQw", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube", "extractor_key": "Youtube", "playlist_count": 5, "playlist": "\u0110en V\u00e2u channel", "playlist_id": "\u0110en V\u00e2u channel", "playlist_title": "\u0110en V\u00e2u channel", "playlist_uploader": null, "playlist_uploader_id": null, "playlist_channel": null, "playlist_channel_id": null, "playlist_webpage_url": "ytsearch5:\u0110en V\u00e2u channel", "n_entries": 5, "playlist_index": 1, "__last_playlist_index": 5, "playlist_autonumber": 1, "epoch": 1773501405, "duration_string": "5:15", "release_year": null, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}}
{"_type": "url", "ie_key": "Youtube", "id": "vTJdVE_gjI0", "url": "https://www.youtube.com/watch?v=vTJdVE_gjI0", "title": "\u0110en x JustaTee - \u0110i V\u1ec1 Nh\u00e0 (M/V)", "description": null, "duration": 206.0, "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "uploader": "\u0110en V\u00e2u Official", "uploader_id": null, "uploader_url": null, "thumbnails": [{"url": "https://i.ytimg.com/vi/vTJdVE_gjI0/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAIqVJmdwcWnFH1AESeFj1vXy6FIg", "height": 202, "width": 360}, {"url": "https://i.ytimg.com/vi/vTJdVE_gjI0/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBuK37jXALeyRM5CgZ_iBbpCu-0Ww", "height": 404, "width": 720}], "timestamp": null, "release_timestamp": null, "availability": null, "view_count": 205403243, "live_status": null, "channel_is_verified": true, "__x_forwarded_for_ip": null, "webpage_url": "https://www.youtube.com/watch?v=vTJdVE_gjI0", "original_url": "https://www.youtube.com/watch?v=vTJdVE_gjI0", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube", "extractor_key": "Youtube", "playlist_count": 5, "playlist": "\u0110en V\u00e2u channel", "playlist_id": "\u0110en V\u00e2u channel", "playlist_title": "\u0110en V\u00e2u channel", "playlist_uploader": null, "playlist_uploader_id": null, "playlist_channel": null, "playlist_channel_id": null, "playlist_webpage_url": "ytsearch5:\u0110en V\u00e2u channel", "n_entries": 5, "playlist_index": 2, "__last_playlist_index": 5, "playlist_autonumber": 2, "epoch": 1773501405, "duration_string": "3:26", "release_year": null, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}}
{"_type": "url", "ie_key": "Youtube", "id": "2bUScL5ojlY", "url": "https://www.youtube.com/watch?v=2bUScL5ojlY", "title": "\u0110en x Chi Pu x Lynk Lee - N\u1ebfu M\u00ecnh G\u1ea7n Nhau (Audio)", "description": null, "duration": 191.0, "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "uploader": "\u0110en V\u00e2u Official", "uploader_id": null, "uploader_url": null, "thumbnails": [{"url": "https://i.ytimg.com/vi/2bUScL5ojlY/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAYOjHpiYUG6Prge3e62O2mcPCN5A", "height": 202, "width": 360}, {"url": "https://i.ytimg.com/vi/2bUScL5ojlY/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBymxV0EPQ4gyNzwsoCy4quAWGa1Q", "height": 404, "width": 720}], "timestamp": null, "release_timestamp": null, "availability": null, "view_count": 2867844, "live_status": null, "channel_is_verified": true, "__x_forwarded_for_ip": null, "webpage_url": "https://www.youtube.com/watch?v=2bUScL5ojlY", "original_url": "https://www.youtube.com/watch?v=2bUScL5ojlY", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube", "extractor_key": "Youtube", "playlist_count": 5, "playlist": "\u0110en V\u00e2u channel", "playlist_id": "\u0110en V\u00e2u channel", "playlist_title": "\u0110en V\u00e2u channel", "playlist_uploader": null, "playlist_uploader_id": null, "playlist_channel": null, "playlist_channel_id": null, "playlist_webpage_url": "ytsearch5:\u0110en V\u00e2u channel", "n_entries": 5, "playlist_index": 3, "__last_playlist_index": 5, "playlist_autonumber": 3, "epoch": 1773501405, "duration_string": "3:11", "release_year": null, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}}
{"_type": "url", "ie_key": "Youtube", "id": "ArexdEMWRlA", "url": "https://www.youtube.com/watch?v=ArexdEMWRlA", "title": "\u0110en - Tr\u1eddi \u01a1i con ch\u01b0a mu\u1ed1n ch\u1ebft (Prod. by Tantu Beats)", "description": null, "duration": 162.0, "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "uploader": "\u0110en V\u00e2u Official", "uploader_id": null, "uploader_url": null, "thumbnails": [{"url": "https://i.ytimg.com/vi/ArexdEMWRlA/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDhCNm1yQB8WUaqIo9Kw7wweMhTzw", "height": 202, "width": 360}, {"url": "https://i.ytimg.com/vi/ArexdEMWRlA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCMgC-38NlV10mjB4rFqZ5jFwSsNw", "height": 404, "width": 720}], "timestamp": null, "release_timestamp": null, "availability": null, "view_count": 17788226, "live_status": null, "channel_is_verified": true, "__x_forwarded_for_ip": null, "webpage_url": "https://www.youtube.com/watch?v=ArexdEMWRlA", "original_url": "https://www.youtube.com/watch?v=ArexdEMWRlA", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube", "extractor_key": "Youtube", "playlist_count": 5, "playlist": "\u0110en V\u00e2u channel", "playlist_id": "\u0110en V\u00e2u channel", "playlist_title": "\u0110en V\u00e2u channel", "playlist_uploader": null, "playlist_uploader_id": null, "playlist_channel": null, "playlist_channel_id": null, "playlist_webpage_url": "ytsearch5:\u0110en V\u00e2u channel", "n_entries": 5, "playlist_index": 4, "__last_playlist_index": 5, "playlist_autonumber": 4, "epoch": 1773501405, "duration_string": "2:42", "release_year": null, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}}
{"_type": "url", "ie_key": "Youtube", "id": "KKc_RMln5UY", "url": "https://www.youtube.com/watch?v=KKc_RMln5UY", "title": "\u0110en - L\u1ed1i Nh\u1ecf ft. Ph\u01b0\u01a1ng Anh \u0110\u00e0o (M/V)", "description": null, "duration": 297.0, "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "uploader": "\u0110en V\u00e2u Official", "uploader_id": null, "uploader_url": null, "thumbnails": [{"url": "https://i.ytimg.com/vi/KKc_RMln5UY/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAE0jKnPS0v1E_YHdg3UPiEvG0j1A", "height": 202, "width": 360}, {"url": "https://i.ytimg.com/vi/KKc_RMln5UY/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAOcBa3yyAadGHbRtHNv7cimOpbfA", "height": 404, "width": 720}], "timestamp": null, "release_timestamp": null, "availability": null, "view_count": 180009882, "live_status": null, "channel_is_verified": true, "__x_forwarded_for_ip": null, "webpage_url": "https://www.youtube.com/watch?v=KKc_RMln5UY", "original_url": "https://www.youtube.com/watch?v=KKc_RMln5UY", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube", "extractor_key": "Youtube", "playlist_count": 5, "playlist": "\u0110en V\u00e2u channel", "playlist_id": "\u0110en V\u00e2u channel", "playlist_title": "\u0110en V\u00e2u channel", "playlist_uploader": null, "playlist_uploader_id": null, "playlist_channel": null, "playlist_channel_id": null, "playlist_webpage_url": "ytsearch5:\u0110en V\u00e2u channel", "n_entries": 5, "playlist_index": 5, "__last_playlist_index": 5, "playlist_autonumber": 5, "epoch": 1773501405, "duration_string": "4:57", "release_year": null, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}}

View file

@ -0,0 +1 @@
{"id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "title": "\u0110en V\u00e2u Official", "availability": null, "channel_follower_count": 5210000, "description": "\u0110en V\u00e2u\nM\u1ed9t ng\u01b0\u1eddi Vi\u1ec7t ch\u01a1i nh\u1ea1c Rap.\nA Vietnamese boy who plays Rap. \n\n\n\n", "tags": ["den", "den vau", "\u0111en", "\u0111en v\u00e2u", "rapper den", "rapper \u0111en", "rapper \u0111en v\u00e2u", "mang ti\u1ec1n v\u1ec1 cho m\u1eb9", "ai mu\u1ed1n nghe kh\u00f4ng", "l\u1ed1i nh\u1ecf", "hip hop rap", "\u0111i v\u1ec1 nh\u00e0", "b\u00e0i n\u00e0y chill ph\u1ebft", "trong r\u1eebng c\u00f3 nai c\u00f3 th\u1ecf", "nh\u1ea1c rap", "friendship", "friendship karaoke", "friendship lyrics", "lu\u00f4n y\u00eau \u0111\u1eddi", "tri\u1ec7u \u0111i\u1ec1u nh\u1ecf x\u00edu xi\u00eau l\u00f2ng", "xi\u00eau l\u00f2ng", "nh\u1ecf x\u00edu xi\u00eau l\u00f2ng", "\u0111\u01b0\u1eddng bi\u00ean h\u00f2a", "kim long", "\u0111en ft kim long", "\u0111en v\u00e0 kim long", "l\u00e3ng \u0111\u00e3ng", "lang dang", "l\u00e3ng \u0111\u00e3ng \u0111en v\u00e2u", "vi\u1ec7c l\u1edbn", "vi\u1ec7c l\u1edbn \u0111en v\u00e2u"], "thumbnails": [{"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 175, "width": 1060, "preference": -10, "id": "0", "resolution": "1060x175"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w1138-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 188, "width": 1138, "preference": -10, "id": "1", "resolution": "1138x188"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w1707-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 283, "width": 1707, "preference": -10, "id": "2", "resolution": "1707x283"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w2120-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 351, "width": 2120, "preference": -10, "id": "3", "resolution": "2120x351"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w2276-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 377, "width": 2276, "preference": -10, "id": "4", "resolution": "2276x377"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 424, "width": 2560, "preference": -10, "id": "5", "resolution": "2560x424"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=s0", "id": "banner_uncropped", "preference": -5}, {"url": "https://yt3.googleusercontent.com/4KreeefY-ytOPUKbpbDQnIqzi-b2qOP8ILqlNDThbk5T8kYJIT2EKvj9uQnv8FHi15quYN5L=s900-c-k-c0x00ffffff-no-rj", "height": 900, "width": 900, "id": "7", "resolution": "900x900"}, {"url": "https://yt3.googleusercontent.com/4KreeefY-ytOPUKbpbDQnIqzi-b2qOP8ILqlNDThbk5T8kYJIT2EKvj9uQnv8FHi15quYN5L=s0", "id": "avatar_uncropped", "preference": 1}], "uploader_id": "@DenVau1305", "uploader_url": "https://www.youtube.com/@DenVau1305", "modified_date": null, "view_count": null, "playlist_count": 2, "uploader": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "_type": "playlist", "entries": [], "webpage_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "original_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "webpage_url_basename": "UCWu91J5KWEj1bQhCBuGeJxw", "webpage_url_domain": "youtube.com", "extractor": "youtube:tab", "extractor_key": "YoutubeTab", "release_year": null, "requested_entries": [], "epoch": 1773501485, "__files_to_move": {}, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}}

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@ import TechSpecs from './TechSpecs';
import AddToPlaylistModal from "./AddToPlaylistModal";
import Lyrics from './Lyrics';
import QueueModal from './QueueModal';
import Recommendations from './Recommendations';
import { useDominantColor } from '../hooks/useDominantColor';
import { useLyrics } from '../hooks/useLyrics';
@ -540,7 +541,7 @@ export default function PlayerBar() {
<img
src={currentTrack.cover_url}
alt={currentTrack.title}
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]"
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]"
/>
</div>
)}

View file

@ -1,5 +1,7 @@
import { X, Play, Pause } from 'lucide-react';
import { usePlayer } from '../context/PlayerContext';
import { useEffect, useState } from 'react';
import { libraryService } from '../services/library';
import CoverImage from './CoverImage';
import { Track } from '../types';
@ -10,6 +12,30 @@ interface QueueModalProps {
export default function QueueModal({ isOpen, onClose }: QueueModalProps) {
const { queue, currentTrack, playTrack, isPlaying, togglePlay } = usePlayer();
const [recommendations, setRecommendations] = useState<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;
@ -48,8 +74,6 @@ export default function QueueModal({ isOpen, onClose }: QueueModalProps) {
// Actually queue usually contains the current track.
// Let's filter out current track visually or just show whole queue?
// Spotify shows "Next In Queue".
if (track.id === currentTrack?.id) return null;
return (
<QueueItem
key={`${track.id}-${i}`}
@ -61,6 +85,47 @@ export default function QueueModal({ isOpen, onClose }: QueueModalProps) {
})
)}
</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>

View file

@ -0,0 +1,209 @@
import { useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { libraryService } from '../services/library';
import { usePlayer } from '../context/PlayerContext';
import { Play } from 'lucide-react';
import { Track } from '../types';
import CoverImage from './CoverImage';
interface RecommendationData {
tracks: Track[];
albums: Array<{ id: string; title: string; artist: string; cover_url: string }>;
playlists: Array<{ id: string; title: string; cover_url: string; track_count: number }>;
artists: Array<{ id: string; name: string; photo_url: string; cover_url?: string }>;
}
interface RecommendationsProps {
seed: string;
seedType?: string;
limit?: number;
title?: string;
showTracks?: boolean;
showAlbums?: boolean;
showPlaylists?: boolean;
showArtists?: boolean;
}
export default function Recommendations({
seed,
seedType = 'track',
limit = 10,
title = 'You might also like',
showTracks = true,
showAlbums = true,
showPlaylists = true,
showArtists = true
}: RecommendationsProps) {
const navigate = useNavigate();
const { playTrack } = usePlayer();
const [data, setData] = useState<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 = () => {
setIsClearingCache(true);
// Wipe common caches
localStorage.removeItem('ytm_browse_cache_v6');
localStorage.removeItem('ytm_browse_cache_v8');
localStorage.removeItem('ytm_browse_cache_v7');
localStorage.removeItem('artist_photos_cache_v5');
localStorage.removeItem('artist_photos_cache_v6');

View file

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

View file

@ -4,6 +4,7 @@ import { libraryService } from '../services/library';
import { usePlayer } from '../context/PlayerContext';
import { Play, Shuffle, Heart, Clock, ListPlus, Download } from 'lucide-react';
import { Track } from '../types';
import Recommendations from '../components/Recommendations';
export default function Album() {
const { id } = useParams();
@ -23,7 +24,21 @@ export default function Album() {
const album = await libraryService.getAlbum(queryId);
if (album) {
setTracks(album.tracks);
// Normalize track IDs - extract YouTube video ID from discovery-* IDs
const normalizedTracks = album.tracks.map((track) => {
let videoId = track.id;
if (track.id.includes('discovery-') || track.id.includes('artist-')) {
const parts = track.id.split('-');
for (const part of parts) {
if (part.length === 11 && /^[a-zA-Z0-9_-]+$/.test(part)) {
videoId = part;
break;
}
}
}
return { ...track, id: videoId, url: `/api/stream/${videoId}` };
});
setTracks(normalizedTracks);
setAlbumInfo({
title: album.title,
artist: album.creator || "Unknown Artist",
@ -35,7 +50,7 @@ export default function Album() {
try {
const artistQuery = album.creator || "Unknown Artist";
const suggestions = await libraryService.search(artistQuery);
const currentIds = new Set(album.tracks.map(t => t.id));
const currentIds = new Set(normalizedTracks.map(t => t.id));
setMoreByArtist(suggestions.filter(t => !currentIds.has(t.id)).slice(0, 10));
} catch (e) { }
} else {
@ -83,7 +98,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">
{/* Cover */}
<div
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"
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"
onClick={() => {
if (tracks.length > 0) {
playTrack(tracks[0], tracks);
@ -192,7 +207,7 @@ export default function Album() {
}}
>
<div className="relative mb-3 md:mb-4">
<img src={track.cover_url} className="w-full aspect-square rounded-md shadow-lg object-cover" />
<img src={track.cover_url} className="w-full aspect-square rounded-2xl 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="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" />
@ -206,6 +221,19 @@ export default function Album() {
</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>
);
}

View file

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

View file

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

View file

@ -144,7 +144,7 @@ export default function Library() {
<CoverImage
src={playlist.cover_url}
alt={playlist.title}
className="w-full aspect-square rounded-xl shadow-lg"
className="w-full aspect-square rounded-2xl shadow-lg"
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">
@ -187,7 +187,7 @@ export default function Library() {
<CoverImage
src={item.cover_url}
alt={item.title}
className={`w-full aspect-square shadow-lg rounded-xl`}
className={`w-full aspect-square shadow-lg rounded-2xl`}
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">

View file

@ -9,6 +9,7 @@ import { Track, StaticPlaylist } from '../types';
import CoverImage from '../components/CoverImage';
import AddToPlaylistModal from '../components/AddToPlaylistModal';
import Skeleton from '../components/Skeleton';
import Recommendations from '../components/Recommendations';
import { GENERATED_CONTENT } from '../data/seed_data';
type PlaylistData = PlaylistType | StaticPlaylist;
@ -87,7 +88,24 @@ export default function Playlist() {
console.log("Fetching from Library Service (Hydrating)...");
const apiPlaylist = await libraryService.getPlaylist(playlistId);
if (apiPlaylist && apiPlaylist.tracks.length > 0) {
setPlaylist(apiPlaylist);
// Normalize track IDs - extract YouTube video ID from discovery-* IDs
const normalizedTracks = apiPlaylist.tracks.map((track: Track) => {
let videoId = track.id;
// If ID contains "discovery-" or "artist-", extract the YouTube video ID
if (track.id.includes('discovery-') || track.id.includes('artist-')) {
const parts = track.id.split('-');
// Find 11-char YouTube ID
for (const part of parts) {
if (part.length === 11 && /^[a-zA-Z0-9_-]+$/.test(part)) {
videoId = part;
break;
}
}
}
return { ...track, id: videoId, url: `/api/stream/${videoId}` };
});
const normalizedPlaylist = { ...apiPlaylist, tracks: normalizedTracks };
setPlaylist(normalizedPlaylist);
setIsUserPlaylist(false);
setLoading(false);
@ -95,7 +113,7 @@ export default function Playlist() {
try {
const query = apiPlaylist.title.replace(' Mix', '');
const recs = await libraryService.search(query);
const currentIds = new Set(apiPlaylist.tracks.map(t => t.id));
const currentIds = new Set(normalizedTracks.map((t: Track) => t.id));
setMoreLikeThis(recs.filter(t => !currentIds.has(t.id)).slice(0, 10));
} catch (e) { }
} else {
@ -186,7 +204,7 @@ export default function Playlist() {
<ArrowLeft className="w-6 h-6" />
</Link>
<div
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"
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"
onClick={() => {
if (playlist && playlist.tracks.length > 0) {
playTrack(playlist.tracks[0], playlist.tracks);
@ -306,7 +324,7 @@ export default function Playlist() {
<CoverImage
src={track.cover_url}
alt={track.title}
className="w-10 h-10 rounded flex-shrink-0"
className="w-10 h-10 rounded-lg flex-shrink-0"
fallbackText="♪"
/>
<div className="min-w-0">
@ -366,7 +384,7 @@ export default function Playlist() {
}}
>
<div className="relative mb-3 md:mb-4">
<img src={track.cover_url} className="w-full aspect-square rounded-md shadow-lg object-cover" />
<img src={track.cover_url} className="w-full aspect-square rounded-2xl 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="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" />
@ -381,6 +399,19 @@ export default function Playlist() {
</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 */}
{selectedTrack && (
<AddToPlaylistModal

View file

@ -183,7 +183,7 @@ export default function Search() {
{relatedAlbums.map(album => (
<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">
<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]} />
<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]} />
</div>
<p className="font-bold truncate text-[11px] md:text-base">{album.name}</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">
<Play className="w-4 h-4 fill-current" />
</div>
<CoverImage src={track.cover_url} alt={track.title} className="w-10 h-10 rounded" fallbackText="♪" />
<CoverImage src={track.cover_url} alt={track.title} className="w-10 h-10 rounded-lg" fallbackText="♪" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.title}</p>
<p className="text-sm text-neutral-400 truncate">{track.artist}</p>

View file

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

View file

@ -27,7 +27,20 @@ export const libraryService = {
async search(query: string): Promise<Track[]> {
const data = await apiFetch(`/search?q=${encodeURIComponent(query)}`);
if (data?.tracks && data.tracks.length > 0) {
return data.tracks;
// Normalize track IDs - extract YouTube video ID from discovery-* IDs
return data.tracks.map((track: Track) => {
let videoId = track.id;
if (track.id.includes('discovery-') || track.id.includes('artist-') || track.id.includes('album-')) {
const parts = track.id.split('-');
for (const part of parts) {
if (part.length === 11 && /^[a-zA-Z0-9_-]+$/.test(part)) {
videoId = part;
break;
}
}
}
return { ...track, id: videoId, url: `/api/stream/${videoId}` };
});
}
return [];
},
@ -78,6 +91,36 @@ export const libraryService = {
return tracks.slice(0, 20);
},
async getRelatedContent(seed: string, seedType: string = 'track', limit: number = 10): Promise<{
tracks: Track[],
albums: Array<{ id: string, title: string, artist: string, cover_url: string }>,
playlists: Array<{ id: string, title: string, cover_url: string, track_count: number }>,
artists: Array<{ id: string, name: string, photo_url: string }>
}> {
try {
const data = await apiFetch(`/recommendations?seed=${encodeURIComponent(seed)}&seed_type=${seedType}&limit=${limit}`);
if (data) {
return {
tracks: data.tracks || [],
albums: data.albums || [],
playlists: data.playlists || [],
artists: data.artists || []
};
}
} catch (e) {
console.error('Failed to get related content:', e);
}
// Fallback to search-based recommendations
const fallbackTracks = await this.search(seed);
return {
tracks: fallbackTracks.slice(0, limit),
albums: [],
playlists: [],
artists: []
};
},
async getPlaylist(id: string): Promise<StaticPlaylist | null> {
// 1. Try to find in GENERATED_CONTENT first (Fast/Instant)
const found = Object.values(GENERATED_CONTENT).find(p => p.id === id);
@ -193,25 +236,33 @@ export const libraryService = {
},
async getArtistInfo(artistName: string): Promise<{ bio?: string; photo?: string }> {
// Try specific API for image
// Method 1: Try backend API for real YouTube channel photo
try {
const res = await apiFetch(`/artist/info?q=${encodeURIComponent(artistName)}`);
if (res && res.image) {
return { photo: res.image };
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
const res = await fetch(`/api/artist/info?q=${encodeURIComponent(artistName)}`, {
signal: controller.signal
});
clearTimeout(timeoutId);
if (res.ok) {
const data = await res.json();
console.log(`[ArtistInfo] ${artistName}:`, data);
if (data.image) {
return { photo: data.image };
}
}
} catch (e) {
// fall through
console.error(`[ArtistInfo] Error for ${artistName}:`, e);
// Fall through to next method
}
// Fallback to track cover
try {
const tracks = await this.search(artistName);
if (tracks.length > 0 && tracks[0]?.cover_url) {
return { photo: tracks[0].cover_url };
}
} catch (e) { }
return { photo: getUnsplashImage(artistName) };
// Method 2: Use UI-Avatars API (instant, always works)
// Using smaller size (128) for faster loading
const encodedName = encodeURIComponent(artistName);
const avatarUrl = `https://ui-avatars.com/api/?name=${encodedName}&background=random&color=fff&size=128&rounded=true&bold=true&font-size=0.33`;
return { photo: avatarUrl };
},
async getLyrics(track: string, artist: string): Promise<{ plainLyrics?: string; syncedLyrics?: string; } | null> {

View file

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

View file

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

Binary file not shown.