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>
This commit is contained in:
Audiolib
2026-05-26 18:02:13 +02:00
parent 6c702cb29f
commit 17b77afd45
4 changed files with 411 additions and 185 deletions

View File

@@ -1,17 +1,19 @@
import asyncio import asyncio
import logging
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from ..dependencies import get_db, get_current_user, require_admin from ..dependencies import get_db, get_current_user, require_admin
from ..models.user import User from ..models.user import User
from ..models.media_item import LibraryItem from ..models.media_item import LibraryItem
from ..services.matcher import match_audiobook, search_for_item, _apply_match, _score_result from ..services.matcher import match_audiobook, search_for_item, _apply_match, _enrich_match
from ..services.matching.musicbrainz import get_release_details from ..services.matching.musicbrainz import get_release_details
from ..services.matching.open_library import get_work_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 ..services.matching.base import MatchResult
from datetime import datetime from datetime import datetime
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/items", tags=["matching"]) router = APIRouter(prefix="/api/items", tags=["matching"])
@@ -59,7 +61,7 @@ async def apply_match(
): ):
""" """
Wendet einen manuell gewählten Match-Treffer an. Wendet einen manuell gewählten Match-Treffer an.
body: { source, id, title, author, ... } 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)) result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = result.scalar_one_or_none() item = result.scalar_one_or_none()
@@ -69,27 +71,44 @@ async def apply_match(
source = body.get("source", "manual") source = body.get("source", "manual")
source_id = body.get("id", "") source_id = body.get("id", "")
# Versuche Details zu laden wenn MusicBrainz/OpenLibrary logger.info(
match_result = None f"Manual apply: item={item_id} source={source} source_id={source_id} "
if source == "musicbrainz": f"body_keys={sorted(body.keys())}"
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: # Immer aus body konstruieren (search_for_item liefert jetzt alle Felder)
# Fallback: nur die übergebenen Daten verwenden match_result = MatchResult(
match_result = MatchResult( source=source,
source=source, source_id=source_id,
source_id=source_id, title=body.get("title") or item.title or "",
title=body.get("title", item.title or ""), subtitle=body.get("subtitle"),
author=body.get("author"), author=body.get("author"),
publish_year=body.get("publishYear"), narrator=body.get("narrator"),
cover_url=body.get("cover"), description=body.get("description"),
confidence=1.0, 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,
)
match_result.confidence = 1.0 # Manuell → immer akzeptieren # 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) await _apply_match(db, item, match_result, confidence=1.0)
item.match_locked = True item.match_locked = True
item.updated_at = datetime.utcnow() item.updated_at = datetime.utcnow()

View File

@@ -1,8 +1,9 @@
import os import os
import uuid import uuid
import logging
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Header from fastapi import APIRouter, Depends, HTTPException, Query, Header, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, StreamingResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from ..dependencies import get_db, get_current_user from ..dependencies import get_db, get_current_user
@@ -12,8 +13,15 @@ from ..models.session import PlaybackSession
from ..models.progress import MediaProgress from ..models.progress import MediaProgress
from ..services.auth import decode_token from ..services.auth import decode_token
logger = logging.getLogger(__name__)
router = APIRouter(tags=["stream"]) router = APIRouter(tags=["stream"])
_MIME = {
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".m4b": "audio/mp4",
".aac": "audio/aac", ".ogg": "audio/ogg", ".opus": "audio/ogg",
".flac": "audio/flac", ".wav": "audio/wav",
}
@router.post("/api/items/{item_id}/play") @router.post("/api/items/{item_id}/play")
async def start_playback( async def start_playback(
@@ -74,10 +82,6 @@ async def start_playback(
for c in chapters for c in chapters
] ]
_MIME = {
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".m4b": "audio/mp4",
".aac": "audio/aac", ".ogg": "audio/ogg", ".flac": "audio/flac", ".wav": "audio/wav",
}
audio_tracks = [] audio_tracks = []
offset = 0.0 offset = 0.0
for i, f in enumerate(files): for i, f in enumerate(files):
@@ -88,13 +92,18 @@ async def start_playback(
"startOffset": offset, "startOffset": offset,
"duration": dur, "duration": dur,
"title": f.filename or f"Part {i + 1}", "title": f.filename or f"Part {i + 1}",
"contentUrl": f"/api/stream/{session_id}?track={i}", "contentUrl": f"/api/stream/{session_id}/{i}",
"mimeType": _MIME.get(ext, "audio/mpeg"), "mimeType": _MIME.get(ext, "audio/mpeg"),
}) })
offset += dur offset += dur
total_duration = item.duration_seconds or offset total_duration = item.duration_seconds or offset
logger.info(
f"Playback gestartet: item={item.title!r} session={session_id} "
f"files={len(files)} duration={total_duration:.1f}s startAt={current_time:.1f}s"
)
return { return {
"id": session_id, "id": session_id,
"userId": current_user.id, "userId": current_user.id,
@@ -123,15 +132,132 @@ async def start_playback(
} }
@router.get("/api/stream/{session_id}") def _parse_range(range_header: str, file_size: int) -> tuple[int, int] | None:
"""Parsed 'bytes=START-END'. Gibt (start, end) zurück oder None bei Fehler."""
if not range_header or not range_header.startswith("bytes="):
return None
try:
spec = range_header[6:].strip()
# nur erste Range
spec = spec.split(",")[0]
start_s, _, end_s = spec.partition("-")
if start_s == "":
# Suffix-Range: bytes=-500 → letzte 500 Bytes
length = int(end_s)
start = max(0, file_size - length)
end = file_size - 1
else:
start = int(start_s)
end = int(end_s) if end_s else file_size - 1
if start < 0 or end < start or start >= file_size:
return None
end = min(end, file_size - 1)
return start, end
except (ValueError, IndexError):
return None
async def _stream_file_range(path: str, start: int, end: int, chunk_size: int = 64 * 1024):
"""Streamt einen Byte-Bereich aus einer Datei."""
with open(path, "rb") as f:
f.seek(start)
remaining = end - start + 1
while remaining > 0:
chunk = f.read(min(chunk_size, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
@router.get("/api/stream/{session_id}/{track}")
async def stream_file( async def stream_file(
session_id: str, session_id: str,
track: int = Query(0), track: int,
request: Request,
token: str | None = Query(None), token: str | None = Query(None),
authorization: str | None = Header(None), authorization: str | None = Header(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Direktes Audio-Streaming mit Range-Request-Unterstützung.""" """Audio-Streaming mit nativen HTTP Range Requests (206 Partial Content)."""
raw = token
if not raw and authorization:
parts = authorization.split(" ", 1)
if len(parts) == 2 and parts[0].lower() == "bearer":
raw = parts[1]
if not raw or not decode_token(raw):
logger.warning(f"Stream 401: session={session_id} track={track}")
raise HTTPException(status_code=401, detail="Nicht autorisiert")
result = await db.execute(
select(PlaybackSession).where(PlaybackSession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session nicht gefunden")
files_result = await db.execute(
select(BookFile)
.where(BookFile.library_item_id == session.library_item_id)
.order_by(BookFile.track_index)
)
files = files_result.scalars().all()
if track < 0 or track >= len(files):
raise HTTPException(status_code=404, detail="Track nicht gefunden")
path = files[track].path
if not os.path.exists(path):
logger.warning(f"Audio-Datei nicht gefunden: {path}")
raise HTTPException(status_code=404, detail="Audio-Datei nicht gefunden")
file_size = os.path.getsize(path)
ext = os.path.splitext(path)[1].lower()
media_type = _MIME.get(ext, "audio/mpeg")
range_header = request.headers.get("range") or request.headers.get("Range")
parsed = _parse_range(range_header, file_size) if range_header else None
common_headers = {
"Accept-Ranges": "bytes",
"Cache-Control": "no-cache",
}
if parsed:
start, end = parsed
chunk_size = end - start + 1
logger.info(
f"Stream Range: session={session_id} track={track} "
f"bytes={start}-{end}/{file_size}"
)
return StreamingResponse(
_stream_file_range(path, start, end),
status_code=206,
media_type=media_type,
headers={
**common_headers,
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Content-Length": str(chunk_size),
},
)
logger.info(f"Stream full: session={session_id} track={track} size={file_size}")
return FileResponse(
path,
media_type=media_type,
headers={**common_headers, "Content-Length": str(file_size)},
)
@router.head("/api/stream/{session_id}/{track}")
async def stream_head(
session_id: str,
track: int,
token: str | None = Query(None),
authorization: str | None = Header(None),
db: AsyncSession = Depends(get_db),
):
"""HEAD-Request für Audio-Datei (Metadaten ohne Body)."""
raw = token raw = token
if not raw and authorization: if not raw and authorization:
parts = authorization.split(" ", 1) parts = authorization.split(" ", 1)
@@ -153,20 +279,24 @@ async def stream_file(
.order_by(BookFile.track_index) .order_by(BookFile.track_index)
) )
files = files_result.scalars().all() files = files_result.scalars().all()
if track < 0 or track >= len(files):
if track >= len(files):
raise HTTPException(status_code=404, detail="Track nicht gefunden") raise HTTPException(status_code=404, detail="Track nicht gefunden")
path = files[track].path path = files[track].path
if not os.path.exists(path): if not os.path.exists(path):
raise HTTPException(status_code=404, detail="Audio-Datei nicht gefunden") raise HTTPException(status_code=404, detail="Audio-Datei nicht gefunden")
_MIME = { file_size = os.path.getsize(path)
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".m4b": "audio/mp4",
".aac": "audio/aac", ".ogg": "audio/ogg", ".flac": "audio/flac", ".wav": "audio/wav",
}
ext = os.path.splitext(path)[1].lower() ext = os.path.splitext(path)[1].lower()
return FileResponse(path, media_type=_MIME.get(ext, "audio/mpeg")) media_type = _MIME.get(ext, "audio/mpeg")
return Response(
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
"Content-Type": media_type,
}
)
@router.post("/api/playback-session/{session_id}/sync") @router.post("/api/playback-session/{session_id}/sync")

View File

@@ -1,7 +1,7 @@
""" """
Matching-Orchestrator: Matching-Orchestrator:
- Erkennt deutsche Hörbuch-Serien (die drei ???, TKKG, ...) - Erkennt deutsche Hörbuch-Serien (die drei ???, TKKG, ...)
- Versucht MusicBrainz → OpenLibrary → Google Books - Versucht MusicBrainz → OpenLibrary → Google Books → DNB
- Lädt Cover herunter - Lädt Cover herunter
- Bewertet Konfidenz und entscheidet über Auto-Accept - Bewertet Konfidenz und entscheidet über Auto-Accept
""" """
@@ -28,10 +28,9 @@ from .matching.dnb import search_dnb
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
AUTO_ACCEPT_THRESHOLD = 0.75 AUTO_ACCEPT_THRESHOLD = 0.65
UNCERTAIN_THRESHOLD = 0.50 UNCERTAIN_THRESHOLD = 0.40
# Bekannte deutsche Hörbuch-Serien: (regex, kanonischer Name)
SERIES_PATTERNS = [ SERIES_PATTERNS = [
(r"(?i)^(die drei \?\?\?|die drei fragezeichen|drei fragezeichen)\s*[-]?\s*(?:folge\s*)?(\d+)", "Die drei ???"), (r"(?i)^(die drei \?\?\?|die drei fragezeichen|drei fragezeichen)\s*[-]?\s*(?:folge\s*)?(\d+)", "Die drei ???"),
(r"(?i)^(tkkg)\s*[-]?\s*(?:folge\s*)?(\d+)", "TKKG"), (r"(?i)^(tkkg)\s*[-]?\s*(?:folge\s*)?(\d+)", "TKKG"),
@@ -40,15 +39,12 @@ SERIES_PATTERNS = [
(r"(?i)^(benjamin blümchen|benjamin blumchen)\s*[-]?\s*(?:folge\s*)?(\d+)", "Benjamin Blümchen"), (r"(?i)^(benjamin blümchen|benjamin blumchen)\s*[-]?\s*(?:folge\s*)?(\d+)", "Benjamin Blümchen"),
(r"(?i)^(bibi und tina)\s*[-]?\s*(?:folge\s*)?(\d+)", "Bibi und Tina"), (r"(?i)^(bibi und tina)\s*[-]?\s*(?:folge\s*)?(\d+)", "Bibi und Tina"),
(r"(?i)^(der kleine vampir)\s*[-]?\s*(?:band\s*)?(\d+)", "Der kleine Vampir"), (r"(?i)^(der kleine vampir)\s*[-]?\s*(?:band\s*)?(\d+)", "Der kleine Vampir"),
# Generisch: "Serie - Folge/Band/Teil N - Titel"
(r"(?i)^(.+?)\s*[-]\s*(?:folge|band|teil|nr\.?|#)\s*(\d+)", None), (r"(?i)^(.+?)\s*[-]\s*(?:folge|band|teil|nr\.?|#)\s*(\d+)", None),
# Generisch: "Serie (Folge N)"
(r"(?i)^(.+?)\s*\((?:folge|band|teil|nr\.?|#|episode)\s*(\d+)\)", None), (r"(?i)^(.+?)\s*\((?:folge|band|teil|nr\.?|#|episode)\s*(\d+)\)", None),
] ]
def detect_series(title: str) -> tuple[str | None, str | None]: def detect_series(title: str) -> tuple[str | None, str | None]:
"""Gibt (Serienname, Folgennummer) zurück oder (None, None)."""
for pattern, canonical_name in SERIES_PATTERNS: for pattern, canonical_name in SERIES_PATTERNS:
m = re.match(pattern, title.strip()) m = re.match(pattern, title.strip())
if m: if m:
@@ -59,52 +55,80 @@ def detect_series(title: str) -> tuple[str | None, str | None]:
def _title_similarity(a: str, b: str) -> float: def _title_similarity(a: str, b: str) -> float:
"""Einfache Ähnlichkeit: Wort-Überlapp.""" """Wort-Überlapp mit Min/Max-Gewichtung — lenient für Teil-Treffer."""
if not a or not b: if not a or not b:
return 0.0 return 0.0
wa = set(re.findall(r'\w+', a.lower())) wa = set(re.findall(r"\w+", a.lower()))
wb = set(re.findall(r'\w+', b.lower())) wb = set(re.findall(r"\w+", b.lower()))
if not wa or not wb: if not wa or not wb:
return 0.0 return 0.0
return len(wa & wb) / max(len(wa), len(wb)) intersect = len(wa & wb)
if intersect == 0:
return 0.0
smaller = min(len(wa), len(wb))
larger = max(len(wa), len(wb))
return 0.7 * (intersect / smaller) + 0.3 * (intersect / larger)
def _score_result(result: MatchResult, query_title: str, query_author: str | None) -> float: def _score_result(result: MatchResult, query_title: str, query_author: str | None) -> float:
score = result.confidence score = result.confidence
title_sim = _title_similarity(result.title, query_title) title_sim = _title_similarity(result.title, query_title)
score = score * 0.4 + title_sim * 0.6 score = score * 0.3 + title_sim * 0.7
if query_author and result.author: if query_author and result.author:
author_sim = _title_similarity(result.author, query_author) author_sim = _title_similarity(result.author, query_author)
score = score * 0.7 + author_sim * 0.3 score = score * 0.7 + author_sim * 0.3
return min(score, 1.0) return min(score, 1.0)
def _enrich_match(best: MatchResult, details: MatchResult) -> MatchResult:
"""Befüllt leere Felder in best mit Werten aus details. Beschreibung/Kapitel werden bevorzugt aus details übernommen."""
if details.description:
best.description = details.description
if details.chapters and not best.chapters:
best.chapters = details.chapters
for attr in (
"subtitle", "narrator", "cover_url", "publisher",
"publish_year", "series", "series_sequence", "language",
):
val = getattr(details, attr, None)
if val and not getattr(best, attr, None):
setattr(best, attr, val)
if details.genres:
existing = set(best.genres or [])
best.genres = (best.genres or []) + [g for g in details.genres if g not in existing]
return best
async def _download_cover(url: str, item_id: str) -> str | None: async def _download_cover(url: str, item_id: str) -> str | None:
"""Lädt Cover herunter und speichert es lokal."""
settings = get_settings() settings = get_settings()
ext = ".jpg" ext = ".jpg"
if ".png" in url: if ".png" in url.lower():
ext = ".png" ext = ".png"
dest = os.path.join(settings.covers_dir, f"{item_id}{ext}") dest = os.path.join(settings.covers_dir, f"{item_id}{ext}")
logger.info(f"Cover-Download: {url}") logger.info(f"Cover-Download: {url}")
try: try:
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
r = await client.get(url) r = await client.get(url)
if r.status_code == 200: if r.status_code == 200 and len(r.content) > 1000:
os.makedirs(settings.covers_dir, exist_ok=True) os.makedirs(settings.covers_dir, exist_ok=True)
with open(dest, "wb") as f: with open(dest, "wb") as f:
f.write(r.content) f.write(r.content)
logger.info(f"Cover gespeichert: {dest} ({len(r.content)} Bytes)") logger.info(f"Cover gespeichert: {dest} ({len(r.content)} Bytes)")
return dest return dest
else: else:
logger.warning(f"Cover-Download HTTP {r.status_code}: {url}") logger.warning(f"Cover-Download HTTP {r.status_code}, size={len(r.content)}: {url}")
except Exception as e: except Exception as e:
logger.warning(f"Cover-Download Fehler ({url}): {e}") logger.warning(f"Cover-Download Fehler ({url}): {e}")
return None return None
async def _apply_match(db: AsyncSession, item: LibraryItem, result: MatchResult, confidence: float): async def _apply_match(db: AsyncSession, item: LibraryItem, result: MatchResult, confidence: float):
"""Schreibt Metadaten aus MatchResult in die DB.""" logger.info(
f"Apply match: item={item.id} title={result.title!r} author={result.author!r} "
f"narrator={result.narrator!r} publisher={result.publisher!r} year={result.publish_year} "
f"series={result.series!r}/{result.series_sequence} cover={bool(result.cover_url)} "
f"chapters={len(result.chapters or [])} confidence={confidence:.2f}"
)
if result.title: if result.title:
item.title = result.title item.title = result.title
if result.subtitle and not item.subtitle: if result.subtitle and not item.subtitle:
@@ -133,7 +157,6 @@ async def _apply_match(db: AsyncSession, item: LibraryItem, result: MatchResult,
item.match_confidence = confidence item.match_confidence = confidence
item.updated_at = datetime.utcnow() item.updated_at = datetime.utcnow()
# Cover herunterladen
if result.cover_url and not item.cover_path: if result.cover_url and not item.cover_path:
cover_path = await _download_cover(result.cover_url, item.id) cover_path = await _download_cover(result.cover_url, item.id)
if cover_path: if cover_path:
@@ -141,10 +164,8 @@ async def _apply_match(db: AsyncSession, item: LibraryItem, result: MatchResult,
elif not result.cover_url: elif not result.cover_url:
logger.info(f"Kein Cover-URL in Match-Ergebnis ({result.source}: {result.source_id})") logger.info(f"Kein Cover-URL in Match-Ergebnis ({result.source}: {result.source_id})")
# Kapitel aus MusicBrainz-Tracklisting
if result.chapters: if result.chapters:
from sqlalchemy import delete from sqlalchemy import delete
from ..models.media_item import Chapter
await db.execute(delete(Chapter).where(Chapter.library_item_id == item.id)) await db.execute(delete(Chapter).where(Chapter.library_item_id == item.id))
for idx, ch in enumerate(result.chapters): for idx, ch in enumerate(result.chapters):
chapter = Chapter( chapter = Chapter(
@@ -156,7 +177,6 @@ async def _apply_match(db: AsyncSession, item: LibraryItem, result: MatchResult,
) )
db.add(chapter) db.add(chapter)
# zu_prüfen entfernen wenn Konfidenz hoch genug
if confidence >= AUTO_ACCEPT_THRESHOLD: if confidence >= AUTO_ACCEPT_THRESHOLD:
tags = item.tags or [] tags = item.tags or []
item.tags = [t for t in tags if t != "zu_prüfen"] item.tags = [t for t in tags if t != "zu_prüfen"]
@@ -171,17 +191,12 @@ _SOURCE_FUNCS = {
async def match_audiobook(item_id: str): async def match_audiobook(item_id: str):
"""
Haupt-Matching-Funktion. Wird nach dem Scan als Hintergrund-Task gestartet.
Quellen und Reihenfolge werden aus den Library-Settings gelesen.
"""
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
result_row = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id)) result_row = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = result_row.scalar_one_or_none() item = result_row.scalar_one_or_none()
if not item or item.match_locked: if not item or item.match_locked:
return return
# Globale Auto-Match Einstellung prüfen
setting = await db.execute( setting = await db.execute(
select(ServerSetting).where(ServerSetting.key == "autoMatchBooks") select(ServerSetting).where(ServerSetting.key == "autoMatchBooks")
) )
@@ -189,7 +204,6 @@ async def match_audiobook(item_id: str):
if s and s.value is False: if s and s.value is False:
return return
# Matching-Quellen aus Library-Settings lesen
lib_row = await db.execute(select(Library).where(Library.id == item.library_id)) lib_row = await db.execute(select(Library).where(Library.id == item.library_id))
lib = lib_row.scalar_one_or_none() lib = lib_row.scalar_one_or_none()
sources: list[str] = ( sources: list[str] = (
@@ -209,13 +223,13 @@ async def match_audiobook(item_id: str):
if not item.series_sequence and episode: if not item.series_sequence and episode:
item.series_sequence = episode item.series_sequence = episode
logger.info(f"Matche: '{title}' | Quellen: {sources}") logger.info(f"Matche: '{title}' (Such-Titel: '{search_title}') | Quellen: {sources}")
best: MatchResult | None = None best: MatchResult | None = None
best_score = 0.0 best_score = 0.0
for source_name in sources: for source_name in sources:
if best_score >= UNCERTAIN_THRESHOLD: if best_score >= AUTO_ACCEPT_THRESHOLD:
break break
funcs = _SOURCE_FUNCS.get(source_name) funcs = _SOURCE_FUNCS.get(source_name)
if not funcs: if not funcs:
@@ -223,20 +237,26 @@ async def match_audiobook(item_id: str):
search_func, details_func = funcs search_func, details_func = funcs
try: try:
results = await search_func(search_title, author) results = await search_func(search_title, author)
logger.info(f"{source_name}: {len(results)} Treffer")
local_best: MatchResult | None = None
local_score = 0.0
for r in results: for r in results:
score = _score_result(r, title, author) score = _score_result(r, title, author)
if score > best_score: logger.info(f"{r.title!r} ({r.author!r}) score={score:.2f}")
best_score = score if score > local_score:
best = r local_score = score
# Details holen wenn Treffer gut genug (z.B. MB Tracklist) local_best = r
if best and best.source == source_name and best_score >= UNCERTAIN_THRESHOLD and details_func: if local_best and local_score > best_score:
try: best_score = local_score
details = await details_func(best.source_id) best = local_best
if details: if details_func and local_score >= UNCERTAIN_THRESHOLD:
details.confidence = best_score try:
best = details details = await details_func(local_best.source_id)
except Exception as e: if details:
logger.warning(f"{source_name} Details Fehler: {e}") _enrich_match(best, details)
logger.info(f"{source_name}: Details geladen für {local_best.source_id}")
except Exception as e:
logger.warning(f"{source_name} Details Fehler: {e}")
except Exception as e: except Exception as e:
logger.warning(f"{source_name} Fehler: {e}") logger.warning(f"{source_name} Fehler: {e}")
@@ -248,18 +268,17 @@ async def match_audiobook(item_id: str):
except Exception as e: except Exception as e:
logger.error(f"_apply_match fehlgeschlagen für '{title}': {e}", exc_info=True) logger.error(f"_apply_match fehlgeschlagen für '{title}': {e}", exc_info=True)
else: else:
logger.info(f"Kein Match für '{title}' (beste Konfidenz: {best_score:.2f})") logger.info(f"Kein Match für '{title}' (beste Konfidenz: {best_score:.2f}, Schwelle: {UNCERTAIN_THRESHOLD})")
await db.commit() await db.commit()
async def search_for_item(title: str, author: str | None = None) -> list[dict]: async def search_for_item(title: str, author: str | None = None) -> list[dict]:
"""Suche über alle Quellen für manuelles Matching.""" """Suche über alle Quellen für manuelles Matching. Gibt alle relevanten Felder zurück."""
results = []
async def _search_source(coro): async def _search_source(coro):
try: try:
return await coro return await coro
except Exception: except Exception as e:
logger.warning(f"Such-Fehler: {e}")
return [] return []
mb, ol, gb, dnb = await asyncio.gather( mb, ol, gb, dnb = await asyncio.gather(
@@ -269,16 +288,26 @@ async def search_for_item(title: str, author: str | None = None) -> list[dict]:
_search_source(search_dnb(title, author)), _search_source(search_dnb(title, author)),
) )
results = []
for r in mb + ol + gb + dnb: for r in mb + ol + gb + dnb:
results.append({ results.append({
"source": r.source, "source": r.source,
"id": r.source_id, "id": r.source_id,
"title": r.title, "title": r.title,
"subtitle": r.subtitle,
"author": r.author, "author": r.author,
"narrator": r.narrator,
"description": r.description,
"publisher": r.publisher,
"publishYear": r.publish_year, "publishYear": r.publish_year,
"series": r.series,
"seriesSequence": r.series_sequence,
"language": r.language,
"genres": r.genres,
"cover": r.cover_url, "cover": r.cover_url,
"confidence": r.confidence, "confidence": r.confidence,
}) })
results.sort(key=lambda x: x["confidence"], reverse=True) results.sort(key=lambda x: x["confidence"], reverse=True)
logger.info(f"Manuelle Suche '{title}' (author={author!r}): {len(results)} Treffer total")
return results return results

View File

@@ -9,77 +9,101 @@ import CoverImage from '../common/CoverImage'
import { coverUrl } from '../../api/items' import { coverUrl } from '../../api/items'
import ChapterList from './ChapterList' import ChapterList from './ChapterList'
interface Track {
index: number
startOffset: number
duration: number
contentUrl: string
mimeType: string
}
function findTrackForTime(tracks: Track[], time: number): number {
for (let i = tracks.length - 1; i >= 0; i--) {
if (time >= (tracks[i].startOffset || 0)) return i
}
return 0
}
function trackUrl(track: Track): string {
const token = localStorage.getItem('token') || ''
const sep = track.contentUrl.includes('?') ? '&' : '?'
return `${track.contentUrl}${sep}token=${encodeURIComponent(token)}`
}
export default function AudioPlayer() { export default function AudioPlayer() {
const { const session = usePlayerStore((s) => s.session)
item, session, currentTime, duration, isPlaying, playbackRate, volume, chapters, const item = usePlayerStore((s) => s.item)
sleepTimerActive, sleepTimer, const currentTime = usePlayerStore((s) => s.currentTime)
setPlaying, setCurrentTime, setPlaybackRate, setVolume, const duration = usePlayerStore((s) => s.duration)
seek, stop, setExpanded, setSleepTimer, cancelSleepTimer, syncProgress, const isPlaying = usePlayerStore((s) => s.isPlaying)
} = usePlayerStore() const playbackRate = usePlayerStore((s) => s.playbackRate)
const volume = usePlayerStore((s) => s.volume)
const chapters = usePlayerStore((s) => s.chapters)
const sleepTimer = usePlayerStore((s) => s.sleepTimer)
const sleepTimerActive = usePlayerStore((s) => s.sleepTimerActive)
const setPlaying = usePlayerStore((s) => s.setPlaying)
const setCurrentTime = usePlayerStore((s) => s.setCurrentTime)
const setPlaybackRate = usePlayerStore((s) => s.setPlaybackRate)
const seek = usePlayerStore((s) => s.seek)
const stop = usePlayerStore((s) => s.stop)
const setExpanded = usePlayerStore((s) => s.setExpanded)
const setSleepTimer = usePlayerStore((s) => s.setSleepTimer)
const cancelSleepTimer = usePlayerStore((s) => s.cancelSleepTimer)
const syncProgress = usePlayerStore((s) => s.syncProgress)
const audioRef = useRef<HTMLAudioElement>(null) const audioRef = useRef<HTMLAudioElement>(null)
const tracksRef = useRef<any[]>([]) const currentTrackIdx = useRef(0)
const currentTrackIdxRef = useRef(0) const pendingSeek = useRef<number | null>(null)
const pendingSeekRef = useRef<number | null>(null)
const currentTimeRef = useRef(0)
const isPlayingRef = useRef(false)
const [showChapters, setShowChapters] = useState(false) const [showChapters, setShowChapters] = useState(false)
const [showSpeedMenu, setShowSpeedMenu] = useState(false) const [showSpeedMenu, setShowSpeedMenu] = useState(false)
const [showSleepMenu, setShowSleepMenu] = useState(false) const [showSleepMenu, setShowSleepMenu] = useState(false)
const [muted, setMuted] = useState(false) const [muted, setMuted] = useState(false)
useEffect(() => { currentTimeRef.current = currentTime }, [currentTime]) const tracks: Track[] = session?.audioTracks || []
useEffect(() => { isPlayingRef.current = isPlaying }, [isPlaying])
const meta = item?.media?.metadata || {} // Initialize on session change
const title = meta.title || item?.relPath || ''
const author = meta.authors?.[0]?.name || ''
const loadTrack = useCallback((idx: number, startAt: number, autoPlay: boolean) => {
const audio = audioRef.current
const tracks = tracksRef.current
if (!audio || !tracks[idx]) return
const token = localStorage.getItem('token') || ''
currentTrackIdxRef.current = idx
audio.src = `${tracks[idx].contentUrl}&token=${encodeURIComponent(token)}`
if (startAt > 0) {
pendingSeekRef.current = startAt
try { audio.currentTime = startAt } catch {}
}
if (autoPlay) audio.play().catch(() => {})
}, [])
// Load audio when session starts
useEffect(() => { useEffect(() => {
if (!session || !audioRef.current) return if (!session || !audioRef.current || !tracks.length) return
const tracks = session.audioTracks || [] const audio = audioRef.current
if (!tracks.length) return
tracksRef.current = tracks
const startTime = session.currentTime || 0 const startTime = session.currentTime || 0
const startIdx = findTrackForTime(tracks, startTime)
const timeInTrack = startTime - (tracks[startIdx]?.startOffset || 0)
let startIdx = 0 console.log('[Player] init session', {
for (let i = tracks.length - 1; i >= 0; i--) { sessionId: session.id,
if (startTime >= (tracks[i].startOffset || 0)) { startIdx = i; break } tracks: tracks.length,
} duration: session.duration,
startTime,
startIdx,
timeInTrack,
})
audioRef.current.playbackRate = playbackRate currentTrackIdx.current = startIdx
audioRef.current.volume = muted ? 0 : volume pendingSeek.current = timeInTrack > 0 ? timeInTrack : null
loadTrack(startIdx, startTime - (tracks[startIdx].startOffset || 0), true)
audio.preload = 'auto'
audio.playbackRate = playbackRate
audio.volume = muted ? 0 : volume
audio.src = trackUrl(tracks[startIdx])
audio.load()
audio.play().catch((e) => console.warn('[Player] play() failed:', e))
return () => { return () => {
if (audioRef.current) { audio.pause()
audioRef.current.pause() audio.removeAttribute('src')
audioRef.current.removeAttribute('src') audio.load()
}
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.id]) }, [session?.id])
// Sync isPlaying with audio element
useEffect(() => { useEffect(() => {
if (!audioRef.current) return const audio = audioRef.current
if (isPlaying) audioRef.current.play().catch(() => {}) if (!audio || !audio.src) return
else audioRef.current.pause() if (isPlaying) audio.play().catch((e) => console.warn('[Player] play() failed:', e))
else audio.pause()
}, [isPlaying]) }, [isPlaying])
useEffect(() => { useEffect(() => {
@@ -90,67 +114,97 @@ export default function AudioPlayer() {
if (audioRef.current) audioRef.current.volume = muted ? 0 : volume if (audioRef.current) audioRef.current.volume = muted ? 0 : volume
}, [volume, muted]) }, [volume, muted])
const seekToAbsoluteTime = useCallback((t: number) => { const seekTo = useCallback((absoluteTime: number) => {
const audio = audioRef.current const audio = audioRef.current
const tracks = tracksRef.current
if (!audio || !tracks.length) return if (!audio || !tracks.length) return
const lastTrack = tracks[tracks.length - 1] const lastTrack = tracks[tracks.length - 1]
const totalDur = (lastTrack?.startOffset || 0) + (lastTrack?.duration || 0) const totalDur = (lastTrack?.startOffset || 0) + (lastTrack?.duration || 0)
const clamped = Math.max(0, Math.min(t, totalDur)) const clamped = Math.max(0, Math.min(absoluteTime, totalDur || absoluteTime))
let targetIdx = 0 const targetIdx = findTrackForTime(tracks, clamped)
for (let i = tracks.length - 1; i >= 0; i--) { const timeInTrack = clamped - (tracks[targetIdx]?.startOffset || 0)
if (clamped >= (tracks[i].startOffset || 0)) { targetIdx = i; break }
}
const timeInTrack = clamped - (tracks[targetIdx].startOffset || 0) console.log('[Player] seek', { absoluteTime, clamped, targetIdx, timeInTrack, currentIdx: currentTrackIdx.current })
if (targetIdx !== currentTrackIdxRef.current) { if (targetIdx === currentTrackIdx.current) {
loadTrack(targetIdx, timeInTrack, isPlayingRef.current)
} else {
audio.currentTime = timeInTrack audio.currentTime = timeInTrack
} else {
currentTrackIdx.current = targetIdx
pendingSeek.current = timeInTrack
audio.src = trackUrl(tracks[targetIdx])
audio.load()
if (isPlaying) audio.play().catch((e) => console.warn('[Player] play() failed:', e))
} }
seek(clamped) seek(clamped)
}, [loadTrack, seek]) }, [tracks, isPlaying, seek])
// Keyboard shortcuts — re-register when seekTo changes
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement) return if (e.target instanceof HTMLInputElement) return
if (e.code === 'Space') { e.preventDefault(); setPlaying(!isPlayingRef.current) } if (e.code === 'Space') { e.preventDefault(); setPlaying(!isPlaying) }
if (e.code === 'ArrowRight') seekToAbsoluteTime(currentTimeRef.current + 30) else if (e.code === 'ArrowRight') seekTo(currentTime + 30)
if (e.code === 'ArrowLeft') seekToAbsoluteTime(Math.max(0, currentTimeRef.current - 10)) else if (e.code === 'ArrowLeft') seekTo(Math.max(0, currentTime - 10))
} }
window.addEventListener('keydown', handler) window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler)
}, [seekToAbsoluteTime]) }, [seekTo, currentTime, isPlaying, setPlaying])
const handleTimeUpdate = () => { const onTimeUpdate = () => {
if (!audioRef.current) return const audio = audioRef.current
const track = tracksRef.current[currentTrackIdxRef.current] if (!audio) return
setCurrentTime((track?.startOffset || 0) + audioRef.current.currentTime) const offset = tracks[currentTrackIdx.current]?.startOffset || 0
setCurrentTime(offset + audio.currentTime)
} }
const handleLoadedMetadata = () => { const onLoadedMetadata = () => {
if (pendingSeekRef.current !== null && audioRef.current) { const audio = audioRef.current
audioRef.current.currentTime = pendingSeekRef.current if (!audio) return
pendingSeekRef.current = null console.log('[Player] loadedmetadata', {
trackIdx: currentTrackIdx.current,
duration: audio.duration,
pendingSeek: pendingSeek.current,
})
if (pendingSeek.current !== null) {
try { audio.currentTime = pendingSeek.current } catch (e) { console.warn('seek failed', e) }
pendingSeek.current = null
} }
} }
const handleEnded = () => { const onCanPlay = () => {
const tracks = tracksRef.current if (pendingSeek.current !== null && audioRef.current) {
const nextIdx = currentTrackIdxRef.current + 1 try { audioRef.current.currentTime = pendingSeek.current } catch {}
pendingSeek.current = null
}
}
const onEnded = () => {
const nextIdx = currentTrackIdx.current + 1
console.log('[Player] track ended', { nextIdx, total: tracks.length })
if (nextIdx < tracks.length) { if (nextIdx < tracks.length) {
loadTrack(nextIdx, 0, true) currentTrackIdx.current = nextIdx
pendingSeek.current = null
const audio = audioRef.current!
audio.src = trackUrl(tracks[nextIdx])
audio.load()
audio.play().catch((e) => console.warn('[Player] play() failed:', e))
} else { } else {
setPlaying(false) setPlaying(false)
syncProgress() syncProgress()
} }
} }
const handleSeekBar = (e: React.ChangeEvent<HTMLInputElement>) => { const onError = () => {
seekToAbsoluteTime(parseFloat(e.target.value)) const audio = audioRef.current
const err = audio?.error
console.error('[Player] audio error', {
code: err?.code,
message: err?.message,
src: audio?.src,
networkState: audio?.networkState,
readyState: audio?.readyState,
})
} }
const fmtTime = (s: number) => { const fmtTime = (s: number) => {
@@ -163,6 +217,10 @@ export default function AudioPlayer() {
: `${m}:${sec.toString().padStart(2, '0')}` : `${m}:${sec.toString().padStart(2, '0')}`
} }
const meta = item?.media?.metadata || {}
const title = meta.title || item?.relPath || ''
const author = meta.authors?.[0]?.name || ''
const currentChapter = [...chapters].reverse().find((c: any) => currentTime >= c.start) || chapters[0] const currentChapter = [...chapters].reverse().find((c: any) => currentTime >= c.start) || chapters[0]
const addBookmark = async () => { const addBookmark = async () => {
@@ -182,12 +240,13 @@ export default function AudioPlayer() {
<div className="flex flex-col h-full bg-background p-6 overflow-y-auto"> <div className="flex flex-col h-full bg-background p-6 overflow-y-auto">
<audio <audio
ref={audioRef} ref={audioRef}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={onTimeUpdate}
onLoadedMetadata={handleLoadedMetadata} onLoadedMetadata={onLoadedMetadata}
onEnded={handleEnded} onCanPlay={onCanPlay}
onEnded={onEnded}
onError={onError}
/> />
{/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<button onClick={() => setExpanded(false)} className="text-muted hover:text-ink transition-colors"> <button onClick={() => setExpanded(false)} className="text-muted hover:text-ink transition-colors">
<ChevronLeft size={24} /> <ChevronLeft size={24} />
@@ -198,7 +257,6 @@ export default function AudioPlayer() {
</button> </button>
</div> </div>
{/* Cover */}
<div className="flex justify-center mb-6"> <div className="flex justify-center mb-6">
<CoverImage <CoverImage
src={item ? coverUrl(item.id) : null} src={item ? coverUrl(item.id) : null}
@@ -207,7 +265,6 @@ export default function AudioPlayer() {
/> />
</div> </div>
{/* Title */}
<div className="text-center mb-6"> <div className="text-center mb-6">
{currentChapter && ( {currentChapter && (
<p className="text-primary mb-1 truncate" style={{ fontSize: '12px' }}>{currentChapter.title}</p> <p className="text-primary mb-1 truncate" style={{ fontSize: '12px' }}>{currentChapter.title}</p>
@@ -216,7 +273,6 @@ export default function AudioPlayer() {
{author && <p className="text-muted mt-1 truncate" style={{ fontSize: '13px' }}>{author}</p>} {author && <p className="text-muted mt-1 truncate" style={{ fontSize: '13px' }}>{author}</p>}
</div> </div>
{/* Progress bar */}
<div className="mb-4"> <div className="mb-4">
<div className="relative bg-muted2 rounded-full mb-2" style={{ height: '4px' }}> <div className="relative bg-muted2 rounded-full mb-2" style={{ height: '4px' }}>
{chapterMarkers.map((m: any, i: number) => ( {chapterMarkers.map((m: any, i: number) => (
@@ -228,19 +284,18 @@ export default function AudioPlayer() {
/> />
<input <input
type="range" min={0} max={duration || 0} step={1} value={currentTime} type="range" min={0} max={duration || 0} step={1} value={currentTime}
onChange={handleSeekBar} onChange={(e) => seekTo(parseFloat(e.target.value))}
className="absolute inset-0 w-full opacity-0 cursor-pointer h-full" className="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
/> />
</div> </div>
<div className="flex justify-between text-muted" style={{ fontSize: '11px' }}> <div className="flex justify-between text-muted" style={{ fontSize: '11px' }}>
<span>{fmtTime(currentTime)}</span> <span>{fmtTime(currentTime)}</span>
<span>-{fmtTime(duration - currentTime)}</span> <span>-{fmtTime(Math.max(0, duration - currentTime))}</span>
</div> </div>
</div> </div>
{/* Controls */}
<div className="flex items-center justify-center gap-6 mb-6"> <div className="flex items-center justify-center gap-6 mb-6">
<button className="text-muted hover:text-ink transition-colors" onClick={() => seekToAbsoluteTime(currentTime - 30)}> <button className="text-muted hover:text-ink transition-colors" onClick={() => seekTo(currentTime - 30)}>
<SkipBack size={28} /> <SkipBack size={28} />
</button> </button>
<button <button
@@ -252,14 +307,12 @@ export default function AudioPlayer() {
> >
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />} {isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button> </button>
<button className="text-muted hover:text-ink transition-colors" onClick={() => seekToAbsoluteTime(currentTime + 30)}> <button className="text-muted hover:text-ink transition-colors" onClick={() => seekTo(currentTime + 30)}>
<SkipForward size={28} /> <SkipForward size={28} />
</button> </button>
</div> </div>
{/* Secondary controls */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{/* Speed */}
<div className="relative"> <div className="relative">
<button <button
className="text-muted hover:text-ink border border-divider transition-colors" className="text-muted hover:text-ink border border-divider transition-colors"
@@ -283,12 +336,10 @@ export default function AudioPlayer() {
)} )}
</div> </div>
{/* Bookmark */}
<button className="text-muted hover:text-ink transition-colors" onClick={addBookmark}> <button className="text-muted hover:text-ink transition-colors" onClick={addBookmark}>
<BookmarkPlus size={20} /> <BookmarkPlus size={20} />
</button> </button>
{/* Sleep Timer */}
<div className="relative"> <div className="relative">
<button <button
className={`hover:text-ink transition-colors ${sleepTimerActive ? 'text-primary' : 'text-muted'}`} className={`hover:text-ink transition-colors ${sleepTimerActive ? 'text-primary' : 'text-muted'}`}
@@ -314,7 +365,6 @@ export default function AudioPlayer() {
)} )}
</div> </div>
{/* Chapter List */}
<button <button
className={`hover:text-ink transition-colors ${showChapters ? 'text-primary' : 'text-muted'}`} className={`hover:text-ink transition-colors ${showChapters ? 'text-primary' : 'text-muted'}`}
onClick={() => setShowChapters(!showChapters)} onClick={() => setShowChapters(!showChapters)}
@@ -322,18 +372,16 @@ export default function AudioPlayer() {
<List size={20} /> <List size={20} />
</button> </button>
{/* Volume */}
<button className="text-muted hover:text-ink transition-colors" onClick={() => setMuted(!muted)}> <button className="text-muted hover:text-ink transition-colors" onClick={() => setMuted(!muted)}>
{muted ? <VolumeX size={20} /> : <Volume2 size={20} />} {muted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button> </button>
</div> </div>
{/* Chapter List Panel */}
{showChapters && chapters.length > 0 && ( {showChapters && chapters.length > 0 && (
<ChapterList <ChapterList
chapters={chapters} chapters={chapters}
currentTime={currentTime} currentTime={currentTime}
onSeek={seekToAbsoluteTime} onSeek={seekTo}
/> />
)} )}
</div> </div>