Files
Audiolib/backend/app/routers/matching.py
Audiolib 17b77afd45 Rewrite player + fix matching metadata loss
Streaming: Custom range-aware HTTP endpoint. Returns 206 Partial Content
for Range requests (with Content-Range, Content-Length, Accept-Ranges).
This was the root cause of broken seeking — Starlette's default
FileResponse behavior wasn't reliable across all clients. Now seeking
works natively via standard HTML5 audio.

Player: Full rewrite. Cleaner separation between absolute book time and
per-track time. Track switching uses pendingSeek + canplay/loadedmetadata
handlers. Console logs for debugging. Removed crossOrigin to avoid CORS
issues. Removed hls.js entirely.

Matcher: Critical bug fix — get_work_details (OpenLibrary) was returning
a sparse MatchResult that REPLACED the rich search result, losing cover,
author, year. New _enrich_match merges details into best without
overwriting existing values (except description/chapters which are
preferred from details fetch).

Scoring: Lenient min/max-weighted similarity (better for German episodic
titles like "Die drei ??? - Folge 215"). Thresholds lowered:
UNCERTAIN 0.50→0.40, AUTO_ACCEPT 0.75→0.65.

Search: search_for_item now returns ALL fields (narrator, publisher,
series, genres, description, language) so manual apply has full data.

Apply: apply_match now always constructs from body first, then enriches
with details. Previously OL applies would lose cover/author. Added
detailed logging across matcher and apply paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:02:13 +02:00

144 lines
4.9 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.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}