diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f982d77 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# 编辑器统一配置(EditorConfig) +# 参考:https://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +# Markdown 保留行尾空格(表格对齐) +[*.md] +trim_trailing_whitespace = false + +# Makefile 必须用 Tab +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore index 28928b6..57ad4fc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,9 +12,34 @@ # Built Visual Studio Code Extensions *.vsix -# MediaOrganizer 运行时缓存(脚本生成的 TMDB 请求缓存) +# MediaOrganizer 运行时缓存(tmdb/ = TMDB API 请求缓存;media_organizer/ = 脚本运行状态账本) mo_cache/ +# 运行时配置目录(含 API 密钥的 config.json),不纳入版本控制 +mo_config/ + # 参考仓库(3rd-party 仅为本地参考,不纳入版本控制) 3rd-party/ +# 本地调试用媒体库文件名镜像(空文件,不纳入版本控制) +debug/ + +# 构建产物目录(dist/media_organizer 由 build.sh 生成,不入库) +dist/ + +# 运行时输出:--export-map 生成的源→目标对照表目录(mo_map/ 位于脚本目录,按源目录名覆盖更新) +mo_map/ + +# rv 管理的 Ruby 环境(本地开发用,不入库) +.rv-rubies/ +.rv-cache/ + +# 自动生成的检查报告(markdownlint/死链/许可证,可随时重新生成,不入库) +docs/link_check_report_*.md +docs/license_check_report_*.md + +# 编辑器与操作系统临时文件 +.DS_Store +*~ +*.swp +*.swo diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..4c03fdc --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,14 @@ +{ + // MediaOrganizer 文档 lint 配置(markdownlint-cli2) + // 豁免规则及原因: + // - MD013 行宽:中文文档按字符计数会大量误报,Prettier 已处理折行 + // - MD028 blockquote 内空行:GitHub 提示块规范要求「marker 单独一行 + + // 空引用行 + 内容」,相邻提示块间必然出现该形态(GitHub 渲染正确, + // markdownlint 与 GitHub 提示块写法已知冲突) + // - MD034 裸 URL:学术报告参考文献(GB/T 7714)按规范以纯 URL 结尾 + "config": { + "MD013": false, + "MD028": false, + "MD034": false + } +} diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..dc50b94 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,9 @@ +# ShellCheck 全局配置(shellcheck 0.9+ 支持 .shellcheckrc) +# 参考:https://github.com/koalaman/shellcheck/wiki/.shellcheckrc + +# 全部源码为 bash(含无 shebang 的库文件片段) +shell=bash + +# 说明:跨文件共享全局变量的 SC2034/SC2004 误报按文件显式管理 +# (见 src/lib/main.sh 声明区及各模块头部的 shellcheck disable 注释), +# 不在全局关闭,保持误报可追踪。 diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..c9fb27b --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,228 @@ +# Media Organizer Context + +Jellyfin 媒体库硬链接整理脚本(`media_organizer.sh`)。本上下文记录该工具的领域术语——重点是其入口层(CLI 参数 + 配置加载)的语言约定。 + +## Language + +**入口层 (entry layer)**: +脚本的前门:命令行参数解析、位置参数校验、env/mo_env 配置加载的统称。讨论入口行为时用它,而不是具体指某个函数。 +_Avoid_: 参数解析(只指其中一部分) + +**模式 (mode)**: +互斥的入口动作,一次调用恰好选择其一:`list`(列出缓存)、`organize`(正常整理)。模式决定入口分发的分支;当 `update-cache` 修饰标志存在且只给了源目录时,退化为只刷缓存的形状(不整理,提前退出)。 +_Avoid_: 选项、动作 + +**修饰标志 (modifier)**: +与模式正交的布尔开关,可自由附加到模式上:`dry-run`、`automated`、`refresh-cache`、`update-cache`。语义上属于"本次调用如何执行",而非"执行什么"。`update-cache` = 强制重取对应缓存条目(不信任陈旧缓存),是唯一会改变流程形状的修饰标志。 +_Avoid_: 选项、参数 + +**干运行 (dry-run)**: +修饰标志;本次调用不产生任何持久化副作用:不建链接、不写缓存、不写日志;网络请求(TMDB/AI)照常执行但不落盘。与 `cache-only` 形状冲突(报错),与 `update+organize` 兼容。 +_Avoid_: 试运行、预览模式(语义含混) + +**配置优先级链 (priority chain)**: +配置值的解析顺序:**CLI > 环境变量 > mo_env 文件 > 内置默认值**。同一键只取最高优先级来源;CLI 显式设置(含 `--no-*` 反选)覆盖一切。 +_Avoid_: 覆盖顺序 + +**位置参数**: +模式之外的目录参数:源目录、目的目录。`organize` 需要两个;`update-cache` 只需源目录(只给一个时仅刷缓存,给两个时刷新并整理);`list` 不接受任何目录参数。 + +**源目录 (source directory)**: +被整理的媒体文件所在目录。英文标识统一用 `source`(`SOURCE_DIR`、`--src-dir`)。 +_Avoid_: src 的其他混用 + +**目的目录 (destination directory)**: +硬链接的目标目录。英文标识统一用 `destination`(`DESTINATION_DIR`、`--dest-dir`),不再使用 `target`。 +_Avoid_: target, dst(作概念名时) + +**过滤词 (filter keyword)**: +`list` 模式的可选关键词,按类型/路径/参数过滤缓存条目。仅 CLI 提供(`--list-cache` 的值或 `=` 形式),不从配置读取。 + +## Specials + +**特典类别词 (special category word)**: +特典识别的判定词(`mo_special_words.json`,JSON 数组)——文件名方括号标记含这些词 → 判定为特典(Season 00)。语义 = 特典类别(Menu/CM/PV/NCOP/NCED 等)。匹配对空格不敏感(词表 `ncop` 可匹配文件里的 `nc op`);词表内容不当作正则(纯字符串子串匹配)。 +_Avoid_: 特典名("名"暗示具体条目,实际是类别判定词) + +**匹配关键字 (match keyword)**: +keymap 值元素与 AI 学习产物的统一术语——`["Menu","菜单","メニュー"]` 中的每个元素都是匹配关键字:用于与 TMDB 特典候选列表条目做匹配(精确/最短前缀/contains 三级)。用户可写 TMDB 条目名,也可写自定义别名。 +_Avoid_: 特典名、集名(这些词本质是特典类别/关键词,不是"名字") + +**TMDB 特典候选列表 (season0 candidate list)**: +`tv/{id}/season/0` 的条目名列表(`PENDING_AI_SPECIAL` 值中的 `编号:名称` 串)。AI 学习时作为候选:**AI 从候选中选择与本地片段匹配的项**(选择判定,与 AI 匹配轮同构),产物必须是候选列表成员(交叉验证,防幻觉污染)。 +_Avoid_: 特典集名(暗示 AI "返回名字",实际是"从候选中选择") + +**特典映射 (special keymap)**: +`mo_special_keymap.json`:`{"本地关键字符串": 匹配关键字数组 | 字符串引用}`——多键共享同一组匹配关键字用字符串引用(递归展开,防循环);键小写化。AI 学习写回前交叉验证(产物必须在候选列表)。 +_Avoid_: 特典名映射 + +## Custom + +**命名模板 (naming template)**: +`NAMING_MOVIE`/`NAMING_SHOW`/`NAMING_SEASON`/`NAMING_EPISODE`/`NAMING_SPECIAL`/`NAMING_MUSIC` 六个配置键,把"命名格式化"从硬编码外置为用户可配置模板。占位符语法:`{name}` 值插入、`{name:NN}` 数字补零、`{?name:text}` 条件段(name 非空才渲染 text,正文可含其他占位符);默认模板与旧版输出逐字节一致(仅格式外置,行为不变)。渲染实现 `render_naming_template` 逐 token 扫描(不用 `${var//pat/repl}` 全局替换——替换串的 `&` 会被当匹配整体,文件名含 `&` 时损坏);`render_naming` 按配置键渲染并在键未配置时回退内置默认(`naming_template_default` 集中定义)。加载期 `validate_naming_templates` 对未知占位符打印警告(渲染为空)。 +_Avoid_: 直接拼接字符串(命名格式必须走模板渲染,保证识别/回退两套路径一致) + +**匹配规则 (match rules)**: +`mo_config/match_rules.json`:用户手工维护的确定性匹配——`search_aliases`(搜索别名)与 `id_maps`(ID 映射),键 = 文件名清洗后的标题(`rule_key` 归一化:小写 + 空白折叠)。不参与 AI 学习写回(用户显式维护即权威)。 +_Avoid_: 匹配规则(泛指 keymap/季偏移等一切匹配类配置) + +**搜索别名 (search alias)**: +匹配规则的 `search_aliases` 条目:`{"<标题键>": "<重搜词>" | {"term": "...", "year": "..."}}`。主搜索(zh→en)**无结果**时用重搜词再搜(zh→en 各一次)——确定性兜底,优先于目录名兜底与 MAL/AI。适合本地俗称/简称(TMDB 搜不到"俺妹"但能搜到全名)。别名 year 仅电影生效。 +_Avoid_: 无条件用别名替换标题(主搜索成功时别名不介入,ID 映射才是一等优先级) + +**ID 映射 (id map)**: +匹配规则的 `id_maps` 条目:`{"movie": {"<标题键>": }, "tv": {...}}`——标题命中直接使用指定 TMDB ID,**完全跳过搜索**(最高优先级,先于一切搜索)。movie/tv 按 parse 判定类型分表。适合 TMDB 多候选易选错(同名动画/真人版)、搜索命中错条目的场景。 +_Avoid_: 用别名模拟 ID 映射(别名仍需搜索验证,ID 映射是确定性绑定) + +**类目分区 (category partition)**: +四个配置类文件(`special_maps`/`special_keywords`/`skip_directories`/`season_offsets`,v9.6)支持分区形态:顶层全部键 ∈ `MO_CATEGORY_KEYS`(movie/tv/music/musicvideo/video/audio/default/global)→ 分区形态(`is_category_partitioned` 判定,混合形态警告并按全局处理)。查询链统一"**类目/流程分区 → default 分区 → 全局表 → 内置默认**"。分区键语义因文件而异:特典映射/词表用类目键(tv/musicvideo),跳过目录用**流程键**(video/music——目录语义判断发生在类目确定前);`skip_directories` 分区形态 = **完整语义**(不叠加内置默认,通用词放 default);`special_keywords` 分区 = 叠加语义(tv 分区词 + 内置兜底,词表是积累型)。AI 学习写回自动适配(分区形态写 tv/video 分区)。 +_Avoid_: 分区形态下叠加内置默认(`skip_directories` 的 music 流程误命中 video 侧内置词 "sps"——分区文件表达"只要这些词") + +**音乐视频类目 (musicvideo category)**: +v9.6 新增第四类目:识别信号 = musicvideo 判定词(`special_keywords.json` 的 `musicvideo` 分区 → default → 内置 `MUSICVIDEO_WORDS` 13 词,**不回退** tv 特典词表——"sp" 子串命中 "SPs" 目录等交叉误判)。两类信号:**目录信号**(目录名**精确匹配**归一化整名——"Music Videos"/"MV"/"Live"/"演唱会";精确匹配避免 "Muv-Luv"/"tmp.xxx" 含词误判)与**文件名信号**(方括号标记子串命中,如 [MV];双命中歧义用 "**歌手 - 歌名**" 模式消解——含 ` - ` → musicvideo,不含 → 特典保持现状)。命名 `MusicVideos/{artist}/{title}`(`NAMING_MUSICVIDEO` 模板),artist 链:元数据 ALBUMARTIST → ARTIST → 父目录名("歌手 - 歌名"取首段/整名)→ FOLDER_UNKNOWN。本地规则无网络,识别失败不消耗 AI。 +_Avoid_: 目录信号用子串匹配("Muv-Luv" 目录含 "mv" 误判——整目录视频被归类音乐视频的代价远超单个文件)、musicvideo 词表回退 tv 特典词表(SPs 目录稳定误判) + +**特典类别判定表 (special category table)**: +`mo_config/special_categories.json`(v9.6 外置):S00 未匹配特典的类别标签判定(`S00{类型} - {片段}` 的"类型"),`[{"match": "nced", "tag": "NCED"}]` **数组保序 = 优先级**(长词/特定词在前)。查询链:用户表(按序子串)→ 内置默认表(24 项,与 `SPECIAL_CATEGORIES_TEMPLATE` 一致)→ "Special"。此前硬编码在 `special_category_tag` 的 24 项类别词表外置为用户可编辑(增删/调序),`special_category_tag` 保留内置表作最后兜底。 +_Avoid_: 对象形态存类别表(JSON 对象无顺序,判定优先级丢失);把类别表并入 special_keywords(语义不同:词表判定"是否特典",类别表判定"S00 标签是什么") + +## Pipeline + +**识别池 (worker pool)**: +`process_video` 与 `process_audio` 共用的并发 worker 池(`MEDIA_WORKERS`,默认 4)。子进程产出 `key\tvalue` 行,父进程合并——bash 子 shell 无法写父关联数组,这是唯一的并行契约。音频与视频走同一通用接口(识别函数 → 结果/结局)。 +_Avoid_: 并行处理(泛指) + +**登记 (register)**: +收拢一切条目写入的注册函数层(`register_identified` / `register_pending` / `register_fallback` / `register_skip` / `register_request_failed`)。一次调用原子完成"目的映射 + 结局账本 + 计数器",结构上不可能出现只写一半的不一致。命名取自"声明这条记录存在并处于什么状态",区别于赋值。 +_Avoid_: 赋值、写入(语义含混) + +**结局账本 (outcome map)**: +`MEDIA_OUTCOME_MAP`:每条目结局类别的唯一账本(identified / fallback / skip_type / skip_unidentified / request_failed / pending_ai)。运行级汇总报告的唯一数据源;跳过/失败条目不进入目的映射,仅登记于此。 +_Avoid_: 状态字段 + +**回退命名 (fallback naming)**: +AI 尽力后仍无法识别时的降级出口(触发条件唯一,无 AI 密钥 → `skip_unidentified`,不回退)。命名不含年份占位(Jellyfin 年份可省略);空集名不加 ` - ` 段;特典回退复用识别路径的 `S00{tag} - {fragment}` 约定。 +_Avoid_: 兜底命名(与识别兜底混淆) + +**降级成功 (degraded success)**: +回退命名的条目在汇总中的归类:链接确实建立了(文件可访问),但名字是文件名推断的。计入成功而非跳过,但单列一类显示。 +_Avoid_: 成功(不区分)、警告 + +**伴随文件优先级**: +同名文件归属判定:**视频 > 音频**。音频文件先查是否存在同名视频(任一视频扩展名)——存在则该音频是视频的伴随音轨(由视频的伴随逻辑处理,从独立音频处理中排除);不存在才是独立音频,再查它自己的伴随(歌词/封面)。当前脚本对 `Movie.zh.ac3` 类音轨会双重处理,需按此规则修复。 +_Avoid_: 按扩展名并列判断 + +**艺术家归类链 (artist resolution chain)**: +音乐条目艺术家归属的逐级判定:专辑艺术家(元数据 ALBUMARTIST,唯一)→ 无则歌曲艺术家(ARTIST)单值视为专辑艺术家 → 多值交 AI(并入现有 AI 批处理,不新增请求类型)→ AI 判断不出用 ` & ` 拼接所有艺术家 → 元数据缺失回退目录名解析(`parse_cd_dir`)→ 仍无则 `Unknown` 文件夹。专辑名(ALBUM 标签 → 目录名)同构;封面等提取仅整理不增删文件,默认关闭。 +_Avoid_: 目录名优先(现状,将被替换为末级兜底) + +**目的目录规范 (destination structure)**: +按 Jellyfin 官方规范:`Movies/{title} ({year})/` 夹内同名文件;`Shows/{title} ({year})/Season NN/`(补零、不缩写,`Season` 为解析格式不可本地化);`SxxExx - 集名`;特典 `Season 00` 描述性命名;`Music/{artist}/{album}/`(一夹一专辑);`MusicVideos/{artist}/{title}`(根目录无空格,识别待样本驱动)。撞名按官方多版本格式去重(` - 2`);同源旧链接(迁移场景)按 inode 清理。根目录名本地化(默认系统语言,`FOLDER_MOVIES` 等可配)。 +_Avoid_: `S01`/`SE01` 季目录名 + +**已链接账本 (linked ledger)**: +`mo_cache/media_organizer/linked.json`:`{src: {dest, inode, linked_at}}`。增量标记的唯一数据源——识别池入口命中且**源存在 + 目标存在 + 同 inode** 才跳过(结局 `already_linked`),任一失效(目标被删/源被替换)自动重新识别+链接。父进程加载一次(worker fork 复制内存),运行末尾 `ledger_flush` 统一落盘(原子写+惰性清理),干运行不落盘且不启用跳过(展示全貌)。 +_Avoid_: marker 文件(污染媒体目录)、每文件落盘(O(n²)) + +**失败冷却 (failure cooldown)**: +`mo_cache/media_organizer/fail_cooldown.json`:`{src: {retry_at, reason}}`。`request_failed`/`skip_unidentified` 登记(`FAIL_RETRY_COOLDOWN_HOURS` 默认 24h,0=禁用),冷却期内识别池入口跳过(结局 `cooldown`),过期自动重试。仅"尽力后失败"冷却;`--rerun` 显式重跑绕过;冷却不计入退出码 3。 +_Avoid_: 每轮全量重试同一批失败项、永久跳过(破坏可逆语义) + +**纠错账本 (correction ledger)**: +`--export-map` 伴生同名 `.ledger.json`:`[{src, dest, outcome, status, action}]`(dest 为绝对路径;status 导出时现场校验 linked/pending)。用户编辑 dest + `action:"rerun"` 后 `--rerun <文件>` 重跑——只做链接层(mkdir+硬链接,目标已存在且非同 inode → 替换),不重新识别,不加载 TMDB 认证。 +_Avoid_: 重新识别纠错(与 AI 流程重叠)、Python WebUI(破坏纯 bash 定位) + +**同集冲突 (episode conflict)**: +`detect_conflicts` 按 `剧集目录||SxxExx`(含区间)聚合识别成功条目,同 key 多源 → `CONFLICT_MAP`,对照表「同集冲突」小节 + 汇总提示。仅检测+报告,不自动删(硬链接库场景自动替换风险大于收益);Season 00 特典/电影/音乐不参与;可经 `--rerun` 指定保留版本。 +_Avoid_: hold/replace 自动替换(AB 式,需下载器信息且破坏性) + +**搜索重试链 (search fallback chain)**: +识别搜索失败顺序重试:zh-CN 空 → `en-US`(`tmdb_api_lang` 局部覆盖,缓存按语言段隔离,默认)→ `SEARCH_FALLBACK_MAL=true` 时 MyAnimeList (jikan v4) 候选标题(罗马音/英/日,≤3 个)逐个回 TMDB en-US 重搜(`mo_cache/media_organizer/mal/` 独立缓存)。请求失败(rc=2)不重试;MAL 失败静默降级到 AI 路径。 +_Avoid_: 直接信任 MAL 标题入库(必须经 TMDB 验证)、默认开启 MAL(外部 API 依赖) + +**AI 时长信号 (AI duration signal)**: +`match_entries` 构造时为待甄别文件运行 ffprobe,附加 `duration`(分钟,<1min 视为空)与 `resolution`。AI 据此区分剧场版(~70-120min)/ OVA·特典(~20-30min)/ 正片(~24min);失败留空不阻塞。 +_Avoid_: 全量文件 ffprobe(仅需甄别条目)、hachoir 新依赖(ffprobe 已存在) + +**候选结构评分 (candidate structure scoring)**: +`pick_tv_show_id` 精确名匹配失败后的评分层:前 5 候选(前缀排除后)按 `季数覆盖文件季号 +40 / 名称 norm 互相包含 +30 / 特典候选含 Season 0 +20` 取最高,全不满足兜底 results[0]。评分请求走 `tmdb_api`(缓存命中免费)。精确匹配始终最高优先——评分层只在歧义场景介入。 +_Avoid_: 直接 results[0](歧义场景浪费 AI 轮次)、评分替代精确匹配(改变正常识别路径) + +**目录名兜底 (directory fallback search)**: +`tv_search_by_dir` / `movie_search_by_dir`:主搜索失败(zh→en 均空)后用父目录名(`clean_name`+`strip_season_suffix`)重搜。源根散放不兜底(共享目录误判);目录名与 query 相同/为空跳过;插在 MAL 之前(零外部依赖优先)。TV 复用结构评分,电影取 results[0]。 +_Avoid_: 目录名直接当标题(未经验证)、无条件用目录名(源根散放误判) + +**纠错学习 (correction learning)**: +`mo_cache/media_organizer/corrections.json`:`{clean_name(文件名)小写: {dest, learned_at}}`。`--rerun` 链接成功即学习(用户显式修正即权威,立即原子写盘);识别入口按同算法 key 前置命中 → 直接 DEST(跳过 parse/识别/AI/冷却)。学习源只有 `--rerun`(自动识别成功不学习——防固化脚本自身错误);dest 须位于当前 `DESTINATION_DIR` 下;目录参数已归一化绝对路径(`normalize_abs`)。 +_Avoid_: 学习 AI 识别结果(无用户确认)、按绝对路径学习(换目录失效)、冷却优先于纠错(用户修正不应被退避阻塞) + +**后缀季模糊 (suffix-season fuzzy)**: +后缀季映射词表全失败且后缀 ≥3 字符时:awk Levenshtein 只比较季名**末尾窗口**(末 flen+1 字符,完美命中距离 ≤1 与阈值分档 1/2/3 匹配),最短距离 ≤ 阈值命中。跨语言不指望命中(距离必然超阈值)——服务同文近似拼写。 +_Avoid_: 整名 Levenshtein(距离不可达,阈值形同虚设)、短后缀启用(词表/罗马数字已覆盖,误配风险高) + +**公共子串剥离 (peer prefix/suffix extraction)**: +`extract_episode_from_peers`:无特征剧集目录(≥2 视频)求最长公共前缀/后缀(bash 逐字符),当前文件中段取**末尾数字**作集号(排除分辨率 1080/720/480/2160/4320);无数字保持 episode=0(不猜)。仅"正片计数 → tv"回退分支启用。 +_Avoid_: difflib 全公共子串(python 新依赖)、中段无数字仍猜集号 + +**AI 响应容错 (AI response tolerance)**: +`ai_extract_json` 四级容错链:① 直接解析 → ② 剥 ` ```json `/` ``` ` 围栏 → ③ 剥离思考链标记(`` 块 + 「思考/分析/推理:」前缀)→ ④ 最外层 `{}` 块(花括号配平,字符串内误计仅致本级失败)。任何一级 jq 验证通过即返回;全失败保持原失败路径(条目留待下批)。纯 awk/sed,零新依赖。调用点 `ai_batch_request` 用 `||` 保护(set -e 安全)。 +_Avoid_: 严格校验整批丢弃(推理模型输出思考内容时浪费一轮+额度)、SDK 结构化输出(curl 直连兼容端点,能力不一) + +**AI 用例落盘 (AI case persistence)**: +`AI_SAVE_CASES=true` 时每次请求的输入 JSON(`build_ai_input_json` 产物)与原始响应存 `mo_cache/media_organizer/ai_cases/<序号>__{request,response}.json`。序号复用 `AI_CALL_COUNT`;输入不含 API 密钥(key 只在 HTTP 头);响应存原始文本(非 JSON 也留档——正是排查价值);干运行不写。反馈闭环 + 防幻觉学习候选数据源。 +_Avoid_: 只存响应(缺输入侧无法重建上下文)、存 payload(无 key 但含 base_url 等配置信息,不必要) + +**同目录上下文 (siblings)**: +`build_ai_input_json` 的 match_entries 每项附 `siblings`(同目录其他视频文件名,≤20,排除自身,目录扫描零请求)。与时长信号互补:时长回答"这个文件多长",siblings 回答"它和什么在一起"——剧场版混 TV 场景(90min 文件旁 23 个 24min 文件 → season 0 剧场版)的判断依据。AI 仍只做判断,命名归脚本。 +_Avoid_: 整目录一次映射(BAR 模式,prompt/输出契约复杂化,与单文件判断流水线不兼容)、附路径/时长(体积膨胀,信息已由 file/directory/duration 提供) + +**搜索链抽象 (search chain abstraction)**: +`tv_search_once` / `movie_search_once` 统一尝试器:参数(query/季号/特典/语言/年份),返回码 0=命中(stdout=id)/1=无结果/2=请求失败;movie 版两行协议(id + 单行响应 JSON)回传响应供年份校验/需甄别检测(命令替换子 shell 丢全局赋值,两行协议是零新依赖回传)。识别链 = zh → en → 规则别名 → 目录 → MAL → 后缀二次剥离,逐层调用尝试器。失败语义:主搜索失败短路 return 2;后续层失败忽略;别名层失败不试 en 变体。`pick_tv_show_id` 调用点收敛到尝试器内部 1 处。 +_Avoid_: 逐层复制(每层 ~10 行且失败语义易漂移)、全局变量回传(子 shell 丢失)、整链编排(各层 query 生成逻辑异构) + +**配置单点化 (config single source)**: +`CONFIG_DEFS`(键|默认值数组,main.sh)→ `render_config_template()` 生成 config.json 模板 + `load_config` 循环加载(`printf -v` 动态赋值 + `${!key:-}` 间接引用,零 eval)。新增纯标量键只需改一处;FOLDER_* locale 默认/扩展名拆数组/数值校验/日志初始化等后处理保留手写。NAMING_* 字面值须与 `naming_template_default` 一致(后者为 render_naming 兜底权威)。 +_Avoid_: 模板/加载/默认值三处手写(漂移源)、全配置关联数组化(动所有消费点) + +**已知缺口 (known gaps)**: +① ~~多集单文件(`S01E01-E02.mkv`)~~ ——**已实现**(parse 输出契约 7 段含 `episode_end`;识别/回退命名 `S01E01-E02 - 首集名 - 末集名`);② Music Videos 识别信号——待真实样本驱动(多数文件规则补齐);③ 纯音频 mkv/mp4 容器改名(mka/m4a)——文档提示,不自动改文件;④ ~~电影/电视判断链~~ ——**已实现**(`(YYYY)` 任意位置提取进 year 字段;`count_main_videos` 扩展名读 `VIDEO_EXTS` + 跳过目录词表 `mo_skip_dirs.json` + 源根散放约束→AI);判断链的"文件名特征评分系统"挂账**正式关闭**——年份任意位置提取后无特征文件已收敛(有年份→movie、剧集目录多集→tv、源根散放→unknown→AI),评分系统价值趋零;⑤ ~~后缀季(`Railgun T`/`II`/`2nd`)~~ ——**已实现**(`strip_season_suffix_word` 剥后缀重搜 + base 剧 seasons 名匹配映射季号 + 罗马数字直映射 + Levenshtein 末尾窗口模糊兜底;AI 的 `season_shift` 仅兜底脚本剥离失败);⑥ ~~AI 使用~~ ——**已实现**(AI 失败→可逆 skip、jq 构造输入、分批、判断/执行职责分离;v9.4 起 match_entries 补充时长/分辨率信号)。 + +**多集区间 (multi-episode range)**: +单文件含多集(`S01E01-E02`)的区间解析与命名规则。目标文件名保留区间:`{标题} - S01E01-E02 - {首集名} - {末集名}.{ext}`(集名段取**首尾两集**的 TMDB 集名,` - ` 连接);Jellyfin 据 `S01E01-E02` 识别为多集条目。解析支持 `E01-E02` 与扩展形式(`E01-E02E03`)。 +_Avoid_: 吞掉区间(现状行为,静默降级为单集) + +## Cache + +**空哨兵 (empty sentinel)**: +搜索"查无此片"(`results:[]`)写入的 `empty:true` 包裹缓存,TTL 3 天(`CACHE_EMPTY_TTL_DAYS`)。TTL 内免重复请求,过期后自动重新搜索——新片出现后会被发现。与"请求失败"(不写缓存,下次运行重试)语义分离。 +_Avoid_: 空结果缓存(30 天 TTL 锁死,旧行为) + +**缓存语言段 (cache language segment)**: +id 类缓存路径(`tv/..json`、`tv//season/..json`、`movie/..json`)携带 `TMDB_LANG`——语言相关的详情/季数据按语言隔离,切换语言自动 miss 重新拉取。与搜索缓存的 `lang` 入 hash 同构。 +_Avoid_: 无语言维度的 id 缓存(切语言后 30 天旧数据) + +**并发去重 (flock double-checked locking)**: +识别池多 worker 同时 miss 同一查询时的互斥机制:`tmdb_api` 未命中后 `flock` 锁(锁文件在 `/tmp`,内核锁进程退出自动释放),锁内**双检**缓存——等待者直接命中先写者的结果,同一查询只发一次 TMDB 请求。 +_Avoid_: 无锁并发(同一剧多集并发识别会重复请求击穿限流) + +## AI + +**判断引擎 / 执行引擎 (judgment vs execution)**: +AI 只做**判断**——输出 `{choice: | "", season_shift: , search_term: "重搜词"}`(选中候选 / 季映射 / 无匹配纠正词);**命名格式化(subdir|filename、年份、Season 补零、SxxExx)始终由脚本构建**。AI 是判断引擎,脚本是执行引擎——格式化是确定性职责,不交给 AI。 +_Avoid_: AI 直接输出目标文件名 + +**AI 批处理批次 (AI batch)**: +`AI_BATCH_SIZE`(默认 50)条/批;`AI_MAX_CALLS` 语义为**批次上限**,达标后剩余 pending 显式 `skip_unidentified`。输入构造用 jq 安全转义(文件名含引号/反斜杠不破坏 JSON);`PENDING_AI_SEARCH` 分隔符用 `$'\t'`(文件名可含 `|`)。 +_Avoid_: 全量单批(数百条 prompt 超上下文)、字符串拼接 JSON + +**AI 失败语义**: +AI 请求失败(网络/非 JSON/上限达标)→ 剩余 pending 显式 `skip_unidentified`(**可逆**,下次运行自动重试);只有"AI 成功返回且判断无解"才走回退命名(不可逆)。与"无 AI 密钥"语义统一——AI 没尽力 ≠ AI 尽力后失败。 +_Avoid_: AI 失败 → 回退命名(伪命名被幂等锁死) + +**AI 匹配输入契约**: +文件信息(`file` 完整路径 + **目录链**——源根相对路径,最多 3 层)+ TMDB **search 响应原样**(缓存中,零额外请求)+ detail 的 **seasons 四字段提炼**(season_number/name/episode_count/air_date)——体积-信息平衡点。 +_Avoid_: 手工提炼候选集(字段选择错误风险)、detail 全量原样(单条 3-8KB,批量超上下文) + +**后缀季剥离 (suffix-season stripping)**: +脚本侧方案:搜索无结果或季数存疑时,剥标题末尾季后缀(`T`/`S`/`II`/`III`/`2nd`/`Season 2` 等词表)重搜 base 剧 → 遍历其 seasons 找**名字含被剥后缀**的季 → 文件季号映射到该 season_number。AI 的 `season_shift` 仅兜底脚本剥离失败的情况。 +_Avoid_: 把后缀季交给 AI(脚本可解,减少 AI 依赖) diff --git a/README.md b/README.md index b45a929..4bcc588 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,51 @@ -# Jellyfin 媒体库硬链接整理脚本 +# 🎬 MediaOrganizer -> **版本**:9.3 | **作者**:LetsShareAll | **许可**:MIT -> -> 自动化整理媒体库:通过 TMDB API 识别电影与电视剧,并用**硬链接**创建 Jellyfin 标准目录结构。支持 AI 辅助识别、全量镜像缓存、特典映射、季数偏移。 +> **Jellyfin 媒体库硬链接整理脚本**:通过 TMDB API 识别电影、电视剧与音乐视频,用**硬链接**零拷贝创建 Jellyfin 标准目录结构。AI 辅助识别、全量镜像缓存、特典自动归类、季数偏移,一次整理,终身整洁。 + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Version](https://img.shields.io/badge/version-9.6-blue.svg)](src/lib/main.sh) +[![Bash](https://img.shields.io/badge/Bash-4.0%2B-black.svg)](https://www.gnu.org/software/bash/) +[![ShellCheck](https://img.shields.io/badge/ShellCheck-passing-brightgreen.svg)](.github/workflows/ci.yml) +[![Tests](https://img.shields.io/badge/tests-120%20cases-blueviolet.svg)](tests/) +[![Style: Google Shell](https://img.shields.io/badge/Style-Google%20Shell-green.svg)](https://google.github.io/styleguide/shellguide.html) + +**作者**: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 | + +### 处理流程(三阶段) + +```mermaid +flowchart LR + A["scan_files 扫描源目录
视频 VIDEO_FILES + 音频 AUDIO_FILES"] --> B["第一阶段 识别池 process_video + process_audio
并发 MEDIA_WORKERS 个 worker
parse → identify_movie / identify_tv_show
音频走艺术家归类链(元数据→目录名)
→ register 层写入 MEDIA_DESTINATION_MAP
+ MEDIA_OUTCOME_MAP 结局账本"] + B --> C["第二阶段 run_ai_batch(分批 AI_BATCH_SIZE/批)
AI 纠正搜索词 + 匹配甄别(选 id/季映射)
+ 判定多艺术家 + 学习特典映射
→ 重处理 PENDING → 回退命名(降级成功)
AI 失败 → 剩余显式跳过(可逆)"] + C --> D["第三阶段 link_media
mkdir → 硬链接(幂等/撞名去重 - 2)
→ 配套文件(字幕/音轨/歌词/封面)"] + D --> E["运行级汇总 + 退出码分级
0 全部成功 / 3 部分失败(非预期跳过/请求失败)"] +``` + +--- + +## 📑 目录 1. [简介与功能](#1-简介与功能) 2. [依赖与环境要求](#2-依赖与环境要求) @@ -29,44 +68,55 @@ 本脚本扫描源目录中的媒体文件,通过 **TMDB API** 识别其真实标题,并使用**硬链接**在目的目录创建 Jellyfin 规范的目录结构(不复制数据、不占用额外磁盘空间)。 -### 功能特性 +### 功能细节 -| 特性 | 说明 | -|---|---| -| 🎬 智能识别 | TMDB API 匹配电影与电视剧,支持多种文件名格式 | -| 🔗 硬链接整理 | 同一文件系统内零拷贝,省空间、省时间 | -| � 媒体类型智能判断 | 文件名无明确季集/年份特征时,**根据媒体目录内正片数量**判断电影/剧集(不依赖下载目录,合集种子可混合);特典归属同理(媒体目录仅 1 个正片→电影特典跳过,多个→剧集特典 Season 00) | -| 🤖 AI 辅助识别 | 无法匹配/需甄别时调用 OpenAI 兼容接口(默认 DeepSeek-V4-Flash)纠正搜索词、**匹配甄别**(选候选 id + 季映射)、判定艺术家、学习特典映射;分批处理、失败可逆跳过 | -| 📚 持久化缓存 | 镜像 TMDB API 路径缓存全部请求数据到磁盘(`search/movie|tv/.json`、`tv/..json`、`tv//season/..json`、`movie/..json`),二次运行零外部请求;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 季数与实际集数不符的剧集 | -| 📦 单文件分发 | 三个配置文件模板全部内嵌为常量,无需携带配套文件 | -| 🖥️ 跨平台 | 支持 Linux / macOS(BSD stat 兼容) | - -### 处理流程(三阶段) - -```mermaid -flowchart LR - A["scan_files 扫描源目录
视频 VIDEO_FILES + 音频 AUDIO_FILES"] --> B["第一阶段 识别池 process_video + process_audio
并发 MEDIA_WORKERS 个 worker
parse → identify_movie / identify_tv_show
音频走艺术家归类链(元数据→目录名)
→ register 层写入 MEDIA_DESTINATION_MAP
+ MEDIA_OUTCOME_MAP 结局账本"] - B --> C["第二阶段 run_ai_batch(分批 AI_BATCH_SIZE/批)
AI 纠正搜索词 + 匹配甄别(选 id/季映射)
+ 判定多艺术家 + 学习特典映射
→ 重处理 PENDING → 回退命名(降级成功)
AI 失败 → 剩余显式跳过(可逆)"] - C --> D["第三阶段 link_media
mkdir → 硬链接(幂等/撞名去重 - 2)
→ 配套文件(字幕/音轨/歌词/封面)"] - D --> E["运行级汇总 + 退出码分级
0 全部成功 / 3 部分失败(非预期跳过/请求失败)"] -``` +| 特性 | 说明 | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🎬 智能识别 | TMDB API 匹配电影与电视剧,支持多种文件名格式 | +| 🔗 硬链接整理 | 同一文件系统内零拷贝,省空间、省时间 | +| � 媒体类型智能判断 | 文件名无明确季集/年份特征时,**根据媒体目录内正片数量**判断电影/剧集(不依赖下载目录,合集种子可混合);特典归属同理(媒体目录仅 1 个正片→电影特典跳过,多个→剧集特典 Season 00) | +| 🤖 AI 辅助识别 | 无法匹配/需甄别时调用 OpenAI 兼容接口(默认 DeepSeek-V4-Flash)纠正搜索词、**匹配甄别**(选候选 id + 季映射)、判定艺术家、学习特典映射;分批处理、失败可逆跳过 | +| 📚 持久化缓存 | 缓存根目录 `mo_cache/` 分两区:`tmdb/` 镜像 TMDB API 路径缓存全部请求数据(`search/movie\|tv/.json`、`tv/..json`、`tv//season/..json`、`movie/..json`),`media_organizer/`存脚本运行状态(账本/冷却),用户配置类文件(特典映射/词表、跳过目录、季偏移)位于`mo_config/`——TMDB 数据与脚本数据分离,二次运行零外部请求;TTL:正常数据 30 天 / 查无此片 3 天;并发 flock 去重 | +| ⭐ 特典自动识别 | 识别 NCOP/NCED/Menu/PV/CM/Teaser/Preview 等特典并归入 Season 00;本地关键字 → keymap 多语言值(多键→多值)→ TMDB SEASON0 匹配;匹配用 `S00E{编号}`,未匹配用 `S00{类型}{编号}` | +| 🎬 电影特典跳过 | **不依赖下载目录**:媒体目录仅 1 个正片(如合集里的剧场版)时,其 SPs/CDs 特典视频不整理(TMDB/Jellyfin 不收录电影特典,避免误判为剧集特典) | +| 🎵 CD 音乐归 Music | 音频文件(flac/mp3 等)解析 CD 目录名,归入 `Music/歌手/专辑/曲目` | +| 🧮 季数偏移 | 处理 TMDB 季数与实际集数不符的剧集 | +| 📦 单文件分发 | 三个配置文件模板全部内嵌为常量,无需携带配套文件 | +| ♻️ 增量标记 | 已链接账本(`linked.json`):重跑时已整理文件直接跳过(源 inode 与目标均有效才跳过,目标被删/源被替换自动失效);cron 重跑只处理新文件 | +| ⏳ 失败冷却 | `fail_cooldown.json`:请求失败/未识别条目登记冷却(默认 24h,`FAIL_RETRY_COOLDOWN_HOURS` 可配),冷却期内跳过,过期自动重试——避免每轮全量重试同一批失败项 | +| ✏️ 手动纠错 | `--export-map` 伴生 `.ledger.json` 账本:改目标路径 + `action:"rerun"` 后 `--rerun` 重跑(不重新识别,只按账本硬链接;目标已存在则替换) | +| 🔎 搜索重试链 | 识别搜索失败时自动重试:zh-CN → en-US(默认)→ MyAnimeList 候选回搜(`SEARCH_FALLBACK_MAL=true` 可选) | +| 📏 AI 时长信号 | AI 匹配甄别输入补充 ffprobe 实测时长/分辨率——AI 可区分剧场版/OVA/正片 | +| ⚠️ 同集冲突报告 | 同一剧集/季/集多个来源时在对照表并列报告(不自动删,链接仍按多版本 `- 2` 去重) | +| 🎯 候选结构评分 | TV 搜索无精确匹配时按结构选候选:季数覆盖文件季号优先、名称归一化包含加分、特典文件优先含 Season 0 的候选(借鉴 Auto_Bangumi 的解析结果参与匹配) | +| 📁 目录名兜底 | 搜索失败用父目录名重搜(清洗+剥季后缀,zh→en)——压制组目录名常是完整剧名(借鉴 AB save_path 反查 / BAR 目录链) | +| 🧠 纠错学习 | `--rerun` 手动修正持久化为 `corrections.json`:同命名系列文件下次识别直接命中用户指定目标(借鉴 AB title_aliases 合并机制) | +| 🧭 命名模板 | 电影/剧集/特典/音乐命名格式可配置(`NAMING_MOVIE`/`NAMING_SHOW`/`NAMING_SEASON`/`NAMING_EPISODE`/`NAMING_SPECIAL`/`NAMING_MUSIC`):占位符 `{title}`/`{season:02}` + 条件段 `{?year: ({year})}`;默认模板与旧版输出逐字节一致,仅把格式外置 | +| 🎛️ 用户匹配规则 | `mo_config/match_rules.json`:**搜索别名**(主搜索失败后用用户指定重搜词兜底,可带年份)与 **ID 映射**(标题直接绑定 TMDB ID,跳过搜索)——确定性规则,优先于目录名兜底与 AI,适合俗称/简称与 TMDB 易选错的条目 | +| 🗂️ 配置按类目分区 | `special_maps`/`special_keywords`/`skip_directories`/`season_offsets` 支持类目分区形态(movie/tv/music/musicvideo + default 回退,8.8 节)——每类目可独立定义,未配置回退全局表;AI 学习写回自动适配分区 | +| 🏷️ 特典类别外置 | S00 未匹配特典的类别标签判定表外置为 `special_categories.json`(数组保序=优先级,8.9 节)——不再硬编码,用户可增删类别/调顺序 | +| 🎵 Music Videos | 音乐视频类目识别(目录信号 + `[MV]` 文件信号,7.2C 节)与命名(`MusicVideos/{artist}/{title}`,本地规则无网络请求) | +| 🔤 后缀季模糊匹配 | 硬编码词表失败后用 Levenshtein 距离对季名末尾窗口模糊匹配(借鉴 BAR SequenceMatcher 思路) | +| ✂️ 公共子串剥离 | 无特征多视频目录:剥离公共前后缀后取差异数字作集号(借鉴 BAR difflib 公共子串) | +| 🧩 AI 响应容错 | AI 返回非纯 JSON 时四级容错提取:直接解析 → 剥围栏 → 剥离思考链标记 → 最外层 {} 块(推理模型不再整批失效) | +| 🗂️ AI 用例落盘 | `AI_SAVE_CASES=true` 时每次请求的输入/响应存 `mo_cache/media_organizer/ai_cases/`——识别错误可复盘"当时 AI 看到了什么" | +| 👥 同目录上下文 | AI 匹配甄别输入附带同目录兄弟文件列表(≤20 个):目录持整季 BD 还是独立电影,AI 据此判断剧场版/OVA/正片 | +| 🖥️ 跨平台 | 支持 Linux / macOS(BSD stat 兼容) | --- ## 2. 依赖与环境要求 -| 依赖 | 用途 | 安装示例 (Arch) | -|---|---|---| -| **Bash 4.0+** | 脚本运行环境 | 系统自带 | -| **curl** | 调用 TMDB / AI API | `sudo pacman -S curl` | -| **jq** | JSON 解析 | `sudo pacman -S jq` | -| **ffprobe** | 媒体探测(依赖检查) | `sudo pacman -S ffmpeg` | +| 依赖 | 用途 | 安装示例 (Arch) | +| ------------- | -------------------- | ----------------------- | +| **Bash 4.0+** | 脚本运行环境 | 系统自带 | +| **curl** | 调用 TMDB / AI API | `sudo pacman -S curl` | +| **jq** | JSON 解析 | `sudo pacman -S jq` | +| **ffprobe** | 媒体探测(依赖检查) | `sudo pacman -S ffmpeg` | -> **重要**:源目录与目标目录必须在**同一文件系统**上,否则无法创建硬链接(脚本启动时会自动检测)。 +> [!WARNING] +> +> 源目录与目标目录必须在**同一文件系统**上,否则无法创建硬链接(脚本启动时会自动检测)。 --- @@ -76,64 +126,70 @@ flowchart LR ```bash # 首次运行会提示创建配置模板 -./media_organizer.sh /downloads /media +./dist/media_organizer /downloads /media ``` -1. 若无 TMDB 密钥,会询问是否生成 `mo_env` 模板 → 输入 `y` -2. 在 `mo_config/mo_env` 中填写 `TMDB_API_RA_TOKEN` -3. 设置权限:`chmod 600 mo_config/mo_env` +1. 若无 TMDB 密钥,会询问是否生成 `config.json` 模板 → 输入 `y` +2. 在 `mo_config/config.json` 中填写 `TMDB_API_RA_TOKEN` +3. 设置权限:`chmod 600 mo_config/config.json` ### 3.2 干运行测试 ```bash # --dry-run 零持久化副作用:不创建链接、不写缓存、不写日志(网络请求照常但不落盘) -./media_organizer.sh --dry-run /downloads /media +./dist/media_organizer --dry-run /downloads /media ``` ### 3.3 正式运行 ```bash -./media_organizer.sh /downloads /media +./dist/media_organizer /downloads /media ``` ### 3.4 自动化模式(适合定时任务) ```bash # 写入 /var/log,跳过无法处理的文件 -./media_organizer.sh -a /downloads /media +./dist/media_organizer -a /downloads /media # 配合 cron 定时运行 -0 3 * * * /path/to/media_organizer.sh -a /downloads /media +0 3 * * * /path/to/dist/media_organizer -a /downloads /media ``` +> [!TIP] +> +> 完整配置项说明见 [配置文件详解](#8-配置文件详解),环境要求见 [依赖与环境要求](#2-依赖与环境要求)。 + --- ## 4. 命令行选项 一次调用对应一种**模式(mode)**,由目录参数个数与 `--update-cache` 修饰标志共同决定: -| 模式形状 | 调用形式 | 行为 | -|---|---|---| -| `organize` | `[选项] <源目录> <目的目录>` | 正常整理(使用缓存) | -| `organize + update` | `--update-cache <源目录> <目的目录>` | 整理并强制重取对应缓存 | -| `cache-only` | `--update-cache <源目录>` | 仅更新缓存,不整理(更新完退出) | -| `list` | `--list-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` | 取消自动化模式(覆盖环境变量/mo_env 设置) | -| `--dry-run` | 干运行:不创建链接、不写缓存、不写日志;网络请求照常但不落盘 | -| `--no-dry-run` | 取消干运行(覆盖环境变量/mo_env 设置) | -| `--refresh-cache` | 清空全部持久化缓存后执行(可与任意模式叠加) | -| `--update-cache` | 强制重取对应缓存条目(与 `--list-cache` 互斥) | -| `--list-cache [关键词]` | 列出缓存内容(哈希/类型/路径/参数/获取时间);可选关键词按类型/路径/参数过滤 | -| `--src-dir <目录>` | 指定源目录(与位置参数互斥) | -| `--dest-dir <目录>` | 指定目的目录(与位置参数互斥) | -| `-h, --help` | 显示帮助信息(退出码 0) | -| `--version` | 显示版本号(退出码 0) | +| 选项 | 说明 | +| ----------------------- | ---------------------------------------------------------------------------------------------------- | +| `-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` 成对指定,两种通道不可混用。`--` 之后的所有参数一律视为位置参数(目录名以 `-` 开头时使用)。 @@ -142,6 +198,7 @@ flowchart LR **退出码**:0 = 全部成功;1 = 运行期错误;2 = 用法错误(未知选项、参数个数、模式冲突,附一行用法提示);3 = 部分失败(非预期跳过 / 请求失败 / 链接失败 > 0,供 cron 感知)。 **冲突规则**(硬报错,不再静默忽略): + - `--list-cache` 不接受目录参数,且不能与 `--update-cache` / `--dry-run` / `--automated` 同时使用 - 仅更新缓存(1 个目录)不接受 `--dry-run` / `--automated`(无链接可预览、无整理步骤) - 0 个目录 + `--update-cache`:报错(缺源目录) @@ -187,10 +244,10 @@ flowchart TD LC -- "是" --> ICD2["init_cache_dir
解析缓存路径 建子目录"] ICD2 --> LCL["cache_list 按过滤词列出
列出后退出"] LC -- "否" --> CFG["load_config"] - CFG --> CFG1["find_config_file 定位 mo_env"] + CFG --> CFG1["find_config_file 定位 config.json"] CFG1 --> CFG2["check_secure_file 校验 600 权限"] - CFG2 --> CFG3["白名单键提取
从 MO_ENV_TEMPLATE 取键名集合
逐键 parse_config_key 读 mo_env
(文件永不执行)"] - CFG3 --> CFG4["应用配置链
CLI > 环境变量 > mo_env > 默认值
扩展名/curl/缓存 TTL 等全部配置项"] + CFG2 --> CFG3["白名单键提取
从 CONFIG_TEMPLATE 取键名集合
逐键 parse_config_key 读 config.json
(文件永不执行)"] + CFG3 --> CFG4["应用配置链
CLI > 环境变量 > config.json > 默认值
扩展名/curl/缓存 TTL 等全部配置项"] CFG4 --> IC["init_colors 初始化色彩"] IC --> SA["select_auth 认证选择
(决策见 7.1)"] SA --> CD["check_dependencies"] @@ -209,7 +266,7 @@ flowchart TD ISM7 --> ISM8["第二遍:resolve_special_value
递归展开引用 → SPECIAL_MAP 统一 JSON 数组"] ISM8 --> ISO["init_season_offset 季偏移"] ISO --> ISO1["内嵌 SEASON_OFFSET_TEMPLATE
→ SEASON_OFFSET_MAP"] - ISO1 --> ISO2["外部 season_offset.json 覆盖
(剧名小写 或 id: 键)"] + ISO1 --> ISO2["外部 season_offsets.json 覆盖
(剧名小写 或 id: 键)"] ISO2 --> ISO3{"文件不存在?"} ISO3 -- "是" --> ISO4["由 SEASON_OFFSET_MAP 生成默认 JSON"] ISO3 -- "否" --> UPD @@ -288,9 +345,25 @@ flowchart TD 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 响应解析走四级容错链(直接 → 围栏 → 思考链剥离 → 最外层 {} 块),推理模型的 ``/「思考:」输出不再导致整批失效;`AI_SAVE_CASES=true` 时请求输入与原始响应落盘 `mo_cache/media_organizer/ai_cases/`;match_entries 附带同目录 siblings 上下文(剧场版混 TV 场景判断依据)。 + ### 5.2 配置优先级 -**值优先级**(同一个配置键取最高来源):**CLI > 环境变量 > mo_env 文件 > 内置默认值**。CLI 显式设置(含 `--no-*` 反选)覆盖一切;mo_env 仅读取白名单键(键名集合取自内嵌模板),文件内容永不执行。 +**值优先级**(同一个配置键取最高来源):**CLI > 环境变量 > config.json > 内置默认值**。CLI 显式设置(含 `--no-*` 反选)覆盖一切;config.json 仅读取白名单键(键名集合取自内嵌模板),文件内容永不执行。 **配置文件查找**遵循严格的优先级(这是"文件在哪里"的问题,与上面的值优先级正交): @@ -300,19 +373,25 @@ $$P = \underbrace{\text{环境变量}}_{1^\text{st}} \succ \underbrace{\text{执 flowchart TD A[查找配置] --> B{环境变量已指定?
如 SPECIAL_MAP_FILE=...} B -- 是 --> B1[使用环境变量路径] - B -- 否 --> C{执行目录 mo_config 存在?
$PWD/mo_config/xxx} + B -- 否 --> C{执行目录已有文件?
配置类文件: $PWD/mo_config/xxx
(config.json / special_maps / ...)} C -- 是 --> C1[使用执行目录路径] C -- 否 --> D[使用脚本目录路径
$SCRIPT_DIR/mo_config/xxx] ``` -**三个配置文件的默认位置**(优先级:环境变量 > 执行目录 > 脚本目录): +> [!NOTE] +> +> **三类数据分离**:`mo_config/` = 用户配置类文件(`config.json` + 特典映射/词表、跳过目录、季偏移——用户可编辑,脚本 AI 学习也会写回);`mo_cache/tmdb/` = TMDB API 响应缓存(可再生,`--refresh-cache` 清除);`mo_cache/media_organizer/` = 脚本运行状态(已链接账本/失败冷却)。旧版文件(各旧文件名与旧位置)在首次运行时**自动迁移**。 -| 文件 | 执行目录 | 脚本目录 | -|---|---|---| -| `mo_env` | `$PWD/mo_config/mo_env` | `$SCRIPT_DIR/mo_config/mo_env` | -| `mo_special_keymap.json` | `$PWD/mo_config/mo_special_keymap.json` | `$SCRIPT_DIR/mo_config/mo_special_keymap.json` | -| `season_offset.json` | `$PWD/mo_config/season_offset.json` | `$SCRIPT_DIR/mo_config/season_offset.json` | -| 缓存 `mo_cache` | `$PWD/mo_cache` | `$SCRIPT_DIR/mo_cache` | +**默认文件位置**(优先级:环境变量 > 执行目录 > 脚本目录): + +| 文件 | 执行目录 | 脚本目录 | +| -------------------------- | -------------------------------------- | --------------------------------------------- | +| `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/` | --- @@ -337,15 +416,15 @@ 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` | +| 端点 | 用途 | 附加参数 | 返回的关键字段 | +| --------------------- | ---------------------- | ----------------------- | ---------------------------------------- | +| `/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 纠正后的搜索词重新搜索。 @@ -369,24 +448,37 @@ flowchart TD I --> J["safe_printf_int 补零
构造目标路径输出"] ``` -### 6.4 缓存方案(v9.0:镜像 TMDB API 路径) +### 6.4 缓存方案(v9.1:TMDB 缓存与脚本缓存分离) -> v9.0 将缓存方案完全重做:**缓存目录结构镜像 TMDB API 端点路径**,一切请求数据(搜索、详情、各季)全量缓存,二次运行零外部请求。 +> v9.0 将缓存方案完全重做:**缓存目录结构镜像 TMDB API 端点路径**,一切请求数据(搜索、详情、各季)全量缓存,二次运行零外部请求。缓存根目录 `mo_cache/` 仅存放可再生数据(`tmdb/` = API 响应缓存,`media_organizer/` = 运行状态账本);**用户配置类文件**(特典映射/词表、跳过目录、季偏移,可编辑 + 脚本可写回)统一位于 `mo_config/`。 **缓存目录结构**(默认 `mo_cache/`): -``` +```text mo_cache/ -├── search/ -│ ├── movie/.json # 搜索缓存(包裹格式) -│ └── tv/.json -├── tv/ -│ ├── ..json # 剧集详情(原始 JSON;语言段随 TMDB_LANG) -│ └── /season/..json # 每季数据(含 season 0 特典季) -└── movie/ - └── ..json # 电影详情(原始 JSON) +├── tmdb/ # TMDB API 响应缓存(--refresh-cache 仅清空此区) +│ ├── search/ +│ │ ├── movie/.json # 搜索缓存(包裹格式) +│ │ └── tv/.json +│ ├── tv/ +│ │ ├── ..json # 剧集详情(原始 JSON;语言段随 TMDB_LANG) +│ │ └── /season/..json # 每季数据(含 season 0 特典季) +│ └── movie/ +│ └── ..json # 电影详情(原始 JSON) +└── media_organizer/ # 脚本运行状态(不随 --refresh-cache 清除) + ├── linked.json # 已链接账本(增量标记) + └── fail_cooldown.json # 失败冷却 + +mo_config/ # 用户配置类文件(可编辑 + 脚本可写回) +├── config.json # 主配置 +├── special_maps.json # 特典映射(AI 学习写回) +├── special_keywords.json # 特典词表(AI 学习写回) +├── skip_directories.json # 跳过目录词表(AI 学习写回) +└── season_offsets.json # 季偏移(运行时生成) ``` +> 旧版自动迁移(仅执行一次,干运行不迁移):① 布局迁移 `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,含查询参数与获取时间): ```json @@ -404,11 +496,11 @@ mo_cache/ - **`tmdb_api` 统一封装**:所有 TMDB 请求必经此函数。先 `cache_get` 查缓存——命中直接返回;未命中加锁后 `curl` 请求,成功后 `cache_put` 落盘。 - **`cache_key`**:将查询参数拼成 `query=xxx&lang=zh-CN` 形式(末尾固定加 `&lang`)。 -- **`cache_path`**:搜索请求用 key 的 md5 哈希作文件名(`search/movie|tv/.json`);详情/季请求从 key 中提取 id 作路径并**附加语言段**(`tv/..json`、`tv//season/..json`、`movie/..json`)——切换 `TMDB_LANG` 自动 miss 重新拉取。 +- **`cache_path`**:搜索请求用 key 的 md5 哈希作文件名(`search/movie|tv/.json`,位于 `tmdb/` 下);详情/季请求从 key 中提取 id 作路径并**附加语言段**(`tv/..json`、`tv//season/..json`、`movie/..json`)——切换 `TMDB_LANG` 自动 miss 重新拉取。 - **并发去重(flock + 双检)**:识别池多 worker 可能同时 miss 同一查询——`tmdb_api` 未命中后用 `flock` 跨进程互斥(锁文件在 `/tmp`),锁内**双检**缓存:等待者直接命中先写者的结果,同一查询只发一次请求。 - **`cache_put_empty`**:搜索**空结果**(`results:[]`)写 `empty:true` 哨兵(3 天短 TTL)——查无此片在新片出现后自动重新搜索;请求失败(重试耗尽)不写哨兵,下次运行重试。 - **过期机制**:普通缓存 `CACHE_TTL_DAYS`(默认 30 天)、空哨兵 `CACHE_EMPTY_TTL_DAYS`(默认 3 天),超时按 mtime 判定后重新请求。空哨兵在 TTL 内由 `cache_empty_fresh` 识别并跳过重复请求(不重新 curl),TTL 过后才重试。 -- **季号归一化**:识别流程将 `parse_media_filename` 产出的季/集号(如 `S01E05` 的 `01`/`05`)归一化为无前导零十进制(`1`/`5`),保证季缓存路径 `season/1.json` 与 `--update-cache` 的整数循环一致,缓存互可命中。 +- **季号归一化**:识别流程将 `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 请求/秒)。 @@ -417,21 +509,57 @@ mo_cache/ ```bash # 查看缓存(哈希/类型/路径/参数/获取时间),可按关键词过滤 -./media_organizer.sh --list-cache -./media_organizer.sh --list-cache "tv" +./dist/media_organizer --list-cache +./dist/media_organizer --list-cache "tv" # 仅更新缓存(1 个目录):扫描源目录所有唯一查询,强制重取并覆盖缓存后退出 -./media_organizer.sh --update-cache /downloads +./dist/media_organizer --update-cache /downloads # 整理并强制重取(2 个目录):整理流水线内对处理的条目不信任陈旧缓存 -./media_organizer.sh --update-cache /downloads /media +./dist/media_organizer --update-cache /downloads /media -# 清空缓存后运行 -./media_organizer.sh --refresh-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 文档,用于核对整理结果(源文件、目标文件、链接状态)。 + +```bash +# 生成对照表(默认输出到脚本目录/mo_map/<源目录名>-.md) +./dist/media_organizer --export-map /downloads /media + +# 指定输出目录(空格形式会与位置参数冲突,须用 = 形式) +./dist/media_organizer --export-map=/path/to/map /downloads /media +``` + +**文档结构**(分类依据 = 目的目录根名 `FOLDER_MOVIES`/`FOLDER_SHOWS`/`FOLDER_MUSIC`/`FOLDER_MUSICVIDEOS`/`FOLDER_UNKNOWN`): + +```markdown +# 媒体整理对照表 + +- 源目录 / 目的目录 / 生成时间 / 运行模式 + +## 📊 分类汇总(N 条) ← 各类条目数一览 + +- 🎬 电影:N 条 / 📺 节目:N 条 / ... + +## 🎬 电影(N 条) ← 每类独立小节与编号 + +| # | 源文件 | 目标文件 | 链接状态 | + +## ⏭ 未完成(未识别/跳过/失败/待 AI)(N 条) + +## 链接状态汇总 ← 已链接/失败/跳过计数 +``` + +> 链接状态在非干运行下**现场校验**(目标存在且与源同 inode);干运行标记 `🔄 干运行`。**伴随文件**(字幕/音轨等)在链接成功后登记映射,随主媒体归入对应大类(`📎 伴随文件`),未链接成功的不展示。0 条的分类不输出小节。**文件名命名**:`<源目录名>-<源目录绝对路径 md5 前 8 位>.md`——源目录名(sanitize 去非法字符,空名回退 `root`)保证可读,md5 前缀保证唯一与稳定(同一源目录多次运行互相覆盖)。 + --- ## 7. 执行判断详解 @@ -447,7 +575,7 @@ flowchart TD B -- 否 --> C{TMDB_API_KEY 非空?} C -- 是 --> C1[API Key 认证
TMDB_AUTH_TOKEN=API_KEY] C -- 否 --> D{自动化模式?} - D -- 否 --> E[提示生成 mo_env 模板] + D -- 否 --> E[提示生成 config.json 模板] E --> E1{用户输入 y?} E1 -- 是 --> E2[生成模板 退出码 0] E1 -- 否 --> F[报错 退出码 1] @@ -477,13 +605,17 @@ flowchart TD G -- "否" --> H["回退:正片数量判断
(原子步骤见 7.2B)"] ``` +> [!NOTE] +> +> **v9.6 Music Videos 前置检查**:年份提取后、上述 TV 规则之前先做音乐视频信号检查(原子步骤见 7.2C)——目录信号(目录名精确匹配 musicvideo 词)命中即判 `musicvideo`(即使文件名含季集标记);文件名信号(`[MV]` 等标记 + "歌手 - 歌名" 模式)同理。未命中任何信号才进入上述主决策树。 + #### 7.2A 特典识别与处理(原子步骤) > 特典识别**优先于季份**(如 `Show 2nd Season [Menu01]` 先识别为特典 Season 00)。特典词在**方括号标记内**匹配(避免误判标题),也支持**父目录**判断(文件位于 `SPs/`、`CDs/`、`Bonus/` 等)。 ```mermaid flowchart TD - SA{"遍历 special_words
文件名方括号内含特典词?
menu/ncop/nced/pv/cm/sp/teaser/
promo/trailer/special/mv/特典/花絮"} + SA{"遍历特典词表(special_keywords)
文件名方括号内含特典词?
menu/ncop/nced/pv/cm/sp/teaser/
promo/trailer/special/mv/特典/花絮"} SA -- "是" --> SA1["is_special=true
frag=命中特典词(去空格)
tag=完整方括号标记"] SA -- "否" --> SB{"父目录是特典目录?
SPs/Specials/CDs/Bonus/
Extras/特典/特番/花絮"} SB -- "是" --> SA1 @@ -508,6 +640,8 @@ flowchart TD #### 7.2B 正片数量判断(回退,v9.3) +> [!NOTE] +> > **不依赖下载目录**。仅统计"正片":跳过 `SPs/CDs/Scans/Fonts/特典` 等子目录;扩展名取 `VIDEO_EXTS`(**mka 是纯音频容器,不计入**)。结果按媒体目录缓存(`MAIN_COUNT_CACHE`),避免重复扫描。 ```mermaid @@ -523,15 +657,37 @@ flowchart TD **支持的文件名格式**: -| 格式 | 示例 | 识别结果 | -|---|---|---| -| 电影(带年份) | `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 | -| 其他(无明确特征) | `Random Movie.mkv` / `[Group] Show [1080p]` | 由媒体目录正片数量判断:**1→movie**,**≥2→tv**,**0/无目录→unknown(AI)** | +| 格式 | 示例 | 识别结果 | +| ------------------ | --------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| 电影(带年份) | `Inception (2010).mkv` | movie | +| 剧集 SxxEyy | `Breaking Bad S01E01.mkv` | tv S01E01 | +| 剧集 #x## | `Show 1x05.mkv` | tv S01E05 | +| 特典 | `Show [NCOP].mkv` / `Show [Menu01].mkv` / `CM01.mkv`(在 SPs 目录) | 剧集特典 S00;媒体目录仅 1 正片 → **电影特典跳过** | +| 季份 | `Show 2nd Season [01].mkv` | tv 季份 | +| 方括号集号 | `Show [03].mkv` | tv S01E03 | +| 音乐视频 | `Music Videos/周杰伦/晴天.mkv`、`周杰伦 - 晴天 [MV].mkv`、`Live/演唱会.mkv` | **musicvideo**(见 7.2C) | +| 其他(无明确特征) | `Random Movie.mkv` / `[Group] Show [1080p]` | 由媒体目录正片数量判断:**1→movie**,**≥2→tv**,**0/无目录→unknown(AI)** | + +#### 7.2C Music Videos 识别(原子步骤,v9.6) + +音乐视频类目识别信号 = musicvideo 判定词(`special_keywords.json` 的 `musicvideo` 分区 → default → 内置默认 13 词,如 `mv`/`music video`/`live`/`concert`/`演唱会`): + +```mermaid +flowchart TD + MA{"目录信号:父目录链任意段
目录名精确匹配 musicvideo 词?
(归一化去空格小写整名相等,
如 Music Videos/MV/Live/演唱会)"} + MA -- "是" --> M1["musicvideo
输出 musicvideo|title|year"] + MA -- "否" --> MB{"文件名信号:方括号标记
子串命中 musicvideo 词?
(如 [MV]/[Live])"} + MB -- "否" --> MC["非音乐视频 → 主决策树"] + MB -- "是" --> MD{"标记同时命中特典词?
(如 [MV]/[PV] 双命中)"} + MD -- "否" --> M1 + MD -- "是" --> ME{"文件名含 \" - \" 模式?
(歌手 - 歌名)"} + ME -- "是" --> M1 + ME -- "否" --> MC["保持特典路径
(\"动画名 [MV]\" 剧集 MV 特典)"] +``` + +- **目录信号**:`Music Videos/`、`MV/`、`Live/`、`演唱会/` 等目录(**精确匹配整目录名**,避免 `Muv-Luv`、`tmp.xxx` 等含词目录误判;`PV/` 特典目录不在 musicvideo 词表 → 保持特典路径)。 +- **文件名信号**:`[MV]`/`[Live]`/`[Concert]` 等标记。双命中歧义(标记同时在特典词表,如 `[MV]`)用 **"歌手 - 歌名" 模式**消解:文件名含 `-` → 音乐视频;不含 → 特典(剧集 MV 特典不被误判)。两侧词表均可配置完全控制(musicvideo 分区删词 → 永不判音乐视频;tv 特典词表删词 → 一律判音乐视频)。 +- **命名**:`MusicVideos/{artist}/{title}.{ext}`(`NAMING_MUSICVIDEO` 模板)。artist 归类链:元数据 `ALBUMARTIST → ARTIST` → 父目录名解析(`歌手 - 歌名` 取首段;无分隔符整名作歌手;源根直属不解析)→ `FOLDER_UNKNOWN`。本地规则无网络请求,识别失败不消耗 AI(unknown 才交 AI)。 ### 7.3 季数偏移判断(identify_tv_show) @@ -579,6 +735,8 @@ $$E_{\text{new}} = E_{\text{old}} + O = 1 + 23 = 24 \quad\Rightarrow\quad \text{ ### 7.4 特典匹配判断(Season 00) +> [!NOTE] +> > **前置归属判断**(v9.3,不依赖下载目录):特典文件先由 `parse_media_filename` 判定归属——媒体目录仅 1 个正片 → **电影特典**(`type=skip`,直接跳过不整理,TMDB/Jellyfin 不收录电影特典);多个正片 → **剧集特典**(进入本节的 Season 00 处理)。以下为剧集特典的**每一个原子化操作**: ```mermaid @@ -625,11 +783,11 @@ flowchart TD **特典命名规则**: -| 情况 | 命名 | 示例 | -|---|---|---| +| 情况 | 命名 | 示例 | +| ------------------- | --------------------------- | ------------------------------------------------ | | TMDB 特典集匹配成功 | `S00E{集号} - TMDB集名.ext` | `S00E01 - 迷你动画「猫猫的独语」第1话:白粉.mkv` | -| 未匹配(有编号) | `S00{类型}{编号}.ext` | `S00CM01.mkv`、`S00Menu01.mkv`、`S00PV01.mkv` | -| 未匹配(无编号) | `S00{类型}.ext` | `S00NCED.mkv`、`S00NCOP.mkv` | +| 未匹配(有编号) | `S00{类型}{编号}.ext` | `S00CM01.mkv`、`S00Menu01.mkv`、`S00PV01.mkv` | +| 未匹配(无编号) | `S00{类型}.ext` | `S00NCED.mkv`、`S00NCOP.mkv` | > `S00E{编号}` 仅用于 TMDB 能匹配的特典集;未匹配的特典用 `S00{类型}{编号}` 命名,避免占用正常特典编号、干扰 Jellyfin 刮削。 @@ -662,12 +820,16 @@ flowchart TD E3 --> E13["仍失败 → 回退命名(降级成功)"] ``` +> [!NOTE] +> > **AI 失败语义**:请求失败/非 JSON/干运行/达上限 → 剩余搜索与匹配待定**显式跳过(可逆)**——下次运行自动重试,与无 AI 密钥语义统一;多艺术家待定直接拼接(信息不丢)。 > -> **AI 成本控制**:`AI_BATCH_SIZE`(默认 50)条/批,`AI_MAX_CALLS`(默认 10)为**批次上限**;`AI_DRY_RUN=true` 时可测试而不产生费用。AI 学到的特典映射会**持久化**到 `mo_special_keymap.json`(值数组合并去重),下次运行直接生效。 +> **AI 成本控制**:`AI_BATCH_SIZE`(默认 50)条/批,`AI_MAX_CALLS`(默认 10)为**批次上限**;`AI_DRY_RUN=true` 时可测试而不产生费用。AI 学到的特典映射会**持久化**到 `special_maps.json`(值数组合并去重),特典词写入 `special_keywords.json`,非媒体目录写入 `skip_directories.json`——下次运行直接生效。 ### 7.6 硬链接处理判断(link_media) +> [!NOTE] +> > **v9.1 起不再做 `.bak` 备份**——遗留的 `.bak_*` 会被 Jellyfin 当作媒体扫描干扰刮削;目标已存在时**直接替换**(先删旧目标再建硬链接)。以下为**每一个原子化操作**: ```mermaid @@ -741,62 +903,95 @@ flowchart TD ## 8. 配置文件详解 -### 8.1 `mo_env`(主配置文件) +### 8.1 `config.json`(主配置文件) -所有配置项及其默认值(优先级:**环境变量 > mo_env > 脚本默认值**): +所有配置项及其默认值(优先级:**环境变量 > 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 API 路径) | -| `CACHE_TTL_DAYS` | `30` | 缓存有效期(天),超过后重新请求 | -| `CACHE_EMPTY_TTL_DAYS` | `3` | 空结果哨兵有效期(天),超过后重新请求 | -| `SPECIAL_MAP_FILE` | `mo_config/mo_special_keymap.json` | 特典映射文件 | -| `SPECIAL_WORDS_FILE` | `mo_config/mo_special_words.json` | 特典识别词表文件 | -| `SEASON_OFFSET_FILE` | `mo_config/season_offset.json` | 季偏移文件 | -| `COLOR_OUTPUT` | `true` | 彩色输出开关 | -| `DEBUG_LEVEL` | `0` | 调试级别(0/1/2) | -| `AI_API_KEY` | (空,留空禁用 AI) | AI API 密钥 | -| `AI_BASE_URL` | `https://api.openai.com` | AI API 基础 URL | -| `AI_FULL_URL` | (空) | AI 完整端点(默认 `{AI_BASE_URL}/v1/chat/completions`) | -| `AI_MODEL` | `gpt-3.5-turbo` | AI 模型 | -| `AI_MAX_CALLS` | `10` | 单次最多 AI 调用次数 | -| `AI_DRY_RUN` | `false` | AI 干运行(不产生费用) | -| `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` | 跟随系统语言 | 目的目录"剧集"根名 | -| `FOLDER_MUSIC` | 跟随系统语言 | 目的目录"音乐"根名 | -| `FOLDER_MUSICVIDEOS` | 跟随系统语言 | 目的目录"音乐视频"根名(官方库名 `MusicVideos`) | -| `FOLDER_UNKNOWN` | 跟随系统语言 | 未知艺术家/标题占位名 | -| `SKIP_HARDLINK_CHECK` | `false` | 跳过硬链接检查(不建议) | +| 配置项 | 默认值 | 说明 | +| --------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `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` | 跟随系统语言 | 未知艺术家/标题占位名 | -### 8.2 `mo_special_keymap.json`(特典映射) +> [!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 作为匹配关键字——写回前交叉验证(产物必须是候选列表成员,防幻觉污染)。 -### 8.3 `mo_special_words.json`(特典识别词表) +> [!NOTE] +> +> **v9.6 类目分区**:支持 `{"tv": {...}, "musicvideo": {...}, "default": {...}}` 分区形态(查询 tv 分区 → default → 全局表;AI 写回 tv 分区),详见 8.8 节。 -**格式**:JSON 数组,元素 = 特典类别词(`["menu","ncop","cm",...]`)。文件名方括号标记含这些词 → 判定为特典(Season 00)。匹配对空格不敏感(词表 `ncop` 可匹配文件里的 `nc op`);词表内容不当作正则。缺失时交互创建内置默认(19 词)。 +> [!NOTE] +> +> **判定与匹配分工**:keymap 解决"**匹配**特典"(本地标记 → TMDB 关键字),词表(8.3 节)解决"**判定**特典"(文件名标记 → 是否特典)。AI 学习会**同时写回两者**:学到的新片段核心词并入 `special_keywords.json`,保证下次遇到不在特典目录里的同类文件名标记时,先能被判定为特典、再走 keymap 匹配——否则只写回 keymap 时判定环节直接失败,学习结果对不上。 + +### 8.3 `special_keywords.json`(特典识别词表) + +**格式**:JSON 数组,元素 = 特典类别词(`["menu","ncop","cm",...]`)。文件名方括号标记含这些词 → 判定为特典(Season 00)。匹配对空格不敏感(词表 `ncop` 可匹配文件里的 `nc op`);词表内容不当作正则。缺失时交互创建空词表 `[]`(v9.7 起无内置示例词,真实词由 AI 学习与用户添加)。 + +**AI 学习写回**:AI 学习特典映射的同时,会把新片段的核心词(去数字/空格、小写,长度 ≥ 2)**去重合并写回**本文件,使后续运行能识别同类标记(已存在于词表则跳过)。 + +> [!NOTE] +> +> **v9.6 类目分区 + 音乐视频判定词**:支持 `{"tv": [...], "musicvideo": [...], "default": [...]}` 分区形态,详见 8.8 节。`musicvideo` 分区词用于 **Music Videos 类目识别信号**(目录名精确匹配 + 文件名 `[MV]` 等标记,见 7.2C 节),默认 13 词(`mv`/`music video`/`live`/`concert`/`演唱会` 等),独立于 tv 特典词表。 ```json { @@ -812,14 +1007,25 @@ flowchart TD - **值**:TMDB SEASON0 特典集中能匹配该关键字的字符串数组(英文/中文/日文等多语言均可),或引用其他键的数组。 **识别/匹配流程**: + 1. 从文件名提取特典片段(如 `Menu01`)→ 转小写查 keymap(先精确,再按去数字核心词 `menu` 查);命中后引用值会递归展开为数组。 2. 命中后用**数组中的每一个值**去该剧集 TMDB `season/0` 的 `episodes[].name` 做 `contains`(忽略大小写)匹配——任一值命中即匹配该特典集。 3. 匹配成功 → 用 `S00E{编号} - TMDB集名` 命名;失败 → 回退 `S00{类型}{编号}`。 > AI 学习也会写入此文件:AI 判定本地关键字对应 SEASON0 的哪些特典名(多语言)后,合并写入值数组(去重)。 -> 缺失时,脚本在用户确认下用内嵌的 `SPECIAL_KEYMAP_TEMPLATE` 常量自动生成。 +> 缺失时,脚本在用户确认下用内嵌的 `SPECIAL_KEYMAP_TEMPLATE` 常量自动生成(v9.7 起模板为空,生成空映射 `{}`,真实映射由 AI 学习与用户添加)。 -### 8.3 `season_offset.json`(季数偏移) +### 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`,值为第一季的集数: @@ -832,7 +1038,122 @@ flowchart TD } ``` -> 缺失时,脚本用内嵌的 `SEASON_OFFSET_TEMPLATE` 常量自动生成 14 个内置默认值。 +> 缺失时,脚本用内嵌的 `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` 等价): + +```json +{ + "search_aliases": { + "俺妹": "Ore no Imouto ga Konna ni Kawaii Wake ga Nai", + "路人女主剧场版": { "term": "Saekano the Movie", "year": "2019" } + }, + "id_maps": { + "movie": { "刀剑神域": 20982 }, + "tv": { "魔法禁书目录": 4654 } + } +} +``` + +**搜索别名**(`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`(可含 `/` 产生多级目录) | + +示例: + +```jsonc +{ + "NAMING_MOVIE": "{title} ({year}) [{quality}]", // 注意:{quality} 不存在 → 渲染为空,加载期打印警告 + "NAMING_EPISODE": "EP{episode} - {episode_name}", + "NAMING_MUSIC": "{artist}/{album} ({year})", // 注意:音乐无 year 变量 +} +``` + +> [!WARNING] +> +> - 未知占位符渲染为空,加载时打印警告(拼写错误可被发现)。 +> - `Season NN` 与 `SxxExx` 是 Jellyfin 解析格式——修改 `NAMING_SEASON`/`NAMING_EPISODE` 默认结构可能导致刮削失败,请仅在了解后果时自定义。 +> - 电影/剧集目标要求**目录名与文件名同名**(Jellyfin 规范),模板渲染结果同时用于两者;渲染后的名称会经 sanitize 清洗(`\ / : * ? " < > |` 被移除),模板中不要依赖这些字符。 + +### 8.8 类目分区格式(`special_maps` / `special_keywords` / `skip_directories` / `season_offsets`,v9.6) + +四个配置类文件支持**两种形态**: + +1. **全局单表(旧版,默认兼容)**:当前结构直接可用(对象/数组),行为与 v9.5 完全一致。 +2. **类目分区(v9.6 新增)**:顶层按类目分键,每个类目一份表,**未配置回退 `default` 分区 → 全局表**。 + +**分区判定**:顶层全部键 ∈ `{movie, tv, music, musicvideo, video, audio, default, global}` 即视为分区形态(混合形态按全局表处理并警告)。 + +**各文件的分区键与查询语义**: + +| 文件 | 分区键 | 查询上下文 | +| ----------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `special_maps.json` | `tv`(特典映射)、`musicvideo`(预留)、`default` | 特典匹配(`translate_fragment`/`match_special_episode`)查 **tv 分区 → default → 全局表**;分区内引用展开同分区优先,其次全局表 | +| `special_keywords.json` | `tv`(特典判定词)、`musicvideo`(音乐视频判定词)、`default` | 特典判定查 **tv 分区 → default → 全局表**(v9.7 起无内置词);音乐视频判定查 **musicvideo 分区 → default → 内置 musicvideo 词表**(**不回退** tv 特典词表——避免 "sp" 命中 "SPs" 目录等交叉误判) | +| `skip_directories.json` | `video`(视频流程:电影/剧集/音乐视频的目录语义)、`music`(音频流程)、`default` | 按流程查对应分区 → default → 全局表。**分区形态 = 完整语义**(不叠加内置默认,通用词放 default 分区) | +| `season_offsets.json` | `tv`(季偏移)、`default` | 查 tv 分区 → default → 全局表(v9.7 起无内置偏移) | + +示例(`special_keywords.json` 分区形态): + +```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 子串按**数组顺序**(=优先级,长词/特定词在前)匹配 → 取标签。**数组保序,顺序即优先级**: + +```json +[ + { "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`)。 --- @@ -859,18 +1180,25 @@ flowchart TD └── 平井大/ └── 幸せのレシピ/ └── 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`) | -| 伴随文件 | 与视频同名,保留语言标签(`.zh.srt`、`.jp.ass` 等) | +| 类型 | 规则 | +| ----------------- | ----------------------------------------------------------------------------------------------------- | +| 电影 | `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{类型}{编号}` 区分,避免干扰正常特典编号。 --- @@ -885,7 +1213,7 @@ flowchart TD $$E' = E + S_1,\qquad S' = 1$$ -- **手动偏移**(来自 `season_offset.json`,偏移值为 $O$): +- **手动偏移**(来自 `season_offsets.json`,偏移值为 $O$): $$E' = E + O,\qquad S' = 1$$ @@ -903,7 +1231,8 @@ $$ 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}$$ +\end{cases} +$$ ### 10.3 特典别称匹配 @@ -929,100 +1258,152 @@ $$\text{match}(f) = \min\{\, \text{episode\_number} \mid N \text{ contains } \te ### 11.1 日志级别 -| 级别 | 颜色 | 场景 | -|---|---|---| -| `信息` | 白 | 常规进度 | -| `搜索` | 蓝 | TMDB 搜索 | -| `匹配` | 紫 | 识别成功 | -| `警告` | 黄 | 可恢复问题 | -| `错误` | 红 | 失败 | -| `跳过` | 灰 | 已存在/跳过 | -| `智能` | 青 | AI 操作 | -| `调试` | 暗 | DEBUG_LEVEL≥1 | +| 级别 | 颜色 | 场景 | +| ------ | ---- | ------------- | +| `信息` | 白 | 常规进度 | +| `搜索` | 蓝 | TMDB 搜索 | +| `匹配` | 紫 | 识别成功 | +| `警告` | 黄 | 可恢复问题 | +| `错误` | 红 | 失败 | +| `跳过` | 灰 | 已存在/跳过 | +| `智能` | 青 | AI 操作 | +| `调试` | 暗 | DEBUG_LEVEL≥1 | ### 11.2 调试技巧 ```bash # 详细日志(DEBUG_LEVEL=2 显示 TMDB 请求) -DEBUG_LEVEL=2 ./media_organizer.sh --dry-run /downloads /media +DEBUG_LEVEL=2 ./dist/media_organizer --dry-run /downloads /media # 测试 AI 而不产生费用 -AI_DRY_RUN=true AI_API_KEY=xxx ./media_organizer.sh --dry-run /downloads /media +AI_DRY_RUN=true AI_API_KEY=xxx ./dist/media_organizer --dry-run /downloads /media ``` --- ## 12. 故障排除 -| 问题 | 解决方法 | -|---|---| -| `Permission denied mo_env` | `chmod 600 mo_config/mo_env` | -| TMDB API 请求超时 | 增大 `TMDB_CURL_MAX_TIME=60`、`TMDB_DELAY=2` | -| 识别准确度低 | 配置 `AI_API_KEY` 启用 AI 辅助 | -| AI 成本过高 | 减小 `AI_MAX_CALLS=5` 或改用免费 Ollama | -| 无法读取配置文件 | 检查文件权限和所有者:`chown $(whoami) mo_env` | -| 无法创建硬链接 | 确认源/目标在同一文件系统 | -| 无法识别特典 | 在 `mo_special_keymap.json` 添加关键词映射 | -| **特典全部未匹配(被命名为 S00xxx 而非 S00E)** | 特典季数据缺失或搜索不中。先用 `--list-cache` 确认 `tv//season/0.json` 是否存在;若缺失或为空,运行 `--update-cache` 强制重取;或 `--refresh-cache` 清空后重跑。特典词可写入 `mo_special_keymap.json` 增强匹配 | -| **特典名变成 `Specialord Art Online` 等怪异文本** | BusyBox/OpenWrt 在空 locale 下 `tr '[:upper:]' '[:lower:]'` 字符类损坏(p→w、u→l 错误映射),污染特典映射。v9.1 已改用 bash 内建 `${var,,}` 转小写,不依赖 tr;升级脚本即可。也可删除被 AI 学习污染的 `mo_special_keymap.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_offset.json` 添加偏移值 | -| 运行中报 `Argument list too long` | 特典季 JSON 过大作为命令行参数所致;已改为独立文件存储,若旧版残留需用新版脚本 | +| 问题 | 解决方法 | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Permission denied config.json` | `chmod 600 mo_config/config.json` | +| TMDB API 请求超时 | 增大 `TMDB_CURL_MAX_TIME=60`、`TMDB_DELAY=2` | +| 识别准确度低 | 配置 `AI_API_KEY` 启用 AI 辅助 | +| AI 成本过高 | 减小 `AI_MAX_CALLS=5` 或改用免费 Ollama | +| 无法读取配置文件 | 检查文件权限和所有者:`chown $(whoami) config.json` | +| 无法创建硬链接 | 确认源/目标在同一文件系统 | +| 无法识别特典 | 在 `special_maps.json` 添加关键词映射 | +| **特典全部未匹配(被命名为 S00xxx 而非 S00E)** | 特典季数据缺失或搜索不中。先用 `--list-cache` 确认 `tmdb/tv//season/0.json` 是否存在;若缺失或为空,运行 `--update-cache` 强制重取;或 `--refresh-cache` 清空 TMDB 缓存后重跑。特典词可写入 `special_maps.json` 增强匹配 | +| **特典名变成 `Specialord Art Online` 等怪异文本** | BusyBox/OpenWrt 在空 locale 下 `tr '[:upper:]' '[:lower:]'` 字符类损坏(p→w、u→l 错误映射),污染特典映射。v9.1 已改用 bash 内建 `${var,,}` 转小写,不依赖 tr;升级脚本即可。也可删除被 AI 学习污染的 `special_maps.json` 重建 | +| **目标文件是完整原文件名(`S00[VCB-Studio] …`)** | 特典 fragment 误用整个文件名。v9.1 修复:仅父目录(SPs)识别特典时,从方括号标记提取简短特典片段,裸特典(如 `CM01.mkv`)用清理后文件名 | +| **伴随文件反复生成 `.bak_*` 备份** | v9.1 起**不再备份**:目标已存在时直接替换(删除旧目标再硬链接),避免 `.bak_*` 干扰 Jellyfin 刮削。此前遗留的 `.bak_*` 可手动清理(`find /media/nas/Shows /media/nas/Movies -name "*.bak_*" -delete`) | +| **硬链接目标覆盖产生 `.bak_*`** | 旧版在目标已存在时备份为 `.bak_时间戳`。v9.1 改为直接替换(不备份)。若发现 `.bak_*` 残留,先升级脚本再清理旧文件 | +| 季数错乱 | 在 `season_offsets.json` 添加偏移值 | +| 运行中报 `Argument list too long` | 特典季 JSON 过大作为命令行参数所致;已改为独立文件存储,若旧版残留需用新版脚本 | --- ## 13. 构建与开发(源码结构) > [!NOTE] -> 分发物始终是**单个文件** `media_organizer.sh`(自带全部配置模板,无需配套文件)。 -> 源码已按职责拆分为 `src/` 下的多个模块,由 `build.sh` 拼接生成分发脚本。 +> +> 分发物始终是**单个文件** `dist/media_organizer`(自带全部配置模板,无需配套文件;构建产物目录为 `dist/`)。 +> CLI 定义与源码位于 `src/`,由 [bashly](https://bashly.dev) 生成分发脚本 +> (开发期依赖 Ruby/basily;**产物运行无需任何依赖**)。 ### 13.1 目录结构 ```text -build.sh # 构建脚本:src/ 模块 → 单文件分发脚本 +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/ - log.sh # 日志与色彩(最先加载,保证全脚本日志输出一致) - config.sh # 配置加载与认证 - maps.sh # 特典映射与季偏移 - strings.sh # 字符串工具 - cache.sh # 缓存管理(cache_* 系列) - tmdb.sh # TMDB API(tmdb_api 统一缓存封装) - filename.sh # 媒体文件名解析 - registry.sh # 识别登记与并发池(register_* / emit / pool_run) - identify.sh # 媒体识别(电影/电视剧) - ai.sh # AI 批处理(请求构造/调用/特典学习) - link.sh # 硬链接基础工具(inode 判断/链接/错误处理) - args.sh # 帮助与参数解析 - process.sh # 目录校验/扫描/识别流水线 - ai_resolve.sh # AI 结果消费(重搜/匹配/回退命名) - link_media.sh # 创建硬链接及伴随文件 - report.sh # 对照表导出与运行汇总 - entry.sh # main() 唯一入口(文件末尾调用) + 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 构建 ```bash -./build.sh # 重新生成 media_organizer.sh(含语法检查与重复函数检查) -./build.sh --check # 仅校验根目录 media_organizer.sh 是否与 src/ 最新源码一致(可接入 CI) +./build.sh # bashly generate + 语法/重复函数检查 → ./dist/media_organizer +./build.sh --check # 仅校验 dist/media_organizer 是否与 src/ 最新源码一致(可接入 CI) ``` -构建产物顶部带有"由 build.sh 生成,请勿直接编辑"的说明头;除该说明头外,产物与原脚本逐字节一致。 +开发期依赖:`gem install bashly`(仅构建需要;产物自包含可独立运行)。 +bashly 开发模式:`bashly generate --watch`(源码变更自动重新生成)。 -### 13.3 修改流程 +### 13.3 参数解析(bashly) -1. 编辑 `src/` 下对应的模块文件 -2. 运行 `./build.sh` 重新生成分发脚本 -3. 运行 `./build.sh --check` 确认产物与源码同步 +- 全部选项/位置参数/帮助文本在 `src/bashly.yml` 声明(含 `--opt=值` 与 `--opt 值` 两种形式、`--` 分隔符) +- flag 互斥用 `conflicts` 声明(如 `--list-cache` 与 `--update-cache`) +- 解析结果在 `root_command` 中经 `args` 关联数组访问并映射到全局变量: + - 位置参数:`args[source_dir]` / `args[destination_dir]` + - flag:`args['--dry-run']`(存在即 1);带参 flag:`args['--rerun']` +- bashly 不支持 flag 可选参数:`--list-cache [关键词]` 的关键词经位置参数传递 + +### 13.4 单元测试 + +零依赖轻量测试框架(纯 bash): + +```bash +./tests/run.sh # 运行全部用例 +./tests/run.sh cache # 仅运行名字含 cache 的用例文件 +./tests/run.sh -v # 详细模式(显示每个用例) +``` + +- 加载 `src/main.sh` + `src/lib/*.sh`(跳过 root_command.sh 的顶层代码),每个用例文件在独立子 shell 中运行(全局状态自动隔离) +- 用例文件位于 `tests/cases/`,断言库 `tests/lib/assert.sh`(`assert_eq` / `assert_contains` / `assert_success` / `assert_failure` 等) +- 参数解析由 bashly 生成器保证,不做单元测试;CLI 行为用冒烟验证 + +### 13.5 静态检查(社区规范) + +```bash +./lint.sh # bash -n + ShellCheck 零容忍检查(src 全部模块 + 构建/测试脚本) +./lint.sh -v # 显示每个文件的检查状态 +``` + +规范遵循 [Google Shell Style Guide](https://google.github.io/styleguide/shellguide.html) + ShellCheck: + +- 库文件首行 `# shellcheck shell=bash`;跨文件全局变量的 SC2034/SC2004 误报 + 在文件头部集中 disable 并注明原因;有意未使用的 read 解构/API 参数用 `_` 前缀 +- 格式:2 空格缩进、`[[ ]]` 测试、`local` 声明、变量引号包裹 +- 可选格式化:`shfmt -w src/`(本仓库未强制全量格式化,新代码建议按 shfmt 风格书写) + +### 13.6 修改流程 + +1. 编辑 `src/` 下对应的模块文件(或 `src/bashly.yml` 调整 CLI 定义) +2. 运行 `./tests/run.sh` 跑相关模块测试 +3. 运行 `./build.sh` 重新生成分发脚本(产物输出到 `dist/media_organizer`) > [!WARNING] -> **不要直接编辑 `media_organizer.sh`**——下次构建会覆盖你的修改。 -> 模块拼接顺序 = 依赖顺序(常量 → 日志 → 配置 → … → 入口),调整模块时请同步更新 `build.sh` 中的模块清单。 +> +> **不要直接编辑 `dist/media_organizer`**——下次构建会覆盖你的修改。 --- @@ -1032,4 +1413,4 @@ src/ --- -*文档生成于 2026-08-08,对应脚本版本 v9.3。* +_文档生成于 2026-08-14,对应脚本版本 v9.6。_ diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..effb20e --- /dev/null +++ b/config.example.json @@ -0,0 +1,55 @@ +{ + "TMDB_API_KEY": "", + "TMDB_API_RA_TOKEN": "", + "TMDB_LANG": "zh-CN", + "TMDB_DELAY": "1", + "TMDB_CURL_RETRY": "3", + "TMDB_CURL_CONNECT_TIMEOUT": "10", + "TMDB_CURL_MAX_TIME": "30", + "VIDEO_EXTS": "mp4,mkv,avi,mov,wmv,flv,m4v,ts,m2ts,webm", + "AUDIO_EXTS": "mp3,flac,aac,ogg,wma,m4a,mka,ac3,dts,wav,opus", + "SUB_EXTS": "srt,ass,ssa,sub,idx,vtt,sup", + "SUB_GROUP_BLACKLIST": "VCB-Studio", + "CACHE_DIR": "", + "SPECIAL_MAP_FILE": "", + "SEASON_OFFSET_FILE": "", + "SPECIAL_WORDS_FILE": "", + "SKIP_DIRS_FILE": "", + "MATCH_RULES_FILE": "", + "NAMING_MOVIE": "{title}{?year: ({year})}", + "NAMING_SHOW": "{title}{?year: ({year})}", + "NAMING_SEASON": "Season {season:02}", + "NAMING_EPISODE": "S{season:02}E{episode:02}{range}{?episode_name: - {episode_name}}", + "NAMING_SPECIAL": "S00{tag} - {fragment}", + "NAMING_MUSIC": "{artist}/{album}", + "SEARCH_FALLBACK_MAL": "false", + "MAL_BASE_URL": "https://api.jikan.moe/v4", + "FAIL_RETRY_COOLDOWN_HOURS": "24", + "COLOR_OUTPUT": "true", + "DEBUG_LEVEL": "0", + "AI_API_KEY": "", + "AI_BASE_URL": "https://api.deepseek.com", + "AI_FULL_URL": "", + "AI_MODEL": "DeepSeek-V4-Flash", + "AI_MAX_CALLS": "10", + "AI_BATCH_SIZE": "50", + "AI_DRY_RUN": "false", + "AI_SAVE_CASES": "false", + "AI_CURL_RETRY": "3", + "AI_CURL_CONNECT_TIMEOUT": "10", + "AI_CURL_MAX_TIME": "30", + "LOG_FILE": "/var/log/media_organizer.log", + "SKIP_LOG_FILE": "/var/log/media_organizer_skip.log", + "LOG_ROTATE_MB": "10", + "SKIP_HARDLINK_CHECK": "false", + "CACHE_TTL_DAYS": "30", + "CACHE_EMPTY_TTL_DAYS": "3", + "AUTOMATED": "false", + "DRY_RUN": "false", + "FOLDER_MOVIES": "", + "FOLDER_SHOWS": "", + "FOLDER_MUSIC": "", + "FOLDER_MUSICVIDEOS": "", + "FOLDER_UNKNOWN": "", + "MEDIA_WORKERS": "4" +} diff --git a/docs/PROJECT_REPORT.md b/docs/PROJECT_REPORT.md new file mode 100644 index 0000000..2f4c87c --- /dev/null +++ b/docs/PROJECT_REPORT.md @@ -0,0 +1,626 @@ +# 基于 TMDB 与 AI 辅助的媒体库自动化整理系统设计与实现 + +## ——MediaOrganizer v9.6 技术报告 + +| 项目信息 | 内容 | +| -------- | ------------------------------------------------------ | +| 项目名称 | MediaOrganizer(Jellyfin 媒体库硬链接整理脚本) | +| 版本 | v9.6 | +| 作者 | LetsShareAll | +| 许可证 | MIT | +| 技术栈 | Bash 4.0+ / curl / jq / ffprobe / bashly 1.4 | +| 代码规模 | 22 个模块源文件,约 8 200 行,127 个函数 | +| 测试规模 | 14 个用例文件,123 个测试用例(120 通过 / 3 已知失败) | + +--- + +## 摘要 + +随着数字媒体收藏规模的增长,个人媒体服务器(如 Jellyfin、Plex)面临两个核心挑战:一是媒体文件命名不规范导致刮削器(Metadata Scraper)无法正确识别,二是多份文件冗余存储造成磁盘空间浪费。本项目设计并实现了一个基于 TMDB(The Movie Database)API 与 AI 大语言模型辅助的媒体库自动化整理系统 **MediaOrganizer**,通过硬链接(Hard Link)技术在**同一文件系统内零拷贝**地创建 Jellyfin 标准目录结构,实现媒体库的自动化、规范化整理。 + +系统采用 Bash 脚本语言实现,以 bashly 框架生成单文件分发产物,具备三阶段流水线架构:**识别池**(并发解析文件名并通过 TMDB 搜索识别媒体)、**AI 批处理**(调用 OpenAI 兼容接口纠正搜索词、甄别匹配、学习特典映射)与**硬链接阶段**(创建目录结构与文件链接)。系统设计了全量镜像缓存、增量账本、失败冷却、季数偏移、特典自动归类、用户匹配规则、命名模板等十余项关键机制,并配套 120 余个单元测试用例与 CI 流水线。 + +测试结果表明:系统在 123 个测试用例中通过 120 个(通过率 97.6%),构建产物与源码保持一致性校验通过,Lint 检查零告警。系统已在 Linux / macOS / BusyBox 环境验证可用。 + +**关键词**:媒体库整理;TMDB API;硬链接;AI 辅助识别;Bash;Jellyfin + +--- + +## 1 绪论 + +### 1.1 研究背景与意义 + +家庭媒体服务器用户通常从多种渠道获取影视资源,不同压制组的文件命名习惯差异巨大:有的采用 `Title (Year)` 格式,有的采用 `Show S01E01` 格式,还有的携带压制组标记与画质标签(如 `[VCB-Studio] Show [1080p]`)。Jellyfin 等媒体服务器依赖文件名与目录结构进行元数据刮削,不规范命名会导致识别失败或错误匹配。 + +另一方面,用户常将同一资源保存在多个目录(下载目录、整理目录、共享目录),造成磁盘空间浪费。硬链接技术允许同一文件系统内的多个目录项指向同一 inode,不占用额外数据空间,是解决该问题的理想手段,但要求源与目标位于同一文件系统,且目录结构必须符合媒体服务器的刮削规范。 + +本项目旨在解决上述问题:**自动识别媒体文件 → 判定规范目标路径 → 硬链接整理**,全程无需人工干预(自动化模式),支持 cron 定时增量整理。 + +### 1.2 国内外研究现状 + +在开源社区中,已有若干同类项目: + +| 项目 | 语言 | 特点 | +| --------------------- | ------ | ------------------------------------------------------------------ | +| Auto_Bangumi | Python | 面向动漫资源的自动重命名与整理,AB 解析结果可参与匹配 | +| Bangumi_Auto_Rename | Python | 基于 Bangumi 番组计划的自动重命名 | +| Sonarr / Radarr | C# | 完整的媒体管理套件,支持自动下载与整理,但依赖 .NET 环境且配置复杂 | +| 本系统 MediaOrganizer | Bash | 零运行时依赖、单文件分发、TMDB 识别 + AI 辅助、硬链接零拷贝 | + +现有方案多依赖 Python/.NET 运行时,部署较重;本系统选择纯 Bash 实现,利用 `find`/`jq`/`curl` 等系统工具组合完成全部逻辑,**单文件分发、无编译、无依赖安装**,特别适合 NAS(群晖、威联通、OpenWrt 等嵌入式环境)。 + +### 1.3 主要研究内容 + +1. 媒体文件名的多格式解析与媒体类型判定(电影/剧集/特典/音乐/音乐视频); +2. 基于 TMDB v3 API 的媒体识别与全量镜像缓存设计; +3. 基于 OpenAI 兼容接口的 AI 辅助识别与特典映射学习; +4. 三阶段并发流水线与增量整理(账本 + 冷却)机制设计; +5. 面向 Jellyfin 规范的目录结构生成与硬链接实现; +6. 单元测试体系与 CI/CD 流水线建设。 + +### 1.4 论文组织结构 + +本文共七章。第 1 章为绪论;第 2 章介绍开发技术与工具链;第 3 章进行需求分析;第 4 章阐述系统详细设计;第 5 章说明编码实现要点;第 6 章给出系统测试方案与结果;第 7 章总结全文并展望后续工作。 + +--- + +## 2 开发技术 + +### 2.1 开发语言与运行环境 + +#### 2.1.1 Bash + +系统主体采用 Bash 4.0+ 编写,利用以下特性保证可维护性: + +- `set -euo pipefail` 严格模式:未定义变量、管道失败、命令失败均立即终止,避免静默错误; +- 关联数组(`declare -A`):用于目的映射、结局账本、缓存等键值数据结构; +- `[[ ]]` 条件表达式:比 `[ ]` 更安全(无单词拆分与路径名展开); +- 进程替换 `< <(...)` 与子 shell 隔离:实现并发池与测试隔离。 + +#### 2.1.2 运行时依赖 + +| 依赖 | 版本要求 | 用途 | +| ------- | -------- | --------------------------- | +| Bash | 4.0+ | 脚本运行环境 | +| curl | 任意 | TMDB / AI API 请求 | +| jq | 任意 | JSON 解析与构造 | +| ffprobe | 任意 | 媒体探测(时长/分辨率信号) | + +系统无编译期依赖,产物为单个 Bash 脚本,可复制到任意目标机器直接运行。 + +### 2.2 开发工具链 + +#### 2.2.1 bashly(CLI 生成框架) + +[bashly](https://bashly.dev) 1.4 是 Ruby 编写的命令行工具生成器:开发者以 YAML 声明参数、选项、帮助文本与互斥关系,bashly 生成完整的参数解析代码并包装自定义命令函数。本系统 CLI 定义位于 `src/bashly.yml`,构建时由 `build.sh` 调用 bashly 生成单文件分发脚本 `dist/media_organizer`。 + +```yaml +# src/bashly.yml(节选) +name: media_organizer +help: |- + Jellyfin 媒体库硬链接整理脚本 v9.6 + + 自动化整理媒体库:通过 TMDB API 识别电影与电视剧,并用硬链接创建 + 标准化的目录结构。支持 AI 辅助识别、全量缓存、特典映射、季数偏移、 + Music Videos 音乐视频类目、配置按类目分区。 +version: 9.6 +args: + - name: source_dir + help: 源目录(--list-cache 模式下作为可选关键词) + - name: destination_dir + help: 目的目录 + +flags: + - long: --automated + short: -a + help: 自动化模式(写入日志,跳过无法处理项目) + - long: --dry-run + help: 干运行:不创建链接、不写缓存、不写日志 +``` + +> [!NOTE] +> +> 示例代码节选自项目源码,格式符合 [Google Shell Style Guide](https://google.github.io/styleguide/shellguide.html)(2 空格缩进、`[[ ]]` 测试、`local` 声明)。 + +#### 2.2.2 代码质量工具链 + +| 工具 | 用途 | 集成方式 | +| --------------- | --------------------------------- | -------------------------- | +| shellcheck 0.11 | 静态分析(SC 规则零容忍) | `lint.sh` / CI | +| shfmt 3.13 | 格式化(Google 风格:2 空格缩进) | `shfmt -i 2 -ci` | +| bash -n | 语法检查 | `lint.sh` / CI | +| GitHub Actions | 持续集成 | `.github/workflows/ci.yml` | + +CI 流水线:`lint → 单元测试 → 构建 → 产物一致性校验`,触发条件为 push/PR。 + +### 2.3 许可证合规分析 + +> [!WARNING] +> +> 本报告由自动化工具扫描生成,仅供参考,不构成法律意见。正式分发前请咨询法律顾问。 + +#### 2.3.1 主许可证 + +项目主许可证为 **MIT License**(`LICENSE`,Copyright (c) 2026 Shuery),允许自由使用、修改、分发与商用,仅要求保留版权声明。 + +#### 2.3.2 依赖许可证清单 + +本项目**运行时零第三方依赖**(仅依赖系统自带的 bash/curl/jq/ffprobe),开发期依赖如下: + +| 依赖 | 版本 | 许可证 | 类型 | 兼容性 | +| ---------- | ----- | ----------------------- | ------------------------- | ----------------------- | +| bashly | 1.4.0 | MIT | 开发期(CLI 生成) | ✅ 兼容 | +| completely | 1.4.0 | MIT | 开发期(bashly 传递依赖) | ✅ 兼容 | +| bash | 5.3+ | GPL-3.0 | 运行时(系统自带) | ✅ 兼容(不随项目分发) | +| curl | 任意 | MIT-like(curl 许可证) | 运行时(系统自带) | ✅ 兼容 | +| jq | 任意 | MIT | 运行时(系统自带) | ✅ 兼容 | +| ffprobe | 任意 | LGPL-2.1+(FFmpeg) | 运行时(系统自带) | ✅ 兼容 | + +**兼容性说明**: + +- 全部依赖许可证均为宽松许可证(MIT/LGPL),与 MIT 主许可证**无冲突**; +- bashly/completely 仅用于构建期,生成产物不包含其代码(纯文本生成器),不产生传染性义务; +- GPL-3.0 的 bash 与 LGPL 的 FFmpeg 为系统组件,随操作系统分发,不属于项目分发物; +- `3rd-party/` 目录下的 Auto_Bangumi、Bangumi_Auto_Rename 仅为本地参考仓库(`.gitignore` 已排除),不随项目分发。 + +**合规结论**:✅ 无许可证冲突,项目可以 MIT 许可证对外分发。 + +--- + +## 3 需求分析 + +### 3.1 功能需求 + +#### 3.1.1 核心功能 + +| 编号 | 需求 | 优先级 | 说明 | +| ----- | ----------------- | ------ | -------------------------------------------------- | +| FR-01 | 媒体识别 | P0 | 通过 TMDB API 识别电影、电视剧,支持多种文件名格式 | +| FR-02 | 硬链接整理 | P0 | 同一文件系统内零拷贝创建 Jellyfin 标准结构 | +| FR-03 | 类型智能判定 | P0 | 无明确季集/年份特征时按媒体目录正片数量判定 | +| FR-04 | 特典自动归类 | P1 | NCOP/NCED/Menu/PV/CM 等特典归入 Season 00 | +| FR-05 | 持久化缓存 | P1 | 全量镜像缓存,二次运行零外部请求 | +| FR-06 | 增量整理 | P1 | 已链接账本跳过,cron 重跑只处理新文件 | +| FR-07 | AI 辅助识别 | P1 | 纠正搜索词、匹配甄别、学习特典映射 | +| FR-08 | 季数偏移 | P2 | 处理 TMDB 季数与实际集数不符 | +| FR-09 | 音乐/音乐视频归类 | P2 | CD 音乐归 Music,MV 归 MusicVideos | +| FR-10 | 用户匹配规则 | P2 | 搜索别名与 ID 映射的确定性兜底 | +| FR-11 | 手动纠错 | P2 | `--export-map` 伴生账本 + `--rerun` 重跑 | + +#### 3.1.2 命令行接口(CLI) + +```text +用法:media_organizer.sh [选项] <源目录> <目的目录> + +模式形状: + <源目录> <目的目录> 正常整理(使用缓存) + --update-cache <源目录> <目的目录> 整理并强制重取对应缓存 + --update-cache <源目录> 仅更新缓存,不整理 + --list-cache [关键词] 列出缓存条目 + --rerun <账本文件> 手动纠错重跑 + +选项:-a/--automated、--dry-run、--refresh-cache、--update-cache、 + --list-cache、--export-map、--rerun、--src-dir、--dest-dir、-h、--version + +退出码:0=全部成功;1=运行期错误;2=用法错误;3=部分失败(供 cron 感知) +``` + +#### 3.1.3 配置需求 + +配置优先级链:**CLI > 环境变量 > config.json > 内置默认值**。配置文件采用 JSON 格式(白名单键读取,文件永不执行),包含 TMDB 认证、网络参数、文件类型扩展名、缓存 TTL、AI 参数、命名模板、目录根名等 50+ 配置项。 + +### 3.2 非功能需求 + +| 编号 | 需求 | 指标 | +| ------ | -------- | ------------------------------------------------------ | +| NFR-01 | 性能 | 识别池并发(默认 4 worker);缓存命中零外部请求 | +| NFR-02 | 可靠性 | 请求失败重试(curl 重试 3 次);失败冷却防每轮全量重试 | +| NFR-03 | 幂等性 | 重复运行结果一致;硬链接撞名去重(`- 2`) | +| NFR-04 | 可移植性 | Linux / macOS(BSD stat)/ BusyBox | +| NFR-05 | 可测试性 | 纯函数设计 + 子 shell 隔离测试框架 | +| NFR-06 | 安全性 | 配置文件权限校验(600);JSON 白名单解析不执行代码 | + +### 3.3 用例模型 + +```mermaid +flowchart LR + U["用户/定时任务"] --> C1["整理媒体库
(正常模式)"] + U --> C2["干运行预览"] + U --> C3["仅更新缓存"] + U --> C4["列出缓存条目"] + U --> C5["手动纠错重跑"] + C1 --> S["系统
MediaOrganizer"] + S --> T["TMDB API"] + S --> A["AI API"] + S --> F["文件系统
(硬链接)"] +``` + +--- + +## 4 详细设计 + +### 4.1 系统总体架构 + +系统按功能域组织源码(不设通用 `utils/` 目录),分为五个功能域 + 基础设施: + +```text +src/ +├── bashly.yml # CLI 定义(bashly 读取) +├── root_command.sh # root 命令实现(bashly 包装) +└── lib/ + ├── main.sh # 常量 + 全局变量(最先加载) + ├── log.sh strings.sh # 基础设施:日志与色彩 / 字符串工具 + ├── config/ # 配置域:加载 / 映射 / 命名模板 / 匹配规则 + ├── storage/ # 持久化域:TMDB 缓存 / 账本与冷却 + ├── integrate/ # 外部集成域:TMDB API / AI 批处理 + ├── media/ # 媒体识别域:文件名解析 / 识别 / AI 结果消费 + └── pipeline/ # 流水线域:登记与并发池 / 扫描 / 链接 / 对照表 +``` + +### 4.2 三阶段流水线设计 + +```mermaid +flowchart LR + A["scan_files 扫描源目录"] --> B["第一阶段:识别池
process_video + process_audio
并发 MEDIA_WORKERS worker
parse → identify_movie / identify_tv_show"] + B --> C["第二阶段:run_ai_batch
AI 纠正搜索词 + 匹配甄别
+ 特典映射学习 + 艺术家判定"] + C --> D["第三阶段:link_media
mkdir → 硬链接 → 配套文件"] + D --> E["汇总 + 退出码分级"] +``` + +**并发契约**:bash 子 shell 无法写父进程关联数组,识别池采用**协议行(emit)**机制——每个 worker 子进程产出 `key\tvalue` 协议行,父进程逐行合并登记,这是并发与数据聚合的唯一契约。 + +### 4.3 数据存储设计 + +#### 4.3.1 三类数据分离 + +| 区域 | 内容 | 生命周期 | +| --------------------------- | --------------------------------------------------------------- | ------------------------------ | +| `mo_config/` | 用户配置类文件(config.json、特典映射、词表、季偏移、匹配规则) | 用户编辑 + AI 学习写回 | +| `mo_cache/tmdb/` | TMDB API 响应缓存(镜像 API 路径) | 可再生,`--refresh-cache` 清除 | +| `mo_cache/media_organizer/` | 运行状态(已链接账本、失败冷却) | 增量累积 | + +```text +mo_cache/ +├── tmdb/ # TMDB API 响应缓存 +│ ├── search/movie/.json # 搜索缓存(包裹格式) +│ ├── search/tv/.json +│ ├── tv/..json # 剧集详情(语言段随 TMDB_LANG) +│ ├── tv//season/..json # 每季数据(含 season 0 特典季) +│ └── movie/..json # 电影详情 +└── media_organizer/ + ├── linked.json # 已链接账本(增量标记) + └── fail_cooldown.json # 失败冷却 +``` + +**缓存关键机制**: + +1. **镜像路径**:缓存目录结构镜像 TMDB API 端点,二次运行零外部请求; +2. **并发去重**:`flock` 跨进程互斥 + 锁内双检(同一查询只发一次请求); +3. **空哨兵**:空结果写 `empty:true` 哨兵(3 天短 TTL),新片出现后自动重新搜索; +4. **TTL 分级**:正常数据 30 天 / 空哨兵 3 天(按 mtime 判定); +5. **原子写**:临时文件 + rename,避免半截 JSON。 + +#### 4.3.2 结局账本与冷却 + +`MEDIA_OUTCOME_MAP` 是每条目结局类别的唯一账本(identified / fallback / skip_type / skip_unidentified / request_failed / pending_ai),运行级汇总报告的唯一数据源。失败条目登记冷却(默认 24h),冷却期内跳过、过期自动重试——避免 cron 每轮全量重试同一批失败项。 + +### 4.4 媒体识别算法设计 + +#### 4.4.1 文件类型判定(顺序匹配) + +```mermaid +flowchart TD + A["parse_media_filename"] --> B{"Title (Year)?
电影"} + B -- 否 --> C{"S##E## / #x## ?
剧集"} + C -- 否 --> D{"特典标记?
方括号词表 / 父目录"} + D -- 否 --> E{"Season 关键词?"} + E -- 否 --> F{"方括号 [数字]?"} + F -- 否 --> G["回退:媒体目录正片数量
1→movie / ≥2→tv / 0→unknown(AI)"] +``` + +特典识别优先于季份判定;特典词在方括号标记内匹配(避免误判标题),也支持父目录判断(`SPs/`、`CDs/`、`Bonus/`)。v9.6 起新增 Music Videos 判定(目录信号精确匹配 + `[MV]` 文件名信号,"歌手 - 歌名"模式消解双命中歧义)。 + +#### 4.4.2 TMDB 识别与季数偏移 + +识别一个剧集文件的 API 调用链:`/search/tv` → `/tv/{id}` → `/tv/{id}/season/0`(特典季)→ `/tv/{id}/season/N`(集名)。季数偏移算法: + +设实际集号为 $E$、季号为 $S$,TMDB 第一季集数为 $S_1$: + +- **自动偏移**(TMDB 有第一季数据时):$E' = E + S_1,\ S' = 1$ +- **手动偏移**(`season_offsets.json` 配置,偏移值 $O$):$E' = E + O,\ S' = 1$ + +> [!TIP] +> +> 搜索失败时自动重试链:**zh-CN → en-US → 规则别名 → 目录名 → MAL → 后缀二次剥离**。主搜索(zh)请求失败短路返回,后续层失败忽略继续。 + +#### 4.4.3 AI 辅助识别 + +AI 批处理四类待办:搜索词纠正/类别判断、特典映射学习、艺术家判定、匹配甄别。每批 `AI_BATCH_SIZE`(默认 50)条,`AI_MAX_CALLS`(默认 10)为批次上限。AI 响应解析走四级容错链(直接 → 剥围栏 → 剥离思考链标记 → 最外层 `{}` 块),推理模型的思考输出不会导致整批失效。 + +AI 学到的特典映射持久化到 `special_maps.json`(写回前交叉验证:产物必须是候选列表成员,防幻觉污染)。 + +### 4.5 输出目录结构设计 + +```text +/media +├── Movies/ +│ └── Inception (2010)/ +│ └── Inception (2010).mkv +├── Shows/ +│ ├── Breaking Bad (2008)/ +│ │ └── Season 01/ +│ │ ├── S01E01 - Pilot.mkv +│ │ └── S01E01 - Pilot.srt +│ └── Some Anime (2020)/ +│ └── Season 00/ +│ ├── S00E01 - 迷你动画「猫猫的独语」第1话:白粉.mkv +│ └── S00CM01 - CM01.mkv +├── Music/ +│ └── 平井大/幸せのレシピ/01. 幸せのレシピ.flac +└── MusicVideos/ + └── 周杰伦/ + ├── 晴天.mkv + └── 七里香.mp4 +``` + +命名遵循 Jellyfin 官方规范:目录名与文件名同名(电影)、`Season NN` 补零、`SxxExx - 集名`、特典 `S00E{编号}`(TMDB 匹配)或 `S00{类型}{编号}`(未匹配)。文件名不含剧集名——Jellyfin 通过父目录识别剧集。 + +--- + +## 5 编码与实现 + +### 5.1 工程规范 + +- **Google Shell Style Guide**:2 空格缩进、`[[ ]]` 测试、`local` 声明、变量引号包裹; +- **shellcheck 零容忍**:跨文件共享全局变量的 SC2034/SC2004 误报在文件头部集中 disable 并注明原因; +- **定义顺序**:日志配置最先 → 全局常量 → 类型/函数 → main 入口; +- **版本单一来源**:`src/lib/main.sh` 的 `SCRIPT_VERSION` 是唯一权威版本号,构建时注入 `bashly.yml`。 + +### 5.2 核心实现:TMDB API 统一缓存封装 + +所有 TMDB 请求必经 `tmdb_api` 函数:先查缓存(命中直接返回),未命中加锁请求并落盘。以下为源码节选(已按 Google 风格格式化): + +```bash +tmdb_api() { + local endpoint="$1" + shift + local key path is_search + key=$(cache_key "$@") + path=$(cache_path "$endpoint" "$key") + is_search=0 + [[ "$endpoint" == /search/* ]] && is_search=1 + + # --update-cache 修饰(整理时强制重取):跳过缓存读与空哨兵,直接请求并覆盖缓存 + local cached + if [[ "${UPDATE_CACHE:-false}" != "true" ]] && cached=$(cache_get "$path" "$is_search"); then + echo "$cached" + return 0 + fi + # 新鲜空哨兵:上次已确认空结果,CACHE_EMPTY_TTL_DAYS 内跳过请求 + if [[ "${UPDATE_CACHE:-false}" != "true" ]] && cache_empty_fresh "$path" "$is_search"; then + [[ "$DEBUG_LEVEL" -ge 2 ]] && _log 调试 "缓存为空哨兵(新鲜),跳过请求: ${endpoint}" + return 1 + fi + + # 并发去重(识别池多 worker 可能同时 miss 同一查询): + # flock 跨进程互斥(内核锁,进程退出自动释放,无残留)→ 锁内双检缓存。 + local lockfile + lockfile="/tmp/mo_lock_$(cache_key_hash "$path")" + local locked=false + if [[ "${UPDATE_CACHE:-false}" != "true" ]]; then + exec 9>"$lockfile" + flock 9 + locked=true + # 双检:等待者可能在锁期间已写入缓存 + if cached=$(cache_get "$path" "$is_search"); then + exec 9>&- + echo "$cached" + return 0 + fi + if cache_empty_fresh "$path" "$is_search"; then + exec 9>&- + return 1 + fi + fi + + local curl_args=( + "--get" "-s" + "--connect-timeout" "${TMDB_CURL_CONNECT_TIMEOUT}" + "--max-time" "${TMDB_CURL_MAX_TIME}" "-fS" + ) + # ...(curl 请求、成功后 cache_put 落盘) +} +``` + +### 5.3 核心实现:登记层(register 系列) + +登记层收拢一切条目写入,一次调用原子完成"目的映射 + 结局账本 + 计数器",结构上不可能出现只写一半的不一致: + +```bash +register_identified() { + local key="$1" subdir="$2" filename="$3" + MEDIA_DESTINATION_MAP["$key"]="${subdir}|${filename}" + MEDIA_OUTCOME_MAP["$key"]="identified" +} + +# 登记回退命名条目(AI 尽力后仍失败;降级成功,可链接)。 +register_fallback() { + local key="$1" subdir="$2" filename="$3" + MEDIA_DESTINATION_MAP["$key"]="${subdir}|${filename}" + MEDIA_OUTCOME_MAP["$key"]="fallback" +} + +# 登记待 AI 搜索纠正条目(不进入目的映射)。 +register_pending() { + local key="$1" ai_data="$2" + PENDING_AI_SEARCH["$key"]="$ai_data" + PENDING_SEARCH_COUNT=$((PENDING_SEARCH_COUNT + 1)) + MEDIA_OUTCOME_MAP["$key"]="pending_ai" +} +``` + +### 5.4 核心实现:命名模板渲染 + +命名格式化采用逐 token 扫描的模板渲染器(不用 `${var//pat/repl}` 全局替换——替换串的 `&` 会被当作匹配整体,文件名含 `&` 时损坏): + +```bash +render_naming_template() { + # 占位符语法:{name} 值插入;{name:NN} 数字补零;{?name:text} 条件段 + # (name 非空才渲染 text,text 内可含其他占位符)。 + local template="$1" + shift + # ...(逐 token 扫描渲染) +} +``` + +### 5.5 构建与分发 + +`build.sh` 实现"源码 → 单文件产物"的可复现构建: + +```bash +# 在临时目录生成(注入版本号,不触碰工作区源码) +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +cp -r src "$work/src" +sed -i "s/^version: .*/version: $VERSION/" "$work/src/bashly.yml" +(cd "$work" && "$BASHLY_CMD" generate --quiet >/dev/null) +generated="$work/media_organizer" + +# 语法检查 +if ! bash -n "$generated"; then + echo "错误:构建产物语法检查未通过" >&2 + exit 1 +fi +``` + +构建后执行**产物一致性校验**(`./build.sh --check`,CI 集成):比对生成产物与 `dist/media_organizer`,不一致即失败。 + +--- + +## 6 系统测试 + +### 6.1 测试方案 + +系统采用零依赖轻量测试框架(纯 bash): + +- 用例文件位于 `tests/cases/`(按功能域分组),每个用例文件在**独立子 shell** 中运行(全局状态自动隔离、互不污染); +- 断言库 `tests/lib/assert.sh`(`assert_eq` / `assert_contains` / `assert_success` / `assert_failure` 等); +- 加载 `src/main.sh` + `src/lib/*.sh`(跳过 root_command.sh 顶层代码); +- 测试中 `_log` 覆盖为 no-op 保持输出干净。 + +```bash +./tests/run.sh # 运行全部用例 +./tests/run.sh cache # 仅运行名字含 cache 的用例文件 +./tests/run.sh -v # 详细模式 +``` + +### 6.2 测试覆盖 + +| 功能域 | 用例文件 | 覆盖点 | +| -------- | ------------------------------------------------ | -------------------------------------- | +| 基础设施 | strings、log | 字符串工具、日志 | +| 配置域 | config(categories/partition/rules/match_rules) | 特典类别、类目分区、命名模板、匹配规则 | +| 存储域 | storage(cache/ledger) | TMDB 缓存、账本与冷却 | +| 集成域 | integrate/ai | AI 批处理 | +| 媒体域 | media(filename/identify/ai_resolve/musicvideo) | 文件名解析、识别、AI 消费、MV 判定 | +| 流水线域 | pipeline(link/registry) | 硬链接、登记 | + +### 6.3 测试结果 + +| 指标 | 数值 | +| ---------------------------- | ------------ | +| 测试用例总数 | 123 | +| 通过 | 120(97.6%) | +| 已知失败 | 3 | +| Lint(bash -n + shellcheck) | 0 问题 | +| 构建产物一致性 | 通过 | + +**已知失败用例**(3 个,均为既有逻辑问题,与格式化无关): + +| 用例 | 现象 | +| ------------------------------------------------- | -------------------------- | +| partition/test_legacy_skip_dirs_empty_falls_back | 遗留跳过目录空回退行为不符 | +| partition/test_partitioned_season_offset | 分区形态季偏移行为不符 | +| musicvideo/test_musicvideo_dual_hit_keeps_special | 双命中歧义保持特典路径不符 | + +### 6.4 CI 流水线 + +```yaml +# .github/workflows/ci.yml(节选) +jobs: + quality: + name: lint + test + build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get install -y shellcheck + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + - run: gem install bashly + - run: ./lint.sh # bash -n + shellcheck + - run: ./tests/run.sh # 单元测试 + - run: ./build.sh # 构建 + - run: ./build.sh --check # 产物一致性 +``` + +--- + +## 7 总结与展望 + +### 7.1 工作总结 + +本文设计并实现了一个基于 TMDB 与 AI 辅助的媒体库自动化整理系统。主要成果: + +1. **架构层面**:三阶段流水线 + 五功能域模块化组织 + bashly 单文件分发,兼顾可维护性与部署便捷性; +2. **识别层面**:多格式文件名解析、媒体目录正片数量判定、搜索重试链、候选结构评分、后缀季模糊匹配等组合策略,识别准确率显著高于单一策略; +3. **工程层面**:全量镜像缓存(二次运行零请求)、增量账本与失败冷却(cron 友好)、AI 学习写回(自进化)、Google 风格规范 + shellcheck 零容忍 + CI 闭环; +4. **质量层面**:120+ 单元测试用例、构建产物一致性校验、决策树文档(README 13 章 + 22 份 ADR 记录架构决策)。 + +### 7.2 不足与展望 + +| 方向 | 现状 | 展望 | +| ---------- | ---------------------- | ---------------------------------- | +| 测试 | 3 个已知失败 | 修复 partition/musicvideo 边界逻辑 | +| AI 能力 | 默认 DeepSeek-V4-Flash | 支持多模型路由与本地模型(Ollama) | +| 音乐元数据 | 依赖目录名与 ID3 标签 | 引入 MusicBrainz 识别 | +| 覆盖范围 | 单机单库 | 多库配置、分布式缓存 | +| 界面 | CLI | 可选 Web 管理界面 | +| 语言 | Bash | 保持零依赖,必要时核心逻辑迁移 | + +--- + +## 参考文献 + +1. Jellyfin Project. _Jellyfin Documentation — Media Management_[EB/OL]. https://jellyfin.org/docs/general/server/libraries/ +2. TMDB. _The Movie Database API v3 Documentation_[EB/OL]. https://developer.themoviedb.org/docs +3. Google. _Google Shell Style Guide_[EB/OL]. https://google.github.io/styleguide/shellguide.html +4. bashly. _Bash Command Line Tool Generator_[EB/OL]. https://bashly.dev +5. ShellCheck Project. _ShellCheck — Shell Script Analysis Tool_[EB/OL]. https://github.com/koalaman/shellcheck +6. mvdan. _shfmt — Shell Formatter_[EB/OL]. https://github.com/mvdan/sh +7. `Auto_Bangumi`. _基于 Mikan Project 的全自动追番整理下载工具_[EB/OL]. https://github.com/EstrellaXD/Auto_Bangumi +8. OpenAI. _OpenAI API Reference_[EB/OL]. https://platform.openai.com/docs/api-reference +9. jq. _jq Manual (development version)_[EB/OL]. https://jqlang.github.io/jq/ +10. Free Software Foundation. _GNU Bash Manual_[EB/OL]. https://www.gnu.org/software/bash/manual/ + +--- + +## 致谢 + +感谢开源社区提供的 TMDB API、Jellyfin、bashly、ShellCheck 等优秀工具与平台;感谢 Auto_Bangumi 与 Bangumi_Auto_Rename 项目在媒体解析算法上的启发(候选结构评分、公共子串剥离、纠错学习等机制借鉴其思路);感谢所有在测试与反馈中提供帮助的社区用户。 + +--- + +## 附录 A 死链检查摘要 + +- 检查范围:`PROJECT_REPORT.md` 全部外部链接; +- 结果:全部可达(详见 `link_check_report_*.md` 或本报告生成时的检查记录)。 + +## 附录 B 许可证合规摘要 + +- 主许可证:MIT;全部依赖(bashly/completely MIT、系统组件 GPL-3.0/LGPL)兼容,无冲突; +- 详细分析见第 2.3 节与 `license_check_report_*.md`。 + +--- + +_本报告基于项目源码自动生成,代码示例均经 Google Shell Style 格式化。生成日期:2026-08-14。_ diff --git a/docs/adr/0001-update-cache-arity-and-mode-contract.md b/docs/adr/0001-update-cache-arity-and-mode-contract.md new file mode 100644 index 0000000..9e1d22f --- /dev/null +++ b/docs/adr/0001-update-cache-arity-and-mode-contract.md @@ -0,0 +1,7 @@ +# update-cache 的 arity 语义与入口模式契约 + +原 `--update-cache` 是独立模式:扫源目录强制重取全部唯一查询后立即退出,从不整理文件;而预期语义是"整理媒体文件时更新对应的缓存"。决定:`--update-cache` 降为修饰标志,行为形状由目录个数推导——只给 1 个目录(位置参数或 `--src-dir`)=仅刷缓存后退出(保留原 `update_cache()` 行为);给 2 个目录=整理流水线内对处理的条目强制重取、不提前退出。 + +模式契约同步收紧:`list` / `organize` / `cache-only` 三种形状一次调用只取其一,冲突(如 `--update-cache --list-cache`)硬报错并附 usage,废弃原先"看似生效实则静默忽略"的分发顺序。用法错误(未知选项、参数个数、模式冲突)统一 `exit 2`(GNU 惯例),运行期错误保持 `exit 1`。目录只能走一个通道:2 个位置参数或 `--src-dir` + `--dest-dir`,不可混用;每种形状声明所需目录数:`organize`=2、`cache-only`=1、`list`=0,多传少传均报错。 + +**Considered Options**: 保持独立标志 + main() 内隐式优先级(原状)——冲突静默,用户无法感知标志未生效。混合通道——无真实用例,徒增解析复杂度。 diff --git a/docs/adr/0002-dry-run-zero-side-effects.md b/docs/adr/0002-dry-run-zero-side-effects.md new file mode 100644 index 0000000..61eb17d --- /dev/null +++ b/docs/adr/0002-dry-run-zero-side-effects.md @@ -0,0 +1,7 @@ +# dry-run 零持久化副作用契约 + +原 `--dry-run` 只拦截链接创建,缓存照写、日志照写——跑一次 dry-run 会悄悄填充缓存。决定:dry-run = 本次调用不产生任何持久化副作用——不建链接、不写缓存、不写日志;TMDB 网络请求照常执行但结果不落盘,AI 批处理照常运行(费用视为运行成本而非持久化副作用)。 + +由此派生的合法性边界:`dry-run` × `cache-only`(1 目录)报错——dry-run 拦掉了 cache-only 的全部意义,且无链接可预览;`dry-run` × `update+organize`(2 目录)合法——强制重取照跑、缓存不写。这是行为变更:dry-run 不再暖缓存,可反复执行且零残留。 + +**Considered Options**: 只拦链接(原状)——预览最接近真实运行但产生隐藏副作用;全只读连网络都不调——最纯净但预览残缺,"待定"条目无法给出整理结果。 diff --git a/docs/adr/0003-mo-env-whitelist-loading.md b/docs/adr/0003-mo-env-whitelist-loading.md new file mode 100644 index 0000000..0a17bb7 --- /dev/null +++ b/docs/adr/0003-mo-env-whitelist-loading.md @@ -0,0 +1,7 @@ +# mo_env 白名单键加载(配置即数据) + +帮助文档宣称"所有选项均可通过环境变量或 mo_env 文件配置",实现却只从 mo_env 提取 8 个键(认证/AI),其余键仅认环境变量——文档与实现不符。决定:全量加载——从 `MO_ENV_TEMPLATE` 提取键名集合作为白名单,对每个键复用 `parse_config_key` 式提取,注入 `${VAR:-mo_env:-默认}` 链,形成 **CLI > 环境变量 > mo_env > 默认值** 四层优先级。 + +mo_env 永不 source、内容永不执行——配置文件是数据而非代码,安全模型只依赖"文件所有者可信"(沿用现有 600 权限 + `check_secure_file` 所有者校验),不依赖"文件内容无害"。白名单外的自定义键不支持(无此需求)。 + +**Considered Options**: `source` 整个文件——一行到位,但使配置文件成为可执行文本,安全模型退化;只修文档列举 8 键——宣称的优先级模型本是合理设计,是实现欠账而非文档夸大。 diff --git a/docs/adr/0004-pipeline-execution-contract.md b/docs/adr/0004-pipeline-execution-contract.md new file mode 100644 index 0000000..ba55a36 --- /dev/null +++ b/docs/adr/0004-pipeline-execution-contract.md @@ -0,0 +1,7 @@ +# 流水线执行契约:识别池、结局账本与错误分类 + +主流水线(scan → 识别 → AI → link)重构为三个新契约:**识别池**(`MEDIA_WORKERS` 默认 4)——`process_video` 与 `process_audio` 共用同一 worker 池,子进程产出 `key\tvalue` 行由父进程合并(bash 子 shell 无法写父关联数组,这是唯一并行契约);**结局账本**——`MEDIA_OUTCOME_MAP` 记录每条目结局(identified/fallback/skip_type/skip_unidentified/request_failed/pending_ai),跳过与失败条目不进入目的映射,`register_*` 层原子完成"映射+账本+计数器";**错误分类**——区分"查询无结果"(可恢复,跳过继续)与"请求失败"(计入失败,连续 ≥5 次或失败率 ≥50% 时终止),网络请求改为递增重试(`2^n` 封顶 16s,`TMDB_CURL_RETRY`/`AI_CURL_RETRY` 分别控制次数,共享实现)。 + +运行级汇总在结尾列出各分类数量与文件清单(每类前 10 条,automated 全量入日志文件);退出码分级:0=全部成功、1=运行期错误、2=用法错误、3=部分失败(skip>0)。回退命名仅限"AI 尽力后仍失败"(无 AI → 显式 `skip_unidentified`),其条目在汇总中记为"降级成功"。 + +**Considered Options**: 后台 job 各自写回(无法共享关联数组,需临时文件合并——正是所选方案的机制);完整 per-file 状态机(bash 关联数组收益有限);全失败即终止(网络抖动即死);无 AI 时也回退命名(批量伪命名污染库且被幂等锁死,跳过可逆——下次运行自动重试)。 diff --git a/docs/adr/0005-destination-structure-compliance.md b/docs/adr/0005-destination-structure-compliance.md new file mode 100644 index 0000000..85b0304 --- /dev/null +++ b/docs/adr/0005-destination-structure-compliance.md @@ -0,0 +1,11 @@ +# 目的目录规范:Jellyfin 官方合规结构与本地化 + +目的目录结构按 Jellyfin 官方文档对齐:`Movies/{title} ({year})/` 夹内同名文件;`Shows/{title} ({year})/Season NN/`(补零、不缩写);`SxxExx - 集名`;特典 `Season 00` 用描述性命名(非匹配 S00Exy);`Music/{artist}/{album}/`(一夹一专辑);`MusicVideos/{artist}/{title}`(根目录无空格,识别信号待样本驱动)。`Season NN` 与 `SxxExx` 是 Jellyfin 解析格式,**不可本地化**;根目录与 Unknown 等语义占位可本地化——新配置键 `FOLDER_MOVIES`/`FOLDER_SHOWS`/`FOLDER_MUSIC`/`FOLDER_MUSICVIDEOS`/`FOLDER_UNKNOWN`,**默认跟随系统语言**(locale 含 zh → 中文名)。 + +三个修复:① 撞名不再替换(后者胜会静默丢链接),按官方多版本格式去重(`{文件夹名} - 2.ext`,前缀与文件夹名逐字符一致);② 同源旧链接(回退 → 正确识别的迁移场景)在目标不存在时按 `find -inum` 清理,幂等路径零开销;③ 年份占位统一——无年份省略括号段(识别与回退共用),消除 `(Unknown)` 与空括号 `()`。 + +音乐条目新增:`AUDIO_EXTS` 补 `mka`;伴随文件优先级 **视频 > 音频**(同名视频存在 → 该音频为伴随音轨,排除独立处理);独立音频跟随歌词(`lrc/elrc/txt` 同名)与专辑级封面(链接既有文件,不提取);艺术家归类链(专辑艺术家 → 单艺术家 → AI → ` & ` 拼接 → 目录名 → Unknown);专辑名同构走元数据链。封面从内嵌标签提取等操作**不做**——脚本仅整理,不增删文件。 + +**Considered Options**: 撞名保留替换(静默丢文件,且与官方多版本机制冲突);回退条目引入跨运行进度持久化(Q5 已定缓存即断点,不做);根目录硬编码英文(违背"使用者的语言");Music Videos 识别本次实现(来源目录非标准结构,识别信号未定,待样本驱动)。 + +**补充(识别与匹配轮)**:多集单文件(`S01E01-E02`)目标命名规则定为 `{标题} - S01E01-E02 - {首集名} - {末集名}.{ext}`(集名段取首尾两集,Jellyfin 据区间识别多集条目)。挂起项见 CONTEXT.md 已知缺口 ④⑤⑥(电影/电视判断链、后缀季剥离机制、AI 使用问题)。 diff --git a/docs/adr/0006-linked-ledger-incremental-skip.md b/docs/adr/0006-linked-ledger-incremental-skip.md new file mode 100644 index 0000000..6c59368 --- /dev/null +++ b/docs/adr/0006-linked-ledger-incremental-skip.md @@ -0,0 +1,9 @@ +# 已链接账本(linked.json):增量标记与失效语义 + +cron 全量重跑时,已整理文件仍会走完整的 parse → TMDB 搜索 →(可能)AI → 链接流水线,虽然链接阶段 inode 幂等兜底正确性,但识别阶段的缓存命中与 ffprobe 仍非零开销。决定:引入 `$CACHE_DIR/media_organizer/linked.json` 已链接账本(`{src: {dest, inode, linked_at}}`),识别池入口先查账本,命中直接跳过(结局 `already_linked`)。 + +**失效语义(安全关键)**:跳过成立需同时满足——源文件存在、目标文件存在、源与目标同 inode。任一不满足(用户清空目标库 → 目标消失 → 重新识别+链接;源被替换 → inode 变化 → 重新识别+撞名处理)即自动失效。绝不依据"记录存在"单独跳过,杜绝"目标已删但记录还在 → 永不重建"的静默丢失。 + +**进程模型**:识别池 worker 是 fork 子进程,账本在父进程 `scan_files` 后加载一次(fork 复制内存副本);`ledger_mark_linked` 在链接成功处只更新父进程内存,`ledger_flush` 在运行末尾统一落盘(原子写 + 惰性清理源已消失条目)。中途异常退出不落盘——链接本身 inode 幂等,最坏情况是下次运行重复链接判定,不产生错误。干运行零持久化副作用(不落盘,且不启用跳过——干运行展示全貌)。 + +**Considered Options**: 每文件写账本(链接阶段逐条原子写)——O(n²) jq 读全文件,大库慢;按 inode 现场 find 校验(同源迁移清理已用)——无需账本但每次全目标目录扫描,开销更大;marker 文件(`ab:renamed` 式)——污染媒体目录且无法表达目标位置。 diff --git a/docs/adr/0006-user-custom-rules.md b/docs/adr/0006-user-custom-rules.md new file mode 100644 index 0000000..5c05390 --- /dev/null +++ b/docs/adr/0006-user-custom-rules.md @@ -0,0 +1,31 @@ +# 用户自定义规则:命名模板与匹配规则 + +用户自定义能力增强:**命名模板**(`NAMING_*` 配置键)把命名格式化从硬编码外置为模板;**匹配规则**(`mo_config/match_rules.json`)提供确定性匹配——搜索别名与 ID 映射。两者共同把"用户的定制意图"从"改代码/依赖 AI 运气"变为"编辑配置文件"。 + +## 命名模板(NAMING_*) + +六个配置键对应六处硬编码命名:`NAMING_MOVIE`(电影目录/文件名)、`NAMING_SHOW`(剧集目录名)、`NAMING_SEASON`(季目录名)、`NAMING_EPISODE`(剧集文件名)、`NAMING_SPECIAL`(未匹配特典文件名)、`NAMING_MUSIC`(音乐目录结构)。占位符语法三级:`{name}` 值插入、`{name:NN}` 数字补零、`{?name:text}` 条件段(name 非空才渲染 text,正文可含其他占位符,不支持嵌套条件段)。 + +关键实现决策: + +1. **逐 token 扫描渲染**:`render_naming_template` 按 `{` 分界逐个解析占位符拼接输出,**不用** `${var//pat/repl}` 全局替换——bash 替换串中的 `&` 会被当作"匹配整体"展开(`MYTH & ROID` 这类乐队名会被损坏),逐 token 拼接天然免疫。 +2. **条件段用花括号深度配对**:`{?year: ({year})}` 的正文含嵌套占位符,正文的结束 `}` 与条件段的结束 `}` 需区分——`match_conditional_close` 按 `{}` 深度计数找配对右花括号,正文取出后递归渲染(深度上限 10)。 +3. **默认值集中**:`naming_template_default` 是六个默认模板的唯一定义点(config.sh 加载与 `render_naming` 兜底共用),保证"未配置"与"配置默认值"行为一致;默认模板与 v9.4 硬编码输出逐字节一致,升级零行为变化。 +4. **加载期校验**:`validate_naming_templates` 提取全部占位符名(含条件段正文内)与允许集合比对,未知名打印警告(渲染为空)——拼写错误不静默。 +5. **值内花括号剔除**:值中的 `{`/`}` 在赋值时剔除(防破坏模板解析);渲染结果整体 sanitize(电影/剧集目标要求目录名=文件名,模板不能引入路径分隔/非法字符)。 + +**考虑过的方案**:模板用 printf 风格 `%s` 占位(可读性差、无法表达条件段);条件段用单独配置键(如 `MOVIE_YEAR_TAG`,组合爆炸);正文禁止嵌套占位符(默认模板 `{?year: ({year})}` 即需要嵌套,否决);`sed` 正则替换(`&` 与转义问题同全局替换,且多字节文件名风险)。 + +## 匹配规则(match_rules.json) + +用户手工维护的 JSON:`search_aliases`(搜索别名)与 `id_maps`(ID 映射,movie/tv 分表)。键 = 文件名清洗后的标题,`rule_key` 归一化(小写 + 空白折叠)后与 parse 产出的标题对齐。文件缺失时在用户确认下用 `MATCH_RULES_TEMPLATE` 常量创建空规则(自动化模式跳过创建、空规则继续);非法 JSON → 空规则不阻断。 + +优先级:**ID 映射(跳过搜索)> 常规搜索链(zh → en → 别名 → 目录名 → MAL)→ AI**。别名只在常规搜索无结果时介入;ID 映射先于一切搜索执行。规则全部确定性命中即不走 AI(省轮次、结果可预期)。 + +**考虑过的方案**:规则支持正则/glob 匹配(bash 正则性能与转义风险高,且命名清洗后的精确标题已覆盖主要场景);别名直接替换标题无条件生效(会绕过 TMDB 原语言搜索的命中,别名定位为"兜底"而非"替换");AI 学习写回 match_rules(用户显式维护是权威信号,与纠错学习同构——自动识别结果不学习)。 + +## 配套 + +- 匹配规则插入 `identify_movie`/`identify_tv_show` 的搜索链(ID 映射在搜索前,别名在 zh→en 之后、目录名兜底之前)。 +- 命名模板接入 `build_movie_dest`/`build_tv_dest`/`special_s00_dest`/`fallback_naming`/音乐目标(识别路径与回退命名共用同一渲染,保证两套命名规则一致)。 +- 新模块 `src/lib/rules.sh`(加载序在 maps.sh 之后);测试 `tests/cases/rules.sh`(渲染器 11 例)与 `tests/cases/match_rules.sh`(加载/查询 7 例)。 diff --git a/docs/adr/0007-category-partitions-musicvideos.md b/docs/adr/0007-category-partitions-musicvideos.md new file mode 100644 index 0000000..d935539 --- /dev/null +++ b/docs/adr/0007-category-partitions-musicvideos.md @@ -0,0 +1,41 @@ +# 配置按类目分区、特典类别外置与 Music Videos 类目 + +用户需求:四个配置文件(special_maps / special_keywords / skip_directories / season_offsets)"不仅需要支持当前的,还需要支持其它所有类目";特典类别判定表完全外置;并实现 Music Videos(音乐视频)类目。三项合为 v9.6。 + +## 配置按类目分区 + +四个文件支持**两种形态**:全局单表(旧版,行为不变)与类目分区(`{"tv": {...}, "musicvideo": [...]}`)。分区判定 `is_category_partitioned`:顶层全部键 ∈ `MO_CATEGORY_KEYS`(movie/tv/music/musicvideo/video/audio/default/global)→ 分区形态;混合形态警告并按全局处理(避免类目键被当数据键)。 + +查询链统一:**类目/流程分区 → default 分区 → 全局表 → 内置默认**。关键决策: + +1. **分区键语义因文件而异**:特典映射/词表用类目键(tv/musicvideo);跳过目录用**流程键**(video/music)——目录语义判断发生在类目确定之前(正是用词表辅助判定),无法按条目类目取值;`find_show_*`/`count_main_videos`/`detect_special` 查 video 流程分区。 +2. **skip_directories 分区 = 完整语义**:分区形态**不叠加内置默认**——否则 music 流程会命中 video 侧内置词(如 "sps"),分区失去意义;通用词放 default 分区。旧全局形态保持"数组内容优先、空数组回退内置"。 +3. **special_keywords 分区 = 叠加语义**:tv 分区词 + 内置默认兜底(词表是积累型,AI 持续学习)。 +4. **AI 写回自动适配**:`learn_skip_dirs`/`learn_special_keywords` 检测分区形态,写 `video`/`tv` 分区(jq `.video[...]`/`.tv[...]` 路径),全局形态写顶层;内存表同步更新分区表。 +5. **分区引用展开**:special_maps 分区内字符串引用同分区优先,其次全局表(`resolve_special_value_cat`)。 + +**考虑过的方案**:分区键用前缀(`category.tv` 等,丑且破坏旧格式兼容);分区判定用"至少一个类目键"(混合形态静默错位);skip_directories 保持全局 + 仅扩展词表(无法按流程区分,music 流程误命中 video 词)。 + +## 特典类别判定表外置 + +`special_category_tag` 原硬编码 24 项类别(Menu/CM/PV/...)→ 外置 `mo_config/special_categories.json`:**数组保序 = 优先级**(`[{"match": "nced", "tag": "NCED"}]`)。查询链:用户表(按序子串)→ 内置默认表(保留在 `special_category_tag` 作最后兜底)→ "Special"。缺失时交互创建(`SPECIAL_CATEGORIES_TEMPLATE` 常量),自动化跳过(内置兜底)。 + +**考虑过的方案**:对象形态(JSON 对象无顺序,判定优先级丢失——"mini anime" 需先于 "anime");并入 special_keywords(语义不同:词表判定"是否特典",类别表判定"S00 标签",且词表被 AI 写回,类别表是用户静态维护)。 + +## Music Videos 类目 + +识别信号 = musicvideo 判定词(special_keywords 的 `musicvideo` 分区 → default → 内置 `MUSICVIDEO_WORDS` 13 词:mv/music video/live/concert/performance/演唱会/音乐视频/音乐录影带/现场 等)。**不回退 tv 特典词表**("sp" 子串命中 "SPs" 目录 = 稳定误判,正是开发中发现的 flakiness 根因之一)。 + +1. **目录信号(强信号)**:目录名**精确匹配**(归一化去空格小写整名相等)musicvideo 词 → 整目录音乐视频(即使文件名含季集标记)。精确匹配避免 "Muv-Luv"/"tmp.xxx" 含词误判——开发中实测 mktemp 临时目录名(tmp.XXXXXXXX)约 3% 概率含 cm/sp/pv/mv 两字符词导致测试偶发失败,精确匹配彻底根除。 +2. **文件名信号**:方括号标记子串命中 musicvideo 词。双命中歧义(标记同时在特典词表,如 [MV]/[PV])用 "**歌手 - 歌名**" 模式消解:文件名含 ` - ` → 音乐视频(歌手分隔是音乐视频命名典型结构);不含 → 保持特典路径("动画名 [MV]" 剧集 MV 特典不被误判)。两侧词表可配置完全控制。 +3. **命名**:`MusicVideos/{artist}/{title}.{ext}`(`NAMING_MUSICVIDEO` 模板,Jellyfin 官方结构)。artist 链:元数据 ALBUMARTIST → ARTIST → 父目录名解析("歌手 - 歌名"取首段;无分隔符整名;源根直属不解析)→ FOLDER_UNKNOWN。 +4. **不进 AI**:本地规则无网络请求,识别失败(artist 兜底 Unknown)恒成功,不消耗 AI 轮次;unknown 才交 AI(保持 AI 类型域 movie/tv 不变,零 prompt 改动)。 +5. **排除在特典路径外**:musicvideo 判定置于 parse 的年份提取之后、TV/特典规则之前;`[PV]`/`[CM]` 不在 musicvideo 词表 → 特典路径不受影响。 + +**考虑过的方案**:目录信号子串匹配(flakiness 实证否决);musicvideo 词表回退 tv 特典词表(SPs 误判);双命中一律 musicvideo(剧集 MV 特典误判);Music Videos 走 AI 判断(需扩展 prompt 类型域,且本地规则已覆盖多数文件——CONTEXT 挂账"多数文件规则补齐"正是此意)。 + +## 配套 + +- 新文件:`src/lib/config/maps.sh` 扩展(分区加载/查询 + 特典类别表),`src/lib/main.sh`(MUSICVIDEO_WORDS_TEMPLATE/SPECIAL_CATEGORIES_TEMPLATE 常量、分区表全局变量),`src/lib/media/filename.sh`(detect_musicvideo),`src/lib/media/identify.sh`(identify_musicvideo),`src/lib/media/ai_resolve.sh`(fallback musicvideo 分支),`src/lib/pipeline/process.sh`(分发),`src/lib/integrate/ai.sh`(写回分区感知)。 +- 测试:`tests/cases/media/musicvideo.sh`(7 例)、`tests/cases/config/categories.sh`(5 例)、`tests/cases/config/partition.sh`(6 例);全量 107 通过。 +- 版本 9.6(main.sh SCRIPT_VERSION + bashly.yml)。 diff --git a/docs/adr/0007-failure-cooldown-retry-backoff.md b/docs/adr/0007-failure-cooldown-retry-backoff.md new file mode 100644 index 0000000..b9fe068 --- /dev/null +++ b/docs/adr/0007-failure-cooldown-retry-backoff.md @@ -0,0 +1,7 @@ +# 失败冷却(fail_cooldown.json):失败条目的重试退避 + +`skip_unidentified`(AI 尽力后仍失败 / 无 AI 密钥)与 `request_failed`(网络/密钥重试耗尽)条目在下次 cron 运行会被自动重试——语义可逆正确,但持续失败(密钥失效、站点长期不可达、源文件永久不可识别)会每轮全量重试同一批条目,浪费请求与 AI 额度。决定:引入 `$CACHE_DIR/media_organizer/fail_cooldown.json`(`{src: {retry_at, reason}}`),上述两类条目登记冷却(默认 24 小时,`FAIL_RETRY_COOLDOWN_HOURS` 可配,0=禁用),冷却期内识别池入口直接跳过(结局 `cooldown`),过期后自动恢复重试。 + +**边界语义**:① 只冷却"尽力后失败"——`skip_type`(电影特典)无成本不冷却;② 源文件已消失的冷却条目不生效(不阻止新文件重试);③ `--rerun` 显式重跑绕过冷却(用户主动纠错不受退避限制);④ 冷却过期条目在 flush 时惰性清理,不单独维护过期任务;⑤ 冷却条目不计入退出码 3 的失败判定(冷却是"等待重试"而非"本次失败")。 + +**Considered Options**: 不冷却(原状)——每轮全量重试;永久跳过——破坏"可逆"语义,站点恢复后永不重试;按结局计数退避(指数)——过度设计,小时级固定冷却已覆盖 cron 场景。 diff --git a/docs/adr/0008-manual-correction-ledger-rerun.md b/docs/adr/0008-manual-correction-ledger-rerun.md new file mode 100644 index 0000000..e13e695 --- /dev/null +++ b/docs/adr/0008-manual-correction-ledger-rerun.md @@ -0,0 +1,7 @@ +# 手动纠错账本(.ledger.json)与 --rerun 重跑 + +无 WebUI 的 CLI 工具缺少"识别错了怎么办"的通道:改配置后全量重跑是唯一的纠错方式,且无法指定"这个文件要放到那里"。决定:`--export-map` 生成对照表 md 时**伴生同名 `.ledger.json`**(JSON 数组:`{src, dest, outcome, status, action}`,dest 为绝对路径),用户编辑账本(改 dest 为期望目标、把 action 置 `"rerun"`)后运行 `--rerun <文件>`:只执行标记条目,**不重新识别**,直接 mkdir + 硬链接(目标已存在且非同一 inode → 替换,即"手动纠错替换"语义)。 + +**职责边界**:账本导出与重跑都只处理"链接"这一层——识别/命名始终是脚本的确定性职责,账本只是把链接目标暴露给用户编辑。`--rerun` 是独立形状:不接受目录参数(账本内为绝对路径)、不加载 TMDB 认证、不跑 scan/识别/AI,可叠加 `--dry-run` 预览;`status` 字段由导出时现场校验(目标存在且同 inode → `linked`)给出,用户据此识别需要重跑的条目。 + +**Considered Options**: 引入 Python WebUI(FastAPI)——增加运行时依赖与部署复杂度,与纯 bash 单文件分发定位冲突;`--rerun` 重新识别目标(改标题重搜)——语义复杂且与 AI 流程重叠,纠错场景主要是"换目录/换名",链接层重跑已覆盖;不提供纠错(原状)——识别错误只能靠删库全量重跑。 diff --git a/docs/adr/0009-conflict-detection-report-only.md b/docs/adr/0009-conflict-detection-report-only.md new file mode 100644 index 0000000..6356484 --- /dev/null +++ b/docs/adr/0009-conflict-detection-report-only.md @@ -0,0 +1,7 @@ +# 同集冲突检测:仅检测 + 报告,不自动处理 + +多个来源文件识别到同一 (剧集, 季, 集) 时(如 1080p 与 720p 双版本、不同字幕组同集),链接阶段按官方多版本格式 `- 2` 去重静默完成——正确但不透明:用户无法知道哪些集存在多版本、无法指定保留版本。决定:新增 `detect_conflicts()`,识别成功的剧集条目按 `剧集目录||SxxExx`(含多集区间 `E01-E02`)聚合,同一 key 出现多个不同源 → 登记 `CONFLICT_MAP`,对照表新增「同集冲突」小节并列展示全部来源与目标,运行汇总打印冲突组数。 + +**边界语义**:① 仅检测 + 报告,**不自动删任何链接**(区别于 AB 的 revision 替换 Saga——硬链接库场景下自动替换风险大于收益);② Season 00 特典不参与(同名特典属正常多版本);③ 电影/音乐不参与(同标题多版本是 Jellyfin 官方支持的正常形态);④ 冲突条目在账本中同样可见——用户可用 `--rerun` 手动指定保留版本;⑤ 幂等:每次调用重建内存账本,不落盘。 + +**Considered Options**: 策略化 hold/replace(AB 式自动替换旧版本)——破坏性操作,且硬链接场景"旧版本"判定依赖下载器信息(本项目无下载器);仅靠 `- 2` 静默去重(原状)——正确但不可见,无法满足"指定保留版本"。 diff --git a/docs/adr/0010-search-fallback-chain.md b/docs/adr/0010-search-fallback-chain.md new file mode 100644 index 0000000..7d71294 --- /dev/null +++ b/docs/adr/0010-search-fallback-chain.md @@ -0,0 +1,7 @@ +# 搜索重试链:zh-CN → en-US → MAL(可选) + +中文标题搜不到(简体译名与 TMDB 条目名不一致、条目只有英文/日文名)是识别失败的主要来源之一,此前直接进 AI 纠正。决定:识别搜索失败时先做**语言重试**——zh-CN 空结果 → `en-US` 重搜(同一 TMDB 端点、`tmdb_api_lang` 局部覆盖 `TMDB_LANG`,缓存 key/路径含语言段天然隔离,零新依赖),仍失败且 `SEARCH_FALLBACK_MAL=true`(默认关)→ MyAnimeList (jikan v4) 取候选标题(罗马音/英文/日文,最多 3 个)逐个回 TMDB en-US 重搜。 + +**边界语义**:① 请求失败(rc=2,网络/密钥)**不重试**——重搜无意义,直接进失败路径;② MAL 是**可选开关**——外部 API(限流 1 req/3s)需要网络,默认关闭保持零新依赖;③ MAL 独立缓存于 `$CACHE_DIR/media_organizer/mal/`(包裹格式 + 空哨兵 3 天 TTL),不污染 TMDB 缓存镜像;④ MAL 请求失败静默降级(回退到原有 PENDING → AI 路径),不阻断主流程;⑤ en-US 重搜的详情/季数据与 zh 缓存按语言段隔离(id 类路径带 `.lang` 后缀),互不污染。 + +**Considered Options**: 仅 AI 纠正词(原状)——每次失败都消耗 AI 额度且延迟一轮;直接信任 MAL 标题入库——MAL 与 TMDB 条目可能不一致,必须经 TMDB 搜索验证;中文占比启发式去拉丁字符重搜(BAR 做法)——零新依赖但只解决混合标题,解决不了纯译名不一致。 diff --git a/docs/adr/0011-ai-match-duration-resolution-signal.md b/docs/adr/0011-ai-match-duration-resolution-signal.md new file mode 100644 index 0000000..54a1ffa --- /dev/null +++ b/docs/adr/0011-ai-match-duration-resolution-signal.md @@ -0,0 +1,7 @@ +# AI 匹配输入补充视频时长/分辨率信号 + +AI 匹配甄别(`match_entries`)此前只有文件名 + TMDB 候选数据——"这是剧场版还是周更剧集"、"这个 30 分钟的文件是 OVA 还是正片"这类信息文件名常常不表达,AI 只能猜。决定:构造 `match_entries` 时对每条待甄别文件运行 ffprobe(已是硬依赖),附加 `duration`(分钟,一位小数)与 `resolution`(如 `1920x1080`),并在 `AI_BATCH_PROMPT` 明确时长判型规则(正片 ~24min / OVA·特典 ~20-30min / 剧场版 ~70-120min)。 + +**边界语义**:① ffprobe 失败 → 字段留空,不阻塞(AI 仍可依据文件名判断)——信号是增强不是前置条件;② 仅对 `match_entries`(需甄别条目)运行,识别成功路径零额外开销;③ 时长 <1 分钟视为无效(占位/损坏文件不产生噪声信号);④ AI 的 `choice` 仍须是候选列表成员(沿用现有匹配输入契约),时长只影响选择不产生新候选。 + +**Considered Options**: 不传(原状)——AI 无法区分剧场版/OVA;传给所有 search_entries——待纠正条目无候选上下文,时长信号无用且批量 ffprobe 开销大;hachoir 读元数据(BAR 做法)——需新增依赖,ffprobe 已存在且更标准。 diff --git a/docs/adr/0012-candidate-structure-scoring.md b/docs/adr/0012-candidate-structure-scoring.md new file mode 100644 index 0000000..5dac4d3 --- /dev/null +++ b/docs/adr/0012-candidate-structure-scoring.md @@ -0,0 +1,7 @@ +# 识别候选结构评分:季数/特典结构参与候选选择 + +TV 搜索选择此前是"精确名匹配 → 排除前缀 → results[0]",无精确匹配时结构信息(文件季号、特典类型)完全不参与——`S03` 文件可能落到只有 2 季的候选、特典文件可能落到无 season 0 的候选。决定:`pick_tv_show_id` 增加**结构评分层**——精确名匹配失败后,对前 5 候选(前缀排除后)各查一次 detail(TMDB 缓存命中免费),按 `(候选季数 ≥ 文件季号 +40;名称 norm 互相包含 +30;特典文件候选含 season 0 +20)` 评分取最高,全不满足才兜底 results[0]。 + +**边界语义**:① 精确名匹配(name/original_name 与 query 相等)始终最高优先——评分层只在歧义场景介入,不改变正常识别路径;② 评分请求走 `tmdb_api`(缓存封装),冷缓存最多 5 个 detail 请求,仅无精确匹配时发生;③ 电影侧维持既有年份过滤(`alt_id` 泛化已覆盖),不引入新评分;④ 前缀排除语义不变("Sword Art Online" 不选 "Sword Art Online Abridged"——同人排除优先于季数)。 + +**Considered Options**: 维持 results[0](原状)——歧义场景依赖 AI 甄别,多一轮成本且结构信息闲置;评分后直接改 `build_tv_dest` 的偏移语义——评分只管选候选,偏移逻辑不动(职责分离)。 diff --git a/docs/adr/0013-directory-name-fallback-search.md b/docs/adr/0013-directory-name-fallback-search.md new file mode 100644 index 0000000..24b9ba6 --- /dev/null +++ b/docs/adr/0013-directory-name-fallback-search.md @@ -0,0 +1,7 @@ +# 目录名兜底重搜:父目录名作为搜索 query 重试 + +识别搜索失败(zh → en 均无结果)时,文件名常是压制组风格(`[Group] - 01`),而**父目录名是完整剧名**(`[VCB-Studio] Re Zero kara Hajimeru Isekai Seikatsu` 目录)。此前只有 AI 能看到目录链(`match_entries` 的 directory 字段),规则路径白白失败一轮。决定:`tv_search_by_dir` / `movie_search_by_dir`——主搜索失败后,取父目录名(`clean_name` + `strip_season_suffix` 清洗)作 query 重搜(zh → en),命中即用(TV 复用 `pick_tv_show_id` 结构评分,电影取 results[0])。 + +**边界语义**:① **源根直属散放不兜底**(`dir == SOURCE_DIR`)——共享目录误判风险,与正片计数约束一致;② 目录名与 query 相同/为空时跳过(无新信息);③ 插在 MAL 兜底**之前**(本地目录信息零外部依赖,优先于外部 API);④ 目录名含季号("Re Zero 2nd Season")先剥季后缀;⑤ 兜底仍失败 → 原路径(MAL → AI)。 + +**Considered Options**: 只交给 AI 处理(原状)——每次失败消耗 AI 额度且延迟一轮;目录名直接当最终标题(跳过 TMDB 验证)——目录名可能是分类目录(特典/合集),必须经搜索验证;无条件用目录名——源根散放场景误判。 diff --git a/docs/adr/0014-correction-learning.md b/docs/adr/0014-correction-learning.md new file mode 100644 index 0000000..137de10 --- /dev/null +++ b/docs/adr/0014-correction-learning.md @@ -0,0 +1,7 @@ +# 纠错学习(corrections.json):手动修正的持久化与前置命中 + +`--rerun` 让用户能手动纠错,但修正是一次性的——同命名系列文件(同一压制组规范命名的整季)下次运行仍会重新识别错。决定:引入 `$CACHE_DIR/media_organizer/corrections.json`(`{clean_name(源文件名)小写: {dest, learned_at}}`)——`--rerun` 链接成功后学习(用户显式修正的目标即权威,立即原子写盘);识别池入口前置命中(key 一致且 dest 位于当前 DESTINATION_DIR 下)→ 直接产出 DEST,跳过 parse/识别/AI/冷却。 + +**边界语义**:① 学习源**只有 --rerun**(用户显式确认),organize 自动识别成功不学习——避免把脚本自身可能的错误固化;② 消费优先级:已链接 > 纠错命中 > 失败冷却——用户修正过的文件不受冷却退避阻塞(纠错是强信号,无需再等重试);③ dest 必须位于当前 `DESTINATION_DIR` 下(用户改到别的库则本次不适用,防跨库误放);④ 目录参数归一化为绝对路径(`normalize_abs`)——账本存绝对路径,相对目录参数下前缀比较才成立;⑤ key 算法 = 去扩展名 + `clean_name` 小写(与 `--rerun` 学习写入严格一致),与特典 keymap 的"学习写回 + 键小写化"同构;⑥ 目标被删不失效(纠错映射是"该放哪"的权威,重新链接即可),源文件消失自然无影响。 + +**Considered Options**: 只改账本不做学习(原状)——同类文件每季重错一遍;学习 AI 识别结果(自动纠错)——无用户确认,错误会被固化;按绝对路径学习——同一文件换目录/移动后失效,按 clean_name 才能覆盖同命名系列。 diff --git a/docs/adr/0015-suffix-season-fuzzy-match.md b/docs/adr/0015-suffix-season-fuzzy-match.md new file mode 100644 index 0000000..a28a5dc --- /dev/null +++ b/docs/adr/0015-suffix-season-fuzzy-match.md @@ -0,0 +1,7 @@ +# 后缀季模糊匹配:Levenshtein 距离兜底 + +后缀季映射("Railgun T" → S3、"Alicization" → S3)此前依赖硬编码词表(中英对照 + 结尾匹配 + 罗马数字直映射),未收录的后缀(变体拼写、未映射词)直接掉 AI。决定:词表全失败且后缀词 ≥3 字符时,增加 **Levenshtein 模糊层**——awk 实现编辑距离,只比较季名**末尾窗口**(末 `flen+1` 字符,后缀词出现在末尾;窗口比后缀多 1 字符容差,完美命中距离 ≤1 与阈值分档匹配),距离 ≤ 阈值(长度 3/6 分档:1/2/3)且最短者命中。 + +**边界语义**:① 仅词表失败后兜底(词表是主路径,模糊层不参与其排序);② 只对**末尾窗口**比较——整名比较距离恒大无意义(`"r2"` vs 全名距离 15+,窗口内距离 1);③ 后缀词 <3 字符不启用(短后缀已被词表/罗马数字覆盖,模糊层误配风险高);④ 空季名跳过(防单字符后缀误配空名);⑤ 跨语言不指望命中(中文名 vs 英文后缀距离必然超阈值)——模糊层服务同文近似拼写。 + +**Considered Options**: 扩展硬编码词表——不可穷举,维护成本高;整名 Levenshtein——距离阈值不可达(完美命中也被拒);末尾窗口 + 阈值分档(选定)——可达、保守、零外部依赖。 diff --git a/docs/adr/0016-peer-common-prefix-episode-extraction.md b/docs/adr/0016-peer-common-prefix-episode-extraction.md new file mode 100644 index 0000000..2ede58d --- /dev/null +++ b/docs/adr/0016-peer-common-prefix-episode-extraction.md @@ -0,0 +1,7 @@ +# 公共子串剥离提集号:同目录差异数字作集号 + +压制组风格文件(`[Group] Title - 01.mkv`)无方括号数字、无 SxxExx 时,parse 回退"正片计数 ≥2 → tv, episode=0"——集号丢失,命名退化为 `S01E00`。决定:无特征且判定为剧集时,调用 `extract_episode_from_peers`——对同目录全部视频文件名求**最长公共前缀 + 最长公共后缀**(逐字符比较,bash 实现),当前文件剥离前后缀后的中段提取**末尾数字**(集号通常在末尾)作集号,排除分辨率(1080/720/480/2160/4320)。 + +**边界语义**:① 仅在"无特征 → tv"回退分支启用(有 SxxExx/方括号数字的文件走主路径,行为不变);② 少于 2 个视频文件不适用(无公共部分可言);③ 中段无数字/数字被排除 → 保持 episode=0(不猜);④ 后缀公共部分不越过公共前缀边界(短文件名防重叠);⑤ 与 BAR 的 difflib 公共子串思路同源,但只取前后缀(bash 内实现,无新依赖)——中间公共块(如季名在中间)场景不处理,由目录兜底/AI 覆盖。 + +**Considered Options**: 维持 episode=0(原状)——集号丢失进 `S01E00` 与 TMDB 集名错配;引入 difflib——新运行时依赖(python),与纯 bash 定位冲突;全公共子串(BAR 原版)——中间公共块场景罕见,前后缀已覆盖主要形态。 diff --git a/docs/adr/0017-ai-response-json-tolerance.md b/docs/adr/0017-ai-response-json-tolerance.md new file mode 100644 index 0000000..2cc7832 --- /dev/null +++ b/docs/adr/0017-ai-response-json-tolerance.md @@ -0,0 +1,7 @@ +# AI 响应 JSON 容错提取链 + +`AI_SYSTEM_MESSAGE` 强制模型"仅输出 JSON"只是要求,推理模型(DeepSeek-R1 类)实际会输出 `…`、「思考:」前缀或代码围栏——此前 `jq -e .` 校验失败即整批丢弃(该批所有条目留待重试,浪费一轮 + 额度)。决定:新增 `ai_extract_json` 四级容错链——① 直接解析 → ② 剥 ` ```json `/` ``` ` 围栏后解析 → ③ 剥离思考链标记(`` 块 + 「思考/分析/推理:」前缀)后重试直接/围栏解析 → ④ 取最外层 `{}` 块解析。任何一级 jq 验证通过即返回;全部失败保持原失败路径(条目留待下批)。 + +**边界语义**:① 纯 awk/sed 实现(零新依赖,busybox 兼容);② 第 ④ 级花括号配平不识别字符串内 `{}`——仅作兜底,误计最多导致该级失败,不影响前三级;③ 成功提取不改变后续语义(仍是同一个 JSON 对象,jq 消费端无感知);④ 失败日志只截取前 200 字符(防超长响应刷屏)。 + +**Considered Options**: 维持严格校验(原状)——模型纪律不可依赖,推理模型输出思考内容时整批失效;客户端 SDK 结构化输出(`response_format`)——本项目用 curl 直连 OpenAI 兼容端点,无法依赖 SDK 能力(各兼容服务对 json_schema 支持不一);提示词强化——与系统消息重复,仍不保证遵守。 diff --git a/docs/adr/0018-ai-case-persistence.md b/docs/adr/0018-ai-case-persistence.md new file mode 100644 index 0000000..7506d33 --- /dev/null +++ b/docs/adr/0018-ai-case-persistence.md @@ -0,0 +1,7 @@ +# AI 用例落盘(AI_SAVE_CASES):请求/响应留档 + +识别错误排查困难的核心原因:无法复现"当时 AI 看到了什么"——输入(TMDB 缓存、文件名、目录链)在下次运行时可能已变化,AI 响应更无法回放。决定:新增 `AI_SAVE_CASES`(默认 false),开启后每次 AI 批量请求的**输入 JSON**(`build_ai_input_json` 产物)与**原始响应**分别存 `$CACHE_DIR/media_organizer/ai_cases/<序号>_<时间戳>_{request,response}.json`。 + +**边界语义**:① 输入不含 API 密钥(key 只出现在 HTTP Authorization 头,不进请求体)——留档无泄密风险;② 响应存**原始文本**(非 JSON 响应也留档——排查"模型到底输出了什么"正是用例价值所在);③ 干运行不写(零持久化副作用,与全项目约定一致);④ 序号复用 `AI_CALL_COUNT`(可对回批次数);⑤ 与 `--list-cache` 无关(`media_organizer/` 学习数据区,不随 `--refresh-cache` 清除);⑥ 用例是防幻觉学习(特典 keymap 等)的候选数据源——AI 判断 + 用户最终确认结果成对存档后,可用于校准。 + +**Considered Options**: 不落盘(原状)——识别错误无法复盘,反馈只能靠截图;只存响应——缺输入侧无法重建上下文;日志级别全量打(DEBUG)——日志轮转会清掉,且与运行日志混杂。 diff --git a/docs/adr/0019-ai-match-siblings-context.md b/docs/adr/0019-ai-match-siblings-context.md new file mode 100644 index 0000000..d4cb739 --- /dev/null +++ b/docs/adr/0019-ai-match-siblings-context.md @@ -0,0 +1,7 @@ +# match_entries 同目录上下文(siblings) + +AI 匹配甄别此前只看**单个文件**(文件名/时长/分辨率 + TMDB 候选)——"这个 90 分钟文件是剧场版还是合集里的一集"这类判断依赖上下文:同目录还有 23 个 24 分钟文件 → 这是整季 BD,当前文件是剧场版;目录只有它一个 → 独立电影。决定:`build_ai_input_json` 构造 match_entries 时为每条待甄别文件附加 `siblings`——同目录其他视频文件名(≤20 个,排除自身,纯目录扫描零额外请求),并在 `AI_BATCH_PROMPT` 说明用法(目录持整季 BD → 剧集;电影时长文件混在剧集文件旁 → TMDB season 0 的剧场版;兄弟文件名透露发布组与整批编号方案)。 + +**边界语义**:① 仅文件名(不含路径/时长)——体积可控,且路径信息已由 file/directory 提供;② 上限 20 个防 prompt 膨胀(整季 BD 也在此限内);③ 与时长信号互补:时长回答"这个文件多长",siblings 回答"它和什么在一起";④ 零新请求(目录扫描本地完成);⑤ AI 仍只做判断(choice/season_shift),命名归脚本——siblings 只影响判断质量。 + +**Considered Options**: 整目录一次性映射(BAR 模式,AI 输出全量 file_mapping)——prompt 与输出契约大幅复杂化,且与我们"单文件判断 + 消费端重处理"流水线不兼容;不加(原状)——剧场版混 TV 场景只能靠时长猜,误判进 AI 兜底轮。 diff --git a/docs/adr/0020-search-chain-abstraction.md b/docs/adr/0020-search-chain-abstraction.md new file mode 100644 index 0000000..c059033 --- /dev/null +++ b/docs/adr/0020-search-chain-abstraction.md @@ -0,0 +1,7 @@ +# 搜索链抽象(R1):统一尝试器消除逐层复制 + +识别搜索链(zh → en → 规则别名 → 目录 → MAL → 后缀二次剥离)经三轮功能叠加后,每层都是 `tmdb_api(_lang) + 结果选择 + 失败条件` 的复制粘贴变体——`pick_tv_show_id` 调用点达 9 处,新增一层兜底需要复制 ~10 行且容易漏掉失败语义。决定:抽象 `tv_search_once` / `movie_search_once` 统一尝试器——参数(query/季号/特典/语言/年份),返回码约定(0=命中 stdout=id;1=无结果;2=请求失败),电影版以两行协议(id + 单行响应 JSON)回传响应供调用方做年份校验/需甄别检测(bash 无多返回值,命令替换子 shell 会丢全局赋值,两行协议是零新依赖的惯用回传)。 + +**行为保持契约**(测试锁定):① 主搜索(zh)失败(rc=2)立即短路 return 2,后续层不执行;② 后续层失败(rc=2)忽略继续下一层;③ 别名层失败不试其 en 变体(`_ar -ne 2` 守卫);④ movie 的 result 只在 zh/en/别名/MAL 命中时更新(目录命中不更新——原行为);⑤ 后缀二次剥离修改 `search_name` 影响后续 MATCH emit 与季偏移查询——保持不变;⑥ 目录兜底(tv/movie_search_by_dir)保留对外签名,内部改用尝试器。 + +**Considered Options**: 保留逐层复制(原状)——每新增一层兜底维护成本线性增长,失败语义易漂移;全局变量回传响应(`LAST_SEARCH_RESPONSE`)——命令替换子 shell 赋值丢失,需调用方同 shell 调用,破坏现有模式;整链一次性编排(query 列表驱动)——六层的 query 生成逻辑(别名查表/MAL 网络/目录名清洗)各不相同,编排层反而更复杂,统一"单次尝试"粒度是抽象与简单的平衡点。 diff --git a/docs/adr/0021-config-single-source.md b/docs/adr/0021-config-single-source.md new file mode 100644 index 0000000..783523f --- /dev/null +++ b/docs/adr/0021-config-single-source.md @@ -0,0 +1,7 @@ +# 配置单点化(R2):CONFIG_DEFS 单一数据源 + +每新增一个配置键需要改三处(`CONFIG_TEMPLATE` 模板 JSON、全局变量声明、`load_config` 逐键默认值),三处漂移是配置 bug 的常见来源。决定:`CONFIG_DEFS`(键|默认值数组)成为**模板与默认值的单一数据源**——`render_config_template()` 从它生成 config.json 模板(替代 `CONFIG_TEMPLATE` 常量),`load_config` 循环加载全部标量键(`printf -v` 动态赋值 + `${!key:-}` 间接引用,零 eval),特殊键在循环后做后处理(FOLDER_* locale 默认、扩展名拆数组、MEDIA_WORKERS/AI_BATCH_SIZE 数值校验、日志目录初始化、命名模板兜底)。 + +**收益**:新增纯标量键 = 改 `CONFIG_DEFS` 一行(模板与默认值自动生效,永不漂移);`load_config` 手写赋值区净减约 70 行。**边界**:① 后处理逻辑保留手写(locale/校验/数组是行为逻辑,不适合数据驱动);② `printf -v` 是 bash 内置(无 eval,配置值不执行);③ NAMING_* 的字面值须与 `naming_template_default` 保持一致(后者是 render_naming 兜底权威,两处同步修改);④ 白名单键集合(load_config / convert_env_to_json)改经 `render_config_template` 派生——模板与白名单同源。 + +**Considered Options**: 保留三处手写(原状)——新增键易漏一处导致模板与默认值漂移;全部配置改关联数组访问(`${CFG[key]}`)——动所有消费点,破坏 `$VAR` 直接引用的大量既有代码;CONFIG_DEFS + 循环加载(选定)——单一数据源 + 最小侵入,特殊键后处理保留确定性。