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>
This commit is contained in:
Audiolib
2026-05-26 13:11:04 +02:00
parent dfbb397e46
commit 52c10a7518
32 changed files with 2987 additions and 223 deletions

View File

@@ -0,0 +1,186 @@
"""
Podcast-Feed-Manager:
- RSS-Feed parsen
- Episoden mit lokalen Dateien abgleichen
- Periodisches Update
"""
import os
import re
import logging
import httpx
import feedparser
from datetime import datetime
from difflib import SequenceMatcher
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from ..database import AsyncSessionLocal
from ..models.library import Library
from ..models.media_item import LibraryItem
from ..models.podcast import Podcast, PodcastEpisode
from ..services.matcher import _download_cover
logger = logging.getLogger(__name__)
def _similarity(a: str, b: str) -> float:
if not a or not b:
return 0.0
return SequenceMatcher(None, a.lower(), b.lower()).ratio()
def _parse_duration(s: str | None) -> float:
"""Parst "HH:MM:SS" oder "MM:SS" oder reine Sekunden."""
if not s:
return 0.0
s = s.strip()
try:
if ":" in s:
parts = s.split(":")
if len(parts) == 3:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])
elif len(parts) == 2:
return int(parts[0]) * 60 + float(parts[1])
return float(s)
except (ValueError, IndexError):
return 0.0
async def search_podcast_feeds(query: str) -> list[dict]:
"""Sucht Podcast-Feeds über iTunes Search API."""
results = []
try:
async with httpx.AsyncClient(timeout=12) as client:
r = await client.get(
"https://itunes.apple.com/search",
params={"term": query, "media": "podcast", "limit": 10, "country": "de"},
)
r.raise_for_status()
data = r.json()
for item in data.get("results", []):
results.append({
"title": item.get("collectionName", ""),
"author": item.get("artistName", ""),
"feedUrl": item.get("feedUrl", ""),
"artworkUrl": item.get("artworkUrl600") or item.get("artworkUrl100", ""),
"trackCount": item.get("trackCount", 0),
"itunesId": item.get("collectionId"),
})
except Exception as e:
logger.warning(f"iTunes-Suche fehlgeschlagen: {e}")
return results
async def fetch_and_update_feed(library_item_id: str):
"""
Holt RSS-Feed und aktualisiert Metadaten + Episoden in der DB.
"""
async with AsyncSessionLocal() as db:
item_result = await db.execute(select(LibraryItem).where(LibraryItem.id == library_item_id))
item = item_result.scalar_one_or_none()
if not item:
return
podcast_result = await db.execute(select(Podcast).where(Podcast.library_item_id == library_item_id))
podcast = podcast_result.scalar_one_or_none()
if not podcast or not podcast.feed_url:
logger.warning(f"Kein Feed für Item {library_item_id}")
return
try:
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
r = await client.get(podcast.feed_url)
r.raise_for_status()
raw = r.text
except Exception as e:
logger.error(f"Feed-Abruf fehlgeschlagen ({podcast.feed_url}): {e}")
return
feed = feedparser.parse(raw)
channel = feed.feed
# Podcast-Metadaten aktualisieren
if channel.get("title") and not item.title:
item.title = channel.get("title")
if channel.get("author") and not item.author:
item.author = channel.get("author")
if channel.get("summary") and not item.description:
item.description = channel.get("summary")
if channel.get("language") and not item.language:
item.language = channel.get("language")
# Cover
cover_url = None
if channel.get("image"):
cover_url = channel.image.get("href") or channel.image.get("url")
if cover_url and not item.cover_path:
cover_path = await _download_cover(cover_url, item.id)
if cover_path:
item.cover_path = cover_path
podcast.feed_last_checked = datetime.utcnow()
# Lokale Episode-Dateien holen
episodes_result = await db.execute(
select(PodcastEpisode).where(PodcastEpisode.podcast_id == podcast.id)
)
existing_episodes = {ep.feed_episode_id: ep for ep in episodes_result.scalars().all()}
# Feed-Einträge verarbeiten
for entry in feed.entries:
feed_ep_id = entry.get("id") or entry.get("link", "")
title = entry.get("title", "")
description = entry.get("summary") or entry.get("content", [{}])[0].get("value", "") if entry.get("content") else ""
pub_date = None
if entry.get("published_parsed"):
import time
pub_date = datetime(*entry.published_parsed[:6])
enclosure_url = None
duration_s = 0.0
for enc in entry.get("enclosures", []):
if enc.get("type", "").startswith("audio/"):
enclosure_url = enc.get("href") or enc.get("url")
break
duration_s = _parse_duration(entry.get("itunes_duration"))
ep_num = entry.get("itunes_episode")
season_num = entry.get("itunes_season")
if feed_ep_id in existing_episodes:
# Vorhandene Episode aktualisieren
ep = existing_episodes[feed_ep_id]
ep.title = title
ep.description = description
ep.feed_episode_url = enclosure_url
ep.duration_seconds = duration_s or ep.duration_seconds
else:
# Neue Episode anlegen
ep = PodcastEpisode(
podcast_id=podcast.id,
title=title,
description=description,
pub_date=pub_date,
duration_seconds=duration_s,
feed_episode_id=feed_ep_id,
feed_episode_url=enclosure_url,
episode_number=str(ep_num) if ep_num else None,
season_number=str(season_num) if season_num else None,
)
db.add(ep)
item.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"Feed aktualisiert: {item.title} ({len(feed.entries)} Einträge)")
async def update_all_feeds():
"""Aktualisiert alle Podcast-Feeds (wird vom Scheduler aufgerufen)."""
async with AsyncSessionLocal() as db:
result = await db.execute(select(Podcast).where(Podcast.feed_url.isnot(None)))
podcasts = result.scalars().all()
for podcast in podcasts:
try:
await fetch_and_update_feed(podcast.library_item_id)
except Exception as e:
logger.error(f"Feed-Update fehlgeschlagen für {podcast.id}: {e}")