Files
platform/services/media/app/api/shows.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

520 lines
16 KiB
Python

"""Show CRUD endpoints."""
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from typing import Optional
import feedparser
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.deps import get_user_id, get_db_session
from app.models import Show, Episode, Progress
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/shows", tags=["shows"])
# ── Schemas ──
class ShowCreate(BaseModel):
feed_url: Optional[str] = None
local_path: Optional[str] = None
title: Optional[str] = None
class ShowOut(BaseModel):
id: str
title: str
author: Optional[str] = None
description: Optional[str] = None
artwork_url: Optional[str] = None
feed_url: Optional[str] = None
local_path: Optional[str] = None
show_type: str
episode_count: int = 0
unplayed_count: int = 0
created_at: Optional[str] = None
updated_at: Optional[str] = None
# ── Helpers ──
def _parse_duration(value: str) -> Optional[int]:
"""Parse HH:MM:SS or MM:SS or seconds string to integer seconds."""
if not value:
return None
parts = value.strip().split(":")
try:
if len(parts) == 3:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
elif len(parts) == 2:
return int(parts[0]) * 60 + int(parts[1])
else:
return int(float(parts[0]))
except (ValueError, IndexError):
return None
async def _fetch_and_parse_feed(feed_url: str, etag: str = None, last_modified: str = None):
"""Fetch RSS feed and parse with feedparser."""
headers = {}
if etag:
headers["If-None-Match"] = etag
if last_modified:
headers["If-Modified-Since"] = last_modified
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
resp = await client.get(feed_url, headers=headers)
if resp.status_code == 304:
return None, None, None # Not modified
resp.raise_for_status()
feed = feedparser.parse(resp.text)
new_etag = resp.headers.get("ETag")
new_last_modified = resp.headers.get("Last-Modified")
return feed, new_etag, new_last_modified
def _extract_show_info(feed) -> dict:
"""Extract show metadata from parsed feed."""
f = feed.feed
artwork = None
if hasattr(f, "image") and f.image:
artwork = getattr(f.image, "href", None)
if not artwork and hasattr(f, "itunes_image"):
artwork = f.get("itunes_image", {}).get("href") if isinstance(f.get("itunes_image"), dict) else None
# Try another common location
if not artwork:
for link in getattr(f, "links", []):
if link.get("rel") == "icon" or link.get("type", "").startswith("image/"):
artwork = link.get("href")
break
return {
"title": getattr(f, "title", "Unknown Show"),
"author": getattr(f, "author", None) or getattr(f, "itunes_author", None),
"description": getattr(f, "summary", None) or getattr(f, "subtitle", None),
"artwork_url": artwork,
}
def _extract_episodes(feed, show_id: uuid.UUID, user_id: str) -> list[dict]:
"""Extract episodes from parsed feed."""
episodes = []
for entry in feed.entries:
audio_url = None
file_size = None
for enc in getattr(entry, "enclosures", []):
if enc.get("type", "").startswith("audio/") or enc.get("href", "").split("?")[0].endswith(
(".mp3", ".m4a", ".ogg", ".opus")
):
audio_url = enc.get("href")
file_size = int(enc.get("length", 0)) or None
break
# Fallback: check links
if not audio_url:
for link in getattr(entry, "links", []):
if link.get("type", "").startswith("audio/"):
audio_url = link.get("href")
file_size = int(link.get("length", 0)) or None
break
if not audio_url:
continue # Skip entries without audio
# Duration
duration = None
itunes_duration = getattr(entry, "itunes_duration", None)
if itunes_duration:
duration = _parse_duration(str(itunes_duration))
# Published date
published = None
if hasattr(entry, "published_parsed") and entry.published_parsed:
try:
from time import mktime
published = datetime.fromtimestamp(mktime(entry.published_parsed))
except (TypeError, ValueError, OverflowError):
pass
# GUID
guid = getattr(entry, "id", None) or audio_url
# Episode artwork
ep_artwork = None
itunes_image = getattr(entry, "itunes_image", None)
if itunes_image and isinstance(itunes_image, dict):
ep_artwork = itunes_image.get("href")
episodes.append({
"id": uuid.uuid4(),
"show_id": show_id,
"user_id": user_id,
"title": getattr(entry, "title", None),
"description": getattr(entry, "summary", None),
"audio_url": audio_url,
"duration_seconds": duration,
"file_size_bytes": file_size,
"published_at": published,
"guid": guid,
"artwork_url": ep_artwork,
})
return episodes
async def _scan_local_folder(local_path: str, show_id: uuid.UUID, user_id: str) -> list[dict]:
"""Scan a local folder for audio files and create episode dicts."""
import os
from mutagen import File as MutagenFile
from app.config import AUDIO_EXTENSIONS
episodes = []
if not os.path.isdir(local_path):
return episodes
files = sorted(os.listdir(local_path))
for i, fname in enumerate(files):
ext = os.path.splitext(fname)[1].lower()
if ext not in AUDIO_EXTENSIONS:
continue
fpath = os.path.join(local_path, fname)
if not os.path.isfile(fpath):
continue
# Read metadata with mutagen
title = os.path.splitext(fname)[0]
duration = None
file_size = os.path.getsize(fpath)
try:
audio = MutagenFile(fpath)
if audio and audio.info:
duration = int(audio.info.length)
# Try to get title from tags
if audio and audio.tags:
for tag_key in ("title", "TIT2", "\xa9nam"):
tag_val = audio.tags.get(tag_key)
if tag_val:
title = str(tag_val[0]) if isinstance(tag_val, list) else str(tag_val)
break
except Exception:
pass
stat = os.stat(fpath)
published = datetime.fromtimestamp(stat.st_mtime)
episodes.append({
"id": uuid.uuid4(),
"show_id": show_id,
"user_id": user_id,
"title": title,
"description": None,
"audio_url": fpath,
"duration_seconds": duration,
"file_size_bytes": file_size,
"published_at": published,
"guid": f"local:{fpath}",
"artwork_url": None,
})
return episodes
# ── Endpoints ──
@router.get("")
async def list_shows(
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""List user's shows with episode counts and unplayed counts."""
# Subquery: total episodes per show
ep_count_sq = (
select(
Episode.show_id,
func.count(Episode.id).label("episode_count"),
)
.where(Episode.user_id == user_id)
.group_by(Episode.show_id)
.subquery()
)
# Subquery: episodes with completed progress
played_sq = (
select(
Episode.show_id,
func.count(Progress.id).label("played_count"),
)
.join(Progress, Progress.episode_id == Episode.id)
.where(Episode.user_id == user_id, Progress.is_completed == True) # noqa: E712
.group_by(Episode.show_id)
.subquery()
)
stmt = (
select(
Show,
func.coalesce(ep_count_sq.c.episode_count, 0).label("episode_count"),
func.coalesce(played_sq.c.played_count, 0).label("played_count"),
)
.outerjoin(ep_count_sq, ep_count_sq.c.show_id == Show.id)
.outerjoin(played_sq, played_sq.c.show_id == Show.id)
.where(Show.user_id == user_id)
.order_by(Show.title)
)
result = await db.execute(stmt)
rows = result.all()
return [
{
"id": str(show.id),
"title": show.title,
"author": show.author,
"description": show.description,
"artwork_url": show.artwork_url,
"feed_url": show.feed_url,
"local_path": show.local_path,
"show_type": show.show_type,
"episode_count": ep_count,
"unplayed_count": ep_count - played_count,
"created_at": show.created_at.isoformat() if show.created_at else None,
"updated_at": show.updated_at.isoformat() if show.updated_at else None,
}
for show, ep_count, played_count in rows
]
@router.post("", status_code=201)
async def create_show(
body: ShowCreate,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Create a show from RSS feed or local folder."""
if not body.feed_url and not body.local_path:
raise HTTPException(400, "Either feed_url or local_path is required")
show_id = uuid.uuid4()
if body.feed_url:
# RSS podcast
try:
feed, etag, last_modified = await _fetch_and_parse_feed(body.feed_url)
except Exception as e:
log.error("Failed to fetch feed %s: %s", body.feed_url, e)
raise HTTPException(400, f"Failed to fetch feed: {e}")
if feed is None:
raise HTTPException(400, "Feed returned no content")
info = _extract_show_info(feed)
show = Show(
id=show_id,
user_id=user_id,
title=body.title or info["title"],
author=info["author"],
description=info["description"],
artwork_url=info["artwork_url"],
feed_url=body.feed_url,
show_type="podcast",
etag=etag,
last_modified=last_modified,
last_fetched_at=datetime.utcnow(),
)
db.add(show)
await db.flush()
ep_dicts = _extract_episodes(feed, show_id, user_id)
for ep_dict in ep_dicts:
db.add(Episode(**ep_dict))
await db.commit()
await db.refresh(show)
return {
"id": str(show.id),
"title": show.title,
"show_type": show.show_type,
"episode_count": len(ep_dicts),
}
else:
# Local folder
if not body.title:
raise HTTPException(400, "title is required for local shows")
show = Show(
id=show_id,
user_id=user_id,
title=body.title,
local_path=body.local_path,
show_type="local",
last_fetched_at=datetime.utcnow(),
)
db.add(show)
await db.flush()
ep_dicts = await _scan_local_folder(body.local_path, show_id, user_id)
for ep_dict in ep_dicts:
db.add(Episode(**ep_dict))
await db.commit()
await db.refresh(show)
return {
"id": str(show.id),
"title": show.title,
"show_type": show.show_type,
"episode_count": len(ep_dicts),
}
@router.get("/{show_id}")
async def get_show(
show_id: str,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Get show details with episodes."""
show = await db.get(Show, uuid.UUID(show_id))
if not show or show.user_id != user_id:
raise HTTPException(404, "Show not found")
# Fetch episodes with progress
stmt = (
select(Episode, Progress)
.outerjoin(Progress, (Progress.episode_id == Episode.id) & (Progress.user_id == user_id))
.where(Episode.show_id == show.id)
.order_by(Episode.published_at.desc().nullslast())
)
result = await db.execute(stmt)
rows = result.all()
episodes = []
for ep, prog in rows:
episodes.append({
"id": str(ep.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,
"progress": {
"position_seconds": prog.position_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,
})
return {
"id": str(show.id),
"title": show.title,
"author": show.author,
"description": show.description,
"artwork_url": show.artwork_url,
"feed_url": show.feed_url,
"local_path": show.local_path,
"show_type": show.show_type,
"last_fetched_at": show.last_fetched_at.isoformat() if show.last_fetched_at else None,
"created_at": show.created_at.isoformat() if show.created_at else None,
"episodes": episodes,
}
@router.delete("/{show_id}", status_code=204)
async def delete_show(
show_id: str,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Delete a show and all its episodes."""
show = await db.get(Show, uuid.UUID(show_id))
if not show or show.user_id != user_id:
raise HTTPException(404, "Show not found")
await db.delete(show)
await db.commit()
@router.post("/{show_id}/refresh")
async def refresh_show(
show_id: str,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Re-fetch RSS feed or re-scan local folder for new episodes."""
show = await db.get(Show, uuid.UUID(show_id))
if not show or show.user_id != user_id:
raise HTTPException(404, "Show not found")
new_count = 0
if show.show_type == "podcast" and show.feed_url:
try:
feed, etag, last_modified = await _fetch_and_parse_feed(
show.feed_url, show.etag, show.last_modified
)
except Exception as e:
raise HTTPException(400, f"Failed to fetch feed: {e}")
if feed is None:
return {"new_episodes": 0, "message": "Feed not modified"}
info = _extract_show_info(feed)
show.title = info["title"] or show.title
show.author = info["author"] or show.author
show.description = info["description"] or show.description
show.artwork_url = info["artwork_url"] or show.artwork_url
show.etag = etag
show.last_modified = last_modified
show.last_fetched_at = datetime.utcnow()
ep_dicts = _extract_episodes(feed, show.id, user_id)
# Get existing guids
existing = await db.execute(
select(Episode.guid).where(Episode.show_id == show.id)
)
existing_guids = {row[0] for row in existing.all()}
for ep_dict in ep_dicts:
if ep_dict["guid"] not in existing_guids:
db.add(Episode(**ep_dict))
new_count += 1
elif show.show_type == "local" and show.local_path:
ep_dicts = await _scan_local_folder(show.local_path, show.id, user_id)
existing = await db.execute(
select(Episode.guid).where(Episode.show_id == show.id)
)
existing_guids = {row[0] for row in existing.all()}
for ep_dict in ep_dicts:
if ep_dict["guid"] not in existing_guids:
db.add(Episode(**ep_dict))
new_count += 1
show.last_fetched_at = datetime.utcnow()
await db.commit()
return {"new_episodes": new_count}