on_session_start()#The plugin host has finished loading. The place to draw a first window or restore state from data_get.
A plugin is one .rhai file that defines handler functions. NanoTorrent calls them when things happen to your torrents, and the file says on its first line what it is allowed to reach — eleven permissions, granted once and enforced by what the engine is built with. Everything the host offers is on this page — search it.
Nothing matches that. Try a function name, a permission, or a word like “magnet”.
Plugins are written in Rhai — a small, pure-Rust scripting language. This is a complete, working plugin:
// notify-me.rhai — say something when a download finishes.
//! permissions: read, notify
let done = 0;
fn on_torrent_completed(hash, name) {
done += 1;
notify("Download finished", name);
log("that is " + done + " this run");
}
Drop it in your plugins folder, then in Preferences ▸ Plugins switch the host on, tick the plugin, and approve what it asks for. Ok applies it — there is no restart.
Two switches, both off by default: the host (nanotorrent --plugins on, or the checkbox) and each plugin individually. A plugin switched off is never compiled or run at all.
| Platform | Folder |
|---|---|
| Windows | %LOCALAPPDATA%\NanoTorrent\plugins |
| Linux | ~/.local/share/nanotorrent/plugins (or $XDG_DATA_HOME) |
| macOS | ~/Library/Application Support/NanoTorrent/plugins |
| Portable | A plugins folder beside the executable (NANOTORRENT_PORTABLE, or portable.txt) |
Preferences ▸ Plugins ▸ Open plugins folder opens the right one. A file added, edited or deleted while NanoTorrent runs is picked up the next time plugin settings are applied — press Ok and the folder is read again. A reloaded plugin starts fresh: it loses whatever it kept in its top-level scope, exactly as a restart would have done.
Three examples are placed there for you, switched off and unapproved: example.rhai, which only watches and reports; rss.rhai, a working feed reader that uses every subsystem the host has; and player.rhai, which puts a Play item on a file’s context menu and hands that file to your player. Each is offered once by name, so a new example in a later version reaches a profile that already has the folder, and one you delete stays deleted.
A plugin’s translation catalogue goes in the same folder, named after the script — player.rhai is translated by player_translations.json. It is the only other file a plugin has, and the host reads that one path and nothing else.
Plugins work in headless builds too — arguably where they are most useful, since there is no window to watch. Use --plugins on, and approve permissions from a desktop build sharing the same profile.
Every plugin declares what it may reach, on one line, before any of it runs:
//! permissions: read, control, notify
NanoTorrent reads that line from the source text, without executing the script — the whole point is knowing what a script wants before any of it runs. Only the leading comment block is scanned, so a permissions: line further down, or inside a string, cannot quietly widen the request. An unknown word is reported rather than ignored, because a typo silently dropping a permission looks like the host is broken.
| Permission | Grants |
|---|---|
read | See the session: torrents, torrent, exists, session_rates, files, peers, trackers, share_limits, magnet_uri, dht_nodes, listen_port, stream_url |
control | Drive a torrent: pause, resume, recheck, reannounce, queue_move, set_file_priority, set_share_limits |
add | Add new torrents: add_magnet, add_torrent_file |
labels | Change torrent labels: set_label, clear_label |
storage | Move downloaded files: move_storage, set_location |
remove | remove — including deleting the downloaded files |
notify | Desktop notifications: notify |
network | Reach servers: http_get, add_torrent_url together with add, and tracker editing together with control |
data | A key/value store of its own: data_get, data_set, data_remove, data_keys |
ui | A window, menus, a cog and an icon: ui_window, ui_rows, ui_groups, ui_buttons, ui_input, ui_status, ui_form, ui_form_close, ui_show, ui_menu, ui_file_menu, ui_configurable, ui_icon |
execute | run, open — start programs on this computer, as you |
network is the one that changes what the others mean. A plugin holding read and network together can send everything it can see to anyone, and nothing in the host can tell a feed request from an upload of your torrent list. That is why approval shows the whole set at once rather than a line at a time — the combination is the decision, not the parts.
execute is the largest thing on the list, and reads last in the approval prompt for that reason. The other ten describe reach over torrents; this one describes reach over the machine. There is no sandbox behind it, no allow-list of programs, and no way for the host to tell a media player from anything else — a plugin holding it is limited by what your account can do and by nothing else. Grant it to scripts you have read, and read what changed when one updates, which the host makes you do: a plugin that adds execute to its header is held until you approve the new set.
What the host does offer is a record. Every run and every open is written to the log at INFO with the plugin’s name, the program and its arguments, before the process starts. Nothing is shelled out: the program and each argument are passed separately, so a filename out of a torrent cannot become a second command however it is spelled.
Each plugin gets its own Rhai engine, holding only the functions it was granted. A function you did not ask for is not registered, so calling it fails with “function not found” rather than being refused at runtime. There is no way to probe for what sits behind a permission you do not hold, and no shared engine that could leak one plugin's grant to another.
log needs no permission. A script that declares nothing gets log and nothing else, and needs no approval — there is nothing to consent to. Ask for the least you need: over-asking is not free, it is shown to the user in plain language next to your plugin's name.
A plugin asking for anything is held — discovered, listed, but not loaded — until it is approved in Preferences ▸ Plugins:
WARN plugin tidy-up is waiting for approval of: read, storage, remove
Consent is stored as the set you asked for, not a yes/no flag. Edit the header to want more and the stored grant no longer matches, so the plugin is held again until the new set is approved — an updated plugin cannot quietly widen its own reach. Asking for less also re-prompts, which is the harmless direction.
Define any of these. All are optional; a plugin that defines none does nothing. Handlers are called on the plugin thread, in alphabetical order by filename.
on_session_start()#The plugin host has finished loading. The place to draw a first window or restore state from data_get.
on_session_stop()#Shutting down — best effort. NanoTorrent does not wait for the plugin thread before exiting, so a slow handler may be cut off mid-run. Do not use it to save anything you cannot lose; write state out as you go instead.
on_torrent_added(hash, name)#A torrent appeared in the session. Lifecycle events are detected by the session itself rather than by the UI, so they fire the same way whether the torrent came from the window, the web interface, a magnet link handed to a second instance, or another plugin.
on_torrent_completed(hash, name)#A torrent finished downloading, on a genuine transition. Torrents that were already complete when NanoTorrent started do not fire it, and a recheck that un-finishes a torrent lets it fire again when it re-completes.
on_torrent_removed(hash, name)#A torrent left the session, with or without its files.
on_error(message)#Background work failed. message is the text that went to the log.
on_tick()#Once a minute, for as long as the session runs, on a wall-clock deadline so a busy session cannot starve it. One minute is fixed: a plugin that wants an hour counts sixty ticks, which is cheaper than a scheduler nobody asked for. Poll things here rather than in a loop — nothing else fires it.
Your window was opened. Redraw its lists here rather than keeping them warm.
A row in the main list was clicked. id is the id you gave that row in ui_rows.
A row in the optional upper list was clicked — the feed, category or account whose contents the main list shows.
A button was pressed. input is whatever the text field held at that moment, or an empty string if there is no field.
One of your ui_file_menu items was used on a file. You are told which item and which file, so nothing has to be remembered between drawing the menu and the click arriving. It fires the same way from the desktop details panel and from the web interface — a plugin cannot tell the two apart, and should not try.
An item in your menu-bar dropdown was chosen.
A form was saved. values is a map of field id to value, and every value is a string — a checkbox included — so a script reads them all the same way. It does not close the form for you: call ui_form_close when you are ready, which is what lets a rejected form stay up with what was typed still in it.
Cancel was pressed. Also does not close the form on its own.
The Configure cog on your row in Preferences ▸ Plugins was pressed. Only fires if you called ui_configurable(true).
returns [ #{ … } ] — an array of maps, one per torrent in the session.
returns #{ … }, or () if that torrent is gone.
returns bool — cheaper than fetching the map when all you want is presence.
returns #{ download: int, upload: int } — bytes per second, session-wide.
returns [ #{ index, name, length, progress, priority } ] — one per file, in the torrent’s own order. index is what every per-file call takes.
returns [ #{ addr, state, fetched_bytes, pieces } ] — the swarm as this client sees it.
returns [ #{ url, tier } ]
URLs and tiers, not the Trackers tab’s status column: that column is translated, and a plugin branching on it would work in English and quietly stop working in the other 75 languages.
returns #{ ratio, seed_minutes }, or () where the torrent has no override of its own.
returns a magnet link for a torrent already added, or "".
returns the routing table size, 0 when DHT is off.
returns the port peers are told to reach, 0 if it is not listening.
returns a URL a media player can open for that one file, or "".
This is how a plugin hands a file to something that is not NanoTorrent. It points at 127.0.0.1 and carries a capability token, not your password: the token is good for that one file of that one torrent, dies half an hour after its last use, and is never written to disk. A plugin cannot read the web interface’s password and does not need to.
The URL serves byte ranges out of a file that is still downloading, so a player can start before the torrent finishes. It returns "" when the web interface is switched off, because then there is nothing listening to stream from — check for that rather than handing a player a URL into a closed port.
Every map from torrents() and torrent(hash) carries these. The names match the web API's JSON, so a plugin and a web client describe the same torrent the same way.
| Field | Type | Meaning |
|---|---|---|
hash | string | Info hash — the key every other function takes |
name | string | Torrent name as shown in the list |
save_path | string | Folder the data is being written to |
label | string | Current label, empty if none |
progress | float | 0.0 to 1.0 — not a percentage |
ratio | float | Uploaded over downloaded |
paused | bool | Paused by you, a plugin or the queue |
error | string | Last error, empty when healthy |
size | int | Total bytes of the selected files |
remaining | int | Bytes still to fetch |
downloaded | int | Bytes downloaded this torrent's lifetime |
uploaded | int | Bytes uploaded |
download_rate | int | Bytes per second, down |
upload_rate | int | Bytes per second, up |
peers | int | Connected peers |
seeds | int | Connected seeds |
queue_position | int | Position in the queue |
state | string | Downloading, seeding, checking, paused, error … |
These are the same verbs the web API exposes, deliberately: a plugin cannot reach anything an authenticated web client could not.
Pause one torrent. A torrent a plugin pauses reads as paused in the window and the web interface too — all three read the same session.
Resume one torrent.
Force a recheck of the data on disk.
Announce to the trackers now, rather than at the next interval.
returns bool — false for anything that is not "top", "up", "down" or "bottom".
0 skips the file, 1 is normal, higher is sooner. index is the one files gave you.
Either may be () to clear that half, so a plugin can set a ratio limit without inventing a time one.
Point the torrent at where its data lives, without moving what is already there — for data you moved by hand or restored from a backup. move_storage is the one that moves it.
returns bool — the URL is validated exactly as it is in the UI.
Tracker editing needs both permissions, which no other call does. Adding a tracker is a control operation on a torrent, but its consequence is disclosure: the torrent starts announcing to a server the plugin chose, which is your address and what you are downloading going somewhere new. A plugin holding only control can stop and start a torrent; it cannot redirect where that torrent tells the world about itself.
returns bool. Changes live in the database, never written back into the .torrent.
Take one announce URL off the torrent.
returns bool — a .torrent the plugin already holds, as base64.
The other half of http_get. add_torrent_url fetches for you, which is no use when the file sits behind a header, a cookie or a POST — fetch it yourself, then hand over the bytes.
Remove the torrent, keeping its files.
Remove the torrent, deleting the downloaded data when delete_files is true. Two arities rather than a default argument: Rhai has no optional parameters, and remove(hash) deleting files by accident is a mistake a plugin author only makes once.
Move the downloaded files to another folder, the way the context menu's Move storage does.
Apply a label by its numeric id — the ids Preferences ▸ Labels assigns.
Take the label off.
Add a magnet link, into the default save path.
Add a magnet link into a folder you name.
Raise a native desktop notification. Windows and macOS always can; on Linux it goes to the D-Bus notification daemon, so a session without one logs a warning and shows nothing.
returns #{ ok: bool, status: int, body: string, error: string }
Only http and https; file:// is refused before the request is made, so the network permission cannot be turned into a filesystem read. Capped at 4 MB and 30 seconds. An unreachable server is not an error that kills your handler — it comes back as ok: false with error set, because a plugin polling the internet on a timer will meet one sooner or later.
returns bool
Takes a magnet link as-is and fetches anything else as a .torrent, which is what feeds actually contain. Needs both permissions.
returns bool — the same, into a folder you name.
returns bool — the same, with options.
Take any of the three; anything left out keeps its default, so a plugin that only wants to add something paused does not have to name a folder as well. label is a label name and is looked up, never created — a plugin should not be able to fill somebody’s label list by getting a rule wrong — so a name that does not exist means no label, and says so in the log. paused is true for any non-empty value except "0" and "false", the same convention a form checkbox follows.
These two are the whole of execute, and they are the reason it reads last in the approval prompt. There is no shell anywhere in the path: the program and each argument are passed separately, so a space in a path needs no quoting and nothing a torrent, a feed or a filename can say becomes a command. Every call is logged with the program and its arguments before the process starts.
returns true if it started — not if it worked.
The child is not waited on: a media player runs for hours. A program that is not installed comes back false rather than raising, which is what makes “try these four paths until one of them is VLC” the obvious way to find a player without being able to look at the file system.
Hand a URL, a file or a folder to whatever the desktop opens it with. It names no program: the association is one the user already made, so this is how a plugin reaches “the player they chose” without being told which it is.
Put them in <your-plugin>_translations.json, beside the script — so player.rhai is translated by player_translations.json. Locale first, key second:
{
"en-US": { "play": "Play", "not_playable": "Nothing here can play {0}" },
"nl-NL": { "play": "Afspelen", "not_playable": "Hier kan {0} niet mee" }
}
No permission: it is your file, beside your plugin, saying what your plugin was going to say anyway. There is no file access here — the host reads that one path and nothing else, and a plugin with no such file simply gets its keys back. The language is read when you call t, not when the plugin loaded, so a window follows somebody changing their language in Preferences without a reload.
returns the string for key in the user’s language, with {0} and {1} filled in.
A key with no string in the current language falls back to en-US, and then to the key itself — so t("play") on a plugin nobody has translated yet reads play, which is obviously a missing string rather than a blank control.
{0} and {1} where the values go — do not build it with +. Word order is exactly what differs between languages: "Checking " + n + " feeds" can only ever come out in English order, while "Checking {0} feeds against {1} rules" lets the translator put the pieces where they belong. Two placeholders is the limit; a message with three moving parts is usually two messages.
player.rhai saves option ids (system, vlc, mpv, custom) and shows t("player_" + id), so the saved value means the same thing in every language. A dropdown whose translated label is the stored value silently forgets itself the day somebody switches language.
None of these needs a permission: two are arithmetic on a string you already hold, and a clock reveals nothing a script could not already infer from how often it is ticked.
returns maps, arrays and scalars — or () if it will not parse.
returns #{ tag, attrs, text, children }, or ().
Not a full document model — no namespaces, no comments — but enough to walk an RSS or Atom feed, which is the job it exists for. Entities and CDATA are decoded, and each element's text is trimmed. Without it, writing a feed reader would have meant writing an XML parser in Rhai.
returns seconds since the Unix epoch, as an integer.
Without one, “ignore this for a week” cannot be written at all. Combine it with data_set to hold a deadline across restarts — the plugin scope does not survive one.
Strings only, namespaced per plugin, so one plugin cannot read or overwrite another's. There is a 64 KB ceiling per plugin: it lives in the settings database, which is not the place for a cache of every item a feed ever published.
returns the stored string, or () if there is none. Test for () before using it.
returns false if the store is full — check it, prune and retry rather than ignoring the result.
Forget one key.
returns an array of every key this plugin has stored.
Nothing appears anywhere unless the script asks for it. The shape is fixed — an optional upper list, a main list, a text field, some buttons, a status line, or a form in place of the lists — and there is no layout language. That is deliberate: a plugin says what goes in the window and NanoTorrent decides how it looks, so a script cannot draw something that passes for part of the client asking for a password.
Because the window is state held by the plugin host rather than by the desktop window, the web interface renders the same surface from the same data — a plugin written for the desktop shows up in the browser unchanged, and needs no extra permission for it. A window opened in the browser stays there rather than also appearing on the machine running NanoTorrent. In a headless build there is nowhere to put a window, so every ui_* call does nothing and the host says so once in the log rather than failing — with one exception: file-menu items are drawn by the web interface, so there they are a plugin’s only way in.
Name the window. A plugin with no name has no window — declaring one is what lists it in the menu.
Show a single text field with this placeholder. An empty string means no field. Its contents arrive as the second argument to on_ui_button.
A row of buttons. Clicks call on_ui_button(id, input).
The main list. Clicks call on_ui_row(id). Which row is current is yours to decide, not the window's — set selected: true on it when you redraw, so the two can never end up disagreeing.
An optional second list above the main one, for when the main list shows the contents of something the user picks: feeds, categories, accounts. Clicks call on_ui_group(id). Leave it empty and the window is the single-list one it was before.
One line under the list. This is where a failure belongs: a window stuck on “Checking…” with a healthy log file somewhere else is indistinguishable from a hang.
Put the window on screen now. Separate from ui_window so a plugin can prepare one at load without a window appearing unasked.
Your own dropdown in the main window's menu bar, after File, View and Help. An empty title falls back to the plugin's name; choosing an item calls on_ui_menu(id). This is where a plugin puts the things a person does with it — “Feeds…”, “Check now”.
One dropdown per plugin, structurally rather than by a rule the host checks: a plugin holds a single menu title and item list, so calling ui_menu twice replaces the menu instead of adding another. Capped at 20 items; the rest are dropped with a line in the log.
Your items on the context menu of a file, in a torrent’s details panel and on the matching row in the web interface. Using one calls on_file_menu. This is the only place a plugin draws outside its own window, and it is deliberately the smallest shape that works: items, no submenus, no icons, no say in where they go. They sit below NanoTorrent’s own Open file, under a separator, in plugin name order — you cannot get above it or replace it.
Items are labelled with your plugin’s name — “Player: Play”, not “Play”. That is not decoration. A menu item is text of your choosing appearing inside NanoTorrent’s own window, which is the shape of every convincing phishing prompt ever written; the prefix means an item reading “Verify your password” is at least visibly somebody’s.
The same list is offered on every file: the host draws the menu before it knows which file it will be used on, so a plugin that only handles video checks the name it is given in on_file_menu and says why, rather than expecting the item to hide itself. A plugin with file-menu items needs no window at all.
Your own mark beside your plugin’s name. The argument is SVG path data on a 16×16 grid — ui_icon("M 4 2 L 14 8 L 4 14 Z") is a play triangle. One string, both front ends: the desktop hands it to Slint’s Path and the web interface to <svg><path d="">, which read the same syntax, so it is sharp at any size in either and takes its colour from the theme rather than carrying one of its own.
Straight lines only, in practice: Slint’s Path does not lean on SVG arc support, so A commands are best avoided — the shipped RSS icon is three rising bars rather than the usual arcs for exactly this reason. And the string is checked before it is stored: anything that is not path data — a tag, an &, a quote, or more than 2 KB of it — is dropped and your plugin falls back to its initial. None of those mean anything in path data, and it is what lets the web interface put your icon in its markup without having to trust it.
A form, in place of the lists. One text field is enough to add a feed and nowhere near enough to edit a rule with a dozen settings on it — this describes the controls you want, and the window draws them and hands the values back in one go through on_ui_form. An empty field list closes the form, so ui_form(id, title, []) and ui_form_close() do the same thing.
A form replaces the lists while it is up, in the window and the web interface alike: it is a different thing to be doing, and half a list behind a form is neither. It also means the redraw a plugin does on a timer will not disturb one — the window only re-pushes a form whose fields have actually changed, so it cannot wipe out what somebody is typing.
Put the lists back. Neither saving nor cancelling does this for you.
Put a cog on your row in Preferences ▸ Plugins, which calls on_ui_configure(). For a plugin that will not work until it is set up — the RSS reader has no feeds until you give it one. It is not a second way to open your window: declare it only if there is genuinely something to configure, or the cog becomes noise on every row.
kind is one of the four below. options is one entry per line and is ignored by every other kind; hint draws a line of explanation under the control, or nothing when empty.
kind | Control | Value |
|---|---|---|
text | A text field | What was typed |
number | A text field that only takes digits | Still a string — parse it yourself |
check | A checkbox; label goes beside it, not above | "1" or "" |
choice | A dropdown over options | The entry chosen |
Write a line to the NanoTorrent log, tagged plugin. Always available — a script that declares no permissions can still do this, which is why declaring nothing needs no approval.
Top-level statements run once, when the plugin loads, and the variables they create are visible to your handlers — and mutable from them. The scope belongs to that one plugin: another plugin's top-level seen is a different variable, and neither can see the other's.
let items = [];
fn draw() { ui_rows(items); } // WRONG — "variable not found: items"
fn draw(list) { ui_rows(list); } // right — pass it down
fn on_ui_open() {
items = fetch(); // fine, a handler sees the scope
draw(items);
}
Keep state in the handlers and pass it to helpers as arguments. The scope is gone at restart, and gone again whenever plugin settings are applied — anything that has to outlive that belongs in data_set.
| Limit | Value |
|---|---|
| Operations per handler call | 500,000 — a script that loops forever is killed here and logged, it cannot hang the client |
| Call depth | 64 levels |
| Expression depth | 64, or 32 inside a function |
| Arrays and maps | 50,000 elements |
| Strings | 8 MB — twice the HTTP ceiling, deliberately |
http_get response | 4 MB, 30 seconds |
| Stored data | 64 KB per plugin |
| Menu items | 20 per plugin, in one dropdown |
ui_icon path data | 2 KB, and path data only |
| Ticks | on_tick once a minute, fixed |
The string and HTTP ceilings are one number and not two on purpose: http_get hands the response body back as a string, so the string limit has to hold a whole response with room to build something from it. Otherwise a fetch inside the documented limit produces a value the engine refuses to hold, and every feed above the smaller number fails with an error its author cannot act on.
Plugins run on their own thread, so a slow one delays other plugins but never the UI, the session or a web request.
A plugin that fails to compile, or throws from a handler, is logged and skipped. It does not stop other plugins running, and it does not stop NanoTorrent starting. Preferences ▸ Plugins shows a compile error next to the plugin, with its line and column, and a broken plugin stays ticked — that it is broken is a fact about the script, not a setting to be undone on your behalf.
INFO loaded plugin ratio-keeper with: read, control
WARN plugin tidy-up is waiting for approval of: read, storage, remove
ERROR plugin broken-thing: Syntax error: ... (line 2, position 32)
INFO plugin: hello from my script
Note that the Preferences tab checks whether a plugin compiles; it does not run its top-level statements, because opening a settings dialog must not have side effects. A script that compiles and then fails on its first line shows up in the log, not in the dialog.
As source. There is no compiled form. Rhai has no serialised-AST or bytecode format to ship, so a plugin is exactly one .rhai file that someone copies into their plugins folder. For this design that is the right way round rather than a limitation: the permission header is only trustworthy because it is read from the same text that will run. A pre-compiled blob would make the declaration unverifiable and put the user's decision on something they cannot read.
example.rhai, rss.rhai and player.rhai are already taken by the shipped ones.run and open exist now, behind execute. They did not, and the reasoning against them was that no prompt makes “execute arbitrary programs” a decision anybody can sensibly consent to. What changed is not that argument — it is who is being asked. A permission shown in plain language, held until approved, held again when the script edits its own header, and logged on every use is the same consent the other ten rest on. Refusing it did not make plugins safer; it made the useful ones impossible and pushed people to run those scripts outside NanoTorrent, where nothing is declared, approved or logged at all.
Reading and writing files are still not offered. Four more things the web interface can do that a plugin cannot, each for a reason rather than an oversight:
stream_url hands a player a URL; the bytes go from the session to the player without passing through the script. A plugin never sees them. Its own state lives in data_* and nowhere else.Beyond those file-menu items, a plugin cannot add to the main window, the toolbar or the rest of the details panel. It also cannot approve itself: the web interface can list plugins and switch them on and off, but consent to what a script may reach is given at the machine it runs on. What a plugin changes does show up everywhere, because every surface reads the same session — a torrent a plugin pauses reads as paused in the window and in the browser.
A plugin with a window, a menu, stored state and a timer — the pieces most plugins need, in one file. The shipped rss.rhai is the full version of this; player.rhai is the shorter one to copy if you want file-menu items, execute and translations instead.
// watchlist.rhai — keep a list of search terms, and label anything matching.
//
// read see torrent names as they are added
// labels put a label on the ones that match
// data remember the watchlist across restarts
// ui a window to edit it in, and a menu to open the window
//! permissions: read, labels, data, ui
const LABEL_ID = 1;
fn terms() {
let raw = data_get("terms");
if raw == () { return []; }
let out = [];
for line in raw.split("\n") {
// Rhai's trim edits in place and returns (), so it is its own statement.
let t = line;
t.trim();
if t != "" { out.push(t); }
}
out
}
fn draw(list) {
let rows = [];
for t in list { rows.push(#{ id: t, title: t }); }
ui_rows(rows);
ui_status("" + list.len() + " terms — click one to remove it");
}
fn on_session_start() {
ui_window("Watchlist");
ui_input("a word to watch for");
ui_buttons([#{ id: "add", label: "Add" }]);
ui_menu("Watchlist", [#{ id: "open", label: "Terms…" }]);
ui_configurable(true);
draw(terms());
}
fn on_ui_menu(id) { ui_show(); }
fn on_ui_configure() { ui_show(); }
fn on_ui_open() { draw(terms()); }
fn on_ui_button(id, input) {
if id != "add" || input == "" { return; }
let list = terms();
list.push(input);
if !data_set("terms", list.reduce(|a, t| if a == () { t } else { a + "\n" + t })) {
ui_status("the store is full — remove a term first");
return;
}
draw(list);
}
fn on_ui_row(id) {
let kept = [];
for t in terms() { if t != id { kept.push(t); } }
data_set("terms", kept.reduce(|a, t| if a == () { t } else { a + "\n" + t }));
draw(kept);
}
fn on_torrent_added(hash, name) {
let lower = name;
lower.to_lower();
for t in terms() {
let needle = t;
needle.to_lower();
if lower.contains(needle) {
set_label(hash, LABEL_ID);
log("watchlist matched " + t + " in " + name);
return;
}
}
}
The canonical version of everything on this page is docs/PLUGINS.md in the repository. Found something this page gets wrong, or want a function that is not here? Open an issue or come to the Discord.