Go 写的 Emby 媒体库元数据编辑器。单文件 exe + 内嵌 Web UI(web/ 原生 JS,无构建步骤,go:embed 进二进制),双击即用,界面跑在本机浏览器里(默认只听 127.0.0.1)。
界面自带访问认证(单密码登录,PBKDF2 派生、内存会话),与 Emby 登录是两回事 —— 所以把端口开到局域网也不等于把管理员权限摊在网上。见访问认证。
下载:
EmbyMetaEditor.exe(Windows x64,约 18.2 MB,无运行库依赖) Docker:aag111/emby-meta-editor(linux/amd64 + arm64)
| 模块 | 说明 |
|---|---|
| 访问认证 | 保护界面本身:单密码、PBKDF2-SHA256 存储、按 IP 退避 |
| Emby 登录 | 用户名密码 / API Key 两种,凭据本地保存 |
| MetaTube 刮削 | 单个 / 批量刮元数据与图片(公共后端已下线,自建必填) |
| gfriends 头像 | 10 万+ 头像索引;人物列表按媒体库 + 人物类型(演员 / 导演)筛,每张卡带资料完整度百分比;索引与图片各带两层 CDN 容错 |
| 演员资料 | 简介 / 出生日期 / 出生年份 / 出生地 / 外部 ID(多源合并 + 内嵌的离线资料库优先、抓取源分列可选、搜索用名字可临时改),并列出该人物在本库的作品;别名记忆在「人工采用 / 写入成功」时落盘、之后所有源共享(只加搜索词,不放宽身份阈值);打开面板不联网,写入默认「只填空白」、单卡可勾选覆盖、可回滚 |
| 番号补全 | 按演员抓全部番号 → 与本地库比对找缺失 → 抓磁力列表(javbus + javdb 双源并发、按种子哈希去重、按体积倒序、按番号并排分页、每行标出来源) |
| 国产传媒 | 选库列条目 → 单个 / 勾选批量刮削 / 直接编辑元数据;四站并发按 封面 → 标题 → 标签 → 日期 合并 |
| 预演(dry-run) | 批量写入前先看「会改成什么」:逐字段新旧对照 + 会写哪些图片,一个字节都不写 |
| 写入历史与回滚 | 每次写条目元数据(刮削 / 国产传媒 / 手动编辑)前自动留快照,设置页可逐字段回滚(图片不还原) |
| 离线资料库 | 2.7 万条演员资料直接编译进 exe 与镜像(data/actresses_export.csv → //go:embed),排在所有在线源最前面,不需要任何配置 |
| 诊断包 | 一键导出脱敏 zip(配置 / 历史 / 任务 / 缓存清单),发给作者排障不怕泄漏密钥 |
| 翻译 | 刮削时把非中文标题 / 简介翻成简体中文(OpenAI 兼容接口,可选) |
| 媒体库统计 | 各库条目数、电影 / 剧集 / 集数 |
EmbyMetaEditor.exe # 默认 127.0.0.1:8097,自动开浏览器
EmbyMetaEditor.exe -port 8098 # 换端口
EmbyMetaEditor.exe -open=false # 不自动开浏览器
EmbyMetaEditor.exe -dir D:\data # 指定数据目录(默认程序目录)
EmbyMetaEditor.exe -host 0.0.0.0 # 允许局域网访问(凭据安全见下)首次启动在数据目录生成 config.json 与 cache/(gfriends 索引约 10 MB,下过一次可离线复用),并生成一个随机访问密码打印到控制台。忘了密码就用环境变量重设(这是唯一的找回入口):
EMBYME_AUTH_USER=admin EMBYME_AUTH_PASSWORD=新密码 EmbyMetaEditor.exe
# PowerShell: $env:EMBYME_AUTH_PASSWORD="新密码"; .\EmbyMetaEditor.exeDocker:
docker run -d --name emby-meta-editor -p 8097:8097 \
-e EMBYME_AUTH_USER=admin -e EMBYME_AUTH_PASSWORD=换成你自己的密码 \
-v emby-data:/data aag111/emby-meta-editor:latest配置存在命名卷 emby-data 里的 config.json。不传 EMBYME_AUTH_PASSWORD 也能起(随机密码打到 docker logs)。容器只跑 http,cookie 带不了 Secure —— 要暴露到公网必须在前面套 HTTPS 反代,且反代要保持 Host 与浏览器一致,否则写请求会被同源校验拒掉。
界面「设置」里可改,也可直接编辑 config.json。
| 配置 | 默认值 | 说明 |
|---|---|---|
emby_url |
http://127.0.0.1:8096 |
Emby 服务器地址 |
metatube_url |
http://127.0.0.1:8080 |
MetaTube Server,必填自建实例 |
metatube_token |
空 | MetaTube 实例开了鉴权时填写 |
gfriends_tree_url |
jsdelivr 上的 Filetree.json |
头像索引地址 |
gfriends_cdn |
jsdelivr 上的仓库根 | 头像图片前缀 |
javbus_url |
https://www.javbus.com |
可换镜像站 |
javbus_cookie |
age=verified; dv=1; existmag=mag |
年龄验证 + 磁力开关 |
javdb_url |
https://javdb.com |
第二个磁力源;留空即停用该源 |
javdb_cookie |
空 | javdb 有 Cloudflare 与 18 岁确认,被拦时填浏览器完整 Cookie |
magnet_sources |
["javbus","javdb"] |
参与磁力抓取的源。缺这个键 = 用默认的全部;显式空数组 = 一个都不用 |
proxy |
空 | HTTP 代理,留空则读系统环境变量 |
concurrency |
4 | 批量任务并发数 |
javbus_interval_ms |
1500 | 请求间隔(javbus / javdb 共用,都是站点级限速),别调太小 |
cn_sites |
四站官方地址 | 国产传媒站点表,可换镜像;留空项回落默认 |
openai.base_url |
空 | 翻译用接口根地址(如 https://api.openai.com/v1) |
openai.api_key |
空 | OpenAI / 中转的 Key |
openai.model |
gpt-4o-mini |
翻译模型 |
openai.enabled |
false | 翻译总开关 |
insecure_tls |
false | 自签证书的 Emby 勾上 |
auth.username |
admin |
界面访问用户名 |
auth.password_hash |
首启生成 | PBKDF2 派生值,不存明文;改这里没用,重设见上 |
auth.password_generated |
true | 是否仍是首启生成的密码(界面据此提示「建议改掉」) |
与 Emby 登录无关,只解决「谁能打开这个界面」。密码来源优先级:EMBYME_AUTH_PASSWORD > config.json 的哈希 > 首启生成并打印。
- PBKDF2-SHA256(20 万轮 + 16 字节随机盐),常量时间比较 ——
config.json被备份 / 贴出来都不泄密。 - 会话是内存里的 32 字节随机令牌,不落盘:进程重启 = 全部登出。代价是容器重启要重新登录。
- Cookie
HttpOnly+SameSite=Lax;进程默认明文 HTTP,故不加Secure,上公网靠反代。 - 写请求校验
Origin/Referer,且只收 JSON(HTML 表单伪造不出application/json)—— CSRF 第二道闸。 - 登录失败按 IP 退避:前两次不罚,之后 3s → 10s → 30s → 2min → 5min → 15min。
- 改密码要验旧密码,改完踢掉其他所有会话。
- 敏感项不下发到页面(Emby 密码 / API Key、MetaTube token、javbus cookie、OpenAI key),设置页显示「已保存,留空则不修改」—— 留空提交 = 不修改。要清空就编辑
config.json。 - 副作用:Emby 海报 / 头像改由服务端带令牌代取(
/api/emby/image),令牌不进<img src>,顺带解决「页面 http、Emby https 自签」被拦的问题。 - CSP:
connect-src 'self'(严)+img-src 'self' data: blob: http: https:(宽)。img-src放开的唯一原因是让 javbus 页面自己的脚本能把磁力预览图注入进来(见踩过的坑)。代价是自家模板里的外链<img>不再被浏览器拦,这条守卫改由tools/check_frontend.py静态扫守。
javbus 图片按 Referer 防盗链:不带 Referer 403,带 Referer: https://www.javbus.com/ 200,带本机 origin 也是 403 —— 浏览器直连必然白板。所以页面里所有外部图片走 /api/img?u=<原始地址>:
| 目标主机 | 行为 |
|---|---|
白名单(配置的 javbus / gfriends / 国产传媒站点主机 + pics.dmm.co.jp、cdn.jsdelivr.net、i0.wp.com、upload.xchina.io 等) |
服务端带正确 Referer / 浏览器 UA 代取,内存缓存 6 小时 |
| 其他公网图床 | 302 回原地址,浏览器直连(效果与直连一致) |
| 内网地址、非 http(s) | 502 拒绝,避免变成 SSRF 跳板 |
白名单以 . 开头是后缀匹配(.xchina.io 匹配 xchina.io 与 upload.xchina.io,不匹配 xchina.io.evil.com)。加自己的图床改 imageproxy.go 的 imageCDNHosts。策略只在服务端,换 javbus 镜像域名不用动前端。
upload.xchina.io对浏览器返回 Cloudflare 挑战页(ERR_BLOCKED_BY_RESPONSE,图片全白),服务端带浏览器 UA 却是正常 200 —— 这类「接口通、页面白」的坑只能靠渲染层回归发现。
公共后端已下线,自建:
docker run -d --name metatube -p 8080:8080 metatube/metatube-server:latestmetatube_url 填 http://你的IP:8080,「设置 → MetaTube → 获取 provider 列表」能列出 FANZA / MGStage / DUMMY 就算通。搜片不指定 provider 更稳:指定就强制走那一个抓取器,抓取器一挂就是 500;不指定则服务端并发聚合,按番号精确匹配取最合适的一条。
API Key 在 Emby 后台「高级 → API 密钥」生成。它是服务器级的、不绑定用户:4.9.x 上用 API Key 请求 /Users/Me 会 500(Unrecognized Guid format),所以 Emby.Me 做多级回退 —— /Users/Me → 已保存的 user_id 查 /Users/{id} → 列 /Users 挑一个。只要正常登录过一次,身份就存下来了。
raw.githubusercontent.com 国内基本直连不了,默认走 jsdelivr。索引 6.5 MB,带两层备用地址(换 CDN 不用重下索引):
| 故障面 | 备用 |
|---|---|
| 仓库 | gfriends/gfriends → xinxin8816/gfriends(内容一致镜像) |
| CDN 节点 | cdn.jsdelivr.net → gcore. / fastly. → raw.githubusercontent.com(不同基础设施) |
索引与图片都走这套列表(只兜索引等于没兜:索引下得回来、图全失败,功能照样整体不可用)。图片回落只在主基址一张都没取下来时触发,正常网络零额外请求;备用基址单次上限 30 秒。自定义镜像站不追加这些外网地址。
- 高清优先:自动刮削会先量每张候选的宽 × 高,选最大的一张(对齐 Emby 官方 gfriends 插件)。
- 手动选图:每张候选下标出
640×960 · 130 KB—— 候选常是一堆同名图、缩略图都缩到 88px,光看图分不出原图与压缩图,这两个数才是依据。数据由POST /api/img/info服务端代取(浏览器读不到<img>字节数,跨域fetch又被connect-src 'self'挡着),顺带写进图片缓存所以缩略图秒开。选中的那张若已被索引移除会明确报错,不会静默换成第一张;客户端传回的地址只用于定位是哪一张,实际下载永远走服务端配置的基址。
「演员头像」页每张卡还能抓人物本人的资料(简介 / 出生日期 / 出生年份 / 出生地 / 外部 ID),并列出其在媒体库里的作品。
每张卡还有一条资料完整度:资料 60% 配一根紫色进度条,分母就是上面那 5 个字段(Emby 里已经填了几个 ÷ 5)。它只统计资料,不含头像 —— 头像那件事卡片上那排标签(已有头像 / gfriends 命中 / 库中无记录)已经说过了,混进来就分不清「资料不全」和「没头像」。0% 的卡片整条转灰,不用紫色假装有进度。悬停会写明「已填 N/5 项」以及是哪 5 项 —— 光给一个百分数,没人知道分母是什么。分母与判空口径都取自 embyProfileSnapshot,和「演员资料」面板逐字段比对的是同一份(将来加字段只改那一处)。
为什么这 5 个字段必须显式列进
/Persons的Fields。 实测 Emby 4.9:不带Fields时返回体只有BackdropImageTags / Id / ImageTags / Name / ServerId / Type——Overview、PremiereDate、ProductionYear、ProductionLocations、ProviderIds一个都没有;同一批 300 条,带上之后分别有 20 / 11 / 11 / 8 / 16 条有值。所以漏列字段不会报错,只会让所有人的百分比静默偏低。字段清单收在emby.go的personListFields常量(唯一出处),单测TestHandlePersonsRequestsProfileFields与tools/verify_profile_completeness.py各守一头。
| 源 | 取什么 |
|---|---|
| 离线资料库(内嵌,v1.10.0) | 排在最前(第一优先级):库里的生年月日 / 出身地 / 身长・三围 / 血型 / 事务所 / 别名 / 退役年份。不提供头像,见下 |
AVデータバンク(av-db.net) |
字段最全:生年月日 / 出身地 / 身长・三围 / 血型 / 爱好 / 事务所 / 别名 / 标签,也是外部 ID(avdb)来源 |
AV-League(av-league.com) |
生年月日 / 出身地 / 三围 / 事务所,用于互相印证 |
| Wikipedia(日文) | 简介段落 |
顺序即优先级(合并是「先到先得」,值非空就不再被后面的源覆盖)。三个在线源默认全开;离线库是编译进程序的,永远在清单里、永远排第一,没有开关与路径可配。
- 抓取是显式动作:点「资料」只开面板,立刻给出本地能拿到的两样 —— 分列的源(每源写明会填哪些字段,顺序即优先级)与作品列表。要抓才点「抓取资料」(一次要并发访问三个站)。侧栏那组源开关与面板里这组是同一份状态。
- 写入策略:默认只填空白;Emby 已有值且本次也抓到的默认跳过,手动勾上即变成「将覆盖原值」(按钮改文案 + 危险色)。批量路径永远只填空白 —— 唯一能改已有值的入口是单卡面板上亲手勾的字段。写前留快照,「同步历史」可逐字段回滚(点两次才执行)。
- 姓名走两轮匹配 + 详情页确认(
minMatchScore=80 /minDetailMatchScore=95)避免同名不同人;别名记忆在cache/actor_aliases.json。 - 搜索用名字(v1.5.0):面板顶上的输入框可以临时改「拿什么名字去搜」,预填 Emby 里的人名,留空 = 用原名。存在的理由很实在 —— Emby 里的人物名多是刮削器写的中文(「三上悠亚」),三个源却全是日文站,名字对不上就是一条都不中,而用户手里明明有正确写法。它只当查询词:Emby 里的人物名一个字都不改,写入字段的归属、同步历史里的名字、别名记忆的 canonical 仍全按 Emby 名走(
effectiveSearchName()是这条规则的唯一定义处,界面留空就发空串,由服务端回退)。手填的名字不落盘 —— 别名候选仍按 Emby 名去查别名记忆,只有源站自己返回的别名照旧在写入成功后被记住。抓完面板会回显本次实际用的搜索名(不是输入框的当前值,两者抓完之后会不同)。 - 标签不写 Emby 的
Tags,而是并进简介最后一行 —— 这个构建对Person的Tags收下不保存,写了会「看起来成功、实际没有」还覆盖用户自己的标签。 - 作品列表按
PremiereDate倒序,库名由GET /Library/VirtualFolders的路径匹配条目Path得出(Views接口不返回Path,靠它拿不到库名),一屏 400 条,更多点「再加载」。
两个时机会把已确认的旧艺名落盘进 cache/actor_aliases.json:人工采用(点了「抓取资料」并拿到结果)与成功同步(真的写进了 Emby)。落盘之后,所有资料源再查这个人时都会把别名一并当查询词(NamesFor()),面板上会写明「已记入本地别名记忆」、侧栏的计数也会跟着动。
它只加搜索词与认可写法,不放宽身份匹配阈值:minMatchScore=80 / minDetailMatchScore=95 一个都不动 —— 别名进的是「候选写法」,最终算不算同一个人仍由那两道闸门判定。这条区别很要紧:把阈值调松能让命中率立刻变好看,代价是把资料写到错的人身上。
2.7 万条演员资料的 CSV 直接编译进二进制(//go:embed data/actresses_export.csv,见 offlinelib.go):Windows exe 与 Docker 镜像里各自自带一份,不需要填路径、也不依赖任何外部文件,设置页因此不再有那张卡片(一个「能关掉」的开关会误导人 —— 它其实关不掉)。
数据是怎么来的:原始资料库是 SQLCipher 4 加密的(文件头不是 SQLite format 3、字节熵接近 8、页数与体积整除),归另一个工具所有。所以本项目既不解析那个 .db、也不写它一个字节,只读它导出成的那份 CSV:
# 从 .db 导出 CSV。脚本对 .db 全程 SQLITE_OPEN_READONLY,只调一次 key 就全是读操作
python tools/sqlcipher_dump.py --db "<资料库>.db" --key "<口令>" --csv data/actresses_export.csv
python tools/sqlcipher_dump.py --db "<资料库>.db" --key "<口令>" # 只看加密参数 / 表结构 / 记录数
# 口令也可以走环境变量,避免留在 shell history 里
EMBYME_OFFLINE_KEY="<口令>" python tools/sqlcipher_dump.py --db "<资料库>.db" --csv data/actresses_export.csv导出到 data/actresses_export.csv 就是喂给构建的那一份。重新导出之后必须重新构建才生效 —— 没有「按 mtime 失效」那一套了,数据是编译期定下来的(这也正是选择内嵌的代价)。
脚本不内置口令。 这类程序不管上层怎么加壳/混淆,密钥最终都必须以明文交给原生 sqlite3_key_v2()(SQLCipher 内部再用它做 PBKDF2),所以在那个进程上挂一个钩子就能拿到 —— 但那是使用者对自己机器上数据做的事,不该被固化进一个要发到公开仓库的脚本里。
data/actresses_export.csv 必须进仓库(CI 是拿仓库内容构建产物的),而带着加密口令与一份真实 Emby API Key 的 演员数据库/ 仍然留在 .gitignore 里。
内嵌之后的行为:
- 它排在资料源最前面(
profileSourceList()把它放到actorSources()之前)—— 真正的第一优先级,不是「参与合并」而已。 - 与别名记忆联动:库里记的别名、以及这个人的各种写法(
name_ja/name_zh_cn/ 罗马音 / 假名)都会作为别名暴露出去 —— 采用或同步时落进cache/actor_aliases.json,之后所有资料源再查这个人都会带上它们。反过来,别名记忆里已确认的旧艺名会作为候选写法发给每个源,所以「Emby 里叫新名、库里只有旧艺名」也能命中(TestOfflineLibrarySharesAliasMemory正反两面都断言了)。 - 头像不走它:
ActorFacts里没有任何图片字段,这个源在类型上就无法参与头像(不是靠约定不用,而是没法用),头像继续走 gfriends 那条独立顺序。导出文件里明明有一列profile_image_url(25,234 条非空),有意不映射。两条测试盯着:TestActorFactsHasNoImageField(反射扫字段名)与TestOfflineLibraryDoesNotMapProfileImage。 - 一个写法对应多条记录时会提醒:实测这份库里
name_original有 15% 的键、kana有 18% 同时属于两条以上记录(重名的不同演员,或同一人的新旧两条)。索引不假装唯一 —— 一个键只认一条(本名类写法优先于读音类),但会把「还有谁」作为告警说出来,因为它会并进面板的告警里。悄悄挑一条的后果是把别人的生日写进你的 Emby,而界面上看起来毫无异常。 - 整条只有名字(没有资料字段、也没有别名)的条目不算命中(否则「命中来源」里会多一个什么都没提供的源,用户还得猜它为什么在那儿)。
- 日期写成
1991/04/19、19910419都会收敛成1991-04-19;数值列163.0收敛成163(不然简介里会出现「身高:163.0 cm」,和别的源的163是两种格式);认不出来的一律原样保留,不在这里猜。 tags_json有意不映射。 它是「日本艺人 / 前演艺从业者 / 30代 / 美魔女」这类派生分类,不是源站的题材标签;而标签是并进简介最后一行的,简介又是整字段写入 —— 把噪声塞进去,用户就只能放弃整个简介(连带丢掉出生日期、身长这些真正有用的行)。social_links_json/awards_json/timeline_json/public_roles_json/data_conflicts_json同样没有对应的 Emby 字段,一并留空。
数据坏了会怎样。 内嵌数据解析不出来时不会崩溃,也不会静默 ——
offlineLib()的那次解析失败会把原因(缺列 / 一个有效条目都没有 / BOM 没剥导致的列名错位)记进告警,指向tools/sqlcipher_dump.py与data/actresses_export.csv。这类「数据编译进程序」的设计最容易悄悄坏掉的地方就是界面一切正常、只是永远查不到人,所以除了三条产物断言(CI grep 表头、镜像内嵌字面量、TestOfflineLibraryParsesBuiltinData),还留了tools/verify_offline_lib.py用真实记录做端到端命中。
页面里的「简介」是结构化版式(
事务所:…/出生日期:…/身高:… cm一行一字段),不是源站那段自由文本。这是拿真实数据核对过的:原版扩展器写进人物Overview的就是这种版式(罗马音: …<br/>出生日期: …),没有散文段落。唯一的例外是一条结构化行都凑不出来时:那种情况下退回源站那段简介正文(
summary)。不兜这一手的话,一个只提供成段简介的源(Wikipedia、以及这个离线库里的 2.6 万条中文简介)在界面上会显示成「简介:未抓取到」—— 抓到了东西却当场丢掉。
人物类型筛选(v1.4.0)走 Emby 的 PersonTypes,下拉:演员 + 导演(默认)/ 仅演员 / 仅导演 / 全部人物(含片商)。personTypesParam() 归一化(空 → 默认、all → 空串、Actor,Bogus 只留合法项、全非法 → 默认),下发到 /api/persons?types= 与两个批量接口的 person_types。
两个实测出来的限制:片商名在 Emby 里就是
Actor,按类型筛不掉(全量 10561 / Actor 9497 / Director 1189 / Actor,Director 10559);返回条目的Type恒为Person,「是演员还是导演」只存在于查询参数里。
javbus 是磁力源之一,与 javdb 并发问、结果合并(见下面的「磁力多源」)。 番号补全的抓取路径只走 javbus(它是按演员列作品的那个站),磁力才走多源。
- 站点要
age=verified之类 Cookie,默认值已内置;被 Cloudflare 拦(403 +Just a moment)就从 F12 复制含cf_clearance的完整 Cookie 进设置。 - 抓磁力 = 详情页 + 一个 ajax,每番号约 2 次请求,默认限速 1.5 秒 / 次、并发 2。
- 磁力按体积从大到小排序(
1.83GB/2.57 GB直接比字符串是错的,先换算字节;解析不出体积的排最后,同体积保持原顺序)。 - 每行磁力是完整
<a href>(不截断,长地址靠 CSS 省略)—— 点它可交给下载工具,也能让 javbus 页面脚本弹预览图。旁边「复制」在非安全上下文(http://+ 局域网 IP,即 Docker 的访问方式)没有navigator.clipboard,会回退execCommand,所以 exe 与 Docker 都能复制。 - 番号补全只列缺失番号(「本地有、javbus 未列出」不再单独成块)。
连通性诊断:抓不到东西时先点它,结论直接摊开。
| 现象 | 结论 |
|---|---|
| 域名解析不了 / 连不上 / 超时 | 无法连接 —— 查网络或代理,或换镜像 |
| HTTP 200 但响应体为空 | 被本机网络或运营商拦截(国内最常见) |
页面含 Just a moment / cf-browser-verification |
被 Cloudflare 拦 —— 复制含 cf_clearance 的 Cookie |
| 连上但页面没有影片列表标记 | 可能返回验证页 / 公告页,原始页面已存 cache/debug/ |
| 演员搜索返回空数组 | 站内没这个演员名,换个写法或直接填演员页地址 |
结果里还会列 HTTP 状态、响应大小、耗时、各结构标记(movie-box / photo-frame / pics/cover …)出现次数。命令行同样可用:
curl "http://127.0.0.1:8097/api/javbus/probe"
curl "http://127.0.0.1:8097/api/javbus/probe?q=三上悠亜"欧美 / 日本番号有 MetaTube 兜底,国产传媒(麻豆、果冻、天美这类)只能去聚合站捞。内置四站适配器,同一番号四站并发搜后按字段优先级合并:
| 站点 | 搜索方式 | 特点 |
|---|---|---|
xChina(xchina.co) |
服务端渲染搜索页 | 命中率最高,标题基本对得上;详情页有 Cloudflare,只取搜索页 |
麻豆区(madouqu.com) |
WordPress ?s= |
字段最全,但搜索是模糊的,必须自己过滤 |
麻豆社(madou.club) |
WordPress ?s= |
偏 MDHG 系列,不含 91CM(0 命中正常) |
7mmtv(7mmtv.sx) |
表单式搜索路径 | 覆盖一般,偶尔补上别人没有的 |
- 字段优先级:封面 → 标题 → 标签 → 日期,每字段单独取值、互不干扰,四个字段固定全开。
- 只认精确匹配:搜
91CM-014会返回91CM074/91CM084一堆近似结果,一律丢弃。比对走归一化后的 key(91CM-014/91CM074/91CM-74归一到同一形态)。 - 先选库再查询:库下拉没有「全部媒体库」—— 既避免把国产番号往日本片库套,也压请求量。
- 三种操作:单个「刮削」/ 勾选批量(勾选翻页不丢,跑完自动清空刷新)/ 「编辑」(名称 / 原始标题 / 简介 / 发行日期 / 年份 / 标签 / 类型 / 分级)。
- 编辑只提交改动过的字段(
POST /Items/{id}是整对象替换):发行日期与年份留空 = 不修改,名称不能为空,标签 / 类型提交空数组才算清空。标题覆盖很保守(当前标题为空 / 等于番号 / 是文件名 / 带下载站水印hhd800.com@…才覆盖,想强制勾「覆盖已有标题」);封面同理。 - 每站独立限速(默认 700ms),批量复用同一限速器。想单独确认某站认不认这个番号:
curl "http://127.0.0.1:8097/api/cn/search?q=91CM-014"批量写入前先看一眼「到底会改成什么」。媒体库页与国产传媒页的批量按钮旁边都有一个预演按钮: 它跑完整条搜索 / 合并 / 翻译链路,把每个条目的逐字段差集(字段 / 现在的值 / 会改成)摊在抽屉里, 然后一个字节都不写 —— 不放写入按钮是刻意的,预演就是预演。
服务端的 dry_run 其实一直存在(ScrapeOptions.DryRun / CNOptions.DryRun),但界面从来不传,
等于白放了好几个版本 —— 「后端支持、界面没接」和「界面能改、请求里没带」是同一个病,
所以 check_frontend.py 把整条链静态钉死:按钮存在 → 绑到 dry=true → 请求体带 dry_run。
关键约束:预演与真实写入必须共用同一份「算出要改什么」的逻辑,否则会出现
「预演说改 3 个字段、真写改了 5 个」—— 预览变成谎话,比没有预览更糟。
MetaTube 侧抽出了 imageSlotsToWrite() / thumbSource(),国产传媒侧抽出了 planCN(),
previewPatch() 只做比对与截断(简介动辄几千字,不截断一次批量就是几 MB)。
TestScrapeMovieDryRunMatchesRealWrite / TestCNPlanMatchesRealWrite 会逐字段核对两边是否一致。
这是 Emby 写入不可逆(POST /Items/{id} 整对象替换、服务端没有版本历史)的唯一后悔药。
每次写条目元数据之前 —— 无论走的是 MetaTube 刮削、国产传媒刮削,还是手动编辑 ——
都会先把「这次要写的字段」的当前值存进 cache/sync_history.json(上限 1000 条)。
设置页的「条目写入历史」列出最近 50 条,每条可以回滚(要点两次确认,防误触)。
演员资料那条路径用的是同一份历史文件,靠 kind 字段区分(老记录没有这个字段,一律按人物处理,
升级后旧历史照常能用)。
回滚到「原来没有这个值」时要注意:清空列表字段必须发 [] 而不是 null ——
UpdateItem 会把 nil 跳过,字段根本没被还原,而界面显示「已回滚」。
v1.7.0 加过「人物归并」:同一个演员在 Emby 里常常是好几个条目(一个库写「三上悠亜」、另一个写「三上悠亞」,或者某次刮削带上了「(中文)」后缀),每个条目各挂一半作品、头像也只补上一个 —— 用户看到的是「这个演员有一半片子没头像」,而根因在人物条目上。它会查出候选,再把被并方名下所有作品的 People 改挂到保留方、转移头像、删掉多余条目。v1.10.0 把整页移除了,因为最后一步走不通,而且代价不可接受:
- Emby 不允许通过 API 删
Person条目。 实测DELETE /Items/{id}、POST /Items/{id}/Delete、DELETE /Items?Ids=对人物条目一律 403(同一个账号删影片条目是 204;账号是管理员、EnableContentDeletion为真;条目自己的响应里写着CanDelete: false)。所以「删掉多余条目」 根本做不到,归并永远停在「作品改挂成功、删条目 403」的半成品状态 —— 修不了,只能下线。 - 唯一真能删掉它的是第三方插件(神医助手)那个隐藏的定时任务 「清除所有人员数据」
(Key =
DeletePersonTask,IsHidden=true,归在插件分类下)。它按名字清全库的人员数据 (一次实测报出Number of Persons: 18474),代价不可接受,绝不能由本工具去触发。这条是有代价换来的教训:诊断时触发过一次,一批条目的
People被清空(有本地快照的 条目可以逐字段回滚,没有的只能重刮)。第三方插件挂在服务器上的破坏性任务,别在排查时随手触发。 - 顺带一提,
CanDelete字段是判据本身:假 ID 的DELETE会回 204(item == null直接短路), 当时据此误判过「路由可用」——幂等的 204 不等于删得掉。
删掉的代码:personmerge.go / personmerge_test.go / tools/verify_person_merge.py、两条路由
(GET /api/persons/duplicates、POST /api/persons/merge)、侧栏入口与整个 view-merge 页面、
emby.go 里只服务它的 CopyImage / DeleteItem,以及 AliasStore.CanonicalKey。
三处断言现在反向盯着它别回来:check_frontend.py §10(导航 / 页面 / JS 残留)、
CI 的产物 grep、verify_docker_image.py 的内嵌字面量。
仍然保留、且与它无关的能力:别名记忆(cache/actor_aliases.json,见上文)照旧在「人工采用 /
写入成功」时落盘并被所有资料源共享 —— 它从来不是归并的附属品。
同一个番号并发问两个站点,结果按种子哈希去重、按体积从大到小合并。两家收录的种子确实不一样 (javbus 偏原盘 / 无码破解,javdb 中字与高清版本更全),而用户要的永远是「能拿到的最大那个种子」。
- 去重按 btih,不按整条链接:同一个种子在两个站上的 tracker 参数(
&tr=…)不同, 按整串比较会把它列成两条,用户以为多了个更快的源。 - 每行标出来源。合并之后光看链接分不出哪条是哪个站给的,而「两个站都有」和「这一家独有」意义不同。
- 一个源挂了不影响另一个。限频、被 Cloudflare 拦、这个番号它没收录,都是常态 ——
结果是「其余源照常返回 + 这一源的失败原因」,
note里写着「javbus 12 条,javdb 26 条」这种小结。 - 客户端在整批抓取里只建一次。每个客户端自带限速器,逐条新建等于每条都从零开始计时 —— javbus 会被封,javdb 会直接甩 403「操作過於頻繁」。
javdb 的两个实测要点(都写进夹具了,见 testdata/javdb/):
- 体积的权威来源是
data-size(单位 MB 的整数),不是页面上那行「6.33GB, 1個文件」。 后者是给人看的、可能被截断;拿它排序会得到和站点自己的「按大小排序」不一致的顺序。 - 「操作過於頻繁」是 javdb 自己的限频文案,不是 Cloudflare 挑战 —— 重试没用,只能等。 所以它走的是和其它源共用的请求间隔(默认 1.5 秒),被限频时提示「把请求间隔调大」。
搜索是模糊的:搜 SSIS-001 会带出 SSIS-0014、SSIS-0015。所以只认精确匹配
(番号写法差异可以,前缀匹配不行)—— 拿错条目等于把别的片子的种子列出来,比没结果严重得多。
设置页「磁力搜索源」可以单独关掉某一个源(关掉 javdb 就回到单源行为);javdb_url 留空即停用。
GET /api/magnets/sources?probe=1 会并发探一遍各源首页,返回每个源的可达性与失败原因 ——
「番号补全」页的「连通性诊断」会把这张表一起打出来。
设置页「诊断」里的一个下载链接,把排障要用的东西打成一个 zip:
info.txt(版本 / Go / 平台 / 运行时长 / 路径)、config.redacted.json、sync_history.json
(不含快照本体)、jobs.json(最近任务与日志)、cache.json(落盘文件清单)。
它设计出来就是要被贴到公开 issue 里的,所以脱敏用的是键名模式匹配
(password / token / cookie / api_key / secret / hash…)而不是列举字段名 ——
以后新加的密钥字段会自动被覆盖。只有非空字符串才替换,并带上原值长度;
URL 里的 user:pass@ 也抹掉。TestDiagBundleNoSentinelAnywhere 会往配置里塞哨兵值,
然后逐字节扫整个 zip 确认一个都没漏。
刮削时把非中文的标题、简介翻成简体中文(国产传媒 / MetaTube / javbus 路径都生效)。
- 开关在「设置 → OpenAI / 翻译」;只认
chat/completions协议,base_url填接口根,拼路径由程序处理。 - 只翻非中文(含假名 / 韩文 / 纯英文才翻)—— 纯汉字的日文标题会被当中文跳过,这类极少且翻错比不翻更糟。
- 番号保留:翻译前剥离前导番号只翻其余部分,译完拼回「番号 + 空格 + 译文」;压制组 / 容器标记(HEVC10 / MP4 / CD1)不会被误判成番号。
- 标题保证带番号:源标题不含番号时,写入前把识别出的番号补到最前面(已有则不重复,
SSIS001/ssis-1都认)。与是否开翻译无关。 - 缩略图一并刮(Thumb,列表 / 横版视图用):MetaTube 优先横版剧照,没有就用封面;国产传媒复用封面。同样遵循「覆盖已有图片」。
- 原标题保留:原标题是日 / 韩文时,翻译写入标题同时把原文存进
OriginalTitle。 - 失败不阻断:超时 / 报错 / 没配 Key 一律静默退回原文。
- 测试连接:填完地址 / Key / 模型点一下即可探测(地址可达 + Key 有效 + 模型可用),实时显示在按钮右侧。不依赖「启用翻译」开关,留空字段自动用已保存配置,只发一次
max_tokens=1的极小请求。
| 访问认证 | 登录 |
|---|---|
![]() |
![]() |
| 概览统计 | 媒体库刮削 |
|---|---|
![]() |
![]() |
| 详情与手动匹配 | 演员头像 |
|---|---|
![]() |
![]() |
| 番号补全 | 连通性诊断 · 正常 |
|---|---|
![]() |
![]() |
| 连通性诊断 · 失败 | 磁力列表 · 按番号分页 |
|---|---|
![]() |
![]() |
| 演员资料(抓取源分列 / 现有值 vs 抓取值 / 媒体库作品) |
|---|
![]() |
(截图数据来自本地 mock Emby / mock javbus。演员资料那张在真实 Emby 上截,截前脚本会隐藏侧栏账号行、blur 作品封面。v1.10.0 起没有「离线资料库设置」那张图了 —— 卡片本身已经撤掉。)
go build -trimpath -buildvcs=false -ldflags "-s -w" -o EmbyMetaEditor.exe .单文件 exe:web/ 与整份演员资料库(data/actresses_export.csv,约 9.5 MB)都用 go:embed 打进二进制,图标走 PE 资源(rsrc_windows_amd64.syso),拷走 exe 就能跑 —— 不需要额外放一个 CSV 在旁边。-buildvcs=false 让构建可复现 —— 照这条命令重建与仓库里的 EmbyMetaEditor.exe 逐字节一致(不加则 Go 会嵌当前 commit 的 VCS 信息,体积与哈希都会变,属正常)。产物约 19 MB:约 9.6 MB 的程序 + 约 9.5 MB 的资料数据,CI 是按 15 MB 这条线守着「数据有没有真的进产物」的。
仓库里的 exe 跟着 main 走,下载链
releases/latest/download/只在发版时更新,两者不一定同步。 当前对齐 v1.11.0:Release 与 main 的 md5 同为6f46f8ea1b0afbd81ccc200ddd80cff8(19,113,472 字节)。
go test ./... 共 281 个用例(通过 275,跳过 6),覆盖访问认证、番号归一化(含 91CM-014 这类数字开头番号、以及「.mp4 被当成番号」的误报)、javbus 解析(备用结构 / 裸 <tr> 片段 / 真实详情页夹具)、磁力按体积倒序、连通性诊断五种失败形态、MetaTube 字段与 provider 结构兼容、Emby 身份多级回退、图片代理主机分类与缓存、图片尺寸体积探测、人物类型参数归一化、搜索用名字的回退与透传(假源注入:查询词是手填名、prof.Name 仍是 Emby 名、手填名不进别名候选、抓取不落盘)、国产传媒四站合并与只认精确匹配、元数据编辑的「只提交改动项」语义、演员资料字段合并与写入策略、资料完整度(分母固定 5 且与「演员资料」面板同源(embyProfileSnapshot);判空口径:空白串 / 0 / 空数组 / 空 map 都不算已填;Person → Item 不漏字段;接口下发的百分比与「已填 N/5」逐档核对;以及断言发出去的 Fields 含全部资料字段 —— 少了它真实 Emby 就不返回该字段,界面会「一致地错」)、作品列表与库名映射、gfriends 两层 CDN 容错、预演与真实写入逐字段一致(TestScrapeMovieDryRunMatchesRealWrite / TestCNPlanMatchesRealWrite,含「预演一个字节都不写」)、条目写入快照与逐字段回滚(清空数组要发 [] 而不是 null,否则字段静默还原不了)、离线资料库(内嵌的 27780 条真实数据解析得出来、索引键数多于记录数、并按数据里第一条带内容的记录真的查一次;CSV 列名映射、BOM 剥与不剥两种、重名告警、空壳条目不算命中、日期与数值收敛、与别名记忆联动的正反两面、导出文件里的头像 URL 有意不映射)、javdb 真实夹具解析(番号只在精确匹配时才认、体积取 data-size 而不是页面上那行文字、& 要还原、没有 data-size 时回退到 span.meta)、磁力多源合并(按 btih 去重而不是整条链接、体积倒序、一个源挂了不影响另一个)、以及 mock Emby + mock MetaTube 跑通的完整刮削链路。
mock Emby 是按真实 4.9 构建的行为建模的,不是「理想 Emby」:读详情只认用户作用域路由(全局路径 404)、写操作只认全局路径、
POST /Items/{id}整对象替换、图片上传只收 base64 文本、列表不返回SortName、/Persons传非法 GUID 会 500。线上踩过的坑因此能在单测里复现。国产传媒夹具由
tools/extract_cn_fixtures.py从cache/debug/的真实响应逐字节切出,不手写 —— 手写最容易「顺手把<tr>补成<table>」,单测全绿、线上全挂。
脚本都要先过访问认证:起 exe 时带 EMBYME_AUTH_PASSWORD,脚本默认按 test-pass 登录(共用 tools/wbauth.py)。
export EMBYME_AUTH_PASSWORD=test-pass # PowerShell: $env:EMBYME_AUTH_PASSWORD="test-pass"
EmbyMetaEditor.exe -port 8097 -open=false & # 起应用,后面几个脚本共用一个实例| 脚本 | 验什么 | 需要什么 |
|---|---|---|
smoke_auth.py |
访问认证 47 项:未登录 /api/ 全 401、静态资源放行、跨站 Origin 被拒、密钥不下发、留空不清空、改密码踢会话、失败退避、环境变量指定密码 |
无(自建临时实例) |
verify_emby_image.py |
Emby 图片代取链路(与直连 Emby 逐字节比对) | 真实 config |
smoke_real.py |
真实环境只读冒烟 43 项(含国产传媒四站搜索 / 批量 dry-run / 封面代理) | 起 exe + 真实 config |
check_frontend.py |
HTML / JS / 后端路由静态对照;<img> 是否都走同源代理;剪贴板调用是否都走 copyText;profileBody 是否带上了 search_name |
无 |
verify_images.py |
页面图片真的渲染出来(番号补全 / MetaTube / gfriends 三处) | 起 exe + 无头 Edge |
verify_gfriends_pick.py |
选图弹窗:候选全走 /api/img、naturalWidth > 0、那行「宽×高 · 体积」分档统计 |
起 exe + 无头 Edge |
verify_person_lib.py |
按媒体库 / 人物类型筛选的交互(切库、总数、换批、切回;三档类型总数互不相同) | 起 exe + 无头 Edge |
verify_magnet_tabs.py |
磁力按番号分页(标签切换、复制当前 / 全部、空态;每行是完整 <a href>、无 clipboard 时回退) |
起 exe + 无头 Edge |
verify_clipboard_insecure.py |
非安全上下文下的复制(局域网地址 isSecureContext === false、回退生效、提示可见)—— Docker 的真实访问方式 |
起 exe(-host 0.0.0.0)+ 无头 Edge |
verify_cn_view.py |
国产传媒选库 → 列表 → 单选/多选刮削 → 编辑元数据(41 项,写路径全用假 api()) |
起 exe + 无头 Edge |
verify_dry_run.py |
批量刮削预演真的不写(29 项:预演按钮发出 dry_run=true、真写按钮发 false、抽屉写明「没有写入任何东西」、逐字段对照表与 .same 灰行、抽屉里没有任何写入按钮;国产传媒同一条链路) |
起 exe + 无头 Edge |
verify_item_history.py |
条目写入历史 / 回滚的界面(16 项:切到设置页自动拉取、列表四列、已回滚行置灰且不再给回滚按钮、回滚点两次才发 POST、请求体是 {"id":…}、写着「图片不可还原」) |
起 exe + 无头 Edge |
verify_offline_lib.py |
内嵌的离线资料库端到端(18 项:设置页没有那张作废的卡片、人物归并整页下线且 /api/persons/merge 回 404、离线源排第一、offline_db 状态字段不再下发;再拿 data/actresses_export.csv 里的真实记录查一次 —— 命中、来源标注、简介里的出生地与身高和数据一致、真实重名写法给出告警) |
起 exe + 无头 Edge |
verify_profile_completeness.py |
头像页的资料完整度百分比(8 项:每张卡都画了完整度区块、文案是「资料 N%」且只落在 20 的档位上、0% 与 .zero 类一一对应;再直连 Emby 挑出资料最全的和中间档位的几位,逐一在界面搜出来核对「界面显示 == Emby 原始数据」—— 全 0% 的抽样证明不了任何事) |
起 exe + 无头 Edge + 配好 Emby |
verify_magnet_sources.py |
磁力多源(16 项:诊断同时问各源、javdb 被限频的原因写出来、每行磁力标出来源与各源小结、磁力地址完整不截断、源开关与 javdb_url 真的进保存请求体) |
起 exe + 无头 Edge |
smoke_profile.py |
演员资料只读冒烟(源清单与说明 / 只填空白 / 预览无副作用 / 错误路径);PROFILE_LIVE=1 加重 真实写入 → 回滚 → 校验还原,以及勾选覆盖 → 回滚 → 还原 |
起 exe + 真实 config |
verify_profile_view.py |
资料面板渲染与抓取时机(打开不抓、源分列与两处同步、对照表勾选态、作品区块、覆盖按钮文案与配色、与 emby 对照);手动改「搜索用名字」(拦下 /api/profile/preview 读请求体:带的是手填名、name 仍是 Emby 名、预览与写入两条路径都有;回显实际搜索名、重画不退回原名) |
起 exe + 无头 Edge |
mock_javbus.py + verify_javbus_probe.py |
模拟站点 + 诊断按钮的界面交互 | 起 exe + 无头 Edge |
extract_cn_fixtures.py |
从 cache/debug/ 的原始响应切测试夹具 |
落盘的原始 HTML |
verify_docker_image.py |
推上去的镜像确实是这份代码(匿名拉 manifest:多架构、revision = 本地 tag / HEAD、source、入口、非 root;再解开层在二进制里实查内嵌前后端字面量) |
能连 Docker Hub |
EMBY_LIVE=1 go test -run TestLive |
实机路径,幂等不改数据:头像原样回传后比对 md5、详情读回原样写回不丢字段、写入 → 回滚后字段逐字节还原(挑一个列表字段写真值再撤销,专门验「还原列表要发 []」这条 mock 未必兜得住的行为) |
真实 config |
几条环境约定:
- 带
NO_PROXY:本机若设了 http 代理,127.0.0.1也会被转发出去,脚本满屏 502。export NO_PROXY=127.0.0.1,localhost,<Emby 地址> - 优先用临时数据目录跑验证(
-dir .tmp-test-home,先把真实config.json拷进去,跑完删)—— 这样EMBYME_AUTH_PASSWORD只写进临时 config,不动用户自己的config.json(环境变量会覆盖并持久化,拿test-pass起过一次就永久改掉用户的密码)。 verify_*那批依赖外部无头 Edge(9333),没起会报 502(非产品 bug)。smoke_profile.py的PROFILE_LIVE=1会在cache/sync_history.json留「已回滚」记录,之后verify_profile_view.py第 9 步会跳过(有意)。- 本机没有 MetaTube Server →
verify_images.py[B] 后半段、smoke_real.py的 MetaTube 一项必然失败(已知,非回归)。
Dockerfile 两阶段(golang:1.27-alpine → alpine:3.22),CGO_ENABLED=0 纯静态 ELF,运行层装 ca-certificates(否则 https 取图全挂)。容器以非 root(uid 1000)运行,用宿主目录映射时先 chown 1000:1000。.dockerignore 排掉了 config.json 与 cache/(前者装着真实 Emby 令牌和 OpenAI key,绝不该进构建上下文)—— 但必须放行 data/:那份演员资料库是镜像内容的一部分(//go:embed 打进去的),排掉它镜像照样构建成功,只是容器里永远查不到演员资料。镜像因此比 v1.9.0 大 9 MB 左右。
两个环境变量可注入访问认证:EMBYME_AUTH_USER / EMBYME_AUTH_PASSWORD,传了就每次启动都覆盖。
docker build -t aag111/emby-meta-editor:latest .
docker run --rm -p 8097:8097 -e EMBYME_AUTH_PASSWORD=你的密码 -v emby-data:/data aag111/emby-meta-editor:latest.github/workflows/docker.yml 在打 v* tag 时自动构建 linux/amd64 + linux/arm64 并推送,也可在 Actions 手动触发(改完 Dockerfile 想先验一次)。首次需配两个 secret:DOCKERHUB_USERNAME 与 DOCKERHUB_TOKEN(权限 Read & Write)。
git tag v1.11.0 && git push origin v1.11.0镜像标签由 tag 推导:v1.11.0 → 1.11.0 / 1.11 / 1 / latest(latest 跟最新正式版)。手动触发没有 tag 可比,只推 latest。镜像名固定 <DOCKERHUB_USERNAME>/emby-meta-editor,换 Docker Hub 用户名只动 secret。
Docker Hub 用户名
aag111≠ GitHub 用户名huangmoling,别照搬。手动触发构建的revision= 触发时的mainHEAD,之后再往 main 提交镜像就落后了 ——verify_docker_image.py会如实报 FAIL,重新触发即可。dispatch 镜像的版本标签是latest,所以那个脚本改从镜像对应提交的version.go取版本串来比,而不是拿latest拼vlatest。改了
web/下任何东西都必须重新打镜像 —— 前端是go:embed编进二进制的,源码修对不等于镜像修对。这是唯一一种「CI 全绿、镜像能拉、容器能起,但界面还是坏的」故障(v1.0.8 的镜像就这么带着 gfriends 弹窗的 bug 发出去过)。verify_docker_image.py解开镜像层在二进制里实查内嵌前端,专门守这条。
.github/workflows/test.yml 每次推送与 PR 跑两件事:
| job | 步骤 |
|---|---|
test |
gofmt -l .(有输出即失败)、go vet ./...、go build、go test -count=1 -timeout 10m、check_frontend.py、sync_readme.py --check |
build-windows |
在 windows-latest 上构建 exe → 体积下限检查(小于 15 MB 说明内嵌的演员资料库或 //go:embed web 没生效 —— 少了数据就只有 9 MB 出头)→ grep -aq 二进制里有没有「磁力搜索源」「预演」「离线资料库」,以及内嵌数据的 CSV 表头(id,name_original,name_ja,name_zh_cn,它是连续的一行,只可能来自那份 CSV)→ 并反向断言已下线的「媒体库体检」「hchip」「人物归并」「查找重复人物」「离线演员资料库」「stOffOn」真的没了 → 与仓库里那个有意跟踪的 exe 比对(仅提示,continue-on-error)→ 传构建产物(保留 14 天) |
最后两步是这个 job 存在的理由:源码改对了不等于 exe 改对了。前端是 go:embed 进去的,grep 是唯一能证明「用户拿到的那个 exe 里真有新界面」的手段;字节比对则是提醒「改了 Go 源码要顺手重建仓库里的 exe」,否则下载链上的文件会和源码对不上。
sync_readme.py --check 守的是另一类事故:README 里的版本号 / 体积 / md5 / 单测条数都是手抄的,md5 尤其容易过期,而它看起来最可信。
改动
.github/workflows/*的提交需要令牌带workflowscope(GitHub 对 CI 配置单独设的门槛,与仓库权限无关)。已有 gh 登录时一条命令就地追加,现有 scope 会保留:gh auth refresh -h github.com -s workflow。
main.go 启动、参数、控制台 UTF-8、自动开浏览器、打印初始密码
auth.go 访问认证:PBKDF2 派生、内存会话、失败退避、安全响应头 / CSRF 中间件
config.go 配置结构与持久化(含敏感字段脱敏)
api.go HTTP 路由与处理函数
emby.go Emby REST 客户端
imageproxy.go 图片代理:Referer 防盗链、白名单、6 小时缓存
imageinfo.go 图片尺寸 / 体积探测(选图弹窗那行小字;复用同一套白名单)
metatube.go MetaTube v1 客户端
gfriends.go gfriends 索引下载 / 缓存 / 查询
javbus.go javbus 抓取、HTML 解析、连通性诊断
javdb.go javdb 抓取(搜索 → 详情 → 磁力),自带限频识别
magnets.go 磁力多源编排:源注册、并发抓取、按 btih 合并去重、各源状态
cnmedia.go 国产传媒四站抓取、番号归一化、精确匹配合并
scrape.go 刮削编排、番号比对(含 dry-run 分支与图片槽位计算)
preview.go 预演差异:把 patch 拆成「字段 / 现在的值 / 会改成」的 []FieldChange
actorprofile.go 演员资料来源适配、姓名匹配、字段合并
profile.go 演员资料编排:只填空白 / 按勾选覆盖、搜索用名字、同步快照与回滚、别名记忆、作品列表
itemsnapshot.go 条目写入快照与回滚(与演员资料共用 SyncStore,靠 Kind 区分)
api_profile.go 演员资料路由(sources / preview / apply / batch / history / rollback / aliases / works)
offlinelib.go 内嵌的离线资料库源://go:embed CSV、列映射、写法索引、重名告警
diag.go 诊断包导出(zip):按键名模式脱敏,URL 里的账号密码也抹掉
jobs.go 后台任务与进度
util.go 番号归一化、HTML 辅助、HTTP 客户端
console_windows.go Windows 控制台切 UTF-8
web/ 前端(原生 JS,无构建步骤)
data/ 内嵌进二进制的演员资料库(`actresses_export.csv`,约 9.5 MB)。
由 tools/sqlcipher_dump.py 导出,用 //go:embed 打进 exe 与镜像 ——
**必须入库**,否则 CI 构建出来的产物里没有数据
tools/ 验证脚本:模拟站点 / CDP 界面回归 / 前端自检 / 预演 / 条目回滚 / 磁力多源 / 封面渲染 / 人物按库与类型筛 / 磁力分页 / 剪贴板回退 / 国产传媒 / 演员资料 / 离线资料库(内嵌数据的端到端命中)/ 夹具切取 / README 数字同步 / 实机冒烟
testdata/ 从真实响应逐字节切出来的夹具(javbus / javdb / cn / actor)
app.ico 图标源文件
Dockerfile Docker 镜像定义(两阶段,静态链接)
.github/workflows/docker.yml 打 tag 自动构建并推送 Docker Hub
.github/workflows/test.yml 每次推送跑 gofmt / vet / 单测 / 前端自检 / README 数字,并构建 Windows exe
- 刮削只处理
Movie条目;剧集 / 分集不在范围内。 - 番号靠正则从片名与路径提取,路径里带番号最稳。数字开头的国产番号(
91CM-014、91BCM-002、18BT.NET-…)走单独提取逻辑,见踩坑表。 - 国产传媒四站都是聚合站,改版会让解析失效 —— 每站一个
parseXxx,夹具在testdata/cn/。站点挂了不会让整次刮削失败,失败原因写在该站卡片里,其余照常出结果。 - 麻豆区搜索是模糊的,「搜了没命中」通常正常,不代表站点挂了;要判可达看那站的
OK/Error。 xchina.co详情页有 Cloudflare(403),只取搜索页。- 编辑元数据时发行日期与年份留空 = 不修改(Emby 的
POST /Items/{id}是整对象替换,发空日期要么 400 要么抹掉)。 - 这个构建(4.9.0.42)详情接口不返回
Tags(恒null),标签只体现在TagItems。写仍发Tags,读时两者都看。 - 人物列表的内容取决于你的 Emby 数据:有些刮削器把片商 / 系列名写进条目的
People,于是它们以「演员」身份出现。类型下拉能滤掉「纯导演 / 编剧 / 制片」,滤不掉被当成Actor的片商名(全量 10561 vs 演员+导演 10559,只差 2 条)。 - 「该人物在媒体库里的作品」按 Emby 侧的
People关联查:同一人被写成不同名字、或条目没关联到这个人时不会出现。库名靠Library/VirtualFolders的盘路径匹配,挂载点变了就只显示不出库名(不影响列表)。一次最多 400 条。 - 单卡勾选覆盖是唯一会改人物已有资料的入口(批量永远只填空白)。写前自动留快照,可逐字段回滚,但快照上限 1000 条。
- 条目的写入历史同样有上限(1000 条,超出丢最旧的),且只还原元数据字段,不还原图片 —— 旧海报被覆盖后回滚也找不回来,界面上写明了这一点。
- 人物条目没法用 API 删(v1.10.0 因此下线了「人物归并」):实测
DELETE /Items/{id}对Person一律 403,唯一能删的是第三方插件的全库任务 —— 详见上文「已下线:人物归并」。 - 离线资料库的数据编译期定死:重新导出 CSV 之后必须重新构建才生效(代价换来的是「exe 与镜像各自完整、没有外部依赖」)。
- 磁力多源的并发窗口固定为 2(每番号内部再并发问各源)。开大只换来 403:「请求间隔」是站点级的,不是我们想不想并发的问题。
- javdb 的搜索有 Cloudflare 与 18 岁确认。匿名请求多数情况够用;被拦时把浏览器里的完整 Cookie(含
over18=1与cf_clearance)填进设置。 - 诊断包(设置页可下载)按键名模式脱敏:名字里带
password/token/cookie/api_key/secret/hash之类的值一律替换成<已脱敏:N 字符>。但这终究是模式匹配 —— 往外发之前自己扫一眼config.redacted.json更稳妥。 SortName/ForcedSortName在部分构建上设不进去,见下。- 访问认证没有关闭开关;真的不想要就别把端口开出去(默认只听
127.0.0.1)。
都属于「照文档写就会错」的类型,全已在代码里修掉。
| 现象 | 根因 | 处理 |
|---|---|---|
Emby 报 token_valid: false,但媒体库明明能列出 |
/Users/Me 在 4.9.x 上用 API Key 调会 500(Unrecognized Guid format)—— 该令牌没关联用户 |
Emby.Me 多级回退:/Users/Me → /Users/{已知id} → /Users 列表 |
媒体库刮削 / 点详情报 404 找不到文件 "/Items/513232" |
这个构建只注册了用户作用域的详情路由,GET /Items/{id} 会落到静态文件处理器;写操作(POST /Items/{id}、/Refresh、DELETE …/Images/…)只有全局路径 |
ItemDetail 先试 /Users/{uid}/Items/{id} 再退回全局;写操作保持全局路径 |
| 刮削后条目简介、年份、评分全没了 | POST /Items/{id} 是整对象替换而非部分更新,body 缺失的字段会被清空 |
UpdateItem 以当前完整 DTO 为底再叠 patch;只排除 Etag/MediaSources/MediaStreams/Chapters 这类服务端派生大字段 |
更新报 400 Value cannot be null. (Parameter 'source') |
body 缺 ProviderIds(或为 null)服务端直接拒绝 |
UpdateItem 始终回填非 nil 的 ProviderIds,并与已有外部 ID 合并 |
上传头像报 500 The input is not a valid Base-64 string… |
这个构建的 POST /Items/{id}/Images/{Primary} 要 base64 文本 body,标准 Emby 要原始字节 |
先发原始字节,命中 base64 报错再换格式重试。顺序不能反 —— 标准服务器上 base64 文本会被当图片数据静默存进去 |
上传图片报 400 Unable to determine image file extension from mime type |
服务端用 Content-Type 决定存盘扩展名 | normalizeImageType 归一化 MIME,缺失时按文件头猜;认不出是图片就本地报错,不发请求 |
| 缺失番号列表里封面全是空白 | javbus 图片的 Referer 防盗链 | 服务端 /api/img 代理 + 6 小时缓存 |
| 媒体库卡片角标显示的是年份,不是番号 | /Items 列表接口不返回 SortName(实测全 null,详情接口才返回) |
番号改由服务端算:itemNumber() 复用刮削归一化,/api/items 与 /api/items/detail 都回填 Number |
| javbus 磁力永远「暂无链接」,但接口明明有数据 | ajax 返回裸 <tr> 片段,HTML5 树构造会把游离 <tr> 直接丢弃 |
parseMagnets 先套一层 <table><tbody> 再解析 |
| MetaTube 报「解析 providers 失败」 | v1 实际返回 {"data":{"movie_providers":{…}}},不是文档里的扁平数组 |
三种结构都兼容 |
| 刮削后制作商 / 简介为空 | 实际字段名是 maker / summary,代码里写的 studio / plot |
两套都留,firstNonEmpty 兜底 |
| 番号统计偶发抓不到演员 | 搜索接口有时返回 JSON、有时 HTML | 两种都解析,失败时落盘原始响应 |
| 按媒体库查演员,库 ID 写错时整个请求 500 | /Persons 的 ParentId 非 GUID 时 Emby 直接 500(不是返回空列表);全零 GUID 这种「格式合法但不存在」的正常返回 0 条 |
前端只传真实库 Id 或空串;mock 照抄了这个 500 行为,避免以后误传坏 ID 时单测看不出来 |
国产传媒条目角标显示 CM-014 而不是 91CM-014 |
通用番号正则要求「字母前缀 + 数字」,数字开头的被当噪声前缀截掉。影响面比想象中大 —— 角标、写入的 Tags、搜索关键词全用它 |
itemNumber() 先跑 cnExtractNumber(允许 0–4 位数字前缀),命中且前缀更长时优先返回;HEVC10 1080P 这类压制组标记进 cnNoisePrefix 过滤 |
国产传媒封面全是白框,但 /api/img 明明 200 |
upload.xchina.io 对浏览器返回 Cloudflare 挑战页(ERR_BLOCKED_BY_RESPONSE),服务端带浏览器 UA 是正常图片 |
该图床加进 imageproxy.go 代理白名单,浏览器只跟本机 /api/img 打交道 |
| 加访问认证后所有海报 / 头像变空白 | 图片原来是浏览器直连 Emby(<img src> 里拼 api_key),收紧 CSP(img-src 'self')且令牌不再进 DOM 后,残留直连地址一律取不到图 |
Emby 图片统一走 /api/emby/image 服务端代取(复用 6 小时缓存),前端 embyImg() 只产同源地址;verify_emby_image.py 与直连 Emby 逐字节比对 |
选头像弹窗里 gfriends 结果全是破图,但 curl 那些 CDN 地址都是 200 |
这个弹窗是全项目唯一漏掉 imgSrc() 的渲染点,<img src> 直接写 CDN 外链;CSP 收紧后被浏览器静默拦掉(同页其他图都走代理,所以看着像「个别地址坏了」) |
改走 imgSrc(en.f) → 同源 /api/img?u=…;check_frontend.py 加静态规则扫出所有没走 imgSrc() / embyImg() 的 <img> 模板 |
| 登录失败两次后,本人输对密码也被挡 | 退避表取了 idx = fails,第二次失败就吃到 3 秒锁,「前两次不罚」形同虚设 |
改成 idx = fails - 1;smoke_auth.py 把「连续失败才退避」固定成断言 |
| 「保存设置」把存好的 Emby API Key / javbus cookie 抹掉了 | /api/config 不再下发明文密钥后,前端输入框本来就是空的,后端却还无条件赋值 —— 空串被当成「清空」 |
密钥一律「留空 = 不修改」(含 /api/emby/login 的 API Key);smoke_auth.py 有一条断言守着 |
修完「数字开头番号」之后,每个 mp4 条目都多出一个「番号 MP-4」 |
为了支持 91CM-014 放宽了正则(允许数字前缀、数字部分只要 1 位),结果 .mp4 被拆成 MP + 4。角标、写进 Tags 的内容、搜索关键词全跟着错 —— 修之前抽样 200 条「有番号」的有 196 条 |
numberSourceFields() 抽番号前先抹掉扩展名(reFileExt),MP / CD 这类容器 / 分卷标记进 cnNoisePrefix。TestCNItemNumberIgnoresFileExtension 守着 |
给演员写 Tags 总是「成功」,读回来却永远空 |
这个构建对 Person 的 Tags 是收下不保存:POST 返回 204,但详情 / 列表 / TagItems / 全局标签字典里都没有。同批实测 Overview / PremiereDate / ProductionYear / ProductionLocations / ProviderIds 都能正常存 |
人物资料不写 Tags,各源抓到的标签并进简介最后一行。否则每跑一次都「重复写入成功」,还顺手覆盖用户自己填的标签 |
想清空某字段时发 null 或干脆不带键,服务端都不为所动 |
这个构建里只有发空数组 [] 才能清空数组字段(如 ProductionLocations),null 和省略键都等于「不改」 |
回滚时把快照里缺失的数组 / map / 数字字段补成 [] / {} / 0 再发,而不是 nil |
| 回滚后字段该还原的没还原、外部 ID 被整个抹掉 | 两个独立坑叠加:① 快照缺失值转成 []string(nil),装箱进 any 后 v == nil 是 false(类型化 nil ≠ nil),既没走「清空」分支又被 nil 守卫跳过;② patch["ProviderIds"].(map[string]any) 遇到 map[string]string(回滚快照正是这种)会静默断言失败,发出去一个空 map |
① isNilVal()(reflect 判 Slice/Map/Ptr/… 的 IsNil)用于 updateItem 守卫与 rollbackSync 补空;② 抽 providerIDsFrom(v any) 同时处理两种 map。TestRollbackClearsFieldsThatWereAbsent / TestApplyThenRollbackRestores 守着 |
同步历史里每条记录都点不动(record_id 是空串) |
SyncStore.Add(rec SyncRecord) 是值传递,内部生成的 ID 传不回调用方 |
Add 改成返回 ID,调用方 res.RecordID = a.sync.Add(rec) |
| 批量补资料只跑一页就停;按名字传入的演员每个字段都判成「可写」 | 前者:/Persons 带 SearchTerm 时 TotalRecordCount 恒为 0,拿 total 当终止条件就提前收工。后者:名字解析失败时 person_id 留空,读不到现有值,于是所有字段都像空白 |
前者:分页改成「本页数量 < 每页上限才停」。后者:profileTargets 对纯名字先 PersonByName,解析不到的直接丢掉,契约收紧为「每个目标 ID 必须非空」 |
| gfriends 换了 CDN 节点 / 仓库后,索引照样能下回来,但候选图全部下载失败 | 原实现只给索引配了备用地址,图片永远只用配置里那一个基址 —— cdn.jsdelivr.net 一挂,「刮削头像 / 选图」整体不可用 |
gfriendsCDNBases() 把同一套备用列表用到图片上:主基址一张都没取下来才依次换(正常网络零额外请求),单次尝试上限 30 秒。索引与图片都按「仓库 × 节点」两维展开。TestPickBestGfriendsFallsBackToMirror 守着 |
| 备用基址的图服务端能取到、界面上却是破图 | 白名单里写的是精确主机 cdn.jsdelivr.net,gcore. / fastly. / raw.githubusercontent.com 全不在名单里。curl 那几个地址都是 200,只有浏览器经 /api/img 才被拒 |
白名单改成后缀匹配 .jsdelivr.net / .githubusercontent.com;TestGfriendsCDNBasesAreProxyAllowed 遍历全部候选基址反查白名单 |
| 「裸文件名也能命中」这条分支永远不成立 | 索引里的 File 形如 三上悠亜-1.jpg?t=1657944780,比对时只对传入值剥了 ?t=、没剥索引那侧的 |
抽 gfriendFileBase() 两边都剥;文件名本身仍要求精确相等。TestMatchGfriendEntryAcceptsAnyBase 覆盖带戳 / 不带戳 / 换一张图 |
想给作品标「属于哪个媒体库」,拿条目 ParentId 去对 Views 的 Id 永远对不上 |
条目的 ParentId 是库内子目录(实测像个短 ID 508698),不是 Views 里的库 ID;且 /Users/{uid}/Views 即使带 Fields=Path 也不返回 Path(实测 path=None) |
库名映射改用 GET /Library/VirtualFolders(返回 [{Name, ItemId, Locations[]}]),拿 Locations 盘路径按分段对齐匹配条目 Path 并取最长前缀(/data/Movies/4K 要赢过 /data/Movies)。判定放在 libraryOfPath() 里;查不到就留空,不让整条链路挂掉 |
磁力「按体积排序」后顺序还是乱的(9 排在 1 后面) |
体积是 1.83GB / 2.57 GB 这样的字符串,直接比就是字典序;解析不出体积的若用 0 当默认,会被排到「比所有真实体积都小」的位置 |
magnetSizeBytes() 换算字节(TB/GB/MB/KB),解析不出返回 -1;sortMagnetsBySize() 用 sort.SliceStable 保持同体积原序。排序只在组装层 magnetResultFrom(),parseMagnets 仍保持文档顺序 |
| 给已有值做覆盖验证时,Emby 明明写进去了,读回来却「没变」 | 不是没写:Emby 会规范化日期(写 1996-04-22,读回 1996-04-22T00:00:00.0000000Z),年份同理;provider_ids 还是合并写(原有的 MetaTube: / Gfriends: 行都留着)。直接比字符串满屏假 FAIL |
覆盖率断言按语义比:日期只比前 10 位,外部 ID 只要求「抓到的每一行都进了 Emby」。同时钉清「只填空白」是默认策略,单卡带 keys 时以勾选为准 |
| 想在选图弹窗标出文件体积,前端怎么都拿不到 | 浏览器没有任何 API 能读一张 <img> 的字节数;fetch 拿 blob.size 会被 CSP connect-src 'self' 挡在跨域之外,而这条 CSP 是防 XSS 的一部分,不能为一个数字开口子。像素也一样:naturalWidth 得等整张图下完 |
服务端加 POST /api/img/info 批量代取,复用 /api/img 白名单(非白名单逐条 ok:false 而不是整批 5xx);image.DecodeConfig 只解析文件头(全解码一张 2000px 图要几十毫秒)。探测顺带写进 ImageProxy 缓存,弹窗缩略图随后秒开。界面先出图、后补小字,所以 verify_gfriends_pick.py 必须把「还没探完」和「读不回来」分开数 |
| 资料抽屉里加了一组「选源」勾选框后,点「写入勾选字段」报「写入 0 个字段」,按钮上却写着「写入 3 个字段」 | 抽屉里有两组 input[data-key](选字段 / 选源),提交时 $$('#pfBody input[data-key]:checked') 把源名(AvDataBank 等)也当字段名发了上去 |
提交范围收到对照表:$$('#pfBody .pf-tbl input[data-key]:checked')。两组勾选框语义完全不同,别用同一个选择器一锅端 |
| 界面回归脚本的断言详情里传了个 dict,脚本在最需要它的时候炸掉 | check() 是把详情直接拼进输出字符串的,一旦断言失败(正要看快照的时候)就抛 TypeError,堆栈把真正的失败信息顶掉。verify_profile_view.py 还有一处更早的:把 Page.errors() 当 dict 用(e.get("method"))—— 它返回的是字符串,所以那段代码只在没有任何报错时才不炸 |
详情统一 str(detail),逐条打印与结尾失败汇总两处都要;错误分类改成按前缀判(HTTP … / 未捕获异常 / console.error) |
| 复制磁力按钮在本机 exe 正常,Docker 部署后点了没反应(连失败提示都没有) | navigator.clipboard 只在安全上下文里存在:http://127.0.0.1 算,http://192.168.x.x(Docker / NAS / 局域网访问)不算。非安全上下文里它是 undefined,writeText() 直接抛 TypeError;这行异常写在 onclick 里会被浏览器吞掉,于是既不复制、也不报错。本机测不出来,必须走局域网地址 |
抽 copyText():先特性检测(try 包住,同步抛也算),不可用就回退隐藏 <textarea> + document.execCommand('copy')(回退元素得 position:fixed;width:1px;height:1px;opacity:0,display:none 会让 execCommand 返回 false);两条路都失败才弹看得见的错误提示。verify_clipboard_insecure.py 用局域网地址实测 |
| 点磁力链接弹不出 javbus 的预览图,控制台也不报错 | 预览图是 javbus 页面脚本注入到我们页面里的外部 <img>,被 CSP 的 img-src 'self' data: blob: 一律拦掉,而且拦得极安静 —— 图片只是不显示。v1.4.0 当时的修法是放开 img-src 到 http: https:,方向是错的:这些图是防盗链的(同一张 pics/sample/c421_1.jpg:无 Referer → HTTP 403 text/html,带 javbus Referer → HTTP 200 image/jpeg 4708 字节),放宽 CSP 治不了 403 —— 放宽之后浏览器倒是肯发请求了,请求照样被对方拒绝。真正的代价是:img-src 一放开,我们自己模板里的外链 <img> 也再没有浏览器兜底了(追踪像素、外链图片旁路都回来了),而这一类错误在命令行上复现不出来(curl 服务端代取永远是 200) |
v1.6.0 把 img-src 收回 'self' data: blob:,并改成我们自己实现预览:/api/javbus/samples 从详情页 #sample-waterfall 解析出样例图,前端一律经 /api/img 同源代取(imageproxy.go 会自动补上目标站 Referer),所以 403 问题从根上消失。TestSecurityHeaders 现在会切出 img-src 指令并断言它不含 http: / https: / *(以前只查了个 img-src *,等于没查)。另外 util.go 的 pageMarkers 加上了 sample-waterfall / pics/sample/,让诊断能区分「页面里没这个区块」和「解析器坏了」 |
磁力地址在列表里显示成 magnet:?xt=… 后面接省略号,点它没反应、也触发不了预览 |
之前把地址当纯文本渲染,还 slice(0,110) + '…' 截断。磁力处理脚本按 <a href> 里完整的 URI 认链接 —— 截断后既不是合法磁力、也不在 DOM 的 href 里,依赖 href 的工具(javbus 预览、下载器接管)全失效 |
每行渲染成真正的 <a href="完整地址">,可见文本也是完整地址,长地址靠 CSS text-overflow: ellipsis 省略。verify_magnet_tabs.py 用一条 200+ 字符、带 dn 与两条 tr 的真实地址钉住「参数一个不少」 |
人物类型筛到「仅演员」,列表里还是有片商名(如 プレミアムビデオ) |
Emby 人物库里连片商名都建成了 Person、类型就是 Actor,按 PersonTypes 过滤不掉(实测全量 10561、演员 9497、导演 1189、演员+导演 10559,只差 2 条)。且返回条目的 Type 恒为 Person,「是演员还是导演」只在查询参数里 |
如实写明:类型筛选滤得掉「纯导演 / 编剧 / 制片」,滤不掉被当成演员的片商名。默认 Actor,Director,?types= 支持 all 与未知值兜底回默认;TestPersonTypesParam / TestHandlePersonsPassesPersonTypes 守着 |
| 预演说「会改 3 个字段」,真写却改了 5 个 —— 预览成了谎话 | 预演和真实写入各写一份「算出要改什么」的条件,两边只靠人工保持一致。这类分叉不会报错,只会让预览慢慢变得不可信;而预览一旦不可信,用户就会开始凭感觉点批量 —— 偏偏 Emby 的写入是不可逆的 | 把「算出要改什么」抽成两边共用的一份东西:MetaTube 走 imageSlotsToWrite() + thumbSource(),国产传媒走 planCN()(从 applyCN 原样拆出的纯计算),previewPatch() 只负责把 patch 和现值比对成一张表。TestScrapeMovieDryRunMatchesRealWrite / TestCNPlanMatchesRealWrite 逐字段核对「预演算出的新值」与「真写进去的值」是否一致 |
想模拟「浏览器没有 clipboard」测回退,delete navigator.clipboard 之后它还在 |
clipboard 是挂在 Navigator.prototype 上的 getter,delete 删实例属性对它无效;于是回退分支根本没被执行,测试却「通过」了 —— 假绿比红更危险 |
用 Object.defineProperty(navigator, 'clipboard', {value: undefined, configurable: true}) 在实例上盖住。断言前先清掉已有 toast,否则会看到上一条残留的「已复制」而误判 |
| 做「诊断包」给用户发给作者排障,怎么保证不泄漏密钥 | 逐个列举要脱敏的字段名是最自然的写法,但它是漏一个就完的:以后新加一个 xxx_token,脱敏代码不会报错、不会红,只会安静地把新密钥打进包里 —— 而用户拿到包的第一件事就是把它贴到公开 issue | 改成按键名模式匹配(password / token / cookie / api_key / secret / hash…),新字段自动被覆盖;只有非空字符串才替换(password_generated 这种值是 bool 的开关保留,否则看不出「密码还是自动生成的」),并在标记里带上原值长度(能看出「只填了 3 个字符」这类手抖)。URL 里的 user:pass@ 也抹掉。TestDiagBundleNoSentinelAnywhere 往配置里塞哨兵值,然后逐字节扫整个 zip |
| 多源磁力的去重函数「单测写得挺全」,实际却一个种子都没去重 —— 两个站都有的种子在界面上出现两遍 | mergeMagnets 抽出来之后只被单测调用过,生产路径(fetchOneMagnetTarget)直接把各源结果 append 起来就返回了。这类「helper 测得很足、但调用点没接上」的错,单测永远是绿的:它测的是那个函数本身,不是「它有没有被用」 | 把合并去重写进并发抓取函数内部(调用点与实现放在同一处,改动时跑不掉),并补一条盯接线的用例:TestFetchOneMagnetTargetDedupsAcrossSources 让两个假源返回同一个 btih,断言结果只剩一条 |
| 用 DELETE /Items/{随便编的ID} 试路由,回了 204,于是判断「删除路由可用」 | 服务端在 item == null 时直接短路返回 NoContent —— 204 在这里只说明「这个 ID 不存在,我什么都没做」,完全不能证明「存在的东西删得掉」。而真正说明问题的是条目自己的 CanDelete:影片条目是 true,人物条目是 false | 判据换成条目响应里的 CanDelete(外加实机对真实人物条目试一次)。结论是 Emby 不支持通过 API 删 Person —— 于是 v1.10.0 把「人物归并」整块下线,见上文 |
| 内嵌的数据没被跟踪 / 没进构建上下文,产物照常构建、界面照常打开,只是永远查不到人 | v1.10.0 把资料库改成 //go:embed 之后,「数据在不在产物里」成了编译期就定下来的事,而它坏掉时没有任何报错:.gitignore 把 data/ 排掉、.dockerignore 误伤、或者换行被 checkout 改掉,症状都只是「这个源一条都命中不了」 | 四层一起守:CI 按 exe 体积下限 15 MB 判、并在产物里 grep CSV 表头连续串(只可能来自那份文件);verify_docker_image.py 对镜像做同样的内嵌字面量断言;单测 TestOfflineLibraryParsesBuiltinData 真解析内嵌数据并要求条目数过万;verify_offline_lib.py 再用真实记录跑一次端到端命中 |
| javdb 的磁力排序和站点自己的「按大小排序」不一致 | 页面上那行「6.33GB, 1個文件」是给人看的(可能被截断、可能省略小数),而站点排序用的是条目容器上的 data-size(单位 MB 的整数)。照直觉取前者,得到的顺序和人家不一样 | 解析时优先 data-size 再回退那行文字;夹具是真实响应逐字节切的(tools/fetch_javdb_fixture.py),断言写死「data-size="6480" → 6.33GB」,手写夹具测不出这个差异 |
| 设置页点了「保存全部设置」,页面显示的还是保存前的样子 | loadConfig() 只回填登录页那几个字段,设置页是切到「设置」时才画的 —— 保存之后没有任何人重画它。用户的第一反应是「没保存成功」,然后再点一次 | saveSettings() 保存成功后补一次 fillSettings();check_frontend.py 静态钉住「保存后必须重画」。这条是 tools/verify_offline_lib.py 抓出来的 —— 只读接口返回 200 是看不出来的。(现在它守的是另一件事:填了新密钥点保存,密钥框还留着刚输入的明文。) |
| 给 /Persons 加字段时改了结构体,却忘了改 Fields | 结构体加了、JSON 标签也对、mock 照样把值返回给你 —— 单测全绿。但真实 Emby 只返回 Fields 里列出的字段(实测不带 Fields 时返回体连 ProviderIds 都没有),线上表现为「所有人的资料完整度都是 0%」:不报错、不崩溃、界面也正常,只是没数 | 字段清单收进 emby.go 的 personListFields 常量(唯一出处,注释里写着实测数字);mock 记下最近一次 /Persons 收到的 Fields,TestHandlePersonsRequestsProfileFields 断言含全体字段;verify_profile_completeness.py 再拿 Emby 条目做独立参照核对非零档位 —— 全 0% 的抽样是「一致地错」,证明不了任何事 |
| 从登录页的高级配置连一次 Emby,设置页里的「自动刷新」「覆盖已有图片」两个开关被静默关掉 | 登录页那份配置只发一部分键(不含这两项),而 handleSaveConfig 当时对布尔项是直接 c.X = in.X —— 布尔值反序列化后,「键不在」和「键在且为 false」长得一模一样,没提交的键被零值覆盖 | 改成「请求里出现过这个键才覆盖」:先读一遍原始 body 收集出现过的 key,再逐项覆盖。TestSaveConfigKeepsAbsentKeys 正反两面钉住:缺键不清空、显式 false 必须真的关掉 |
| javdb 抓几次之后开始返回 403,但页面里没有 Cloudflare 的特征 | 那不是 Cloudflare 挑战,是 javdb 自己的限频:响应体只有一句「操作過於頻繁,請等一会再試」。当挑战处理会去翻 Cookie,翻到天亮也没用 | JavDB.get() 单独识别这句话,提示「已自动放慢重试,一直如此就把请求间隔调大」;客户端整批抓取只建一次(限速器是实例级的,逐条新建等于每条都从零计时) |
| 删掉「媒体库体检」之后,某个界面上还留着一个必然 404 的入口 | 删功能最典型的漏法是删一半:后端路由删了、前端导航和渲染函数还在(或者反过来)。两边都能编译通过、测试也能全绿 —— 只有用户点进去才发现 | 三层一起守:check_frontend.py 静态扫(导航、按钮、渲染函数、接口路径),CI 在产物里反向 grep 必须消失的字面量(v1.7.0 的「媒体库体检」「hchip」;v1.10.0 的「人物归并」「查找重复人物」「离线演员资料库」「stOffOn」),verify_docker_image.py 也对内嵌前后端做同样的反向断言 |
| 离线资料库读进来了,但一条记录的每个字段都是空的,而文件看起来完全正常 | 导出脚本用 encoding="utf-8-sig" 写 CSV → 文件带 UTF-8 BOM → 表头第一列变成 "\ufeffid",按列名取值全部落空。症状很隐蔽:没有报错,只是什么都没有 | 读之前先剥 BOM,并且两种都要测:夹具默认带 BOM(守「剥掉」),另有一条用例喂不带 BOM 的文件(守「别无脑截 3 字节,那会吃掉真实数据的头 3 字节」)。.gitattributes 里给 data/** 加 -text,免得 checkout 的换行转换也来动它 |
| 用 sqlite3_key 下了错的口令,导出得到 0 行,看起来像「这个库是空的」 | SQLCipher 是第一次读页时才校验口令的。循环写成 while step() == SQLITE_ROW 时,错误码 SQLITE_NOTADB(26) 不等于 ROW,循环就安静地结束了 —— 错误被当成了「读完」 | 让 step 只有 ROW/DONE 两种正常返回,其余一律抛异常;打开后先跑一次 SELECT count(*) FROM sqlite_master 主动验证口令,把「口令错」和「表是空的」分开 |
| 离线库里一个写法对应多条记录,我们却悄悄挑了一条,把别人的出生日期写进了 Emby | 实测这份库里 name_original 有 15% 的键、kana 有 18% 同时属于两条以上记录(重名的不同演员,或同一人的新旧两条)。索引「先到先得」本身没错,错的是没告诉用户 | 索引记住「同一个键还指向谁」(offlineSlot.dupes),命中多条时把这件事写进 ActorFacts.Note,由 buildActorProfile 并进面板告警(带上另一条的 id)。同时让本名类写法优先于读音类,且认下的那条是确定的(按文件顺序),不是随机的 |
| 「取数值列」写成了直接读字符串,于是简介里出现「身高:163.0 cm」 | 导出脚本把 height_cm / bust_cm 这类列写成了浮点(163.0)。直接透传的话,同一个「身高」行在这个源和别的源(163)里是两种格式 | 数值列统一过一遍 strconv.FormatFloat(v, 'f', -1, 64):整数去掉 .0,真小数(85.5)保留。认不出的原样返回,不猜 |
SortName / ForcedSortName 在这个 Emby 构建(4.9.0.42)上无法通过 API 设置。实测三种 payload 形态、只发 ForcedSortName、以及 /Items/{id}/Metadata 端点,全部无效(POST 返回 204 但服务端总是按 Name 重新计算),条目本身没有锁定(LockedFields 为空)。
好在原始日文标题同时存在于 OriginalTitle 里,没真的丢。刮削时也就没必要往 patch 里塞这两个字段了。
MIT License.










