From 74748ec86fee5b42b93d6d46b7d18a54b683029f Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 1 Jan 2026 13:43:26 +0700 Subject: [PATCH] Fix: Add fallback logic for stream fetching and implement browse endpoint --- backend/api/endpoints/browse.py | 15 ++++ backend/main.py | 13 ++-- backend/services/youtube.py | 128 ++++++++++++++++++++++++-------- 3 files changed, 120 insertions(+), 36 deletions(-) create mode 100644 backend/api/endpoints/browse.py diff --git a/backend/api/endpoints/browse.py b/backend/api/endpoints/browse.py new file mode 100644 index 0000000..b959368 --- /dev/null +++ b/backend/api/endpoints/browse.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter, Depends +from backend.services.youtube import YouTubeService + +router = APIRouter() + +def get_youtube_service(): + return YouTubeService() + +@router.get("/browse") +async def get_browse_content(yt: YouTubeService = Depends(get_youtube_service)): + return yt.get_home() + +@router.get("/trending") +async def get_trending(yt: YouTubeService = Depends(get_youtube_service)): + return yt.get_trending() diff --git a/backend/main.py b/backend/main.py index 54de65b..b655c14 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,15 +5,15 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse import os -from backend.core.config import settings # Renamed to settings_config to avoid conflict -from backend.api.endpoints import playlists, search, stream, lyrics, settings as settings_router # Aliased settings router +from backend.core.config import settings as settings_config # Renamed to settings_config to avoid conflict +from backend.api.endpoints import playlists, search, stream, lyrics, settings as settings_router, browse # Aliased settings router -app = FastAPI(title=settings.APP_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json") # Used settings_config +app = FastAPI(title=settings_config.APP_NAME, openapi_url=f"{settings_config.API_V1_STR}/openapi.json") # Used settings_config # CORS setup app.add_middleware( CORSMiddleware, - allow_origins=settings.BACKEND_CORS_ORIGINS, # Used settings_config + allow_origins=settings_config.BACKEND_CORS_ORIGINS, # Used settings_config allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -25,12 +25,13 @@ api_router.include_router(playlists.router, prefix="/playlists", tags=["playlist api_router.include_router(search.router, tags=["search"]) api_router.include_router(stream.router, tags=["stream"]) api_router.include_router(lyrics.router, tags=["lyrics"]) +api_router.include_router(browse.router, tags=["browse"]) api_router.include_router(settings_router.router, prefix="/settings", tags=["settings"]) # Included settings_router -app.include_router(api_router, prefix=settings.API_V1_STR) # Corrected prefix and removed extra tags +app.include_router(api_router, prefix=settings_config.API_V1_STR) # Corrected prefix and removed extra tags # Serve Static Frontend (Production Mode) -if settings.CACHE_DIR.parent.name == "backend": +if settings_config.CACHE_DIR.parent.name == "backend": # assuming running from root STATIC_DIR = "static" else: diff --git a/backend/services/youtube.py b/backend/services/youtube.py index 7755ad7..2976c85 100644 --- a/backend/services/youtube.py +++ b/backend/services/youtube.py @@ -159,36 +159,51 @@ class YouTubeService: cached = self.cache.get(cache_key) if cached: return cached - try: - url = f"https://www.youtube.com/watch?v={id}" - ydl_opts = { - 'format': 'bestaudio[ext=m4a]/best[ext=mp4]/best', - 'quiet': True, - 'noplaylist': True, - 'force_ipv4': True, - # Use mobile clients to avoid web scraping blocks - 'extractor_args': { - 'youtube': { - 'player_client': ['ios', 'android', 'web'] + # Strategy: Try versatile clients in order + clients_to_try = [ + # 1. iOS (often best for audio) + {'extractor_args': {'youtube': {'player_client': ['ios']}}}, + # 2. Android (robust) + {'extractor_args': {'youtube': {'player_client': ['android']}}}, + # 3. Web (standard, prone to 403) + {'extractor_args': {'youtube': {'player_client': ['web']}}}, + # 4. TV (sometimes works for age-gated) + {'extractor_args': {'youtube': {'player_client': ['tv']}}}, + ] + + last_error = None + + for client_config in clients_to_try: + try: + url = f"https://www.youtube.com/watch?v={id}" + ydl_opts = { + 'format': 'bestaudio[ext=m4a]/best[ext=mp4]/best', + 'quiet': True, + 'noplaylist': True, + 'force_ipv4': True, + } + ydl_opts.update(client_config) + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=False) + stream_url = info.get('url') + + if stream_url: + headers = info.get('http_headers', {}) + result = { + "url": stream_url, + "headers": headers } - } - } - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(url, download=False) - stream_url = info.get('url') - - if stream_url: - # Extract headers that yt-dlp used/recommends - headers = info.get('http_headers', {}) - result = { - "url": stream_url, - "headers": headers - } - self.cache.set(cache_key, result, ttl_seconds=3600) - return result - raise ResourceNotFound("Stream not found") - except Exception as e: - raise ExternalAPIError(str(e)) + self.cache.set(cache_key, result, ttl_seconds=3600) + return result + except Exception as e: + last_error = e + print(f"Fetch failed with client {client_config}: {e}") + continue + + # If all fail + print(f"All clients failed for {id}. Last error: {last_error}") + raise ExternalAPIError(str(last_error)) def invalidate_stream_cache(self, id: str): cache_key = f"stream:{id}" @@ -228,6 +243,59 @@ class YouTubeService: response = {"tracks": tracks} self.cache.set(cache_key, response, ttl_seconds=3600) return response - except Exception as e: print(f"Rec Error: {e}") return {"tracks": []} + + def get_home(self): + cache_key = "home:browse" + cached = self.cache.get(cache_key) + if cached: return cached + + try: + # ytmusicapi `get_home` returns complex Sections + # For simplicity, we'll fetch charts and new releases as "Browse" content + charts = self.yt.get_charts(country="US") + + # Formating Charts + trending_songs = [] + if 'videos' in charts and 'items' in charts['videos']: + for track in charts['videos']['items']: + trending_songs.append({ + "title": track.get('title', 'Unknown'), + "artist": self._extract_artist_names(track), + "album": "Trending", + "duration": 0, # Charts often lack duration + "cover_url": self._get_high_res_thumbnail(track.get('thumbnails', [])), + "id": track.get('videoId'), + "url": f"https://music.youtube.com/watch?v={track.get('videoId')}" + }) + + # New Releases (using search for "New Songs" as proxy or actual new releases if supported) + # Actually ytmusicapi has get_new_releases usually under get_charts or specific calls + # We'll use get_charts "trending" for "Trending" category + # And maybe "Top Songs" for "Top Hits" + + response = { + "Trending": [{ + "id": "trending", + "title": "Trending Now", + "description": "Top music videos right now", + "cover_url": trending_songs[0]['cover_url'] if trending_songs else "", + "tracks": trending_songs, + "type": "Playlist", + "creator": "YouTube Charts" + }] + } + + self.cache.set(cache_key, response, ttl_seconds=3600) + return response + except Exception as e: + print(f"Home Error: {e}") + return {} + + def get_trending(self): + # Dedicated trending endpoint + home = self.get_home() + if "Trending" in home and home["Trending"]: + return {"tracks": home["Trending"][0]["tracks"]} + return {"tracks": []}