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:
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import auth, libraries, items, stream, me, users, settings
|
||||
102
backend/app/routers/auth.py
Normal file
102
backend/app/routers/auth.py
Normal 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"}
|
||||
272
backend/app/routers/items.py
Normal file
272
backend/app/routers/items.py
Normal 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",
|
||||
)
|
||||
218
backend/app/routers/libraries.py
Normal file
218
backend/app/routers/libraries.py
Normal 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
234
backend/app/routers/me.py
Normal 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}
|
||||
53
backend/app/routers/settings.py
Normal file
53
backend/app/routers/settings.py
Normal 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}
|
||||
248
backend/app/routers/stream.py
Normal file
248
backend/app/routers/stream.py
Normal 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)
|
||||
160
backend/app/routers/users.py
Normal file
160
backend/app/routers/users.py
Normal 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": {},
|
||||
}
|
||||
Reference in New Issue
Block a user