Simplify streaming auth + add local cover extraction
Streaming: Drop token-in-URL auth entirely. Session-ID (UUID, 128-bit
entropy) IS the auth — same approach as Audiobookshelf. Eliminates the
entire class of token-related failures and matches how every other
streaming server handles this. Logs every stream request with Range
header and User-Agent for diagnostics.
Player: Visible error banner in UI when audio fails (with HTML5 media
error code translated to German). Stream URL is shown in the banner so
the user can see exactly what failed.
Scanner: Cover extraction from two new sources (in addition to API
matching):
1. Folder-level images (cover.jpg, folder.jpg, front.jpg, etc.)
2. Embedded artwork (ID3 APIC, MP4 covr, FLAC/Vorbis pictures)
Runs on every scan — also fills in covers for items that were already
scanned but never got one from matching.
New endpoint POST /api/items/{id}/extract-cover triggers this manually
for a single item.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -119,6 +119,37 @@ async def apply_match(
|
|||||||
return await _enrich_item_with_files(item, db)
|
return await _enrich_item_with_files(item, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{item_id}/extract-cover")
|
||||||
|
async def extract_local_cover(
|
||||||
|
item_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Extrahiert ein Cover aus Ordner-Dateien oder eingebettetem Artwork."""
|
||||||
|
from ..services.scanner import _save_local_cover
|
||||||
|
from ..models.media_item import BookFile
|
||||||
|
import os
|
||||||
|
|
||||||
|
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
|
||||||
|
item = result.scalar_one_or_none()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
files_result = await db.execute(
|
||||||
|
select(BookFile).where(BookFile.library_item_id == item_id).order_by(BookFile.track_index)
|
||||||
|
)
|
||||||
|
audio_files = [f.path for f in files_result.scalars().all()]
|
||||||
|
|
||||||
|
cover = _save_local_cover(item.path, audio_files, item.id)
|
||||||
|
if cover:
|
||||||
|
item.cover_path = cover
|
||||||
|
item.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Lokales Cover gesetzt für {item_id}: {cover}")
|
||||||
|
return {"success": True, "cover_path": cover}
|
||||||
|
return {"success": False, "message": "Kein Cover gefunden"}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{item_id}/match")
|
@router.delete("/{item_id}/match")
|
||||||
async def clear_match(
|
async def clear_match(
|
||||||
item_id: str,
|
item_id: str,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Header, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse, StreamingResponse, Response
|
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
|
||||||
@@ -11,7 +11,6 @@ from ..models.user import User
|
|||||||
from ..models.media_item import LibraryItem, BookFile, Chapter
|
from ..models.media_item import LibraryItem, BookFile, Chapter
|
||||||
from ..models.session import PlaybackSession
|
from ..models.session import PlaybackSession
|
||||||
from ..models.progress import MediaProgress
|
from ..models.progress import MediaProgress
|
||||||
from ..services.auth import decode_token
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["stream"])
|
router = APIRouter(tags=["stream"])
|
||||||
@@ -175,25 +174,22 @@ async def stream_file(
|
|||||||
session_id: str,
|
session_id: str,
|
||||||
track: int,
|
track: int,
|
||||||
request: Request,
|
request: Request,
|
||||||
token: str | None = Query(None),
|
|
||||||
authorization: str | None = Header(None),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Audio-Streaming mit nativen HTTP Range Requests (206 Partial Content)."""
|
"""Audio-Streaming mit nativen HTTP Range Requests (206 Partial Content).
|
||||||
raw = token
|
Session-ID (UUID, 128-bit Entropie) dient als Auth wie bei Audiobookshelf.
|
||||||
if not raw and authorization:
|
Damit funktioniert das mit <audio src> ohne Token-Header-Problematik.
|
||||||
parts = authorization.split(" ", 1)
|
"""
|
||||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
logger.info(
|
||||||
raw = parts[1]
|
f"Stream request: session={session_id} track={track} "
|
||||||
if not raw or not decode_token(raw):
|
f"range={request.headers.get('range')!r} ua={request.headers.get('user-agent', '?')[:40]!r}"
|
||||||
logger.warning(f"Stream 401: session={session_id} track={track}")
|
)
|
||||||
raise HTTPException(status_code=401, detail="Nicht autorisiert")
|
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(PlaybackSession).where(PlaybackSession.id == session_id)
|
select(PlaybackSession).where(PlaybackSession.id == session_id)
|
||||||
)
|
)
|
||||||
session = result.scalar_one_or_none()
|
session = result.scalar_one_or_none()
|
||||||
if not session:
|
if not session:
|
||||||
|
logger.warning(f"Stream: Session nicht gefunden: {session_id}")
|
||||||
raise HTTPException(status_code=404, detail="Session nicht gefunden")
|
raise HTTPException(status_code=404, detail="Session nicht gefunden")
|
||||||
|
|
||||||
files_result = await db.execute(
|
files_result = await db.execute(
|
||||||
@@ -253,19 +249,9 @@ async def stream_file(
|
|||||||
async def stream_head(
|
async def stream_head(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
track: int,
|
track: int,
|
||||||
token: str | None = Query(None),
|
|
||||||
authorization: str | None = Header(None),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""HEAD-Request für Audio-Datei (Metadaten ohne Body)."""
|
"""HEAD-Request für Audio-Datei. Session-ID = Auth."""
|
||||||
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):
|
|
||||||
raise HTTPException(status_code=401, detail="Nicht autorisiert")
|
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(PlaybackSession).where(PlaybackSession.id == session_id)
|
select(PlaybackSession).where(PlaybackSession.id == session_id)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import shutil
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from ..database import AsyncSessionLocal
|
from ..database import AsyncSessionLocal
|
||||||
|
from ..config import get_settings
|
||||||
from ..models.library import Library
|
from ..models.library import Library
|
||||||
from ..models.media_item import LibraryItem, BookFile, Chapter
|
from ..models.media_item import LibraryItem, BookFile, Chapter
|
||||||
from ..models.session import ScanJob
|
from ..models.session import ScanJob
|
||||||
@@ -14,6 +16,8 @@ from ..models.session import ScanJob
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".m4b", ".opus"}
|
AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".m4b", ".opus"}
|
||||||
|
COVER_NAMES = ["cover", "folder", "front", "album", "albumart", "Cover", "Folder", "Front"]
|
||||||
|
COVER_EXTS = [".jpg", ".jpeg", ".png", ".webp"]
|
||||||
|
|
||||||
|
|
||||||
def _get_audio_duration(file_path: str) -> float:
|
def _get_audio_duration(file_path: str) -> float:
|
||||||
@@ -34,6 +38,94 @@ def _get_file_size(file_path: str) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _find_folder_cover(folder: str) -> str | None:
|
||||||
|
"""Sucht cover.jpg / folder.jpg / front.jpg etc. im Ordner."""
|
||||||
|
try:
|
||||||
|
for entry in os.listdir(folder):
|
||||||
|
name, ext = os.path.splitext(entry)
|
||||||
|
if ext.lower() in COVER_EXTS and name.lower() in [c.lower() for c in COVER_NAMES]:
|
||||||
|
return os.path.join(folder, entry)
|
||||||
|
except (PermissionError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_embedded_cover(file_path: str) -> tuple[bytes, str] | None:
|
||||||
|
"""Extrahiert eingebettetes Cover aus Audio-Datei. Gibt (bytes, ext) zurück."""
|
||||||
|
try:
|
||||||
|
from mutagen import File as MutagenFile
|
||||||
|
from mutagen.id3 import APIC
|
||||||
|
from mutagen.mp4 import MP4Cover
|
||||||
|
from mutagen.flac import Picture
|
||||||
|
|
||||||
|
audio = MutagenFile(file_path)
|
||||||
|
if not audio:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# MP3 / ID3 (APIC)
|
||||||
|
if audio.tags and hasattr(audio.tags, 'getall'):
|
||||||
|
try:
|
||||||
|
apics = audio.tags.getall('APIC')
|
||||||
|
if apics:
|
||||||
|
apic = apics[0]
|
||||||
|
ext = '.png' if apic.mime == 'image/png' else '.jpg'
|
||||||
|
return (apic.data, ext)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# MP4/M4B/M4A (covr atom)
|
||||||
|
if audio.tags and 'covr' in audio.tags:
|
||||||
|
covr = audio.tags['covr']
|
||||||
|
if covr:
|
||||||
|
cover = covr[0]
|
||||||
|
ext = '.png' if cover.imageformat == MP4Cover.FORMAT_PNG else '.jpg'
|
||||||
|
return (bytes(cover), ext)
|
||||||
|
|
||||||
|
# FLAC, OGG
|
||||||
|
if hasattr(audio, 'pictures') and audio.pictures:
|
||||||
|
pic = audio.pictures[0]
|
||||||
|
ext = '.png' if 'png' in (pic.mime or '').lower() else '.jpg'
|
||||||
|
return (pic.data, ext)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Cover-Extraktion fehlgeschlagen für {file_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _save_local_cover(folder_path: str, audio_files: list[str], item_id: str) -> str | None:
|
||||||
|
"""Findet ein Cover (Ordner-Datei oder Embed) und speichert es lokal."""
|
||||||
|
settings = get_settings()
|
||||||
|
covers_dir = settings.covers_dir
|
||||||
|
os.makedirs(covers_dir, exist_ok=True)
|
||||||
|
|
||||||
|
folder_cover = _find_folder_cover(folder_path)
|
||||||
|
if folder_cover:
|
||||||
|
ext = os.path.splitext(folder_cover)[1].lower()
|
||||||
|
if ext not in COVER_EXTS:
|
||||||
|
ext = ".jpg"
|
||||||
|
dest = os.path.join(covers_dir, f"{item_id}{ext}")
|
||||||
|
try:
|
||||||
|
shutil.copyfile(folder_cover, dest)
|
||||||
|
logger.info(f"Ordner-Cover übernommen: {folder_cover} → {dest}")
|
||||||
|
return dest
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Cover-Copy fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
for f in audio_files[:1]:
|
||||||
|
result = _extract_embedded_cover(f)
|
||||||
|
if result:
|
||||||
|
data, ext = result
|
||||||
|
if len(data) > 1000:
|
||||||
|
dest = os.path.join(covers_dir, f"{item_id}{ext}")
|
||||||
|
try:
|
||||||
|
with open(dest, "wb") as fd:
|
||||||
|
fd.write(data)
|
||||||
|
logger.info(f"Embedded Cover extrahiert: {f} → {dest} ({len(data)} Bytes)")
|
||||||
|
return dest
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Cover-Save fehlgeschlagen: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _guess_title_from_path(folder_path: str) -> str:
|
def _guess_title_from_path(folder_path: str) -> str:
|
||||||
"""Leitet Titel aus dem Ordnernamen ab."""
|
"""Leitet Titel aus dem Ordnernamen ab."""
|
||||||
return os.path.basename(folder_path)
|
return os.path.basename(folder_path)
|
||||||
@@ -135,9 +227,15 @@ async def scan_library_task(library_id: str, job_id: str):
|
|||||||
existing_item.is_missing = False
|
existing_item.is_missing = False
|
||||||
existing_item.updated_at = datetime.utcnow()
|
existing_item.updated_at = datetime.utcnow()
|
||||||
item = existing_item
|
item = existing_item
|
||||||
|
# Cover aus Ordner/Embed nachziehen falls noch keins da ist
|
||||||
|
if not item.cover_path or not os.path.exists(item.cover_path or ""):
|
||||||
|
local_cover = _save_local_cover(folder_path, audio_files, item.id)
|
||||||
|
if local_cover:
|
||||||
|
item.cover_path = local_cover
|
||||||
else:
|
else:
|
||||||
item_id = str(uuid.uuid4())
|
item_id = str(uuid.uuid4())
|
||||||
title = _guess_title_from_path(folder_path)
|
title = _guess_title_from_path(folder_path)
|
||||||
|
local_cover = _save_local_cover(folder_path, audio_files, item_id)
|
||||||
item = LibraryItem(
|
item = LibraryItem(
|
||||||
id=item_id,
|
id=item_id,
|
||||||
library_id=library_id,
|
library_id=library_id,
|
||||||
@@ -148,6 +246,7 @@ async def scan_library_task(library_id: str, job_id: str):
|
|||||||
duration_seconds=total_duration,
|
duration_seconds=total_duration,
|
||||||
size_bytes=total_size,
|
size_bytes=total_size,
|
||||||
num_files=len(audio_files),
|
num_files=len(audio_files),
|
||||||
|
cover_path=local_cover,
|
||||||
tags=["zu_prüfen"],
|
tags=["zu_prüfen"],
|
||||||
)
|
)
|
||||||
db.add(item)
|
db.add(item)
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ function findTrackForTime(tracks: Track[], time: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function trackUrl(track: Track): string {
|
function trackUrl(track: Track): string {
|
||||||
const token = localStorage.getItem('token') || ''
|
return track.contentUrl
|
||||||
const sep = track.contentUrl.includes('?') ? '&' : '?'
|
|
||||||
return `${track.contentUrl}${sep}token=${encodeURIComponent(token)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AudioPlayer() {
|
export default function AudioPlayer() {
|
||||||
@@ -59,6 +57,7 @@ export default function AudioPlayer() {
|
|||||||
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)
|
||||||
|
const [playerError, setPlayerError] = useState<string | null>(null)
|
||||||
|
|
||||||
const tracks: Track[] = session?.audioTracks || []
|
const tracks: Track[] = session?.audioTracks || []
|
||||||
|
|
||||||
@@ -82,13 +81,17 @@ export default function AudioPlayer() {
|
|||||||
|
|
||||||
currentTrackIdx.current = startIdx
|
currentTrackIdx.current = startIdx
|
||||||
pendingSeek.current = timeInTrack > 0 ? timeInTrack : null
|
pendingSeek.current = timeInTrack > 0 ? timeInTrack : null
|
||||||
|
setPlayerError(null)
|
||||||
|
|
||||||
audio.preload = 'auto'
|
audio.preload = 'auto'
|
||||||
audio.playbackRate = playbackRate
|
audio.playbackRate = playbackRate
|
||||||
audio.volume = muted ? 0 : volume
|
audio.volume = muted ? 0 : volume
|
||||||
audio.src = trackUrl(tracks[startIdx])
|
audio.src = trackUrl(tracks[startIdx])
|
||||||
audio.load()
|
audio.load()
|
||||||
audio.play().catch((e) => console.warn('[Player] play() failed:', e))
|
audio.play().catch((e) => {
|
||||||
|
console.warn('[Player] play() failed:', e)
|
||||||
|
setPlayerError(`Wiedergabe blockiert: ${e?.message || e}`)
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
audio.pause()
|
audio.pause()
|
||||||
@@ -198,6 +201,13 @@ export default function AudioPlayer() {
|
|||||||
const onError = () => {
|
const onError = () => {
|
||||||
const audio = audioRef.current
|
const audio = audioRef.current
|
||||||
const err = audio?.error
|
const err = audio?.error
|
||||||
|
const codeMap: Record<number, string> = {
|
||||||
|
1: 'MEDIA_ERR_ABORTED — Abruf abgebrochen',
|
||||||
|
2: 'MEDIA_ERR_NETWORK — Netzwerkfehler beim Laden',
|
||||||
|
3: 'MEDIA_ERR_DECODE — Format nicht dekodierbar',
|
||||||
|
4: 'MEDIA_ERR_SRC_NOT_SUPPORTED — Quelle nicht unterstützt (404/Format/MIME)',
|
||||||
|
}
|
||||||
|
const msg = err ? `${codeMap[err.code] || `Code ${err.code}`}${err.message ? ': ' + err.message : ''}` : 'Unbekannter Fehler'
|
||||||
console.error('[Player] audio error', {
|
console.error('[Player] audio error', {
|
||||||
code: err?.code,
|
code: err?.code,
|
||||||
message: err?.message,
|
message: err?.message,
|
||||||
@@ -205,6 +215,7 @@ export default function AudioPlayer() {
|
|||||||
networkState: audio?.networkState,
|
networkState: audio?.networkState,
|
||||||
readyState: audio?.readyState,
|
readyState: audio?.readyState,
|
||||||
})
|
})
|
||||||
|
setPlayerError(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmtTime = (s: number) => {
|
const fmtTime = (s: number) => {
|
||||||
@@ -273,6 +284,16 @@ 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>
|
||||||
|
|
||||||
|
{playerError && (
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg border border-red-500/40 bg-red-500/10 text-red-300" style={{ fontSize: '12px' }}>
|
||||||
|
<p className="font-medium mb-1">Audio-Fehler</p>
|
||||||
|
<p className="opacity-80">{playerError}</p>
|
||||||
|
<p className="opacity-60 mt-2" style={{ fontSize: '10px' }}>
|
||||||
|
Stream-URL: <span className="font-mono break-all">{tracks[currentTrackIdx.current]?.contentUrl}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<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) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user