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:
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user