diff --git a/backend/app/routers/stream.py b/backend/app/routers/stream.py index 53a0905..93dddc4 100644 --- a/backend/app/routers/stream.py +++ b/backend/app/routers/stream.py @@ -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") diff --git a/frontend/src/components/player/AudioPlayer.tsx b/frontend/src/components/player/AudioPlayer.tsx index f4ac1b3..a9bc964 100644 --- a/frontend/src/components/player/AudioPlayer.tsx +++ b/frontend/src/components/player/AudioPlayer.tsx @@ -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(null) - const hlsRef = useRef(null) + const tracksRef = useRef([]) + const currentTrackIdxRef = useRef(0) + const pendingSeekRef = useRef(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 = () => { - setPlaying(false) - syncProgress() + 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) => { - 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() {
{chapterMarkers.map((m: any, i: number) => ( -
+
))}
0 ? (currentTime / duration) * 100 : 0}%` }} /> @@ -203,29 +240,19 @@ export default function AudioPlayer() { {/* Controls */}
- -
@@ -235,7 +262,7 @@ export default function AudioPlayer() { {/* Speed */}
diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index c291a02..2a2aaf7 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -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(null) const submit = async () => { setSaving(true) @@ -182,32 +184,64 @@ function LibraryForm({ {form.mediaType === 'book' && (
-

Matching-Quellen (Reihenfolge = Priorität)

-
- {MATCH_SOURCES.map((s) => { - const checked = form.matchSources.includes(s.id) +

+ Matching-Quellen · Reihenfolge = Priorität · per Drag verschieben +

+ {/* Selected sources — sortable */} +
+ {form.matchSources.map((id, i) => { + const src = MATCH_SOURCES.find((s) => s.id === id) + if (!src) return null return ( - + + {src.label} + +
) })}
+ {/* Unselected sources — add button */} + {MATCH_SOURCES.filter((s) => !form.matchSources.includes(s.id)).length > 0 && ( +
+ {MATCH_SOURCES.filter((s) => !form.matchSources.includes(s.id)).map((s) => ( + + ))} +
+ )}
)}