# 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 }