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>
179 lines
6.3 KiB
Python
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()]}
|