Files
MediaOrganizer/src/lib/media/ai_resolve.sh
T
Shuery 0df07ad0ae 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 与旧扁平模块
2026-08-14 22:41:28 +08:00

388 lines
16 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
}