- .gitignore 移除 dist/ 忽略(仅忽略构建临时文件) - dist/media_organizer 随仓库提交:Gitea raw 链接可直接下载单文件产物 - README 获取项目恢复 curl 下载引导(raw/branch/main/dist/media_organizer) - build.sh 注释明确入库策略:CI --check 强制产物与源码同步
114 KiB
🎬 MediaOrganizer
Jellyfin 媒体库硬链接整理脚本:通过 TMDB API 识别电影、电视剧与音乐视频,用硬链接零拷贝创建 Jellyfin 标准目录结构。AI 辅助识别、全量镜像缓存、特典自动归类、季数偏移,一次整理,终身整洁。
作者:LetsShareAll | 许可:MIT | 语言:Bash(零运行时依赖,单文件分发)
📦 获取项目
# 自托管 Gitea(推荐):SSH 或 HTTPS 克隆
git clone [email protected]: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 |
处理流程(三阶段)
flowchart LR
A["scan_files 扫描源目录<br>视频 VIDEO_FILES + 音频 AUDIO_FILES"] --> B["第一阶段 识别池 process_video + process_audio<br>并发 MEDIA_WORKERS 个 worker<br>parse → identify_movie / identify_tv_show<br>音频走艺术家归类链(元数据→目录名)<br>→ register 层写入 MEDIA_DESTINATION_MAP<br>+ MEDIA_OUTCOME_MAP 结局账本"]
B --> C["第二阶段 run_ai_batch(分批 AI_BATCH_SIZE/批)<br>AI 纠正搜索词 + 匹配甄别(选 id/季映射)<br>+ 判定多艺术家 + 学习特典映射<br>→ 重处理 PENDING → 回退命名(降级成功)<br>AI 失败 → 剩余显式跳过(可逆)"]
C --> D["第三阶段 link_media<br>mkdir → 硬链接(幂等/撞名去重 - 2)<br>→ 配套文件(字幕/音轨/歌词/封面)"]
D --> E["运行级汇总 + 退出码分级<br>0 全部成功 / 3 部分失败(非预期跳过/请求失败)"]
📑 目录
- 获取项目
- 简介与功能
- 依赖与环境要求
- 快速开始
- 命令行选项
- 执行流程总览
- TMDB API 调用详解
- 执行判断详解
- 配置文件详解
- 输出目录结构
- 核心算法与公式
- 日志与调试
- 故障排除
- 构建与开发(源码结构)
- 许可证
1. 简介与功能
本脚本扫描源目录中的媒体文件,通过 TMDB API 识别其真实标题,并使用硬链接在目的目录创建 Jellyfin 规范的目录结构(不复制数据、不占用额外磁盘空间)。
功能细节
| 特性 | 说明 |
|---|---|
| 🎬 智能识别 | TMDB API 匹配电影与电视剧,支持多种文件名格式 |
| 🔗 硬链接整理 | 同一文件系统内零拷贝,省空间、省时间 |
| � 媒体类型智能判断 | 文件名无明确季集/年份特征时,根据媒体目录内正片数量判断电影/剧集(不依赖下载目录,合集种子可混合);特典归属同理(媒体目录仅 1 个正片→电影特典跳过,多个→剧集特典 Season 00) |
| 🤖 AI 辅助识别 | 无法匹配/需甄别时调用 OpenAI 兼容接口(默认 DeepSeek-V4-Flash)纠正搜索词、匹配甄别(选候选 id + 季映射)、判定艺术家、学习特典映射;分批处理、失败可逆跳过 |
| 📚 持久化缓存 | 缓存根目录 mo_cache/ 分两区:tmdb/ 镜像 TMDB API 路径缓存全部请求数据(search/movie|tv/<hash>.json、tv/<id>.<lang>.json、tv/<id>/season/<n>.<lang>.json、movie/<id>.<lang>.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 兼容) |
2. 依赖与环境要求
| 依赖 | 用途 | 安装示例 (Arch) |
|---|---|---|
| Bash 4.0+ | 脚本运行环境 | 系统自带 |
| curl | 调用 TMDB / AI API | sudo pacman -S curl |
| jq | JSON 解析 | sudo pacman -S jq |
| ffprobe | 媒体探测(依赖检查) | sudo pacman -S ffmpeg |
Warning
源目录与目标目录必须在同一文件系统上,否则无法创建硬链接(脚本启动时会自动检测)。
3. 快速开始
3.1 首次运行(生成配置)
# 首次运行会提示创建配置模板
./dist/media_organizer /downloads /media
- 若无 TMDB 密钥,会询问是否生成
config.json模板 → 输入y - 在
mo_config/config.json中填写TMDB_API_RA_TOKEN - 设置权限:
chmod 600 mo_config/config.json
3.2 干运行测试
# --dry-run 零持久化副作用:不创建链接、不写缓存、不写日志(网络请求照常但不落盘)
./dist/media_organizer --dry-run /downloads /media
3.3 正式运行
./dist/media_organizer /downloads /media
3.4 自动化模式(适合定时任务)
# 写入 /var/log,跳过无法处理的文件
./dist/media_organizer -a /downloads /media
# 配合 cron 定时运行
0 3 * * * /path/to/dist/media_organizer -a /downloads /media
Tip
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 预览) |
选项:
| 选项 | 说明 |
|---|---|
-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 章。
flowchart TD
START(["main 脚本入口"]) --> PA["parse_args<br>索引遍历 逐项分发(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<br>下一位非选项则作过滤词"]
CASE -- "--src-dir / --dest-dir<br>(空格或 = 形式)" --> OPT6["收集命名目录"]
CASE -- "--" --> DASH["后续均为位置参数"]
CASE -- "-h / --help" --> HELP["show_help 打印帮助"]
CASE -- "--version" --> VER["打印版本号"]
CASE -- "未知 -*" --> UNK["_usage_error 报错<br>附用法提示"]
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["形状推导<br>通道互斥校验<br>list=0 目录 / cache-only=1 / organize=2"]
SHAPE -- "冲突或个数不符" --> UNK
SHAPE --> LC{"--list-cache?"}
subgraph CONFIG["① 配置与初始化"]
LC -- "是" --> ICD2["init_cache_dir<br>解析缓存路径 建子目录"]
ICD2 --> LCL["cache_list 按过滤词列出<br>列出后退出"]
LC -- "否" --> CFG["load_config"]
CFG --> CFG1["find_config_file 定位 config.json"]
CFG1 --> CFG2["check_secure_file 校验 600 权限"]
CFG2 --> CFG3["白名单键提取<br>从 CONFIG_TEMPLATE 取键名集合<br>逐键 parse_config_key 读 config.json<br>(文件永不执行)"]
CFG3 --> CFG4["应用配置链<br>CLI > 环境变量 > config.json > 默认值<br>扩展名/curl/缓存 TTL 等全部配置项"]
CFG4 --> IC["init_colors 初始化色彩"]
IC --> SA["select_auth 认证选择<br>(决策见 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<br>SPECIAL_KEYMAP_TEMPLATE 落盘"]
ISM3 -- "是" --> ISM5["跳过创建"]
ISM4 --> ISM6["load_special_map"]
ISM5 --> ISM6
ISM2 -- "是" --> ISM6
ISM6 --> ISM7["第一遍:读 RAW_SPECIAL_MAP<br>值=数组或字符串引用"]
ISM7 --> ISM8["第二遍:resolve_special_value<br>递归展开引用 → SPECIAL_MAP 统一 JSON 数组"]
ISM8 --> ISO["init_season_offset 季偏移"]
ISO --> ISO1["内嵌 SEASON_OFFSET_TEMPLATE<br>→ SEASON_OFFSET_MAP"]
ISO1 --> ISO2["外部 season_offsets.json 覆盖<br>(剧名小写 或 id: 键)"]
ISO2 --> ISO3{"文件不存在?"}
ISO3 -- "是" --> ISO4["由 SEASON_OFFSET_MAP 生成默认 JSON"]
ISO3 -- "否" --> UPD
ISO4 --> UPD{"cache-only?<br>--update-cache 且仅 1 个目录"}
UPD -- "是" --> VCO["validate_directories<br>校验源目录存在可读"]
VCO --> ICCO["init_cache_dir<br>(含 --refresh-cache 清空)"]
ICCO --> UC["update_cache<br>扫描源目录唯一查询<br>强制重取覆盖缓存后退出"]
UPD -- "否" --> VD["validate_directories<br>源目录存在可读<br>目的目录可写/可创建<br>(干运行不创建目录)"]
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 清空缓存<br>(干运行跳过并警告)"]
ICD2A -- "否" --> ICD4
ICD3 --> ICD4["mkdir search/movie|tv tv movie 子目录<br>(干运行跳过)"]
ICD4 --> TRAP["trap ERR 注册错误处理"]
TRAP --> BANNER["打印版本/源/目的/运行模式"]
end
subgraph PIPE["② 三阶段流水线"]
BANNER --> SF["scan_files 扫描"]
SF --> SF1["find 视频扩展名文件<br>clean_name 去方括号后 sort"]
SF1 --> SF2["填充 VIDEO_FILES 数组"]
SF2 --> SF3["find 音频扩展名文件<br>填充 AUDIO_FILES"]
SF3 --> PM["识别池 process_video + process_audio<br>并发 MEDIA_WORKERS 个 worker<br>子进程 emit 协议行 → 父进程合并登记<br>(register_* 写入目的映射+结局账本)"]
PM --> PM1{"worker 处理单个文件<br>遍历 VIDEO_FILES / AUDIO_FILES"}
PM1 -- "是" --> PM2["parse_media_filename<br>→ type|title|year|season|episode|fragment<br>(决策见 7.2)"]
PM2 --> PM3{"type 分支"}
PM3 -- "movie" --> PM4["identify_movie 搜索电影<br>(见 6/7)"]
PM3 -- "tv" --> PM5["identify_tv_show 搜索剧集<br>(见 7.3/7.4)"]
PM3 -- "skip" --> PM6["电影特典跳过不整理<br>自动化写 SKIP_LOG_FILE"]
PM3 -- "unknown" --> PM7["PENDING_AI_SEARCH 记录<br>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 目录<br>([日期]/专辑/格式特征)"]
PAUD1 --> PAUD2["parse_cd_dir 解析 歌手|专辑"]
PAUD2 --> PAUD3["目标 Music/歌手/专辑[/子碟] 入 MAP"]
PAUD3 --> RAB{"AI 密钥存在 且 有待处理项?"}
RAB -- "否" --> LM
RAB -- "是" --> AIB["run_ai_batch 第二阶段<br>(见 7.5)"]
AIB --> AIB1["ai_batch_request 一次合并请求<br>search + special"]
AIB1 --> AIB2["构造输入 JSON → curl AI 接口"]
AIB2 --> AIB3["解析:search 追加词/年/类别<br>special 写 keymap 文件+内存"]
AIB3 --> AIB4{"遍历 PENDING_AI 还有?"}
AIB4 -- "是" --> AIB5["parse 重识别<br>AI 纠正词重搜<br>media_type 定 movie/tv"]
AIB5 --> AIB6{"识别成功?"}
AIB6 -- "是" --> AIB7["记录目标路径"]
AIB6 -- "否" --> AIB8["回退命名(原始标题)"]
AIB7 --> AIB4
AIB8 --> AIB4
AIB4 -- "结束" --> LM["link_media 第三阶段硬链接<br>(见 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["处理配套文件<br>base_name.* → is_companion<br>→ 保语言后缀 → 硬链接"]
LM4 -- "失败" --> LMSKIP
LM5 --> LM1
LMSKIP --> LM1
LM1 -- "结束" --> SUM["汇总输出<br>成功 hardlink_count 个<br>跳过 skip_count 个"]
end
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 响应解析走四级容错链(直接 → 围栏 → 思考链剥离 → 最外层 {} 块),推理模型的
<thinking>/「思考:」输出不再导致整批失效;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}}
flowchart TD
A[查找配置] --> B{环境变量已指定?<br>如 SPECIAL_MAP_FILE=...}
B -- 是 --> B1[使用环境变量路径]
B -- 否 --> C{执行目录已有文件?<br>配置类文件: $PWD/mo_config/xxx<br>(config.json / special_maps / ...)}
C -- 是 --> C1[使用执行目录路径]
C -- 否 --> D[使用脚本目录路径<br>$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/ |
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 <token>TMDB_API_KEY:URL 参数api_key=<key>
- 固定参数:
language=<TMDB_LANG>(默认zh-CN,影响返回的中/英文名) - curl 行为:
GET、重试TMDB_CURL_RETRY次、连接超时TMDB_CURL_CONNECT_TIMEOUT、最大时长TMDB_CURL_MAX_TIME、-fS(HTTP 错误时返回非零) - 返回值:JSON 响应写入 stdout;失败返回非零退出码并记录
[错误] TMDB 请求失败
# 调用示例(脚本内部)
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 调用顺序(识别一个剧集文件时)
flowchart TD
A["identify_tv_show"] --> A1["归一化季/集号<br>10# 去前导零"]
A1 --> B["tmdb_api /search/tv 搜索剧集<br>统一缓存封装(cache_get→curl→cache_put)"]
B --> C{"results[0].id 非空?"}
C -- "否" --> PENDING["记录到 AI 待处理<br>返回失败"]
C -- "是" --> D["tmdb_api /tv/id 获取详情<br>取 number_of_seasons"]
D --> E{"season>1 且<br>total_seasons<season?"}
E -- "是" --> F["tmdb_api /tv/id/season/1<br>取第一季集数用于偏移"]
E -- "否" --> G
F --> G["tmdb_api /tv/id/season/0<br>特典季数据(season0_json)"]
G --> H{"取季集名(按需)"}
H -- "season==0" --> H0["tmdb_api /tv/id/season/0<br>按集号取特典集名"]
H -- "普通季" --> I["tmdb_api /tv/id/season/N<br>取第 N 季集名"]
H0 --> J
I --> J["safe_printf_int 补零<br>构造目标路径输出"]
6.4 缓存方案(v9.1:TMDB 缓存与脚本缓存分离)
v9.0 将缓存方案完全重做:缓存目录结构镜像 TMDB API 端点路径,一切请求数据(搜索、详情、各季)全量缓存,二次运行零外部请求。缓存根目录
mo_cache/仅存放可再生数据(tmdb/= API 响应缓存,media_organizer/= 运行状态账本);用户配置类文件(特典映射/词表、跳过目录、季偏移,可编辑 + 脚本可写回)统一位于mo_config/。
缓存目录结构(默认 mo_cache/):
mo_cache/
├── tmdb/ # TMDB API 响应缓存(--refresh-cache 仅清空此区)
│ ├── search/
│ │ ├── movie/<md5(query|year|lang)>.json # 搜索缓存(包裹格式)
│ │ └── tv/<md5(query|lang)>.json
│ ├── tv/
│ │ ├── <id>.<lang>.json # 剧集详情(原始 JSON;语言段随 TMDB_LANG)
│ │ └── <id>/season/<n>.<lang>.json # 每季数据(含 season 0 特典季)
│ └── movie/
│ └── <id>.<lang>.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 # 季偏移(运行时生成)
旧版自动迁移(仅执行一次,干运行不迁移):① 布局迁移
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,含查询参数与获取时间):
{
"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/<hash>.json,位于tmdb/下);详情/季请求从 key 中提取 id 作路径并附加语言段(tv/<id>.<lang>.json、tv/<id>/season/<n>.<lang>.json、movie/<id>.<lang>.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 请求/秒)。
缓存维护命令:
# 查看缓存(哈希/类型/路径/参数/获取时间),可按关键词过滤
./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
为何不再用内存缓存 + 同步文件:v8 的
SHOW_CACHE/SEASON_CACHE在识别函数(子 shell)内直接赋值不传播回父 shell,依赖CACHE_SYNC_FILE同步文件绕行,且无法缓存全部请求数据。v9.0 改为纯磁盘镜像缓存,天然规避子 shell 问题,且搜索/详情/季数据全量保存。
6.5 源→目标对照表(--export-map,按分类展示)
脚本运行完毕后,可生成按分类展示的源→目标对照表 markdown 文档,用于核对整理结果(源文件、目标文件、链接状态)。
# 生成对照表(默认输出到脚本目录/mo_map/<源目录名>-<md5前8位>.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):
# 媒体整理对照表
- 源目录 / 目的目录 / 生成时间 / 运行模式
## 📊 分类汇总(N 条) ← 各类条目数一览
- 🎬 电影:N 条 / 📺 节目:N 条 / ...
## 🎬 电影(N 条) ← 每类独立小节与编号
| # | 源文件 | 目标文件 | 链接状态 |
## ⏭ 未完成(未识别/跳过/失败/待 AI)(N 条)
## 链接状态汇总 ← 已链接/失败/跳过计数
链接状态在非干运行下现场校验(目标存在且与源同 inode);干运行标记
🔄 干运行。伴随文件(字幕/音轨等)在链接成功后登记映射,随主媒体归入对应大类(📎 伴随文件),未链接成功的不展示。0 条的分类不输出小节。文件名命名:<源目录名>-<源目录绝对路径 md5 前 8 位>.md——源目录名(sanitize 去非法字符,空名回退root)保证可读,md5 前缀保证唯一与稳定(同一源目录多次运行互相覆盖)。
7. 执行判断详解
本章是脚本的决策树,展示每一个关键分支判断。箭头上的文字为判断条件,菱形为判断节点。
7.1 TMDB 认证选择
flowchart TD
A[select_auth] --> B{TMDB_API_RA_TOKEN 非空?}
B -- 是 --> B1[Bearer 认证<br>TMDB_AUTH_TOKEN=RA_TOKEN]
B -- 否 --> C{TMDB_API_KEY 非空?}
C -- 是 --> C1[API Key 认证<br>TMDB_AUTH_TOKEN=API_KEY]
C -- 否 --> D{自动化模式?}
D -- 否 --> E[提示生成 config.json 模板]
E --> E1{用户输入 y?}
E1 -- 是 --> E2[生成模板 退出码 0]
E1 -- 否 --> F[报错 退出码 1]
D -- 是 --> F
7.2 文件类型识别(parse_media_filename)
脚本按顺序尝试匹配,第一个命中的格式生效。所有格式均不命中时,不再默认判为电影,而是根据媒体目录内正片数量判断(v9.3,不依赖下载目录——合集种子可能把剧场版电影与剧集混放)。
7.2 主决策树(顺序匹配)
flowchart TD
A["parse_media_filename<br>file → basename<br>base=去扩展名 ext=扩展名<br>clean_name 去方括号"] --> B{"匹配 Title (Year)?<br>^(.*)\([0-9]{4}\)$"}
B -- "是" --> B1["movie 电影<br>title=去尾部空白<br>输出 movie|title|year"]
B -- "否" --> C{"匹配 S##E##?<br>[\ ._-]*[Ss][0-9]{2}[Ee][0-9]{2}"}
C -- "是" --> C1["tv 剧集<br>提取 season/episode<br>strip_season_suffix 去季后缀"]
C -- "否" --> D{"匹配 #x##?<br>[0-9]{1,2}[xX][0-9]{2}"}
D -- "是" --> D1["tv 剧集<br>提取 season/episode"]
D -- "否" --> E{"特典识别?<br>(原子步骤见 7.2A)"}
E -- "是" --> E1["特典处理<br>(原子步骤见 7.2A)"]
E -- "否" --> F{"包含 Season 关键词?<br>[0-9]+(st|nd|rd|th)? Season"}
F -- "是" --> F1["tv 季份<br>season=提取数字<br>episode=方括号 [N]"]
F -- "否" --> G{"匹配方括号 [数字]?<br>且非 1080/720/480/2160/4320"}
G -- "是" --> G1["tv 剧集<br>episode=[N] season=1<br>标题去掉 [N]"]
G -- "否" --> H["回退:正片数量判断<br>(原子步骤见 7.2B)"]
Note
v9.6 Music Videos 前置检查:年份提取后、上述 TV 规则之前先做音乐视频信号检查(原子步骤见 7.2C)——目录信号(目录名精确匹配 musicvideo 词)命中即判
musicvideo(即使文件名含季集标记);文件名信号([MV]等标记 + "歌手 - 歌名" 模式)同理。未命中任何信号才进入上述主决策树。
7.2A 特典识别与处理(原子步骤)
特典识别优先于季份(如
Show 2nd Season [Menu01]先识别为特典 Season 00)。特典词在方括号标记内匹配(避免误判标题),也支持父目录判断(文件位于SPs/、CDs/、Bonus/等)。
flowchart TD
SA{"遍历特典词表(special_keywords)<br>文件名方括号内含特典词?<br>menu/ncop/nced/pv/cm/sp/teaser/<br>promo/trailer/special/mv/特典/花絮"}
SA -- "是" --> SA1["is_special=true<br>frag=命中特典词(去空格)<br>tag=完整方括号标记"]
SA -- "否" --> SB{"父目录是特典目录?<br>SPs/Specials/CDs/Bonus/<br>Extras/特典/特番/花絮"}
SB -- "是" --> SA1
SB -- "否" --> SC["非特典 → 回到主流程季份判断"]
SA1 --> SD["find_show_path_from_file<br>向上跳过特典/分类/CD 目录<br>找到媒体目录完整路径"]
SD --> SE{"count_main_videos(媒体目录)<br>正片数量?"}
SE -- "==1(电影特典)" --> SF["type=skip title=movie_extra<br>跳过不整理<br>TMDB/Jellyfin 不收录电影特典<br>避免误判为剧集特典"]
SE -- ">=2 或找不到(剧集特典)" --> SG["进入 Season 00 处理"]
SG --> SH{"frag 非空?<br>文件名标记命中特典词"}
SH -- "是" --> SI["ep_num=tag 中首个数字<br>sp_frag=frag+编号<br>如 Preview02 → Preview+02"]
SH -- "否" --> SJ{"有方括号标记?"}
SJ -- "是" --> SK["逐个方括号片段挑选<br>跳过压制/编码/画质标记<br>vcb/ma10p/x264/flac/1080p...<br>取首个非技术标记"]
SJ -- "否" --> SL["无标记(裸特典如 CM01.mkv)<br>sp_frag=clean_name 文件名"]
SK --> SM["season=0<br>special_fragment=sp_frag"]
SL --> SM
SI --> SM
SM --> SN{"标题仅由特典标记构成?<br>无剧名"}
SN -- "是" --> SO["find_show_dir_from_path<br>从父目录链向上找剧名目录"]
SN -- "否" --> SP
SO --> SP["type=tv<br>输出 tv|标题|S0|集号|fragment"]
7.2B 正片数量判断(回退,v9.3)
Note
不依赖下载目录。仅统计"正片":跳过
SPs/CDs/Scans/Fonts/特典等子目录;扩展名取VIDEO_EXTS(mka 是纯音频容器,不计入)。结果按媒体目录缓存(MAIN_COUNT_CACHE),避免重复扫描。
flowchart TD
FB["文件名无明确季集/年份特征<br>(如压制组风格 [Group] Title [1080p])"] --> FB1["find_show_path_from_file<br>向上找媒体目录完整路径"]
FB1 --> FB2{"找到媒体目录?"}
FB2 -- "否" --> FU["type=unknown<br>记录 PENDING_AI_SEARCH<br>交 AI 判断类别"]
FB2 -- "是" --> FB3["count_main_videos<br>find -maxdepth 2 统计正片<br>跳过特典/附带子目录<br>mka 不算视频"]
FB3 -- "==1" --> FB4["movie 电影<br>title=cleaned<br>(如合集里的剧场版)"]
FB3 -- ">=2" --> FB5["tv 剧集<br>season=1 episode=0<br>(多集动画)"]
FB3 -- "==0" --> FU
支持的文件名格式:
| 格式 | 示例 | 识别结果 |
|---|---|---|
| 电影(带年份) | 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/演唱会):
flowchart TD
MA{"目录信号:父目录链任意段<br>目录名精确匹配 musicvideo 词?<br>(归一化去空格小写整名相等,<br>如 Music Videos/MV/Live/演唱会)"}
MA -- "是" --> M1["musicvideo<br>输出 musicvideo|title|year"]
MA -- "否" --> MB{"文件名信号:方括号标记<br>子串命中 musicvideo 词?<br>(如 [MV]/[Live])"}
MB -- "否" --> MC["非音乐视频 → 主决策树"]
MB -- "是" --> MD{"标记同时命中特典词?<br>(如 [MV]/[PV] 双命中)"}
MD -- "否" --> M1
MD -- "是" --> ME{"文件名含 \" - \" 模式?<br>(歌手 - 歌名)"}
ME -- "是" --> M1
ME -- "否" --> MC["保持特典路径<br>(\"动画名 [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 季数与实际不符的情况。以下为每一个原子化操作:
flowchart TD
A["identify_tv_show 输入<br>title season episode base ext fragment file"] --> A1["归一化季/集号<br>10# 去前导零(防 08 当八进制)"]
A1 --> A2["strip_season_suffix 去季后缀<br>→ search_name"]
A2 --> S1["tmdb_api /search/tv<br>query=search_name"]
S1 --> S2{"results[0].id 非空?"}
S2 -- "否" --> S3["PENDING_AI_SEARCH 记录<br>(文件名|父目录|tv)返回失败"]
S2 -- "是" --> S4["提取 show_title / year<br>sanitize 清理非法字符"]
S4 --> S5["tmdb_api /tv/{id}<br>→ number_of_seasons"]
S5 --> S5A{"season>1 且 total<season?<br>需要第一季集数"}
S5A -- "是" --> S5B["tmdb_api /tv/{id}/season/1<br>→ s1_ep_count"]
S5A -- "否" --> S6
S5B --> S6["tmdb_api /tv/{id}/season/0<br>→ season0_json(特典季原始 JSON)"]
S6 --> OFF{"偏移判断<br>原子步骤见 7.3A"}
OFF --> TM{"season==0 且 fragment 非空?<br>(特典匹配,原子步骤见 7.4)"}
TM -- "否" --> EP["tmdb_api /tv/{id}/season/N<br>取 episode_name(季集名)"]
TM -- "是" --> EP
EP --> EP1{"episode_name 空?"}
EP1 -- "是" --> EP2["用文件名尾部残余<br>或 Episode {e_fmt}"]
EP1 -- "否" --> OUT
EP2 --> OUT["safe_printf_int 补零<br>S{s_fmt}E{e_fmt}<br>输出 Shows/... 目标路径"]
7.3A 季偏移决策(原子步骤)
flowchart TD
A{"season>1 且<br>total_seasons < season?"}
A -- "否" --> OK["正常处理<br>无需偏移"]
A -- "是" --> B{"s1_ep_count > 0?<br>第一季集数可获取"}
B -- "是" --> B1["自动偏移<br>episode = episode + s1_ep_count<br>season = 1"]
B -- "否" --> C{"get_season_offset 命中?<br>SEASON_OFFSET_MAP[剧名小写]<br>或 [id:TMDB_ID]"}
C -- "是" --> C1["手动偏移<br>episode = episode + offset<br>season = 1"]
C -- "否" --> D["报错 无法计算季偏移<br>自动化写 SKIP_LOG_FILE<br>返回失败 跳过文件"]
示例:资源实际为 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 处理)。以下为剧集特典的每一个原子化操作:
flowchart TD
A{"season==0 且<br>special_fragment 非空?"}
A -- "否" --> NORMAL["正常集处理"]
A -- "是" --> B["translate_fragment 别称归一化<br>(原子步骤见 7.4A)"]
B --> C["match_special_episode<br>用标准键匹配季0<br>(原子步骤见 7.4B)"]
C -- "成功" --> C1["tmdb_matched=0<br>episode = TMDB 集号"]
C -- "失败" --> C2{"用原始 fragment<br>再次 match_special_episode?"}
C2 -- "成功" --> C1
C2 -- "失败" --> D["记录 PENDING_AI_SPECIAL<br>show_id|fragment → 待 AI 学习"]
D --> F["sanitize 清理<br>S00{类型}{编号} 命名<br>如 CM01 → S00CM01 - CM01"]
C1 --> G["season0_json 按集号取集名<br>用 S00E{集号} - TMDB集名 命名"]
特典匹配使用多语言别称:候选词 = 原始片段 + 去数字核心 + keymap 值数组中的全部多语言值(中文/日文/英文缩写),逐一
contains(忽略大小写)匹配 TMDB 季 0 的集名。
7.4A translate_fragment(原子步骤)
flowchart TD
T1["输入 fragment<br>如 Menu01 / WebPreview01"] --> T2{"SPECIAL_MAP[fragment 小写]<br>精确命中?"}
T2 -- "是" --> T6["值=JSON 数组<br>取第一个作为标准键返回"]
T2 -- "否" --> T3["去末尾数字得到核心词<br>Menu01 → menu<br>转小写"]
T3 --> T4{"SPECIAL_MAP[核心词] 命中?"}
T4 -- "是" --> T6
T4 -- "否" --> T5["无映射<br>返回原 fragment"]
7.4B match_special_episode(原子步骤)
flowchart TD
M1["输入 show_id fragment fallback season0_json"] --> M2{"season0_json 为空?"}
M2 -- "是" --> MF["返回 fallback 集号"]
M2 -- "否" --> M3["构造候选词列表 terms<br>① fragment 本身<br>② 去数字核心词<br>③ keymap 值数组全部元素<br>(多键→多值展开后)"]
M3 --> M4{"遍历 terms 还有?"}
M4 -- "是" --> M5["term 转小写<br>jq 匹配 season0 episodes[].name<br>(ascii_downcase contains)"]
M5 -- "命中" --> M6["返回该 TMDB 集号"]
M5 -- "未命中" --> M4
M4 -- "结束" --> M7["日志警告 未匹配<br>返回 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 为批次上限。以下为每一个原子化操作:
flowchart TD
A["run_ai_batch"] --> B{"AI_API_KEY 非空?"}
B -- "否" --> SKIP["搜索/匹配待定显式 skip_unidentified(可逆)<br>多艺术家直接拼接"]
B -- "是" --> C{"有待处理项?<br>四类 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 安全转义)<br>search_entries(file/directory 目录链/type)<br>+ special_entries(show_id/fragment/season0 全量)<br>+ artist_entries(多艺术家/专辑)<br>+ match_entries(search 原样 + seasons 四字段提炼)"]
D1 --> D2["拼 AI_BATCH_PROMPT + Input → payload<br>temperature=0.2"]
D2 --> D3{"AI_DRY_RUN=true?"}
D3 -- "是" --> D4["打印 Prompt 摘要<br>返回失败态 → 剩余跳过"]
D3 -- "否" --> D5{"curl 调 {AI_FULL_URL 或 BASE/v1/chat/completions}<br>递增重试(2^n 封顶 16s)"}
D5 -- "失败" --> DFAIL["记录错误 返回失败态<br>→ 剩余待定显式跳过(可逆)"]
D5 -- "成功" --> D6["AI_CALL_COUNT++<br>校验返回 JSON"]
D6 --> D7["解析四部分:search / artist_choice / match / special"]
D7 --> D8["记录本批响应覆盖的 key<br>缺失条目留待下一批"]
D8 --> E["消费:resolve_pending_artists → reprocess_pending_searches → resolve_pending_matches"]
E --> E1["match 消费:choice → 脚本 build_*_dest 构建命名<br>(命名是脚本职责,AI 只做判断)"]
E1 --> E2["season_shift 叠加到文件季号<br>(Railgun T 类后缀季由脚本剥离优先)"]
E2 --> E3["无匹配 + search_term → 重搜取首条<br>(单轮优先,不给 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 当作媒体扫描干扰刮削;目标已存在时直接替换(先删旧目标再建硬链接)。以下为每一个原子化操作:
flowchart TD
A["link_media 遍历 VIDEO_DEST_MAP"] --> A1{"目标路径有效?<br>subdir 与 filename 均非空"}
A1 -- "否" --> ASKIP["警告 目标路径无效<br>skip++ 继续下一文件"]
A1 -- "是" --> B["mkdir -p 目标目录"]
B -- "失败" --> B1["报错 无法创建目标目录<br>skip++ 继续"]
B -- "成功" --> C["hardlink_or_dryrun 主文件<br>(原子步骤见 7.6A)"]
C -- "成功" --> H["处理配套文件<br>(原子步骤见 7.6B)"]
C -- "失败" --> H2["skip++<br>自动化写 SKIP_LOG_FILE"]
H --> H3["hardlink_count++"]
H2 --> A
H3 --> A
A -- "遍历结束" --> SUM["汇总<br>成功 hardlink_count 个 跳过 skip_count 个"]
7.6A hardlink_or_dryrun(原子步骤)
flowchart TD
H1["输入 src dst"] --> H2{"--dry-run 模式?"}
H2 -- "是" --> H3["仅打印 干运行:硬链接<br>返回成功"]
H2 -- "否" --> H4{"same_inode(src,dst)?<br>get_file_inode 取 inode<br>依次 stat -c → stat -f →<br>ls -i → find -printf"}
H4 -- "是" --> H5["跳过 硬链接已存在<br>返回成功"]
H4 -- "否" --> H6{"目标 dst 已存在?"}
H6 -- "是" --> H7{"dst 是目录?"}
H7 -- "是" --> H8["报错 拒绝替换目录<br>返回失败"]
H7 -- "否" --> H9["rm -f 删除旧目标<br>(直接替换 不备份)"]
H9 --> H10
H6 -- "否" --> H10["ln src dst 创建硬链接"]
H10 --> H11{"创建成功?"}
H11 -- "是" --> H12["返回成功"]
H11 -- "否" --> H13["报错 返回失败"]
7.6B 配套文件处理(原子步骤)
配套文件(字幕
.srt/.ass、音轨.mka等)跟随其主视频一起硬链接。同名或语言标签命名均识别。
flowchart TD
P1["主视频 hardlink 成功后<br>base_name = video 去扩展名"] --> P2["for companion in 'base_name'.*<br>遍历同基名文件"]
P2 --> P3{"文件存在且非主视频自身?"}
P3 -- "否" --> PNEXT["继续下一个 companion"]
P3 -- "是" --> P4["is_companion 判断<br>(原子步骤见 7.6C)"]
P4 -- "否(非配套)" --> PNEXT
P4 -- "是(配套)" --> P5["comp_suffix = companion 去掉 base_name 前缀<br>再去掉扩展名<br>(字符串截取,保留语言后缀)<br>如 .zh / .zh-tw"]
P5 --> P6["dest = 目标文件去扩展名 + comp_suffix + 新扩展名<br>如 S01E01.zh.ass"]
P6 --> P7["hardlink_or_dryrun companion → dest"]
P7 --> PNEXT
PNEXT --> P8{"还有 companion?"}
P8 -- "是" --> P2
P8 -- "否" --> P9["返回 处理完成"]
7.6C is_companion(原子步骤)
flowchart TD
I1["输入 companion 与主视频 base_name"] --> I2{"name_noext == vbase?<br>同名"}
I2 -- "是" --> IYES["是配套<br>返回 0"]
I2 -- "否" --> I3{"name_noext 含语言标签?<br>inner_ext 匹配 ^[a-z]{2,3}(-[a-z]{2,})?$<br>如 zh / zh-tw / en"}
I3 -- "否" --> INO["非配套<br>返回 1"]
I3 -- "是" --> I4{"possible_base == vbase?<br>去掉语言标签后与主视频同名"}
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 特典词表。
{
"menu": ["Menu", "菜单", "メニュー"],
"menu01": "menu",
"menu_1": "menu",
"preview": ["Preview", "预告片", "予告"],
"webpreview": "preview"
}
- 键:从文件名解析出的特典关键字(可带编号,如
Menu01;也常用去编号的核心词如menu)。 - 值:TMDB SEASON0 特典集中能匹配该关键字的字符串数组(英文/中文/日文等多语言均可),或引用其他键的数组。
识别/匹配流程:
- 从文件名提取特典片段(如
Menu01)→ 转小写查 keymap(先精确,再按去数字核心词menu查);命中后引用值会递归展开为数组。 - 命中后用数组中的每一个值去该剧集 TMDB
season/0的episodes[].name做contains(忽略大小写)匹配——任一值命中即匹配该特典集。 - 匹配成功 → 用
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
v9.6 类目分区:支持
{"video": [...], "music": [...], "default": [...]}分区形态(视频流程查 video、音频流程查 music;分区形态 = 完整语义,不叠加内置默认),详见 8.8 节。
8.5 season_offsets.json(季数偏移)
格式:键为剧名(小写)或 id:TMDB_ID,值为第一季的集数:
{
"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 等价):
{
"search_aliases": {
"俺妹": "Ore no Imouto ga Konna ni Kawaii Wake ga Nai",
"路人女主剧场版": { "term": "Saekano the Movie", "year": "2019" }
},
"id_maps": {
"movie": { "刀剑神域": 20982 },
"tv": { "魔法禁书目录": 4654 }
}
}
搜索别名(search_aliases):主搜索(zh-CN → en-US)无结果时,用指定的重搜词再搜一次(确定性兜底,优先于目录名重搜与 MAL/AI)。值可为字符串(重搜词),或对象 {"term": "...", "year": "..."}(带年份约束,仅电影生效)。适合本地俗称/简称(TMDB 搜不到"俺妹"但能搜到全名)。
ID 映射(id_maps):标题命中后直接使用指定 TMDB ID,完全跳过搜索(最高优先级,先于一切搜索)。movie/tv 分表(按 parse 判定的类型取表)。适合 TMDB 多候选易选错(同名动画/真人版)、搜索命中错条目的场景。
Tip
匹配规则优先级:ID 映射(跳过搜索)> 常规搜索链(zh → en → 别名 → 目录名 → MAL)→ AI。别名只在常规搜索无结果时介入;规则全部命中即走确定性路径,不消耗 AI 轮次。
8.7 命名模板(NAMING_*)
命名格式化从硬编码改为用户可配置模板(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(可含 / 产生多级目录) |
示例:
{
"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)
四个配置类文件支持两种形态:
- 全局单表(旧版,默认兼容):当前结构直接可用(对象/数组),行为与 v9.5 完全一致。
- 类目分区(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 分区形态):
{
"tv": ["menu", "ncop", "nced", "pv", "cm"],
"musicvideo": ["mv", "music video", "live", "concert", "演唱会"],
"default": ["特典", "特番"]
}
Note
AI 学习写回自动适配分区:AI 学到的特典映射/特典词写入
tv分区、跳过目录写入video分区(分区形态时);全局形态仍写顶层。
8.9 special_categories.json(特典类别判定表,v9.6 外置)
S00 未匹配特典的类别标签判定表(S00{类型} - {片段} 的"类型"):fragment 子串按数组顺序(=优先级,长词/特定词在前)匹配 → 取标签。数组保序,顺序即优先级:
[
{ "match": "nced", "tag": "NCED" },
{ "match": "mini anime", "tag": "Mini Anime" },
{ "match": "spot", "tag": "CM" },
{ "match": "iv", "tag": "IV" }
]
- 查询链:用户表(按序)→ 内置默认表(24 项,运行时硬编码,v9.7 起独立于模板)→
Special。 - 缺失时脚本在用户确认下用
SPECIAL_CATEGORIES_TEMPLATE常量创建(v9.7 起模板为空,生成空判定表[];自动化模式跳过,内置表兜底)。 - 用户可增删类别或调整顺序(长词在前,如
mini anime先于anime)。
9. 输出目录结构
脚本在目标目录创建 Jellyfin 规范的结构:
/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{集} - 集名.扩展名 |
| 特典(TMDB 匹配) | Shows/标题 (年份)/Season 00/S00E{集号} - TMDB集名.扩展名 |
| 特典(未匹配) | Shows/标题 (年份)/Season 00/S00{类型}{编号} - 原片段.扩展名(如 S00CM01、S00Menu01、S00PV01) |
| 音乐视频 | MusicVideos/歌手/歌名.扩展名(artist 元数据 → 目录名 → FOLDER_UNKNOWN) |
| 伴随文件 | 与视频同名,保留语言标签(.zh.srt、.jp.ass 等) |
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 调试技巧
# 详细日志(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
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/<id>/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 生成分发脚本 (开发期依赖 Ruby/basily;产物运行无需任何依赖)。
13.1 目录结构
build.sh # 构建脚本:bashly generate → 单文件分发脚本 dist/media_organizer
dist/ # 构建产物目录(media_organizer,由 build.sh 生成,不入库)
src/
bashly.yml # CLI 定义(选项/位置参数/帮助文本/互斥),bashly 读取此文件
main.sh # 文件头注释 + 常量 + 全局变量
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 # 对照表导出与运行汇总
组织原则:围绕功能域组织目录(config/storage/integrate/media/pipeline), 而非代码类型;不设
utils/类通用目录(strings.sh主题明确,属基础设施)。 测试用例tests/cases/按同名功能域分组。
13.2 构建
./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):
./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 静态检查(社区规范)
./lint.sh # bash -n + ShellCheck 零容忍检查(src 全部模块 + 构建/测试脚本)
./lint.sh -v # 显示每个文件的检查状态
规范遵循 Google Shell Style Guide + ShellCheck:
- 库文件首行
# shellcheck shell=bash;跨文件全局变量的 SC2034/SC2004 误报 在文件头部集中 disable 并注明原因;有意未使用的 read 解构/API 参数用_前缀 - 格式:2 空格缩进、
[[ ]]测试、local声明、变量引号包裹 - 可选格式化:
shfmt -w src/(本仓库未强制全量格式化,新代码建议按 shfmt 风格书写)
13.6 修改流程
- 编辑
src/下对应的模块文件(或src/bashly.yml调整 CLI 定义) - 运行
./tests/run.sh跑相关模块测试 - 运行
./build.sh重新生成分发脚本(产物输出到dist/media_organizer)
Warning
不要直接编辑
dist/media_organizer——下次构建会覆盖你的修改。
14. 许可证
本项目基于 MIT License 开源。允许自由使用、修改、分发,需保留版权声明。
文档生成于 2026-08-14,对应脚本版本 v9.6。