From f8a2964264fd44346315b75713b9009b89673a3f Mon Sep 17 00:00:00 2001 From: mtvpls Date: Fri, 5 Dec 2025 20:05:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=BC=B9=E5=B9=95=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 38 +++-- src/app/play/page.tsx | 6 + src/components/UserMenu.tsx | 77 +++++++++ src/lib/danmaku/api.ts | 64 ++++++- src/lib/danmaku/cache.ts | 330 ++++++++++++++++++++++++++++++++++++ 5 files changed, 501 insertions(+), 14 deletions(-) create mode 100644 src/lib/danmaku/cache.ts diff --git a/README.md b/README.md index 5a2a82d..b05ff12 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ - ✨ **视频超分 (Anime4K)**:使用 WebGPU 技术实现实时视频画质增强(支持 1.5x/2x/3x/4x 超分) - 💬 **弹幕系统**:完整的弹幕搜索、匹配、加载功能,支持弹幕设置持久化、弹幕屏蔽 - 📝 **豆瓣评论抓取**:自动抓取并展示豆瓣电影短评,支持分页加载 +- 🪒**自定义去广告**:你可以自定义你的去广告代码,实现更强力的去广告功能 ## ✨ 功能特性 @@ -34,7 +35,7 @@ - ❤️ **收藏 + 继续观看**:支持 Kvrocks/Redis/Upstash 存储,多端同步进度。 - 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。 - 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。 -- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)。 +- 👿 **智能去广告**:自动跳过视频中的切片广告,更可以自定义你的去广告代码以增强去广告功能。 ### 注意:部署后项目为空壳项目,无内置播放源和直播源,需要自行收集 @@ -49,27 +50,18 @@ ## 🗺 目录 -- [弹幕后端部署](#弹幕后端部署) - [技术栈](#技术栈) - [部署](#部署) - [配置文件](#配置文件) - [自动更新](#自动更新) - [环境变量](#环境变量) +- [弹幕后端部署](#弹幕后端部署) +- [超分功能说明](#超分功能说明) - [AndroidTV 使用](#AndroidTV-使用) - [安全与隐私提醒](#安全与隐私提醒) - [License](#license) - [致谢](#致谢) -## 弹幕后端部署 - -要使用弹幕功能,需要额外部署弹幕 API 后端服务。 - -### 部署步骤 - -1. 按照[danmu_api](https://github.com/huangxd-/danmu_api.git)教程部署后端 -2. 建议配置SOURCE_ORDER或PLATFORM_ORDER环境变量,默认弹幕源很少 -3. 在管理面板设置后端地址 - ## 技术栈 @@ -250,6 +242,7 @@ dockge/komodo 等 docker compose UI 也有自动更新功能 | NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false | | NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true | | NEXT_PUBLIC_PROXY_M3U8_TOKEN | M3U8 代理 API 鉴权 Token(外部播放器跳转时的鉴权token,不填为无鉴权) | 任意字符串 | (空) | +| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 4320(3天) | NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释: @@ -268,6 +261,27 @@ NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE 选项解释: - cmliussss-cdn-ali:由浏览器请求豆瓣 CDN,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速 - custom: 用户自定义 proxy,由 NEXT_PUBLIC_DOUBAN_IMAGE_PROXY 定义 + + +## 弹幕后端部署 + +要使用弹幕功能,需要额外部署弹幕 API 后端服务。 + +### 部署步骤 + +1. 按照[danmu_api](https://github.com/huangxd-/danmu_api.git)教程部署后端 +2. 建议配置SOURCE_ORDER或PLATFORM_ORDER环境变量,默认弹幕源很少 +3. 在管理面板设置后端地址 + + + + +## 超分功能说明 +超分功能需要浏览器支持webgpu并且你的浏览器环境不能是http(如非要在http中使用,需要在浏览器端设置允许不安全的内容) + + + + ## AndroidTV 使用 目前该项目可以配合 [OrionTV](https://github.com/zimplexing/OrionTV) 在 Android TV 上使用,可以直接作为 OrionTV 后端 diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index e251628..c0e5b87 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -30,6 +30,7 @@ import { saveDanmakuMemory, saveDanmakuSettings, searchAnime, + initDanmakuModule, } from '@/lib/danmaku/api'; import type { DanmakuAnime, DanmakuSelection, DanmakuSettings } from '@/lib/danmaku/types'; import { SearchResult, DanmakuFilterConfig } from '@/lib/types'; @@ -272,6 +273,11 @@ function PlayPageClient() { danmakuSettingsRef.current = danmakuSettings; }, [danmakuSettings]); + // 初始化弹幕模块(清理过期缓存) + useEffect(() => { + initDanmakuModule(); + }, []); + // 加载弹幕过滤配置 useEffect(() => { const loadFilterConfig = async () => { diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index 2d55278..80f1669 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -18,6 +18,7 @@ import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; +import { clearAllDanmakuCache } from '@/lib/danmaku/api'; import { CURRENT_VERSION } from '@/lib/version'; import { checkForUpdates, UpdateStatus } from '@/lib/version_check'; @@ -109,6 +110,10 @@ export const UserMenu: React.FC = () => { const [updateStatus, setUpdateStatus] = useState(null); const [isChecking, setIsChecking] = useState(true); + // 清除弹幕缓存相关状态 + const [isClearingCache, setIsClearingCache] = useState(false); + const [clearCacheMessage, setClearCacheMessage] = useState(null); + // 确保组件已挂载 useEffect(() => { setMounted(true); @@ -450,6 +455,33 @@ export const UserMenu: React.FC = () => { } }; + // 清除弹幕缓存 + const handleClearDanmakuCache = async () => { + setIsClearingCache(true); + setClearCacheMessage(null); + + try { + await clearAllDanmakuCache(); + setClearCacheMessage('弹幕缓存已清除成功!'); + console.log('弹幕缓存已清除'); + + // 3秒后自动清除提示 + setTimeout(() => { + setClearCacheMessage(null); + }, 3000); + } catch (error) { + console.error('清除弹幕缓存失败:', error); + setClearCacheMessage('清除失败,请重试'); + + // 3秒后自动清除提示 + setTimeout(() => { + setClearCacheMessage(null); + }, 3000); + } finally { + setIsClearingCache(false); + } + }; + // 检查是否显示管理面板按钮 const showAdminPanel = authInfo?.role === 'owner' || authInfo?.role === 'admin'; @@ -954,6 +986,51 @@ export const UserMenu: React.FC = () => { + + {/* 分割线 */} +
+ + {/* 清除弹幕缓存 */} +
+
+

+ 弹幕缓存管理 +

+

+ 清除所有已缓存的弹幕数据 +

+
+ + + {/* 成功/失败提示 */} + {clearCacheMessage && ( +
+ {clearCacheMessage} +
+ )} +
{/* 底部说明 */} diff --git a/src/lib/danmaku/api.ts b/src/lib/danmaku/api.ts index 8333eb0..cf5ae17 100644 --- a/src/lib/danmaku/api.ts +++ b/src/lib/danmaku/api.ts @@ -10,6 +10,44 @@ import type { DanmakuSettings, } from './types'; +import { + getDanmakuFromCache, + saveDanmakuToCache, + clearExpiredDanmakuCache, + clearAllDanmakuCache, + clearDanmakuCache, + getDanmakuCacheStats, +} from './cache'; + +// 初始化弹幕模块(清理过期缓存) +let _cacheCleanupInitialized = false; + +export function initDanmakuModule(): void { + if (typeof window === 'undefined') return; + if (_cacheCleanupInitialized) return; + + _cacheCleanupInitialized = true; + + // 启动时清理一次过期缓存 + clearExpiredDanmakuCache() + .then((count) => { + if (count > 0) { + console.log(`[弹幕缓存] 启动清理: 已删除 ${count} 个过期缓存`); + } + }) + .catch((error) => { + console.error('[弹幕缓存] 清理失败:', error); + }); +} + +// 导出缓存管理函数 +export { + clearAllDanmakuCache, + clearDanmakuCache, + clearExpiredDanmakuCache, + getDanmakuCacheStats, +}; + // 搜索动漫 export async function searchAnime( keyword: string @@ -98,11 +136,20 @@ export async function getEpisodes( } } -// 通过剧集 ID 获取弹幕 +// 通过剧集 ID 获取弹幕(优先从缓存读取) export async function getDanmakuById( episodeId: number ): Promise { try { + // 1. 先尝试从缓存读取 + const cachedComments = await getDanmakuFromCache(episodeId); + if (cachedComments) { + console.log(`[弹幕缓存] 使用缓存: episodeId=${episodeId}, 数量=${cachedComments.length}`); + return cachedComments; + } + + // 2. 缓存未命中,从 API 获取 + console.log(`[弹幕缓存] 缓存未命中,从 API 获取: episodeId=${episodeId}`); const url = `/api/danmaku/comment?episodeId=${episodeId}`; const response = await fetch(url); @@ -111,7 +158,20 @@ export async function getDanmakuById( } const data = (await response.json()) as DanmakuCommentsResponse; - return data.comments || []; + const comments = data.comments || []; + + // 3. 保存到缓存 + if (comments.length > 0) { + try { + await saveDanmakuToCache(episodeId, comments); + console.log(`[弹幕缓存] 已缓存: episodeId=${episodeId}, 数量=${comments.length}`); + } catch (cacheError) { + console.error('[弹幕缓存] 保存缓存失败:', cacheError); + // 缓存失败不影响返回结果 + } + } + + return comments; } catch (error) { console.error('获取弹幕失败:', error); return []; diff --git a/src/lib/danmaku/cache.ts b/src/lib/danmaku/cache.ts new file mode 100644 index 0000000..bbd6209 --- /dev/null +++ b/src/lib/danmaku/cache.ts @@ -0,0 +1,330 @@ +// 弹幕缓存工具(IndexedDB) + +import type { DanmakuComment } from './types'; + +// IndexedDB 数据库名称和版本 +const DB_NAME = 'moontvplus_danmaku_cache'; +const DB_VERSION = 1; +const STORE_NAME = 'danmaku'; + +// 缓存数据结构 +export interface DanmakuCacheData { + episodeId: number; + comments: DanmakuComment[]; + timestamp: number; // 缓存时间戳 +} + +// 获取弹幕缓存失效时间(毫秒) +// 从环境变量读取,默认 3 天(4320 分钟) +// 设置为 0 表示不缓存 +export function getDanmakuCacheExpireTime(): number { + if (typeof window === 'undefined') return 4320 * 60 * 1000; // 3天 = 4320分钟 + + const envValue = process.env.NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES; + if (envValue) { + const minutes = parseInt(envValue, 10); + if (!isNaN(minutes)) { + // 0 表示不缓存 + if (minutes === 0) return 0; + // 正数表示缓存时间(分钟) + if (minutes > 0) return minutes * 60 * 1000; + } + } + + // 默认 3 天(4320 分钟) + return 4320 * 60 * 1000; +} + +// 打开数据库 +async function openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + reject(new Error('无法打开 IndexedDB')); + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // 创建对象存储(如果不存在) + if (!db.objectStoreNames.contains(STORE_NAME)) { + const objectStore = db.createObjectStore(STORE_NAME, { keyPath: 'episodeId' }); + objectStore.createIndex('timestamp', 'timestamp', { unique: false }); + console.log('IndexedDB 对象存储已创建:', STORE_NAME); + } + }; + }); +} + +// 保存弹幕到缓存 +export async function saveDanmakuToCache( + episodeId: number, + comments: DanmakuComment[] +): Promise { + // 如果缓存时间设置为 0,不保存缓存 + const expireTime = getDanmakuCacheExpireTime(); + if (expireTime === 0) { + console.log('弹幕缓存已禁用,跳过保存'); + return; + } + + try { + const db = await openDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const objectStore = transaction.objectStore(STORE_NAME); + + const cacheData: DanmakuCacheData = { + episodeId, + comments, + timestamp: Date.now(), + }; + + return new Promise((resolve, reject) => { + const request = objectStore.put(cacheData); + + request.onsuccess = () => { + console.log(`弹幕已缓存: episodeId=${episodeId}, 数量=${comments.length}`); + resolve(); + }; + + request.onerror = () => { + reject(new Error('保存弹幕缓存失败')); + }; + + transaction.oncomplete = () => { + db.close(); + }; + }); + } catch (error) { + console.error('保存弹幕缓存失败:', error); + throw error; + } +} + +// 从缓存获取弹幕 +export async function getDanmakuFromCache( + episodeId: number +): Promise { + // 如果缓存时间设置为 0,不使用缓存 + const expireTime = getDanmakuCacheExpireTime(); + if (expireTime === 0) { + console.log('弹幕缓存已禁用,跳过读取'); + return null; + } + + try { + const db = await openDB(); + const transaction = db.transaction([STORE_NAME], 'readonly'); + const objectStore = transaction.objectStore(STORE_NAME); + + return new Promise((resolve, reject) => { + const request = objectStore.get(episodeId); + + request.onsuccess = () => { + const result = request.result as DanmakuCacheData | undefined; + + if (!result) { + console.log(`弹幕缓存未找到: episodeId=${episodeId}`); + resolve(null); + return; + } + + // 检查缓存是否过期 + const expireTime = getDanmakuCacheExpireTime(); + const now = Date.now(); + const age = now - result.timestamp; + + if (age > expireTime) { + const ageMinutes = Math.floor(age / 1000 / 60); + console.log( + `弹幕缓存已过期: episodeId=${episodeId}, 年龄=${ageMinutes}分钟` + ); + resolve(null); + return; + } + + const ageMinutes = Math.floor(age / 1000 / 60); + console.log( + `从缓存获取弹幕: episodeId=${episodeId}, 数量=${result.comments.length}, 年龄=${ageMinutes}分钟` + ); + resolve(result.comments); + }; + + request.onerror = () => { + reject(new Error('获取弹幕缓存失败')); + }; + + transaction.oncomplete = () => { + db.close(); + }; + }); + } catch (error) { + console.error('获取弹幕缓存失败:', error); + return null; + } +} + +// 清除指定弹幕缓存 +export async function clearDanmakuCache(episodeId: number): Promise { + try { + const db = await openDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const objectStore = transaction.objectStore(STORE_NAME); + + return new Promise((resolve, reject) => { + const request = objectStore.delete(episodeId); + + request.onsuccess = () => { + console.log(`弹幕缓存已清除: episodeId=${episodeId}`); + resolve(); + }; + + request.onerror = () => { + reject(new Error('清除弹幕缓存失败')); + }; + + transaction.oncomplete = () => { + db.close(); + }; + }); + } catch (error) { + console.error('清除弹幕缓存失败:', error); + throw error; + } +} + +// 清除所有过期缓存 +export async function clearExpiredDanmakuCache(): Promise { + try { + const db = await openDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const objectStore = transaction.objectStore(STORE_NAME); + const index = objectStore.index('timestamp'); + + const expireTime = getDanmakuCacheExpireTime(); + const now = Date.now(); + const expireThreshold = now - expireTime; + + return new Promise((resolve, reject) => { + const request = index.openCursor(); + let deletedCount = 0; + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result; + + if (cursor) { + const data = cursor.value as DanmakuCacheData; + + if (data.timestamp < expireThreshold) { + cursor.delete(); + deletedCount++; + } + + cursor.continue(); + } else { + console.log(`已清除 ${deletedCount} 个过期弹幕缓存`); + resolve(deletedCount); + } + }; + + request.onerror = () => { + reject(new Error('清除过期弹幕缓存失败')); + }; + + transaction.oncomplete = () => { + db.close(); + }; + }); + } catch (error) { + console.error('清除过期弹幕缓存失败:', error); + return 0; + } +} + +// 清除所有弹幕缓存 +export async function clearAllDanmakuCache(): Promise { + try { + const db = await openDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const objectStore = transaction.objectStore(STORE_NAME); + + return new Promise((resolve, reject) => { + const request = objectStore.clear(); + + request.onsuccess = () => { + console.log('所有弹幕缓存已清除'); + resolve(); + }; + + request.onerror = () => { + reject(new Error('清除所有弹幕缓存失败')); + }; + + transaction.oncomplete = () => { + db.close(); + }; + }); + } catch (error) { + console.error('清除所有弹幕缓存失败:', error); + throw error; + } +} + +// 获取缓存统计信息 +export async function getDanmakuCacheStats(): Promise<{ + total: number; + expired: number; + totalSize: number; +}> { + try { + const db = await openDB(); + const transaction = db.transaction([STORE_NAME], 'readonly'); + const objectStore = transaction.objectStore(STORE_NAME); + + return new Promise((resolve, reject) => { + const request = objectStore.openCursor(); + + let total = 0; + let expired = 0; + let totalSize = 0; + + const expireTime = getDanmakuCacheExpireTime(); + const now = Date.now(); + const expireThreshold = now - expireTime; + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result; + + if (cursor) { + const data = cursor.value as DanmakuCacheData; + total++; + totalSize += data.comments.length; + + if (data.timestamp < expireThreshold) { + expired++; + } + + cursor.continue(); + } else { + resolve({ total, expired, totalSize }); + } + }; + + request.onerror = () => { + reject(new Error('获取缓存统计信息失败')); + }; + + transaction.oncomplete = () => { + db.close(); + }; + }); + } catch (error) { + console.error('获取缓存统计信息失败:', error); + return { total: 0, expired: 0, totalSize: 0 }; + } +}