-- anime_sort.lua — a Naut-Torrent script that files each anime episode into a -- tidy library as it finishes downloading: -- -- //Season NN/ - SNNENN. -- -- 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. -- -- Install: -- nautd --socket /tmp/nautd.sock --script examples/anime_sort.lua -- nautctl --socket /tmp/nautd.sock add_torrent \ -- '{"torrent_id":1,"torrent":"show.torrent","root":"/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 — edit these ---------------------------------------------------------------------- local SORTED_ROOT = "/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" ---------------------------------------------------------------------- -- 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 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: //<file> local fname if KEEP_ORIGINAL_NAME then fname = original else fname = title .. (parsed.year and (" (" .. parsed.year .. ")") or "") .. ext end return SORTED_ROOT .. "/" .. title .. "/" .. fname end local season = parsed.season or 1 local sdir = string.format("Season %02d", season) local fname if KEEP_ORIGINAL_NAME 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 SORTED_ROOT .. "/" .. title .. "/" .. sdir .. "/" .. fname end -- exposed for tests; harmless in the daemon _G.anime_sort = { anitomy = anitomy, destination = destination } ---------------------------------------------------------------------- -- event hook ---------------------------------------------------------------------- function on_file_complete(event) if not event.path then return end local name = basename(event.path) local parsed = anitomy.parse(name) if 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