From 50a357968a519662f34f59840b5a65d19c89c8cd Mon Sep 17 00:00:00 2001 From: ookami125 Date: Mon, 15 Jun 2026 12:25:23 -0400 Subject: [PATCH] examples: anime library sorter + pure-Lua Anitomy parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a worked nautd scripting example that files each anime episode into a media-server-friendly library the moment its file finishes verifying: //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> --- CMakeLists.txt | 10 ++ README.md | 5 +- docs/scripting.md | 8 + examples/README.md | 86 ++++++++++ examples/anime_sort.lua | 301 +++++++++++++++++++++++++++++++++++ examples/anitomy.lua | 265 ++++++++++++++++++++++++++++++ examples/test_anime_sort.lua | 88 ++++++++++ examples/test_anitomy.lua | 75 +++++++++ 8 files changed, 837 insertions(+), 1 deletion(-) create mode 100644 examples/README.md create mode 100644 examples/anime_sort.lua create mode 100644 examples/anitomy.lua create mode 100644 examples/test_anime_sort.lua create mode 100644 examples/test_anitomy.lua diff --git a/CMakeLists.txt b/CMakeLists.txt index 7d4f7a6..49b0b38 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/README.md b/README.md index 8422ade..16a70df 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/scripting.md b/docs/scripting.md index e40cc49..b472583 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -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. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..cf22d24 --- /dev/null +++ b/examples/README.md @@ -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`. diff --git a/examples/anime_sort.lua b/examples/anime_sort.lua new file mode 100644 index 0000000..89fb553 --- /dev/null +++ b/examples/anime_sort.lua @@ -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 diff --git a/examples/anitomy.lua b/examples/anitomy.lua new file mode 100644 index 0000000..f4a8aee --- /dev/null +++ b/examples/anitomy.lua @@ -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. diff --git a/examples/test_anime_sort.lua b/examples/test_anime_sort.lua new file mode 100644 index 0000000..a7b24ca --- /dev/null +++ b/examples/test_anime_sort.lua @@ -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 diff --git a/examples/test_anitomy.lua b/examples/test_anitomy.lua new file mode 100644 index 0000000..3325d5e --- /dev/null +++ b/examples/test_anitomy.lua @@ -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