419 lines
15 KiB
TypeScript
419 lines
15 KiB
TypeScript
import { TRPCError } from "@trpc/server";
|
||
import { mediaItemDao } from "@app/dao";
|
||
import {
|
||
hasOpenCredentials,
|
||
matchFileName,
|
||
openFetchJson,
|
||
openMatch,
|
||
rankCandidates,
|
||
} from "@app/danmaku";
|
||
import type { BatchResult, DanmakuMatchOutput } from "@app/types";
|
||
import { remoteFileHash } from "./danmaku.service.js";
|
||
|
||
/** 从文件名提取集数:SxxExx / - NN / EPxx / 第N集·话 / [NN] / Exx */
|
||
export function parseEpisodeFromFilename(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\-_[·])EP?(\d{1,4})(?:[\s\-_\]·]|$)/i,
|
||
/第\s*(\d{1,4})\s*[集话話]/,
|
||
/\[(\d{1,3})\]/,
|
||
/\s-\s(\d{2,3})(?:\s|$|v\d)/i,
|
||
];
|
||
for (const re of patterns) {
|
||
const m = s.match(re);
|
||
if (m?.[1]) {
|
||
const n = Number.parseInt(m[1], 10);
|
||
if (Number.isFinite(n)) return n;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 从文件名剥出作品检索词:去扩展名/字幕组/集数/分辨率等噪声 */
|
||
export function buildSearchQuery(filename: string): string {
|
||
let s = filename.replace(/\.[a-z0-9]+$/i, "");
|
||
s = s.replace(/^\[[^\]]*\]\s*/, "");
|
||
s = s.replace(/\[[^\]]*\]/g, " ");
|
||
s = s.replace(/[((][^))]*[))]/g, " ");
|
||
// 集数标记
|
||
s = s.replace(/S\d{1,2}\s*E\d{1,3}/i, " ");
|
||
s = s.replace(/第\s*\d{1,4}\s*[集话話]/, " ");
|
||
s = s.replace(/(?:^|[\s\-_])EP?\d{1,4}(?:[\s\-_]|$)/i, " ");
|
||
s = s.replace(/\s-\s\d{2,3}(?:\s|$|v\d).*$/i, " ");
|
||
// 清晰度/编码/字幕组残留
|
||
s = s.replace(
|
||
/\b(1080p|720p|2160p|4k|x264|x265|hevc|aac|flac|10bit|8bit|bdrip|webrip|web-dl|bluray|aac\d\.?\d?|dual|简体|繁体|中字)\b/gi,
|
||
" ",
|
||
);
|
||
s = s.replace(/[【】[\]()()]/g, " ");
|
||
return s.replace(/[\s\-_·・.]+/g, " ").trim();
|
||
}
|
||
|
||
/** 归一化标题,便于模糊比对 */
|
||
function normalizeTitle(s: string): string {
|
||
return s
|
||
.toLowerCase()
|
||
.replace(/[\s\-_·・.。!!??:::、,,'"]/g, "")
|
||
.replace(/[((][^))]*[))]/g, "");
|
||
}
|
||
|
||
/** 候选与检索词的相似度 0–1;借鉴弹弹play「候选列表+择优」而非盲取第一条 */
|
||
export function scoreHit(query: string, hit: { name: string; nameCn: string }): number {
|
||
const q = normalizeTitle(query);
|
||
if (!q) return 0;
|
||
const cn = normalizeTitle(hit.nameCn);
|
||
const jp = normalizeTitle(hit.name);
|
||
if (cn === q || jp === q) return 1;
|
||
if (cn && (cn.includes(q) || q.includes(cn))) return 0.82;
|
||
if (jp && (jp.includes(q) || q.includes(jp))) return 0.78;
|
||
// token 重叠
|
||
const qTokens = new Set(query.toLowerCase().split(/\s+/).filter(Boolean));
|
||
const hitTokens = new Set(
|
||
`${hit.nameCn} ${hit.name}`.toLowerCase().split(/\s+/).filter(Boolean),
|
||
);
|
||
if (qTokens.size === 0 || hitTokens.size === 0) return 0.2;
|
||
let overlap = 0;
|
||
for (const tok of qTokens) if (hitTokens.has(tok)) overlap += 1;
|
||
return 0.3 + 0.5 * (overlap / qTokens.size);
|
||
}
|
||
|
||
/** 自动绑定阈值:低于此值留给手动匹配 */
|
||
export const AUTO_BIND_THRESHOLD = 0.55;
|
||
|
||
export type BangumiSearchHit = {
|
||
id: number;
|
||
name: string;
|
||
nameCn: string;
|
||
image: string;
|
||
date: string | null;
|
||
summary: string;
|
||
};
|
||
|
||
type RawDandanEpisode = {
|
||
episodeId?: number | string;
|
||
episodeTitle?: string | null;
|
||
episodeNumber?: string | null;
|
||
};
|
||
|
||
/**
|
||
* bgmtv 桥择集:episodeNumber 数值相等优先,其次按位序取第 N 条。
|
||
* epNumber 越界时不瞎猜返回 null;epNumber 未知时取第 1 条。
|
||
*/
|
||
export function pickDandanEpisode(
|
||
episodes: RawDandanEpisode[],
|
||
epNumber: number | null,
|
||
): { episodeId: number; episodeTitle: string | null } | null {
|
||
const parsed: {
|
||
episodeId: number;
|
||
episodeTitle: string | null;
|
||
episodeNumber: string | null;
|
||
}[] = [];
|
||
for (const e of episodes) {
|
||
const id =
|
||
typeof e.episodeId === "number"
|
||
? e.episodeId
|
||
: Number.parseInt(String(e.episodeId ?? ""), 10);
|
||
if (Number.isFinite(id)) {
|
||
parsed.push({
|
||
episodeId: id,
|
||
episodeTitle: e.episodeTitle ?? null,
|
||
episodeNumber: e.episodeNumber ?? null,
|
||
});
|
||
}
|
||
}
|
||
if (parsed.length === 0) return null;
|
||
if (epNumber != null) {
|
||
const exact = parsed.find((e) => {
|
||
const n = Number.parseInt(String(e.episodeNumber ?? ""), 10);
|
||
return Number.isFinite(n) && n === epNumber;
|
||
});
|
||
if (exact) return { episodeId: exact.episodeId, episodeTitle: exact.episodeTitle };
|
||
const byIndex = parsed[epNumber - 1];
|
||
if (byIndex) return { episodeId: byIndex.episodeId, episodeTitle: byIndex.episodeTitle };
|
||
return null;
|
||
}
|
||
const first = parsed[0];
|
||
return first ? { episodeId: first.episodeId, episodeTitle: first.episodeTitle } : null;
|
||
}
|
||
|
||
/** Bangumi 绑定 → 弹弹play 剧集(免 hash 桥 /api/v2/bangumi/bgmtv/{subjectId});失败/无数据返回 null */
|
||
async function bridgeDandanEpisode(
|
||
bangumiId: number,
|
||
epNumber: number | null,
|
||
): Promise<{ episodeId: number; episodeTitle: string | null } | null> {
|
||
const res = await openFetchJson(`/api/v2/bangumi/bgmtv/${bangumiId}`);
|
||
if (!res.ok) return null;
|
||
const body = res.json as { bangumi?: { episodes?: RawDandanEpisode[] } };
|
||
return pickDandanEpisode(body.bangumi?.episodes ?? [], epNumber);
|
||
}
|
||
|
||
function apiBase(): string {
|
||
return process.env["BANGUMI_API_BASE"] ?? "https://api.bgm.tv";
|
||
}
|
||
|
||
export async function bangumiSearch(q: string): Promise<BangumiSearchHit[]> {
|
||
const url = `${apiBase()}/search/subject/${encodeURIComponent(q)}?type=2&responseSearch=subjects`;
|
||
const res = await fetch(url, {
|
||
headers: { "User-Agent": "dandanplay-web/0.1" },
|
||
});
|
||
if (!res.ok) throw new Error(`Bangumi HTTP ${res.status}`);
|
||
const data = (await res.json()) as {
|
||
list?: Array<{
|
||
id: number;
|
||
name: string;
|
||
nameCn?: string;
|
||
date?: string | null;
|
||
summary?: string;
|
||
images?: { medium?: string; large?: string };
|
||
}>;
|
||
};
|
||
return (data.list ?? []).slice(0, 12).map((item) => ({
|
||
id: item.id,
|
||
name: item.name,
|
||
nameCn: item.nameCn ?? item.name,
|
||
image: item.images?.large ?? item.images?.medium ?? "",
|
||
date: item.date ?? null,
|
||
summary: item.summary ?? "",
|
||
}));
|
||
}
|
||
|
||
async function fetchSubjectMeta(bangumiId: number): Promise<{
|
||
title: string | null;
|
||
posterUrl: string | null;
|
||
}> {
|
||
try {
|
||
const res = await fetch(`${apiBase()}/subject/${bangumiId}`, {
|
||
headers: { "User-Agent": "dandanplay-web/0.1" },
|
||
});
|
||
if (!res.ok) return { title: null, posterUrl: null };
|
||
const subject = (await res.json()) as {
|
||
name?: string;
|
||
name_cn?: string;
|
||
images?: { medium?: string; large?: string };
|
||
};
|
||
return {
|
||
title: subject.name_cn || subject.name || null,
|
||
posterUrl: subject.images?.large ?? subject.images?.medium ?? null,
|
||
};
|
||
} catch {
|
||
return { title: null, posterUrl: null };
|
||
}
|
||
}
|
||
|
||
export const scrapeService = {
|
||
search: bangumiSearch,
|
||
|
||
/**
|
||
* 弹弹play 文件识别(刮削 + 弹幕身份):hash match 优先;
|
||
* 未中且有 Bangumi 绑定时走 bgmtv 桥按集数兜底。写 dandanplayEpisodeId + 元数据。
|
||
*/
|
||
async matchDandanplay(userId: string, mediaItemId: string): Promise<DanmakuMatchOutput> {
|
||
const row = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" });
|
||
if (!hasOpenCredentials()) {
|
||
return {
|
||
ok: false,
|
||
matched: false,
|
||
message:
|
||
"未配置开放弹幕网络:服务端需设置 OPEN_DANMAKU_APP_ID / OPEN_DANMAKU_APP_SECRET",
|
||
candidates: [],
|
||
highConfidence: false,
|
||
};
|
||
}
|
||
|
||
const hash = await remoteFileHash(userId, mediaItemId);
|
||
const outcome = await openMatch(matchFileName(row.path), row.size, hash);
|
||
if (!outcome.ok) {
|
||
return {
|
||
ok: false,
|
||
matched: false,
|
||
message: outcome.errorMessage ?? "弹弹play 识别失败",
|
||
candidates: [],
|
||
highConfidence: false,
|
||
};
|
||
}
|
||
const ranked = rankCandidates(outcome.candidates);
|
||
const best = outcome.candidates[0];
|
||
if (best) {
|
||
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
||
dandanplayEpisodeId: best.episodeId,
|
||
matchedHash: hash,
|
||
scrapeStatus: "ok",
|
||
scrapedAt: new Date(),
|
||
posterUrl: best.imageUrl ?? row.posterUrl,
|
||
title: best.animeTitle ?? row.title,
|
||
epNumber: row.epNumber ?? parseEpisodeFromFilename(row.rawName),
|
||
});
|
||
return {
|
||
ok: true,
|
||
matched: true,
|
||
candidates: ranked.candidates,
|
||
highConfidence: ranked.highConfidence,
|
||
};
|
||
}
|
||
|
||
if (row.bangumiId != null) {
|
||
const bridged = await bridgeDandanEpisode(
|
||
row.bangumiId,
|
||
row.epNumber ?? parseEpisodeFromFilename(row.rawName),
|
||
);
|
||
if (bridged) {
|
||
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
||
dandanplayEpisodeId: bridged.episodeId,
|
||
matchedHash: hash,
|
||
scrapeStatus: row.scrapeStatus,
|
||
scrapedAt: new Date(),
|
||
});
|
||
return {
|
||
ok: true,
|
||
matched: true,
|
||
message: "已通过 Bangumi 绑定桥接到弹弹play 剧集",
|
||
candidates: [
|
||
{
|
||
episodeId: bridged.episodeId,
|
||
animeId: null,
|
||
animeTitle: row.title,
|
||
episodeTitle: bridged.episodeTitle,
|
||
imageUrl: row.posterUrl,
|
||
},
|
||
],
|
||
highConfidence: true,
|
||
};
|
||
}
|
||
}
|
||
return {
|
||
ok: true,
|
||
matched: false,
|
||
message: "弹弹play 未识别到该文件",
|
||
candidates: [],
|
||
highConfidence: false,
|
||
};
|
||
},
|
||
|
||
/** 手动绑定:指定作品(可覆盖集数),弹弹play 式「用户选定对应关系」 */
|
||
async bind(
|
||
userId: string,
|
||
mediaItemId: string,
|
||
bangumiId: number,
|
||
epNumber?: number | undefined,
|
||
): Promise<{ success: true }> {
|
||
const row = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||
if (!row) throw new Error("媒体不存在");
|
||
const meta = await fetchSubjectMeta(bangumiId);
|
||
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
||
bangumiId,
|
||
scrapeStatus: "ok",
|
||
scrapedAt: new Date(),
|
||
posterUrl: meta.posterUrl ?? row.posterUrl,
|
||
title: meta.title ?? row.title,
|
||
epNumber: epNumber ?? parseEpisodeFromFilename(row.rawName) ?? row.epNumber,
|
||
});
|
||
return { success: true };
|
||
},
|
||
|
||
/** 整夹指定作品:前缀下所有条目绑同一 bangumiId,集数从文件名解析 */
|
||
async bindPath(
|
||
userId: string,
|
||
mountId: string,
|
||
path: string,
|
||
bangumiId: number,
|
||
): Promise<BatchResult> {
|
||
const rows = await mediaItemDao.listByPathPrefix(userId, mountId, path);
|
||
if (rows.length === 0) {
|
||
return { total: 0, bound: 0, skipped: 0, message: "该路径下暂无已入库媒体" };
|
||
}
|
||
const meta = await fetchSubjectMeta(bangumiId);
|
||
let bound = 0;
|
||
for (const row of rows) {
|
||
await mediaItemDao.updateScrape(row.id, userId, {
|
||
bangumiId,
|
||
scrapeStatus: "ok",
|
||
scrapedAt: new Date(),
|
||
posterUrl: meta.posterUrl ?? row.posterUrl,
|
||
title: meta.title ?? row.title,
|
||
epNumber: parseEpisodeFromFilename(row.rawName) ?? row.epNumber,
|
||
});
|
||
bound += 1;
|
||
}
|
||
return {
|
||
total: rows.length,
|
||
bound,
|
||
skipped: 0,
|
||
message: `已将 ${bound} 个条目指定为该作品`,
|
||
};
|
||
},
|
||
|
||
/** 扫描后自动刮削:择优候选,低置信留 unmatched */
|
||
async autoScrape(userId: string, mediaItemId: string): Promise<void> {
|
||
const row = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||
if (!row || row.scrapeStatus === "ok") return;
|
||
const q = buildSearchQuery(row.rawName);
|
||
if (!q) {
|
||
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
||
scrapeStatus: "unmatched",
|
||
scrapedAt: new Date(),
|
||
});
|
||
return;
|
||
}
|
||
// 只包网络搜索:DB 写失败必须向外抛,避免被误标 failed 后吞掉
|
||
let hits: BangumiSearchHit[];
|
||
try {
|
||
hits = await bangumiSearch(q);
|
||
} catch {
|
||
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
||
scrapeStatus: "failed",
|
||
scrapedAt: new Date(),
|
||
});
|
||
return;
|
||
}
|
||
// 候选打分择优(弹弹play:返回候选列表择优,而非盲取第一条)
|
||
let best: BangumiSearchHit | null = null;
|
||
let bestScore = 0;
|
||
for (const hit of hits) {
|
||
const score = scoreHit(q, hit);
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
best = hit;
|
||
}
|
||
}
|
||
if (!best || bestScore < AUTO_BIND_THRESHOLD) {
|
||
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
||
scrapeStatus: "unmatched",
|
||
scrapedAt: new Date(),
|
||
});
|
||
return;
|
||
}
|
||
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
||
bangumiId: best.id,
|
||
scrapeStatus: "ok",
|
||
scrapedAt: new Date(),
|
||
posterUrl: best.image || null,
|
||
title: best.nameCn || best.name,
|
||
epNumber: parseEpisodeFromFilename(row.rawName),
|
||
});
|
||
},
|
||
|
||
/** 文件夹级批量重刮(仅处理未 ok 的条目) */
|
||
async rescrapePath(userId: string, mountId: string, path: string): Promise<BatchResult> {
|
||
const rows = await mediaItemDao.listByPathPrefix(userId, mountId, path);
|
||
let bound = 0;
|
||
let skipped = 0;
|
||
for (const row of rows) {
|
||
if (row.scrapeStatus === "ok" && row.bangumiId != null) {
|
||
skipped += 1;
|
||
continue;
|
||
}
|
||
await this.autoScrape(userId, row.id);
|
||
const after = await mediaItemDao.getByIdForUser(row.id, userId);
|
||
if (after?.scrapeStatus === "ok") bound += 1;
|
||
}
|
||
return {
|
||
total: rows.length,
|
||
bound,
|
||
skipped,
|
||
message: `批量刮削完成:成功 ${bound},跳过已绑定 ${skipped}`,
|
||
};
|
||
},
|
||
};
|