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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user