求片增加开关和冷却

This commit is contained in:
mtvpls
2026-01-12 21:06:45 +08:00
parent 3807f4cf60
commit 2c805e2522
8 changed files with 194 additions and 42 deletions
+100 -3
View File
@@ -8726,7 +8726,7 @@ const XiaoyaConfigComponent = ({
}; };
// 求片列表组件 // 求片列表组件
const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => void }) => { const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise<void> }) => {
const { alertModal, showAlert, hideAlert } = useAlertModal(); const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState(); const { isLoading, withLoading } = useLoadingState();
const [requests, setRequests] = useState<any[]>([]); const [requests, setRequests] = useState<any[]>([]);
@@ -8735,6 +8735,11 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
const [fulfilledCount, setFulfilledCount] = useState(0); const [fulfilledCount, setFulfilledCount] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// 求片功能设置
const [enableMovieRequest, setEnableMovieRequest] = useState(config?.SiteConfig?.EnableMovieRequest ?? true);
const [movieRequestCooldown, setMovieRequestCooldown] = useState(config?.SiteConfig?.MovieRequestCooldown ?? 3600);
const [savingSettings, setSavingSettings] = useState(false);
useEffect(() => { useEffect(() => {
loadRequests(); loadRequests();
loadCounts(); loadCounts();
@@ -8795,9 +8800,100 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
}); });
}; };
const handleSaveSettings = async () => {
setSavingSettings(true);
try {
if (!config) throw new Error('配置未加载');
const updatedConfig = {
...config,
SiteConfig: {
...config.SiteConfig,
EnableMovieRequest: enableMovieRequest,
MovieRequestCooldown: movieRequestCooldown,
},
};
const response = await fetch('/api/admin/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedConfig),
});
if (!response.ok) throw new Error('保存失败');
showSuccess('求片设置已保存', showAlert);
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '保存失败', showAlert);
} finally {
setSavingSettings(false);
}
};
return ( return (
<div className='space-y-4'> <div className='space-y-4'>
<div className='flex gap-2 mb-4'> {/* 求片功能设置 */}
<div className='p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100 mb-4'></h3>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div>
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</label>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
访
</p>
</div>
<label className='relative inline-flex items-center cursor-pointer'>
<input
type='checkbox'
checked={enableMovieRequest}
onChange={(e) => setEnableMovieRequest(e.target.checked)}
className='sr-only peer'
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<p className='text-xs text-gray-500 dark:text-gray-400 mb-2'>
36001
</p>
<input
type='number'
min='0'
value={movieRequestCooldown}
onChange={(e) => setMovieRequestCooldown(parseInt(e.target.value) || 0)}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
/>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
{movieRequestCooldown >= 3600
? `${Math.floor(movieRequestCooldown / 3600)} 小时 ${Math.floor((movieRequestCooldown % 3600) / 60)} 分钟`
: movieRequestCooldown >= 60
? `${Math.floor(movieRequestCooldown / 60)} 分钟`
: `${movieRequestCooldown}`}
</p>
</div>
<button
onClick={handleSaveSettings}
disabled={savingSettings}
className={buttonStyles.primary}
>
{savingSettings ? '保存中...' : '保存设置'}
</button>
</div>
</div>
{/* 求片列表 */}
<div className='p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100 mb-4'></h3>
<div className='flex gap-2 mb-4'>
<button <button
onClick={() => setFilter('pending')} onClick={() => setFilter('pending')}
className={`px-4 py-2 rounded-lg ${ className={`px-4 py-2 rounded-lg ${
@@ -8882,6 +8978,7 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
))} ))}
</div> </div>
)} )}
</div>
<AlertModal <AlertModal
isOpen={alertModal.isOpen} isOpen={alertModal.isOpen}
@@ -10419,7 +10516,7 @@ function AdminPageClient() {
isExpanded={expandedTabs.movieRequests} isExpanded={expandedTabs.movieRequests}
onToggle={() => toggleTab('movieRequests')} onToggle={() => toggleTab('movieRequests')}
> >
<MovieRequestsComponent /> <MovieRequestsComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab> </CollapsibleTab>
</div> </div>
</CollapsibleTab> </CollapsibleTab>
+20 -6
View File
@@ -71,6 +71,14 @@ export async function POST(request: NextRequest) {
} }
try { try {
// 检查求片功能是否启用并获取冷却时间
const { getConfig } = await import('@/lib/config');
const config = await getConfig();
if (config.SiteConfig.EnableMovieRequest === false) {
return NextResponse.json({ error: '求片功能已关闭' }, { status: 403 });
}
const body = await request.json(); const body = await request.json();
const { tmdbId, title, year, mediaType, season, poster, overview } = body; const { tmdbId, title, year, mediaType, season, poster, overview } = body;
@@ -80,10 +88,11 @@ export async function POST(request: NextRequest) {
const storage = getStorage(); const storage = getStorage();
// 检查频率限制 // 检查频率限制 - 使用配置中的冷却时间
const userInfo = await storage.getUserInfoV2(authInfo.username); const cooldownSeconds = config.SiteConfig.MovieRequestCooldown ?? 3600;
const rateLimit = parseInt(process.env.MOVIE_REQUEST_RATE_LIMIT || '3600') * 1000; const rateLimit = cooldownSeconds * 1000;
const userInfo = await storage.getUserInfoV2(authInfo.username);
if (userInfo?.last_movie_request_time) { if (userInfo?.last_movie_request_time) {
const elapsed = Date.now() - userInfo.last_movie_request_time; const elapsed = Date.now() - userInfo.last_movie_request_time;
if (elapsed < rateLimit) { if (elapsed < rateLimit) {
@@ -163,12 +172,17 @@ export async function POST(request: NextRequest) {
await storage.createMovieRequest(newRequest); await storage.createMovieRequest(newRequest);
await storage.addUserMovieRequest(authInfo.username, newRequest.id); await storage.addUserMovieRequest(authInfo.username, newRequest.id);
// 更新频率限制 // 更新频率限制 - 保存到用户信息的 hash 中
await storage.setGlobalValue( await storage.client.hSet(
`user:${authInfo.username}:info:last_movie_request_time`, `user:${authInfo.username}:info`,
'last_movie_request_time',
Date.now().toString() Date.now().toString()
); );
// 清除用户信息缓存,确保下次读取到最新数据
const { userInfoCache } = await import('@/lib/user-cache');
userInfoCache?.delete(authInfo.username);
// 给站长发送通知 // 给站长发送通知
const ownerUsername = process.env.USERNAME; const ownerUsername = process.env.USERNAME;
if (ownerUsername) { if (ownerUsername) {
+4 -1
View File
@@ -82,6 +82,7 @@ export default async function RootLayout({
let aiEnablePlayPageEntry = false; let aiEnablePlayPageEntry = false;
let aiDefaultMessageNoVideo = ''; let aiDefaultMessageNoVideo = '';
let aiDefaultMessageWithVideo = ''; let aiDefaultMessageWithVideo = '';
let enableMovieRequest = true;
let customCategories = [] as { let customCategories = [] as {
name: string; name: string;
type: 'movie' | 'tv'; type: 'movie' | 'tv';
@@ -124,6 +125,8 @@ export default async function RootLayout({
aiEnablePlayPageEntry = config.AIConfig?.EnablePlayPageEntry || false; aiEnablePlayPageEntry = config.AIConfig?.EnablePlayPageEntry || false;
aiDefaultMessageNoVideo = config.AIConfig?.DefaultMessageNoVideo || ''; aiDefaultMessageNoVideo = config.AIConfig?.DefaultMessageNoVideo || '';
aiDefaultMessageWithVideo = config.AIConfig?.DefaultMessageWithVideo || ''; aiDefaultMessageWithVideo = config.AIConfig?.DefaultMessageWithVideo || '';
// 求片功能配置
enableMovieRequest = config.SiteConfig.EnableMovieRequest ?? true;
// 检查是否启用了 OpenList 功能 // 检查是否启用了 OpenList 功能
openListEnabled = !!( openListEnabled = !!(
config.OpenListConfig?.Enabled && config.OpenListConfig?.Enabled &&
@@ -178,7 +181,7 @@ export default async function RootLayout({
AI_ENABLE_PLAYPAGE_ENTRY: aiEnablePlayPageEntry, AI_ENABLE_PLAYPAGE_ENTRY: aiEnablePlayPageEntry,
AI_DEFAULT_MESSAGE_NO_VIDEO: aiDefaultMessageNoVideo, AI_DEFAULT_MESSAGE_NO_VIDEO: aiDefaultMessageNoVideo,
AI_DEFAULT_MESSAGE_WITH_VIDEO: aiDefaultMessageWithVideo, AI_DEFAULT_MESSAGE_WITH_VIDEO: aiDefaultMessageWithVideo,
ENABLE_SOURCE_SEARCH: process.env.NEXT_PUBLIC_ENABLE_SOURCE_SEARCH !== 'false', ENABLE_MOVIE_REQUEST: enableMovieRequest,
}; };
return ( return (
+44 -24
View File
@@ -49,6 +49,15 @@ export default function MovieRequestPage() {
const [myRequests, setMyRequests] = useState<MovieRequest[]>([]); const [myRequests, setMyRequests] = useState<MovieRequest[]>([]);
const [loadingMyRequests, setLoadingMyRequests] = useState(true); const [loadingMyRequests, setLoadingMyRequests] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [isFeatureEnabled, setIsFeatureEnabled] = useState(true);
// 检查求片功能是否启用
useEffect(() => {
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig && runtimeConfig.ENABLE_MOVIE_REQUEST === false) {
setIsFeatureEnabled(false);
}
}, []);
// TMDB搜索 // TMDB搜索
const handleSearch = async () => { const handleSearch = async () => {
@@ -199,32 +208,43 @@ export default function MovieRequestPage() {
</h1> </h1>
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'> <p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
{isFeatureEnabled ? '搜索并提交您想看的影片' : '求片功能已关闭,仅可查看已求片列表'}
</p> </p>
</div> </div>
{/* 搜索框 */} {/* 功能关闭提示 */}
<div className='mb-6'> {!isFeatureEnabled && (
<div className='flex gap-2'> <div className='mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg'>
<input <p className='text-sm text-yellow-800 dark:text-yellow-200'>
type='text'
placeholder='搜索影片名称...' </p>
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSearch();
}}
className='flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500'
/>
<button
onClick={handleSearch}
disabled={!searchKeyword.trim() || isSearching}
className='px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed'
>
{isSearching ? '搜索中...' : '搜索'}
</button>
</div> </div>
</div> )}
{/* 搜索框 - 仅在功能启用时显示 */}
{isFeatureEnabled && (
<div className='mb-6'>
<div className='flex gap-2'>
<input
type='text'
placeholder='搜索影片名称...'
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSearch();
}}
className='flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500'
/>
<button
onClick={handleSearch}
disabled={!searchKeyword.trim() || isSearching}
className='px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed'
>
{isSearching ? '搜索中...' : '搜索'}
</button>
</div>
</div>
)}
{/* 我的求片列表 */} {/* 我的求片列表 */}
{searchResults.length === 0 && ( {searchResults.length === 0 && (
@@ -308,10 +328,10 @@ export default function MovieRequestPage() {
</p> </p>
<button <button
onClick={() => handleRequest(item)} onClick={() => handleRequest(item)}
disabled={submitting} disabled={submitting || !isFeatureEnabled}
className='w-full px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed' className='w-full px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
> >
{submitting ? '处理中...' : '求片'} {submitting ? '处理中...' : !isFeatureEnabled ? '功能已关闭' : '求片'}
</button> </button>
</div> </div>
</div> </div>
+9 -7
View File
@@ -457,13 +457,15 @@ export default function PrivateLibraryPage() {
</p> </p>
</div> </div>
<button {mounted && (
onClick={() => router.push('/movie-request')} <button
className='flex items-center gap-2 px-3 py-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors' onClick={() => router.push('/movie-request')}
> className='flex items-center gap-2 px-3 py-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors'
<Film size={20} /> >
<span></span> <Film size={20} />
</button> <span></span>
</button>
)}
</div> </div>
{/* 第一级:源类型选择(OpenList / Emby / 小雅) */} {/* 第一级:源类型选择(OpenList / Emby / 小雅) */}
+3
View File
@@ -40,6 +40,9 @@ export interface AdminConfig {
TurnstileSiteKey?: string; // Cloudflare Turnstile Site Key TurnstileSiteKey?: string; // Cloudflare Turnstile Site Key
TurnstileSecretKey?: string; // Cloudflare Turnstile Secret Key TurnstileSecretKey?: string; // Cloudflare Turnstile Secret Key
DefaultUserTags?: string[]; // 新注册用户的默认用户组 DefaultUserTags?: string[]; // 新注册用户的默认用户组
// 求片功能配置
EnableMovieRequest?: boolean; // 启用求片功能
MovieRequestCooldown?: number; // 求片冷却时间(秒),默认3600
// OIDC配置 // OIDC配置
EnableOIDCLogin?: boolean; // 启用OIDC登录 EnableOIDCLogin?: boolean; // 启用OIDC登录
EnableOIDCRegistration?: boolean; // 启用OIDC注册 EnableOIDCRegistration?: boolean; // 启用OIDC注册
+2
View File
@@ -659,6 +659,7 @@ export abstract class BaseRedisStorage implements IStorage {
playrecord_migrated?: boolean; playrecord_migrated?: boolean;
favorite_migrated?: boolean; favorite_migrated?: boolean;
skip_migrated?: boolean; skip_migrated?: boolean;
last_movie_request_time?: number;
} | null> { } | null> {
const userInfo = await this.withRetry(() => const userInfo = await this.withRetry(() =>
this.client.hGetAll(this.userInfoKey(userName)) this.client.hGetAll(this.userInfoKey(userName))
@@ -678,6 +679,7 @@ export abstract class BaseRedisStorage implements IStorage {
playrecord_migrated: userInfo.playrecord_migrated === 'true', playrecord_migrated: userInfo.playrecord_migrated === 'true',
favorite_migrated: userInfo.favorite_migrated === 'true', favorite_migrated: userInfo.favorite_migrated === 'true',
skip_migrated: userInfo.skip_migrated === 'true', skip_migrated: userInfo.skip_migrated === 'true',
last_movie_request_time: userInfo.last_movie_request_time ? parseInt(userInfo.last_movie_request_time, 10) : undefined,
}; };
} }
+12 -1
View File
@@ -65,7 +65,12 @@ export class UpstashRedisStorage implements IStorage {
this._client = getUpstashRedisClient(); this._client = getUpstashRedisClient();
// 创建兼容Redis API的client包装器(支持camelCase和lowercase // 创建兼容Redis API的client包装器(支持camelCase和lowercase
this.client = { this.client = {
hSet: (key: string, data: Record<string, any>) => this._client.hset(key, data), hSet: (key: string, field: string | Record<string, any>, value?: string) => {
if (typeof field === 'string' && value !== undefined) {
return this._client.hset(key, { [field]: value });
}
return this._client.hset(key, field as Record<string, any>);
},
hset: (key: string, data: Record<string, any>) => this._client.hset(key, data), hset: (key: string, data: Record<string, any>) => this._client.hset(key, data),
zAdd: (key: string, member: { score: number; value: string }) => this._client.zadd(key, { score: member.score, member: member.value }), zAdd: (key: string, member: { score: number; value: string }) => this._client.zadd(key, { score: member.score, member: member.value }),
zadd: (key: string, member: { score: number; value: string }) => this._client.zadd(key, { score: member.score, member: member.value }), zadd: (key: string, member: { score: number; value: string }) => this._client.zadd(key, { score: member.score, member: member.value }),
@@ -584,6 +589,7 @@ export class UpstashRedisStorage implements IStorage {
playrecord_migrated?: boolean; playrecord_migrated?: boolean;
favorite_migrated?: boolean; favorite_migrated?: boolean;
skip_migrated?: boolean; skip_migrated?: boolean;
last_movie_request_time?: number;
} | null> { } | null> {
// 先从缓存获取 // 先从缓存获取
const cached = userInfoCache?.get(userName); const cached = userInfoCache?.get(userName);
@@ -677,6 +683,11 @@ export class UpstashRedisStorage implements IStorage {
playrecord_migrated, playrecord_migrated,
favorite_migrated, favorite_migrated,
skip_migrated, skip_migrated,
last_movie_request_time: userInfo.last_movie_request_time
? (typeof userInfo.last_movie_request_time === 'number'
? userInfo.last_movie_request_time
: parseInt(userInfo.last_movie_request_time as string, 10))
: undefined,
}; };
// 存入缓存 // 存入缓存