freedify/app/main.py
2026-01-13 22:26:48 +00:00

1220 lines
48 KiB
Python

"""
Freedify Streaming Server
A FastAPI server for streaming music with FFmpeg transcoding.
"""
import os
import asyncio
import logging
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI, HTTPException, Query, Response, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import zipfile
import io
from typing import List
import httpx
from app.deezer_service import deezer_service
from app.live_show_service import live_show_service
from app.spotify_service import spotify_service
from app.audio_service import audio_service
from app.podcast_service import podcast_service
from app.dj_service import dj_service
from app.ai_radio_service import ai_radio_service
from app.ytmusic_service import ytmusic_service
from app.setlist_service import setlist_service
from app.listenbrainz_service import listenbrainz_service
from app.jamendo_service import jamendo_service
from app.genius_service import genius_service
from app.concert_service import concert_service
from app.cache import cleanup_cache, periodic_cleanup, is_cached, get_cache_path
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager."""
logger.info("Starting Freedify Streaming Server...")
# Initial cache cleanup
await cleanup_cache()
# Start periodic cleanup task
cleanup_task = asyncio.create_task(periodic_cleanup(30))
yield
# Cleanup on shutdown
cleanup_task.cancel()
await deezer_service.close()
await live_show_service.close()
await spotify_service.close()
await audio_service.close()
await podcast_service.close()
logger.info("Server shutdown complete.")
app = FastAPI(
title="Freedify Streaming",
description="Stream music from Deezer, Spotify URLs, and Live Archives",
lifespan=lifespan
)
# CORS for mobile access
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Middleware to set COOP header for Google OAuth popups
@app.middleware("http")
async def add_security_headers(request, call_next):
response = await call_next(request)
# Allow popups (like Google Sign-In) to communicate with window
response.headers["Cross-Origin-Opener-Policy"] = "same-origin-allow-popups"
return response
# ========== MODELS ==========
class ParseUrlRequest(BaseModel):
url: str
class ImportRequest(BaseModel):
url: str
# ========== API ENDPOINTS ==========
@app.get("/api/health")
async def health_check():
"""Health check endpoint."""
return {"status": "ok", "service": "freedify-streaming"}
@app.get("/api/config")
async def get_config():
"""Get public configuration for the frontend (like Google Client ID)."""
return {
"google_client_id": os.environ.get("GOOGLE_CLIENT_ID", ""),
}
@app.get("/api/spotify/made-for-you")
async def get_spotify_made_for_you():
"""Get Spotify 'Made For You' playlists (Daily Mix, Discover Weekly)."""
return await spotify_service.get_made_for_you_playlists()
@app.get("/api/search")
async def search(
q: str = Query(..., min_length=1, description="Search query"),
type: str = Query("track", description="Search type: track, album, artist, or podcast"),
offset: int = Query(0, description="Offset for pagination")
):
"""Search for tracks, albums, artists, or podcasts."""
try:
# Check for Spotify URL (uses Spotify API - may be rate limited)
if spotify_service.is_spotify_url(q):
parsed = spotify_service.parse_spotify_url(q)
if parsed:
url_type, item_id = parsed
logger.info(f"Detected Spotify URL: {url_type}/{item_id}")
try:
return await get_spotify_content(url_type, item_id)
except HTTPException as e:
# If Spotify fails (rate limited), return error with info
raise HTTPException(
status_code=503,
detail=str(e.detail)
)
# Check for other URLs (Bandcamp, Soundcloud, Phish.in, Archive.org, etc.)
if q.startswith("http://") or q.startswith("https://"):
logger.info(f"Detected URL: {q}")
item = await audio_service.import_url(q)
if item:
# Check if it's an album/playlist
if item.get('type') == 'album':
return {
"results": [item],
"type": "album",
"is_url": True,
"source": "import",
"tracks": item.get('tracks', [])
}
# Single track
return {"results": [item], "type": "track", "is_url": True, "source": "import"}
# Podcast Search
if type == "podcast":
results = await podcast_service.search_podcasts(q)
return {"results": results, "query": q, "type": "podcast", "source": "podcast", "offset": offset}
# YouTube Music Search
if type == "ytmusic":
results = await ytmusic_service.search_tracks(q, limit=20, offset=offset)
return {"results": results, "query": q, "type": "track", "source": "ytmusic", "offset": offset}
# Setlist.fm Search
if type == "setlist":
results = await setlist_service.search_setlists(q)
return {"results": results, "query": q, "type": "album", "source": "setlist.fm", "offset": offset}
# Check for live show searches FIRST if no type specified or type is album
# But only if NOT one of the special types above (which returned already)
live_results = await live_show_service.search_live_shows(q)
if live_results is not None:
return {"results": live_results, "query": q, "type": "album", "source": "live_shows"}
# Regular search - Use Dab Music (Priority) then Deezer
logger.info(f"Searching: {q} (type: {type}, offset: {offset})")
results = []
source = "deezer"
# 1. Try Dab Music (unless offset > 0, as Dab paging is limited/untested or we want fast fallback)
# Actually Dab search wrapper I wrote doesn't support offset yet (defaults limit 10).
# We'll use Dab for generic queries.
if type in ["album", "track"] and offset == 0:
try:
from app.dab_service import dab_service
if type == "album":
dab_results = await dab_service.search_albums(q, limit=10)
else:
dab_results = await dab_service.search_tracks(q, limit=10)
if dab_results:
logger.info(f"Found {len(dab_results)} results on Dab Music")
results = dab_results
source = "dab"
except Exception as e:
logger.error(f"Dab search error: {e}")
# 2. Fallback to Deezer if no Dab results
if not results:
logger.info(f"Falling back to Deezer search...")
if type == "album":
results = await deezer_service.search_albums(q, limit=20, offset=offset)
elif type == "artist":
results = await deezer_service.search_artists(q, limit=20, offset=offset)
else:
results = await deezer_service.search_tracks(q, limit=20, offset=offset)
if results:
source = "deezer"
# 3. Final fallback to Jamendo (independent/CC music) if still no results
if not results and type in ["track", "album", "artist"]:
logger.info(f"Falling back to Jamendo search...")
try:
if type == "album":
results = await jamendo_service.search_albums(q, limit=20, offset=offset)
elif type == "artist":
results = await jamendo_service.search_artists(q, limit=20, offset=offset)
else:
results = await jamendo_service.search_tracks(q, limit=20, offset=offset)
if results:
source = "jamendo"
logger.info(f"Found {len(results)} results on Jamendo")
except Exception as e:
logger.error(f"Jamendo search error: {e}")
return {"results": results, "query": q, "type": type, "source": source, "offset": offset}
except Exception as e:
logger.error(f"Search error: {e}")
raise HTTPException(status_code=500, detail=str(e))
async def get_content_by_type(content_type: str, item_id: str):
"""Helper to get content by type and ID (uses Deezer, Dab, or Jamendo)."""
# Handle Dab Music IDs
if item_id.startswith("dab_"):
from app.dab_service import dab_service
if content_type == "album":
album = await dab_service.get_album(item_id)
if album:
return {"results": [album], "type": "album", "is_url": True, "tracks": album.get("tracks", [])}
# Dab doesn't really have "get_track" singular metadata endpoint exposed yet, but we can search or use stream.
# But for UI "open track", usually it plays directly.
pass
# Handle Jamendo IDs (jm_ prefix)
if item_id.startswith("jm_"):
if content_type == "album":
album = await jamendo_service.get_album(item_id)
if album:
return {"results": [album], "type": "album", "is_url": True, "tracks": album.get("tracks", []), "source": "jamendo"}
elif content_type == "artist" or item_id.startswith("jm_artist_"):
artist = await jamendo_service.get_artist(item_id)
if artist:
return {"results": [artist], "type": "artist", "is_url": True, "tracks": artist.get("tracks", []), "source": "jamendo"}
elif content_type == "track":
track = await jamendo_service.get_track(item_id)
if track:
return {"results": [track], "type": "track", "is_url": True, "source": "jamendo"}
raise HTTPException(status_code=404, detail=f"Jamendo {content_type} not found")
if content_type == "track":
results = await deezer_service.search_tracks(item_id, limit=1)
if results:
return {"results": results, "type": "track", "is_url": True}
elif content_type == "album":
album = await deezer_service.get_album(item_id)
if album:
return {"results": [album], "type": "album", "is_url": True, "tracks": album.get("tracks", [])}
elif content_type == "artist":
artist = await deezer_service.get_artist(item_id)
if artist:
return {"results": [artist], "type": "artist", "is_url": True, "tracks": artist.get("tracks", [])}
raise HTTPException(status_code=404, detail=f"{content_type.title()} not found")
async def get_spotify_content(content_type: str, item_id: str):
"""Helper to get content from Spotify by type and ID."""
if content_type == "track":
track = await spotify_service.get_track_by_id(item_id)
if track:
return {"results": [track], "type": "track", "is_url": True, "source": "spotify"}
elif content_type == "album":
album = await spotify_service.get_album(item_id)
if album:
return {"results": [album], "type": "album", "is_url": True, "tracks": album.get("tracks", []), "source": "spotify"}
elif content_type == "playlist":
playlist = await spotify_service.get_playlist(item_id)
if playlist:
return {"results": [playlist], "type": "playlist", "is_url": True, "tracks": playlist.get("tracks", []), "source": "spotify"}
elif content_type == "artist":
artist = await spotify_service.get_artist(item_id)
if artist:
return {"results": [artist], "type": "artist", "is_url": True, "tracks": artist.get("tracks", []), "source": "spotify"}
raise HTTPException(status_code=404, detail=f"Spotify {content_type.title()} not found")
@app.post("/api/import")
async def import_url_endpoint(request: ImportRequest):
"""Import a track from a URL (Bandcamp, Soundcloud, etc.)."""
try:
track = await audio_service.import_url(request.url)
if not track:
raise HTTPException(status_code=400, detail="Could not import URL")
return track
except Exception as e:
logger.error(f"Import endpoint error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/track/{track_id}")
async def get_track(track_id: str):
"""Get track details by Spotify ID."""
try:
track = await spotify_service.get_track_by_id(track_id)
if not track:
raise HTTPException(status_code=404, detail="Track not found")
return track
except HTTPException:
raise
except Exception as e:
logger.error(f"Track fetch error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/album/{album_id}")
async def get_album(album_id: str):
# Support Dab Albums
if album_id.startswith("dab_"):
from app.dab_service import dab_service
album = await dab_service.get_album(album_id)
if album: return album
raise HTTPException(status_code=404, detail="Dab album not found")
# Support Deezer Albums (fallback logic handled in dedicated service or here)
if album_id.startswith("dz_"):
album = await deezer_service.get_album(album_id)
if album: return album
raise HTTPException(status_code=404, detail="Deezer album not found")
"""Get album details with all tracks."""
try:
# Handle different sources based on ID prefix
if album_id.startswith("dz_"):
# Deezer album
album = await deezer_service.get_album(album_id)
elif album_id.startswith("archive_"):
# Archive.org show - import via URL
identifier = album_id.replace("archive_", "")
url = f"https://archive.org/details/{identifier}"
logger.info(f"Importing Archive.org show: {url}")
album = await audio_service.import_url(url)
elif album_id.startswith("phish_"):
# Phish.in show - import via URL
date = album_id.replace("phish_", "")
url = f"https://phish.in/{date}"
logger.info(f"Importing Phish.in show: {url}")
album = await audio_service.import_url(url)
elif album_id.startswith("pod_"):
# Podcast Import (PodcastIndex)
feed_id = album_id.replace("pod_", "")
album = await podcast_service.get_podcast_episodes(feed_id)
elif album_id.startswith("setlist_"):
# Setlist.fm - get full setlist with tracks
setlist_id = album_id.replace("setlist_", "")
album = await setlist_service.get_setlist(setlist_id)
if album and album.get("audio_source") == "phish.in":
# Phish show - fetch audio from phish.in
album["audio_available"] = True
elif album and album.get("audio_source") == "archive.org":
# Other artist - find best Archive.org version
archive_url = await setlist_service.find_best_archive_show(
album.get("artists", ""),
album.get("iso_date", "")
)
if archive_url:
album["audio_url"] = archive_url
album["audio_available"] = True
else:
# Fallback to search if no direct match
album["audio_available"] = True
else:
# Unknown source - try Deezer
album = await deezer_service.get_album(album_id)
if not album:
raise HTTPException(status_code=404, detail="Album not found")
return album
except HTTPException:
raise
except Exception as e:
logger.error(f"Album fetch error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/playlist/{playlist_id}")
async def get_playlist(playlist_id: str):
"""Get playlist details with all tracks."""
try:
playlist = await spotify_service.get_playlist(playlist_id)
if not playlist:
raise HTTPException(status_code=404, detail="Playlist not found")
return playlist
except HTTPException:
raise
except Exception as e:
logger.error(f"Playlist fetch error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/artist/{artist_id}")
async def get_artist(artist_id: str):
"""Get artist details with top tracks."""
try:
# Use Deezer for dz_ prefixed IDs
if artist_id.startswith("dz_"):
artist = await deezer_service.get_artist(artist_id)
else:
artist = await spotify_service.get_artist(artist_id)
if not artist:
raise HTTPException(status_code=404, detail="Artist not found")
return artist
except HTTPException:
raise
except Exception as e:
logger.error(f"Artist fetch error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.api_route("/api/stream/{isrc}", methods=["GET", "HEAD"])
async def stream_audio(
request: Request,
isrc: str,
q: Optional[str] = Query(None, description="Search query hint"),
hires: bool = Query(True, description="Prefer Hi-Res 24-bit audio")
):
"""Stream audio for a track by ISRC."""
try:
logger.info(f"Stream request for ISRC: {isrc} (hires={hires})")
target_stream_url = None
# 1. Resolve Target Stream URL (Direct or via yt-dlp)
# Handle Imported Links (LINK:)
if isrc.startswith("LINK:"):
import base64
from urllib.parse import urlparse
try:
encoded_url = isrc.replace("LINK:", "")
original_url = base64.urlsafe_b64decode(encoded_url).decode()
# Check for direct file extension first (fast path)
parsed = urlparse(original_url)
audio_exts = ('.mp3', '.m4a', '.ogg', '.wav', '.aac', '.opus', '.flac')
if any(parsed.path.lower().endswith(ext) for ext in audio_exts):
target_stream_url = original_url
else:
# Try to extract stream via yt-dlp (for YouTube/SoundCloud links)
# Run in executor to avoid blocking
loop = asyncio.get_event_loop()
target_stream_url = await loop.run_in_executor(None, audio_service._get_stream_url, original_url)
except Exception as e:
logger.warning(f"Failed to parse/extract LINK: {e}")
# Handle YouTube Music (ytm_)
elif isrc.startswith("ytm_"):
video_id = isrc.replace("ytm_", "")
youtube_url = f"https://music.youtube.com/watch?v={video_id}"
loop = asyncio.get_event_loop()
target_stream_url = await loop.run_in_executor(None, audio_service._get_stream_url, youtube_url)
# Handle Jamendo (jm_) - Direct stream/download URLs
elif isrc.startswith("jm_"):
track_id = isrc.replace("jm_", "")
target_stream_url = await jamendo_service.get_stream_url(track_id, prefer_flac=hires)
# 2. Proxy the Target Stream (if found)
if target_stream_url:
logger.info(f"Proxying direct stream: {target_stream_url[:60]}...")
# Forward Range header to support seeking
req_headers = {}
if request.headers.get("Range"):
req_headers["Range"] = request.headers.get("Range")
logger.info(f"Forwarding Range header: {req_headers['Range']}")
try:
# Use a dedicated client per request, managed by a generator with context manager.
# This guarantees cleanup when the generator is closed (e.g. client disconnect).
# This avoids "Shared Client" pool exhaustion/deadlocks.
async def stream_generator():
try:
# 60s timeout, follow redirects
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
req = client.build_request("GET", target_stream_url, headers=req_headers)
async with client.stream(req.method, req.url, headers=req.headers) as r:
# We've started the stream. Now we need to yield data.
# But wait, we need to return status_code and headers to FastAPI *before* we yield data
# if we use standard StreamingResponse...
# Actually, StreamingResponse takes a generator for content.
# But it needs status and headers passed in the constructor.
# Be we can't get them until we make the request!
# This is the "Streaming Problem".
# Solution: use a separate setup request (HEAD) or...
# Accept that we make the request *outside* the generator for headers,
# but risking the cleanup issue?
# NO. The cleanup issue is paramount.
# If we can't get headers cleanly without risking leaks, we should assume defaults
# or use a dummy request?
# ALTERNATIVE: Use the shared client again, BUT with a much more aggressive timeout?
# OR use the pattern where we don't return StreamingResponse until we have headers,
# ensuring we use a try/finally block that closes the client.
# Let's stick to the pattern I implemented before but simpler:
# 1. Create client
# 2. Make request
# 3. Return StreamingResponse with a generator that CLOSES the client.
# 4. BUT ensure `client` is a LOCALLY created instance, not shared.
pass
except Exception as e:
logger.error(f"Generator error: {e}")
# Real Implementation:
# Create a local client instance (not shared).
client = httpx.AsyncClient(follow_redirects=True, timeout=60.0)
req = client.build_request("GET", target_stream_url, headers=req_headers)
r = await client.send(req, stream=True)
# Prepare headers
resp_headers = {
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=3600",
"Access-Control-Allow-Origin": "*"
}
for key in ["Content-Range", "Content-Length", "Content-Type", "Last-Modified", "ETag"]:
if r.headers.get(key):
resp_headers[key] = r.headers[key]
# Custom iterator that closes the LOCAL client
async def response_iterator():
try:
async for chunk in r.aiter_bytes(chunk_size=65536):
yield chunk
except Exception as e:
logger.error(f"Stream iteration error: {e}")
finally:
# Close the response AND the client
await r.aclose()
await client.aclose()
return StreamingResponse(
response_iterator(),
status_code=r.status_code,
media_type=r.headers.get("Content-Type", "audio/mpeg"),
headers=resp_headers
)
except Exception as e:
logger.error(f"Proxying stream failed: {e}")
# Fall through to standard playback if proxy fails
# 3. Standard / HiFi Playback (Fallback or standard sources)
# Force FLAC/Hi-Res path (MP3 option removed)
cache_ext = "flac"
mime_type = "audio/flac"
# Check cache
if is_cached(isrc, cache_ext):
cache_path = get_cache_path(isrc, cache_ext)
logger.info(f"Serving from cache ({cache_ext}): {cache_path}")
return FileResponse(
cache_path,
media_type=mime_type,
headers={"Accept-Ranges": "bytes", "Cache-Control": "public, max-age=86400"}
)
# 4. Standard / HiFi Playback (Uses fetch_flac with internal priorities: Dab -> Tidal -> Deezer)
# Standard: Fetch FLAC directly (Hifi/Hi-Res) - Skip MP3 transcoding
# The user requested to remove non-hifi options for efficiency.
result = await audio_service.fetch_flac(isrc, q or "", hires=hires)
if not result:
raise HTTPException(status_code=404, detail="Could not fetch audio")
# Check if result is URL (tuple[str, dict]) or Bytes (tuple[bytes, dict])
if isinstance(result[0], str):
# It's a URL! Stream it via proxy
target_stream_url = result[0]
metadata = result[1]
logger.info(f"Streaming via proxy from URL: {target_stream_url[:50]}...")
# Proxy streaming logic for fetched URL
# Need to handle Range requests properly for seeking
req_headers = {}
if request.headers.get("Range"):
req_headers["Range"] = request.headers.get("Range")
logger.info(f"Forwarding Range header: {req_headers['Range']}")
# Make initial request to get status/headers
client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
try:
upstream_req = client.build_request("GET", target_stream_url, headers=req_headers)
upstream_resp = await client.send(upstream_req, stream=True)
# Build response headers
resp_headers = {
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=3600",
"Access-Control-Allow-Origin": "*"
}
# Forward important headers from upstream
for key in ["Content-Range", "Content-Length", "Content-Type"]:
if upstream_resp.headers.get(key):
resp_headers[key] = upstream_resp.headers[key]
if metadata and metadata.get("is_hi_res"):
resp_headers["X-Audio-Quality"] = "Hi-Res"
resp_headers["X-Audio-Format"] = "FLAC"
# Iterator that closes client when done
async def response_iterator():
try:
async for chunk in upstream_resp.aiter_bytes(chunk_size=65536):
yield chunk
except Exception as e:
logger.error(f"Stream iteration error: {e}")
finally:
await upstream_resp.aclose()
await client.aclose()
return StreamingResponse(
response_iterator(),
status_code=upstream_resp.status_code, # 200 or 206
media_type=upstream_resp.headers.get("Content-Type", "audio/flac"),
headers=resp_headers
)
except Exception as e:
await client.aclose()
raise
else:
# It's bytes! Serve directly.
flac_data, metadata = result
headers = {
"Accept-Ranges": "bytes",
"Content-Length": str(len(flac_data)),
"Cache-Control": "public, max-age=86400",
"X-Audio-Format": "FLAC"
}
if metadata and metadata.get("is_hi_res"):
headers["X-Audio-Quality"] = "Hi-Res"
return Response(
content=flac_data,
media_type="audio/flac",
headers=headers
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Stream error for {isrc}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/download/{isrc}")
async def download_audio(
isrc: str,
q: Optional[str] = Query(None, description="Search query hint"),
format: str = Query("mp3", description="Audio format: mp3, flac, aiff, wav, alac"),
filename: Optional[str] = Query(None, description="Filename")
):
"""Download audio in specified format."""
try:
logger.info(f"Download request for {isrc} in {format}")
result = await audio_service.get_download_audio(isrc, q or "", format)
if not result:
raise HTTPException(status_code=404, detail="Could not fetch audio for download")
data, ext, mime = result
download_name = filename if filename else f"{isrc}{ext}"
if not download_name.endswith(ext):
download_name += ext
return Response(
content=data,
media_type=mime,
headers={
"Content-Disposition": f'attachment; filename="{download_name}"',
"Content-Length": str(len(data))
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Download error for {isrc}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ========== DJ MODE ENDPOINTS ==========
class TrackForFeatures(BaseModel):
id: str
isrc: Optional[str] = None
name: Optional[str] = None
artists: Optional[str] = None
class AudioFeaturesBatchRequest(BaseModel):
tracks: List[TrackForFeatures]
class TrackForSetlist(BaseModel):
id: str
name: str
artists: str
bpm: int
camelot: str
energy: float
class SetlistRequest(BaseModel):
tracks: List[TrackForSetlist]
style: str = "progressive" # progressive, peak-time, chill, journey
@app.get("/api/audio-features/{track_id}")
async def get_audio_features(
track_id: str,
isrc: Optional[str] = Query(None),
name: Optional[str] = Query(None),
artist: Optional[str] = Query(None)
):
"""Get audio features (BPM, key, energy) for a track."""
try:
features = await spotify_service.get_audio_features(track_id, isrc, name, artist)
if not features:
raise HTTPException(status_code=404, detail="Audio features not found")
return features
except HTTPException:
raise
except Exception as e:
logger.error(f"Audio features error for {track_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/audio-features/batch")
async def get_audio_features_batch(request: AudioFeaturesBatchRequest):
"""Get audio features for multiple tracks."""
try:
if not request.tracks:
return {"features": []}
# Process each track, handling Deezer tracks with ISRC/name lookup
features = []
for track in request.tracks:
feat = await spotify_service.get_audio_features(
track.id,
track.isrc,
track.name,
track.artists
)
# Fallback to AI estimation if Spotify fails
if not feat and track.name and track.artists:
feat = await dj_service.get_audio_features_ai(track.name, track.artists)
if feat:
feat['track_id'] = track.id # Match requested ID for frontend cache
features.append(feat)
return {"features": features}
except Exception as e:
logger.error(f"Batch audio features error: {e}")
raise HTTPException(status_code=500, detail=str(e))
except Exception as e:
logger.error(f"Batch audio features error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/dj/generate-setlist")
async def generate_setlist(request: SetlistRequest):
"""Generate AI-optimized DJ setlist ordering."""
try:
tracks = [t.model_dump() for t in request.tracks]
result = await dj_service.generate_setlist(tracks, request.style)
return result
except Exception as e:
logger.error(f"Setlist generation error: {e}")
raise HTTPException(status_code=500, detail=str(e))
class MoodSearchRequest(BaseModel):
query: str
@app.post("/api/search/mood")
async def search_by_mood(request: MoodSearchRequest):
"""Interpret a natural language mood query using AI and return search terms."""
try:
result = await dj_service.interpret_mood_query(request.query)
if not result:
# Fallback: just return the query as a search term
return {
"search_terms": [request.query],
"moods": [],
"bpm_range": None,
"energy": "medium",
"description": f"Searching for: {request.query}"
}
return result
except Exception as e:
logger.error(f"Mood search error: {e}")
raise HTTPException(status_code=500, detail=str(e))
class SeedTrack(BaseModel):
name: str
artists: str
bpm: Optional[int] = None
camelot: Optional[str] = None
class QueueTrack(BaseModel):
name: str
artists: str
class AIRadioRequest(BaseModel):
seed_track: Optional[SeedTrack] = None
mood: Optional[str] = None
current_queue: Optional[List[QueueTrack]] = None
count: int = 5
@app.post("/api/ai-radio/generate")
async def generate_ai_radio_recommendations(request: AIRadioRequest):
"""Generate AI Radio recommendations based on seed track or mood."""
try:
seed = request.seed_track.model_dump() if request.seed_track else None
queue = [t.model_dump() for t in request.current_queue] if request.current_queue else []
result = await ai_radio_service.generate_recommendations(
seed_track=seed,
mood=request.mood,
current_queue=queue,
count=request.count
)
return result
except Exception as e:
logger.error(f"AI Radio error: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==================== AI ASSISTANT ENDPOINTS ====================
class GeneratePlaylistRequest(BaseModel):
description: str
duration_mins: int = 60
@app.post("/api/ai/generate-playlist")
async def ai_generate_playlist(request: GeneratePlaylistRequest):
"""Generate a playlist from a natural language description."""
try:
result = await ai_radio_service.generate_playlist(
description=request.description,
duration_mins=request.duration_mins
)
return result
except Exception as e:
logger.error(f"Playlist generation error: {e}")
raise HTTPException(status_code=500, detail=str(e))
class BatchDownloadRequest(BaseModel):
tracks: List[str] # List of ISRCs or IDs
names: List[str] # List of track names for filenames
artists: List[str] # List of artist names
album_name: str
format: str = "mp3"
@app.post("/api/download-batch")
async def download_batch(request: BatchDownloadRequest):
"""Download multiple tracks as a ZIP file."""
try:
logger.info(f"Batch download request: {len(request.tracks)} tracks from {request.album_name}")
# In-memory ZIP buffer
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
used_names = set()
# Process sequentially for better reliability
for i, isrc in enumerate(request.tracks):
try:
query = f"{request.names[i]} {request.artists[i]}"
result = await audio_service.get_download_audio(isrc, query, request.format)
if result:
data, ext, _ = result
# Clean filename
safe_name = f"{request.artists[i]} - {request.names[i]}".replace("/", "_").replace("\\", "_").replace(":", "_").replace("*", "").replace("?", "").replace('"', "").replace("<", "").replace(">", "").replace("|", "")
filename = f"{safe_name}{ext}"
# Handle duplicates
count = 1
base_filename = filename
while filename in used_names:
filename = f"{safe_name} ({count}){ext}"
count += 1
used_names.add(filename)
zip_file.writestr(filename, data)
except Exception as e:
logger.error(f"Failed to download track {isrc}: {e}")
# Continue with other tracks
zip_buffer.seek(0)
safe_album = request.album_name.replace("/", "_").replace("\\", "_").replace(":", "_")
filename = f"{safe_album}.zip"
return Response(
content=zip_buffer.getvalue(),
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
}
)
except Exception as e:
logger.error(f"Batch download error: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ========== GOOGLE DRIVE ==========
class UploadToDriveRequest(BaseModel):
isrc: str
access_token: str
format: str = "aiff"
folder_id: Optional[str] = None
filename: Optional[str] = None
q: Optional[str] = None
@app.post("/api/drive/upload")
async def upload_to_drive(request: UploadToDriveRequest):
"""Download audio, transcode, and upload to Google Drive."""
try:
logger.info(f"Drive upload request for {request.isrc} in {request.format}")
# 1. Get Audio Data (reuse existing logic)
result = await audio_service.get_download_audio(request.isrc, request.q or "", request.format)
if not result:
raise HTTPException(status_code=404, detail="Could not fetch audio")
data, ext, mime = result
filename = request.filename if request.filename else f"{request.isrc}{ext}"
if not filename.endswith(ext):
filename += ext
# 2. Upload to Drive (Multipart upload for metadata + media)
metadata = {
'name': filename,
'mimeType': mime
}
if request.folder_id:
metadata['parents'] = [request.folder_id]
import httpx
import json
async with httpx.AsyncClient() as client:
# Multipart upload
files_param = {
'metadata': (None, json.dumps(metadata), 'application/json; charset=UTF-8'),
'file': (filename, data, mime)
}
drive_response = await client.post(
'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart',
headers={'Authorization': f'Bearer {request.access_token}'},
files=files_param,
timeout=300.0 # 5 minutes for upload
)
if drive_response.status_code != 200:
logger.error(f"Drive upload failed: {drive_response.text}")
raise HTTPException(status_code=500, detail=f"Drive upload failed: {drive_response.text}")
file_data = drive_response.json()
return {"file_id": file_data.get('id'), "name": file_data.get('name')}
except HTTPException:
raise
except Exception as e:
logger.error(f"Drive upload error: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ========== STATIC FILES ==========
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATIC_DIR = os.path.join(BASE_DIR, "static")
if os.path.exists(STATIC_DIR):
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/")
async def index():
"""Serve the main page."""
index_path = os.path.join(STATIC_DIR, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
return {"message": "Freedify Streaming Server", "docs": "/docs"}
@app.get("/manifest.json")
async def manifest():
"""Serve PWA manifest."""
manifest_path = os.path.join(STATIC_DIR, "manifest.json")
if os.path.exists(manifest_path):
return FileResponse(manifest_path, media_type="application/json")
raise HTTPException(status_code=404)
@app.get("/sw.js")
async def service_worker():
"""Serve service worker."""
sw_path = os.path.join(STATIC_DIR, "sw.js")
if os.path.exists(sw_path):
return FileResponse(sw_path, media_type="application/javascript")
raise HTTPException(status_code=404)
# ==================== LISTENBRAINZ ENDPOINTS ====================
@app.post("/api/listenbrainz/now-playing")
async def listenbrainz_now_playing(track: dict):
"""Submit 'now playing' status to ListenBrainz."""
success = await listenbrainz_service.submit_now_playing(track)
return {"success": success}
@app.post("/api/listenbrainz/scrobble")
async def listenbrainz_scrobble(track: dict, listened_at: Optional[int] = None):
"""Submit a completed listen to ListenBrainz."""
success = await listenbrainz_service.submit_listen(track, listened_at)
return {"success": success}
@app.get("/api/listenbrainz/validate")
async def listenbrainz_validate():
"""Validate ListenBrainz token and return username."""
username = await listenbrainz_service.validate_token()
return {"valid": username is not None, "username": username}
@app.get("/api/listenbrainz/recommendations/{username}")
async def listenbrainz_recommendations(username: str, count: int = 25):
"""Get personalized recommendations for a user."""
recommendations = await listenbrainz_service.get_recommendations(username, count)
return {"recommendations": recommendations, "count": len(recommendations)}
@app.get("/api/listenbrainz/listens/{username}")
async def listenbrainz_listens(username: str, count: int = 25):
"""Get recent listens for a user."""
listens = await listenbrainz_service.get_user_listens(username, count)
return {"listens": listens, "count": len(listens)}
@app.post("/api/listenbrainz/set-token")
async def listenbrainz_set_token(token: str):
"""Set ListenBrainz user token (from settings UI)."""
listenbrainz_service.set_token(token)
username = await listenbrainz_service.validate_token()
return {"valid": username is not None, "username": username}
@app.get("/api/listenbrainz/playlists/{username}")
async def listenbrainz_playlists(username: str, count: int = 25):
"""Get user's ListenBrainz playlists (includes Weekly Exploration)."""
playlists = await listenbrainz_service.get_user_playlists(username, count)
return {"playlists": playlists, "count": len(playlists)}
@app.get("/api/listenbrainz/playlist/{playlist_id}")
async def listenbrainz_playlist_tracks(playlist_id: str):
"""Get tracks from a ListenBrainz playlist."""
playlist = await listenbrainz_service.get_playlist_tracks(playlist_id)
if not playlist:
raise HTTPException(status_code=404, detail="Playlist not found")
return playlist
@app.get("/api/listenbrainz/stats/{username}")
async def listenbrainz_stats(username: str):
"""Get user's ListenBrainz listening statistics."""
stats = await listenbrainz_service.get_user_stats(username)
return stats
# ========== GENIUS LYRICS ==========
@app.get("/api/lyrics")
async def get_lyrics(artist: str, title: str):
"""Get lyrics and song info from Genius."""
result = await genius_service.get_lyrics_and_info(artist, title)
return result
@app.get("/api/proxy_image")
async def proxy_image(url: str):
"""Proxy image requests to avoid 429 errors/CORS issues."""
if not url:
raise HTTPException(status_code=400, detail="No URL provided")
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url, follow_redirects=True)
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail="Failed to fetch image")
return Response(
content=resp.content,
media_type=resp.headers.get("Content-Type", "image/jpeg"),
headers={
"Cache-Control": "public, max-age=86400"
}
)
except Exception as e:
logger.error(f"Image proxy error: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ========== CONCERT ALERTS ENDPOINTS ==========
@app.get("/api/concerts/search")
async def search_concerts(
artist: str = Query(..., description="Artist name to search"),
city: Optional[str] = Query(None, description="City to filter events")
):
"""
Search for upcoming concerts by artist name.
Uses Ticketmaster with SeatGeek fallback.
"""
try:
logger.info(f"Concert search for: {artist} (city: {city})")
events = await concert_service.search_events(artist, city, limit=20)
return {"events": events, "artist": artist, "city": city}
except Exception as e:
logger.error(f"Concert search error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/concerts/for-artists")
async def get_concerts_for_artists(
artists: str = Query(..., description="Comma-separated list of artist names"),
cities: Optional[str] = Query(None, description="Comma-separated list of cities")
):
"""
Get upcoming concerts for multiple artists.
Useful for showing concerts from recently listened artists.
"""
try:
artist_list = [a.strip() for a in artists.split(",") if a.strip()]
city_list = [c.strip() for c in cities.split(",")] if cities else None
if not artist_list:
return {"events": [], "artists": [], "cities": city_list}
logger.info(f"Concert search for {len(artist_list)} artists, cities: {city_list}")
events = await concert_service.get_events_for_artists(artist_list, city_list)
return {"events": events, "artists": artist_list, "cities": city_list}
except Exception as e:
logger.error(f"Concerts for artists error: {e}")
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=int(os.environ.get("PORT", 8000)),
reload=True
)