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 @@
# shellcheck shell=bash
# shellcheck disable=SC2034 # 跨文件共享全局 map(MEDIA_DESTINATION_MAP 等,声明于 main.sh)
# 结构上不可能出现只写一半的不一致。识别池并发运行时,子进程通过 emit 输出
# 协议行(key\tTYPE\tpayload)到 POOL_EMIT_FILE,父进程合并时调用 register_*。
# 协议行类型:
# DEST <subdir|filename> → register_identified
# FALLBACK <subdir|filename> → register_fallback
# PENDING <ai_data> → register_pending(待 AI 搜索纠正)
# ARTIST_PENDING <artists|album> → register_pending_artist(音频多艺术家待 AI 判定)
# SPECIAL_PENDING <show_id|frag|show|names> → 特典映射待学习
# SKIP <reason> → register_skip(skip_type/skip_unidentified)
# REQ_FAILED → register_request_failed
################################################################################
# 登记识别成功条目(可链接)。
register_identified() {
local key="$1" subdir="$2" filename="$3"
MEDIA_DESTINATION_MAP["$key"]="${subdir}|${filename}"
MEDIA_OUTCOME_MAP["$key"]="identified"
}
# 登记回退命名条目(AI 尽力后仍失败;降级成功,可链接)。
register_fallback() {
local key="$1" subdir="$2" filename="$3"
MEDIA_DESTINATION_MAP["$key"]="${subdir}|${filename}"
MEDIA_OUTCOME_MAP["$key"]="fallback"
}
# 登记待 AI 搜索纠正条目(不进入目的映射)。
register_pending() {
local key="$1" ai_data="$2"
PENDING_AI_SEARCH["$key"]="$ai_data"
PENDING_SEARCH_COUNT=$((PENDING_SEARCH_COUNT + 1))
MEDIA_OUTCOME_MAP["$key"]="pending_ai"
}
# 登记待 AI 判定艺术家的音频条目(不进入目的映射)。
register_pending_artist() {
local key="$1" artists_album="$2"
PENDING_AI_ARTIST["$key"]="$artists_album"
PENDING_ARTIST_COUNT=$((PENDING_ARTIST_COUNT + 1))
MEDIA_OUTCOME_MAP["$key"]="pending_ai"
}
# 登记跳过条目(type=skip_type/skip_unidentified;不进入目的映射)。
# skip_unidentified(AI 尽力后仍失败 / 无 AI 密钥)→ 登记失败冷却(下次运行冷却期内跳过,
# 避免 cron 每轮全量重试;冷却过期后自动重试,语义仍可逆)。skip_type(电影特典)无成本,不冷却。
register_skip() {
local key="$1" reason="$2"
MEDIA_OUTCOME_MAP["$key"]="$reason"
if [[ "$reason" == "skip_unidentified" ]]; then
cooldown_set "$key" "$reason"
fi
}
# 登记请求失败条目(重试耗尽仍失败;不进入目的映射)。
# 网络/密钥类失败同样登记冷却:冷却期内跳过,过期后自动重试。
register_request_failed() {
local key="$1"
MEDIA_OUTCOME_MAP["$key"]="request_failed"
cooldown_set "$key" "request_failed"
}
# 登记待 AI 匹配甄别条目(搜索有结果但需甄别;不进入目的映射)。
# 同 key 重复登记(如纠正词重搜再次需甄别)不重复计数。
register_pending_match() {
local key="$1" payload="$2"
if [[ -z "${PENDING_AI_MATCH[$key]:-}" ]]; then
PENDING_MATCH_COUNT=$((PENDING_MATCH_COUNT + 1))
fi
PENDING_AI_MATCH["$key"]="$payload"
MEDIA_OUTCOME_MAP["$key"]="pending_ai"
}
# 目录链:文件父目录相对源根的路径,最多保留靠近文件的 3 层(剧名通常靠近文件)。
# 用于 AI 输入(directory 字段)与 PENDING_AI_SEARCH 存储。
dir_chain() {
local file="$1" rel
rel=$(dirname "$file")
rel="${rel#"$SOURCE_DIR"/}"
# 不在源目录下(或就是源根)→ 用父目录名
if [[ "$rel" == "$(dirname "$file")" ]]; then
rel=$(basename "$rel")
fi
local parts=() out="" p
IFS='/' read -ra parts <<<"$rel"
if ((${#parts[@]} > 3)); then
parts=("${parts[@]:${#parts[@]}-3}")
fi
for p in "${parts[@]}"; do
out="${out:+$out/}$p"
done
echo "$out"
}
# 合并协议行文件(register_* 登记)。返回前记录 MERGE_PROCESSED/FAILED/STREAK 供阈值检查。
# 用于:识别池合并(pool_run)与父进程 identify 调用(命令替换会丢 emit 副作用,需经临时文件回传)。
merge_emit_lines() {
local emit_file="$1"
MERGE_PROCESSED=0
MERGE_FAILED=0
MERGE_STREAK=0
local key type payload
while IFS=$'\t' read -r key type payload; do
[[ -z "$key" ]] && continue
MERGE_PROCESSED=$((MERGE_PROCESSED + 1))
case "$type" in
DEST)
local subdir filename
IFS='|' read -r subdir filename <<<"$payload"
register_identified "$key" "$subdir" "$filename"
;;
FALLBACK)
local subdir filename
IFS='|' read -r subdir filename <<<"$payload"
register_fallback "$key" "$subdir" "$filename"
;;
PENDING)
register_pending "$key" "$payload"
;;
ARTIST_PENDING)
register_pending_artist "$key" "$payload"
;;
SPECIAL_PENDING)
local show_id fragment show_title season0_names
IFS='|' read -r show_id fragment show_title season0_names <<<"$payload"
PENDING_AI_SPECIAL["${show_id}|${fragment}"]="${show_title}|${season0_names}"
PENDING_SPECIAL_COUNT=$((PENDING_SPECIAL_COUNT + 1))
;;
SKIP)
register_skip "$key" "$payload"
;;
REQ_FAILED)
register_request_failed "$key"
MERGE_FAILED=$((MERGE_FAILED + 1))
MERGE_STREAK=$((MERGE_STREAK + 1))
;;
MATCH)
register_pending_match "$key" "$payload"
;;
*)
MERGE_STREAK=0
;;
esac
done < <(cat "$emit_file")
}
# 协议输出:worker 模式写入 POOL_EMIT_FILE;父进程模式(无池)直接登记。
emit() {
local key="${1:-}" type="${2:-}" payload="${3:-}"
if [[ -n "${POOL_EMIT_FILE:-}" ]]; then
printf '%s\t%s\t%s\n' "$key" "$type" "$payload" >>"$POOL_EMIT_FILE"
return 0
fi
case "$type" in
SPECIAL_PENDING)
local show_id fragment show_title season0_names
IFS='|' read -r show_id fragment show_title season0_names <<<"$payload"
PENDING_AI_SPECIAL["${show_id}|${fragment}"]="${show_title}|${season0_names}"
PENDING_SPECIAL_COUNT=$((PENDING_SPECIAL_COUNT + 1))
;;
MATCH)
register_pending_match "$key" "$payload"
;;
esac
}
# 识别池:对文件列表并发执行 process_one_file,结果行经 POOL_EMIT_FILE 收集后由父进程合并。
# 并发控制:满池时等待最老的 job(bash 4.0 兼容,不用 wait -n)。
pool_run() {
local pool_dir
pool_dir=$(mktemp -d)
export POOL_EMIT_FILE="$pool_dir/emit"
: >"$POOL_EMIT_FILE"
local -a pids=()
local f workers="${MEDIA_WORKERS:-4}"
for f in "$@"; do
# worker 协议只走 POOL_EMIT_FILE,stdout 丢弃(防止识别函数 echo 泄漏到终端)
(process_one_file "$f" >/dev/null) &
pids+=("$!")
if ((${#pids[@]} >= workers)); then
wait "${pids[0]}" || true
pids=("${pids[@]:1}")
fi
done
for p in "${pids[@]}"; do
wait "$p" || true
done
# 合并协议行(register_* 登记 + 请求失败统计)
merge_emit_lines "$POOL_EMIT_FILE"
# 请求失败阈值(仅初始识别池启用):连续 ≥5 次或失败率 ≥50% → 终止,防止整库跑空
if [[ "${POOL_THRESHOLD_CHECK:-false}" == "true" ]]; then
local rate=0
((MERGE_PROCESSED > 0)) && rate=$((MERGE_FAILED * 100 / MERGE_PROCESSED))
if ((MERGE_STREAK >= 5 || (MERGE_PROCESSED >= 10 && rate >= 50))); then
_log 错误 "TMDB 请求失败过多(连续 ${MERGE_STREAK} 次 / 失败率 ${rate}%),终止运行——请检查网络或 TMDB 密钥"
exit 1
fi
fi
unset POOL_EMIT_FILE
rm -rf "$pool_dir"
}