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