Files
MediaOrganizer/README.md
T
Shuery c1ed1795c2
CI / lint + test + build (push) Canceled after 0s
docs: 重写 README、新增项目报告与开发工具配置
- README 现代化重写:徽章区、特性一览、统一 GitHub 提示块格式、修正版本号与 AI 默认值
- 新增 docs/PROJECT_REPORT.md 学术项目报告(含许可证合规分析与死链修复记录)
- 收录 23 份 ADR 架构决策记录与 CONTEXT.md 领域术语表
- 新增开发配置:.editorconfig / .shellcheckrc / .markdownlint-cli2.jsonc
- .gitignore 补充 mo_map/、检查报告、编辑器临时文件
- 新增 config.example.json 配置模板(不含真实密钥)
2026-08-14 22:41:40 +08:00

113 KiB
Raw Blame History

🎬 MediaOrganizer

Jellyfin 媒体库硬链接整理脚本:通过 TMDB API 识别电影、电视剧与音乐视频,用硬链接零拷贝创建 Jellyfin 标准目录结构。AI 辅助识别、全量镜像缓存、特典自动归类、季数偏移,一次整理,终身整洁。

License: MIT Version Bash ShellCheck Tests Style: Google Shell

作者:LetsShareAll | 许可:MIT | 语言:Bash(零运行时依赖,单文件分发)


✨ 特性一览

特性 说明
🎬 智能识别 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 部分失败(非预期跳过/请求失败)"]

📑 目录

  1. 简介与功能
  2. 依赖与环境要求
  3. 快速开始
  4. 命令行选项
  5. 执行流程总览
  6. TMDB API 调用详解
  7. 执行判断详解
  8. 配置文件详解
  9. 输出目录结构
  10. 核心算法与公式
  11. 日志与调试
  12. 故障排除
  13. 构建与开发(源码结构)
  14. 许可证

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
  1. 若无 TMDB 密钥,会询问是否生成 config.json 模板 → 输入 y
  2. 在 mo_config/config.json 中填写 TMDB_API_RA_TOKEN
  3. 设置权限: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——下次运行直接生效。

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 个"]
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 特典集中能匹配该关键字的字符串数组(英文/中文/日文等多语言均可),或引用其他键的数组。

识别/匹配流程:

  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

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)

四个配置类文件支持两种形态:

  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 分区形态):

{
  "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 修改流程

  1. 编辑 src/ 下对应的模块文件(或 src/bashly.yml 调整 CLI 定义)
  2. 运行 ./tests/run.sh 跑相关模块测试
  3. 运行 ./build.sh 重新生成分发脚本(产物输出到 dist/media_organizer)

Warning

不要直接编辑 dist/media_organizer——下次构建会覆盖你的修改。


14. 许可证

本项目基于 MIT License 开源。允许自由使用、修改、分发,需保留版权声明。


文档生成于 2026-08-14,对应脚本版本 v9.6。