Files
Audiolib/backend/app/routers/podcasts.py
Audiolib 52c10a7518 Phase 5-9: Matching-Engine, Podcast-Support, Web-Interface + Player
Backend:
- Matching-Orchestrator mit deutschen Serien-Patterns (drei ???, TKKG, ...)
- Vollständige MusicBrainz-Integration (Tracklist → Kapitel, Cover Art Archive)
- OpenLibrary + Google Books als Fallback-Quellen
- Auto-Accept (≥0.75) vs zu_prüfen (0.5-0.75) vs kein Match
- Manuelles Matching: GET /api/items/:id/match/search, POST apply
- RSS-Feed-Manager: feedparser, iTunes Search, periodisches Update
- APScheduler für Podcast-Feed-Updates (konfigurierbares Intervall)
- Podcast-Router: Feed-URL setzen, Episoden, Feed-Suche
- HLS: FFmpeg läuft als Background-Task, wartet auf ersten Segment
- main.py: APScheduler + neue Router eingebunden

Frontend (React + Vite + Tailwind + HLS.js):
- Login-Seite mit Fehlerbehandlung
- Library-Seite: Grid/Listen-Ansicht, Suche, Tag-Filter, Pagination, Scan
- BookCard: Cover, Fortschrittsbalken, zu_prüfen Badge, Quick-Play
- BookDetail: Metadaten, Matching-Panel, Kapitel-Liste, Lesezeichen
- AudioPlayer: HLS.js, Kapitel-Marker auf Fortschrittsbalken, Speed,
  Sleep-Timer, Lesezeichen, Keyboard-Shortcuts (Space/Arrows)
- MiniPlayer: persistent an Fußzeile, expandierbar
- PodcastDetail: Feed-URL, iTunes-Suche, Episoden-Liste
- Admin-Panel: Benutzer/Bibliotheken/Einstellungen verwalten
- App.tsx: React Router, Auth-Guard, Player-Overlay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:11:04 +02:00

179 lines
6.3 KiB
Python

import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from ..dependencies import get_db, get_current_user, require_admin
from ..models.user import User
from ..models.library import Library
from ..models.media_item import LibraryItem
from ..models.podcast import Podcast, PodcastEpisode
from ..services.podcast_feed import fetch_and_update_feed, search_podcast_feeds
router = APIRouter(prefix="/api/podcasts", tags=["podcasts"])
def _episode_out(ep: PodcastEpisode) -> dict:
return {
"id": ep.id,
"podcastId": ep.podcast_id,
"title": ep.title,
"description": ep.description,
"episode": ep.episode_number,
"season": ep.season_number,
"pubDate": ep.pub_date.isoformat() if ep.pub_date else None,
"duration": ep.duration_seconds,
"size": ep.size_bytes,
"path": ep.path,
"feedEpisodeId": ep.feed_episode_id,
"feedEpisodeUrl": ep.feed_episode_url,
"explicit": ep.explicit,
"addedAt": int(ep.created_at.timestamp() * 1000) if ep.created_at else 0,
}
def _podcast_out(podcast: Podcast, item: LibraryItem, episodes: list[PodcastEpisode]) -> dict:
return {
"id": item.id,
"libraryId": item.library_id,
"title": item.title,
"author": item.author or podcast.author,
"description": item.description,
"cover": f"/api/items/{item.id}/cover" if item.cover_path else None,
"feedUrl": podcast.feed_url,
"feedLastChecked": podcast.feed_last_checked.isoformat() if podcast.feed_last_checked else None,
"updateIntervalHours": podcast.update_interval_hours,
"tags": item.tags or [],
"episodes": [_episode_out(ep) for ep in episodes],
"numEpisodes": len(episodes),
"matchedSource": item.matched_source,
}
@router.get("")
async def list_podcasts(
library_id: str | None = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(LibraryItem).where(LibraryItem.media_type == "podcast")
if library_id:
query = query.where(LibraryItem.library_id == library_id)
items_result = await db.execute(query)
items = items_result.scalars().all()
result = []
for item in items:
podcast_result = await db.execute(select(Podcast).where(Podcast.library_item_id == item.id))
podcast = podcast_result.scalar_one_or_none()
if not podcast:
continue
ep_result = await db.execute(
select(PodcastEpisode).where(PodcastEpisode.podcast_id == podcast.id)
.order_by(PodcastEpisode.pub_date.desc())
)
episodes = ep_result.scalars().all()
result.append(_podcast_out(podcast, item, episodes))
return {"podcasts": result}
@router.get("/search")
async def search_feeds(
q: str,
current_user: User = Depends(get_current_user),
):
results = await search_podcast_feeds(q)
return {"results": results}
@router.get("/{item_id}")
async def get_podcast(
item_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
item_result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = item_result.scalar_one_or_none()
if not item or item.media_type != "podcast":
raise HTTPException(status_code=404, detail="Podcast not found")
podcast_result = await db.execute(select(Podcast).where(Podcast.library_item_id == item_id))
podcast = podcast_result.scalar_one_or_none()
if not podcast:
raise HTTPException(status_code=404, detail="Podcast data not found")
ep_result = await db.execute(
select(PodcastEpisode).where(PodcastEpisode.podcast_id == podcast.id)
.order_by(PodcastEpisode.pub_date.desc())
)
episodes = ep_result.scalars().all()
return _podcast_out(podcast, item, episodes)
@router.patch("/{item_id}/feed")
async def set_feed_url(
item_id: str,
body: dict,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
item_result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = item_result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
podcast_result = await db.execute(select(Podcast).where(Podcast.library_item_id == item_id))
podcast = podcast_result.scalar_one_or_none()
if not podcast:
podcast = Podcast(library_item_id=item_id)
db.add(podcast)
feed_url = body.get("feedUrl", "")
if not feed_url:
raise HTTPException(status_code=400, detail="feedUrl required")
podcast.feed_url = feed_url
if body.get("updateIntervalHours"):
podcast.update_interval_hours = int(body["updateIntervalHours"])
await db.commit()
background_tasks.add_task(fetch_and_update_feed, item_id)
return {"success": True, "message": "Feed wird aktualisiert..."}
@router.post("/{item_id}/update-feed")
async def update_feed(
item_id: str,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
podcast_result = await db.execute(select(Podcast).where(Podcast.library_item_id == item_id))
podcast = podcast_result.scalar_one_or_none()
if not podcast or not podcast.feed_url:
raise HTTPException(status_code=400, detail="Kein Feed konfiguriert")
background_tasks.add_task(fetch_and_update_feed, item_id)
return {"success": True}
@router.get("/{item_id}/episodes")
async def get_episodes(
item_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
podcast_result = await db.execute(select(Podcast).where(Podcast.library_item_id == item_id))
podcast = podcast_result.scalar_one_or_none()
if not podcast:
raise HTTPException(status_code=404, detail="Podcast not found")
ep_result = await db.execute(
select(PodcastEpisode).where(PodcastEpisode.podcast_id == podcast.id)
.order_by(PodcastEpisode.pub_date.desc())
)
return {"episodes": [_episode_out(ep) for ep in ep_result.scalars().all()]}