Files
Shuery 83d2008a5e
CI / lint + test + build (push) Canceled after 0s
docs: 重写 README(现代化精炼版,543 行)
- 8 枚 shields.io 徽章(MIT/v9.6/Bash/ShellCheck/120 tests/Google Shell/Gitea/PRs)
- 5 幅 Mermaid 图:三阶段流水线、配置优先级链、搜索重试链、AI 判断/执行分离、增量账本决策流
- 6 处 GitHub 提示块(NOTE/TIP/WARNING,规范格式防 Prettier 折叠)
- 结构精简:1438 行 → 543 行,保留模式/退出码/配置/缓存/特典/AI/纠错等全部关键事实
- 质量验证:Prettier 3 + markdownlint-cli2 0 问题 + 死链检查 0 确认失效
2026-08-15 00:12:39 +08:00

544 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🎬 MediaOrganizer
> **Jellyfin 媒体库硬链接整理脚本**:通过 TMDB API 识别电影、电视剧、音乐与音乐视频,用**硬链接**在同一文件系统内**零拷贝**创建 Jellyfin 标准目录结构。AI 辅助识别、全量镜像缓存、特典自动归类、季数偏移、增量整理——一次整理,终身整洁。
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-9.6-blue.svg)](src/lib/main.sh)
[![Bash](https://img.shields.io/badge/Bash-4.0%2B-black.svg)](https://www.gnu.org/software/bash/)
[![ShellCheck](https://img.shields.io/badge/ShellCheck-passing-brightgreen.svg)](.github/workflows/ci.yml)
[![Tests](https://img.shields.io/badge/tests-120%20passed-blueviolet.svg)](tests/)
[![Style: Google Shell](https://img.shields.io/badge/Style-Google%20Shell-green.svg)](https://google.github.io/styleguide/shellguide.html)
[![Hosted on](https://img.shields.io/badge/hosted%20on-Gitea-609926.svg)](https://gitea.ppuc.lssa.fun/Shuery/MediaOrganizer)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](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 扫描源目录<br>视频 VIDEO_FILES + 音频 AUDIO_FILES"] --> B["第一阶段 识别池<br>process_video + process_audio<br>并发 MEDIA_WORKERS 个 worker<br>parse → TMDB 识别 → register 登记"]
B --> C["第二阶段 AI 批处理<br>run_ai_batch(AI_BATCH_SIZE 条/批)<br>纠正搜索词 / 匹配甄别 / 判定艺术家 / 学习特典<br>AI 失败 → 剩余显式跳过(可逆)"]
C --> D["第三阶段 硬链接<br>link_media:mkdir → ln(幂等 / 撞名去重)<br>→ 伴随文件(字幕 / 音轨 / 歌词 / 封面)"]
D --> E["运行级汇总 + 退出码分级<br>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 [email protected]: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 参数<br>--dry-run / --src-dir ..."] --> P{"最终配置值"}
B["环境变量<br>TMDB_LANG / AI_MODEL ..."] --> P
C["config.json<br>mo_config/ 白名单键"] --> P
D["内置默认值<br>CONFIG_DEFS 单一数据源"] --> P
```
### 识别搜索重试链
```mermaid
flowchart TD
A["识别搜索<br>identify_tv_show / identify_movie"] --> B["zh-CN 主搜索"]
B -- "无结果" --> C["en-US 重搜"]
C -- "无结果" --> D["规则别名 search_aliases"]
D -- "无结果" --> E["目录名兜底<br>(清洗 + 剥季后缀)"]
E -- "无结果" --> F["MAL 候选回搜<br>(SEARCH_FALLBACK_MAL=true,可选)"]
F -- "无结果" --> G["后缀季二次剥离<br>(Railgun T / II / 2nd)"]
G -- "仍失败" --> H["交 AI 待处理<br>(失败可逆跳过,下次自动重试)"]
```
> [!NOTE]
>
> **ID 映射优先于一切搜索**:`match_rules.json` 的 `id_maps` 标题命中后直接使用指定 TMDB ID、完全跳过搜索(适合 TMDB 多候选易选错、同名动画/真人版场景);`search_aliases` 仅在常规搜索无结果时介入。
### AI:判断引擎与执行引擎分离
```mermaid
flowchart TD
subgraph AI["🤖 AI 只做判断"]
A["输入:文件信息 + 目录链 +<br>TMDB 搜索结果 + 时长/分辨率<br>+ 同目录兄弟文件"] --> B["输出:choice / season_shift<br>/ search_term / artist /<br>特典匹配关键字"]
end
subgraph SCRIPT["📜 脚本负责执行"]
C["命名格式化(年份 / Season 补零<br>/ 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/<hash>.json # 搜索缓存(包裹格式,含空哨兵)
│ ├── tv/<id>.<lang>.json # 剧集详情(语言段隔离)
│ ├── tv/<id>/season/<n>.<lang>.json # 每季数据(含 season 0 特典季)
│ └── movie/<id>.<lang>.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/<id>.<lang>.json`),切换 `TMDB_LANG` 自动 miss 重取
- **并发去重**:识别池多 worker 同时 miss 同一查询时,`flock` 互斥 + 锁内**双检**——同一查询只发一次 TMDB 请求
- **空哨兵**:搜索"查无此片"写 `empty:true` 包裹缓存(3 天短 TTL),新片出现后自动重新搜索;请求失败不写缓存,下次运行重试
- **原子写**:先写临时文件再 `rename`,避免并发/中断产生半截 JSON
### 增量账本与失败冷却
```mermaid
flowchart TD
A["识别池入口 process_one_file"] --> B{"纠错学习命中?<br>corrections.json"}
B -- "是" --> C["直接用用户指定目标<br>(跳过 parse/识别/AI)"]
B -- "否" --> D{"已链接账本命中?<br>源存在 + 目标存在 + 同 inode"}
D -- "是" --> E["结局 already_linked 跳过"]
D -- "否" --> F{"失败冷却中?<br>request_failed / skip_unidentified"}
F -- "是" --> G["结局 cooldown 跳过<br>(过期自动重试)"]
F -- "否" --> H["正常识别 + 链接<br>账本运行末尾统一落盘"]
```
- 任一账本失效(目标被删 / 源被替换)自动**重新识别 + 链接**,可逆语义完整
- 干运行不落盘、不启用跳过(展示全貌)
### 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/<id>/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` 为唯一权威版本号)。_