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")