- 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,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["整理媒体库<br>(正常模式)"]
|
||||
U --> C2["干运行预览"]
|
||||
U --> C3["仅更新缓存"]
|
||||
U --> C4["列出缓存条目"]
|
||||
U --> C5["手动纠错重跑"]
|
||||
C1 --> S["系统<br>MediaOrganizer"]
|
||||
S --> T["TMDB API"]
|
||||
S --> A["AI API"]
|
||||
S --> F["文件系统<br>(硬链接)"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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["第一阶段:识别池<br>process_video + process_audio<br>并发 MEDIA_WORKERS worker<br>parse → identify_movie / identify_tv_show"]
|
||||
B --> C["第二阶段:run_ai_batch<br>AI 纠正搜索词 + 匹配甄别<br>+ 特典映射学习 + 艺术家判定"]
|
||||
C --> D["第三阶段:link_media<br>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/<hash>.json # 搜索缓存(包裹格式)
|
||||
│ ├── search/tv/<hash>.json
|
||||
│ ├── tv/<id>.<lang>.json # 剧集详情(语言段随 TMDB_LANG)
|
||||
│ ├── tv/<id>/season/<n>.<lang>.json # 每季数据(含 season 0 特典季)
|
||||
│ └── movie/<id>.<lang>.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)?<br>电影"}
|
||||
B -- 否 --> C{"S##E## / #x## ?<br>剧集"}
|
||||
C -- 否 --> D{"特典标记?<br>方括号词表 / 父目录"}
|
||||
D -- 否 --> E{"Season 关键词?"}
|
||||
E -- 否 --> F{"方括号 [数字]?"}
|
||||
F -- 否 --> G["回退:媒体目录正片数量<br>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。_
|
||||
@@ -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