Initial commit: Phase 1 – Projektstruktur, DB-Schema, Core-API

- FastAPI-Backend mit vollständiger ABS v2.x API-Kompatibilität
- SQLAlchemy-Models: User, Library, LibraryItem, BookFile, Chapter,
  Podcast, PodcastEpisode, MediaProgress, Bookmark, PlaybackSession
- Auth: JWT-Login (/login, /logout, /api/authorize)
- Library + Items Endpoints inkl. camelCase ABS-Response-Format
- HLS-Streaming via FFmpeg (POST /api/items/:id/play, Session-Sync)
- Me/Progress Endpoints + Lesezeichen
- User-Management + Server-Settings (Admin)
- Library-Scanner (MP3/WAV Discovery, Hintergrund-Task)
- File Watcher (watchdog, 30s Debounce)
- Matching-Skelett (MusicBrainz, OpenLibrary, Google Books – Phase 5)
- Docker-Setup: backend (Python 3.12+FFmpeg), frontend (React/Vite),
  nginx Reverse-Proxy auf Port 3000
- setup.sh: Installiert Docker auf Debian/Ubuntu, richtet .env ein

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Audiolib
2026-05-26 11:43:35 +02:00
commit 14ffee3051
56 changed files with 3220 additions and 0 deletions

View File

@@ -0,0 +1,248 @@
import os
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
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.hls import create_hls_session, cleanup_hls_session, get_hls_session_path
from ..config import get_settings
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="No audio files for this item")
chapters_result = await db.execute(
select(Chapter).where(Chapter.library_item_id == item_id).order_by(Chapter.chapter_index)
)
chapters = chapters_result.scalars().all()
# Fortschritt ermitteln
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())
# HLS-Session asynchron starten
audio_paths = [f.path for f in files]
hls_dir = await create_hls_session(session_id, audio_paths, start_time=0.0)
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=hls_dir,
is_active=True,
)
db.add(session)
await db.commit()
settings = get_settings()
# URL-Basis relativ — wird durch nginx weitergeleitet
hls_url = f"/hls/{session_id}/output.m3u8"
audio_tracks = [
{
"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,
},
}
]
chapters_out = [
{
"id": c.chapter_index,
"start": c.start_seconds,
"end": c.end_seconds,
"title": c.title,
}
for c in chapters
]
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": item.duration_seconds or 0.0,
"playMethod": 0, # 0 = HLS Transcode
"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.post("/api/playback-session/{session_id}/sync")
async def sync_session(
session_id: str,
body: dict = {},
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")
current_time = float(body.get("currentTime", session.current_time))
duration = float(body.get("duration", session.duration))
time_listening = float(body.get("timeListening", 0))
session.current_time = current_time
session.duration = duration
session.updated_at = datetime.utcnow()
# Fortschritt persistieren
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()
# HLS-Temp-Dateien bereinigen
cleanup_hls_session(session_id)
return {"success": True}
@router.get("/hls/{session_id}/{filename}")
async def serve_hls(
session_id: str,
filename: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# Session prüfen
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")
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")
elif filename.endswith(".ts"):
return FileResponse(file_path, media_type="video/MP2T")
else:
return FileResponse(file_path)