forked from akai/readest
feat(opds): add OPDS-PSE streaming support and custom OPDS 2.0 parser (#3951)
* feat(opds): add OPDS-PSE streaming support and custom OPDS 2.0 parser * refactor(opds): remove custom parser, use updated foliate-js dependency * fix(opds): resolve PSE auth at fetch time, drop credentials from book.url Previously the streaming `pse://` virtual file baked the proxy URL with the basic auth header into `book.url`, which (a) failed on desktop where no proxy is used because the auth header was never applied to the page fetch, and (b) leaked the credential to the sync server because transient books still get pushed on first sync. Now the `pse://` payload stores only the upstream OPDS template URL plus the catalog id. A new `createPseStreamPageLoader` looks up the catalog from settings on each open, probes auth once (cached for the session), and applies the auth header via `tauriFetch` on desktop or via the proxy URL on web. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,7 +52,7 @@ import {
|
||||
MIME,
|
||||
validateOPDSURL,
|
||||
} from '@/app/opds/utils/opdsUtils';
|
||||
import type { OPDSLink } from '@/types/opds';
|
||||
import type { OPDSBaseLink } from '@/types/opds';
|
||||
import { fetchWithAuth } from '@/app/opds/utils/opdsReq';
|
||||
|
||||
const mockFetchWithAuth = vi.mocked(fetchWithAuth);
|
||||
@@ -195,70 +195,63 @@ describe('opdsUtils', () => {
|
||||
|
||||
describe('isSearchLink', () => {
|
||||
it('should return true for a search link with OPENSEARCH type', () => {
|
||||
const link: OPDSLink = {
|
||||
rel: 'search',
|
||||
const link: OPDSBaseLink = {
|
||||
rel: ['search'],
|
||||
href: '/search',
|
||||
type: MIME.OPENSEARCH,
|
||||
properties: {},
|
||||
};
|
||||
expect(isSearchLink(link)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for a search link with ATOM type', () => {
|
||||
const link: OPDSLink = {
|
||||
rel: 'search',
|
||||
const link: OPDSBaseLink = {
|
||||
rel: ['search'],
|
||||
href: '/search',
|
||||
type: MIME.ATOM,
|
||||
properties: {},
|
||||
};
|
||||
expect(isSearchLink(link)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when rel is an array containing "search"', () => {
|
||||
const link: OPDSLink = {
|
||||
const link: OPDSBaseLink = {
|
||||
rel: ['self', 'search'],
|
||||
href: '/search',
|
||||
type: MIME.OPENSEARCH,
|
||||
properties: {},
|
||||
};
|
||||
expect(isSearchLink(link)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when rel does not include "search"', () => {
|
||||
const link: OPDSLink = {
|
||||
rel: 'self',
|
||||
const link: OPDSBaseLink = {
|
||||
rel: ['self'],
|
||||
href: '/search',
|
||||
type: MIME.OPENSEARCH,
|
||||
properties: {},
|
||||
};
|
||||
expect(isSearchLink(link)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when type is not OPENSEARCH or ATOM', () => {
|
||||
const link: OPDSLink = {
|
||||
rel: 'search',
|
||||
const link: OPDSBaseLink = {
|
||||
rel: ['search'],
|
||||
href: '/search',
|
||||
type: 'text/html',
|
||||
properties: {},
|
||||
};
|
||||
expect(isSearchLink(link)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when rel is undefined', () => {
|
||||
const link: OPDSLink = {
|
||||
const link: OPDSBaseLink = {
|
||||
href: '/search',
|
||||
type: MIME.OPENSEARCH,
|
||||
properties: {},
|
||||
};
|
||||
expect(isSearchLink(link)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when rel is an empty array', () => {
|
||||
const link: OPDSLink = {
|
||||
const link: OPDSBaseLink = {
|
||||
rel: [],
|
||||
href: '/search',
|
||||
type: MIME.ATOM,
|
||||
properties: {},
|
||||
};
|
||||
expect(isSearchLink(link)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
PSE_SCHEME,
|
||||
buildPseStreamFileName,
|
||||
parsePseStreamFileName,
|
||||
isPseStreamFileName,
|
||||
} from '@/services/opds/pseStream';
|
||||
|
||||
describe('pseStream', () => {
|
||||
const sample = {
|
||||
url: 'https://library.example.com/api/v1/series/42/page/{pageNumber}',
|
||||
catalogId: 'catalog-1',
|
||||
count: 120,
|
||||
title: 'Sample Title',
|
||||
author: 'Sample Author',
|
||||
};
|
||||
|
||||
describe('buildPseStreamFileName', () => {
|
||||
it('produces a pse:// URL', () => {
|
||||
const name = buildPseStreamFileName(sample);
|
||||
expect(name.startsWith(PSE_SCHEME)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not embed auth credentials or proxy URLs', () => {
|
||||
const name = buildPseStreamFileName(sample);
|
||||
const decoded = decodeURIComponent(name.replace(PSE_SCHEME, ''));
|
||||
expect(decoded).not.toMatch(/Basic\s/i);
|
||||
expect(decoded).not.toMatch(/auth=/);
|
||||
expect(decoded).not.toMatch(/\/opds\/proxy/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePseStreamFileName', () => {
|
||||
it('round-trips through build', () => {
|
||||
const parsed = parsePseStreamFileName(buildPseStreamFileName(sample));
|
||||
expect(parsed).toEqual(sample);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPseStreamFileName', () => {
|
||||
it('recognizes pse:// URLs', () => {
|
||||
expect(isPseStreamFileName('pse://abc')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects other URLs', () => {
|
||||
expect(isPseStreamFileName('https://example.com')).toBe(false);
|
||||
expect(isPseStreamFileName('book.cbz')).toBe(false);
|
||||
expect(isPseStreamFileName('')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,11 @@ vi.mock('@/services/constants', () => ({
|
||||
vi.mock('@/libs/document', () => ({
|
||||
DocumentLoader: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/services/opds/pseStream', () => ({
|
||||
isPseStreamFileName: () => false,
|
||||
openPseStreamBook: vi.fn(),
|
||||
parsePseStreamFileName: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
|
||||
@@ -44,17 +44,17 @@ describe('OPDS feed parsing', () => {
|
||||
|
||||
it('should separate navigation and publication entries in mixed feeds', () => {
|
||||
const doc = parseXML(copypartyRootFeed);
|
||||
const feed = getFeed(doc);
|
||||
const feed = getFeed(doc) as OPDSFeed;
|
||||
|
||||
// Should have navigation items (the "Sub/" folder)
|
||||
expect(feed.navigation).toBeDefined();
|
||||
expect(feed.navigation!).toHaveLength(1);
|
||||
expect(feed.navigation![0].title).toBe('Sub/');
|
||||
expect(feed.navigation![0]!.title).toBe('Sub/');
|
||||
|
||||
// Should have publication items (the book)
|
||||
expect(feed.publications).toBeDefined();
|
||||
expect(feed.publications!).toHaveLength(1);
|
||||
expect(feed.publications![0].metadata.title).toBe(
|
||||
expect(feed.publications![0]!.metadata.title).toBe(
|
||||
'Children of the Fleet - Orson Scott Card.epub',
|
||||
);
|
||||
});
|
||||
@@ -82,15 +82,15 @@ describe('OPDS feed parsing', () => {
|
||||
</feed>`;
|
||||
|
||||
const doc = parseXML(subFeed);
|
||||
const feed = getFeed(doc);
|
||||
const feed = getFeed(doc) as OPDSFeed;
|
||||
|
||||
expect(feed.navigation).toBeUndefined();
|
||||
expect(feed.publications).toBeDefined();
|
||||
expect(feed.publications!).toHaveLength(2);
|
||||
expect(feed.publications![0].metadata.title).toBe(
|
||||
expect(feed.publications![0]!.metadata.title).toBe(
|
||||
'Children of the Fleet - Orson Scott Card.epub',
|
||||
);
|
||||
expect(feed.publications![1].metadata.title).toBe(
|
||||
expect(feed.publications![1]!.metadata.title).toBe(
|
||||
'Earth Afire - Orson Scott Card & Aaron Johnston.epub',
|
||||
);
|
||||
});
|
||||
@@ -116,22 +116,22 @@ describe('OPDS feed parsing', () => {
|
||||
</feed>`;
|
||||
|
||||
const doc = parseXML(navFeed);
|
||||
const feed = getFeed(doc);
|
||||
const feed = getFeed(doc) as OPDSFeed;
|
||||
|
||||
expect(feed.publications).toBeUndefined();
|
||||
expect(feed.navigation).toBeDefined();
|
||||
expect(feed.navigation!).toHaveLength(2);
|
||||
expect(feed.navigation![0].title).toBe('Fiction/');
|
||||
expect(feed.navigation![1].title).toBe('Non-Fiction/');
|
||||
expect(feed.navigation![0]!.title).toBe('Fiction/');
|
||||
expect(feed.navigation![1]!.title).toBe('Non-Fiction/');
|
||||
});
|
||||
|
||||
it('should parse entry id and updated fields from publications', () => {
|
||||
const doc = parseXML(copypartyRootFeed);
|
||||
const feed = getFeed(doc);
|
||||
const feed = getFeed(doc) as OPDSFeed;
|
||||
|
||||
// The book entry has an <updated> element but no <id>
|
||||
expect(feed.publications).toBeDefined();
|
||||
expect(feed.publications![0].metadata.updated).toBe('2025-11-02T17:50:21Z');
|
||||
expect(feed.publications![0]!.metadata.updated).toBe('2025-11-02T17:50:21Z');
|
||||
});
|
||||
|
||||
it('should handle navigation entry after publications', () => {
|
||||
@@ -155,15 +155,15 @@ describe('OPDS feed parsing', () => {
|
||||
</feed>`;
|
||||
|
||||
const doc = parseXML(reverseFeed);
|
||||
const feed = getFeed(doc);
|
||||
const feed = getFeed(doc) as OPDSFeed;
|
||||
|
||||
expect(feed.publications).toBeDefined();
|
||||
expect(feed.publications!).toHaveLength(1);
|
||||
expect(feed.publications![0].metadata.title).toBe('Some Book.epub');
|
||||
expect(feed.publications![0]!.metadata.title).toBe('Some Book.epub');
|
||||
|
||||
expect(feed.navigation).toBeDefined();
|
||||
expect(feed.navigation!).toHaveLength(1);
|
||||
expect(feed.navigation![0].title).toBe('Sub/');
|
||||
expect(feed.navigation![0]!.title).toBe('Sub/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,14 +195,14 @@ describe('OPDS feed parsing', () => {
|
||||
|
||||
it('should parse id and updated on publications', () => {
|
||||
const doc = parseXML(shelfFeed);
|
||||
const feed = getFeed(doc);
|
||||
const feed = getFeed(doc) as OPDSFeed;
|
||||
|
||||
expect(feed.publications).toHaveLength(2);
|
||||
expect(feed.publications![0].metadata.id).toBe('urn:shelf:issue:abc123');
|
||||
expect(feed.publications![0].metadata.updated).toBe('2026-01-15T10:30:00.000Z');
|
||||
expect(feed.publications![0].metadata.published).toBe('2026-01-14T00:00:00.000Z');
|
||||
expect(feed.publications![1].metadata.id).toBe('urn:shelf:issue:def456');
|
||||
expect(feed.publications![1].metadata.updated).toBe('2026-01-16T08:00:00.000Z');
|
||||
expect(feed.publications![0]!.metadata.id).toBe('urn:shelf:issue:abc123');
|
||||
expect(feed.publications![0]!.metadata.updated).toBe('2026-01-15T10:30:00.000Z');
|
||||
expect(feed.publications![0]!.metadata.published).toBe('2026-01-14T00:00:00.000Z');
|
||||
expect(feed.publications![1]!.metadata.id).toBe('urn:shelf:issue:def456');
|
||||
expect(feed.publications![1]!.metadata.updated).toBe('2026-01-16T08:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should parse id and updated on the feed itself', () => {
|
||||
@@ -233,11 +233,11 @@ describe('OPDS feed parsing', () => {
|
||||
</entry>
|
||||
</feed>`;
|
||||
const doc = parseXML(minimalFeed);
|
||||
const feed = getFeed(doc);
|
||||
const feed = getFeed(doc) as OPDSFeed;
|
||||
|
||||
expect(feed.publications).toHaveLength(1);
|
||||
expect(feed.publications![0].metadata.id).toBeUndefined();
|
||||
expect(feed.publications![0].metadata.updated).toBeUndefined();
|
||||
expect(feed.publications![0]!.metadata.id).toBeUndefined();
|
||||
expect(feed.publications![0]!.metadata.updated).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMemo, useCallback } from 'react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { IoChevronBack, IoChevronForward, IoFilter } from 'react-icons/io5';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { OPDSFeed, OPDSLink } from '@/types/opds';
|
||||
import { OPDSFeed, OPDSGenericLink } from '@/types/opds';
|
||||
import { PublicationCard } from './PublicationCard';
|
||||
import { NavigationCard } from './NavigationCard';
|
||||
import { groupByArray } from '../utils/opdsUtils';
|
||||
@@ -40,7 +40,7 @@ export function FeedView({
|
||||
|
||||
const hasFacets = feed.facets && feed.facets.length > 0;
|
||||
|
||||
const handlePaginationClick = (links?: OPDSLink[]) => {
|
||||
const handlePaginationClick = (links?: OPDSGenericLink[]) => {
|
||||
if (links && links.length > 0) {
|
||||
const url = resolveURL(links[0]?.href || '', baseURL);
|
||||
onNavigate(url);
|
||||
@@ -94,8 +94,8 @@ export function FeedView({
|
||||
)}
|
||||
<ul className='space-y-1'>
|
||||
{facet.links.map((link, linkIndex: number) => {
|
||||
const isActive = link.rel === 'self' || link.rel?.includes('self');
|
||||
const href = resolveURL(link.href, baseURL);
|
||||
const isActive = link.rel?.includes('self');
|
||||
const href = resolveURL(link.href || '', baseURL);
|
||||
return (
|
||||
<li key={linkIndex}>
|
||||
<button
|
||||
|
||||
@@ -42,7 +42,7 @@ export function PublicationCard({
|
||||
}, [publication.images]);
|
||||
|
||||
const imageLink = coverImage || thumbnailImage;
|
||||
const imageUrl = imageLink ? resolveURL(imageLink.href, baseURL) : null;
|
||||
const imageUrl = imageLink?.href ? resolveURL(imageLink.href, baseURL) : null;
|
||||
|
||||
const authors = useMemo(() => {
|
||||
const author = publication.metadata?.author;
|
||||
@@ -56,8 +56,14 @@ export function PublicationCard({
|
||||
const price = useMemo(() => {
|
||||
const priceLink = publication.links?.find((link) => link.properties?.price);
|
||||
if (priceLink?.properties?.price) {
|
||||
const { currency, value } = priceLink.properties.price;
|
||||
return `${currency} ${value}`;
|
||||
const priceObj = Array.isArray(priceLink.properties.price)
|
||||
? priceLink.properties.price[0]
|
||||
: priceLink.properties.price;
|
||||
|
||||
if (priceObj) {
|
||||
const { currency, value } = priceObj;
|
||||
return `${currency ? currency + ' ' : ''}${value}`;
|
||||
}
|
||||
}
|
||||
if (linksByRel.has(REL.ACQ + '/open-access')) {
|
||||
return _('Open Access');
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IoPricetag } from 'react-icons/io5';
|
||||
import { Book } from '@/types/book';
|
||||
import { OPDSLink, OPDSPublication, REL, SYMBOL } from '@/types/opds';
|
||||
import { OPDSPublication, REL, SYMBOL, OPDSAcquisitionLink, OPDSStreamLink } from '@/types/opds';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getFileExtFromMimeType } from '@/libs/document';
|
||||
import { formatDate, formatLanguage } from '@/utils/book';
|
||||
@@ -26,6 +26,7 @@ interface PublicationViewProps {
|
||||
type?: string,
|
||||
onProgress?: (progress: { progress: number; total: number }) => void,
|
||||
) => Promise<Book | null | undefined>;
|
||||
onStream?: (href: string, count: number, title: string, author: string) => void;
|
||||
onGenerateCachedImageUrl: (url: string) => Promise<string>;
|
||||
}
|
||||
|
||||
@@ -34,6 +35,7 @@ export function PublicationView({
|
||||
baseURL,
|
||||
resolveURL,
|
||||
onDownload,
|
||||
onStream,
|
||||
onGenerateCachedImageUrl,
|
||||
}: PublicationViewProps) {
|
||||
const _ = useTranslation();
|
||||
@@ -54,7 +56,7 @@ export function PublicationView({
|
||||
return covers?.[0] || publication.images?.[0];
|
||||
}, [publication.images]);
|
||||
|
||||
const imageUrl = coverImage ? resolveURL(coverImage.href, baseURL) : null;
|
||||
const imageUrl = coverImage?.href ? resolveURL(coverImage.href, baseURL) : null;
|
||||
|
||||
const authors = useMemo(() => {
|
||||
const author = publication.metadata?.author;
|
||||
@@ -66,15 +68,19 @@ export function PublicationView({
|
||||
}, [publication.metadata?.author]);
|
||||
|
||||
const acquisitionLinks = useMemo(() => {
|
||||
const links: Array<{ rel: string; links: OPDSLink[] }> = [];
|
||||
const links: Array<{ rel: string; links: OPDSAcquisitionLink[] }> = [];
|
||||
for (const [rel, linkList] of Array.from(linksByRel.entries())) {
|
||||
if (rel?.startsWith(REL.ACQ)) {
|
||||
links.push({ rel, links: linkList });
|
||||
links.push({ rel, links: linkList as OPDSAcquisitionLink[] });
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}, [linksByRel]);
|
||||
|
||||
const streamLinks = useMemo(() => {
|
||||
return (linksByRel.get(REL.STREAM) || []) as OPDSStreamLink[];
|
||||
}, [linksByRel]);
|
||||
|
||||
const handleActionButton = async (href: string, type?: string) => {
|
||||
if (downloadedBook) {
|
||||
navigateToReader(router, [downloadedBook.hash]);
|
||||
@@ -157,58 +163,96 @@ export function PublicationView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{acquisitionLinks.length > 0 && (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{acquisitionLinks.map(({ rel, links }) => (
|
||||
<div key={rel} className='flex gap-1'>
|
||||
{links.length === 1 || downloadedBook ? (
|
||||
<button
|
||||
onClick={() => handleActionButton(links[0]!.href, links[0]!.type)}
|
||||
disabled={downloading}
|
||||
className={clsx(
|
||||
'btn btn-primary min-w-20 rounded-3xl',
|
||||
downloadedBook && 'btn-success',
|
||||
)}
|
||||
>
|
||||
{downloadedBook ? _('Open & Read') : 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',
|
||||
)}
|
||||
disabled={downloading}
|
||||
toggleButton={
|
||||
<div>{downloadedBook ? _('Open') : getAcquisitionLabel(rel)}</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
{(acquisitionLinks.length > 0 || streamLinks.length > 0) && (
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{acquisitionLinks.map(({ rel, links }) => {
|
||||
const validLinks = links.filter((l) => l.href);
|
||||
if (validLinks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={rel} className='flex gap-1'>
|
||||
{validLinks.length === 1 || downloadedBook ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
handleActionButton(validLinks[0]!.href!, validLinks[0]!.type)
|
||||
}
|
||||
disabled={downloading}
|
||||
className={clsx(
|
||||
'delete-menu dropdown-content no-triangle !relative',
|
||||
'border-base-300 !bg-base-200 z-20 mt-2 max-w-[80vw] shadow-2xl',
|
||||
'btn btn-primary min-w-20 rounded-3xl',
|
||||
downloadedBook && 'btn-success',
|
||||
)}
|
||||
>
|
||||
{links.map((link, idx: number) => (
|
||||
<MenuItem
|
||||
key={idx}
|
||||
noIcon
|
||||
transient
|
||||
label={
|
||||
link.title ||
|
||||
getFileExtFromMimeType(link.type || '').toUpperCase() ||
|
||||
idx.toString()
|
||||
}
|
||||
onClick={() => handleActionButton(link.href, link.type)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{downloadedBook ? _('Open & Read') : 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',
|
||||
)}
|
||||
disabled={downloading}
|
||||
toggleButton={
|
||||
<div>{downloadedBook ? _('Open') : getAcquisitionLabel(rel)}</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'delete-menu dropdown-content no-triangle !relative',
|
||||
'border-base-300 !bg-base-200 z-20 mt-2 max-w-[80vw] shadow-2xl',
|
||||
)}
|
||||
>
|
||||
{validLinks.map((link, idx: number) => (
|
||||
<MenuItem
|
||||
key={idx}
|
||||
noIcon
|
||||
transient
|
||||
label={
|
||||
link.title ||
|
||||
getFileExtFromMimeType(link.type || '').toUpperCase() ||
|
||||
idx.toString()
|
||||
}
|
||||
onClick={() => handleActionButton(link.href!, link.type)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{streamLinks.map((link, idx) => {
|
||||
if (!link.href) return null;
|
||||
const countRaw =
|
||||
link.properties?.['pse:count'] ?? link.properties?.numberOfItems ?? 0;
|
||||
const count = Number(countRaw);
|
||||
|
||||
if (count > 0) {
|
||||
return (
|
||||
<button
|
||||
key={`stream-${idx}`}
|
||||
type='button'
|
||||
onClick={() =>
|
||||
onStream?.(
|
||||
link.href!,
|
||||
count,
|
||||
publication.metadata?.title || '',
|
||||
authors?.join(', ') || '',
|
||||
)
|
||||
}
|
||||
disabled={downloading || !!downloadedBook}
|
||||
className={clsx('btn btn-secondary min-w-20 rounded-3xl')}
|
||||
>
|
||||
{_('Read (Stream)')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
<div className='flex h-12 w-12 items-center justify-center'>
|
||||
{downloading && progress && progress > 0 && (
|
||||
<div
|
||||
@@ -240,10 +284,7 @@ export function PublicationView({
|
||||
{content ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
content.type === 'html' || content.type === 'xhtml'
|
||||
? content.value
|
||||
: content.value,
|
||||
__html: typeof content === 'string' ? content : content.value,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -272,6 +313,7 @@ export function PublicationView({
|
||||
: Array.isArray(publication.metadata.publisher)
|
||||
? publication.metadata.publisher
|
||||
.map((p) => (typeof p === 'string' ? p : p.name))
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: publication.metadata.publisher.name}
|
||||
</td>
|
||||
@@ -288,9 +330,7 @@ export function PublicationView({
|
||||
<th>{_('Language')}</th>
|
||||
<td>
|
||||
{Array.isArray(publication.metadata.language)
|
||||
? publication.metadata.language
|
||||
.map((lang: string) => formatLanguage(lang))
|
||||
.join(', ')
|
||||
? publication.metadata.language.map((lang) => formatLanguage(lang)).join(', ')
|
||||
: formatLanguage(publication.metadata.language)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -31,11 +31,11 @@ export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchVi
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const map = new Map<string | null, Map<string | null, string>>();
|
||||
const map = new Map<string | undefined, Map<string, string>>();
|
||||
|
||||
for (const param of search.params || []) {
|
||||
const value = formData[param.name] || '';
|
||||
const ns = param.ns ?? null;
|
||||
const ns = param.ns ?? undefined;
|
||||
|
||||
if (map.has(ns)) {
|
||||
map.get(ns)!.set(param.name, value);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useTransferQueue } from '@/hooks/useTransferQueue';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
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 {
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
} from './utils/opdsReq';
|
||||
import { ImportError } from '@/services/errors';
|
||||
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
|
||||
import { buildPseStreamFileName } from '@/services/opds/pseStream';
|
||||
import { FeedView } from './components/FeedView';
|
||||
import { PublicationView } from './components/PublicationView';
|
||||
import { SearchView } from './components/SearchView';
|
||||
@@ -140,11 +142,11 @@ export default function BrowserPage() {
|
||||
formData[param.name] = param.value || '';
|
||||
}
|
||||
});
|
||||
const map = new Map<string | null, Map<string | null, string>>();
|
||||
const map = new Map<string | undefined, Map<string, string>>();
|
||||
|
||||
for (const param of search.params || []) {
|
||||
const value = formData[param.name] || '';
|
||||
const ns = param.ns ?? null;
|
||||
const ns = param.ns ?? undefined;
|
||||
|
||||
if (map.has(ns)) {
|
||||
map.get(ns)!.set(param.name, value);
|
||||
@@ -229,7 +231,7 @@ export default function BrowserPage() {
|
||||
addToHistory(url, newState, 'feed', null);
|
||||
}
|
||||
} else if (localName === 'entry') {
|
||||
const publication = getPublication(doc.documentElement);
|
||||
const publication = getPublication(doc.documentElement) as OPDSPublication;
|
||||
const newState = {
|
||||
publication,
|
||||
baseURL: responseURL,
|
||||
@@ -244,7 +246,7 @@ export default function BrowserPage() {
|
||||
addToHistory(url, newState, 'publication', null);
|
||||
}
|
||||
} else if (localName === 'OpenSearchDescription') {
|
||||
const search = getOpenSearch(doc);
|
||||
const search = getOpenSearch(doc) as OPDSSearch;
|
||||
const newState = {
|
||||
search,
|
||||
baseURL: responseURL,
|
||||
@@ -383,8 +385,8 @@ export default function BrowserPage() {
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
search: (map: Map<string | null, Map<string | null, string>>) => {
|
||||
const defaultParams = map.get(null);
|
||||
search: (map: Map<string | undefined, Map<string, string>>) => {
|
||||
const defaultParams = map.get(undefined);
|
||||
const searchTerms = defaultParams?.get('searchTerms') || '';
|
||||
const decodedURL = decodeURIComponent(searchURL);
|
||||
return decodedURL.replace('{searchTerms}', encodeURIComponent(searchTerms));
|
||||
@@ -496,6 +498,29 @@ export default function BrowserPage() {
|
||||
[user, state.baseURL, appService, libraryLoaded],
|
||||
);
|
||||
|
||||
const handleStream = useCallback(
|
||||
async (href: string, count: number, title: string, author: string) => {
|
||||
if (!appService || !libraryLoaded) return;
|
||||
try {
|
||||
const url = resolveURL(href, state.baseURL);
|
||||
const psePath = buildPseStreamFileName({ url, catalogId, count, title, author });
|
||||
const { library, setLibrary } = useLibraryStore.getState();
|
||||
const book = await appService.importBook(psePath, library, { transient: true });
|
||||
if (book) {
|
||||
setLibrary(library);
|
||||
navigateToReader(router, [book.hash]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Stream error:', e);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Failed to start stream') + `:\n${e instanceof Error ? e.message : e}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
[state.baseURL, catalogId, appService, libraryLoaded, router, _],
|
||||
);
|
||||
|
||||
const handleGenerateCachedImageUrl = useCallback(
|
||||
async (url: string) => {
|
||||
if (!appService) return url;
|
||||
@@ -673,6 +698,7 @@ export default function BrowserPage() {
|
||||
publication={publication}
|
||||
baseURL={state.baseURL}
|
||||
onDownload={handleDownload}
|
||||
onStream={handleStream}
|
||||
resolveURL={resolveURL}
|
||||
onGenerateCachedImageUrl={handleGenerateCachedImageUrl}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isOPDSCatalog } from 'foliate-js/opds.js';
|
||||
import { OPDSLink } from '@/types/opds';
|
||||
import { OPDSBaseLink } from '@/types/opds';
|
||||
import { EXTS } from '@/libs/document';
|
||||
import { fetchWithAuth } from './opdsReq';
|
||||
|
||||
@@ -68,7 +68,7 @@ export const parseMediaType = (str?: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const isSearchLink = (link: OPDSLink): boolean => {
|
||||
export const isSearchLink = (link: OPDSBaseLink): boolean => {
|
||||
const rels = Array.isArray(link.rel) ? link.rel : [link.rel || ''];
|
||||
return rels.includes('search') && (link.type === MIME.OPENSEARCH || link.type === MIME.ATOM);
|
||||
};
|
||||
|
||||
@@ -5,13 +5,21 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
|
||||
export const useLibrary = () => {
|
||||
const { envConfig } = useEnv();
|
||||
const { setLibrary } = useLibraryStore();
|
||||
const { setLibrary, libraryLoaded: storeLibraryLoaded } = useLibraryStore();
|
||||
const { setSettings } = useSettingsStore();
|
||||
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
||||
// Skip the disk reload when another mount has already populated the store —
|
||||
// re-reading would clobber transient in-memory entries (e.g. OPDS-PSE
|
||||
// streamed books) that aren't persisted to disk.
|
||||
const [libraryLoaded, setLibraryLoaded] = useState(storeLibraryLoaded);
|
||||
const isInitiating = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitiating.current) return;
|
||||
if (isInitiating.current || storeLibraryLoaded) {
|
||||
if (storeLibraryLoaded && !libraryLoaded) {
|
||||
setLibraryLoaded(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
isInitiating.current = true;
|
||||
const initLibrary = async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
@@ -23,7 +31,7 @@ export const useLibrary = () => {
|
||||
|
||||
initLibrary();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [storeLibraryLoaded]);
|
||||
|
||||
return { libraryLoaded };
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { BookNav } from '@/services/nav';
|
||||
import { partialMD5, md5 } from '@/utils/md5';
|
||||
import { getBaseFilename, getFilename } from '@/utils/path';
|
||||
import { BookDoc, DocumentLoader, EXTS } from '@/libs/document';
|
||||
import { isPseStreamFileName, openPseStreamBook, parsePseStreamFileName } from './opds/pseStream';
|
||||
import { DEFAULT_BOOK_SEARCH_CONFIG, DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS } from './constants';
|
||||
import { isContentURI, isValidURL, makeSafeFilename } from '@/utils/misc';
|
||||
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
|
||||
@@ -238,32 +239,39 @@ export async function importBook(
|
||||
transient = false,
|
||||
lookupIndex,
|
||||
} = options;
|
||||
const isPseStream = typeof file === 'string' && isPseStreamFileName(file);
|
||||
try {
|
||||
let loadedBook: BookDoc;
|
||||
let format: BookFormat;
|
||||
let filename: string;
|
||||
let fileobj: File;
|
||||
let fileobj: File | undefined;
|
||||
|
||||
if (transient && typeof file !== 'string') {
|
||||
throw new Error('Transient import is only supported for file paths');
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof file === 'string') {
|
||||
fileobj = await fs.openFile(file, 'None');
|
||||
filename = fileobj.name || getFilename(file);
|
||||
if (isPseStream) {
|
||||
const data = parsePseStreamFileName(file as string);
|
||||
({ book: loadedBook, format } = await openPseStreamBook(data));
|
||||
filename = file as string;
|
||||
} else {
|
||||
fileobj = file;
|
||||
filename = file.name;
|
||||
if (typeof file === 'string') {
|
||||
fileobj = await fs.openFile(file, 'None');
|
||||
filename = fileobj.name || getFilename(file);
|
||||
} else {
|
||||
fileobj = file;
|
||||
filename = file.name;
|
||||
}
|
||||
if (/\.txt$/i.test(filename)) {
|
||||
const txt2epub = new TxtToEpubConverter();
|
||||
({ file: fileobj } = await txt2epub.convert({ file: fileobj }));
|
||||
}
|
||||
if (!fileobj || fileobj.size === 0) {
|
||||
throw new Error('Invalid or empty book file');
|
||||
}
|
||||
({ book: loadedBook, format } = await new DocumentLoader(fileobj).open());
|
||||
}
|
||||
if (/\.txt$/i.test(filename)) {
|
||||
const txt2epub = new TxtToEpubConverter();
|
||||
({ file: fileobj } = await txt2epub.convert({ file: fileobj }));
|
||||
}
|
||||
if (!fileobj || fileobj.size === 0) {
|
||||
throw new Error('Invalid or empty book file');
|
||||
}
|
||||
({ book: loadedBook, format } = await new DocumentLoader(fileobj).open());
|
||||
if (!loadedBook) {
|
||||
throw new Error('Unsupported or corrupted book file');
|
||||
}
|
||||
@@ -276,7 +284,8 @@ export async function importBook(
|
||||
throw new Error(`Failed to open the book file: ${(error as Error).message || error}`);
|
||||
}
|
||||
|
||||
const hash = await partialMD5(fileobj);
|
||||
const hash = isPseStream ? md5(file as string) : await partialMD5(fileobj!);
|
||||
|
||||
const metaHash = getMetadataHash(loadedBook.metadata);
|
||||
let existingBook = lookupIndex
|
||||
? lookupIndex.byHash.get(hash)
|
||||
@@ -366,7 +375,12 @@ export async function importBook(
|
||||
await fs.createDir(getDir(book), 'Books');
|
||||
}
|
||||
const bookFilename = getLocalBookFilename(book);
|
||||
if (saveBook && !transient && (!(await fs.exists(bookFilename, 'Books')) || overwrite)) {
|
||||
if (
|
||||
saveBook &&
|
||||
!transient &&
|
||||
fileobj &&
|
||||
(!(await fs.exists(bookFilename, 'Books')) || overwrite)
|
||||
) {
|
||||
if (/\.txt$/i.test(filename)) {
|
||||
await fs.writeFile(bookFilename, 'Books', fileobj);
|
||||
} else if (typeof file === 'string' && isContentURI(file)) {
|
||||
@@ -442,7 +456,10 @@ export async function importBook(
|
||||
}
|
||||
|
||||
// update file links with url or path or content uri
|
||||
if (typeof file === 'string') {
|
||||
if (isPseStream) {
|
||||
book.url = file as string;
|
||||
if (existingBook) existingBook.url = file as string;
|
||||
} else if (typeof file === 'string') {
|
||||
if (isValidURL(file)) {
|
||||
book.url = file;
|
||||
if (existingBook) existingBook.url = file;
|
||||
@@ -498,6 +515,7 @@ export async function getBookFileSize(fs: FileSystem, book: Book): Promise<numbe
|
||||
export async function loadBookContent(fs: FileSystem, book: Book): Promise<BookContent> {
|
||||
let file: File;
|
||||
const fp = getLocalBookFilename(book);
|
||||
|
||||
if (await fs.exists(fp, 'Books')) {
|
||||
file = await fs.openFile(fp, 'Books');
|
||||
} else if (book.filePath) {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { getFeed, isOPDSCatalog } from 'foliate-js/opds.js';
|
||||
import type {
|
||||
OPDSAcquisitionLink,
|
||||
OPDSBaseLink,
|
||||
OPDSCatalog,
|
||||
OPDSFeed,
|
||||
OPDSLink,
|
||||
OPDSGenericLink,
|
||||
OPDSNavigationItem,
|
||||
OPDSPublication,
|
||||
OPDSStreamLink,
|
||||
} from '@/types/opds';
|
||||
import { REL } from '@/types/opds';
|
||||
import { MIMETYPES } from '@/libs/document';
|
||||
@@ -33,7 +36,11 @@ const MIME_XML = 'application/xml';
|
||||
// landing document, not the file).
|
||||
const SAFE_ACQ_RELS = [REL.ACQ, `${REL.ACQ}/open-access`];
|
||||
|
||||
function isSafeAcquisitionLink(link: OPDSLink): boolean {
|
||||
type AnyAcqLink = OPDSAcquisitionLink | OPDSStreamLink | OPDSGenericLink;
|
||||
type ValidAcqLink = OPDSAcquisitionLink & { href: string };
|
||||
|
||||
function isSafeAcquisitionLink(link: AnyAcqLink): link is ValidAcqLink {
|
||||
if (!link.href) return false;
|
||||
if (link.properties?.indirectAcquisition?.length) return false;
|
||||
const rels = Array.isArray(link.rel) ? link.rel : [link.rel ?? ''];
|
||||
return rels.some((r) => SAFE_ACQ_RELS.includes(r));
|
||||
@@ -57,7 +64,7 @@ const INFERENCE_RULES: ReadonlyArray<{ mime: string; href: RegExp; title: RegExp
|
||||
{ mime: 'application/x-fictionbook+xml', href: /\.fb2(?:[?#]|$)/i, title: /\bfb2\b/i },
|
||||
];
|
||||
|
||||
function inferMediaType(link: OPDSLink): string {
|
||||
function inferMediaType(link: ValidAcqLink): string {
|
||||
const title = link.title ?? '';
|
||||
for (const rule of INFERENCE_RULES) {
|
||||
if (rule.href.test(link.href) || rule.title.test(title)) return rule.mime;
|
||||
@@ -65,7 +72,7 @@ function inferMediaType(link: OPDSLink): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function getEffectiveMediaType(link: OPDSLink): string {
|
||||
function getEffectiveMediaType(link: ValidAcqLink): string {
|
||||
const declared = parseMediaType(link.type)?.mediaType ?? '';
|
||||
// Treat octet-stream as "the server didn't actually tell us" — most
|
||||
// OPDS feeds emit a real media type for known formats, and falling back
|
||||
@@ -74,7 +81,7 @@ function getEffectiveMediaType(link: OPDSLink): string {
|
||||
return inferMediaType(link);
|
||||
}
|
||||
|
||||
function isAdvancedEpub(link: OPDSLink, mediaType: string): boolean {
|
||||
function isAdvancedEpub(link: ValidAcqLink, mediaType: string): boolean {
|
||||
if (mediaType !== 'application/epub+zip') return false;
|
||||
const version = parseMediaType(link.type)?.parameters?.['version'];
|
||||
if (version && /^3(\.|$)/.test(version)) return true;
|
||||
@@ -85,7 +92,7 @@ function isAdvancedEpub(link: OPDSLink, mediaType: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getFormatTier(link: OPDSLink): FormatTier {
|
||||
function getFormatTier(link: ValidAcqLink): FormatTier {
|
||||
const mediaType = getEffectiveMediaType(link);
|
||||
if (MIMETYPES.EPUB.includes(mediaType)) {
|
||||
return isAdvancedEpub(link, mediaType) ? 0 : 1;
|
||||
@@ -115,7 +122,7 @@ function getFormatTier(link: OPDSLink): FormatTier {
|
||||
*
|
||||
* Returns undefined when no safe link exists — the entry is skipped.
|
||||
*/
|
||||
export function getAcquisitionLink(pub: OPDSPublication): OPDSLink | undefined {
|
||||
export function getAcquisitionLink(pub: OPDSPublication): ValidAcqLink | undefined {
|
||||
const acqLinks = pub.links.filter(isSafeAcquisitionLink);
|
||||
if (acqLinks.length === 0) return undefined;
|
||||
|
||||
@@ -188,7 +195,7 @@ export function collectNewEntries(
|
||||
seenInBatch.add(entryId);
|
||||
items.push({
|
||||
entryId,
|
||||
title: pub.metadata.title,
|
||||
title: pub.metadata.title || acqLink.title || 'Untitled',
|
||||
acquisitionHref: acqLink.href,
|
||||
mimeType: acqLink.type ?? 'application/octet-stream',
|
||||
updated: pub.metadata.updated,
|
||||
@@ -249,7 +256,7 @@ async function fetchFeed(
|
||||
return null;
|
||||
}
|
||||
|
||||
type LinkLike = Pick<OPDSLink, 'href' | 'rel' | 'title'> | OPDSNavigationItem;
|
||||
type LinkLike = Pick<OPDSBaseLink, 'href' | 'rel' | 'title'> | OPDSNavigationItem;
|
||||
|
||||
function hasRel(item: LinkLike, target: string): boolean {
|
||||
const rels = Array.isArray(item.rel) ? item.rel : [item.rel ?? ''];
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { needsProxy, getProxiedURL, probeAuth } from '@/app/opds/utils/opdsReq';
|
||||
import { normalizeOPDSCustomHeaders } from '@/app/opds/utils/customHeaders';
|
||||
import type { BookFormat } from '@/types/book';
|
||||
import type { BookDoc, BookMetadata } from '@/libs/document';
|
||||
|
||||
export const PSE_SCHEME = 'pse://';
|
||||
|
||||
export interface PseStreamData {
|
||||
url: string;
|
||||
catalogId: string;
|
||||
count: number;
|
||||
title: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
export const isPseStreamFileName = (name: string): boolean => name.startsWith(PSE_SCHEME);
|
||||
|
||||
export const buildPseStreamFileName = (data: PseStreamData): string =>
|
||||
PSE_SCHEME + encodeURIComponent(JSON.stringify(data));
|
||||
|
||||
export const parsePseStreamFileName = (name: string): PseStreamData =>
|
||||
JSON.parse(decodeURIComponent(name.replace(PSE_SCHEME, '')));
|
||||
|
||||
const PAGE_NUMBER_PATTERN = /%7BpageNumber%7D|\{pageNumber\}/gi;
|
||||
const MAX_WIDTH_PATTERN = /%7BmaxWidth%7D|\{maxWidth\}/gi;
|
||||
const DEFAULT_MAX_WIDTH = '2000';
|
||||
|
||||
export const createPseStreamPageLoader = (data: PseStreamData) => {
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
const catalog = settings.opdsCatalogs?.find((c) => c.id === data.catalogId);
|
||||
const username = catalog?.username || '';
|
||||
const password = catalog?.password || '';
|
||||
const customHeaders = normalizeOPDSCustomHeaders(catalog?.customHeaders);
|
||||
let authHeaderPromise: Promise<string | null> | null = null;
|
||||
|
||||
return async (pageIndex: number): Promise<Blob> => {
|
||||
let url = data.url.replace(PAGE_NUMBER_PATTERN, pageIndex.toString());
|
||||
url = url.replace(MAX_WIDTH_PATTERN, DEFAULT_MAX_WIDTH);
|
||||
const useProxy = needsProxy(url);
|
||||
|
||||
if (!authHeaderPromise) {
|
||||
authHeaderPromise =
|
||||
username || password
|
||||
? probeAuth(url, username, password, useProxy, customHeaders)
|
||||
: Promise.resolve(null);
|
||||
}
|
||||
const authHeader = await authHeaderPromise;
|
||||
|
||||
const fetchURL = useProxy ? getProxiedURL(url, authHeader || '', true, customHeaders) : url;
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': READEST_OPDS_USER_AGENT,
|
||||
...(!useProxy ? customHeaders : {}),
|
||||
...(!useProxy && authHeader ? { Authorization: authHeader } : {}),
|
||||
};
|
||||
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
|
||||
const res = await fetch(fetchURL, {
|
||||
headers,
|
||||
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: true },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch page ${pageIndex}: ${res.statusText}`);
|
||||
}
|
||||
return res.blob();
|
||||
};
|
||||
};
|
||||
|
||||
export const openPseStreamBook = async (
|
||||
data: PseStreamData,
|
||||
): Promise<{ book: BookDoc; format: BookFormat }> => {
|
||||
const loadPage = createPseStreamPageLoader(data);
|
||||
const entries = Array.from({ length: data.count }).map((_, i) => ({
|
||||
filename: `${i.toString().padStart(4, '0')}.jpg`,
|
||||
directory: false,
|
||||
size: 0,
|
||||
}));
|
||||
const loader = {
|
||||
entries,
|
||||
loadText: async () => null,
|
||||
loadBlob: async (name: string) => {
|
||||
const i = parseInt(name.split('.')[0] || '0', 10);
|
||||
return loadPage(i);
|
||||
},
|
||||
getSize: () => 0,
|
||||
getComment: async () => null,
|
||||
};
|
||||
const { makeComicBook } = await import('foliate-js/comic-book.js');
|
||||
// makeComicBook only consults `file.name` as a fallback title; we override
|
||||
// metadata from `data` regardless, so a name-only stand-in is sufficient.
|
||||
const rawComicBook = await makeComicBook(loader, { name: data.title });
|
||||
const book = {
|
||||
...rawComicBook,
|
||||
dir: 'auto',
|
||||
metadata: {
|
||||
...(rawComicBook.metadata || {}),
|
||||
title: data.title,
|
||||
author: data.author,
|
||||
} as BookMetadata,
|
||||
} as BookDoc;
|
||||
return { book, format: 'CBZ' };
|
||||
};
|
||||
@@ -13,6 +13,11 @@ import { Insets } from '@/types/misc';
|
||||
import { EnvConfigType } from '@/services/environment';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { DocumentLoader, TOCItem } from '@/libs/document';
|
||||
import {
|
||||
isPseStreamFileName,
|
||||
openPseStreamBook,
|
||||
parsePseStreamFileName,
|
||||
} from '@/services/opds/pseStream';
|
||||
import { BOOK_NAV_VERSION, computeBookNav, hydrateBookNav, updateToc } from '@/services/nav';
|
||||
import { formatTitle, getMetadataHash, getPrimaryLanguage } from '@/utils/book';
|
||||
import { getBaseFilename } from '@/utils/path';
|
||||
@@ -147,19 +152,30 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
try {
|
||||
const appService = await envConfig.getAppService();
|
||||
const { settings } = useSettingsStore.getState();
|
||||
const { getBookByHash } = useLibraryStore.getState();
|
||||
const { getBookByHash, library } = useLibraryStore.getState();
|
||||
const book = getBookByHash(id);
|
||||
if (!book) {
|
||||
console.error(
|
||||
`Book ${id} not found in library (size=${library.length}); likely the in-memory entry was dropped by a library reload.`,
|
||||
);
|
||||
throw new Error('Book not found');
|
||||
}
|
||||
const isPseStream = !!book.url && isPseStreamFileName(book.url);
|
||||
let bookDoc = bookData?.bookDoc;
|
||||
let file = bookData?.file;
|
||||
if (!bookDoc || !file || reload) {
|
||||
const content = (await appService.loadBookContent(book)) as BookContent;
|
||||
file = content.file;
|
||||
let file: File | null = bookData?.file ?? null;
|
||||
if (!bookDoc || (!isPseStream && !file) || reload) {
|
||||
console.log('Loading book', key);
|
||||
const doc = await new DocumentLoader(file).open();
|
||||
bookDoc = doc.book;
|
||||
if (isPseStream) {
|
||||
const data = parsePseStreamFileName(book.url!);
|
||||
const doc = await openPseStreamBook(data);
|
||||
bookDoc = doc.book;
|
||||
file = null;
|
||||
} else {
|
||||
const content = (await appService.loadBookContent(book)) as BookContent;
|
||||
file = content.file;
|
||||
const doc = await new DocumentLoader(file).open();
|
||||
bookDoc = doc.book;
|
||||
}
|
||||
}
|
||||
const config = await appService.loadBookConfig(book, settings);
|
||||
// Import annotations from third-party readers on first open
|
||||
@@ -201,7 +217,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
config.viewSettings?.sortedTOC ?? false,
|
||||
config.viewSettings?.convertChineseVariant ?? 'none',
|
||||
);
|
||||
if (!bookDoc.metadata.title) {
|
||||
if (!bookDoc.metadata.title && file) {
|
||||
bookDoc.metadata.title = getBaseFilename(file.name);
|
||||
}
|
||||
book.sourceTitle = formatTitle(bookDoc.metadata.title);
|
||||
|
||||
@@ -2,9 +2,14 @@ export const REL = {
|
||||
ACQ: 'http://opds-spec.org/acquisition',
|
||||
FACET: 'http://opds-spec.org/facet',
|
||||
GROUP: 'http://opds-spec.org/group',
|
||||
COVER: ['http://opds-spec.org/image', 'http://opds-spec.org/cover'],
|
||||
THUMBNAIL: ['http://opds-spec.org/image/thumbnail', 'http://opds-spec.org/thumbnail'],
|
||||
};
|
||||
COVER: ['http://opds-spec.org/image', 'http://opds-spec.org/cover', 'x-stanza-cover-image'],
|
||||
THUMBNAIL: [
|
||||
'http://opds-spec.org/image/thumbnail',
|
||||
'http://opds-spec.org/thumbnail',
|
||||
'x-stanza-cover-image-thumbnail',
|
||||
],
|
||||
STREAM: 'http://vaemendis.net/opds-pse/stream',
|
||||
} as const;
|
||||
|
||||
const SUMMARY = Symbol('summary');
|
||||
const CONTENT = Symbol('content');
|
||||
@@ -33,8 +38,13 @@ export interface OPDSFeed {
|
||||
updated?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
numberOfItems?: number;
|
||||
itemsPerPage?: number;
|
||||
currentPage?: number;
|
||||
};
|
||||
links: OPDSLink[];
|
||||
links: OPDSGenericLink[];
|
||||
isComplete?: boolean;
|
||||
isArchive?: boolean;
|
||||
navigation?: OPDSNavigationItem[];
|
||||
publications?: OPDSPublication[];
|
||||
groups?: OPDSGroup[];
|
||||
@@ -45,22 +55,22 @@ export interface OPDSPublication {
|
||||
metadata: {
|
||||
id?: string;
|
||||
updated?: string;
|
||||
title: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
author?: OPDSPerson[];
|
||||
description?: string;
|
||||
content?: OPDSContent;
|
||||
author?: OPDSPerson[];
|
||||
contributor?: OPDSPerson[];
|
||||
publisher?: string | OPDSPerson;
|
||||
publisher?: string | OPDSPerson | OPDSPerson[];
|
||||
published?: string;
|
||||
language?: string;
|
||||
language?: string | string[];
|
||||
identifier?: string;
|
||||
subject?: OPDSSubject[];
|
||||
rights?: string;
|
||||
content?: OPDSContent;
|
||||
[SYMBOL.CONTENT]?: OPDSContent;
|
||||
};
|
||||
links: OPDSLink[];
|
||||
images: OPDSLink[];
|
||||
links: Array<OPDSAcquisitionLink | OPDSStreamLink | OPDSGenericLink>;
|
||||
images: OPDSGenericLink[];
|
||||
}
|
||||
|
||||
export interface OPDSSearch {
|
||||
@@ -68,27 +78,19 @@ export interface OPDSSearch {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
search: (map: Map<string | null, Map<string | null, string>>) => string;
|
||||
search: (map: Map<string | undefined, Map<string, string>>) => string;
|
||||
params: OPDSSearchParam[];
|
||||
}
|
||||
|
||||
export interface OPDSLink {
|
||||
export interface OPDSBaseLink {
|
||||
rel?: string | string[];
|
||||
href: string;
|
||||
href?: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
properties: {
|
||||
price?: {
|
||||
currency: string;
|
||||
value: string;
|
||||
} | null;
|
||||
indirectAcquisition?: Array<{ type: string }>;
|
||||
numberOfItems?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface OPDSPerson {
|
||||
name: string;
|
||||
name?: string;
|
||||
links: Array<{ href: string }>;
|
||||
}
|
||||
|
||||
@@ -103,17 +105,61 @@ interface OPDSContent {
|
||||
type: 'text' | 'html' | 'xhtml';
|
||||
}
|
||||
|
||||
export interface OPDSNavigationItem extends Partial<OPDSLink> {
|
||||
export interface OPDSGenericLink extends OPDSBaseLink {
|
||||
properties?: {
|
||||
price?: undefined;
|
||||
indirectAcquisition?: undefined;
|
||||
numberOfItems?: number;
|
||||
'pse:count'?: undefined;
|
||||
'pse:lastRead'?: undefined;
|
||||
'pse:lastReadDate'?: undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OPDSAcquisitionLink extends OPDSBaseLink {
|
||||
properties?: {
|
||||
price?: OPDSPrice | OPDSPrice[];
|
||||
indirectAcquisition?: OPDSIndirectAcquisition[];
|
||||
numberOfItems?: number;
|
||||
'pse:count'?: undefined;
|
||||
'pse:lastRead'?: undefined;
|
||||
'pse:lastReadDate'?: undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OPDSStreamLink extends OPDSBaseLink {
|
||||
properties?: {
|
||||
price?: OPDSPrice | OPDSPrice[];
|
||||
indirectAcquisition?: OPDSIndirectAcquisition[];
|
||||
numberOfItems?: number;
|
||||
'pse:count'?: number;
|
||||
'pse:lastRead'?: number;
|
||||
'pse:lastReadDate'?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OPDSFacetLink extends OPDSBaseLink {
|
||||
properties?: {
|
||||
price?: undefined;
|
||||
indirectAcquisition?: undefined;
|
||||
numberOfItems?: number;
|
||||
'pse:count'?: undefined;
|
||||
'pse:lastRead'?: undefined;
|
||||
'pse:lastReadDate'?: undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OPDSNavigationItem extends OPDSGenericLink {
|
||||
title?: string;
|
||||
[SYMBOL.SUMMARY]?: string;
|
||||
}
|
||||
|
||||
interface OPDSGroup {
|
||||
export interface OPDSGroup {
|
||||
metadata: {
|
||||
title?: string;
|
||||
numberOfItems?: string;
|
||||
numberOfItems?: number;
|
||||
};
|
||||
links: Array<{ rel: string; href: string; type?: string }>;
|
||||
links: OPDSGenericLink[];
|
||||
publications?: OPDSPublication[];
|
||||
navigation?: OPDSNavigationItem[];
|
||||
}
|
||||
@@ -122,12 +168,22 @@ export interface OPDSFacet {
|
||||
metadata: {
|
||||
title?: string;
|
||||
};
|
||||
links: OPDSLink[];
|
||||
links: OPDSFacetLink[];
|
||||
}
|
||||
|
||||
interface OPDSSearchParam {
|
||||
ns?: string | null;
|
||||
ns?: string;
|
||||
name: string;
|
||||
required?: boolean;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface OPDSPrice {
|
||||
currency?: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface OPDSIndirectAcquisition {
|
||||
type: string;
|
||||
child?: OPDSIndirectAcquisition[];
|
||||
}
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 92582cee5b...2204a28af1
Reference in New Issue
Block a user