diff --git a/.dockerignore b/.dockerignore index cf8c547..8f19325 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,29 +1,29 @@ -# Git -.git -.gitignore - -# Node -node_modules -npm-debug.log - -# Python -venv -__pycache__ -*.pyc -*.pyo -*.pyd - -# Next.js -.next -out - -# Docker -Dockerfile -.dockerignore - -# OS -.DS_Store -Thumbs.db - -# Misc -*.log +# Git +.git +.gitignore + +# Node +node_modules +npm-debug.log + +# Python +venv +__pycache__ +*.pyc +*.pyo +*.pyd + +# Next.js +.next +out + +# Docker +Dockerfile +.dockerignore + +# OS +.DS_Store +Thumbs.db + +# Misc +*.log diff --git a/Dockerfile b/Dockerfile index 4404c06..c7051b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,65 +1,65 @@ -# --- Stage 1: Frontend Builder --- -FROM node:18-slim AS builder -WORKDIR /app/frontend -COPY frontend/package*.json ./ -# Install dependencies including sharp for build -RUN npm install --legacy-peer-deps -COPY frontend/ ./ -# Build with standalone output -ENV NEXT_PUBLIC_API_URL="" -RUN npm run build - -# --- Stage 2: Final Runtime Image --- -FROM python:3.11-slim - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - curl \ - gnupg \ - ffmpeg \ - ca-certificates \ - && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ - && apt-get install -y nodejs \ - && update-ca-certificates \ - && 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 from Builder) -# Copy the standalone server -COPY --from=builder /app/frontend/.next/standalone /app/frontend -# Explicitly install sharp in the standalone folder to ensure compatibility -RUN cd /app/frontend && npm install sharp - -# Copy static files (required for standalone) -COPY --from=builder /app/frontend/.next/static /app/frontend/.next/static -COPY --from=builder /app/frontend/public /app/frontend/public - -# Copy Backend Code -COPY backend/ ./backend/ - -# Create start script -RUN mkdir -p backend/data_seed && cp -r backend/data/* backend/data_seed/ || true - -# Set Environment Variables -ENV NODE_ENV=production -ENV PORT=3000 -ENV HOSTNAME="0.0.0.0" - -# Note: Standalone mode runs with 'node server.js' -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 && node server.js\n\ - ' > start.sh && chmod +x start.sh - -EXPOSE 3000 8000 - -CMD ["./start.sh"] +# --- Stage 1: Frontend Builder --- +FROM node:18-slim AS builder +WORKDIR /app/frontend +COPY frontend/package*.json ./ +# Install dependencies including sharp for build +RUN npm install --legacy-peer-deps +COPY frontend/ ./ +# Build with standalone output +ENV NEXT_PUBLIC_API_URL="" +RUN npm run build + +# --- Stage 2: Final Runtime Image --- +FROM python:3.11-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + gnupg \ + ffmpeg \ + ca-certificates \ + && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ + && apt-get install -y nodejs \ + && update-ca-certificates \ + && 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 from Builder) +# Copy the standalone server +COPY --from=builder /app/frontend/.next/standalone /app/frontend +# Explicitly install sharp in the standalone folder to ensure compatibility +RUN cd /app/frontend && npm install sharp + +# Copy static files (required for standalone) +COPY --from=builder /app/frontend/.next/static /app/frontend/.next/static +COPY --from=builder /app/frontend/public /app/frontend/public + +# Copy Backend Code +COPY backend/ ./backend/ + +# Create start script +RUN mkdir -p backend/data_seed && cp -r backend/data/* backend/data_seed/ || true + +# Set Environment Variables +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +# Note: Standalone mode runs with 'node server.js' +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 && node server.js\n\ + ' > start.sh && chmod +x start.sh + +EXPOSE 3000 8000 + +CMD ["./start.sh"] diff --git a/backend/api/routes.py b/backend/api/routes.py index cd3074f..0c41d49 100644 --- a/backend/api/routes.py +++ b/backend/api/routes.py @@ -1,922 +1,922 @@ -from fastapi import APIRouter, HTTPException, BackgroundTasks, Response -from fastapi.responses import StreamingResponse, JSONResponse -from pydantic import BaseModel -import json -from pathlib import Path -import yt_dlp -import requests -from backend.services.spotify import SpotifyService -from backend.services.cache import CacheManager -from backend.playlist_manager import PlaylistManager -from backend.scheduler import update_ytdlp # Import update function - -import re - -router = APIRouter() -# Services (Assumed to be initialized elsewhere if not here, adhering to existing patterns) -# spotify = SpotifyService() # Commented out as duplicates if already imported -if 'CacheManager' in globals(): - cache = CacheManager() -else: - from backend.cache_manager import CacheManager - cache = CacheManager() - -playlist_manager = PlaylistManager() - -@router.post("/system/update-ytdlp") -async def manual_ytdlp_update(background_tasks: BackgroundTasks): - """ - Trigger a manual update of yt-dlp in the background. - """ - background_tasks.add_task(update_ytdlp) - return {"status": "success", "message": "yt-dlp update started in background"} - -def get_high_res_thumbnail(thumbnails: list) -> str: - """ - Selects the best thumbnail and attempts to upgrade resolution - if it's a Google/YouTube URL. - """ - if not thumbnails: - return "https://placehold.co/300x300" - - # 1. Start with the largest available in the list - best_url = thumbnails[-1]['url'] - - # 2. Upgrade resolution for Google User Content (lh3.googleusercontent.com, yt3.ggpht.com) - # Common patterns: - # =w120-h120-l90-rj (Small) - # =w544-h544-l90-rj (High Res) - # s120-c-k-c0x00ffffff-no-rj (Profile/Avatar) - - if "googleusercontent.com" in best_url or "ggpht.com" in best_url: - import re - # Replace width/height params with 544 (standard YTM high res) - # We look for patterns like =w-h... - if "w" in best_url and "h" in best_url: - best_url = re.sub(r'=w\d+-h\d+', '=w544-h544', best_url) - elif best_url.startswith("https://lh3.googleusercontent.com") and "=" in best_url: - # Sometimes it's just URL=... - # We can try to force it - pass - - return best_url - -def extract_artist_names(track: dict) -> str: - """Safely extracts artist names from track data (dict or str items).""" - artists = track.get('artists') or [] - if isinstance(artists, list): - names = [] - for a in artists: - if isinstance(a, dict): - names.append(a.get('name', 'Unknown')) - elif isinstance(a, str): - names.append(a) - return ", ".join(names) if names else "Unknown Artist" - return "Unknown Artist" - -def extract_album_name(track: dict, default="Single") -> str: - """Safely extracts album name from track data.""" - album = track.get('album') - if isinstance(album, dict): - return album.get('name', default) - if isinstance(album, str): - return album - return default - -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 [] - -CATEGORIES_MAP = { - "Trending Vietnam": {"query": "Top 50 Vietnam", "type": "playlists"}, - "Just released Songs": {"query": "New Released Songs", "type": "playlists"}, - "Albums": {"query": "New Albums 2024", "type": "albums"}, - "Vietnamese DJs": {"query": "Vinahouse Remix", "type": "playlists"}, - "Global Hits": {"query": "Global Top 50", "type": "playlists"}, - "Chill Vibes": {"query": "Chill Lofi", "type": "playlists"}, - "Party Time": {"query": "Party EDM Hits", "type": "playlists"}, - "Best of Ballad": {"query": "Vietnamese Ballad", "type": "playlists"}, - "Hip Hop & Rap": {"query": "Vietnamese Rap", "type": "playlists"}, -} - -@router.get("/browse/category") -async def get_browse_category(name: str): - """ - Fetch live data for a specific category (infinite scroll support). - Fetches up to 50-100 items. - """ - if name not in CATEGORIES_MAP: - raise HTTPException(status_code=404, detail="Category not found") - - info = CATEGORIES_MAP[name] - query = info["query"] - search_type = info["type"] - - # Check Cache - cache_key = f"browse_category:{name}" - cached = cache.get(cache_key) - if cached: - return cached - - try: - from ytmusicapi import YTMusic - yt = YTMusic() - - # Search for more items (e.g. 50) - results = yt.search(query, filter=search_type, limit=50) - - category_items = [] - - for result in results: - item_id = result.get('browseId') - if not item_id: continue - - title = result.get('title', 'Unknown') - - # Simple item structure for list view (we don't need full track list for every item immediately) - # But frontend expects some structure. - - # Extract basic thumbnails - thumbnails = result.get('thumbnails', []) - cover_url = get_high_res_thumbnail(thumbnails) - - # description logic - description = "" - if search_type == "albums": - artists_text = ", ".join([a.get('name') for a in result.get('artists', [])]) - year = result.get('year', '') - description = f"Album by {artists_text} • {year}" - is_album = True - else: - is_album = False - # For playlists result, description might be missing in search result - description = f"Playlist • {result.get('itemCount', '')} tracks" - - category_items.append({ - "id": item_id, - "title": title, - "description": description, - "cover_url": cover_url, - "type": "album" if is_album else "playlist", - # Note: We are NOT fetching full tracks for each item here to save speed/quota. - # The frontend only needs cover, title, description, id. - # Tracks are fetched when user clicks the item (via get_playlist). - "tracks": [] - }) - - cache.set(cache_key, category_items, ttl_seconds=3600) # Cache for 1 hour - return category_items - - except Exception as e: - print(f"Category Fetch 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() - - playlist_data = None - is_album = False - - if id.startswith("MPREb"): - try: - playlist_data = yt.get_album(id) - is_album = True - except Exception as e: - print(f"DEBUG: get_album(1) failed: {e}") - pass - - if not playlist_data: - try: - # ytmusicapi returns a dict with 'tracks' list - playlist_data = yt.get_playlist(id, limit=100) - except Exception as e: - print(f"DEBUG: get_playlist failed: {e}") - import traceback, sys - traceback.print_exc(file=sys.stdout) - # Fallback: Try as album if not tried yet - if not is_album: - try: - playlist_data = yt.get_album(id) - is_album = True - except Exception as e2: - print(f"DEBUG: get_album(2) failed: {e2}") - traceback.print_exc(file=sys.stdout) - raise e # Re-raise if both fail - - if not isinstance(playlist_data, dict): - print(f"DEBUG: Validation Failed! playlist_data type: {type(playlist_data)}", flush=True) - raise ValueError(f"Invalid playlist_data: {playlist_data}") - - # Format to match our app's Protocol - formatted_tracks = [] - if 'tracks' in playlist_data: - for track in playlist_data['tracks']: - artist_names = extract_artist_names(track) - - # Safely extract thumbnails - thumbnails = track.get('thumbnails', []) - if not thumbnails and is_album: - # Albums sometimes have thumbnails at root level, not per track - thumbnails = playlist_data.get('thumbnails', []) - - cover_url = get_high_res_thumbnail(thumbnails) - - # Safely extract album - album_name = extract_album_name(track, playlist_data.get('title', 'Single')) - - video_id = track.get('videoId') - if not video_id: - continue - - formatted_tracks.append({ - "title": track.get('title', 'Unknown Title'), - "artist": artist_names, - "album": album_name, - "duration": track.get('duration_seconds', track.get('length_seconds', 0)), - "cover_url": cover_url, - "id": video_id, - "url": f"https://music.youtube.com/watch?v={video_id}" - }) - - # Get Playlist Cover (usually highest res) - thumbnails = playlist_data.get('thumbnails', []) - p_cover = get_high_res_thumbnail(thumbnails) - - # Safely extract author/artists - author = "YouTube Music" - if is_album: - artists = playlist_data.get('artists', []) - names = [] - for a in artists: - if isinstance(a, dict): names.append(a.get('name', 'Unknown')) - elif isinstance(a, str): names.append(a) - author = ", ".join(names) - else: - author_data = playlist_data.get('author', {}) - if isinstance(author_data, dict): - author = author_data.get('name', 'YouTube Music') - else: - author = str(author_data) - - formatted_playlist = { - "id": playlist_data.get('browseId', playlist_data.get('id')), - "title": clean_title(playlist_data.get('title', 'Unknown')), - "description": clean_description(playlist_data.get('description', '')), - "author": author, - "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: - import traceback - print(f"Playlist Fetch Error (NEW CODE): {e}", flush=True) - print(traceback.format_exc(), flush=True) - try: - print(f"Playlist Data Type: {type(playlist_data)}") - if 'tracks' in playlist_data and playlist_data['tracks']: - print(f"First Track Type: {type(playlist_data['tracks'][0])}") - except: - pass - 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: - artist_names = extract_artist_names(track) - - # Safely extract thumbnails - thumbnails = track.get('thumbnails', []) - cover_url = get_high_res_thumbnail(thumbnails) - - album_name = extract_album_name(track, "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: - seen_ids = set() - seen_ids.add(seed_id) - for track in watch_playlist['tracks']: - # Skip if seen or seed - t_id = track.get('videoId') - if not t_id or t_id in seen_ids: - continue - seen_ids.add(t_id) - - artist_names = extract_artist_names(track) - - thumbnails = track.get('thumbnails') or track.get('thumbnail') or [] - cover_url = get_high_res_thumbnail(thumbnails) - - album_name = extract_album_name(track, "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": t_id, - "url": f"https://music.youtube.com/watch?v={t_id}" - }) - - 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 = get_high_res_thumbnail(thumbnails) - - 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("/artist/info") -async def get_artist_info(name: str): - """ - Get artist metadata (photo) by name. - """ - if not name: - return {"photo": None} - - cache_key = f"artist_info:{name.lower().strip()}" - cached = cache.get(cache_key) - if cached: - return cached - - try: - from ytmusicapi import YTMusic - yt = YTMusic() - - results = yt.search(name, filter="artists", limit=1) - if results: - artist = results[0] - thumbnails = artist.get('thumbnails', []) - photo_url = get_high_res_thumbnail(thumbnails) - result = {"photo": photo_url} - - cache.set(cache_key, result, ttl_seconds=86400 * 7) # Cache for 1 week - return result - - return {"photo": None} - except Exception as e: - print(f"Artist Info Error: {e}") - return {"photo": None} - -@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 - # Check Cache for stream URL - cache_key = f"v10:stream:{id}" # v10 - web_creator client bypass - cached_data = cache.get(cache_key) - - stream_url = None - mime_type = "audio/mp4" - - if cached_data: - print(f"DEBUG: Using cached stream data for '{id}'") - if isinstance(cached_data, dict): - stream_url = cached_data.get('url') - mime_type = cached_data.get('mime', 'audio/mp4') - else: - stream_url = cached_data # Legacy fallback - - if not stream_url: - print(f"DEBUG: Fetching new stream URL for '{id}'") - url = f"https://www.youtube.com/watch?v={id}" - ydl_opts = { - # Try multiple formats, prefer webm which often works better - 'format': 'bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio/best', - 'quiet': False, # Enable output for debugging - 'noplaylist': True, - 'nocheckcertificate': True, - 'geo_bypass': True, - 'geo_bypass_country': 'US', - 'socket_timeout': 30, - 'retries': 5, - 'force_ipv4': True, - # Try web_creator client which sometimes bypasses auth, fallback to ios/android - 'extractor_args': {'youtube': {'player_client': ['web_creator', 'ios', 'android', 'web']}}, - # Additional options to avoid bot detection - 'http_headers': { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept-Language': 'en-US,en;q=0.9', - }, - } - - try: - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(url, download=False) - stream_url = info.get('url') - ext = info.get('ext') - http_headers = info.get('http_headers', {}) # Get headers required for the URL - - # Determine MIME type - if ext == 'm4a' or ext == 'mp4': - mime_type = "audio/mp4" - elif ext == 'webm': - mime_type = "audio/webm" - else: - mime_type = "audio/mpeg" - - print(f"DEBUG: Got stream URL format: {info.get('format')}, ext: {ext}, mime: {mime_type}", flush=True) - except Exception as ydl_error: - print(f"DEBUG: yt-dlp extraction error: {type(ydl_error).__name__}: {str(ydl_error)}", flush=True) - raise ydl_error - - if stream_url: - cached_data = {"url": stream_url, "mime": mime_type, "headers": http_headers} - cache.set(cache_key, cached_data, ttl_seconds=3600) - - if not stream_url: - raise HTTPException(status_code=404, detail="Audio stream not found") - - print(f"Streaming {id} with Content-Type: {mime_type}", flush=True) - - # Pre-open the connection to verify it works and get headers - try: - # Sanitize headers: prevent Host/Cookie conflicts, but keep User-Agent and Cookies - base_headers = {} - if 'http_headers' in locals(): - base_headers = http_headers - elif cached_data and isinstance(cached_data, dict): - base_headers = cached_data.get('headers', {}) - - req_headers = { - 'User-Agent': base_headers.get('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'), - 'Referer': 'https://www.youtube.com/', - 'Accept': '*/*', - 'Accept-Language': base_headers.get('Accept-Language', 'en-US,en;q=0.9'), - } - if 'Cookie' in base_headers: - req_headers['Cookie'] = base_headers['Cookie'] - - # Disable SSL verify to match yt-dlp 'nocheckcertificate' (fixes NAS CA issues) - external_req = requests.get(stream_url, stream=True, timeout=30, headers=req_headers, verify=False) - external_req.raise_for_status() - - except requests.exceptions.HTTPError as http_err: - error_details = f"Upstream error: {http_err.response.status_code}" - print(f"Stream Error: {error_details}") - # If 403/404/410, invalidate cache - if http_err.response.status_code in [403, 404, 410]: - cache.delete(cache_key) - raise HTTPException(status_code=500, detail=error_details) - except Exception as e: - print(f"Stream Connection Error: {e}") - raise HTTPException(status_code=500, detail=f"Stream connection failed: {str(e)}") - - # Forward Content-Length if available - headers = {} - if "Content-Length" in external_req.headers: - headers["Content-Length"] = external_req.headers["Content-Length"] - - def iterfile(): - try: - # Use the already open request - for chunk in external_req.iter_content(chunk_size=64*1024): - yield chunk - external_req.close() - except Exception as e: - pass - - return StreamingResponse(iterfile(), media_type=mime_type, headers=headers) - - except HTTPException: - raise - except Exception as e: - import traceback - print(f"Stream Error for ID '{id}': {type(e).__name__}: {str(e)}") - print(traceback.format_exc()) - raise HTTPException(status_code=500, detail=f"Stream error: {type(e).__name__}: {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 +from fastapi import APIRouter, HTTPException, BackgroundTasks, Response +from fastapi.responses import StreamingResponse, JSONResponse +from pydantic import BaseModel +import json +from pathlib import Path +import yt_dlp +import requests +from backend.services.spotify import SpotifyService +from backend.services.cache import CacheManager +from backend.playlist_manager import PlaylistManager +from backend.scheduler import update_ytdlp # Import update function + +import re + +router = APIRouter() +# Services (Assumed to be initialized elsewhere if not here, adhering to existing patterns) +# spotify = SpotifyService() # Commented out as duplicates if already imported +if 'CacheManager' in globals(): + cache = CacheManager() +else: + from backend.cache_manager import CacheManager + cache = CacheManager() + +playlist_manager = PlaylistManager() + +@router.post("/system/update-ytdlp") +async def manual_ytdlp_update(background_tasks: BackgroundTasks): + """ + Trigger a manual update of yt-dlp in the background. + """ + background_tasks.add_task(update_ytdlp) + return {"status": "success", "message": "yt-dlp update started in background"} + +def get_high_res_thumbnail(thumbnails: list) -> str: + """ + Selects the best thumbnail and attempts to upgrade resolution + if it's a Google/YouTube URL. + """ + if not thumbnails: + return "https://placehold.co/300x300" + + # 1. Start with the largest available in the list + best_url = thumbnails[-1]['url'] + + # 2. Upgrade resolution for Google User Content (lh3.googleusercontent.com, yt3.ggpht.com) + # Common patterns: + # =w120-h120-l90-rj (Small) + # =w544-h544-l90-rj (High Res) + # s120-c-k-c0x00ffffff-no-rj (Profile/Avatar) + + if "googleusercontent.com" in best_url or "ggpht.com" in best_url: + import re + # Replace width/height params with 544 (standard YTM high res) + # We look for patterns like =w-h... + if "w" in best_url and "h" in best_url: + best_url = re.sub(r'=w\d+-h\d+', '=w544-h544', best_url) + elif best_url.startswith("https://lh3.googleusercontent.com") and "=" in best_url: + # Sometimes it's just URL=... + # We can try to force it + pass + + return best_url + +def extract_artist_names(track: dict) -> str: + """Safely extracts artist names from track data (dict or str items).""" + artists = track.get('artists') or [] + if isinstance(artists, list): + names = [] + for a in artists: + if isinstance(a, dict): + names.append(a.get('name', 'Unknown')) + elif isinstance(a, str): + names.append(a) + return ", ".join(names) if names else "Unknown Artist" + return "Unknown Artist" + +def extract_album_name(track: dict, default="Single") -> str: + """Safely extracts album name from track data.""" + album = track.get('album') + if isinstance(album, dict): + return album.get('name', default) + if isinstance(album, str): + return album + return default + +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 [] + +CATEGORIES_MAP = { + "Trending Vietnam": {"query": "Top 50 Vietnam", "type": "playlists"}, + "Just released Songs": {"query": "New Released Songs", "type": "playlists"}, + "Albums": {"query": "New Albums 2024", "type": "albums"}, + "Vietnamese DJs": {"query": "Vinahouse Remix", "type": "playlists"}, + "Global Hits": {"query": "Global Top 50", "type": "playlists"}, + "Chill Vibes": {"query": "Chill Lofi", "type": "playlists"}, + "Party Time": {"query": "Party EDM Hits", "type": "playlists"}, + "Best of Ballad": {"query": "Vietnamese Ballad", "type": "playlists"}, + "Hip Hop & Rap": {"query": "Vietnamese Rap", "type": "playlists"}, +} + +@router.get("/browse/category") +async def get_browse_category(name: str): + """ + Fetch live data for a specific category (infinite scroll support). + Fetches up to 50-100 items. + """ + if name not in CATEGORIES_MAP: + raise HTTPException(status_code=404, detail="Category not found") + + info = CATEGORIES_MAP[name] + query = info["query"] + search_type = info["type"] + + # Check Cache + cache_key = f"browse_category:{name}" + cached = cache.get(cache_key) + if cached: + return cached + + try: + from ytmusicapi import YTMusic + yt = YTMusic() + + # Search for more items (e.g. 50) + results = yt.search(query, filter=search_type, limit=50) + + category_items = [] + + for result in results: + item_id = result.get('browseId') + if not item_id: continue + + title = result.get('title', 'Unknown') + + # Simple item structure for list view (we don't need full track list for every item immediately) + # But frontend expects some structure. + + # Extract basic thumbnails + thumbnails = result.get('thumbnails', []) + cover_url = get_high_res_thumbnail(thumbnails) + + # description logic + description = "" + if search_type == "albums": + artists_text = ", ".join([a.get('name') for a in result.get('artists', [])]) + year = result.get('year', '') + description = f"Album by {artists_text} • {year}" + is_album = True + else: + is_album = False + # For playlists result, description might be missing in search result + description = f"Playlist • {result.get('itemCount', '')} tracks" + + category_items.append({ + "id": item_id, + "title": title, + "description": description, + "cover_url": cover_url, + "type": "album" if is_album else "playlist", + # Note: We are NOT fetching full tracks for each item here to save speed/quota. + # The frontend only needs cover, title, description, id. + # Tracks are fetched when user clicks the item (via get_playlist). + "tracks": [] + }) + + cache.set(cache_key, category_items, ttl_seconds=3600) # Cache for 1 hour + return category_items + + except Exception as e: + print(f"Category Fetch 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() + + playlist_data = None + is_album = False + + if id.startswith("MPREb"): + try: + playlist_data = yt.get_album(id) + is_album = True + except Exception as e: + print(f"DEBUG: get_album(1) failed: {e}") + pass + + if not playlist_data: + try: + # ytmusicapi returns a dict with 'tracks' list + playlist_data = yt.get_playlist(id, limit=100) + except Exception as e: + print(f"DEBUG: get_playlist failed: {e}") + import traceback, sys + traceback.print_exc(file=sys.stdout) + # Fallback: Try as album if not tried yet + if not is_album: + try: + playlist_data = yt.get_album(id) + is_album = True + except Exception as e2: + print(f"DEBUG: get_album(2) failed: {e2}") + traceback.print_exc(file=sys.stdout) + raise e # Re-raise if both fail + + if not isinstance(playlist_data, dict): + print(f"DEBUG: Validation Failed! playlist_data type: {type(playlist_data)}", flush=True) + raise ValueError(f"Invalid playlist_data: {playlist_data}") + + # Format to match our app's Protocol + formatted_tracks = [] + if 'tracks' in playlist_data: + for track in playlist_data['tracks']: + artist_names = extract_artist_names(track) + + # Safely extract thumbnails + thumbnails = track.get('thumbnails', []) + if not thumbnails and is_album: + # Albums sometimes have thumbnails at root level, not per track + thumbnails = playlist_data.get('thumbnails', []) + + cover_url = get_high_res_thumbnail(thumbnails) + + # Safely extract album + album_name = extract_album_name(track, playlist_data.get('title', 'Single')) + + video_id = track.get('videoId') + if not video_id: + continue + + formatted_tracks.append({ + "title": track.get('title', 'Unknown Title'), + "artist": artist_names, + "album": album_name, + "duration": track.get('duration_seconds', track.get('length_seconds', 0)), + "cover_url": cover_url, + "id": video_id, + "url": f"https://music.youtube.com/watch?v={video_id}" + }) + + # Get Playlist Cover (usually highest res) + thumbnails = playlist_data.get('thumbnails', []) + p_cover = get_high_res_thumbnail(thumbnails) + + # Safely extract author/artists + author = "YouTube Music" + if is_album: + artists = playlist_data.get('artists', []) + names = [] + for a in artists: + if isinstance(a, dict): names.append(a.get('name', 'Unknown')) + elif isinstance(a, str): names.append(a) + author = ", ".join(names) + else: + author_data = playlist_data.get('author', {}) + if isinstance(author_data, dict): + author = author_data.get('name', 'YouTube Music') + else: + author = str(author_data) + + formatted_playlist = { + "id": playlist_data.get('browseId', playlist_data.get('id')), + "title": clean_title(playlist_data.get('title', 'Unknown')), + "description": clean_description(playlist_data.get('description', '')), + "author": author, + "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: + import traceback + print(f"Playlist Fetch Error (NEW CODE): {e}", flush=True) + print(traceback.format_exc(), flush=True) + try: + print(f"Playlist Data Type: {type(playlist_data)}") + if 'tracks' in playlist_data and playlist_data['tracks']: + print(f"First Track Type: {type(playlist_data['tracks'][0])}") + except: + pass + 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: + artist_names = extract_artist_names(track) + + # Safely extract thumbnails + thumbnails = track.get('thumbnails', []) + cover_url = get_high_res_thumbnail(thumbnails) + + album_name = extract_album_name(track, "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: + seen_ids = set() + seen_ids.add(seed_id) + for track in watch_playlist['tracks']: + # Skip if seen or seed + t_id = track.get('videoId') + if not t_id or t_id in seen_ids: + continue + seen_ids.add(t_id) + + artist_names = extract_artist_names(track) + + thumbnails = track.get('thumbnails') or track.get('thumbnail') or [] + cover_url = get_high_res_thumbnail(thumbnails) + + album_name = extract_album_name(track, "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": t_id, + "url": f"https://music.youtube.com/watch?v={t_id}" + }) + + 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 = get_high_res_thumbnail(thumbnails) + + 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("/artist/info") +async def get_artist_info(name: str): + """ + Get artist metadata (photo) by name. + """ + if not name: + return {"photo": None} + + cache_key = f"artist_info:{name.lower().strip()}" + cached = cache.get(cache_key) + if cached: + return cached + + try: + from ytmusicapi import YTMusic + yt = YTMusic() + + results = yt.search(name, filter="artists", limit=1) + if results: + artist = results[0] + thumbnails = artist.get('thumbnails', []) + photo_url = get_high_res_thumbnail(thumbnails) + result = {"photo": photo_url} + + cache.set(cache_key, result, ttl_seconds=86400 * 7) # Cache for 1 week + return result + + return {"photo": None} + except Exception as e: + print(f"Artist Info Error: {e}") + return {"photo": None} + +@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 + # Check Cache for stream URL + cache_key = f"v10:stream:{id}" # v10 - web_creator client bypass + cached_data = cache.get(cache_key) + + stream_url = None + mime_type = "audio/mp4" + + if cached_data: + print(f"DEBUG: Using cached stream data for '{id}'") + if isinstance(cached_data, dict): + stream_url = cached_data.get('url') + mime_type = cached_data.get('mime', 'audio/mp4') + else: + stream_url = cached_data # Legacy fallback + + if not stream_url: + print(f"DEBUG: Fetching new stream URL for '{id}'") + url = f"https://www.youtube.com/watch?v={id}" + ydl_opts = { + # Try multiple formats, prefer webm which often works better + 'format': 'bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio/best', + 'quiet': False, # Enable output for debugging + 'noplaylist': True, + 'nocheckcertificate': True, + 'geo_bypass': True, + 'geo_bypass_country': 'US', + 'socket_timeout': 30, + 'retries': 5, + 'force_ipv4': True, + # Try web_creator client which sometimes bypasses auth, fallback to ios/android + 'extractor_args': {'youtube': {'player_client': ['web_creator', 'ios', 'android', 'web']}}, + # Additional options to avoid bot detection + 'http_headers': { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept-Language': 'en-US,en;q=0.9', + }, + } + + try: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=False) + stream_url = info.get('url') + ext = info.get('ext') + http_headers = info.get('http_headers', {}) # Get headers required for the URL + + # Determine MIME type + if ext == 'm4a' or ext == 'mp4': + mime_type = "audio/mp4" + elif ext == 'webm': + mime_type = "audio/webm" + else: + mime_type = "audio/mpeg" + + print(f"DEBUG: Got stream URL format: {info.get('format')}, ext: {ext}, mime: {mime_type}", flush=True) + except Exception as ydl_error: + print(f"DEBUG: yt-dlp extraction error: {type(ydl_error).__name__}: {str(ydl_error)}", flush=True) + raise ydl_error + + if stream_url: + cached_data = {"url": stream_url, "mime": mime_type, "headers": http_headers} + cache.set(cache_key, cached_data, ttl_seconds=3600) + + if not stream_url: + raise HTTPException(status_code=404, detail="Audio stream not found") + + print(f"Streaming {id} with Content-Type: {mime_type}", flush=True) + + # Pre-open the connection to verify it works and get headers + try: + # Sanitize headers: prevent Host/Cookie conflicts, but keep User-Agent and Cookies + base_headers = {} + if 'http_headers' in locals(): + base_headers = http_headers + elif cached_data and isinstance(cached_data, dict): + base_headers = cached_data.get('headers', {}) + + req_headers = { + 'User-Agent': base_headers.get('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'), + 'Referer': 'https://www.youtube.com/', + 'Accept': '*/*', + 'Accept-Language': base_headers.get('Accept-Language', 'en-US,en;q=0.9'), + } + if 'Cookie' in base_headers: + req_headers['Cookie'] = base_headers['Cookie'] + + # Disable SSL verify to match yt-dlp 'nocheckcertificate' (fixes NAS CA issues) + external_req = requests.get(stream_url, stream=True, timeout=30, headers=req_headers, verify=False) + external_req.raise_for_status() + + except requests.exceptions.HTTPError as http_err: + error_details = f"Upstream error: {http_err.response.status_code}" + print(f"Stream Error: {error_details}") + # If 403/404/410, invalidate cache + if http_err.response.status_code in [403, 404, 410]: + cache.delete(cache_key) + raise HTTPException(status_code=500, detail=error_details) + except Exception as e: + print(f"Stream Connection Error: {e}") + raise HTTPException(status_code=500, detail=f"Stream connection failed: {str(e)}") + + # Forward Content-Length if available + headers = {} + if "Content-Length" in external_req.headers: + headers["Content-Length"] = external_req.headers["Content-Length"] + + def iterfile(): + try: + # Use the already open request + for chunk in external_req.iter_content(chunk_size=64*1024): + yield chunk + external_req.close() + except Exception as e: + pass + + return StreamingResponse(iterfile(), media_type=mime_type, headers=headers) + + except HTTPException: + raise + except Exception as e: + import traceback + print(f"Stream Error for ID '{id}': {type(e).__name__}: {str(e)}") + print(traceback.format_exc()) + raise HTTPException(status_code=500, detail=f"Stream error: {type(e).__name__}: {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 diff --git a/backend/backend/data/user_playlists.json b/backend/backend/data/user_playlists.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/backend/backend/data/user_playlists.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/backend/cache_manager.py b/backend/cache_manager.py index 575a9a2..58935fa 100644 --- a/backend/cache_manager.py +++ b/backend/cache_manager.py @@ -1,53 +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}") +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}") diff --git a/backend/main.py b/backend/main.py index 5330a04..2e37f9d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,65 +1,65 @@ -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager -from backend.api.routes import router as api_router -from backend.scheduler import start_scheduler -import os - -@asynccontextmanager -async def lifespan(app: FastAPI): - # Startup: Start scheduler - scheduler = start_scheduler() - yield - # Shutdown: Scheduler shuts down automatically with process, or we can explicit shutdown if needed - scheduler.shutdown() - -app = FastAPI(title="Spotify Clone Backend", lifespan=lifespan) - -# 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(api_router, prefix="/api") - -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse - -# 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"} +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager +from backend.api.routes import router as api_router +from backend.scheduler import start_scheduler +import os + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup: Start scheduler + scheduler = start_scheduler() + yield + # Shutdown: Scheduler shuts down automatically with process, or we can explicit shutdown if needed + scheduler.shutdown() + +app = FastAPI(title="Spotify Clone Backend", lifespan=lifespan) + +# 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(api_router, prefix="/api") + +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + +# 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"} diff --git a/backend/playlist_manager.py b/backend/playlist_manager.py index b3ac6eb..98e5b87 100644 --- a/backend/playlist_manager.py +++ b/backend/playlist_manager.py @@ -1,88 +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 +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 diff --git a/backend/requirements.txt b/backend/requirements.txt index 9f8a221..31d21b1 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,10 +1,10 @@ -fastapi==0.115.6 -uvicorn==0.34.0 -spotdl -pydantic==2.10.4 -python-multipart==0.0.20 -APScheduler>=3.10 -requests==2.32.3 -yt-dlp @ https://github.com/yt-dlp/yt-dlp/archive/master.zip -ytmusicapi==1.9.1 -syncedlyrics +fastapi==0.115.6 +uvicorn==0.34.0 +spotdl +pydantic==2.10.4 +python-multipart==0.0.20 +APScheduler>=3.10 +requests==2.32.3 +yt-dlp @ https://github.com/yt-dlp/yt-dlp/archive/master.zip +ytmusicapi==1.9.1 +syncedlyrics diff --git a/backend/scheduler.py b/backend/scheduler.py index 877c50f..077fa3e 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -1,51 +1,51 @@ -import subprocess -import logging -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.interval import IntervalTrigger - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -def update_ytdlp(): - """ - Check for and install the latest version of yt-dlp. - """ - logger.info("Scheduler: Checking for yt-dlp updates...") - try: - # Run pip install --upgrade yt-dlp - result = subprocess.run( - ["pip", "install", "--upgrade", "yt-dlp"], - capture_output=True, - text=True, - check=True - ) - logger.info(f"Scheduler: yt-dlp update completed.\n{result.stdout}") - except subprocess.CalledProcessError as e: - logger.error(f"Scheduler: Failed to update yt-dlp.\nError: {e.stderr}") - except Exception as e: - logger.error(f"Scheduler: Unexpected error during update: {str(e)}") - -def start_scheduler(): - """ - Initialize and start the background scheduler. - """ - scheduler = BackgroundScheduler() - - # Schedule yt-dlp update every 24 hours - trigger = IntervalTrigger(days=1) - scheduler.add_job( - update_ytdlp, - trigger=trigger, - id="update_ytdlp_job", - name="Update yt-dlp daily", - replace_existing=True - ) - - scheduler.start() - logger.info("Scheduler: Started background scheduler. yt-dlp will update every 24 hours.") - - # Run once on startup to ensure we are up to date immediately - # update_ytdlp() # Optional: Uncomment if we want strict enforcement on boot - - return scheduler +import subprocess +import logging +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.interval import IntervalTrigger + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def update_ytdlp(): + """ + Check for and install the latest version of yt-dlp. + """ + logger.info("Scheduler: Checking for yt-dlp updates...") + try: + # Run pip install --upgrade yt-dlp + result = subprocess.run( + ["pip", "install", "--upgrade", "yt-dlp"], + capture_output=True, + text=True, + check=True + ) + logger.info(f"Scheduler: yt-dlp update completed.\n{result.stdout}") + except subprocess.CalledProcessError as e: + logger.error(f"Scheduler: Failed to update yt-dlp.\nError: {e.stderr}") + except Exception as e: + logger.error(f"Scheduler: Unexpected error during update: {str(e)}") + +def start_scheduler(): + """ + Initialize and start the background scheduler. + """ + scheduler = BackgroundScheduler() + + # Schedule yt-dlp update every 24 hours + trigger = IntervalTrigger(days=1) + scheduler.add_job( + update_ytdlp, + trigger=trigger, + id="update_ytdlp_job", + name="Update yt-dlp daily", + replace_existing=True + ) + + scheduler.start() + logger.info("Scheduler: Started background scheduler. yt-dlp will update every 24 hours.") + + # Run once on startup to ensure we are up to date immediately + # update_ytdlp() # Optional: Uncomment if we want strict enforcement on boot + + return scheduler diff --git a/backend/services/__init__.py b/backend/services/__init__.py index bc4540c..66067bb 100644 --- a/backend/services/__init__.py +++ b/backend/services/__init__.py @@ -1 +1 @@ -# Services Package +# Services Package diff --git a/backend/services/cache.py b/backend/services/cache.py index 11ae029..dda4c26 100644 --- a/backend/services/cache.py +++ b/backend/services/cache.py @@ -1,4 +1,4 @@ -# Cache Service - Re-export CacheManager from backend.cache_manager -from backend.cache_manager import CacheManager - -__all__ = ['CacheManager'] +# Cache Service - Re-export CacheManager from backend.cache_manager +from backend.cache_manager import CacheManager + +__all__ = ['CacheManager'] diff --git a/backend/services/spotify.py b/backend/services/spotify.py index cc58a33..4981347 100644 --- a/backend/services/spotify.py +++ b/backend/services/spotify.py @@ -1,19 +1,19 @@ -# Spotify Service - Placeholder for YouTube Music API interactions -# Currently uses yt-dlp directly in routes.py - -class SpotifyService: - """ - Placeholder service for Spotify/YouTube Music integration. - Currently, all music operations are handled directly in routes.py using yt-dlp. - This class exists to satisfy imports but has minimal functionality. - """ - def __init__(self): - pass - - def search(self, query: str, limit: int = 20): - """Search for music - placeholder""" - return [] - - def get_track(self, track_id: str): - """Get track info - placeholder""" - return None +# Spotify Service - Placeholder for YouTube Music API interactions +# Currently uses yt-dlp directly in routes.py + +class SpotifyService: + """ + Placeholder service for Spotify/YouTube Music integration. + Currently, all music operations are handled directly in routes.py using yt-dlp. + This class exists to satisfy imports but has minimal functionality. + """ + def __init__(self): + pass + + def search(self, query: str, limit: int = 20): + """Search for music - placeholder""" + return [] + + def get_track(self, track_id: str): + """Get track info - placeholder""" + return None diff --git a/debug_recs.py b/debug_recs.py index 6349c4a..cac4832 100644 --- a/debug_recs.py +++ b/debug_recs.py @@ -1,17 +1,17 @@ -from ytmusicapi import YTMusic -import json - -yt = YTMusic() -seed_id = "hDrFd1W8fvU" -print(f"Fetching watch playlist for {seed_id}...") -results = yt.get_watch_playlist(videoId=seed_id, limit=5) - -if 'tracks' in results: - print(f"Found {len(results['tracks'])} tracks.") - if len(results['tracks']) > 0: - first_track = results['tracks'][0] - print(json.dumps(first_track, indent=2)) - print("Keys:", first_track.keys()) -else: - print("No 'tracks' key in results") - print(results.keys()) +from ytmusicapi import YTMusic +import json + +yt = YTMusic() +seed_id = "hDrFd1W8fvU" +print(f"Fetching watch playlist for {seed_id}...") +results = yt.get_watch_playlist(videoId=seed_id, limit=5) + +if 'tracks' in results: + print(f"Found {len(results['tracks'])} tracks.") + if len(results['tracks']) > 0: + first_track = results['tracks'][0] + print(json.dumps(first_track, indent=2)) + print("Keys:", first_track.keys()) +else: + print("No 'tracks' key in results") + print(results.keys()) diff --git a/docker-compose.yml b/docker-compose.yml index 1b6ef1b..1bea826 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +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 +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 diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico index 718d6fe..1ec95ad 100644 Binary files a/frontend/app/favicon.ico and b/frontend/app/favicon.ico differ diff --git a/frontend/app/favicon.png b/frontend/app/favicon.png new file mode 100644 index 0000000..e583f21 Binary files /dev/null and b/frontend/app/favicon.png differ diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 545ea2a..dae3cdd 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,62 +1,64 @@ -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 RightSidebar from "@/components/RightSidebar"; -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", - }, - other: { - "mobile-web-app-capable": "yes", - }, - icons: { - icon: "/icons/icon-192x192.png", - apple: "/icons/icon-512x512.png", - }, -}; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - - -
- -
- {children} -
- -
- - -
-
- - - ); -} +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 RightSidebar from "@/components/RightSidebar"; +import { PlayerProvider } from "@/context/PlayerContext"; +import { LibraryProvider } from "@/context/LibraryContext"; +import ServiceWorkerRegistration from "@/components/ServiceWorkerRegistration"; + +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", + }, + other: { + "mobile-web-app-capable": "yes", + }, + icons: { + icon: "/icons/icon-192x192.png", + apple: "/icons/icon-512x512.png", + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + +
+ +
+ {children} +
+ +
+ + +
+
+ + + ); +} diff --git a/frontend/app/library/page.tsx b/frontend/app/library/page.tsx index c2b04c5..9d4d16a 100644 --- a/frontend/app/library/page.tsx +++ b/frontend/app/library/page.tsx @@ -1,156 +1,156 @@ -"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"; -import CoverImage from "@/components/CoverImage"; - -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 ( -
-
-

Your Library

- -
- -
- - - - -
- -
- {/* Playlists & Liked Songs */} - {showPlaylists && ( - <> - -
-

Liked Songs

-

Auto-generated

-
- - - {playlists.map((playlist) => ( - -
-
- -
-

{playlist.title}

-

Playlist • You

-
- - ))} - - {browsePlaylists.map((playlist) => ( - -
-
- -
-

{playlist.title}

-

Playlist • Made for you

-
- - ))} - - )} - - {/* Artists Content (Circular Images) */} - {showArtists && artists.map((artist) => ( - -
-
- -
-

{artist.title}

-

Artist

-
- - ))} - - {/* Albums Content */} - {showAlbums && albums.map((album) => ( - -
-
- -
-

{album.title}

-

Album • {album.creator || 'Spotify'}

-
- - ))} -
- - setIsCreateModalOpen(false)} - onCreate={handleCreatePlaylist} - /> -
- ); -} +"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"; +import CoverImage from "@/components/CoverImage"; + +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 ( +
+
+

Your Library

+ +
+ +
+ + + + +
+ +
+ {/* Playlists & Liked Songs */} + {showPlaylists && ( + <> + +
+

Liked Songs

+

Auto-generated

+
+ + + {playlists.map((playlist) => ( + +
+
+ +
+

{playlist.title}

+

Playlist • You

+
+ + ))} + + {browsePlaylists.map((playlist) => ( + +
+
+ +
+

{playlist.title}

+

Playlist • Made for you

+
+ + ))} + + )} + + {/* Artists Content (Circular Images) */} + {showArtists && artists.map((artist) => ( + +
+
+ +
+

{artist.title}

+

Artist

+
+ + ))} + + {/* Albums Content */} + {showAlbums && albums.map((album) => ( + +
+
+ +
+

{album.title}

+

Album • {album.creator || 'Spotify'}

+
+ + ))} +
+ + setIsCreateModalOpen(false)} + onCreate={handleCreatePlaylist} + /> +
+ ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index c3affbc..c908016 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,510 +1,510 @@ -"use client"; - -import { Play, Pause, ArrowUpDown, Clock, Music2, User } from "lucide-react"; -import { useEffect, useState } from "react"; -import { usePlayer } from "@/context/PlayerContext"; -import Link from "next/link"; -import { libraryService } from "@/services/library"; -import { Track } from "@/types"; -import CoverImage from "@/components/CoverImage"; -import Skeleton from "@/components/Skeleton"; - -type SortOption = 'recent' | 'alpha-asc' | 'alpha-desc' | 'artist'; - -export default function Home() { - const [timeOfDay, setTimeOfDay] = useState("Good evening"); - const [browseData, setBrowseData] = useState>({}); - const [loading, setLoading] = useState(true); - const [sortBy, setSortBy] = useState('recent'); - const [showSortMenu, setShowSortMenu] = useState(false); - - 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 - setLoading(true); - libraryService.getBrowseContent() - .then(data => { - setBrowseData(data); - setLoading(false); - }) - .catch(err => { - console.error("Error fetching browse:", err); - setLoading(false); - }); - }, []); - - // Sort playlists based on selected option - const sortPlaylists = (playlists: any[]) => { - const sorted = [...playlists]; - switch (sortBy) { - case 'alpha-asc': - return sorted.sort((a, b) => (a.title || '').localeCompare(b.title || '')); - case 'alpha-desc': - return sorted.sort((a, b) => (b.title || '').localeCompare(a.title || '')); - case 'artist': - return sorted.sort((a, b) => (a.author || a.creator || '').localeCompare(b.author || b.creator || '')); - case 'recent': - default: - return sorted; - } - }; - - // 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; - - const sortOptions = [ - { value: 'recent', label: 'Recently Added', icon: Clock }, - { value: 'alpha-asc', label: 'Alphabetical (A-Z)', icon: ArrowUpDown }, - { value: 'alpha-desc', label: 'Alphabetical (Z-A)', icon: ArrowUpDown }, - { value: 'artist', label: 'Artist Name', icon: User }, - ]; - - return ( -
- - {/* Header / Greetings with Sort Button */} -
-

{timeOfDay}

- - {/* Sort Dropdown */} -
- - - {showSortMenu && ( -
- {sortOptions.map((option) => ( - - ))} -
- )} -
-
- - {/* Hero Section (Big Playlist Banner - AT THE TOP) */} - {loading ? ( -
- -
- - - -
-
- ) : heroPlaylist && ( - -
-
- -
-
- Featured Playlist -

{heroPlaylist.title}

-

- {heroPlaylist.description} -

-
- - Play Now -
-
-
- - )} - - {/* Made For You Section (Recommendations) */} - - - {/* Artist Section (Vietnam) */} - - - {/* Dynamic Recommended Albums based on history */} - - - {/* Recently Listened */} - - - {/* Main Browse Lists */} - {loading ? ( -
- {[1, 2].map(i => ( -
- -
- {[1, 2, 3, 4, 5].map(j => ( -
- - - -
- ))} -
-
- ))} -
- ) : Object.keys(browseData).length > 0 ? ( - Object.entries(browseData).map(([category, playlists]) => ( -
-
-

{category}

- - Show all - -
- -
- {sortPlaylists(playlists).slice(0, 5).map((playlist: any) => ( - -
-
- -
-
- -
-
-
-

{playlist.title}

-

{playlist.description}

-
- - ))} -
-
- )) - ) : ( -
-

Ready to explore?

-

Browse content is loading or empty. Try initializing data.

-
- )} -
- ); -} - -// NEW: Recently Listened Section - Pinned to top -function RecentlyListenedSection() { - const { playHistory, playTrack } = usePlayer(); - - if (playHistory.length === 0) return null; - - return ( -
-
- -

Recently Listened

-
- - {/* Horizontal Scrollable Row */} -
- {playHistory.slice(0, 10).map((track, i) => ( -
playTrack(track, playHistory)} - className="flex-shrink-0 w-40 bg-[#181818] rounded-lg overflow-hidden hover:bg-[#282828] transition duration-300 group cursor-pointer" - > -
- -
-
- -
-
-
-
-

{track.title}

-

{track.artist}

-
-
- ))} -
-
- ); -} - -function MadeForYouSection() { - const { playHistory, playTrack } = usePlayer(); - const [recommendations, setRecommendations] = useState([]); - const [seedTrack, setSeedTrack] = useState(null); - const [loading, setLoading] = useState(false); - - useEffect(() => { - if (playHistory.length > 0) { - const seed = playHistory[0]; // Last played - setSeedTrack(seed); - setLoading(true); - - // Fetch actual recommendations from backend - fetch(`/api/recommendations?seed_id=${seed.id}`) - .then(res => res.json()) - .then(data => { - if (data.tracks) { - setRecommendations(data.tracks); - } - setLoading(false); - }) - .catch(err => { - console.error("Rec error:", err); - setLoading(false); - }); - } - }, [playHistory.length > 0 ? playHistory[0].id : null]); - - if (playHistory.length === 0) return null; - if (!loading && recommendations.length === 0) return null; - - return ( -
-
- -

Made For You

-
-

- {seedTrack ? ( - <>Because you listened to {seedTrack.artist} - ) : "Recommended for you"} -

- - {loading ? ( -
- {[1, 2, 3, 4, 5].map(i => ( -
- - - -
- ))} -
- ) : ( -
- {recommendations.slice(0, 5).map((track, i) => ( -
playTrack(track, recommendations)} className="bg-[#181818] p-2 md:p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col"> -
- -
-
- -
-
-
-

{track.title}

-

{track.artist}

-
- ))} -
- )} -
- ); -} - -function RecommendedAlbumsSection() { - const { playHistory } = usePlayer(); - const [albums, setAlbums] = useState([]); - const [seedArtist, setSeedArtist] = useState(null); - const [loading, setLoading] = useState(false); - - useEffect(() => { - if (playHistory.length > 0) { - const artist = playHistory[0].artist; - if (!artist) return; - - // Clean artist name (remove delimiters like commas if multiple) - const primaryArtist = artist.split(',')[0].trim(); - setSeedArtist(primaryArtist); - setLoading(true); - - fetch(`/api/recommendations/albums?seed_artist=${encodeURIComponent(primaryArtist)}`) - .then(res => res.json()) - .then(data => { - if (Array.isArray(data)) setAlbums(data); - setLoading(false); - }) - .catch(err => { - console.error("Album Rec error:", err); - setLoading(false); - }); - } - }, [playHistory.length > 0 ? playHistory[0].artist : null]); - - if (playHistory.length === 0) return null; - if (!loading && albums.length === 0) return null; - - return ( -
-

More from {seedArtist}

-

Albums you might like

- - {loading ? ( -
- {[1, 2, 3, 4, 5].map(i => ( -
- - - -
- ))} -
- ) : ( -
- {albums.slice(0, 5).map((album, i) => ( - -
-
- -
-
- -
-
-
-

{album.title}

-

{album.description}

-
- - ))} -
- )} -
- ); -} - -// NEW: Artist Vietnam Section with dynamic photos -function ArtistVietnamSection() { - // Popular Vietnamese artists - const artistNames = [ - "Sơn Tùng M-TP", - "HIEUTHUHAI", - "Đen Vâu", - "Hoàng Dũng", - "Vũ.", - "MONO", - "Tlinh", - "Erik", - ]; - - const [artistPhotos, setArtistPhotos] = useState>({}); - const [loading, setLoading] = useState(true); - - useEffect(() => { - // Fetch artist photos from API - const fetchArtistPhotos = async () => { - setLoading(true); - const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; - const photos: Record = {}; - - await Promise.all( - artistNames.map(async (name) => { - try { - const res = await fetch(`${apiUrl}/api/artist/info?name=${encodeURIComponent(name)}`); - if (res.ok) { - const data = await res.json(); - if (data.photo) { - photos[name] = data.photo; - } - } - } catch (e) { - console.error(`Failed to fetch photo for ${name}:`, e); - } - }) - ); - - setArtistPhotos(photos); - setLoading(false); - }; - - fetchArtistPhotos(); - }, []); - - return ( -
-
- -

Artist Vietnam

-
-

Popular Vietnamese artists

- - {/* Horizontal Scrollable Row */} -
- {loading ? ( - // Skeleton Row - [1, 2, 3, 4, 5, 6].map(i => ( -
- - -
- )) - ) : ( - artistNames.map((name, i) => ( - -
-
- -
-
- -
-
-
-

{name}

-

Artist

-
- - )) - )} -
-
- ); -} - +"use client"; + +import { Play, Pause, ArrowUpDown, Clock, Music2, User } from "lucide-react"; +import { useEffect, useState } from "react"; +import { usePlayer } from "@/context/PlayerContext"; +import Link from "next/link"; +import { libraryService } from "@/services/library"; +import { Track } from "@/types"; +import CoverImage from "@/components/CoverImage"; +import Skeleton from "@/components/Skeleton"; + +type SortOption = 'recent' | 'alpha-asc' | 'alpha-desc' | 'artist'; + +export default function Home() { + const [timeOfDay, setTimeOfDay] = useState("Good evening"); + const [browseData, setBrowseData] = useState>({}); + const [loading, setLoading] = useState(true); + const [sortBy, setSortBy] = useState('recent'); + const [showSortMenu, setShowSortMenu] = useState(false); + + 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 + setLoading(true); + libraryService.getBrowseContent() + .then(data => { + setBrowseData(data); + setLoading(false); + }) + .catch(err => { + console.error("Error fetching browse:", err); + setLoading(false); + }); + }, []); + + // Sort playlists based on selected option + const sortPlaylists = (playlists: any[]) => { + const sorted = [...playlists]; + switch (sortBy) { + case 'alpha-asc': + return sorted.sort((a, b) => (a.title || '').localeCompare(b.title || '')); + case 'alpha-desc': + return sorted.sort((a, b) => (b.title || '').localeCompare(a.title || '')); + case 'artist': + return sorted.sort((a, b) => (a.author || a.creator || '').localeCompare(b.author || b.creator || '')); + case 'recent': + default: + return sorted; + } + }; + + // 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; + + const sortOptions = [ + { value: 'recent', label: 'Recently Added', icon: Clock }, + { value: 'alpha-asc', label: 'Alphabetical (A-Z)', icon: ArrowUpDown }, + { value: 'alpha-desc', label: 'Alphabetical (Z-A)', icon: ArrowUpDown }, + { value: 'artist', label: 'Artist Name', icon: User }, + ]; + + return ( +
+ + {/* Header / Greetings with Sort Button */} +
+

{timeOfDay}

+ + {/* Sort Dropdown */} +
+ + + {showSortMenu && ( +
+ {sortOptions.map((option) => ( + + ))} +
+ )} +
+
+ + {/* Hero Section (Big Playlist Banner - AT THE TOP) */} + {loading ? ( +
+ +
+ + + +
+
+ ) : heroPlaylist && ( + +
+
+ +
+
+ Featured Playlist +

{heroPlaylist.title}

+

+ {heroPlaylist.description} +

+
+ + Play Now +
+
+
+ + )} + + {/* Made For You Section (Recommendations) */} + + + {/* Artist Section (Vietnam) */} + + + {/* Dynamic Recommended Albums based on history */} + + + {/* Recently Listened */} + + + {/* Main Browse Lists */} + {loading ? ( +
+ {[1, 2].map(i => ( +
+ +
+ {[1, 2, 3, 4, 5].map(j => ( +
+ + + +
+ ))} +
+
+ ))} +
+ ) : Object.keys(browseData).length > 0 ? ( + Object.entries(browseData).map(([category, playlists]) => ( +
+
+

{category}

+ + Show all + +
+ +
+ {sortPlaylists(playlists).slice(0, 5).map((playlist: any) => ( + +
+
+ +
+
+ +
+
+
+

{playlist.title}

+

{playlist.description}

+
+ + ))} +
+
+ )) + ) : ( +
+

Ready to explore?

+

Browse content is loading or empty. Try initializing data.

+
+ )} +
+ ); +} + +// NEW: Recently Listened Section - Pinned to top +function RecentlyListenedSection() { + const { playHistory, playTrack } = usePlayer(); + + if (playHistory.length === 0) return null; + + return ( +
+
+ +

Recently Listened

+
+ + {/* Horizontal Scrollable Row */} +
+ {playHistory.slice(0, 10).map((track, i) => ( +
playTrack(track, playHistory)} + className="flex-shrink-0 w-40 bg-[#181818] rounded-lg overflow-hidden hover:bg-[#282828] transition duration-300 group cursor-pointer" + > +
+ +
+
+ +
+
+
+
+

{track.title}

+

{track.artist}

+
+
+ ))} +
+
+ ); +} + +function MadeForYouSection() { + const { playHistory, playTrack } = usePlayer(); + const [recommendations, setRecommendations] = useState([]); + const [seedTrack, setSeedTrack] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (playHistory.length > 0) { + const seed = playHistory[0]; // Last played + setSeedTrack(seed); + setLoading(true); + + // Fetch actual recommendations from backend + fetch(`/api/recommendations?seed_id=${seed.id}`) + .then(res => res.json()) + .then(data => { + if (data.tracks) { + setRecommendations(data.tracks); + } + setLoading(false); + }) + .catch(err => { + console.error("Rec error:", err); + setLoading(false); + }); + } + }, [playHistory.length > 0 ? playHistory[0].id : null]); + + if (playHistory.length === 0) return null; + if (!loading && recommendations.length === 0) return null; + + return ( +
+
+ +

Made For You

+
+

+ {seedTrack ? ( + <>Because you listened to {seedTrack.artist} + ) : "Recommended for you"} +

+ + {loading ? ( +
+ {[1, 2, 3, 4, 5].map(i => ( +
+ + + +
+ ))} +
+ ) : ( +
+ {recommendations.slice(0, 5).map((track, i) => ( +
playTrack(track, recommendations)} className="bg-[#181818] p-2 md:p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col"> +
+ +
+
+ +
+
+
+

{track.title}

+

{track.artist}

+
+ ))} +
+ )} +
+ ); +} + +function RecommendedAlbumsSection() { + const { playHistory } = usePlayer(); + const [albums, setAlbums] = useState([]); + const [seedArtist, setSeedArtist] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (playHistory.length > 0) { + const artist = playHistory[0].artist; + if (!artist) return; + + // Clean artist name (remove delimiters like commas if multiple) + const primaryArtist = artist.split(',')[0].trim(); + setSeedArtist(primaryArtist); + setLoading(true); + + fetch(`/api/recommendations/albums?seed_artist=${encodeURIComponent(primaryArtist)}`) + .then(res => res.json()) + .then(data => { + if (Array.isArray(data)) setAlbums(data); + setLoading(false); + }) + .catch(err => { + console.error("Album Rec error:", err); + setLoading(false); + }); + } + }, [playHistory.length > 0 ? playHistory[0].artist : null]); + + if (playHistory.length === 0) return null; + if (!loading && albums.length === 0) return null; + + return ( +
+

More from {seedArtist}

+

Albums you might like

+ + {loading ? ( +
+ {[1, 2, 3, 4, 5].map(i => ( +
+ + + +
+ ))} +
+ ) : ( +
+ {albums.slice(0, 5).map((album, i) => ( + +
+
+ +
+
+ +
+
+
+

{album.title}

+

{album.description}

+
+ + ))} +
+ )} +
+ ); +} + +// NEW: Artist Vietnam Section with dynamic photos +function ArtistVietnamSection() { + // Popular Vietnamese artists + const artistNames = [ + "Sơn Tùng M-TP", + "HIEUTHUHAI", + "Đen Vâu", + "Hoàng Dũng", + "Vũ.", + "MONO", + "Tlinh", + "Erik", + ]; + + const [artistPhotos, setArtistPhotos] = useState>({}); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Fetch artist photos from API + const fetchArtistPhotos = async () => { + setLoading(true); + const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; + const photos: Record = {}; + + await Promise.all( + artistNames.map(async (name) => { + try { + const res = await fetch(`${apiUrl}/api/artist/info?name=${encodeURIComponent(name)}`); + if (res.ok) { + const data = await res.json(); + if (data.photo) { + photos[name] = data.photo; + } + } + } catch (e) { + console.error(`Failed to fetch photo for ${name}:`, e); + } + }) + ); + + setArtistPhotos(photos); + setLoading(false); + }; + + fetchArtistPhotos(); + }, []); + + return ( +
+
+ +

Artist Vietnam

+
+

Popular Vietnamese artists

+ + {/* Horizontal Scrollable Row */} +
+ {loading ? ( + // Skeleton Row + [1, 2, 3, 4, 5, 6].map(i => ( +
+ + +
+ )) + ) : ( + artistNames.map((name, i) => ( + +
+
+ +
+
+ +
+
+
+

{name}

+

Artist

+
+ + )) + )} +
+
+ ); +} + diff --git a/frontend/components/AddToPlaylistModal.tsx b/frontend/components/AddToPlaylistModal.tsx index 8009d17..d35d2d0 100644 --- a/frontend/components/AddToPlaylistModal.tsx +++ b/frontend/components/AddToPlaylistModal.tsx @@ -1,102 +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([]); - - 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 ( -
-
-
-

Add to Playlist

- -
- -
- {playlists.length === 0 ? ( -
No playlists found. Create one first!
- ) : ( - playlists.map((playlist) => ( -
handleAddToPlaylist(playlist.id)} - className="flex items-center gap-3 p-3 hover:bg-[#3e3e3e] rounded-md cursor-pointer transition text-white" - > -
- {playlist.cover_url && !playlist.cover_url.includes("placehold") ? ( - - ) : ( - 🎵 - )} -
- {playlist.title} -
- )) - )} -
- -
- -
-
-
- ); -} +"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([]); + + 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 ( +
+
+
+

Add to Playlist

+ +
+ +
+ {playlists.length === 0 ? ( +
No playlists found. Create one first!
+ ) : ( + playlists.map((playlist) => ( +
handleAddToPlaylist(playlist.id)} + className="flex items-center gap-3 p-3 hover:bg-[#3e3e3e] rounded-md cursor-pointer transition text-white" + > +
+ {playlist.cover_url && !playlist.cover_url.includes("placehold") ? ( + + ) : ( + 🎵 + )} +
+ {playlist.title} +
+ )) + )} +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/components/LyricsDetail.tsx b/frontend/components/LyricsDetail.tsx index 15ccbdc..a5e22d0 100644 --- a/frontend/components/LyricsDetail.tsx +++ b/frontend/components/LyricsDetail.tsx @@ -1,150 +1,150 @@ -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; - isInSidebar?: boolean; -} - -const LyricsDetail: React.FC = ({ track, currentTime, onClose, onSeek, isInSidebar = false }) => { - const [lyrics, setLyrics] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const scrollContainerRef = useRef(null); - const activeLineRef = useRef(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 ( -
- {/* Header - only show when NOT in sidebar */} - {!isInSidebar && ( -
-
-

Lyrics

-

- {track.artist} -

-
- -
- )} - - {/* Lyrics Container */} -
- {isLoading ? ( -
-
-
- ) : lyrics.length === 0 ? ( -
-

Looks like we don't have lyrics for this song.

-

Enjoy the vibe!

-
- ) : ( -
{/* Reverted to center padding, added max-width */} - {lyrics.map((line, index) => { - const isActive = index === activeIndex; - const isPast = index < activeIndex; - - return ( -
{ - if (onSeek) onSeek(line.time); - }} - > - {line.text} -
- ); - })} -
- )} -
-
- ); -}; - -export default LyricsDetail; +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; + isInSidebar?: boolean; +} + +const LyricsDetail: React.FC = ({ track, currentTime, onClose, onSeek, isInSidebar = false }) => { + const [lyrics, setLyrics] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const scrollContainerRef = useRef(null); + const activeLineRef = useRef(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 ( +
+ {/* Header - only show when NOT in sidebar */} + {!isInSidebar && ( +
+
+

Lyrics

+

+ {track.artist} +

+
+ +
+ )} + + {/* Lyrics Container */} +
+ {isLoading ? ( +
+
+
+ ) : lyrics.length === 0 ? ( +
+

Looks like we don't have lyrics for this song.

+

Enjoy the vibe!

+
+ ) : ( +
{/* Reverted to center padding, added max-width */} + {lyrics.map((line, index) => { + const isActive = index === activeIndex; + const isPast = index < activeIndex; + + return ( +
{ + if (onSeek) onSeek(line.time); + }} + > + {line.text} +
+ ); + })} +
+ )} +
+
+ ); +}; + +export default LyricsDetail; diff --git a/frontend/components/MobileNav.tsx b/frontend/components/MobileNav.tsx index 242db2a..549ae71 100644 --- a/frontend/components/MobileNav.tsx +++ b/frontend/components/MobileNav.tsx @@ -1,30 +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 ( -
- - - Home - - - - - Search - - - - - Library - -
- ); -} +"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 ( +
+ + + Home + + + + + Search + + + + + Library + +
+ ); +} diff --git a/frontend/components/PlayerBar.tsx b/frontend/components/PlayerBar.tsx index b7ab4dc..68ba0b9 100644 --- a/frontend/components/PlayerBar.tsx +++ b/frontend/components/PlayerBar.tsx @@ -11,6 +11,7 @@ import LyricsDetail from './LyricsDetail'; export default function PlayerBar() { const { currentTrack, isPlaying, isBuffering, togglePlay, setBuffering, likedTracks, toggleLike, nextTrack, prevTrack, shuffle, toggleShuffle, repeatMode, toggleRepeat, audioQuality, isLyricsOpen, toggleLyrics } = usePlayer(); const audioRef = useRef(null); + const wakeLockRef = useRef(null); const [progress, setProgress] = useState(0); const [duration, setDuration] = useState(0); const [volume, setVolume] = useState(1); @@ -22,6 +23,70 @@ export default function PlayerBar() { const [isFullScreenPlayerOpen, setIsFullScreenPlayerOpen] = useState(false); const [isCoverModalOpen, setIsCoverModalOpen] = useState(false); + // Wake Lock API - Keeps device awake during playback (for FiiO/Android) + useEffect(() => { + const requestWakeLock = async () => { + if ('wakeLock' in navigator && isPlaying) { + try { + wakeLockRef.current = await navigator.wakeLock.request('screen'); + console.log('Wake Lock acquired for background playback'); + + wakeLockRef.current.addEventListener('release', () => { + console.log('Wake Lock released'); + }); + } catch (err) { + console.log('Wake Lock not available:', err); + } + } + }; + + const releaseWakeLock = async () => { + if (wakeLockRef.current) { + await wakeLockRef.current.release(); + wakeLockRef.current = null; + } + }; + + if (isPlaying) { + requestWakeLock(); + } else { + releaseWakeLock(); + } + + // Re-acquire wake lock when page becomes visible again + const handleVisibilityChange = async () => { + if (document.visibilityState === 'visible' && isPlaying) { + await requestWakeLock(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + releaseWakeLock(); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [isPlaying]); + + // Prevent audio pause on visibility change (screen off) - Critical for FiiO + useEffect(() => { + const handleVisibilityChange = () => { + // When screen turns off, Android might pause audio + // We explicitly resume if we should be playing + if (document.visibilityState === 'hidden' && isPlaying && audioRef.current) { + // Use setTimeout to ensure audio continues after visibility change + setTimeout(() => { + if (audioRef.current && audioRef.current.paused && isPlaying) { + audioRef.current.play().catch(e => console.log('Resume on hidden:', e)); + } + }, 100); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, [isPlaying]); + useEffect(() => { if (currentTrack && audioRef.current && currentTrack.url) { // Prevent reloading if URL hasn't changed diff --git a/frontend/components/ServiceWorkerRegistration.tsx b/frontend/components/ServiceWorkerRegistration.tsx new file mode 100644 index 0000000..39721dd --- /dev/null +++ b/frontend/components/ServiceWorkerRegistration.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect } from "react"; + +export default function ServiceWorkerRegistration() { + useEffect(() => { + if ("serviceWorker" in navigator) { + navigator.serviceWorker + .register("/sw.js") + .then((registration) => { + console.log("Service Worker registered:", registration.scope); + }) + .catch((error) => { + console.error("Service Worker registration failed:", error); + }); + } + }, []); + + return null; +} diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index b53999a..81c06c6 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -1,232 +1,232 @@ -"use client"; - -import { Home, Search, Library, Plus, Heart, RefreshCcw } 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"; -import Logo from "./Logo"; -import CoverImage from "./CoverImage"; - -export default function Sidebar() { - const { likedTracks } = usePlayer(); - const { userPlaylists, libraryItems, refreshLibrary: refresh, activeFilter, setActiveFilter } = useLibrary(); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const [isUpdating, setIsUpdating] = useState(false); - const [updateStatus, setUpdateStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); - - 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(); - } - }; - - const handleUpdateYtdlp = async () => { - if (isUpdating) return; - setIsUpdating(true); - setUpdateStatus('loading'); - try { - const response = await fetch('/api/system/update-ytdlp', { method: 'POST' }); - if (response.ok) { - setUpdateStatus('success'); - setTimeout(() => setUpdateStatus('idle'), 5000); - } else { - setUpdateStatus('error'); - } - } catch (error) { - console.error("Failed to update yt-dlp:", error); - setUpdateStatus('error'); - } finally { - setIsUpdating(false); - } - }; - - // 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 ( - - ); -} - +"use client"; + +import { Home, Search, Library, Plus, Heart, RefreshCcw } 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"; +import Logo from "./Logo"; +import CoverImage from "./CoverImage"; + +export default function Sidebar() { + const { likedTracks } = usePlayer(); + const { userPlaylists, libraryItems, refreshLibrary: refresh, activeFilter, setActiveFilter } = useLibrary(); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [updateStatus, setUpdateStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + + 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(); + } + }; + + const handleUpdateYtdlp = async () => { + if (isUpdating) return; + setIsUpdating(true); + setUpdateStatus('loading'); + try { + const response = await fetch('/api/system/update-ytdlp', { method: 'POST' }); + if (response.ok) { + setUpdateStatus('success'); + setTimeout(() => setUpdateStatus('idle'), 5000); + } else { + setUpdateStatus('error'); + } + } catch (error) { + console.error("Failed to update yt-dlp:", error); + setUpdateStatus('error'); + } finally { + setIsUpdating(false); + } + }; + + // 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 ( + + ); +} + diff --git a/frontend/context/LibraryContext.tsx b/frontend/context/LibraryContext.tsx index 7507037..93644c2 100644 --- a/frontend/context/LibraryContext.tsx +++ b/frontend/context/LibraryContext.tsx @@ -1,151 +1,151 @@ - -"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; -} - -const LibraryContext = createContext(undefined); - -export function LibraryProvider({ children }: { children: React.ReactNode }) { - const [userPlaylists, setUserPlaylists] = useState([]); - const [libraryItems, setLibraryItems] = useState([]); - const [activeFilter, setActiveFilter] = useState('all'); - - const fetchAllData = async () => { - try { - // 1. User Playlists - const playlists = await dbService.getPlaylists(); - setUserPlaylists(playlists); - - // 2. Local/Backend Content - const browse = await libraryService.getBrowseContent(); - // Deduplicate by ID to avoid React duplicate key warnings - const browsePlaylistsRaw = Object.values(browse).flat(); - const seenIds = new Map(); - const browsePlaylists = browsePlaylistsRaw.filter((p: any) => { - if (seenIds.has(p.id)) return false; - seenIds.set(p.id, true); - return true; - }); - - 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 ( - - {children} - - ); -} - -export function useLibrary() { - const context = useContext(LibraryContext); - if (context === undefined) { - throw new Error("useLibrary must be used within a LibraryProvider"); - } - return context; -} + +"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; +} + +const LibraryContext = createContext(undefined); + +export function LibraryProvider({ children }: { children: React.ReactNode }) { + const [userPlaylists, setUserPlaylists] = useState([]); + const [libraryItems, setLibraryItems] = useState([]); + const [activeFilter, setActiveFilter] = useState('all'); + + const fetchAllData = async () => { + try { + // 1. User Playlists + const playlists = await dbService.getPlaylists(); + setUserPlaylists(playlists); + + // 2. Local/Backend Content + const browse = await libraryService.getBrowseContent(); + // Deduplicate by ID to avoid React duplicate key warnings + const browsePlaylistsRaw = Object.values(browse).flat(); + const seenIds = new Map(); + const browsePlaylists = browsePlaylistsRaw.filter((p: any) => { + if (seenIds.has(p.id)) return false; + seenIds.set(p.id, true); + return true; + }); + + 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 ( + + {children} + + ); +} + +export function useLibrary() { + const context = useContext(LibraryContext); + if (context === undefined) { + throw new Error("useLibrary must be used within a LibraryProvider"); + } + return context; +} diff --git a/frontend/context/PlayerContext.tsx b/frontend/context/PlayerContext.tsx index c84d0f1..63a46c2 100644 --- a/frontend/context/PlayerContext.tsx +++ b/frontend/context/PlayerContext.tsx @@ -1,279 +1,279 @@ -"use client"; - -import { createContext, useContext, useState, useEffect, ReactNode } from "react"; -import { dbService } from "@/services/db"; -import { Track, AudioQuality } from "@/types"; -import * as mm from 'music-metadata-browser'; - -interface PlayerContextType { - currentTrack: Track | null; - isPlaying: boolean; - isBuffering: boolean; - likedTracks: Set; - 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; - // Lyrics panel state - isLyricsOpen: boolean; - toggleLyrics: () => void; -} - -const PlayerContext = createContext(undefined); - -export function PlayerProvider({ children }: { children: ReactNode }) { - const [currentTrack, setCurrentTrack] = useState(null); - const [isPlaying, setIsPlaying] = useState(false); - const [isBuffering, setIsBuffering] = useState(false); - const [likedTracks, setLikedTracks] = useState>(new Set()); - const [likedTracksData, setLikedTracksData] = useState([]); - - // Audio Engine State - const [audioQuality, setAudioQuality] = useState(null); - const [preloadedBlobs, setPreloadedBlobs] = useState>(new Map()); - - // Queue State - const [queue, setQueue] = useState([]); - const [currentIndex, setCurrentIndex] = useState(-1); - const [shuffle, setShuffle] = useState(false); - const [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('none'); - - // History State - const [playHistory, setPlayHistory] = useState([]); - - // Lyrics Panel State - const [isLyricsOpen, setIsLyricsOpen] = useState(false); - const toggleLyrics = () => setIsLyricsOpen(prev => !prev); - - // 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 ( - - {children} - - ); -} - -export function usePlayer() { - const context = useContext(PlayerContext); - if (context === undefined) { - throw new Error("usePlayer must be used within a PlayerProvider"); - } - return context; -} +"use client"; + +import { createContext, useContext, useState, useEffect, ReactNode } from "react"; +import { dbService } from "@/services/db"; +import { Track, AudioQuality } from "@/types"; +import * as mm from 'music-metadata-browser'; + +interface PlayerContextType { + currentTrack: Track | null; + isPlaying: boolean; + isBuffering: boolean; + likedTracks: Set; + 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; + // Lyrics panel state + isLyricsOpen: boolean; + toggleLyrics: () => void; +} + +const PlayerContext = createContext(undefined); + +export function PlayerProvider({ children }: { children: ReactNode }) { + const [currentTrack, setCurrentTrack] = useState(null); + const [isPlaying, setIsPlaying] = useState(false); + const [isBuffering, setIsBuffering] = useState(false); + const [likedTracks, setLikedTracks] = useState>(new Set()); + const [likedTracksData, setLikedTracksData] = useState([]); + + // Audio Engine State + const [audioQuality, setAudioQuality] = useState(null); + const [preloadedBlobs, setPreloadedBlobs] = useState>(new Map()); + + // Queue State + const [queue, setQueue] = useState([]); + const [currentIndex, setCurrentIndex] = useState(-1); + const [shuffle, setShuffle] = useState(false); + const [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('none'); + + // History State + const [playHistory, setPlayHistory] = useState([]); + + // Lyrics Panel State + const [isLyricsOpen, setIsLyricsOpen] = useState(false); + const toggleLyrics = () => setIsLyricsOpen(prev => !prev); + + // 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 ( + + {children} + + ); +} + +export function usePlayer() { + const context = useContext(PlayerContext); + if (context === undefined) { + throw new Error("usePlayer must be used within a PlayerProvider"); + } + return context; +} diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 1834fd6..386d267 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,63 +1,63 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - // strict mode true is default but good to be explicit - // strict mode true is default but good to be explicit - reactStrictMode: true, - output: "standalone", - 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', destination: 'http://127.0.0.1:8000/api/search' }, - { source: '/api/search/:path*', destination: 'http://127.0.0.1:8000/api/search/:path*' }, - { source: '/api/artist/:path*', destination: 'http://127.0.0.1:8000/api/artist/: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*' }, - { source: '/api/recommendations/:path*', destination: 'http://127.0.0.1:8000/api/recommendations/:path*' }, - ]; - }, - images: { - remotePatterns: [ - { - protocol: 'https', - hostname: 'i.ytimg.com', - }, - { - protocol: 'https', - hostname: 'lh3.googleusercontent.com', - }, - { - protocol: 'https', - hostname: 'yt3.googleusercontent.com', - }, - { - protocol: 'https', - hostname: 'yt3.ggpht.com', - }, - { - protocol: 'https', - hostname: 'placehold.co', - }, - { - protocol: 'https', - hostname: 'images.unsplash.com', - }, - { - protocol: 'https', - hostname: 'misc.scdn.co', - }, - ], - }, -}; - -export default nextConfig; +/** @type {import('next').NextConfig} */ +const nextConfig = { + // strict mode true is default but good to be explicit + // strict mode true is default but good to be explicit + reactStrictMode: true, + output: "standalone", + 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', destination: 'http://127.0.0.1:8000/api/search' }, + { source: '/api/search/:path*', destination: 'http://127.0.0.1:8000/api/search/:path*' }, + { source: '/api/artist/:path*', destination: 'http://127.0.0.1:8000/api/artist/: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*' }, + { source: '/api/recommendations/:path*', destination: 'http://127.0.0.1:8000/api/recommendations/:path*' }, + ]; + }, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'i.ytimg.com', + }, + { + protocol: 'https', + hostname: 'lh3.googleusercontent.com', + }, + { + protocol: 'https', + hostname: 'yt3.googleusercontent.com', + }, + { + protocol: 'https', + hostname: 'yt3.ggpht.com', + }, + { + protocol: 'https', + hostname: 'placehold.co', + }, + { + protocol: 'https', + hostname: 'images.unsplash.com', + }, + { + protocol: 'https', + hostname: 'misc.scdn.co', + }, + ], + }, +}; + +export default nextConfig; diff --git a/frontend/public/icons/icon-192x192.png b/frontend/public/icons/icon-192x192.png index 8fd8414..2c5d065 100644 Binary files a/frontend/public/icons/icon-192x192.png and b/frontend/public/icons/icon-192x192.png differ diff --git a/frontend/public/icons/icon-512x512.png b/frontend/public/icons/icon-512x512.png index 55ec7f8..dc56392 100644 Binary files a/frontend/public/icons/icon-512x512.png and b/frontend/public/icons/icon-512x512.png differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 4b27f80..5f2f641 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -1,20 +1,26 @@ { - "name": "Spotify Clone", - "short_name": "Spotify", + "name": "Audiophile Web Player", + "short_name": "Audiophile", + "description": "High-Fidelity Local-First Music Player", "start_url": "/", + "scope": "/", "display": "standalone", + "orientation": "portrait", "background_color": "#121212", "theme_color": "#1DB954", + "categories": ["music", "entertainment"], "icons": [ { "src": "/icons/icon-192x192.png", "sizes": "192x192", - "type": "image/png" + "type": "image/png", + "purpose": "any maskable" }, { "src": "/icons/icon-512x512.png", "sizes": "512x512", - "type": "image/png" + "type": "image/png", + "purpose": "any maskable" } ] } \ No newline at end of file diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..870139a --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,96 @@ +const CACHE_NAME = 'audiophile-v1'; +const STATIC_ASSETS = [ + '/', + '/manifest.json', + '/icons/icon-192x192.png', + '/icons/icon-512x512.png' +]; + +// Install event - cache static assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(STATIC_ASSETS); + }) + ); + // Activate immediately + self.skipWaiting(); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => caches.delete(name)) + ); + }) + ); + // Take control of all pages immediately + self.clients.claim(); +}); + +// Fetch event - network first for API, cache first for static +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // Skip non-GET requests + if (event.request.method !== 'GET') return; + + // Skip streaming/audio requests - let them go directly to network + if (url.pathname.includes('/api/stream') || + url.pathname.includes('/api/download') || + event.request.headers.get('range')) { + return; + } + + // API requests - network first + if (url.pathname.startsWith('/api/')) { + event.respondWith( + fetch(event.request) + .catch(() => caches.match(event.request)) + ); + return; + } + + // Static assets - cache first, then network + event.respondWith( + caches.match(event.request).then((cachedResponse) => { + if (cachedResponse) { + // Return cached version, but also update cache in background + fetch(event.request).then((response) => { + if (response.ok) { + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, response); + }); + } + }).catch(() => {}); + return cachedResponse; + } + + // Not in cache - fetch from network + return fetch(event.request).then((response) => { + // Cache successful responses + if (response.ok && response.type === 'basic') { + const responseClone = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseClone); + }); + } + return response; + }); + }) + ); +}); + +// Handle background sync for offline actions (future enhancement) +self.addEventListener('sync', (event) => { + console.log('Background sync:', event.tag); +}); + +// Handle push notifications (future enhancement) +self.addEventListener('push', (event) => { + console.log('Push received:', event); +}); diff --git a/frontend/services/db.ts b/frontend/services/db.ts index 759e597..b040a5a 100644 --- a/frontend/services/db.ts +++ b/frontend/services/db.ts @@ -1,157 +1,157 @@ -import { openDB, DBSchema } from 'idb'; -import { Track, Playlist } from '@/types'; - -export type { Track, Playlist }; - -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(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; - } -}; +import { openDB, DBSchema } from 'idb'; +import { Track, Playlist } from '@/types'; + +export type { Track, Playlist }; + +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(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; + } +}; diff --git a/frontend/services/library.ts b/frontend/services/library.ts index 2995851..f115111 100644 --- a/frontend/services/library.ts +++ b/frontend/services/library.ts @@ -1,76 +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 { - // 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 { - // No-op in API mode - }, - - async getBrowseContent(): Promise> { - return await apiFetch('/browse'); - }, - - async getPlaylist(id: string): Promise { - try { - return await apiFetch(`/playlists/${id}`); - } catch (e) { - console.error("Failed to fetch playlist", id, e); - return null; - } - }, - - async getRecommendations(seedTrackId?: string): Promise { - // Use trending as recommendations for now - const data = await apiFetch('/trending'); - return data.tracks || []; - }, - - async getRecommendedAlbums(seedArtist?: string): Promise { - 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 { - 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"); - } -}; +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 { + // 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 { + // No-op in API mode + }, + + async getBrowseContent(): Promise> { + return await apiFetch('/browse'); + }, + + async getPlaylist(id: string): Promise { + try { + return await apiFetch(`/playlists/${id}`); + } catch (e) { + console.error("Failed to fetch playlist", id, e); + return null; + } + }, + + async getRecommendations(seedTrackId?: string): Promise { + // Use trending as recommendations for now + const data = await apiFetch('/trending'); + return data.tracks || []; + }, + + async getRecommendedAlbums(seedArtist?: string): Promise { + 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 { + 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"); + } +}; diff --git a/test_audio.py b/test_audio.py index 86a3fa7..d44089b 100644 --- a/test_audio.py +++ b/test_audio.py @@ -1,21 +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}") +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}")