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

@@ -52,6 +52,63 @@ async def search_match(
return {"results": results} return {"results": results}
@router.get("/match/debug")
async def debug_match(
title: str,
author: str | None = None,
current_user: User = Depends(get_current_user),
):
"""Debug-Endpoint: gibt rohe Ergebnisse aller Such-Quellen zurück.
Aufruf direkt aus Browser: /api/items/match/debug?title=Foo&author=Bar
"""
from ..services.matching.musicbrainz import search_musicbrainz
from ..services.matching.open_library import search_open_library
from ..services.matching.google_books import search_google_books
from ..services.matching.dnb import search_dnb
from ..services.matcher import _build_search_title, detect_series
series, episode = detect_series(title)
search_title = _build_search_title(title)
if series and episode:
search_title = f"{series} {episode}"
logger.info(f"DEBUG: title={title!r} → search={search_title!r} series={series!r} episode={episode!r}")
async def _try(name, coro):
try:
r = await coro
return {
"source": name,
"ok": True,
"count": len(r),
"results": [
{
"title": x.title, "author": x.author, "narrator": x.narrator,
"publisher": x.publisher, "year": x.publish_year,
"series": x.series, "series_sequence": x.series_sequence,
"cover_url": x.cover_url, "language": x.language,
"genres": x.genres, "description": (x.description or "")[:200],
"confidence": x.confidence, "source_id": x.source_id,
} for x in r
],
}
except Exception as e:
return {"source": name, "ok": False, "error": f"{type(e).__name__}: {e}"}
results = await asyncio.gather(
_try("musicbrainz", search_musicbrainz(search_title, author)),
_try("open_library", search_open_library(search_title, author)),
_try("google_books", search_google_books(search_title, author)),
_try("dnb", search_dnb(search_title, author)),
)
return {
"input": {"title": title, "author": author},
"normalized": {"search_title": search_title, "series": series, "episode": episode},
"sources": results,
}
@router.post("/{item_id}/match/apply") @router.post("/{item_id}/match/apply")
async def apply_match( async def apply_match(
item_id: str, item_id: str,

View File

@@ -31,7 +31,8 @@ logger = logging.getLogger(__name__)
AUTO_ACCEPT_THRESHOLD = 0.65 AUTO_ACCEPT_THRESHOLD = 0.65
UNCERTAIN_THRESHOLD = 0.40 UNCERTAIN_THRESHOLD = 0.40
SERIES_PATTERNS = [ # Mit Folgenummer
SERIES_PATTERNS_WITH_EPISODE = [
(r"(?i)^(die drei \?\?\?|die drei fragezeichen|drei fragezeichen)\s*[-]?\s*(?:folge\s*)?(\d+)", "Die drei ???"), (r"(?i)^(die drei \?\?\?|die drei fragezeichen|drei fragezeichen)\s*[-]?\s*(?:folge\s*)?(\d+)", "Die drei ???"),
(r"(?i)^(tkkg)\s*[-]?\s*(?:folge\s*)?(\d+)", "TKKG"), (r"(?i)^(tkkg)\s*[-]?\s*(?:folge\s*)?(\d+)", "TKKG"),
(r"(?i)^(fünf freunde|funf freunde)\s*[-]?\s*(?:band\s*)?(\d+)", "Fünf Freunde"), (r"(?i)^(fünf freunde|funf freunde)\s*[-]?\s*(?:band\s*)?(\d+)", "Fünf Freunde"),
@@ -43,17 +44,41 @@ SERIES_PATTERNS = [
(r"(?i)^(.+?)\s*\((?:folge|band|teil|nr\.?|#|episode)\s*(\d+)\)", None), (r"(?i)^(.+?)\s*\((?:folge|band|teil|nr\.?|#|episode)\s*(\d+)\)", None),
] ]
# Ohne Folgenummer (nur Serie erkennen)
SERIES_PATTERNS_SERIES_ONLY = [
(r"(?i)^(die drei \?\?\?|die drei fragezeichen|drei fragezeichen)", "Die drei ???"),
(r"(?i)^(tkkg)\b", "TKKG"),
(r"(?i)^(fünf freunde|funf freunde)", "Fünf Freunde"),
(r"(?i)^(bibi blocksberg)", "Bibi Blocksberg"),
(r"(?i)^(benjamin blümchen|benjamin blumchen)", "Benjamin Blümchen"),
(r"(?i)^(bibi und tina)", "Bibi und Tina"),
(r"(?i)^(der kleine vampir)", "Der kleine Vampir"),
]
def detect_series(title: str) -> tuple[str | None, str | None]: def detect_series(title: str) -> tuple[str | None, str | None]:
for pattern, canonical_name in SERIES_PATTERNS: t = title.strip()
m = re.match(pattern, title.strip()) for pattern, canonical_name in SERIES_PATTERNS_WITH_EPISODE:
m = re.match(pattern, t)
if m: if m:
series = canonical_name or m.group(1).strip() return (canonical_name or m.group(1).strip(), m.group(2))
episode = m.group(2) for pattern, canonical_name in SERIES_PATTERNS_SERIES_ONLY:
return series, episode m = re.match(pattern, t)
if m:
return (canonical_name or m.group(1).strip(), None)
return None, None return None, None
def _build_search_title(original: str) -> str:
"""Bereinigt Titel für Such-APIs: ??? raus, Sonderzeichen, Klammer-Suffixe."""
t = original
t = re.sub(r"\?{2,}", "", t)
t = re.sub(r"\s*\([^)]*\)\s*$", "", t)
t = re.sub(r"[_\-]+", " ", t)
t = re.sub(r"\s+", " ", t).strip()
return t
def _title_similarity(a: str, b: str) -> float: def _title_similarity(a: str, b: str) -> float:
"""Wort-Überlapp mit Min/Max-Gewichtung — lenient für Teil-Treffer.""" """Wort-Überlapp mit Min/Max-Gewichtung — lenient für Teil-Treffer."""
if not a or not b: if not a or not b:
@@ -215,15 +240,20 @@ async def match_audiobook(item_id: str):
author = item.author author = item.author
series, episode = detect_series(title) series, episode = detect_series(title)
search_title = title
if series: if series:
search_title = f"{series} {episode}" if episode else series if episode:
search_title = f"{series} {episode}"
else:
# Serie erkannt, keine Folgennummer → kompletten Titel suchen
search_title = _build_search_title(title)
if not item.series: if not item.series:
item.series = series item.series = series
if not item.series_sequence and episode: if not item.series_sequence and episode:
item.series_sequence = episode item.series_sequence = episode
else:
search_title = _build_search_title(title)
logger.info(f"Matche: '{title}' (Such-Titel: '{search_title}') | Quellen: {sources}") logger.info(f"Matche: orig='{title}' suchTitel='{search_title}' author={author!r} | Quellen: {sources}")
best: MatchResult | None = None best: MatchResult | None = None
best_score = 0.0 best_score = 0.0
@@ -274,18 +304,23 @@ async def match_audiobook(item_id: str):
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]:
"""Suche über alle Quellen für manuelles Matching. Gibt alle relevanten Felder zurück.""" """Suche über alle Quellen für manuelles Matching. Gibt alle relevanten Felder zurück."""
async def _search_source(coro): search_title = _build_search_title(title)
logger.info(f"Manuelle Suche: orig='{title}' bereinigt='{search_title}' author={author!r}")
async def _search_source(name: str, coro):
try: try:
return await coro r = await coro
logger.info(f"Manuelle Suche {name}: {len(r)} Treffer")
return r
except Exception as e: except Exception as e:
logger.warning(f"Such-Fehler: {e}") logger.warning(f"Manuelle Suche {name} Fehler: {e}")
return [] return []
mb, ol, gb, dnb = await asyncio.gather( mb, ol, gb, dnb = await asyncio.gather(
_search_source(search_musicbrainz(title, author)), _search_source("musicbrainz", search_musicbrainz(search_title, author)),
_search_source(search_open_library(title, author)), _search_source("open_library", search_open_library(search_title, author)),
_search_source(search_google_books(title, author)), _search_source("google_books", search_google_books(search_title, author)),
_search_source(search_dnb(title, author)), _search_source("dnb", search_dnb(search_title, author)),
) )
results = [] results = []

View File

@@ -1,24 +1,67 @@
""" """
Deutsche Nationalbibliothek (DNB) SRU-Schnittstelle. Deutsche Nationalbibliothek (DNB) SRU-Schnittstelle.
Sucht Hörbücher (mat=ton) über MARC21-XML. Mehrere Query-Strategien mit Fallback; ausführliches Logging.
""" """
import re import re
import logging
import httpx import httpx
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from .base import MatchResult from .base import MatchResult
logger = logging.getLogger(__name__)
DNB_SRU = "https://services.dnb.de/sru/dnb" DNB_SRU = "https://services.dnb.de/sru/dnb"
HEADERS = {"User-Agent": "audiolib/1.0 (contact@audiolib.local)"} HEADERS = {"User-Agent": "audiolib/1.0 (contact@audiolib.local)"}
_NS_SRW = "http://www.loc.gov/zing/srw/" _NS_SRW = "http://www.loc.gov/zing/srw/"
_NS_MARC = "http://www.loc.gov/MARC21/slim" _NS_MARC = "http://www.loc.gov/MARC21/slim"
# CQL Wildcards / Sonderzeichen die wir aus Such-Titeln entfernen
_CQL_STRIP = re.compile(r"[?*<>=/\"']")
_WHITESPACE = re.compile(r"\s+")
def _norm_for_query(text: str) -> str:
"""Entfernt CQL-Sonderzeichen und Doppelspaces."""
out = _CQL_STRIP.sub(" ", text)
out = _WHITESPACE.sub(" ", out).strip()
return out
async def search_dnb(title: str, author: str | None = None) -> list[MatchResult]: async def search_dnb(title: str, author: str | None = None) -> list[MatchResult]:
parts = [f'tit="{title}"', "mat=ton"] """Mehrere Query-Strategien, gibt beim ersten Erfolg zurück."""
if author: norm_title = _norm_for_query(title)
parts.append(f'per="{author}"') norm_author = _norm_for_query(author) if author else None
query = " AND ".join(parts)
if not norm_title:
logger.info("DNB: leerer Titel nach Normalisierung")
return []
queries: list[str] = []
# 1) Titel + Autor (mit Hörbuch-Filter)
if norm_author:
queries.append(f'tit="{norm_title}" AND per="{norm_author}" AND mat=ton')
queries.append(f'tit="{norm_title}" AND per="{norm_author}"')
# 2) Nur Titel (mit Hörbuch-Filter)
queries.append(f'tit="{norm_title}" AND mat=ton')
# 3) Nur Titel ohne Filter
queries.append(f'tit="{norm_title}"')
# 4) Volltext-Fallback
if norm_author:
queries.append(f'{norm_title} {norm_author}')
else:
queries.append(norm_title)
async with httpx.AsyncClient(headers=HEADERS, timeout=20) as client:
for query in queries:
results = await _dnb_query(client, query)
if results:
logger.info(f"DNB: '{query}'{len(results)} Treffer")
return results
logger.info(f"DNB: '{query}' → 0 Treffer")
return []
async def _dnb_query(client: httpx.AsyncClient, query: str) -> list[MatchResult]:
params = { params = {
"version": "1.1", "version": "1.1",
"operation": "searchRetrieve", "operation": "searchRetrieve",
@@ -26,18 +69,38 @@ async def search_dnb(title: str, author: str | None = None) -> list[MatchResult]
"recordSchema": "MARC21-xml", "recordSchema": "MARC21-xml",
"maximumRecords": "5", "maximumRecords": "5",
} }
async with httpx.AsyncClient(headers=HEADERS, timeout=15) as client:
try: try:
r = await client.get(DNB_SRU, params=params) r = await client.get(DNB_SRU, params=params)
r.raise_for_status() except Exception as e:
except Exception: logger.warning(f"DNB HTTP-Fehler ({query!r}): {e}")
return []
if r.status_code != 200:
snippet = r.text[:200] if r.text else ""
logger.warning(f"DNB HTTP {r.status_code} für {query!r}: {snippet}")
return [] return []
try: try:
root = ET.fromstring(r.text) root = ET.fromstring(r.text)
except ET.ParseError: except ET.ParseError as e:
logger.warning(f"DNB XML-Parse-Fehler: {e} — Body: {r.text[:200]}")
return [] return []
# numberOfRecords prüfen
num_elem = root.find(f".//{{{_NS_SRW}}}numberOfRecords")
num = 0
if num_elem is not None and num_elem.text:
try:
num = int(num_elem.text)
except ValueError:
pass
# Diagnose-Fehler aus DNB
diag = root.find(f".//{{http://www.loc.gov/zing/srw/diagnostic/}}diagnostic")
if diag is not None:
diag_msg = "".join(diag.itertext()).strip()
logger.warning(f"DNB Diagnose: {diag_msg}")
results = [] results = []
for record in root.findall(f".//{{{_NS_SRW}}}record"): for record in root.findall(f".//{{{_NS_SRW}}}record"):
marc = record.find(f".//{{{_NS_MARC}}}record") marc = record.find(f".//{{{_NS_MARC}}}record")
@@ -47,8 +110,8 @@ async def search_dnb(title: str, author: str | None = None) -> list[MatchResult]
result = _parse_marc(marc) result = _parse_marc(marc)
if result: if result:
results.append(result) results.append(result)
except Exception: except Exception as e:
continue logger.warning(f"DNB MARC-Parse-Fehler: {e}")
return results return results
@@ -86,23 +149,31 @@ def _parse_marc(marc) -> MatchResult | None:
author = _field(marc, "100", "a") author = _field(marc, "100", "a")
if author: if author:
author = author.rstrip(",").strip() author = author.rstrip(",").strip()
# DNB-Format "Nachname, Vorname" → "Vorname Nachname"
if "," in author:
parts = [p.strip() for p in author.split(",", 1)]
if len(parts) == 2:
author = f"{parts[1]} {parts[0]}"
# Sprecher aus 700 $e = "Sprecher" oder $4 = "spk"
narrator = None narrator = None
for f in marc.findall(f"{{{_NS_MARC}}}datafield[@tag='700']"): for f in marc.findall(f"{{{_NS_MARC}}}datafield[@tag='700']"):
e_sf = f.find(f"{{{_NS_MARC}}}subfield[@code='e']") e_sf = f.find(f"{{{_NS_MARC}}}subfield[@code='e']")
r_sf = f.find(f"{{{_NS_MARC}}}subfield[@code='4']") r_sf = f.find(f"{{{_NS_MARC}}}subfield[@code='4']")
is_narrator = ( is_narrator = (
(e_sf is not None and e_sf.text and "prech" in e_sf.text.lower()) (e_sf is not None and e_sf.text and ("prech" in e_sf.text.lower() or "erzähl" in e_sf.text.lower()))
or (r_sf is not None and r_sf.text == "spk") or (r_sf is not None and r_sf.text in ("spk", "nrt"))
) )
if is_narrator: if is_narrator:
n_sf = f.find(f"{{{_NS_MARC}}}subfield[@code='a']") n_sf = f.find(f"{{{_NS_MARC}}}subfield[@code='a']")
if n_sf is not None and n_sf.text: if n_sf is not None and n_sf.text:
narrator = n_sf.text.rstrip(",").strip() narrator = n_sf.text.rstrip(",").strip()
if "," in narrator:
parts = [p.strip() for p in narrator.split(",", 1)]
if len(parts) == 2:
narrator = f"{parts[1]} {parts[0]}"
break break
publisher = (_field(marc, "264", "b") or "").rstrip(",").strip() or None publisher = (_field(marc, "264", "b") or _field(marc, "260", "b") or "").rstrip(",").strip() or None
year_raw = _field(marc, "264", "c") or _field(marc, "260", "c") year_raw = _field(marc, "264", "c") or _field(marc, "260", "c")
publish_year = None publish_year = None
if year_raw: if year_raw:
@@ -114,14 +185,13 @@ def _parse_marc(marc) -> MatchResult | None:
language = _field(marc, "041", "a") language = _field(marc, "041", "a")
genres = _fields(marc, "650", "a")[:5] genres = _fields(marc, "650", "a")[:5]
series = _field(marc, "830", "a") or _field(marc, "800", "t") series = _field(marc, "830", "a") or _field(marc, "800", "t") or _field(marc, "490", "a")
series_seq = _field(marc, "830", "v") or _field(marc, "800", "v") series_seq = _field(marc, "830", "v") or _field(marc, "800", "v") or _field(marc, "490", "v")
# DNB-ID aus Kontrollfeld 001
ctrl = marc.find(f"{{{_NS_MARC}}}controlfield[@tag='001']") ctrl = marc.find(f"{{{_NS_MARC}}}controlfield[@tag='001']")
dnb_id = ctrl.text.strip() if ctrl is not None and ctrl.text else None dnb_id = ctrl.text.strip() if ctrl is not None and ctrl.text else None
# ISBN für Cover # ISBN für Cover (sehr unzuverlässig bei DNB-Hörbüchern)
isbn_raw = _field(marc, "020", "a") or "" isbn_raw = _field(marc, "020", "a") or ""
isbn = re.sub(r"[^0-9X]", "", isbn_raw.split()[0]) if isbn_raw else None isbn = re.sub(r"[^0-9X]", "", isbn_raw.split()[0]) if isbn_raw else None
cover_url = f"https://portal.dnb.de/opac/mvb/cover?isbn={isbn}" if isbn else None cover_url = f"https://portal.dnb.de/opac/mvb/cover?isbn={isbn}" if isbn else None

View File

@@ -27,4 +27,10 @@ export const applyMatch = (id: string, match: object) =>
export const triggerMatch = (id: string) => export const triggerMatch = (id: string) =>
api.post(`/api/items/${id}/match`).then((r) => r.data) 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` export const coverUrl = (id: string) => `/api/items/${id}/cover`

View File

@@ -4,7 +4,7 @@ import {
Play, ArrowLeft, RefreshCw, Search, Check, Play, ArrowLeft, RefreshCw, Search, Check,
Loader2, Trash2, X Loader2, Trash2, X
} from 'lucide-react' } 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 { getMe, createBookmark, deleteBookmark } from '../api/me'
import { usePlayerStore } from '../store/playerStore' import { usePlayerStore } from '../store/playerStore'
import CoverImage from '../components/common/CoverImage' import CoverImage from '../components/common/CoverImage'
@@ -21,6 +21,8 @@ export default function BookDetail() {
const [matchQuery, setMatchQuery] = useState('') const [matchQuery, setMatchQuery] = useState('')
const [matchLoading, setMatchLoading] = useState(false) const [matchLoading, setMatchLoading] = useState(false)
const [showMatchPanel, setShowMatchPanel] = useState(false) const [showMatchPanel, setShowMatchPanel] = useState(false)
const [debugData, setDebugData] = useState<any>(null)
const [debugLoading, setDebugLoading] = useState(false)
const { play, item: currentItem, currentTime } = usePlayerStore() const { play, item: currentItem, currentTime } = usePlayerStore()
useEffect(() => { useEffect(() => {
@@ -79,6 +81,27 @@ export default function BookDetail() {
}, 3000) }, 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 fmtTime = (s: number) => {
const h = Math.floor(s / 3600) const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60) const m = Math.floor((s % 3600) / 60)
@@ -167,10 +190,59 @@ export default function BookDetail() {
<RefreshCw size={14} className={matchLoading ? 'animate-spin' : ''} /> <RefreshCw size={14} className={matchLoading ? 'animate-spin' : ''} />
Auto-Match Auto-Match
</button> </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> </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 */} {/* Description */}
{meta.description && ( {meta.description && (
<div className="mb-6"> <div className="mb-6">