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:
noelorin 2026-09-27 22:28:17 +08:00
parent e3dbaaeb31
commit 1142174f8f
16 changed files with 1080 additions and 183 deletions

View File

@ -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([]);
});
});

View File

@ -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;

View File

@ -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,

View File

@ -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);
});
}); });

View File

@ -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 };
} }

View File

@ -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 = {

View File

@ -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

View File

@ -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"),

View File

@ -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", () => {

View File

@ -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) {

View File

@ -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");
});
});

View File

@ -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 };
}, },
}; };

View File

@ -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);
});
});

View File

@ -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 };
}
}

View File

@ -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();
});
});

View File

@ -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;
}