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:
ookami125 2026-06-15 12:25:23 -04:00
parent 2178d6a70c
commit 50a357968a
8 changed files with 837 additions and 1 deletions

View file

@ -274,6 +274,16 @@ add_test(NAME phase7_extensibility
${CMAKE_SOURCE_DIR}/tests/fixtures/phase7.lua) ${CMAKE_SOURCE_DIR}/tests/fixtures/phase7.lua)
set_tests_properties(phase7_extensibility PROPERTIES TIMEOUT 15) set_tests_properties(phase7_extensibility PROPERTIES TIMEOUT 15)
# Example Lua scripts: parser battery + end-to-end sort path building. Only
# registered when a standalone lua interpreter is available.
find_program(LUA_BIN NAMES lua lua5.5 lua5.4 lua5.3)
if(LUA_BIN)
add_test(NAME example_anitomy
COMMAND ${LUA_BIN} ${CMAKE_SOURCE_DIR}/examples/test_anitomy.lua)
add_test(NAME example_anime_sort
COMMAND ${LUA_BIN} ${CMAKE_SOURCE_DIR}/examples/test_anime_sort.lua)
endif()
add_executable(test_crypto tests/unit/test_crypto.c) add_executable(test_crypto tests/unit/test_crypto.c)
target_link_libraries(test_crypto PRIVATE naut_crypto) target_link_libraries(test_crypto PRIVATE naut_crypto)
add_test(NAME test_crypto COMMAND test_crypto) add_test(NAME test_crypto COMMAND test_crypto)

View file

@ -114,7 +114,10 @@ end to end and asserts the file actually moves on disk.
The full script-visible surface — every event hook, the `event` object's The full script-visible surface — every event hook, the `event` object's
fields, and the `naut` API table — is documented in fields, and the `naut` API table — is documented in
[`docs/scripting.md`](docs/scripting.md). [`docs/scripting.md`](docs/scripting.md). A complete worked example,
[`examples/anime_sort.lua`](examples/anime_sort.lua), sorts anime into a
`Title/Season NN/Title - SNNENN.ext` library as each episode finishes, using an
embedded Anitomy-style filename parser ([`examples/`](examples/)).
## Roadmap (status) ## Roadmap (status)

View file

@ -247,6 +247,14 @@ nautctl --socket /tmp/nautd.sock emit \
## 9. Complete example ## 9. Complete example
A full, tested real-world script lives in
[`../examples/anime_sort.lua`](../examples/anime_sort.lua): it parses each
finished file with an embedded Anitomy-style parser and sorts anime into a
`Title/Season NN/Title - SNNENN.ext` library as episodes complete. See
[`../examples/README.md`](../examples/README.md).
A minimal version:
```lua ```lua
-- archive-on-finish.lua -- archive-on-finish.lua
-- Move each file to /archive as it completes, and log torrent completion. -- Move each file to /archive as it completes, and log torrent completion.

86
examples/README.md Normal file
View file

@ -0,0 +1,86 @@
# Examples
Drop-in Lua scripts for the `nautd` scripting layer (see
[`../docs/scripting.md`](../docs/scripting.md)).
## `anime_sort.lua` — sort anime into a library as it downloads
Files each episode into a clean, media-server-friendly tree **the moment its
file finishes verifying** — before the rest of the torrent completes — using
Naut-Torrent's `on_file_complete` hook + `naut.move_file()`:
```
<SORTED_ROOT>/<Anime Title>/Season NN/<Anime Title> - SNNENN.<ext>
```
Movies (no detectable episode) go to `<SORTED_ROOT>/<Title>/<Title> (year).<ext>`.
### Use it
```sh
# 1. edit SORTED_ROOT (and options) at the top of the script
# 2. start the daemon with the script
nautd --socket /tmp/nautd.sock --script examples/anime_sort.lua
# 3. register each torrent's storage so the move can resolve + relocate its files
nautctl --socket /tmp/nautd.sock add_torrent \
'{"torrent_id":1,"torrent":"show.torrent","root":"/downloads/1"}'
```
As each file completes you'll see, e.g.:
```
[anime_sort] [SubsPlease] Frieren - 12 [1080p].mkv -> /library/Frieren/Season 01/Frieren - S01E12.mkv
```
### Options (top of the script)
| Option | Default | Effect |
|---|---|---|
| `SORTED_ROOT` | `/sorted` | destination library root |
| `ONLY_VIDEO` | `true` | skip non-video files (subtitles, `.nfo`, samples) and leave them in place |
| `KEEP_ORIGINAL_NAME` | `false` | `true` keeps the original filename instead of `Title - SNNENN.ext` |
## `anitomy.lua` — the anime filename parser
A compact, dependency-free reimplementation of the ideas in
[Anitomy](https://github.com/erengy/anitomy): given a scene/fansub filename it
returns the anime **title**, **season**, **episode** (plus release group,
resolution, year, extension). Pure Lua, no `require`/`io`/`os`, so it can be
embedded verbatim in a sandboxed script — which is exactly what `anime_sort.lua`
does.
```lua
local anitomy = require("anitomy")
local p = anitomy.parse("[Erai-raws] Jujutsu Kaisen 2nd Season - 17 [1080p].mkv")
-- p.title = "Jujutsu Kaisen", p.season = 2, p.episode = 17, p.extension = "mkv"
```
`anime_sort.lua` carries a **verbatim copy** of this parser (the sandbox can't
`require`); `test_anime_sort.lua` asserts the two stay in agreement.
### What it recognizes
- `[Group] Title - 12 [1080p].mkv` (dash-delimited episode)
- `Title S04E28`, `Title S2 - 03`, `Title 2nd Season - 17`, `Title Season 2 - 14`
- `Title.S03E11.1080p.x264.mkv` (dotted delimiters)
- `Title_-_01v2_[720p].mkv` (underscores, version suffix)
- `Episode 25`, `Ep5`, `#07`, absolute numbering (`- 500`)
- ranges (`01-12`), release-group and CRC/resolution stripping, movie years
### Heuristic limitations (honest)
- A title that ends in a number with no episode marker (`Mob Psycho 100.mkv`)
treats the number as the episode — same as Anitomy.
- Absolute episode numbering reports season 1 (there is no season marker to read).
- Episode *titles* after the number (`- 05 - The Battle`) are dropped.
## Tests
```sh
lua examples/test_anitomy.lua # parser battery
lua examples/test_anime_sort.lua # end-to-end path building + drift guard
```
These also run under `ctest` (as `example_anitomy` / `example_anime_sort`) when a
`lua` interpreter is on `PATH`.

301
examples/anime_sort.lua Normal file
View file

@ -0,0 +1,301 @@
-- 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.
--
-- 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: <root>/<title>/<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

265
examples/anitomy.lua Normal file
View 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.

View file

@ -0,0 +1,88 @@
-- Tests anime_sort.lua end to end (stubbing the `naut` host API) and verifies
-- its embedded parser still matches examples/anitomy.lua.
-- Run: lua examples/test_anime_sort.lua
local dir = arg[0]:match("^(.*/)") or "./"
package.path = dir .. "?.lua;" .. package.path
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 }
local realprint = print
_G.print = function() end
dofile(dir .. "anime_sort.lua") -- defines on_file_complete + _G.anime_sort
_G.print = realprint
local fails = 0
local function fire(path)
captured = nil
on_file_complete({ torrent_id = 1, index = 0, path = path })
return captured
end
local function expect(path, want_dest)
local got = fire(path)
if got ~= want_dest then
fails = fails + 1
realprint("FAIL " .. path)
realprint(" got: " .. tostring(got))
realprint(" want: " .. tostring(want_dest))
else
realprint("ok " .. path)
end
end
-- episodes
expect("/downloads/1/[HorribleSubs] Boku no Hero Academia - 12 [1080p].mkv",
"/sorted/Boku no Hero Academia/Season 01/Boku no Hero Academia - S01E12.mkv")
expect("/downloads/2/[Group] Attack on Titan S04E28 [BD 1080p HEVC].mkv",
"/sorted/Attack on Titan/Season 04/Attack on Titan - S04E28.mkv")
expect("/dl/[Commie] Steins;Gate 0 - 12 [BD 1080p].mkv",
"/sorted/Steins;Gate 0/Season 01/Steins;Gate 0 - S01E12.mkv")
expect("Demon.Slayer.Kimetsu.no.Yaiba.S03E11.1080p.mkv",
"/sorted/Demon Slayer Kimetsu no Yaiba/Season 03/Demon Slayer Kimetsu no Yaiba - S03E11.mkv")
expect("/x/[Erai-raws] Jujutsu Kaisen 2nd Season - 17 [1080p].mkv",
"/sorted/Jujutsu Kaisen/Season 02/Jujutsu Kaisen - S02E17.mkv")
-- movies (no episode)
expect("/m/[Group] A Silent Voice [BD 1080p FLAC].mkv",
"/sorted/A Silent Voice/A Silent Voice.mkv")
expect("/m/Spirited.Away.2001.1080p.BluRay.x264.mkv",
"/sorted/Spirited Away/Spirited Away (2001).mkv")
-- non-video file must be skipped (no move queued)
do
local got = fire("/d/[Group] Some Show - 01 [1080p].ass")
if got ~= nil then
fails = fails + 1
realprint("FAIL non-video should be skipped, got: " .. tostring(got))
else
realprint("ok non-video skipped")
end
end
-- drift guard: embedded parser must agree with anitomy.lua
local embedded = _G.anime_sort.anitomy
local drift = 0
for _, name in ipairs({
"[HorribleSubs] Boku no Hero Academia - 12 [1080p].mkv",
"[Group] Attack on Titan S04E28 [BD 1080p HEVC].mkv",
"One.Piece.S01E1071.1080p.WEB.x264.mkv",
"Spirited.Away.2001.1080p.BluRay.x264.mkv",
"[Commie] Steins;Gate 0 - 12 [BD 1080p].mkv",
}) do
local a, b = ref.parse(name), embedded.parse(name)
for _, k in ipairs({ "title", "season", "episode", "year", "episode_end" }) do
if a[k] ~= b[k] then
drift = drift + 1
realprint(("DRIFT %s field %s: anitomy=%s embedded=%s")
:format(name, k, tostring(a[k]), tostring(b[k])))
end
end
end
if drift == 0 then realprint("ok embedded parser matches anitomy.lua")
else fails = fails + drift end
if fails == 0 then realprint("\nALL PASS"); os.exit(0)
else realprint("\n" .. fails .. " FAILED"); os.exit(1) end

75
examples/test_anitomy.lua Normal file
View file

@ -0,0 +1,75 @@
-- Battery test for anitomy.lua. Run: lua examples/test_anitomy.lua
package.path = (arg[0]:match("^(.*/)") or "./") .. "?.lua;" .. package.path
local anitomy = require("anitomy")
local fails = 0
local function check(name, want)
local p = anitomy.parse(name)
local ok = true
local why = {}
for _, k in ipairs({ "title", "season", "episode" }) do
if want[k] ~= nil and p[k] ~= want[k] then
ok = false
why[#why + 1] = string.format("%s: got %s want %s",
k, tostring(p[k]), tostring(want[k]))
end
end
if not ok then
fails = fails + 1
print("FAIL " .. name)
for _, w in ipairs(why) do print(" " .. w) end
else
print(string.format("ok %-58s -> %s / S%s / E%s",
name, p.title, tostring(p.season), tostring(p.episode)))
end
end
-- title, season, episode expectations
check("[HorribleSubs] Boku no Hero Academia - 12 [1080p].mkv",
{ title = "Boku no Hero Academia", season = 1, episode = 12 })
check("[SubsPlease] Spy x Family - 05 (1080p) [A1B2C3D4].mkv",
{ title = "Spy x Family", season = 1, episode = 5 })
check("[Group] Attack on Titan S04E28 [BD 1080p HEVC].mkv",
{ title = "Attack on Titan", season = 4, episode = 28 })
check("Kaguya-sama wa Kokurasetai S2 - 03 [720p].mkv",
{ title = "Kaguya-sama wa Kokurasetai", season = 2, episode = 3 })
check("[Erai-raws] Jujutsu Kaisen 2nd Season - 17 [1080p][Multiple Subtitle].mkv",
{ title = "Jujutsu Kaisen", season = 2, episode = 17 })
check("One.Piece.S01E1071.1080p.WEB.x264.mkv",
{ title = "One Piece", season = 1, episode = 1071 })
check("Anime_Name_-_01v2_[BD][720p].mkv",
{ title = "Anime Name", season = 1, episode = 1 })
check("[Judas] Chainsaw Man - 08v2 [1080p][HEVC x265 10bit].mkv",
{ title = "Chainsaw Man", season = 1, episode = 8 })
check("Mob Psycho 100 II - 05 [1080p].mkv",
{ title = "Mob Psycho 100 II", season = 1, episode = 5 })
check("[Commie] Steins;Gate 0 - 12 [BD 1080p].mkv",
{ title = "Steins;Gate 0", season = 1, episode = 12 })
check("[Group] Re Zero kara Hajimeru Isekai Seikatsu - Episode 25 [1080p].mkv",
{ title = "Re Zero kara Hajimeru Isekai Seikatsu", season = 1, episode = 25 })
check("Naruto Shippuuden - 500 [720p].mkv",
{ title = "Naruto Shippuuden", season = 1, episode = 500 })
check("[Group] Bleach - Season 2 - 14 [1080p].mkv",
{ title = "Bleach", season = 2, episode = 14 })
check("Demon.Slayer.Kimetsu.no.Yaiba.S03E11.1080p.mkv",
{ title = "Demon Slayer Kimetsu no Yaiba", season = 3, episode = 11 })
check("[Doki] K-On! - 01 (1280x720 Hi10P AAC) [12345678].mkv",
{ title = "K-On!", season = 1, episode = 1 })
check("[Group] Vinland Saga S2 - 24 END [1080p].mkv",
{ title = "Vinland Saga", season = 2, episode = 24 })
check("Frieren - 28 [Multi Sub] [1080p].mkv",
{ title = "Frieren", season = 1, episode = 28 })
-- movie / no-episode cases: episode must be nil
check("[Group] A Silent Voice [BD 1080p FLAC].mkv",
{ title = "A Silent Voice", episode = nil })
check("Spirited.Away.2001.1080p.BluRay.x264.mkv",
{ title = "Spirited Away", episode = nil })
if fails == 0 then
print("\nALL PASS")
os.exit(0)
else
print("\n" .. fails .. " FAILED")
os.exit(1)
end