Serve EPUBs instead XTC in daily OPDS

It turns out it is currently impossible for the Xteink X4 to download
XTC files from an OPDS server. So I switched the built-in OPDS server in
this binary (daily.hallada.net/opds) to serve the EPUB files instead of
the XTC files. The X4 is pretty capable of reading the X4 edition of the
EPUB that was optimized for it anyways, and I prefer the flexibility of
EPUB, so I might end up eventually deleting the XTC conversion. For now,
I kept the conversion step (in case I ever want to ever try manually
copying them to the device) and I keep a limited number of XTC issues on
the server since they are quite big in filesize. The BookOrbit
integration is now optional since the built-in OPDS server is able to
serve the EPUBs. In my installation, I serve the EPUBs through both. The
daily.hallada.net/opds server is just a little quicker to navigate and
download the EPUBs on the X4 since the BookOrbit OPDS requires diving
into a couple layers of folders before you get to the files.
This commit is contained in:
2026-08-15 20:24:20 +00:00
parent 9e30c1dcdf
commit 9e27a32fb6
12 changed files with 630 additions and 282 deletions
+19 -11
View File
@@ -59,7 +59,7 @@ fn test_config(root: &Path) -> Config {
prefilter_keep: 20,
world_briefing: false,
publish: PublishConfig {
bookorbit_dir: root.join("bookorbit"),
epub_dir: root.join("bookorbit"),
xtc_dir: root.join("xtc"),
},
xtc: XtcConfig {
@@ -305,35 +305,44 @@ async fn assemble_build_publish(
);
// --- Publish (§3.11) ---
let published = publish::publish_issue(db, cfg, &issue, &artifacts, None)
let published = publish::publish_issue(cfg, &issue, &artifacts, None)
.await
.expect("publish");
assert_eq!(published.epubs.len(), 2);
for artifact in &published.epubs {
assert!(artifact.path.starts_with(&cfg.publish.bookorbit_dir));
assert!(artifact.path.starts_with(&cfg.publish.epub_dir));
assert!(artifact.path.exists(), "{}", artifact.path.display());
}
assert!(
cfg.publish
.bookorbit_dir
.epub_dir
.join("The Daily EPUB - 2026-08-15.epub")
.exists()
);
assert!(
cfg.publish
.bookorbit_dir
.epub_dir
.join("The Daily EPUB - 2026-08-15 (X4).epub")
.exists()
);
assert!(published.xtc.is_none(), "the converter is disabled here");
// The OPDS feed is regenerated on every publish, even with no XTC files yet.
let opds = published.opds.clone().expect("an OPDS feed was written");
assert_eq!(opds, cfg.publish.xtc_dir.join("xtc.xml"));
let feed = std::fs::read_to_string(&opds).expect("read the OPDS feed");
// The OPDS feed is derived from what was just published — both editions,
// typed so CrossPoint will accept them (§3.11).
let feed = publish::build_opds(db, cfg).await.expect("build the feed");
assert!(feed.starts_with("<?xml"), "{feed}");
assert!(feed.contains("<feed xmlns=\"http://www.w3.org/2005/Atom\""));
assert!(feed.contains(&cfg.server.public_url));
assert_eq!(feed.matches("<entry>").count(), 2, "{feed}");
assert_eq!(feed.matches("application/epub+zip").count(), 2, "{feed}");
assert!(
feed.contains("The Daily EPUB — 2026-08-15</title>"),
"{feed}"
);
assert!(
feed.contains("The Daily EPUB — 2026-08-15 (X4)</title>"),
"{feed}"
);
// --- Record (§3.13) ---
let epub_path = published
@@ -438,7 +447,6 @@ async fn skip_llm_pipeline_produces_a_published_issue() {
// Re-running the same date replaces rather than duplicates (notes §12).
let republished = publish::publish_issue(
&db,
&cfg,
&issue,
&[
@@ -458,7 +466,7 @@ async fn skip_llm_pipeline_produces_a_published_issue() {
.await
.expect("republish");
assert_eq!(republished.epubs.len(), 2);
let files: Vec<String> = std::fs::read_dir(&cfg.publish.bookorbit_dir)
let files: Vec<String> = std::fs::read_dir(&cfg.publish.epub_dir)
.expect("read bookorbit dir")
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
.collect();
+48 -26
View File
@@ -41,6 +41,7 @@ impl Server {
let dir = tempfile::tempdir().expect("tempdir");
let xtc_dir = dir.path().join("xtc");
std::fs::create_dir_all(&xtc_dir).expect("xtc dir");
std::fs::create_dir_all(dir.path().join("bookorbit")).expect("epub dir");
let log = std::fs::File::create(dir.path().join("server.log")).expect("log file");
let port = free_port();
@@ -59,10 +60,7 @@ impl Server {
format!("http://127.0.0.1:{port}"),
)
.env("DAILY_EPUB_PUBLISH__XTC_DIR", &xtc_dir)
.env(
"DAILY_EPUB_PUBLISH__BOOKORBIT_DIR",
dir.path().join("bookorbit"),
)
.env("DAILY_EPUB_PUBLISH__EPUB_DIR", dir.path().join("bookorbit"))
.stdout(Stdio::null())
.stderr(Stdio::from(log));
if basic_auth {
@@ -80,6 +78,10 @@ impl Server {
self.dir.path().join("xtc")
}
fn epub_dir(&self) -> std::path::PathBuf {
self.dir.path().join("bookorbit")
}
fn wait_until_ready(&self) {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
@@ -217,22 +219,38 @@ fn binary_serves_health_issues_and_rating_endpoints() {
#[test]
fn binary_serves_opds_and_files_behind_basic_auth() {
let server = Server::start(true);
let name = "The Daily EPUB - 2026-08-15 (X4).xtch";
write(&server.xtc_dir().join("xtc.xml"), FEED);
write(&server.xtc_dir().join(name), "XTCH");
write(
&server.epub_dir().join("The Daily EPUB - 2026-08-15.epub"),
"STANDARD",
);
write(
&server
.epub_dir()
.join("The Daily EPUB - 2026-08-15 (X4).epub"),
"X4EPUB",
);
write(
&server
.xtc_dir()
.join("The Daily EPUB - 2026-08-15 (X4).xtch"),
"XTCH",
);
write(&server.dir.path().join("secret"), "top secret");
// No credentials → challenge.
let res = server.get("/opds/xtc.xml");
let res = server.get("/opds/daily.xml");
assert_eq!(res.status, 401);
assert_eq!(
res.header("www-authenticate"),
Some("Basic realm=\"The Daily EPUB\", charset=\"UTF-8\"")
);
assert_eq!(server.get_auth("/opds/xtc.xml", "bm9wZTpub3Bl").status, 401);
assert_eq!(
server.get_auth("/opds/daily.xml", "bm9wZTpub3Bl").status,
401
);
// Correct credentials → the feed, typed as OPDS.
let res = server.get_auth("/opds/xtc.xml", BASIC_AUTH);
// Correct credentials → a feed built from the publish dir, typed as OPDS.
let res = server.get_auth("/opds/daily.xml", BASIC_AUTH);
assert_eq!(res.status, 200);
assert!(
res.header("content-type")
@@ -242,8 +260,27 @@ fn binary_serves_opds_and_files_behind_basic_auth() {
res.headers
);
assert!(res.body.contains("opds-spec.org/acquisition"));
// Both editions, and only as `application/epub+zip` — the one acquisition
// type CrossPoint's parser accepts.
assert_eq!(res.body.matches("<entry>").count(), 2, "{}", res.body);
assert_eq!(
res.body.matches("type=\"application/epub+zip\"").count(),
2,
"{}",
res.body
);
assert!(!res.body.contains(".xtch"), "{}", res.body);
// The acquisition link in the feed resolves to the file itself.
let res = server.get_auth(
"/files/epub/The%20Daily%20EPUB%20-%202026-08-15%20%28X4%29.epub",
BASIC_AUTH,
);
assert_eq!(res.status, 200);
assert_eq!(res.body, "X4EPUB");
assert_eq!(res.header("content-type"), Some("application/epub+zip"));
// XTC stays reachable by URL for sideloading, just unlisted.
let res = server.get_auth(
"/files/xtc/The%20Daily%20EPUB%20-%202026-08-15%20%28X4%29.xtch",
BASIC_AUTH,
@@ -272,18 +309,3 @@ fn binary_serves_opds_and_files_behind_basic_auth() {
fn write(path: &Path, body: &str) {
std::fs::write(path, body).unwrap_or_else(|e| panic!("writing {}: {e}", path.display()));
}
/// A feed shaped like the one `publish::write_xtc_opds` generates.
const FEED: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>urn:daily-epub:xtc</id>
<title>The Daily EPUB — XTC editions</title>
<updated>2026-08-15T05:40:00Z</updated>
<entry>
<title>The Daily EPUB — 2026-08-15</title>
<id>urn:daily-epub:xtc:x</id>
<updated>2026-08-15T05:40:00Z</updated>
<link rel="http://opds-spec.org/acquisition" href="http://127.0.0.1/files/xtc/x.xtch" type="application/octet-stream" length="4"/>
</entry>
</feed>
"#;