# 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/.json /search/movie 搜索结果(键=query&year&lang) # search/tv/.json /search/tv 搜索结果 # tv/..json /tv/{id} 剧集详情(原始响应;语言段随 TMDB_LANG) # tv//season/..json /tv/{id}/season/{n} 季数据(含 n=0 特典季) # movie/..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: " 输入数据。 # # 预期返回格式(ai_batch_request 用 jq 解析 .search 与 .special 两个部分): # { # "search": { "<完整 file 路径>": { "search_term": "清洗后的标题", "year": "年份或空串" }, ... }, # "special": { "|": { "english_keyword": "英文标准键", "chinese_keyword": "中文别称" }, ... } # } # - 某类无条目时,对应键为 {}。 # - search 的键 = 输入 search_entries 中每项的 "file" 完整路径(逐字节复制)。 # - special 的键 = 输入 special_entries 中每项的 "|"(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": | "", "season_shift": | "", "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=''