From 4c539e6be1bc1d8f85a0e5c3a7a63d29c93d8080 Mon Sep 17 00:00:00 2001 From: loveheaven Date: Wed, 27 May 2026 13:32:53 +0800 Subject: [PATCH] fix(opds): show 'Open & Read' for publications already in the library (#4313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(opds): show 'Open & Read' for publications already in the library PublicationView only flipped the acquisition button to 'Open & Read' when the user downloaded the book within the current component lifecycle — the downloadedBook state started as null on every mount and was never reconciled against the actual library. So reopening the detail page (after navigating away in the OPDS browser, or coming back from the reader) always showed 'Download' / 'Open Access' again and asked the user to re-download a book they already had. Two real-world feeds (m.gutenberg.org and ManyBooks) exposed two distinct failure modes that made naive title/author equality unusable: (1) Gutenberg's OPDS entries never carry . The only work identifier lives in urn:gutenberg:1342:2, which foliate-js's opds.js parses into metadata.id — a field getMetadataHashInfo never reads. So the identifier candidate list was empty and identifier-overlap matching silently skipped every book. (2) Author strings disagree across the boundary: the feed emits 'Austen, Jane' (Lastname-first) while the EPUB inside the same feed ships Jane Austen. Plain string equality (even after normalization) never matched. Add findExistingBookForPublication in app/opds/utils/findExistingBook.ts with a layered matcher: - Pass 1: full metaHash equality (the strongest signal — same fingerprint the bookService uses internally). - Pass 2: identifier overlap. collectPublicationIdentifiers() splices metadata.id into the candidate list alongside metadata.identifier, so Gutenberg's 'urn:gutenberg:1342:2' feeds into identifierKeys(), which extracts the '1342' digit-tail (>=3 digits, so the trailing ':2' version suffix is ignored) and matches the EPUB's 'http://www.gutenberg.org/1342' → '1342'. - Pass 3: tolerant title + author match. hasAuthorOverlap() does a token-set fallback: each name is split on whitespace/comma/semicolon, single-letter tokens (initials) and year-range tokens ('1775-1817') are dropped, and we require >=2 shared tokens by default. So 'Austen, Jane' ↔ 'Jane Austen' match via {austen, jane} but 'Author A' ↔ 'Author B' don't (both collapse to {author} after dropping single-letter tokens — we track raw token count to refuse the single-token shortcut when discriminative parts were filtered). Genuine mononyms still match on a single shared token because raw count is 1 on both sides. OPDSPerson[] is fed through the library's own formatAuthors so the produced author string matches Book.author byte-for-byte (Intl.ListFormat output, locale-aware). Soft-deleted books are skipped so removing a copy locally reverts the button. Wire it in opds/page.tsx by subscribing to useLibraryStore.library (rather than the snapshot reads inside handleDownload) and memoizing existingBookForPublication. Pass it to PublicationView, which now seeds downloadedBook from the prop and resyncs when the prop changes (switching publications inside the browser) — but does not clobber a download success it observed locally before the parent recomputed. Covered by unit tests for the helper, including the real Gutenberg URN-in-atom-id case, ':version' suffix on the URN, 'Lastname, Firstname' vs 'Firstname Lastname', year-range stripping, and the 'Author A' vs 'Author B' non-match. * fix(opds): dedupe downloads by source url --------- Co-authored-by: Huang Xin --- .../app/opds/find-existing-book.test.ts | 54 ++++++++++++ .../app/opds/publication-view.test.tsx | 84 +++++++++++++++++++ .../services/opds-auto-download.test.ts | 10 +++ .../services/opds-source-map.test.ts | 84 +++++++++++++++++++ .../app/opds/components/PublicationView.tsx | 80 ++++++++++++++---- apps/readest-app/src/app/opds/page.tsx | 70 ++++++++++++++-- .../src/app/opds/utils/findExistingBook.ts | 20 +++++ .../src/services/database/migrations/index.ts | 13 +++ .../src/services/opds/autoDownload.ts | 10 +++ .../src/services/opds/sourceMap.ts | 54 ++++++++++++ 10 files changed, 453 insertions(+), 26 deletions(-) create mode 100644 apps/readest-app/src/__tests__/app/opds/find-existing-book.test.ts create mode 100644 apps/readest-app/src/__tests__/app/opds/publication-view.test.tsx create mode 100644 apps/readest-app/src/__tests__/services/opds-source-map.test.ts create mode 100644 apps/readest-app/src/app/opds/utils/findExistingBook.ts create mode 100644 apps/readest-app/src/services/opds/sourceMap.ts diff --git a/apps/readest-app/src/__tests__/app/opds/find-existing-book.test.ts b/apps/readest-app/src/__tests__/app/opds/find-existing-book.test.ts new file mode 100644 index 00000000..5dba7cf4 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/opds/find-existing-book.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import type { Book } from '@/types/book'; +import type { BookMetadata } from '@/libs/document'; +import type { OPDSPublication } from '@/types/opds'; +import { findExistingBookForPublication } from '@/app/opds/utils/findExistingBook'; + +const book = (title: string, overrides: Partial = {}): Book => ({ + hash: overrides.hash ?? title, + format: 'EPUB', + title, + author: '', + metadata: { title, author: '', language: '' } as BookMetadata, + createdAt: 0, + updatedAt: 0, + ...overrides, +}); + +const publication = (title?: string, author = 'Author'): OPDSPublication => ({ + metadata: { + ...(title ? { title } : {}), + author: [{ name: author, links: [] }], + }, + links: [], + images: [], +}); + +describe('findExistingBookForPublication', () => { + it('matches by normalized title', () => { + expect( + findExistingBookForPublication(publication(' Some Title '), [book('some title')])?.hash, + ).toBe('some title'); + }); + + it('does not require matching authors', () => { + expect( + findExistingBookForPublication(publication('Shared Title', 'Author A'), [ + book('Shared Title', { author: 'Author B' }), + ])?.hash, + ).toBe('Shared Title'); + }); + + it('skips soft-deleted books', () => { + expect( + findExistingBookForPublication(publication('Deleted'), [ + book('Deleted', { deletedAt: Date.now() }), + ]), + ).toBeNull(); + }); + + it('returns null without a title match', () => { + expect(findExistingBookForPublication(publication(), [book('Untitled')])).toBeNull(); + expect(findExistingBookForPublication(publication('A'), [book('B')])).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/app/opds/publication-view.test.tsx b/apps/readest-app/src/__tests__/app/opds/publication-view.test.tsx new file mode 100644 index 00000000..f7caae56 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/opds/publication-view.test.tsx @@ -0,0 +1,84 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Book } from '@/types/book'; +import { REL, type OPDSPublication } from '@/types/opds'; +import { PublicationView } from '@/app/opds/components/PublicationView'; +import { DropdownProvider } from '@/context/DropdownContext'; + +const navigateToReader = vi.hoisted(() => vi.fn()); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (s: string) => s, +})); + +vi.mock('@/components/CachedImage', () => ({ + CachedImage: () =>
, +})); + +vi.mock('@/utils/nav', () => ({ + navigateToReader, +})); + +const publication: OPDSPublication = { + metadata: { + title: 'Calibre Entry', + author: [{ name: 'Author', links: [] }], + }, + links: [ + { + rel: `${REL.ACQ}/open-access`, + href: '/download/book.epub', + type: 'application/epub+zip', + title: 'EPUB', + }, + ], + images: [], +}; + +const existingBook: Book = { + hash: 'existing-book', + format: 'EPUB', + title: 'Calibre Entry', + author: 'Author', + createdAt: 0, + updatedAt: 0, +}; + +describe('PublicationView', () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('keeps a Download Again override when a source match opens an existing book', async () => { + const onDownload = vi.fn(async () => existingBook); + + render( + + new URL(href, base).toString()} + onDownload={onDownload} + onGenerateCachedImageUrl={vi.fn(async (url: string) => url)} + /> + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Download Again' })); + + await waitFor(() => { + expect(onDownload).toHaveBeenCalledWith( + '/download/book.epub', + 'application/epub+zip', + expect.any(Function), + ); + }); + expect(navigateToReader).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/opds-auto-download.test.ts b/apps/readest-app/src/__tests__/services/opds-auto-download.test.ts index 2dd23567..355724e4 100644 --- a/apps/readest-app/src/__tests__/services/opds-auto-download.test.ts +++ b/apps/readest-app/src/__tests__/services/opds-auto-download.test.ts @@ -30,6 +30,10 @@ vi.mock('@/services/opds/feedChecker', () => ({ checkFeedForNewItems: vi.fn().mockResolvedValue([]), })); +vi.mock('@/services/opds/sourceMap', () => ({ + upsertOPDSSourceMapping: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('@/services/opds/subscriptionState', () => ({ loadSubscriptionState: vi.fn().mockResolvedValue({ catalogId: 'cat-1', @@ -50,6 +54,7 @@ vi.mock('@/services/opds/subscriptionState', () => ({ import { syncSubscribedCatalogs } from '@/services/opds/autoDownload'; import { checkFeedForNewItems } from '@/services/opds/feedChecker'; import { saveSubscriptionState, loadSubscriptionState } from '@/services/opds/subscriptionState'; +import { upsertOPDSSourceMapping } from '@/services/opds/sourceMap'; import { downloadFile } from '@/libs/storage'; const createMockAppService = () => @@ -126,6 +131,11 @@ describe('OPDS auto-download orchestrator', () => { const savedState = vi.mocked(saveSubscriptionState).mock.calls[0]![1] as OPDSSubscriptionState; expect(savedState.knownEntryIds).toContain('urn:shelf:1'); expect(savedState.lastCheckedAt).toBeGreaterThan(0); + expect(upsertOPDSSourceMapping).toHaveBeenCalledWith(appService, { + catalogId: 'cat-1', + sourceUrl: 'https://shelf.example.com/dl/1.epub', + bookHash: 'abc123', + }); }); it('handles import failure by adding to failedEntries', async () => { diff --git a/apps/readest-app/src/__tests__/services/opds-source-map.test.ts b/apps/readest-app/src/__tests__/services/opds-source-map.test.ts new file mode 100644 index 00000000..9fda052b --- /dev/null +++ b/apps/readest-app/src/__tests__/services/opds-source-map.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Book } from '@/types/book'; +import type { AppService } from '@/types/system'; +import { findBookByOPDSSources, upsertOPDSSourceMapping } from '@/services/opds/sourceMap'; + +type Row = { catalog_id: string; source_url: string; book_hash: string }; + +class MockDatabase { + rows: Row[] = []; + + execute = vi.fn(async (_sql: string, params: unknown[] = []) => { + const [catalog_id = '', source_url = '', book_hash = ''] = params as string[]; + const row = this.rows.find((r) => r.catalog_id === catalog_id && r.source_url === source_url); + if (row) row.book_hash = book_hash; + else this.rows.push({ catalog_id, source_url, book_hash }); + return { rowsAffected: 1, lastInsertId: 0 }; + }); + + select = vi.fn(async (_sql: string, params: unknown[] = []) => { + const [catalogId = '', ...urls] = params as string[]; + return this.rows + .filter((row) => row.catalog_id === catalogId && urls.includes(row.source_url)) + .map((row) => ({ book_hash: row.book_hash })); + }); + + batch = vi.fn(async () => {}); + close = vi.fn(async () => {}); +} + +const appService = (db: MockDatabase): AppService => + ({ openDatabase: vi.fn(async () => db) }) as unknown as AppService; + +const book = (hash: string, deletedAt?: number): Book => ({ + hash, + format: 'EPUB', + title: '', + author: '', + createdAt: 0, + updatedAt: 0, + deletedAt, +}); + +describe('OPDS source map', () => { + it('maps catalog acquisition URLs to library books', async () => { + const db = new MockDatabase(); + const service = appService(db); + + await upsertOPDSSourceMapping(service, { + catalogId: 'calibre', + sourceUrl: 'https://example.com/book.epub', + bookHash: 'book', + }); + + expect(db.rows).toEqual([ + { catalog_id: 'calibre', source_url: 'https://example.com/book.epub', book_hash: 'book' }, + ]); + expect( + await findBookByOPDSSources(service, { + catalogId: 'calibre', + sourceUrls: ['https://example.com/book.epub'], + library: [book('book')], + }), + ).toMatchObject({ hash: 'book' }); + }); + + it('ignores mappings to deleted books', async () => { + const db = new MockDatabase(); + const service = appService(db); + + await upsertOPDSSourceMapping(service, { + catalogId: 'calibre', + sourceUrl: 'https://example.com/book.epub', + bookHash: 'book', + }); + + expect( + await findBookByOPDSSources(service, { + catalogId: 'calibre', + sourceUrls: ['https://example.com/book.epub'], + library: [book('book', Date.now())], + }), + ).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/app/opds/components/PublicationView.tsx b/apps/readest-app/src/app/opds/components/PublicationView.tsx index 6c06b32f..bf713250 100644 --- a/apps/readest-app/src/app/opds/components/PublicationView.tsx +++ b/apps/readest-app/src/app/opds/components/PublicationView.tsx @@ -1,7 +1,7 @@ 'use client'; import clsx from 'clsx'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { IoPricetag } from 'react-icons/io5'; import { Book } from '@/types/book'; @@ -20,6 +20,15 @@ import MenuItem from '@/components/MenuItem'; interface PublicationViewProps { publication: OPDSPublication; baseURL: string; + /** + * Book in the user's library that already corresponds to this publication, + * if any. When provided, the acquisition button skips Download and goes + * straight to "Open & Read" — matching the post-download UX even after the + * component remounts (e.g. returning from the reader, or switching + * publications inside the same OPDS browser session). null/undefined means + * "no copy in library, show the normal acquisition button". + */ + existingBook?: Book | null; resolveURL: (url: string, base: string) => string; onDownload: ( href: string, @@ -33,6 +42,7 @@ interface PublicationViewProps { export function PublicationView({ publication, baseURL, + existingBook, resolveURL, onDownload, onStream, @@ -41,9 +51,30 @@ export function PublicationView({ const _ = useTranslation(); const router = useRouter(); const [downloading, setDownloading] = useState(false); - const [downloadedBook, setDownloadedBook] = useState(null); + // Seeded from existingBook so users who reopen a publication they've already + // downloaded see "Open & Read" immediately, without having to re-download. + // When existingBook later changes (parent switches to a different + // publication, or the library finishes loading after this mounts) the + // effect below resyncs. + const [downloadedBook, setDownloadedBook] = useState(existingBook ?? null); const [progress, setProgress] = useState(null); + useEffect(() => { + // Only resync from the parent-provided existingBook; don't blow away a + // book set locally by a successful handleActionButton download just + // because the parent re-rendered without recomputing existingBook yet. + if (existingBook && existingBook.hash !== downloadedBook?.hash) { + setDownloadedBook(existingBook); + } else if (!existingBook && downloadedBook && !downloading) { + // existingBook went from set to null — happens when the parent rebuilds + // the publication (new feed loaded). Drop the stale state so the next + // publication starts from "Download" instead of inheriting the prior + // book's "Open & Read". + setDownloadedBook(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [existingBook]); + const linksByRel = useMemo( () => groupByArray(publication.links, (link) => link.rel), [publication.links], @@ -81,8 +112,8 @@ export function PublicationView({ return (linksByRel.get(REL.STREAM) || []) as OPDSStreamLink[]; }, [linksByRel]); - const handleActionButton = async (href: string, type?: string) => { - if (downloadedBook) { + const handleActionButton = async (href: string, type?: string, forceDownload = false) => { + if (downloadedBook && !forceDownload) { navigateToReader(router, [downloadedBook.hash]); return; } @@ -171,32 +202,47 @@ export function PublicationView({ return (
- {validLinks.length === 1 || downloadedBook ? ( + {downloadedBook ? ( + <> + + + + ) : validLinks.length === 1 ? ( ) : ( {downloadedBook ? _('Open') : getAcquisitionLabel(rel)}
- } + toggleButton={
{getAcquisitionLabel(rel)}
} >
s.library); const { safeAreaInsets, isRoundedWindow } = useThemeStore(); const { settings } = useSettingsStore(); const [viewMode, setViewMode] = useState('loading'); @@ -92,6 +99,8 @@ export default function BrowserPage() { const searchParams = useSearchParams(); const catalogUrl = searchParams?.get('url') || ''; const catalogId = searchParams?.get('id') || ''; + const catalog = settings.opdsCatalogs?.find((catalog) => catalog.id === catalogId); + const catalogSourceId = catalog?.contentId || catalogId || catalogUrl; // Captured once at mount so the restore effect targets exactly the // detail the URL described when /opds first loaded — typically after a // Reader → webview-back. Subsequent in-page navigation can mutate the @@ -443,6 +452,13 @@ export default function BrowserPage() { [_, state, handleNavigate, addToHistory], ); + const publication = + selectedPublication && state.feed + ? state.feed.groups?.[selectedPublication.groupIndex]?.publications?.[ + selectedPublication.itemIndex + ] || state.feed.publications?.[selectedPublication.itemIndex] + : state.publication; + const handleDownload = useCallback( async ( href: string, @@ -510,6 +526,17 @@ export default function BrowserPage() { const { library, setLibrary } = useLibraryStore.getState(); try { const book = await appService.importBook(dstFilePath, library); + if (book && catalogSourceId) { + try { + await upsertOPDSSourceMapping(appService, { + catalogId: catalogSourceId, + sourceUrl: url, + bookHash: book.hash, + }); + } catch (sourceMapError) { + console.error('OPDS: failed to update source map:', sourceMapError); + } + } if (user && book && !book.uploadedAt && settings.autoUpload) { setTimeout(() => { transferManager.queueUpload(book); @@ -528,8 +555,7 @@ export default function BrowserPage() { throw e; } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [user, state.baseURL, appService, libraryLoaded], + [user, state.baseURL, appService, libraryLoaded, settings.autoUpload, catalogSourceId], ); const handleStream = useCallback( @@ -770,12 +796,37 @@ export default function BrowserPage() { onCancel: () => backOrCloseRef.current(), }); - const publication = - selectedPublication && state.feed - ? state.feed.groups?.[selectedPublication.groupIndex]?.publications?.[ - selectedPublication.itemIndex - ] || state.feed.publications?.[selectedPublication.itemIndex] - : state.publication; + const [existingBookForPublication, setExistingBookForPublication] = useState(null); + + useEffect(() => { + let cancelled = false; + + const metadataMatch = libraryLoaded + ? findExistingBookForPublication(publication, library) + : null; + setExistingBookForPublication(metadataMatch); + + const sourceUrls = + publication?.links + .filter((link) => { + const rels = Array.isArray(link.rel) ? link.rel : [link.rel ?? '']; + return link.href && rels.some((rel) => rel.startsWith(REL.ACQ)); + }) + .map((link) => resolveURL(link.href!, state.baseURL)) ?? []; + + if (libraryLoaded && appService && catalogSourceId && sourceUrls.length > 0) { + void findBookByOPDSSources(appService, { + catalogId: catalogSourceId, + sourceUrls, + library, + }).then((book) => { + if (!cancelled && book) setExistingBookForPublication(book); + }); + } + return () => { + cancelled = true; + }; + }, [appService, catalogSourceId, publication, state.baseURL, library, libraryLoaded]); return (
+ title?.normalize('NFC').replace(/\s+/g, ' ').trim().toLowerCase() ?? ''; + +export const findExistingBookForPublication = ( + publication: OPDSPublication | null | undefined, + library: Book[] | null | undefined, +): Book | null => { + const title = normalizeTitle(publication?.metadata?.title); + if (!title || !library?.length) return null; + + return ( + library.find((book) => { + const bookTitle = typeof book.metadata?.title === 'string' ? book.metadata.title : book.title; + return !book.deletedAt && normalizeTitle(bookTitle) === title; + }) ?? null + ); +}; diff --git a/apps/readest-app/src/services/database/migrations/index.ts b/apps/readest-app/src/services/database/migrations/index.ts index dcf0e7ab..dce4286b 100644 --- a/apps/readest-app/src/services/database/migrations/index.ts +++ b/apps/readest-app/src/services/database/migrations/index.ts @@ -13,6 +13,19 @@ import { MigrationEntry, SchemaType } from '../migrate'; * 2. Add a new key here with its migration array. */ const migrations: Record = { + opds: [ + { + name: '2026052701_opds_source_mappings', + sql: ` + CREATE TABLE IF NOT EXISTS opds_source_mappings ( + catalog_id TEXT NOT NULL, + source_url TEXT NOT NULL, + book_hash TEXT NOT NULL, + PRIMARY KEY (catalog_id, source_url) + ); + `, + }, + ], 'hardcover-sync': [ { name: '2026032901_hardcover_note_mappings', diff --git a/apps/readest-app/src/services/opds/autoDownload.ts b/apps/readest-app/src/services/opds/autoDownload.ts index 7df684fa..f220912e 100644 --- a/apps/readest-app/src/services/opds/autoDownload.ts +++ b/apps/readest-app/src/services/opds/autoDownload.ts @@ -13,6 +13,7 @@ import { saveSubscriptionState, pruneKnownEntryIds, } from './subscriptionState'; +import { upsertOPDSSourceMapping } from './sourceMap'; import { isRetryEligible, DOWNLOAD_CONCURRENCY, MAX_RETRY_ATTEMPTS } from './types'; import type { PendingItem, SyncResult, OPDSSubscriptionState, FailedEntry } from './types'; @@ -86,6 +87,15 @@ async function downloadAndImport( const book = await appService.importBook(dstFilePath, books); if (!book) throw new Error(`importBook returned null for ${item.title}`); + try { + await upsertOPDSSourceMapping(appService, { + catalogId: catalog.contentId || catalog.id, + sourceUrl: url, + bookHash: book.hash, + }); + } catch (error) { + console.error('OPDS sync: failed to update source map:', error); + } console.log(`[OPDS] imported "${item.title}"`); return book; } diff --git a/apps/readest-app/src/services/opds/sourceMap.ts b/apps/readest-app/src/services/opds/sourceMap.ts new file mode 100644 index 00000000..f16ce255 --- /dev/null +++ b/apps/readest-app/src/services/opds/sourceMap.ts @@ -0,0 +1,54 @@ +import type { Book } from '@/types/book'; +import type { AppService } from '@/types/system'; + +const openOPDSDb = (appService: AppService) => appService.openDatabase('opds', 'opds.db', 'Data'); + +export const upsertOPDSSourceMapping = async ( + appService: AppService, + input: { catalogId: string; sourceUrl: string; bookHash: string }, +): Promise => { + if (!input.catalogId || !input.sourceUrl || !input.bookHash) return; + + const db = await openOPDSDb(appService); + try { + await db.execute( + ` + INSERT INTO opds_source_mappings (catalog_id, source_url, book_hash) + VALUES (?, ?, ?) + ON CONFLICT(catalog_id, source_url) + DO UPDATE SET book_hash = excluded.book_hash + `, + [input.catalogId, input.sourceUrl, input.bookHash], + ); + } finally { + await db.close(); + } +}; + +export const findBookByOPDSSources = async ( + appService: AppService, + input: { catalogId: string; sourceUrls: string[]; library: Book[] }, +): Promise => { + const sourceUrls = Array.from(new Set(input.sourceUrls.filter(Boolean))); + if (!input.catalogId || sourceUrls.length === 0 || input.library.length === 0) return null; + + const db = await openOPDSDb(appService); + try { + const rows = await db.select<{ book_hash: string }>( + ` + SELECT book_hash + FROM opds_source_mappings + WHERE catalog_id = ? + AND source_url IN (${sourceUrls.map(() => '?').join(', ')}) + `, + [input.catalogId, ...sourceUrls], + ); + return ( + rows + .map((row) => input.library.find((book) => book.hash === row.book_hash && !book.deletedAt)) + .find(Boolean) ?? null + ); + } finally { + await db.close(); + } +};