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