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:
Audiolib
2026-05-26 18:09:22 +02:00
parent 17b77afd45
commit d93f972079
4 changed files with 166 additions and 29 deletions

View File

@@ -119,6 +119,37 @@ async def apply_match(
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")
async def clear_match(
item_id: str,

View File

@@ -2,7 +2,7 @@ import os
import uuid
import logging
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 sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -11,7 +11,6 @@ from ..models.user import User
from ..models.media_item import LibraryItem, BookFile, Chapter
from ..models.session import PlaybackSession
from ..models.progress import MediaProgress
from ..services.auth import decode_token
logger = logging.getLogger(__name__)
router = APIRouter(tags=["stream"])
@@ -175,25 +174,22 @@ async def stream_file(
session_id: str,
track: int,
request: Request,
token: str | None = Query(None),
authorization: str | None = Header(None),
db: AsyncSession = Depends(get_db),
):
"""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")
"""Audio-Streaming mit nativen HTTP Range Requests (206 Partial Content).
Session-ID (UUID, 128-bit Entropie) dient als Auth wie bei Audiobookshelf.
Damit funktioniert das mit <audio src> ohne Token-Header-Problematik.
"""
logger.info(
f"Stream request: session={session_id} track={track} "
f"range={request.headers.get('range')!r} ua={request.headers.get('user-agent', '?')[:40]!r}"
)
result = await db.execute(
select(PlaybackSession).where(PlaybackSession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
logger.warning(f"Stream: Session nicht gefunden: {session_id}")
raise HTTPException(status_code=404, detail="Session nicht gefunden")
files_result = await db.execute(
@@ -253,19 +249,9 @@ async def stream_file(
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)
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")
"""HEAD-Request für Audio-Datei. Session-ID = Auth."""
result = await db.execute(
select(PlaybackSession).where(PlaybackSession.id == session_id)
)

View File

@@ -2,11 +2,13 @@ import os
import uuid
import asyncio
import logging
import shutil
from datetime import datetime
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from ..database import AsyncSessionLocal
from ..config import get_settings
from ..models.library import Library
from ..models.media_item import LibraryItem, BookFile, Chapter
from ..models.session import ScanJob
@@ -14,6 +16,8 @@ from ..models.session import ScanJob
logger = logging.getLogger(__name__)
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:
@@ -34,6 +38,94 @@ def _get_file_size(file_path: str) -> int:
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:
"""Leitet Titel aus dem Ordnernamen ab."""
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.updated_at = datetime.utcnow()
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:
item_id = str(uuid.uuid4())
title = _guess_title_from_path(folder_path)
local_cover = _save_local_cover(folder_path, audio_files, item_id)
item = LibraryItem(
id=item_id,
library_id=library_id,
@@ -148,6 +246,7 @@ async def scan_library_task(library_id: str, job_id: str):
duration_seconds=total_duration,
size_bytes=total_size,
num_files=len(audio_files),
cover_path=local_cover,
tags=["zu_prüfen"],
)
db.add(item)

View File

@@ -25,9 +25,7 @@ function findTrackForTime(tracks: Track[], time: number): number {
}
function trackUrl(track: Track): string {
const token = localStorage.getItem('token') || ''
const sep = track.contentUrl.includes('?') ? '&' : '?'
return `${track.contentUrl}${sep}token=${encodeURIComponent(token)}`
return track.contentUrl
}
export default function AudioPlayer() {
@@ -59,6 +57,7 @@ export default function AudioPlayer() {
const [showSpeedMenu, setShowSpeedMenu] = useState(false)
const [showSleepMenu, setShowSleepMenu] = useState(false)
const [muted, setMuted] = useState(false)
const [playerError, setPlayerError] = useState<string | null>(null)
const tracks: Track[] = session?.audioTracks || []
@@ -82,13 +81,17 @@ export default function AudioPlayer() {
currentTrackIdx.current = startIdx
pendingSeek.current = timeInTrack > 0 ? timeInTrack : null
setPlayerError(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))
audio.play().catch((e) => {
console.warn('[Player] play() failed:', e)
setPlayerError(`Wiedergabe blockiert: ${e?.message || e}`)
})
return () => {
audio.pause()
@@ -198,6 +201,13 @@ export default function AudioPlayer() {
const onError = () => {
const audio = audioRef.current
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', {
code: err?.code,
message: err?.message,
@@ -205,6 +215,7 @@ export default function AudioPlayer() {
networkState: audio?.networkState,
readyState: audio?.readyState,
})
setPlayerError(msg)
}
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>}
</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="relative bg-muted2 rounded-full mb-2" style={{ height: '4px' }}>
{chapterMarkers.map((m: any, i: number) => (