Something went wrong. Try again.
The Cavu Reader
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182/** * OPDS 1.2 (Atom) catalog feeds for downloadable books. * * Two feeds: a navigation root at /opds and an acquisition feed at * /opds/all listing every indexed book whose author set * page.cavu.book.allowDownloads. Entries point at the existing * /{did}/{rkey}/download/{epub|pdf} routes, so the feed is a thin XML * view over the publications table — no records are fetched here. * Every URL is absolute (they leave our origin for e-reader apps). */import { canonicalUrl, rootUrl } from '#/lib/hosted'import { listPublicationsPage } from '#/server/db'import type { PublicationRow } from '#/server/db'import { escapeXml } from '#/server/export/xhtml'import { ensureShelfIndex } from '#/server/shelfIndex'import { CORS_HEADERS, coverUrls } from '#/server/xrpc'
export const OPDS_PAGE_SIZE = 50
const NAV_TYPE = 'application/atom+xml;profile=opds-catalog;kind=navigation'const ACQ_TYPE = 'application/atom+xml;profile=opds-catalog;kind=acquisition'const ACQUISITION_REL = 'http://opds-spec.org/acquisition'const IMAGE_REL = 'http://opds-spec.org/image'const THUMB_REL = 'http://opds-spec.org/image/thumbnail'
export function feedContentType(kind: 'navigation' | 'acquisition'): string { return `${kind === 'navigation' ? NAV_TYPE : ACQ_TYPE};charset=utf-8`}
/** `<rkey>|<uri>` — same shape as page.cavu.getShelf's cursor. */export function parseCursor(cursor: string): { rkey: string; uri: string } | undefined { const split = cursor.indexOf('|') if (split < 1 || !cursor.slice(split + 1).startsWith('at://')) return undefined return { rkey: cursor.slice(0, split), uri: cursor.slice(split + 1) }}
export function nextCursor(rows: PublicationRow[]): string | undefined { if (rows.length < OPDS_PAGE_SIZE) return undefined const last = rows[rows.length - 1] return `${last.rkey}|${last.uri}`}
interface Attrs { [name: string]: string | undefined}
function el(name: string, attrs: Attrs, body?: string): string { const a = Object.entries(attrs) .filter(([, v]) => v !== undefined) .map(([k, v]) => ` ${k}="${escapeXml(v as string)}"`) .join('') return body === undefined ? `<${name}${a}/>` : `<${name}${a}>${body}</${name}>`}
function text(name: string, value: string): string { return el(name, {}, escapeXml(value))}
function link(rel: string, href: string, type?: string, title?: string): string { return el('link', { rel, href, type, title })}
function feedOpen(root: string, id: string, title: string, updated: string, links: string[]): string { return ( `<?xml version="1.0" encoding="UTF-8"?>\n` + `<feed xmlns="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/terms/" xmlns:opds="http://opds-spec.org/2010/catalog">\n` + ` ${text('id', id)}\n` + ` ${text('title', title)}\n` + ` ${text('updated', updated)}\n` + ` <author><name>Cavu</name>${text('uri', root)}</author>\n` + links.map((l) => ` ${l}\n`).join('') )}
/** The catalog root: a navigation feed with one entry per sub-feed. */export function navigationFeed(root: string, now: string): string { const self = `${root}/opds` const all = `${root}/opds/all` return ( feedOpen(root, self, 'Cavu', now, [ link('self', self, NAV_TYPE), link('start', self, NAV_TYPE), ]) + ` <entry>\n` + ` ${text('title', 'All books')}\n` + ` ${text('id', all)}\n` + ` ${text('updated', now)}\n` + ` ${text('content', 'Every book on Cavu whose author allows downloads, newest first.')}\n` + ` ${link('subsection', all, ACQ_TYPE)}\n` + ` </entry>\n` + `</feed>\n` )}
/** One page of downloadable books, newest first. */export function acquisitionFeed( root: string, rows: PublicationRow[], opts: { cursor?: string; next?: string; now: string },): string { const start = `${root}/opds` const base = `${root}/opds/all` const self = opts.cursor ? `${base}?cursor=${encodeURIComponent(opts.cursor)}` : base const updated = rows.reduce((max, r) => (r.indexed_at > max ? r.indexed_at : max), '') || opts.now const links = [ link('self', self, ACQ_TYPE), link('start', start, NAV_TYPE), link('up', start, NAV_TYPE), ] if (opts.next) { links.push(link('next', `${base}?cursor=${encodeURIComponent(opts.next)}`, ACQ_TYPE)) } return ( feedOpen(root, base, 'Cavu — books you can download', updated, links) + rows.map((row) => entry(root, row)).join('') + `</feed>\n` )}
function entry(root: string, row: PublicationRow): string { const bookUrl = canonicalUrl(row.did, row.rkey, root)! const authors = JSON.parse(row.authors) as string[] const covers = coverUrls(row) // CDN renditions are always webp (lib/cdnUrl); the /img fallback // redirects to a PDS blob of unknown type, so leave `type` off there. const cdn = row.hosted === 1 && row.cdn_ready_at != null const imageType = cdn ? 'image/webp' : undefined const lines = [ text('id', row.uri), text('title', row.title), text('updated', row.indexed_at), ...authors.map((name) => `<author>${text('name', name)}</author>`), ] if (row.description) lines.push(el('summary', { type: 'text' }, escapeXml(row.description))) if (covers.cover) lines.push(link(IMAGE_REL, covers.cover, imageType)) if (covers.thumb) lines.push(link(THUMB_REL, covers.thumb, imageType)) lines.push( link(ACQUISITION_REL, `${bookUrl}/download/epub`, 'application/epub+zip', 'EPUB'), link(ACQUISITION_REL, `${bookUrl}/download/pdf`, 'application/pdf', 'PDF'), link('alternate', bookUrl, 'text/html', 'Read on Cavu'), ) return ` <entry>\n` + lines.map((l) => ` ${l}\n`).join('') + ` </entry>\n`}
// --- HTTP glue (routes/opds.*.ts) ---
/** Absolute origin for feed URLs: the deployment root, else the request's. */function feedRoot(request: Request): string { return rootUrl() ?? new URL(request.url).origin}
function feedResponse(body: string, kind: 'navigation' | 'acquisition'): Response { return new Response(body, { status: 200, headers: { ...CORS_HEADERS, 'content-type': feedContentType(kind), 'cache-control': 'public, max-age=300', }, })}
export function handleOpdsRoot(request: Request): Response { return feedResponse(navigationFeed(feedRoot(request), new Date().toISOString()), 'navigation')}
export function handleOpdsAll(request: Request): Response { const cursor = new URL(request.url).searchParams.get('cursor') ?? undefined const after = cursor ? parseCursor(cursor) : undefined if (cursor && !after) { return new Response('malformed cursor', { status: 400, headers: CORS_HEADERS }) } ensureShelfIndex() const rows = listPublicationsPage({ limit: OPDS_PAGE_SIZE, after, downloadable: true }) const body = acquisitionFeed(feedRoot(request), rows, { cursor, next: nextCursor(rows), now: new Date().toISOString(), }) return feedResponse(body, 'acquisition')}