"""Playback control endpoints — play, pause, seek, complete, now-playing, speed.""" from __future__ import annotations import uuid from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select, and_ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_user_id, get_db_session from app.models import Episode, Show, Progress, PlaybackEvent router = APIRouter(prefix="/api/playback", tags=["playback"]) # ── Schemas ── class PlayRequest(BaseModel): episode_id: str position_seconds: float = 0 class PauseRequest(BaseModel): episode_id: str position_seconds: float class SeekRequest(BaseModel): episode_id: str position_seconds: float class CompleteRequest(BaseModel): episode_id: str class SpeedRequest(BaseModel): speed: float # ── Helpers ── async def _get_or_create_progress( db: AsyncSession, user_id: str, episode_id: uuid.UUID ) -> Progress: """Get existing progress or create a new one.""" stmt = select(Progress).where( Progress.user_id == user_id, Progress.episode_id == episode_id, ) prog = (await db.execute(stmt)).scalar_one_or_none() if not prog: ep = await db.get(Episode, episode_id) prog = Progress( user_id=user_id, episode_id=episode_id, duration_seconds=ep.duration_seconds if ep else None, ) db.add(prog) await db.flush() return prog async def _log_event( db: AsyncSession, user_id: str, episode_id: uuid.UUID, event_type: str, position: float, speed: float = 1.0, ): db.add(PlaybackEvent( user_id=user_id, episode_id=episode_id, event_type=event_type, position_seconds=position, playback_speed=speed, )) async def _validate_episode(db: AsyncSession, user_id: str, episode_id_str: str) -> Episode: eid = uuid.UUID(episode_id_str) ep = await db.get(Episode, eid) if not ep or ep.user_id != user_id: raise HTTPException(404, "Episode not found") return ep # ── Endpoints ── @router.post("/play") async def play( body: PlayRequest, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): """Set episode as currently playing.""" ep = await _validate_episode(db, user_id, body.episode_id) prog = await _get_or_create_progress(db, user_id, ep.id) prog.position_seconds = body.position_seconds prog.last_played_at = datetime.utcnow() prog.is_completed = False await _log_event(db, user_id, ep.id, "play", body.position_seconds, prog.playback_speed) await db.commit() return {"status": "playing", "position_seconds": prog.position_seconds} @router.post("/pause") async def pause( body: PauseRequest, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): """Pause and save position.""" ep = await _validate_episode(db, user_id, body.episode_id) prog = await _get_or_create_progress(db, user_id, ep.id) prog.position_seconds = body.position_seconds prog.last_played_at = datetime.utcnow() await _log_event(db, user_id, ep.id, "pause", body.position_seconds, prog.playback_speed) await db.commit() return {"status": "paused", "position_seconds": prog.position_seconds} @router.post("/seek") async def seek( body: SeekRequest, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): """Seek to position.""" ep = await _validate_episode(db, user_id, body.episode_id) prog = await _get_or_create_progress(db, user_id, ep.id) prog.position_seconds = body.position_seconds prog.last_played_at = datetime.utcnow() await _log_event(db, user_id, ep.id, "seek", body.position_seconds, prog.playback_speed) await db.commit() return {"status": "seeked", "position_seconds": prog.position_seconds} @router.post("/complete") async def complete( body: CompleteRequest, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): """Mark episode as completed.""" ep = await _validate_episode(db, user_id, body.episode_id) prog = await _get_or_create_progress(db, user_id, ep.id) prog.is_completed = True prog.position_seconds = prog.duration_seconds or prog.position_seconds prog.last_played_at = datetime.utcnow() await _log_event(db, user_id, ep.id, "complete", prog.position_seconds, prog.playback_speed) await db.commit() return {"status": "completed"} @router.get("/now-playing") async def now_playing( user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): """Get the most recently played episode with progress.""" 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.is_completed == False, # noqa: E712 ) .order_by(Progress.last_played_at.desc()) .limit(1) ) row = (await db.execute(stmt)).first() if not row: return None ep, show, prog = row return { "episode": { "id": str(ep.id), "title": ep.title, "audio_url": ep.audio_url, "duration_seconds": ep.duration_seconds, "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, }, } @router.post("/speed") async def set_speed( body: SpeedRequest, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): """Update playback speed for all user's active progress records.""" if body.speed < 0.5 or body.speed > 3.0: raise HTTPException(400, "Speed must be between 0.5 and 3.0") # Update the most recent (now-playing) progress record's speed stmt = ( select(Progress) .where( Progress.user_id == user_id, Progress.is_completed == False, # noqa: E712 ) .order_by(Progress.last_played_at.desc()) .limit(1) ) prog = (await db.execute(stmt)).scalar_one_or_none() if prog: prog.playback_speed = body.speed await db.commit() return {"speed": body.speed}