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>
352 lines
13 KiB
Lua
352 lines
13 KiB
Lua
-- anime_sort.lua — a Naut-Torrent script that files each anime episode into a
|
|
-- tidy library as it finishes downloading:
|
|
--
|
|
-- <SORTED_ROOT>/<Anime Title>/Season NN/<Anime Title> - SNNENN.<ext>
|
|
--
|
|
-- It parses the filename with an embedded Anitomy-style parser (anime title,
|
|
-- season, episode) and asks the daemon to relocate the completed file via
|
|
-- naut.move_file(). Because Naut-Torrent fires on_file_complete the instant a
|
|
-- 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
|
|
-- nautctl --socket /tmp/nautd.sock add show.torrent /downloads/1
|
|
--
|
|
-- The parser below is a VERBATIM COPY of examples/anitomy.lua (the sandbox has
|
|
-- no `require`, so it must be inlined). Keep the two in sync; examples/
|
|
-- test_anime_sort.lua asserts they agree.
|
|
|
|
----------------------------------------------------------------------
|
|
-- 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 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)
|
|
----------------------------------------------------------------------
|
|
|
|
local anitomy = (function()
|
|
local M = {}
|
|
|
|
local VIDEO_EXT = {
|
|
mkv=true, mp4=true, avi=true, ogm=true, wmv=true, mov=true, flv=true,
|
|
webm=true, m4v=true, mpg=true, mpeg=true, ts=true, m2ts=true, rmvb=true,
|
|
}
|
|
|
|
local KEYWORD = {}
|
|
for _, w in ipairs({
|
|
"bd","bdrip","bdremux","bluray","blu-ray","dvd","dvdrip","dvd5","dvd9",
|
|
"web","webrip","web-dl","webdl","hdtv","tvrip","hdrip","remux","hr",
|
|
"x264","x265","h264","h265","h.264","h.265","hevc","avc","xvid","divx",
|
|
"vp9","10bit","10bits","10-bit","8bit","hi10p","hi10",
|
|
"aac","ac3","eac3","flac","dts","dts-hd","opus","mp3","truehd","aacx2",
|
|
"flacx2","2.0","2.1","5.1","7.1","2ch","6ch",
|
|
"dual","dualaudio","multi","multisubs","eng","engsub","jpn","jap","ita",
|
|
"esp","vostfr","vf","raw","sub","subs","subbed","softsub","softsubs",
|
|
"hardsub","hardsubs","dub","dubbed",
|
|
"uncensored","censored","batch","repack","proper","internal","final",
|
|
"ncop","nced","menu","preview",
|
|
}) do KEYWORD[w] = true end
|
|
|
|
local ORDINAL = { st=true, nd=true, rd=true, th=true }
|
|
|
|
local function splitext(name)
|
|
local base, ext = name:match("^(.*)%.([%w]+)$")
|
|
if base and #ext <= 4 then return base, ext:lower() end
|
|
return name, nil
|
|
end
|
|
|
|
local function is_resolution(t)
|
|
return t:match("^%d+[pi]$") ~= nil
|
|
or t:match("^%d+[xX]%d+$") ~= nil
|
|
or t:match("^[24][kK]$") ~= nil
|
|
end
|
|
|
|
local function is_crc(t)
|
|
return #t == 8 and t:match("^%x+$") ~= nil and t:match("%a") ~= nil
|
|
end
|
|
|
|
local function is_junk(t)
|
|
local low = t:lower()
|
|
return KEYWORD[low] or is_resolution(t) or is_crc(t)
|
|
end
|
|
|
|
local function tokenize(s)
|
|
local out = {}
|
|
for tok in s:gmatch("%S+") do out[#out + 1] = tok end
|
|
return out
|
|
end
|
|
|
|
local function extract(toks)
|
|
local n = #toks
|
|
local season, episode, episode_end, e_idx, s_idx
|
|
|
|
for i = 1, n do
|
|
local s, e = toks[i]:match("^[Ss](%d+)[Ee](%d+)$")
|
|
if s then
|
|
local _, e2 = toks[i]:match("[Ee](%d+)[%-~][Ee]?(%d+)$")
|
|
return tonumber(s), tonumber(e), e2 and tonumber(e2) or nil, i
|
|
end
|
|
end
|
|
|
|
for i = 1, n do
|
|
local t = toks[i]
|
|
if t:match("^[Ss]eason$") and toks[i + 1] and toks[i + 1]:match("^%d+$") then
|
|
season, s_idx = tonumber(toks[i + 1]), i
|
|
break
|
|
end
|
|
local num, ord = t:match("^(%d+)(%a%a)$")
|
|
if num and ORDINAL[ord:lower()] and toks[i + 1]
|
|
and toks[i + 1]:match("^[Ss]eason$") then
|
|
season, s_idx = tonumber(num), i
|
|
break
|
|
end
|
|
local sn = t:match("^[Ss](%d+)$")
|
|
if sn and i > 1 then
|
|
season, s_idx = tonumber(sn), i
|
|
break
|
|
end
|
|
end
|
|
|
|
for i = 1, n do
|
|
local t = toks[i]
|
|
local ep, epend
|
|
local m = t:match("^[Ee](%d+)$") or t:match("^[Ee][Pp]%.?(%d+)$")
|
|
or t:match("^[Ee]pisode(%d+)$") or t:match("^#(%d+)$")
|
|
if m then ep = tonumber(m) end
|
|
if not ep then
|
|
local v = t:match("^(%d+)[vV]%d+$")
|
|
if v then ep = tonumber(v) end
|
|
end
|
|
if not ep then
|
|
local a, b = t:match("^(%d+)[%-~](%d+)$")
|
|
if a then ep, epend = tonumber(a), tonumber(b) end
|
|
end
|
|
if not ep and (t:lower() == "ep" or t:lower() == "episode"
|
|
or t == "#") and toks[i + 1]
|
|
and toks[i + 1]:match("^%d+$") then
|
|
ep = tonumber(toks[i + 1])
|
|
end
|
|
if not ep and t:match("^%d+$") and i > 1 and toks[i - 1] == "-" then
|
|
ep = tonumber(t)
|
|
end
|
|
if ep ~= nil then
|
|
episode, episode_end, e_idx = ep, epend, i
|
|
end
|
|
end
|
|
|
|
if episode == nil then
|
|
for i = n, 1, -1 do
|
|
local t = toks[i]
|
|
if t == "-" then
|
|
-- skip trailing dashes
|
|
elseif t:match("^%d+$") then
|
|
local num = tonumber(t)
|
|
local year = (#t == 4 and num >= 1900 and num <= 2099)
|
|
if i > 1 and not year and num > 0 then
|
|
episode, e_idx = num, i
|
|
end
|
|
break
|
|
else
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
local cut
|
|
if e_idx then cut = e_idx end
|
|
if s_idx and (not cut or s_idx < cut) then cut = s_idx end
|
|
if episode ~= nil and season == nil then season = 1 end
|
|
return season, episode, episode_end, cut
|
|
end
|
|
|
|
local function build_title(toks, cut)
|
|
local last = (cut and cut - 1) or #toks
|
|
local parts = {}
|
|
for i = 1, last do
|
|
local t = toks[i]
|
|
if not is_junk(t) then parts[#parts + 1] = t end
|
|
end
|
|
while #parts > 0 and parts[#parts]:match("^[%-~:_]+$") do
|
|
parts[#parts] = nil
|
|
end
|
|
local year
|
|
if #parts > 1 then
|
|
local y = parts[#parts]:match("^([12]%d%d%d)$")
|
|
if y and tonumber(y) >= 1900 and tonumber(y) <= 2099 then
|
|
year = tonumber(y)
|
|
parts[#parts] = nil
|
|
while #parts > 0 and parts[#parts]:match("^[%-~:_]+$") do
|
|
parts[#parts] = nil
|
|
end
|
|
end
|
|
end
|
|
local title = table.concat(parts, " ")
|
|
title = title:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
|
|
return title, year
|
|
end
|
|
|
|
function M.is_video(ext)
|
|
return ext ~= nil and VIDEO_EXT[ext:lower()] == true
|
|
end
|
|
|
|
function M.parse(filename)
|
|
local base, ext = splitext(filename)
|
|
local release = base:match("^%s*%[(.-)%]") or base:match("^%s*%((.-)%)")
|
|
local resolution = base:match("(%d+[pi])%f[%A]") or base:match("(%d+[xX]%d+)")
|
|
local work = base:gsub("%b[]", " "):gsub("%b()", " "):gsub("%b{}", " ")
|
|
work = work:gsub("[_]", " ")
|
|
if not work:find(" ") and work:find("%.") then
|
|
work = work:gsub("%.", " ")
|
|
end
|
|
work = work:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
|
|
|
|
local toks = tokenize(work)
|
|
local season, episode, episode_end, cut = extract(toks)
|
|
local title, year = build_title(toks, cut)
|
|
if title == "" then title = release or "Unknown" end
|
|
|
|
return {
|
|
title=title, season=season, episode=episode, episode_end=episode_end,
|
|
year=year, release_group=release, resolution=resolution,
|
|
extension=ext, file_name=filename,
|
|
}
|
|
end
|
|
|
|
return M
|
|
end)()
|
|
|
|
----------------------------------------------------------------------
|
|
-- path helpers
|
|
----------------------------------------------------------------------
|
|
|
|
local function basename(path)
|
|
return path:match("[^/]+$") or path
|
|
end
|
|
|
|
-- Make a path component safe: drop characters that are illegal or awkward on
|
|
-- common filesystems, collapse whitespace, and trim.
|
|
local function sanitize(s)
|
|
s = s:gsub('[<>:"/\\|%?%*%c]', "")
|
|
s = s:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
|
|
if s == "" then s = "Unknown" end
|
|
return 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))
|
|
|
|
if parsed.episode == nil then
|
|
-- movie / special: <root>/<title>/<file>
|
|
local fname
|
|
if keep_original then
|
|
fname = original
|
|
else
|
|
fname = title .. (parsed.year and (" (" .. parsed.year .. ")") or "") .. ext
|
|
end
|
|
return root .. "/" .. title .. "/" .. fname
|
|
end
|
|
|
|
local season = parsed.season or 1
|
|
local sdir = string.format("Season %02d", season)
|
|
local fname
|
|
if keep_original then
|
|
fname = original
|
|
else
|
|
fname = string.format("%s - S%02dE%02d", title, season, parsed.episode)
|
|
if parsed.episode_end then
|
|
fname = fname .. string.format("-E%02d", parsed.episode_end)
|
|
end
|
|
fname = fname .. ext
|
|
end
|
|
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,
|
|
has_label = has_label }
|
|
|
|
----------------------------------------------------------------------
|
|
-- event hook
|
|
----------------------------------------------------------------------
|
|
|
|
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 setting("only_video") and not anitomy.is_video(parsed.extension) then
|
|
return -- leave subtitles, .nfo, samples, etc. where they are
|
|
end
|
|
|
|
local dest = destination(parsed)
|
|
local ok, err = pcall(naut.move_file, event.torrent_id, event.index, dest)
|
|
if ok then
|
|
print(string.format("[anime_sort] %s -> %s", name, dest))
|
|
else
|
|
print(string.format("[anime_sort] could not queue move for %s: %s",
|
|
name, tostring(err)))
|
|
end
|
|
end
|
|
|
|
function on_torrent_finished(event)
|
|
print(string.format("[anime_sort] torrent %d finished", event.torrent_id))
|
|
end
|