Skip to main content
Version: 0.0.26

Development

This page covers everything you need to build a Shisho plugin, from project setup to the available APIs.

Getting Started

A plugin consists of two files:

  • manifest.json — declares the plugin's identity, capabilities, permissions, and configuration schema
  • main.js — the JavaScript code that implements the plugin's hooks

TypeScript SDK

The @shisho/plugin-sdk npm package provides TypeScript type definitions for all plugin APIs. Install it for IDE autocompletion and type checking:

npm install @shisho/plugin-sdk

You can write plugins in TypeScript and compile to JavaScript, or use plain JavaScript with JSDoc annotations for type hints:

/// <reference types="@shisho/plugin-sdk" />

/** @type {ShishoPlugin} */
var plugin = {
// your hooks here
};

Plugin Code Pattern

All plugins must define a global plugin variable using an IIFE (Immediately Invoked Function Expression). The runtime is ES5.1 (no const, let, arrow functions, or template literals):

var plugin = (function() {
return {
// hook implementations go here
};
})();

Manifest

The manifest declares what your plugin does, what permissions it needs, and what configuration it accepts.

Required Fields

{
"manifestVersion": 1,
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0"
}
FieldDescription
manifestVersionMust be 1
idUnique identifier for the plugin (lowercase, hyphens allowed)
nameDisplay name shown in the UI
versionSemver version string

Optional Fields

FieldDescription
descriptionBrief description of the plugin
authorPlugin author name
homepageURL to plugin homepage or repository
licenseLicense identifier (e.g., MIT)
minShishoVersionMinimum compatible Shisho version

Capabilities

The capabilities object declares which hooks the plugin implements and what permissions it needs.

Hook Capabilities

Each hook type has its own capability declaration:

{
"capabilities": {
"inputConverter": {
"sourceTypes": ["mobi"],
"targetType": "epub"
},
"fileParser": {
"types": ["pdf"]
},
"metadataEnricher": {
"fields": ["description", "genres", "cover"]
},
"outputGenerator": {
"id": "mobi",
"name": "MOBI",
"sourceTypes": ["epub"]
}
}
}

A plugin can implement multiple hook types.

Permission Capabilities

{
"capabilities": {
"httpAccess": {
"domains": ["*.goodreads.com", "api.example.com"]
},
"fileAccess": {
"level": "read"
},
"ffmpegAccess": {},
"shellAccess": {
"commands": ["calibre-debug", "kindlegen"]
}
}
}
PermissionDescription
httpAccessDomains the plugin can make HTTP requests to. Supports wildcards (*.example.com)
fileAccessFilesystem access beyond the plugin's own directory. "read" or "readwrite"
ffmpegAccessAccess to FFmpeg for media processing
shellAccessAllowlist of shell commands the plugin can execute

Custom Identifier Types

Plugins can register custom identifier types (e.g., for Goodreads or MyAnonamouse IDs):

{
"capabilities": {
"identifierTypes": [
{
"id": "goodreads",
"name": "Goodreads",
"urlTemplate": "https://www.goodreads.com/book/show/{value}",
"pattern": "^\\d+$"
}
]
}
}

Configuration Schema

Plugins can define user-configurable settings:

{
"configSchema": {
"apiKey": {
"type": "string",
"label": "API Key",
"description": "Your API key for the service",
"required": true,
"secret": true
},
"maxResults": {
"type": "number",
"label": "Max Results",
"min": 1,
"max": 100,
"default": 10
},
"mode": {
"type": "select",
"label": "Lookup Mode",
"options": [
{ "value": "fast", "label": "Fast" },
{ "value": "thorough", "label": "Thorough" }
]
}
}
}

Supported field types: string, boolean, number, select, textarea. Fields marked secret are masked in the UI.

Validation Rules

  • If your JavaScript exports a hook but the manifest doesn't declare the corresponding capability, the plugin will fail to load
  • If the manifest declares a capability but the JavaScript doesn't export the hook, it's silently ignored
  • The built-in file types (epub, cbz, m4b) cannot be claimed by file parsers
  • Metadata enrichers must declare a fields array — if missing or empty, the enricher hook is disabled

Hook Types

Input Converter

Converts unsupported file formats to supported ones. Runs during library scans when a file with a matching source type is discovered.

Timeout: 5 minutes

var plugin = (function() {
return {
inputConverter: {
convert: function(context) {
// context.sourcePath - path to the input file
// context.targetDir - directory to write the output file

var targetPath = context.targetDir + "/output.epub";
// ... perform conversion ...

return { success: true, targetPath: targetPath };
}
}
};
})();

Manifest capability:

{
"inputConverter": {
"sourceTypes": ["mobi"],
"targetType": "epub"
}
}

File Parser

Extracts metadata from file formats that Shisho doesn't natively support. Runs during library scans for files with matching extensions.

Timeout: 1 minute

var plugin = (function() {
return {
fileParser: {
parse: function(context) {
// context.filePath - path to the file
// context.fileType - file extension (e.g., "pdf")

return {
title: "Book Title",
authors: [{ name: "Author Name" }],
description: "A description of the book.",
genres: ["Fiction"],
identifiers: [{ type: "isbn_13", value: "9781234567890" }]
};
}
}
};
})();

The full set of fields you can return:

FieldTypeDescription
titlestringBook title
subtitlestringBook subtitle
authors[{ name, role? }]List of authors
narrators[string]List of narrator names
seriesstringSeries name
seriesNumbernumberPosition in series (supports decimals like 1.5)
genres[string]Genre names
tags[string]Tag names
descriptionstringBook description
publisherstringPublisher name
imprintstringImprint name
urlstringBook URL
releaseDatestringISO 8601 date string
coverDataArrayBufferCover image data
coverMimeTypestringCover MIME type (e.g., image/jpeg)
coverPagenumberCover page index (0-indexed, for page-based formats)
coverUrlstringPublic URL for cover image. Server downloads at apply time. Domain must be in httpAccess.domains.
durationnumberAudiobook duration in seconds
bitrateBpsnumberAudio bitrate in bits per second
pageCountnumberPage count
identifiers[{ type, value }]Identifiers (isbn_10, isbn_13, asin, uuid, etc.)
chapters[{ title, startPage?, startTimestampMs?, href?, children? }]Chapter list
Field groupings for enrichers

When declaring fields in an enricher manifest, some return fields are grouped under a single logical name:

  • cover controls coverData, coverMimeType, coverPage, and coverUrl
  • series controls both series and seriesNumber

Manifest capability:

{
"fileParser": {
"types": ["pdf"],
"mimeTypes": ["application/pdf"]
}
}

Metadata Enricher

Searches external APIs for book metadata. The enricher implements a single search() hook that returns candidate results with complete metadata. Users can then review and selectively apply fields from the result they choose.

Timeout: 1 minute

Search Results

The search() hook returns { results: ParsedMetadata[] } — the same metadata structure used by file parsers, with all fields populated:

var plugin = (function() {
return {
metadataEnricher: {
search: function(context) {
// context.query — search query (title or free text)
// context.author — author name (optional)
// context.identifiers — [{ type, value }] (optional)

// context.file — read-only file metadata for matching
// context.file.fileType — "epub", "cbz", "m4b", "pdf"
// context.file.duration — seconds (audiobooks only)
// context.file.pageCount — CBZ/PDF page count
// context.file.filesizeBytes — file size in bytes

var apiKey = shisho.config.get("apiKey");

// Check for ISBN in query (users may paste ISBNs into the search box)
var isbnMatch = context.query.match(/^97[89]\d{10}$/);
if (isbnMatch) {
// Direct ISBN lookup
}

// Use author to narrow results
var searchUrl = "https://api.example.com/search?q=" + shisho.url.encodeURIComponent(context.query);
if (context.author) {
searchUrl += "&author=" + shisho.url.encodeURIComponent(context.author);
}

// Check for known identifiers
if (context.identifiers) {
for (var i = 0; i < context.identifiers.length; i++) {
var id = context.identifiers[i];
if (id.type === "isbn_13") {
// Direct lookup by ISBN
}
}
}

var resp = shisho.http.fetch(
searchUrl,
{ headers: { "Authorization": "Bearer " + apiKey } }
);

if (!resp.ok) return { results: [] };

var data = resp.json();
return {
results: data.items.map(function(item) {
return {
title: item.title,
subtitle: item.subtitle,
authors: [{ name: item.author, role: "writer" }],
narrators: item.narrators,
series: item.seriesName,
seriesNumber: item.seriesNumber,
genres: item.genres,
tags: item.tags,
description: item.description,
coverUrl: item.coverUrl,
releaseDate: item.publishDate,
publisher: item.publisher,
imprint: item.imprint,
url: item.url,
identifiers: [{ type: "isbn_13", value: item.isbn }],
confidence: item.matchScore // optional, 0-1
};
})
};
}
}
};
})();

Search result fields:

FieldTypeDescription
titlestringRequired. Book title
subtitlestringSubtitle or edition info
authorsArray<{name, role?}>Authors with optional role (e.g., "writer", "illustrator")
narratorsstring[]Narrator names (audiobooks)
seriesstringSeries name
seriesNumbernumberPosition in series
genresstring[]Genre classification
tagsstring[]Freeform labels
descriptionstringBook description
coverUrlstringCover image URL. Server downloads at apply time. Domain must be in httpAccess.domains.
releaseDatestringPublication date (YYYY-MM-DD or ISO 8601)
publisherstringPublisher name
imprintstringImprint name
urlstringWeb URL for the book
identifiersArray<{type, value}>ISBNs, ASINs, etc.
confidencenumberOptional match confidence score, 0–1

All fields except title are optional. The more fields you provide, the easier it is for users to pick the correct match and the more metadata can be applied.

Cover Images

Set coverUrl on your search results — the server handles downloading and domain validation automatically. The URL's domain must be in your manifest's httpAccess.domains list.

return {
results: [{
title: "Book Title",
coverUrl: "https://covers.example.com/book.jpg"
}]
};

For advanced use cases (file parsers extracting embedded covers, or enrichers that generate/composite images), you can set coverData as an ArrayBuffer instead. If both are set, coverData takes precedence.

Cover enrichment rules:

During automatic scans, enricher-provided covers are subject to additional checks:

  • Resolution gate: An enricher cover is only applied if its total resolution (width × height) is strictly greater than the file's current cover. If the file already has a cover of equal or greater resolution, the enricher cover is skipped. This prevents low-resolution external images from replacing high-quality embedded covers.
  • Page-based formats: CBZ and PDF files derive covers from their page content. Plugin covers are never applied to these formats, even if the plugin declares the cover field.
  • Field settings: The cover field must be enabled in the plugin's per-library field settings for cover enrichment to take effect. If disabled, all cover data from the plugin is silently stripped.

File Hints

The context.file object provides read-only metadata about the file being enriched. Use it to narrow your search — for example, filtering audiobook results by duration or distinguishing a comic from a novel by page count:

search: function(context) {
// context.file — read-only file metadata for matching
// context.file.fileType — "epub", "cbz", "m4b", "pdf"
// context.file.duration — seconds (audiobooks only)
// context.file.pageCount — CBZ/PDF page count
// context.file.filesizeBytes — file size in bytes

var searchUrl = "https://api.example.com/search?q=" + shisho.url.encodeURIComponent(context.query);

// Narrow to audiobooks when enriching an M4B
if (context.file && context.file.fileType === "m4b") {
searchUrl += "&type=audiobook";
if (context.file.duration) {
searchUrl += "&minDuration=" + Math.floor(context.file.duration);
}
}

// ...
}

Confidence Scores

Return a confidence value (0–1) on each result to tell Shisho how confident you are in the match:

return {
results: [{
title: "The Great Book",
confidence: 0.92, // optional, 0-1
// ... other metadata fields
}]
};

Auto-apply behavior during automatic scans:

  • Results with confidence >= threshold are auto-applied
  • Results with confidence below threshold are skipped (logged as a warning)
  • Results without a confidence field are always applied (backwards compatible)
  • The threshold defaults to 85% and is configurable globally via enrichment_confidence_threshold in your server config, and per-plugin in the plugin settings

Enrichment Behavior

When a user identifies a book using the interactive review screen, they choose which fields to keep on a field-by-field basis. During automatic scans, the first search result's metadata is applied for all enabled fields.

Enricher values override file-embedded metadata for the same field. If a file has a bad title and your enricher returns a corrected title, the enricher's value wins. Among multiple enrichers, the first one (in user-defined order) to provide a value for a field wins. File-embedded metadata is only used as a fallback for fields that no enricher provided.

During automatic scans, enrichment also respects a cover resolution gate — enricher covers must have a higher total resolution than the existing cover to be applied. See Cover Images above for details.

Only fields declared in the manifest's fields array will be applied. Any returned fields not in the declared list are silently stripped. Users can further disable individual fields in the plugin settings.

Valid enricher fields: title, subtitle, authors, narrators, series, seriesNumber, genres, tags, description, publisher, imprint, url, releaseDate, cover, identifiers

Manifest capability:

{
"metadataEnricher": {
"fields": ["description", "genres", "cover"],
"fileTypes": ["epub", "cbz"]
}
}

The fileTypes filter is optional — omit it to enrich all file types.

Output Generator

Generates alternative download formats from existing files. The generated files are cached and served when users download the alternative format.

Timeout: 5 minutes

var plugin = (function() {
return {
outputGenerator: {
generate: function(context) {
// context.sourcePath - path to the source file
// context.destPath - path to write the output file
// context.book - book metadata
// context.file - file metadata

// ... generate output file at context.destPath ...
},
fingerprint: function(context) {
// context.book - book metadata
// context.file - file metadata

// Return a string used for cache invalidation.
// When the fingerprint changes, the cached output is regenerated.
return context.file.fileType + "-" + context.book.title;
}
}
};
})();

Manifest capability:

{
"outputGenerator": {
"id": "mobi",
"name": "MOBI",
"sourceTypes": ["epub"]
}
}

Host APIs

Plugins access Shisho's host APIs through the global shisho object.

Logging

shisho.log.debug("Debug message");
shisho.log.info("Info message");
shisho.log.warn("Warning message");
shisho.log.error("Error message");

Configuration

var apiKey = shisho.config.get("apiKey");  // returns string or undefined
var all = shisho.config.getAll(); // returns { key: value, ... }

HTTP

Make HTTP requests to domains declared in the manifest's httpAccess.domains:

var resp = shisho.http.fetch("https://api.example.com/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: "search term" })
});

resp.ok; // boolean (true if status is 2xx)
resp.status; // number
resp.statusText; // string
resp.headers; // { "content-type": "application/json", ... }

var text = resp.text(); // response body as string
var json = resp.json(); // response body parsed as JSON
var bytes = resp.arrayBuffer(); // response body as ArrayBuffer

Domain patterns support wildcards: "*.example.com" matches example.com, api.example.com, and a.b.example.com.

URL Utilities

Helpers for URL manipulation that aren't available in the ES5.1 runtime:

shisho.url.encodeURIComponent("hello world");  // "hello+world"
shisho.url.decodeURIComponent("hello+world"); // "hello world"

// Build query strings (keys sorted alphabetically)
shisho.url.searchParams({ q: "test", page: 1 }); // "page=1&q=test"

// Parse URLs
var url = shisho.url.parse("https://api.example.com:8080/search?q=test");
url.protocol; // "https"
url.hostname; // "api.example.com"
url.port; // "8080"
url.pathname; // "/search"
url.query; // { q: "test" }

Filesystem

Read and write files within the sandbox. Plugins always have access to their own directory and a temporary directory. Access to other paths requires the fileAccess capability.

var content = shisho.fs.readTextFile("/path/to/file.txt");
shisho.fs.writeTextFile("/path/to/output.txt", content);

var bytes = shisho.fs.readFile("/path/to/file.bin"); // ArrayBuffer
shisho.fs.writeFile("/path/to/output.bin", bytes);

shisho.fs.exists("/path/to/file"); // boolean
shisho.fs.mkdir("/path/to/dir"); // creates parents
shisho.fs.listDir("/path/to/dir"); // ["file1.txt", "file2.txt"]

var tmpDir = shisho.fs.tempDir(); // auto-cleaned after hook returns

Archives

Work with ZIP files:

shisho.archive.extractZip("/path/to/archive.zip", "/path/to/dest");
shisho.archive.createZip("/path/to/source/dir", "/path/to/output.zip");

var entries = shisho.archive.listZipEntries("/path/to/archive.zip");
var data = shisho.archive.readZipEntry("/path/to/archive.zip", "file.txt");

XML

Parse and query XML documents:

var doc = shisho.xml.parse(xmlString);
var title = shisho.xml.querySelector(doc, "metadata > title");
var items = shisho.xml.querySelectorAll(doc, "item");

// XMLElement properties:
title.tag; // element tag name
title.text; // direct text content
title.attributes; // { "attr": "value" }
title.children; // child elements

HTML

HTML parsing with full CSS selector support. Uses a two-step parse-then-query pattern (same as shisho.xml). Use this instead of regex for scraping HTML content.

// Parse once, query many times
var doc = shisho.html.parse(html);

// Find a single element
var meta = shisho.html.querySelector(doc, 'meta[name="description"]');
var description = meta ? meta.attributes.content : "";

// Find all matching elements
var items = shisho.html.querySelectorAll(doc, '.book-item');

// Extract JSON-LD (common pattern for metadata enrichers)
var scripts = shisho.html.querySelectorAll(doc, 'script[type="application/ld+json"]');
if (scripts.length > 0) {
var jsonLd = JSON.parse(scripts[0].text);
}

// Extract Open Graph data
var ogTitle = shisho.html.querySelector(doc, 'meta[property="og:title"]');
var title = ogTitle ? ogTitle.attributes.content : "";

// You can also query child elements returned by previous queries
var section = shisho.html.querySelector(doc, "section.content");
var links = shisho.html.querySelectorAll(section, "a");

Each returned element has:

  • tag — element tag name (e.g., "div", "meta")
  • attributes — key-value pairs (e.g., { name: "description", content: "..." })
  • text — recursive inner text content
  • innerHTML — raw inner HTML string
  • children — child elements

FFmpeg

Requires the ffmpegAccess capability:

var result = shisho.ffmpeg.transcode(["-i", input, "-c:a", "aac", output]);
result.exitCode; // 0 = success
result.stderr; // FFmpeg output

var probe = shisho.ffmpeg.probe([filePath]);
probe.format; // { duration, bit_rate, tags, ... }
probe.streams; // [{ codec_name, sample_rate, ... }]
probe.chapters; // [{ start_time, end_time, tags, ... }]

Shell

Execute allowlisted commands. Requires the shellAccess capability with the command in the commands array:

var result = shisho.shell.exec("calibre-debug", ["-c", "print('hello')"]);
result.exitCode; // 0 = success
result.stdout;
result.stderr;

Commands run via exec directly (no shell) to prevent injection.

Local Development

For development, you can load plugins directly from the filesystem without going through a repository.

  1. Create your plugin directory at {pluginDir}/local/{your-plugin-id}/
  2. Add your manifest.json and main.js files
  3. In Admin > Plugins, click Scan to discover the plugin
  4. Enable the plugin to start using it

The default plugin directory is plugins/ relative to the Shisho data directory. You can change this with the plugin_dir configuration option.

Hot Reload

During development, you can modify your plugin's files and click Reload in the admin interface to pick up changes without restarting the server.

Testing Plugins

The SDK includes test utilities at @shisho/plugin-sdk/testing that eliminate mock boilerplate.

Setup

import { createMockShisho } from "@shisho/plugin-sdk/testing";

const mockShisho = createMockShisho({
fetch: {
"https://api.example.com/search?q=test": {
status: 200,
body: JSON.stringify({ results: [{ title: "Test Book" }] }),
},
},
config: {
api_key: "test-key",
},
});

globalThis.shisho = mockShisho;

What's Included

APIBehavior
log.*Silent no-ops
url.*Real implementations (encodeURIComponent, searchParams, parse)
config.*Returns values from the config map you provide
http.fetchRoute-based mock — matches URLs, throws on unmatched
fs.*Path-based mock — virtual filesystem from the map you provide
xml.*Real implementation (parse, querySelector, querySelectorAll)
html.*Real implementation (parse, querySelector, querySelectorAll with CSS selectors)

Unmatched fetch URLs and missing fs paths throw descriptive errors so you know exactly what mock data to add.

Runtime Differences

Plugins run in a goja runtime (ES5.1), but tests run in Node.js. Your tests may pass using ES6+ features (arrow functions, const/let, template literals, destructuring) that will fail in the actual plugin runtime. Always write your main.js using ES5.1 syntax (var, function expressions, string concatenation) — the test utilities mock the shisho.* APIs, not the JavaScript runtime itself.