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
|
|
@ -274,6 +274,16 @@ add_test(NAME phase7_extensibility
|
|||
${CMAKE_SOURCE_DIR}/tests/fixtures/phase7.lua)
|
||||
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)
|
||||
target_link_libraries(test_crypto PRIVATE naut_crypto)
|
||||
add_test(NAME test_crypto COMMAND test_crypto)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -247,6 +247,14 @@ nautctl --socket /tmp/nautd.sock emit \
|
|||
|
||||
## 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
|
||||
-- archive-on-finish.lua
|
||||
-- Move each file to /archive as it completes, and log torrent completion.
|
||||
|
|
|
|||
86
examples/README.md
Normal file
86
examples/README.md
Normal 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
301
examples/anime_sort.lua
Normal 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
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.
|
||||
88
examples/test_anime_sort.lua
Normal file
88
examples/test_anime_sort.lua
Normal 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
75
examples/test_anitomy.lua
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue