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

+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 命中目标"
}