增加弹幕本地缓存

This commit is contained in:
mtvpls
2025-12-05 20:05:30 +08:00
parent 40da6ae5b2
commit f8a2964264
5 changed files with 501 additions and 14 deletions

View File

@@ -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 或正整数 | 43203天 |
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 后端

View File

@@ -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 () => {

View File

@@ -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<UpdateStatus | null>(null);
const [isChecking, setIsChecking] = useState(true);
// 清除弹幕缓存相关状态
const [isClearingCache, setIsClearingCache] = useState(false);
const [clearCacheMessage, setClearCacheMessage] = useState<string | null>(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 = () => {
</div>
</label>
</div>
{/* 分割线 */}
<div className='border-t border-gray-200 dark:border-gray-700'></div>
{/* 清除弹幕缓存 */}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<button
onClick={handleClearDanmakuCache}
disabled={isClearingCache}
className='w-full px-4 py-2.5 bg-red-500 hover:bg-red-600 disabled:bg-red-400 dark:bg-red-600 dark:hover:bg-red-700 dark:disabled:bg-red-500 text-white text-sm font-medium rounded-lg transition-colors duration-200 shadow-sm hover:shadow-md disabled:cursor-not-allowed flex items-center justify-center gap-2'
>
{isClearingCache ? (
<>
<div className='w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin'></div>
<span>...</span>
</>
) : (
<>
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16' />
</svg>
<span></span>
</>
)}
</button>
{/* 成功/失败提示 */}
{clearCacheMessage && (
<div className={`text-sm p-3 rounded-lg border ${
clearCacheMessage.includes('成功')
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-700 dark:text-green-300'
: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300'
}`}>
{clearCacheMessage}
</div>
)}
</div>
</div>
{/* 底部说明 */}

View File

@@ -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<DanmakuComment[]> {
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 [];

330
src/lib/danmaku/cache.ts Normal file
View File

@@ -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<IDBDatabase> {
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<void> {
// 如果缓存时间设置为 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<DanmakuComment[] | null> {
// 如果缓存时间设置为 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<void> {
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<number> {
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<IDBCursorWithValue>).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<void> {
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<IDBCursorWithValue>).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 };
}
}