Case-insensitive login, auto-matching after scan, per-library sources

- auth: username lookup is now case-insensitive via func.lower()
- scanner: trigger match_audiobook for each newly found item after scan
- matcher: read match_sources from library settings; refactored to loop
  over configured sources in priority order instead of hardcoded sequence
- schemas/routers: expose matchSources in LibraryOut API response
- Admin UI: pill-toggle for MusicBrainz/Open Library/Google Books per library

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Audiolib
2026-05-26 15:01:56 +02:00
parent b8984f0c2c
commit 6bb07ff873
6 changed files with 104 additions and 57 deletions

View File

@@ -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()

View File

@@ -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: