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 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()

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")

View File

@@ -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

View File

@@ -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<HTMLAudioElement>(null)
const tracksRef = useRef<any[]>([])
const currentTrackIdxRef = useRef(0)
const pendingSeekRef = useRef<number | null>(null)
const currentTimeRef = useRef(0)
const isPlayingRef = useRef(false)
const currentTrackIdx = useRef(0)
const pendingSeek = useRef<number | null>(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<HTMLInputElement>) => {
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() {
<div className="flex flex-col h-full bg-background p-6 overflow-y-auto">
<audio
ref={audioRef}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMetadata}
onCanPlay={onCanPlay}
onEnded={onEnded}
onError={onError}
/>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<button onClick={() => setExpanded(false)} className="text-muted hover:text-ink transition-colors">
<ChevronLeft size={24} />
@@ -198,7 +257,6 @@ export default function AudioPlayer() {
</button>
</div>
{/* Cover */}
<div className="flex justify-center mb-6">
<CoverImage
src={item ? coverUrl(item.id) : null}
@@ -207,7 +265,6 @@ export default function AudioPlayer() {
/>
</div>
{/* Title */}
<div className="text-center mb-6">
{currentChapter && (
<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>}
</div>
{/* Progress bar */}
<div className="mb-4">
<div className="relative bg-muted2 rounded-full mb-2" style={{ height: '4px' }}>
{chapterMarkers.map((m: any, i: number) => (
@@ -228,19 +284,18 @@ export default function AudioPlayer() {
/>
<input
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"
/>
</div>
<div className="flex justify-between text-muted" style={{ fontSize: '11px' }}>
<span>{fmtTime(currentTime)}</span>
<span>-{fmtTime(duration - currentTime)}</span>
<span>-{fmtTime(Math.max(0, duration - currentTime))}</span>
</div>
</div>
{/* Controls */}
<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} />
</button>
<button
@@ -252,14 +307,12 @@ export default function AudioPlayer() {
>
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</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} />
</button>
</div>
{/* Secondary controls */}
<div className="flex items-center justify-between">
{/* Speed */}
<div className="relative">
<button
className="text-muted hover:text-ink border border-divider transition-colors"
@@ -283,12 +336,10 @@ export default function AudioPlayer() {
)}
</div>
{/* Bookmark */}
<button className="text-muted hover:text-ink transition-colors" onClick={addBookmark}>
<BookmarkPlus size={20} />
</button>
{/* Sleep Timer */}
<div className="relative">
<button
className={`hover:text-ink transition-colors ${sleepTimerActive ? 'text-primary' : 'text-muted'}`}
@@ -314,7 +365,6 @@ export default function AudioPlayer() {
)}
</div>
{/* Chapter List */}
<button
className={`hover:text-ink transition-colors ${showChapters ? 'text-primary' : 'text-muted'}`}
onClick={() => setShowChapters(!showChapters)}
@@ -322,18 +372,16 @@ export default function AudioPlayer() {
<List size={20} />
</button>
{/* Volume */}
<button className="text-muted hover:text-ink transition-colors" onClick={() => setMuted(!muted)}>
{muted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
</div>
{/* Chapter List Panel */}
{showChapters && chapters.length > 0 && (
<ChapterList
chapters={chapters}
currentTime={currentTime}
onSeek={seekToAbsoluteTime}
onSeek={seekTo}
/>
)}
</div>