feat: rebuild iOS app from API audit + new podcast/media service
iOS App (complete rebuild): - Audited all fitness API endpoints against live responses - Models match exact API field names (snapshot_ prefixes, UUID strings) - FoodEntry uses computed properties (foodName, calories, etc.) wrapping snapshot fields - Flexible Int/Double decoding for all numeric fields - AI assistant with raw JSON state management (JSONSerialization, not Codable) - Home dashboard with custom background, frosted glass calorie widget - Fitness: Today/Templates/Goals/Foods tabs - Food search with recent + all sections - Meal sections with colored accent bars, swipe to delete - 120fps ProMotion, iOS 17+ @Observable Podcast/Media Service: - FastAPI backend for podcast RSS + local audiobook folders - Shows, episodes, playback progress, queue management - RSS feed fetching with feedparser + ETag support - Local folder scanning with mutagen for audio metadata - HTTP Range streaming for local audio files - Playback events logging (play/pause/seek/complete) - Reuses brain's PostgreSQL + Redis - media_ prefixed tables Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
229
services/media/app/api/playback.py
Normal file
229
services/media/app/api/playback.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user