import os import uuid import logging from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import FileResponse, StreamingResponse, Response 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 logger = logging.getLogger(__name__) router = APIRouter(tags=["stream"]) _MIME = { ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".m4b": "audio/mp4", ".aac": "audio/aac", ".ogg": "audio/ogg", ".opus": "audio/ogg", ".flac": "audio/flac", ".wav": "audio/wav", } @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 ] 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}/{i}", "mimeType": _MIME.get(ext, "audio/mpeg"), }) offset += dur total_duration = item.duration_seconds or offset logger.info( f"Playback gestartet: item={item.title!r} session={session_id} " f"files={len(files)} duration={total_duration:.1f}s startAt={current_time:.1f}s" ) 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, } def _parse_range(range_header: str, file_size: int) -> tuple[int, int] | None: """Parsed 'bytes=START-END'. Gibt (start, end) zurück oder None bei Fehler.""" if not range_header or not range_header.startswith("bytes="): return None try: spec = range_header[6:].strip() # nur erste Range spec = spec.split(",")[0] start_s, _, end_s = spec.partition("-") if start_s == "": # Suffix-Range: bytes=-500 → letzte 500 Bytes length = int(end_s) start = max(0, file_size - length) end = file_size - 1 else: start = int(start_s) end = int(end_s) if end_s else file_size - 1 if start < 0 or end < start or start >= file_size: return None end = min(end, file_size - 1) return start, end except (ValueError, IndexError): return None async def _stream_file_range(path: str, start: int, end: int, chunk_size: int = 64 * 1024): """Streamt einen Byte-Bereich aus einer Datei.""" with open(path, "rb") as f: f.seek(start) remaining = end - start + 1 while remaining > 0: chunk = f.read(min(chunk_size, remaining)) if not chunk: break remaining -= len(chunk) yield chunk @router.get("/api/stream/{session_id}/{track}") async def stream_file( session_id: str, track: int, request: Request, db: AsyncSession = Depends(get_db), ): """Audio-Streaming mit nativen HTTP Range Requests (206 Partial Content). Session-ID (UUID, 128-bit Entropie) dient als Auth wie bei Audiobookshelf. Damit funktioniert das mit