- 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>
161 lines
5.0 KiB
Python
161 lines
5.0 KiB
Python
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": {},
|
|
}
|