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>
237 lines
7.0 KiB
Python
237 lines
7.0 KiB
Python
"""Queue management endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import List
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select, func, delete, and_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_user_id, get_db_session
|
|
from app.models import Episode, Show, QueueItem, Progress, PlaybackEvent
|
|
|
|
router = APIRouter(prefix="/api/queue", tags=["queue"])
|
|
|
|
|
|
# ── Schemas ──
|
|
|
|
class QueueAddRequest(BaseModel):
|
|
episode_id: str
|
|
|
|
|
|
class QueueReorderRequest(BaseModel):
|
|
episode_ids: List[str]
|
|
|
|
|
|
# ── Endpoints ──
|
|
|
|
@router.get("")
|
|
async def get_queue(
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
"""Get user's queue ordered by sort_order, with episode and show info."""
|
|
stmt = (
|
|
select(QueueItem, Episode, Show)
|
|
.join(Episode, Episode.id == QueueItem.episode_id)
|
|
.join(Show, Show.id == Episode.show_id)
|
|
.where(QueueItem.user_id == user_id)
|
|
.order_by(QueueItem.sort_order)
|
|
)
|
|
result = await db.execute(stmt)
|
|
rows = result.all()
|
|
|
|
return [
|
|
{
|
|
"queue_id": str(qi.id),
|
|
"sort_order": qi.sort_order,
|
|
"added_at": qi.added_at.isoformat() if qi.added_at else None,
|
|
"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,
|
|
"published_at": ep.published_at.isoformat() if ep.published_at else None,
|
|
},
|
|
"show": {
|
|
"id": str(show.id),
|
|
"title": show.title,
|
|
"author": show.author,
|
|
"artwork_url": show.artwork_url,
|
|
},
|
|
}
|
|
for qi, ep, show in rows
|
|
]
|
|
|
|
|
|
@router.post("/add", status_code=201)
|
|
async def add_to_queue(
|
|
body: QueueAddRequest,
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
"""Add episode to end of queue."""
|
|
eid = uuid.UUID(body.episode_id)
|
|
ep = await db.get(Episode, eid)
|
|
if not ep or ep.user_id != user_id:
|
|
raise HTTPException(404, "Episode not found")
|
|
|
|
# Check if already in queue
|
|
existing = await db.execute(
|
|
select(QueueItem).where(QueueItem.user_id == user_id, QueueItem.episode_id == eid)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(409, "Episode already in queue")
|
|
|
|
# Get max sort_order
|
|
max_order = await db.scalar(
|
|
select(func.coalesce(func.max(QueueItem.sort_order), -1)).where(QueueItem.user_id == user_id)
|
|
)
|
|
|
|
qi = QueueItem(
|
|
user_id=user_id,
|
|
episode_id=eid,
|
|
sort_order=max_order + 1,
|
|
)
|
|
db.add(qi)
|
|
await db.commit()
|
|
return {"status": "added", "sort_order": qi.sort_order}
|
|
|
|
|
|
@router.post("/play-next", status_code=201)
|
|
async def play_next(
|
|
body: QueueAddRequest,
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
"""Insert episode at position 1 (after currently playing)."""
|
|
eid = uuid.UUID(body.episode_id)
|
|
ep = await db.get(Episode, eid)
|
|
if not ep or ep.user_id != user_id:
|
|
raise HTTPException(404, "Episode not found")
|
|
|
|
# Remove if already in queue
|
|
await db.execute(
|
|
delete(QueueItem).where(QueueItem.user_id == user_id, QueueItem.episode_id == eid)
|
|
)
|
|
|
|
# Shift all items at position >= 1 up by 1
|
|
stmt = (
|
|
select(QueueItem)
|
|
.where(QueueItem.user_id == user_id, QueueItem.sort_order >= 1)
|
|
.order_by(QueueItem.sort_order.desc())
|
|
)
|
|
items = (await db.execute(stmt)).scalars().all()
|
|
for item in items:
|
|
item.sort_order += 1
|
|
|
|
qi = QueueItem(user_id=user_id, episode_id=eid, sort_order=1)
|
|
db.add(qi)
|
|
await db.commit()
|
|
return {"status": "added", "sort_order": 1}
|
|
|
|
|
|
@router.post("/play-now", status_code=201)
|
|
async def play_now(
|
|
body: QueueAddRequest,
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
"""Insert at position 0 and start playing."""
|
|
eid = uuid.UUID(body.episode_id)
|
|
ep = await db.get(Episode, eid)
|
|
if not ep or ep.user_id != user_id:
|
|
raise HTTPException(404, "Episode not found")
|
|
|
|
# Remove if already in queue
|
|
await db.execute(
|
|
delete(QueueItem).where(QueueItem.user_id == user_id, QueueItem.episode_id == eid)
|
|
)
|
|
|
|
# Shift everything up
|
|
stmt = (
|
|
select(QueueItem)
|
|
.where(QueueItem.user_id == user_id)
|
|
.order_by(QueueItem.sort_order.desc())
|
|
)
|
|
items = (await db.execute(stmt)).scalars().all()
|
|
for item in items:
|
|
item.sort_order += 1
|
|
|
|
qi = QueueItem(user_id=user_id, episode_id=eid, sort_order=0)
|
|
db.add(qi)
|
|
|
|
# Also create/update progress and log play event
|
|
prog_stmt = select(Progress).where(Progress.user_id == user_id, Progress.episode_id == eid)
|
|
prog = (await db.execute(prog_stmt)).scalar_one_or_none()
|
|
if not prog:
|
|
prog = Progress(
|
|
user_id=user_id,
|
|
episode_id=eid,
|
|
duration_seconds=ep.duration_seconds,
|
|
)
|
|
db.add(prog)
|
|
prog.last_played_at = datetime.utcnow()
|
|
prog.is_completed = False
|
|
|
|
db.add(PlaybackEvent(
|
|
user_id=user_id,
|
|
episode_id=eid,
|
|
event_type="play",
|
|
position_seconds=prog.position_seconds or 0,
|
|
playback_speed=prog.playback_speed,
|
|
))
|
|
|
|
await db.commit()
|
|
return {"status": "playing", "sort_order": 0}
|
|
|
|
|
|
@router.delete("/{episode_id}", status_code=204)
|
|
async def remove_from_queue(
|
|
episode_id: str,
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
"""Remove episode from queue."""
|
|
eid = uuid.UUID(episode_id)
|
|
result = await db.execute(
|
|
delete(QueueItem).where(QueueItem.user_id == user_id, QueueItem.episode_id == eid)
|
|
)
|
|
if result.rowcount == 0:
|
|
raise HTTPException(404, "Episode not in queue")
|
|
await db.commit()
|
|
|
|
|
|
@router.post("/reorder")
|
|
async def reorder_queue(
|
|
body: QueueReorderRequest,
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
"""Reorder queue by providing episode IDs in desired order."""
|
|
for i, eid_str in enumerate(body.episode_ids):
|
|
eid = uuid.UUID(eid_str)
|
|
stmt = select(QueueItem).where(QueueItem.user_id == user_id, QueueItem.episode_id == eid)
|
|
qi = (await db.execute(stmt)).scalar_one_or_none()
|
|
if qi:
|
|
qi.sort_order = i
|
|
|
|
await db.commit()
|
|
return {"status": "reordered", "count": len(body.episode_ids)}
|
|
|
|
|
|
@router.delete("")
|
|
async def clear_queue(
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
"""Clear entire queue."""
|
|
await db.execute(delete(QueueItem).where(QueueItem.user_id == user_id))
|
|
await db.commit()
|
|
return {"status": "cleared"}
|