feat(danmaku): derive series danmaku bindings from scrape metadata
- derive unbound series siblings via bgmtv bridge on bind, manual select, auto match and playback fetch fallback (skip per-file hash match) - store bangumi aliases/air year on scrape; aliases join candidate scoring, year disambiguates same-name subjects during auto-scrape - structure filter: hard-converge candidates on equal episode numbers and drop movie/OVA/SP specials when the local episode number is known - season-aware bridge picking converts absolute episode numbers via season offset; rebinding a series resets stale danmaku episode identity
This commit is contained in:
parent
e3dbaaeb31
commit
1142174f8f
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,7 +34,11 @@ export function effectiveCredentials(): OpenCredentials | null {
|
|||
}
|
||||
|
||||
export function openHeaders(path: string, creds: OpenCredentials | null): Record<string, string> {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
const headers: Record<string, string> = {
|
||||
"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<OpenEpisodesOu
|
|||
return { ok: true, anime: parseAnime(body) };
|
||||
}
|
||||
|
||||
export type OpenBangumiEpisodesOutcome =
|
||||
| { ok: true; episodes: OpenEpisodeBrief[] }
|
||||
| { ok: false; errorMessage: string; episodes: [] };
|
||||
|
||||
/** GET /api/v2/bangumi/bgmtv/{subjectId} — Bangumi 作品 → 弹弹play 剧集列表(bgmtv 桥) */
|
||||
export async function openBangumiEpisodes(bangumiId: number): Promise<OpenBangumiEpisodesOutcome> {
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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<MediaItemRow | null> {
|
||||
const [row] = await db
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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("<i>open</i>");
|
||||
const r = await danmakuService.fetch("u1", "m1");
|
||||
expect(openMatch).toHaveBeenCalled();
|
||||
expect(r).toMatchObject({ ok: true, source: "open-network", xml: "<i>open</i>" });
|
||||
});
|
||||
|
||||
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("<i>open</i>");
|
||||
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: "<i>open</i>" });
|
||||
});
|
||||
|
||||
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("<i>open</i>");
|
||||
const r = await danmakuService.fetch("u1", "m1");
|
||||
expect(openMatch).toHaveBeenCalled();
|
||||
expect(r).toMatchObject({ ok: true, source: "open-network", xml: "<i>open</i>" });
|
||||
});
|
||||
});
|
||||
|
||||
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("<i>open</i>");
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -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,6 +234,10 @@ 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);
|
||||
|
|
@ -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,6 +311,10 @@ 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 {
|
||||
|
|
@ -369,7 +366,11 @@ export const danmakuService = {
|
|||
async searchSource(keyword: string): Promise<DanmakuSourceSearchOutput> {
|
||||
await ensureOpenRuntime();
|
||||
if (!hasOpenCredentials()) {
|
||||
return { ok: false, message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置", animes: [] };
|
||||
return {
|
||||
ok: false,
|
||||
message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置",
|
||||
animes: [],
|
||||
};
|
||||
}
|
||||
const outcome = await openSearchEpisodes(keyword);
|
||||
if (!outcome.ok) {
|
||||
|
|
|
|||
|
|
@ -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> = {}): MediaItem {
|
||||
|
|
@ -135,6 +120,8 @@ function mediaItem(over: Partial<MediaItem> = {}): 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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<BangumiSearchHit[]> {
|
|||
}));
|
||||
}
|
||||
|
||||
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,9 +241,14 @@ export const scrapeService = {
|
|||
}
|
||||
|
||||
if (row.bangumiId != null) {
|
||||
const bridged = await bridgeDandanEpisode(
|
||||
row.bangumiId,
|
||||
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, {
|
||||
|
|
@ -265,6 +257,7 @@ export const scrapeService = {
|
|||
scrapeStatus: row.scrapeStatus,
|
||||
scrapedAt: new Date(),
|
||||
});
|
||||
await deriveSeriesDanmaku(userId, row.bangumiId, row.mountId);
|
||||
return {
|
||||
ok: true,
|
||||
matched: true,
|
||||
|
|
@ -282,6 +275,7 @@ export const scrapeService = {
|
|||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
matched: false,
|
||||
|
|
@ -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<BatchResult> {
|
||||
const rows = await mediaItemDao.listByPathPrefix(userId, mountId, path);
|
||||
let bound = 0;
|
||||
let skipped = 0;
|
||||
const derived = new Set<number>();
|
||||
const deriveOnce = async (bangumiId: number): Promise<void> => {
|
||||
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 };
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@app/danmaku")>();
|
||||
return {
|
||||
...actual,
|
||||
hasOpenCredentials: vi.fn(),
|
||||
openBangumiEpisodes: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function mediaItem(over: Partial<MediaItem> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, number> }> {
|
||||
const episodeIds = new Map<string, number>();
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue