Plugins
Add metadata and storage providers that run in a separate, permission-checked process, and write your own with the manifest, SDK and protocol.
On this page
A plugin adds a metadata provider, a catalogue that identifies and describes what is in your libraries alongside the built-in ones. It can also add a storage provider, somewhere your media lives that Collectoh cannot read by itself, or both. Plugins cannot change how the web app looks or behaves: themes do that, and themes run no code.
A plugin is a program someone else wrote, and Collectoh treats it as one. It runs in a process of its own, starts only after an admin agrees to what it asks for, and gets nothing it was not granted.
Installing a plugin
Plugins live under Admin → Plugins. There is no plugin store: a plugin is a zip file from someone you trust.
- Choose Install from zip… and pick the file. The server validates the manifest, checks that every file lands inside the plugin's own folder, and installs it switched off. Nothing runs yet.
- If it has settings, choose Settings and fill them in. Those marked (needed) must have a value before it can run.
- Choose Enable…, read what it asks for, untick anything you would rather not grant, and choose Enable with these permissions. A plugin that will not start stays off, and the dialog says why.
- Put it to work:
- For a metadata plugin, edit a library under Admin → Libraries, choose Add an agent…, and refresh (see Metadata).
- For a storage plugin, add a storage of kind plugin under Admin → Storage and choose the plugin under Served by.
You can also copy a plugin's folder into the plugins folder of the config directory, which is /config/plugins/<id>/ in Docker. The folder must be named after the plugin's id; it then appears installed and off, like an uploaded one.
An archive may hold up to 5,000 files and 50 MB unpacked, with the manifest at its top level or inside a single top-level folder. An id that is already installed is refused (remove the old plugin first), and so is a built-in provider's id.
What you are granting
The Enable dialog lists what the manifest asks for, each with a tick box, all ticked to begin with:
- Talks to: the hosts it says it will contact, or any host.
- Reads and Writes: folders on the server.
A plugin can always read its own folder. The two halves are not equally strong, and the dialog says so:
- The filesystem is enforced. The plugin runs under Node's permission model: it can read its own folder and the paths you tick, and write only to the paths you tick. Anything else fails inside the plugin. It cannot start other programs or worker threads, and it gets none of the server's environment: no
SECRET_KEY, no database address. - The network is declared, not enforced. Node's permission model has no network switch. The host list is the plugin's own statement of what it needs, for you to read before you trust it; it is not a wall.
Your answer is stored and used every time the plugin starts, including after the server restarts.
Settings
A plugin can ask for up to 20 settings: an API key, a client name, a region. Ordinary values are shown as stored. A secret is encrypted with the server's SECRET_KEY and never shown again; the page only says it is set, and leaving its box empty keeps it.
Saving restarts a running plugin with the new values. If it refuses them, it goes back to the old ones and keeps running, and you see its reason. A required setting cannot be emptied while the plugin is on.
Disabling, removing and the log
- Disable stops the process and withdraws its providers at once: libraries skip it, and the storages it serves stop answering. Its folder, settings and grant are kept.
- Remove also deletes its folder and settings. To upgrade a plugin, remove it and install the new zip.
- Log shows the last 200 lines the plugin wrote to its error output, with notes from the server when it exits or prints something unreadable. The same lines reach the server's own log.
Each plugin shows its state (Running, Enabled, not running, Installed, off, or Invalid with the reason) and how often it has restarted. One that crashes is started again on its next call, after a pause that grows each time: 1 second, 5, 30, then 2 minutes. Each call has 30 seconds to be answered. A failing plugin never fails a scan: the item just goes without what that plugin would have said.
Plugins run beside the main server process. If the job queues run in a separate worker (WORKER_MODE=external), refreshes there skip plugin agents.
Writing a plugin
A plugin is a folder holding a manifest, collectoh-plugin.json, and an entry script in JavaScript. The server runs the entry with its own Node (Node 22 in the official image), so fetch and the rest of Node's standard library are available. Nothing is installed from npm along with a plugin. Everything the entry imports must be in the folder, usually bundled into the entry itself.
The manifest
| Field | Required | What it is |
|---|---|---|
protocol | Yes | Always 1. |
id | Yes | 2–64 lower-case letters, digits and dashes, starting with a letter. The folder's name, and a metadata plugin's agent id. |
name | Yes | Up to 100 characters, shown on the Plugins page. |
version | Yes | Your version string, up to 50 characters. |
description, author | No | Up to 1,000 and 200 characters. |
homepage | No | An http or https address. |
entry | Yes | The script to run: a .js, .mjs or .cjs file inside the folder, given relative to it. |
provides | Yes | metadata, storage or both, described below. |
permissions | No | network is a list of hosts, or "*"; filesystem.read and filesystem.write are lists of absolute paths. Up to 50 each, empty by default. |
settings | No | Up to 20 of { key, label, help, secret, required }. A key is lower-case letters, digits and underscores; secret and required default to false. |
provides.metadata.supports lists the item types the provider describes: movie, show, season, episode, artist, album, track, audiobook, audiobook_chapter, course, section, lesson, photo_album, photo, video_folder, video, document_folder, document and book. Its optional byId, episodeOrders and people are explained under metadata providers; provides.storage.capabilities is explained under storage providers. A key the schema does not know, anywhere in the manifest, is an error.
A manifest using every metadata field, for a hypothetical AniDB plugin:
{
"protocol": 1,
"id": "anidb",
"name": "AniDB",
"version": "1.2.0",
"description": "Anime from AniDB.",
"author": "A. Developer",
"homepage": "https://example.org/anidb-plugin",
"entry": "index.mjs",
"provides": {
"metadata": {
"supports": ["show", "season", "episode"],
"byId": [{ "type": "show", "key": "anidb" }],
"episodeOrders": ["aired", "absolute"],
"people": false
}
},
"permissions": {
"network": ["api.anidb.net"],
"filesystem": { "read": [], "write": ["/config/plugins/anidb/cache"] }
},
"settings": [
{ "key": "client", "label": "Client name", "help": "Registered at anidb.net.", "required": true },
{ "key": "apikey", "label": "API key", "secret": true }
]
}The cache folder needs a write grant because a plugin cannot write anywhere unless it asks, not even inside its own folder. Paths are the server's: in Docker, the config directory is /config.
A first plugin with the SDK
@collectoh/plugin-sdk holds the manifest schema, the protocol, a schema for every payload, and servePlugin(). That function turns a provider object into a running plugin: it sends the greeting, reads and checks each call, and turns anything thrown into an error answer rather than a crash. The SDK is TypeScript source in the Collectoh repository (packages/plugin-sdk) and depends on another package there. The easiest route is to build your plugin as a package inside a checkout of the repository and bundle it into one file.
This plugin answers every search with one candidate and describes it with a greeting the admin chooses. Its folder holds this manifest and, once built, index.mjs:
{
"protocol": 1,
"id": "hello-metadata",
"name": "Hello Metadata",
"version": "1.0.0",
"description": "Greets every film and show it is asked about.",
"entry": "index.mjs",
"provides": {
"metadata": { "supports": ["movie", "show"], "people": true }
},
"settings": [
{ "key": "greeting", "label": "Greeting", "help": "The first word of every summary." }
]
}import { PluginCallError, servePlugin } from '@collectoh/plugin-sdk';
// What the admin typed under Settings; `configure` fills it in before any other call.
let greeting = 'Hello';
await servePlugin({
id: 'hello-metadata',
version: '1.0.0',
// The admin's values, by key. Only settings that have a value arrive.
// Throwing refuses them, and the plugin does not start.
configure(settings) {
const value = settings['greeting'];
if (value === 'refuse') throw new Error('That greeting is not one this plugin will say.');
greeting = value ?? 'Hello';
},
metadata: {
// Candidates for the item, best first, each with a confidence from 0 to 1.
async search(subject) {
console.error(`search: ${subject.title}`); // stderr is the plugin's log
return [{ id: `hello:${subject.title}`, title: subject.title, confidence: 0.9 }];
},
// Everything this plugin knows about one candidate.
async fetch(id, subject) {
if (!id.startsWith('hello:')) throw new PluginCallError('NOT_FOUND', `No record ${id}.`);
return {
fields: { summary: `${greeting}, ${subject.title}.` },
externalIds: { hello: id.slice('hello:'.length) },
tags: { genre: ['Greeting'] },
credits: [{ name: 'Ada Lovelace', role: 'director' }],
artwork: [],
};
},
// Asked only because the manifest says "people": true.
async fetchPerson(_externalIds, name) {
return { fields: { biography: `About ${name}.` } };
},
},
});Bundle it into the folder and zip the folder:
npx esbuild src/index.ts --bundle --platform=node --format=esm --outfile=hello-metadata/index.mjs
cd hello-metadata && zip -r ../hello-metadata.zip .In TypeScript, fetch must return all five of fields, externalIds, tags, credits and artwork, even if empty; over the wire, any you leave out count as empty. If you would rather not bundle, see packages/plugin-sdk/examples/. It holds a metadata plugin and an in-memory storage plugin that speak the protocol directly, each one file with no dependencies and no build step.
The protocol
The server and the plugin exchange newline-delimited JSON over the plugin's standard input and output, one object per line in UTF-8:
- The plugin speaks first, within 10 seconds: a
hellowith the protocol version and its id, which must match the manifest. - The server sends
callframes, and the plugin answers each with aresultor anerrorcarrying the sameid. Calls can overlap, and answers may come in any order. - A plugin that declares settings always receives
plugin.configurefirst, each time its process starts.
{"type":"hello","protocol":1,"plugin":{"id":"hello-metadata","version":"1.0.0"}}
{"type":"call","id":1,"method":"plugin.configure","params":{"settings":{"greeting":"Good evening"}}}
{"type":"result","id":1,"result":{"ok":true}}
{"type":"call","id":2,"method":"metadata.fetchPerson","params":{"externalIds":{},"name":"Ada Lovelace"}}
{"type":"result","id":2,"result":{"fields":{"biography":"About Ada Lovelace."}}}| Method | Params | Answer |
|---|---|---|
plugin.configure | settings, the values by key | {"ok": true} |
metadata.search | subject | Up to 50 candidates |
metadata.fetch | id, subject | The metadata for one candidate |
metadata.fetchPerson | externalIds, name | A person, or null |
storage.open | storageId, rootPath, config | {"ok": true} |
storage.list | storageId, dir, recursive | Entries |
storage.stat | storageId, path | One entry's details |
storage.read | storageId, path, start, end | data in base64, and the file's sizeBytes |
storage.ffmpegInput | storageId, path | A URL for FFmpeg |
storage.remove, storage.write | storageId, path, and for a write, base64 data | {"ok": true} |
- Standard output belongs to the protocol. Log to standard error with
console.error: that is the plugin's log. Anything else on standard output is recorded as unreadable and ignored. - Every answer is checked against the SDK's schemas before the server uses it. Unknown keys are dropped, and a wrong shape is the plugin's error.
- An error's
codeis yours to choose, as in{"type":"error","id":4,"error":{"code":"NOT_FOUND","message":"No record other:1."}}.NOT_FOUND, for a file or record that does not exist, is the one the server understands; any other code reaches whoever asked asPLUGIN_ERROR. With the SDK, thrownew PluginCallError('NOT_FOUND', message). - When the server closes standard input, the plugin should exit.
Metadata providers
A library asks its agents in order, top first. For each field, the first agent with a value gives it, while tags, credits and pictures are gathered from all of them.
The subject describes the item:
type,titleandyearindexandparentIndex: an episode's number and its season'sparent, androot: the show above an episode, with itsexternalIds- the item's own
externalIdsandruntimeMs pathandprimaryFilePath, relative to the storage- the library's
languageandepisodeOrder
search(subject) returns up to 50 candidates { id, title, year, confidence, popularity }, where confidence runs from 0 to 1 and year and popularity are optional. The best candidate is fetched only if its confidence is at least 0.85; otherwise the plugin says nothing about the item. If someone matches an item by hand in a library that uses the plugin, it is searched too, with their words as subject.title.
fetch(id, subject) returns what the plugin knows about one candidate:
fields:title,originalTitle,year,releaseDate,summary,tagline,runtimeMs,contentRating,communityRatingandcriticRating(0–10),status,network,airDate.externalIds: its ids for the item, such as{ "anidb": "69" }. They join the item's, and agents later in the order are asked with them.guid: the item's identity in your catalogue, such asanidb://show/69. The first agent with a guid names the item.tags: lists by namespace, such asgenre,mood,keywordorcountry.credits:{ name, role, character, sort, imageUrl, externalIds }, all butnameandroleoptional, with roles such asactor,directorandwriter.artwork:{ kind, url, language, priority }. Thekindisposter,backdrop,thumb,logo,banner,clearartorstill, and the highest priority is preferred.- Optional
movie,album,book,courseandstudioblocks, andtrailers(links to YouTube or Vimeo).
byId skips the search when the answer is already known. With "byId": [{ "type": "show", "key": "anidb" }], a show that already carries an anidb id is fetched with that id directly. The id may have come from an NFO, an earlier match or another agent. prefix is put in front of the id if your fetch expects one.
episodeOrders. A show library numbers its episodes as aired, by DVD or absolutely, and every call says which in subject.episodeOrder. A season or episode in a DVD- or absolutely-numbered library is asked only of providers that list that order. A manifest that says nothing means aired only.
people and fetchPerson(externalIds, name). With "people": true, the plugin is asked to describe the people credited in the libraries it serves, by name and whatever ids are known for them. Answer { fields, aliases, externalIds, imageUrl, images } or null. fields holds biography, birthDate, deathDate, birthplace, gender and the like. Without people, fetchPerson is never called, so no names are sent to the plugin for nothing.
Storage providers
A storage plugin serves storages of kind plugin. Each storage is opened with storage.open before anything else is asked of it, and again whenever the process restarts. The call carries the storage's id, the root path the admin gave it and its config. With the SDK you implement open(handle) and return a backend:
interface StorageBackend {
list(dir: string, recursive: boolean): Promise<StorageEntry[]>;
stat(path: string): Promise<StorageStat>;
read(path: string, start: number, end: number): Promise<{ data: Uint8Array; sizeBytes: number }>;
ffmpegInput(path: string): Promise<FfmpegInput>;
remove?(path: string): Promise<void>;
write?(path: string, data: Uint8Array): Promise<void>;
}- Paths are relative to the storage's root, with forward slashes and no leading
/;''is the root. listreturns{ path, kind, sizeBytes, mtime }for the files and folders underdir(all the way down whenrecursive), in sorted order.statreturns{ kind, sizeBytes, mtime, fileId }. AfileIdthat survives a rename lets a moved file keep its item and watch history.readreturns bytesstarttoend, inclusive, and the file's size. The server reads 1 MB at a time.ffmpegInputreturns{ kind: "url", url, headers, args }: an address FFmpeg can open from the server. A local path is not accepted.removeandwriteare called only when the manifest's capabilities includewrite.
The manifest's capabilities are:
randomAccess(true by default): any byte range can be read quickly.ffmpegDirect(false by default): FFmpeg can open the URLsffmpegInputreturns. Without it, files on the storage play only as they are, never converted.write(false by default).watch, which must be false: changes are found by scans.
Put what the backend needs, such as an endpoint or credentials, in the manifest's settings.
What a plugin may not do
- Send addresses other than
httporhttps. Artwork, a credited person's picture, a trailer, a studio's logo and page, and a course's provider link must be web addresses, or the whole answer is refused. - Serve pictures from anywhere. The server downloads artwork only from hosts on its allowlist,
ARTWORK_ALLOWED_HOSTS. If your pictures come from a host of their own, the admin must add it. Setting the variable replaces the built-in list rather than adding to it. - Lock fields.
locksandsourceHashare for the files beside the media. A plugin's are dropped, so it can fill fields but never freeze them. - Take a built-in's id.
local,tmdb,tvdb,fanart,musicbrainzand the rest are reserved;GET /api/v1/metadata/agentslists them withsource: "builtin". - Reach past its grant. It may not:
- read files beyond its folder and granted paths, or write anywhere not granted;
- start child processes or worker threads, or load native add-ons;
- see any environment beyond
PATHand the temporary-folder, home, locale and time-zone variables.
Testing locally
From the plugin's folder, run the entry the way the server does; it greets you:
cd hello-metadata node --experimental-permission --allow-fs-read="$PWD" --allow-fs-read="$PWD/*" index.mjsPaste a call as one line and press Enter. A search needs a whole subject:
{"type":"call","id":1,"method":"metadata.search","params":{"subject":{"itemId":"i","libraryId":"l","storageId":"s","type":"movie","title":"Heat","year":1995,"index":null,"parentIndex":null,"path":"Heat (1995)","primaryFilePath":"Heat (1995)/Heat (1995).mkv","pathRoot":"Films","parent":null,"root":null,"externalIds":{},"runtimeMs":null,"language":"en"}}}Reading a file outside the folder fails here just as it will on a server. Close the input (Ctrl+D) to end it.
For automated tests,
servePlugintakesstdinandstdoutoptions. Hand it twoPassThroughstreams, then write calls to one and read answers from the other.On a test server, install the zip and enable it. To try a new build without reinstalling, replace the files in
/config/plugins/<id>/, then Disable and Enable it. Each start runs the entry afresh, and its settings are kept.
Last updated 28 September 2026