Compare commits
No commits in common. "9129b9ad5423c57a6bcdc9d37f51e44a0a7131ac" and "20edeefeaddc406c3d76a111beb2a4b610896581" have entirely different histories.
9129b9ad54
...
20edeefead
5 changed files with 36 additions and 113 deletions
|
|
@ -1,36 +1,19 @@
|
||||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Response
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import StreamingResponse, JSONResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
import requests
|
import requests
|
||||||
from backend.services.spotify import SpotifyService
|
from backend.cache_manager import CacheManager
|
||||||
from backend.services.cache import CacheManager
|
|
||||||
from backend.playlist_manager import PlaylistManager
|
from backend.playlist_manager import PlaylistManager
|
||||||
from backend.scheduler import update_ytdlp # Import update function
|
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
# Services (Assumed to be initialized elsewhere if not here, adhering to existing patterns)
|
cache = CacheManager()
|
||||||
# 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()
|
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:
|
def get_high_res_thumbnail(thumbnails: list) -> str:
|
||||||
"""
|
"""
|
||||||
Selects the best thumbnail and attempts to upgrade resolution
|
Selects the best thumbnail and attempts to upgrade resolution
|
||||||
|
|
@ -625,8 +608,7 @@ async def stream_audio(id: str):
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check Cache for stream URL
|
# Check Cache for stream URL
|
||||||
# Check Cache for stream URL
|
cache_key = f"v2:stream:{id}" # v2 cache key for new format
|
||||||
cache_key = f"v8:stream:{id}" # v8 cache key - deep debug mode
|
|
||||||
cached_data = cache.get(cache_key)
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
stream_url = None
|
stream_url = None
|
||||||
|
|
@ -644,7 +626,6 @@ async def stream_audio(id: str):
|
||||||
print(f"DEBUG: Fetching new stream URL for '{id}'")
|
print(f"DEBUG: Fetching new stream URL for '{id}'")
|
||||||
url = f"https://www.youtube.com/watch?v={id}"
|
url = f"https://www.youtube.com/watch?v={id}"
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
# Try standard bestaudio but prefer m4a. Removed protocol constraint to see what we actually get.
|
|
||||||
'format': 'bestaudio[ext=m4a]/bestaudio/best',
|
'format': 'bestaudio[ext=m4a]/bestaudio/best',
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
'noplaylist': True,
|
'noplaylist': True,
|
||||||
|
|
@ -653,7 +634,22 @@ async def stream_audio(id: str):
|
||||||
'socket_timeout': 30,
|
'socket_timeout': 30,
|
||||||
'retries': 3,
|
'retries': 3,
|
||||||
'force_ipv4': True,
|
'force_ipv4': True,
|
||||||
'extractor_args': {'youtube': {'player_client': ['ios', 'android', 'web']}},
|
'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
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -664,58 +660,48 @@ async def stream_audio(id: str):
|
||||||
http_headers = info.get('http_headers', {}) # Get headers required for the URL
|
http_headers = info.get('http_headers', {}) # Get headers required for the URL
|
||||||
|
|
||||||
# Determine MIME type
|
# Determine MIME type
|
||||||
if ext == 'm4a' or ext == 'mp4':
|
if ext == 'm4a':
|
||||||
mime_type = "audio/mp4"
|
mime_type = "audio/mp4"
|
||||||
elif ext == 'webm':
|
elif ext == 'webm':
|
||||||
mime_type = "audio/webm"
|
mime_type = "audio/webm"
|
||||||
else:
|
else:
|
||||||
mime_type = "audio/mpeg"
|
mime_type = "audio/mpeg"
|
||||||
|
|
||||||
print(f"DEBUG: Got stream URL format: {info.get('format')}, ext: {ext}, mime: {mime_type}", flush=True)
|
print(f"DEBUG: Got stream URL format: {info.get('format')}, ext: {ext}, mime: {mime_type}")
|
||||||
except Exception as ydl_error:
|
except Exception as ydl_error:
|
||||||
print(f"DEBUG: yt-dlp extraction error: {type(ydl_error).__name__}: {str(ydl_error)}", flush=True)
|
print(f"DEBUG: yt-dlp extraction error: {type(ydl_error).__name__}: {str(ydl_error)}")
|
||||||
raise ydl_error
|
raise ydl_error
|
||||||
|
|
||||||
if stream_url:
|
if stream_url:
|
||||||
cached_data = {"url": stream_url, "mime": mime_type, "headers": http_headers}
|
cache_data = {"url": stream_url, "mime": mime_type, "headers": http_headers}
|
||||||
cache.set(cache_key, cached_data, ttl_seconds=3600)
|
cache.set(cache_key, cache_data, ttl_seconds=3600)
|
||||||
|
|
||||||
if not stream_url:
|
if not stream_url:
|
||||||
raise HTTPException(status_code=404, detail="Audio stream not found")
|
raise HTTPException(status_code=404, detail="Audio stream not found")
|
||||||
|
|
||||||
print(f"Streaming {id} with Content-Type: {mime_type}", flush=True)
|
print(f"Streaming {id} with Content-Type: {mime_type}")
|
||||||
|
|
||||||
# Pre-open the connection to verify it works and get headers
|
# Pre-open the connection to verify it works and get headers
|
||||||
try:
|
try:
|
||||||
# Sanitize headers: prevent Host/Cookie conflicts, but keep User-Agent and Cookies
|
# Sanitize headers: prevent Host/Cookie conflicts, but keep User-Agent
|
||||||
base_headers = {}
|
base_headers = cached_data.get('headers', {}) if 'cached_data' in locals() else http_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 = {
|
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'),
|
'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/',
|
'Referer': 'https://www.youtube.com/',
|
||||||
'Accept': '*/*',
|
'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)
|
# 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 = requests.get(stream_url, stream=True, timeout=30, headers=req_headers, verify=False)
|
||||||
external_req.raise_for_status()
|
external_req.raise_for_status()
|
||||||
|
|
||||||
except requests.exceptions.HTTPError as http_err:
|
except requests.exceptions.HTTPError as http_err:
|
||||||
error_details = f"Upstream error: {http_err.response.status_code}"
|
print(f"DEBUG: Stream Pre-flight HTTP Error: {http_err}")
|
||||||
print(f"Stream Error: {error_details}")
|
|
||||||
# If 403/404/410, invalidate cache
|
# If 403/404/410, invalidate cache
|
||||||
if http_err.response.status_code in [403, 404, 410]:
|
if http_err.response.status_code in [403, 404, 410]:
|
||||||
cache.delete(cache_key)
|
cache.delete(cache_key)
|
||||||
raise HTTPException(status_code=500, detail=error_details)
|
raise HTTPException(status_code=500, detail=f"Upstream stream error: {http_err.response.status_code}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Stream Connection Error: {e}")
|
print(f"DEBUG: Stream Connection Error: {e}")
|
||||||
raise HTTPException(status_code=500, detail=f"Stream connection failed: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Stream connection failed: {str(e)}")
|
||||||
|
|
||||||
# Forward Content-Length if available
|
# Forward Content-Length if available
|
||||||
|
|
@ -730,6 +716,7 @@ async def stream_audio(id: str):
|
||||||
yield chunk
|
yield chunk
|
||||||
external_req.close()
|
external_req.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
print(f"DEBUG: Stream Iterator Error: {e}")
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return StreamingResponse(iterfile(), media_type=mime_type, headers=headers)
|
return StreamingResponse(iterfile(), media_type=mime_type, headers=headers)
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,9 @@
|
||||||
```python
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from contextlib import asynccontextmanager
|
from backend.api.routes import router as api_router
|
||||||
from backend.api import routes
|
|
||||||
from backend.scheduler import start_scheduler
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@asynccontextmanager
|
app = FastAPI(title="Spotify Clone Backend")
|
||||||
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
|
# CORS setup
|
||||||
origins = [
|
origins = [
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ uvicorn==0.34.0
|
||||||
spotdl
|
spotdl
|
||||||
pydantic==2.10.4
|
pydantic==2.10.4
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
APScheduler==0.0.20
|
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
yt-dlp==2024.12.23
|
yt-dlp==2024.12.23
|
||||||
ytmusicapi==1.9.1
|
ytmusicapi==1.9.1
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -16,8 +16,7 @@
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1"
|
||||||
"sharp": "^0.33.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue