Switch to direct MP3 streaming, add DNB source selection + drag-to-reorder

Player: Replace HLS with direct FileResponse streaming. Token passed as
query param (?token=JWT) so browser <audio> can authenticate. Multi-track
support: seeks and track transitions handled in AudioPlayer with refs.
Removes hls.js dependency from playback path.

Admin: Add DNB to match sources list. Replace toggle buttons with ordered
drag-to-reorder list (HTML5 drag API) + separate add/remove buttons so
source priority is explicit and adjustable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Audiolib
2026-05-26 17:49:40 +02:00
parent eefdfc9886
commit 6c702cb29f
3 changed files with 226 additions and 134 deletions

View File

@@ -1,7 +1,7 @@
import os
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query, Header
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -10,8 +10,7 @@ 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.hls import start_hls_session, wait_for_playlist, cleanup_hls_session
from ..config import get_settings
from ..services.auth import decode_token
router = APIRouter(tags=["stream"])
@@ -53,14 +52,6 @@ async def start_playback(
current_time = float(body["startTime"])
session_id = str(uuid.uuid4())
audio_paths = [f.path for f in files]
hls_dir = start_hls_session(session_id, audio_paths, start_time=0.0)
# Warten bis erste Segmente da sind (max. 60s)
ready = await wait_for_playlist(session_id, timeout=60.0)
if not ready:
cleanup_hls_session(session_id)
raise HTTPException(status_code=500, detail="HLS-Konvertierung fehlgeschlagen")
session = PlaybackSession(
id=session_id,
@@ -72,18 +63,38 @@ async def start_playback(
device_id=body.get("deviceId", ""),
device_info=body.get("deviceInfo", {}),
media_player=body.get("mediaPlayer", ""),
hls_session_path=hls_dir,
hls_session_path=None,
is_active=True,
)
db.add(session)
await db.commit()
hls_url = f"/hls/{session_id}/output.m3u8"
chapters_out = [
{"id": c.chapter_index, "start": c.start_seconds, "end": c.end_seconds, "title": c.title}
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):
dur = f.duration_seconds or 0.0
ext = os.path.splitext(f.filename or "")[1].lower()
audio_tracks.append({
"index": i,
"startOffset": offset,
"duration": dur,
"title": f.filename or f"Part {i + 1}",
"contentUrl": f"/api/stream/{session_id}?track={i}",
"mimeType": _MIME.get(ext, "audio/mpeg"),
})
offset += dur
total_duration = item.duration_seconds or offset
return {
"id": session_id,
"userId": current_user.id,
@@ -95,8 +106,8 @@ async def start_playback(
"displayTitle": item.title,
"displayAuthor": item.author,
"coverPath": f"/api/items/{item_id}/cover" if item.cover_path else None,
"duration": item.duration_seconds or 0.0,
"playMethod": 0,
"duration": total_duration,
"playMethod": 2,
"mediaPlayer": body.get("mediaPlayer", ""),
"deviceInfo": body.get("deviceInfo", {}),
"serverVersion": "2.4.0",
@@ -107,19 +118,57 @@ async def start_playback(
"currentTime": current_time,
"startedAt": int(datetime.utcnow().timestamp() * 1000),
"updatedAt": int(datetime.utcnow().timestamp() * 1000),
"audioTracks": [{
"index": 0,
"startOffset": 0.0,
"duration": item.duration_seconds or 0.0,
"title": "Part 1",
"contentUrl": hls_url,
"mimeType": "application/x-mpegURL",
"metadata": {"filename": "output.m3u8", "ext": ".m3u8", "path": hls_url, "relPath": "output.m3u8", "size": 0},
}],
"audioTracks": audio_tracks,
"videoTrack": None,
}
@router.get("/api/stream/{session_id}")
async def stream_file(
session_id: str,
track: int = Query(0),
token: str | None = Query(None),
authorization: str | None = Header(None),
db: AsyncSession = Depends(get_db),
):
"""Direktes Audio-Streaming mit Range-Request-Unterstützung."""
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(
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 >= 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",
}
ext = os.path.splitext(path)[1].lower()
return FileResponse(path, media_type=_MIME.get(ext, "audio/mpeg"))
@router.post("/api/playback-session/{session_id}/sync")
async def sync_session(
session_id: str,
@@ -129,7 +178,10 @@ async def sync_session(
):
body = body or {}
result = await db.execute(
select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id)
select(PlaybackSession).where(
PlaybackSession.id == session_id,
PlaybackSession.user_id == current_user.id,
)
)
session = result.scalar_one_or_none()
if not session:
@@ -180,35 +232,14 @@ async def close_session(
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id)
select(PlaybackSession).where(
PlaybackSession.id == session_id,
PlaybackSession.user_id == current_user.id,
)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
session.is_active = False
await db.commit()
cleanup_hls_session(session_id)
return {"success": True}
@router.get("/hls/{session_id}/{filename:path}")
async def serve_hls(
session_id: str,
filename: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Session not found")
settings = get_settings()
file_path = os.path.join(settings.hls_cache_dir, session_id, filename)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Segment not found")
if filename.endswith(".m3u8"):
return FileResponse(file_path, media_type="application/x-mpegURL")
return FileResponse(file_path, media_type="video/MP2T")

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'
import Hls from 'hls.js'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX,
List, BookmarkPlus, Moon, X, ChevronLeft
@@ -14,51 +13,66 @@ export default function AudioPlayer() {
const {
item, session, currentTime, duration, isPlaying, playbackRate, volume, chapters,
sleepTimerActive, sleepTimer,
setPlaying, setCurrentTime, setDuration, setPlaybackRate, setVolume,
setPlaying, setCurrentTime, setPlaybackRate, setVolume,
seek, stop, setExpanded, setSleepTimer, cancelSleepTimer, syncProgress,
} = usePlayerStore()
const audioRef = useRef<HTMLAudioElement>(null)
const hlsRef = useRef<Hls | null>(null)
const tracksRef = useRef<any[]>([])
const currentTrackIdxRef = useRef(0)
const pendingSeekRef = useRef<number | null>(null)
const currentTimeRef = useRef(0)
const isPlayingRef = useRef(false)
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 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
useEffect(() => {
if (!session || !audioRef.current) return
const hlsUrl = session.audioTracks?.[0]?.contentUrl
if (!hlsUrl) return
const tracks = session.audioTracks || []
if (!tracks.length) return
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null }
tracksRef.current = tracks
const startTime = session.currentTime || 0
const audio = audioRef.current
if (Hls.isSupported()) {
const token = localStorage.getItem('token')
const hls = new Hls({
startPosition: session.currentTime || 0,
xhrSetup: (xhr: XMLHttpRequest) => {
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
},
})
hls.loadSource(hlsUrl)
hls.attachMedia(audio)
hlsRef.current = hls
} else if (audio.canPlayType('application/vnd.apple.mpegurl')) {
audio.src = hlsUrl
audio.currentTime = session.currentTime || 0
let startIdx = 0
for (let i = tracks.length - 1; i >= 0; i--) {
if (startTime >= (tracks[i].startOffset || 0)) { startIdx = i; break }
}
audio.playbackRate = playbackRate
audio.volume = volume
audio.play().catch(() => {})
audioRef.current.playbackRate = playbackRate
audioRef.current.volume = muted ? 0 : volume
loadTrack(startIdx, startTime - (tracks[startIdx].startOffset || 0), true)
return () => {
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null }
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.removeAttribute('src')
}
}
}, [session?.id])
@@ -76,36 +90,67 @@ export default function AudioPlayer() {
if (audioRef.current) audioRef.current.volume = muted ? 0 : volume
}, [volume, muted])
const seekToAbsoluteTime = useCallback((t: 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))
let targetIdx = 0
for (let i = tracks.length - 1; i >= 0; i--) {
if (clamped >= (tracks[i].startOffset || 0)) { targetIdx = i; break }
}
const timeInTrack = clamped - (tracks[targetIdx].startOffset || 0)
if (targetIdx !== currentTrackIdxRef.current) {
loadTrack(targetIdx, timeInTrack, isPlayingRef.current)
} else {
audio.currentTime = timeInTrack
}
seek(clamped)
}, [loadTrack, seek])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement) return
if (e.code === 'Space') { e.preventDefault(); setPlaying(!isPlaying) }
if (e.code === 'ArrowRight') audioRef.current && (audioRef.current.currentTime += 30)
if (e.code === 'ArrowLeft') audioRef.current && (audioRef.current.currentTime -= 10)
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))
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isPlaying])
}, [seekToAbsoluteTime])
const handleTimeUpdate = () => {
if (!audioRef.current) return
setCurrentTime(audioRef.current.currentTime)
const track = tracksRef.current[currentTrackIdxRef.current]
setCurrentTime((track?.startOffset || 0) + audioRef.current.currentTime)
}
const handleLoadedMetadata = () => {
if (!audioRef.current) return
setDuration(audioRef.current.duration)
if (pendingSeekRef.current !== null && audioRef.current) {
audioRef.current.currentTime = pendingSeekRef.current
pendingSeekRef.current = null
}
}
const handleEnded = () => {
const tracks = tracksRef.current
const nextIdx = currentTrackIdxRef.current + 1
if (nextIdx < tracks.length) {
loadTrack(nextIdx, 0, true)
} else {
setPlaying(false)
syncProgress()
}
}
const handleSeekBar = (e: React.ChangeEvent<HTMLInputElement>) => {
const t = parseFloat(e.target.value)
if (audioRef.current) audioRef.current.currentTime = t
seek(t)
seekToAbsoluteTime(parseFloat(e.target.value))
}
const fmtTime = (s: number) => {
@@ -175,22 +220,14 @@ export default function AudioPlayer() {
<div className="mb-4">
<div className="relative bg-muted2 rounded-full mb-2" style={{ height: '4px' }}>
{chapterMarkers.map((m: any, i: number) => (
<div
key={i}
className="absolute top-0 w-px h-full bg-muted"
style={{ left: `${m.pct}%` }}
/>
<div key={i} className="absolute top-0 w-px h-full bg-muted" style={{ left: `${m.pct}%` }} />
))}
<div
className="absolute top-0 left-0 h-full bg-ink rounded-full pointer-events-none"
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
/>
<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}
className="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
/>
@@ -203,29 +240,19 @@ export default function AudioPlayer() {
{/* Controls */}
<div className="flex items-center justify-center gap-6 mb-6">
<button
className="text-muted hover:text-ink transition-colors"
onClick={() => audioRef.current && (audioRef.current.currentTime -= 30)}
>
<button className="text-muted hover:text-ink transition-colors" onClick={() => seekToAbsoluteTime(currentTime - 30)}>
<SkipBack size={28} />
</button>
<button
className="flex items-center justify-center"
style={{
width: 52, height: 52, borderRadius: '50%',
background: '#e4ede5', color: '#000',
transition: 'transform 0.1s',
}}
style={{ width: 52, height: 52, borderRadius: '50%', background: '#e4ede5', color: '#000', transition: 'transform 0.1s' }}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.background = '#fff' }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = '#e4ede5' }}
onClick={() => setPlaying(!isPlaying)}
>
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button>
<button
className="text-muted hover:text-ink transition-colors"
onClick={() => audioRef.current && (audioRef.current.currentTime += 30)}
>
<button className="text-muted hover:text-ink transition-colors" onClick={() => seekToAbsoluteTime(currentTime + 30)}>
<SkipForward size={28} />
</button>
</div>
@@ -235,7 +262,7 @@ export default function AudioPlayer() {
{/* Speed */}
<div className="relative">
<button
className="text-muted hover:text-ink border border-divider px-2 py-1 transition-colors"
className="text-muted hover:text-ink border border-divider transition-colors"
style={{ fontSize: '11px', fontWeight: 600, borderRadius: '4px', padding: '3px 8px' }}
onClick={() => setShowSpeedMenu(!showSpeedMenu)}
>
@@ -306,7 +333,7 @@ export default function AudioPlayer() {
<ChapterList
chapters={chapters}
currentTime={currentTime}
onSeek={(t) => { if (audioRef.current) audioRef.current.currentTime = t; seek(t) }}
onSeek={seekToAbsoluteTime}
/>
)}
</div>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'
import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X, FolderOpen, Pencil, Sparkles, ScrollText } from 'lucide-react'
import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X, FolderOpen, Pencil, Sparkles, ScrollText, GripVertical } from 'lucide-react'
import { getUsers, createUser, deleteUser, getSettings, updateSettings } from '../api/me'
import { getLibraries, scanLibrary, matchAllLibrary, createLibrary, updateLibrary, deleteLibrary } from '../api/libraries'
import { getLog, clearLog } from '../api/logs'
@@ -131,6 +131,7 @@ const MATCH_SOURCES = [
{ id: 'musicbrainz', label: 'MusicBrainz' },
{ id: 'open_library', label: 'Open Library' },
{ id: 'google_books', label: 'Google Books' },
{ id: 'dnb', label: 'DNB' },
]
function LibraryForm({
@@ -144,6 +145,7 @@ function LibraryForm({
const [form, setForm] = useState(initial)
const [showBrowser, setShowBrowser] = useState(false)
const [saving, setSaving] = useState(false)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
const submit = async () => {
setSaving(true)
@@ -182,32 +184,64 @@ function LibraryForm({
{form.mediaType === 'book' && (
<div>
<p className="text-muted mb-2" style={{ fontSize: '11px' }}>Matching-Quellen (Reihenfolge = Priorität)</p>
<div className="flex flex-wrap gap-2">
{MATCH_SOURCES.map((s) => {
const checked = form.matchSources.includes(s.id)
<p className="text-muted mb-2" style={{ fontSize: '11px' }}>
Matching-Quellen · Reihenfolge = Priorität · per Drag verschieben
</p>
{/* Selected sources — sortable */}
<div className="space-y-1 mb-2">
{form.matchSources.map((id, i) => {
const src = MATCH_SOURCES.find((s) => s.id === id)
if (!src) return null
return (
<button
key={s.id}
type="button"
onClick={() => setForm({
...form,
matchSources: checked
? form.matchSources.filter((x) => x !== s.id)
: [...form.matchSources, s.id],
})}
className={`px-3 py-1 rounded-full border transition-colors ${
checked
? 'bg-primary-dim border-primary text-primary'
: 'bg-card border-divider text-muted hover:text-ink'
<div
key={id}
draggable
onDragStart={(e) => { e.dataTransfer.setData('text/plain', String(i)); e.dataTransfer.effectAllowed = 'move' }}
onDragOver={(e) => { e.preventDefault(); setDragOverIdx(i) }}
onDrop={(e) => {
e.preventDefault()
const from = parseInt(e.dataTransfer.getData('text/plain'))
if (from === i) { setDragOverIdx(null); return }
const next = [...form.matchSources]
const [moved] = next.splice(from, 1)
next.splice(i, 0, moved)
setForm({ ...form, matchSources: next })
setDragOverIdx(null)
}}
onDragEnd={() => setDragOverIdx(null)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border cursor-grab active:cursor-grabbing transition-colors ${
dragOverIdx === i ? 'border-primary bg-primary-dim' : 'border-primary/40 bg-primary-dim'
}`}
style={{ fontSize: '12px' }}
>
{s.label}
<GripVertical size={12} className="text-primary opacity-50 flex-shrink-0" />
<span className="text-primary flex-1" style={{ fontSize: '12px' }}>{src.label}</span>
<button
type="button"
onClick={() => setForm({ ...form, matchSources: form.matchSources.filter((x) => x !== id) })}
className="text-primary opacity-50 hover:opacity-100 transition-opacity"
>
<X size={12} />
</button>
</div>
)
})}
</div>
{/* Unselected sources — add button */}
{MATCH_SOURCES.filter((s) => !form.matchSources.includes(s.id)).length > 0 && (
<div className="flex flex-wrap gap-2">
{MATCH_SOURCES.filter((s) => !form.matchSources.includes(s.id)).map((s) => (
<button
key={s.id}
type="button"
onClick={() => setForm({ ...form, matchSources: [...form.matchSources, s.id] })}
className="flex items-center gap-1 px-3 py-1 rounded-full border border-divider bg-card text-muted hover:text-ink transition-colors"
style={{ fontSize: '12px' }}
>
<Plus size={10} /> {s.label}
</button>
))}
</div>
)}
</div>
)}
<div className="flex gap-2">