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>
233 lines
7.7 KiB
Python
233 lines
7.7 KiB
Python
"""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",
|
|
},
|
|
)
|