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(); + } +};