Files
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

229 lines
26 KiB
Markdown
Raw Permalink Blame History

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