Files
MediaOrganizer/CONTEXT.md
T
Shuery c1ed1795c2
CI / lint + test + build (push) Canceled after 0s
docs: 重写 README、新增项目报告与开发工具配置
- README 现代化重写:徽章区、特性一览、统一 GitHub 提示块格式、修正版本号与 AI 默认值
- 新增 docs/PROJECT_REPORT.md 学术项目报告(含许可证合规分析与死链修复记录)
- 收录 23 份 ADR 架构决策记录与 CONTEXT.md 领域术语表
- 新增开发配置:.editorconfig / .shellcheckrc / .markdownlint-cli2.jsonc
- .gitignore 补充 mo_map/、检查报告、编辑器临时文件
- 新增 config.example.json 配置模板(不含真实密钥)
2026-08-14 22:41:40 +08:00

26 KiB
Raw Blame History

Media Organizer Context

Jellyfin 媒体库硬链接整理脚本(media_organizer.sh)。本上下文记录该工具的领域术语——重点是其入口层(CLI 参数 + 配置加载)的语言约定。

Language

入口层 (entry layer): 脚本的前门:命令行参数解析、位置参数校验、env/mo_env 配置加载的统称。讨论入口行为时用它,而不是具体指某个函数。 Avoid: 参数解析(只指其中一部分)

模式 (mode): 互斥的入口动作,一次调用恰好选择其一:list(列出缓存)、organize(正常整理)。模式决定入口分发的分支;当 update-cache 修饰标志存在且只给了源目录时,退化为只刷缓存的形状(不整理,提前退出)。 Avoid: 选项、动作

修饰标志 (modifier): 与模式正交的布尔开关,可自由附加到模式上:dry-run、automated、refresh-cache、update-cache。语义上属于"本次调用如何执行",而非"执行什么"。update-cache = 强制重取对应缓存条目(不信任陈旧缓存),是唯一会改变流程形状的修饰标志。 Avoid: 选项、参数

干运行 (dry-run): 修饰标志;本次调用不产生任何持久化副作用:不建链接、不写缓存、不写日志;网络请求(TMDB/AI)照常执行但不落盘。与 cache-only 形状冲突(报错),与 update+organize 兼容。 Avoid: 试运行、预览模式(语义含混)

配置优先级链 (priority chain): 配置值的解析顺序:CLI > 环境变量 > mo_env 文件 > 内置默认值。同一键只取最高优先级来源;CLI 显式设置(含 --no-* 反选)覆盖一切。 Avoid: 覆盖顺序

位置参数: 模式之外的目录参数:源目录、目的目录。organize 需要两个;update-cache 只需源目录(只给一个时仅刷缓存,给两个时刷新并整理);list 不接受任何目录参数。

源目录 (source directory): 被整理的媒体文件所在目录。英文标识统一用 source(SOURCE_DIR、--src-dir)。 Avoid: src 的其他混用

目的目录 (destination directory): 硬链接的目标目录。英文标识统一用 destination(DESTINATION_DIR、--dest-dir),不再使用 target。 Avoid: target, dst(作概念名时)

过滤词 (filter keyword): list 模式的可选关键词,按类型/路径/参数过滤缓存条目。仅 CLI 提供(--list-cache 的值或 = 形式),不从配置读取。

Specials

特典类别词 (special category word): 特典识别的判定词(mo_special_words.json,JSON 数组)——文件名方括号标记含这些词 → 判定为特典(Season 00)。语义 = 特典类别(Menu/CM/PV/NCOP/NCED 等)。匹配对空格不敏感(词表 ncop 可匹配文件里的 nc op);词表内容不当作正则(纯字符串子串匹配)。 Avoid: 特典名("名"暗示具体条目,实际是类别判定词)

匹配关键字 (match keyword): keymap 值元素与 AI 学习产物的统一术语——["Menu","菜单","メニュー"] 中的每个元素都是匹配关键字:用于与 TMDB 特典候选列表条目做匹配(精确/最短前缀/contains 三级)。用户可写 TMDB 条目名,也可写自定义别名。 Avoid: 特典名、集名(这些词本质是特典类别/关键词,不是"名字")

TMDB 特典候选列表 (season0 candidate list): tv/{id}/season/0 的条目名列表(PENDING_AI_SPECIAL 值中的 编号:名称 串)。AI 学习时作为候选:AI 从候选中选择与本地片段匹配的项(选择判定,与 AI 匹配轮同构),产物必须是候选列表成员(交叉验证,防幻觉污染)。 Avoid: 特典集名(暗示 AI "返回名字",实际是"从候选中选择")

特典映射 (special keymap): mo_special_keymap.json:{"本地关键字符串": 匹配关键字数组 | 字符串引用}——多键共享同一组匹配关键字用字符串引用(递归展开,防循环);键小写化。AI 学习写回前交叉验证(产物必须在候选列表)。 Avoid: 特典名映射

Custom

命名模板 (naming template): NAMING_MOVIE/NAMING_SHOW/NAMING_SEASON/NAMING_EPISODE/NAMING_SPECIAL/NAMING_MUSIC 六个配置键,把"命名格式化"从硬编码外置为用户可配置模板。占位符语法:{name} 值插入、{name:NN} 数字补零、{?name:text} 条件段(name 非空才渲染 text,正文可含其他占位符);默认模板与旧版输出逐字节一致(仅格式外置,行为不变)。渲染实现 render_naming_template 逐 token 扫描(不用 ${var//pat/repl} 全局替换——替换串的 & 会被当匹配整体,文件名含 & 时损坏);render_naming 按配置键渲染并在键未配置时回退内置默认(naming_template_default 集中定义)。加载期 validate_naming_templates 对未知占位符打印警告(渲染为空)。 Avoid: 直接拼接字符串(命名格式必须走模板渲染,保证识别/回退两套路径一致)

匹配规则 (match rules): mo_config/match_rules.json:用户手工维护的确定性匹配——search_aliases(搜索别名)与 id_maps(ID 映射),键 = 文件名清洗后的标题(rule_key 归一化:小写 + 空白折叠)。不参与 AI 学习写回(用户显式维护即权威)。 Avoid: 匹配规则(泛指 keymap/季偏移等一切匹配类配置)

搜索别名 (search alias): 匹配规则的 search_aliases 条目:{"<标题键>": "<重搜词>" | {"term": "...", "year": "..."}}。主搜索(zh→en)无结果时用重搜词再搜(zh→en 各一次)——确定性兜底,优先于目录名兜底与 MAL/AI。适合本地俗称/简称(TMDB 搜不到"俺妹"但能搜到全名)。别名 year 仅电影生效。 Avoid: 无条件用别名替换标题(主搜索成功时别名不介入,ID 映射才是一等优先级)

ID 映射 (id map): 匹配规则的 id_maps 条目:{"movie": {"<标题键>": <id>}, "tv": {...}}——标题命中直接使用指定 TMDB ID,完全跳过搜索(最高优先级,先于一切搜索)。movie/tv 按 parse 判定类型分表。适合 TMDB 多候选易选错(同名动画/真人版)、搜索命中错条目的场景。 Avoid: 用别名模拟 ID 映射(别名仍需搜索验证,ID 映射是确定性绑定)

类目分区 (category partition): 四个配置类文件(special_maps/special_keywords/skip_directories/season_offsets,v9.6)支持分区形态:顶层全部键 ∈ MO_CATEGORY_KEYS(movie/tv/music/musicvideo/video/audio/default/global)→ 分区形态(is_category_partitioned 判定,混合形态警告并按全局处理)。查询链统一"类目/流程分区 → default 分区 → 全局表 → 内置默认"。分区键语义因文件而异:特典映射/词表用类目键(tv/musicvideo),跳过目录用流程键(video/music——目录语义判断发生在类目确定前);skip_directories 分区形态 = 完整语义(不叠加内置默认,通用词放 default);special_keywords 分区 = 叠加语义(tv 分区词 + 内置兜底,词表是积累型)。AI 学习写回自动适配(分区形态写 tv/video 分区)。 Avoid: 分区形态下叠加内置默认(skip_directories 的 music 流程误命中 video 侧内置词 "sps"——分区文件表达"只要这些词")

音乐视频类目 (musicvideo category): v9.6 新增第四类目:识别信号 = musicvideo 判定词(special_keywords.json 的 musicvideo 分区 → default → 内置 MUSICVIDEO_WORDS 13 词,不回退 tv 特典词表——"sp" 子串命中 "SPs" 目录等交叉误判)。两类信号:目录信号(目录名精确匹配归一化整名——"Music Videos"/"MV"/"Live"/"演唱会";精确匹配避免 "Muv-Luv"/"tmp.xxx" 含词误判)与文件名信号(方括号标记子串命中,如 [MV];双命中歧义用 "歌手 - 歌名" 模式消解——含 - → musicvideo,不含 → 特典保持现状)。命名 MusicVideos/{artist}/{title}(NAMING_MUSICVIDEO 模板),artist 链:元数据 ALBUMARTIST → ARTIST → 父目录名("歌手 - 歌名"取首段/整名)→ FOLDER_UNKNOWN。本地规则无网络,识别失败不消耗 AI。 Avoid: 目录信号用子串匹配("Muv-Luv" 目录含 "mv" 误判——整目录视频被归类音乐视频的代价远超单个文件)、musicvideo 词表回退 tv 特典词表(SPs 目录稳定误判)

特典类别判定表 (special category table): mo_config/special_categories.json(v9.6 外置):S00 未匹配特典的类别标签判定(S00{类型} - {片段} 的"类型"),[{"match": "nced", "tag": "NCED"}] 数组保序 = 优先级(长词/特定词在前)。查询链:用户表(按序子串)→ 内置默认表(24 项,与 SPECIAL_CATEGORIES_TEMPLATE 一致)→ "Special"。此前硬编码在 special_category_tag 的 24 项类别词表外置为用户可编辑(增删/调序),special_category_tag 保留内置表作最后兜底。 Avoid: 对象形态存类别表(JSON 对象无顺序,判定优先级丢失);把类别表并入 special_keywords(语义不同:词表判定"是否特典",类别表判定"S00 标签是什么")

Pipeline

识别池 (worker pool): process_video 与 process_audio 共用的并发 worker 池(MEDIA_WORKERS,默认 4)。子进程产出 key\tvalue 行,父进程合并——bash 子 shell 无法写父关联数组,这是唯一的并行契约。音频与视频走同一通用接口(识别函数 → 结果/结局)。 Avoid: 并行处理(泛指)

登记 (register): 收拢一切条目写入的注册函数层(register_identified / register_pending / register_fallback / register_skip / register_request_failed)。一次调用原子完成"目的映射 + 结局账本 + 计数器",结构上不可能出现只写一半的不一致。命名取自"声明这条记录存在并处于什么状态",区别于赋值。 Avoid: 赋值、写入(语义含混)

结局账本 (outcome map): MEDIA_OUTCOME_MAP:每条目结局类别的唯一账本(identified / fallback / skip_type / skip_unidentified / request_failed / pending_ai)。运行级汇总报告的唯一数据源;跳过/失败条目不进入目的映射,仅登记于此。 Avoid: 状态字段

回退命名 (fallback naming): AI 尽力后仍无法识别时的降级出口(触发条件唯一,无 AI 密钥 → skip_unidentified,不回退)。命名不含年份占位(Jellyfin 年份可省略);空集名不加 - 段;特典回退复用识别路径的 S00{tag} - {fragment} 约定。 Avoid: 兜底命名(与识别兜底混淆)

降级成功 (degraded success): 回退命名的条目在汇总中的归类:链接确实建立了(文件可访问),但名字是文件名推断的。计入成功而非跳过,但单列一类显示。 Avoid: 成功(不区分)、警告

伴随文件优先级: 同名文件归属判定:视频 > 音频。音频文件先查是否存在同名视频(任一视频扩展名)——存在则该音频是视频的伴随音轨(由视频的伴随逻辑处理,从独立音频处理中排除);不存在才是独立音频,再查它自己的伴随(歌词/封面)。当前脚本对 Movie.zh.ac3 类音轨会双重处理,需按此规则修复。 Avoid: 按扩展名并列判断

艺术家归类链 (artist resolution chain): 音乐条目艺术家归属的逐级判定:专辑艺术家(元数据 ALBUMARTIST,唯一)→ 无则歌曲艺术家(ARTIST)单值视为专辑艺术家 → 多值交 AI(并入现有 AI 批处理,不新增请求类型)→ AI 判断不出用 & 拼接所有艺术家 → 元数据缺失回退目录名解析(parse_cd_dir)→ 仍无则 Unknown 文件夹。专辑名(ALBUM 标签 → 目录名)同构;封面等提取仅整理不增删文件,默认关闭。 Avoid: 目录名优先(现状,将被替换为末级兜底)

目的目录规范 (destination structure): 按 Jellyfin 官方规范:Movies/{title} ({year})/ 夹内同名文件;Shows/{title} ({year})/Season NN/(补零、不缩写,Season 为解析格式不可本地化);SxxExx - 集名;特典 Season 00 描述性命名;Music/{artist}/{album}/(一夹一专辑);MusicVideos/{artist}/{title}(根目录无空格,识别待样本驱动)。撞名按官方多版本格式去重( - 2);同源旧链接(迁移场景)按 inode 清理。根目录名本地化(默认系统语言,FOLDER_MOVIES 等可配)。 Avoid: S01/SE01 季目录名

已链接账本 (linked ledger): mo_cache/media_organizer/linked.json:{src: {dest, inode, linked_at}}。增量标记的唯一数据源——识别池入口命中且源存在 + 目标存在 + 同 inode 才跳过(结局 already_linked),任一失效(目标被删/源被替换)自动重新识别+链接。父进程加载一次(worker fork 复制内存),运行末尾 ledger_flush 统一落盘(原子写+惰性清理),干运行不落盘且不启用跳过(展示全貌)。 Avoid: marker 文件(污染媒体目录)、每文件落盘(O(n²))

失败冷却 (failure cooldown): mo_cache/media_organizer/fail_cooldown.json:{src: {retry_at, reason}}。request_failed/skip_unidentified 登记(FAIL_RETRY_COOLDOWN_HOURS 默认 24h,0=禁用),冷却期内识别池入口跳过(结局 cooldown),过期自动重试。仅"尽力后失败"冷却;--rerun 显式重跑绕过;冷却不计入退出码 3。 Avoid: 每轮全量重试同一批失败项、永久跳过(破坏可逆语义)

纠错账本 (correction ledger): --export-map 伴生同名 .ledger.json:[{src, dest, outcome, status, action}](dest 为绝对路径;status 导出时现场校验 linked/pending)。用户编辑 dest + action:"rerun" 后 --rerun <文件> 重跑——只做链接层(mkdir+硬链接,目标已存在且非同 inode → 替换),不重新识别,不加载 TMDB 认证。 Avoid: 重新识别纠错(与 AI 流程重叠)、Python WebUI(破坏纯 bash 定位)

同集冲突 (episode conflict): detect_conflicts 按 剧集目录||SxxExx(含区间)聚合识别成功条目,同 key 多源 → CONFLICT_MAP,对照表「同集冲突」小节 + 汇总提示。仅检测+报告,不自动删(硬链接库场景自动替换风险大于收益);Season 00 特典/电影/音乐不参与;可经 --rerun 指定保留版本。 Avoid: hold/replace 自动替换(AB 式,需下载器信息且破坏性)

搜索重试链 (search fallback chain): 识别搜索失败顺序重试:zh-CN 空 → en-US(tmdb_api_lang 局部覆盖,缓存按语言段隔离,默认)→ SEARCH_FALLBACK_MAL=true 时 MyAnimeList (jikan v4) 候选标题(罗马音/英/日,≤3 个)逐个回 TMDB en-US 重搜(mo_cache/media_organizer/mal/ 独立缓存)。请求失败(rc=2)不重试;MAL 失败静默降级到 AI 路径。 Avoid: 直接信任 MAL 标题入库(必须经 TMDB 验证)、默认开启 MAL(外部 API 依赖)

AI 时长信号 (AI duration signal): match_entries 构造时为待甄别文件运行 ffprobe,附加 duration(分钟,<1min 视为空)与 resolution。AI 据此区分剧场版(~70-120min)/ OVA·特典(~20-30min)/ 正片(~24min);失败留空不阻塞。 Avoid: 全量文件 ffprobe(仅需甄别条目)、hachoir 新依赖(ffprobe 已存在)

候选结构评分 (candidate structure scoring): pick_tv_show_id 精确名匹配失败后的评分层:前 5 候选(前缀排除后)按 季数覆盖文件季号 +40 / 名称 norm 互相包含 +30 / 特典候选含 Season 0 +20 取最高,全不满足兜底 results[0]。评分请求走 tmdb_api(缓存命中免费)。精确匹配始终最高优先——评分层只在歧义场景介入。 Avoid: 直接 results[0](歧义场景浪费 AI 轮次)、评分替代精确匹配(改变正常识别路径)

目录名兜底 (directory fallback search): tv_search_by_dir / movie_search_by_dir:主搜索失败(zh→en 均空)后用父目录名(clean_name+strip_season_suffix)重搜。源根散放不兜底(共享目录误判);目录名与 query 相同/为空跳过;插在 MAL 之前(零外部依赖优先)。TV 复用结构评分,电影取 results[0]。 Avoid: 目录名直接当标题(未经验证)、无条件用目录名(源根散放误判)

纠错学习 (correction learning): mo_cache/media_organizer/corrections.json:{clean_name(文件名)小写: {dest, learned_at}}。--rerun 链接成功即学习(用户显式修正即权威,立即原子写盘);识别入口按同算法 key 前置命中 → 直接 DEST(跳过 parse/识别/AI/冷却)。学习源只有 --rerun(自动识别成功不学习——防固化脚本自身错误);dest 须位于当前 DESTINATION_DIR 下;目录参数已归一化绝对路径(normalize_abs)。 Avoid: 学习 AI 识别结果(无用户确认)、按绝对路径学习(换目录失效)、冷却优先于纠错(用户修正不应被退避阻塞)

后缀季模糊 (suffix-season fuzzy): 后缀季映射词表全失败且后缀 ≥3 字符时:awk Levenshtein 只比较季名末尾窗口(末 flen+1 字符,完美命中距离 ≤1 与阈值分档 1/2/3 匹配),最短距离 ≤ 阈值命中。跨语言不指望命中(距离必然超阈值)——服务同文近似拼写。 Avoid: 整名 Levenshtein(距离不可达,阈值形同虚设)、短后缀启用(词表/罗马数字已覆盖,误配风险高)

公共子串剥离 (peer prefix/suffix extraction): extract_episode_from_peers:无特征剧集目录(≥2 视频)求最长公共前缀/后缀(bash 逐字符),当前文件中段取末尾数字作集号(排除分辨率 1080/720/480/2160/4320);无数字保持 episode=0(不猜)。仅"正片计数 → tv"回退分支启用。 Avoid: difflib 全公共子串(python 新依赖)、中段无数字仍猜集号

AI 响应容错 (AI response tolerance): ai_extract_json 四级容错链:① 直接解析 → ② 剥 ```json/``` 围栏 → ③ 剥离思考链标记(<thinking> 块 + 「思考/分析/推理:」前缀)→ ④ 最外层 {} 块(花括号配平,字符串内误计仅致本级失败)。任何一级 jq 验证通过即返回;全失败保持原失败路径(条目留待下批)。纯 awk/sed,零新依赖。调用点 ai_batch_request 用 || 保护(set -e 安全)。 Avoid: 严格校验整批丢弃(推理模型输出思考内容时浪费一轮+额度)、SDK 结构化输出(curl 直连兼容端点,能力不一)

AI 用例落盘 (AI case persistence): AI_SAVE_CASES=true 时每次请求的输入 JSON(build_ai_input_json 产物)与原始响应存 mo_cache/media_organizer/ai_cases/<序号>_<ts>_{request,response}.json。序号复用 AI_CALL_COUNT;输入不含 API 密钥(key 只在 HTTP 头);响应存原始文本(非 JSON 也留档——正是排查价值);干运行不写。反馈闭环 + 防幻觉学习候选数据源。 Avoid: 只存响应(缺输入侧无法重建上下文)、存 payload(无 key 但含 base_url 等配置信息,不必要)

同目录上下文 (siblings): build_ai_input_json 的 match_entries 每项附 siblings(同目录其他视频文件名,≤20,排除自身,目录扫描零请求)。与时长信号互补:时长回答"这个文件多长",siblings 回答"它和什么在一起"——剧场版混 TV 场景(90min 文件旁 23 个 24min 文件 → season 0 剧场版)的判断依据。AI 仍只做判断,命名归脚本。 Avoid: 整目录一次映射(BAR 模式,prompt/输出契约复杂化,与单文件判断流水线不兼容)、附路径/时长(体积膨胀,信息已由 file/directory/duration 提供)

搜索链抽象 (search chain abstraction): tv_search_once / movie_search_once 统一尝试器:参数(query/季号/特典/语言/年份),返回码 0=命中(stdout=id)/1=无结果/2=请求失败;movie 版两行协议(id + 单行响应 JSON)回传响应供年份校验/需甄别检测(命令替换子 shell 丢全局赋值,两行协议是零新依赖回传)。识别链 = zh → en → 规则别名 → 目录 → MAL → 后缀二次剥离,逐层调用尝试器。失败语义:主搜索失败短路 return 2;后续层失败忽略;别名层失败不试 en 变体。pick_tv_show_id 调用点收敛到尝试器内部 1 处。 Avoid: 逐层复制(每层 ~10 行且失败语义易漂移)、全局变量回传(子 shell 丢失)、整链编排(各层 query 生成逻辑异构)

配置单点化 (config single source): CONFIG_DEFS(键|默认值数组,main.sh)→ render_config_template() 生成 config.json 模板 + load_config 循环加载(printf -v 动态赋值 + ${!key:-} 间接引用,零 eval)。新增纯标量键只需改一处;FOLDER_* locale 默认/扩展名拆数组/数值校验/日志初始化等后处理保留手写。NAMING_* 字面值须与 naming_template_default 一致(后者为 render_naming 兜底权威)。 Avoid: 模板/加载/默认值三处手写(漂移源)、全配置关联数组化(动所有消费点)

已知缺口 (known gaps): ① 多集单文件(S01E01-E02.mkv) ——已实现(parse 输出契约 7 段含 episode_end;识别/回退命名 S01E01-E02 - 首集名 - 末集名);② Music Videos 识别信号——待真实样本驱动(多数文件规则补齐);③ 纯音频 mkv/mp4 容器改名(mka/m4a)——文档提示,不自动改文件;④ 电影/电视判断链 ——已实现((YYYY) 任意位置提取进 year 字段;count_main_videos 扩展名读 VIDEO_EXTS + 跳过目录词表 mo_skip_dirs.json + 源根散放约束→AI);判断链的"文件名特征评分系统"挂账正式关闭——年份任意位置提取后无特征文件已收敛(有年份→movie、剧集目录多集→tv、源根散放→unknown→AI),评分系统价值趋零;⑤ 后缀季(Railgun T/II/2nd) ——已实现(strip_season_suffix_word 剥后缀重搜 + base 剧 seasons 名匹配映射季号 + 罗马数字直映射 + Levenshtein 末尾窗口模糊兜底;AI 的 season_shift 仅兜底脚本剥离失败);⑥ AI 使用 ——已实现(AI 失败→可逆 skip、jq 构造输入、分批、判断/执行职责分离;v9.4 起 match_entries 补充时长/分辨率信号)。

多集区间 (multi-episode range): 单文件含多集(S01E01-E02)的区间解析与命名规则。目标文件名保留区间:{标题} - S01E01-E02 - {首集名} - {末集名}.{ext}(集名段取首尾两集的 TMDB 集名,- 连接);Jellyfin 据 S01E01-E02 识别为多集条目。解析支持 E01-E02 与扩展形式(E01-E02E03)。 Avoid: 吞掉区间(现状行为,静默降级为单集)

Cache

空哨兵 (empty sentinel): 搜索"查无此片"(results:[])写入的 empty:true 包裹缓存,TTL 3 天(CACHE_EMPTY_TTL_DAYS)。TTL 内免重复请求,过期后自动重新搜索——新片出现后会被发现。与"请求失败"(不写缓存,下次运行重试)语义分离。 Avoid: 空结果缓存(30 天 TTL 锁死,旧行为)

缓存语言段 (cache language segment): id 类缓存路径(tv/<id>.<lang>.json、tv/<id>/season/<n>.<lang>.json、movie/<id>.<lang>.json)携带 TMDB_LANG——语言相关的详情/季数据按语言隔离,切换语言自动 miss 重新拉取。与搜索缓存的 lang 入 hash 同构。 Avoid: 无语言维度的 id 缓存(切语言后 30 天旧数据)

并发去重 (flock double-checked locking): 识别池多 worker 同时 miss 同一查询时的互斥机制:tmdb_api 未命中后 flock 锁(锁文件在 /tmp,内核锁进程退出自动释放),锁内双检缓存——等待者直接命中先写者的结果,同一查询只发一次 TMDB 请求。 Avoid: 无锁并发(同一剧多集并发识别会重复请求击穿限流)

AI

判断引擎 / 执行引擎 (judgment vs execution): AI 只做判断——输出 {choice: <id> | "", season_shift: <N>, search_term: "重搜词"}(选中候选 / 季映射 / 无匹配纠正词);命名格式化(subdir|filename、年份、Season 补零、SxxExx)始终由脚本构建。AI 是判断引擎,脚本是执行引擎——格式化是确定性职责,不交给 AI。 Avoid: AI 直接输出目标文件名

AI 批处理批次 (AI batch): AI_BATCH_SIZE(默认 50)条/批;AI_MAX_CALLS 语义为批次上限,达标后剩余 pending 显式 skip_unidentified。输入构造用 jq 安全转义(文件名含引号/反斜杠不破坏 JSON);PENDING_AI_SEARCH 分隔符用 $'\t'(文件名可含 |)。 Avoid: 全量单批(数百条 prompt 超上下文)、字符串拼接 JSON

AI 失败语义: AI 请求失败(网络/非 JSON/上限达标)→ 剩余 pending 显式 skip_unidentified(可逆,下次运行自动重试);只有"AI 成功返回且判断无解"才走回退命名(不可逆)。与"无 AI 密钥"语义统一——AI 没尽力 ≠ AI 尽力后失败。 Avoid: AI 失败 → 回退命名(伪命名被幂等锁死)

AI 匹配输入契约: 文件信息(file 完整路径 + 目录链——源根相对路径,最多 3 层)+ TMDB search 响应原样(缓存中,零额外请求)+ detail 的 seasons 四字段提炼(season_number/name/episode_count/air_date)——体积-信息平衡点。 Avoid: 手工提炼候选集(字段选择错误风险)、detail 全量原样(单条 3-8KB,批量超上下文)

后缀季剥离 (suffix-season stripping): 脚本侧方案:搜索无结果或季数存疑时,剥标题末尾季后缀(T/S/II/III/2nd/Season 2 等词表)重搜 base 剧 → 遍历其 seasons 找名字含被剥后缀的季 → 文件季号映射到该 season_number。AI 的 season_shift 仅兜底脚本剥离失败的情况。 Avoid: 把后缀季交给 AI(脚本可解,减少 AI 依赖)