Initial commit: Phase 1 – Projektstruktur, DB-Schema, Core-API

- FastAPI-Backend mit vollständiger ABS v2.x API-Kompatibilität
- SQLAlchemy-Models: User, Library, LibraryItem, BookFile, Chapter,
  Podcast, PodcastEpisode, MediaProgress, Bookmark, PlaybackSession
- Auth: JWT-Login (/login, /logout, /api/authorize)
- Library + Items Endpoints inkl. camelCase ABS-Response-Format
- HLS-Streaming via FFmpeg (POST /api/items/:id/play, Session-Sync)
- Me/Progress Endpoints + Lesezeichen
- User-Management + Server-Settings (Admin)
- Library-Scanner (MP3/WAV Discovery, Hintergrund-Task)
- File Watcher (watchdog, 30s Debounce)
- Matching-Skelett (MusicBrainz, OpenLibrary, Google Books – Phase 5)
- Docker-Setup: backend (Python 3.12+FFmpeg), frontend (React/Vite),
  nginx Reverse-Proxy auf Port 3000
- setup.sh: Installiert Docker auf Debian/Ubuntu, richtet .env ein

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Audiolib
2026-05-26 11:43:35 +02:00
commit 14ffee3051
56 changed files with 3220 additions and 0 deletions

20
.env.example Normal file
View File

@@ -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

36
.gitignore vendored Normal file
View File

@@ -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

123
README.md Normal file
View File

@@ -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://<server-ip>:3000`
### 4. Erste Schritte
1. Öffne `http://<server-ip>:3000` im Browser
2. Melde dich mit den in `.env` gesetzten Admin-Daten an
3. In der Audiobookshelf-App: Server-URL `http://<server-ip>: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

17
backend/Dockerfile Normal file
View File

@@ -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"]

0
backend/app/__init__.py Normal file
View File

34
backend/app/config.py Normal file
View File

@@ -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()

26
backend/app/database.py Normal file
View File

@@ -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)

View File

@@ -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

115
backend/app/main.py Normal file
View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -0,0 +1 @@
from . import auth, libraries, items, stream, me, users, settings

102
backend/app/routers/auth.py Normal file
View File

@@ -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"}

View File

@@ -0,0 +1,272 @@
import os
import zipfile
import tempfile
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from ..dependencies import get_db, get_current_user, require_admin
from ..models.user import User
from ..models.media_item import LibraryItem, BookFile, Chapter
from ..schemas.media_item import (
LibraryItemOut, BookOut, BookMetadata, AudioFileOut, AudioFileMetadata,
ChapterOut, LibraryItemUpdateRequest
)
from ..config import get_settings
router = APIRouter(prefix="/api/items", tags=["items"])
def _item_to_out(item: LibraryItem) -> dict:
settings = get_settings()
# Audio-Dateien aus BookFile-Relationen werden nachgeladen — hier bauen wir
# eine kompakte Darstellung aus den gespeicherten Item-Daten
metadata = BookMetadata(
title=item.title,
subtitle=item.subtitle,
authors=[{"id": None, "name": item.author}] if item.author else [],
narrators=[item.narrator] if item.narrator else [],
series=[{"id": None, "name": item.series, "sequence": item.series_sequence}]
if item.series else [],
genres=item.genres or [],
published_year=str(item.publish_year) if item.publish_year else None,
publisher=item.publisher,
description=item.description,
language=item.language,
explicit=item.explicit or False,
abridged=item.abridged or False,
)
cover_url = None
if item.cover_path and os.path.exists(item.cover_path):
cover_url = f"/api/items/{item.id}/cover"
media = BookOut(
library_item_id=item.id,
metadata=metadata,
cover_path=cover_url,
tags=item.tags or [],
duration=item.duration_seconds or 0.0,
size=item.size_bytes or 0,
is_missing=item.is_missing or False,
is_invalid=item.is_invalid or False,
)
out = LibraryItemOut(
id=item.id,
ino=item.ino or "",
library_id=item.library_id,
path=item.path,
rel_path=os.path.basename(item.path),
is_missing=item.is_missing or False,
is_invalid=item.is_invalid or False,
media_type=item.media_type,
media=media,
num_files=item.num_files or 0,
size=item.size_bytes or 0,
added_at=int(item.added_at.timestamp() * 1000) if item.added_at else 0,
updated_at=int(item.updated_at.timestamp() * 1000) if item.updated_at else 0,
)
return out.model_dump(by_alias=True)
async def _enrich_item_with_files(item: LibraryItem, db: AsyncSession) -> dict:
"""Vollständige Darstellung inkl. Audio-Dateien und Kapitel."""
out = _item_to_out(item)
files_result = await db.execute(
select(BookFile).where(BookFile.library_item_id == item.id).order_by(BookFile.track_index)
)
files = files_result.scalars().all()
chapters_result = await db.execute(
select(Chapter).where(Chapter.library_item_id == item.id).order_by(Chapter.chapter_index)
)
chapters = chapters_result.scalars().all()
audio_files = []
for f in files:
af = AudioFileOut(
index=f.track_index,
ino=f.id,
metadata=AudioFileMetadata(
filename=f.filename,
ext=os.path.splitext(f.filename)[1],
path=f.path,
rel_path=f.filename,
size=f.size_bytes,
),
format=f.format,
duration=f.duration_seconds,
mime_type="audio/mpeg" if f.format == "mp3" else "audio/wav",
added_at=int(item.added_at.timestamp() * 1000) if item.added_at else 0,
)
audio_files.append(af.model_dump(by_alias=True))
chaps = [
ChapterOut(id=c.chapter_index, start=c.start_seconds, end=c.end_seconds, title=c.title).model_dump(by_alias=True)
for c in chapters
]
out["media"]["audioFiles"] = audio_files
out["media"]["tracks"] = audio_files
out["media"]["chapters"] = chaps
out["media"]["numTracks"] = len(audio_files)
out["media"]["numAudioFiles"] = len(audio_files)
out["media"]["numChapters"] = len(chaps)
return out
@router.get("/{item_id}")
async def get_item(
item_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
return await _enrich_item_with_files(item, db)
@router.get("/{item_id}/cover")
async def get_cover(
item_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = result.scalar_one_or_none()
if not item or not item.cover_path:
raise HTTPException(status_code=404, detail="Cover not found")
if not os.path.exists(item.cover_path):
raise HTTPException(status_code=404, detail="Cover file missing")
return FileResponse(item.cover_path, media_type="image/jpeg")
@router.patch("/{item_id}")
async def update_item(
item_id: str,
body: LibraryItemUpdateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if body.tags is not None:
item.tags = body.tags
if body.metadata:
meta = body.metadata
if "title" in meta:
item.title = meta["title"]
if "subtitle" in meta:
item.subtitle = meta["subtitle"]
if "authors" in meta and meta["authors"]:
item.author = meta["authors"][0].get("name") if isinstance(meta["authors"][0], dict) else meta["authors"][0]
if "narrators" in meta and meta["narrators"]:
item.narrator = meta["narrators"][0]
if "series" in meta and meta["series"]:
s = meta["series"][0]
item.series = s.get("name") if isinstance(s, dict) else s
item.series_sequence = s.get("sequence") if isinstance(s, dict) else None
if "publisher" in meta:
item.publisher = meta["publisher"]
if "publishedYear" in meta:
item.publish_year = int(meta["publishedYear"]) if meta["publishedYear"] else None
if "description" in meta:
item.description = meta["description"]
if "language" in meta:
item.language = meta["language"]
if "genres" in meta:
item.genres = meta["genres"]
if "explicit" in meta:
item.explicit = meta["explicit"]
if "abridged" in meta:
item.abridged = meta["abridged"]
if body.chapters:
await db.execute(delete(Chapter).where(Chapter.library_item_id == item_id))
for idx, c in enumerate(body.chapters):
chapter = Chapter(
library_item_id=item_id,
chapter_index=idx,
title=c.get("title", f"Kapitel {idx + 1}"),
start_seconds=c.get("start", 0.0),
end_seconds=c.get("end", 0.0),
)
db.add(chapter)
item.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(item)
return await _enrich_item_with_files(item, db)
@router.delete("/{item_id}")
async def delete_item(
item_id: str,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
await db.delete(item)
await db.commit()
return {"success": True}
@router.post("/batch/delete")
async def batch_delete_items(
body: dict,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
item_ids = body.get("libraryItemIds", [])
for item_id in item_ids:
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = result.scalar_one_or_none()
if item:
await db.delete(item)
await db.commit()
return {"success": True}
@router.get("/{item_id}/download")
async def download_item(
item_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
files_result = await db.execute(
select(BookFile).where(BookFile.library_item_id == item_id)
)
files = files_result.scalars().all()
if not files:
raise HTTPException(status_code=404, detail="No audio files found")
tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
with zipfile.ZipFile(tmp.name, "w") as zf:
for f in files:
if os.path.exists(f.path):
zf.write(f.path, f.filename)
title = item.title or item_id
return FileResponse(
tmp.name,
media_type="application/zip",
filename=f"{title}.zip",
)

View File

@@ -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}

234
backend/app/routers/me.py Normal file
View File

@@ -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}

View File

@@ -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}

View File

@@ -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)

View File

@@ -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": {},
}

View File

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

View File

@@ -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

View File

@@ -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.")

108
backend/app/services/hls.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

15
backend/requirements.txt Normal file
View File

@@ -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

0
data/db/.gitkeep Normal file
View File

36
docker-compose.yml Normal file
View File

@@ -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

12
frontend/Dockerfile Normal file
View File

@@ -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

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Audiolib</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

9
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,9 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

27
frontend/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

17
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react'
export default function App() {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold text-primary mb-4">Audiolib</h1>
<p className="text-gray-400 text-lg">Web-Interface wird in Phase 8 implementiert.</p>
<p className="text-gray-500 mt-2 text-sm">
Die Swift-App kann bereits über{' '}
<code className="bg-surface px-2 py-1 rounded text-primary">localhost:3000</code>{' '}
verbunden werden.
</p>
</div>
</div>
)
}

8
frontend/src/index.css Normal file
View File

@@ -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;
}

10
frontend/src/main.tsx Normal file
View File

@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -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: [],
}

17
frontend/tsconfig.json Normal file
View File

@@ -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"]
}

15
frontend/vite.config.ts Normal file
View File

@@ -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',
},
},
})

51
nginx/nginx.conf Normal file
View File

@@ -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;
}
}
}

126
setup.sh Normal file
View File

@@ -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=<sicheres_passwort>"
echo " AUDIOFILES_PATH=<pfad_zu_deinen_audiodateien>"
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 ""