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>
246 lines
8.2 KiB
Python
246 lines
8.2 KiB
Python
import os
|
|
import uuid
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from ..dependencies import get_db, get_current_user
|
|
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
|
|
|
|
router = APIRouter(tags=["stream"])
|
|
|
|
|
|
@router.post("/api/items/{item_id}/play")
|
|
async def start_playback(
|
|
item_id: str,
|
|
body: dict | None = None,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
body = body or {}
|
|
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)
|
|
)
|
|
files = files_result.scalars().all()
|
|
if not files:
|
|
raise HTTPException(status_code=400, detail="Keine Audio-Dateien vorhanden")
|
|
|
|
chapters_result = await db.execute(
|
|
select(Chapter).where(Chapter.library_item_id == item_id).order_by(Chapter.chapter_index)
|
|
)
|
|
chapters = chapters_result.scalars().all()
|
|
|
|
progress_result = await db.execute(
|
|
select(MediaProgress).where(
|
|
MediaProgress.user_id == current_user.id,
|
|
MediaProgress.library_item_id == item_id,
|
|
)
|
|
)
|
|
progress = progress_result.scalar_one_or_none()
|
|
current_time = progress.current_time if progress else 0.0
|
|
if body.get("startTime") is not None:
|
|
current_time = float(body["startTime"])
|
|
|
|
session_id = str(uuid.uuid4())
|
|
|
|
session = PlaybackSession(
|
|
id=session_id,
|
|
user_id=current_user.id,
|
|
library_item_id=item_id,
|
|
media_type=item.media_type,
|
|
current_time=current_time,
|
|
duration=item.duration_seconds or 0.0,
|
|
device_id=body.get("deviceId", ""),
|
|
device_info=body.get("deviceInfo", {}),
|
|
media_player=body.get("mediaPlayer", ""),
|
|
hls_session_path=None,
|
|
is_active=True,
|
|
)
|
|
db.add(session)
|
|
await db.commit()
|
|
|
|
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,
|
|
"libraryId": item.library_id,
|
|
"libraryItemId": item_id,
|
|
"episodeId": None,
|
|
"mediaType": item.media_type,
|
|
"chapters": chapters_out,
|
|
"displayTitle": item.title,
|
|
"displayAuthor": item.author,
|
|
"coverPath": f"/api/items/{item_id}/cover" if item.cover_path else None,
|
|
"duration": total_duration,
|
|
"playMethod": 2,
|
|
"mediaPlayer": body.get("mediaPlayer", ""),
|
|
"deviceInfo": body.get("deviceInfo", {}),
|
|
"serverVersion": "2.4.0",
|
|
"date": datetime.utcnow().strftime("%Y-%m-%d"),
|
|
"dayOfWeek": datetime.utcnow().strftime("%A"),
|
|
"timeListening": 0,
|
|
"startTime": current_time,
|
|
"currentTime": current_time,
|
|
"startedAt": int(datetime.utcnow().timestamp() * 1000),
|
|
"updatedAt": int(datetime.utcnow().timestamp() * 1000),
|
|
"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,
|
|
body: dict | None = None,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
body = body or {}
|
|
result = await db.execute(
|
|
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")
|
|
|
|
current_time = float(body.get("currentTime", session.current_time))
|
|
duration = float(body.get("duration", session.duration))
|
|
session.current_time = current_time
|
|
session.duration = duration
|
|
session.updated_at = datetime.utcnow()
|
|
|
|
progress_result = await db.execute(
|
|
select(MediaProgress).where(
|
|
MediaProgress.user_id == current_user.id,
|
|
MediaProgress.library_item_id == session.library_item_id,
|
|
MediaProgress.episode_id == session.episode_id,
|
|
)
|
|
)
|
|
progress = progress_result.scalar_one_or_none()
|
|
if not progress:
|
|
progress = MediaProgress(
|
|
user_id=current_user.id,
|
|
library_item_id=session.library_item_id,
|
|
episode_id=session.episode_id,
|
|
duration=duration,
|
|
started_at=datetime.utcnow(),
|
|
)
|
|
db.add(progress)
|
|
|
|
progress.current_time = current_time
|
|
progress.duration = duration
|
|
progress.last_update = datetime.utcnow()
|
|
is_finished = duration > 0 and (current_time / duration) >= 0.99
|
|
if is_finished and not progress.is_finished:
|
|
progress.is_finished = True
|
|
progress.finished_at = datetime.utcnow()
|
|
elif not is_finished:
|
|
progress.is_finished = False
|
|
|
|
await db.commit()
|
|
return {"id": session_id, "currentTime": current_time}
|
|
|
|
|
|
@router.delete("/api/playback-session/{session_id}")
|
|
async def close_session(
|
|
session_id: 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,
|
|
)
|
|
)
|
|
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()
|
|
return {"success": True}
|