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 datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, func
|
||||||
from ..database import AsyncSessionLocal
|
from ..database import AsyncSessionLocal
|
||||||
from ..dependencies import get_db, get_current_user
|
from ..dependencies import get_db, get_current_user
|
||||||
from ..models.user import User
|
from ..models.user import User
|
||||||
@@ -42,7 +42,7 @@ def _build_user_out(user: User) -> UserOut:
|
|||||||
@router.post("/login", response_model=LoginResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(
|
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()
|
user = result.scalar_one_or_none()
|
||||||
if not user or not verify_password(body.password, user.password_hash):
|
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,
|
media_type=lib.media_type,
|
||||||
icon=settings_data.get("icon", "database"),
|
icon=settings_data.get("icon", "database"),
|
||||||
provider=settings_data.get("provider", "google"),
|
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,
|
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,
|
last_update=int(lib.updated_at.timestamp() * 1000) if lib.updated_at else 0,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class LibraryOut(BaseModel):
|
|||||||
icon: str = "database"
|
icon: str = "database"
|
||||||
media_type: str = "book"
|
media_type: str = "book"
|
||||||
provider: str = "google"
|
provider: str = "google"
|
||||||
|
match_sources: list[str] = ["musicbrainz", "open_library", "google_books"]
|
||||||
settings: LibrarySettings = LibrarySettings()
|
settings: LibrarySettings = LibrarySettings()
|
||||||
created_at: int = 0 # ABS nutzt Unix-Timestamps in ms
|
created_at: int = 0 # ABS nutzt Unix-Timestamps in ms
|
||||||
last_update: int = 0
|
last_update: int = 0
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..models.media_item import LibraryItem, BookFile, Chapter
|
from ..models.media_item import LibraryItem, BookFile, Chapter
|
||||||
|
from ..models.library import Library
|
||||||
from ..models.session import ServerSetting
|
from ..models.session import ServerSetting
|
||||||
from ..database import AsyncSessionLocal
|
from ..database import AsyncSessionLocal
|
||||||
from .matching.base import MatchResult
|
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"]
|
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):
|
async def match_audiobook(item_id: str):
|
||||||
"""
|
"""
|
||||||
Haupt-Matching-Funktion. Wird nach dem Scan als Hintergrund-Task gestartet.
|
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:
|
async with AsyncSessionLocal() as db:
|
||||||
result_row = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
|
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:
|
if not item or item.match_locked:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Einstellung prüfen
|
# Globale Auto-Match Einstellung prüfen
|
||||||
setting = await db.execute(
|
setting = await db.execute(
|
||||||
select(ServerSetting).where(ServerSetting.key == "autoMatchBooks")
|
select(ServerSetting).where(ServerSetting.key == "autoMatchBooks")
|
||||||
)
|
)
|
||||||
@@ -172,10 +181,17 @@ async def match_audiobook(item_id: str):
|
|||||||
if s and s.value is False:
|
if s and s.value is False:
|
||||||
return
|
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 ""
|
title = item.title or ""
|
||||||
author = item.author
|
author = item.author
|
||||||
|
|
||||||
# Serien-Erkennung verbessert den Suchbegriff
|
|
||||||
series, episode = detect_series(title)
|
series, episode = detect_series(title)
|
||||||
search_title = title
|
search_title = title
|
||||||
if series:
|
if series:
|
||||||
@@ -185,65 +201,42 @@ async def match_audiobook(item_id: str):
|
|||||||
if not item.series_sequence and episode:
|
if not item.series_sequence and episode:
|
||||||
item.series_sequence = 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: MatchResult | None = None
|
||||||
best_score = 0.0
|
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:
|
try:
|
||||||
mb_results = await search_musicbrainz(search_title, author)
|
results = await search_func(search_title, author)
|
||||||
for r in mb_results:
|
for r in results:
|
||||||
score = _score_result(r, title, author)
|
score = _score_result(r, title, author)
|
||||||
if score > best_score:
|
if score > best_score:
|
||||||
best_score = score
|
best_score = score
|
||||||
best = r
|
best = r
|
||||||
except Exception as e:
|
# Details holen wenn Treffer gut genug (z.B. MB Tracklist)
|
||||||
logger.warning(f"MusicBrainz Fehler: {e}")
|
if best and best.source == source_name and best_score >= UNCERTAIN_THRESHOLD and details_func:
|
||||||
|
|
||||||
# Wenn guter MB-Treffer → Details holen (Tracklist + Cover)
|
|
||||||
if best and best_score >= UNCERTAIN_THRESHOLD and best.source == "musicbrainz":
|
|
||||||
try:
|
try:
|
||||||
details = await get_release_details(best.source_id)
|
details = await details_func(best.source_id)
|
||||||
if details:
|
if details:
|
||||||
details.confidence = best_score
|
details.confidence = best_score
|
||||||
best = details
|
best = details
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"MusicBrainz Details Fehler: {e}")
|
logger.warning(f"{source_name} 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
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"OpenLibrary Fehler: {e}")
|
logger.warning(f"{source_name} 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}")
|
|
||||||
|
|
||||||
if best and best_score >= UNCERTAIN_THRESHOLD:
|
if best and best_score >= UNCERTAIN_THRESHOLD:
|
||||||
await _apply_match(db, item, best, best_score)
|
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:
|
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()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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))
|
all_books.extend(_discover_audiobook_folders(folder_path))
|
||||||
|
|
||||||
items_found = 0
|
items_found = 0
|
||||||
|
new_item_ids: list[str] = []
|
||||||
for book_info in all_books:
|
for book_info in all_books:
|
||||||
folder_path = book_info["path"]
|
folder_path = book_info["path"]
|
||||||
audio_files = book_info["files"]
|
audio_files = book_info["files"]
|
||||||
@@ -164,6 +166,8 @@ async def scan_library_task(library_id: str, job_id: str):
|
|||||||
)
|
)
|
||||||
db.add(bf)
|
db.add(bf)
|
||||||
|
|
||||||
|
new_item_ids.append(item.id)
|
||||||
|
|
||||||
items_found += 1
|
items_found += 1
|
||||||
|
|
||||||
await db.commit()
|
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}")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Scan-Fehler für Library {library_id}: {e}", exc_info=True)
|
logger.error(f"Scan-Fehler für Library {library_id}: {e}", exc_info=True)
|
||||||
async with AsyncSessionLocal() as err_db:
|
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({
|
function LibraryForm({
|
||||||
initial, onSave, onCancel, title,
|
initial, onSave, onCancel, title,
|
||||||
}: {
|
}: {
|
||||||
initial: { name: string; path: string; mediaType: string }
|
initial: { name: string; path: string; mediaType: string; matchSources: string[] }
|
||||||
onSave: (v: { name: string; path: string; mediaType: string }) => Promise<void>
|
onSave: (v: { name: string; path: string; mediaType: string; matchSources: string[] }) => Promise<void>
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
title: string
|
title: string
|
||||||
}) {
|
}) {
|
||||||
@@ -161,6 +167,36 @@ function LibraryForm({
|
|||||||
<option value="book">Hörbücher</option>
|
<option value="book">Hörbücher</option>
|
||||||
<option value="podcast">Podcasts</option>
|
<option value="podcast">Podcasts</option>
|
||||||
</select>
|
</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">
|
<div className="flex gap-2">
|
||||||
<button onClick={submit} disabled={!form.name || !form.path || saving}
|
<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">
|
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)
|
setTimeout(() => setScanning(null), 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = async (form: { name: string; path: string; mediaType: string }) => {
|
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 })
|
await createLibrary({
|
||||||
|
name: form.name, folders: [{ fullPath: form.path }], media_type: form.mediaType,
|
||||||
|
settings: { match_sources: form.matchSources },
|
||||||
|
})
|
||||||
await reload()
|
await reload()
|
||||||
setShowCreate(false)
|
setShowCreate(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = async (id: string, form: { name: string; path: string; mediaType: string }) => {
|
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 })
|
await updateLibrary(id, {
|
||||||
|
name: form.name, folders: [{ fullPath: form.path }], media_type: form.mediaType,
|
||||||
|
settings: { match_sources: form.matchSources },
|
||||||
|
})
|
||||||
await reload()
|
await reload()
|
||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
}
|
}
|
||||||
@@ -221,7 +263,7 @@ function LibrariesPanel() {
|
|||||||
{showCreate && (
|
{showCreate && (
|
||||||
<LibraryForm
|
<LibraryForm
|
||||||
title="Neue Bibliothek"
|
title="Neue Bibliothek"
|
||||||
initial={{ name: '', path: '', mediaType: 'book' }}
|
initial={{ name: '', path: '', mediaType: 'book', matchSources: ['musicbrainz', 'open_library', 'google_books'] }}
|
||||||
onSave={handleCreate}
|
onSave={handleCreate}
|
||||||
onCancel={() => setShowCreate(false)}
|
onCancel={() => setShowCreate(false)}
|
||||||
/>
|
/>
|
||||||
@@ -234,7 +276,7 @@ function LibrariesPanel() {
|
|||||||
{editingId === lib.id ? (
|
{editingId === lib.id ? (
|
||||||
<LibraryForm
|
<LibraryForm
|
||||||
title={`„${lib.name}" bearbeiten`}
|
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)}
|
onSave={(form) => handleUpdate(lib.id, form)}
|
||||||
onCancel={() => setEditingId(null)}
|
onCancel={() => setEditingId(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user