Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils

- name: Install node packages
run: bun install
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ jobs:
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils

- name: Install node packages
run: bun install
Expand Down
4 changes: 3 additions & 1 deletion crates/citadel-opds/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -873,7 +873,9 @@ fn root_navigation_feed(library_uuid: &str) -> Result<Vec<u8>, quick_xml::Error>
],
entries: entries
.into_iter()
.map(|(id, title, href, media_type)| navigation_entry(library_uuid, id, title, href, media_type, None))
.map(|(id, title, href, media_type)| {
navigation_entry(library_uuid, id, title, href, media_type, None)
})
.collect::<Result<Vec<_>, _>>()?,
};
crate::xml::write_feed(&feed)
Expand Down
1 change: 1 addition & 0 deletions crates/citadel-opds/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,7 @@ mod tests {
InterfaceSnapshot {
id: "en0".to_string(),
label: "Ethernet".to_string(),
description: None,
state,
kind: OpdsInterfaceKind::Lan,
addresses: vec![InterfaceAddress {
Expand Down
130 changes: 130 additions & 0 deletions crates/libcalibre/examples/create_opds_validation_library.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
use std::{collections::HashMap, env, fs, path::PathBuf};

use chrono::NaiveDate;
use libcalibre::{util::get_db_path, BookAdd, BookUpdate, Library};

const ACQUIRABLE_BOOKS: usize = 105;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut arguments = env::args_os().skip(1);
let target = arguments
.next()
.map(PathBuf::from)
.ok_or("usage: create_opds_validation_library TARGET SOURCE_EPUB")?;
let source_epub = arguments
.next()
.map(PathBuf::from)
.ok_or("usage: create_opds_validation_library TARGET SOURCE_EPUB")?;
if arguments.next().is_some() {
return Err("usage: create_opds_validation_library TARGET SOURCE_EPUB".into());
}
if target.exists() && fs::read_dir(&target)?.next().is_some() {
return Err(format!("target is not empty: {}", target.display()).into());
}
if source_epub.extension().and_then(|value| value.to_str()) != Some("epub") {
return Err("SOURCE_EPUB must use the .epub extension".into());
}

fs::create_dir_all(&target)?;
let fixture =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/empty_library/metadata.db");
fs::copy(fixture, target.join("metadata.db"))?;
let database = get_db_path(target.to_str().ok_or("target path is not UTF-8")?)
.ok_or("target is not a Calibre library")?;
let mut library = Library::new(database)?;
let large_source = target.join(".validation-large.txt");
let coverless_source = target.join(".validation-coverless.txt");
fs::write(&large_source, vec![b'L'; 8 * 1024 * 1024])?;
fs::write(&coverless_source, b"Coverless validation fixture\n")?;

for index in 0..=ACQUIRABLE_BOOKS {
let is_fileless = index == ACQUIRABLE_BOOKS;
let title = if index == 0 {
"A & B <C> — 東京".to_string()
} else {
format!("Validation Book {index:03}")
};
let authors = if index == 0 {
vec!["Zoë & Co.".to_string(), "李 小龍".to_string()]
} else {
vec![format!("Author {:03}", index % 61)]
};
let series = (index % 2 == 0).then(|| format!("Series {:02}", index % 7));
let tags = match index % 3 {
0 => vec!["Science Fiction".to_string(), "Tag & <unsafe>".to_string()],
1 => vec!["Mystery".to_string()],
_ => Vec::new(),
};
let book = library.add_book(BookAdd {
title,
author_names: authors,
tags: Some(tags),
series,
series_index: Some(index as f32 / 2.0 + 0.5),
publisher: None,
publication_date: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
rating: None,
comments: None,
identifiers: HashMap::new(),
language: Some(if index % 2 == 0 { "en" } else { "fr" }.to_string()),
file_paths: if is_fileless {
Vec::new()
} else if index == 0 {
vec![large_source.clone(), source_epub.clone()]
} else if index % 2 == 1 {
vec![coverless_source.clone(), source_epub.clone()]
} else {
vec![source_epub.clone()]
},
})?;

library.upsert_book_identifier(
book.id,
"isbn".to_string(),
format!("9780000{index:06}"),
None,
)?;

library.update_book(
book.id,
BookUpdate {
title: None,
author_names: None,
author_ids: None,
description: (index % 4 != 0)
.then(|| "Escaped <summary> & Unicode café 東京".to_string()),
is_read: Some(index % 3 == 0),
tags: None,
series: None,
series_index: None,
language_codes: None,
publisher: (index % 5 != 0).then(|| "Fixture Press".to_string()),
publication_date: None,
rating: None,
comments: None,
identifiers: None,
},
)?;

if index % 3 != 2 {
library.add_book_genres(
book.id,
vec![
if index % 2 == 0 {
"Speculative Fiction"
} else {
"Mystery"
}
.to_string(),
"Fixture Genre".to_string(),
],
)?;
}
}

drop(library);
fs::remove_file(large_source)?;
fs::remove_file(coverless_source)?;
println!("{}", target.display());
Ok(())
}
3 changes: 1 addition & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ browser engine installed on the user's system.

## OPDS

- **[Share a library with KOReader](./opds-sharing.md)** - Desktop setup, optional Basic authentication, network limits, and troubleshooting
- **[OPDS v1 validation record](./opds-validation.md)** - Automated evidence and the physical package/KOReader acceptance matrix
- **[Share a library over OPDS](./opds-sharing.md)** - Turn on sharing and add the catalog from any OPDS 1.x reader
- **[Headless OPDS server](./headless-server.md)** - Run the Tauri-independent server process from an explicit configuration file

**Start here if you're:**
Expand Down
91 changes: 10 additions & 81 deletions docs/opds-sharing.md
Original file line number Diff line number Diff line change
@@ -1,87 +1,16 @@
# Share a library with KOReader
# Share a library over OPDS

Citadel can expose the active Calibre library as a read-only OPDS catalog while
the desktop app is running. Current KOReader is the supported v1 client; other
OPDS readers may work but are not yet part of Citadel's compatibility promise.
Citadel can expose the active Calibre library as a read-only OPDS 1.x catalog while the desktop app runs.

## Start sharing
## Turn on sharing

1. Open Citadel's **Settings**, then **Sharing**.
2. Choose **All local networks** or the specific Wi-Fi/Ethernet interface that
should host the catalog.
3. Keep the default port, `8080`, unless it conflicts with another service.
4. Optionally enable **Require a password** and set a username and password.
Citadel can generate a password, but shows it only once. Save it before
leaving the pane.
1. Open **Settings → Sharing**.
2. Choose **All local networks** or a specific interface.
3. Keep port `8080` unless it conflicts with another service.
4. Optionally enable **Require a password**. Citadel can generate one; it is shown only once.
5. Select **Start Sharing**.
6. Copy one of the concrete catalog URLs shown by Citadel. Do not add the
username or password to the URL.
6. Copy one of the catalog URLs Citadel displays.

Only the current active library is shared. Switching libraries changes the
served catalog. Sharing stops when Citadel quits and must be started again
after every launch; the network, port, and authentication settings remain
saved.
## Read it from any OPDS 1.x reader

## Add the catalog to KOReader

The KOReader device must be able to reach the selected computer interface.
Usually that means both devices are on the same local network.

1. In KOReader's File Browser, open the top menu and choose **OPDS catalog**.
2. Choose **Add new OPDS catalog**.
3. Enter a name such as `Citadel` and paste the URL copied from Citadel.
4. If authentication is enabled, enter the username and password in KOReader's
credential fields.
5. Open the new catalog.

The root contains All Books, Recently Modified, Unread, Authors, Series, Tags,
and Genres, plus search. Genre is separate from arbitrary Calibre tags and is
populated only after genres have been accepted into Citadel's `Genres` custom
column. Books and covers are downloaded directly from the active library;
OPDS cannot edit the library or synchronize reading progress.

KOReader's current user guide documents the OPDS catalog entry point:
<https://koreader.rocks/user_guide/>.

## Network and security limits

Citadel serves plain HTTP. A Basic-auth password prevents unauthenticated
browsing, but the credentials and downloaded books are not encrypted in
transit. Use sharing only on a network you trust. Citadel does not configure
the operating-system firewall and does not provide HTTPS, remote internet
exposure, Bonjour/mDNS discovery, or a QR code in v1.

**All local networks** binds Citadel to the concrete addresses of eligible
local Wi-Fi/Ethernet interfaces; it does not use a wildcard listener. Choosing
one interface prevents Citadel from silently broadening to another interface.
If that interface disappears, Citadel waits for the same interface to return.

Citadel advertises usable IPv4 and global/unique-local IPv6 addresses. It does
not advertise IPv6 link-local URLs because their zone identifiers are not
portable between the computer and reader. If a reader cannot route a displayed
IPv6 address, use the displayed IPv4 URL instead.

## Troubleshooting

- **KOReader cannot open the catalog:** confirm sharing still says **Sharing**,
use a URL currently displayed by Citadel, and check that both devices can
communicate on the selected network. Guest Wi-Fi often isolates devices.
- **Authentication keeps failing:** edit credentials only while sharing is
stopped, then restart sharing. Enter them in KOReader's username/password
fields, not in the catalog URL.
- **Citadel is waiting for the network:** reconnect the selected interface or
stop sharing and choose another interface. Citadel will not fall back to a
broader listener automatically.
- **The port is already in use:** stop the conflicting service or choose a
different port in Citadel before starting again.
- **macOS asks about incoming connections:** allow them for Citadel if the
catalog should be reachable. Signed and unsigned builds can receive different
firewall treatment.
- **Linux cannot be reached:** allow the selected TCP port in the host firewall.
Citadel intentionally does not modify firewall rules.
- **The wrong books appear:** stop sharing if needed, select the intended
library in Citadel, and reopen the catalog. Only one active library is served.

Stopping sharing or quitting Citadel closes every listener. If a listener still
appears reachable after the app exits, record the Citadel version, platform,
selected target, and catalog URL when reporting the defect.
Add a catalog with the copied URL and, if enabled, the username and password. Works with any OPDS 1.x reader — for example KOReader: File browser → OPDS catalog → Add new. Sharing stops when Citadel quits.
87 changes: 0 additions & 87 deletions docs/opds-validation.md

This file was deleted.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ license = "MIT"
repository = "https://github.com/every-day-things/citadel"
default-run = "citadel-rs"
edition = "2021"
rust-version = "1.77"
rust-version.workspace = true

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

Expand Down
5 changes: 4 additions & 1 deletion src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,10 @@ fn run_tauri_backend() -> std::io::Result<()> {
Ok(_) => {
api.prevent_exit();
let app_handle = app_handle.clone();
let opds_service = app_handle.state::<citadel_opds::OpdsService>().inner().clone();
let opds_service = app_handle
.state::<citadel_opds::OpdsService>()
.inner()
.clone();
let exit_phase = exit_phase.clone();
let exit_code = code.unwrap_or(0);
tauri::async_runtime::spawn(async move {
Expand Down
Loading