Compare commits

...

11 commits

5 changed files with 113 additions and 36 deletions

View file

@ -1,19 +1,36 @@
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
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.cache_manager import CacheManager
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()
cache = CacheManager()
# 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
@ -608,7 +625,8 @@ async def stream_audio(id: str):
"""
try:
# Check Cache for stream URL
cache_key = f"v2:stream:{id}" # v2 cache key for new format
# Check Cache for stream URL
cache_key = f"v8:stream:{id}" # v8 cache key - deep debug mode
cached_data = cache.get(cache_key)
stream_url = None
@ -626,6 +644,7 @@ async def stream_audio(id: str):
print(f"DEBUG: Fetching new stream URL for '{id}'")
url = f"https://www.youtube.com/watch?v={id}"
ydl_opts = {
# Try standard bestaudio but prefer m4a. Removed protocol constraint to see what we actually get.
'format': 'bestaudio[ext=m4a]/bestaudio/best',
'quiet': True,
'noplaylist': True,
@ -634,22 +653,7 @@ async def stream_audio(id: str):
'socket_timeout': 30,
'retries': 3,
'force_ipv4': True,
'extractor_args': {'youtube': {'player_client': ['ios', 'android']}},
}
if not stream_url:
print(f"DEBUG: Fetching new stream URL for '{id}'")
url = f"https://www.youtube.com/watch?v={id}"
ydl_opts = {
'format': 'bestaudio[ext=m4a][protocol^=http]/bestaudio[protocol^=http]/best[protocol^=http]', # Strictly exclude m3u8/HLS
'quiet': True,
'noplaylist': True,
'nocheckcertificate': True,
'geo_bypass': True,
'socket_timeout': 30,
'retries': 3,
'force_ipv4': True,
'extractor_args': {'youtube': {'player_client': ['android', 'web', 'ios']}}, # Android often gives good progressive streams
'extractor_args': {'youtube': {'player_client': ['ios', 'android', 'web']}},
}
try:
@ -660,48 +664,58 @@ async def stream_audio(id: str):
http_headers = info.get('http_headers', {}) # Get headers required for the URL
# Determine MIME type
if ext == 'm4a':
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}")
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)}")
print(f"DEBUG: yt-dlp extraction error: {type(ydl_error).__name__}: {str(ydl_error)}", flush=True)
raise ydl_error
if stream_url:
cache_data = {"url": stream_url, "mime": mime_type, "headers": http_headers}
cache.set(cache_key, cache_data, ttl_seconds=3600)
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}")
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
base_headers = cached_data.get('headers', {}) if 'cached_data' in locals() else http_headers
# 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:
print(f"DEBUG: Stream Pre-flight HTTP Error: {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=f"Upstream stream error: {http_err.response.status_code}")
raise HTTPException(status_code=500, detail=error_details)
except Exception as e:
print(f"DEBUG: Stream Connection Error: {e}")
print(f"Stream Connection Error: {e}")
raise HTTPException(status_code=500, detail=f"Stream connection failed: {str(e)}")
# Forward Content-Length if available
@ -716,7 +730,6 @@ async def stream_audio(id: str):
yield chunk
external_req.close()
except Exception as e:
print(f"DEBUG: Stream Iterator Error: {e}")
pass
return StreamingResponse(iterfile(), media_type=mime_type, headers=headers)

View file

@ -1,9 +1,20 @@
```python
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from backend.api.routes import router as api_router
from contextlib import asynccontextmanager
from backend.api import routes
from backend.scheduler import start_scheduler
import os
app = FastAPI(title="Spotify Clone Backend")
@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 = [

View file

@ -3,6 +3,7 @@ uvicorn==0.34.0
spotdl
pydantic==2.10.4
python-multipart==0.0.20
APScheduler==0.0.20
requests==2.32.3
yt-dlp==2024.12.23
ytmusicapi==1.9.1

51
backend/scheduler.py Normal file
View file

@ -0,0 +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

View file

@ -16,7 +16,8 @@
"next": "^14.2.0",
"postcss": "^8.5.6",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"sharp": "^0.33.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",