Naut/docs/scripting.md
ookami125 b633b7d216 nautd/webui: scripting, labels, settings, set-location, pause fix
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>
2026-06-21 23:19:41 -04:00

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 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.

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 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