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,82 @@
|
||||
# ============================================================================
|
||||
# bashly CLI 定义
|
||||
#
|
||||
# 迁移说明(bashly 1.4 能力边界):
|
||||
# - flag 不支持可选参数 → --list-cache / --export-map 为纯 flag:
|
||||
# · --list-cache 的可选关键词经位置参数传递(root_command 中映射)
|
||||
# · --export-map 使用默认/配置目录(config.json 的 EXPORT_MAP_DIR)
|
||||
# · 原 --list-cache=KEY / --export-map=DIR 的 = 形式不再支持
|
||||
# - 位置参数与 flag 的 arg 均设置 expose: true(生成 arg_xxx/flag_xxx 变量)
|
||||
# - 互斥用 conflicts(bashly 报错退出码 1;原 _usage_error 为 2)
|
||||
# ============================================================================
|
||||
name: media_organizer
|
||||
help: |-
|
||||
Jellyfin 媒体库硬链接整理脚本 v9.6
|
||||
|
||||
自动化整理媒体库:通过 TMDB API 识别电影与电视剧,并用硬链接创建
|
||||
标准化的目录结构。支持 AI 辅助识别、全量缓存、特典映射、季数偏移、
|
||||
Music Videos 音乐视频类目、配置按类目分区。
|
||||
|
||||
模式(一次调用只取其一):
|
||||
<源目录> <目的目录> 正常整理(使用缓存)
|
||||
--update-cache <源目录> <目的目录> 整理并强制重取对应缓存
|
||||
--update-cache <源目录> 仅更新缓存,不整理
|
||||
--list-cache [关键词] 列出缓存条目(可选关键词过滤)
|
||||
--export-map [目录] 生成源→目标对照表 markdown
|
||||
--rerun <账本文件> 手动纠错重跑(不重新识别)
|
||||
|
||||
配置优先级:CLI > 环境变量 > config.json > 默认值
|
||||
|
||||
示例:
|
||||
dist/media_organizer /downloads /media
|
||||
dist/media_organizer --dry-run /downloads /media
|
||||
dist/media_organizer --list-cache 刀剑
|
||||
version: 9.6
|
||||
|
||||
args:
|
||||
- name: source_dir
|
||||
help: 源目录(--list-cache 模式下作为可选关键词)
|
||||
- name: destination_dir
|
||||
help: 目的目录
|
||||
|
||||
flags:
|
||||
- long: --automated
|
||||
short: -a
|
||||
help: 自动化模式(写入日志,跳过无法处理项目)
|
||||
conflicts:
|
||||
- --list-cache
|
||||
- long: --no-automated
|
||||
help: 取消自动化模式(覆盖环境变量/config.json 设置)
|
||||
- long: --dry-run
|
||||
help: 干运行:不创建链接、不写缓存、不写日志(网络请求照常但不落盘)
|
||||
conflicts:
|
||||
- --list-cache
|
||||
- long: --no-dry-run
|
||||
help: 取消干运行(覆盖环境变量/config.json 设置)
|
||||
- long: --refresh-cache
|
||||
help: 清空 TMDB 缓存后执行(保留特典映射等脚本学习数据;可与任意模式叠加)
|
||||
- long: --update-cache
|
||||
help: 强制重取对应缓存条目(与 --list-cache 互斥)
|
||||
conflicts:
|
||||
- --list-cache
|
||||
- long: --list-cache
|
||||
help: 列出缓存条目(可选关键词经位置参数传递)
|
||||
conflicts:
|
||||
- --update-cache
|
||||
- long: --export-map
|
||||
help: 生成源→目标对照表 markdown(目录经 config.json 的 EXPORT_MAP_DIR 或默认 mo_map)
|
||||
- long: --rerun
|
||||
arg: ledger_file
|
||||
help: 手动纠错重跑:按账本中 action="rerun" 的条目重新硬链接(可叠加 --dry-run 预览)
|
||||
- long: --src-dir
|
||||
arg: src
|
||||
help: 指定源目录(与位置参数互斥)
|
||||
- long: --dest-dir
|
||||
arg: dest
|
||||
help: 指定目的目录(与位置参数互斥)
|
||||
|
||||
# 复杂校验(bashly 声明式表达不了的,在 root_command 中实现):
|
||||
# - 目录只能通过一种方式指定:2 个位置参数,或 --src-dir + --dest-dir
|
||||
# - --list-cache 不接受目的目录参数;--dry-run/--automated 不能与 --list-cache 同用
|
||||
# - --update-cache 需要源目录
|
||||
# - 整理模式需要源目录与目的目录
|
||||
-246
@@ -1,246 +0,0 @@
|
||||
|
||||
# 单次 AI 批量请求:合并处理搜索词纠正与特典映射学习(一次 API 调用)。
|
||||
#
|
||||
# AI 预期返回 JSON(由 AI_BATCH_PROMPT 引导,ai_batch_request 用 jq 解析):
|
||||
# {
|
||||
# "search": { "<完整 file 路径>": { "search_term": "...", "year": "..." }, ... },
|
||||
# "special": { "<show_id>|<fragment>": { "english_keyword": "...", "chinese_keyword": "..." }, ... }
|
||||
# }
|
||||
# 无条目的一类返回 {}。解析后:
|
||||
# - search 部分更新 PENDING_AI_SEARCH(追加 |搜索词|年份)
|
||||
# - special 部分写入特典映射文件与内存映射(AI 学习)
|
||||
# 构造 AI 批处理输入 JSON(jq 安全转义:文件名含引号/反斜杠不破坏 JSON;每类最多 AI_BATCH_SIZE 条)。
|
||||
# 记录本批 key 集合(AI_BATCH_KEYS_*):消费端只处理 AI 响应覆盖的条目,批外条目留待下一批。
|
||||
build_ai_input_json() {
|
||||
local input_json='{"search_entries":[],"special_entries":[],"artist_entries":[],"match_entries":[]}'
|
||||
local batch_limit="${AI_BATCH_SIZE:-50}"
|
||||
AI_BATCH_KEYS_SEARCH=()
|
||||
AI_BATCH_KEYS_SPECIAL=()
|
||||
AI_BATCH_KEYS_ARTIST=()
|
||||
AI_BATCH_KEYS_MATCH=()
|
||||
local key info type dir_chain_val count=0
|
||||
for key in "${!PENDING_AI_SEARCH[@]}"; do
|
||||
((count >= batch_limit)) && break
|
||||
info="${PENDING_AI_SEARCH[$key]}"
|
||||
IFS=$'\t' read -r type dir_chain_val <<<"$info"
|
||||
input_json=$(jq -c --arg f "$key" --arg d "$dir_chain_val" --arg t "$type" \
|
||||
'.search_entries += [{file: $f, directory: $d, type: $t}]' <<<"$input_json")
|
||||
AI_BATCH_KEYS_SEARCH+=("$key")
|
||||
count=$((count + 1))
|
||||
done
|
||||
count=0
|
||||
local show_id fragment show_val show_title season0_names
|
||||
for key in "${!PENDING_AI_SPECIAL[@]}"; do
|
||||
((count >= batch_limit)) && break
|
||||
IFS='|' read -r show_id fragment <<<"$key"
|
||||
show_val="${PENDING_AI_SPECIAL[$key]}"
|
||||
IFS='|' read -r show_title season0_names <<<"$show_val"
|
||||
input_json=$(jq -c --arg s "$show_id" --arg f "$fragment" --arg t "$show_title" --arg s0 "$season0_names" \
|
||||
'.special_entries += [{show_id: $s, fragment: $f, series: $t, season0: $s0}]' <<<"$input_json")
|
||||
AI_BATCH_KEYS_SPECIAL+=("$key")
|
||||
count=$((count + 1))
|
||||
done
|
||||
count=0
|
||||
local artists_str album_name
|
||||
for key in "${!PENDING_AI_ARTIST[@]}"; do
|
||||
((count >= batch_limit)) && break
|
||||
IFS='|' read -r artists_str album_name _ <<<"${PENDING_AI_ARTIST[$key]}"
|
||||
input_json=$(jq -c --arg f "$key" --arg a "$artists_str" --arg al "$album_name" \
|
||||
'.artist_entries += [{file: $f, artists: $a, album: $al}]' <<<"$input_json")
|
||||
AI_BATCH_KEYS_ARTIST+=("$key")
|
||||
count=$((count + 1))
|
||||
done
|
||||
count=0
|
||||
local m_type m_year m_season m_episode m_endpoint m_key
|
||||
for key in "${!PENDING_AI_MATCH[@]}"; do
|
||||
((count >= batch_limit)) && break
|
||||
IFS=$'\t' read -r m_type m_year m_season m_episode m_endpoint m_key <<<"${PENDING_AI_MATCH[$key]}"
|
||||
# 输入 = search 响应原样(缓存中,零额外请求)+ 首候选 detail 的 seasons 四字段提炼(缓存命中才给)
|
||||
local search_data="" seasons_json="[]"
|
||||
search_data=$(jq -r '.data // empty' "$(cache_path "$m_endpoint" "$m_key")" 2>/dev/null) || search_data=""
|
||||
if [[ -n "$search_data" ]]; then
|
||||
local first_id d_json
|
||||
first_id=$(echo "$search_data" | jq -r '.results[0].id // empty' 2>/dev/null)
|
||||
if [[ -n "$first_id" ]]; then
|
||||
d_json=$(cat "$(cache_path "/${m_type}/${first_id}" "$m_key")" 2>/dev/null)
|
||||
if [[ -n "$d_json" ]] && echo "$d_json" | jq -e . >/dev/null 2>&1; then
|
||||
seasons_json=$(echo "$d_json" | jq -c '[.seasons[]? | {season_number, name, episode_count, air_date}]' 2>/dev/null)
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
[[ -n "$search_data" ]] || search_data="null"
|
||||
[[ -n "$seasons_json" ]] || seasons_json="[]"
|
||||
input_json=$(jq -c --arg f "$key" --arg t "$m_type" --arg y "$m_year" --arg s "$m_season" \
|
||||
--argjson sd "$search_data" --argjson sn "$seasons_json" \
|
||||
'.match_entries += [{file: $f, type: $t, year: $y, season: $s, search: $sd, seasons: $sn}]' <<<"$input_json")
|
||||
AI_BATCH_KEYS_MATCH+=("$key")
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "$input_json"
|
||||
}
|
||||
|
||||
ai_batch_request() {
|
||||
# 无待处理项直接返回
|
||||
if [[ $PENDING_SEARCH_COUNT -eq 0 ]] &&
|
||||
[[ $PENDING_SPECIAL_COUNT -eq 0 ]] &&
|
||||
[[ $PENDING_ARTIST_COUNT -eq 0 ]] &&
|
||||
[[ $PENDING_MATCH_COUNT -eq 0 ]]; then
|
||||
return
|
||||
fi
|
||||
_log 智能 "AI 批量请求(搜索 $PENDING_SEARCH_COUNT + 特典 $PENDING_SPECIAL_COUNT + 艺术家 $PENDING_ARTIST_COUNT + 匹配 $PENDING_MATCH_COUNT)"
|
||||
|
||||
local input_json
|
||||
input_json=$(build_ai_input_json)
|
||||
local full_prompt="$AI_BATCH_PROMPT"$'\n\n'"Input: $input_json"
|
||||
|
||||
local payload
|
||||
payload=$(jq -n --arg content "$full_prompt" --arg model "$AI_MODEL" --arg sys "$AI_SYSTEM_MESSAGE" \
|
||||
'{model: $model, messages: [{role: "system", content: $sys}, {role: "user", content: $content}], temperature: 0.2}')
|
||||
|
||||
[[ "${AI_DRY_RUN,,}" == "true" ]] && {
|
||||
_log 智能 "AI 干运行,Prompt 摘要: 搜索 $PENDING_SEARCH_COUNT 个 + 特典 $PENDING_SPECIAL_COUNT 个 + 艺术家 $PENDING_ARTIST_COUNT 个 + 匹配 $PENDING_MATCH_COUNT 个"
|
||||
# 返回非零:未消费任何条目(run_ai_batch 视作失败 → 剩余显式跳过,可逆)
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ "$AI_CALL_COUNT" -ge "${AI_MAX_CALLS}" ]]; then
|
||||
_log 警告 "AI 调用次数已达上限,跳过批量请求"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local api_url="${AI_FULL_URL:-${AI_BASE_URL}/v1/chat/completions}"
|
||||
# 递增重试(2^n 封顶 16s),与 tmdb_api 同一策略
|
||||
local response=""
|
||||
local attempt=0 delay=1
|
||||
while true; do
|
||||
if response=$(curl -s --connect-timeout "${AI_CURL_CONNECT_TIMEOUT}" --max-time "${AI_CURL_MAX_TIME}" \
|
||||
-H "Content-Type: application/json" -H "Authorization: Bearer ${AI_API_KEY}" -d "$payload" "$api_url" 2>&1); then
|
||||
break
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
if ((attempt > AI_CURL_RETRY)); then
|
||||
_log 错误 "AI 批量请求失败(重试 ${AI_CURL_RETRY} 次后)"
|
||||
return 1
|
||||
fi
|
||||
_log 警告 "AI 批量请求失败,${delay}s 后重试(${attempt}/${AI_CURL_RETRY})"
|
||||
sleep "$delay"
|
||||
((delay *= 2))
|
||||
[[ $delay -gt 16 ]] && delay=16
|
||||
done
|
||||
AI_CALL_COUNT=$((AI_CALL_COUNT + 1))
|
||||
|
||||
local content
|
||||
content=$(echo "$response" | jq -r '.choices[0].message.content // empty' 2>/dev/null)
|
||||
if [[ -z "$content" ]]; then
|
||||
_log 错误 "AI 批量请求未返回有效内容"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$content" | jq -e . >/dev/null 2>&1 || {
|
||||
_log 错误 "AI 批量请求返回非 JSON: $content"
|
||||
return 1
|
||||
}
|
||||
|
||||
_log 智能 "AI 批量请求结果: $content"
|
||||
local result_json="$content"
|
||||
|
||||
# 解析 search 部分:更新 PENDING_AI_SEARCH(追加 |搜索词|年份|AI判断类别)
|
||||
local ai_term ai_year ai_media
|
||||
for key in "${!PENDING_AI_SEARCH[@]}"; do
|
||||
ai_term=$(echo "$result_json" | jq -r --arg file "$key" '.search[$file].search_term // empty')
|
||||
ai_year=$(echo "$result_json" | jq -r --arg file "$key" '.search[$file].year // empty')
|
||||
ai_media=$(echo "$result_json" | jq -r --arg file "$key" '.search[$file].media_type // empty')
|
||||
if [[ -n "$ai_term" ]] || [[ -n "$ai_media" ]]; then
|
||||
local old="${PENDING_AI_SEARCH[$key]}"
|
||||
PENDING_AI_SEARCH[$key]="${old} ${ai_term} ${ai_year} ${ai_media}"
|
||||
fi
|
||||
done
|
||||
|
||||
# 解析 artist_choice 部分:AI 判定多艺术家中的专辑艺术家(空串=判断不出)
|
||||
for key in "${!PENDING_AI_ARTIST[@]}"; do
|
||||
AI_ARTIST_CHOICE[$key]=$(echo "$result_json" | jq -r --arg file "$key" '.artist_choice[$file] // empty')
|
||||
done
|
||||
|
||||
# 解析 match 部分:AI 匹配甄别(choice=选定 id;season_shift=季映射;search_term=无匹配纠正词)
|
||||
for key in "${!PENDING_AI_MATCH[@]}"; do
|
||||
AI_MATCH_CHOICE[$key]=$(echo "$result_json" | jq -r --arg file "$key" '.match[$file].choice // empty')
|
||||
AI_MATCH_SHIFT[$key]=$(echo "$result_json" | jq -r --arg file "$key" '.match[$file].season_shift // empty')
|
||||
AI_MATCH_TERM[$key]=$(echo "$result_json" | jq -r --arg file "$key" '.match[$file].search_term // empty')
|
||||
done
|
||||
|
||||
# 记录本批响应覆盖的 key(AI 漏了的条目留待下一批;缺失计数警告)
|
||||
AI_RESPONDED_SEARCH=()
|
||||
AI_RESPONDED_ARTIST=()
|
||||
AI_RESPONDED_MATCH=()
|
||||
local responded
|
||||
for key in "${AI_BATCH_KEYS_SEARCH[@]}"; do
|
||||
responded=$(echo "$result_json" | jq -r --arg f "$key" '.search | has($f)' 2>/dev/null)
|
||||
[[ "$responded" == "true" ]] && AI_RESPONDED_SEARCH["$key"]=1
|
||||
done
|
||||
for key in "${AI_BATCH_KEYS_ARTIST[@]}"; do
|
||||
responded=$(echo "$result_json" | jq -r --arg f "$key" '.artist_choice | has($f)' 2>/dev/null)
|
||||
[[ "$responded" == "true" ]] && AI_RESPONDED_ARTIST["$key"]=1
|
||||
done
|
||||
for key in "${AI_BATCH_KEYS_MATCH[@]}"; do
|
||||
responded=$(echo "$result_json" | jq -r --arg f "$key" '.match | has($f)' 2>/dev/null)
|
||||
[[ "$responded" == "true" ]] && AI_RESPONDED_MATCH["$key"]=1
|
||||
done
|
||||
local missing=$((${#AI_BATCH_KEYS_SEARCH[@]} + ${#AI_BATCH_KEYS_ARTIST[@]} + ${#AI_BATCH_KEYS_MATCH[@]} - \
|
||||
${#AI_RESPONDED_SEARCH[@]} - ${#AI_RESPONDED_ARTIST[@]} - ${#AI_RESPONDED_MATCH[@]}))
|
||||
((missing > 0)) && _log 警告 "AI 响应缺失 $missing 个条目(留待下一批)"
|
||||
|
||||
learn_special_keywords "$result_json"
|
||||
}
|
||||
|
||||
# AI 学习写回:AI 从该条 season0 候选关键字列表中选出与本地片段匹配的项,
|
||||
# 写回 keymap 作为匹配关键字(可多语言数组)。
|
||||
# 交叉验证:AI 返回的每个匹配关键字必须存在于该条 season0 候选列表(防 AI 幻觉污染映射)。
|
||||
learn_special_keywords() {
|
||||
local result_json="$1"
|
||||
local tmdb_name key
|
||||
for key in "${!PENDING_AI_SPECIAL[@]}"; do
|
||||
local fragment key_lower season0_names
|
||||
IFS='|' read -r _ fragment <<<"$key"
|
||||
key_lower="${fragment,,}"
|
||||
season0_names="${PENDING_AI_SPECIAL[$key]#*|}"
|
||||
tmdb_name=$(echo "$result_json" | jq -r --arg k "$fragment" '.special[$k] // empty')
|
||||
if [[ -n "$tmdb_name" ]] && [[ "$tmdb_name" != "null" ]]; then
|
||||
# 交叉验证:过滤掉不在候选列表中的项(数组逐项 / 字符串单项)。
|
||||
# 注意:`$s0 | contains(.)` 中 . 指管道输入($s0 自身)——须先捕获元素为 $item
|
||||
local valid_name
|
||||
valid_name=$(echo "$tmdb_name" | jq -r --arg s0 "$season0_names" \
|
||||
'if type == "array" then ([.[] | select(. as $item | ($item != "") and ($s0 | contains($item)))] | if length > 0 then . else empty end)
|
||||
else (if (. != "") and ($s0 | contains(.)) then . else empty end) end' 2>/dev/null) || valid_name=""
|
||||
if [[ -z "$valid_name" ]] || [[ "$valid_name" == "null" ]]; then
|
||||
_log 警告 "AI 特典匹配关键字不在候选列表,丢弃: $fragment -> $tmdb_name"
|
||||
continue
|
||||
fi
|
||||
tmdb_name="$valid_name"
|
||||
# 确保映射文件存在(不存在则创建空 JSON,AI 学到的映射需要持久化)
|
||||
if [[ ! -f "$SPECIAL_MAP_FILE" ]]; then
|
||||
echo '{}' >"$SPECIAL_MAP_FILE" 2>/dev/null || true
|
||||
fi
|
||||
local tmp_map_file="${SPECIAL_MAP_FILE}.tmp.$$"
|
||||
# JSON 文件:合并写入数组(已有值则去重追加;字符串值归并为数组)
|
||||
if jq --arg k "$key_lower" --argjson v "$tmdb_name" \
|
||||
'if has($k) then (.[$k] = (((if (.[$k]|type)=="array" then .[$k] else [.[$k]] end) + (if ($v|type)=="array" then $v else [$v] end)) | unique)) else (.[$k] = (if ($v|type)=="array" then $v else [$v] end)) end' \
|
||||
"$SPECIAL_MAP_FILE" >"$tmp_map_file" 2>/dev/null; then
|
||||
if ! mv "$tmp_map_file" "$SPECIAL_MAP_FILE"; then
|
||||
rm -f "$tmp_map_file"
|
||||
fi
|
||||
else
|
||||
rm -f "$tmp_map_file"
|
||||
fi
|
||||
# 更新内存映射(JSON 数组)
|
||||
local existing="${SPECIAL_MAP[$key_lower]:-}"
|
||||
if [[ -z "$existing" ]]; then
|
||||
SPECIAL_MAP["$key_lower"]=$(jq -n --argjson v "$tmdb_name" 'if ($v|type)=="array" then $v else [$v] end')
|
||||
else
|
||||
SPECIAL_MAP["$key_lower"]=$(echo "$existing" | jq --argjson v "$tmdb_name" \
|
||||
'(if type=="array" then . else [.] end) + (if ($v|type)=="array" then $v else [$v] end) | unique')
|
||||
fi
|
||||
_log 智能 "已添加特典映射: $fragment -> $tmdb_name"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
-211
@@ -1,211 +0,0 @@
|
||||
################################################################################
|
||||
# 命令行参数与帮助
|
||||
################################################################################
|
||||
|
||||
# 显示帮助信息。
|
||||
show_help() {
|
||||
cat <<EOF
|
||||
Jellyfin 媒体库硬链接整理脚本 v${SCRIPT_VERSION}
|
||||
|
||||
用法:
|
||||
media_organizer.sh [选项] <源目录> <目的目录>
|
||||
media_organizer.sh --update-cache <源目录> 仅更新缓存,不整理
|
||||
media_organizer.sh --list-cache [关键词] 列出缓存条目
|
||||
|
||||
模式(一次调用只取其一):
|
||||
<源目录> <目的目录> 正常整理(使用缓存)
|
||||
--update-cache <源目录> <目的目录> 整理并强制重取对应缓存
|
||||
--update-cache <源目录> 仅更新缓存,不整理
|
||||
--list-cache [关键词] 列出缓存条目(可选关键词过滤)
|
||||
--export-map [目录] 生成源→目标对照表 markdown(文件名=源目录绝对路径 md5;默认输出到脚本目录/mo_map,可指定目录)
|
||||
|
||||
选项:
|
||||
-a, --automated 自动化模式(写入日志,跳过无法处理的项目)
|
||||
--no-automated 取消自动化模式(覆盖环境变量/mo_env 设置)
|
||||
--dry-run 干运行:不创建链接、不写缓存、不写日志(网络请求照常但不落盘)
|
||||
--no-dry-run 取消干运行(覆盖环境变量/mo_env 设置)
|
||||
--refresh-cache 清空全部持久化缓存后执行(可与任意模式叠加)
|
||||
--update-cache 强制重取对应缓存条目(与 --list-cache 互斥)
|
||||
--list-cache 列出缓存条目(与 --update-cache 互斥)
|
||||
--src-dir <目录> 指定源目录(与位置参数互斥)
|
||||
--dest-dir <目录> 指定目的目录(与位置参数互斥)
|
||||
-h, --help 显示此帮助
|
||||
--version 显示版本号
|
||||
|
||||
语法:
|
||||
-- 之后的所有参数一律视为位置参数
|
||||
带参数选项支持 --opt=值 与 --opt 值 两种形式(值以 - 开头时必须用 = 形式)
|
||||
|
||||
配置:
|
||||
优先级:CLI > 环境变量 > mo_env 文件 > 默认值
|
||||
mo_env 中可配置全部选项(配置示例:mo_env.example)
|
||||
|
||||
TMDB 认证:
|
||||
至少需要:TMDB_API_KEY 或 TMDB_API_RA_TOKEN
|
||||
获取位置:https://www.themoviedb.org/settings/api
|
||||
|
||||
示例:
|
||||
./media_organizer.sh /downloads /media
|
||||
./media_organizer.sh -a /downloads /media
|
||||
./media_organizer.sh --dry-run /downloads /media
|
||||
./media_organizer.sh --update-cache /downloads /media
|
||||
./media_organizer.sh --list-cache 刀剑
|
||||
|
||||
文档:
|
||||
详细配置:CONFIG.md
|
||||
问题排除:TROUBLESHOOTING.md
|
||||
配置示例:mo_env.example
|
||||
EOF
|
||||
}
|
||||
|
||||
# 用法错误:输出错误、用法提示与帮助指引,退出码 2(GNU 惯例)。
|
||||
_usage_error() {
|
||||
local msg="$1"
|
||||
_log 错误 "$msg"
|
||||
_log 错误 "用法:media_organizer.sh [选项] <源目录> <目的目录>(详见 --help)"
|
||||
exit 2
|
||||
}
|
||||
|
||||
# 解析命令行参数(选项任意顺序;-- 后均为位置参数;带参选项支持 = 与空格两种形式)。
|
||||
# 按入口形状(list / cache-only / organize)校验目录个数与模式冲突。
|
||||
parse_args() {
|
||||
local args=("$@") i=0 arg
|
||||
local positional=() src="" dst="" after_dash=false
|
||||
|
||||
while [[ $i -lt ${#args[@]} ]]; do
|
||||
arg="${args[$i]}"
|
||||
if [[ "$after_dash" == "true" ]]; then
|
||||
positional+=("$arg")
|
||||
i=$((i + 1))
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--)
|
||||
after_dash=true
|
||||
;;
|
||||
-a | --automated)
|
||||
AUTOMATED=true
|
||||
;;
|
||||
--no-automated)
|
||||
AUTOMATED=false
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
;;
|
||||
--no-dry-run)
|
||||
DRY_RUN=false
|
||||
;;
|
||||
--refresh-cache)
|
||||
REFRESH_CACHE=true
|
||||
;;
|
||||
--update-cache)
|
||||
UPDATE_CACHE=true
|
||||
;;
|
||||
--list-cache)
|
||||
LIST_CACHE=true
|
||||
# 可选过滤关键词(下一位非选项则作为过滤词)
|
||||
if [[ $i -lt $((${#args[@]} - 1)) && "${args[$((i + 1))]}" != -* ]]; then
|
||||
i=$((i + 1))
|
||||
LIST_CACHE_FILTER="${args[$i]}"
|
||||
fi
|
||||
;;
|
||||
--export-map)
|
||||
EXPORT_MAP=true
|
||||
# 不接字符串 → 默认目录(脚本所在目录/mo_map)。
|
||||
# 指定目录请用 --export-map=DIR 形式(空格形式会与位置参数冲突)。
|
||||
;;
|
||||
--export-map=*)
|
||||
EXPORT_MAP=true
|
||||
EXPORT_MAP_DIR="${arg#*=}"
|
||||
;;
|
||||
--list-cache=*)
|
||||
LIST_CACHE=true
|
||||
LIST_CACHE_FILTER="${arg#*=}"
|
||||
;;
|
||||
--src-dir | --src-dir=*)
|
||||
if [[ "$arg" == --src-dir=* ]]; then
|
||||
src="${arg#*=}"
|
||||
else
|
||||
i=$((i + 1))
|
||||
if [[ $i -ge ${#args[@]} || "${args[$i]}" == -* ]]; then
|
||||
_usage_error "选项 --src-dir 缺少参数值(以 - 开头的值请用 --src-dir=值 形式)"
|
||||
fi
|
||||
src="${args[$i]}"
|
||||
fi
|
||||
;;
|
||||
--dest-dir | --dest-dir=*)
|
||||
if [[ "$arg" == --dest-dir=* ]]; then
|
||||
dst="${arg#*=}"
|
||||
else
|
||||
i=$((i + 1))
|
||||
if [[ $i -ge ${#args[@]} || "${args[$i]}" == -* ]]; then
|
||||
_usage_error "选项 --dest-dir 缺少参数值(以 - 开头的值请用 --dest-dir=值 形式)"
|
||||
fi
|
||||
dst="${args[$i]}"
|
||||
fi
|
||||
;;
|
||||
-h | --help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
--version)
|
||||
echo "media_organizer.sh v${SCRIPT_VERSION}"
|
||||
exit 0
|
||||
;;
|
||||
-*)
|
||||
_usage_error "未知选项 '$arg'"
|
||||
;;
|
||||
*)
|
||||
positional+=("$arg")
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
# ---- 目录通道与形状校验 ----
|
||||
# 通道互斥:位置参数与 --src-dir/--dest-dir 不可混用
|
||||
if [[ ${#positional[@]} -gt 0 && (-n "$src" || -n "$dst") ]]; then
|
||||
_usage_error "目录只能通过一种方式指定:2 个位置参数,或 --src-dir + --dest-dir"
|
||||
fi
|
||||
if [[ ${#positional[@]} -gt 2 ]]; then
|
||||
_usage_error "位置参数过多:最多 2 个(源目录、目的目录)"
|
||||
fi
|
||||
if [[ ${#positional[@]} -eq 1 ]]; then
|
||||
SOURCE_DIR="${positional[0]}"
|
||||
elif [[ ${#positional[@]} -eq 2 ]]; then
|
||||
SOURCE_DIR="${positional[0]}"
|
||||
DESTINATION_DIR="${positional[1]}"
|
||||
fi
|
||||
[[ -n "$src" ]] && SOURCE_DIR="$src"
|
||||
[[ -n "$dst" ]] && DESTINATION_DIR="$dst"
|
||||
|
||||
# list 形状:0 个目录,拒绝目录参数与修饰标志
|
||||
if [[ "$LIST_CACHE" == "true" ]]; then
|
||||
if [[ -n "$SOURCE_DIR" || -n "$DESTINATION_DIR" ]]; then
|
||||
_usage_error "--list-cache 不接受目录参数"
|
||||
fi
|
||||
[[ "$UPDATE_CACHE" == "true" ]] && _usage_error "--list-cache 与 --update-cache 不能同时使用"
|
||||
[[ "$DRY_RUN" == "true" ]] && _usage_error "--dry-run 不能与 --list-cache 同时使用"
|
||||
[[ "$AUTOMATED" == "true" ]] && _usage_error "--automated 不能与 --list-cache 同时使用"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# update-cache 形状:1 个目录 = 仅刷缓存;2 个目录 = 刷新并整理
|
||||
if [[ "$UPDATE_CACHE" == "true" ]]; then
|
||||
if [[ -z "$SOURCE_DIR" ]]; then
|
||||
_usage_error "--update-cache 需要源目录(位置参数或 --src-dir)"
|
||||
fi
|
||||
if [[ -z "$DESTINATION_DIR" ]]; then
|
||||
# cache-only:仅刷缓存,不整理——无链接可预览、无跳过概念
|
||||
[[ "$DRY_RUN" == "true" ]] && _usage_error "--dry-run 不能与仅更新缓存同时使用(无链接可预览)"
|
||||
[[ "$AUTOMATED" == "true" ]] && _usage_error "--automated 不能与仅更新缓存同时使用(无整理步骤)"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# organize 形状:恰好 2 个目录
|
||||
if [[ -z "$SOURCE_DIR" || -z "$DESTINATION_DIR" ]]; then
|
||||
_usage_error "需要源目录与目的目录(2 个位置参数,或 --src-dir + --dest-dir)"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
################################################################################
|
||||
# 配置加载与认证
|
||||
################################################################################
|
||||
|
||||
# 查找配置文件。优先级:执行目录 mo_config > 脚本目录 mo_config。
|
||||
find_config_file() {
|
||||
if [[ -f "$PWD/mo_config/mo_env" ]]; then
|
||||
echo "$PWD/mo_config/mo_env"
|
||||
return 0
|
||||
elif [[ -f "$SCRIPT_DIR/mo_config/mo_env" ]]; then
|
||||
echo "$SCRIPT_DIR/mo_config/mo_env"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 获取文件所有者(兼容 GNU/BSD/BusyBox stat)。
|
||||
# 依次尝试:GNU stat -c '%U' -> BSD stat -f '%Su' -> 数字 UID + /etc/passwd -> ls -ld -> find -printf。
|
||||
get_file_owner() {
|
||||
local filepath="$1"
|
||||
local owner uid
|
||||
owner=$(stat -c '%U' "$filepath" 2>/dev/null)
|
||||
[[ -n "$owner" ]] && {
|
||||
echo "$owner"
|
||||
return 0
|
||||
}
|
||||
owner=$(stat -f '%Su' "$filepath" 2>/dev/null)
|
||||
[[ -n "$owner" ]] && {
|
||||
echo "$owner"
|
||||
return 0
|
||||
}
|
||||
# BusyBox 回退:数字 UID,再从 /etc/passwd 解析用户名
|
||||
uid=$(stat -c '%u' "$filepath" 2>/dev/null)
|
||||
if [[ -n "$uid" ]]; then
|
||||
owner=$(awk -F: -v u="$uid" '$3==u {print $1; exit}' /etc/passwd 2>/dev/null)
|
||||
echo "${owner:-$uid}"
|
||||
return 0
|
||||
fi
|
||||
# ls -ld 解析所有者(BusyBox 可靠,stat/find -printf 可能不可用)
|
||||
# shellcheck disable=SC2012
|
||||
owner=$(ls -ld "$filepath" 2>/dev/null | awk '{print $3}')
|
||||
[[ -n "$owner" ]] && {
|
||||
echo "$owner"
|
||||
return 0
|
||||
}
|
||||
# GNU find 兜底:-printf 解析所有者
|
||||
find "$filepath" -maxdepth 0 -printf '%u' 2>/dev/null
|
||||
}
|
||||
|
||||
# 获取文件权限字符串(兼容 GNU/BSD/BusyBox stat)。
|
||||
# 依次尝试:GNU stat -c '%A' -> BSD stat -f '%Sp' -> stat -c '%a' -> ls -ld -> find -printf。
|
||||
get_file_permission() {
|
||||
local filepath="$1"
|
||||
local perm
|
||||
perm=$(stat -c '%A' "$filepath" 2>/dev/null)
|
||||
[[ -n "$perm" ]] && {
|
||||
echo "$perm"
|
||||
return 0
|
||||
}
|
||||
perm=$(stat -f '%Sp' "$filepath" 2>/dev/null)
|
||||
[[ -n "$perm" ]] && {
|
||||
echo "$perm"
|
||||
return 0
|
||||
}
|
||||
# BusyBox 回退:数字权限(如 600)
|
||||
perm=$(stat -c '%a' "$filepath" 2>/dev/null)
|
||||
[[ -n "$perm" ]] && {
|
||||
echo "$perm"
|
||||
return 0
|
||||
}
|
||||
# ls -ld 解析权限字段(BusyBox 可靠,stat/find -printf 可能不可用)
|
||||
# shellcheck disable=SC2012
|
||||
perm=$(ls -ld "$filepath" 2>/dev/null | awk '{print $1}')
|
||||
[[ -n "$perm" ]] && {
|
||||
echo "$perm"
|
||||
return 0
|
||||
}
|
||||
# GNU find 兜底:-printf 解析数字权限
|
||||
find "$filepath" -maxdepth 0 -printf '%m' 2>/dev/null
|
||||
}
|
||||
|
||||
# 检查密钥文件安全性:必须为当前用户所有,权限为 600。
|
||||
# 严格校验,不允许跳过(防止 mo_env 被篡改)。
|
||||
# 读取所有者/权限时依赖 get_file_* 的完整降级链(stat -> ls -> find),
|
||||
# 确保在 stat/find -printf 不可用的系统上仍能取得正确值完成校验。
|
||||
check_secure_file() {
|
||||
local filepath="$1"
|
||||
|
||||
if [[ ! -f "$filepath" ]]; then
|
||||
_log 错误 "密钥文件不存在:$filepath"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local current_user file_owner
|
||||
current_user=$(id -un 2>/dev/null || true)
|
||||
|
||||
file_owner=$(get_file_owner "$filepath")
|
||||
if [[ -z "$file_owner" ]]; then
|
||||
_log 错误 "无法读取文件所有者(系统 stat 命令不兼容)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 仅当能获取当前用户名时才校验所有者(嵌入式系统可能无法解析)
|
||||
if [[ -n "$current_user" ]] && [[ "$file_owner" != "$current_user" ]]; then
|
||||
_log 错误 "文件所有者应为当前用户"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local file_perm
|
||||
file_perm=$(get_file_permission "$filepath")
|
||||
if [[ -z "$file_perm" ]]; then
|
||||
_log 错误 "无法读取文件权限"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 接受符号格式(-rw-------)或数字格式(600)
|
||||
if [[ "$file_perm" != "-rw-------" ]] && [[ "$file_perm" != "600" ]]; then
|
||||
_log 错误 "文件权限应为 600,当前为 $file_perm"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# 从配置文件解析指定键的值(KEY=VALUE 格式)。
|
||||
parse_config_key() {
|
||||
local config_file="$1"
|
||||
local key="$2"
|
||||
|
||||
grep -E "^[[:space:]]*${key}=" "$config_file" 2>/dev/null |
|
||||
head -1 |
|
||||
sed -E 's/^[[:space:]]*'"${key}"'=//; s/[[:space:]]*$//' || true
|
||||
}
|
||||
|
||||
# 加载 mo_env 配置并应用环境变量默认值。
|
||||
# 配置优先级链:CLI > 环境变量 > mo_env 文件 > 默认值。
|
||||
# mo_env 采用白名单键加载:键名集合取自 MO_ENV_TEMPLATE,逐键 grep 提取,
|
||||
# 文件内容永不执行(配置即数据);白名单外的自定义键不生效。
|
||||
load_config() {
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# 读取 mo_env 白名单键值(配置文件存在且通过安全检查时)
|
||||
local -A moenv=()
|
||||
if CONFIG_FILE=$(find_config_file); then
|
||||
if ! check_secure_file "$CONFIG_FILE"; then
|
||||
exit 1
|
||||
fi
|
||||
local key
|
||||
while IFS= read -r key; do
|
||||
[[ -z "$key" ]] && continue
|
||||
moenv["$key"]=$(parse_config_key "$CONFIG_FILE" "$key")
|
||||
done < <(
|
||||
grep -oE '^[A-Za-z_][A-Za-z0-9_]*=' <<<"$MO_ENV_TEMPLATE" |
|
||||
sed 's/=$//' | sort -u
|
||||
)
|
||||
fi
|
||||
|
||||
# 模式标志(CLI 显式设置后不再覆盖;env/mo_env 可设,默认 false)
|
||||
AUTOMATED="${AUTOMATED:-${moenv[AUTOMATED]:-false}}"
|
||||
DRY_RUN="${DRY_RUN:-${moenv[DRY_RUN]:-false}}"
|
||||
|
||||
# 目的目录根名(默认跟随系统语言:locale 含 zh → 中文名;FOLDER_* 显式设置覆盖)
|
||||
local _lang="${LANG:-${LC_ALL:-}}"
|
||||
if [[ "$_lang" == zh* ]]; then
|
||||
FOLDER_MOVIES="${FOLDER_MOVIES:-${moenv[FOLDER_MOVIES]:-电影}}"
|
||||
FOLDER_SHOWS="${FOLDER_SHOWS:-${moenv[FOLDER_SHOWS]:-剧集}}"
|
||||
FOLDER_MUSIC="${FOLDER_MUSIC:-${moenv[FOLDER_MUSIC]:-音乐}}"
|
||||
FOLDER_MUSICVIDEOS="${FOLDER_MUSICVIDEOS:-${moenv[FOLDER_MUSICVIDEOS]:-音乐视频}}"
|
||||
FOLDER_UNKNOWN="${FOLDER_UNKNOWN:-${moenv[FOLDER_UNKNOWN]:-未知}}"
|
||||
else
|
||||
FOLDER_MOVIES="${FOLDER_MOVIES:-${moenv[FOLDER_MOVIES]:-Movies}}"
|
||||
FOLDER_SHOWS="${FOLDER_SHOWS:-${moenv[FOLDER_SHOWS]:-Shows}}"
|
||||
FOLDER_MUSIC="${FOLDER_MUSIC:-${moenv[FOLDER_MUSIC]:-Music}}"
|
||||
FOLDER_MUSICVIDEOS="${FOLDER_MUSICVIDEOS:-${moenv[FOLDER_MUSICVIDEOS]:-MusicVideos}}"
|
||||
FOLDER_UNKNOWN="${FOLDER_UNKNOWN:-${moenv[FOLDER_UNKNOWN]:-Unknown}}"
|
||||
fi
|
||||
|
||||
# 识别池并发数(1-8)
|
||||
MEDIA_WORKERS="${MEDIA_WORKERS:-${moenv[MEDIA_WORKERS]:-4}}"
|
||||
[[ "$MEDIA_WORKERS" =~ ^[1-8]$ ]] || MEDIA_WORKERS=4
|
||||
|
||||
# TMDB 认证
|
||||
TMDB_API_KEY="${TMDB_API_KEY:-${moenv[TMDB_API_KEY]:-}}"
|
||||
TMDB_API_RA_TOKEN="${TMDB_API_RA_TOKEN:-${moenv[TMDB_API_RA_TOKEN]:-}}"
|
||||
|
||||
# 基础配置
|
||||
TMDB_LANG="${TMDB_LANG:-${moenv[TMDB_LANG]:-zh-CN}}"
|
||||
TMDB_DELAY="${TMDB_DELAY:-${moenv[TMDB_DELAY]:-1}}"
|
||||
COLOR_OUTPUT="${COLOR_OUTPUT:-${moenv[COLOR_OUTPUT]:-true}}"
|
||||
DEBUG_LEVEL="${DEBUG_LEVEL:-${moenv[DEBUG_LEVEL]:-0}}"
|
||||
|
||||
# 自动化模式日志初始化
|
||||
if [[ "$AUTOMATED" == "true" ]]; then
|
||||
LOG_FILE="${LOG_FILE:-${moenv[LOG_FILE]:-/var/log/media_organizer.log}}"
|
||||
SKIP_LOG_FILE="${SKIP_LOG_FILE:-${moenv[SKIP_LOG_FILE]:-/var/log/media_organizer_skip.log}}"
|
||||
LOG_ROTATE_MB="${LOG_ROTATE_MB:-${moenv[LOG_ROTATE_MB]:-10}}"
|
||||
[[ "$LOG_ROTATE_MB" =~ ^[0-9]+$ ]] || LOG_ROTATE_MB=10
|
||||
# 干运行不创建日志目录(零持久化副作用)
|
||||
if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$SKIP_LOG_FILE")" \
|
||||
2>/dev/null || true
|
||||
# 日志轮转:超阈值时滚动保留 5 份(.1 最新)
|
||||
if ((LOG_ROTATE_MB > 0)); then
|
||||
local _limit=$((LOG_ROTATE_MB * 1024 * 1024)) _lf _size
|
||||
for _lf in "$LOG_FILE" "$SKIP_LOG_FILE"; do
|
||||
[[ -f "$_lf" ]] || continue
|
||||
_size=$(stat -c '%s' "$_lf" 2>/dev/null) || _size=0
|
||||
if ((_size > _limit)); then
|
||||
rm -f "${_lf}.5"
|
||||
mv -f "${_lf}.4" "${_lf}.5" 2>/dev/null || true
|
||||
mv -f "${_lf}.3" "${_lf}.4" 2>/dev/null || true
|
||||
mv -f "${_lf}.2" "${_lf}.3" 2>/dev/null || true
|
||||
mv -f "${_lf}.1" "${_lf}.2" 2>/dev/null || true
|
||||
mv -f "$_lf" "${_lf}.1"
|
||||
_log 信息 "日志已轮转: $_lf -> ${_lf}.1"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 解析扩展名配置(mka 为官方音频容器;纯音频 mkv/mp4/webm 需用户自行改容器为 mka/m4a)
|
||||
IFS=',' read -ra VIDEO_EXTS <<<"${VIDEO_EXTS:-${moenv[VIDEO_EXTS]:-mp4,mkv,avi,mov,wmv,flv,m4v,ts,m2ts,webm}}"
|
||||
IFS=',' read -ra AUDIO_EXTS <<<"${AUDIO_EXTS:-${moenv[AUDIO_EXTS]:-mp3,flac,aac,ogg,wma,m4a,mka,ac3,dts,wav,opus}}"
|
||||
IFS=',' read -ra SUB_EXTS <<<"${SUB_EXTS:-${moenv[SUB_EXTS]:-srt,ass,ssa,sub,idx,vtt,sup}}"
|
||||
|
||||
# curl 网络配置
|
||||
TMDB_CURL_RETRY="${TMDB_CURL_RETRY:-${moenv[TMDB_CURL_RETRY]:-3}}"
|
||||
TMDB_CURL_CONNECT_TIMEOUT="${TMDB_CURL_CONNECT_TIMEOUT:-${moenv[TMDB_CURL_CONNECT_TIMEOUT]:-10}}"
|
||||
TMDB_CURL_MAX_TIME="${TMDB_CURL_MAX_TIME:-${moenv[TMDB_CURL_MAX_TIME]:-30}}"
|
||||
AI_CURL_RETRY="${AI_CURL_RETRY:-${moenv[AI_CURL_RETRY]:-3}}"
|
||||
AI_CURL_CONNECT_TIMEOUT="${AI_CURL_CONNECT_TIMEOUT:-${moenv[AI_CURL_CONNECT_TIMEOUT]:-10}}"
|
||||
AI_CURL_MAX_TIME="${AI_CURL_MAX_TIME:-${moenv[AI_CURL_MAX_TIME]:-30}}"
|
||||
AI_MAX_CALLS="${AI_MAX_CALLS:-${moenv[AI_MAX_CALLS]:-10}}"
|
||||
AI_DRY_RUN="${AI_DRY_RUN:-${moenv[AI_DRY_RUN]:-false}}"
|
||||
# AI 批处理:模型/端点/批大小默认(DeepSeek-V4-Flash,OpenAI 兼容端点)
|
||||
AI_API_KEY="${AI_API_KEY:-${moenv[AI_API_KEY]:-}}"
|
||||
AI_MODEL="${AI_MODEL:-${moenv[AI_MODEL]:-DeepSeek-V4-Flash}}"
|
||||
AI_BASE_URL="${AI_BASE_URL:-${moenv[AI_BASE_URL]:-https://api.deepseek.com}}"
|
||||
AI_BATCH_SIZE="${AI_BATCH_SIZE:-${moenv[AI_BATCH_SIZE]:-50}}"
|
||||
[[ "$AI_BATCH_SIZE" =~ ^[0-9]+$ ]] && [[ "$AI_BATCH_SIZE" -ge 1 ]] || AI_BATCH_SIZE=50
|
||||
AI_FULL_URL="${AI_FULL_URL:-${moenv[AI_FULL_URL]:-}}"
|
||||
SKIP_HARDLINK_CHECK="${SKIP_HARDLINK_CHECK:-${moenv[SKIP_HARDLINK_CHECK]:-false}}"
|
||||
# 字幕组提取的压制组黑名单(发布组括号 & 拆分后过滤;逗号分隔)
|
||||
SUB_GROUP_BLACKLIST="${SUB_GROUP_BLACKLIST:-${moenv[SUB_GROUP_BLACKLIST]:-VCB-Studio}}"
|
||||
|
||||
# 缓存 TTL 配置(天)
|
||||
CACHE_TTL_DAYS="${CACHE_TTL_DAYS:-${moenv[CACHE_TTL_DAYS]:-30}}"
|
||||
CACHE_EMPTY_TTL_DAYS="${CACHE_EMPTY_TTL_DAYS:-${moenv[CACHE_EMPTY_TTL_DAYS]:-3}}"
|
||||
|
||||
# 路径配置(空值时由 init_cache_dir / init_special_map / init_season_offset / init_special_words 解析默认路径)
|
||||
CACHE_DIR="${CACHE_DIR:-${moenv[CACHE_DIR]:-}}"
|
||||
SPECIAL_MAP_FILE="${SPECIAL_MAP_FILE:-${moenv[SPECIAL_MAP_FILE]:-}}"
|
||||
SEASON_OFFSET_FILE="${SEASON_OFFSET_FILE:-${moenv[SEASON_OFFSET_FILE]:-}}"
|
||||
SPECIAL_WORDS_FILE="${SPECIAL_WORDS_FILE:-${moenv[SPECIAL_WORDS_FILE]:-}}"
|
||||
SKIP_DIRS_FILE="${SKIP_DIRS_FILE:-${moenv[SKIP_DIRS_FILE]:-}}"
|
||||
}
|
||||
|
||||
# 选择 TMDB 认证方式并校验。无密钥时交互生成 mo_env 模板。
|
||||
select_auth() {
|
||||
TMDB_API_KEY="${TMDB_API_KEY:-}"
|
||||
TMDB_API_RA_TOKEN="${TMDB_API_RA_TOKEN:-}"
|
||||
|
||||
if [[ -n "$TMDB_API_RA_TOKEN" ]]; then
|
||||
TMDB_AUTH_TOKEN="$TMDB_API_RA_TOKEN"
|
||||
TMDB_USE_BEARER=true
|
||||
elif [[ -n "$TMDB_API_KEY" ]]; then
|
||||
TMDB_AUTH_TOKEN="$TMDB_API_KEY"
|
||||
else
|
||||
if [[ "$AUTOMATED" != "true" ]]; then
|
||||
_log 信息 "未检测到 TMDB API 密钥。是否生成 mo_env 模板?(y/N)"
|
||||
read -r answer
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
generate_mo_env_template
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
_log 错误 "未提供 TMDB 认证信息。"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 生成 mo_env 配置模板(仅交互式调用)。
|
||||
generate_mo_env_template() {
|
||||
mkdir -p "$PWD/mo_config" 2>/dev/null || {
|
||||
_log 错误 "无法创建配置目录 $PWD/mo_config"
|
||||
return 1
|
||||
}
|
||||
# 子 shell 设置 umask 077,确保创建的文件权限即 600
|
||||
(umask 077 && printf '%s\n' "$MO_ENV_TEMPLATE" >"$PWD/mo_config/mo_env") || {
|
||||
_log 错误 "无法写入配置文件 $PWD/mo_config/mo_env"
|
||||
return 1
|
||||
}
|
||||
# 兜底:确保权限为 600(不静默失败)
|
||||
chmod 600 "$PWD/mo_config/mo_env" || {
|
||||
_log 错误 "无法设置文件权限为 600:$PWD/mo_config/mo_env"
|
||||
return 1
|
||||
}
|
||||
_log 信息 "模板已创建: $PWD/mo_config/mo_env"
|
||||
}
|
||||
|
||||
# 检查依赖命令是否可用。
|
||||
check_dependencies() {
|
||||
for cmd in curl jq ffprobe; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
_log 错误 "缺少命令 $cmd"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034 # 跨文件共享全局变量(TMDB_AUTH_TOKEN/TMDB_USE_BEARER,tmdb.sh 消费)
|
||||
|
||||
################################################################################
|
||||
# 配置加载与认证
|
||||
################################################################################
|
||||
|
||||
# 查找配置文件。优先级:执行目录 mo_config > 脚本目录 mo_config。
|
||||
# v9.3 起文件名为 config.json(JSON 格式,目录名 mo_config 已含 mo 标识);
|
||||
# 旧文本格式 env(v9.2)/ mo_env(v9.0)自动转换为 config.json(干运行只读回退)。
|
||||
find_config_file() {
|
||||
if [[ -f "$PWD/mo_config/config.json" ]]; then
|
||||
echo "$PWD/mo_config/config.json"
|
||||
return 0
|
||||
elif [[ -f "$SCRIPT_DIR/mo_config/config.json" ]]; then
|
||||
echo "$SCRIPT_DIR/mo_config/config.json"
|
||||
return 0
|
||||
fi
|
||||
# 旧文本格式:env / mo_env → 转换为 JSON 格式的 config.json
|
||||
local legacy=""
|
||||
[[ -f "$PWD/mo_config/env" ]] && legacy="$PWD/mo_config/env"
|
||||
[[ -z "$legacy" && -f "$SCRIPT_DIR/mo_config/env" ]] && legacy="$SCRIPT_DIR/mo_config/env"
|
||||
[[ -z "$legacy" && -f "$PWD/mo_config/mo_env" ]] && legacy="$PWD/mo_config/mo_env"
|
||||
[[ -z "$legacy" && -f "$SCRIPT_DIR/mo_config/mo_env" ]] && legacy="$SCRIPT_DIR/mo_config/mo_env"
|
||||
if [[ -n "$legacy" ]]; then
|
||||
if [[ "${DRY_RUN:-false}" == "true" ]]; then
|
||||
echo "$legacy"
|
||||
else
|
||||
local new_path="${legacy%/*}/config.json"
|
||||
if convert_env_to_json "$legacy" "$new_path" 2>/dev/null; then
|
||||
chmod 600 "$new_path" 2>/dev/null || true
|
||||
_log 信息 "已迁移旧版配置: $legacy -> $new_path"
|
||||
rm -f "$legacy"
|
||||
echo "$new_path"
|
||||
else
|
||||
echo "$legacy"
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 获取文件所有者(兼容 GNU/BSD/BusyBox stat)。
|
||||
# 依次尝试:GNU stat -c '%U' -> BSD stat -f '%Su' -> 数字 UID + /etc/passwd -> ls -ld -> find -printf。
|
||||
get_file_owner() {
|
||||
local filepath="$1"
|
||||
local owner uid
|
||||
owner=$(stat -c '%U' "$filepath" 2>/dev/null)
|
||||
[[ -n "$owner" ]] && {
|
||||
echo "$owner"
|
||||
return 0
|
||||
}
|
||||
owner=$(stat -f '%Su' "$filepath" 2>/dev/null)
|
||||
[[ -n "$owner" ]] && {
|
||||
echo "$owner"
|
||||
return 0
|
||||
}
|
||||
# BusyBox 回退:数字 UID,再从 /etc/passwd 解析用户名
|
||||
uid=$(stat -c '%u' "$filepath" 2>/dev/null)
|
||||
if [[ -n "$uid" ]]; then
|
||||
owner=$(awk -F: -v u="$uid" '$3==u {print $1; exit}' /etc/passwd 2>/dev/null)
|
||||
echo "${owner:-$uid}"
|
||||
return 0
|
||||
fi
|
||||
# ls -ld 解析所有者(BusyBox 可靠,stat/find -printf 可能不可用)
|
||||
# shellcheck disable=SC2012
|
||||
owner=$(ls -ld "$filepath" 2>/dev/null | awk '{print $3}')
|
||||
[[ -n "$owner" ]] && {
|
||||
echo "$owner"
|
||||
return 0
|
||||
}
|
||||
# GNU find 兜底:-printf 解析所有者
|
||||
find "$filepath" -maxdepth 0 -printf '%u' 2>/dev/null
|
||||
}
|
||||
|
||||
# 获取文件权限字符串(兼容 GNU/BSD/BusyBox stat)。
|
||||
# 依次尝试:GNU stat -c '%A' -> BSD stat -f '%Sp' -> stat -c '%a' -> ls -ld -> find -printf。
|
||||
get_file_permission() {
|
||||
local filepath="$1"
|
||||
local perm
|
||||
perm=$(stat -c '%A' "$filepath" 2>/dev/null)
|
||||
[[ -n "$perm" ]] && {
|
||||
echo "$perm"
|
||||
return 0
|
||||
}
|
||||
perm=$(stat -f '%Sp' "$filepath" 2>/dev/null)
|
||||
[[ -n "$perm" ]] && {
|
||||
echo "$perm"
|
||||
return 0
|
||||
}
|
||||
# BusyBox 回退:数字权限(如 600)
|
||||
perm=$(stat -c '%a' "$filepath" 2>/dev/null)
|
||||
[[ -n "$perm" ]] && {
|
||||
echo "$perm"
|
||||
return 0
|
||||
}
|
||||
# ls -ld 解析权限字段(BusyBox 可靠,stat/find -printf 可能不可用)
|
||||
# shellcheck disable=SC2012
|
||||
perm=$(ls -ld "$filepath" 2>/dev/null | awk '{print $1}')
|
||||
[[ -n "$perm" ]] && {
|
||||
echo "$perm"
|
||||
return 0
|
||||
}
|
||||
# GNU find 兜底:-printf 解析数字权限
|
||||
find "$filepath" -maxdepth 0 -printf '%m' 2>/dev/null
|
||||
}
|
||||
|
||||
# 检查密钥文件安全性:必须为当前用户所有,权限为 600。
|
||||
# 严格校验,不允许跳过(防止 config.json 被篡改)。
|
||||
# 读取所有者/权限时依赖 get_file_* 的完整降级链(stat -> ls -> find),
|
||||
# 确保在 stat/find -printf 不可用的系统上仍能取得正确值完成校验。
|
||||
check_secure_file() {
|
||||
local filepath="$1"
|
||||
|
||||
if [[ ! -f "$filepath" ]]; then
|
||||
_log 错误 "密钥文件不存在:$filepath"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local current_user file_owner
|
||||
current_user=$(id -un 2>/dev/null || true)
|
||||
|
||||
file_owner=$(get_file_owner "$filepath")
|
||||
if [[ -z "$file_owner" ]]; then
|
||||
_log 错误 "无法读取文件所有者(系统 stat 命令不兼容)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 仅当能获取当前用户名时才校验所有者(嵌入式系统可能无法解析)
|
||||
if [[ -n "$current_user" ]] && [[ "$file_owner" != "$current_user" ]]; then
|
||||
_log 错误 "文件所有者应为当前用户"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local file_perm
|
||||
file_perm=$(get_file_permission "$filepath")
|
||||
if [[ -z "$file_perm" ]]; then
|
||||
_log 错误 "无法读取文件权限"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 接受符号格式(-rw-------)或数字格式(600)
|
||||
if [[ "$file_perm" != "-rw-------" ]] && [[ "$file_perm" != "600" ]]; then
|
||||
_log 错误 "文件权限应为 600,当前为 $file_perm"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# 从配置文件解析指定键的值。
|
||||
# 支持两种格式:JSON(config.json,主格式)与旧 KEY=VALUE 文本(env/mo_env,
|
||||
# 迁移期或干运行只读回退时使用)。按文件内容自动识别。
|
||||
parse_config_key() {
|
||||
local config_file="$1"
|
||||
local key="$2"
|
||||
|
||||
if jq -e . "$config_file" >/dev/null 2>&1; then
|
||||
# JSON:值统一转字符串(数字/布尔 → 文本)
|
||||
jq -r --arg k "$key" 'if has($k) then (.[$k] | tostring) else empty end' \
|
||||
"$config_file" 2>/dev/null || true
|
||||
else
|
||||
# 旧 KEY=VALUE 文本格式
|
||||
grep -E "^[[:space:]]*${key}=" "$config_file" 2>/dev/null |
|
||||
head -1 |
|
||||
sed -E 's/^[[:space:]]*'"${key}"'=//; s/[[:space:]]*$//' || true
|
||||
fi
|
||||
}
|
||||
|
||||
# 渲染主配置模板 JSON(从 CONFIG_DEFS 单一数据源生成,替代原 CONFIG_TEMPLATE 常量)。
|
||||
# 模板生成(generate_config_template)与白名单键来源(load_config / convert_env_to_json)
|
||||
# 都经此函数——新增配置键只需改 CONFIG_DEFS 一处。
|
||||
render_config_template() {
|
||||
local json="{}" def key val
|
||||
for def in "${CONFIG_DEFS[@]}"; do
|
||||
key="${def%%|*}"
|
||||
val="${def#*|}"
|
||||
json=$(jq -n --argjson o "$json" --arg k "$key" --arg v "$val" '$o + {($k): $v}' 2>/dev/null) || return 1
|
||||
done
|
||||
printf '%s\n' "$json"
|
||||
}
|
||||
|
||||
# 旧 KEY=VALUE 文本配置 → config.json(仅白名单键;注释行忽略)。
|
||||
# 原子写:临时文件 + rename;umask 077 保证权限 600。
|
||||
convert_env_to_json() {
|
||||
local src="$1" dst="$2"
|
||||
local out="{}" key val
|
||||
while IFS= read -r key; do
|
||||
[[ -z "$key" ]] && continue
|
||||
val=$(grep -E "^[[:space:]]*${key}=" "$src" 2>/dev/null |
|
||||
head -1 | sed -E 's/^[[:space:]]*'"${key}"'=//; s/[[:space:]]*$//')
|
||||
out=$(jq -n --argjson o "$out" --arg k "$key" --arg v "$val" '$o + {($k): $v}')
|
||||
done < <(render_config_template | jq -r 'keys[]' 2>/dev/null)
|
||||
local tmp="${dst}.tmp.$$"
|
||||
(umask 077 && printf '%s\n' "$out" | jq . >"$tmp" 2>/dev/null) || {
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
}
|
||||
mv "$tmp" "$dst" 2>/dev/null
|
||||
}
|
||||
|
||||
# 加载 config.json 配置并应用环境变量默认值。
|
||||
# 配置优先级链:CLI > 环境变量 > config.json 文件 > CONFIG_DEFS 默认值。
|
||||
# 采用白名单键加载:键名集合取自 CONFIG_DEFS(render_config_template),逐键提取,
|
||||
# 文件内容永不执行(配置即数据);白名单外的自定义键不生效。
|
||||
load_config() {
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# 读取 config.json 白名单键值(配置文件存在且通过安全检查时)
|
||||
local -A moenv=()
|
||||
if CONFIG_FILE=$(find_config_file); then
|
||||
if ! check_secure_file "$CONFIG_FILE"; then
|
||||
exit 1
|
||||
fi
|
||||
local key
|
||||
while IFS= read -r key; do
|
||||
[[ -z "$key" ]] && continue
|
||||
moenv["$key"]=$(parse_config_key "$CONFIG_FILE" "$key")
|
||||
done < <(
|
||||
render_config_template | jq -r 'keys[]' 2>/dev/null | sort -u
|
||||
)
|
||||
fi
|
||||
|
||||
# 循环加载全部标量键(CLI/环境变量 > config.json > CONFIG_DEFS 默认值):
|
||||
# 已设值(环境变量/CLI)优先;否则取 config 文件值;再否则 CONFIG_DEFS 默认。
|
||||
# 特殊键的 locale 默认/数组拆分/数值校验在循环后做后处理。
|
||||
local def
|
||||
for def in "${CONFIG_DEFS[@]}"; do
|
||||
local ckey="${def%%|*}" cval="${def#*|}"
|
||||
if [[ -z "${!ckey:-}" ]]; then
|
||||
printf -v "$ckey" '%s' "${moenv[$ckey]:-$cval}"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 后处理(循环已加载全部标量键;以下为 locale 默认/数组/校验等特殊逻辑)----
|
||||
|
||||
# 模式标志(循环已按 CONFIG_DEFS 加载;此处仅防环境变量空串覆盖——保留原语义)
|
||||
AUTOMATED="${AUTOMATED:-false}"
|
||||
DRY_RUN="${DRY_RUN:-false}"
|
||||
|
||||
# 目的目录根名(默认跟随系统语言:locale 含 zh → 中文;FOLDER_* 显式设置覆盖)。
|
||||
# 名称与 Jellyfin 官方媒体库名一致:Movies=电影、Shows=节目、Music=音乐、
|
||||
# MusicVideos=音乐视频;Unknown=未知为脚本兜底分类。
|
||||
local _lang="${LANG:-${LC_ALL:-}}"
|
||||
if [[ "$_lang" == zh* ]]; then
|
||||
FOLDER_MOVIES="${FOLDER_MOVIES:-电影}"
|
||||
FOLDER_SHOWS="${FOLDER_SHOWS:-节目}"
|
||||
FOLDER_MUSIC="${FOLDER_MUSIC:-音乐}"
|
||||
FOLDER_MUSICVIDEOS="${FOLDER_MUSICVIDEOS:-音乐视频}"
|
||||
FOLDER_UNKNOWN="${FOLDER_UNKNOWN:-未知}"
|
||||
else
|
||||
FOLDER_MOVIES="${FOLDER_MOVIES:-Movies}"
|
||||
FOLDER_SHOWS="${FOLDER_SHOWS:-Shows}"
|
||||
FOLDER_MUSIC="${FOLDER_MUSIC:-Music}"
|
||||
FOLDER_MUSICVIDEOS="${FOLDER_MUSICVIDEOS:-MusicVideos}"
|
||||
FOLDER_UNKNOWN="${FOLDER_UNKNOWN:-Unknown}"
|
||||
fi
|
||||
|
||||
# 识别池并发数(1-8)
|
||||
[[ "$MEDIA_WORKERS" =~ ^[1-8]$ ]] || MEDIA_WORKERS=4
|
||||
|
||||
# 自动化模式日志初始化(LOG_FILE/SKIP_LOG_FILE/LOG_ROTATE_MB 已由循环加载默认值)
|
||||
if [[ "$AUTOMATED" == "true" ]]; then
|
||||
[[ "$LOG_ROTATE_MB" =~ ^[0-9]+$ ]] || LOG_ROTATE_MB=10
|
||||
# 干运行不创建日志目录(零持久化副作用)
|
||||
if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$SKIP_LOG_FILE")" \
|
||||
2>/dev/null || true
|
||||
# 日志轮转:超阈值时滚动保留 5 份(.1 最新)
|
||||
if ((LOG_ROTATE_MB > 0)); then
|
||||
local _limit=$((LOG_ROTATE_MB * 1024 * 1024)) _lf _size
|
||||
for _lf in "$LOG_FILE" "$SKIP_LOG_FILE"; do
|
||||
[[ -f "$_lf" ]] || continue
|
||||
_size=$(stat -c '%s' "$_lf" 2>/dev/null) || _size=0
|
||||
if ((_size > _limit)); then
|
||||
rm -f "${_lf}.5"
|
||||
mv -f "${_lf}.4" "${_lf}.5" 2>/dev/null || true
|
||||
mv -f "${_lf}.3" "${_lf}.4" 2>/dev/null || true
|
||||
mv -f "${_lf}.2" "${_lf}.3" 2>/dev/null || true
|
||||
mv -f "${_lf}.1" "${_lf}.2" 2>/dev/null || true
|
||||
mv -f "$_lf" "${_lf}.1"
|
||||
_log 信息 "日志已轮转: $_lf -> ${_lf}.1"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 解析扩展名配置(mka 为官方音频容器;纯音频 mkv/mp4/webm 需用户自行改容器为 mka/m4a)
|
||||
IFS=',' read -ra VIDEO_EXTS <<<"${VIDEO_EXTS}"
|
||||
IFS=',' read -ra AUDIO_EXTS <<<"${AUDIO_EXTS}"
|
||||
IFS=',' read -ra SUB_EXTS <<<"${SUB_EXTS}"
|
||||
|
||||
# AI 批大小校验
|
||||
[[ "$AI_BATCH_SIZE" =~ ^[0-9]+$ ]] && [[ "$AI_BATCH_SIZE" -ge 1 ]] || AI_BATCH_SIZE=50
|
||||
|
||||
# 命名模板(v9.5):用户自定义命名格式。占位符语法见 render_naming_template:
|
||||
# {name} 变量值;{name:NN} 数字补零;{?name:text} 条件段(name 非空才渲染 text)。
|
||||
# 默认模板与 v9.4 硬编码命名完全一致(仅把格式外置,行为不变);
|
||||
# 默认值集中定义于 rules.sh 的 naming_template_default(render_naming 兜底共用)。
|
||||
NAMING_MOVIE="${NAMING_MOVIE:-$(naming_template_default NAMING_MOVIE)}"
|
||||
NAMING_SHOW="${NAMING_SHOW:-$(naming_template_default NAMING_SHOW)}"
|
||||
NAMING_SEASON="${NAMING_SEASON:-$(naming_template_default NAMING_SEASON)}"
|
||||
NAMING_EPISODE="${NAMING_EPISODE:-$(naming_template_default NAMING_EPISODE)}"
|
||||
NAMING_SPECIAL="${NAMING_SPECIAL:-$(naming_template_default NAMING_SPECIAL)}"
|
||||
NAMING_MUSIC="${NAMING_MUSIC:-$(naming_template_default NAMING_MUSIC)}"
|
||||
NAMING_MUSICVIDEO="${NAMING_MUSICVIDEO:-$(naming_template_default NAMING_MUSICVIDEO)}"
|
||||
# 模板校验:未知占位符打印警告(渲染时按空值处理,防拼写错误静默丢字段)
|
||||
validate_naming_templates
|
||||
}
|
||||
|
||||
# 选择 TMDB 认证方式并校验。无密钥时交互生成 config.json 模板。
|
||||
select_auth() {
|
||||
TMDB_API_KEY="${TMDB_API_KEY:-}"
|
||||
TMDB_API_RA_TOKEN="${TMDB_API_RA_TOKEN:-}"
|
||||
|
||||
if [[ -n "$TMDB_API_RA_TOKEN" ]]; then
|
||||
TMDB_AUTH_TOKEN="$TMDB_API_RA_TOKEN"
|
||||
TMDB_USE_BEARER=true
|
||||
elif [[ -n "$TMDB_API_KEY" ]]; then
|
||||
TMDB_AUTH_TOKEN="$TMDB_API_KEY"
|
||||
else
|
||||
if [[ "$AUTOMATED" != "true" ]]; then
|
||||
_log 信息 "未检测到 TMDB API 密钥。是否生成 config.json 模板?(y/N)"
|
||||
read -r answer
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
generate_config_template
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
_log 错误 "未提供 TMDB 认证信息。"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 生成 config.json 配置模板(仅交互式调用)。
|
||||
generate_config_template() {
|
||||
mkdir -p "$PWD/mo_config" 2>/dev/null || {
|
||||
_log 错误 "无法创建配置目录 $PWD/mo_config"
|
||||
return 1
|
||||
}
|
||||
local tmpl
|
||||
tmpl=$(render_config_template) || {
|
||||
_log 错误 "配置模板渲染失败"
|
||||
return 1
|
||||
}
|
||||
# 子 shell 设置 umask 077,确保创建的文件权限即 600
|
||||
(umask 077 && printf '%s\n' "$tmpl" >"$PWD/mo_config/config.json") || {
|
||||
_log 错误 "无法写入配置文件 $PWD/mo_config/config.json"
|
||||
return 1
|
||||
}
|
||||
# 兜底:确保权限为 600(不静默失败)
|
||||
chmod 600 "$PWD/mo_config/config.json" || {
|
||||
_log 错误 "无法设置文件权限为 600:$PWD/mo_config/config.json"
|
||||
return 1
|
||||
}
|
||||
_log 信息 "模板已创建: $PWD/mo_config/config.json"
|
||||
}
|
||||
|
||||
# 检查依赖命令是否可用。
|
||||
check_dependencies() {
|
||||
for cmd in curl jq ffprobe; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
_log 错误 "缺少命令 $cmd"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034 # 跨文件共享全局变量(SPECIAL_MAP/SPECIAL_MAP_FILE 等,声明于 main.sh)
|
||||
|
||||
################################################################################
|
||||
# 特典映射、季偏移与类目分区配置
|
||||
################################################################################
|
||||
|
||||
# 迁移单个旧版文件(旧文件名/旧位置 → 新文件名/新位置)。
|
||||
# 干运行只读回退旧路径(零持久化副作用,不搬运不创建);迁移失败同样回退旧路径。
|
||||
# 返回 stdout = 实际应使用的路径。
|
||||
_migrate_legacy_file() {
|
||||
local legacy="$1" new_path="$2"
|
||||
[[ -f "$legacy" ]] || {
|
||||
echo "$new_path"
|
||||
return 0
|
||||
}
|
||||
if [[ "${DRY_RUN:-false}" == "true" ]]; then
|
||||
echo "$legacy"
|
||||
return 0
|
||||
fi
|
||||
if mkdir -p "$(dirname "$new_path")" 2>/dev/null && mv "$legacy" "$new_path" 2>/dev/null; then
|
||||
_log 信息 "已迁移旧版文件: $legacy -> $new_path"
|
||||
echo "$new_path"
|
||||
else
|
||||
echo "$legacy"
|
||||
fi
|
||||
}
|
||||
|
||||
# 解析用户配置类文件(特典映射/特典词表/跳过目录词表/季偏移/特典类别)的默认路径。
|
||||
# 这些文件用户可编辑、脚本也会写回(AI 学习/运行时生成)——本质是配置数据,
|
||||
# 统一存放于配置目录 mo_config/(与可再生的 TMDB 缓存 mo_cache/tmdb/ 分离)。
|
||||
# 旧版自动迁移:新位置不存在时,按优先级检查旧位置(mo_config/、mo_cache/media_organizer/)
|
||||
# × 旧名(special_keymap/special_words/skip_dirs/season_offset 及更早的 mo_ 前缀名),
|
||||
# 首个命中的迁移到新位置;脚本目录同理。
|
||||
# 参数:name = 新文件名;$@ = 旧文件名列表(按优先级)。
|
||||
# 返回 stdout = 实际应使用的路径。
|
||||
resolve_mo_config_file() {
|
||||
local name="$1"
|
||||
shift
|
||||
local pwd_new="$PWD/mo_config/$name"
|
||||
local script_new="$SCRIPT_DIR/mo_config/$name"
|
||||
if [[ -f "$pwd_new" ]]; then
|
||||
echo "$pwd_new"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$script_new" ]]; then
|
||||
echo "$script_new"
|
||||
return 0
|
||||
fi
|
||||
# 旧候选:执行目录(mo_config/ 优先于 mo_cache/media_organizer/),脚本目录同理
|
||||
local root old
|
||||
for root in "$PWD/mo_config" "$PWD/mo_cache/media_organizer"; do
|
||||
for old in "$@"; do
|
||||
if [[ -f "$root/$old" ]]; then
|
||||
_migrate_legacy_file "$root/$old" "$pwd_new"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
done
|
||||
for root in "$SCRIPT_DIR/mo_config" "$SCRIPT_DIR/mo_cache/media_organizer"; do
|
||||
for old in "$@"; do
|
||||
if [[ -f "$root/$old" ]]; then
|
||||
_migrate_legacy_file "$root/$old" "$script_new"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "$pwd_new"
|
||||
}
|
||||
|
||||
# 判定 JSON 文件是否为"类目分区形态"(v9.6):
|
||||
# 分区形态:顶层全部键 ∈ MO_CATEGORY_KEYS(movie/tv/music/musicvideo/video/audio/default/global)
|
||||
# —— 每分区一份表,查询按"类目分区 → default 分区 → 全局表"回退。
|
||||
# 全局形态:其余任意对象(旧版单表,行为不变)。
|
||||
# 混合形态(部分键是类目键、部分不是)→ 按全局形态处理并警告(避免把类目键当数据键)。
|
||||
is_category_partitioned() {
|
||||
local file="$1"
|
||||
local keys total
|
||||
keys=$(jq -r 'keys | length' "$file" 2>/dev/null) || return 1
|
||||
total=$(jq -r 'type' "$file" 2>/dev/null)
|
||||
[[ "$total" == "object" ]] || return 1
|
||||
[[ "$keys" == "0" ]] && return 1
|
||||
if jq -e 'keys | all(. == "movie" or . == "tv" or . == "music" or . == "musicvideo" or . == "video" or . == "audio" or . == "default" or . == "global")' "$file" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
# 混合形态:顶层键是否与保留键集合有交集(有交集 → 疑似写错的分区形态,警告)
|
||||
if jq -e 'keys | any(. == "movie" or . == "tv" or . == "music" or . == "musicvideo" or . == "video" or . == "audio" or . == "default" or . == "global")' "$file" >/dev/null 2>&1; then
|
||||
_log 警告 "配置文件疑似类目分区格式错误(应全部为类目键 movie/tv/music/musicvideo/video/audio/default):$file(按全局表处理)"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 初始化特典映射:解析默认路径,若文件不存在则交互创建。
|
||||
# 默认位置:配置目录 mo_config/special_maps.json(用户可编辑,AI 学习结果也写回此文件);
|
||||
# 旧版文件(special_keymap.json/mo_special_keymap.json 等)自动迁移。
|
||||
# v9.6 支持类目分区形态:{"tv": {...}, "musicvideo": {...}, "default": {...}}。
|
||||
init_special_map() {
|
||||
local file="$1"
|
||||
# 解析默认路径(优先级:环境变量 > 执行目录 mo_config > 脚本目录 mo_config)
|
||||
if [[ -z "$file" ]]; then
|
||||
file=$(resolve_mo_config_file "special_maps.json" "special_keymap.json" "mo_special_keymap.json")
|
||||
SPECIAL_MAP_FILE="$file"
|
||||
fi
|
||||
if [[ ! -f "$file" ]]; then
|
||||
if [[ "$AUTOMATED" == "true" ]]; then
|
||||
_log 警告 "未找到特典映射文件,自动化模式跳过创建: $file"
|
||||
else
|
||||
_log 信息 "未找到特典映射文件: $file"
|
||||
_log 信息 "是否创建默认配置文件?(y/N)"
|
||||
read -r answer || true # EOF(非交互)时跳过创建
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
mkdir -p "$(dirname "$file")" 2>/dev/null || true
|
||||
# 子 shell 设置 umask 077,确保创建的文件权限即 600
|
||||
(umask 077 && printf '%s\n' "$SPECIAL_KEYMAP_TEMPLATE" | jq . >"$file" 2>/dev/null) || true
|
||||
if [[ ! -s "$file" ]]; then
|
||||
echo '{}' >"$file"
|
||||
fi
|
||||
# 兜底:确保权限为 600(不静默失败)
|
||||
chmod 600 "$file" 2>/dev/null || _log 警告 "无法设置文件权限为 600: $file"
|
||||
_log 信息 "已创建特典映射文件: $file"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
load_special_map "$file"
|
||||
}
|
||||
|
||||
# 初始化跳过目录词表(skip_directories.json):解析默认路径,缺失时交互创建或使用内置默认。
|
||||
# 默认位置:配置目录 mo_config/(用户可编辑,AI 学习会写回新目录);旧版文件自动迁移。
|
||||
# 语义 = 不作为媒体名目录的目录词(特典/附带 + 分类目录);内容不当作正则(精确匹配)。
|
||||
# v9.6 支持类目分区形态:{"video": [...], "music": [...], "default": [...]}——
|
||||
# 视频流程(movie/tv/musicvideo 的目录语义判断)查 video 分区,音频流程查 music 分区,
|
||||
# 未配置回退 default 分区 → 全局表。**分区形态 = 完整语义**(不叠加内置默认;
|
||||
# 通用词放 default 分区)——避免 music 流程误命中 video 侧内置词(如 "sps")。
|
||||
init_skip_dirs() {
|
||||
local file="$1"
|
||||
if [[ -z "$file" ]]; then
|
||||
file=$(resolve_mo_config_file "skip_directories.json" "skip_dirs.json" "mo_skip_dirs.json")
|
||||
SKIP_DIRS_FILE="$file"
|
||||
fi
|
||||
if [[ ! -f "$file" ]]; then
|
||||
if [[ "$AUTOMATED" == "true" ]]; then
|
||||
_log 警告 "未找到跳过目录词表文件,自动化模式使用内置默认: $file"
|
||||
else
|
||||
_log 信息 "未找到跳过目录词表文件: $file"
|
||||
_log 信息 "是否创建默认配置文件?(y/N)"
|
||||
read -r answer || true # EOF(非交互)时跳过创建
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
(umask 077 && printf '%s\n' "$SKIP_DIRS_TEMPLATE" | jq . >"$file" 2>/dev/null) || true
|
||||
if [[ ! -s "$file" ]]; then
|
||||
echo '[]' >"$file"
|
||||
fi
|
||||
chmod 600 "$file" 2>/dev/null || _log 警告 "无法设置文件权限为 600: $file"
|
||||
_log 信息 "已创建跳过目录词表文件: $file"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
SKIP_DIRS=()
|
||||
SKIP_DIRS_LOWER=()
|
||||
SKIP_DIRS_CAT=()
|
||||
local partitioned=false
|
||||
if [[ -f "$file" ]] && jq -e 'type == "array"' "$file" >/dev/null 2>&1; then
|
||||
mapfile -t SKIP_DIRS < <(jq -r '.[] | select(. != "")' "$file" 2>/dev/null)
|
||||
elif [[ -f "$file" ]] && is_category_partitioned "$file"; then
|
||||
partitioned=true
|
||||
# 分区形态:分区词存入 SKIP_DIRS_CAT(键 = "类目|小写目录名");全局数组保持空
|
||||
# (完整语义,不叠加内置默认——通用词由 default 分区提供)
|
||||
local cat d
|
||||
while IFS=$'\t' read -r cat d; do
|
||||
[[ -z "$cat" || -z "$d" ]] && continue
|
||||
SKIP_DIRS_CAT["$cat|${d,,}"]=1
|
||||
done < <(jq -r 'to_entries[] | .key as $c | .value[]? | select(. != "") | "\($c)\t\(.)"' "$file" 2>/dev/null)
|
||||
_log 调试 "跳过目录词表(分区): ${#SKIP_DIRS_CAT[@]} 项"
|
||||
fi
|
||||
# 全局形态(或文件缺失):数组为空 → 内置默认(查询兜底);分区形态不回退内置
|
||||
if [[ "$partitioned" != "true" ]] && [[ ${#SKIP_DIRS[@]} -eq 0 ]]; then
|
||||
mapfile -t SKIP_DIRS < <(echo "$SKIP_DIRS_TEMPLATE" | jq -r '.[]')
|
||||
fi
|
||||
local d
|
||||
for d in "${SKIP_DIRS[@]}"; do
|
||||
SKIP_DIRS_LOWER+=("${d,,}")
|
||||
done
|
||||
_log 调试 "跳过目录词表 ${#SKIP_DIRS[@]} 个(全局兜底)"
|
||||
}
|
||||
|
||||
# 初始化特典识别词表(special_keywords.json):解析默认路径,缺失时交互创建或使用内置默认。
|
||||
# 默认位置:配置目录 mo_config/(用户可编辑,AI 学习会写回新词);旧版文件自动迁移。
|
||||
# 词表语义 = 特典类别词(Menu/CM/PV 等);匹配对空格不敏感;内容不当作正则。
|
||||
# v9.6 支持类目分区形态:{"tv": [...], "musicvideo": [...], "default": [...]}——
|
||||
# tv 分区 = 特典判定词(detect_special);musicvideo 分区 = 音乐视频判定词
|
||||
# (目录信号 + 文件名信号);查询按"类目分区 → default 分区 → 全局表 → 内置默认"。
|
||||
init_special_words() {
|
||||
local file="$1"
|
||||
if [[ -z "$file" ]]; then
|
||||
file=$(resolve_mo_config_file "special_keywords.json" "special_words.json" "mo_special_words.json")
|
||||
SPECIAL_WORDS_FILE="$file"
|
||||
fi
|
||||
if [[ ! -f "$file" ]]; then
|
||||
if [[ "$AUTOMATED" == "true" ]]; then
|
||||
_log 警告 "未找到特典词表文件,自动化模式使用内置默认: $file"
|
||||
else
|
||||
_log 信息 "未找到特典词表文件: $file"
|
||||
_log 信息 "是否创建默认配置文件?(y/N)"
|
||||
read -r answer || true # EOF(非交互)时跳过创建
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
mkdir -p "$(dirname "$file")" 2>/dev/null || true
|
||||
(umask 077 && printf '%s\n' "$SPECIAL_WORDS_TEMPLATE" | jq . >"$file" 2>/dev/null) || true
|
||||
if [[ ! -s "$file" ]]; then
|
||||
echo '[]' >"$file"
|
||||
fi
|
||||
chmod 600 "$file" 2>/dev/null || _log 警告 "无法设置文件权限为 600: $file"
|
||||
_log 信息 "已创建特典词表文件: $file"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# 加载词表(全局形态 = JSON 数组);缺失/非法/空 → 内置默认(查询兜底)
|
||||
SPECIAL_WORDS=()
|
||||
SPECIAL_WORDS_CAT=()
|
||||
MUSICVIDEO_WORDS=()
|
||||
# 音乐视频判定词:内置默认(独立于 tv 特典词表,避免 "sp" 命中 "SPs" 目录等交叉误判)
|
||||
mapfile -t MUSICVIDEO_WORDS < <(echo "$MUSICVIDEO_WORDS_TEMPLATE" | jq -r '.[]')
|
||||
if [[ -f "$file" ]] && jq -e 'type == "array"' "$file" >/dev/null 2>&1; then
|
||||
mapfile -t SPECIAL_WORDS < <(jq -r '.[] | select(. != "")' "$file" 2>/dev/null)
|
||||
elif [[ -f "$file" ]] && is_category_partitioned "$file"; then
|
||||
# 分区形态:词按归一化(去空格+小写)存入 SPECIAL_WORDS_CAT(键 = "类目|词")
|
||||
local cat w w_norm
|
||||
while IFS=$'\t' read -r cat w; do
|
||||
[[ -z "$cat" || -z "$w" ]] && continue
|
||||
w_norm="${w//[[:space:]]/}"
|
||||
w_norm="${w_norm,,}"
|
||||
[[ -z "$w_norm" ]] && continue
|
||||
SPECIAL_WORDS_CAT["$cat|$w_norm"]=1
|
||||
done < <(jq -r 'to_entries[] | .key as $c | .value[]? | select(. != "") | "\($c)\t\(.)"' "$file" 2>/dev/null)
|
||||
_log 调试 "特典识别词表(分区): ${#SPECIAL_WORDS_CAT[@]} 项"
|
||||
fi
|
||||
if [[ ${#SPECIAL_WORDS[@]} -eq 0 ]]; then
|
||||
mapfile -t SPECIAL_WORDS < <(echo "$SPECIAL_WORDS_TEMPLATE" | jq -r '.[]')
|
||||
fi
|
||||
_log 调试 "特典识别词表 ${#SPECIAL_WORDS[@]} 个(全局兜底);音乐视频词表 ${#MUSICVIDEO_WORDS[@]} 个"
|
||||
}
|
||||
|
||||
# 加载特典映射(JSON 对象:{"本地关键字符串": 值} 或 v9.6 分区形态 {"tv": {...}})。
|
||||
# 构建 SPECIAL_MAP(全局表)与 SPECIAL_MAP_CAT(类目分区表)。
|
||||
# 值可为:① JSON 数组(直接多值,如 ["Menu","菜单","メニュー"]);
|
||||
# ② 字符串(引用另一键的数组,如 "menu01": "menu" 复用 menu 的数组)。
|
||||
# —— 不同压制组特典命名不同(Menu01/Menu 01/MENU01),多键共享同一组 TMDB 关键字,避免重复定义。
|
||||
load_special_map() {
|
||||
local map_file="$1"
|
||||
[[ ! -f "$map_file" ]] && return
|
||||
|
||||
if ! jq -e 'type == "object"' "$map_file" >/dev/null 2>&1; then
|
||||
_log 错误 "特典映射文件必须为 JSON 对象格式: $map_file"
|
||||
return
|
||||
fi
|
||||
|
||||
SPECIAL_MAP=()
|
||||
RAW_SPECIAL_MAP=()
|
||||
SPECIAL_MAP_CAT=()
|
||||
RAW_SPECIAL_MAP_CAT=()
|
||||
|
||||
if is_category_partitioned "$map_file"; then
|
||||
# 分区形态:第一遍读原始值("类目|键" → 数组或字符串引用)
|
||||
local cat local_str raw
|
||||
while IFS=$'\t' read -r cat local_str raw; do
|
||||
[[ -z "$cat" || -z "$local_str" ]] && continue
|
||||
RAW_SPECIAL_MAP_CAT["$cat|${local_str,,}"]="$raw"
|
||||
done < <(jq -r 'to_entries[] | .key as $c | .value | to_entries[] | "\($c)\t\(.key)\t\(.value | @json)"' "$map_file" 2>/dev/null)
|
||||
# 第二遍:展开引用(同分区优先,其次全局表),值统一为 JSON 数组
|
||||
local ck
|
||||
for ck in "${!RAW_SPECIAL_MAP_CAT[@]}"; do
|
||||
SPECIAL_MAP_CAT["$ck"]="$(resolve_special_value_cat "$ck")"
|
||||
done
|
||||
_log 调试 "特典映射(分区): ${#SPECIAL_MAP_CAT[@]} 项"
|
||||
return
|
||||
fi
|
||||
|
||||
# 全局形态(旧版):第一遍读取全部原始值(数组或字符串引用)
|
||||
local local_str raw
|
||||
while IFS=$'\t' read -r local_str raw; do
|
||||
[[ -z "$local_str" ]] && continue
|
||||
RAW_SPECIAL_MAP["${local_str,,}"]="$raw"
|
||||
done < <(jq -r 'to_entries[] | "\(.key)\t\(.value | @json)"' "$map_file")
|
||||
|
||||
# 第二遍:展开引用(多键→多值),SPECIAL_MAP 值统一为 JSON 数组
|
||||
local key
|
||||
for key in "${!RAW_SPECIAL_MAP[@]}"; do
|
||||
SPECIAL_MAP["$key"]="$(resolve_special_value "$key")"
|
||||
done
|
||||
}
|
||||
|
||||
# 解析特典键的最终数组值:值为数组直接返回;值为字符串视为引用另一键(递归展开引用链,防循环)。
|
||||
# 引用键不存在时把引用串本身当单元素数组。
|
||||
resolve_special_value() {
|
||||
local key="$1" depth="${2:-0}" v ref
|
||||
[[ $depth -gt 20 ]] && {
|
||||
echo "[]"
|
||||
return
|
||||
}
|
||||
v="${RAW_SPECIAL_MAP[$key]:-}"
|
||||
if [[ -n "$v" ]] && echo "$v" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
||||
echo "$v"
|
||||
elif [[ -z "$v" ]]; then
|
||||
# 键不存在:视为单元素数组
|
||||
jq -n --arg v "$key" '[$v]'
|
||||
else
|
||||
ref="${v#\"}"
|
||||
ref="${ref%\"}"
|
||||
if [[ -n "${RAW_SPECIAL_MAP[$ref]:-}" ]]; then
|
||||
resolve_special_value "$ref" $((depth + 1))
|
||||
else
|
||||
jq -n --arg v "$ref" '[$v]'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 分区形态的特典键引用展开:引用解析"同分区优先,其次全局表"。
|
||||
# 参数:ck = "类目|键"。
|
||||
resolve_special_value_cat() {
|
||||
local ck="$1" depth="${2:-0}" cat key v ref
|
||||
[[ $depth -gt 20 ]] && {
|
||||
echo "[]"
|
||||
return
|
||||
}
|
||||
cat="${ck%%|*}"
|
||||
key="${ck#*|}"
|
||||
v="${RAW_SPECIAL_MAP_CAT[$ck]:-}"
|
||||
if [[ -n "$v" ]] && echo "$v" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
||||
echo "$v"
|
||||
elif [[ -z "$v" ]]; then
|
||||
jq -n --arg v "$key" '[$v]'
|
||||
else
|
||||
ref="${v#\"}"
|
||||
ref="${ref%\"}"
|
||||
if [[ -n "${RAW_SPECIAL_MAP_CAT["$cat|$ref"]:-}" ]]; then
|
||||
resolve_special_value_cat "$cat|$ref" $((depth + 1))
|
||||
elif [[ -n "${RAW_SPECIAL_MAP[$ref]:-}" ]]; then
|
||||
resolve_special_value "$ref" $((depth + 1))
|
||||
else
|
||||
jq -n --arg v "$ref" '[$v]'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 特典映射查询:类目分区 → default 分区 → 全局表(值 = TMDB 可匹配关键字 JSON 数组)。
|
||||
special_map_value() {
|
||||
local cat="$1" key="$2"
|
||||
local v
|
||||
v="${SPECIAL_MAP_CAT["$cat|$key"]:-}"
|
||||
[[ -n "$v" ]] && {
|
||||
echo "$v"
|
||||
return 0
|
||||
}
|
||||
v="${SPECIAL_MAP_CAT["default|$key"]:-}"
|
||||
[[ -n "$v" ]] && {
|
||||
echo "$v"
|
||||
return 0
|
||||
}
|
||||
echo "${SPECIAL_MAP[$key]:-}"
|
||||
}
|
||||
|
||||
# 类目判定词子串命中查询(v9.6):类目分区 → default 分区 → 该类目全局兜底数组
|
||||
# (tv → SPECIAL_WORDS 全局特典词;musicvideo → MUSICVIDEO_WORDS 内置默认,不回退 tv 词表)。
|
||||
# 参数:类目 + 归一化字符串(去空格小写);stdout = 命中的归一化词;未命中返回 1。
|
||||
special_word_hit() {
|
||||
local cat="$1" s="$2"
|
||||
[[ -z "$s" ]] && return 1
|
||||
local ck w
|
||||
for ck in "${!SPECIAL_WORDS_CAT[@]}"; do
|
||||
case "$ck" in
|
||||
"$cat|"* | "default|"*)
|
||||
w="${ck#*|}"
|
||||
[[ -n "$w" && "$s" == *"$w"* ]] && {
|
||||
echo "$w"
|
||||
return 0
|
||||
}
|
||||
;;
|
||||
esac
|
||||
done
|
||||
local -a fb=()
|
||||
if [[ "$cat" == "musicvideo" ]]; then
|
||||
fb=("${MUSICVIDEO_WORDS[@]}")
|
||||
else
|
||||
fb=("${SPECIAL_WORDS[@]}")
|
||||
fi
|
||||
local i wn
|
||||
for i in "${fb[@]}"; do
|
||||
wn="${i//[[:space:]]/}"
|
||||
wn="${wn,,}"
|
||||
[[ -n "$wn" && "$s" == *"$wn"* ]] && {
|
||||
echo "$wn"
|
||||
return 0
|
||||
}
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 类目判定词精确命中查询(v9.6):类目分区 → default 分区 → 该类目全局兜底数组。
|
||||
# 归一化后**整串相等**(目录信号用——"tmp.xxx"/"Muv-Luv" 等含词目录名不误判;
|
||||
# 子串语义见 special_word_hit)。参数:类目 + 归一化字符串。命中返回 0。
|
||||
special_word_exact() {
|
||||
local cat="$1" s="$2"
|
||||
[[ -z "$s" ]] && return 1
|
||||
local ck w
|
||||
for ck in "${!SPECIAL_WORDS_CAT[@]}"; do
|
||||
case "$ck" in
|
||||
"$cat|"* | "default|"*)
|
||||
w="${ck#*|}"
|
||||
[[ -n "$w" && "$s" == "$w" ]] && return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
local -a fb=()
|
||||
if [[ "$cat" == "musicvideo" ]]; then
|
||||
fb=("${MUSICVIDEO_WORDS[@]}")
|
||||
else
|
||||
fb=("${SPECIAL_WORDS[@]}")
|
||||
fi
|
||||
local i wn
|
||||
for i in "${fb[@]}"; do
|
||||
wn="${i//[[:space:]]/}"
|
||||
wn="${wn,,}"
|
||||
[[ -n "$wn" && "$s" == "$wn" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 跳过目录精确命中查询(v9.6):流程分区(video/audio)→ default 分区 → 全局表 → 内置默认。
|
||||
# 参数:流程类目 + 小写目录名。命中返回 0。
|
||||
skip_dir_in() {
|
||||
local cat="$1" d="$2"
|
||||
[[ -n "${SKIP_DIRS_CAT["$cat|$d"]:-}" ]] && return 0
|
||||
[[ -n "${SKIP_DIRS_CAT["default|$d"]:-}" ]] && return 0
|
||||
local i
|
||||
for i in "${SKIP_DIRS_LOWER[@]}"; do
|
||||
[[ "$i" == "$d" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 季偏移查询(v9.6):类目分区(tv)→ default 分区 → 全局表(含内置默认)。
|
||||
season_offset_get() {
|
||||
local cat="$1" name="$2"
|
||||
local v
|
||||
v="${SEASON_OFFSET_CAT["$cat|$name"]:-}"
|
||||
[[ -n "$v" ]] && {
|
||||
echo "$v"
|
||||
return 0
|
||||
}
|
||||
v="${SEASON_OFFSET_CAT["default|$name"]:-}"
|
||||
[[ -n "$v" ]] && {
|
||||
echo "$v"
|
||||
return 0
|
||||
}
|
||||
echo "${SEASON_OFFSET_MAP[$name]:-}"
|
||||
}
|
||||
|
||||
# 初始化季偏移配置:加载外部 JSON,缺失时用内置默认值生成。
|
||||
# 默认位置:配置目录 mo_config/season_offsets.json(用户可编辑,缺失时脚本自动生成);
|
||||
# 旧版文件(season_offset.json 等)自动迁移。
|
||||
# v9.6 支持类目分区形态:{"tv": {...}, "default": {...}}(季偏移仅 tv 有语义,其余预留)。
|
||||
init_season_offset() {
|
||||
SEASON_OFFSET_FILE="${SEASON_OFFSET_FILE:-}"
|
||||
# 解析默认路径(优先级:环境变量 > 执行目录 mo_config > 脚本目录 mo_config)
|
||||
if [[ -z "$SEASON_OFFSET_FILE" ]]; then
|
||||
SEASON_OFFSET_FILE=$(resolve_mo_config_file "season_offsets.json" "season_offset.json")
|
||||
fi
|
||||
|
||||
SEASON_OFFSET_MAP=()
|
||||
SEASON_OFFSET_CAT=()
|
||||
|
||||
# 内置季偏移默认值(来自 SEASON_OFFSET_TEMPLATE 常量,作全局兜底)
|
||||
if [[ -n "$SEASON_OFFSET_TEMPLATE" ]]; then
|
||||
while IFS=$'\t' read -r k v; do
|
||||
[[ -z "$k" ]] && continue
|
||||
SEASON_OFFSET_MAP["${k,,}"]="$v"
|
||||
done < <(printf '%s\n' "$SEASON_OFFSET_TEMPLATE" | jq -r 'to_entries[] | "\(.key)\t\(.value)"')
|
||||
fi
|
||||
|
||||
# 加载外部 JSON 配置覆盖默认值(全局形态直接覆盖全局表;分区形态写入分区表)
|
||||
if [[ -f "$SEASON_OFFSET_FILE" ]]; then
|
||||
if jq -e 'type == "object"' "$SEASON_OFFSET_FILE" >/dev/null 2>&1; then
|
||||
if is_category_partitioned "$SEASON_OFFSET_FILE"; then
|
||||
local cat key value
|
||||
while IFS=$'\t' read -r cat key value; do
|
||||
[[ -z "$cat" || -z "$key" ]] && continue
|
||||
SEASON_OFFSET_CAT["$cat|${key,,}"]="$value"
|
||||
done < <(jq -r 'to_entries[] | .key as $c | .value | to_entries[] | "\($c)\t\(.key)\t\(.value)"' "$SEASON_OFFSET_FILE" 2>/dev/null)
|
||||
else
|
||||
while IFS=$'\t' read -r key value; do
|
||||
[[ -z "$key" ]] && continue
|
||||
SEASON_OFFSET_MAP["${key,,}"]="$value"
|
||||
done < <(jq -r 'to_entries[] | "\(.key)\t\(.value)"' "$SEASON_OFFSET_FILE")
|
||||
fi
|
||||
else
|
||||
_log 错误 "季偏移配置文件必须为 JSON 对象格式: $SEASON_OFFSET_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 生成默认季偏移配置文件(JSON 对象格式,全局形态;用户可自行改分区)
|
||||
if [[ ! -f "$SEASON_OFFSET_FILE" ]]; then
|
||||
mkdir -p "$(dirname "$SEASON_OFFSET_FILE")" 2>/dev/null || true
|
||||
offset_json="{}"
|
||||
for key in "${!SEASON_OFFSET_MAP[@]}"; do
|
||||
offset_json=$(printf '%s' "$offset_json" | jq --arg k "$key" --argjson v "${SEASON_OFFSET_MAP[$key]}" '.[$k] = $v')
|
||||
done
|
||||
printf '%s\n' "$offset_json" | jq . >"$SEASON_OFFSET_FILE" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# 初始化特典类别判定表(special_categories.json,v9.6 外置):解析默认路径,缺失时交互创建。
|
||||
# 默认位置:配置目录 mo_config/(用户可编辑);语义 = S00 未匹配特典的类别标签判定
|
||||
# (fragment 子串匹配 → 类别标签,数组顺序 = 优先级)。缺失/自动化模式 → 空表
|
||||
# (special_category_tag 回退内置默认表)。
|
||||
init_special_categories() {
|
||||
local file="$1"
|
||||
if [[ -z "$file" ]]; then
|
||||
file=$(resolve_mo_config_file "special_categories.json")
|
||||
SPECIAL_CATEGORIES_FILE="$file"
|
||||
fi
|
||||
if [[ ! -f "$file" ]]; then
|
||||
if [[ "$AUTOMATED" == "true" ]]; then
|
||||
_log 警告 "未找到特典类别判定表文件,自动化模式使用内置默认: $file"
|
||||
else
|
||||
_log 信息 "未找到特典类别判定表文件: $file"
|
||||
_log 信息 "是否创建默认配置文件?(y/N)"
|
||||
read -r answer || true # EOF(非交互)时跳过创建
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
mkdir -p "$(dirname "$file")" 2>/dev/null || true
|
||||
(umask 077 && printf '%s\n' "$SPECIAL_CATEGORIES_TEMPLATE" | jq . >"$file" 2>/dev/null) || true
|
||||
if [[ ! -s "$file" ]]; then
|
||||
echo '[]' >"$file"
|
||||
fi
|
||||
chmod 600 "$file" 2>/dev/null || _log 警告 "无法设置文件权限为 600: $file"
|
||||
_log 信息 "已创建特典类别判定表文件: $file"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
load_special_categories "$file"
|
||||
}
|
||||
|
||||
# 加载特典类别判定表(JSON 数组:[{"match": "...", "tag": "..."}],顺序 = 优先级)。
|
||||
# 元素存入 SPECIAL_CATEGORIES("match|tag");文件缺失/非法/空 → 空表(回退内置默认)。
|
||||
load_special_categories() {
|
||||
local file="$1"
|
||||
SPECIAL_CATEGORIES=()
|
||||
[[ -f "$file" ]] || return 0
|
||||
if ! jq -e 'type == "array"' "$file" >/dev/null 2>&1; then
|
||||
_log 错误 "特典类别判定表必须为 JSON 数组格式: $file"
|
||||
return 0
|
||||
fi
|
||||
local kw tag
|
||||
while IFS=$'\t' read -r kw tag; do
|
||||
[[ -z "$kw" || -z "$tag" ]] && continue
|
||||
SPECIAL_CATEGORIES+=("${kw,,}|$tag")
|
||||
done < <(jq -r '.[] | select(.match != "" and .tag != "") | "\(.match)\t\(.tag)"' "$file" 2>/dev/null)
|
||||
_log 调试 "特典类别判定表 ${#SPECIAL_CATEGORIES[@]} 项"
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034 # 跨文件共享全局变量(MATCH_RULES_FILE,声明于 main.sh)
|
||||
|
||||
################################################################################
|
||||
# 用户自定义规则:命名模板与匹配规则
|
||||
#
|
||||
# 命名模板(NAMING_* 配置键):把"命名格式化"从硬编码改为用户可配置的模板。
|
||||
# 匹配规则(match_rules.json):用户手工维护的确定性规则——标题别名(搜索词
|
||||
# 纠正)与 ID 映射(跳过搜索直接使用指定 TMDB ID),优先级高于目录名兜底与 AI。
|
||||
################################################################################
|
||||
|
||||
# 查找条件段 {?name:...} 的配对右花括号位置({} 深度计数,支持正文含嵌套占位符)。
|
||||
# 参数:以 "{?" 开头的字符串;输出:配对 } 的下标(0 起),无法配对输出 -1。
|
||||
match_conditional_close() {
|
||||
local s="$1" i depth=1 c
|
||||
for ((i = 1; i < ${#s}; i++)); do
|
||||
c="${s:i:1}"
|
||||
if [[ "$c" == "{" ]]; then
|
||||
depth=$((depth + 1))
|
||||
elif [[ "$c" == "}" ]]; then
|
||||
depth=$((depth - 1))
|
||||
((depth == 0)) && {
|
||||
echo "$i"
|
||||
return 0
|
||||
}
|
||||
fi
|
||||
done
|
||||
echo -1
|
||||
}
|
||||
|
||||
# 内置命名模板默认值(集中于此:config.sh 加载与 render_naming 兜底共用,
|
||||
# 保证"未配置时"与"配置默认值"行为一致)。
|
||||
naming_template_default() {
|
||||
case "$1" in
|
||||
NAMING_MOVIE) echo '{title}{?year: ({year})}' ;;
|
||||
NAMING_SHOW) echo '{title}{?year: ({year})}' ;;
|
||||
NAMING_SEASON) echo 'Season {season:02}' ;;
|
||||
NAMING_EPISODE) echo 'S{season:02}E{episode:02}{range}{?episode_name: - {episode_name}}' ;;
|
||||
NAMING_SPECIAL) echo 'S00{tag} - {fragment}' ;;
|
||||
NAMING_MUSIC) echo '{artist}/{album}' ;;
|
||||
NAMING_MUSICVIDEO) echo '{artist}/{title}' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 按配置键渲染命名模板(键未配置/为空时用内置默认——覆盖测试环境与
|
||||
# 未走 load_config 的调用路径)。参数:配置键名 + render_naming_template 变量对。
|
||||
render_naming() {
|
||||
local key="$1"
|
||||
shift
|
||||
local tmpl
|
||||
tmpl="${!key:-$(naming_template_default "$key")}"
|
||||
render_naming_template "$tmpl" "$@"
|
||||
}
|
||||
|
||||
# 渲染命名模板。第一个参数为模板,其余为 name=value 变量对(值可为空)。
|
||||
# 占位符语法:
|
||||
# {name} → 变量值;未定义的变量渲染为空串(加载期校验会警告未知名)
|
||||
# {name:NN} → 数字补零至 NN 位(非数字保持原值)
|
||||
# {?name:text} → 条件段:name 非空时渲染 text;text 内可含其他占位符
|
||||
# (text 内不支持嵌套条件段 {?...};值内不应含 { })
|
||||
# 实现要点:逐 token 扫描拼接(不用 ${var//pat/repl} 全局替换——替换串中的 &
|
||||
# 会被 bash 当作"匹配整体"展开,文件名含 &(如 "MYTH & ROID")时损坏)。
|
||||
render_naming_template() {
|
||||
local tmpl="$1"
|
||||
shift
|
||||
local depth="${RENDER_DEPTH:-0}"
|
||||
if ((depth > 10)); then
|
||||
_log 警告 "命名模板条件段嵌套过深,按字面保留: $tmpl"
|
||||
printf '%s' "$tmpl"
|
||||
return 0
|
||||
fi
|
||||
local -A vars=()
|
||||
local kv name val
|
||||
for kv in "$@"; do
|
||||
name="${kv%%=*}"
|
||||
[[ -z "$name" ]] && continue
|
||||
val="${kv#*=}"
|
||||
# 剔除值中的花括号(破坏模板解析的字符;文件名中花括号极罕见)
|
||||
val="${val//\{/}"
|
||||
val="${val//\}/}"
|
||||
vars["$name"]="$val"
|
||||
done
|
||||
|
||||
local out="" rest="$tmpl" pre token tname spec guard=0
|
||||
while [[ "$rest" == *"{"* ]]; do
|
||||
guard=$((guard + 1))
|
||||
if ((guard > 100)); then
|
||||
_log 警告 "命名模板解析超限,截断处理: $tmpl"
|
||||
break
|
||||
fi
|
||||
if [[ "$rest" == '{?'* ]]; then
|
||||
# 条件段:配对 } 内的正文整体取出(可含嵌套占位符),name 非空时递归渲染
|
||||
local close_i close_s cond_all cond_name cond_body
|
||||
close_i=$(match_conditional_close "$rest")
|
||||
if ((close_i < 0)); then
|
||||
out+="${rest:0:1}"
|
||||
rest="${rest:1}"
|
||||
continue
|
||||
fi
|
||||
close_s="${rest:0:close_i}"
|
||||
rest="${rest:close_i+1}"
|
||||
cond_all="${close_s:2}"
|
||||
if [[ "$cond_all" == *:* ]]; then
|
||||
cond_name="${cond_all%%:*}"
|
||||
cond_body="${cond_all#*:}"
|
||||
if [[ -n "${vars[$cond_name]:-}" ]]; then
|
||||
out+="$(RENDER_DEPTH=$((depth + 1)) render_naming_template "$cond_body" "$@")"
|
||||
fi
|
||||
else
|
||||
# 无正文形式 {?name}:等价于普通占位符(仅条件段语义)
|
||||
out+="${vars[$cond_all]:-}"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
pre="${rest%%\{*}"
|
||||
out+="$pre"
|
||||
rest="${rest#*"{"}"
|
||||
if [[ "$rest" != *"}"* ]]; then
|
||||
# 无配对右花括号:剩余部分按字面保留(避免吞掉用户模板尾部)
|
||||
out+="{$rest"
|
||||
rest=""
|
||||
break
|
||||
fi
|
||||
token="${rest%%\}*}"
|
||||
rest="${rest#*"}"}"
|
||||
if [[ -z "$token" ]]; then
|
||||
out+="{}"
|
||||
continue
|
||||
fi
|
||||
# 普通占位符 {name} / {name:NN}
|
||||
tname="$token"
|
||||
spec=""
|
||||
if [[ "$tname" == *:* ]]; then
|
||||
spec="${tname##*:}"
|
||||
tname="${tname%%:*}"
|
||||
fi
|
||||
val="${vars[$tname]:-}"
|
||||
if [[ -n "$spec" ]] && [[ "$spec" =~ ^[0-9]+$ ]] && [[ "$val" =~ ^[0-9]+$ ]]; then
|
||||
val=$(printf "%0*d" "$spec" "$((10#$val))")
|
||||
fi
|
||||
out+="$val"
|
||||
done
|
||||
out+="$rest"
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# 提取模板中的占位符名({name} / {name:NN} / {?name:text} 的 name;含条件段正文内)。
|
||||
# 输出:每行一个占位符名(已去 ? 前缀),调用方可 sort -u 去重。
|
||||
template_token_names() {
|
||||
local t="$1" i=0 token tname body
|
||||
while [[ "$t" == *"{"* ]]; do
|
||||
i=$((i + 1))
|
||||
((i > 200)) && break
|
||||
if [[ "$t" == '{?'* ]]; then
|
||||
local ci
|
||||
ci=$(match_conditional_close "$t")
|
||||
if ((ci < 0)); then
|
||||
t="${t:1}"
|
||||
continue
|
||||
fi
|
||||
token="${t:0:ci}"
|
||||
tname="${token:2}"
|
||||
body="${tname#*:}"
|
||||
tname="${tname%%:*}"
|
||||
[[ -n "$tname" ]] && echo "$tname"
|
||||
# 正文重新入队(正文内占位符同样参与校验)
|
||||
t="${body}${t:ci+1}"
|
||||
continue
|
||||
fi
|
||||
t="${t#*"{"}"
|
||||
[[ "$t" != *"}"* ]] && break
|
||||
token="${t%%\}*}"
|
||||
t="${t#*"}"}"
|
||||
tname="${token%%:*}"
|
||||
[[ -n "$tname" ]] && echo "$tname"
|
||||
done
|
||||
}
|
||||
|
||||
# 校验命名模板:占位符名须在允许集合内(未知名渲染为空,打印警告防拼写错误)。
|
||||
# 参数:模板 + 允许的变量名列表(空格分隔)+ 用途标签。
|
||||
validate_naming_template() {
|
||||
local tmpl="$1" allow="$2" label="$3"
|
||||
[[ -z "$tmpl" ]] && return 0
|
||||
local tname
|
||||
while IFS= read -r tname; do
|
||||
if [[ " $allow " != *" $tname "* ]]; then
|
||||
_log 警告 "命名模板 [$label] 含未知占位符 {${tname}}(渲染为空):$tmpl"
|
||||
fi
|
||||
done < <(template_token_names "$tmpl" | sort -u)
|
||||
}
|
||||
|
||||
# 校验全部命名模板(load_config 调用;允许集合与各模板用途对应)。
|
||||
validate_naming_templates() {
|
||||
validate_naming_template "${NAMING_MOVIE:-}" "title year" "NAMING_MOVIE"
|
||||
validate_naming_template "${NAMING_SHOW:-}" "title year" "NAMING_SHOW"
|
||||
validate_naming_template "${NAMING_SEASON:-}" "season" "NAMING_SEASON"
|
||||
validate_naming_template "${NAMING_EPISODE:-}" "season episode range episode_name episode_end" "NAMING_EPISODE"
|
||||
validate_naming_template "${NAMING_SPECIAL:-}" "tag fragment" "NAMING_SPECIAL"
|
||||
validate_naming_template "${NAMING_MUSIC:-}" "artist album" "NAMING_MUSIC"
|
||||
validate_naming_template "${NAMING_MUSICVIDEO:-}" "artist title" "NAMING_MUSICVIDEO"
|
||||
}
|
||||
|
||||
# 匹配规则键归一化:小写 + 连续空白折叠为单空格 + 去首尾空白。
|
||||
# 与 parse 产出的 clean 标题对齐(键写文件名清洗后的标题即可)。
|
||||
rule_key() {
|
||||
printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -s '[:space:]' ' ' |
|
||||
sed -E 's/^ +| +$//g'
|
||||
}
|
||||
|
||||
# 初始化匹配规则文件(match_rules.json):解析默认路径,缺失时交互创建。
|
||||
# 默认位置:配置目录 mo_config/(用户可编辑);与特典映射等配置文件同构。
|
||||
# 规则语义 = 用户手工维护的确定性匹配(搜索别名 + ID 映射),不参与 AI 学习写回。
|
||||
init_match_rules() {
|
||||
local file="$1"
|
||||
if [[ -z "$file" ]]; then
|
||||
file=$(resolve_mo_config_file "match_rules.json" "mo_match_rules.json")
|
||||
MATCH_RULES_FILE="$file"
|
||||
fi
|
||||
if [[ ! -f "$file" ]]; then
|
||||
if [[ "$AUTOMATED" == "true" ]]; then
|
||||
_log 警告 "未找到匹配规则文件,自动化模式跳过创建: $file"
|
||||
else
|
||||
_log 信息 "未找到匹配规则文件: $file"
|
||||
_log 信息 "是否创建默认配置文件?(y/N)"
|
||||
read -r answer || true # EOF(非交互)时跳过创建
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
mkdir -p "$(dirname "$file")" 2>/dev/null || true
|
||||
# 子 shell 设置 umask 077,确保创建的文件权限即 600
|
||||
(umask 077 && printf '%s\n' "$MATCH_RULES_TEMPLATE" | jq . >"$file" 2>/dev/null) || true
|
||||
if [[ ! -s "$file" ]]; then
|
||||
echo '{"search_aliases": {}, "id_maps": {"movie": {}, "tv": {}}}' >"$file"
|
||||
fi
|
||||
chmod 600 "$file" 2>/dev/null || _log 警告 "无法设置文件权限为 600: $file"
|
||||
_log 信息 "已创建匹配规则文件: $file"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
load_match_rules "$file"
|
||||
}
|
||||
|
||||
# 加载匹配规则。文件缺失/非 JSON 对象 → 空规则(不阻断主流程)。
|
||||
# 结构(键在加载期经 rule_key 归一化:小写 + 空白折叠):
|
||||
# search_aliases: {"<标题键>": "<重搜词>" | {"term": "...", "year": "..."}}
|
||||
# —— 主搜索(zh/en)无结果时用重搜词再搜(确定性,优先于目录名兜底与 AI)
|
||||
# id_maps: {"movie": {"<标题键>": <id>}, "tv": {"<标题键>": <id>}}
|
||||
# —— 标题命中直接使用指定 TMDB ID(跳过搜索,最高优先级)
|
||||
load_match_rules() {
|
||||
local file="$1"
|
||||
MATCH_RULES_ALIAS_TERM=()
|
||||
MATCH_RULES_ALIAS_YEAR=()
|
||||
MATCH_RULES_ID_MOVIE=()
|
||||
MATCH_RULES_ID_TV=()
|
||||
[[ -f "$file" ]] || return 0
|
||||
if ! jq -e 'type == "object"' "$file" >/dev/null 2>&1; then
|
||||
_log 错误 "匹配规则文件必须为 JSON 对象格式: $file"
|
||||
return 0
|
||||
fi
|
||||
local k term year rk
|
||||
while IFS=$'\t' read -r k term year; do
|
||||
[[ -z "$k" ]] && continue
|
||||
[[ -z "$term" ]] && continue
|
||||
rk=$(rule_key "$k")
|
||||
[[ -z "$rk" ]] && continue
|
||||
MATCH_RULES_ALIAS_TERM["$rk"]="$term"
|
||||
[[ -n "$year" ]] && MATCH_RULES_ALIAS_YEAR["$rk"]="$year"
|
||||
done < <(
|
||||
jq -r '.search_aliases // {} | to_entries[] |
|
||||
[.key,
|
||||
(.value | if type == "object" then (.term // "") else . end),
|
||||
(.value | if type == "object" then (.year // "") else "" end)] |
|
||||
@tsv' "$file" 2>/dev/null
|
||||
)
|
||||
while IFS=$'\t' read -r k term; do
|
||||
[[ -z "$k" ]] && continue
|
||||
[[ -z "$term" ]] && continue
|
||||
rk=$(rule_key "$k")
|
||||
[[ -z "$rk" ]] && continue
|
||||
MATCH_RULES_ID_MOVIE["$rk"]="$term"
|
||||
done < <(jq -r '.id_maps.movie // {} | to_entries[] | "\(.key)\t\(.value)"' "$file" 2>/dev/null)
|
||||
while IFS=$'\t' read -r k term; do
|
||||
[[ -z "$k" ]] && continue
|
||||
[[ -z "$term" ]] && continue
|
||||
rk=$(rule_key "$k")
|
||||
[[ -z "$rk" ]] && continue
|
||||
MATCH_RULES_ID_TV["$rk"]="$term"
|
||||
done < <(jq -r '.id_maps.tv // {} | to_entries[] | "\(.key)\t\(.value)"' "$file" 2>/dev/null)
|
||||
_log 调试 "匹配规则: 搜索别名 ${#MATCH_RULES_ALIAS_TERM[@]} 条,ID 映射 电影 ${#MATCH_RULES_ID_MOVIE[@]} 条 / 剧集 ${#MATCH_RULES_ID_TV[@]} 条"
|
||||
}
|
||||
|
||||
# 按规则键查搜索别名。命中输出 "term\tyear"(year 可为空),未命中返回 1。
|
||||
match_rule_lookup_alias() {
|
||||
local title="$1" rk
|
||||
rk=$(rule_key "$title")
|
||||
if [[ -n "${MATCH_RULES_ALIAS_TERM[$rk]:-}" ]]; then
|
||||
printf '%s\t%s' "${MATCH_RULES_ALIAS_TERM[$rk]}" "${MATCH_RULES_ALIAS_YEAR[$rk]:-}"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
################################################################################
|
||||
# 入口函数
|
||||
################################################################################
|
||||
|
||||
# 主入口:参数解析 → 配置加载 → 认证 → 初始化 → 三阶段流水线。
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
# list 形状:无需配置认证,直接列出后退出
|
||||
if [[ "$LIST_CACHE" == "true" ]]; then
|
||||
init_cache_dir
|
||||
cache_list "$LIST_CACHE_FILTER"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
load_config
|
||||
init_colors
|
||||
select_auth
|
||||
check_dependencies
|
||||
init_special_map "$SPECIAL_MAP_FILE"
|
||||
init_special_words "$SPECIAL_WORDS_FILE"
|
||||
init_skip_dirs "$SKIP_DIRS_FILE"
|
||||
init_season_offset
|
||||
|
||||
# cache-only 形状:仅更新缓存后退出(无整理步骤,不做硬链接检查)
|
||||
if [[ "$UPDATE_CACHE" == "true" && -z "$DESTINATION_DIR" ]]; then
|
||||
validate_directories
|
||||
init_cache_dir
|
||||
update_cache
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# organize 形状(可叠加 --update-cache 修饰):入口校验 + 硬链接检查(干运行跳过)
|
||||
validate_directories
|
||||
if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
check_hardlink_support
|
||||
fi
|
||||
init_cache_dir
|
||||
|
||||
# 注册信号处理
|
||||
trap '_handle_error $LINENO' ERR
|
||||
|
||||
# 启动信息
|
||||
_log 信息 "=== Jellyfin 媒体库整理脚本 v${SCRIPT_VERSION} ==="
|
||||
_log 信息 "源目录:$SOURCE_DIR"
|
||||
_log 信息 "目的目录:$DESTINATION_DIR"
|
||||
[[ "$DRY_RUN" == "true" ]] && _log 信息 "运行模式:[干运行] - 不创建链接、不写缓存与日志"
|
||||
[[ "$AUTOMATED" == "true" ]] && _log 信息 "运行模式:[自动化] - 将写入日志文件"
|
||||
[[ "$UPDATE_CACHE" == "true" ]] && _log 信息 "运行模式:[更新缓存] - 整理时强制重取对应缓存条目"
|
||||
|
||||
# 流水线:扫描 → 识别池 → AI 批处理 → 链接 → 对照表 → 汇总
|
||||
scan_files
|
||||
process_video
|
||||
process_audio
|
||||
run_ai_batch
|
||||
link_media
|
||||
export_map_md
|
||||
print_summary
|
||||
|
||||
# 退出码分级:0=全部成功;3=部分失败(非预期跳过/请求失败/链接失败 > 0)
|
||||
local fail_count=0 key outcome
|
||||
for key in "${!MEDIA_OUTCOME_MAP[@]}"; do
|
||||
outcome="${MEDIA_OUTCOME_MAP[$key]}"
|
||||
if [[ "$outcome" == "skip_unidentified" || "$outcome" == "request_failed" ]]; then
|
||||
fail_count=$((fail_count + 1))
|
||||
fi
|
||||
done
|
||||
if ((fail_count > 0 || LINK_FAIL_COUNT > 0)); then
|
||||
exit 3
|
||||
fi
|
||||
}
|
||||
|
||||
# 脚本唯一入口
|
||||
main "$@"
|
||||
@@ -0,0 +1,526 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034,SC2004 # 跨文件共享关联数组(声明于 main.sh;[$key] 下标是字符串不是算术)
|
||||
|
||||
# AI 响应 JSON 容错提取(借鉴 BAR _extract_json_from_response 思路,纯 awk/sed 实现):
|
||||
# ① 直接解析 → ② 剥 ```json/``` 围栏后解析 → ③ 剥离思考链标记后解析 → ④ 取最外层 {} 块解析。
|
||||
# 推理模型常输出 <thinking>…</thinking> 或「思考:…」前缀——AI_SYSTEM_MESSAGE 强制 JSON-only
|
||||
# 只是要求,模型不保证遵守;容错链保证任何一级成功即返回,全部失败返回空。
|
||||
# 返回:stdout=合法 JSON;失败返回空(调用方按原失败路径处理)。
|
||||
ai_extract_json() {
|
||||
local content="$1"
|
||||
local candidate=""
|
||||
# ① 直接解析
|
||||
if echo "$content" | jq -e . >/dev/null 2>&1; then
|
||||
echo "$content"
|
||||
return 0
|
||||
fi
|
||||
# ② 剥围栏(```json / ``` 行 toggle,围栏外内容丢弃)
|
||||
candidate=$(printf '%s\n' "$content" | awk '
|
||||
/^```/{ inblock = !inblock; next }
|
||||
inblock { print }
|
||||
')
|
||||
if [[ -n "$candidate" ]] && echo "$candidate" | jq -e . >/dev/null 2>&1; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
# ③ 剥离思考链标记(<thinking> 块 + 「思考/分析/推理:」前缀)后重试直接/围栏解析
|
||||
# 先剥同行 <thinking>…</thinking>(awk 按行处理无法吞掉同行标签后的 JSON)
|
||||
local cleaned
|
||||
cleaned=$(printf '%s\n' "$content" | sed -E 's#<thinking>[^<]*</thinking>##g; /^[[:space:]]*$/d' | awk '
|
||||
/<thinking>/{ skip = 1 }
|
||||
!skip { print }
|
||||
/<\/thinking>/{ skip = 0 }
|
||||
' | sed -E 's/^(思考|分析|推理)[::].*\{/\{/')
|
||||
if [[ -n "$cleaned" ]] && echo "$cleaned" | jq -e . >/dev/null 2>&1; then
|
||||
echo "$cleaned"
|
||||
return 0
|
||||
fi
|
||||
candidate=$(printf '%s\n' "$cleaned" | awk '
|
||||
/^```/{ inblock = !inblock; next }
|
||||
inblock { print }
|
||||
')
|
||||
if [[ -n "$candidate" ]] && echo "$candidate" | jq -e . >/dev/null 2>&1; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
# ④ 最外层 {} 块(花括号配平;字符串内 {} 会误计,仅作兜底——失败继续/返回空)
|
||||
candidate=$(printf '%s' "$cleaned" | awk '
|
||||
{
|
||||
line = $0
|
||||
for (i = 1; i <= length(line); i++) {
|
||||
c = substr(line, i, 1)
|
||||
if (c == "{") { depth++; inblock = 1 }
|
||||
if (inblock) printf "%s", c
|
||||
if (c == "}") {
|
||||
depth--
|
||||
if (depth == 0 && inblock) { printf "\n"; exit }
|
||||
}
|
||||
}
|
||||
}')
|
||||
if [[ -n "$candidate" ]] && echo "$candidate" | jq -e . >/dev/null 2>&1; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# AI 用例落盘(AI_SAVE_CASES=true):每次请求的输入与响应存
|
||||
# $CACHE_DIR/media_organizer/ai_cases/<序号>_<ts>_{request,response}.json。
|
||||
# 反馈闭环素材:识别错时可查"当时 AI 看到了什么";也是防幻觉学习的候选数据源。
|
||||
# 输入不含 API 密钥(key 只在 HTTP 头),响应为原始文本(非 JSON 响应也能留档)。
|
||||
# 干运行不写(零持久化副作用)。
|
||||
ai_case_save() {
|
||||
[[ "${AI_SAVE_CASES,,}" == "true" ]] || return 0
|
||||
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||
[[ -n "$CACHE_DIR" ]] || return 0
|
||||
local kind="$1" data="$2"
|
||||
local dir="$CACHE_DIR/media_organizer/ai_cases"
|
||||
mkdir -p "$dir" 2>/dev/null || return 0
|
||||
local ts
|
||||
ts=$(date +%Y%m%d_%H%M%S)
|
||||
local file="${dir}/${AI_CALL_COUNT}_${ts}_${kind}.json"
|
||||
printf '%s\n' "$data" >"$file" 2>/dev/null
|
||||
_log 调试 "AI 用例已保存: $file"
|
||||
}
|
||||
|
||||
# 单次 AI 批量请求:合并处理搜索词纠正、特典映射学习与跳过目录学习(一次 API 调用)。
|
||||
#
|
||||
# AI 预期返回 JSON(由 AI_BATCH_PROMPT 引导,ai_batch_request 用 jq 解析):
|
||||
# {
|
||||
# "search": { "<完整 file 路径>": { "search_term": "...", "year": "..." }, ... },
|
||||
# "special": { "<show_id>|<fragment>": { "english_keyword": "...", "chinese_keyword": "..." }, ... },
|
||||
# "skip_dirs": [ "<非媒体目录名>", ... ]
|
||||
# }
|
||||
# 无条目的一类返回 {}(skip_dirs 返回 [])。解析后:
|
||||
# - search 部分更新 PENDING_AI_SEARCH(追加 |搜索词|年份)
|
||||
# - special 部分写入特典映射/词表文件与内存(AI 学习)
|
||||
# - skip_dirs 部分写入跳过目录词表文件与内存(AI 学习,交叉验证目录链)
|
||||
# 构造 AI 批处理输入 JSON(jq 安全转义:文件名含引号/反斜杠不破坏 JSON;每类最多 AI_BATCH_SIZE 条)。
|
||||
# 记录本批 key 集合(AI_BATCH_KEYS_*):消费端只处理 AI 响应覆盖的条目,批外条目留待下一批。
|
||||
build_ai_input_json() {
|
||||
# 注意:调用方必须用 `build_ai_input_json <var>` 形式(非命令替换)——
|
||||
# 本函数会修改 AI_BATCH_KEYS_*/AI_DIR_CHAIN 全局数组,$(...) 子 shell 中修改会丢失。
|
||||
local out_var="${1:-}"
|
||||
local input_json='{"search_entries":[],"special_entries":[],"artist_entries":[],"match_entries":[]}'
|
||||
local batch_limit="${AI_BATCH_SIZE:-50}"
|
||||
AI_BATCH_KEYS_SEARCH=()
|
||||
AI_BATCH_KEYS_SPECIAL=()
|
||||
AI_BATCH_KEYS_ARTIST=()
|
||||
AI_BATCH_KEYS_MATCH=()
|
||||
local key info type dir_chain_val count=0
|
||||
AI_DIR_CHAIN=()
|
||||
for key in "${!PENDING_AI_SEARCH[@]}"; do
|
||||
((count >= batch_limit)) && break
|
||||
info="${PENDING_AI_SEARCH[$key]}"
|
||||
IFS=$'\t' read -r type dir_chain_val <<<"$info"
|
||||
input_json=$(jq -c --arg f "$key" --arg d "$dir_chain_val" --arg t "$type" \
|
||||
'.search_entries += [{file: $f, directory: $d, type: $t}]' <<<"$input_json")
|
||||
AI_BATCH_KEYS_SEARCH+=("$key")
|
||||
count=$((count + 1))
|
||||
# 收集目录链段(skip_dirs 学习候选;AI 只能从这些目录中选择,防幻觉)
|
||||
local seg
|
||||
IFS='/' read -ra segs <<<"$dir_chain_val"
|
||||
for seg in "${segs[@]}"; do
|
||||
[[ -n "$seg" ]] && AI_DIR_CHAIN+=("$seg")
|
||||
done
|
||||
done
|
||||
count=0
|
||||
local show_id fragment show_val show_title season0_names
|
||||
for key in "${!PENDING_AI_SPECIAL[@]}"; do
|
||||
((count >= batch_limit)) && break
|
||||
IFS='|' read -r show_id fragment <<<"$key"
|
||||
show_val="${PENDING_AI_SPECIAL[$key]}"
|
||||
IFS='|' read -r show_title season0_names <<<"$show_val"
|
||||
input_json=$(jq -c --arg s "$show_id" --arg f "$fragment" --arg t "$show_title" --arg s0 "$season0_names" \
|
||||
'.special_entries += [{show_id: $s, fragment: $f, series: $t, season0: $s0}]' <<<"$input_json")
|
||||
AI_BATCH_KEYS_SPECIAL+=("$key")
|
||||
count=$((count + 1))
|
||||
done
|
||||
count=0
|
||||
local artists_str album_name
|
||||
for key in "${!PENDING_AI_ARTIST[@]}"; do
|
||||
((count >= batch_limit)) && break
|
||||
IFS='|' read -r artists_str album_name _ <<<"${PENDING_AI_ARTIST[$key]}"
|
||||
input_json=$(jq -c --arg f "$key" --arg a "$artists_str" --arg al "$album_name" \
|
||||
'.artist_entries += [{file: $f, artists: $a, album: $al}]' <<<"$input_json")
|
||||
AI_BATCH_KEYS_ARTIST+=("$key")
|
||||
count=$((count + 1))
|
||||
done
|
||||
count=0
|
||||
local m_type m_year m_season _m_episode m_endpoint m_key
|
||||
for key in "${!PENDING_AI_MATCH[@]}"; do
|
||||
((count >= batch_limit)) && break
|
||||
IFS=$'\t' read -r m_type m_year m_season _m_episode m_endpoint m_key <<<"${PENDING_AI_MATCH[$key]}"
|
||||
# 输入 = search 响应原样(缓存中,零额外请求)+ 首候选 detail 的 seasons 四字段提炼(缓存命中才给)
|
||||
local search_data="" seasons_json="[]"
|
||||
search_data=$(jq -r '.data // empty' "$(cache_path "$m_endpoint" "$m_key")" 2>/dev/null) || search_data=""
|
||||
if [[ -n "$search_data" ]]; then
|
||||
local first_id d_json
|
||||
first_id=$(echo "$search_data" | jq -r '.results[0].id // empty' 2>/dev/null)
|
||||
if [[ -n "$first_id" ]]; then
|
||||
d_json=$(cat "$(cache_path "/${m_type}/${first_id}" "$m_key")" 2>/dev/null)
|
||||
if [[ -n "$d_json" ]] && echo "$d_json" | jq -e . >/dev/null 2>&1; then
|
||||
seasons_json=$(echo "$d_json" | jq -c '[.seasons[]? | {season_number, name, episode_count, air_date}]' 2>/dev/null)
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
[[ -n "$search_data" ]] || search_data="null"
|
||||
[[ -n "$seasons_json" ]] || seasons_json="[]"
|
||||
# 视频时长/分辨率信号(ffprobe 实测;失败留空不阻塞——AI 仍可依据文件名判断)。
|
||||
# 时长用于区分内容类型:正片 ~24min、OVA/特典 ~20-30min、剧场版 ~70-120min。
|
||||
local dur="" res="" probe
|
||||
probe=$(ffprobe -v quiet -print_format json -show_entries format=duration:stream=width,height \
|
||||
"$key" 2>/dev/null) || probe=""
|
||||
if [[ -n "$probe" ]]; then
|
||||
dur=$(echo "$probe" | jq -r '(.format.duration // 0 | tonumber) / 60 | if . >= 1 then (. * 10 | floor) / 10 else empty end' 2>/dev/null)
|
||||
res=$(echo "$probe" | jq -r '([.streams[]? | select(.width != null and .height != null) | "\(.width)x\(.height)"] | .[0]) // empty' 2>/dev/null)
|
||||
fi
|
||||
# 同目录兄弟文件上下文(≤20 个视频文件名):AI 据此判断该文件是剧场版/OVA/正片
|
||||
# 或该目录是整季合集(借鉴 BAR 整目录一次映射的视角,零额外请求)。
|
||||
local -a sib=()
|
||||
local sdir f2 e2
|
||||
sdir=$(dirname "$key")
|
||||
for f2 in "$sdir"/*; do
|
||||
[[ -f "$f2" && "$f2" != "$key" ]] || continue
|
||||
e2="${f2##*.}"
|
||||
e2="${e2,,}"
|
||||
[[ " ${VIDEO_EXTS[*]} " =~ [[:space:]]${e2}[[:space:]] ]] || continue
|
||||
sib+=("$(basename "$f2")")
|
||||
((${#sib[@]} >= 20)) && break
|
||||
done
|
||||
local siblings_json="[]"
|
||||
if ((${#sib[@]} > 0)); then
|
||||
siblings_json=$(printf '%s\n' "${sib[@]}" | jq -R -s 'split("\n") | map(select(. != ""))')
|
||||
fi
|
||||
input_json=$(jq -c --arg f "$key" --arg t "$m_type" --arg y "$m_year" --arg s "$m_season" \
|
||||
--arg d "$dur" --arg r "$res" --argjson sb "$siblings_json" \
|
||||
--argjson sd "$search_data" --argjson sn "$seasons_json" \
|
||||
'.match_entries += [{file: $f, type: $t, year: $y, season: $s, duration: $d, resolution: $r, siblings: $sb, search: $sd, seasons: $sn}]' <<<"$input_json")
|
||||
AI_BATCH_KEYS_MATCH+=("$key")
|
||||
count=$((count + 1))
|
||||
done
|
||||
if [[ -n "$out_var" ]]; then
|
||||
printf -v "$out_var" '%s' "$input_json"
|
||||
else
|
||||
echo "$input_json"
|
||||
fi
|
||||
}
|
||||
|
||||
ai_batch_request() {
|
||||
# 无待处理项直接返回
|
||||
if [[ $PENDING_SEARCH_COUNT -eq 0 ]] &&
|
||||
[[ $PENDING_SPECIAL_COUNT -eq 0 ]] &&
|
||||
[[ $PENDING_ARTIST_COUNT -eq 0 ]] &&
|
||||
[[ $PENDING_MATCH_COUNT -eq 0 ]]; then
|
||||
return
|
||||
fi
|
||||
_log 智能 "AI 批量请求(搜索 $PENDING_SEARCH_COUNT + 特典 $PENDING_SPECIAL_COUNT + 艺术家 $PENDING_ARTIST_COUNT + 匹配 $PENDING_MATCH_COUNT)"
|
||||
|
||||
local input_json
|
||||
build_ai_input_json input_json
|
||||
# 用例落盘:请求输入(不含 API 密钥——key 只在 HTTP 头)
|
||||
ai_case_save "request" "$input_json"
|
||||
local full_prompt="$AI_BATCH_PROMPT"$'\n\n'"Input: $input_json"
|
||||
|
||||
local payload
|
||||
payload=$(jq -n --arg content "$full_prompt" --arg model "$AI_MODEL" --arg sys "$AI_SYSTEM_MESSAGE" \
|
||||
'{model: $model, messages: [{role: "system", content: $sys}, {role: "user", content: $content}], temperature: 0.2}')
|
||||
|
||||
[[ "${AI_DRY_RUN,,}" == "true" ]] && {
|
||||
_log 智能 "AI 干运行,Prompt 摘要: 搜索 $PENDING_SEARCH_COUNT 个 + 特典 $PENDING_SPECIAL_COUNT 个 + 艺术家 $PENDING_ARTIST_COUNT 个 + 匹配 $PENDING_MATCH_COUNT 个"
|
||||
# 返回非零:未消费任何条目(run_ai_batch 视作失败 → 剩余显式跳过,可逆)
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ "$AI_CALL_COUNT" -ge "${AI_MAX_CALLS}" ]]; then
|
||||
_log 警告 "AI 调用次数已达上限,跳过批量请求"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local api_url="${AI_FULL_URL:-${AI_BASE_URL}/v1/chat/completions}"
|
||||
# 递增重试(2^n 封顶 16s),与 tmdb_api 同一策略
|
||||
local response=""
|
||||
local attempt=0 delay=1
|
||||
while true; do
|
||||
if response=$(curl -s --connect-timeout "${AI_CURL_CONNECT_TIMEOUT}" --max-time "${AI_CURL_MAX_TIME}" \
|
||||
-H "Content-Type: application/json" -H "Authorization: Bearer ${AI_API_KEY}" -d "$payload" "$api_url" 2>&1); then
|
||||
break
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
if ((attempt > AI_CURL_RETRY)); then
|
||||
_log 错误 "AI 批量请求失败(重试 ${AI_CURL_RETRY} 次后)"
|
||||
return 1
|
||||
fi
|
||||
_log 警告 "AI 批量请求失败,${delay}s 后重试(${attempt}/${AI_CURL_RETRY})"
|
||||
sleep "$delay"
|
||||
((delay *= 2))
|
||||
[[ $delay -gt 16 ]] && delay=16
|
||||
done
|
||||
AI_CALL_COUNT=$((AI_CALL_COUNT + 1))
|
||||
# 用例落盘:原始响应留档(含非 JSON 响应,便于排查模型输出)
|
||||
ai_case_save "response" "$response"
|
||||
|
||||
local content
|
||||
content=$(echo "$response" | jq -r '.choices[0].message.content // empty' 2>/dev/null)
|
||||
if [[ -z "$content" ]]; then
|
||||
_log 错误 "AI 批量请求未返回有效内容"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# JSON 容错提取:直接 → 围栏 → 思考链剥离 → 最外层 {} 块
|
||||
local result_json
|
||||
result_json=$(ai_extract_json "$content") || {
|
||||
_log 错误 "AI 批量请求返回非 JSON(容错提取失败): $(printf '%s' "$content" | head -c 200)"
|
||||
return 1
|
||||
}
|
||||
|
||||
_log 智能 "AI 批量请求结果: $result_json"
|
||||
|
||||
# 解析 search 部分:更新 PENDING_AI_SEARCH(追加 |搜索词|年份|AI判断类别)
|
||||
local ai_term ai_year ai_media
|
||||
for key in "${!PENDING_AI_SEARCH[@]}"; do
|
||||
ai_term=$(echo "$result_json" | jq -r --arg file "$key" '.search[$file].search_term // empty')
|
||||
ai_year=$(echo "$result_json" | jq -r --arg file "$key" '.search[$file].year // empty')
|
||||
ai_media=$(echo "$result_json" | jq -r --arg file "$key" '.search[$file].media_type // empty')
|
||||
if [[ -n "$ai_term" ]] || [[ -n "$ai_media" ]]; then
|
||||
local old="${PENDING_AI_SEARCH[$key]}"
|
||||
PENDING_AI_SEARCH[$key]="${old} ${ai_term} ${ai_year} ${ai_media}"
|
||||
fi
|
||||
done
|
||||
|
||||
# 解析 artist_choice 部分:AI 判定多艺术家中的专辑艺术家(空串=判断不出)
|
||||
for key in "${!PENDING_AI_ARTIST[@]}"; do
|
||||
AI_ARTIST_CHOICE[$key]=$(echo "$result_json" | jq -r --arg file "$key" '.artist_choice[$file] // empty')
|
||||
done
|
||||
|
||||
# 解析 match 部分:AI 匹配甄别(choice=选定 id;season_shift=季映射;search_term=无匹配纠正词)
|
||||
for key in "${!PENDING_AI_MATCH[@]}"; do
|
||||
AI_MATCH_CHOICE[$key]=$(echo "$result_json" | jq -r --arg file "$key" '.match[$file].choice // empty')
|
||||
AI_MATCH_SHIFT[$key]=$(echo "$result_json" | jq -r --arg file "$key" '.match[$file].season_shift // empty')
|
||||
AI_MATCH_TERM[$key]=$(echo "$result_json" | jq -r --arg file "$key" '.match[$file].search_term // empty')
|
||||
done
|
||||
|
||||
# 记录本批响应覆盖的 key(AI 漏了的条目留待下一批;缺失计数警告)
|
||||
AI_RESPONDED_SEARCH=()
|
||||
AI_RESPONDED_ARTIST=()
|
||||
AI_RESPONDED_MATCH=()
|
||||
local responded
|
||||
for key in "${AI_BATCH_KEYS_SEARCH[@]}"; do
|
||||
responded=$(echo "$result_json" | jq -r --arg f "$key" '.search | has($f)' 2>/dev/null)
|
||||
[[ "$responded" == "true" ]] && AI_RESPONDED_SEARCH["$key"]=1
|
||||
done
|
||||
for key in "${AI_BATCH_KEYS_ARTIST[@]}"; do
|
||||
responded=$(echo "$result_json" | jq -r --arg f "$key" '.artist_choice | has($f)' 2>/dev/null)
|
||||
[[ "$responded" == "true" ]] && AI_RESPONDED_ARTIST["$key"]=1
|
||||
done
|
||||
for key in "${AI_BATCH_KEYS_MATCH[@]}"; do
|
||||
responded=$(echo "$result_json" | jq -r --arg f "$key" '.match | has($f)' 2>/dev/null)
|
||||
[[ "$responded" == "true" ]] && AI_RESPONDED_MATCH["$key"]=1
|
||||
done
|
||||
local missing=$((${#AI_BATCH_KEYS_SEARCH[@]} + ${#AI_BATCH_KEYS_ARTIST[@]} + ${#AI_BATCH_KEYS_MATCH[@]} - \
|
||||
${#AI_RESPONDED_SEARCH[@]} - ${#AI_RESPONDED_ARTIST[@]} - ${#AI_RESPONDED_MATCH[@]}))
|
||||
((missing > 0)) && _log 警告 "AI 响应缺失 $missing 个条目(留待下一批)"
|
||||
|
||||
learn_special_keywords "$result_json"
|
||||
learn_skip_dirs "$result_json"
|
||||
}
|
||||
|
||||
# AI 学习写回跳过目录词表(skip_directories.json)。
|
||||
# AI 从输入条目的目录链中挑出非媒体目录(特典/附带/分类目录),
|
||||
# 交叉验证:返回的目录名必须实际出现在本批输入目录链中(防 AI 幻觉)。
|
||||
# 与现有词表去重(忽略大小写,与 SKIP_DIRS_LOWER 匹配规则一致)后写回文件并同步内存。
|
||||
learn_skip_dirs() {
|
||||
local result_json="$1"
|
||||
[[ -n "${SKIP_DIRS_FILE:-}" ]] || return 0
|
||||
local dir
|
||||
while IFS= read -r dir; do
|
||||
[[ -z "$dir" ]] && continue
|
||||
# 交叉验证:必须在输入目录链中出现过
|
||||
local found=false d
|
||||
for d in "${AI_DIR_CHAIN[@]}"; do
|
||||
[[ "${d,,}" == "${dir,,}" ]] && found=true
|
||||
done
|
||||
[[ "$found" != "true" ]] && {
|
||||
_log 警告 "AI 跳过目录不在输入目录链,丢弃: $dir"
|
||||
continue
|
||||
}
|
||||
# 去重(忽略大小写,与现有词表比较;分区形态查 video 分区,全局形态查全局数组)
|
||||
local already=false sd
|
||||
if [[ -n "${SKIP_DIRS_CAT["video|${dir,,}"]:-}" ]]; then
|
||||
already=true
|
||||
else
|
||||
for sd in "${SKIP_DIRS[@]}"; do
|
||||
[[ "${sd,,}" == "${dir,,}" ]] && already=true
|
||||
done
|
||||
fi
|
||||
[[ "$already" == "true" ]] && continue
|
||||
# 确保词表文件存在(不存在则创建空数组,AI 学到的目录需要持久化)
|
||||
if [[ ! -f "$SKIP_DIRS_FILE" ]]; then
|
||||
mkdir -p "$(dirname "$SKIP_DIRS_FILE")" 2>/dev/null || true
|
||||
echo '[]' >"$SKIP_DIRS_FILE" 2>/dev/null || true
|
||||
fi
|
||||
local tmp="${SKIP_DIRS_FILE}.tmp.$$"
|
||||
# 分区形态 → 写入 video 分区;全局形态 → 追加顶层数组(原子写:临时文件 + rename)
|
||||
if is_category_partitioned "$SKIP_DIRS_FILE"; then
|
||||
if jq --arg v "$dir" '.video = ((.video // []) + [$v] | unique)' \
|
||||
"$SKIP_DIRS_FILE" >"$tmp" 2>/dev/null; then
|
||||
if mv "$tmp" "$SKIP_DIRS_FILE"; then
|
||||
SKIP_DIRS_CAT["video|${dir,,}"]=1
|
||||
_log 智能 "已添加跳过目录(video 分区): $dir"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
else
|
||||
# JSON 数组:追加去重(原子写:临时文件 + rename)
|
||||
if jq --arg v "$dir" '. + [$v] | unique' "$SKIP_DIRS_FILE" >"$tmp" 2>/dev/null; then
|
||||
if mv "$tmp" "$SKIP_DIRS_FILE"; then
|
||||
SKIP_DIRS+=("$dir")
|
||||
SKIP_DIRS_LOWER+=("${dir,,}")
|
||||
_log 智能 "已添加跳过目录: $dir"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
fi
|
||||
done < <(echo "$result_json" | jq -r '.skip_dirs[]? // empty' 2>/dev/null)
|
||||
}
|
||||
|
||||
# AI 学习写回:AI 从该条 season0 候选关键字列表中选出与本地片段匹配的项,
|
||||
# 写回 keymap 作为匹配关键字(可多语言数组)。
|
||||
# 交叉验证:AI 返回的每个匹配关键字必须存在于该条 season0 候选列表(防 AI 幻觉污染映射)。
|
||||
# 同时把 fragment 核心词并入特典词表(special_keywords.json)——词表负责"判定特典",
|
||||
# keymap 负责"匹配特典":只写回 keymap 时,下次遇到不在特典目录里的同类文件名标记
|
||||
# 仍会因词表未命中而无法判定为特典(学习结果对不上),两者缺一不可。
|
||||
learn_special_keywords() {
|
||||
local result_json="$1"
|
||||
local tmdb_name key
|
||||
for key in "${!PENDING_AI_SPECIAL[@]}"; do
|
||||
local fragment key_lower season0_names
|
||||
IFS='|' read -r _ fragment <<<"$key"
|
||||
key_lower="${fragment,,}"
|
||||
season0_names="${PENDING_AI_SPECIAL[$key]#*|}"
|
||||
tmdb_name=$(echo "$result_json" | jq -r --arg k "$fragment" '.special[$k] // empty')
|
||||
if [[ -n "$tmdb_name" ]] && [[ "$tmdb_name" != "null" ]]; then
|
||||
# 交叉验证:过滤掉不在候选列表中的项(数组逐项 / 字符串单项)。
|
||||
# 注意:`$s0 | contains(.)` 中 . 指管道输入($s0 自身)——须先捕获元素为 $item
|
||||
local valid_name
|
||||
valid_name=$(echo "$tmdb_name" | jq -r --arg s0 "$season0_names" \
|
||||
'if type == "array" then ([.[] | select(. as $item | ($item != "") and ($s0 | contains($item)))] | if length > 0 then . else empty end)
|
||||
else (if (. != "") and ($s0 | contains(.)) then . else empty end) end' 2>/dev/null) || valid_name=""
|
||||
if [[ -z "$valid_name" ]] || [[ "$valid_name" == "null" ]]; then
|
||||
_log 警告 "AI 特典匹配关键字不在候选列表,丢弃: $fragment -> $tmdb_name"
|
||||
continue
|
||||
fi
|
||||
tmdb_name="$valid_name"
|
||||
# 确保映射文件存在(不存在则创建空 JSON,AI 学到的映射需要持久化)
|
||||
if [[ ! -f "$SPECIAL_MAP_FILE" ]]; then
|
||||
mkdir -p "$(dirname "$SPECIAL_MAP_FILE")" 2>/dev/null || true
|
||||
echo '{}' >"$SPECIAL_MAP_FILE" 2>/dev/null || true
|
||||
fi
|
||||
local tmp_map_file="${SPECIAL_MAP_FILE}.tmp.$$"
|
||||
# JSON 文件:合并写入数组(已有值则去重追加;字符串值归并为数组)。
|
||||
# 分区形态 → 写入 tv 分区(特典映射属剧集语境);全局形态 → 顶层。
|
||||
if is_category_partitioned "$SPECIAL_MAP_FILE"; then
|
||||
if jq --arg k "$key_lower" --argjson v "$tmdb_name" \
|
||||
'if (.tv // {} | has($k)) then (.tv[$k] = ((((if (.tv[$k]|type)=="array" then .tv[$k] else [.tv[$k]] end) + (if ($v|type)=="array" then $v else [$v] end)) | unique))) else (.tv[$k] = (if ($v|type)=="array" then $v else [$v] end)) end' \
|
||||
"$SPECIAL_MAP_FILE" >"$tmp_map_file" 2>/dev/null; then
|
||||
if ! mv "$tmp_map_file" "$SPECIAL_MAP_FILE"; then
|
||||
rm -f "$tmp_map_file"
|
||||
fi
|
||||
else
|
||||
rm -f "$tmp_map_file"
|
||||
fi
|
||||
else
|
||||
if jq --arg k "$key_lower" --argjson v "$tmdb_name" \
|
||||
'if has($k) then (.[$k] = (((if (.[$k]|type)=="array" then .[$k] else [.[$k]] end) + (if ($v|type)=="array" then $v else [$v] end)) | unique)) else (.[$k] = (if ($v|type)=="array" then $v else [$v] end)) end' \
|
||||
"$SPECIAL_MAP_FILE" >"$tmp_map_file" 2>/dev/null; then
|
||||
if ! mv "$tmp_map_file" "$SPECIAL_MAP_FILE"; then
|
||||
rm -f "$tmp_map_file"
|
||||
fi
|
||||
else
|
||||
rm -f "$tmp_map_file"
|
||||
fi
|
||||
fi
|
||||
# 更新内存映射(JSON 数组;分区形态 → tv 分区表,全局形态 → 全局表)
|
||||
local existing
|
||||
if is_category_partitioned "$SPECIAL_MAP_FILE"; then
|
||||
existing="${SPECIAL_MAP_CAT["tv|$key_lower"]:-}"
|
||||
if [[ -z "$existing" ]]; then
|
||||
SPECIAL_MAP_CAT["tv|$key_lower"]=$(jq -n --argjson v "$tmdb_name" 'if ($v|type)=="array" then $v else [$v] end')
|
||||
else
|
||||
SPECIAL_MAP_CAT["tv|$key_lower"]=$(echo "$existing" | jq --argjson v "$tmdb_name" \
|
||||
'(if type=="array" then . else [.] end) + (if ($v|type)=="array" then $v else [$v] end) | unique')
|
||||
fi
|
||||
else
|
||||
existing="${SPECIAL_MAP[$key_lower]:-}"
|
||||
if [[ -z "$existing" ]]; then
|
||||
SPECIAL_MAP["$key_lower"]=$(jq -n --argjson v "$tmdb_name" 'if ($v|type)=="array" then $v else [$v] end')
|
||||
else
|
||||
SPECIAL_MAP["$key_lower"]=$(echo "$existing" | jq --argjson v "$tmdb_name" \
|
||||
'(if type=="array" then . else [.] end) + (if ($v|type)=="array" then $v else [$v] end) | unique')
|
||||
fi
|
||||
fi
|
||||
_log 智能 "已添加特典映射: $fragment -> $tmdb_name"
|
||||
|
||||
# ---- 特典词表学习写回(判定环节)----
|
||||
# 核心词 = fragment 去数字、去空格、小写(与 detect_special 的归一化匹配一致);
|
||||
# 长度 < 2 的词(如纯数字/单字符)不入表,避免过度判定。
|
||||
local frag_core
|
||||
frag_core=$(printf '%s' "$fragment" | sed -E 's/[0-9]+//g' | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||
frag_core="${frag_core//[[:space:]]/}"
|
||||
frag_core="${frag_core,,}"
|
||||
if [[ ${#frag_core} -ge 2 ]] && [[ -n "${SPECIAL_WORDS_FILE:-}" ]]; then
|
||||
local already=false w w_norm
|
||||
if [[ -n "${SPECIAL_WORDS_CAT["tv|$frag_core"]:-}" ]]; then
|
||||
already=true
|
||||
else
|
||||
for w in "${SPECIAL_WORDS[@]}"; do
|
||||
w_norm="${w//[[:space:]]/}"
|
||||
w_norm="${w_norm,,}"
|
||||
[[ "$w_norm" == "$frag_core" ]] && already=true
|
||||
done
|
||||
fi
|
||||
if [[ "$already" != "true" ]]; then
|
||||
# 确保词表文件存在(不存在则创建空数组,AI 学到的词需要持久化)
|
||||
if [[ ! -f "$SPECIAL_WORDS_FILE" ]]; then
|
||||
mkdir -p "$(dirname "$SPECIAL_WORDS_FILE")" 2>/dev/null || true
|
||||
echo '[]' >"$SPECIAL_WORDS_FILE" 2>/dev/null || true
|
||||
fi
|
||||
local tmp_words="${SPECIAL_WORDS_FILE}.tmp.$$"
|
||||
# 分区形态 → 写入 tv 分区;全局形态 → 追加顶层数组(原子写:临时文件 + rename)
|
||||
if is_category_partitioned "$SPECIAL_WORDS_FILE"; then
|
||||
if jq --arg v "$frag_core" '.tv = ((.tv // []) + [$v] | unique)' \
|
||||
"$SPECIAL_WORDS_FILE" >"$tmp_words" 2>/dev/null; then
|
||||
if mv "$tmp_words" "$SPECIAL_WORDS_FILE"; then
|
||||
SPECIAL_WORDS_CAT["tv|$frag_core"]=1
|
||||
_log 智能 "已添加特典词表(tv 分区): $fragment -> $frag_core"
|
||||
else
|
||||
rm -f "$tmp_words"
|
||||
fi
|
||||
else
|
||||
rm -f "$tmp_words"
|
||||
fi
|
||||
else
|
||||
# JSON 数组:追加去重(原子写:临时文件 + rename)
|
||||
if jq --arg v "$frag_core" '. + [$v] | unique' \
|
||||
"$SPECIAL_WORDS_FILE" >"$tmp_words" 2>/dev/null; then
|
||||
if mv "$tmp_words" "$SPECIAL_WORDS_FILE"; then
|
||||
SPECIAL_WORDS+=("$frag_core")
|
||||
_log 智能 "已添加特典词表: $fragment -> $frag_core"
|
||||
else
|
||||
rm -f "$tmp_words"
|
||||
fi
|
||||
else
|
||||
rm -f "$tmp_words"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
################################################################################
|
||||
# TMDB API
|
||||
################################################################################
|
||||
|
||||
# 指定语言的 tmdb_api 封装(语言重试链用):局部覆盖 TMDB_LANG 后调用。
|
||||
# bash 动态作用域:tmdb_api 内读到的 $TMDB_LANG 为本次调用值;
|
||||
# 缓存 key/路径含语言段(cache_key 拼 lang、id 类路径带 .lang),语言切换天然隔离缓存。
|
||||
# 参数:lang(如 en-US) + 原 tmdb_api 全部参数。
|
||||
tmdb_api_lang() {
|
||||
local lang="$1"
|
||||
shift
|
||||
local TMDB_LANG="$lang"
|
||||
tmdb_api "$@"
|
||||
}
|
||||
|
||||
# 调用 TMDB API 并缓存结果。端点形如 "/search/movie",额外参数以 key=value 传入。
|
||||
# 先查缓存(命中且未过期直接返回),未命中才请求并写缓存。
|
||||
# 返回码约定:0=成功(含空结果的有效响应);1=新鲜空哨兵跳过请求;
|
||||
@@ -98,4 +111,3 @@ tmdb_api() {
|
||||
fi
|
||||
echo "$result"
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,3 +1,5 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
################################################################################
|
||||
# 日志与色彩(最先加载,保证全脚本日志输出一致)
|
||||
################################################################################
|
||||
@@ -96,4 +98,3 @@ _log() {
|
||||
"$timestamp" "$tag" "$(get_indent)" "$*" >>"$LOG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
+608
@@ -0,0 +1,608 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034 # 全局变量声明区:由其他模块跨文件消费(shellcheck 单文件分析无法追踪)
|
||||
|
||||
################################################################################
|
||||
# Jellyfin 媒体库硬链接整理脚本
|
||||
#
|
||||
# 版本:9.6(v9.5 + 配置按类目分区 / 特典类别外置 / Music Videos 类目)
|
||||
# 作者:LetsShareAll
|
||||
# 许可:MIT
|
||||
#
|
||||
# 说明:
|
||||
# 自动化整理媒体库,通过 TMDB API 识别电影和电视剧,并使用硬链接创建
|
||||
# 标准化的目录结构。支持 AI 辅助识别、全量缓存、特典映射、季数偏移。
|
||||
#
|
||||
# 功能特性:
|
||||
# - 自动识别和整理电影、电视剧
|
||||
# - TMDB API 集成,支持 Bearer token 和 API Key 认证
|
||||
# - AI 辅助识别(支持 OpenAI API 和兼容接口)
|
||||
# - 三阶段处理流程(识别、AI 纠正、硬链接)
|
||||
# - 全量持久化缓存(镜像 API 路径,一切请求数据皆缓存)
|
||||
# - 特典/番外篇自动识别(TMDB 匹配用 S00E,未匹配用 S00{类型})
|
||||
# - 跨平台支持(Linux/macOS/BusyBox)
|
||||
# - v9.4:增量标记(已链接跳过)、失败冷却(cron 重试退避)、
|
||||
# 纠错账本(--export-map 伴生 .ledger.json + --rerun 重跑)、
|
||||
# 搜索重试链(zh-CN → en-US → MAL 可选)、AI 输入时长/分辨率信号、
|
||||
# 同集冲突检测与报告
|
||||
# - v9.5:用户自定义规则——命名模板(NAMING_* 配置键,电影/剧集/特典/音乐
|
||||
# 命名格式可配置)+ 匹配规则(match_rules.json:搜索别名与 ID 映射,
|
||||
# 确定性兜底,优先于目录名重搜与 AI)
|
||||
# - v9.6:配置按类目分区(special_maps/special_keywords/skip_directories/
|
||||
# season_offsets 支持 movie/tv/music/musicvideo 分区,未配置回退全局表)、
|
||||
# 特典类别判定表外置(special_categories.json)、Music Videos(音乐视频)
|
||||
# 类目识别与命名(MusicVideos/{artist}/{title})
|
||||
#
|
||||
# 最低要求:
|
||||
# - Bash 4.0+
|
||||
# - curl、jq、ffprobe
|
||||
# - 源目录和目的目录在同一文件系统
|
||||
#
|
||||
# 用法:
|
||||
# ./media_organizer.sh [选项] <源目录> <目的目录>
|
||||
#
|
||||
# 选项:
|
||||
# -a, --automated 自动化模式(写入日志,跳过无法处理项目)
|
||||
# --dry-run 干运行模式(仅打印操作,不创建链接)
|
||||
# --refresh-cache 清空全部持久化缓存
|
||||
# --update-cache 基于源目录重新获取并更新缓存
|
||||
# --list-cache [关键词] 列出缓存条目(可选关键词过滤)
|
||||
# --export-map [目录] 生成对照表 markdown + 伴生纠错账本 .ledger.json
|
||||
# --rerun <账本文件> 按账本 action="rerun" 条目重跑硬链接(手动纠错)
|
||||
# -h, --help 显示帮助信息
|
||||
#
|
||||
# 示例:
|
||||
# ./media_organizer.sh /downloads /media
|
||||
# ./media_organizer.sh -a /downloads /media
|
||||
# ./media_organizer.sh --dry-run /downloads /media
|
||||
# ./media_organizer.sh --list-cache 刀剑
|
||||
# ./media_organizer.sh --export-map /downloads /media
|
||||
# ./media_organizer.sh --rerun mo_map/downloads-xxxx.ledger.json
|
||||
#
|
||||
# 配置:
|
||||
# - 参考 config.example.json 创建 config.json 文件
|
||||
# - 查看 CONFIG.md 了解所有配置项
|
||||
# - 查看 TROUBLESHOOTING.md 获取问题解决方案
|
||||
#
|
||||
# 缓存方案(v9.2):
|
||||
# 缓存根目录 mo_cache/ 分两个子目录,TMDB 数据与脚本自身数据分离:
|
||||
# tmdb/ 镜像 TMDB API 路径,一切请求数据全量落盘:
|
||||
# search/movie/<hash>.json /search/movie 搜索结果(键=query&year&lang)
|
||||
# search/tv/<hash>.json /search/tv 搜索结果
|
||||
# tv/<id>.<lang>.json /tv/{id} 剧集详情(原始响应;语言段随 TMDB_LANG)
|
||||
# tv/<id>/season/<n>.<lang>.json /tv/{id}/season/{n} 季数据(含 n=0 特典季)
|
||||
# movie/<id>.<lang>.json /movie/{id} 电影详情(原始响应)
|
||||
# mo_cache/ 分区:tmdb/(可再生 API 缓存)+ media_organizer/(脚本运行状态:账本/冷却)。
|
||||
# 用户配置类文件(可编辑 + 脚本可写回)位于配置目录 mo_config/:
|
||||
# config.json 主配置(JSON)
|
||||
# special_maps.json AI 学习写回的特典映射
|
||||
# special_keywords.json AI 学习写回的特典词表(判定特典用)
|
||||
# skip_directories.json AI 学习写回的跳过目录词表
|
||||
# season_offsets.json 运行时生成的季偏移
|
||||
# match_rules.json 用户匹配规则(搜索别名/ID 映射,用户手工维护)
|
||||
# 搜索缓存为包裹格式(query/year/lang/fetched_at/empty/data),其余直接存原始 JSON。
|
||||
# TTL:CACHE_TTL_DAYS(默认 30,正常数据)/ CACHE_EMPTY_TTL_DAYS(默认 3,查无此片哨兵)。
|
||||
# 并发:tmdb_api 对同一缓存路径用 flock 互斥 + 锁内双检(识别池多 worker 避免重复请求)。
|
||||
# 搜索空结果写 empty 哨兵(3 天短 TTL),新片出现后自动重新搜索。
|
||||
# --refresh-cache 仅清空 tmdb/(API 响应缓存可再生);mo_config/ 配置与 media_organizer/ 运行状态保留。
|
||||
# 旧版自动迁移:mo_cache/{movie,search,tv} → mo_cache/tmdb/;
|
||||
# 配置类文件统一归入 mo_config/ 并使用新命名(special_maps/special_keywords/
|
||||
# skip_directories/season_offsets),旧文件名与旧位置(mo_config/、
|
||||
# mo_cache/media_organizer/ 及更早 mo_ 前缀名)自动迁移。
|
||||
#
|
||||
# 单文件分发说明:
|
||||
# 本脚本为单文件分发,已内置以下模板与默认值,无需携带任何配套文件:
|
||||
# - config.json 完整配置模板(未提供 API 密钥时自动生成到 mo_config/)
|
||||
# - special_maps.json 特典关键词映射(缺失时在用户确认下用常量模板创建到 mo_config/;
|
||||
# AI 学习写回同一位置)
|
||||
# - special_keywords.json 特典识别词表(缺失时在用户确认下用常量模板创建到 mo_config/;
|
||||
# AI 学习到的新特典词写回同一位置)
|
||||
# - skip_directories.json 跳过目录词表(缺失时在用户确认下用常量模板创建到 mo_config/;
|
||||
# AI 学习到的新目录写回同一位置)
|
||||
# - season_offsets.json 常见季偏移默认值(内置,缺失时自动生成到 mo_config/)
|
||||
# - match_rules.json 用户匹配规则(搜索别名/ID 映射,缺失时在用户确认下用
|
||||
# 常量模板创建到 mo_config/;命名模板 NAMING_* 直接在 config.json 配置)
|
||||
# 主配置为 JSON 格式 config.json(生成到 mo_config/;配置项说明见 README)。
|
||||
# 所有配置均可通过环境变量或自动生成的配置文件调整。
|
||||
#
|
||||
# 代码结构:
|
||||
# 1. 常量与全局变量声明
|
||||
# 2. 日志与色彩函数(最先加载,保证全脚本日志输出一致)
|
||||
# 3. 配置加载与认证
|
||||
# 4. 特典映射与季偏移
|
||||
# 5. 字符串工具
|
||||
# 6. 缓存管理(cache_* 系列)
|
||||
# 7. 账本与冷却(ledger:增量标记/失败冷却/纠错重跑)
|
||||
# 8. TMDB API(tmdb_api 统一缓存封装)
|
||||
# 9. 媒体文件名解析
|
||||
# 10. 媒体识别(电影/电视剧)
|
||||
# 11. AI 批处理
|
||||
# 12. 硬链接处理
|
||||
# 13. 错误处理
|
||||
# 14. main()(唯一入口,文件末尾调用)
|
||||
#
|
||||
################################################################################
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
################################################################################
|
||||
# 常量
|
||||
################################################################################
|
||||
|
||||
readonly SCRIPT_VERSION="9.6"
|
||||
readonly TMDB_API_BASE_URL="https://api.themoviedb.org/3"
|
||||
|
||||
# 配置类文件按类目分区的保留键集合(v9.6):
|
||||
# movie/tv/music/musicvideo = 媒体类目分区;video/audio = 流程分区
|
||||
# (目录语义判断发生在类目确定前,按视频/音频流程取值);
|
||||
# default = 通用回退分区;global = 兼容别名。
|
||||
# 分区形态判定:JSON 对象顶层全部键 ∈ 此集合 → 分区形态;否则全局单表形态。
|
||||
readonly MO_CATEGORY_KEYS="movie tv music musicvideo video audio default global"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# AI 提示词常量
|
||||
#
|
||||
# 作用:
|
||||
# AI 批量请求(ai_batch_request)使用的提示词模板。统一置于文件顶部常量区,
|
||||
# 便于维护与测试。运行时会在提示词后追加 "Input: <JSON>" 输入数据。
|
||||
#
|
||||
# 预期返回格式(ai_batch_request 用 jq 解析 .search 与 .special 两个部分):
|
||||
# {
|
||||
# "search": { "<完整 file 路径>": { "search_term": "清洗后的标题", "year": "年份或空串" }, ... },
|
||||
# "special": { "<show_id>|<fragment>": { "english_keyword": "英文标准键", "chinese_keyword": "中文别称" }, ... }
|
||||
# }
|
||||
# - 某类无条目时,对应键为 {}。
|
||||
# - search 的键 = 输入 search_entries 中每项的 "file" 完整路径(逐字节复制)。
|
||||
# - special 的键 = 输入 special_entries 中每项的 "<show_id>|<fragment>"(fragment 保留原数字)。
|
||||
# - 缺失字段用空串 ""。
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# AI 系统消息:强制模型仅输出 JSON,拒绝 Markdown 围栏/注释/尾逗号;
|
||||
# 要求输出键逐字节复制输入键(否则 jq 查不到导致整批静默丢弃)。
|
||||
readonly AI_SYSTEM_MESSAGE='You are a strict JSON-only assistant. Respond with exactly one valid JSON object and nothing else. Do NOT wrap it in Markdown code fences (no ```), do NOT add explanations, comments, or trailing commas. All strings must use double quotes. If a field is unknown or absent, use an empty string "". Every input entry MUST appear as an exact key in the output, with the key copied byte-for-byte from the input.'
|
||||
|
||||
# 批量 AI 提示词(合并搜索纠正 + 特典映射 + 跳过目录学习为一次请求)。
|
||||
# 输入含 search_entries(file/filename/directory/type)与 special_entries(show_id/fragment/series)。
|
||||
# 返回 {"search": {...}, "special": {...}, "skip_dirs": [...]},键规则见上方注释。
|
||||
readonly AI_BATCH_PROMPT='You are a media file organizer. You receive a JSON input with four arrays:
|
||||
- "search_entries": media files that failed TMDB search. Each has "file" (full path), "directory" (path chain relative to source root, max 3 levels), "type" ("tv" or "movie").
|
||||
- "special_entries": unidentifiable special features. Each has "show_id", "fragment" (the local key string from the filename, e.g. "Menu01", "CM01", "Preview02"), "series" (show title), and "season0" (that show'"'"'s TMDB special-season episode list in "episode_number:name; ..." format).
|
||||
- "artist_entries": audio files with multiple artists. Each has "file" (full path), "artists" (pipe-separated artist list, e.g. "Artist1|Artist2"), "album" (album name or "").
|
||||
- "match_entries": files whose TMDB search returned results but need disambiguation (year mismatch, season count suspicious, or suffix-season like "Railgun T"). Each has "file", "type", "year" (parsed year or ""), "season" (file season or ""), "search" (the raw TMDB search response JSON), "seasons" (seasons array of the first candidate: season_number/name/episode_count/air_date; may be [] if unavailable), "duration" (video length in minutes, or "" if unknown), "resolution" (e.g. "1920x1080", or "" if unknown), "siblings" (array of other video filenames in the same directory, up to 20, or []). Use duration to judge content type: TV episodes are ~24 minutes, OVAs/specials ~20-30 minutes, movies/theatrical releases ~70-120 minutes; a file whose duration matches a movie but sits among TV episodes is likely a theatrical release that TMDB lists in season 0. Use "siblings" to judge context: a directory holding a full BD season (many files sharing a title pattern) is a TV series, while a lone movie-length file next to episode files is a theatrical release; sibling names may reveal the release group and the numbering scheme of the whole batch.
|
||||
|
||||
Return ONE top-level JSON object with exactly five keys:
|
||||
- "search": an object mapping each entry'"'"'s exact "file" string (copied byte-for-byte; keep full path, brackets, spaces, ampersands) to {"search_term": "...", "year": "..."}. search_term = clean title for TMDB search: strip only media/release tags (S01E30, S1, E12, [Menu01], [1080p], leading [Group]); if type is "tv" remove episode/season numbers BUT keep ordinal words like "2nd Season"; year = 4-digit year if identifiable, else "". If the input "type" is "unknown", also infer and return "media_type": "movie" or "tv" based on the filename/directory (e.g. contains season/episode markers -> "tv", ends with a year or no episode markers -> "movie").
|
||||
- "special": an object mapping each entry'"'"'s local "fragment" string (exact, keep digits, e.g. "Menu01", "Preview02", "CM01") to an ARRAY of MATCH KEYWORDS — entries from that entry'"'"'s "season0" candidate list that match it, in any language (e.g. "Menu01" -> ["Menu", "菜单"], "Preview02" -> ["Preview", "预告片"], "CM01" -> ["CM", "广告"]). Use the exact strings as written in season0; if no match, use [].
|
||||
- "artist_choice": an object mapping each artist entry'"'"'s exact "file" string to a SINGLE artist string — pick the one most likely to be the album artist (the primary artist credited for the whole album). If truly undecidable, return "".
|
||||
- "match": an object mapping each match entry'"'"'s exact "file" string to {"choice": <id> | "", "season_shift": <int> | "", "search_term": "..."}. choice = the result id from "search" that matches this file; if the correct show is a season-with-suffix of a candidate (e.g. file "Toaru Kagaku no Railgun T S01E01" matches candidate 某科学的超电磁炮 whose season 3 is named 某科学的超电磁炮T), pick the candidate id AND set season_shift so that file season + season_shift = the correct TMDB season number (e.g. file S01 -> season_shift 2 -> S03). If the file is a movie, season_shift must be "". If no candidate matches but a corrected search would help, set choice "" and search_term to the corrected title. If truly no match and no correction, return {"choice": "", "season_shift": "", "search_term": ""}.
|
||||
- "skip_dirs": an array of directory names from the input entries'"'"' "directory" chains that are NOT media (show/movie) directories — special-feature or attached-content directories (e.g. "PV", "CM", "MAD", "预告", "SPs", "特典"), or category folders that hold no media of their own. Only pick names that literally appear in some entry'"'"'s "directory" chain, copied exactly as written; if none, return [].
|
||||
|
||||
If a type has no entries, use {} for that key (for "skip_dirs", use []). Process EVERY entry. Missing fields become "". Output ONLY the JSON object.'
|
||||
|
||||
################################################################################
|
||||
# 配置文件模板
|
||||
#
|
||||
# 以下常量是脚本生成缺失配置文件时使用的默认模板:
|
||||
# CONFIG_DEFS(render_config_template)-> config.json 主配置文件
|
||||
# SPECIAL_KEYMAP_TEMPLATE -> special_maps.json 特典映射
|
||||
# SEASON_OFFSET_TEMPLATE -> season_offsets.json 季数偏移
|
||||
#
|
||||
# 说明:
|
||||
# - 这些常量仅在对应配置文件缺失时,由脚本(在用户确认下)生成默认文件。
|
||||
# - 生成后可自行编辑文件,脚本以文件内容为准,常量不影响运行时的配置。
|
||||
################################################################################
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# ----------------------------------------------------------------------------
|
||||
# CONFIG_DEFS —— 主配置单一数据源(键|默认值)
|
||||
#
|
||||
# 作用:
|
||||
# 生成脚本的主配置文件 config.json(render_config_template),同时是
|
||||
# load_config 的默认值来源,包含全部配置项(TMDB 认证、网络、文件类型、
|
||||
# 缓存映射、输出调试、AI 辅助、自动化日志、高级选项)。
|
||||
#
|
||||
# 用法:
|
||||
# 1. 复制为 config.json:cp config.example.json config.json(或运行脚本交互生成)
|
||||
# 2. 设置权限:chmod 600 config.json
|
||||
# 3. 编辑并填写必要的配置项(至少填写 TMDB_API_RA_TOKEN)
|
||||
# 4. 测试:./media_organizer.sh --dry-run /downloads /media
|
||||
#
|
||||
# 配置方法:
|
||||
# 直接编辑此文件,键值格式 "KEY": "VALUE"。
|
||||
# 配置优先级:环境变量 > config.json 文件 > CONFIG_DEFS 默认值。
|
||||
# ----------------------------------------------------------------------------
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# 模板生成(render_config_template → config.json)与 load_config 默认值
|
||||
# 都从本表读取:新增配置键只需在此加一行,模板与默认值永不漂移。
|
||||
# 特殊键的"后处理"(FOLDER_* 按 locale 补默认、扩展名拆数组、数值校验等)
|
||||
# 在 load_config 循环后执行,默认值仍以本表为准。
|
||||
# NAMING_* 字面值须与 rules.sh 的 naming_template_default 保持一致
|
||||
# (render_naming 兜底共用后者;两处同步修改时以 naming_template_default 为权威)。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly CONFIG_DEFS=(
|
||||
"TMDB_API_KEY|"
|
||||
"TMDB_API_RA_TOKEN|"
|
||||
"TMDB_LANG|zh-CN"
|
||||
"TMDB_DELAY|1"
|
||||
"TMDB_CURL_RETRY|3"
|
||||
"TMDB_CURL_CONNECT_TIMEOUT|10"
|
||||
"TMDB_CURL_MAX_TIME|30"
|
||||
"VIDEO_EXTS|mp4,mkv,avi,mov,wmv,flv,m4v,ts,m2ts,webm"
|
||||
"AUDIO_EXTS|mp3,flac,aac,ogg,wma,m4a,mka,ac3,dts,wav,opus"
|
||||
"SUB_EXTS|srt,ass,ssa,sub,idx,vtt,sup"
|
||||
"SUB_GROUP_BLACKLIST|VCB-Studio"
|
||||
"CACHE_DIR|"
|
||||
"SPECIAL_MAP_FILE|"
|
||||
"SEASON_OFFSET_FILE|"
|
||||
"SPECIAL_WORDS_FILE|"
|
||||
"SKIP_DIRS_FILE|"
|
||||
"SPECIAL_CATEGORIES_FILE|"
|
||||
"MATCH_RULES_FILE|"
|
||||
"NAMING_MOVIE|{title}{?year: ({year})}"
|
||||
"NAMING_SHOW|{title}{?year: ({year})}"
|
||||
"NAMING_SEASON|Season {season:02}"
|
||||
"NAMING_EPISODE|S{season:02}E{episode:02}{range}{?episode_name: - {episode_name}}"
|
||||
"NAMING_SPECIAL|S00{tag} - {fragment}"
|
||||
"NAMING_MUSIC|{artist}/{album}"
|
||||
"NAMING_MUSICVIDEO|{artist}/{title}"
|
||||
"SEARCH_FALLBACK_MAL|false"
|
||||
"MAL_BASE_URL|https://api.jikan.moe/v4"
|
||||
"FAIL_RETRY_COOLDOWN_HOURS|24"
|
||||
"COLOR_OUTPUT|true"
|
||||
"DEBUG_LEVEL|0"
|
||||
"AI_API_KEY|"
|
||||
"AI_BASE_URL|https://api.deepseek.com"
|
||||
"AI_FULL_URL|"
|
||||
"AI_MODEL|DeepSeek-V4-Flash"
|
||||
"AI_MAX_CALLS|10"
|
||||
"AI_BATCH_SIZE|50"
|
||||
"AI_DRY_RUN|false"
|
||||
"AI_SAVE_CASES|false"
|
||||
"AI_CURL_RETRY|3"
|
||||
"AI_CURL_CONNECT_TIMEOUT|10"
|
||||
"AI_CURL_MAX_TIME|30"
|
||||
"LOG_FILE|/var/log/media_organizer.log"
|
||||
"SKIP_LOG_FILE|/var/log/media_organizer_skip.log"
|
||||
"LOG_ROTATE_MB|10"
|
||||
"SKIP_HARDLINK_CHECK|false"
|
||||
"CACHE_TTL_DAYS|30"
|
||||
"CACHE_EMPTY_TTL_DAYS|3"
|
||||
"AUTOMATED|false"
|
||||
"DRY_RUN|false"
|
||||
"FOLDER_MOVIES|"
|
||||
"FOLDER_SHOWS|"
|
||||
"FOLDER_MUSIC|"
|
||||
"FOLDER_MUSICVIDEOS|"
|
||||
"FOLDER_UNKNOWN|"
|
||||
"MEDIA_WORKERS|4"
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SPECIAL_KEYMAP_TEMPLATE —— 特典映射模板(special_maps.json)
|
||||
#
|
||||
# 作用:
|
||||
# 定义特典/番外篇的识别映射:本地关键字符串(文件中的特典标记)→ TMDB 可匹配关键字数组。
|
||||
# 脚本用数组内任一值去匹配文件名与 TMDB 季 0 的特典名称。
|
||||
#
|
||||
# 用法:
|
||||
# 1. 文件缺失时,脚本在用户确认下用此模板生成 special_maps.json
|
||||
# 2. 可编辑该文件增删映射(键=本地关键字符串,值=TMDB 可匹配关键字)
|
||||
#
|
||||
# 多键→多值:
|
||||
# 值为 JSON 数组(多语言,如 ["Menu","菜单","メニュー"]);
|
||||
# 值为字符串时表示引用另一键的数组(多键共享一组值,如不同压制组的
|
||||
# Menu01/Menu 01/MENU01 都指向 "menu")。
|
||||
# 示例:{"menu": ["Menu", "菜单"], "menu01": "menu", "menu_1": "menu"}
|
||||
# 识别时先用本地字符串精确查表,再按去数字后的核心词查表;数组内任一值命中 TMDB 特典名即匹配。
|
||||
#
|
||||
# 注意:仅用于创建默认文件,不作为运行时默认映射(匹配完全依赖文件内容)。
|
||||
# v9.7 起模板清空(无内置示例数据):生成空映射 {},真实映射由 AI 学习写回与用户手动添加。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly SPECIAL_KEYMAP_TEMPLATE='{}'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SPECIAL_WORDS_TEMPLATE —— 特典识别词表模板(special_keywords.json)
|
||||
#
|
||||
# 作用:
|
||||
# 特典识别的判定词(文件名方括号标记含这些词 → 判定为特典)。
|
||||
# 语义 = 特典类别词(Menu/CM/PV/NCOP/NCED 等),匹配对空格不敏感
|
||||
# (词表 "ncop" 可匹配文件名里的 "nc op")。
|
||||
# 词表内容不当作正则(纯字符串子串匹配)。
|
||||
# v9.7 起模板清空(无内置示例数据):生成空词表 [],真实词由 AI 学习写回与用户手动添加。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly SPECIAL_WORDS_TEMPLATE='[]'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# MUSICVIDEO_WORDS_TEMPLATE —— 音乐视频判定词表(special_keywords.json 的
|
||||
# musicvideo 分区默认值,v9.6)
|
||||
#
|
||||
# 作用:
|
||||
# Music Videos 类目识别信号(detect_musicvideo):
|
||||
# - 目录信号:目录名**精确匹配**(归一化去空格小写后整名相等)词表词
|
||||
# ("Music Videos"/"MV"/"Live"/"演唱会" 等 → 整目录音乐视频)。
|
||||
# - 文件名信号:方括号标记**子串**命中词表词,且未命中 tv 特典词
|
||||
# ([MV]/[Live] → 音乐视频;[PV]/[CM] 不在此表 → 保持特典路径)。
|
||||
# 独立于 tv 特典词表(不回退 SPECIAL_WORDS——"sp" 子串命中 "SPs" 目录等
|
||||
# 交叉误判):查询链 = musicvideo 分区 → default 分区 → 本内置默认。
|
||||
# 词表可经 special_keywords.json 的 musicvideo 分区覆盖。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly MUSICVIDEO_WORDS_TEMPLATE='["mv","music video","music videos","musicvideo","musicvideos","music clip","video clip","live","concert","performance","演唱会","音乐视频","音乐录影带","现场"]'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SPECIAL_CATEGORIES_TEMPLATE —— 特典类别判定表模板(special_categories.json)
|
||||
#
|
||||
# 作用:
|
||||
# S00 未匹配特典的类别标签判定表(S00{类型} - {片段} 的"类型"):
|
||||
# fragment 子串按数组顺序(=优先级,长词/特定词在前)匹配 → 取标签。
|
||||
# 外置为可配置(v9.6):用户可增删类别或调整顺序;文件缺失时脚本在用户
|
||||
# 确认下用此模板创建;未命中用户表时回退内置默认表(见 special_category_tag),
|
||||
# 仍未命中 → "Special"。
|
||||
#
|
||||
# 注意:仅用于创建默认文件;运行时以文件内容为准。
|
||||
# v9.7 起模板清空(无内置示例数据):生成空判定表 [],真实类别由用户手动添加
|
||||
# (未命中用户表时仍回退 special_category_tag 内置默认表)。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly SPECIAL_CATEGORIES_TEMPLATE='[]'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SKIP_DIRS_TEMPLATE —— 跳过目录词表模板(skip_directories.json)
|
||||
#
|
||||
# 作用:
|
||||
# 不作为"媒体名目录"的目录词(特典/附带目录 + 分类目录):
|
||||
# - find_show_path_from_file / find_show_dir_from_path:目录名精确匹配(忽略大小写)
|
||||
# - count_main_videos:路径段精确匹配
|
||||
# 内容不当作正则(纯字符串比较)。
|
||||
# v9.7 起模板清空(无内置示例数据):生成空词表 [],真实目录词由 AI 学习写回与用户手动添加。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly SKIP_DIRS_TEMPLATE='[]'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SEASON_OFFSET_TEMPLATE —— 季数偏移模板(season_offsets.json)
|
||||
#
|
||||
# 作用:
|
||||
# 定义常见剧集的季数偏移值,用于处理 TMDB 季数与实际集数不符的情况
|
||||
# (如 TMDB 只有一季,但资源实际有 S01-S03)。
|
||||
#
|
||||
# 用法:
|
||||
# 1. 文件缺失时,脚本用此模板生成 season_offsets.json
|
||||
# 2. 可编辑该文件覆盖/新增偏移值
|
||||
#
|
||||
# 配置方法:
|
||||
# 键为剧名(小写)或 "id:TMDB_ID",值为第一季的集数(偏移基准)。
|
||||
# 示例:{"jujutsu kaisen": 23} 表示第一季有 23 集,后续季自动 +23 偏移。
|
||||
# v9.7 起模板清空(无内置示例数据):生成空偏移表 {},真实偏移由用户手动添加。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly SEASON_OFFSET_TEMPLATE='{}'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# MATCH_RULES_TEMPLATE —— 匹配规则文件模板(match_rules.json)
|
||||
#
|
||||
# 作用:
|
||||
# 定义用户手工维护的确定性匹配规则——搜索别名与 ID 映射。
|
||||
# 文件缺失时,脚本在用户确认下用此模板创建(内容为空规则,直接编辑生效)。
|
||||
#
|
||||
# 结构:
|
||||
# search_aliases: 标题(文件名清洗后的标题,小写+空白折叠匹配)→ 重搜词。
|
||||
# 主搜索(zh-CN → en-US)无结果时,用重搜词再搜一次(可带 year)——
|
||||
# 确定性兜底,优先于目录名重搜与 AI。适合本地俗称/简称(如 "俺妹")。
|
||||
# 值可为字符串(重搜词)或对象 {"term": "...", "year": "..."}。
|
||||
# id_maps: 标题 → TMDB ID(movie/tv 分表)。标题命中直接使用指定 ID,
|
||||
# 完全跳过搜索(最高优先级)——适合 TMDB 多候选易选错/搜索命中错条目的场景。
|
||||
#
|
||||
# 注意:仅用于创建默认文件;运行时规则完全以文件内容为准。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly MATCH_RULES_TEMPLATE='{
|
||||
"search_aliases": {},
|
||||
"id_maps": {
|
||||
"movie": {},
|
||||
"tv": {}
|
||||
}
|
||||
}'
|
||||
|
||||
################################################################################
|
||||
# 全局变量
|
||||
################################################################################
|
||||
|
||||
SCRIPT_DIR=""
|
||||
|
||||
# 运行模式标志。
|
||||
# 可用 ${VAR:-} 保形初始化:AUTOMATED/DRY_RUN 允许从环境变量读取(优先级链第一层),
|
||||
# 直接赋 "" 的其余标志为 CLI 专用(--list-cache / --update-cache / --refresh-cache),忽略环境变量。
|
||||
AUTOMATED="${AUTOMATED:-}"
|
||||
DRY_RUN="${DRY_RUN:-}"
|
||||
REFRESH_CACHE=""
|
||||
UPDATE_CACHE=""
|
||||
LIST_CACHE=""
|
||||
LIST_CACHE_FILTER=""
|
||||
# 源→目标对照表导出:空=关闭;"true"=默认目录(脚本所在目录/mo_map);非空=指定目录
|
||||
EXPORT_MAP=""
|
||||
EXPORT_MAP_DIR=""
|
||||
# 手动纠错重跑:--rerun <账本文件>(独立形状,不识别只链接)
|
||||
RERUN_FILE=""
|
||||
|
||||
# 目录和文件路径(SOURCE_DIR/DESTINATION_DIR 仅来自 CLI;保形初始化便于测试环境注入)
|
||||
SOURCE_DIR="${SOURCE_DIR:-}"
|
||||
DESTINATION_DIR="${DESTINATION_DIR:-}"
|
||||
CACHE_DIR="${CACHE_DIR:-}"
|
||||
CONFIG_FILE=""
|
||||
|
||||
# 目的目录根名(本地化,默认跟随系统语言;load_config 中按 FOLDER_* 配置链赋值;
|
||||
# 保形初始化避免吞掉环境变量)
|
||||
FOLDER_MOVIES="${FOLDER_MOVIES:-}"
|
||||
FOLDER_SHOWS="${FOLDER_SHOWS:-}"
|
||||
FOLDER_MUSIC="${FOLDER_MUSIC:-}"
|
||||
FOLDER_MUSICVIDEOS="${FOLDER_MUSICVIDEOS:-}"
|
||||
FOLDER_UNKNOWN="${FOLDER_UNKNOWN:-}"
|
||||
|
||||
# TMDB 认证信息
|
||||
TMDB_AUTH_TOKEN=""
|
||||
TMDB_USE_BEARER=false
|
||||
|
||||
# 配置变量
|
||||
# 默认值由 load_config() 统一设置(${VAR:-config:-default}),支持环境变量和 config.json 覆盖;
|
||||
# 此处用 ${VAR:-} 保形初始化,避免顶层赋空串吞掉环境变量
|
||||
TMDB_LANG="${TMDB_LANG:-}"
|
||||
TMDB_DELAY="${TMDB_DELAY:-}"
|
||||
COLOR_OUTPUT="${COLOR_OUTPUT:-}"
|
||||
DEBUG_LEVEL="${DEBUG_LEVEL:-}"
|
||||
|
||||
# 扩展名(字符串保形,load_config 中 read -ra 转为数组)
|
||||
VIDEO_EXTS="${VIDEO_EXTS:-}"
|
||||
AUDIO_EXTS="${AUDIO_EXTS:-}"
|
||||
SUB_EXTS="${SUB_EXTS:-}"
|
||||
|
||||
# AI 调用统计
|
||||
AI_CALL_COUNT=0
|
||||
|
||||
# curl 网络配置
|
||||
TMDB_CURL_RETRY="${TMDB_CURL_RETRY:-}"
|
||||
TMDB_CURL_CONNECT_TIMEOUT="${TMDB_CURL_CONNECT_TIMEOUT:-}"
|
||||
TMDB_CURL_MAX_TIME="${TMDB_CURL_MAX_TIME:-}"
|
||||
AI_CURL_RETRY="${AI_CURL_RETRY:-}"
|
||||
AI_CURL_CONNECT_TIMEOUT="${AI_CURL_CONNECT_TIMEOUT:-}"
|
||||
AI_CURL_MAX_TIME="${AI_CURL_MAX_TIME:-}"
|
||||
|
||||
# AI 配置
|
||||
AI_API_KEY="${AI_API_KEY:-}"
|
||||
AI_BASE_URL="${AI_BASE_URL:-}"
|
||||
AI_FULL_URL="${AI_FULL_URL:-}"
|
||||
AI_MODEL="${AI_MODEL:-}"
|
||||
AI_MAX_CALLS="${AI_MAX_CALLS:-}"
|
||||
AI_DRY_RUN="${AI_DRY_RUN:-}"
|
||||
# AI 用例落盘(true 时每次请求的输入/响应存 mo_cache/media_organizer/ai_cases/)
|
||||
AI_SAVE_CASES="${AI_SAVE_CASES:-}"
|
||||
|
||||
# 搜索重试链(zh-CN 空 → en-US 重搜 → MAL 可选兜底)
|
||||
SEARCH_FALLBACK_MAL="${SEARCH_FALLBACK_MAL:-}"
|
||||
MAL_BASE_URL="${MAL_BASE_URL:-}"
|
||||
|
||||
# 失败冷却(小时;0=禁用)
|
||||
FAIL_RETRY_COOLDOWN_HOURS="${FAIL_RETRY_COOLDOWN_HOURS:-}"
|
||||
|
||||
# 日志文件
|
||||
LOG_FILE="${LOG_FILE:-}"
|
||||
SKIP_LOG_FILE="${SKIP_LOG_FILE:-}"
|
||||
# 日志轮转阈值(MB;0=禁用,超阈值时滚动保留 5 份)
|
||||
LOG_ROTATE_MB="${LOG_ROTATE_MB:-}"
|
||||
|
||||
# 映射配置文件(init_special_map/init_season_offset/init_special_words/init_skip_dirs 中解析默认路径)
|
||||
SPECIAL_MAP_FILE="${SPECIAL_MAP_FILE:-}"
|
||||
SEASON_OFFSET_FILE="${SEASON_OFFSET_FILE:-}"
|
||||
SPECIAL_WORDS_FILE="${SPECIAL_WORDS_FILE:-}"
|
||||
SKIP_DIRS_FILE="${SKIP_DIRS_FILE:-}"
|
||||
# 特典类别判定表(special_categories.json,v9.6 外置;init_special_categories 解析默认路径)
|
||||
SPECIAL_CATEGORIES_FILE="${SPECIAL_CATEGORIES_FILE:-}"
|
||||
# 用户自定义规则:匹配规则文件路径(init_match_rules 解析默认路径)+ 命名模板
|
||||
# (NAMING_* 为命名模板字符串,load_config 赋默认值;渲染见 render_naming_template)
|
||||
MATCH_RULES_FILE="${MATCH_RULES_FILE:-}"
|
||||
NAMING_MOVIE="${NAMING_MOVIE:-}"
|
||||
NAMING_SHOW="${NAMING_SHOW:-}"
|
||||
NAMING_SEASON="${NAMING_SEASON:-}"
|
||||
NAMING_EPISODE="${NAMING_EPISODE:-}"
|
||||
NAMING_SPECIAL="${NAMING_SPECIAL:-}"
|
||||
NAMING_MUSIC="${NAMING_MUSIC:-}"
|
||||
NAMING_MUSICVIDEO="${NAMING_MUSICVIDEO:-}"
|
||||
# 本批 AI 输入目录链中出现的目录名集合(skip_dirs 学习候选;防 AI 幻觉)
|
||||
declare -a AI_DIR_CHAIN=()
|
||||
# 特典识别词表(init_special_words 加载;worker 经 fork 复制可见;
|
||||
# 全局形态=词表文件内容+内置兜底;分区形态下为内置兜底,分区词见 SPECIAL_WORDS_CAT)
|
||||
declare -a SPECIAL_WORDS=()
|
||||
# 音乐视频判定词表(内置默认 MUSICVIDEO_WORDS_TEMPLATE;可经 musicvideo 分区覆盖;
|
||||
# 查询链:musicvideo 分区 → default 分区 → 本数组——不回退 tv 特典词表)
|
||||
declare -a MUSICVIDEO_WORDS=()
|
||||
# 跳过目录词表(init_skip_dirs 加载;SKIP_DIRS_LOWER 为小写化版本供精确匹配)
|
||||
declare -a SKIP_DIRS=()
|
||||
declare -a SKIP_DIRS_LOWER=()
|
||||
# 特典类别判定表(special_categories.json 加载;元素 = "match|tag",顺序=优先级)
|
||||
declare -a SPECIAL_CATEGORIES=()
|
||||
|
||||
# 关联数组
|
||||
# 特典映射(本地关键字符串 -> TMDB 字符串,一对一;全局形态表)
|
||||
declare -A SPECIAL_MAP=()
|
||||
# 特典映射原始值缓存(值可为 JSON 数组或字符串引用,供 resolve_special_value 展开)
|
||||
declare -A RAW_SPECIAL_MAP=()
|
||||
# 特典映射类目分区表(v9.6:键 = "类目|关键字符串";查询类目分区 → default 分区 → 全局表)
|
||||
declare -A SPECIAL_MAP_CAT=()
|
||||
declare -A RAW_SPECIAL_MAP_CAT=()
|
||||
# 特典识别词类目分区表(v9.6:键 = "类目|归一化词",值为 1;查询类目分区 → default 分区 → 全局数组)
|
||||
declare -A SPECIAL_WORDS_CAT=()
|
||||
# 跳过目录类目分区表(v9.6:键 = "类目|小写目录名";查询流程分区 → default 分区 → 全局数组)
|
||||
declare -A SKIP_DIRS_CAT=()
|
||||
# 季数偏移类目分区表(v9.6:键 = "类目|剧名小写";查询类目分区 → default 分区 → 全局表)
|
||||
declare -A SEASON_OFFSET_CAT=()
|
||||
# 剧名/剧ID -> 季数偏移(全局形态表)
|
||||
declare -A SEASON_OFFSET_MAP=()
|
||||
# 用户匹配规则(load_match_rules 加载;键经 rule_key 归一化):
|
||||
# 搜索别名(标题键 -> 重搜词/年份)与 ID 映射(标题键 -> TMDB ID,movie/tv 分表)
|
||||
declare -A MATCH_RULES_ALIAS_TERM=()
|
||||
declare -A MATCH_RULES_ALIAS_YEAR=()
|
||||
declare -A MATCH_RULES_ID_MOVIE=()
|
||||
declare -A MATCH_RULES_ID_TV=()
|
||||
# 源文件路径 -> 目的信息(子目录|文件名,两段式;key=源文件)
|
||||
declare -A MEDIA_DESTINATION_MAP=()
|
||||
# 结局账本:源文件路径 -> 结局类别(identified/fallback/skip_type/skip_unidentified/request_failed/pending_ai)
|
||||
# 汇总报告唯一数据源;跳过/失败条目不进入目的映射
|
||||
declare -A MEDIA_OUTCOME_MAP=()
|
||||
# 待 AI 纠正的搜索项
|
||||
declare -A PENDING_AI_SEARCH=()
|
||||
# 待 AI 判定的多艺术家音频项(key=音频文件,value=候选艺术家|专辑)
|
||||
declare -A PENDING_AI_ARTIST=()
|
||||
# AI 对多艺术家的判定结果(key=音频文件,value=选定的专辑艺术家或空)
|
||||
declare -A AI_ARTIST_CHOICE=()
|
||||
# 待 AI 匹配甄别的条目(key=文件,value=type\tyear\tseason\tendpoint\tkey——搜索响应在缓存中)
|
||||
declare -A PENDING_AI_MATCH=()
|
||||
# AI 匹配结果(key=文件,value=选定 id / 季偏移 / 重搜词)
|
||||
declare -A AI_MATCH_CHOICE=()
|
||||
declare -A AI_MATCH_SHIFT=()
|
||||
declare -A AI_MATCH_TERM=()
|
||||
# 本批 AI 请求覆盖的 key 集合(消费端只处理响应覆盖的条目,批外留待下一批)
|
||||
declare -a AI_BATCH_KEYS_SEARCH=()
|
||||
declare -a AI_BATCH_KEYS_SPECIAL=()
|
||||
declare -a AI_BATCH_KEYS_ARTIST=()
|
||||
declare -a AI_BATCH_KEYS_MATCH=()
|
||||
# AI 响应中实际出现的 key(AI 漏了的条目留待下一批)
|
||||
declare -A AI_RESPONDED_SEARCH=()
|
||||
declare -A AI_RESPONDED_ARTIST=()
|
||||
declare -A AI_RESPONDED_MATCH=()
|
||||
# 待 AI 学习的特典项
|
||||
declare -A PENDING_AI_SPECIAL=()
|
||||
|
||||
# 数组
|
||||
declare -a VIDEO_FILES=()
|
||||
# 音频文件(CD 音乐,归入 Music 分类)
|
||||
declare -a AUDIO_FILES=()
|
||||
# 媒体目录正片数量缓存(避免重复扫描,键=show 目录完整路径)
|
||||
declare -A MAIN_COUNT_CACHE=()
|
||||
# 同集冲突:key=剧集目录||SxxExx(含区间),value=多行 "src|dest"(detect_conflicts 构建)
|
||||
declare -A CONFLICT_MAP=()
|
||||
|
||||
# 计数器
|
||||
PENDING_SEARCH_COUNT=0
|
||||
PENDING_SPECIAL_COUNT=0
|
||||
PENDING_ARTIST_COUNT=0
|
||||
PENDING_MATCH_COUNT=0
|
||||
INDENT_LEVEL=0
|
||||
|
||||
# 链接阶段计数(供运行级汇总与退出码分级)
|
||||
LINK_SUCCESS_COUNT=0
|
||||
LINK_FAIL_COUNT=0
|
||||
|
||||
# 协议行合并统计(merge_emit_lines 输出,供 pool_run 阈值检查)
|
||||
MERGE_PROCESSED=0
|
||||
MERGE_FAILED=0
|
||||
MERGE_STREAK=0
|
||||
|
||||
# 高级选项
|
||||
SKIP_HARDLINK_CHECK="${SKIP_HARDLINK_CHECK:-}"
|
||||
|
||||
# 色彩代码(init_colors() 中按 COLOR_OUTPUT 赋值)
|
||||
C_RESET=''
|
||||
C_RED=''
|
||||
C_GREEN=''
|
||||
C_YELLOW=''
|
||||
C_BLUE=''
|
||||
C_MAGENTA=''
|
||||
C_GRAY=''
|
||||
C_WHITE=''
|
||||
C_DIM=''
|
||||
C_LIGHT_CYAN=''
|
||||
-218
@@ -1,218 +0,0 @@
|
||||
################################################################################
|
||||
# 特典映射与季偏移
|
||||
################################################################################
|
||||
|
||||
# 初始化特典映射:解析默认路径,若文件不存在则交互创建。
|
||||
init_special_map() {
|
||||
local file="$1"
|
||||
# 解析默认路径(优先级:环境变量 > 执行目录 mo_config > 脚本目录 mo_config)
|
||||
if [[ -z "$file" ]]; then
|
||||
if [[ -f "$PWD/mo_config/mo_special_keymap.json" ]]; then
|
||||
file="$PWD/mo_config/mo_special_keymap.json"
|
||||
else
|
||||
file="$SCRIPT_DIR/mo_config/mo_special_keymap.json"
|
||||
fi
|
||||
SPECIAL_MAP_FILE="$file"
|
||||
fi
|
||||
if [[ ! -f "$file" ]]; then
|
||||
if [[ "$AUTOMATED" == "true" ]]; then
|
||||
_log 警告 "未找到特典映射文件,自动化模式跳过创建: $file"
|
||||
else
|
||||
_log 信息 "未找到特典映射文件: $file"
|
||||
_log 信息 "是否创建默认配置文件?(y/N)"
|
||||
read -r answer || true # EOF(非交互)时跳过创建
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
# 子 shell 设置 umask 077,确保创建的文件权限即 600
|
||||
(umask 077 && printf '%s\n' "$SPECIAL_KEYMAP_TEMPLATE" | jq . >"$file" 2>/dev/null) || true
|
||||
if [[ ! -s "$file" ]]; then
|
||||
echo '{}' >"$file"
|
||||
fi
|
||||
# 兜底:确保权限为 600(不静默失败)
|
||||
chmod 600 "$file" 2>/dev/null || _log 警告 "无法设置文件权限为 600: $file"
|
||||
_log 信息 "已创建特典映射文件: $file"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
load_special_map "$file"
|
||||
}
|
||||
|
||||
# 初始化跳过目录词表(mo_skip_dirs.json):解析默认路径,缺失时交互创建或使用内置默认。
|
||||
# 语义 = 不作为媒体名目录的目录词(特典/附带 + 分类目录);内容不当作正则(精确匹配)。
|
||||
init_skip_dirs() {
|
||||
local file="$1"
|
||||
if [[ -z "$file" ]]; then
|
||||
if [[ -f "$PWD/mo_config/mo_skip_dirs.json" ]]; then
|
||||
file="$PWD/mo_config/mo_skip_dirs.json"
|
||||
else
|
||||
file="$SCRIPT_DIR/mo_config/mo_skip_dirs.json"
|
||||
fi
|
||||
SKIP_DIRS_FILE="$file"
|
||||
fi
|
||||
if [[ ! -f "$file" ]]; then
|
||||
if [[ "$AUTOMATED" == "true" ]]; then
|
||||
_log 警告 "未找到跳过目录词表文件,自动化模式使用内置默认: $file"
|
||||
else
|
||||
_log 信息 "未找到跳过目录词表文件: $file"
|
||||
_log 信息 "是否创建默认配置文件?(y/N)"
|
||||
read -r answer || true # EOF(非交互)时跳过创建
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
(umask 077 && printf '%s\n' "$SKIP_DIRS_TEMPLATE" | jq . >"$file" 2>/dev/null) || true
|
||||
if [[ ! -s "$file" ]]; then
|
||||
echo '[]' >"$file"
|
||||
fi
|
||||
chmod 600 "$file" 2>/dev/null || _log 警告 "无法设置文件权限为 600: $file"
|
||||
_log 信息 "已创建跳过目录词表文件: $file"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
SKIP_DIRS=()
|
||||
SKIP_DIRS_LOWER=()
|
||||
if [[ -f "$file" ]] && jq -e 'type == "array"' "$file" >/dev/null 2>&1; then
|
||||
mapfile -t SKIP_DIRS < <(jq -r '.[] | select(. != "")' "$file" 2>/dev/null)
|
||||
fi
|
||||
if [[ ${#SKIP_DIRS[@]} -eq 0 ]]; then
|
||||
mapfile -t SKIP_DIRS < <(echo "$SKIP_DIRS_TEMPLATE" | jq -r '.[]')
|
||||
fi
|
||||
local d
|
||||
for d in "${SKIP_DIRS[@]}"; do
|
||||
SKIP_DIRS_LOWER+=("${d,,}")
|
||||
done
|
||||
_log 调试 "跳过目录词表 ${#SKIP_DIRS[@]} 个"
|
||||
}
|
||||
|
||||
# 初始化特典识别词表(mo_special_words.json):解析默认路径,缺失时交互创建或使用内置默认。
|
||||
# 词表语义 = 特典类别词(Menu/CM/PV 等);匹配对空格不敏感;内容不当作正则。
|
||||
init_special_words() {
|
||||
local file="$1"
|
||||
if [[ -z "$file" ]]; then
|
||||
if [[ -f "$PWD/mo_config/mo_special_words.json" ]]; then
|
||||
file="$PWD/mo_config/mo_special_words.json"
|
||||
else
|
||||
file="$SCRIPT_DIR/mo_config/mo_special_words.json"
|
||||
fi
|
||||
SPECIAL_WORDS_FILE="$file"
|
||||
fi
|
||||
if [[ ! -f "$file" ]]; then
|
||||
if [[ "$AUTOMATED" == "true" ]]; then
|
||||
_log 警告 "未找到特典词表文件,自动化模式使用内置默认: $file"
|
||||
else
|
||||
_log 信息 "未找到特典词表文件: $file"
|
||||
_log 信息 "是否创建默认配置文件?(y/N)"
|
||||
read -r answer || true # EOF(非交互)时跳过创建
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
(umask 077 && printf '%s\n' "$SPECIAL_WORDS_TEMPLATE" | jq . >"$file" 2>/dev/null) || true
|
||||
if [[ ! -s "$file" ]]; then
|
||||
echo '[]' >"$file"
|
||||
fi
|
||||
chmod 600 "$file" 2>/dev/null || _log 警告 "无法设置文件权限为 600: $file"
|
||||
_log 信息 "已创建特典词表文件: $file"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# 加载词表(JSON 数组);缺失/非法/空 → 内置默认
|
||||
SPECIAL_WORDS=()
|
||||
if [[ -f "$file" ]] && jq -e 'type == "array"' "$file" >/dev/null 2>&1; then
|
||||
mapfile -t SPECIAL_WORDS < <(jq -r '.[] | select(. != "")' "$file" 2>/dev/null)
|
||||
fi
|
||||
if [[ ${#SPECIAL_WORDS[@]} -eq 0 ]]; then
|
||||
mapfile -t SPECIAL_WORDS < <(echo "$SPECIAL_WORDS_TEMPLATE" | jq -r '.[]')
|
||||
fi
|
||||
_log 调试 "特典识别词表 ${#SPECIAL_WORDS[@]} 个"
|
||||
}
|
||||
|
||||
# 加载特典映射(JSON 对象:{"本地关键字符串": 值})。
|
||||
# 构建 SPECIAL_MAP(本地字符串 → TMDB 可匹配关键字 JSON 数组,多键→多值)。
|
||||
# 值可为:① JSON 数组(直接多值,如 ["Menu","菜单","メニュー"]);
|
||||
# ② 字符串(引用另一键的数组,如 "menu01": "menu" 复用 menu 的数组)。
|
||||
# —— 不同压制组特典命名不同(Menu01/Menu 01/MENU01),多键共享同一组 TMDB 关键字,避免重复定义。
|
||||
load_special_map() {
|
||||
local map_file="$1"
|
||||
[[ ! -f "$map_file" ]] && return
|
||||
|
||||
if ! jq -e 'type == "object"' "$map_file" >/dev/null 2>&1; then
|
||||
_log 错误 "特典映射文件必须为 JSON 对象格式: $map_file"
|
||||
return
|
||||
fi
|
||||
|
||||
# 第一遍:读取全部原始值(数组或字符串引用)
|
||||
local local_str raw
|
||||
RAW_SPECIAL_MAP=()
|
||||
while IFS=$'\t' read -r local_str raw; do
|
||||
[[ -z "$local_str" ]] && continue
|
||||
RAW_SPECIAL_MAP["${local_str,,}"]="$raw"
|
||||
done < <(jq -r 'to_entries[] | "\(.key)\t\(.value | @json)"' "$map_file")
|
||||
|
||||
# 第二遍:展开引用(多键→多值),SPECIAL_MAP 值统一为 JSON 数组
|
||||
local key
|
||||
for key in "${!RAW_SPECIAL_MAP[@]}"; do
|
||||
SPECIAL_MAP["$key"]="$(resolve_special_value "$key")"
|
||||
done
|
||||
}
|
||||
|
||||
# 解析特典键的最终数组值:值为数组直接返回;值为字符串视为引用另一键(递归展开引用链,防循环)。
|
||||
# 引用键不存在时把引用串本身当单元素数组。
|
||||
resolve_special_value() {
|
||||
local key="$1" depth="${2:-0}" v ref
|
||||
[[ $depth -gt 20 ]] && {
|
||||
echo "[]"
|
||||
return
|
||||
}
|
||||
v="${RAW_SPECIAL_MAP[$key]:-}"
|
||||
if [[ -n "$v" ]] && echo "$v" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
||||
echo "$v"
|
||||
elif [[ -z "$v" ]]; then
|
||||
# 键不存在:视为单元素数组
|
||||
jq -n --arg v "$key" '[$v]'
|
||||
else
|
||||
ref="${v#\"}"
|
||||
ref="${ref%\"}"
|
||||
if [[ -n "${RAW_SPECIAL_MAP[$ref]:-}" ]]; then
|
||||
resolve_special_value "$ref" $((depth + 1))
|
||||
else
|
||||
jq -n --arg v "$ref" '[$v]'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 初始化季偏移配置:加载外部 JSON,缺失时用内置默认值生成。
|
||||
init_season_offset() {
|
||||
SEASON_OFFSET_FILE="${SEASON_OFFSET_FILE:-}"
|
||||
# 解析默认路径(优先级:环境变量 > 执行目录 mo_config > 脚本目录 mo_config)
|
||||
if [[ -z "$SEASON_OFFSET_FILE" ]]; then
|
||||
if [[ -f "$PWD/mo_config/season_offset.json" ]]; then
|
||||
SEASON_OFFSET_FILE="$PWD/mo_config/season_offset.json"
|
||||
else
|
||||
SEASON_OFFSET_FILE="$SCRIPT_DIR/mo_config/season_offset.json"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 内置季偏移默认值(来自 SEASON_OFFSET_TEMPLATE 常量)
|
||||
if [[ -n "$SEASON_OFFSET_TEMPLATE" ]]; then
|
||||
while IFS=$'\t' read -r k v; do
|
||||
[[ -z "$k" ]] && continue
|
||||
SEASON_OFFSET_MAP["${k,,}"]="$v"
|
||||
done < <(printf '%s\n' "$SEASON_OFFSET_TEMPLATE" | jq -r 'to_entries[] | "\(.key)\t\(.value)"')
|
||||
fi
|
||||
|
||||
# 加载外部 JSON 配置覆盖默认值
|
||||
if [[ -f "$SEASON_OFFSET_FILE" ]]; then
|
||||
if jq -e 'type == "object"' "$SEASON_OFFSET_FILE" >/dev/null 2>&1; then
|
||||
while IFS=$'\t' read -r key value; do
|
||||
[[ -z "$key" ]] && continue
|
||||
SEASON_OFFSET_MAP["${key,,}"]="$value"
|
||||
done < <(jq -r 'to_entries[] | "\(.key)\t\(.value)"' "$SEASON_OFFSET_FILE")
|
||||
else
|
||||
_log 错误 "季偏移配置文件必须为 JSON 对象格式: $SEASON_OFFSET_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 生成默认季偏移配置文件(JSON 对象格式)
|
||||
if [[ ! -f "$SEASON_OFFSET_FILE" ]]; then
|
||||
offset_json="{}"
|
||||
for key in "${!SEASON_OFFSET_MAP[@]}"; do
|
||||
offset_json=$(printf '%s' "$offset_json" | jq --arg k "$key" --argjson v "${SEASON_OFFSET_MAP[$key]}" '.[$k] = $v')
|
||||
done
|
||||
printf '%s\n' "$offset_json" | jq . >"$SEASON_OFFSET_FILE" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1,29 +1,53 @@
|
||||
# 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_title="$1" year_tag="$2" fragment="$3" ext="$4"
|
||||
local clean_fragment sp_type sp_num s00_tag
|
||||
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{类别} - {片段}(类别不带编号,编号保留在片段中,如 S00Menu - Menu01)
|
||||
# 命名约定(默认模板):S00{类别} - {片段}(类别不带编号,编号保留在片段中)
|
||||
sp_type="$(special_category_tag "$fragment")"
|
||||
s00_tag="${sp_type:-Special}"
|
||||
echo "${FOLDER_SHOWS}/${show_title}${year_tag}/Season 00|S00${s00_tag} - ${clean_fragment}.${ext}"
|
||||
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 kw tag
|
||||
while IFS='|' read -r kw tag; do
|
||||
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
|
||||
@@ -56,25 +80,44 @@ EOF
|
||||
|
||||
# 回退命名(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 year_tag=""
|
||||
local safe_title s_fmt e_fmt
|
||||
safe_title=$(sanitize "$title")
|
||||
[[ -n "$year" ]] && year_tag=" (${year})"
|
||||
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
|
||||
echo "${FOLDER_MOVIES}/${safe_title}${year_tag}|${safe_title}${year_tag}.${ext}"
|
||||
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 "$safe_title" "$year_tag" "$fragment" "$ext"
|
||||
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=""
|
||||
local ep_range="" e_fmt_end=""
|
||||
if [[ -n "$episode_end" ]]; then
|
||||
local e_fmt_end
|
||||
e_fmt_end=$(safe_printf_int "$episode_end" "00")
|
||||
ep_range="-E${e_fmt_end}"
|
||||
fi
|
||||
echo "${FOLDER_SHOWS}/${safe_title}${year_tag}/Season ${s_fmt}|S${s_fmt}E${e_fmt}${ep_range}.${ext}"
|
||||
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
|
||||
}
|
||||
|
||||
@@ -97,11 +140,16 @@ resolve_pending_artists() {
|
||||
choice="${AI_ARTIST_CHOICE[$audio]:-}"
|
||||
local artist="$choice"
|
||||
if [[ -z "$artist" ]]; then
|
||||
artist="$arts_album" # 已在存储时用 " & " 连接
|
||||
artist="$arts_album" # 已在存储时用 " & " 连接
|
||||
_log 警告 "AI 无法判定艺术家,拼接处理: $artist"
|
||||
fi
|
||||
local subdir
|
||||
subdir="${FOLDER_MUSIC}/$(sanitize "$artist")/$(sanitize "$album")"
|
||||
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)
|
||||
@@ -203,8 +251,8 @@ resolve_pending_matches() {
|
||||
_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 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%.*}"
|
||||
@@ -337,4 +385,3 @@ run_ai_batch() {
|
||||
resolve_pending_artists force
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
################################################################################
|
||||
# 媒体文件名解析
|
||||
################################################################################
|
||||
@@ -10,8 +12,8 @@ find_show_dir_from_path() {
|
||||
dir=$(dirname "$file")
|
||||
while [[ -n "$dir" && "$dir" != "/" && "$dir" != "." ]]; do
|
||||
parent=$(basename "$dir")
|
||||
# 跳过特典目录与常见分类目录
|
||||
if [[ " ${SKIP_DIRS_LOWER[*]} " == *" ${parent,,} "* ]]; then
|
||||
# 跳过特典目录与常见分类目录(skip_directories 词表,video 流程分区)
|
||||
if skip_dir_in video "${parent,,}"; then
|
||||
dir=$(dirname "$dir")
|
||||
continue
|
||||
fi
|
||||
@@ -38,8 +40,8 @@ find_show_path_from_file() {
|
||||
dir=$(dirname "$file")
|
||||
while [[ -n "$dir" && "$dir" != "/" && "$dir" != "." ]]; do
|
||||
parent=$(basename "$dir")
|
||||
# 跳过特典目录与常见分类目录
|
||||
if [[ " ${SKIP_DIRS_LOWER[*]} " == *" ${parent,,} "* ]]; then
|
||||
# 跳过特典目录与常见分类目录(skip_directories 词表,video 流程分区)
|
||||
if skip_dir_in video "${parent,,}"; then
|
||||
dir=$(dirname "$dir")
|
||||
continue
|
||||
fi
|
||||
@@ -88,10 +90,10 @@ count_main_videos() {
|
||||
find_args+=("(" "${joined[@]}" ")")
|
||||
fi
|
||||
while IFS= read -r -d '' f; do
|
||||
# 跳过特典/附带子目录(SPs/CDs/Scans/Fonts 等,词表见 SKIP_DIRS)
|
||||
# 跳过特典/附带子目录(SPs/CDs/Scans/Fonts 等,词表见 skip_directories.json)
|
||||
local skip=false seg
|
||||
for seg in ${f//\// }; do
|
||||
if [[ " ${SKIP_DIRS_LOWER[*]} " == *" ${seg,,} "* ]]; then
|
||||
if skip_dir_in video "${seg,,}"; then
|
||||
skip=true
|
||||
break
|
||||
fi
|
||||
@@ -110,38 +112,33 @@ count_main_videos() {
|
||||
# tv|<title>|<year>|0|<ep_num>|<fragment> 剧集特典(归 Season 00)
|
||||
detect_special() {
|
||||
local file="$1" base="$2" cleaned="$3" year="$4" season="$5" episode="$6" episode_end="$7"
|
||||
# 词表来自 mo_special_words.json(SPECIAL_WORDS,语义=特典类别词);
|
||||
# 空格归一化后子串匹配(纯字符串匹配,词表内容不当作正则;"nc op" == "ncop")。
|
||||
# 词表来自 special_keywords.json(tv 分区 → default 分区 → 全局表,special_word_hit),
|
||||
# 语义=特典类别词;空格归一化后子串匹配(纯字符串匹配,词表内容不当作正则;"nc op" == "ncop")。
|
||||
local is_special=false
|
||||
local frag=""
|
||||
local tag=""
|
||||
local frag_raw="" # 词表匹配到的原始括号内容(保留大小写,如 "Menu01")
|
||||
local frag_raw="" # 词表匹配到的原始括号内容(保留大小写,如 "Menu01")
|
||||
local i
|
||||
if [[ ${#SPECIAL_WORDS[@]} -gt 0 ]] && echo "$base" | grep -qE '\[[^]]*\]'; then
|
||||
local t_line w_norm t_norm
|
||||
if echo "$base" | grep -qE '\[[^]]*\]'; then
|
||||
local t_line t_norm hit
|
||||
while IFS= read -r t_line; do
|
||||
[[ -z "$t_line" ]] && continue
|
||||
t_norm="${t_line//[[:space:]]/}"
|
||||
t_norm="${t_norm,,}"
|
||||
for i in "${!SPECIAL_WORDS[@]}"; do
|
||||
w_norm="${SPECIAL_WORDS[$i]//[[:space:]]/}"
|
||||
w_norm="${w_norm,,}"
|
||||
[[ -z "$w_norm" ]] && continue
|
||||
if [[ "$t_norm" == *"$w_norm"* ]]; then
|
||||
is_special=true
|
||||
frag="$w_norm"
|
||||
tag="[$t_line]"
|
||||
frag_raw="$t_line"
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
if hit=$(special_word_hit tv "$t_norm"); then
|
||||
is_special=true
|
||||
frag="$hit"
|
||||
tag="[$t_line]"
|
||||
frag_raw="$t_line"
|
||||
break
|
||||
fi
|
||||
done < <(echo "$base" | grep -oE '\[[^]]*\]' | tr -d '[]')
|
||||
fi
|
||||
# 父目录为特典目录(SPs/Specials/CDs/Bonus/Extras 等)
|
||||
if ! $is_special; then
|
||||
local parent_dir
|
||||
parent_dir=$(basename "$(dirname "$file")")
|
||||
if echo "$parent_dir" | grep -qiE '^(sp|sps|special|specials|cd|cds|bonus|bonuses|extra|extras|previews|ps3 special bd|特典|特番|花絮)$'; then
|
||||
if skip_dir_in video "${parent_dir,,}"; then
|
||||
is_special=true
|
||||
fi
|
||||
fi
|
||||
@@ -207,6 +204,147 @@ detect_special() {
|
||||
echo "tv|${title}|${year}|0|${ep_num:-1}||${sp_frag}"
|
||||
}
|
||||
|
||||
# Music Videos(音乐视频)识别(v9.6):目录信号或文件名信号命中 musicvideo 词表
|
||||
# (special_keywords.json 的 musicvideo 分区 → default 分区 → 内置默认 MUSICVIDEO_WORDS;
|
||||
# **不回退** tv 特典词表——"sp" 子串命中 "SPs" 目录、"cm" 命中 "tmp.xxx" 等交叉误判)。
|
||||
# - 目录信号:父目录链任意段(到源根为止)目录名**精确匹配**(归一化去空格小写
|
||||
# 整名相等)musicvideo 词(如 "Music Videos"/"MV"/"Live"/"演唱会")→ 整目录音乐视频。
|
||||
# 精确匹配避免普通目录名含词误判("Muv-Luv"/"PV" 特典目录等)。
|
||||
# - 文件名信号:方括号标记**子串**命中 musicvideo 词,且该标记**未命中**特典词
|
||||
# (tv 分区/全局表)——[MV]/[Live] 判音乐视频;[PV]/[CM] 不在 musicvideo 词表
|
||||
# → 保持特典路径(剧集 PV/CM 特典不被误判)。
|
||||
# 输出契约(stdout 协议,空=非音乐视频):
|
||||
# musicvideo|<title>|<year>|||| (title = clean 后文件名)
|
||||
detect_musicvideo() {
|
||||
local file="$1" base="$2" cleaned="$3" year="$4"
|
||||
local dir parent pn
|
||||
# 目录信号:父目录向上到源根(精确匹配整目录名)
|
||||
dir=$(dirname "$file")
|
||||
while [[ -n "$dir" && "$dir" != "/" && "$dir" != "." ]]; do
|
||||
parent=$(basename "$dir")
|
||||
pn="${parent,,}"
|
||||
pn="${pn//[[:space:]]/}"
|
||||
if special_word_exact musicvideo "$pn"; then
|
||||
_log 调试 "音乐视频目录信号: $parent"
|
||||
echo "musicvideo|$(echo "$cleaned" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')|${year}||||"
|
||||
return 0
|
||||
fi
|
||||
[[ -n "$SOURCE_DIR" && "$dir" == "$SOURCE_DIR" ]] && break
|
||||
dir=$(dirname "$dir")
|
||||
done
|
||||
# 文件名信号:方括号标记子串命中 musicvideo 词。双命中歧义(标记同时在
|
||||
# 特典词表,如 [MV]/[PV])用 "歌手 - 歌名" 模式消解:文件名含 " - " →
|
||||
# 音乐视频(歌手分隔是音乐视频文件名的典型结构);不含 → 保持特典路径
|
||||
# ("动画名 [MV]" 剧集 MV 特典不被误判)。用户可通过两词表完全控制:
|
||||
# musicvideo 分区删词 → 永不判音乐视频;tv 特典词表删词 → 一律判音乐视频。
|
||||
if echo "$base" | grep -qE '\[[^]]*\]'; then
|
||||
local t_line tn
|
||||
while IFS= read -r t_line; do
|
||||
[[ -z "$t_line" ]] && continue
|
||||
tn="${t_line//[[:space:]]/}"
|
||||
tn="${tn,,}"
|
||||
if special_word_hit musicvideo "$tn" >/dev/null; then
|
||||
local dual=false
|
||||
if special_word_hit tv "$tn" >/dev/null; then
|
||||
dual=true
|
||||
fi
|
||||
if ! $dual || [[ "$cleaned" == *" - "* ]]; then
|
||||
_log 调试 "音乐视频文件信号: [$t_line]"
|
||||
echo "musicvideo|$(echo "$cleaned" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')|${year}||||"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done < <(echo "$base" | grep -oE '\[[^]]*\]' | tr -d '[]')
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 从同目录视频文件名集合剥离公共前后缀,提取当前文件的差异数字作集号。
|
||||
# 适用:压制组风格([Group] Title - 01 / Title - 02),文件名无方括号数字时
|
||||
# 公共部分剔除后,中段数字即集号(BAR find_common_substrings 思路的 bash 简化版)。
|
||||
# 输出:集号(stdout);无法提取输出空。数字排除分辨率(1080/720/480/2160/4320),
|
||||
# 取中段末尾数字(集号通常在末尾)。
|
||||
extract_episode_from_peers() {
|
||||
local file="$1" dir="$2"
|
||||
local -a stems=()
|
||||
local f2
|
||||
for f2 in "$dir"/*; do
|
||||
[[ -f "$f2" ]] || continue
|
||||
local ext2="${f2##*.}"
|
||||
ext2="${ext2,,}"
|
||||
[[ " ${VIDEO_EXTS[*]} " =~ [[:space:]]${ext2}[[:space:]] ]] || continue
|
||||
stems+=("$(basename "$f2")")
|
||||
done
|
||||
((${#stems[@]} >= 2)) || return 1
|
||||
|
||||
# 最长公共前缀(逐字符比较所有 stem)
|
||||
local first="${stems[0]}"
|
||||
local plen=${#first} i s
|
||||
for s in "${stems[@]:1}"; do
|
||||
i=0
|
||||
while ((i < plen && i < ${#s})) && [[ "${first:i:1}" == "${s:i:1}" ]]; do
|
||||
((i++))
|
||||
done
|
||||
plen=$i
|
||||
((plen == 0)) && break
|
||||
done
|
||||
|
||||
# 最长公共后缀(中段保护:后缀不越过公共前缀边界)
|
||||
local suf=0 max_suf=$((${#first} - plen)) s2 pos ok ch c
|
||||
while ((suf < max_suf)); do
|
||||
ok=true
|
||||
ch=""
|
||||
for s2 in "${stems[@]}"; do
|
||||
pos=$((${#s2} - 1 - suf))
|
||||
((pos >= plen)) || {
|
||||
ok=false
|
||||
break
|
||||
}
|
||||
c="${s2:pos:1}"
|
||||
if [[ -z "$ch" ]]; then
|
||||
ch="$c"
|
||||
elif [[ "$c" != "$ch" ]]; then
|
||||
ok=false
|
||||
break
|
||||
fi
|
||||
done
|
||||
$ok || break
|
||||
suf=$((suf + 1))
|
||||
done
|
||||
|
||||
# 当前文件 stem 的中段 = 剥离公共前后缀后的差异部分
|
||||
local stem mid
|
||||
stem=$(basename "$file")
|
||||
mid="${stem:plen:${#stem}-plen-suf}"
|
||||
# 中段末尾数字(集号通常在末尾);排除分辨率
|
||||
if [[ "$mid" =~ ([0-9]{1,3})[^0-9]*$ ]]; then
|
||||
local n="${BASH_REMATCH[1]}"
|
||||
if ! [[ "$n" =~ ^(1080|720|480|2160|4320)$ ]]; then
|
||||
echo "$((10#$n))"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 解析多集区间尾部,输出末集号(无区间输出空)。
|
||||
# 输入 = S##E## 匹配点之后的尾部字符串(如 "-E02"、" -E02E03"、"E05");
|
||||
# 支持连续扩展段(S01E01-E02E03 → 03)。
|
||||
# 注意:调用方传入的匹配点可能含 glob 元字符,本函数只做子串运算不展开。
|
||||
parse_episode_range_tail() {
|
||||
local range_tail="$1"
|
||||
local episode_end="" tail2
|
||||
if [[ "$range_tail" =~ ^[\ ._-]*[Ee]([0-9]{1,2}) ]]; then
|
||||
episode_end="${BASH_REMATCH[1]}"
|
||||
tail2="${range_tail#*"${BASH_REMATCH[0]}"}"
|
||||
while [[ "$tail2" =~ ^[\ ._-]*[Ee]([0-9]{1,2}) ]]; do
|
||||
episode_end="${BASH_REMATCH[1]}"
|
||||
tail2="${tail2#*"${BASH_REMATCH[0]}"}"
|
||||
done
|
||||
fi
|
||||
echo "$episode_end"
|
||||
}
|
||||
|
||||
# 解析媒体文件名,输出:type|title|year|season|episode|special_fragment
|
||||
# type:movie(电影正片)/ tv(剧集,含特典 Season 00)/ skip(电影特典,跳过不整理)/ unknown(识别不出,交 AI)
|
||||
parse_media_filename() {
|
||||
@@ -228,6 +366,16 @@ parse_media_filename() {
|
||||
cleaned=$(clean_name "$base")
|
||||
fi
|
||||
|
||||
# Music Videos 识别(v9.6):目录信号(目录链含音乐视频目录词)或文件名信号
|
||||
# (方括号标记命中 musicvideo 词且未命中特典词)→ musicvideo 类目。
|
||||
# 置于 TV/特典规则之前:目录信号优先(音乐视频目录下的文件即使文件名含
|
||||
# 季集标记也归类音乐视频);[MV]/[PV] 双命中场景按特典处理(保持现状)。
|
||||
local mv_result
|
||||
if mv_result=$(detect_musicvideo "$file" "$base" "$cleaned" "$year"); then
|
||||
echo "$mv_result"
|
||||
return
|
||||
fi
|
||||
|
||||
# 电视剧格式:S##E##(季/集号 1-2 位均可;支持多集区间 S01E01-E02 及扩展 S01E01-E02E03)
|
||||
if [[ "$cleaned" =~ (.*)[\ ._-]*[Ss]([0-9]{1,2})[Ee]([0-9]{1,2}) ]]; then
|
||||
title="${BASH_REMATCH[1]}"
|
||||
@@ -235,15 +383,7 @@ parse_media_filename() {
|
||||
episode="${BASH_REMATCH[3]}"
|
||||
# 多集区间:匹配点之后的 -E## / E## 段(可连续扩展)
|
||||
# 注意:${BASH_REMATCH[0]} 作 glob pattern 可能含元字符——整体引号按字面匹配(SC2295)
|
||||
local range_tail="${cleaned#*"${BASH_REMATCH[0]}"}"
|
||||
if [[ "$range_tail" =~ ^[\ ._-]*[Ee]([0-9]{1,2}) ]]; then
|
||||
episode_end="${BASH_REMATCH[1]}"
|
||||
local tail2="${range_tail#*"${BASH_REMATCH[0]}"}"
|
||||
while [[ "$tail2" =~ ^[\ ._-]*[Ee]([0-9]{1,2}) ]]; do
|
||||
episode_end="${BASH_REMATCH[1]}"
|
||||
tail2="${tail2#*"${BASH_REMATCH[0]}"}"
|
||||
done
|
||||
fi
|
||||
episode_end=$(parse_episode_range_tail "${cleaned#*"${BASH_REMATCH[0]}"}")
|
||||
title=$(strip_season_suffix "$title")
|
||||
type="tv"
|
||||
echo "$type|$title|$year|$season|$episode|${episode_end:-}|$special_fragment"
|
||||
@@ -338,6 +478,13 @@ parse_media_filename() {
|
||||
title="$cleaned"
|
||||
type="tv"
|
||||
season="${season:-1}"
|
||||
if [[ -z "${episode:-}" || "$episode" == "0" ]]; then
|
||||
# 公共子串剥离:同目录视频的公共前后缀剔除后,中段数字作集号
|
||||
# (压制组风格 [Group] Title - 01 无方括号数字时不再丢失集号)
|
||||
local peer_ep
|
||||
peer_ep=$(extract_episode_from_peers "$file" "$show_path") || true
|
||||
[[ -n "$peer_ep" ]] && episode="$peer_ep"
|
||||
fi
|
||||
episode="${episode:-0}"
|
||||
echo "$type|$title|$year|$season|$episode|${episode_end:-}|$special_fragment"
|
||||
return
|
||||
@@ -1,7 +1,214 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
################################################################################
|
||||
# 媒体识别
|
||||
################################################################################
|
||||
|
||||
# 从 TV 搜索结果中选取最匹配的 id(识别与重试链共用):
|
||||
# ① name/original_name 与 query 完全相等 → ② 排除"query 为结果名前缀"
|
||||
# (如 "Sword Art Online" 命中同人 "Sword Art Online Abridged")→
|
||||
# ③ 结构评分(前 5 候选,detail 缓存命中免费):
|
||||
# 候选季数覆盖文件季号 +40(S03 文件不选只有 2 季的候选);
|
||||
# 名称 norm 互相包含 +30;特典文件候选含 season 0 +20 → ④ 兜底第一个。
|
||||
pick_tv_show_id() {
|
||||
local result="$1" query="$2" file_season="${3:-}" special_fragment="${4:-}"
|
||||
local exact
|
||||
# 注意:jq 无匹配时输出字面量 "null"——末尾 // empty 确保空输出(-n 检查才可靠)
|
||||
exact=$(echo "$result" | jq -r --arg q "$query" '
|
||||
def norm: ascii_downcase;
|
||||
([.results[] | select((.name // "") | norm == ($q | norm)) | .id] | .[0]) //
|
||||
([.results[] | select((.original_name // "") | norm == ($q | norm)) | .id] | .[0]) //
|
||||
empty' 2>/dev/null)
|
||||
[[ -n "$exact" ]] && {
|
||||
echo "$exact"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ③ 结构评分层(前缀排除后的前 5 候选)
|
||||
local -a ids=() names=()
|
||||
local i2
|
||||
local lower_q
|
||||
lower_q=$(echo "$query" | tr '[:upper:]' '[:lower:]')
|
||||
while IFS=$'\t' read -r id name; do
|
||||
[[ -n "$id" ]] || continue
|
||||
local lower_name
|
||||
lower_name=$(echo "$name" | tr '[:upper:]' '[:lower:]')
|
||||
[[ "$lower_name" == "$lower_q"* ]] && continue
|
||||
ids+=("$id")
|
||||
names+=("$name")
|
||||
done < <(echo "$result" | jq -r '.results[0:5][] | "\(.id // empty)\t\(.name // "")"' 2>/dev/null)
|
||||
|
||||
local best_id="" best_score=-1
|
||||
for ((i2 = 0; i2 < ${#ids[@]}; i2++)); do
|
||||
local id2="${ids[$i2]}" score=0 detail_json
|
||||
detail_json=$(tmdb_api "/tv/${id2}" 2>/dev/null) || detail_json=""
|
||||
if [[ -n "$detail_json" ]]; then
|
||||
local total_seasons
|
||||
total_seasons=$(echo "$detail_json" | jq -r '.number_of_seasons // 0' 2>/dev/null)
|
||||
if [[ -n "$file_season" ]] && [[ "$file_season" =~ ^[0-9]+$ ]] &&
|
||||
[[ "$total_seasons" =~ ^[0-9]+$ ]] && ((10#$total_seasons >= 10#$file_season)); then
|
||||
score=$((score + 40))
|
||||
fi
|
||||
if [[ -n "$special_fragment" ]] &&
|
||||
echo "$detail_json" | jq -e '.seasons[]? | select(.season_number == 0)' >/dev/null 2>&1; then
|
||||
score=$((score + 20))
|
||||
fi
|
||||
fi
|
||||
# 名称 norm 互相包含(去空格小写;中文原样)
|
||||
local qn sn
|
||||
qn=$(printf '%s' "$query" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||
sn=$(printf '%s' "${names[$i2]}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||
if [[ -n "$qn" && -n "$sn" ]]; then
|
||||
if [[ "$sn" == *"$qn"* || "$qn" == *"$sn"* ]]; then
|
||||
score=$((score + 30))
|
||||
fi
|
||||
fi
|
||||
if ((score > best_score)); then
|
||||
best_score=$score
|
||||
best_id="$id2"
|
||||
fi
|
||||
done
|
||||
[[ -n "$best_id" ]] && {
|
||||
echo "$best_id"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ④ 兜底:排除前缀后的第一个,否则 results[0]
|
||||
echo "$result" | jq -r --arg q "$query" '
|
||||
def norm: ascii_downcase;
|
||||
([.results[] | select(((.name // "") | norm | startswith($q | norm)) | not) | .id] | .[0]) //
|
||||
(.results[0].id // empty)' 2>/dev/null
|
||||
}
|
||||
|
||||
# MyAnimeList (jikan v4) 搜索兜底:输出候选标题列表(每行一个,最多 3 个),
|
||||
# 供识别函数回 TMDB en-US 重搜。独立缓存于 $CACHE_DIR/media_organizer/mal/
|
||||
# (包裹格式,TTL=CACHE_TTL_DAYS;空结果写 3 天哨兵)。
|
||||
# 请求失败/非 JSON → 空输出(静默降级,不阻断主流程)。
|
||||
mal_search() {
|
||||
local title="$1"
|
||||
[[ "${SEARCH_FALLBACK_MAL,,}" == "true" ]] || return 0
|
||||
[[ -n "$CACHE_DIR" ]] || return 0
|
||||
local dir="$CACHE_DIR/media_organizer/mal"
|
||||
# 干运行零持久化副作用:不创建缓存目录(网络请求照常,结果不落盘)
|
||||
[[ "${DRY_RUN:-false}" == "true" ]] || mkdir -p "$dir" 2>/dev/null || true
|
||||
local key path
|
||||
key=$(cache_key "query=${title}")
|
||||
path="$dir/$(cache_key_hash "$key").json"
|
||||
local cached
|
||||
if cached=$(cache_get "$path" 1); then
|
||||
echo "$cached" | jq -r '.[]?' 2>/dev/null
|
||||
return 0
|
||||
fi
|
||||
if cache_empty_fresh "$path" 1; then
|
||||
return 0
|
||||
fi
|
||||
local url="${MAL_BASE_URL%/}/anime"
|
||||
_log 搜索 "MAL 搜索: $title"
|
||||
local response=""
|
||||
if ! response=$(curl -s --connect-timeout "${TMDB_CURL_CONNECT_TIMEOUT:-10}" \
|
||||
--max-time "${TMDB_CURL_MAX_TIME:-30}" --get -fS \
|
||||
--data-urlencode "q=${title}" --data-urlencode "limit=3" \
|
||||
--data-urlencode "sfw=true" "$url" 2>/dev/null); then
|
||||
_log 警告 "MAL 搜索失败(跳过兜底): $title"
|
||||
return 0
|
||||
fi
|
||||
# 候选标题:罗马音/英文/日文名去重去空,最多 3 个
|
||||
local titles
|
||||
titles=$(echo "$response" | jq -c '[.data[]? | (.title // ""), (.title_english // ""), (.title_japanese // "")] | map(select(. != "" and . != null)) | unique | .[0:3]' 2>/dev/null)
|
||||
if [[ -z "$titles" || "$titles" == "[]" ]]; then
|
||||
cache_put_empty "$path" 1 "$key"
|
||||
return 0
|
||||
fi
|
||||
cache_put "$path" "$titles" 1 "$key"
|
||||
echo "$titles" | jq -r '.[]?'
|
||||
}
|
||||
|
||||
# 统一 TV 搜索尝试器(搜索链抽象,R1):执行一次 /search/tv(可指定语言),
|
||||
# 命中输出 show_id(stdout)。返回码:0=命中;1=无结果;2=请求失败(不重试)。
|
||||
# 选择器 = pick_tv_show_id(结构评分:精确名 → 前缀排除 → 季数/特典结构评分 → 兜底)。
|
||||
# 调用方无需响应回传(tv 选中后只用 show_id;二次剥离/MATCH 用 search_name)。
|
||||
tv_search_once() {
|
||||
local query="$1" file_season="$2" special_fragment="$3" lang="$4"
|
||||
local result="" rc=0 id=""
|
||||
if [[ -z "$lang" || "$lang" == "$TMDB_LANG" ]]; then
|
||||
result=$(tmdb_api "/search/tv" "query=${query}") || rc=$?
|
||||
else
|
||||
result=$(tmdb_api_lang "$lang" "/search/tv" "query=${query}") || rc=$?
|
||||
fi
|
||||
[[ $rc -eq 2 ]] && return 2
|
||||
if [[ -n "$result" ]]; then
|
||||
id=$(pick_tv_show_id "$result" "$query" "$file_season" "$special_fragment")
|
||||
fi
|
||||
if [[ -n "$id" ]]; then
|
||||
echo "$id"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 统一电影搜索尝试器:执行一次 /search/movie(可指定语言与年份),命中输出两行
|
||||
# (id + 响应单行 JSON——调用方需响应做年份校验/需甄别检测)。
|
||||
# 返回码:0=命中;1=无结果;2=请求失败。
|
||||
movie_search_once() {
|
||||
local query="$1" year="$2" lang="$3"
|
||||
local result="" rc=0 id=""
|
||||
if [[ -z "$lang" || "$lang" == "$TMDB_LANG" ]]; then
|
||||
result=$(tmdb_api "/search/movie" "query=${query}" ${year:+"year=${year}"}) || rc=$?
|
||||
else
|
||||
result=$(tmdb_api_lang "$lang" "/search/movie" "query=${query}" ${year:+"year=${year}"}) || rc=$?
|
||||
fi
|
||||
[[ $rc -eq 2 ]] && return 2
|
||||
if [[ -n "$result" ]]; then
|
||||
id=$(echo "$result" | jq -r '.results[0].id // empty')
|
||||
fi
|
||||
if [[ -n "$id" ]]; then
|
||||
printf '%s\n%s\n' "$id" "$(echo "$result" | jq -c . 2>/dev/null || echo "$result")"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 目录名兜底重搜(剧集):主搜索失败后,用父目录名(清洗+剥季后缀)作 query 重搜
|
||||
# (zh → en)。目录名常是完整剧名(如 [Group] Re Zero 目录),零成本多一次缓存化搜索。
|
||||
# 源根直属散放不兜底(共享目录误判风险,与正片计数约束一致)。
|
||||
# 输出:show_id(stdout);空=未命中。内部复用 tv_search_once(结构评分)。
|
||||
tv_search_by_dir() {
|
||||
local file="$1" query="$2" file_season="$3" special_fragment="$4"
|
||||
local dir dname dq
|
||||
dir=$(dirname "$file")
|
||||
[[ -n "$SOURCE_DIR" && "$dir" == "$SOURCE_DIR" ]] && return 0
|
||||
dname=$(basename "$dir")
|
||||
dq=$(clean_name "$dname")
|
||||
dq=$(strip_season_suffix "$dq")
|
||||
[[ -n "$dq" && "$dq" != "$query" ]] || return 0
|
||||
_log 搜索 "目录名兜底搜索剧集: $dq"
|
||||
local id="" _rc=0
|
||||
id=$(tv_search_once "$dq" "$file_season" "$special_fragment" "") || _rc=$?
|
||||
if [[ -z "$id" ]] && [[ $_rc -ne 2 ]]; then
|
||||
id=$(tv_search_once "$dq" "$file_season" "$special_fragment" "en-US") || true
|
||||
fi
|
||||
echo "$id"
|
||||
}
|
||||
|
||||
# 目录名兜底重搜(电影):同上,取 results[0](年份校验由主流程负责)。
|
||||
# 输出:movie_id(stdout);空=未命中。内部复用 movie_search_once。
|
||||
movie_search_by_dir() {
|
||||
local file="$1" query="$2" year="$3"
|
||||
local dir dname dq
|
||||
dir=$(dirname "$file")
|
||||
[[ -n "$SOURCE_DIR" && "$dir" == "$SOURCE_DIR" ]] && return 0
|
||||
dname=$(basename "$dir")
|
||||
dq=$(clean_name "$dname")
|
||||
[[ -n "$dq" && "$dq" != "$query" ]] || return 0
|
||||
_log 搜索 "目录名兜底搜索电影: $dq"
|
||||
local id="" _rc=0
|
||||
id=$(movie_search_once "$dq" "$year" "") || _rc=$?
|
||||
if [[ -z "$id" ]] && [[ $_rc -ne 2 ]]; then
|
||||
id=$(movie_search_once "$dq" "$year" "en-US") || true
|
||||
fi
|
||||
echo "$id"
|
||||
}
|
||||
|
||||
# 识别电影。成功时 echo "子目录|文件名" 到 stdout,失败时 echo 空并记录 AI 待处理。
|
||||
# 由已知 movie id 构建目的命名(detail 缓存优先,fallback 用搜索结果的标题/年份)。
|
||||
# 识别与 AI 匹配消费共用——命名格式化是脚本的确定性职责,AI 只做判断。
|
||||
@@ -22,13 +229,17 @@ build_movie_dest() {
|
||||
return 1
|
||||
}
|
||||
mtitle=$(sanitize "$mtitle")
|
||||
# 年份可省略(Jellyfin 规范):无年份时不带括号段
|
||||
local year_tag=""
|
||||
[[ -n "$myear" ]] && year_tag=" (${myear})"
|
||||
_log 匹配 "电影匹配: $mtitle${year_tag}"
|
||||
_log 信息 "目标: ${FOLDER_MOVIES}/${mtitle}${year_tag}/${mtitle}${year_tag}.${ext}"
|
||||
# 命名模板(用户可配置 NAMING_MOVIE):{title}{?year: ({year})}——年份可省略
|
||||
# (Jellyfin 规范:无年份时不带括号段)。渲染结果同时用于目录名与文件名
|
||||
# (Jellyfin 要求同名文件),整体 sanitize 保证模板不引入非法字符。
|
||||
local mname
|
||||
mname=$(render_naming NAMING_MOVIE "title=$mtitle" "year=$myear")
|
||||
mname=$(sanitize "$mname")
|
||||
[[ -n "$mname" ]] || mname="$mtitle"
|
||||
_log 匹配 "电影匹配: $mname"
|
||||
_log 信息 "目标: ${FOLDER_MOVIES}/${mname}/${mname}.${ext}"
|
||||
sleep "$TMDB_DELAY"
|
||||
echo "${FOLDER_MOVIES}/${mtitle}${year_tag}|${mtitle}${year_tag}.${ext}"
|
||||
echo "${FOLDER_MOVIES}/${mname}|${mname}.${ext}"
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -38,16 +249,87 @@ identify_movie() {
|
||||
|
||||
# 返回码约定:0=识别成功(stdout=子目录|文件名);1=查询无结果;2=请求失败;
|
||||
# 3=需甄别(emit MATCH,交 AI 匹配)——待定登记由调用方负责。
|
||||
# 注意:不能用 `if ! result=$(...)` 捕获 rc——! 会把 $? 反转为 0,须用 || rc=$?
|
||||
local result="" movie_id="" rc=0
|
||||
result=$(tmdb_api "/search/movie" "query=${title}" \
|
||||
${year:+"year=${year}"}) || rc=$?
|
||||
if [[ $rc -eq 0 && -n "$result" ]]; then
|
||||
movie_id=$(echo "$result" | jq -r '.results[0].id // empty')
|
||||
local result="" movie_id="" _out="" _rc=0
|
||||
|
||||
# 用户匹配规则(最高优先级,跳过搜索):标题命中 ID 映射 → 直接用指定电影 ID。
|
||||
# 适合 TMDB 多候选易选错/搜索命中错条目的场景(确定性,不依赖 AI)。
|
||||
local rk
|
||||
rk=$(rule_key "$title")
|
||||
if [[ -n "${MATCH_RULES_ID_MOVIE[$rk]:-}" ]]; then
|
||||
_log 匹配 "用户规则命中电影 ID: ${MATCH_RULES_ID_MOVIE[$rk]}($title)"
|
||||
local rule_dest
|
||||
if rule_dest=$(build_movie_dest "${MATCH_RULES_ID_MOVIE[$rk]}" "$ext" "$title" "$year"); then
|
||||
echo "$rule_dest"
|
||||
return 0
|
||||
fi
|
||||
_log 错误 "用户规则指定的电影详情缺失: /movie/${MATCH_RULES_ID_MOVIE[$rk]}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 搜索重试链(统一尝试器 movie_search_once,命中回传响应供年份校验):
|
||||
# zh → en → 规则别名(zh/en) → 目录 → MAL。
|
||||
# 请求失败语义:主搜索(zh)失败(rc=2)短路 return 2;后续层失败忽略继续。
|
||||
# result 只在 zh/en/别名/MAL 命中时更新(目录命中不更新——与原行为一致)。
|
||||
_out=$(movie_search_once "$title" "$year" "") || _rc=$?
|
||||
if [[ $_rc -eq 2 ]]; then
|
||||
return 2
|
||||
fi
|
||||
if [[ $_rc -eq 0 ]]; then
|
||||
movie_id=$(printf '%s\n' "$_out" | sed -n '1p')
|
||||
result=$(printf '%s\n' "$_out" | sed -n '2p')
|
||||
fi
|
||||
# en-US 重搜(zh 空结果时)
|
||||
if [[ -z "$movie_id" ]]; then
|
||||
_out=$(movie_search_once "$title" "$year" "en-US") || true
|
||||
if [[ -n "$_out" ]]; then
|
||||
movie_id=$(printf '%s\n' "$_out" | sed -n '1p')
|
||||
result=$(printf '%s\n' "$_out" | sed -n '2p')
|
||||
fi
|
||||
fi
|
||||
# 用户匹配规则(搜索别名):zh/en 均空时用用户指定的重搜词再搜(确定性,
|
||||
# 优先于目录名兜底与 MAL/AI)。别名可带 year(对象形式 {"term","year"})。
|
||||
if [[ -z "$movie_id" ]]; then
|
||||
local alias_term="" alias_year="" _ar=0
|
||||
IFS=$'\t' read -r alias_term alias_year <<<"$(match_rule_lookup_alias "$title")" || true
|
||||
if [[ -n "$alias_term" ]]; then
|
||||
_log 搜索 "用户规则别名搜索电影: $alias_term (年份: ${alias_year:-无})"
|
||||
_out=$(movie_search_once "$alias_term" "$alias_year" "") || _ar=$?
|
||||
if [[ -n "$_out" ]]; then
|
||||
movie_id=$(printf '%s\n' "$_out" | sed -n '1p')
|
||||
result=$(printf '%s\n' "$_out" | sed -n '2p')
|
||||
fi
|
||||
if [[ -z "$movie_id" ]] && [[ $_ar -ne 2 ]]; then
|
||||
_out=$(movie_search_once "$alias_term" "$alias_year" "en-US") || true
|
||||
if [[ -n "$_out" ]]; then
|
||||
movie_id=$(printf '%s\n' "$_out" | sed -n '1p')
|
||||
result=$(printf '%s\n' "$_out" | sed -n '2p')
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# 目录名兜底重搜(父目录名常是完整剧名;零外部依赖,优先于 MAL)
|
||||
if [[ -z "$movie_id" ]]; then
|
||||
movie_id=$(movie_search_by_dir "$file" "$title" "$year") || true
|
||||
if [[ -n "$movie_id" ]]; then
|
||||
_log 匹配 "目录名兜底命中电影: $movie_id"
|
||||
fi
|
||||
fi
|
||||
# MAL 兜底(SEARCH_FALLBACK_MAL=true 时)
|
||||
if [[ -z "$movie_id" ]] && [[ "${SEARCH_FALLBACK_MAL,,}" == "true" ]]; then
|
||||
local mal_title
|
||||
while IFS= read -r mal_title; do
|
||||
[[ -z "$mal_title" ]] && continue
|
||||
_log 搜索 "MAL 候选回搜电影: $mal_title"
|
||||
_out=$(movie_search_once "$mal_title" "$year" "en-US") || true
|
||||
if [[ -n "$_out" ]]; then
|
||||
movie_id=$(printf '%s\n' "$_out" | sed -n '1p')
|
||||
result=$(printf '%s\n' "$_out" | sed -n '2p')
|
||||
[[ -n "$movie_id" ]] && break
|
||||
fi
|
||||
done < <(mal_search "$title")
|
||||
fi
|
||||
if [[ -z "$movie_id" ]]; then
|
||||
# 1=空哨兵跳过请求/无结果;2=请求失败
|
||||
[[ $rc -eq 2 ]] && return 2
|
||||
# 1=空哨兵跳过请求/无结果(2=请求失败已在主搜索处短路)
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -72,11 +354,66 @@ identify_movie() {
|
||||
build_movie_dest "$movie_id" "$ext" "$fb_title" "$fb_year"
|
||||
}
|
||||
|
||||
# Music Videos 识别(v9.6):本地规则,无网络请求。
|
||||
# 命名:MusicVideos/{artist}/{title}.{ext}(Jellyfin 官方结构:艺术家目录 + 歌名文件)。
|
||||
# artist 归类链(与音乐同构的简化版):
|
||||
# 元数据 ALBUMARTIST → ARTIST → 父目录名解析("歌手 - 歌名"取首段;无分隔符整名
|
||||
# 作歌手;源根直属不解析)→ FOLDER_UNKNOWN。
|
||||
# 返回 0(stdout=子目录|文件名);本地规则不依赖网络,恒成功(artist 有 Unknown 兜底)。
|
||||
identify_musicvideo() {
|
||||
local file="$1" title="$2" ext="$3"
|
||||
local artist=""
|
||||
# 元数据读取(ffprobe 读格式标签;仅整理不增删文件)
|
||||
local meta
|
||||
meta=$(ffprobe -v quiet -show_entries format_tags=album_artist,artist \
|
||||
-of default=noprint_wrappers=1 "$file" 2>/dev/null) || meta=""
|
||||
local aa a
|
||||
aa=$(echo "$meta" | grep -ioE 'album_artist=.*' | head -1 | sed -E 's/^[^=]*=//') || true
|
||||
a=$(echo "$meta" | grep -viE 'album_artist=' | grep -ioE 'artist=.*' | head -1 | sed -E 's/^[^=]*=//') || true
|
||||
artist="${aa:-$a}"
|
||||
# 父目录名解析(元数据缺失时):"歌手 - 歌名"取首段;无分隔符整名作歌手
|
||||
if [[ -z "$artist" ]]; then
|
||||
local pdir pname
|
||||
pdir=$(dirname "$file")
|
||||
if [[ -n "$SOURCE_DIR" && "$pdir" != "$SOURCE_DIR" ]]; then
|
||||
pname=$(basename "$pdir")
|
||||
pname=$(clean_name "$pname")
|
||||
if [[ -n "$pname" ]]; then
|
||||
if [[ "$pname" == *" - "* ]]; then
|
||||
artist="${pname%% - *}"
|
||||
else
|
||||
artist="$pname"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
[[ -z "$artist" ]] && artist="$FOLDER_UNKNOWN"
|
||||
|
||||
local safe_title safe_artist mroot
|
||||
safe_title=$(sanitize "$title")
|
||||
safe_artist=$(sanitize "$artist")
|
||||
[[ -n "$safe_title" ]] || safe_title="Music Video"
|
||||
# 目录结构走 NAMING_MUSICVIDEO 模板(默认 {artist}/{title};可自定义)
|
||||
mroot=$(render_naming NAMING_MUSICVIDEO "artist=$safe_artist" "title=$safe_title")
|
||||
[[ -n "$mroot" ]] || mroot="${safe_artist}/${safe_title}"
|
||||
_log 匹配 "音乐视频匹配: ${safe_artist} / ${safe_title}"
|
||||
_log 信息 "目标: ${FOLDER_MUSICVIDEOS}/${mroot}/${safe_title}.${ext}"
|
||||
echo "${FOLDER_MUSICVIDEOS}/${mroot}|${safe_title}.${ext}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# 查询季偏移值(优先精确剧名,其次 TMDB ID)。
|
||||
# v9.6:查表走类目分区(tv 分区 → default 分区 → 全局表)。
|
||||
get_season_offset() {
|
||||
local search_name="$1" show_id="$2" season="$3"
|
||||
local lower_name="${search_name,,}"
|
||||
echo "${SEASON_OFFSET_MAP[$lower_name]:-${SEASON_OFFSET_MAP["id:$show_id"]:-}}"
|
||||
local v
|
||||
v=$(season_offset_get tv "$lower_name")
|
||||
[[ -n "$v" ]] && {
|
||||
echo "$v"
|
||||
return 0
|
||||
}
|
||||
season_offset_get tv "id:$show_id"
|
||||
}
|
||||
|
||||
# 匹配特典集号。成功返回 TMDB 集号,失败返回 fallback。
|
||||
@@ -91,11 +428,11 @@ match_special_episode() {
|
||||
# 候选词:本地关键字符串 + 去数字核心 + keymap 映射的所有 TMDB 关键字(多语言数组)
|
||||
local -a terms=("$fragment")
|
||||
local frag_full core val
|
||||
frag_full="${fragment,,}" # 原始 fragment 小写(contains 数字对齐校验用)
|
||||
frag_full="${fragment,,}" # 原始 fragment 小写(contains 数字对齐校验用)
|
||||
core=$(printf '%s' "$fragment" | sed -E 's/[0-9]+//g' | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||
[[ -n "$core" && "$core" != "$fragment" ]] && terms+=("$core")
|
||||
val="${SPECIAL_MAP[${fragment,,}]:-}"
|
||||
[[ -z "$val" ]] && val="${SPECIAL_MAP[$core]:-}"
|
||||
val=$(special_map_value tv "${fragment,,}")
|
||||
[[ -z "$val" ]] && val=$(special_map_value tv "$core")
|
||||
if [[ -n "$val" ]]; then
|
||||
while IFS= read -r v; do
|
||||
[[ -n "$v" ]] && terms+=("$v")
|
||||
@@ -158,7 +495,7 @@ match_special_episode() {
|
||||
local frag_norm
|
||||
frag_norm=$(norm_str "$fragment")
|
||||
[[ -z "$frag_norm" ]] && frag_norm="$fragment"
|
||||
local SP_FALLBACK_EP="" SP_FALLBACK_NAME="" # SP 第一季候选暂存(第二季优先)
|
||||
local SP_FALLBACK_EP="" _SP_FALLBACK_NAME="" # SP 第一季候选暂存(第二季优先)
|
||||
|
||||
# 提取 S0 条目表(编号|名称|简介|名称norm|简介norm),jq 一次性预计算(避免逐条 spawn jq)。
|
||||
# 两个坑:
|
||||
@@ -166,8 +503,8 @@ match_special_episode() {
|
||||
# 2) overview 为空 → 连续 tab 会被 read 的 IFS 空白折叠(字段错位)→ 空值补 "-" 占位
|
||||
local s0_entries
|
||||
s0_entries=$(echo "$season0_json" | jq -r 'def norm: '"$JQ_NORM_LITE"'; .episodes[] | ((.overview // "") | split("\n") | join(" ")) as $ov | (if $ov == "" then "-" else $ov end) as $ov2 | "\(.episode_number)\t\(.name // "")\t\($ov2)\t\((.name // "") | norm)\t\(($ov2) | norm)"' 2>/dev/null)
|
||||
local eid ename eov en_norm eov_norm fn_norm
|
||||
while IFS=$'\t' read -r eid ename eov en_norm eov_norm; do
|
||||
local eid ename _eov en_norm eov_norm
|
||||
while IFS=$'\t' read -r eid ename _eov en_norm eov_norm; do
|
||||
[[ -z "$eid" ]] && continue
|
||||
[[ -z "$en_norm" ]] && continue
|
||||
|
||||
@@ -175,14 +512,16 @@ match_special_episode() {
|
||||
if [[ "$frag_norm" == "0" || "$frag_norm" == "00" ]]; then
|
||||
if [[ "$ename" == *"第0话"* ]]; then
|
||||
_log 匹配 "特典匹配成功(第0话),TMDB集号: $eid"
|
||||
echo "$eid"; return 0
|
||||
echo "$eid"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4) 标题归一化子串(如 "swordartoffline1" in "Sword Art Offline 1")
|
||||
if [[ "$en_norm" == *"$frag_norm"* ]]; then
|
||||
_log 匹配 "特典匹配成功(归一化标题,词: $fragment),TMDB集号: $eid"
|
||||
echo "$eid"; return 0
|
||||
echo "$eid"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 5) Mini Anime:标题含"迷你动画" + "第N话"对齐(无话数特别篇自动排除)
|
||||
@@ -191,10 +530,11 @@ match_special_episode() {
|
||||
if [[ "$en_norm" == *"迷你动画"* ]]; then
|
||||
if [[ "$ename" =~ 第([0-9]+)话 ]] && ((10#${BASH_REMATCH[1]} == 10#$mini_frag_n)); then
|
||||
_log 匹配 "特典匹配成功(迷你动画话数 $mini_frag_n),TMDB集号: $eid"
|
||||
echo "$eid"; return 0
|
||||
echo "$eid"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
continue # Mini Anime 只匹配迷你动画条目
|
||||
continue # Mini Anime 只匹配迷你动画条目
|
||||
fi
|
||||
|
||||
# 6) 类型词 + 编号对齐(含中英对照词表)
|
||||
@@ -202,10 +542,10 @@ match_special_episode() {
|
||||
# (如 angereport↔总结报告、indextan↔茵蒂克丝)
|
||||
local -a type_frags type_names
|
||||
type_frags=("swordartoffline" "extraedition" "webpreview" "debriefing"
|
||||
"recollection" "reflection" "indextan" "angereport" "mmr")
|
||||
"recollection" "reflection" "indextan" "angereport" "mmr")
|
||||
type_names=("swordartoffline" "extraedition" "webpreview" "debriefing"
|
||||
"recollection" "reflection" "茵蒂克丝" "总结报告" "mmr")
|
||||
local ti type_kw type_num en_num frag_num
|
||||
"recollection" "reflection" "茵蒂克丝" "总结报告" "mmr")
|
||||
local ti type_kw en_num frag_num
|
||||
for ti in "${!type_frags[@]}"; do
|
||||
type_kw="${type_frags[$ti]}"
|
||||
if [[ "$frag_norm" == *"$type_kw"* ]] && [[ "$en_norm" == *"${type_names[$ti]}"* ]]; then
|
||||
@@ -215,7 +555,8 @@ match_special_episode() {
|
||||
local mmr_frag_n="${BASH_REMATCH[1]}"
|
||||
if [[ "$en_norm" =~ mmr0*([0-9]+) ]] && ((10#${BASH_REMATCH[1]} == 10#$mmr_frag_n)); then
|
||||
_log 匹配 "特典匹配成功(MMR 编号),TMDB集号: $eid"
|
||||
echo "$eid"; return 0
|
||||
echo "$eid"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
continue
|
||||
@@ -226,11 +567,13 @@ match_special_episode() {
|
||||
if [[ -n "$frag_num" && -n "$en_num" ]]; then
|
||||
if awk "BEGIN{exit !(($frag_num - $en_num) < 0.01 && ($en_num - $frag_num) < 0.01)}"; then
|
||||
_log 匹配 "特典匹配成功(类型词 $type_kw 编号对齐),TMDB集号: $eid"
|
||||
echo "$eid"; return 0
|
||||
echo "$eid"
|
||||
return 0
|
||||
fi
|
||||
elif [[ -z "$frag_num" && -z "$en_num" ]]; then
|
||||
_log 匹配 "特典匹配成功(类型词 $type_kw 无编号),TMDB集号: $eid"
|
||||
echo "$eid"; return 0
|
||||
echo "$eid"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
@@ -244,11 +587,12 @@ match_special_episode() {
|
||||
if [[ "$ename" =~ 特典动画[[:space:]]*0*([0-9]+) ]] && ((10#${BASH_REMATCH[1]} == 10#$sp_frag_n)); then
|
||||
if [[ "$ename" == *"第二季"* ]]; then
|
||||
_log 匹配 "特典匹配成功(特典动画编号 $sp_frag_n),TMDB集号: $eid"
|
||||
echo "$eid"; return 0
|
||||
echo "$eid"
|
||||
return 0
|
||||
fi
|
||||
# 第一季候选暂存(若有第二季条目则优先,循环后兜底用)
|
||||
SP_FALLBACK_EP="$eid"
|
||||
SP_FALLBACK_NAME="$ename"
|
||||
_SP_FALLBACK_NAME="$ename"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -257,11 +601,13 @@ match_special_episode() {
|
||||
if [[ "$frag_norm" == *"ova"* || "$frag_norm" == *"ovb"* ]]; then
|
||||
if [[ "$frag_norm" == *"ovb"* ]] && [[ "$eov_norm" == *"ovb"* ]]; then
|
||||
_log 匹配 "特典匹配成功(OVB 简介),TMDB集号: $eid"
|
||||
echo "$eid"; return 0
|
||||
echo "$eid"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$frag_norm" == *"ova"* && "$frag_norm" != *"ovb"* ]] && [[ "$eov_norm" == *"ova"* ]]; then
|
||||
_log 匹配 "特典匹配成功(OVA 简介),TMDB集号: $eid"
|
||||
echo "$eid"; return 0
|
||||
echo "$eid"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done <<<"$s0_entries"
|
||||
@@ -269,17 +615,19 @@ match_special_episode() {
|
||||
# 8.5) SP 第一季候选兜底(无第二季条目时使用暂存的第一季特典动画)
|
||||
if [[ -n "$SP_FALLBACK_EP" ]]; then
|
||||
_log 匹配 "特典匹配成功(特典动画编号兜底),TMDB集号: $SP_FALLBACK_EP"
|
||||
echo "$SP_FALLBACK_EP"; return 0
|
||||
echo "$SP_FALLBACK_EP"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 9) 小数集号(14.5 → 第14.5话)—— 逐条目编号含小数
|
||||
if [[ "$frag_norm" =~ [0-9]+\.[0-9]+ ]]; then
|
||||
local dec_num="${BASH_REMATCH[0]}"
|
||||
while IFS=$'\t' read -r eid ename eov; do
|
||||
while IFS=$'\t' read -r eid ename _eov; do
|
||||
[[ -z "$eid" ]] && continue
|
||||
if [[ "$ename" == *"$dec_num"* ]]; then
|
||||
_log 匹配 "特典匹配成功(小数集号 $dec_num),TMDB集号: $eid"
|
||||
echo "$eid"; return 0
|
||||
echo "$eid"
|
||||
return 0
|
||||
fi
|
||||
done <<<"$s0_entries"
|
||||
fi
|
||||
@@ -311,7 +659,7 @@ build_tv_dest() {
|
||||
local show_id="$1" season="$2" episode="$3" episode_end="$4" special_fragment="$5"
|
||||
local base="$6" ext="$7" file="$8" search_name="$9"
|
||||
# 归一化季/集号为无前导零十进制:parse 可能产出 "01"/"08"(S##E## 格式)。
|
||||
# 1) 保证季缓存路径 season/1.json 与 update_cache 的整数循环一致(否则缓存互不命中);
|
||||
# 1) 保证季缓存路径 tmdb/tv/{id}/season/1.json 与 update_cache 的整数循环一致(否则缓存互不命中);
|
||||
# 2) 避免 $((episode + 偏移)) 将 "08" 当八进制报错。
|
||||
if [[ "$season" =~ ^[0-9]+$ ]]; then season=$((10#$season)); fi
|
||||
if [[ "$episode" =~ ^[0-9]+$ ]]; then episode=$((10#$episode)); fi
|
||||
@@ -333,10 +681,13 @@ build_tv_dest() {
|
||||
return 1
|
||||
}
|
||||
show_title=$(sanitize "$show_title")
|
||||
# 年份可省略(Jellyfin 规范):无年份时不带括号段
|
||||
local year_tag=""
|
||||
[[ -n "$year" ]] && year_tag=" (${year})"
|
||||
_log 匹配 "剧集匹配: $show_title${year_tag}"
|
||||
# 命名模板(用户可配置 NAMING_SHOW):{title}{?year: ({year})}——年份可省略
|
||||
# (Jellyfin 规范)。渲染结果(show_dir)作为 Season 目录的上级目录名。
|
||||
local show_dir
|
||||
show_dir=$(render_naming NAMING_SHOW "title=$show_title" "year=$year")
|
||||
show_dir=$(sanitize "$show_dir")
|
||||
[[ -n "$show_dir" ]] || show_dir="$show_title"
|
||||
_log 匹配 "剧集匹配: $show_dir"
|
||||
|
||||
# 第一季集数(季偏移用;tmdb_api 自动缓存 /tv/{id}/season/1)
|
||||
s1_ep_count=0
|
||||
@@ -431,8 +782,8 @@ build_tv_dest() {
|
||||
# 避免占用 S00E 编号,干扰 TMDB 正常识别的特典集。
|
||||
# fragment 用原始值(保留编号,如 Menu01);translated 仅用于匹配。
|
||||
s00_frag="$special_fragment"
|
||||
[[ -z "$s00_frag" ]] && s00_frag="SP" # 空 fragment 防护(S00Special - 空名)
|
||||
s00_dest=$(special_s00_dest "$show_title" "$year_tag" "$s00_frag" "$ext")
|
||||
[[ -z "$s00_frag" ]] && s00_frag="SP" # 空 fragment 防护(S00Special - 空名)
|
||||
s00_dest=$(special_s00_dest "$show_dir" "$s00_frag" "$ext")
|
||||
_log 跳过 "特典未匹配,文件名无单集标题"
|
||||
_log 信息 "目标: ${s00_dest%%|*}/${s00_dest#*|}"
|
||||
echo "$s00_dest"
|
||||
@@ -445,7 +796,7 @@ build_tv_dest() {
|
||||
# 集号异常特典同样用 S00{类型}{编号} 命名
|
||||
s00_frag2="$special_fragment"
|
||||
[[ -z "$s00_frag2" ]] && s00_frag2="SP"
|
||||
s00_dest=$(special_s00_dest "$show_title" "$year_tag" "$s00_frag2" "$ext")
|
||||
s00_dest=$(special_s00_dest "$show_dir" "$s00_frag2" "$ext")
|
||||
_log 警告 "特典集号异常,使用原始片段"
|
||||
_log 信息 "目标: ${s00_dest%%|*}/${s00_dest#*|}"
|
||||
echo "$s00_dest"
|
||||
@@ -483,10 +834,9 @@ build_tv_dest() {
|
||||
}
|
||||
|
||||
# 多集区间:补充末集集名(同一季 JSON 已缓存;首末集名以 " - " 连接)
|
||||
local episode_end_name="" ep_range="" name_part=""
|
||||
local episode_end_name="" ep_range="" name_part="" e_fmt_end=""
|
||||
if [[ -n "$episode_end" ]] && [[ "$episode_end" != "$episode" ]]; then
|
||||
local end_json="${tmp_json:-$season0_json}"
|
||||
local e_fmt_end
|
||||
e_fmt_end=$(safe_printf_int "$episode_end" "00")
|
||||
ep_range="-E${e_fmt_end}"
|
||||
[[ -n "$end_json" ]] && episode_end_name=$(echo "$end_json" | jq -r ".episodes[] | select(.episode_number == ${episode_end}) | .name // empty")
|
||||
@@ -498,12 +848,21 @@ build_tv_dest() {
|
||||
name_part="$(sanitize "$episode_name")"
|
||||
fi
|
||||
|
||||
# 命名模板(用户可配置 NAMING_SEASON / NAMING_EPISODE):渲染季目录名与文件名。
|
||||
# 默认:Season {season:02} / S{season:02}E{episode:02}{range}{?episode_name: - {episode_name}}。
|
||||
# 输出匹配到的单集信息与目标文件(不受 DEBUG_LEVEL 限制)。
|
||||
# 文件名不含剧集名(Jellyfin 通过父目录识别剧集,不影响刮削)
|
||||
_log 匹配 "匹配单集: ${show_title}${year_tag} S${s_fmt}E${e_fmt}${ep_range} - ${name_part}"
|
||||
_log 信息 "目标: ${FOLDER_SHOWS}/${show_title}${year_tag}/Season ${s_fmt}/S${s_fmt}E${e_fmt}${ep_range} - ${name_part}.${ext}"
|
||||
local season_dir ep_display
|
||||
season_dir=$(render_naming NAMING_SEASON "season=$s_fmt")
|
||||
ep_display=$(render_naming NAMING_EPISODE \
|
||||
"season=$s_fmt" "episode=$e_fmt" "range=$ep_range" \
|
||||
"episode_name=$name_part" "episode_end=$e_fmt_end")
|
||||
ep_display=$(sanitize "$ep_display")
|
||||
[[ -n "$ep_display" ]] || ep_display="S${s_fmt}E${e_fmt}${ep_range}"
|
||||
_log 匹配 "匹配单集: ${show_dir} ${season_dir}/${ep_display}"
|
||||
_log 信息 "目标: ${FOLDER_SHOWS}/${show_dir}/${season_dir}/${ep_display}.${ext}"
|
||||
|
||||
echo "${FOLDER_SHOWS}/${show_title}${year_tag}/Season ${s_fmt}|S${s_fmt}E${e_fmt}${ep_range} - ${name_part}.${ext}"
|
||||
echo "${FOLDER_SHOWS}/${show_dir}/${season_dir}|${ep_display}.${ext}"
|
||||
sleep "$TMDB_DELAY"
|
||||
return 0
|
||||
}
|
||||
@@ -530,43 +889,80 @@ identify_tv_show() {
|
||||
search_name=$(strip_season_suffix "$title")
|
||||
fi
|
||||
|
||||
# 搜索剧集(tmdb_api 自动缓存 /search/tv;用 base 名命中率更高)
|
||||
# 注意:不能用 `if ! result=$(...)` 捕获 rc——! 会把 $? 反转为 0,须用 || search_rc=$?
|
||||
# 用户匹配规则(最高优先级,跳过搜索):标题命中 ID 映射 → 直接用指定剧集 ID。
|
||||
# 适合 TMDB 多候选易选错/搜索命中错条目的场景(确定性,不依赖 AI)。
|
||||
local rk
|
||||
rk=$(rule_key "$title")
|
||||
if [[ -n "${MATCH_RULES_ID_TV[$rk]:-}" ]]; then
|
||||
_log 匹配 "用户规则命中剧集 ID: ${MATCH_RULES_ID_TV[$rk]}($title)"
|
||||
local rule_dest
|
||||
if rule_dest=$(build_tv_dest "${MATCH_RULES_ID_TV[$rk]}" "${season:-1}" "${episode:-0}" \
|
||||
"${episode_end:-}" "$special_fragment" "$base" "$ext" "$file" "$title"); then
|
||||
echo "$rule_dest"
|
||||
return 0
|
||||
fi
|
||||
_log 错误 "用户规则指定的剧集详情缺失: /tv/${MATCH_RULES_ID_TV[$rk]}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 搜索剧集(tmdb_api 自动缓存 /search/tv;用 base 名命中率更高)。
|
||||
# 搜索重试链(统一尝试器 tv_search_once):zh → en → 规则别名(zh/en) → 目录 →
|
||||
# MAL → 后缀二次剥离(zh/en)。请求失败语义:主搜索(zh)失败(rc=2)短路 return 2;
|
||||
# 后续层失败忽略继续(与原逐层实现一致)。
|
||||
_log 搜索 "搜索剧集: $search_name"
|
||||
local result="" show_id="" search_rc=0
|
||||
result=$(tmdb_api "/search/tv" "query=${search_name}") || search_rc=$?
|
||||
if [[ $search_rc -eq 0 && -n "$result" ]]; then
|
||||
# 结果选择:① name/original_name 与 query 完全相等 → ② 排除"query 为结果名前缀"
|
||||
# 的结果(如 "Sword Art Online" 命中同人 "Sword Art Online Abridged")→ ③ 兜底第一个
|
||||
show_id=$(echo "$result" | jq -r --arg q "$search_name" '
|
||||
def norm: ascii_downcase;
|
||||
([.results[] | select((.name // "") | norm == ($q | norm)) | .id] | .[0]) //
|
||||
([.results[] | select((.original_name // "") | norm == ($q | norm)) | .id] | .[0]) //
|
||||
([.results[] | select(((.name // "") | norm | startswith($q | norm)) | not) | .id] | .[0]) //
|
||||
(.results[0].id // empty)' 2>/dev/null)
|
||||
local show_id="" _rc=0
|
||||
show_id=$(tv_search_once "$search_name" "$season" "$special_fragment" "") || _rc=$?
|
||||
if [[ $_rc -eq 2 ]]; then
|
||||
return 2
|
||||
fi
|
||||
if [[ -z "$show_id" ]]; then
|
||||
show_id=$(tv_search_once "$search_name" "$season" "$special_fragment" "en-US") || true
|
||||
fi
|
||||
# 用户匹配规则(搜索别名):zh/en 均空时用用户指定的重搜词再搜(确定性,
|
||||
# 优先于目录名兜底与 MAL/AI)。
|
||||
if [[ -z "$show_id" ]]; then
|
||||
local alias_term="" alias_year="" _ar=0
|
||||
IFS=$'\t' read -r alias_term alias_year <<<"$(match_rule_lookup_alias "$title")" || true
|
||||
if [[ -n "$alias_term" ]]; then
|
||||
_log 搜索 "用户规则别名搜索剧集: $alias_term"
|
||||
show_id=$(tv_search_once "$alias_term" "$season" "$special_fragment" "") || _ar=$?
|
||||
if [[ -z "$show_id" ]] && [[ $_ar -ne 2 ]]; then
|
||||
show_id=$(tv_search_once "$alias_term" "$season" "$special_fragment" "en-US") || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# 目录名兜底重搜(父目录名常是完整剧名;零外部依赖,优先于 MAL)
|
||||
if [[ -z "$show_id" ]]; then
|
||||
show_id=$(tv_search_by_dir "$file" "$search_name" "$season" "$special_fragment") || true
|
||||
if [[ -n "$show_id" ]]; then
|
||||
_log 匹配 "目录名兜底命中剧集: $show_id"
|
||||
fi
|
||||
fi
|
||||
# MAL 兜底(SEARCH_FALLBACK_MAL=true 时)
|
||||
if [[ -z "$show_id" ]] && [[ "${SEARCH_FALLBACK_MAL,,}" == "true" ]]; then
|
||||
local mal_title
|
||||
while IFS= read -r mal_title; do
|
||||
[[ -z "$mal_title" ]] && continue
|
||||
_log 搜索 "MAL 候选回搜剧集: $mal_title"
|
||||
show_id=$(tv_search_once "$mal_title" "$season" "$special_fragment" "en-US") || true
|
||||
[[ -n "$show_id" ]] && break
|
||||
done < <(mal_search "$search_name")
|
||||
fi
|
||||
# 兜底:base 名搜索无结果时,剥更深一层后缀重搜(如 "Railgun S" → "Railgun")
|
||||
if [[ -z "$show_id" ]] && [[ $search_rc -ne 2 ]] && [[ -n "$season_suffix" ]]; then
|
||||
local stripped2
|
||||
if [[ -z "$show_id" ]] && [[ -n "$season_suffix" ]]; then
|
||||
local stripped2 _r2=0
|
||||
stripped2=$(strip_season_suffix_word "$search_name")
|
||||
if [[ -n "${stripped2#* }" ]]; then
|
||||
search_name="${stripped2%% *}"
|
||||
_log 搜索 "季后缀二次剥离重搜: $search_name"
|
||||
local result2="" rc2=0
|
||||
result2=$(tmdb_api "/search/tv" "query=${search_name}") || rc2=$?
|
||||
if [[ $rc2 -eq 0 && -n "$result2" ]]; then
|
||||
result="$result2"
|
||||
show_id=$(echo "$result" | jq -r --arg q "$search_name" '
|
||||
def norm: ascii_downcase;
|
||||
([.results[] | select((.name // "") | norm == ($q | norm)) | .id] | .[0]) //
|
||||
([.results[] | select((.original_name // "") | norm == ($q | norm)) | .id] | .[0]) //
|
||||
([.results[] | select(((.name // "") | norm | startswith($q | norm)) | not) | .id] | .[0]) //
|
||||
(.results[0].id // empty)' 2>/dev/null)
|
||||
show_id=$(tv_search_once "$search_name" "$season" "$special_fragment" "") || _r2=$?
|
||||
if [[ -z "$show_id" ]] && [[ $_r2 -ne 2 ]]; then
|
||||
show_id=$(tv_search_once "$search_name" "$season" "$special_fragment" "en-US") || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$show_id" ]]; then
|
||||
[[ $search_rc -eq 2 ]] && return 2
|
||||
# 1=空哨兵跳过请求/无结果(2=请求失败已在主搜索处短路)
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -638,8 +1034,8 @@ identify_tv_show() {
|
||||
if [[ "$ms" == "0" || -z "$ms" ]]; then
|
||||
local rom_n=0
|
||||
case "$sf_norm" in
|
||||
ii) rom_n=2;; iii) rom_n=3;; iv) rom_n=4;; v) rom_n=5;;
|
||||
[0-9]) rom_n=10#$sf_norm;;
|
||||
ii) rom_n=2 ;; iii) rom_n=3 ;; iv) rom_n=4 ;; v) rom_n=5 ;;
|
||||
[0-9]) rom_n=10#$sf_norm ;;
|
||||
esac
|
||||
if ((rom_n >= 2)); then
|
||||
if echo "$tv_map" | jq -e --argjson n "$rom_n" \
|
||||
@@ -649,6 +1045,45 @@ identify_tv_show() {
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# 模糊匹配兜底(词表未收录的后缀,如 "code geass r2" 类变体):
|
||||
# 只比较季名**末尾窗口**(末 flen+2 字符——后缀词出现在末尾;整名比较
|
||||
# 距离恒大无意义),Levenshtein 距离 ≤ 阈值(长度 3/6 分档,上限 3)且
|
||||
# 最短者命中。仅后缀词 ≥3 字符启用(短后缀已被词表/罗马数字覆盖)。
|
||||
if [[ "$ms" == "0" || -z "$ms" ]] && ((${#sf_norm} >= 3)); then
|
||||
local lev_hit
|
||||
lev_hit=$(echo "$tv_map" | jq -r 'def norm: '"$JQ_NORM_LITE"'; .seasons[]? | "\(.season_number)\t\((.name // "") | norm)"' 2>/dev/null |
|
||||
awk -v q="$sf_norm" -v flen="${#sf_norm}" '
|
||||
function min3(a, b, c) { return a < b ? (a < c ? a : c) : (b < c ? b : c) }
|
||||
function lev(s, t, m, n, i, j, cost, d) {
|
||||
m = length(s); n = length(t)
|
||||
for (i = 0; i <= m; i++) d[i, 0] = i
|
||||
for (j = 0; j <= n; j++) d[0, j] = j
|
||||
for (i = 1; i <= m; i++) for (j = 1; j <= n; j++) {
|
||||
cost = (substr(s, i, 1) == substr(t, j, 1)) ? 0 : 1
|
||||
d[i, j] = min3(d[i-1, j] + 1, d[i, j-1] + 1, d[i-1, j-1] + cost)
|
||||
}
|
||||
return d[m, n]
|
||||
}
|
||||
BEGIN { best = -1; bd = 999 }
|
||||
{
|
||||
if ($2 == "") next
|
||||
# 末尾窗口:末 flen+1 字符(季名更短则整体比较)。
|
||||
# 窗口比后缀词多 1 个字符的容差——完美命中时距离 ≤1,
|
||||
# 与 thr 分档(1/2/3)匹配,保证阈值可达。
|
||||
w = (length($2) <= flen + 1) ? $2 : substr($2, length($2) - flen)
|
||||
dist = lev(q, w)
|
||||
if (dist < bd) { bd = dist; best = $1 }
|
||||
}
|
||||
END {
|
||||
thr = (flen <= 3) ? 1 : (flen <= 6) ? 2 : 3
|
||||
if (best > 0 && bd <= thr) print best
|
||||
}
|
||||
')
|
||||
if [[ -n "$lev_hit" ]]; then
|
||||
ms="$lev_hit"
|
||||
_log 警告 "季后缀 '$season_suffix' 模糊匹配 Season ${ms}"
|
||||
fi
|
||||
fi
|
||||
if [[ -n "$ms" ]] && [[ "$ms" != "0" ]]; then
|
||||
mapped_season="$ms"
|
||||
_log 警告 "季后缀 '$season_suffix' 匹配 Season ${ms},文件 S${season} 映射为 S${ms}"
|
||||
@@ -1,3 +1,5 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
################################################################################
|
||||
# 硬链接处理
|
||||
################################################################################
|
||||
@@ -86,4 +88,3 @@ _handle_error() {
|
||||
_log 错误 "脚本在第 $line_num 行出错(错误码:$?)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034,SC2153 # 跨文件共享全局变量(DESTINATION_DIR/MEDIA_OUTCOME_MAP,声明于 main.sh)
|
||||
|
||||
# 第三阶段:创建硬链接及处理伴随文件。
|
||||
# 幂等:same_inode 跳过已链接;撞名(不同 inode 占用)按官方多版本格式去重(- 2 后缀);
|
||||
# 同源旧链接(回退→正确识别的迁移)按 inode 清理。计数写入 LINK_SUCCESS_COUNT/LINK_FAIL_COUNT 供汇总。
|
||||
@@ -36,6 +39,8 @@ link_media() {
|
||||
# 幂等:目标已存在且同 inode(上次运行已链接)
|
||||
LINK_SUCCESS_COUNT=$((LINK_SUCCESS_COUNT + 1))
|
||||
_log 跳过 "硬链接已存在: $destination_file"
|
||||
# 补记账本(历史链接可能未入账;flush 在运行末尾统一落盘)
|
||||
[[ "${DRY_RUN:-false}" != "true" ]] && ledger_mark_linked "$video" "$destination_file"
|
||||
else
|
||||
# 撞名去重:目标被其他文件占用(inode 不同)→ 追加 " - 2" 后缀(官方多版本格式,
|
||||
# 前缀=文件夹名;Jellyfin 会把它们识别为一个条目的多个版本)
|
||||
@@ -65,6 +70,8 @@ link_media() {
|
||||
|
||||
if hardlink_or_dryrun "$video" "$destination_file"; then
|
||||
LINK_SUCCESS_COUNT=$((LINK_SUCCESS_COUNT + 1))
|
||||
# 登记已链接账本(增量标记;干运行不写内存——flush 也会跳过落盘)
|
||||
[[ "${DRY_RUN:-false}" != "true" ]] && ledger_mark_linked "$video" "$destination_file"
|
||||
else
|
||||
LINK_FAIL_COUNT=$((LINK_FAIL_COUNT + 1))
|
||||
if $AUTOMATED && [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
@@ -84,6 +91,8 @@ link_media() {
|
||||
|
||||
# 链接主视频的伴随文件(字幕/音轨等):目标名保留语言后缀(如 .zh、.zh-tw),
|
||||
# 避免多个伴随文件(.ass/.zh.ass/.zh-tw.ass)互相覆盖产生 .bak 堆积。
|
||||
# 链接成功后登记 MEDIA_DESTINATION_MAP/MEDIA_OUTCOME_MAP=companion,
|
||||
# 供 --export-map 对照表按主媒体分类展示(未链接成功的不登记)。
|
||||
link_companions() {
|
||||
local video="$1" destination_file="$2"
|
||||
local base_name companion companion_ext dest_companion
|
||||
@@ -133,16 +142,24 @@ link_companions() {
|
||||
_log 信息 "处理伴随文件: $(basename "$companion")"
|
||||
if ! hardlink_or_dryrun "$companion" "$dest_companion"; then
|
||||
_log 警告 "伴随文件硬链接失败: $(basename "$companion")"
|
||||
else
|
||||
# 登记伴随文件映射(对照表按分类展示用):dest 为相对 DESTINATION_DIR 的 "子目录|文件名"。
|
||||
# 与主媒体同分类(dest 首段 = 分类根),未链接成功的不登记(无映射可展示)。
|
||||
local comp_rel="${dest_companion#"$DESTINATION_DIR"/}"
|
||||
local comp_subdir="${comp_rel%/*}"
|
||||
local comp_filename="${comp_rel##*/}"
|
||||
MEDIA_DESTINATION_MAP["$companion"]="${comp_subdir}|${comp_filename}"
|
||||
MEDIA_OUTCOME_MAP["$companion"]="companion"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# 从视频文件名的发布组括号提取字幕组标记(& 原样保留;压制组词表过滤)。
|
||||
# 压制组词表:SUB_GROUP_BLACKLIST(mo_env,逗号分隔;默认 VCB-Studio)。
|
||||
# 压制组词表:SUB_GROUP_BLACKLIST(config.json,逗号分隔;默认 VCB-Studio)。
|
||||
subtitle_group_tag() {
|
||||
local video="$1"
|
||||
local bn br groups="" g
|
||||
local bn br
|
||||
bn=$(basename "$video")
|
||||
br=$(echo "$bn" | grep -oE '^\[[^]]*\]' | head -1 | tr -d '[]')
|
||||
[[ -z "$br" ]] && {
|
||||
@@ -159,6 +176,8 @@ subtitle_group_tag() {
|
||||
done
|
||||
$skip || keep+=("$p")
|
||||
done
|
||||
(IFS='&'; echo "${keep[*]}")
|
||||
(
|
||||
IFS='&'
|
||||
echo "${keep[*]}"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034,SC2141 # 跨文件共享全局变量(POOL_THRESHOLD_CHECK);IFS=$'...\...' 反斜杠为有意分隔符
|
||||
|
||||
# 入口目录校验:源目录存在可读;目的目录存在可写或可创建(干运行不创建目录)。
|
||||
validate_directories() {
|
||||
if [[ -n "$SOURCE_DIR" && (! -d "$SOURCE_DIR" || ! -r "$SOURCE_DIR") ]]; then
|
||||
@@ -44,7 +47,7 @@ check_hardlink_support() {
|
||||
fi
|
||||
}
|
||||
|
||||
# 初始化缓存目录,处理 --refresh-cache(清空整个缓存目录)。
|
||||
# 初始化缓存目录,处理 --refresh-cache(仅清空 tmdb/ 缓存,保留脚本自身学习数据)。
|
||||
# 干运行模式只解析路径、不创建目录(零持久化副作用)。
|
||||
init_cache_dir() {
|
||||
# 解析默认缓存路径(优先级:环境变量 > 执行目录 mo_cache > 脚本目录 mo_cache)
|
||||
@@ -63,13 +66,21 @@ init_cache_dir() {
|
||||
|
||||
if [[ "$REFRESH_CACHE" == "true" ]] && [[ -n "$CACHE_DIR" ]]; then
|
||||
cache_clear
|
||||
[[ "${DRY_RUN:-false}" != "true" ]] && _log 信息 "缓存已清除"
|
||||
[[ "${DRY_RUN:-false}" != "true" ]] && _log 信息 "TMDB 缓存已清除"
|
||||
fi
|
||||
|
||||
# 创建镜像 API 的缓存子目录
|
||||
# 旧版缓存布局(mo_cache/{movie,search,tv} 平铺)迁移到 tmdb/ 子目录
|
||||
if [[ -n "$CACHE_DIR" ]]; then
|
||||
migrate_tmdb_cache
|
||||
fi
|
||||
|
||||
# 创建缓存子目录:tmdb/(镜像 TMDB API 路径,可再生)+ media_organizer/
|
||||
# (脚本运行状态:已链接账本/失败冷却 ledger;用户配置类文件位于 mo_config/,
|
||||
# 由 init_special_map/init_special_words/init_skip_dirs/init_season_offset 各自创建)
|
||||
if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
mkdir -p "$CACHE_DIR/search/movie" "$CACHE_DIR/search/tv" \
|
||||
"$CACHE_DIR/tv" "$CACHE_DIR/movie" 2>/dev/null || true
|
||||
mkdir -p "$CACHE_DIR/tmdb/search/movie" "$CACHE_DIR/tmdb/search/tv" \
|
||||
"$CACHE_DIR/tmdb/tv" "$CACHE_DIR/tmdb/movie" \
|
||||
"$CACHE_DIR/media_organizer" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -120,8 +131,46 @@ scan_files() {
|
||||
|
||||
# 第一阶段:解析文件名并识别,构建 MEDIA_DESTINATION_MAP。
|
||||
# 按扩展名分发单个文件到视频/音频处理(识别池 worker 入口)。
|
||||
# 入口先做增量跳过:已链接(ledger 有效)与失败冷却(未到期)条目直接 SKIP,
|
||||
# 不再走 parse/识别(干运行不启用——干运行展示全貌;--rerun 不经此路径)。
|
||||
process_one_file() {
|
||||
local f="$1"
|
||||
if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
if ledger_is_linked "$f"; then
|
||||
_log 跳过 "增量跳过(已链接): $(basename "$f")"
|
||||
emit "$f" SKIP "already_linked"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# 纠错学习命中:--rerun 手动修正过的同命名文件,直接使用用户指定的目标
|
||||
# (key=去扩展名 + clean_name 小写,与 correction_learn 写入算法一致;
|
||||
# dest 必须位于当前 DESTINATION_DIR 下才适用;
|
||||
# 优先于失败冷却——用户显式纠错是强信号,无需再等冷却;
|
||||
# 干运行同样展示命中结果——学习写入才受 dry-run 约束)
|
||||
local ckey corr_dest
|
||||
ckey="${f##*/}"
|
||||
ckey="${ckey%.*}"
|
||||
ckey=$(clean_name "$ckey")
|
||||
ckey="${ckey,,}"
|
||||
corr_dest="${CORRECTIONS_MAP[$ckey]:-}"
|
||||
if [[ -n "$corr_dest" ]] && [[ -n "$DESTINATION_DIR" ]] && [[ "$corr_dest" == "$DESTINATION_DIR"/* ]]; then
|
||||
local corr_subdir corr_filename
|
||||
corr_subdir="${corr_dest#"$DESTINATION_DIR"/}"
|
||||
corr_filename="${corr_subdir##*/}"
|
||||
corr_subdir="${corr_subdir%/*}"
|
||||
if [[ -n "$corr_subdir" && -n "$corr_filename" ]]; then
|
||||
_log 信息 "纠错学习命中: $(basename "$f") -> $corr_subdir/"
|
||||
emit "$f" DEST "$corr_subdir|$corr_filename"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
if cooldown_active "$f"; then
|
||||
_log 跳过 "冷却中跳过(失败冷却未到期): $(basename "$f")"
|
||||
emit "$f" SKIP "cooldown"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
local ext="${f##*.}"
|
||||
ext="${ext,,}"
|
||||
if [[ " ${VIDEO_EXTS[*]} " =~ [[:space:]]${ext}[[:space:]] ]]; then
|
||||
@@ -151,6 +200,9 @@ process_one_video() {
|
||||
elif [[ "$type" == "tv" ]]; then
|
||||
dest=$(identify_tv_show "$title" "${season:-1}" "${episode:-0}" "${episode_end:-}" \
|
||||
"$base" "$ext" "$special_fragment" "$video") || rc=$?
|
||||
elif [[ "$type" == "musicvideo" ]]; then
|
||||
# Music Videos(v9.6):本地规则识别(无网络);恒成功(artist 有 Unknown 兜底)
|
||||
dest=$(identify_musicvideo "$video" "$title" "$ext") || rc=$?
|
||||
fi
|
||||
|
||||
if [[ "$type" == "skip" ]]; then
|
||||
@@ -318,8 +370,12 @@ process_one_audio() {
|
||||
[[ -z "$artist" ]] && artist="$FOLDER_UNKNOWN"
|
||||
[[ -z "$album" ]] && album="$FOLDER_UNKNOWN"
|
||||
|
||||
local destination_subdir
|
||||
destination_subdir="${FOLDER_MUSIC}/$(sanitize "$artist")/$(sanitize "$album")"
|
||||
local destination_subdir music_root
|
||||
# 音乐目录结构走 NAMING_MUSIC 模板(默认 {artist}/{album};用户可自定义)
|
||||
music_root=$(render_naming NAMING_MUSIC \
|
||||
"artist=$(sanitize "$artist")" "album=$(sanitize "$album")")
|
||||
[[ -n "$music_root" ]] || music_root="$(sanitize "$artist")/$(sanitize "$album")"
|
||||
destination_subdir="${FOLDER_MUSIC}/$music_root"
|
||||
[[ -n "$rel_dir" ]] && destination_subdir="$destination_subdir/$rel_dir"
|
||||
emit "$audio" DEST "$destination_subdir|$(basename "$audio")"
|
||||
_log 信息 "音频: $(basename "$audio") -> $destination_subdir/"
|
||||
@@ -337,14 +393,17 @@ emit_audio_companions() {
|
||||
[[ -f "$c" ]] && emit "$c" DEST "$destination_subdir|$(basename "$c")"
|
||||
done
|
||||
# 专辑封面:源专辑根目录的类型名图片(cover/folder/poster/jacket/thumb/default)
|
||||
local cover_file cb cname
|
||||
local cover_file cb cname music_root
|
||||
music_root=$(render_naming NAMING_MUSIC \
|
||||
"artist=$(sanitize "$artist")" "album=$(sanitize "$album")")
|
||||
[[ -n "$music_root" ]] || music_root="$(sanitize "$artist")/$(sanitize "$album")"
|
||||
for cover_file in "$cd_root"/*.jpg "$cd_root"/*.png "$cd_root"/*.webp; do
|
||||
[[ ! -f "$cover_file" ]] && continue
|
||||
cb=$(basename "$cover_file")
|
||||
cname="${cb%.*}"
|
||||
if echo "$cname" | grep -qiE '^(cover|folder|poster|jacket|thumb|default)$'; then
|
||||
emit "$cover_file" DEST \
|
||||
"${FOLDER_MUSIC}/$(sanitize "$artist")/$(sanitize "$album")|$cb"
|
||||
"${FOLDER_MUSIC}/$music_root|$cb"
|
||||
break
|
||||
fi
|
||||
done
|
||||
@@ -357,4 +416,3 @@ process_audio() {
|
||||
POOL_THRESHOLD_CHECK=false
|
||||
pool_run "${AUDIO_FILES[@]}"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034 # 跨文件共享全局 map(MEDIA_DESTINATION_MAP 等,声明于 main.sh)
|
||||
|
||||
# 结构上不可能出现只写一半的不一致。识别池并发运行时,子进程通过 emit 输出
|
||||
# 协议行(key\tTYPE\tpayload)到 POOL_EMIT_FILE,父进程合并时调用 register_*。
|
||||
# 协议行类型:
|
||||
@@ -41,15 +44,22 @@ register_pending_artist() {
|
||||
}
|
||||
|
||||
# 登记跳过条目(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 匹配甄别条目(搜索有结果但需甄别;不进入目的映射)。
|
||||
@@ -91,7 +101,7 @@ merge_emit_lines() {
|
||||
MERGE_PROCESSED=0
|
||||
MERGE_FAILED=0
|
||||
MERGE_STREAK=0
|
||||
local line key type payload
|
||||
local key type payload
|
||||
while IFS=$'\t' read -r key type payload; do
|
||||
[[ -z "$key" ]] && continue
|
||||
MERGE_PROCESSED=$((MERGE_PROCESSED + 1))
|
||||
@@ -195,4 +205,3 @@ pool_run() {
|
||||
unset POOL_EMIT_FILE
|
||||
rm -rf "$pool_dir"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
# 同集冲突检测:识别成功的剧集条目按 "剧集目录||SxxExx"(含区间 E01-E02)聚合,
|
||||
# 同一 (剧集, 季, 集) 出现多个不同源文件 → 登记冲突(并列展示,不自动处理;
|
||||
# 链接阶段仍按 - 2 官方多版本格式去重)。Season 00 特典不参与(同名特典属正常多版本)。
|
||||
# 幂等:每次调用重建 CONFLICT_MAP(内存账本)。
|
||||
detect_conflicts() {
|
||||
CONFLICT_MAP=()
|
||||
local src outcome dest subdir filename
|
||||
for src in "${!MEDIA_DESTINATION_MAP[@]}"; do
|
||||
outcome="${MEDIA_OUTCOME_MAP[$src]:-}"
|
||||
[[ "$outcome" == "identified" || "$outcome" == "fallback" ]] || continue
|
||||
dest="${MEDIA_DESTINATION_MAP[$src]}"
|
||||
IFS='|' read -r subdir filename <<<"$dest"
|
||||
# 剧集目标形如:Shows/Title (Year)/Season 01/S01E01 - name.ext
|
||||
# (Season 补零格式 [0-9]+;Season 00 特典数值排除,不参与冲突检测)
|
||||
if [[ "$subdir" =~ ^(.*)/Season[[:space:]]([0-9]+)$ ]] && ((10#${BASH_REMATCH[2]} >= 1)); then
|
||||
local show_dir="${BASH_REMATCH[1]}"
|
||||
if [[ "$filename" =~ ^(S[0-9]+E[0-9]+(-E[0-9]+)?) ]]; then
|
||||
local key="${show_dir}||${BASH_REMATCH[1]}"
|
||||
CONFLICT_MAP["$key"]+="${src}|${dest}"$'\n'
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# 只保留多于 1 个来源的冲突
|
||||
local k lines
|
||||
for k in "${!CONFLICT_MAP[@]}"; do
|
||||
lines=$(printf '%s' "${CONFLICT_MAP[$k]}" | grep -c . || true)
|
||||
((lines >= 2)) || unset "CONFLICT_MAP[$k]"
|
||||
done
|
||||
}
|
||||
|
||||
# 生成源→目标对照表 markdown(--export-map):
|
||||
# 文件名 = <源目录名(sanitize)>-<源目录绝对路径 md5 前 8 位>.md(可读 + 唯一 + 稳定覆盖);
|
||||
# 默认目录 = 脚本所在目录/mo_map;--export-map=DIR 指定目录。
|
||||
# 内容按分类展示:按目的目录根(FOLDER_MOVIES/FOLDER_SHOWS/FOLDER_MUSIC/
|
||||
# FOLDER_MUSICVIDEOS/FOLDER_UNKNOWN)分区,每类一张表格 + 分类汇总;
|
||||
# 伴随文件(字幕/音轨等,link_companions 登记 dest)随主媒体归入对应大类;
|
||||
# 无目标/失败/跳过/待 AI 条目归入「未完成」区。
|
||||
# 每行:源文件绝对路径链接 + 目标文件绝对路径链接 + 链接状态(已链接/失败/跳过/未识别/请求失败/待 AI)。
|
||||
# 链接状态在非干运行下现场校验(目标存在且同 inode)。
|
||||
# 目的子目录首段 → 分类键(与 FOLDER_* 目的根名对应)。
|
||||
classify_dest_root() {
|
||||
local root="$1"
|
||||
case "$root" in
|
||||
"$FOLDER_MOVIES") echo movies ;;
|
||||
"$FOLDER_SHOWS") echo shows ;;
|
||||
"$FOLDER_MUSIC") echo music ;;
|
||||
"$FOLDER_MUSICVIDEOS") echo musicvideos ;;
|
||||
"$FOLDER_UNKNOWN") echo unknown ;;
|
||||
*) echo other ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 按结局分类:identified/fallback/companion 且有目的 → 目的根分类;其余 → 未完成。
|
||||
# 伴随文件(字幕/音轨等)在 link_companions 链接成功后登记 dest,随主媒体归入对应大类。
|
||||
cat_for_outcome() {
|
||||
local outcome="$1" dest="$2"
|
||||
if [[ -n "$dest" ]] && [[ "$outcome" == "identified" || "$outcome" == "fallback" || "$outcome" == "companion" ]]; then
|
||||
# dest = "子目录|文件名",子目录 = "分类根/.../...",取分类根首段
|
||||
local root="${dest%%|*}"
|
||||
root="${root%%/*}"
|
||||
classify_dest_root "$root"
|
||||
else
|
||||
echo other
|
||||
fi
|
||||
}
|
||||
|
||||
# 渲染分类表格(每类独立编号;0 条分类不输出小节)。
|
||||
render_section() {
|
||||
local title="$1" count="$2"
|
||||
shift 2
|
||||
((count > 0)) || return 0
|
||||
local -a rows=("$@")
|
||||
echo ""
|
||||
echo "## ${title}(${count} 条)"
|
||||
echo ""
|
||||
echo "| # | 源文件 | 目标文件 | 链接状态 |"
|
||||
echo "|---|---|---|---|"
|
||||
local i=0 k
|
||||
for ((k = 0; k < ${#rows[@]}; k += 3)); do
|
||||
i=$((i + 1))
|
||||
echo "| $i | ${rows[$k]} | ${rows[$((k + 1))]} | ${rows[$((k + 2))]} |"
|
||||
done
|
||||
}
|
||||
|
||||
export_map_md() {
|
||||
[[ "$EXPORT_MAP" != "true" ]] && return 0
|
||||
detect_conflicts
|
||||
local out_dir="${EXPORT_MAP_DIR:-${SCRIPT_DIR}/mo_map}"
|
||||
if ! mkdir -p "$out_dir" 2>/dev/null; then
|
||||
_log 错误 "无法创建对照表目录: $out_dir"
|
||||
return 1
|
||||
fi
|
||||
# 文件名 = <源目录名>-<md5 前 8 位>.md:源目录名保证可读(sanitize 去非法字符,
|
||||
# 空名(如根目录)回退 root),md5 前缀保证唯一与稳定(同一源目录覆盖更新)。
|
||||
local src_name src_hash
|
||||
src_name=$(basename "$SOURCE_DIR")
|
||||
src_name=$(sanitize "$src_name")
|
||||
[[ -n "$src_name" ]] || src_name="root"
|
||||
src_hash=$(printf '%s' "$SOURCE_DIR" | md5sum | cut -d' ' -f1 | cut -c1-8)
|
||||
local out_file="$out_dir/${src_name}-${src_hash}.md"
|
||||
|
||||
# 分类桶:每行存 3 段(源显示|目标显示|状态),输出时按类重编号。
|
||||
# 顺序固定:电影 → 节目 → 音乐 → 音乐视频 → 未知 → 未完成。
|
||||
local -a ROW_MOVIES=() ROW_SHOWS=() ROW_MUSIC=() ROW_MUSICVIDEOS=() ROW_UNKNOWN=() ROW_OTHER=()
|
||||
local -A CAT_COUNT=()
|
||||
local -A CAT_LABEL=(
|
||||
[movies]="🎬 电影"
|
||||
[shows]="📺 节目"
|
||||
[music]="🎵 音乐"
|
||||
[musicvideos]="🎤 音乐视频"
|
||||
[unknown]="❓ 未知"
|
||||
[other]="⏭ 未完成(未识别/跳过/失败/待 AI)"
|
||||
)
|
||||
|
||||
local n_total=0 n_linked=0 n_fail=0 n_skip=0
|
||||
local -a srcs=("${!MEDIA_OUTCOME_MAP[@]}")
|
||||
# 补充伴随音轨(源媒体文件无结局条目 → 伴随,不独立整理)
|
||||
local f
|
||||
for f in "${VIDEO_FILES[@]}" "${AUDIO_FILES[@]}"; do
|
||||
[[ -z "${MEDIA_OUTCOME_MAP[$f]:-}" ]] && srcs+=("$f")
|
||||
done
|
||||
n_total=${#srcs[@]}
|
||||
|
||||
local src outcome dest status dest_display cat
|
||||
local src_disp dest_subdir dest_filename dest_path
|
||||
for src in "${srcs[@]}"; do
|
||||
outcome="${MEDIA_OUTCOME_MAP[$src]:-companion}"
|
||||
dest="${MEDIA_DESTINATION_MAP[$src]:-}"
|
||||
status=""
|
||||
dest_subdir=""
|
||||
dest_filename=""
|
||||
dest_path=""
|
||||
if [[ -n "$dest" ]]; then
|
||||
# dest 格式为 "子目录|文件名"(| 是条目分隔符,不能进 URL)
|
||||
IFS='|' read -r dest_subdir dest_filename <<<"$dest"
|
||||
dest_path="${DESTINATION_DIR}/${dest_subdir}/${dest_filename}"
|
||||
fi
|
||||
case "$outcome" in
|
||||
identified | fallback)
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
status="🔄 干运行"
|
||||
elif [[ -n "$dest_path" && -f "$dest_path" ]] && same_inode "$src" "$dest_path"; then
|
||||
status="✅ 已链接"
|
||||
n_linked=$((n_linked + 1))
|
||||
else
|
||||
status="❌ 链接失败"
|
||||
n_fail=$((n_fail + 1))
|
||||
fi
|
||||
;;
|
||||
skip_type)
|
||||
status="⏭ 类型跳过"
|
||||
n_skip=$((n_skip + 1))
|
||||
;;
|
||||
skip_unidentified)
|
||||
status="❓ 未识别跳过"
|
||||
n_skip=$((n_skip + 1))
|
||||
;;
|
||||
request_failed)
|
||||
status="⚠️ 请求失败"
|
||||
n_fail=$((n_fail + 1))
|
||||
;;
|
||||
pending_ai) status="🤖 待 AI" ;;
|
||||
already_linked) status="✅ 增量跳过(已链接)" ;;
|
||||
cooldown) status="⏳ 冷却中跳过" ;;
|
||||
companion) status="📎 伴随文件(跟随主媒体)" ;;
|
||||
*) status="$outcome" ;;
|
||||
esac
|
||||
src_disp="[$(basename "$src" | sed 's/|/\\|/g')](file://$src)"
|
||||
dest_display="(无目标)"
|
||||
if [[ -n "$dest_path" ]]; then
|
||||
dest_display="[$(basename "$dest_filename" | sed 's/|/\\|/g')](file://${dest_path})"
|
||||
fi
|
||||
cat=$(cat_for_outcome "$outcome" "$dest")
|
||||
CAT_COUNT["$cat"]=$((${CAT_COUNT["$cat"]:-0} + 1))
|
||||
case "$cat" in
|
||||
movies) ROW_MOVIES+=("$src_disp" "$dest_display" "$status") ;;
|
||||
shows) ROW_SHOWS+=("$src_disp" "$dest_display" "$status") ;;
|
||||
music) ROW_MUSIC+=("$src_disp" "$dest_display" "$status") ;;
|
||||
musicvideos) ROW_MUSICVIDEOS+=("$src_disp" "$dest_display" "$status") ;;
|
||||
unknown) ROW_UNKNOWN+=("$src_disp" "$dest_display" "$status") ;;
|
||||
*) ROW_OTHER+=("$src_disp" "$dest_display" "$status") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
{
|
||||
echo "# 媒体整理对照表"
|
||||
echo ""
|
||||
echo "- 源目录:\`$SOURCE_DIR\`"
|
||||
echo "- 目的目录:\`${DESTINATION_DIR:-(未指定)}\`"
|
||||
echo "- 生成时间:$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
[[ "$DRY_RUN" == "true" ]] && echo "- 运行模式:**干运行**(未创建链接)"
|
||||
[[ "$AUTOMATED" == "true" ]] && echo "- 运行模式:自动化"
|
||||
echo ""
|
||||
echo "## 📊 分类汇总(${n_total} 条)"
|
||||
echo ""
|
||||
local c
|
||||
for c in movies shows music musicvideos unknown other; do
|
||||
[[ -n "${CAT_COUNT[$c]:-}" ]] && echo "- ${CAT_LABEL[$c]}:${CAT_COUNT[$c]} 条"
|
||||
done
|
||||
render_section "${CAT_LABEL[movies]}" "${CAT_COUNT[movies]:-0}" "${ROW_MOVIES[@]}"
|
||||
render_section "${CAT_LABEL[shows]}" "${CAT_COUNT[shows]:-0}" "${ROW_SHOWS[@]}"
|
||||
render_section "${CAT_LABEL[music]}" "${CAT_COUNT[music]:-0}" "${ROW_MUSIC[@]}"
|
||||
render_section "${CAT_LABEL[musicvideos]}" "${CAT_COUNT[musicvideos]:-0}" "${ROW_MUSICVIDEOS[@]}"
|
||||
render_section "${CAT_LABEL[unknown]}" "${CAT_COUNT[unknown]:-0}" "${ROW_UNKNOWN[@]}"
|
||||
render_section "${CAT_LABEL[other]}" "${CAT_COUNT[other]:-0}" "${ROW_OTHER[@]}"
|
||||
# 同集冲突小节(仅检测+报告:同一季集多个来源,链接已按多版本 - 2 去重)
|
||||
if [[ ${#CONFLICT_MAP[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "## ⚠️ 同集冲突(${#CONFLICT_MAP[@]} 组)"
|
||||
echo ""
|
||||
echo "同一剧集/季/集出现多个来源文件。已按官方多版本格式(- 2 后缀)去重链接;"
|
||||
echo "如需指定保留版本,编辑对应 .ledger.json 后运行 --rerun 重跑。"
|
||||
echo ""
|
||||
echo "| 剧集/集 | 来源文件 | 目标 |"
|
||||
echo "|---|---|---|"
|
||||
local ck cline i=0 csrc cdest cfile
|
||||
for ck in "${!CONFLICT_MAP[@]}"; do
|
||||
i=$((i + 1))
|
||||
while IFS= read -r cline; do
|
||||
[[ -z "$cline" ]] && continue
|
||||
csrc="${cline%%|*}"
|
||||
cdest="${cline#*|}"
|
||||
cfile="${cdest##*|}"
|
||||
echo "| $i | [$(basename "$csrc" | sed 's/|/\\|/g')](file://$csrc) | $(basename "$cfile" | sed 's/|/\\|/g') |"
|
||||
done <<<"${CONFLICT_MAP[$ck]}"
|
||||
done
|
||||
fi
|
||||
echo ""
|
||||
echo "## 链接状态汇总"
|
||||
echo ""
|
||||
echo "- 已链接:${n_linked}"
|
||||
echo "- 失败:${n_fail}"
|
||||
echo "- 跳过/未识别:${n_skip}"
|
||||
echo "- 干运行/待 AI/伴随:$((n_total - n_linked - n_fail - n_skip))"
|
||||
} >"$out_file"
|
||||
_log 成功 "源目标对照表已生成: $out_file"
|
||||
|
||||
# 伴生账本(同名 .ledger.json):供 --rerun 手动纠错。
|
||||
# 条目:src(源绝对路径)/ dest(目标绝对路径)/ outcome / status(linked|pending)/ action("")。
|
||||
# 用户编辑:改 dest 为期望目标;把 action 置 "rerun" 后运行 --rerun <文件>。
|
||||
export_ledger_json "${out_file%.md}.ledger.json" "${srcs[@]}"
|
||||
}
|
||||
|
||||
# 导出手动纠错账本(JSON 数组;与对照表 md 同名 .ledger.json)。
|
||||
# 参数:账本路径 + 源条目列表(export_map_md 传入)。
|
||||
# status 现场校验(非干运行):目标存在且同 inode → linked,否则 pending。
|
||||
export_ledger_json() {
|
||||
local ledger_file="$1"
|
||||
shift
|
||||
local -a srcs=("$@")
|
||||
local -a ledger_rows=()
|
||||
local src outcome dest dest_path dest_subdir dest_filename row_status
|
||||
for src in "${srcs[@]}"; do
|
||||
outcome="${MEDIA_OUTCOME_MAP[$src]:-companion}"
|
||||
dest="${MEDIA_DESTINATION_MAP[$src]:-}"
|
||||
dest_path=""
|
||||
if [[ -n "$dest" ]]; then
|
||||
IFS='|' read -r dest_subdir dest_filename <<<"$dest"
|
||||
dest_path="${DESTINATION_DIR}/${dest_subdir}/${dest_filename}"
|
||||
fi
|
||||
row_status="pending"
|
||||
if [[ "$DRY_RUN" != "true" ]] && [[ -n "$dest_path" && -f "$dest_path" ]] &&
|
||||
same_inode "$src" "$dest_path"; then
|
||||
row_status="linked"
|
||||
fi
|
||||
ledger_rows+=("$(jq -nc --arg s "$src" --arg d "${dest_path:-}" --arg o "$outcome" \
|
||||
--arg st "$row_status" '{src: $s, dest: $d, outcome: $o, status: $st, action: ""}')")
|
||||
done
|
||||
if printf '%s\n' "${ledger_rows[@]}" | jq -s . >"$ledger_file" 2>/dev/null; then
|
||||
_log 成功 "纠错账本已生成(编辑后 --rerun 重跑): $ledger_file"
|
||||
else
|
||||
_log 警告 "账本写入失败: $ledger_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# 运行级汇总:按结局账本分类计数并列出文件清单(每类前 10 条,其余折叠;
|
||||
# 自动化模式同时向日志文件写入全量清单)。
|
||||
print_summary() {
|
||||
detect_conflicts
|
||||
local -A counts=()
|
||||
local -A lists=()
|
||||
local key outcome
|
||||
for key in "${!MEDIA_OUTCOME_MAP[@]}"; do
|
||||
outcome="${MEDIA_OUTCOME_MAP[$key]}"
|
||||
counts["$outcome"]=$((${counts[$outcome]:-0} + 1))
|
||||
if ((${counts[$outcome]} <= 10)); then
|
||||
lists["$outcome"]+="$(basename "$key")"$'\n'
|
||||
fi
|
||||
done
|
||||
|
||||
local -a categories=(identified fallback skip_type skip_unidentified request_failed already_linked cooldown)
|
||||
local -a labels=("识别成功" "回退命名(降级成功)" "类型跳过(电影特典)" "未识别跳过" "请求失败" "增量跳过(已链接)" "冷却中跳过")
|
||||
local i
|
||||
for i in "${!categories[@]}"; do
|
||||
local cat="${categories[$i]}"
|
||||
[[ -z "${counts[$cat]:-}" ]] && continue
|
||||
_log 成功 "${labels[$i]}:${counts[$cat]} 个"
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" ]] && _log 信息 " - $line"
|
||||
done <<<"${lists[$cat]:-}"
|
||||
if ((${counts[$cat]} > 10)); then
|
||||
_log 信息 " ... 其余 $((${counts[$cat]} - 10)) 条(automated 模式日志文件含全量清单)"
|
||||
fi
|
||||
done
|
||||
_log 成功 "链接:成功 $LINK_SUCCESS_COUNT,失败 $LINK_FAIL_COUNT"
|
||||
|
||||
# 同集冲突提示(仅检测+报告;链接已按多版本 - 2 去重)
|
||||
if [[ ${#CONFLICT_MAP[@]} -gt 0 ]]; then
|
||||
_log 警告 "同集冲突:${#CONFLICT_MAP[@]} 组(同一剧集/季/集多个来源;详见对照表,可用 --rerun 指定保留版本)"
|
||||
fi
|
||||
|
||||
# 自动化模式:全量清单写入日志文件(干运行不写,零持久化副作用)
|
||||
if [[ "${AUTOMATED:-false}" == "true" ]] && [[ "${DRY_RUN:-false}" != "true" ]] &&
|
||||
[[ -n "${LOG_FILE:-}" ]] && [[ ${#MEDIA_OUTCOME_MAP[@]} -gt 0 ]]; then
|
||||
{
|
||||
echo "=== 运行汇总(全量清单)==="
|
||||
for key in "${!MEDIA_OUTCOME_MAP[@]}"; do
|
||||
printf '%s\t%s\n' "$key" "${MEDIA_OUTCOME_MAP[$key]}"
|
||||
done
|
||||
} >>"$LOG_FILE"
|
||||
fi
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
# 生成源→目标对照表 markdown(--export-map):
|
||||
# 文件名 = 源目录绝对路径的 md5;默认目录 = 脚本所在目录/mo_map;--export-map=DIR 指定目录。
|
||||
# 每行:源文件绝对路径链接 + 目标文件绝对路径链接 + 链接状态(已链接/失败/跳过/未识别/请求失败/待 AI)。
|
||||
# 链接状态在非干运行下现场校验(目标存在且同 inode)。
|
||||
export_map_md() {
|
||||
[[ "$EXPORT_MAP" != "true" ]] && return 0
|
||||
local out_dir="${EXPORT_MAP_DIR:-${SCRIPT_DIR}/mo_map}"
|
||||
if ! mkdir -p "$out_dir" 2>/dev/null; then
|
||||
_log 错误 "无法创建对照表目录: $out_dir"
|
||||
return 1
|
||||
fi
|
||||
local src_hash
|
||||
src_hash=$(printf '%s' "$SOURCE_DIR" | md5sum | cut -d' ' -f1)
|
||||
local out_file="$out_dir/${src_hash}.md"
|
||||
{
|
||||
echo "# 媒体整理对照表"
|
||||
echo ""
|
||||
echo "- 源目录:\`$SOURCE_DIR\`"
|
||||
echo "- 目的目录:\`${DESTINATION_DIR:-(未指定)}\`"
|
||||
echo "- 生成时间:$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
[[ "$DRY_RUN" == "true" ]] && echo "- 运行模式:**干运行**(未创建链接)"
|
||||
[[ "$AUTOMATED" == "true" ]] && echo "- 运行模式:自动化"
|
||||
echo ""
|
||||
local n_total=0 n_linked=0 n_fail=0 n_skip=0
|
||||
local -a srcs=("${!MEDIA_OUTCOME_MAP[@]}")
|
||||
# 补充伴随音轨(源媒体文件无结局条目 → 伴随,不独立整理)
|
||||
local f
|
||||
for f in "${VIDEO_FILES[@]}" "${AUDIO_FILES[@]}"; do
|
||||
[[ -z "${MEDIA_OUTCOME_MAP[$f]:-}" ]] && srcs+=("$f")
|
||||
done
|
||||
n_total=${#srcs[@]}
|
||||
echo "## 文件对照(${n_total} 条)"
|
||||
echo ""
|
||||
echo "| # | 源文件 | 目标文件 | 链接状态 |"
|
||||
echo "|---|---|---|---|"
|
||||
local i=0 src outcome dest status dest_display
|
||||
for src in "${srcs[@]}"; do
|
||||
i=$((i + 1))
|
||||
outcome="${MEDIA_OUTCOME_MAP[$src]:-companion}"
|
||||
dest=""
|
||||
status=""
|
||||
case "$outcome" in
|
||||
identified | fallback)
|
||||
dest="${MEDIA_DESTINATION_MAP[$src]:-}"
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
status="🔄 干运行"
|
||||
elif [[ -n "$dest" && -f "${DESTINATION_DIR}/${dest}" ]] && same_inode "$src" "${DESTINATION_DIR}/${dest}"; then
|
||||
status="✅ 已链接"
|
||||
n_linked=$((n_linked + 1))
|
||||
else
|
||||
status="❌ 链接失败"
|
||||
n_fail=$((n_fail + 1))
|
||||
fi
|
||||
;;
|
||||
skip_type) status="⏭ 类型跳过"; n_skip=$((n_skip + 1));;
|
||||
skip_unidentified) status="❓ 未识别跳过"; n_skip=$((n_skip + 1));;
|
||||
request_failed) status="⚠️ 请求失败"; n_fail=$((n_fail + 1));;
|
||||
pending_ai) status="🤖 待 AI";;
|
||||
companion) status="🎬 伴随音轨(跟随视频)";;
|
||||
*) status="$outcome";;
|
||||
esac
|
||||
dest_display=""
|
||||
if [[ -n "$dest" ]]; then
|
||||
# dest 格式为 "子目录|文件名",拆分后拼接绝对路径(| 是条目分隔符,不能进 URL)
|
||||
local dest_subdir dest_filename
|
||||
IFS='|' read -r dest_subdir dest_filename <<<"$dest"
|
||||
local dest_abs="${DESTINATION_DIR}/${dest_subdir}/${dest_filename}"
|
||||
dest_display="[$(basename "$dest_filename" | sed 's/|/\\|/g')](file://${dest_abs})"
|
||||
fi
|
||||
echo "| $i | [$(basename "$src" | sed 's/|/\\|/g')](file://$src) | ${dest_display:-(无目标)} | $status |"
|
||||
done
|
||||
echo ""
|
||||
echo "## 链接状态汇总"
|
||||
echo ""
|
||||
echo "- 已链接:${n_linked}"
|
||||
echo "- 失败:${n_fail}"
|
||||
echo "- 跳过/未识别:${n_skip}"
|
||||
echo "- 干运行/待 AI/伴随:$((n_total - n_linked - n_fail - n_skip))"
|
||||
} >"$out_file"
|
||||
_log 成功 "源目标对照表已生成: $out_file"
|
||||
}
|
||||
|
||||
# 运行级汇总:按结局账本分类计数并列出文件清单(每类前 10 条,其余折叠;
|
||||
# 自动化模式同时向日志文件写入全量清单)。
|
||||
print_summary() {
|
||||
local -A counts=()
|
||||
local -A lists=()
|
||||
local key outcome
|
||||
for key in "${!MEDIA_OUTCOME_MAP[@]}"; do
|
||||
outcome="${MEDIA_OUTCOME_MAP[$key]}"
|
||||
counts["$outcome"]=$((${counts[$outcome]:-0} + 1))
|
||||
if ((${counts[$outcome]} <= 10)); then
|
||||
lists["$outcome"]+="$(basename "$key")"$'\n'
|
||||
fi
|
||||
done
|
||||
|
||||
local -a categories=(identified fallback skip_type skip_unidentified request_failed)
|
||||
local -a labels=("识别成功" "回退命名(降级成功)" "类型跳过(电影特典)" "未识别跳过" "请求失败")
|
||||
local i
|
||||
for i in "${!categories[@]}"; do
|
||||
local cat="${categories[$i]}"
|
||||
[[ -z "${counts[$cat]:-}" ]] && continue
|
||||
_log 成功 "${labels[$i]}:${counts[$cat]} 个"
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" ]] && _log 信息 " - $line"
|
||||
done <<<"${lists[$cat]:-}"
|
||||
if ((${counts[$cat]} > 10)); then
|
||||
_log 信息 " ... 其余 $((${counts[$cat]} - 10)) 条(automated 模式日志文件含全量清单)"
|
||||
fi
|
||||
done
|
||||
_log 成功 "链接:成功 $LINK_SUCCESS_COUNT,失败 $LINK_FAIL_COUNT"
|
||||
|
||||
# 自动化模式:全量清单写入日志文件(干运行不写,零持久化副作用)
|
||||
if [[ "${AUTOMATED:-false}" == "true" ]] && [[ "${DRY_RUN:-false}" != "true" ]] &&
|
||||
[[ -n "${LOG_FILE:-}" ]] && [[ ${#MEDIA_OUTCOME_MAP[@]} -gt 0 ]]; then
|
||||
{
|
||||
echo "=== 运行汇总(全量清单)==="
|
||||
for key in "${!MEDIA_OUTCOME_MAP[@]}"; do
|
||||
printf '%s\t%s\n' "$key" "${MEDIA_OUTCOME_MAP[$key]}"
|
||||
done
|
||||
} >>"$LOG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
################################################################################
|
||||
# 缓存管理
|
||||
################################################################################
|
||||
|
||||
# 计算缓存文件路径(镜像 TMDB API 路径结构)。
|
||||
# 计算缓存文件路径(镜像 TMDB API 路径结构,位于 $CACHE_DIR/tmdb 下)。
|
||||
# 缓存根目录分两区:tmdb/(TMDB API 响应缓存,可整体删除重建)与
|
||||
# media_organizer/(脚本运行状态:账本/冷却/辅助数据);用户配置类文件位于 mo_config/。
|
||||
# 参数:endpoint(如 /search/tv、/tv/{id}/season/{n})与 key(查询参数串)。
|
||||
# 搜索类用 key 的 md5 哈希作文件名;id 类直接用 id 作文件名。
|
||||
cache_path() {
|
||||
@@ -11,7 +15,7 @@ cache_path() {
|
||||
case "$endpoint" in
|
||||
/search/*)
|
||||
hash=$(cache_key_hash "$key")
|
||||
echo "$CACHE_DIR${endpoint}/${hash}.json"
|
||||
echo "$CACHE_DIR/tmdb${endpoint}/${hash}.json"
|
||||
;;
|
||||
# 注意:id 类路径含语言段(.${TMDB_LANG})——详情/季数据语言相关,切语言自动 miss 重新拉取
|
||||
/tv/*/season/*)
|
||||
@@ -19,16 +23,16 @@ cache_path() {
|
||||
rest="${endpoint#/tv/}"
|
||||
id="${rest%%/*}"
|
||||
season="${rest##*/}"
|
||||
echo "$CACHE_DIR/tv/${id}/season/${season}.${TMDB_LANG}.json"
|
||||
echo "$CACHE_DIR/tmdb/tv/${id}/season/${season}.${TMDB_LANG}.json"
|
||||
;;
|
||||
/tv/*)
|
||||
echo "$CACHE_DIR/tv/${endpoint#/tv/}.${TMDB_LANG}.json"
|
||||
echo "$CACHE_DIR/tmdb/tv/${endpoint#/tv/}.${TMDB_LANG}.json"
|
||||
;;
|
||||
/movie/*)
|
||||
echo "$CACHE_DIR/movie/${endpoint#/movie/}.${TMDB_LANG}.json"
|
||||
echo "$CACHE_DIR/tmdb/movie/${endpoint#/movie/}.${TMDB_LANG}.json"
|
||||
;;
|
||||
*)
|
||||
echo "$CACHE_DIR/${endpoint#/}.json"
|
||||
echo "$CACHE_DIR/tmdb/${endpoint#/}.json"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -144,24 +148,49 @@ cache_put_empty() {
|
||||
mv "$tmp" "$path" 2>/dev/null || return 1
|
||||
}
|
||||
|
||||
# 清空全部缓存(--refresh-cache)。
|
||||
# 清空 TMDB 缓存(--refresh-cache)。
|
||||
# 仅清除 tmdb/ 子目录(API 响应缓存,可再生);mo_config/ 配置与 media_organizer/
|
||||
# 运行状态不随 --refresh-cache 清除。
|
||||
# 干运行模式下跳过清理(零持久化副作用),并提示其不生效。
|
||||
cache_clear() {
|
||||
if [[ "${DRY_RUN:-false}" == "true" ]]; then
|
||||
_log 警告 "干运行模式下跳过缓存清理"
|
||||
return 0
|
||||
fi
|
||||
rm -rf "$CACHE_DIR" 2>/dev/null || true
|
||||
rm -rf "$CACHE_DIR/tmdb" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# 迁移旧版缓存布局(v9 前:mo_cache/{movie,search,tv} 平铺在根下)到 tmdb/ 子目录。
|
||||
# 仅当新布局不存在且旧布局存在时执行一次;纯数据搬运,不影响缓存语义。
|
||||
# 干运行模式下跳过(零持久化副作用)。
|
||||
migrate_tmdb_cache() {
|
||||
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||
[[ -n "${CACHE_DIR:-}" ]] || return 0
|
||||
[[ -d "$CACHE_DIR/tmdb" ]] && return 0
|
||||
local legacy=("$CACHE_DIR/movie" "$CACHE_DIR/search" "$CACHE_DIR/tv") d exists=false
|
||||
for d in "${legacy[@]}"; do
|
||||
[[ -d "$d" ]] && exists=true
|
||||
done
|
||||
[[ "$exists" == "true" ]] || return 0
|
||||
mkdir -p "$CACHE_DIR/tmdb" 2>/dev/null || return 0
|
||||
for d in "${legacy[@]}"; do
|
||||
[[ -d "$d" ]] || continue
|
||||
if mv "$d" "$CACHE_DIR/tmdb/" 2>/dev/null; then
|
||||
_log 信息 "已迁移旧版 TMDB 缓存: $d -> $CACHE_DIR/tmdb/"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# 列出缓存条目(--list-cache 一键反查)。可选 filter 过滤参数串。
|
||||
# 仅列出 tmdb/ 下的 API 响应缓存;mo_config/ 配置类文件与 media_organizer/ 运行状态不在此列。
|
||||
# 输出首行标题:哈希 / 类型 / 路径 / 参数 / 获取时间。
|
||||
cache_list() {
|
||||
local filter="$1"
|
||||
printf '哈希\t类型\t路径\t参数\t获取时间\n'
|
||||
local f path type params ts hash
|
||||
local f rel path type params ts hash
|
||||
while IFS= read -r -d '' f; do
|
||||
path="${f#"$CACHE_DIR"/}"
|
||||
rel="${f#"$CACHE_DIR"/}"
|
||||
path="${rel#tmdb/}" # 去 tmdb/ 前缀用于类型识别,展示保留完整相对路径
|
||||
hash=$(basename "$f" .json)
|
||||
case "$path" in
|
||||
search/*)
|
||||
@@ -173,19 +202,19 @@ cache_list() {
|
||||
tv/*/season/*)
|
||||
type="TV"
|
||||
params="season=$(basename "$f" .json)"
|
||||
params="season=${params%%.*}" # 去语言后缀(0.zh-CN → 0)
|
||||
params="${params%%.*}" # 去语言后缀(0.zh-CN → 0),保留 season= 前缀
|
||||
ts=$(date -r "$f" +%s 2>/dev/null || echo "?")
|
||||
;;
|
||||
tv/*)
|
||||
type="TV"
|
||||
params="id=$(basename "$f" .json)"
|
||||
params="id=${params%%.*}" # 去语言后缀
|
||||
params="${params%%.*}" # 去语言后缀,保留 id= 前缀
|
||||
ts=$(date -r "$f" +%s 2>/dev/null || echo "?")
|
||||
;;
|
||||
movie/*)
|
||||
type="Movie"
|
||||
params="id=$(basename "$f" .json)"
|
||||
params="id=${params%%.*}" # 去语言后缀
|
||||
params="${params%%.*}" # 去语言后缀,保留 id= 前缀
|
||||
ts=$(date -r "$f" +%s 2>/dev/null || echo "?")
|
||||
;;
|
||||
*)
|
||||
@@ -193,8 +222,8 @@ cache_list() {
|
||||
;;
|
||||
esac
|
||||
[[ -n "$filter" && "$params" != *"$filter"* ]] && continue
|
||||
printf '%s\t%s\t%s\t%s\t%s\n' "$hash" "$type" "$path" "$params" "$ts"
|
||||
done < <(find "$CACHE_DIR" -type f -name '*.json' -print0 2>/dev/null)
|
||||
printf '%s\t%s\t%s\t%s\t%s\n' "$hash" "$type" "$rel" "$params" "$ts"
|
||||
done < <(find "$CACHE_DIR/tmdb" -type f -name '*.json' -print0 2>/dev/null)
|
||||
}
|
||||
|
||||
# 基于源目录重新获取并更新缓存(--update-cache)。
|
||||
@@ -0,0 +1,252 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
################################################################################
|
||||
# 账本与冷却(增量标记 / 失败冷却 / 手动纠错账本)
|
||||
#
|
||||
# 两个运行期数据文件(位于 $CACHE_DIR/media_organizer/,与 TMDB 缓存分离):
|
||||
# linked.json 已链接账本:{ "<src>": {"dest","inode","linked_at"} }
|
||||
# —— 增量标记:重跑时源 inode 与目标均有效才跳过识别;
|
||||
# 目标被删 / 源被替换(inode 变化)→ 自动失效,重新识别+链接。
|
||||
# fail_cooldown.json 失败冷却:{ "<src>": {"retry_at","reason"} }
|
||||
# —— request_failed / skip_unidentified 登记冷却,
|
||||
# 冷却期内 cron 重跑直接跳过(避免每轮全量重试同一批失败项)。
|
||||
#
|
||||
# 进程模型:
|
||||
# 识别池 worker 是 fork 子进程——load 在父进程完成(fork 复制内存关联数组),
|
||||
# set/mark 只更新父进程内存,flush 在运行末尾统一落盘(原子写:临时文件+rename)。
|
||||
# 中途异常退出不落盘:链接阶段本身 inode 幂等、冷却丢失仅导致下次重试,均安全。
|
||||
#
|
||||
# 干运行零持久化副作用:flush 跳过写入(与 cache_put 同约定)。
|
||||
################################################################################
|
||||
|
||||
# 已链接账本:src -> dest(绝对路径)
|
||||
declare -A LEDGER_LINKED=()
|
||||
# 已链接账本:src -> inode(链接时的源 inode)
|
||||
declare -A LEDGER_INODE=()
|
||||
# 失败冷却:src -> retry_at(epoch 秒)
|
||||
declare -A COOLDOWN_MAP=()
|
||||
# 纠错学习:clean_name(源文件名) 小写 -> 用户修正的绝对目标路径(corrections.json)
|
||||
# 来源:--rerun 手动纠错成功链接后学习;消费:识别池入口前置命中(同命名文件直接归位)。
|
||||
declare -A CORRECTIONS_MAP=()
|
||||
|
||||
# 加载两个账本(父进程在扫描前调用一次;worker fork 时复制内存副本)。
|
||||
ledger_load() {
|
||||
LEDGER_LINKED=()
|
||||
LEDGER_INODE=()
|
||||
COOLDOWN_MAP=()
|
||||
CORRECTIONS_MAP=()
|
||||
local f="$CACHE_DIR/media_organizer/linked.json"
|
||||
if [[ -f "$f" ]]; then
|
||||
local src dest inode
|
||||
while IFS=$'\t' read -r src dest inode; do
|
||||
[[ -z "$src" || -z "$dest" ]] && continue
|
||||
LEDGER_LINKED["$src"]="$dest"
|
||||
[[ -n "$inode" ]] && LEDGER_INODE["$src"]="$inode"
|
||||
done < <(jq -r 'to_entries[] | "\(.key)\t\(.value.dest // "")\t\(.value.inode // "")"' "$f" 2>/dev/null)
|
||||
fi
|
||||
f="$CACHE_DIR/media_organizer/fail_cooldown.json"
|
||||
if [[ -f "$f" ]]; then
|
||||
local src2 retry
|
||||
while IFS=$'\t' read -r src2 retry; do
|
||||
[[ -z "$src2" || -z "$retry" ]] && continue
|
||||
COOLDOWN_MAP["$src2"]="$retry"
|
||||
done < <(jq -r 'to_entries[] | "\(.key)\t\(.value.retry_at // 0)"' "$f" 2>/dev/null)
|
||||
fi
|
||||
# 纠错学习(corrections.json:clean_name 小写 → 绝对目标路径)
|
||||
f="$CACHE_DIR/media_organizer/corrections.json"
|
||||
if [[ -f "$f" ]]; then
|
||||
local ck cd
|
||||
while IFS=$'\t' read -r ck cd; do
|
||||
[[ -z "$ck" || -z "$cd" ]] && continue
|
||||
CORRECTIONS_MAP["$ck"]="$cd"
|
||||
done < <(jq -r 'to_entries[] | "\(.key)\t\(.value.dest // "")"' "$f" 2>/dev/null)
|
||||
fi
|
||||
_log 调试 "已链接账本 ${#LEDGER_LINKED[@]} 条,冷却账本 ${#COOLDOWN_MAP[@]} 条,纠错学习 ${#CORRECTIONS_MAP[@]} 条"
|
||||
}
|
||||
|
||||
# 学习纠错映射:--rerun 手动修正成功链接后调用(用户显式确认的目标即权威)。
|
||||
# key = clean_name(源文件名) 小写;值 = 绝对目标路径。立即原子写 corrections.json
|
||||
# (rerun 是独立进程,学习必须当场落盘;dry-run 不写——零持久化副作用)。
|
||||
correction_learn() {
|
||||
local src="$1" dest="$2"
|
||||
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||
[[ -n "$CACHE_DIR" ]] || return 0
|
||||
local key
|
||||
# key = 去扩展名 + clean_name(与消费端 process_one_file 的算法一致)
|
||||
key="${src##*/}"
|
||||
key="${key%.*}"
|
||||
key=$(clean_name "$key")
|
||||
key="${key,,}"
|
||||
[[ -n "$key" ]] || return 0
|
||||
CORRECTIONS_MAP["$key"]="$dest"
|
||||
local dir="$CACHE_DIR/media_organizer"
|
||||
mkdir -p "$dir" 2>/dev/null || return 0
|
||||
local file="$dir/corrections.json"
|
||||
local tmp="${file}.tmp.$$"
|
||||
local now
|
||||
now=$(date +%s)
|
||||
if [[ -f "$file" ]]; then
|
||||
jq --arg k "$key" --arg d "$dest" --argjson t "$now" \
|
||||
'.[$k] = {dest: $d, learned_at: $t}' "$file" >"$tmp" 2>/dev/null
|
||||
else
|
||||
jq -n --arg k "$key" --arg d "$dest" --argjson t "$now" \
|
||||
'{($k): {dest: $d, learned_at: $t}}' >"$tmp" 2>/dev/null
|
||||
fi
|
||||
if [[ -s "$tmp" ]]; then
|
||||
mv "$tmp" "$file" 2>/dev/null || rm -f "$tmp"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
_log 信息 "已学习纠错映射: $key -> $dest"
|
||||
}
|
||||
|
||||
# 已链接判定(增量跳过):源存在且与目标同 inode。
|
||||
# 目标被删(用户清库)→ 返回失败,重新识别+链接;源被替换(inode 变化)→ 同样失效。
|
||||
# 返回值:0=已链接(可跳过);1=未链接/失效。
|
||||
ledger_is_linked() {
|
||||
local src="$1"
|
||||
local dest="${LEDGER_LINKED[$src]:-}"
|
||||
[[ -n "$dest" ]] || return 1
|
||||
[[ -f "$src" ]] || return 1
|
||||
same_inode "$src" "$dest"
|
||||
}
|
||||
|
||||
# 标记已链接(内存;flush 统一落盘)。调用时机:硬链接成功后(link_media 父进程)。
|
||||
ledger_mark_linked() {
|
||||
local src="$1" dest="$2"
|
||||
LEDGER_LINKED["$src"]="$dest"
|
||||
local inode
|
||||
inode=$(get_file_inode "$src")
|
||||
[[ -n "$inode" ]] && LEDGER_INODE["$src"]="$inode"
|
||||
}
|
||||
|
||||
# 登记失败冷却(内存;flush 落盘)。retry_at = now + FAIL_RETRY_COOLDOWN_HOURS。
|
||||
# 仅在 request_failed / skip_unidentified 登记(尽力后失败才冷却;类型跳过无成本不冷却)。
|
||||
cooldown_set() {
|
||||
local src="$1" _reason="$2"
|
||||
local hours="${FAIL_RETRY_COOLDOWN_HOURS:-24}"
|
||||
[[ "$hours" =~ ^[0-9]+$ ]] || hours=24
|
||||
((hours > 0)) || return 0
|
||||
local now
|
||||
now=$(date +%s)
|
||||
COOLDOWN_MAP["$src"]=$((now + hours * 3600))
|
||||
}
|
||||
|
||||
# 冷却判定:记录存在且未到期,且源文件仍在(源消失 → 不冷却,允许重试)。
|
||||
# 返回值:0=冷却中(跳过);1=可处理。
|
||||
cooldown_active() {
|
||||
local src="$1"
|
||||
local retry="${COOLDOWN_MAP[$src]:-}"
|
||||
[[ -n "$retry" ]] || return 1
|
||||
[[ -f "$src" ]] || return 1
|
||||
local now
|
||||
now=$(date +%s)
|
||||
((now < retry))
|
||||
}
|
||||
|
||||
# 统一落盘(运行末尾、汇总前调用):原子写 + 惰性清理(源已消失/已过期条目不写回)。
|
||||
# 干运行跳过(零持久化副作用)。
|
||||
ledger_flush() {
|
||||
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||
[[ -n "${CACHE_DIR:-}" ]] || return 0
|
||||
local dir="$CACHE_DIR/media_organizer"
|
||||
mkdir -p "$dir" 2>/dev/null || return 0
|
||||
local now
|
||||
now=$(date +%s)
|
||||
|
||||
# linked.json:仅保留源仍存在的条目(jq -s 'add' 合并为单一对象,原子写)
|
||||
local -a rows=()
|
||||
local src dest
|
||||
for src in "${!LEDGER_LINKED[@]}"; do
|
||||
[[ -f "$src" ]] || continue
|
||||
dest="${LEDGER_LINKED[$src]}"
|
||||
rows+=("$(jq -nc --arg s "$src" --arg d "$dest" --arg i "${LEDGER_INODE[$src]:-}" \
|
||||
--argjson t "$now" '{($s): {dest: $d, inode: $i, linked_at: $t}}')")
|
||||
done
|
||||
local tmp="${dir}/linked.json.tmp.$$"
|
||||
if printf '%s\n' "${rows[@]}" | jq -s 'if length == 0 then {} else add end' >"$tmp" 2>/dev/null; then
|
||||
mv "$tmp" "${dir}/linked.json" 2>/dev/null || rm -f "$tmp"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
|
||||
# fail_cooldown.json:清理已过期与源已消失的条目
|
||||
rows=()
|
||||
local retry
|
||||
for src in "${!COOLDOWN_MAP[@]}"; do
|
||||
[[ -f "$src" ]] || continue
|
||||
retry="${COOLDOWN_MAP[$src]}"
|
||||
((retry > now)) || continue
|
||||
rows+=("$(jq -nc --arg s "$src" --argjson r "$retry" '{($s): {retry_at: $r}}')")
|
||||
done
|
||||
tmp="${dir}/fail_cooldown.json.tmp.$$"
|
||||
if printf '%s\n' "${rows[@]}" | jq -s 'if length == 0 then {} else add end' >"$tmp" 2>/dev/null; then
|
||||
mv "$tmp" "${dir}/fail_cooldown.json" 2>/dev/null || rm -f "$tmp"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# 手动纠错账本(--export-map 伴生 .ledger.json + --rerun 消费)
|
||||
#
|
||||
# 导出:export_map_md 在生成对照表 md 的同时,同目录生成同名 .ledger.json:
|
||||
# [{"src": "<源绝对路径>", "dest": "<目标绝对路径>", "outcome": "...",
|
||||
# "status": "linked"|"pending", "action": ""}, ...]
|
||||
# 用户编辑:改 dest(绝对路径)为期望目标;把 action 置 "rerun" 标记重跑条目。
|
||||
# 重跑:--rerun <文件> 只执行 action=="rerun" 的条目——不重新识别,
|
||||
# 直接 mkdir + 硬链接(目标已存在且非同一 inode → 替换,即"手动纠错替换"语义)。
|
||||
# --rerun 不做冷却检查(用户显式重跑不受冷却限制)。
|
||||
################################################################################
|
||||
|
||||
# --rerun 主流程:读取账本 → 执行标记条目 → 汇总。
|
||||
# 参数:账本文件路径。返回退出码(0=全部成功;3=部分失败)。
|
||||
run_rerun() {
|
||||
local ledger_file="$1"
|
||||
if [[ ! -f "$ledger_file" ]]; then
|
||||
_log 错误 "账本文件不存在: $ledger_file"
|
||||
exit 1
|
||||
fi
|
||||
if ! jq -e 'type == "array"' "$ledger_file" >/dev/null 2>&1; then
|
||||
_log 错误 "账本文件必须为 JSON 数组: $ledger_file"
|
||||
exit 1
|
||||
fi
|
||||
_log 信息 "=== 重跑账本: $ledger_file ==="
|
||||
local ok=0 skipped=0 missing=0
|
||||
local src dest action _status
|
||||
while IFS=$'\t' read -r src dest action _status; do
|
||||
[[ -z "$src" || -z "$dest" ]] && continue
|
||||
if [[ "$action" != "rerun" ]]; then
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
if [[ ! -f "$src" ]]; then
|
||||
_log 警告 "源文件不存在,跳过: $src"
|
||||
missing=$((missing + 1))
|
||||
continue
|
||||
fi
|
||||
_log 信息 "重跑: $(basename "$src") -> $dest"
|
||||
push_indent
|
||||
if ! mkdir -p "$(dirname "$dest")"; then
|
||||
_log 错误 "无法创建目的目录: $(dirname "$dest")"
|
||||
missing=$((missing + 1))
|
||||
pop_indent
|
||||
continue
|
||||
fi
|
||||
if hardlink_or_dryrun "$src" "$dest"; then
|
||||
# 学习纠错映射(用户显式修正 → 下次识别前置命中;干运行不写)。
|
||||
# 不在此处记账本:rerun 模式未加载冷却账本,ledger_flush 会把冷却文件
|
||||
# 覆盖为 {}(数据丢失);链接本身 inode 幂等,下次 organize 的 link_media 会补记。
|
||||
correction_learn "$src" "$dest"
|
||||
ok=$((ok + 1))
|
||||
else
|
||||
missing=$((missing + 1))
|
||||
fi
|
||||
pop_indent
|
||||
done < <(jq -r '.[] | "\(.src // "")\t\(.dest // "")\t\(.action // "")\t\(.status // "")"' "$ledger_file" 2>/dev/null)
|
||||
_log 成功 "重跑完成:成功 $ok,跳过(未标记 rerun)$skipped,失败/缺失 $missing"
|
||||
if ((missing > 0)); then
|
||||
return 3
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
+27
-4
@@ -1,3 +1,6 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034 # 跨文件共享常量(JQ_NORM_LITE,identify.sh 消费)
|
||||
|
||||
################################################################################
|
||||
# 字符串工具
|
||||
################################################################################
|
||||
@@ -53,10 +56,31 @@ safe_printf_int() {
|
||||
fi
|
||||
}
|
||||
|
||||
# 归一化为绝对路径(目录可不存在;空串原样返回)。
|
||||
# 用途:SOURCE_DIR/DESTINATION_DIR 归一化——纠错账本存绝对路径、目录比较
|
||||
# (find_show_path_from_file 等)都依赖一致的绝对路径语义。
|
||||
normalize_abs() {
|
||||
local p="$1"
|
||||
[[ -z "$p" ]] && {
|
||||
echo ""
|
||||
return 0
|
||||
}
|
||||
[[ "$p" == /* ]] && {
|
||||
echo "$p"
|
||||
return 0
|
||||
}
|
||||
local base dir
|
||||
base=$(basename "$p")
|
||||
dir=$(dirname "$p")
|
||||
dir=$(cd "$dir" 2>/dev/null && pwd || echo "$dir")
|
||||
echo "${dir%/}/${base}"
|
||||
}
|
||||
|
||||
# 将特典片段(文件中的关键字符串,如 "Menu01")翻译为 TMDB 字符串(查 keymap,键已归一化为小写)。
|
||||
# 先精确匹配片段(转小写),再匹配去数字后的核心(如 Menu01 -> menu);无映射时返回原片段。
|
||||
# v9.6:查表走类目分区(默认 tv 特典语境):类目分区 → default 分区 → 全局表。
|
||||
translate_fragment() {
|
||||
local fragment="$1"
|
||||
local fragment="$1" cat="${2:-tv}"
|
||||
# 查表键规范化(仅影响查找,不改存储/输出):
|
||||
# 去空白与连字符("Menu 01"/"PV-01" → "menu01"/"pv01")+ ASCII 全角转半角
|
||||
# (sed y/ 按字符翻译;tr 对多字节 UTF-8 失效)
|
||||
@@ -66,10 +90,10 @@ translate_fragment() {
|
||||
-e 's/[[:space:]-]//g')
|
||||
norm="${norm,,}"
|
||||
local val core
|
||||
val="${SPECIAL_MAP[$norm]:-}"
|
||||
val=$(special_map_value "$cat" "$norm")
|
||||
if [[ -z "$val" ]]; then
|
||||
core=$(printf '%s' "$norm" | sed -E 's/[0-9]+$//')
|
||||
val="${SPECIAL_MAP[$core]:-}"
|
||||
val=$(special_map_value "$cat" "$core")
|
||||
fi
|
||||
if [[ -n "$val" ]]; then
|
||||
# 值为 JSON 数组(多语言),取第一个作为翻译结果
|
||||
@@ -78,4 +102,3 @@ translate_fragment() {
|
||||
fi
|
||||
printf '%s' "$fragment"
|
||||
}
|
||||
|
||||
-738
@@ -1,738 +0,0 @@
|
||||
|
||||
################################################################################
|
||||
# Jellyfin 媒体库硬链接整理脚本
|
||||
#
|
||||
# 版本:9.3(入口层/流水线执行/识别匹配/缓存/AI/特典映射六轮需求对齐)
|
||||
# 作者:LetsShareAll
|
||||
# 许可:MIT
|
||||
#
|
||||
# 说明:
|
||||
# 自动化整理媒体库,通过 TMDB API 识别电影和电视剧,并使用硬链接创建
|
||||
# 标准化的目录结构。支持 AI 辅助识别、全量缓存、特典映射等功能。
|
||||
#
|
||||
# 功能特性:
|
||||
# - 自动识别和整理电影、电视剧
|
||||
# - TMDB API 集成,支持 Bearer token 和 API Key 认证
|
||||
# - AI 辅助识别(支持 OpenAI API 和兼容接口)
|
||||
# - 三阶段处理流程(识别、AI 纠正、硬链接)
|
||||
# - 全量持久化缓存(镜像 API 路径,一切请求数据皆缓存)
|
||||
# - 特典/番外篇自动识别(TMDB 匹配用 S00E,未匹配用 S00{类型})
|
||||
# - 跨平台支持(Linux/macOS/BusyBox)
|
||||
#
|
||||
# 最低要求:
|
||||
# - Bash 4.0+
|
||||
# - curl、jq、ffprobe
|
||||
# - 源目录和目的目录在同一文件系统
|
||||
#
|
||||
# 用法:
|
||||
# ./media_organizer.sh [选项] <源目录> <目的目录>
|
||||
#
|
||||
# 选项:
|
||||
# -a, --automated 自动化模式(写入日志,跳过无法处理项目)
|
||||
# --dry-run 干运行模式(仅打印操作,不创建链接)
|
||||
# --refresh-cache 清空全部持久化缓存
|
||||
# --update-cache 基于源目录重新获取并更新缓存
|
||||
# --list-cache [关键词] 列出缓存条目(可选关键词过滤)
|
||||
# -h, --help 显示帮助信息
|
||||
#
|
||||
# 示例:
|
||||
# ./media_organizer.sh /downloads /media
|
||||
# ./media_organizer.sh -a /downloads /media
|
||||
# ./media_organizer.sh --dry-run /downloads /media
|
||||
# ./media_organizer.sh --list-cache 刀剑
|
||||
#
|
||||
# 配置:
|
||||
# - 参考 mo_env.example 创建 mo_env 文件
|
||||
# - 查看 CONFIG.md 了解所有配置项
|
||||
# - 查看 TROUBLESHOOTING.md 获取问题解决方案
|
||||
#
|
||||
# 缓存方案(v9):
|
||||
# 缓存目录镜像 TMDB API 路径,一切请求数据全量落盘:
|
||||
# search/movie/<hash>.json /search/movie 搜索结果(键=query&year&lang)
|
||||
# search/tv/<hash>.json /search/tv 搜索结果
|
||||
# tv/<id>.<lang>.json /tv/{id} 剧集详情(原始响应;语言段随 TMDB_LANG)
|
||||
# tv/<id>/season/<n>.<lang>.json /tv/{id}/season/{n} 季数据(含 n=0 特典季)
|
||||
# movie/<id>.<lang>.json /movie/{id} 电影详情(原始响应)
|
||||
# 搜索缓存为包裹格式(query/year/lang/fetched_at/empty/data),其余直接存原始 JSON。
|
||||
# TTL:CACHE_TTL_DAYS(默认 30,正常数据)/ CACHE_EMPTY_TTL_DAYS(默认 3,查无此片哨兵)。
|
||||
# 并发:tmdb_api 对同一缓存路径用 flock 互斥 + 锁内双检(识别池多 worker 避免重复请求)。
|
||||
# 搜索空结果写 empty 哨兵(3 天短 TTL),新片出现后自动重新搜索。
|
||||
#
|
||||
# 单文件分发说明:
|
||||
# 本脚本为单文件分发,已内置以下模板与默认值,无需携带任何配套文件:
|
||||
# - mo_env 完整配置模板(未提供 API 密钥时自动生成)
|
||||
# - mo_special_keymap.json 特典关键词映射(缺失时在用户确认下用常量模板创建)
|
||||
# - season_offset.json 常见季偏移默认值(内置,外部 JSON 文件可覆盖)
|
||||
# 所有配置均可通过环境变量或自动生成的配置文件调整。
|
||||
#
|
||||
# 代码结构:
|
||||
# 1. 常量与全局变量声明
|
||||
# 2. 日志与色彩函数(最先加载,保证全脚本日志输出一致)
|
||||
# 3. 配置加载与认证
|
||||
# 4. 特典映射与季偏移
|
||||
# 5. 字符串工具
|
||||
# 6. 缓存管理(cache_* 系列)
|
||||
# 7. TMDB API(tmdb_api 统一缓存封装)
|
||||
# 8. 媒体文件名解析
|
||||
# 9. 媒体识别(电影/电视剧)
|
||||
# 10. AI 批处理
|
||||
# 11. 硬链接处理
|
||||
# 12. 错误处理
|
||||
# 13. main()(唯一入口,文件末尾调用)
|
||||
#
|
||||
################################################################################
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
################################################################################
|
||||
# 常量
|
||||
################################################################################
|
||||
|
||||
readonly SCRIPT_VERSION="9.3"
|
||||
readonly TMDB_API_BASE_URL="https://api.themoviedb.org/3"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# AI 提示词常量
|
||||
#
|
||||
# 作用:
|
||||
# AI 批量请求(ai_batch_request)使用的提示词模板。统一置于文件顶部常量区,
|
||||
# 便于维护与测试。运行时会在提示词后追加 "Input: <JSON>" 输入数据。
|
||||
#
|
||||
# 预期返回格式(ai_batch_request 用 jq 解析 .search 与 .special 两个部分):
|
||||
# {
|
||||
# "search": { "<完整 file 路径>": { "search_term": "清洗后的标题", "year": "年份或空串" }, ... },
|
||||
# "special": { "<show_id>|<fragment>": { "english_keyword": "英文标准键", "chinese_keyword": "中文别称" }, ... }
|
||||
# }
|
||||
# - 某类无条目时,对应键为 {}。
|
||||
# - search 的键 = 输入 search_entries 中每项的 "file" 完整路径(逐字节复制)。
|
||||
# - special 的键 = 输入 special_entries 中每项的 "<show_id>|<fragment>"(fragment 保留原数字)。
|
||||
# - 缺失字段用空串 ""。
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# AI 系统消息:强制模型仅输出 JSON,拒绝 Markdown 围栏/注释/尾逗号;
|
||||
# 要求输出键逐字节复制输入键(否则 jq 查不到导致整批静默丢弃)。
|
||||
readonly AI_SYSTEM_MESSAGE='You are a strict JSON-only assistant. Respond with exactly one valid JSON object and nothing else. Do NOT wrap it in Markdown code fences (no ```), do NOT add explanations, comments, or trailing commas. All strings must use double quotes. If a field is unknown or absent, use an empty string "". Every input entry MUST appear as an exact key in the output, with the key copied byte-for-byte from the input.'
|
||||
|
||||
# 批量 AI 提示词(合并搜索纠正 + 特典映射为一次请求)。
|
||||
# 输入含 search_entries(file/filename/directory/type)与 special_entries(show_id/fragment/series)。
|
||||
# 返回 {"search": {...}, "special": {...}},键规则见上方注释。
|
||||
readonly AI_BATCH_PROMPT='You are a media file organizer. You receive a JSON input with four arrays:
|
||||
- "search_entries": media files that failed TMDB search. Each has "file" (full path), "directory" (path chain relative to source root, max 3 levels), "type" ("tv" or "movie").
|
||||
- "special_entries": unidentifiable special features. Each has "show_id", "fragment" (the local key string from the filename, e.g. "Menu01", "CM01", "Preview02"), "series" (show title), and "season0" (that show'"'"'s TMDB special-season episode list in "episode_number:name; ..." format).
|
||||
- "artist_entries": audio files with multiple artists. Each has "file" (full path), "artists" (pipe-separated artist list, e.g. "Artist1|Artist2"), "album" (album name or "").
|
||||
- "match_entries": files whose TMDB search returned results but need disambiguation (year mismatch, season count suspicious, or suffix-season like "Railgun T"). Each has "file", "type", "year" (parsed year or ""), "season" (file season or ""), "search" (the raw TMDB search response JSON), "seasons" (seasons array of the first candidate: season_number/name/episode_count/air_date; may be [] if unavailable).
|
||||
|
||||
Return ONE top-level JSON object with exactly four keys:
|
||||
- "search": an object mapping each entry'"'"'s exact "file" string (copied byte-for-byte; keep full path, brackets, spaces, ampersands) to {"search_term": "...", "year": "..."}. search_term = clean title for TMDB search: strip only media/release tags (S01E30, S1, E12, [Menu01], [1080p], leading [Group]); if type is "tv" remove episode/season numbers BUT keep ordinal words like "2nd Season"; year = 4-digit year if identifiable, else "". If the input "type" is "unknown", also infer and return "media_type": "movie" or "tv" based on the filename/directory (e.g. contains season/episode markers -> "tv", ends with a year or no episode markers -> "movie").
|
||||
- "special": an object mapping each entry'"'"'s local "fragment" string (exact, keep digits, e.g. "Menu01", "Preview02", "CM01") to an ARRAY of MATCH KEYWORDS — entries from that entry'"'"'s "season0" candidate list that match it, in any language (e.g. "Menu01" -> ["Menu", "菜单"], "Preview02" -> ["Preview", "预告片"], "CM01" -> ["CM", "广告"]). Use the exact strings as written in season0; if no match, use [].
|
||||
- "artist_choice": an object mapping each artist entry'"'"'s exact "file" string to a SINGLE artist string — pick the one most likely to be the album artist (the primary artist credited for the whole album). If truly undecidable, return "".
|
||||
- "match": an object mapping each match entry'"'"'s exact "file" string to {"choice": <id> | "", "season_shift": <int> | "", "search_term": "..."}. choice = the result id from "search" that matches this file; if the correct show is a season-with-suffix of a candidate (e.g. file "Toaru Kagaku no Railgun T S01E01" matches candidate 某科学的超电磁炮 whose season 3 is named 某科学的超电磁炮T), pick the candidate id AND set season_shift so that file season + season_shift = the correct TMDB season number (e.g. file S01 -> season_shift 2 -> S03). If the file is a movie, season_shift must be "". If no candidate matches but a corrected search would help, set choice "" and search_term to the corrected title. If truly no match and no correction, return {"choice": "", "season_shift": "", "search_term": ""}.
|
||||
|
||||
If a type has no entries, use {} for that key. Process EVERY entry. Missing fields become "". Output ONLY the JSON object.'
|
||||
|
||||
################################################################################
|
||||
# 配置文件模板
|
||||
#
|
||||
# 以下常量是脚本生成缺失配置文件时使用的默认模板:
|
||||
# MO_ENV_TEMPLATE -> mo_env 主配置文件
|
||||
# SPECIAL_KEYMAP_TEMPLATE -> mo_special_keymap.json 特典映射
|
||||
# SEASON_OFFSET_TEMPLATE -> season_offset.json 季数偏移
|
||||
#
|
||||
# 说明:
|
||||
# - 这些常量仅在对应配置文件缺失时,由脚本(在用户确认下)生成默认文件。
|
||||
# - 生成后可自行编辑文件,脚本以文件内容为准,常量不影响运行时的配置。
|
||||
################################################################################
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# MO_ENV_TEMPLATE —— 主配置文件模板(mo_env)
|
||||
#
|
||||
# 作用:
|
||||
# 生成脚本的主配置文件 mo_env,包含全部配置项(TMDB 认证、网络、
|
||||
# 文件类型、缓存映射、输出调试、AI 辅助、自动化日志、高级选项)。
|
||||
#
|
||||
# 用法:
|
||||
# 1. 复制为 mo_env:cp mo_env.example mo_env(或运行脚本交互生成)
|
||||
# 2. 设置权限:chmod 600 mo_env
|
||||
# 3. 编辑并填写必要的配置项(至少填写 TMDB_API_RA_TOKEN)
|
||||
# 4. 测试:./media_organizer.sh --dry-run /downloads /media
|
||||
#
|
||||
# 配置方法:
|
||||
# 直接编辑此文件,键值格式 KEY=VALUE。完整说明见模板内注释。
|
||||
# ----------------------------------------------------------------------------
|
||||
# 该字符串没有表达式需要展开,忽略 ShellCheck 警告 SC2016。
|
||||
# shellcheck disable=SC2016
|
||||
readonly MO_ENV_TEMPLATE='################################################################################
|
||||
# 配置文件示例 (mo_env)
|
||||
#
|
||||
# 说明:
|
||||
# 1. 复制此文件为 mo_env 并填写你的 API 密钥
|
||||
# 2. 设置文件权限为 600:chmod 600 mo_env
|
||||
# 3. 不要将此文件提交到版本控制系统
|
||||
#
|
||||
# 配置优先级:环境变量 > mo_env 文件 > 脚本默认值
|
||||
#
|
||||
################################################################################
|
||||
|
||||
# ====== 第一步:TMDB API 认证(必填)======
|
||||
#
|
||||
# 必须至少填写以下之一:
|
||||
# 1. TMDB_API_RA_TOKEN(推荐)- Read Access Token
|
||||
# 2. TMDB_API_KEY(可选)- v3 API Key
|
||||
#
|
||||
# 获取位置:https://www.themoviedb.org/settings/api
|
||||
#
|
||||
|
||||
# TMDB v3 API Key(较旧的认证方式,如已有 RA_TOKEN 可留空)
|
||||
# 默认值:(空,必须手动填写)
|
||||
TMDB_API_KEY=
|
||||
|
||||
# TMDB Read Access Token(推荐使用,优先级更高)
|
||||
# 默认值:(空,必须手动填写)
|
||||
TMDB_API_RA_TOKEN=
|
||||
|
||||
# ====== 第二步:基础 TMDB 配置(可选,通常使用默认值)======
|
||||
|
||||
# TMDB API 查询语言
|
||||
# 常见值:zh-CN (中文简体), zh-TW (中文繁体), en-US (英文), ja-JP (日语)
|
||||
# 默认值:zh-CN
|
||||
TMDB_LANG=zh-CN
|
||||
|
||||
# 两次 TMDB 请求间的延迟(秒)
|
||||
# 避免 API 限流,TMDB 限制约 4 请求/秒
|
||||
# 默认值:1
|
||||
TMDB_DELAY=1
|
||||
|
||||
# TMDB 网络配置
|
||||
# TMDB_CURL_RETRY:curl 重试次数,默认值:3
|
||||
# TMDB_CURL_CONNECT_TIMEOUT:连接超时(秒),默认值:10
|
||||
# TMDB_CURL_MAX_TIME:请求最大时长(秒),默认值:30
|
||||
TMDB_CURL_RETRY=3
|
||||
TMDB_CURL_CONNECT_TIMEOUT=10
|
||||
TMDB_CURL_MAX_TIME=30
|
||||
|
||||
# ====== 第三步:文件类型配置(可选,通常使用默认值)======
|
||||
|
||||
# 需要处理的视频文件扩展名(逗号分隔,不含点号)
|
||||
# 默认值:mp4,mkv,avi,mov,wmv,flv,m4v,ts,m2ts,webm
|
||||
VIDEO_EXTS=mp4,mkv,avi,mov,wmv,flv,m4v,ts,m2ts,webm
|
||||
|
||||
# 需要处理的音频文件扩展名(含官方音频容器 mka)
|
||||
# 注意:纯音频 mkv/mp4/webm 请改容器为 mka/m4a(Jellyfin 规范)
|
||||
# 默认值:mp3,flac,aac,ogg,wma,m4a,mka,ac3,dts,wav,opus
|
||||
AUDIO_EXTS=mp3,flac,aac,ogg,wma,m4a,mka,ac3,dts,wav,opus
|
||||
|
||||
# 字幕文件扩展名(作为配套文件跟随视频)
|
||||
# 默认值:srt,ass,ssa,sub,idx,vtt,sup
|
||||
SUB_EXTS=srt,ass,ssa,sub,idx,vtt,sup
|
||||
|
||||
# 字幕组提取的压制组黑名单(发布组括号 & 拆分后过滤;字幕组保留为 stream title 标记)
|
||||
# 默认值:VCB-Studio
|
||||
# SUB_GROUP_BLACKLIST=VCB-Studio
|
||||
|
||||
# ====== 第四步:缓存与映射配置(可选)======
|
||||
|
||||
# 缓存目录路径
|
||||
# 默认值:$PWD/mo_cache 或 $SCRIPT_DIR/mo_cache(优先级:环境变量 > 执行目录 > 脚本目录)
|
||||
# 留空使用默认值(推荐)
|
||||
CACHE_DIR=
|
||||
|
||||
# 特典/番外篇关键词映射文件路径
|
||||
# 格式(仅支持 JSON):{"Jellyfin/TMDB标准名": ["别称1", "别称2", ...]}
|
||||
# 键为 Jellyfin/TMDB 能够识别的标准字符串,值为其它语言的别称(可配多个)
|
||||
# 默认值:$PWD/mo_config/mo_special_keymap.json 或 $SCRIPT_DIR/mo_config/mo_special_keymap.json
|
||||
# 留空使用默认值(推荐)
|
||||
SPECIAL_MAP_FILE=
|
||||
|
||||
# 季数偏移配置文件路径(仅支持 JSON 对象格式)
|
||||
# 用于处理 TMDB 季数与实际不符的情况
|
||||
# 格式:{"剧名": 偏移值, "id:TMDB_ID": 偏移值}
|
||||
# 默认值:$PWD/mo_config/season_offset.json 或 $SCRIPT_DIR/mo_config/season_offset.json
|
||||
# 留空使用默认值(推荐)
|
||||
SEASON_OFFSET_FILE=
|
||||
|
||||
# 特典识别词表文件路径(仅支持 JSON 数组格式)
|
||||
# 语义 = 特典类别词(Menu/CM/PV 等),文件名方括号标记含这些词 → 判定为特典
|
||||
# 匹配对空格不敏感("ncop" 可匹配 "nc op");内容不当作正则
|
||||
# 默认值:$PWD/mo_config/mo_special_words.json 或 $SCRIPT_DIR/mo_config/mo_special_words.json
|
||||
# 留空使用默认值(推荐)
|
||||
SPECIAL_WORDS_FILE=
|
||||
|
||||
# 跳过目录词表文件路径(仅支持 JSON 数组格式)
|
||||
# 语义 = 不作为媒体名目录的目录词(特典/附带目录 + 分类目录,如 SPs/CDs/特典/视频/剧集/动画/电影)
|
||||
# 匹配为目录名精确比较(忽略大小写);内容不当作正则
|
||||
# 默认值:$PWD/mo_config/mo_skip_dirs.json 或 $SCRIPT_DIR/mo_config/mo_skip_dirs.json
|
||||
# 留空使用默认值(推荐)
|
||||
SKIP_DIRS_FILE=
|
||||
|
||||
# ====== 第五步:输出与调试(可选)======
|
||||
|
||||
# 彩色输出开关
|
||||
# 值:true (启用) / false (禁用)
|
||||
# 默认值:true
|
||||
COLOR_OUTPUT=true
|
||||
|
||||
# 调试级别
|
||||
# 值:0 (禁用) / 1 (基础) / 2 (详细)
|
||||
# 默认值:0
|
||||
DEBUG_LEVEL=0
|
||||
|
||||
# ====== 第六步:AI 辅助识别配置(可选)======
|
||||
#
|
||||
# 配置此部分可使用 AI 辅助识别无法匹配的文件
|
||||
# 支持 OpenAI API 和 Ollama 等兼容 API
|
||||
#
|
||||
# 如果不需要 AI 功能,将 AI_API_KEY 留空即可
|
||||
#
|
||||
|
||||
# AI API 密钥
|
||||
# OpenAI 获取:https://platform.openai.com/api-keys
|
||||
# 默认值:(空,留空则禁用 AI 功能)
|
||||
AI_API_KEY=
|
||||
|
||||
# AI API 基础 URL(OpenAI 兼容端点)
|
||||
# 常见值:
|
||||
# - https://api.deepseek.com (DeepSeek 官方,默认)
|
||||
# - https://api.openai.com (OpenAI 官方)
|
||||
# - http://localhost:11434 (本地 Ollama)
|
||||
# 默认值:https://api.deepseek.com
|
||||
AI_BASE_URL=https://api.deepseek.com
|
||||
|
||||
# AI API 完整端点 URL(高级选项,留空使用默认)
|
||||
# 默认值:{AI_BASE_URL}/v1/chat/completions
|
||||
AI_FULL_URL=
|
||||
|
||||
# AI 模型名称
|
||||
# 默认值:DeepSeek-V4-Flash
|
||||
AI_MODEL=DeepSeek-V4-Flash
|
||||
|
||||
# 单次运行最多 AI 批次调用次数(成本控制;每批 AI_BATCH_SIZE 条)
|
||||
# 范围:1-100
|
||||
# 默认值:10
|
||||
AI_MAX_CALLS=10
|
||||
|
||||
# AI 每批处理条目数(搜索/特典/艺术家/匹配四类各自的上限)
|
||||
# 默认值:50
|
||||
AI_BATCH_SIZE=50
|
||||
|
||||
# AI 干运行模式(测试 AI 功能而不产生费用)
|
||||
# 默认值:false
|
||||
AI_DRY_RUN=false
|
||||
|
||||
# AI 网络配置
|
||||
# AI_CURL_RETRY:curl 重试次数,默认值:3
|
||||
# AI_CURL_CONNECT_TIMEOUT:连接超时(秒),默认值:10
|
||||
# AI_CURL_MAX_TIME:请求最大时长(秒),默认值:30
|
||||
AI_CURL_RETRY=3
|
||||
AI_CURL_CONNECT_TIMEOUT=10
|
||||
AI_CURL_MAX_TIME=30
|
||||
|
||||
# ====== 第七步:自动化模式日志配置(可选)======
|
||||
#
|
||||
# 脚本运行在 --automated 模式下时写入日志
|
||||
#
|
||||
|
||||
# 普通日志文件路径
|
||||
# 默认值:/var/log/media_organizer.log
|
||||
LOG_FILE=/var/log/media_organizer.log
|
||||
|
||||
# 跳过记录日志文件路径(无法处理的文件)
|
||||
# 默认值:/var/log/media_organizer_skip.log
|
||||
SKIP_LOG_FILE=/var/log/media_organizer_skip.log
|
||||
|
||||
# 日志轮转阈值(MB;0=禁用)
|
||||
# 超阈值时滚动保留 5 份(.1 最新)
|
||||
# 默认值:10
|
||||
LOG_ROTATE_MB=10
|
||||
|
||||
# ====== 第八步:高级选项(通常不需要修改)======
|
||||
|
||||
# 跳过硬链接检查(不建议启用)
|
||||
# 仅在确定源和目标在同一文件系统时使用
|
||||
# 默认值:false
|
||||
SKIP_HARDLINK_CHECK=false
|
||||
|
||||
# ====== 第九步:缓存配置(可选)======
|
||||
|
||||
# 正常缓存有效天数(搜索/详情/季数据)
|
||||
# 超过后自动重新请求
|
||||
# 默认值:30
|
||||
CACHE_TTL_DAYS=30
|
||||
|
||||
# 无结果缓存有效天数(404/空结果的哨兵缓存)
|
||||
# 较短,便于发现新增内容
|
||||
# 默认值:3
|
||||
CACHE_EMPTY_TTL_DAYS=3
|
||||
|
||||
# ====== 第十步:运行模式(可选,通常由命令行指定)======
|
||||
#
|
||||
# 也可在此设置(优先级:CLI > 环境变量 > mo_env > 默认值):
|
||||
# AUTOMATED=true 等同命令行 --automated
|
||||
# DRY_RUN=true 等同命令行 --dry-run
|
||||
# 命令行可用 --no-automated / --no-dry-run 反向覆盖此处设置
|
||||
#
|
||||
# 注意:--list-cache / --update-cache / --refresh-cache 仅限命令行指定
|
||||
#
|
||||
# 自动化模式(写入日志文件,跳过无法处理的项目)
|
||||
# 默认值:false
|
||||
AUTOMATED=false
|
||||
|
||||
# 干运行模式(不创建链接、不写缓存与日志;网络请求照常但不落盘)
|
||||
# 默认值:false
|
||||
DRY_RUN=false
|
||||
|
||||
# ====== 第十一步:目的目录命名与识别池(可选)======
|
||||
#
|
||||
# 目的目录根名默认跟随系统语言(locale 含 zh → 中文名),
|
||||
# 也可在此显式覆盖。Season NN / SxxExx 为 Jellyfin 解析格式,不可本地化。
|
||||
# 留空使用默认值(推荐)
|
||||
FOLDER_MOVIES=
|
||||
FOLDER_SHOWS=
|
||||
FOLDER_MUSIC=
|
||||
FOLDER_MUSICVIDEOS=
|
||||
FOLDER_UNKNOWN=
|
||||
|
||||
# 识别池并发数(视频/音频共用;范围 1-8)
|
||||
# TMDB 限流约 4 请求/秒,并发提高时建议同步增大 TMDB_DELAY
|
||||
# 默认值:4
|
||||
MEDIA_WORKERS=4
|
||||
|
||||
################################################################################
|
||||
# 使用说明
|
||||
################################################################################
|
||||
|
||||
# 1. 保存此文件为 mo_env
|
||||
# cp mo_env.example mo_env
|
||||
|
||||
# 2. 设置文件权限为 600(重要!)
|
||||
# chmod 600 mo_env
|
||||
|
||||
# 3. 编辑 mo_env 并填写必要的配置项
|
||||
# vim mo_env
|
||||
|
||||
# 4. 测试配置(干运行模式)
|
||||
# ./media_organizer.sh --dry-run /downloads /media
|
||||
|
||||
# 5. 正式运行
|
||||
# ./media_organizer.sh /downloads /media
|
||||
|
||||
# 6. 自动化模式(如需定时运行)
|
||||
# ./media_organizer.sh --automated /downloads /media
|
||||
|
||||
################################################################################
|
||||
# 配置优先级示例
|
||||
################################################################################
|
||||
|
||||
# 假设配置如下:
|
||||
# mo_env 中:TMDB_DELAY=2
|
||||
# 环境变量:export TMDB_DELAY=3
|
||||
|
||||
# 脚本实际使用的值:3(环境变量优先)
|
||||
|
||||
# 如果都没有设置,使用脚本默认值:1
|
||||
|
||||
################################################################################
|
||||
# 常见配置场景
|
||||
################################################################################
|
||||
|
||||
# 【场景 1】基础用户 - 仅使用默认设置
|
||||
# 只需填写:TMDB_API_RA_TOKEN
|
||||
# 其余保持默认值
|
||||
|
||||
# 【场景 2】日本动画专用 - 针对日语字幕和特典
|
||||
# TMDB_LANG=ja-JP
|
||||
# SPECIAL_MAP_FILE=/path/to/anime_special_map.json
|
||||
# SEASON_OFFSET_FILE=/path/to/anime_offset.json
|
||||
|
||||
# 【场景 3】大规模处理 - 优化性能和成本
|
||||
# TMDB_DELAY=2
|
||||
# AI_MAX_CALLS=50
|
||||
# CACHE_DIR=/var/cache/media_organizer
|
||||
# DEBUG_LEVEL=1
|
||||
|
||||
# 【场景 4】测试模式 - 不产生成本
|
||||
# AI_DRY_RUN=true
|
||||
# DEBUG_LEVEL=2
|
||||
|
||||
# 【场景 5】网络不稳定环境
|
||||
# TMDB_CURL_RETRY=5
|
||||
# TMDB_CURL_MAX_TIME=60
|
||||
# AI_CURL_MAX_TIME=60
|
||||
# TMDB_DELAY=2
|
||||
|
||||
################################################################################
|
||||
# 故障排除快速参考
|
||||
################################################################################
|
||||
|
||||
# 问题:Permission denied mo_env
|
||||
# 解决:chmod 600 mo_env
|
||||
|
||||
# 问题:TMDB API 请求超时
|
||||
# 解决:增大 TMDB_CURL_MAX_TIME=60, TMDB_DELAY=2
|
||||
|
||||
# 问题:识别准确度低
|
||||
# 解决:配置 AI_API_KEY,启用 AI 辅助
|
||||
|
||||
# 问题:AI 成本过高
|
||||
# 解决:减小 AI_MAX_CALLS=5,或改用免费 Ollama
|
||||
|
||||
# 问题:无法读取配置文件
|
||||
# 解决:检查文件权限和所有者
|
||||
# stat -l mo_env
|
||||
# chown $(whoami) mo_env
|
||||
|
||||
################################################################################'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SPECIAL_KEYMAP_TEMPLATE —— 特典映射模板(mo_special_keymap.json)
|
||||
#
|
||||
# 作用:
|
||||
# 定义特典/番外篇的识别映射:本地关键字符串(文件中的特典标记)→ TMDB 可匹配关键字数组。
|
||||
# 脚本用数组内任一值去匹配文件名与 TMDB 季 0 的特典名称。
|
||||
#
|
||||
# 用法:
|
||||
# 1. 文件缺失时,脚本在用户确认下用此模板生成 mo_special_keymap.json
|
||||
# 2. 可编辑该文件增删映射(键=本地关键字符串,值=TMDB 可匹配关键字)
|
||||
#
|
||||
# 多键→多值:
|
||||
# 值为 JSON 数组(多语言,如 ["Menu","菜单","メニュー"]);
|
||||
# 值为字符串时表示引用另一键的数组(多键共享一组值,如不同压制组的
|
||||
# Menu01/Menu 01/MENU01 都指向 "menu")。
|
||||
# 示例:{"menu": ["Menu", "菜单"], "menu01": "menu", "menu_1": "menu"}
|
||||
# 识别时先用本地字符串精确查表,再按去数字后的核心词查表;数组内任一值命中 TMDB 特典名即匹配。
|
||||
#
|
||||
# 注意:仅用于创建默认文件,不作为运行时默认映射(匹配完全依赖文件内容)。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly SPECIAL_KEYMAP_TEMPLATE='{
|
||||
"menu": ["Menu", "菜单", "メニュー"],
|
||||
"cm": ["CM", "广告", "コマーシャル"],
|
||||
"pv": ["PV", "宣传片", "プロモーションビデオ"],
|
||||
"ncop": ["NCOP", "无字幕片头", "ノンクレジットオープニング"],
|
||||
"nced": ["NCED", "无字幕片尾", "ノンクレジットエンディング"],
|
||||
"trailer": ["Trailer", "预告片", "トレーラー"],
|
||||
"teaser": ["Teaser", "先行预告", "ティザー"],
|
||||
"preview": ["Preview", "预告片", "予告"],
|
||||
"webpreview": "preview",
|
||||
"event": ["Event", "活动", "イベント"],
|
||||
"sp": ["Special", "特别篇", "スペシャル"],
|
||||
"special": ["Special", "特别篇", "スペシャル"],
|
||||
"opening": ["Opening", "片头曲", "オープニング"],
|
||||
"ending": ["Ending", "片尾曲", "エンディング"],
|
||||
"mv": ["Music Video", "音乐视频", "ミュージックビデオ"],
|
||||
"musicvideo": "mv",
|
||||
"interview": ["Interview", "访谈", "インタビュー"]
|
||||
}'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SPECIAL_WORDS_TEMPLATE —— 特典识别词表模板(mo_special_words.json)
|
||||
#
|
||||
# 作用:
|
||||
# 特典识别的判定词(文件名方括号标记含这些词 → 判定为特典)。
|
||||
# 语义 = 特典类别词(Menu/CM/PV/NCOP/NCED 等),匹配对空格不敏感
|
||||
# (词表 "ncop" 可匹配文件名里的 "nc op")。
|
||||
# 词表内容不当作正则(纯字符串子串匹配)。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly SPECIAL_WORDS_TEMPLATE='["menu","ncop","nced","mini anime","pv","cm","sp","teaser","program","promo","trailer","special","music video","mv","opening","ending","特典","特番","花絮"]'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SKIP_DIRS_TEMPLATE —— 跳过目录词表模板(mo_skip_dirs.json)
|
||||
#
|
||||
# 作用:
|
||||
# 不作为"媒体名目录"的目录词(特典/附带目录 + 分类目录):
|
||||
# - find_show_path_from_file / find_show_dir_from_path:目录名精确匹配(忽略大小写)
|
||||
# - count_main_videos:路径段精确匹配
|
||||
# 内容不当作正则(纯字符串比较)。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly SKIP_DIRS_TEMPLATE='["sp","sps","special","specials","cd","cds","bonus","bonuses","extra","extras","previews","ps3 special bd","scans","fonts","特典","特番","花絮","视频","剧集","动画","合集","电影","音乐","movies","series","shows","anime","collections"]'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SEASON_OFFSET_TEMPLATE —— 季数偏移模板(season_offset.json)
|
||||
#
|
||||
# 作用:
|
||||
# 定义常见剧集的季数偏移值,用于处理 TMDB 季数与实际集数不符的情况
|
||||
# (如 TMDB 只有一季,但资源实际有 S01-S03)。
|
||||
#
|
||||
# 用法:
|
||||
# 1. 文件缺失时,脚本用此模板生成 season_offset.json
|
||||
# 2. 可编辑该文件覆盖/新增偏移值
|
||||
#
|
||||
# 配置方法:
|
||||
# 键为剧名(小写)或 "id:TMDB_ID",值为第一季的集数(偏移基准)。
|
||||
# 示例:{"jujutsu kaisen": 23} 表示第一季有 23 集,后续季自动 +23 偏移。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly SEASON_OFFSET_TEMPLATE='{
|
||||
"jujutsu kaisen": 23,
|
||||
"id:109620": 23,
|
||||
"demon slayer": 26,
|
||||
"demon slayer: kimetsu no yaiba": 26,
|
||||
"attack on titan": 25,
|
||||
"shingeki no kyojin": 25,
|
||||
"bleach": 26,
|
||||
"naruto": 220,
|
||||
"one piece": 130,
|
||||
"game of thrones": 10,
|
||||
"breaking bad": 7,
|
||||
"the big bang theory": 17,
|
||||
"ling cage": 24,
|
||||
"heaven official'"'"'s blessing": 11
|
||||
}'
|
||||
|
||||
################################################################################
|
||||
# 全局变量
|
||||
################################################################################
|
||||
|
||||
SCRIPT_DIR=""
|
||||
|
||||
# 运行模式标志。
|
||||
# 可用 ${VAR:-} 保形初始化:AUTOMATED/DRY_RUN 允许从环境变量读取(优先级链第一层),
|
||||
# 直接赋 "" 的其余标志为 CLI 专用(--list-cache / --update-cache / --refresh-cache),忽略环境变量。
|
||||
AUTOMATED="${AUTOMATED:-}"
|
||||
DRY_RUN="${DRY_RUN:-}"
|
||||
REFRESH_CACHE=""
|
||||
UPDATE_CACHE=""
|
||||
LIST_CACHE=""
|
||||
LIST_CACHE_FILTER=""
|
||||
# 源→目标对照表导出:空=关闭;"true"=默认目录(脚本所在目录/mo_map);非空=指定目录
|
||||
EXPORT_MAP=""
|
||||
EXPORT_MAP_DIR=""
|
||||
|
||||
# 目录和文件路径(SOURCE_DIR/DESTINATION_DIR 仅来自 CLI;保形初始化便于测试环境注入)
|
||||
SOURCE_DIR="${SOURCE_DIR:-}"
|
||||
DESTINATION_DIR="${DESTINATION_DIR:-}"
|
||||
CACHE_DIR="${CACHE_DIR:-}"
|
||||
CONFIG_FILE=""
|
||||
|
||||
# 目的目录根名(本地化,默认跟随系统语言;load_config 中按 FOLDER_* 配置链赋值;
|
||||
# 保形初始化避免吞掉环境变量)
|
||||
FOLDER_MOVIES="${FOLDER_MOVIES:-}"
|
||||
FOLDER_SHOWS="${FOLDER_SHOWS:-}"
|
||||
FOLDER_MUSIC="${FOLDER_MUSIC:-}"
|
||||
FOLDER_MUSICVIDEOS="${FOLDER_MUSICVIDEOS:-}"
|
||||
FOLDER_UNKNOWN="${FOLDER_UNKNOWN:-}"
|
||||
|
||||
# TMDB 认证信息
|
||||
TMDB_AUTH_TOKEN=""
|
||||
TMDB_USE_BEARER=false
|
||||
|
||||
# 配置变量
|
||||
# 默认值由 load_config() 统一设置(${VAR:-mo_env:-default}),支持环境变量和 mo_env 覆盖;
|
||||
# 此处用 ${VAR:-} 保形初始化,避免顶层赋空串吞掉环境变量
|
||||
TMDB_LANG="${TMDB_LANG:-}"
|
||||
TMDB_DELAY="${TMDB_DELAY:-}"
|
||||
COLOR_OUTPUT="${COLOR_OUTPUT:-}"
|
||||
DEBUG_LEVEL="${DEBUG_LEVEL:-}"
|
||||
|
||||
# 扩展名(字符串保形,load_config 中 read -ra 转为数组)
|
||||
VIDEO_EXTS="${VIDEO_EXTS:-}"
|
||||
AUDIO_EXTS="${AUDIO_EXTS:-}"
|
||||
SUB_EXTS="${SUB_EXTS:-}"
|
||||
|
||||
# AI 调用统计
|
||||
AI_CALL_COUNT=0
|
||||
|
||||
# curl 网络配置
|
||||
TMDB_CURL_RETRY="${TMDB_CURL_RETRY:-}"
|
||||
TMDB_CURL_CONNECT_TIMEOUT="${TMDB_CURL_CONNECT_TIMEOUT:-}"
|
||||
TMDB_CURL_MAX_TIME="${TMDB_CURL_MAX_TIME:-}"
|
||||
AI_CURL_RETRY="${AI_CURL_RETRY:-}"
|
||||
AI_CURL_CONNECT_TIMEOUT="${AI_CURL_CONNECT_TIMEOUT:-}"
|
||||
AI_CURL_MAX_TIME="${AI_CURL_MAX_TIME:-}"
|
||||
|
||||
# AI 配置
|
||||
AI_API_KEY="${AI_API_KEY:-}"
|
||||
AI_BASE_URL="${AI_BASE_URL:-}"
|
||||
AI_FULL_URL="${AI_FULL_URL:-}"
|
||||
AI_MODEL="${AI_MODEL:-}"
|
||||
AI_MAX_CALLS="${AI_MAX_CALLS:-}"
|
||||
AI_DRY_RUN="${AI_DRY_RUN:-}"
|
||||
|
||||
# 日志文件
|
||||
LOG_FILE="${LOG_FILE:-}"
|
||||
SKIP_LOG_FILE="${SKIP_LOG_FILE:-}"
|
||||
# 日志轮转阈值(MB;0=禁用,超阈值时滚动保留 5 份)
|
||||
LOG_ROTATE_MB="${LOG_ROTATE_MB:-}"
|
||||
|
||||
# 映射配置文件(init_special_map/init_season_offset/init_special_words 中解析默认路径)
|
||||
SPECIAL_MAP_FILE="${SPECIAL_MAP_FILE:-}"
|
||||
SEASON_OFFSET_FILE="${SEASON_OFFSET_FILE:-}"
|
||||
SPECIAL_WORDS_FILE="${SPECIAL_WORDS_FILE:-}"
|
||||
SKIP_DIRS_FILE="${SKIP_DIRS_FILE:-}"
|
||||
# 特典识别词表(init_special_words 加载;worker 经 fork 复制可见)
|
||||
declare -a SPECIAL_WORDS=()
|
||||
# 跳过目录词表(init_skip_dirs 加载;SKIP_DIRS_LOWER 为小写化版本供精确匹配)
|
||||
declare -a SKIP_DIRS=()
|
||||
declare -a SKIP_DIRS_LOWER=()
|
||||
|
||||
# 关联数组
|
||||
# 特典映射(本地关键字符串 -> TMDB 字符串,一对一)
|
||||
declare -A SPECIAL_MAP=()
|
||||
# 剧名/剧ID -> 季数偏移
|
||||
declare -A SEASON_OFFSET_MAP=()
|
||||
# 源文件路径 -> 目的信息(子目录|文件名,两段式;key=源文件)
|
||||
declare -A MEDIA_DESTINATION_MAP=()
|
||||
# 结局账本:源文件路径 -> 结局类别(identified/fallback/skip_type/skip_unidentified/request_failed/pending_ai)
|
||||
# 汇总报告唯一数据源;跳过/失败条目不进入目的映射
|
||||
declare -A MEDIA_OUTCOME_MAP=()
|
||||
# 待 AI 纠正的搜索项
|
||||
declare -A PENDING_AI_SEARCH=()
|
||||
# 待 AI 判定的多艺术家音频项(key=音频文件,value=候选艺术家|专辑)
|
||||
declare -A PENDING_AI_ARTIST=()
|
||||
# AI 对多艺术家的判定结果(key=音频文件,value=选定的专辑艺术家或空)
|
||||
declare -A AI_ARTIST_CHOICE=()
|
||||
# 待 AI 匹配甄别的条目(key=文件,value=type\tyear\tseason\tendpoint\tkey——搜索响应在缓存中)
|
||||
declare -A PENDING_AI_MATCH=()
|
||||
# AI 匹配结果(key=文件,value=选定 id / 季偏移 / 重搜词)
|
||||
declare -A AI_MATCH_CHOICE=()
|
||||
declare -A AI_MATCH_SHIFT=()
|
||||
declare -A AI_MATCH_TERM=()
|
||||
# 本批 AI 请求覆盖的 key 集合(消费端只处理响应覆盖的条目,批外留待下一批)
|
||||
declare -a AI_BATCH_KEYS_SEARCH=()
|
||||
declare -a AI_BATCH_KEYS_SPECIAL=()
|
||||
declare -a AI_BATCH_KEYS_ARTIST=()
|
||||
declare -a AI_BATCH_KEYS_MATCH=()
|
||||
# AI 响应中实际出现的 key(AI 漏了的条目留待下一批)
|
||||
declare -A AI_RESPONDED_SEARCH=()
|
||||
declare -A AI_RESPONDED_ARTIST=()
|
||||
declare -A AI_RESPONDED_MATCH=()
|
||||
# 待 AI 学习的特典项
|
||||
declare -A PENDING_AI_SPECIAL=()
|
||||
|
||||
# 数组
|
||||
declare -a VIDEO_FILES=()
|
||||
# 音频文件(CD 音乐,归入 Music 分类)
|
||||
declare -a AUDIO_FILES=()
|
||||
# 媒体目录正片数量缓存(避免重复扫描,键=show 目录完整路径)
|
||||
declare -A MAIN_COUNT_CACHE=()
|
||||
# 特典映射原始值缓存(值可为 JSON 数组或字符串引用,供 resolve_special_value 展开)
|
||||
declare -A RAW_SPECIAL_MAP=()
|
||||
|
||||
# 计数器
|
||||
PENDING_SEARCH_COUNT=0
|
||||
PENDING_SPECIAL_COUNT=0
|
||||
PENDING_ARTIST_COUNT=0
|
||||
PENDING_MATCH_COUNT=0
|
||||
INDENT_LEVEL=0
|
||||
|
||||
# 链接阶段计数(供运行级汇总与退出码分级)
|
||||
LINK_SUCCESS_COUNT=0
|
||||
LINK_FAIL_COUNT=0
|
||||
|
||||
# 协议行合并统计(merge_emit_lines 输出,供 pool_run 阈值检查)
|
||||
MERGE_PROCESSED=0
|
||||
MERGE_FAILED=0
|
||||
MERGE_STREAK=0
|
||||
|
||||
# 高级选项
|
||||
SKIP_HARDLINK_CHECK="${SKIP_HARDLINK_CHECK:-}"
|
||||
|
||||
# 色彩代码(init_colors() 中按 COLOR_OUTPUT 赋值)
|
||||
C_RESET=''
|
||||
C_RED=''
|
||||
C_GREEN=''
|
||||
C_YELLOW=''
|
||||
C_BLUE=''
|
||||
C_MAGENTA=''
|
||||
C_GRAY=''
|
||||
C_WHITE=''
|
||||
C_DIM=''
|
||||
C_LIGHT_CYAN=''
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034,SC2168 # 跨文件共享全局变量(映射后由其他模块消费);local 在 bashly 包装的函数体内合法
|
||||
|
||||
################################################################################
|
||||
# root 命令文件(bashly 生成脚本的入口调用 root_command)
|
||||
#
|
||||
# bashly 已自动完成:参数解析(位置参数/选项/--opt=值/-- 分隔符)、
|
||||
# 帮助与版本输出、flag 互斥检查(conflicts)。本函数负责:
|
||||
# 1. bashly 解析变量 → 现有全局变量映射
|
||||
# 2. 形状校验(目录组合、模式互斥的剩余部分)
|
||||
# 3. 原 main() 的完整流水线
|
||||
#
|
||||
# 注意:本文件内容由 bashly 包装为 root_command() 函数体——
|
||||
# 不要包含函数定义行(root_command() { ... }),也不要包含顶层代码。
|
||||
#
|
||||
# bashly 1.4 参数访问约定(全部经 args 关联数组):
|
||||
# 位置参数 source_dir/destination_dir → args[source_dir] / args[destination_dir]
|
||||
# 纯 flag --dry-run → args['--dry-run'](存在即 1)
|
||||
# 带参 flag --rerun <file> → args['--rerun']
|
||||
################################################################################
|
||||
|
||||
# 用法错误:输出错误与用法提示,退出码 2(GNU 惯例)。
|
||||
_usage_error() {
|
||||
_log 错误 "$1"
|
||||
_log 错误 "用法:media_organizer.sh [选项] <源目录> <目的目录>(详见 --help)"
|
||||
exit 2
|
||||
}
|
||||
|
||||
# bashly 解析结果 → 现有全局变量映射
|
||||
# (bashly 1.4 全部参数存 args 关联数组:flag 存在=1,命名位置参数用键名;
|
||||
# --no-* 反向开关优先于正向开关,与原 parse_args 的后者覆盖语义基本一致)
|
||||
SOURCE_DIR="${args['--src-dir']:-${args[source_dir]:-${SOURCE_DIR:-}}}"
|
||||
DESTINATION_DIR="${args['--dest-dir']:-${args[destination_dir]:-${DESTINATION_DIR:-}}}"
|
||||
if [[ -n "${args['--no-automated']:-}" ]]; then
|
||||
AUTOMATED=false
|
||||
elif [[ -n "${args['--automated']:-}" ]]; then
|
||||
AUTOMATED=true
|
||||
else
|
||||
AUTOMATED=false
|
||||
fi
|
||||
if [[ -n "${args['--no-dry-run']:-}" ]]; then
|
||||
DRY_RUN=false
|
||||
elif [[ -n "${args['--dry-run']:-}" ]]; then
|
||||
DRY_RUN=true
|
||||
else
|
||||
DRY_RUN=false
|
||||
fi
|
||||
REFRESH_CACHE=false
|
||||
[[ -n "${args['--refresh-cache']:-}" ]] && REFRESH_CACHE=true
|
||||
UPDATE_CACHE=false
|
||||
[[ -n "${args['--update-cache']:-}" ]] && UPDATE_CACHE=true
|
||||
LIST_CACHE=false
|
||||
[[ -n "${args['--list-cache']:-}" ]] && LIST_CACHE=true
|
||||
LIST_CACHE_FILTER=""
|
||||
EXPORT_MAP=false
|
||||
[[ -n "${args['--export-map']:-}" ]] && EXPORT_MAP=true
|
||||
RERUN_FILE="${args['--rerun']:-}"
|
||||
|
||||
# 形状校验(flag 互斥已由 bashly conflicts 处理)
|
||||
if [[ "$LIST_CACHE" == "true" ]]; then
|
||||
# --list-cache [关键词]:bashly 1.4 的 flag 不支持可选参数,
|
||||
# 关键词经位置参数 source_dir 传递(目的目录/--dest-dir 仍视为非法目录参数)
|
||||
if [[ -n "${args['--src-dir']:-}" || -n "${args['--dest-dir']:-}" || -n "${args[destination_dir]:-}" ]]; then
|
||||
_usage_error "--list-cache 不接受目录参数"
|
||||
fi
|
||||
LIST_CACHE_FILTER="${args[source_dir]:-}"
|
||||
fi
|
||||
if [[ "$UPDATE_CACHE" == "true" ]]; then
|
||||
if [[ -z "$SOURCE_DIR" ]]; then
|
||||
_usage_error "--update-cache 需要源目录(位置参数或 --src-dir)"
|
||||
fi
|
||||
if [[ -z "$DESTINATION_DIR" ]]; then
|
||||
# cache-only:仅刷缓存,不整理——无链接可预览、无跳过概念
|
||||
[[ "$DRY_RUN" == "true" ]] && _usage_error "--dry-run 不能与仅更新缓存同时使用(无链接可预览)"
|
||||
[[ "$AUTOMATED" == "true" ]] && _usage_error "--automated 不能与仅更新缓存同时使用(无整理步骤)"
|
||||
fi
|
||||
fi
|
||||
if [[ "$LIST_CACHE" != "true" ]] && [[ -z "$SOURCE_DIR" || -z "$DESTINATION_DIR" ]]; then
|
||||
_usage_error "需要源目录与目的目录(2 个位置参数,或 --src-dir + --dest-dir)"
|
||||
fi
|
||||
|
||||
# 目录归一化为绝对路径(纠错账本存绝对目标、目录比较等依赖一致语义;
|
||||
# list/rerun 形状目录为空,原样返回)
|
||||
SOURCE_DIR=$(normalize_abs "$SOURCE_DIR")
|
||||
DESTINATION_DIR=$(normalize_abs "$DESTINATION_DIR")
|
||||
|
||||
# list 形状:无需配置认证,直接列出后退出
|
||||
if [[ "$LIST_CACHE" == "true" ]]; then
|
||||
init_cache_dir
|
||||
cache_list "$LIST_CACHE_FILTER"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# rerun 形状:手动纠错重跑(--rerun <账本>)——不识别、只按账本执行链接。
|
||||
# 轻量初始化:色彩 + 依赖检查;不加载 TMDB 认证(账本内为绝对路径,无需搜索)。
|
||||
# DRY_RUN/AUTOMATED 补默认值(load_config 不执行,空串在 `if $VAR` 中为真会误判干运行)。
|
||||
# CACHE_DIR 只读解析(纠错学习写回需要;correction_learn 内部会按需创建目录)。
|
||||
if [[ -n "$RERUN_FILE" ]]; then
|
||||
DRY_RUN="${DRY_RUN:-false}"
|
||||
AUTOMATED="${AUTOMATED:-false}"
|
||||
init_colors
|
||||
check_dependencies
|
||||
if [[ -z "${CACHE_DIR:-}" ]]; then
|
||||
if [[ -d "$PWD/mo_cache" ]]; then
|
||||
CACHE_DIR="$PWD/mo_cache"
|
||||
elif [[ -d "$SCRIPT_DIR/mo_cache" ]]; then
|
||||
CACHE_DIR="$SCRIPT_DIR/mo_cache"
|
||||
fi
|
||||
fi
|
||||
run_rerun "$RERUN_FILE"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
load_config
|
||||
init_colors
|
||||
select_auth
|
||||
check_dependencies
|
||||
init_special_map "$SPECIAL_MAP_FILE"
|
||||
init_special_words "$SPECIAL_WORDS_FILE"
|
||||
init_skip_dirs "$SKIP_DIRS_FILE"
|
||||
init_season_offset
|
||||
init_special_categories "$SPECIAL_CATEGORIES_FILE"
|
||||
init_match_rules "$MATCH_RULES_FILE"
|
||||
|
||||
# cache-only 形状:仅更新缓存后退出(无整理步骤,不做硬链接检查)
|
||||
if [[ "$UPDATE_CACHE" == "true" && -z "$DESTINATION_DIR" ]]; then
|
||||
validate_directories
|
||||
init_cache_dir
|
||||
update_cache
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# organize 形状(可叠加 --update-cache 修饰):入口校验 + 硬链接检查(干运行跳过)
|
||||
validate_directories
|
||||
if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
check_hardlink_support
|
||||
fi
|
||||
init_cache_dir
|
||||
|
||||
# 注册信号处理
|
||||
trap '_handle_error $LINENO' ERR
|
||||
|
||||
# 启动信息
|
||||
_log 信息 "=== Jellyfin 媒体库整理脚本 v${SCRIPT_VERSION} ==="
|
||||
_log 信息 "源目录:$SOURCE_DIR"
|
||||
_log 信息 "目的目录:$DESTINATION_DIR"
|
||||
[[ "$DRY_RUN" == "true" ]] && _log 信息 "运行模式:[干运行] - 不创建链接、不写缓存与日志"
|
||||
[[ "$AUTOMATED" == "true" ]] && _log 信息 "运行模式:[自动化] - 将写入日志文件"
|
||||
[[ "$UPDATE_CACHE" == "true" ]] && _log 信息 "运行模式:[更新缓存] - 整理时强制重取对应缓存条目"
|
||||
|
||||
# 流水线:扫描 → 识别池 → AI 批处理 → 链接 → 对照表 → 汇总
|
||||
scan_files
|
||||
ledger_load
|
||||
process_video
|
||||
process_audio
|
||||
run_ai_batch
|
||||
link_media
|
||||
# 账本统一落盘(增量标记 + 失败冷却;惰性清理过期条目)
|
||||
ledger_flush
|
||||
export_map_md
|
||||
print_summary
|
||||
|
||||
# 退出码分级:0=全部成功;3=部分失败(非预期跳过/请求失败/链接失败 > 0)
|
||||
local fail_count=0 key outcome
|
||||
for key in "${!MEDIA_OUTCOME_MAP[@]}"; do
|
||||
outcome="${MEDIA_OUTCOME_MAP[$key]}"
|
||||
if [[ "$outcome" == "skip_unidentified" || "$outcome" == "request_failed" ]]; then
|
||||
fail_count=$((fail_count + 1))
|
||||
fi
|
||||
done
|
||||
if ((fail_count > 0 || LINK_FAIL_COUNT > 0)); then
|
||||
exit 3
|
||||
fi
|
||||
Reference in new issue
Block a user