Skip to content

Commit 0dfca3d

Browse files
committed
✨ feat: authenticated catalog covers via Coil + thumbnail-rel parsing (#2)
* docs: 📝 design for authenticated catalog cover loading Documents the gap (Coil uses a default ImageLoader without the OPDS BasicAuthInterceptor, so calibre-web cover requests 401) and the planned fix: an ImageLoaderFactory on EReaderApp wired to OpdsHttpClient.okHttp, plus reordering OpdsClient to prefer rel="…/image/thumbnail" over rel="…/image" for grid use. * docs: 📝 implementation plan for catalog covers Plan for wiring Coil to OpdsHttpClient.okHttp via an ImageLoaderFactory on EReaderApp, and reordering OpdsClient to prefer rel="…/image/thumbnail" over rel="…/image". Tests, fixtures, and manual verification steps included. References the design at docs/superpowers/specs/2026-05-07-catalog-covers-design.md. * feat: ✨ prefer OPDS thumbnail rel and survive thumbnail-only feeds Readium's OPDS model populates pub.subcollections["images"] only when an opds:image rel is present and silently drops opds:image/thumbnail when it appears alone. Skip the Readium-routed image extraction and parse cover links directly from each <entry>, mirroring the existing parseSearchLink workaround for the analogous {searchTerms} quirk. Within an entry, prefer opds:image/thumbnail (sized for the 2-col grid) over opds:image, and join back to Readium's parsed publications via the unique epub acquisition href. Spec at docs/superpowers/specs/2026-05-07-catalog-covers-design.md updated to record the discovered Readium behavior. * feat: ✨ route Coil image loads through authenticated OPDS OkHttp client Coil's default singleton ImageLoader has no awareness of the OPDS BasicAuthInterceptor, so cover requests against authenticated calibre-web instances 401 silently and the catalog grid only ever shows the gradient+initials fallback. Implement coil.ImageLoaderFactory on EReaderApp with an ImageLoader built against the existing OpdsHttpClient.okHttp, so Coil inherits Basic auth (and any future interceptors) automatically. AppContainer's opdsHttp is promoted from private to public to be reachable from the Application class.
1 parent 14ade5c commit 0dfca3d

8 files changed

Lines changed: 593 additions & 10 deletions

File tree

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
package io.theficos.ereader
22

33
import android.app.Application
4+
import coil.ImageLoader
5+
import coil.ImageLoaderFactory
46
import io.theficos.ereader.di.AppContainer
57

6-
class EReaderApp : Application() {
8+
class EReaderApp : Application(), ImageLoaderFactory {
79
lateinit var container: AppContainer
810
private set
911

1012
override fun onCreate() {
1113
super.onCreate()
1214
container = AppContainer(this)
1315
}
16+
17+
override fun newImageLoader(): ImageLoader =
18+
ImageLoader.Builder(this)
19+
.okHttpClient(container.opdsHttp.okHttp)
20+
.build()
1421
}

app/src/main/java/io/theficos/ereader/di/AppContainer.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class AppContainer(context: Context) {
2020

2121
val credentialStore: CalibreCredentialStore = CalibreCredentialStore(appContext)
2222

23-
private val opdsHttp = OpdsHttpClient(credentialStore)
23+
val opdsHttp = OpdsHttpClient(credentialStore)
2424
val opdsClient: OpdsClient = OpdsClient(opdsHttp.okHttp)
2525
val bookDownloader: BookDownloader = BookDownloader(
2626
okHttp = opdsHttp.okHttp,

data/opds/src/main/java/io/theficos/ereader/data/opds/OpdsClient.kt

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ class OpdsClient(
2626
// Readium silently drops links whose href contains template chars like `{searchTerms}`,
2727
// so we always look for rel="search" ourselves from the raw XML.
2828
val searchLink = parseSearchLink(bytes, absoluteUrl)
29+
// Readium also drops opds:image/thumbnail links unless a sibling opds:image link
30+
// is present, so we pull cover hrefs from the raw XML and join by acquisition href.
31+
val coversByEpubHref = parseCoverHrefs(bytes, absoluteUrl)
2932
OpdsFeed(
3033
title = feed.metadata.title,
3134
navigation = feed.navigation.map { link ->
@@ -39,17 +42,12 @@ class OpdsClient(
3942
link.rels.contains("http://opds-spec.org/acquisition") &&
4043
link.mediaType.toString() == "application/epub+zip"
4144
} ?: return@mapNotNull null
42-
val imageLinks = pub.subcollections["images"].orEmpty().flatMap { it.links }
43-
val coverLink = imageLinks.firstOrNull { link ->
44-
link.rels.contains("http://opds-spec.org/image")
45-
} ?: imageLinks.firstOrNull { link ->
46-
link.rels.contains("http://opds-spec.org/image/thumbnail")
47-
} ?: imageLinks.firstOrNull()
45+
val absoluteEpubHref = absolutize(absoluteUrl, epubLink.href.toString())
4846
OpdsPublication(
4947
title = pub.metadata.title.orEmpty(),
5048
author = pub.metadata.authors.firstOrNull()?.name,
51-
epubDownloadHref = absolutize(absoluteUrl, epubLink.href.toString()),
52-
coverUrl = coverLink?.href?.toString()?.let { absolutize(absoluteUrl, it) },
49+
epubDownloadHref = absoluteEpubHref,
50+
coverUrl = coversByEpubHref[absoluteEpubHref],
5351
)
5452
},
5553
searchLink = searchLink,
@@ -64,6 +62,41 @@ class OpdsClient(
6462
return absolutize(link.baseUrl, applyTemplate(rawTemplate, query))
6563
}
6664

65+
private fun parseCoverHrefs(bytes: ByteArray, feedUrl: String): Map<String, String> {
66+
val doc = runCatching {
67+
DocumentBuilderFactory.newInstance()
68+
.apply { isNamespaceAware = true }
69+
.newDocumentBuilder()
70+
.parse(ByteArrayInputStream(bytes))
71+
}.getOrNull() ?: return emptyMap()
72+
val entries = doc.getElementsByTagNameNS("http://www.w3.org/2005/Atom", "entry")
73+
val result = mutableMapOf<String, String>()
74+
for (i in 0 until entries.length) {
75+
val entry = entries.item(i) as org.w3c.dom.Element
76+
val links = entry.getElementsByTagNameNS("http://www.w3.org/2005/Atom", "link")
77+
var epubHref: String? = null
78+
var thumbnailHref: String? = null
79+
var imageHref: String? = null
80+
for (j in 0 until links.length) {
81+
val el = links.item(j) as org.w3c.dom.Element
82+
val rel = el.getAttribute("rel")
83+
val href = el.getAttribute("href").takeIf { it.isNotBlank() } ?: continue
84+
when (rel) {
85+
"http://opds-spec.org/acquisition" -> {
86+
if (el.getAttribute("type") == "application/epub+zip") epubHref = href
87+
}
88+
"http://opds-spec.org/image/thumbnail" -> thumbnailHref = href
89+
"http://opds-spec.org/image" -> imageHref = href
90+
}
91+
}
92+
val cover = thumbnailHref ?: imageHref
93+
if (epubHref != null && cover != null) {
94+
result[absolutize(feedUrl, epubHref)] = absolutize(feedUrl, cover)
95+
}
96+
}
97+
return result
98+
}
99+
67100
private fun parseSearchLink(bytes: ByteArray, feedUrl: String): OpdsSearchLink? {
68101
val doc = runCatching {
69102
DocumentBuilderFactory.newInstance()

data/opds/src/test/java/io/theficos/ereader/data/opds/OpdsClientTest.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ class OpdsClientTest {
4242
.setBody(resource("/opds/catalog-with-direct-search.xml"))
4343
"/opds/osd" -> MockResponse().setHeader("Content-Type", "application/opensearchdescription+xml")
4444
.setBody(resource("/opds/opensearch.xml"))
45+
"/opds/both" -> MockResponse().setHeader("Content-Type", "application/atom+xml")
46+
.setBody(resource("/opds/catalog-feed-thumbnail-and-image.xml"))
47+
"/opds/thumb-only" -> MockResponse().setHeader("Content-Type", "application/atom+xml")
48+
.setBody(resource("/opds/catalog-feed-thumbnail-only.xml"))
4549
else -> MockResponse().setResponseCode(404)
4650
}
4751
}
@@ -111,4 +115,25 @@ class OpdsClientTest {
111115
assertThat(resolved).doesNotContain("startIndex={")
112116
}
113117

118+
@Test fun `cover prefers thumbnail rel when both rels present`() = runTest {
119+
val feed = client.fetch(server.url("/opds/both").toString())
120+
val pub = feed.publications.single()
121+
assertThat(pub.coverUrl).isNotNull()
122+
assertThat(pub.coverUrl).endsWith("/opds/cover/42/thumb")
123+
}
124+
125+
@Test fun `cover uses thumbnail rel when only thumbnail is present`() = runTest {
126+
val feed = client.fetch(server.url("/opds/thumb-only").toString())
127+
val pub = feed.publications.single()
128+
assertThat(pub.coverUrl).isNotNull()
129+
assertThat(pub.coverUrl).endsWith("/opds/cover/42/thumb")
130+
}
131+
132+
@Test fun `cover falls back to full-size image rel when thumbnail is absent`() = runTest {
133+
val feed = client.fetch(server.url("/opds/new").toString())
134+
val pub = feed.publications.single()
135+
assertThat(pub.coverUrl).isNotNull()
136+
assertThat(pub.coverUrl).endsWith("/opds/cover/42")
137+
}
138+
114139
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:opds="http://opds-spec.org/2010/catalog" xmlns:dc="http://purl.org/dc/terms/">
3+
<id>urn:calibre-web:both</id>
4+
<title>Both Rels</title>
5+
<updated>2026-04-26T00:00:00Z</updated>
6+
<link rel="self" href="/opds/both" type="application/atom+xml;profile=opds-catalog;kind=acquisition"/>
7+
<entry>
8+
<title>The Sample Book</title>
9+
<id>urn:calibre-web:42</id>
10+
<updated>2026-04-26T00:00:00Z</updated>
11+
<author><name>Jane Doe</name></author>
12+
<dc:identifier>urn:uuid:550e8400-e29b-41d4-a716-446655440000</dc:identifier>
13+
<link rel="http://opds-spec.org/acquisition" href="/opds/download/42/epub" type="application/epub+zip"/>
14+
<link rel="http://opds-spec.org/image" href="/opds/cover/42" type="image/jpeg"/>
15+
<link rel="http://opds-spec.org/image/thumbnail" href="/opds/cover/42/thumb" type="image/jpeg"/>
16+
</entry>
17+
</feed>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:opds="http://opds-spec.org/2010/catalog" xmlns:dc="http://purl.org/dc/terms/">
3+
<id>urn:calibre-web:thumb-only</id>
4+
<title>Thumb Only</title>
5+
<updated>2026-04-26T00:00:00Z</updated>
6+
<link rel="self" href="/opds/thumb-only" type="application/atom+xml;profile=opds-catalog;kind=acquisition"/>
7+
<entry>
8+
<title>The Sample Book</title>
9+
<id>urn:calibre-web:42</id>
10+
<updated>2026-04-26T00:00:00Z</updated>
11+
<author><name>Jane Doe</name></author>
12+
<dc:identifier>urn:uuid:550e8400-e29b-41d4-a716-446655440000</dc:identifier>
13+
<link rel="http://opds-spec.org/acquisition" href="/opds/download/42/epub" type="application/epub+zip"/>
14+
<link rel="http://opds-spec.org/image/thumbnail" href="/opds/cover/42/thumb" type="image/jpeg"/>
15+
</entry>
16+
</feed>

0 commit comments

Comments
 (0)