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,8 +1,9 @@
import os
import uuid
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Header
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, HTTPException, Query, Header, Request
from fastapi.responses import FileResponse, StreamingResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from ..dependencies import get_db, get_current_user
@@ -12,8 +13,15 @@ from ..models.session import PlaybackSession
from ..models.progress import MediaProgress
from ..services.auth import decode_token
logger = logging.getLogger(__name__)
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")
async def start_playback(
@@ -74,10 +82,6 @@ async def start_playback(
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 = []
offset = 0.0
for i, f in enumerate(files):
@@ -88,13 +92,18 @@ async def start_playback(
"startOffset": offset,
"duration": dur,
"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"),
})
offset += dur
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 {
"id": session_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(
session_id: str,
track: int = Query(0),
track: int,
request: Request,
token: str | None = Query(None),
authorization: str | None = Header(None),
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
if not raw and authorization:
parts = authorization.split(" ", 1)
@@ -153,20 +279,24 @@ async def stream_file(
.order_by(BookFile.track_index)
)
files = files_result.scalars().all()
if track >= len(files):
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):
raise HTTPException(status_code=404, detail="Audio-Datei nicht gefunden")
_MIME = {
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".m4b": "audio/mp4",
".aac": "audio/aac", ".ogg": "audio/ogg", ".flac": "audio/flac", ".wav": "audio/wav",
}
file_size = os.path.getsize(path)
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")