From 17b77afd45823c75be28a9231ec1d69a8206ee91 Mon Sep 17 00:00:00 2001 From: Audiolib Date: Tue, 26 May 2026 18:02:13 +0200 Subject: [PATCH] Rewrite player + fix matching metadata loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/routers/matching.py | 63 +++-- backend/app/routers/stream.py | 164 ++++++++++-- backend/app/services/matcher.py | 121 +++++---- .../src/components/player/AudioPlayer.tsx | 248 +++++++++++------- 4 files changed, 411 insertions(+), 185 deletions(-) diff --git a/backend/app/routers/matching.py b/backend/app/routers/matching.py index ad9cefc..af725be 100644 --- a/backend/app/routers/matching.py +++ b/backend/app/routers/matching.py @@ -1,17 +1,19 @@ 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, _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.open_library import get_work_details -from ..services.matching.google_books import search_google_books from ..services.matching.base import MatchResult from datetime import datetime +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/items", tags=["matching"]) @@ -59,7 +61,7 @@ async def apply_match( ): """ 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)) item = result.scalar_one_or_none() @@ -69,27 +71,44 @@ async def apply_match( source = body.get("source", "manual") source_id = body.get("id", "") - # Versuche Details zu laden wenn MusicBrainz/OpenLibrary - match_result = None - if source == "musicbrainz": - 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) + logger.info( + f"Manual apply: item={item_id} source={source} source_id={source_id} " + f"body_keys={sorted(body.keys())}" + ) - if not match_result: - # Fallback: nur die übergebenen Daten verwenden - match_result = MatchResult( - source=source, - source_id=source_id, - title=body.get("title", item.title or ""), - author=body.get("author"), - publish_year=body.get("publishYear"), - cover_url=body.get("cover"), - confidence=1.0, - ) + # 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, + ) - 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) item.match_locked = True item.updated_at = datetime.utcnow() diff --git a/backend/app/routers/stream.py b/backend/app/routers/stream.py index 93dddc4..3003166 100644 --- a/backend/app/routers/stream.py +++ b/backend/app/routers/stream.py @@ -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") diff --git a/backend/app/services/matcher.py b/backend/app/services/matcher.py index 9398f7d..cf87413 100644 --- a/backend/app/services/matcher.py +++ b/backend/app/services/matcher.py @@ -1,7 +1,7 @@ """ Matching-Orchestrator: - Erkennt deutsche Hörbuch-Serien (die drei ???, TKKG, ...) -- Versucht MusicBrainz → OpenLibrary → Google Books +- Versucht MusicBrainz → OpenLibrary → Google Books → DNB - Lädt Cover herunter - Bewertet Konfidenz und entscheidet über Auto-Accept """ @@ -28,10 +28,9 @@ from .matching.dnb import search_dnb logger = logging.getLogger(__name__) -AUTO_ACCEPT_THRESHOLD = 0.75 -UNCERTAIN_THRESHOLD = 0.50 +AUTO_ACCEPT_THRESHOLD = 0.65 +UNCERTAIN_THRESHOLD = 0.40 -# Bekannte deutsche Hörbuch-Serien: (regex, kanonischer Name) SERIES_PATTERNS = [ (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"), @@ -40,15 +39,12 @@ SERIES_PATTERNS = [ (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)^(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), - # Generisch: "Serie (Folge N)" (r"(?i)^(.+?)\s*\((?:folge|band|teil|nr\.?|#|episode)\s*(\d+)\)", 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: m = re.match(pattern, title.strip()) if m: @@ -59,52 +55,80 @@ def detect_series(title: str) -> tuple[str | None, str | None]: 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: return 0.0 - wa = set(re.findall(r'\w+', a.lower())) - wb = set(re.findall(r'\w+', b.lower())) + wa = set(re.findall(r"\w+", a.lower())) + wb = set(re.findall(r"\w+", b.lower())) if not wa or not wb: 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: score = result.confidence 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: author_sim = _title_similarity(result.author, query_author) score = score * 0.7 + author_sim * 0.3 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: - """Lädt Cover herunter und speichert es lokal.""" settings = get_settings() ext = ".jpg" - if ".png" in url: + if ".png" in url.lower(): ext = ".png" dest = os.path.join(settings.covers_dir, f"{item_id}{ext}") logger.info(f"Cover-Download: {url}") try: async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client: 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) with open(dest, "wb") as f: f.write(r.content) logger.info(f"Cover gespeichert: {dest} ({len(r.content)} Bytes)") return dest 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: logger.warning(f"Cover-Download Fehler ({url}): {e}") return None 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: item.title = result.title 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.updated_at = datetime.utcnow() - # Cover herunterladen if result.cover_url and not item.cover_path: cover_path = await _download_cover(result.cover_url, item.id) if cover_path: @@ -141,10 +164,8 @@ async def _apply_match(db: AsyncSession, item: LibraryItem, result: MatchResult, elif not result.cover_url: logger.info(f"Kein Cover-URL in Match-Ergebnis ({result.source}: {result.source_id})") - # Kapitel aus MusicBrainz-Tracklisting if result.chapters: from sqlalchemy import delete - from ..models.media_item import Chapter await db.execute(delete(Chapter).where(Chapter.library_item_id == item.id)) for idx, ch in enumerate(result.chapters): chapter = Chapter( @@ -156,7 +177,6 @@ async def _apply_match(db: AsyncSession, item: LibraryItem, result: MatchResult, ) db.add(chapter) - # zu_prüfen entfernen wenn Konfidenz hoch genug if confidence >= AUTO_ACCEPT_THRESHOLD: tags = item.tags or [] 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): - """ - 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: result_row = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id)) item = result_row.scalar_one_or_none() if not item or item.match_locked: return - # Globale Auto-Match Einstellung prüfen setting = await db.execute( select(ServerSetting).where(ServerSetting.key == "autoMatchBooks") ) @@ -189,7 +204,6 @@ async def match_audiobook(item_id: str): if s and s.value is False: return - # Matching-Quellen aus Library-Settings lesen lib_row = await db.execute(select(Library).where(Library.id == item.library_id)) lib = lib_row.scalar_one_or_none() sources: list[str] = ( @@ -209,13 +223,13 @@ async def match_audiobook(item_id: str): if not item.series_sequence and 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_score = 0.0 for source_name in sources: - if best_score >= UNCERTAIN_THRESHOLD: + if best_score >= AUTO_ACCEPT_THRESHOLD: break funcs = _SOURCE_FUNCS.get(source_name) if not funcs: @@ -223,20 +237,26 @@ async def match_audiobook(item_id: str): search_func, details_func = funcs try: 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: score = _score_result(r, title, author) - if score > best_score: - best_score = score - best = r - # Details holen wenn Treffer gut genug (z.B. MB Tracklist) - if best and best.source == source_name and best_score >= UNCERTAIN_THRESHOLD and details_func: - try: - details = await details_func(best.source_id) - if details: - details.confidence = best_score - best = details - except Exception as e: - logger.warning(f"{source_name} Details Fehler: {e}") + logger.info(f" → {r.title!r} ({r.author!r}) score={score:.2f}") + if score > local_score: + local_score = score + local_best = r + if local_best and local_score > best_score: + best_score = local_score + best = local_best + if details_func and local_score >= UNCERTAIN_THRESHOLD: + try: + details = await details_func(local_best.source_id) + if details: + _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: logger.warning(f"{source_name} Fehler: {e}") @@ -248,18 +268,17 @@ async def match_audiobook(item_id: str): except Exception as e: logger.error(f"_apply_match fehlgeschlagen für '{title}': {e}", exc_info=True) 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() async def search_for_item(title: str, author: str | None = None) -> list[dict]: - """Suche über alle Quellen – für manuelles Matching.""" - results = [] - + """Suche über alle Quellen – für manuelles Matching. Gibt alle relevanten Felder zurück.""" async def _search_source(coro): try: return await coro - except Exception: + except Exception as e: + logger.warning(f"Such-Fehler: {e}") return [] 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)), ) + results = [] for r in mb + ol + gb + dnb: results.append({ "source": r.source, "id": r.source_id, "title": r.title, + "subtitle": r.subtitle, "author": r.author, + "narrator": r.narrator, + "description": r.description, + "publisher": r.publisher, "publishYear": r.publish_year, + "series": r.series, + "seriesSequence": r.series_sequence, + "language": r.language, + "genres": r.genres, "cover": r.cover_url, "confidence": r.confidence, }) results.sort(key=lambda x: x["confidence"], reverse=True) + logger.info(f"Manuelle Suche '{title}' (author={author!r}): {len(results)} Treffer total") return results diff --git a/frontend/src/components/player/AudioPlayer.tsx b/frontend/src/components/player/AudioPlayer.tsx index a9bc964..b874206 100644 --- a/frontend/src/components/player/AudioPlayer.tsx +++ b/frontend/src/components/player/AudioPlayer.tsx @@ -9,77 +9,101 @@ import CoverImage from '../common/CoverImage' import { coverUrl } from '../../api/items' 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() { - const { - item, session, currentTime, duration, isPlaying, playbackRate, volume, chapters, - sleepTimerActive, sleepTimer, - setPlaying, setCurrentTime, setPlaybackRate, setVolume, - seek, stop, setExpanded, setSleepTimer, cancelSleepTimer, syncProgress, - } = usePlayerStore() + const session = usePlayerStore((s) => s.session) + const item = usePlayerStore((s) => s.item) + const currentTime = usePlayerStore((s) => s.currentTime) + const duration = usePlayerStore((s) => s.duration) + const isPlaying = usePlayerStore((s) => s.isPlaying) + 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(null) - const tracksRef = useRef([]) - const currentTrackIdxRef = useRef(0) - const pendingSeekRef = useRef(null) - const currentTimeRef = useRef(0) - const isPlayingRef = useRef(false) + const currentTrackIdx = useRef(0) + const pendingSeek = useRef(null) const [showChapters, setShowChapters] = useState(false) const [showSpeedMenu, setShowSpeedMenu] = useState(false) const [showSleepMenu, setShowSleepMenu] = useState(false) const [muted, setMuted] = useState(false) - useEffect(() => { currentTimeRef.current = currentTime }, [currentTime]) - useEffect(() => { isPlayingRef.current = isPlaying }, [isPlaying]) + const tracks: Track[] = session?.audioTracks || [] - const meta = item?.media?.metadata || {} - 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 + // Initialize on session change useEffect(() => { - if (!session || !audioRef.current) return - const tracks = session.audioTracks || [] - if (!tracks.length) return + if (!session || !audioRef.current || !tracks.length) return + const audio = audioRef.current - tracksRef.current = tracks const startTime = session.currentTime || 0 + const startIdx = findTrackForTime(tracks, startTime) + const timeInTrack = startTime - (tracks[startIdx]?.startOffset || 0) - let startIdx = 0 - for (let i = tracks.length - 1; i >= 0; i--) { - if (startTime >= (tracks[i].startOffset || 0)) { startIdx = i; break } - } + console.log('[Player] init session', { + sessionId: session.id, + tracks: tracks.length, + duration: session.duration, + startTime, + startIdx, + timeInTrack, + }) - audioRef.current.playbackRate = playbackRate - audioRef.current.volume = muted ? 0 : volume - loadTrack(startIdx, startTime - (tracks[startIdx].startOffset || 0), true) + currentTrackIdx.current = startIdx + pendingSeek.current = timeInTrack > 0 ? timeInTrack : null + + 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 () => { - if (audioRef.current) { - audioRef.current.pause() - audioRef.current.removeAttribute('src') - } + audio.pause() + audio.removeAttribute('src') + audio.load() } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [session?.id]) + // Sync isPlaying with audio element useEffect(() => { - if (!audioRef.current) return - if (isPlaying) audioRef.current.play().catch(() => {}) - else audioRef.current.pause() + const audio = audioRef.current + if (!audio || !audio.src) return + if (isPlaying) audio.play().catch((e) => console.warn('[Player] play() failed:', e)) + else audio.pause() }, [isPlaying]) useEffect(() => { @@ -90,67 +114,97 @@ export default function AudioPlayer() { if (audioRef.current) audioRef.current.volume = muted ? 0 : volume }, [volume, muted]) - const seekToAbsoluteTime = useCallback((t: number) => { + const seekTo = useCallback((absoluteTime: number) => { const audio = audioRef.current - const tracks = tracksRef.current if (!audio || !tracks.length) return const lastTrack = tracks[tracks.length - 1] 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 - for (let i = tracks.length - 1; i >= 0; i--) { - if (clamped >= (tracks[i].startOffset || 0)) { targetIdx = i; break } - } + const targetIdx = findTrackForTime(tracks, clamped) + const timeInTrack = clamped - (tracks[targetIdx]?.startOffset || 0) - const timeInTrack = clamped - (tracks[targetIdx].startOffset || 0) + console.log('[Player] seek', { absoluteTime, clamped, targetIdx, timeInTrack, currentIdx: currentTrackIdx.current }) - if (targetIdx !== currentTrackIdxRef.current) { - loadTrack(targetIdx, timeInTrack, isPlayingRef.current) - } else { + if (targetIdx === currentTrackIdx.current) { 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) - }, [loadTrack, seek]) + }, [tracks, isPlaying, seek]) + // Keyboard shortcuts — re-register when seekTo changes useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement) return - if (e.code === 'Space') { e.preventDefault(); setPlaying(!isPlayingRef.current) } - if (e.code === 'ArrowRight') seekToAbsoluteTime(currentTimeRef.current + 30) - if (e.code === 'ArrowLeft') seekToAbsoluteTime(Math.max(0, currentTimeRef.current - 10)) + if (e.code === 'Space') { e.preventDefault(); setPlaying(!isPlaying) } + else if (e.code === 'ArrowRight') seekTo(currentTime + 30) + else if (e.code === 'ArrowLeft') seekTo(Math.max(0, currentTime - 10)) } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) - }, [seekToAbsoluteTime]) + }, [seekTo, currentTime, isPlaying, setPlaying]) - const handleTimeUpdate = () => { - if (!audioRef.current) return - const track = tracksRef.current[currentTrackIdxRef.current] - setCurrentTime((track?.startOffset || 0) + audioRef.current.currentTime) + const onTimeUpdate = () => { + const audio = audioRef.current + if (!audio) return + const offset = tracks[currentTrackIdx.current]?.startOffset || 0 + setCurrentTime(offset + audio.currentTime) } - const handleLoadedMetadata = () => { - if (pendingSeekRef.current !== null && audioRef.current) { - audioRef.current.currentTime = pendingSeekRef.current - pendingSeekRef.current = null + const onLoadedMetadata = () => { + const audio = audioRef.current + if (!audio) return + 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 tracks = tracksRef.current - const nextIdx = currentTrackIdxRef.current + 1 + const onCanPlay = () => { + if (pendingSeek.current !== null && audioRef.current) { + 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) { - 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 { setPlaying(false) syncProgress() } } - const handleSeekBar = (e: React.ChangeEvent) => { - seekToAbsoluteTime(parseFloat(e.target.value)) + const onError = () => { + 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) => { @@ -163,6 +217,10 @@ export default function AudioPlayer() { : `${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 addBookmark = async () => { @@ -182,12 +240,13 @@ export default function AudioPlayer() {