feat(trpc): library scan + bangumi scrape
This commit is contained in:
parent
cdc40d61b1
commit
94d389b855
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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: "扫描完成" };
|
||||
},
|
||||
};
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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:",
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue