app-template/packages/trpc/src/services/scrape.service.ts

419 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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