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:
1 parent
d978a13a83
commit
0df07ad0ae
48 files changed
+5813
-6812
No files matched your search
@@ -0,0 +1,387 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034 # 跨文件共享全局变量(POOL_EMIT_FILE/PENDING_AI_SPECIAL)
|
||||
|
||||
# 特典回退目标:S00{类型}{编号} - {片段}(避免占用 S00E 编号干扰 TMDB 识别)。
|
||||
# 识别路径与回退命名共用,保证两套特典命名规则一致。
|
||||
# 参数:show_dir(剧集目录名,已含年份段,由 NAMING_SHOW 渲染)fragment ext。
|
||||
# 季目录名与文件名分别由 NAMING_SEASON(season=0 → Season 00)与 NAMING_SPECIAL 渲染。
|
||||
special_s00_dest() {
|
||||
local show_dir="$1" fragment="$2" ext="$3"
|
||||
local clean_fragment sp_type s00_tag
|
||||
clean_fragment=$(sanitize "$fragment")
|
||||
# 特典类别词表(子串匹配;校准驱动扩充:Character PV→PV、ED Lyric→ED、TV-SPOT→CM 等)。
|
||||
# 命名约定(默认模板):S00{类别} - {片段}(类别不带编号,编号保留在片段中)
|
||||
sp_type="$(special_category_tag "$fragment")"
|
||||
s00_tag="${sp_type:-Special}"
|
||||
local s00_name s00_dir
|
||||
s00_name=$(render_naming NAMING_SPECIAL "tag=$s00_tag" "fragment=$clean_fragment")
|
||||
s00_name=$(sanitize "$s00_name")
|
||||
[[ -n "$s00_name" ]] || s00_name="S00${s00_tag} - ${clean_fragment}"
|
||||
s00_dir=$(render_naming NAMING_SEASON "season=0")
|
||||
echo "${FOLDER_SHOWS}/${show_dir}/${s00_dir}|${s00_name}.${ext}"
|
||||
}
|
||||
|
||||
# 特典类别标签:fragment 子串匹配类别词(优先级从高到低)。
|
||||
# 语义 = 特典类别(Menu/CM/PV/NCED/NCOP/Preview/Mini Anime/IV/MV/WebRadio/Program/...)。
|
||||
# v9.6:先查用户外置表(special_categories.json,数组顺序=优先级),
|
||||
# 未命中回退内置默认表,仍未命中 → "Special"。
|
||||
special_category_tag() {
|
||||
local fragment="$1" fl
|
||||
# busybox tr 字符类在 UTF-8 locale 下按字节错乱("Menu"→"Menl"),用 bash 内置 ${var,,}
|
||||
fl="${fragment,,}"
|
||||
local entry kw tag
|
||||
# 用户表(外置 special_categories.json)
|
||||
for entry in "${SPECIAL_CATEGORIES[@]}"; do
|
||||
kw="${entry%%|*}"
|
||||
tag="${entry#*|}"
|
||||
[[ -z "$kw" ]] && continue
|
||||
if [[ "$fl" == *"$kw"* ]]; then
|
||||
echo "$tag"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
# 内置默认表(文件缺失/未命中时回退)
|
||||
local kw2 tag2
|
||||
while IFS='|' read -r kw2 tag2; do
|
||||
[[ -z "$kw2" ]] && continue
|
||||
if [[ "$fl" == *"$kw2"* ]]; then
|
||||
echo "$tag2"
|
||||
return 0
|
||||
fi
|
||||
done <<'EOF'
|
||||
nced|NCED
|
||||
ncop|NCOP
|
||||
menu|Menu
|
||||
cm|CM
|
||||
pv|PV
|
||||
trailer|Trailer
|
||||
teaser|Teaser
|
||||
preview|Preview
|
||||
mini anime|Mini Anime
|
||||
picture drama|Picture Drama
|
||||
drama|Picture Drama
|
||||
opening|Opening
|
||||
ending|Ending
|
||||
event|Event
|
||||
mv|Music Video
|
||||
program|Program
|
||||
webradio|WebRadio
|
||||
spot|CM
|
||||
tvsp|CM
|
||||
lyric|ED
|
||||
announcement|PV
|
||||
ova|Special
|
||||
special|Special
|
||||
sp|Special
|
||||
iv|IV
|
||||
EOF
|
||||
echo "Special"
|
||||
}
|
||||
|
||||
# 回退命名(AI 尽力后仍失败):按文件名推断的标题组织;年份可省略;空集名不加 " - " 段。
|
||||
# 参数:type title year season episode episode_end fragment ext → 输出 子目录|文件名
|
||||
# 命名格式走 NAMING_* 模板(与识别路径一致,保证两套命名规则统一)。
|
||||
fallback_naming() {
|
||||
local type="$1" title="$2" year="$3" season="$4" episode="$5" episode_end="$6" fragment="$7" ext="$8"
|
||||
local safe_title s_fmt e_fmt
|
||||
safe_title=$(sanitize "$title")
|
||||
local show_dir mname
|
||||
show_dir=$(render_naming NAMING_SHOW "title=$safe_title" "year=$year")
|
||||
show_dir=$(sanitize "$show_dir")
|
||||
[[ -n "$show_dir" ]] || show_dir="$safe_title"
|
||||
if [[ "$type" == "movie" ]]; then
|
||||
mname=$(render_naming NAMING_MOVIE "title=$safe_title" "year=$year")
|
||||
mname=$(sanitize "$mname")
|
||||
[[ -n "$mname" ]] || mname="$safe_title"
|
||||
echo "${FOLDER_MOVIES}/${mname}|${mname}.${ext}"
|
||||
elif [[ "$type" == "musicvideo" ]]; then
|
||||
# 音乐视频回退(本地规则识别失败/防御路径):艺术家不可知 → FOLDER_UNKNOWN
|
||||
local mroot
|
||||
mroot=$(render_naming NAMING_MUSICVIDEO "artist=$FOLDER_UNKNOWN" "title=$safe_title")
|
||||
[[ -n "$mroot" ]] || mroot="${FOLDER_UNKNOWN}/${safe_title}"
|
||||
echo "${FOLDER_MUSICVIDEOS}/${mroot}|${safe_title}.${ext}"
|
||||
elif [[ -n "$fragment" ]] && [[ "${season:-0}" == "0" ]]; then
|
||||
special_s00_dest "$show_dir" "$fragment" "$ext"
|
||||
else
|
||||
s_fmt=$(safe_printf_int "${season:-1}" "00")
|
||||
e_fmt=$(safe_printf_int "${episode:-0}" "00")
|
||||
local ep_range="" e_fmt_end=""
|
||||
if [[ -n "$episode_end" ]]; then
|
||||
e_fmt_end=$(safe_printf_int "$episode_end" "00")
|
||||
ep_range="-E${e_fmt_end}"
|
||||
fi
|
||||
local season_dir ep_name
|
||||
season_dir=$(render_naming NAMING_SEASON "season=$s_fmt")
|
||||
ep_name=$(render_naming NAMING_EPISODE \
|
||||
"season=$s_fmt" "episode=$e_fmt" "range=$ep_range" \
|
||||
"episode_name=" "episode_end=$e_fmt_end")
|
||||
ep_name=$(sanitize "$ep_name")
|
||||
[[ -n "$ep_name" ]] || ep_name="S${s_fmt}E${e_fmt}${ep_range}"
|
||||
echo "${FOLDER_SHOWS}/${show_dir}/${season_dir}|${ep_name}.${ext}"
|
||||
fi
|
||||
}
|
||||
|
||||
# 解析待 AI 判定艺术家的音频条目:AI 有判定用判定结果,否则所有艺术家用 " & " 拼接。
|
||||
resolve_pending_artists() {
|
||||
local force="${1:-}"
|
||||
local -a keys=()
|
||||
if [[ -n "$force" ]]; then
|
||||
keys=("${!PENDING_AI_ARTIST[@]}")
|
||||
else
|
||||
keys=("${AI_BATCH_KEYS_ARTIST[@]}")
|
||||
fi
|
||||
local audio
|
||||
for audio in "${keys[@]}"; do
|
||||
[[ -n "${PENDING_AI_ARTIST[$audio]:-}" ]] || continue
|
||||
# AI 漏了该条目 → 留待下一批(force 模式忽略该检查)
|
||||
[[ -n "$force" || -n "${AI_RESPONDED_ARTIST[$audio]:-}" ]] || continue
|
||||
local arts_album album rel_dir choice
|
||||
IFS=$'\t' read -r arts_album album rel_dir <<<"${PENDING_AI_ARTIST[$audio]}"
|
||||
choice="${AI_ARTIST_CHOICE[$audio]:-}"
|
||||
local artist="$choice"
|
||||
if [[ -z "$artist" ]]; then
|
||||
artist="$arts_album" # 已在存储时用 " & " 连接
|
||||
_log 警告 "AI 无法判定艺术家,拼接处理: $artist"
|
||||
fi
|
||||
local subdir music_root
|
||||
# 音乐目录结构走 NAMING_MUSIC 模板(默认 {artist}/{album};可自定义如
|
||||
# "{album} ({year})" 等——注意变量集合仅 artist/album)
|
||||
music_root=$(render_naming NAMING_MUSIC \
|
||||
"artist=$(sanitize "$artist")" "album=$(sanitize "$album")")
|
||||
[[ -n "$music_root" ]] || music_root="$(sanitize "$artist")/$(sanitize "$album")"
|
||||
subdir="${FOLDER_MUSIC}/$music_root"
|
||||
[[ -n "$rel_dir" ]] && subdir="$subdir/$rel_dir"
|
||||
register_identified "$audio" "$subdir" "$(basename "$audio")"
|
||||
# 歌词伴随(同名 lrc/elrc/txt)
|
||||
local base_name="${audio%.*}" c
|
||||
for c in "${base_name}.lrc" "${base_name}.elrc" "${base_name}.txt"; do
|
||||
[[ -f "$c" ]] && register_identified "$c" "$subdir" "$(basename "$c")"
|
||||
done
|
||||
_log 信息 "音频艺术家确定: $(basename "$audio") -> $subdir/"
|
||||
unset "PENDING_AI_ARTIST[$audio]"
|
||||
PENDING_ARTIST_COUNT=$((PENDING_ARTIST_COUNT - 1))
|
||||
done
|
||||
}
|
||||
|
||||
# AI 辅助重处理待定搜索项:AI 纠正词 → 原始标题兜底 → 回退命名(降级成功)。
|
||||
reprocess_pending_searches() {
|
||||
local video
|
||||
for video in "${AI_BATCH_KEYS_SEARCH[@]}"; do
|
||||
[[ -n "${PENDING_AI_SEARCH[$video]:-}" ]] || continue
|
||||
# AI 漏了该条目 → 留待下一批
|
||||
[[ -n "${AI_RESPONDED_SEARCH[$video]:-}" ]] || continue
|
||||
_log 信息 "重新处理(AI辅助): $(basename "$video")"
|
||||
push_indent
|
||||
|
||||
local info type title year season episode episode_end special_fragment base ext dest rc=0
|
||||
info=$(parse_media_filename "$video")
|
||||
IFS='|' read -r type title year season episode episode_end special_fragment <<<"$info"
|
||||
base="${video##*/}"
|
||||
base="${base%.*}"
|
||||
ext="${video##*.}"
|
||||
|
||||
# identify 在命令替换中执行(子 shell 丢 emit 副作用)——经临时 emit 文件回传合并
|
||||
local tmp_emit
|
||||
tmp_emit=$(mktemp)
|
||||
POOL_EMIT_FILE="$tmp_emit"
|
||||
|
||||
# 使用 AI 纠正后的搜索词重新搜索(复用 identify_*,无内联重复)
|
||||
local ai_data old_type ai_search ai_year ai_media
|
||||
ai_data="${PENDING_AI_SEARCH[$video]:-}"
|
||||
IFS=$'\t' read -r old_type _ ai_search ai_year ai_media <<<"$ai_data"
|
||||
local eff_type="$old_type"
|
||||
if [[ "$eff_type" == "unknown" && -n "$ai_media" ]]; then
|
||||
eff_type="$ai_media"
|
||||
fi
|
||||
if [[ -n "$ai_search" ]]; then
|
||||
if [[ "$eff_type" == "movie" ]]; then
|
||||
_log 搜索 "使用 AI 纠正词: $ai_search (年份: ${ai_year:-})"
|
||||
dest=$(identify_movie "$base" "$ext" "$video" "$ai_search" "${ai_year:-}") || rc=$?
|
||||
else
|
||||
_log 搜索 "使用 AI 纠正词搜索剧集: $ai_search"
|
||||
dest=$(identify_tv_show "$ai_search" "${season:-1}" "${episode:-0}" "${episode_end:-}" \
|
||||
"$base" "$ext" "$special_fragment" "$video") || rc=$?
|
||||
fi
|
||||
fi
|
||||
|
||||
# 兜底重试(原始标题,仅 tv——movie 走 AI 纠正词,失败即回退)
|
||||
if [[ $rc -ne 2 ]] && { [[ $rc -ne 0 ]] || [[ -z "$dest" ]]; } && [[ "$type" == "tv" ]]; then
|
||||
dest=$(identify_tv_show "$title" "${season:-1}" "${episode:-0}" "${episode_end:-}" \
|
||||
"$base" "$ext" "$special_fragment" "$video") || rc=$?
|
||||
fi
|
||||
|
||||
unset POOL_EMIT_FILE
|
||||
merge_emit_lines "$tmp_emit"
|
||||
rm -f "$tmp_emit"
|
||||
|
||||
# 记录结果或回退命名
|
||||
local destination_subdir destination_filename
|
||||
if [[ $rc -eq 0 && -n "$dest" ]]; then
|
||||
IFS='|' read -r destination_subdir destination_filename <<<"$dest"
|
||||
register_identified "$video" "$destination_subdir" "$destination_filename"
|
||||
elif [[ $rc -eq 2 ]]; then
|
||||
register_request_failed "$video"
|
||||
elif [[ $rc -eq 3 ]]; then
|
||||
# identify 已重新登记 MATCH(下一批 AI 匹配)
|
||||
_log 信息 "需甄别,交 AI 匹配: $(basename "$video")"
|
||||
else
|
||||
# AI 尽力后仍失败 → 回退命名(降级成功)
|
||||
_log 错误 "经 AI 处理后仍无法识别,使用回退命名"
|
||||
local fallback_dest
|
||||
fallback_dest=$(fallback_naming "$type" "$title" "$year" "${season:-1}" "${episode:-0}" "${episode_end:-}" "$special_fragment" "$ext")
|
||||
IFS='|' read -r destination_subdir destination_filename <<<"$fallback_dest"
|
||||
register_fallback "$video" "$destination_subdir" "$destination_filename"
|
||||
_log 信息 "回退命名: ${destination_subdir}/${destination_filename}"
|
||||
fi
|
||||
|
||||
pop_indent
|
||||
unset "PENDING_AI_SEARCH[$video]"
|
||||
PENDING_SEARCH_COUNT=$((PENDING_SEARCH_COUNT - 1))
|
||||
done
|
||||
}
|
||||
|
||||
# 消费 AI 匹配结果:choice → 脚本构建命名(build_*_dest,命名是脚本职责);
|
||||
# season_shift 叠加到文件季号;search_term → 纠正词重搜取首条(单轮优先);仍失败 → 回退命名。
|
||||
resolve_pending_matches() {
|
||||
local video
|
||||
for video in "${AI_BATCH_KEYS_MATCH[@]}"; do
|
||||
[[ -n "${PENDING_AI_MATCH[$video]:-}" ]] || continue
|
||||
# AI 漏了该条目 → 留待下一批
|
||||
[[ -n "${AI_RESPONDED_MATCH[$video]:-}" ]] || continue
|
||||
_log 信息 "AI 匹配: $(basename "$video")"
|
||||
push_indent
|
||||
|
||||
local m_type _m_year m_season m_episode _m_endpoint _m_key
|
||||
IFS=$'\t' read -r m_type _m_year m_season m_episode _m_endpoint _m_key <<<"${PENDING_AI_MATCH[$video]}"
|
||||
local base ext
|
||||
base="${video##*/}"
|
||||
base="${base%.*}"
|
||||
ext="${video##*.}"
|
||||
local dest="" rc=0
|
||||
local choice="${AI_MATCH_CHOICE[$video]:-}"
|
||||
local shift="${AI_MATCH_SHIFT[$video]:-}"
|
||||
local term="${AI_MATCH_TERM[$video]:-}"
|
||||
|
||||
if [[ -n "$choice" ]]; then
|
||||
# AI 选中候选 → 脚本构建命名
|
||||
local eff_season="${m_season:-1}"
|
||||
if [[ -n "$shift" ]] && [[ "$shift" =~ ^[0-9]+$ ]]; then
|
||||
eff_season=$((10#$eff_season + 10#$shift))
|
||||
_log 信息 "AI 季映射: S${m_season} → S${eff_season}"
|
||||
fi
|
||||
if [[ "$m_type" == "movie" ]]; then
|
||||
dest=$(build_movie_dest "$choice" "$ext" "" "") || rc=$?
|
||||
else
|
||||
dest=$(build_tv_dest "$choice" "$eff_season" "${m_episode:-1}" "" "" "$base" "$ext" "$video" "") || rc=$?
|
||||
fi
|
||||
fi
|
||||
# AI 无匹配 + 纠正词 → 重搜取首条(单轮优先,不给 AI 第二轮);
|
||||
# identify 在命令替换中执行——经临时 emit 文件回传 MATCH/SPECIAL 副作用
|
||||
if [[ -z "$dest" ]] && [[ -n "$term" ]]; then
|
||||
_log 搜索 "AI 纠正词重搜: $term"
|
||||
local tmp_emit
|
||||
tmp_emit=$(mktemp)
|
||||
POOL_EMIT_FILE="$tmp_emit"
|
||||
if [[ "$m_type" == "movie" ]]; then
|
||||
dest=$(identify_movie "$base" "$ext" "$video" "$term" "") || rc=$?
|
||||
else
|
||||
dest=$(identify_tv_show "$term" "${m_season:-1}" "${m_episode:-0}" "" "$base" "$ext" "" "$video") || rc=$?
|
||||
fi
|
||||
unset POOL_EMIT_FILE
|
||||
merge_emit_lines "$tmp_emit"
|
||||
rm -f "$tmp_emit"
|
||||
fi
|
||||
|
||||
# 记录结果或回退命名
|
||||
local destination_subdir destination_filename
|
||||
if [[ $rc -eq 0 && -n "$dest" ]]; then
|
||||
IFS='|' read -r destination_subdir destination_filename <<<"$dest"
|
||||
register_identified "$video" "$destination_subdir" "$destination_filename"
|
||||
elif [[ $rc -eq 2 ]]; then
|
||||
register_request_failed "$video"
|
||||
elif [[ $rc -eq 3 ]]; then
|
||||
: # identify 已重新登记 MATCH(下一批处理)
|
||||
else
|
||||
# AI 判断无解 → 回退命名(降级成功)
|
||||
local info type title year season episode episode_end special_fragment
|
||||
info=$(parse_media_filename "$video")
|
||||
IFS='|' read -r type title year season episode episode_end special_fragment <<<"$info"
|
||||
_log 错误 "AI 匹配无解,使用回退命名"
|
||||
local fallback_dest
|
||||
fallback_dest=$(fallback_naming "$type" "$title" "$year" "${season:-1}" "${episode:-0}" "${episode_end:-}" "$special_fragment" "$ext")
|
||||
IFS='|' read -r destination_subdir destination_filename <<<"$fallback_dest"
|
||||
register_fallback "$video" "$destination_subdir" "$destination_filename"
|
||||
_log 信息 "回退命名: ${destination_subdir}/${destination_filename}"
|
||||
fi
|
||||
|
||||
pop_indent
|
||||
unset "PENDING_AI_MATCH[$video]"
|
||||
PENDING_MATCH_COUNT=$((PENDING_MATCH_COUNT - 1))
|
||||
done
|
||||
}
|
||||
|
||||
# 第二阶段:AI 批处理(纠正搜索词、学习特典映射、判定艺术家、匹配甄别、重处理待定文件)。
|
||||
# 分批循环:每批 AI_BATCH_SIZE 条;AI_MAX_CALLS 为批次上限;消费端只处理响应覆盖的条目。
|
||||
run_ai_batch() {
|
||||
# 无 AI 密钥:搜索/匹配待定显式登记跳过(可逆——下次运行自动重试);多艺术家直接拼接
|
||||
if [[ -z "${AI_API_KEY:-}" ]]; then
|
||||
local f
|
||||
for f in "${!PENDING_AI_SEARCH[@]}"; do
|
||||
_log 警告 "未识别跳过(无 AI 密钥): $(basename "$f")"
|
||||
register_skip "$f" "skip_unidentified"
|
||||
done
|
||||
for f in "${!PENDING_AI_MATCH[@]}"; do
|
||||
_log 警告 "需甄别跳过(无 AI 密钥): $(basename "$f")"
|
||||
register_skip "$f" "skip_unidentified"
|
||||
done
|
||||
PENDING_AI_SEARCH=()
|
||||
PENDING_SEARCH_COUNT=0
|
||||
PENDING_AI_MATCH=()
|
||||
PENDING_MATCH_COUNT=0
|
||||
resolve_pending_artists force
|
||||
return 0
|
||||
fi
|
||||
if [[ $PENDING_SEARCH_COUNT -eq 0 ]] &&
|
||||
[[ $PENDING_SPECIAL_COUNT -eq 0 ]] &&
|
||||
[[ $PENDING_ARTIST_COUNT -eq 0 ]] &&
|
||||
[[ $PENDING_MATCH_COUNT -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
_log 信息 "开始 AI 批量处理..."
|
||||
local ai_failed=false
|
||||
while ((PENDING_SEARCH_COUNT > 0 || PENDING_SPECIAL_COUNT > 0 || \
|
||||
PENDING_ARTIST_COUNT > 0 || PENDING_MATCH_COUNT > 0)); do
|
||||
if [[ "$AI_CALL_COUNT" -ge "${AI_MAX_CALLS}" ]]; then
|
||||
_log 警告 "AI 调用次数已达上限($AI_MAX_CALLS),剩余条目显式跳过"
|
||||
ai_failed=true
|
||||
break
|
||||
fi
|
||||
if ! ai_batch_request; then
|
||||
ai_failed=true
|
||||
break
|
||||
fi
|
||||
# 特典映射学习已由 ai_batch_request 完成(写回映射文件与内存映射)
|
||||
PENDING_AI_SPECIAL=()
|
||||
PENDING_SPECIAL_COUNT=0
|
||||
resolve_pending_artists
|
||||
reprocess_pending_searches
|
||||
resolve_pending_matches
|
||||
done
|
||||
# AI 失败(网络/非 JSON/干运行/上限):剩余搜索/匹配待定显式跳过(可逆,与无 AI 语义统一)
|
||||
if $ai_failed; then
|
||||
local f
|
||||
for f in "${!PENDING_AI_SEARCH[@]}"; do
|
||||
register_skip "$f" "skip_unidentified"
|
||||
done
|
||||
for f in "${!PENDING_AI_MATCH[@]}"; do
|
||||
register_skip "$f" "skip_unidentified"
|
||||
done
|
||||
PENDING_AI_SEARCH=()
|
||||
PENDING_SEARCH_COUNT=0
|
||||
PENDING_AI_MATCH=()
|
||||
PENDING_MATCH_COUNT=0
|
||||
# 艺术家走拼接(信息不丢,优于跳过)
|
||||
resolve_pending_artists force
|
||||
fi
|
||||
}
|
||||
Reference in new issue
Block a user