diff --git a/packages/trpc/src/index.ts b/packages/trpc/src/index.ts index 83682eb..50acc58 100644 --- a/packages/trpc/src/index.ts +++ b/packages/trpc/src/index.ts @@ -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"; diff --git a/packages/trpc/src/services/library.service.ts b/packages/trpc/src/services/library.service.ts new file mode 100644 index 0000000..09a481a --- /dev/null +++ b/packages/trpc/src/services/library.service.ts @@ -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[], +): Promise { + 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 { + 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[] = []; + await walkVideos(userId, mountId, mount.rootPath || "/", "/", budget, jobs); + await Promise.allSettled(jobs); + return { scanned: MAX_SCAN_FILES - budget.left, message: "扫描完成" }; + }, +}; diff --git a/packages/trpc/src/services/scrape.service.test.ts b/packages/trpc/src/services/scrape.service.test.ts new file mode 100644 index 0000000..645ed01 --- /dev/null +++ b/packages/trpc/src/services/scrape.service.test.ts @@ -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"); + }); +}); diff --git a/packages/trpc/src/services/scrape.service.ts b/packages/trpc/src/services/scrape.service.ts new file mode 100644 index 0000000..fbc2676 --- /dev/null +++ b/packages/trpc/src/services/scrape.service.ts @@ -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 { + 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 { + 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(), + }); + } + }, +}; diff --git a/packages/trpc/vitest.config.ts b/packages/trpc/vitest.config.ts new file mode 100644 index 0000000..3afce0c --- /dev/null +++ b/packages/trpc/vitest.config.ts @@ -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:", + }, + }, +});