Streaming: Drop token-in-URL auth entirely. Session-ID (UUID, 128-bit
entropy) IS the auth — same approach as Audiobookshelf. Eliminates the
entire class of token-related failures and matches how every other
streaming server handles this. Logs every stream request with Range
header and User-Agent for diagnostics.
Player: Visible error banner in UI when audio fails (with HTML5 media
error code translated to German). Stream URL is shown in the banner so
the user can see exactly what failed.
Scanner: Cover extraction from two new sources (in addition to API
matching):
1. Folder-level images (cover.jpg, folder.jpg, front.jpg, etc.)
2. Embedded artwork (ID3 APIC, MP4 covr, FLAC/Vorbis pictures)
Runs on every scan — also fills in covers for items that were already
scanned but never got one from matching.
New endpoint POST /api/items/{id}/extract-cover triggers this manually
for a single item.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
175 lines
6.0 KiB
Python
175 lines
6.0 KiB
Python
import asyncio
|
|
import logging
|
|
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, _enrich_match
|
|
from ..services.matching.musicbrainz import get_release_details
|
|
from ..services.matching.open_library import get_work_details
|
|
from ..services.matching.base import MatchResult
|
|
from datetime import datetime
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
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, narrator, description, publisher, publishYear, series, seriesSequence, language, genres, cover, ... }
|
|
"""
|
|
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", "")
|
|
|
|
logger.info(
|
|
f"Manual apply: item={item_id} source={source} source_id={source_id} "
|
|
f"body_keys={sorted(body.keys())}"
|
|
)
|
|
|
|
# Immer aus body konstruieren (search_for_item liefert jetzt alle Felder)
|
|
match_result = MatchResult(
|
|
source=source,
|
|
source_id=source_id,
|
|
title=body.get("title") or item.title or "",
|
|
subtitle=body.get("subtitle"),
|
|
author=body.get("author"),
|
|
narrator=body.get("narrator"),
|
|
description=body.get("description"),
|
|
publisher=body.get("publisher"),
|
|
publish_year=body.get("publishYear"),
|
|
series=body.get("series"),
|
|
series_sequence=body.get("seriesSequence"),
|
|
language=body.get("language"),
|
|
genres=body.get("genres") or [],
|
|
cover_url=body.get("cover"),
|
|
confidence=1.0,
|
|
)
|
|
|
|
# Mit Details anreichern (Beschreibung, Kapitel) — überschreibt keine vorhandenen Werte
|
|
try:
|
|
if source == "musicbrainz":
|
|
details = await get_release_details(source_id)
|
|
if details:
|
|
_enrich_match(match_result, details)
|
|
elif source == "open_library":
|
|
details = await get_work_details(source_id)
|
|
if details:
|
|
_enrich_match(match_result, details)
|
|
except Exception as e:
|
|
logger.warning(f"Details-Laden fehlgeschlagen ({source}: {source_id}): {e}")
|
|
|
|
match_result.confidence = 1.0
|
|
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.post("/{item_id}/extract-cover")
|
|
async def extract_local_cover(
|
|
item_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Extrahiert ein Cover aus Ordner-Dateien oder eingebettetem Artwork."""
|
|
from ..services.scanner import _save_local_cover
|
|
from ..models.media_item import BookFile
|
|
import os
|
|
|
|
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")
|
|
|
|
files_result = await db.execute(
|
|
select(BookFile).where(BookFile.library_item_id == item_id).order_by(BookFile.track_index)
|
|
)
|
|
audio_files = [f.path for f in files_result.scalars().all()]
|
|
|
|
cover = _save_local_cover(item.path, audio_files, item.id)
|
|
if cover:
|
|
item.cover_path = cover
|
|
item.updated_at = datetime.utcnow()
|
|
await db.commit()
|
|
logger.info(f"Lokales Cover gesetzt für {item_id}: {cover}")
|
|
return {"success": True, "cover_path": cover}
|
|
return {"success": False, "message": "Kein Cover gefunden"}
|
|
|
|
|
|
@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}
|