Session checkpoint on webui-plugin: - engine dump (nautctl dump) + engine endgame integration - per-file move locations persistence; torrent-level "Set location" with reset/keep-relative/leave-separate handling + residual prune - Lua: naut.get_labels, define_settings/get_setting (script_host struct) - daemon-owned labels (category+tags) + taxonomy persistence; webui write-through - fix: pausing a completed/seeding torrent now sticks (stop wins over result) - automation tab responsive layout; anime_sort label gating + settings Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
13 KiB
Naut-Torrent Lua scripting reference
Naut-Torrent embeds a sandboxed Lua interpreter so you can react to engine events (a torrent finished, a file completed, a peer connected) and drive a small set of actions — most importantly moving a file out of the download directory the moment it finishes, without waiting for the whole torrent.
This document describes the entire script-visible surface: how scripts are
loaded and run, the sandbox, every event hook, the event object passed to
them, and the naut API table.
Implemented in
src/script/script.c; the host API isinclude/naut/script.h. The daemon wiring is inapps/nautd/main.c.
1. Loading a script
Load a script through the control API:
nautctl --socket /tmp/nautd.sock script ./my-rules.lua
- One active script per daemon. Loading another script replaces the current
one.
nautctl unscriptunloads it.nautd --script PATHis also available for startup configuration. - The file is loaded and executed once, synchronously on the control thread,
before its event worker starts. Use this top-level run to define your hook
functions (and any state they need). A load or top-level error rejects the
request (or prevents startup when
--scriptis used). - After startup the script is event-driven: the functions you defined are called as matching events occur.
-- my-rules.lua — top level runs once at load
local moved = 0
function on_file_complete(event) -- called later, per event
naut.move_file(event.torrent_id, event.index, "/archive/" .. event.path)
moved = moved + 1
end
2. Execution & threading model
- Hooks run on a single dedicated script thread, one event at a time, in the order events were emitted. Your hooks never run concurrently with each other, so ordinary Lua locals/tables need no locking.
- Events reach the script through a bounded queue (
queue_capacity, 256 innautd). The engine never blocks waiting for your script: if the queue is full because a hook is slow, new events are dropped (and counted — see §7). Keep hooks short; offload nothing back onto the engine threads. - The event is copied before it is handed to your hook. Two copied fields
are length-bounded:
messageis truncated to 255 bytes andpathtoPATH_MAX-1. Longer values are silently shortened. - A hook that raises an error does not crash the daemon or stop the script: the error is recorded and the next event proceeds (see §7).
- Hook return values are ignored.
3. The sandbox
The standard libraries are opened and then the dangerous globals are removed, so a script cannot touch the filesystem, spawn processes, load native modules, or compile raw chunks.
Removed (set to nil):
| Global | Why |
|---|---|
os |
process/clock/os.execute/os.remove |
io |
filesystem handles |
package, require |
native/Lua module loading |
debug |
introspection / sandbox escape |
dofile, loadfile |
run code from a file |
load, loadstring |
compile arbitrary chunks — the default "bt" mode accepts binary bytecode, which can escape the VM, so the loaders are denied as defense-in-depth |
Available: the rest of the base library (print, pcall, error,
assert, type, tostring, tonumber, pairs, ipairs, select,
setmetatable, next, …) plus string, table, math, coroutine, and
utf8. Plus the naut table (§6).
print writes to the daemon's stdout/log — handy for debugging rules.
4. Event hooks
Define any subset of these as global functions. Each is optional; an event
with no matching global is simply ignored. Each hook is called with one
argument, the event object.
| Hook | Fired when | Most relevant fields |
|---|---|---|
on_torrent_added(event) |
a torrent is registered with the session | torrent_id |
on_piece_complete(event) |
a piece verifies and is written | torrent_id, index = piece index |
on_file_complete(event) |
a file's last covering piece verifies — the file is final on disk and safe to move, before the torrent finishes | torrent_id, index = file index, path = file path |
on_torrent_finished(event) |
every piece of the torrent is complete | torrent_id |
on_peer_connected(event) |
a peer connection is established | torrent_id, message = peer address (when provided) |
on_alert(event) |
a general engine alert/notice | message = alert text |
function on_torrent_finished(event)
print("torrent " .. event.torrent_id .. " complete")
end
Which fields carry meaningful data depends on the component that emits the event (engine, a plugin, or the
emitRPC). The table above lists the conventional payload for each type. Always guard optional fields (if event.path then ... end) —messageandpatharenilwhen the emitter didn't set them.
5. The event object
The single argument to every hook is a plain Lua table with these fields:
| Field | Lua type | Always present | Meaning |
|---|---|---|---|
event.type |
string |
yes | the event name, e.g. "file_complete" (see §8) |
event.torrent_id |
integer |
yes | the torrent this event belongs to (0 if not torrent-scoped) |
event.index |
integer |
yes | a type-specific index — piece index for piece_complete, file index for file_complete; 0 otherwise |
event.message |
string |
no | free-form text; nil when unset |
event.path |
string |
no | a filesystem path (e.g. the completed file); nil when unset |
The table is freshly created per call; mutating it has no effect on the engine and the table is not reused between events.
function on_file_complete(event)
assert(event.type == "file_complete")
print(("file #%d of torrent %d done: %s")
:format(event.index, event.torrent_id, event.path or "?"))
end
6. The naut API table
A single global table, naut, exposes the actions a script may take.
naut.move_file(torrent_id, file_index, destination)
Move one completed file of a torrent to destination (the headline
"move files as they finish" feature). Typically called from on_file_complete.
Arguments
| # | Name | Lua type | Notes |
|---|---|---|---|
| 1 | torrent_id |
integer |
must be ≥ 0; identifies a torrent registered with the daemon (see prerequisite below) |
| 2 | file_index |
integer |
must be in [0, 2³²-1]; index of the file within the torrent |
| 3 | destination |
string |
target path to move the file to |
Return value: none on success.
Asynchronous semantics — important. move_file does not perform the
move inline on the script thread. It enqueues a bounded command that the
torrent's owner thread executes shortly after (the script thread never
touches storage directly).
So a successful call means "the move was accepted", not "the file has moved."
The actual relocate (a rename, or copy+unlink across filesystems) runs later;
its success or failure is reported in the daemon log and reflected in the
move/relocate side effects — it is not returned to the script.
Errors (raised as Lua errors — catch with pcall if you don't want them to
abort the hook):
| Message | Cause |
|---|---|
move_file arguments out of range |
torrent_id < 0, file_index < 0, or file_index > 2³²-1 |
move_file is unavailable |
the host did not wire a move handler |
move_file failed: <code> |
the host rejected the command; the integer is a naut_err — most commonly -8 (queue full / backpressure: the owner thread is behind on draining moves) |
function on_file_complete(event)
local ok, err = pcall(naut.move_file,
event.torrent_id, event.index,
"/archive/" .. event.path)
if not ok then
print("could not queue move: " .. tostring(err))
end
end
Prerequisite — the torrent must be loaded. Add the torrent to the daemon; the same worker that downloads it owns and executes its move commands:
nautctl --socket /tmp/nautd.sock add file.torrent /downloads/42
If torrent_id is unknown or is being removed, naut.move_file raises a
move_file failed error in the hook.
naut.get_labels(torrent_id)
Return the torrent's labels as a plain array (table) of strings. Labels are
the user-assigned tags/category set in the web UI (or via the set_labels RPC);
they are stored on the daemon, persisted across restarts, and surfaced here so a
script can branch on them (e.g. route a finished file by its label).
Arguments
| # | Name | Lua type | Notes |
|---|---|---|---|
| 1 | torrent_id |
integer |
must be ≥ 0 |
Return value: a sequence table of strings, e.g. {"anime", "airing"}. The
table is empty (#labels == 0) when the torrent has no labels or is unknown
— it is never nil, so it is always safe to iterate.
function on_file_complete(event)
local labels = naut.get_labels(event.torrent_id)
for _, label in ipairs(labels) do
if label == "anime" then
naut.move_file(event.torrent_id, event.index,
"/archive/anime/" .. event.path)
return
end
end
end
Labels reflect the daemon's current state at call time (re-read on every call), so a script always sees the latest assignment.
naut.define_settings({ ... })
Declare the user-configurable variables the script reads, so they can be edited in the web UI (Automation ▸ Settings) instead of by hand in the source. Pass an array of entries:
| Field | Lua type | Meaning |
|---|---|---|
key |
string |
identifier passed to naut.get_setting |
label |
string |
human label shown in the form (defaults to key) |
type |
string |
"string", "bool", or "number" (drives the widget + the type returned by get_setting) |
default |
string/bool/number | value used until the user sets one |
Call it once at load time (re-declaring replaces the schema). The values the user saves persist across restarts, independently of the script source.
naut.get_setting(key)
Return the current value of a declared setting: the user-saved value if present,
otherwise the declared default. The result is typed per the schema — a Lua
boolean for bool, a number for number, a string otherwise — or nil
if the key was never declared. Re-read it on each use so live edits take effect
without reloading the script.
naut.define_settings({
{ key = "library_root", label = "Library root", type = "string",
default = "/media/anime" },
{ key = "only_video", label = "Only video files", type = "bool",
default = true },
})
function on_file_complete(event)
if naut.get_setting("only_video") and not is_video(event.path) then return end
local root = naut.get_setting("library_root")
naut.move_file(event.torrent_id, event.index, root .. "/" .. basename(event.path))
end
7. Error handling & observability
- A hook that raises is caught; the error text is stored as the script's last error and an error counter is incremented. The script keeps running.
- A global named after a hook that is not a function (e.g. you set
on_alert = 5) is treated as an error for that event. - Counters are exposed over RPC via
nautctl statusunder thescriptobject:Counter Meaning queuedevents accepted into the script queue handledhook invocations that returned without error droppedevents discarded because the queue was full errorshooks that raised, weren't functions, or had an unknown type move_requestssuccessful naut.move_filecalls (commands queued)
nautctl --socket /tmp/nautd.sock status
# { ... "script": { "queued": 12, "handled": 12, "dropped": 0,
# "errors": 0, "move_requests": 3 }, ... }
8. Event type name strings
event.type (and the emit RPC type field) uses these exact strings:
torrent_added, piece_complete, file_complete, torrent_finished,
peer_connected, alert.
You can inject any of them for testing with the emit RPC:
nautctl --socket /tmp/nautd.sock emit \
'{"type":"file_complete","torrent_id":42,"index":0,"path":"/downloads/42/movie.mkv"}'
9. Complete example
A full, tested real-world script lives in
../examples/anime_sort.lua: it parses each
finished file with an embedded Anitomy-style parser and sorts anime into a
Title/Season NN/Title - SNNENN.ext library as episodes complete. See
../examples/README.md.
A minimal version:
-- archive-on-finish.lua
-- Move each file to /archive as it completes, and log torrent completion.
local archive = "/archive"
function on_file_complete(event)
if not event.path then return end
local name = event.path:match("[^/]+$") or event.path
local ok, err = pcall(naut.move_file,
event.torrent_id, event.index,
archive .. "/" .. name)
if ok then
print(("archived file #%d of torrent %d -> %s/%s")
:format(event.index, event.torrent_id, archive, name))
else
print("move failed to queue: " .. tostring(err))
end
end
function on_torrent_finished(event)
print("torrent " .. event.torrent_id .. " fully downloaded")
end
Run it:
nautd --socket /tmp/nautd.sock &
nautctl --socket /tmp/nautd.sock script ./archive-on-finish.lua
nautctl --socket /tmp/nautd.sock add big.torrent /downloads/1