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:
186
backend/app/services/podcast_feed.py
Normal file
186
backend/app/services/podcast_feed.py
Normal 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}")
|
||||
Reference in New Issue
Block a user