refactor: 迁移至 bashly CLI 与功能域模块结构

- CLI 定义迁移至 src/bashly.yml(bashly 1.4 生成参数解析),入口收敛为 src/root_command.sh
- 源码按功能域重组:config/storage/integrate/media/pipeline,消除扁平散落
- 新增零依赖测试框架 tests/(123 用例)与 CI 流水线(lint + test + build + 产物一致性)
- build.sh 支持版本单一来源注入与 --check 产物校验;新增 lint.sh(bash -n + shellcheck 零容忍)
- 删除旧单文件 media_organizer.sh 与旧扁平模块
This commit is contained in:
Shuery committed 2026-08-14 22:41:28 +08:00
1 parent d978a13a83
commit 0df07ad0ae
48 files changed
+5813 -6812

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
# ============================================================================
# 特典命名模块测试(src/lib/ai_resolve.sh)
# ============================================================================
setup() {
FOLDER_MOVIES="/media/Movies"
FOLDER_SHOWS="/media/Shows"
FOLDER_UNKNOWN="/media/Unknown"
}
# special_category_tag:特典类别识别
test_special_category_tag_mapping() {
assert_eq "Menu" "$(special_category_tag "Menu01")"
assert_eq "NCED" "$(special_category_tag "NCED02")"
assert_eq "NCOP" "$(special_category_tag "NCOP")"
assert_eq "CM" "$(special_category_tag "CM01")"
assert_eq "CM" "$(special_category_tag "TVSP")"
assert_eq "PV" "$(special_category_tag "Announcement")"
assert_eq "ED" "$(special_category_tag "Lyric")"
assert_eq "Picture Drama" "$(special_category_tag "Drama")"
assert_eq "Music Video" "$(special_category_tag "MV")"
assert_eq "Special" "$(special_category_tag "OVA")"
assert_eq "Special" "$(special_category_tag "未知词")"
}
# special_s00_dest:S00 目标路径(show_dir 含年份段,由 NAMING_SHOW 渲染)
test_special_s00_dest_path() {
# 参数:show_dir + fragment + ext
assert_eq "/media/Shows/Show (2020)/Season 00|S00Menu - Menu01.mkv" \
"$(special_s00_dest "Show (2020)" "Menu01" "mkv")"
assert_eq "/media/Shows/Show/Season 00|S00Special - SP.mkv" \
"$(special_s00_dest "Show" "SP" "mkv")"
}
# fallback_naming:电影回退命名
test_fallback_naming_movie() {
assert_eq "/media/Movies/Movie (2020)|Movie (2020).mkv" \
"$(fallback_naming "movie" "Movie" "2020" "" "" "" "" "mkv")"
}
# fallback_naming:剧集回退命名
test_fallback_naming_tv() {
assert_eq "/media/Shows/Show/Season 01|S01E02.mkv" \
"$(fallback_naming "tv" "Show" "" "1" "2" "" "" "mkv")"
assert_eq "/media/Shows/Show (2021)/Season 02|S02E03-E04.mkv" \
"$(fallback_naming "tv" "Show" "2021" "2" "3" "4" "" "mkv")"
}
# fallback_naming:特典回退命名(走 special_s00_dest)
test_fallback_naming_special() {
assert_eq "/media/Shows/Show/Season 00|S00CM - CM01.mkv" \
"$(fallback_naming "tv" "Show" "" "0" "1" "" "CM01" "mkv")"
}
+103
View File
@@ -0,0 +1,103 @@
# ============================================================================
# 媒体文件名解析模块测试(src/lib/filename.sh)
# ============================================================================
setup() {
TEST_DIR="$(mktemp -d)"
SOURCE_DIR="$TEST_DIR"
SKIP_DIRS_LOWER=(sp sps special specials cd cds bonus bonuses extra extras previews "ps3 special bd" scans fonts 特典 特番 花絮 视频 剧集 动画 合集 电影 音乐 movies series shows anime collections)
SPECIAL_WORDS=("menu" "ncop" "nced" "mini anime" "pv" "cm" "sp" "teaser" "program" "promo" "trailer" "special" "music video" "mv" "opening" "ending" "特典" "特番" "花絮")
VIDEO_EXTS=(mp4 mkv avi mov wmv flv m4v ts m2ts webm)
MAIN_COUNT_CACHE=()
}
teardown() {
rm -rf "$TEST_DIR"
}
# parse_media_filename:标准 S##E## 格式
test_parse_tv_sxxexx() {
assert_eq "tv|Show Name||01|02||" "$(parse_media_filename "$TEST_DIR/Show Name S01E02.mkv")"
}
# parse_media_filename:S##E## + 年份
test_parse_tv_with_year() {
assert_eq "tv|Show|2020|01|01||" "$(parse_media_filename "$TEST_DIR/Show (2020) S01E01.mkv")"
}
# parse_media_filename:多集区间 S01E01-E02
test_parse_tv_episode_range() {
assert_eq "tv|Show||01|01|02|" "$(parse_media_filename "$TEST_DIR/Show S01E01-E02.mkv")"
}
# parse_media_filename:#x## 格式
test_parse_tv_hash_format() {
assert_eq "tv|Show||1|05||" "$(parse_media_filename "$TEST_DIR/Show 1x05.mkv")"
}
# parse_media_filename:Season 2 格式(无集号 → 0)
test_parse_tv_season_format() {
assert_eq "tv|Show||2|0|" "$(parse_media_filename "$TEST_DIR/Show Season 2.mkv")"
}
# parse_media_filename:2nd Season + [NN]
test_parse_tv_ordinal_season() {
assert_eq "tv|Show||2|01|" "$(parse_media_filename "$TEST_DIR/Show 2nd Season [01].mkv")"
}
# parse_media_filename:方括号集号 [NN]
test_parse_tv_bracket_episode() {
assert_eq "tv|Show||1|07||" "$(parse_media_filename "$TEST_DIR/Show [07].mkv")"
}
# parse_media_filename:小数集号 → 特典路径(season=0)
test_parse_tv_decimal_episode_as_special() {
assert_eq "tv|Show||0|||14.5" "$(parse_media_filename "$TEST_DIR/Show [14.5].mkv")"
}
# parse_media_filename:第 0 话 → 特典路径
test_parse_tv_zero_episode_as_special() {
assert_eq "tv|Show||0|||0" "$(parse_media_filename "$TEST_DIR/Show [0].mkv")"
}
# parse_media_filename:电影(年份)
test_parse_movie_with_year() {
assert_eq "movie|Movie|2020||||" "$(parse_media_filename "$TEST_DIR/Movie (2020).mkv")"
}
# parse_media_filename:压制组风格电影
test_parse_movie_group_style() {
assert_eq "movie|Movie|2021||||" "$(parse_media_filename "$TEST_DIR/[Group] Movie (2021) [1080p].mkv")"
}
# detect_special:父目录为特典目录(SPs)
test_detect_special_parent_dir() {
mkdir -p "$TEST_DIR/Show/SPs"
touch "$TEST_DIR/Show/SPs/CM01.mkv"
assert_eq "tv|Show||0|01||CM01" "$(parse_media_filename "$TEST_DIR/Show/SPs/CM01.mkv")"
}
# detect_special:文件名含特典标记 [CM01]
test_detect_special_bracket_mark() {
# Show 目录需 ≥2 个正片(count==1 会被判为电影特典跳过)
mkdir -p "$TEST_DIR/Show"
touch "$TEST_DIR/Show/[CM01].mkv" "$TEST_DIR/Show/EP01.mkv"
assert_eq "tv|Show||0|01||CM01" "$(parse_media_filename "$TEST_DIR/Show/[CM01].mkv")"
}
# find_show_dir_from_path:跳过特典目录找到剧名目录
test_find_show_dir_skips_special_dirs() {
assert_eq "Show" "$(find_show_dir_from_path "$TEST_DIR/Show/SPs/CM01.mkv")"
}
# find_show_dir_from_path:跳过 [数字 CD 子目录
test_find_show_dir_skips_cd_dir() {
assert_eq "Show" "$(find_show_dir_from_path "$TEST_DIR/Show/[200226] Song/01.flac")"
}
# count_main_videos:统计正片、跳过特典子目录
test_count_main_videos_counts_and_skips() {
mkdir -p "$TEST_DIR/Show" "$TEST_DIR/Show/SPs"
touch "$TEST_DIR/Show/EP01.mkv" "$TEST_DIR/Show/EP02.mkv" "$TEST_DIR/Show/SPs/CM01.mkv"
assert_eq "2" "$(count_main_videos "$TEST_DIR/Show")"
}
+207
View File
@@ -0,0 +1,207 @@
# ============================================================================
# 识别搜索链测试(src/lib/media/identify.sh)
#
# 覆盖 R1 搜索链抽象后的行为保持:统一尝试器(tv_search_once/movie_search_once)
# 的命中/无结果/请求失败返回码、两行协议回传、识别函数的层级短路语义
# (主搜索失败即 return 2,后续层失败忽略)、目录/MAL/别名/后缀剥离兜底。
#
# mock 策略:按 "endpoint|语言" 查 MOCK_RESPONSES 表;无表项 = 空哨兵(无结果);
# MOCK_FAIL_ENDPOINTS 匹配 → 请求失败(rc=2)。tmdb_api_lang 经动态作用域
# 透传 TMDB_LANG,mock 用它区分 zh/en 响应。
# ============================================================================
# mock 响应表(文件级全局:declare 在函数内是函数局部,mock 函数访问不到)
declare -A MOCK_RESPONSES=()
declare -A MOCK_FAIL_ENDPOINTS=()
setup() {
TEST_DIR="$(mktemp -d)"
DRY_RUN=false
TMDB_DELAY=0
TMDB_LANG="zh-CN"
SOURCE_DIR="$TEST_DIR/src"
FOLDER_SHOWS="节目"
FOLDER_MOVIES="电影"
mkdir -p "$SOURCE_DIR"
MOCK_RESPONSES=()
MOCK_FAIL_ENDPOINTS=()
}
teardown() {
rm -rf "$TEST_DIR"
}
# mock:按 "endpoint|lang" 查表;无表项 → 无结果(rc=1);失败表命中 → rc=2
tmdb_api() {
local endpoint="$1"
shift
local key="${endpoint}|${TMDB_LANG:-}"
if [[ -n "${MOCK_FAIL_ENDPOINTS[$endpoint]:-}" ]]; then
return 2
fi
local resp="${MOCK_RESPONSES[$key]:-}"
[[ -z "$resp" ]] && return 1
echo "$resp"
}
# mock:用户规则别名查找(真实函数读规则文件,测试桩直接返回)
match_rule_lookup_alias() {
echo ""
}
# --- 统一尝试器 ---
# tv_search_once:zh 命中 → 输出 id,返回 0
test_tv_search_once_hit() {
MOCK_RESPONSES["/search/tv|zh-CN"]='{"results":[{"id":100,"name":"Show A"}]}'
local id="" rc=0
id=$(tv_search_once "Show A" "1" "" "") || rc=$?
assert_eq "0" "$rc" "tv_search_once 命中返回码"
assert_eq "100" "$id" "tv_search_once 命中 id"
}
# tv_search_once:无结果 → 返回 1,无输出
test_tv_search_once_miss() {
local id="" rc=0
id=$(tv_search_once "No Such Show" "1" "" "") || rc=$?
assert_eq "1" "$rc" "tv_search_once 无结果返回码"
assert_empty "$id" "tv_search_once 无结果输出"
}
# tv_search_once:请求失败 → 返回 2(不重试语义由调用方处理)
test_tv_search_once_request_failed() {
MOCK_FAIL_ENDPOINTS["/search/tv"]=1
local rc=0
tv_search_once "Show A" "1" "" "" >/dev/null || rc=$?
assert_eq "2" "$rc" "tv_search_once 请求失败返回码"
}
# tv_search_once:zh 空 → en-US 命中(语言分支走 tmdb_api_lang)
test_tv_search_once_en_fallback() {
MOCK_RESPONSES["/search/tv|en-US"]='{"results":[{"id":200,"name":"Show A"}]}'
local id="" rc=0
id=$(tv_search_once "Show A" "1" "" "en-US") || rc=$?
assert_eq "0" "$rc" "en 命中返回码"
assert_eq "200" "$id" "en 命中 id"
}
# movie_search_once:两行协议(id + 单行响应 JSON)
test_movie_search_once_two_line_protocol() {
MOCK_RESPONSES["/search/movie|zh-CN"]='{"results":[{"id":50,"title":"Movie X","release_date":"2020-01-01"}]}'
local out="" rc=0
out=$(movie_search_once "Movie X" "2020" "") || rc=$?
assert_eq "0" "$rc" "movie_search_once 命中返回码"
local id resp
id=$(printf '%s\n' "$out" | sed -n '1p')
resp=$(printf '%s\n' "$out" | sed -n '2p')
assert_eq "50" "$id" "两行协议 id 行"
assert_contains "$resp" '"title":"Movie X"' "两行协议响应行"
}
# --- 目录兜底 ---
# tv_search_by_dir:源根直属散放不兜底(无输出)
test_tv_search_by_dir_source_root_skipped() {
local id
id=$(tv_search_by_dir "$SOURCE_DIR/Show.mkv" "Show" "1" "")
assert_empty "$id" "源根散放不兜底"
}
# tv_search_by_dir:目录名命中(zh → en)
test_tv_search_by_dir_hit() {
mkdir -p "$TEST_DIR/src/[Group] Show X"
local f="$TEST_DIR/src/[Group] Show X/01.mkv"
MOCK_RESPONSES["/search/tv|zh-CN"]='{"results":[{"id":300,"name":"Show X"}]}'
local id
id=$(tv_search_by_dir "$f" "01" "1" "")
assert_eq "300" "$id" "目录名兜底命中"
}
# --- 完整识别链 ---
# identify_tv_show:zh 命中 → build_tv_dest 输出目标
test_identify_tv_show_zh_hit() {
MOCK_RESPONSES["/search/tv|zh-CN"]='{"results":[{"id":100,"name":"Show A"}]}'
MOCK_RESPONSES["/tv/100|zh-CN"]='{"name":"Show A","first_air_date":"2020-01-01","number_of_seasons":2,"seasons":[{"season_number":1},{"season_number":2}]}'
MOCK_RESPONSES["/tv/100/season/1|zh-CN"]='{"episodes":[{"episode_number":1,"name":"Pilot"},{"episode_number":2,"name":"Two"}]}'
local dest="" rc=0
dest=$(identify_tv_show "Show A" "1" "1" "" "Show A S01E01" "mkv" "" "$TEST_DIR/src/Show A/Show A S01E01.mkv") || rc=$?
assert_eq "0" "$rc" "zh 命中识别返回码"
assert_contains "$dest" "节目/Show A (2020)/Season 01|S01E01 - Pilot.mkv" "zh 命中目标"
}
# identify_tv_show:zh 空 → en 命中
test_identify_tv_show_zh_miss_en_hit() {
MOCK_RESPONSES["/search/tv|en-US"]='{"results":[{"id":200,"name":"Show B"}]}'
MOCK_RESPONSES["/tv/200|zh-CN"]='{"name":"Show B","first_air_date":"2021-01-01","number_of_seasons":1,"seasons":[{"season_number":1}]}'
MOCK_RESPONSES["/tv/200/season/1|zh-CN"]='{"episodes":[{"episode_number":1,"name":"First"}]}'
local dest="" rc=0
dest=$(identify_tv_show "Show B" "1" "1" "" "Show B S01E01" "mkv" "" "$TEST_DIR/src/Show B/Show B S01E01.mkv") || rc=$?
assert_eq "0" "$rc" "en 命中识别返回码"
assert_contains "$dest" "Show B (2021)" "en 命中目标"
}
# identify_tv_show:主搜索(zh)请求失败 → 短路 return 2(不试后续层)
test_identify_tv_show_zh_fail_shortcircuit() {
MOCK_FAIL_ENDPOINTS["/search/tv"]=1
MOCK_RESPONSES["/search/tv|en-US"]='{"results":[{"id":200,"name":"Show B"}]}'
local rc=0
identify_tv_show "Show B" "1" "1" "" "Show B S01E01" "mkv" "" "$TEST_DIR/src/Show B/Show B S01E01.mkv" >/dev/null 2>&1 || rc=$?
assert_eq "2" "$rc" "主搜索失败短路返回码"
}
# identify_tv_show:zh/en 空 → 后缀二次剥离重搜命中("Railgun S" → "Railgun")
test_identify_tv_show_suffix_strip() {
MOCK_RESPONSES["/search/tv|zh-CN"]='{"results":[{"id":400,"name":"Railgun"}]}'
MOCK_RESPONSES["/tv/400|zh-CN"]='{"name":"Railgun","first_air_date":"2020-01-01","number_of_seasons":3,"seasons":[{"season_number":1},{"season_number":2},{"season_number":3}]}'
MOCK_RESPONSES["/tv/400/season/1|zh-CN"]='{"episodes":[{"episode_number":1,"name":"First"}]}'
# 注意:首次搜索 "Railgun S" 无结果(mock 表无 "Railgun S" 条目),二次剥离后 "Railgun" 命中
local dest="" rc=0
dest=$(identify_tv_show "Railgun S" "1" "1" "" "Railgun S S01E01" "mkv" "" "$TEST_DIR/src/Railgun S/Railgun S S01E01.mkv") || rc=$?
assert_eq "0" "$rc" "后缀剥离识别返回码"
assert_contains "$dest" "Railgun (2020)" "后缀剥离命中目标"
}
# identify_tv_show:全链失败 → return 1(无结果)
test_identify_tv_show_all_miss() {
local rc=0
identify_tv_show "No Such Show" "1" "1" "" "No Such Show S01E01" "mkv" "" "$TEST_DIR/src/No Such Show/No Such Show S01E01.mkv" >/dev/null 2>&1 || rc=$?
assert_eq "1" "$rc" "全链无结果返回码"
}
# identify_movie:zh 命中 → build_movie_dest 输出目标
test_identify_movie_zh_hit() {
MOCK_RESPONSES["/search/movie|zh-CN"]='{"results":[{"id":50,"title":"Movie X","release_date":"2020-01-01"}]}'
MOCK_RESPONSES["/movie/50|zh-CN"]='{"title":"Movie X","release_date":"2020-01-01"}'
local dest="" rc=0
dest=$(identify_movie "Movie X" "mkv" "$TEST_DIR/src/Movie X (2020).mkv" "Movie X" "2020") || rc=$?
assert_eq "0" "$rc" "movie zh 命中返回码"
assert_contains "$dest" "电影/Movie X (2020)|Movie X (2020).mkv" "movie zh 命中目标"
}
# identify_movie:年份不符且无一致候选 → 需甄别(emit MATCH,返回 3)
test_identify_movie_year_mismatch_match() {
MOCK_RESPONSES["/search/movie|zh-CN"]='{"results":[{"id":50,"title":"Movie X","release_date":"2019-05-01"}]}'
local rc=0
identify_movie "Movie X" "mkv" "$TEST_DIR/src/Movie X (2020).mkv" "Movie X" "2020" >/dev/null 2>&1 || rc=$?
assert_eq "3" "$rc" "年份不符需甄别返回码"
assert_success test -n "${PENDING_AI_MATCH["$TEST_DIR/src/Movie X (2020).mkv"]:-}"
}
# identify_movie:主搜索失败 → 短路 return 2
test_identify_movie_zh_fail_shortcircuit() {
MOCK_FAIL_ENDPOINTS["/search/movie"]=1
local rc=0
identify_movie "Movie X" "mkv" "$TEST_DIR/src/Movie X (2020).mkv" "Movie X" "2020" >/dev/null 2>&1 || rc=$?
assert_eq "2" "$rc" "movie 主搜索失败短路返回码"
}
# identify_movie:zh 空 → en 命中(两行协议回传年份校验通过)
test_identify_movie_en_fallback() {
MOCK_RESPONSES["/search/movie|en-US"]='{"results":[{"id":60,"title":"Movie Y","release_date":"2021-01-01"}]}'
MOCK_RESPONSES["/movie/60|zh-CN"]='{"title":"Movie Y","release_date":"2021-01-01"}'
local dest="" rc=0
dest=$(identify_movie "Movie Y" "mkv" "$TEST_DIR/src/Movie Y (2021).mkv" "Movie Y" "2021") || rc=$?
assert_eq "0" "$rc" "movie en 命中返回码"
assert_contains "$dest" "Movie Y (2021)" "movie en 命中目标"
}
+75
View File
@@ -0,0 +1,75 @@
# ============================================================================
# Music Videos 类目测试(src/lib/media/filename.sh / identify.sh)
# ============================================================================
setup() {
TEST_DIR="$(mktemp -d)"
SOURCE_DIR="$TEST_DIR"
FOLDER_MUSICVIDEOS="音乐视频"
FOLDER_UNKNOWN="未知"
SPECIAL_WORDS_CAT=()
mapfile -t MUSICVIDEO_WORDS < <(echo "$MUSICVIDEO_WORDS_TEMPLATE" | jq -r '.[]')
mapfile -t SPECIAL_WORDS < <(echo "$SPECIAL_WORDS_TEMPLATE" | jq -r '.[]')
MAIN_COUNT_CACHE=()
VIDEO_EXTS=(mp4 mkv)
}
teardown() {
rm -rf "$TEST_DIR"
}
# detect_musicvideo:目录信号(Music Videos / MV / Live 目录 → musicvideo)
test_musicvideo_dir_signal() {
mkdir -p "$TEST_DIR/Music Videos/周杰伦"
assert_eq "musicvideo|晴天|||||" \
"$(parse_media_filename "$TEST_DIR/Music Videos/周杰伦/晴天 [MV].mkv")"
assert_eq "musicvideo|七里香|||||" \
"$(parse_media_filename "$TEST_DIR/Music Videos/周杰伦/七里香.mp4")"
mkdir -p "$TEST_DIR/Live"
assert_eq "musicvideo|演唱会|||||" \
"$(parse_media_filename "$TEST_DIR/Live/演唱会.mkv")"
}
# detect_musicvideo:文件名信号([MV] + "歌手 - 歌名" 模式 → musicvideo)
test_musicvideo_filename_signal() {
assert_eq "musicvideo|周杰伦 - 晴天|||||" \
"$(parse_media_filename "$TEST_DIR/周杰伦 - 晴天 [MV].mkv")"
assert_eq "musicvideo|歌手 - 歌|||||" \
"$(parse_media_filename "$TEST_DIR/歌手 - 歌 [Live].mkv")"
}
# detect_musicvideo:双命中歧义([MV] 无 " - " → 保持特典路径)
test_musicvideo_dual_hit_keeps_special() {
assert_eq "tv|某动画||0|1||MV" \
"$(parse_media_filename "$TEST_DIR/某动画 [MV].mkv")"
}
# detect_musicvideo:目录名含词但不精确匹配 → 不误判(Muv-Luv / tmp.xxx)
test_musicvideo_dir_exact_match_only() {
assert_eq "tv|Muv-Luv||01|01||" \
"$(parse_media_filename "$TEST_DIR/Muv-Luv S01E01.mkv")"
assert_eq "tv|Show||1|07||" \
"$(parse_media_filename "$TEST_DIR/Show [07].mkv")"
}
# identify_musicvideo:目录名解析 artist("歌手 - 歌名" 取首段;无分隔符整名)
test_identify_musicvideo_artist_from_dir() {
mkdir -p "$TEST_DIR/Music Videos/周杰伦"
assert_eq "音乐视频/周杰伦/晴天|晴天.mkv" \
"$(identify_musicvideo "$TEST_DIR/Music Videos/周杰伦/晴天.mkv" "晴天" mkv)"
mkdir -p "$TEST_DIR/歌手 - 歌名"
assert_eq "音乐视频/歌手/x|x.mkv" \
"$(identify_musicvideo "$TEST_DIR/歌手 - 歌名/x.mkv" "x" mkv)"
}
# identify_musicvideo:无元数据/目录线索 → FOLDER_UNKNOWN 兜底
test_identify_musicvideo_unknown_artist() {
assert_eq "音乐视频/未知/歌|歌.mkv" \
"$(identify_musicvideo "$TEST_DIR/歌.mkv" "歌" mkv)"
}
# fallback_naming:musicvideo 分支(Unknown 艺术家)
test_fallback_naming_musicvideo() {
assert_eq "音乐视频/未知/歌|歌.mkv" \
"$(fallback_naming "musicvideo" "歌" "" "" "" "" "" "mkv")"
}