Bücher: Titel/Autor-Fallback + manuelle ISBN-Eingabe für Regal-Lookup (SW v39)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 25s

Bisher scheiterte "Metadaten abrufen" komplett, wenn im EPUB keine ISBN steckte
(oder bei PDFs, die nie eine liefern). Der Server versucht jetzt bei fehlendem/
erfolglosem ISBN-Treffer automatisch eine DNB- bzw. Open-Library-Textsuche nach
Titel+Autor (weniger präzise, daher als "dnb-title"/"openlibrary-title" markiert).
Bleibt auch das ohne Treffer, fragt der Client einmalig nach einer manuell
eingetippten ISBN (z.B. vom Buchrücken) und versucht es damit erneut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj
This commit is contained in:
marwin 2026-08-17 12:33:54 +02:00
parent e6b3ee620e
commit 635fd7ef1c
3 changed files with 137 additions and 28 deletions

View file

@ -120,29 +120,112 @@ def _lookup_openlibrary_shelf(isbn):
return None
def _cql_phrase(value):
# CQL string literals are quoted; strip embedded quotes rather than escaping them
# (this only feeds a lookup heuristic, not a stored/displayed value).
return value.replace('"', ' ').strip()
def _lookup_dnb_shelf_by_title(title, author):
url = 'https://services.dnb.de/sru/dnb'
query = f'dnb.tit="{_cql_phrase(title)}"'
if author:
query += f' and dnb.per="{_cql_phrase(author)}"'
params = {
'version': '1.1',
'operation': 'searchRetrieve',
'query': query,
'recordSchema': 'MARC21-xml',
'maximumRecords': '1',
}
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
resp.raise_for_status()
root = ET.fromstring(resp.content)
for datafield in root.iter(f'{_MARC_NS}datafield'):
if datafield.get('tag') != '082':
continue
for subfield in datafield.findall(f'{_MARC_NS}subfield'):
if subfield.get('code') == 'a' and subfield.text:
label = _ddc_label(subfield.text)
if label:
return label
return None
def _lookup_openlibrary_shelf_by_title(title, author):
url = 'https://openlibrary.org/search.json'
params = {'title': title, 'limit': 1, 'fields': 'ddc,subject'}
if author:
params['author'] = author
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
resp.raise_for_status()
docs = resp.json().get('docs') or []
if not docs:
return None
doc = docs[0]
ddc = doc.get('ddc') or []
if ddc:
label = _ddc_label(ddc[0])
if label:
return label
subjects = doc.get('subject') or []
if subjects:
return subjects[0]
return None
@require_http_methods(['GET'])
def lookup_book_metadata(request):
err = _require_auth(request)
if err:
return err
isbn = re.sub(r'[^0-9Xx]', '', request.GET.get('isbn', ''))
if len(isbn) not in (10, 13):
isbn_raw = request.GET.get('isbn', '').strip()
title = request.GET.get('title', '').strip()
author = request.GET.get('author', '').strip()
isbn = re.sub(r'[^0-9Xx]', '', isbn_raw)
if isbn_raw and len(isbn) not in (10, 13):
return JsonResponse({'error': 'invalid ISBN'}, status=400)
if not isbn and not title:
return JsonResponse({'error': 'isbn or title required'}, status=400)
label = None
source = None
try:
label = _lookup_dnb_shelf(isbn)
source = 'dnb' if label else None
except Exception:
pass
if not label:
# ISBN is the precise path — try it first when we have one.
if isbn:
try:
label = _lookup_openlibrary_shelf(isbn)
source = 'openlibrary' if label else None
label = _lookup_dnb_shelf(isbn)
if label:
source = 'dnb'
except Exception:
pass
if not label:
try:
label = _lookup_openlibrary_shelf(isbn)
if label:
source = 'openlibrary'
except Exception:
pass
# No ISBN (or it drew a blank) — fall back to a title/author text search. Less
# precise (wrong edition/translation is possible), so the source is tagged
# distinctly for the client to hint at that if it wants to.
if not label and title:
try:
label = _lookup_dnb_shelf_by_title(title, author)
if label:
source = 'dnb-title'
except Exception:
pass
if not label:
try:
label = _lookup_openlibrary_shelf_by_title(title, author)
if label:
source = 'openlibrary-title'
except Exception:
pass
return JsonResponse({'label': label, 'source': source})

View file

@ -3647,40 +3647,66 @@ async function _extractIsbnFromBookFile(bookId) {
}
}
async function _fetchShelfLookup(isbn, title, author) {
const params = new URLSearchParams();
if (isbn) params.set('isbn', isbn);
if (title) params.set('title', title);
if (author) params.set('author', author);
const res = await fetch(`/books/metadata-lookup/?${params.toString()}`);
return res.json();
}
// Manually triggered (never automatic) — looks up which library shelf/DDC category this
// book falls under via the server-side DNB→Open Library proxy, purely as an organizational
// hint (shown as a badge). See books/views.py:lookup_book_metadata for why this one call is
// allowed to briefly touch the server with a plaintext ISBN.
// allowed to briefly touch the server with a plaintext ISBN/title/author.
//
// Fallback chain when there's no ISBN (older upload, or a book that never had one — e.g.
// every PDF, since pdf.js doesn't expose one): (1) extract ISBN from the file itself, for
// EPUBs, (2) server-side title/author text search (less precise — could match the wrong
// edition/translation), (3) if that also comes up empty, ask the user to type in an ISBN
// by hand (from the book's cover/an online store) and retry once.
async function lookupBookMetadata(bookId) {
const book = _lastBookListData.find(b => b.id === bookId);
if (!book) return;
const cached = bookMetaCache[bookId] || {};
let isbn = cached.isbn || book.isbn || '';
const isPdf = (cached.type || book.type) === 'pdf';
const title = cached.title || book.title || '';
const author = cached.author || book.author || '';
if (!isbn) {
if (isPdf) {
await customAlert('Keine ISBN gespeichert — bei PDFs wird sie nicht automatisch erkannt.');
return;
}
if (!isbn && !isPdf) {
// Older upload without a stored ISBN — extract it from the book file itself (may take
// a moment for large books, since it downloads/decrypts the full file if not cached).
isbn = await _extractIsbnFromBookFile(bookId);
if (!isbn) {
await customAlert('Keine ISBN in diesem Buch gefunden.');
return;
}
}
try {
const res = await fetch(`/books/metadata-lookup/?isbn=${encodeURIComponent(isbn)}`);
const data = await res.json();
// Persist the (possibly newly-found) ISBN either way, so a repeat lookup never needs
// to re-download/decrypt the book file again.
await _updateBookMeta(bookId, data.label ? {isbn, shelfTag: data.label} : {isbn});
renderBookList(_lastBookListData);
let data = await _fetchShelfLookup(isbn, title, author);
if (!data.label) {
await customAlert('Keine Regal-Kategorie gefunden (weder bei der DNB noch bei Open Library). Die ISBN wurde für spätere Versuche gespeichert.');
const manual = await customPrompt(
'Keine Regal-Kategorie per ISBN/Titel/Autor gefunden. ISBN manuell eingeben (z. B. vom Buchrücken oder Online-Shop), oder leer lassen zum Abbrechen:', ''
);
const manualIsbn = (manual || '').replace(/[^0-9Xx]/g, '');
if (manualIsbn) {
isbn = manualIsbn;
data = await _fetchShelfLookup(isbn, title, author);
}
}
// Persist whatever we ended up with either way, so a repeat lookup never needs to
// re-extract/re-search from scratch.
const overrides = {};
if (isbn) overrides.isbn = isbn;
if (data.label) overrides.shelfTag = data.label;
if (Object.keys(overrides).length) {
await _updateBookMeta(bookId, overrides);
renderBookList(_lastBookListData);
}
if (!data.label) {
await customAlert('Keine Regal-Kategorie gefunden (weder DNB noch Open Library, per ISBN oder Titel/Autor).');
}
} catch (e) {
await customAlert('Metadaten-Abruf fehlgeschlagen: ' + e.message);

View file

@ -2,7 +2,7 @@
* diora service worker caches the app shell for offline use.
*/
const CACHE = 'diora-v38';
const CACHE = 'diora-v39';
const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [
'/static/css/app.css',