"""Episode listing, detail, and streaming endpoints.""" from __future__ import annotations import os import uuid from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Header, Query from fastapi.responses import StreamingResponse, Response from sqlalchemy import select, and_ from sqlalchemy.ext.asyncio import AsyncSession from starlette.requests import Request from app.api.deps import get_user_id, get_db_session from app.config import CONTENT_TYPES from app.models import Episode, Show, Progress router = APIRouter(prefix="/api/episodes", tags=["episodes"]) # ── Named convenience endpoints (must be before /{episode_id}) ── @router.get("/recent") async def recent_episodes( limit: int = Query(20, ge=1, le=100), user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): """Recently played episodes ordered by last_played_at.""" stmt = ( select(Episode, Show, Progress) .join(Progress, Progress.episode_id == Episode.id) .join(Show, Show.id == Episode.show_id) .where(Progress.user_id == user_id) .order_by(Progress.last_played_at.desc()) .limit(limit) ) result = await db.execute(stmt) return [_format(ep, show, prog) for ep, show, prog in result.all()] @router.get("/in-progress") async def in_progress_episodes( limit: int = Query(20, ge=1, le=100), user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): """Episodes with progress > 0 and not completed.""" stmt = ( select(Episode, Show, Progress) .join(Progress, Progress.episode_id == Episode.id) .join(Show, Show.id == Episode.show_id) .where( Progress.user_id == user_id, Progress.position_seconds > 0, Progress.is_completed == False, # noqa: E712 ) .order_by(Progress.last_played_at.desc()) .limit(limit) ) result = await db.execute(stmt) return [_format(ep, show, prog) for ep, show, prog in result.all()] # ── List ── @router.get("") async def list_episodes( show_id: Optional[str] = Query(None), status: Optional[str] = Query(None), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): """List episodes with optional filters: show_id, status (unplayed|in_progress|completed).""" stmt = ( select(Episode, Show, Progress) .join(Show, Show.id == Episode.show_id) .outerjoin(Progress, and_(Progress.episode_id == Episode.id, Progress.user_id == user_id)) .where(Episode.user_id == user_id) ) if show_id: stmt = stmt.where(Episode.show_id == uuid.UUID(show_id)) if status == "unplayed": stmt = stmt.where(Progress.id == None) # noqa: E711 elif status == "in_progress": stmt = stmt.where(Progress.position_seconds > 0, Progress.is_completed == False) # noqa: E712 elif status == "completed": stmt = stmt.where(Progress.is_completed == True) # noqa: E712 stmt = stmt.order_by(Episode.published_at.desc().nullslast()).offset(offset).limit(limit) result = await db.execute(stmt) return [_format(ep, show, prog) for ep, show, prog in result.all()] # ── Detail ── @router.get("/{episode_id}") async def get_episode( episode_id: str, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): """Episode detail with progress.""" stmt = ( select(Episode, Show, Progress) .join(Show, Show.id == Episode.show_id) .outerjoin(Progress, and_(Progress.episode_id == Episode.id, Progress.user_id == user_id)) .where(Episode.id == uuid.UUID(episode_id), Episode.user_id == user_id) ) row = (await db.execute(stmt)).first() if not row: raise HTTPException(404, "Episode not found") ep, show, prog = row return _format(ep, show, prog) # ── Stream local audio ── @router.get("/{episode_id}/stream") async def stream_episode( episode_id: str, request: Request, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): """Stream local audio file with HTTP Range support for seeking.""" ep = await db.get(Episode, uuid.UUID(episode_id)) if not ep or ep.user_id != user_id: raise HTTPException(404, "Episode not found") show = await db.get(Show, ep.show_id) if not show or show.show_type != "local": raise HTTPException(400, "Streaming only available for local episodes") file_path = ep.audio_url if not file_path or not os.path.isfile(file_path): raise HTTPException(404, "Audio file not found") file_size = os.path.getsize(file_path) ext = os.path.splitext(file_path)[1].lower() content_type = CONTENT_TYPES.get(ext, "application/octet-stream") range_header = request.headers.get("range") return _range_response(file_path, file_size, content_type, range_header) # ── Helpers ── def _format(ep: Episode, show: Show, prog: Optional[Progress]) -> dict: return { "id": str(ep.id), "show_id": str(ep.show_id), "title": ep.title, "description": ep.description, "audio_url": ep.audio_url, "duration_seconds": ep.duration_seconds, "file_size_bytes": ep.file_size_bytes, "published_at": ep.published_at.isoformat() if ep.published_at else None, "artwork_url": ep.artwork_url or show.artwork_url, "show": { "id": str(show.id), "title": show.title, "author": show.author, "artwork_url": show.artwork_url, "show_type": show.show_type, }, "progress": { "position_seconds": prog.position_seconds, "duration_seconds": prog.duration_seconds, "is_completed": prog.is_completed, "playback_speed": prog.playback_speed, "last_played_at": prog.last_played_at.isoformat() if prog.last_played_at else None, } if prog else None, } def _range_response(file_path: str, file_size: int, content_type: str, range_header: Optional[str]): """Build a streaming response with optional Range support for seeking.""" if range_header and range_header.startswith("bytes="): range_spec = range_header[6:] parts = range_spec.split("-") start = int(parts[0]) if parts[0] else 0 end = int(parts[1]) if len(parts) > 1 and parts[1] else file_size - 1 end = min(end, file_size - 1) if start >= file_size: return Response(status_code=416, headers={"Content-Range": f"bytes */{file_size}"}) length = end - start + 1 def iter_range(): with open(file_path, "rb") as f: f.seek(start) remaining = length while remaining > 0: chunk = f.read(min(65536, remaining)) if not chunk: break remaining -= len(chunk) yield chunk return StreamingResponse( iter_range(), status_code=206, media_type=content_type, headers={ "Content-Range": f"bytes {start}-{end}/{file_size}", "Content-Length": str(length), "Accept-Ranges": "bytes", }, ) def iter_file(): with open(file_path, "rb") as f: while chunk := f.read(65536): yield chunk return StreamingResponse( iter_file(), media_type=content_type, headers={ "Content-Length": str(file_size), "Accept-Ranges": "bytes", }, )