Initial commit: Spotify Clone with Audiophile specs and Synology support

This commit is contained in:
Khoa.vo 2025-12-17 11:18:36 +07:00
commit e1cb73f817
66 changed files with 28539 additions and 0 deletions

55
.gitignore vendored Normal file
View file

@ -0,0 +1,55 @@
# System Files
.DS_Store
Thumbs.db
# Environment Variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.next/
out/
build/
dist/
# Python
venv/
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# IDEs
.idea/
.vscode/
# Project Specific
backend/data/*.json
!backend/data/browse_playlists.json
!backend/data/data.json
backend/cache/
backend/data_seed/

105
DOCKER_README.md Normal file
View file

@ -0,0 +1,105 @@
# Spotify Clone 🎵
A fully functional clone of the Spotify web player, built with a modern stack featuring **Next.js**, **FastAPI**, and **TailwindCSS**. This application replicates the premium authentic feel of the original web player with added features like synchronized lyrics and custom playlist management.
### ⚡ One-Click Deployment
We have included a script to automate the build and deploy process.
1. Ensure Docker Desktop is running.
2. Double-click `deploy.bat`.
### 📦 Clean Build & Publish (For Developers)
If you are the developer or want to build the image yourself, follow these steps:
### 1. Build the Image
```bash
docker build -t vndangkhoa/spotify-clone:latest .
```
### 2. Push to Docker Hub
```bash
# Login first
docker login
# Push
docker push vndangkhoa/spotify-clone:latest
```
---
## 🚀 Quick Start (Pre-built)
Run the application instantly from Docker Hub:
```bash
docker run -p 3000:3000 -p 8000:8000 vndangkhoa/spotify-clone
```
After the container starts, open **[http://localhost:3000](http://localhost:3000)** in your browser.
---
## 🏠 Synology / Docker Compose (Self-Hosted/Offline)
For permanent deployment on a NAS (Synology) or local server with data persistence, use **Docker Compose**.
### 1. Create `docker-compose.yml`
Create a file named `docker-compose.yml` with the following content:
```yaml
services:
spotify-clone:
image: vndangkhoa/spotify-clone:latest
container_name: spotify-clone
restart: always
network_mode: bridge
ports:
- "3000:3000" # Web UI (Access this)
- "8000:8000" # Optional: Direct API access
volumes:
# Persist your playlists and data
- ./data:/app/backend/data
```
### 2. Run the Service
Run the following command in the same directory:
```bash
docker-compose up -d
```
Your data (playlists) will be securely saved in the `./data` folder on your host machine.
---
## 📱 PWA Support (Mobile App)
This web app is **Progressive Web App (PWA)** compatible. You can install it on your mobile device for a native-like experience:
1. Open the web app in **Safari** (iOS) or **Chrome** (Android).
2. Tap the **Share** button (iOS) or **Menu** (Android).
3. Select **"Add to Home Screen"**.
4. Launch it from your home screen—it looks and feels just like an app! 🎧
---
## ✨ Key Features
- **mic Synced Lyrics**: Real-time scrolling lyrics with precise synchronization.
- **🎧 Seamless Playback**: Full audio controls, background play support.
- **📂 Playlist Management**: Create, edit, and save playlists locally.
- **🔍 Smart Search**: Instantly find tracks.
- **🎨 Premium UI**: Glassmorphism, dynamic gradients, and smooth animations.
## 🛠️ Tech Stack
- **Frontend**: Next.js 14, React, TailwindCSS.
- **Backend**: FastAPI, Python 3.11.
- **Container**: Hybrid Image (Node.js + Python).
## 📦 Ports
- **3000**: Frontend (User Interface) - **Primary Access Point**
- **8000**: Backend (API) - *Internal use, but can be mapped for direct API access.*
For standard usage, accessing via Port 3000 handles everything.

57
Dockerfile Normal file
View file

@ -0,0 +1,57 @@
FROM python:3.11-slim
# Install Node.js
RUN apt-get update && apt-get install -y \
curl \
gnupg \
ffmpeg \
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# --- Backend Setup ---
COPY backend/requirements.txt ./backend/requirements.txt
RUN pip install --no-cache-dir -r backend/requirements.txt
# --- Frontend Setup ---
COPY frontend/package*.json ./frontend/
WORKDIR /app/frontend
# Install dependencies (ignoring peer deps conflicts)
RUN npm install --legacy-peer-deps
COPY frontend/ .
# Build Next.js (with ignore-lint already set in next.config.mjs)
# We set API URL to http://localhost:8000 because in this container strategy,
# the browser will access the backend directly.
# Wait, for client-side fetches, "localhost" refers to the user's machine.
# If we run this container on port 3000 and 8000, localhost:8000 works internally via Rewrites.
# ENV NEXT_PUBLIC_API_URL="http://localhost:8000" Removed to use relative path proxying
# Build Next.js
ENV NEXTAUTH_URL=http://localhost:3000
# Generate a static secret for now to prevent 500 error, or use a build arg
ENV NEXTAUTH_SECRET=changeme_in_production_but_this_fixes_500_error
RUN npm run build
# --- Final Setup ---
WORKDIR /app
COPY backend/ ./backend/
# Create a start script
# We also implement a "seed data" check.
# If the volume mount is empty (missing data.json), we copy from our backup.
RUN mkdir -p backend/data_seed && cp -r backend/data/* backend/data_seed/ || true
RUN echo '#!/bin/bash\n\
if [ ! -f backend/data/data.json ]; then\n\
echo "Data volume appears empty. Seeding with bundled data..."\n\
cp -r backend/data_seed/* backend/data/\n\
fi\n\
uvicorn backend.main:app --host 0.0.0.0 --port 8000 &\n\
cd frontend && npm start -- -p 3000\n\
' > start.sh && chmod +x start.sh
EXPOSE 3000 8000
CMD ["./start.sh"]

125
README.md Normal file
View file

@ -0,0 +1,125 @@
# Spotify Clone 🎵
A fully functional clone of the Spotify web player, built with a modern stack featuring **Next.js**, **FastAPI**, and **TailwindCSS**. This application replicates the premium authentic feel of the original web player with added features like synchronized lyrics, custom playlist management, and "Audiophile" technical specs.
![Preview](https://opengraph.githubassets.com/1/vndangkhoa/spotify-clone)
---
## 🚀 Quick Start (Docker)
The easiest way to run the application is using Docker.
### Option 1: Run from Docker Hub (Pre-built)
```bash
docker run -p 3000:3000 -p 8000:8000 vndangkhoa/spotify-clone:latest
```
Open **[http://localhost:3000](http://localhost:3000)**.
### Option 2: Build Locally
```bash
docker build -t spotify-clone .
docker run -p 3000:3000 -p 8000:8000 spotify-clone
```
---
## 🛠️ Local Development
If you want to contribute or modify the code:
### Prerequisites
- Node.js 18+
- Python 3.11+
- ffmpeg (optional, for some audio features)
### 1. Backend Setup
```bash
cd backend
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
python main.py
```
Backend runs on `http://localhost:8000`.
### 2. Frontend Setup
```bash
cd frontend
npm install
npm run dev
```
Frontend runs on `http://localhost:3000`.
---
## 📦 Deployment Guide
### 1. Deploy to GitHub
Initialize the repository (if not done) and push:
```bash
git init
git add .
git commit -m "Initial commit"
git branch -M main
git remote add origin https://github.com/YOUR_USERNAME/spotify-clone.git
git push -u origin main
```
### 2. Deploy to Docker Hub
To share your image with the world (or your NAS):
```bash
# 1. Login to Docker Hub
docker login
# 2. Build the image (replace 'vndangkhoa' with your Docker Hub username)
docker build -t vndangkhoa/spotify-clone:latest .
# 3. Push the image
docker push vndangkhoa/spotify-clone:latest
```
### 3. Deploy to Synology NAS (Container Manager)
This app runs perfectly on Synology NAS using **Container Manager** (formerly Docker).
#### Method A: Using Container Manager UI (Easy)
1. Open **Container Manager**.
2. Go to **Registry** -> Search for `vndangkhoa/spotify-clone` (or your image).
3. Download the image.
4. Go to **Image** -> Select image -> **Run**.
- **Network**: Bridge (default).
- **Port Settings**: Map Local Port `3110` (or any) to Container Port `3000`.
- **Volume Settings** (Optional): Map a folder `/docker/spotify/data` to `/app/backend/data` to save playlists.
5. Done! Access at `http://YOUR_NAS_IP:3110`.
#### Method B: Using Docker Compose (Recommended)
1. Create a folder on your NAS (e.g., `/volume1/docker/spotify`).
2. Create a file named `docker-compose.yml` inside it:
```yaml
services:
spotify-clone:
image: vndangkhoa/spotify-clone:latest
container_name: spotify-clone
restart: always
network_mode: bridge
ports:
- "3110:3000" # Web UI Access Port
volumes:
- ./data:/app/backend/data
```
3. In Container Manager, go to **Project** -> **Create**.
4. Select the folder path, give it a name, and it will detect the compose file.
5. Click **Build** / **Run**.
---
## ✨ Features
- **Real-Time Lyrics**: Fetch and sync lyrics from multiple sources (YouTube, LRCLIB).
- **Audiophile Engine**: "Tech Specs" view showing live bitrate, LUFS, and Dynamic Range.
- **Local-First**: Works offline (PWA) and syncs local playlists.
- **Smart Search**: Unified search across YouTube Music.
- **Responsive**: Full mobile support with a dedicated full-screen player.
## 📝 License
MIT License

0
backend/api/__init__.py Normal file
View file

614
backend/api/routes.py Normal file
View file

@ -0,0 +1,614 @@
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
import json
from pathlib import Path
import yt_dlp
import requests
from backend.cache_manager import CacheManager
from backend.playlist_manager import PlaylistManager
import re
router = APIRouter()
cache = CacheManager()
playlist_manager = PlaylistManager()
def clean_text(text: str) -> str:
if not text:
return ""
# Remove emojis
text = text.encode('ascii', 'ignore').decode('ascii')
# Remove text inside * * or similar patterns if they look spammy
# Remove excessive punctuation
# Example: "THE * VIRAL 50 *" -> "THE VIRAL 50"
# 1. Remove URLs
text = re.sub(r'http\S+|www\.\S+', '', text)
# 2. Remove "Playlist", "Music Chart", "Full SPOTIFY" spam keywords if desirable,
# but that might be too aggressive.
# Let's focus on cleaning the "Structure".
# 3. Truncate Description if too long (e.g. > 300 chars)?
# The user example had a MASSIVE description.
# Let's just take the first paragraph or chunk?
# 4. Remove excessive non-alphanumeric separators
text = re.sub(r'[*_=]{3,}', '', text) # Remove long separator lines
# Custom cleaning for the specific example style:
# Remove text between asterisks if it looks like garbage? No, sometimes it's emphasis.
return text.strip()
def clean_title(title: str) -> str:
if not title: return "Playlist"
# Remove emojis (simple way)
title = title.encode('ascii', 'ignore').decode('ascii')
# Remove "Playlist", "Music Chart", "Full Video" spam
spam_words = ["Playlist", "Music Chart", "Full SPOTIFY Video", "Updated Weekly", "Official", "Video"]
for word in spam_words:
title = re.sub(word, "", title, flags=re.IGNORECASE)
# Remove extra spaces and asterisks
title = re.sub(r'\s+', ' ', title).strip()
title = title.strip('*- ')
return title
def clean_description(desc: str) -> str:
if not desc: return ""
# Remove URLs
desc = re.sub(r'http\S+', '', desc)
# Remove massive divider lines
desc = re.sub(r'[*_=]{3,}', '', desc)
# Be more aggressive with length?
if len(desc) > 300:
desc = desc[:300] + "..."
return desc.strip()
CACHE_DIR = Path("backend/cache")
class SearchRequest(BaseModel):
url: str
class CreatePlaylistRequest(BaseModel):
name: str # Renamed from Title to Name to match Sidebar usage more typically, but API expects pydantic model
description: str = ""
@router.get("/browse")
async def get_browse_content():
"""
Returns the real fetched playlists from browse_playlists.json
"""
try:
data_path = Path("backend/data/browse_playlists.json")
if data_path.exists():
with open(data_path, "r") as f:
return json.load(f)
else:
return []
except Exception as e:
print(f"Browse Error: {e}")
return []
@router.get("/playlists")
async def get_user_playlists():
return playlist_manager.get_all()
@router.post("/playlists")
async def create_user_playlist(playlist: CreatePlaylistRequest):
return playlist_manager.create(playlist.name, playlist.description)
@router.delete("/playlists/{id}")
async def delete_user_playlist(id: str):
success = playlist_manager.delete(id)
if not success:
raise HTTPException(status_code=404, detail="Playlist not found")
return {"status": "ok"}
@router.get("/playlists/{id}")
async def get_playlist(id: str):
"""
Get a specific playlist by ID.
1. Check if it's a User Playlist.
2. If not, fetch from YouTube Music (Browse/External).
"""
# 1. Try User Playlist
user_playlists = playlist_manager.get_all()
user_playlist = next((p for p in user_playlists if p['id'] == id), None)
if user_playlist:
return user_playlist
# 2. Try External (YouTube Music)
# Check Cache first
cache_key = f"playlist:{id}"
cached_playlist = cache.get(cache_key)
if cached_playlist:
return cached_playlist
try:
from ytmusicapi import YTMusic
yt = YTMusic()
# ytmusicapi returns a dict with 'tracks' list
playlist_data = yt.get_playlist(id, limit=100)
# Format to match our app's Protocol
formatted_tracks = []
if 'tracks' in playlist_data:
for track in playlist_data['tracks']:
# Safely extract artists
artists_list = track.get('artists') or []
if isinstance(artists_list, list):
artist_names = ", ".join([a.get('name', 'Unknown') for a in artists_list])
else:
artist_names = "Unknown Artist"
# Safely extract thumbnails
thumbnails = track.get('thumbnails', [])
cover_url = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300"
# Safely extract album
album_info = track.get('album')
album_name = album_info.get('name', 'Single') if album_info else "Single"
formatted_tracks.append({
"title": track.get('title', 'Unknown Title'),
"artist": artist_names,
"album": album_name,
"duration": track.get('duration_seconds', 0),
"cover_url": cover_url,
"id": track.get('videoId'),
"url": f"https://music.youtube.com/watch?v={track.get('videoId')}"
})
# Get Playlist Cover (usually highest res)
thumbnails = playlist_data.get('thumbnails', [])
p_cover = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300"
formatted_playlist = {
"id": playlist_data.get('id'),
"title": clean_title(playlist_data.get('title', 'Unknown')),
"description": clean_description(playlist_data.get('description', '')),
"author": playlist_data.get('author', {}).get('name', 'YouTube Music'),
"cover_url": p_cover,
"tracks": formatted_tracks
}
# Cache it (1 hr)
cache.set(cache_key, formatted_playlist, ttl_seconds=3600)
return formatted_playlist
except Exception as e:
print(f"Playlist Fetch Error: {e}")
raise HTTPException(status_code=404, detail="Playlist not found")
class UpdatePlaylistRequest(BaseModel):
name: str = None
description: str = None
@router.put("/playlists/{id}")
async def update_user_playlist(id: str, playlist: UpdatePlaylistRequest):
updated = playlist_manager.update(id, name=playlist.name, description=playlist.description)
if not updated:
raise HTTPException(status_code=404, detail="Playlist not found")
return updated
class AddTrackRequest(BaseModel):
id: str
title: str
artist: str
album: str
cover_url: str
duration: int = 0
url: str = ""
@router.post("/playlists/{id}/tracks")
async def add_track_to_playlist(id: str, track: AddTrackRequest):
track_data = track.dict()
success = playlist_manager.add_track(id, track_data)
if not success:
raise HTTPException(status_code=404, detail="Playlist not found")
return {"status": "ok"}
@router.get("/search")
async def search_tracks(query: str):
"""
Search for tracks using ytmusicapi.
"""
if not query:
return []
# Check Cache
cache_key = f"search:{query.lower().strip()}"
cached_result = cache.get(cache_key)
if cached_result:
print(f"DEBUG: Returning cached search results for '{query}'")
return cached_result
try:
from ytmusicapi import YTMusic
yt = YTMusic()
results = yt.search(query, filter="songs", limit=20)
tracks = []
for track in results:
# Safely extract artists
artists_list = track.get('artists') or []
if isinstance(artists_list, list):
artist_names = ", ".join([a.get('name', 'Unknown') for a in artists_list])
else:
artist_names = "Unknown Artist"
# Safely extract thumbnails
thumbnails = track.get('thumbnails', [])
cover_url = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300"
# Safely extract album
album_info = track.get('album')
album_name = album_info.get('name', 'Single') if album_info else "Single"
tracks.append({
"title": track.get('title', 'Unknown Title'),
"artist": artist_names,
"album": album_name,
"duration": track.get('duration_seconds', 0),
"cover_url": cover_url,
"id": track.get('videoId'),
"url": f"https://music.youtube.com/watch?v={track.get('videoId')}"
})
response_data = {"tracks": tracks}
# Cache for 24 hours (86400 seconds)
cache.set(cache_key, response_data, ttl_seconds=86400)
return response_data
except Exception as e:
print(f"Search Error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/recommendations")
async def get_recommendations(seed_id: str = None):
"""
Get recommended tracks (Play History based or Trending).
If seed_id is provided, fetches 'Up Next' / 'Radio' tracks for that video.
"""
try:
from ytmusicapi import YTMusic
yt = YTMusic()
if not seed_id:
# Fallback to Trending if no history
return await get_trending()
cache_key = f"rec:{seed_id}"
cached = cache.get(cache_key)
if cached:
return cached
# Use get_watch_playlist to find similar tracks (Radio)
watch_playlist = yt.get_watch_playlist(videoId=seed_id, limit=20)
tracks = []
if 'tracks' in watch_playlist:
for track in watch_playlist['tracks']:
# Skip the seed track itself if play history already has it
if track.get('videoId') == seed_id:
continue
artists_list = track.get('artists') or []
if isinstance(artists_list, list):
artist_names = ", ".join([a.get('name', 'Unknown') for a in artists_list])
else:
artist_names = "Unknown Artist"
thumbnails = track.get('thumbnails', [])
cover_url = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300"
# album is often missing in watch playlist, fallback
album_info = track.get('album')
album_name = album_info.get('name', 'Single') if album_info else "Single"
tracks.append({
"title": track.get('title', 'Unknown Title'),
"artist": artist_names,
"album": album_name,
"duration": track.get('length_seconds', track.get('duration_seconds', 0)),
"cover_url": cover_url,
"id": track.get('videoId'),
"url": f"https://music.youtube.com/watch?v={track.get('videoId')}"
})
response_data = {"tracks": tracks}
cache.set(cache_key, response_data, ttl_seconds=3600) # 1 hour cache
return response_data
except Exception as e:
print(f"Recommendation Error: {e}")
# Fallback to trending on error
return await get_trending()
@router.get("/recommendations/albums")
async def get_recommended_albums(seed_artist: str = None):
"""
Get recommended albums based on an artist query.
"""
if not seed_artist:
return []
cache_key = f"rec_albums:{seed_artist.lower().strip()}"
cached = cache.get(cache_key)
if cached:
return cached
try:
from ytmusicapi import YTMusic
yt = YTMusic()
# Search for albums by this artist
results = yt.search(seed_artist, filter="albums", limit=10)
albums = []
for album in results:
thumbnails = album.get('thumbnails', [])
cover_url = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300"
albums.append({
"title": album.get('title', 'Unknown Album'),
"description": album.get('year', '') + "" + album.get('artist', seed_artist),
"cover_url": cover_url,
"id": album.get('browseId'),
"type": "Album"
})
cache.set(cache_key, albums, ttl_seconds=86400)
return albums
except Exception as e:
print(f"Album Rec Error: {e}")
return []
@router.get("/trending")
async def get_trending():
"""
Returns the pre-fetched Trending Vietnam playlist.
"""
try:
data_path = Path("backend/data.json")
if data_path.exists():
with open(data_path, "r") as f:
return json.load(f)
else:
return {"error": "Trending data not found. Run fetch_data.py first."}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/stream")
async def stream_audio(id: str):
"""
Stream audio for a given YouTube video ID.
Extracts direct URL via yt-dlp and streams it.
"""
try:
# Check Cache for stream URL
cache_key = f"stream:{id}"
cached_url = cache.get(cache_key)
stream_url = None
if cached_url:
print(f"DEBUG: Using cached stream URL for '{id}'")
stream_url = cached_url
else:
print(f"DEBUG: Fetching new stream URL for '{id}'")
url = f"https://www.youtube.com/watch?v={id}"
ydl_opts = {
'format': 'bestaudio/best',
'quiet': True,
'noplaylist': True,
}
# Extract direct URL
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
stream_url = info.get('url')
if stream_url:
# Cache for 1 hour (3600 seconds) - URLs expire
cache.set(cache_key, stream_url, ttl_seconds=3600)
if not stream_url:
raise HTTPException(status_code=404, detail="Audio stream not found")
# Stream the content
def iterfile():
# Verify if URL is still valid by making a HEAD request or handling stream error
# For simplicity, we just try to stream. If 403, we might need to invalidate,
# but that logic is complex for this method.
with requests.get(stream_url, stream=True) as r:
r.raise_for_status() # Check for 403
# Use smaller chunks (64KB) for better TTFB (Time To First Byte)
for chunk in r.iter_content(chunk_size=64*1024):
yield chunk
# Note: We return audio/mpeg, but it might be opus/webm.
# Browsers are usually smart enough to sniff.
return StreamingResponse(iterfile(), media_type="audio/mpeg")
except Exception as e:
print(f"Stream Error: {e}")
# If cached URL failed (likely 403), we could try to invalidate here,
# but for now we just return error.
raise HTTPException(status_code=500, detail=str(e))
@router.get("/download")
async def download_audio(id: str, title: str = "audio"):
"""
Download audio for a given YouTube video ID.
Proxies the stream content as a file attachment.
"""
try:
# Check Cache for stream URL
cache_key = f"stream:{id}"
cached_url = cache.get(cache_key)
stream_url = None
if cached_url:
stream_url = cached_url
else:
url = f"https://www.youtube.com/watch?v={id}"
ydl_opts = {
'format': 'bestaudio/best',
'quiet': True,
'noplaylist': True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
stream_url = info.get('url')
if stream_url:
cache.set(cache_key, stream_url, ttl_seconds=3600)
if not stream_url:
raise HTTPException(status_code=404, detail="Audio stream not found")
# Stream the content with attachment header
def iterfile():
with requests.get(stream_url, stream=True) as r:
r.raise_for_status()
for chunk in r.iter_content(chunk_size=1024*1024):
yield chunk
# Sanitize filename
safe_filename = "".join([c for c in title if c.isalnum() or c in (' ', '-', '_')]).strip()
headers = {
"Content-Disposition": f'attachment; filename="{safe_filename}.mp3"'
}
return StreamingResponse(iterfile(), media_type="audio/mpeg", headers=headers)
except Exception as e:
print(f"Download Error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/lyrics")
async def get_lyrics(id: str, title: str = None, artist: str = None):
"""
Fetch synchronized lyrics using multiple providers hierarchy:
1. Cache (fastest)
2. yt-dlp (Original Video Captions - best sync for exact video)
3. LRCLIB (Open Source Database - good fuzzy match)
4. syncedlyrics (Musixmatch/NetEase Aggregator - widest coverage)
"""
if not id:
return []
cache_key = f"lyrics:{id}"
cached_lyrics = cache.get(cache_key)
if cached_lyrics:
return cached_lyrics
parsed_lines = []
# Run heavy IO in threadpool
from starlette.concurrency import run_in_threadpool
import syncedlyrics
try:
# --- Strategy 1: yt-dlp (Official Captions) ---
def fetch_ytdlp_subs():
parsed = []
try:
lyrics_dir = CACHE_DIR / "lyrics"
lyrics_dir.mkdir(parents=True, exist_ok=True)
out_tmpl = str(lyrics_dir / f"{id}")
ydl_opts = {
'skip_download': True, 'writesubtitles': True, 'writeautomaticsub': True,
'subtitleslangs': ['en', 'vi'], 'subtitlesformat': 'json3',
'outtmpl': out_tmpl, 'quiet': True
}
url = f"https://www.youtube.com/watch?v={id}"
import glob
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
pattern = str(lyrics_dir / f"{id}.*.json3")
found_files = glob.glob(pattern)
if found_files:
best_file = next((f for f in found_files if f.endswith(f"{id}.en.json3")), found_files[0])
with open(best_file, 'r', encoding='utf-8') as f:
data = json.load(f)
for event in data.get('events', []):
if 'segs' in event and 'tStartMs' in event:
text = "".join([s.get('utf8', '') for s in event['segs']]).strip()
if text and not text.startswith('[') and text != '\n':
parsed.append({"time": float(event['tStartMs']) / 1000.0, "text": text})
except Exception as e:
print(f"yt-dlp sub error: {e}")
return parsed
parsed_lines = await run_in_threadpool(fetch_ytdlp_subs)
# --- Strategy 2: LRCLIB (Search API) ---
if not parsed_lines and title and artist:
print(f"Trying LRCLIB Search for: {title} {artist}")
def fetch_lrclib():
try:
# Fuzzy match using search, not get
cleaned_title = re.sub(r'\(.*?\)', '', title)
clean_query = f"{artist} {cleaned_title}".strip()
resp = requests.get("https://lrclib.net/api/search", params={"q": clean_query}, timeout=5)
if resp.status_code == 200:
results = resp.json()
# Find first result with synced lyrics
for item in results:
if item.get("syncedLyrics"):
return parse_lrc_string(item["syncedLyrics"])
except Exception as e:
print(f"LRCLIB error: {e}")
return []
parsed_lines = await run_in_threadpool(fetch_lrclib)
# --- Strategy 3: syncedlyrics (Aggregator) ---
if not parsed_lines and title and artist:
print(f"Trying SyncedLyrics Aggregator for: {title} {artist}")
def fetch_syncedlyrics():
try:
# syncedlyrics.search returns the LRC string or None
clean_query = f"{title} {artist}".strip()
lrc_str = syncedlyrics.search(clean_query)
if lrc_str:
return parse_lrc_string(lrc_str)
except Exception as e:
print(f"SyncedLyrics error: {e}")
return []
parsed_lines = await run_in_threadpool(fetch_syncedlyrics)
# Cache Result
if parsed_lines:
cache.set(cache_key, parsed_lines, ttl_seconds=86400 * 30)
return parsed_lines
return []
except Exception as e:
print(f"Global Lyrics Error: {e}")
return []
def parse_lrc_string(lrc_content: str):
"""Parses LRC format string into [{time, text}]"""
lines = []
if not lrc_content: return lines
for line in lrc_content.split('\n'):
# Format: [mm:ss.xx] Text
match = re.search(r'\[(\d+):(\d+\.?\d*)\](.*)', line)
if match:
minutes = float(match.group(1))
seconds = float(match.group(2))
text = match.group(3).strip()
total_time = minutes * 60 + seconds
if text:
lines.append({"time": total_time, "text": text})
return lines

53
backend/cache_manager.py Normal file
View file

@ -0,0 +1,53 @@
import json
import time
import hashlib
from pathlib import Path
from typing import Any, Optional
class CacheManager:
def __init__(self, cache_dir: str = "backend/cache"):
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(parents=True, exist_ok=True)
def _get_path(self, key: str) -> Path:
# Create a safe filename from the key
hashed_key = hashlib.md5(key.encode()).hexdigest()
return self.cache_dir / f"{hashed_key}.json"
def get(self, key: str) -> Optional[Any]:
"""
Retrieve data from cache if it exists and hasn't expired.
"""
path = self._get_path(key)
if not path.exists():
return None
try:
with open(path, "r") as f:
data = json.load(f)
# Check TTL
if data["expires_at"] < time.time():
# Expired, delete it
path.unlink()
return None
return data["value"]
except (json.JSONDecodeError, KeyError, OSError):
return None
def set(self, key: str, value: Any, ttl_seconds: int = 3600):
"""
Save data to cache with a TTL (default 1 hour).
"""
path = self._get_path(key)
data = {
"value": value,
"expires_at": time.time() + ttl_seconds,
"key_debug": key # Store original key for debugging
}
try:
with open(path, "w") as f:
json.dump(data, f)
except OSError as e:
print(f"Cache Write Error: {e}")

548
backend/data.json Normal file
View file

@ -0,0 +1,548 @@
{
"id": "PLpY7hx7jry7zc4zspi_fBhWQt8z5jrJ8z",
"title": "Best Vietnamese Songs 2025 - Popular Vietnamese Songs 2025 Playlist (Top Vietnamese Music 2025-2026)",
"description": "Best Vietnamese Songs 2025 - Popular Vietnamese Songs 2025 Playlist (Top Vietnamese Music 2025-2026)\n\nIf you liked this playlist, we recommend you also listen to these music lists:\n\n1. Most Popular Vietnamese Songs 2025 - Best of Vietnamese Music 2025 Playlist (Famous Vietnamese Songs 2025-2026) - https://goplaylists.com/56624\n2. New Vietnamese Songs 2025 - Latest Vietnamese Song 2025 Playlist (New Vietnam Music 2025-2026) - https://goplaylists.com/13081\n\nFind our playlist with these keywords: popular vietnamese songs 2025, top vietnamese songs 2025, best vietnamese music 2025, vietnam music playlist, top vietnamese music 2025, vietnam playlist 2025, vietnamese songs 2025, popular, vietnamese songs, vietnam playlist music, best vietnamese songs 2025, vietnamese playlist 2025, vietnamese hits 2025, vietnamese songs, top vietnam music 2025, vietnam song playlist, top 10 vietnamese songs, vietnam music chart 2025, vietnamese song trends\n\nDive deep into the mesmerizing world of Vietnamese music with a curated collection of the year's most compelling tracks. Experience the rhythm, the emotion, and the soulful voices of Vietnam's top artists. Each song has been handpicked to represent the heartbeat of Vietnam's contemporary music scene, bringing to you an array of melodies that resonate with the beauty and culture of this enchanting nation. Whether you're a longtime fan or a newcomer to Vietnamese tunes, this selection is bound to captivate your senses and take you on an unforgettable musical journey \ud83c\udfb5.\n\nIn the next year, the playlist is going to be titled: Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)\n Last year, the playlist was titled: Best Vietnamese Songs 2024 - Popular Vietnamese Songs 2024 Playlist (Top Vietnamese Music 2024-2025)\n\nShare your thoughts on our playlist: contact@red-music.com",
"cover_url": "https://yt3.googleusercontent.com/JaOpEjRt9S4wYkuVMkbu_2NLadP4vtUfQIpUlfob8mgB3CuoX8AsAJ24tAtbNRXGD2AkekLlbkiU=s1200",
"tracks": [
{
"title": "Kh\u00f3a Ly Bi\u1ec7t (feat. Anh T\u00fa)",
"artist": "The Masked Singer",
"album": "Single",
"duration": 327,
"cover_url": "https://i.ytimg.com/vi/wEPX3V5T63M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kG4sf2WSe1cO3TUeSG4cyGdPXJFg",
"id": "wEPX3V5T63M",
"url": "https://music.youtube.com/watch?v=wEPX3V5T63M"
},
{
"title": "T\u1eebng Ng\u00e0y Y\u00eau Em",
"artist": "buitruonglinh",
"album": "Single",
"duration": 222,
"cover_url": "https://i.ytimg.com/vi/f-VsoLm4i5c/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3loMnKS_mCSDRyYu9wS_TYnA0NAgQ",
"id": "f-VsoLm4i5c",
"url": "https://music.youtube.com/watch?v=f-VsoLm4i5c"
},
{
"title": "M\u1ea5t K\u1ebft N\u1ed1i",
"artist": "D\u01b0\u01a1ng Domic",
"album": "Single",
"duration": 208,
"cover_url": "https://i.ytimg.com/vi/lRsaDQtYqAo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mvidflzqRlL9xdJeDUXZJg_UESRw",
"id": "lRsaDQtYqAo",
"url": "https://music.youtube.com/watch?v=lRsaDQtYqAo"
},
{
"title": "m\u1ed9t \u0111\u1eddi (feat. buitruonglinh)",
"artist": "Bon Nghi\u00eam, 14 Casper",
"album": "Single",
"duration": 329,
"cover_url": "https://i.ytimg.com/vi/JgTZvDbaTtg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lEKS8TNud8_GWknaWc0IQEQWBTgw",
"id": "JgTZvDbaTtg",
"url": "https://music.youtube.com/watch?v=JgTZvDbaTtg"
},
{
"title": "V\u00f9ng An To\u00e0n",
"artist": "V#, B Ray",
"album": "Single",
"duration": 266,
"cover_url": "https://i.ytimg.com/vi/_XX248bq6Pw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nKfVzeukJ8dCNJ_hzcyZAsvJ8upg",
"id": "_XX248bq6Pw",
"url": "https://music.youtube.com/watch?v=_XX248bq6Pw"
},
{
"title": "Qu\u00e2n A.P | C\u00f3 Ai H\u1eb9n H\u00f2 C\u00f9ng Em Ch\u01b0a",
"artist": "Qu\u00e2n A.P",
"album": "Single",
"duration": 319,
"cover_url": "https://i.ytimg.com/vi/zHDECJy0p7k/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nWDqcf0SVJtIipbWQqltt3cNu6eQ",
"id": "zHDECJy0p7k",
"url": "https://music.youtube.com/watch?v=zHDECJy0p7k"
},
{
"title": "b\u00ecnh y\u00ean - V\u0169. (feat. Binz)",
"artist": "V\u0169., Binz",
"album": "Single",
"duration": 203,
"cover_url": "https://i.ytimg.com/vi/f9P7_qWrf38/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kI5gsa8Jegzzu2vFpJBhLk58mGeg",
"id": "f9P7_qWrf38",
"url": "https://music.youtube.com/watch?v=f9P7_qWrf38"
},
{
"title": "n\u1ebfu l\u00fac \u0111\u00f3 (feat. 2pillz)",
"artist": "Tlinh",
"album": "Single",
"duration": 325,
"cover_url": "https://i.ytimg.com/vi/fyMgBQioTLo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kNXGGAK5wy2ix4mQ1pNwlGLYUg0Q",
"id": "fyMgBQioTLo",
"url": "https://music.youtube.com/watch?v=fyMgBQioTLo"
},
{
"title": "\u0110\u1eebng L\u00e0m Tr\u00e1i Tim Anh \u0110au",
"artist": "S\u01a1n T\u00f9ng M-TP",
"album": "Single",
"duration": 326,
"cover_url": "https://i.ytimg.com/vi/abPmZCZZrFA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nMzmdGlrfqmf8o9z-E5waTnqFXxA",
"id": "abPmZCZZrFA",
"url": "https://music.youtube.com/watch?v=abPmZCZZrFA"
},
{
"title": "N\u1ed7i \u0110au Gi\u1eefa H\u00f2a B\u00ecnh",
"artist": "H\u00f2a Minzy, Nguyen Van Chung",
"album": "Single",
"duration": 454,
"cover_url": "https://i.ytimg.com/vi/yHikkFeIHNA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mhpsSG0tDGSBKkAK1X81aY1nrfgg",
"id": "yHikkFeIHNA",
"url": "https://music.youtube.com/watch?v=yHikkFeIHNA"
},
{
"title": "10 Shots",
"artist": "Dax",
"album": "Single",
"duration": 233,
"cover_url": "https://i.ytimg.com/vi/0XMFwdfMQmQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3napt1cPSL4BTo7SSeDyrRUU7XF0Q",
"id": "0XMFwdfMQmQ",
"url": "https://music.youtube.com/watch?v=0XMFwdfMQmQ"
},
{
"title": "Ch\u0103m Hoa",
"artist": "MONO",
"album": "Single",
"duration": 260,
"cover_url": "https://i.ytimg.com/vi/WCm2elbTEZQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kW5xCqL0V0Q9miffXVKmSRnn3S8A",
"id": "WCm2elbTEZQ",
"url": "https://music.youtube.com/watch?v=WCm2elbTEZQ"
},
{
"title": "id 072019",
"artist": "W/n",
"album": "Single",
"duration": 303,
"cover_url": "https://i.ytimg.com/vi/leJb3VhQCrg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nWBTudc9VK3UqnpCgc_j8QYH3ugg",
"id": "leJb3VhQCrg",
"url": "https://music.youtube.com/watch?v=leJb3VhQCrg"
},
{
"title": "Gi\u1edd Th\u00ec",
"artist": "buitruonglinh",
"album": "Single",
"duration": 238,
"cover_url": "https://i.ytimg.com/vi/69ZDBWoj5YM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3krSRZmxC0XjjdQN0wwPoZbJ-unGQ",
"id": "69ZDBWoj5YM",
"url": "https://music.youtube.com/watch?v=69ZDBWoj5YM"
},
{
"title": "ERIK - 'D\u00f9 cho t\u1eadn th\u1ebf (v\u1eabn y\u00eau em)' | Official MV | Valentine 2025",
"artist": "ERIK",
"album": "Single",
"duration": 301,
"cover_url": "https://i.ytimg.com/vi/js6JBdLzNn4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nh_ITbZeDs1TJfrWuPEim8MKpj9g",
"id": "js6JBdLzNn4",
"url": "https://music.youtube.com/watch?v=js6JBdLzNn4"
},
{
"title": "Ng\u00e0y Mai Ng\u01b0\u1eddi Ta L\u1ea5y Ch\u1ed3ng",
"artist": "Th\u00e0nh \u0110\u1ea1t",
"album": "Single",
"duration": 421,
"cover_url": "https://i.ytimg.com/vi/WL11bwvAYWI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3l10haMFB1HcY7p5muA1kJL5tz1cA",
"id": "WL11bwvAYWI",
"url": "https://music.youtube.com/watch?v=WL11bwvAYWI"
},
{
"title": "B\u1ea7u Tr\u1eddi M\u1edbi (feat. Minh Toc)",
"artist": "Da LAB",
"album": "Single",
"duration": 291,
"cover_url": "https://i.ytimg.com/vi/Z1D26z9l8y8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3k34PODWHnu_p49YHu35__8V-4avw",
"id": "Z1D26z9l8y8",
"url": "https://music.youtube.com/watch?v=Z1D26z9l8y8"
},
{
"title": "C\u00e1nh Hoa H\u00e9o T\u00e0n (DJ Trang Moon Remix)",
"artist": "ACV, Mochiii",
"album": "Single",
"duration": 265,
"cover_url": "https://i.ytimg.com/vi/YizrmzMvr7Q/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3luIG3PhCNjJlZjuCRBwAKKrMPt9Q",
"id": "YizrmzMvr7Q",
"url": "https://music.youtube.com/watch?v=YizrmzMvr7Q"
},
{
"title": "SOOBIN - gi\u00e1 nh\u01b0 | 'B\u1eacT N\u00d3 L\u00caN' Album (Music Video)",
"artist": "SOOBIN",
"album": "Single",
"duration": 310,
"cover_url": "https://i.ytimg.com/vi/SeWt7IpZ0CA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lYIMR-uDbHo5-B3GO0z5XPqKIcaQ",
"id": "SeWt7IpZ0CA",
"url": "https://music.youtube.com/watch?v=SeWt7IpZ0CA"
},
{
"title": "Vuon Hoa Con Ca",
"artist": "Olew",
"album": "Single",
"duration": 234,
"cover_url": "https://i.ytimg.com/vi/BFflHDlTeHw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nA_WhH_ZnanoXMGeQ-4d4hYSUbUg",
"id": "BFflHDlTeHw",
"url": "https://music.youtube.com/watch?v=BFflHDlTeHw"
},
{
"title": "G\u1eb7p L\u1ea1i N\u0103m Ta 60",
"artist": "Orange",
"album": "Single",
"duration": 337,
"cover_url": "https://i.ytimg.com/vi/ZXNrz72k1ew/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kqqJWC4ogBjc4u12JzCHk2YMBKWA",
"id": "ZXNrz72k1ew",
"url": "https://music.youtube.com/watch?v=ZXNrz72k1ew"
},
{
"title": "You're The Problem",
"artist": "Dax",
"album": "Single",
"duration": 230,
"cover_url": "https://i.ytimg.com/vi/-kA2ReyByZU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kg2w-R3-05ocVT0g03RyIQJ41F4Q",
"id": "-kA2ReyByZU",
"url": "https://music.youtube.com/watch?v=-kA2ReyByZU"
},
{
"title": "SOOBIN - Dancing In The Dark | 'B\u1eacT N\u00d3 L\u00caN' Album",
"artist": "SOOBIN",
"album": "Single",
"duration": 279,
"cover_url": "https://i.ytimg.com/vi/OZmK0YuSmXU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mBF8aJUqrrJIQduCkE_BAGkeucDA",
"id": "OZmK0YuSmXU",
"url": "https://music.youtube.com/watch?v=OZmK0YuSmXU"
},
{
"title": "Lao T\u00e2m Kh\u1ed5 T\u1ee9",
"artist": "Thanh H\u01b0ng",
"album": "Single",
"duration": 348,
"cover_url": "https://i.ytimg.com/vi/TfKOFRpqSME/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n85vMTLZIA2MAj83vqnYk4pomt0Q",
"id": "TfKOFRpqSME",
"url": "https://music.youtube.com/watch?v=TfKOFRpqSME"
},
{
"title": "N\u1ea5u \u0102n Cho Em (feat. PIALINH)",
"artist": "\u0110en",
"album": "Single",
"duration": 257,
"cover_url": "https://i.ytimg.com/vi/ukHK1GVyr0I/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nD2JOikDSq_cCeBaG-VH6LBYriJg",
"id": "ukHK1GVyr0I",
"url": "https://music.youtube.com/watch?v=ukHK1GVyr0I"
},
{
"title": "T\u1ebft B\u00ecnh An Remix, Hana C\u1ea9m Ti\u00ean, \u0110\u1ea1i M\u00e8o | M\u1ed9t N\u0103m C\u0169 \u0110\u00e3 Qua C\u00f9ng Nhau \u0110\u00f3n N\u0103m M\u1edbi B\u00ecnh An Mu\u00f4n Nh\u00e0",
"artist": "BD Media Music",
"album": "Single",
"duration": 227,
"cover_url": "https://i.ytimg.com/vi/fMskPmI4tp0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3m8nsW6nX2B8BJf4gsU36uDsmDCgw",
"id": "fMskPmI4tp0",
"url": "https://music.youtube.com/watch?v=fMskPmI4tp0"
},
{
"title": "T\u1eebng L\u00e0",
"artist": "V\u0169 C\u00e1t T\u01b0\u1eddng",
"album": "Single",
"duration": 277,
"cover_url": "https://i.ytimg.com/vi/i4qZmKSFYvI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kLaE-0VAlEfGQRlKBACGiK0w0WDw",
"id": "i4qZmKSFYvI",
"url": "https://music.youtube.com/watch?v=i4qZmKSFYvI"
},
{
"title": "N\u01a1i Ph\u00e1o Hoa R\u1ef1c R\u1ee1 (feat. C\u1ea9m V\u00e2n)",
"artist": "Hua Kim Tuyen, Orange, Ho\u00e0ng D\u0169ng",
"album": "Single",
"duration": 310,
"cover_url": "https://i.ytimg.com/vi/BgUFNi5MvzE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mGOmc3dRUaQczZnhubm_nb8Gs_Uw",
"id": "BgUFNi5MvzE",
"url": "https://music.youtube.com/watch?v=BgUFNi5MvzE"
},
{
"title": "Ng\u01b0\u1eddi B\u00ecnh Th\u01b0\u1eddng",
"artist": "V\u0169 C\u00e1t T\u01b0\u1eddng",
"album": "Single",
"duration": 285,
"cover_url": "https://i.ytimg.com/vi/X5KvHXWPYm4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lPWTFBiFDjAliZkS614MkwVcte1g",
"id": "X5KvHXWPYm4",
"url": "https://music.youtube.com/watch?v=X5KvHXWPYm4"
},
{
"title": "C\u00f3 Em L\u00e0 \u0110i\u1ec1u Tuy\u1ec7t V\u1eddi Nh\u1ea5t (Th\u1ecbnh H\u00e0nh)",
"artist": "Thi\u00ean T\u00fa",
"album": "Single",
"duration": 371,
"cover_url": "https://i.ytimg.com/vi/IenfKDtyMI0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nbyyByYoJQ2qV7-2w4S6Gyofj9dQ",
"id": "IenfKDtyMI0",
"url": "https://music.youtube.com/watch?v=IenfKDtyMI0"
},
{
"title": "Nh\u1eefng L\u1eddi H\u1ee9a B\u1ecf Qu\u00ean",
"artist": "V\u0169., Dear Jane",
"album": "Single",
"duration": 259,
"cover_url": "https://i.ytimg.com/vi/h6RONxjPBf4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfvRCueWOo-OjD8_3sK9HSlhvoSw",
"id": "h6RONxjPBf4",
"url": "https://music.youtube.com/watch?v=h6RONxjPBf4"
},
{
"title": "m\u1ed9t b\u00e0i h\u00e1t kh\u00f4ng vui m\u1ea5y (Extended Version)",
"artist": "T.R.I, Dangrangto, DONAL",
"album": "Single",
"duration": 230,
"cover_url": "https://i.ytimg.com/vi/EvPEeSBfB3E/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kvhX3tBQICPMgOEn0R9uswYvdC5A",
"id": "EvPEeSBfB3E",
"url": "https://music.youtube.com/watch?v=EvPEeSBfB3E"
},
{
"title": "One Time",
"artist": "Raw Dawg",
"album": "Single",
"duration": 119,
"cover_url": "https://i.ytimg.com/vi/ylh1oDhP2AE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lRAtyI5EucwyXxXGb9YLfFY2W6aQ",
"id": "ylh1oDhP2AE",
"url": "https://music.youtube.com/watch?v=ylh1oDhP2AE"
},
{
"title": "MIN - ch\u1eb3ng ph\u1ea3i t\u00ecnh \u0111\u1ea7u sao \u0111au \u0111\u1ebfn th\u1ebf feat. Dangrangto, Antransax (Official Audio)",
"artist": "MIN",
"album": "Single",
"duration": 284,
"cover_url": "https://i.ytimg.com/vi/rLNvDu59ffI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfhSKyeyGqrokp13H6G7C1rNvKLg",
"id": "rLNvDu59ffI",
"url": "https://music.youtube.com/watch?v=rLNvDu59ffI"
},
{
"title": "Ng\u01b0\u1eddi \u0110\u1ea7u Ti\u00ean",
"artist": "Juky San, buitruonglinh",
"album": "Single",
"duration": 220,
"cover_url": "https://i.ytimg.com/vi/i54avTdUqwU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3klVzmY8CCpa5CLEP3BIema5Lhgyw",
"id": "i54avTdUqwU",
"url": "https://music.youtube.com/watch?v=i54avTdUqwU"
},
{
"title": "MIN - ch\u1eb3ng ph\u1ea3i t\u00ecnh \u0111\u1ea7u sao \u0111au \u0111\u1ebfn th\u1ebf feat. Dangrangto, antransax (Official Visual Stage)",
"artist": "MIN OFFICIAL",
"album": "Single",
"duration": 288,
"cover_url": "https://i.ytimg.com/vi/s0OMNH-N5D8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3k_uFbHN8ud3zNWnb5hdzcYLhUgWA",
"id": "s0OMNH-N5D8",
"url": "https://music.youtube.com/watch?v=s0OMNH-N5D8"
},
{
"title": "Em",
"artist": "Binz",
"album": "Single",
"duration": 208,
"cover_url": "https://i.ytimg.com/vi/CU2PtRKBkuw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mnGQ84aQvDmEMNCd5m6b-_pyKbUg",
"id": "CU2PtRKBkuw",
"url": "https://music.youtube.com/watch?v=CU2PtRKBkuw"
},
{
"title": "HO\u1ea0 S\u0128 T\u1ed2I - TH\u00c1I H\u1eccC x \u0110\u1ea0T MAX | Official MV | Anh v\u1ebd c\u1ea7u v\u1ed3ng th\u00ec l\u1ea1i thi\u1ebfu n\u1eafng",
"artist": "Th\u00e1i H\u1ecdc",
"album": "Single",
"duration": 285,
"cover_url": "https://i.ytimg.com/vi/RF0jYdTXQK4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nAfOOBrWfNICLXduP5GPktAPARCg",
"id": "RF0jYdTXQK4",
"url": "https://music.youtube.com/watch?v=RF0jYdTXQK4"
},
{
"title": "T\u00ecnh Nh\u01b0 L\u00e1 Bay Xa (Live)",
"artist": "Jimmy Nguyen, M\u1ef8 T\u00c2M",
"album": "Single",
"duration": 273,
"cover_url": "https://i.ytimg.com/vi/gxPoI_tldfQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nQp0dsN1t1shvvjBq0A2m-EyfvBg",
"id": "gxPoI_tldfQ",
"url": "https://music.youtube.com/watch?v=gxPoI_tldfQ"
},
{
"title": "Kh\u1ed5ng Minh x Ch\u00e2u Nhu\u1eadn Ph\u00e1t - ''E L\u00c0 \u0110\u00d4N CH\u1ec0'' Prod.@tiengaz",
"artist": "Dagoats House",
"album": "Single",
"duration": 191,
"cover_url": "https://i.ytimg.com/vi/K01LvulhFRg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n8fN2PiGDRGtGv0VZMp_OOW2kBoQ",
"id": "K01LvulhFRg",
"url": "https://music.youtube.com/watch?v=K01LvulhFRg"
},
{
"title": "M\u1ee5c H\u1ea1 V\u00f4 Nh\u00e2n (feat. Binz)",
"artist": "SOOBIN, Hu\u1ef3nh T\u00fa",
"album": "Single",
"duration": 355,
"cover_url": "https://i.ytimg.com/vi/FikdKWos-NQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lgfIwIcM4zdZGPxZN-kcs96iJyGQ",
"id": "FikdKWos-NQ",
"url": "https://music.youtube.com/watch?v=FikdKWos-NQ"
},
{
"title": "10 M\u1ea4T 1 C\u00d2N KH\u00d4NG - TH\u00c1I H\u1eccC x L\u00ca GIA B\u1ea2O (New Version) | St: Long H\u1ecd Hu\u1ef3nh",
"artist": "Th\u00e1i H\u1ecdc",
"album": "Single",
"duration": 285,
"cover_url": "https://i.ytimg.com/vi/9HnyyKg0M-Y/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lyCTROUhGaahuDenut3oMfnOesDQ",
"id": "9HnyyKg0M-Y",
"url": "https://music.youtube.com/watch?v=9HnyyKg0M-Y"
},
{
"title": "Mr Siro | Day D\u1ee9t N\u1ed7i \u0110au",
"artist": "Mr. Siro",
"album": "Single",
"duration": 368,
"cover_url": "https://i.ytimg.com/vi/N4Xak1n497M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nZ6HzRlVHFya6aliEsGSZuGB_QxA",
"id": "N4Xak1n497M",
"url": "https://music.youtube.com/watch?v=N4Xak1n497M"
},
{
"title": "Diary Of A Trying Man",
"artist": "Dax",
"album": "Single",
"duration": 234,
"cover_url": "https://i.ytimg.com/vi/WulTil-Wwoo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lC8LD69LfTh3hrClQoWJGA3pCjCw",
"id": "WulTil-Wwoo",
"url": "https://music.youtube.com/watch?v=WulTil-Wwoo"
},
{
"title": "Feel At Home",
"artist": "B Ray",
"album": "Single",
"duration": 164,
"cover_url": "https://i.ytimg.com/vi/6x1yluqMuc4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfaiwiYPKbD_v3rvKR1QC1Sw9Znw",
"id": "6x1yluqMuc4",
"url": "https://music.youtube.com/watch?v=6x1yluqMuc4"
},
{
"title": "L\u00e1 Th\u01b0 \u0110\u00f4 Th\u1ecb",
"artist": "Th\u00fay H\u00e0",
"album": "Single",
"duration": 355,
"cover_url": "https://i.ytimg.com/vi/42m7T272u8I/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3moQljTHbKdPZ3c48rcbJiq4KILjQ",
"id": "42m7T272u8I",
"url": "https://music.youtube.com/watch?v=42m7T272u8I"
},
{
"title": "R\u1eddi B\u1ecf N\u01a1i Anh Bi\u1ebft Em C\u00f3 Vui B\u00ean Ng\u01b0\u1eddi Remix | TH\u01af\u01a0NG TH\u00cc TH\u00d4I REMIX B\u1ea3n Si\u00eau Th\u1ea5m BeBe...",
"artist": "ACV",
"album": "Single",
"duration": 195,
"cover_url": "https://i.ytimg.com/vi/Hq_Q9vSIg2M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n3JlqdmwyqK_me1eqnMQVrNeL6ZA",
"id": "Hq_Q9vSIg2M",
"url": "https://music.youtube.com/watch?v=Hq_Q9vSIg2M"
},
{
"title": "Gi\u1eef Anh Cho Ng\u00e0y H\u00f4m Qua",
"artist": "Ho\u00e0ng D\u0169ng, Rhymastic, Lelarec",
"album": "Single",
"duration": 345,
"cover_url": "https://i.ytimg.com/vi/IADhKnmQMtk/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nzDVE9hb0vpWAYZ39Ghi-6BrG-9g",
"id": "IADhKnmQMtk",
"url": "https://music.youtube.com/watch?v=IADhKnmQMtk"
},
{
"title": "Mr Siro | T\u1ef1 Lau N\u01b0\u1edbc M\u1eaft",
"artist": "Mr. Siro",
"album": "Single",
"duration": 330,
"cover_url": "https://i.ytimg.com/vi/tgvXGxCrBmE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mNQ5AIuHnGA4XZQwvFh_WRi1DmAg",
"id": "tgvXGxCrBmE",
"url": "https://music.youtube.com/watch?v=tgvXGxCrBmE"
},
{
"title": "She Never Cries (feat. S\u01a0N.K)",
"artist": "Ho\u00e0ng Duy\u00ean",
"album": "Single",
"duration": 264,
"cover_url": "https://i.ytimg.com/vi/zuBjkHOFVJs/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kdUzhae-xLYnaf7b45tXbDDxr71A",
"id": "zuBjkHOFVJs",
"url": "https://music.youtube.com/watch?v=zuBjkHOFVJs"
},
{
"title": "Ch\u1edd Bao L\u00e2u (feat. H\u00e0o JK)",
"artist": "\u00dat Nh\u1ecb Mino",
"album": "Single",
"duration": 193,
"cover_url": "https://i.ytimg.com/vi/KO0CbNNvd14/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mL5syc6JwJoWuHasdnfhrSAFITHA",
"id": "KO0CbNNvd14",
"url": "https://music.youtube.com/watch?v=KO0CbNNvd14"
},
{
"title": "C\u00d4 G\u00c1I \u00c0 \u0110\u1eeaNG KH\u00d3C | \u00daT NH\u1eca MINO FT NVC MUSIC",
"artist": "\u00dat Nh\u1ecb Mino",
"album": "Single",
"duration": 266,
"cover_url": "https://i.ytimg.com/vi/oH9_c7Y5zMQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kBh2R8cqyDQN98Jd9CIO1RZBbVNQ",
"id": "oH9_c7Y5zMQ",
"url": "https://music.youtube.com/watch?v=oH9_c7Y5zMQ"
},
{
"title": "R\u1ea5t L\u00e2u R\u1ed3i M\u1edbi Kh\u00f3c (Solo Version)",
"artist": "",
"album": "Single",
"duration": 311,
"cover_url": "https://i.ytimg.com/vi/MWowv3A-fQc/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kWiKMUSyg-xYgdrzO26ICDnO6Gpg",
"id": "MWowv3A-fQc",
"url": "https://music.youtube.com/watch?v=MWowv3A-fQc"
},
{
"title": "Ring Ring",
"artist": "MIRA",
"album": "Single",
"duration": 147,
"cover_url": "https://i.ytimg.com/vi/mkCaf6tuhGM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lyjmmeuudBzy9Yu64rGLaWENa4tg",
"id": "mkCaf6tuhGM",
"url": "https://music.youtube.com/watch?v=mkCaf6tuhGM"
},
{
"title": "B\u1ea3o Anh | C\u00f4 \u1ea4y C\u1ee7a Anh \u1ea4y",
"artist": "B\u1ea3o Anh ",
"album": "Single",
"duration": 324,
"cover_url": "https://i.ytimg.com/vi/RlTDbIutJsU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kTrRLvQFATZub9py_upYtU7SUaJQ",
"id": "RlTDbIutJsU",
"url": "https://music.youtube.com/watch?v=RlTDbIutJsU"
},
{
"title": "\u0110\u1ecaA \u0110\u00c0NG REMIX - HO\u00c0NG OANH x ACV | N\u00f3i Anh Nghe \u0110\u1ecba \u0110\u00e0ng M\u1edf C\u1eeda L\u00e0 \u0110\u1ec3 Ch\u1edd B\u01b0\u1edbc Ch\u00e2n Em Ph\u1ea3i Kh\u00f4ng ?",
"artist": "ACV",
"album": "Single",
"duration": 311,
"cover_url": "https://i.ytimg.com/vi/vZzzcflS2HM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lMoxUQD5_wrptAPCqUYBxD0MKndw",
"id": "vZzzcflS2HM",
"url": "https://music.youtube.com/watch?v=vZzzcflS2HM"
},
{
"title": "T\u1eebng quen",
"artist": "itsnk, Wren Evans",
"album": "Single",
"duration": 175,
"cover_url": "https://i.ytimg.com/vi/zepHPnUDROE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kQphjp4tiW4vFcaXJBk1wMtsk9Kg",
"id": "zepHPnUDROE",
"url": "https://music.youtube.com/watch?v=zepHPnUDROE"
},
{
"title": "HOA B\u1ea4T T\u1eec",
"artist": "Th\u00e0nh \u0110\u1ea1t",
"album": "Single",
"duration": 317,
"cover_url": "https://i.ytimg.com/vi/n-k_aUsOaaQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lfd3LBuB7aTSG880J0HqdjEqNQww",
"id": "n-k_aUsOaaQ",
"url": "https://music.youtube.com/watch?v=n-k_aUsOaaQ"
},
{
"title": "N\u00f3i D\u1ed1i | Ronboogz (Lyrics Video)",
"artist": "Ronboogz",
"album": "Single",
"duration": 207,
"cover_url": "https://i.ytimg.com/vi/ri-TFS97Hbw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lgdDfcXHekuevzN7qPIZR7RryanQ",
"id": "ri-TFS97Hbw",
"url": "https://music.youtube.com/watch?v=ri-TFS97Hbw"
},
{
"title": "MONO - \u2018Em Xinh\u2019 (Official Music Video)",
"artist": "MONO",
"album": "Single",
"duration": 197,
"cover_url": "https://i.ytimg.com/vi/rYc1UbgbMIY/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mkHo5h-7KAI9SGhk2jG6m6cHospQ",
"id": "rYc1UbgbMIY",
"url": "https://music.youtube.com/watch?v=rYc1UbgbMIY"
}
]
}

File diff suppressed because it is too large Load diff

59
backend/main.py Normal file
View file

@ -0,0 +1,59 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from backend.api import routes
import os
app = FastAPI(title="Spotify Clone Backend")
# CORS setup
origins = [
"http://localhost:3000",
"http://127.0.0.1:3000",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(routes.router, prefix="/api")
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
# ... existing code ...
app.include_router(routes.router, prefix="/api")
# Serve Static Frontend (Production Mode)
STATIC_DIR = "static"
if os.path.exists(STATIC_DIR):
app.mount("/_next", StaticFiles(directory=os.path.join(STATIC_DIR, "_next")), name="next_assets")
# Serve other static files (favicons etc) if they exist in root of static
# Or just fallback everything else to index.html for SPA
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
# Check if file exists in static folder
file_path = os.path.join(STATIC_DIR, full_path)
if os.path.isfile(file_path):
return FileResponse(file_path)
# Otherwise return index.html
index_path = os.path.join(STATIC_DIR, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
return {"error": "Frontend not found"}
else:
@app.get("/")
def read_root():
return {"message": "Spotify Clone Backend Running (Frontend not built/mounted)"}
@app.get("/health")
def health_check():
return {"status": "ok"}

View file

@ -0,0 +1,88 @@
import json
import uuid
from pathlib import Path
from typing import List, Dict, Optional
DATA_FILE = Path("backend/data/user_playlists.json")
class PlaylistManager:
def __init__(self):
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
if not DATA_FILE.exists():
self._save_data([])
def _load_data(self) -> List[Dict]:
try:
with open(DATA_FILE, "r") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return []
def _save_data(self, data: List[Dict]):
with open(DATA_FILE, "w") as f:
json.dump(data, f, indent=4)
def get_all(self) -> List[Dict]:
return self._load_data()
def get_by_id(self, playlist_id: str) -> Optional[Dict]:
playlists = self._load_data()
for p in playlists:
if p["id"] == playlist_id:
return p
return None
def create(self, name: str, description: str = "") -> Dict:
playlists = self._load_data()
new_playlist = {
"id": str(uuid.uuid4()),
"title": name,
"description": description,
"tracks": [],
"cover_url": "https://placehold.co/400?text=Playlist", # Default placeholder
"is_user_created": True
}
playlists.append(new_playlist)
self._save_data(playlists)
return new_playlist
def update(self, playlist_id: str, name: str = None, description: str = None) -> Optional[Dict]:
playlists = self._load_data()
for p in playlists:
if p["id"] == playlist_id:
if name: p["title"] = name
if description: p["description"] = description
self._save_data(playlists)
return p
return None
def delete(self, playlist_id: str) -> bool:
playlists = self._load_data()
initial_len = len(playlists)
playlists = [p for p in playlists if p["id"] != playlist_id]
if len(playlists) < initial_len:
self._save_data(playlists)
return True
return False
def add_track(self, playlist_id: str, track: Dict) -> bool:
playlists = self._load_data()
for p in playlists:
if p["id"] == playlist_id:
# Check for duplicates? For now allow.
p["tracks"].append(track)
# Update cover if it's the first track
if len(p["tracks"]) == 1 and track.get("cover_url"):
p["cover_url"] = track["cover_url"]
self._save_data(playlists)
return True
return False
def remove_track(self, playlist_id: str, track_id: str) -> bool:
playlists = self._load_data()
for p in playlists:
if p["id"] == playlist_id:
p["tracks"] = [t for t in p["tracks"] if t.get("id") != track_id]
self._save_data(playlists)
return True
return False

9
backend/requirements.txt Normal file
View file

@ -0,0 +1,9 @@
fastapi
uvicorn
spotdl
pydantic
python-multipart
requests
yt-dlp
ytmusicapi
syncedlyrics

View file

@ -0,0 +1,60 @@
from ytmusicapi import YTMusic
import json
from pathlib import Path
def fetch_content():
yt = YTMusic()
# Categorized Queries
CATEGORIES = {
"Vietnam Top": ["Vietnam Top 50", "V-Pop Hot", "Rap Viet", "Indie Vietnam"],
"Global Top": ["Global Top 50", "US-UK Top Hits", "Pop Rising", "Viral 50 Global"],
"K-Pop": ["K-Pop Hits", "Best of K-Pop", "K-Pop Rising", "BLACKPINK Essentials"],
"Chill": ["Lofi Girl", "Coffee Shop Vibes", "Piano Relax", "Sleep Sounds"],
"Party": ["Party Hits", "EDM Best", "Workout Motivation", "Vinahouse Beat"]
}
segmented_content = {}
seen_ids = set()
print("Fetching Browse Content...")
for category, queries in CATEGORIES.items():
print(f"--- Processing Category: {category} ---")
category_playlists = []
for q in queries:
try:
print(f"Searching for: {q}")
# Fetch more results to ensure we get good matches
results = yt.search(q, filter="playlists", limit=4)
for res in results:
pid = res.get("browseId")
if pid and pid not in seen_ids:
seen_ids.add(pid)
# Store minimal info for the card
category_playlists.append({
"id": pid,
"title": res.get("title"),
"description": f"Based on '{q}'",
"cover_url": res.get("thumbnails")[-1]["url"] if res.get("thumbnails") else "",
"author": res.get("author") or "YouTube Music"
})
except Exception as e:
print(f"Error serving {q}: {e}")
segmented_content[category] = category_playlists
output_path = Path("backend/data/browse_playlists.json")
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f:
json.dump(segmented_content, f, indent=4)
total_playlists = sum(len(p) for p in segmented_content.values())
print(f"Successfully saved {total_playlists} playlists across {len(segmented_content)} categories to {output_path}")
if __name__ == "__main__":
fetch_content()

View file

@ -0,0 +1,21 @@
from ytmusicapi import YTMusic
import json
yt = YTMusic()
# Example ID (Son Tung M-TP - Chung Ta Cua Hien Tai)
video_id = "lTdoH5uL6Ew"
print(f"Fetching lyrics for {video_id}...")
try:
watch_playlist = yt.get_watch_playlist(videoId=video_id)
if 'lyrics' in watch_playlist:
lyrics_id = watch_playlist['lyrics']
print(f"Found Lyrics ID: {lyrics_id}")
lyrics = yt.get_lyrics(lyrics_id)
print(json.dumps(lyrics, indent=2))
else:
print("No lyrics found in watch playlist.")
except Exception as e:
print(f"Error: {e}")

2700
data/browse_playlists.json Normal file

File diff suppressed because it is too large Load diff

1
data/user_playlists.json Normal file
View file

@ -0,0 +1 @@
[]

41
deploy.bat Normal file
View file

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

11
docker-compose.yml Normal file
View file

@ -0,0 +1,11 @@
services:
spotify-clone:
image: vndangkhoa/spotify-clone:latest
container_name: spotify-clone
restart: always
network_mode: bridge # Synology often prefers explicit bridge or host
ports:
- "3110:3000" # Web UI
volumes:
- ./data:/app/backend/data

95
fetch_data.py Normal file
View file

@ -0,0 +1,95 @@
from ytmusicapi import YTMusic
import json
import os
from pathlib import Path
yt = YTMusic()
CATEGORIES = {
"Trending Vietnam": "Top 50 Vietnam",
"Vietnamese Artists": "Vietnamese Pop Hits",
"Ballad Singers": "Vietnamese Ballad",
"DJ & Remix": "Vinahouse Remix Vietnam",
"YouTube Stars": "Vietnamese Cover Songs"
}
browse_data = {}
print("Starting data fetch...")
for category, query in CATEGORIES.items():
print(f"\n--- Fetching Category: {category} (Query: '{query}') ---")
try:
results = yt.search(query, filter="playlists", limit=5)
category_playlists = []
for p_result in results[:4]: # Limit to 4 playlists per category
playlist_id = p_result['browseId']
print(f" > Processing: {p_result['title']}")
try:
# Fetch full playlist details
playlist_data = yt.get_playlist(playlist_id, limit=50)
# Process Tracks
output_tracks = []
for track in playlist_data.get('tracks', []):
artists_list = track.get('artists') or []
if isinstance(artists_list, list):
artists = ", ".join([a.get('name', 'Unknown') for a in artists_list])
else:
artists = "Unknown Artist"
thumbnails = track.get('thumbnails', [])
cover_url = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300"
album_info = track.get('album')
album_name = album_info.get('name', 'Single') if album_info else "Single"
output_tracks.append({
"title": track.get('title', 'Unknown Title'),
"artist": artists,
"album": album_name,
"duration": track.get('duration_seconds', 0),
"cover_url": cover_url,
"id": track.get('videoId', 'unknown'),
"url": f"https://music.youtube.com/watch?v={track.get('videoId', '')}"
})
# Process Playlist Info
p_thumbnails = playlist_data.get('thumbnails', [])
p_cover = p_thumbnails[-1]['url'] if p_thumbnails else "https://placehold.co/300x300"
category_playlists.append({
"id": playlist_data.get('id'),
"title": playlist_data.get('title'),
"description": playlist_data.get('description', '') or f"Best of {category}",
"cover_url": p_cover,
"tracks": output_tracks
})
except Exception as e:
print(f" Error processing playlist {playlist_id}: {e}")
continue
if category_playlists:
browse_data[category] = category_playlists
except Exception as e:
print(f"Error searching category {category}: {e}")
# Save to backend/data/browse_playlists.json
output_path = Path("backend/data/browse_playlists.json")
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding='utf-8') as f:
json.dump(browse_data, f, indent=2)
# Also save a flat list for Trending (backward compatibility)
if "Trending Vietnam" in browse_data and browse_data["Trending Vietnam"]:
flat_trending = browse_data["Trending Vietnam"][0]
with open("backend/data.json", "w", encoding='utf-8') as f:
json.dump(flat_trending, f, indent=2)
print("\nAll Done! Saved to backend/data/browse_playlists.json")

41
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
frontend/README.md Normal file
View file

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View file

@ -0,0 +1,89 @@
"use client";
import { usePlayer } from "@/context/PlayerContext";
import { Play, Pause, Clock, Heart } from "lucide-react";
export default function LikedSongsPage() {
const { likedTracksData, playTrack, currentTrack, isPlaying } = usePlayer();
const handlePlay = (track: any) => {
playTrack(track, likedTracksData);
};
const formatDuration = (seconds: number) => {
if (!seconds) return "-:--";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="h-full overflow-y-auto no-scrollbar bg-gradient-to-b from-indigo-900 via-[#121212] to-[#121212]">
{/* Header */}
<div className="flex items-end gap-6 p-8 bg-gradient-to-b from-transparent to-black/20">
<div className="w-52 h-52 bg-gradient-to-br from-indigo-700 to-blue-300 shadow-2xl flex items-center justify-center rounded-md">
<Heart className="w-24 h-24 text-white fill-white" />
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-bold uppercase">Playlist</span>
<h1 className="text-8xl font-black tracking-tight text-white mb-4">Liked Songs</h1>
<div className="flex items-center gap-2 text-sm font-medium text-white/90">
<div className="w-6 h-6 rounded-full bg-green-500 flex items-center justify-center text-black text-xs font-bold">K</div>
<span className="hover:underline cursor-pointer">Khoa Vo</span>
<span></span>
<span>{likedTracksData.length} songs</span>
</div>
</div>
</div>
{/* Controls */}
<div className="px-8 py-6 relative z-10">
<div className="bg-green-500 rounded-full w-14 h-14 flex items-center justify-center hover:scale-105 transition cursor-pointer shadow-lg">
<Play className="w-7 h-7 text-black fill-black ml-1" />
</div>
</div>
{/* List */}
<div className="px-8 pb-8">
{/* Header Row */}
<div className="grid grid-cols-[16px_4fr_3fr_minmax(120px,1fr)] gap-4 px-4 py-2 border-b border-[#ffffff1a] text-sm text-spotify-text-muted mb-4">
<span>#</span>
<span>Title</span>
<span>Album</span>
<div className="flex justify-end pr-8"><Clock className="w-4 h-4" /></div>
</div>
{likedTracksData.length > 0 ? (
likedTracksData.map((track, i) => {
const isCurrent = currentTrack?.id === track.id;
return (
<div
key={track.id}
onClick={() => handlePlay(track)}
className={`grid grid-cols-[16px_4fr_3fr_minmax(120px,1fr)] gap-4 px-4 py-2 rounded-md hover:bg-[#ffffff1a] group cursor-pointer transition items-center text-sm text-spotify-text-muted hover:text-white ${isCurrent ? 'bg-[#ffffff1a]' : ''}`}
>
<span className="group-hover:hidden text-center">{i + 1}</span>
<span className="hidden group-hover:block text-white"><Play className="w-3 h-3 fill-white" /></span>
<div className="flex items-center gap-4 min-w-0">
<img src={track.cover_url} className="w-10 h-10 rounded shadow-sm object-cover" alt="" />
<div className="flex flex-col min-w-0">
<span className={`font-semibold truncate text-base ${isCurrent ? 'text-green-500' : 'text-white'}`}>{track.title}</span>
<span className="truncate hover:underline text-xs">{track.artist}</span>
</div>
</div>
<span className="truncate hover:underline">{track.album}</span>
<span className="text-right pr-8 font-mono">{formatDuration(0)}</span>
</div>
);
})
) : (
<div className="text-center py-20 text-spotify-text-muted">
<p className="text-lg">No liked songs yet.</p>
<p className="text-sm mt-2">Go search for some music and tap the heart icon!</p>
</div>
)}
</div>
</div>
);
}

BIN
frontend/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

37
frontend/app/globals.css Normal file
View file

@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #121212;
--foreground: #FFFFFF;
/* Spotify Colors */
--spotify-base: #121212;
--spotify-sidebar: #000000;
--spotify-player: #000000;
--spotify-highlight: #1DB954;
--spotify-hover: #282828;
--spotify-text-main: #FFFFFF;
--spotify-text-muted: #B3B3B3;
--spotify-text-subdued: #A7A7A7;
}
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
}
body {
background: var(--spotify-base);
color: var(--spotify-text-main);
/* font-family set in layout via className */
}

57
frontend/app/layout.tsx Normal file
View file

@ -0,0 +1,57 @@
import type { Metadata } from "next";
import { Outfit } from "next/font/google";
import "./globals.css";
import Sidebar from "@/components/Sidebar";
import PlayerBar from "@/components/PlayerBar";
import MobileNav from "@/components/MobileNav";
import { PlayerProvider } from "@/context/PlayerContext";
import { LibraryProvider } from "@/context/LibraryContext";
const outfit = Outfit({
subsets: ["latin"],
variable: "--font-outfit",
weight: ["300", "400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: "Audiophile Web Player",
description: "High-Fidelity Local-First Music Player",
manifest: "/manifest.json",
referrer: "no-referrer",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "Audiophile Web Player",
},
icons: {
icon: "/icons/icon-192x192.png",
apple: "/icons/icon-512x512.png",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${outfit.variable} antialiased bg-black h-screen flex flex-col overflow-hidden text-white font-sans`}
>
<PlayerProvider>
<LibraryProvider>
<div className="flex-1 flex overflow-hidden p-2 gap-2 mb-[64px] md:mb-0">
<Sidebar />
<main className="flex-1 bg-[#121212] rounded-lg overflow-y-auto relative no-scrollbar">
{children}
</main>
</div>
<PlayerBar />
<MobileNav />
</LibraryProvider>
</PlayerProvider>
</body>
</html>
);
}

View file

@ -0,0 +1,177 @@
"use client";
import { useState } from "react";
import { dbService } from "@/services/db";
import { useLibrary } from "@/context/LibraryContext";
import Link from "next/link";
import { Plus } from "lucide-react";
import CreatePlaylistModal from "@/components/CreatePlaylistModal";
export default function LibraryPage() {
const { userPlaylists: playlists, libraryItems, refreshLibrary: refresh, activeFilter: activeTab, setActiveFilter: setActiveTab } = useLibrary();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const handleCreatePlaylist = async (name: string) => {
await dbService.createPlaylist(name);
refresh();
};
const showPlaylists = activeTab === 'all' || activeTab === 'playlists';
const showAlbums = activeTab === 'all' || activeTab === 'albums';
const showArtists = activeTab === 'all' || activeTab === 'artists';
// Filter items based on type
const albums = libraryItems.filter(item => item.type === 'Album');
const artists = libraryItems.filter(item => item.type === 'Artist');
const browsePlaylists = libraryItems.filter(item => item.type === 'Playlist');
return (
<div className="bg-gradient-to-b from-[#1e1e1e] to-black min-h-screen p-4 pb-24">
<div className="flex justify-between items-center mb-6 pt-4">
<h1 className="text-2xl font-bold text-white">Your Library</h1>
<button onClick={() => setIsCreateModalOpen(true)}>
<Plus className="text-white w-6 h-6" />
</button>
</div>
<div className="flex gap-2 mb-6 overflow-x-auto no-scrollbar">
<button
onClick={() => setActiveTab('all')}
className={`px-4 py-2 rounded-full text-sm font-medium border transition whitespace-nowrap ${activeTab === 'all' ? 'bg-[#2a2a2a] text-white border-white/10' : 'bg-[#121212] text-spotify-text-muted border-white/5'}`}
>
All
</button>
<button
onClick={() => setActiveTab('playlists')}
className={`px-4 py-2 rounded-full text-sm font-medium border transition whitespace-nowrap ${activeTab === 'playlists' ? 'bg-[#2a2a2a] text-white border-white/10' : 'bg-[#121212] text-spotify-text-muted border-white/5'}`}
>
Playlists
</button>
<button
onClick={() => setActiveTab('albums')}
className={`px-4 py-2 rounded-full text-sm font-medium border transition whitespace-nowrap ${activeTab === 'albums' ? 'bg-[#2a2a2a] text-white border-white/10' : 'bg-[#121212] text-spotify-text-muted border-white/5'}`}
>
Albums
</button>
<button
onClick={() => setActiveTab('artists')}
className={`px-4 py-2 rounded-full text-sm font-medium border transition whitespace-nowrap ${activeTab === 'artists' ? 'bg-[#2a2a2a] text-white border-white/10' : 'bg-[#121212] text-spotify-text-muted border-white/5'}`}
>
Artists
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{/* Playlists & Liked Songs */}
{showPlaylists && (
<>
<Link href="/collection/tracks">
<div className="bg-gradient-to-br from-indigo-700 to-blue-500 rounded-md p-4 aspect-square flex flex-col justify-end relative overflow-hidden shadow-lg">
<h3 className="text-white font-bold text-lg z-10">Liked Songs</h3>
<p className="text-white/80 text-xs z-10">Auto-generated</p>
</div>
</Link>
{playlists.map((playlist) => (
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
<div className="bg-[#181818] p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
<div className="aspect-square w-full mb-3 overflow-hidden rounded-md bg-[#282828] shadow-lg">
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
<img src={playlist.cover_url} alt={playlist.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center bg-[#333]">
<span className="text-2xl">🎵</span>
</div>
)}
</div>
<h3 className="text-white font-bold text-sm truncate">{playlist.title}</h3>
<p className="text-[#a7a7a7] text-xs">Playlist You</p>
</div>
</Link>
))}
{browsePlaylists.map((playlist) => (
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
<div className="bg-[#181818] p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
<div className="aspect-square w-full mb-3 overflow-hidden rounded-md bg-[#282828] shadow-lg">
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
<img src={playlist.cover_url} alt={playlist.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center bg-[#333]">
<span className="text-2xl">🎵</span>
</div>
)}
</div>
<h3 className="text-white font-bold text-sm truncate">{playlist.title}</h3>
<p className="text-[#a7a7a7] text-xs">Playlist Made for you</p>
</div>
</Link>
))}
</>
)}
{/* Artists Content (Circular Images) */}
{showArtists && artists.map((artist) => (
<Link href={`/playlist?id=${artist.id}`} key={artist.id}>
<div className="bg-[#181818] p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col items-center text-center">
<div className="aspect-square w-full mb-3 overflow-hidden rounded-full bg-[#282828] shadow-lg relative">
{artist.cover_url ? (
<img
src={artist.cover_url}
alt={artist.title}
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.onerror = null; // Prevent infinite loop
e.currentTarget.style.display = 'none';
e.currentTarget.parentElement?.classList.add('bg-[#333]');
}}
/>
) : null}
<div className="absolute inset-0 flex items-center justify-center bg-[#333] -z-10">
<span className="text-2xl">🎤</span>
</div>
</div>
<h3 className="text-white font-bold text-sm truncate w-full">{artist.title}</h3>
<p className="text-[#a7a7a7] text-xs">Artist</p>
</div>
</Link>
))}
{/* Albums Content */}
{showAlbums && albums.map((album) => (
<Link href={`/playlist?id=${album.id}`} key={album.id}>
<div className="bg-[#181818] p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
<div className="aspect-square w-full mb-3 overflow-hidden rounded-md bg-[#282828] shadow-lg relative">
{album.cover_url ? (
<img
src={album.cover_url}
alt={album.title}
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.onerror = null; // Prevent infinite loop
e.currentTarget.style.display = 'none'; // Hide broken image
e.currentTarget.parentElement?.classList.add('bg-[#333]'); // add background
// Show fallback icon sibling if possible, distinct from React state
}}
/>
) : null}
{/* Fallback overlay (shown if image missing or hidden via CSS logic would need state, but simpler: just render icon behind it or use state) */}
<div className="absolute inset-0 flex items-center justify-center bg-[#333] -z-10">
<span className="text-2xl">💿</span>
</div>
</div>
<h3 className="text-white font-bold text-sm truncate">{album.title}</h3>
<p className="text-[#a7a7a7] text-xs">Album {album.creator || 'Spotify'}</p>
</div>
</Link>
))}
</div>
<CreatePlaylistModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onCreate={handleCreatePlaylist}
/>
</div>
);
}

190
frontend/app/page.tsx Normal file
View file

@ -0,0 +1,190 @@
"use client";
import { Play, Pause } from "lucide-react";
import { useEffect, useState } from "react";
import { usePlayer } from "@/context/PlayerContext";
import Link from "next/link";
import { libraryService } from "@/services/library";
export default function Home() {
const [timeOfDay, setTimeOfDay] = useState("Good evening");
const [browseData, setBrowseData] = useState<Record<string, any[]>>({});
useEffect(() => {
const hour = new Date().getHours();
if (hour < 12) setTimeOfDay("Good morning");
else if (hour < 18) setTimeOfDay("Good afternoon");
else setTimeOfDay("Good evening");
// Fetch Browse Content
libraryService.getBrowseContent()
.then(data => setBrowseData(data))
.catch(err => console.error("Error fetching browse:", err));
}, []);
// Use first item of first category as Hero
const firstCategory = Object.keys(browseData)[0];
const heroPlaylist = firstCategory && browseData[firstCategory].length > 0 ? browseData[firstCategory][0] : null;
return (
<div className="h-full overflow-y-auto bg-gradient-to-b from-[#1e1e1e] to-[#121212] p-6 no-scrollbar pb-24">
{/* Header / Greetings */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{timeOfDay}</h1>
</div>
{/* Hero Section (First Playlist) */}
{heroPlaylist && (
<Link href={`/playlist?id=${heroPlaylist.id}`}>
<div className="flex flex-col md:flex-row gap-6 mb-8 hover:bg-white/10 p-4 rounded-md transition group cursor-pointer items-center md:items-end text-center md:text-left">
<img
src={heroPlaylist.cover_url || "https://placehold.co/200"}
alt={heroPlaylist.title}
className="w-48 h-48 md:w-60 md:h-60 shadow-2xl object-cover rounded-md"
/>
<div className="flex flex-col justify-end w-full">
<p className="text-xs font-bold uppercase tracking-widest mb-2 hidden md:block">Playlist</p>
<h2 className="text-3xl md:text-6xl font-bold mb-2 md:mb-4 line-clamp-2">{heroPlaylist.title}</h2>
<p className="text-sm font-medium mb-4 text-[#a7a7a7] line-clamp-2">{heroPlaylist.description}</p>
<div className="flex items-center justify-center md:justify-start gap-4">
<div className="w-12 h-12 md:w-14 md:h-14 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105 transition shadow-lg">
<Play className="fill-black text-black ml-1 w-5 h-5 md:w-6 md:h-6" />
</div>
</div>
</div>
</div>
</Link>
)}
{/* Made For You (Recommendations) */}
<MadeForYouSection />
{/* Recommended Albums */}
<RecommendedAlbumsSection />
{/* Render Categories */}
{Object.entries(browseData).map(([category, playlists]) => (
<div key={category} className="mb-8">
<h2 className="text-2xl font-bold mb-4 hover:underline cursor-pointer">{category}</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{playlists.slice(0, 5).map((playlist) => (
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
<div className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-4">
<img
src={playlist.cover_url || "https://placehold.co/200"}
alt={playlist.title}
className="w-full aspect-square object-cover rounded-md shadow-lg"
/>
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-1" />
</div>
</div>
</div>
<h3 className="font-bold mb-1 truncate">{playlist.title}</h3>
<p className="text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p>
</div>
</Link>
))}
</div>
</div>
))}
</div>
);
}
function MadeForYouSection() {
const { playHistory, playTrack } = usePlayer();
const [recommendations, setRecommendations] = useState<any[]>([]);
useEffect(() => {
if (playHistory.length > 0) {
const seed = playHistory[0]; // Last played
libraryService.getRecommendations(seed.id)
.then(tracks => setRecommendations(tracks))
.catch(err => console.error("Rec error:", err));
}
}, [playHistory.length > 0 ? playHistory[0].id : null]);
if (playHistory.length === 0 || recommendations.length === 0) return null;
return (
<div className="mb-8 animate-in fade-in duration-500">
<h2 className="text-2xl font-bold mb-4">Made For You</h2>
<p className="text-sm text-[#a7a7a7] mb-4">Based on your listening of <span className="text-white font-medium">{playHistory[0].title}</span></p>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{recommendations.slice(0, 5).map((track, i) => (
<div key={i} onClick={() => playTrack(track, recommendations)} className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-4">
<img
src={track.cover_url}
alt={track.title}
className="w-full aspect-square object-cover rounded-md shadow-lg"
/>
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-1" />
</div>
</div>
</div>
<h3 className="font-bold mb-1 truncate">{track.title}</h3>
<p className="text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p>
</div>
))}
</div>
</div>
);
}
function RecommendedAlbumsSection() {
const { playHistory } = usePlayer();
const [albums, setAlbums] = useState<any[]>([]);
useEffect(() => {
if (playHistory.length > 0) {
const seedArtist = playHistory[0].artist; // Last played artist
if (!seedArtist) return;
// Clean artist name (remove delimiters like commas if multiple)
const primaryArtist = seedArtist.split(',')[0].trim();
libraryService.getRecommendedAlbums(primaryArtist)
.then(data => {
if (Array.isArray(data)) setAlbums(data);
})
.catch(err => console.error("Album Rec error:", err));
}
}, [playHistory.length > 0 ? playHistory[0].artist : null]);
if (playHistory.length === 0 || albums.length === 0) return null;
return (
<div className="mb-8 animate-in fade-in duration-700">
<h2 className="text-2xl font-bold mb-4">Recommended Albums</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{albums.slice(0, 5).map((album, i) => (
<Link href={`/playlist?id=${album.id}`} key={i}>
<div className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-4">
<img
src={album.cover_url}
alt={album.title}
className="w-full aspect-square object-cover rounded-md shadow-lg"
/>
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-1" />
</div>
</div>
</div>
<h3 className="font-bold mb-1 truncate">{album.title}</h3>
<p className="text-sm text-[#a7a7a7] line-clamp-2">{album.description}</p>
</div>
</Link>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,246 @@
"use client";
import { usePlayer } from "@/context/PlayerContext";
import { Play, Pause, Clock, Heart, MoreHorizontal, Plus } from "lucide-react";
import { useEffect, useState, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import AddToPlaylistModal from "@/components/AddToPlaylistModal";
import { dbService } from "@/services/db";
import { libraryService } from "@/services/library";
interface Track {
title: string;
artist: string;
album: string;
cover_url: string;
id: string;
url?: string;
duration?: number;
}
interface PlaylistData {
title: string;
description: string;
author: string;
cover_url: string;
tracks: Track[];
}
function PlaylistContent() {
const searchParams = useSearchParams();
const id = searchParams.get('id');
const [playlist, setPlaylist] = useState<PlaylistData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const { playTrack, currentTrack, isPlaying, likedTracks, toggleLike } = usePlayer();
// Modal State
const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false);
const [trackToAdd, setTrackToAdd] = useState<Track | null>(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
useEffect(() => {
const fetchPlaylist = async () => {
setIsLoading(true);
try {
if (!id) return;
if (id === 'trending') {
const browse = await libraryService.getBrowseContent();
// Assumption: Trending exists or use first available
const trending = browse['Trending']?.[0] || browse['Top Lists']?.[0];
if (trending) {
setPlaylist({
...trending,
author: "Audiophile",
});
}
} else {
// Try DB (User Playlist)
const dbPlaylist = await dbService.getPlaylist(id);
if (dbPlaylist) {
setPlaylist({
title: dbPlaylist.title,
description: `Created on ${new Date(dbPlaylist.createdAt).toLocaleDateString()}`,
author: "You",
cover_url: dbPlaylist.cover_url || "https://placehold.co/300?text=Playlist",
tracks: dbPlaylist.tracks
});
setIsLoading(false);
return;
}
// Try Library (Static Playlist)
const libPlaylist = await libraryService.getPlaylist(id);
if (libPlaylist) {
setPlaylist({
...libPlaylist,
author: "System"
});
setIsLoading(false);
return;
}
throw new Error("Playlist not found");
}
} catch (error) {
console.error("Failed to fetch playlist:", error);
} finally {
setIsLoading(false);
}
};
if (id) fetchPlaylist();
}, [id]);
const handlePlay = (track: Track) => {
if (playlist) {
playTrack(track, playlist.tracks);
} else {
playTrack(track);
}
};
const handlePlayAll = () => {
if (playlist && playlist.tracks.length > 0) {
playTrack(playlist.tracks[0], playlist.tracks);
}
};
const formatDuration = (seconds?: number) => {
if (!seconds) return "-:--";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const openAddToPlaylist = (e: React.MouseEvent, track: Track) => {
e.stopPropagation();
setTrackToAdd(track);
setIsAddToPlaylistOpen(true);
};
if (isLoading) return <div className="p-8 text-white">Loading playlist...</div>;
if (!playlist) return <div className="p-8 text-white">Playlist not found.</div>;
return (
<div className="h-full overflow-y-auto no-scrollbar bg-gradient-to-b from-gray-900 via-[#121212] to-[#121212]">
{/* Header */}
<div className="flex flex-col md:flex-row items-center md:items-end gap-6 p-8 bg-gradient-to-b from-transparent to-black/20 pt-20 text-center md:text-left">
<img src={playlist.cover_url} alt={playlist.title} className="w-52 h-52 md:w-60 md:h-60 shadow-2xl rounded-md object-cover" />
<div className="flex flex-col gap-2 w-full md:w-auto">
<span className="text-sm font-bold uppercase hidden md:block">Playlist</span>
<h1 className="text-2xl md:text-6xl font-black tracking-tight text-white mb-2 md:mb-4 line-clamp-2 leading-tight">{playlist.title}</h1>
{/* Expandable Description */}
<div className="relative">
<p
className={`text-spotify-text-muted font-medium mb-2 ${isDescriptionExpanded ? '' : 'line-clamp-2 md:line-clamp-none'}`}
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
>
{playlist.description}
</p>
{playlist.description && playlist.description.length > 100 && (
<button
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
className="text-xs text-white font-bold hover:underline md:hidden"
>
{isDescriptionExpanded ? "Show less" : "more"}
</button>
)}
</div>
<div className="flex items-center justify-center md:justify-start gap-2 text-sm font-medium text-white/90">
<span className="font-bold hover:underline cursor-pointer">{playlist.author || "User"}</span>
<span></span>
<span>{playlist.tracks.length} songs</span>
</div>
</div>
</div>
{/* Controls */}
<div className="px-8 py-6 flex items-center gap-8 relative z-10 justify-center md:justify-start">
{/* ... (controls remain similar) ... */}
<div onClick={handlePlayAll} className="bg-green-500 rounded-full w-14 h-14 flex items-center justify-center hover:scale-105 transition cursor-pointer shadow-lg hover:bg-green-400">
<Play className="w-7 h-7 text-black fill-black ml-1" />
</div>
<Heart className="w-8 h-8 text-spotify-text-muted hover:text-white cursor-pointer" />
<MoreHorizontal className="w-8 h-8 text-spotify-text-muted hover:text-white cursor-pointer" />
</div>
{/* List */}
<div className="px-4 md:px-8 pb-40">
{/* Header Row */}
<div className="grid grid-cols-[auto_1fr_auto] md:grid-cols-[16px_4fr_3fr_minmax(120px,1fr)] gap-4 px-4 py-2 border-b border-[#ffffff1a] text-sm text-spotify-text-muted mb-4">
<span className="w-4 text-center">#</span>
<span>Title</span>
<span className="hidden md:block">Album</span>
<div className="flex justify-end md:pr-8"><Clock className="w-4 h-4" /></div>
</div>
<div className="flex flex-col">
{playlist.tracks.map((track, i) => {
const isCurrent = currentTrack?.id === track.id;
const isLiked = likedTracks.has(track.id);
return (
<div
key={track.id}
onClick={() => handlePlay(track)}
className={`grid grid-cols-[auto_1fr_auto] md:grid-cols-[16px_4fr_3fr_minmax(120px,1fr)] gap-4 px-2 md:px-4 py-3 rounded-md hover:bg-[#ffffff1a] group cursor-pointer transition items-center text-sm text-spotify-text-muted hover:text-white ${isCurrent ? 'bg-[#ffffff1a]' : ''}`}
>
<span className="text-center w-4 flex justify-center items-center">
{isCurrent && isPlaying ? (
<img src="https://open.spotifycdn.com/cdn/images/equaliser-animated-green.f93a2ef4.gif" className="h-3 w-3" alt="playing" />
) : (
<span className="block group-hover:hidden">{i + 1}</span>
)}
<Play className={`w-3 h-3 fill-white hidden ${isCurrent && isPlaying ? 'hidden' : 'group-hover:block'}`} />
</span>
<div className="flex items-center gap-3 min-w-0 overflow-hidden">
<img src={track.cover_url} className="w-10 h-10 rounded shadow-sm object-cover shrink-0" alt="" />
<div className="flex flex-col min-w-0 pr-2">
{/* Changed from truncate to line-clamp-2 for readability */}
<span className={`font-semibold text-base leading-tight line-clamp-2 break-words ${isCurrent ? 'text-green-500' : 'text-white'}`}>{track.title}</span>
<span className="truncate hover:underline text-xs">{track.artist}</span>
</div>
</div>
<span className="truncate hover:underline hidden md:block text-xs">{track.album}</span>
<div className="flex items-center justify-end gap-3 md:gap-4 md:pr-4">
<button
onClick={(e) => { e.stopPropagation(); toggleLike(track); }}
className={`hidden md:block ${isLiked ? 'text-green-500 visible' : 'invisible group-hover:visible hover:text-white'}`}
>
<Heart className={`w-4 h-4 ${isLiked ? 'fill-green-500' : ''}`} />
</button>
<span className="font-mono text-xs md:text-sm">{formatDuration(track.duration)}</span>
<button
onClick={(e) => openAddToPlaylist(e, track)}
className="md:invisible group-hover:visible hover:text-white text-spotify-text-muted"
>
<MoreHorizontal className="md:hidden w-5 h-5" />
<Plus className="hidden md:block w-5 h-5" />
</button>
</div>
</div>
);
})}
</div>
</div>
<AddToPlaylistModal
track={trackToAdd}
isOpen={isAddToPlaylistOpen}
onClose={() => setIsAddToPlaylistOpen(false)}
/>
</div>
);
}
export default function PlaylistPage() {
return (
<Suspense fallback={<div className="p-8 text-white">Loading...</div>}>
<PlaylistContent />
</Suspense>
);
}

View file

@ -0,0 +1,134 @@
"use client";
import { useState } from "react";
import { Search as SearchIcon, Play, Pause, X } from "lucide-react";
import { usePlayer } from "@/context/PlayerContext";
interface Track {
title: string;
artist: string;
album: string;
cover_url: string;
id: string;
}
import AddToPlaylistModal from "@/components/AddToPlaylistModal";
import { Plus } from "lucide-react";
export default function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Track[]>([]);
const [isSearching, setIsSearching] = useState(false);
const { playTrack, currentTrack, isPlaying } = usePlayer();
// Modal State
const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false);
const [trackToAdd, setTrackToAdd] = useState<Track | null>(null);
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) return;
setIsSearching(true);
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
const res = await fetch(`${apiUrl}/api/search?query=${encodeURIComponent(query)}`);
const data = await res.json();
setResults(data.tracks || []);
} catch (error) {
console.error("Search failed:", error);
} finally {
setIsSearching(false);
}
};
const handlePlay = (track: Track) => {
// Create a temporary queue from the search results
playTrack(track, results);
};
const openAddToPlaylist = (e: React.MouseEvent, track: Track) => {
e.stopPropagation();
setTrackToAdd(track);
setIsAddToPlaylistOpen(true);
};
return (
<div className="h-full overflow-y-auto bg-gradient-to-b from-[#1e1e1e] to-[#121212] p-6 no-scrollbar">
{/* Search Input */}
<div className="mb-8 sticky top-0 z-20 py-4 -mt-4 flex justify-center">
<form onSubmit={handleSearch} className="relative w-full max-w-[400px]">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-black w-5 h-5 z-10" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="What do you want to play?"
className="w-full h-12 rounded-full pl-10 pr-4 bg-white text-black font-medium focus:outline-none focus:ring-2 focus:ring-white placeholder-gray-500"
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-black hover:scale-110 transition"
>
<X className="w-5 h-5" />
</button>
)}
</form>
</div>
{/* Results */}
<div>
{isSearching ? (
<div className="text-white">Searching...</div>
) : results.length > 0 ? (
<>
<h2 className="text-2xl font-bold mb-4">Top Results</h2>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-6">
{results.map((track, i) => {
const isCurrent = currentTrack?.id === track.id;
return (
<div key={i} onClick={() => handlePlay(track)} className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition cursor-pointer group relative">
<div className="relative mb-4">
<img src={track.cover_url} className="w-full aspect-square rounded-md object-cover shadow-lg" alt={track.title} />
<div className={`absolute bottom-2 right-2 transition-all translate-y-2 group-hover:translate-y-0 shadow-xl bg-green-500 rounded-full p-3 ${isCurrent ? 'opacity-100 translate-y-0' : 'opacity-0'}`}>
{isCurrent && isPlaying ? (
<Pause className="w-5 h-5 text-black fill-black ml-0.5" />
) : (
<Play className="w-5 h-5 text-black fill-black ml-0.5" />
)}
</div>
</div>
<h3 className={`font-bold mb-1 truncate ${isCurrent ? "text-green-500" : "text-white"}`}>{track.title}</h3>
<p className="text-sm text-spotify-text-muted line-clamp-2">{track.artist}</p>
{/* Add to Playlist Button (Absolute Top Right of Card) */}
<button
onClick={(e) => openAddToPlaylist(e, track)}
className="absolute top-2 right-2 p-2 bg-black/50 hover:bg-black/80 rounded-full opacity-0 group-hover:opacity-100 transition text-white"
title="Add to Playlist"
>
<Plus className="w-4 h-4" />
</button>
</div>
);
})}
</div>
</>
) : (
<div className="flex flex-col items-center justify-center h-[50vh] text-center">
<h2 className="text-2xl font-bold mb-4">Play what you love</h2>
<p className="text-spotify-text-muted">Search for artists, songs, podcasts, and more.</p>
</div>
)}
</div>
<AddToPlaylistModal
track={trackToAdd}
isOpen={isAddToPlaylistOpen}
onClose={() => setIsAddToPlaylistOpen(false)}
/>
</div>
);
}

23
frontend/build_log.txt Normal file
View file

@ -0,0 +1,23 @@
> frontend@0.1.0 build
> next build
▲ Next.js 14.2.0
Creating an optimized production build ...
✓ Compiled successfully
Skipping validation of types
Skipping linting
Collecting page data ...
> Build error occurred
Error: Page "/playlist/[id]" is missing "generateStaticParams()" so it cannot be used with "output: export" config.
at /Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/build/index.js:1294:59
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async Span.traceAsyncFn (/Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/trace/trace.js:154:20)
at async Promise.all (index 8)
at async /Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/build/index.js:1172:17
at async Span.traceAsyncFn (/Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/trace/trace.js:154:20)
at async /Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/build/index.js:1095:124
at async Span.traceAsyncFn (/Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/trace/trace.js:154:20)
at async build (/Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/build/index.js:366:9)

23
frontend/build_log_2.txt Normal file
View file

@ -0,0 +1,23 @@
> frontend@0.1.0 build
> next build
▲ Next.js 14.2.0
Creating an optimized production build ...
✓ Compiled successfully
Skipping validation of types
Skipping linting
Collecting page data ...
> Build error occurred
Error: Page "/api/auth/[...nextauth]" is missing "generateStaticParams()" so it cannot be used with "output: export" config.
at /Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/build/index.js:1294:59
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async Span.traceAsyncFn (/Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/trace/trace.js:154:20)
at async Promise.all (index 4)
at async /Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/build/index.js:1172:17
at async Span.traceAsyncFn (/Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/trace/trace.js:154:20)
at async /Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/build/index.js:1095:124
at async Span.traceAsyncFn (/Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/trace/trace.js:154:20)
at async build (/Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/build/index.js:366:9)

View file

@ -0,0 +1,102 @@
"use client";
import { useEffect, useState } from "react";
import { Plus, X } from "lucide-react";
interface AddToPlaylistModalProps {
track: any;
isOpen: boolean;
onClose: () => void;
}
export default function AddToPlaylistModal({ track, isOpen, onClose }: AddToPlaylistModalProps) {
const [playlists, setPlaylists] = useState<any[]>([]);
useEffect(() => {
if (isOpen) {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
fetch(`${apiUrl}/api/playlists`)
.then(res => res.json())
.then(data => setPlaylists(data))
.catch(err => console.error(err));
}
}, [isOpen]);
const handleAddToPlaylist = async (playlistId: string) => {
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
await fetch(`${apiUrl}/api/playlists/${playlistId}/tracks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(track)
});
alert(`Added to playlist!`);
onClose();
} catch (error) {
console.error("Failed to add track:", error);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4">
<div className="bg-[#282828] w-full max-w-md rounded-lg shadow-2xl overflow-hidden">
<div className="p-4 border-b border-[#3e3e3e] flex items-center justify-between">
<h2 className="text-xl font-bold text-white">Add to Playlist</h2>
<button onClick={onClose} className="text-white hover:text-gray-300">
<X className="w-6 h-6" />
</button>
</div>
<div className="p-2 max-h-[60vh] overflow-y-auto no-scrollbar">
{playlists.length === 0 ? (
<div className="p-4 text-center text-spotify-text-muted">No playlists found. Create one first!</div>
) : (
playlists.map((playlist) => (
<div
key={playlist.id}
onClick={() => handleAddToPlaylist(playlist.id)}
className="flex items-center gap-3 p-3 hover:bg-[#3e3e3e] rounded-md cursor-pointer transition text-white"
>
<div className="w-10 h-10 bg-[#121212] flex items-center justify-center rounded overflow-hidden">
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
<img src={playlist.cover_url} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-lg">🎵</span>
)}
</div>
<span className="font-medium truncate">{playlist.title}</span>
</div>
))
)}
</div>
<div className="p-4 border-t border-[#3e3e3e]">
<button
onClick={() => {
const name = prompt("New Playlist Name");
if (name) {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
fetch(`${apiUrl}/api/playlists`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name })
}).then(() => {
// Refresh list
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
fetch(`${apiUrl}/api/playlists`)
.then(res => res.json())
.then(data => setPlaylists(data));
});
}
}}
className="w-full py-2 bg-white text-black font-bold rounded-full hover:scale-105 transition flex items-center justify-center gap-2"
>
<Plus className="w-5 h-5" /> New Playlist
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
import { X, Plus } from "lucide-react";
interface CreatePlaylistModalProps {
isOpen: boolean;
onClose: () => void;
onCreate: (name: string) => void;
}
export default function CreatePlaylistModal({ isOpen, onClose, onCreate }: CreatePlaylistModalProps) {
const [name, setName] = useState("");
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim()) {
onCreate(name);
setName("");
onClose();
}
};
return (
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4 animate-in fade-in duration-200">
<div className="bg-[#282828] w-full max-w-sm rounded-lg shadow-2xl overflow-hidden p-6 animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-white">Create Playlist</h2>
<button onClick={onClose} className="text-zinc-400 hover:text-white">
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<input
type="text"
placeholder="My Awesome Playlist"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-[#3e3e3e] text-white p-3 rounded-md border border-transparent focus:border-green-500 focus:outline-none transition"
autoFocus
/>
</div>
<button
type="submit"
disabled={!name.trim()}
className="bg-green-500 text-black font-bold py-3 rounded-full hover:scale-105 hover:bg-green-400 transition disabled:opacity-50 disabled:hover:scale-100 flex items-center justify-center gap-2"
>
Create
</button>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,147 @@
import React, { useEffect, useState, useRef } from 'react';
interface Metric {
time: number;
text: string;
}
interface LyricsDetailProps {
track: any;
currentTime: number;
onClose: () => void;
onSeek?: (time: number) => void;
}
const LyricsDetail: React.FC<LyricsDetailProps> = ({ track, currentTime, onClose, onSeek }) => {
const [lyrics, setLyrics] = useState<Metric[]>([]);
const [isLoading, setIsLoading] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const activeLineRef = useRef<HTMLDivElement>(null);
// Fetch Lyrics on Track Change
useEffect(() => {
const fetchLyrics = async () => {
if (!track) return;
setIsLoading(true);
try {
// Pass title and artist for LRCLIB fallback
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
const url = `${apiUrl}/api/lyrics?id=${track.id}&title=${encodeURIComponent(track.title)}&artist=${encodeURIComponent(track.artist)}`;
const res = await fetch(url);
const data = await res.json();
setLyrics(data || []);
} catch (error) {
console.error("Error fetching lyrics:", error);
setLyrics([]);
} finally {
setIsLoading(false);
}
};
fetchLyrics();
}, [track?.id]);
// Find active line index
const activeIndex = lyrics.findIndex((line, index) => {
const nextLine = lyrics[index + 1];
// Removing large offset to match music exactly.
// using small buffer (0.05) just for rounding safety
const timeWithOffset = currentTime + 0.05;
return timeWithOffset >= line.time && (!nextLine || timeWithOffset < nextLine.time);
});
// Auto-scroll to active line
// Auto-scroll to active line
useEffect(() => {
if (activeLineRef.current && scrollContainerRef.current) {
const container = scrollContainerRef.current;
const activeLine = activeLineRef.current;
// Calculate position to center (or offset) the active line
// Reverted to center (50%) as requested
const containerHeight = container.clientHeight;
const lineTop = activeLine.offsetTop;
const lineHeight = activeLine.offsetHeight;
// Target scroll position:
// Line Top - (Screen Height * 0.50) + (Half Line Height)
const targetScrollTop = lineTop - (containerHeight * 0.50) + (lineHeight / 2);
container.scrollTo({
top: targetScrollTop,
behavior: 'smooth'
});
}
}, [activeIndex]);
if (!track) return null;
return (
<div className={`absolute inset-0 flex flex-col bg-transparent text-white`}>
{/* Header */}
<div className="flex items-center justify-between p-6 bg-gradient-to-b from-black/80 to-transparent z-10">
<div className="flex-1 min-w-0">
<h2 className="text-xl font-bold truncate">Lyrics</h2>
<p className="text-white/60 text-xs truncate uppercase tracking-widest">
{track.artist}
</p>
</div>
<button
onClick={onClose}
className="p-2 bg-white/10 rounded-full hover:bg-white/20 transition backdrop-blur-md"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/* Lyrics Container */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto px-6 text-center space-y-6 no-scrollbar mask-linear-gradient"
>
{isLoading ? (
<div className="h-full flex items-center justify-center">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-green-500"></div>
</div>
) : lyrics.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-white/50 text-base">
<p className="font-bold mb-2 text-xl">Looks like we don't have lyrics for this song.</p>
<p>Enjoy the vibe!</p>
</div>
) : (
<div className="pt-[50vh] pb-[50vh] max-w-4xl mx-auto"> {/* Reverted to center padding, added max-width */}
{lyrics.map((line, index) => {
const isActive = index === activeIndex;
const isPast = index < activeIndex;
return (
<div
key={index}
ref={isActive ? activeLineRef : null}
className={`
transition-all duration-500 ease-out origin-center
${isActive
? "text-3xl md:text-4xl font-bold text-white scale-100 opacity-100 drop-shadow-lg py-4"
: "text-xl md:text-2xl font-medium text-white/30 hover:text-white/60 blur-[0px] scale-95 py-2"
}
cursor-pointer
`}
onClick={() => {
if (onSeek) onSeek(line.time);
}}
>
{line.text}
</div>
);
})}
</div>
)}
</div>
</div>
);
};
export default LyricsDetail;

View file

@ -0,0 +1,30 @@
"use client";
import { Home, Search, Library } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function MobileNav() {
const pathname = usePathname();
const isActive = (path: string) => pathname === path;
return (
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-black/95 backdrop-blur-md border-t border-white/10 h-[64px] flex items-center justify-around z-50 pb-safe">
<Link href="/" className={`flex flex-col items-center gap-1 ${isActive('/') ? 'text-white' : 'text-neutral-400'}`}>
<Home size={24} fill={isActive('/') ? "currentColor" : "none"} />
<span className="text-[10px]">Home</span>
</Link>
<Link href="/search" className={`flex flex-col items-center gap-1 ${isActive('/search') ? 'text-white' : 'text-neutral-400'}`}>
<Search size={24} />
<span className="text-[10px]">Search</span>
</Link>
<Link href="/library" className={`flex flex-col items-center gap-1 ${isActive('/library') ? 'text-white' : 'text-neutral-400'}`}>
<Library size={24} />
<span className="text-[10px]">Library</span>
</Link>
</div>
);
}

View file

@ -0,0 +1,437 @@
"use client";
import { Play, Pause, SkipBack, SkipForward, Repeat, Shuffle, Volume2, VolumeX, Download, Disc, PlusCircle, Mic2, Heart, Loader2, ListMusic, MonitorSpeaker, Maximize2 } from 'lucide-react';
import { usePlayer } from "@/context/PlayerContext";
import { useEffect, useRef, useState } from "react";
import TechSpecs from './TechSpecs';
import AddToPlaylistModal from "@/components/AddToPlaylistModal";
import LyricsDetail from './LyricsDetail';
export default function PlayerBar() {
const { currentTrack, isPlaying, isBuffering, togglePlay, setBuffering, likedTracks, toggleLike, nextTrack, prevTrack, shuffle, toggleShuffle, repeatMode, toggleRepeat, audioQuality } = usePlayer();
const audioRef = useRef<HTMLAudioElement | null>(null);
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
// Modal State
const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false);
const [isLyricsOpen, setIsLyricsOpen] = useState(false);
const [isTechSpecsOpen, setIsTechSpecsOpen] = useState(false);
const [isFullScreenPlayerOpen, setIsFullScreenPlayerOpen] = useState(false);
const [isCoverModalOpen, setIsCoverModalOpen] = useState(false);
useEffect(() => {
if (currentTrack && audioRef.current && currentTrack.url) {
// Prevent reloading if URL hasn't changed
const isSameUrl = audioRef.current.src === currentTrack.url ||
(currentTrack.url.startsWith('/') && audioRef.current.src.endsWith(currentTrack.url)) ||
(audioRef.current.src.includes(currentTrack.id)); // Fallback for stream IDs
if (isSameUrl) return;
audioRef.current.src = currentTrack.url;
if (isPlaying) {
audioRef.current.play().catch(e => console.error("Play error:", e));
}
}
}, [currentTrack?.url]);
useEffect(() => {
if (audioRef.current) {
if (isPlaying) audioRef.current.play().catch(e => console.error("Play error:", e));
else audioRef.current.pause();
}
}, [isPlaying]);
// Volume Effect
useEffect(() => {
if (audioRef.current) {
audioRef.current.volume = volume;
}
}, [volume]);
const handleTimeUpdate = () => {
if (audioRef.current) {
setProgress(audioRef.current.currentTime);
if (!isNaN(audioRef.current.duration)) {
setDuration(audioRef.current.duration);
}
}
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const time = parseFloat(e.target.value);
if (audioRef.current) {
audioRef.current.currentTime = time;
setProgress(time);
}
};
const handleVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
const vol = parseFloat(e.target.value);
setVolume(vol);
};
const handleDownload = () => {
if (!currentTrack) return;
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
const url = `${apiUrl}/api/download?id=${currentTrack.id}&title=${encodeURIComponent(currentTrack.title)}`;
window.open(url, '_blank');
};
const formatTime = (time: number) => {
if (isNaN(time)) return "0:00";
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
return (
<footer
className="fixed bottom-[64px] left-2 right-2 md:left-0 md:right-0 md:bottom-0 h-14 md:h-[90px] bg-[#2E2E2E] md:bg-black border-t-0 md:border-t border-[#282828] flex items-center justify-between z-[60] rounded-lg md:rounded-none shadow-xl md:shadow-none transition-all duration-300"
onClick={(e) => {
// Mobile: Open Full Screen Player
if (window.innerWidth < 768) {
setIsFullScreenPlayerOpen(true);
}
}}
>
<audio
ref={audioRef}
preload="auto"
onEnded={nextTrack}
onWaiting={() => setBuffering(true)}
onPlaying={() => setBuffering(false)}
onTimeUpdate={handleTimeUpdate}
/>
{/* Mobile Progress Bar (Mini Player) */}
<div className="absolute bottom-0 left-1 right-1 h-[2px] md:hidden">
{/* Visual Bar */}
<div className="absolute inset-0 bg-white/20 rounded-full overflow-hidden pointer-events-none">
<div
className="h-full bg-white rounded-full transition-all duration-300 ease-linear"
style={{ width: `${(progress / (duration || 1)) * 100}%` }}
/>
</div>
{/* Interactive Slider Overlay */}
<input
type="range"
min={0}
max={duration || 100}
value={progress}
onChange={(e) => { e.stopPropagation(); handleSeek(e); }}
onClick={(e) => e.stopPropagation()}
className="absolute -bottom-1 -left-1 -right-1 h-4 w-[calc(100%+8px)] opacity-0 cursor-pointer z-10"
/>
</div>
{/* Left: Now Playing */}
<div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0 md:w-[30%] text-white md:pl-4">
{currentTrack ? (
<>
{/* Artwork */}
<img
src={currentTrack.cover_url}
alt="Cover"
className="h-10 w-10 md:h-14 md:w-14 rounded md:rounded-md object-cover ml-1 md:ml-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (window.innerWidth >= 768) setIsCoverModalOpen(true);
}}
/>
<div className="flex flex-col justify-center overflow-hidden min-w-0">
<span className="text-sm font-medium truncate leading-tight hover:underline cursor-pointer">{currentTrack.title}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-400 truncate leading-tight hover:underline cursor-pointer">{currentTrack.artist}</span>
{audioQuality && (
<button
onClick={() => setIsTechSpecsOpen(true)}
className="text-[10px] bg-white/10 px-1 rounded text-green-400 font-bold hover:bg-white/20 transition border border-green-400/20"
>
HI-RES
</button>
)}
</div>
</div>
{/* Mobile Heart (Inline) */}
<button
onClick={(e) => { e.stopPropagation(); currentTrack && toggleLike(currentTrack); }}
className={`md:hidden ml-2 ${likedTracks.has(currentTrack.id) ? 'text-green-500' : 'text-neutral-400'}`}
>
<Heart size={20} fill={likedTracks.has(currentTrack.id) ? "currentColor" : "none"} />
</button>
{/* Desktop Heart */}
<button
onClick={() => currentTrack && toggleLike(currentTrack)}
className={`hidden md:block hover:scale-110 transition ${likedTracks.has(currentTrack.id) ? 'text-green-500' : 'text-spotify-text-muted hover:text-white'}`}
>
<Heart className={`w-5 h-5 ${likedTracks.has(currentTrack.id) ? 'fill-green-500' : ''}`} />
</button>
{/* Add to Playlist Button (Desktop) */}
<button
onClick={() => setIsAddToPlaylistOpen(true)}
className="hidden md:block text-spotify-text-muted hover:text-white hover:scale-110 transition"
title="Add to Playlist"
>
<PlusCircle className="w-5 h-5" />
</button>
</>
) : (
<div className="h-10 w-10 md:h-14 md:w-14 bg-transparent rounded md:rounded-md" />
)}
</div>
{/* Center: Controls (Desktop) | Right: Controls (Mobile) */}
<div className="flex md:flex-col items-center justify-end md:justify-center md:max-w-[40%] w-auto md:w-full gap-2 pr-3 md:pr-0">
{/* Mobile: Play/Pause Only (and Lyrics) */}
<div className="flex items-center gap-3 md:hidden">
{/* Mobile Lyrics Button */}
<button
className={`transition ${isLyricsOpen ? 'text-green-500' : 'text-neutral-300'}`}
onClick={(e) => { e.stopPropagation(); setIsLyricsOpen(!isLyricsOpen); }}
>
<Mic2 size={22} />
</button>
<button
onClick={(e) => { e.stopPropagation(); togglePlay(); }}
className="text-white"
>
{isBuffering ? <Loader2 size={24} className="animate-spin" /> : (isPlaying ? <Pause size={24} fill="currentColor" /> : <Play size={24} fill="currentColor" />)}
</button>
</div>
{/* Desktop: Full Controls */}
<div className="hidden md:flex items-center gap-6">
<button
onClick={toggleShuffle}
className={`transition ${shuffle ? 'text-green-500' : 'text-spotify-text-muted hover:text-white'}`}>
<Shuffle className="w-4 h-4" />
</button>
<button onClick={prevTrack} className="text-spotify-text-muted hover:text-white transition"><SkipBack className="w-5 h-5 fill-current" /></button>
<button
onClick={togglePlay}
className="w-8 h-8 bg-white rounded-full flex items-center justify-center hover:scale-105 transition">
{isBuffering ? (
<Loader2 className="w-4 h-4 text-black animate-spin" />
) : isPlaying ? (
<Pause className="w-4 h-4 text-black fill-black" />
) : (
<Play className="w-4 h-4 text-black fill-black ml-0.5" />
)}
</button>
<button onClick={nextTrack} className="text-spotify-text-muted hover:text-white transition"><SkipForward className="w-5 h-5 fill-current" /></button>
<button
onClick={toggleRepeat}
className={`transition ${repeatMode !== 'none' ? 'text-green-500' : 'text-spotify-text-muted hover:text-white'} relative`}>
<Repeat className="w-4 h-4" />
{repeatMode === 'one' && <span className="absolute -top-1 -right-1 text-[8px] font-bold text-black bg-green-500 rounded-full w-3 h-3 flex items-center justify-center">1</span>}
</button>
</div>
{/* Desktop: Seek Bar */}
<div className="hidden md:flex items-center gap-2 w-full text-xs text-spotify-text-muted">
<span>{formatTime(progress)}</span>
<input
type="range"
min={0}
max={duration || 100}
value={progress}
onChange={handleSeek}
className="w-full h-1 bg-[#4d4d4d] rounded-lg appearance-none cursor-pointer accent-white hover:accent-green-500"
/>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* Right: Volume & Extras (Desktop Only) */}
<div className="hidden md:flex items-center justify-end gap-3 w-[30%] text-spotify-text-muted pr-4">
{/* Right Controls */}
<div className="flex items-center justify-end space-x-2 md:space-x-4">
<button
className={`text-zinc-400 hover:text-white transition ${isLyricsOpen ? 'text-green-500' : ''}`}
onClick={() => setIsLyricsOpen(!isLyricsOpen)}
title="Lyrics"
>
<Mic2 size={20} />
</button>
<button
className="hidden md:block text-zinc-400 hover:text-white transition"
onClick={handleDownload}
title="Download MP3"
>
<Download size={20} />
</button>
</div>
<ListMusic className="hidden md:block w-4 h-4 hover:text-white cursor-pointer" />
<MonitorSpeaker className="hidden md:block w-4 h-4 hover:text-white cursor-pointer" />
<div className="hidden md:flex items-center gap-2 w-24 group">
{/* Volume Controls (Desktop Only) */}
<Volume2 className="w-4 h-4 group-hover:text-white" />
<input
type="range"
min={0}
max={1}
step={0.01}
value={volume}
onChange={handleVolume}
className="w-full h-1 bg-[#4d4d4d] rounded-lg appearance-none cursor-pointer accent-white group-hover:accent-green-500"
/>
</div>
<Maximize2 className="hidden md:block w-4 h-4 hover:text-white cursor-pointer" />
</div>
{/* --- OVERLAYS --- */}
{/* Mobile Full Screen Player */}
{isFullScreenPlayerOpen && currentTrack && (
<div className="fixed inset-0 z-[65] h-[100dvh] bg-gradient-to-b from-[#404040] via-[#121212] to-black flex flex-col px-6 pt-12 pb-[calc(2rem+env(safe-area-inset-bottom))] md:hidden animate-in slide-in-from-bottom duration-300">
{/* Header */}
<div className="flex items-center justify-between mb-8 shrink-0">
<button onClick={(e) => { e.stopPropagation(); setIsFullScreenPlayerOpen(false); }} className="text-white">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 9l-7 7-7-7" /></svg>
</button>
<span className="text-xs font-bold tracking-widest uppercase text-white/80">Now Playing</span>
<button className="text-white">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" /></svg>
</button>
</div>
{/* Artwork */}
<div className="flex-1 flex items-center justify-center w-full mb-8 min-h-0">
<img src={currentTrack.cover_url} alt="Cover" className="w-full max-h-[45vh] aspect-square object-contain rounded-lg shadow-2xl" />
</div>
{/* Info */}
<div className="flex items-center justify-between mb-6 shrink-0">
<div className="flex flex-col gap-1 pr-4">
<div className="relative overflow-hidden w-full">
<h2 className="text-2xl font-bold text-white leading-tight line-clamp-1">{currentTrack.title}</h2>
</div>
<p className="text-lg text-gray-400 line-clamp-1">{currentTrack.artist}</p>
</div>
<button onClick={(e) => { e.stopPropagation(); toggleLike(currentTrack); }} className={`${likedTracks.has(currentTrack.id) ? 'text-green-500' : 'text-white'}`}>
<Heart size={28} fill={likedTracks.has(currentTrack.id) ? "currentColor" : "none"} />
</button>
</div>
{/* Progress */}
<div className="mb-4 shrink-0">
<input
type="range"
min={0}
max={duration || 100}
value={progress}
onChange={handleSeek}
className="w-full h-1 bg-white/20 rounded-lg appearance-none cursor-pointer accent-white hover:accent-green-500 mb-2"
/>
<div className="flex justify-between text-xs text-gray-400 font-medium">
<span>{formatTime(progress)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center justify-between mb-8 px-2 shrink-0">
<button onClick={toggleShuffle} className={`${shuffle ? 'text-green-500' : 'text-zinc-400'} hover:text-white transition`}>
<Shuffle size={24} />
</button>
<button onClick={prevTrack} className="text-white hover:scale-110 transition">
<SkipBack size={36} fill="currentColor" />
</button>
<button
onClick={(e) => { e.stopPropagation(); togglePlay(); }}
className="w-16 h-16 bg-white rounded-full flex items-center justify-center hover:scale-105 transition active:scale-95 text-black shadow-lg"
>
{isBuffering ? <Loader2 className="w-8 h-8 text-black animate-spin" /> : (isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" className="ml-1" />)}
</button>
<button onClick={nextTrack} className="text-white hover:scale-110 transition">
<SkipForward size={36} fill="currentColor" />
</button>
<button onClick={toggleRepeat} className={`${repeatMode !== 'none' ? 'text-green-500' : 'text-zinc-400'} hover:text-white transition relative`}>
<Repeat size={24} />
{repeatMode === 'one' && <span className="absolute -top-1 -right-1 text-[8px] bg-green-500 text-black px-1 rounded-full">1</span>}
</button>
</div>
{/* Footer Actions */}
<div className="flex items-center justify-between px-2 shrink-0">
<button onClick={() => setIsTechSpecsOpen(true)} className="text-green-500 hover:text-white transition">
<MonitorSpeaker size={20} />
</button>
<div className="flex gap-6 items-center">
<button className="text-zinc-400 hover:text-white">
<PlusCircle size={24} onClick={() => setIsAddToPlaylistOpen(true)} />
</button>
<button
className={`transition ${isLyricsOpen ? 'text-green-500' : 'text-zinc-400'} hover:text-white`}
onClick={(e) => { e.stopPropagation(); setIsLyricsOpen(!isLyricsOpen); }}
>
<Mic2 size={24} />
</button>
<button className="text-zinc-400 hover:text-white">
<ListMusic size={24} />
</button>
</div>
</div>
</div>
)}
{/* Desktop Full Cover Modal */}
{isCoverModalOpen && currentTrack && (
<div className="fixed inset-0 z-[65] bg-black/80 backdrop-blur-sm flex items-center justify-center p-8 animate-in fade-in duration-200" onClick={() => setIsCoverModalOpen(false)}>
<img src={currentTrack.cover_url} alt="Cover Full" className="max-h-[80vh] max-w-[80vw] object-contain shadow-2xl rounded-lg scale-100" onClick={(e) => e.stopPropagation()} />
<button onClick={() => setIsCoverModalOpen(false)} className="absolute top-8 right-8 text-white/50 hover:text-white"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6L6 18M6 6l12 12" /></svg></button>
</div>
)}
{/* Modal */}
<TechSpecs
isOpen={isTechSpecsOpen}
onClose={() => setIsTechSpecsOpen(false)}
quality={audioQuality}
trackTitle={currentTrack?.title || ''}
/>
{isAddToPlaylistOpen && currentTrack && (
<AddToPlaylistModal
track={currentTrack}
isOpen={true}
onClose={() => setIsAddToPlaylistOpen(false)}
/>
)}
{/* Lyrics Sheet (Responsive) */}
{isLyricsOpen && currentTrack && (
<div className="fixed inset-0 md:inset-auto md:bottom-[100px] md:left-1/2 md:-translate-x-1/2 h-[100dvh] md:h-[500px] md:w-[600px] z-[70] bg-[#121212] md:bg-black/80 backdrop-blur-xl border-t md:border border-white/10 md:rounded-2xl shadow-2xl animate-in slide-in-from-bottom-full duration-500 overflow-hidden flex flex-col">
{/* Mobile Drag Handle Visual - Removed for full screen immersion */}
{/* <div
className="w-12 h-1 bg-white/20 rounded-full mx-auto mt-3 mb-1 md:hidden cursor-pointer"
onClick={() => setIsLyricsOpen(false)}
/> */}
<LyricsDetail
track={currentTrack}
currentTime={audioRef.current ? audioRef.current.currentTime : 0}
onClose={() => setIsLyricsOpen(false)}
onSeek={(time) => {
if (audioRef.current) {
audioRef.current.currentTime = time;
setProgress(time);
}
}}
/>
</div>
)}
</footer>
);
}

View file

@ -0,0 +1,192 @@
"use client";
import { Home, Search, Library, Plus, Heart } from "lucide-react";
import Link from "next/link";
import { usePlayer } from "@/context/PlayerContext";
import { useState } from "react";
import CreatePlaylistModal from "./CreatePlaylistModal";
import { dbService } from "@/services/db";
import { useLibrary } from "@/context/LibraryContext";
export default function Sidebar() {
const { likedTracks } = usePlayer();
const { userPlaylists, libraryItems, refreshLibrary: refresh, activeFilter, setActiveFilter } = useLibrary();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const handleCreatePlaylist = async (name: string) => {
await dbService.createPlaylist(name);
refresh();
};
const handleDeletePlaylist = async (e: React.MouseEvent, id: string) => {
e.preventDefault();
e.stopPropagation();
if (confirm("Delete this playlist?")) {
await dbService.deletePlaylist(id);
refresh();
}
};
// Filtering Logic
const showPlaylists = activeFilter === 'all' || activeFilter === 'playlists';
const showArtists = activeFilter === 'all' || activeFilter === 'artists';
const showAlbums = activeFilter === 'all' || activeFilter === 'albums';
const artists = libraryItems.filter(i => i.type === 'Artist');
const albums = libraryItems.filter(i => i.type === 'Album');
const browsePlaylists = libraryItems.filter(i => i.type === 'Playlist');
return (
<aside className="hidden md:flex flex-col w-[280px] bg-black h-full gap-2 p-2">
<div className="bg-[#121212] rounded-lg p-4 flex flex-col gap-4">
<Link href="/" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer">
<Home className="w-6 h-6" />
<span className="font-bold">Home</span>
</Link>
<Link href="/search" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer">
<Search className="w-6 h-6" />
<span className="font-bold">Search</span>
</Link>
</div>
<div className="bg-[#121212] rounded-lg flex-1 flex flex-col overflow-hidden">
<div className="p-4 shadow-md z-10">
<div className="flex items-center justify-between text-spotify-text-muted mb-4">
<Link href="/library" className="flex items-center gap-2 hover:text-white transition cursor-pointer">
<Library className="w-6 h-6" />
<span className="font-bold">Your Library</span>
</Link>
<div className="flex items-center gap-2">
<button onClick={() => setIsCreateModalOpen(true)} className="hover:text-white hover:scale-110 transition">
<Plus className="w-6 h-6" />
</button>
</div>
</div>
{/* Filters */}
<div className="flex gap-2 mt-4 overflow-x-auto no-scrollbar">
{['Playlists', 'Artists', 'Albums'].map((filter) => {
const key = filter.toLowerCase() as any;
const isActive = activeFilter === key;
return (
<button
key={filter}
onClick={() => setActiveFilter(isActive ? 'all' : key)}
className={`px-3 py-1 rounded-full text-sm font-medium transition whitespace-nowrap ${isActive ? 'bg-white text-black' : 'bg-[#2a2a2a] text-white hover:bg-[#3a3a3a]'}`}
>
{filter}
</button>
);
})}
</div>
</div>
<div className="flex-1 overflow-y-auto px-2 no-scrollbar">
{/* Liked Songs (Always top if 'Playlists' or 'All') */}
{showPlaylists && (
<Link href="/collection/tracks">
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer group mb-2">
<div className="w-12 h-12 bg-gradient-to-br from-indigo-700 to-blue-300 rounded flex items-center justify-center">
<Heart className="w-6 h-6 text-white fill-white" />
</div>
<div>
<h3 className="text-white font-medium">Liked Songs</h3>
<p className="text-sm text-spotify-text-muted">Playlist {likedTracks.size} songs</p>
</div>
</div>
</Link>
)}
{/* User Playlists */}
{showPlaylists && userPlaylists.map((playlist) => (
<div key={playlist.id} className="group relative flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
<Link href={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3">
<div className="w-12 h-12 bg-[#282828] rounded flex items-center justify-center overflow-hidden">
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
<img src={playlist.cover_url} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-xl">🎵</span>
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-medium truncate">{playlist.title}</h3>
<p className="text-sm text-spotify-text-muted truncate">Playlist You</p>
</div>
</Link>
<button
onClick={(e) => handleDeletePlaylist(e, playlist.id)}
className="absolute right-2 text-zinc-400 hover:text-white opacity-0 group-hover:opacity-100 transition"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
))}
{/* Fake/Browse Playlists */}
{showPlaylists && browsePlaylists.map((playlist) => (
<div key={playlist.id} className="group relative flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
<Link href={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3">
<div className="w-12 h-12 bg-[#282828] rounded flex items-center justify-center overflow-hidden">
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
<img src={playlist.cover_url} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-xl">🎵</span>
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-medium truncate">{playlist.title}</h3>
<p className="text-sm text-spotify-text-muted truncate">Playlist Made for you</p>
</div>
</Link>
</div>
))}
{/* Artists */}
{showArtists && artists.map((artist) => (
<Link href={`/search?q=${encodeURIComponent(artist.title)}`} key={artist.id}>
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
<div className="w-12 h-12 bg-[#282828] rounded-full flex items-center justify-center overflow-hidden relative">
{artist.cover_url ? (
<img src={artist.cover_url} alt="" className="w-full h-full object-cover" onError={(e) => e.currentTarget.style.display = 'none'} />
) : null}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-medium truncate">{artist.title}</h3>
<p className="text-sm text-spotify-text-muted truncate">Artist</p>
</div>
</div>
</Link>
))}
{/* Albums */}
{showAlbums && albums.map((album) => (
<Link href={`/ search ? q = ${encodeURIComponent(album.title)} `} key={album.id}>
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
<div className="w-12 h-12 bg-[#282828] rounded flex items-center justify-center overflow-hidden relative">
{album.cover_url ? (
<img src={album.cover_url} alt="" className="w-full h-full object-cover" onError={(e) => e.currentTarget.style.display = 'none'} />
) : (
<span className="text-xl">💿</span>
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-medium truncate">{album.title}</h3>
<p className="text-sm text-spotify-text-muted truncate">Album {album.creator || 'Spotify'}</p>
</div>
</div>
</Link>
))}
</div>
</div>
<CreatePlaylistModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onCreate={handleCreatePlaylist}
/>
</aside>
);
}

View file

@ -0,0 +1,247 @@
import { X, Activity, Zap, Server, ShieldCheck, Waves, Wifi, ArrowDown, ArrowUp } from "lucide-react";
import { useEffect, useState, useRef } from "react";
interface AudioQuality {
format: string;
sampleRate: number;
bitDepth?: number;
bitrate: number;
channels: number;
codec?: string;
}
interface TechSpecsProps {
isOpen: boolean;
onClose: () => void;
quality: AudioQuality | null;
trackTitle: string;
}
export default function TechSpecs({ isOpen, onClose, quality, trackTitle }: TechSpecsProps) {
const [bitrateHistory, setBitrateHistory] = useState<number[]>(new Array(40).fill(0));
const [currentKbps, setCurrentKbps] = useState(0);
const [bufferHealth, setBufferHealth] = useState(100);
const [networkStats, setNetworkStats] = useState({ download: 0, upload: 0 });
const canvasRef = useRef<HTMLCanvasElement>(null);
// Simulate live bitrate fluctuation around the target bitrate
useEffect(() => {
if (!isOpen || !quality) return;
const baseBitrate = quality.bitrate / 1000; // kbps
setCurrentKbps(baseBitrate);
setBitrateHistory(new Array(40).fill(baseBitrate));
const interval = setInterval(() => {
// Fluctuate +/- 5%
const fluctuation = baseBitrate * 0.05 * (Math.random() - 0.5);
const newValue = baseBitrate + fluctuation;
setCurrentKbps(prev => newValue);
setBitrateHistory(prev => [...prev.slice(1), newValue]);
// Random buffer fluctuation
setBufferHealth(prev => Math.min(100, Math.max(98, prev + (Math.random() - 0.5))));
// Simulate Network Traffic (Bursty Download, Consistent Upload)
setNetworkStats({
download: Math.random() > 0.6 ? Math.floor(Math.random() * 2500) + 800 : 0, // kbps
upload: Math.floor(Math.random() * 40) + 10 // kbps
});
}, 100);
return () => clearInterval(interval);
}, [isOpen, quality]);
// Draw Graph
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !quality) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
const baseBitrate = quality.bitrate / 1000;
const range = baseBitrate * 0.2; // Zoom in range
ctx.clearRect(0, 0, width, height);
// Gradient
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, 'rgba(74, 222, 128, 0.5)'); // Green-400
gradient.addColorStop(1, 'rgba(74, 222, 128, 0)');
ctx.beginPath();
ctx.moveTo(0, height);
bitrateHistory.forEach((val, i) => {
const x = (i / (bitrateHistory.length - 1)) * width;
// Map value to height (inverted)
// min = base - range, max = base + range
const normalized = (val - (baseBitrate - range)) / (range * 2);
const y = height - (normalized * height);
ctx.lineTo(x, y);
});
ctx.lineTo(width, height);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
// Line
ctx.beginPath();
bitrateHistory.forEach((val, i) => {
const x = (i / (bitrateHistory.length - 1)) * width;
const normalized = (val - (baseBitrate - range)) / (range * 2);
const y = height - (normalized * height);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
ctx.stroke();
}, [bitrateHistory, quality]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-in fade-in duration-200">
<div className="bg-[#121212] border border-white/10 rounded-xl w-full max-w-md relative shadow-2xl flex flex-col overflow-hidden">
{/* Header */}
<div className="p-6 border-b border-white/5 flex justify-between items-start">
<div>
<div className="flex items-center gap-2 mb-1">
<Activity className="text-green-500 w-5 h-5" />
<h3 className="text-lg font-bold text-white tracking-wide">AUDIO ENGINE</h3>
</div>
<p className="text-xs text-[#a7a7a7] uppercase tracking-wider font-mono">
{quality ? 'DIRECT SOUND • EXCLUSIVE MODE' : 'INITIALIZING...'}
</p>
</div>
<button
onClick={onClose}
className="text-white/50 hover:text-white transition"
>
<X size={24} />
</button>
</div>
{/* Content */}
{quality ? (
<div className="p-6 space-y-6">
{/* Live Monitor */}
<div className="bg-black/40 rounded-lg p-4 border border-white/5">
<div className="flex justify-between items-end mb-2">
<span className="text-xs text-gray-400 font-mono uppercase">Live Bitrate</span>
<span className="text-xl font-mono font-bold text-green-400">
{currentKbps.toFixed(0)} <span className="text-xs text-gray-500">kbps</span>
</span>
</div>
<canvas ref={canvasRef} width={300} height={60} className="w-full h-[60px]" />
</div>
{/* Signal Path / Stats Grid */}
<div className="grid grid-cols-2 gap-4">
{/* Source Stats */}
<div className="space-y-3">
<h4 className="text-xs text-gray-500 uppercase tracking-widest font-bold mb-2">Source</h4>
<div className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-gray-400">Codec</span>
<span className="text-white font-mono">{quality.codec || quality.format}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-gray-400">Sample Rate</span>
<span className="text-white font-mono">{quality.sampleRate / 1000} kHz</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-gray-400">Bit Depth</span>
<span className="text-white font-mono">{quality.bitDepth || 24}-bit</span>
</div>
</div>
</div>
{/* Processing Stats */}
<div className="space-y-3">
<h4 className="text-xs text-gray-500 uppercase tracking-widest font-bold mb-2">Processing</h4>
<div className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-gray-400">Integrity</span>
<span className="text-green-400 font-mono flex items-center gap-1">
<ShieldCheck className="w-3 h-3" /> Bit-Perfect
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-gray-400">Dynamic Range</span>
<span className="text-white font-mono">14 dB (Est)</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-gray-400">Loudness</span>
<span className="text-white font-mono">-14.2 LUFS</span>
</div>
</div>
</div>
</div>
{/* Network & Buffer */}
<div className="space-y-2 pt-2 border-t border-white/5">
{/* Network Stream */}
<div className="flex items-center justify-between text-xs text-gray-400 mb-3">
<div className="flex items-center gap-2">
<Wifi className="w-3 h-3" />
<span>Network Stream</span>
</div>
<div className="flex gap-3 font-mono">
<span className="text-white flex items-center gap-1">
<ArrowDown className="w-3 h-3 text-blue-400" />
{networkStats.download > 0 ? (networkStats.download / 1000).toFixed(1) : '0.0'} <span className="text-gray-600">MB/s</span>
</span>
<span className="text-white/60 flex items-center gap-1">
<ArrowUp className="w-3 h-3 text-orange-400" />
{networkStats.upload} <span className="text-gray-600">kbps</span>
</span>
</div>
</div>
<div className="flex items-center justify-between text-xs text-gray-400">
<div className="flex items-center gap-2">
<Server className="w-3 h-3" />
<span>Stream Buffer</span>
</div>
<span className="font-mono text-white">{bufferHealth.toFixed(1)}%</span>
</div>
<div className="h-1 w-full bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-300"
style={{ width: `${bufferHealth}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs text-gray-400 mt-2">
<div className="flex items-center gap-2">
<Zap className="w-3 h-3" />
<span>Output Device</span>
</div>
<span className="font-mono text-white">Default Audio Interface</span>
</div>
</div>
</div>
) : (
<div className="p-12 text-center text-gray-500 animate-pulse font-mono text-sm">
HANDSHAKING WITH AUDIO CORE...
</div>
)}
{/* Footer */}
<div className="bg-white/5 p-3 text-[10px] text-center text-gray-600 font-mono uppercase tracking-widest">
Quantum Audio Engine v2.4.0 64-bit Floating Point
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,144 @@
"use client";
import React, { createContext, useContext, useState, useEffect } from "react";
import { dbService, Playlist } from "@/services/db";
import { libraryService } from "@/services/library";
type FilterType = 'all' | 'playlists' | 'artists' | 'albums';
interface LibraryContextType {
userPlaylists: Playlist[];
libraryItems: any[];
activeFilter: FilterType;
setActiveFilter: (filter: FilterType) => void;
refreshLibrary: () => Promise<void>;
}
const LibraryContext = createContext<LibraryContextType | undefined>(undefined);
export function LibraryProvider({ children }: { children: React.ReactNode }) {
const [userPlaylists, setUserPlaylists] = useState<Playlist[]>([]);
const [libraryItems, setLibraryItems] = useState<any[]>([]);
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
const fetchAllData = async () => {
try {
// 1. User Playlists
const playlists = await dbService.getPlaylists();
setUserPlaylists(playlists);
// 2. Local/Backend Content
const browse = await libraryService.getBrowseContent();
const browsePlaylists = Object.values(browse).flat();
const artistsMap = new Map();
const albumsMap = new Map();
const allTracks: any[] = [];
// 3. Extract metadata
browsePlaylists.forEach((p: any) => {
if (p.tracks) {
p.tracks.forEach((t: any) => {
allTracks.push(t);
// Fake Artist
if (artistsMap.size < 40 && t.artist && t.artist !== 'Unknown Artist' && t.artist !== 'Unknown') {
if (!artistsMap.has(t.artist)) {
artistsMap.set(t.artist, {
id: `artist-${t.artist}`,
title: t.artist,
type: 'Artist',
cover_url: t.cover_url
});
}
}
// Fake Album
if (albumsMap.size < 40 && t.album && t.album !== 'Single' && t.album !== 'Unknown Album') {
if (!albumsMap.has(t.album)) {
albumsMap.set(t.album, {
id: `album-${t.album}`,
title: t.album,
type: 'Album',
creator: t.artist,
cover_url: t.cover_url
});
}
}
});
}
});
// 4. Generate Fake Extra Playlists (Creative Names)
const fakePlaylists = [...browsePlaylists];
const targetCount = 40;
const needed = targetCount - fakePlaylists.length;
const creativeNames = [
"Chill Vibes", "Late Night Focus", "Workout Energy", "Road Trip Classics",
"Indie Mix", "Pop Hits", "Throwback Thursday", "Weekend Flow",
"Deep Focus", "Party Anthems", "Jazz & Blues", "Acoustic Sessions",
"Morning Coffee", "Rainy Day", "Sleep Sounds", "Gaming Beats",
"Coding Mode", "Summer Hits", "Winter Lo-Fi", "Discover Weekly",
"Release Radar", "On Repeat", "Time Capsule", "Viral 50",
"Global Top 50", "Trending Now", "Fresh Finds", "Audiobook Mode",
"Podcast Favorites", "Rock Classics", "Metal Essentials", "Hip Hop Gold",
"Electronic Dreams", "Ambient Spaces", "Classical Masterpieces", "Country Roads"
];
if (needed > 0 && allTracks.length > 0) {
const shuffle = (array: any[]) => array.sort(() => 0.5 - Math.random());
for (let i = 0; i < needed; i++) {
const shuffled = shuffle([...allTracks]);
const selected = shuffled.slice(0, 8 + Math.floor(Math.random() * 12));
const cover = selected[0]?.cover_url;
const name = creativeNames[i] || `Daily Mix ${i + 1}`;
fakePlaylists.push({
id: `mix-${i}`,
title: name,
description: `Curated just for you • ${selected.length} songs`,
cover_url: cover,
tracks: selected,
type: 'Playlist'
});
}
}
const uniqueItems = [
...fakePlaylists.map(p => ({ ...p, type: 'Playlist' })),
...Array.from(artistsMap.values()),
...Array.from(albumsMap.values())
];
setLibraryItems(uniqueItems);
} catch (err) {
console.error(err);
}
};
useEffect(() => {
fetchAllData();
}, []);
return (
<LibraryContext.Provider value={{
userPlaylists,
libraryItems,
activeFilter,
setActiveFilter,
refreshLibrary: fetchAllData
}}>
{children}
</LibraryContext.Provider>
);
}
export function useLibrary() {
const context = useContext(LibraryContext);
if (context === undefined) {
throw new Error("useLibrary must be used within a LibraryProvider");
}
return context;
}

View file

@ -0,0 +1,288 @@
"use client";
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
import { dbService } from "@/services/db";
interface Track {
title: string;
artist: string;
album: string;
cover_url: string;
id: string;
url?: string;
}
import * as mm from 'music-metadata-browser';
interface AudioQuality {
format: string;
sampleRate: number;
bitDepth?: number;
bitrate: number;
channels: number;
codec?: string;
}
interface PlayerContextType {
currentTrack: Track | null;
isPlaying: boolean;
isBuffering: boolean;
likedTracks: Set<string>;
likedTracksData: Track[];
shuffle: boolean;
repeatMode: 'none' | 'all' | 'one';
playTrack: (track: Track, queue?: Track[]) => void;
togglePlay: () => void;
nextTrack: () => void;
prevTrack: () => void;
toggleShuffle: () => void;
toggleRepeat: () => void;
setBuffering: (state: boolean) => void;
toggleLike: (track: Track) => void;
playHistory: Track[];
audioQuality: AudioQuality | null;
}
const PlayerContext = createContext<PlayerContextType | undefined>(undefined);
export function PlayerProvider({ children }: { children: ReactNode }) {
const [currentTrack, setCurrentTrack] = useState<Track | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(false);
const [likedTracks, setLikedTracks] = useState<Set<string>>(new Set());
const [likedTracksData, setLikedTracksData] = useState<Track[]>([]);
// Audio Engine State
const [audioQuality, setAudioQuality] = useState<AudioQuality | null>(null);
const [preloadedBlobs, setPreloadedBlobs] = useState<Map<string, string>>(new Map());
// Queue State
const [queue, setQueue] = useState<Track[]>([]);
const [currentIndex, setCurrentIndex] = useState(-1);
const [shuffle, setShuffle] = useState(false);
const [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('none');
// History State
const [playHistory, setPlayHistory] = useState<Track[]>([]);
// Load Likes from DB
useEffect(() => {
dbService.getLikedSongs().then(tracks => {
setLikedTracks(new Set(tracks.map(t => t.id)));
setLikedTracksData(tracks);
});
}, []);
// Load History from LocalStorage
useEffect(() => {
try {
const saved = localStorage.getItem('playHistory');
if (saved) {
setPlayHistory(JSON.parse(saved));
}
} catch (e) {
console.error("Failed to load history", e);
}
}, []);
// Save History
useEffect(() => {
localStorage.setItem('playHistory', JSON.stringify(playHistory));
}, [playHistory]);
// Metadata & Preloading Effect
useEffect(() => {
if (!currentTrack) return;
// 1. Reset Quality
setAudioQuality(null);
// 2. Parse Metadata for Current Track
const parseMetadata = async () => {
try {
// Skip metadata parsing for backend streams AND external URLs (YouTube) to avoid CORS/Double-fetch
if (currentTrack.url && (currentTrack.url.includes('/api/stream') || currentTrack.url.startsWith('http'))) {
setAudioQuality({
format: 'WEBM/OPUS', // YT Music typically
sampleRate: 48000,
bitrate: 128000,
channels: 2,
codec: 'Opus'
});
return;
}
if (currentTrack.url) {
// Note: In a real scenario, we might need a proxy or CORS-enabled server.
// music-metadata-browser fetches the file.
const metadata = await mm.fetchFromUrl(currentTrack.url);
setAudioQuality({
format: metadata.format.container || 'Unknown',
sampleRate: metadata.format.sampleRate || 44100,
bitDepth: metadata.format.bitsPerSample,
bitrate: metadata.format.bitrate || 0,
channels: metadata.format.numberOfChannels || 2,
codec: metadata.format.codec
});
}
} catch (e) {
console.warn("Failed to parse metadata", e);
// Fallback mock if parsing fails (likely due to CORS on sample URL)
setAudioQuality({
format: 'MP3',
sampleRate: 44100,
bitrate: 320000,
channels: 2,
codec: 'MPEG-1 Layer 3'
});
}
};
parseMetadata();
// 3. Smart Buffering (Preload Next 2 Tracks)
const preloadNext = async () => {
if (queue.length === 0) return;
const index = queue.findIndex(t => t.id === currentTrack.id);
if (index === -1) return;
const nextTracks = queue.slice(index + 1, index + 3);
nextTracks.forEach(async (track) => {
if (!preloadedBlobs.has(track.id) && track.url) {
try {
// Construct the correct stream URL for preloading if it's external
const fetchUrl = track.url.startsWith('http') ? `/api/stream?id=${track.id}` : track.url;
const res = await fetch(fetchUrl);
if (!res.ok) throw new Error("Fetch failed");
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
setPreloadedBlobs(prev => new Map(prev).set(track.id, blobUrl));
console.log(`Buffered ${track.title}`);
} catch (e) {
// console.warn(`Failed to buffer ${track.title}`);
}
}
});
};
preloadNext();
}, [currentTrack, queue, preloadedBlobs]);
const playTrack = (track: Track, newQueue?: Track[]) => {
if (currentTrack?.id !== track.id) {
setIsBuffering(true);
// Add to History (prevent duplicates at top)
setPlayHistory(prev => {
const filtered = prev.filter(t => t.id !== track.id);
return [track, ...filtered].slice(0, 20); // Keep last 20
});
}
setCurrentTrack(track);
setIsPlaying(true);
if (newQueue) {
setQueue(newQueue);
const index = newQueue.findIndex(t => t.id === track.id);
setCurrentIndex(index);
}
};
const togglePlay = () => {
setIsPlaying((prev) => !prev);
};
const nextTrack = () => {
if (queue.length === 0) return;
let nextIndex = currentIndex + 1;
if (shuffle) {
nextIndex = Math.floor(Math.random() * queue.length);
} else if (nextIndex >= queue.length) {
if (repeatMode === 'all') nextIndex = 0;
else return; // Stop if end of queue and no repeat
}
playTrack(queue[nextIndex]);
setCurrentIndex(nextIndex);
};
const prevTrack = () => {
if (queue.length === 0) return;
let prevIndex = currentIndex - 1;
if (prevIndex < 0) prevIndex = 0; // Or wrap around if desired
playTrack(queue[prevIndex]);
setCurrentIndex(prevIndex);
};
const toggleShuffle = () => setShuffle(prev => !prev);
const toggleRepeat = () => {
setRepeatMode(prev => {
if (prev === 'none') return 'all';
if (prev === 'all') return 'one';
return 'none';
});
};
const setBuffering = (state: boolean) => setIsBuffering(state);
const toggleLike = async (track: Track) => {
const isNowLiked = await dbService.toggleLike(track);
setLikedTracks(prev => {
const next = new Set(prev);
if (isNowLiked) next.add(track.id);
else next.delete(track.id);
return next;
});
setLikedTracksData(prev => {
if (!isNowLiked) {
return prev.filter(t => t.id !== track.id);
} else {
return [...prev, track];
}
});
};
const effectiveCurrentTrack = currentTrack ? {
...currentTrack,
// improved URL logic: usage of backend API if no local blob
url: preloadedBlobs.get(currentTrack.id) ||
(currentTrack.url && currentTrack.url.startsWith('/') ? currentTrack.url : `/api/stream?id=${currentTrack.id}`)
} : null;
return (
<PlayerContext.Provider value={{
currentTrack: effectiveCurrentTrack,
isPlaying,
isBuffering,
likedTracks,
likedTracksData,
shuffle,
repeatMode,
playTrack,
togglePlay,
nextTrack,
prevTrack,
toggleShuffle,
toggleRepeat,
setBuffering,
toggleLike,
playHistory,
audioQuality
}}>
{children}
</PlayerContext.Provider>
);
}
export function usePlayer() {
const context = useContext(PlayerContext);
if (context === undefined) {
throw new Error("usePlayer must be used within a PlayerProvider");
}
return context;
}

4
frontend/env_template Normal file
View file

@ -0,0 +1,4 @@
SPOTIFY_CLIENT_ID=your_client_id_here
SPOTIFY_CLIENT_SECRET=your_client_secret_here
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=changeme_to_a_random_string_openssl_rand_base64_32

View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View file

@ -0,0 +1,116 @@
import { useState, useEffect } from 'react';
import { dbService, Playlist } from '@/services/db';
import { libraryService } from '@/services/library';
export function useLibraryData() {
const [userPlaylists, setUserPlaylists] = useState<Playlist[]>([]);
const [libraryItems, setLibraryItems] = useState<any[]>([]);
const fetchAllData = async () => {
try {
// 1. User Playlists
const playlists = await dbService.getPlaylists();
setUserPlaylists(playlists);
// 2. Local/Backend Content
const browse = await libraryService.getBrowseContent();
const browsePlaylists = Object.values(browse).flat();
const artistsMap = new Map();
const albumsMap = new Map();
const allTracks: any[] = [];
// 3. Extract metadata and flattening tracks
browsePlaylists.forEach((p: any) => {
if (p.tracks) {
p.tracks.forEach((t: any) => {
allTracks.push(t);
// Fake Artist
if (artistsMap.size < 40 && t.artist && t.artist !== 'Unknown Artist' && t.artist !== 'Unknown') {
if (!artistsMap.has(t.artist)) {
artistsMap.set(t.artist, {
id: `artist-${t.artist}`,
title: t.artist,
type: 'Artist',
cover_url: t.cover_url
});
}
}
// Fake Album
if (albumsMap.size < 40 && t.album && t.album !== 'Single' && t.album !== 'Unknown Album') {
if (!albumsMap.has(t.album)) {
albumsMap.set(t.album, {
id: `album-${t.album}`,
title: t.album,
type: 'Album',
creator: t.artist,
cover_url: t.cover_url
});
}
}
});
}
});
const creativeNames = [
"Chill Vibes", "Late Night Focus", "Workout Energy", "Road Trip Classics",
"Indie Mix", "Pop Hits", "Throwback Thursday", "Weekend Flow",
"Deep Focus", "Party Anthems", "Jazz & Blues", "Acoustic Sessions",
"Morning Coffee", "Rainy Day", "Sleep Sounds", "Gaming Beats",
"Coding Mode", "Summer Hits", "Winter Lo-Fi", "Discover Weekly",
"Release Radar", "On Repeat", "Time Capsule", "Viral 50",
"Global Top 50", "Trending Now", "Fresh Finds", "Audiobook Mode",
"Podcast Favorites", "Rock Classics", "Metal Essentials", "Hip Hop Gold",
"Electronic Dreams", "Ambient Spaces", "Classical Masterpieces", "Country Roads"
];
// 4. Generate Fake Extra Playlists if needed (Target ~40 total)
const fakePlaylists = [...browsePlaylists];
const targetCount = 40;
const needed = targetCount - fakePlaylists.length;
if (needed > 0 && allTracks.length > 0) {
// Shuffle utility
const shuffle = (array: any[]) => array.sort(() => 0.5 - Math.random());
for (let i = 0; i < needed; i++) {
const shuffled = shuffle([...allTracks]);
const selected = shuffled.slice(0, 8 + Math.floor(Math.random() * 12)); // 8-20 tracks
const cover = selected[0]?.cover_url;
const name = creativeNames[i] || `Daily Mix ${i + 1}`;
fakePlaylists.push({
id: `mix-${i}`,
title: name,
description: `Curated just for you • ${selected.length} songs`,
cover_url: cover,
tracks: selected,
type: 'Playlist'
});
}
}
// Dedupe Playlists (just in case)
// const uniquePlaylists = Array.from(new Map(fakePlaylists.map(p => [p.id, p])).values());
const uniqueItems = [
...fakePlaylists.map(p => ({ ...p, type: 'Playlist' })),
...Array.from(artistsMap.values()),
...Array.from(albumsMap.values())
];
setLibraryItems(uniqueItems);
} catch (err) {
console.error(err);
}
};
useEffect(() => {
fetchAllData();
}, []);
return { userPlaylists, libraryItems, refresh: fetchAllData };
}

50
frontend/next.config.mjs Normal file
View file

@ -0,0 +1,50 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// strict mode true is default but good to be explicit
reactStrictMode: true,
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
async rewrites() {
return [
// Backend API Proxies (Specific, so we don't block NextAuth at /api/auth)
{ source: '/api/browse/:path*', destination: 'http://127.0.0.1:8000/api/browse/:path*' },
{ source: '/api/playlists/:path*', destination: 'http://127.0.0.1:8000/api/playlists/:path*' },
{ source: '/api/search/:path*', destination: 'http://127.0.0.1:8000/api/search/:path*' },
{ source: '/api/stream/:path*', destination: 'http://127.0.0.1:8000/api/stream/:path*' },
{ source: '/api/download/:path*', destination: 'http://127.0.0.1:8000/api/download/:path*' },
{ source: '/api/download-status/:path*', destination: 'http://127.0.0.1:8000/api/download-status/:path*' },
{ source: '/api/lyrics/:path*', destination: 'http://127.0.0.1:8000/api/lyrics/:path*' },
{ source: '/api/trending/:path*', destination: 'http://127.0.0.1:8000/api/trending/:path*' },
];
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'i.ytimg.com',
},
{
protocol: 'https',
hostname: 'lh3.googleusercontent.com',
},
{
protocol: 'https',
hostname: 'placehold.co',
},
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
{
protocol: 'https',
hostname: 'misc.scdn.co',
},
],
},
};
export default nextConfig;

6893
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

31
frontend/package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"autoprefixer": "^10.4.23",
"idb": "^8.0.3",
"lucide-react": "^0.561.0",
"music-metadata-browser": "^2.5.11",
"next": "^14.2.0",
"postcss": "^8.5.6",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "^14.2.0",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
frontend/public/file.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

View file

@ -0,0 +1,548 @@
{
"id": "PLpY7hx7jry7zc4zspi_fBhWQt8z5jrJ8z",
"title": "Best Vietnamese Songs 2025 - Popular Vietnamese Songs 2025 Playlist (Top Vietnamese Music 2025-2026)",
"description": "Best Vietnamese Songs 2025 - Popular Vietnamese Songs 2025 Playlist (Top Vietnamese Music 2025-2026)\n\nIf you liked this playlist, we recommend you also listen to these music lists:\n\n1. Most Popular Vietnamese Songs 2025 - Best of Vietnamese Music 2025 Playlist (Famous Vietnamese Songs 2025-2026) - https://goplaylists.com/56624\n2. New Vietnamese Songs 2025 - Latest Vietnamese Song 2025 Playlist (New Vietnam Music 2025-2026) - https://goplaylists.com/13081\n\nFind our playlist with these keywords: popular vietnamese songs 2025, top vietnamese songs 2025, best vietnamese music 2025, vietnam music playlist, top vietnamese music 2025, vietnam playlist 2025, vietnamese songs 2025, popular, vietnamese songs, vietnam playlist music, best vietnamese songs 2025, vietnamese playlist 2025, vietnamese hits 2025, vietnamese songs, top vietnam music 2025, vietnam song playlist, top 10 vietnamese songs, vietnam music chart 2025, vietnamese song trends\n\nDive deep into the mesmerizing world of Vietnamese music with a curated collection of the year's most compelling tracks. Experience the rhythm, the emotion, and the soulful voices of Vietnam's top artists. Each song has been handpicked to represent the heartbeat of Vietnam's contemporary music scene, bringing to you an array of melodies that resonate with the beauty and culture of this enchanting nation. Whether you're a longtime fan or a newcomer to Vietnamese tunes, this selection is bound to captivate your senses and take you on an unforgettable musical journey \ud83c\udfb5.\n\nIn the next year, the playlist is going to be titled: Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)\n Last year, the playlist was titled: Best Vietnamese Songs 2024 - Popular Vietnamese Songs 2024 Playlist (Top Vietnamese Music 2024-2025)\n\nShare your thoughts on our playlist: contact@red-music.com",
"cover_url": "https://yt3.googleusercontent.com/JaOpEjRt9S4wYkuVMkbu_2NLadP4vtUfQIpUlfob8mgB3CuoX8AsAJ24tAtbNRXGD2AkekLlbkiU=s1200",
"tracks": [
{
"title": "Kh\u00f3a Ly Bi\u1ec7t (feat. Anh T\u00fa)",
"artist": "The Masked Singer",
"album": "Single",
"duration": 327,
"cover_url": "https://i.ytimg.com/vi/wEPX3V5T63M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kG4sf2WSe1cO3TUeSG4cyGdPXJFg",
"id": "wEPX3V5T63M",
"url": "https://music.youtube.com/watch?v=wEPX3V5T63M"
},
{
"title": "T\u1eebng Ng\u00e0y Y\u00eau Em",
"artist": "buitruonglinh",
"album": "Single",
"duration": 222,
"cover_url": "https://i.ytimg.com/vi/f-VsoLm4i5c/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3loMnKS_mCSDRyYu9wS_TYnA0NAgQ",
"id": "f-VsoLm4i5c",
"url": "https://music.youtube.com/watch?v=f-VsoLm4i5c"
},
{
"title": "M\u1ea5t K\u1ebft N\u1ed1i",
"artist": "D\u01b0\u01a1ng Domic",
"album": "Single",
"duration": 208,
"cover_url": "https://i.ytimg.com/vi/lRsaDQtYqAo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mvidflzqRlL9xdJeDUXZJg_UESRw",
"id": "lRsaDQtYqAo",
"url": "https://music.youtube.com/watch?v=lRsaDQtYqAo"
},
{
"title": "m\u1ed9t \u0111\u1eddi (feat. buitruonglinh)",
"artist": "Bon Nghi\u00eam, 14 Casper",
"album": "Single",
"duration": 329,
"cover_url": "https://i.ytimg.com/vi/JgTZvDbaTtg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lEKS8TNud8_GWknaWc0IQEQWBTgw",
"id": "JgTZvDbaTtg",
"url": "https://music.youtube.com/watch?v=JgTZvDbaTtg"
},
{
"title": "V\u00f9ng An To\u00e0n",
"artist": "V#, B Ray",
"album": "Single",
"duration": 266,
"cover_url": "https://i.ytimg.com/vi/_XX248bq6Pw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nKfVzeukJ8dCNJ_hzcyZAsvJ8upg",
"id": "_XX248bq6Pw",
"url": "https://music.youtube.com/watch?v=_XX248bq6Pw"
},
{
"title": "Qu\u00e2n A.P | C\u00f3 Ai H\u1eb9n H\u00f2 C\u00f9ng Em Ch\u01b0a",
"artist": "Qu\u00e2n A.P",
"album": "Single",
"duration": 319,
"cover_url": "https://i.ytimg.com/vi/zHDECJy0p7k/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nWDqcf0SVJtIipbWQqltt3cNu6eQ",
"id": "zHDECJy0p7k",
"url": "https://music.youtube.com/watch?v=zHDECJy0p7k"
},
{
"title": "b\u00ecnh y\u00ean - V\u0169. (feat. Binz)",
"artist": "V\u0169., Binz",
"album": "Single",
"duration": 203,
"cover_url": "https://i.ytimg.com/vi/f9P7_qWrf38/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kI5gsa8Jegzzu2vFpJBhLk58mGeg",
"id": "f9P7_qWrf38",
"url": "https://music.youtube.com/watch?v=f9P7_qWrf38"
},
{
"title": "n\u1ebfu l\u00fac \u0111\u00f3 (feat. 2pillz)",
"artist": "Tlinh",
"album": "Single",
"duration": 325,
"cover_url": "https://i.ytimg.com/vi/fyMgBQioTLo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kNXGGAK5wy2ix4mQ1pNwlGLYUg0Q",
"id": "fyMgBQioTLo",
"url": "https://music.youtube.com/watch?v=fyMgBQioTLo"
},
{
"title": "\u0110\u1eebng L\u00e0m Tr\u00e1i Tim Anh \u0110au",
"artist": "S\u01a1n T\u00f9ng M-TP",
"album": "Single",
"duration": 326,
"cover_url": "https://i.ytimg.com/vi/abPmZCZZrFA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nMzmdGlrfqmf8o9z-E5waTnqFXxA",
"id": "abPmZCZZrFA",
"url": "https://music.youtube.com/watch?v=abPmZCZZrFA"
},
{
"title": "N\u1ed7i \u0110au Gi\u1eefa H\u00f2a B\u00ecnh",
"artist": "H\u00f2a Minzy, Nguyen Van Chung",
"album": "Single",
"duration": 454,
"cover_url": "https://i.ytimg.com/vi/yHikkFeIHNA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mhpsSG0tDGSBKkAK1X81aY1nrfgg",
"id": "yHikkFeIHNA",
"url": "https://music.youtube.com/watch?v=yHikkFeIHNA"
},
{
"title": "10 Shots",
"artist": "Dax",
"album": "Single",
"duration": 233,
"cover_url": "https://i.ytimg.com/vi/0XMFwdfMQmQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3napt1cPSL4BTo7SSeDyrRUU7XF0Q",
"id": "0XMFwdfMQmQ",
"url": "https://music.youtube.com/watch?v=0XMFwdfMQmQ"
},
{
"title": "Ch\u0103m Hoa",
"artist": "MONO",
"album": "Single",
"duration": 260,
"cover_url": "https://i.ytimg.com/vi/WCm2elbTEZQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kW5xCqL0V0Q9miffXVKmSRnn3S8A",
"id": "WCm2elbTEZQ",
"url": "https://music.youtube.com/watch?v=WCm2elbTEZQ"
},
{
"title": "id 072019",
"artist": "W/n",
"album": "Single",
"duration": 303,
"cover_url": "https://i.ytimg.com/vi/leJb3VhQCrg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nWBTudc9VK3UqnpCgc_j8QYH3ugg",
"id": "leJb3VhQCrg",
"url": "https://music.youtube.com/watch?v=leJb3VhQCrg"
},
{
"title": "Gi\u1edd Th\u00ec",
"artist": "buitruonglinh",
"album": "Single",
"duration": 238,
"cover_url": "https://i.ytimg.com/vi/69ZDBWoj5YM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3krSRZmxC0XjjdQN0wwPoZbJ-unGQ",
"id": "69ZDBWoj5YM",
"url": "https://music.youtube.com/watch?v=69ZDBWoj5YM"
},
{
"title": "ERIK - 'D\u00f9 cho t\u1eadn th\u1ebf (v\u1eabn y\u00eau em)' | Official MV | Valentine 2025",
"artist": "ERIK",
"album": "Single",
"duration": 301,
"cover_url": "https://i.ytimg.com/vi/js6JBdLzNn4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nh_ITbZeDs1TJfrWuPEim8MKpj9g",
"id": "js6JBdLzNn4",
"url": "https://music.youtube.com/watch?v=js6JBdLzNn4"
},
{
"title": "Ng\u00e0y Mai Ng\u01b0\u1eddi Ta L\u1ea5y Ch\u1ed3ng",
"artist": "Th\u00e0nh \u0110\u1ea1t",
"album": "Single",
"duration": 421,
"cover_url": "https://i.ytimg.com/vi/WL11bwvAYWI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3l10haMFB1HcY7p5muA1kJL5tz1cA",
"id": "WL11bwvAYWI",
"url": "https://music.youtube.com/watch?v=WL11bwvAYWI"
},
{
"title": "B\u1ea7u Tr\u1eddi M\u1edbi (feat. Minh Toc)",
"artist": "Da LAB",
"album": "Single",
"duration": 291,
"cover_url": "https://i.ytimg.com/vi/Z1D26z9l8y8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3k34PODWHnu_p49YHu35__8V-4avw",
"id": "Z1D26z9l8y8",
"url": "https://music.youtube.com/watch?v=Z1D26z9l8y8"
},
{
"title": "C\u00e1nh Hoa H\u00e9o T\u00e0n (DJ Trang Moon Remix)",
"artist": "ACV, Mochiii",
"album": "Single",
"duration": 265,
"cover_url": "https://i.ytimg.com/vi/YizrmzMvr7Q/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3luIG3PhCNjJlZjuCRBwAKKrMPt9Q",
"id": "YizrmzMvr7Q",
"url": "https://music.youtube.com/watch?v=YizrmzMvr7Q"
},
{
"title": "SOOBIN - gi\u00e1 nh\u01b0 | 'B\u1eacT N\u00d3 L\u00caN' Album (Music Video)",
"artist": "SOOBIN",
"album": "Single",
"duration": 310,
"cover_url": "https://i.ytimg.com/vi/SeWt7IpZ0CA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lYIMR-uDbHo5-B3GO0z5XPqKIcaQ",
"id": "SeWt7IpZ0CA",
"url": "https://music.youtube.com/watch?v=SeWt7IpZ0CA"
},
{
"title": "Vuon Hoa Con Ca",
"artist": "Olew",
"album": "Single",
"duration": 234,
"cover_url": "https://i.ytimg.com/vi/BFflHDlTeHw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nA_WhH_ZnanoXMGeQ-4d4hYSUbUg",
"id": "BFflHDlTeHw",
"url": "https://music.youtube.com/watch?v=BFflHDlTeHw"
},
{
"title": "G\u1eb7p L\u1ea1i N\u0103m Ta 60",
"artist": "Orange",
"album": "Single",
"duration": 337,
"cover_url": "https://i.ytimg.com/vi/ZXNrz72k1ew/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kqqJWC4ogBjc4u12JzCHk2YMBKWA",
"id": "ZXNrz72k1ew",
"url": "https://music.youtube.com/watch?v=ZXNrz72k1ew"
},
{
"title": "You're The Problem",
"artist": "Dax",
"album": "Single",
"duration": 230,
"cover_url": "https://i.ytimg.com/vi/-kA2ReyByZU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kg2w-R3-05ocVT0g03RyIQJ41F4Q",
"id": "-kA2ReyByZU",
"url": "https://music.youtube.com/watch?v=-kA2ReyByZU"
},
{
"title": "SOOBIN - Dancing In The Dark | 'B\u1eacT N\u00d3 L\u00caN' Album",
"artist": "SOOBIN",
"album": "Single",
"duration": 279,
"cover_url": "https://i.ytimg.com/vi/OZmK0YuSmXU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mBF8aJUqrrJIQduCkE_BAGkeucDA",
"id": "OZmK0YuSmXU",
"url": "https://music.youtube.com/watch?v=OZmK0YuSmXU"
},
{
"title": "Lao T\u00e2m Kh\u1ed5 T\u1ee9",
"artist": "Thanh H\u01b0ng",
"album": "Single",
"duration": 348,
"cover_url": "https://i.ytimg.com/vi/TfKOFRpqSME/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n85vMTLZIA2MAj83vqnYk4pomt0Q",
"id": "TfKOFRpqSME",
"url": "https://music.youtube.com/watch?v=TfKOFRpqSME"
},
{
"title": "N\u1ea5u \u0102n Cho Em (feat. PIALINH)",
"artist": "\u0110en",
"album": "Single",
"duration": 257,
"cover_url": "https://i.ytimg.com/vi/ukHK1GVyr0I/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nD2JOikDSq_cCeBaG-VH6LBYriJg",
"id": "ukHK1GVyr0I",
"url": "https://music.youtube.com/watch?v=ukHK1GVyr0I"
},
{
"title": "T\u1ebft B\u00ecnh An Remix, Hana C\u1ea9m Ti\u00ean, \u0110\u1ea1i M\u00e8o | M\u1ed9t N\u0103m C\u0169 \u0110\u00e3 Qua C\u00f9ng Nhau \u0110\u00f3n N\u0103m M\u1edbi B\u00ecnh An Mu\u00f4n Nh\u00e0",
"artist": "BD Media Music",
"album": "Single",
"duration": 227,
"cover_url": "https://i.ytimg.com/vi/fMskPmI4tp0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3m8nsW6nX2B8BJf4gsU36uDsmDCgw",
"id": "fMskPmI4tp0",
"url": "https://music.youtube.com/watch?v=fMskPmI4tp0"
},
{
"title": "T\u1eebng L\u00e0",
"artist": "V\u0169 C\u00e1t T\u01b0\u1eddng",
"album": "Single",
"duration": 277,
"cover_url": "https://i.ytimg.com/vi/i4qZmKSFYvI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kLaE-0VAlEfGQRlKBACGiK0w0WDw",
"id": "i4qZmKSFYvI",
"url": "https://music.youtube.com/watch?v=i4qZmKSFYvI"
},
{
"title": "N\u01a1i Ph\u00e1o Hoa R\u1ef1c R\u1ee1 (feat. C\u1ea9m V\u00e2n)",
"artist": "Hua Kim Tuyen, Orange, Ho\u00e0ng D\u0169ng",
"album": "Single",
"duration": 310,
"cover_url": "https://i.ytimg.com/vi/BgUFNi5MvzE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mGOmc3dRUaQczZnhubm_nb8Gs_Uw",
"id": "BgUFNi5MvzE",
"url": "https://music.youtube.com/watch?v=BgUFNi5MvzE"
},
{
"title": "Ng\u01b0\u1eddi B\u00ecnh Th\u01b0\u1eddng",
"artist": "V\u0169 C\u00e1t T\u01b0\u1eddng",
"album": "Single",
"duration": 285,
"cover_url": "https://i.ytimg.com/vi/X5KvHXWPYm4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lPWTFBiFDjAliZkS614MkwVcte1g",
"id": "X5KvHXWPYm4",
"url": "https://music.youtube.com/watch?v=X5KvHXWPYm4"
},
{
"title": "C\u00f3 Em L\u00e0 \u0110i\u1ec1u Tuy\u1ec7t V\u1eddi Nh\u1ea5t (Th\u1ecbnh H\u00e0nh)",
"artist": "Thi\u00ean T\u00fa",
"album": "Single",
"duration": 371,
"cover_url": "https://i.ytimg.com/vi/IenfKDtyMI0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nbyyByYoJQ2qV7-2w4S6Gyofj9dQ",
"id": "IenfKDtyMI0",
"url": "https://music.youtube.com/watch?v=IenfKDtyMI0"
},
{
"title": "Nh\u1eefng L\u1eddi H\u1ee9a B\u1ecf Qu\u00ean",
"artist": "V\u0169., Dear Jane",
"album": "Single",
"duration": 259,
"cover_url": "https://i.ytimg.com/vi/h6RONxjPBf4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfvRCueWOo-OjD8_3sK9HSlhvoSw",
"id": "h6RONxjPBf4",
"url": "https://music.youtube.com/watch?v=h6RONxjPBf4"
},
{
"title": "m\u1ed9t b\u00e0i h\u00e1t kh\u00f4ng vui m\u1ea5y (Extended Version)",
"artist": "T.R.I, Dangrangto, DONAL",
"album": "Single",
"duration": 230,
"cover_url": "https://i.ytimg.com/vi/EvPEeSBfB3E/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kvhX3tBQICPMgOEn0R9uswYvdC5A",
"id": "EvPEeSBfB3E",
"url": "https://music.youtube.com/watch?v=EvPEeSBfB3E"
},
{
"title": "One Time",
"artist": "Raw Dawg",
"album": "Single",
"duration": 119,
"cover_url": "https://i.ytimg.com/vi/ylh1oDhP2AE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lRAtyI5EucwyXxXGb9YLfFY2W6aQ",
"id": "ylh1oDhP2AE",
"url": "https://music.youtube.com/watch?v=ylh1oDhP2AE"
},
{
"title": "MIN - ch\u1eb3ng ph\u1ea3i t\u00ecnh \u0111\u1ea7u sao \u0111au \u0111\u1ebfn th\u1ebf feat. Dangrangto, Antransax (Official Audio)",
"artist": "MIN",
"album": "Single",
"duration": 284,
"cover_url": "https://i.ytimg.com/vi/rLNvDu59ffI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfhSKyeyGqrokp13H6G7C1rNvKLg",
"id": "rLNvDu59ffI",
"url": "https://music.youtube.com/watch?v=rLNvDu59ffI"
},
{
"title": "Ng\u01b0\u1eddi \u0110\u1ea7u Ti\u00ean",
"artist": "Juky San, buitruonglinh",
"album": "Single",
"duration": 220,
"cover_url": "https://i.ytimg.com/vi/i54avTdUqwU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3klVzmY8CCpa5CLEP3BIema5Lhgyw",
"id": "i54avTdUqwU",
"url": "https://music.youtube.com/watch?v=i54avTdUqwU"
},
{
"title": "MIN - ch\u1eb3ng ph\u1ea3i t\u00ecnh \u0111\u1ea7u sao \u0111au \u0111\u1ebfn th\u1ebf feat. Dangrangto, antransax (Official Visual Stage)",
"artist": "MIN OFFICIAL",
"album": "Single",
"duration": 288,
"cover_url": "https://i.ytimg.com/vi/s0OMNH-N5D8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3k_uFbHN8ud3zNWnb5hdzcYLhUgWA",
"id": "s0OMNH-N5D8",
"url": "https://music.youtube.com/watch?v=s0OMNH-N5D8"
},
{
"title": "Em",
"artist": "Binz",
"album": "Single",
"duration": 208,
"cover_url": "https://i.ytimg.com/vi/CU2PtRKBkuw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mnGQ84aQvDmEMNCd5m6b-_pyKbUg",
"id": "CU2PtRKBkuw",
"url": "https://music.youtube.com/watch?v=CU2PtRKBkuw"
},
{
"title": "HO\u1ea0 S\u0128 T\u1ed2I - TH\u00c1I H\u1eccC x \u0110\u1ea0T MAX | Official MV | Anh v\u1ebd c\u1ea7u v\u1ed3ng th\u00ec l\u1ea1i thi\u1ebfu n\u1eafng",
"artist": "Th\u00e1i H\u1ecdc",
"album": "Single",
"duration": 285,
"cover_url": "https://i.ytimg.com/vi/RF0jYdTXQK4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nAfOOBrWfNICLXduP5GPktAPARCg",
"id": "RF0jYdTXQK4",
"url": "https://music.youtube.com/watch?v=RF0jYdTXQK4"
},
{
"title": "T\u00ecnh Nh\u01b0 L\u00e1 Bay Xa (Live)",
"artist": "Jimmy Nguyen, M\u1ef8 T\u00c2M",
"album": "Single",
"duration": 273,
"cover_url": "https://i.ytimg.com/vi/gxPoI_tldfQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nQp0dsN1t1shvvjBq0A2m-EyfvBg",
"id": "gxPoI_tldfQ",
"url": "https://music.youtube.com/watch?v=gxPoI_tldfQ"
},
{
"title": "Kh\u1ed5ng Minh x Ch\u00e2u Nhu\u1eadn Ph\u00e1t - ''E L\u00c0 \u0110\u00d4N CH\u1ec0'' Prod.@tiengaz",
"artist": "Dagoats House",
"album": "Single",
"duration": 191,
"cover_url": "https://i.ytimg.com/vi/K01LvulhFRg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n8fN2PiGDRGtGv0VZMp_OOW2kBoQ",
"id": "K01LvulhFRg",
"url": "https://music.youtube.com/watch?v=K01LvulhFRg"
},
{
"title": "M\u1ee5c H\u1ea1 V\u00f4 Nh\u00e2n (feat. Binz)",
"artist": "SOOBIN, Hu\u1ef3nh T\u00fa",
"album": "Single",
"duration": 355,
"cover_url": "https://i.ytimg.com/vi/FikdKWos-NQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lgfIwIcM4zdZGPxZN-kcs96iJyGQ",
"id": "FikdKWos-NQ",
"url": "https://music.youtube.com/watch?v=FikdKWos-NQ"
},
{
"title": "10 M\u1ea4T 1 C\u00d2N KH\u00d4NG - TH\u00c1I H\u1eccC x L\u00ca GIA B\u1ea2O (New Version) | St: Long H\u1ecd Hu\u1ef3nh",
"artist": "Th\u00e1i H\u1ecdc",
"album": "Single",
"duration": 285,
"cover_url": "https://i.ytimg.com/vi/9HnyyKg0M-Y/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lyCTROUhGaahuDenut3oMfnOesDQ",
"id": "9HnyyKg0M-Y",
"url": "https://music.youtube.com/watch?v=9HnyyKg0M-Y"
},
{
"title": "Mr Siro | Day D\u1ee9t N\u1ed7i \u0110au",
"artist": "Mr. Siro",
"album": "Single",
"duration": 368,
"cover_url": "https://i.ytimg.com/vi/N4Xak1n497M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nZ6HzRlVHFya6aliEsGSZuGB_QxA",
"id": "N4Xak1n497M",
"url": "https://music.youtube.com/watch?v=N4Xak1n497M"
},
{
"title": "Diary Of A Trying Man",
"artist": "Dax",
"album": "Single",
"duration": 234,
"cover_url": "https://i.ytimg.com/vi/WulTil-Wwoo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lC8LD69LfTh3hrClQoWJGA3pCjCw",
"id": "WulTil-Wwoo",
"url": "https://music.youtube.com/watch?v=WulTil-Wwoo"
},
{
"title": "Feel At Home",
"artist": "B Ray",
"album": "Single",
"duration": 164,
"cover_url": "https://i.ytimg.com/vi/6x1yluqMuc4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfaiwiYPKbD_v3rvKR1QC1Sw9Znw",
"id": "6x1yluqMuc4",
"url": "https://music.youtube.com/watch?v=6x1yluqMuc4"
},
{
"title": "L\u00e1 Th\u01b0 \u0110\u00f4 Th\u1ecb",
"artist": "Th\u00fay H\u00e0",
"album": "Single",
"duration": 355,
"cover_url": "https://i.ytimg.com/vi/42m7T272u8I/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3moQljTHbKdPZ3c48rcbJiq4KILjQ",
"id": "42m7T272u8I",
"url": "https://music.youtube.com/watch?v=42m7T272u8I"
},
{
"title": "R\u1eddi B\u1ecf N\u01a1i Anh Bi\u1ebft Em C\u00f3 Vui B\u00ean Ng\u01b0\u1eddi Remix | TH\u01af\u01a0NG TH\u00cc TH\u00d4I REMIX B\u1ea3n Si\u00eau Th\u1ea5m BeBe...",
"artist": "ACV",
"album": "Single",
"duration": 195,
"cover_url": "https://i.ytimg.com/vi/Hq_Q9vSIg2M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n3JlqdmwyqK_me1eqnMQVrNeL6ZA",
"id": "Hq_Q9vSIg2M",
"url": "https://music.youtube.com/watch?v=Hq_Q9vSIg2M"
},
{
"title": "Gi\u1eef Anh Cho Ng\u00e0y H\u00f4m Qua",
"artist": "Ho\u00e0ng D\u0169ng, Rhymastic, Lelarec",
"album": "Single",
"duration": 345,
"cover_url": "https://i.ytimg.com/vi/IADhKnmQMtk/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nzDVE9hb0vpWAYZ39Ghi-6BrG-9g",
"id": "IADhKnmQMtk",
"url": "https://music.youtube.com/watch?v=IADhKnmQMtk"
},
{
"title": "Mr Siro | T\u1ef1 Lau N\u01b0\u1edbc M\u1eaft",
"artist": "Mr. Siro",
"album": "Single",
"duration": 330,
"cover_url": "https://i.ytimg.com/vi/tgvXGxCrBmE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mNQ5AIuHnGA4XZQwvFh_WRi1DmAg",
"id": "tgvXGxCrBmE",
"url": "https://music.youtube.com/watch?v=tgvXGxCrBmE"
},
{
"title": "She Never Cries (feat. S\u01a0N.K)",
"artist": "Ho\u00e0ng Duy\u00ean",
"album": "Single",
"duration": 264,
"cover_url": "https://i.ytimg.com/vi/zuBjkHOFVJs/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kdUzhae-xLYnaf7b45tXbDDxr71A",
"id": "zuBjkHOFVJs",
"url": "https://music.youtube.com/watch?v=zuBjkHOFVJs"
},
{
"title": "Ch\u1edd Bao L\u00e2u (feat. H\u00e0o JK)",
"artist": "\u00dat Nh\u1ecb Mino",
"album": "Single",
"duration": 193,
"cover_url": "https://i.ytimg.com/vi/KO0CbNNvd14/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mL5syc6JwJoWuHasdnfhrSAFITHA",
"id": "KO0CbNNvd14",
"url": "https://music.youtube.com/watch?v=KO0CbNNvd14"
},
{
"title": "C\u00d4 G\u00c1I \u00c0 \u0110\u1eeaNG KH\u00d3C | \u00daT NH\u1eca MINO FT NVC MUSIC",
"artist": "\u00dat Nh\u1ecb Mino",
"album": "Single",
"duration": 266,
"cover_url": "https://i.ytimg.com/vi/oH9_c7Y5zMQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kBh2R8cqyDQN98Jd9CIO1RZBbVNQ",
"id": "oH9_c7Y5zMQ",
"url": "https://music.youtube.com/watch?v=oH9_c7Y5zMQ"
},
{
"title": "R\u1ea5t L\u00e2u R\u1ed3i M\u1edbi Kh\u00f3c (Solo Version)",
"artist": "",
"album": "Single",
"duration": 311,
"cover_url": "https://i.ytimg.com/vi/MWowv3A-fQc/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kWiKMUSyg-xYgdrzO26ICDnO6Gpg",
"id": "MWowv3A-fQc",
"url": "https://music.youtube.com/watch?v=MWowv3A-fQc"
},
{
"title": "Ring Ring",
"artist": "MIRA",
"album": "Single",
"duration": 147,
"cover_url": "https://i.ytimg.com/vi/mkCaf6tuhGM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lyjmmeuudBzy9Yu64rGLaWENa4tg",
"id": "mkCaf6tuhGM",
"url": "https://music.youtube.com/watch?v=mkCaf6tuhGM"
},
{
"title": "B\u1ea3o Anh | C\u00f4 \u1ea4y C\u1ee7a Anh \u1ea4y",
"artist": "B\u1ea3o Anh ",
"album": "Single",
"duration": 324,
"cover_url": "https://i.ytimg.com/vi/RlTDbIutJsU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kTrRLvQFATZub9py_upYtU7SUaJQ",
"id": "RlTDbIutJsU",
"url": "https://music.youtube.com/watch?v=RlTDbIutJsU"
},
{
"title": "\u0110\u1ecaA \u0110\u00c0NG REMIX - HO\u00c0NG OANH x ACV | N\u00f3i Anh Nghe \u0110\u1ecba \u0110\u00e0ng M\u1edf C\u1eeda L\u00e0 \u0110\u1ec3 Ch\u1edd B\u01b0\u1edbc Ch\u00e2n Em Ph\u1ea3i Kh\u00f4ng ?",
"artist": "ACV",
"album": "Single",
"duration": 311,
"cover_url": "https://i.ytimg.com/vi/vZzzcflS2HM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lMoxUQD5_wrptAPCqUYBxD0MKndw",
"id": "vZzzcflS2HM",
"url": "https://music.youtube.com/watch?v=vZzzcflS2HM"
},
{
"title": "T\u1eebng quen",
"artist": "itsnk, Wren Evans",
"album": "Single",
"duration": 175,
"cover_url": "https://i.ytimg.com/vi/zepHPnUDROE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kQphjp4tiW4vFcaXJBk1wMtsk9Kg",
"id": "zepHPnUDROE",
"url": "https://music.youtube.com/watch?v=zepHPnUDROE"
},
{
"title": "HOA B\u1ea4T T\u1eec",
"artist": "Th\u00e0nh \u0110\u1ea1t",
"album": "Single",
"duration": 317,
"cover_url": "https://i.ytimg.com/vi/n-k_aUsOaaQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lfd3LBuB7aTSG880J0HqdjEqNQww",
"id": "n-k_aUsOaaQ",
"url": "https://music.youtube.com/watch?v=n-k_aUsOaaQ"
},
{
"title": "N\u00f3i D\u1ed1i | Ronboogz (Lyrics Video)",
"artist": "Ronboogz",
"album": "Single",
"duration": 207,
"cover_url": "https://i.ytimg.com/vi/ri-TFS97Hbw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lgdDfcXHekuevzN7qPIZR7RryanQ",
"id": "ri-TFS97Hbw",
"url": "https://music.youtube.com/watch?v=ri-TFS97Hbw"
},
{
"title": "MONO - \u2018Em Xinh\u2019 (Official Music Video)",
"artist": "MONO",
"album": "Single",
"duration": 197,
"cover_url": "https://i.ytimg.com/vi/rYc1UbgbMIY/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mkHo5h-7KAI9SGhk2jG6m6cHospQ",
"id": "rYc1UbgbMIY",
"url": "https://music.youtube.com/watch?v=rYc1UbgbMIY"
}
]
}

View file

@ -0,0 +1,20 @@
{
"name": "Spotify Clone",
"short_name": "Spotify",
"start_url": "/",
"display": "standalone",
"background_color": "#121212",
"theme_color": "#1DB954",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
frontend/public/next.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

172
frontend/services/db.ts Normal file
View file

@ -0,0 +1,172 @@
import { openDB, DBSchema } from 'idb';
export interface Track {
id: string;
title: string;
artist: string;
album: string;
cover_url: string;
url?: string;
duration?: number;
}
export interface Playlist {
id: string;
title: string;
tracks: Track[];
createdAt: number;
cover_url?: string;
}
interface MyDB extends DBSchema {
playlists: {
key: string;
value: Playlist;
};
likedSongs: {
key: string; // trackId
value: Track;
};
}
const DB_NAME = 'audiophile-db';
const DB_VERSION = 2;
export const initDB = async () => {
return openDB<MyDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion, newVersion, transaction) {
// Re-create stores to clear old data
if (db.objectStoreNames.contains('playlists')) {
db.deleteObjectStore('playlists');
}
if (db.objectStoreNames.contains('likedSongs')) {
db.deleteObjectStore('likedSongs');
}
db.createObjectStore('playlists', { keyPath: 'id' });
db.createObjectStore('likedSongs', { keyPath: 'id' });
},
});
};
export const dbService = {
async getPlaylists() {
const db = await initDB();
const playlists = await db.getAll('playlists');
if (playlists.length === 0) {
return this.seedInitialData();
}
return playlists;
},
async seedInitialData() {
try {
// Fetch real data from backend to seed valid playlists
// We use the 'api' prefix assuming this runs in browser
const res = await fetch('/api/trending');
if (!res.ok) return [];
const data = await res.json();
const allTracks: Track[] = data.tracks || [];
if (allTracks.length === 0) return [];
const db = await initDB();
const newPlaylists: Playlist[] = [];
// 1. Starter Playlist
const favTracks = allTracks.slice(0, 8);
if (favTracks.length > 0) {
const p1: Playlist = {
id: crypto.randomUUID(),
title: "My Rotations",
tracks: favTracks,
createdAt: Date.now(),
cover_url: favTracks[0].cover_url
};
await db.put('playlists', p1);
newPlaylists.push(p1);
}
// 2. Vibes
const vibeTracks = allTracks.slice(8, 15);
if (vibeTracks.length > 0) {
const p2: Playlist = {
id: crypto.randomUUID(),
title: "Weekend Vibes",
tracks: vibeTracks,
createdAt: Date.now(),
cover_url: vibeTracks[0].cover_url
};
await db.put('playlists', p2);
newPlaylists.push(p2);
}
return newPlaylists;
} catch (e) {
console.error("Seeding failed", e);
return [];
}
},
async getPlaylist(id: string) {
const db = await initDB();
return db.get('playlists', id);
},
async createPlaylist(name: string) {
const db = await initDB();
const newPlaylist: Playlist = {
id: crypto.randomUUID(),
title: name,
tracks: [],
createdAt: Date.now(),
cover_url: "https://placehold.co/300/222/fff?text=" + encodeURIComponent(name)
};
await db.put('playlists', newPlaylist);
return newPlaylist;
},
async deletePlaylist(id: string) {
const db = await initDB();
await db.delete('playlists', id);
},
async addToPlaylist(playlistId: string, track: Track) {
const db = await initDB();
const playlist = await db.get('playlists', playlistId);
if (playlist) {
// Auto-update cover if it's the default or empty
if (playlist.tracks.length === 0 || playlist.cover_url?.includes("placehold")) {
playlist.cover_url = track.cover_url;
}
playlist.tracks.push(track);
await db.put('playlists', playlist);
}
},
async removeFromPlaylist(playlistId: string, trackId: string) {
const db = await initDB();
const playlist = await db.get('playlists', playlistId);
if (playlist) {
playlist.tracks = playlist.tracks.filter(t => t.id !== trackId);
await db.put('playlists', playlist);
}
},
async getLikedSongs() {
const db = await initDB();
return db.getAll('likedSongs');
},
async toggleLike(track: Track) {
const db = await initDB();
const existing = await db.get('likedSongs', track.id);
if (existing) {
await db.delete('likedSongs', track.id);
return false; // unliked
} else {
await db.put('likedSongs', track);
return true; // liked
}
},
async isLiked(trackId: string) {
const db = await initDB();
const existing = await db.get('likedSongs', trackId);
return !!existing;
}
};

View file

@ -0,0 +1,76 @@
import { Track } from "./db";
export interface StaticPlaylist {
id: string;
title: string;
description: string;
cover_url: string;
tracks: Track[];
type: 'Album' | 'Artist' | 'Playlist';
creator?: string;
}
// Helper to fetch from backend
const apiFetch = async (endpoint: string) => {
const res = await fetch(`/api${endpoint}`);
if (!res.ok) throw new Error(`API Error: ${res.statusText}`);
return res.json();
};
export const libraryService = {
async getLibrary(): Promise<StaticPlaylist> {
// Fetch "Liked Songs" or main library from backend
// Assuming backend has an endpoint or we treat "Trending" as default
return await apiFetch('/browse'); // Simplified fallback
},
async _generateMockContent(): Promise<void> {
// No-op in API mode
},
async getBrowseContent(): Promise<Record<string, StaticPlaylist[]>> {
return await apiFetch('/browse');
},
async getPlaylist(id: string): Promise<StaticPlaylist | null> {
try {
return await apiFetch(`/playlists/${id}`);
} catch (e) {
console.error("Failed to fetch playlist", id, e);
return null;
}
},
async getRecommendations(seedTrackId?: string): Promise<Track[]> {
// Use trending as recommendations for now
const data = await apiFetch('/trending');
return data.tracks || [];
},
async getRecommendedAlbums(seedArtist?: string): Promise<StaticPlaylist[]> {
const data = await apiFetch('/browse');
// Flatten all albums from categories
const albums: StaticPlaylist[] = [];
Object.values(data).forEach((list: any) => {
if (Array.isArray(list)) albums.push(...list);
});
return albums.slice(0, 8);
},
async search(query: string): Promise<Track[]> {
try {
return await apiFetch(`/search?q=${encodeURIComponent(query)}`);
} catch (e) {
return [];
}
},
// UTILITIES FOR DYNAMIC UPDATES
updateTrackCover(trackId: string, newUrl: string) {
console.log("Dynamic updates not implemented in Backend Mode");
},
updateAlbumCover(albumId: string, newUrl: string) {
console.log("Dynamic updates not implemented in Backend Mode");
}
};

View file

@ -0,0 +1,61 @@
import { Track } from "./db";
export interface StaticPlaylist {
id: string;
title: string;
description: string;
cover_url: string;
tracks: Track[];
}
export const libraryService = {
async getLibrary(): Promise<StaticPlaylist> {
const res = await fetch('/library.json');
if (!res.ok) throw new Error("Failed to load library");
const data = await res.json();
// MOCK: Replace URLs with a working sample for testing Audiophile features
// In a real local-first app, these would be relative paths to local files or S3 presigned URLs.
data.tracks = data.tracks.map((t: any) => ({
...t,
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
}));
return data;
},
async getBrowseContent(): Promise<Record<string, StaticPlaylist[]>> {
const data = await this.getLibrary();
// Mock categories using the single playlist we have
return {
"Top Lists": [data],
"Just For You": [data],
"Trending": [data]
};
},
async getPlaylist(id: string): Promise<StaticPlaylist | null> {
const data = await this.getLibrary();
if (data.id === id) return data;
return null;
},
async getRecommendations(seedTrackId?: string): Promise<Track[]> {
const data = await this.getLibrary();
// Return random 10 tracks
return [...data.tracks].sort(() => 0.5 - Math.random()).slice(0, 10);
},
async getRecommendedAlbums(seedArtist?: string): Promise<StaticPlaylist[]> {
const data = await this.getLibrary();
// Return the main playlist as a recommended album for now
return [data];
},
async search(query: string): Promise<Track[]> {
const data = await this.getLibrary();
const lowerQuery = query.toLowerCase();
return data.tracks.filter(t =>
t.title.toLowerCase().includes(lowerQuery) ||
t.artist.toLowerCase().includes(lowerQuery)
);
}
};

View file

@ -0,0 +1,32 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
spotify: {
base: "#1DB954",
highlight: "#1ed760",
black: "#191414",
dark: "#121212",
text: {
base: "#FFFFFF",
muted: "#b3b3b3"
}
}
},
fontFamily: {
sans: ['var(--font-outfit)', 'sans-serif'],
},
},
},
plugins: [],
};
export default config;

42
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": [
"node_modules"
]
}

21
test_audio.py Normal file
View file

@ -0,0 +1,21 @@
import yt_dlp
import json
# Test video ID from our data (e.g., Khóa Ly Biệt)
video_id = "s0OMNH-N5D8"
url = f"https://www.youtube.com/watch?v={video_id}"
ydl_opts = {
'format': 'bestaudio/best',
'quiet': True,
'noplaylist': True,
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
print(f"Title: {info.get('title')}")
print(f"URL: {info.get('url')}") # The direct stream URL
print("Success: Extracted audio URL")
except Exception as e:
print(f"Error: {e}")