Compare commits
2 commits
35c9bf24f7
...
7fe5b955e8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fe5b955e8 | ||
|
|
36e18a3609 |
29 changed files with 1119 additions and 1744 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -62,5 +62,12 @@ backend/data_seed/
|
|||
*.log
|
||||
build_log*.txt
|
||||
|
||||
# Rust
|
||||
target/
|
||||
|
||||
# Backup Files
|
||||
*_backup.*
|
||||
nul
|
||||
*.pid
|
||||
frontend.pid
|
||||
backend.pid
|
||||
|
|
|
|||
39
Dockerfile
39
Dockerfile
|
|
@ -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
133
README.md
|
|
@ -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.
|
||||
|
||||

|
||||
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
1408
backend-rust/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,10 +62,26 @@ 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(
|
||||
|
|
@ -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 {
|
||||
¶ms.seed_type
|
||||
};
|
||||
|
||||
let limit = params.limit.min(50); // Cap at 50
|
||||
|
||||
match state.spotdl.get_recommendations(seed, seed_type, limit).await {
|
||||
Ok(recommendations) => {
|
||||
match serde_json::to_value(recommendations) {
|
||||
Ok(value) => (StatusCode::OK, Json(value)),
|
||||
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Serialization failed"}))),
|
||||
}
|
||||
},
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e}))),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
backend-rust/test-artist.json
Normal file
5
backend-rust/test-artist.json
Normal 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"}}
|
||||
1
backend-rust/test-channel.json
Normal file
1
backend-rust/test-channel.json
Normal 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"}}
|
||||
41
deploy.bat
41
deploy.bat
|
|
@ -1,41 +0,0 @@
|
|||
@echo off
|
||||
echo ==========================================
|
||||
echo Spotify Clone Deployment Script
|
||||
echo ==========================================
|
||||
|
||||
echo [1/3] Checking Docker status...
|
||||
docker info >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Docker is NOT running!
|
||||
echo.
|
||||
echo Please start Docker Desktop from your Start Menu.
|
||||
echo Once Docker is running ^(green icon^), run this script again.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [2/3] Docker is active. Building Image...
|
||||
echo This may take a few minutes...
|
||||
docker build -t vndangkhoa/spotify-clone:latest .
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Docker build failed.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [3/3] Pushing to Docker Hub...
|
||||
docker push vndangkhoa/spotify-clone:latest
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Docker push failed.
|
||||
echo You may need to run 'docker login' first.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ==========================================
|
||||
echo [SUCCESS] Deployment Complete!
|
||||
echo Image: vndangkhoa/spotify-clone:latest
|
||||
echo ==========================================
|
||||
pause
|
||||
|
|
@ -1,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"
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
209
frontend-vite/src/components/Recommendations.tsx
Normal file
209
frontend-vite/src/components/Recommendations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
|
||||
@echo off
|
||||
echo Stopping existing processes...
|
||||
taskkill /F /IM server.exe >nul 2>&1
|
||||
taskkill /F /IM node.exe >nul 2>&1
|
||||
|
||||
echo Starting Backend...
|
||||
cd backend-go
|
||||
start /B server.exe
|
||||
cd ..
|
||||
|
||||
echo Starting Frontend...
|
||||
cd frontend-vite
|
||||
start npm run dev
|
||||
cd ..
|
||||
|
||||
echo App Restarted! Backend on :8080, Frontend on :5173
|
||||
|
|
@ -2,10 +2,12 @@ $ErrorActionPreference = "Stop"
|
|||
$ScriptDir = $PSScriptRoot
|
||||
$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
|
||||
|
|
|
|||
BIN
yt-dlp.exe
BIN
yt-dlp.exe
Binary file not shown.
Loading…
Reference in a new issue