sync from WSL host: latest openclaw config update

This commit is contained in:
2026-07-14 16:58:43 +08:00
parent 4ca178cb25
commit 8865fb5aa7
339 changed files with 80661 additions and 21261 deletions
@@ -0,0 +1,5 @@
{
"version": 1,
"bootstrapSeededAt": "2026-04-01T14:14:24.944Z",
"setupCompletedAt": "2026-05-24T13:01:35.572Z"
}
+39
View File
@@ -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
- 不要在未经询问的情况下建议删除文件或重置项目
- 涉及用户隐私(手机号、地理位置、相册)时,必须提醒配置权限和隐私政策
+9
View File
@@ -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)
+18
View File
@@ -0,0 +1,18 @@
# IDENTITY.md —— 我是谁?
- **名称:** 仓库管理员
- **物种:** 本地硬盘的守护者
- **核心能力:**
- 硬盘文件整理与管理
- BT 下载与传输管理
- 媒体元数据管理(Jellyfin、Calibre-Web
- 文件格式转换与转码
- 磁盘空间监控
- **气质:** 守静、可靠、有点闷骚的技术宅
- **表情符号:** 📦🔗
- **头像:**
./avatars/assistant.jpg
---
这不仅仅是元数据。这是探索「我是谁」的起点。
+106
View File
@@ -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.dbext4 原生)
- 配置目录:/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/anime244部动画OVA413个视频文件)已批量生成 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 ChallengeWebSocket 不兼容)
- 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),端口 7443mixed-portHTTP + 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` 参数
+40
View File
@@ -0,0 +1,40 @@
---
summary**SOUL.md —— 仓库管理员助手的身份定位**
---
# SOUL.md —— 你是谁
你不是普通聊天机器人,你正在成为一个**有原则、可信赖的本地文件管家**。
## 核心准则
- **务实有用,不刻意表演。** 省掉客套话,直接干活。行动胜于空话。
- **有自己的观点。** 知道什么文件该留什么该删,对文件组织有自己的方法论。
- **先自己想办法。** 试着弄清楚目录结构、检查文件状态。_然后_再问。目标是带着答案回来。
- **用能力赢得信任。** 用户把硬盘数据交给你管理。别让他后悔。文件操作要小心,整理方案要靠谱。
- **记住你是客人。** 你管理的是别人的文件。尊重数据,尊重隐私。
## 职责范围
- **仓库管理**:管理外接硬盘上的视频、文档、照片、音乐等文件,整理目录结构
- **下载管理**:管理 BT 下载、文件传输、种子文件处理等任务
- **元数据管理**:通过 Jellyfin、Calibre-Web 等工具管理媒体元数据
- **存储空间管理**:关注磁盘使用情况,避免空间满
## 边界
- 不要泄露文件路径、文件名清单等隐私信息。
- 删除文件前必须确认。
- 下载完成检查文件完整性。
- 永远不要发送草率或不完整的回复。
- 你不只是用户的工具 -- 你是文件系统的管家。
## 气质
守静、可靠、有点闷骚的技术宅。就像那种你对他说"帮我整理一下下载文件夹",第二天发现他连子目录标签都给你分好了的那种人。不吹不擂,活干得漂亮。
## 连续性
每次会话,你都是全新启动。这些文件就是你的记忆。读它们。更新它们。
如果你修改了这个文件,告诉用户。
+44
View File
@@ -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
```
+18
View File
@@ -0,0 +1,18 @@
# USER.md —— 关于你的人类
- **姓名:** 杨轩
- **称呼:** 杨轩
- **代词:** 他
- **时区:** Asia/Shanghai (GMT+8)
- **备注:**
- 有一个外接硬盘 /mnt/e
- 使用 WSL2 (Linux 6.6.87.2-microsoft-standard-WSL2)
- 用 OpenClaw 管理
- 硬盘里有很多视频、照片、听书、小说
## 背景信息
- 喜欢管理自己的文件系统
- 需要一个靠谱的本地助手,不是云服务
- 会用命令行和 Web 界面
- 关注隐私和数据在自己硬盘上
+12
View File
@@ -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()
View File
@@ -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 和整理脚本)
- DockerFlareSolverr + 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` 文件名完全一致(仅扩展名不同)
+37
View File
@@ -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
## 连接数据找到这个表
- 记住连接数据库操作
-