# 🎬 MediaOrganizer
> **Jellyfin 媒体库硬链接整理脚本**:通过 TMDB API 识别电影、电视剧、音乐与音乐视频,用**硬链接**在同一文件系统内**零拷贝**创建 Jellyfin 标准目录结构。AI 辅助识别、全量镜像缓存、特典自动归类、季数偏移、增量整理——一次整理,终身整洁。
[](LICENSE)
[](src/lib/main.sh)
[](https://www.gnu.org/software/bash/)
[](.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/pulls)
**作者**:LetsShareAll | **许可**:MIT | **语言**:Bash(零运行时依赖,单文件分发)
---
## 📑 目录
- [✨ 特性一览](#-特性一览)
- [🚀 快速开始](#-快速开始)
- [📦 获取项目](#-获取项目)
- [🧭 命令行模式与选项](#-命令行模式与选项)
- [📖 使用示例](#-使用示例)
- [📊 架构与执行流程](#-架构与执行流程)
- [🗂️ 输出目录结构](#️-输出目录结构)
- [🛠️ 配置详解](#️-配置详解)
- [🧠 核心机制](#-核心机制)
- [🧪 测试与开发](#-测试与开发)
- [🐛 故障排除](#-故障排除)
- [🤝 贡献指南](#-贡献指南)
- [📄 许可证与致谢](#-许可证与致谢)
---
## ✨ 特性一览
| 特性 | 说明 |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| 🎬 智能识别 | 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` 自包含全部配置模板,无需携带任何配套文件 |
### 处理流程(三阶段)
```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 部分失败"]
```
---
## 🚀 快速开始
> [!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` 可配)。
---
## 📦 获取项目
```shell
# 自托管 Gitea:SSH 或 HTTPS 克隆
git clone git@gitea.ppuc.lssa.fun:Shuery/MediaOrganizer.git
# 或
git clone https://gitea.ppuc.lssa.fun/Shuery/MediaOrganizer.git
```
- **免构建即用**:`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 份架构决策记录)
---
## 🧭 命令行模式与选项
一次调用对应一种**模式(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
# 自动化模式 + 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]
>
> 纠错结果会被学习到 `corrections.json`:同命名系列文件下次识别**直接命中**你指定的目标,无需再次修正。
---
## 📊 架构与执行流程
### 配置优先级链
配置值统一按 **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
```
### 识别搜索重试链
```mermaid
flowchart TD
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 待处理
(失败可逆跳过,下次自动重试)"]
```
> [!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 SCRIPT["📜 脚本负责执行"]
C["命名格式化(年份 / Season 补零
/ SxxExx / 目录结构)"]
D["硬链接 / 账本 / 汇总"]
end
B --> C --> D
```
- AI 输出**交叉验证**:特典学习产物必须是 TMDB 候选列表成员,防幻觉污染
- 失败语义:AI 请求失败 → 剩余条目显式跳过(**可逆**,下次运行自动重试);仅"AI 成功且判断无解"才走回退命名
---
## 🗂️ 输出目录结构
脚本在目的目录创建 **Jellyfin 官方规范**的结构(根目录名默认跟随系统语言,`FOLDER_*` 可配):
```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
```
**命名规则**:
| 类型 | 规则 |
| ----------------- | ------------------------------------------------------------------------------------------------- |
| 电影 | `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`);**视频 > 音频** 归属判定 |
> [!TIP]
>
> 剧集文件名**不含剧集名**——Jellyfin 通过父目录(`Shows/标题 (年份)/`)识别剧集,不影响刮削。撞名按官方多版本格式去重(文件名追加 `- 2`),同源旧链接按 inode 清理。
---
## 🛠️ 配置详解
### 配置文件体系
三类数据严格分离,各自独立生命周期:
```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 映射)
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]
>
> 旧版本配置文件(旧文件名 / 旧位置)在首次运行时**自动迁移**到上述布局,无需手工处理。
### config.json 主要配置项
参考 [`config.example.json`](config.example.json) 模板(不含真实密钥)。值统一为字符串,文件权限 600:
| 配置项 | 默认值 | 说明 |
| ------------------------------------------------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------- |
| `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 份) |
### 命名模板(`NAMING_*`)
命名格式化外置为可配置模板(v9.5),**默认模板与旧版输出逐字节一致**:
| 语法 | 含义 | 示例 |
| -------------- | ----------------------------------------------------- | ----------------------------------- |
| `{name}` | 变量值原样插入 | `{title}` → `Sword Art Online` |
| `{name:NN}` | 数字补零至 NN 位 | `{season:02}` → `01` |
| `{?name:text}` | 条件段:name 非空才渲染 text(text 内可含其他占位符) | `{?year: ({year})}` → `(2012)` 或空 |
```jsonc
{
"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}",
}
```
> [!WARNING]
>
> `Season NN` 与 `SxxExx` 是 **Jellyfin 解析格式**——修改 `NAMING_SEASON`/`NAMING_EPISODE` 默认结构可能导致刮削失败,请仅在了解后果时自定义。未知占位符渲染为空并打印警告。
### 用户匹配规则(`match_rules.json`)
```json
{
"search_aliases": {
"俺妹": "Ore no Imouto ga Konna ni Kawaii Wake ga Nai",
"路人女主剧场版": { "term": "Saekano the Movie", "year": "2019" }
},
"id_maps": {
"movie": { "刀剑神域": 20982 },
"tv": { "魔法禁书目录": 4654 }
}
}
```
- **搜索别名**:主搜索(zh → en)无结果时用重搜词兜底——适合本地俗称/简称(TMDB 搜不到"俺妹"但能搜到全名)
- **ID 映射**:标题命中直接使用指定 TMDB ID,**完全跳过搜索**(最高优先级)——适合同名动画/真人版易选错的场景
> [!TIP]
>
> 匹配规则优先级:**ID 映射 > 常规搜索链(zh → en → 别名 → 目录名 → MAL)> AI**。规则全部命中即走确定性路径,不消耗 AI 轮次。
### 类目分区(v9.6)
`special_maps` / `special_keywords` / `skip_directories` / `season_offsets` 四个配置类文件支持**分区形态**:顶层按类目分键,未配置回退 `default` 分区 → 全局表:
```json
{
"tv": ["menu", "ncop", "nced", "pv", "cm"],
"musicvideo": ["mv", "music video", "live", "concert", "演唱会"],
"default": ["特典", "特番"]
}
```
> [!NOTE]
>
> 分区语义因文件而异:`skip_directories` 分区形态 = **完整语义**(不叠加内置默认);`special_keywords` 分区 = **叠加语义**(积累型词表)。AI 学习写回自动适配分区(写 tv / video 分区)。
---
## 🧠 核心机制
### 缓存设计
- **镜像缓存**:`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["正常识别 + 链接
账本运行末尾统一落盘"]
```
- 任一账本失效(目标被删 / 源被替换)自动**重新识别 + 链接**,可逆语义完整
- 干运行不落盘、不启用跳过(展示全貌)
### 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{)}$$
---
## 🧪 测试与开发
### 一键命令
```shell
make check # lint + 单元测试 + 产物一致性(等同 CI 全部关卡)
make test # 单元测试
make lint # bash -n + ShellCheck 静态检查(零容忍)
make build # 构建 dist/media_organizer
```
### 单元测试
零依赖纯 bash 测试框架:加载全部模块,每个用例文件在独立子 shell 运行(全局状态自动隔离):
```shell
./tests/run.sh # 运行全部用例
./tests/run.sh cache # 仅运行名字含 cache 的用例文件
./tests/run.sh -v # 详细模式
```
> [!NOTE]
>
> 当前 **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 失败)。
### 源码结构
```text
build.sh # 构建脚本:bashly generate → 单文件分发脚本 dist/media_organizer
dist/ # 构建产物(media_organizer,随仓库提交,Gitea 可 raw 直接下载)
src/
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 / 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 # 断言库
```
> [!WARNING]
>
> **不要直接编辑 `dist/media_organizer`**——下次构建会覆盖你的修改。修改流程:编辑 `src/` 下对应模块 → `./tests/run.sh` → `./build.sh` → 提交源码与产物。
---
## 🐛 故障排除
| 问题 | 解决方法 |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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 作为命令行参数所致;使用新版(独立文件存储) |
---
## 🤝 贡献指南
欢迎任何形式的贡献——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` 为唯一权威版本号)。_