diff --git a/packages/danmaku/src/client.test.ts b/packages/danmaku/src/client.test.ts index 10ccc68..3d664db 100644 --- a/packages/danmaku/src/client.test.ts +++ b/packages/danmaku/src/client.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { hasOpenCredentials, openApiBase, + openBangumiEpisodes, openCommentXml, openHeaders, openMatch, @@ -113,3 +114,39 @@ describe("openMatch 请求契约", () => { }); }); }); + +describe("openBangumiEpisodes 请求契约", () => { + it("GET /api/v2/bangumi/bgmtv/{id},解析 episodeNumber", async () => { + const fetchMock = stubFetchJson({ + success: true, + bangumi: { + episodes: [ + { episodeId: 11, episodeTitle: "ep1", episodeNumber: "1" }, + { episodeId: "12", episodeTitle: "ep2", episodeNumber: "2" }, + { episodeTitle: "bad" }, // 无 episodeId → 丢弃 + ], + }, + }); + const r = await openBangumiEpisodes(42); + const url = String(fetchMock.mock.calls[0]?.[0]); + expect(url).toBe("https://api.dandanplay.net/api/v2/bangumi/bgmtv/42"); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.episodes).toHaveLength(2); + expect(r.episodes[1]).toEqual({ + episodeId: 12, + episodeTitle: "ep2", + episodeNumber: "2", + type: null, + typeDescription: null, + }); + }); + + it("HTTP 失败返回 ok:false 且 episodes 为空", async () => { + const fetchMock = stubFetchJson({}); + fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) }); + const r = await openBangumiEpisodes(1); + expect(r.ok).toBe(false); + expect(r.episodes).toEqual([]); + }); +}); diff --git a/packages/danmaku/src/client.ts b/packages/danmaku/src/client.ts index 0dfe056..5490104 100644 --- a/packages/danmaku/src/client.ts +++ b/packages/danmaku/src/client.ts @@ -34,7 +34,11 @@ export function effectiveCredentials(): OpenCredentials | null { } export function openHeaders(path: string, creds: OpenCredentials | null): Record { - const headers: Record = { "Content-Type": "application/json" }; + const headers: Record = { + "Content-Type": "application/json", + // 登录态存在时附加 Bearer token(可选增强;官方 match/comment 不强制) + ...authHeaders(), + }; if (!creds) return headers; const timestamp = Math.floor(Date.now() / 1000); return { @@ -119,6 +123,7 @@ export type OpenAnimeBrief = { export type OpenEpisodeBrief = { episodeId: number; episodeTitle: string | null; + episodeNumber: string | null; type: string | null; typeDescription: string | null; }; @@ -126,6 +131,7 @@ export type OpenEpisodeBrief = { type RawEpisode = { episodeId?: number | string; episodeTitle?: string | null; + episodeNumber?: string | null; type?: string | null; typeDescription?: string | null; }; @@ -150,6 +156,7 @@ function parseEpisode(e: RawEpisode): OpenEpisodeBrief | null { return { episodeId, episodeTitle: e.episodeTitle ?? null, + episodeNumber: e.episodeNumber ?? null, type: e.type ?? null, typeDescription: e.typeDescription ?? null, }; @@ -198,6 +205,21 @@ export async function openAnimeEpisodes(animeId: number): Promise { + const res = await openFetchJson(`/api/v2/bangumi/bgmtv/${bangumiId}`); + if (!res.ok) return { ok: false, errorMessage: res.error, episodes: [] }; + const body = res.json as { bangumi?: { episodes?: RawEpisode[] } }; + const episodes = (body.bangumi?.episodes ?? []) + .map(parseEpisode) + .filter((x): x is OpenEpisodeBrief => x != null); + return { ok: true, episodes }; +} + /** 会话 token(登录弹弹play 后设置;请求附加 Authorization) */ let sessionToken: string | null = null; diff --git a/packages/danmaku/src/index.ts b/packages/danmaku/src/index.ts index 0d1e9f3..3c8bbc3 100644 --- a/packages/danmaku/src/index.ts +++ b/packages/danmaku/src/index.ts @@ -9,6 +9,7 @@ export { hasOpenCredentials, openApiBase, openAnimeEpisodes, + openBangumiEpisodes, openCommentXml, openFetchJson, openHeaders, @@ -23,6 +24,7 @@ export { } from "./client.js"; export type { OpenAnimeBrief, + OpenBangumiEpisodesOutcome, OpenCredentials, OpenEpisodeBrief, OpenEpisodesOutcome, diff --git a/packages/danmaku/src/match.test.ts b/packages/danmaku/src/match.test.ts index 45adf47..26b47f9 100644 --- a/packages/danmaku/src/match.test.ts +++ b/packages/danmaku/src/match.test.ts @@ -155,4 +155,90 @@ describe("rankCandidates", () => { ]; expect(rankCandidates(list).highConfidence).toBe(false); }); + + it("刮削别名参与打分:日文原名命中候选标题", () => { + const list = [ + { + episodeId: 1, + animeId: 1, + animeTitle: "Sousou no Frieren", + episodeTitle: "01", + imageUrl: null, + }, + { + episodeId: 2, + animeId: 2, + animeTitle: "Mairimashita Iruma-kun", + episodeTitle: "01", + imageUrl: null, + }, + ]; + // 中文标题与候选均不匹配,别名(日文罗马音)命中 + const r = rankCandidates(list, { + hint: { title: "葬送的芙莉莲", aliases: ["Sousou no Frieren"] }, + }); + expect(r.highConfidence).toBe(true); + expect(r.candidates[0]?.episodeId).toBe(1); + }); + + it("结构约束:集号相等的候选硬收敛,集号不符者出局", () => { + const list = [ + { episodeId: 1, animeId: 1, animeTitle: "A", episodeTitle: "05", imageUrl: null }, + { episodeId: 2, animeId: 2, animeTitle: "B", episodeTitle: "12", imageUrl: null }, + ]; + const r = rankCandidates(list, { hint: { title: "A", epNumber: 12 } }); + expect(r.candidates).toHaveLength(1); + expect(r.candidates[0]?.episodeId).toBe(2); + expect(r.highConfidence).toBe(true); + }); + + it("结构约束:无集号相等时剔除剧场版/OVA 类候选", () => { + const list = [ + { + episodeId: 1, + animeId: 1, + animeTitle: "A", + episodeTitle: "剧场版", + imageUrl: null, + }, + { episodeId: 2, animeId: 2, animeTitle: "B", episodeTitle: "最终话", imageUrl: null }, + ]; + const r = rankCandidates(list, { hint: { title: "A", epNumber: 12 } }); + expect(r.candidates).toHaveLength(1); + expect(r.candidates[0]?.episodeId).toBe(2); + }); + + it("结构约束:type 字段标记剧场版同样剔除", () => { + const list = [ + { + episodeId: 1, + animeId: 1, + animeTitle: "A", + episodeTitle: "故事的终结", + typeDescription: "剧场版", + imageUrl: null, + }, + { episodeId: 2, animeId: 2, animeTitle: "B", episodeTitle: "12", imageUrl: null }, + ]; + const r = rankCandidates(list, { hint: { epNumber: 12 } }); + expect(r.candidates[0]?.episodeId).toBe(2); + }); + + it("结构约束:全部为特殊候选时保持原列表", () => { + const list = [ + { episodeId: 1, animeId: 1, animeTitle: "A", episodeTitle: "OVA 2", imageUrl: null }, + { episodeId: 2, animeId: 2, animeTitle: "B", episodeTitle: "SP", imageUrl: null }, + ]; + const r = rankCandidates(list, { hint: { epNumber: 1 } }); + expect(r.candidates).toHaveLength(2); + }); + + it("结构约束:本地集号未知时不做过滤", () => { + const list = [ + { episodeId: 1, animeId: 1, animeTitle: "A", episodeTitle: "05", imageUrl: null }, + { episodeId: 2, animeId: 2, animeTitle: "B", episodeTitle: "12", imageUrl: null }, + ]; + const r = rankCandidates(list, { hint: { title: "x" } }); + expect(r.candidates).toHaveLength(2); + }); }); diff --git a/packages/danmaku/src/match.ts b/packages/danmaku/src/match.ts index e05e2ba..838e99a 100644 --- a/packages/danmaku/src/match.ts +++ b/packages/danmaku/src/match.ts @@ -13,6 +13,8 @@ type RawMatch = { animeTitle?: string | null; episodeTitle?: string | null; imageUrl?: string | null; + type?: string | null; + typeDescription?: string | null; }; /** 本地媒体上下文,用于候选打分(标题包含关系 / 集号相等加分) */ @@ -20,6 +22,8 @@ export type RankHint = { title?: string | undefined; rawName?: string | undefined; epNumber?: number | null | undefined; + /** Bangumi 刮削别名(原名/罗马音等),与候选标题同权比对 */ + aliases?: string[] | null | undefined; }; export function parseMatchOutcome(json: unknown): { @@ -44,6 +48,8 @@ export function parseMatchOutcome(json: unknown): { animeTitle: m.animeTitle ?? null, episodeTitle: m.episodeTitle ?? null, imageUrl: m.imageUrl ?? null, + type: m.type ?? null, + typeDescription: m.typeDescription ?? null, }); } return { isMatched: body.isMatched === true, candidates }; @@ -84,13 +90,44 @@ function episodeBonus(hint: RankHint | undefined, episodeTitle: string | null): return firstInt(episodeTitle) === ep ? 0.5 : 0; } +function isSpecialCandidate(c: MatchCandidate): boolean { + const t = `${c.episodeTitle ?? ""} ${c.type ?? ""} ${c.typeDescription ?? ""}`; + return /剧场版|特別篇|特别篇|番外|\bOAD\b|\bOVA\b|\bSP\b|\bMovie\b/i.test(t); +} + +/** + * 结构约束消歧:本地集号已知时先按「候选集号相等」硬收敛; + * 无相等候选则剔除剧场版/OVA/SP 类特殊候选(仅当还剩普通候选); + * 全部落空时保持原列表,交给打分。 + */ +function structureFilter( + candidates: MatchCandidate[], + hint: RankHint | undefined, +): MatchCandidate[] { + const ep = hint?.epNumber; + if (ep == null || candidates.length === 0) return candidates; + const exact = candidates.filter((c) => firstInt(c.episodeTitle) === ep); + if (exact.length > 0) return exact; + const normal = candidates.filter((c) => !isSpecialCandidate(c)); + if (normal.length > 0 && normal.length < candidates.length) return normal; + return candidates; +} + +/** 标题与全部本地别名(title/rawName/aliases)对候选标题的最佳命中分 */ +function bestTitleBonus(hint: RankHint, candidateTitle: string | null): number { + const keys = [hint.title, hint.rawName, ...(hint.aliases ?? []).slice(0, 6)]; + let best = 0; + for (const k of keys) { + best = Math.max(best, titleBonus(k, candidateTitle)); + if (best >= 1) break; + } + return best; +} + function scoreCandidate(c: MatchCandidate, hint: RankHint | undefined): number { const base = (c.animeTitle ? 1 : 0) * 0.5 + (c.episodeTitle ? 0.5 : 0) + (c.imageUrl ? 0.1 : 0); if (!hint) return base; - const matched = Math.max( - titleBonus(hint.title, c.animeTitle), - titleBonus(hint.rawName, c.animeTitle), - ); + const matched = bestTitleBonus(hint, c.animeTitle); return base + matched + episodeBonus(hint, c.episodeTitle); } @@ -100,14 +137,15 @@ export function rankCandidates( opts?: { lead?: number; hint?: RankHint } | undefined, ): { candidates: MatchCandidate[]; highConfidence: boolean } { const lead = opts?.lead ?? 0.15; - if (candidates.length === 0) return { candidates: [], highConfidence: false }; - if (candidates.length === 1) return { candidates, highConfidence: true }; - const scored = candidates + const pool = structureFilter(candidates, opts?.hint); + if (pool.length === 0) return { candidates: [], highConfidence: false }; + if (pool.length === 1) return { candidates: pool, highConfidence: true }; + const scored = pool .map((c) => ({ c, score: scoreCandidate(c, opts?.hint) })) .sort((a, b) => b.score - a.score); const top = scored[0]; const second = scored[1]; - if (!top) return { candidates, highConfidence: false }; + if (!top) return { candidates: [], highConfidence: false }; const high = second ? top.score - second.score >= lead : true; return { candidates: scored.map((s) => s.c), highConfidence: high }; } diff --git a/packages/danmaku/src/types.ts b/packages/danmaku/src/types.ts index e07cd22..41a6eea 100644 --- a/packages/danmaku/src/types.ts +++ b/packages/danmaku/src/types.ts @@ -16,6 +16,9 @@ export type MatchCandidate = { animeTitle: string | null; episodeTitle: string | null; imageUrl: string | null; + /** match 响应可选的类型字段(TV/OVA/剧场版),结构消歧用;响应缺省为 undefined */ + type?: string | null | undefined; + typeDescription?: string | null | undefined; }; export type LoginResult = { diff --git a/packages/dao/src/media-items.ts b/packages/dao/src/media-items.ts index f2977af..e57ba5d 100644 --- a/packages/dao/src/media-items.ts +++ b/packages/dao/src/media-items.ts @@ -283,6 +283,11 @@ export const mediaItemDao = { scrapedAt?: Date | null; posterUrl?: string | null; title?: string; + /** Bangumi 别名 JSON 数组字符串;null 清空 */ + titleAliases?: string | null; + airYear?: number | null; + /** 换绑作品时重置弹幕身份来源 */ + danmakuMatchSource?: "auto" | "manual" | "none"; }, ): Promise { const [row] = await db diff --git a/packages/models/src/media-items.ts b/packages/models/src/media-items.ts index afd0183..a7fdcb0 100644 --- a/packages/models/src/media-items.ts +++ b/packages/models/src/media-items.ts @@ -23,6 +23,10 @@ export const mediaItems = sqliteTable( scrapeStatus: text("scrape_status").notNull().default("pending"), scrapedAt: integer("scraped_at", { mode: "timestamp" }), posterUrl: text("poster_url"), + /** Bangumi 刮削别名(JSON 数组:原名/罗马音/假名),弹幕候选打分用 */ + titleAliases: text("title_aliases"), + /** 首播年份(刮削时落库),同名作品消歧 */ + airYear: integer("air_year"), /** 弹弹play 弹幕库编号(文件识别成功后落库,弹幕拉取直达) */ dandanplayEpisodeId: integer("dandanplay_episode_id"), matchedHash: text("matched_hash"), diff --git a/packages/trpc/src/services/danmaku.service.test.ts b/packages/trpc/src/services/danmaku.service.test.ts index b4dc535..6687695 100644 --- a/packages/trpc/src/services/danmaku.service.test.ts +++ b/packages/trpc/src/services/danmaku.service.test.ts @@ -1,5 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { openCommentXml, openCommentsToXml, openMatch, hasOpenCredentials } from "@app/danmaku"; +import { + openCommentXml, + openCommentsToXml, + openMatch, + hasOpenCredentials, + openBangumiEpisodes, +} from "@app/danmaku"; import { danmakuCacheDao, danmakuDocsDao, danmakuPrefsDao, mediaItemDao, mountDao } from "@app/dao"; import type { DanmakuCacheRow, @@ -9,6 +15,7 @@ import type { Mount, } from "@app/models"; import { danmakuService } from "./danmaku.service.js"; +import { getDandanplayAuthStatus } from "./settings.service.js"; import { createWebdav } from "./webdav-client.js"; vi.mock("./settings.service.js", () => ({ @@ -24,6 +31,7 @@ vi.mock("@app/dao", () => ({ mediaItemDao: { getByIdForUser: vi.fn(), setDanmakuMatch: vi.fn(), + listByBangumiId: vi.fn(), }, mountDao: { getByIdForUser: vi.fn() }, })); @@ -43,6 +51,7 @@ vi.mock("@app/danmaku", async (importOriginal) => { openMatch: vi.fn(), openCommentXml: vi.fn(), hasOpenCredentials: vi.fn(), + openBangumiEpisodes: vi.fn(), }; }); @@ -64,6 +73,8 @@ function mediaItem(over: Partial = {}): MediaItemRow { dandanplayEpisodeId: null, matchedHash: null, danmakuMatchSource: "none", + titleAliases: null, + airYear: null, scannedAt: new Date(), updatedAt: new Date(), ...over, @@ -244,6 +255,33 @@ describe("danmakuService.fetch", () => { expect(openMatch).not.toHaveBeenCalled(); }); + // 官方 open API 的 match/comment 无需登录(swagger security=null),登录态不构成门禁 + it("未登录弹弹play 也继续匹配拉取", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + vi.mocked(danmakuDocsDao.get).mockResolvedValue(null); + vi.mocked(hasOpenCredentials).mockReturnValue(true); + vi.mocked(danmakuCacheDao.getValid).mockResolvedValue(null); + vi.mocked(getDandanplayAuthStatus).mockResolvedValue({ loggedIn: false, screenName: null }); + vi.mocked(openMatch).mockResolvedValue({ + ok: true, + errorMessage: null, + isMatched: true, + candidates: [ + { + episodeId: 777, + animeId: 1, + animeTitle: "T", + episodeTitle: "E", + imageUrl: null, + }, + ], + }); + vi.mocked(openCommentXml).mockResolvedValue("open"); + const r = await danmakuService.fetch("u1", "m1"); + expect(openMatch).toHaveBeenCalled(); + expect(r).toMatchObject({ ok: true, source: "open-network", xml: "open" }); + }); + it("高置信自动绑(source=auto)后拉取,缓存键为 String(episodeId)", async () => { vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); vi.mocked(danmakuDocsDao.get).mockResolvedValue(null); @@ -307,6 +345,87 @@ describe("danmakuService.fetch", () => { expect(openCommentXml).not.toHaveBeenCalled(); expect(danmakuCacheDao.upsert).not.toHaveBeenCalled(); }); + + it("已刮削未绑定 → bgmtv 桥整组推导后直拉,免文件 match", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue( + mediaItem({ bangumiId: 99, epNumber: 3 }), + ); + vi.mocked(danmakuDocsDao.get).mockResolvedValue(null); + vi.mocked(hasOpenCredentials).mockReturnValue(true); + vi.mocked(danmakuCacheDao.getValid).mockResolvedValue(null); + vi.mocked(mediaItemDao.listByBangumiId).mockResolvedValue([ + mediaItem({ bangumiId: 99, epNumber: 3 }), + ]); + vi.mocked(openBangumiEpisodes).mockResolvedValue({ + ok: true, + episodes: [ + { + episodeId: 31, + episodeTitle: "ep1", + episodeNumber: "1", + type: null, + typeDescription: null, + }, + { + episodeId: 32, + episodeTitle: "ep2", + episodeNumber: "2", + type: null, + typeDescription: null, + }, + { + episodeId: 33, + episodeTitle: "ep3", + episodeNumber: "3", + type: null, + typeDescription: null, + }, + ], + }); + vi.mocked(openCommentXml).mockResolvedValue("open"); + const r = await danmakuService.fetch("u1", "m1"); + expect(openMatch).not.toHaveBeenCalled(); + expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", { + episodeId: 33, + source: "auto", + }); + expect(r).toMatchObject({ ok: true, source: "open-network", xml: "open" }); + }); + + it("桥失败时回退文件 match 老路径", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue( + mediaItem({ bangumiId: 99, epNumber: 1 }), + ); + vi.mocked(danmakuDocsDao.get).mockResolvedValue(null); + vi.mocked(hasOpenCredentials).mockReturnValue(true); + vi.mocked(danmakuCacheDao.getValid).mockResolvedValue(null); + vi.mocked(mediaItemDao.listByBangumiId).mockResolvedValue([ + mediaItem({ bangumiId: 99, epNumber: 1 }), + ]); + vi.mocked(openBangumiEpisodes).mockResolvedValue({ + ok: false, + errorMessage: "HTTP 500", + episodes: [], + }); + vi.mocked(openMatch).mockResolvedValue({ + ok: true, + errorMessage: null, + isMatched: true, + candidates: [ + { + episodeId: 555, + animeId: 1, + animeTitle: "T", + episodeTitle: "E", + imageUrl: null, + }, + ], + }); + vi.mocked(openCommentXml).mockResolvedValue("open"); + const r = await danmakuService.fetch("u1", "m1"); + expect(openMatch).toHaveBeenCalled(); + expect(r).toMatchObject({ ok: true, source: "open-network", xml: "open" }); + }); }); describe("danmakuService.selectMatch", () => { @@ -346,6 +465,49 @@ describe("danmakuService.selectMatch", () => { // 手选即离开本地源:即便拉取失败也清 local-xml expect(danmakuDocsDao.remove).toHaveBeenCalledWith("u1", "m1", "local-xml"); }); + + it("手选反哺:同组未绑定条目按集号整组继承", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue( + mediaItem({ bangumiId: 99, matchedHash: "abc" }), + ); + vi.mocked(hasOpenCredentials).mockReturnValue(true); + // 组内:m1 已手选绑定;m2 未绑定(epNumber 2) + vi.mocked(mediaItemDao.listByBangumiId).mockResolvedValue([ + mediaItem({ id: "m1", bangumiId: 99, dandanplayEpisodeId: 4242 }), + mediaItem({ id: "m2", bangumiId: 99, epNumber: 2 }), + ]); + vi.mocked(openBangumiEpisodes).mockResolvedValue({ + ok: true, + episodes: [ + { + episodeId: 21, + episodeTitle: "ep1", + episodeNumber: "1", + type: null, + typeDescription: null, + }, + { + episodeId: 22, + episodeTitle: "ep2", + episodeNumber: "2", + type: null, + typeDescription: null, + }, + ], + }); + vi.mocked(openCommentXml).mockResolvedValue("open"); + const r = await danmakuService.selectMatch("u1", "m1", 4242); + expect(r).toMatchObject({ ok: true, source: "open-network" }); + expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", { + episodeId: 4242, + matchedHash: "abc", + source: "manual", + }); + expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m2", "u1", { + episodeId: 22, + source: "auto", + }); + }); }); describe("danmakuService.matchCandidates", () => { diff --git a/packages/trpc/src/services/danmaku.service.ts b/packages/trpc/src/services/danmaku.service.ts index 19e8faf..c4e0dd9 100644 --- a/packages/trpc/src/services/danmaku.service.ts +++ b/packages/trpc/src/services/danmaku.service.ts @@ -20,7 +20,8 @@ import type { DanmakuSourceSearchOutput, } from "@app/types"; import { decryptSecret } from "./secret.js"; -import { ensureOpenRuntime, getDandanplayAuthStatus } from "./settings.service.js"; +import { ensureOpenRuntime } from "./settings.service.js"; +import { buildMatchHint, deriveSeriesDanmaku } from "./series-derive.js"; import { createWebdav, joinWebdavPath, listDirectory, type DirEntry } from "./webdav-client.js"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; @@ -185,30 +186,20 @@ export const danmakuService = { return { ok: true, source: "cache", xml: cached.payload, reason: "cache" }; } - { - const auth = await getDandanplayAuthStatus(); - if (!auth.loggedIn) { - return { - ok: true, - source: "none", - xml: "", - reason: "need-login", - message: "获取在线弹幕需登录弹弹play 账号(设置页)", - }; - } - } + // 官方 open API 的 match/comment 无需登录(swagger security=null); + // 登录态 token 由 @app/danmaku client 在请求头附加,属可选增强 let episodeId = item.dandanplayEpisodeId ?? null; + // Series 批量推导兜底(方案1):已刮削作品先走 bgmtv 桥按集号整组绑定, + // 免逐文件 hash match;组内其他未绑定条目一并继承 + if (episodeId == null && item.bangumiId != null) { + const derived = await deriveSeriesDanmaku(userId, item.bangumiId, item.mountId); + episodeId = derived.episodeIds.get(item.id) ?? null; + } if (episodeId == null) { const hash = await remoteFileHash(userId, mediaItemId); const outcome = await openMatch(matchFileName(item.path), item.size, hash); - const ranked = rankCandidates(outcome.candidates, { - hint: { - title: item.title, - rawName: item.rawName, - epNumber: item.epNumber ?? null, - }, - }); + const ranked = rankCandidates(outcome.candidates, { hint: buildMatchHint(item) }); if (!outcome.ok || !outcome.isMatched || ranked.candidates.length === 0) { return { ok: true, @@ -230,7 +221,11 @@ export const danmakuService = { } episodeId = ranked.candidates[0]?.episodeId ?? null; if (episodeId == null) { - return { ok: true, source: "none", xml: "", reason: "no-match", + return { + ok: true, + source: "none", + xml: "", + reason: "no-match", message: "开放网络未匹配到弹幕", }; } @@ -239,17 +234,21 @@ export const danmakuService = { matchedHash: hash, source: "auto", }); + // 自动绑后反哺同组未绑定条目(方案1/5) + if (item.bangumiId != null) { + await deriveSeriesDanmaku(userId, item.bangumiId, item.mountId); + } } const xml = await openCommentXml(episodeId); if (xml === null) { return { - ok: true, - source: "none", - xml: "", - reason: "fetch-failed", - message: "弹幕库拉取失败", - }; + ok: true, + source: "none", + xml: "", + reason: "fetch-failed", + message: "弹幕库拉取失败", + }; } await persistOpenXml(userId, mediaItemId, xml); await persistOpenCache(episodeId, xml); @@ -279,13 +278,7 @@ export const danmakuService = { } const hash = await remoteFileHash(userId, mediaItemId); const outcome = await openMatch(matchFileName(item.path), item.size, hash); - const ranked = rankCandidates(outcome.candidates, { - hint: { - title: item.title, - rawName: item.rawName, - epNumber: item.epNumber ?? null, - }, - }); + const ranked = rankCandidates(outcome.candidates, { hint: buildMatchHint(item) }); return { ok: outcome.ok, matched: outcome.isMatched && ranked.candidates.length > 0, @@ -318,15 +311,19 @@ export const danmakuService = { }); // 手选即切换到开放网络:清掉 local-xml,避免 fetch 仍优先进本地源 await danmakuDocsDao.remove(userId, mediaItemId, "local-xml"); + // 手选反哺(方案5):同组未绑定条目按集号整组继承 + if (item.bangumiId != null) { + await deriveSeriesDanmaku(userId, item.bangumiId, item.mountId); + } const xml = await openCommentXml(episodeId); if (xml === null) { return { - ok: true, - source: "none", - xml: "", - reason: "fetch-failed", - message: "弹幕库拉取失败", - }; + ok: true, + source: "none", + xml: "", + reason: "fetch-failed", + message: "弹幕库拉取失败", + }; } await persistOpenXml(userId, mediaItemId, xml); await persistOpenCache(episodeId, xml); @@ -369,7 +366,11 @@ export const danmakuService = { async searchSource(keyword: string): Promise { await ensureOpenRuntime(); if (!hasOpenCredentials()) { - return { ok: false, message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置", animes: [] }; + return { + ok: false, + message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置", + animes: [], + }; } const outcome = await openSearchEpisodes(keyword); if (!outcome.ok) { diff --git a/packages/trpc/src/services/scrape.service.test.ts b/packages/trpc/src/services/scrape.service.test.ts index d1064b5..0a52684 100644 --- a/packages/trpc/src/services/scrape.service.test.ts +++ b/packages/trpc/src/services/scrape.service.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { openMatch, hasOpenCredentials } from "@app/danmaku"; import { mediaItemDao } from "@app/dao"; import type { MediaItem } from "@app/models"; @@ -6,7 +6,6 @@ import { AUTO_BIND_THRESHOLD, buildSearchQuery, parseEpisodeFromFilename, - pickDandanEpisode, scoreHit, } from "./scrape.service.js"; import { scrapeService } from "./scrape.service.js"; @@ -16,6 +15,8 @@ vi.mock("@app/dao", () => ({ getByIdForUser: vi.fn(), updateScrape: vi.fn(), listByPathPrefix: vi.fn(), + listByBangumiId: vi.fn(), + setDanmakuMatch: vi.fn(), }, })); @@ -27,6 +28,7 @@ vi.mock("@app/danmaku", async (importOriginal) => { openCommentXml: vi.fn(), hasOpenCredentials: vi.fn(), openFetchJson: vi.fn(), + openBangumiEpisodes: vi.fn(), }; }); @@ -68,35 +70,6 @@ describe("buildSearchQuery", () => { }); }); -describe("pickDandanEpisode", () => { - const eps = [ - { episodeId: 11, episodeTitle: "ep1", episodeNumber: "1" }, - { episodeId: 12, episodeTitle: "ep2", episodeNumber: "2" }, - { episodeId: 13, episodeTitle: "ep3", episodeNumber: "3" }, - ]; - it("episodeNumber 数值相等优先", () => { - expect(pickDandanEpisode(eps, 2)?.episodeId).toBe(12); - }); - it("episodeNumber 非数值时按位序回退", () => { - expect( - pickDandanEpisode([{ episodeId: 21, episodeTitle: "a", episodeNumber: "第1话" }], 1) - ?.episodeId, - ).toBe(21); - }); - it("epNumber 越界不瞎猜", () => { - expect(pickDandanEpisode(eps, 9)).toBeNull(); - }); - it("epNumber 未知取第 1 条", () => { - expect(pickDandanEpisode(eps, null)?.episodeId).toBe(11); - }); - it("空列表返回 null", () => { - expect(pickDandanEpisode([], 1)).toBeNull(); - }); - it("episodeId 为字符串也能解析", () => { - expect(pickDandanEpisode([{ episodeId: "31", episodeNumber: "1" }], 1)?.episodeId).toBe(31); - }); -}); - describe("scoreHit", () => { it("完全命中最高", () => { expect( @@ -115,6 +88,18 @@ describe("scoreHit", () => { const s = scoreHit("葬送的芙莉莲", { name: "Naruto", nameCn: "火影忍者" }); expect(s).toBeLessThan(AUTO_BIND_THRESHOLD); }); + it("年份与首播一致加分(同名重制消歧)", () => { + const hit = { name: "狼と辛香料", nameCn: "狼与香辛料", date: "2024-04-01" }; + const withYear = scoreHit("狼与香辛料", hit, 2024); + const withoutYear = scoreHit("狼与香辛料", hit); + expect(withYear).toBe(Math.min(1, withoutYear + 0.15)); + }); + it("年份不一致不加分", () => { + const hit = { name: "Show", nameCn: "Show", date: "2008-01-08" }; + expect(scoreHit("Show", hit, 2024)).toBe(1); // 已是满分,加分被封顶 + const partial = { name: "Show", nameCn: "Show 第二季", date: "2008-01-08" }; + expect(scoreHit("Show", partial, 2024)).toBe(scoreHit("Show", partial)); + }); }); function mediaItem(over: Partial = {}): MediaItem { @@ -135,6 +120,8 @@ function mediaItem(over: Partial = {}): MediaItem { dandanplayEpisodeId: null, matchedHash: null, danmakuMatchSource: "none", + titleAliases: null, + airYear: null, scannedAt: new Date(), updatedAt: new Date(), ...over, @@ -145,6 +132,7 @@ describe("scrapeService.matchDandanplay 绑定一致性", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(hasOpenCredentials).mockReturnValue(true); + vi.mocked(mediaItemDao.listByBangumiId).mockResolvedValue([]); vi.mocked(openMatch).mockResolvedValue({ ok: true, errorMessage: null, @@ -236,3 +224,64 @@ describe("scrapeService.matchDandanplay 绑定一致性", () => { expect(mediaItemDao.updateScrape).not.toHaveBeenCalled(); }); }); + +describe("scrapeService.bind 换绑重置与别名落库", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(hasOpenCredentials).mockReturnValue(true); + vi.mocked(mediaItemDao.listByBangumiId).mockResolvedValue([]); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "新作", + name_cn: "新作CN", + date: "2024-04-01", + images: {}, + }), + }), + ); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("换绑其他作品:重置弹幕身份 + 写入别名/年份", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue( + mediaItem({ + bangumiId: 1, + dandanplayEpisodeId: 55, + matchedHash: "h", + danmakuMatchSource: "manual", + }), + ); + const r = await scrapeService.bind("u1", "m1", 2); + expect(r.success).toBe(true); + expect(mediaItemDao.updateScrape).toHaveBeenCalledWith( + "m1", + "u1", + expect.objectContaining({ + bangumiId: 2, + dandanplayEpisodeId: null, + matchedHash: null, + danmakuMatchSource: "none", + titleAliases: JSON.stringify(["新作", "新作CN"]), + airYear: 2024, + }), + ); + // 换绑后按新作品整组推导弹幕身份 + expect(mediaItemDao.listByBangumiId).toHaveBeenCalledWith("u1", 2, undefined); + }); + + it("同作品重复绑定:不重置弹幕身份", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue( + mediaItem({ bangumiId: 1, dandanplayEpisodeId: 55, matchedHash: "h" }), + ); + await scrapeService.bind("u1", "m1", 1); + const data = vi.mocked(mediaItemDao.updateScrape).mock.calls[0]?.[2]; + expect(data).toBeDefined(); + expect(data).not.toHaveProperty("dandanplayEpisodeId"); + expect(data).not.toHaveProperty("danmakuMatchSource"); + }); +}); diff --git a/packages/trpc/src/services/scrape.service.ts b/packages/trpc/src/services/scrape.service.ts index d0dd730..5f29642 100644 --- a/packages/trpc/src/services/scrape.service.ts +++ b/packages/trpc/src/services/scrape.service.ts @@ -3,13 +3,20 @@ import { mediaItemDao } from "@app/dao"; import { hasOpenCredentials, matchFileName, - openFetchJson, + openBangumiEpisodes, openMatch, rankCandidates, } from "@app/danmaku"; import { ensureOpenRuntime } from "./settings.service.js"; -import { parseEpisodeFromFilename, type BatchResult, type DanmakuMatchOutput } from "@app/types"; +import { + parseEpisodeFromFilename, + parseSeasonFromFilename, + parseYearFromFilename, + type BatchResult, + type DanmakuMatchOutput, +} from "@app/types"; import { remoteFileHash } from "./danmaku.service.js"; +import { buildMatchHint, deriveSeriesDanmaku, pickDandanEpisode } from "./series-derive.js"; /** 集数解析统一走 @app/types(前后端共用同一套正则);此处再导出保持旧入口可用 */ export { parseEpisodeFromFilename }; @@ -42,8 +49,21 @@ function normalizeTitle(s: string): string { .replace(/[((][^))]*[))]/g, ""); } -/** 候选与检索词的相似度 0–1;借鉴弹弹play「候选列表+择优」而非盲取第一条 */ -export function scoreHit(query: string, hit: { name: string; nameCn: string }): number { +/** 候选与检索词的相似度 0–1;借鉴弹弹play「候选列表+择优」而非盲取第一条。 + * 年份消歧(方案2):文件名年份与 Bangumi 首播年一致时 +0.15(同名重制不误绑)。 */ +export function scoreHit( + query: string, + hit: { name: string; nameCn: string; date?: string | null }, + year?: number | null, +): number { + const base = scoreHitBase(query, hit); + if (year != null && hit.date && hit.date.slice(0, 4) === String(year)) { + return Math.min(1, base + 0.15); + } + return base; +} + +function scoreHitBase(query: string, hit: { name: string; nameCn: string }): number { const q = normalizeTitle(query); if (!q) return 0; const cn = normalizeTitle(hit.nameCn); @@ -74,64 +94,6 @@ export type BangumiSearchHit = { summary: string; }; -type RawDandanEpisode = { - episodeId?: number | string; - episodeTitle?: string | null; - episodeNumber?: string | null; -}; - -/** - * bgmtv 桥择集:episodeNumber 数值相等优先,其次按位序取第 N 条。 - * epNumber 越界时不瞎猜返回 null;epNumber 未知时取第 1 条。 - */ -export function pickDandanEpisode( - episodes: RawDandanEpisode[], - epNumber: number | null, -): { episodeId: number; episodeTitle: string | null } | null { - const parsed: { - episodeId: number; - episodeTitle: string | null; - episodeNumber: string | null; - }[] = []; - for (const e of episodes) { - const id = - typeof e.episodeId === "number" - ? e.episodeId - : Number.parseInt(String(e.episodeId ?? ""), 10); - if (Number.isFinite(id)) { - parsed.push({ - episodeId: id, - episodeTitle: e.episodeTitle ?? null, - episodeNumber: e.episodeNumber ?? null, - }); - } - } - if (parsed.length === 0) return null; - if (epNumber != null) { - const exact = parsed.find((e) => { - const n = Number.parseInt(String(e.episodeNumber ?? ""), 10); - return Number.isFinite(n) && n === epNumber; - }); - if (exact) return { episodeId: exact.episodeId, episodeTitle: exact.episodeTitle }; - const byIndex = parsed[epNumber - 1]; - if (byIndex) return { episodeId: byIndex.episodeId, episodeTitle: byIndex.episodeTitle }; - return null; - } - const first = parsed[0]; - return first ? { episodeId: first.episodeId, episodeTitle: first.episodeTitle } : null; -} - -/** Bangumi 绑定 → 弹弹play 剧集(免 hash 桥 /api/v2/bangumi/bgmtv/{subjectId});失败/无数据返回 null */ -async function bridgeDandanEpisode( - bangumiId: number, - epNumber: number | null, -): Promise<{ episodeId: number; episodeTitle: string | null } | null> { - const res = await openFetchJson(`/api/v2/bangumi/bgmtv/${bangumiId}`); - if (!res.ok) return null; - const body = res.json as { bangumi?: { episodes?: RawDandanEpisode[] } }; - return pickDandanEpisode(body.bangumi?.episodes ?? [], epNumber); -} - function apiBase(): string { return process.env["BANGUMI_API_BASE"] ?? "https://api.bgm.tv"; } @@ -162,29 +124,56 @@ export async function bangumiSearch(q: string): Promise { })); } +function yearFromDate(date: string | null | undefined): number | null { + if (!date) return null; + const n = Number.parseInt(date.slice(0, 4), 10); + return Number.isFinite(n) ? n : null; +} + async function fetchSubjectMeta(bangumiId: number): Promise<{ title: string | null; posterUrl: string | null; + aliases: string[]; + airYear: number | null; }> { try { const res = await fetch(`${apiBase()}/subject/${bangumiId}`, { headers: { "User-Agent": "dandanplay-web/0.1" }, }); - if (!res.ok) return { title: null, posterUrl: null }; + if (!res.ok) { + return { title: null, posterUrl: null, aliases: [], airYear: null }; + } const subject = (await res.json()) as { name?: string; name_cn?: string; + name_romaji?: string; + name_kana?: string; + date?: string | null; + date_cn?: string | null; images?: { medium?: string; large?: string }; }; + const aliases = [ + ...new Set( + [subject.name, subject.name_romaji, subject.name_kana, subject.name_cn].filter( + (s): s is string => typeof s === "string" && s.trim().length > 0, + ), + ), + ].slice(0, 6); return { title: subject.name_cn || subject.name || null, posterUrl: subject.images?.large ?? subject.images?.medium ?? null, + aliases, + airYear: yearFromDate(subject.date ?? subject.date_cn), }; } catch { - return { title: null, posterUrl: null }; + return { title: null, posterUrl: null, aliases: [], airYear: null }; } } +function aliasesColumn(aliases: string[]): string | null { + return aliases.length > 0 ? JSON.stringify(aliases) : null; +} + export const scrapeService = { search: bangumiSearch, @@ -217,13 +206,7 @@ export const scrapeService = { highConfidence: false, }; } - const ranked = rankCandidates(outcome.candidates, { - hint: { - title: row.title, - rawName: row.rawName, - epNumber: row.epNumber ?? null, - }, - }); + const ranked = rankCandidates(outcome.candidates, { hint: buildMatchHint(row) }); const best = ranked.candidates[0]; // 与 fetch 对齐:仅高置信才自动绑并写元数据;低置信不落库,返回候选供手选 if (best && ranked.highConfidence) { @@ -236,6 +219,10 @@ export const scrapeService = { title: best.animeTitle ?? row.title, epNumber: row.epNumber ?? parseEpisodeFromFilename(row.rawName), }); + // Series 批量推导:同组未绑定条目按集号继承 + if (row.bangumiId != null) { + await deriveSeriesDanmaku(userId, row.bangumiId, row.mountId); + } return { ok: true, matched: true, @@ -254,32 +241,39 @@ export const scrapeService = { } if (row.bangumiId != null) { - const bridged = await bridgeDandanEpisode( - row.bangumiId, - row.epNumber ?? parseEpisodeFromFilename(row.rawName), - ); - if (bridged) { - await mediaItemDao.updateScrape(mediaItemId, userId, { - dandanplayEpisodeId: bridged.episodeId, - matchedHash: hash, - scrapeStatus: row.scrapeStatus, - scrapedAt: new Date(), - }); - return { - ok: true, - matched: true, - message: "已通过 Bangumi 绑定桥接到弹弹play 剧集", - candidates: [ - { - episodeId: bridged.episodeId, - animeId: null, - animeTitle: row.title, - episodeTitle: bridged.episodeTitle, - imageUrl: row.posterUrl, - }, - ], - highConfidence: true, - }; + const episodes = await openBangumiEpisodes(row.bangumiId); + if (episodes.ok) { + const bridged = pickDandanEpisode( + episodes.episodes, + row.epNumber ?? parseEpisodeFromFilename(row.rawName), + { + season: parseSeasonFromFilename(row.rawName), + }, + ); + if (bridged) { + await mediaItemDao.updateScrape(mediaItemId, userId, { + dandanplayEpisodeId: bridged.episodeId, + matchedHash: hash, + scrapeStatus: row.scrapeStatus, + scrapedAt: new Date(), + }); + await deriveSeriesDanmaku(userId, row.bangumiId, row.mountId); + return { + ok: true, + matched: true, + message: "已通过 Bangumi 绑定桥接到弹弹play 剧集", + candidates: [ + { + episodeId: bridged.episodeId, + animeId: null, + animeTitle: row.title, + episodeTitle: bridged.episodeTitle, + imageUrl: row.posterUrl, + }, + ], + highConfidence: true, + }; + } } } return { @@ -301,6 +295,7 @@ export const scrapeService = { const row = await mediaItemDao.getByIdForUser(mediaItemId, userId); if (!row) throw new Error("媒体不存在"); const meta = await fetchSubjectMeta(bangumiId); + const changed = row.bangumiId !== bangumiId; await mediaItemDao.updateScrape(mediaItemId, userId, { bangumiId, scrapeStatus: "ok", @@ -308,7 +303,18 @@ export const scrapeService = { posterUrl: meta.posterUrl ?? row.posterUrl, title: meta.title ?? row.title, epNumber: epNumber ?? parseEpisodeFromFilename(row.rawName) ?? row.epNumber, + titleAliases: aliasesColumn(meta.aliases), + airYear: meta.airYear, + // 换绑其他作品:旧弹幕身份失效,随后整组按新作品重新推导 + ...(changed + ? { + dandanplayEpisodeId: null, + matchedHash: null, + danmakuMatchSource: "none" as const, + } + : {}), }); + await deriveSeriesDanmaku(userId, bangumiId, row.mountId); return { success: true }; }, @@ -326,6 +332,7 @@ export const scrapeService = { const meta = await fetchSubjectMeta(bangumiId); let bound = 0; for (const row of rows) { + const changed = row.bangumiId !== bangumiId; await mediaItemDao.updateScrape(row.id, userId, { bangumiId, scrapeStatus: "ok", @@ -333,9 +340,19 @@ export const scrapeService = { posterUrl: meta.posterUrl ?? row.posterUrl, title: meta.title ?? row.title, epNumber: parseEpisodeFromFilename(row.rawName) ?? row.epNumber, + titleAliases: aliasesColumn(meta.aliases), + airYear: meta.airYear, + ...(changed + ? { + dandanplayEpisodeId: null, + matchedHash: null, + danmakuMatchSource: "none" as const, + } + : {}), }); bound += 1; } + await deriveSeriesDanmaku(userId, bangumiId, mountId); return { total: rows.length, bound, @@ -380,11 +397,12 @@ export const scrapeService = { }); return; } - // 候选打分择优(弹弹play:返回候选列表择优,而非盲取第一条) + // 候选打分择优(弹弹play:返回候选列表择优,而非盲取第一条);年份参与同名消歧 + const year = parseYearFromFilename(row.rawName); let best: BangumiSearchHit | null = null; let bestScore = 0; for (const hit of hits) { - const score = scoreHit(q, hit); + const score = scoreHit(q, hit, year); if (score > bestScore) { bestScore = score; best = hit; @@ -398,6 +416,7 @@ export const scrapeService = { }); return; } + const alias = best.name && best.name !== (best.nameCn || best.name) ? best.name : null; await mediaItemDao.updateScrape(mediaItemId, userId, { bangumiId: best.id, scrapeStatus: "ok", @@ -405,22 +424,32 @@ export const scrapeService = { posterUrl: best.image || null, title: best.nameCn || best.name, epNumber, + titleAliases: alias ? JSON.stringify([alias]) : null, + airYear: yearFromDate(best.date), }); }, - /** 文件夹级批量重刮(仅处理未 ok 的条目) */ + /** 文件夹级批量重刮(仅处理未 ok 的条目);完成后按作品整组推导弹幕身份 */ async rescrapePath(userId: string, mountId: string, path: string): Promise { const rows = await mediaItemDao.listByPathPrefix(userId, mountId, path); let bound = 0; let skipped = 0; + const derived = new Set(); + const deriveOnce = async (bangumiId: number): Promise => { + if (derived.has(bangumiId)) return; + derived.add(bangumiId); + await deriveSeriesDanmaku(userId, bangumiId, mountId); + }; for (const row of rows) { if (row.scrapeStatus === "ok" && row.bangumiId != null) { skipped += 1; + await deriveOnce(row.bangumiId); continue; } await this.autoScrape(userId, row.id); const after = await mediaItemDao.getByIdForUser(row.id, userId); if (after?.scrapeStatus === "ok") bound += 1; + if (after?.bangumiId != null) await deriveOnce(after.bangumiId); } return { total: rows.length, @@ -461,6 +490,7 @@ export const scrapeService = { const rows = await mediaItemDao.listByBangumiId(userId, bangumiId, mountId); if (rows.length === 0) throw new TRPCError({ code: "NOT_FOUND", message: "作品组不存在" }); const meta = await fetchSubjectMeta(newBangumiId); + const changed = bangumiId !== newBangumiId; for (const row of rows) { await mediaItemDao.updateScrape(row.id, userId, { bangumiId: newBangumiId, @@ -469,8 +499,19 @@ export const scrapeService = { posterUrl: meta.posterUrl ?? row.posterUrl, title: meta.title ?? row.title, epNumber: row.epNumber, + titleAliases: aliasesColumn(meta.aliases), + airYear: meta.airYear, + // 换绑其他作品:旧弹幕身份失效,随后整组按新作品重新推导 + ...(changed + ? { + dandanplayEpisodeId: null, + matchedHash: null, + danmakuMatchSource: "none" as const, + } + : {}), }); } + await deriveSeriesDanmaku(userId, newBangumiId, mountId); return { updated: rows.length, title: meta.title, posterUrl: meta.posterUrl }; }, }; diff --git a/packages/trpc/src/services/series-derive.test.ts b/packages/trpc/src/services/series-derive.test.ts new file mode 100644 index 0000000..56f6744 --- /dev/null +++ b/packages/trpc/src/services/series-derive.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { hasOpenCredentials, openBangumiEpisodes } from "@app/danmaku"; +import { mediaItemDao } from "@app/dao"; +import type { MediaItem } from "@app/models"; +import { + buildMatchHint, + deriveSeriesDanmaku, + parseTitleAliases, + pickDandanEpisode, +} from "./series-derive.js"; + +vi.mock("@app/dao", () => ({ + mediaItemDao: { + listByBangumiId: vi.fn(), + setDanmakuMatch: vi.fn(), + }, +})); + +vi.mock("@app/danmaku", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + hasOpenCredentials: vi.fn(), + openBangumiEpisodes: vi.fn(), + }; +}); + +function mediaItem(over: Partial = {}): MediaItem { + return { + id: "m1", + userId: "u1", + mountId: "mnt1", + path: "/anime/Show - 01.mkv", + rawName: "Show - 01.mkv", + title: "Show", + size: 1024, + mime: null, + bangumiId: 99, + epNumber: 1, + scrapeStatus: "ok", + scrapedAt: null, + posterUrl: null, + titleAliases: null, + airYear: null, + dandanplayEpisodeId: null, + matchedHash: null, + danmakuMatchSource: "none", + scannedAt: new Date(), + updatedAt: new Date(), + ...over, + }; +} + +const episodes = [ + { episodeId: 11, episodeTitle: "ep1", episodeNumber: "1", type: null, typeDescription: null }, + { episodeId: 12, episodeTitle: "ep2", episodeNumber: "2", type: null, typeDescription: null }, + { episodeId: 13, episodeTitle: "ep3", episodeNumber: "3", type: null, typeDescription: null }, +]; + +/** 12 集作品(季长度与之相当,绝对集号换算用) */ +const eps12 = Array.from({ length: 12 }, (_, i) => ({ + episodeId: 101 + i, + episodeTitle: `ep${i + 1}`, + episodeNumber: String(i + 1), + type: null, + typeDescription: null, +})); + +describe("pickDandanEpisode", () => { + it("episodeNumber 数值相等优先", () => { + expect(pickDandanEpisode(episodes, 2)?.episodeId).toBe(12); + }); + it("episodeNumber 非数值时按位序回退", () => { + expect( + pickDandanEpisode([{ episodeId: 21, episodeTitle: "a", episodeNumber: "第1话" }], 1) + ?.episodeId, + ).toBe(21); + }); + it("epNumber 越界不瞎猜", () => { + expect(pickDandanEpisode(episodes, 9)).toBeNull(); + }); + it("epNumber 未知取第 1 条", () => { + expect(pickDandanEpisode(episodes, null)?.episodeId).toBe(11); + }); + it("空列表返回 null", () => { + expect(pickDandanEpisode([], 1)).toBeNull(); + }); + it("episodeId 为字符串也能解析", () => { + expect(pickDandanEpisode([{ episodeId: "31", episodeNumber: "1" }], 1)?.episodeId).toBe(31); + }); + + it("多季偏移:绝对集号超出本作集数时按季换算", () => { + // S2 文件记绝对第 15 集,本作 12 集 → 季内第 3 集 + expect(pickDandanEpisode(eps12, 15, { season: 2 })?.episodeId).toBe(103); + }); + it("多季偏移:季内集号(≤ 集数)不受偏移影响", () => { + expect(pickDandanEpisode(eps12, 2, { season: 2 })?.episodeId).toBe(102); + }); + it("多季偏移:换算后仍越界则返回 null", () => { + expect(pickDandanEpisode(episodes, 30, { season: 2 })).toBeNull(); + }); + it("季 1 不做偏移", () => { + expect(pickDandanEpisode(episodes, 9, { season: 1 })).toBeNull(); + }); + it("无季号且越界仍不瞎猜", () => { + expect(pickDandanEpisode(episodes, 9, { season: null })).toBeNull(); + }); + it("绝对编号剧集(episodeNumber 从 13 起)精确命中", () => { + const abs = [ + { episodeId: 51, episodeTitle: "ep13", episodeNumber: "13" }, + { episodeId: 52, episodeTitle: "ep14", episodeNumber: "14" }, + ]; + expect(pickDandanEpisode(abs, 14)?.episodeId).toBe(52); + }); +}); + +describe("parseTitleAliases", () => { + it("解析 JSON 数组并截断到 6 个", () => { + const raw = JSON.stringify(["A", "B", "C", "D", "E", "F", "G"]); + expect(parseTitleAliases(raw)).toEqual(["A", "B", "C", "D", "E", "F"]); + }); + it("过滤空串与非字符串", () => { + expect(parseTitleAliases(JSON.stringify(["A", "", " ", 1, null]))).toEqual(["A"]); + }); + it("null / 坏 JSON / 空数组返回 null", () => { + expect(parseTitleAliases(null)).toBeNull(); + expect(parseTitleAliases("{bad")).toBeNull(); + expect(parseTitleAliases('"str"')).toBeNull(); + expect(parseTitleAliases("[]")).toBeNull(); + expect(parseTitleAliases(JSON.stringify([" "]))).toBeNull(); + }); +}); + +describe("buildMatchHint", () => { + it("组装标题/文件名/集号/别名", () => { + const hint = buildMatchHint({ + title: "标题", + rawName: "raw.mkv", + epNumber: 3, + titleAliases: JSON.stringify(["Alias"]), + }); + expect(hint).toEqual({ + title: "标题", + rawName: "raw.mkv", + epNumber: 3, + aliases: ["Alias"], + }); + }); + it("无别名时 aliases 为 null", () => { + expect( + buildMatchHint({ + title: "t", + rawName: "r", + epNumber: null, + titleAliases: null, + }).aliases, + ).toBeNull(); + }); +}); + +describe("deriveSeriesDanmaku", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(hasOpenCredentials).mockReturnValue(true); + }); + + it("给组内未绑定条目按集号择集绑定,已绑定的不动", async () => { + vi.mocked(mediaItemDao.listByBangumiId).mockResolvedValue([ + mediaItem({ id: "m1", epNumber: 1, dandanplayEpisodeId: 11 }), + mediaItem({ id: "m2", epNumber: 2 }), + mediaItem({ id: "m3", epNumber: 9 }), // 越界 → 不绑 + ]); + vi.mocked(openBangumiEpisodes).mockResolvedValue({ ok: true, episodes }); + const r = await deriveSeriesDanmaku("u1", 99, "mnt1"); + expect(r.bound).toBe(1); + expect(r.episodeIds.get("m2")).toBe(12); + expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledTimes(1); + expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m2", "u1", { + episodeId: 12, + source: "auto", + }); + }); + + it("未配置开放凭据直接返回空", async () => { + vi.mocked(hasOpenCredentials).mockReturnValue(false); + const r = await deriveSeriesDanmaku("u1", 99, "mnt1"); + expect(r.bound).toBe(0); + expect(mediaItemDao.listByBangumiId).not.toHaveBeenCalled(); + expect(openBangumiEpisodes).not.toHaveBeenCalled(); + }); + + it("组内全部已绑定 → 不发网络请求", async () => { + vi.mocked(mediaItemDao.listByBangumiId).mockResolvedValue([ + mediaItem({ id: "m1", epNumber: 1, dandanplayEpisodeId: 11 }), + ]); + const r = await deriveSeriesDanmaku("u1", 99, "mnt1"); + expect(r.bound).toBe(0); + expect(openBangumiEpisodes).not.toHaveBeenCalled(); + }); + + it("桥接口失败静默返回空", async () => { + vi.mocked(mediaItemDao.listByBangumiId).mockResolvedValue([mediaItem({ epNumber: 1 })]); + vi.mocked(openBangumiEpisodes).mockResolvedValue({ + ok: false, + errorMessage: "HTTP 500", + episodes: [], + }); + const r = await deriveSeriesDanmaku("u1", 99, "mnt1"); + expect(r).toEqual({ bound: 0, episodeIds: expect.any(Map) }); + expect(mediaItemDao.setDanmakuMatch).not.toHaveBeenCalled(); + }); + + it("dao 抛错也静默返回空", async () => { + vi.mocked(mediaItemDao.listByBangumiId).mockRejectedValue(new Error("db down")); + const r = await deriveSeriesDanmaku("u1", 99, "mnt1"); + expect(r.bound).toBe(0); + }); + + it("epNumber 缺失时从文件名解析,多季文件按偏移换算", async () => { + vi.mocked(mediaItemDao.listByBangumiId).mockResolvedValue([ + mediaItem({ id: "m9", epNumber: null, rawName: "[Sub] Show S2 - 15 [1080p].mkv" }), + ]); + vi.mocked(openBangumiEpisodes).mockResolvedValue({ ok: true, episodes: eps12 }); + const r = await deriveSeriesDanmaku("u1", 99, "mnt1"); + // 文件名解析:ep=15、season=2 → 本作 12 集 → 季内第 3 集 + expect(r.episodeIds.get("m9")).toBe(103); + }); +}); diff --git a/packages/trpc/src/services/series-derive.ts b/packages/trpc/src/services/series-derive.ts new file mode 100644 index 0000000..340e8c5 --- /dev/null +++ b/packages/trpc/src/services/series-derive.ts @@ -0,0 +1,137 @@ +import { mediaItemDao } from "@app/dao"; +import { hasOpenCredentials, openBangumiEpisodes, type RankHint } from "@app/danmaku"; +import { parseEpisodeFromFilename, parseSeasonFromFilename } from "@app/types"; + +/** 桥接/择集可用的剧集条目(OpenEpisodeBrief 的结构子集) */ +export type PickableEpisode = { + episodeId?: number | string; + episodeTitle?: string | null; + episodeNumber?: string | null; +}; + +/** + * 桥接择集:episodeNumber 数值相等优先,其次按位序取第 N 条。 + * epNumber 越界不瞎猜返回 null;epNumber 未知取第 1 条。 + * 多季换算:文件名带季号且 epNumber 超出本作集数时,按「绝对集号 − 集数×(季号−1)」 + * 换算季内集号后重试(S2 第 15 集、本作 12 集 → 季内第 3 集)。 + */ +export function pickDandanEpisode( + episodes: PickableEpisode[], + epNumber: number | null, + opts?: { season?: number | null } | undefined, +): { episodeId: number; episodeTitle: string | null } | null { + const parsed: { + episodeId: number; + episodeTitle: string | null; + episodeNumber: string | null; + }[] = []; + for (const e of episodes) { + const id = + typeof e.episodeId === "number" + ? e.episodeId + : Number.parseInt(String(e.episodeId ?? ""), 10); + if (Number.isFinite(id)) { + parsed.push({ + episodeId: id, + episodeTitle: e.episodeTitle ?? null, + episodeNumber: e.episodeNumber ?? null, + }); + } + } + if (parsed.length === 0) return null; + if (epNumber != null) { + const attempts: number[] = [epNumber]; + const season = opts?.season ?? null; + if (season != null && season > 1) { + const within = epNumber - parsed.length * (season - 1); + if (within >= 1) attempts.push(within); + } + for (const n of attempts) { + const exact = parsed.find((e) => { + const num = Number.parseInt(String(e.episodeNumber ?? ""), 10); + return Number.isFinite(num) && num === n; + }); + if (exact) return { episodeId: exact.episodeId, episodeTitle: exact.episodeTitle }; + } + for (const n of attempts) { + const byIndex = parsed[n - 1]; + if (byIndex) + return { episodeId: byIndex.episodeId, episodeTitle: byIndex.episodeTitle }; + } + return null; + } + const first = parsed[0]; + return first ? { episodeId: first.episodeId, episodeTitle: first.episodeTitle } : null; +} + +/** title_aliases 列(JSON 数组字符串)→ 别名列表;坏数据/空数组返回 null */ +export function parseTitleAliases(raw: string | null): string[] | null { + if (!raw) return null; + try { + const v: unknown = JSON.parse(raw); + if (!Array.isArray(v)) return null; + const aliases = v + .filter((x): x is string => typeof x === "string" && x.trim().length > 0) + .slice(0, 6); + return aliases.length > 0 ? aliases : null; + } catch { + return null; + } +} + +/** 媒体行 → 候选打分上下文(标题/文件名/集号/刮削别名) */ +export function buildMatchHint(item: { + title: string; + rawName: string; + epNumber: number | null; + titleAliases: string | null; +}): RankHint { + return { + title: item.title, + rawName: item.rawName, + epNumber: item.epNumber, + aliases: parseTitleAliases(item.titleAliases), + }; +} + +/** + * Series 批量推导(方案1+5):拉取作品剧集列表,给组内所有未绑定条目按各自 + * 集号/季号择集绑定。高置信自动绑、手选、刮削换绑后调用即整组继承; + * 幂等(只补 dandanplayEpisodeId 为空的行),失败静默返回空结果不抛。 + */ +export async function deriveSeriesDanmaku( + userId: string, + bangumiId: number, + mountId?: string | null | undefined, +): Promise<{ bound: number; episodeIds: Map }> { + const episodeIds = new Map(); + if (!hasOpenCredentials()) return { bound: 0, episodeIds }; + try { + const siblings = await mediaItemDao.listByBangumiId( + userId, + bangumiId, + mountId ?? undefined, + ); + const targets = siblings.filter((s) => s.dandanplayEpisodeId == null); + if (targets.length === 0) return { bound: 0, episodeIds }; + const outcome = await openBangumiEpisodes(bangumiId); + if (!outcome.ok || outcome.episodes.length === 0) return { bound: 0, episodeIds }; + let bound = 0; + for (const t of targets) { + const ep = t.epNumber ?? parseEpisodeFromFilename(t.rawName); + const pick = pickDandanEpisode(outcome.episodes, ep, { + season: parseSeasonFromFilename(t.rawName), + }); + if (!pick) continue; + await mediaItemDao.setDanmakuMatch(t.id, userId, { + episodeId: pick.episodeId, + source: "auto", + }); + episodeIds.set(t.id, pick.episodeId); + bound += 1; + } + return { bound, episodeIds }; + } catch { + return { bound: 0, episodeIds }; + } +} diff --git a/packages/types/src/episode.test.ts b/packages/types/src/episode.test.ts index 7116d4c..5fea744 100644 --- a/packages/types/src/episode.test.ts +++ b/packages/types/src/episode.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { parseEpisodeFromFilename } from "./episode.js"; +import { + parseEpisodeFromFilename, + parseSeasonFromFilename, + parseYearFromFilename, +} from "./episode.js"; describe("parseEpisodeFromFilename", () => { it("S01E03", () => { @@ -40,3 +44,45 @@ describe("parseEpisodeFromFilename", () => { expect(parseEpisodeFromFilename("Movie.mkv")).toBeNull(); }); }); + +describe("parseSeasonFromFilename", () => { + it("SxxExx 中的季号", () => { + expect(parseSeasonFromFilename("Show.S02E05.1080p.mkv")).toBe(2); + expect(parseSeasonFromFilename("TRIGUN STARGAZE S01E03.mp4")).toBe(1); + }); + it("第N季", () => { + expect(parseSeasonFromFilename("[Sub] 进击的巨人 第二季 - 05.mkv")).toBeNull(); // 汉字数字不识别 + expect(parseSeasonFromFilename("[Sub] 进击的巨人 第2季 - 05.mkv")).toBe(2); + }); + it("Season N", () => { + expect(parseSeasonFromFilename("Show Season 2 - 05.mkv")).toBe(2); + }); + it("裸 Sx 标记", () => { + expect(parseSeasonFromFilename("[Sub] Show S2 - 15 [1080p].mkv")).toBe(2); + expect(parseSeasonFromFilename("Show S02 - 15.mkv")).toBe(2); + }); + it("无季号返回 null", () => { + expect(parseSeasonFromFilename("[Group] Title - 05 [1080p].mkv")).toBeNull(); + expect(parseSeasonFromFilename("Movie.mkv")).toBeNull(); + }); + it("S0 视为无效", () => { + expect(parseSeasonFromFilename("Show S0E1.mkv")).toBeNull(); + }); +}); + +describe("parseYearFromFilename", () => { + it("括号/方括号年份", () => { + expect(parseYearFromFilename("Title (2023) [1080p].mkv")).toBe(2023); + expect(parseYearFromFilename("[Group] Title [2019] [1080p].mkv")).toBe(2019); + }); + it("空格分隔年份", () => { + expect(parseYearFromFilename("Title 2023 1080p.mkv")).toBe(2023); + expect(parseYearFromFilename("Title.2023.1080p.x264.mkv")).toBe(2023); + }); + it("不吞分辨率/编码/日期串", () => { + expect(parseYearFromFilename("Movie 1080p.mkv")).toBeNull(); + expect(parseYearFromFilename("Movie 2160p x265.mkv")).toBeNull(); + expect(parseYearFromFilename("Show 20230101.mkv")).toBeNull(); + expect(parseYearFromFilename("Movie.mkv")).toBeNull(); + }); +}); diff --git a/packages/types/src/episode.ts b/packages/types/src/episode.ts index 293ecdd..1942869 100644 --- a/packages/types/src/episode.ts +++ b/packages/types/src/episode.ts @@ -23,3 +23,39 @@ export function parseEpisodeFromFilename(name: string): number | null { } return null; } + +/** + * 从文件名提取季号(SxxExx → 第N季 → Season N → 裸 Sx 标记)。 + * 与 parseEpisodeFromFilename 配合:SxxExx 模式下集数为季内集号,季号用于绝对集号换算。 + */ +export function parseSeasonFromFilename(name: string): number | null { + const s = name.replace(/\.[a-z0-9]+$/i, ""); + const patterns: RegExp[] = [ + /S(\d{1,2})\s*E\d{1,3}/i, + /第\s*(\d{1,4})\s*季/, + /Season\s*(\d{1,2})/i, + /(?:^|[\s\-_[(])S(\d{1,2})(?:[\s\])\-_.]|$)/i, + ]; + for (const re of patterns) { + const m = s.match(re); + if (m?.[1]) { + const n = Number.parseInt(m[1], 10); + // 季号从 0 起无意义(S0E1 不存在),0 视为未识别 + if (Number.isFinite(n) && n > 0) return n; + } + } + return null; +} + +/** + * 从文件名提取年份((2023) / [2023] / Title 2023)。 + * 限定 19xx/20xx 并排除后随数字,避免吞掉 1080/2160/264 等分辨率与编码串。 + */ +export function parseYearFromFilename(name: string): number | null { + const s = name.replace(/\.[a-z0-9]+$/i, ""); + const m = /(?:^|[^\d])((?:19|20)\d{2})(?![\d])/.exec(s); + const raw = m?.[1]; + if (!raw) return null; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) ? n : null; +}