import logging import time from urllib.parse import urljoin import httpx from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from src.channels import CHANNELS from src.config import settings from src.modules.discovery import find_live_video from src.modules.stream_extractor import extract_audio_stream logging.basicConfig( level=settings.LOG_LEVEL.upper(), format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) logger = logging.getLogger(__name__) app = FastAPI(title="Lofi Radio Backend", version="0.1.0") # Cache: video_id -> (stream_url, stream_type, expires_at) _stream_cache: dict[str, tuple[str, str, float]] = {} CACHE_TTL = 15 * 60 # 15 minutes def _get_cached_stream(video_id: str) -> tuple[str, str]: """Get or refresh cached stream for a video. Returns (url, stream_type).""" now = time.time() if video_id in _stream_cache: url, stype, expires = _stream_cache[video_id] if now < expires: return url, stype info = extract_audio_stream(video_id) if not info: raise HTTPException(status_code=503, detail="Unable to extract stream") _stream_cache[video_id] = (info["url"], info["streamType"], now + CACHE_TTL) return info["url"], info["streamType"] def _fetch_playlist(playlist_url: str) -> str: """Fetch HLS playlist content from YouTube.""" resp = httpx.get(playlist_url, timeout=15, follow_redirects=True, headers={ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://www.youtube.com/", }) if resp.status_code != 200: raise HTTPException(status_code=502, detail="Failed to fetch playlist") return resp.text def _rewrite_playlist(playlist_content: str, video_id: str) -> str: """Rewrite HLS playlist segment URIs to go through server proxy.""" proxy_base = f"/api/proxy/segment?video={video_id}" lines = playlist_content.split("\n") result = [] seg_idx = 0 for line in lines: stripped = line.strip() if stripped and not stripped.startswith("#"): result.append(f"{proxy_base}&idx={seg_idx}") seg_idx += 1 else: result.append(line) return "\n".join(result) def _extract_segments(playlist_content: str, playlist_url: str) -> list[str]: """Extract segment URLs from HLS playlist.""" segments = [] for line in playlist_content.split("\n"): stripped = line.strip() if stripped and not stripped.startswith("#"): seg_url = urljoin(playlist_url, stripped) segments.append(seg_url) return segments @app.get("/api/proxy/hls") def proxy_hls(video: str = Query(...)): """Proxy HLS playlist - fetches fresh playlist and rewrites segments.""" stream_url, stream_type = _get_cached_stream(video) if stream_type == "hls": content = _fetch_playlist(stream_url) rewritten = _rewrite_playlist(content, video) return StreamingResponse( iter([rewritten]), media_type="application/x-mpegURL", headers={"Cache-Control": "no-cache"}, ) else: m3u8 = ( f"#EXTM3U\n" f"#EXT-X-VERSION:3\n" f"#EXT-X-TARGETDURATION:30\n" f"#EXT-X-MEDIA-SEQUENCE:0\n" f"#EXTINF:30.0,\n" f"/api/proxy/segment?video={video}&idx=0\n" ) return StreamingResponse( iter([m3u8]), media_type="application/x-mpegURL", headers={"Cache-Control": "no-cache"}, ) @app.get("/api/proxy/segment") def proxy_segment(video: str = Query(...), idx: int = Query(...)): """Proxy individual HLS segment.""" stream_url, _ = _get_cached_stream(video) content = _fetch_playlist(stream_url) segments = _extract_segments(content, stream_url) if idx < 0 or idx >= len(segments): raise HTTPException(status_code=404, detail="Segment not found") seg_url = segments[idx] resp = httpx.get(seg_url, timeout=30, follow_redirects=True, headers={ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://www.youtube.com/", }) if resp.status_code != 200: raise HTTPException(status_code=502, detail="Failed to fetch segment") return StreamingResponse( iter([resp.content]), media_type="video/MP2T", headers={"Cache-Control": "no-cache"}, ) def _stream_direct(url: str): """Stream direct audio from YouTube.""" import urllib.request req = urllib.request.Request(url, headers={ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://www.youtube.com/", }) resp = urllib.request.urlopen(req, timeout=300) try: while True: chunk = resp.read(64 * 1024) if not chunk: break yield chunk finally: resp.close() @app.get("/api/proxy/audio") def proxy_audio(video: str = Query(...)): """Proxy direct audio stream.""" stream_url, stream_type = _get_cached_stream(video) if stream_type != "direct": raise HTTPException(status_code=503, detail="Not a direct stream") return StreamingResponse( _stream_direct(stream_url), media_type="audio/*", headers={"Cache-Control": "no-cache"}, ) app.add_middleware( CORSMiddleware, allow_origins=[ "http://localhost:5173", "http://localhost:5175", "http://frontend:80", ], allow_methods=["GET"], allow_headers=["*"], ) @app.get("/api/channels") def list_channels() -> list[dict]: """List all channels with thumbnails.""" return [ { "id": channel["id"], "name": channel["name"], "handle": channel.get("handle", ""), "description": channel.get("description", ""), "isLive": True, "videoId": None, "thumbnail": channel.get("thumbnail"), } for channel in CHANNELS ] @app.get("/api/channels/{channel_id}/live") async def check_channel_live(channel_id: str) -> dict: """Check if a specific channel is currently live.""" channel = next((c for c in CHANNELS if c["id"] == channel_id), None) if not channel: raise HTTPException(status_code=404, detail="Channel not found") video_id, thumbnail = await find_live_video(channel["id"], channel.get("handle", "")) return { "channelId": channel_id, "name": channel["name"], "isLive": video_id is not None, "videoId": video_id, "thumbnail": thumbnail, } @app.get("/api/channel/{channel_id}/latest") async def get_channel_latest(channel_id: str) -> dict: """Find the latest video for a channel.""" channel = next((c for c in CHANNELS if c["id"] == channel_id), None) if not channel: raise HTTPException(status_code=404, detail="Channel not found") video_id, thumbnail = await find_live_video(channel["id"], channel.get("handle", "")) if not video_id: raise HTTPException(status_code=404, detail="No videos found for this channel") return { "channelId": channel_id, "videoId": video_id, "thumbnail": thumbnail, } @app.get("/api/stream/{video_id}") def get_stream(video_id: str) -> dict: """Get proxied stream URL for a YouTube video.""" stream_info = extract_audio_stream(video_id) if not stream_info: raise HTTPException( status_code=503, detail="Unable to extract stream for this video" ) # Cache the stream URL (server's IP-bound) _stream_cache[video_id] = ( stream_info["url"], stream_info["streamType"], time.time() + CACHE_TTL ) if stream_info["streamType"] == "hls": stream_info["url"] = f"/api/proxy/hls?video={video_id}" else: stream_info["url"] = f"/api/proxy/audio?video={video_id}" return stream_info @app.get("/api/now-playing") async def now_playing() -> dict: """Get the current active live stream from any channel.""" for channel in CHANNELS: video_id, thumbnail = await find_live_video(channel["id"], channel.get("handle", "")) if video_id: stream_info = extract_audio_stream(video_id) if stream_info: _stream_cache[video_id] = ( stream_info["url"], stream_info["streamType"], time.time() + CACHE_TTL ) if stream_info["streamType"] == "hls": stream_info["url"] = f"/api/proxy/hls?video={video_id}" else: stream_info["url"] = f"/api/proxy/audio?video={video_id}" stream_info["channel"] = { "id": channel["id"], "name": channel["name"], "handle": channel.get("handle", ""), "description": channel.get("description", ""), } stream_info["thumbnail"] = thumbnail return stream_info return {"channel": None, "videoId": None, "url": None}