38d7ba80fe
* feat: auto-download new items from subscribed OPDS catalogs Add opt-in auto-download per OPDS catalog. When enabled, new publications are downloaded on app startup and pull-to-refresh. Dedup via Atom <id>. - Extend foliate-js parser to surface entry id/updated fields - Add subscription state to OPDSCatalog type - New headless opdsSyncService with navigation feed crawling - Auto-download toggle in CatalogManager UI - Wire sync into startup and pull-to-refresh hooks - 16 new test cases Built with an AI code agent. Closes #3836 * chore: point foliate-js submodule to fork with OPDS id/updated fields CI needs to fetch the foliate-js commit that adds id and updated field extraction from OPDS feeds. Point submodule URL to our fork where the commit is pushed. * feat(opds): rearchitect auto-download with two-phase OPDS sync Replaces the monolithic opdsSyncService from #3837 with a separated discovery + acquisition pipeline. Subscription state lives in per-catalog JSON files under Data/opds-subscriptions/, decoupled from catalog config. Discovery (services/opds/feedChecker.ts) resolves a catalog URL down to its "by newest" feed via three detection tiers — rel="http://opds- spec.org/sort/new" (Calibre / Calibre-Web), title heuristics ("Newest", "Recently Added", "Latest"), and href patterns (?sort_order=release_date for Project Gutenberg, /new-releases for Standard Ebooks). It then walks only that feed plus rel=next pagination, never the full navigation tree. When no by-newest feed is found, the catalog is skipped with a warning instead of silently mirroring everything. Acquisition link selection filters to safe rels (acquisition / acquisition/open-access — drops buy / borrow / subscribe / sample / indirect), prefers open-access when both rels are present, then ranks by format tier: 0 Advanced EPUB / EPUB 3 (link title, .epub3 href, version=3.x) 1 plain EPUB 2 MOBI / AZW / AZW3 3 PDF / CBZ 4 anything else Within a tier, ties resolve by feed order. When the upstream omits or mis-declares (octet-stream) the link's media type, format is inferred from href extension and link title. Entries that appear in both feed.publications and a group are deduped within the same batch. Orchestration (services/opds/autoDownload.ts) runs catalogs sequentially — per-catalog concurrency already caps parallel downloads at 3, so a parallel fan-out across catalogs would be N×3 simultaneous requests on cellular. Pending and retry items are deduped by entryId, and entries still inside their exponential-backoff window are skipped instead of re-attempted (avoids appending duplicate failedEntries records). Filenames come from the last path segment, capped at 200 chars, with a try/catch around decodeURIComponent for malformed %-sequences. Hook (hooks/useOPDSSubscriptions.ts): startup, 5-minute interval, and 'check-opds-subscriptions' event triggers. Toggling auto-download on fires the event so the sync runs immediately. Newly imported books are queued for cloud upload via transferManager when the user is logged in and settings.autoUpload is on, mirroring the manual OPDS download path. 'opds-sync-complete' is dispatched after every sync so listening UI can refresh without polling. loadSubscriptionState self-heals duplicate failedEntries from older state files. The submodule URL is reverted to readest/foliate-js (the changes needed for OPDS id/updated extraction are already in main), and OPDSCatalog is trimmed to only the autoDownload field. Verbose [OPDS]-prefixed debug logs cover the whole pipeline: feed fetch → resolve → discovery → per-item download (with magic-byte dump of the saved file) → import → cloud upload queueing. 34 new tests across opds-feed-checker, opds-auto-download, opds-subscription-state, opds-types. Closes #3836. Supersedes #3837. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Johannes Mauerer <johannes@mauerer.info> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
243 lines
9.0 KiB
TypeScript
243 lines
9.0 KiB
TypeScript
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { deserializeOPDSCustomHeaders } from '@/app/opds/utils/customHeaders';
|
|
|
|
async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
|
|
// Cloudflare Workers incorrectly decodes %26 to & in the url parameter value,
|
|
// causing query parameters within the proxied URL (like &start_index=26) to be
|
|
// treated as separate top-level parameters instead of part of the url value.
|
|
// We work around this by manually extracting the url parameter - capturing everything
|
|
// from 'url=' until we hit our known parameters (&stream=, &auth=, or &headers=), then decoding it.
|
|
const fullUrl = request.url;
|
|
const urlParamStart = fullUrl.indexOf('url=') + 4;
|
|
const streamParam = fullUrl.lastIndexOf('&stream=');
|
|
const authParam = fullUrl.lastIndexOf('&auth=');
|
|
const headersParam = fullUrl.lastIndexOf('&headers=');
|
|
const urlParamEnd = Math.min(
|
|
...[streamParam, authParam, headersParam].filter((i) => i > 0),
|
|
fullUrl.length,
|
|
);
|
|
const encodedUrl = fullUrl.substring(urlParamStart, urlParamEnd);
|
|
const url = decodeURIComponent(encodedUrl);
|
|
const auth = request.nextUrl.searchParams.get('auth');
|
|
const stream = request.nextUrl.searchParams.get('stream');
|
|
const customHeaders = deserializeOPDSCustomHeaders(request.nextUrl.searchParams.get('headers'));
|
|
|
|
if (!url) {
|
|
return NextResponse.json(
|
|
{ error: 'Missing URL parameter. Usage: /api/opds/proxy?url=YOUR_OPDS_URL' },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
try {
|
|
new URL(url);
|
|
} catch {
|
|
return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 });
|
|
}
|
|
|
|
try {
|
|
console.log(`[OPDS Proxy] ${method}: ${url}`);
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 20000);
|
|
const headers = new Headers({
|
|
'User-Agent': READEST_OPDS_USER_AGENT,
|
|
Accept: 'application/atom+xml, application/xml, text/xml, application/json, */*',
|
|
});
|
|
|
|
for (const [key, value] of Object.entries(customHeaders)) {
|
|
headers.set(key, value);
|
|
}
|
|
|
|
if (auth) {
|
|
headers.set('Authorization', auth);
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeout);
|
|
|
|
if (!response.ok) {
|
|
console.error(`[OPDS Proxy] HTTP ${response.status} for ${url}`);
|
|
if (method === 'HEAD') {
|
|
if (response.status === 401) {
|
|
return new NextResponse(null, {
|
|
status: 403,
|
|
headers: {
|
|
...Object.fromEntries(response.headers.entries()),
|
|
'Cache-Control': 'no-store',
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
},
|
|
});
|
|
}
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.text();
|
|
if (response.status === 401) {
|
|
return new NextResponse(data, {
|
|
status: 403,
|
|
headers: {
|
|
...Object.fromEntries(response.headers.entries()),
|
|
'Cache-Control': 'no-store',
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
},
|
|
});
|
|
}
|
|
return new NextResponse(data, {
|
|
status: response.status,
|
|
headers: {
|
|
...Object.fromEntries(response.headers.entries()),
|
|
'Cache-Control': 'no-store',
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
},
|
|
});
|
|
}
|
|
|
|
const contentType = response.headers.get('Content-Type') || 'text/xml';
|
|
const contentLength = response.headers.get('Content-Length');
|
|
const contentEncoding = response.headers.get('Content-Encoding');
|
|
const transferEncoding = response.headers.get('Transfer-Encoding');
|
|
const upstreamContentDisposition = response.headers.get('Content-Disposition');
|
|
console.log(
|
|
`[OPDS Proxy] upstream OK: ${url} ` +
|
|
`content-type=${contentType} content-length=${contentLength ?? '(none)'} ` +
|
|
`content-encoding=${contentEncoding ?? '(none)'} ` +
|
|
`transfer-encoding=${transferEncoding ?? '(none)'} ` +
|
|
`content-disposition=${upstreamContentDisposition ?? '(none)'}`,
|
|
);
|
|
|
|
// Headers that must NOT be forwarded as-is when we proxy the body:
|
|
// - content-encoding: fetch() has already decoded gzip/br/deflate, so
|
|
// the body the client receives is plain. Forwarding the original
|
|
// Content-Encoding makes the browser try to decode it again, which
|
|
// truncates or empties the response. This was the cause of
|
|
// Content-Length: 0 / 0-byte downloads from Calibre and similar.
|
|
// - content-length: must match the bytes we actually emit (post-decode).
|
|
// We set the right value below where we know it.
|
|
// - transfer-encoding / connection / keep-alive: hop-by-hop headers,
|
|
// must not cross the proxy boundary.
|
|
const excludedHeaders = new Set([
|
|
'content-encoding',
|
|
'content-length',
|
|
'transfer-encoding',
|
|
'connection',
|
|
'keep-alive',
|
|
]);
|
|
|
|
// Use a Headers object so name comparison is case-insensitive — this
|
|
// prevents the `content-type` (lowercase from Headers.entries) and
|
|
// `Content-Type` (title-case override) duplication that produced
|
|
// "application/epub+zip, application/epub+zip" responses.
|
|
const buildResponseHeaders = (extras: Record<string, string>) => {
|
|
const h = new Headers();
|
|
for (const [key, value] of response.headers.entries()) {
|
|
if (!excludedHeaders.has(key.toLowerCase())) {
|
|
h.set(key, value);
|
|
}
|
|
}
|
|
// Don't cache file downloads — a single broken response would otherwise
|
|
// be cached for 5 minutes and keep returning 0 bytes. Catalog feeds
|
|
// (XML/JSON) are still cacheable.
|
|
const isFileDownload =
|
|
stream === 'true' ||
|
|
(upstreamContentDisposition ?? '').toLowerCase().includes('attachment');
|
|
h.set('Cache-Control', isFileDownload ? 'no-store' : 'public, max-age=300');
|
|
h.set('Access-Control-Allow-Origin', '*');
|
|
h.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
|
h.set('Access-Control-Allow-Headers', 'Content-Type');
|
|
for (const [key, value] of Object.entries(extras)) {
|
|
h.set(key, value);
|
|
}
|
|
return h;
|
|
};
|
|
|
|
if (method === 'HEAD') {
|
|
console.log(`[OPDS Proxy] HEAD Success: ${url}`);
|
|
return new NextResponse(null, {
|
|
status: 200,
|
|
headers: buildResponseHeaders({
|
|
'Content-Type': contentType,
|
|
...(contentLength ? { 'Content-Length': contentLength } : {}),
|
|
}),
|
|
});
|
|
}
|
|
|
|
if (stream === 'true' && contentLength && parseInt(contentLength) > 1024 * 1024) {
|
|
console.log(`[OPDS Proxy] Streaming: ${url} (${contentLength} bytes)`);
|
|
const headers = buildResponseHeaders({
|
|
'Content-Type': contentType,
|
|
// Surface the upstream length to the client without setting the real
|
|
// Content-Length header (which must match the streamed bytes — and
|
|
// we let Next.js / the runtime compute that).
|
|
'X-Content-Length': contentLength,
|
|
'Access-Control-Expose-Headers': 'X-Content-Length',
|
|
});
|
|
return new NextResponse(response.body, { status: 200, headers });
|
|
} else {
|
|
const buf = await response.arrayBuffer();
|
|
const length = buf.byteLength;
|
|
console.log(`[OPDS Proxy] Buffered Success: ${url} (${length} bytes)`);
|
|
return new NextResponse(buf, {
|
|
status: 200,
|
|
headers: buildResponseHeaders({
|
|
'Content-Type': contentType,
|
|
'Content-Length': length.toString(),
|
|
}),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('[OPDS Proxy] Error:', error);
|
|
|
|
if (error instanceof Error) {
|
|
if (error.name === 'AbortError') {
|
|
return NextResponse.json(
|
|
{ error: 'Request timeout - the OPDS server took too long to respond' },
|
|
{ status: 504 },
|
|
);
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{
|
|
error: error.message,
|
|
url: url,
|
|
hint: 'Check if the OPDS URL is accessible and returns valid OPDS/Atom/JSON content',
|
|
},
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
return NextResponse.json({ error: 'Failed to fetch OPDS feed', url: url }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
return handleRequest(request, 'GET');
|
|
}
|
|
|
|
export async function HEAD(request: NextRequest) {
|
|
return handleRequest(request, 'HEAD');
|
|
}
|
|
|
|
export async function OPTIONS(_: NextRequest) {
|
|
return new NextResponse(null, {
|
|
status: 200,
|
|
headers: {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
},
|
|
});
|
|
}
|