feat(settings): auto-scrape toggle default off

Site setting auto_scrape; scan/ensurePath skip Bangumi when off; admin switch on /settings.
This commit is contained in:
noelorin 2026-09-27 18:33:13 +08:00
parent 2d7d67149b
commit e3dbaaeb31
9 changed files with 118 additions and 2 deletions

View File

@ -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 }) {
</div>
)}
</div>
<div className="space-y-3 border-t border-border pt-3">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{label("settings.autoScrape")}</p>
<span
className={cn(
"rounded px-1.5 py-0.5 text-[11px]",
autoScrapeOn
? "bg-[var(--color-primary)]/15 text-[var(--color-primary)]"
: "bg-[var(--color-muted)] text-[var(--color-muted-foreground)]",
)}
>
{autoScrapeOn
? label("settings.autoScrapeOn")
: label("settings.autoScrapeOff")}
</span>
</div>
<p className="text-xs text-muted-foreground">
{label("settings.autoScrapeHint")}
</p>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={autoScrapeOn}
onChange={(e) => setAutoScrapeOn(e.target.checked)}
className="h-4 w-4"
/>
{label("settings.autoScrapeOn")}
</label>
<Button
size="sm"
disabled={autoScrapeSave.isPending}
onClick={() =>
autoScrapeSave.mutate(
{ enabled: autoScrapeOn },
{
onSuccess: () => {
void autoScrapeQ.refetch();
toast.success(t(locale, "settings.autoScrapeSaved"));
},
onError: () => toast.error(t(locale, "settings.openSaveFailed")),
},
)
}
>
{label("settings.save")}
</Button>
</div>
</Card>
);
}

View File

@ -359,4 +359,9 @@ export const en: Record<MessageKey, string> = {
"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",
};

View File

@ -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;

View File

@ -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";

View File

@ -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";

View File

@ -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()),

View File

@ -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;
},
};

View File

@ -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<void> {
setSessionToken(null);
}
}
/** 自动刮削开关:未配置默认关闭 */
export async function getAutoScrapeEnabled(): Promise<boolean> {
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;

View File

@ -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;
};