Make matching debuggable + fix metadata search blockers

DNB rewrite:
- Multiple query strategies with fallback (title+author+mat=ton →
  title+author → title+mat=ton → title-only → fulltext). Returns on
  first hit. Most German audiobooks aren't tagged mat=ton in DNB,
  which was killing all searches.
- Strip CQL wildcard chars (?, *, <, >, =, /, quotes) from search
  terms. The "???" in "Die drei ???" was breaking the CQL parser.
- Log HTTP status, body snippet on non-200, and numberOfRecords on
  every query so log shows exactly what DNB returned.
- Parse SRU diagnostic elements (DNB error messages buried in XML).
- Convert author/narrator from "Lastname, Firstname" to
  "Firstname Lastname" for consistency with other sources.

Matcher:
- Split series patterns: WITH_EPISODE (need digit) and SERIES_ONLY
  (just the series name). "Die drei ??? und der Fluch des Rubins"
  now properly detects "Die drei ???" as series even without folge#.
- New _build_search_title: removes ??? sequences, trailing parens,
  collapses whitespace, before sending to APIs.
- Manual search also passes through normalization. Logs source +
  hit count per query.

Debug endpoint:
- GET /api/items/match/debug?title=...&author=... returns raw results
  from all 4 sources with status, error messages, and full metadata.
- "Debug" button added in BookDetail — shows what each API actually
  returns inline, so the user can see if it's a search problem,
  parse problem, or threshold problem.
- "Cover aus Datei" button — triggers local cover extraction
  (folder.jpg or embedded artwork) on demand.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Audiolib
2026-05-26 18:34:49 +02:00
parent 38f7c9726e
commit e3e6492b1f
5 changed files with 279 additions and 39 deletions

View File

@@ -27,4 +27,10 @@ export const applyMatch = (id: string, match: object) =>
export const triggerMatch = (id: string) =>
api.post(`/api/items/${id}/match`).then((r) => r.data)
export const debugMatch = (title: string, author?: string) =>
api.get(`/api/items/match/debug`, { params: { title, author } }).then((r) => r.data)
export const extractCover = (id: string) =>
api.post(`/api/items/${id}/extract-cover`).then((r) => r.data)
export const coverUrl = (id: string) => `/api/items/${id}/cover`

View File

@@ -4,7 +4,7 @@ import {
Play, ArrowLeft, RefreshCw, Search, Check,
Loader2, Trash2, X
} from 'lucide-react'
import { getItem, updateItem, triggerMatch, searchMatch, applyMatch, coverUrl } from '../api/items'
import { getItem, updateItem, triggerMatch, searchMatch, applyMatch, debugMatch, extractCover, coverUrl } from '../api/items'
import { getMe, createBookmark, deleteBookmark } from '../api/me'
import { usePlayerStore } from '../store/playerStore'
import CoverImage from '../components/common/CoverImage'
@@ -21,6 +21,8 @@ export default function BookDetail() {
const [matchQuery, setMatchQuery] = useState('')
const [matchLoading, setMatchLoading] = useState(false)
const [showMatchPanel, setShowMatchPanel] = useState(false)
const [debugData, setDebugData] = useState<any>(null)
const [debugLoading, setDebugLoading] = useState(false)
const { play, item: currentItem, currentTime } = usePlayerStore()
useEffect(() => {
@@ -79,6 +81,27 @@ export default function BookDetail() {
}, 3000)
}
const handleDebug = async () => {
setDebugLoading(true)
try {
const data = await debugMatch(title, author || undefined)
setDebugData(data)
} finally {
setDebugLoading(false)
}
}
const handleExtractCover = async () => {
if (!id) return
const res = await extractCover(id)
if (res.success) {
const updated = await getItem(id)
setItem(updated)
} else {
alert('Kein lokales Cover gefunden')
}
}
const fmtTime = (s: number) => {
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
@@ -167,10 +190,59 @@ export default function BookDetail() {
<RefreshCw size={14} className={matchLoading ? 'animate-spin' : ''} />
Auto-Match
</button>
<button
onClick={handleDebug}
disabled={debugLoading}
className="flex items-center gap-2 bg-card border border-divider px-4 py-2 rounded-lg text-sm text-muted hover:text-ink hover:bg-card/80 disabled:opacity-50 transition-colors"
title="Zeigt was die APIs tatsächlich zurückgeben"
>
{debugLoading ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} />}
Debug
</button>
<button
onClick={handleExtractCover}
className="flex items-center gap-2 bg-card border border-divider px-4 py-2 rounded-lg text-sm text-muted hover:text-ink hover:bg-card/80 transition-colors"
title="Cover aus Ordner-Datei oder MP3-Tag extrahieren"
>
Cover aus Datei
</button>
</div>
</div>
</div>
{debugData && (
<div className="mb-6 bg-surface border border-divider rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-ink" style={{ fontSize: '13px', fontWeight: 600 }}>API-Debug</h3>
<button onClick={() => setDebugData(null)} className="text-muted hover:text-ink">
<X size={14} />
</button>
</div>
<p className="text-muted mb-2" style={{ fontSize: '12px' }}>
Such-Titel (bereinigt): <span className="font-mono text-ink">{debugData.normalized?.search_title}</span>
{debugData.normalized?.series && <> · Serie: <span className="text-primary">{debugData.normalized.series}</span></>}
{debugData.normalized?.episode && <> · Folge: <span className="text-primary">{debugData.normalized.episode}</span></>}
</p>
{debugData.sources?.map((s: any) => (
<div key={s.source} className="mb-3 border-t border-divider pt-3">
<p className="text-ink mb-1" style={{ fontSize: '12px', fontWeight: 600 }}>
{s.source} {s.ok ? `${s.count} Treffer` : <span className="text-red-400">Fehler: {s.error}</span>}
</p>
{s.ok && s.results?.slice(0, 3).map((r: any, i: number) => (
<div key={i} className="text-muted ml-2 mb-1" style={{ fontSize: '11px' }}>
<p className="text-ink">{r.title} {r.author && `${r.author}`} {r.year && `(${r.year})`}</p>
{r.narrator && <p>Sprecher: {r.narrator}</p>}
{r.publisher && <p>Verlag: {r.publisher}</p>}
{r.series && <p>Serie: {r.series} {r.series_sequence && `#${r.series_sequence}`}</p>}
{r.cover_url && <p className="break-all">Cover: <span className="font-mono">{r.cover_url}</span></p>}
<p className="opacity-50">conf={r.confidence} id={r.source_id}</p>
</div>
))}
</div>
))}
</div>
)}
{/* Description */}
{meta.description && (
<div className="mb-6">