commit 14ffee305193cbfa8d62248f22be9a9ceb14231c Author: Audiolib Date: Tue May 26 11:43:35 2026 +0200 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..560d507 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Server +SERVER_PORT=3000 +JWT_SECRET=change_me_in_production_use_a_long_random_string + +# Admin-Account (wird beim ersten Start angelegt) +ADMIN_USERNAME=admin +ADMIN_PASSWORD=changeme +ADMIN_EMAIL=admin@example.com + +# Pfade +AUDIOFILES_PATH=./audiofiles +DATABASE_URL=sqlite+aiosqlite:////app/data/db/audiolib.db +HLS_CACHE_DIR=/app/data/hls_cache +COVERS_DIR=/app/data/covers +LOG_DIR=/app/data/logs + +# Matching (true/false) +AUTO_MATCH_BOOKS=true +AUTO_MATCH_PODCASTS=true +PODCAST_UPDATE_INTERVAL_HOURS=24 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d27b9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Umgebungsvariablen (Passwörter etc.) +.env + +# Claude Code lokale Einstellungen +.claude/ + +# Laufzeit-Daten +data/db/*.db +data/hls_cache/ +data/logs/ +data/covers/ + +# Python +__pycache__/ +*.py[cod] +*.pyo +.venv/ +venv/ +*.egg-info/ +dist/ +build/ + +# Node / Frontend +frontend/node_modules/ +frontend/dist/ + +# IDE +.vscode/ +.idea/ +*.swp + +# macOS +.DS_Store + +# Windows +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..c84b3f3 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# Audiolib + +Selbst gehosteter Audiobook- und Podcast-Server, vollständig API-kompatibel mit **Audiobookshelf (ABS) v2.x**. Bestehende Audiobookshelf-Apps (iOS, Android, etc.) funktionieren ohne Änderungen. + +## Voraussetzungen + +- Docker und Docker Compose installiert +- Audiodateien (MP3/WAV) auf dem Server verfügbar + +## Schnellstart + +### 1. Repository klonen / Dateien vorbereiten + +```bash +cd /opt/audiolib +cp .env.example .env +``` + +### 2. `.env` anpassen + +Mindestens folgende Werte setzen: + +```env +JWT_SECRET=mein_langer_geheimer_zufallsstring +ADMIN_USERNAME=admin +ADMIN_PASSWORD=sicheres_passwort +AUDIOFILES_PATH=/pfad/zu/meinen/audiodateien +``` + +### 3. Starten + +```bash +docker-compose up -d --build +``` + +Der Server ist dann erreichbar unter: `http://:3000` + +### 4. Erste Schritte + +1. Öffne `http://:3000` im Browser +2. Melde dich mit den in `.env` gesetzten Admin-Daten an +3. In der Audiobookshelf-App: Server-URL `http://:3000` eintragen +4. Library scannen: Admin → Libraries → Scan + +## Konfiguration + +Alle Einstellungen werden über die `.env`-Datei konfiguriert: + +| Variable | Standard | Beschreibung | +|---|---|---| +| `SERVER_PORT` | `3000` | Externer Port | +| `JWT_SECRET` | — | **Unbedingt ändern!** Langer zufälliger String | +| `ADMIN_USERNAME` | `admin` | Admin-Benutzername | +| `ADMIN_PASSWORD` | `changeme` | **Unbedingt ändern!** | +| `AUDIOFILES_PATH` | `./audiofiles` | Pfad zu den Audiodateien (NAS-Mount möglich) | +| `AUTO_MATCH_BOOKS` | `true` | Automatisches Metadaten-Matching für Hörbücher | +| `AUTO_MATCH_PODCASTS` | `true` | Automatisches Matching für Podcasts | +| `PODCAST_UPDATE_INTERVAL_HOURS` | `24` | Wie oft Podcast-Feeds aktualisiert werden | + +## Projektstruktur + +``` +audiolib/ +├── backend/ Python/FastAPI Backend +│ └── app/ +│ ├── models/ SQLAlchemy-Datenmodelle +│ ├── routers/ API-Endpoints +│ ├── schemas/ Pydantic-Schemas (ABS-kompatibel) +│ └── services/ Scanner, HLS, File Watcher, Matching +├── frontend/ React/Vite Web-Interface (Phase 8) +├── nginx/ Reverse-Proxy-Konfiguration +├── data/ Persistente Daten (DB, Cover, HLS-Cache) +└── docker-compose.yml +``` + +## API-Kompatibilität + +Implementierte ABS-Endpoints: + +- `POST /login` — Authentifizierung +- `GET /api/authorize` — Token-Validierung + User/Libraries +- `GET /api/libraries` — Library-Liste +- `GET /api/libraries/:id/items` — Hörbücher/Podcasts einer Library +- `GET /api/items/:id` — Einzelnes Item mit Metadaten +- `GET /api/items/:id/cover` — Cover-Bild +- `POST /api/items/:id/play` — HLS-Streaming starten +- `POST /api/playback-session/:id/sync` — Fortschritt synchronisieren +- `GET /api/me` — User-Profil mit Fortschritt +- `PATCH /api/me/progress/:id` — Hörfortschritt setzen +- `POST /api/me/bookmark/:id` — Lesezeichen setzen +- `GET /api/users` — User-Verwaltung (Admin) +- `GET /api/settings` — Server-Einstellungen + +## Entwicklung + +### Backend lokal starten (ohne Docker) + +```bash +cd backend +pip install -r requirements.txt +cp ../.env.example .env +uvicorn app.main:app --reload --port 8000 +``` + +### Frontend lokal starten + +```bash +cd frontend +npm install +npm run dev +``` + +## Geplante Features (nächste Phasen) + +- [ ] Phase 5: Matching-Engine Hörbücher (MusicBrainz, OpenLibrary, Google Books) +- [ ] Phase 6: Matching-Engine Podcasts (iTunes, Podcastindex) +- [ ] Phase 7: Vollständiger Podcast-Support (RSS-Feeds, Episoden) +- [ ] Phase 8: Vollständiges Web-Interface mit Player +- [ ] Phase 9: Web-Player mit Kapitelnavigation, Lesezeichen, Sleep-Timer + +## Lizenz + +MIT diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1bbe586 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +RUN mkdir -p /app/data/db /app/data/covers /app/data/hls_cache /app/data/logs + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..0028300 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,34 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict +from functools import lru_cache + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + # Server + server_port: int = 3000 + jwt_secret: str = "change_me_in_production" + jwt_algorithm: str = "HS256" + jwt_expire_days: int = 365 # ABS verwendet sehr lange Token-Laufzeiten + + # Admin + admin_username: str = "admin" + admin_password: str = "changeme" + admin_email: str = "admin@example.com" + + # Paths + audiofiles_path: str = "/audiofiles" + database_url: str = "sqlite+aiosqlite:////app/data/db/audiolib.db" + hls_cache_dir: str = "/app/data/hls_cache" + covers_dir: str = "/app/data/covers" + log_dir: str = "/app/data/logs" + + # Matching + auto_match_books: bool = True + auto_match_podcasts: bool = True + podcast_update_interval_hours: int = 24 + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..8865fdc --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,26 @@ +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.orm import DeclarativeBase +from .config import get_settings + + +class Base(DeclarativeBase): + pass + + +def create_engine_and_session(): + settings = get_settings() + engine = create_async_engine( + settings.database_url, + connect_args={"check_same_thread": False}, + echo=False, + ) + session_factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + return engine, session_factory + + +engine, AsyncSessionLocal = create_engine_and_session() + + +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..e45bab6 --- /dev/null +++ b/backend/app/dependencies.py @@ -0,0 +1,39 @@ +from typing import AsyncGenerator +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from .database import AsyncSessionLocal +from .models.user import User +from .services.auth import decode_token + +bearer_scheme = HTTPBearer(auto_error=False) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + yield session + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + db: AsyncSession = Depends(get_db), +) -> User: + if not credentials: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + + user_id = decode_token(credentials.credentials) + if not user_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + + result = await db.execute(select(User).where(User.id == user_id, User.is_active == True)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + return user + + +async def require_admin(current_user: User = Depends(get_current_user)) -> User: + if not current_user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin required") + return current_user diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..22b2d8c --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,115 @@ +import uuid +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +import os + +from .database import init_db, AsyncSessionLocal +from .config import get_settings +from .models import User, Library +from .services.auth import hash_password, create_token +from .services.file_watcher import start_file_watcher, stop_file_watcher +from sqlalchemy import select + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + + +async def _seed_admin(): + settings = get_settings() + async with AsyncSessionLocal() as db: + result = await db.execute(select(User).where(User.is_admin == True)) + if result.scalar_one_or_none(): + return # Admin existiert bereits + + logger.info(f"Lege Admin-User an: {settings.admin_username}") + admin = User( + id=str(uuid.uuid4()), + username=settings.admin_username, + email=settings.admin_email, + password_hash=hash_password(settings.admin_password), + is_admin=True, + ) + db.add(admin) + await db.flush() + # Token mit echter ID erstellen + admin.token = create_token(admin.id) + await db.commit() + logger.info("Admin-User angelegt.") + + +async def _seed_default_library(): + settings = get_settings() + async with AsyncSessionLocal() as db: + result = await db.execute(select(Library)) + if result.scalar_one_or_none(): + return # Bereits eine Library vorhanden + + folder_id = str(uuid.uuid4()) + lib = Library( + id=str(uuid.uuid4()), + name="Hörbücher", + display_name="Hörbücher", + folders=[{"id": folder_id, "fullPath": settings.audiofiles_path}], + media_type="book", + settings={"icon": "headphones", "provider": "google"}, + ) + db.add(lib) + await db.commit() + logger.info(f"Standard-Library angelegt: {settings.audiofiles_path}") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + settings = get_settings() + os.makedirs(settings.hls_cache_dir, exist_ok=True) + os.makedirs(settings.covers_dir, exist_ok=True) + os.makedirs(settings.log_dir, exist_ok=True) + + await init_db() + await _seed_admin() + await _seed_default_library() + await start_file_watcher() + logger.info("Audiolib gestartet.") + yield + # Shutdown + stop_file_watcher() + logger.info("Audiolib gestoppt.") + + +app = FastAPI( + title="Audiolib", + version="2.4.0", + description="Selbst gehosteter Audiobook-Server, API-kompatibel mit Audiobookshelf", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Cover-Dateien direkt ausliefern +settings = get_settings() +if os.path.exists(settings.covers_dir): + app.mount("/covers", StaticFiles(directory=settings.covers_dir), name="covers") + +# Router registrieren +from .routers import auth, libraries, items, stream, me, users, settings as settings_router + +app.include_router(auth.router) +app.include_router(libraries.router) +app.include_router(items.router) +app.include_router(stream.router) +app.include_router(me.router) +app.include_router(users.router) +app.include_router(settings_router.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..ede302a --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,12 @@ +from .user import User +from .library import Library +from .media_item import LibraryItem, BookFile, Chapter +from .podcast import Podcast, PodcastEpisode +from .progress import MediaProgress, Bookmark +from .session import PlaybackSession, ServerSetting, ScanJob + +__all__ = [ + "User", "Library", "LibraryItem", "BookFile", "Chapter", + "Podcast", "PodcastEpisode", "MediaProgress", "Bookmark", + "PlaybackSession", "ServerSetting", "ScanJob", +] diff --git a/backend/app/models/library.py b/backend/app/models/library.py new file mode 100644 index 0000000..e1a6a07 --- /dev/null +++ b/backend/app/models/library.py @@ -0,0 +1,21 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.sqlite import JSON +from ..database import Base + + +class Library(Base): + __tablename__ = "libraries" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + name: Mapped[str] = mapped_column(String(255), nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + # ABS-Format: [{"id": "...", "fullPath": "/audiofiles/..."}] + folders: Mapped[list] = mapped_column(JSON, default=list) + # "book" oder "podcast" + media_type: Mapped[str] = mapped_column(String(20), default="book") + settings: Mapped[dict] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/app/models/media_item.py b/backend/app/models/media_item.py new file mode 100644 index 0000000..145e3b2 --- /dev/null +++ b/backend/app/models/media_item.py @@ -0,0 +1,76 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Integer, Float, Boolean, DateTime, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.sqlite import JSON +from ..database import Base + + +class LibraryItem(Base): + __tablename__ = "library_items" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + library_id: Mapped[str] = mapped_column(String(36), ForeignKey("libraries.id", ondelete="CASCADE"), nullable=False) + media_type: Mapped[str] = mapped_column(String(20), default="book") # "book" / "podcast" + path: Mapped[str] = mapped_column(Text, nullable=False) + ino: Mapped[str | None] = mapped_column(String(100), nullable=True) # ABS inode-Feld + + # Metadaten + title: Mapped[str | None] = mapped_column(String(500), nullable=True) + subtitle: Mapped[str | None] = mapped_column(String(500), nullable=True) + author: Mapped[str | None] = mapped_column(String(500), nullable=True) + narrator: Mapped[str | None] = mapped_column(String(500), nullable=True) + publisher: Mapped[str | None] = mapped_column(String(255), nullable=True) + publish_year: Mapped[int | None] = mapped_column(Integer, nullable=True) + series: Mapped[str | None] = mapped_column(String(500), nullable=True) + series_sequence: Mapped[str | None] = mapped_column(String(50), nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + cover_path: Mapped[str | None] = mapped_column(Text, nullable=True) + language: Mapped[str | None] = mapped_column(String(10), nullable=True) + genres: Mapped[list] = mapped_column(JSON, default=list) + tags: Mapped[list] = mapped_column(JSON, default=list) + explicit: Mapped[bool] = mapped_column(Boolean, default=False) + abridged: Mapped[bool] = mapped_column(Boolean, default=False) + + # Datei-Infos + duration_seconds: Mapped[float] = mapped_column(Float, default=0.0) + size_bytes: Mapped[int] = mapped_column(Integer, default=0) + num_files: Mapped[int] = mapped_column(Integer, default=0) + + # Matching + matched_source: Mapped[str] = mapped_column(String(50), default="none") + matched_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + match_confidence: Mapped[float] = mapped_column(Float, default=0.0) + match_locked: Mapped[bool] = mapped_column(Boolean, default=False) + + # Status + is_missing: Mapped[bool] = mapped_column(Boolean, default=False) + is_invalid: Mapped[bool] = mapped_column(Boolean, default=False) + + added_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class BookFile(Base): + __tablename__ = "book_files" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + library_item_id: Mapped[str] = mapped_column(String(36), ForeignKey("library_items.id", ondelete="CASCADE"), nullable=False) + filename: Mapped[str] = mapped_column(String(500), nullable=False) + path: Mapped[str] = mapped_column(Text, nullable=False) + format: Mapped[str] = mapped_column(String(10), nullable=False) # mp3 / wav + size_bytes: Mapped[int] = mapped_column(Integer, default=0) + duration_seconds: Mapped[float] = mapped_column(Float, default=0.0) + track_index: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class Chapter(Base): + __tablename__ = "chapters" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + library_item_id: Mapped[str] = mapped_column(String(36), ForeignKey("library_items.id", ondelete="CASCADE"), nullable=False) + chapter_index: Mapped[int] = mapped_column(Integer, nullable=False) + title: Mapped[str] = mapped_column(String(500), nullable=False) + start_seconds: Mapped[float] = mapped_column(Float, default=0.0) + end_seconds: Mapped[float] = mapped_column(Float, default=0.0) diff --git a/backend/app/models/podcast.py b/backend/app/models/podcast.py new file mode 100644 index 0000000..ba76186 --- /dev/null +++ b/backend/app/models/podcast.py @@ -0,0 +1,40 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Integer, Float, Boolean, DateTime, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.sqlite import JSON +from ..database import Base + + +class Podcast(Base): + __tablename__ = "podcasts" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + library_item_id: Mapped[str] = mapped_column(String(36), ForeignKey("library_items.id", ondelete="CASCADE"), unique=True, nullable=False) + feed_url: Mapped[str | None] = mapped_column(Text, nullable=True) + feed_type: Mapped[str] = mapped_column(String(10), default="rss") + feed_last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + update_interval_hours: Mapped[int] = mapped_column(Integer, default=24) + author: Mapped[str | None] = mapped_column(String(500), nullable=True) + categories: Mapped[list] = mapped_column(JSON, default=list) + explicit: Mapped[bool] = mapped_column(Boolean, default=False) + language: Mapped[str | None] = mapped_column(String(10), nullable=True) + + +class PodcastEpisode(Base): + __tablename__ = "podcast_episodes" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + podcast_id: Mapped[str] = mapped_column(String(36), ForeignKey("podcasts.id", ondelete="CASCADE"), nullable=False) + episode_number: Mapped[str | None] = mapped_column(String(50), nullable=True) + season_number: Mapped[str | None] = mapped_column(String(50), nullable=True) + title: Mapped[str | None] = mapped_column(String(500), nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + pub_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + duration_seconds: Mapped[float] = mapped_column(Float, default=0.0) + path: Mapped[str | None] = mapped_column(Text, nullable=True) + size_bytes: Mapped[int] = mapped_column(Integer, default=0) + feed_episode_id: Mapped[str | None] = mapped_column(String(500), nullable=True) + feed_episode_url: Mapped[str | None] = mapped_column(Text, nullable=True) + explicit: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/backend/app/models/progress.py b/backend/app/models/progress.py new file mode 100644 index 0000000..c411c94 --- /dev/null +++ b/backend/app/models/progress.py @@ -0,0 +1,32 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Float, Boolean, DateTime, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from ..database import Base + + +class MediaProgress(Base): + __tablename__ = "media_progress" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + library_item_id: Mapped[str] = mapped_column(String(36), ForeignKey("library_items.id", ondelete="CASCADE"), nullable=False) + episode_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + duration: Mapped[float] = mapped_column(Float, default=0.0) + current_time: Mapped[float] = mapped_column(Float, default=0.0) + is_finished: Mapped[bool] = mapped_column(Boolean, default=False) + hide_from_continue_listening: Mapped[bool] = mapped_column(Boolean, default=False) + started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_update: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + +class Bookmark(Base): + __tablename__ = "bookmarks" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + library_item_id: Mapped[str] = mapped_column(String(36), ForeignKey("library_items.id", ondelete="CASCADE"), nullable=False) + time_seconds: Mapped[float] = mapped_column(Float, nullable=False) + title: Mapped[str] = mapped_column(String(500), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/backend/app/models/session.py b/backend/app/models/session.py new file mode 100644 index 0000000..ab2a658 --- /dev/null +++ b/backend/app/models/session.py @@ -0,0 +1,46 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Float, Boolean, DateTime, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.sqlite import JSON +from ..database import Base + + +class PlaybackSession(Base): + __tablename__ = "playback_sessions" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + library_item_id: Mapped[str] = mapped_column(String(36), ForeignKey("library_items.id", ondelete="CASCADE"), nullable=False) + episode_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + media_type: Mapped[str] = mapped_column(String(20), default="book") + current_time: Mapped[float] = mapped_column(Float, default=0.0) + duration: Mapped[float] = mapped_column(Float, default=0.0) + device_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + device_info: Mapped[dict] = mapped_column(JSON, default=dict) + media_player: Mapped[str | None] = mapped_column(String(100), nullable=True) + started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + hls_session_path: Mapped[str | None] = mapped_column(Text, nullable=True) + + +class ServerSetting(Base): + __tablename__ = "server_settings" + + key: Mapped[str] = mapped_column(String(100), primary_key=True) + value: Mapped[dict | list | str | bool | int | float | None] = mapped_column(JSON, nullable=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class ScanJob(Base): + __tablename__ = "scan_jobs" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + library_id: Mapped[str] = mapped_column(String(36), ForeignKey("libraries.id", ondelete="CASCADE"), nullable=False) + status: Mapped[str] = mapped_column(String(20), default="queued") # queued/running/done/error + progress: Mapped[float] = mapped_column(Float, default=0.0) + items_found: Mapped[int] = mapped_column(default=0) + started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + log: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..6c891af --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,22 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Boolean, DateTime, Text +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.sqlite import JSON +from ..database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + email: Mapped[str] = mapped_column(String(255), nullable=True) + password_hash: Mapped[str] = mapped_column(Text, nullable=False) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + token: Mapped[str | None] = mapped_column(Text, nullable=True) + settings: Mapped[dict] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..dabf920 --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1 @@ +from . import auth, libraries, items, stream, me, users, settings diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..65ed8ba --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,102 @@ +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from ..database import AsyncSessionLocal +from ..dependencies import get_db, get_current_user +from ..models.user import User +from ..models.library import Library +from ..models.session import ServerSetting +from ..services.auth import verify_password, create_token +from ..schemas.auth import LoginRequest, LoginResponse, AuthorizeResponse +from ..schemas.user import UserOut, UserSettings, ServerSettingsOut + +router = APIRouter(tags=["auth"]) + + +def _build_user_out(user: User) -> UserOut: + raw_settings = user.settings or {} + settings = UserSettings(**{k: v for k, v in raw_settings.items() if k in UserSettings.model_fields}) + return UserOut( + id=user.id, + username=user.username, + email=user.email, + is_admin=user.is_admin, + is_active=user.is_active, + last_seen=user.last_seen, + created_at=user.created_at, + token=user.token, + settings=settings, + type="root" if user.is_admin else "user", + permissions={ + "download": True, + "update": user.is_admin, + "delete": user.is_admin, + "upload": user.is_admin, + "access_all_libraries": True, + "access_explicit_content": True, + }, + ) + + +@router.post("/login", response_model=LoginResponse) +async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(User).where(User.username == body.username, User.is_active == True) + ) + user = result.scalar_one_or_none() + if not user or not verify_password(body.password, user.password_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + + token = create_token(user.id) + user.token = token + user.last_seen = datetime.utcnow() + await db.commit() + + # Erste Library als Default zurückgeben + lib_result = await db.execute(select(Library).limit(1)) + first_lib = lib_result.scalar_one_or_none() + + return LoginResponse( + user=_build_user_out(user), + user_default_library_id=first_lib.id if first_lib else None, + server_settings=ServerSettingsOut(), + ) + + +@router.post("/logout") +async def logout(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + current_user.token = None + await db.commit() + return {"success": True} + + +@router.get("/api/authorize", response_model=AuthorizeResponse) +async def authorize(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + current_user.last_seen = datetime.utcnow() + await db.commit() + + lib_result = await db.execute(select(Library)) + libraries = lib_result.scalars().all() + + from ..routers.libraries import _library_to_out + libs_out = [_library_to_out(lib) for lib in libraries] + + first_lib_id = libraries[0].id if libraries else None + + return AuthorizeResponse( + user=_build_user_out(current_user), + libraries=libs_out, + user_default_library_id=first_lib_id, + server_settings=ServerSettingsOut(), + ) + + +@router.get("/ping") +async def ping(): + return {"success": True} + + +@router.get("/health") +async def health(): + return {"status": "ok"} diff --git a/backend/app/routers/items.py b/backend/app/routers/items.py new file mode 100644 index 0000000..213f98c --- /dev/null +++ b/backend/app/routers/items.py @@ -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", + ) diff --git a/backend/app/routers/libraries.py b/backend/app/routers/libraries.py new file mode 100644 index 0000000..d6c4a48 --- /dev/null +++ b/backend/app/routers/libraries.py @@ -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} diff --git a/backend/app/routers/me.py b/backend/app/routers/me.py new file mode 100644 index 0000000..3db1154 --- /dev/null +++ b/backend/app/routers/me.py @@ -0,0 +1,234 @@ +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete +from ..dependencies import get_db, get_current_user +from ..models.user import User +from ..models.progress import MediaProgress, Bookmark +from ..models.media_item import LibraryItem +from ..schemas.user import UserOut, UserSettings +from ..routers.auth import _build_user_out + +router = APIRouter(prefix="/api/me", tags=["me"]) + + +def _progress_to_out(p: MediaProgress) -> dict: + return { + "id": p.id, + "libraryItemId": p.library_item_id, + "episodeId": p.episode_id, + "duration": p.duration, + "progress": round(p.current_time / p.duration, 4) if p.duration > 0 else 0.0, + "currentTime": p.current_time, + "isFinished": p.is_finished, + "hideFromContinueListening": p.hide_from_continue_listening, + "lastUpdate": int(p.last_update.timestamp() * 1000) if p.last_update else 0, + "startedAt": int(p.started_at.timestamp() * 1000) if p.started_at else 0, + "finishedAt": int(p.finished_at.timestamp() * 1000) if p.finished_at else None, + } + + +def _bookmark_to_out(b: Bookmark) -> dict: + return { + "libraryItemId": b.library_item_id, + "title": b.title, + "time": b.time_seconds, + "createdAt": int(b.created_at.timestamp() * 1000) if b.created_at else 0, + } + + +@router.get("") +async def get_me( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + progress_result = await db.execute( + select(MediaProgress).where(MediaProgress.user_id == current_user.id) + ) + all_progress = progress_result.scalars().all() + + bookmarks_result = await db.execute( + select(Bookmark).where(Bookmark.user_id == current_user.id) + ) + all_bookmarks = bookmarks_result.scalars().all() + + user_out = _build_user_out(current_user) + user_dict = user_out.model_dump(by_alias=True) + user_dict["mediaProgress"] = [_progress_to_out(p) for p in all_progress] + user_dict["bookmarks"] = [_bookmark_to_out(b) for b in all_bookmarks] + return user_dict + + +@router.get("/progress") +async def get_all_progress( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(MediaProgress).where(MediaProgress.user_id == current_user.id) + ) + return [_progress_to_out(p) for p in result.scalars().all()] + + +@router.patch("/progress/{library_item_id}") +async def update_progress( + library_item_id: str, + body: dict, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + episode_id = body.get("episodeId") + query = select(MediaProgress).where( + MediaProgress.user_id == current_user.id, + MediaProgress.library_item_id == library_item_id, + ) + if episode_id: + query = query.where(MediaProgress.episode_id == episode_id) + + result = await db.execute(query) + progress = result.scalar_one_or_none() + + if not progress: + progress = MediaProgress( + user_id=current_user.id, + library_item_id=library_item_id, + episode_id=episode_id, + ) + db.add(progress) + + if "currentTime" in body: + progress.current_time = float(body["currentTime"]) + if "duration" in body: + progress.duration = float(body["duration"]) + if "isFinished" in body: + progress.is_finished = bool(body["isFinished"]) + if progress.is_finished and not progress.finished_at: + progress.finished_at = datetime.utcnow() + if "hideFromContinueListening" in body: + progress.hide_from_continue_listening = bool(body["hideFromContinueListening"]) + if not progress.started_at: + progress.started_at = datetime.utcnow() + progress.last_update = datetime.utcnow() + + await db.commit() + await db.refresh(progress) + return _progress_to_out(progress) + + +@router.post("/sync-local-progress") +async def sync_local_progress( + body: dict, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + local_progress_list = body.get("localMediaProgress", []) + updated = [] + + for lp in local_progress_list: + lib_item_id = lp.get("libraryItemId") + episode_id = lp.get("episodeId") + if not lib_item_id: + continue + + query = select(MediaProgress).where( + MediaProgress.user_id == current_user.id, + MediaProgress.library_item_id == lib_item_id, + ) + if episode_id: + query = query.where(MediaProgress.episode_id == episode_id) + + result = await db.execute(query) + progress = result.scalar_one_or_none() + + local_last_update = lp.get("lastUpdate", 0) + server_last_update = int(progress.last_update.timestamp() * 1000) if progress and progress.last_update else 0 + + # Nur updaten wenn lokaler Stand neuer ist + if not progress or local_last_update > server_last_update: + if not progress: + progress = MediaProgress( + user_id=current_user.id, + library_item_id=lib_item_id, + episode_id=episode_id, + ) + db.add(progress) + progress.current_time = float(lp.get("currentTime", 0)) + progress.duration = float(lp.get("duration", 0)) + progress.is_finished = bool(lp.get("isFinished", False)) + progress.last_update = datetime.utcnow() + updated.append(lib_item_id) + + await db.commit() + return {"updated": updated} + + +@router.delete("/progress/{progress_id}") +async def delete_progress( + progress_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(MediaProgress).where( + MediaProgress.id == progress_id, + MediaProgress.user_id == current_user.id, + ) + ) + progress = result.scalar_one_or_none() + if not progress: + raise HTTPException(status_code=404, detail="Progress not found") + await db.delete(progress) + await db.commit() + return {"success": True} + + +@router.get("/bookmarks") +async def get_bookmarks( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(Bookmark).where(Bookmark.user_id == current_user.id) + ) + return [_bookmark_to_out(b) for b in result.scalars().all()] + + +@router.post("/bookmark/{library_item_id}") +async def create_bookmark( + library_item_id: str, + body: dict, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + bookmark = Bookmark( + user_id=current_user.id, + library_item_id=library_item_id, + time_seconds=float(body.get("time", 0)), + title=body.get("title", "Lesezeichen"), + ) + db.add(bookmark) + await db.commit() + await db.refresh(bookmark) + return _bookmark_to_out(bookmark) + + +@router.delete("/bookmark/{library_item_id}/{time}") +async def delete_bookmark( + library_item_id: str, + time: float, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(Bookmark).where( + Bookmark.user_id == current_user.id, + Bookmark.library_item_id == library_item_id, + Bookmark.time_seconds == time, + ) + ) + bookmark = result.scalar_one_or_none() + if not bookmark: + raise HTTPException(status_code=404, detail="Bookmark not found") + await db.delete(bookmark) + await db.commit() + return {"success": True} diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py new file mode 100644 index 0000000..fd5e0b3 --- /dev/null +++ b/backend/app/routers/settings.py @@ -0,0 +1,53 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from datetime import datetime +from ..dependencies import get_db, get_current_user, require_admin +from ..models.user import User +from ..models.session import ServerSetting +from ..config import get_settings + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + +DEFAULT_SETTINGS = { + "autoMatchBooks": True, + "autoMatchPodcasts": True, + "matchSources": ["musicbrainz", "open_library", "google_books"], + "podcastUpdateIntervalHours": 24, + "coverAspectRatio": 1, + "disableOpds": False, + "logLevel": 2, + "version": "2.4.0", + "buildNumber": 1, +} + + +@router.get("") +async def get_settings_endpoint( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(ServerSetting)) + rows = result.scalars().all() + settings_dict = {**DEFAULT_SETTINGS} + for row in rows: + settings_dict[row.key] = row.value + return settings_dict + + +@router.patch("") +async def update_settings( + body: dict, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + for key, value in body.items(): + result = await db.execute(select(ServerSetting).where(ServerSetting.key == key)) + setting = result.scalar_one_or_none() + if setting: + setting.value = value + setting.updated_at = datetime.utcnow() + else: + db.add(ServerSetting(key=key, value=value)) + await db.commit() + return {"success": True} diff --git a/backend/app/routers/stream.py b/backend/app/routers/stream.py new file mode 100644 index 0000000..031277c --- /dev/null +++ b/backend/app/routers/stream.py @@ -0,0 +1,248 @@ +import os +import uuid +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from ..dependencies import get_db, get_current_user +from ..models.user import User +from ..models.media_item import LibraryItem, BookFile, Chapter +from ..models.session import PlaybackSession +from ..models.progress import MediaProgress +from ..services.hls import create_hls_session, cleanup_hls_session, get_hls_session_path +from ..config import get_settings + +router = APIRouter(tags=["stream"]) + + +@router.post("/api/items/{item_id}/play") +async def start_playback( + item_id: str, + body: dict | None = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + body = body or {} + 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).order_by(BookFile.track_index) + ) + files = files_result.scalars().all() + if not files: + raise HTTPException(status_code=400, detail="No audio files for this item") + + chapters_result = await db.execute( + select(Chapter).where(Chapter.library_item_id == item_id).order_by(Chapter.chapter_index) + ) + chapters = chapters_result.scalars().all() + + # Fortschritt ermitteln + progress_result = await db.execute( + select(MediaProgress).where( + MediaProgress.user_id == current_user.id, + MediaProgress.library_item_id == item_id, + ) + ) + progress = progress_result.scalar_one_or_none() + current_time = progress.current_time if progress else 0.0 + if body.get("startTime") is not None: + current_time = float(body["startTime"]) + + session_id = str(uuid.uuid4()) + + # HLS-Session asynchron starten + audio_paths = [f.path for f in files] + hls_dir = await create_hls_session(session_id, audio_paths, start_time=0.0) + + session = PlaybackSession( + id=session_id, + user_id=current_user.id, + library_item_id=item_id, + media_type=item.media_type, + current_time=current_time, + duration=item.duration_seconds or 0.0, + device_id=body.get("deviceId", ""), + device_info=body.get("deviceInfo", {}), + media_player=body.get("mediaPlayer", ""), + hls_session_path=hls_dir, + is_active=True, + ) + db.add(session) + await db.commit() + + settings = get_settings() + # URL-Basis relativ — wird durch nginx weitergeleitet + hls_url = f"/hls/{session_id}/output.m3u8" + + audio_tracks = [ + { + "index": 0, + "startOffset": 0.0, + "duration": item.duration_seconds or 0.0, + "title": "Part 1", + "contentUrl": hls_url, + "mimeType": "application/x-mpegURL", + "metadata": { + "filename": "output.m3u8", + "ext": ".m3u8", + "path": hls_url, + "relPath": "output.m3u8", + "size": 0, + }, + } + ] + + chapters_out = [ + { + "id": c.chapter_index, + "start": c.start_seconds, + "end": c.end_seconds, + "title": c.title, + } + for c in chapters + ] + + return { + "id": session_id, + "userId": current_user.id, + "libraryId": item.library_id, + "libraryItemId": item_id, + "episodeId": None, + "mediaType": item.media_type, + "chapters": chapters_out, + "displayTitle": item.title, + "displayAuthor": item.author, + "coverPath": f"/api/items/{item_id}/cover" if item.cover_path else None, + "duration": item.duration_seconds or 0.0, + "playMethod": 0, # 0 = HLS Transcode + "mediaPlayer": body.get("mediaPlayer", ""), + "deviceInfo": body.get("deviceInfo", {}), + "serverVersion": "2.4.0", + "date": datetime.utcnow().strftime("%Y-%m-%d"), + "dayOfWeek": datetime.utcnow().strftime("%A"), + "timeListening": 0, + "startTime": current_time, + "currentTime": current_time, + "startedAt": int(datetime.utcnow().timestamp() * 1000), + "updatedAt": int(datetime.utcnow().timestamp() * 1000), + "audioTracks": audio_tracks, + "videoTrack": None, + } + + +@router.post("/api/playback-session/{session_id}/sync") +async def sync_session( + session_id: str, + body: dict = {}, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(PlaybackSession).where( + PlaybackSession.id == session_id, + PlaybackSession.user_id == current_user.id, + ) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + current_time = float(body.get("currentTime", session.current_time)) + duration = float(body.get("duration", session.duration)) + time_listening = float(body.get("timeListening", 0)) + + session.current_time = current_time + session.duration = duration + session.updated_at = datetime.utcnow() + + # Fortschritt persistieren + progress_result = await db.execute( + select(MediaProgress).where( + MediaProgress.user_id == current_user.id, + MediaProgress.library_item_id == session.library_item_id, + MediaProgress.episode_id == session.episode_id, + ) + ) + progress = progress_result.scalar_one_or_none() + if not progress: + progress = MediaProgress( + user_id=current_user.id, + library_item_id=session.library_item_id, + episode_id=session.episode_id, + duration=duration, + started_at=datetime.utcnow(), + ) + db.add(progress) + + progress.current_time = current_time + progress.duration = duration + progress.last_update = datetime.utcnow() + is_finished = duration > 0 and (current_time / duration) >= 0.99 + if is_finished and not progress.is_finished: + progress.is_finished = True + progress.finished_at = datetime.utcnow() + elif not is_finished: + progress.is_finished = False + + await db.commit() + return {"id": session_id, "currentTime": current_time} + + +@router.delete("/api/playback-session/{session_id}") +async def close_session( + session_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(PlaybackSession).where( + PlaybackSession.id == session_id, + PlaybackSession.user_id == current_user.id, + ) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + session.is_active = False + await db.commit() + + # HLS-Temp-Dateien bereinigen + cleanup_hls_session(session_id) + return {"success": True} + + +@router.get("/hls/{session_id}/{filename}") +async def serve_hls( + session_id: str, + filename: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + # Session prüfen + result = await db.execute( + select(PlaybackSession).where( + PlaybackSession.id == session_id, + PlaybackSession.user_id == current_user.id, + ) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + settings = get_settings() + file_path = os.path.join(settings.hls_cache_dir, session_id, filename) + if not os.path.exists(file_path): + raise HTTPException(status_code=404, detail="Segment not found") + + if filename.endswith(".m3u8"): + return FileResponse(file_path, media_type="application/x-mpegURL") + elif filename.endswith(".ts"): + return FileResponse(file_path, media_type="video/MP2T") + else: + return FileResponse(file_path) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..44abafd --- /dev/null +++ b/backend/app/routers/users.py @@ -0,0 +1,160 @@ +import uuid +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from ..dependencies import get_db, get_current_user, require_admin +from ..models.user import User +from ..models.session import PlaybackSession +from ..services.auth import hash_password +from ..schemas.user import UserCreate, UserUpdate, UserOut, UserSettings +from ..routers.auth import _build_user_out + +router = APIRouter(prefix="/api/users", tags=["users"]) + + +@router.get("") +async def list_users( + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(User)) + users = result.scalars().all() + return [_build_user_out(u).model_dump(by_alias=True) for u in users] + + +@router.post("") +async def create_user( + body: UserCreate, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + existing = await db.execute(select(User).where(User.username == body.username)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Username already exists") + + user = User( + id=str(uuid.uuid4()), + username=body.username, + email=body.email, + password_hash=hash_password(body.password), + is_admin=body.is_admin, + ) + db.add(user) + await db.commit() + await db.refresh(user) + return _build_user_out(user).model_dump(by_alias=True) + + +@router.get("/{user_id}") +async def get_user( + user_id: str, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return _build_user_out(user).model_dump(by_alias=True) + + +@router.patch("/{user_id}") +async def update_user( + user_id: str, + body: UserUpdate, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if body.email is not None: + user.email = body.email + if body.password is not None: + user.password_hash = hash_password(body.password) + if body.is_admin is not None: + user.is_admin = body.is_admin + if body.is_active is not None: + user.is_active = body.is_active + if body.settings is not None: + user.settings = {**(user.settings or {}), **body.settings} + + user.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(user) + return _build_user_out(user).model_dump(by_alias=True) + + +@router.delete("/{user_id}") +async def delete_user( + user_id: str, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + if user.is_admin: + # Sicherstellen dass mindestens ein Admin übrig bleibt + admin_count_result = await db.execute( + select(User).where(User.is_admin == True) + ) + admins = admin_count_result.scalars().all() + if len(admins) <= 1: + raise HTTPException(status_code=400, detail="Cannot delete the last admin") + await db.delete(user) + await db.commit() + return {"success": True} + + +@router.get("/{user_id}/listening-sessions") +async def get_listening_sessions( + user_id: str, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(PlaybackSession) + .where(PlaybackSession.user_id == user_id) + .order_by(PlaybackSession.updated_at.desc()) + .limit(100) + ) + sessions = result.scalars().all() + return { + "sessions": [ + { + "id": s.id, + "userId": s.user_id, + "libraryItemId": s.library_item_id, + "episodeId": s.episode_id, + "mediaType": s.media_type, + "currentTime": s.current_time, + "duration": s.duration, + "startedAt": int(s.started_at.timestamp() * 1000) if s.started_at else 0, + "updatedAt": int(s.updated_at.timestamp() * 1000) if s.updated_at else 0, + } + for s in sessions + ] + } + + +@router.get("/{user_id}/listening-stats") +async def get_listening_stats( + user_id: str, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(PlaybackSession).where(PlaybackSession.user_id == user_id) + ) + sessions = result.scalars().all() + total_time = sum(s.duration for s in sessions if s.duration) + return { + "totalTime": total_time, + "numSessions": len(sessions), + "days": {}, + } diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..47b17df --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel +from .user import UserOut, ServerSettingsOut + + +class LoginRequest(BaseModel): + username: str + password: str + + +class LoginResponse(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + user: UserOut + user_default_library_id: str | None = None + server_settings: ServerSettingsOut + source: str = "local" + + +class AuthorizeResponse(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + user: UserOut + libraries: list + user_default_library_id: str | None = None + server_settings: ServerSettingsOut diff --git a/backend/app/schemas/library.py b/backend/app/schemas/library.py new file mode 100644 index 0000000..2bdb0ab --- /dev/null +++ b/backend/app/schemas/library.py @@ -0,0 +1,67 @@ +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel +from datetime import datetime + + +class LibraryFolder(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + id: str + full_path: str + library_id: str = "" + added_at: int = 0 + + +class LibrarySettings(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + cover_aspect_ratio: int = 1 + disable_watcher: bool = False + skip_matching_media_with_asin: bool = False + skip_matching_media_with_isbn: bool = False + auto_scan_cron_expression: str = "" + audio_files_global_include: list[str] = [] + audio_files_global_exclude: list[str] = [] + metadata_precision: int = 10 + hide_single_book_series: bool = False + only_show_later_books_in_continue_series: bool = False + metadata_providers: list[str] = ["google", "audible"] + prefer_matched_metadata: bool = False + disable_embed_covers: bool = False + best_books_matching: bool = False + + +class LibraryOut(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + id: str + name: str + folders: list[LibraryFolder] = [] + display_order: int = 1 + icon: str = "database" + media_type: str = "book" + provider: str = "google" + settings: LibrarySettings = LibrarySettings() + created_at: int = 0 # ABS nutzt Unix-Timestamps in ms + last_update: int = 0 + + +class LibraryCreate(BaseModel): + name: str + folders: list[dict] + media_type: str = "book" + icon: str = "database" + provider: str = "google" + + +class LibraryUpdate(BaseModel): + name: str | None = None + folders: list[dict] | None = None + settings: dict | None = None + + +class LibraryItemsResponse(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + results: list + total: int + limit: int + page: int diff --git a/backend/app/schemas/media_item.py b/backend/app/schemas/media_item.py new file mode 100644 index 0000000..67e4f6f --- /dev/null +++ b/backend/app/schemas/media_item.py @@ -0,0 +1,115 @@ +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel +from datetime import datetime +from typing import Any + + +class AudioFileMetadata(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + filename: str + ext: str + path: str + rel_path: str = "" + size: int = 0 + + +class AudioFileOut(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + index: int + ino: str = "" + metadata: AudioFileMetadata + added_at: int = 0 + updated_at: int = 0 + track_num_from_meta: int | None = None + disc_num_from_meta: int | None = None + format: str = "" + duration: float = 0.0 + bitrate: int = 0 + language: str | None = None + codec: str = "" + time_base: str = "1/44100" + channels: int = 2 + channel_layout: str = "stereo" + chapters: list = [] + embedded_cover_art: str | None = None + mime_type: str = "audio/mpeg" + + +class ChapterOut(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + id: int + start: float + end: float + title: str + + +class BookMetadata(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + title: str | None = None + subtitle: str | None = None + authors: list[dict] = [] # [{"id": null, "name": "..."}] + narrators: list[str] = [] + series: list[dict] = [] # [{"id": null, "name": "...", "sequence": "..."}] + genres: list[str] = [] + published_year: str | None = None + published_date: str | None = None + publisher: str | None = None + description: str | None = None + isbn: str | None = None + asin: str | None = None + language: str | None = None + explicit: bool = False + abridged: bool = False + + +class BookOut(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + library_item_id: str + metadata: BookMetadata + cover_path: str | None = None + tags: list[str] = [] + audio_files: list[AudioFileOut] = [] + chapters: list[ChapterOut] = [] + missing_parts: list = [] + ebookFile: Any = None + duration: float = 0.0 + size: int = 0 + tracks: list[AudioFileOut] = [] + num_tracks: int = 0 + num_audio_files: int = 0 + num_chapters: int = 0 + num_missing_parts: int = 0 + num_invalid_audio_files: int = 0 + is_missing: bool = False + is_invalid: bool = False + + +class LibraryItemOut(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + id: str + ino: str = "" + library_id: str + folder_id: str = "" + path: str + rel_path: str = "" + is_file: bool = False + mtime_ms: int = 0 + ctime_ms: int = 0 + birth_time_ms: int = 0 + added_at: int = 0 + updated_at: int = 0 + last_scan: int | None = None + scan_version: str | None = None + is_missing: bool = False + is_invalid: bool = False + media_type: str = "book" + media: BookOut | None = None + num_files: int = 0 + size: int = 0 + + +class LibraryItemUpdateRequest(BaseModel): + metadata: dict | None = None + tags: list[str] | None = None + chapters: list[dict] | None = None + cover_path: str | None = None diff --git a/backend/app/schemas/podcast.py b/backend/app/schemas/podcast.py new file mode 100644 index 0000000..d1bfa7b --- /dev/null +++ b/backend/app/schemas/podcast.py @@ -0,0 +1,55 @@ +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel +from datetime import datetime + + +class PodcastEpisodeOut(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + id: str + index: int = 0 + season: str | None = None + episode: str | None = None + episode_type: str = "full" + title: str | None = None + subtitle: str | None = None + description: str | None = None + enclosure: dict | None = None + pub_date: str | None = None + audio_file: dict | None = None + published_at: int | None = None + added_at: int = 0 + updated_at: int = 0 + duration: float = 0.0 + size: int = 0 + + +class PodcastMetadata(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + title: str | None = None + author: str | None = None + description: str | None = None + release_date: str | None = None + genres: list[str] = [] + feed_url: str | None = None + image_url: str | None = None + itunes_page_url: str | None = None + itunes_id: int | None = None + itunes_artist_id: int | None = None + explicit: bool = False + language: str | None = None + type: str = "episodic" + + +class PodcastOut(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + library_item_id: str + metadata: PodcastMetadata + cover_path: str | None = None + tags: list[str] = [] + episodes: list[PodcastEpisodeOut] = [] + auto_download_episodes: bool = False + auto_download_schedule: str = "0 * * * *" + last_episode_check: int | None = None + max_episodes_to_keep: int = 0 + max_new_episodes_to_download: int = 3 + num_episodes: int = 0 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..285e4b5 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,72 @@ +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel +from datetime import datetime +from typing import Any + + +class ServerSettingsOut(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + id: str = "server-settings" + token_secret: str = "" + items_per_page: int = 10 + metadata_provider: str = "google" + store_covers_with_item: bool = False + ratio_cover_book_name: bool = False + cover_aspect_ratio: int = 1 + disable_opds: bool = False + log_level: int = 2 + scanner_parse_same_author_name: bool = False + auth_active_users: list = [] + auth_local_users: list = [] + + +class UserSettings(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + muted_badge_notifications: bool = False + show_remaining_time: bool = False + display_remaining_time: bool = False + library_filters: dict = {} + playback_rate: float = 1.0 + bookmarks_list_collapsed: bool = False + sleep_timer_duration: int = 900 + sleep_timer_podcast_chapters: bool = False + language: str = "de" + + +class UserOut(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + id: str + username: str + email: str | None = None + type: str = "user" + is_active: bool = True + is_locked: bool = False + last_seen: datetime | None = None + created_at: datetime + token: str | None = None + media_progress: list = [] + bookmarks: list = [] + is_admin: bool = False + libraries_accessible: list[str] = [] + item_tags_accessible: list[str] = [] + permissions: dict = {} + series_hide_from_continue_listening: list = [] + settings: UserSettings = UserSettings() + + +class UserCreate(BaseModel): + username: str + password: str + email: str | None = None + is_admin: bool = False + + +class UserUpdate(BaseModel): + email: str | None = None + password: str | None = None + is_admin: bool | None = None + is_active: bool | None = None + settings: dict | None = None diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..2cd1ec4 --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,31 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from ..config import get_settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_token(user_id: str) -> str: + settings = get_settings() + expire = datetime.utcnow() + timedelta(days=settings.jwt_expire_days) + payload = {"sub": user_id, "exp": expire, "iat": datetime.utcnow()} + return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +def decode_token(token: str) -> Optional[str]: + settings = get_settings() + try: + payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) + return payload.get("sub") + except JWTError: + return None diff --git a/backend/app/services/file_watcher.py b/backend/app/services/file_watcher.py new file mode 100644 index 0000000..48ecf4d --- /dev/null +++ b/backend/app/services/file_watcher.py @@ -0,0 +1,94 @@ +import asyncio +import logging +import uuid +from pathlib import Path +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileMovedEvent +from ..database import AsyncSessionLocal +from ..models.library import Library +from ..models.session import ScanJob +from sqlalchemy import select + +logger = logging.getLogger(__name__) + +AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".m4b", ".opus"} + +_observer: Observer | None = None +_scan_debounce: dict[str, asyncio.TimerHandle] = {} + + +class AudioFileHandler(FileSystemEventHandler): + def __init__(self, library_id: str, loop: asyncio.AbstractEventLoop): + self.library_id = library_id + self.loop = loop + + def _schedule_scan(self): + key = self.library_id + if key in _scan_debounce: + _scan_debounce[key].cancel() + handle = self.loop.call_later( + 30.0, # 30s Debounce — nicht bei jeder Datei sofort scannen + lambda: asyncio.run_coroutine_threadsafe( + _trigger_scan(self.library_id), self.loop + ), + ) + _scan_debounce[key] = handle + + def on_created(self, event): + if not event.is_directory: + ext = Path(event.src_path).suffix.lower() + if ext in AUDIO_EXTENSIONS: + logger.info(f"Neue Audiodatei erkannt: {event.src_path}") + self._schedule_scan() + + def on_moved(self, event): + if not event.is_directory: + ext = Path(event.dest_path).suffix.lower() + if ext in AUDIO_EXTENSIONS: + logger.info(f"Audiodatei verschoben: {event.dest_path}") + self._schedule_scan() + + +async def _trigger_scan(library_id: str): + from ..services.scanner import scan_library_task + async with AsyncSessionLocal() as db: + job = ScanJob( + id=str(uuid.uuid4()), + library_id=library_id, + status="queued", + ) + db.add(job) + await db.commit() + await db.refresh(job) + asyncio.create_task(scan_library_task(library_id, job.id)) + + +async def start_file_watcher(): + global _observer + loop = asyncio.get_event_loop() + + async with AsyncSessionLocal() as db: + result = await db.execute(select(Library)) + libraries = result.scalars().all() + + observer = Observer() + for lib in libraries: + for folder_info in (lib.folders or []): + folder_path = folder_info.get("fullPath", folder_info.get("full_path", "")) + if folder_path and Path(folder_path).exists(): + handler = AudioFileHandler(lib.id, loop) + observer.schedule(handler, folder_path, recursive=True) + logger.info(f"Watching: {folder_path} (Library: {lib.name})") + + observer.start() + _observer = observer + logger.info("File Watcher gestartet.") + + +def stop_file_watcher(): + global _observer + if _observer: + _observer.stop() + _observer.join() + _observer = None + logger.info("File Watcher gestoppt.") diff --git a/backend/app/services/hls.py b/backend/app/services/hls.py new file mode 100644 index 0000000..d9e35db --- /dev/null +++ b/backend/app/services/hls.py @@ -0,0 +1,108 @@ +import os +import asyncio +import uuid +import shutil +from pathlib import Path +from typing import Optional +from ..config import get_settings + + +HLS_SEGMENT_DURATION = 10 # Sekunden pro Segment + + +async def create_hls_session( + session_id: str, + audio_files: list[str], + start_time: float = 0.0, +) -> str: + """ + Erstellt HLS-Segmente via FFmpeg für die gegebenen Audio-Dateien. + Gibt den Pfad zum HLS-Verzeichnis zurück. + """ + settings = get_settings() + session_dir = os.path.join(settings.hls_cache_dir, session_id) + os.makedirs(session_dir, exist_ok=True) + + playlist_path = os.path.join(session_dir, "output.m3u8") + + if len(audio_files) == 1: + input_path = audio_files[0] + else: + # Mehrere Dateien: Concat-Liste erstellen + concat_file = os.path.join(session_dir, "concat.txt") + with open(concat_file, "w", encoding="utf-8") as f: + for af in audio_files: + safe_path = af.replace("\\", "/") + f.write(f"file '{safe_path}'\n") + input_path = concat_file + + if len(audio_files) == 1: + cmd = [ + "ffmpeg", "-y", + "-ss", str(start_time), + "-i", input_path, + "-c:a", "aac", + "-b:a", "192k", + "-ac", "2", + "-hls_time", str(HLS_SEGMENT_DURATION), + "-hls_list_size", "0", + "-hls_segment_filename", os.path.join(session_dir, "seg%05d.ts"), + "-hls_flags", "independent_segments", + playlist_path, + ] + else: + cmd = [ + "ffmpeg", "-y", + "-f", "concat", "-safe", "0", + "-i", input_path, + "-ss", str(start_time), + "-c:a", "aac", + "-b:a", "192k", + "-ac", "2", + "-hls_time", str(HLS_SEGMENT_DURATION), + "-hls_list_size", "0", + "-hls_segment_filename", os.path.join(session_dir, "seg%05d.ts"), + "-hls_flags", "independent_segments", + playlist_path, + ] + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + + if proc.returncode != 0: + error_msg = stderr.decode(errors="replace") if stderr else "unknown error" + raise RuntimeError(f"FFmpeg fehler: {error_msg}") + + return session_dir + + +def cleanup_hls_session(session_id: str): + settings = get_settings() + session_dir = os.path.join(settings.hls_cache_dir, session_id) + if os.path.exists(session_dir): + shutil.rmtree(session_dir, ignore_errors=True) + + +def get_hls_session_path(session_id: str) -> Optional[str]: + settings = get_settings() + session_dir = os.path.join(settings.hls_cache_dir, session_id) + playlist = os.path.join(session_dir, "output.m3u8") + return session_dir if os.path.exists(playlist) else None + + +def parse_m3u8_duration(playlist_path: str) -> float: + """Berechnet Gesamtdauer aus M3U8-Playlist.""" + total = 0.0 + try: + with open(playlist_path, "r") as f: + for line in f: + if line.startswith("#EXTINF:"): + duration_str = line.split(":")[1].split(",")[0] + total += float(duration_str) + except Exception: + pass + return total diff --git a/backend/app/services/matching/__init__.py b/backend/app/services/matching/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/matching/base.py b/backend/app/services/matching/base.py new file mode 100644 index 0000000..ca98985 --- /dev/null +++ b/backend/app/services/matching/base.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class MatchResult: + source: str # musicbrainz / open_library / google_books + source_id: str + title: str + author: str | None = None + narrator: str | None = None + description: str | None = None + cover_url: str | None = None + publisher: str | None = None + publish_year: int | None = None + series: str | None = None + series_sequence: str | None = None + language: str | None = None + genres: list[str] = field(default_factory=list) + chapters: list[dict] = field(default_factory=list) + confidence: float = 0.0 + + +class BaseMatcherError(Exception): + pass diff --git a/backend/app/services/matching/google_books.py b/backend/app/services/matching/google_books.py new file mode 100644 index 0000000..7ce24e6 --- /dev/null +++ b/backend/app/services/matching/google_books.py @@ -0,0 +1,35 @@ +"""Google Books-Matching — Phase 5.""" +import httpx +from .base import MatchResult + +GB_BASE = "https://www.googleapis.com/books/v1" + + +async def search_google_books(title: str, author: str | None = None) -> list[MatchResult]: + q = f'intitle:"{title}"' + if author: + q += f' inauthor:"{author}"' + + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(f"{GB_BASE}/volumes", params={"q": q, "maxResults": 5, "langRestrict": "de"}) + resp.raise_for_status() + data = resp.json() + + results = [] + for item in data.get("items", []): + vol = item.get("volumeInfo", {}) + authors = vol.get("authors", []) + results.append( + MatchResult( + source="google_books", + source_id=item.get("id", ""), + title=vol.get("title", title), + author=authors[0] if authors else None, + description=vol.get("description"), + publisher=vol.get("publisher"), + publish_year=int(vol.get("publishedDate", "0")[:4]) if vol.get("publishedDate") else None, + language=vol.get("language"), + confidence=0.5, + ) + ) + return results diff --git a/backend/app/services/matching/musicbrainz.py b/backend/app/services/matching/musicbrainz.py new file mode 100644 index 0000000..139891b --- /dev/null +++ b/backend/app/services/matching/musicbrainz.py @@ -0,0 +1,40 @@ +"""MusicBrainz-Matching — Phase 5.""" +import httpx +from .base import MatchResult + +MB_BASE = "https://musicbrainz.org/ws/2" +HEADERS = {"User-Agent": "audiolib/1.0 (https://github.com/audiolib)"} + + +async def search_musicbrainz(title: str, artist: str | None = None) -> list[MatchResult]: + query = f'release:"{title}"' + if artist: + query += f' AND artist:"{artist}"' + query += " AND format:Digital" + + async with httpx.AsyncClient(headers=HEADERS, timeout=10) as client: + resp = await client.get( + f"{MB_BASE}/release", + params={"query": query, "fmt": "json", "limit": 5}, + ) + resp.raise_for_status() + data = resp.json() + + results = [] + for release in data.get("releases", []): + confidence = release.get("score", 0) / 100.0 + artist_name = None + credits = release.get("artist-credit", []) + if credits: + artist_name = credits[0].get("name") or credits[0].get("artist", {}).get("name") + + results.append( + MatchResult( + source="musicbrainz", + source_id=release.get("id", ""), + title=release.get("title", title), + author=artist_name, + confidence=confidence, + ) + ) + return results diff --git a/backend/app/services/matching/open_library.py b/backend/app/services/matching/open_library.py new file mode 100644 index 0000000..a9d2d20 --- /dev/null +++ b/backend/app/services/matching/open_library.py @@ -0,0 +1,30 @@ +"""OpenLibrary-Matching — Phase 5.""" +import httpx +from .base import MatchResult + +OL_BASE = "https://openlibrary.org" + + +async def search_open_library(title: str, author: str | None = None) -> list[MatchResult]: + params: dict = {"title": title, "limit": 5} + if author: + params["author"] = author + + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(f"{OL_BASE}/search.json", params=params) + resp.raise_for_status() + data = resp.json() + + results = [] + for doc in data.get("docs", []): + results.append( + MatchResult( + source="open_library", + source_id=doc.get("key", ""), + title=doc.get("title", title), + author=doc.get("author_name", [None])[0] if doc.get("author_name") else None, + publish_year=doc.get("first_publish_year"), + confidence=0.6, + ) + ) + return results diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py new file mode 100644 index 0000000..9f2313b --- /dev/null +++ b/backend/app/services/scanner.py @@ -0,0 +1,199 @@ +import os +import uuid +import logging +from datetime import datetime +from pathlib import Path +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from ..database import AsyncSessionLocal +from ..models.library import Library +from ..models.media_item import LibraryItem, BookFile, Chapter +from ..models.session import ScanJob + +logger = logging.getLogger(__name__) + +AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".m4b", ".opus"} + + +def _get_audio_duration(file_path: str) -> float: + try: + from mutagen import File as MutagenFile + audio = MutagenFile(file_path) + if audio and audio.info: + return float(audio.info.length) + except Exception: + pass + return 0.0 + + +def _get_file_size(file_path: str) -> int: + try: + return os.path.getsize(file_path) + except Exception: + return 0 + + +def _guess_title_from_path(folder_path: str) -> str: + """Leitet Titel aus dem Ordnernamen ab.""" + return os.path.basename(folder_path) + + +def _discover_audiobook_folders(base_path: str) -> list[dict]: + """ + Findet alle Unterordner mit Audio-Dateien. + Jeder Ordner = ein Hörbuch (ABS-Prinzip). + """ + books = [] + base = Path(base_path) + + if not base.exists(): + logger.warning(f"Pfad nicht gefunden: {base_path}") + return books + + # Direkte Audio-Dateien im Root → ein "Root"-Buch + root_audio = [f for f in base.iterdir() if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS] + if root_audio: + books.append({ + "path": str(base), + "files": [str(f) for f in sorted(root_audio)], + }) + + # Unterordner durchsuchen + for entry in base.iterdir(): + if not entry.is_dir(): + continue + audio_files = [] + _collect_audio_files(entry, audio_files) + if audio_files: + books.append({ + "path": str(entry), + "files": sorted(audio_files), + }) + + return books + + +def _collect_audio_files(folder: Path, result: list): + """Rekursiv alle Audio-Dateien sammeln.""" + try: + for entry in sorted(folder.iterdir()): + if entry.is_file() and entry.suffix.lower() in AUDIO_EXTENSIONS: + result.append(str(entry)) + elif entry.is_dir(): + _collect_audio_files(entry, result) + except PermissionError: + pass + + +async def scan_library_task(library_id: str, job_id: str): + """Hintergrund-Task: Scannt eine Library und befüllt die DB.""" + async with AsyncSessionLocal() as db: + try: + # Job auf "running" setzen + job_result = await db.execute(select(ScanJob).where(ScanJob.id == job_id)) + job = job_result.scalar_one_or_none() + if job: + job.status = "running" + job.started_at = datetime.utcnow() + await db.commit() + + lib_result = await db.execute(select(Library).where(Library.id == library_id)) + lib = lib_result.scalar_one_or_none() + if not lib: + return + + folders = lib.folders or [] + all_books = [] + for folder_info in folders: + folder_path = folder_info.get("fullPath", folder_info.get("full_path", "")) + if folder_path: + all_books.extend(_discover_audiobook_folders(folder_path)) + + items_found = 0 + for book_info in all_books: + folder_path = book_info["path"] + audio_files = book_info["files"] + + # Existiert schon? + existing = await db.execute( + select(LibraryItem).where( + LibraryItem.library_id == library_id, + LibraryItem.path == folder_path, + ) + ) + existing_item = existing.scalar_one_or_none() + + total_duration = sum(_get_audio_duration(f) for f in audio_files) + total_size = sum(_get_file_size(f) for f in audio_files) + + if existing_item: + existing_item.duration_seconds = total_duration + existing_item.size_bytes = total_size + existing_item.num_files = len(audio_files) + existing_item.is_missing = False + existing_item.updated_at = datetime.utcnow() + item = existing_item + else: + item_id = str(uuid.uuid4()) + title = _guess_title_from_path(folder_path) + item = LibraryItem( + id=item_id, + library_id=library_id, + media_type=lib.media_type, + path=folder_path, + ino=str(os.stat(folder_path).st_ino) if os.path.exists(folder_path) else "", + title=title, + duration_seconds=total_duration, + size_bytes=total_size, + num_files=len(audio_files), + tags=["zu_prüfen"], + ) + db.add(item) + await db.flush() + + # BookFiles anlegen + for idx, file_path in enumerate(audio_files): + bf = BookFile( + library_item_id=item.id, + filename=os.path.basename(file_path), + path=file_path, + format=Path(file_path).suffix.lstrip(".").lower(), + size_bytes=_get_file_size(file_path), + duration_seconds=_get_audio_duration(file_path), + track_index=idx, + ) + db.add(bf) + + items_found += 1 + + await db.commit() + + # Fehlende Items markieren + all_items_result = await db.execute( + select(LibraryItem).where(LibraryItem.library_id == library_id) + ) + all_items = all_items_result.scalars().all() + found_paths = {b["path"] for b in all_books} + for item in all_items: + item.is_missing = item.path not in found_paths + await db.commit() + + if job: + job.status = "done" + job.items_found = items_found + job.finished_at = datetime.utcnow() + job.progress = 1.0 + await db.commit() + + logger.info(f"Scan abgeschlossen: {items_found} Items in Library {library_id}") + + except Exception as e: + logger.error(f"Scan-Fehler für Library {library_id}: {e}", exc_info=True) + async with AsyncSessionLocal() as err_db: + job_result = await err_db.execute(select(ScanJob).where(ScanJob.id == job_id)) + job = job_result.scalar_one_or_none() + if job: + job.status = "error" + job.log = str(e) + job.finished_at = datetime.utcnow() + await err_db.commit() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..be351d4 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,15 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +sqlalchemy[asyncio]==2.0.35 +aiosqlite==0.20.0 +pydantic==2.9.2 +pydantic-settings==2.5.2 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.12 +httpx==0.27.2 +watchdog==5.0.3 +mutagen==1.47.0 +aiofiles==24.1.0 +pillow==10.4.0 +feedparser==6.0.11 diff --git a/data/db/.gitkeep b/data/db/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c3859fc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + backend: + build: ./backend + container_name: audiolib-backend + restart: unless-stopped + env_file: .env + volumes: + - ./data:/app/data + - ${AUDIOFILES_PATH:-./audiofiles}:/audiofiles:ro + expose: + - "8000" + depends_on: [] + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + + frontend: + build: ./frontend + container_name: audiolib-frontend + restart: unless-stopped + expose: + - "80" + + nginx: + image: nginx:1.25-alpine + container_name: audiolib-nginx + restart: unless-stopped + ports: + - "${SERVER_PORT:-3000}:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - backend + - frontend diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..0b5132d --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS builder + +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:1.25-alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..605c17e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Audiolib + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..f749e02 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,9 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e05433c --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "audiolib-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", + "zustand": "^5.0.0", + "axios": "^1.7.7" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3", + "vite": "^5.4.9" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..40d13cb --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,17 @@ +import React from 'react' + +export default function App() { + return ( +
+
+

Audiolib

+

Web-Interface wird in Phase 8 implementiert.

+

+ Die Swift-App kann bereits über{' '} + localhost:3000{' '} + verbunden werden. +

+
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..6631cba --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,8 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-background text-white; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..4a1b150 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..b251213 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,15 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{ts,tsx}'], + darkMode: 'class', + theme: { + extend: { + colors: { + primary: '#1db954', + surface: '#1e1e2e', + background: '#13131f', + }, + }, + }, + plugins: [], +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..42e0521 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..6c8cb13 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:8000', + '/hls': 'http://localhost:8000', + '/login': 'http://localhost:8000', + '/logout': 'http://localhost:8000', + '/ping': 'http://localhost:8000', + }, + }, +}) diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..6d8671d --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,51 @@ +worker_processes auto; +error_log /var/log/nginx/error.log warn; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + client_max_body_size 100M; + + upstream backend { + server backend:8000; + } + + upstream frontend { + server frontend:80; + } + + server { + listen 80; + + # API, HLS-Streams, Auth + location ~ ^/(api|hls|login|logout|ping)(/.*)?$ { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300s; + # HLS streaming: disable buffering + proxy_buffering off; + } + + # Cover images / static media served by backend + location /covers/ { + proxy_pass http://backend; + proxy_set_header Host $host; + expires 7d; + add_header Cache-Control "public"; + } + + # Frontend (React SPA) + location / { + proxy_pass http://frontend; + proxy_set_header Host $host; + } + } +} diff --git a/setup.sh b/setup.sh new file mode 100644 index 0000000..7a220e9 --- /dev/null +++ b/setup.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ───────────────────────────────────────────────────────────────────────────── +# Audiolib – Setup-Skript +# Läuft auf Debian/Ubuntu-basierten Systemen (inkl. Proxmox LXC, Raspberry Pi) +# ───────────────────────────────────────────────────────────────────────────── + +BOLD="\033[1m" +GREEN="\033[32m" +YELLOW="\033[33m" +RED="\033[31m" +RESET="\033[0m" + +info() { echo -e "${GREEN}[OK]${RESET} $*"; } +warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } +error() { echo -e "${RED}[FEHLER]${RESET} $*" >&2; exit 1; } +header() { echo -e "\n${BOLD}$*${RESET}"; } + +# ── Root-Check ──────────────────────────────────────────────────────────────── +if [[ $EUID -ne 0 ]]; then + error "Bitte als root ausführen: sudo bash setup.sh" +fi + +header "━━━ Audiolib Setup ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# ── Docker installieren ─────────────────────────────────────────────────────── +header "1/4 · Docker prüfen / installieren" + +if command -v docker &>/dev/null; then + DOCKER_VERSION=$(docker --version | awk '{print $3}' | tr -d ',') + info "Docker bereits installiert: $DOCKER_VERSION" +else + warn "Docker nicht gefunden – installiere Docker..." + apt-get update -qq + apt-get install -y -qq ca-certificates curl gnupg lsb-release + + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/debian/gpg \ + | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + + DISTRO=$(lsb_release -si | tr '[:upper:]' '[:lower:]') + # Ubuntu-Basis (z.B. Mint) als ubuntu behandeln + [[ "$DISTRO" == "ubuntu" || "$DISTRO" == "linuxmint" ]] && DISTRO="ubuntu" || DISTRO="debian" + + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ + https://download.docker.com/linux/${DISTRO} \ + $(lsb_release -cs) stable" \ + > /etc/apt/sources.list.d/docker.list + + apt-get update -qq + apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin + systemctl enable --now docker + info "Docker installiert: $(docker --version)" +fi + +# ── Docker Compose prüfen ───────────────────────────────────────────────────── +if docker compose version &>/dev/null; then + info "Docker Compose Plugin verfügbar: $(docker compose version --short)" +elif command -v docker-compose &>/dev/null; then + warn "Altes docker-compose gefunden. Empfehle: apt install docker-compose-plugin" + # Alias damit der Rest des Skripts 'docker compose' nutzen kann + shopt -s expand_aliases + alias docker\ compose='docker-compose' +else + error "Docker Compose nicht gefunden. Bitte manuell installieren." +fi + +# ── .env einrichten ─────────────────────────────────────────────────────────── +header "2/4 · Konfiguration (.env)" + +if [[ -f .env ]]; then + warn ".env existiert bereits – wird nicht überschrieben." + warn "Bitte manuell prüfen: nano .env" +else + cp .env.example .env + + # Zufälligen JWT_SECRET generieren + JWT_SECRET=$(openssl rand -hex 32 2>/dev/null || cat /dev/urandom | tr -dc 'a-f0-9' | head -c 64) + sed -i "s|JWT_SECRET=change_me_in_production_use_a_long_random_string|JWT_SECRET=${JWT_SECRET}|" .env + + info ".env angelegt mit zufälligem JWT_SECRET." + echo "" + echo -e " ${YELLOW}WICHTIG: Bitte jetzt .env öffnen und anpassen:${RESET}" + echo -e " ${BOLD}nano .env${RESET}" + echo "" + echo " Mindestens setzen:" + echo " ADMIN_PASSWORD=" + echo " AUDIOFILES_PATH=" + echo "" +fi + +# ── Verzeichnisse anlegen ───────────────────────────────────────────────────── +header "3/4 · Verzeichnisse anlegen" + +mkdir -p data/db data/covers data/hls_cache data/logs +info "data/-Verzeichnisse erstellt." + +# audiofiles-Verzeichnis nur anlegen wenn AUDIOFILES_PATH auf ./audiofiles zeigt +if grep -q "^AUDIOFILES_PATH=\./audiofiles" .env 2>/dev/null; then + mkdir -p audiofiles + warn "audiofiles/ angelegt. Audiodateien dort ablegen oder AUDIOFILES_PATH in .env anpassen." +fi + +# ── Berechtigungen ──────────────────────────────────────────────────────────── +# Docker läuft normalerweise als root im Container, data-Ordner für alle beschreibbar +chmod -R 777 data/ 2>/dev/null || true + +# ── Fertig ──────────────────────────────────────────────────────────────────── +header "4/4 · Bereit zum Starten" + +echo "" +echo -e "${BOLD}Setup abgeschlossen.${RESET} Nächste Schritte:" +echo "" +echo -e " 1. ${YELLOW}nano .env${RESET} ← ADMIN_PASSWORD und AUDIOFILES_PATH setzen" +echo "" +echo -e " 2. ${BOLD}docker compose up -d --build${RESET}" +echo "" +echo -e " 3. Audiobookshelf-App öffnen → Server-URL:" +echo -e " ${BOLD}http://$(hostname -I | awk '{print $1}'):3000${RESET}" +echo "" +echo -e " Logs anzeigen: ${BOLD}docker compose logs -f backend${RESET}" +echo -e " Stoppen: ${BOLD}docker compose down${RESET}" +echo ""