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 @@
from . import auth, libraries, items, stream, me, users, settings

102
backend/app/routers/auth.py Normal file
View File

@@ -0,0 +1,102 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from ..database import AsyncSessionLocal
from ..dependencies import get_db, get_current_user
from ..models.user import User
from ..models.library import Library
from ..models.session import ServerSetting
from ..services.auth import verify_password, create_token
from ..schemas.auth import LoginRequest, LoginResponse, AuthorizeResponse
from ..schemas.user import UserOut, UserSettings, ServerSettingsOut
router = APIRouter(tags=["auth"])
def _build_user_out(user: User) -> UserOut:
raw_settings = user.settings or {}
settings = UserSettings(**{k: v for k, v in raw_settings.items() if k in UserSettings.model_fields})
return UserOut(
id=user.id,
username=user.username,
email=user.email,
is_admin=user.is_admin,
is_active=user.is_active,
last_seen=user.last_seen,
created_at=user.created_at,
token=user.token,
settings=settings,
type="root" if user.is_admin else "user",
permissions={
"download": True,
"update": user.is_admin,
"delete": user.is_admin,
"upload": user.is_admin,
"access_all_libraries": True,
"access_explicit_content": True,
},
)
@router.post("/login", response_model=LoginResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(User).where(User.username == body.username, User.is_active == True)
)
user = result.scalar_one_or_none()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = create_token(user.id)
user.token = token
user.last_seen = datetime.utcnow()
await db.commit()
# Erste Library als Default zurückgeben
lib_result = await db.execute(select(Library).limit(1))
first_lib = lib_result.scalar_one_or_none()
return LoginResponse(
user=_build_user_out(user),
user_default_library_id=first_lib.id if first_lib else None,
server_settings=ServerSettingsOut(),
)
@router.post("/logout")
async def logout(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
current_user.token = None
await db.commit()
return {"success": True}
@router.get("/api/authorize", response_model=AuthorizeResponse)
async def authorize(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
current_user.last_seen = datetime.utcnow()
await db.commit()
lib_result = await db.execute(select(Library))
libraries = lib_result.scalars().all()
from ..routers.libraries import _library_to_out
libs_out = [_library_to_out(lib) for lib in libraries]
first_lib_id = libraries[0].id if libraries else None
return AuthorizeResponse(
user=_build_user_out(current_user),
libraries=libs_out,
user_default_library_id=first_lib_id,
server_settings=ServerSettingsOut(),
)
@router.get("/ping")
async def ping():
return {"success": True}
@router.get("/health")
async def health():
return {"status": "ok"}

View File

@@ -0,0 +1,272 @@
import os
import zipfile
import tempfile
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from ..dependencies import get_db, get_current_user, require_admin
from ..models.user import User
from ..models.media_item import LibraryItem, BookFile, Chapter
from ..schemas.media_item import (
LibraryItemOut, BookOut, BookMetadata, AudioFileOut, AudioFileMetadata,
ChapterOut, LibraryItemUpdateRequest
)
from ..config import get_settings
router = APIRouter(prefix="/api/items", tags=["items"])
def _item_to_out(item: LibraryItem) -> dict:
settings = get_settings()
# Audio-Dateien aus BookFile-Relationen werden nachgeladen — hier bauen wir
# eine kompakte Darstellung aus den gespeicherten Item-Daten
metadata = BookMetadata(
title=item.title,
subtitle=item.subtitle,
authors=[{"id": None, "name": item.author}] if item.author else [],
narrators=[item.narrator] if item.narrator else [],
series=[{"id": None, "name": item.series, "sequence": item.series_sequence}]
if item.series else [],
genres=item.genres or [],
published_year=str(item.publish_year) if item.publish_year else None,
publisher=item.publisher,
description=item.description,
language=item.language,
explicit=item.explicit or False,
abridged=item.abridged or False,
)
cover_url = None
if item.cover_path and os.path.exists(item.cover_path):
cover_url = f"/api/items/{item.id}/cover"
media = BookOut(
library_item_id=item.id,
metadata=metadata,
cover_path=cover_url,
tags=item.tags or [],
duration=item.duration_seconds or 0.0,
size=item.size_bytes or 0,
is_missing=item.is_missing or False,
is_invalid=item.is_invalid or False,
)
out = LibraryItemOut(
id=item.id,
ino=item.ino or "",
library_id=item.library_id,
path=item.path,
rel_path=os.path.basename(item.path),
is_missing=item.is_missing or False,
is_invalid=item.is_invalid or False,
media_type=item.media_type,
media=media,
num_files=item.num_files or 0,
size=item.size_bytes or 0,
added_at=int(item.added_at.timestamp() * 1000) if item.added_at else 0,
updated_at=int(item.updated_at.timestamp() * 1000) if item.updated_at else 0,
)
return out.model_dump(by_alias=True)
async def _enrich_item_with_files(item: LibraryItem, db: AsyncSession) -> dict:
"""Vollständige Darstellung inkl. Audio-Dateien und Kapitel."""
out = _item_to_out(item)
files_result = await db.execute(
select(BookFile).where(BookFile.library_item_id == item.id).order_by(BookFile.track_index)
)
files = files_result.scalars().all()
chapters_result = await db.execute(
select(Chapter).where(Chapter.library_item_id == item.id).order_by(Chapter.chapter_index)
)
chapters = chapters_result.scalars().all()
audio_files = []
for f in files:
af = AudioFileOut(
index=f.track_index,
ino=f.id,
metadata=AudioFileMetadata(
filename=f.filename,
ext=os.path.splitext(f.filename)[1],
path=f.path,
rel_path=f.filename,
size=f.size_bytes,
),
format=f.format,
duration=f.duration_seconds,
mime_type="audio/mpeg" if f.format == "mp3" else "audio/wav",
added_at=int(item.added_at.timestamp() * 1000) if item.added_at else 0,
)
audio_files.append(af.model_dump(by_alias=True))
chaps = [
ChapterOut(id=c.chapter_index, start=c.start_seconds, end=c.end_seconds, title=c.title).model_dump(by_alias=True)
for c in chapters
]
out["media"]["audioFiles"] = audio_files
out["media"]["tracks"] = audio_files
out["media"]["chapters"] = chaps
out["media"]["numTracks"] = len(audio_files)
out["media"]["numAudioFiles"] = len(audio_files)
out["media"]["numChapters"] = len(chaps)
return out
@router.get("/{item_id}")
async def get_item(
item_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
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")
return await _enrich_item_with_files(item, db)
@router.get("/{item_id}/cover")
async def get_cover(
item_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = result.scalar_one_or_none()
if not item or not item.cover_path:
raise HTTPException(status_code=404, detail="Cover not found")
if not os.path.exists(item.cover_path):
raise HTTPException(status_code=404, detail="Cover file missing")
return FileResponse(item.cover_path, media_type="image/jpeg")
@router.patch("/{item_id}")
async def update_item(
item_id: str,
body: LibraryItemUpdateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
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")
if body.tags is not None:
item.tags = body.tags
if body.metadata:
meta = body.metadata
if "title" in meta:
item.title = meta["title"]
if "subtitle" in meta:
item.subtitle = meta["subtitle"]
if "authors" in meta and meta["authors"]:
item.author = meta["authors"][0].get("name") if isinstance(meta["authors"][0], dict) else meta["authors"][0]
if "narrators" in meta and meta["narrators"]:
item.narrator = meta["narrators"][0]
if "series" in meta and meta["series"]:
s = meta["series"][0]
item.series = s.get("name") if isinstance(s, dict) else s
item.series_sequence = s.get("sequence") if isinstance(s, dict) else None
if "publisher" in meta:
item.publisher = meta["publisher"]
if "publishedYear" in meta:
item.publish_year = int(meta["publishedYear"]) if meta["publishedYear"] else None
if "description" in meta:
item.description = meta["description"]
if "language" in meta:
item.language = meta["language"]
if "genres" in meta:
item.genres = meta["genres"]
if "explicit" in meta:
item.explicit = meta["explicit"]
if "abridged" in meta:
item.abridged = meta["abridged"]
if body.chapters:
await db.execute(delete(Chapter).where(Chapter.library_item_id == item_id))
for idx, c in enumerate(body.chapters):
chapter = Chapter(
library_item_id=item_id,
chapter_index=idx,
title=c.get("title", f"Kapitel {idx + 1}"),
start_seconds=c.get("start", 0.0),
end_seconds=c.get("end", 0.0),
)
db.add(chapter)
item.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(item)
return await _enrich_item_with_files(item, db)
@router.delete("/{item_id}")
async def delete_item(
item_id: str,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
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")
await db.delete(item)
await db.commit()
return {"success": True}
@router.post("/batch/delete")
async def batch_delete_items(
body: dict,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
item_ids = body.get("libraryItemIds", [])
for item_id in item_ids:
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = result.scalar_one_or_none()
if item:
await db.delete(item)
await db.commit()
return {"success": True}
@router.get("/{item_id}/download")
async def download_item(
item_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
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)
)
files = files_result.scalars().all()
if not files:
raise HTTPException(status_code=404, detail="No audio files found")
tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
with zipfile.ZipFile(tmp.name, "w") as zf:
for f in files:
if os.path.exists(f.path):
zf.write(f.path, f.filename)
title = item.title or item_id
return FileResponse(
tmp.name,
media_type="application/zip",
filename=f"{title}.zip",
)

View File

@@ -0,0 +1,218 @@
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from ..dependencies import get_db, get_current_user, require_admin
from ..models.user import User
from ..models.library import Library
from ..models.media_item import LibraryItem
from ..models.session import ScanJob
from ..schemas.library import LibraryOut, LibraryFolder, LibrarySettings, LibraryCreate, LibraryUpdate, LibraryItemsResponse
from ..config import get_settings
router = APIRouter(prefix="/api/libraries", tags=["libraries"])
def _library_to_out(lib: Library) -> dict:
settings_data = lib.settings or {}
folders = [
LibraryFolder(
id=f.get("id", str(uuid.uuid4())),
full_path=f.get("fullPath", f.get("full_path", "")),
library_id=lib.id,
added_at=int(lib.created_at.timestamp() * 1000) if lib.created_at else 0,
)
for f in (lib.folders or [])
]
out = LibraryOut(
id=lib.id,
name=lib.name,
folders=folders,
media_type=lib.media_type,
icon=settings_data.get("icon", "database"),
provider=settings_data.get("provider", "google"),
created_at=int(lib.created_at.timestamp() * 1000) if lib.created_at else 0,
last_update=int(lib.updated_at.timestamp() * 1000) if lib.updated_at else 0,
)
return out.model_dump(by_alias=True)
@router.get("")
async def list_libraries(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Library))
libraries = result.scalars().all()
return {"libraries": [_library_to_out(lib) for lib in libraries]}
@router.get("/{library_id}")
async def get_library(
library_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalar_one_or_none()
if not lib:
raise HTTPException(status_code=404, detail="Library not found")
return _library_to_out(lib)
@router.get("/{library_id}/items")
async def get_library_items(
library_id: str,
sort: str = "addedAt",
desc: int = 0,
filter: str | None = None,
search: str | None = None,
page: int = 0,
limit: int = 0,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalar_one_or_none()
if not lib:
raise HTTPException(status_code=404, detail="Library not found")
query = select(LibraryItem).where(LibraryItem.library_id == library_id)
if search:
query = query.where(
LibraryItem.title.ilike(f"%{search}%") |
LibraryItem.author.ilike(f"%{search}%") |
LibraryItem.series.ilike(f"%{search}%")
)
count_result = await db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar()
actual_limit = limit if limit > 0 else 50
query = query.offset(page * actual_limit).limit(actual_limit)
items_result = await db.execute(query)
items = items_result.scalars().all()
from ..routers.items import _item_to_out
return {
"results": [_item_to_out(item) for item in items],
"total": total,
"limit": actual_limit,
"page": page,
}
@router.get("/{library_id}/search")
async def search_library(
library_id: str,
q: str = "",
limit: int = 12,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(LibraryItem).where(
LibraryItem.library_id == library_id,
LibraryItem.title.ilike(f"%{q}%") |
LibraryItem.author.ilike(f"%{q}%") |
LibraryItem.series.ilike(f"%{q}%")
).limit(limit)
result = await db.execute(query)
items = result.scalars().all()
from ..routers.items import _item_to_out
return {"book": [_item_to_out(item) for item in items]}
@router.post("")
async def create_library(
body: LibraryCreate,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
lib_id = str(uuid.uuid4())
folders = [
{"id": str(uuid.uuid4()), "fullPath": f.get("fullPath", f.get("full_path", ""))}
for f in body.folders
]
lib = Library(
id=lib_id,
name=body.name,
display_name=body.name,
folders=folders,
media_type=body.media_type,
settings={"icon": body.icon, "provider": body.provider},
)
db.add(lib)
await db.commit()
await db.refresh(lib)
return _library_to_out(lib)
@router.patch("/{library_id}")
async def update_library(
library_id: str,
body: LibraryUpdate,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalar_one_or_none()
if not lib:
raise HTTPException(status_code=404, detail="Library not found")
if body.name is not None:
lib.name = body.name
lib.display_name = body.name
if body.folders is not None:
lib.folders = [
{"id": f.get("id", str(uuid.uuid4())), "fullPath": f.get("fullPath", f.get("full_path", ""))}
for f in body.folders
]
if body.settings is not None:
lib.settings = {**(lib.settings or {}), **body.settings}
await db.commit()
await db.refresh(lib)
return _library_to_out(lib)
@router.delete("/{library_id}")
async def delete_library(
library_id: str,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalar_one_or_none()
if not lib:
raise HTTPException(status_code=404, detail="Library not found")
await db.delete(lib)
await db.commit()
return {"success": True}
@router.post("/{library_id}/scan")
async def scan_library(
library_id: str,
background_tasks: BackgroundTasks,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalar_one_or_none()
if not lib:
raise HTTPException(status_code=404, detail="Library not found")
job = ScanJob(library_id=library_id, status="queued")
db.add(job)
await db.commit()
await db.refresh(job)
from ..services.scanner import scan_library_task
background_tasks.add_task(scan_library_task, library_id, job.id)
return {"id": job.id, "type": "scan", "libraryId": library_id}

234
backend/app/routers/me.py Normal file
View File

@@ -0,0 +1,234 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from ..dependencies import get_db, get_current_user
from ..models.user import User
from ..models.progress import MediaProgress, Bookmark
from ..models.media_item import LibraryItem
from ..schemas.user import UserOut, UserSettings
from ..routers.auth import _build_user_out
router = APIRouter(prefix="/api/me", tags=["me"])
def _progress_to_out(p: MediaProgress) -> dict:
return {
"id": p.id,
"libraryItemId": p.library_item_id,
"episodeId": p.episode_id,
"duration": p.duration,
"progress": round(p.current_time / p.duration, 4) if p.duration > 0 else 0.0,
"currentTime": p.current_time,
"isFinished": p.is_finished,
"hideFromContinueListening": p.hide_from_continue_listening,
"lastUpdate": int(p.last_update.timestamp() * 1000) if p.last_update else 0,
"startedAt": int(p.started_at.timestamp() * 1000) if p.started_at else 0,
"finishedAt": int(p.finished_at.timestamp() * 1000) if p.finished_at else None,
}
def _bookmark_to_out(b: Bookmark) -> dict:
return {
"libraryItemId": b.library_item_id,
"title": b.title,
"time": b.time_seconds,
"createdAt": int(b.created_at.timestamp() * 1000) if b.created_at else 0,
}
@router.get("")
async def get_me(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
progress_result = await db.execute(
select(MediaProgress).where(MediaProgress.user_id == current_user.id)
)
all_progress = progress_result.scalars().all()
bookmarks_result = await db.execute(
select(Bookmark).where(Bookmark.user_id == current_user.id)
)
all_bookmarks = bookmarks_result.scalars().all()
user_out = _build_user_out(current_user)
user_dict = user_out.model_dump(by_alias=True)
user_dict["mediaProgress"] = [_progress_to_out(p) for p in all_progress]
user_dict["bookmarks"] = [_bookmark_to_out(b) for b in all_bookmarks]
return user_dict
@router.get("/progress")
async def get_all_progress(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(MediaProgress).where(MediaProgress.user_id == current_user.id)
)
return [_progress_to_out(p) for p in result.scalars().all()]
@router.patch("/progress/{library_item_id}")
async def update_progress(
library_item_id: str,
body: dict,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
episode_id = body.get("episodeId")
query = select(MediaProgress).where(
MediaProgress.user_id == current_user.id,
MediaProgress.library_item_id == library_item_id,
)
if episode_id:
query = query.where(MediaProgress.episode_id == episode_id)
result = await db.execute(query)
progress = result.scalar_one_or_none()
if not progress:
progress = MediaProgress(
user_id=current_user.id,
library_item_id=library_item_id,
episode_id=episode_id,
)
db.add(progress)
if "currentTime" in body:
progress.current_time = float(body["currentTime"])
if "duration" in body:
progress.duration = float(body["duration"])
if "isFinished" in body:
progress.is_finished = bool(body["isFinished"])
if progress.is_finished and not progress.finished_at:
progress.finished_at = datetime.utcnow()
if "hideFromContinueListening" in body:
progress.hide_from_continue_listening = bool(body["hideFromContinueListening"])
if not progress.started_at:
progress.started_at = datetime.utcnow()
progress.last_update = datetime.utcnow()
await db.commit()
await db.refresh(progress)
return _progress_to_out(progress)
@router.post("/sync-local-progress")
async def sync_local_progress(
body: dict,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
local_progress_list = body.get("localMediaProgress", [])
updated = []
for lp in local_progress_list:
lib_item_id = lp.get("libraryItemId")
episode_id = lp.get("episodeId")
if not lib_item_id:
continue
query = select(MediaProgress).where(
MediaProgress.user_id == current_user.id,
MediaProgress.library_item_id == lib_item_id,
)
if episode_id:
query = query.where(MediaProgress.episode_id == episode_id)
result = await db.execute(query)
progress = result.scalar_one_or_none()
local_last_update = lp.get("lastUpdate", 0)
server_last_update = int(progress.last_update.timestamp() * 1000) if progress and progress.last_update else 0
# Nur updaten wenn lokaler Stand neuer ist
if not progress or local_last_update > server_last_update:
if not progress:
progress = MediaProgress(
user_id=current_user.id,
library_item_id=lib_item_id,
episode_id=episode_id,
)
db.add(progress)
progress.current_time = float(lp.get("currentTime", 0))
progress.duration = float(lp.get("duration", 0))
progress.is_finished = bool(lp.get("isFinished", False))
progress.last_update = datetime.utcnow()
updated.append(lib_item_id)
await db.commit()
return {"updated": updated}
@router.delete("/progress/{progress_id}")
async def delete_progress(
progress_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(MediaProgress).where(
MediaProgress.id == progress_id,
MediaProgress.user_id == current_user.id,
)
)
progress = result.scalar_one_or_none()
if not progress:
raise HTTPException(status_code=404, detail="Progress not found")
await db.delete(progress)
await db.commit()
return {"success": True}
@router.get("/bookmarks")
async def get_bookmarks(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Bookmark).where(Bookmark.user_id == current_user.id)
)
return [_bookmark_to_out(b) for b in result.scalars().all()]
@router.post("/bookmark/{library_item_id}")
async def create_bookmark(
library_item_id: str,
body: dict,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
bookmark = Bookmark(
user_id=current_user.id,
library_item_id=library_item_id,
time_seconds=float(body.get("time", 0)),
title=body.get("title", "Lesezeichen"),
)
db.add(bookmark)
await db.commit()
await db.refresh(bookmark)
return _bookmark_to_out(bookmark)
@router.delete("/bookmark/{library_item_id}/{time}")
async def delete_bookmark(
library_item_id: str,
time: float,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Bookmark).where(
Bookmark.user_id == current_user.id,
Bookmark.library_item_id == library_item_id,
Bookmark.time_seconds == time,
)
)
bookmark = result.scalar_one_or_none()
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
await db.delete(bookmark)
await db.commit()
return {"success": True}

View File

@@ -0,0 +1,53 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime
from ..dependencies import get_db, get_current_user, require_admin
from ..models.user import User
from ..models.session import ServerSetting
from ..config import get_settings
router = APIRouter(prefix="/api/settings", tags=["settings"])
DEFAULT_SETTINGS = {
"autoMatchBooks": True,
"autoMatchPodcasts": True,
"matchSources": ["musicbrainz", "open_library", "google_books"],
"podcastUpdateIntervalHours": 24,
"coverAspectRatio": 1,
"disableOpds": False,
"logLevel": 2,
"version": "2.4.0",
"buildNumber": 1,
}
@router.get("")
async def get_settings_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ServerSetting))
rows = result.scalars().all()
settings_dict = {**DEFAULT_SETTINGS}
for row in rows:
settings_dict[row.key] = row.value
return settings_dict
@router.patch("")
async def update_settings(
body: dict,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
for key, value in body.items():
result = await db.execute(select(ServerSetting).where(ServerSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
setting.updated_at = datetime.utcnow()
else:
db.add(ServerSetting(key=key, value=value))
await db.commit()
return {"success": True}

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)

View File

@@ -0,0 +1,160 @@
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from ..dependencies import get_db, get_current_user, require_admin
from ..models.user import User
from ..models.session import PlaybackSession
from ..services.auth import hash_password
from ..schemas.user import UserCreate, UserUpdate, UserOut, UserSettings
from ..routers.auth import _build_user_out
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("")
async def list_users(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User))
users = result.scalars().all()
return [_build_user_out(u).model_dump(by_alias=True) for u in users]
@router.post("")
async def create_user(
body: UserCreate,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
existing = await db.execute(select(User).where(User.username == body.username))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Username already exists")
user = User(
id=str(uuid.uuid4()),
username=body.username,
email=body.email,
password_hash=hash_password(body.password),
is_admin=body.is_admin,
)
db.add(user)
await db.commit()
await db.refresh(user)
return _build_user_out(user).model_dump(by_alias=True)
@router.get("/{user_id}")
async def get_user(
user_id: str,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return _build_user_out(user).model_dump(by_alias=True)
@router.patch("/{user_id}")
async def update_user(
user_id: str,
body: UserUpdate,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if body.email is not None:
user.email = body.email
if body.password is not None:
user.password_hash = hash_password(body.password)
if body.is_admin is not None:
user.is_admin = body.is_admin
if body.is_active is not None:
user.is_active = body.is_active
if body.settings is not None:
user.settings = {**(user.settings or {}), **body.settings}
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
return _build_user_out(user).model_dump(by_alias=True)
@router.delete("/{user_id}")
async def delete_user(
user_id: str,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.is_admin:
# Sicherstellen dass mindestens ein Admin übrig bleibt
admin_count_result = await db.execute(
select(User).where(User.is_admin == True)
)
admins = admin_count_result.scalars().all()
if len(admins) <= 1:
raise HTTPException(status_code=400, detail="Cannot delete the last admin")
await db.delete(user)
await db.commit()
return {"success": True}
@router.get("/{user_id}/listening-sessions")
async def get_listening_sessions(
user_id: str,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(PlaybackSession)
.where(PlaybackSession.user_id == user_id)
.order_by(PlaybackSession.updated_at.desc())
.limit(100)
)
sessions = result.scalars().all()
return {
"sessions": [
{
"id": s.id,
"userId": s.user_id,
"libraryItemId": s.library_item_id,
"episodeId": s.episode_id,
"mediaType": s.media_type,
"currentTime": s.current_time,
"duration": s.duration,
"startedAt": int(s.started_at.timestamp() * 1000) if s.started_at else 0,
"updatedAt": int(s.updated_at.timestamp() * 1000) if s.updated_at else 0,
}
for s in sessions
]
}
@router.get("/{user_id}/listening-stats")
async def get_listening_stats(
user_id: str,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(PlaybackSession).where(PlaybackSession.user_id == user_id)
)
sessions = result.scalars().all()
total_time = sum(s.duration for s in sessions if s.duration)
return {
"totalTime": total_time,
"numSessions": len(sessions),
"days": {},
}