app-template/docs/superpowers/specs/2026-09-26-dandanplay-danma...

8.1 KiB

弹弹play 弹幕获取 + 刮削能力打通 — 设计规格

  • 日期:2026-09-26
  • 状态:已实现(与并行会话的增强实现合并;实现超出本文档处见 §7)
  • 上游规格:2026-09-25-dandanplay-web-design.md(开放弹幕网络中继 + Bangumi 刮削已批准)

1. 问题

上一版实现后能力不可用/不完整,用户反馈「没有实现接入弹弹play 的弹幕获取和刮削能力」:

  1. 弹幕获取实际拿不到:danmaku.service.ts 客户端已写(match/签名/16MB head MD5/24h 缓存),但
    • 根 .env 中 OPEN_DANMAKU_APP_ID/APP_SECRET 为空 → 运行时恒走「未配置开放网络」降级,零弹幕;
    • match 请求的 fileName 传了含扩展名的完整文件名,官方 swagger 明确要求「不包含文件夹名称和扩展名」,降低识别率;
    • HTTP 非 200 / errorCode≠0 全部吞成 null,排障无从下手;
    • match 成功得到的 episodeId 不落库,每次播放都重新 16MB 读流 + match。
  2. 刮削没有弹弹play 源:只有 Bangumi 文件名搜索;match 响应中的 animeTitle/episodeTitle/imageUrl/animeId 元数据被丢弃。

2. 方案选型

方案 说明 结论
A. 双源互补(推荐) dandanplay 做「文件级识别 + 弹幕身份」源,Bangumi 保留做「文件名级刮削」源;识别结果落库复用 ✅ 采纳
B. 全面换 dandanplay 扫描期每文件要读 16MB 算 hash,大库不可行;凭据未申请时连展示元数据都没有 ❌
C. 只修弹幕、刮削维持现状 不满足「刮削能力」诉求 ❌

凭据配置:维持 env-only(OPEN_DANMAKU_APP_ID/SECRET/API_BASE),不做 DB 存凭据的设置 UI(YAGNI,单实例自托管);弹幕设置页明示配置状态,消除「静默降级」体感。

3. 官方 API 事实依据(2026-09-26 swagger 实读,api.dandanplay.net/swagger/v2/swagger.json)

  • 签名:X-Signature = base64(sha256(AppId + Timestamp + Path + AppSecret)),头 X-AppId/X-Timestamp/X-Signature(与现实现一致)。
  • POST /api/v2/match:请求 {fileName(无扩展名), fileHash(前16MB MD5,可空), fileSize, matchMode(hashAndFileName|fileNameOnly|hashOnly)};响应 MatchResponseV2 = ResponseBase + {isMatched, matches: MatchResultV2[]};MatchResultV2 = {episodeId, animeId, animeTitle, episodeTitle, type, typeDescription, shift, imageUrl};ResponseBase = {errorCode, success, errorMessage}。
  • GET /api/v2/comment/{episodeId}?withRelated=true:响应 {count, comments: [{cid, p: "time,mode,color,uid", m}]}(与现 XML 转换兼容)。
  • GET /api/v2/bangumi/bgmtv/{bgmtvSubjectId}:Bangumi subjectId → 弹弹play 番剧详情(BangumiDetails.episodes[] = {episodeId, episodeTitle, episodeNumber(短标题可排序), airDate}),免 hash 桥。

4. 设计

4.1 数据模型(@app/models)

media_items 新增一列:

字段 说明
dandanplayEpisodeId integer 可空;弹弹play 弹幕库编号(识别成功后落库,弹幕拉取直达)

matchedHash(原预留列)开始写入:成功 hash 识别时存「前 16MB MD5」。迁移:yarn db:generate + yarn db:push。

4.2 服务层(@app/trpc)

danmaku.service = 开放 API 客户端单点(scrape 复用其导出):

  • matchFileName(path):去扩展名修 fileName(新增,含点文件名边界)。
  • openMatch(fileName, size, hash) → {ok, errorCode, errorMessage, isMatched, candidates: MatchResultV2[]}:非 200/errorCode≠0 透出 errorMessage,不再吞 null。
  • openCommentXml(episodeId) → XML(原逻辑拆出)。
  • remoteFileHash() 导出供 scrape 用;openHeaders 恒带 Content-Type。
  • fetch() 流程改为:
    1. 无凭据 → source:"none" + 明确文案(含「需配置 OPEN_DANMAKU_APP_ID/SECRET」);
    2. item.dandanplayEpisodeId 已存在 → 跳过 match 直接 comment;
    3. 否则 hash + match;精确命中(isMatched)时 setDanmakuMatch 落库 episodeId+matchedHash(仅身份,不动标题/海报——改标题属刮削语义,由识别入口显式做)。

scrape.service.matchDandanplay(userId, mediaItemId)(新增,手动入口):

  1. 无凭据 → 报错提示配置;已有 dandanplayEpisodeId 且未要求重识别 → 直接返回已识别信息;
  2. hash + match:isMatched 或有候选 → 取首条,写 dandanplayEpisodeId/matchedHash/title=animeTitle/posterUrl=imageUrl/scrapeStatus=ok;
  3. match 未中且 bangumiId 存在 → bgmtv 桥:pickDandanEpisode(episodes, epNumber) 按集数择集,只补 dandanplayEpisodeId;
  4. 输出 DanmakuMatchOutput {ok, matched, message?, result?},UI toast 展示。

pickDandanEpisode 为纯函数:episodeNumber 数值相等优先,否则同位序回退(第 N 条)。

4.3 tRPC 路由

  • media.scrapeDandanMatch(mutation):入参 {mediaItemId},调 scrapeService.matchDandanplay。
  • media.danmakuGetSettings:响应增加 openNetworkConfigured: boolean(其余仍为本地偏移默认值,持久化仍走 localStorage,不在本次范围)。
  • media.danmakuFetch 行为如 4.2,签名不变。

4.4 前端(@app/web + @app/i18n)

  • 库页匹配弹窗顶部加「弹弹play 文件识别」按钮:调 scrapeDandanMatch,成功 toast 展示 animeTitle/episodeTitle,失败透出 message;弹窗仍保留 Bangumi 搜索绑定。
  • 弹幕设置页顶部状态条:「开放弹幕网络:已配置 / 未配置(服务端 .env 设置 OPEN_DANMAKU_APP_ID/APP_SECRET 后重启)」。
  • i18n:zh-CN 权威,en 同构新增 ~8 key。

4.5 错误处理

场景 策略
凭据未配置 明确文案 + 设置页状态条;不阻断播放/本地 XML
match/comment HTTP 错误或 errorCode≠0 message 透传 errorMessage;返回 source:"none"
hash 读流失败 降级 fileNameOnly 匹配
识别无候选 matched=false + 文案;Bangumi 已绑走桥接兜底
桥接无对应集数 只提示,不写库

4.6 测试与验证

  • 纯函数单测(沿用 vitest 模式):matchFileName(去扩展名/点文件名)、match 响应解析(isMatched/候选/errorCode)、pickDandanEpisode(集数命中/回退)。
  • 命令:yarn workspace @app/trpc test、yarn typecheck、yarn lint、yarn build:web。
  • 真实弹幕需官方凭据:设置页状态条 + 文档说明申请入口,凭据就绪后播放页冒烟即可验证。

5. 不做(本次)

弹幕发送(POST comment)· match 候选多选 UI(并行会话已补,见 §7)· shift 弹幕偏移应用 · chConvert 简繁参数 · 扫描期批量 hash 识别。

6. 批准记录

用户提出能力缺口后未在线答复澄清(问询超时),按最佳判断采纳方案 A 推进;如与预期不符,数据模型/service 边界已隔离,可低成本调整。

7. 实现终态(含并行会话合并)

实现期间工作区另一会话同步推进同一能力,终态为本设计 + 以下增强(均已在工作树落地并通过 typecheck/test/build):

增强点 说明
@app/danmaku 独立包 开放 API 客户端/签名/hash/匹配/过滤/XML 纯函数下沉(./browser 子路径无 node:crypto)
凭据 DB 化(超越 env-only 决策) app_settings 表存 AppId/Secret,管理员设置页可改,env 兜底,运行时热更新(settings.service)
候选手选式匹配 match 结果 rankCandidates 打分;高置信自动绑,低置信在播放页手选(danmakuMatch/danmakuSelectMatch),另支持关键词指定弹幕源(searchSource/sourceEpisodes)
服务端弹幕文档 danmaku_docs 表持久化 local-xml/open-network 全文;同目录同名 .xml 侧车自动关联
用户弹幕偏好落库 danmaku_prefs 表(enabled/opacity/density/屏蔽词/类型屏蔽),取代 localStorage
匹配来源审计 media_items.danmaku_match_source 列(auto/manual/none)

本会话独有贡献:fileName 去扩展名修复、setDanmakuMatch 身份落库、scrapeService.matchDandanplay(刮削写标题/海报 + bgmtv 桥)、库页识别入口、match 响应解析与择集单测、openNetworkConfigured 状态透出。

遗留:WatchBody/globals.css 有 5 处 lint 违规(并行会话在改文件,未越界处理);真实弹幕需向弹弹play 官方申请 AppId/AppSecret(管理员设置页或服务端 .env 配置)。