diff --git a/src/app/api/danmaku-filter/route.ts b/src/app/api/danmaku-filter/route.ts new file mode 100644 index 0000000..b973451 --- /dev/null +++ b/src/app/api/danmaku-filter/route.ts @@ -0,0 +1,101 @@ +/* eslint-disable no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; +import { db } from '@/lib/db'; +import { DanmakuFilterConfig } from '@/lib/types'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未登录' }, { status: 401 }); + } + + const config = await getConfig(); + if (authInfo.username !== process.env.ADMIN_USERNAME) { + // 非站长,检查用户存在或被封禁 + const user = config.UserConfig.Users.find( + (u) => u.username === authInfo.username + ); + if (!user) { + return NextResponse.json({ error: '用户不存在' }, { status: 401 }); + } + if (user.banned) { + return NextResponse.json({ error: '用户已被封禁' }, { status: 401 }); + } + } + + // 获取弹幕过滤配置 + const filterConfig = await db.getDanmakuFilterConfig(authInfo.username); + + // 如果没有配置,返回默认值 + if (!filterConfig) { + return NextResponse.json({ rules: [] }); + } + + return NextResponse.json(filterConfig); + } catch (error) { + console.error('获取弹幕过滤配置失败:', error); + return NextResponse.json( + { error: '获取弹幕过滤配置失败' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未登录' }, { status: 401 }); + } + + const adminConfig = await getConfig(); + if (authInfo.username !== process.env.ADMIN_USERNAME) { + // 非站长,检查用户存在或被封禁 + const user = adminConfig.UserConfig.Users.find( + (u) => u.username === authInfo.username + ); + if (!user) { + return NextResponse.json({ error: '用户不存在' }, { status: 401 }); + } + if (user.banned) { + return NextResponse.json({ error: '用户已被封禁' }, { status: 401 }); + } + } + + const body = await request.json(); + const config: DanmakuFilterConfig = body; + + if (!config || !Array.isArray(config.rules)) { + return NextResponse.json({ error: '配置格式错误' }, { status: 400 }); + } + + // 验证每个规则的格式 + const validatedRules = config.rules.map((rule) => ({ + keyword: String(rule.keyword || ''), + type: (rule.type === 'regex' || rule.type === 'normal') ? rule.type : 'normal', + enabled: Boolean(rule.enabled), + id: rule.id || undefined, + })); + + const validatedConfig: DanmakuFilterConfig = { + rules: validatedRules, + }; + + await db.setDanmakuFilterConfig(authInfo.username, validatedConfig); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('保存弹幕过滤配置失败:', error); + return NextResponse.json( + { error: '保存弹幕过滤配置失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index d323aa7..b4e141f 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -19,6 +19,7 @@ import { savePlayRecord, saveSkipConfig, subscribeToDataUpdates, + getDanmakuFilterConfig, } from '@/lib/db.client'; import { convertDanmakuFormat, @@ -31,12 +32,13 @@ import { searchAnime, } from '@/lib/danmaku/api'; import type { DanmakuAnime, DanmakuSelection, DanmakuSettings } from '@/lib/danmaku/types'; -import { SearchResult } from '@/lib/types'; +import { SearchResult, DanmakuFilterConfig } from '@/lib/types'; import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; import EpisodeSelector from '@/components/EpisodeSelector'; import PageLayout from '@/components/PageLayout'; import DoubanComments from '@/components/DoubanComments'; +import DanmakuFilterSettings from '@/components/DanmakuFilterSettings'; import { useEnableComments } from '@/hooks/useEnableComments'; // 扩展 HTMLVideoElement 类型以支持 hls 属性 @@ -168,6 +170,8 @@ function PlayPageClient() { const [danmakuSettings, setDanmakuSettings] = useState( loadDanmakuSettings() ); + const [danmakuFilterConfig, setDanmakuFilterConfig] = useState(null); + const danmakuFilterConfigRef = useRef(null); const [currentDanmakuSelection, setCurrentDanmakuSelection] = useState(null); const [danmakuEpisodesList, setDanmakuEpisodesList] = useState< @@ -181,11 +185,38 @@ function PlayPageClient() { // 多条弹幕匹配结果 const [danmakuMatches, setDanmakuMatches] = useState([]); const [showDanmakuSourceSelector, setShowDanmakuSourceSelector] = useState(false); + const [showDanmakuFilterSettings, setShowDanmakuFilterSettings] = useState(false); useEffect(() => { danmakuSettingsRef.current = danmakuSettings; }, [danmakuSettings]); + // 加载弹幕过滤配置 + useEffect(() => { + const loadFilterConfig = async () => { + try { + const config = await getDanmakuFilterConfig(); + if (config) { + setDanmakuFilterConfig(config); + danmakuFilterConfigRef.current = config; + } else { + // 如果没有配置,设置默认空配置 + const defaultConfig: DanmakuFilterConfig = { rules: [] }; + setDanmakuFilterConfig(defaultConfig); + danmakuFilterConfigRef.current = defaultConfig; + } + } catch (error) { + console.error('加载弹幕过滤配置失败:', error); + } + }; + loadFilterConfig(); + }, []); + + // 同步弹幕过滤配置到ref + useEffect(() => { + danmakuFilterConfigRef.current = danmakuFilterConfig; + }, [danmakuFilterConfig]); + // 视频基本信息 const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || ''); const [videoYear, setVideoYear] = useState(searchParams.get('year') || ''); @@ -2098,11 +2129,23 @@ function PlayPageClient() { theme: 'dark', filter: (danmu: any) => { // 应用过滤规则 - if (danmakuSettingsRef.current.filterRules.length > 0) { - for (const rule of danmakuSettingsRef.current.filterRules) { + const filterConfig = danmakuFilterConfigRef.current; + if (filterConfig && filterConfig.rules.length > 0) { + for (const rule of filterConfig.rules) { + // 跳过未启用的规则 + if (!rule.enabled) continue; + try { - if (new RegExp(rule).test(danmu.text)) { - return false; + if (rule.type === 'normal') { + // 普通模式:字符串包含匹配 + if (danmu.text.includes(rule.keyword)) { + return false; + } + } else if (rule.type === 'regex') { + // 正则模式:正则表达式匹配 + if (new RegExp(rule.keyword).test(danmu.text)) { + return false; + } } } catch (e) { console.error('弹幕过滤规则错误:', e); @@ -2144,6 +2187,15 @@ function PlayPageClient() { return newVal ? '当前开启' : '当前关闭'; }, }, + { + html: '弹幕过滤', + icon: '', + tooltip: '配置弹幕过滤规则', + onClick() { + setShowDanmakuFilterSettings(true); + return '打开设置'; + }, + }, ...(webGPUSupported ? [ { name: 'Anime4K超分', @@ -3344,6 +3396,16 @@ function PlayPageClient() { )} + + {/* 弹幕过滤设置对话框 */} + setShowDanmakuFilterSettings(false)} + onConfigUpdate={(config) => { + setDanmakuFilterConfig(config); + danmakuFilterConfigRef.current = config; + }} + /> ); } diff --git a/src/components/DanmakuFilterSettings.tsx b/src/components/DanmakuFilterSettings.tsx new file mode 100644 index 0000000..2adfa71 --- /dev/null +++ b/src/components/DanmakuFilterSettings.tsx @@ -0,0 +1,247 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +'use client'; + +import { useState, useEffect } from 'react'; +import { X, Plus, Trash2, ToggleLeft, ToggleRight } from 'lucide-react'; +import { DanmakuFilterConfig, DanmakuFilterRule } from '@/lib/types'; +import { getDanmakuFilterConfig, saveDanmakuFilterConfig } from '@/lib/db.client'; + +interface DanmakuFilterSettingsProps { + isOpen: boolean; + onClose: () => void; + onConfigUpdate?: (config: DanmakuFilterConfig) => void; +} + +export default function DanmakuFilterSettings({ + isOpen, + onClose, + onConfigUpdate, +}: DanmakuFilterSettingsProps) { + const [config, setConfig] = useState({ rules: [] }); + const [newKeyword, setNewKeyword] = useState(''); + const [newType, setNewType] = useState<'normal' | 'regex'>('normal'); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + // 加载配置 + useEffect(() => { + if (isOpen) { + loadConfig(); + } + }, [isOpen]); + + const loadConfig = async () => { + setLoading(true); + try { + const loadedConfig = await getDanmakuFilterConfig(); + if (loadedConfig) { + setConfig(loadedConfig); + } else { + setConfig({ rules: [] }); + } + } catch (error) { + console.error('加载弹幕过滤配置失败:', error); + } finally { + setLoading(false); + } + }; + + // 保存配置 + const handleSave = async () => { + setSaving(true); + try { + await saveDanmakuFilterConfig(config); + if (onConfigUpdate) { + onConfigUpdate(config); + } + alert('保存成功!'); + } catch (error) { + console.error('保存弹幕过滤配置失败:', error); + alert('保存失败,请重试'); + } finally { + setSaving(false); + } + }; + + // 添加规则 + const handleAddRule = () => { + if (!newKeyword.trim()) { + alert('请输入关键字'); + return; + } + + const newRule: DanmakuFilterRule = { + keyword: newKeyword.trim(), + type: newType, + enabled: true, + id: Date.now().toString(), + }; + + setConfig((prev) => ({ + rules: [...prev.rules, newRule], + })); + + setNewKeyword(''); + }; + + // 删除规则 + const handleDeleteRule = (id: string | undefined) => { + if (!id) return; + setConfig((prev) => ({ + rules: prev.rules.filter((rule) => rule.id !== id), + })); + }; + + // 切换规则启用状态 + const handleToggleRule = (id: string | undefined) => { + if (!id) return; + setConfig((prev) => ({ + rules: prev.rules.map((rule) => + rule.id === id ? { ...rule, enabled: !rule.enabled } : rule + ), + })); + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* 头部 */} +
+

弹幕关键字屏蔽设置

+ +
+ + {/* 内容区域 */} +
+ {/* 添加规则 */} +
+

添加屏蔽规则

+
+ setNewKeyword(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleAddRule()} + placeholder="输入要屏蔽的关键字" + className="flex-1 px-3 py-2 bg-gray-700 text-white rounded border border-gray-600 focus:border-teal-500 focus:outline-none" + /> + + +
+

+ * 普通模式:包含关键字即屏蔽 | 正则模式:支持正则表达式匹配 +

+
+ + {/* 规则列表 */} +
+

+ 当前规则 ({config.rules.length}) +

+ {loading ? ( +
加载中...
+ ) : config.rules.length === 0 ? ( +
+ 暂无屏蔽规则,点击上方添加 +
+ ) : ( +
+ {config.rules.map((rule) => ( +
+ {/* 启用/禁用按钮 */} + + + {/* 关键字 */} +
+
+ + {rule.keyword} + + + {rule.type === 'regex' ? '正则' : '普通'} + +
+
+ + {/* 删除按钮 */} + +
+ ))} +
+ )} +
+
+ + {/* 底部按钮 */} +
+ + +
+
+
+ ); +} diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index d5059e3..1bcc861 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -15,7 +15,7 @@ */ import { getAuthInfoFromBrowserCookie } from './auth'; -import { SkipConfig } from './types'; +import { SkipConfig, DanmakuFilterConfig } from './types'; // 全局错误触发函数 function triggerGlobalError(message: string) { @@ -66,6 +66,7 @@ interface UserCacheStore { favorites?: CacheData>; searchHistory?: CacheData; skipConfigs?: CacheData>; + danmakuFilterConfig?: CacheData; } // ---- 常量 ---- @@ -340,6 +341,32 @@ class HybridCacheManager { this.saveUserCache(username, userCache); } + /** + * 弹幕过滤配置缓存方法 + */ + getCachedDanmakuFilterConfig(): DanmakuFilterConfig | null { + const username = this.getCurrentUsername(); + if (!username) return null; + + const userCache = this.getUserCache(username); + const cached = userCache.danmakuFilterConfig; + + if (cached && this.isCacheValid(cached)) { + return cached.data; + } + + return null; + } + + cacheDanmakuFilterConfig(data: DanmakuFilterConfig): void { + const username = this.getCurrentUsername(); + if (!username) return; + + const userCache = this.getUserCache(username); + userCache.danmakuFilterConfig = this.createCacheData(data); + this.saveUserCache(username, userCache); + } + /** * 清除指定用户的所有缓存 */ @@ -1656,3 +1683,122 @@ export async function deleteSkipConfig( throw err; } } + +// ---------------- 弹幕过滤配置相关 API ---------------- + +/** + * 获取弹幕过滤配置。 + * 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。 + */ +export async function getDanmakuFilterConfig(): Promise { + // 服务器端渲染阶段直接返回空 + if (typeof window === 'undefined') { + return null; + } + + // 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash) + if (STORAGE_TYPE !== 'localstorage') { + // 优先从缓存获取数据 + const cachedData = cacheManager.getCachedDanmakuFilterConfig(); + + if (cachedData) { + // 返回缓存数据,同时后台异步更新 + fetchFromApi(`/api/danmaku-filter`) + .then((freshData) => { + // 只有数据真正不同时才更新缓存 + if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) { + cacheManager.cacheDanmakuFilterConfig(freshData); + // 触发数据更新事件 + window.dispatchEvent( + new CustomEvent('danmakuFilterConfigUpdated', { + detail: freshData, + }) + ); + } + }) + .catch((err) => { + console.warn('后台同步弹幕过滤配置失败:', err); + }); + + return cachedData; + } else { + // 缓存为空,直接从 API 获取并缓存 + try { + const freshData = await fetchFromApi( + `/api/danmaku-filter` + ); + cacheManager.cacheDanmakuFilterConfig(freshData); + return freshData; + } catch (err) { + console.error('获取弹幕过滤配置失败:', err); + return null; + } + } + } + + // localStorage 模式 + try { + const raw = localStorage.getItem('moontv_danmaku_filter_config'); + if (!raw) return null; + return JSON.parse(raw) as DanmakuFilterConfig; + } catch (err) { + console.error('读取弹幕过滤配置失败:', err); + triggerGlobalError('读取弹幕过滤配置失败'); + return null; + } +} + +/** + * 保存弹幕过滤配置。 + * 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。 + */ +export async function saveDanmakuFilterConfig( + config: DanmakuFilterConfig +): Promise { + // 数据库存储模式:乐观更新策略(包括 redis 和 upstash) + if (STORAGE_TYPE !== 'localstorage') { + // 立即更新缓存 + cacheManager.cacheDanmakuFilterConfig(config); + + // 触发立即更新事件 + window.dispatchEvent( + new CustomEvent('danmakuFilterConfigUpdated', { + detail: config, + }) + ); + + // 异步同步到数据库 + try { + await fetchWithAuth('/api/danmaku-filter', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(config), + }); + } catch (err) { + console.error('保存弹幕过滤配置失败:', err); + triggerGlobalError('保存弹幕过滤配置失败'); + } + return; + } + + // localStorage 模式 + if (typeof window === 'undefined') { + console.warn('无法在服务端保存弹幕过滤配置到 localStorage'); + return; + } + + try { + localStorage.setItem('moontv_danmaku_filter_config', JSON.stringify(config)); + window.dispatchEvent( + new CustomEvent('danmakuFilterConfigUpdated', { + detail: config, + }) + ); + } catch (err) { + console.error('保存弹幕过滤配置失败:', err); + triggerGlobalError('保存弹幕过滤配置失败'); + throw err; + } +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 9f129b5..9cf127f 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -3,7 +3,7 @@ import { AdminConfig } from './admin.types'; import { KvrocksStorage } from './kvrocks.db'; import { RedisStorage } from './redis.db'; -import { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; +import { Favorite, IStorage, PlayRecord, SkipConfig, DanmakuFilterConfig } from './types'; import { UpstashRedisStorage } from './upstash.db'; // storage type 常量: 'localstorage' | 'redis' | 'upstash',默认 'localstorage' @@ -231,6 +231,29 @@ export class DbManager { return {}; } + // ---------- 弹幕过滤配置 ---------- + async getDanmakuFilterConfig(userName: string): Promise { + if (typeof (this.storage as any).getDanmakuFilterConfig === 'function') { + return (this.storage as any).getDanmakuFilterConfig(userName); + } + return null; + } + + async setDanmakuFilterConfig( + userName: string, + config: DanmakuFilterConfig + ): Promise { + if (typeof (this.storage as any).setDanmakuFilterConfig === 'function') { + await (this.storage as any).setDanmakuFilterConfig(userName, config); + } + } + + async deleteDanmakuFilterConfig(userName: string): Promise { + if (typeof (this.storage as any).deleteDanmakuFilterConfig === 'function') { + await (this.storage as any).deleteDanmakuFilterConfig(userName); + } + } + // ---------- 数据清理 ---------- async clearAllData(): Promise { if (typeof (this.storage as any).clearAllData === 'function') { diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index caad222..e12d6d8 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -378,6 +378,10 @@ export abstract class BaseRedisStorage implements IStorage { return `u:${user}:skip:${source}+${id}`; } + private danmakuFilterConfigKey(user: string) { + return `u:${user}:danmaku_filter`; + } + async getSkipConfig( userName: string, source: string, @@ -443,6 +447,34 @@ export abstract class BaseRedisStorage implements IStorage { return configs; } + // ---------- 弹幕过滤配置 ---------- + async getDanmakuFilterConfig( + userName: string + ): Promise { + const val = await this.withRetry(() => + this.client.get(this.danmakuFilterConfigKey(userName)) + ); + return val ? (JSON.parse(val) as import('./types').DanmakuFilterConfig) : null; + } + + async setDanmakuFilterConfig( + userName: string, + config: import('./types').DanmakuFilterConfig + ): Promise { + await this.withRetry(() => + this.client.set( + this.danmakuFilterConfigKey(userName), + JSON.stringify(config) + ) + ); + } + + async deleteDanmakuFilterConfig(userName: string): Promise { + await this.withRetry(() => + this.client.del(this.danmakuFilterConfigKey(userName)) + ); + } + // 清空所有数据 async clearAllData(): Promise { try { diff --git a/src/lib/types.ts b/src/lib/types.ts index 329b275..1d206a1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -81,6 +81,14 @@ export interface IStorage { deleteSkipConfig(userName: string, source: string, id: string): Promise; getAllSkipConfigs(userName: string): Promise<{ [key: string]: SkipConfig }>; + // 弹幕过滤配置相关 + getDanmakuFilterConfig(userName: string): Promise; + setDanmakuFilterConfig( + userName: string, + config: DanmakuFilterConfig + ): Promise; + deleteDanmakuFilterConfig(userName: string): Promise; + // 数据清理相关 clearAllData(): Promise; } @@ -122,3 +130,16 @@ export interface SkipConfig { intro_time: number; // 片头时间(秒) outro_time: number; // 片尾时间(秒) } + +// 弹幕过滤规则数据结构 +export interface DanmakuFilterRule { + keyword: string; // 关键字 + type: 'normal' | 'regex'; // 普通模式或正则模式 + enabled: boolean; // 是否启用 + id?: string; // 规则ID(用于前端管理) +} + +// 弹幕过滤配置数据结构 +export interface DanmakuFilterConfig { + rules: DanmakuFilterRule[]; // 过滤规则列表 +} diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index bfd1d32..d7fa4b6 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -282,6 +282,10 @@ export class UpstashRedisStorage implements IStorage { return `u:${user}:skip:${source}+${id}`; } + private danmakuFilterConfigKey(user: string) { + return `u:${user}:danmaku_filter`; + } + async getSkipConfig( userName: string, source: string, @@ -344,6 +348,31 @@ export class UpstashRedisStorage implements IStorage { return configs; } + // ---------- 弹幕过滤配置 ---------- + async getDanmakuFilterConfig( + userName: string + ): Promise { + const val = await withRetry(() => + this.client.get(this.danmakuFilterConfigKey(userName)) + ); + return val ? (val as import('./types').DanmakuFilterConfig) : null; + } + + async setDanmakuFilterConfig( + userName: string, + config: import('./types').DanmakuFilterConfig + ): Promise { + await withRetry(() => + this.client.set(this.danmakuFilterConfigKey(userName), config) + ); + } + + async deleteDanmakuFilterConfig(userName: string): Promise { + await withRetry(() => + this.client.del(this.danmakuFilterConfigKey(userName)) + ); + } + // 清空所有数据 async clearAllData(): Promise { try {