- README 现代化重写:徽章区、特性一览、统一 GitHub 提示块格式、修正版本号与 AI 默认值 - 新增 docs/PROJECT_REPORT.md 学术项目报告(含许可证合规分析与死链修复记录) - 收录 23 份 ADR 架构决策记录与 CONTEXT.md 领域术语表 - 新增开发配置:.editorconfig / .shellcheckrc / .markdownlint-cli2.jsonc - .gitignore 补充 mo_map/、检查报告、编辑器临时文件 - 新增 config.example.json 配置模板(不含真实密钥)
This commit is contained in:
1 parent
0df07ad0ae
commit
c1ed1795c2
31 files changed
+1816
-234
No files matched your search
@@ -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() 内隐式优先级(原状)——冲突静默,用户无法感知标志未生效。混合通道——无真实用例,徒增解析复杂度。
|
||||
@@ -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**: 只拦链接(原状)——预览最接近真实运行但产生隐藏副作用;全只读连网络都不调——最纯净但预览残缺,"待定"条目无法给出整理结果。
|
||||
@@ -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 键——宣称的优先级模型本是合理设计,是实现欠账而非文档夸大。
|
||||
@@ -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 时也回退命名(批量伪命名污染库且被幂等锁死,跳过可逆——下次运行自动重试)。
|
||||
@@ -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 使用问题)。
|
||||
@@ -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` 式)——污染媒体目录且无法表达目标位置。
|
||||
@@ -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 例)。
|
||||
@@ -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)。
|
||||
@@ -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 场景。
|
||||
@@ -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 流程重叠,纠错场景主要是"换目录/换名",链接层重跑已覆盖;不提供纠错(原状)——识别错误只能靠删库全量重跑。
|
||||
@@ -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` 静默去重(原状)——正确但不可见,无法满足"指定保留版本"。
|
||||
@@ -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 做法)——零新依赖但只解决混合标题,解决不了纯译名不一致。
|
||||
@@ -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 已存在且更标准。
|
||||
@@ -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` 的偏移语义——评分只管选候选,偏移逻辑不动(职责分离)。
|
||||
@@ -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 验证)——目录名可能是分类目录(特典/合集),必须经搜索验证;无条件用目录名——源根散放场景误判。
|
||||
@@ -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 才能覆盖同命名系列。
|
||||
@@ -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——距离阈值不可达(完美命中也被拒);末尾窗口 + 阈值分档(选定)——可达、保守、零外部依赖。
|
||||
@@ -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 原版)——中间公共块场景罕见,前后缀已覆盖主要形态。
|
||||
@@ -0,0 +1,7 @@
|
||||
# AI 响应 JSON 容错提取链
|
||||
|
||||
`AI_SYSTEM_MESSAGE` 强制模型"仅输出 JSON"只是要求,推理模型(DeepSeek-R1 类)实际会输出 `<thinking>…</thinking>`、「思考:」前缀或代码围栏——此前 `jq -e .` 校验失败即整批丢弃(该批所有条目留待重试,浪费一轮 + 额度)。决定:新增 `ai_extract_json` 四级容错链——① 直接解析 → ② 剥 ` ```json `/` ``` ` 围栏后解析 → ③ 剥离思考链标记(`<thinking>` 块 + 「思考/分析/推理:」前缀)后重试直接/围栏解析 → ④ 取最外层 `{}` 块解析。任何一级 jq 验证通过即返回;全部失败保持原失败路径(条目留待下批)。
|
||||
|
||||
**边界语义**:① 纯 awk/sed 实现(零新依赖,busybox 兼容);② 第 ④ 级花括号配平不识别字符串内 `{}`——仅作兜底,误计最多导致该级失败,不影响前三级;③ 成功提取不改变后续语义(仍是同一个 JSON 对象,jq 消费端无感知);④ 失败日志只截取前 200 字符(防超长响应刷屏)。
|
||||
|
||||
**Considered Options**: 维持严格校验(原状)——模型纪律不可依赖,推理模型输出思考内容时整批失效;客户端 SDK 结构化输出(`response_format`)——本项目用 curl 直连 OpenAI 兼容端点,无法依赖 SDK 能力(各兼容服务对 json_schema 支持不一);提示词强化——与系统消息重复,仍不保证遵守。
|
||||
@@ -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)——日志轮转会清掉,且与运行日志混杂。
|
||||
@@ -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 兜底轮。
|
||||
@@ -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 网络/目录名清洗)各不相同,编排层反而更复杂,统一"单次尝试"粒度是抽象与简单的平衡点。
|
||||
@@ -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 + 循环加载(选定)——单一数据源 + 最小侵入,特殊键后处理保留确定性。
|
||||
Reference in new issue
Block a user