Naut/docs/scripting.md
ookami125 8dde48c05a webui: replace nautctl web server with a loadable plugin
Drop the web UI that was compiled into nautctl and serve the
torrent-ui front end (../torrent-ui/public) from a native plugin
(plugins/webui) loaded via `nautd --plugin`. The plugin talks to the
engine only through the host call_rpc ABI and adapts the daemon's RPC
surface to the qBittorrent-style contract the UI expects (snapshot/SSE,
torrent detail tabs, add/delete, cookie auth).

Also folds in the daemon refactor that owns per-torrent worker threads
and the swarm engine (naut_swarm) used by the plugin's data source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:21:48 -04:00

11 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 is include/naut/script.h. The daemon wiring is in apps/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 unscript unloads it. nautd --script PATH is 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 --script is 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 in nautd). 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: message is truncated to 255 bytes and path to PATH_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 emit RPC). The table above lists the conventional payload for each type. Always guard optional fields (if event.path then ... end) — message and path are nil when 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.


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 status under the script object:
    Counter Meaning
    queued events accepted into the script queue
    handled hook invocations that returned without error
    dropped events discarded because the queue was full
    errors hooks that raised, weren't functions, or had an unknown type
    move_requests successful naut.move_file calls (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