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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user