Compare commits
5
Commits
452c6f26f9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83d2008a5e | ||
|
|
e95ec522f2 | ||
|
|
c1ed1795c2 | ||
|
|
0df07ad0ae | ||
|
|
d978a13a83 |
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,41 @@
|
||||
# ============================================================================
|
||||
# CI:静态检查 → 单元测试 → 构建一致性
|
||||
# 触发:push / pull_request(默认分支与 PR)
|
||||
# ============================================================================
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: lint + test + build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install shellcheck
|
||||
run: sudo apt-get update && sudo apt-get install -y shellcheck
|
||||
|
||||
- name: Setup Ruby (bashly)
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.4'
|
||||
|
||||
- name: Install bashly
|
||||
run: gem install bashly
|
||||
|
||||
- name: Lint (bash -n + shellcheck)
|
||||
run: ./lint.sh
|
||||
|
||||
- name: Unit tests
|
||||
run: ./tests/run.sh
|
||||
|
||||
- name: Build
|
||||
run: ./build.sh
|
||||
|
||||
- name: Verify artifact freshness
|
||||
run: ./build.sh --check
|
||||
+33
@@ -12,3 +12,36 @@
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
# MediaOrganizer 运行时缓存(tmdb/ = TMDB API 请求缓存;media_organizer/ = 脚本运行状态账本)
|
||||
mo_cache/
|
||||
|
||||
# 运行时配置目录(含 API 密钥的 config.json),不纳入版本控制
|
||||
mo_config/
|
||||
|
||||
# 参考仓库(3rd-party 仅为本地参考,不纳入版本控制)
|
||||
3rd-party/
|
||||
|
||||
# 本地调试用媒体库文件名镜像(空文件,不纳入版本控制)
|
||||
debug/
|
||||
|
||||
# 构建产物:dist/media_organizer 入库(Gitea 可 raw 下载、CI --check 保证与源码同步);
|
||||
# 仅忽略构建过程中的临时文件
|
||||
dist/*.tmp
|
||||
dist/*.orig
|
||||
|
||||
# 运行时输出:--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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 注释),
|
||||
# 不在全局关闭,保持误报可追踪。
|
||||
+228
@@ -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": {"<标题键>": <id>}, "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 `/` ``` ` 围栏 → ③ 剥离思考链标记(`<thinking>` 块 + 「思考/分析/推理:」前缀)→ ④ 最外层 `{}` 块(花括号配平,字符串内误计仅致本级失败)。任何一级 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/<序号>_<ts>_{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/<id>.<lang>.json`、`tv/<id>/season/<n>.<lang>.json`、`movie/<id>.<lang>.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: <id> | "", season_shift: <N>, 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 依赖)
|
||||
@@ -0,0 +1,27 @@
|
||||
# ============================================================================
|
||||
# 任务统一入口
|
||||
#
|
||||
# make build 构建分发脚本 dist/media_organizer(bashly generate)
|
||||
# make test 运行单元测试
|
||||
# make lint ShellCheck + bash -n 静态检查
|
||||
# make check lint + test + 产物一致性(可接 CI)
|
||||
# make clean 删除构建产物
|
||||
#
|
||||
# 直接调用底层脚本亦可(./build.sh、./tests/run.sh、./lint.sh)。
|
||||
# ============================================================================
|
||||
.PHONY: build test lint check clean
|
||||
|
||||
build:
|
||||
./build.sh
|
||||
|
||||
test:
|
||||
./tests/run.sh
|
||||
|
||||
lint:
|
||||
./lint.sh
|
||||
|
||||
check: lint test
|
||||
./build.sh --check
|
||||
|
||||
clean:
|
||||
rm -f dist/media_organizer
|
||||
@@ -1,3 +1,543 @@
|
||||
# MediaOrganizer
|
||||
# 🎬 MediaOrganizer
|
||||
|
||||
媒体库整理,将媒体库整理成 Jellyfin 支持的格式。
|
||||
> **Jellyfin 媒体库硬链接整理脚本**:通过 TMDB API 识别电影、电视剧、音乐与音乐视频,用**硬链接**在同一文件系统内**零拷贝**创建 Jellyfin 标准目录结构。AI 辅助识别、全量镜像缓存、特典自动归类、季数偏移、增量整理——一次整理,终身整洁。
|
||||
|
||||
[](LICENSE)
|
||||
[](src/lib/main.sh)
|
||||
[](https://www.gnu.org/software/bash/)
|
||||
[](.github/workflows/ci.yml)
|
||||
[](tests/)
|
||||
[](https://google.github.io/styleguide/shellguide.html)
|
||||
[](https://gitea.ppuc.lssa.fun/Shuery/MediaOrganizer)
|
||||
[](https://gitea.ppuc.lssa.fun/Shuery/MediaOrganizer/pulls)
|
||||
|
||||
**作者**:LetsShareAll | **许可**:MIT | **语言**:Bash(零运行时依赖,单文件分发)
|
||||
|
||||
---
|
||||
|
||||
## 📑 目录
|
||||
|
||||
- [✨ 特性一览](#-特性一览)
|
||||
- [🚀 快速开始](#-快速开始)
|
||||
- [📦 获取项目](#-获取项目)
|
||||
- [🧭 命令行模式与选项](#-命令行模式与选项)
|
||||
- [📖 使用示例](#-使用示例)
|
||||
- [📊 架构与执行流程](#-架构与执行流程)
|
||||
- [🗂️ 输出目录结构](#️-输出目录结构)
|
||||
- [🛠️ 配置详解](#️-配置详解)
|
||||
- [🧠 核心机制](#-核心机制)
|
||||
- [🧪 测试与开发](#-测试与开发)
|
||||
- [🐛 故障排除](#-故障排除)
|
||||
- [🤝 贡献指南](#-贡献指南)
|
||||
- [📄 许可证与致谢](#-许可证与致谢)
|
||||
|
||||
---
|
||||
|
||||
## ✨ 特性一览
|
||||
|
||||
| 特性 | 说明 |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🎬 智能识别 | TMDB API 匹配电影与电视剧,支持多种文件名格式,媒体类型自动判定 |
|
||||
| 🔗 硬链接整理 | 同一文件系统内零拷贝,不复制数据、不占额外空间 |
|
||||
| 🤖 AI 辅助识别 | OpenAI 兼容接口(默认 DeepSeek-V4-Flash):纠正搜索词、匹配甄别(选候选 id + 季映射)、判定专辑艺术家、学习特典映射;分批处理、失败**可逆**跳过 |
|
||||
| 📚 全量镜像缓存 | `mo_cache/tmdb/` 镜像 TMDB API 路径缓存全部请求,二次运行**零外部请求**;正常 30 天 / 查无此片 3 天 TTL;并发 `flock` 去重 |
|
||||
| ⭐ 特典自动识别 | NCOP/NCED/Menu/PV/CM 等特典归入 Season 00;TMDB 匹配用 `S00E{编号}`,未匹配用 `S00{类型}{编号}`;AI 学习写回映射 |
|
||||
| 🎵 音乐与音乐视频 | CD 音乐归 `Music/歌手/专辑/曲目`;Music Videos(v9.6)识别与 `MusicVideos/{artist}/{title}` 命名 |
|
||||
| ♻️ 增量标记 | 已链接账本(inode 校验):cron 重跑只处理新文件;目标被删/源被替换自动失效重新链接 |
|
||||
| ⏳ 失败冷却 | 尽力后失败的条目登记冷却(默认 24h),冷却期内跳过、过期自动重试,不重复打 API |
|
||||
| ✏️ 手动纠错 | `--export-map` 伴生 `.ledger.json`:改目标路径 + `--rerun` 重跑;修正结果自动学习(`corrections.json`) |
|
||||
| 🔎 搜索重试链 | zh-CN → en-US → 规则别名 → 目录名兜底 → MAL(可选)→ 后缀季二次剥离 |
|
||||
| 🧮 季数偏移 | 处理 TMDB 季数与实际集数不符的剧集(自动 / 手动两种偏移,含后缀季 `Railgun T`) |
|
||||
| 🧭 命名模板 | 电影/剧集/特典/音乐/音乐视频命名格式全部可配置(`NAMING_*`),默认与旧版输出逐字节一致 |
|
||||
| 🎛️ 用户匹配规则 | `match_rules.json`:搜索别名 + ID 映射,确定性兜底,优先于 AI,零 AI 轮次消耗 |
|
||||
| 🗂️ 配置按类目分区 | 特典映射/词表/跳过目录/季偏移支持 movie/tv/music/musicvideo 分区(v9.6) |
|
||||
| 📏 AI 多维信号 | AI 甄别输入附带 ffprobe 时长/分辨率 + 同目录兄弟文件列表——区分剧场版/OVA/正片 |
|
||||
| 🧩 AI 响应容错 | 四级 JSON 容错提取(直接解析 → 剥围栏 → 剥离思考链 → 最外层 `{}`),推理模型不再整批失效 |
|
||||
| 🖥️ 跨平台 | Linux / macOS(BSD stat 兼容)/ BusyBox(NAS / OpenWrt) |
|
||||
| 📦 单文件分发 | `dist/media_organizer` 自包含全部配置模板,无需携带任何配套文件 |
|
||||
|
||||
### 处理流程(三阶段)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["scan_files 扫描源目录<br>视频 VIDEO_FILES + 音频 AUDIO_FILES"] --> B["第一阶段 识别池<br>process_video + process_audio<br>并发 MEDIA_WORKERS 个 worker<br>parse → TMDB 识别 → register 登记"]
|
||||
B --> C["第二阶段 AI 批处理<br>run_ai_batch(AI_BATCH_SIZE 条/批)<br>纠正搜索词 / 匹配甄别 / 判定艺术家 / 学习特典<br>AI 失败 → 剩余显式跳过(可逆)"]
|
||||
C --> D["第三阶段 硬链接<br>link_media:mkdir → ln(幂等 / 撞名去重)<br>→ 伴随文件(字幕 / 音轨 / 歌词 / 封面)"]
|
||||
D --> E["运行级汇总 + 退出码分级<br>0 全部成功 / 3 部分失败"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> **零依赖设计**:`dist/media_organizer` 是自包含单文件产物(bashly 生成),仅需系统自带的 `bash`/`curl`/`jq`/`ffprobe`,无需安装 Ruby 或任何语言运行时。构建工具链(bashly)仅在从源码二次构建时需要。
|
||||
|
||||
```shell
|
||||
# 1. 获取分发脚本(单文件即用)
|
||||
curl -O https://gitea.ppuc.lssa.fun/Shuery/MediaOrganizer/raw/branch/main/dist/media_organizer
|
||||
chmod +x media_organizer
|
||||
|
||||
# 2. 首次运行:自动生成配置模板(询问时输入 y)
|
||||
./media_organizer /downloads /media
|
||||
|
||||
# 3. 填写 TMDB 密钥
|
||||
chmod 600 mo_config/config.json # 配置含密钥,权限收紧
|
||||
# 编辑 mo_config/config.json,至少填写 TMDB_API_RA_TOKEN
|
||||
|
||||
# 4. 干运行预览(零副作用:不建链接、不写缓存、不写日志)
|
||||
./media_organizer --dry-run /downloads /media
|
||||
|
||||
# 5. 正式运行
|
||||
./media_organizer /downloads /media
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> 源目录与目的目录必须在**同一文件系统**上,否则无法创建硬链接(脚本启动时会自动检测并报错)。
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> 需要 TMDB API 密钥?前往 [TMDB 官网](https://www.themoviedb.org/settings/api) 免费申请;AI 密钥可选用 [DeepSeek](https://platform.deepseek.com) 等任意 OpenAI 兼容端点(`AI_BASE_URL`/`AI_MODEL` 可配)。
|
||||
|
||||
---
|
||||
|
||||
## 📦 获取项目
|
||||
|
||||
```shell
|
||||
# 自托管 Gitea:SSH 或 HTTPS 克隆
|
||||
git clone [email protected]:Shuery/MediaOrganizer.git
|
||||
# 或
|
||||
git clone https://gitea.ppuc.lssa.fun/Shuery/MediaOrganizer.git
|
||||
```
|
||||
|
||||
- **免构建即用**:`dist/media_organizer` 随仓库提交,Gitea 可 raw 直接下载,无需任何工具链
|
||||
- **从源码构建**(约 1 秒,需 Ruby + bashly):`./build.sh`
|
||||
- 开发配套文档:[`CONTEXT.md`](CONTEXT.md)(领域术语表)、[`docs/PROJECT_REPORT.md`](docs/PROJECT_REPORT.md)(技术报告)、[`docs/adr/`](docs/adr/)(23 份架构决策记录)
|
||||
|
||||
---
|
||||
|
||||
## 🧭 命令行模式与选项
|
||||
|
||||
一次调用对应一种**模式(mode)**,由目录参数个数与修饰标志共同决定:
|
||||
|
||||
| 模式形状 | 调用形式 | 行为 |
|
||||
| ------------------- | ------------------------------------ | --------------------------------------------------------------- |
|
||||
| `organize` | `[选项] <源目录> <目的目录>` | 正常整理(使用缓存) |
|
||||
| `organize + update` | `--update-cache <源目录> <目的目录>` | 整理并强制重取对应缓存 |
|
||||
| `cache-only` | `--update-cache <源目录>` | 仅更新缓存,不整理(更新完退出) |
|
||||
| `list` | `--list-cache [关键词]` | 列出缓存条目(可选关键词按类型/路径/参数过滤) |
|
||||
| `export-map` | `--export-map [目录]` | 生成源→目标对照表 markdown + 伴生纠错账本 `.ledger.json` |
|
||||
| `rerun` | `--rerun <账本文件>` | 手动纠错重跑:只做链接层,不重新识别(可叠加 `--dry-run` 预览) |
|
||||
|
||||
**选项**:
|
||||
|
||||
| 选项 | 说明 |
|
||||
| ---------------------------------------- | ------------------------------------------------------------ |
|
||||
| `-a, --automated` | 自动化模式:写入日志文件,跳过无法处理的项目(适合 cron) |
|
||||
| `--no-automated` | 取消自动化模式(覆盖环境变量 / config.json 设置) |
|
||||
| `--dry-run` | 干运行:不创建链接、不写缓存、不写日志;网络请求照常但不落盘 |
|
||||
| `--no-dry-run` | 取消干运行(覆盖环境变量 / config.json 设置) |
|
||||
| `--refresh-cache` | 清空 TMDB 缓存后执行(保留特典映射等脚本学习数据) |
|
||||
| `--update-cache` | 强制重取对应缓存条目(与 `--list-cache` 互斥) |
|
||||
| `--src-dir <目录>` / `--dest-dir <目录>` | 以选项形式指定源/目的目录(与位置参数互斥,二选一通道) |
|
||||
| `-h, --help` / `--version` | 帮助 / 版本(退出码 0) |
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> **退出码分级**:`0` = 全部成功;`1` = 运行期错误;`2` = 用法错误(未知选项、参数个数、模式冲突);`3` = 部分失败(非预期跳过 / 请求失败 / 链接失败 > 0,供 cron 感知)。
|
||||
|
||||
---
|
||||
|
||||
## 📖 使用示例
|
||||
|
||||
```shell
|
||||
# 基础整理
|
||||
./dist/media_organizer /downloads /media
|
||||
|
||||
# 干运行预览(推荐先跑一次)
|
||||
./dist/media_organizer --dry-run /downloads /media
|
||||
|
||||
# 自动化模式 + cron 定时增量整理(已链接/冷却条目自动跳过,只处理新文件)
|
||||
0 3 * * * /path/to/dist/media_organizer -a /downloads /media
|
||||
|
||||
# 缓存维护
|
||||
./dist/media_organizer --list-cache # 列出全部缓存条目
|
||||
./dist/media_organizer --list-cache 刀剑 # 关键词过滤
|
||||
./dist/media_organizer --update-cache /downloads # 仅刷新缓存
|
||||
./dist/media_organizer --update-cache /downloads /media # 刷新并整理
|
||||
./dist/media_organizer --refresh-cache /downloads /media # 清空 TMDB 缓存后整理
|
||||
|
||||
# 手动纠错闭环:导出对照表 → 编辑账本 → 重跑
|
||||
./dist/media_organizer --export-map /downloads /media # 生成 mo_map/*.md + *.ledger.json
|
||||
# 编辑 *.ledger.json:修改 dest 路径,将 action 改为 "rerun"
|
||||
./dist/media_organizer --rerun mo_map/downloads-xxxx.ledger.json # 按账本重跑(不重新识别)
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> 纠错结果会被学习到 `corrections.json`:同命名系列文件下次识别**直接命中**你指定的目标,无需再次修正。
|
||||
|
||||
---
|
||||
|
||||
## 📊 架构与执行流程
|
||||
|
||||
### 配置优先级链
|
||||
|
||||
配置值统一按 **CLI > 环境变量 > config.json > 内置默认值** 解析,同一键只取最高优先级来源;CLI 显式设置(含 `--no-*` 反选)覆盖一切。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["CLI 参数<br>--dry-run / --src-dir ..."] --> P{"最终配置值"}
|
||||
B["环境变量<br>TMDB_LANG / AI_MODEL ..."] --> P
|
||||
C["config.json<br>mo_config/ 白名单键"] --> P
|
||||
D["内置默认值<br>CONFIG_DEFS 单一数据源"] --> P
|
||||
```
|
||||
|
||||
### 识别搜索重试链
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["识别搜索<br>identify_tv_show / identify_movie"] --> B["zh-CN 主搜索"]
|
||||
B -- "无结果" --> C["en-US 重搜"]
|
||||
C -- "无结果" --> D["规则别名 search_aliases"]
|
||||
D -- "无结果" --> E["目录名兜底<br>(清洗 + 剥季后缀)"]
|
||||
E -- "无结果" --> F["MAL 候选回搜<br>(SEARCH_FALLBACK_MAL=true,可选)"]
|
||||
F -- "无结果" --> G["后缀季二次剥离<br>(Railgun T / II / 2nd)"]
|
||||
G -- "仍失败" --> H["交 AI 待处理<br>(失败可逆跳过,下次自动重试)"]
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> **ID 映射优先于一切搜索**:`match_rules.json` 的 `id_maps` 标题命中后直接使用指定 TMDB ID、完全跳过搜索(适合 TMDB 多候选易选错、同名动画/真人版场景);`search_aliases` 仅在常规搜索无结果时介入。
|
||||
|
||||
### AI:判断引擎与执行引擎分离
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph AI["🤖 AI 只做判断"]
|
||||
A["输入:文件信息 + 目录链 +<br>TMDB 搜索结果 + 时长/分辨率<br>+ 同目录兄弟文件"] --> B["输出:choice / season_shift<br>/ search_term / artist /<br>特典匹配关键字"]
|
||||
end
|
||||
subgraph SCRIPT["📜 脚本负责执行"]
|
||||
C["命名格式化(年份 / Season 补零<br>/ SxxExx / 目录结构)"]
|
||||
D["硬链接 / 账本 / 汇总"]
|
||||
end
|
||||
B --> C --> D
|
||||
```
|
||||
|
||||
- AI 输出**交叉验证**:特典学习产物必须是 TMDB 候选列表成员,防幻觉污染
|
||||
- 失败语义:AI 请求失败 → 剩余条目显式跳过(**可逆**,下次运行自动重试);仅"AI 成功且判断无解"才走回退命名
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ 输出目录结构
|
||||
|
||||
脚本在目的目录创建 **Jellyfin 官方规范**的结构(根目录名默认跟随系统语言,`FOLDER_*` 可配):
|
||||
|
||||
```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 ← TMDB 匹配(S00E 编号)
|
||||
│ ├── S00CM01 - CM01.mkv ← 未匹配(S00{类型}{编号})
|
||||
│ └── S00Menu01 - Menu01.mkv
|
||||
├── Music/ # 🎵 CD 音乐(flac/mp3)
|
||||
│ └── 平井大/
|
||||
│ └── 幸せのレシピ/
|
||||
│ └── 01. 幸せのレシピ.flac
|
||||
└── MusicVideos/ # 🎤 音乐视频(v9.6)
|
||||
└── 周杰伦/
|
||||
├── 晴天.mkv
|
||||
└── 七里香.mp4
|
||||
```
|
||||
|
||||
**命名规则**:
|
||||
|
||||
| 类型 | 规则 |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| 电影 | `Movies/标题 (年份)/标题 (年份).扩展名` |
|
||||
| 剧集 | `Shows/标题 (年份)/Season NN/S{季}E{集} - 集名.扩展名`(多集区间 `S01E01-E02 - 首集名 - 末集名`) |
|
||||
| 特典(TMDB 匹配) | `Shows/标题 (年份)/Season 00/S00E{集号} - TMDB集名.扩展名` |
|
||||
| 特典(未匹配) | `Shows/标题 (年份)/Season 00/S00{类型}{编号} - 片段.扩展名`(如 `S00CM01`、`S00Menu01`) |
|
||||
| 音乐 | `Music/歌手/专辑/[子碟]/曲目.扩展名`(一夹一专辑) |
|
||||
| 音乐视频 | `MusicVideos/歌手/歌名.扩展名`(artist 元数据 → 目录名 → `FOLDER_UNKNOWN`) |
|
||||
| 伴随文件 | 与主视频同名,保留语言标签(`.zh.srt`、`.jp.ass`);**视频 > 音频** 归属判定 |
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> 剧集文件名**不含剧集名**——Jellyfin 通过父目录(`Shows/标题 (年份)/`)识别剧集,不影响刮削。撞名按官方多版本格式去重(文件名追加 `- 2`),同源旧链接按 inode 清理。
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 配置详解
|
||||
|
||||
### 配置文件体系
|
||||
|
||||
三类数据严格分离,各自独立生命周期:
|
||||
|
||||
```text
|
||||
mo_config/ # 用户配置类文件(可编辑 + 脚本可写回)
|
||||
├── config.json # 主配置(含 API 密钥,权限 600)
|
||||
├── special_maps.json # 特典映射(AI 学习写回)
|
||||
├── special_keywords.json # 特典识别词表(AI 学习写回)
|
||||
├── skip_directories.json # 跳过目录词表(AI 学习写回)
|
||||
├── season_offsets.json # 季数偏移(运行时生成)
|
||||
├── special_categories.json # 特典类别判定表(v9.6 外置)
|
||||
└── match_rules.json # 用户匹配规则(搜索别名 / ID 映射)
|
||||
|
||||
mo_cache/ # 缓存根目录(可再生)
|
||||
├── tmdb/ # TMDB API 响应缓存(--refresh-cache 仅清空此区)
|
||||
│ ├── search/movie|tv/<hash>.json # 搜索缓存(包裹格式,含空哨兵)
|
||||
│ ├── tv/<id>.<lang>.json # 剧集详情(语言段隔离)
|
||||
│ ├── tv/<id>/season/<n>.<lang>.json # 每季数据(含 season 0 特典季)
|
||||
│ └── movie/<id>.<lang>.json # 电影详情
|
||||
└── media_organizer/ # 脚本运行状态(不清除)
|
||||
├── linked.json # 已链接账本(增量标记)
|
||||
├── fail_cooldown.json # 失败冷却
|
||||
├── corrections.json # 纠错学习
|
||||
├── mal/ # MAL 搜索缓存
|
||||
└── ai_cases/ # AI 用例落盘(AI_SAVE_CASES=true 时)
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> 旧版本配置文件(旧文件名 / 旧位置)在首次运行时**自动迁移**到上述布局,无需手工处理。
|
||||
|
||||
### config.json 主要配置项
|
||||
|
||||
参考 [`config.example.json`](config.example.json) 模板(不含真实密钥)。值统一为字符串,文件权限 600:
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
| ------------------------------------------------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------- |
|
||||
| `TMDB_API_RA_TOKEN` / `TMDB_API_KEY` | (空,必填之一) | TMDB 认证:Bearer Token(推荐)或 API Key |
|
||||
| `TMDB_LANG` | `zh-CN` | API 查询语言;缓存按语言段隔离,切换自动重取 |
|
||||
| `TMDB_DELAY` / `TMDB_CURL_RETRY` | `1` / `3` | 请求间延迟(防限流)/ 重试次数 |
|
||||
| `VIDEO_EXTS` / `AUDIO_EXTS` / `SUB_EXTS` | 常见媒体扩展名 | 视频 / 音频 / 字幕扩展名清单 |
|
||||
| `CACHE_TTL_DAYS` / `CACHE_EMPTY_TTL_DAYS` | `30` / `3` | 正常缓存 / 空哨兵 TTL(查无此片短 TTL,新片出现后自动发现) |
|
||||
| `AI_API_KEY` | (空,留空禁用 AI) | AI API 密钥(OpenAI 兼容) |
|
||||
| `AI_BASE_URL` / `AI_FULL_URL` | `https://api.deepseek.com` / 空 | AI 端点(`AI_FULL_URL` 优先,默认 `{BASE}/v1/chat/completions`) |
|
||||
| `AI_MODEL` | `DeepSeek-V4-Flash` | AI 模型 |
|
||||
| `AI_MAX_CALLS` / `AI_BATCH_SIZE` | `10` / `50` | AI 批次上限 / 每批条目数(成本控制) |
|
||||
| `AI_DRY_RUN` / `AI_SAVE_CASES` | `false` | AI 干运行(不产生费用)/ 用例落盘(复盘 AI 输入输出) |
|
||||
| `SEARCH_FALLBACK_MAL` / `MAL_BASE_URL` | `false` / `https://api.jikan.moe/v4` | 可选 MAL 兜底搜索(外部 API 依赖,默认关闭) |
|
||||
| `FAIL_RETRY_COOLDOWN_HOURS` | `24` | 失败冷却时长(`0` 禁用) |
|
||||
| `MEDIA_WORKERS` | `4` | 识别池并发数 |
|
||||
| `FOLDER_MOVIES` / `FOLDER_SHOWS` / `FOLDER_MUSIC` / `FOLDER_MUSICVIDEOS` / `FOLDER_UNKNOWN` | 跟随系统语言 | 目的目录根名(对齐 Jellyfin 官方库名 Movies/Shows/Music/MusicVideos) |
|
||||
| `LOG_FILE` / `SKIP_LOG_FILE` / `LOG_ROTATE_MB` | `/var/log/...` / `10` | 自动化日志路径 / 跳过记录 / 轮转阈值(超阈值滚动保留 5 份) |
|
||||
|
||||
### 命名模板(`NAMING_*`)
|
||||
|
||||
命名格式化外置为可配置模板(v9.5),**默认模板与旧版输出逐字节一致**:
|
||||
|
||||
| 语法 | 含义 | 示例 |
|
||||
| -------------- | ----------------------------------------------------- | ----------------------------------- |
|
||||
| `{name}` | 变量值原样插入 | `{title}` → `Sword Art Online` |
|
||||
| `{name:NN}` | 数字补零至 NN 位 | `{season:02}` → `01` |
|
||||
| `{?name:text}` | 条件段:name 非空才渲染 text(text 内可含其他占位符) | `{?year: ({year})}` → `(2012)` 或空 |
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"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}",
|
||||
"NAMING_MUSICVIDEO": "{artist}/{title}",
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> `Season NN` 与 `SxxExx` 是 **Jellyfin 解析格式**——修改 `NAMING_SEASON`/`NAMING_EPISODE` 默认结构可能导致刮削失败,请仅在了解后果时自定义。未知占位符渲染为空并打印警告。
|
||||
|
||||
### 用户匹配规则(`match_rules.json`)
|
||||
|
||||
```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 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **搜索别名**:主搜索(zh → en)无结果时用重搜词兜底——适合本地俗称/简称(TMDB 搜不到"俺妹"但能搜到全名)
|
||||
- **ID 映射**:标题命中直接使用指定 TMDB ID,**完全跳过搜索**(最高优先级)——适合同名动画/真人版易选错的场景
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> 匹配规则优先级:**ID 映射 > 常规搜索链(zh → en → 别名 → 目录名 → MAL)> AI**。规则全部命中即走确定性路径,不消耗 AI 轮次。
|
||||
|
||||
### 类目分区(v9.6)
|
||||
|
||||
`special_maps` / `special_keywords` / `skip_directories` / `season_offsets` 四个配置类文件支持**分区形态**:顶层按类目分键,未配置回退 `default` 分区 → 全局表:
|
||||
|
||||
```json
|
||||
{
|
||||
"tv": ["menu", "ncop", "nced", "pv", "cm"],
|
||||
"musicvideo": ["mv", "music video", "live", "concert", "演唱会"],
|
||||
"default": ["特典", "特番"]
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> 分区语义因文件而异:`skip_directories` 分区形态 = **完整语义**(不叠加内置默认);`special_keywords` 分区 = **叠加语义**(积累型词表)。AI 学习写回自动适配分区(写 tv / video 分区)。
|
||||
|
||||
---
|
||||
|
||||
## 🧠 核心机制
|
||||
|
||||
### 缓存设计
|
||||
|
||||
- **镜像缓存**:`mo_cache/tmdb/` 路径结构镜像 TMDB API 端点,一切请求数据全量落盘;详情/季缓存带语言段(`tv/<id>.<lang>.json`),切换 `TMDB_LANG` 自动 miss 重取
|
||||
- **并发去重**:识别池多 worker 同时 miss 同一查询时,`flock` 互斥 + 锁内**双检**——同一查询只发一次 TMDB 请求
|
||||
- **空哨兵**:搜索"查无此片"写 `empty:true` 包裹缓存(3 天短 TTL),新片出现后自动重新搜索;请求失败不写缓存,下次运行重试
|
||||
- **原子写**:先写临时文件再 `rename`,避免并发/中断产生半截 JSON
|
||||
|
||||
### 增量账本与失败冷却
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["识别池入口 process_one_file"] --> B{"纠错学习命中?<br>corrections.json"}
|
||||
B -- "是" --> C["直接用用户指定目标<br>(跳过 parse/识别/AI)"]
|
||||
B -- "否" --> D{"已链接账本命中?<br>源存在 + 目标存在 + 同 inode"}
|
||||
D -- "是" --> E["结局 already_linked 跳过"]
|
||||
D -- "否" --> F{"失败冷却中?<br>request_failed / skip_unidentified"}
|
||||
F -- "是" --> G["结局 cooldown 跳过<br>(过期自动重试)"]
|
||||
F -- "否" --> H["正常识别 + 链接<br>账本运行末尾统一落盘"]
|
||||
```
|
||||
|
||||
- 任一账本失效(目标被删 / 源被替换)自动**重新识别 + 链接**,可逆语义完整
|
||||
- 干运行不落盘、不启用跳过(展示全貌)
|
||||
|
||||
### AI 辅助识别
|
||||
|
||||
- **批量**:每批 `AI_BATCH_SIZE` 条(默认 50),`AI_MAX_CALLS` 为批次上限(默认 10),`jq` 安全转义构造输入
|
||||
- **四类任务合并为一次请求**:搜索词纠正 / 特典映射学习 / 专辑艺术家判定 / 匹配甄别
|
||||
- **判断与执行分离**:AI 只输出判断(`choice`/`season_shift`/`search_term`),命名格式化始终由脚本构建——确定性职责不交给 AI
|
||||
- **失败可逆**:AI 请求失败 → 剩余条目显式 `skip_unidentified`(下次自动重试);只有"AI 成功且判断无解"才走回退命名(降级成功,单列一类显示)
|
||||
- **防幻觉**:特典学习产物必须 ∈ TMDB 候选列表(交叉验证);跳过目录学习产物必须实际出现在输入目录链中
|
||||
|
||||
### 特典识别(Season 00)
|
||||
|
||||
判定信号 = 文件名方括号标记(含特典词)+ 特典父目录(`SPs/`/`CDs/`/`Bonus/` 等)。归属判断:媒体目录仅 1 个正片 → **电影特典直接跳过**(TMDB/Jellyfin 不收录);多个正片 → 剧集特典 Season 00。匹配用多语言别称(本地关键字 → keymap 多语言值 → TMDB season 0 候选,三级匹配:精确 > 最短前缀 > contains)。
|
||||
|
||||
$$E' = E + S_1,\qquad S' = 1 \quad\text{(自动偏移:TMDB 第一季集数 } S_1 \text{)}$$
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试与开发
|
||||
|
||||
### 一键命令
|
||||
|
||||
```shell
|
||||
make check # lint + 单元测试 + 产物一致性(等同 CI 全部关卡)
|
||||
make test # 单元测试
|
||||
make lint # bash -n + ShellCheck 静态检查(零容忍)
|
||||
make build # 构建 dist/media_organizer
|
||||
```
|
||||
|
||||
### 单元测试
|
||||
|
||||
零依赖纯 bash 测试框架:加载全部模块,每个用例文件在独立子 shell 运行(全局状态自动隔离):
|
||||
|
||||
```shell
|
||||
./tests/run.sh # 运行全部用例
|
||||
./tests/run.sh cache # 仅运行名字含 cache 的用例文件
|
||||
./tests/run.sh -v # 详细模式
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> 当前 **120 通过 / 3 已知失败**(分区回退、季偏移分区、音乐视频双命中——见 [`docs/PROJECT_REPORT.md`](docs/PROJECT_REPORT.md) 的记录)。CI 流水线([`.github/workflows/ci.yml`](.github/workflows/ci.yml))执行:ShellCheck 静态检查 → 单元测试 → 构建 → `./build.sh --check` 产物一致性校验(源码变更后必须重新构建并提交 `dist/media_organizer`,否则 CI 失败)。
|
||||
|
||||
### 源码结构
|
||||
|
||||
```text
|
||||
build.sh # 构建脚本:bashly generate → 单文件分发脚本 dist/media_organizer
|
||||
dist/ # 构建产物(media_organizer,随仓库提交,Gitea 可 raw 直接下载)
|
||||
src/
|
||||
bashly.yml # CLI 定义(选项/位置参数/互斥/帮助文本),bashly 读取
|
||||
root_command.sh # root 命令实现(参数映射/形状校验/主流水线),由 bashly 包装
|
||||
lib/
|
||||
main.sh # 常量 + 全局变量 + 配置模板(最先加载)
|
||||
log.sh strings.sh # 基础设施:日志与色彩 / 字符串工具
|
||||
config/ # 配置与规则域:config.sh / maps.sh / rules.sh
|
||||
storage/ # 数据持久化域:cache.sh / ledger.sh
|
||||
integrate/ # 外部集成域:tmdb.sh / ai.sh
|
||||
media/ # 媒体识别域:filename.sh / identify.sh / ai_resolve.sh
|
||||
pipeline/ # 执行流水线域:registry.sh / process.sh / link.sh / link_media.sh / report.sh
|
||||
tests/
|
||||
run.sh # 测试运行器(零依赖)
|
||||
cases/ # 用例文件(按功能域同名分组)
|
||||
lib/assert.sh # 断言库
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> **不要直接编辑 `dist/media_organizer`**——下次构建会覆盖你的修改。修改流程:编辑 `src/` 下对应模块 → `./tests/run.sh` → `./build.sh` → 提交源码与产物。
|
||||
|
||||
---
|
||||
|
||||
## 🐛 故障排除
|
||||
|
||||
| 问题 | 解决方法 |
|
||||
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Permission denied config.json` | `chmod 600 mo_config/config.json` |
|
||||
| TMDB API 请求超时 | 增大 `TMDB_CURL_MAX_TIME`(如 60)、`TMDB_DELAY=2` |
|
||||
| 识别准确度低 | 配置 `AI_API_KEY` 启用 AI 辅助,或添加 `match_rules.json` 规则 |
|
||||
| AI 成本过高 | 减小 `AI_MAX_CALLS`(如 5),或先用 `AI_DRY_RUN=true` 测试 |
|
||||
| 无法创建硬链接 | 确认源/目标在**同一文件系统** |
|
||||
| 特典全部未匹配(`S00xxx` 而非 `S00E`) | `--list-cache` 确认 `tmdb/tv/<id>/season/0.json` 是否存在;缺失则 `--update-cache` 强制重取或 `--refresh-cache` 清空重跑;可在 `special_maps.json` 增强匹配 |
|
||||
| 季数错乱 | 在 `season_offsets.json` 添加偏移值(键 = 剧名小写或 `id:TMDB_ID`) |
|
||||
| 目标文件被覆盖产生 `.bak_*` | v9.1 起不再备份(直接替换),遗留 `.bak_*` 可手动清理 |
|
||||
| 运行中报 `Argument list too long` | 旧版特典季 JSON 作为命令行参数所致;使用新版(独立文件存储) |
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
欢迎任何形式的贡献——Bug 报告、功能建议、文档改进、Pull Request!
|
||||
|
||||
- **提出问题**:在 [Issues](https://gitea.ppuc.lssa.fun/Shuery/MediaOrganizer/issues) 提交,请附上运行环境、复现步骤与相关日志
|
||||
- **架构约定**:动手前先读 [`CONTEXT.md`](CONTEXT.md)(领域术语表)与 [`docs/adr/`](docs/adr/)(架构决策记录)——命名与职责边界是项目的一等公民
|
||||
- **修改流程**:编辑 `src/` 模块 → `./tests/run.sh` 跑相关测试 → `./build.sh` 重新生成产物 → 提交源码与 `dist/media_organizer`
|
||||
- **代码风格**:遵循 [Google Shell Style Guide](https://google.github.io/styleguide/shellguide.html) + ShellCheck 零容忍(`./lint.sh`)
|
||||
- **测试要求**:新功能/修复请配套 `tests/cases/` 下的用例
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> 本项目配套了完整的自动化质量闭环:`make check` 一条命令覆盖 lint + 测试 + 产物一致性,CI 强制 `dist/media_organizer` 与源码同步。
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证与致谢
|
||||
|
||||
本项目基于 **MIT License** 开源(见 [LICENSE](LICENSE)),允许自由使用、修改、分发,需保留版权声明。
|
||||
|
||||
**致谢**:
|
||||
|
||||
- [TMDB(The Movie Database)](https://www.themoviedb.org) —— 媒体元数据与搜索 API
|
||||
- [bashly](https://bashly.dev) —— Bash CLI 生成器(开发期工具)
|
||||
- [Jellyfin](https://jellyfin.org) —— 开源媒体服务器,目录结构规范参照
|
||||
- [MyAnimeList / Jikan](https://jikan.moe) —— 可选兜底搜索 API
|
||||
- [Auto_Bangumi](https://github.com/EstrellaXD/Auto_Bangumi) 等开源项目 —— 部分识别思路借鉴(详见 ADR 记录)
|
||||
|
||||
---
|
||||
|
||||
_文档版本对应脚本 v9.6(`SCRIPT_VERSION` 为唯一权威版本号)。_
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# 构建脚本:bashly generate → 单文件分发脚本 dist/media_organizer
|
||||
#
|
||||
# 用法:
|
||||
# ./build.sh 构建(bashly generate + 语法/重复函数检查)
|
||||
# ./build.sh --check 仅校验当前 dist/media_organizer 是否与 src/ 最新源码一致
|
||||
#
|
||||
# 版本单一来源:src/lib/main.sh 的 SCRIPT_VERSION 是唯一权威版本号,
|
||||
# 构建时注入 bashly.yml 的 version 字段(产物 --version 与脚本内版本一致)。
|
||||
#
|
||||
# 产物入库策略:dist/media_organizer 随仓库提交(Gitea 可 raw 直接下载,
|
||||
# 免构建即用);CI 的 `./build.sh --check` 强制产物与源码同步——源码变更
|
||||
# 后必须重新构建并提交产物,否则 CI 失败。
|
||||
#
|
||||
# 依赖:bashly(gem install bashly;开发期工具,产物运行无需 Ruby)
|
||||
# ============================================================================
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
OUT="dist/media_organizer"
|
||||
BASE="$(pwd)"
|
||||
|
||||
# 定位 bashly:环境变量 BASHLY 覆盖 > PATH > 本仓库 .rv-rubies(rv 管理的 Ruby)
|
||||
find_bashly() {
|
||||
if [[ -n "${BASHLY:-}" ]] && [[ -x "$BASHLY" ]]; then
|
||||
echo "$BASHLY"
|
||||
return 0
|
||||
fi
|
||||
if command -v bashly >/dev/null 2>&1; then
|
||||
command -v bashly
|
||||
return 0
|
||||
fi
|
||||
local d
|
||||
for d in "$BASE"/.rv-rubies/*/bin/bashly; do
|
||||
[[ -x "$d" ]] && {
|
||||
echo "$d"
|
||||
return 0
|
||||
}
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! BASHLY_CMD="$(find_bashly)"; then
|
||||
echo "错误:未找到 bashly(gem install bashly,或设 BASHLY=/path/to/bashly)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 版本单一来源:从 main.sh 提取 SCRIPT_VERSION
|
||||
VERSION="$(sed -n 's/^readonly SCRIPT_VERSION="\([^"]*\)"/\1/p' src/lib/main.sh | head -1)"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "错误:无法从 src/lib/main.sh 提取 SCRIPT_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 在临时目录生成(注入版本号,不触碰工作区源码)
|
||||
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
|
||||
|
||||
# 重复函数定义检查
|
||||
dups="$(grep -oE '^[A-Za-z_][A-Za-z0-9_]*\(\) \{' "$generated" | sort | uniq -d || true)"
|
||||
if [[ -n "$dups" ]]; then
|
||||
echo "错误:存在重复函数定义:$dups" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "--check" ]]; then
|
||||
if [[ -f "$OUT" ]] && cmp -s "$generated" "$OUT"; then
|
||||
echo "OK:$OUT 与最新源码一致(v$VERSION)"
|
||||
else
|
||||
echo "DIFF:$OUT 已过期或不存在,请运行 ./build.sh 重新生成" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
mkdir -p "$(dirname "$OUT")"
|
||||
chmod +x "$generated"
|
||||
mv "$generated" "$OUT"
|
||||
trap - EXIT
|
||||
echo "构建完成:$OUT v$VERSION($(wc -l <"$OUT") 行,$(wc -c <"$OUT") 字节)"
|
||||
fi
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"TMDB_API_KEY": "<your-tmdb-api-key>",
|
||||
"TMDB_API_RA_TOKEN": "<your-tmdb-read-access-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": "<your-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"
|
||||
}
|
||||
+7007
File diff suppressed because it is too large.
Load diff
@@ -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 + 循环加载(选定)——单一数据源 + 最小侵入,特殊键后处理保留确定性。
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# 静态检查(社区规范:Google Shell Style Guide + ShellCheck 零容忍)
|
||||
#
|
||||
# 用法:
|
||||
# ./lint.sh 检查全部 bash 源码(bash -n + shellcheck)
|
||||
# ./lint.sh -v 显示每个文件的检查状态
|
||||
#
|
||||
# 规范要点(项目约定):
|
||||
# - 模块文件首行声明 `# shellcheck shell=bash`(库文件无 shebang)
|
||||
# - 跨文件共享全局变量的 SC2034/SC2004 误报在声明/使用文件头部集中 disable
|
||||
# 并注明原因;有意未使用的 read 解构/API 参数用 `_` 前缀命名
|
||||
# - 格式规范(Google Style):2 空格缩进、`[[ ]]` 测试、`local` 声明、
|
||||
# 引号包裹变量;可选运行 `shfmt -w` 格式化(本仓库未强制全量格式化,
|
||||
# 新文件/新代码建议按 shfmt 风格书写)
|
||||
# ============================================================================
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
VERBOSE=false
|
||||
[[ "${1:-}" == "-v" ]] && VERBOSE=true
|
||||
|
||||
# 检查目标:src 全部模块 + 本仓库其余 bash 脚本
|
||||
# src/lib 递归收集(按功能域子目录组织);其余脚本显式列出
|
||||
FILES=()
|
||||
mapfile -t < <(find src/lib -name '*.sh' | sort)
|
||||
FILES+=("${MAPFILE[@]}")
|
||||
FILES+=(
|
||||
src/root_command.sh
|
||||
build.sh
|
||||
lint.sh
|
||||
tests/run.sh
|
||||
tests/lib/assert.sh
|
||||
)
|
||||
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
echo "错误:未找到 shellcheck(pacman -S shellcheck / apt install shellcheck)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
fail=0
|
||||
# shellcheck disable=SC2048 # FILES 含 glob 模式,需要单词拆分来展开
|
||||
for f in ${FILES[*]}; do
|
||||
[[ -f "$f" ]] || continue
|
||||
if ! bash -n "$f"; then
|
||||
echo "❌ $f:语法错误"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
if ! shellcheck "$f"; then
|
||||
echo "❌ $f:shellcheck 未通过"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
[[ $VERBOSE == true ]] && echo "✅ $f"
|
||||
done
|
||||
|
||||
if ((fail > 0)); then
|
||||
echo "Lint 未通过" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Lint OK:全部文件通过 bash -n 与 shellcheck(0 问题)"
|
||||
@@ -0,0 +1,82 @@
|
||||
# ============================================================================
|
||||
# bashly CLI 定义
|
||||
#
|
||||
# 迁移说明(bashly 1.4 能力边界):
|
||||
# - flag 不支持可选参数 → --list-cache / --export-map 为纯 flag:
|
||||
# · --list-cache 的可选关键词经位置参数传递(root_command 中映射)
|
||||
# · --export-map 使用默认/配置目录(config.json 的 EXPORT_MAP_DIR)
|
||||
# · 原 --list-cache=KEY / --export-map=DIR 的 = 形式不再支持
|
||||
# - 位置参数与 flag 的 arg 均设置 expose: true(生成 arg_xxx/flag_xxx 变量)
|
||||
# - 互斥用 conflicts(bashly 报错退出码 1;原 _usage_error 为 2)
|
||||
# ============================================================================
|
||||
name: media_organizer
|
||||
help: |-
|
||||
Jellyfin 媒体库硬链接整理脚本 v9.6
|
||||
|
||||
自动化整理媒体库:通过 TMDB API 识别电影与电视剧,并用硬链接创建
|
||||
标准化的目录结构。支持 AI 辅助识别、全量缓存、特典映射、季数偏移、
|
||||
Music Videos 音乐视频类目、配置按类目分区。
|
||||
|
||||
模式(一次调用只取其一):
|
||||
<源目录> <目的目录> 正常整理(使用缓存)
|
||||
--update-cache <源目录> <目的目录> 整理并强制重取对应缓存
|
||||
--update-cache <源目录> 仅更新缓存,不整理
|
||||
--list-cache [关键词] 列出缓存条目(可选关键词过滤)
|
||||
--export-map [目录] 生成源→目标对照表 markdown
|
||||
--rerun <账本文件> 手动纠错重跑(不重新识别)
|
||||
|
||||
配置优先级:CLI > 环境变量 > config.json > 默认值
|
||||
|
||||
示例:
|
||||
dist/media_organizer /downloads /media
|
||||
dist/media_organizer --dry-run /downloads /media
|
||||
dist/media_organizer --list-cache 刀剑
|
||||
version: 9.6
|
||||
|
||||
args:
|
||||
- name: source_dir
|
||||
help: 源目录(--list-cache 模式下作为可选关键词)
|
||||
- name: destination_dir
|
||||
help: 目的目录
|
||||
|
||||
flags:
|
||||
- long: --automated
|
||||
short: -a
|
||||
help: 自动化模式(写入日志,跳过无法处理项目)
|
||||
conflicts:
|
||||
- --list-cache
|
||||
- long: --no-automated
|
||||
help: 取消自动化模式(覆盖环境变量/config.json 设置)
|
||||
- long: --dry-run
|
||||
help: 干运行:不创建链接、不写缓存、不写日志(网络请求照常但不落盘)
|
||||
conflicts:
|
||||
- --list-cache
|
||||
- long: --no-dry-run
|
||||
help: 取消干运行(覆盖环境变量/config.json 设置)
|
||||
- long: --refresh-cache
|
||||
help: 清空 TMDB 缓存后执行(保留特典映射等脚本学习数据;可与任意模式叠加)
|
||||
- long: --update-cache
|
||||
help: 强制重取对应缓存条目(与 --list-cache 互斥)
|
||||
conflicts:
|
||||
- --list-cache
|
||||
- long: --list-cache
|
||||
help: 列出缓存条目(可选关键词经位置参数传递)
|
||||
conflicts:
|
||||
- --update-cache
|
||||
- long: --export-map
|
||||
help: 生成源→目标对照表 markdown(目录经 config.json 的 EXPORT_MAP_DIR 或默认 mo_map)
|
||||
- long: --rerun
|
||||
arg: ledger_file
|
||||
help: 手动纠错重跑:按账本中 action="rerun" 的条目重新硬链接(可叠加 --dry-run 预览)
|
||||
- long: --src-dir
|
||||
arg: src
|
||||
help: 指定源目录(与位置参数互斥)
|
||||
- long: --dest-dir
|
||||
arg: dest
|
||||
help: 指定目的目录(与位置参数互斥)
|
||||
|
||||
# 复杂校验(bashly 声明式表达不了的,在 root_command 中实现):
|
||||
# - 目录只能通过一种方式指定:2 个位置参数,或 --src-dir + --dest-dir
|
||||
# - --list-cache 不接受目的目录参数;--dry-run/--automated 不能与 --list-cache 同用
|
||||
# - --update-cache 需要源目录
|
||||
# - 整理模式需要源目录与目的目录
|
||||
@@ -0,0 +1,369 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034 # 跨文件共享全局变量(TMDB_AUTH_TOKEN/TMDB_USE_BEARER,tmdb.sh 消费)
|
||||
|
||||
################################################################################
|
||||
# 配置加载与认证
|
||||
################################################################################
|
||||
|
||||
# 查找配置文件。优先级:执行目录 mo_config > 脚本目录 mo_config。
|
||||
# v9.3 起文件名为 config.json(JSON 格式,目录名 mo_config 已含 mo 标识);
|
||||
# 旧文本格式 env(v9.2)/ mo_env(v9.0)自动转换为 config.json(干运行只读回退)。
|
||||
find_config_file() {
|
||||
if [[ -f "$PWD/mo_config/config.json" ]]; then
|
||||
echo "$PWD/mo_config/config.json"
|
||||
return 0
|
||||
elif [[ -f "$SCRIPT_DIR/mo_config/config.json" ]]; then
|
||||
echo "$SCRIPT_DIR/mo_config/config.json"
|
||||
return 0
|
||||
fi
|
||||
# 旧文本格式:env / mo_env → 转换为 JSON 格式的 config.json
|
||||
local legacy=""
|
||||
[[ -f "$PWD/mo_config/env" ]] && legacy="$PWD/mo_config/env"
|
||||
[[ -z "$legacy" && -f "$SCRIPT_DIR/mo_config/env" ]] && legacy="$SCRIPT_DIR/mo_config/env"
|
||||
[[ -z "$legacy" && -f "$PWD/mo_config/mo_env" ]] && legacy="$PWD/mo_config/mo_env"
|
||||
[[ -z "$legacy" && -f "$SCRIPT_DIR/mo_config/mo_env" ]] && legacy="$SCRIPT_DIR/mo_config/mo_env"
|
||||
if [[ -n "$legacy" ]]; then
|
||||
if [[ "${DRY_RUN:-false}" == "true" ]]; then
|
||||
echo "$legacy"
|
||||
else
|
||||
local new_path="${legacy%/*}/config.json"
|
||||
if convert_env_to_json "$legacy" "$new_path" 2>/dev/null; then
|
||||
chmod 600 "$new_path" 2>/dev/null || true
|
||||
_log 信息 "已迁移旧版配置: $legacy -> $new_path"
|
||||
rm -f "$legacy"
|
||||
echo "$new_path"
|
||||
else
|
||||
echo "$legacy"
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 获取文件所有者(兼容 GNU/BSD/BusyBox stat)。
|
||||
# 依次尝试:GNU stat -c '%U' -> BSD stat -f '%Su' -> 数字 UID + /etc/passwd -> ls -ld -> find -printf。
|
||||
get_file_owner() {
|
||||
local filepath="$1"
|
||||
local owner uid
|
||||
owner=$(stat -c '%U' "$filepath" 2>/dev/null)
|
||||
[[ -n "$owner" ]] && {
|
||||
echo "$owner"
|
||||
return 0
|
||||
}
|
||||
owner=$(stat -f '%Su' "$filepath" 2>/dev/null)
|
||||
[[ -n "$owner" ]] && {
|
||||
echo "$owner"
|
||||
return 0
|
||||
}
|
||||
# BusyBox 回退:数字 UID,再从 /etc/passwd 解析用户名
|
||||
uid=$(stat -c '%u' "$filepath" 2>/dev/null)
|
||||
if [[ -n "$uid" ]]; then
|
||||
owner=$(awk -F: -v u="$uid" '$3==u {print $1; exit}' /etc/passwd 2>/dev/null)
|
||||
echo "${owner:-$uid}"
|
||||
return 0
|
||||
fi
|
||||
# ls -ld 解析所有者(BusyBox 可靠,stat/find -printf 可能不可用)
|
||||
# shellcheck disable=SC2012
|
||||
owner=$(ls -ld "$filepath" 2>/dev/null | awk '{print $3}')
|
||||
[[ -n "$owner" ]] && {
|
||||
echo "$owner"
|
||||
return 0
|
||||
}
|
||||
# GNU find 兜底:-printf 解析所有者
|
||||
find "$filepath" -maxdepth 0 -printf '%u' 2>/dev/null
|
||||
}
|
||||
|
||||
# 获取文件权限字符串(兼容 GNU/BSD/BusyBox stat)。
|
||||
# 依次尝试:GNU stat -c '%A' -> BSD stat -f '%Sp' -> stat -c '%a' -> ls -ld -> find -printf。
|
||||
get_file_permission() {
|
||||
local filepath="$1"
|
||||
local perm
|
||||
perm=$(stat -c '%A' "$filepath" 2>/dev/null)
|
||||
[[ -n "$perm" ]] && {
|
||||
echo "$perm"
|
||||
return 0
|
||||
}
|
||||
perm=$(stat -f '%Sp' "$filepath" 2>/dev/null)
|
||||
[[ -n "$perm" ]] && {
|
||||
echo "$perm"
|
||||
return 0
|
||||
}
|
||||
# BusyBox 回退:数字权限(如 600)
|
||||
perm=$(stat -c '%a' "$filepath" 2>/dev/null)
|
||||
[[ -n "$perm" ]] && {
|
||||
echo "$perm"
|
||||
return 0
|
||||
}
|
||||
# ls -ld 解析权限字段(BusyBox 可靠,stat/find -printf 可能不可用)
|
||||
# shellcheck disable=SC2012
|
||||
perm=$(ls -ld "$filepath" 2>/dev/null | awk '{print $1}')
|
||||
[[ -n "$perm" ]] && {
|
||||
echo "$perm"
|
||||
return 0
|
||||
}
|
||||
# GNU find 兜底:-printf 解析数字权限
|
||||
find "$filepath" -maxdepth 0 -printf '%m' 2>/dev/null
|
||||
}
|
||||
|
||||
# 检查密钥文件安全性:必须为当前用户所有,权限为 600。
|
||||
# 严格校验,不允许跳过(防止 config.json 被篡改)。
|
||||
# 读取所有者/权限时依赖 get_file_* 的完整降级链(stat -> ls -> find),
|
||||
# 确保在 stat/find -printf 不可用的系统上仍能取得正确值完成校验。
|
||||
check_secure_file() {
|
||||
local filepath="$1"
|
||||
|
||||
if [[ ! -f "$filepath" ]]; then
|
||||
_log 错误 "密钥文件不存在:$filepath"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local current_user file_owner
|
||||
current_user=$(id -un 2>/dev/null || true)
|
||||
|
||||
file_owner=$(get_file_owner "$filepath")
|
||||
if [[ -z "$file_owner" ]]; then
|
||||
_log 错误 "无法读取文件所有者(系统 stat 命令不兼容)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 仅当能获取当前用户名时才校验所有者(嵌入式系统可能无法解析)
|
||||
if [[ -n "$current_user" ]] && [[ "$file_owner" != "$current_user" ]]; then
|
||||
_log 错误 "文件所有者应为当前用户"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local file_perm
|
||||
file_perm=$(get_file_permission "$filepath")
|
||||
if [[ -z "$file_perm" ]]; then
|
||||
_log 错误 "无法读取文件权限"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 接受符号格式(-rw-------)或数字格式(600)
|
||||
if [[ "$file_perm" != "-rw-------" ]] && [[ "$file_perm" != "600" ]]; then
|
||||
_log 错误 "文件权限应为 600,当前为 $file_perm"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# 从配置文件解析指定键的值。
|
||||
# 支持两种格式:JSON(config.json,主格式)与旧 KEY=VALUE 文本(env/mo_env,
|
||||
# 迁移期或干运行只读回退时使用)。按文件内容自动识别。
|
||||
parse_config_key() {
|
||||
local config_file="$1"
|
||||
local key="$2"
|
||||
|
||||
if jq -e . "$config_file" >/dev/null 2>&1; then
|
||||
# JSON:值统一转字符串(数字/布尔 → 文本)
|
||||
jq -r --arg k "$key" 'if has($k) then (.[$k] | tostring) else empty end' \
|
||||
"$config_file" 2>/dev/null || true
|
||||
else
|
||||
# 旧 KEY=VALUE 文本格式
|
||||
grep -E "^[[:space:]]*${key}=" "$config_file" 2>/dev/null |
|
||||
head -1 |
|
||||
sed -E 's/^[[:space:]]*'"${key}"'=//; s/[[:space:]]*$//' || true
|
||||
fi
|
||||
}
|
||||
|
||||
# 渲染主配置模板 JSON(从 CONFIG_DEFS 单一数据源生成,替代原 CONFIG_TEMPLATE 常量)。
|
||||
# 模板生成(generate_config_template)与白名单键来源(load_config / convert_env_to_json)
|
||||
# 都经此函数——新增配置键只需改 CONFIG_DEFS 一处。
|
||||
render_config_template() {
|
||||
local json="{}" def key val
|
||||
for def in "${CONFIG_DEFS[@]}"; do
|
||||
key="${def%%|*}"
|
||||
val="${def#*|}"
|
||||
json=$(jq -n --argjson o "$json" --arg k "$key" --arg v "$val" '$o + {($k): $v}' 2>/dev/null) || return 1
|
||||
done
|
||||
printf '%s\n' "$json"
|
||||
}
|
||||
|
||||
# 旧 KEY=VALUE 文本配置 → config.json(仅白名单键;注释行忽略)。
|
||||
# 原子写:临时文件 + rename;umask 077 保证权限 600。
|
||||
convert_env_to_json() {
|
||||
local src="$1" dst="$2"
|
||||
local out="{}" key val
|
||||
while IFS= read -r key; do
|
||||
[[ -z "$key" ]] && continue
|
||||
val=$(grep -E "^[[:space:]]*${key}=" "$src" 2>/dev/null |
|
||||
head -1 | sed -E 's/^[[:space:]]*'"${key}"'=//; s/[[:space:]]*$//')
|
||||
out=$(jq -n --argjson o "$out" --arg k "$key" --arg v "$val" '$o + {($k): $v}')
|
||||
done < <(render_config_template | jq -r 'keys[]' 2>/dev/null)
|
||||
local tmp="${dst}.tmp.$$"
|
||||
(umask 077 && printf '%s\n' "$out" | jq . >"$tmp" 2>/dev/null) || {
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
}
|
||||
mv "$tmp" "$dst" 2>/dev/null
|
||||
}
|
||||
|
||||
# 加载 config.json 配置并应用环境变量默认值。
|
||||
# 配置优先级链:CLI > 环境变量 > config.json 文件 > CONFIG_DEFS 默认值。
|
||||
# 采用白名单键加载:键名集合取自 CONFIG_DEFS(render_config_template),逐键提取,
|
||||
# 文件内容永不执行(配置即数据);白名单外的自定义键不生效。
|
||||
load_config() {
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# 读取 config.json 白名单键值(配置文件存在且通过安全检查时)
|
||||
local -A moenv=()
|
||||
if CONFIG_FILE=$(find_config_file); then
|
||||
if ! check_secure_file "$CONFIG_FILE"; then
|
||||
exit 1
|
||||
fi
|
||||
local key
|
||||
while IFS= read -r key; do
|
||||
[[ -z "$key" ]] && continue
|
||||
moenv["$key"]=$(parse_config_key "$CONFIG_FILE" "$key")
|
||||
done < <(
|
||||
render_config_template | jq -r 'keys[]' 2>/dev/null | sort -u
|
||||
)
|
||||
fi
|
||||
|
||||
# 循环加载全部标量键(CLI/环境变量 > config.json > CONFIG_DEFS 默认值):
|
||||
# 已设值(环境变量/CLI)优先;否则取 config 文件值;再否则 CONFIG_DEFS 默认。
|
||||
# 特殊键的 locale 默认/数组拆分/数值校验在循环后做后处理。
|
||||
local def
|
||||
for def in "${CONFIG_DEFS[@]}"; do
|
||||
local ckey="${def%%|*}" cval="${def#*|}"
|
||||
if [[ -z "${!ckey:-}" ]]; then
|
||||
printf -v "$ckey" '%s' "${moenv[$ckey]:-$cval}"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 后处理(循环已加载全部标量键;以下为 locale 默认/数组/校验等特殊逻辑)----
|
||||
|
||||
# 模式标志(循环已按 CONFIG_DEFS 加载;此处仅防环境变量空串覆盖——保留原语义)
|
||||
AUTOMATED="${AUTOMATED:-false}"
|
||||
DRY_RUN="${DRY_RUN:-false}"
|
||||
|
||||
# 目的目录根名(默认跟随系统语言:locale 含 zh → 中文;FOLDER_* 显式设置覆盖)。
|
||||
# 名称与 Jellyfin 官方媒体库名一致:Movies=电影、Shows=节目、Music=音乐、
|
||||
# MusicVideos=音乐视频;Unknown=未知为脚本兜底分类。
|
||||
local _lang="${LANG:-${LC_ALL:-}}"
|
||||
if [[ "$_lang" == zh* ]]; then
|
||||
FOLDER_MOVIES="${FOLDER_MOVIES:-电影}"
|
||||
FOLDER_SHOWS="${FOLDER_SHOWS:-节目}"
|
||||
FOLDER_MUSIC="${FOLDER_MUSIC:-音乐}"
|
||||
FOLDER_MUSICVIDEOS="${FOLDER_MUSICVIDEOS:-音乐视频}"
|
||||
FOLDER_UNKNOWN="${FOLDER_UNKNOWN:-未知}"
|
||||
else
|
||||
FOLDER_MOVIES="${FOLDER_MOVIES:-Movies}"
|
||||
FOLDER_SHOWS="${FOLDER_SHOWS:-Shows}"
|
||||
FOLDER_MUSIC="${FOLDER_MUSIC:-Music}"
|
||||
FOLDER_MUSICVIDEOS="${FOLDER_MUSICVIDEOS:-MusicVideos}"
|
||||
FOLDER_UNKNOWN="${FOLDER_UNKNOWN:-Unknown}"
|
||||
fi
|
||||
|
||||
# 识别池并发数(1-8)
|
||||
[[ "$MEDIA_WORKERS" =~ ^[1-8]$ ]] || MEDIA_WORKERS=4
|
||||
|
||||
# 自动化模式日志初始化(LOG_FILE/SKIP_LOG_FILE/LOG_ROTATE_MB 已由循环加载默认值)
|
||||
if [[ "$AUTOMATED" == "true" ]]; then
|
||||
[[ "$LOG_ROTATE_MB" =~ ^[0-9]+$ ]] || LOG_ROTATE_MB=10
|
||||
# 干运行不创建日志目录(零持久化副作用)
|
||||
if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$SKIP_LOG_FILE")" \
|
||||
2>/dev/null || true
|
||||
# 日志轮转:超阈值时滚动保留 5 份(.1 最新)
|
||||
if ((LOG_ROTATE_MB > 0)); then
|
||||
local _limit=$((LOG_ROTATE_MB * 1024 * 1024)) _lf _size
|
||||
for _lf in "$LOG_FILE" "$SKIP_LOG_FILE"; do
|
||||
[[ -f "$_lf" ]] || continue
|
||||
_size=$(stat -c '%s' "$_lf" 2>/dev/null) || _size=0
|
||||
if ((_size > _limit)); then
|
||||
rm -f "${_lf}.5"
|
||||
mv -f "${_lf}.4" "${_lf}.5" 2>/dev/null || true
|
||||
mv -f "${_lf}.3" "${_lf}.4" 2>/dev/null || true
|
||||
mv -f "${_lf}.2" "${_lf}.3" 2>/dev/null || true
|
||||
mv -f "${_lf}.1" "${_lf}.2" 2>/dev/null || true
|
||||
mv -f "$_lf" "${_lf}.1"
|
||||
_log 信息 "日志已轮转: $_lf -> ${_lf}.1"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 解析扩展名配置(mka 为官方音频容器;纯音频 mkv/mp4/webm 需用户自行改容器为 mka/m4a)
|
||||
IFS=',' read -ra VIDEO_EXTS <<<"${VIDEO_EXTS}"
|
||||
IFS=',' read -ra AUDIO_EXTS <<<"${AUDIO_EXTS}"
|
||||
IFS=',' read -ra SUB_EXTS <<<"${SUB_EXTS}"
|
||||
|
||||
# AI 批大小校验
|
||||
[[ "$AI_BATCH_SIZE" =~ ^[0-9]+$ ]] && [[ "$AI_BATCH_SIZE" -ge 1 ]] || AI_BATCH_SIZE=50
|
||||
|
||||
# 命名模板(v9.5):用户自定义命名格式。占位符语法见 render_naming_template:
|
||||
# {name} 变量值;{name:NN} 数字补零;{?name:text} 条件段(name 非空才渲染 text)。
|
||||
# 默认模板与 v9.4 硬编码命名完全一致(仅把格式外置,行为不变);
|
||||
# 默认值集中定义于 rules.sh 的 naming_template_default(render_naming 兜底共用)。
|
||||
NAMING_MOVIE="${NAMING_MOVIE:-$(naming_template_default NAMING_MOVIE)}"
|
||||
NAMING_SHOW="${NAMING_SHOW:-$(naming_template_default NAMING_SHOW)}"
|
||||
NAMING_SEASON="${NAMING_SEASON:-$(naming_template_default NAMING_SEASON)}"
|
||||
NAMING_EPISODE="${NAMING_EPISODE:-$(naming_template_default NAMING_EPISODE)}"
|
||||
NAMING_SPECIAL="${NAMING_SPECIAL:-$(naming_template_default NAMING_SPECIAL)}"
|
||||
NAMING_MUSIC="${NAMING_MUSIC:-$(naming_template_default NAMING_MUSIC)}"
|
||||
NAMING_MUSICVIDEO="${NAMING_MUSICVIDEO:-$(naming_template_default NAMING_MUSICVIDEO)}"
|
||||
# 模板校验:未知占位符打印警告(渲染时按空值处理,防拼写错误静默丢字段)
|
||||
validate_naming_templates
|
||||
}
|
||||
|
||||
# 选择 TMDB 认证方式并校验。无密钥时交互生成 config.json 模板。
|
||||
select_auth() {
|
||||
TMDB_API_KEY="${TMDB_API_KEY:-}"
|
||||
TMDB_API_RA_TOKEN="${TMDB_API_RA_TOKEN:-}"
|
||||
|
||||
if [[ -n "$TMDB_API_RA_TOKEN" ]]; then
|
||||
TMDB_AUTH_TOKEN="$TMDB_API_RA_TOKEN"
|
||||
TMDB_USE_BEARER=true
|
||||
elif [[ -n "$TMDB_API_KEY" ]]; then
|
||||
TMDB_AUTH_TOKEN="$TMDB_API_KEY"
|
||||
else
|
||||
if [[ "$AUTOMATED" != "true" ]]; then
|
||||
_log 信息 "未检测到 TMDB API 密钥。是否生成 config.json 模板?(y/N)"
|
||||
read -r answer
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
generate_config_template
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
_log 错误 "未提供 TMDB 认证信息。"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 生成 config.json 配置模板(仅交互式调用)。
|
||||
generate_config_template() {
|
||||
mkdir -p "$PWD/mo_config" 2>/dev/null || {
|
||||
_log 错误 "无法创建配置目录 $PWD/mo_config"
|
||||
return 1
|
||||
}
|
||||
local tmpl
|
||||
tmpl=$(render_config_template) || {
|
||||
_log 错误 "配置模板渲染失败"
|
||||
return 1
|
||||
}
|
||||
# 子 shell 设置 umask 077,确保创建的文件权限即 600
|
||||
(umask 077 && printf '%s\n' "$tmpl" >"$PWD/mo_config/config.json") || {
|
||||
_log 错误 "无法写入配置文件 $PWD/mo_config/config.json"
|
||||
return 1
|
||||
}
|
||||
# 兜底:确保权限为 600(不静默失败)
|
||||
chmod 600 "$PWD/mo_config/config.json" || {
|
||||
_log 错误 "无法设置文件权限为 600:$PWD/mo_config/config.json"
|
||||
return 1
|
||||
}
|
||||
_log 信息 "模板已创建: $PWD/mo_config/config.json"
|
||||
}
|
||||
|
||||
# 检查依赖命令是否可用。
|
||||
check_dependencies() {
|
||||
for cmd in curl jq ffprobe; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
_log 错误 "缺少命令 $cmd"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034 # 跨文件共享全局变量(SPECIAL_MAP/SPECIAL_MAP_FILE 等,声明于 main.sh)
|
||||
|
||||
################################################################################
|
||||
# 特典映射、季偏移与类目分区配置
|
||||
################################################################################
|
||||
|
||||
# 迁移单个旧版文件(旧文件名/旧位置 → 新文件名/新位置)。
|
||||
# 干运行只读回退旧路径(零持久化副作用,不搬运不创建);迁移失败同样回退旧路径。
|
||||
# 返回 stdout = 实际应使用的路径。
|
||||
_migrate_legacy_file() {
|
||||
local legacy="$1" new_path="$2"
|
||||
[[ -f "$legacy" ]] || {
|
||||
echo "$new_path"
|
||||
return 0
|
||||
}
|
||||
if [[ "${DRY_RUN:-false}" == "true" ]]; then
|
||||
echo "$legacy"
|
||||
return 0
|
||||
fi
|
||||
if mkdir -p "$(dirname "$new_path")" 2>/dev/null && mv "$legacy" "$new_path" 2>/dev/null; then
|
||||
_log 信息 "已迁移旧版文件: $legacy -> $new_path"
|
||||
echo "$new_path"
|
||||
else
|
||||
echo "$legacy"
|
||||
fi
|
||||
}
|
||||
|
||||
# 解析用户配置类文件(特典映射/特典词表/跳过目录词表/季偏移/特典类别)的默认路径。
|
||||
# 这些文件用户可编辑、脚本也会写回(AI 学习/运行时生成)——本质是配置数据,
|
||||
# 统一存放于配置目录 mo_config/(与可再生的 TMDB 缓存 mo_cache/tmdb/ 分离)。
|
||||
# 旧版自动迁移:新位置不存在时,按优先级检查旧位置(mo_config/、mo_cache/media_organizer/)
|
||||
# × 旧名(special_keymap/special_words/skip_dirs/season_offset 及更早的 mo_ 前缀名),
|
||||
# 首个命中的迁移到新位置;脚本目录同理。
|
||||
# 参数:name = 新文件名;$@ = 旧文件名列表(按优先级)。
|
||||
# 返回 stdout = 实际应使用的路径。
|
||||
resolve_mo_config_file() {
|
||||
local name="$1"
|
||||
shift
|
||||
local pwd_new="$PWD/mo_config/$name"
|
||||
local script_new="$SCRIPT_DIR/mo_config/$name"
|
||||
if [[ -f "$pwd_new" ]]; then
|
||||
echo "$pwd_new"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$script_new" ]]; then
|
||||
echo "$script_new"
|
||||
return 0
|
||||
fi
|
||||
# 旧候选:执行目录(mo_config/ 优先于 mo_cache/media_organizer/),脚本目录同理
|
||||
local root old
|
||||
for root in "$PWD/mo_config" "$PWD/mo_cache/media_organizer"; do
|
||||
for old in "$@"; do
|
||||
if [[ -f "$root/$old" ]]; then
|
||||
_migrate_legacy_file "$root/$old" "$pwd_new"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
done
|
||||
for root in "$SCRIPT_DIR/mo_config" "$SCRIPT_DIR/mo_cache/media_organizer"; do
|
||||
for old in "$@"; do
|
||||
if [[ -f "$root/$old" ]]; then
|
||||
_migrate_legacy_file "$root/$old" "$script_new"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "$pwd_new"
|
||||
}
|
||||
|
||||
# 判定 JSON 文件是否为"类目分区形态"(v9.6):
|
||||
# 分区形态:顶层全部键 ∈ MO_CATEGORY_KEYS(movie/tv/music/musicvideo/video/audio/default/global)
|
||||
# —— 每分区一份表,查询按"类目分区 → default 分区 → 全局表"回退。
|
||||
# 全局形态:其余任意对象(旧版单表,行为不变)。
|
||||
# 混合形态(部分键是类目键、部分不是)→ 按全局形态处理并警告(避免把类目键当数据键)。
|
||||
is_category_partitioned() {
|
||||
local file="$1"
|
||||
local keys total
|
||||
keys=$(jq -r 'keys | length' "$file" 2>/dev/null) || return 1
|
||||
total=$(jq -r 'type' "$file" 2>/dev/null)
|
||||
[[ "$total" == "object" ]] || return 1
|
||||
[[ "$keys" == "0" ]] && return 1
|
||||
if jq -e 'keys | all(. == "movie" or . == "tv" or . == "music" or . == "musicvideo" or . == "video" or . == "audio" or . == "default" or . == "global")' "$file" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
# 混合形态:顶层键是否与保留键集合有交集(有交集 → 疑似写错的分区形态,警告)
|
||||
if jq -e 'keys | any(. == "movie" or . == "tv" or . == "music" or . == "musicvideo" or . == "video" or . == "audio" or . == "default" or . == "global")' "$file" >/dev/null 2>&1; then
|
||||
_log 警告 "配置文件疑似类目分区格式错误(应全部为类目键 movie/tv/music/musicvideo/video/audio/default):$file(按全局表处理)"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 初始化特典映射:解析默认路径,若文件不存在则交互创建。
|
||||
# 默认位置:配置目录 mo_config/special_maps.json(用户可编辑,AI 学习结果也写回此文件);
|
||||
# 旧版文件(special_keymap.json/mo_special_keymap.json 等)自动迁移。
|
||||
# v9.6 支持类目分区形态:{"tv": {...}, "musicvideo": {...}, "default": {...}}。
|
||||
init_special_map() {
|
||||
local file="$1"
|
||||
# 解析默认路径(优先级:环境变量 > 执行目录 mo_config > 脚本目录 mo_config)
|
||||
if [[ -z "$file" ]]; then
|
||||
file=$(resolve_mo_config_file "special_maps.json" "special_keymap.json" "mo_special_keymap.json")
|
||||
SPECIAL_MAP_FILE="$file"
|
||||
fi
|
||||
if [[ ! -f "$file" ]]; then
|
||||
if [[ "$AUTOMATED" == "true" ]]; then
|
||||
_log 警告 "未找到特典映射文件,自动化模式跳过创建: $file"
|
||||
else
|
||||
_log 信息 "未找到特典映射文件: $file"
|
||||
_log 信息 "是否创建默认配置文件?(y/N)"
|
||||
read -r answer || true # EOF(非交互)时跳过创建
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
mkdir -p "$(dirname "$file")" 2>/dev/null || true
|
||||
# 子 shell 设置 umask 077,确保创建的文件权限即 600
|
||||
(umask 077 && printf '%s\n' "$SPECIAL_KEYMAP_TEMPLATE" | jq . >"$file" 2>/dev/null) || true
|
||||
if [[ ! -s "$file" ]]; then
|
||||
echo '{}' >"$file"
|
||||
fi
|
||||
# 兜底:确保权限为 600(不静默失败)
|
||||
chmod 600 "$file" 2>/dev/null || _log 警告 "无法设置文件权限为 600: $file"
|
||||
_log 信息 "已创建特典映射文件: $file"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
load_special_map "$file"
|
||||
}
|
||||
|
||||
# 初始化跳过目录词表(skip_directories.json):解析默认路径,缺失时交互创建或使用内置默认。
|
||||
# 默认位置:配置目录 mo_config/(用户可编辑,AI 学习会写回新目录);旧版文件自动迁移。
|
||||
# 语义 = 不作为媒体名目录的目录词(特典/附带 + 分类目录);内容不当作正则(精确匹配)。
|
||||
# v9.6 支持类目分区形态:{"video": [...], "music": [...], "default": [...]}——
|
||||
# 视频流程(movie/tv/musicvideo 的目录语义判断)查 video 分区,音频流程查 music 分区,
|
||||
# 未配置回退 default 分区 → 全局表。**分区形态 = 完整语义**(不叠加内置默认;
|
||||
# 通用词放 default 分区)——避免 music 流程误命中 video 侧内置词(如 "sps")。
|
||||
init_skip_dirs() {
|
||||
local file="$1"
|
||||
if [[ -z "$file" ]]; then
|
||||
file=$(resolve_mo_config_file "skip_directories.json" "skip_dirs.json" "mo_skip_dirs.json")
|
||||
SKIP_DIRS_FILE="$file"
|
||||
fi
|
||||
if [[ ! -f "$file" ]]; then
|
||||
if [[ "$AUTOMATED" == "true" ]]; then
|
||||
_log 警告 "未找到跳过目录词表文件,自动化模式使用内置默认: $file"
|
||||
else
|
||||
_log 信息 "未找到跳过目录词表文件: $file"
|
||||
_log 信息 "是否创建默认配置文件?(y/N)"
|
||||
read -r answer || true # EOF(非交互)时跳过创建
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
(umask 077 && printf '%s\n' "$SKIP_DIRS_TEMPLATE" | jq . >"$file" 2>/dev/null) || true
|
||||
if [[ ! -s "$file" ]]; then
|
||||
echo '[]' >"$file"
|
||||
fi
|
||||
chmod 600 "$file" 2>/dev/null || _log 警告 "无法设置文件权限为 600: $file"
|
||||
_log 信息 "已创建跳过目录词表文件: $file"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
SKIP_DIRS=()
|
||||
SKIP_DIRS_LOWER=()
|
||||
SKIP_DIRS_CAT=()
|
||||
local partitioned=false
|
||||
if [[ -f "$file" ]] && jq -e 'type == "array"' "$file" >/dev/null 2>&1; then
|
||||
mapfile -t SKIP_DIRS < <(jq -r '.[] | select(. != "")' "$file" 2>/dev/null)
|
||||
elif [[ -f "$file" ]] && is_category_partitioned "$file"; then
|
||||
partitioned=true
|
||||
# 分区形态:分区词存入 SKIP_DIRS_CAT(键 = "类目|小写目录名");全局数组保持空
|
||||
# (完整语义,不叠加内置默认——通用词由 default 分区提供)
|
||||
local cat d
|
||||
while IFS=$'\t' read -r cat d; do
|
||||
[[ -z "$cat" || -z "$d" ]] && continue
|
||||
SKIP_DIRS_CAT["$cat|${d,,}"]=1
|
||||
done < <(jq -r 'to_entries[] | .key as $c | .value[]? | select(. != "") | "\($c)\t\(.)"' "$file" 2>/dev/null)
|
||||
_log 调试 "跳过目录词表(分区): ${#SKIP_DIRS_CAT[@]} 项"
|
||||
fi
|
||||
# 全局形态(或文件缺失):数组为空 → 内置默认(查询兜底);分区形态不回退内置
|
||||
if [[ "$partitioned" != "true" ]] && [[ ${#SKIP_DIRS[@]} -eq 0 ]]; then
|
||||
mapfile -t SKIP_DIRS < <(echo "$SKIP_DIRS_TEMPLATE" | jq -r '.[]')
|
||||
fi
|
||||
local d
|
||||
for d in "${SKIP_DIRS[@]}"; do
|
||||
SKIP_DIRS_LOWER+=("${d,,}")
|
||||
done
|
||||
_log 调试 "跳过目录词表 ${#SKIP_DIRS[@]} 个(全局兜底)"
|
||||
}
|
||||
|
||||
# 初始化特典识别词表(special_keywords.json):解析默认路径,缺失时交互创建或使用内置默认。
|
||||
# 默认位置:配置目录 mo_config/(用户可编辑,AI 学习会写回新词);旧版文件自动迁移。
|
||||
# 词表语义 = 特典类别词(Menu/CM/PV 等);匹配对空格不敏感;内容不当作正则。
|
||||
# v9.6 支持类目分区形态:{"tv": [...], "musicvideo": [...], "default": [...]}——
|
||||
# tv 分区 = 特典判定词(detect_special);musicvideo 分区 = 音乐视频判定词
|
||||
# (目录信号 + 文件名信号);查询按"类目分区 → default 分区 → 全局表 → 内置默认"。
|
||||
init_special_words() {
|
||||
local file="$1"
|
||||
if [[ -z "$file" ]]; then
|
||||
file=$(resolve_mo_config_file "special_keywords.json" "special_words.json" "mo_special_words.json")
|
||||
SPECIAL_WORDS_FILE="$file"
|
||||
fi
|
||||
if [[ ! -f "$file" ]]; then
|
||||
if [[ "$AUTOMATED" == "true" ]]; then
|
||||
_log 警告 "未找到特典词表文件,自动化模式使用内置默认: $file"
|
||||
else
|
||||
_log 信息 "未找到特典词表文件: $file"
|
||||
_log 信息 "是否创建默认配置文件?(y/N)"
|
||||
read -r answer || true # EOF(非交互)时跳过创建
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
mkdir -p "$(dirname "$file")" 2>/dev/null || true
|
||||
(umask 077 && printf '%s\n' "$SPECIAL_WORDS_TEMPLATE" | jq . >"$file" 2>/dev/null) || true
|
||||
if [[ ! -s "$file" ]]; then
|
||||
echo '[]' >"$file"
|
||||
fi
|
||||
chmod 600 "$file" 2>/dev/null || _log 警告 "无法设置文件权限为 600: $file"
|
||||
_log 信息 "已创建特典词表文件: $file"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# 加载词表(全局形态 = JSON 数组);缺失/非法/空 → 内置默认(查询兜底)
|
||||
SPECIAL_WORDS=()
|
||||
SPECIAL_WORDS_CAT=()
|
||||
MUSICVIDEO_WORDS=()
|
||||
# 音乐视频判定词:内置默认(独立于 tv 特典词表,避免 "sp" 命中 "SPs" 目录等交叉误判)
|
||||
mapfile -t MUSICVIDEO_WORDS < <(echo "$MUSICVIDEO_WORDS_TEMPLATE" | jq -r '.[]')
|
||||
if [[ -f "$file" ]] && jq -e 'type == "array"' "$file" >/dev/null 2>&1; then
|
||||
mapfile -t SPECIAL_WORDS < <(jq -r '.[] | select(. != "")' "$file" 2>/dev/null)
|
||||
elif [[ -f "$file" ]] && is_category_partitioned "$file"; then
|
||||
# 分区形态:词按归一化(去空格+小写)存入 SPECIAL_WORDS_CAT(键 = "类目|词")
|
||||
local cat w w_norm
|
||||
while IFS=$'\t' read -r cat w; do
|
||||
[[ -z "$cat" || -z "$w" ]] && continue
|
||||
w_norm="${w//[[:space:]]/}"
|
||||
w_norm="${w_norm,,}"
|
||||
[[ -z "$w_norm" ]] && continue
|
||||
SPECIAL_WORDS_CAT["$cat|$w_norm"]=1
|
||||
done < <(jq -r 'to_entries[] | .key as $c | .value[]? | select(. != "") | "\($c)\t\(.)"' "$file" 2>/dev/null)
|
||||
_log 调试 "特典识别词表(分区): ${#SPECIAL_WORDS_CAT[@]} 项"
|
||||
fi
|
||||
if [[ ${#SPECIAL_WORDS[@]} -eq 0 ]]; then
|
||||
mapfile -t SPECIAL_WORDS < <(echo "$SPECIAL_WORDS_TEMPLATE" | jq -r '.[]')
|
||||
fi
|
||||
_log 调试 "特典识别词表 ${#SPECIAL_WORDS[@]} 个(全局兜底);音乐视频词表 ${#MUSICVIDEO_WORDS[@]} 个"
|
||||
}
|
||||
|
||||
# 加载特典映射(JSON 对象:{"本地关键字符串": 值} 或 v9.6 分区形态 {"tv": {...}})。
|
||||
# 构建 SPECIAL_MAP(全局表)与 SPECIAL_MAP_CAT(类目分区表)。
|
||||
# 值可为:① JSON 数组(直接多值,如 ["Menu","菜单","メニュー"]);
|
||||
# ② 字符串(引用另一键的数组,如 "menu01": "menu" 复用 menu 的数组)。
|
||||
# —— 不同压制组特典命名不同(Menu01/Menu 01/MENU01),多键共享同一组 TMDB 关键字,避免重复定义。
|
||||
load_special_map() {
|
||||
local map_file="$1"
|
||||
[[ ! -f "$map_file" ]] && return
|
||||
|
||||
if ! jq -e 'type == "object"' "$map_file" >/dev/null 2>&1; then
|
||||
_log 错误 "特典映射文件必须为 JSON 对象格式: $map_file"
|
||||
return
|
||||
fi
|
||||
|
||||
SPECIAL_MAP=()
|
||||
RAW_SPECIAL_MAP=()
|
||||
SPECIAL_MAP_CAT=()
|
||||
RAW_SPECIAL_MAP_CAT=()
|
||||
|
||||
if is_category_partitioned "$map_file"; then
|
||||
# 分区形态:第一遍读原始值("类目|键" → 数组或字符串引用)
|
||||
local cat local_str raw
|
||||
while IFS=$'\t' read -r cat local_str raw; do
|
||||
[[ -z "$cat" || -z "$local_str" ]] && continue
|
||||
RAW_SPECIAL_MAP_CAT["$cat|${local_str,,}"]="$raw"
|
||||
done < <(jq -r 'to_entries[] | .key as $c | .value | to_entries[] | "\($c)\t\(.key)\t\(.value | @json)"' "$map_file" 2>/dev/null)
|
||||
# 第二遍:展开引用(同分区优先,其次全局表),值统一为 JSON 数组
|
||||
local ck
|
||||
for ck in "${!RAW_SPECIAL_MAP_CAT[@]}"; do
|
||||
SPECIAL_MAP_CAT["$ck"]="$(resolve_special_value_cat "$ck")"
|
||||
done
|
||||
_log 调试 "特典映射(分区): ${#SPECIAL_MAP_CAT[@]} 项"
|
||||
return
|
||||
fi
|
||||
|
||||
# 全局形态(旧版):第一遍读取全部原始值(数组或字符串引用)
|
||||
local local_str raw
|
||||
while IFS=$'\t' read -r local_str raw; do
|
||||
[[ -z "$local_str" ]] && continue
|
||||
RAW_SPECIAL_MAP["${local_str,,}"]="$raw"
|
||||
done < <(jq -r 'to_entries[] | "\(.key)\t\(.value | @json)"' "$map_file")
|
||||
|
||||
# 第二遍:展开引用(多键→多值),SPECIAL_MAP 值统一为 JSON 数组
|
||||
local key
|
||||
for key in "${!RAW_SPECIAL_MAP[@]}"; do
|
||||
SPECIAL_MAP["$key"]="$(resolve_special_value "$key")"
|
||||
done
|
||||
}
|
||||
|
||||
# 解析特典键的最终数组值:值为数组直接返回;值为字符串视为引用另一键(递归展开引用链,防循环)。
|
||||
# 引用键不存在时把引用串本身当单元素数组。
|
||||
resolve_special_value() {
|
||||
local key="$1" depth="${2:-0}" v ref
|
||||
[[ $depth -gt 20 ]] && {
|
||||
echo "[]"
|
||||
return
|
||||
}
|
||||
v="${RAW_SPECIAL_MAP[$key]:-}"
|
||||
if [[ -n "$v" ]] && echo "$v" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
||||
echo "$v"
|
||||
elif [[ -z "$v" ]]; then
|
||||
# 键不存在:视为单元素数组
|
||||
jq -n --arg v "$key" '[$v]'
|
||||
else
|
||||
ref="${v#\"}"
|
||||
ref="${ref%\"}"
|
||||
if [[ -n "${RAW_SPECIAL_MAP[$ref]:-}" ]]; then
|
||||
resolve_special_value "$ref" $((depth + 1))
|
||||
else
|
||||
jq -n --arg v "$ref" '[$v]'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 分区形态的特典键引用展开:引用解析"同分区优先,其次全局表"。
|
||||
# 参数:ck = "类目|键"。
|
||||
resolve_special_value_cat() {
|
||||
local ck="$1" depth="${2:-0}" cat key v ref
|
||||
[[ $depth -gt 20 ]] && {
|
||||
echo "[]"
|
||||
return
|
||||
}
|
||||
cat="${ck%%|*}"
|
||||
key="${ck#*|}"
|
||||
v="${RAW_SPECIAL_MAP_CAT[$ck]:-}"
|
||||
if [[ -n "$v" ]] && echo "$v" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
||||
echo "$v"
|
||||
elif [[ -z "$v" ]]; then
|
||||
jq -n --arg v "$key" '[$v]'
|
||||
else
|
||||
ref="${v#\"}"
|
||||
ref="${ref%\"}"
|
||||
if [[ -n "${RAW_SPECIAL_MAP_CAT["$cat|$ref"]:-}" ]]; then
|
||||
resolve_special_value_cat "$cat|$ref" $((depth + 1))
|
||||
elif [[ -n "${RAW_SPECIAL_MAP[$ref]:-}" ]]; then
|
||||
resolve_special_value "$ref" $((depth + 1))
|
||||
else
|
||||
jq -n --arg v "$ref" '[$v]'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 特典映射查询:类目分区 → default 分区 → 全局表(值 = TMDB 可匹配关键字 JSON 数组)。
|
||||
special_map_value() {
|
||||
local cat="$1" key="$2"
|
||||
local v
|
||||
v="${SPECIAL_MAP_CAT["$cat|$key"]:-}"
|
||||
[[ -n "$v" ]] && {
|
||||
echo "$v"
|
||||
return 0
|
||||
}
|
||||
v="${SPECIAL_MAP_CAT["default|$key"]:-}"
|
||||
[[ -n "$v" ]] && {
|
||||
echo "$v"
|
||||
return 0
|
||||
}
|
||||
echo "${SPECIAL_MAP[$key]:-}"
|
||||
}
|
||||
|
||||
# 类目判定词子串命中查询(v9.6):类目分区 → default 分区 → 该类目全局兜底数组
|
||||
# (tv → SPECIAL_WORDS 全局特典词;musicvideo → MUSICVIDEO_WORDS 内置默认,不回退 tv 词表)。
|
||||
# 参数:类目 + 归一化字符串(去空格小写);stdout = 命中的归一化词;未命中返回 1。
|
||||
special_word_hit() {
|
||||
local cat="$1" s="$2"
|
||||
[[ -z "$s" ]] && return 1
|
||||
local ck w
|
||||
for ck in "${!SPECIAL_WORDS_CAT[@]}"; do
|
||||
case "$ck" in
|
||||
"$cat|"* | "default|"*)
|
||||
w="${ck#*|}"
|
||||
[[ -n "$w" && "$s" == *"$w"* ]] && {
|
||||
echo "$w"
|
||||
return 0
|
||||
}
|
||||
;;
|
||||
esac
|
||||
done
|
||||
local -a fb=()
|
||||
if [[ "$cat" == "musicvideo" ]]; then
|
||||
fb=("${MUSICVIDEO_WORDS[@]}")
|
||||
else
|
||||
fb=("${SPECIAL_WORDS[@]}")
|
||||
fi
|
||||
local i wn
|
||||
for i in "${fb[@]}"; do
|
||||
wn="${i//[[:space:]]/}"
|
||||
wn="${wn,,}"
|
||||
[[ -n "$wn" && "$s" == *"$wn"* ]] && {
|
||||
echo "$wn"
|
||||
return 0
|
||||
}
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 类目判定词精确命中查询(v9.6):类目分区 → default 分区 → 该类目全局兜底数组。
|
||||
# 归一化后**整串相等**(目录信号用——"tmp.xxx"/"Muv-Luv" 等含词目录名不误判;
|
||||
# 子串语义见 special_word_hit)。参数:类目 + 归一化字符串。命中返回 0。
|
||||
special_word_exact() {
|
||||
local cat="$1" s="$2"
|
||||
[[ -z "$s" ]] && return 1
|
||||
local ck w
|
||||
for ck in "${!SPECIAL_WORDS_CAT[@]}"; do
|
||||
case "$ck" in
|
||||
"$cat|"* | "default|"*)
|
||||
w="${ck#*|}"
|
||||
[[ -n "$w" && "$s" == "$w" ]] && return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
local -a fb=()
|
||||
if [[ "$cat" == "musicvideo" ]]; then
|
||||
fb=("${MUSICVIDEO_WORDS[@]}")
|
||||
else
|
||||
fb=("${SPECIAL_WORDS[@]}")
|
||||
fi
|
||||
local i wn
|
||||
for i in "${fb[@]}"; do
|
||||
wn="${i//[[:space:]]/}"
|
||||
wn="${wn,,}"
|
||||
[[ -n "$wn" && "$s" == "$wn" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 跳过目录精确命中查询(v9.6):流程分区(video/audio)→ default 分区 → 全局表 → 内置默认。
|
||||
# 参数:流程类目 + 小写目录名。命中返回 0。
|
||||
skip_dir_in() {
|
||||
local cat="$1" d="$2"
|
||||
[[ -n "${SKIP_DIRS_CAT["$cat|$d"]:-}" ]] && return 0
|
||||
[[ -n "${SKIP_DIRS_CAT["default|$d"]:-}" ]] && return 0
|
||||
local i
|
||||
for i in "${SKIP_DIRS_LOWER[@]}"; do
|
||||
[[ "$i" == "$d" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 季偏移查询(v9.6):类目分区(tv)→ default 分区 → 全局表(含内置默认)。
|
||||
season_offset_get() {
|
||||
local cat="$1" name="$2"
|
||||
local v
|
||||
v="${SEASON_OFFSET_CAT["$cat|$name"]:-}"
|
||||
[[ -n "$v" ]] && {
|
||||
echo "$v"
|
||||
return 0
|
||||
}
|
||||
v="${SEASON_OFFSET_CAT["default|$name"]:-}"
|
||||
[[ -n "$v" ]] && {
|
||||
echo "$v"
|
||||
return 0
|
||||
}
|
||||
echo "${SEASON_OFFSET_MAP[$name]:-}"
|
||||
}
|
||||
|
||||
# 初始化季偏移配置:加载外部 JSON,缺失时用内置默认值生成。
|
||||
# 默认位置:配置目录 mo_config/season_offsets.json(用户可编辑,缺失时脚本自动生成);
|
||||
# 旧版文件(season_offset.json 等)自动迁移。
|
||||
# v9.6 支持类目分区形态:{"tv": {...}, "default": {...}}(季偏移仅 tv 有语义,其余预留)。
|
||||
init_season_offset() {
|
||||
SEASON_OFFSET_FILE="${SEASON_OFFSET_FILE:-}"
|
||||
# 解析默认路径(优先级:环境变量 > 执行目录 mo_config > 脚本目录 mo_config)
|
||||
if [[ -z "$SEASON_OFFSET_FILE" ]]; then
|
||||
SEASON_OFFSET_FILE=$(resolve_mo_config_file "season_offsets.json" "season_offset.json")
|
||||
fi
|
||||
|
||||
SEASON_OFFSET_MAP=()
|
||||
SEASON_OFFSET_CAT=()
|
||||
|
||||
# 内置季偏移默认值(来自 SEASON_OFFSET_TEMPLATE 常量,作全局兜底)
|
||||
if [[ -n "$SEASON_OFFSET_TEMPLATE" ]]; then
|
||||
while IFS=$'\t' read -r k v; do
|
||||
[[ -z "$k" ]] && continue
|
||||
SEASON_OFFSET_MAP["${k,,}"]="$v"
|
||||
done < <(printf '%s\n' "$SEASON_OFFSET_TEMPLATE" | jq -r 'to_entries[] | "\(.key)\t\(.value)"')
|
||||
fi
|
||||
|
||||
# 加载外部 JSON 配置覆盖默认值(全局形态直接覆盖全局表;分区形态写入分区表)
|
||||
if [[ -f "$SEASON_OFFSET_FILE" ]]; then
|
||||
if jq -e 'type == "object"' "$SEASON_OFFSET_FILE" >/dev/null 2>&1; then
|
||||
if is_category_partitioned "$SEASON_OFFSET_FILE"; then
|
||||
local cat key value
|
||||
while IFS=$'\t' read -r cat key value; do
|
||||
[[ -z "$cat" || -z "$key" ]] && continue
|
||||
SEASON_OFFSET_CAT["$cat|${key,,}"]="$value"
|
||||
done < <(jq -r 'to_entries[] | .key as $c | .value | to_entries[] | "\($c)\t\(.key)\t\(.value)"' "$SEASON_OFFSET_FILE" 2>/dev/null)
|
||||
else
|
||||
while IFS=$'\t' read -r key value; do
|
||||
[[ -z "$key" ]] && continue
|
||||
SEASON_OFFSET_MAP["${key,,}"]="$value"
|
||||
done < <(jq -r 'to_entries[] | "\(.key)\t\(.value)"' "$SEASON_OFFSET_FILE")
|
||||
fi
|
||||
else
|
||||
_log 错误 "季偏移配置文件必须为 JSON 对象格式: $SEASON_OFFSET_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 生成默认季偏移配置文件(JSON 对象格式,全局形态;用户可自行改分区)
|
||||
if [[ ! -f "$SEASON_OFFSET_FILE" ]]; then
|
||||
mkdir -p "$(dirname "$SEASON_OFFSET_FILE")" 2>/dev/null || true
|
||||
offset_json="{}"
|
||||
for key in "${!SEASON_OFFSET_MAP[@]}"; do
|
||||
offset_json=$(printf '%s' "$offset_json" | jq --arg k "$key" --argjson v "${SEASON_OFFSET_MAP[$key]}" '.[$k] = $v')
|
||||
done
|
||||
printf '%s\n' "$offset_json" | jq . >"$SEASON_OFFSET_FILE" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# 初始化特典类别判定表(special_categories.json,v9.6 外置):解析默认路径,缺失时交互创建。
|
||||
# 默认位置:配置目录 mo_config/(用户可编辑);语义 = S00 未匹配特典的类别标签判定
|
||||
# (fragment 子串匹配 → 类别标签,数组顺序 = 优先级)。缺失/自动化模式 → 空表
|
||||
# (special_category_tag 回退内置默认表)。
|
||||
init_special_categories() {
|
||||
local file="$1"
|
||||
if [[ -z "$file" ]]; then
|
||||
file=$(resolve_mo_config_file "special_categories.json")
|
||||
SPECIAL_CATEGORIES_FILE="$file"
|
||||
fi
|
||||
if [[ ! -f "$file" ]]; then
|
||||
if [[ "$AUTOMATED" == "true" ]]; then
|
||||
_log 警告 "未找到特典类别判定表文件,自动化模式使用内置默认: $file"
|
||||
else
|
||||
_log 信息 "未找到特典类别判定表文件: $file"
|
||||
_log 信息 "是否创建默认配置文件?(y/N)"
|
||||
read -r answer || true # EOF(非交互)时跳过创建
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
mkdir -p "$(dirname "$file")" 2>/dev/null || true
|
||||
(umask 077 && printf '%s\n' "$SPECIAL_CATEGORIES_TEMPLATE" | jq . >"$file" 2>/dev/null) || true
|
||||
if [[ ! -s "$file" ]]; then
|
||||
echo '[]' >"$file"
|
||||
fi
|
||||
chmod 600 "$file" 2>/dev/null || _log 警告 "无法设置文件权限为 600: $file"
|
||||
_log 信息 "已创建特典类别判定表文件: $file"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
load_special_categories "$file"
|
||||
}
|
||||
|
||||
# 加载特典类别判定表(JSON 数组:[{"match": "...", "tag": "..."}],顺序 = 优先级)。
|
||||
# 元素存入 SPECIAL_CATEGORIES("match|tag");文件缺失/非法/空 → 空表(回退内置默认)。
|
||||
load_special_categories() {
|
||||
local file="$1"
|
||||
SPECIAL_CATEGORIES=()
|
||||
[[ -f "$file" ]] || return 0
|
||||
if ! jq -e 'type == "array"' "$file" >/dev/null 2>&1; then
|
||||
_log 错误 "特典类别判定表必须为 JSON 数组格式: $file"
|
||||
return 0
|
||||
fi
|
||||
local kw tag
|
||||
while IFS=$'\t' read -r kw tag; do
|
||||
[[ -z "$kw" || -z "$tag" ]] && continue
|
||||
SPECIAL_CATEGORIES+=("${kw,,}|$tag")
|
||||
done < <(jq -r '.[] | select(.match != "" and .tag != "") | "\(.match)\t\(.tag)"' "$file" 2>/dev/null)
|
||||
_log 调试 "特典类别判定表 ${#SPECIAL_CATEGORIES[@]} 项"
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034 # 跨文件共享全局变量(MATCH_RULES_FILE,声明于 main.sh)
|
||||
|
||||
################################################################################
|
||||
# 用户自定义规则:命名模板与匹配规则
|
||||
#
|
||||
# 命名模板(NAMING_* 配置键):把"命名格式化"从硬编码改为用户可配置的模板。
|
||||
# 匹配规则(match_rules.json):用户手工维护的确定性规则——标题别名(搜索词
|
||||
# 纠正)与 ID 映射(跳过搜索直接使用指定 TMDB ID),优先级高于目录名兜底与 AI。
|
||||
################################################################################
|
||||
|
||||
# 查找条件段 {?name:...} 的配对右花括号位置({} 深度计数,支持正文含嵌套占位符)。
|
||||
# 参数:以 "{?" 开头的字符串;输出:配对 } 的下标(0 起),无法配对输出 -1。
|
||||
match_conditional_close() {
|
||||
local s="$1" i depth=1 c
|
||||
for ((i = 1; i < ${#s}; i++)); do
|
||||
c="${s:i:1}"
|
||||
if [[ "$c" == "{" ]]; then
|
||||
depth=$((depth + 1))
|
||||
elif [[ "$c" == "}" ]]; then
|
||||
depth=$((depth - 1))
|
||||
((depth == 0)) && {
|
||||
echo "$i"
|
||||
return 0
|
||||
}
|
||||
fi
|
||||
done
|
||||
echo -1
|
||||
}
|
||||
|
||||
# 内置命名模板默认值(集中于此:config.sh 加载与 render_naming 兜底共用,
|
||||
# 保证"未配置时"与"配置默认值"行为一致)。
|
||||
naming_template_default() {
|
||||
case "$1" in
|
||||
NAMING_MOVIE) echo '{title}{?year: ({year})}' ;;
|
||||
NAMING_SHOW) echo '{title}{?year: ({year})}' ;;
|
||||
NAMING_SEASON) echo 'Season {season:02}' ;;
|
||||
NAMING_EPISODE) echo 'S{season:02}E{episode:02}{range}{?episode_name: - {episode_name}}' ;;
|
||||
NAMING_SPECIAL) echo 'S00{tag} - {fragment}' ;;
|
||||
NAMING_MUSIC) echo '{artist}/{album}' ;;
|
||||
NAMING_MUSICVIDEO) echo '{artist}/{title}' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 按配置键渲染命名模板(键未配置/为空时用内置默认——覆盖测试环境与
|
||||
# 未走 load_config 的调用路径)。参数:配置键名 + render_naming_template 变量对。
|
||||
render_naming() {
|
||||
local key="$1"
|
||||
shift
|
||||
local tmpl
|
||||
tmpl="${!key:-$(naming_template_default "$key")}"
|
||||
render_naming_template "$tmpl" "$@"
|
||||
}
|
||||
|
||||
# 渲染命名模板。第一个参数为模板,其余为 name=value 变量对(值可为空)。
|
||||
# 占位符语法:
|
||||
# {name} → 变量值;未定义的变量渲染为空串(加载期校验会警告未知名)
|
||||
# {name:NN} → 数字补零至 NN 位(非数字保持原值)
|
||||
# {?name:text} → 条件段:name 非空时渲染 text;text 内可含其他占位符
|
||||
# (text 内不支持嵌套条件段 {?...};值内不应含 { })
|
||||
# 实现要点:逐 token 扫描拼接(不用 ${var//pat/repl} 全局替换——替换串中的 &
|
||||
# 会被 bash 当作"匹配整体"展开,文件名含 &(如 "MYTH & ROID")时损坏)。
|
||||
render_naming_template() {
|
||||
local tmpl="$1"
|
||||
shift
|
||||
local depth="${RENDER_DEPTH:-0}"
|
||||
if ((depth > 10)); then
|
||||
_log 警告 "命名模板条件段嵌套过深,按字面保留: $tmpl"
|
||||
printf '%s' "$tmpl"
|
||||
return 0
|
||||
fi
|
||||
local -A vars=()
|
||||
local kv name val
|
||||
for kv in "$@"; do
|
||||
name="${kv%%=*}"
|
||||
[[ -z "$name" ]] && continue
|
||||
val="${kv#*=}"
|
||||
# 剔除值中的花括号(破坏模板解析的字符;文件名中花括号极罕见)
|
||||
val="${val//\{/}"
|
||||
val="${val//\}/}"
|
||||
vars["$name"]="$val"
|
||||
done
|
||||
|
||||
local out="" rest="$tmpl" pre token tname spec guard=0
|
||||
while [[ "$rest" == *"{"* ]]; do
|
||||
guard=$((guard + 1))
|
||||
if ((guard > 100)); then
|
||||
_log 警告 "命名模板解析超限,截断处理: $tmpl"
|
||||
break
|
||||
fi
|
||||
if [[ "$rest" == '{?'* ]]; then
|
||||
# 条件段:配对 } 内的正文整体取出(可含嵌套占位符),name 非空时递归渲染
|
||||
local close_i close_s cond_all cond_name cond_body
|
||||
close_i=$(match_conditional_close "$rest")
|
||||
if ((close_i < 0)); then
|
||||
out+="${rest:0:1}"
|
||||
rest="${rest:1}"
|
||||
continue
|
||||
fi
|
||||
close_s="${rest:0:close_i}"
|
||||
rest="${rest:close_i+1}"
|
||||
cond_all="${close_s:2}"
|
||||
if [[ "$cond_all" == *:* ]]; then
|
||||
cond_name="${cond_all%%:*}"
|
||||
cond_body="${cond_all#*:}"
|
||||
if [[ -n "${vars[$cond_name]:-}" ]]; then
|
||||
out+="$(RENDER_DEPTH=$((depth + 1)) render_naming_template "$cond_body" "$@")"
|
||||
fi
|
||||
else
|
||||
# 无正文形式 {?name}:等价于普通占位符(仅条件段语义)
|
||||
out+="${vars[$cond_all]:-}"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
pre="${rest%%\{*}"
|
||||
out+="$pre"
|
||||
rest="${rest#*"{"}"
|
||||
if [[ "$rest" != *"}"* ]]; then
|
||||
# 无配对右花括号:剩余部分按字面保留(避免吞掉用户模板尾部)
|
||||
out+="{$rest"
|
||||
rest=""
|
||||
break
|
||||
fi
|
||||
token="${rest%%\}*}"
|
||||
rest="${rest#*"}"}"
|
||||
if [[ -z "$token" ]]; then
|
||||
out+="{}"
|
||||
continue
|
||||
fi
|
||||
# 普通占位符 {name} / {name:NN}
|
||||
tname="$token"
|
||||
spec=""
|
||||
if [[ "$tname" == *:* ]]; then
|
||||
spec="${tname##*:}"
|
||||
tname="${tname%%:*}"
|
||||
fi
|
||||
val="${vars[$tname]:-}"
|
||||
if [[ -n "$spec" ]] && [[ "$spec" =~ ^[0-9]+$ ]] && [[ "$val" =~ ^[0-9]+$ ]]; then
|
||||
val=$(printf "%0*d" "$spec" "$((10#$val))")
|
||||
fi
|
||||
out+="$val"
|
||||
done
|
||||
out+="$rest"
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# 提取模板中的占位符名({name} / {name:NN} / {?name:text} 的 name;含条件段正文内)。
|
||||
# 输出:每行一个占位符名(已去 ? 前缀),调用方可 sort -u 去重。
|
||||
template_token_names() {
|
||||
local t="$1" i=0 token tname body
|
||||
while [[ "$t" == *"{"* ]]; do
|
||||
i=$((i + 1))
|
||||
((i > 200)) && break
|
||||
if [[ "$t" == '{?'* ]]; then
|
||||
local ci
|
||||
ci=$(match_conditional_close "$t")
|
||||
if ((ci < 0)); then
|
||||
t="${t:1}"
|
||||
continue
|
||||
fi
|
||||
token="${t:0:ci}"
|
||||
tname="${token:2}"
|
||||
body="${tname#*:}"
|
||||
tname="${tname%%:*}"
|
||||
[[ -n "$tname" ]] && echo "$tname"
|
||||
# 正文重新入队(正文内占位符同样参与校验)
|
||||
t="${body}${t:ci+1}"
|
||||
continue
|
||||
fi
|
||||
t="${t#*"{"}"
|
||||
[[ "$t" != *"}"* ]] && break
|
||||
token="${t%%\}*}"
|
||||
t="${t#*"}"}"
|
||||
tname="${token%%:*}"
|
||||
[[ -n "$tname" ]] && echo "$tname"
|
||||
done
|
||||
}
|
||||
|
||||
# 校验命名模板:占位符名须在允许集合内(未知名渲染为空,打印警告防拼写错误)。
|
||||
# 参数:模板 + 允许的变量名列表(空格分隔)+ 用途标签。
|
||||
validate_naming_template() {
|
||||
local tmpl="$1" allow="$2" label="$3"
|
||||
[[ -z "$tmpl" ]] && return 0
|
||||
local tname
|
||||
while IFS= read -r tname; do
|
||||
if [[ " $allow " != *" $tname "* ]]; then
|
||||
_log 警告 "命名模板 [$label] 含未知占位符 {${tname}}(渲染为空):$tmpl"
|
||||
fi
|
||||
done < <(template_token_names "$tmpl" | sort -u)
|
||||
}
|
||||
|
||||
# 校验全部命名模板(load_config 调用;允许集合与各模板用途对应)。
|
||||
validate_naming_templates() {
|
||||
validate_naming_template "${NAMING_MOVIE:-}" "title year" "NAMING_MOVIE"
|
||||
validate_naming_template "${NAMING_SHOW:-}" "title year" "NAMING_SHOW"
|
||||
validate_naming_template "${NAMING_SEASON:-}" "season" "NAMING_SEASON"
|
||||
validate_naming_template "${NAMING_EPISODE:-}" "season episode range episode_name episode_end" "NAMING_EPISODE"
|
||||
validate_naming_template "${NAMING_SPECIAL:-}" "tag fragment" "NAMING_SPECIAL"
|
||||
validate_naming_template "${NAMING_MUSIC:-}" "artist album" "NAMING_MUSIC"
|
||||
validate_naming_template "${NAMING_MUSICVIDEO:-}" "artist title" "NAMING_MUSICVIDEO"
|
||||
}
|
||||
|
||||
# 匹配规则键归一化:小写 + 连续空白折叠为单空格 + 去首尾空白。
|
||||
# 与 parse 产出的 clean 标题对齐(键写文件名清洗后的标题即可)。
|
||||
rule_key() {
|
||||
printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -s '[:space:]' ' ' |
|
||||
sed -E 's/^ +| +$//g'
|
||||
}
|
||||
|
||||
# 初始化匹配规则文件(match_rules.json):解析默认路径,缺失时交互创建。
|
||||
# 默认位置:配置目录 mo_config/(用户可编辑);与特典映射等配置文件同构。
|
||||
# 规则语义 = 用户手工维护的确定性匹配(搜索别名 + ID 映射),不参与 AI 学习写回。
|
||||
init_match_rules() {
|
||||
local file="$1"
|
||||
if [[ -z "$file" ]]; then
|
||||
file=$(resolve_mo_config_file "match_rules.json" "mo_match_rules.json")
|
||||
MATCH_RULES_FILE="$file"
|
||||
fi
|
||||
if [[ ! -f "$file" ]]; then
|
||||
if [[ "$AUTOMATED" == "true" ]]; then
|
||||
_log 警告 "未找到匹配规则文件,自动化模式跳过创建: $file"
|
||||
else
|
||||
_log 信息 "未找到匹配规则文件: $file"
|
||||
_log 信息 "是否创建默认配置文件?(y/N)"
|
||||
read -r answer || true # EOF(非交互)时跳过创建
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
mkdir -p "$(dirname "$file")" 2>/dev/null || true
|
||||
# 子 shell 设置 umask 077,确保创建的文件权限即 600
|
||||
(umask 077 && printf '%s\n' "$MATCH_RULES_TEMPLATE" | jq . >"$file" 2>/dev/null) || true
|
||||
if [[ ! -s "$file" ]]; then
|
||||
echo '{"search_aliases": {}, "id_maps": {"movie": {}, "tv": {}}}' >"$file"
|
||||
fi
|
||||
chmod 600 "$file" 2>/dev/null || _log 警告 "无法设置文件权限为 600: $file"
|
||||
_log 信息 "已创建匹配规则文件: $file"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
load_match_rules "$file"
|
||||
}
|
||||
|
||||
# 加载匹配规则。文件缺失/非 JSON 对象 → 空规则(不阻断主流程)。
|
||||
# 结构(键在加载期经 rule_key 归一化:小写 + 空白折叠):
|
||||
# search_aliases: {"<标题键>": "<重搜词>" | {"term": "...", "year": "..."}}
|
||||
# —— 主搜索(zh/en)无结果时用重搜词再搜(确定性,优先于目录名兜底与 AI)
|
||||
# id_maps: {"movie": {"<标题键>": <id>}, "tv": {"<标题键>": <id>}}
|
||||
# —— 标题命中直接使用指定 TMDB ID(跳过搜索,最高优先级)
|
||||
load_match_rules() {
|
||||
local file="$1"
|
||||
MATCH_RULES_ALIAS_TERM=()
|
||||
MATCH_RULES_ALIAS_YEAR=()
|
||||
MATCH_RULES_ID_MOVIE=()
|
||||
MATCH_RULES_ID_TV=()
|
||||
[[ -f "$file" ]] || return 0
|
||||
if ! jq -e 'type == "object"' "$file" >/dev/null 2>&1; then
|
||||
_log 错误 "匹配规则文件必须为 JSON 对象格式: $file"
|
||||
return 0
|
||||
fi
|
||||
local k term year rk
|
||||
while IFS=$'\t' read -r k term year; do
|
||||
[[ -z "$k" ]] && continue
|
||||
[[ -z "$term" ]] && continue
|
||||
rk=$(rule_key "$k")
|
||||
[[ -z "$rk" ]] && continue
|
||||
MATCH_RULES_ALIAS_TERM["$rk"]="$term"
|
||||
[[ -n "$year" ]] && MATCH_RULES_ALIAS_YEAR["$rk"]="$year"
|
||||
done < <(
|
||||
jq -r '.search_aliases // {} | to_entries[] |
|
||||
[.key,
|
||||
(.value | if type == "object" then (.term // "") else . end),
|
||||
(.value | if type == "object" then (.year // "") else "" end)] |
|
||||
@tsv' "$file" 2>/dev/null
|
||||
)
|
||||
while IFS=$'\t' read -r k term; do
|
||||
[[ -z "$k" ]] && continue
|
||||
[[ -z "$term" ]] && continue
|
||||
rk=$(rule_key "$k")
|
||||
[[ -z "$rk" ]] && continue
|
||||
MATCH_RULES_ID_MOVIE["$rk"]="$term"
|
||||
done < <(jq -r '.id_maps.movie // {} | to_entries[] | "\(.key)\t\(.value)"' "$file" 2>/dev/null)
|
||||
while IFS=$'\t' read -r k term; do
|
||||
[[ -z "$k" ]] && continue
|
||||
[[ -z "$term" ]] && continue
|
||||
rk=$(rule_key "$k")
|
||||
[[ -z "$rk" ]] && continue
|
||||
MATCH_RULES_ID_TV["$rk"]="$term"
|
||||
done < <(jq -r '.id_maps.tv // {} | to_entries[] | "\(.key)\t\(.value)"' "$file" 2>/dev/null)
|
||||
_log 调试 "匹配规则: 搜索别名 ${#MATCH_RULES_ALIAS_TERM[@]} 条,ID 映射 电影 ${#MATCH_RULES_ID_MOVIE[@]} 条 / 剧集 ${#MATCH_RULES_ID_TV[@]} 条"
|
||||
}
|
||||
|
||||
# 按规则键查搜索别名。命中输出 "term\tyear"(year 可为空),未命中返回 1。
|
||||
match_rule_lookup_alias() {
|
||||
local title="$1" rk
|
||||
rk=$(rule_key "$title")
|
||||
if [[ -n "${MATCH_RULES_ALIAS_TERM[$rk]:-}" ]]; then
|
||||
printf '%s\t%s' "${MATCH_RULES_ALIAS_TERM[$rk]}" "${MATCH_RULES_ALIAS_YEAR[$rk]:-}"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034,SC2004 # 跨文件共享关联数组(声明于 main.sh;[$key] 下标是字符串不是算术)
|
||||
|
||||
# AI 响应 JSON 容错提取(借鉴 BAR _extract_json_from_response 思路,纯 awk/sed 实现):
|
||||
# ① 直接解析 → ② 剥 ```json/``` 围栏后解析 → ③ 剥离思考链标记后解析 → ④ 取最外层 {} 块解析。
|
||||
# 推理模型常输出 <thinking>…</thinking> 或「思考:…」前缀——AI_SYSTEM_MESSAGE 强制 JSON-only
|
||||
# 只是要求,模型不保证遵守;容错链保证任何一级成功即返回,全部失败返回空。
|
||||
# 返回:stdout=合法 JSON;失败返回空(调用方按原失败路径处理)。
|
||||
ai_extract_json() {
|
||||
local content="$1"
|
||||
local candidate=""
|
||||
# ① 直接解析
|
||||
if echo "$content" | jq -e . >/dev/null 2>&1; then
|
||||
echo "$content"
|
||||
return 0
|
||||
fi
|
||||
# ② 剥围栏(```json / ``` 行 toggle,围栏外内容丢弃)
|
||||
candidate=$(printf '%s\n' "$content" | awk '
|
||||
/^```/{ inblock = !inblock; next }
|
||||
inblock { print }
|
||||
')
|
||||
if [[ -n "$candidate" ]] && echo "$candidate" | jq -e . >/dev/null 2>&1; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
# ③ 剥离思考链标记(<thinking> 块 + 「思考/分析/推理:」前缀)后重试直接/围栏解析
|
||||
# 先剥同行 <thinking>…</thinking>(awk 按行处理无法吞掉同行标签后的 JSON)
|
||||
local cleaned
|
||||
cleaned=$(printf '%s\n' "$content" | sed -E 's#<thinking>[^<]*</thinking>##g; /^[[:space:]]*$/d' | awk '
|
||||
/<thinking>/{ skip = 1 }
|
||||
!skip { print }
|
||||
/<\/thinking>/{ skip = 0 }
|
||||
' | sed -E 's/^(思考|分析|推理)[::].*\{/\{/')
|
||||
if [[ -n "$cleaned" ]] && echo "$cleaned" | jq -e . >/dev/null 2>&1; then
|
||||
echo "$cleaned"
|
||||
return 0
|
||||
fi
|
||||
candidate=$(printf '%s\n' "$cleaned" | awk '
|
||||
/^```/{ inblock = !inblock; next }
|
||||
inblock { print }
|
||||
')
|
||||
if [[ -n "$candidate" ]] && echo "$candidate" | jq -e . >/dev/null 2>&1; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
# ④ 最外层 {} 块(花括号配平;字符串内 {} 会误计,仅作兜底——失败继续/返回空)
|
||||
candidate=$(printf '%s' "$cleaned" | awk '
|
||||
{
|
||||
line = $0
|
||||
for (i = 1; i <= length(line); i++) {
|
||||
c = substr(line, i, 1)
|
||||
if (c == "{") { depth++; inblock = 1 }
|
||||
if (inblock) printf "%s", c
|
||||
if (c == "}") {
|
||||
depth--
|
||||
if (depth == 0 && inblock) { printf "\n"; exit }
|
||||
}
|
||||
}
|
||||
}')
|
||||
if [[ -n "$candidate" ]] && echo "$candidate" | jq -e . >/dev/null 2>&1; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# AI 用例落盘(AI_SAVE_CASES=true):每次请求的输入与响应存
|
||||
# $CACHE_DIR/media_organizer/ai_cases/<序号>_<ts>_{request,response}.json。
|
||||
# 反馈闭环素材:识别错时可查"当时 AI 看到了什么";也是防幻觉学习的候选数据源。
|
||||
# 输入不含 API 密钥(key 只在 HTTP 头),响应为原始文本(非 JSON 响应也能留档)。
|
||||
# 干运行不写(零持久化副作用)。
|
||||
ai_case_save() {
|
||||
[[ "${AI_SAVE_CASES,,}" == "true" ]] || return 0
|
||||
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||
[[ -n "$CACHE_DIR" ]] || return 0
|
||||
local kind="$1" data="$2"
|
||||
local dir="$CACHE_DIR/media_organizer/ai_cases"
|
||||
mkdir -p "$dir" 2>/dev/null || return 0
|
||||
local ts
|
||||
ts=$(date +%Y%m%d_%H%M%S)
|
||||
local file="${dir}/${AI_CALL_COUNT}_${ts}_${kind}.json"
|
||||
printf '%s\n' "$data" >"$file" 2>/dev/null
|
||||
_log 调试 "AI 用例已保存: $file"
|
||||
}
|
||||
|
||||
# 单次 AI 批量请求:合并处理搜索词纠正、特典映射学习与跳过目录学习(一次 API 调用)。
|
||||
#
|
||||
# AI 预期返回 JSON(由 AI_BATCH_PROMPT 引导,ai_batch_request 用 jq 解析):
|
||||
# {
|
||||
# "search": { "<完整 file 路径>": { "search_term": "...", "year": "..." }, ... },
|
||||
# "special": { "<show_id>|<fragment>": { "english_keyword": "...", "chinese_keyword": "..." }, ... },
|
||||
# "skip_dirs": [ "<非媒体目录名>", ... ]
|
||||
# }
|
||||
# 无条目的一类返回 {}(skip_dirs 返回 [])。解析后:
|
||||
# - search 部分更新 PENDING_AI_SEARCH(追加 |搜索词|年份)
|
||||
# - special 部分写入特典映射/词表文件与内存(AI 学习)
|
||||
# - skip_dirs 部分写入跳过目录词表文件与内存(AI 学习,交叉验证目录链)
|
||||
# 构造 AI 批处理输入 JSON(jq 安全转义:文件名含引号/反斜杠不破坏 JSON;每类最多 AI_BATCH_SIZE 条)。
|
||||
# 记录本批 key 集合(AI_BATCH_KEYS_*):消费端只处理 AI 响应覆盖的条目,批外条目留待下一批。
|
||||
build_ai_input_json() {
|
||||
# 注意:调用方必须用 `build_ai_input_json <var>` 形式(非命令替换)——
|
||||
# 本函数会修改 AI_BATCH_KEYS_*/AI_DIR_CHAIN 全局数组,$(...) 子 shell 中修改会丢失。
|
||||
local out_var="${1:-}"
|
||||
local input_json='{"search_entries":[],"special_entries":[],"artist_entries":[],"match_entries":[]}'
|
||||
local batch_limit="${AI_BATCH_SIZE:-50}"
|
||||
AI_BATCH_KEYS_SEARCH=()
|
||||
AI_BATCH_KEYS_SPECIAL=()
|
||||
AI_BATCH_KEYS_ARTIST=()
|
||||
AI_BATCH_KEYS_MATCH=()
|
||||
local key info type dir_chain_val count=0
|
||||
AI_DIR_CHAIN=()
|
||||
for key in "${!PENDING_AI_SEARCH[@]}"; do
|
||||
((count >= batch_limit)) && break
|
||||
info="${PENDING_AI_SEARCH[$key]}"
|
||||
IFS=$'\t' read -r type dir_chain_val <<<"$info"
|
||||
input_json=$(jq -c --arg f "$key" --arg d "$dir_chain_val" --arg t "$type" \
|
||||
'.search_entries += [{file: $f, directory: $d, type: $t}]' <<<"$input_json")
|
||||
AI_BATCH_KEYS_SEARCH+=("$key")
|
||||
count=$((count + 1))
|
||||
# 收集目录链段(skip_dirs 学习候选;AI 只能从这些目录中选择,防幻觉)
|
||||
local seg
|
||||
IFS='/' read -ra segs <<<"$dir_chain_val"
|
||||
for seg in "${segs[@]}"; do
|
||||
[[ -n "$seg" ]] && AI_DIR_CHAIN+=("$seg")
|
||||
done
|
||||
done
|
||||
count=0
|
||||
local show_id fragment show_val show_title season0_names
|
||||
for key in "${!PENDING_AI_SPECIAL[@]}"; do
|
||||
((count >= batch_limit)) && break
|
||||
IFS='|' read -r show_id fragment <<<"$key"
|
||||
show_val="${PENDING_AI_SPECIAL[$key]}"
|
||||
IFS='|' read -r show_title season0_names <<<"$show_val"
|
||||
input_json=$(jq -c --arg s "$show_id" --arg f "$fragment" --arg t "$show_title" --arg s0 "$season0_names" \
|
||||
'.special_entries += [{show_id: $s, fragment: $f, series: $t, season0: $s0}]' <<<"$input_json")
|
||||
AI_BATCH_KEYS_SPECIAL+=("$key")
|
||||
count=$((count + 1))
|
||||
done
|
||||
count=0
|
||||
local artists_str album_name
|
||||
for key in "${!PENDING_AI_ARTIST[@]}"; do
|
||||
((count >= batch_limit)) && break
|
||||
IFS='|' read -r artists_str album_name _ <<<"${PENDING_AI_ARTIST[$key]}"
|
||||
input_json=$(jq -c --arg f "$key" --arg a "$artists_str" --arg al "$album_name" \
|
||||
'.artist_entries += [{file: $f, artists: $a, album: $al}]' <<<"$input_json")
|
||||
AI_BATCH_KEYS_ARTIST+=("$key")
|
||||
count=$((count + 1))
|
||||
done
|
||||
count=0
|
||||
local m_type m_year m_season _m_episode m_endpoint m_key
|
||||
for key in "${!PENDING_AI_MATCH[@]}"; do
|
||||
((count >= batch_limit)) && break
|
||||
IFS=$'\t' read -r m_type m_year m_season _m_episode m_endpoint m_key <<<"${PENDING_AI_MATCH[$key]}"
|
||||
# 输入 = search 响应原样(缓存中,零额外请求)+ 首候选 detail 的 seasons 四字段提炼(缓存命中才给)
|
||||
local search_data="" seasons_json="[]"
|
||||
search_data=$(jq -r '.data // empty' "$(cache_path "$m_endpoint" "$m_key")" 2>/dev/null) || search_data=""
|
||||
if [[ -n "$search_data" ]]; then
|
||||
local first_id d_json
|
||||
first_id=$(echo "$search_data" | jq -r '.results[0].id // empty' 2>/dev/null)
|
||||
if [[ -n "$first_id" ]]; then
|
||||
d_json=$(cat "$(cache_path "/${m_type}/${first_id}" "$m_key")" 2>/dev/null)
|
||||
if [[ -n "$d_json" ]] && echo "$d_json" | jq -e . >/dev/null 2>&1; then
|
||||
seasons_json=$(echo "$d_json" | jq -c '[.seasons[]? | {season_number, name, episode_count, air_date}]' 2>/dev/null)
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
[[ -n "$search_data" ]] || search_data="null"
|
||||
[[ -n "$seasons_json" ]] || seasons_json="[]"
|
||||
# 视频时长/分辨率信号(ffprobe 实测;失败留空不阻塞——AI 仍可依据文件名判断)。
|
||||
# 时长用于区分内容类型:正片 ~24min、OVA/特典 ~20-30min、剧场版 ~70-120min。
|
||||
local dur="" res="" probe
|
||||
probe=$(ffprobe -v quiet -print_format json -show_entries format=duration:stream=width,height \
|
||||
"$key" 2>/dev/null) || probe=""
|
||||
if [[ -n "$probe" ]]; then
|
||||
dur=$(echo "$probe" | jq -r '(.format.duration // 0 | tonumber) / 60 | if . >= 1 then (. * 10 | floor) / 10 else empty end' 2>/dev/null)
|
||||
res=$(echo "$probe" | jq -r '([.streams[]? | select(.width != null and .height != null) | "\(.width)x\(.height)"] | .[0]) // empty' 2>/dev/null)
|
||||
fi
|
||||
# 同目录兄弟文件上下文(≤20 个视频文件名):AI 据此判断该文件是剧场版/OVA/正片
|
||||
# 或该目录是整季合集(借鉴 BAR 整目录一次映射的视角,零额外请求)。
|
||||
local -a sib=()
|
||||
local sdir f2 e2
|
||||
sdir=$(dirname "$key")
|
||||
for f2 in "$sdir"/*; do
|
||||
[[ -f "$f2" && "$f2" != "$key" ]] || continue
|
||||
e2="${f2##*.}"
|
||||
e2="${e2,,}"
|
||||
[[ " ${VIDEO_EXTS[*]} " =~ [[:space:]]${e2}[[:space:]] ]] || continue
|
||||
sib+=("$(basename "$f2")")
|
||||
((${#sib[@]} >= 20)) && break
|
||||
done
|
||||
local siblings_json="[]"
|
||||
if ((${#sib[@]} > 0)); then
|
||||
siblings_json=$(printf '%s\n' "${sib[@]}" | jq -R -s 'split("\n") | map(select(. != ""))')
|
||||
fi
|
||||
input_json=$(jq -c --arg f "$key" --arg t "$m_type" --arg y "$m_year" --arg s "$m_season" \
|
||||
--arg d "$dur" --arg r "$res" --argjson sb "$siblings_json" \
|
||||
--argjson sd "$search_data" --argjson sn "$seasons_json" \
|
||||
'.match_entries += [{file: $f, type: $t, year: $y, season: $s, duration: $d, resolution: $r, siblings: $sb, search: $sd, seasons: $sn}]' <<<"$input_json")
|
||||
AI_BATCH_KEYS_MATCH+=("$key")
|
||||
count=$((count + 1))
|
||||
done
|
||||
if [[ -n "$out_var" ]]; then
|
||||
printf -v "$out_var" '%s' "$input_json"
|
||||
else
|
||||
echo "$input_json"
|
||||
fi
|
||||
}
|
||||
|
||||
ai_batch_request() {
|
||||
# 无待处理项直接返回
|
||||
if [[ $PENDING_SEARCH_COUNT -eq 0 ]] &&
|
||||
[[ $PENDING_SPECIAL_COUNT -eq 0 ]] &&
|
||||
[[ $PENDING_ARTIST_COUNT -eq 0 ]] &&
|
||||
[[ $PENDING_MATCH_COUNT -eq 0 ]]; then
|
||||
return
|
||||
fi
|
||||
_log 智能 "AI 批量请求(搜索 $PENDING_SEARCH_COUNT + 特典 $PENDING_SPECIAL_COUNT + 艺术家 $PENDING_ARTIST_COUNT + 匹配 $PENDING_MATCH_COUNT)"
|
||||
|
||||
local input_json
|
||||
build_ai_input_json input_json
|
||||
# 用例落盘:请求输入(不含 API 密钥——key 只在 HTTP 头)
|
||||
ai_case_save "request" "$input_json"
|
||||
local full_prompt="$AI_BATCH_PROMPT"$'\n\n'"Input: $input_json"
|
||||
|
||||
local payload
|
||||
payload=$(jq -n --arg content "$full_prompt" --arg model "$AI_MODEL" --arg sys "$AI_SYSTEM_MESSAGE" \
|
||||
'{model: $model, messages: [{role: "system", content: $sys}, {role: "user", content: $content}], temperature: 0.2}')
|
||||
|
||||
[[ "${AI_DRY_RUN,,}" == "true" ]] && {
|
||||
_log 智能 "AI 干运行,Prompt 摘要: 搜索 $PENDING_SEARCH_COUNT 个 + 特典 $PENDING_SPECIAL_COUNT 个 + 艺术家 $PENDING_ARTIST_COUNT 个 + 匹配 $PENDING_MATCH_COUNT 个"
|
||||
# 返回非零:未消费任何条目(run_ai_batch 视作失败 → 剩余显式跳过,可逆)
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ "$AI_CALL_COUNT" -ge "${AI_MAX_CALLS}" ]]; then
|
||||
_log 警告 "AI 调用次数已达上限,跳过批量请求"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local api_url="${AI_FULL_URL:-${AI_BASE_URL}/v1/chat/completions}"
|
||||
# 递增重试(2^n 封顶 16s),与 tmdb_api 同一策略
|
||||
local response=""
|
||||
local attempt=0 delay=1
|
||||
while true; do
|
||||
if response=$(curl -s --connect-timeout "${AI_CURL_CONNECT_TIMEOUT}" --max-time "${AI_CURL_MAX_TIME}" \
|
||||
-H "Content-Type: application/json" -H "Authorization: Bearer ${AI_API_KEY}" -d "$payload" "$api_url" 2>&1); then
|
||||
break
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
if ((attempt > AI_CURL_RETRY)); then
|
||||
_log 错误 "AI 批量请求失败(重试 ${AI_CURL_RETRY} 次后)"
|
||||
return 1
|
||||
fi
|
||||
_log 警告 "AI 批量请求失败,${delay}s 后重试(${attempt}/${AI_CURL_RETRY})"
|
||||
sleep "$delay"
|
||||
((delay *= 2))
|
||||
[[ $delay -gt 16 ]] && delay=16
|
||||
done
|
||||
AI_CALL_COUNT=$((AI_CALL_COUNT + 1))
|
||||
# 用例落盘:原始响应留档(含非 JSON 响应,便于排查模型输出)
|
||||
ai_case_save "response" "$response"
|
||||
|
||||
local content
|
||||
content=$(echo "$response" | jq -r '.choices[0].message.content // empty' 2>/dev/null)
|
||||
if [[ -z "$content" ]]; then
|
||||
_log 错误 "AI 批量请求未返回有效内容"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# JSON 容错提取:直接 → 围栏 → 思考链剥离 → 最外层 {} 块
|
||||
local result_json
|
||||
result_json=$(ai_extract_json "$content") || {
|
||||
_log 错误 "AI 批量请求返回非 JSON(容错提取失败): $(printf '%s' "$content" | head -c 200)"
|
||||
return 1
|
||||
}
|
||||
|
||||
_log 智能 "AI 批量请求结果: $result_json"
|
||||
|
||||
# 解析 search 部分:更新 PENDING_AI_SEARCH(追加 |搜索词|年份|AI判断类别)
|
||||
local ai_term ai_year ai_media
|
||||
for key in "${!PENDING_AI_SEARCH[@]}"; do
|
||||
ai_term=$(echo "$result_json" | jq -r --arg file "$key" '.search[$file].search_term // empty')
|
||||
ai_year=$(echo "$result_json" | jq -r --arg file "$key" '.search[$file].year // empty')
|
||||
ai_media=$(echo "$result_json" | jq -r --arg file "$key" '.search[$file].media_type // empty')
|
||||
if [[ -n "$ai_term" ]] || [[ -n "$ai_media" ]]; then
|
||||
local old="${PENDING_AI_SEARCH[$key]}"
|
||||
PENDING_AI_SEARCH[$key]="${old} ${ai_term} ${ai_year} ${ai_media}"
|
||||
fi
|
||||
done
|
||||
|
||||
# 解析 artist_choice 部分:AI 判定多艺术家中的专辑艺术家(空串=判断不出)
|
||||
for key in "${!PENDING_AI_ARTIST[@]}"; do
|
||||
AI_ARTIST_CHOICE[$key]=$(echo "$result_json" | jq -r --arg file "$key" '.artist_choice[$file] // empty')
|
||||
done
|
||||
|
||||
# 解析 match 部分:AI 匹配甄别(choice=选定 id;season_shift=季映射;search_term=无匹配纠正词)
|
||||
for key in "${!PENDING_AI_MATCH[@]}"; do
|
||||
AI_MATCH_CHOICE[$key]=$(echo "$result_json" | jq -r --arg file "$key" '.match[$file].choice // empty')
|
||||
AI_MATCH_SHIFT[$key]=$(echo "$result_json" | jq -r --arg file "$key" '.match[$file].season_shift // empty')
|
||||
AI_MATCH_TERM[$key]=$(echo "$result_json" | jq -r --arg file "$key" '.match[$file].search_term // empty')
|
||||
done
|
||||
|
||||
# 记录本批响应覆盖的 key(AI 漏了的条目留待下一批;缺失计数警告)
|
||||
AI_RESPONDED_SEARCH=()
|
||||
AI_RESPONDED_ARTIST=()
|
||||
AI_RESPONDED_MATCH=()
|
||||
local responded
|
||||
for key in "${AI_BATCH_KEYS_SEARCH[@]}"; do
|
||||
responded=$(echo "$result_json" | jq -r --arg f "$key" '.search | has($f)' 2>/dev/null)
|
||||
[[ "$responded" == "true" ]] && AI_RESPONDED_SEARCH["$key"]=1
|
||||
done
|
||||
for key in "${AI_BATCH_KEYS_ARTIST[@]}"; do
|
||||
responded=$(echo "$result_json" | jq -r --arg f "$key" '.artist_choice | has($f)' 2>/dev/null)
|
||||
[[ "$responded" == "true" ]] && AI_RESPONDED_ARTIST["$key"]=1
|
||||
done
|
||||
for key in "${AI_BATCH_KEYS_MATCH[@]}"; do
|
||||
responded=$(echo "$result_json" | jq -r --arg f "$key" '.match | has($f)' 2>/dev/null)
|
||||
[[ "$responded" == "true" ]] && AI_RESPONDED_MATCH["$key"]=1
|
||||
done
|
||||
local missing=$((${#AI_BATCH_KEYS_SEARCH[@]} + ${#AI_BATCH_KEYS_ARTIST[@]} + ${#AI_BATCH_KEYS_MATCH[@]} - \
|
||||
${#AI_RESPONDED_SEARCH[@]} - ${#AI_RESPONDED_ARTIST[@]} - ${#AI_RESPONDED_MATCH[@]}))
|
||||
((missing > 0)) && _log 警告 "AI 响应缺失 $missing 个条目(留待下一批)"
|
||||
|
||||
learn_special_keywords "$result_json"
|
||||
learn_skip_dirs "$result_json"
|
||||
}
|
||||
|
||||
# AI 学习写回跳过目录词表(skip_directories.json)。
|
||||
# AI 从输入条目的目录链中挑出非媒体目录(特典/附带/分类目录),
|
||||
# 交叉验证:返回的目录名必须实际出现在本批输入目录链中(防 AI 幻觉)。
|
||||
# 与现有词表去重(忽略大小写,与 SKIP_DIRS_LOWER 匹配规则一致)后写回文件并同步内存。
|
||||
learn_skip_dirs() {
|
||||
local result_json="$1"
|
||||
[[ -n "${SKIP_DIRS_FILE:-}" ]] || return 0
|
||||
local dir
|
||||
while IFS= read -r dir; do
|
||||
[[ -z "$dir" ]] && continue
|
||||
# 交叉验证:必须在输入目录链中出现过
|
||||
local found=false d
|
||||
for d in "${AI_DIR_CHAIN[@]}"; do
|
||||
[[ "${d,,}" == "${dir,,}" ]] && found=true
|
||||
done
|
||||
[[ "$found" != "true" ]] && {
|
||||
_log 警告 "AI 跳过目录不在输入目录链,丢弃: $dir"
|
||||
continue
|
||||
}
|
||||
# 去重(忽略大小写,与现有词表比较;分区形态查 video 分区,全局形态查全局数组)
|
||||
local already=false sd
|
||||
if [[ -n "${SKIP_DIRS_CAT["video|${dir,,}"]:-}" ]]; then
|
||||
already=true
|
||||
else
|
||||
for sd in "${SKIP_DIRS[@]}"; do
|
||||
[[ "${sd,,}" == "${dir,,}" ]] && already=true
|
||||
done
|
||||
fi
|
||||
[[ "$already" == "true" ]] && continue
|
||||
# 确保词表文件存在(不存在则创建空数组,AI 学到的目录需要持久化)
|
||||
if [[ ! -f "$SKIP_DIRS_FILE" ]]; then
|
||||
mkdir -p "$(dirname "$SKIP_DIRS_FILE")" 2>/dev/null || true
|
||||
echo '[]' >"$SKIP_DIRS_FILE" 2>/dev/null || true
|
||||
fi
|
||||
local tmp="${SKIP_DIRS_FILE}.tmp.$$"
|
||||
# 分区形态 → 写入 video 分区;全局形态 → 追加顶层数组(原子写:临时文件 + rename)
|
||||
if is_category_partitioned "$SKIP_DIRS_FILE"; then
|
||||
if jq --arg v "$dir" '.video = ((.video // []) + [$v] | unique)' \
|
||||
"$SKIP_DIRS_FILE" >"$tmp" 2>/dev/null; then
|
||||
if mv "$tmp" "$SKIP_DIRS_FILE"; then
|
||||
SKIP_DIRS_CAT["video|${dir,,}"]=1
|
||||
_log 智能 "已添加跳过目录(video 分区): $dir"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
else
|
||||
# JSON 数组:追加去重(原子写:临时文件 + rename)
|
||||
if jq --arg v "$dir" '. + [$v] | unique' "$SKIP_DIRS_FILE" >"$tmp" 2>/dev/null; then
|
||||
if mv "$tmp" "$SKIP_DIRS_FILE"; then
|
||||
SKIP_DIRS+=("$dir")
|
||||
SKIP_DIRS_LOWER+=("${dir,,}")
|
||||
_log 智能 "已添加跳过目录: $dir"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
fi
|
||||
done < <(echo "$result_json" | jq -r '.skip_dirs[]? // empty' 2>/dev/null)
|
||||
}
|
||||
|
||||
# AI 学习写回:AI 从该条 season0 候选关键字列表中选出与本地片段匹配的项,
|
||||
# 写回 keymap 作为匹配关键字(可多语言数组)。
|
||||
# 交叉验证:AI 返回的每个匹配关键字必须存在于该条 season0 候选列表(防 AI 幻觉污染映射)。
|
||||
# 同时把 fragment 核心词并入特典词表(special_keywords.json)——词表负责"判定特典",
|
||||
# keymap 负责"匹配特典":只写回 keymap 时,下次遇到不在特典目录里的同类文件名标记
|
||||
# 仍会因词表未命中而无法判定为特典(学习结果对不上),两者缺一不可。
|
||||
learn_special_keywords() {
|
||||
local result_json="$1"
|
||||
local tmdb_name key
|
||||
for key in "${!PENDING_AI_SPECIAL[@]}"; do
|
||||
local fragment key_lower season0_names
|
||||
IFS='|' read -r _ fragment <<<"$key"
|
||||
key_lower="${fragment,,}"
|
||||
season0_names="${PENDING_AI_SPECIAL[$key]#*|}"
|
||||
tmdb_name=$(echo "$result_json" | jq -r --arg k "$fragment" '.special[$k] // empty')
|
||||
if [[ -n "$tmdb_name" ]] && [[ "$tmdb_name" != "null" ]]; then
|
||||
# 交叉验证:过滤掉不在候选列表中的项(数组逐项 / 字符串单项)。
|
||||
# 注意:`$s0 | contains(.)` 中 . 指管道输入($s0 自身)——须先捕获元素为 $item
|
||||
local valid_name
|
||||
valid_name=$(echo "$tmdb_name" | jq -r --arg s0 "$season0_names" \
|
||||
'if type == "array" then ([.[] | select(. as $item | ($item != "") and ($s0 | contains($item)))] | if length > 0 then . else empty end)
|
||||
else (if (. != "") and ($s0 | contains(.)) then . else empty end) end' 2>/dev/null) || valid_name=""
|
||||
if [[ -z "$valid_name" ]] || [[ "$valid_name" == "null" ]]; then
|
||||
_log 警告 "AI 特典匹配关键字不在候选列表,丢弃: $fragment -> $tmdb_name"
|
||||
continue
|
||||
fi
|
||||
tmdb_name="$valid_name"
|
||||
# 确保映射文件存在(不存在则创建空 JSON,AI 学到的映射需要持久化)
|
||||
if [[ ! -f "$SPECIAL_MAP_FILE" ]]; then
|
||||
mkdir -p "$(dirname "$SPECIAL_MAP_FILE")" 2>/dev/null || true
|
||||
echo '{}' >"$SPECIAL_MAP_FILE" 2>/dev/null || true
|
||||
fi
|
||||
local tmp_map_file="${SPECIAL_MAP_FILE}.tmp.$$"
|
||||
# JSON 文件:合并写入数组(已有值则去重追加;字符串值归并为数组)。
|
||||
# 分区形态 → 写入 tv 分区(特典映射属剧集语境);全局形态 → 顶层。
|
||||
if is_category_partitioned "$SPECIAL_MAP_FILE"; then
|
||||
if jq --arg k "$key_lower" --argjson v "$tmdb_name" \
|
||||
'if (.tv // {} | has($k)) then (.tv[$k] = ((((if (.tv[$k]|type)=="array" then .tv[$k] else [.tv[$k]] end) + (if ($v|type)=="array" then $v else [$v] end)) | unique))) else (.tv[$k] = (if ($v|type)=="array" then $v else [$v] end)) end' \
|
||||
"$SPECIAL_MAP_FILE" >"$tmp_map_file" 2>/dev/null; then
|
||||
if ! mv "$tmp_map_file" "$SPECIAL_MAP_FILE"; then
|
||||
rm -f "$tmp_map_file"
|
||||
fi
|
||||
else
|
||||
rm -f "$tmp_map_file"
|
||||
fi
|
||||
else
|
||||
if jq --arg k "$key_lower" --argjson v "$tmdb_name" \
|
||||
'if has($k) then (.[$k] = (((if (.[$k]|type)=="array" then .[$k] else [.[$k]] end) + (if ($v|type)=="array" then $v else [$v] end)) | unique)) else (.[$k] = (if ($v|type)=="array" then $v else [$v] end)) end' \
|
||||
"$SPECIAL_MAP_FILE" >"$tmp_map_file" 2>/dev/null; then
|
||||
if ! mv "$tmp_map_file" "$SPECIAL_MAP_FILE"; then
|
||||
rm -f "$tmp_map_file"
|
||||
fi
|
||||
else
|
||||
rm -f "$tmp_map_file"
|
||||
fi
|
||||
fi
|
||||
# 更新内存映射(JSON 数组;分区形态 → tv 分区表,全局形态 → 全局表)
|
||||
local existing
|
||||
if is_category_partitioned "$SPECIAL_MAP_FILE"; then
|
||||
existing="${SPECIAL_MAP_CAT["tv|$key_lower"]:-}"
|
||||
if [[ -z "$existing" ]]; then
|
||||
SPECIAL_MAP_CAT["tv|$key_lower"]=$(jq -n --argjson v "$tmdb_name" 'if ($v|type)=="array" then $v else [$v] end')
|
||||
else
|
||||
SPECIAL_MAP_CAT["tv|$key_lower"]=$(echo "$existing" | jq --argjson v "$tmdb_name" \
|
||||
'(if type=="array" then . else [.] end) + (if ($v|type)=="array" then $v else [$v] end) | unique')
|
||||
fi
|
||||
else
|
||||
existing="${SPECIAL_MAP[$key_lower]:-}"
|
||||
if [[ -z "$existing" ]]; then
|
||||
SPECIAL_MAP["$key_lower"]=$(jq -n --argjson v "$tmdb_name" 'if ($v|type)=="array" then $v else [$v] end')
|
||||
else
|
||||
SPECIAL_MAP["$key_lower"]=$(echo "$existing" | jq --argjson v "$tmdb_name" \
|
||||
'(if type=="array" then . else [.] end) + (if ($v|type)=="array" then $v else [$v] end) | unique')
|
||||
fi
|
||||
fi
|
||||
_log 智能 "已添加特典映射: $fragment -> $tmdb_name"
|
||||
|
||||
# ---- 特典词表学习写回(判定环节)----
|
||||
# 核心词 = fragment 去数字、去空格、小写(与 detect_special 的归一化匹配一致);
|
||||
# 长度 < 2 的词(如纯数字/单字符)不入表,避免过度判定。
|
||||
local frag_core
|
||||
frag_core=$(printf '%s' "$fragment" | sed -E 's/[0-9]+//g' | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||
frag_core="${frag_core//[[:space:]]/}"
|
||||
frag_core="${frag_core,,}"
|
||||
if [[ ${#frag_core} -ge 2 ]] && [[ -n "${SPECIAL_WORDS_FILE:-}" ]]; then
|
||||
local already=false w w_norm
|
||||
if [[ -n "${SPECIAL_WORDS_CAT["tv|$frag_core"]:-}" ]]; then
|
||||
already=true
|
||||
else
|
||||
for w in "${SPECIAL_WORDS[@]}"; do
|
||||
w_norm="${w//[[:space:]]/}"
|
||||
w_norm="${w_norm,,}"
|
||||
[[ "$w_norm" == "$frag_core" ]] && already=true
|
||||
done
|
||||
fi
|
||||
if [[ "$already" != "true" ]]; then
|
||||
# 确保词表文件存在(不存在则创建空数组,AI 学到的词需要持久化)
|
||||
if [[ ! -f "$SPECIAL_WORDS_FILE" ]]; then
|
||||
mkdir -p "$(dirname "$SPECIAL_WORDS_FILE")" 2>/dev/null || true
|
||||
echo '[]' >"$SPECIAL_WORDS_FILE" 2>/dev/null || true
|
||||
fi
|
||||
local tmp_words="${SPECIAL_WORDS_FILE}.tmp.$$"
|
||||
# 分区形态 → 写入 tv 分区;全局形态 → 追加顶层数组(原子写:临时文件 + rename)
|
||||
if is_category_partitioned "$SPECIAL_WORDS_FILE"; then
|
||||
if jq --arg v "$frag_core" '.tv = ((.tv // []) + [$v] | unique)' \
|
||||
"$SPECIAL_WORDS_FILE" >"$tmp_words" 2>/dev/null; then
|
||||
if mv "$tmp_words" "$SPECIAL_WORDS_FILE"; then
|
||||
SPECIAL_WORDS_CAT["tv|$frag_core"]=1
|
||||
_log 智能 "已添加特典词表(tv 分区): $fragment -> $frag_core"
|
||||
else
|
||||
rm -f "$tmp_words"
|
||||
fi
|
||||
else
|
||||
rm -f "$tmp_words"
|
||||
fi
|
||||
else
|
||||
# JSON 数组:追加去重(原子写:临时文件 + rename)
|
||||
if jq --arg v "$frag_core" '. + [$v] | unique' \
|
||||
"$SPECIAL_WORDS_FILE" >"$tmp_words" 2>/dev/null; then
|
||||
if mv "$tmp_words" "$SPECIAL_WORDS_FILE"; then
|
||||
SPECIAL_WORDS+=("$frag_core")
|
||||
_log 智能 "已添加特典词表: $fragment -> $frag_core"
|
||||
else
|
||||
rm -f "$tmp_words"
|
||||
fi
|
||||
else
|
||||
rm -f "$tmp_words"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
################################################################################
|
||||
# TMDB API
|
||||
################################################################################
|
||||
|
||||
# 指定语言的 tmdb_api 封装(语言重试链用):局部覆盖 TMDB_LANG 后调用。
|
||||
# bash 动态作用域:tmdb_api 内读到的 $TMDB_LANG 为本次调用值;
|
||||
# 缓存 key/路径含语言段(cache_key 拼 lang、id 类路径带 .lang),语言切换天然隔离缓存。
|
||||
# 参数:lang(如 en-US) + 原 tmdb_api 全部参数。
|
||||
tmdb_api_lang() {
|
||||
local lang="$1"
|
||||
shift
|
||||
local TMDB_LANG="$lang"
|
||||
tmdb_api "$@"
|
||||
}
|
||||
|
||||
# 调用 TMDB API 并缓存结果。端点形如 "/search/movie",额外参数以 key=value 传入。
|
||||
# 先查缓存(命中且未过期直接返回),未命中才请求并写缓存。
|
||||
# 返回码约定:0=成功(含空结果的有效响应);1=新鲜空哨兵跳过请求;
|
||||
# 2=请求失败(curl 错误/HTTP 非 2xx,递增重试耗尽)——不写空哨兵(失败≠无结果)。
|
||||
# 返回原始 JSON 响应到 stdout。
|
||||
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 跨进程互斥(内核锁,进程退出自动释放,无残留)→ 锁内双检缓存。
|
||||
# 锁文件放 /tmp(复用不删除;不污染缓存目录,干运行零持久化副作用)。
|
||||
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"
|
||||
)
|
||||
if $TMDB_USE_BEARER; then
|
||||
curl_args+=("--header" "Authorization: Bearer ${TMDB_AUTH_TOKEN}")
|
||||
fi
|
||||
curl_args+=("--data-urlencode" "language=${TMDB_LANG}")
|
||||
if ! $TMDB_USE_BEARER; then
|
||||
curl_args+=("--data-urlencode" "api_key=${TMDB_AUTH_TOKEN}")
|
||||
fi
|
||||
local a
|
||||
for a in "$@"; do
|
||||
curl_args+=("--data-urlencode" "$a")
|
||||
done
|
||||
|
||||
local full_url="${TMDB_API_BASE_URL}${endpoint}"
|
||||
[[ "$DEBUG_LEVEL" -ge 2 ]] && _log 调试 "TMDB 请求: ${full_url}?..."
|
||||
|
||||
# 递增重试(2^n 封顶 16s):curl 的 --retry-delay 为固定延迟,这里手写循环
|
||||
local result=""
|
||||
local attempt=0 delay=1
|
||||
while true; do
|
||||
if result=$(curl "${curl_args[@]}" "$full_url" 2>&1); then
|
||||
break
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
if ((attempt > TMDB_CURL_RETRY)); then
|
||||
_log 错误 "TMDB 请求失败(重试 ${TMDB_CURL_RETRY} 次后): ${full_url} 错误: ${result}"
|
||||
return 2
|
||||
fi
|
||||
_log 警告 "TMDB 请求失败,${delay}s 后重试(${attempt}/${TMDB_CURL_RETRY}): ${full_url}"
|
||||
sleep "$delay"
|
||||
((delay *= 2))
|
||||
[[ $delay -gt 16 ]] && delay=16
|
||||
done
|
||||
# 搜索空结果 → 写 3 天短 TTL 哨兵(查无此片;新片出现后自动重试);否则正常写缓存
|
||||
if [[ "$is_search" == "1" ]] && ! echo "$result" | jq -e '.results | length > 0' >/dev/null 2>&1; then
|
||||
cache_put_empty "$path" "$is_search" "$key"
|
||||
else
|
||||
cache_put "$path" "$result" "$is_search" "$key"
|
||||
fi
|
||||
if $locked; then
|
||||
exec 9>&-
|
||||
fi
|
||||
echo "$result"
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
################################################################################
|
||||
# 日志与色彩(最先加载,保证全脚本日志输出一致)
|
||||
################################################################################
|
||||
|
||||
# 初始化 ANSI 色彩代码。COLOR_OUTPUT=true 时启用,否则保持空字符串。
|
||||
init_colors() {
|
||||
if [[ "${COLOR_OUTPUT,,}" == "true" ]]; then
|
||||
readonly C_RESET='\033[0m'
|
||||
readonly C_RED='\033[0;31m'
|
||||
readonly C_GREEN='\033[0;32m'
|
||||
readonly C_YELLOW='\033[1;33m'
|
||||
readonly C_BLUE='\033[0;34m'
|
||||
readonly C_MAGENTA='\033[0;35m'
|
||||
readonly C_GRAY='\033[0;90m'
|
||||
readonly C_WHITE='\033[1;37m'
|
||||
readonly C_DIM='\033[2m'
|
||||
readonly C_LIGHT_CYAN='\033[1;36m'
|
||||
fi
|
||||
}
|
||||
|
||||
# 返回当前缩进字符串(每级 2 空格)。
|
||||
get_indent() {
|
||||
local count=$((INDENT_LEVEL * 2))
|
||||
printf '%*s' "$count" ''
|
||||
}
|
||||
|
||||
# 增加缩进级别。
|
||||
push_indent() {
|
||||
# 用赋值而非 ((var++)):((0++)) 表达式值为 0 会返回非零退出状态,
|
||||
# 在 set -e 下会导致脚本意外退出。
|
||||
INDENT_LEVEL=$((INDENT_LEVEL + 1))
|
||||
}
|
||||
|
||||
# 减少缩进级别。
|
||||
pop_indent() {
|
||||
INDENT_LEVEL=$((INDENT_LEVEL - 1))
|
||||
}
|
||||
|
||||
# 统一日志输出。级别:信息/警告/错误/成功/匹配/搜索/跳过/调试/智能。
|
||||
# 自动化模式下同时写入日志文件;干运行模式下不写日志文件(零持久化副作用)。
|
||||
_log() {
|
||||
local level="$1"
|
||||
shift
|
||||
local color="" tag=""
|
||||
case "$level" in
|
||||
信息)
|
||||
color="$C_WHITE"
|
||||
tag="信息"
|
||||
;;
|
||||
警告)
|
||||
color="$C_YELLOW"
|
||||
tag="警告"
|
||||
;;
|
||||
错误)
|
||||
color="$C_RED"
|
||||
tag="错误"
|
||||
;;
|
||||
成功)
|
||||
color="$C_GREEN"
|
||||
tag="成功"
|
||||
;;
|
||||
匹配)
|
||||
color="$C_MAGENTA"
|
||||
tag="匹配"
|
||||
;;
|
||||
搜索)
|
||||
color="$C_BLUE"
|
||||
tag="搜索"
|
||||
;;
|
||||
跳过)
|
||||
color="$C_GRAY"
|
||||
tag="跳过"
|
||||
;;
|
||||
调试)
|
||||
color="$C_DIM"
|
||||
tag="调试"
|
||||
;;
|
||||
智能)
|
||||
color="$C_LIGHT_CYAN"
|
||||
tag="智能"
|
||||
;;
|
||||
*)
|
||||
color="$C_RESET"
|
||||
tag="未知"
|
||||
;;
|
||||
esac
|
||||
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
printf "${color}[%s] [%s]%s%s${C_RESET}\n" \
|
||||
"$timestamp" "$tag" "$(get_indent)" "$*" >&2
|
||||
|
||||
if [[ "${AUTOMATED:-false}" == "true" ]] && [[ -n "${LOG_FILE:-}" ]] &&
|
||||
[[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
printf "[%s] [%s]%s%s\n" \
|
||||
"$timestamp" "$tag" "$(get_indent)" "$*" >>"$LOG_FILE"
|
||||
fi
|
||||
}
|
||||
+608
@@ -0,0 +1,608 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034 # 全局变量声明区:由其他模块跨文件消费(shellcheck 单文件分析无法追踪)
|
||||
|
||||
################################################################################
|
||||
# Jellyfin 媒体库硬链接整理脚本
|
||||
#
|
||||
# 版本:9.6(v9.5 + 配置按类目分区 / 特典类别外置 / Music Videos 类目)
|
||||
# 作者:LetsShareAll
|
||||
# 许可:MIT
|
||||
#
|
||||
# 说明:
|
||||
# 自动化整理媒体库,通过 TMDB API 识别电影和电视剧,并使用硬链接创建
|
||||
# 标准化的目录结构。支持 AI 辅助识别、全量缓存、特典映射、季数偏移。
|
||||
#
|
||||
# 功能特性:
|
||||
# - 自动识别和整理电影、电视剧
|
||||
# - TMDB API 集成,支持 Bearer token 和 API Key 认证
|
||||
# - AI 辅助识别(支持 OpenAI API 和兼容接口)
|
||||
# - 三阶段处理流程(识别、AI 纠正、硬链接)
|
||||
# - 全量持久化缓存(镜像 API 路径,一切请求数据皆缓存)
|
||||
# - 特典/番外篇自动识别(TMDB 匹配用 S00E,未匹配用 S00{类型})
|
||||
# - 跨平台支持(Linux/macOS/BusyBox)
|
||||
# - v9.4:增量标记(已链接跳过)、失败冷却(cron 重试退避)、
|
||||
# 纠错账本(--export-map 伴生 .ledger.json + --rerun 重跑)、
|
||||
# 搜索重试链(zh-CN → en-US → MAL 可选)、AI 输入时长/分辨率信号、
|
||||
# 同集冲突检测与报告
|
||||
# - v9.5:用户自定义规则——命名模板(NAMING_* 配置键,电影/剧集/特典/音乐
|
||||
# 命名格式可配置)+ 匹配规则(match_rules.json:搜索别名与 ID 映射,
|
||||
# 确定性兜底,优先于目录名重搜与 AI)
|
||||
# - v9.6:配置按类目分区(special_maps/special_keywords/skip_directories/
|
||||
# season_offsets 支持 movie/tv/music/musicvideo 分区,未配置回退全局表)、
|
||||
# 特典类别判定表外置(special_categories.json)、Music Videos(音乐视频)
|
||||
# 类目识别与命名(MusicVideos/{artist}/{title})
|
||||
#
|
||||
# 最低要求:
|
||||
# - Bash 4.0+
|
||||
# - curl、jq、ffprobe
|
||||
# - 源目录和目的目录在同一文件系统
|
||||
#
|
||||
# 用法:
|
||||
# ./media_organizer.sh [选项] <源目录> <目的目录>
|
||||
#
|
||||
# 选项:
|
||||
# -a, --automated 自动化模式(写入日志,跳过无法处理项目)
|
||||
# --dry-run 干运行模式(仅打印操作,不创建链接)
|
||||
# --refresh-cache 清空全部持久化缓存
|
||||
# --update-cache 基于源目录重新获取并更新缓存
|
||||
# --list-cache [关键词] 列出缓存条目(可选关键词过滤)
|
||||
# --export-map [目录] 生成对照表 markdown + 伴生纠错账本 .ledger.json
|
||||
# --rerun <账本文件> 按账本 action="rerun" 条目重跑硬链接(手动纠错)
|
||||
# -h, --help 显示帮助信息
|
||||
#
|
||||
# 示例:
|
||||
# ./media_organizer.sh /downloads /media
|
||||
# ./media_organizer.sh -a /downloads /media
|
||||
# ./media_organizer.sh --dry-run /downloads /media
|
||||
# ./media_organizer.sh --list-cache 刀剑
|
||||
# ./media_organizer.sh --export-map /downloads /media
|
||||
# ./media_organizer.sh --rerun mo_map/downloads-xxxx.ledger.json
|
||||
#
|
||||
# 配置:
|
||||
# - 参考 config.example.json 创建 config.json 文件
|
||||
# - 查看 CONFIG.md 了解所有配置项
|
||||
# - 查看 TROUBLESHOOTING.md 获取问题解决方案
|
||||
#
|
||||
# 缓存方案(v9.2):
|
||||
# 缓存根目录 mo_cache/ 分两个子目录,TMDB 数据与脚本自身数据分离:
|
||||
# tmdb/ 镜像 TMDB API 路径,一切请求数据全量落盘:
|
||||
# search/movie/<hash>.json /search/movie 搜索结果(键=query&year&lang)
|
||||
# search/tv/<hash>.json /search/tv 搜索结果
|
||||
# tv/<id>.<lang>.json /tv/{id} 剧集详情(原始响应;语言段随 TMDB_LANG)
|
||||
# tv/<id>/season/<n>.<lang>.json /tv/{id}/season/{n} 季数据(含 n=0 特典季)
|
||||
# movie/<id>.<lang>.json /movie/{id} 电影详情(原始响应)
|
||||
# mo_cache/ 分区:tmdb/(可再生 API 缓存)+ media_organizer/(脚本运行状态:账本/冷却)。
|
||||
# 用户配置类文件(可编辑 + 脚本可写回)位于配置目录 mo_config/:
|
||||
# config.json 主配置(JSON)
|
||||
# special_maps.json AI 学习写回的特典映射
|
||||
# special_keywords.json AI 学习写回的特典词表(判定特典用)
|
||||
# skip_directories.json AI 学习写回的跳过目录词表
|
||||
# season_offsets.json 运行时生成的季偏移
|
||||
# match_rules.json 用户匹配规则(搜索别名/ID 映射,用户手工维护)
|
||||
# 搜索缓存为包裹格式(query/year/lang/fetched_at/empty/data),其余直接存原始 JSON。
|
||||
# TTL:CACHE_TTL_DAYS(默认 30,正常数据)/ CACHE_EMPTY_TTL_DAYS(默认 3,查无此片哨兵)。
|
||||
# 并发:tmdb_api 对同一缓存路径用 flock 互斥 + 锁内双检(识别池多 worker 避免重复请求)。
|
||||
# 搜索空结果写 empty 哨兵(3 天短 TTL),新片出现后自动重新搜索。
|
||||
# --refresh-cache 仅清空 tmdb/(API 响应缓存可再生);mo_config/ 配置与 media_organizer/ 运行状态保留。
|
||||
# 旧版自动迁移:mo_cache/{movie,search,tv} → mo_cache/tmdb/;
|
||||
# 配置类文件统一归入 mo_config/ 并使用新命名(special_maps/special_keywords/
|
||||
# skip_directories/season_offsets),旧文件名与旧位置(mo_config/、
|
||||
# mo_cache/media_organizer/ 及更早 mo_ 前缀名)自动迁移。
|
||||
#
|
||||
# 单文件分发说明:
|
||||
# 本脚本为单文件分发,已内置以下模板与默认值,无需携带任何配套文件:
|
||||
# - config.json 完整配置模板(未提供 API 密钥时自动生成到 mo_config/)
|
||||
# - special_maps.json 特典关键词映射(缺失时在用户确认下用常量模板创建到 mo_config/;
|
||||
# AI 学习写回同一位置)
|
||||
# - special_keywords.json 特典识别词表(缺失时在用户确认下用常量模板创建到 mo_config/;
|
||||
# AI 学习到的新特典词写回同一位置)
|
||||
# - skip_directories.json 跳过目录词表(缺失时在用户确认下用常量模板创建到 mo_config/;
|
||||
# AI 学习到的新目录写回同一位置)
|
||||
# - season_offsets.json 常见季偏移默认值(内置,缺失时自动生成到 mo_config/)
|
||||
# - match_rules.json 用户匹配规则(搜索别名/ID 映射,缺失时在用户确认下用
|
||||
# 常量模板创建到 mo_config/;命名模板 NAMING_* 直接在 config.json 配置)
|
||||
# 主配置为 JSON 格式 config.json(生成到 mo_config/;配置项说明见 README)。
|
||||
# 所有配置均可通过环境变量或自动生成的配置文件调整。
|
||||
#
|
||||
# 代码结构:
|
||||
# 1. 常量与全局变量声明
|
||||
# 2. 日志与色彩函数(最先加载,保证全脚本日志输出一致)
|
||||
# 3. 配置加载与认证
|
||||
# 4. 特典映射与季偏移
|
||||
# 5. 字符串工具
|
||||
# 6. 缓存管理(cache_* 系列)
|
||||
# 7. 账本与冷却(ledger:增量标记/失败冷却/纠错重跑)
|
||||
# 8. TMDB API(tmdb_api 统一缓存封装)
|
||||
# 9. 媒体文件名解析
|
||||
# 10. 媒体识别(电影/电视剧)
|
||||
# 11. AI 批处理
|
||||
# 12. 硬链接处理
|
||||
# 13. 错误处理
|
||||
# 14. main()(唯一入口,文件末尾调用)
|
||||
#
|
||||
################################################################################
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
################################################################################
|
||||
# 常量
|
||||
################################################################################
|
||||
|
||||
readonly SCRIPT_VERSION="9.6"
|
||||
readonly TMDB_API_BASE_URL="https://api.themoviedb.org/3"
|
||||
|
||||
# 配置类文件按类目分区的保留键集合(v9.6):
|
||||
# movie/tv/music/musicvideo = 媒体类目分区;video/audio = 流程分区
|
||||
# (目录语义判断发生在类目确定前,按视频/音频流程取值);
|
||||
# default = 通用回退分区;global = 兼容别名。
|
||||
# 分区形态判定:JSON 对象顶层全部键 ∈ 此集合 → 分区形态;否则全局单表形态。
|
||||
readonly MO_CATEGORY_KEYS="movie tv music musicvideo video audio default global"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# AI 提示词常量
|
||||
#
|
||||
# 作用:
|
||||
# AI 批量请求(ai_batch_request)使用的提示词模板。统一置于文件顶部常量区,
|
||||
# 便于维护与测试。运行时会在提示词后追加 "Input: <JSON>" 输入数据。
|
||||
#
|
||||
# 预期返回格式(ai_batch_request 用 jq 解析 .search 与 .special 两个部分):
|
||||
# {
|
||||
# "search": { "<完整 file 路径>": { "search_term": "清洗后的标题", "year": "年份或空串" }, ... },
|
||||
# "special": { "<show_id>|<fragment>": { "english_keyword": "英文标准键", "chinese_keyword": "中文别称" }, ... }
|
||||
# }
|
||||
# - 某类无条目时,对应键为 {}。
|
||||
# - search 的键 = 输入 search_entries 中每项的 "file" 完整路径(逐字节复制)。
|
||||
# - special 的键 = 输入 special_entries 中每项的 "<show_id>|<fragment>"(fragment 保留原数字)。
|
||||
# - 缺失字段用空串 ""。
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# AI 系统消息:强制模型仅输出 JSON,拒绝 Markdown 围栏/注释/尾逗号;
|
||||
# 要求输出键逐字节复制输入键(否则 jq 查不到导致整批静默丢弃)。
|
||||
readonly AI_SYSTEM_MESSAGE='You are a strict JSON-only assistant. Respond with exactly one valid JSON object and nothing else. Do NOT wrap it in Markdown code fences (no ```), do NOT add explanations, comments, or trailing commas. All strings must use double quotes. If a field is unknown or absent, use an empty string "". Every input entry MUST appear as an exact key in the output, with the key copied byte-for-byte from the input.'
|
||||
|
||||
# 批量 AI 提示词(合并搜索纠正 + 特典映射 + 跳过目录学习为一次请求)。
|
||||
# 输入含 search_entries(file/filename/directory/type)与 special_entries(show_id/fragment/series)。
|
||||
# 返回 {"search": {...}, "special": {...}, "skip_dirs": [...]},键规则见上方注释。
|
||||
readonly AI_BATCH_PROMPT='You are a media file organizer. You receive a JSON input with four arrays:
|
||||
- "search_entries": media files that failed TMDB search. Each has "file" (full path), "directory" (path chain relative to source root, max 3 levels), "type" ("tv" or "movie").
|
||||
- "special_entries": unidentifiable special features. Each has "show_id", "fragment" (the local key string from the filename, e.g. "Menu01", "CM01", "Preview02"), "series" (show title), and "season0" (that show'"'"'s TMDB special-season episode list in "episode_number:name; ..." format).
|
||||
- "artist_entries": audio files with multiple artists. Each has "file" (full path), "artists" (pipe-separated artist list, e.g. "Artist1|Artist2"), "album" (album name or "").
|
||||
- "match_entries": files whose TMDB search returned results but need disambiguation (year mismatch, season count suspicious, or suffix-season like "Railgun T"). Each has "file", "type", "year" (parsed year or ""), "season" (file season or ""), "search" (the raw TMDB search response JSON), "seasons" (seasons array of the first candidate: season_number/name/episode_count/air_date; may be [] if unavailable), "duration" (video length in minutes, or "" if unknown), "resolution" (e.g. "1920x1080", or "" if unknown), "siblings" (array of other video filenames in the same directory, up to 20, or []). Use duration to judge content type: TV episodes are ~24 minutes, OVAs/specials ~20-30 minutes, movies/theatrical releases ~70-120 minutes; a file whose duration matches a movie but sits among TV episodes is likely a theatrical release that TMDB lists in season 0. Use "siblings" to judge context: a directory holding a full BD season (many files sharing a title pattern) is a TV series, while a lone movie-length file next to episode files is a theatrical release; sibling names may reveal the release group and the numbering scheme of the whole batch.
|
||||
|
||||
Return ONE top-level JSON object with exactly five keys:
|
||||
- "search": an object mapping each entry'"'"'s exact "file" string (copied byte-for-byte; keep full path, brackets, spaces, ampersands) to {"search_term": "...", "year": "..."}. search_term = clean title for TMDB search: strip only media/release tags (S01E30, S1, E12, [Menu01], [1080p], leading [Group]); if type is "tv" remove episode/season numbers BUT keep ordinal words like "2nd Season"; year = 4-digit year if identifiable, else "". If the input "type" is "unknown", also infer and return "media_type": "movie" or "tv" based on the filename/directory (e.g. contains season/episode markers -> "tv", ends with a year or no episode markers -> "movie").
|
||||
- "special": an object mapping each entry'"'"'s local "fragment" string (exact, keep digits, e.g. "Menu01", "Preview02", "CM01") to an ARRAY of MATCH KEYWORDS — entries from that entry'"'"'s "season0" candidate list that match it, in any language (e.g. "Menu01" -> ["Menu", "菜单"], "Preview02" -> ["Preview", "预告片"], "CM01" -> ["CM", "广告"]). Use the exact strings as written in season0; if no match, use [].
|
||||
- "artist_choice": an object mapping each artist entry'"'"'s exact "file" string to a SINGLE artist string — pick the one most likely to be the album artist (the primary artist credited for the whole album). If truly undecidable, return "".
|
||||
- "match": an object mapping each match entry'"'"'s exact "file" string to {"choice": <id> | "", "season_shift": <int> | "", "search_term": "..."}. choice = the result id from "search" that matches this file; if the correct show is a season-with-suffix of a candidate (e.g. file "Toaru Kagaku no Railgun T S01E01" matches candidate 某科学的超电磁炮 whose season 3 is named 某科学的超电磁炮T), pick the candidate id AND set season_shift so that file season + season_shift = the correct TMDB season number (e.g. file S01 -> season_shift 2 -> S03). If the file is a movie, season_shift must be "". If no candidate matches but a corrected search would help, set choice "" and search_term to the corrected title. If truly no match and no correction, return {"choice": "", "season_shift": "", "search_term": ""}.
|
||||
- "skip_dirs": an array of directory names from the input entries'"'"' "directory" chains that are NOT media (show/movie) directories — special-feature or attached-content directories (e.g. "PV", "CM", "MAD", "预告", "SPs", "特典"), or category folders that hold no media of their own. Only pick names that literally appear in some entry'"'"'s "directory" chain, copied exactly as written; if none, return [].
|
||||
|
||||
If a type has no entries, use {} for that key (for "skip_dirs", use []). Process EVERY entry. Missing fields become "". Output ONLY the JSON object.'
|
||||
|
||||
################################################################################
|
||||
# 配置文件模板
|
||||
#
|
||||
# 以下常量是脚本生成缺失配置文件时使用的默认模板:
|
||||
# CONFIG_DEFS(render_config_template)-> config.json 主配置文件
|
||||
# SPECIAL_KEYMAP_TEMPLATE -> special_maps.json 特典映射
|
||||
# SEASON_OFFSET_TEMPLATE -> season_offsets.json 季数偏移
|
||||
#
|
||||
# 说明:
|
||||
# - 这些常量仅在对应配置文件缺失时,由脚本(在用户确认下)生成默认文件。
|
||||
# - 生成后可自行编辑文件,脚本以文件内容为准,常量不影响运行时的配置。
|
||||
################################################################################
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# ----------------------------------------------------------------------------
|
||||
# CONFIG_DEFS —— 主配置单一数据源(键|默认值)
|
||||
#
|
||||
# 作用:
|
||||
# 生成脚本的主配置文件 config.json(render_config_template),同时是
|
||||
# load_config 的默认值来源,包含全部配置项(TMDB 认证、网络、文件类型、
|
||||
# 缓存映射、输出调试、AI 辅助、自动化日志、高级选项)。
|
||||
#
|
||||
# 用法:
|
||||
# 1. 复制为 config.json:cp config.example.json config.json(或运行脚本交互生成)
|
||||
# 2. 设置权限:chmod 600 config.json
|
||||
# 3. 编辑并填写必要的配置项(至少填写 TMDB_API_RA_TOKEN)
|
||||
# 4. 测试:./media_organizer.sh --dry-run /downloads /media
|
||||
#
|
||||
# 配置方法:
|
||||
# 直接编辑此文件,键值格式 "KEY": "VALUE"。
|
||||
# 配置优先级:环境变量 > config.json 文件 > CONFIG_DEFS 默认值。
|
||||
# ----------------------------------------------------------------------------
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# 模板生成(render_config_template → config.json)与 load_config 默认值
|
||||
# 都从本表读取:新增配置键只需在此加一行,模板与默认值永不漂移。
|
||||
# 特殊键的"后处理"(FOLDER_* 按 locale 补默认、扩展名拆数组、数值校验等)
|
||||
# 在 load_config 循环后执行,默认值仍以本表为准。
|
||||
# NAMING_* 字面值须与 rules.sh 的 naming_template_default 保持一致
|
||||
# (render_naming 兜底共用后者;两处同步修改时以 naming_template_default 为权威)。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly CONFIG_DEFS=(
|
||||
"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|"
|
||||
"SPECIAL_CATEGORIES_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}"
|
||||
"NAMING_MUSICVIDEO|{artist}/{title}"
|
||||
"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"
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SPECIAL_KEYMAP_TEMPLATE —— 特典映射模板(special_maps.json)
|
||||
#
|
||||
# 作用:
|
||||
# 定义特典/番外篇的识别映射:本地关键字符串(文件中的特典标记)→ TMDB 可匹配关键字数组。
|
||||
# 脚本用数组内任一值去匹配文件名与 TMDB 季 0 的特典名称。
|
||||
#
|
||||
# 用法:
|
||||
# 1. 文件缺失时,脚本在用户确认下用此模板生成 special_maps.json
|
||||
# 2. 可编辑该文件增删映射(键=本地关键字符串,值=TMDB 可匹配关键字)
|
||||
#
|
||||
# 多键→多值:
|
||||
# 值为 JSON 数组(多语言,如 ["Menu","菜单","メニュー"]);
|
||||
# 值为字符串时表示引用另一键的数组(多键共享一组值,如不同压制组的
|
||||
# Menu01/Menu 01/MENU01 都指向 "menu")。
|
||||
# 示例:{"menu": ["Menu", "菜单"], "menu01": "menu", "menu_1": "menu"}
|
||||
# 识别时先用本地字符串精确查表,再按去数字后的核心词查表;数组内任一值命中 TMDB 特典名即匹配。
|
||||
#
|
||||
# 注意:仅用于创建默认文件,不作为运行时默认映射(匹配完全依赖文件内容)。
|
||||
# v9.7 起模板清空(无内置示例数据):生成空映射 {},真实映射由 AI 学习写回与用户手动添加。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly SPECIAL_KEYMAP_TEMPLATE='{}'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SPECIAL_WORDS_TEMPLATE —— 特典识别词表模板(special_keywords.json)
|
||||
#
|
||||
# 作用:
|
||||
# 特典识别的判定词(文件名方括号标记含这些词 → 判定为特典)。
|
||||
# 语义 = 特典类别词(Menu/CM/PV/NCOP/NCED 等),匹配对空格不敏感
|
||||
# (词表 "ncop" 可匹配文件名里的 "nc op")。
|
||||
# 词表内容不当作正则(纯字符串子串匹配)。
|
||||
# v9.7 起模板清空(无内置示例数据):生成空词表 [],真实词由 AI 学习写回与用户手动添加。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly SPECIAL_WORDS_TEMPLATE='[]'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# MUSICVIDEO_WORDS_TEMPLATE —— 音乐视频判定词表(special_keywords.json 的
|
||||
# musicvideo 分区默认值,v9.6)
|
||||
#
|
||||
# 作用:
|
||||
# Music Videos 类目识别信号(detect_musicvideo):
|
||||
# - 目录信号:目录名**精确匹配**(归一化去空格小写后整名相等)词表词
|
||||
# ("Music Videos"/"MV"/"Live"/"演唱会" 等 → 整目录音乐视频)。
|
||||
# - 文件名信号:方括号标记**子串**命中词表词,且未命中 tv 特典词
|
||||
# ([MV]/[Live] → 音乐视频;[PV]/[CM] 不在此表 → 保持特典路径)。
|
||||
# 独立于 tv 特典词表(不回退 SPECIAL_WORDS——"sp" 子串命中 "SPs" 目录等
|
||||
# 交叉误判):查询链 = musicvideo 分区 → default 分区 → 本内置默认。
|
||||
# 词表可经 special_keywords.json 的 musicvideo 分区覆盖。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly MUSICVIDEO_WORDS_TEMPLATE='["mv","music video","music videos","musicvideo","musicvideos","music clip","video clip","live","concert","performance","演唱会","音乐视频","音乐录影带","现场"]'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SPECIAL_CATEGORIES_TEMPLATE —— 特典类别判定表模板(special_categories.json)
|
||||
#
|
||||
# 作用:
|
||||
# S00 未匹配特典的类别标签判定表(S00{类型} - {片段} 的"类型"):
|
||||
# fragment 子串按数组顺序(=优先级,长词/特定词在前)匹配 → 取标签。
|
||||
# 外置为可配置(v9.6):用户可增删类别或调整顺序;文件缺失时脚本在用户
|
||||
# 确认下用此模板创建;未命中用户表时回退内置默认表(见 special_category_tag),
|
||||
# 仍未命中 → "Special"。
|
||||
#
|
||||
# 注意:仅用于创建默认文件;运行时以文件内容为准。
|
||||
# v9.7 起模板清空(无内置示例数据):生成空判定表 [],真实类别由用户手动添加
|
||||
# (未命中用户表时仍回退 special_category_tag 内置默认表)。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly SPECIAL_CATEGORIES_TEMPLATE='[]'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SKIP_DIRS_TEMPLATE —— 跳过目录词表模板(skip_directories.json)
|
||||
#
|
||||
# 作用:
|
||||
# 不作为"媒体名目录"的目录词(特典/附带目录 + 分类目录):
|
||||
# - find_show_path_from_file / find_show_dir_from_path:目录名精确匹配(忽略大小写)
|
||||
# - count_main_videos:路径段精确匹配
|
||||
# 内容不当作正则(纯字符串比较)。
|
||||
# v9.7 起模板清空(无内置示例数据):生成空词表 [],真实目录词由 AI 学习写回与用户手动添加。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly SKIP_DIRS_TEMPLATE='[]'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SEASON_OFFSET_TEMPLATE —— 季数偏移模板(season_offsets.json)
|
||||
#
|
||||
# 作用:
|
||||
# 定义常见剧集的季数偏移值,用于处理 TMDB 季数与实际集数不符的情况
|
||||
# (如 TMDB 只有一季,但资源实际有 S01-S03)。
|
||||
#
|
||||
# 用法:
|
||||
# 1. 文件缺失时,脚本用此模板生成 season_offsets.json
|
||||
# 2. 可编辑该文件覆盖/新增偏移值
|
||||
#
|
||||
# 配置方法:
|
||||
# 键为剧名(小写)或 "id:TMDB_ID",值为第一季的集数(偏移基准)。
|
||||
# 示例:{"jujutsu kaisen": 23} 表示第一季有 23 集,后续季自动 +23 偏移。
|
||||
# v9.7 起模板清空(无内置示例数据):生成空偏移表 {},真实偏移由用户手动添加。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly SEASON_OFFSET_TEMPLATE='{}'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# MATCH_RULES_TEMPLATE —— 匹配规则文件模板(match_rules.json)
|
||||
#
|
||||
# 作用:
|
||||
# 定义用户手工维护的确定性匹配规则——搜索别名与 ID 映射。
|
||||
# 文件缺失时,脚本在用户确认下用此模板创建(内容为空规则,直接编辑生效)。
|
||||
#
|
||||
# 结构:
|
||||
# search_aliases: 标题(文件名清洗后的标题,小写+空白折叠匹配)→ 重搜词。
|
||||
# 主搜索(zh-CN → en-US)无结果时,用重搜词再搜一次(可带 year)——
|
||||
# 确定性兜底,优先于目录名重搜与 AI。适合本地俗称/简称(如 "俺妹")。
|
||||
# 值可为字符串(重搜词)或对象 {"term": "...", "year": "..."}。
|
||||
# id_maps: 标题 → TMDB ID(movie/tv 分表)。标题命中直接使用指定 ID,
|
||||
# 完全跳过搜索(最高优先级)——适合 TMDB 多候选易选错/搜索命中错条目的场景。
|
||||
#
|
||||
# 注意:仅用于创建默认文件;运行时规则完全以文件内容为准。
|
||||
# ----------------------------------------------------------------------------
|
||||
readonly MATCH_RULES_TEMPLATE='{
|
||||
"search_aliases": {},
|
||||
"id_maps": {
|
||||
"movie": {},
|
||||
"tv": {}
|
||||
}
|
||||
}'
|
||||
|
||||
################################################################################
|
||||
# 全局变量
|
||||
################################################################################
|
||||
|
||||
SCRIPT_DIR=""
|
||||
|
||||
# 运行模式标志。
|
||||
# 可用 ${VAR:-} 保形初始化:AUTOMATED/DRY_RUN 允许从环境变量读取(优先级链第一层),
|
||||
# 直接赋 "" 的其余标志为 CLI 专用(--list-cache / --update-cache / --refresh-cache),忽略环境变量。
|
||||
AUTOMATED="${AUTOMATED:-}"
|
||||
DRY_RUN="${DRY_RUN:-}"
|
||||
REFRESH_CACHE=""
|
||||
UPDATE_CACHE=""
|
||||
LIST_CACHE=""
|
||||
LIST_CACHE_FILTER=""
|
||||
# 源→目标对照表导出:空=关闭;"true"=默认目录(脚本所在目录/mo_map);非空=指定目录
|
||||
EXPORT_MAP=""
|
||||
EXPORT_MAP_DIR=""
|
||||
# 手动纠错重跑:--rerun <账本文件>(独立形状,不识别只链接)
|
||||
RERUN_FILE=""
|
||||
|
||||
# 目录和文件路径(SOURCE_DIR/DESTINATION_DIR 仅来自 CLI;保形初始化便于测试环境注入)
|
||||
SOURCE_DIR="${SOURCE_DIR:-}"
|
||||
DESTINATION_DIR="${DESTINATION_DIR:-}"
|
||||
CACHE_DIR="${CACHE_DIR:-}"
|
||||
CONFIG_FILE=""
|
||||
|
||||
# 目的目录根名(本地化,默认跟随系统语言;load_config 中按 FOLDER_* 配置链赋值;
|
||||
# 保形初始化避免吞掉环境变量)
|
||||
FOLDER_MOVIES="${FOLDER_MOVIES:-}"
|
||||
FOLDER_SHOWS="${FOLDER_SHOWS:-}"
|
||||
FOLDER_MUSIC="${FOLDER_MUSIC:-}"
|
||||
FOLDER_MUSICVIDEOS="${FOLDER_MUSICVIDEOS:-}"
|
||||
FOLDER_UNKNOWN="${FOLDER_UNKNOWN:-}"
|
||||
|
||||
# TMDB 认证信息
|
||||
TMDB_AUTH_TOKEN=""
|
||||
TMDB_USE_BEARER=false
|
||||
|
||||
# 配置变量
|
||||
# 默认值由 load_config() 统一设置(${VAR:-config:-default}),支持环境变量和 config.json 覆盖;
|
||||
# 此处用 ${VAR:-} 保形初始化,避免顶层赋空串吞掉环境变量
|
||||
TMDB_LANG="${TMDB_LANG:-}"
|
||||
TMDB_DELAY="${TMDB_DELAY:-}"
|
||||
COLOR_OUTPUT="${COLOR_OUTPUT:-}"
|
||||
DEBUG_LEVEL="${DEBUG_LEVEL:-}"
|
||||
|
||||
# 扩展名(字符串保形,load_config 中 read -ra 转为数组)
|
||||
VIDEO_EXTS="${VIDEO_EXTS:-}"
|
||||
AUDIO_EXTS="${AUDIO_EXTS:-}"
|
||||
SUB_EXTS="${SUB_EXTS:-}"
|
||||
|
||||
# AI 调用统计
|
||||
AI_CALL_COUNT=0
|
||||
|
||||
# curl 网络配置
|
||||
TMDB_CURL_RETRY="${TMDB_CURL_RETRY:-}"
|
||||
TMDB_CURL_CONNECT_TIMEOUT="${TMDB_CURL_CONNECT_TIMEOUT:-}"
|
||||
TMDB_CURL_MAX_TIME="${TMDB_CURL_MAX_TIME:-}"
|
||||
AI_CURL_RETRY="${AI_CURL_RETRY:-}"
|
||||
AI_CURL_CONNECT_TIMEOUT="${AI_CURL_CONNECT_TIMEOUT:-}"
|
||||
AI_CURL_MAX_TIME="${AI_CURL_MAX_TIME:-}"
|
||||
|
||||
# AI 配置
|
||||
AI_API_KEY="${AI_API_KEY:-}"
|
||||
AI_BASE_URL="${AI_BASE_URL:-}"
|
||||
AI_FULL_URL="${AI_FULL_URL:-}"
|
||||
AI_MODEL="${AI_MODEL:-}"
|
||||
AI_MAX_CALLS="${AI_MAX_CALLS:-}"
|
||||
AI_DRY_RUN="${AI_DRY_RUN:-}"
|
||||
# AI 用例落盘(true 时每次请求的输入/响应存 mo_cache/media_organizer/ai_cases/)
|
||||
AI_SAVE_CASES="${AI_SAVE_CASES:-}"
|
||||
|
||||
# 搜索重试链(zh-CN 空 → en-US 重搜 → MAL 可选兜底)
|
||||
SEARCH_FALLBACK_MAL="${SEARCH_FALLBACK_MAL:-}"
|
||||
MAL_BASE_URL="${MAL_BASE_URL:-}"
|
||||
|
||||
# 失败冷却(小时;0=禁用)
|
||||
FAIL_RETRY_COOLDOWN_HOURS="${FAIL_RETRY_COOLDOWN_HOURS:-}"
|
||||
|
||||
# 日志文件
|
||||
LOG_FILE="${LOG_FILE:-}"
|
||||
SKIP_LOG_FILE="${SKIP_LOG_FILE:-}"
|
||||
# 日志轮转阈值(MB;0=禁用,超阈值时滚动保留 5 份)
|
||||
LOG_ROTATE_MB="${LOG_ROTATE_MB:-}"
|
||||
|
||||
# 映射配置文件(init_special_map/init_season_offset/init_special_words/init_skip_dirs 中解析默认路径)
|
||||
SPECIAL_MAP_FILE="${SPECIAL_MAP_FILE:-}"
|
||||
SEASON_OFFSET_FILE="${SEASON_OFFSET_FILE:-}"
|
||||
SPECIAL_WORDS_FILE="${SPECIAL_WORDS_FILE:-}"
|
||||
SKIP_DIRS_FILE="${SKIP_DIRS_FILE:-}"
|
||||
# 特典类别判定表(special_categories.json,v9.6 外置;init_special_categories 解析默认路径)
|
||||
SPECIAL_CATEGORIES_FILE="${SPECIAL_CATEGORIES_FILE:-}"
|
||||
# 用户自定义规则:匹配规则文件路径(init_match_rules 解析默认路径)+ 命名模板
|
||||
# (NAMING_* 为命名模板字符串,load_config 赋默认值;渲染见 render_naming_template)
|
||||
MATCH_RULES_FILE="${MATCH_RULES_FILE:-}"
|
||||
NAMING_MOVIE="${NAMING_MOVIE:-}"
|
||||
NAMING_SHOW="${NAMING_SHOW:-}"
|
||||
NAMING_SEASON="${NAMING_SEASON:-}"
|
||||
NAMING_EPISODE="${NAMING_EPISODE:-}"
|
||||
NAMING_SPECIAL="${NAMING_SPECIAL:-}"
|
||||
NAMING_MUSIC="${NAMING_MUSIC:-}"
|
||||
NAMING_MUSICVIDEO="${NAMING_MUSICVIDEO:-}"
|
||||
# 本批 AI 输入目录链中出现的目录名集合(skip_dirs 学习候选;防 AI 幻觉)
|
||||
declare -a AI_DIR_CHAIN=()
|
||||
# 特典识别词表(init_special_words 加载;worker 经 fork 复制可见;
|
||||
# 全局形态=词表文件内容+内置兜底;分区形态下为内置兜底,分区词见 SPECIAL_WORDS_CAT)
|
||||
declare -a SPECIAL_WORDS=()
|
||||
# 音乐视频判定词表(内置默认 MUSICVIDEO_WORDS_TEMPLATE;可经 musicvideo 分区覆盖;
|
||||
# 查询链:musicvideo 分区 → default 分区 → 本数组——不回退 tv 特典词表)
|
||||
declare -a MUSICVIDEO_WORDS=()
|
||||
# 跳过目录词表(init_skip_dirs 加载;SKIP_DIRS_LOWER 为小写化版本供精确匹配)
|
||||
declare -a SKIP_DIRS=()
|
||||
declare -a SKIP_DIRS_LOWER=()
|
||||
# 特典类别判定表(special_categories.json 加载;元素 = "match|tag",顺序=优先级)
|
||||
declare -a SPECIAL_CATEGORIES=()
|
||||
|
||||
# 关联数组
|
||||
# 特典映射(本地关键字符串 -> TMDB 字符串,一对一;全局形态表)
|
||||
declare -A SPECIAL_MAP=()
|
||||
# 特典映射原始值缓存(值可为 JSON 数组或字符串引用,供 resolve_special_value 展开)
|
||||
declare -A RAW_SPECIAL_MAP=()
|
||||
# 特典映射类目分区表(v9.6:键 = "类目|关键字符串";查询类目分区 → default 分区 → 全局表)
|
||||
declare -A SPECIAL_MAP_CAT=()
|
||||
declare -A RAW_SPECIAL_MAP_CAT=()
|
||||
# 特典识别词类目分区表(v9.6:键 = "类目|归一化词",值为 1;查询类目分区 → default 分区 → 全局数组)
|
||||
declare -A SPECIAL_WORDS_CAT=()
|
||||
# 跳过目录类目分区表(v9.6:键 = "类目|小写目录名";查询流程分区 → default 分区 → 全局数组)
|
||||
declare -A SKIP_DIRS_CAT=()
|
||||
# 季数偏移类目分区表(v9.6:键 = "类目|剧名小写";查询类目分区 → default 分区 → 全局表)
|
||||
declare -A SEASON_OFFSET_CAT=()
|
||||
# 剧名/剧ID -> 季数偏移(全局形态表)
|
||||
declare -A SEASON_OFFSET_MAP=()
|
||||
# 用户匹配规则(load_match_rules 加载;键经 rule_key 归一化):
|
||||
# 搜索别名(标题键 -> 重搜词/年份)与 ID 映射(标题键 -> TMDB ID,movie/tv 分表)
|
||||
declare -A MATCH_RULES_ALIAS_TERM=()
|
||||
declare -A MATCH_RULES_ALIAS_YEAR=()
|
||||
declare -A MATCH_RULES_ID_MOVIE=()
|
||||
declare -A MATCH_RULES_ID_TV=()
|
||||
# 源文件路径 -> 目的信息(子目录|文件名,两段式;key=源文件)
|
||||
declare -A MEDIA_DESTINATION_MAP=()
|
||||
# 结局账本:源文件路径 -> 结局类别(identified/fallback/skip_type/skip_unidentified/request_failed/pending_ai)
|
||||
# 汇总报告唯一数据源;跳过/失败条目不进入目的映射
|
||||
declare -A MEDIA_OUTCOME_MAP=()
|
||||
# 待 AI 纠正的搜索项
|
||||
declare -A PENDING_AI_SEARCH=()
|
||||
# 待 AI 判定的多艺术家音频项(key=音频文件,value=候选艺术家|专辑)
|
||||
declare -A PENDING_AI_ARTIST=()
|
||||
# AI 对多艺术家的判定结果(key=音频文件,value=选定的专辑艺术家或空)
|
||||
declare -A AI_ARTIST_CHOICE=()
|
||||
# 待 AI 匹配甄别的条目(key=文件,value=type\tyear\tseason\tendpoint\tkey——搜索响应在缓存中)
|
||||
declare -A PENDING_AI_MATCH=()
|
||||
# AI 匹配结果(key=文件,value=选定 id / 季偏移 / 重搜词)
|
||||
declare -A AI_MATCH_CHOICE=()
|
||||
declare -A AI_MATCH_SHIFT=()
|
||||
declare -A AI_MATCH_TERM=()
|
||||
# 本批 AI 请求覆盖的 key 集合(消费端只处理响应覆盖的条目,批外留待下一批)
|
||||
declare -a AI_BATCH_KEYS_SEARCH=()
|
||||
declare -a AI_BATCH_KEYS_SPECIAL=()
|
||||
declare -a AI_BATCH_KEYS_ARTIST=()
|
||||
declare -a AI_BATCH_KEYS_MATCH=()
|
||||
# AI 响应中实际出现的 key(AI 漏了的条目留待下一批)
|
||||
declare -A AI_RESPONDED_SEARCH=()
|
||||
declare -A AI_RESPONDED_ARTIST=()
|
||||
declare -A AI_RESPONDED_MATCH=()
|
||||
# 待 AI 学习的特典项
|
||||
declare -A PENDING_AI_SPECIAL=()
|
||||
|
||||
# 数组
|
||||
declare -a VIDEO_FILES=()
|
||||
# 音频文件(CD 音乐,归入 Music 分类)
|
||||
declare -a AUDIO_FILES=()
|
||||
# 媒体目录正片数量缓存(避免重复扫描,键=show 目录完整路径)
|
||||
declare -A MAIN_COUNT_CACHE=()
|
||||
# 同集冲突:key=剧集目录||SxxExx(含区间),value=多行 "src|dest"(detect_conflicts 构建)
|
||||
declare -A CONFLICT_MAP=()
|
||||
|
||||
# 计数器
|
||||
PENDING_SEARCH_COUNT=0
|
||||
PENDING_SPECIAL_COUNT=0
|
||||
PENDING_ARTIST_COUNT=0
|
||||
PENDING_MATCH_COUNT=0
|
||||
INDENT_LEVEL=0
|
||||
|
||||
# 链接阶段计数(供运行级汇总与退出码分级)
|
||||
LINK_SUCCESS_COUNT=0
|
||||
LINK_FAIL_COUNT=0
|
||||
|
||||
# 协议行合并统计(merge_emit_lines 输出,供 pool_run 阈值检查)
|
||||
MERGE_PROCESSED=0
|
||||
MERGE_FAILED=0
|
||||
MERGE_STREAK=0
|
||||
|
||||
# 高级选项
|
||||
SKIP_HARDLINK_CHECK="${SKIP_HARDLINK_CHECK:-}"
|
||||
|
||||
# 色彩代码(init_colors() 中按 COLOR_OUTPUT 赋值)
|
||||
C_RESET=''
|
||||
C_RED=''
|
||||
C_GREEN=''
|
||||
C_YELLOW=''
|
||||
C_BLUE=''
|
||||
C_MAGENTA=''
|
||||
C_GRAY=''
|
||||
C_WHITE=''
|
||||
C_DIM=''
|
||||
C_LIGHT_CYAN=''
|
||||
@@ -0,0 +1,387 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034 # 跨文件共享全局变量(POOL_EMIT_FILE/PENDING_AI_SPECIAL)
|
||||
|
||||
# 特典回退目标:S00{类型}{编号} - {片段}(避免占用 S00E 编号干扰 TMDB 识别)。
|
||||
# 识别路径与回退命名共用,保证两套特典命名规则一致。
|
||||
# 参数:show_dir(剧集目录名,已含年份段,由 NAMING_SHOW 渲染)fragment ext。
|
||||
# 季目录名与文件名分别由 NAMING_SEASON(season=0 → Season 00)与 NAMING_SPECIAL 渲染。
|
||||
special_s00_dest() {
|
||||
local show_dir="$1" fragment="$2" ext="$3"
|
||||
local clean_fragment sp_type s00_tag
|
||||
clean_fragment=$(sanitize "$fragment")
|
||||
# 特典类别词表(子串匹配;校准驱动扩充:Character PV→PV、ED Lyric→ED、TV-SPOT→CM 等)。
|
||||
# 命名约定(默认模板):S00{类别} - {片段}(类别不带编号,编号保留在片段中)
|
||||
sp_type="$(special_category_tag "$fragment")"
|
||||
s00_tag="${sp_type:-Special}"
|
||||
local s00_name s00_dir
|
||||
s00_name=$(render_naming NAMING_SPECIAL "tag=$s00_tag" "fragment=$clean_fragment")
|
||||
s00_name=$(sanitize "$s00_name")
|
||||
[[ -n "$s00_name" ]] || s00_name="S00${s00_tag} - ${clean_fragment}"
|
||||
s00_dir=$(render_naming NAMING_SEASON "season=0")
|
||||
echo "${FOLDER_SHOWS}/${show_dir}/${s00_dir}|${s00_name}.${ext}"
|
||||
}
|
||||
|
||||
# 特典类别标签:fragment 子串匹配类别词(优先级从高到低)。
|
||||
# 语义 = 特典类别(Menu/CM/PV/NCED/NCOP/Preview/Mini Anime/IV/MV/WebRadio/Program/...)。
|
||||
# v9.6:先查用户外置表(special_categories.json,数组顺序=优先级),
|
||||
# 未命中回退内置默认表,仍未命中 → "Special"。
|
||||
special_category_tag() {
|
||||
local fragment="$1" fl
|
||||
# busybox tr 字符类在 UTF-8 locale 下按字节错乱("Menu"→"Menl"),用 bash 内置 ${var,,}
|
||||
fl="${fragment,,}"
|
||||
local entry kw tag
|
||||
# 用户表(外置 special_categories.json)
|
||||
for entry in "${SPECIAL_CATEGORIES[@]}"; do
|
||||
kw="${entry%%|*}"
|
||||
tag="${entry#*|}"
|
||||
[[ -z "$kw" ]] && continue
|
||||
if [[ "$fl" == *"$kw"* ]]; then
|
||||
echo "$tag"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
# 内置默认表(文件缺失/未命中时回退)
|
||||
local kw2 tag2
|
||||
while IFS='|' read -r kw2 tag2; do
|
||||
[[ -z "$kw2" ]] && continue
|
||||
if [[ "$fl" == *"$kw2"* ]]; then
|
||||
echo "$tag2"
|
||||
return 0
|
||||
fi
|
||||
done <<'EOF'
|
||||
nced|NCED
|
||||
ncop|NCOP
|
||||
menu|Menu
|
||||
cm|CM
|
||||
pv|PV
|
||||
trailer|Trailer
|
||||
teaser|Teaser
|
||||
preview|Preview
|
||||
mini anime|Mini Anime
|
||||
picture drama|Picture Drama
|
||||
drama|Picture Drama
|
||||
opening|Opening
|
||||
ending|Ending
|
||||
event|Event
|
||||
mv|Music Video
|
||||
program|Program
|
||||
webradio|WebRadio
|
||||
spot|CM
|
||||
tvsp|CM
|
||||
lyric|ED
|
||||
announcement|PV
|
||||
ova|Special
|
||||
special|Special
|
||||
sp|Special
|
||||
iv|IV
|
||||
EOF
|
||||
echo "Special"
|
||||
}
|
||||
|
||||
# 回退命名(AI 尽力后仍失败):按文件名推断的标题组织;年份可省略;空集名不加 " - " 段。
|
||||
# 参数:type title year season episode episode_end fragment ext → 输出 子目录|文件名
|
||||
# 命名格式走 NAMING_* 模板(与识别路径一致,保证两套命名规则统一)。
|
||||
fallback_naming() {
|
||||
local type="$1" title="$2" year="$3" season="$4" episode="$5" episode_end="$6" fragment="$7" ext="$8"
|
||||
local safe_title s_fmt e_fmt
|
||||
safe_title=$(sanitize "$title")
|
||||
local show_dir mname
|
||||
show_dir=$(render_naming NAMING_SHOW "title=$safe_title" "year=$year")
|
||||
show_dir=$(sanitize "$show_dir")
|
||||
[[ -n "$show_dir" ]] || show_dir="$safe_title"
|
||||
if [[ "$type" == "movie" ]]; then
|
||||
mname=$(render_naming NAMING_MOVIE "title=$safe_title" "year=$year")
|
||||
mname=$(sanitize "$mname")
|
||||
[[ -n "$mname" ]] || mname="$safe_title"
|
||||
echo "${FOLDER_MOVIES}/${mname}|${mname}.${ext}"
|
||||
elif [[ "$type" == "musicvideo" ]]; then
|
||||
# 音乐视频回退(本地规则识别失败/防御路径):艺术家不可知 → FOLDER_UNKNOWN
|
||||
local mroot
|
||||
mroot=$(render_naming NAMING_MUSICVIDEO "artist=$FOLDER_UNKNOWN" "title=$safe_title")
|
||||
[[ -n "$mroot" ]] || mroot="${FOLDER_UNKNOWN}/${safe_title}"
|
||||
echo "${FOLDER_MUSICVIDEOS}/${mroot}|${safe_title}.${ext}"
|
||||
elif [[ -n "$fragment" ]] && [[ "${season:-0}" == "0" ]]; then
|
||||
special_s00_dest "$show_dir" "$fragment" "$ext"
|
||||
else
|
||||
s_fmt=$(safe_printf_int "${season:-1}" "00")
|
||||
e_fmt=$(safe_printf_int "${episode:-0}" "00")
|
||||
local ep_range="" e_fmt_end=""
|
||||
if [[ -n "$episode_end" ]]; then
|
||||
e_fmt_end=$(safe_printf_int "$episode_end" "00")
|
||||
ep_range="-E${e_fmt_end}"
|
||||
fi
|
||||
local season_dir ep_name
|
||||
season_dir=$(render_naming NAMING_SEASON "season=$s_fmt")
|
||||
ep_name=$(render_naming NAMING_EPISODE \
|
||||
"season=$s_fmt" "episode=$e_fmt" "range=$ep_range" \
|
||||
"episode_name=" "episode_end=$e_fmt_end")
|
||||
ep_name=$(sanitize "$ep_name")
|
||||
[[ -n "$ep_name" ]] || ep_name="S${s_fmt}E${e_fmt}${ep_range}"
|
||||
echo "${FOLDER_SHOWS}/${show_dir}/${season_dir}|${ep_name}.${ext}"
|
||||
fi
|
||||
}
|
||||
|
||||
# 解析待 AI 判定艺术家的音频条目:AI 有判定用判定结果,否则所有艺术家用 " & " 拼接。
|
||||
resolve_pending_artists() {
|
||||
local force="${1:-}"
|
||||
local -a keys=()
|
||||
if [[ -n "$force" ]]; then
|
||||
keys=("${!PENDING_AI_ARTIST[@]}")
|
||||
else
|
||||
keys=("${AI_BATCH_KEYS_ARTIST[@]}")
|
||||
fi
|
||||
local audio
|
||||
for audio in "${keys[@]}"; do
|
||||
[[ -n "${PENDING_AI_ARTIST[$audio]:-}" ]] || continue
|
||||
# AI 漏了该条目 → 留待下一批(force 模式忽略该检查)
|
||||
[[ -n "$force" || -n "${AI_RESPONDED_ARTIST[$audio]:-}" ]] || continue
|
||||
local arts_album album rel_dir choice
|
||||
IFS=$'\t' read -r arts_album album rel_dir <<<"${PENDING_AI_ARTIST[$audio]}"
|
||||
choice="${AI_ARTIST_CHOICE[$audio]:-}"
|
||||
local artist="$choice"
|
||||
if [[ -z "$artist" ]]; then
|
||||
artist="$arts_album" # 已在存储时用 " & " 连接
|
||||
_log 警告 "AI 无法判定艺术家,拼接处理: $artist"
|
||||
fi
|
||||
local subdir music_root
|
||||
# 音乐目录结构走 NAMING_MUSIC 模板(默认 {artist}/{album};可自定义如
|
||||
# "{album} ({year})" 等——注意变量集合仅 artist/album)
|
||||
music_root=$(render_naming NAMING_MUSIC \
|
||||
"artist=$(sanitize "$artist")" "album=$(sanitize "$album")")
|
||||
[[ -n "$music_root" ]] || music_root="$(sanitize "$artist")/$(sanitize "$album")"
|
||||
subdir="${FOLDER_MUSIC}/$music_root"
|
||||
[[ -n "$rel_dir" ]] && subdir="$subdir/$rel_dir"
|
||||
register_identified "$audio" "$subdir" "$(basename "$audio")"
|
||||
# 歌词伴随(同名 lrc/elrc/txt)
|
||||
local base_name="${audio%.*}" c
|
||||
for c in "${base_name}.lrc" "${base_name}.elrc" "${base_name}.txt"; do
|
||||
[[ -f "$c" ]] && register_identified "$c" "$subdir" "$(basename "$c")"
|
||||
done
|
||||
_log 信息 "音频艺术家确定: $(basename "$audio") -> $subdir/"
|
||||
unset "PENDING_AI_ARTIST[$audio]"
|
||||
PENDING_ARTIST_COUNT=$((PENDING_ARTIST_COUNT - 1))
|
||||
done
|
||||
}
|
||||
|
||||
# AI 辅助重处理待定搜索项:AI 纠正词 → 原始标题兜底 → 回退命名(降级成功)。
|
||||
reprocess_pending_searches() {
|
||||
local video
|
||||
for video in "${AI_BATCH_KEYS_SEARCH[@]}"; do
|
||||
[[ -n "${PENDING_AI_SEARCH[$video]:-}" ]] || continue
|
||||
# AI 漏了该条目 → 留待下一批
|
||||
[[ -n "${AI_RESPONDED_SEARCH[$video]:-}" ]] || continue
|
||||
_log 信息 "重新处理(AI辅助): $(basename "$video")"
|
||||
push_indent
|
||||
|
||||
local info type title year season episode episode_end special_fragment base ext dest rc=0
|
||||
info=$(parse_media_filename "$video")
|
||||
IFS='|' read -r type title year season episode episode_end special_fragment <<<"$info"
|
||||
base="${video##*/}"
|
||||
base="${base%.*}"
|
||||
ext="${video##*.}"
|
||||
|
||||
# identify 在命令替换中执行(子 shell 丢 emit 副作用)——经临时 emit 文件回传合并
|
||||
local tmp_emit
|
||||
tmp_emit=$(mktemp)
|
||||
POOL_EMIT_FILE="$tmp_emit"
|
||||
|
||||
# 使用 AI 纠正后的搜索词重新搜索(复用 identify_*,无内联重复)
|
||||
local ai_data old_type ai_search ai_year ai_media
|
||||
ai_data="${PENDING_AI_SEARCH[$video]:-}"
|
||||
IFS=$'\t' read -r old_type _ ai_search ai_year ai_media <<<"$ai_data"
|
||||
local eff_type="$old_type"
|
||||
if [[ "$eff_type" == "unknown" && -n "$ai_media" ]]; then
|
||||
eff_type="$ai_media"
|
||||
fi
|
||||
if [[ -n "$ai_search" ]]; then
|
||||
if [[ "$eff_type" == "movie" ]]; then
|
||||
_log 搜索 "使用 AI 纠正词: $ai_search (年份: ${ai_year:-})"
|
||||
dest=$(identify_movie "$base" "$ext" "$video" "$ai_search" "${ai_year:-}") || rc=$?
|
||||
else
|
||||
_log 搜索 "使用 AI 纠正词搜索剧集: $ai_search"
|
||||
dest=$(identify_tv_show "$ai_search" "${season:-1}" "${episode:-0}" "${episode_end:-}" \
|
||||
"$base" "$ext" "$special_fragment" "$video") || rc=$?
|
||||
fi
|
||||
fi
|
||||
|
||||
# 兜底重试(原始标题,仅 tv——movie 走 AI 纠正词,失败即回退)
|
||||
if [[ $rc -ne 2 ]] && { [[ $rc -ne 0 ]] || [[ -z "$dest" ]]; } && [[ "$type" == "tv" ]]; then
|
||||
dest=$(identify_tv_show "$title" "${season:-1}" "${episode:-0}" "${episode_end:-}" \
|
||||
"$base" "$ext" "$special_fragment" "$video") || rc=$?
|
||||
fi
|
||||
|
||||
unset POOL_EMIT_FILE
|
||||
merge_emit_lines "$tmp_emit"
|
||||
rm -f "$tmp_emit"
|
||||
|
||||
# 记录结果或回退命名
|
||||
local destination_subdir destination_filename
|
||||
if [[ $rc -eq 0 && -n "$dest" ]]; then
|
||||
IFS='|' read -r destination_subdir destination_filename <<<"$dest"
|
||||
register_identified "$video" "$destination_subdir" "$destination_filename"
|
||||
elif [[ $rc -eq 2 ]]; then
|
||||
register_request_failed "$video"
|
||||
elif [[ $rc -eq 3 ]]; then
|
||||
# identify 已重新登记 MATCH(下一批 AI 匹配)
|
||||
_log 信息 "需甄别,交 AI 匹配: $(basename "$video")"
|
||||
else
|
||||
# AI 尽力后仍失败 → 回退命名(降级成功)
|
||||
_log 错误 "经 AI 处理后仍无法识别,使用回退命名"
|
||||
local fallback_dest
|
||||
fallback_dest=$(fallback_naming "$type" "$title" "$year" "${season:-1}" "${episode:-0}" "${episode_end:-}" "$special_fragment" "$ext")
|
||||
IFS='|' read -r destination_subdir destination_filename <<<"$fallback_dest"
|
||||
register_fallback "$video" "$destination_subdir" "$destination_filename"
|
||||
_log 信息 "回退命名: ${destination_subdir}/${destination_filename}"
|
||||
fi
|
||||
|
||||
pop_indent
|
||||
unset "PENDING_AI_SEARCH[$video]"
|
||||
PENDING_SEARCH_COUNT=$((PENDING_SEARCH_COUNT - 1))
|
||||
done
|
||||
}
|
||||
|
||||
# 消费 AI 匹配结果:choice → 脚本构建命名(build_*_dest,命名是脚本职责);
|
||||
# season_shift 叠加到文件季号;search_term → 纠正词重搜取首条(单轮优先);仍失败 → 回退命名。
|
||||
resolve_pending_matches() {
|
||||
local video
|
||||
for video in "${AI_BATCH_KEYS_MATCH[@]}"; do
|
||||
[[ -n "${PENDING_AI_MATCH[$video]:-}" ]] || continue
|
||||
# AI 漏了该条目 → 留待下一批
|
||||
[[ -n "${AI_RESPONDED_MATCH[$video]:-}" ]] || continue
|
||||
_log 信息 "AI 匹配: $(basename "$video")"
|
||||
push_indent
|
||||
|
||||
local m_type _m_year m_season m_episode _m_endpoint _m_key
|
||||
IFS=$'\t' read -r m_type _m_year m_season m_episode _m_endpoint _m_key <<<"${PENDING_AI_MATCH[$video]}"
|
||||
local base ext
|
||||
base="${video##*/}"
|
||||
base="${base%.*}"
|
||||
ext="${video##*.}"
|
||||
local dest="" rc=0
|
||||
local choice="${AI_MATCH_CHOICE[$video]:-}"
|
||||
local shift="${AI_MATCH_SHIFT[$video]:-}"
|
||||
local term="${AI_MATCH_TERM[$video]:-}"
|
||||
|
||||
if [[ -n "$choice" ]]; then
|
||||
# AI 选中候选 → 脚本构建命名
|
||||
local eff_season="${m_season:-1}"
|
||||
if [[ -n "$shift" ]] && [[ "$shift" =~ ^[0-9]+$ ]]; then
|
||||
eff_season=$((10#$eff_season + 10#$shift))
|
||||
_log 信息 "AI 季映射: S${m_season} → S${eff_season}"
|
||||
fi
|
||||
if [[ "$m_type" == "movie" ]]; then
|
||||
dest=$(build_movie_dest "$choice" "$ext" "" "") || rc=$?
|
||||
else
|
||||
dest=$(build_tv_dest "$choice" "$eff_season" "${m_episode:-1}" "" "" "$base" "$ext" "$video" "") || rc=$?
|
||||
fi
|
||||
fi
|
||||
# AI 无匹配 + 纠正词 → 重搜取首条(单轮优先,不给 AI 第二轮);
|
||||
# identify 在命令替换中执行——经临时 emit 文件回传 MATCH/SPECIAL 副作用
|
||||
if [[ -z "$dest" ]] && [[ -n "$term" ]]; then
|
||||
_log 搜索 "AI 纠正词重搜: $term"
|
||||
local tmp_emit
|
||||
tmp_emit=$(mktemp)
|
||||
POOL_EMIT_FILE="$tmp_emit"
|
||||
if [[ "$m_type" == "movie" ]]; then
|
||||
dest=$(identify_movie "$base" "$ext" "$video" "$term" "") || rc=$?
|
||||
else
|
||||
dest=$(identify_tv_show "$term" "${m_season:-1}" "${m_episode:-0}" "" "$base" "$ext" "" "$video") || rc=$?
|
||||
fi
|
||||
unset POOL_EMIT_FILE
|
||||
merge_emit_lines "$tmp_emit"
|
||||
rm -f "$tmp_emit"
|
||||
fi
|
||||
|
||||
# 记录结果或回退命名
|
||||
local destination_subdir destination_filename
|
||||
if [[ $rc -eq 0 && -n "$dest" ]]; then
|
||||
IFS='|' read -r destination_subdir destination_filename <<<"$dest"
|
||||
register_identified "$video" "$destination_subdir" "$destination_filename"
|
||||
elif [[ $rc -eq 2 ]]; then
|
||||
register_request_failed "$video"
|
||||
elif [[ $rc -eq 3 ]]; then
|
||||
: # identify 已重新登记 MATCH(下一批处理)
|
||||
else
|
||||
# AI 判断无解 → 回退命名(降级成功)
|
||||
local info type title year season episode episode_end special_fragment
|
||||
info=$(parse_media_filename "$video")
|
||||
IFS='|' read -r type title year season episode episode_end special_fragment <<<"$info"
|
||||
_log 错误 "AI 匹配无解,使用回退命名"
|
||||
local fallback_dest
|
||||
fallback_dest=$(fallback_naming "$type" "$title" "$year" "${season:-1}" "${episode:-0}" "${episode_end:-}" "$special_fragment" "$ext")
|
||||
IFS='|' read -r destination_subdir destination_filename <<<"$fallback_dest"
|
||||
register_fallback "$video" "$destination_subdir" "$destination_filename"
|
||||
_log 信息 "回退命名: ${destination_subdir}/${destination_filename}"
|
||||
fi
|
||||
|
||||
pop_indent
|
||||
unset "PENDING_AI_MATCH[$video]"
|
||||
PENDING_MATCH_COUNT=$((PENDING_MATCH_COUNT - 1))
|
||||
done
|
||||
}
|
||||
|
||||
# 第二阶段:AI 批处理(纠正搜索词、学习特典映射、判定艺术家、匹配甄别、重处理待定文件)。
|
||||
# 分批循环:每批 AI_BATCH_SIZE 条;AI_MAX_CALLS 为批次上限;消费端只处理响应覆盖的条目。
|
||||
run_ai_batch() {
|
||||
# 无 AI 密钥:搜索/匹配待定显式登记跳过(可逆——下次运行自动重试);多艺术家直接拼接
|
||||
if [[ -z "${AI_API_KEY:-}" ]]; then
|
||||
local f
|
||||
for f in "${!PENDING_AI_SEARCH[@]}"; do
|
||||
_log 警告 "未识别跳过(无 AI 密钥): $(basename "$f")"
|
||||
register_skip "$f" "skip_unidentified"
|
||||
done
|
||||
for f in "${!PENDING_AI_MATCH[@]}"; do
|
||||
_log 警告 "需甄别跳过(无 AI 密钥): $(basename "$f")"
|
||||
register_skip "$f" "skip_unidentified"
|
||||
done
|
||||
PENDING_AI_SEARCH=()
|
||||
PENDING_SEARCH_COUNT=0
|
||||
PENDING_AI_MATCH=()
|
||||
PENDING_MATCH_COUNT=0
|
||||
resolve_pending_artists force
|
||||
return 0
|
||||
fi
|
||||
if [[ $PENDING_SEARCH_COUNT -eq 0 ]] &&
|
||||
[[ $PENDING_SPECIAL_COUNT -eq 0 ]] &&
|
||||
[[ $PENDING_ARTIST_COUNT -eq 0 ]] &&
|
||||
[[ $PENDING_MATCH_COUNT -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
_log 信息 "开始 AI 批量处理..."
|
||||
local ai_failed=false
|
||||
while ((PENDING_SEARCH_COUNT > 0 || PENDING_SPECIAL_COUNT > 0 || \
|
||||
PENDING_ARTIST_COUNT > 0 || PENDING_MATCH_COUNT > 0)); do
|
||||
if [[ "$AI_CALL_COUNT" -ge "${AI_MAX_CALLS}" ]]; then
|
||||
_log 警告 "AI 调用次数已达上限($AI_MAX_CALLS),剩余条目显式跳过"
|
||||
ai_failed=true
|
||||
break
|
||||
fi
|
||||
if ! ai_batch_request; then
|
||||
ai_failed=true
|
||||
break
|
||||
fi
|
||||
# 特典映射学习已由 ai_batch_request 完成(写回映射文件与内存映射)
|
||||
PENDING_AI_SPECIAL=()
|
||||
PENDING_SPECIAL_COUNT=0
|
||||
resolve_pending_artists
|
||||
reprocess_pending_searches
|
||||
resolve_pending_matches
|
||||
done
|
||||
# AI 失败(网络/非 JSON/干运行/上限):剩余搜索/匹配待定显式跳过(可逆,与无 AI 语义统一)
|
||||
if $ai_failed; then
|
||||
local f
|
||||
for f in "${!PENDING_AI_SEARCH[@]}"; do
|
||||
register_skip "$f" "skip_unidentified"
|
||||
done
|
||||
for f in "${!PENDING_AI_MATCH[@]}"; do
|
||||
register_skip "$f" "skip_unidentified"
|
||||
done
|
||||
PENDING_AI_SEARCH=()
|
||||
PENDING_SEARCH_COUNT=0
|
||||
PENDING_AI_MATCH=()
|
||||
PENDING_MATCH_COUNT=0
|
||||
# 艺术家走拼接(信息不丢,优于跳过)
|
||||
resolve_pending_artists force
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
################################################################################
|
||||
# 媒体文件名解析
|
||||
################################################################################
|
||||
|
||||
# 从文件路径向上查找剧名目录(跳过特典/分类/CD 子目录)。
|
||||
# 用于特典文件名不含剧名时(如 SPs/CM01.mkv)从父目录链推断所属剧集。
|
||||
find_show_dir_from_path() {
|
||||
local file="$1"
|
||||
local dir parent cleaned_dir
|
||||
dir=$(dirname "$file")
|
||||
while [[ -n "$dir" && "$dir" != "/" && "$dir" != "." ]]; do
|
||||
parent=$(basename "$dir")
|
||||
# 跳过特典目录与常见分类目录(skip_directories 词表,video 流程分区)
|
||||
if skip_dir_in video "${parent,,}"; then
|
||||
dir=$(dirname "$dir")
|
||||
continue
|
||||
fi
|
||||
# 跳过以 [数字 开头的 CD 子目录(如 [200226]...)
|
||||
if echo "$parent" | grep -qE '^\[[0-9]'; then
|
||||
dir=$(dirname "$dir")
|
||||
continue
|
||||
fi
|
||||
cleaned_dir=$(clean_name "$parent")
|
||||
if [[ -n "$cleaned_dir" ]]; then
|
||||
echo "$parent"
|
||||
return 0
|
||||
fi
|
||||
dir=$(dirname "$dir")
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 从文件路径向上查找媒体(剧名/电影名)目录的完整路径。
|
||||
# 跳过特典目录、分类目录、CD 编号目录等;返回首个"媒体名"目录。
|
||||
# 用于:①特典文件名不含媒体名时推断归属;②统计媒体目录内正片数量以区分电影/剧集。
|
||||
find_show_path_from_file() {
|
||||
local file="$1" dir parent cleaned
|
||||
dir=$(dirname "$file")
|
||||
while [[ -n "$dir" && "$dir" != "/" && "$dir" != "." ]]; do
|
||||
parent=$(basename "$dir")
|
||||
# 跳过特典目录与常见分类目录(skip_directories 词表,video 流程分区)
|
||||
if skip_dir_in video "${parent,,}"; then
|
||||
dir=$(dirname "$dir")
|
||||
continue
|
||||
fi
|
||||
# 跳过以 [数字 开头的 CD 子目录(如 [200226]...)
|
||||
if echo "$parent" | grep -qE '^\[[0-9]'; then
|
||||
dir=$(dirname "$dir")
|
||||
continue
|
||||
fi
|
||||
cleaned=$(clean_name "$parent")
|
||||
if [[ -n "$cleaned" ]]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
dir=$(dirname "$dir")
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 统计媒体目录内"正片"(非特典/附带)视频文件数量。
|
||||
# 用于文件名无明确季集/年份特征时区分电影/剧集(不依赖下载目录,合集种子可能混合):
|
||||
# 仅 1 个正片 → 电影(如合集里的剧场版);多个正片 → 剧集(多集动画)。
|
||||
# 注意:mka 是纯音频容器(电影音轨),不算正片视频。
|
||||
# 结果按 show 目录缓存,避免重复扫描。
|
||||
count_main_videos() {
|
||||
local dir="$1" f count=0
|
||||
if [[ -n "${MAIN_COUNT_CACHE[$dir]:-}" ]]; then
|
||||
echo "${MAIN_COUNT_CACHE[$dir]}"
|
||||
return
|
||||
fi
|
||||
# 用 VIDEO_EXTS 动态生成 find -name 列表(与配置一致,用户加扩展即生效)
|
||||
local -a name_args=()
|
||||
local ext
|
||||
for ext in "${VIDEO_EXTS[@]}"; do
|
||||
[[ -z "$ext" ]] && continue
|
||||
name_args+=(-name "*.${ext}")
|
||||
done
|
||||
local -a find_args=(-maxdepth 2 -type f)
|
||||
if [[ ${#name_args[@]} -gt 0 ]]; then
|
||||
# name_args 是 -name/pattern 交替对——按对追加,-o 插在配对之间
|
||||
local -a joined=()
|
||||
local i
|
||||
for ((i = 0; i < ${#name_args[@]}; i += 2)); do
|
||||
joined+=("${name_args[$i]}" "${name_args[$((i + 1))]}")
|
||||
((i + 2 < ${#name_args[@]})) && joined+=(-o)
|
||||
done
|
||||
find_args+=("(" "${joined[@]}" ")")
|
||||
fi
|
||||
while IFS= read -r -d '' f; do
|
||||
# 跳过特典/附带子目录(SPs/CDs/Scans/Fonts 等,词表见 skip_directories.json)
|
||||
local skip=false seg
|
||||
for seg in ${f//\// }; do
|
||||
if skip_dir_in video "${seg,,}"; then
|
||||
skip=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
$skip && continue
|
||||
count=$((count + 1))
|
||||
done < <(find "$dir" "${find_args[@]}" -print0 2>/dev/null)
|
||||
MAIN_COUNT_CACHE["$dir"]="$count"
|
||||
echo "$count"
|
||||
}
|
||||
|
||||
# 特典识别(Season 00):文件名含特典标记(可带可不带数字),或父目录为特典目录。
|
||||
# 如 "2nd Season [Menu01]"、" [NCOP]"、"CM01.mkv"(在 SPs 目录)均识别为特典。
|
||||
# 输出(stdout 协议,空=非特典):
|
||||
# skip|movie_extra|<year>|<season>|<episode>|<episode_end>|<fragment> 电影特典(TMDB 不收录,跳过)
|
||||
# tv|<title>|<year>|0|<ep_num>|<fragment> 剧集特典(归 Season 00)
|
||||
detect_special() {
|
||||
local file="$1" base="$2" cleaned="$3" year="$4" season="$5" episode="$6" episode_end="$7"
|
||||
# 词表来自 special_keywords.json(tv 分区 → default 分区 → 全局表,special_word_hit),
|
||||
# 语义=特典类别词;空格归一化后子串匹配(纯字符串匹配,词表内容不当作正则;"nc op" == "ncop")。
|
||||
local is_special=false
|
||||
local frag=""
|
||||
local tag=""
|
||||
local frag_raw="" # 词表匹配到的原始括号内容(保留大小写,如 "Menu01")
|
||||
local i
|
||||
if echo "$base" | grep -qE '\[[^]]*\]'; then
|
||||
local t_line t_norm hit
|
||||
while IFS= read -r t_line; do
|
||||
[[ -z "$t_line" ]] && continue
|
||||
t_norm="${t_line//[[:space:]]/}"
|
||||
t_norm="${t_norm,,}"
|
||||
if hit=$(special_word_hit tv "$t_norm"); then
|
||||
is_special=true
|
||||
frag="$hit"
|
||||
tag="[$t_line]"
|
||||
frag_raw="$t_line"
|
||||
break
|
||||
fi
|
||||
done < <(echo "$base" | grep -oE '\[[^]]*\]' | tr -d '[]')
|
||||
fi
|
||||
# 父目录为特典目录(SPs/Specials/CDs/Bonus/Extras 等)
|
||||
if ! $is_special; then
|
||||
local parent_dir
|
||||
parent_dir=$(basename "$(dirname "$file")")
|
||||
if skip_dir_in video "${parent_dir,,}"; then
|
||||
is_special=true
|
||||
fi
|
||||
fi
|
||||
if ! $is_special; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local ep_num sp_frag stripped
|
||||
# 电影/剧集特典归属(不依赖下载目录,合集种子可能混合电影与剧集):
|
||||
# 媒体目录仅 1 个正片 → 电影特典(TMDB 不收录,跳过不整理);
|
||||
# 多个正片 → 剧集特典(归 Season 00)。
|
||||
local show_path main_count
|
||||
show_path=$(find_show_path_from_file "$file")
|
||||
if [[ -n "$show_path" ]]; then
|
||||
main_count=$(count_main_videos "$show_path")
|
||||
if ((main_count == 1)); then
|
||||
echo "skip|movie_extra|${year}|${season}|${episode}|${episode_end}|"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# 特典编号与片段:
|
||||
# - 文件名标记匹配到特典词时(frag 非空),用 frag+编号(如 Preview02)
|
||||
# - 仅父目录判断(SPs 等)时,从方括号标记中挑选特典片段(跳过压制组/编码/画质
|
||||
# 格式标记),避免用完整文件名(会导致 S00 前缀超长且后续 translate 误替换)
|
||||
if [[ -n "$frag" ]]; then
|
||||
ep_num=$(echo "$tag" | grep -oE '[0-9]+' | head -1)
|
||||
# 优先原始括号内容(保留大小写,如 "Menu01");无原始值时用小写词表词+编号
|
||||
sp_frag="${frag_raw:-${frag}${ep_num:-}}"
|
||||
else
|
||||
local pick="" tag_line
|
||||
while IFS= read -r tag_line; do
|
||||
[[ -z "$tag_line" ]] && continue
|
||||
if ! echo "$tag_line" | grep -qiE \
|
||||
'vcb|studio|team|ma10p|hi10p|x26[45]|flac|aac|opus|ac3|dts|1080p|720p|480p|2160p'; then
|
||||
pick="$tag_line"
|
||||
break
|
||||
fi
|
||||
done < <(echo "$base" | grep -oE '\[[^]]*\]' | tr -d '[]')
|
||||
if [[ -n "$pick" ]]; then
|
||||
sp_frag="$pick"
|
||||
elif [[ -n "$cleaned" ]]; then
|
||||
# 无方括号特典标记(如裸特典 CM01.mkv):用 clean_name 后的文件名
|
||||
sp_frag="$cleaned"
|
||||
else
|
||||
sp_frag="SP"
|
||||
fi
|
||||
ep_num=$(echo "$sp_frag" | grep -oE '[0-9]+' | head -1)
|
||||
fi
|
||||
local title
|
||||
title=$(echo "$cleaned" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||
# 若标题仅由特典标记构成(无剧名),从父目录链向上查找剧名目录
|
||||
stripped=""
|
||||
if [[ -n "$frag" ]]; then
|
||||
stripped=$(echo "$title" | sed -E "s/${frag}[0-9]*//Ig" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||
fi
|
||||
if [[ -z "$stripped" ]]; then
|
||||
title=$(find_show_dir_from_path "$file")
|
||||
[[ -n "$title" ]] && title=$(clean_name "$title")
|
||||
[[ -z "$title" ]] && title="$cleaned"
|
||||
fi
|
||||
# 输出契约 7 段(type|title|year|season|episode|episode_end|special_fragment)——
|
||||
# episode_end 空段不可省略(缺段会让 read 把 fragment 读进 episode_end,特典匹配全失效)
|
||||
echo "tv|${title}|${year}|0|${ep_num:-1}||${sp_frag}"
|
||||
}
|
||||
|
||||
# Music Videos(音乐视频)识别(v9.6):目录信号或文件名信号命中 musicvideo 词表
|
||||
# (special_keywords.json 的 musicvideo 分区 → default 分区 → 内置默认 MUSICVIDEO_WORDS;
|
||||
# **不回退** tv 特典词表——"sp" 子串命中 "SPs" 目录、"cm" 命中 "tmp.xxx" 等交叉误判)。
|
||||
# - 目录信号:父目录链任意段(到源根为止)目录名**精确匹配**(归一化去空格小写
|
||||
# 整名相等)musicvideo 词(如 "Music Videos"/"MV"/"Live"/"演唱会")→ 整目录音乐视频。
|
||||
# 精确匹配避免普通目录名含词误判("Muv-Luv"/"PV" 特典目录等)。
|
||||
# - 文件名信号:方括号标记**子串**命中 musicvideo 词,且该标记**未命中**特典词
|
||||
# (tv 分区/全局表)——[MV]/[Live] 判音乐视频;[PV]/[CM] 不在 musicvideo 词表
|
||||
# → 保持特典路径(剧集 PV/CM 特典不被误判)。
|
||||
# 输出契约(stdout 协议,空=非音乐视频):
|
||||
# musicvideo|<title>|<year>|||| (title = clean 后文件名)
|
||||
detect_musicvideo() {
|
||||
local file="$1" base="$2" cleaned="$3" year="$4"
|
||||
local dir parent pn
|
||||
# 目录信号:父目录向上到源根(精确匹配整目录名)
|
||||
dir=$(dirname "$file")
|
||||
while [[ -n "$dir" && "$dir" != "/" && "$dir" != "." ]]; do
|
||||
parent=$(basename "$dir")
|
||||
pn="${parent,,}"
|
||||
pn="${pn//[[:space:]]/}"
|
||||
if special_word_exact musicvideo "$pn"; then
|
||||
_log 调试 "音乐视频目录信号: $parent"
|
||||
echo "musicvideo|$(echo "$cleaned" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')|${year}||||"
|
||||
return 0
|
||||
fi
|
||||
[[ -n "$SOURCE_DIR" && "$dir" == "$SOURCE_DIR" ]] && break
|
||||
dir=$(dirname "$dir")
|
||||
done
|
||||
# 文件名信号:方括号标记子串命中 musicvideo 词。双命中歧义(标记同时在
|
||||
# 特典词表,如 [MV]/[PV])用 "歌手 - 歌名" 模式消解:文件名含 " - " →
|
||||
# 音乐视频(歌手分隔是音乐视频文件名的典型结构);不含 → 保持特典路径
|
||||
# ("动画名 [MV]" 剧集 MV 特典不被误判)。用户可通过两词表完全控制:
|
||||
# musicvideo 分区删词 → 永不判音乐视频;tv 特典词表删词 → 一律判音乐视频。
|
||||
if echo "$base" | grep -qE '\[[^]]*\]'; then
|
||||
local t_line tn
|
||||
while IFS= read -r t_line; do
|
||||
[[ -z "$t_line" ]] && continue
|
||||
tn="${t_line//[[:space:]]/}"
|
||||
tn="${tn,,}"
|
||||
if special_word_hit musicvideo "$tn" >/dev/null; then
|
||||
local dual=false
|
||||
if special_word_hit tv "$tn" >/dev/null; then
|
||||
dual=true
|
||||
fi
|
||||
if ! $dual || [[ "$cleaned" == *" - "* ]]; then
|
||||
_log 调试 "音乐视频文件信号: [$t_line]"
|
||||
echo "musicvideo|$(echo "$cleaned" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')|${year}||||"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done < <(echo "$base" | grep -oE '\[[^]]*\]' | tr -d '[]')
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 从同目录视频文件名集合剥离公共前后缀,提取当前文件的差异数字作集号。
|
||||
# 适用:压制组风格([Group] Title - 01 / Title - 02),文件名无方括号数字时
|
||||
# 公共部分剔除后,中段数字即集号(BAR find_common_substrings 思路的 bash 简化版)。
|
||||
# 输出:集号(stdout);无法提取输出空。数字排除分辨率(1080/720/480/2160/4320),
|
||||
# 取中段末尾数字(集号通常在末尾)。
|
||||
extract_episode_from_peers() {
|
||||
local file="$1" dir="$2"
|
||||
local -a stems=()
|
||||
local f2
|
||||
for f2 in "$dir"/*; do
|
||||
[[ -f "$f2" ]] || continue
|
||||
local ext2="${f2##*.}"
|
||||
ext2="${ext2,,}"
|
||||
[[ " ${VIDEO_EXTS[*]} " =~ [[:space:]]${ext2}[[:space:]] ]] || continue
|
||||
stems+=("$(basename "$f2")")
|
||||
done
|
||||
((${#stems[@]} >= 2)) || return 1
|
||||
|
||||
# 最长公共前缀(逐字符比较所有 stem)
|
||||
local first="${stems[0]}"
|
||||
local plen=${#first} i s
|
||||
for s in "${stems[@]:1}"; do
|
||||
i=0
|
||||
while ((i < plen && i < ${#s})) && [[ "${first:i:1}" == "${s:i:1}" ]]; do
|
||||
((i++))
|
||||
done
|
||||
plen=$i
|
||||
((plen == 0)) && break
|
||||
done
|
||||
|
||||
# 最长公共后缀(中段保护:后缀不越过公共前缀边界)
|
||||
local suf=0 max_suf=$((${#first} - plen)) s2 pos ok ch c
|
||||
while ((suf < max_suf)); do
|
||||
ok=true
|
||||
ch=""
|
||||
for s2 in "${stems[@]}"; do
|
||||
pos=$((${#s2} - 1 - suf))
|
||||
((pos >= plen)) || {
|
||||
ok=false
|
||||
break
|
||||
}
|
||||
c="${s2:pos:1}"
|
||||
if [[ -z "$ch" ]]; then
|
||||
ch="$c"
|
||||
elif [[ "$c" != "$ch" ]]; then
|
||||
ok=false
|
||||
break
|
||||
fi
|
||||
done
|
||||
$ok || break
|
||||
suf=$((suf + 1))
|
||||
done
|
||||
|
||||
# 当前文件 stem 的中段 = 剥离公共前后缀后的差异部分
|
||||
local stem mid
|
||||
stem=$(basename "$file")
|
||||
mid="${stem:plen:${#stem}-plen-suf}"
|
||||
# 中段末尾数字(集号通常在末尾);排除分辨率
|
||||
if [[ "$mid" =~ ([0-9]{1,3})[^0-9]*$ ]]; then
|
||||
local n="${BASH_REMATCH[1]}"
|
||||
if ! [[ "$n" =~ ^(1080|720|480|2160|4320)$ ]]; then
|
||||
echo "$((10#$n))"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 解析多集区间尾部,输出末集号(无区间输出空)。
|
||||
# 输入 = S##E## 匹配点之后的尾部字符串(如 "-E02"、" -E02E03"、"E05");
|
||||
# 支持连续扩展段(S01E01-E02E03 → 03)。
|
||||
# 注意:调用方传入的匹配点可能含 glob 元字符,本函数只做子串运算不展开。
|
||||
parse_episode_range_tail() {
|
||||
local range_tail="$1"
|
||||
local episode_end="" tail2
|
||||
if [[ "$range_tail" =~ ^[\ ._-]*[Ee]([0-9]{1,2}) ]]; then
|
||||
episode_end="${BASH_REMATCH[1]}"
|
||||
tail2="${range_tail#*"${BASH_REMATCH[0]}"}"
|
||||
while [[ "$tail2" =~ ^[\ ._-]*[Ee]([0-9]{1,2}) ]]; do
|
||||
episode_end="${BASH_REMATCH[1]}"
|
||||
tail2="${tail2#*"${BASH_REMATCH[0]}"}"
|
||||
done
|
||||
fi
|
||||
echo "$episode_end"
|
||||
}
|
||||
|
||||
# 解析媒体文件名,输出:type|title|year|season|episode|special_fragment
|
||||
# type:movie(电影正片)/ tv(剧集,含特典 Season 00)/ skip(电影特典,跳过不整理)/ unknown(识别不出,交 AI)
|
||||
parse_media_filename() {
|
||||
local file="$1"
|
||||
local filename base ext cleaned
|
||||
filename=$(basename "$file")
|
||||
base="${filename%.*}"
|
||||
ext="${filename##*.}"
|
||||
cleaned=$(clean_name "$base")
|
||||
|
||||
local type="unknown" title="" year="" season="" episode="" episode_end="" special_fragment=""
|
||||
# 输出契约:type|title|year|season|episode|episode_end|special_fragment(episode_end 空=单集)
|
||||
|
||||
# (YYYY) 年份提取:任意位置(Title (2020) [1080p]、Show (2020) S01E01、Movie (2020))。
|
||||
# 提取后从 base 移除并重新 clean;TV 规则命中时年份进 year 字段,否则判定为 movie。
|
||||
if [[ "$base" =~ \(([0-9]{4})\) ]]; then
|
||||
year="${BASH_REMATCH[1]}"
|
||||
base="${base/\(${year}\)/}"
|
||||
cleaned=$(clean_name "$base")
|
||||
fi
|
||||
|
||||
# Music Videos 识别(v9.6):目录信号(目录链含音乐视频目录词)或文件名信号
|
||||
# (方括号标记命中 musicvideo 词且未命中特典词)→ musicvideo 类目。
|
||||
# 置于 TV/特典规则之前:目录信号优先(音乐视频目录下的文件即使文件名含
|
||||
# 季集标记也归类音乐视频);[MV]/[PV] 双命中场景按特典处理(保持现状)。
|
||||
local mv_result
|
||||
if mv_result=$(detect_musicvideo "$file" "$base" "$cleaned" "$year"); then
|
||||
echo "$mv_result"
|
||||
return
|
||||
fi
|
||||
|
||||
# 电视剧格式:S##E##(季/集号 1-2 位均可;支持多集区间 S01E01-E02 及扩展 S01E01-E02E03)
|
||||
if [[ "$cleaned" =~ (.*)[\ ._-]*[Ss]([0-9]{1,2})[Ee]([0-9]{1,2}) ]]; then
|
||||
title="${BASH_REMATCH[1]}"
|
||||
season="${BASH_REMATCH[2]}"
|
||||
episode="${BASH_REMATCH[3]}"
|
||||
# 多集区间:匹配点之后的 -E## / E## 段(可连续扩展)
|
||||
# 注意:${BASH_REMATCH[0]} 作 glob pattern 可能含元字符——整体引号按字面匹配(SC2295)
|
||||
episode_end=$(parse_episode_range_tail "${cleaned#*"${BASH_REMATCH[0]}"}")
|
||||
title=$(strip_season_suffix "$title")
|
||||
type="tv"
|
||||
echo "$type|$title|$year|$season|$episode|${episode_end:-}|$special_fragment"
|
||||
return
|
||||
fi
|
||||
|
||||
# 电视剧格式:#x##(季/集号 1-2 位均可)
|
||||
if [[ "$cleaned" =~ (.*)[\ ._-]*([0-9]{1,2})[xX]([0-9]{1,2}) ]]; then
|
||||
title="${BASH_REMATCH[1]}"
|
||||
season="${BASH_REMATCH[2]}"
|
||||
episode="${BASH_REMATCH[3]}"
|
||||
title=$(strip_season_suffix "$title")
|
||||
type="tv"
|
||||
echo "$type|$title|$year|$season|$episode|${episode_end:-}|$special_fragment"
|
||||
return
|
||||
fi
|
||||
|
||||
# 特典识别(Season 00):文件名含特典标记(可带可不带数字),或父目录为特典目录。
|
||||
# 如 "2nd Season [Menu01]"、" [NCOP]"、"CM01.mkv"(在 SPs 目录)均识别为特典。
|
||||
local special_result
|
||||
special_result=$(detect_special "$file" "$base" "$cleaned" "$year" "$season" "$episode" "${episode_end:-}")
|
||||
if [[ -n "$special_result" ]]; then
|
||||
echo "$special_result"
|
||||
return
|
||||
fi
|
||||
|
||||
# 季份格式:支持 "2nd Season"(季号在前)与 "Season 2"(季号在后,如盾勇 Season 2)
|
||||
if echo "$cleaned" | grep -qiE '([0-9]+)(st|nd|rd|th)?[[:space:]]*[Ss]eason|[Ss]eason[[:space:]]*[0-9]+'; then
|
||||
if [[ "$cleaned" =~ ([0-9]+)(st|nd|rd|th)?[[:space:]]*[Ss]eason ]]; then
|
||||
season="${BASH_REMATCH[1]}"
|
||||
title=$(echo "$cleaned" | sed -E 's/[[:space:]]*[0-9]+(st|nd|rd|th)?[[:space:]]*[Ss]eason[[:space:]]*[0-9]*//I' | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||
elif [[ "$cleaned" =~ [Ss]eason[[:space:]]*([0-9]+) ]]; then
|
||||
season="${BASH_REMATCH[1]}"
|
||||
title=$(echo "$cleaned" | sed -E 's/[[:space:]]*[Ss]eason[[:space:]]*[0-9]+//I' | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||
fi
|
||||
if [[ "$base" =~ \[([0-9]{1,3})(v[0-9]+)?(\.[0-9]+)?(\([^]]*\))?\] ]]; then
|
||||
episode="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
type="tv"
|
||||
echo "$type|$title|$year|$season|${episode:-0}|$special_fragment"
|
||||
return
|
||||
fi
|
||||
|
||||
# 方括号格式 [#]:支持 [NN]、[NNvN](重制版)、[NN.N](总集篇小数集号)、
|
||||
# [NN(注释)](如 25(Director's Cut))、[0]/[00](第 0 话特典)。
|
||||
# 小数集号与第 0 话 → 特典路径(season=0 + special_fragment,交 S0 匹配)。
|
||||
if [[ "$base" =~ \[([0-9]{1,3})(v[0-9]+)?(\.[0-9]+)?(\([^]]*\))?\] ]]; then
|
||||
local num="${BASH_REMATCH[1]}" dec="${BASH_REMATCH[3]}"
|
||||
if ! [[ "$num" =~ ^(1080|720|480|2160|4320)$ ]]; then
|
||||
if [[ -n "$dec" ]]; then
|
||||
# 小数集号(14.5 等)→ 特典路径(S0 匹配 第N.N话)
|
||||
special_fragment="${num}${dec}"
|
||||
season=0
|
||||
episode=""
|
||||
elif [[ "$num" == "0" ]]; then
|
||||
# 第 0 话(如异界战争 Reflection)→ 特典路径
|
||||
special_fragment="$num"
|
||||
season=0
|
||||
episode=""
|
||||
else
|
||||
episode="$num"
|
||||
fi
|
||||
title=$(echo "$cleaned" | sed -E 's/\[[0-9]{1,3}(v[0-9]+)?(\.[0-9]+)?(\([^]]*\))?\]//g')
|
||||
type="tv"
|
||||
echo "$type|$title|$year|${season:-1}|${episode:-}|${episode_end:-}|$special_fragment"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# 电影判定:无任何 TV/特典特征且年份存在((YYYY) 已提取)
|
||||
if [[ -n "$year" ]]; then
|
||||
title=$(echo "$cleaned" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||
type="movie"
|
||||
echo "$type|$title|$year|$season|$episode|${episode_end:-}|$special_fragment"
|
||||
return
|
||||
fi
|
||||
|
||||
# 文件名无明确季集/年份特征(如压制组风格 [Group] Title [1080p]):
|
||||
# 不依赖下载目录(合集种子可能把剧场版电影放在剧集目录下),
|
||||
# 根据媒体目录内正片数量判断:仅 1 个正片 → 电影;多个正片 → 剧集。
|
||||
# 源根直属散放文件(show_path == SOURCE_DIR)不统计(共享目录误判风险)→ 交 AI。
|
||||
local show_path main_count
|
||||
show_path=$(find_show_path_from_file "$file")
|
||||
if [[ -n "$show_path" ]] && [[ "$show_path" != "$SOURCE_DIR" ]]; then
|
||||
main_count=$(count_main_videos "$show_path")
|
||||
if ((main_count == 1)); then
|
||||
title="$cleaned"
|
||||
type="movie"
|
||||
echo "$type|$title|$year|$season|$episode|${episode_end:-}|$special_fragment"
|
||||
return
|
||||
elif ((main_count >= 2)); then
|
||||
title="$cleaned"
|
||||
type="tv"
|
||||
season="${season:-1}"
|
||||
if [[ -z "${episode:-}" || "$episode" == "0" ]]; then
|
||||
# 公共子串剥离:同目录视频的公共前后缀剔除后,中段数字作集号
|
||||
# (压制组风格 [Group] Title - 01 无方括号数字时不再丢失集号)
|
||||
local peer_ep
|
||||
peer_ep=$(extract_episode_from_peers "$file" "$show_path") || true
|
||||
[[ -n "$peer_ep" ]] && episode="$peer_ep"
|
||||
fi
|
||||
episode="${episode:-0}"
|
||||
echo "$type|$title|$year|$season|$episode|${episode_end:-}|$special_fragment"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# 无法判断 → unknown 交 AI
|
||||
type="unknown"
|
||||
title="$cleaned"
|
||||
echo "$type|$title|$year|$season|$episode|$special_fragment"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# 条目登记(register 层)与识别池
|
||||
#
|
||||
# 一切条目写入收拢于 register_*:原子完成"目的映射 + 结局账本 + 计数器",
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,90 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
################################################################################
|
||||
# 硬链接处理
|
||||
################################################################################
|
||||
|
||||
# 判断源与目标是否为同一 inode(硬链接已存在)。
|
||||
# 获取文件 inode(兼容 GNU/BSD/BusyBox stat)。
|
||||
# 依次尝试:GNU stat -c '%i' -> BSD stat -f '%i' -> ls -i -> find -printf。
|
||||
get_file_inode() {
|
||||
local filepath="$1"
|
||||
local inode
|
||||
inode=$(stat -c '%i' "$filepath" 2>/dev/null)
|
||||
[[ -n "$inode" ]] && {
|
||||
echo "$inode"
|
||||
return 0
|
||||
}
|
||||
inode=$(stat -f '%i' "$filepath" 2>/dev/null)
|
||||
[[ -n "$inode" ]] && {
|
||||
echo "$inode"
|
||||
return 0
|
||||
}
|
||||
# ls -i 解析 inode 字段(BusyBox 可靠,stat/find -printf 可能不可用)
|
||||
# shellcheck disable=SC2012
|
||||
inode=$(ls -i "$filepath" 2>/dev/null | awk '{print $1}')
|
||||
[[ -n "$inode" ]] && {
|
||||
echo "$inode"
|
||||
return 0
|
||||
}
|
||||
# GNU find 兜底:-printf 解析 inode
|
||||
find "$filepath" -maxdepth 0 -printf '%i' 2>/dev/null
|
||||
}
|
||||
|
||||
same_inode() {
|
||||
local src="$1" dst="$2"
|
||||
[[ ! -e "$dst" ]] && return 1
|
||||
local src_inode dst_inode
|
||||
|
||||
src_inode=$(get_file_inode "$src")
|
||||
dst_inode=$(get_file_inode "$dst")
|
||||
[[ -z "$src_inode" || -z "$dst_inode" ]] && return 1
|
||||
|
||||
[[ "$src_inode" == "$dst_inode" ]]
|
||||
}
|
||||
|
||||
# 创建硬链接;干运行模式下仅打印;目标已存在时直接替换(不备份)。
|
||||
# 注:不做 .bak 备份——遗留的 .bak_* 文件会被 Jellyfin 当作媒体扫描,干扰刮削。
|
||||
hardlink_or_dryrun() {
|
||||
local src="$1" dst="$2"
|
||||
|
||||
if $DRY_RUN; then
|
||||
_log 信息 "干运行:硬链接 '$src' -> '$dst'"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if same_inode "$src" "$dst"; then
|
||||
_log 跳过 "硬链接已存在,inode: $(stat -c '%i' "$src" 2>/dev/null)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 目标已存在:直接替换(先删旧目标再建硬链接)
|
||||
if [[ -e "$dst" ]]; then
|
||||
# 安全保护:目标是目录(如误传根目录)时拒绝替换,避免误操作真实目录
|
||||
if [[ -d "$dst" ]]; then
|
||||
_log 错误 "目标是目录,拒绝替换: $dst"
|
||||
return 1
|
||||
fi
|
||||
_log 信息 "目标已存在,直接替换: $dst"
|
||||
rm -f "$dst"
|
||||
fi
|
||||
|
||||
if ln "$src" "$dst"; then
|
||||
_log 成功 "硬链接已创建"
|
||||
return 0
|
||||
else
|
||||
_log 错误 "硬链接创建失败"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# 错误处理
|
||||
################################################################################
|
||||
|
||||
# 全局 ERR trap:记录出错行号后退出。
|
||||
_handle_error() {
|
||||
local line_num="$1"
|
||||
_log 错误 "脚本在第 $line_num 行出错(错误码:$?)"
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034,SC2153 # 跨文件共享全局变量(DESTINATION_DIR/MEDIA_OUTCOME_MAP,声明于 main.sh)
|
||||
|
||||
# 第三阶段:创建硬链接及处理伴随文件。
|
||||
# 幂等:same_inode 跳过已链接;撞名(不同 inode 占用)按官方多版本格式去重(- 2 后缀);
|
||||
# 同源旧链接(回退→正确识别的迁移)按 inode 清理。计数写入 LINK_SUCCESS_COUNT/LINK_FAIL_COUNT 供汇总。
|
||||
link_media() {
|
||||
_log 信息 "开始创建硬链接..."
|
||||
LINK_SUCCESS_COUNT=0
|
||||
LINK_FAIL_COUNT=0
|
||||
|
||||
local video dest_info destination_subdir destination_filename
|
||||
for video in "${!MEDIA_DESTINATION_MAP[@]}"; do
|
||||
dest_info="${MEDIA_DESTINATION_MAP[$video]}"
|
||||
IFS='|' read -r destination_subdir destination_filename <<<"$dest_info"
|
||||
|
||||
# 目标路径无效(防御:register 层保证可链接条目必有目的信息)
|
||||
if [[ -z "$destination_subdir" || -z "$destination_filename" ]]; then
|
||||
_log 警告 "目标路径无效,跳过: ${video##*/}"
|
||||
LINK_FAIL_COUNT=$((LINK_FAIL_COUNT + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
local destination_dir destination_file
|
||||
destination_dir="${DESTINATION_DIR}/${destination_subdir}"
|
||||
destination_file="${destination_dir}/${destination_filename}"
|
||||
|
||||
_log 信息 "处理: $(basename "$video")"
|
||||
push_indent
|
||||
|
||||
if ! mkdir -p "$destination_dir"; then
|
||||
_log 错误 "无法创建目的目录: $destination_dir"
|
||||
LINK_FAIL_COUNT=$((LINK_FAIL_COUNT + 1))
|
||||
pop_indent
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ -e "$destination_file" ]] && same_inode "$video" "$destination_file"; then
|
||||
# 幂等:目标已存在且同 inode(上次运行已链接)
|
||||
LINK_SUCCESS_COUNT=$((LINK_SUCCESS_COUNT + 1))
|
||||
_log 跳过 "硬链接已存在: $destination_file"
|
||||
# 补记账本(历史链接可能未入账;flush 在运行末尾统一落盘)
|
||||
[[ "${DRY_RUN:-false}" != "true" ]] && ledger_mark_linked "$video" "$destination_file"
|
||||
else
|
||||
# 撞名去重:目标被其他文件占用(inode 不同)→ 追加 " - 2" 后缀(官方多版本格式,
|
||||
# 前缀=文件夹名;Jellyfin 会把它们识别为一个条目的多个版本)
|
||||
local dedup_suffix=2
|
||||
while [[ -e "$destination_file" ]]; do
|
||||
local stem dext
|
||||
stem="${destination_filename%.*}"
|
||||
dext="${destination_filename##*.}"
|
||||
destination_filename="${stem} - ${dedup_suffix}.${dext}"
|
||||
destination_file="${destination_dir}/${destination_filename}"
|
||||
dedup_suffix=$((dedup_suffix + 1))
|
||||
done
|
||||
|
||||
# 同源迁移清理:本次目标位置是新的(旧链接可能在回退名等位置)→ 按 inode 找旧链接删除。
|
||||
# 仅非干运行执行(干运行零持久化副作用)。
|
||||
if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
local src_inode old_link
|
||||
src_inode=$(get_file_inode "$video")
|
||||
if [[ -n "$src_inode" ]]; then
|
||||
while IFS= read -r -d '' old_link; do
|
||||
[[ "$old_link" == "$destination_file" ]] && continue
|
||||
_log 信息 "清理旧链接: $old_link"
|
||||
rm -f "$old_link"
|
||||
done < <(find "$DESTINATION_DIR" -type f -inum "$src_inode" -print0 2>/dev/null)
|
||||
fi
|
||||
fi
|
||||
|
||||
if hardlink_or_dryrun "$video" "$destination_file"; then
|
||||
LINK_SUCCESS_COUNT=$((LINK_SUCCESS_COUNT + 1))
|
||||
# 登记已链接账本(增量标记;干运行不写内存——flush 也会跳过落盘)
|
||||
[[ "${DRY_RUN:-false}" != "true" ]] && ledger_mark_linked "$video" "$destination_file"
|
||||
else
|
||||
LINK_FAIL_COUNT=$((LINK_FAIL_COUNT + 1))
|
||||
if $AUTOMATED && [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') | $video | 硬链接失败" >>"$SKIP_LOG_FILE"
|
||||
fi
|
||||
pop_indent
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# 处理伴随文件(字幕、音轨等;主文件已链接时也执行——新伴随文件需补链)
|
||||
link_companions "$video" "$destination_file"
|
||||
|
||||
pop_indent
|
||||
done
|
||||
}
|
||||
|
||||
# 链接主视频的伴随文件(字幕/音轨等):目标名保留语言后缀(如 .zh、.zh-tw),
|
||||
# 避免多个伴随文件(.ass/.zh.ass/.zh-tw.ass)互相覆盖产生 .bak 堆积。
|
||||
# 链接成功后登记 MEDIA_DESTINATION_MAP/MEDIA_OUTCOME_MAP=companion,
|
||||
# 供 --export-map 对照表按主媒体分类展示(未链接成功的不登记)。
|
||||
link_companions() {
|
||||
local video="$1" destination_file="$2"
|
||||
local base_name companion companion_ext dest_companion
|
||||
base_name="${video%.*}"
|
||||
|
||||
# 字幕组标记(Jellyfin stream title 约定,如 .tc.Airota&LoliHouse.ass):
|
||||
# 从视频发布组括号 [Airota&VCB-Studio&LoliHouse] 提取字幕组(& 拆分,压制组词表过滤)。
|
||||
local sub_group=""
|
||||
sub_group=$(subtitle_group_tag "$video")
|
||||
|
||||
# 伴随文件处理:同目录 glob + 跨目录按 base 名匹配(发布目录与常见字幕子目录)。
|
||||
local -a companions=()
|
||||
local c
|
||||
for c in "$base_name".*; do
|
||||
[[ -f "$c" ]] && companions+=("$c")
|
||||
done
|
||||
# 跨目录搜索(发布目录 + Subs/字幕 子目录),按 base 名(含语言标记段)匹配
|
||||
local vdir updir sdir
|
||||
vdir=$(dirname "$video")
|
||||
updir=$(dirname "$vdir")
|
||||
for sdir in "$vdir" "$updir" "$vdir/subs" "$vdir/Subs" "$vdir/字幕"; do
|
||||
[[ ! -d "$sdir" || "$sdir" == "$vdir" ]] && continue
|
||||
while IFS= read -r -d '' c; do
|
||||
companions+=("$c")
|
||||
done < <(find "$sdir" -maxdepth 1 -type f -print0 2>/dev/null)
|
||||
done
|
||||
|
||||
local -A seen=()
|
||||
for companion in "${companions[@]}"; do
|
||||
[[ ! -f "$companion" ]] && continue
|
||||
[[ "$companion" == "$video" ]] && continue
|
||||
[[ -n "${seen[$companion]:-}" ]] && continue
|
||||
seen[$companion]=1
|
||||
|
||||
companion_ext="${companion##*.}"
|
||||
if is_companion "$companion" "${video%.*}"; then
|
||||
# 用字符串截取而非 ${var#pat}:base_name 含 [] 等 glob 特殊字符,
|
||||
# ${var#pat} 的 pattern 是 glob,含 [ 时匹配不可靠。
|
||||
local comp_suffix="${companion:${#base_name}}"
|
||||
comp_suffix="${comp_suffix%.*}"
|
||||
# 字幕类文件(ass/srt/sub/idx/vtt/sup)追加字幕组标记(如有)
|
||||
if [[ -n "$sub_group" ]] && [[ "$companion_ext" =~ ^(ass|srt|sub|idx|vtt|sup)$ ]]; then
|
||||
dest_companion="${destination_file%.*}${comp_suffix}.${sub_group}.${companion_ext}"
|
||||
else
|
||||
dest_companion="${destination_file%.*}${comp_suffix}.${companion_ext}"
|
||||
fi
|
||||
_log 信息 "处理伴随文件: $(basename "$companion")"
|
||||
if ! hardlink_or_dryrun "$companion" "$dest_companion"; then
|
||||
_log 警告 "伴随文件硬链接失败: $(basename "$companion")"
|
||||
else
|
||||
# 登记伴随文件映射(对照表按分类展示用):dest 为相对 DESTINATION_DIR 的 "子目录|文件名"。
|
||||
# 与主媒体同分类(dest 首段 = 分类根),未链接成功的不登记(无映射可展示)。
|
||||
local comp_rel="${dest_companion#"$DESTINATION_DIR"/}"
|
||||
local comp_subdir="${comp_rel%/*}"
|
||||
local comp_filename="${comp_rel##*/}"
|
||||
MEDIA_DESTINATION_MAP["$companion"]="${comp_subdir}|${comp_filename}"
|
||||
MEDIA_OUTCOME_MAP["$companion"]="companion"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# 从视频文件名的发布组括号提取字幕组标记(& 原样保留;压制组词表过滤)。
|
||||
# 压制组词表:SUB_GROUP_BLACKLIST(config.json,逗号分隔;默认 VCB-Studio)。
|
||||
subtitle_group_tag() {
|
||||
local video="$1"
|
||||
local bn br
|
||||
bn=$(basename "$video")
|
||||
br=$(echo "$bn" | grep -oE '^\[[^]]*\]' | head -1 | tr -d '[]')
|
||||
[[ -z "$br" ]] && {
|
||||
echo ""
|
||||
return 0
|
||||
}
|
||||
local -a black=() b parts keep=() p
|
||||
IFS=',' read -ra black <<<"${SUB_GROUP_BLACKLIST:-VCB-Studio}"
|
||||
IFS='&' read -ra parts <<<"$br"
|
||||
for p in "${parts[@]}"; do
|
||||
local skip=false
|
||||
for b in "${black[@]}"; do
|
||||
[[ -n "$b" && "${p,,}" == "${b,,}" ]] && skip=true && break
|
||||
done
|
||||
$skip || keep+=("$p")
|
||||
done
|
||||
(
|
||||
IFS='&'
|
||||
echo "${keep[*]}"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034,SC2141 # 跨文件共享全局变量(POOL_THRESHOLD_CHECK);IFS=$'...\...' 反斜杠为有意分隔符
|
||||
|
||||
# 入口目录校验:源目录存在可读;目的目录存在可写或可创建(干运行不创建目录)。
|
||||
validate_directories() {
|
||||
if [[ -n "$SOURCE_DIR" && (! -d "$SOURCE_DIR" || ! -r "$SOURCE_DIR") ]]; then
|
||||
_log 错误 "源目录不存在或不可读:$SOURCE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$DESTINATION_DIR" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ ! -e "$DESTINATION_DIR" ]]; then
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
_log 错误 "目的目录不存在(干运行不会创建目录):$DESTINATION_DIR"
|
||||
exit 1
|
||||
fi
|
||||
if ! mkdir -p "$DESTINATION_DIR" 2>/dev/null; then
|
||||
_log 错误 "无法创建目的目录:$DESTINATION_DIR"
|
||||
exit 1
|
||||
fi
|
||||
elif [[ ! -d "$DESTINATION_DIR" || ! -w "$DESTINATION_DIR" ]]; then
|
||||
_log 错误 "目的目录不可用(需为可写目录):$DESTINATION_DIR"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# 环境检查
|
||||
################################################################################
|
||||
|
||||
# 检查硬链接支持(源与目标必须在同一文件系统)。
|
||||
check_hardlink_support() {
|
||||
if ! ${SKIP_HARDLINK_CHECK,,}; then
|
||||
src_test="$SOURCE_DIR/.hardlink_test_src_$$"
|
||||
dst_test="$DESTINATION_DIR/.hardlink_test_dst_$$"
|
||||
touch "$src_test" 2>/dev/null || {
|
||||
_log 错误 "无法在源目录创建测试文件"
|
||||
exit 1
|
||||
}
|
||||
if ! ln "$src_test" "$dst_test" 2>/dev/null; then
|
||||
rm -f "$src_test"
|
||||
_log 错误 "无法创建硬链接"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$dst_test" "$src_test"
|
||||
fi
|
||||
}
|
||||
|
||||
# 初始化缓存目录,处理 --refresh-cache(仅清空 tmdb/ 缓存,保留脚本自身学习数据)。
|
||||
# 干运行模式只解析路径、不创建目录(零持久化副作用)。
|
||||
init_cache_dir() {
|
||||
# 解析默认缓存路径(优先级:环境变量 > 执行目录 mo_cache > 脚本目录 mo_cache)
|
||||
if [[ -z "${CACHE_DIR:-}" ]]; then
|
||||
if [[ -d "$PWD/mo_cache" ]]; then
|
||||
CACHE_DIR="$PWD/mo_cache"
|
||||
else
|
||||
CACHE_DIR="$SCRIPT_DIR/mo_cache"
|
||||
fi
|
||||
fi
|
||||
if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
if ! mkdir -p "$CACHE_DIR" 2>/dev/null; then
|
||||
CACHE_DIR=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$REFRESH_CACHE" == "true" ]] && [[ -n "$CACHE_DIR" ]]; then
|
||||
cache_clear
|
||||
[[ "${DRY_RUN:-false}" != "true" ]] && _log 信息 "TMDB 缓存已清除"
|
||||
fi
|
||||
|
||||
# 旧版缓存布局(mo_cache/{movie,search,tv} 平铺)迁移到 tmdb/ 子目录
|
||||
if [[ -n "$CACHE_DIR" ]]; then
|
||||
migrate_tmdb_cache
|
||||
fi
|
||||
|
||||
# 创建缓存子目录:tmdb/(镜像 TMDB API 路径,可再生)+ media_organizer/
|
||||
# (脚本运行状态:已链接账本/失败冷却 ledger;用户配置类文件位于 mo_config/,
|
||||
# 由 init_special_map/init_special_words/init_skip_dirs/init_season_offset 各自创建)
|
||||
if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
mkdir -p "$CACHE_DIR/tmdb/search/movie" "$CACHE_DIR/tmdb/search/tv" \
|
||||
"$CACHE_DIR/tmdb/tv" "$CACHE_DIR/tmdb/movie" \
|
||||
"$CACHE_DIR/media_organizer" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# 主处理流水线
|
||||
################################################################################
|
||||
|
||||
# 扫描源目录,收集视频文件列表。
|
||||
# 按去除压制组/字幕组等方括号信息后的名称排序,
|
||||
# 保证同一部剧先按季、再按集有序处理(先第一季再第二季)。
|
||||
scan_files() {
|
||||
_log 信息 "正在扫描源目录..."
|
||||
local file ext ext_lower key entry
|
||||
# 进程替换保证 while 在父 shell 执行,VIDEO_FILES 修改生效
|
||||
# 用换行分隔 + sort(BusyBox sort 仅支持 -nru,不支持 -z/-k)
|
||||
while IFS= read -r entry; do
|
||||
file="${entry#*$'\t'}"
|
||||
VIDEO_FILES+=("$file")
|
||||
done < <(
|
||||
find "$SOURCE_DIR" -type f -print0 |
|
||||
while IFS= read -r -d '' file; do
|
||||
ext="${file##*.}"
|
||||
ext_lower="${ext,,}"
|
||||
if [[ " ${VIDEO_EXTS[*]} " =~ [[:space:]]${ext_lower}[[:space:]] ]]; then
|
||||
key=$(clean_name "$(basename "$file")")
|
||||
printf '%s\t%s\n' "$key" "$file"
|
||||
fi
|
||||
done |
|
||||
sort
|
||||
)
|
||||
# 收集音频文件(CD 音乐,归入 Music 分类)
|
||||
while IFS= read -r entry; do
|
||||
file="${entry#*$'\t'}"
|
||||
AUDIO_FILES+=("$file")
|
||||
done < <(
|
||||
find "$SOURCE_DIR" -type f -print0 |
|
||||
while IFS= read -r -d '' file; do
|
||||
ext="${file##*.}"
|
||||
ext_lower="${ext,,}"
|
||||
if [[ " ${AUDIO_EXTS[*]} " =~ [[:space:]]${ext_lower}[[:space:]] ]]; then
|
||||
printf '%s\t%s\n' "$(basename "$file")" "$file"
|
||||
fi
|
||||
done |
|
||||
sort
|
||||
)
|
||||
_log 信息 "共发现 ${#VIDEO_FILES[@]} 个视频文件、${#AUDIO_FILES[@]} 个音频文件,开始识别..."
|
||||
}
|
||||
|
||||
# 第一阶段:解析文件名并识别,构建 MEDIA_DESTINATION_MAP。
|
||||
# 按扩展名分发单个文件到视频/音频处理(识别池 worker 入口)。
|
||||
# 入口先做增量跳过:已链接(ledger 有效)与失败冷却(未到期)条目直接 SKIP,
|
||||
# 不再走 parse/识别(干运行不启用——干运行展示全貌;--rerun 不经此路径)。
|
||||
process_one_file() {
|
||||
local f="$1"
|
||||
if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
if ledger_is_linked "$f"; then
|
||||
_log 跳过 "增量跳过(已链接): $(basename "$f")"
|
||||
emit "$f" SKIP "already_linked"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# 纠错学习命中:--rerun 手动修正过的同命名文件,直接使用用户指定的目标
|
||||
# (key=去扩展名 + clean_name 小写,与 correction_learn 写入算法一致;
|
||||
# dest 必须位于当前 DESTINATION_DIR 下才适用;
|
||||
# 优先于失败冷却——用户显式纠错是强信号,无需再等冷却;
|
||||
# 干运行同样展示命中结果——学习写入才受 dry-run 约束)
|
||||
local ckey corr_dest
|
||||
ckey="${f##*/}"
|
||||
ckey="${ckey%.*}"
|
||||
ckey=$(clean_name "$ckey")
|
||||
ckey="${ckey,,}"
|
||||
corr_dest="${CORRECTIONS_MAP[$ckey]:-}"
|
||||
if [[ -n "$corr_dest" ]] && [[ -n "$DESTINATION_DIR" ]] && [[ "$corr_dest" == "$DESTINATION_DIR"/* ]]; then
|
||||
local corr_subdir corr_filename
|
||||
corr_subdir="${corr_dest#"$DESTINATION_DIR"/}"
|
||||
corr_filename="${corr_subdir##*/}"
|
||||
corr_subdir="${corr_subdir%/*}"
|
||||
if [[ -n "$corr_subdir" && -n "$corr_filename" ]]; then
|
||||
_log 信息 "纠错学习命中: $(basename "$f") -> $corr_subdir/"
|
||||
emit "$f" DEST "$corr_subdir|$corr_filename"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
if cooldown_active "$f"; then
|
||||
_log 跳过 "冷却中跳过(失败冷却未到期): $(basename "$f")"
|
||||
emit "$f" SKIP "cooldown"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
local ext="${f##*.}"
|
||||
ext="${ext,,}"
|
||||
if [[ " ${VIDEO_EXTS[*]} " =~ [[:space:]]${ext}[[:space:]] ]]; then
|
||||
process_one_video "$f"
|
||||
elif [[ " ${AUDIO_EXTS[*]} " =~ [[:space:]]${ext}[[:space:]] ]]; then
|
||||
process_one_audio "$f"
|
||||
fi
|
||||
}
|
||||
|
||||
# 单个视频文件的识别:parse → 分类 → 识别 → 协议输出。
|
||||
# 协议:DEST(识别成功)/ PENDING(无结果或 unknown,待 AI)/ REQ_FAILED(请求失败)/
|
||||
# SKIP(类型跳过)。待定登记全部由父进程合并时执行。
|
||||
process_one_video() {
|
||||
local video="$1"
|
||||
_log 信息 "处理视频: $(basename "$video")"
|
||||
push_indent
|
||||
|
||||
local info type title year season episode episode_end special_fragment base ext dest="" rc=0
|
||||
info=$(parse_media_filename "$video")
|
||||
IFS='|' read -r type title year season episode episode_end special_fragment <<<"$info"
|
||||
base="${video##*/}"
|
||||
base="${base%.*}"
|
||||
ext="${video##*.}"
|
||||
|
||||
if [[ "$type" == "movie" ]]; then
|
||||
dest=$(identify_movie "$base" "$ext" "$video" "$title" "$year") || rc=$?
|
||||
elif [[ "$type" == "tv" ]]; then
|
||||
dest=$(identify_tv_show "$title" "${season:-1}" "${episode:-0}" "${episode_end:-}" \
|
||||
"$base" "$ext" "$special_fragment" "$video") || rc=$?
|
||||
elif [[ "$type" == "musicvideo" ]]; then
|
||||
# Music Videos(v9.6):本地规则识别(无网络);恒成功(artist 有 Unknown 兜底)
|
||||
dest=$(identify_musicvideo "$video" "$title" "$ext") || rc=$?
|
||||
fi
|
||||
|
||||
if [[ "$type" == "skip" ]]; then
|
||||
# 电影域特典/附带:不整理(TMDB/Jellyfin 均不收录电影特典,避免误判为剧集特典)
|
||||
_log 跳过 "电影特典/附带,不整理: $(basename "$video")"
|
||||
if $AUTOMATED && [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') | $video | 电影特典不整理" >>"$SKIP_LOG_FILE"
|
||||
fi
|
||||
emit "$video" SKIP "skip_type"
|
||||
pop_indent
|
||||
return 0
|
||||
elif [[ "$type" == "unknown" ]]; then
|
||||
# unknown:识别不出类别,记录待 AI 判断(type=unknown,AI 返回 media_type 决定 movie/tv)
|
||||
_log 信息 "类别未定,交 AI 判断: $(basename "$video")"
|
||||
emit "$video" PENDING "unknown $(dir_chain "$video")"
|
||||
pop_indent
|
||||
return 0
|
||||
elif [[ $rc -eq 2 ]]; then
|
||||
# 请求失败(网络/密钥问题,重试耗尽)——不登记 AI 待定,语义与"无结果"分离
|
||||
_log 错误 "TMDB 请求失败,跳过: $(basename "$video")"
|
||||
emit "$video" REQ_FAILED
|
||||
elif [[ $rc -eq 3 ]]; then
|
||||
# 需甄别:identify 已 emit MATCH(交 AI 匹配)
|
||||
_log 信息 "需甄别,交 AI 匹配: $(basename "$video")"
|
||||
elif [[ $rc -eq 0 && -n "$dest" ]]; then
|
||||
local destination_subdir destination_filename
|
||||
IFS='|' read -r destination_subdir destination_filename <<<"$dest"
|
||||
emit "$video" DEST "$destination_subdir|$destination_filename"
|
||||
else
|
||||
# 查询无结果(rc=1)→ 待 AI 搜索纠正
|
||||
_log 信息 "未匹配,交 AI 搜索: $(basename "$video")"
|
||||
emit "$video" PENDING "${type} $(dir_chain "$video")"
|
||||
fi
|
||||
|
||||
pop_indent
|
||||
}
|
||||
|
||||
# 第一阶段:识别池处理全部视频文件(并发)。
|
||||
process_video() {
|
||||
[[ ${#VIDEO_FILES[@]} -eq 0 ]] && return 0
|
||||
_log 信息 "开始识别视频(${#VIDEO_FILES[@]} 个,并发 ${MEDIA_WORKERS})..."
|
||||
POOL_THRESHOLD_CHECK=true
|
||||
pool_run "${VIDEO_FILES[@]}"
|
||||
}
|
||||
|
||||
# 解析 CD 目录名,输出:歌手|专辑。
|
||||
# 目录名格式:[日期] 「专辑名」/歌手 [规格] (格式),如 "[250110] 「幸せのレシピ」/平井大 [24bit_48kHz] (flac)"。
|
||||
# 解析失败时歌手回退为 "Unknown",专辑用原始目录名。
|
||||
parse_cd_dir() {
|
||||
local dir="$1"
|
||||
local artist album
|
||||
# 去掉 [日期] 前缀
|
||||
local name="${dir#*] }"
|
||||
# 循环去掉末尾的 [规格] 与 (格式) 后缀(可能多个)
|
||||
local prev=""
|
||||
while [[ "$name" != "$prev" ]]; do
|
||||
prev="$name"
|
||||
name=$(echo "$name" | sed -E 's/[[:space:]]*\[[^]]*\]$//; s/[[:space:]]*\([^)]*\)$//')
|
||||
done
|
||||
name=$(echo "$name" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||
# 按 / 分割:「专辑」/歌手
|
||||
if [[ "$name" == *"/"* ]]; then
|
||||
album="${name%%/*}"
|
||||
artist="${name##*/}"
|
||||
else
|
||||
album="$name"
|
||||
artist="Unknown"
|
||||
fi
|
||||
# 专辑名去掉 「」 引号
|
||||
album=$(echo "$album" | sed -E 's/^「//; s/」$//' | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||
artist=$(echo "$artist" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||
[[ -z "$artist" ]] && artist="Unknown"
|
||||
[[ -z "$album" ]] && album="$name"
|
||||
echo "$artist|$album"
|
||||
}
|
||||
|
||||
# 单个音频文件的整理:伴随优先级(同名视频→音轨排除)→ 艺术家归类链(元数据)→ 协议输出。
|
||||
# 协议:DEST(音频及其歌词/封面伴随,可链接);ARTIST_PENDING(多艺术家待 AI 判定)。
|
||||
# 艺术家归类链:专辑艺术家 → 单歌曲艺术家 → 多艺术家交 AI → 目录名解析 → Unknown。
|
||||
process_one_audio() {
|
||||
local audio="$1"
|
||||
# 向上找到 CD 目录(目录名含日期/专辑/格式特征,如 [250110] ...、「专辑」/歌手、…(flac))
|
||||
local dir cd_root bn
|
||||
dir=$(dirname "$audio")
|
||||
cd_root="$dir"
|
||||
while [[ "$cd_root" != "/" && "$cd_root" != "." ]]; do
|
||||
bn=$(basename "$cd_root")
|
||||
if echo "$bn" | grep -qE '\[[0-9]{6}\]|/|「|\(flac|\(mp3|\(wav|\(webp'; then
|
||||
break
|
||||
fi
|
||||
cd_root=$(dirname "$cd_root")
|
||||
done
|
||||
# 未找到 CD 目录(走到 / 或 .)→ 以音频所在目录为专辑根,避免 parse_cd_dir("/") 产出垃圾
|
||||
[[ "$cd_root" == "/" || "$cd_root" == "." ]] && cd_root="$dir"
|
||||
|
||||
local base_name="${audio%.*}"
|
||||
# 伴随优先级(视频 > 音频):同名视频存在 → 该音频是视频的伴随音轨,
|
||||
# 由视频的伴随逻辑处理(link 时跟随),不独立整理
|
||||
local same_video="" v vext
|
||||
for v in "$base_name".*; do
|
||||
[[ ! -f "$v" ]] && continue
|
||||
[[ "$v" == "$audio" ]] && continue
|
||||
vext="${v##*.}"
|
||||
if [[ " ${VIDEO_EXTS[*]} " =~ [[:space:]]${vext,,}[[:space:]] ]]; then
|
||||
same_video="$v"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -n "$same_video" ]]; then
|
||||
_log 跳过 "音频为视频伴随音轨,不独立整理: $(basename "$audio")"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 元数据读取(ffprobe 读格式标签;仅整理不增删文件)
|
||||
local meta album_artist meta_artist meta_album
|
||||
meta=$(ffprobe -v quiet -show_entries format_tags=album_artist,artist,album \
|
||||
-of default=noprint_wrappers=1 "$audio" 2>/dev/null) || meta=""
|
||||
# 注意:pipefail 下 grep 无匹配会使管道整体返回非零,必须 || true(set -e 保护)
|
||||
# ffmpeg 6.x 输出 "TAG:ALBUM=" 前缀且键全大写;grep -ioE 从键处提取(大小写不敏感),
|
||||
# sed 去键("^[^=]*=" 兼容 TAG: 前缀与全大写键,busybox 无 sed -I)
|
||||
album_artist=$(echo "$meta" | grep -ioE 'album_artist=.*' | head -1 | sed -E 's/^[^=]*=//') || true
|
||||
meta_artist=$(echo "$meta" | grep -viE 'album_artist=' | grep -ioE 'artist=.*' | head -1 | sed -E 's/^[^=]*=//') || true
|
||||
meta_album=$(echo "$meta" | grep -viE 'album_artist=' | grep -ioE 'album=.*' | head -1 | sed -E 's/^[^=]*=//') || true
|
||||
|
||||
# CD 目录名解析(元数据缺失时的回退源)
|
||||
local parsed cd_artist cd_album
|
||||
parsed=$(parse_cd_dir "$(basename "$cd_root")")
|
||||
IFS='|' read -r cd_artist cd_album <<<"$parsed"
|
||||
|
||||
# CD 目录下的相对子路径(保留碟片子目录,如 THCA-60298-1)
|
||||
local rel rel_dir
|
||||
rel="${audio#"$cd_root"/}"
|
||||
rel_dir=$(dirname "$rel")
|
||||
[[ "$rel_dir" == "." ]] && rel_dir=""
|
||||
|
||||
local artist="" album="${meta_album:-$cd_album}"
|
||||
if [[ -n "$album_artist" ]]; then
|
||||
artist="$album_artist"
|
||||
elif [[ -n "$meta_artist" ]]; then
|
||||
# 拆分多艺术家(ID3v2 常用 / \ 分隔;日系曲目常用 、;,分隔)。
|
||||
# 注意:& 不拆("MYTH & ROID" 是乐队名,误拆会破坏艺术家)
|
||||
local -a arts=() cleaned_arts=() a
|
||||
IFS=$'/\\;、,,' read -ra arts <<<"$meta_artist"
|
||||
for a in "${arts[@]}"; do
|
||||
a=$(echo "$a" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||
[[ -n "$a" ]] && cleaned_arts+=("$a")
|
||||
done
|
||||
if ((${#cleaned_arts[@]} <= 1)); then
|
||||
artist="${cleaned_arts[0]:-$meta_artist}"
|
||||
else
|
||||
# 多艺术家 → 交 AI 判定(AI 判断不出时拼接,见 run_ai_batch)。
|
||||
# 字段用 \t 分隔(艺术家名可含 |;PENDING_AI_SEARCH 同约定);
|
||||
# 艺术家内部用顿号连接(日系命名惯例,与校准约定一致,避免 | 与字段分隔冲突)。
|
||||
_log 信息 "多艺术家,交 AI 判定: $(basename "$audio")"
|
||||
emit "$audio" ARTIST_PENDING \
|
||||
"$(
|
||||
IFS='; '
|
||||
echo "${cleaned_arts[*]}"
|
||||
)"$'\t'"${album:-}"$'\t'"${rel_dir}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# 艺术家仍未解析 → 目录名解析 → Unknown
|
||||
[[ -z "$artist" ]] && artist="$cd_artist"
|
||||
[[ -z "$artist" ]] && artist="$FOLDER_UNKNOWN"
|
||||
[[ -z "$album" ]] && album="$FOLDER_UNKNOWN"
|
||||
|
||||
local destination_subdir music_root
|
||||
# 音乐目录结构走 NAMING_MUSIC 模板(默认 {artist}/{album};用户可自定义)
|
||||
music_root=$(render_naming NAMING_MUSIC \
|
||||
"artist=$(sanitize "$artist")" "album=$(sanitize "$album")")
|
||||
[[ -n "$music_root" ]] || music_root="$(sanitize "$artist")/$(sanitize "$album")"
|
||||
destination_subdir="${FOLDER_MUSIC}/$music_root"
|
||||
[[ -n "$rel_dir" ]] && destination_subdir="$destination_subdir/$rel_dir"
|
||||
emit "$audio" DEST "$destination_subdir|$(basename "$audio")"
|
||||
_log 信息 "音频: $(basename "$audio") -> $destination_subdir/"
|
||||
|
||||
emit_audio_companions "$audio" "$cd_root" "$destination_subdir" "$artist" "$album"
|
||||
}
|
||||
|
||||
# 音频伴随文件发射(链接既有文件,不提取/不生成):歌词(同名 lrc/elrc/txt)+ 专辑级封面。
|
||||
# 歌词随曲目(含碟片相对路径);封面在源专辑根目录(目标专辑根)。
|
||||
emit_audio_companions() {
|
||||
local audio="$1" cd_root="$2" destination_subdir="$3" artist="$4" album="$5"
|
||||
local base_name="${audio%.*}"
|
||||
local c
|
||||
for c in "${base_name}.lrc" "${base_name}.elrc" "${base_name}.txt"; do
|
||||
[[ -f "$c" ]] && emit "$c" DEST "$destination_subdir|$(basename "$c")"
|
||||
done
|
||||
# 专辑封面:源专辑根目录的类型名图片(cover/folder/poster/jacket/thumb/default)
|
||||
local cover_file cb cname music_root
|
||||
music_root=$(render_naming NAMING_MUSIC \
|
||||
"artist=$(sanitize "$artist")" "album=$(sanitize "$album")")
|
||||
[[ -n "$music_root" ]] || music_root="$(sanitize "$artist")/$(sanitize "$album")"
|
||||
for cover_file in "$cd_root"/*.jpg "$cd_root"/*.png "$cd_root"/*.webp; do
|
||||
[[ ! -f "$cover_file" ]] && continue
|
||||
cb=$(basename "$cover_file")
|
||||
cname="${cb%.*}"
|
||||
if echo "$cname" | grep -qiE '^(cover|folder|poster|jacket|thumb|default)$'; then
|
||||
emit "$cover_file" DEST \
|
||||
"${FOLDER_MUSIC}/$music_root|$cb"
|
||||
break
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# 处理音频文件(CD 音乐):识别池并发处理,归入 Music/歌手/专辑/曲目。
|
||||
process_audio() {
|
||||
[[ ${#AUDIO_FILES[@]} -eq 0 ]] && return 0
|
||||
_log 信息 "处理音频文件(CD 音乐,共 ${#AUDIO_FILES[@]} 个)..."
|
||||
POOL_THRESHOLD_CHECK=false
|
||||
pool_run "${AUDIO_FILES[@]}"
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034 # 跨文件共享全局 map(MEDIA_DESTINATION_MAP 等,声明于 main.sh)
|
||||
|
||||
# 结构上不可能出现只写一半的不一致。识别池并发运行时,子进程通过 emit 输出
|
||||
# 协议行(key\tTYPE\tpayload)到 POOL_EMIT_FILE,父进程合并时调用 register_*。
|
||||
# 协议行类型:
|
||||
# DEST <subdir|filename> → register_identified
|
||||
# FALLBACK <subdir|filename> → register_fallback
|
||||
# PENDING <ai_data> → register_pending(待 AI 搜索纠正)
|
||||
# ARTIST_PENDING <artists|album> → register_pending_artist(音频多艺术家待 AI 判定)
|
||||
# SPECIAL_PENDING <show_id|frag|show|names> → 特典映射待学习
|
||||
# SKIP <reason> → register_skip(skip_type/skip_unidentified)
|
||||
# REQ_FAILED → register_request_failed
|
||||
################################################################################
|
||||
|
||||
# 登记识别成功条目(可链接)。
|
||||
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"
|
||||
}
|
||||
|
||||
# 登记待 AI 判定艺术家的音频条目(不进入目的映射)。
|
||||
register_pending_artist() {
|
||||
local key="$1" artists_album="$2"
|
||||
PENDING_AI_ARTIST["$key"]="$artists_album"
|
||||
PENDING_ARTIST_COUNT=$((PENDING_ARTIST_COUNT + 1))
|
||||
MEDIA_OUTCOME_MAP["$key"]="pending_ai"
|
||||
}
|
||||
|
||||
# 登记跳过条目(type=skip_type/skip_unidentified;不进入目的映射)。
|
||||
# skip_unidentified(AI 尽力后仍失败 / 无 AI 密钥)→ 登记失败冷却(下次运行冷却期内跳过,
|
||||
# 避免 cron 每轮全量重试;冷却过期后自动重试,语义仍可逆)。skip_type(电影特典)无成本,不冷却。
|
||||
register_skip() {
|
||||
local key="$1" reason="$2"
|
||||
MEDIA_OUTCOME_MAP["$key"]="$reason"
|
||||
if [[ "$reason" == "skip_unidentified" ]]; then
|
||||
cooldown_set "$key" "$reason"
|
||||
fi
|
||||
}
|
||||
|
||||
# 登记请求失败条目(重试耗尽仍失败;不进入目的映射)。
|
||||
# 网络/密钥类失败同样登记冷却:冷却期内跳过,过期后自动重试。
|
||||
register_request_failed() {
|
||||
local key="$1"
|
||||
MEDIA_OUTCOME_MAP["$key"]="request_failed"
|
||||
cooldown_set "$key" "request_failed"
|
||||
}
|
||||
|
||||
# 登记待 AI 匹配甄别条目(搜索有结果但需甄别;不进入目的映射)。
|
||||
# 同 key 重复登记(如纠正词重搜再次需甄别)不重复计数。
|
||||
register_pending_match() {
|
||||
local key="$1" payload="$2"
|
||||
if [[ -z "${PENDING_AI_MATCH[$key]:-}" ]]; then
|
||||
PENDING_MATCH_COUNT=$((PENDING_MATCH_COUNT + 1))
|
||||
fi
|
||||
PENDING_AI_MATCH["$key"]="$payload"
|
||||
MEDIA_OUTCOME_MAP["$key"]="pending_ai"
|
||||
}
|
||||
|
||||
# 目录链:文件父目录相对源根的路径,最多保留靠近文件的 3 层(剧名通常靠近文件)。
|
||||
# 用于 AI 输入(directory 字段)与 PENDING_AI_SEARCH 存储。
|
||||
dir_chain() {
|
||||
local file="$1" rel
|
||||
rel=$(dirname "$file")
|
||||
rel="${rel#"$SOURCE_DIR"/}"
|
||||
# 不在源目录下(或就是源根)→ 用父目录名
|
||||
if [[ "$rel" == "$(dirname "$file")" ]]; then
|
||||
rel=$(basename "$rel")
|
||||
fi
|
||||
local parts=() out="" p
|
||||
IFS='/' read -ra parts <<<"$rel"
|
||||
if ((${#parts[@]} > 3)); then
|
||||
parts=("${parts[@]:${#parts[@]}-3}")
|
||||
fi
|
||||
for p in "${parts[@]}"; do
|
||||
out="${out:+$out/}$p"
|
||||
done
|
||||
echo "$out"
|
||||
}
|
||||
|
||||
# 合并协议行文件(register_* 登记)。返回前记录 MERGE_PROCESSED/FAILED/STREAK 供阈值检查。
|
||||
# 用于:识别池合并(pool_run)与父进程 identify 调用(命令替换会丢 emit 副作用,需经临时文件回传)。
|
||||
merge_emit_lines() {
|
||||
local emit_file="$1"
|
||||
MERGE_PROCESSED=0
|
||||
MERGE_FAILED=0
|
||||
MERGE_STREAK=0
|
||||
local key type payload
|
||||
while IFS=$'\t' read -r key type payload; do
|
||||
[[ -z "$key" ]] && continue
|
||||
MERGE_PROCESSED=$((MERGE_PROCESSED + 1))
|
||||
case "$type" in
|
||||
DEST)
|
||||
local subdir filename
|
||||
IFS='|' read -r subdir filename <<<"$payload"
|
||||
register_identified "$key" "$subdir" "$filename"
|
||||
;;
|
||||
FALLBACK)
|
||||
local subdir filename
|
||||
IFS='|' read -r subdir filename <<<"$payload"
|
||||
register_fallback "$key" "$subdir" "$filename"
|
||||
;;
|
||||
PENDING)
|
||||
register_pending "$key" "$payload"
|
||||
;;
|
||||
ARTIST_PENDING)
|
||||
register_pending_artist "$key" "$payload"
|
||||
;;
|
||||
SPECIAL_PENDING)
|
||||
local show_id fragment show_title season0_names
|
||||
IFS='|' read -r show_id fragment show_title season0_names <<<"$payload"
|
||||
PENDING_AI_SPECIAL["${show_id}|${fragment}"]="${show_title}|${season0_names}"
|
||||
PENDING_SPECIAL_COUNT=$((PENDING_SPECIAL_COUNT + 1))
|
||||
;;
|
||||
SKIP)
|
||||
register_skip "$key" "$payload"
|
||||
;;
|
||||
REQ_FAILED)
|
||||
register_request_failed "$key"
|
||||
MERGE_FAILED=$((MERGE_FAILED + 1))
|
||||
MERGE_STREAK=$((MERGE_STREAK + 1))
|
||||
;;
|
||||
MATCH)
|
||||
register_pending_match "$key" "$payload"
|
||||
;;
|
||||
*)
|
||||
MERGE_STREAK=0
|
||||
;;
|
||||
esac
|
||||
done < <(cat "$emit_file")
|
||||
}
|
||||
|
||||
# 协议输出:worker 模式写入 POOL_EMIT_FILE;父进程模式(无池)直接登记。
|
||||
emit() {
|
||||
local key="${1:-}" type="${2:-}" payload="${3:-}"
|
||||
if [[ -n "${POOL_EMIT_FILE:-}" ]]; then
|
||||
printf '%s\t%s\t%s\n' "$key" "$type" "$payload" >>"$POOL_EMIT_FILE"
|
||||
return 0
|
||||
fi
|
||||
case "$type" in
|
||||
SPECIAL_PENDING)
|
||||
local show_id fragment show_title season0_names
|
||||
IFS='|' read -r show_id fragment show_title season0_names <<<"$payload"
|
||||
PENDING_AI_SPECIAL["${show_id}|${fragment}"]="${show_title}|${season0_names}"
|
||||
PENDING_SPECIAL_COUNT=$((PENDING_SPECIAL_COUNT + 1))
|
||||
;;
|
||||
MATCH)
|
||||
register_pending_match "$key" "$payload"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 识别池:对文件列表并发执行 process_one_file,结果行经 POOL_EMIT_FILE 收集后由父进程合并。
|
||||
# 并发控制:满池时等待最老的 job(bash 4.0 兼容,不用 wait -n)。
|
||||
pool_run() {
|
||||
local pool_dir
|
||||
pool_dir=$(mktemp -d)
|
||||
export POOL_EMIT_FILE="$pool_dir/emit"
|
||||
: >"$POOL_EMIT_FILE"
|
||||
|
||||
local -a pids=()
|
||||
local f workers="${MEDIA_WORKERS:-4}"
|
||||
for f in "$@"; do
|
||||
# worker 协议只走 POOL_EMIT_FILE,stdout 丢弃(防止识别函数 echo 泄漏到终端)
|
||||
(process_one_file "$f" >/dev/null) &
|
||||
pids+=("$!")
|
||||
if ((${#pids[@]} >= workers)); then
|
||||
wait "${pids[0]}" || true
|
||||
pids=("${pids[@]:1}")
|
||||
fi
|
||||
done
|
||||
for p in "${pids[@]}"; do
|
||||
wait "$p" || true
|
||||
done
|
||||
|
||||
# 合并协议行(register_* 登记 + 请求失败统计)
|
||||
merge_emit_lines "$POOL_EMIT_FILE"
|
||||
|
||||
# 请求失败阈值(仅初始识别池启用):连续 ≥5 次或失败率 ≥50% → 终止,防止整库跑空
|
||||
if [[ "${POOL_THRESHOLD_CHECK:-false}" == "true" ]]; then
|
||||
local rate=0
|
||||
((MERGE_PROCESSED > 0)) && rate=$((MERGE_FAILED * 100 / MERGE_PROCESSED))
|
||||
if ((MERGE_STREAK >= 5 || (MERGE_PROCESSED >= 10 && rate >= 50))); then
|
||||
_log 错误 "TMDB 请求失败过多(连续 ${MERGE_STREAK} 次 / 失败率 ${rate}%),终止运行——请检查网络或 TMDB 密钥"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
unset POOL_EMIT_FILE
|
||||
rm -rf "$pool_dir"
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
# 同集冲突检测:识别成功的剧集条目按 "剧集目录||SxxExx"(含区间 E01-E02)聚合,
|
||||
# 同一 (剧集, 季, 集) 出现多个不同源文件 → 登记冲突(并列展示,不自动处理;
|
||||
# 链接阶段仍按 - 2 官方多版本格式去重)。Season 00 特典不参与(同名特典属正常多版本)。
|
||||
# 幂等:每次调用重建 CONFLICT_MAP(内存账本)。
|
||||
detect_conflicts() {
|
||||
CONFLICT_MAP=()
|
||||
local src outcome dest subdir filename
|
||||
for src in "${!MEDIA_DESTINATION_MAP[@]}"; do
|
||||
outcome="${MEDIA_OUTCOME_MAP[$src]:-}"
|
||||
[[ "$outcome" == "identified" || "$outcome" == "fallback" ]] || continue
|
||||
dest="${MEDIA_DESTINATION_MAP[$src]}"
|
||||
IFS='|' read -r subdir filename <<<"$dest"
|
||||
# 剧集目标形如:Shows/Title (Year)/Season 01/S01E01 - name.ext
|
||||
# (Season 补零格式 [0-9]+;Season 00 特典数值排除,不参与冲突检测)
|
||||
if [[ "$subdir" =~ ^(.*)/Season[[:space:]]([0-9]+)$ ]] && ((10#${BASH_REMATCH[2]} >= 1)); then
|
||||
local show_dir="${BASH_REMATCH[1]}"
|
||||
if [[ "$filename" =~ ^(S[0-9]+E[0-9]+(-E[0-9]+)?) ]]; then
|
||||
local key="${show_dir}||${BASH_REMATCH[1]}"
|
||||
CONFLICT_MAP["$key"]+="${src}|${dest}"$'\n'
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# 只保留多于 1 个来源的冲突
|
||||
local k lines
|
||||
for k in "${!CONFLICT_MAP[@]}"; do
|
||||
lines=$(printf '%s' "${CONFLICT_MAP[$k]}" | grep -c . || true)
|
||||
((lines >= 2)) || unset "CONFLICT_MAP[$k]"
|
||||
done
|
||||
}
|
||||
|
||||
# 生成源→目标对照表 markdown(--export-map):
|
||||
# 文件名 = <源目录名(sanitize)>-<源目录绝对路径 md5 前 8 位>.md(可读 + 唯一 + 稳定覆盖);
|
||||
# 默认目录 = 脚本所在目录/mo_map;--export-map=DIR 指定目录。
|
||||
# 内容按分类展示:按目的目录根(FOLDER_MOVIES/FOLDER_SHOWS/FOLDER_MUSIC/
|
||||
# FOLDER_MUSICVIDEOS/FOLDER_UNKNOWN)分区,每类一张表格 + 分类汇总;
|
||||
# 伴随文件(字幕/音轨等,link_companions 登记 dest)随主媒体归入对应大类;
|
||||
# 无目标/失败/跳过/待 AI 条目归入「未完成」区。
|
||||
# 每行:源文件绝对路径链接 + 目标文件绝对路径链接 + 链接状态(已链接/失败/跳过/未识别/请求失败/待 AI)。
|
||||
# 链接状态在非干运行下现场校验(目标存在且同 inode)。
|
||||
# 目的子目录首段 → 分类键(与 FOLDER_* 目的根名对应)。
|
||||
classify_dest_root() {
|
||||
local root="$1"
|
||||
case "$root" in
|
||||
"$FOLDER_MOVIES") echo movies ;;
|
||||
"$FOLDER_SHOWS") echo shows ;;
|
||||
"$FOLDER_MUSIC") echo music ;;
|
||||
"$FOLDER_MUSICVIDEOS") echo musicvideos ;;
|
||||
"$FOLDER_UNKNOWN") echo unknown ;;
|
||||
*) echo other ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 按结局分类:identified/fallback/companion 且有目的 → 目的根分类;其余 → 未完成。
|
||||
# 伴随文件(字幕/音轨等)在 link_companions 链接成功后登记 dest,随主媒体归入对应大类。
|
||||
cat_for_outcome() {
|
||||
local outcome="$1" dest="$2"
|
||||
if [[ -n "$dest" ]] && [[ "$outcome" == "identified" || "$outcome" == "fallback" || "$outcome" == "companion" ]]; then
|
||||
# dest = "子目录|文件名",子目录 = "分类根/.../...",取分类根首段
|
||||
local root="${dest%%|*}"
|
||||
root="${root%%/*}"
|
||||
classify_dest_root "$root"
|
||||
else
|
||||
echo other
|
||||
fi
|
||||
}
|
||||
|
||||
# 渲染分类表格(每类独立编号;0 条分类不输出小节)。
|
||||
render_section() {
|
||||
local title="$1" count="$2"
|
||||
shift 2
|
||||
((count > 0)) || return 0
|
||||
local -a rows=("$@")
|
||||
echo ""
|
||||
echo "## ${title}(${count} 条)"
|
||||
echo ""
|
||||
echo "| # | 源文件 | 目标文件 | 链接状态 |"
|
||||
echo "|---|---|---|---|"
|
||||
local i=0 k
|
||||
for ((k = 0; k < ${#rows[@]}; k += 3)); do
|
||||
i=$((i + 1))
|
||||
echo "| $i | ${rows[$k]} | ${rows[$((k + 1))]} | ${rows[$((k + 2))]} |"
|
||||
done
|
||||
}
|
||||
|
||||
export_map_md() {
|
||||
[[ "$EXPORT_MAP" != "true" ]] && return 0
|
||||
detect_conflicts
|
||||
local out_dir="${EXPORT_MAP_DIR:-${SCRIPT_DIR}/mo_map}"
|
||||
if ! mkdir -p "$out_dir" 2>/dev/null; then
|
||||
_log 错误 "无法创建对照表目录: $out_dir"
|
||||
return 1
|
||||
fi
|
||||
# 文件名 = <源目录名>-<md5 前 8 位>.md:源目录名保证可读(sanitize 去非法字符,
|
||||
# 空名(如根目录)回退 root),md5 前缀保证唯一与稳定(同一源目录覆盖更新)。
|
||||
local src_name src_hash
|
||||
src_name=$(basename "$SOURCE_DIR")
|
||||
src_name=$(sanitize "$src_name")
|
||||
[[ -n "$src_name" ]] || src_name="root"
|
||||
src_hash=$(printf '%s' "$SOURCE_DIR" | md5sum | cut -d' ' -f1 | cut -c1-8)
|
||||
local out_file="$out_dir/${src_name}-${src_hash}.md"
|
||||
|
||||
# 分类桶:每行存 3 段(源显示|目标显示|状态),输出时按类重编号。
|
||||
# 顺序固定:电影 → 节目 → 音乐 → 音乐视频 → 未知 → 未完成。
|
||||
local -a ROW_MOVIES=() ROW_SHOWS=() ROW_MUSIC=() ROW_MUSICVIDEOS=() ROW_UNKNOWN=() ROW_OTHER=()
|
||||
local -A CAT_COUNT=()
|
||||
local -A CAT_LABEL=(
|
||||
[movies]="🎬 电影"
|
||||
[shows]="📺 节目"
|
||||
[music]="🎵 音乐"
|
||||
[musicvideos]="🎤 音乐视频"
|
||||
[unknown]="❓ 未知"
|
||||
[other]="⏭ 未完成(未识别/跳过/失败/待 AI)"
|
||||
)
|
||||
|
||||
local n_total=0 n_linked=0 n_fail=0 n_skip=0
|
||||
local -a srcs=("${!MEDIA_OUTCOME_MAP[@]}")
|
||||
# 补充伴随音轨(源媒体文件无结局条目 → 伴随,不独立整理)
|
||||
local f
|
||||
for f in "${VIDEO_FILES[@]}" "${AUDIO_FILES[@]}"; do
|
||||
[[ -z "${MEDIA_OUTCOME_MAP[$f]:-}" ]] && srcs+=("$f")
|
||||
done
|
||||
n_total=${#srcs[@]}
|
||||
|
||||
local src outcome dest status dest_display cat
|
||||
local src_disp dest_subdir dest_filename dest_path
|
||||
for src in "${srcs[@]}"; do
|
||||
outcome="${MEDIA_OUTCOME_MAP[$src]:-companion}"
|
||||
dest="${MEDIA_DESTINATION_MAP[$src]:-}"
|
||||
status=""
|
||||
dest_subdir=""
|
||||
dest_filename=""
|
||||
dest_path=""
|
||||
if [[ -n "$dest" ]]; then
|
||||
# dest 格式为 "子目录|文件名"(| 是条目分隔符,不能进 URL)
|
||||
IFS='|' read -r dest_subdir dest_filename <<<"$dest"
|
||||
dest_path="${DESTINATION_DIR}/${dest_subdir}/${dest_filename}"
|
||||
fi
|
||||
case "$outcome" in
|
||||
identified | fallback)
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
status="🔄 干运行"
|
||||
elif [[ -n "$dest_path" && -f "$dest_path" ]] && same_inode "$src" "$dest_path"; then
|
||||
status="✅ 已链接"
|
||||
n_linked=$((n_linked + 1))
|
||||
else
|
||||
status="❌ 链接失败"
|
||||
n_fail=$((n_fail + 1))
|
||||
fi
|
||||
;;
|
||||
skip_type)
|
||||
status="⏭ 类型跳过"
|
||||
n_skip=$((n_skip + 1))
|
||||
;;
|
||||
skip_unidentified)
|
||||
status="❓ 未识别跳过"
|
||||
n_skip=$((n_skip + 1))
|
||||
;;
|
||||
request_failed)
|
||||
status="⚠️ 请求失败"
|
||||
n_fail=$((n_fail + 1))
|
||||
;;
|
||||
pending_ai) status="🤖 待 AI" ;;
|
||||
already_linked) status="✅ 增量跳过(已链接)" ;;
|
||||
cooldown) status="⏳ 冷却中跳过" ;;
|
||||
companion) status="📎 伴随文件(跟随主媒体)" ;;
|
||||
*) status="$outcome" ;;
|
||||
esac
|
||||
src_disp="[$(basename "$src" | sed 's/|/\\|/g')](file://$src)"
|
||||
dest_display="(无目标)"
|
||||
if [[ -n "$dest_path" ]]; then
|
||||
dest_display="[$(basename "$dest_filename" | sed 's/|/\\|/g')](file://${dest_path})"
|
||||
fi
|
||||
cat=$(cat_for_outcome "$outcome" "$dest")
|
||||
CAT_COUNT["$cat"]=$((${CAT_COUNT["$cat"]:-0} + 1))
|
||||
case "$cat" in
|
||||
movies) ROW_MOVIES+=("$src_disp" "$dest_display" "$status") ;;
|
||||
shows) ROW_SHOWS+=("$src_disp" "$dest_display" "$status") ;;
|
||||
music) ROW_MUSIC+=("$src_disp" "$dest_display" "$status") ;;
|
||||
musicvideos) ROW_MUSICVIDEOS+=("$src_disp" "$dest_display" "$status") ;;
|
||||
unknown) ROW_UNKNOWN+=("$src_disp" "$dest_display" "$status") ;;
|
||||
*) ROW_OTHER+=("$src_disp" "$dest_display" "$status") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
{
|
||||
echo "# 媒体整理对照表"
|
||||
echo ""
|
||||
echo "- 源目录:\`$SOURCE_DIR\`"
|
||||
echo "- 目的目录:\`${DESTINATION_DIR:-(未指定)}\`"
|
||||
echo "- 生成时间:$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
[[ "$DRY_RUN" == "true" ]] && echo "- 运行模式:**干运行**(未创建链接)"
|
||||
[[ "$AUTOMATED" == "true" ]] && echo "- 运行模式:自动化"
|
||||
echo ""
|
||||
echo "## 📊 分类汇总(${n_total} 条)"
|
||||
echo ""
|
||||
local c
|
||||
for c in movies shows music musicvideos unknown other; do
|
||||
[[ -n "${CAT_COUNT[$c]:-}" ]] && echo "- ${CAT_LABEL[$c]}:${CAT_COUNT[$c]} 条"
|
||||
done
|
||||
render_section "${CAT_LABEL[movies]}" "${CAT_COUNT[movies]:-0}" "${ROW_MOVIES[@]}"
|
||||
render_section "${CAT_LABEL[shows]}" "${CAT_COUNT[shows]:-0}" "${ROW_SHOWS[@]}"
|
||||
render_section "${CAT_LABEL[music]}" "${CAT_COUNT[music]:-0}" "${ROW_MUSIC[@]}"
|
||||
render_section "${CAT_LABEL[musicvideos]}" "${CAT_COUNT[musicvideos]:-0}" "${ROW_MUSICVIDEOS[@]}"
|
||||
render_section "${CAT_LABEL[unknown]}" "${CAT_COUNT[unknown]:-0}" "${ROW_UNKNOWN[@]}"
|
||||
render_section "${CAT_LABEL[other]}" "${CAT_COUNT[other]:-0}" "${ROW_OTHER[@]}"
|
||||
# 同集冲突小节(仅检测+报告:同一季集多个来源,链接已按多版本 - 2 去重)
|
||||
if [[ ${#CONFLICT_MAP[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "## ⚠️ 同集冲突(${#CONFLICT_MAP[@]} 组)"
|
||||
echo ""
|
||||
echo "同一剧集/季/集出现多个来源文件。已按官方多版本格式(- 2 后缀)去重链接;"
|
||||
echo "如需指定保留版本,编辑对应 .ledger.json 后运行 --rerun 重跑。"
|
||||
echo ""
|
||||
echo "| 剧集/集 | 来源文件 | 目标 |"
|
||||
echo "|---|---|---|"
|
||||
local ck cline i=0 csrc cdest cfile
|
||||
for ck in "${!CONFLICT_MAP[@]}"; do
|
||||
i=$((i + 1))
|
||||
while IFS= read -r cline; do
|
||||
[[ -z "$cline" ]] && continue
|
||||
csrc="${cline%%|*}"
|
||||
cdest="${cline#*|}"
|
||||
cfile="${cdest##*|}"
|
||||
echo "| $i | [$(basename "$csrc" | sed 's/|/\\|/g')](file://$csrc) | $(basename "$cfile" | sed 's/|/\\|/g') |"
|
||||
done <<<"${CONFLICT_MAP[$ck]}"
|
||||
done
|
||||
fi
|
||||
echo ""
|
||||
echo "## 链接状态汇总"
|
||||
echo ""
|
||||
echo "- 已链接:${n_linked}"
|
||||
echo "- 失败:${n_fail}"
|
||||
echo "- 跳过/未识别:${n_skip}"
|
||||
echo "- 干运行/待 AI/伴随:$((n_total - n_linked - n_fail - n_skip))"
|
||||
} >"$out_file"
|
||||
_log 成功 "源目标对照表已生成: $out_file"
|
||||
|
||||
# 伴生账本(同名 .ledger.json):供 --rerun 手动纠错。
|
||||
# 条目:src(源绝对路径)/ dest(目标绝对路径)/ outcome / status(linked|pending)/ action("")。
|
||||
# 用户编辑:改 dest 为期望目标;把 action 置 "rerun" 后运行 --rerun <文件>。
|
||||
export_ledger_json "${out_file%.md}.ledger.json" "${srcs[@]}"
|
||||
}
|
||||
|
||||
# 导出手动纠错账本(JSON 数组;与对照表 md 同名 .ledger.json)。
|
||||
# 参数:账本路径 + 源条目列表(export_map_md 传入)。
|
||||
# status 现场校验(非干运行):目标存在且同 inode → linked,否则 pending。
|
||||
export_ledger_json() {
|
||||
local ledger_file="$1"
|
||||
shift
|
||||
local -a srcs=("$@")
|
||||
local -a ledger_rows=()
|
||||
local src outcome dest dest_path dest_subdir dest_filename row_status
|
||||
for src in "${srcs[@]}"; do
|
||||
outcome="${MEDIA_OUTCOME_MAP[$src]:-companion}"
|
||||
dest="${MEDIA_DESTINATION_MAP[$src]:-}"
|
||||
dest_path=""
|
||||
if [[ -n "$dest" ]]; then
|
||||
IFS='|' read -r dest_subdir dest_filename <<<"$dest"
|
||||
dest_path="${DESTINATION_DIR}/${dest_subdir}/${dest_filename}"
|
||||
fi
|
||||
row_status="pending"
|
||||
if [[ "$DRY_RUN" != "true" ]] && [[ -n "$dest_path" && -f "$dest_path" ]] &&
|
||||
same_inode "$src" "$dest_path"; then
|
||||
row_status="linked"
|
||||
fi
|
||||
ledger_rows+=("$(jq -nc --arg s "$src" --arg d "${dest_path:-}" --arg o "$outcome" \
|
||||
--arg st "$row_status" '{src: $s, dest: $d, outcome: $o, status: $st, action: ""}')")
|
||||
done
|
||||
if printf '%s\n' "${ledger_rows[@]}" | jq -s . >"$ledger_file" 2>/dev/null; then
|
||||
_log 成功 "纠错账本已生成(编辑后 --rerun 重跑): $ledger_file"
|
||||
else
|
||||
_log 警告 "账本写入失败: $ledger_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# 运行级汇总:按结局账本分类计数并列出文件清单(每类前 10 条,其余折叠;
|
||||
# 自动化模式同时向日志文件写入全量清单)。
|
||||
print_summary() {
|
||||
detect_conflicts
|
||||
local -A counts=()
|
||||
local -A lists=()
|
||||
local key outcome
|
||||
for key in "${!MEDIA_OUTCOME_MAP[@]}"; do
|
||||
outcome="${MEDIA_OUTCOME_MAP[$key]}"
|
||||
counts["$outcome"]=$((${counts[$outcome]:-0} + 1))
|
||||
if ((${counts[$outcome]} <= 10)); then
|
||||
lists["$outcome"]+="$(basename "$key")"$'\n'
|
||||
fi
|
||||
done
|
||||
|
||||
local -a categories=(identified fallback skip_type skip_unidentified request_failed already_linked cooldown)
|
||||
local -a labels=("识别成功" "回退命名(降级成功)" "类型跳过(电影特典)" "未识别跳过" "请求失败" "增量跳过(已链接)" "冷却中跳过")
|
||||
local i
|
||||
for i in "${!categories[@]}"; do
|
||||
local cat="${categories[$i]}"
|
||||
[[ -z "${counts[$cat]:-}" ]] && continue
|
||||
_log 成功 "${labels[$i]}:${counts[$cat]} 个"
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" ]] && _log 信息 " - $line"
|
||||
done <<<"${lists[$cat]:-}"
|
||||
if ((${counts[$cat]} > 10)); then
|
||||
_log 信息 " ... 其余 $((${counts[$cat]} - 10)) 条(automated 模式日志文件含全量清单)"
|
||||
fi
|
||||
done
|
||||
_log 成功 "链接:成功 $LINK_SUCCESS_COUNT,失败 $LINK_FAIL_COUNT"
|
||||
|
||||
# 同集冲突提示(仅检测+报告;链接已按多版本 - 2 去重)
|
||||
if [[ ${#CONFLICT_MAP[@]} -gt 0 ]]; then
|
||||
_log 警告 "同集冲突:${#CONFLICT_MAP[@]} 组(同一剧集/季/集多个来源;详见对照表,可用 --rerun 指定保留版本)"
|
||||
fi
|
||||
|
||||
# 自动化模式:全量清单写入日志文件(干运行不写,零持久化副作用)
|
||||
if [[ "${AUTOMATED:-false}" == "true" ]] && [[ "${DRY_RUN:-false}" != "true" ]] &&
|
||||
[[ -n "${LOG_FILE:-}" ]] && [[ ${#MEDIA_OUTCOME_MAP[@]} -gt 0 ]]; then
|
||||
{
|
||||
echo "=== 运行汇总(全量清单)==="
|
||||
for key in "${!MEDIA_OUTCOME_MAP[@]}"; do
|
||||
printf '%s\t%s\n' "$key" "${MEDIA_OUTCOME_MAP[$key]}"
|
||||
done
|
||||
} >>"$LOG_FILE"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
################################################################################
|
||||
# 缓存管理
|
||||
################################################################################
|
||||
|
||||
# 计算缓存文件路径(镜像 TMDB API 路径结构,位于 $CACHE_DIR/tmdb 下)。
|
||||
# 缓存根目录分两区:tmdb/(TMDB API 响应缓存,可整体删除重建)与
|
||||
# media_organizer/(脚本运行状态:账本/冷却/辅助数据);用户配置类文件位于 mo_config/。
|
||||
# 参数:endpoint(如 /search/tv、/tv/{id}/season/{n})与 key(查询参数串)。
|
||||
# 搜索类用 key 的 md5 哈希作文件名;id 类直接用 id 作文件名。
|
||||
cache_path() {
|
||||
local endpoint="$1" key="$2"
|
||||
local hash
|
||||
case "$endpoint" in
|
||||
/search/*)
|
||||
hash=$(cache_key_hash "$key")
|
||||
echo "$CACHE_DIR/tmdb${endpoint}/${hash}.json"
|
||||
;;
|
||||
# 注意:id 类路径含语言段(.${TMDB_LANG})——详情/季数据语言相关,切语言自动 miss 重新拉取
|
||||
/tv/*/season/*)
|
||||
local rest id season
|
||||
rest="${endpoint#/tv/}"
|
||||
id="${rest%%/*}"
|
||||
season="${rest##*/}"
|
||||
echo "$CACHE_DIR/tmdb/tv/${id}/season/${season}.${TMDB_LANG}.json"
|
||||
;;
|
||||
/tv/*)
|
||||
echo "$CACHE_DIR/tmdb/tv/${endpoint#/tv/}.${TMDB_LANG}.json"
|
||||
;;
|
||||
/movie/*)
|
||||
echo "$CACHE_DIR/tmdb/movie/${endpoint#/movie/}.${TMDB_LANG}.json"
|
||||
;;
|
||||
*)
|
||||
echo "$CACHE_DIR/tmdb/${endpoint#/}.json"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 将调用参数拼为规范 key 串(形如 query=xxx&year=2020&lang=zh-CN)。
|
||||
cache_key() {
|
||||
local key=""
|
||||
local a
|
||||
for a in "$@"; do
|
||||
key="${key}${a}"
|
||||
done
|
||||
key="${key}&lang=${TMDB_LANG}"
|
||||
echo "$key"
|
||||
}
|
||||
|
||||
# 计算 key 的 md5 哈希(搜索缓存文件名用)。
|
||||
cache_key_hash() {
|
||||
printf '%s' "$1" | md5sum | awk '{print $1}'
|
||||
}
|
||||
|
||||
# 判断缓存文件是否过期(按文件 mtime 与 TTL 天数)。
|
||||
cache_expired() {
|
||||
local path="$1" ttl_days="$2"
|
||||
local mtime now ttl_sec
|
||||
mtime=$(stat -c '%Y' "$path" 2>/dev/null) ||
|
||||
mtime=$(date -r "$path" +%s 2>/dev/null) ||
|
||||
mtime=$(date +%s)
|
||||
now=$(date +%s)
|
||||
ttl_sec=$((ttl_days * 86400))
|
||||
((now - mtime > ttl_sec))
|
||||
}
|
||||
|
||||
# 读取缓存。命中且未过期则输出数据到 stdout 并返回 0;否则返回 1。
|
||||
# 参数:path 缓存文件路径;is_search 是否搜索缓存(1=是,0=否)。
|
||||
# 搜索缓存为包裹格式(含 empty/data 字段),其余为原始响应或空文件哨兵。
|
||||
cache_get() {
|
||||
local path="$1" is_search="$2"
|
||||
local ttl="${CACHE_TTL_DAYS:-30}"
|
||||
[[ -f "$path" ]] || return 1
|
||||
if cache_expired "$path" "$ttl"; then
|
||||
return 1
|
||||
fi
|
||||
if [[ "$is_search" == "1" ]]; then
|
||||
local empty
|
||||
empty=$(jq -r '.empty // false' "$path" 2>/dev/null || echo true)
|
||||
[[ "$empty" == "true" ]] && return 1
|
||||
jq -r '.data // empty' "$path" 2>/dev/null
|
||||
else
|
||||
[[ -s "$path" ]] || return 1
|
||||
cat "$path"
|
||||
fi
|
||||
}
|
||||
|
||||
# 判断缓存文件是否为"新鲜空哨兵"(在 CACHE_EMPTY_TTL_DAYS 内)。
|
||||
# 用于让 tmdb_api 知道上次查询已确认为空结果,TTL 内不再重复请求。
|
||||
# 返回 0=新鲜空哨兵(免重试);1=非空哨兵/已过期(允许重试)。
|
||||
cache_empty_fresh() {
|
||||
local path="$1" is_search="$2"
|
||||
[[ -f "$path" ]] || return 1
|
||||
if cache_expired "$path" "${CACHE_EMPTY_TTL_DAYS:-3}"; then
|
||||
return 1
|
||||
fi
|
||||
if [[ "$is_search" == "1" ]]; then
|
||||
[[ "$(jq -r '.empty // false' "$path" 2>/dev/null || echo false)" == "true" ]]
|
||||
else
|
||||
[[ ! -s "$path" ]]
|
||||
fi
|
||||
}
|
||||
|
||||
# 写入缓存(原子写:临时文件 + rename)。
|
||||
# 搜索缓存写包裹格式,其余直接写原始响应。
|
||||
# 干运行模式下跳过写入(零持久化副作用)。
|
||||
cache_put() {
|
||||
local path="$1" data="$2" is_search="$3"
|
||||
local key="$4"
|
||||
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||
local tmp="${path}.tmp.$$"
|
||||
mkdir -p "$(dirname "$path")"
|
||||
if [[ "$is_search" == "1" ]]; then
|
||||
local q y
|
||||
q=$(echo "$key" | sed -n 's/.*query=\([^&]*\).*/\1/p')
|
||||
y=$(echo "$key" | sed -n 's/.*year=\([^&]*\).*/\1/p')
|
||||
jq -n --arg q "$q" --arg y "$y" --arg l "$TMDB_LANG" \
|
||||
--argjson t "$(date +%s)" --argjson d "$data" \
|
||||
'{query: $q, year: $y, lang: $l, fetched_at: $t,
|
||||
empty: false, data: $d}' >"$tmp" 2>/dev/null || return 1
|
||||
else
|
||||
printf '%s\n' "$data" >"$tmp" 2>/dev/null || return 1
|
||||
fi
|
||||
mv "$tmp" "$path" 2>/dev/null || return 1
|
||||
}
|
||||
|
||||
# 写入空结果哨兵(404/无结果),避免重复请求。
|
||||
# 搜索缓存写 empty 包裹;其余写 0 字节文件。
|
||||
# 干运行模式下跳过写入(零持久化副作用)。
|
||||
cache_put_empty() {
|
||||
local path="$1" is_search="$2"
|
||||
local key="$3"
|
||||
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||
local tmp="${path}.tmp.$$"
|
||||
mkdir -p "$(dirname "$path")"
|
||||
if [[ "$is_search" == "1" ]]; then
|
||||
local q y
|
||||
q=$(echo "$key" | sed -n 's/.*query=\([^&]*\).*/\1/p')
|
||||
y=$(echo "$key" | sed -n 's/.*year=\([^&]*\).*/\1/p')
|
||||
jq -n --arg q "$q" --arg y "$y" --arg l "$TMDB_LANG" \
|
||||
--argjson t "$(date +%s)" \
|
||||
'{query: $q, year: $y, lang: $l, fetched_at: $t,
|
||||
empty: true, data: null}' >"$tmp" 2>/dev/null || return 1
|
||||
else
|
||||
: >"$tmp" 2>/dev/null || return 1
|
||||
fi
|
||||
mv "$tmp" "$path" 2>/dev/null || return 1
|
||||
}
|
||||
|
||||
# 清空 TMDB 缓存(--refresh-cache)。
|
||||
# 仅清除 tmdb/ 子目录(API 响应缓存,可再生);mo_config/ 配置与 media_organizer/
|
||||
# 运行状态不随 --refresh-cache 清除。
|
||||
# 干运行模式下跳过清理(零持久化副作用),并提示其不生效。
|
||||
cache_clear() {
|
||||
if [[ "${DRY_RUN:-false}" == "true" ]]; then
|
||||
_log 警告 "干运行模式下跳过缓存清理"
|
||||
return 0
|
||||
fi
|
||||
rm -rf "$CACHE_DIR/tmdb" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# 迁移旧版缓存布局(v9 前:mo_cache/{movie,search,tv} 平铺在根下)到 tmdb/ 子目录。
|
||||
# 仅当新布局不存在且旧布局存在时执行一次;纯数据搬运,不影响缓存语义。
|
||||
# 干运行模式下跳过(零持久化副作用)。
|
||||
migrate_tmdb_cache() {
|
||||
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||
[[ -n "${CACHE_DIR:-}" ]] || return 0
|
||||
[[ -d "$CACHE_DIR/tmdb" ]] && return 0
|
||||
local legacy=("$CACHE_DIR/movie" "$CACHE_DIR/search" "$CACHE_DIR/tv") d exists=false
|
||||
for d in "${legacy[@]}"; do
|
||||
[[ -d "$d" ]] && exists=true
|
||||
done
|
||||
[[ "$exists" == "true" ]] || return 0
|
||||
mkdir -p "$CACHE_DIR/tmdb" 2>/dev/null || return 0
|
||||
for d in "${legacy[@]}"; do
|
||||
[[ -d "$d" ]] || continue
|
||||
if mv "$d" "$CACHE_DIR/tmdb/" 2>/dev/null; then
|
||||
_log 信息 "已迁移旧版 TMDB 缓存: $d -> $CACHE_DIR/tmdb/"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# 列出缓存条目(--list-cache 一键反查)。可选 filter 过滤参数串。
|
||||
# 仅列出 tmdb/ 下的 API 响应缓存;mo_config/ 配置类文件与 media_organizer/ 运行状态不在此列。
|
||||
# 输出首行标题:哈希 / 类型 / 路径 / 参数 / 获取时间。
|
||||
cache_list() {
|
||||
local filter="$1"
|
||||
printf '哈希\t类型\t路径\t参数\t获取时间\n'
|
||||
local f rel path type params ts hash
|
||||
while IFS= read -r -d '' f; do
|
||||
rel="${f#"$CACHE_DIR"/}"
|
||||
path="${rel#tmdb/}" # 去 tmdb/ 前缀用于类型识别,展示保留完整相对路径
|
||||
hash=$(basename "$f" .json)
|
||||
case "$path" in
|
||||
search/*)
|
||||
type="Search"
|
||||
params=$(jq -r '"query=" + (.query // "") + "&lang=" + (.lang // "")' \
|
||||
"$f" 2>/dev/null || echo "?")
|
||||
ts=$(jq -r '.fetched_at // 0' "$f" 2>/dev/null || echo "?")
|
||||
;;
|
||||
tv/*/season/*)
|
||||
type="TV"
|
||||
params="season=$(basename "$f" .json)"
|
||||
params="${params%%.*}" # 去语言后缀(0.zh-CN → 0),保留 season= 前缀
|
||||
ts=$(date -r "$f" +%s 2>/dev/null || echo "?")
|
||||
;;
|
||||
tv/*)
|
||||
type="TV"
|
||||
params="id=$(basename "$f" .json)"
|
||||
params="${params%%.*}" # 去语言后缀,保留 id= 前缀
|
||||
ts=$(date -r "$f" +%s 2>/dev/null || echo "?")
|
||||
;;
|
||||
movie/*)
|
||||
type="Movie"
|
||||
params="id=$(basename "$f" .json)"
|
||||
params="${params%%.*}" # 去语言后缀,保留 id= 前缀
|
||||
ts=$(date -r "$f" +%s 2>/dev/null || echo "?")
|
||||
;;
|
||||
*)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
[[ -n "$filter" && "$params" != *"$filter"* ]] && continue
|
||||
printf '%s\t%s\t%s\t%s\t%s\n' "$hash" "$type" "$rel" "$params" "$ts"
|
||||
done < <(find "$CACHE_DIR/tmdb" -type f -name '*.json' -print0 2>/dev/null)
|
||||
}
|
||||
|
||||
# 基于源目录重新获取并更新缓存(--update-cache)。
|
||||
# 扫描源目录解析出唯一查询,删除对应旧缓存后强制重新请求并覆盖写;
|
||||
# 源目录中不存在的查询缓存保留不动。
|
||||
update_cache() {
|
||||
_log 信息 "开始更新缓存(基于源目录)..."
|
||||
local -A queries=()
|
||||
local file ext ext_lower type title year info
|
||||
# 扫描源目录,收集唯一查询
|
||||
while IFS= read -r -d '' file; do
|
||||
ext="${file##*.}"
|
||||
ext_lower="${ext,,}"
|
||||
[[ " ${VIDEO_EXTS[*]} " =~ [[:space:]]${ext_lower}[[:space:]] ]] || continue
|
||||
info=$(parse_media_filename "$file")
|
||||
IFS='|' read -r type title year _ _ _ <<<"$info"
|
||||
[[ "$type" == "movie" || "$type" == "tv" ]] || continue
|
||||
queries["${title}||${type}||${year}"]=1
|
||||
done < <(find "$SOURCE_DIR" -type f -print0)
|
||||
|
||||
_log 信息 "共 ${#queries[@]} 个唯一查询待更新"
|
||||
local q t ty y endpoint key path result id
|
||||
for q in "${!queries[@]}"; do
|
||||
t="${q%%||*}"
|
||||
ty="${q#*||}"
|
||||
y="${ty##*||}"
|
||||
ty="${ty%%||*}"
|
||||
endpoint="/search/movie"
|
||||
[[ "$ty" == "tv" ]] && endpoint="/search/tv"
|
||||
key=$(cache_key "query=${t}" ${y:+"year=${y}"})
|
||||
path=$(cache_path "$endpoint" "$key")
|
||||
rm -f "$path"
|
||||
_log 信息 "更新搜索: $t ($y)"
|
||||
if result=$(tmdb_api "$endpoint" "query=${t}" ${y:+"year=${y}"}); then
|
||||
id=$(echo "$result" | jq -r '.results[0].id // empty')
|
||||
if [[ -n "$id" ]]; then
|
||||
# 更新详情
|
||||
rm -f "$(cache_path "/${ty}/${id}" "$key")"
|
||||
_log 信息 "更新详情: ${ty}/${id}"
|
||||
tmdb_api "/${ty}/${id}" >/dev/null 2>&1 || true
|
||||
# 更新剧集季数据
|
||||
if [[ "$ty" == "tv" ]]; then
|
||||
local details n s
|
||||
details=$(tmdb_api "/tv/${id}" 2>/dev/null || echo "")
|
||||
n=$(echo "$details" | jq -r '.number_of_seasons // 0' 2>/dev/null || echo 0)
|
||||
for ((s = 0; s <= n; s++)); do
|
||||
rm -f "$(cache_path "/tv/${id}/season/${s}" "$key")"
|
||||
tmdb_api "/tv/${id}/season/${s}" >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
_log 成功 "缓存更新完成"
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
################################################################################
|
||||
# 账本与冷却(增量标记 / 失败冷却 / 手动纠错账本)
|
||||
#
|
||||
# 两个运行期数据文件(位于 $CACHE_DIR/media_organizer/,与 TMDB 缓存分离):
|
||||
# linked.json 已链接账本:{ "<src>": {"dest","inode","linked_at"} }
|
||||
# —— 增量标记:重跑时源 inode 与目标均有效才跳过识别;
|
||||
# 目标被删 / 源被替换(inode 变化)→ 自动失效,重新识别+链接。
|
||||
# fail_cooldown.json 失败冷却:{ "<src>": {"retry_at","reason"} }
|
||||
# —— request_failed / skip_unidentified 登记冷却,
|
||||
# 冷却期内 cron 重跑直接跳过(避免每轮全量重试同一批失败项)。
|
||||
#
|
||||
# 进程模型:
|
||||
# 识别池 worker 是 fork 子进程——load 在父进程完成(fork 复制内存关联数组),
|
||||
# set/mark 只更新父进程内存,flush 在运行末尾统一落盘(原子写:临时文件+rename)。
|
||||
# 中途异常退出不落盘:链接阶段本身 inode 幂等、冷却丢失仅导致下次重试,均安全。
|
||||
#
|
||||
# 干运行零持久化副作用:flush 跳过写入(与 cache_put 同约定)。
|
||||
################################################################################
|
||||
|
||||
# 已链接账本:src -> dest(绝对路径)
|
||||
declare -A LEDGER_LINKED=()
|
||||
# 已链接账本:src -> inode(链接时的源 inode)
|
||||
declare -A LEDGER_INODE=()
|
||||
# 失败冷却:src -> retry_at(epoch 秒)
|
||||
declare -A COOLDOWN_MAP=()
|
||||
# 纠错学习:clean_name(源文件名) 小写 -> 用户修正的绝对目标路径(corrections.json)
|
||||
# 来源:--rerun 手动纠错成功链接后学习;消费:识别池入口前置命中(同命名文件直接归位)。
|
||||
declare -A CORRECTIONS_MAP=()
|
||||
|
||||
# 加载两个账本(父进程在扫描前调用一次;worker fork 时复制内存副本)。
|
||||
ledger_load() {
|
||||
LEDGER_LINKED=()
|
||||
LEDGER_INODE=()
|
||||
COOLDOWN_MAP=()
|
||||
CORRECTIONS_MAP=()
|
||||
local f="$CACHE_DIR/media_organizer/linked.json"
|
||||
if [[ -f "$f" ]]; then
|
||||
local src dest inode
|
||||
while IFS=$'\t' read -r src dest inode; do
|
||||
[[ -z "$src" || -z "$dest" ]] && continue
|
||||
LEDGER_LINKED["$src"]="$dest"
|
||||
[[ -n "$inode" ]] && LEDGER_INODE["$src"]="$inode"
|
||||
done < <(jq -r 'to_entries[] | "\(.key)\t\(.value.dest // "")\t\(.value.inode // "")"' "$f" 2>/dev/null)
|
||||
fi
|
||||
f="$CACHE_DIR/media_organizer/fail_cooldown.json"
|
||||
if [[ -f "$f" ]]; then
|
||||
local src2 retry
|
||||
while IFS=$'\t' read -r src2 retry; do
|
||||
[[ -z "$src2" || -z "$retry" ]] && continue
|
||||
COOLDOWN_MAP["$src2"]="$retry"
|
||||
done < <(jq -r 'to_entries[] | "\(.key)\t\(.value.retry_at // 0)"' "$f" 2>/dev/null)
|
||||
fi
|
||||
# 纠错学习(corrections.json:clean_name 小写 → 绝对目标路径)
|
||||
f="$CACHE_DIR/media_organizer/corrections.json"
|
||||
if [[ -f "$f" ]]; then
|
||||
local ck cd
|
||||
while IFS=$'\t' read -r ck cd; do
|
||||
[[ -z "$ck" || -z "$cd" ]] && continue
|
||||
CORRECTIONS_MAP["$ck"]="$cd"
|
||||
done < <(jq -r 'to_entries[] | "\(.key)\t\(.value.dest // "")"' "$f" 2>/dev/null)
|
||||
fi
|
||||
_log 调试 "已链接账本 ${#LEDGER_LINKED[@]} 条,冷却账本 ${#COOLDOWN_MAP[@]} 条,纠错学习 ${#CORRECTIONS_MAP[@]} 条"
|
||||
}
|
||||
|
||||
# 学习纠错映射:--rerun 手动修正成功链接后调用(用户显式确认的目标即权威)。
|
||||
# key = clean_name(源文件名) 小写;值 = 绝对目标路径。立即原子写 corrections.json
|
||||
# (rerun 是独立进程,学习必须当场落盘;dry-run 不写——零持久化副作用)。
|
||||
correction_learn() {
|
||||
local src="$1" dest="$2"
|
||||
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||
[[ -n "$CACHE_DIR" ]] || return 0
|
||||
local key
|
||||
# key = 去扩展名 + clean_name(与消费端 process_one_file 的算法一致)
|
||||
key="${src##*/}"
|
||||
key="${key%.*}"
|
||||
key=$(clean_name "$key")
|
||||
key="${key,,}"
|
||||
[[ -n "$key" ]] || return 0
|
||||
CORRECTIONS_MAP["$key"]="$dest"
|
||||
local dir="$CACHE_DIR/media_organizer"
|
||||
mkdir -p "$dir" 2>/dev/null || return 0
|
||||
local file="$dir/corrections.json"
|
||||
local tmp="${file}.tmp.$$"
|
||||
local now
|
||||
now=$(date +%s)
|
||||
if [[ -f "$file" ]]; then
|
||||
jq --arg k "$key" --arg d "$dest" --argjson t "$now" \
|
||||
'.[$k] = {dest: $d, learned_at: $t}' "$file" >"$tmp" 2>/dev/null
|
||||
else
|
||||
jq -n --arg k "$key" --arg d "$dest" --argjson t "$now" \
|
||||
'{($k): {dest: $d, learned_at: $t}}' >"$tmp" 2>/dev/null
|
||||
fi
|
||||
if [[ -s "$tmp" ]]; then
|
||||
mv "$tmp" "$file" 2>/dev/null || rm -f "$tmp"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
_log 信息 "已学习纠错映射: $key -> $dest"
|
||||
}
|
||||
|
||||
# 已链接判定(增量跳过):源存在且与目标同 inode。
|
||||
# 目标被删(用户清库)→ 返回失败,重新识别+链接;源被替换(inode 变化)→ 同样失效。
|
||||
# 返回值:0=已链接(可跳过);1=未链接/失效。
|
||||
ledger_is_linked() {
|
||||
local src="$1"
|
||||
local dest="${LEDGER_LINKED[$src]:-}"
|
||||
[[ -n "$dest" ]] || return 1
|
||||
[[ -f "$src" ]] || return 1
|
||||
same_inode "$src" "$dest"
|
||||
}
|
||||
|
||||
# 标记已链接(内存;flush 统一落盘)。调用时机:硬链接成功后(link_media 父进程)。
|
||||
ledger_mark_linked() {
|
||||
local src="$1" dest="$2"
|
||||
LEDGER_LINKED["$src"]="$dest"
|
||||
local inode
|
||||
inode=$(get_file_inode "$src")
|
||||
[[ -n "$inode" ]] && LEDGER_INODE["$src"]="$inode"
|
||||
}
|
||||
|
||||
# 登记失败冷却(内存;flush 落盘)。retry_at = now + FAIL_RETRY_COOLDOWN_HOURS。
|
||||
# 仅在 request_failed / skip_unidentified 登记(尽力后失败才冷却;类型跳过无成本不冷却)。
|
||||
cooldown_set() {
|
||||
local src="$1" _reason="$2"
|
||||
local hours="${FAIL_RETRY_COOLDOWN_HOURS:-24}"
|
||||
[[ "$hours" =~ ^[0-9]+$ ]] || hours=24
|
||||
((hours > 0)) || return 0
|
||||
local now
|
||||
now=$(date +%s)
|
||||
COOLDOWN_MAP["$src"]=$((now + hours * 3600))
|
||||
}
|
||||
|
||||
# 冷却判定:记录存在且未到期,且源文件仍在(源消失 → 不冷却,允许重试)。
|
||||
# 返回值:0=冷却中(跳过);1=可处理。
|
||||
cooldown_active() {
|
||||
local src="$1"
|
||||
local retry="${COOLDOWN_MAP[$src]:-}"
|
||||
[[ -n "$retry" ]] || return 1
|
||||
[[ -f "$src" ]] || return 1
|
||||
local now
|
||||
now=$(date +%s)
|
||||
((now < retry))
|
||||
}
|
||||
|
||||
# 统一落盘(运行末尾、汇总前调用):原子写 + 惰性清理(源已消失/已过期条目不写回)。
|
||||
# 干运行跳过(零持久化副作用)。
|
||||
ledger_flush() {
|
||||
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||
[[ -n "${CACHE_DIR:-}" ]] || return 0
|
||||
local dir="$CACHE_DIR/media_organizer"
|
||||
mkdir -p "$dir" 2>/dev/null || return 0
|
||||
local now
|
||||
now=$(date +%s)
|
||||
|
||||
# linked.json:仅保留源仍存在的条目(jq -s 'add' 合并为单一对象,原子写)
|
||||
local -a rows=()
|
||||
local src dest
|
||||
for src in "${!LEDGER_LINKED[@]}"; do
|
||||
[[ -f "$src" ]] || continue
|
||||
dest="${LEDGER_LINKED[$src]}"
|
||||
rows+=("$(jq -nc --arg s "$src" --arg d "$dest" --arg i "${LEDGER_INODE[$src]:-}" \
|
||||
--argjson t "$now" '{($s): {dest: $d, inode: $i, linked_at: $t}}')")
|
||||
done
|
||||
local tmp="${dir}/linked.json.tmp.$$"
|
||||
if printf '%s\n' "${rows[@]}" | jq -s 'if length == 0 then {} else add end' >"$tmp" 2>/dev/null; then
|
||||
mv "$tmp" "${dir}/linked.json" 2>/dev/null || rm -f "$tmp"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
|
||||
# fail_cooldown.json:清理已过期与源已消失的条目
|
||||
rows=()
|
||||
local retry
|
||||
for src in "${!COOLDOWN_MAP[@]}"; do
|
||||
[[ -f "$src" ]] || continue
|
||||
retry="${COOLDOWN_MAP[$src]}"
|
||||
((retry > now)) || continue
|
||||
rows+=("$(jq -nc --arg s "$src" --argjson r "$retry" '{($s): {retry_at: $r}}')")
|
||||
done
|
||||
tmp="${dir}/fail_cooldown.json.tmp.$$"
|
||||
if printf '%s\n' "${rows[@]}" | jq -s 'if length == 0 then {} else add end' >"$tmp" 2>/dev/null; then
|
||||
mv "$tmp" "${dir}/fail_cooldown.json" 2>/dev/null || rm -f "$tmp"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# 手动纠错账本(--export-map 伴生 .ledger.json + --rerun 消费)
|
||||
#
|
||||
# 导出:export_map_md 在生成对照表 md 的同时,同目录生成同名 .ledger.json:
|
||||
# [{"src": "<源绝对路径>", "dest": "<目标绝对路径>", "outcome": "...",
|
||||
# "status": "linked"|"pending", "action": ""}, ...]
|
||||
# 用户编辑:改 dest(绝对路径)为期望目标;把 action 置 "rerun" 标记重跑条目。
|
||||
# 重跑:--rerun <文件> 只执行 action=="rerun" 的条目——不重新识别,
|
||||
# 直接 mkdir + 硬链接(目标已存在且非同一 inode → 替换,即"手动纠错替换"语义)。
|
||||
# --rerun 不做冷却检查(用户显式重跑不受冷却限制)。
|
||||
################################################################################
|
||||
|
||||
# --rerun 主流程:读取账本 → 执行标记条目 → 汇总。
|
||||
# 参数:账本文件路径。返回退出码(0=全部成功;3=部分失败)。
|
||||
run_rerun() {
|
||||
local ledger_file="$1"
|
||||
if [[ ! -f "$ledger_file" ]]; then
|
||||
_log 错误 "账本文件不存在: $ledger_file"
|
||||
exit 1
|
||||
fi
|
||||
if ! jq -e 'type == "array"' "$ledger_file" >/dev/null 2>&1; then
|
||||
_log 错误 "账本文件必须为 JSON 数组: $ledger_file"
|
||||
exit 1
|
||||
fi
|
||||
_log 信息 "=== 重跑账本: $ledger_file ==="
|
||||
local ok=0 skipped=0 missing=0
|
||||
local src dest action _status
|
||||
while IFS=$'\t' read -r src dest action _status; do
|
||||
[[ -z "$src" || -z "$dest" ]] && continue
|
||||
if [[ "$action" != "rerun" ]]; then
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
if [[ ! -f "$src" ]]; then
|
||||
_log 警告 "源文件不存在,跳过: $src"
|
||||
missing=$((missing + 1))
|
||||
continue
|
||||
fi
|
||||
_log 信息 "重跑: $(basename "$src") -> $dest"
|
||||
push_indent
|
||||
if ! mkdir -p "$(dirname "$dest")"; then
|
||||
_log 错误 "无法创建目的目录: $(dirname "$dest")"
|
||||
missing=$((missing + 1))
|
||||
pop_indent
|
||||
continue
|
||||
fi
|
||||
if hardlink_or_dryrun "$src" "$dest"; then
|
||||
# 学习纠错映射(用户显式修正 → 下次识别前置命中;干运行不写)。
|
||||
# 不在此处记账本:rerun 模式未加载冷却账本,ledger_flush 会把冷却文件
|
||||
# 覆盖为 {}(数据丢失);链接本身 inode 幂等,下次 organize 的 link_media 会补记。
|
||||
correction_learn "$src" "$dest"
|
||||
ok=$((ok + 1))
|
||||
else
|
||||
missing=$((missing + 1))
|
||||
fi
|
||||
pop_indent
|
||||
done < <(jq -r '.[] | "\(.src // "")\t\(.dest // "")\t\(.action // "")\t\(.status // "")"' "$ledger_file" 2>/dev/null)
|
||||
_log 成功 "重跑完成:成功 $ok,跳过(未标记 rerun)$skipped,失败/缺失 $missing"
|
||||
if ((missing > 0)); then
|
||||
return 3
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034 # 跨文件共享常量(JQ_NORM_LITE,identify.sh 消费)
|
||||
|
||||
################################################################################
|
||||
# 字符串工具
|
||||
################################################################################
|
||||
|
||||
# 移除文件名非法字符(保留安全的跨平台字符)。
|
||||
sanitize() {
|
||||
# 非法字符直接删除(不做替换)——Windows/文件系统不兼容字符:\ / : * ? " < > |
|
||||
echo "${1:-}" | sed 's/[\\/:*?"<>|]//g'
|
||||
}
|
||||
|
||||
# 判断文件是否为视频的配套文件(同名、含语言标签等)。
|
||||
is_companion() {
|
||||
local companion="$1" vbase="$2"
|
||||
local name_noext="${companion%.*}"
|
||||
[[ "$name_noext" == "$vbase" ]] && return 0
|
||||
local inner_ext="${name_noext##*.}"
|
||||
if [[ "$inner_ext" != "$name_noext" ]]; then
|
||||
[[ "$inner_ext" =~ ^[a-zA-Z]{2,3}(-[a-zA-Z]{2,})?$ ]] || return 1
|
||||
local possible_base="${name_noext%.*}"
|
||||
[[ "$possible_base" == "$vbase" ]] && return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 清理文件名中的方括号标记及首尾空白。
|
||||
clean_name() {
|
||||
echo "$1" | sed -E 's/\[[^]]*\]//g' | sed -E 's/^[[:space:]]+|[[:space:]]+$//g'
|
||||
}
|
||||
|
||||
# 移除标题中的季数后缀(如 "S01"、"Season 2")。
|
||||
strip_season_suffix() {
|
||||
echo "$1" | sed -E 's/[[:space:]]*[0-9]+(st|nd|rd|th)?[[:space:]]*[Ss]eason[[:space:]]*[0-9]*//I; s/[[:space:]]*[Ss][0-9]+//I; s/[[:space:]]+$//; s/^[[:space:]]+//'
|
||||
}
|
||||
|
||||
# 字符串归一化 jq 表达式(全角转半角 + 去空格/连字符 + 小写)。
|
||||
# 共享常量:norm_str 与批量提取(S0 条目预计算)复用,避免逐条 spawn jq。
|
||||
readonly JQ_NORM='split("Ⅱ") | join("II") | split("Ⅲ") | join("III") | split("Ⅳ") | join("IV") | split("Ⅴ") | join("V") | split("0") | join("0") | split("1") | join("1") | split("2") | join("2") | split("3") | join("3") | split("4") | join("4") | split("5") | join("5") | split("6") | join("6") | split("7") | join("7") | split("8") | join("8") | split("9") | join("9") | split("A") | join("A") | split("B") | join("B") | split("C") | join("C") | split("D") | join("D") | split("E") | join("E") | split("F") | join("F") | split("G") | join("G") | split("H") | join("H") | split("I") | join("I") | split("J") | join("J") | split("K") | join("K") | split("L") | join("L") | split("M") | join("M") | split("N") | join("N") | split("O") | join("O") | split("P") | join("P") | split("Q") | join("Q") | split("R") | join("R") | split("S") | join("S") | split("T") | join("T") | split("U") | join("U") | split("V") | join("V") | split("W") | join("W") | split("X") | join("X") | split("Y") | join("Y") | split("Z") | join("Z") | split("a") | join("a") | split("b") | join("b") | split("c") | join("c") | split("d") | join("d") | split("e") | join("e") | split("f") | join("f") | split("g") | join("g") | split("h") | join("h") | split("i") | join("i") | split("j") | join("j") | split("k") | join("k") | split("l") | join("l") | split("m") | join("m") | split("n") | join("n") | split("o") | join("o") | split("p") | join("p") | split("q") | join("q") | split("r") | join("r") | split("s") | join("s") | split("t") | join("t") | split("u") | join("u") | split("v") | join("v") | split("w") | join("w") | split("x") | join("x") | split("y") | join("y") | split("z") | join("z") | split(" ") | join("") | split("-") | join("") | split(" ") | join("") | ascii_downcase'
|
||||
readonly JQ_NORM_LITE='split("Ⅱ") | join("II") | split("Ⅲ") | join("III") | split("Ⅳ") | join("IV") | split("Ⅴ") | join("V") | split(" ") | join("") | split("-") | join("") | split("\t") | join("") | ascii_downcase'
|
||||
|
||||
# 字符串归一化:全角转半角(含 ⅡⅢⅣⅤ 罗马数字)+ 去空格/连字符 + 小写。
|
||||
# 用 jq 实现(busybox sed/tr 按字节处理多字节 UTF-8,会破坏中文;jq 正确处理)。
|
||||
norm_str() {
|
||||
printf '%s' "$1" | jq -Rr "$JQ_NORM"
|
||||
}
|
||||
|
||||
# 安全格式化整数(两位补零),非数字时返回默认值。
|
||||
safe_printf_int() {
|
||||
local val="$1" default="${2:-00}"
|
||||
if [[ "$val" =~ ^[0-9]+$ ]]; then
|
||||
printf "%02d" "$((10#$val))"
|
||||
else
|
||||
printf "%s" "$default"
|
||||
fi
|
||||
}
|
||||
|
||||
# 归一化为绝对路径(目录可不存在;空串原样返回)。
|
||||
# 用途:SOURCE_DIR/DESTINATION_DIR 归一化——纠错账本存绝对路径、目录比较
|
||||
# (find_show_path_from_file 等)都依赖一致的绝对路径语义。
|
||||
normalize_abs() {
|
||||
local p="$1"
|
||||
[[ -z "$p" ]] && {
|
||||
echo ""
|
||||
return 0
|
||||
}
|
||||
[[ "$p" == /* ]] && {
|
||||
echo "$p"
|
||||
return 0
|
||||
}
|
||||
local base dir
|
||||
base=$(basename "$p")
|
||||
dir=$(dirname "$p")
|
||||
dir=$(cd "$dir" 2>/dev/null && pwd || echo "$dir")
|
||||
echo "${dir%/}/${base}"
|
||||
}
|
||||
|
||||
# 将特典片段(文件中的关键字符串,如 "Menu01")翻译为 TMDB 字符串(查 keymap,键已归一化为小写)。
|
||||
# 先精确匹配片段(转小写),再匹配去数字后的核心(如 Menu01 -> menu);无映射时返回原片段。
|
||||
# v9.6:查表走类目分区(默认 tv 特典语境):类目分区 → default 分区 → 全局表。
|
||||
translate_fragment() {
|
||||
local fragment="$1" cat="${2:-tv}"
|
||||
# 查表键规范化(仅影响查找,不改存储/输出):
|
||||
# 去空白与连字符("Menu 01"/"PV-01" → "menu01"/"pv01")+ ASCII 全角转半角
|
||||
# (sed y/ 按字符翻译;tr 对多字节 UTF-8 失效)
|
||||
local norm
|
||||
norm=$(printf '%s' "$fragment" |
|
||||
sed -e 'y/0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz/0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz/' \
|
||||
-e 's/[[:space:]-]//g')
|
||||
norm="${norm,,}"
|
||||
local val core
|
||||
val=$(special_map_value "$cat" "$norm")
|
||||
if [[ -z "$val" ]]; then
|
||||
core=$(printf '%s' "$norm" | sed -E 's/[0-9]+$//')
|
||||
val=$(special_map_value "$cat" "$core")
|
||||
fi
|
||||
if [[ -n "$val" ]]; then
|
||||
# 值为 JSON 数组(多语言),取第一个作为翻译结果
|
||||
printf '%s' "$(echo "$val" | jq -r 'if type == "array" then .[0] else . end // empty' 2>/dev/null)"
|
||||
return
|
||||
fi
|
||||
printf '%s' "$fragment"
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034,SC2168 # 跨文件共享全局变量(映射后由其他模块消费);local 在 bashly 包装的函数体内合法
|
||||
|
||||
################################################################################
|
||||
# root 命令文件(bashly 生成脚本的入口调用 root_command)
|
||||
#
|
||||
# bashly 已自动完成:参数解析(位置参数/选项/--opt=值/-- 分隔符)、
|
||||
# 帮助与版本输出、flag 互斥检查(conflicts)。本函数负责:
|
||||
# 1. bashly 解析变量 → 现有全局变量映射
|
||||
# 2. 形状校验(目录组合、模式互斥的剩余部分)
|
||||
# 3. 原 main() 的完整流水线
|
||||
#
|
||||
# 注意:本文件内容由 bashly 包装为 root_command() 函数体——
|
||||
# 不要包含函数定义行(root_command() { ... }),也不要包含顶层代码。
|
||||
#
|
||||
# bashly 1.4 参数访问约定(全部经 args 关联数组):
|
||||
# 位置参数 source_dir/destination_dir → args[source_dir] / args[destination_dir]
|
||||
# 纯 flag --dry-run → args['--dry-run'](存在即 1)
|
||||
# 带参 flag --rerun <file> → args['--rerun']
|
||||
################################################################################
|
||||
|
||||
# 用法错误:输出错误与用法提示,退出码 2(GNU 惯例)。
|
||||
_usage_error() {
|
||||
_log 错误 "$1"
|
||||
_log 错误 "用法:media_organizer.sh [选项] <源目录> <目的目录>(详见 --help)"
|
||||
exit 2
|
||||
}
|
||||
|
||||
# bashly 解析结果 → 现有全局变量映射
|
||||
# (bashly 1.4 全部参数存 args 关联数组:flag 存在=1,命名位置参数用键名;
|
||||
# --no-* 反向开关优先于正向开关,与原 parse_args 的后者覆盖语义基本一致)
|
||||
SOURCE_DIR="${args['--src-dir']:-${args[source_dir]:-${SOURCE_DIR:-}}}"
|
||||
DESTINATION_DIR="${args['--dest-dir']:-${args[destination_dir]:-${DESTINATION_DIR:-}}}"
|
||||
if [[ -n "${args['--no-automated']:-}" ]]; then
|
||||
AUTOMATED=false
|
||||
elif [[ -n "${args['--automated']:-}" ]]; then
|
||||
AUTOMATED=true
|
||||
else
|
||||
AUTOMATED=false
|
||||
fi
|
||||
if [[ -n "${args['--no-dry-run']:-}" ]]; then
|
||||
DRY_RUN=false
|
||||
elif [[ -n "${args['--dry-run']:-}" ]]; then
|
||||
DRY_RUN=true
|
||||
else
|
||||
DRY_RUN=false
|
||||
fi
|
||||
REFRESH_CACHE=false
|
||||
[[ -n "${args['--refresh-cache']:-}" ]] && REFRESH_CACHE=true
|
||||
UPDATE_CACHE=false
|
||||
[[ -n "${args['--update-cache']:-}" ]] && UPDATE_CACHE=true
|
||||
LIST_CACHE=false
|
||||
[[ -n "${args['--list-cache']:-}" ]] && LIST_CACHE=true
|
||||
LIST_CACHE_FILTER=""
|
||||
EXPORT_MAP=false
|
||||
[[ -n "${args['--export-map']:-}" ]] && EXPORT_MAP=true
|
||||
RERUN_FILE="${args['--rerun']:-}"
|
||||
|
||||
# 形状校验(flag 互斥已由 bashly conflicts 处理)
|
||||
if [[ "$LIST_CACHE" == "true" ]]; then
|
||||
# --list-cache [关键词]:bashly 1.4 的 flag 不支持可选参数,
|
||||
# 关键词经位置参数 source_dir 传递(目的目录/--dest-dir 仍视为非法目录参数)
|
||||
if [[ -n "${args['--src-dir']:-}" || -n "${args['--dest-dir']:-}" || -n "${args[destination_dir]:-}" ]]; then
|
||||
_usage_error "--list-cache 不接受目录参数"
|
||||
fi
|
||||
LIST_CACHE_FILTER="${args[source_dir]:-}"
|
||||
fi
|
||||
if [[ "$UPDATE_CACHE" == "true" ]]; then
|
||||
if [[ -z "$SOURCE_DIR" ]]; then
|
||||
_usage_error "--update-cache 需要源目录(位置参数或 --src-dir)"
|
||||
fi
|
||||
if [[ -z "$DESTINATION_DIR" ]]; then
|
||||
# cache-only:仅刷缓存,不整理——无链接可预览、无跳过概念
|
||||
[[ "$DRY_RUN" == "true" ]] && _usage_error "--dry-run 不能与仅更新缓存同时使用(无链接可预览)"
|
||||
[[ "$AUTOMATED" == "true" ]] && _usage_error "--automated 不能与仅更新缓存同时使用(无整理步骤)"
|
||||
fi
|
||||
fi
|
||||
if [[ "$LIST_CACHE" != "true" ]] && [[ -z "$SOURCE_DIR" || -z "$DESTINATION_DIR" ]]; then
|
||||
_usage_error "需要源目录与目的目录(2 个位置参数,或 --src-dir + --dest-dir)"
|
||||
fi
|
||||
|
||||
# 目录归一化为绝对路径(纠错账本存绝对目标、目录比较等依赖一致语义;
|
||||
# list/rerun 形状目录为空,原样返回)
|
||||
SOURCE_DIR=$(normalize_abs "$SOURCE_DIR")
|
||||
DESTINATION_DIR=$(normalize_abs "$DESTINATION_DIR")
|
||||
|
||||
# list 形状:无需配置认证,直接列出后退出
|
||||
if [[ "$LIST_CACHE" == "true" ]]; then
|
||||
init_cache_dir
|
||||
cache_list "$LIST_CACHE_FILTER"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# rerun 形状:手动纠错重跑(--rerun <账本>)——不识别、只按账本执行链接。
|
||||
# 轻量初始化:色彩 + 依赖检查;不加载 TMDB 认证(账本内为绝对路径,无需搜索)。
|
||||
# DRY_RUN/AUTOMATED 补默认值(load_config 不执行,空串在 `if $VAR` 中为真会误判干运行)。
|
||||
# CACHE_DIR 只读解析(纠错学习写回需要;correction_learn 内部会按需创建目录)。
|
||||
if [[ -n "$RERUN_FILE" ]]; then
|
||||
DRY_RUN="${DRY_RUN:-false}"
|
||||
AUTOMATED="${AUTOMATED:-false}"
|
||||
init_colors
|
||||
check_dependencies
|
||||
if [[ -z "${CACHE_DIR:-}" ]]; then
|
||||
if [[ -d "$PWD/mo_cache" ]]; then
|
||||
CACHE_DIR="$PWD/mo_cache"
|
||||
elif [[ -d "$SCRIPT_DIR/mo_cache" ]]; then
|
||||
CACHE_DIR="$SCRIPT_DIR/mo_cache"
|
||||
fi
|
||||
fi
|
||||
run_rerun "$RERUN_FILE"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
load_config
|
||||
init_colors
|
||||
select_auth
|
||||
check_dependencies
|
||||
init_special_map "$SPECIAL_MAP_FILE"
|
||||
init_special_words "$SPECIAL_WORDS_FILE"
|
||||
init_skip_dirs "$SKIP_DIRS_FILE"
|
||||
init_season_offset
|
||||
init_special_categories "$SPECIAL_CATEGORIES_FILE"
|
||||
init_match_rules "$MATCH_RULES_FILE"
|
||||
|
||||
# cache-only 形状:仅更新缓存后退出(无整理步骤,不做硬链接检查)
|
||||
if [[ "$UPDATE_CACHE" == "true" && -z "$DESTINATION_DIR" ]]; then
|
||||
validate_directories
|
||||
init_cache_dir
|
||||
update_cache
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# organize 形状(可叠加 --update-cache 修饰):入口校验 + 硬链接检查(干运行跳过)
|
||||
validate_directories
|
||||
if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
check_hardlink_support
|
||||
fi
|
||||
init_cache_dir
|
||||
|
||||
# 注册信号处理
|
||||
trap '_handle_error $LINENO' ERR
|
||||
|
||||
# 启动信息
|
||||
_log 信息 "=== Jellyfin 媒体库整理脚本 v${SCRIPT_VERSION} ==="
|
||||
_log 信息 "源目录:$SOURCE_DIR"
|
||||
_log 信息 "目的目录:$DESTINATION_DIR"
|
||||
[[ "$DRY_RUN" == "true" ]] && _log 信息 "运行模式:[干运行] - 不创建链接、不写缓存与日志"
|
||||
[[ "$AUTOMATED" == "true" ]] && _log 信息 "运行模式:[自动化] - 将写入日志文件"
|
||||
[[ "$UPDATE_CACHE" == "true" ]] && _log 信息 "运行模式:[更新缓存] - 整理时强制重取对应缓存条目"
|
||||
|
||||
# 流水线:扫描 → 识别池 → AI 批处理 → 链接 → 对照表 → 汇总
|
||||
scan_files
|
||||
ledger_load
|
||||
process_video
|
||||
process_audio
|
||||
run_ai_batch
|
||||
link_media
|
||||
# 账本统一落盘(增量标记 + 失败冷却;惰性清理过期条目)
|
||||
ledger_flush
|
||||
export_map_md
|
||||
print_summary
|
||||
|
||||
# 退出码分级:0=全部成功;3=部分失败(非预期跳过/请求失败/链接失败 > 0)
|
||||
local fail_count=0 key outcome
|
||||
for key in "${!MEDIA_OUTCOME_MAP[@]}"; do
|
||||
outcome="${MEDIA_OUTCOME_MAP[$key]}"
|
||||
if [[ "$outcome" == "skip_unidentified" || "$outcome" == "request_failed" ]]; then
|
||||
fail_count=$((fail_count + 1))
|
||||
fi
|
||||
done
|
||||
if ((fail_count > 0 || LINK_FAIL_COUNT > 0)); then
|
||||
exit 3
|
||||
fi
|
||||
@@ -0,0 +1,61 @@
|
||||
# ============================================================================
|
||||
# 特典类别判定表外置测试(src/lib/config/maps.sh + ai_resolve.sh)
|
||||
# ============================================================================
|
||||
|
||||
setup() {
|
||||
TEST_DIR="$(mktemp -d)"
|
||||
AUTOMATED=true
|
||||
load_special_categories "$TEST_DIR/categories.json"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$TEST_DIR"
|
||||
}
|
||||
|
||||
# load_special_categories:数组加载(顺序 = 优先级)
|
||||
test_load_special_categories_basic() {
|
||||
cat >"$TEST_DIR/categories.json" <<'EOF'
|
||||
[
|
||||
{"match": "wow", "tag": "新类别"},
|
||||
{"match": "menu", "tag": "自定义Menu"}
|
||||
]
|
||||
EOF
|
||||
load_special_categories "$TEST_DIR/categories.json"
|
||||
assert_eq "2" "${#SPECIAL_CATEGORIES[@]}"
|
||||
# 用户表优先于内置默认表
|
||||
assert_eq "自定义Menu" "$(special_category_tag "Menu01")"
|
||||
assert_eq "新类别" "$(special_category_tag "wow01")"
|
||||
}
|
||||
|
||||
# special_category_tag:用户表未命中 → 回退内置默认表
|
||||
test_special_category_tag_fallback_builtin() {
|
||||
assert_eq "NCED" "$(special_category_tag "NCED02")"
|
||||
assert_eq "CM" "$(special_category_tag "TVSP")"
|
||||
assert_eq "Special" "$(special_category_tag "完全未知词")"
|
||||
}
|
||||
|
||||
# special_category_tag:空表(文件缺失/自动化)→ 内置默认表全量工作
|
||||
test_special_category_tag_empty_table() {
|
||||
assert_eq "Menu" "$(special_category_tag "Menu01")"
|
||||
assert_eq "PV" "$(special_category_tag "Announcement")"
|
||||
assert_eq "ED" "$(special_category_tag "Lyric")"
|
||||
assert_eq "Music Video" "$(special_category_tag "MV")"
|
||||
assert_eq "Picture Drama" "$(special_category_tag "Drama")"
|
||||
assert_eq "IV" "$(special_category_tag "IV01")"
|
||||
}
|
||||
|
||||
# load_special_categories:非法格式 → 空表不阻断
|
||||
test_load_special_categories_invalid() {
|
||||
echo '{"a": 1}' >"$TEST_DIR/categories.json"
|
||||
load_special_categories "$TEST_DIR/categories.json"
|
||||
assert_eq "0" "${#SPECIAL_CATEGORIES[@]}"
|
||||
}
|
||||
|
||||
# init_special_categories:自动化 + 文件缺失 → 不创建、空表(内置兜底可用)
|
||||
test_init_special_categories_automated_missing() {
|
||||
MATCH_FILE="$TEST_DIR/missing/special_categories.json"
|
||||
init_special_categories "$MATCH_FILE"
|
||||
assert_file_absent "$MATCH_FILE"
|
||||
assert_eq "0" "${#SPECIAL_CATEGORIES[@]}"
|
||||
assert_eq "Special" "$(special_category_tag "未知")"
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
# ============================================================================
|
||||
# 匹配规则测试(src/lib/rules.sh):match_rules.json 加载与查询
|
||||
# ============================================================================
|
||||
|
||||
setup() {
|
||||
TEST_DIR="$(mktemp -d)"
|
||||
AUTOMATED=true # 跳过交互创建
|
||||
load_match_rules "$TEST_DIR/match_rules.json"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$TEST_DIR"
|
||||
}
|
||||
|
||||
# load_match_rules:搜索别名(字符串与对象形式)+ ID 映射
|
||||
test_load_match_rules_basic() {
|
||||
cat >"$TEST_DIR/match_rules.json" <<'EOF'
|
||||
{
|
||||
"search_aliases": {
|
||||
"俺妹": "Ore no Imouto ga Konna ni Kawaii Wake ga Nai",
|
||||
"路人女主剧场版": {"term": "Saekano the Movie", "year": "2019"}
|
||||
},
|
||||
"id_maps": {
|
||||
"movie": {"刀剑神域": 20982},
|
||||
"tv": {"魔法禁书目录": 4654}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
load_match_rules "$TEST_DIR/match_rules.json"
|
||||
# 别名:字符串形式(无 year)与对象形式(带 year)
|
||||
assert_eq "Ore no Imouto ga Konna ni Kawaii Wake ga Nai " \
|
||||
"$(match_rule_lookup_alias "俺妹")"
|
||||
assert_eq "Saekano the Movie 2019" \
|
||||
"$(match_rule_lookup_alias "路人女主剧场版")"
|
||||
# ID 映射:movie/tv 分表,键归一化后命中
|
||||
assert_eq "20982" "${MATCH_RULES_ID_MOVIE[刀剑神域]:-}"
|
||||
assert_eq "4654" "${MATCH_RULES_ID_TV[魔法禁书目录]:-}"
|
||||
# 未命中
|
||||
assert_empty "$(match_rule_lookup_alias "不存在的标题")"
|
||||
assert_empty "${MATCH_RULES_ID_MOVIE[不存在的标题]:-}"
|
||||
assert_empty "${MATCH_RULES_ID_TV[不存在的标题]:-}"
|
||||
}
|
||||
|
||||
# load_match_rules:键归一化(大小写/空白)后命中
|
||||
test_load_match_rules_key_normalized() {
|
||||
cat >"$TEST_DIR/match_rules.json" <<'EOF'
|
||||
{
|
||||
"search_aliases": {
|
||||
"Sword Art Online": "ソードアート・オンライン"
|
||||
},
|
||||
"id_maps": {
|
||||
"movie": {"A B": 1}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
load_match_rules "$TEST_DIR/match_rules.json"
|
||||
assert_eq "ソードアート・オンライン " "$(match_rule_lookup_alias "sword art online")"
|
||||
assert_eq "1" "${MATCH_RULES_ID_MOVIE[a b]:-}"
|
||||
}
|
||||
|
||||
# load_match_rules:文件缺失 → 空规则(不报错)
|
||||
test_load_match_rules_missing_file() {
|
||||
assert_eq "0" "${#MATCH_RULES_ALIAS_TERM[@]}"
|
||||
assert_empty "$(match_rule_lookup_alias "任意标题")"
|
||||
}
|
||||
|
||||
# load_match_rules:非 JSON 对象 → 空规则 + 不阻断
|
||||
test_load_match_rules_invalid_json() {
|
||||
echo 'not json' >"$TEST_DIR/match_rules.json"
|
||||
load_match_rules "$TEST_DIR/match_rules.json"
|
||||
assert_eq "0" "${#MATCH_RULES_ALIAS_TERM[@]}"
|
||||
assert_eq "0" "${#MATCH_RULES_ID_MOVIE[@]}"
|
||||
}
|
||||
|
||||
# load_match_rules:空别名 term 的条目跳过(不产生空键)
|
||||
test_load_match_rules_empty_term_skipped() {
|
||||
cat >"$TEST_DIR/match_rules.json" <<'EOF'
|
||||
{
|
||||
"search_aliases": {
|
||||
"空词": ""
|
||||
},
|
||||
"id_maps": {
|
||||
"tv": {"空ID": ""}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
load_match_rules "$TEST_DIR/match_rules.json"
|
||||
assert_eq "0" "${#MATCH_RULES_ALIAS_TERM[@]}"
|
||||
assert_eq "0" "${#MATCH_RULES_ID_TV[@]}"
|
||||
}
|
||||
|
||||
# init_match_rules:自动化模式 + 文件缺失 → 不创建、空规则
|
||||
test_init_match_rules_automated_missing() {
|
||||
MATCH_RULES_FILE="$TEST_DIR/nonexistent/match_rules.json"
|
||||
init_match_rules "$MATCH_RULES_FILE"
|
||||
assert_file_absent "$MATCH_RULES_FILE"
|
||||
assert_eq "0" "${#MATCH_RULES_ALIAS_TERM[@]}"
|
||||
}
|
||||
|
||||
# init_match_rules:文件存在 → 正常加载
|
||||
test_init_match_rules_existing() {
|
||||
cat >"$TEST_DIR/rules.json" <<'EOF'
|
||||
{"search_aliases": {"别名": "Term"}, "id_maps": {"movie": {"影": 9}}}
|
||||
EOF
|
||||
init_match_rules "$TEST_DIR/rules.json"
|
||||
assert_eq "Term " "$(match_rule_lookup_alias "别名")"
|
||||
assert_eq "9" "${MATCH_RULES_ID_MOVIE[影]:-}"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# ============================================================================
|
||||
# 配置文件类目分区测试(src/lib/config/maps.sh)
|
||||
# ============================================================================
|
||||
|
||||
setup() {
|
||||
TEST_DIR="$(mktemp -d)"
|
||||
AUTOMATED=true
|
||||
# 干净状态
|
||||
SPECIAL_WORDS_CAT=()
|
||||
SKIP_DIRS_CAT=()
|
||||
SPECIAL_MAP_CAT=()
|
||||
RAW_SPECIAL_MAP_CAT=()
|
||||
SEASON_OFFSET_CAT=()
|
||||
SKIP_DIRS=()
|
||||
SKIP_DIRS_LOWER=()
|
||||
SPECIAL_WORDS=()
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$TEST_DIR"
|
||||
}
|
||||
|
||||
# is_category_partitioned:分区形态判定
|
||||
test_is_category_partitioned() {
|
||||
echo '{"tv": ["menu"], "musicvideo": ["mv"], "default": ["x"]}' >"$TEST_DIR/p.json"
|
||||
assert_success is_category_partitioned "$TEST_DIR/p.json"
|
||||
echo '{"menu": ["Menu"]}' >"$TEST_DIR/g.json"
|
||||
assert_failure is_category_partitioned "$TEST_DIR/g.json"
|
||||
echo '{"tv": ["menu"], "menu": ["x"]}' >"$TEST_DIR/mixed.json"
|
||||
assert_failure is_category_partitioned "$TEST_DIR/mixed.json"
|
||||
}
|
||||
|
||||
# init_special_words:分区形态 → 词按类目查询(musicvideo 不回退 tv 词表)
|
||||
test_partitioned_special_words() {
|
||||
cat >"$TEST_DIR/kw.json" <<'EOF'
|
||||
{"tv": ["menu", "cm"], "musicvideo": ["mv", "live"], "default": ["通用"]}
|
||||
EOF
|
||||
init_special_words "$TEST_DIR/kw.json"
|
||||
# tv 分区词
|
||||
assert_eq "menu" "$(special_word_hit tv "menu01")"
|
||||
# musicvideo 分区词
|
||||
assert_eq "mv" "$(special_word_hit musicvideo "mv")"
|
||||
# tv 分区词不落入 musicvideo(子串 "mv" 在 musicvideo 分区,tv 词不回退)
|
||||
assert_empty "$(special_word_hit musicvideo "cm")"
|
||||
# default 分区词两则命中
|
||||
assert_eq "通用" "$(special_word_hit tv "通用词")"
|
||||
assert_eq "通用" "$(special_word_hit musicvideo "通用词")"
|
||||
# 精确匹配:目录信号语义("tmp.xxx" 不命中任何词)
|
||||
assert_failure special_word_exact musicvideo "tmp.xxx"
|
||||
assert_success special_word_exact musicvideo "mv"
|
||||
assert_success special_word_exact musicvideo "live"
|
||||
}
|
||||
|
||||
# init_skip_dirs:分区形态 = 完整语义(不叠加内置默认)
|
||||
test_partitioned_skip_dirs() {
|
||||
cat >"$TEST_DIR/skips.json" <<'EOF'
|
||||
{"video": ["sps", "specials"], "music": ["scans", "fonts"], "default": ["特典"]}
|
||||
EOF
|
||||
init_skip_dirs "$TEST_DIR/skips.json"
|
||||
assert_success skip_dir_in video "sps"
|
||||
# music 分区未含 sps → 不回退内置默认(分区完整语义)
|
||||
assert_failure skip_dir_in music "sps"
|
||||
assert_success skip_dir_in music "scans"
|
||||
# default 分区回退
|
||||
assert_success skip_dir_in video "特典"
|
||||
assert_success skip_dir_in music "特典"
|
||||
# 分区外词不命中(内置默认不叠加)
|
||||
assert_failure skip_dir_in video "cds"
|
||||
}
|
||||
|
||||
# 全局形态(旧版数组文件)保持行为:文件内容优先,空数组回退内置默认
|
||||
test_legacy_skip_dirs_global() {
|
||||
echo '["myspecial"]' >"$TEST_DIR/skips_legacy.json"
|
||||
init_skip_dirs "$TEST_DIR/skips_legacy.json"
|
||||
assert_success skip_dir_in video "myspecial"
|
||||
# 非空数组文件 = 文件内容优先(不叠加内置默认)
|
||||
assert_failure skip_dir_in video "sps"
|
||||
}
|
||||
|
||||
# 全局形态空数组 → 内置默认兜底
|
||||
test_legacy_skip_dirs_empty_falls_back() {
|
||||
echo '[]' >"$TEST_DIR/skips_empty.json"
|
||||
init_skip_dirs "$TEST_DIR/skips_empty.json"
|
||||
assert_success skip_dir_in video "sps"
|
||||
}
|
||||
|
||||
# load_special_map:分区形态(tv 分区 + default 分区回退 + 引用展开)
|
||||
test_partitioned_special_map() {
|
||||
cat >"$TEST_DIR/maps.json" <<'EOF'
|
||||
{"tv": {"menu": ["Menu", "菜单"], "menu01": "menu"}, "default": {"cm": ["CM"]}}
|
||||
EOF
|
||||
load_special_map "$TEST_DIR/maps.json"
|
||||
assert_eq '["Menu","菜单"]' "$(special_map_value tv "menu")"
|
||||
# 分区内引用展开
|
||||
assert_eq '["Menu","菜单"]' "$(special_map_value tv "menu01")"
|
||||
# default 分区回退
|
||||
assert_eq '["CM"]' "$(special_map_value tv "cm")"
|
||||
# 无映射键 → 空
|
||||
assert_empty "$(special_map_value tv "不存在")"
|
||||
}
|
||||
|
||||
# init_season_offset:分区形态(tv 分区 + default 分区回退)
|
||||
test_partitioned_season_offset() {
|
||||
cat >"$TEST_DIR/offsets.json" <<'EOF'
|
||||
{"tv": {"demo show": 5}, "default": {"id:123": 3}}
|
||||
EOF
|
||||
SEASON_OFFSET_FILE="$TEST_DIR/offsets.json" init_season_offset "$TEST_DIR/offsets.json"
|
||||
assert_eq "5" "$(season_offset_get tv "demo show")"
|
||||
assert_eq "3" "$(season_offset_get tv "id:123")"
|
||||
# 内置默认仍可用(全局表兜底)
|
||||
assert_eq "23" "$(season_offset_get tv "jujutsu kaisen")"
|
||||
# 未命中 → 空
|
||||
assert_empty "$(season_offset_get tv "no such show")"
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# ============================================================================
|
||||
# 用户自定义规则测试(src/lib/rules.sh):命名模板渲染 + 匹配规则键
|
||||
# ============================================================================
|
||||
|
||||
# render_naming_template:普通占位符 + 补零
|
||||
test_render_plain_and_padding() {
|
||||
assert_eq "S01E02" "$(render_naming_template 'S{season:02}E{episode:02}' season=1 episode=2)"
|
||||
assert_eq "Season 12" "$(render_naming_template 'Season {season}' season=12)"
|
||||
}
|
||||
|
||||
# render_naming_template:条件段(year 非空渲染,空则不渲染)
|
||||
test_render_conditional_year() {
|
||||
assert_eq "Movie (2020)" "$(render_naming_template '{title}{?year: ({year})}' title=Movie year=2020)"
|
||||
assert_eq "Movie" "$(render_naming_template '{title}{?year: ({year})}' title=Movie year=)"
|
||||
}
|
||||
|
||||
# render_naming_template:条件段正文含嵌套占位符
|
||||
test_render_conditional_nested_placeholder() {
|
||||
assert_eq "Show [2020]" "$(render_naming_template '{title}{?year: [{year}]}' title=Show year=2020)"
|
||||
}
|
||||
|
||||
# render_naming_template:默认剧集模板全链路(含空集名条件段)
|
||||
test_render_episode_template_full() {
|
||||
assert_eq "S01E02 - Name" \
|
||||
"$(render_naming_template 'S{season:02}E{episode:02}{range}{?episode_name: - {episode_name}}' season=01 episode=02 range= 'episode_name=Name')"
|
||||
assert_eq "S01E02" \
|
||||
"$(render_naming_template 'S{season:02}E{episode:02}{range}{?episode_name: - {episode_name}}' season=01 episode=02 range= 'episode_name=')"
|
||||
assert_eq "S01E02-E03 - A - B" \
|
||||
"$(render_naming_template 'S{season:02}E{episode:02}{range}{?episode_name: - {episode_name}}' season=01 episode=02 range=-E03 'episode_name=A - B')"
|
||||
}
|
||||
|
||||
# render_naming_template:值含 & 不被替换语义破坏(${var//pat/repl} 会把 & 当匹配整体)
|
||||
test_render_ampersand_safe() {
|
||||
assert_eq "A & B" "$(render_naming_template '{episode_name}' 'episode_name=A & B')"
|
||||
assert_eq "Artist & Band - Album" \
|
||||
"$(render_naming_template '{artist} - {album}' 'artist=Artist & Band' album=Album)"
|
||||
}
|
||||
|
||||
# render_naming_template:值内花括号被剔除(防破坏模板解析)
|
||||
test_render_braces_in_value_stripped() {
|
||||
assert_eq "AB" "$(render_naming_template '{title}' 'title=A{B')"
|
||||
}
|
||||
|
||||
# render_naming_template:未知占位符渲染为空
|
||||
test_render_unknown_placeholder_empty() {
|
||||
assert_eq "X " "$(render_naming_template '{title} {foo}' title=X)"
|
||||
}
|
||||
|
||||
# render_naming_template:补零对非数字值原样保留
|
||||
test_render_padding_non_numeric() {
|
||||
assert_eq "abc" "$(render_naming_template '{n:03}' n=abc)"
|
||||
}
|
||||
|
||||
# render_naming_template:无配对右花括号按字面保留
|
||||
test_render_unclosed_brace_literal() {
|
||||
assert_eq "X {tail" "$(render_naming_template 'X {tail' title=X)"
|
||||
}
|
||||
|
||||
# render_naming:配置键未设置时用内置默认(测试环境不经过 load_config)
|
||||
test_render_naming_default_fallback() {
|
||||
assert_eq "Season 03" "$(render_naming NAMING_SEASON season=3)"
|
||||
assert_eq "Movie (2020)" "$(render_naming NAMING_MOVIE title=Movie year=2020)"
|
||||
assert_eq "S00Menu - Menu01" "$(render_naming NAMING_SPECIAL tag=Menu fragment=Menu01)"
|
||||
assert_eq "Artist/Album" "$(render_naming NAMING_MUSIC artist=Artist album=Album)"
|
||||
}
|
||||
|
||||
# template_token_names:提取占位符名(含条件段正文内的;重复名由调用方去重)
|
||||
test_template_token_names() {
|
||||
assert_eq "$(printf 'title\nyear' | sort)" "$(template_token_names '{title}{?year: ({year})}' | sort -u)"
|
||||
assert_eq "$(printf 'season\nepisode\nrange\nepisode_name' | sort)" \
|
||||
"$(template_token_names 'S{season:02}E{episode:02}{range}{?episode_name: - {episode_name}}' | sort -u)"
|
||||
}
|
||||
|
||||
# rule_key:小写 + 空白折叠 + 去首尾空白
|
||||
test_rule_key_normalization() {
|
||||
assert_eq "sword art online" "$(rule_key ' Sword Art ONLINE ')"
|
||||
assert_eq "sword art online" "$(rule_key 'Sword Art Online')"
|
||||
assert_eq "俺妹" "$(rule_key '俺妹')"
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# ============================================================================
|
||||
# AI 批处理模块测试(src/lib/ai.sh)
|
||||
# ============================================================================
|
||||
|
||||
setup() {
|
||||
AI_BATCH_SIZE=50
|
||||
AI_BATCH_KEYS_SEARCH=()
|
||||
AI_BATCH_KEYS_SPECIAL=()
|
||||
AI_BATCH_KEYS_ARTIST=()
|
||||
AI_BATCH_KEYS_MATCH=()
|
||||
PENDING_AI_SEARCH=()
|
||||
PENDING_AI_SPECIAL=()
|
||||
PENDING_AI_ARTIST=()
|
||||
PENDING_AI_MATCH=()
|
||||
}
|
||||
|
||||
# ai_extract_json:直接 JSON
|
||||
test_ai_extract_direct_json() {
|
||||
assert_eq '{"a": 1}' "$(ai_extract_json '{"a": 1}')"
|
||||
}
|
||||
|
||||
# ai_extract_json:```json 围栏
|
||||
test_ai_extract_fenced_json() {
|
||||
local input=$'```json\n{"a": 1}\n```'
|
||||
assert_eq '{"a": 1}' "$(ai_extract_json "$input")"
|
||||
}
|
||||
|
||||
# ai_extract_json:异行 <thinking> 标签
|
||||
test_ai_extract_thinking_block() {
|
||||
local input=$'<thinking>分析中</thinking>\n{"a": 1}'
|
||||
assert_eq '{"a": 1}' "$(ai_extract_json "$input")"
|
||||
}
|
||||
|
||||
# ai_extract_json:同行 <thinking> 标签(awk 行级处理无法吞掉,需 sed 预处理)
|
||||
test_ai_extract_thinking_inline() {
|
||||
assert_eq '{"a": 1}' "$(ai_extract_json '<thinking>分析中</thinking>{"a": 1}')"
|
||||
}
|
||||
|
||||
# ai_extract_json:「思考:」前缀
|
||||
test_ai_extract_thinking_prefix() {
|
||||
assert_eq '{"a": 1}' "$(ai_extract_json '思考:这是答案。{"a": 1}')"
|
||||
}
|
||||
|
||||
# ai_extract_json:非 JSON 返回空
|
||||
test_ai_extract_invalid_returns_empty() {
|
||||
assert_eq "" "$(ai_extract_json 'not json at all')"
|
||||
assert_eq "" "$(ai_extract_json '')"
|
||||
}
|
||||
|
||||
# build_ai_input_json:三类条目构造正确的 JSON 结构
|
||||
test_ai_input_json_structure() {
|
||||
PENDING_AI_SEARCH["/m/电影 (2020).mkv"]="movie dir"
|
||||
PENDING_AI_SPECIAL["123|Menu01"]="Show|01:Menu; 02:CM"
|
||||
PENDING_AI_ARTIST["/m/song.flac"]="A; B|Album|" # 值格式:artists|album|rel_dir
|
||||
local json rc=0
|
||||
build_ai_input_json json
|
||||
echo "$json" | jq -e '
|
||||
(.search_entries | length) == 1 and
|
||||
(.special_entries | length) == 1 and
|
||||
(.artist_entries | length) == 1 and
|
||||
(.match_entries | length) == 0
|
||||
' >/dev/null || rc=$?
|
||||
assert_eq '0' "$rc" "JSON 结构校验"
|
||||
assert_eq "/m/电影 (2020).mkv" "$(echo "$json" | jq -r '.search_entries[0].file')"
|
||||
assert_eq "123" "$(echo "$json" | jq -r '.special_entries[0].show_id')"
|
||||
assert_eq "A; B" "$(echo "$json" | jq -r '.artist_entries[0].artists')"
|
||||
# 副作用:keys 数组在调用方可见(修复子 shell 丢失 bug 后)
|
||||
assert_eq '1' "${#AI_BATCH_KEYS_SEARCH[@]}"
|
||||
assert_eq '1' "${#AI_BATCH_KEYS_SPECIAL[@]}"
|
||||
assert_eq '1' "${#AI_BATCH_KEYS_ARTIST[@]}"
|
||||
}
|
||||
|
||||
# build_ai_input_json:批大小上限
|
||||
test_ai_input_json_batch_limit() {
|
||||
AI_BATCH_SIZE=2
|
||||
PENDING_AI_SEARCH["/a1.mkv"]="movie "
|
||||
PENDING_AI_SEARCH["/a2.mkv"]="movie "
|
||||
PENDING_AI_SEARCH["/a3.mkv"]="movie "
|
||||
local json
|
||||
build_ai_input_json json
|
||||
assert_eq '2' "$(echo "$json" | jq '.search_entries | length')"
|
||||
assert_eq '2' "${#AI_BATCH_KEYS_SEARCH[@]}"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# ============================================================================
|
||||
# 特典命名模块测试(src/lib/ai_resolve.sh)
|
||||
# ============================================================================
|
||||
|
||||
setup() {
|
||||
FOLDER_MOVIES="/media/Movies"
|
||||
FOLDER_SHOWS="/media/Shows"
|
||||
FOLDER_UNKNOWN="/media/Unknown"
|
||||
}
|
||||
|
||||
# special_category_tag:特典类别识别
|
||||
test_special_category_tag_mapping() {
|
||||
assert_eq "Menu" "$(special_category_tag "Menu01")"
|
||||
assert_eq "NCED" "$(special_category_tag "NCED02")"
|
||||
assert_eq "NCOP" "$(special_category_tag "NCOP")"
|
||||
assert_eq "CM" "$(special_category_tag "CM01")"
|
||||
assert_eq "CM" "$(special_category_tag "TVSP")"
|
||||
assert_eq "PV" "$(special_category_tag "Announcement")"
|
||||
assert_eq "ED" "$(special_category_tag "Lyric")"
|
||||
assert_eq "Picture Drama" "$(special_category_tag "Drama")"
|
||||
assert_eq "Music Video" "$(special_category_tag "MV")"
|
||||
assert_eq "Special" "$(special_category_tag "OVA")"
|
||||
assert_eq "Special" "$(special_category_tag "未知词")"
|
||||
}
|
||||
|
||||
# special_s00_dest:S00 目标路径(show_dir 含年份段,由 NAMING_SHOW 渲染)
|
||||
test_special_s00_dest_path() {
|
||||
# 参数:show_dir + fragment + ext
|
||||
assert_eq "/media/Shows/Show (2020)/Season 00|S00Menu - Menu01.mkv" \
|
||||
"$(special_s00_dest "Show (2020)" "Menu01" "mkv")"
|
||||
assert_eq "/media/Shows/Show/Season 00|S00Special - SP.mkv" \
|
||||
"$(special_s00_dest "Show" "SP" "mkv")"
|
||||
}
|
||||
|
||||
# fallback_naming:电影回退命名
|
||||
test_fallback_naming_movie() {
|
||||
assert_eq "/media/Movies/Movie (2020)|Movie (2020).mkv" \
|
||||
"$(fallback_naming "movie" "Movie" "2020" "" "" "" "" "mkv")"
|
||||
}
|
||||
|
||||
# fallback_naming:剧集回退命名
|
||||
test_fallback_naming_tv() {
|
||||
assert_eq "/media/Shows/Show/Season 01|S01E02.mkv" \
|
||||
"$(fallback_naming "tv" "Show" "" "1" "2" "" "" "mkv")"
|
||||
assert_eq "/media/Shows/Show (2021)/Season 02|S02E03-E04.mkv" \
|
||||
"$(fallback_naming "tv" "Show" "2021" "2" "3" "4" "" "mkv")"
|
||||
}
|
||||
|
||||
# fallback_naming:特典回退命名(走 special_s00_dest)
|
||||
test_fallback_naming_special() {
|
||||
assert_eq "/media/Shows/Show/Season 00|S00CM - CM01.mkv" \
|
||||
"$(fallback_naming "tv" "Show" "" "0" "1" "" "CM01" "mkv")"
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
# ============================================================================
|
||||
# 媒体文件名解析模块测试(src/lib/filename.sh)
|
||||
# ============================================================================
|
||||
|
||||
setup() {
|
||||
TEST_DIR="$(mktemp -d)"
|
||||
SOURCE_DIR="$TEST_DIR"
|
||||
SKIP_DIRS_LOWER=(sp sps special specials cd cds bonus bonuses extra extras previews "ps3 special bd" scans fonts 特典 特番 花絮 视频 剧集 动画 合集 电影 音乐 movies series shows anime collections)
|
||||
SPECIAL_WORDS=("menu" "ncop" "nced" "mini anime" "pv" "cm" "sp" "teaser" "program" "promo" "trailer" "special" "music video" "mv" "opening" "ending" "特典" "特番" "花絮")
|
||||
VIDEO_EXTS=(mp4 mkv avi mov wmv flv m4v ts m2ts webm)
|
||||
MAIN_COUNT_CACHE=()
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$TEST_DIR"
|
||||
}
|
||||
|
||||
# parse_media_filename:标准 S##E## 格式
|
||||
test_parse_tv_sxxexx() {
|
||||
assert_eq "tv|Show Name||01|02||" "$(parse_media_filename "$TEST_DIR/Show Name S01E02.mkv")"
|
||||
}
|
||||
|
||||
# parse_media_filename:S##E## + 年份
|
||||
test_parse_tv_with_year() {
|
||||
assert_eq "tv|Show|2020|01|01||" "$(parse_media_filename "$TEST_DIR/Show (2020) S01E01.mkv")"
|
||||
}
|
||||
|
||||
# parse_media_filename:多集区间 S01E01-E02
|
||||
test_parse_tv_episode_range() {
|
||||
assert_eq "tv|Show||01|01|02|" "$(parse_media_filename "$TEST_DIR/Show S01E01-E02.mkv")"
|
||||
}
|
||||
|
||||
# parse_media_filename:#x## 格式
|
||||
test_parse_tv_hash_format() {
|
||||
assert_eq "tv|Show||1|05||" "$(parse_media_filename "$TEST_DIR/Show 1x05.mkv")"
|
||||
}
|
||||
|
||||
# parse_media_filename:Season 2 格式(无集号 → 0)
|
||||
test_parse_tv_season_format() {
|
||||
assert_eq "tv|Show||2|0|" "$(parse_media_filename "$TEST_DIR/Show Season 2.mkv")"
|
||||
}
|
||||
|
||||
# parse_media_filename:2nd Season + [NN]
|
||||
test_parse_tv_ordinal_season() {
|
||||
assert_eq "tv|Show||2|01|" "$(parse_media_filename "$TEST_DIR/Show 2nd Season [01].mkv")"
|
||||
}
|
||||
|
||||
# parse_media_filename:方括号集号 [NN]
|
||||
test_parse_tv_bracket_episode() {
|
||||
assert_eq "tv|Show||1|07||" "$(parse_media_filename "$TEST_DIR/Show [07].mkv")"
|
||||
}
|
||||
|
||||
# parse_media_filename:小数集号 → 特典路径(season=0)
|
||||
test_parse_tv_decimal_episode_as_special() {
|
||||
assert_eq "tv|Show||0|||14.5" "$(parse_media_filename "$TEST_DIR/Show [14.5].mkv")"
|
||||
}
|
||||
|
||||
# parse_media_filename:第 0 话 → 特典路径
|
||||
test_parse_tv_zero_episode_as_special() {
|
||||
assert_eq "tv|Show||0|||0" "$(parse_media_filename "$TEST_DIR/Show [0].mkv")"
|
||||
}
|
||||
|
||||
# parse_media_filename:电影(年份)
|
||||
test_parse_movie_with_year() {
|
||||
assert_eq "movie|Movie|2020||||" "$(parse_media_filename "$TEST_DIR/Movie (2020).mkv")"
|
||||
}
|
||||
|
||||
# parse_media_filename:压制组风格电影
|
||||
test_parse_movie_group_style() {
|
||||
assert_eq "movie|Movie|2021||||" "$(parse_media_filename "$TEST_DIR/[Group] Movie (2021) [1080p].mkv")"
|
||||
}
|
||||
|
||||
# detect_special:父目录为特典目录(SPs)
|
||||
test_detect_special_parent_dir() {
|
||||
mkdir -p "$TEST_DIR/Show/SPs"
|
||||
touch "$TEST_DIR/Show/SPs/CM01.mkv"
|
||||
assert_eq "tv|Show||0|01||CM01" "$(parse_media_filename "$TEST_DIR/Show/SPs/CM01.mkv")"
|
||||
}
|
||||
|
||||
# detect_special:文件名含特典标记 [CM01]
|
||||
test_detect_special_bracket_mark() {
|
||||
# Show 目录需 ≥2 个正片(count==1 会被判为电影特典跳过)
|
||||
mkdir -p "$TEST_DIR/Show"
|
||||
touch "$TEST_DIR/Show/[CM01].mkv" "$TEST_DIR/Show/EP01.mkv"
|
||||
assert_eq "tv|Show||0|01||CM01" "$(parse_media_filename "$TEST_DIR/Show/[CM01].mkv")"
|
||||
}
|
||||
|
||||
# find_show_dir_from_path:跳过特典目录找到剧名目录
|
||||
test_find_show_dir_skips_special_dirs() {
|
||||
assert_eq "Show" "$(find_show_dir_from_path "$TEST_DIR/Show/SPs/CM01.mkv")"
|
||||
}
|
||||
|
||||
# find_show_dir_from_path:跳过 [数字 CD 子目录
|
||||
test_find_show_dir_skips_cd_dir() {
|
||||
assert_eq "Show" "$(find_show_dir_from_path "$TEST_DIR/Show/[200226] Song/01.flac")"
|
||||
}
|
||||
|
||||
# count_main_videos:统计正片、跳过特典子目录
|
||||
test_count_main_videos_counts_and_skips() {
|
||||
mkdir -p "$TEST_DIR/Show" "$TEST_DIR/Show/SPs"
|
||||
touch "$TEST_DIR/Show/EP01.mkv" "$TEST_DIR/Show/EP02.mkv" "$TEST_DIR/Show/SPs/CM01.mkv"
|
||||
assert_eq "2" "$(count_main_videos "$TEST_DIR/Show")"
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
# ============================================================================
|
||||
# 识别搜索链测试(src/lib/media/identify.sh)
|
||||
#
|
||||
# 覆盖 R1 搜索链抽象后的行为保持:统一尝试器(tv_search_once/movie_search_once)
|
||||
# 的命中/无结果/请求失败返回码、两行协议回传、识别函数的层级短路语义
|
||||
# (主搜索失败即 return 2,后续层失败忽略)、目录/MAL/别名/后缀剥离兜底。
|
||||
#
|
||||
# mock 策略:按 "endpoint|语言" 查 MOCK_RESPONSES 表;无表项 = 空哨兵(无结果);
|
||||
# MOCK_FAIL_ENDPOINTS 匹配 → 请求失败(rc=2)。tmdb_api_lang 经动态作用域
|
||||
# 透传 TMDB_LANG,mock 用它区分 zh/en 响应。
|
||||
# ============================================================================
|
||||
|
||||
# mock 响应表(文件级全局:declare 在函数内是函数局部,mock 函数访问不到)
|
||||
declare -A MOCK_RESPONSES=()
|
||||
declare -A MOCK_FAIL_ENDPOINTS=()
|
||||
|
||||
setup() {
|
||||
TEST_DIR="$(mktemp -d)"
|
||||
DRY_RUN=false
|
||||
TMDB_DELAY=0
|
||||
TMDB_LANG="zh-CN"
|
||||
SOURCE_DIR="$TEST_DIR/src"
|
||||
FOLDER_SHOWS="节目"
|
||||
FOLDER_MOVIES="电影"
|
||||
mkdir -p "$SOURCE_DIR"
|
||||
MOCK_RESPONSES=()
|
||||
MOCK_FAIL_ENDPOINTS=()
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$TEST_DIR"
|
||||
}
|
||||
|
||||
# mock:按 "endpoint|lang" 查表;无表项 → 无结果(rc=1);失败表命中 → rc=2
|
||||
tmdb_api() {
|
||||
local endpoint="$1"
|
||||
shift
|
||||
local key="${endpoint}|${TMDB_LANG:-}"
|
||||
if [[ -n "${MOCK_FAIL_ENDPOINTS[$endpoint]:-}" ]]; then
|
||||
return 2
|
||||
fi
|
||||
local resp="${MOCK_RESPONSES[$key]:-}"
|
||||
[[ -z "$resp" ]] && return 1
|
||||
echo "$resp"
|
||||
}
|
||||
|
||||
# mock:用户规则别名查找(真实函数读规则文件,测试桩直接返回)
|
||||
match_rule_lookup_alias() {
|
||||
echo ""
|
||||
}
|
||||
|
||||
# --- 统一尝试器 ---
|
||||
|
||||
# tv_search_once:zh 命中 → 输出 id,返回 0
|
||||
test_tv_search_once_hit() {
|
||||
MOCK_RESPONSES["/search/tv|zh-CN"]='{"results":[{"id":100,"name":"Show A"}]}'
|
||||
local id="" rc=0
|
||||
id=$(tv_search_once "Show A" "1" "" "") || rc=$?
|
||||
assert_eq "0" "$rc" "tv_search_once 命中返回码"
|
||||
assert_eq "100" "$id" "tv_search_once 命中 id"
|
||||
}
|
||||
|
||||
# tv_search_once:无结果 → 返回 1,无输出
|
||||
test_tv_search_once_miss() {
|
||||
local id="" rc=0
|
||||
id=$(tv_search_once "No Such Show" "1" "" "") || rc=$?
|
||||
assert_eq "1" "$rc" "tv_search_once 无结果返回码"
|
||||
assert_empty "$id" "tv_search_once 无结果输出"
|
||||
}
|
||||
|
||||
# tv_search_once:请求失败 → 返回 2(不重试语义由调用方处理)
|
||||
test_tv_search_once_request_failed() {
|
||||
MOCK_FAIL_ENDPOINTS["/search/tv"]=1
|
||||
local rc=0
|
||||
tv_search_once "Show A" "1" "" "" >/dev/null || rc=$?
|
||||
assert_eq "2" "$rc" "tv_search_once 请求失败返回码"
|
||||
}
|
||||
|
||||
# tv_search_once:zh 空 → en-US 命中(语言分支走 tmdb_api_lang)
|
||||
test_tv_search_once_en_fallback() {
|
||||
MOCK_RESPONSES["/search/tv|en-US"]='{"results":[{"id":200,"name":"Show A"}]}'
|
||||
local id="" rc=0
|
||||
id=$(tv_search_once "Show A" "1" "" "en-US") || rc=$?
|
||||
assert_eq "0" "$rc" "en 命中返回码"
|
||||
assert_eq "200" "$id" "en 命中 id"
|
||||
}
|
||||
|
||||
# movie_search_once:两行协议(id + 单行响应 JSON)
|
||||
test_movie_search_once_two_line_protocol() {
|
||||
MOCK_RESPONSES["/search/movie|zh-CN"]='{"results":[{"id":50,"title":"Movie X","release_date":"2020-01-01"}]}'
|
||||
local out="" rc=0
|
||||
out=$(movie_search_once "Movie X" "2020" "") || rc=$?
|
||||
assert_eq "0" "$rc" "movie_search_once 命中返回码"
|
||||
local id resp
|
||||
id=$(printf '%s\n' "$out" | sed -n '1p')
|
||||
resp=$(printf '%s\n' "$out" | sed -n '2p')
|
||||
assert_eq "50" "$id" "两行协议 id 行"
|
||||
assert_contains "$resp" '"title":"Movie X"' "两行协议响应行"
|
||||
}
|
||||
|
||||
# --- 目录兜底 ---
|
||||
|
||||
# tv_search_by_dir:源根直属散放不兜底(无输出)
|
||||
test_tv_search_by_dir_source_root_skipped() {
|
||||
local id
|
||||
id=$(tv_search_by_dir "$SOURCE_DIR/Show.mkv" "Show" "1" "")
|
||||
assert_empty "$id" "源根散放不兜底"
|
||||
}
|
||||
|
||||
# tv_search_by_dir:目录名命中(zh → en)
|
||||
test_tv_search_by_dir_hit() {
|
||||
mkdir -p "$TEST_DIR/src/[Group] Show X"
|
||||
local f="$TEST_DIR/src/[Group] Show X/01.mkv"
|
||||
MOCK_RESPONSES["/search/tv|zh-CN"]='{"results":[{"id":300,"name":"Show X"}]}'
|
||||
local id
|
||||
id=$(tv_search_by_dir "$f" "01" "1" "")
|
||||
assert_eq "300" "$id" "目录名兜底命中"
|
||||
}
|
||||
|
||||
# --- 完整识别链 ---
|
||||
|
||||
# identify_tv_show:zh 命中 → build_tv_dest 输出目标
|
||||
test_identify_tv_show_zh_hit() {
|
||||
MOCK_RESPONSES["/search/tv|zh-CN"]='{"results":[{"id":100,"name":"Show A"}]}'
|
||||
MOCK_RESPONSES["/tv/100|zh-CN"]='{"name":"Show A","first_air_date":"2020-01-01","number_of_seasons":2,"seasons":[{"season_number":1},{"season_number":2}]}'
|
||||
MOCK_RESPONSES["/tv/100/season/1|zh-CN"]='{"episodes":[{"episode_number":1,"name":"Pilot"},{"episode_number":2,"name":"Two"}]}'
|
||||
local dest="" rc=0
|
||||
dest=$(identify_tv_show "Show A" "1" "1" "" "Show A S01E01" "mkv" "" "$TEST_DIR/src/Show A/Show A S01E01.mkv") || rc=$?
|
||||
assert_eq "0" "$rc" "zh 命中识别返回码"
|
||||
assert_contains "$dest" "节目/Show A (2020)/Season 01|S01E01 - Pilot.mkv" "zh 命中目标"
|
||||
}
|
||||
|
||||
# identify_tv_show:zh 空 → en 命中
|
||||
test_identify_tv_show_zh_miss_en_hit() {
|
||||
MOCK_RESPONSES["/search/tv|en-US"]='{"results":[{"id":200,"name":"Show B"}]}'
|
||||
MOCK_RESPONSES["/tv/200|zh-CN"]='{"name":"Show B","first_air_date":"2021-01-01","number_of_seasons":1,"seasons":[{"season_number":1}]}'
|
||||
MOCK_RESPONSES["/tv/200/season/1|zh-CN"]='{"episodes":[{"episode_number":1,"name":"First"}]}'
|
||||
local dest="" rc=0
|
||||
dest=$(identify_tv_show "Show B" "1" "1" "" "Show B S01E01" "mkv" "" "$TEST_DIR/src/Show B/Show B S01E01.mkv") || rc=$?
|
||||
assert_eq "0" "$rc" "en 命中识别返回码"
|
||||
assert_contains "$dest" "Show B (2021)" "en 命中目标"
|
||||
}
|
||||
|
||||
# identify_tv_show:主搜索(zh)请求失败 → 短路 return 2(不试后续层)
|
||||
test_identify_tv_show_zh_fail_shortcircuit() {
|
||||
MOCK_FAIL_ENDPOINTS["/search/tv"]=1
|
||||
MOCK_RESPONSES["/search/tv|en-US"]='{"results":[{"id":200,"name":"Show B"}]}'
|
||||
local rc=0
|
||||
identify_tv_show "Show B" "1" "1" "" "Show B S01E01" "mkv" "" "$TEST_DIR/src/Show B/Show B S01E01.mkv" >/dev/null 2>&1 || rc=$?
|
||||
assert_eq "2" "$rc" "主搜索失败短路返回码"
|
||||
}
|
||||
|
||||
# identify_tv_show:zh/en 空 → 后缀二次剥离重搜命中("Railgun S" → "Railgun")
|
||||
test_identify_tv_show_suffix_strip() {
|
||||
MOCK_RESPONSES["/search/tv|zh-CN"]='{"results":[{"id":400,"name":"Railgun"}]}'
|
||||
MOCK_RESPONSES["/tv/400|zh-CN"]='{"name":"Railgun","first_air_date":"2020-01-01","number_of_seasons":3,"seasons":[{"season_number":1},{"season_number":2},{"season_number":3}]}'
|
||||
MOCK_RESPONSES["/tv/400/season/1|zh-CN"]='{"episodes":[{"episode_number":1,"name":"First"}]}'
|
||||
# 注意:首次搜索 "Railgun S" 无结果(mock 表无 "Railgun S" 条目),二次剥离后 "Railgun" 命中
|
||||
local dest="" rc=0
|
||||
dest=$(identify_tv_show "Railgun S" "1" "1" "" "Railgun S S01E01" "mkv" "" "$TEST_DIR/src/Railgun S/Railgun S S01E01.mkv") || rc=$?
|
||||
assert_eq "0" "$rc" "后缀剥离识别返回码"
|
||||
assert_contains "$dest" "Railgun (2020)" "后缀剥离命中目标"
|
||||
}
|
||||
|
||||
# identify_tv_show:全链失败 → return 1(无结果)
|
||||
test_identify_tv_show_all_miss() {
|
||||
local rc=0
|
||||
identify_tv_show "No Such Show" "1" "1" "" "No Such Show S01E01" "mkv" "" "$TEST_DIR/src/No Such Show/No Such Show S01E01.mkv" >/dev/null 2>&1 || rc=$?
|
||||
assert_eq "1" "$rc" "全链无结果返回码"
|
||||
}
|
||||
|
||||
# identify_movie:zh 命中 → build_movie_dest 输出目标
|
||||
test_identify_movie_zh_hit() {
|
||||
MOCK_RESPONSES["/search/movie|zh-CN"]='{"results":[{"id":50,"title":"Movie X","release_date":"2020-01-01"}]}'
|
||||
MOCK_RESPONSES["/movie/50|zh-CN"]='{"title":"Movie X","release_date":"2020-01-01"}'
|
||||
local dest="" rc=0
|
||||
dest=$(identify_movie "Movie X" "mkv" "$TEST_DIR/src/Movie X (2020).mkv" "Movie X" "2020") || rc=$?
|
||||
assert_eq "0" "$rc" "movie zh 命中返回码"
|
||||
assert_contains "$dest" "电影/Movie X (2020)|Movie X (2020).mkv" "movie zh 命中目标"
|
||||
}
|
||||
|
||||
# identify_movie:年份不符且无一致候选 → 需甄别(emit MATCH,返回 3)
|
||||
test_identify_movie_year_mismatch_match() {
|
||||
MOCK_RESPONSES["/search/movie|zh-CN"]='{"results":[{"id":50,"title":"Movie X","release_date":"2019-05-01"}]}'
|
||||
local rc=0
|
||||
identify_movie "Movie X" "mkv" "$TEST_DIR/src/Movie X (2020).mkv" "Movie X" "2020" >/dev/null 2>&1 || rc=$?
|
||||
assert_eq "3" "$rc" "年份不符需甄别返回码"
|
||||
assert_success test -n "${PENDING_AI_MATCH["$TEST_DIR/src/Movie X (2020).mkv"]:-}"
|
||||
}
|
||||
|
||||
# identify_movie:主搜索失败 → 短路 return 2
|
||||
test_identify_movie_zh_fail_shortcircuit() {
|
||||
MOCK_FAIL_ENDPOINTS["/search/movie"]=1
|
||||
local rc=0
|
||||
identify_movie "Movie X" "mkv" "$TEST_DIR/src/Movie X (2020).mkv" "Movie X" "2020" >/dev/null 2>&1 || rc=$?
|
||||
assert_eq "2" "$rc" "movie 主搜索失败短路返回码"
|
||||
}
|
||||
|
||||
# identify_movie:zh 空 → en 命中(两行协议回传年份校验通过)
|
||||
test_identify_movie_en_fallback() {
|
||||
MOCK_RESPONSES["/search/movie|en-US"]='{"results":[{"id":60,"title":"Movie Y","release_date":"2021-01-01"}]}'
|
||||
MOCK_RESPONSES["/movie/60|zh-CN"]='{"title":"Movie Y","release_date":"2021-01-01"}'
|
||||
local dest="" rc=0
|
||||
dest=$(identify_movie "Movie Y" "mkv" "$TEST_DIR/src/Movie Y (2021).mkv" "Movie Y" "2021") || rc=$?
|
||||
assert_eq "0" "$rc" "movie en 命中返回码"
|
||||
assert_contains "$dest" "Movie Y (2021)" "movie en 命中目标"
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# ============================================================================
|
||||
# Music Videos 类目测试(src/lib/media/filename.sh / identify.sh)
|
||||
# ============================================================================
|
||||
|
||||
setup() {
|
||||
TEST_DIR="$(mktemp -d)"
|
||||
SOURCE_DIR="$TEST_DIR"
|
||||
FOLDER_MUSICVIDEOS="音乐视频"
|
||||
FOLDER_UNKNOWN="未知"
|
||||
SPECIAL_WORDS_CAT=()
|
||||
mapfile -t MUSICVIDEO_WORDS < <(echo "$MUSICVIDEO_WORDS_TEMPLATE" | jq -r '.[]')
|
||||
mapfile -t SPECIAL_WORDS < <(echo "$SPECIAL_WORDS_TEMPLATE" | jq -r '.[]')
|
||||
MAIN_COUNT_CACHE=()
|
||||
VIDEO_EXTS=(mp4 mkv)
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$TEST_DIR"
|
||||
}
|
||||
|
||||
# detect_musicvideo:目录信号(Music Videos / MV / Live 目录 → musicvideo)
|
||||
test_musicvideo_dir_signal() {
|
||||
mkdir -p "$TEST_DIR/Music Videos/周杰伦"
|
||||
assert_eq "musicvideo|晴天|||||" \
|
||||
"$(parse_media_filename "$TEST_DIR/Music Videos/周杰伦/晴天 [MV].mkv")"
|
||||
assert_eq "musicvideo|七里香|||||" \
|
||||
"$(parse_media_filename "$TEST_DIR/Music Videos/周杰伦/七里香.mp4")"
|
||||
mkdir -p "$TEST_DIR/Live"
|
||||
assert_eq "musicvideo|演唱会|||||" \
|
||||
"$(parse_media_filename "$TEST_DIR/Live/演唱会.mkv")"
|
||||
}
|
||||
|
||||
# detect_musicvideo:文件名信号([MV] + "歌手 - 歌名" 模式 → musicvideo)
|
||||
test_musicvideo_filename_signal() {
|
||||
assert_eq "musicvideo|周杰伦 - 晴天|||||" \
|
||||
"$(parse_media_filename "$TEST_DIR/周杰伦 - 晴天 [MV].mkv")"
|
||||
assert_eq "musicvideo|歌手 - 歌|||||" \
|
||||
"$(parse_media_filename "$TEST_DIR/歌手 - 歌 [Live].mkv")"
|
||||
}
|
||||
|
||||
# detect_musicvideo:双命中歧义([MV] 无 " - " → 保持特典路径)
|
||||
test_musicvideo_dual_hit_keeps_special() {
|
||||
assert_eq "tv|某动画||0|1||MV" \
|
||||
"$(parse_media_filename "$TEST_DIR/某动画 [MV].mkv")"
|
||||
}
|
||||
|
||||
# detect_musicvideo:目录名含词但不精确匹配 → 不误判(Muv-Luv / tmp.xxx)
|
||||
test_musicvideo_dir_exact_match_only() {
|
||||
assert_eq "tv|Muv-Luv||01|01||" \
|
||||
"$(parse_media_filename "$TEST_DIR/Muv-Luv S01E01.mkv")"
|
||||
assert_eq "tv|Show||1|07||" \
|
||||
"$(parse_media_filename "$TEST_DIR/Show [07].mkv")"
|
||||
}
|
||||
|
||||
# identify_musicvideo:目录名解析 artist("歌手 - 歌名" 取首段;无分隔符整名)
|
||||
test_identify_musicvideo_artist_from_dir() {
|
||||
mkdir -p "$TEST_DIR/Music Videos/周杰伦"
|
||||
assert_eq "音乐视频/周杰伦/晴天|晴天.mkv" \
|
||||
"$(identify_musicvideo "$TEST_DIR/Music Videos/周杰伦/晴天.mkv" "晴天" mkv)"
|
||||
mkdir -p "$TEST_DIR/歌手 - 歌名"
|
||||
assert_eq "音乐视频/歌手/x|x.mkv" \
|
||||
"$(identify_musicvideo "$TEST_DIR/歌手 - 歌名/x.mkv" "x" mkv)"
|
||||
}
|
||||
|
||||
# identify_musicvideo:无元数据/目录线索 → FOLDER_UNKNOWN 兜底
|
||||
test_identify_musicvideo_unknown_artist() {
|
||||
assert_eq "音乐视频/未知/歌|歌.mkv" \
|
||||
"$(identify_musicvideo "$TEST_DIR/歌.mkv" "歌" mkv)"
|
||||
}
|
||||
|
||||
# fallback_naming:musicvideo 分支(Unknown 艺术家)
|
||||
test_fallback_naming_musicvideo() {
|
||||
assert_eq "音乐视频/未知/歌|歌.mkv" \
|
||||
"$(fallback_naming "musicvideo" "歌" "" "" "" "" "" "mkv")"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# ============================================================================
|
||||
# 硬链接基础工具测试(src/lib/link.sh)
|
||||
# ============================================================================
|
||||
|
||||
setup() {
|
||||
TEST_DIR="$(mktemp -d)"
|
||||
DRY_RUN=false
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$TEST_DIR"
|
||||
}
|
||||
|
||||
# get_file_inode:GNU stat 与兜底路径均可用
|
||||
test_get_file_inode_works() {
|
||||
local f="$TEST_DIR/a.txt"
|
||||
echo hi >"$f"
|
||||
local inode
|
||||
inode="$(get_file_inode "$f")"
|
||||
assert_not_empty "$inode"
|
||||
assert_eq "$(stat -c '%i' "$f")" "$inode" "inode 应与 stat 一致"
|
||||
}
|
||||
|
||||
# same_inode:硬链接同 inode,普通文件不同
|
||||
test_same_inode_detection() {
|
||||
local f1="$TEST_DIR/a.txt" f2="$TEST_DIR/b.txt"
|
||||
echo hi >"$f1"
|
||||
ln "$f1" "$f2"
|
||||
assert_success same_inode "$f1" "$f2"
|
||||
echo other >"$TEST_DIR/c.txt"
|
||||
assert_failure same_inode "$f1" "$TEST_DIR/c.txt"
|
||||
assert_failure same_inode "$f1" "$TEST_DIR/不存在.txt"
|
||||
}
|
||||
|
||||
# hardlink_or_dryrun:真实创建硬链接
|
||||
test_hardlink_or_dryrun_creates_link() {
|
||||
local f1="$TEST_DIR/a.txt" f2="$TEST_DIR/link.txt"
|
||||
echo data >"$f1"
|
||||
assert_success hardlink_or_dryrun "$f1" "$f2"
|
||||
assert_file_exists "$f2"
|
||||
assert_success same_inode "$f1" "$f2"
|
||||
}
|
||||
|
||||
# hardlink_or_dryrun:干运行不创建
|
||||
test_hardlink_or_dryrun_dry_run() {
|
||||
local f1="$TEST_DIR/a.txt" f2="$TEST_DIR/link.txt"
|
||||
echo data >"$f1"
|
||||
DRY_RUN=true
|
||||
assert_success hardlink_or_dryrun "$f1" "$f2"
|
||||
assert_file_absent "$f2"
|
||||
}
|
||||
|
||||
# hardlink_or_dryrun:目标已存在时替换
|
||||
test_hardlink_or_dryrun_replaces_existing() {
|
||||
local f1="$TEST_DIR/a.txt" f2="$TEST_DIR/exists.txt"
|
||||
echo new >"$f1"
|
||||
echo old >"$f2"
|
||||
assert_success hardlink_or_dryrun "$f1" "$f2"
|
||||
assert_success same_inode "$f1" "$f2"
|
||||
assert_eq "new" "$(cat "$f2")"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# ============================================================================
|
||||
# 识别登记与并发池模块测试(src/lib/registry.sh)
|
||||
# ============================================================================
|
||||
|
||||
setup() {
|
||||
MEDIA_DESTINATION_MAP=()
|
||||
MEDIA_OUTCOME_MAP=()
|
||||
PENDING_AI_SEARCH=()
|
||||
PENDING_AI_SPECIAL=()
|
||||
PENDING_AI_ARTIST=()
|
||||
PENDING_AI_MATCH=()
|
||||
PENDING_SEARCH_COUNT=0
|
||||
PENDING_SPECIAL_COUNT=0
|
||||
PENDING_ARTIST_COUNT=0
|
||||
PENDING_MATCH_COUNT=0
|
||||
LINK_SUCCESS_COUNT=0
|
||||
LINK_FAIL_COUNT=0
|
||||
}
|
||||
|
||||
# register_identified:登记目的与结局账本
|
||||
test_register_identified() {
|
||||
register_identified "/m/a.mkv" "Movie/A (2020)" "A (2020).mkv"
|
||||
assert_eq "Movie/A (2020)|A (2020).mkv" "${MEDIA_DESTINATION_MAP[/m/a.mkv]}"
|
||||
assert_eq "identified" "${MEDIA_OUTCOME_MAP[/m/a.mkv]}"
|
||||
}
|
||||
|
||||
# register_skip:跳过不进入目的映射
|
||||
test_register_skip() {
|
||||
register_skip "/m/a.mkv" "skip_unidentified"
|
||||
assert_eq "skip_unidentified" "${MEDIA_OUTCOME_MAP[/m/a.mkv]}"
|
||||
assert_empty "${MEDIA_DESTINATION_MAP[/m/a.mkv]:-}"
|
||||
}
|
||||
|
||||
# register_request_failed:请求失败结局
|
||||
test_register_request_failed() {
|
||||
register_request_failed "/m/a.mkv"
|
||||
assert_eq "request_failed" "${MEDIA_OUTCOME_MAP[/m/a.mkv]}"
|
||||
}
|
||||
|
||||
# register_pending:待 AI 搜索并计入计数
|
||||
test_register_pending() {
|
||||
register_pending "/m/a.mkv" "unknown dir"
|
||||
assert_eq "unknown dir" "${PENDING_AI_SEARCH[/m/a.mkv]}"
|
||||
assert_eq "1" "$PENDING_SEARCH_COUNT"
|
||||
}
|
||||
|
||||
# emit:DEST 协议行写入 POOL_EMIT_FILE(识别池 worker 模式;父进程 merge 后登记)
|
||||
test_emit_dest_protocol() {
|
||||
local f
|
||||
f="$(mktemp)"
|
||||
POOL_EMIT_FILE="$f"
|
||||
emit "/m/a.mkv" DEST "Movie/A (2020)|A (2020).mkv"
|
||||
assert_eq $'/m/a.mkv DEST Movie/A (2020)|A (2020).mkv' "$(cat "$f")"
|
||||
assert_empty "${MEDIA_OUTCOME_MAP[/m/a.mkv]:-}" "emit 本身不登记,merge 才登记"
|
||||
rm -f "$f"
|
||||
unset POOL_EMIT_FILE
|
||||
}
|
||||
|
||||
# emit:SPECIAL_PENDING 直接登记待学习特典
|
||||
test_emit_special_pending() {
|
||||
emit "/m/a.mkv" SPECIAL_PENDING "123|Menu01|Show|01:Menu; 02:CM"
|
||||
assert_eq "Show|01:Menu; 02:CM" "${PENDING_AI_SPECIAL[123|Menu01]}"
|
||||
assert_eq "1" "$PENDING_SPECIAL_COUNT"
|
||||
}
|
||||
|
||||
# emit:MATCH 直接登记待甄别
|
||||
test_emit_match_protocol() {
|
||||
emit "/m/a.mkv" MATCH "tv 1 /search/tv key123"
|
||||
assert_eq "1" "$PENDING_MATCH_COUNT"
|
||||
assert_not_empty "${PENDING_AI_MATCH[/m/a.mkv]:-}"
|
||||
}
|
||||
|
||||
# merge_emit_lines:合并子进程协议行(临时文件)
|
||||
test_merge_emit_lines() {
|
||||
local f
|
||||
f="$(mktemp)"
|
||||
printf '/m/a.mkv\tDEST\tMovie/A|A.mkv\n' >"$f"
|
||||
printf '/m/b.mkv\tSKIP\tskip_type\n' >>"$f"
|
||||
merge_emit_lines "$f"
|
||||
rm -f "$f"
|
||||
assert_eq "identified" "${MEDIA_OUTCOME_MAP[/m/a.mkv]}"
|
||||
assert_eq "skip_type" "${MEDIA_OUTCOME_MAP[/m/b.mkv]}"
|
||||
}
|
||||
|
||||
# dir_chain:相对源根的目录链(最多 3 层)
|
||||
test_dir_chain() {
|
||||
SOURCE_DIR="/media/src"
|
||||
assert_eq "Show/Season 1" "$(dir_chain "/media/src/Show/Season 1/a.mkv")"
|
||||
# 契约:最多 3 层,保留最靠近文件的 3 层(含文件所在目录)
|
||||
assert_eq "B/C/D" "$(dir_chain "/media/src/A/B/C/D/a.mkv")"
|
||||
assert_eq "src" "$(dir_chain "/media/src/a.mkv")"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# ============================================================================
|
||||
# 缓存管理模块测试(src/lib/cache.sh)
|
||||
# ============================================================================
|
||||
|
||||
setup() {
|
||||
CACHE_DIR="$(mktemp -d)"
|
||||
TMDB_LANG="zh-CN"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$CACHE_DIR"
|
||||
}
|
||||
|
||||
# cache_path:搜索类用 md5 哈希文件名
|
||||
test_cache_path_search_endpoint() {
|
||||
local p
|
||||
p="$(cache_path "/search/movie" "query=刀剑&year=2020")"
|
||||
assert_eq "$CACHE_DIR/tmdb/search/movie/$(cache_key_hash "query=刀剑&year=2020").json" "$p"
|
||||
}
|
||||
|
||||
# cache_path:tv 详情含语言段
|
||||
test_cache_path_tv_detail() {
|
||||
assert_eq "$CACHE_DIR/tmdb/tv/12345.zh-CN.json" "$(cache_path "/tv/12345" "x")"
|
||||
}
|
||||
|
||||
# cache_path:季数据含语言段
|
||||
test_cache_path_tv_season() {
|
||||
assert_eq "$CACHE_DIR/tmdb/tv/12345/season/1.zh-CN.json" "$(cache_path "/tv/12345/season/1" "x")"
|
||||
}
|
||||
|
||||
# cache_path:movie 详情含语言段
|
||||
test_cache_path_movie_detail() {
|
||||
assert_eq "$CACHE_DIR/tmdb/movie/6789.zh-CN.json" "$(cache_path "/movie/6789" "x")"
|
||||
}
|
||||
|
||||
# cache_path:语言切换后路径变化(缓存自动失效)
|
||||
test_cache_path_lang_switch_changes_path() {
|
||||
TMDB_LANG="ja-JP"
|
||||
assert_eq "$CACHE_DIR/tmdb/tv/12345.ja-JP.json" "$(cache_path "/tv/12345" "x")"
|
||||
}
|
||||
|
||||
# cache_key:拼接参数并追加 lang
|
||||
test_cache_key_joins_args_with_lang() {
|
||||
assert_eq "query=刀剑&lang=zh-CN" "$(cache_key "query=刀剑")"
|
||||
assert_eq "a=1&b=2&lang=zh-CN" "$(cache_key "a=1" "&b=2")"
|
||||
}
|
||||
|
||||
# cache_key_hash:md5 稳定
|
||||
test_cache_key_hash_stable() {
|
||||
local h1 h2
|
||||
h1="$(cache_key_hash "query=hello")"
|
||||
h2="$(cache_key_hash "query=hello")"
|
||||
assert_eq "$h1" "$h2"
|
||||
assert_eq "5d41402abc4b2a76b9719d911017c592" "$(cache_key_hash "hello")"
|
||||
}
|
||||
|
||||
# cache_expired:TTL 内不失效,超期失效
|
||||
test_cache_expired_ttl() {
|
||||
local f="$CACHE_DIR/f.json"
|
||||
echo '{}' >"$f"
|
||||
touch -d "2 days ago" "$f"
|
||||
assert_success cache_expired "$f" 1
|
||||
assert_failure cache_expired "$f" 30
|
||||
# 新文件不失效
|
||||
echo '{}' >"$f"
|
||||
assert_failure cache_expired "$f" 1
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# ============================================================================
|
||||
# 账本与冷却模块测试(src/lib/ledger.sh)
|
||||
# ============================================================================
|
||||
|
||||
setup() {
|
||||
TEST_DIR="$(mktemp -d)"
|
||||
CACHE_DIR="$TEST_DIR/cache"
|
||||
DRY_RUN=false
|
||||
FAIL_RETRY_COOLDOWN_HOURS=1
|
||||
LEDGER_LINKED=()
|
||||
LEDGER_INODE=()
|
||||
COOLDOWN_MAP=()
|
||||
CORRECTIONS_MAP=()
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$TEST_DIR"
|
||||
}
|
||||
|
||||
# ledger_mark_linked + ledger_is_linked:标记后判定已链接
|
||||
test_ledger_mark_and_is_linked() {
|
||||
local src="$TEST_DIR/a.mkv" dest="$TEST_DIR/dest/a.mkv"
|
||||
mkdir -p "$TEST_DIR/dest"
|
||||
echo data >"$src"
|
||||
ln "$src" "$dest"
|
||||
ledger_mark_linked "$src" "$dest"
|
||||
assert_success ledger_is_linked "$src"
|
||||
}
|
||||
|
||||
# ledger_is_linked:目标被删 → 失效(重新识别)
|
||||
test_ledger_is_linked_invalid_when_dest_deleted() {
|
||||
local src="$TEST_DIR/a.mkv" dest="$TEST_DIR/dest/a.mkv"
|
||||
mkdir -p "$TEST_DIR/dest"
|
||||
echo data >"$src"
|
||||
ln "$src" "$dest"
|
||||
ledger_mark_linked "$src" "$dest"
|
||||
rm -f "$dest"
|
||||
assert_failure ledger_is_linked "$src"
|
||||
}
|
||||
|
||||
# ledger_is_linked:未标记 → 未链接
|
||||
test_ledger_is_linked_unmarked() {
|
||||
local src="$TEST_DIR/a.mkv"
|
||||
echo data >"$src"
|
||||
assert_failure ledger_is_linked "$src"
|
||||
}
|
||||
|
||||
# cooldown_set + cooldown_active:冷却期内判定
|
||||
test_cooldown_set_and_active() {
|
||||
local src="$TEST_DIR/a.mkv"
|
||||
echo data >"$src"
|
||||
cooldown_set "$src" "request_failed"
|
||||
assert_success cooldown_active "$src"
|
||||
}
|
||||
|
||||
# cooldown_active:源文件消失 → 不冷却(允许重试)
|
||||
test_cooldown_inactive_when_src_gone() {
|
||||
local src="$TEST_DIR/a.mkv"
|
||||
echo data >"$src"
|
||||
cooldown_set "$src" "request_failed"
|
||||
rm -f "$src"
|
||||
assert_failure cooldown_active "$src"
|
||||
}
|
||||
|
||||
# cooldown_active:过期 → 不冷却
|
||||
test_cooldown_inactive_when_expired() {
|
||||
local src="$TEST_DIR/a.mkv"
|
||||
echo data >"$src"
|
||||
COOLDOWN_MAP["$src"]=$(($(date +%s) - 10))
|
||||
assert_failure cooldown_active "$src"
|
||||
}
|
||||
|
||||
# ledger_flush:落盘 linked.json 并可重新加载
|
||||
test_ledger_flush_roundtrip() {
|
||||
local src="$TEST_DIR/a.mkv" dest="$TEST_DIR/dest/a.mkv"
|
||||
mkdir -p "$TEST_DIR/dest"
|
||||
echo data >"$src"
|
||||
ln "$src" "$dest"
|
||||
ledger_mark_linked "$src" "$dest"
|
||||
ledger_flush
|
||||
assert_file_exists "$CACHE_DIR/media_organizer/linked.json"
|
||||
LEDGER_LINKED=()
|
||||
LEDGER_INODE=()
|
||||
ledger_load
|
||||
assert_success ledger_is_linked "$src"
|
||||
}
|
||||
|
||||
# ledger_flush:干运行零持久化副作用
|
||||
test_ledger_flush_skipped_in_dry_run() {
|
||||
local src="$TEST_DIR/a.mkv"
|
||||
echo data >"$src"
|
||||
ledger_mark_linked "$src" "$TEST_DIR/dest.mkv"
|
||||
DRY_RUN=true
|
||||
ledger_flush
|
||||
assert_file_absent "$CACHE_DIR/media_organizer/linked.json"
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# ============================================================================
|
||||
# 字符串工具模块测试(src/lib/strings.sh)
|
||||
# ============================================================================
|
||||
|
||||
# 每个测试前重置依赖的全局状态
|
||||
setup() {
|
||||
COLOR_OUTPUT=false
|
||||
TMDB_LANG="zh-CN"
|
||||
SPECIAL_MAP=()
|
||||
}
|
||||
|
||||
# sanitize:删除非法字符
|
||||
test_sanitize_removes_illegal_chars() {
|
||||
assert_eq "abcdefghij" "$(sanitize 'a/b\c:d*e?f"g<h>i|j')" "sanitize 应删除 \\ / : * ? \" < > |"
|
||||
}
|
||||
|
||||
# sanitize:保留中文与空格
|
||||
test_sanitize_keeps_cjk_and_spaces() {
|
||||
assert_eq "某科学的超电磁炮T" "$(sanitize '某科学的超电磁炮T')"
|
||||
assert_eq "刀剑神域 爱丽丝篇" "$(sanitize '刀剑神域 爱丽丝篇')"
|
||||
}
|
||||
|
||||
# sanitize:空输入
|
||||
test_sanitize_empty_input() {
|
||||
assert_eq "" "$(sanitize "")"
|
||||
assert_eq "" "$(sanitize "///")"
|
||||
}
|
||||
|
||||
# clean_name:去方括号标记与首尾空白
|
||||
test_clean_name_strips_brackets_and_spaces() {
|
||||
assert_eq "Show Title" "$(clean_name '[VCB-Studio] Show Title [1080p]')"
|
||||
assert_eq "Title" "$(clean_name ' [A] Title [B] ')"
|
||||
}
|
||||
|
||||
# strip_season_suffix:多种季后缀
|
||||
test_strip_season_suffix_variants() {
|
||||
assert_eq "Show" "$(strip_season_suffix 'Show S01')"
|
||||
assert_eq "Show" "$(strip_season_suffix 'Show 2nd Season')"
|
||||
assert_eq "Show" "$(strip_season_suffix 'Show S12')"
|
||||
# 行为契约:仅剥离 Season 前带数字的形式;"Show Season 2" 由 parse_media_filename 处理
|
||||
assert_eq "Show Season 2" "$(strip_season_suffix 'Show Season 2')"
|
||||
assert_eq "无需处理" "$(strip_season_suffix '无需处理')"
|
||||
}
|
||||
|
||||
# norm_str:全角转半角 + 去空格/连字符 + 小写
|
||||
test_norm_str_fullwidth_to_halfwidth() {
|
||||
assert_eq "aliceization" "$(norm_str 'Aliceization')"
|
||||
assert_eq "swordartonline" "$(norm_str 'Sword Art Online')"
|
||||
assert_eq "iiiiiivv" "$(norm_str 'ⅡⅢⅣⅤ')"
|
||||
assert_eq "menu01" "$(norm_str 'Menu 01')"
|
||||
}
|
||||
|
||||
# safe_printf_int:补零与非数字默认值
|
||||
test_safe_printf_int_padding() {
|
||||
assert_eq "01" "$(safe_printf_int 1)"
|
||||
assert_eq "23" "$(safe_printf_int 23)"
|
||||
assert_eq "00" "$(safe_printf_int "")"
|
||||
assert_eq "99" "$(safe_printf_int "abc" "99")"
|
||||
assert_eq "07" "$(safe_printf_int "007")"
|
||||
}
|
||||
|
||||
# normalize_abs:相对/绝对路径归一化
|
||||
test_normalize_abs_paths() {
|
||||
local d
|
||||
d="$(mktemp -d)"
|
||||
assert_eq "$d" "$(normalize_abs "$d")" "绝对路径原样返回"
|
||||
assert_eq "$d/sub" "$(normalize_abs "$d/sub")" "不存在的子目录也应归一化"
|
||||
assert_eq "" "$(normalize_abs "")" "空串原样返回"
|
||||
rm -rf "$d"
|
||||
}
|
||||
|
||||
# is_companion:伴随文件判断
|
||||
test_is_companion_detection() {
|
||||
assert_success is_companion "/path/Show.S01E01.mkv.ass" "/path/Show.S01E01.mkv"
|
||||
assert_success is_companion "/path/Show.S01E01.zh-CN.ass" "/path/Show.S01E01"
|
||||
assert_success is_companion "/path/Show.S01E01.flac" "/path/Show.S01E01"
|
||||
assert_failure is_companion "/path/Other.mkv" "/path/Show.S01E01"
|
||||
# 行为契约:视频自身去扩展名后等于 vbase → 也判为伴随(link_companions 调用前先跳过自身)
|
||||
assert_success is_companion "/path/Show.S01E01.mkv" "/path/Show.S01E01"
|
||||
}
|
||||
|
||||
# translate_fragment:查表、核心词回退、归一化
|
||||
test_translate_fragment_lookup() {
|
||||
SPECIAL_MAP["menu"]='["Menu", "菜单"]'
|
||||
SPECIAL_MAP["cm01"]='["CM", "广告"]'
|
||||
assert_eq "Menu" "$(translate_fragment "Menu01")" "去数字后查核心词"
|
||||
assert_eq "CM" "$(translate_fragment "CM01")" "精确键优先"
|
||||
assert_eq "Menu" "$(translate_fragment "Menu01")" "全角归一化"
|
||||
assert_eq "PV-01" "$(translate_fragment "PV-01")" "无映射时原样返回"
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# shellcheck shell=bash
|
||||
# ============================================================================
|
||||
# 轻量断言库(tests/run.sh 在每个用例文件的子 shell 中 source)
|
||||
# 所有断言失败时打印诊断并返回非 0;测试函数应直接调用并以其返回值作为结果。
|
||||
# ============================================================================
|
||||
|
||||
# 断言两值相等。用法:assert_eq <期望值> <实际值> [描述]
|
||||
assert_eq() {
|
||||
local expected="$1" actual="$2" msg="${3:-}"
|
||||
if [[ "$expected" == "$actual" ]]; then
|
||||
return 0
|
||||
fi
|
||||
echo " ✗ assert_eq${msg:+($msg)}" >&2
|
||||
echo " 期望: [$expected]" >&2
|
||||
echo " 实际: [$actual]" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# 断言字符串包含子串。用法:assert_contains <字符串> <子串> [描述]
|
||||
assert_contains() {
|
||||
local haystack="$1" needle="$2" msg="${3:-}"
|
||||
if [[ "$haystack" == *"$needle"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
echo " ✗ assert_contains${msg:+($msg)}" >&2
|
||||
echo " 字符串: [$haystack]" >&2
|
||||
echo " 子串: [$needle]" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# 断言字符串为空。用法:assert_empty <字符串> [描述]
|
||||
assert_empty() {
|
||||
local val="$1" msg="${2:-}"
|
||||
if [[ -z "$val" ]]; then
|
||||
return 0
|
||||
fi
|
||||
echo " ✗ assert_empty${msg:+($msg)}:实际为 [$val]" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# 断言字符串非空。用法:assert_not_empty <字符串> [描述]
|
||||
assert_not_empty() {
|
||||
local val="$1" msg="${2:-}"
|
||||
if [[ -n "$val" ]]; then
|
||||
return 0
|
||||
fi
|
||||
echo " ✗ assert_not_empty${msg:+($msg)}:实际为空" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# 断言命令成功。用法:assert_success <命令...>
|
||||
assert_success() {
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
echo " ✗ assert_success:命令失败:$*" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# 断言命令失败(返回非 0)。用法:assert_failure <命令...>
|
||||
assert_failure() {
|
||||
if "$@"; then
|
||||
echo " ✗ assert_failure:命令意外成功:$*" >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# 断言文件存在。用法:assert_file_exists <路径> [描述]
|
||||
assert_file_exists() {
|
||||
local path="$1" msg="${2:-}"
|
||||
if [[ -f "$path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
echo " ✗ assert_file_exists${msg:+($msg)}:$path 不存在" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# 断言文件不存在。用法:assert_file_absent <路径> [描述]
|
||||
assert_file_absent() {
|
||||
local path="$1" msg="${2:-}"
|
||||
if [[ ! -e "$path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
echo " ✗ assert_file_absent${msg:+($msg)}:$path 意外存在" >&2
|
||||
return 1
|
||||
}
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# 单元测试运行器(零依赖,纯 bash)
|
||||
#
|
||||
# 用法:
|
||||
# ./tests/run.sh 运行全部用例
|
||||
# ./tests/run.sh cache 仅运行匹配名字的用例文件(如 cache、filename)
|
||||
# ./tests/run.sh -v 显示每个用例名称(默认只显示失败与汇总)
|
||||
#
|
||||
# 设计:
|
||||
# - 按 src/modules.list 顺序加载全部模块(跳过 lib/entry.sh,避免触发 main)
|
||||
# - 每个用例文件在独立子 shell 中运行:全局状态自动隔离、互不污染
|
||||
# - 用例文件可定义可选 setup()(每个测试前调用)与 teardown()(测试后调用)
|
||||
# - 断言使用 tests/lib/assert.sh,失败返回非 0,不影响其余用例继续执行
|
||||
# ============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VERBOSE=false
|
||||
FILTER=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
-v) VERBOSE=true ;;
|
||||
*) FILTER="$a" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 加载全部模块(bashly 源码树):
|
||||
# - src/lib/main.sh 常量与全局变量最先
|
||||
# - src/lib/*.sh 纯函数定义库
|
||||
# - 跳过 src/root_command.sh(bashly 命令文件为顶层代码,仅由 bashly 包装执行)
|
||||
# 注意:必须在顶层加载(不能包在函数里)——模块中的 declare -A 在函数内
|
||||
# 会变成函数局部变量,函数返回后关联数组全部丢失。
|
||||
# shellcheck source=/dev/null
|
||||
source "$ROOT/src/lib/main.sh"
|
||||
while IFS= read -r -d '' m; do
|
||||
# main.sh 已加载(readonly 常量不能重复 source)
|
||||
[[ "$(basename "$m")" == "main.sh" ]] && continue
|
||||
# shellcheck source=/dev/null
|
||||
source "$m"
|
||||
done < <(find "$ROOT/src/lib" -name '*.sh' -print0 | sort -z)
|
||||
|
||||
# 断言库
|
||||
# shellcheck source=/dev/null
|
||||
source "$ROOT/tests/lib/assert.sh"
|
||||
|
||||
# 测试环境约定:_log 仅作日志输出(不参与业务逻辑),覆盖为 no-op 保持测试输出干净;
|
||||
# 如需断言日志行为,可在具体用例中临时重新定义。
|
||||
_log() { return 0; }
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
failed_names=()
|
||||
|
||||
run_case_file() {
|
||||
local case_file="$1"
|
||||
local case_name
|
||||
case_name="$(basename "$case_file" .sh)"
|
||||
# 子 shell:全局状态隔离(每个用例文件一组干净状态)
|
||||
(
|
||||
set +e
|
||||
# shellcheck source=/dev/null
|
||||
source "$case_file"
|
||||
local fn
|
||||
for fn in $(compgen -A function | grep '^test_' | sort); do
|
||||
if declare -F setup >/dev/null; then setup; fi
|
||||
# 注意:不能在 if 条件中运行 ( set -e; "$fn" )——bash 的条件上下文会
|
||||
# 抑制子 shell 内的 errexit(多断言测试会漏报)。先执行再取退出码。
|
||||
# stdout 吞掉(_log 等噪声),stderr 透传(断言失败诊断可见)
|
||||
(
|
||||
set -e
|
||||
"$fn"
|
||||
) >/dev/null
|
||||
rc=$?
|
||||
if ((rc == 0)); then
|
||||
printf 'PASS\t%s\t%s\n' "$case_name" "$fn"
|
||||
else
|
||||
printf 'FAIL\t%s\t%s\n' "$case_name" "$fn"
|
||||
fi
|
||||
if declare -F teardown >/dev/null; then teardown; fi
|
||||
done
|
||||
)
|
||||
}
|
||||
|
||||
while IFS= read -r -d '' case_file; do
|
||||
if [[ -n "$FILTER" ]] && [[ "$(basename "$case_file" .sh)" != *"$FILTER"* ]]; then
|
||||
continue
|
||||
fi
|
||||
[[ $VERBOSE == true ]] && echo "── $case_file"
|
||||
while IFS=$'\t' read -r kind case_name fn; do
|
||||
if [[ "$kind" == "PASS" ]]; then
|
||||
pass=$((pass + 1))
|
||||
[[ $VERBOSE == true ]] && echo " ✅ $case_name/$fn"
|
||||
elif [[ "$kind" == "FAIL" ]]; then
|
||||
fail=$((fail + 1))
|
||||
failed_names+=("$case_name/$fn")
|
||||
echo " ❌ $case_name/$fn"
|
||||
fi
|
||||
done < <(run_case_file "$case_file")
|
||||
done < <(find "$ROOT/tests/cases" -name '*.sh' -print0 | sort -z)
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════"
|
||||
echo "结果:$pass 通过,$fail 失败"
|
||||
if ((fail > 0)); then
|
||||
printf '失败用例:\n'
|
||||
for n in "${failed_names[@]}"; do
|
||||
echo " - $n"
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
Reference in new issue
Block a user