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:
20
.env.example
Normal file
20
.env.example
Normal 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
36
.gitignore
vendored
Normal 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
123
README.md
Normal 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
17
backend/Dockerfile
Normal 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
0
backend/app/__init__.py
Normal file
34
backend/app/config.py
Normal file
34
backend/app/config.py
Normal 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
26
backend/app/database.py
Normal 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)
|
||||||
39
backend/app/dependencies.py
Normal file
39
backend/app/dependencies.py
Normal 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
115
backend/app/main.py
Normal 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)
|
||||||
12
backend/app/models/__init__.py
Normal file
12
backend/app/models/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
21
backend/app/models/library.py
Normal file
21
backend/app/models/library.py
Normal 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)
|
||||||
76
backend/app/models/media_item.py
Normal file
76
backend/app/models/media_item.py
Normal 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)
|
||||||
40
backend/app/models/podcast.py
Normal file
40
backend/app/models/podcast.py
Normal 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)
|
||||||
32
backend/app/models/progress.py
Normal file
32
backend/app/models/progress.py
Normal 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)
|
||||||
46
backend/app/models/session.py
Normal file
46
backend/app/models/session.py
Normal 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)
|
||||||
22
backend/app/models/user.py
Normal file
22
backend/app/models/user.py
Normal 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)
|
||||||
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import auth, libraries, items, stream, me, users, settings
|
||||||
102
backend/app/routers/auth.py
Normal file
102
backend/app/routers/auth.py
Normal 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"}
|
||||||
272
backend/app/routers/items.py
Normal file
272
backend/app/routers/items.py
Normal 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",
|
||||||
|
)
|
||||||
218
backend/app/routers/libraries.py
Normal file
218
backend/app/routers/libraries.py
Normal 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
234
backend/app/routers/me.py
Normal 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}
|
||||||
53
backend/app/routers/settings.py
Normal file
53
backend/app/routers/settings.py
Normal 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}
|
||||||
248
backend/app/routers/stream.py
Normal file
248
backend/app/routers/stream.py
Normal 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)
|
||||||
160
backend/app/routers/users.py
Normal file
160
backend/app/routers/users.py
Normal 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": {},
|
||||||
|
}
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
26
backend/app/schemas/auth.py
Normal file
26
backend/app/schemas/auth.py
Normal 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
|
||||||
67
backend/app/schemas/library.py
Normal file
67
backend/app/schemas/library.py
Normal 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
|
||||||
115
backend/app/schemas/media_item.py
Normal file
115
backend/app/schemas/media_item.py
Normal 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
|
||||||
55
backend/app/schemas/podcast.py
Normal file
55
backend/app/schemas/podcast.py
Normal 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
|
||||||
72
backend/app/schemas/user.py
Normal file
72
backend/app/schemas/user.py
Normal 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
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
31
backend/app/services/auth.py
Normal file
31
backend/app/services/auth.py
Normal 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
|
||||||
94
backend/app/services/file_watcher.py
Normal file
94
backend/app/services/file_watcher.py
Normal 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
108
backend/app/services/hls.py
Normal 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
|
||||||
0
backend/app/services/matching/__init__.py
Normal file
0
backend/app/services/matching/__init__.py
Normal file
25
backend/app/services/matching/base.py
Normal file
25
backend/app/services/matching/base.py
Normal 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
|
||||||
35
backend/app/services/matching/google_books.py
Normal file
35
backend/app/services/matching/google_books.py
Normal 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
|
||||||
40
backend/app/services/matching/musicbrainz.py
Normal file
40
backend/app/services/matching/musicbrainz.py
Normal 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
|
||||||
30
backend/app/services/matching/open_library.py
Normal file
30
backend/app/services/matching/open_library.py
Normal 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
|
||||||
199
backend/app/services/scanner.py
Normal file
199
backend/app/services/scanner.py
Normal 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
15
backend/requirements.txt
Normal 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
0
data/db/.gitkeep
Normal file
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal 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
12
frontend/Dockerfile
Normal 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
12
frontend/index.html
Normal 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
9
frontend/nginx.conf
Normal 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
27
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
17
frontend/src/App.tsx
Normal file
17
frontend/src/App.tsx
Normal 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
8
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>
|
||||||
|
)
|
||||||
15
frontend/tailwind.config.js
Normal file
15
frontend/tailwind.config.js
Normal 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
17
frontend/tsconfig.json
Normal 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
15
frontend/vite.config.ts
Normal 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
51
nginx/nginx.conf
Normal 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
126
setup.sh
Normal 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 ""
|
||||||
Reference in New Issue
Block a user