fix(opds): show 'Open & Read' for publications already in the library (#4313)
* 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 <dc:identifier>. The only work
identifier lives in <atom:id>urn:gutenberg:1342:2</atom:id>, 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
'<author><name>Austen, Jane</name>' (Lastname-first) while the EPUB
inside the same feed ships <dc:creator>Jane Austen</dc:creator>.
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 <chrox.huang@gmail.com>
This commit is contained in:
@@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
@@ -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: () => <div data-testid='cached-image' />,
|
||||
}));
|
||||
|
||||
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(
|
||||
<DropdownProvider>
|
||||
<PublicationView
|
||||
publication={publication}
|
||||
baseURL='https://calibre.example.com/opds'
|
||||
existingBook={existingBook}
|
||||
resolveURL={(href, base) => new URL(href, base).toString()}
|
||||
onDownload={onDownload}
|
||||
onGenerateCachedImageUrl={vi.fn(async (url: string) => url)}
|
||||
/>
|
||||
</DropdownProvider>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<Book | null>(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<Book | null>(existingBook ?? null);
|
||||
const [progress, setProgress] = useState<number | null>(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 (
|
||||
<div key={rel} className='flex gap-1'>
|
||||
{validLinks.length === 1 || downloadedBook ? (
|
||||
{downloadedBook ? (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
handleActionButton(validLinks[0]!.href!, validLinks[0]!.type)
|
||||
}
|
||||
disabled={downloading}
|
||||
className='btn btn-primary btn-success min-w-20 rounded-3xl'
|
||||
>
|
||||
{_('Open & Read')}
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
handleActionButton(validLinks[0]!.href!, validLinks[0]!.type, true)
|
||||
}
|
||||
disabled={downloading}
|
||||
className='btn btn-primary min-w-20 rounded-3xl'
|
||||
>
|
||||
{_('Download Again')}
|
||||
</button>
|
||||
</>
|
||||
) : validLinks.length === 1 ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
handleActionButton(validLinks[0]!.href!, validLinks[0]!.type)
|
||||
}
|
||||
disabled={downloading}
|
||||
className={clsx(
|
||||
'btn btn-primary min-w-20 rounded-3xl',
|
||||
downloadedBook && 'btn-success',
|
||||
)}
|
||||
className='btn btn-primary min-w-20 rounded-3xl'
|
||||
>
|
||||
{downloadedBook ? _('Open & Read') : getAcquisitionLabel(rel)}
|
||||
{getAcquisitionLabel(rel)}
|
||||
</button>
|
||||
) : (
|
||||
<Dropdown
|
||||
label={_('Download')}
|
||||
className='dropdown-bottom dropdown-center flex justify-center'
|
||||
buttonClassName={clsx(
|
||||
'btn btn-primary min-w-20 rounded-3xl p-0 bg-primary hover:bg-primary',
|
||||
downloadedBook && 'btn-success',
|
||||
)}
|
||||
buttonClassName='btn btn-primary min-w-20 rounded-3xl p-0 bg-primary hover:bg-primary'
|
||||
disabled={downloading}
|
||||
toggleButton={
|
||||
<div>{downloadedBook ? _('Open') : getAcquisitionLabel(rel)}</div>
|
||||
}
|
||||
toggleButton={<div>{getAcquisitionLabel(rel)}</div>}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useLibrary } from '@/hooks/useLibrary';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { navigateToReader } from '@/utils/nav';
|
||||
import { getFileExtFromMimeType } from '@/libs/document';
|
||||
import { OPDSFeed, OPDSPublication, OPDSSearch } from '@/types/opds';
|
||||
import { OPDSFeed, OPDSPublication, OPDSSearch, REL } from '@/types/opds';
|
||||
import {
|
||||
getFileExtFromPath,
|
||||
isSearchLink,
|
||||
@@ -41,13 +41,16 @@ import {
|
||||
} from './utils/opdsReq';
|
||||
import { ImportError } from '@/services/errors';
|
||||
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
|
||||
import { findBookByOPDSSources, upsertOPDSSourceMapping } from '@/services/opds/sourceMap';
|
||||
import { buildPseStreamFileName } from '@/services/opds/pseStream';
|
||||
import type { Book } from '@/types/book';
|
||||
import { FeedView } from './components/FeedView';
|
||||
import { PublicationView } from './components/PublicationView';
|
||||
import { SearchView } from './components/SearchView';
|
||||
import { Navigation } from './components/Navigation';
|
||||
import { normalizeOPDSCustomHeaders } from './utils/customHeaders';
|
||||
import { closeOPDSBrowser, stashOPDSReturnTarget } from './utils/opdsClose';
|
||||
import { findExistingBookForPublication } from './utils/findExistingBook';
|
||||
|
||||
type ViewMode = 'feed' | 'publication' | 'search' | 'loading' | 'error';
|
||||
|
||||
@@ -73,6 +76,10 @@ export default function BrowserPage() {
|
||||
const { appService } = useEnv();
|
||||
const { user } = useAuth();
|
||||
const { libraryLoaded } = useLibrary();
|
||||
// Subscribe to library so the publication detail page can detect copies
|
||||
// already imported (shown as "Open & Read" instead of "Download"), and
|
||||
// re-evaluate whenever a download finishes or a book is removed.
|
||||
const library = useLibraryStore((s) => s.library);
|
||||
const { safeAreaInsets, isRoundedWindow } = useThemeStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('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<Book | null>(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 (
|
||||
<div
|
||||
@@ -841,6 +892,7 @@ export default function BrowserPage() {
|
||||
<PublicationView
|
||||
publication={publication}
|
||||
baseURL={state.baseURL}
|
||||
existingBook={existingBookForPublication}
|
||||
onDownload={handleDownload}
|
||||
onStream={handleStream}
|
||||
resolveURL={resolveURL}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Book } from '@/types/book';
|
||||
import type { OPDSPublication } from '@/types/opds';
|
||||
|
||||
const normalizeTitle = (title: string | undefined): string =>
|
||||
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
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,19 @@ import { MigrationEntry, SchemaType } from '../migrate';
|
||||
* 2. Add a new key here with its migration array.
|
||||
*/
|
||||
const migrations: Record<SchemaType, MigrationEntry[]> = {
|
||||
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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<void> => {
|
||||
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<Book | null> => {
|
||||
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();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user