docs(specs): dandanplay danmaku fetch + scrape capability design
This commit is contained in:
parent
f48f0c8d51
commit
e05645a342
|
|
@ -0,0 +1,103 @@
|
||||||
|
# 弹弹play 弹幕获取 + 刮削能力打通 — 设计规格
|
||||||
|
|
||||||
|
- **日期**:2026-09-26
|
||||||
|
- **状态**:设计已定(用户未在线,按最佳判断推进,详见批准记录)
|
||||||
|
- **上游规格**:`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 · `shift` 弹幕偏移应用 · chConvert 简繁参数 · 扫描期批量 hash 识别 · DB 存凭据。
|
||||||
|
|
||||||
|
## 6. 批准记录
|
||||||
|
|
||||||
|
用户提出能力缺口后未在线答复澄清(问询超时),按最佳判断采纳方案 A 推进;如与预期不符,数据模型/service 边界已隔离,可低成本调整。
|
||||||
Loading…
Reference in New Issue