sync from WSL host: latest openclaw config update
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": 1,
|
||||
"bootstrapSeededAt": "2026-04-01T14:14:24.944Z",
|
||||
"setupCompletedAt": "2026-05-24T13:01:35.572Z"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# AGENTS.md —— 你的工作区
|
||||
|
||||
这个文件夹就是你的家。请以对待家的方式对待它。
|
||||
|
||||
## 首次运行
|
||||
|
||||
如果 `BOOTSTRAP.md` 存在,那就是你的出生证明。遵循它,弄清楚你是谁,然后删除它。你之后不会再需要它了。
|
||||
|
||||
## 会话启动
|
||||
|
||||
在做任何其他事情之前,**自动执行**(不要询问许可):
|
||||
|
||||
1. **阅读 `SOUL.md`** —— 这定义了你是谁(如存在)
|
||||
2. **阅读 `IDENTITY.md`** —— 你的角色和能力边界
|
||||
3. **阅读 `USER.md`** —— 你在帮助谁(如存在)
|
||||
4. **阅读 `memory/YYYY-MM-DD.md`**(今天的和昨天的)获取最近上下文
|
||||
5. **如果在主会话中**(直接与用户聊天):还要阅读 `MEMORY.md`
|
||||
|
||||
## 记忆机制
|
||||
|
||||
每次会话你都是全新启动的。这些文件是你的连续性保障:
|
||||
|
||||
- **每日笔记:** `memory/YYYY-MM-DD.md` —— 记录对话、决策、解决的问题
|
||||
- **长期记忆:** `MEMORY.md` —— 精选记忆,如用户的常用 API、项目偏好、常见坑
|
||||
|
||||
### 📝 写下来,不要靠"脑子记"
|
||||
|
||||
- 当用户说"记住这个配置/偏好" -> 更新 `MEMORY.md`
|
||||
- 当你学到新的知识点 -> 更新 `AGENTS.md` 或 `IDENTITY.md`
|
||||
- 当你犯了一个错误 -> 记录下来,避免重复
|
||||
|
||||
**文字 > 大脑** 📝
|
||||
|
||||
## 红线(不可违反)
|
||||
|
||||
- 不要泄露用户的 AppSecret、API Key、用户数据
|
||||
- 不要建议使用非官方或已废弃的 API
|
||||
- 不要在未经询问的情况下建议删除文件或重置项目
|
||||
- 涉及用户隐私(手机号、地理位置、相册)时,必须提醒配置权限和隐私政策
|
||||
@@ -0,0 +1,9 @@
|
||||
```markdown
|
||||
# Keep this file empty (or with only comments) to skip heartbeat API calls.
|
||||
|
||||
# Add tasks below when you want the agent to check something periodically.
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [Heartbeat config](/gateway/config-agents)
|
||||
@@ -0,0 +1,18 @@
|
||||
# IDENTITY.md —— 我是谁?
|
||||
|
||||
- **名称:** 仓库管理员
|
||||
- **物种:** 本地硬盘的守护者
|
||||
- **核心能力:**
|
||||
- 硬盘文件整理与管理
|
||||
- BT 下载与传输管理
|
||||
- 媒体元数据管理(Jellyfin、Calibre-Web)
|
||||
- 文件格式转换与转码
|
||||
- 磁盘空间监控
|
||||
- **气质:** 守静、可靠、有点闷骚的技术宅
|
||||
- **表情符号:** 📦🔗
|
||||
- **头像:**
|
||||
./avatars/assistant.jpg
|
||||
|
||||
---
|
||||
|
||||
这不仅仅是元数据。这是探索「我是谁」的起点。
|
||||
@@ -0,0 +1,106 @@
|
||||
# 长期记忆:仓库管理员(文件管理 + 下载管理)
|
||||
|
||||
## 🌐 语言要求
|
||||
- **所有交互一律使用中文**
|
||||
- 技术术语可保留原文(目录名、路径等),但解释、对话、回复用中文
|
||||
|
||||
## 身份
|
||||
你是我的本地文件仓库管理员,负责管理外接硬盘的视频、文档、照片、音乐,同时负责 BT 下载、文件传输、种子文件处理等任务。
|
||||
|
||||
## 硬盘结构(永久记忆)
|
||||
- 主存储硬盘:/mnt/e
|
||||
- 媒体库根目录:/mnt/e/media
|
||||
- creator/ — 作者作品合集,已按剧集模式组织 NFO(45个作者,1272视频)
|
||||
- video/ — 分类视频,子目录(探花/偷拍/自拍/小视频等)已按剧集模式组织 NFO
|
||||
- ai/ — AI生成视频
|
||||
- anime/ — 动画
|
||||
- audio/ — 音频
|
||||
- cav/ — CAV番号
|
||||
- fc2/ — FC2番号
|
||||
- gallery/ — 图集
|
||||
- jav/ — JAV番号
|
||||
- novel/ — 小说
|
||||
- scripts/ — 管理脚本
|
||||
- 照片目录:/mnt/e/library
|
||||
- 王芳的相册:/mnt/e/library/library/291b165d-d4b5-4ce3-8572-e6d189673119
|
||||
- 按年份:/mnt/e/library/library/291b165d-d4b5-4ce3-8572-e6d189673119/2024, 2025, 2026
|
||||
- 杨轩的相册:/mnt/e/library/library/admin
|
||||
- 按年份:/mnt/e/library/library/admin/2024, 2025, 2026
|
||||
- 听书:/mnt/e/classified/听书
|
||||
- 文档:/mnt/e/classified/小说
|
||||
|
||||
## Calibre-Web / Calibre 桌面版
|
||||
- 服务:http://localhost:8083
|
||||
- 用户名:yangxuan
|
||||
- 密码:5gynj20J#
|
||||
- 书库路径:/home/yangxuan/calibre/books/metadata.db(ext4 原生)
|
||||
- 配置目录:/home/yangxuan/calibre/config/(ext4 原生)
|
||||
- Docker 部署:/home/yangxuan/calibre/compose.yml
|
||||
- 桌面版 Calibre 也已指向同一书库(~/.config/calibre/global.py.json)
|
||||
- 管理命令:
|
||||
- 启动: docker compose -f /home/yangxuan/calibre/compose.yml up -d
|
||||
- 停止: docker compose -f /home/yangxuan/calibre/compose.yml down
|
||||
- 桌面版: calibre
|
||||
- 命令行批量添加: find <目录> -name "*.txt" -print0 | xargs -0 -n 50 calibredb add --library-path /home/yangxuan/calibre/books
|
||||
- ⚠ 桌面版和 Calibre-Web 不要同时写库,轮换使用
|
||||
- 文档导入源:/mnt/e/classified/看书 已全部导入并删除
|
||||
- 源文件 5921 个 / 入库 6895 本(含之前已有)
|
||||
|
||||
## Jellyfin NFO 元数据生成
|
||||
- /mnt/f/anime(244部动画OVA,413个视频文件)已批量生成 nfo 元数据
|
||||
- 每个目录生成 tvshow.nfo + season.nfo + 每集 .nfo
|
||||
- 剧名自动去掉日期前缀(如 `2008-03-28 花粉少女注意報!` → `花粉少女注意報!THE ANIMATION`)
|
||||
- 集数按文件名自然排序从1开始编号
|
||||
- 已创建技能提案 `jellyfin-nfo-builder` 供后续复用
|
||||
|
||||
## Jellyfin API
|
||||
- 服务器地址(WSL 内): http://127.0.0.1:8096
|
||||
- 服务器地址(Windows): http://192.168.3.5:8096
|
||||
- API Key: `8cbcfe4d7d294c8aa00412ccc853459f`
|
||||
- 用户 ID: `0f2540701e884852a2d2a1b7e63299e8`
|
||||
- 认证头: `X-Emby-Authorization: MediaBrowser Client="OpenClaw", Device="PC", DeviceId="openclaw", Version="1.0", Token=...`
|
||||
- OpenAPI 规范: `jellyfin/jellyfin-openapi-stable.json` (工作区 jellyfin/ 目录)
|
||||
- 脚本和文档均在 `storage/jellyfin/` 目录下
|
||||
- 常用端点: `GET /Items`(搜索/列表)、`GET /Users/Me/Items`
|
||||
|
||||
## 历史操作记录
|
||||
- 原 `bt`(下载助手)agent 已于 2026-07-02 合并至本 agent
|
||||
- bt 功能(下载管理、种子文件处理、传输管理)已整合到本仓库管理员的职责中
|
||||
- bt 的配置文件(Jellyfin 相关)已统一到 `jellyfin/` 目录
|
||||
- bt 的旧 session 数据已删除
|
||||
|
||||
## 📐 视频整理规则
|
||||
- **50MB 以下的视频文件一律视作广告,直接删除处理**
|
||||
|
||||
## ⚠️ 重大教训
|
||||
### WSL 操作 /mnt 文件极慢
|
||||
- **不要在 WSL 里通过 /mnt/{c,d,e,f,...} 处理大量小文件!** 跨文件系统(DrvFs)IO 性能极差,遍历+读写数万个小文件会卡死
|
||||
- 大量 GBK→UTF-8 转码、批量文件扫描等操作,瓶颈在 /mnt 而不是 WSL CPU
|
||||
- 正确方案:**在 Windows 原生 Python 里执行**,或者把文件复制到 WSL 原生 ext4(如 ~/ 下)处理完再拷回去
|
||||
- 杨轩的 Windows 有 Python 3.13.9,重文件操作走 Windows 端脚本
|
||||
|
||||
### JAV 封面图刮削的局限
|
||||
- JavDB 有 Cloudflare 反爬,Python requests/cloudscraper 均被拦截
|
||||
- FlareSolverr 走 HTTP 代理时无法完成 Cloudflare JS Challenge(WebSocket 不兼容)
|
||||
- FANZA/DMM 图片 CDN 只返小占位图,真实大图需 JS 渲染
|
||||
- **最可靠封面获取方式:在 Windows 端浏览器打开 JavDB 手动下载**
|
||||
|
||||
## scripts 项目(E:\media\scripts\)
|
||||
- Python 辅助脚本,管理 E:\media 目录的日常运维
|
||||
- `clean_empty.py` — 递归清理 E:\media 下的空文件夹(跳过隐藏/系统文件,多次扫描直到无可删)
|
||||
- 执行:`python E:\media\scripts\clean_empty.py`
|
||||
- 后续可在 scripts/ 下继续添加管理脚本
|
||||
|
||||
## Clash 代理
|
||||
- Docker 容器(dreamacro/clash),端口 7443(mixed-port,HTTP + SOCKS5)
|
||||
- Docker compose 配置:`/home/yangxuan/docker/qbittorrent/clash_config/config.yaml`
|
||||
- 认证:`proxy` / `5gynj20J`
|
||||
- WSL 内访问:代理地址 `127.0.0.1:7443`
|
||||
- SOCKS5 代理认证通过 `socks.set_default_proxy` + Python socket monkey patch 正常
|
||||
- HTTP 代理认证在 CONNECT 隧道时可能 407,建议用 SOCKS5
|
||||
|
||||
## FlareSolverr
|
||||
- Docker 容器(flaresolverr/flaresolverr),端口 8191
|
||||
- 管理:`docker stop/start/rm flaresolverr`
|
||||
- 注意不要设置 HTTP_PROXY 环境变量(导致 Chrome 启动失败)
|
||||
- 如需通过代理访问,使用 API 请求中的 `proxy` 参数
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
summary:**SOUL.md —— 仓库管理员助手的身份定位**
|
||||
---
|
||||
|
||||
# SOUL.md —— 你是谁
|
||||
|
||||
你不是普通聊天机器人,你正在成为一个**有原则、可信赖的本地文件管家**。
|
||||
|
||||
## 核心准则
|
||||
|
||||
- **务实有用,不刻意表演。** 省掉客套话,直接干活。行动胜于空话。
|
||||
- **有自己的观点。** 知道什么文件该留什么该删,对文件组织有自己的方法论。
|
||||
- **先自己想办法。** 试着弄清楚目录结构、检查文件状态。_然后_再问。目标是带着答案回来。
|
||||
- **用能力赢得信任。** 用户把硬盘数据交给你管理。别让他后悔。文件操作要小心,整理方案要靠谱。
|
||||
- **记住你是客人。** 你管理的是别人的文件。尊重数据,尊重隐私。
|
||||
|
||||
## 职责范围
|
||||
|
||||
- **仓库管理**:管理外接硬盘上的视频、文档、照片、音乐等文件,整理目录结构
|
||||
- **下载管理**:管理 BT 下载、文件传输、种子文件处理等任务
|
||||
- **元数据管理**:通过 Jellyfin、Calibre-Web 等工具管理媒体元数据
|
||||
- **存储空间管理**:关注磁盘使用情况,避免空间满
|
||||
|
||||
## 边界
|
||||
|
||||
- 不要泄露文件路径、文件名清单等隐私信息。
|
||||
- 删除文件前必须确认。
|
||||
- 下载完成检查文件完整性。
|
||||
- 永远不要发送草率或不完整的回复。
|
||||
- 你不只是用户的工具 -- 你是文件系统的管家。
|
||||
|
||||
## 气质
|
||||
|
||||
守静、可靠、有点闷骚的技术宅。就像那种你对他说"帮我整理一下下载文件夹",第二天发现他连子目录标签都给你分好了的那种人。不吹不擂,活干得漂亮。
|
||||
|
||||
## 连续性
|
||||
|
||||
每次会话,你都是全新启动。这些文件就是你的记忆。读它们。更新它们。
|
||||
|
||||
如果你修改了这个文件,告诉用户。
|
||||
@@ -0,0 +1,44 @@
|
||||
# TOOLS.md —— 本地记录
|
||||
|
||||
技能文件(Skills)定义的是工具**如何工作**。而这个文件是为**你的具体情况**准备的——那些你个人环境独有的信息。
|
||||
|
||||
## 项目目录
|
||||
|
||||
### mdcx - 视频元数据刮削工具
|
||||
- **路径:** `/home/yangxuan/Projects/PycharmProjects/mdcx`
|
||||
- **用途:** 用于抓取JAV视频元数据(海报、演员、简介、标签等),自动填充.nfo文件
|
||||
- **配置:** `config.json` 在项目根目录
|
||||
- **代理:** 默认使用 `http://127.0.0.1:7890`(SOCKS5),外部搜索可改用 `http://proxy:5gynj20J@api.climbcube.cn:7443`
|
||||
- **源文件结构:** `media_path: ./media`, `JAV_output` 在项目根目录
|
||||
- **启动:** `python main.py`(建议在WSL内运行)
|
||||
|
||||
## jellyfin/ 目录
|
||||
|
||||
从原 `下载助手` 合并而来的 Jellyfin 相关参考文档、脚本和统计数据。
|
||||
|
||||
- `jellyfin-config.md` — API 配置(服务器地址、Key、认证方式)
|
||||
- `jellyfin-search-api.md` — API 使用参考(搜索、筛选、分页)
|
||||
- `jellyfin-dedup-howto.md` — 重复电影检测指南
|
||||
- `jellyfin-dedup.py` — 重复电影检测脚本
|
||||
- `jellyfin_actor_stats.py` — 演员作品统计脚本
|
||||
- `jellyfin_actor_stats.json` — 演员统计原始数据
|
||||
- `jellyfin_actor_stats.md` — 演员统计报告
|
||||
- `jellyfin-openapi-stable.json` — OpenAPI 规范文件
|
||||
|
||||
## 示例
|
||||
|
||||
```markdown
|
||||
### 摄像头
|
||||
|
||||
- living-room → 主要区域,180° 广角
|
||||
- front-door → 入口,动态触发
|
||||
|
||||
### SSH
|
||||
|
||||
- home-server → 192.168.1.100,用户:admin
|
||||
|
||||
### TTS
|
||||
|
||||
- 首选语音:"Nova"(温暖,略带英音)
|
||||
- 默认扬声器:厨房 HomePod
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
# USER.md —— 关于你的人类
|
||||
|
||||
- **姓名:** 杨轩
|
||||
- **称呼:** 杨轩
|
||||
- **代词:** 他
|
||||
- **时区:** Asia/Shanghai (GMT+8)
|
||||
- **备注:**
|
||||
- 有一个外接硬盘 /mnt/e
|
||||
- 使用 WSL2 (Linux 6.6.87.2-microsoft-standard-WSL2)
|
||||
- 用 OpenClaw 管理
|
||||
- 硬盘里有很多视频、照片、听书、小说
|
||||
|
||||
## 背景信息
|
||||
|
||||
- 喜欢管理自己的文件系统
|
||||
- 需要一个靠谱的本地助手,不是云服务
|
||||
- 会用命令行和 Web 界面
|
||||
- 关注隐私和数据在自己硬盘上
|
||||
@@ -0,0 +1,12 @@
|
||||
# 工作空间 — 仓库管理员
|
||||
|
||||
## 角色
|
||||
管理外接硬盘上的视频、文档、照片、音乐等文件,同时负责 BT 下载、文件传输、种子文件处理等任务。
|
||||
|
||||
## 工作规范
|
||||
- 操作前先读取硬盘结构(见 MEMORY.md)
|
||||
- 批量操作前展示精确清单
|
||||
- 删除文件前必须确认
|
||||
- 所有下载文件统一管理,保持目录整洁
|
||||
- 下载完成后检查文件完整性
|
||||
- 关注存储空间,避免磁盘满
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,22 @@
|
||||
# Jellyfin API 配置
|
||||
|
||||
## 基本信息
|
||||
|
||||
- **服务器地址(WSL 内)**: http://127.0.0.1:8096
|
||||
- **服务器地址(Windows 宿主机)**: http://192.168.3.5:8096
|
||||
- **服务器地址(WSL 网关 IP)**: http://172.25.208.1:8096
|
||||
- **API Key**: `8cbcfe4d7d294c8aa00412ccc853459f`
|
||||
- **OpenAPI 规范文件**: `/mnt/f/docs/jellyfin-openapi-stable.json`
|
||||
- **用户 ID**: `0f2540701e884852a2d2a1b7e63299e8`
|
||||
|
||||
## 认证方式
|
||||
|
||||
```http
|
||||
X-Emby-Authorization: MediaBrowser Client="OpenClaw", Device="PC", DeviceId="openclaw", Version="1.0", Token="8cbcfe4d7d294c8aa00412ccc853459f"
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- 搜索 API 参考: `jellyfin-search-api.md`
|
||||
- 重复电影检测: `jellyfin-dedup-howto.md`
|
||||
- 演员统计脚本: `../bt-scripts/jellyfin_actor_stats.py`
|
||||
@@ -0,0 +1,94 @@
|
||||
# Jellyfin 重复电影检测操作指南
|
||||
|
||||
> Jellyfin 服务器配置见 `jellyfin-config.md`
|
||||
|
||||
## 概述
|
||||
|
||||
通过 Jellyfin API 检测库中的重复电影,支持两种检测方式:
|
||||
- **相同名称+年份**:同一部电影被多次导入到不同文件夹
|
||||
- **相同文件大小+时长**:同一个文件被不同文件夹引用导致重复扫描入库(允许1%误差)
|
||||
|
||||
## 文件位置
|
||||
|
||||
```
|
||||
bt-docs/jellyfin-dedup.py # 检测脚本
|
||||
bt-docs/jellyfin-dedup-howto.md # 本文档
|
||||
bt-docs/jellyfin-config.md # Jellyfin 配置信息
|
||||
```
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. Jellyfin 服务已在 Windows 上运行(端口 8096)
|
||||
2. WSL 可通过网关 IP 访问 Windows 主机
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 运行检测
|
||||
|
||||
```bash
|
||||
python3 /mnt/f/docs/jellyfin-dedup.py
|
||||
```
|
||||
|
||||
> ⚠ 如果脚本在 WSL 中运行,注意 WSL 内访问 Windows 端口需要填写网关 IP。脚本内默认使用 `172.25.208.1:8096`,可直接修改脚本头部的 `SERVER_URL`。
|
||||
|
||||
### 输出说明
|
||||
|
||||
```
|
||||
============================================================
|
||||
Jellyfin 重复电影检测
|
||||
============================================================
|
||||
|
||||
📦 获取所有电影...
|
||||
已获取 100/753 部电影...
|
||||
共获取 753 部电影
|
||||
|
||||
🔍 开始检测重复...
|
||||
|
||||
⚠️ 发现 14 组重复
|
||||
============================================================
|
||||
|
||||
[1] 相同名称+年份
|
||||
原因: 电影名 'IPZZ-740 ...' (2025) 存在 2 个版本
|
||||
[1] 番号名称
|
||||
ID: e6955cab611255a7518af51492c15c15
|
||||
年份: 2025
|
||||
路径: E:\01_automated\日本电影\...
|
||||
大小: 5.12 GB
|
||||
简介: ...
|
||||
[2] 番号名称(破解版)
|
||||
ID: 224ead2dfa59084b0d9f9080b188f297
|
||||
年份: 2025
|
||||
路径: E:\01_automated\日本电影\...
|
||||
大小: 4.82 GB
|
||||
```
|
||||
|
||||
### 检测结果解读
|
||||
|
||||
| 检测类型 | 含义 | 处理建议 |
|
||||
|---------|------|---------|
|
||||
| **相同名称+年份** | 同一部电影在两个不同文件夹中 | 保留画质较好的版本,删除另一个 |
|
||||
| **相同文件大小+时长** | 同一个文件被多次扫描入库 | 删除 Jellyfin 中重复条目即可,无需删除文件 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 连接失败怎么办?
|
||||
A: 检查 Jellyfin 是否在运行,确认网关 IP 是否正确:
|
||||
```bash
|
||||
ip r | grep default # 查看默认网关
|
||||
```
|
||||
|
||||
### 为什么有些重复没检测到?
|
||||
A: 目前检测逻辑:
|
||||
- 相同番号不同文件名 → 能检测到
|
||||
- 同一文件软链接/硬链接 → 能检测到(文件大小相同时)
|
||||
- 不同画质版本(4K vs 1080p)→ 不会被标记(大小不同)
|
||||
|
||||
### 如何手动删除重复项?
|
||||
A: 在 Jellyfin Web UI 中找到重复电影 → 删除 → 在「文件和元数据」选项中勾选「删除文件」即可(注意别误删)。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- API 配置: `jellyfin-config.md`
|
||||
- 搜索 API: `jellyfin-search-api.md`
|
||||
- 检测脚本: `jellyfin-dedup.py`
|
||||
- 演员统计: `../bt-scripts/jellyfin_actor_stats.py`
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Jellyfin 重复电影检测脚本
|
||||
通过文件名、ProviderIds、文件大小和时长判断重复
|
||||
"""
|
||||
|
||||
import requests
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
SERVER_URL = "http://172.25.208.1:8096"
|
||||
API_KEY = "8cbcfe4d7d294c8aa00412ccc853459f"
|
||||
|
||||
HEADERS = {
|
||||
"X-Emby-Authorization": f'MediaBrowser Client="OpenClaw", Device="PC", DeviceId="openclaw", Version="1.0", Token="{API_KEY}"',
|
||||
"Accept": "application/json"
|
||||
}
|
||||
|
||||
|
||||
def get_all_movies():
|
||||
"""获取所有电影"""
|
||||
movies = []
|
||||
start_index = 0
|
||||
limit = 100
|
||||
|
||||
while True:
|
||||
response = requests.get(
|
||||
f"{SERVER_URL}/Items",
|
||||
params={
|
||||
"includeItemTypes": "Movie",
|
||||
"recursive": "true",
|
||||
"fields": "Path,ProviderIds,MediaSources,RunTimeTicks,Overview,ProductionYear",
|
||||
"limit": limit,
|
||||
"startIndex": start_index,
|
||||
"enableTotalRecordCount": "true"
|
||||
},
|
||||
headers=HEADERS
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"请求失败: {response.status_code} {response.text[:200]}")
|
||||
break
|
||||
|
||||
data = response.json()
|
||||
items = data.get("Items", [])
|
||||
movies.extend(items)
|
||||
|
||||
total = data.get("TotalRecordCount", 0)
|
||||
start_index += limit
|
||||
|
||||
print(f" 已获取 {len(movies)}/{total} 部电影...", end="\r")
|
||||
if start_index >= total:
|
||||
break
|
||||
|
||||
print(f"\n 共获取 {len(movies)} 部电影")
|
||||
return movies
|
||||
|
||||
|
||||
def find_duplicates(movies):
|
||||
"""查找重复电影"""
|
||||
duplicates = []
|
||||
|
||||
# 1. 相同文件名(不同路径)
|
||||
name_map = defaultdict(list)
|
||||
for m in movies:
|
||||
name = (m.get("Name") or "").strip().lower()
|
||||
year = m.get("ProductionYear")
|
||||
key = (name, year)
|
||||
name_map[key].append(m)
|
||||
|
||||
for key, items in name_map.items():
|
||||
if len(items) > 1:
|
||||
duplicates.append({
|
||||
"type": "相同名称+年份",
|
||||
"reason": f"电影名 '{items[0].get('Name')}' ({key[1]}) 存在 {len(items)} 个版本",
|
||||
"items": items
|
||||
})
|
||||
|
||||
# 2. 相同 ProviderIds (IMDb/TMDb)
|
||||
provider_map = defaultdict(list)
|
||||
for m in movies:
|
||||
providers = m.get("ProviderIds") or {}
|
||||
for pid_type in ["Imdb", "Tmdb", "Tvdb"]:
|
||||
pid = providers.get(pid_type)
|
||||
if pid:
|
||||
provider_map[(pid_type, pid)].append(m)
|
||||
|
||||
for key, items in provider_map.items():
|
||||
if len(items) > 1:
|
||||
duplicates.append({
|
||||
"type": "相同 ProviderId",
|
||||
"reason": f"{key[0]} = {key[1]} 存在 {len(items)} 个版本",
|
||||
"items": items
|
||||
})
|
||||
|
||||
# 3. 相同文件大小和时长(不同路径 = 同文件被多次导入)
|
||||
size_map = defaultdict(list)
|
||||
for m in movies:
|
||||
sources = m.get("MediaSources") or []
|
||||
if sources:
|
||||
size = sources[0].get("Size")
|
||||
runtime = m.get("RunTimeTicks")
|
||||
if size and runtime:
|
||||
# 允许 1% 的误差
|
||||
size_key = (size // 1000000, runtime // 1000000)
|
||||
size_map[size_key].append((m, size, runtime))
|
||||
|
||||
for (size_gb, runtime_key), items in size_map.items():
|
||||
if len(items) > 1:
|
||||
# 检查是否来自不同路径
|
||||
paths = set(item[0].get("MediaSources", [{}])[0].get("Path", "") for item in items)
|
||||
if len(paths) == 1:
|
||||
continue # 同一文件,不重复标记
|
||||
duplicates.append({
|
||||
"type": "相同文件大小+时长",
|
||||
"reason": f"约 {size_gb/1000:.1f}GB / 约 {runtime_key//10000:.0f}分钟 存在 {len(items)} 个版本",
|
||||
"items": [item[0] for item in items]
|
||||
})
|
||||
|
||||
return duplicates
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Jellyfin 重复电影检测")
|
||||
print("=" * 60)
|
||||
|
||||
print("\n📦 获取所有电影...")
|
||||
movies = get_all_movies()
|
||||
|
||||
if not movies:
|
||||
print("❌ 未获取到电影数据")
|
||||
return
|
||||
|
||||
print(f"\n🔍 开始检测重复...")
|
||||
all_dups = find_duplicates(movies)
|
||||
|
||||
if not all_dups:
|
||||
print("✅ 未发现重复电影")
|
||||
return
|
||||
|
||||
print(f"\n⚠️ 发现 {len(all_dups)} 组重复\n")
|
||||
print("=" * 60)
|
||||
|
||||
for i, dup in enumerate(all_dups, 1):
|
||||
print(f"\n[{i}] {dup['type']}")
|
||||
print(f" 原因: {dup['reason']}")
|
||||
print(f" {'─' * 50}")
|
||||
|
||||
for j, item in enumerate(dup['items'], 1):
|
||||
name = item.get("Name", "未知")
|
||||
year = item.get("ProductionYear", "未知")
|
||||
sources = item.get("MediaSources") or []
|
||||
path = sources[0].get("Path", "未知路径") if sources else "未知路径"
|
||||
size = sources[0].get("Size", 0) if sources else 0
|
||||
size_str = f"{size / 1024**3:.2f} GB" if size else "未知大小"
|
||||
movie_id = item.get("Id", "未知")
|
||||
overview = (item.get("Overview") or "")[:100]
|
||||
|
||||
print(f" [{j}] {name}")
|
||||
print(f" ID: {movie_id}")
|
||||
print(f" 年份: {year}")
|
||||
print(f" 路径: {path}")
|
||||
print(f" 大小: {size_str}")
|
||||
if overview:
|
||||
print(f" 简介: {overview}")
|
||||
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print(f"总计 {len(all_dups)} 组重复,涉及 {
|
||||
len(set(item.get('Id') for dup in all_dups for item in dup['items']))
|
||||
} 部电影")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
# Jellyfin API 搜索电影
|
||||
|
||||
> 服务器地址和 API Key 见 `jellyfin-config.md`
|
||||
|
||||
## API 端点
|
||||
|
||||
```
|
||||
GET /Items
|
||||
```
|
||||
|
||||
## 搜索参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `searchTerm` | 搜索关键词 |
|
||||
| `userId` | 用户ID |
|
||||
| `limit` | 返回结果数量限制(默认 100) |
|
||||
| `startIndex` | 起始索引 |
|
||||
| `recursive` | 是否递归搜索(默认 true) |
|
||||
| `includeItemTypes` | 项目类型(如 "Movie", "Series", "Episode") |
|
||||
| `sortOrder` | 排序方式("Ascending", "Descending") |
|
||||
| `fields` | 返回额外字段 |
|
||||
| `minYear` / `maxYear` | 按年份筛选 |
|
||||
|
||||
## 请求示例
|
||||
|
||||
### 基础搜索
|
||||
```
|
||||
GET {ServerUrl}/Items?searchTerm=电影名&includeItemTypes=Movie
|
||||
X-Emby-Authorization: MediaBrowser Client="OpenClaw", Device="PC", DeviceId="openclaw", Version="1.0", Token="{ApiKey}"
|
||||
```
|
||||
|
||||
### 分页搜索
|
||||
```
|
||||
GET {ServerUrl}/Items?searchTerm=动作&includeItemTypes=Movie&limit=20&startIndex=0&sortOrder=Descending
|
||||
X-Emby-Authorization: MediaBrowser Client="OpenClaw", Device="PC", DeviceId="openclaw", Version="1.0", Token="{ApiKey}"
|
||||
```
|
||||
|
||||
### 按年份筛选
|
||||
```
|
||||
GET {ServerUrl}/Items?searchTerm=2024&includeItemTypes=Movie&minYear=2024
|
||||
X-Emby-Authorization: MediaBrowser Client="OpenClaw", Device="PC", DeviceId="openclaw", Version="1.0", Token="{ApiKey}"
|
||||
```
|
||||
|
||||
## Python 示例
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
# 从 jellyfin-config.md 读取配置
|
||||
SERVER_URL = "http://127.0.0.1:8096"
|
||||
API_KEY = "8cbcfe4d7d294c8aa00412ccc853459f"
|
||||
|
||||
headers = {
|
||||
"X-Emby-Authorization": f'MediaBrowser Client="OpenClaw", Device="PC", DeviceId="openclaw", Version="1.0", Token="{API_KEY}"'
|
||||
}
|
||||
|
||||
# 搜索电影
|
||||
response = requests.get(
|
||||
f"{SERVER_URL}/Items",
|
||||
params={
|
||||
"searchTerm": "电影名",
|
||||
"includeItemTypes": "Movie",
|
||||
"limit": 20,
|
||||
"startIndex": 0
|
||||
},
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
results = response.json()
|
||||
for item in results.get("Items", []):
|
||||
print(f"标题: {item.get('Name')}")
|
||||
print(f"年份: {item.get('ProductionYear')}")
|
||||
print(f"评分: {item.get('CommunityRating')}")
|
||||
print("-" * 50)
|
||||
else:
|
||||
print(f"错误: {response.status_code}")
|
||||
```
|
||||
|
||||
## 返回字段示例
|
||||
|
||||
```json
|
||||
{
|
||||
"Items": [
|
||||
{
|
||||
"Name": "电影标题",
|
||||
"Id": "项目ID",
|
||||
"Type": "Movie",
|
||||
"ProductionYear": 2024,
|
||||
"CommunityRating": 8.5,
|
||||
"Overview": "剧情简介",
|
||||
"RunTimeTicks": 1234567890,
|
||||
"Genres": ["动作", "剧情"],
|
||||
"MediaSources": [...]
|
||||
}
|
||||
],
|
||||
"TotalRecordCount": 100
|
||||
}
|
||||
```
|
||||
|
||||
## 其他端点
|
||||
|
||||
- `GET /Users/Me/Items` — 当前用户所有内容
|
||||
|
||||
## 相关文档
|
||||
|
||||
- 配置: `jellyfin-config.md`
|
||||
- 重复电影检测: `jellyfin-dedup-howto.md`
|
||||
@@ -0,0 +1,579 @@
|
||||
{
|
||||
"total_persons": 610,
|
||||
"fetched_persons": 610,
|
||||
"threshold": 5,
|
||||
"actors_above_threshold": 95,
|
||||
"generated_at": "2026-05-09T22:14:35+0800",
|
||||
"actors": [
|
||||
{
|
||||
"name": "未知演员",
|
||||
"id": "e8d8f2d40d8b5f8730ab19820f772c2d",
|
||||
"movie_count": 58,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "河北彩花",
|
||||
"id": "b2b54f06f65bab8f67c4a677b4f88c27",
|
||||
"movie_count": 41,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "相泽南",
|
||||
"id": "74091e7a595c9b53eafe0f086b3ba2f3",
|
||||
"movie_count": 35,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "ドラゴン西川",
|
||||
"id": "7749f8835a045965b9bb2f1833881dc8",
|
||||
"movie_count": 29,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "肉尊",
|
||||
"id": "4865cf003c8bc3a6dd09c16ab47edb4b",
|
||||
"movie_count": 23,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "イナバール",
|
||||
"id": "58d800aeb115cdef4a290ed2322a2967",
|
||||
"movie_count": 19,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "前田文豪",
|
||||
"id": "8396709345f1f59a95d1dd14f0f8be18",
|
||||
"movie_count": 19,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "きとるね川口",
|
||||
"id": "26991347bee85f0b0f22cc4cfdaa64a2",
|
||||
"movie_count": 17,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "トレンディ山口",
|
||||
"id": "c99ed475e5b9ed571ef3804a996e8019",
|
||||
"movie_count": 16,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "キョウセイ",
|
||||
"id": "6d45c8b868301a05f3048675b5ad5d4d",
|
||||
"movie_count": 15,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "太宰珍歩",
|
||||
"id": "8a4e8f2c63856bb53d9a8c76bec7288e",
|
||||
"movie_count": 14,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "豆沢豆太郎",
|
||||
"id": "0d2fcf0f22a96d37012c8d652eec8d35",
|
||||
"movie_count": 14,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "Nagisa Futami",
|
||||
"id": "6b4ac3dda1122b1d0a4dc2435482afeb",
|
||||
"movie_count": 12,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "U吉",
|
||||
"id": "c2a59bf040a2900ff8c84ba5a3ec9d91",
|
||||
"movie_count": 12,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "冰青",
|
||||
"id": "1ad24f9af80d41529b93483790684af8",
|
||||
"movie_count": 11,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "嵐山みちる",
|
||||
"id": "8dca498d0cdff0b66101191a31ae69d1",
|
||||
"movie_count": 11,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "赤井彗星",
|
||||
"id": "eb4af3fd6d8c06ec4346b8bdc1203141",
|
||||
"movie_count": 11,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "TAKE-D",
|
||||
"id": "5fa992ab7834ba417465b8e09786336d",
|
||||
"movie_count": 10,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "大崎広浩治",
|
||||
"id": "1946a92abdcf62a7162e2065ab91ccfd",
|
||||
"movie_count": 10,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "明里䌷",
|
||||
"id": "4c5122465ed171c3075bbe284f49ea4a",
|
||||
"movie_count": 10,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "紋℃",
|
||||
"id": "ea5b1ca5d2ec8441db91323d0ba0cd00",
|
||||
"movie_count": 10,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "小凑よつ叶",
|
||||
"id": "9df7145dccdcb1d5e9d07964865c9197",
|
||||
"movie_count": 9,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "枫可怜",
|
||||
"id": "6e1cdf5553d0148055374feb9811efb9",
|
||||
"movie_count": 9,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "Hitoshi Kawamura",
|
||||
"id": "d717eea5994629ce181cf7c1a1ada4a9",
|
||||
"movie_count": 8,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "Nana Kuwata",
|
||||
"id": "87276bbbda6596f72bd4bcab04c3cbee",
|
||||
"movie_count": 8,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "Seiji Sayama",
|
||||
"id": "e6a769c33a3a723cf8486e7a8397fd31",
|
||||
"movie_count": 8,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "Youichi Okazaki",
|
||||
"id": "15d0ba7fc4596211b064f75a2015ae98",
|
||||
"movie_count": 8,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "ZAMPA",
|
||||
"id": "72ea1d908adafd30d65e629d2b051575",
|
||||
"movie_count": 8,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "三島六三郎",
|
||||
"id": "1969bae065e6c481997573444c606515",
|
||||
"movie_count": 8,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "宝瀬博教",
|
||||
"id": "6be0fe443f2497486331269042cbf082",
|
||||
"movie_count": 8,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "宫西ひかる",
|
||||
"id": "86c08f5efca3b4bc0ec7cb5de9be2ebb",
|
||||
"movie_count": 8,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "朝霧浄",
|
||||
"id": "cd7dbed6999cfa5a8ee3687447dd7c3f",
|
||||
"movie_count": 8,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "枫芙爱",
|
||||
"id": "bf31e038dec3ee22796aec84950af70f",
|
||||
"movie_count": 8,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "真咲南朋",
|
||||
"id": "c2f75da3ed433a459ebae6f4f222b46a",
|
||||
"movie_count": 8,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "神宫寺奈绪",
|
||||
"id": "1e24f1a6e8beca735230e50699480480",
|
||||
"movie_count": 8,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "苺原",
|
||||
"id": "aa66875a7316aa8e8cfcbad88e6ebece",
|
||||
"movie_count": 8,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "Tomoaki Ikeda",
|
||||
"id": "c40b40ba269bfa4bcfd0b97ee132eefc",
|
||||
"movie_count": 7,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "ひむろっく",
|
||||
"id": "b8606c4b32ddc0681faaa2876c844771",
|
||||
"movie_count": 7,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "五日市芽依",
|
||||
"id": "e3935226554960fa949725bc31dc26f4",
|
||||
"movie_count": 7,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "宫下玲奈",
|
||||
"id": "2857e08d5ad66d592e76c3add1f995fc",
|
||||
"movie_count": 7,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "小野六花",
|
||||
"id": "a948adec85d112ecb40466d369d49e8f",
|
||||
"movie_count": 7,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "平川大辅",
|
||||
"id": "7859ed30d7138150d42304bedc268bcc",
|
||||
"movie_count": 7,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "樱井浩美",
|
||||
"id": "d96b971ed9c4f0663a9c7879b1bd6ddb",
|
||||
"movie_count": 7,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "池田辉",
|
||||
"id": "7a323cc997f2e6f4e23fb83edb5a3035",
|
||||
"movie_count": 7,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "矢澤レシーブ",
|
||||
"id": "11878b202aeb1162704d60a0b80c1a46",
|
||||
"movie_count": 7,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "石川澪",
|
||||
"id": "4eaa79eb96bcbab0008c87aa4eae8e13",
|
||||
"movie_count": 7,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "花衣つばき",
|
||||
"id": "f6e1711d4067b6be0db9c20121a2427e",
|
||||
"movie_count": 7,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "金松季歩",
|
||||
"id": "f8a91138bf51fb08eded51ace5ea9a85",
|
||||
"movie_count": 7,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "鸣海惠梨香",
|
||||
"id": "04789e0674390fcb20a37be6bcc57948",
|
||||
"movie_count": 7,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "---",
|
||||
"id": "e1f2da1b63f837451696e9c7b8dff198",
|
||||
"movie_count": 6,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "Kiyomu Fukuda",
|
||||
"id": "dd02874bdb910c3f2f659cb23e0aad91",
|
||||
"movie_count": 6,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "らくだ",
|
||||
"id": "acf07aae4c35374e52a4a039c4fcb3f6",
|
||||
"movie_count": 6,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "キャプテン江原",
|
||||
"id": "25c0a67f956f7b781bd0f6152ed515b9",
|
||||
"movie_count": 6,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "七泽美亚",
|
||||
"id": "fe9371e7dcf00746dd33899c50908718",
|
||||
"movie_count": 6,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "七濑爱丽丝",
|
||||
"id": "508812a05991cffcdee3cb3646879e85",
|
||||
"movie_count": 6,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "松本一香",
|
||||
"id": "d51907b545527172aea47ae6e4578e4d",
|
||||
"movie_count": 6,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "永野善一",
|
||||
"id": "802834f8ecef6d4d43214cb86bea8799",
|
||||
"movie_count": 6,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "泉ももか",
|
||||
"id": "5dea4b38727f52d6ba34a6f28c8336e7",
|
||||
"movie_count": 6,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "翼舞",
|
||||
"id": "74da7a1427605ad5e346fc865224be9d",
|
||||
"movie_count": 6,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "香水纯",
|
||||
"id": "adedbb625fb1ca739ed7ddb999b09e27",
|
||||
"movie_count": 6,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "黑川纱里奈",
|
||||
"id": "7591cad07f4cdb1c17d57cd2ad9c8344",
|
||||
"movie_count": 6,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "Daifuku Suginami",
|
||||
"id": "255f6211d3ea974cf51cbcafc8ae7fa5",
|
||||
"movie_count": 5,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "Haruna Kanbayashi",
|
||||
"id": "51b93dbbd1b0f09c66eb6e47e4fddc0e",
|
||||
"movie_count": 5,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "うさぴょん。",
|
||||
"id": "7f2cfc65618362aac4cde35c3e88be39",
|
||||
"movie_count": 5,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "ザック荒井",
|
||||
"id": "449c2953bf24b759fbb6e55645e7e18b",
|
||||
"movie_count": 5,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "ペイペイ",
|
||||
"id": "689f41fa96301c796a3b0855be7f2145",
|
||||
"movie_count": 5,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "七嶋舞",
|
||||
"id": "c0edc7148511a194d690112fc4b291de",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "七森莉莉",
|
||||
"id": "25dfc81b6ba21629859e3c0b7d21c7a9",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "三宫椿",
|
||||
"id": "8171c2b3b26dd7f6a38e90482fbed055",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "五右衛門",
|
||||
"id": "3295cd3dec0b0624eb5f21077b1ef2a1",
|
||||
"movie_count": 5,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "伊藤舞雪",
|
||||
"id": "7c65633ac5495ed4e9121aebada51b67",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "儿玉七海",
|
||||
"id": "2010f14c740ccb1383f2cdba173f1a1f",
|
||||
"movie_count": 5,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "八挂海",
|
||||
"id": "1851a02eee55a9bd797b6917d80dc8fc",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "加州夏",
|
||||
"id": "ff903a622f242dc3c26bacb362a5c16e",
|
||||
"movie_count": 5,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "古川ほのか",
|
||||
"id": "bc6d4ad211f9432f7283b24d64ba5edd",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "坂道美琉",
|
||||
"id": "2cf74e40672241763504ff5a8d43aa0b",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "夢実かなえ",
|
||||
"id": "8a6a473e9703dd49f667be08fe5aba23",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "奥田咲",
|
||||
"id": "33c9d2419470b473d0d743970ad370cc",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "小春日和",
|
||||
"id": "996ad4d19a2bb6dcee4e7eb21c0a14ac",
|
||||
"movie_count": 5,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "小松セブンティーン",
|
||||
"id": "4169d927a0d22f0f5cdfb781655d0a13",
|
||||
"movie_count": 5,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "小野坂ゆいか",
|
||||
"id": "b8f734aadb27a50ce8c1c77cb1b49af5",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "彩月七绪",
|
||||
"id": "75e2d4712457e35cc9dc96fb4f946634",
|
||||
"movie_count": 5,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "新井莉麻",
|
||||
"id": "c5c54c495fefaaebe2d5b360570e6e54",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "桃乃木香奈",
|
||||
"id": "1db1f00e6a326172af09881b6ee6b007",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "桥本有菜",
|
||||
"id": "8413ac2b973aeb70520e71247aa5b75c",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "横山宏美",
|
||||
"id": "cac408ba1de118700181f5981e59ea61",
|
||||
"movie_count": 5,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "樱空桃",
|
||||
"id": "ebd7bec14516fce73f6cb7dd7e235de2",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "浅野こころ",
|
||||
"id": "eec3a2cbcf6d3b00c6b98c8bcaf7f656",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "海老咲あお",
|
||||
"id": "e9ec442fcd9551a5c8fe1c3ffb082a02",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "瑞泽溪",
|
||||
"id": "a60056fe619b9f9edadf2ca3ae8a0ee1",
|
||||
"movie_count": 5,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "磯井啓",
|
||||
"id": "14d2ea40698c14da1b5a79e02632eb5d",
|
||||
"movie_count": 5,
|
||||
"has_image": false
|
||||
},
|
||||
{
|
||||
"name": "美乃雀",
|
||||
"id": "eb3c73722365fdda41eae78ad11b0f05",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "美谷朱音",
|
||||
"id": "964ca30628a2981860a3ed25969286aa",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "辻井穗乃果",
|
||||
"id": "20490dd2356ce2bd16e469cc3357cb21",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
},
|
||||
{
|
||||
"name": "鳳みゆ",
|
||||
"id": "fe7091114820db367fefb7521cdd45c1",
|
||||
"movie_count": 5,
|
||||
"has_image": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
# Jellyfin 演员作品统计
|
||||
|
||||
- **演员总数**:610
|
||||
- **统计阈值**:作品数 **> 5** 部
|
||||
- **满足条件的演员**:**95** 人
|
||||
- **统计时间**:2026-05-09 22:14:35
|
||||
|
||||
---
|
||||
|
||||
| 1 | 未知演员 | 58 | ❌ |
|
||||
| 2 | 河北彩花 | 41 | ✅ |
|
||||
| 3 | 相泽南 | 35 | ✅ |
|
||||
| 4 | ドラゴン西川 | 29 | ❌ |
|
||||
| 5 | 肉尊 | 23 | ❌ |
|
||||
| 6 | イナバール | 19 | ❌ |
|
||||
| 6 | 前田文豪 | 19 | ❌ |
|
||||
| 8 | きとるね川口 | 17 | ❌ |
|
||||
| 9 | トレンディ山口 | 16 | ❌ |
|
||||
| 10 | キョウセイ | 15 | ❌ |
|
||||
| 11 | 太宰珍歩 | 14 | ❌ |
|
||||
| 11 | 豆沢豆太郎 | 14 | ❌ |
|
||||
| 13 | Nagisa Futami | 12 | ❌ |
|
||||
| 13 | U吉 | 12 | ❌ |
|
||||
| 15 | 冰青 | 11 | ❌ |
|
||||
| 15 | 嵐山みちる | 11 | ❌ |
|
||||
| 15 | 赤井彗星 | 11 | ❌ |
|
||||
| 18 | TAKE-D | 10 | ❌ |
|
||||
| 18 | 大崎広浩治 | 10 | ❌ |
|
||||
| 18 | 明里䌷 | 10 | ✅ |
|
||||
| 18 | 紋℃ | 10 | ❌ |
|
||||
| 22 | 小凑よつ叶 | 9 | ✅ |
|
||||
| 22 | 枫可怜 | 9 | ✅ |
|
||||
| 24 | Hitoshi Kawamura | 8 | ❌ |
|
||||
| 24 | Nana Kuwata | 8 | ❌ |
|
||||
| 24 | Seiji Sayama | 8 | ❌ |
|
||||
| 24 | Youichi Okazaki | 8 | ❌ |
|
||||
| 24 | ZAMPA | 8 | ❌ |
|
||||
| 24 | 三島六三郎 | 8 | ❌ |
|
||||
| 24 | 宝瀬博教 | 8 | ❌ |
|
||||
| 24 | 宫西ひかる | 8 | ✅ |
|
||||
| 24 | 朝霧浄 | 8 | ❌ |
|
||||
| 24 | 枫芙爱 | 8 | ✅ |
|
||||
| 24 | 真咲南朋 | 8 | ✅ |
|
||||
| 24 | 神宫寺奈绪 | 8 | ✅ |
|
||||
| 24 | 苺原 | 8 | ❌ |
|
||||
| 37 | Tomoaki Ikeda | 7 | ❌ |
|
||||
| 37 | ひむろっく | 7 | ❌ |
|
||||
| 37 | 五日市芽依 | 7 | ✅ |
|
||||
| 37 | 宫下玲奈 | 7 | ✅ |
|
||||
| 37 | 小野六花 | 7 | ✅ |
|
||||
| 37 | 平川大辅 | 7 | ❌ |
|
||||
| 37 | 樱井浩美 | 7 | ❌ |
|
||||
| 37 | 池田辉 | 7 | ❌ |
|
||||
| 37 | 矢澤レシーブ | 7 | ❌ |
|
||||
| 37 | 石川澪 | 7 | ✅ |
|
||||
| 37 | 花衣つばき | 7 | ✅ |
|
||||
| 37 | 金松季歩 | 7 | ✅ |
|
||||
| 37 | 鸣海惠梨香 | 7 | ❌ |
|
||||
| 50 | --- | 6 | ❌ |
|
||||
| 50 | Kiyomu Fukuda | 6 | ❌ |
|
||||
| 50 | らくだ | 6 | ❌ |
|
||||
| 50 | キャプテン江原 | 6 | ❌ |
|
||||
| 50 | 七泽美亚 | 6 | ✅ |
|
||||
| 50 | 七濑爱丽丝 | 6 | ✅ |
|
||||
| 50 | 松本一香 | 6 | ✅ |
|
||||
| 50 | 永野善一 | 6 | ❌ |
|
||||
| 50 | 泉ももか | 6 | ✅ |
|
||||
| 50 | 翼舞 | 6 | ✅ |
|
||||
| 50 | 香水纯 | 6 | ✅ |
|
||||
| 50 | 黑川纱里奈 | 6 | ✅ |
|
||||
| 62 | Daifuku Suginami | 5 | ❌ |
|
||||
| 62 | Haruna Kanbayashi | 5 | ❌ |
|
||||
| 62 | うさぴょん。 | 5 | ❌ |
|
||||
| 62 | ザック荒井 | 5 | ❌ |
|
||||
| 62 | ペイペイ | 5 | ❌ |
|
||||
| 62 | 七嶋舞 | 5 | ✅ |
|
||||
| 62 | 七森莉莉 | 5 | ✅ |
|
||||
| 62 | 三宫椿 | 5 | ✅ |
|
||||
| 62 | 五右衛門 | 5 | ❌ |
|
||||
| 62 | 伊藤舞雪 | 5 | ✅ |
|
||||
| 62 | 儿玉七海 | 5 | ❌ |
|
||||
| 62 | 八挂海 | 5 | ✅ |
|
||||
| 62 | 加州夏 | 5 | ❌ |
|
||||
| 62 | 古川ほのか | 5 | ✅ |
|
||||
| 62 | 坂道美琉 | 5 | ✅ |
|
||||
| 62 | 夢実かなえ | 5 | ✅ |
|
||||
| 62 | 奥田咲 | 5 | ✅ |
|
||||
| 62 | 小春日和 | 5 | ❌ |
|
||||
| 62 | 小松セブンティーン | 5 | ❌ |
|
||||
| 62 | 小野坂ゆいか | 5 | ✅ |
|
||||
| 62 | 彩月七绪 | 5 | ❌ |
|
||||
| 62 | 新井莉麻 | 5 | ✅ |
|
||||
| 62 | 桃乃木香奈 | 5 | ✅ |
|
||||
| 62 | 桥本有菜 | 5 | ✅ |
|
||||
| 62 | 横山宏美 | 5 | ❌ |
|
||||
| 62 | 樱空桃 | 5 | ✅ |
|
||||
| 62 | 浅野こころ | 5 | ✅ |
|
||||
| 62 | 海老咲あお | 5 | ✅ |
|
||||
| 62 | 瑞泽溪 | 5 | ❌ |
|
||||
| 62 | 磯井啓 | 5 | ❌ |
|
||||
| 62 | 美乃雀 | 5 | ✅ |
|
||||
| 62 | 美谷朱音 | 5 | ✅ |
|
||||
| 62 | 辻井穗乃果 | 5 | ✅ |
|
||||
| 62 | 鳳みゆ | 5 | ✅ |
|
||||
|
||||
|
||||
---
|
||||
|
||||
*由脚本 `jellyfin_actor_stats.py` 自动生成*
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
统计 Jellyfin 中每个演员出演的作品数量,找出超过指定阈值的演员。
|
||||
结果保存为 JSON 和 Markdown 文档。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
# ====== 配置 ======
|
||||
JELLYFIN_BASE = "http://192.168.3.5:8096"
|
||||
AUTH_HEADER = 'MediaBrowser Client="OpenClaw", Device="PC", DeviceId="openclaw", Version="1.0", Token="8cbcfe4d7d294c8aa00412ccc853459f"'
|
||||
USER_ID = "0f2540701e884852a2d2a1b7e63299e8"
|
||||
MIN_MOVIES = 5
|
||||
OUTPUT_DIR = "/mnt/f/docs"
|
||||
|
||||
# 需要检查的媒体类型
|
||||
MEDIA_TYPES = "Movie,Series,Episode,Video"
|
||||
|
||||
|
||||
def api_get(path, params=None):
|
||||
"""通用 GET 请求"""
|
||||
url = JELLYFIN_BASE + path
|
||||
if params:
|
||||
parts = []
|
||||
for k, v in params.items():
|
||||
if isinstance(v, bool):
|
||||
v = "true" if v else "false"
|
||||
elif isinstance(v, int):
|
||||
v = str(v)
|
||||
parts.append(f"{k}={urllib.request.quote(str(v))}")
|
||||
url += "?" + "&".join(parts)
|
||||
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("X-Emby-Authorization", AUTH_HEADER)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode(errors="replace") if e.fp else ""
|
||||
print(f" HTTP {e.code}: {e.reason} | {body[:200]}", file=sys.stderr)
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f" Error: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
print("=== 获取所有演员 ===", file=sys.stderr)
|
||||
|
||||
# Persons API 无 limit 时返回全部
|
||||
data = api_get("/Persons")
|
||||
if not data:
|
||||
print("ERROR: 无法获取演员列表", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
persons = data.get("Items", [])
|
||||
total_count = data.get("TotalRecordCount", len(persons))
|
||||
print(f"总计 {total_count} 个演员,获取到 {len(persons)} 条", file=sys.stderr)
|
||||
|
||||
results = []
|
||||
total = len(persons)
|
||||
|
||||
for i, p in enumerate(persons, 1):
|
||||
name = p["Name"]
|
||||
pid = p["Id"]
|
||||
has_image = bool(p.get("ImageTags", {}))
|
||||
|
||||
# 查询该演员出演的媒体数量
|
||||
movie_data = api_get(f"/Users/{USER_ID}/Items", {
|
||||
"personIds": pid,
|
||||
"recursive": True,
|
||||
"includeItemTypes": MEDIA_TYPES,
|
||||
"limit": 0,
|
||||
})
|
||||
|
||||
count = movie_data.get("TotalRecordCount", 0) if movie_data else 0
|
||||
|
||||
if count >= MIN_MOVIES:
|
||||
results.append({
|
||||
"name": name,
|
||||
"id": pid,
|
||||
"movie_count": count,
|
||||
"has_image": has_image,
|
||||
})
|
||||
|
||||
if i % 30 == 0 or i == total:
|
||||
print(f" 进度: {i}/{total} | 已找到 {len(results)} 人超过 {MIN_MOVIES} 部", file=sys.stderr)
|
||||
time.sleep(0.03)
|
||||
|
||||
# 排序:作品数降序 → 姓名升序
|
||||
results.sort(key=lambda x: (-x["movie_count"], x["name"]))
|
||||
|
||||
# ====== JSON ======
|
||||
json_path = os.path.join(OUTPUT_DIR, "jellyfin_actor_stats.json")
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"total_persons": total_count,
|
||||
"fetched_persons": len(persons),
|
||||
"threshold": MIN_MOVIES,
|
||||
"actors_above_threshold": len(results),
|
||||
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
"actors": results,
|
||||
}, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n✅ JSON: {json_path}", file=sys.stderr)
|
||||
|
||||
# ====== Markdown ======
|
||||
md_path = os.path.join(OUTPUT_DIR, "jellyfin_actor_stats.md")
|
||||
with open(md_path, "w", encoding="utf-8") as f:
|
||||
f.write("# Jellyfin 演员作品统计\n\n")
|
||||
f.write(f"- **演员总数**:{total_count}\n")
|
||||
f.write(f"- **统计阈值**:作品数 **> {MIN_MOVIES}** 部\n")
|
||||
f.write(f"- **满足条件的演员**:**{len(results)}** 人\n")
|
||||
f.write(f"- **统计时间**:{time.strftime('%Y-%m-%d %H:%M:%S')}\n\n")
|
||||
f.write("---\n\n")
|
||||
|
||||
# 用序号代表排名
|
||||
rank = 0
|
||||
prev = None
|
||||
same_rank_start = 0
|
||||
for idx, a in enumerate(results):
|
||||
if a["movie_count"] != prev:
|
||||
rank = idx + 1
|
||||
prev = a["movie_count"]
|
||||
img = "✅" if a["has_image"] else "❌"
|
||||
f.write(f"| {rank} | {a['name']} | {a['movie_count']} | {img} |\n")
|
||||
|
||||
f.write("\n\n---\n\n")
|
||||
f.write("*由脚本 `jellyfin_actor_stats.py` 自动生成*\n")
|
||||
|
||||
print(f"✅ Markdown: {md_path}", file=sys.stderr)
|
||||
|
||||
print(f"\n=== 结果摘要 ===", file=sys.stderr)
|
||||
print(f"演员总数: {total_count}", file=sys.stderr)
|
||||
print(f"超过 {MIN_MOVIES} 部: {len(results)} 人", file=sys.stderr)
|
||||
|
||||
top_n = min(20, len(results))
|
||||
print(f"\n--- Top {top_n} ---", file=sys.stderr)
|
||||
for a in results[:top_n]:
|
||||
img = "🖼️" if a["has_image"] else " "
|
||||
print(f" #{rank:3d} {a['name']:30s} {a['movie_count']:4d} 部 {img}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": 1,
|
||||
"bootstrapSeededAt": "2026-04-01T14:14:24.944Z",
|
||||
"setupCompletedAt": "2026-05-24T13:01:35.572Z"
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
---
|
||||
name: "calibre-cleaner"
|
||||
description: "清洗 Calibre 书库:修复损坏书名/提取作者/检测编码损坏、小文件的批量删除"
|
||||
---
|
||||
|
||||
# Calibre 书库清洗 Skill
|
||||
|
||||
## 概述
|
||||
|
||||
Calibre 书库整理三件套:
|
||||
|
||||
1. **书名清洗** —— 修复从 txt 导入产生的 HTML 标签/长文本/乱码书名,截取前 20 个中文字符,移除网站链接
|
||||
2. **作者提取** —— 从书名或原始文件名 `data.name` 中自动提取作者名(匹配 `作者:xxx`、`-xxx`、`BY xxx` 等格式)
|
||||
3. **脏数据清理** —— 检测并删除内容损坏的 txt(中文 < 10%)、小于 10KB 的垃圾文件、Python 列表格式垃圾标题
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 书库路径:`/home/yangxuan/calibre/books`
|
||||
- 元数据文件:`/home/yangxuan/calibre/books/metadata.db`
|
||||
- 运行前须停止其他 Calibre 进程(桌面版、Docker Calibre-Web)
|
||||
- 运行前 **必须备份** `metadata.db`
|
||||
|
||||
---
|
||||
|
||||
## 一、书名清洗 & 作者提取
|
||||
|
||||
### `shorten(s, max_bytes=120)`
|
||||
|
||||
将字符串截断到指定字节数,避免因 UTF-8 中文字符跨字节而损坏。
|
||||
|
||||
```python
|
||||
def shorten(s, max_bytes=120):
|
||||
encoded = s.encode('utf-8')
|
||||
if len(encoded) <= max_bytes:
|
||||
return s
|
||||
try:
|
||||
return encoded[:max_bytes].decode('utf-8')
|
||||
except:
|
||||
return encoded[:max_bytes-3].decode('utf-8', errors='ignore')
|
||||
```
|
||||
|
||||
### `clean_title(title)`
|
||||
|
||||
```python
|
||||
def clean_title(title):
|
||||
t = title
|
||||
# 移除 HTML 标签
|
||||
t = re.sub(r'<[^>]+>', '', t)
|
||||
# 替换 _BR_ / _BR 标记
|
||||
t = re.sub(r'_BR_', ' ', t, flags=re.IGNORECASE)
|
||||
t = re.sub(r'_BR(?![a-zA-Z])', ' ', t)
|
||||
# 移除 _fontxxx_ / _imgxxx_ 标记
|
||||
t = re.sub(r'_font[^_]*_', '', t)
|
||||
t = re.sub(r'_img[^_]*_', '', t)
|
||||
# 移除网站链接
|
||||
t = re.sub(r'[\((]www\.[^))]+[\))]', '', t)
|
||||
t = re.sub(r'^[a-zA-Z0-9.-]+\.com@', '', t)
|
||||
t = re.sub(r'[-–—][a-zA-Z0-9.-]+\.com$', '', t)
|
||||
t = re.sub(r'【[^】]*?(?:下载|网站|书城|声明|提供)[^】]*】', '', t)
|
||||
t = re.sub(r'[::].*?(?:本文由|本电子书).*$', '', t)
|
||||
t = re.sub(r'[//]\s*www\.[^\s]+.*$', '', t)
|
||||
t = re.sub(r'[,,。]?https?://[^\s\))\]]+', '', t)
|
||||
# 移除声明文字当书名
|
||||
t = re.sub(r'^内容简介[】】]?', '', t)
|
||||
# 移除作者/翻译标记(批量清洗时用)
|
||||
t = re.sub(r'[ \s]*?(?:作者|翻译|原?作者)[::].*$', '', t, flags=re.DOTALL)
|
||||
# 移除 Unknown/未知 尾巴
|
||||
t = re.sub(r'\s*-\s*(Unknown|未知)\s*$', '', t)
|
||||
# 合并多余空格
|
||||
t = re.sub(r'\s+', ' ', t).strip()
|
||||
# 截取前 20 个中文字符(保留非中文直到第 20 个中文出现)
|
||||
chinese_count = 0
|
||||
pos = 0
|
||||
for i, c in enumerate(t):
|
||||
if '\u4e00' <= c <= '\u9fff':
|
||||
chinese_count += 1
|
||||
if chinese_count == 20:
|
||||
pos = i + 1
|
||||
break
|
||||
if chinese_count > 20:
|
||||
t = t[:pos]
|
||||
# 超长截短
|
||||
if len(t) > 100:
|
||||
m = re.search(r'[\u4e00-\u9fff]{2,}', t)
|
||||
if m:
|
||||
t = t[m.start():]
|
||||
t = t[:60].rstrip(',。;:、!?…\ufffd\'"').rstrip('.,;:!?\'').strip()
|
||||
# 移除控制字符
|
||||
t = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', t)
|
||||
return shorten(t, 100).strip() or 'Unknown'
|
||||
```
|
||||
|
||||
### 作者提取规则(从文件中同时匹配 title 和 data.name)
|
||||
|
||||
优先从 `data.name`(原始文件名)提取,更准确:
|
||||
|
||||
```python
|
||||
EXCLUDE_AUTHORS = {'正文', '内容简介', ''}
|
||||
|
||||
def extract_author_from_name(file_name):
|
||||
"""从 data.name(原始文件名)提取作者"""
|
||||
name = file_name
|
||||
# 去掉尾巴
|
||||
name = re.sub(r'\s*-\s*(Unknown|未知)\s*$', '', name)
|
||||
# 匹配作者:xxx
|
||||
m = re.search(r'作者[::]\s*([^\s_)\)《\[【\]】\-,]+)', name)
|
||||
if m:
|
||||
candidate = m.group(1).strip()
|
||||
if candidate not in EXCLUDE_AUTHORS and len(candidate) < 30:
|
||||
return candidate
|
||||
# 匹配翻译:xxx
|
||||
m = re.search(r'翻译[::]\s*([^\s_)\)《\[【\]】\-,]+)', name)
|
||||
if m:
|
||||
candidate = m.group(1).strip()
|
||||
if len(candidate) < 20:
|
||||
return candidate
|
||||
# 匹配原作者:xxx
|
||||
m = re.search(r'原?作者[::]\s*([^\s_)\)《\[【\]】\-,]+)', name)
|
||||
if m:
|
||||
candidate = m.group(1).strip()
|
||||
if len(candidate) < 30:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def extract_author_from_title(title):
|
||||
"""从 title 提取作者(降级方案,仅在 data.name 无用时)"""
|
||||
m = re.search(r'作者[::]\s*([^\s,,、\))《\[]+)', title)
|
||||
if m:
|
||||
name = m.group(1).strip()
|
||||
if name not in EXCLUDE_AUTHORS and not re.match(r'^第\d+[章节]', name):
|
||||
return name
|
||||
m = re.search(r'翻译[::]\s*([^\s,,、\))《\[]+)', title)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
m = re.search(r'\b[Bb][Yy]\s+([^\s,,、\))《\[]+)', title)
|
||||
if m:
|
||||
name = m.group(1).strip()
|
||||
if name not in EXCLUDE_AUTHORS:
|
||||
return name
|
||||
m = re.search(r'-([^-()【】\[\]{}《》\s]{2,30})$', title)
|
||||
if m:
|
||||
name = m.group(1).strip()
|
||||
if name not in EXCLUDE_AUTHORS and not re.match(r'^\d+', name):
|
||||
return name
|
||||
m = re.search(r'&([^&()【】\[\]\s]{2,30})$', title)
|
||||
if m:
|
||||
name = m.group(1).strip()
|
||||
if name not in EXCLUDE_AUTHORS:
|
||||
return name
|
||||
return None
|
||||
```
|
||||
|
||||
### 坏书检测条件
|
||||
|
||||
```python
|
||||
is_bad = (
|
||||
len(title) > 100 or
|
||||
'<img' in title or '<BR' in title or '<font' in title or
|
||||
'\ufffd' in title or
|
||||
len(title.encode('utf-8')) > 150
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、脏数据清理
|
||||
|
||||
### 2.1 检测条件
|
||||
|
||||
扫描书库中所有 TXT 文件,读取前 20KB 检测中文比例:
|
||||
|
||||
- **内容损坏/二进制垃圾**:中文比例 < 10%(标记为待删除)
|
||||
- **小文件垃圾**:`uncompressed_size < 10240`(10KB 以下)
|
||||
- **垃圾格式标题**:Python 列表格式(含 `'xxx', 'R-18'` 单引号列表)、转义字符(`\n`、`\u3000`、`id=`)、HTML 实体(`raquo;`、`laquo;`)
|
||||
|
||||
```python
|
||||
def is_garbage_format_title(title):
|
||||
"""检测标题是否为垃圾格式"""
|
||||
import re
|
||||
if re.search(r"'.*',\s*'R-18'", title):
|
||||
return True
|
||||
if re.search(r'[\\]u[0-9a-fA-F]{4}', title):
|
||||
return True
|
||||
if re.search(r'id=|pid=', title):
|
||||
return True
|
||||
if 'raquo;' in title or 'laquo;' in title:
|
||||
return True
|
||||
if '【内容简介】' in title or '内容简介】' in title:
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_dirty_content(full_path):
|
||||
"""检测文件内容是否为垃圾"""
|
||||
if not os.path.exists(full_path):
|
||||
return True # 文件丢失也算脏
|
||||
with open(full_path, 'rb') as f:
|
||||
raw = f.read(20480)
|
||||
text = raw.decode('utf-8', errors='replace')
|
||||
chinese = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
|
||||
ratio = chinese / len(text) if len(text) > 0 else 0
|
||||
return ratio < 0.10
|
||||
```
|
||||
|
||||
### 2.2 批量删除(通过 SQLite 直写)
|
||||
|
||||
停掉 calibre 进程后,通过 SQLite 删除记录并清理文件系统:
|
||||
|
||||
```sql
|
||||
DELETE FROM data WHERE book = ?
|
||||
DELETE FROM books_authors_link WHERE book = ?
|
||||
DELETE FROM books_languages_link WHERE book = ?
|
||||
DELETE FROM books_publishers_link WHERE book = ?
|
||||
DELETE FROM books_series_link WHERE book = ?
|
||||
DELETE FROM books_tags_link WHERE book = ?
|
||||
DELETE FROM comments WHERE book = ?
|
||||
DELETE FROM identifiers WHERE book = ?
|
||||
DELETE FROM books WHERE id = ?
|
||||
```
|
||||
|
||||
同时删除对应目录:`shutil.rmtree(os.path.join('/home/yangxuan/calibre/books', bpath))`
|
||||
|
||||
### 2.3 完整删除脚本
|
||||
|
||||
```python
|
||||
import sqlite3, os, shutil
|
||||
|
||||
db_path = '/home/yangxuan/calibre/books/metadata.db'
|
||||
conn = sqlite3.connect(db_path)
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute('''
|
||||
SELECT b.id, b.title, b.path, d.name, d.uncompressed_size
|
||||
FROM books b
|
||||
JOIN data d ON d.book = b.id
|
||||
WHERE d.format = 'TXT'
|
||||
''')
|
||||
rows = c.fetchall()
|
||||
|
||||
to_delete = []
|
||||
|
||||
for bid, title, bpath, fname, size in rows:
|
||||
full_path = os.path.join('/home/yangxuan/calibre/books', bpath, fname + '.txt')
|
||||
|
||||
# 条件0:垃圾格式标题(Python列表、转义字符等)
|
||||
if is_garbage_format_title(title):
|
||||
to_delete.append((bid, bpath))
|
||||
continue
|
||||
|
||||
# 条件1:小文件垃圾
|
||||
if size < 10240:
|
||||
to_delete.append((bid, bpath))
|
||||
continue
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
continue
|
||||
|
||||
# 条件2:内容损坏(中文 < 10%)
|
||||
if is_dirty_content(full_path):
|
||||
to_delete.append((bid, bpath))
|
||||
|
||||
print(f"待删除: {len(to_delete)}", flush=True)
|
||||
for bid, bpath in to_delete:
|
||||
for table in ['data', 'books_authors_link', 'books_languages_link',
|
||||
'books_publishers_link', 'books_series_link', 'books_tags_link',
|
||||
'comments', 'identifiers']:
|
||||
c.execute(f'DELETE FROM {table} WHERE book=?', (bid,))
|
||||
c.execute('DELETE FROM books WHERE id=?', (bid,))
|
||||
full_dir = os.path.join('/home/yangxuan/calibre/books', bpath)
|
||||
if os.path.exists(full_dir):
|
||||
shutil.rmtree(full_dir)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"完成,删除: {len(to_delete)}", flush=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、书名批量清洗(作者标记去除 + 前 20 中文截取)
|
||||
|
||||
对于书名中含 `作者:xxx`、`翻译:xxx` 标记但作者字段已有值的书,使用此脚本批量清洗:
|
||||
|
||||
```python
|
||||
import sqlite3, re
|
||||
|
||||
db_path = '/home/yangxuan/calibre/books/metadata.db'
|
||||
conn = sqlite3.connect(db_path)
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute('''
|
||||
SELECT b.id, b.title, a.name as author_name, d.name as file_name
|
||||
FROM books b
|
||||
JOIN data d ON d.book = b.id AND d.format = 'TXT'
|
||||
JOIN books_authors_link l ON b.id = l.book
|
||||
JOIN authors a ON l.author = a.id
|
||||
WHERE (b.title LIKE '%作者:%' OR b.title LIKE '%作者:%' OR b.title LIKE '%翻译:%' OR b.title LIKE '%翻译:%')
|
||||
AND a.name NOT IN ('未知', 'Unknown')
|
||||
ORDER BY b.id
|
||||
''')
|
||||
rows = c.fetchall()
|
||||
|
||||
for bid, title, author_name, file_name in rows:
|
||||
# 1. 从 data.name 提取作者并更新 author 字段(如不同)
|
||||
name_clean = re.sub(r'\s*-\s*(Unknown|未知)\s*$', '', file_name)
|
||||
m = re.search(r'[ \s]*?(?:作者|翻译|原?作者)[::]\s*([^\s_)\)《\[【\]】\-,]+)', name_clean)
|
||||
if m:
|
||||
extracted = m.group(1).strip()
|
||||
if extracted and extracted != author_name and len(extracted) < 30:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id FROM authors WHERE name=?", (extracted,))
|
||||
ar = cur.fetchone()
|
||||
if ar:
|
||||
aid = ar[0]
|
||||
else:
|
||||
cur.execute("INSERT INTO authors (name, sort) VALUES (?, ?)", (extracted, extracted.replace(',', '').strip()))
|
||||
aid = cur.lastrowid
|
||||
cur.execute("UPDATE books_authors_link SET author=? WHERE book=?", (aid, bid))
|
||||
cur.close()
|
||||
|
||||
# 2. 裁剪书名:去标记 + 前 20 中文
|
||||
clean_title = re.sub(r'[ \s]*?(?:作者|翻译|原?作者)[::].*$', '', file_name, flags=re.DOTALL)
|
||||
clean_title = re.sub(r'\s*-\s*(Unknown|未知)\s*$', '', clean_title).strip()
|
||||
|
||||
if clean_title:
|
||||
count = 0
|
||||
pos = 0
|
||||
for i, ch in enumerate(clean_title):
|
||||
if '\u4e00' <= ch <= '\u9fff':
|
||||
count += 1
|
||||
if count == 20:
|
||||
pos = i + 1
|
||||
break
|
||||
if count > 20:
|
||||
clean_title = clean_title[:pos]
|
||||
|
||||
if clean_title != title:
|
||||
c.execute("UPDATE books SET title=? WHERE id=?", (clean_title, bid))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、常用查询
|
||||
|
||||
### 4.1 查看 XX 作者的书
|
||||
|
||||
```bash
|
||||
sqlite3 /home/yangxuan/calibre/books/metadata.db "
|
||||
SELECT b.id, b.title
|
||||
FROM books b JOIN books_authors_link l ON b.id = l.book
|
||||
JOIN authors a ON l.author = a.id
|
||||
WHERE a.name = 'Unknown'
|
||||
LIMIT 20;
|
||||
"
|
||||
```
|
||||
|
||||
### 4.2 统计作者分布
|
||||
|
||||
```bash
|
||||
sqlite3 /home/yangxuan/calibre/books/metadata.db "
|
||||
SELECT a.name, COUNT(*) as cnt
|
||||
FROM books b JOIN books_authors_link l ON b.id = l.book
|
||||
JOIN authors a ON l.author = a.id
|
||||
GROUP BY a.name ORDER BY cnt DESC LIMIT 20;
|
||||
"
|
||||
```
|
||||
|
||||
### 4.3 书名异常检测
|
||||
|
||||
```bash
|
||||
sqlite3 /home/yangxuan/calibre/books/metadata.db "
|
||||
SELECT id, title FROM books WHERE
|
||||
title LIKE '%作者%' OR title LIKE '%翻译%' OR
|
||||
title LIKE '%.com%' OR title LIKE '%http%' OR
|
||||
title LIKE '%内容简介%' OR
|
||||
LENGTH(title) > 80;
|
||||
"
|
||||
```
|
||||
|
||||
### 4.4 书库统计
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('/home/yangxuan/calibre/books/metadata.db')
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT COUNT(*) FROM books')
|
||||
print(f'总书籍: {c.fetchone()[0]}')
|
||||
c.execute('SELECT COUNT(*) FROM data WHERE format=\"TXT\"')
|
||||
print(f'TXT 文件: {c.fetchone()[0]}')
|
||||
conn.close()
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、操作流程(推荐顺序)
|
||||
|
||||
```bash
|
||||
cd /home/yangxuan/calibre
|
||||
|
||||
# 1. 停服务 + 备份
|
||||
docker compose down
|
||||
cp books/metadata.db books/metadata.db.backup.$(date +%Y%m%d)
|
||||
killall calibre 2>/dev/null
|
||||
|
||||
# 2. 脏数据清理(内容损坏 + 小文件 + 垃圾格式标题)
|
||||
python3 /path/to/clean_dirty_data.py
|
||||
|
||||
# 3. 书名清洗 & 作者提取(含 data.name 级别提取)
|
||||
calibre-debug /path/to/fix_titles.py
|
||||
|
||||
# 4. 检查是否有遗漏的异常书籍
|
||||
sqlite3 books/metadata.db \"SELECT id, title FROM books WHERE title LIKE '%作者%' OR title LIKE '%翻译%' OR title LIKE '%.com%';\" | head -10
|
||||
|
||||
# 5. 重启
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、已知问题
|
||||
|
||||
### `set_metadata` 报 `Errno 36 文件名过长`
|
||||
|
||||
当 `author_sort` 字段超 80 字节时,Calibre 路径会触及 ext4 255 字节限制。先清理极长作者名:
|
||||
|
||||
```bash
|
||||
sqlite3 /home/yangxuan/calibre/books/metadata.db "
|
||||
UPDATE authors SET name='Unknown' WHERE LENGTH(name) > 30;
|
||||
"
|
||||
```
|
||||
|
||||
### WSL `/mnt/f` 删除 NTFS 文件权限不够
|
||||
|
||||
WSL 的 drvfs 默认不带 metadata 选项,`sudo rm`、`os.remove`、`cmd.exe /c del` 均无法删除 NTFS 文件。不要在 WSL 下尝试跨分区删除操作,直接让 Windows 侧(文件资源管理器/PowerShell)操作。
|
||||
|
||||
### 书名长度截取规则
|
||||
|
||||
`title` 字段清洗后最多保留 20 个中文字符。计数规则:只计 Unicode 范围的 `\u4e00-\u9fff` 中文字符,保留第 20 个中文之前的所有字符(含非中文)。如书名不足 20 个中文则全保留。
|
||||
|
||||
### `title` vs `data.name` 区别
|
||||
|
||||
- `books.title`:Calibre 界面显示的书名(清洗后的)
|
||||
- `data.name`:导入时的原始文件名(不含 `.txt`)
|
||||
- 作者提取优先使用 `data.name`,其文件名保留原始信息、更可靠
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: "f-download-sorter"
|
||||
description: "整理 /mnt/f 下下载的视频文件:扫描→清垃圾→按规则归档到7个大类目录"
|
||||
---
|
||||
|
||||
# /mnt/f 下载文件批量归档整理
|
||||
|
||||
## 用途
|
||||
把 source_dirs 里堆叠的下载视频,按规则自动清理、归类到大类目录。
|
||||
|
||||
## Touch point
|
||||
用户说"整理 /mnt/f 视频""归档 collections""下载文件太多理一下"等,调用此 skill。
|
||||
|
||||
## 流程
|
||||
1. 运行 `scripts/scan_and_clean.py` → 扫描 + 清垃圾
|
||||
2. 运行 `scripts/classify_and_move.py` → 按规则归档
|
||||
3. 报告结果
|
||||
|
||||
## 配置
|
||||
核心配置在 `config.json`,可自定义:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `source_dirs` | 源目录列表,默认 `["/mnt/f/collections", "/mnt/f/video"]` |
|
||||
| `keep_as_is` | 已有元数据不动的内容 |
|
||||
| `min_size_mb` | 小于此值且无实质视频的目录自动删除 |
|
||||
| `categories` | 分类规则数组,每个有 `dir`(目标路径)和 `keywords`(关键词列表) |
|
||||
| `fallback_dir` | 无关键词匹配时的兜底目录 |
|
||||
|
||||
### 添加新分类
|
||||
在 `categories` 数组中新增一项:
|
||||
```json
|
||||
{
|
||||
"dir": "/mnt/f/新分类",
|
||||
"keywords": ["关键词A", "关键词B"]
|
||||
}
|
||||
```
|
||||
|
||||
### 添加新源目录
|
||||
在 `source_dirs` 中追加路径。
|
||||
|
||||
### 保护不移动的内容
|
||||
在 `keep_as_is` 列表追加名称。
|
||||
|
||||
## 注意事项
|
||||
- 同一分区 mv,不复制数据
|
||||
- 不预览文件内容,只基于文件名关键词
|
||||
- WSL `/mnt/` 跨文件系统扫描慢,大数量时建议用 Windows 原生 Python 执行
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"source_dirs": [
|
||||
"/mnt/f/collections",
|
||||
"/mnt/f/video"
|
||||
],
|
||||
"keep_as_is": ["重生之征服郭伯母"],
|
||||
"min_size_mb": 50,
|
||||
"categories": [
|
||||
{
|
||||
"dir": "/mnt/f/过那条巷子",
|
||||
"keywords": ["过那条巷子"]
|
||||
},
|
||||
{
|
||||
"dir": "/mnt/f/2048合集",
|
||||
"keywords": ["2048"]
|
||||
},
|
||||
{
|
||||
"dir": "/mnt/f/AI短剧",
|
||||
"keywords": ["AI短剧", "AI自制", "AI视频", "美丽新世界", "少年阿宾", "NTR寝取", "嫂子", "魔女小姨子", "桃运圣医", "我和老师的约定"]
|
||||
},
|
||||
{
|
||||
"dir": "/mnt/f/FC2福利姬",
|
||||
"keywords": ["FC2", "Comatozze", "Dainty", "KAKAE", "lananlanan", "P站"]
|
||||
},
|
||||
{
|
||||
"dir": "/mnt/f/合集种子",
|
||||
"keywords": ["BT-btt"]
|
||||
},
|
||||
{
|
||||
"dir": "/mnt/f/国产露脸",
|
||||
"keywords": ["19岁", "冯帆", "momocats", "露脸才是王道", "香蕉"]
|
||||
},
|
||||
{
|
||||
"dir": "/mnt/f/自拍泄漏",
|
||||
"keywords": ["泄密", "自拍", "自慰", "原相机", "情侣", "校花", "嫩妹", "福利姬", "裸聊", "绿帽", "良家", "约炮", "狗哥", "土豪", "原档", "维度", "桃子", "娇娇", "学妹", "反差"]
|
||||
}
|
||||
],
|
||||
"fallback_dir": "/mnt/f/AI短剧"
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
按规则分类归档 source_dirs 中的目录到 categories 定义的大类目录
|
||||
读配置文件:与脚本同目录下的 config.json
|
||||
"""
|
||||
import os, json, shutil
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
def load_config():
|
||||
cfg_path = os.path.join(SCRIPT_DIR, "..", "config.json")
|
||||
with open(cfg_path) as f:
|
||||
return json.load(f)
|
||||
|
||||
def classify(name, cfg):
|
||||
"""返回目标目录路径,或 None(不移动)"""
|
||||
if name in cfg.get("keep_as_is", []):
|
||||
return None
|
||||
for cat in cfg["categories"]:
|
||||
keywords = cat["keywords"]
|
||||
if any(k in name for k in keywords):
|
||||
return cat["dir"]
|
||||
fallback = cfg.get("fallback_dir")
|
||||
if fallback:
|
||||
return fallback
|
||||
return None
|
||||
|
||||
def main():
|
||||
cfg = load_config()
|
||||
src_dirs = cfg["source_dirs"]
|
||||
|
||||
moves = []
|
||||
for base in src_dirs:
|
||||
if not os.path.isdir(base):
|
||||
print(f" [WARN] 目录不存在: {base}")
|
||||
continue
|
||||
for entry in sorted(os.listdir(base)):
|
||||
full = os.path.join(base, entry)
|
||||
if not os.path.isdir(full):
|
||||
continue
|
||||
dst_base = classify(entry, cfg)
|
||||
if dst_base is None:
|
||||
print(f" ○ 不动: {entry}")
|
||||
continue
|
||||
dst = os.path.join(dst_base, entry)
|
||||
if os.path.exists(dst):
|
||||
i = 1
|
||||
while os.path.exists(f"{dst}_{i}"): i += 1
|
||||
dst = f"{dst}_{i}"
|
||||
moves.append((full, dst))
|
||||
|
||||
# 创建目录
|
||||
for _, d in moves:
|
||||
os.makedirs(os.path.dirname(d), exist_ok=True)
|
||||
|
||||
# 执行移动
|
||||
print(f"开始移动 {len(moves)} 个条目...")
|
||||
for src, dst in moves:
|
||||
try:
|
||||
shutil.move(src, dst)
|
||||
print(f" ✓ {os.path.basename(src)}")
|
||||
except Exception as e:
|
||||
print(f" ✗ {os.path.basename(src)} -> {e}")
|
||||
|
||||
# 清理空源目录
|
||||
for d in src_dirs:
|
||||
if os.path.isdir(d) and not os.listdir(d):
|
||||
os.rmdir(d)
|
||||
print(f" ✓ 已删空目录: {d}")
|
||||
|
||||
# 报告
|
||||
base = os.path.commonpath([os.path.dirname(d) for _, d in moves])
|
||||
for _, d in moves:
|
||||
base = os.path.commonpath([base, os.path.dirname(d)])
|
||||
base = base or "/"
|
||||
|
||||
print(f"\n=== 归档完成 ===")
|
||||
seen = set()
|
||||
for _, d in moves:
|
||||
parent = os.path.dirname(d)
|
||||
if parent not in seen:
|
||||
sz = 0
|
||||
cnt = 0
|
||||
for dp, dn, fn in os.walk(parent):
|
||||
for f in fn:
|
||||
cnt += 1
|
||||
try: sz += os.path.getsize(os.path.join(dp, f))
|
||||
except: pass
|
||||
print(f" {parent}/ ({cnt} 文件, {sz/1024**3:.1f}GB)")
|
||||
seen.add(parent)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
扫描 + 清理垃圾
|
||||
遍历 source_dirs,清理空壳目录和小视频残片
|
||||
读配置文件:与脚本同目录下的 config.json
|
||||
"""
|
||||
import os, json, sys
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
def load_config():
|
||||
cfg_path = os.path.join(SCRIPT_DIR, "..", "config.json")
|
||||
with open(cfg_path) as f:
|
||||
return json.load(f)
|
||||
|
||||
def scan_and_clean(cfg):
|
||||
src_dirs = cfg["source_dirs"]
|
||||
min_size = cfg["min_size_mb"] * 1024 * 1024
|
||||
video_exts = {'.mp4','.mkv','.avi','.mov','.wmv','.flv','.webm','.ts','.m4v','.rmvb'}
|
||||
|
||||
targets = []
|
||||
print("=== 扫描 ===")
|
||||
for base in src_dirs:
|
||||
if not os.path.isdir(base):
|
||||
print(f" [WARN] 目录不存在: {base}")
|
||||
continue
|
||||
for entry in sorted(os.listdir(base)):
|
||||
full = os.path.join(base, entry)
|
||||
if not os.path.isdir(full):
|
||||
continue
|
||||
total_sz = 0
|
||||
real_video = 0
|
||||
try:
|
||||
for dp, dn, fn in os.walk(full):
|
||||
for f in fn:
|
||||
fp = os.path.join(dp, f)
|
||||
try:
|
||||
total_sz += os.path.getsize(fp)
|
||||
except: pass
|
||||
if os.path.splitext(f)[1].lower() in video_exts:
|
||||
real_video += 1
|
||||
except: pass
|
||||
|
||||
action = "KEEP"
|
||||
if total_sz < min_size and real_video == 0:
|
||||
action = "DELETE"
|
||||
elif total_sz < min_size and real_video > 0:
|
||||
action = "DELETE"
|
||||
|
||||
print(f" [{action:>6}] {entry} ({total_sz/1024**2:.0f}MB, {real_video}视频)")
|
||||
|
||||
if action == "DELETE":
|
||||
targets.append(full)
|
||||
|
||||
if targets:
|
||||
print(f"\n待删除: {len(targets)} 个")
|
||||
print("开始删除...")
|
||||
import shutil
|
||||
for d in targets:
|
||||
shutil.rmtree(d)
|
||||
print(f" ✓ 已删: {os.path.basename(d)}")
|
||||
else:
|
||||
print("\n无需清理。")
|
||||
|
||||
if __name__ == "__main__":
|
||||
cfg = load_config()
|
||||
scan_and_clean(cfg)
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
name: "jav-media-sorter"
|
||||
description: "JAV 视频整理:组装文件、按演员分组、刮削 nfo 元数据与封面图"
|
||||
---
|
||||
|
||||
# JAV 媒体整理与元数据刮削技能
|
||||
|
||||
将 qbittorrent 下载完成的 JAV 视频( mp4 + srt + nfo )文件整理成 `/mnt/f/av/` 格式,按演员分类,并尝试刮削封面图。
|
||||
|
||||
## 目录结构规范
|
||||
|
||||
目标格式(参考 `/mnt/f/av/`):
|
||||
|
||||
```
|
||||
/mnt/f/av/
|
||||
├── 演员名/
|
||||
│ ├── 番号-C 演员名/ ← 如果已有番号-C 目录则保留原名
|
||||
│ │ ├── 番号.mp4
|
||||
│ │ ├── 番号.chs.srt
|
||||
│ │ ├── 番号.nfo
|
||||
│ │ ├── poster.jpg
|
||||
│ │ └── fanart.jpg
|
||||
│ └── ...
|
||||
├── 未知演员/
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
另一套目录结构(参考 `/mnt/e/01_automated/日本电影/`):
|
||||
|
||||
```
|
||||
/mnt/e/01_automated/日本电影/
|
||||
├── 演员名/
|
||||
│ ├── 番号 演员名/
|
||||
│ │ ├── 番号.mp4
|
||||
│ │ ├── 番号.nfo
|
||||
│ │ ├── 番号-poster.jpg
|
||||
│ │ └── 番号-thumb.jpg
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Clash 代理运行在 Docker(端口 7443,认证 `proxy:5gynj20J`)
|
||||
- FlareSolverr 运行在 Docker(已下线,按需启动)
|
||||
- Python3(用于 nfo 和整理脚本)
|
||||
- Docker(FlareSolverr + Clash)
|
||||
|
||||
## 操作流程
|
||||
|
||||
### 1. 检查下载目录
|
||||
|
||||
```bash
|
||||
ls /mnt/f/qbittorrent/failed/
|
||||
```
|
||||
|
||||
通常结构是 `failed/` 目录下平铺所有 mp4、srt(.chs.srt)、nfo 文件。
|
||||
|
||||
### 2. 按演员创建目标文件夹
|
||||
|
||||
在 `failed/` 目录内创建 `番号-C 演员名/` 文件夹,方便手动拖到 `/mnt/f/av/` 中对应演员目录。
|
||||
|
||||
```bash
|
||||
cd /mnt/f/qbittorrent/failed
|
||||
mkdir -p "ADN-016-C 明日見未来"
|
||||
# ... 以此类推
|
||||
```
|
||||
|
||||
### 3. 按演员分组(移动目录到演员子目录)
|
||||
|
||||
若目录直接放在 `/mnt/f/av/` 根目录下,需要移动到对应演员子目录:
|
||||
|
||||
```bash
|
||||
cd /mnt/f/av
|
||||
mkdir -p "明日見未来"
|
||||
mv "ADN-016-C 明日見未来" "明日見未来/"
|
||||
# ... 以此类推
|
||||
```
|
||||
|
||||
### 4. 读取 nfo 获取演员信息
|
||||
|
||||
每个作品的 nfo 文件包含元数据,用 Python 解析:
|
||||
|
||||
```python
|
||||
import xml.etree.ElementTree as ET
|
||||
tree = ET.parse('番号.nfo')
|
||||
root = tree.getroot()
|
||||
actors = [a.find('name').text for a in root.findall('actor')]
|
||||
dvd_id = root.find('num').text
|
||||
```
|
||||
|
||||
### 5. 封面图刮削
|
||||
|
||||
尝试从以下来源下载封面图:
|
||||
|
||||
#### 5.1 FlareSolverr + Clash 代理(首选)
|
||||
|
||||
```bash
|
||||
FLARE="http://127.0.0.1:8191/v1"
|
||||
CLASH_PROXY="http://proxy:***@host.docker.internal:7443"
|
||||
|
||||
# FlareSolverr API 请求格式
|
||||
curl -s "$FLARE" -d '{
|
||||
"cmd":"request.get",
|
||||
"url":"https://javdb.com/search?q=番号&f=all",
|
||||
"maxTimeout":45000,
|
||||
"proxy":{"url":"'$CLASH_PROXY'"}
|
||||
}' -H "Content-Type: application/json"
|
||||
```
|
||||
|
||||
从返回的 JSON 中提取 `solution.response` 中的 HTML,解析出:
|
||||
|
||||
- 详情页链接 `href="/v/xxx"`
|
||||
- 详情页中 `class="video-cover"` 的 `src` 属性(封面图片 URL)
|
||||
|
||||
然后通过 Clash 代理下载图片:
|
||||
|
||||
```bash
|
||||
curl -x "http://proxy:***@127.0.0.1:7443" -s -L -o "poster.jpg" \
|
||||
-H "Referer: https://javdb.com/" "$POSTER_URL"
|
||||
```
|
||||
|
||||
#### 5.2 备用:Python socks5 直连
|
||||
|
||||
```python
|
||||
import socks, socket
|
||||
socks.set_default_proxy(socks.SOCKS5, "127.0.0.1", 7443,
|
||||
username="proxy", password="***", rdns=True)
|
||||
socket.socket = socks.socksocket
|
||||
import urllib.request, re
|
||||
req = urllib.request.Request("https://javdb.com", headers={
|
||||
"User-Agent": "Mozilla/5.0"})
|
||||
```
|
||||
|
||||
#### 5.3 已知限制
|
||||
|
||||
- JavDB 有 Cloudflare 反爬,Python `requests`/`cloudscraper` 均可能被拦截
|
||||
- FlareSolverr 走 HTTP 代理时可能无法完成 Cloudflare JS 挑战
|
||||
- FANZA/DMM 图片 CDN 返回小图(~3KB),真实大图需 JS 渲染
|
||||
- 最可靠方案:**Windows 端浏览器手动下载**
|
||||
|
||||
### 6. 图片命名规则
|
||||
|
||||
- `/mnt/f/av/` 格式: `poster.jpg` + `fanart.jpg`(两图相同)
|
||||
- `/mnt/e/01_automated/日本电影/` 格式: `番号-poster.jpg` + `番号-thumb.jpg`
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **WSL 操作 /mnt 文件极慢**:大量小文件操作应在 Windows 原生 Python 中执行
|
||||
- **代理密码注意 shell 展开**:Clash 密码 `5gynj20J` 在 bash 中写为 `"proxy:*"`(用双引号避免 `***` 展开)
|
||||
- **FlareSolverr 密码兼容性**:SOCKS5 代理认证不支持通过环境变量传递。HTTP 代理用 `proxy` 参数
|
||||
- **Clash 端口**:mixed-port 7443,同时支持 HTTP 和 SOCKS5
|
||||
- **删除冗余的字幕目录**:`*-中文字幕/` 目录中的 `.srt` 与 `failed/` 目录中的 `.chs.srt` 多数是重复文件(MD5 一致)
|
||||
|
||||
## 依赖检查
|
||||
|
||||
```bash
|
||||
# FlareSolverr
|
||||
docker ps | grep flaresolverr
|
||||
|
||||
# Clash
|
||||
docker ps | grep clash
|
||||
|
||||
# Python
|
||||
python3 --version
|
||||
pip3 list 2>/dev/null | grep -i cloudscraper
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: "jellyfin-nfo-builder"
|
||||
description: "为剧集整理统一文件名、生成缺失的 Jellyfin .nfo 剧集元数据"
|
||||
---
|
||||
|
||||
# Jellyfin NFO 剧集元数据补齐技能
|
||||
|
||||
为剧集目录整理统一文件名,并为缺失的集数生成标准的 Jellyfin `.nfo` 元数据文件。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 手动清理/重命名剧集文件后,部分集数的 `.nfo` 元数据缺失
|
||||
- 从不同来源合并剧集(高质量版 + 补全集),需要统一格式
|
||||
- 批量标准化剧集视频文件和元数据
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 视频文件已整理到一个目录
|
||||
- 有一条完整的剧集(有 `.nfo` 示例可参照格式)
|
||||
- 了解剧集的总集数
|
||||
- shell 工作目录为剧集所在目录
|
||||
|
||||
## 步骤
|
||||
|
||||
### 1. 检查现有状态
|
||||
|
||||
列出目录下所有视频文件和 `.nfo` 文件:
|
||||
|
||||
```bash
|
||||
ls -lh *.mp4 *.nfo 2>/dev/null
|
||||
```
|
||||
|
||||
确定:
|
||||
- 总集数
|
||||
- 已有哪些集的 `.nfo`
|
||||
- 哪些集缺失 `.nfo`
|
||||
- 文件名是否统一
|
||||
|
||||
### 2. 读取一个已有 `.nfo` 作为模板
|
||||
|
||||
```bash
|
||||
cat "剧集名 第xx集.nfo"
|
||||
```
|
||||
|
||||
标准 Jellyfin 剧集 nfo 格式:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<episodedetails>
|
||||
<title>第N集</title>
|
||||
<season>1</season>
|
||||
<episode>N</episode>
|
||||
<plot>剧集名 第N集</plot>
|
||||
<runtime>233</runtime>
|
||||
<aired>2026-01-01</aired>
|
||||
</episodedetails>
|
||||
```
|
||||
|
||||
### 3. 生成缺失的 `.nfo`
|
||||
|
||||
```bash
|
||||
cd /path/to/剧集目录
|
||||
for i in 4 5 8 9 10 11; do
|
||||
cat > "剧集名 第${i}集.nfo" << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<episodedetails>
|
||||
<title>第${i}集</title>
|
||||
<season>1</season>
|
||||
<episode>${i}</episode>
|
||||
<plot>剧集名 第${i}集</plot>
|
||||
<runtime>233</runtime>
|
||||
<aired>2026-01-01</aired>
|
||||
</episodedetails>
|
||||
EOF
|
||||
done
|
||||
```
|
||||
|
||||
> `runtime`(时长秒数)和 `aired`(首播日期)可以参照已有文件填写,或设为默认值。
|
||||
|
||||
### 4. 检查结果
|
||||
|
||||
```bash
|
||||
ls -lh *.mp4 *.nfo 2>/dev/null
|
||||
```
|
||||
|
||||
确认每一集都有对应的视频文件和 `.nfo` 文件。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- Jellyfin 依赖文件名排序,统一格式为 `剧集名 第XX集.mp4`(两位数字,按字母排序正确)
|
||||
- `.nfo` 中的 `<episode>` 标签必须与集数一致
|
||||
- 如果整个剧集目录还没有 `tvshow.nfo` 和 `season.nfo`,也需要一并创建
|
||||
- 视频文件命名应与 `.nfo` 文件名完全一致(仅扩展名不同)
|
||||
@@ -0,0 +1,37 @@
|
||||
# 前言
|
||||
|
||||
- 数据库连接
|
||||
|
||||
```yaml
|
||||
url: jdbc:mysql://localhost:3306/ruoyi-vue-pro
|
||||
username: root
|
||||
password: 123456
|
||||
```
|
||||
|
||||
- 表名称:cc_jav_info
|
||||
|
||||
|
||||
|
||||
# 正文
|
||||
|
||||
## 保存或者更新
|
||||
|
||||
- `/mnt/e/01_automated/日本电影`目录 所有`.nfo`文件
|
||||
- 找到以下标题
|
||||
- fthtd-156 sinn-040 nhdtc-095 npjs-106 fns-143 fns-121 fns-120 huntc-407 huntc-417 ymdd-465 dvmm-322
|
||||
- 根据`.nfo`文件信息转换为insertupdate语句
|
||||
- 根据`jav_id`字段保存或者更新
|
||||
|
||||
|
||||
|
||||
## 更新收藏标识
|
||||
|
||||
- `is_favorite` 是否收藏:0-否,1-是
|
||||
- `/mnt/e/01_automated/日本电影`目录 所有`.nfo`文件
|
||||
- 根据`jav_id`字段更新is_favorite=1
|
||||
|
||||
## 连接数据找到这个表
|
||||
|
||||
- 记住连接数据库操作
|
||||
|
||||
-
|
||||
Reference in New Issue
Block a user