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