Fix matching: add missing subtitle field, proper error logging, match-all endpoint
- MatchResult was missing subtitle field, causing AttributeError in _apply_match that silently killed every background match task - Wrap _apply_match in try/except with exc_info logging so failures are visible in docker compose logs backend - New POST /api/libraries/:id/match-all endpoint to trigger matching for all unlocked items (useful for items scanned before the fix) - Admin UI: Match button per library next to the Scan button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -219,3 +219,33 @@ async def scan_library(
|
|||||||
background_tasks.add_task(scan_library_task, library_id, job.id)
|
background_tasks.add_task(scan_library_task, library_id, job.id)
|
||||||
|
|
||||||
return {"id": job.id, "type": "scan", "libraryId": library_id}
|
return {"id": job.id, "type": "scan", "libraryId": library_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{library_id}/match-all")
|
||||||
|
async def match_all_items(
|
||||||
|
library_id: str,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Startet Matching für alle nicht-gematchten Items der Library."""
|
||||||
|
result = await db.execute(select(Library).where(Library.id == library_id))
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=404, detail="Library not found")
|
||||||
|
|
||||||
|
items_result = await db.execute(
|
||||||
|
select(LibraryItem).where(
|
||||||
|
LibraryItem.library_id == library_id,
|
||||||
|
LibraryItem.match_locked == False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
items = items_result.scalars().all()
|
||||||
|
item_ids = [i.id for i in items]
|
||||||
|
|
||||||
|
async def _run_all():
|
||||||
|
from ..services.matcher import match_audiobook
|
||||||
|
for iid in item_ids:
|
||||||
|
await match_audiobook(iid)
|
||||||
|
|
||||||
|
background_tasks.add_task(_run_all)
|
||||||
|
return {"queued": len(item_ids), "libraryId": library_id}
|
||||||
|
|||||||
@@ -233,12 +233,15 @@ async def match_audiobook(item_id: str):
|
|||||||
logger.warning(f"{source_name} Fehler: {e}")
|
logger.warning(f"{source_name} Fehler: {e}")
|
||||||
|
|
||||||
if best and best_score >= UNCERTAIN_THRESHOLD:
|
if best and best_score >= UNCERTAIN_THRESHOLD:
|
||||||
await _apply_match(db, item, best, best_score)
|
try:
|
||||||
logger.info(f"Match angewendet: '{item.title}' ← {best.source} ({best_score:.2f})")
|
await _apply_match(db, item, best, best_score)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Match angewendet: '{item.title}' ← {best.source} ({best_score:.2f})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"_apply_match fehlgeschlagen für '{title}': {e}", exc_info=True)
|
||||||
else:
|
else:
|
||||||
logger.info(f"Kein Match 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()
|
|
||||||
|
|
||||||
|
|
||||||
async def search_for_item(title: str, author: str | None = None) -> list[dict]:
|
async def search_for_item(title: str, author: str | None = None) -> list[dict]:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class MatchResult:
|
|||||||
source: str # musicbrainz / open_library / google_books
|
source: str # musicbrainz / open_library / google_books
|
||||||
source_id: str
|
source_id: str
|
||||||
title: str
|
title: str
|
||||||
|
subtitle: str | None = None
|
||||||
author: str | None = None
|
author: str | None = None
|
||||||
narrator: str | None = None
|
narrator: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export const searchLibrary = (libraryId: string, q: string) =>
|
|||||||
export const scanLibrary = (libraryId: string) =>
|
export const scanLibrary = (libraryId: string) =>
|
||||||
api.post(`/api/libraries/${libraryId}/scan`).then((r) => r.data)
|
api.post(`/api/libraries/${libraryId}/scan`).then((r) => r.data)
|
||||||
|
|
||||||
|
export const matchAllLibrary = (libraryId: string) =>
|
||||||
|
api.post(`/api/libraries/${libraryId}/match-all`).then((r) => r.data)
|
||||||
|
|
||||||
export const createLibrary = (data: object) =>
|
export const createLibrary = (data: object) =>
|
||||||
api.post('/api/libraries', data).then((r) => r.data)
|
api.post('/api/libraries', data).then((r) => r.data)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X, FolderOpen, Pencil } from 'lucide-react'
|
import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X, FolderOpen, Pencil, Sparkles } from 'lucide-react'
|
||||||
import { getUsers, createUser, deleteUser, getSettings, updateSettings } from '../api/me'
|
import { getUsers, createUser, deleteUser, getSettings, updateSettings } from '../api/me'
|
||||||
import { getLibraries, scanLibrary, createLibrary, updateLibrary, deleteLibrary } from '../api/libraries'
|
import { getLibraries, scanLibrary, matchAllLibrary, createLibrary, updateLibrary, deleteLibrary } from '../api/libraries'
|
||||||
import FileBrowser from '../components/common/FileBrowser'
|
import FileBrowser from '../components/common/FileBrowser'
|
||||||
|
|
||||||
type Tab = 'users' | 'libraries' | 'settings'
|
type Tab = 'users' | 'libraries' | 'settings'
|
||||||
@@ -214,6 +214,7 @@ function LibrariesPanel() {
|
|||||||
const [libraries, setLibraries] = useState<any[]>([])
|
const [libraries, setLibraries] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [scanning, setScanning] = useState<string | null>(null)
|
const [scanning, setScanning] = useState<string | null>(null)
|
||||||
|
const [matching, setMatching] = useState<string | null>(null)
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -226,6 +227,12 @@ function LibrariesPanel() {
|
|||||||
setTimeout(() => setScanning(null), 5000)
|
setTimeout(() => setScanning(null), 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMatchAll = async (id: string) => {
|
||||||
|
setMatching(id)
|
||||||
|
await matchAllLibrary(id).catch(() => {})
|
||||||
|
setTimeout(() => setMatching(null), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreate = async (form: { name: string; path: string; mediaType: string; matchSources: string[] }) => {
|
const handleCreate = async (form: { name: string; path: string; mediaType: string; matchSources: string[] }) => {
|
||||||
await createLibrary({
|
await createLibrary({
|
||||||
name: form.name, folders: [{ fullPath: form.path }], media_type: form.mediaType,
|
name: form.name, folders: [{ fullPath: form.path }], media_type: form.mediaType,
|
||||||
@@ -293,6 +300,14 @@ function LibrariesPanel() {
|
|||||||
<RefreshCw size={12} className={scanning === lib.id ? 'animate-spin' : ''} />
|
<RefreshCw size={12} className={scanning === lib.id ? 'animate-spin' : ''} />
|
||||||
Scan
|
Scan
|
||||||
</button>
|
</button>
|
||||||
|
{lib.mediaType !== 'podcast' && (
|
||||||
|
<button onClick={() => handleMatchAll(lib.id)} disabled={matching === lib.id}
|
||||||
|
title="Matching für alle Items starten"
|
||||||
|
className="flex items-center gap-1 text-xs text-gray-400 hover:text-white bg-white/5 px-3 py-1.5 rounded-lg disabled:opacity-50 flex-shrink-0">
|
||||||
|
<Sparkles size={12} className={matching === lib.id ? 'animate-pulse' : ''} />
|
||||||
|
Match
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button onClick={() => { setEditingId(lib.id); setShowCreate(false) }}
|
<button onClick={() => { setEditingId(lib.id); setShowCreate(false) }}
|
||||||
className="text-gray-500 hover:text-white p-1 flex-shrink-0">
|
className="text-gray-500 hover:text-white p-1 flex-shrink-0">
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
|
|||||||
Reference in New Issue
Block a user