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:
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()
|
||||
Reference in New Issue
Block a user