diff --git a/README.md b/README.md
index f16bebd..f563d3f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# 🎬 MediaOrganizer
-> **Jellyfin 媒体库硬链接整理脚本**:通过 TMDB API 识别电影、电视剧与音乐视频,用**硬链接**零拷贝创建 Jellyfin 标准目录结构。AI 辅助识别、全量镜像缓存、特典自动归类、季数偏移,一次整理,终身整洁。
+> **Jellyfin 媒体库硬链接整理脚本**:通过 TMDB API 识别电影、电视剧、音乐与音乐视频,用**硬链接**在同一文件系统内**零拷贝**创建 Jellyfin 标准目录结构。AI 辅助识别、全量镜像缓存、特典自动归类、季数偏移、增量整理——一次整理,终身整洁。
[](LICENSE)
[](src/lib/main.sh)
@@ -8,1067 +8,359 @@
[](.github/workflows/ci.yml)
[](tests/)
[](https://google.github.io/styleguide/shellguide.html)
-[](https://gitea.ppuc.lssa.fun/Shuery/MediaOrganizer)
+[](https://gitea.ppuc.lssa.fun/Shuery/MediaOrganizer)
+[](https://gitea.ppuc.lssa.fun/Shuery/MediaOrganizer/pulls)
**作者**:LetsShareAll | **许可**:MIT | **语言**:Bash(零运行时依赖,单文件分发)
-## 📦 获取项目
-
-```bash
-# 自托管 Gitea(推荐):SSH 或 HTTPS 克隆
-git clone git@gitea.ppuc.lssa.fun:Shuery/MediaOrganizer.git
-# 或
-git clone https://gitea.ppuc.lssa.fun/Shuery/MediaOrganizer.git
-
-# 直接下载分发脚本(单文件即用,无需构建)
-curl -O https://gitea.ppuc.lssa.fun/Shuery/MediaOrganizer/raw/branch/main/dist/media_organizer
-chmod +x media_organizer
-
-# 或从源码自行构建(约 1 秒,需 Ruby + bashly)
-./build.sh
-```
-
-> [!NOTE]
->
-> **零依赖设计**:`dist/media_organizer` 是自包含单文件产物(bashly 生成),仅需系统自带的 `bash`/`curl`/`jq`/`ffprobe`,无需安装 Ruby 或任何语言运行时。构建工具链(bashly)仅在从源码二次构建时需要。
-
----
-
-## ✨ 特性一览
-
-| 特性 | 说明 |
-| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 🎬 智能识别 | TMDB API 匹配电影与电视剧,支持多种文件名格式 |
-| 🔗 硬链接整理 | 同一文件系统内零拷贝,省空间、省时间 |
-| 🤖 AI 辅助识别 | 无法匹配/需甄别时调用 OpenAI 兼容接口(默认 DeepSeek-V4-Flash)纠正搜索词、**匹配甄别**(选候选 id + 季映射)、判定艺术家、学习特典映射;分批处理、失败可逆跳过 |
-| 📚 持久化缓存 | 缓存根目录 `mo_cache/` 分两区:`tmdb/` 镜像 TMDB API 路径缓存全部请求数据,`media_organizer/` 存脚本运行状态(账本/冷却),用户配置类文件位于 `mo_config/`——二次运行零外部请求;TTL:正常 30 天 / 查无此片 3 天;并发 flock 去重 |
-| ⭐ 特典自动识别 | 识别 NCOP/NCED/Menu/PV/CM/Teaser/Preview 等特典并归入 Season 00;本地关键字 → keymap 多语言值 → TMDB SEASON0 匹配 |
-| 🎵 音乐支持 | CD 音乐归 `Music/歌手/专辑/曲目`;音乐视频(v9.6)归 `MusicVideos/{artist}/{title}` |
-| 🧮 季数偏移 | 处理 TMDB 季数与实际集数不符的剧集(自动/手动两种偏移) |
-| ♻️ 增量标记 | 已链接账本:重跑只处理新文件(源 inode 与目标均有效才跳过) |
-| ⏳ 失败冷却 | 请求失败/未识别条目登记冷却(默认 24h),冷却期内跳过,过期自动重试 |
-| ✏️ 手动纠错 | `--export-map` 伴生 `.ledger.json` 账本:改目标路径 + `--rerun` 重跑 |
-| 🔎 搜索重试链 | zh-CN → en-US → 规则别名 → 目录名 → MAL → 后缀二次剥离 |
-| 🧭 命名模板 | 电影/剧集/特典/音乐命名格式可配置(`NAMING_*`),默认与旧版输出逐字节一致 |
-| 🎛️ 用户匹配规则 | `match_rules.json`:搜索别名 + ID 映射,确定性兜底,优先于 AI |
-| 🗂️ 配置按类目分区 | special_maps/keywords/skip_directories/season_offsets 支持 movie/tv/music/musicvideo 分区(v9.6) |
-| 🖥️ 跨平台 | Linux / macOS(BSD stat 兼容)/ BusyBox |
-
-### 处理流程(三阶段)
-
-```mermaid
-flowchart LR
- A["scan_files 扫描源目录
视频 VIDEO_FILES + 音频 AUDIO_FILES"] --> B["第一阶段 识别池 process_video + process_audio
并发 MEDIA_WORKERS 个 worker
parse → identify_movie / identify_tv_show
音频走艺术家归类链(元数据→目录名)
→ register 层写入 MEDIA_DESTINATION_MAP
+ MEDIA_OUTCOME_MAP 结局账本"]
- B --> C["第二阶段 run_ai_batch(分批 AI_BATCH_SIZE/批)
AI 纠正搜索词 + 匹配甄别(选 id/季映射)
+ 判定多艺术家 + 学习特典映射
→ 重处理 PENDING → 回退命名(降级成功)
AI 失败 → 剩余显式跳过(可逆)"]
- C --> D["第三阶段 link_media
mkdir → 硬链接(幂等/撞名去重 - 2)
→ 配套文件(字幕/音轨/歌词/封面)"]
- D --> E["运行级汇总 + 退出码分级
0 全部成功 / 3 部分失败(非预期跳过/请求失败)"]
-```
-
---
## 📑 目录
-0. [获取项目](#-获取项目)
-1. [简介与功能](#1-简介与功能)
-2. [依赖与环境要求](#2-依赖与环境要求)
-3. [快速开始](#3-快速开始)
-4. [命令行选项](#4-命令行选项)
-5. [执行流程总览](#5-执行流程总览)
-6. [TMDB API 调用详解](#6-tmdb-api-调用详解)
-7. [执行判断详解](#7-执行判断详解)
-8. [配置文件详解](#8-配置文件详解)
-9. [输出目录结构](#9-输出目录结构)
-10. [核心算法与公式](#10-核心算法与公式)
-11. [日志与调试](#11-日志与调试)
-12. [故障排除](#12-故障排除)
-13. [构建与开发(源码结构)](#13-构建与开发源码结构)
-14. [许可证](#14-许可证)
+- [✨ 特性一览](#-特性一览)
+- [🚀 快速开始](#-快速开始)
+- [📦 获取项目](#-获取项目)
+- [🧭 命令行模式与选项](#-命令行模式与选项)
+- [📖 使用示例](#-使用示例)
+- [📊 架构与执行流程](#-架构与执行流程)
+- [🗂️ 输出目录结构](#️-输出目录结构)
+- [🛠️ 配置详解](#️-配置详解)
+- [🧠 核心机制](#-核心机制)
+- [🧪 测试与开发](#-测试与开发)
+- [🐛 故障排除](#-故障排除)
+- [🤝 贡献指南](#-贡献指南)
+- [📄 许可证与致谢](#-许可证与致谢)
---
-## 1. 简介与功能
+## ✨ 特性一览
-本脚本扫描源目录中的媒体文件,通过 **TMDB API** 识别其真实标题,并使用**硬链接**在目的目录创建 Jellyfin 规范的目录结构(不复制数据、不占用额外磁盘空间)。
+| 特性 | 说明 |
+| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
+| 🎬 智能识别 | TMDB API 匹配电影与电视剧,支持多种文件名格式,媒体类型自动判定 |
+| 🔗 硬链接整理 | 同一文件系统内零拷贝,不复制数据、不占额外空间 |
+| 🤖 AI 辅助识别 | OpenAI 兼容接口(默认 DeepSeek-V4-Flash):纠正搜索词、匹配甄别(选候选 id + 季映射)、判定专辑艺术家、学习特典映射;分批处理、失败**可逆**跳过 |
+| 📚 全量镜像缓存 | `mo_cache/tmdb/` 镜像 TMDB API 路径缓存全部请求,二次运行**零外部请求**;正常 30 天 / 查无此片 3 天 TTL;并发 `flock` 去重 |
+| ⭐ 特典自动识别 | NCOP/NCED/Menu/PV/CM 等特典归入 Season 00;TMDB 匹配用 `S00E{编号}`,未匹配用 `S00{类型}{编号}`;AI 学习写回映射 |
+| 🎵 音乐与音乐视频 | CD 音乐归 `Music/歌手/专辑/曲目`;Music Videos(v9.6)识别与 `MusicVideos/{artist}/{title}` 命名 |
+| ♻️ 增量标记 | 已链接账本(inode 校验):cron 重跑只处理新文件;目标被删/源被替换自动失效重新链接 |
+| ⏳ 失败冷却 | 尽力后失败的条目登记冷却(默认 24h),冷却期内跳过、过期自动重试,不重复打 API |
+| ✏️ 手动纠错 | `--export-map` 伴生 `.ledger.json`:改目标路径 + `--rerun` 重跑;修正结果自动学习(`corrections.json`) |
+| 🔎 搜索重试链 | zh-CN → en-US → 规则别名 → 目录名兜底 → MAL(可选)→ 后缀季二次剥离 |
+| 🧮 季数偏移 | 处理 TMDB 季数与实际集数不符的剧集(自动 / 手动两种偏移,含后缀季 `Railgun T`) |
+| 🧭 命名模板 | 电影/剧集/特典/音乐/音乐视频命名格式全部可配置(`NAMING_*`),默认与旧版输出逐字节一致 |
+| 🎛️ 用户匹配规则 | `match_rules.json`:搜索别名 + ID 映射,确定性兜底,优先于 AI,零 AI 轮次消耗 |
+| 🗂️ 配置按类目分区 | 特典映射/词表/跳过目录/季偏移支持 movie/tv/music/musicvideo 分区(v9.6) |
+| 📏 AI 多维信号 | AI 甄别输入附带 ffprobe 时长/分辨率 + 同目录兄弟文件列表——区分剧场版/OVA/正片 |
+| 🧩 AI 响应容错 | 四级 JSON 容错提取(直接解析 → 剥围栏 → 剥离思考链 → 最外层 `{}`),推理模型不再整批失效 |
+| 🖥️ 跨平台 | Linux / macOS(BSD stat 兼容)/ BusyBox(NAS / OpenWrt) |
+| 📦 单文件分发 | `dist/media_organizer` 自包含全部配置模板,无需携带任何配套文件 |
-### 功能细节
+### 处理流程(三阶段)
-| 特性 | 说明 |
-| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 🎬 智能识别 | TMDB API 匹配电影与电视剧,支持多种文件名格式 |
-| 🔗 硬链接整理 | 同一文件系统内零拷贝,省空间、省时间 |
-| � 媒体类型智能判断 | 文件名无明确季集/年份特征时,**根据媒体目录内正片数量**判断电影/剧集(不依赖下载目录,合集种子可混合);特典归属同理(媒体目录仅 1 个正片→电影特典跳过,多个→剧集特典 Season 00) |
-| 🤖 AI 辅助识别 | 无法匹配/需甄别时调用 OpenAI 兼容接口(默认 DeepSeek-V4-Flash)纠正搜索词、**匹配甄别**(选候选 id + 季映射)、判定艺术家、学习特典映射;分批处理、失败可逆跳过 |
-| 📚 持久化缓存 | 缓存根目录 `mo_cache/` 分两区:`tmdb/` 镜像 TMDB API 路径缓存全部请求数据(`search/movie\|tv/.json`、`tv/..json`、`tv//season/..json`、`movie/..json`),`media_organizer/`存脚本运行状态(账本/冷却),用户配置类文件(特典映射/词表、跳过目录、季偏移)位于`mo_config/`——TMDB 数据与脚本数据分离,二次运行零外部请求;TTL:正常数据 30 天 / 查无此片 3 天;并发 flock 去重 |
-| ⭐ 特典自动识别 | 识别 NCOP/NCED/Menu/PV/CM/Teaser/Preview 等特典并归入 Season 00;本地关键字 → keymap 多语言值(多键→多值)→ TMDB SEASON0 匹配;匹配用 `S00E{编号}`,未匹配用 `S00{类型}{编号}` |
-| 🎬 电影特典跳过 | **不依赖下载目录**:媒体目录仅 1 个正片(如合集里的剧场版)时,其 SPs/CDs 特典视频不整理(TMDB/Jellyfin 不收录电影特典,避免误判为剧集特典) |
-| 🎵 CD 音乐归 Music | 音频文件(flac/mp3 等)解析 CD 目录名,归入 `Music/歌手/专辑/曲目` |
-| 🧮 季数偏移 | 处理 TMDB 季数与实际集数不符的剧集 |
-| 📦 单文件分发 | 三个配置文件模板全部内嵌为常量,无需携带配套文件 |
-| ♻️ 增量标记 | 已链接账本(`linked.json`):重跑时已整理文件直接跳过(源 inode 与目标均有效才跳过,目标被删/源被替换自动失效);cron 重跑只处理新文件 |
-| ⏳ 失败冷却 | `fail_cooldown.json`:请求失败/未识别条目登记冷却(默认 24h,`FAIL_RETRY_COOLDOWN_HOURS` 可配),冷却期内跳过,过期自动重试——避免每轮全量重试同一批失败项 |
-| ✏️ 手动纠错 | `--export-map` 伴生 `.ledger.json` 账本:改目标路径 + `action:"rerun"` 后 `--rerun` 重跑(不重新识别,只按账本硬链接;目标已存在则替换) |
-| 🔎 搜索重试链 | 识别搜索失败时自动重试:zh-CN → en-US(默认)→ MyAnimeList 候选回搜(`SEARCH_FALLBACK_MAL=true` 可选) |
-| 📏 AI 时长信号 | AI 匹配甄别输入补充 ffprobe 实测时长/分辨率——AI 可区分剧场版/OVA/正片 |
-| ⚠️ 同集冲突报告 | 同一剧集/季/集多个来源时在对照表并列报告(不自动删,链接仍按多版本 `- 2` 去重) |
-| 🎯 候选结构评分 | TV 搜索无精确匹配时按结构选候选:季数覆盖文件季号优先、名称归一化包含加分、特典文件优先含 Season 0 的候选(借鉴 Auto_Bangumi 的解析结果参与匹配) |
-| 📁 目录名兜底 | 搜索失败用父目录名重搜(清洗+剥季后缀,zh→en)——压制组目录名常是完整剧名(借鉴 AB save_path 反查 / BAR 目录链) |
-| 🧠 纠错学习 | `--rerun` 手动修正持久化为 `corrections.json`:同命名系列文件下次识别直接命中用户指定目标(借鉴 AB title_aliases 合并机制) |
-| 🧭 命名模板 | 电影/剧集/特典/音乐命名格式可配置(`NAMING_MOVIE`/`NAMING_SHOW`/`NAMING_SEASON`/`NAMING_EPISODE`/`NAMING_SPECIAL`/`NAMING_MUSIC`):占位符 `{title}`/`{season:02}` + 条件段 `{?year: ({year})}`;默认模板与旧版输出逐字节一致,仅把格式外置 |
-| 🎛️ 用户匹配规则 | `mo_config/match_rules.json`:**搜索别名**(主搜索失败后用用户指定重搜词兜底,可带年份)与 **ID 映射**(标题直接绑定 TMDB ID,跳过搜索)——确定性规则,优先于目录名兜底与 AI,适合俗称/简称与 TMDB 易选错的条目 |
-| 🗂️ 配置按类目分区 | `special_maps`/`special_keywords`/`skip_directories`/`season_offsets` 支持类目分区形态(movie/tv/music/musicvideo + default 回退,8.8 节)——每类目可独立定义,未配置回退全局表;AI 学习写回自动适配分区 |
-| 🏷️ 特典类别外置 | S00 未匹配特典的类别标签判定表外置为 `special_categories.json`(数组保序=优先级,8.9 节)——不再硬编码,用户可增删类别/调顺序 |
-| 🎵 Music Videos | 音乐视频类目识别(目录信号 + `[MV]` 文件信号,7.2C 节)与命名(`MusicVideos/{artist}/{title}`,本地规则无网络请求) |
-| 🔤 后缀季模糊匹配 | 硬编码词表失败后用 Levenshtein 距离对季名末尾窗口模糊匹配(借鉴 BAR SequenceMatcher 思路) |
-| ✂️ 公共子串剥离 | 无特征多视频目录:剥离公共前后缀后取差异数字作集号(借鉴 BAR difflib 公共子串) |
-| 🧩 AI 响应容错 | AI 返回非纯 JSON 时四级容错提取:直接解析 → 剥围栏 → 剥离思考链标记 → 最外层 {} 块(推理模型不再整批失效) |
-| 🗂️ AI 用例落盘 | `AI_SAVE_CASES=true` 时每次请求的输入/响应存 `mo_cache/media_organizer/ai_cases/`——识别错误可复盘"当时 AI 看到了什么" |
-| 👥 同目录上下文 | AI 匹配甄别输入附带同目录兄弟文件列表(≤20 个):目录持整季 BD 还是独立电影,AI 据此判断剧场版/OVA/正片 |
-| 🖥️ 跨平台 | 支持 Linux / macOS(BSD stat 兼容) |
+```mermaid
+flowchart LR
+ A["scan_files 扫描源目录
视频 VIDEO_FILES + 音频 AUDIO_FILES"] --> B["第一阶段 识别池
process_video + process_audio
并发 MEDIA_WORKERS 个 worker
parse → TMDB 识别 → register 登记"]
+ B --> C["第二阶段 AI 批处理
run_ai_batch(AI_BATCH_SIZE 条/批)
纠正搜索词 / 匹配甄别 / 判定艺术家 / 学习特典
AI 失败 → 剩余显式跳过(可逆)"]
+ C --> D["第三阶段 硬链接
link_media:mkdir → ln(幂等 / 撞名去重)
→ 伴随文件(字幕 / 音轨 / 歌词 / 封面)"]
+ D --> E["运行级汇总 + 退出码分级
0 全部成功 / 3 部分失败"]
+```
---
-## 2. 依赖与环境要求
+## 🚀 快速开始
-| 依赖 | 用途 | 安装示例 (Arch) |
-| ------------- | -------------------- | ----------------------- |
-| **Bash 4.0+** | 脚本运行环境 | 系统自带 |
-| **curl** | 调用 TMDB / AI API | `sudo pacman -S curl` |
-| **jq** | JSON 解析 | `sudo pacman -S jq` |
-| **ffprobe** | 媒体探测(依赖检查) | `sudo pacman -S ffmpeg` |
+> [!NOTE]
+>
+> **零依赖设计**:`dist/media_organizer` 是自包含单文件产物(bashly 生成),仅需系统自带的 `bash`/`curl`/`jq`/`ffprobe`,无需安装 Ruby 或任何语言运行时。构建工具链(bashly)仅在从源码二次构建时需要。
+
+```shell
+# 1. 获取分发脚本(单文件即用)
+curl -O https://gitea.ppuc.lssa.fun/Shuery/MediaOrganizer/raw/branch/main/dist/media_organizer
+chmod +x media_organizer
+
+# 2. 首次运行:自动生成配置模板(询问时输入 y)
+./media_organizer /downloads /media
+
+# 3. 填写 TMDB 密钥
+chmod 600 mo_config/config.json # 配置含密钥,权限收紧
+# 编辑 mo_config/config.json,至少填写 TMDB_API_RA_TOKEN
+
+# 4. 干运行预览(零副作用:不建链接、不写缓存、不写日志)
+./media_organizer --dry-run /downloads /media
+
+# 5. 正式运行
+./media_organizer /downloads /media
+```
> [!WARNING]
>
-> 源目录与目标目录必须在**同一文件系统**上,否则无法创建硬链接(脚本启动时会自动检测)。
+> 源目录与目的目录必须在**同一文件系统**上,否则无法创建硬链接(脚本启动时会自动检测并报错)。
+
+> [!TIP]
+>
+> 需要 TMDB API 密钥?前往 [TMDB 官网](https://www.themoviedb.org/settings/api) 免费申请;AI 密钥可选用 [DeepSeek](https://platform.deepseek.com) 等任意 OpenAI 兼容端点(`AI_BASE_URL`/`AI_MODEL` 可配)。
---
-## 3. 快速开始
+## 📦 获取项目
-### 3.1 首次运行(生成配置)
-
-```bash
-# 首次运行会提示创建配置模板
-./dist/media_organizer /downloads /media
+```shell
+# 自托管 Gitea:SSH 或 HTTPS 克隆
+git clone git@gitea.ppuc.lssa.fun:Shuery/MediaOrganizer.git
+# 或
+git clone https://gitea.ppuc.lssa.fun/Shuery/MediaOrganizer.git
```
-1. 若无 TMDB 密钥,会询问是否生成 `config.json` 模板 → 输入 `y`
-2. 在 `mo_config/config.json` 中填写 `TMDB_API_RA_TOKEN`
-3. 设置权限:`chmod 600 mo_config/config.json`
+- **免构建即用**:`dist/media_organizer` 随仓库提交,Gitea 可 raw 直接下载,无需任何工具链
+- **从源码构建**(约 1 秒,需 Ruby + bashly):`./build.sh`
+- 开发配套文档:[`CONTEXT.md`](CONTEXT.md)(领域术语表)、[`docs/PROJECT_REPORT.md`](docs/PROJECT_REPORT.md)(技术报告)、[`docs/adr/`](docs/adr/)(23 份架构决策记录)
-### 3.2 干运行测试
+---
-```bash
-# --dry-run 零持久化副作用:不创建链接、不写缓存、不写日志(网络请求照常但不落盘)
+## 🧭 命令行模式与选项
+
+一次调用对应一种**模式(mode)**,由目录参数个数与修饰标志共同决定:
+
+| 模式形状 | 调用形式 | 行为 |
+| ------------------- | ------------------------------------ | --------------------------------------------------------------- |
+| `organize` | `[选项] <源目录> <目的目录>` | 正常整理(使用缓存) |
+| `organize + update` | `--update-cache <源目录> <目的目录>` | 整理并强制重取对应缓存 |
+| `cache-only` | `--update-cache <源目录>` | 仅更新缓存,不整理(更新完退出) |
+| `list` | `--list-cache [关键词]` | 列出缓存条目(可选关键词按类型/路径/参数过滤) |
+| `export-map` | `--export-map [目录]` | 生成源→目标对照表 markdown + 伴生纠错账本 `.ledger.json` |
+| `rerun` | `--rerun <账本文件>` | 手动纠错重跑:只做链接层,不重新识别(可叠加 `--dry-run` 预览) |
+
+**选项**:
+
+| 选项 | 说明 |
+| ---------------------------------------- | ------------------------------------------------------------ |
+| `-a, --automated` | 自动化模式:写入日志文件,跳过无法处理的项目(适合 cron) |
+| `--no-automated` | 取消自动化模式(覆盖环境变量 / config.json 设置) |
+| `--dry-run` | 干运行:不创建链接、不写缓存、不写日志;网络请求照常但不落盘 |
+| `--no-dry-run` | 取消干运行(覆盖环境变量 / config.json 设置) |
+| `--refresh-cache` | 清空 TMDB 缓存后执行(保留特典映射等脚本学习数据) |
+| `--update-cache` | 强制重取对应缓存条目(与 `--list-cache` 互斥) |
+| `--src-dir <目录>` / `--dest-dir <目录>` | 以选项形式指定源/目的目录(与位置参数互斥,二选一通道) |
+| `-h, --help` / `--version` | 帮助 / 版本(退出码 0) |
+
+> [!NOTE]
+>
+> **退出码分级**:`0` = 全部成功;`1` = 运行期错误;`2` = 用法错误(未知选项、参数个数、模式冲突);`3` = 部分失败(非预期跳过 / 请求失败 / 链接失败 > 0,供 cron 感知)。
+
+---
+
+## 📖 使用示例
+
+```shell
+# 基础整理
+./dist/media_organizer /downloads /media
+
+# 干运行预览(推荐先跑一次)
./dist/media_organizer --dry-run /downloads /media
-```
-### 3.3 正式运行
-
-```bash
-./dist/media_organizer /downloads /media
-```
-
-### 3.4 自动化模式(适合定时任务)
-
-```bash
-# 写入 /var/log,跳过无法处理的文件
-./dist/media_organizer -a /downloads /media
-
-# 配合 cron 定时运行
+# 自动化模式 + cron 定时增量整理(已链接/冷却条目自动跳过,只处理新文件)
0 3 * * * /path/to/dist/media_organizer -a /downloads /media
+
+# 缓存维护
+./dist/media_organizer --list-cache # 列出全部缓存条目
+./dist/media_organizer --list-cache 刀剑 # 关键词过滤
+./dist/media_organizer --update-cache /downloads # 仅刷新缓存
+./dist/media_organizer --update-cache /downloads /media # 刷新并整理
+./dist/media_organizer --refresh-cache /downloads /media # 清空 TMDB 缓存后整理
+
+# 手动纠错闭环:导出对照表 → 编辑账本 → 重跑
+./dist/media_organizer --export-map /downloads /media # 生成 mo_map/*.md + *.ledger.json
+# 编辑 *.ledger.json:修改 dest 路径,将 action 改为 "rerun"
+./dist/media_organizer --rerun mo_map/downloads-xxxx.ledger.json # 按账本重跑(不重新识别)
```
> [!TIP]
>
-> 完整配置项说明见 [配置文件详解](#8-配置文件详解),环境要求见 [依赖与环境要求](#2-依赖与环境要求)。
+> 纠错结果会被学习到 `corrections.json`:同命名系列文件下次识别**直接命中**你指定的目标,无需再次修正。
---
-## 4. 命令行选项
+## 📊 架构与执行流程
-一次调用对应一种**模式(mode)**,由目录参数个数与 `--update-cache` 修饰标志共同决定:
+### 配置优先级链
-| 模式形状 | 调用形式 | 行为 |
-| ------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
-| `organize` | `[选项] <源目录> <目的目录>` | 正常整理(使用缓存) |
-| `organize + update` | `--update-cache <源目录> <目的目录>` | 整理并强制重取对应缓存 |
-| `cache-only` | `--update-cache <源目录>` | 仅更新缓存,不整理(更新完退出) |
-| `list` | `--list-cache [关键词]` | 列出缓存条目(不接受目录参数) |
-| `rerun` | `--rerun <账本文件>` | 手动纠错重跑:按 `--export-map` 伴生 `.ledger.json` 中 `action="rerun"` 的条目重新硬链接(不重新识别;可叠加 `--dry-run` 预览) |
+配置值统一按 **CLI > 环境变量 > config.json > 内置默认值** 解析,同一键只取最高优先级来源;CLI 显式设置(含 `--no-*` 反选)覆盖一切。
-**选项**:
+```mermaid
+flowchart LR
+ A["CLI 参数
--dry-run / --src-dir ..."] --> P{"最终配置值"}
+ B["环境变量
TMDB_LANG / AI_MODEL ..."] --> P
+ C["config.json
mo_config/ 白名单键"] --> P
+ D["内置默认值
CONFIG_DEFS 单一数据源"] --> P
+```
-| 选项 | 说明 |
-| ----------------------- | ---------------------------------------------------------------------------------------------------- |
-| `-a, --automated` | 自动化模式:写入日志文件,跳过无法处理的项目 |
-| `--no-automated` | 取消自动化模式(覆盖环境变量/config.json 设置) |
-| `--dry-run` | 干运行:不创建链接、不写缓存、不写日志;网络请求照常但不落盘 |
-| `--no-dry-run` | 取消干运行(覆盖环境变量/config.json 设置) |
-| `--refresh-cache` | 清空 TMDB 缓存后执行(保留特典映射等脚本学习数据;可与任意模式叠加) |
-| `--rerun <文件>` | 手动纠错重跑(独立形状;与 `--list-cache`/`--update-cache`/`--export-map` 冲突,可叠加 `--dry-run`) |
-| `--update-cache` | 强制重取对应缓存条目(与 `--list-cache` 互斥) |
-| `--list-cache [关键词]` | 列出缓存内容(哈希/类型/路径/参数/获取时间);可选关键词按类型/路径/参数过滤 |
-| `--src-dir <目录>` | 指定源目录(与位置参数互斥) |
-| `--dest-dir <目录>` | 指定目的目录(与位置参数互斥) |
-| `-h, --help` | 显示帮助信息(退出码 0) |
-| `--version` | 显示版本号(退出码 0) |
-
-**目录参数**:要么 2 个位置参数,要么 `--src-dir + --dest-dir` 成对指定,两种通道不可混用。`--` 之后的所有参数一律视为位置参数(目录名以 `-` 开头时使用)。
-
-**语法**:选项可出现在位置参数之前或之后(permute);带参数选项支持 `--opt=值` 与 `--opt 值` 两种形式(值以 `-` 开头时必须用 `=` 形式)。
-
-**退出码**:0 = 全部成功;1 = 运行期错误;2 = 用法错误(未知选项、参数个数、模式冲突,附一行用法提示);3 = 部分失败(非预期跳过 / 请求失败 / 链接失败 > 0,供 cron 感知)。
-
-**冲突规则**(硬报错,不再静默忽略):
-
-- `--list-cache` 不接受目录参数,且不能与 `--update-cache` / `--dry-run` / `--automated` 同时使用
-- 仅更新缓存(1 个目录)不接受 `--dry-run` / `--automated`(无链接可预览、无整理步骤)
-- 0 个目录 + `--update-cache`:报错(缺源目录)
-
----
-
-## 5. 执行流程总览
-
-### 5.1 main() 生命周期
-
-> 以下为**每一个原子化操作**。各识别函数内部细节见 [第 7 章](#7-执行判断详解)。
+### 识别搜索重试链
```mermaid
flowchart TD
- START(["main 脚本入口"]) --> PA["parse_args
索引遍历 逐项分发(permute 任意顺序)"]
- PA --> CASE{"case arg"}
- CASE -- "-a / --automated / --no-automated" --> OPT1["AUTOMATED=true/false"]
- CASE -- "--dry-run / --no-dry-run" --> OPT2["DRY_RUN=true/false"]
- CASE -- "--refresh-cache" --> OPT3["REFRESH_CACHE=true"]
- CASE -- "--update-cache" --> OPT4["UPDATE_CACHE=true"]
- CASE -- "--list-cache [过滤词] / --list-cache=过滤词" --> OPT5["LIST_CACHE=true
下一位非选项则作过滤词"]
- CASE -- "--src-dir / --dest-dir
(空格或 = 形式)" --> OPT6["收集命名目录"]
- CASE -- "--" --> DASH["后续均为位置参数"]
- CASE -- "-h / --help" --> HELP["show_help 打印帮助"]
- CASE -- "--version" --> VER["打印版本号"]
- CASE -- "未知 -*" --> UNK["_usage_error 报错
附用法提示"]
- CASE -- "其他" --> POS["收集为位置参数"]
- OPT1 --> PA
- OPT2 --> PA
- OPT3 --> PA
- OPT4 --> PA
- OPT5 --> PA
- OPT6 --> PA
- DASH --> PA
- HELP --> EXIT0([退出码 0])
- VER --> EXIT0
- UNK --> EXIT2([退出码 2])
- POS --> SHAPE["形状推导
通道互斥校验
list=0 目录 / cache-only=1 / organize=2"]
- SHAPE -- "冲突或个数不符" --> UNK
- SHAPE --> LC{"--list-cache?"}
+ A["识别搜索
identify_tv_show / identify_movie"] --> B["zh-CN 主搜索"]
+ B -- "无结果" --> C["en-US 重搜"]
+ C -- "无结果" --> D["规则别名 search_aliases"]
+ D -- "无结果" --> E["目录名兜底
(清洗 + 剥季后缀)"]
+ E -- "无结果" --> F["MAL 候选回搜
(SEARCH_FALLBACK_MAL=true,可选)"]
+ F -- "无结果" --> G["后缀季二次剥离
(Railgun T / II / 2nd)"]
+ G -- "仍失败" --> H["交 AI 待处理
(失败可逆跳过,下次自动重试)"]
+```
- subgraph CONFIG["① 配置与初始化"]
- LC -- "是" --> ICD2["init_cache_dir
解析缓存路径 建子目录"]
- ICD2 --> LCL["cache_list 按过滤词列出
列出后退出"]
- LC -- "否" --> CFG["load_config"]
- CFG --> CFG1["find_config_file 定位 config.json"]
- CFG1 --> CFG2["check_secure_file 校验 600 权限"]
- CFG2 --> CFG3["白名单键提取
从 CONFIG_TEMPLATE 取键名集合
逐键 parse_config_key 读 config.json
(文件永不执行)"]
- CFG3 --> CFG4["应用配置链
CLI > 环境变量 > config.json > 默认值
扩展名/curl/缓存 TTL 等全部配置项"]
- CFG4 --> IC["init_colors 初始化色彩"]
- IC --> SA["select_auth 认证选择
(决策见 7.1)"]
- SA --> CD["check_dependencies"]
- CD --> CD1["依次验证 curl / jq / ffprobe"]
- CD1 -- "任一缺失" --> CDE["报错缺少命令"]
- CD1 -- "全部就绪" --> ISM["init_special_map 特典映射"]
- ISM --> ISM1["解析 SPECIAL_MAP_FILE 路径"]
- ISM1 --> ISM2{"文件存在?"}
- ISM2 -- "否" --> ISM3{"自动化模式?"}
- ISM3 -- "否" --> ISM4["交互创建默认 keymap
SPECIAL_KEYMAP_TEMPLATE 落盘"]
- ISM3 -- "是" --> ISM5["跳过创建"]
- ISM4 --> ISM6["load_special_map"]
- ISM5 --> ISM6
- ISM2 -- "是" --> ISM6
- ISM6 --> ISM7["第一遍:读 RAW_SPECIAL_MAP
值=数组或字符串引用"]
- ISM7 --> ISM8["第二遍:resolve_special_value
递归展开引用 → SPECIAL_MAP 统一 JSON 数组"]
- ISM8 --> ISO["init_season_offset 季偏移"]
- ISO --> ISO1["内嵌 SEASON_OFFSET_TEMPLATE
→ SEASON_OFFSET_MAP"]
- ISO1 --> ISO2["外部 season_offsets.json 覆盖
(剧名小写 或 id: 键)"]
- ISO2 --> ISO3{"文件不存在?"}
- ISO3 -- "是" --> ISO4["由 SEASON_OFFSET_MAP 生成默认 JSON"]
- ISO3 -- "否" --> UPD
- ISO4 --> UPD{"cache-only?
--update-cache 且仅 1 个目录"}
- UPD -- "是" --> VCO["validate_directories
校验源目录存在可读"]
- VCO --> ICCO["init_cache_dir
(含 --refresh-cache 清空)"]
- ICCO --> UC["update_cache
扫描源目录唯一查询
强制重取覆盖缓存后退出"]
- UPD -- "否" --> VD["validate_directories
源目录存在可读
目的目录可写/可创建
(干运行不创建目录)"]
- VD --> CHSD{"干运行?"}
- CHSD -- "是" --> ICD
- CHSD -- "否" --> CHS["check_hardlink_support"]
- CHS --> CHS1["touch 源目录测试文件"]
- CHS1 --> CHS2["ln 测试到目的目录"]
- CHS2 -- "失败" --> CHSE["报错无法创建硬链接"]
- CHS2 -- "成功" --> CHS3["清理两个测试文件"]
- CHS3 --> ICD["init_cache_dir"]
- ICD --> ICD1["解析 CACHE_DIR 路径"]
- ICD1 --> ICD2A{"--refresh-cache?"}
- ICD2A -- "是" --> ICD3["cache_clear 清空缓存
(干运行跳过并警告)"]
- ICD2A -- "否" --> ICD4
- ICD3 --> ICD4["mkdir search/movie|tv tv movie 子目录
(干运行跳过)"]
- ICD4 --> TRAP["trap ERR 注册错误处理"]
- TRAP --> BANNER["打印版本/源/目的/运行模式"]
+> [!NOTE]
+>
+> **ID 映射优先于一切搜索**:`match_rules.json` 的 `id_maps` 标题命中后直接使用指定 TMDB ID、完全跳过搜索(适合 TMDB 多候选易选错、同名动画/真人版场景);`search_aliases` 仅在常规搜索无结果时介入。
+
+### AI:判断引擎与执行引擎分离
+
+```mermaid
+flowchart TD
+ subgraph AI["🤖 AI 只做判断"]
+ A["输入:文件信息 + 目录链 +
TMDB 搜索结果 + 时长/分辨率
+ 同目录兄弟文件"] --> B["输出:choice / season_shift
/ search_term / artist /
特典匹配关键字"]
end
-
- subgraph PIPE["② 三阶段流水线"]
- BANNER --> SF["scan_files 扫描"]
- SF --> SF1["find 视频扩展名文件
clean_name 去方括号后 sort"]
- SF1 --> SF2["填充 VIDEO_FILES 数组"]
- SF2 --> SF3["find 音频扩展名文件
填充 AUDIO_FILES"]
- SF3 --> PM["识别池 process_video + process_audio
并发 MEDIA_WORKERS 个 worker
子进程 emit 协议行 → 父进程合并登记
(register_* 写入目的映射+结局账本)"]
- PM --> PM1{"worker 处理单个文件
遍历 VIDEO_FILES / AUDIO_FILES"}
- PM1 -- "是" --> PM2["parse_media_filename
→ type|title|year|season|episode|fragment
(决策见 7.2)"]
- PM2 --> PM3{"type 分支"}
- PM3 -- "movie" --> PM4["identify_movie 搜索电影
(见 6/7)"]
- PM3 -- "tv" --> PM5["identify_tv_show 搜索剧集
(见 7.3/7.4)"]
- PM3 -- "skip" --> PM6["电影特典跳过不整理
自动化写 SKIP_LOG_FILE"]
- PM3 -- "unknown" --> PM7["PENDING_AI_SEARCH 记录
VIDEO_DEST_MAP=PENDING_AI"]
- PM4 --> PM8{"dest 非空?"}
- PM5 --> PM8
- PM6 --> PM1
- PM7 --> PM1
- PM8 -- "是" --> PM9["VIDEO_DEST_MAP[目标|子目录|文件名]"]
- PM8 -- "否" --> PM10["VIDEO_DEST_MAP=PENDING_AI"]
- PM9 --> PM1
- PM10 --> PM1
- PM1 -- "结束" --> PAUD["process_audio 音频处理"]
- PAUD --> PAUD1["向上定位 CD 目录
([日期]/专辑/格式特征)"]
- PAUD1 --> PAUD2["parse_cd_dir 解析 歌手|专辑"]
- PAUD2 --> PAUD3["目标 Music/歌手/专辑[/子碟] 入 MAP"]
- PAUD3 --> RAB{"AI 密钥存在 且 有待处理项?"}
- RAB -- "否" --> LM
- RAB -- "是" --> AIB["run_ai_batch 第二阶段
(见 7.5)"]
- AIB --> AIB1["ai_batch_request 一次合并请求
search + special"]
- AIB1 --> AIB2["构造输入 JSON → curl AI 接口"]
- AIB2 --> AIB3["解析:search 追加词/年/类别
special 写 keymap 文件+内存"]
- AIB3 --> AIB4{"遍历 PENDING_AI 还有?"}
- AIB4 -- "是" --> AIB5["parse 重识别
AI 纠正词重搜
media_type 定 movie/tv"]
- AIB5 --> AIB6{"识别成功?"}
- AIB6 -- "是" --> AIB7["记录目标路径"]
- AIB6 -- "否" --> AIB8["回退命名(原始标题)"]
- AIB7 --> AIB4
- AIB8 --> AIB4
- AIB4 -- "结束" --> LM["link_media 第三阶段硬链接
(见 7.6)"]
- LM --> LM1{"遍历 VIDEO_DEST_MAP 还有?"}
- LM1 -- "是" --> LM2{"目标路径有效?"}
- LM2 -- "否" --> LMSKIP["skip++"]
- LM2 -- "是" --> LM3["mkdir -p 目标目录"]
- LM3 -- "失败" --> LMSKIP
- LM3 -- "成功" --> LM4["hardlink_or_dryrun 主文件"]
- LM4 -- "成功" --> LM5["处理配套文件
base_name.* → is_companion
→ 保语言后缀 → 硬链接"]
- LM4 -- "失败" --> LMSKIP
- LM5 --> LM1
- LMSKIP --> LM1
- LM1 -- "结束" --> SUM["汇总输出
成功 hardlink_count 个
跳过 skip_count 个"]
+ subgraph SCRIPT["📜 脚本负责执行"]
+ C["命名格式化(年份 / Season 补零
/ SxxExx / 目录结构)"]
+ D["硬链接 / 账本 / 汇总"]
end
+ B --> C --> D
```
-> [!NOTE]
->
-> **增量与冷却(v9.4)**:识别池入口(`process_one_file`)先查已链接账本与失败冷却账本——已链接(源/目标 inode 有效)→ 结局 `already_linked` 跳过;冷却未到期(`request_failed`/`skip_unidentified`)→ 结局 `cooldown` 跳过。两者均不触发 parse/识别,干运行不启用(展示全貌)。账本在运行末尾统一落盘(`linked.json` + `fail_cooldown.json`,位于 `mo_cache/media_organizer/`)。
-
-> [!NOTE]
->
-> **匹配改进(v9.4)**:① 识别候选结构评分——TV 搜索无精确匹配时按 `(季数覆盖文件季号, 名称归一化包含, 特典候选含 Season 0)` 评分选候选;② 目录名兜底——搜索失败用父目录名重搜(源根散放除外,优先于 MAL);③ 纠错学习——`--rerun` 修正写 `corrections.json`,识别入口按 `clean_name(文件名)小写` 前置命中(优先于失败冷却);④ 后缀季模糊——词表失败后 Levenshtein 距离匹配季名末尾窗口;⑤ 公共子串剥离——无特征剧集目录用公共前后缀剔除后提取集号。
-
-> [!NOTE]
->
-> **内部重构(v9.6)**:搜索链抽象——`tv_search_once`/`movie_search_once` 统一尝试器收敛识别链全部搜索层(zh→en→别名→目录→MAL→后缀剥离),失败语义(主搜索短路/后续层忽略)不变,行为由新增 identify 搜索链测试锁定;配置单点化——`CONFIG_DEFS` 单一数据源驱动模板生成与默认值加载,新增配置键只需改一处。
-
-> [!NOTE]
->
-> **AI 鲁棒性(v9.4)**:AI 响应解析走四级容错链(直接 → 围栏 → 思考链剥离 → 最外层 {} 块),推理模型的 ``/「思考:」输出不再导致整批失效;`AI_SAVE_CASES=true` 时请求输入与原始响应落盘 `mo_cache/media_organizer/ai_cases/`;match_entries 附带同目录 siblings 上下文(剧场版混 TV 场景判断依据)。
-
-### 5.2 配置优先级
-
-**值优先级**(同一个配置键取最高来源):**CLI > 环境变量 > config.json > 内置默认值**。CLI 显式设置(含 `--no-*` 反选)覆盖一切;config.json 仅读取白名单键(键名集合取自内嵌模板),文件内容永不执行。
-
-**配置文件查找**遵循严格的优先级(这是"文件在哪里"的问题,与上面的值优先级正交):
-
-$$P = \underbrace{\text{环境变量}}_{1^\text{st}} \succ \underbrace{\text{执行目录}\ (PWD)}_{2^\text{nd}} \succ \underbrace{\text{脚本目录}\ (SCRIPT\_DIR)}_{3^\text{rd}}$$
-
-```mermaid
-flowchart TD
- A[查找配置] --> B{环境变量已指定?
如 SPECIAL_MAP_FILE=...}
- B -- 是 --> B1[使用环境变量路径]
- B -- 否 --> C{执行目录已有文件?
配置类文件: $PWD/mo_config/xxx
(config.json / special_maps / ...)}
- C -- 是 --> C1[使用执行目录路径]
- C -- 否 --> D[使用脚本目录路径
$SCRIPT_DIR/mo_config/xxx]
-```
-
-> [!NOTE]
->
-> **三类数据分离**:`mo_config/` = 用户配置类文件(`config.json` + 特典映射/词表、跳过目录、季偏移——用户可编辑,脚本 AI 学习也会写回);`mo_cache/tmdb/` = TMDB API 响应缓存(可再生,`--refresh-cache` 清除);`mo_cache/media_organizer/` = 脚本运行状态(已链接账本/失败冷却)。旧版文件(各旧文件名与旧位置)在首次运行时**自动迁移**。
-
-**默认文件位置**(优先级:环境变量 > 执行目录 > 脚本目录):
-
-| 文件 | 执行目录 | 脚本目录 |
-| -------------------------- | -------------------------------------- | --------------------------------------------- |
-| `config.json` | `$PWD/mo_config/config.json` | `$SCRIPT_DIR/mo_config/config.json` |
-| `skip_directories.json` | `$PWD/mo_config/skip_directories.json` | `$SCRIPT_DIR/mo_config/skip_directories.json` |
-| `special_maps.json` | `$PWD/mo_config/special_maps.json` | `$SCRIPT_DIR/mo_config/special_maps.json` |
-| `special_keywords.json` | `$PWD/mo_config/special_keywords.json` | `$SCRIPT_DIR/mo_config/special_keywords.json` |
-| `season_offsets.json` | `$PWD/mo_config/season_offsets.json` | `$SCRIPT_DIR/mo_config/season_offsets.json` |
-| TMDB 缓存 `mo_cache/tmdb/` | `$PWD/mo_cache/tmdb/` | `$SCRIPT_DIR/mo_cache/tmdb/` |
+- AI 输出**交叉验证**:特典学习产物必须是 TMDB 候选列表成员,防幻觉污染
+- 失败语义:AI 请求失败 → 剩余条目显式跳过(**可逆**,下次运行自动重试);仅"AI 成功且判断无解"才走回退命名
---
-## 6. TMDB API 调用详解
+## 🗂️ 输出目录结构
-> 脚本通过 TMDB v3 API 识别电影与剧集。所有请求经统一的 `tmdb_api` 函数发起,配置项控制重试/超时/延迟。
-
-### 6.1 认证与基础函数 `tmdb_api`
-
-- **基础 URL**:`https://api.themoviedb.org/3`(常量 `TMDB_API_BASE_URL`)
-- **认证方式**(二选一):
- - `TMDB_API_RA_TOKEN`(优先):请求头 `Authorization: Bearer `
- - `TMDB_API_KEY`:URL 参数 `api_key=`
-- **固定参数**:`language=`(默认 `zh-CN`,影响返回的中/英文名)
-- **curl 行为**:`GET`、重试 `TMDB_CURL_RETRY` 次、连接超时 `TMDB_CURL_CONNECT_TIMEOUT`、最大时长 `TMDB_CURL_MAX_TIME`、`-fS`(HTTP 错误时返回非零)
-- **返回值**:JSON 响应写入 **stdout**;失败返回非零退出码并记录 `[错误] TMDB 请求失败`
-
-```bash
-# 调用示例(脚本内部)
-tmdb_api "/search/movie" "query=Inception" "year=2010"
-```
-
-### 6.2 所有调用点一览
-
-| 端点 | 用途 | 附加参数 | 返回的关键字段 |
-| --------------------- | ---------------------- | ----------------------- | ---------------------------------------- |
-| `/search/movie` | 电影搜索 | `query`、`year`(可选) | `results[0].id/title/release_date` |
-| `/search/tv` | 剧集搜索 | `query` | `results[0].id/name/first_air_date` |
-| `/movie/{id}` | 电影详情(识别后补调) | - | `title/release_date/overview` 等完整信息 |
-| `/tv/{id}` | 剧集详情 | - | `number_of_seasons` |
-| `/tv/{id}/season/1` | 第一季集数 | - | `episodes` 数组长度(仅季偏移需要) |
-| `/tv/{id}/season/0` | 特典季数据 | - | `episodes[].episode_number/name` |
-| `/tv/{id}/season/{N}` | 第 N 季集名 | - | `episodes[].name` |
-
-> 注:AI 辅助阶段会重复调用 `/search/movie`、`/search/tv` 用 AI 纠正后的搜索词重新搜索。
-
-### 6.3 调用顺序(识别一个剧集文件时)
-
-```mermaid
-flowchart TD
- A["identify_tv_show"] --> A1["归一化季/集号
10# 去前导零"]
- A1 --> B["tmdb_api /search/tv 搜索剧集
统一缓存封装(cache_get→curl→cache_put)"]
- B --> C{"results[0].id 非空?"}
- C -- "否" --> PENDING["记录到 AI 待处理
返回失败"]
- C -- "是" --> D["tmdb_api /tv/id 获取详情
取 number_of_seasons"]
- D --> E{"season>1 且
total_seasons F["tmdb_api /tv/id/season/1
取第一季集数用于偏移"]
- E -- "否" --> G
- F --> G["tmdb_api /tv/id/season/0
特典季数据(season0_json)"]
- G --> H{"取季集名(按需)"}
- H -- "season==0" --> H0["tmdb_api /tv/id/season/0
按集号取特典集名"]
- H -- "普通季" --> I["tmdb_api /tv/id/season/N
取第 N 季集名"]
- H0 --> J
- I --> J["safe_printf_int 补零
构造目标路径输出"]
-```
-
-### 6.4 缓存方案(v9.1:TMDB 缓存与脚本缓存分离)
-
-> v9.0 将缓存方案完全重做:**缓存目录结构镜像 TMDB API 端点路径**,一切请求数据(搜索、详情、各季)全量缓存,二次运行零外部请求。缓存根目录 `mo_cache/` 仅存放可再生数据(`tmdb/` = API 响应缓存,`media_organizer/` = 运行状态账本);**用户配置类文件**(特典映射/词表、跳过目录、季偏移,可编辑 + 脚本可写回)统一位于 `mo_config/`。
-
-**缓存目录结构**(默认 `mo_cache/`):
+脚本在目的目录创建 **Jellyfin 官方规范**的结构(根目录名默认跟随系统语言,`FOLDER_*` 可配):
```text
-mo_cache/
-├── tmdb/ # TMDB API 响应缓存(--refresh-cache 仅清空此区)
-│ ├── search/
-│ │ ├── movie/.json # 搜索缓存(包裹格式)
-│ │ └── tv/.json
-│ ├── tv/
-│ │ ├── ..json # 剧集详情(原始 JSON;语言段随 TMDB_LANG)
-│ │ └── /season/..json # 每季数据(含 season 0 特典季)
-│ └── movie/
-│ └── ..json # 电影详情(原始 JSON)
-└── media_organizer/ # 脚本运行状态(不随 --refresh-cache 清除)
- ├── linked.json # 已链接账本(增量标记)
- └── fail_cooldown.json # 失败冷却
-
-mo_config/ # 用户配置类文件(可编辑 + 脚本可写回)
-├── config.json # 主配置
-├── special_maps.json # 特典映射(AI 学习写回)
-├── special_keywords.json # 特典词表(AI 学习写回)
-├── skip_directories.json # 跳过目录词表(AI 学习写回)
-└── season_offsets.json # 季偏移(运行时生成)
+/media
+├── Movies/ # 🎬 电影:目录名与文件名同名
+│ └── Inception (2010)/
+│ └── Inception (2010).mkv
+├── Shows/ # 📺 剧集
+│ ├── Breaking Bad (2008)/
+│ │ └── Season 01/
+│ │ ├── S01E01 - Pilot.mkv
+│ │ └── S01E01 - Pilot.srt ← 伴随文件(保留语言标签)
+│ └── Some Anime (2020)/
+│ └── Season 00/ # ⭐ 特典
+│ ├── S00E01 - 迷你动画「猫猫的独语」第1话:白粉.mkv ← TMDB 匹配(S00E 编号)
+│ ├── S00CM01 - CM01.mkv ← 未匹配(S00{类型}{编号})
+│ └── S00Menu01 - Menu01.mkv
+├── Music/ # 🎵 CD 音乐(flac/mp3)
+│ └── 平井大/
+│ └── 幸せのレシピ/
+│ └── 01. 幸せのレシピ.flac
+└── MusicVideos/ # 🎤 音乐视频(v9.6)
+ └── 周杰伦/
+ ├── 晴天.mkv
+ └── 七里香.mp4
```
-> 旧版自动迁移(仅执行一次,干运行不迁移):① 布局迁移 `mo_cache/{movie,search,tv}` → `mo_cache/tmdb/`;② 命名/归属迁移:配置类文件统一归入 `mo_config/` 并改用新名(`special_keymap`→`special_maps`、`special_words`→`special_keywords`、`skip_dirs`→`skip_directories`、`season_offset`→`season_offsets`),旧位置(`mo_config/`、`mo_cache/media_organizer/`)与旧名(含更早 `mo_` 前缀)均自动迁移;③ 配置格式迁移:`env`/`mo_env` 文本 → `config.json`(JSON,仅白名单键自动转换)。
+**命名规则**:
-**搜索缓存文件格式**(包裹 JSON,含查询参数与获取时间):
+| 类型 | 规则 |
+| ----------------- | ------------------------------------------------------------------------------------------------- |
+| 电影 | `Movies/标题 (年份)/标题 (年份).扩展名` |
+| 剧集 | `Shows/标题 (年份)/Season NN/S{季}E{集} - 集名.扩展名`(多集区间 `S01E01-E02 - 首集名 - 末集名`) |
+| 特典(TMDB 匹配) | `Shows/标题 (年份)/Season 00/S00E{集号} - TMDB集名.扩展名` |
+| 特典(未匹配) | `Shows/标题 (年份)/Season 00/S00{类型}{编号} - 片段.扩展名`(如 `S00CM01`、`S00Menu01`) |
+| 音乐 | `Music/歌手/专辑/[子碟]/曲目.扩展名`(一夹一专辑) |
+| 音乐视频 | `MusicVideos/歌手/歌名.扩展名`(artist 元数据 → 目录名 → `FOLDER_UNKNOWN`) |
+| 伴随文件 | 与主视频同名,保留语言标签(`.zh.srt`、`.jp.ass`);**视频 > 音频** 归属判定 |
-```json
-{
- "query": "Sword Art Online II",
- "year": "",
- "lang": "zh-CN",
- "fetched_at": 1786169195,
- "empty": false,
- "data": { "...TMDB 原始响应..." }
-}
-```
-
-**核心机制**:
-
-- **`tmdb_api` 统一封装**:所有 TMDB 请求必经此函数。先 `cache_get` 查缓存——命中直接返回;未命中加锁后 `curl` 请求,成功后 `cache_put` 落盘。
-- **`cache_key`**:将查询参数拼成 `query=xxx&lang=zh-CN` 形式(末尾固定加 `&lang`)。
-- **`cache_path`**:搜索请求用 key 的 md5 哈希作文件名(`search/movie|tv/.json`,位于 `tmdb/` 下);详情/季请求从 key 中提取 id 作路径并**附加语言段**(`tv/..json`、`tv//season/..json`、`movie/..json`)——切换 `TMDB_LANG` 自动 miss 重新拉取。
-- **并发去重(flock + 双检)**:识别池多 worker 可能同时 miss 同一查询——`tmdb_api` 未命中后用 `flock` 跨进程互斥(锁文件在 `/tmp`),锁内**双检**缓存:等待者直接命中先写者的结果,同一查询只发一次请求。
-- **`cache_put_empty`**:搜索**空结果**(`results:[]`)写 `empty:true` 哨兵(3 天短 TTL)——查无此片在新片出现后自动重新搜索;请求失败(重试耗尽)不写哨兵,下次运行重试。
-- **过期机制**:普通缓存 `CACHE_TTL_DAYS`(默认 30 天)、空哨兵 `CACHE_EMPTY_TTL_DAYS`(默认 3 天),超时按 mtime 判定后重新请求。空哨兵在 TTL 内由 `cache_empty_fresh` 识别并跳过重复请求(不重新 curl),TTL 过后才重试。
-- **季号归一化**:识别流程将 `parse_media_filename` 产出的季/集号(如 `S01E05` 的 `01`/`05`)归一化为无前导零十进制(`1`/`5`),保证季缓存路径 `tmdb/tv/{id}/season/1.json` 与 `--update-cache` 的整数循环一致,缓存互可命中。
-- **原子写**:`cache_put` 先写临时文件再 `rename`,避免并发/中断产生半截 JSON。
-- **相同标题分类型**:同一标题(如某作品既有剧场版又有 TV 版)会分别缓存 `search/movie` 与 `search/tv`,识别时各取所需。
-- 请求间延迟 `TMDB_DELAY` 秒,避免触发限流(约 4 请求/秒)。
-
-**缓存维护命令**:
-
-```bash
-# 查看缓存(哈希/类型/路径/参数/获取时间),可按关键词过滤
-./dist/media_organizer --list-cache
-./dist/media_organizer --list-cache "tv"
-
-# 仅更新缓存(1 个目录):扫描源目录所有唯一查询,强制重取并覆盖缓存后退出
-./dist/media_organizer --update-cache /downloads
-
-# 整理并强制重取(2 个目录):整理流水线内对处理的条目不信任陈旧缓存
-./dist/media_organizer --update-cache /downloads /media
-
-# 清空 TMDB 缓存后运行(特典映射等脚本学习数据保留)
-./dist/media_organizer --refresh-cache /downloads /media
-```
-
-> [!NOTE]
+> [!TIP]
>
-> **为何不再用内存缓存 + 同步文件**:v8 的 `SHOW_CACHE`/`SEASON_CACHE` 在识别函数(子 shell)内直接赋值不传播回父 shell,依赖 `CACHE_SYNC_FILE` 同步文件绕行,且无法缓存全部请求数据。v9.0 改为纯磁盘镜像缓存,天然规避子 shell 问题,且搜索/详情/季数据全量保存。
-
-### 6.5 源→目标对照表(--export-map,按分类展示)
-
-> 脚本运行完毕后,可生成**按分类展示**的源→目标对照表 markdown 文档,用于核对整理结果(源文件、目标文件、链接状态)。
-
-```bash
-# 生成对照表(默认输出到脚本目录/mo_map/<源目录名>-.md)
-./dist/media_organizer --export-map /downloads /media
-
-# 指定输出目录(空格形式会与位置参数冲突,须用 = 形式)
-./dist/media_organizer --export-map=/path/to/map /downloads /media
-```
-
-**文档结构**(分类依据 = 目的目录根名 `FOLDER_MOVIES`/`FOLDER_SHOWS`/`FOLDER_MUSIC`/`FOLDER_MUSICVIDEOS`/`FOLDER_UNKNOWN`):
-
-```markdown
-# 媒体整理对照表
-
-- 源目录 / 目的目录 / 生成时间 / 运行模式
-
-## 📊 分类汇总(N 条) ← 各类条目数一览
-
-- 🎬 电影:N 条 / 📺 节目:N 条 / ...
-
-## 🎬 电影(N 条) ← 每类独立小节与编号
-
-| # | 源文件 | 目标文件 | 链接状态 |
-
-## ⏭ 未完成(未识别/跳过/失败/待 AI)(N 条)
-
-## 链接状态汇总 ← 已链接/失败/跳过计数
-```
-
-> 链接状态在非干运行下**现场校验**(目标存在且与源同 inode);干运行标记 `🔄 干运行`。**伴随文件**(字幕/音轨等)在链接成功后登记映射,随主媒体归入对应大类(`📎 伴随文件`),未链接成功的不展示。0 条的分类不输出小节。**文件名命名**:`<源目录名>-<源目录绝对路径 md5 前 8 位>.md`——源目录名(sanitize 去非法字符,空名回退 `root`)保证可读,md5 前缀保证唯一与稳定(同一源目录多次运行互相覆盖)。
+> 剧集文件名**不含剧集名**——Jellyfin 通过父目录(`Shows/标题 (年份)/`)识别剧集,不影响刮削。撞名按官方多版本格式去重(文件名追加 `- 2`),同源旧链接按 inode 清理。
---
-## 7. 执行判断详解
+## 🛠️ 配置详解
-> 本章是脚本的**决策树**,展示每一个关键分支判断。箭头上的文字为判断条件,菱形为判断节点。
+### 配置文件体系
-### 7.1 TMDB 认证选择
+三类数据严格分离,各自独立生命周期:
-```mermaid
-flowchart TD
- A[select_auth] --> B{TMDB_API_RA_TOKEN 非空?}
- B -- 是 --> B1[Bearer 认证
TMDB_AUTH_TOKEN=RA_TOKEN]
- B -- 否 --> C{TMDB_API_KEY 非空?}
- C -- 是 --> C1[API Key 认证
TMDB_AUTH_TOKEN=API_KEY]
- C -- 否 --> D{自动化模式?}
- D -- 否 --> E[提示生成 config.json 模板]
- E --> E1{用户输入 y?}
- E1 -- 是 --> E2[生成模板 退出码 0]
- E1 -- 否 --> F[报错 退出码 1]
- D -- 是 --> F
-```
+```text
+mo_config/ # 用户配置类文件(可编辑 + 脚本可写回)
+├── config.json # 主配置(含 API 密钥,权限 600)
+├── special_maps.json # 特典映射(AI 学习写回)
+├── special_keywords.json # 特典识别词表(AI 学习写回)
+├── skip_directories.json # 跳过目录词表(AI 学习写回)
+├── season_offsets.json # 季数偏移(运行时生成)
+├── special_categories.json # 特典类别判定表(v9.6 外置)
+└── match_rules.json # 用户匹配规则(搜索别名 / ID 映射)
-### 7.2 文件类型识别(parse_media_filename)
-
-脚本按**顺序**尝试匹配,第一个命中的格式生效。所有格式均不命中时,**不再默认判为电影**,而是根据**媒体目录内正片数量**判断(v9.3,不依赖下载目录——合集种子可能把剧场版电影与剧集混放)。
-
-#### 7.2 主决策树(顺序匹配)
-
-```mermaid
-flowchart TD
- A["parse_media_filename
file → basename
base=去扩展名 ext=扩展名
clean_name 去方括号"] --> B{"匹配 Title (Year)?
^(.*)\([0-9]{4}\)$"}
- B -- "是" --> B1["movie 电影
title=去尾部空白
输出 movie|title|year"]
- B -- "否" --> C{"匹配 S##E##?
[\ ._-]*[Ss][0-9]{2}[Ee][0-9]{2}"}
- C -- "是" --> C1["tv 剧集
提取 season/episode
strip_season_suffix 去季后缀"]
- C -- "否" --> D{"匹配 #x##?
[0-9]{1,2}[xX][0-9]{2}"}
- D -- "是" --> D1["tv 剧集
提取 season/episode"]
- D -- "否" --> E{"特典识别?
(原子步骤见 7.2A)"}
- E -- "是" --> E1["特典处理
(原子步骤见 7.2A)"]
- E -- "否" --> F{"包含 Season 关键词?
[0-9]+(st|nd|rd|th)? Season"}
- F -- "是" --> F1["tv 季份
season=提取数字
episode=方括号 [N]"]
- F -- "否" --> G{"匹配方括号 [数字]?
且非 1080/720/480/2160/4320"}
- G -- "是" --> G1["tv 剧集
episode=[N] season=1
标题去掉 [N]"]
- G -- "否" --> H["回退:正片数量判断
(原子步骤见 7.2B)"]
+mo_cache/ # 缓存根目录(可再生)
+├── tmdb/ # TMDB API 响应缓存(--refresh-cache 仅清空此区)
+│ ├── search/movie|tv/.json # 搜索缓存(包裹格式,含空哨兵)
+│ ├── tv/..json # 剧集详情(语言段隔离)
+│ ├── tv//season/..json # 每季数据(含 season 0 特典季)
+│ └── movie/..json # 电影详情
+└── media_organizer/ # 脚本运行状态(不清除)
+ ├── linked.json # 已链接账本(增量标记)
+ ├── fail_cooldown.json # 失败冷却
+ ├── corrections.json # 纠错学习
+ ├── mal/ # MAL 搜索缓存
+ └── ai_cases/ # AI 用例落盘(AI_SAVE_CASES=true 时)
```
> [!NOTE]
>
-> **v9.6 Music Videos 前置检查**:年份提取后、上述 TV 规则之前先做音乐视频信号检查(原子步骤见 7.2C)——目录信号(目录名精确匹配 musicvideo 词)命中即判 `musicvideo`(即使文件名含季集标记);文件名信号(`[MV]` 等标记 + "歌手 - 歌名" 模式)同理。未命中任何信号才进入上述主决策树。
+> 旧版本配置文件(旧文件名 / 旧位置)在首次运行时**自动迁移**到上述布局,无需手工处理。
-#### 7.2A 特典识别与处理(原子步骤)
+### config.json 主要配置项
-> 特典识别**优先于季份**(如 `Show 2nd Season [Menu01]` 先识别为特典 Season 00)。特典词在**方括号标记内**匹配(避免误判标题),也支持**父目录**判断(文件位于 `SPs/`、`CDs/`、`Bonus/` 等)。
+参考 [`config.example.json`](config.example.json) 模板(不含真实密钥)。值统一为字符串,文件权限 600:
-```mermaid
-flowchart TD
- SA{"遍历特典词表(special_keywords)
文件名方括号内含特典词?
menu/ncop/nced/pv/cm/sp/teaser/
promo/trailer/special/mv/特典/花絮"}
- SA -- "是" --> SA1["is_special=true
frag=命中特典词(去空格)
tag=完整方括号标记"]
- SA -- "否" --> SB{"父目录是特典目录?
SPs/Specials/CDs/Bonus/
Extras/特典/特番/花絮"}
- SB -- "是" --> SA1
- SB -- "否" --> SC["非特典 → 回到主流程季份判断"]
- SA1 --> SD["find_show_path_from_file
向上跳过特典/分类/CD 目录
找到媒体目录完整路径"]
- SD --> SE{"count_main_videos(媒体目录)
正片数量?"}
- SE -- "==1(电影特典)" --> SF["type=skip title=movie_extra
跳过不整理
TMDB/Jellyfin 不收录电影特典
避免误判为剧集特典"]
- SE -- ">=2 或找不到(剧集特典)" --> SG["进入 Season 00 处理"]
- SG --> SH{"frag 非空?
文件名标记命中特典词"}
- SH -- "是" --> SI["ep_num=tag 中首个数字
sp_frag=frag+编号
如 Preview02 → Preview+02"]
- SH -- "否" --> SJ{"有方括号标记?"}
- SJ -- "是" --> SK["逐个方括号片段挑选
跳过压制/编码/画质标记
vcb/ma10p/x264/flac/1080p...
取首个非技术标记"]
- SJ -- "否" --> SL["无标记(裸特典如 CM01.mkv)
sp_frag=clean_name 文件名"]
- SK --> SM["season=0
special_fragment=sp_frag"]
- SL --> SM
- SI --> SM
- SM --> SN{"标题仅由特典标记构成?
无剧名"}
- SN -- "是" --> SO["find_show_dir_from_path
从父目录链向上找剧名目录"]
- SN -- "否" --> SP
- SO --> SP["type=tv
输出 tv|标题|S0|集号|fragment"]
-```
+| 配置项 | 默认值 | 说明 |
+| ------------------------------------------------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------- |
+| `TMDB_API_RA_TOKEN` / `TMDB_API_KEY` | (空,必填之一) | TMDB 认证:Bearer Token(推荐)或 API Key |
+| `TMDB_LANG` | `zh-CN` | API 查询语言;缓存按语言段隔离,切换自动重取 |
+| `TMDB_DELAY` / `TMDB_CURL_RETRY` | `1` / `3` | 请求间延迟(防限流)/ 重试次数 |
+| `VIDEO_EXTS` / `AUDIO_EXTS` / `SUB_EXTS` | 常见媒体扩展名 | 视频 / 音频 / 字幕扩展名清单 |
+| `CACHE_TTL_DAYS` / `CACHE_EMPTY_TTL_DAYS` | `30` / `3` | 正常缓存 / 空哨兵 TTL(查无此片短 TTL,新片出现后自动发现) |
+| `AI_API_KEY` | (空,留空禁用 AI) | AI API 密钥(OpenAI 兼容) |
+| `AI_BASE_URL` / `AI_FULL_URL` | `https://api.deepseek.com` / 空 | AI 端点(`AI_FULL_URL` 优先,默认 `{BASE}/v1/chat/completions`) |
+| `AI_MODEL` | `DeepSeek-V4-Flash` | AI 模型 |
+| `AI_MAX_CALLS` / `AI_BATCH_SIZE` | `10` / `50` | AI 批次上限 / 每批条目数(成本控制) |
+| `AI_DRY_RUN` / `AI_SAVE_CASES` | `false` | AI 干运行(不产生费用)/ 用例落盘(复盘 AI 输入输出) |
+| `SEARCH_FALLBACK_MAL` / `MAL_BASE_URL` | `false` / `https://api.jikan.moe/v4` | 可选 MAL 兜底搜索(外部 API 依赖,默认关闭) |
+| `FAIL_RETRY_COOLDOWN_HOURS` | `24` | 失败冷却时长(`0` 禁用) |
+| `MEDIA_WORKERS` | `4` | 识别池并发数 |
+| `FOLDER_MOVIES` / `FOLDER_SHOWS` / `FOLDER_MUSIC` / `FOLDER_MUSICVIDEOS` / `FOLDER_UNKNOWN` | 跟随系统语言 | 目的目录根名(对齐 Jellyfin 官方库名 Movies/Shows/Music/MusicVideos) |
+| `LOG_FILE` / `SKIP_LOG_FILE` / `LOG_ROTATE_MB` | `/var/log/...` / `10` | 自动化日志路径 / 跳过记录 / 轮转阈值(超阈值滚动保留 5 份) |
-#### 7.2B 正片数量判断(回退,v9.3)
+### 命名模板(`NAMING_*`)
-> [!NOTE]
->
-> **不依赖下载目录**。仅统计"正片":跳过 `SPs/CDs/Scans/Fonts/特典` 等子目录;扩展名取 `VIDEO_EXTS`(**mka 是纯音频容器,不计入**)。结果按媒体目录缓存(`MAIN_COUNT_CACHE`),避免重复扫描。
+命名格式化外置为可配置模板(v9.5),**默认模板与旧版输出逐字节一致**:
-```mermaid
-flowchart TD
- FB["文件名无明确季集/年份特征
(如压制组风格 [Group] Title [1080p])"] --> FB1["find_show_path_from_file
向上找媒体目录完整路径"]
- FB1 --> FB2{"找到媒体目录?"}
- FB2 -- "否" --> FU["type=unknown
记录 PENDING_AI_SEARCH
交 AI 判断类别"]
- FB2 -- "是" --> FB3["count_main_videos
find -maxdepth 2 统计正片
跳过特典/附带子目录
mka 不算视频"]
- FB3 -- "==1" --> FB4["movie 电影
title=cleaned
(如合集里的剧场版)"]
- FB3 -- ">=2" --> FB5["tv 剧集
season=1 episode=0
(多集动画)"]
- FB3 -- "==0" --> FU
-```
+| 语法 | 含义 | 示例 |
+| -------------- | ----------------------------------------------------- | ----------------------------------- |
+| `{name}` | 变量值原样插入 | `{title}` → `Sword Art Online` |
+| `{name:NN}` | 数字补零至 NN 位 | `{season:02}` → `01` |
+| `{?name:text}` | 条件段:name 非空才渲染 text(text 内可含其他占位符) | `{?year: ({year})}` → `(2012)` 或空 |
-**支持的文件名格式**:
-
-| 格式 | 示例 | 识别结果 |
-| ------------------ | --------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
-| 电影(带年份) | `Inception (2010).mkv` | movie |
-| 剧集 SxxEyy | `Breaking Bad S01E01.mkv` | tv S01E01 |
-| 剧集 #x## | `Show 1x05.mkv` | tv S01E05 |
-| 特典 | `Show [NCOP].mkv` / `Show [Menu01].mkv` / `CM01.mkv`(在 SPs 目录) | 剧集特典 S00;媒体目录仅 1 正片 → **电影特典跳过** |
-| 季份 | `Show 2nd Season [01].mkv` | tv 季份 |
-| 方括号集号 | `Show [03].mkv` | tv S01E03 |
-| 音乐视频 | `Music Videos/周杰伦/晴天.mkv`、`周杰伦 - 晴天 [MV].mkv`、`Live/演唱会.mkv` | **musicvideo**(见 7.2C) |
-| 其他(无明确特征) | `Random Movie.mkv` / `[Group] Show [1080p]` | 由媒体目录正片数量判断:**1→movie**,**≥2→tv**,**0/无目录→unknown(AI)** |
-
-#### 7.2C Music Videos 识别(原子步骤,v9.6)
-
-音乐视频类目识别信号 = musicvideo 判定词(`special_keywords.json` 的 `musicvideo` 分区 → default → 内置默认 13 词,如 `mv`/`music video`/`live`/`concert`/`演唱会`):
-
-```mermaid
-flowchart TD
- MA{"目录信号:父目录链任意段
目录名精确匹配 musicvideo 词?
(归一化去空格小写整名相等,
如 Music Videos/MV/Live/演唱会)"}
- MA -- "是" --> M1["musicvideo
输出 musicvideo|title|year"]
- MA -- "否" --> MB{"文件名信号:方括号标记
子串命中 musicvideo 词?
(如 [MV]/[Live])"}
- MB -- "否" --> MC["非音乐视频 → 主决策树"]
- MB -- "是" --> MD{"标记同时命中特典词?
(如 [MV]/[PV] 双命中)"}
- MD -- "否" --> M1
- MD -- "是" --> ME{"文件名含 \" - \" 模式?
(歌手 - 歌名)"}
- ME -- "是" --> M1
- ME -- "否" --> MC["保持特典路径
(\"动画名 [MV]\" 剧集 MV 特典)"]
-```
-
-- **目录信号**:`Music Videos/`、`MV/`、`Live/`、`演唱会/` 等目录(**精确匹配整目录名**,避免 `Muv-Luv`、`tmp.xxx` 等含词目录误判;`PV/` 特典目录不在 musicvideo 词表 → 保持特典路径)。
-- **文件名信号**:`[MV]`/`[Live]`/`[Concert]` 等标记。双命中歧义(标记同时在特典词表,如 `[MV]`)用 **"歌手 - 歌名" 模式**消解:文件名含 `-` → 音乐视频;不含 → 特典(剧集 MV 特典不被误判)。两侧词表均可配置完全控制(musicvideo 分区删词 → 永不判音乐视频;tv 特典词表删词 → 一律判音乐视频)。
-- **命名**:`MusicVideos/{artist}/{title}.{ext}`(`NAMING_MUSICVIDEO` 模板)。artist 归类链:元数据 `ALBUMARTIST → ARTIST` → 父目录名解析(`歌手 - 歌名` 取首段;无分隔符整名作歌手;源根直属不解析)→ `FOLDER_UNKNOWN`。本地规则无网络请求,识别失败不消耗 AI(unknown 才交 AI)。
-
-### 7.3 季数偏移判断(identify_tv_show)
-
-处理 TMDB 季数与实际不符的情况。以下为**每一个原子化操作**:
-
-```mermaid
-flowchart TD
- A["identify_tv_show 输入
title season episode base ext fragment file"] --> A1["归一化季/集号
10# 去前导零(防 08 当八进制)"]
- A1 --> A2["strip_season_suffix 去季后缀
→ search_name"]
- A2 --> S1["tmdb_api /search/tv
query=search_name"]
- S1 --> S2{"results[0].id 非空?"}
- S2 -- "否" --> S3["PENDING_AI_SEARCH 记录
(文件名|父目录|tv)返回失败"]
- S2 -- "是" --> S4["提取 show_title / year
sanitize 清理非法字符"]
- S4 --> S5["tmdb_api /tv/{id}
→ number_of_seasons"]
- S5 --> S5A{"season>1 且 total需要第一季集数"}
- S5A -- "是" --> S5B["tmdb_api /tv/{id}/season/1
→ s1_ep_count"]
- S5A -- "否" --> S6
- S5B --> S6["tmdb_api /tv/{id}/season/0
→ season0_json(特典季原始 JSON)"]
- S6 --> OFF{"偏移判断
原子步骤见 7.3A"}
- OFF --> TM{"season==0 且 fragment 非空?
(特典匹配,原子步骤见 7.4)"}
- TM -- "否" --> EP["tmdb_api /tv/{id}/season/N
取 episode_name(季集名)"]
- TM -- "是" --> EP
- EP --> EP1{"episode_name 空?"}
- EP1 -- "是" --> EP2["用文件名尾部残余
或 Episode {e_fmt}"]
- EP1 -- "否" --> OUT
- EP2 --> OUT["safe_printf_int 补零
S{s_fmt}E{e_fmt}
输出 Shows/... 目标路径"]
-```
-
-#### 7.3A 季偏移决策(原子步骤)
-
-```mermaid
-flowchart TD
- A{"season>1 且
total_seasons < season?"}
- A -- "否" --> OK["正常处理
无需偏移"]
- A -- "是" --> B{"s1_ep_count > 0?
第一季集数可获取"}
- B -- "是" --> B1["自动偏移
episode = episode + s1_ep_count
season = 1"]
- B -- "否" --> C{"get_season_offset 命中?
SEASON_OFFSET_MAP[剧名小写]
或 [id:TMDB_ID]"}
- C -- "是" --> C1["手动偏移
episode = episode + offset
season = 1"]
- C -- "否" --> D["报错 无法计算季偏移
自动化写 SKIP_LOG_FILE
返回失败 跳过文件"]
-```
-
-**示例**:资源实际为 `S01E23`,但 TMDB 只有一季(23 集/季),配置 `{"jujutsu kaisen": 23}` 后,实际 `S02E01` 被映射为:
-
-$$E_{\text{new}} = E_{\text{old}} + O = 1 + 23 = 24 \quad\Rightarrow\quad \text{S01E24}$$
-
-### 7.4 特典匹配判断(Season 00)
-
-> [!NOTE]
->
-> **前置归属判断**(v9.3,不依赖下载目录):特典文件先由 `parse_media_filename` 判定归属——媒体目录仅 1 个正片 → **电影特典**(`type=skip`,直接跳过不整理,TMDB/Jellyfin 不收录电影特典);多个正片 → **剧集特典**(进入本节的 Season 00 处理)。以下为剧集特典的**每一个原子化操作**:
-
-```mermaid
-flowchart TD
- A{"season==0 且
special_fragment 非空?"}
- A -- "否" --> NORMAL["正常集处理"]
- A -- "是" --> B["translate_fragment 别称归一化
(原子步骤见 7.4A)"]
- B --> C["match_special_episode
用标准键匹配季0
(原子步骤见 7.4B)"]
- C -- "成功" --> C1["tmdb_matched=0
episode = TMDB 集号"]
- C -- "失败" --> C2{"用原始 fragment
再次 match_special_episode?"}
- C2 -- "成功" --> C1
- C2 -- "失败" --> D["记录 PENDING_AI_SPECIAL
show_id|fragment → 待 AI 学习"]
- D --> F["sanitize 清理
S00{类型}{编号} 命名
如 CM01 → S00CM01 - CM01"]
- C1 --> G["season0_json 按集号取集名
用 S00E{集号} - TMDB集名 命名"]
-```
-
-> 特典匹配使用**多语言别称**:候选词 = 原始片段 + 去数字核心 + keymap 值数组中的全部多语言值(中文/日文/英文缩写),逐一 `contains`(忽略大小写)匹配 TMDB 季 0 的集名。
-
-#### 7.4A translate_fragment(原子步骤)
-
-```mermaid
-flowchart TD
- T1["输入 fragment
如 Menu01 / WebPreview01"] --> T2{"SPECIAL_MAP[fragment 小写]
精确命中?"}
- T2 -- "是" --> T6["值=JSON 数组
取第一个作为标准键返回"]
- T2 -- "否" --> T3["去末尾数字得到核心词
Menu01 → menu
转小写"]
- T3 --> T4{"SPECIAL_MAP[核心词] 命中?"}
- T4 -- "是" --> T6
- T4 -- "否" --> T5["无映射
返回原 fragment"]
-```
-
-#### 7.4B match_special_episode(原子步骤)
-
-```mermaid
-flowchart TD
- M1["输入 show_id fragment fallback season0_json"] --> M2{"season0_json 为空?"}
- M2 -- "是" --> MF["返回 fallback 集号"]
- M2 -- "否" --> M3["构造候选词列表 terms
① fragment 本身
② 去数字核心词
③ keymap 值数组全部元素
(多键→多值展开后)"]
- M3 --> M4{"遍历 terms 还有?"}
- M4 -- "是" --> M5["term 转小写
jq 匹配 season0 episodes[].name
(ascii_downcase contains)"]
- M5 -- "命中" --> M6["返回该 TMDB 集号"]
- M5 -- "未命中" --> M4
- M4 -- "结束" --> M7["日志警告 未匹配
返回 fallback"]
-```
-
-**特典命名规则**:
-
-| 情况 | 命名 | 示例 |
-| ------------------- | --------------------------- | ------------------------------------------------ |
-| TMDB 特典集匹配成功 | `S00E{集号} - TMDB集名.ext` | `S00E01 - 迷你动画「猫猫的独语」第1话:白粉.mkv` |
-| 未匹配(有编号) | `S00{类型}{编号}.ext` | `S00CM01.mkv`、`S00Menu01.mkv`、`S00PV01.mkv` |
-| 未匹配(无编号) | `S00{类型}.ext` | `S00NCED.mkv`、`S00NCOP.mkv` |
-
-> `S00E{编号}` 仅用于 TMDB 能匹配的特典集;未匹配的特典用 `S00{类型}{编号}` 命名,避免占用正常特典编号、干扰 Jellyfin 刮削。
-
-### 7.5 AI 批处理判断(run_ai_batch)
-
-AI 分批处理四类待办:**搜索词纠正/类别判断**(`PENDING_AI_SEARCH`)、**特典映射学习**(`PENDING_AI_SPECIAL`)、**艺术家判定**(`PENDING_AI_ARTIST`)、**匹配甄别**(`PENDING_AI_MATCH`)。每批 `AI_BATCH_SIZE` 条,`AI_MAX_CALLS` 为批次上限。以下为**每一个原子化操作**:
-
-```mermaid
-flowchart TD
- A["run_ai_batch"] --> B{"AI_API_KEY 非空?"}
- B -- "否" --> SKIP["搜索/匹配待定显式 skip_unidentified(可逆)
多艺术家直接拼接"]
- B -- "是" --> C{"有待处理项?
四类 PENDING_COUNT 任一 >0"}
- C -- "否" --> SKIP
- C -- "是" --> L{"AI_CALL_COUNT ≥ AI_MAX_CALLS?"}
- L -- "是" --> LFAIL["剩余待定显式跳过"]
- L -- "否" --> D["ai_batch_request(每批最多 AI_BATCH_SIZE 条)"]
- D --> D1["构造输入 JSON(jq 安全转义)
search_entries(file/directory 目录链/type)
+ special_entries(show_id/fragment/season0 全量)
+ artist_entries(多艺术家/专辑)
+ match_entries(search 原样 + seasons 四字段提炼)"]
- D1 --> D2["拼 AI_BATCH_PROMPT + Input → payload
temperature=0.2"]
- D2 --> D3{"AI_DRY_RUN=true?"}
- D3 -- "是" --> D4["打印 Prompt 摘要
返回失败态 → 剩余跳过"]
- D3 -- "否" --> D5{"curl 调 {AI_FULL_URL 或 BASE/v1/chat/completions}
递增重试(2^n 封顶 16s)"}
- D5 -- "失败" --> DFAIL["记录错误 返回失败态
→ 剩余待定显式跳过(可逆)"]
- D5 -- "成功" --> D6["AI_CALL_COUNT++
校验返回 JSON"]
- D6 --> D7["解析四部分:search / artist_choice / match / special"]
- D7 --> D8["记录本批响应覆盖的 key
缺失条目留待下一批"]
- D8 --> E["消费:resolve_pending_artists → reprocess_pending_searches → resolve_pending_matches"]
- E --> E1["match 消费:choice → 脚本 build_*_dest 构建命名
(命名是脚本职责,AI 只做判断)"]
- E1 --> E2["season_shift 叠加到文件季号
(Railgun T 类后缀季由脚本剥离优先)"]
- E2 --> E3["无匹配 + search_term → 重搜取首条
(单轮优先,不给 AI 第二轮)"]
- E3 --> E13["仍失败 → 回退命名(降级成功)"]
-```
-
-> [!NOTE]
->
-> **AI 失败语义**:请求失败/非 JSON/干运行/达上限 → 剩余搜索与匹配待定**显式跳过(可逆)**——下次运行自动重试,与无 AI 密钥语义统一;多艺术家待定直接拼接(信息不丢)。
->
-> **AI 成本控制**:`AI_BATCH_SIZE`(默认 50)条/批,`AI_MAX_CALLS`(默认 10)为**批次上限**;`AI_DRY_RUN=true` 时可测试而不产生费用。AI 学到的特典映射会**持久化**到 `special_maps.json`(值数组合并去重),特典词写入 `special_keywords.json`,非媒体目录写入 `skip_directories.json`——下次运行直接生效。
-
-### 7.6 硬链接处理判断(link_media)
-
-> [!NOTE]
->
-> **v9.1 起不再做 `.bak` 备份**——遗留的 `.bak_*` 会被 Jellyfin 当作媒体扫描干扰刮削;目标已存在时**直接替换**(先删旧目标再建硬链接)。以下为**每一个原子化操作**:
-
-```mermaid
-flowchart TD
- A["link_media 遍历 VIDEO_DEST_MAP"] --> A1{"目标路径有效?
subdir 与 filename 均非空"}
- A1 -- "否" --> ASKIP["警告 目标路径无效
skip++ 继续下一文件"]
- A1 -- "是" --> B["mkdir -p 目标目录"]
- B -- "失败" --> B1["报错 无法创建目标目录
skip++ 继续"]
- B -- "成功" --> C["hardlink_or_dryrun 主文件
(原子步骤见 7.6A)"]
- C -- "成功" --> H["处理配套文件
(原子步骤见 7.6B)"]
- C -- "失败" --> H2["skip++
自动化写 SKIP_LOG_FILE"]
- H --> H3["hardlink_count++"]
- H2 --> A
- H3 --> A
- A -- "遍历结束" --> SUM["汇总
成功 hardlink_count 个 跳过 skip_count 个"]
-```
-
-#### 7.6A hardlink_or_dryrun(原子步骤)
-
-```mermaid
-flowchart TD
- H1["输入 src dst"] --> H2{"--dry-run 模式?"}
- H2 -- "是" --> H3["仅打印 干运行:硬链接
返回成功"]
- H2 -- "否" --> H4{"same_inode(src,dst)?
get_file_inode 取 inode
依次 stat -c → stat -f →
ls -i → find -printf"}
- H4 -- "是" --> H5["跳过 硬链接已存在
返回成功"]
- H4 -- "否" --> H6{"目标 dst 已存在?"}
- H6 -- "是" --> H7{"dst 是目录?"}
- H7 -- "是" --> H8["报错 拒绝替换目录
返回失败"]
- H7 -- "否" --> H9["rm -f 删除旧目标
(直接替换 不备份)"]
- H9 --> H10
- H6 -- "否" --> H10["ln src dst 创建硬链接"]
- H10 --> H11{"创建成功?"}
- H11 -- "是" --> H12["返回成功"]
- H11 -- "否" --> H13["报错 返回失败"]
-```
-
-#### 7.6B 配套文件处理(原子步骤)
-
-> 配套文件(字幕 `.srt/.ass`、音轨 `.mka` 等)**跟随其主视频**一起硬链接。同名或语言标签命名均识别。
-
-```mermaid
-flowchart TD
- P1["主视频 hardlink 成功后
base_name = video 去扩展名"] --> P2["for companion in 'base_name'.*
遍历同基名文件"]
- P2 --> P3{"文件存在且非主视频自身?"}
- P3 -- "否" --> PNEXT["继续下一个 companion"]
- P3 -- "是" --> P4["is_companion 判断
(原子步骤见 7.6C)"]
- P4 -- "否(非配套)" --> PNEXT
- P4 -- "是(配套)" --> P5["comp_suffix = companion 去掉 base_name 前缀
再去掉扩展名
(字符串截取,保留语言后缀)
如 .zh / .zh-tw"]
- P5 --> P6["dest = 目标文件去扩展名 + comp_suffix + 新扩展名
如 S01E01.zh.ass"]
- P6 --> P7["hardlink_or_dryrun companion → dest"]
- P7 --> PNEXT
- PNEXT --> P8{"还有 companion?"}
- P8 -- "是" --> P2
- P8 -- "否" --> P9["返回 处理完成"]
-```
-
-#### 7.6C is_companion(原子步骤)
-
-```mermaid
-flowchart TD
- I1["输入 companion 与主视频 base_name"] --> I2{"name_noext == vbase?
同名"}
- I2 -- "是" --> IYES["是配套
返回 0"]
- I2 -- "否" --> I3{"name_noext 含语言标签?
inner_ext 匹配 ^[a-z]{2,3}(-[a-z]{2,})?$
如 zh / zh-tw / en"}
- I3 -- "否" --> INO["非配套
返回 1"]
- I3 -- "是" --> I4{"possible_base == vbase?
去掉语言标签后与主视频同名"}
- I4 -- "是" --> IYES
- I4 -- "否" --> INO
-```
-
----
-
-## 8. 配置文件详解
-
-### 8.1 `config.json`(主配置文件)
-
-所有配置项及其默认值(优先级:**环境变量 > config.json > 脚本默认值**);JSON 对象格式,值统一为字符串(`"KEY": "VALUE"`),文件权限 600:
-
-| 配置项 | 默认值 | 说明 |
-| --------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
-| `TMDB_API_KEY` | (空,必填之一) | TMDB v3 API Key |
-| `TMDB_API_RA_TOKEN` | (空,必填之一) | TMDB Read Access Token(推荐) |
-| `TMDB_LANG` | `zh-CN` | API 查询语言 |
-| `TMDB_DELAY` | `1` | 请求间延迟(秒),防限流 |
-| `TMDB_CURL_RETRY` | `3` | curl 重试次数 |
-| `TMDB_CURL_CONNECT_TIMEOUT` | `10` | 连接超时(秒) |
-| `TMDB_CURL_MAX_TIME` | `30` | 请求最大时长(秒) |
-| `VIDEO_EXTS` | `mp4,mkv,avi,mov,...` | 视频扩展名 |
-| `AUDIO_EXTS` | `mp3,flac,aac,ogg,...` | 音频扩展名 |
-| `SUB_EXTS` | `srt,ass,ssa,sub,...` | 字幕扩展名 |
-| `CACHE_DIR` | `mo_cache` | 缓存根目录:`tmdb/` = TMDB API 镜像缓存(可删除重建),`media_organizer/` = 脚本运行状态(账本/冷却);用户配置类文件位于 `mo_config/` |
-| `CACHE_TTL_DAYS` | `30` | 缓存有效期(天),超过后重新请求 |
-| `CACHE_EMPTY_TTL_DAYS` | `3` | 空结果哨兵有效期(天),超过后重新请求 |
-| `SPECIAL_MAP_FILE` | `mo_config/special_maps.json` | 特典映射文件(AI 学习写回;旧版文件名/位置自动迁移) |
-| `SPECIAL_WORDS_FILE` | `mo_config/special_keywords.json` | 特典识别词表文件(AI 学习到的新词写回;旧版文件名/位置自动迁移) |
-| `SEASON_OFFSET_FILE` | `mo_config/season_offsets.json` | 季偏移文件(旧版文件名/位置自动迁移) |
-| `SKIP_DIRS_FILE` | `mo_config/skip_directories.json` | 跳过目录词表(AI 学习写回;旧版文件名/位置自动迁移) |
-| `COLOR_OUTPUT` | `true` | 彩色输出开关 |
-| `DEBUG_LEVEL` | `0` | 调试级别(0/1/2) |
-| `AI_API_KEY` | (空,留空禁用 AI) | AI API 密钥 |
-| `AI_BASE_URL` | `https://api.deepseek.com` | AI API 基础 URL |
-| `AI_FULL_URL` | (空) | AI 完整端点(默认 `{AI_BASE_URL}/v1/chat/completions`) |
-| `AI_MODEL` | `DeepSeek-V4-Flash` | AI 模型 |
-| `AI_MAX_CALLS` | `10` | 单次最多 AI 调用次数 |
-| `AI_DRY_RUN` | `false` | AI 干运行(不产生费用) |
-| `AI_SAVE_CASES` | `false` | AI 用例落盘:每次请求的输入/响应存 `mo_cache/media_organizer/ai_cases/`(识别错误复盘/防幻觉学习数据源) |
-| `AI_CURL_RETRY` | `3` | AI curl 重试次数 |
-| `AI_CURL_CONNECT_TIMEOUT` | `10` | AI 连接超时(秒) |
-| `AI_CURL_MAX_TIME` | `30` | AI 请求最大时长(秒) |
-| `LOG_FILE` | `/var/log/media_organizer.log` | 自动化日志路径 |
-| `SKIP_LOG_FILE` | `/var/log/media_organizer_skip.log` | 跳过记录日志路径 |
-| `MEDIA_WORKERS` | `4` | 识别池并发数(1-8;并发高时建议增大 `TMDB_DELAY`) |
-| `FOLDER_MOVIES` | 跟随系统语言 | 目的目录"电影"根名(zh locale 默认 `电影`) |
-| `FOLDER_SHOWS` | 跟随系统语言 | 目的目录"节目"根名(Jellyfin 官方库名 Shows;zh locale 默认 `节目`) |
-| `FOLDER_MUSIC` | 跟随系统语言 | 目的目录"音乐"根名 |
-| `FOLDER_MUSICVIDEOS` | 跟随系统语言 | 目的目录"音乐视频"根名(官方库名 `MusicVideos`) |
-| `FOLDER_UNKNOWN` | 跟随系统语言 | 未知艺术家/标题占位名 |
-
-> [!NOTE]
->
-> **目的目录命名规则**:默认跟随系统语言(`LANG`/`LC_ALL` 以 `zh` 开头 → 中文,否则英文),且与 **Jellyfin 官方媒体库名称**保持一致——`Movies`=电影、`Shows`=节目、`Music`=音乐、`MusicVideos`=音乐视频(`Unknown`=未知为脚本兜底分类,非 Jellyfin 库类型)。任一 `FOLDER_*` 均可通过环境变量或 `config.json` **显式覆盖**(如日语环境用 `Shows`=アニメ)。注意:旧版本默认 `Shows`=剧集,升级后未显式设置的既有媒体库会新建"节目"根目录——如要保持旧目录名,请在 `config.json` 中显式设置 `FOLDER_SHOWS`。
-> | `SKIP_HARDLINK_CHECK` | `false` | 跳过硬链接检查(不建议) |
-> | `SEARCH_FALLBACK_MAL` | `false` | 搜索重试链第三级:zh-CN/en-US 均空时用 MyAnimeList (jikan v4) 候选标题回搜 TMDB |
-> | `MAL_BASE_URL` | `https://api.jikan.moe/v4` | jikan API 基础 URL(可换镜像) |
-> | `FAIL_RETRY_COOLDOWN_HOURS` | `24` | 失败冷却时长(小时):请求失败/未识别条目在此期限内重跑直接跳过;`0` 禁用 |
-> | `MATCH_RULES_FILE` | `mo_config/match_rules.json` | 用户匹配规则文件(搜索别名 + ID 映射,见 8.6 节;旧版文件名/位置自动迁移) |
-> | `NAMING_MOVIE` | `{title}{?year: ({year})}` | 电影目录/文件名模板(见 8.7 节命名模板) |
-> | `NAMING_SHOW` | `{title}{?year: ({year})}` | 剧集目录名模板 |
-> | `NAMING_SEASON` | `Season {season:02}` | 季目录名模板(Jellyfin 解析格式,默认不可本地化) |
-> | `NAMING_EPISODE` | `S{season:02}E{episode:02}{range}{?episode_name: - {episode_name}}` | 剧集文件名模板 |
-> | `NAMING_SPECIAL` | `S00{tag} - {fragment}` | 未匹配特典文件名模板(S00{类型} - {片段}) |
-> | `NAMING_MUSIC` | `{artist}/{album}` | 音乐目录结构模板(歌手/专辑;可含 `/` 产生多级) |
-> | `NAMING_MUSICVIDEO` | `{artist}/{title}` | 音乐视频目录结构模板(歌手/歌名;见 9 节 MusicVideos 结构) |
-> | `SPECIAL_CATEGORIES_FILE` | `mo_config/special_categories.json` | 特典类别判定表文件(S00 未匹配特典的类别标签,见 8.8 节;旧版文件名/位置自动迁移) |
-
-### 8.2 `special_maps.json`(特典映射)
-
-**格式**:**键为文件中的关键字符串(本地特典关键字),值为匹配关键字数组(多键→多值,可含多语言)**。匹配关键字与 TMDB 特典候选列表条目做三级匹配(精确 > 最短前缀 > contains)。
-
-值可以是:
-
-- **JSON 数组**:直接的多语言匹配关键字,如 `["Menu", "菜单", "メニュー"]`。
-- **字符串(引用另一键)**:复用其他键的数组,实现多键共享同一组值。不同压制组的特典命名不同(`Menu01`/`Menu 01`/`MENU01`),但对应同一组匹配关键字,用引用避免重复定义。
-
-**AI 学习**:AI 从 TMDB 特典候选列表(season 0 条目)中**选择**与本地片段匹配的项,写回 keymap 作为匹配关键字——写回前交叉验证(产物必须是候选列表成员,防幻觉污染)。
-
-> [!NOTE]
->
-> **v9.6 类目分区**:支持 `{"tv": {...}, "musicvideo": {...}, "default": {...}}` 分区形态(查询 tv 分区 → default → 全局表;AI 写回 tv 分区),详见 8.8 节。
-
-> [!NOTE]
->
-> **判定与匹配分工**:keymap 解决"**匹配**特典"(本地标记 → TMDB 关键字),词表(8.3 节)解决"**判定**特典"(文件名标记 → 是否特典)。AI 学习会**同时写回两者**:学到的新片段核心词并入 `special_keywords.json`,保证下次遇到不在特典目录里的同类文件名标记时,先能被判定为特典、再走 keymap 匹配——否则只写回 keymap 时判定环节直接失败,学习结果对不上。
-
-### 8.3 `special_keywords.json`(特典识别词表)
-
-**格式**:JSON 数组,元素 = 特典类别词(`["menu","ncop","cm",...]`)。文件名方括号标记含这些词 → 判定为特典(Season 00)。匹配对空格不敏感(词表 `ncop` 可匹配文件里的 `nc op`);词表内容不当作正则。缺失时交互创建空词表 `[]`(v9.7 起无内置示例词,真实词由 AI 学习与用户添加)。
-
-**AI 学习写回**:AI 学习特典映射的同时,会把新片段的核心词(去数字/空格、小写,长度 ≥ 2)**去重合并写回**本文件,使后续运行能识别同类标记(已存在于词表则跳过)。
-
-> [!NOTE]
->
-> **v9.6 类目分区 + 音乐视频判定词**:支持 `{"tv": [...], "musicvideo": [...], "default": [...]}` 分区形态,详见 8.8 节。`musicvideo` 分区词用于 **Music Videos 类目识别信号**(目录名精确匹配 + 文件名 `[MV]` 等标记,见 7.2C 节),默认 13 词(`mv`/`music video`/`live`/`concert`/`演唱会` 等),独立于 tv 特典词表。
-
-```json
+```jsonc
{
- "menu": ["Menu", "菜单", "メニュー"],
- "menu01": "menu",
- "menu_1": "menu",
- "preview": ["Preview", "预告片", "予告"],
- "webpreview": "preview"
+ "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}",
}
```
-- **键**:从文件名解析出的特典关键字(可带编号,如 `Menu01`;也常用去编号的核心词如 `menu`)。
-- **值**:TMDB SEASON0 特典集中能匹配该关键字的字符串数组(英文/中文/日文等多语言均可),或引用其他键的数组。
-
-**识别/匹配流程**:
-
-1. 从文件名提取特典片段(如 `Menu01`)→ 转小写查 keymap(先精确,再按去数字核心词 `menu` 查);命中后引用值会递归展开为数组。
-2. 命中后用**数组中的每一个值**去该剧集 TMDB `season/0` 的 `episodes[].name` 做 `contains`(忽略大小写)匹配——任一值命中即匹配该特典集。
-3. 匹配成功 → 用 `S00E{编号} - TMDB集名` 命名;失败 → 回退 `S00{类型}{编号}`。
-
-> AI 学习也会写入此文件:AI 判定本地关键字对应 SEASON0 的哪些特典名(多语言)后,合并写入值数组(去重)。
-> 缺失时,脚本在用户确认下用内嵌的 `SPECIAL_KEYMAP_TEMPLATE` 常量自动生成(v9.7 起模板为空,生成空映射 `{}`,真实映射由 AI 学习与用户添加)。
-
-### 8.4 `skip_directories.json`(跳过目录词表)
-
-**格式**:JSON 数组,元素 = 不作为媒体名目录的目录词(`["sp","cds","特典","视频",...]`)。匹配为目录名**精确比较**(忽略大小写);内容不当作正则。缺失时交互创建空词表 `[]`(v9.7 起无内置示例词,真实目录词由 AI 学习与用户添加)。
-
-**AI 学习写回**:AI 批处理会从识别失败条目的目录链中挑出**非媒体目录**(特典/附带/分类目录,如 `PV`/`CM`/`MAD`),交叉验证(必须实际出现在输入目录链中,防幻觉)后**去重写回**本文件,使后续运行正确跳过此类目录(避免被误计为正片影响电影/剧集归属判断)。
-
-> [!NOTE]
+> [!WARNING]
>
-> **v9.6 类目分区**:支持 `{"video": [...], "music": [...], "default": [...]}` 分区形态(视频流程查 video、音频流程查 music;**分区形态 = 完整语义**,不叠加内置默认),详见 8.8 节。
+> `Season NN` 与 `SxxExx` 是 **Jellyfin 解析格式**——修改 `NAMING_SEASON`/`NAMING_EPISODE` 默认结构可能导致刮削失败,请仅在了解后果时自定义。未知占位符渲染为空并打印警告。
-### 8.5 `season_offsets.json`(季数偏移)
-
-**格式**:键为剧名(小写)或 `id:TMDB_ID`,值为第一季的集数:
-
-```json
-{
- "jujutsu kaisen": 23,
- "demon slayer": 26,
- "one piece": 130,
- "id:109620": 23
-}
-```
-
-> 缺失时,脚本用内嵌的 `SEASON_OFFSET_TEMPLATE` 常量自动生成(v9.7 起模板为空,生成空偏移表 `{}`,真实偏移由用户添加)。
-
-> [!NOTE]
->
-> **v9.6 类目分区**:支持 `{"tv": {...}, "default": {...}}` 分区形态(季偏移仅 tv 有语义,其余类目预留),详见 8.8 节。
-
-### 8.6 `match_rules.json`(用户匹配规则)
-
-**格式**:用户手工维护的确定性匹配规则,两部分——**搜索别名**与 **ID 映射**。缺失时脚本在用户确认下用内嵌 `MATCH_RULES_TEMPLATE` 常量创建空规则(`{}` 结构);键为**文件名清洗后的标题**(大小写/空白不敏感:加载期统一小写 + 空白折叠,如 `Sword Art Online` 与 `sword art online` 等价):
+### 用户匹配规则(`match_rules.json`)
```json
{
@@ -1083,70 +375,16 @@ flowchart TD
}
```
-**搜索别名**(`search_aliases`):主搜索(zh-CN → en-US)**无结果**时,用指定的重搜词再搜一次(确定性兜底,优先于目录名重搜与 MAL/AI)。值可为字符串(重搜词),或对象 `{"term": "...", "year": "..."}`(带年份约束,仅电影生效)。适合本地俗称/简称(TMDB 搜不到"俺妹"但能搜到全名)。
-
-**ID 映射**(`id_maps`):标题命中后**直接使用指定 TMDB ID,完全跳过搜索**(最高优先级,先于一切搜索)。`movie`/`tv` 分表(按 parse 判定的类型取表)。适合 TMDB 多候选易选错(同名动画/真人版)、搜索命中错条目的场景。
+- **搜索别名**:主搜索(zh → en)无结果时用重搜词兜底——适合本地俗称/简称(TMDB 搜不到"俺妹"但能搜到全名)
+- **ID 映射**:标题命中直接使用指定 TMDB ID,**完全跳过搜索**(最高优先级)——适合同名动画/真人版易选错的场景
> [!TIP]
>
-> **匹配规则优先级**:ID 映射(跳过搜索)> 常规搜索链(zh → en → 别名 → 目录名 → MAL)→ AI。别名只在常规搜索无结果时介入;规则全部命中即走确定性路径,不消耗 AI 轮次。
+> 匹配规则优先级:**ID 映射 > 常规搜索链(zh → en → 别名 → 目录名 → MAL)> AI**。规则全部命中即走确定性路径,不消耗 AI 轮次。
-### 8.7 命名模板(`NAMING_*`)
+### 类目分区(v9.6)
-命名格式化从硬编码改为用户可配置模板(v9.5)。**默认模板与旧版输出逐字节一致**,不配置则行为完全不变。模板占位符语法:
-
-| 语法 | 含义 | 示例 |
-| -------------- | --------------------------------------------------------- | ------------------------------------------------ |
-| `{name}` | 变量值原样插入(未定义/未知变量渲染为空) | `{title}` → `Sword Art Online` |
-| `{name:NN}` | 数字补零至 NN 位(非数字原样保留) | `{season:02}` → `01` |
-| `{?name:text}` | **条件段**:name 非空才渲染 text(text 内可含其他占位符) | `{?year: ({year})}` → `(2012)`(带前导空格)或空 |
-
-各模板可用变量:
-
-| 配置键 | 默认模板 | 可用变量 |
-| ---------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
-| `NAMING_MOVIE` | `{title}{?year: ({year})}` | `title`、`year` |
-| `NAMING_SHOW` | `{title}{?year: ({year})}` | `title`、`year` |
-| `NAMING_SEASON` | `Season {season:02}` | `season` |
-| `NAMING_EPISODE` | `S{season:02}E{episode:02}{range}{?episode_name: - {episode_name}}` | `season`、`episode`、`range`(多集 `-E02`,单集空)、`episode_name`(首末集名以 `-` 连接)、`episode_end` |
-| `NAMING_SPECIAL` | `S00{tag} - {fragment}` | `tag`(特典类别 Menu/CM/PV...)、`fragment`(原始片段如 `Menu01`) |
-| `NAMING_MUSIC` | `{artist}/{album}` | `artist`、`album`(可含 `/` 产生多级目录) |
-
-示例:
-
-```jsonc
-{
- "NAMING_MOVIE": "{title} ({year}) [{quality}]", // 注意:{quality} 不存在 → 渲染为空,加载期打印警告
- "NAMING_EPISODE": "EP{episode} - {episode_name}",
- "NAMING_MUSIC": "{artist}/{album} ({year})", // 注意:音乐无 year 变量
-}
-```
-
-> [!WARNING]
->
-> - 未知占位符渲染为空,加载时打印警告(拼写错误可被发现)。
-> - `Season NN` 与 `SxxExx` 是 Jellyfin 解析格式——修改 `NAMING_SEASON`/`NAMING_EPISODE` 默认结构可能导致刮削失败,请仅在了解后果时自定义。
-> - 电影/剧集目标要求**目录名与文件名同名**(Jellyfin 规范),模板渲染结果同时用于两者;渲染后的名称会经 sanitize 清洗(`\ / : * ? " < > |` 被移除),模板中不要依赖这些字符。
-
-### 8.8 类目分区格式(`special_maps` / `special_keywords` / `skip_directories` / `season_offsets`,v9.6)
-
-四个配置类文件支持**两种形态**:
-
-1. **全局单表(旧版,默认兼容)**:当前结构直接可用(对象/数组),行为与 v9.5 完全一致。
-2. **类目分区(v9.6 新增)**:顶层按类目分键,每个类目一份表,**未配置回退 `default` 分区 → 全局表**。
-
-**分区判定**:顶层全部键 ∈ `{movie, tv, music, musicvideo, video, audio, default, global}` 即视为分区形态(混合形态按全局表处理并警告)。
-
-**各文件的分区键与查询语义**:
-
-| 文件 | 分区键 | 查询上下文 |
-| ----------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `special_maps.json` | `tv`(特典映射)、`musicvideo`(预留)、`default` | 特典匹配(`translate_fragment`/`match_special_episode`)查 **tv 分区 → default → 全局表**;分区内引用展开同分区优先,其次全局表 |
-| `special_keywords.json` | `tv`(特典判定词)、`musicvideo`(音乐视频判定词)、`default` | 特典判定查 **tv 分区 → default → 全局表**(v9.7 起无内置词);音乐视频判定查 **musicvideo 分区 → default → 内置 musicvideo 词表**(**不回退** tv 特典词表——避免 "sp" 命中 "SPs" 目录等交叉误判) |
-| `skip_directories.json` | `video`(视频流程:电影/剧集/音乐视频的目录语义)、`music`(音频流程)、`default` | 按流程查对应分区 → default → 全局表。**分区形态 = 完整语义**(不叠加内置默认,通用词放 default 分区) |
-| `season_offsets.json` | `tv`(季偏移)、`default` | 查 tv 分区 → default → 全局表(v9.7 起无内置偏移) |
-
-示例(`special_keywords.json` 分区形态):
+`special_maps` / `special_keywords` / `skip_directories` / `season_offsets` 四个配置类文件支持**分区形态**:顶层按类目分键,未配置回退 `default` 分区 → 全局表:
```json
{
@@ -1158,281 +396,148 @@ flowchart TD
> [!NOTE]
>
-> **AI 学习写回自动适配分区**:AI 学到的特典映射/特典词写入 `tv` 分区、跳过目录写入 `video` 分区(分区形态时);全局形态仍写顶层。
+> 分区语义因文件而异:`skip_directories` 分区形态 = **完整语义**(不叠加内置默认);`special_keywords` 分区 = **叠加语义**(积累型词表)。AI 学习写回自动适配分区(写 tv / video 分区)。
-### 8.9 `special_categories.json`(特典类别判定表,v9.6 外置)
+---
-S00 未匹配特典的类别标签判定表(`S00{类型} - {片段}` 的"类型"):fragment 子串按**数组顺序**(=优先级,长词/特定词在前)匹配 → 取标签。**数组保序,顺序即优先级**:
+## 🧠 核心机制
-```json
-[
- { "match": "nced", "tag": "NCED" },
- { "match": "mini anime", "tag": "Mini Anime" },
- { "match": "spot", "tag": "CM" },
- { "match": "iv", "tag": "IV" }
-]
+### 缓存设计
+
+- **镜像缓存**:`mo_cache/tmdb/` 路径结构镜像 TMDB API 端点,一切请求数据全量落盘;详情/季缓存带语言段(`tv/..json`),切换 `TMDB_LANG` 自动 miss 重取
+- **并发去重**:识别池多 worker 同时 miss 同一查询时,`flock` 互斥 + 锁内**双检**——同一查询只发一次 TMDB 请求
+- **空哨兵**:搜索"查无此片"写 `empty:true` 包裹缓存(3 天短 TTL),新片出现后自动重新搜索;请求失败不写缓存,下次运行重试
+- **原子写**:先写临时文件再 `rename`,避免并发/中断产生半截 JSON
+
+### 增量账本与失败冷却
+
+```mermaid
+flowchart TD
+ A["识别池入口 process_one_file"] --> B{"纠错学习命中?
corrections.json"}
+ B -- "是" --> C["直接用用户指定目标
(跳过 parse/识别/AI)"]
+ B -- "否" --> D{"已链接账本命中?
源存在 + 目标存在 + 同 inode"}
+ D -- "是" --> E["结局 already_linked 跳过"]
+ D -- "否" --> F{"失败冷却中?
request_failed / skip_unidentified"}
+ F -- "是" --> G["结局 cooldown 跳过
(过期自动重试)"]
+ F -- "否" --> H["正常识别 + 链接
账本运行末尾统一落盘"]
```
-- 查询链:**用户表(按序)→ 内置默认表(24 项,运行时硬编码,v9.7 起独立于模板)→ `Special`**。
-- 缺失时脚本在用户确认下用 `SPECIAL_CATEGORIES_TEMPLATE` 常量创建(v9.7 起模板为空,生成空判定表 `[]`;自动化模式跳过,内置表兜底)。
-- 用户可增删类别或调整顺序(长词在前,如 `mini anime` 先于 `anime`)。
+- 任一账本失效(目标被删 / 源被替换)自动**重新识别 + 链接**,可逆语义完整
+- 干运行不落盘、不启用跳过(展示全貌)
+
+### AI 辅助识别
+
+- **批量**:每批 `AI_BATCH_SIZE` 条(默认 50),`AI_MAX_CALLS` 为批次上限(默认 10),`jq` 安全转义构造输入
+- **四类任务合并为一次请求**:搜索词纠正 / 特典映射学习 / 专辑艺术家判定 / 匹配甄别
+- **判断与执行分离**:AI 只输出判断(`choice`/`season_shift`/`search_term`),命名格式化始终由脚本构建——确定性职责不交给 AI
+- **失败可逆**:AI 请求失败 → 剩余条目显式 `skip_unidentified`(下次自动重试);只有"AI 成功且判断无解"才走回退命名(降级成功,单列一类显示)
+- **防幻觉**:特典学习产物必须 ∈ TMDB 候选列表(交叉验证);跳过目录学习产物必须实际出现在输入目录链中
+
+### 特典识别(Season 00)
+
+判定信号 = 文件名方括号标记(含特典词)+ 特典父目录(`SPs/`/`CDs/`/`Bonus/` 等)。归属判断:媒体目录仅 1 个正片 → **电影特典直接跳过**(TMDB/Jellyfin 不收录);多个正片 → 剧集特典 Season 00。匹配用多语言别称(本地关键字 → keymap 多语言值 → TMDB season 0 候选,三级匹配:精确 > 最短前缀 > contains)。
+
+$$E' = E + S_1,\qquad S' = 1 \quad\text{(自动偏移:TMDB 第一季集数 } S_1 \text{)}$$
---
-## 9. 输出目录结构
+## 🧪 测试与开发
-脚本在目标目录创建 Jellyfin 规范的结构:
+### 一键命令
-```text
-/media
-├── Movies/
-│ └── Inception (2010)/
-│ └── Inception (2010).mkv
-├── Shows/
-│ ├── Breaking Bad (2008)/
-│ │ └── Season 01/
-│ │ ├── S01E01 - Pilot.mkv
-│ │ └── S01E01 - Pilot.srt ← 伴随文件
-│ └── Some Anime (2020)/
-│ └── Season 00/
-│ ├── S00E01 - 迷你动画「猫猫的独语」第1话:白粉.mkv ← TMDB 匹配特典(S00E 编号)
-│ ├── S00CM01 - CM01.mkv ← 未匹配特典(S00{类型}{编号})
-│ └── S00Menu01 - Menu01.mkv
-└── Music/ ← CD 音乐(flac/mp3)
- └── 平井大/
- └── 幸せのレシピ/
- └── 01. 幸せのレシピ.flac
-└── MusicVideos/ ← 音乐视频(v9.6)
- └── 周杰伦/
- ├── 晴天.mkv
- └── 七里香.mp4
+```shell
+make check # lint + 单元测试 + 产物一致性(等同 CI 全部关卡)
+make test # 单元测试
+make lint # bash -n + ShellCheck 静态检查(零容忍)
+make build # 构建 dist/media_organizer
```
-**命名规则**:
+### 单元测试
-| 类型 | 规则 |
-| ----------------- | ----------------------------------------------------------------------------------------------------- |
-| 电影 | `Movies/标题 (年份)/标题 (年份).扩展名` |
-| 剧集 | `Shows/标题 (年份)/Season NN/S{季}E{集} - 集名.扩展名` |
-| 特典(TMDB 匹配) | `Shows/标题 (年份)/Season 00/S00E{集号} - TMDB集名.扩展名` |
-| 特典(未匹配) | `Shows/标题 (年份)/Season 00/S00{类型}{编号} - 原片段.扩展名`(如 `S00CM01`、`S00Menu01`、`S00PV01`) |
-| 音乐视频 | `MusicVideos/歌手/歌名.扩展名`(artist 元数据 → 目录名 → `FOLDER_UNKNOWN`) |
-| 伴随文件 | 与视频同名,保留语言标签(`.zh.srt`、`.jp.ass` 等) |
+零依赖纯 bash 测试框架:加载全部模块,每个用例文件在独立子 shell 运行(全局状态自动隔离):
-> [!TIP]
->
-> **要点**:文件名**不含剧集名**——Jellyfin 通过父目录(`Shows/标题 (年份)/`)识别剧集,不影响刮削。`S00E{编号}` 仅用于 TMDB 能匹配的特典集;未匹配的特典用 `S00{类型}{编号}` 区分,避免干扰正常特典编号。
-
----
-
-## 10. 核心算法与公式
-
-### 10.1 季数偏移
-
-设实际集号为 $E$、季号为 $S$,TMDB 第一季集数为 $S_1$:
-
-- **自动偏移**(TMDB 有第一季数据时):
-
-$$E' = E + S_1,\qquad S' = 1$$
-
-- **手动偏移**(来自 `season_offsets.json`,偏移值为 $O$):
-
-$$E' = E + O,\qquad S' = 1$$
-
-### 10.2 编号格式化
-
-集号/季号统一补零为两位:
-
-$$\text{pad2}(n) = \begin{cases} \text{sprintf}\left(\%02d,\ n\right) & n \in \mathbb{Z}^+ \\ 00 & \text{otherwise} \end{cases}$$
-
-生成文件名(**不含剧集名**,Jellyfin 通过父目录识别剧集):
-
-$$
-\text{filename} =
-\begin{cases}
-S_{\text{pad2}(S')}E_{\text{pad2}(E')} - \text{EpisodeName}.\text{ext} & \text{剧集}\\
-S00E_{\text{pad2}(E')} - \text{TMDBName}.\text{ext} & \text{特典(TMDB 匹配)}\\
-S00\text{类型}\text{编号} - \text{片段}.\text{ext} & \text{特典(未匹配)}
-\end{cases}
-$$
-
-### 10.3 特典别称匹配
-
-设文件名片段为 $f$,别称映射为 $\mathcal{A}$(标准键 → 别称集合)。归一化:
-
-$$\text{translate}(f) = \underset{\text{按长度倒序}}{\arg\max}\ \{\, a \in \mathcal{A} \mid a \subseteq f \,\}$$
-
-匹配 TMDB 季 0 集名 $N$:
-
-$$\text{match}(f) = \min\{\, \text{episode\_number} \mid N \text{ contains } \text{translate}(f) \lor N \text{ contains } f \,\}$$
-
-**特典识别输入**(进入匹配前的判定):
-
-- 文件名**方括号标记内**含特典词(可带可不带数字):`menu`、`ncop`、`nced`、`nc op`、`nc ed`、`mini anime`、`pv`、`cm`、`sp`、`teaser`、`program`、`promo`、`trailer`、`special`、`opening`、`ending`、`preview`、`特典`、`特番`、`花絮` 等
-- 或**父目录**为特典目录:`SPs`、`Specials`、`CDs`、`Bonus`、`Extras`、`特典` 等
-- 裸特典文件名(无剧名,如 `CM01.mkv`)通过 `find_show_dir_from_path` **向上查找父目录链**推断所属剧集(跳过特典/分类/`[数字]`CD 子目录)
-
-> 特典集号优先从文件名提取(如 `Menu01` → `01`);无数字的特典(如 `NCED`)用 `S00{类型}` 命名。
-
----
-
-## 11. 日志与调试
-
-### 11.1 日志级别
-
-| 级别 | 颜色 | 场景 |
-| ------ | ---- | ------------- |
-| `信息` | 白 | 常规进度 |
-| `搜索` | 蓝 | TMDB 搜索 |
-| `匹配` | 紫 | 识别成功 |
-| `警告` | 黄 | 可恢复问题 |
-| `错误` | 红 | 失败 |
-| `跳过` | 灰 | 已存在/跳过 |
-| `智能` | 青 | AI 操作 |
-| `调试` | 暗 | DEBUG_LEVEL≥1 |
-
-### 11.2 调试技巧
-
-```bash
-# 详细日志(DEBUG_LEVEL=2 显示 TMDB 请求)
-DEBUG_LEVEL=2 ./dist/media_organizer --dry-run /downloads /media
-
-# 测试 AI 而不产生费用
-AI_DRY_RUN=true AI_API_KEY=xxx ./dist/media_organizer --dry-run /downloads /media
+```shell
+./tests/run.sh # 运行全部用例
+./tests/run.sh cache # 仅运行名字含 cache 的用例文件
+./tests/run.sh -v # 详细模式
```
----
-
-## 12. 故障排除
-
-| 问题 | 解决方法 |
-| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `Permission denied config.json` | `chmod 600 mo_config/config.json` |
-| TMDB API 请求超时 | 增大 `TMDB_CURL_MAX_TIME=60`、`TMDB_DELAY=2` |
-| 识别准确度低 | 配置 `AI_API_KEY` 启用 AI 辅助 |
-| AI 成本过高 | 减小 `AI_MAX_CALLS=5` 或改用免费 Ollama |
-| 无法读取配置文件 | 检查文件权限和所有者:`chown $(whoami) config.json` |
-| 无法创建硬链接 | 确认源/目标在同一文件系统 |
-| 无法识别特典 | 在 `special_maps.json` 添加关键词映射 |
-| **特典全部未匹配(被命名为 S00xxx 而非 S00E)** | 特典季数据缺失或搜索不中。先用 `--list-cache` 确认 `tmdb/tv//season/0.json` 是否存在;若缺失或为空,运行 `--update-cache` 强制重取;或 `--refresh-cache` 清空 TMDB 缓存后重跑。特典词可写入 `special_maps.json` 增强匹配 |
-| **特典名变成 `Specialord Art Online` 等怪异文本** | BusyBox/OpenWrt 在空 locale 下 `tr '[:upper:]' '[:lower:]'` 字符类损坏(p→w、u→l 错误映射),污染特典映射。v9.1 已改用 bash 内建 `${var,,}` 转小写,不依赖 tr;升级脚本即可。也可删除被 AI 学习污染的 `special_maps.json` 重建 |
-| **目标文件是完整原文件名(`S00[VCB-Studio] …`)** | 特典 fragment 误用整个文件名。v9.1 修复:仅父目录(SPs)识别特典时,从方括号标记提取简短特典片段,裸特典(如 `CM01.mkv`)用清理后文件名 |
-| **伴随文件反复生成 `.bak_*` 备份** | v9.1 起**不再备份**:目标已存在时直接替换(删除旧目标再硬链接),避免 `.bak_*` 干扰 Jellyfin 刮削。此前遗留的 `.bak_*` 可手动清理(`find /media/nas/Shows /media/nas/Movies -name "*.bak_*" -delete`) |
-| **硬链接目标覆盖产生 `.bak_*`** | 旧版在目标已存在时备份为 `.bak_时间戳`。v9.1 改为直接替换(不备份)。若发现 `.bak_*` 残留,先升级脚本再清理旧文件 |
-| 季数错乱 | 在 `season_offsets.json` 添加偏移值 |
-| 运行中报 `Argument list too long` | 特典季 JSON 过大作为命令行参数所致;已改为独立文件存储,若旧版残留需用新版脚本 |
-
----
-
-## 13. 构建与开发(源码结构)
-
> [!NOTE]
>
-> 分发物始终是**单个文件** `dist/media_organizer`(自带全部配置模板,无需配套文件;构建产物目录为 `dist/`)。
-> CLI 定义与源码位于 `src/`,由 [bashly](https://bashly.dev) 生成分发脚本
-> (开发期依赖 Ruby/basily;**产物运行无需任何依赖**)。
+> 当前 **120 通过 / 3 已知失败**(分区回退、季偏移分区、音乐视频双命中——见 [`docs/PROJECT_REPORT.md`](docs/PROJECT_REPORT.md) 的记录)。CI 流水线([`.github/workflows/ci.yml`](.github/workflows/ci.yml))执行:ShellCheck 静态检查 → 单元测试 → 构建 → `./build.sh --check` 产物一致性校验(源码变更后必须重新构建并提交 `dist/media_organizer`,否则 CI 失败)。
-### 13.1 目录结构
+### 源码结构
```text
build.sh # 构建脚本:bashly generate → 单文件分发脚本 dist/media_organizer
-dist/ # 构建产物目录(media_organizer,由 build.sh 生成,不入库)
+dist/ # 构建产物(media_organizer,随仓库提交,Gitea 可 raw 直接下载)
src/
- bashly.yml # CLI 定义(选项/位置参数/帮助文本/互斥),bashly 读取此文件
- main.sh # 文件头注释 + 常量 + 全局变量
+ bashly.yml # CLI 定义(选项/位置参数/互斥/帮助文本),bashly 读取
root_command.sh # root 命令实现(参数映射/形状校验/主流水线),由 bashly 包装
lib/
- main.sh # 常量 + 全局变量(最先加载)
- log.sh strings.sh # 基础设施:日志与色彩 / 字符串工具
- config/ # 配置与规则域
- config.sh # 配置加载与认证
- maps.sh # 特典映射/词表/跳过目录/季偏移/特典类别(含类目分区加载与查询)
- rules.sh # 命名模板渲染/校验 + 匹配规则加载
- storage/ # 数据持久化域
- cache.sh # TMDB 响应缓存(cache_* 系列)
- ledger.sh # 账本与冷却(增量标记 / 失败冷却 / 纠错账本)
- integrate/ # 外部集成域
- tmdb.sh # TMDB API(tmdb_api 统一缓存封装)
- ai.sh # AI 批处理(请求构造/调用/特典学习)
- media/ # 媒体识别域
- filename.sh # 媒体文件名解析(含 Music Videos 判定)
- identify.sh # 媒体识别(电影/电视剧/音乐视频)
- ai_resolve.sh # AI 结果消费(重搜/匹配/回退命名)
- pipeline/ # 执行流水线域
- registry.sh # 识别登记与并发池(register_* / emit / pool_run)
- process.sh # 目录校验/扫描/识别流水线
- link.sh # 硬链接基础工具(inode 判断/链接/错误处理)
- link_media.sh # 创建硬链接及伴随文件
- report.sh # 对照表导出与运行汇总
+ main.sh # 常量 + 全局变量 + 配置模板(最先加载)
+ log.sh strings.sh # 基础设施:日志与色彩 / 字符串工具
+ config/ # 配置与规则域:config.sh / maps.sh / rules.sh
+ storage/ # 数据持久化域:cache.sh / ledger.sh
+ integrate/ # 外部集成域:tmdb.sh / ai.sh
+ media/ # 媒体识别域:filename.sh / identify.sh / ai_resolve.sh
+ pipeline/ # 执行流水线域:registry.sh / process.sh / link.sh / link_media.sh / report.sh
+tests/
+ run.sh # 测试运行器(零依赖)
+ cases/ # 用例文件(按功能域同名分组)
+ lib/assert.sh # 断言库
```
-> 组织原则:**围绕功能域组织目录**(config/storage/integrate/media/pipeline),
-> 而非代码类型;不设 `utils/` 类通用目录(`strings.sh` 主题明确,属基础设施)。
-> 测试用例 `tests/cases/` 按同名功能域分组。
-
-### 13.2 构建
-
-```bash
-./build.sh # bashly generate + 语法/重复函数检查 → ./dist/media_organizer
-./build.sh --check # 仅校验 dist/media_organizer 是否与 src/ 最新源码一致(可接入 CI)
-```
-
-开发期依赖:`gem install bashly`(仅构建需要;产物自包含可独立运行)。
-bashly 开发模式:`bashly generate --watch`(源码变更自动重新生成)。
-
-### 13.3 参数解析(bashly)
-
-- 全部选项/位置参数/帮助文本在 `src/bashly.yml` 声明(含 `--opt=值` 与 `--opt 值` 两种形式、`--` 分隔符)
-- flag 互斥用 `conflicts` 声明(如 `--list-cache` 与 `--update-cache`)
-- 解析结果在 `root_command` 中经 `args` 关联数组访问并映射到全局变量:
- - 位置参数:`args[source_dir]` / `args[destination_dir]`
- - flag:`args['--dry-run']`(存在即 1);带参 flag:`args['--rerun']`
-- bashly 不支持 flag 可选参数:`--list-cache [关键词]` 的关键词经位置参数传递
-
-### 13.4 单元测试
-
-零依赖轻量测试框架(纯 bash):
-
-```bash
-./tests/run.sh # 运行全部用例
-./tests/run.sh cache # 仅运行名字含 cache 的用例文件
-./tests/run.sh -v # 详细模式(显示每个用例)
-```
-
-- 加载 `src/main.sh` + `src/lib/*.sh`(跳过 root_command.sh 的顶层代码),每个用例文件在独立子 shell 中运行(全局状态自动隔离)
-- 用例文件位于 `tests/cases/`,断言库 `tests/lib/assert.sh`(`assert_eq` / `assert_contains` / `assert_success` / `assert_failure` 等)
-- 参数解析由 bashly 生成器保证,不做单元测试;CLI 行为用冒烟验证
-
-### 13.5 静态检查(社区规范)
-
-```bash
-./lint.sh # bash -n + ShellCheck 零容忍检查(src 全部模块 + 构建/测试脚本)
-./lint.sh -v # 显示每个文件的检查状态
-```
-
-规范遵循 [Google Shell Style Guide](https://google.github.io/styleguide/shellguide.html) + ShellCheck:
-
-- 库文件首行 `# shellcheck shell=bash`;跨文件全局变量的 SC2034/SC2004 误报
- 在文件头部集中 disable 并注明原因;有意未使用的 read 解构/API 参数用 `_` 前缀
-- 格式:2 空格缩进、`[[ ]]` 测试、`local` 声明、变量引号包裹
-- 可选格式化:`shfmt -w src/`(本仓库未强制全量格式化,新代码建议按 shfmt 风格书写)
-
-### 13.6 修改流程
-
-1. 编辑 `src/` 下对应的模块文件(或 `src/bashly.yml` 调整 CLI 定义)
-2. 运行 `./tests/run.sh` 跑相关模块测试
-3. 运行 `./build.sh` 重新生成分发脚本(产物输出到 `dist/media_organizer`)
-
> [!WARNING]
>
-> **不要直接编辑 `dist/media_organizer`**——下次构建会覆盖你的修改。
+> **不要直接编辑 `dist/media_organizer`**——下次构建会覆盖你的修改。修改流程:编辑 `src/` 下对应模块 → `./tests/run.sh` → `./build.sh` → 提交源码与产物。
---
-## 14. 许可证
+## 🐛 故障排除
-本项目基于 **MIT License** 开源。允许自由使用、修改、分发,需保留版权声明。
+| 问题 | 解决方法 |
+| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `Permission denied config.json` | `chmod 600 mo_config/config.json` |
+| TMDB API 请求超时 | 增大 `TMDB_CURL_MAX_TIME`(如 60)、`TMDB_DELAY=2` |
+| 识别准确度低 | 配置 `AI_API_KEY` 启用 AI 辅助,或添加 `match_rules.json` 规则 |
+| AI 成本过高 | 减小 `AI_MAX_CALLS`(如 5),或先用 `AI_DRY_RUN=true` 测试 |
+| 无法创建硬链接 | 确认源/目标在**同一文件系统** |
+| 特典全部未匹配(`S00xxx` 而非 `S00E`) | `--list-cache` 确认 `tmdb/tv//season/0.json` 是否存在;缺失则 `--update-cache` 强制重取或 `--refresh-cache` 清空重跑;可在 `special_maps.json` 增强匹配 |
+| 季数错乱 | 在 `season_offsets.json` 添加偏移值(键 = 剧名小写或 `id:TMDB_ID`) |
+| 目标文件被覆盖产生 `.bak_*` | v9.1 起不再备份(直接替换),遗留 `.bak_*` 可手动清理 |
+| 运行中报 `Argument list too long` | 旧版特典季 JSON 作为命令行参数所致;使用新版(独立文件存储) |
---
-_文档生成于 2026-08-14,对应脚本版本 v9.6。_
+## 🤝 贡献指南
+
+欢迎任何形式的贡献——Bug 报告、功能建议、文档改进、Pull Request!
+
+- **提出问题**:在 [Issues](https://gitea.ppuc.lssa.fun/Shuery/MediaOrganizer/issues) 提交,请附上运行环境、复现步骤与相关日志
+- **架构约定**:动手前先读 [`CONTEXT.md`](CONTEXT.md)(领域术语表)与 [`docs/adr/`](docs/adr/)(架构决策记录)——命名与职责边界是项目的一等公民
+- **修改流程**:编辑 `src/` 模块 → `./tests/run.sh` 跑相关测试 → `./build.sh` 重新生成产物 → 提交源码与 `dist/media_organizer`
+- **代码风格**:遵循 [Google Shell Style Guide](https://google.github.io/styleguide/shellguide.html) + ShellCheck 零容忍(`./lint.sh`)
+- **测试要求**:新功能/修复请配套 `tests/cases/` 下的用例
+
+> [!NOTE]
+>
+> 本项目配套了完整的自动化质量闭环:`make check` 一条命令覆盖 lint + 测试 + 产物一致性,CI 强制 `dist/media_organizer` 与源码同步。
+
+---
+
+## 📄 许可证与致谢
+
+本项目基于 **MIT License** 开源(见 [LICENSE](LICENSE)),允许自由使用、修改、分发,需保留版权声明。
+
+**致谢**:
+
+- [TMDB(The Movie Database)](https://www.themoviedb.org) —— 媒体元数据与搜索 API
+- [bashly](https://bashly.dev) —— Bash CLI 生成器(开发期工具)
+- [Jellyfin](https://jellyfin.org) —— 开源媒体服务器,目录结构规范参照
+- [MyAnimeList / Jikan](https://jikan.moe) —— 可选兜底搜索 API
+- [Auto_Bangumi](https://github.com/EstrellaXD/Auto_Bangumi) 等开源项目 —— 部分识别思路借鉴(详见 ADR 记录)
+
+---
+
+_文档版本对应脚本 v9.6(`SCRIPT_VERSION` 为唯一权威版本号)。_