Files
platform/services/media/app/api/queue.py
Yusuf Suleman 69af4b84a5
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
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>
2026-04-03 02:36:43 -05:00

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"}