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, 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") ext = os.path.splitext(item.cover_path)[1].lower() media_type = "image/png" if ext == ".png" else "image/jpeg" return FileResponse(item.cover_path, media_type=media_type) @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", )