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>
This commit is contained in:
ookami125 2026-06-21 23:19:41 -04:00
parent 6dc711cf57
commit b633b7d216
40 changed files with 3305 additions and 3267 deletions

View file

@ -9,6 +9,9 @@
-- file's last piece verifies, episodes are sorted the moment they're done —
-- without waiting for the rest of the torrent.
--
-- By default it only sorts torrents you have labelled "anime" (REQUIRE_LABEL
-- below), read via naut.get_labels(), so non-anime downloads are left untouched.
--
-- Install:
-- nautd --socket /tmp/nautd.sock
-- nautctl --socket /tmp/nautd.sock script examples/anime_sort.lua
@ -19,13 +22,42 @@
-- test_anime_sort.lua asserts they agree.
----------------------------------------------------------------------
-- CONFIG — edit these
-- CONFIG — these are exposed in the web UI (Automation ▸ Settings) through
-- naut.define_settings, so you can change them there WITHOUT editing this file.
-- The values below are only the defaults used until you set them in the UI.
----------------------------------------------------------------------
local SORTED_ROOT = "/workspaces/source/ai-garbo/Naut-Torrent/sorted" -- destination library root
local ONLY_VIDEO = true -- skip non-video files (subs, nfo, samples)
local KEEP_ORIGINAL_NAME = false -- true: keep the original filename;
-- false: rename to "Title - SNNENN.ext"
local DEFAULTS = {
sorted_root = "/workspaces/source/ai-garbo/Naut-Torrent/Downloads/Sorted/",
only_video = true, -- skip non-video files (subs, nfo, samples)
keep_original_name = false, -- false: rename to "Title - SNNENN.ext"
require_label = "anime", -- only sort torrents with this label
-- (case-insensitive); blank = any torrent
}
-- Read a setting from the host live (so UI edits apply without a reload),
-- falling back to the default when unset or running on an older daemon.
local function setting(key)
if type(naut) == "table" and type(naut.get_setting) == "function" then
local v = naut.get_setting(key)
if v ~= nil then return v end
end
return DEFAULTS[key]
end
-- Declare the configurable variables so the web UI can render a form for them.
if type(naut) == "table" and type(naut.define_settings) == "function" then
naut.define_settings({
{ key = "sorted_root", label = "Library root", type = "string",
default = DEFAULTS.sorted_root },
{ key = "only_video", label = "Only video files", type = "bool",
default = DEFAULTS.only_video },
{ key = "keep_original_name", label = "Keep original filename",
type = "bool", default = DEFAULTS.keep_original_name },
{ key = "require_label", label = "Required label (blank = any)",
type = "string", default = DEFAULTS.require_label },
})
end
----------------------------------------------------------------------
-- embedded anitomy parser (== examples/anitomy.lua)
@ -240,6 +272,8 @@ local function sanitize(s)
end
local function destination(parsed)
local root = setting("sorted_root")
local keep_original = setting("keep_original_name")
local title = sanitize(parsed.title)
local ext = parsed.extension and ("." .. parsed.extension) or ""
local original = sanitize(basename(parsed.file_name))
@ -247,18 +281,18 @@ local function destination(parsed)
if parsed.episode == nil then
-- movie / special: <root>/<title>/<file>
local fname
if KEEP_ORIGINAL_NAME then
if keep_original then
fname = original
else
fname = title .. (parsed.year and (" (" .. parsed.year .. ")") or "") .. ext
end
return SORTED_ROOT .. "/" .. title .. "/" .. fname
return root .. "/" .. title .. "/" .. fname
end
local season = parsed.season or 1
local sdir = string.format("Season %02d", season)
local fname
if KEEP_ORIGINAL_NAME then
if keep_original then
fname = original
else
fname = string.format("%s - S%02dE%02d", title, season, parsed.episode)
@ -267,11 +301,25 @@ local function destination(parsed)
end
fname = fname .. ext
end
return SORTED_ROOT .. "/" .. title .. "/" .. sdir .. "/" .. fname
return root .. "/" .. title .. "/" .. sdir .. "/" .. fname
end
-- True if the torrent carries `want` among its labels (case-insensitive). When
-- `want` is nil the gate is disabled. If the daemon predates naut.get_labels we
-- can't check, so we sort anyway rather than silently dropping every file.
local function has_label(torrent_id, want)
if not want or want == "" then return true end
if type(naut.get_labels) ~= "function" then return true end
want = want:lower()
for _, label in ipairs(naut.get_labels(torrent_id)) do
if label:lower() == want then return true end
end
return false
end
-- exposed for tests; harmless in the daemon
_G.anime_sort = { anitomy = anitomy, destination = destination }
_G.anime_sort = { anitomy = anitomy, destination = destination,
has_label = has_label }
----------------------------------------------------------------------
-- event hook
@ -279,10 +327,13 @@ _G.anime_sort = { anitomy = anitomy, destination = destination }
function on_file_complete(event)
if not event.path then return end
if not has_label(event.torrent_id, setting("require_label")) then
return -- not labelled "anime": leave this torrent's files alone
end
local name = basename(event.path)
local parsed = anitomy.parse(name)
if ONLY_VIDEO and not anitomy.is_video(parsed.extension) then
if setting("only_video") and not anitomy.is_video(parsed.extension) then
return -- leave subtitles, .nfo, samples, etc. where they are
end

View file

@ -7,7 +7,16 @@ local ref = require("anitomy")
-- stub the host API the script calls, and silence its prints
local captured
_G.naut = { move_file = function(tid, idx, dest) captured = dest end }
-- Per-torrent label stub: torrent 1 is labelled "anime"; others are unlabelled.
local LABELS = { [1] = { "anime" } }
-- Optional per-key setting overrides; nil falls back to the script's defaults.
local SETTINGS = {}
_G.naut = {
move_file = function(tid, idx, dest) captured = dest end,
get_labels = function(tid) return LABELS[tid] or {} end,
define_settings = function(_) end, -- schema declaration: no-op here
get_setting = function(key) return SETTINGS[key] end,
}
local realprint = print
_G.print = function() end
@ -23,9 +32,9 @@ local configured_root = _G.anime_sort.destination({
assert(configured_root, "could not derive SORTED_ROOT from anime_sort.lua")
local fails = 0
local function fire(path)
local function fire(path, torrent_id)
captured = nil
on_file_complete({ torrent_id = 1, index = 0, path = path })
on_file_complete({ torrent_id = torrent_id or 1, index = 0, path = path })
return captured
end
local function expect(path, want_dest)
@ -69,6 +78,42 @@ do
end
end
-- label gate: a torrent without the "anime" label is left alone (no move),
-- while an "anime"-labelled torrent is still sorted.
do
local episode = "/downloads/[HorribleSubs] Boku no Hero Academia - 12 [1080p].mkv"
local unlabelled = fire(episode, 2) -- torrent 2 has no labels
if unlabelled ~= nil then
fails = fails + 1
realprint("FAIL unlabelled torrent should be skipped, got: "
.. tostring(unlabelled))
else
realprint("ok unlabelled torrent skipped")
end
local labelled = fire(episode, 1) -- torrent 1 is labelled "anime"
if labelled == nil then
fails = fails + 1
realprint("FAIL anime-labelled torrent should be sorted")
else
realprint("ok anime-labelled torrent sorted")
end
end
-- settings: a value configured in the UI (delivered via naut.get_setting) takes
-- effect live, without editing the script.
do
SETTINGS.sorted_root = "/custom/lib"
local got = fire("/downloads/[HorribleSubs] Boku no Hero Academia - 12 [1080p].mkv", 1)
SETTINGS.sorted_root = nil
local want = "/custom/lib/Boku no Hero Academia/Season 01/Boku no Hero Academia - S01E12.mkv"
if got ~= want then
fails = fails + 1
realprint("FAIL sorted_root override not applied, got: " .. tostring(got))
else
realprint("ok sorted_root setting override applied")
end
end
-- drift guard: embedded parser must agree with anitomy.lua
local embedded = _G.anime_sort.anitomy
local drift = 0