examples: anime library sorter + pure-Lua Anitomy parser
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>
This commit is contained in:
parent
2178d6a70c
commit
50a357968a
8 changed files with 837 additions and 1 deletions
265
examples/anitomy.lua
Normal file
265
examples/anitomy.lua
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
-- anitomy.lua — a compact, dependency-free anime filename parser in pure Lua.
|
||||
--
|
||||
-- A focused reimplementation of the ideas in Anitomy (akatquas/Anitomy): it
|
||||
-- pulls the anime title, season, and episode (plus release group, resolution,
|
||||
-- and extension) out of typical scene/fansub filenames. It is intentionally
|
||||
-- self-contained — no `require`, no io/os — so it can be embedded verbatim in a
|
||||
-- sandboxed Naut-Torrent script (see anime_sort.lua).
|
||||
--
|
||||
-- Usage:
|
||||
-- local anitomy = require("anitomy") -- outside the sandbox
|
||||
-- local p = anitomy.parse("[Group] Some Show - 12 [1080p].mkv")
|
||||
-- --> p.title="Some Show", p.season=1, p.episode=12, p.extension="mkv"
|
||||
--
|
||||
-- It is a heuristic parser; the limitations are documented at the bottom.
|
||||
|
||||
local M = {}
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- keyword / token tables
|
||||
----------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
-- Technical/release tags that are never part of a title. Lowercased; matched
|
||||
-- token-by-token after bracket removal and delimiter normalization.
|
||||
local KEYWORD = {}
|
||||
for _, w in ipairs({
|
||||
-- source
|
||||
"bd","bdrip","bdremux","bluray","blu-ray","dvd","dvdrip","dvd5","dvd9",
|
||||
"web","webrip","web-dl","webdl","hdtv","tvrip","hdrip","remux","hr",
|
||||
-- video codec / depth
|
||||
"x264","x265","h264","h265","h.264","h.265","hevc","avc","xvid","divx",
|
||||
"vp9","10bit","10bits","10-bit","8bit","hi10p","hi10",
|
||||
-- audio
|
||||
"aac","ac3","eac3","flac","dts","dts-hd","opus","mp3","truehd","aacx2",
|
||||
"flacx2","2.0","2.1","5.1","7.1","2ch","6ch",
|
||||
-- language / subs
|
||||
"dual","dualaudio","multi","multisubs","eng","engsub","jpn","jap","ita",
|
||||
"esp","vostfr","vf","raw","sub","subs","subbed","softsub","softsubs",
|
||||
"hardsub","hardsubs","dub","dubbed",
|
||||
-- release misc
|
||||
"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 }
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- small helpers
|
||||
----------------------------------------------------------------------
|
||||
|
||||
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 -- 1080p, 480i
|
||||
or t:match("^%d+[xX]%d+$") ~= nil -- 1280x720
|
||||
or t:match("^[24][kK]$") ~= nil -- 4k, 2k
|
||||
end
|
||||
|
||||
local function is_crc(t)
|
||||
-- 8 hex digits with at least one a-f letter (a CRC32, not a date)
|
||||
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
|
||||
|
||||
-- tokenize on spaces, keeping "-" as its own token (a common episode delimiter)
|
||||
local function tokenize(s)
|
||||
local out = {}
|
||||
for tok in s:gmatch("%S+") do
|
||||
out[#out + 1] = tok
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- season + episode extraction over the cleaned token list
|
||||
----------------------------------------------------------------------
|
||||
|
||||
-- Returns season, episode, episode_end, cut_index (first token that belongs to
|
||||
-- the season/episode region, so title = tokens[1 .. cut_index-1]).
|
||||
local function extract(toks)
|
||||
local n = #toks
|
||||
local season, episode, episode_end, e_idx, s_idx
|
||||
|
||||
-- 1) combined SxxExx in a single token
|
||||
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
|
||||
|
||||
-- 2) season: "Season 2", "2nd Season", or a standalone "S2"
|
||||
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
|
||||
|
||||
-- 3) episode: rightmost strong marker wins
|
||||
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+$") -- 01v2
|
||||
if v then ep = tonumber(v) end
|
||||
end
|
||||
if not ep then
|
||||
local a, b = t:match("^(%d+)[%-~](%d+)$") -- 01-12 range token
|
||||
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) -- dash-delimited "- 12"
|
||||
end
|
||||
if ep ~= nil then
|
||||
episode, episode_end, e_idx = ep, epend, i
|
||||
end
|
||||
end
|
||||
|
||||
-- 4) fallback: a bare trailing number (e.g. "Show 12"), ignoring a 4-digit
|
||||
-- year and refusing 0 so titles like "Steins;Gate 0" stay intact.
|
||||
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
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- title assembly
|
||||
----------------------------------------------------------------------
|
||||
|
||||
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
|
||||
-- drop trailing connective punctuation ("-", "~")
|
||||
while #parts > 0 and parts[#parts]:match("^[%-~:_]+$") do
|
||||
parts[#parts] = nil
|
||||
end
|
||||
-- drop a trailing release year (a movie tag), but keep numbers that are part
|
||||
-- of the title (e.g. "Mob Psycho 100", "Steins;Gate 0", "86").
|
||||
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
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- public API
|
||||
----------------------------------------------------------------------
|
||||
|
||||
function M.is_video(ext)
|
||||
return ext ~= nil and VIDEO_EXT[ext:lower()] == true
|
||||
end
|
||||
|
||||
function M.parse(filename)
|
||||
local base, ext = splitext(filename)
|
||||
|
||||
-- release group: a bracketed run at the very start
|
||||
local release = base:match("^%s*%[(.-)%]") or base:match("^%s*%((.-)%)")
|
||||
|
||||
-- resolution: scan bracket/whole-string for an explicit token
|
||||
local resolution = base:match("(%d+[pi])%f[%A]")
|
||||
or base:match("(%d+[xX]%d+)")
|
||||
|
||||
-- strip balanced bracket groups; what remains is title + season/episode
|
||||
local work = base:gsub("%b[]", " "):gsub("%b()", " "):gsub("%b{}", " ")
|
||||
work = work:gsub("[_]", " ")
|
||||
if not work:find(" ") and work:find("%.") then
|
||||
work = work:gsub("%.", " ") -- dotted delimiter style
|
||||
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, -- nil only when no episode at all
|
||||
episode = episode, -- nil for movies / unparsed
|
||||
episode_end = episode_end, -- set for ranges like 01-12
|
||||
year = year, -- release year, when present
|
||||
release_group = release,
|
||||
resolution = resolution,
|
||||
extension = ext,
|
||||
file_name = filename,
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
-- Known limitations (heuristic parser, documented honestly):
|
||||
-- * A title that ends in a number with no episode marker (e.g.
|
||||
-- "Mob Psycho 100.mkv") treats the number as the episode, matching Anitomy.
|
||||
-- * Absolute episode numbering is reported with season 1 (no season marker).
|
||||
-- * Episode *titles* after the number ("- 05 - The Battle") are discarded.
|
||||
-- * Multi-token audio tags like "2.0" survive only if spelled as one token.
|
||||
Loading…
Add table
Add a link
Reference in a new issue