# 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: ```sh 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. ```lua -- 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](#5-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 | ```lua 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. ```lua 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: ` | 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) | ```lua 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: ```sh 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. ```lua 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. ```lua 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) | ```sh 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: ```sh 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`](../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`](../examples/README.md). A minimal version: ```lua -- 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: ```sh 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 ```