- 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>
95 lines
3.0 KiB
Python
95 lines
3.0 KiB
Python
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.")
|