feat(trpc): library scan + bangumi scrape

This commit is contained in:
noelorin 2026-09-25 02:52:08 +08:00
parent cdc40d61b1
commit 94d389b855
5 changed files with 257 additions and 0 deletions

View File

@ -31,3 +31,10 @@ export type {
// Media / WebDAV
export { mountService } from "./services/mount.service.js";
export type { MountPublic } from "./services/mount.service.js";
export { libraryService } from "./services/library.service.js";
export {
scrapeService,
parseEpisodeFromFilename,
buildSearchQuery,
} from "./services/scrape.service.js";
export type { BangumiSearchHit } from "./services/scrape.service.js";

View File

@ -0,0 +1,86 @@
import { TRPCError } from "@trpc/server";
import { mediaItemDao, mountDao, type MediaItemRow } from "@app/dao";
import { decryptSecret } from "./secret.js";
import { createWebdav, isVideoFilename, joinWebdavPath, listDirectory } from "./webdav-client.js";
import { scrapeService } from "./scrape.service.js";
const MAX_SCAN_FILES = 2000;
async function walkVideos(
userId: string,
mountId: string,
root: string,
rel: string,
budget: { left: number },
out: Promise<void>[],
): Promise<void> {
if (budget.left <= 0) return;
const mount = await mountDao.getByIdForUser(mountId, userId);
if (!mount) return;
const client = createWebdav({
baseUrl: mount.baseUrl,
username: mount.username ?? undefined,
password: mount.secretEnc ? decryptSecret(mount.secretEnc) : undefined,
});
const abs = joinWebdavPath(root, rel);
const entries = await listDirectory(client, abs).catch(() => null);
if (!entries) return;
for (const entry of entries) {
if (budget.left <= 0) return;
const childRel =
rel === "/" || rel === ""
? `/${entry.basename}`
: `${rel.replace(/\/$/, "")}/${entry.basename}`;
if (entry.type === "directory") {
await walkVideos(userId, mountId, root, childRel, budget, out);
continue;
}
if (!isVideoFilename(entry.basename)) continue;
budget.left -= 1;
const title = entry.basename.replace(/\.[a-z0-9]+$/i, "");
out.push(
(async () => {
const item = await mediaItemDao.upsertFromScan({
userId,
mountId,
path: childRel,
rawName: entry.basename,
title,
size: entry.size,
});
await scrapeService.autoScrape(userId, item.id);
})(),
);
}
}
export const libraryService = {
async list(
userId: string,
params: {
limit: number;
offset: number;
scrapeStatus?: string | undefined;
q?: string | undefined;
},
) {
return mediaItemDao.list(userId, params);
},
async get(userId: string, id: string): Promise<MediaItemRow> {
const row = await mediaItemDao.getByIdForUser(id, userId);
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" });
return row;
},
async scan(userId: string, mountId: string): Promise<{ scanned: number; message: string }> {
const mount = await mountDao.getByIdForUser(mountId, userId);
if (!mount) throw new TRPCError({ code: "NOT_FOUND", message: "挂载不存在" });
if (!mount.enabled) throw new TRPCError({ code: "BAD_REQUEST", message: "挂载已禁用" });
const budget = { left: MAX_SCAN_FILES };
const jobs: Promise<void>[] = [];
await walkVideos(userId, mountId, mount.rootPath || "/", "/", budget, jobs);
await Promise.allSettled(jobs);
return { scanned: MAX_SCAN_FILES - budget.left, message: "扫描完成" };
},
};

View File

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { buildSearchQuery, parseEpisodeFromFilename } from "./scrape.service.js";
describe("parseEpisodeFromFilename", () => {
it("S01E02", () => {
expect(parseEpisodeFromFilename("Show.S01E02.1080p.mkv")).toBe(2);
});
it("[Group] Title - 05 [1080p].mkv", () => {
expect(parseEpisodeFromFilename("[Group] Title - 05 [1080p].mkv")).toBe(5);
});
it("无集数", () => {
expect(parseEpisodeFromFilename("Movie.mkv")).toBeNull();
});
});
describe("buildSearchQuery", () => {
it("去扩展名与字幕组前缀", () => {
expect(buildSearchQuery("[Sub] Bocchi the Rock! - 01.mkv")).toContain("Bocchi");
expect(buildSearchQuery("[Sub] Bocchi the Rock! - 01.mkv")).not.toContain(".mkv");
});
});

View File

@ -0,0 +1,132 @@
import { mediaItemDao } from "@app/dao";
export function parseEpisodeFromFilename(name: string): number | null {
const s = name.replace(/\.[a-z0-9]+$/i, "");
const m =
s.match(/S\d{1,2}\s*E(\d{1,3})/i) ||
s.match(/(?:^|[\s\-_])EP?(\d{1,3})(?:[\s\-_]|$)/i) ||
s.match(/\s-\s(\d{2,3})(?:\s|$)/);
if (!m?.[1]) return null;
const n = Number.parseInt(m[1], 10);
return Number.isFinite(n) ? n : null;
}
export function buildSearchQuery(filename: string): string {
let s = filename.replace(/\.[a-z0-9]+$/i, "");
s = s.replace(/^\[[^\]]*\]\s*/, "");
s = s.replace(/\s*-\s*\d{2,3}.*$/, "");
s = s.replace(/\b(1080p|720p|2160p|4k|x264|x265|hevc|aac|flac)\b/gi, " ");
return s.replace(/\s+/g, " ").trim();
}
export type BangumiSearchHit = {
id: number;
name: string;
nameCn: string;
image: string;
date: string | null;
summary: string;
};
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?.medium ?? item.images?.large ?? "",
date: item.date ?? null,
summary: item.summary ?? "",
}));
}
export const scrapeService = {
search: bangumiSearch,
async bind(userId: string, mediaItemId: string, bangumiId: number): Promise<{ success: true }> {
const row = await mediaItemDao.getByIdForUser(mediaItemId, userId);
if (!row) throw new Error("媒体不存在");
let title = row.title;
let posterUrl: string | null = row.posterUrl;
try {
const url = `${apiBase()}/subject/${bangumiId}`;
const res = await fetch(url, { headers: { "User-Agent": "dandanplay-web/0.1" } });
if (res.ok) {
const subject = (await res.json()) as {
name?: string;
name_cn?: string;
images?: { medium?: string; large?: string };
};
title = subject.name_cn || subject.name || title;
posterUrl = subject.images?.medium ?? subject.images?.large ?? posterUrl;
}
} catch {
// 绑定 id 仍成功,海报可后补
}
await mediaItemDao.updateScrape(mediaItemId, userId, {
bangumiId,
scrapeStatus: "ok",
scrapedAt: new Date(),
posterUrl,
title,
epNumber: row.epNumber,
});
return { success: true };
},
/** 扫描后自动刮削:命中绑第一条,否则 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;
}
try {
const hits = await bangumiSearch(q);
const hit = hits[0];
if (!hit) {
await mediaItemDao.updateScrape(mediaItemId, userId, {
scrapeStatus: "unmatched",
scrapedAt: new Date(),
});
return;
}
await mediaItemDao.updateScrape(mediaItemId, userId, {
bangumiId: hit.id,
scrapeStatus: "ok",
scrapedAt: new Date(),
posterUrl: hit.image || null,
title: hit.nameCn || hit.name,
epNumber: parseEpisodeFromFilename(row.rawName),
});
} catch {
await mediaItemDao.updateScrape(mediaItemId, userId, {
scrapeStatus: "failed",
scrapedAt: new Date(),
});
}
},
};

View File

@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
// 纯函数单测不需要真实数据库,但 import @app/dao 会连带初始化 libsql client。
// 用内存 sqlite 占位,避免要求调用方先配置 TURSO_DATABASE_URL。
export default defineConfig({
test: {
env: {
TURSO_DATABASE_URL: "file::memory:",
},
},
});