Fix: Add fallback logic for stream fetching and implement browse endpoint
This commit is contained in:
parent
701b146dc2
commit
74748ec86f
3 changed files with 120 additions and 36 deletions
15
backend/api/endpoints/browse.py
Normal file
15
backend/api/endpoints/browse.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -5,15 +5,15 @@ from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from backend.core.config import settings # Renamed to settings_config to avoid conflict
|
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 # Aliased settings router
|
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
|
# CORS setup
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.BACKEND_CORS_ORIGINS, # Used settings_config
|
allow_origins=settings_config.BACKEND_CORS_ORIGINS, # Used settings_config
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
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(search.router, tags=["search"])
|
||||||
api_router.include_router(stream.router, tags=["stream"])
|
api_router.include_router(stream.router, tags=["stream"])
|
||||||
api_router.include_router(lyrics.router, tags=["lyrics"])
|
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
|
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)
|
# Serve Static Frontend (Production Mode)
|
||||||
if settings.CACHE_DIR.parent.name == "backend":
|
if settings_config.CACHE_DIR.parent.name == "backend":
|
||||||
# assuming running from root
|
# assuming running from root
|
||||||
STATIC_DIR = "static"
|
STATIC_DIR = "static"
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,21 @@ class YouTubeService:
|
||||||
cached = self.cache.get(cache_key)
|
cached = self.cache.get(cache_key)
|
||||||
if cached: return cached
|
if cached: return cached
|
||||||
|
|
||||||
|
# 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:
|
try:
|
||||||
url = f"https://www.youtube.com/watch?v={id}"
|
url = f"https://www.youtube.com/watch?v={id}"
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
|
|
@ -166,19 +181,14 @@ class YouTubeService:
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
'noplaylist': True,
|
'noplaylist': True,
|
||||||
'force_ipv4': True,
|
'force_ipv4': True,
|
||||||
# Use mobile clients to avoid web scraping blocks
|
|
||||||
'extractor_args': {
|
|
||||||
'youtube': {
|
|
||||||
'player_client': ['ios', 'android', 'web']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ydl_opts.update(client_config)
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(url, download=False)
|
info = ydl.extract_info(url, download=False)
|
||||||
stream_url = info.get('url')
|
stream_url = info.get('url')
|
||||||
|
|
||||||
if stream_url:
|
if stream_url:
|
||||||
# Extract headers that yt-dlp used/recommends
|
|
||||||
headers = info.get('http_headers', {})
|
headers = info.get('http_headers', {})
|
||||||
result = {
|
result = {
|
||||||
"url": stream_url,
|
"url": stream_url,
|
||||||
|
|
@ -186,9 +196,14 @@ class YouTubeService:
|
||||||
}
|
}
|
||||||
self.cache.set(cache_key, result, ttl_seconds=3600)
|
self.cache.set(cache_key, result, ttl_seconds=3600)
|
||||||
return result
|
return result
|
||||||
raise ResourceNotFound("Stream not found")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ExternalAPIError(str(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):
|
def invalidate_stream_cache(self, id: str):
|
||||||
cache_key = f"stream:{id}"
|
cache_key = f"stream:{id}"
|
||||||
|
|
@ -228,6 +243,59 @@ class YouTubeService:
|
||||||
response = {"tracks": tracks}
|
response = {"tracks": tracks}
|
||||||
self.cache.set(cache_key, response, ttl_seconds=3600)
|
self.cache.set(cache_key, response, ttl_seconds=3600)
|
||||||
return response
|
return response
|
||||||
except Exception as e:
|
|
||||||
print(f"Rec Error: {e}")
|
print(f"Rec Error: {e}")
|
||||||
return {"tracks": []}
|
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": []}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue