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:
124
backend/app/routers/matching.py
Normal file
124
backend/app/routers/matching.py
Normal file
@@ -0,0 +1,124 @@
|
||||
import asyncio
|
||||
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.media_item import LibraryItem
|
||||
from ..services.matcher import match_audiobook, search_for_item, _apply_match, _score_result
|
||||
from ..services.matching.musicbrainz import get_release_details
|
||||
from ..services.matching.open_library import get_work_details
|
||||
from ..services.matching.google_books import search_google_books
|
||||
from ..services.matching.base import MatchResult
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/api/items", tags=["matching"])
|
||||
|
||||
|
||||
@router.post("/{item_id}/match")
|
||||
async def trigger_match(
|
||||
item_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
background_tasks.add_task(match_audiobook, item_id)
|
||||
return {"message": "Matching gestartet", "itemId": item_id}
|
||||
|
||||
|
||||
@router.get("/{item_id}/match/search")
|
||||
async def search_match(
|
||||
item_id: str,
|
||||
q: str | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
query = q or item.title or ""
|
||||
author = item.author if not q else None
|
||||
|
||||
results = await search_for_item(query, author)
|
||||
return {"results": results}
|
||||
|
||||
|
||||
@router.post("/{item_id}/match/apply")
|
||||
async def apply_match(
|
||||
item_id: str,
|
||||
body: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Wendet einen manuell gewählten Match-Treffer an.
|
||||
body: { source, id, title, author, ... }
|
||||
"""
|
||||
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
source = body.get("source", "manual")
|
||||
source_id = body.get("id", "")
|
||||
|
||||
# Versuche Details zu laden wenn MusicBrainz/OpenLibrary
|
||||
match_result = None
|
||||
if source == "musicbrainz":
|
||||
match_result = await get_release_details(source_id)
|
||||
elif source == "open_library":
|
||||
from ..services.matching.open_library import get_work_details
|
||||
match_result = await get_work_details(source_id)
|
||||
|
||||
if not match_result:
|
||||
# Fallback: nur die übergebenen Daten verwenden
|
||||
match_result = MatchResult(
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
title=body.get("title", item.title or ""),
|
||||
author=body.get("author"),
|
||||
publish_year=body.get("publishYear"),
|
||||
cover_url=body.get("cover"),
|
||||
confidence=1.0,
|
||||
)
|
||||
|
||||
match_result.confidence = 1.0 # Manuell → immer akzeptieren
|
||||
await _apply_match(db, item, match_result, confidence=1.0)
|
||||
item.match_locked = True
|
||||
item.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
from ..routers.items import _enrich_item_with_files
|
||||
return await _enrich_item_with_files(item, db)
|
||||
|
||||
|
||||
@router.delete("/{item_id}/match")
|
||||
async def clear_match(
|
||||
item_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
item.matched_source = "none"
|
||||
item.matched_id = None
|
||||
item.match_confidence = 0.0
|
||||
item.match_locked = False
|
||||
tags = item.tags or []
|
||||
if "zu_prüfen" not in tags:
|
||||
tags.append("zu_prüfen")
|
||||
item.tags = tags
|
||||
item.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
return {"success": True}
|
||||
178
backend/app/routers/podcasts.py
Normal file
178
backend/app/routers/podcasts.py
Normal file
@@ -0,0 +1,178 @@
|
||||
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()]}
|
||||
@@ -10,7 +10,7 @@ from ..models.user import User
|
||||
from ..models.media_item import LibraryItem, BookFile, Chapter
|
||||
from ..models.session import PlaybackSession
|
||||
from ..models.progress import MediaProgress
|
||||
from ..services.hls import create_hls_session, cleanup_hls_session, get_hls_session_path
|
||||
from ..services.hls import start_hls_session, wait_for_playlist, cleanup_hls_session
|
||||
from ..config import get_settings
|
||||
|
||||
router = APIRouter(tags=["stream"])
|
||||
@@ -34,14 +34,13 @@ async def start_playback(
|
||||
)
|
||||
files = files_result.scalars().all()
|
||||
if not files:
|
||||
raise HTTPException(status_code=400, detail="No audio files for this item")
|
||||
raise HTTPException(status_code=400, detail="Keine Audio-Dateien vorhanden")
|
||||
|
||||
chapters_result = await db.execute(
|
||||
select(Chapter).where(Chapter.library_item_id == item_id).order_by(Chapter.chapter_index)
|
||||
)
|
||||
chapters = chapters_result.scalars().all()
|
||||
|
||||
# Fortschritt ermitteln
|
||||
progress_result = await db.execute(
|
||||
select(MediaProgress).where(
|
||||
MediaProgress.user_id == current_user.id,
|
||||
@@ -54,10 +53,14 @@ async def start_playback(
|
||||
current_time = float(body["startTime"])
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
# HLS-Session asynchron starten
|
||||
audio_paths = [f.path for f in files]
|
||||
hls_dir = await create_hls_session(session_id, audio_paths, start_time=0.0)
|
||||
hls_dir = start_hls_session(session_id, audio_paths, start_time=0.0)
|
||||
|
||||
# Warten bis erste Segmente da sind (max. 60s)
|
||||
ready = await wait_for_playlist(session_id, timeout=60.0)
|
||||
if not ready:
|
||||
cleanup_hls_session(session_id)
|
||||
raise HTTPException(status_code=500, detail="HLS-Konvertierung fehlgeschlagen")
|
||||
|
||||
session = PlaybackSession(
|
||||
id=session_id,
|
||||
@@ -75,35 +78,9 @@ async def start_playback(
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
|
||||
settings = get_settings()
|
||||
# URL-Basis relativ — wird durch nginx weitergeleitet
|
||||
hls_url = f"/hls/{session_id}/output.m3u8"
|
||||
|
||||
audio_tracks = [
|
||||
{
|
||||
"index": 0,
|
||||
"startOffset": 0.0,
|
||||
"duration": item.duration_seconds or 0.0,
|
||||
"title": "Part 1",
|
||||
"contentUrl": hls_url,
|
||||
"mimeType": "application/x-mpegURL",
|
||||
"metadata": {
|
||||
"filename": "output.m3u8",
|
||||
"ext": ".m3u8",
|
||||
"path": hls_url,
|
||||
"relPath": "output.m3u8",
|
||||
"size": 0,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
chapters_out = [
|
||||
{
|
||||
"id": c.chapter_index,
|
||||
"start": c.start_seconds,
|
||||
"end": c.end_seconds,
|
||||
"title": c.title,
|
||||
}
|
||||
{"id": c.chapter_index, "start": c.start_seconds, "end": c.end_seconds, "title": c.title}
|
||||
for c in chapters
|
||||
]
|
||||
|
||||
@@ -119,7 +96,7 @@ async def start_playback(
|
||||
"displayAuthor": item.author,
|
||||
"coverPath": f"/api/items/{item_id}/cover" if item.cover_path else None,
|
||||
"duration": item.duration_seconds or 0.0,
|
||||
"playMethod": 0, # 0 = HLS Transcode
|
||||
"playMethod": 0,
|
||||
"mediaPlayer": body.get("mediaPlayer", ""),
|
||||
"deviceInfo": body.get("deviceInfo", {}),
|
||||
"serverVersion": "2.4.0",
|
||||
@@ -130,7 +107,15 @@ async def start_playback(
|
||||
"currentTime": current_time,
|
||||
"startedAt": int(datetime.utcnow().timestamp() * 1000),
|
||||
"updatedAt": int(datetime.utcnow().timestamp() * 1000),
|
||||
"audioTracks": audio_tracks,
|
||||
"audioTracks": [{
|
||||
"index": 0,
|
||||
"startOffset": 0.0,
|
||||
"duration": item.duration_seconds or 0.0,
|
||||
"title": "Part 1",
|
||||
"contentUrl": hls_url,
|
||||
"mimeType": "application/x-mpegURL",
|
||||
"metadata": {"filename": "output.m3u8", "ext": ".m3u8", "path": hls_url, "relPath": "output.m3u8", "size": 0},
|
||||
}],
|
||||
"videoTrack": None,
|
||||
}
|
||||
|
||||
@@ -138,15 +123,13 @@ async def start_playback(
|
||||
@router.post("/api/playback-session/{session_id}/sync")
|
||||
async def sync_session(
|
||||
session_id: str,
|
||||
body: dict = {},
|
||||
body: dict | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
body = body or {}
|
||||
result = await db.execute(
|
||||
select(PlaybackSession).where(
|
||||
PlaybackSession.id == session_id,
|
||||
PlaybackSession.user_id == current_user.id,
|
||||
)
|
||||
select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
@@ -154,13 +137,10 @@ async def sync_session(
|
||||
|
||||
current_time = float(body.get("currentTime", session.current_time))
|
||||
duration = float(body.get("duration", session.duration))
|
||||
time_listening = float(body.get("timeListening", 0))
|
||||
|
||||
session.current_time = current_time
|
||||
session.duration = duration
|
||||
session.updated_at = datetime.utcnow()
|
||||
|
||||
# Fortschritt persistieren
|
||||
progress_result = await db.execute(
|
||||
select(MediaProgress).where(
|
||||
MediaProgress.user_id == current_user.id,
|
||||
@@ -200,39 +180,28 @@ async def close_session(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(PlaybackSession).where(
|
||||
PlaybackSession.id == session_id,
|
||||
PlaybackSession.user_id == current_user.id,
|
||||
)
|
||||
select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
session.is_active = False
|
||||
await db.commit()
|
||||
|
||||
# HLS-Temp-Dateien bereinigen
|
||||
cleanup_hls_session(session_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/hls/{session_id}/{filename}")
|
||||
@router.get("/hls/{session_id}/{filename:path}")
|
||||
async def serve_hls(
|
||||
session_id: str,
|
||||
filename: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Session prüfen
|
||||
result = await db.execute(
|
||||
select(PlaybackSession).where(
|
||||
PlaybackSession.id == session_id,
|
||||
PlaybackSession.user_id == current_user.id,
|
||||
)
|
||||
select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
settings = get_settings()
|
||||
@@ -242,7 +211,4 @@ async def serve_hls(
|
||||
|
||||
if filename.endswith(".m3u8"):
|
||||
return FileResponse(file_path, media_type="application/x-mpegURL")
|
||||
elif filename.endswith(".ts"):
|
||||
return FileResponse(file_path, media_type="video/MP2T")
|
||||
else:
|
||||
return FileResponse(file_path)
|
||||
return FileResponse(file_path, media_type="video/MP2T")
|
||||
|
||||
Reference in New Issue
Block a user