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:
@@ -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,
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user