Files
Audiolib/backend/app/routers/items.py
Audiolib 14ffee3051 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>
2026-05-26 11:43:35 +02:00

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",
)