diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 65ed8ba..07e4a0c 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,7 +1,7 @@ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, func from ..database import AsyncSessionLocal from ..dependencies import get_db, get_current_user from ..models.user import User @@ -42,7 +42,7 @@ def _build_user_out(user: User) -> UserOut: @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) + select(User).where(func.lower(User.username) == body.username.lower(), User.is_active == True) ) user = result.scalar_one_or_none() if not user or not verify_password(body.password, user.password_hash): diff --git a/backend/app/routers/libraries.py b/backend/app/routers/libraries.py index 0393174..e08b710 100644 --- a/backend/app/routers/libraries.py +++ b/backend/app/routers/libraries.py @@ -32,6 +32,7 @@ def _library_to_out(lib: Library) -> dict: media_type=lib.media_type, icon=settings_data.get("icon", "database"), provider=settings_data.get("provider", "google"), + match_sources=settings_data.get("match_sources", ["musicbrainz", "open_library", "google_books"]), 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, ) diff --git a/backend/app/schemas/library.py b/backend/app/schemas/library.py index cfcfaad..b0fdfaf 100644 --- a/backend/app/schemas/library.py +++ b/backend/app/schemas/library.py @@ -39,6 +39,7 @@ class LibraryOut(BaseModel): icon: str = "database" media_type: str = "book" provider: str = "google" + match_sources: list[str] = ["musicbrainz", "open_library", "google_books"] settings: LibrarySettings = LibrarySettings() created_at: int = 0 # ABS nutzt Unix-Timestamps in ms last_update: int = 0 diff --git a/backend/app/services/matcher.py b/backend/app/services/matcher.py index c701982..5f7a2cc 100644 --- a/backend/app/services/matcher.py +++ b/backend/app/services/matcher.py @@ -17,6 +17,7 @@ from sqlalchemy import select from ..config import get_settings from ..models.media_item import LibraryItem, BookFile, Chapter +from ..models.library import Library from ..models.session import ServerSetting from ..database import AsyncSessionLocal from .matching.base import MatchResult @@ -154,9 +155,17 @@ async def _apply_match(db: AsyncSession, item: LibraryItem, result: MatchResult, item.tags = [t for t in tags if t != "zu_prüfen"] +_SOURCE_FUNCS = { + "musicbrainz": (search_musicbrainz, get_release_details), + "open_library": (search_open_library, get_work_details), + "google_books": (search_google_books, None), +} + + async def match_audiobook(item_id: str): """ Haupt-Matching-Funktion. Wird nach dem Scan als Hintergrund-Task gestartet. + Quellen und Reihenfolge werden aus den Library-Settings gelesen. """ async with AsyncSessionLocal() as db: result_row = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id)) @@ -164,7 +173,7 @@ async def match_audiobook(item_id: str): if not item or item.match_locked: return - # Einstellung prüfen + # Globale Auto-Match Einstellung prüfen setting = await db.execute( select(ServerSetting).where(ServerSetting.key == "autoMatchBooks") ) @@ -172,10 +181,17 @@ async def match_audiobook(item_id: str): if s and s.value is False: return + # Matching-Quellen aus Library-Settings lesen + lib_row = await db.execute(select(Library).where(Library.id == item.library_id)) + lib = lib_row.scalar_one_or_none() + sources: list[str] = ( + (lib.settings or {}).get("match_sources", list(_SOURCE_FUNCS.keys())) + if lib else list(_SOURCE_FUNCS.keys()) + ) + title = item.title or "" author = item.author - # Serien-Erkennung verbessert den Suchbegriff series, episode = detect_series(title) search_title = title if series: @@ -185,65 +201,42 @@ async def match_audiobook(item_id: str): if not item.series_sequence and episode: item.series_sequence = episode - logger.info(f"Matche: '{title}' (Serie: {series}, Folge: {episode})") + logger.info(f"Matche: '{title}' | Quellen: {sources}") best: MatchResult | None = None best_score = 0.0 - # 1. MusicBrainz - try: - mb_results = await search_musicbrainz(search_title, author) - for r in mb_results: - score = _score_result(r, title, author) - if score > best_score: - best_score = score - best = r - except Exception as e: - logger.warning(f"MusicBrainz Fehler: {e}") - - # Wenn guter MB-Treffer → Details holen (Tracklist + Cover) - if best and best_score >= UNCERTAIN_THRESHOLD and best.source == "musicbrainz": + for source_name in sources: + if best_score >= UNCERTAIN_THRESHOLD: + break + funcs = _SOURCE_FUNCS.get(source_name) + if not funcs: + continue + search_func, details_func = funcs try: - details = await get_release_details(best.source_id) - if details: - details.confidence = best_score - best = details - except Exception as e: - logger.warning(f"MusicBrainz Details Fehler: {e}") - - # 2. OpenLibrary als Fallback - if best_score < UNCERTAIN_THRESHOLD: - try: - ol_results = await search_open_library(search_title, author) - for r in ol_results: + results = await search_func(search_title, author) + for r in results: score = _score_result(r, title, author) if score > best_score: best_score = score best = r - if best and best.source == "open_library" and best_score >= UNCERTAIN_THRESHOLD: - details = await get_work_details(best.source_id) - if details and details.description: - best.description = details.description + # Details holen wenn Treffer gut genug (z.B. MB Tracklist) + if best and best.source == source_name and best_score >= UNCERTAIN_THRESHOLD and details_func: + try: + details = await details_func(best.source_id) + if details: + details.confidence = best_score + best = details + except Exception as e: + logger.warning(f"{source_name} Details Fehler: {e}") except Exception as e: - logger.warning(f"OpenLibrary Fehler: {e}") - - # 3. Google Books als letzter Fallback - if best_score < UNCERTAIN_THRESHOLD: - try: - gb_results = await search_google_books(search_title, author) - for r in gb_results: - score = _score_result(r, title, author) - if score > best_score: - best_score = score - best = r - except Exception as e: - logger.warning(f"Google Books Fehler: {e}") + logger.warning(f"{source_name} Fehler: {e}") if best and best_score >= UNCERTAIN_THRESHOLD: await _apply_match(db, item, best, best_score) - logger.info(f"Match angewendet: '{item.title}' ← {best.source} (Konfidenz: {best_score:.2f})") + logger.info(f"Match angewendet: '{item.title}' ← {best.source} ({best_score:.2f})") else: - logger.info(f"Kein Match gefunden für '{title}' (beste Konfidenz: {best_score:.2f})") + logger.info(f"Kein Match für '{title}' (beste Konfidenz: {best_score:.2f})") await db.commit() diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py index 9f2313b..9e1a7d7 100644 --- a/backend/app/services/scanner.py +++ b/backend/app/services/scanner.py @@ -1,5 +1,6 @@ import os import uuid +import asyncio import logging from datetime import datetime from pathlib import Path @@ -110,6 +111,7 @@ async def scan_library_task(library_id: str, job_id: str): all_books.extend(_discover_audiobook_folders(folder_path)) items_found = 0 + new_item_ids: list[str] = [] for book_info in all_books: folder_path = book_info["path"] audio_files = book_info["files"] @@ -164,6 +166,8 @@ async def scan_library_task(library_id: str, job_id: str): ) db.add(bf) + new_item_ids.append(item.id) + items_found += 1 await db.commit() @@ -187,6 +191,12 @@ async def scan_library_task(library_id: str, job_id: str): logger.info(f"Scan abgeschlossen: {items_found} Items in Library {library_id}") + # Auto-Matching für neue Items starten + if new_item_ids: + from .matcher import match_audiobook + for iid in new_item_ids: + asyncio.create_task(match_audiobook(iid)) + except Exception as e: logger.error(f"Scan-Fehler für Library {library_id}: {e}", exc_info=True) async with AsyncSessionLocal() as err_db: diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index a4df4d0..b358b66 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -115,11 +115,17 @@ function UsersPanel() { ) } +const MATCH_SOURCES = [ + { id: 'musicbrainz', label: 'MusicBrainz' }, + { id: 'open_library', label: 'Open Library' }, + { id: 'google_books', label: 'Google Books' }, +] + function LibraryForm({ initial, onSave, onCancel, title, }: { - initial: { name: string; path: string; mediaType: string } - onSave: (v: { name: string; path: string; mediaType: string }) => Promise + initial: { name: string; path: string; mediaType: string; matchSources: string[] } + onSave: (v: { name: string; path: string; mediaType: string; matchSources: string[] }) => Promise onCancel: () => void title: string }) { @@ -161,6 +167,36 @@ function LibraryForm({ + + {form.mediaType === 'book' && ( +
+

Matching-Quellen (Reihenfolge = Priorität)

+
+ {MATCH_SOURCES.map((s) => { + const checked = form.matchSources.includes(s.id) + return ( + + ) + })} +
+
+ )}