- 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>
273 lines
9.4 KiB
Python
273 lines
9.4 KiB
Python
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",
|
|
)
|