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