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