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