diff --git a/app/web/src/components/pages/SettingsAdminBody.tsx b/app/web/src/components/pages/SettingsAdminBody.tsx
index bed3513..62d010a 100644
--- a/app/web/src/components/pages/SettingsAdminBody.tsx
+++ b/app/web/src/components/pages/SettingsAdminBody.tsx
@@ -4,6 +4,7 @@ import { Button, Card, Input, toast } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
+import { cn } from "~/lib/utils";
function Inner(props: { locale: Locale }) {
const { locale } = props;
@@ -11,6 +12,8 @@ function Inner(props: { locale: Locale }) {
const isAdmin = me.data?.user?.role === "admin";
const openNet = trpc.settings.getOpenNetwork.useQuery(undefined, { enabled: isAdmin });
const saveOpenNet = trpc.settings.saveOpenNetwork.useMutation();
+ const autoScrapeQ = trpc.settings.getAutoScrape.useQuery(undefined, { enabled: isAdmin });
+ const autoScrapeSave = trpc.settings.saveAutoScrape.useMutation();
const ddpStatus = trpc.settings.dandanplayAuthStatus.useQuery(undefined, {
enabled: Boolean(me.data?.user),
});
@@ -18,12 +21,18 @@ function Inner(props: { locale: Locale }) {
const ddpLogout = trpc.settings.logoutDandanplay.useMutation();
const [ddpUser, setDdpUser] = useState("");
const [ddpPass, setDdpPass] = useState("");
+ const [autoScrapeOn, setAutoScrapeOn] = useState(false);
+ const [autoFilled, setAutoFilled] = useState(false);
const [appId, setAppId] = useState("");
const [appSecret, setAppSecret] = useState("");
const [filled, setFilled] = useState(false);
useEffect(() => {
+ if (autoScrapeQ.data !== undefined && !autoFilled) {
+ setAutoScrapeOn(autoScrapeQ.data);
+ setAutoFilled(true);
+ }
if (!openNet.data || filled) return;
setAppId(openNet.data.appId);
setAppSecret(openNet.data.appSecret);
@@ -188,6 +197,54 @@ function Inner(props: { locale: Locale }) {
)}
+
+
+
+
{label("settings.autoScrape")}
+
+ {autoScrapeOn
+ ? label("settings.autoScrapeOn")
+ : label("settings.autoScrapeOff")}
+
+
+
+ {label("settings.autoScrapeHint")}
+
+
+
+
);
}
diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts
index 5cb5447..1eb3deb 100644
--- a/packages/i18n/src/messages/en.ts
+++ b/packages/i18n/src/messages/en.ts
@@ -359,4 +359,9 @@ export const en: Record = {
"watch.clearLocalOk": "Local danmaku cleared",
"settings.nonAdminHint": "Admin configures the open network; any signed-in user can bind the shared DanDanPlay session here.",
"settings.sharedAccountHint": "The DanDanPlay account is a site-wide session used to fetch online danmaku.",
+ "settings.autoScrape": "Auto scrape",
+ "settings.autoScrapeHint": "Automatically match Bangumi metadata on scan/import. Off by default to avoid external API calls.",
+ "settings.autoScrapeOn": "Auto scrape enabled",
+ "settings.autoScrapeOff": "Off (default)",
+ "settings.autoScrapeSaved": "Saved",
};
diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts
index 8b11664..1eda0a2 100644
--- a/packages/i18n/src/messages/zh-CN.ts
+++ b/packages/i18n/src/messages/zh-CN.ts
@@ -349,6 +349,11 @@ export const zhCN = {
"watch.clearLocalOk": "已清除本地弹幕",
"settings.nonAdminHint": "在线弹幕需管理员配置开放网络;普通账号可在此登录弹弹play 会话。",
"settings.sharedAccountHint": "弹弹play 账号为站点共用会话,用于拉取在线弹幕。",
+ "settings.autoScrape": "自动刮削",
+ "settings.autoScrapeHint": "扫描/入库时自动匹配 Bangumi 元数据。默认关闭,避免自动请求外部接口。",
+ "settings.autoScrapeOn": "开启自动刮削",
+ "settings.autoScrapeOff": "已关闭(默认)",
+ "settings.autoScrapeSaved": "已保存",
} as const;
export type MessageKey = keyof typeof zhCN;
diff --git a/packages/models/src/app-settings.ts b/packages/models/src/app-settings.ts
index 0ec85f2..9557940 100644
--- a/packages/models/src/app-settings.ts
+++ b/packages/models/src/app-settings.ts
@@ -36,3 +36,5 @@ export const APP_SETTING_OPEN_DANMAKU_APP_SECRET = "open_danmaku_app_secret";
export const APP_SETTING_DANDANPLAY_TOKEN = "dandanplay_token";
export const APP_SETTING_DANDANPLAY_SCREEN_NAME = "dandanplay_screen_name";
+
+export const APP_SETTING_AUTO_SCRAPE = "auto_scrape";
diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts
index 9a2118a..97297d8 100644
--- a/packages/models/src/index.ts
+++ b/packages/models/src/index.ts
@@ -23,5 +23,6 @@ export {
APP_SETTING_OPEN_DANMAKU_APP_SECRET,
APP_SETTING_DANDANPLAY_TOKEN,
APP_SETTING_DANDANPLAY_SCREEN_NAME,
+ APP_SETTING_AUTO_SCRAPE,
} from "./app-settings.js";
export type { AppSettingRow, NewAppSettingRow } from "./app-settings.js";
diff --git a/packages/trpc/src/router/settings.router.ts b/packages/trpc/src/router/settings.router.ts
index a20e4a6..8065c01 100644
--- a/packages/trpc/src/router/settings.router.ts
+++ b/packages/trpc/src/router/settings.router.ts
@@ -19,6 +19,14 @@ export const settingsRouter = t.router({
logoutDandanplay: protectedProcedure
.input(settingsSchemas.logoutDandanplay)
.mutation(({ ctx }) => settingsService.logoutDandanplay(ctx.userId)),
+ getAutoScrape: adminProcedure
+ .input(settingsSchemas.getAutoScrape)
+ .query(() => settingsService.getAutoScrapeEnabled()),
+ saveAutoScrape: adminProcedure
+ .input(settingsSchemas.saveAutoScrape)
+ .mutation(({ ctx, input }) =>
+ settingsService.saveAutoScrape(ctx.userId, input.enabled),
+ ),
dandanplayAuthStatus: protectedProcedure
.input(settingsSchemas.logoutDandanplay)
.query(() => settingsService.getDandanplayAuthStatus()),
diff --git a/packages/trpc/src/services/library.service.ts b/packages/trpc/src/services/library.service.ts
index 0ee139a..83eec2d 100644
--- a/packages/trpc/src/services/library.service.ts
+++ b/packages/trpc/src/services/library.service.ts
@@ -4,6 +4,7 @@ import { parseEpisodeFromFilename, type LibraryGroupPage } from "@app/types";
import { decryptSecret } from "./secret.js";
import { createWebdav, isVideoFilename, joinWebdavPath, listDirectory } from "./webdav-client.js";
import { scrapeService } from "./scrape.service.js";
+import { getAutoScrapeEnabled } from "./settings.service.js";
const MAX_SCAN_FILES = 2000;
/** 同时执行的 upsert+刮削 job 上限,避免最坏 2000 并发打 Bangumi 被限流 */
@@ -53,7 +54,9 @@ async function walkVideos(
title,
size: entry.size,
});
- await scrapeService.autoScrape(userId, item.id);
+ if (await getAutoScrapeEnabled()) {
+ await scrapeService.autoScrape(userId, item.id);
+ }
});
}
}
@@ -251,7 +254,9 @@ export const libraryService = {
title: basename.replace(/\.[a-z0-9]+$/i, ""),
size,
});
- await scrapeService.autoScrape(userId, item.id);
+ if (await getAutoScrapeEnabled()) {
+ await scrapeService.autoScrape(userId, item.id);
+ }
return item;
},
};
diff --git a/packages/trpc/src/services/settings.service.ts b/packages/trpc/src/services/settings.service.ts
index e6bdad0..4a7e15a 100644
--- a/packages/trpc/src/services/settings.service.ts
+++ b/packages/trpc/src/services/settings.service.ts
@@ -1,4 +1,5 @@
import {
+ APP_SETTING_AUTO_SCRAPE,
APP_SETTING_DANDANPLAY_SCREEN_NAME,
APP_SETTING_DANDANPLAY_TOKEN,
APP_SETTING_OPEN_DANMAKU_APP_ID,
@@ -97,6 +98,8 @@ export const settingsService = {
logoutDandanplay,
getDandanplayAuthStatus,
applyRuntimeSession,
+ getAutoScrapeEnabled,
+ saveAutoScrape,
};
const APP_KEY_DDP_TOKEN = "dandanplay_token";
@@ -165,3 +168,27 @@ export async function applyRuntimeSession(): Promise {
setSessionToken(null);
}
}
+
+/** 自动刮削开关:未配置默认关闭 */
+export async function getAutoScrapeEnabled(): Promise {
+ try {
+ const row = await appSettingsDao.get(APP_SETTING_AUTO_SCRAPE);
+ return row?.value === "1";
+ } catch {
+ return false;
+ }
+}
+
+export async function saveAutoScrape(
+ userId: string,
+ enabled: boolean,
+): Promise<{ enabled: boolean }> {
+ await appSettingsDao.upsert({
+ key: APP_SETTING_AUTO_SCRAPE,
+ value: enabled ? "1" : "0",
+ updatedBy: userId,
+ });
+ return { enabled };
+}
+
+export const isAutoScrapeEnabled = getAutoScrapeEnabled;
diff --git a/packages/types/src/schemas.ts b/packages/types/src/schemas.ts
index c94dc64..002efef 100644
--- a/packages/types/src/schemas.ts
+++ b/packages/types/src/schemas.ts
@@ -153,6 +153,8 @@ export const settingsSchemas = {
password: z.string().min(1, "请输入密码").max(128),
}),
logoutDandanplay: z.object({}).optional(),
+ getAutoScrape: z.object({}).optional(),
+ saveAutoScrape: z.object({ enabled: z.boolean() }),
};
export type DandanplayLoginOutput = {
@@ -175,3 +177,7 @@ export type SaveOpenNetworkSettingsInput = {
appId: string;
appSecret: string;
};
+
+export type AutoScrapeSettingsOutput = {
+ enabled: boolean;
+};