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:
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}
|
||||
Reference in New Issue
Block a user