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

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

View File

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

View File

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

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
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:
mb_results = await search_musicbrainz(search_title, author)
for r in mb_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
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":
# 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 get_release_details(best.source_id)
details = await details_func(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:
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
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:

View File

@@ -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<void>
initial: { name: string; path: string; mediaType: string; matchSources: string[] }
onSave: (v: { name: string; path: string; mediaType: string; matchSources: string[] }) => Promise<void>
onCancel: () => void
title: string
}) {
@@ -161,6 +167,36 @@ function LibraryForm({
<option value="book">Hörbücher</option>
<option value="podcast">Podcasts</option>
</select>
{form.mediaType === 'book' && (
<div>
<p className="text-xs text-gray-500 mb-2">Matching-Quellen (Reihenfolge = Priorität)</p>
<div className="flex flex-wrap gap-2">
{MATCH_SOURCES.map((s) => {
const checked = form.matchSources.includes(s.id)
return (
<button
key={s.id}
type="button"
onClick={() => setForm({
...form,
matchSources: checked
? form.matchSources.filter((x) => x !== s.id)
: [...form.matchSources, s.id],
})}
className={`px-3 py-1 rounded-full text-xs border transition-colors ${
checked
? 'bg-primary/20 border-primary text-primary'
: 'bg-white/5 border-white/10 text-gray-400 hover:text-white'
}`}
>
{s.label}
</button>
)
})}
</div>
</div>
)}
<div className="flex gap-2">
<button onClick={submit} disabled={!form.name || !form.path || saving}
className="flex items-center gap-2 bg-primary text-black px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
@@ -190,14 +226,20 @@ function LibrariesPanel() {
setTimeout(() => setScanning(null), 5000)
}
const handleCreate = async (form: { name: string; path: string; mediaType: string }) => {
await createLibrary({ name: form.name, folders: [{ fullPath: form.path }], media_type: form.mediaType })
const handleCreate = async (form: { name: string; path: string; mediaType: string; matchSources: string[] }) => {
await createLibrary({
name: form.name, folders: [{ fullPath: form.path }], media_type: form.mediaType,
settings: { match_sources: form.matchSources },
})
await reload()
setShowCreate(false)
}
const handleUpdate = async (id: string, form: { name: string; path: string; mediaType: string }) => {
await updateLibrary(id, { name: form.name, folders: [{ fullPath: form.path }], media_type: form.mediaType })
const handleUpdate = async (id: string, form: { name: string; path: string; mediaType: string; matchSources: string[] }) => {
await updateLibrary(id, {
name: form.name, folders: [{ fullPath: form.path }], media_type: form.mediaType,
settings: { match_sources: form.matchSources },
})
await reload()
setEditingId(null)
}
@@ -221,7 +263,7 @@ function LibrariesPanel() {
{showCreate && (
<LibraryForm
title="Neue Bibliothek"
initial={{ name: '', path: '', mediaType: 'book' }}
initial={{ name: '', path: '', mediaType: 'book', matchSources: ['musicbrainz', 'open_library', 'google_books'] }}
onSave={handleCreate}
onCancel={() => setShowCreate(false)}
/>
@@ -234,7 +276,7 @@ function LibrariesPanel() {
{editingId === lib.id ? (
<LibraryForm
title={`${lib.name}" bearbeiten`}
initial={{ name: lib.name, path: lib.folders?.[0]?.fullPath || '', mediaType: lib.mediaType || 'book' }}
initial={{ name: lib.name, path: lib.folders?.[0]?.fullPath || '', mediaType: lib.mediaType || 'book', matchSources: lib.matchSources || ['musicbrainz', 'open_library', 'google_books'] }}
onSave={(form) => handleUpdate(lib.id, form)}
onCancel={() => setEditingId(null)}
/>