Plugins
Plugins
Everything about writing Dalibre plugins on one page, from a first book source to the full API reference. Also available as plain Markdown at /docs/plugins.md.
A Dalibre plugin is a folder with a plugin.json and a JavaScript file.
Plugin API 1 offers two capabilities, and one plugin may have either
or both:
| Capability | What it does | Where users see it |
|---|---|---|
| Book source | Searches and browses a catalogue, and says where each book’s file is. Dalibre downloads the file and adds the book to the library, with the metadata the plugin gave. | The Get books page |
| Metadata source | Looks a book up by its title, authors and identifiers, and returns candidates with their details. | Edit metadata › Fetch metadata |
On this page:
- Overview
- Quick start
- plugin.json
- Book sources
- Metadata sources
- Runtime and errors
- Permissions and limits
- Publishing
- Type reference
Overview
Plugins give data, never UI
A plugin returns plain objects; Dalibre draws every screen with its own controls. Your plugin never sees a window, a DOM or a dialog.
That’s a deliberate trade:
- Users learn it once. Every book source has the same search box, filters, shelves, detail panel and download progress.
- Plugins can’t impersonate. A plugin can’t fake a sign-in page or a system dialog, and its icon is picked from Dalibre’s own set, never an image.
- Dalibre knows what a plugin does before running it. Capabilities,
filters, settings and permissions are declared in
plugin.json, so the app can lay out the source picker and ask for consent without loading any code. - The UI can change without breaking you. Your contract is data, not layout.
How a plugin runs
Plugins are plain JavaScript modules (ES2023), run by the QuickJS interpreter built into Dalibre. There is no Node, no browser and no build step.
my-source/
├── plugin.json what the plugin is, adds and asks for
├── main.js the functions Dalibre calls
├── lib/… more modules, imported by relative path (optional)
├── dalibre.d.ts types for your editor (optional)
└── jsconfig.json
- Dalibre reads
plugin.jsonand checks it: unknown fields are errors. - When the plugin is first needed, Dalibre runs
main.jsonce as a module. Module-level variables live until Dalibre quits or reloads the plugin. - Dalibre calls your exports (
search,identify, …) with plain objects, and checks what you return. Bad results are dropped with a warning; the rest are shown. - Network access goes through
dalibre.fetch, limited to the hosts you declared. Files are downloaded by Dalibre, not by your script.
Calls into one plugin run one at a time; different plugins run in parallel. A plugin that throws, runs out of time or out of memory only fails that one call.
What a book source looks like
{
"id": "com.example.books",
"name": "Example Books",
"version": "0.1.0",
"api": 1,
"capabilities": { "bookSource": {} },
"permissions": { "hosts": ["books.example.com"] }
}
// @ts-check
export async function search({ text, page }) {
const res = await dalibre.fetch(
`https://books.example.com/api?q=${encodeURIComponent(text)}&p=${page}`
);
const { books, next } = res.json();
return {
items: books.map((b) => ({
id: b.id,
title: b.title,
authors: b.authors,
cover: b.cover,
formats: [{ format: "EPUB", url: b.epub }],
})),
hasMore: Boolean(next),
};
}
That is a complete plugin. Everything else (filters, browsable collections, a detail view, signed download links) is optional and added one export at a time.
Built-in plugins
Two plugins ship inside Dalibre, and are good worked examples:
- Project Gutenberg (
dalibre.gutenberg), a book source on gutenberg.org’s own OPDS catalog: search with language and sort filters, Most popular and Recently added shelves, andgetfor each book’s details and file sizes. - NeoDB (
dalibre.neodb), a metadata source on neodb.social’s open catalog: by NeoDB id or ISBN when the book has one, else by title and first author, with ranking. Candidates carry NeoDB, Douban, Goodreads, Open Library and Google Books ids.
Quick start
In this guide you’ll write a book source for Project Gutenberg’s 70,000 public-domain books, read from its OPDS catalog. By the end it will search, filter by language, show covers, offer shelves to browse and download EPUBs into your library.
You need Dalibre and a text editor. No Node, no npm, no build step.
1. Make a folder
Make a folder called gutenberg, and download three files into it for
your editor:
dalibre.d.ts: the plugin API’s types.jsconfig.json: tells your editor to checkmain.jsagainst them.plugin.schema.json: the manifest’s JSON Schema.
mkdir gutenberg && cd gutenberg
for f in dalibre.d.ts jsconfig.json plugin.schema.json; do
curl -fsSO "https://dalibre.randynamic.org/sdk/$f"
done
They’re optional, but worth it: with // @ts-check at the top of
main.js, VS Code and other editors check your code and complete the
API as you type, and plugin.json is validated as you write it.
2. Pick an id
Every plugin has a permanent id: it’s how Dalibre tells your plugin
apart from every other, across updates. Use a domain you control,
reversed (com.example.books), or your GitHub name. This guide uses
io.github.you.gutenberg.
3. Describe the plugin
Replace plugin.json with:
{
"$schema": "./plugin.schema.json",
"id": "io.github.you.gutenberg",
"name": "Gutenberg (OPDS)",
"version": "0.1.0",
"api": 1,
"description": "70,000 public-domain books from Project Gutenberg",
"author": "You",
"license": "MIT",
"icon": "library",
"capabilities": {
"bookSource": {
"filters": [
{
"key": "language", "title": "Language", "type": "language",
"options": [
{ "value": "", "title": "Any language" },
{ "value": "en" }, { "value": "fr" }, { "value": "de" }, { "value": "zh" }
]
}
]
}
},
"permissions": {
"hosts": ["www.gutenberg.org", "gutenberg.org"]
}
}
Two things matter here:
capabilities.bookSourcetells Dalibre to list the plugin on the Get books page, and declares one filter. Dalibre draws the drop-down and names the languages; you only get the chosen code.permissions.hostslists every host the plugin reaches: the catalog, the files and the covers, redirects included.
4. Write search
Project Gutenberg publishes its catalog as
OPDS, an Atom XML feed that most e-book catalogs
speak. Each book in a search is an <entry> like this:
<entry>
<id>https://www.gutenberg.org/ebooks/1342.opds</id>
<title>Pride and Prejudice</title>
<content type="text">Jane Austen</content>
…
</entry>
The sandbox has no XML parser, but the feed is generated and
well-formed, so a few regular expressions are enough. Replace main.js
with:
// @ts-check
const SITE = "https://www.gutenberg.org";
const PER_PAGE = 25; // what the catalog returns per page
/**
* @param {Dalibre.Query} query
* @returns {Promise<Dalibre.Page>}
*/
export async function search({ text, page, filters }) {
return feed(text, filters.language, "downloads", page);
}
/**
* A page of the catalog: books matching text, in sort order.
* @param {string} text @param {string} language @param {string} sort @param {number} page
* @returns {Promise<Dalibre.Page>}
*/
async function feed(text, language, sort, page) {
// The catalog's own syntax narrows by language: "dickens l.fr".
const query = [text, language ? `l.${language}` : ""].filter(Boolean).join(" ");
let url = `${SITE}/ebooks/search.opds/?sort_order=${sort}&start_index=${(page - 1) * PER_PAGE + 1}`;
if (query) url += `&query=${encodeURIComponent(query)}`;
const res = await dalibre.fetch(url);
if (res.status === 429) throw dalibre.error("rateLimited");
if (!res.ok) throw dalibre.error("unavailable", `Project Gutenberg answered ${res.status}.`);
const xml = res.text();
/** @type {Dalibre.Result[]} */
const items = [];
for (const [, entry] of xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g)) {
// Books link to /ebooks/<number>.opds; other entries are lists of authors or subjects.
const id = /\/ebooks\/(\d+)\.opds/.exec(tag(entry, "id"))?.[1];
if (!id) continue;
const author = tag(entry, "content");
items.push({
id,
// With a language asked for, titles end in it: "Oliver Twist (French)".
title: tag(entry, "title").replace(/\s+\([A-Z][a-z]+\)$/, ""),
authors: author ? [author] : [],
identifiers: { gutenberg: id },
url: `${SITE}/ebooks/${id}`,
cover: `${SITE}/cache/epub/${id}/pg${id}.cover.medium.jpg`,
formats: [
{ format: "EPUB", url: `${SITE}/ebooks/${id}.epub3.images` },
{ format: "MOBI", url: `${SITE}/ebooks/${id}.kindle.images` },
],
});
}
return { items, hasMore: xml.includes('rel="next"') };
}
/** The text inside the first <name>…</name>, or "". @param {string} xml @param {string} name */
function tag(xml, name) {
const m = new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`).exec(xml);
return m ? decode(m[1]).trim() : "";
}
/** @param {string} s */
function decode(s) {
return s
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
.replace(/"/g, '"').replace(/'/g, "'")
.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
}
What’s going on:
searchis called with what the user typed, the page (from 1) and the value of every filter you declared. When the user asks for more, it’s called again withpage + 1, as long as you returnedhasMore: true.- Each result needs an
id, atitleand at least one format. The rest is optional, but users choose from what they see: authors and a cover make the choice easy. Gutenberg keeps files and covers at predictable addresses, so one request per search is enough. identifiers.gutenberglets Dalibre mark a result In library when the user already has that book.dalibre.error("rateLimited")tells Dalibre to show “Try again in a while” instead of a generic failure.
5. Load it into Dalibre
In Dalibre, open Settings › Plugins › Add plugin › From a folder… and pick the plugin’s folder. Grant the hosts it asks for.
Dalibre loads the plugin from that folder, not a copy, and reloads it a moment after you save a change. Open Get books, pick Gutenberg (OPDS) in the source picker, and search: try pride and prejudice, then dickens with the language set to French, then download a book into your library.
If the plugin doesn’t load, Settings › Plugins shows why: a field
plugin.json doesn’t allow, a missing export, a script error.
Dalibre already ships a Project Gutenberg source built on this same catalog. Put yours next to it and compare: the built-in one also exports
get, which reads each book’s own entry for its description, subjects and file sizes.
6. Add browsable shelves (optional)
With the search box empty, a source can show shelves of covers. Add
"collections": true to capabilities.bookSource in plugin.json, and
two exports to main.js:
const SHELVES = [
{ id: "popular", title: "Most popular", sort: "downloads" },
{ id: "new", title: "Recently added", sort: "release_date" },
];
export function collections() {
return SHELVES.map(({ id, title }) => ({ id, title }));
}
/** @param {Dalibre.Query} query @returns {Promise<Dalibre.Page>} */
export async function list({ collection, page, filters }) {
const shelf = SHELVES.find((s) => s.id === collection);
if (!shelf) throw dalibre.error("notFound");
return feed("", filters.language, shelf.sort, page);
}
Save, and the Get books page shows your two shelves, each with See all.
7. Share it
Push the folder to a GitHub repository, with plugin.json at its root.
Anyone can then install it from Settings › Plugins › Install from
GitHub.
plugin.json
plugin.json sits at the root of your plugin’s folder. Dalibre reads it
before running any code: it’s how the app lays out the source picker,
draws your filters and settings form, and asks the user for consent.
A full example
{
"$schema": "./plugin.schema.json",
"id": "com.example.books",
"name": "Example Books",
"version": "1.2.0",
"api": 1,
"description": "Books from example.com",
"author": "Ann Author",
"homepage": "https://github.com/ann/dalibre-example-books",
"license": "MIT",
"main": "main.js",
"icon": "globe",
"capabilities": {
"bookSource": {
"collections": true,
"filters": [
{ "key": "language", "title": "Language", "type": "language",
"options": [{ "value": "", "title": "Any language" }, { "value": "en" }, { "value": "fr" }] },
{ "key": "sort", "title": "Sort", "type": "select", "default": "popular",
"options": [{ "value": "popular", "title": "Most popular" }, { "value": "new", "title": "Newest" }] }
]
}
},
"permissions": {
"hosts": ["api.example.com", "*.cdn.example.com"],
"localNetwork": false
},
"settings": [
{ "key": "apiKey", "title": "API key", "type": "secret", "required": true,
"description": "From your account page on example.com." }
]
}
Unknown fields are errors, so a misspelt one stops the plugin from
loading rather than being silently ignored. Point "$schema" at a copy
of plugin.schema.json, and your editor
validates and completes the file as you type.
Identity
| Field | |
|---|---|
id |
Required. Permanent and unique: lowercase letters, digits and -, in dot-separated parts, up to 64 characters. Use a domain you control, reversed (com.example.books), or your GitHub name (io.github.ann.books). Changing it makes a different plugin. |
name |
Required. Shown to users; 1 to 64 characters. |
version |
Required. A semantic version: 1.2.0, 2.0.0-beta.1. Bump it in every release, or users won’t be offered the update. |
api |
Required. The plugin API you wrote for: 1. |
description |
One line, shown under your name in the source picker and when the user installs. Say what the catalogue holds: “Over 70,000 free public-domain ebooks”. |
author, homepage, license |
Shown when the user installs. license is an SPDX id (MIT, Apache-2.0). The Get books page’s Open site button opens homepage. |
icon |
One of Dalibre’s icons: globe (default), library, book-open, database, network, building, bookmark, layers. Never an image, so a plugin can’t dress up as another app. |
main |
The script, relative to plugin.json. Default main.js. |
Ids starting with
dalibreare kept for the plugins that ship inside Dalibre. A package using one isn’t installed.
capabilities
Required. What the plugin adds to Dalibre: an object with one or both keys.
"capabilities": {
"bookSource": { "collections": true, "filters": [ … ] },
"metadata": { "fields": ["title", "authors", "cover", "description"] }
}
bookSource
Lists the plugin on the Get books page. Export search; see
Book sources.
| Field | |
|---|---|
collections |
true when you export collections and list: shelves to browse while the search box is empty. |
filters |
Up to 4 drop-downs under the search box. See filters. |
metadata
Lists the plugin in Fetch metadata. Export identify, and ask for
permissions.library: "read"; see
Metadata sources.
| Field | |
|---|---|
fields |
Optional: what you can fill, from title, authors, series, tags, publisher, pubdate, language, identifiers, cover and description. |
The array form
API 1 began with "capabilities": ["source"]. It still works and means
a book source with no filters or collections; ["metadata"] works too.
Prefer the object form in new plugins.
Filters
Each filter is a drop-down Dalibre draws under the search box. Its value
arrives in query.filters[key] on every search and list call.
| Field | |
|---|---|
key |
Required. The name in query.filters: a letter, then letters, digits or _. |
title |
Required. Shown on the drop-down. |
type |
Required. "select": options with titles. "language": options are ISO 639 codes, and Dalibre names them in the user’s language; "" with a title means “any”. |
options |
Required. 1 to 64 { value, title? }. title is required for select. |
default |
The option chosen at first. Without it, the first option. |
Every declared filter always has one of its options’ values: the
default, else the first, until the user picks.
In All sources, only a language filter shows, shared by every source that has one. While browsing collections, only the language shows too: a collection keeps its own order.
permissions
Required. What the plugin may reach. Users grant these when they install; see Permissions & limits.
| Field | |
|---|---|
hosts |
Required. Every host your plugin reaches, through fetch, file downloads, covers and redirects. *.example.com matches any subdomain, not example.com itself. |
library |
"read": your plugin sees the title, authors, identifiers and language of the books the user looks up with it. Required for a metadata source; the install dialog says so. |
localNetwork |
true to reach this computer and the local network: a home OPDS or calibre server. Users are warned. Default false. |
settings
Values the user enters, up to 32. Dalibre draws the form in the plugin’s
settings, and your code reads them from dalibre.settings.
"settings": [
{ "key": "server", "title": "Server address", "default": "http://192.168.1.10:8080" },
{ "key": "token", "title": "Access token", "type": "secret", "required": true,
"description": "Create one under Account › Tokens." }
]
| Field | |
|---|---|
key |
Required. The name in dalibre.settings: a letter, then letters, digits or _. |
title |
Required. The field’s label. |
type |
text (default) or secret. Secrets are masked, hidden in logs, and kept in the system’s keychain rather than Dalibre’s settings file. |
description |
A line of help under the field. |
default |
The value until the user sets one. |
required |
The plugin isn’t called until it’s filled; the source shows Needs setup instead. |
All values are strings.
Book sources
A book source lets users search and browse a catalogue from Dalibre’s Get books page, open a book in the detail panel, and download it into their library with the metadata you gave.
Declare it in plugin.json:
"capabilities": { "bookSource": { "collections": true, "filters": [ … ] } }
and export the functions for each step:
| Export | Called when | Returns | Required |
|---|---|---|---|
search(query) |
The user searches, or asks for more | Page |
Yes |
collections() |
The browse page opens | Collection[] |
With collections |
list(query) |
A shelf loads, or See all | Page |
With collections |
get(item) |
A result is opened in the detail panel | Result |
No |
resolve(item, format) |
The user downloads | DownloadRequest |
No |
search
/** @param {Dalibre.Query} query @returns {Promise<Dalibre.Page>} */
export async function search({ text, page, cursor, filters }) { … }
Called when the user searches with your source, and again with
page + 1 when they ask for more.
query: {
text: string, // what the user typed, trimmed; may be empty
page: number, // from 1
cursor: string, // "" at first; what your last page returned
filters: Record<string, string>, // every declared filter's value
}
Page: { items: Result[], hasMore?: boolean, cursor?: string }
- Return
{ items: [] }when nothing is found. textmay be empty: return popular or new books, or nothing.- Set
hasMore: trueto let the user ask for the next page. - If your API pages by token rather than number, return it as
cursor; you get it back asquery.cursorin the next call. - At most 100 items per page.
With All sources picked, every enabled source is searched at once, and each shows its first 3 results under its name. One source failing or needing setup is a state of its own group, never of the page.
Results
A Result is one book.
| Field | ||
|---|---|---|
id |
required | Unique within your source: a string, up to 256 characters. |
title |
required | |
formats |
required | At least one { format, url?, size? }. |
authors |
Array of names, “First Last”. | |
description |
Plain text or simple HTML. | |
publisher, series, seriesIndex, tags |
||
language |
ISO 639: "en", "eng", "zh-CN". |
|
pubdate |
"2006", "2006-01", "2006-01-02" or an RFC 3339 time. |
|
identifiers |
{ isbn: "…", goodreads: "…" }, as calibre keeps them. Add your own id under your plugin’s name, so users can find the book’s page later. |
|
cover |
An image URL on one of your hosts. Shown in the results, and used if the file has no cover. | |
url |
The book’s page on your site; the detail panel links to it. | |
fields |
Up to 12 more lines for the detail panel: [{ label: "Rights", value: "Public domain" }]. |
|
extra |
Anything JSON. Handed back unchanged to resolve. |
Formats
A format is { format, url?, size? }:
formatis one ofEPUB,KEPUB,AZW3,AZW,MOBI,PDF,FB2,DJVU,CBZ,CBR,CB7,TXT,RTF,DOCX,ODT,HTMLZ,LIT,PRC,PDB,LRF,SNB,TCR(any case).sizeis in bytes, shown to the user.urlis where to download it. Leave it out and exportresolvewhen links must be made at download time.
Results are checked
Dalibre checks every result you return:
- A result missing
id,titleorformats, or with an unknown format, is dropped. - An invalid
coverorpubdateis ignored. - An unknown field (
authorforauthors) is ignored.
Each gives a warning in Dalibre’s log; users never see them. If a book
you expect is missing from the results, check its id, title and
formats first.
In library
Dalibre marks a result In library (instead of Get) when one of its
identifiers matches a book the user already has. Give your own id and
the ISBN whenever you know them.
Filters
Filters are drop-downs under the search box, declared in
capabilities.bookSource.filters and passed in query.filters.
"filters": [
{ "key": "language", "title": "Language", "type": "language",
"options": [{ "value": "", "title": "Any language" }, { "value": "en" }, { "value": "fr" }] },
{ "key": "sort", "title": "Sort", "type": "select", "default": "popular",
"options": [{ "value": "popular", "title": "Most popular" }, { "value": "new", "title": "Newest" }] }
]
export async function search({ text, page, filters }) {
// filters.language is "", "en" or "fr"; filters.sort is "popular" or "new"
}
Every declared filter is always present, with one of its options’ values. Field by field: plugin.json › Filters.
collections and list
With "collections": true, the Get books page shows your collections as
shelves of covers while the search box is empty, each with See all.
export function collections() {
return [
{ id: "popular", title: "Most popular" },
{ id: "new", title: "Recently added" },
];
}
/** @param {Dalibre.Query} query @returns {Promise<Dalibre.Page>} */
export async function list({ collection, page, cursor, filters }) {
// a Page of that collection, as search returns
}
collections()is called once per session; return at most 12.list(query)gets the same query assearch, withcollectionset to a collection’sidandtextempty.- While browsing, only a language filter shows; a collection keeps its own order.
get
/** @param {Dalibre.Result} item @returns {Promise<Dalibre.Result>} */
export async function get(item) { … }
Optional. Called when the user opens a result in the detail panel.
Return everything you know: description, subjects, language, file sizes,
url, fields.
It costs a request per book opened, so search can return only what its
listing has, and get fills in the rest. The id is kept; formats and
extra you leave out are kept from item.
export async function get(item) {
const b = (await dalibre.fetch(`${API}/books/${item.id}`)).json();
return {
...item,
description: b.summary,
tags: b.subjects,
url: b.page_url,
fields: [{ label: "Rights", value: b.rights }],
};
}
Without get, the detail panel shows the search result as it is.
resolve
/** @param {Dalibre.Result} item @param {Dalibre.FormatName} format
@returns {Promise<Dalibre.DownloadRequest>} */
export async function resolve(item, format) { … }
Optional. Without it, Dalibre downloads format.url. Export it when
links expire, need a second request, or need headers:
export async function resolve(item, format) {
const auth = { Authorization: `Bearer ${dalibre.settings.apiKey}` };
const res = await dalibre.fetch(
`https://api.example.com/books/${item.id}/download?format=${format}`,
{ headers: auth },
);
if (res.status === 404) throw dalibre.error("notFound");
return { url: res.json().url, headers: auth };
}
- If you export
resolve, it’s called for every download. itemis the result assearchreturned it (after checking),extraincluded.formatis upper case.- The file name comes from
filename, else the server’sContent-Disposition, else the title. Its extension is always the format’s.
Dalibre does the download
Your script never touches the file. Dalibre:
- streams it to disk with progress and cancel, in the jobs popover;
- follows redirects, only within your hosts;
- refuses an HTML page (a login or error page) where a book was expected;
- turns HTTP 401, 403, 404 and 429 into the errors users understand;
- imports the book with the metadata of your result.
This keeps files off the plugin’s side of the sandbox, and gives every source the same download behaviour.
How states map to the page
| Your plugin | The Get books page shows |
|---|---|
A required setting is empty, or you throw auth |
Needs setup, with a button to your settings |
You throw rateLimited or unavailable |
A banner with Retry |
You throw notFound or invalid |
Your message |
| Anything else thrown | “The plugin failed”, with your message |
{ items: [] } |
No results |
Metadata sources
A metadata source looks books up. Given a book’s title, authors and identifiers, it returns candidates with their details. Dalibre shows them in Edit metadata › Fetch metadata, next to the book’s own values, with a checkbox per field, and fills in the fields the user picks. Only Save writes the library.
It’s the equivalent of calibre’s metadata sources.
Declaring it
{
"capabilities": {
"metadata": { "fields": ["title", "authors", "cover", "description"] }
},
"permissions": {
"hosts": ["api.example.com"],
"library": "read"
}
}
"library": "read"is required: your plugin sees the books the user looks up with it, and the install dialog says so.fields(optional) lists what you can fill, fromtitle,authors,series,tags,publisher,pubdate,language,identifiers,coveranddescription.
A plugin can be both a book source and a metadata source; declare both
keys under capabilities.
identify
/** @param {Dalibre.Book} book @returns {Promise<Dalibre.Candidate[]>} */
export async function identify(book) { … }
Called when the user opens Fetch metadata, or searches in it. book is:
{
title: string, // may be empty when identifiers are given
authors: string[],
identifiers: Record<string, string>, // lowercase keys: { isbn, goodreads, … }
language?: string, // ISO 639, when the book has one
}
That’s the book’s own values, or, when the user typed something else in
the step’s search box, { title: "what they typed" }, or
{ identifiers: { isbn } } when they typed an ISBN. Nothing else of
the library is sent.
export async function identify({ title, authors, identifiers }) {
const q = identifiers.isbn || [title, authors[0]].filter(Boolean).join(" ");
if (!q) return [];
const res = await dalibre.fetch(
`https://api.example.com/books?q=${encodeURIComponent(q)}`
);
if (res.status === 429) throw dalibre.error("rateLimited");
if (!res.ok) throw dalibre.error("unavailable");
return res.json().books.map((b) => ({
id: b.key,
title: b.title,
authors: b.authors,
publisher: b.publisher,
pubdate: b.date,
identifiers: { isbn: b.isbn, example: b.key },
cover: b.image,
description: b.summary,
}));
}
Candidates
A candidate is a result without
formats or extra:
idandtitleare required; everything else is optional, and shown next to the book’s value.- Return candidates best first, at most 20, and
[]when nothing matches. - Give everything you know in
identify. There is no second call for details. - Dalibre fetches
coverwhen the candidate is shown, so it must be on one of your hosts. identifiersare merged into the book’s (keys are lower-cased). Add your own id under your plugin’s name, so a later lookup can go straight to the same record.
Ranking
Rank with what you’re given:
- An identifier match first: your own id, then the ISBN. When the book has one and you find it, that’s usually the only candidate worth returning.
- Then the title: exact, then prefix, then contains. Compare normalised (lower case, no punctuation or accents).
- Then the authors and the language.
The built-in NeoDB plugin is a complete example: it looks up by NeoDB id or ISBN when the book has one (an ISBN search keeps only that edition), else searches by title and first author, then by title alone, and scores the merged results by title, authors and language.
With several metadata sources
Fetch metadata searches every enabled metadata source at once, or the one picked in its source menu. Candidates are listed by plugin, then by your rank.
Runtime and errors
Plugins run in QuickJS, an ES2023
interpreter built into Dalibre. It’s a small, strict sandbox: no Node, no
browser, and nothing but what the dalibre object hands you.
Modules
main.js is an ES module, run once when Dalibre loads the plugin. It
exports the functions Dalibre calls:
// @ts-check
import { toResult } from "./lib/map.js"; // your own files, relative paths
/** @param {Dalibre.Query} query @returns {Promise<Dalibre.Page>} */
export async function search({ text, page, cursor }) { … }
/** @param {Dalibre.Result} item @param {Dalibre.FormatName} format */
export async function resolve(item, format) { … } // optional
export default { search, resolve } and CommonJS
(module.exports = { search }, exports.search = …) work too.
- Imports are relative paths to
.jsfiles in your plugin’s folder. Package names ("lodash") can’t be imported; bundle them in. - Modules are strict mode: writing to a read-only property (anything
in
dalibre) throws, as in browsers. - Your functions may be
async.
State
Keep state across calls in module variables: a token, a cache.
let token = "";
async function signIn() {
if (token) return token;
const res = await dalibre.fetch("https://api.example.com/token", { method: "POST", … });
return (token = res.json().token);
}
It lasts until Dalibre quits or reloads plugins, or until a call runs out of time or memory, after which your module is loaded afresh. Don’t rely on it for anything that must survive.
The dalibre object
dalibre.fetch(url, init?) |
The web’s fetch, cut down. Below. |
dalibre.settings |
The user’s values for your settings, by key; defaults where unset. Read-only strings. |
dalibre.error(kind, message?) |
An Error Dalibre understands. See errors. |
dalibre.log(...) |
To Dalibre’s log. Secret settings are masked. |
console.log/info/warn/error/debug(...) |
Same as dalibre.log. |
dalibre.plugin |
{ id, version } from your manifest. |
dalibre.api |
The plugin API version of the running Dalibre. |
Everything in the dalibre object is frozen.
dalibre.fetch
const res = await dalibre.fetch("https://api.example.com/search?q=dune", {
method: "GET",
headers: { Accept: "application/json" },
});
if (!res.ok) throw dalibre.error("unavailable", `example.com answered ${res.status}.`);
const data = res.json();
initis{ method?, headers?, body? }, all strings. To post JSON,JSON.stringifythe body and setContent-Type.- It resolves to
{ status, statusText, ok, url, headers, text(), json() }.urlis the final URL after redirects;headershas lowercase names.text()andjson()are synchronous. - It rejects for a host outside your permissions, a network failure, or a response over 16 MB.
- Cookies are kept as a browser keeps them, for your plugin alone and in memory, and sent with later requests and downloads. Sign in by posting a form once, and stay signed in until the plugin is loaded again.
- Requests run one at a time;
Promise.alldoesn’t make them parallel. - At most 50 requests per call, and 10 redirects per request.
What’s not there
There is no require, file system, timers (setTimeout), URL,
URLSearchParams, TextEncoder or Intl.
- Build query strings with
encodeURIComponent. - Parse XML or HTML with regular expressions, or a small bundled parser. The built-in Project Gutenberg plugin reads OPDS (Atom XML) with a few regular expressions.
- Need
URLor a date library? Bundle a small one.
TypeScript and npm packages
Put dalibre.d.ts and
jsconfig.json next to main.js. With
// @ts-check at the top of main.js, your editor type-checks plain
JavaScript and completes the API, using JSDoc types:
// @ts-check
/** @param {Dalibre.Query} query @returns {Promise<Dalibre.Page>} */
export async function search(query) { … }
Prefer TypeScript? Write main.ts and compile it to one module:
esbuild main.ts --bundle --format=esm --target=es2023 --outfile=main.js
Bundling is also how you use npm packages, as long as they don’t need
Node’s built-ins or the browser’s. Commit the bundled main.js: Dalibre
has no build step.
Errors
Throw dalibre.error(kind, message) so Dalibre can tell the user what to
do:
| kind | When | Dalibre |
|---|---|---|
auth |
Credentials missing, expired or refused | Offers your plugin’s settings |
rateLimited |
The service asks you to slow down (429) | “Try again in a while” |
unavailable |
The service is down or answers oddly | “Try again later” |
notFound |
The book or file no longer exists | Says so |
invalid |
The query can’t be searched (too short…) | Shows your message |
if (res.status === 401) throw dalibre.error("auth", "The API key was refused.");
if (res.status === 429) throw dalibre.error("rateLimited");
if (text.length < 3) throw dalibre.error("invalid", "Type at least 3 letters.");
- Anything else thrown (a
TypeError, a plainError) is reported as a bug in the plugin, with your message; the stack goes to the log. - A network failure you don’t catch counts as
unavailable. - Messages are shown to users: write them for readers, in a sentence.
Permissions and limits
Plugins are code from strangers, so Dalibre holds them to what they
declare in plugin.json, and users see all of it before they install.
Hosts
Every request, redirect, download and cover must be to a host in
permissions.hosts, over http or https.
"permissions": { "hosts": ["api.example.com", "*.cdn.example.com"] }
*.example.commatches any subdomain, notexample.comitself. List both if you need both.- Redirects are checked at every hop. A download that redirects to a CDN needs the CDN listed.
- Credentials in URLs (
https://user:pw@…) are refused; send them in headers.
Local network
Without "localNetwork": true, addresses on the user’s computer or
network (127.0.0.1, 192.168.x.x, 10.x.x.x, fe80::…) are refused
after name resolution, so listing a host that resolves to one doesn’t
help.
Ask for it only when the plugin is for a server at home, such as a calibre content server or an OPDS catalog on a NAS. Users are warned at install.
Library
"library": "read" lets a metadata source see the title, authors,
identifiers and language of the books the user looks up with it, and
nothing else. The install dialog says so. A book source doesn’t need it.
Plugins never write the library. A metadata source returns candidates; the user picks the fields, and only Save writes.
Consent
Users grant your permissions when they install. The install dialog shows your name, author, description, what the plugin adds (Get books, Metadata), and every host and permission it asks for.
Updates are held to the same consent:
- An update asking for the same or less installs silently.
- An update asking for more (a new host, the local network, library access) is installed but stays off until the user grants it again.
So list the hosts you need up front: *.cdn.example.com rather than one
CDN node.
Limits
| Limit | |
|---|---|
One call (search, list, get, resolve, identify…) |
60 seconds; then the script is stopped |
| Memory | 64 MB for your plugin’s interpreter; then the call fails |
| Requests per call | 50 |
One fetch response |
16 MB (files go through the download) |
| Redirects per request | 10 |
| Results per page | 100 |
| Metadata candidates | 20 |
| Collections | 12 |
| Download | 2 GB |
| Cover | 10 MB |
| Scripts | 4 MB, all modules together |
| Package | 32 MB unpacked, 1000 files |
| Settings | 32 |
| Filters | 4, of 1 to 64 options each |
Calls into one plugin run one at a time; different plugins run in parallel. A plugin that throws, times out or runs out of memory only fails that call. The next one runs normally (after a timeout or running out of memory, with the module loaded afresh).
Publishing
Packages
A .dalibre-plugin file is a zip of your plugin’s folder, with
plugin.json at its root, named <id>-<version>.dalibre-plugin.
Leave out hidden files and node_modules:
zip -r com.example.books-1.2.0.dalibre-plugin . -x '.*' '*/.*' 'node_modules/*'
Users can install the file directly. Publish it as a GitHub release asset, with your source next to it.
Installing from GitHub
Users can install straight from your repository: Settings › Plugins ›
Install from GitHub, with owner/repo or a github.com link. They pick
one of your releases (the latest by default) or a branch:
- A release installs its
.dalibre-pluginasset. A release without one installs the repository as tagged. - A branch installs the repository at the branch’s latest commit. So
keep
plugin.jsonat the repository’s root, or in apluginfolder. There is no build step: commit whatmain.jsimports, bundled output included.
A typical release:
# bump "version" in plugin.json first
zip -r com.example.books-1.2.0.dalibre-plugin . -x '.*' '*/.*' 'node_modules/*'
gh release create v1.2.0 com.example.books-1.2.0.dalibre-plugin --generate-notes
Updates
Dalibre checks for updates at launch and once a day: a newer release that isn’t a pre-release, or a new commit on the branch followed.
- An update that asks for no new permissions can install by itself.
- One that asks for more waits for the user.
- Bump
versionin each release, or it won’t be seen as newer.
Compatibility
Plugin API 1 is a promise:
- Within API 1, Dalibre only adds: new optional fields in what you
receive, new optional fields you may return, new functions on
dalibre. Nothing you rely on changes or goes away. - Check for a new feature before using it:
if (dalibre.somethingNew) { … }. - Fields you return that Dalibre doesn’t know yet are ignored with a warning, never an error, so returning extra data is safe.
- A breaking change will be API 2, announced in Dalibre’s release notes, with API 1 plugins kept running alongside it for a transition period.
- A plugin declaring a newer
apithan the user’s Dalibre isn’t loaded, and the user is told to update Dalibre.
Guidelines
Users see your name, author, description and what you ask for before they install. A plugin that asks for little, says what it does and links its source gets installed.
- Only sources you may use. Publish plugins for catalogues whose terms allow it: public-domain libraries, stores and services the user has an account with, the user’s own servers. Dalibre doesn’t host, review or endorse plugins.
- Be polite. One request per search where the API allows it. Cache
what doesn’t change, in module variables. Honour
429by throwingrateLimitedrather than retrying in a loop. - Fill in metadata. Users see results before they download: authors,
language, size and cover make the choice easy. Add your own id to
identifiers. - Ask only for what you need. Exact hosts beat wildcards. Avoid
localNetworkunless the plugin is for a server at home. - Never log secrets, or send them to a host other than the one they’re for.
- Write errors for readers. Messages are shown to users, in a sentence.
Type reference
Download dalibre.d.ts next to your code. In plain
JavaScript, add // @ts-check to main.js and refer to these types in
JSDoc (@param {Dalibre.Query} query). In TypeScript, they’re global.
Exports
What Dalibre calls. A book source implements Source; a metadata source
implements MetadataSource; a plugin with both capabilities, both.
interface Source {
/** The user searched, or asked for the next page. */
search(query: Query): Page | Promise<Page>;
/** With "collections": true: what to browse while the search box is empty. At most 12. */
collections?(): Collection[] | Promise<Collection[]>;
/** With "collections": true: a page of query.collection. */
list?(query: Query): Page | Promise<Page>;
/** The user opened a result. Formats and extra you leave out are kept from item. */
get?(item: Result): Result | Promise<Result>;
/** Where to download item as format, when format.url won't do. */
resolve?(item: Result, format: FormatName): DownloadRequest | Promise<DownloadRequest>;
}
interface MetadataSource {
/** Candidates for book, best first; at most 20. [] when nothing matches. */
identify(book: Book): Candidate[] | Promise<Candidate[]>;
}
Book sources
Query
interface Query {
/** What the user typed, trimmed. May be empty. */
text: string;
/** The page asked for, from 1. */
page: number;
/** The cursor the previous page returned; "" on the first page. */
cursor: string;
/** The value of every filter plugin.json declares, by key. */
filters: Record<string, string>;
/** list only: the id of the collection asked for. */
collection?: string;
}
Page
interface Page {
/** At most 100. [] when nothing is found. */
items: Result[];
/** Whether the user can ask for the next page. */
hasMore?: boolean;
/** Handed back as query.cursor for the next page. */
cursor?: string;
}
Collection
/** A shelf to browse while the search box is empty: "Most popular". */
interface Collection {
id: string;
title: string;
}
Result
/** A book the source found. Only id, title and formats are required. */
interface Result {
/** Unique within this source, at most 256 characters. */
id: string;
title: string;
authors?: string[];
/** Plain text or simple HTML. */
description?: string;
publisher?: string;
/** ISO 639: "en", "eng", "zh-CN". */
language?: string;
/** "2006", "2006-01", "2006-01-02" or an RFC 3339 time. */
pubdate?: string;
series?: string;
seriesIndex?: number;
tags?: string[];
/** As calibre keeps them. Use your plugin's id as a key for its own id. */
identifiers?: Record<string, string>;
/** An image URL on a host in permissions.hosts. */
cover?: string;
/** The book's page on your site; the detail panel links to it. */
url?: string;
/** More lines for the detail panel, at most 12. */
fields?: Field[];
/** At least one. */
formats: Format[];
/** Anything JSON; handed back unchanged to resolve. */
extra?: unknown;
}
/** A line of the detail panel: { label: "Rights", value: "Public domain" }. */
interface Field {
label: string;
value: string;
}
Format
type FormatName =
| "EPUB" | "KEPUB" | "AZW3" | "AZW" | "MOBI" | "PDF" | "FB2" | "DJVU"
| "CBZ" | "CBR" | "CB7" | "TXT" | "RTF" | "DOCX" | "ODT" | "HTMLZ"
| "LIT" | "PRC" | "PDB" | "LRF" | "SNB" | "TCR";
interface Format {
format: FormatName;
/** Bytes, if known; shown to the user. */
size?: number;
/** Where to download it. Leave it out and export resolve for links made at download time. */
url?: string;
}
DownloadRequest
/** Where Dalibre downloads a file from. */
interface DownloadRequest {
url: string;
headers?: Record<string, string>;
/** The file's name; else the server's, else the title. The extension is fixed to the format. */
filename?: string;
}
Metadata sources
Book
/**
* What a metadata source is asked to look up: what the user has for the
* book, or what they typed in Fetch metadata. Nothing else of the library.
*/
interface Book {
/** May be empty when identifiers are given. */
title: string;
authors: string[];
/** Lowercase keys: { isbn: "9780765382030", goodreads: "20518872" }. */
identifiers: Record<string, string>;
/** ISO 639, when the book has a language. */
language?: string;
}
Candidate
/** A Result without formats. id and title are required; give every field you know. */
type Candidate = Omit<Result, "formats" | "extra">;
The dalibre object
declare const dalibre: {
/** The plugin API version of the running Dalibre. */
readonly api: number;
readonly plugin: { readonly id: string; readonly version: string };
/** The user's values for the settings in plugin.json; defaults when not set. */
readonly settings: Readonly<Record<string, string>>;
/** The web's fetch, cut down. Only hosts in permissions.hosts. */
fetch(url: string, init?: FetchInit): Promise<Response>;
/** throw dalibre.error("auth", "The API key was refused") */
error(kind: ErrorKind, message?: string): Error;
log(...args: unknown[]): void;
};
interface FetchInit {
method?: string;
headers?: Record<string, string>;
body?: string;
}
interface Response {
status: number;
statusText: string;
ok: boolean;
/** The final URL, after redirects. */
url: string;
/** Lowercase names. */
headers: Record<string, string>;
text(): string;
json(): any;
}
type ErrorKind =
| "auth" // credentials missing or refused: Dalibre offers the plugin's settings
| "notFound" // the book or file is gone
| "rateLimited" // the service asks to slow down
| "unavailable" // the service is down
| "invalid"; // the query can't be searched
console.log, info, warn, error and debug are there too, and
write to the same log as dalibre.log.