feat: rebuild iOS app from API audit + new podcast/media service
All checks were successful
Security Checks / dependency-audit (push) Successful in 12s
Security Checks / secret-scanning (push) Successful in 3s
Security Checks / dockerfile-lint (push) Successful in 3s

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:
Yusuf Suleman
2026-04-03 02:36:43 -05:00
parent e350a354a3
commit 69af4b84a5
56 changed files with 4256 additions and 4620 deletions

View File

@@ -0,0 +1,232 @@
"""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",
},
)