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}
|
||||
Reference in New Issue
Block a user