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 {
|
import {
|
||||||
hasOpenCredentials,
|
hasOpenCredentials,
|
||||||
openApiBase,
|
openApiBase,
|
||||||
|
openBangumiEpisodes,
|
||||||
openCommentXml,
|
openCommentXml,
|
||||||
openHeaders,
|
openHeaders,
|
||||||
openMatch,
|
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> {
|
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;
|
if (!creds) return headers;
|
||||||
const timestamp = Math.floor(Date.now() / 1000);
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
return {
|
return {
|
||||||
|
|
@ -119,6 +123,7 @@ export type OpenAnimeBrief = {
|
||||||
export type OpenEpisodeBrief = {
|
export type OpenEpisodeBrief = {
|
||||||
episodeId: number;
|
episodeId: number;
|
||||||
episodeTitle: string | null;
|
episodeTitle: string | null;
|
||||||
|
episodeNumber: string | null;
|
||||||
type: string | null;
|
type: string | null;
|
||||||
typeDescription: string | null;
|
typeDescription: string | null;
|
||||||
};
|
};
|
||||||
|
|
@ -126,6 +131,7 @@ export type OpenEpisodeBrief = {
|
||||||
type RawEpisode = {
|
type RawEpisode = {
|
||||||
episodeId?: number | string;
|
episodeId?: number | string;
|
||||||
episodeTitle?: string | null;
|
episodeTitle?: string | null;
|
||||||
|
episodeNumber?: string | null;
|
||||||
type?: string | null;
|
type?: string | null;
|
||||||
typeDescription?: string | null;
|
typeDescription?: string | null;
|
||||||
};
|
};
|
||||||
|
|
@ -150,6 +156,7 @@ function parseEpisode(e: RawEpisode): OpenEpisodeBrief | null {
|
||||||
return {
|
return {
|
||||||
episodeId,
|
episodeId,
|
||||||
episodeTitle: e.episodeTitle ?? null,
|
episodeTitle: e.episodeTitle ?? null,
|
||||||
|
episodeNumber: e.episodeNumber ?? null,
|
||||||
type: e.type ?? null,
|
type: e.type ?? null,
|
||||||
typeDescription: e.typeDescription ?? null,
|
typeDescription: e.typeDescription ?? null,
|
||||||
};
|
};
|
||||||
|
|
@ -198,6 +205,21 @@ export async function openAnimeEpisodes(animeId: number): Promise<OpenEpisodesOu
|
||||||
return { ok: true, anime: parseAnime(body) };
|
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) */
|
/** 会话 token(登录弹弹play 后设置;请求附加 Authorization) */
|
||||||
let sessionToken: string | null = null;
|
let sessionToken: string | null = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export {
|
||||||
hasOpenCredentials,
|
hasOpenCredentials,
|
||||||
openApiBase,
|
openApiBase,
|
||||||
openAnimeEpisodes,
|
openAnimeEpisodes,
|
||||||
|
openBangumiEpisodes,
|
||||||
openCommentXml,
|
openCommentXml,
|
||||||
openFetchJson,
|
openFetchJson,
|
||||||
openHeaders,
|
openHeaders,
|
||||||
|
|
@ -23,6 +24,7 @@ export {
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
export type {
|
export type {
|
||||||
OpenAnimeBrief,
|
OpenAnimeBrief,
|
||||||
|
OpenBangumiEpisodesOutcome,
|
||||||
OpenCredentials,
|
OpenCredentials,
|
||||||
OpenEpisodeBrief,
|
OpenEpisodeBrief,
|
||||||
OpenEpisodesOutcome,
|
OpenEpisodesOutcome,
|
||||||
|
|
|
||||||
|
|
@ -155,4 +155,90 @@ describe("rankCandidates", () => {
|
||||||
];
|
];
|
||||||
expect(rankCandidates(list).highConfidence).toBe(false);
|
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;
|
animeTitle?: string | null;
|
||||||
episodeTitle?: string | null;
|
episodeTitle?: string | null;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
|
type?: string | null;
|
||||||
|
typeDescription?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 本地媒体上下文,用于候选打分(标题包含关系 / 集号相等加分) */
|
/** 本地媒体上下文,用于候选打分(标题包含关系 / 集号相等加分) */
|
||||||
|
|
@ -20,6 +22,8 @@ export type RankHint = {
|
||||||
title?: string | undefined;
|
title?: string | undefined;
|
||||||
rawName?: string | undefined;
|
rawName?: string | undefined;
|
||||||
epNumber?: number | null | undefined;
|
epNumber?: number | null | undefined;
|
||||||
|
/** Bangumi 刮削别名(原名/罗马音等),与候选标题同权比对 */
|
||||||
|
aliases?: string[] | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function parseMatchOutcome(json: unknown): {
|
export function parseMatchOutcome(json: unknown): {
|
||||||
|
|
@ -44,6 +48,8 @@ export function parseMatchOutcome(json: unknown): {
|
||||||
animeTitle: m.animeTitle ?? null,
|
animeTitle: m.animeTitle ?? null,
|
||||||
episodeTitle: m.episodeTitle ?? null,
|
episodeTitle: m.episodeTitle ?? null,
|
||||||
imageUrl: m.imageUrl ?? null,
|
imageUrl: m.imageUrl ?? null,
|
||||||
|
type: m.type ?? null,
|
||||||
|
typeDescription: m.typeDescription ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { isMatched: body.isMatched === true, candidates };
|
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;
|
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 {
|
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);
|
const base = (c.animeTitle ? 1 : 0) * 0.5 + (c.episodeTitle ? 0.5 : 0) + (c.imageUrl ? 0.1 : 0);
|
||||||
if (!hint) return base;
|
if (!hint) return base;
|
||||||
const matched = Math.max(
|
const matched = bestTitleBonus(hint, c.animeTitle);
|
||||||
titleBonus(hint.title, c.animeTitle),
|
|
||||||
titleBonus(hint.rawName, c.animeTitle),
|
|
||||||
);
|
|
||||||
return base + matched + episodeBonus(hint, c.episodeTitle);
|
return base + matched + episodeBonus(hint, c.episodeTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,14 +137,15 @@ export function rankCandidates(
|
||||||
opts?: { lead?: number; hint?: RankHint } | undefined,
|
opts?: { lead?: number; hint?: RankHint } | undefined,
|
||||||
): { candidates: MatchCandidate[]; highConfidence: boolean } {
|
): { candidates: MatchCandidate[]; highConfidence: boolean } {
|
||||||
const lead = opts?.lead ?? 0.15;
|
const lead = opts?.lead ?? 0.15;
|
||||||
if (candidates.length === 0) return { candidates: [], highConfidence: false };
|
const pool = structureFilter(candidates, opts?.hint);
|
||||||
if (candidates.length === 1) return { candidates, highConfidence: true };
|
if (pool.length === 0) return { candidates: [], highConfidence: false };
|
||||||
const scored = candidates
|
if (pool.length === 1) return { candidates: pool, highConfidence: true };
|
||||||
|
const scored = pool
|
||||||
.map((c) => ({ c, score: scoreCandidate(c, opts?.hint) }))
|
.map((c) => ({ c, score: scoreCandidate(c, opts?.hint) }))
|
||||||
.sort((a, b) => b.score - a.score);
|
.sort((a, b) => b.score - a.score);
|
||||||
const top = scored[0];
|
const top = scored[0];
|
||||||
const second = scored[1];
|
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;
|
const high = second ? top.score - second.score >= lead : true;
|
||||||
return { candidates: scored.map((s) => s.c), highConfidence: high };
|
return { candidates: scored.map((s) => s.c), highConfidence: high };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ export type MatchCandidate = {
|
||||||
animeTitle: string | null;
|
animeTitle: string | null;
|
||||||
episodeTitle: string | null;
|
episodeTitle: string | null;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
|
/** match 响应可选的类型字段(TV/OVA/剧场版),结构消歧用;响应缺省为 undefined */
|
||||||
|
type?: string | null | undefined;
|
||||||
|
typeDescription?: string | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoginResult = {
|
export type LoginResult = {
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,11 @@ export const mediaItemDao = {
|
||||||
scrapedAt?: Date | null;
|
scrapedAt?: Date | null;
|
||||||
posterUrl?: string | null;
|
posterUrl?: string | null;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
/** Bangumi 别名 JSON 数组字符串;null 清空 */
|
||||||
|
titleAliases?: string | null;
|
||||||
|
airYear?: number | null;
|
||||||
|
/** 换绑作品时重置弹幕身份来源 */
|
||||||
|
danmakuMatchSource?: "auto" | "manual" | "none";
|
||||||
},
|
},
|
||||||
): Promise<MediaItemRow | null> {
|
): Promise<MediaItemRow | null> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ export const mediaItems = sqliteTable(
|
||||||
scrapeStatus: text("scrape_status").notNull().default("pending"),
|
scrapeStatus: text("scrape_status").notNull().default("pending"),
|
||||||
scrapedAt: integer("scraped_at", { mode: "timestamp" }),
|
scrapedAt: integer("scraped_at", { mode: "timestamp" }),
|
||||||
posterUrl: text("poster_url"),
|
posterUrl: text("poster_url"),
|
||||||
|
/** Bangumi 刮削别名(JSON 数组:原名/罗马音/假名),弹幕候选打分用 */
|
||||||
|
titleAliases: text("title_aliases"),
|
||||||
|
/** 首播年份(刮削时落库),同名作品消歧 */
|
||||||
|
airYear: integer("air_year"),
|
||||||
/** 弹弹play 弹幕库编号(文件识别成功后落库,弹幕拉取直达) */
|
/** 弹弹play 弹幕库编号(文件识别成功后落库,弹幕拉取直达) */
|
||||||
dandanplayEpisodeId: integer("dandanplay_episode_id"),
|
dandanplayEpisodeId: integer("dandanplay_episode_id"),
|
||||||
matchedHash: text("matched_hash"),
|
matchedHash: text("matched_hash"),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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 { danmakuCacheDao, danmakuDocsDao, danmakuPrefsDao, mediaItemDao, mountDao } from "@app/dao";
|
||||||
import type {
|
import type {
|
||||||
DanmakuCacheRow,
|
DanmakuCacheRow,
|
||||||
|
|
@ -9,6 +15,7 @@ import type {
|
||||||
Mount,
|
Mount,
|
||||||
} from "@app/models";
|
} from "@app/models";
|
||||||
import { danmakuService } from "./danmaku.service.js";
|
import { danmakuService } from "./danmaku.service.js";
|
||||||
|
import { getDandanplayAuthStatus } from "./settings.service.js";
|
||||||
import { createWebdav } from "./webdav-client.js";
|
import { createWebdav } from "./webdav-client.js";
|
||||||
|
|
||||||
vi.mock("./settings.service.js", () => ({
|
vi.mock("./settings.service.js", () => ({
|
||||||
|
|
@ -24,6 +31,7 @@ vi.mock("@app/dao", () => ({
|
||||||
mediaItemDao: {
|
mediaItemDao: {
|
||||||
getByIdForUser: vi.fn(),
|
getByIdForUser: vi.fn(),
|
||||||
setDanmakuMatch: vi.fn(),
|
setDanmakuMatch: vi.fn(),
|
||||||
|
listByBangumiId: vi.fn(),
|
||||||
},
|
},
|
||||||
mountDao: { getByIdForUser: vi.fn() },
|
mountDao: { getByIdForUser: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
@ -43,6 +51,7 @@ vi.mock("@app/danmaku", async (importOriginal) => {
|
||||||
openMatch: vi.fn(),
|
openMatch: vi.fn(),
|
||||||
openCommentXml: vi.fn(),
|
openCommentXml: vi.fn(),
|
||||||
hasOpenCredentials: vi.fn(),
|
hasOpenCredentials: vi.fn(),
|
||||||
|
openBangumiEpisodes: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -64,6 +73,8 @@ function mediaItem(over: Partial<MediaItemRow> = {}): MediaItemRow {
|
||||||
dandanplayEpisodeId: null,
|
dandanplayEpisodeId: null,
|
||||||
matchedHash: null,
|
matchedHash: null,
|
||||||
danmakuMatchSource: "none",
|
danmakuMatchSource: "none",
|
||||||
|
titleAliases: null,
|
||||||
|
airYear: null,
|
||||||
scannedAt: new Date(),
|
scannedAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
...over,
|
...over,
|
||||||
|
|
@ -244,6 +255,33 @@ describe("danmakuService.fetch", () => {
|
||||||
expect(openMatch).not.toHaveBeenCalled();
|
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 () => {
|
it("高置信自动绑(source=auto)后拉取,缓存键为 String(episodeId)", async () => {
|
||||||
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
vi.mocked(danmakuDocsDao.get).mockResolvedValue(null);
|
vi.mocked(danmakuDocsDao.get).mockResolvedValue(null);
|
||||||
|
|
@ -307,6 +345,87 @@ describe("danmakuService.fetch", () => {
|
||||||
expect(openCommentXml).not.toHaveBeenCalled();
|
expect(openCommentXml).not.toHaveBeenCalled();
|
||||||
expect(danmakuCacheDao.upsert).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", () => {
|
describe("danmakuService.selectMatch", () => {
|
||||||
|
|
@ -346,6 +465,49 @@ describe("danmakuService.selectMatch", () => {
|
||||||
// 手选即离开本地源:即便拉取失败也清 local-xml
|
// 手选即离开本地源:即便拉取失败也清 local-xml
|
||||||
expect(danmakuDocsDao.remove).toHaveBeenCalledWith("u1", "m1", "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", () => {
|
describe("danmakuService.matchCandidates", () => {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ import type {
|
||||||
DanmakuSourceSearchOutput,
|
DanmakuSourceSearchOutput,
|
||||||
} from "@app/types";
|
} from "@app/types";
|
||||||
import { decryptSecret } from "./secret.js";
|
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";
|
import { createWebdav, joinWebdavPath, listDirectory, type DirEntry } from "./webdav-client.js";
|
||||||
|
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
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" };
|
return { ok: true, source: "cache", xml: cached.payload, reason: "cache" };
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
// 官方 open API 的 match/comment 无需登录(swagger security=null);
|
||||||
const auth = await getDandanplayAuthStatus();
|
// 登录态 token 由 @app/danmaku client 在请求头附加,属可选增强
|
||||||
if (!auth.loggedIn) {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
source: "none",
|
|
||||||
xml: "",
|
|
||||||
reason: "need-login",
|
|
||||||
message: "获取在线弹幕需登录弹弹play 账号(设置页)",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let episodeId = item.dandanplayEpisodeId ?? null;
|
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) {
|
if (episodeId == null) {
|
||||||
const hash = await remoteFileHash(userId, mediaItemId);
|
const hash = await remoteFileHash(userId, mediaItemId);
|
||||||
const outcome = await openMatch(matchFileName(item.path), item.size, hash);
|
const outcome = await openMatch(matchFileName(item.path), item.size, hash);
|
||||||
const ranked = rankCandidates(outcome.candidates, {
|
const ranked = rankCandidates(outcome.candidates, { hint: buildMatchHint(item) });
|
||||||
hint: {
|
|
||||||
title: item.title,
|
|
||||||
rawName: item.rawName,
|
|
||||||
epNumber: item.epNumber ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!outcome.ok || !outcome.isMatched || ranked.candidates.length === 0) {
|
if (!outcome.ok || !outcome.isMatched || ranked.candidates.length === 0) {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
@ -230,7 +221,11 @@ export const danmakuService = {
|
||||||
}
|
}
|
||||||
episodeId = ranked.candidates[0]?.episodeId ?? null;
|
episodeId = ranked.candidates[0]?.episodeId ?? null;
|
||||||
if (episodeId == null) {
|
if (episodeId == null) {
|
||||||
return { ok: true, source: "none", xml: "", reason: "no-match",
|
return {
|
||||||
|
ok: true,
|
||||||
|
source: "none",
|
||||||
|
xml: "",
|
||||||
|
reason: "no-match",
|
||||||
message: "开放网络未匹配到弹幕",
|
message: "开放网络未匹配到弹幕",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -239,6 +234,10 @@ export const danmakuService = {
|
||||||
matchedHash: hash,
|
matchedHash: hash,
|
||||||
source: "auto",
|
source: "auto",
|
||||||
});
|
});
|
||||||
|
// 自动绑后反哺同组未绑定条目(方案1/5)
|
||||||
|
if (item.bangumiId != null) {
|
||||||
|
await deriveSeriesDanmaku(userId, item.bangumiId, item.mountId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const xml = await openCommentXml(episodeId);
|
const xml = await openCommentXml(episodeId);
|
||||||
|
|
@ -279,13 +278,7 @@ export const danmakuService = {
|
||||||
}
|
}
|
||||||
const hash = await remoteFileHash(userId, mediaItemId);
|
const hash = await remoteFileHash(userId, mediaItemId);
|
||||||
const outcome = await openMatch(matchFileName(item.path), item.size, hash);
|
const outcome = await openMatch(matchFileName(item.path), item.size, hash);
|
||||||
const ranked = rankCandidates(outcome.candidates, {
|
const ranked = rankCandidates(outcome.candidates, { hint: buildMatchHint(item) });
|
||||||
hint: {
|
|
||||||
title: item.title,
|
|
||||||
rawName: item.rawName,
|
|
||||||
epNumber: item.epNumber ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
ok: outcome.ok,
|
ok: outcome.ok,
|
||||||
matched: outcome.isMatched && ranked.candidates.length > 0,
|
matched: outcome.isMatched && ranked.candidates.length > 0,
|
||||||
|
|
@ -318,6 +311,10 @@ export const danmakuService = {
|
||||||
});
|
});
|
||||||
// 手选即切换到开放网络:清掉 local-xml,避免 fetch 仍优先进本地源
|
// 手选即切换到开放网络:清掉 local-xml,避免 fetch 仍优先进本地源
|
||||||
await danmakuDocsDao.remove(userId, mediaItemId, "local-xml");
|
await danmakuDocsDao.remove(userId, mediaItemId, "local-xml");
|
||||||
|
// 手选反哺(方案5):同组未绑定条目按集号整组继承
|
||||||
|
if (item.bangumiId != null) {
|
||||||
|
await deriveSeriesDanmaku(userId, item.bangumiId, item.mountId);
|
||||||
|
}
|
||||||
const xml = await openCommentXml(episodeId);
|
const xml = await openCommentXml(episodeId);
|
||||||
if (xml === null) {
|
if (xml === null) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -369,7 +366,11 @@ export const danmakuService = {
|
||||||
async searchSource(keyword: string): Promise<DanmakuSourceSearchOutput> {
|
async searchSource(keyword: string): Promise<DanmakuSourceSearchOutput> {
|
||||||
await ensureOpenRuntime();
|
await ensureOpenRuntime();
|
||||||
if (!hasOpenCredentials()) {
|
if (!hasOpenCredentials()) {
|
||||||
return { ok: false, message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置", animes: [] };
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置",
|
||||||
|
animes: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const outcome = await openSearchEpisodes(keyword);
|
const outcome = await openSearchEpisodes(keyword);
|
||||||
if (!outcome.ok) {
|
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 { openMatch, hasOpenCredentials } from "@app/danmaku";
|
||||||
import { mediaItemDao } from "@app/dao";
|
import { mediaItemDao } from "@app/dao";
|
||||||
import type { MediaItem } from "@app/models";
|
import type { MediaItem } from "@app/models";
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
AUTO_BIND_THRESHOLD,
|
AUTO_BIND_THRESHOLD,
|
||||||
buildSearchQuery,
|
buildSearchQuery,
|
||||||
parseEpisodeFromFilename,
|
parseEpisodeFromFilename,
|
||||||
pickDandanEpisode,
|
|
||||||
scoreHit,
|
scoreHit,
|
||||||
} from "./scrape.service.js";
|
} from "./scrape.service.js";
|
||||||
import { scrapeService } from "./scrape.service.js";
|
import { scrapeService } from "./scrape.service.js";
|
||||||
|
|
@ -16,6 +15,8 @@ vi.mock("@app/dao", () => ({
|
||||||
getByIdForUser: vi.fn(),
|
getByIdForUser: vi.fn(),
|
||||||
updateScrape: vi.fn(),
|
updateScrape: vi.fn(),
|
||||||
listByPathPrefix: vi.fn(),
|
listByPathPrefix: vi.fn(),
|
||||||
|
listByBangumiId: vi.fn(),
|
||||||
|
setDanmakuMatch: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -27,6 +28,7 @@ vi.mock("@app/danmaku", async (importOriginal) => {
|
||||||
openCommentXml: vi.fn(),
|
openCommentXml: vi.fn(),
|
||||||
hasOpenCredentials: vi.fn(),
|
hasOpenCredentials: vi.fn(),
|
||||||
openFetchJson: 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", () => {
|
describe("scoreHit", () => {
|
||||||
it("完全命中最高", () => {
|
it("完全命中最高", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -115,6 +88,18 @@ describe("scoreHit", () => {
|
||||||
const s = scoreHit("葬送的芙莉莲", { name: "Naruto", nameCn: "火影忍者" });
|
const s = scoreHit("葬送的芙莉莲", { name: "Naruto", nameCn: "火影忍者" });
|
||||||
expect(s).toBeLessThan(AUTO_BIND_THRESHOLD);
|
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 {
|
function mediaItem(over: Partial<MediaItem> = {}): MediaItem {
|
||||||
|
|
@ -135,6 +120,8 @@ function mediaItem(over: Partial<MediaItem> = {}): MediaItem {
|
||||||
dandanplayEpisodeId: null,
|
dandanplayEpisodeId: null,
|
||||||
matchedHash: null,
|
matchedHash: null,
|
||||||
danmakuMatchSource: "none",
|
danmakuMatchSource: "none",
|
||||||
|
titleAliases: null,
|
||||||
|
airYear: null,
|
||||||
scannedAt: new Date(),
|
scannedAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
...over,
|
...over,
|
||||||
|
|
@ -145,6 +132,7 @@ describe("scrapeService.matchDandanplay 绑定一致性", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(hasOpenCredentials).mockReturnValue(true);
|
vi.mocked(hasOpenCredentials).mockReturnValue(true);
|
||||||
|
vi.mocked(mediaItemDao.listByBangumiId).mockResolvedValue([]);
|
||||||
vi.mocked(openMatch).mockResolvedValue({
|
vi.mocked(openMatch).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
|
|
@ -236,3 +224,64 @@ describe("scrapeService.matchDandanplay 绑定一致性", () => {
|
||||||
expect(mediaItemDao.updateScrape).not.toHaveBeenCalled();
|
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 {
|
import {
|
||||||
hasOpenCredentials,
|
hasOpenCredentials,
|
||||||
matchFileName,
|
matchFileName,
|
||||||
openFetchJson,
|
openBangumiEpisodes,
|
||||||
openMatch,
|
openMatch,
|
||||||
rankCandidates,
|
rankCandidates,
|
||||||
} from "@app/danmaku";
|
} from "@app/danmaku";
|
||||||
import { ensureOpenRuntime } from "./settings.service.js";
|
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 { remoteFileHash } from "./danmaku.service.js";
|
||||||
|
import { buildMatchHint, deriveSeriesDanmaku, pickDandanEpisode } from "./series-derive.js";
|
||||||
|
|
||||||
/** 集数解析统一走 @app/types(前后端共用同一套正则);此处再导出保持旧入口可用 */
|
/** 集数解析统一走 @app/types(前后端共用同一套正则);此处再导出保持旧入口可用 */
|
||||||
export { parseEpisodeFromFilename };
|
export { parseEpisodeFromFilename };
|
||||||
|
|
@ -42,8 +49,21 @@ function normalizeTitle(s: string): string {
|
||||||
.replace(/[((][^))]*[))]/g, "");
|
.replace(/[((][^))]*[))]/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 候选与检索词的相似度 0–1;借鉴弹弹play「候选列表+择优」而非盲取第一条 */
|
/** 候选与检索词的相似度 0–1;借鉴弹弹play「候选列表+择优」而非盲取第一条。
|
||||||
export function scoreHit(query: string, hit: { name: string; nameCn: string }): number {
|
* 年份消歧(方案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);
|
const q = normalizeTitle(query);
|
||||||
if (!q) return 0;
|
if (!q) return 0;
|
||||||
const cn = normalizeTitle(hit.nameCn);
|
const cn = normalizeTitle(hit.nameCn);
|
||||||
|
|
@ -74,64 +94,6 @@ export type BangumiSearchHit = {
|
||||||
summary: string;
|
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 {
|
function apiBase(): string {
|
||||||
return process.env["BANGUMI_API_BASE"] ?? "https://api.bgm.tv";
|
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<{
|
async function fetchSubjectMeta(bangumiId: number): Promise<{
|
||||||
title: string | null;
|
title: string | null;
|
||||||
posterUrl: string | null;
|
posterUrl: string | null;
|
||||||
|
aliases: string[];
|
||||||
|
airYear: number | null;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${apiBase()}/subject/${bangumiId}`, {
|
const res = await fetch(`${apiBase()}/subject/${bangumiId}`, {
|
||||||
headers: { "User-Agent": "dandanplay-web/0.1" },
|
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 {
|
const subject = (await res.json()) as {
|
||||||
name?: string;
|
name?: string;
|
||||||
name_cn?: string;
|
name_cn?: string;
|
||||||
|
name_romaji?: string;
|
||||||
|
name_kana?: string;
|
||||||
|
date?: string | null;
|
||||||
|
date_cn?: string | null;
|
||||||
images?: { medium?: string; large?: string };
|
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 {
|
return {
|
||||||
title: subject.name_cn || subject.name || null,
|
title: subject.name_cn || subject.name || null,
|
||||||
posterUrl: subject.images?.large ?? subject.images?.medium ?? null,
|
posterUrl: subject.images?.large ?? subject.images?.medium ?? null,
|
||||||
|
aliases,
|
||||||
|
airYear: yearFromDate(subject.date ?? subject.date_cn),
|
||||||
};
|
};
|
||||||
} catch {
|
} 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 = {
|
export const scrapeService = {
|
||||||
search: bangumiSearch,
|
search: bangumiSearch,
|
||||||
|
|
||||||
|
|
@ -217,13 +206,7 @@ export const scrapeService = {
|
||||||
highConfidence: false,
|
highConfidence: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const ranked = rankCandidates(outcome.candidates, {
|
const ranked = rankCandidates(outcome.candidates, { hint: buildMatchHint(row) });
|
||||||
hint: {
|
|
||||||
title: row.title,
|
|
||||||
rawName: row.rawName,
|
|
||||||
epNumber: row.epNumber ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const best = ranked.candidates[0];
|
const best = ranked.candidates[0];
|
||||||
// 与 fetch 对齐:仅高置信才自动绑并写元数据;低置信不落库,返回候选供手选
|
// 与 fetch 对齐:仅高置信才自动绑并写元数据;低置信不落库,返回候选供手选
|
||||||
if (best && ranked.highConfidence) {
|
if (best && ranked.highConfidence) {
|
||||||
|
|
@ -236,6 +219,10 @@ export const scrapeService = {
|
||||||
title: best.animeTitle ?? row.title,
|
title: best.animeTitle ?? row.title,
|
||||||
epNumber: row.epNumber ?? parseEpisodeFromFilename(row.rawName),
|
epNumber: row.epNumber ?? parseEpisodeFromFilename(row.rawName),
|
||||||
});
|
});
|
||||||
|
// Series 批量推导:同组未绑定条目按集号继承
|
||||||
|
if (row.bangumiId != null) {
|
||||||
|
await deriveSeriesDanmaku(userId, row.bangumiId, row.mountId);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
matched: true,
|
matched: true,
|
||||||
|
|
@ -254,9 +241,14 @@ export const scrapeService = {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.bangumiId != null) {
|
if (row.bangumiId != null) {
|
||||||
const bridged = await bridgeDandanEpisode(
|
const episodes = await openBangumiEpisodes(row.bangumiId);
|
||||||
row.bangumiId,
|
if (episodes.ok) {
|
||||||
|
const bridged = pickDandanEpisode(
|
||||||
|
episodes.episodes,
|
||||||
row.epNumber ?? parseEpisodeFromFilename(row.rawName),
|
row.epNumber ?? parseEpisodeFromFilename(row.rawName),
|
||||||
|
{
|
||||||
|
season: parseSeasonFromFilename(row.rawName),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (bridged) {
|
if (bridged) {
|
||||||
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
||||||
|
|
@ -265,6 +257,7 @@ export const scrapeService = {
|
||||||
scrapeStatus: row.scrapeStatus,
|
scrapeStatus: row.scrapeStatus,
|
||||||
scrapedAt: new Date(),
|
scrapedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
await deriveSeriesDanmaku(userId, row.bangumiId, row.mountId);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
matched: true,
|
matched: true,
|
||||||
|
|
@ -282,6 +275,7 @@ export const scrapeService = {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
matched: false,
|
matched: false,
|
||||||
|
|
@ -301,6 +295,7 @@ export const scrapeService = {
|
||||||
const row = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
const row = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||||||
if (!row) throw new Error("媒体不存在");
|
if (!row) throw new Error("媒体不存在");
|
||||||
const meta = await fetchSubjectMeta(bangumiId);
|
const meta = await fetchSubjectMeta(bangumiId);
|
||||||
|
const changed = row.bangumiId !== bangumiId;
|
||||||
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
||||||
bangumiId,
|
bangumiId,
|
||||||
scrapeStatus: "ok",
|
scrapeStatus: "ok",
|
||||||
|
|
@ -308,7 +303,18 @@ export const scrapeService = {
|
||||||
posterUrl: meta.posterUrl ?? row.posterUrl,
|
posterUrl: meta.posterUrl ?? row.posterUrl,
|
||||||
title: meta.title ?? row.title,
|
title: meta.title ?? row.title,
|
||||||
epNumber: epNumber ?? parseEpisodeFromFilename(row.rawName) ?? row.epNumber,
|
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 };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -326,6 +332,7 @@ export const scrapeService = {
|
||||||
const meta = await fetchSubjectMeta(bangumiId);
|
const meta = await fetchSubjectMeta(bangumiId);
|
||||||
let bound = 0;
|
let bound = 0;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
const changed = row.bangumiId !== bangumiId;
|
||||||
await mediaItemDao.updateScrape(row.id, userId, {
|
await mediaItemDao.updateScrape(row.id, userId, {
|
||||||
bangumiId,
|
bangumiId,
|
||||||
scrapeStatus: "ok",
|
scrapeStatus: "ok",
|
||||||
|
|
@ -333,9 +340,19 @@ export const scrapeService = {
|
||||||
posterUrl: meta.posterUrl ?? row.posterUrl,
|
posterUrl: meta.posterUrl ?? row.posterUrl,
|
||||||
title: meta.title ?? row.title,
|
title: meta.title ?? row.title,
|
||||||
epNumber: parseEpisodeFromFilename(row.rawName) ?? row.epNumber,
|
epNumber: parseEpisodeFromFilename(row.rawName) ?? row.epNumber,
|
||||||
|
titleAliases: aliasesColumn(meta.aliases),
|
||||||
|
airYear: meta.airYear,
|
||||||
|
...(changed
|
||||||
|
? {
|
||||||
|
dandanplayEpisodeId: null,
|
||||||
|
matchedHash: null,
|
||||||
|
danmakuMatchSource: "none" as const,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
bound += 1;
|
bound += 1;
|
||||||
}
|
}
|
||||||
|
await deriveSeriesDanmaku(userId, bangumiId, mountId);
|
||||||
return {
|
return {
|
||||||
total: rows.length,
|
total: rows.length,
|
||||||
bound,
|
bound,
|
||||||
|
|
@ -380,11 +397,12 @@ export const scrapeService = {
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 候选打分择优(弹弹play:返回候选列表择优,而非盲取第一条)
|
// 候选打分择优(弹弹play:返回候选列表择优,而非盲取第一条);年份参与同名消歧
|
||||||
|
const year = parseYearFromFilename(row.rawName);
|
||||||
let best: BangumiSearchHit | null = null;
|
let best: BangumiSearchHit | null = null;
|
||||||
let bestScore = 0;
|
let bestScore = 0;
|
||||||
for (const hit of hits) {
|
for (const hit of hits) {
|
||||||
const score = scoreHit(q, hit);
|
const score = scoreHit(q, hit, year);
|
||||||
if (score > bestScore) {
|
if (score > bestScore) {
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
best = hit;
|
best = hit;
|
||||||
|
|
@ -398,6 +416,7 @@ export const scrapeService = {
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const alias = best.name && best.name !== (best.nameCn || best.name) ? best.name : null;
|
||||||
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
||||||
bangumiId: best.id,
|
bangumiId: best.id,
|
||||||
scrapeStatus: "ok",
|
scrapeStatus: "ok",
|
||||||
|
|
@ -405,22 +424,32 @@ export const scrapeService = {
|
||||||
posterUrl: best.image || null,
|
posterUrl: best.image || null,
|
||||||
title: best.nameCn || best.name,
|
title: best.nameCn || best.name,
|
||||||
epNumber,
|
epNumber,
|
||||||
|
titleAliases: alias ? JSON.stringify([alias]) : null,
|
||||||
|
airYear: yearFromDate(best.date),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 文件夹级批量重刮(仅处理未 ok 的条目) */
|
/** 文件夹级批量重刮(仅处理未 ok 的条目);完成后按作品整组推导弹幕身份 */
|
||||||
async rescrapePath(userId: string, mountId: string, path: string): Promise<BatchResult> {
|
async rescrapePath(userId: string, mountId: string, path: string): Promise<BatchResult> {
|
||||||
const rows = await mediaItemDao.listByPathPrefix(userId, mountId, path);
|
const rows = await mediaItemDao.listByPathPrefix(userId, mountId, path);
|
||||||
let bound = 0;
|
let bound = 0;
|
||||||
let skipped = 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) {
|
for (const row of rows) {
|
||||||
if (row.scrapeStatus === "ok" && row.bangumiId != null) {
|
if (row.scrapeStatus === "ok" && row.bangumiId != null) {
|
||||||
skipped += 1;
|
skipped += 1;
|
||||||
|
await deriveOnce(row.bangumiId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await this.autoScrape(userId, row.id);
|
await this.autoScrape(userId, row.id);
|
||||||
const after = await mediaItemDao.getByIdForUser(row.id, userId);
|
const after = await mediaItemDao.getByIdForUser(row.id, userId);
|
||||||
if (after?.scrapeStatus === "ok") bound += 1;
|
if (after?.scrapeStatus === "ok") bound += 1;
|
||||||
|
if (after?.bangumiId != null) await deriveOnce(after.bangumiId);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
total: rows.length,
|
total: rows.length,
|
||||||
|
|
@ -461,6 +490,7 @@ export const scrapeService = {
|
||||||
const rows = await mediaItemDao.listByBangumiId(userId, bangumiId, mountId);
|
const rows = await mediaItemDao.listByBangumiId(userId, bangumiId, mountId);
|
||||||
if (rows.length === 0) throw new TRPCError({ code: "NOT_FOUND", message: "作品组不存在" });
|
if (rows.length === 0) throw new TRPCError({ code: "NOT_FOUND", message: "作品组不存在" });
|
||||||
const meta = await fetchSubjectMeta(newBangumiId);
|
const meta = await fetchSubjectMeta(newBangumiId);
|
||||||
|
const changed = bangumiId !== newBangumiId;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
await mediaItemDao.updateScrape(row.id, userId, {
|
await mediaItemDao.updateScrape(row.id, userId, {
|
||||||
bangumiId: newBangumiId,
|
bangumiId: newBangumiId,
|
||||||
|
|
@ -469,8 +499,19 @@ export const scrapeService = {
|
||||||
posterUrl: meta.posterUrl ?? row.posterUrl,
|
posterUrl: meta.posterUrl ?? row.posterUrl,
|
||||||
title: meta.title ?? row.title,
|
title: meta.title ?? row.title,
|
||||||
epNumber: row.epNumber,
|
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 };
|
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 { describe, expect, it } from "vitest";
|
||||||
import { parseEpisodeFromFilename } from "./episode.js";
|
import {
|
||||||
|
parseEpisodeFromFilename,
|
||||||
|
parseSeasonFromFilename,
|
||||||
|
parseYearFromFilename,
|
||||||
|
} from "./episode.js";
|
||||||
|
|
||||||
describe("parseEpisodeFromFilename", () => {
|
describe("parseEpisodeFromFilename", () => {
|
||||||
it("S01E03", () => {
|
it("S01E03", () => {
|
||||||
|
|
@ -40,3 +44,45 @@ describe("parseEpisodeFromFilename", () => {
|
||||||
expect(parseEpisodeFromFilename("Movie.mkv")).toBeNull();
|
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;
|
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