Add a worked nautd scripting example that files each anime episode into a
media-server-friendly library the moment its file finishes verifying:
<SORTED_ROOT>/<Title>/Season NN/<Title> - SNNENN.<ext>
- examples/anitomy.lua: a compact, dependency-free reimplementation of Anitomy
(title/season/episode/release-group/resolution/year) in pure Lua — no
require/io/os, so it embeds in the sandbox.
- examples/anime_sort.lua: on_file_complete hook that parses the filename and
calls naut.move_file(); the embedded parser is a verbatim copy of anitomy.lua.
- examples/test_anitomy.lua, test_anime_sort.lua: parser battery + end-to-end
path-building test with an embedded-vs-module drift guard. Wired into ctest as
example_anitomy / example_anime_sort when a lua interpreter is present.
- docs: examples/README.md plus pointers from README and docs/scripting.md.
Verified end to end against a live nautd: file_complete -> move_file -> on-disk
relocate into the sorted tree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
289 lines
11 KiB
Markdown
289 lines
11 KiB
Markdown
# 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
|
|
|
|
Pass a script to the daemon with `--script`:
|
|
|
|
```sh
|
|
nautd --socket /tmp/nautd.sock --script ./my-rules.lua
|
|
```
|
|
|
|
- **One script per daemon.** If `--script` is given more than once, the last
|
|
path wins.
|
|
- The file is **loaded and executed once at startup**, on the main thread,
|
|
before the event worker starts. Use this top-level run to define your hook
|
|
functions (and any state they need). If the file fails to load or its
|
|
top-level code raises, the daemon refuses to start and prints the Lua error.
|
|
- 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 daemon
|
|
**owner thread** executes shortly after (this preserves the engine's
|
|
shared-nothing threading: 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) |
|
|
|
|
```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 — register the torrent's storage first.** `move_file` resolves
|
|
`torrent_id` through the daemon's torrent registry (`naut_session`). Register a
|
|
torrent's storage with the `add_torrent` RPC before any move command can take
|
|
effect:
|
|
|
|
```sh
|
|
nautctl --socket /tmp/nautd.sock add_torrent \
|
|
'{"torrent_id":42,"torrent":"file.torrent","root":"/downloads/42"}'
|
|
```
|
|
|
|
If `torrent_id` is unknown when the move drains, the relocate fails with
|
|
"not found" in the daemon log (the Lua call itself still succeeded, because it
|
|
only *queued* the command).
|
|
|
|
---
|
|
|
|
## 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 --script ./archive-on-finish.lua &
|
|
nautctl --socket /tmp/nautd.sock add_torrent \
|
|
'{"torrent_id":1,"torrent":"big.torrent","root":"/downloads/1"}'
|
|
```
|