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:
parent
2d7d67149b
commit
e3dbaaeb31
|
|
@ -4,6 +4,7 @@ import { Button, Card, Input, toast } from "@app/ui";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
function Inner(props: { locale: Locale }) {
|
function Inner(props: { locale: Locale }) {
|
||||||
const { locale } = props;
|
const { locale } = props;
|
||||||
|
|
@ -11,6 +12,8 @@ function Inner(props: { locale: Locale }) {
|
||||||
const isAdmin = me.data?.user?.role === "admin";
|
const isAdmin = me.data?.user?.role === "admin";
|
||||||
const openNet = trpc.settings.getOpenNetwork.useQuery(undefined, { enabled: isAdmin });
|
const openNet = trpc.settings.getOpenNetwork.useQuery(undefined, { enabled: isAdmin });
|
||||||
const saveOpenNet = trpc.settings.saveOpenNetwork.useMutation();
|
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, {
|
const ddpStatus = trpc.settings.dandanplayAuthStatus.useQuery(undefined, {
|
||||||
enabled: Boolean(me.data?.user),
|
enabled: Boolean(me.data?.user),
|
||||||
});
|
});
|
||||||
|
|
@ -18,12 +21,18 @@ function Inner(props: { locale: Locale }) {
|
||||||
const ddpLogout = trpc.settings.logoutDandanplay.useMutation();
|
const ddpLogout = trpc.settings.logoutDandanplay.useMutation();
|
||||||
const [ddpUser, setDdpUser] = useState("");
|
const [ddpUser, setDdpUser] = useState("");
|
||||||
const [ddpPass, setDdpPass] = useState("");
|
const [ddpPass, setDdpPass] = useState("");
|
||||||
|
const [autoScrapeOn, setAutoScrapeOn] = useState(false);
|
||||||
|
const [autoFilled, setAutoFilled] = useState(false);
|
||||||
|
|
||||||
const [appId, setAppId] = useState("");
|
const [appId, setAppId] = useState("");
|
||||||
const [appSecret, setAppSecret] = useState("");
|
const [appSecret, setAppSecret] = useState("");
|
||||||
const [filled, setFilled] = useState(false);
|
const [filled, setFilled] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (autoScrapeQ.data !== undefined && !autoFilled) {
|
||||||
|
setAutoScrapeOn(autoScrapeQ.data);
|
||||||
|
setAutoFilled(true);
|
||||||
|
}
|
||||||
if (!openNet.data || filled) return;
|
if (!openNet.data || filled) return;
|
||||||
setAppId(openNet.data.appId);
|
setAppId(openNet.data.appId);
|
||||||
setAppSecret(openNet.data.appSecret);
|
setAppSecret(openNet.data.appSecret);
|
||||||
|
|
@ -188,6 +197,54 @@ function Inner(props: { locale: Locale }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -359,4 +359,9 @@ export const en: Record<MessageKey, string> = {
|
||||||
"watch.clearLocalOk": "Local danmaku cleared",
|
"watch.clearLocalOk": "Local danmaku cleared",
|
||||||
"settings.nonAdminHint": "Admin configures the open network; any signed-in user can bind the shared DanDanPlay session here.",
|
"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.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",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,11 @@ export const zhCN = {
|
||||||
"watch.clearLocalOk": "已清除本地弹幕",
|
"watch.clearLocalOk": "已清除本地弹幕",
|
||||||
"settings.nonAdminHint": "在线弹幕需管理员配置开放网络;普通账号可在此登录弹弹play 会话。",
|
"settings.nonAdminHint": "在线弹幕需管理员配置开放网络;普通账号可在此登录弹弹play 会话。",
|
||||||
"settings.sharedAccountHint": "弹弹play 账号为站点共用会话,用于拉取在线弹幕。",
|
"settings.sharedAccountHint": "弹弹play 账号为站点共用会话,用于拉取在线弹幕。",
|
||||||
|
"settings.autoScrape": "自动刮削",
|
||||||
|
"settings.autoScrapeHint": "扫描/入库时自动匹配 Bangumi 元数据。默认关闭,避免自动请求外部接口。",
|
||||||
|
"settings.autoScrapeOn": "开启自动刮削",
|
||||||
|
"settings.autoScrapeOff": "已关闭(默认)",
|
||||||
|
"settings.autoScrapeSaved": "已保存",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type MessageKey = keyof typeof zhCN;
|
export type MessageKey = keyof typeof zhCN;
|
||||||
|
|
|
||||||
|
|
@ -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_TOKEN = "dandanplay_token";
|
||||||
export const APP_SETTING_DANDANPLAY_SCREEN_NAME = "dandanplay_screen_name";
|
export const APP_SETTING_DANDANPLAY_SCREEN_NAME = "dandanplay_screen_name";
|
||||||
|
|
||||||
|
export const APP_SETTING_AUTO_SCRAPE = "auto_scrape";
|
||||||
|
|
|
||||||
|
|
@ -23,5 +23,6 @@ export {
|
||||||
APP_SETTING_OPEN_DANMAKU_APP_SECRET,
|
APP_SETTING_OPEN_DANMAKU_APP_SECRET,
|
||||||
APP_SETTING_DANDANPLAY_TOKEN,
|
APP_SETTING_DANDANPLAY_TOKEN,
|
||||||
APP_SETTING_DANDANPLAY_SCREEN_NAME,
|
APP_SETTING_DANDANPLAY_SCREEN_NAME,
|
||||||
|
APP_SETTING_AUTO_SCRAPE,
|
||||||
} from "./app-settings.js";
|
} from "./app-settings.js";
|
||||||
export type { AppSettingRow, NewAppSettingRow } from "./app-settings.js";
|
export type { AppSettingRow, NewAppSettingRow } from "./app-settings.js";
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,14 @@ export const settingsRouter = t.router({
|
||||||
logoutDandanplay: protectedProcedure
|
logoutDandanplay: protectedProcedure
|
||||||
.input(settingsSchemas.logoutDandanplay)
|
.input(settingsSchemas.logoutDandanplay)
|
||||||
.mutation(({ ctx }) => settingsService.logoutDandanplay(ctx.userId)),
|
.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
|
dandanplayAuthStatus: protectedProcedure
|
||||||
.input(settingsSchemas.logoutDandanplay)
|
.input(settingsSchemas.logoutDandanplay)
|
||||||
.query(() => settingsService.getDandanplayAuthStatus()),
|
.query(() => settingsService.getDandanplayAuthStatus()),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { parseEpisodeFromFilename, type LibraryGroupPage } from "@app/types";
|
||||||
import { decryptSecret } from "./secret.js";
|
import { decryptSecret } from "./secret.js";
|
||||||
import { createWebdav, isVideoFilename, joinWebdavPath, listDirectory } from "./webdav-client.js";
|
import { createWebdav, isVideoFilename, joinWebdavPath, listDirectory } from "./webdav-client.js";
|
||||||
import { scrapeService } from "./scrape.service.js";
|
import { scrapeService } from "./scrape.service.js";
|
||||||
|
import { getAutoScrapeEnabled } from "./settings.service.js";
|
||||||
|
|
||||||
const MAX_SCAN_FILES = 2000;
|
const MAX_SCAN_FILES = 2000;
|
||||||
/** 同时执行的 upsert+刮削 job 上限,避免最坏 2000 并发打 Bangumi 被限流 */
|
/** 同时执行的 upsert+刮削 job 上限,避免最坏 2000 并发打 Bangumi 被限流 */
|
||||||
|
|
@ -53,7 +54,9 @@ async function walkVideos(
|
||||||
title,
|
title,
|
||||||
size: entry.size,
|
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, ""),
|
title: basename.replace(/\.[a-z0-9]+$/i, ""),
|
||||||
size,
|
size,
|
||||||
});
|
});
|
||||||
await scrapeService.autoScrape(userId, item.id);
|
if (await getAutoScrapeEnabled()) {
|
||||||
|
await scrapeService.autoScrape(userId, item.id);
|
||||||
|
}
|
||||||
return item;
|
return item;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
APP_SETTING_AUTO_SCRAPE,
|
||||||
APP_SETTING_DANDANPLAY_SCREEN_NAME,
|
APP_SETTING_DANDANPLAY_SCREEN_NAME,
|
||||||
APP_SETTING_DANDANPLAY_TOKEN,
|
APP_SETTING_DANDANPLAY_TOKEN,
|
||||||
APP_SETTING_OPEN_DANMAKU_APP_ID,
|
APP_SETTING_OPEN_DANMAKU_APP_ID,
|
||||||
|
|
@ -97,6 +98,8 @@ export const settingsService = {
|
||||||
logoutDandanplay,
|
logoutDandanplay,
|
||||||
getDandanplayAuthStatus,
|
getDandanplayAuthStatus,
|
||||||
applyRuntimeSession,
|
applyRuntimeSession,
|
||||||
|
getAutoScrapeEnabled,
|
||||||
|
saveAutoScrape,
|
||||||
};
|
};
|
||||||
|
|
||||||
const APP_KEY_DDP_TOKEN = "dandanplay_token";
|
const APP_KEY_DDP_TOKEN = "dandanplay_token";
|
||||||
|
|
@ -165,3 +168,27 @@ export async function applyRuntimeSession(): Promise<void> {
|
||||||
setSessionToken(null);
|
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;
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,8 @@ export const settingsSchemas = {
|
||||||
password: z.string().min(1, "请输入密码").max(128),
|
password: z.string().min(1, "请输入密码").max(128),
|
||||||
}),
|
}),
|
||||||
logoutDandanplay: z.object({}).optional(),
|
logoutDandanplay: z.object({}).optional(),
|
||||||
|
getAutoScrape: z.object({}).optional(),
|
||||||
|
saveAutoScrape: z.object({ enabled: z.boolean() }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DandanplayLoginOutput = {
|
export type DandanplayLoginOutput = {
|
||||||
|
|
@ -175,3 +177,7 @@ export type SaveOpenNetworkSettingsInput = {
|
||||||
appId: string;
|
appId: string;
|
||||||
appSecret: string;
|
appSecret: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AutoScrapeSettingsOutput = {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue