feat(trpc): library scan + bangumi scrape
This commit is contained in:
parent
cdc40d61b1
commit
94d389b855
|
|
@ -31,3 +31,10 @@ export type {
|
||||||
// Media / WebDAV
|
// Media / WebDAV
|
||||||
export { mountService } from "./services/mount.service.js";
|
export { mountService } from "./services/mount.service.js";
|
||||||
export type { MountPublic } 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