From 89603b8ea78937f488c28fe800d0b9ad75d446c5 Mon Sep 17 00:00:00 2001 From: Steve Grosbois Date: Wed, 21 Oct 2026 09:04:33 +0200 Subject: [PATCH] Load OPDS covers with the catalog's credentials Covers were loaded by the webview with a plain , which sends no Authorization header, so catalogs behind a login (Calibre-Web with anonymous browsing off, COPS...) answered 401 and only the title placeholder showed. - Add an opds_image command that fetches the image with the catalog's Basic/Bearer auth and returns it as a data: URL. - Browse covers go through it for authenticated catalogs, only once they near the screen (IntersectionObserver), like loading="lazy". - Downloads from such catalogs keep a 120px JPEG data: URL as their cover, since the downloads list has no credentials. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Gha79rGY6XCLY2TF28saL --- app/src-tauri/src/books.rs | 5 +++ app/src-tauri/src/lib.rs | 1 + app/src-tauri/src/opds/mod.rs | 18 +++++++++ app/src/Browse.jsx | 71 ++++++++++++++++++++++++++--------- app/src/catalogs.js | 41 +++++++++++++++++++- app/src/catalogs.test.js | 33 +++++++++++++++- 6 files changed, 147 insertions(+), 22 deletions(-) diff --git a/app/src-tauri/src/books.rs b/app/src-tauri/src/books.rs index 5e43306..645cfe3 100644 --- a/app/src-tauri/src/books.rs +++ b/app/src-tauri/src/books.rs @@ -82,6 +82,11 @@ pub async fn opds_search(url: String, query: String, auth: Option) -> Res< opds::search(&auth.unwrap_or_default(), &url, &query).await.map_err(err) } +#[tauri::command] +pub async fn opds_image(url: String, auth: Option) -> Res { + opds::image(&auth.unwrap_or_default(), &url).await.map_err(err) +} + #[tauri::command] pub async fn download_book( app: AppHandle, diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index f85eec1..e9a280d 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -31,6 +31,7 @@ pub fn run() { mdns::resolve_local, books::opds_feed, books::opds_search, + books::opds_image, books::download_book, books::list_downloads, books::delete_download, diff --git a/app/src-tauri/src/opds/mod.rs b/app/src-tauri/src/opds/mod.rs index 892260d..6b8096e 100644 --- a/app/src-tauri/src/opds/mod.rs +++ b/app/src-tauri/src/opds/mod.rs @@ -125,6 +125,24 @@ pub async fn download( Ok((bytes, ct, cd)) } +/// Fetch a cover or thumbnail with the catalog's credentials, as a data: URL. +/// The webview can't load these itself: an sends no Authorization header, +/// so catalogs behind a login (Calibre-Web, COPS...) answer 401. +pub async fn image(auth: &Auth, url: &str) -> Result { + let resp = check(apply(client(30).get(url).header(ACCEPT, "image/*"), auth).send().await?)?; + let ct = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + if !ct.starts_with("image/") { + return Err(anyhow!("Not an image ({})", ct)); + } + let bytes = resp.bytes().await?; + Ok(format!("data:{};base64,{}", ct, B64.encode(&bytes))) +} + /// Parse an OpenSearch Description Document and return the best atom/opds /// search URL template. Returns None if no usable template is found. /// Expand an OPDS search template. Catalogs advertise either OpenSearch's diff --git a/app/src/Browse.jsx b/app/src/Browse.jsx index 5c6af22..b56deb6 100644 --- a/app/src/Browse.jsx +++ b/app/src/Browse.jsx @@ -3,7 +3,7 @@ import { impactFeedback, selectionFeedback } from '@tauri-apps/plugin-haptics' import { ArrowLeft, BookOpen, ChevronRight, Download, GripVertical, Loader2, Pencil, Plus, Search, Send as SendIcon, Server, Trash2 } from 'lucide-react' import { isApp } from './api.js' import { loadDevicePrefs } from './device.js' -import { download, feed as fetchFeed, loadCatalogs, moveCatalog, navRows, readable, saveCatalogs, search, sendDownload } from './catalogs.js' +import { download, feed as fetchFeed, image, loadCatalogs, moveCatalog, navRows, needsAuthedImage, readable, saveCatalogs, search, sendDownload } from './catalogs.js' import { progressLabel } from './Send.jsx' import { Card, EmptyAction, EmptyState, Eyebrow, Spinner, folderLabel, notify } from './ui.jsx' import { useNativeBack } from './tabbar.js' @@ -78,13 +78,48 @@ function BackBar({ back, label }) { ) } -function Cover({ entry, large = false, className = '' }) { +// The image to put in an : the URL itself, or, behind a login, the data: URL the +// app fetched with the catalog's credentials (undefined while loading, null if it failed). +// Only fetched once the cover nears the screen, like . +function useCoverSrc(cat, url, seen) { + const authed = needsAuthedImage(cat, url) + const [loaded, setLoaded] = useState(null) // { url, src } + useEffect(() => { + if (!authed || !seen) return + let live = true + image(cat, url).then( + (src) => live && setLoaded({ url, src }), + () => live && setLoaded({ url, src: null }), + ) + return () => { + live = false + } + }, [cat, url, authed, seen]) + if (!authed) return url + return loaded?.url === url ? loaded.src : undefined +} + +// Whether the element has come near the screen (stays true once it has). +function useSeen() { + const ref = useRef(null) + const [seen, setSeen] = useState(typeof IntersectionObserver === 'undefined') + useEffect(() => { + if (seen || !ref.current) return + const io = new IntersectionObserver(([e]) => e.isIntersecting && setSeen(true), { rootMargin: '300px' }) + io.observe(ref.current) + return () => io.disconnect() + }, [seen]) + return [ref, seen] +} + +function Cover({ cat, entry, large = false, className = '' }) { const [broken, setBroken] = useState(false) - const src = large ? (entry.cover ?? entry.thumbnail) : (entry.thumbnail ?? entry.cover) + const [ref, seen] = useSeen() + const src = useCoverSrc(cat, large ? (entry.cover ?? entry.thumbnail) : (entry.thumbnail ?? entry.cover), seen) const frame = `aspect-[2/3] overflow-hidden rounded-md shadow-sm ring-1 ring-stone-950/10 ${className}` - if (src && !broken) return setBroken(true)} className={`${frame} block h-auto max-w-full object-cover`} /> + if (src && !broken) return setBroken(true)} className={`${frame} block h-auto max-w-full object-cover`} /> return ( -
+

{entry.title}

{entry.authors.map(person)[0]}

@@ -92,21 +127,21 @@ function Cover({ entry, large = false, className = '' }) { } // block + w-full: a button otherwise sizes to its content, and a wide cover image blows out its slot. -function Tile({ entry, onOpen }) { +function Tile({ cat, entry, onOpen }) { return ( ) } -function Grid({ entries, onOpen }) { +function Grid({ cat, entries, onOpen }) { return (
{entries.map((e, i) => ( - + ))}
) @@ -133,12 +168,12 @@ function Rail({ title, more, children }) { const railItem = 'w-28 shrink-0 snap-start sm:w-32 md:w-36' -function CoverRail({ title, more, entries, onOpen }) { +function CoverRail({ cat, title, more, entries, onOpen }) { return ( {entries.map((e, i) => (
- +
))}
@@ -249,7 +284,7 @@ function BookPage({ cat, entryKey }) {
@@ -384,7 +419,7 @@ function SectionRail({ cat, link }) { if (failed) return null if (!feed) return const books = feed.groups.length ? feed.groups.flatMap((g) => g.entries) : feed.entries - if (books.length) return + if (books.length) return // A section of sub-sections (genres, authors...): swipeable category tiles. const subs = navRows(feed.navigation).slice(0, 24) if (!subs.length) return null @@ -422,7 +457,7 @@ function CatalogBlock({ cat }) { if (error) body =

{error}

else if (!root) body = else if (root.entries.length && !root.groups.length) { - body = + body = } else { // OPDS 2 groups arrive with their books; nav sections load their own rails. const lanes = root.groups.filter((g) => g.entries.length) @@ -430,7 +465,7 @@ function CatalogBlock({ cat }) { body = ( <> {lanes.slice(0, MAX_RAILS).map((g, i) => ( - + ))} {sections.slice(0, Math.max(0, MAX_RAILS - lanes.length)).map((l) => ( @@ -518,7 +553,7 @@ function SearchAll({ catalogs, query }) {

{r.error ?? 'No matches.'}

) - return + return })}
) @@ -601,12 +636,12 @@ function FeedView({ cat, url, query }) { )} {feed.groups.map((g, gi) => ( - + ))} {loose.length > 0 && (
- +
)} diff --git a/app/src/catalogs.js b/app/src/catalogs.js index f6e434a..8ca207e 100644 --- a/app/src/catalogs.js +++ b/app/src/catalogs.js @@ -48,6 +48,11 @@ const authKey = (cat) => JSON.stringify(cat.auth ?? null) export const feed = (cat, url = cat.url) => memo(`f ${authKey(cat)} ${url}`, () => invoke('opds_feed', { url, auth: cat.auth })) export const search = (cat, query) => memo(`s ${authKey(cat)} ${cat.url} ${query}`, () => invoke('opds_search', { url: cat.url, query, auth: cat.auth })) +// An sends no credentials, so covers from a catalog behind a login are fetched +// by the app (with the catalog's auth) and handed back as data: URLs. +export const needsAuthedImage = (cat, url) => !!url && (cat.auth?.kind ?? 'none') !== 'none' && /^https?:/i.test(url) +export const image = (cat, url) => memo(`i ${authKey(cat)} ${url}`, () => invoke('opds_image', { url, auth: cat.auth })) + // Rows worth showing from a feed's navigation: drop page chrome (start, self, html alternates). export const navRows = (links) => links.filter((l) => !['start', 'self', 'alternate', 'search', 'up'].includes(l.rel) && !/html/.test(l.mime ?? '')) @@ -74,12 +79,44 @@ const onProgress = (cb) => { return ch } -export const download = (cat, entry, acq, progress = () => {}) => +// A data: URL of the image scaled down to `width` px, as JPEG. +function shrink(src, width) { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = Math.min(width, img.naturalWidth) + canvas.height = Math.round((img.naturalHeight * canvas.width) / img.naturalWidth) + canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height) + resolve(canvas.toDataURL('image/jpeg', 0.85)) + } + img.onerror = reject + img.src = src + }) +} + +// The cover kept with a download. The downloads list has no catalog credentials, so a +// cover behind a login is stored as a small data: URL (the list shows it 40px wide). +export async function storedCover(cat, url) { + if (!needsAuthedImage(cat, url)) return url ?? null + try { + return await shrink(await image(cat, url), 120) + } catch { + return null + } +} + +export const download = async (cat, entry, acq, progress = () => {}) => invoke('download_book', { url: acq.href, mime: acq.mime, auth: cat.auth, - meta: { title: entry.title, author: entry.authors[0]?.replace(/^([^,]+), ([^,]+)$/, '$2 $1') ?? null, cover: entry.cover ?? entry.thumbnail ?? null, source: cat.name }, + meta: { + title: entry.title, + author: entry.authors[0]?.replace(/^([^,]+), ([^,]+)$/, '$2 $1') ?? null, + cover: await storedCover(cat, entry.cover ?? entry.thumbnail), + source: cat.name, + }, onProgress: onProgress(progress), }) diff --git a/app/src/catalogs.test.js b/app/src/catalogs.test.js index 69edeab..f15d1a3 100644 --- a/app/src/catalogs.test.js +++ b/app/src/catalogs.test.js @@ -1,6 +1,9 @@ // @vitest-environment jsdom -import { expect, it } from 'vitest' -import { moveCatalog } from './catalogs.js' +import { expect, it, vi } from 'vitest' +import { invoke } from '@tauri-apps/api/core' +import { image, moveCatalog, needsAuthedImage, storedCover } from './catalogs.js' + +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), Channel: class {} })) it('moves a catalog to where another one is', () => { const list = ['a', 'b', 'c', 'd'].map((id) => ({ id })) @@ -9,3 +12,29 @@ it('moves a catalog to where another one is', () => { expect(ids(moveCatalog(list, 'd', 'b'))).toBe('adbc') // up expect(ids(list)).toBe('abcd') // untouched }) + +it('fetches covers through the app only for catalogs behind a login', () => { + const open = { auth: { kind: 'none' } } + const basic = { auth: { kind: 'basic', username: 'u', password: 'p' } } + expect(needsAuthedImage(open, 'https://x.org/c.jpg')).toBe(false) + expect(needsAuthedImage({}, 'https://x.org/c.jpg')).toBe(false) + expect(needsAuthedImage(basic, 'https://x.org/c.jpg')).toBe(true) + expect(needsAuthedImage(basic, 'data:image/png;base64,AAAA')).toBe(false) + expect(needsAuthedImage(basic, undefined)).toBe(false) +}) + +it('fetches a cover once with the catalog credentials', async () => { + invoke.mockResolvedValue('data:image/jpeg;base64,AAAA') + const cat = { auth: { kind: 'basic', username: 'u', password: 'p' } } + expect(await image(cat, 'https://x.org/opds/cover/1')).toBe('data:image/jpeg;base64,AAAA') + await image(cat, 'https://x.org/opds/cover/1') + expect(invoke).toHaveBeenCalledTimes(1) + expect(invoke).toHaveBeenCalledWith('opds_image', { url: 'https://x.org/opds/cover/1', auth: cat.auth }) +}) + +it('keeps a public cover URL as is with a download, and drops a login-only one it cannot fetch', async () => { + expect(await storedCover({ auth: { kind: 'none' } }, 'https://x.org/c.jpg')).toBe('https://x.org/c.jpg') + expect(await storedCover({ auth: { kind: 'none' } }, undefined)).toBe(null) + invoke.mockRejectedValueOnce('401') + expect(await storedCover({ auth: { kind: 'bearer', token: 't' } }, 'https://x.org/opds/cover/2')).toBe(null) +})