Files
MoonTVPlus/src/lib/db.client.ts

1885 lines
53 KiB
TypeScript
Raw Normal View History

2025-08-12 21:50:58 +08:00
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function */
'use client';
/**
* 使 localStorage
* bundle `fs`, `path` Node.js
* "Module not found: Can't resolve 'fs'"
*
*
* 1. getAllPlayRecords
* 2. savePlayRecord
* 3.
*
*
*/
import { getAuthInfoFromBrowserCookie } from './auth';
2025-12-15 20:16:40 +08:00
import { SkipConfig, DanmakuFilterConfig, EpisodeFilterConfig } from './types';
2025-08-12 21:50:58 +08:00
// 全局错误触发函数
function triggerGlobalError(message: string) {
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message },
})
);
}
}
// ---- 类型 ----
export interface PlayRecord {
title: string;
source_name: string;
year: string;
cover: string;
index: number; // 第几集
total_episodes: number; // 总集数
play_time: number; // 播放进度(秒)
total_time: number; // 总进度(秒)
save_time: number; // 记录保存时间(时间戳)
search_title?: string; // 搜索时使用的标题
2025-12-18 00:01:31 +08:00
origin?: 'vod' | 'live'; // 来源类型
2025-08-12 21:50:58 +08:00
}
// ---- 收藏类型 ----
export interface Favorite {
title: string;
source_name: string;
year: string;
cover: string;
total_episodes: number;
save_time: number;
search_title?: string;
origin?: 'vod' | 'live';
2025-12-18 23:31:10 +08:00
is_completed?: boolean; // 是否已完结
vod_remarks?: string; // 视频备注信息
2025-08-12 21:50:58 +08:00
}
// ---- 缓存数据结构 ----
interface CacheData<T> {
data: T;
timestamp: number;
version: string;
}
interface UserCacheStore {
playRecords?: CacheData<Record<string, PlayRecord>>;
favorites?: CacheData<Record<string, Favorite>>;
searchHistory?: CacheData<string[]>;
skipConfigs?: CacheData<Record<string, SkipConfig>>;
2025-12-03 21:34:05 +08:00
danmakuFilterConfig?: CacheData<DanmakuFilterConfig>;
2025-08-12 21:50:58 +08:00
}
// ---- 常量 ----
const PLAY_RECORDS_KEY = 'moontv_play_records';
const FAVORITES_KEY = 'moontv_favorites';
const SEARCH_HISTORY_KEY = 'moontv_search_history';
// 缓存相关常量
const CACHE_PREFIX = 'moontv_cache_';
const CACHE_VERSION = '1.0.0';
const CACHE_EXPIRE_TIME = 60 * 60 * 1000; // 一小时缓存过期
// ---- 环境变量 ----
const STORAGE_TYPE = (() => {
const raw =
(typeof window !== 'undefined' &&
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE) ||
(process.env.STORAGE_TYPE as
| 'localstorage'
| 'redis'
| 'upstash'
| undefined) ||
'localstorage';
return raw;
})();
// ---------------- 搜索历史相关常量 ----------------
// 搜索历史最大保存条数
const SEARCH_HISTORY_LIMIT = 20;
// ---- 缓存管理器 ----
class HybridCacheManager {
private static instance: HybridCacheManager;
// 正在进行的请求 Promise 缓存(彻底防止并发重复请求)
private pendingRequests: Map<string, Promise<any>> = new Map();
2025-08-12 21:50:58 +08:00
static getInstance(): HybridCacheManager {
if (!HybridCacheManager.instance) {
HybridCacheManager.instance = new HybridCacheManager();
}
return HybridCacheManager.instance;
}
/**
* Promise
*/
getOrCreateRequest<T>(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
// 如果已有正在进行的请求,直接返回
if (this.pendingRequests.has(key)) {
console.log(`[${key}] 复用进行中的请求`);
return this.pendingRequests.get(key)!;
}
console.log(`[${key}] 创建新请求`);
// 创建新请求
const promise = fetcher()
.finally(() => {
// 请求完成后清除缓存
this.pendingRequests.delete(key);
});
this.pendingRequests.set(key, promise);
return promise;
}
2025-08-12 21:50:58 +08:00
/**
*
*/
private getCurrentUsername(): string | null {
const authInfo = getAuthInfoFromBrowserCookie();
return authInfo?.username || null;
}
/**
* key
*/
private getUserCacheKey(username: string): string {
return `${CACHE_PREFIX}${username}`;
}
/**
*
*/
private getUserCache(username: string): UserCacheStore {
if (typeof window === 'undefined') return {};
try {
const cacheKey = this.getUserCacheKey(username);
const cached = localStorage.getItem(cacheKey);
return cached ? JSON.parse(cached) : {};
} catch (error) {
console.warn('获取用户缓存失败:', error);
return {};
}
}
/**
*
*/
private saveUserCache(username: string, cache: UserCacheStore): void {
if (typeof window === 'undefined') return;
try {
// 检查缓存大小超过15MB时清理旧数据
const cacheSize = JSON.stringify(cache).length;
if (cacheSize > 15 * 1024 * 1024) {
console.warn('缓存过大,清理旧数据');
this.cleanOldCache(cache);
}
const cacheKey = this.getUserCacheKey(username);
localStorage.setItem(cacheKey, JSON.stringify(cache));
} catch (error) {
console.warn('保存用户缓存失败:', error);
// 存储空间不足时清理缓存后重试
if (
error instanceof DOMException &&
error.name === 'QuotaExceededError'
) {
this.clearAllCache();
try {
const cacheKey = this.getUserCacheKey(username);
localStorage.setItem(cacheKey, JSON.stringify(cache));
} catch (retryError) {
console.error('重试保存缓存仍然失败:', retryError);
}
}
}
}
/**
*
*/
private cleanOldCache(cache: UserCacheStore): void {
const now = Date.now();
const maxAge = 60 * 24 * 60 * 60 * 1000; // 两个月
// 清理过期的播放记录缓存
if (cache.playRecords && now - cache.playRecords.timestamp > maxAge) {
delete cache.playRecords;
}
// 清理过期的收藏缓存
if (cache.favorites && now - cache.favorites.timestamp > maxAge) {
delete cache.favorites;
}
}
/**
*
*/
private clearAllCache(): void {
const keys = Object.keys(localStorage);
keys.forEach((key) => {
if (key.startsWith('moontv_cache_')) {
localStorage.removeItem(key);
}
});
}
/**
*
*/
private isCacheValid<T>(cache: CacheData<T>): boolean {
const now = Date.now();
return (
cache.version === CACHE_VERSION &&
now - cache.timestamp < CACHE_EXPIRE_TIME
);
}
/**
*
*/
private createCacheData<T>(data: T): CacheData<T> {
return {
data,
timestamp: Date.now(),
version: CACHE_VERSION,
};
}
/**
*
*/
getCachedPlayRecords(): Record<string, PlayRecord> | null {
const username = this.getCurrentUsername();
if (!username) return null;
const userCache = this.getUserCache(username);
const cached = userCache.playRecords;
if (cached && this.isCacheValid(cached)) {
return cached.data;
}
return null;
}
/**
*
*/
cachePlayRecords(data: Record<string, PlayRecord>): void {
const username = this.getCurrentUsername();
if (!username) return;
const userCache = this.getUserCache(username);
userCache.playRecords = this.createCacheData(data);
this.saveUserCache(username, userCache);
}
/**
*
*/
getCachedFavorites(): Record<string, Favorite> | null {
const username = this.getCurrentUsername();
if (!username) return null;
const userCache = this.getUserCache(username);
const cached = userCache.favorites;
if (cached && this.isCacheValid(cached)) {
return cached.data;
}
return null;
}
/**
*
*/
cacheFavorites(data: Record<string, Favorite>): void {
const username = this.getCurrentUsername();
if (!username) return;
const userCache = this.getUserCache(username);
userCache.favorites = this.createCacheData(data);
this.saveUserCache(username, userCache);
}
/**
*
*/
getCachedSearchHistory(): string[] | null {
const username = this.getCurrentUsername();
if (!username) return null;
const userCache = this.getUserCache(username);
const cached = userCache.searchHistory;
if (cached && this.isCacheValid(cached)) {
return cached.data;
}
return null;
}
/**
*
*/
cacheSearchHistory(data: string[]): void {
const username = this.getCurrentUsername();
if (!username) return;
const userCache = this.getUserCache(username);
userCache.searchHistory = this.createCacheData(data);
this.saveUserCache(username, userCache);
}
/**
*
*/
getCachedSkipConfigs(): Record<string, SkipConfig> | null {
const username = this.getCurrentUsername();
if (!username) return null;
const userCache = this.getUserCache(username);
const cached = userCache.skipConfigs;
if (cached && this.isCacheValid(cached)) {
return cached.data;
}
return null;
}
/**
*
*/
cacheSkipConfigs(data: Record<string, SkipConfig>): void {
const username = this.getCurrentUsername();
if (!username) return;
const userCache = this.getUserCache(username);
userCache.skipConfigs = this.createCacheData(data);
this.saveUserCache(username, userCache);
}
2025-12-03 21:34:05 +08:00
/**
*
*/
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);
}
2025-08-12 21:50:58 +08:00
/**
*
*/
clearUserCache(username?: string): void {
const targetUsername = username || this.getCurrentUsername();
if (!targetUsername) return;
try {
const cacheKey = this.getUserCacheKey(targetUsername);
localStorage.removeItem(cacheKey);
} catch (error) {
console.warn('清除用户缓存失败:', error);
}
}
/**
*
*/
clearExpiredCaches(): void {
if (typeof window === 'undefined') return;
try {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(CACHE_PREFIX)) {
try {
const cache = JSON.parse(localStorage.getItem(key) || '{}');
// 检查是否有任何缓存数据过期
let hasValidData = false;
for (const [, cacheData] of Object.entries(cache)) {
if (cacheData && this.isCacheValid(cacheData as CacheData<any>)) {
hasValidData = true;
break;
}
}
if (!hasValidData) {
keysToRemove.push(key);
}
} catch {
// 解析失败的缓存也删除
keysToRemove.push(key);
}
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
} catch (error) {
console.warn('清除过期缓存失败:', error);
}
}
}
// 获取缓存管理器实例
const cacheManager = HybridCacheManager.getInstance();
// ---- 错误处理辅助函数 ----
/**
*
*
*/
async function handleDatabaseOperationFailure(
dataType: 'playRecords' | 'favorites' | 'searchHistory',
error: any
): Promise<void> {
console.error(`数据库操作失败 (${dataType}):`, error);
triggerGlobalError(`数据库操作失败`);
try {
// 使用 Promise 缓存防止并发重复请求
await cacheManager.getOrCreateRequest(`recovery-${dataType}`, async () => {
let freshData: any;
let eventName: string;
switch (dataType) {
case 'playRecords':
freshData = await fetchFromApi<Record<string, PlayRecord>>(
`/api/playrecords`
);
cacheManager.cachePlayRecords(freshData);
eventName = 'playRecordsUpdated';
break;
case 'favorites':
freshData = await fetchFromApi<Record<string, Favorite>>(
`/api/favorites`
);
cacheManager.cacheFavorites(freshData);
eventName = 'favoritesUpdated';
break;
case 'searchHistory':
freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
cacheManager.cacheSearchHistory(freshData);
eventName = 'searchHistoryUpdated';
break;
}
2025-08-12 21:50:58 +08:00
// 触发更新事件通知组件
window.dispatchEvent(
new CustomEvent(eventName, {
detail: freshData,
})
);
});
2025-08-12 21:50:58 +08:00
} catch (refreshErr) {
console.error(`刷新${dataType}缓存失败:`, refreshErr);
triggerGlobalError(`刷新${dataType}缓存失败`);
}
}
// 页面加载时清理过期缓存
if (typeof window !== 'undefined') {
setTimeout(() => cacheManager.clearExpiredCaches(), 1000);
}
// ---- 工具函数 ----
/**
* fetch 401
*/
async function fetchWithAuth(
url: string,
options?: RequestInit
): Promise<Response> {
const res = await fetch(url, options);
if (!res.ok) {
// 如果是 401 未授权,跳转到登录页面
if (res.status === 401) {
// 调用 logout 接口
try {
await fetch('/api/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('注销请求失败:', error);
}
const currentUrl = window.location.pathname + window.location.search;
const loginUrl = new URL('/login', window.location.origin);
loginUrl.searchParams.set('redirect', currentUrl);
window.location.href = loginUrl.toString();
throw new Error('用户未授权,已跳转到登录页面');
}
throw new Error(`请求 ${url} 失败: ${res.status}`);
}
return res;
}
async function fetchFromApi<T>(path: string): Promise<T> {
const res = await fetchWithAuth(path);
return (await res.json()) as T;
}
/**
* key
*/
export function generateStorageKey(source: string, id: string): string {
return `${source}+${id}`;
}
// ---- API ----
/**
*
* 使
* (window === undefined)
*/
export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
// 服务器端渲染阶段直接返回空,交由客户端 useEffect 再行请求
if (typeof window === 'undefined') {
return {};
}
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedPlayRecords();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cachePlayRecords(freshData);
// 触发数据更新事件,供组件监听
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步播放记录失败:', err);
triggerGlobalError('后台同步播放记录失败');
});
return cachedData;
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<Record<string, PlayRecord>>(
`/api/playrecords`
);
cacheManager.cachePlayRecords(freshData);
return freshData;
} catch (err) {
console.error('获取播放记录失败:', err);
triggerGlobalError('获取播放记录失败');
return {};
}
}
}
// localstorage 模式
try {
const raw = localStorage.getItem(PLAY_RECORDS_KEY);
if (!raw) return {};
return JSON.parse(raw) as Record<string, PlayRecord>;
} catch (err) {
console.error('读取播放记录失败:', err);
triggerGlobalError('读取播放记录失败');
return {};
}
}
/**
*
* 使
*/
export async function savePlayRecord(
source: string,
id: string,
record: PlayRecord
): Promise<void> {
const key = generateStorageKey(source, id);
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
cachedRecords[key] = record;
cacheManager.cachePlayRecords(cachedRecords);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: cachedRecords,
})
);
// 异步同步到数据库
try {
await fetchWithAuth('/api/playrecords', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key, record }),
});
} catch (err) {
await handleDatabaseOperationFailure('playRecords', err);
triggerGlobalError('保存播放记录失败');
throw err;
}
return;
}
// localstorage 模式
if (typeof window === 'undefined') {
console.warn('无法在服务端保存播放记录到 localStorage');
return;
}
try {
const allRecords = await getAllPlayRecords();
allRecords[key] = record;
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: allRecords,
})
);
} catch (err) {
console.error('保存播放记录失败:', err);
triggerGlobalError('保存播放记录失败');
throw err;
}
}
/**
*
* 使
*/
export async function deletePlayRecord(
source: string,
id: string
): Promise<void> {
const key = generateStorageKey(source, id);
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
delete cachedRecords[key];
cacheManager.cachePlayRecords(cachedRecords);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: cachedRecords,
})
);
// 异步同步到数据库
try {
await fetchWithAuth(`/api/playrecords?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
});
} catch (err) {
await handleDatabaseOperationFailure('playRecords', err);
triggerGlobalError('删除播放记录失败');
throw err;
}
return;
}
// localstorage 模式
if (typeof window === 'undefined') {
console.warn('无法在服务端删除播放记录到 localStorage');
return;
}
try {
const allRecords = await getAllPlayRecords();
delete allRecords[key];
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: allRecords,
})
);
} catch (err) {
console.error('删除播放记录失败:', err);
triggerGlobalError('删除播放记录失败');
throw err;
}
}
/* ---------------- 搜索历史相关 API ---------------- */
/**
*
* 使
*/
export async function getSearchHistory(): Promise<string[]> {
// 服务器端渲染阶段直接返回空
if (typeof window === 'undefined') {
return [];
}
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedSearchHistory();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<string[]>(`/api/searchhistory`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheSearchHistory(freshData);
// 触发数据更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步搜索历史失败:', err);
triggerGlobalError('后台同步搜索历史失败');
});
return cachedData;
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
cacheManager.cacheSearchHistory(freshData);
return freshData;
} catch (err) {
console.error('获取搜索历史失败:', err);
triggerGlobalError('获取搜索历史失败');
return [];
}
}
}
// localStorage 模式
try {
const raw = localStorage.getItem(SEARCH_HISTORY_KEY);
if (!raw) return [];
const arr = JSON.parse(raw) as string[];
// 仅返回字符串数组
return Array.isArray(arr) ? arr : [];
} catch (err) {
console.error('读取搜索历史失败:', err);
triggerGlobalError('读取搜索历史失败');
return [];
}
}
/**
*
* 使
*/
export async function addSearchHistory(keyword: string): Promise<void> {
const trimmed = keyword.trim();
if (!trimmed) return;
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
const newHistory = [trimmed, ...cachedHistory.filter((k) => k !== trimmed)];
// 限制长度
if (newHistory.length > SEARCH_HISTORY_LIMIT) {
newHistory.length = SEARCH_HISTORY_LIMIT;
}
cacheManager.cacheSearchHistory(newHistory);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: newHistory,
})
);
// 异步同步到数据库
try {
await fetchWithAuth('/api/searchhistory', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ keyword: trimmed }),
});
} catch (err) {
await handleDatabaseOperationFailure('searchHistory', err);
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') return;
try {
const history = await getSearchHistory();
const newHistory = [trimmed, ...history.filter((k) => k !== trimmed)];
// 限制长度
if (newHistory.length > SEARCH_HISTORY_LIMIT) {
newHistory.length = SEARCH_HISTORY_LIMIT;
}
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory));
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: newHistory,
})
);
} catch (err) {
console.error('保存搜索历史失败:', err);
triggerGlobalError('保存搜索历史失败');
}
}
/**
*
* 使
*/
export async function clearSearchHistory(): Promise<void> {
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
cacheManager.cacheSearchHistory([]);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: [],
})
);
// 异步同步到数据库
try {
await fetchWithAuth(`/api/searchhistory`, {
method: 'DELETE',
});
} catch (err) {
await handleDatabaseOperationFailure('searchHistory', err);
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') return;
localStorage.removeItem(SEARCH_HISTORY_KEY);
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: [],
})
);
}
/**
*
* 使
*/
export async function deleteSearchHistory(keyword: string): Promise<void> {
const trimmed = keyword.trim();
if (!trimmed) return;
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
const newHistory = cachedHistory.filter((k) => k !== trimmed);
cacheManager.cacheSearchHistory(newHistory);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: newHistory,
})
);
// 异步同步到数据库
try {
await fetchWithAuth(
`/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`,
{
method: 'DELETE',
}
);
} catch (err) {
await handleDatabaseOperationFailure('searchHistory', err);
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') return;
try {
const history = await getSearchHistory();
const newHistory = history.filter((k) => k !== trimmed);
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory));
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: newHistory,
})
);
} catch (err) {
console.error('删除搜索历史失败:', err);
triggerGlobalError('删除搜索历史失败');
}
}
// ---------------- 收藏相关 API ----------------
// 模块级别的防重复请求机制
let pendingFavoritesBackgroundRequest: Promise<void> | null = null;
let pendingFavoritesFetchRequest: Promise<Record<string, Favorite>> | null = null;
let lastFavoritesBackgroundFetchTime = 0;
const MIN_BACKGROUND_FETCH_INTERVAL = 3000; // 3秒内不重复后台请求
2025-08-12 21:50:58 +08:00
/**
*
* 使
*/
export async function getAllFavorites(): Promise<Record<string, Favorite>> {
// 服务器端渲染阶段直接返回空
if (typeof window === 'undefined') {
return {};
}
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedFavorites();
if (cachedData) {
// 有缓存:返回缓存,后台异步刷新(带防抖和防重复)
const now = Date.now();
if (now - lastFavoritesBackgroundFetchTime > MIN_BACKGROUND_FETCH_INTERVAL && !pendingFavoritesBackgroundRequest) {
lastFavoritesBackgroundFetchTime = now;
pendingFavoritesBackgroundRequest = (async () => {
try {
const freshData = await fetchFromApi<Record<string, Favorite>>(`/api/favorites`);
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheFavorites(freshData);
// 触发数据更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: freshData,
})
);
}
} catch (err) {
console.warn('后台同步收藏失败:', err);
triggerGlobalError('后台同步收藏失败');
} finally {
pendingFavoritesBackgroundRequest = null;
2025-08-12 21:50:58 +08:00
}
})();
}
2025-08-12 21:50:58 +08:00
return cachedData;
} else {
// 无缓存:直接获取(防重复请求)
if (pendingFavoritesFetchRequest) {
return pendingFavoritesFetchRequest;
2025-08-12 21:50:58 +08:00
}
pendingFavoritesFetchRequest = (async () => {
try {
const freshData = await fetchFromApi<Record<string, Favorite>>(`/api/favorites`);
cacheManager.cacheFavorites(freshData);
return freshData;
} catch (err) {
console.error('获取收藏失败:', err);
triggerGlobalError('获取收藏失败');
return {};
} finally {
pendingFavoritesFetchRequest = null;
}
})();
return pendingFavoritesFetchRequest;
2025-08-12 21:50:58 +08:00
}
}
// localStorage 模式
try {
const raw = localStorage.getItem(FAVORITES_KEY);
if (!raw) return {};
return JSON.parse(raw) as Record<string, Favorite>;
} catch (err) {
console.error('读取收藏失败:', err);
triggerGlobalError('读取收藏失败');
return {};
}
}
/**
*
* 使
*/
export async function saveFavorite(
source: string,
id: string,
favorite: Favorite
): Promise<void> {
const key = generateStorageKey(source, id);
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedFavorites = cacheManager.getCachedFavorites() || {};
cachedFavorites[key] = favorite;
cacheManager.cacheFavorites(cachedFavorites);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: cachedFavorites,
})
);
// 异步同步到数据库
try {
await fetchWithAuth('/api/favorites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key, favorite }),
});
} catch (err) {
await handleDatabaseOperationFailure('favorites', err);
triggerGlobalError('保存收藏失败');
throw err;
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') {
console.warn('无法在服务端保存收藏到 localStorage');
return;
}
try {
const allFavorites = await getAllFavorites();
allFavorites[key] = favorite;
localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites));
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: allFavorites,
})
);
} catch (err) {
console.error('保存收藏失败:', err);
triggerGlobalError('保存收藏失败');
throw err;
}
}
/**
*
* 使
*/
export async function deleteFavorite(
source: string,
id: string
): Promise<void> {
const key = generateStorageKey(source, id);
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedFavorites = cacheManager.getCachedFavorites() || {};
delete cachedFavorites[key];
cacheManager.cacheFavorites(cachedFavorites);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: cachedFavorites,
})
);
// 异步同步到数据库
try {
await fetchWithAuth(`/api/favorites?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
});
} catch (err) {
await handleDatabaseOperationFailure('favorites', err);
triggerGlobalError('删除收藏失败');
throw err;
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') {
console.warn('无法在服务端删除收藏到 localStorage');
return;
}
try {
const allFavorites = await getAllFavorites();
delete allFavorites[key];
localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites));
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: allFavorites,
})
);
} catch (err) {
console.error('删除收藏失败:', err);
triggerGlobalError('删除收藏失败');
throw err;
}
}
/**
*
* 使
*/
export async function isFavorited(
source: string,
id: string
): Promise<boolean> {
const key = generateStorageKey(source, id);
// 数据库存储模式:直接从缓存读取,不触发后台刷新
// 后台刷新由 getAllFavorites() 统一管理,避免重复请求
2025-08-12 21:50:58 +08:00
if (STORAGE_TYPE !== 'localstorage') {
const cachedFavorites = cacheManager.getCachedFavorites();
if (cachedFavorites) {
// 直接返回缓存结果,不触发后台刷新
2025-08-12 21:50:58 +08:00
return !!cachedFavorites[key];
} else {
// 缓存为空时,调用 getAllFavorites() 来获取并缓存数据
// 这样可以复用 getAllFavorites() 中的防重复请求机制
const allFavorites = await getAllFavorites();
return !!allFavorites[key];
2025-08-12 21:50:58 +08:00
}
}
// localStorage 模式
const allFavorites = await getAllFavorites();
return !!allFavorites[key];
}
/**
*
* 使
*/
export async function clearAllPlayRecords(): Promise<void> {
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
cacheManager.cachePlayRecords({});
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: {},
})
);
// 异步同步到数据库
try {
await fetchWithAuth(`/api/playrecords`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
await handleDatabaseOperationFailure('playRecords', err);
triggerGlobalError('清空播放记录失败');
throw err;
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') return;
localStorage.removeItem(PLAY_RECORDS_KEY);
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: {},
})
);
}
/**
*
* 使
*/
export async function clearAllFavorites(): Promise<void> {
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
cacheManager.cacheFavorites({});
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: {},
})
);
// 异步同步到数据库
try {
await fetchWithAuth(`/api/favorites`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
await handleDatabaseOperationFailure('favorites', err);
triggerGlobalError('清空收藏失败');
throw err;
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') return;
localStorage.removeItem(FAVORITES_KEY);
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: {},
})
);
}
// ---------------- 混合缓存辅助函数 ----------------
/**
*
*
*/
export function clearUserCache(): void {
if (STORAGE_TYPE !== 'localstorage') {
cacheManager.clearUserCache();
}
}
/**
*
*
*/
export async function refreshAllCache(): Promise<void> {
if (STORAGE_TYPE === 'localstorage') return;
try {
// 使用 Promise 缓存防止并发重复刷新
await cacheManager.getOrCreateRequest('refresh-all-cache', async () => {
// 并行刷新所有数据
const [playRecords, favorites, searchHistory, skipConfigs] =
await Promise.allSettled([
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
fetchFromApi<string[]>(`/api/searchhistory`),
fetchFromApi<Record<string, SkipConfig>>(`/api/skipconfigs`),
]);
if (playRecords.status === 'fulfilled') {
cacheManager.cachePlayRecords(playRecords.value);
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: playRecords.value,
})
);
}
2025-08-12 21:50:58 +08:00
if (favorites.status === 'fulfilled') {
cacheManager.cacheFavorites(favorites.value);
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: favorites.value,
})
);
}
2025-08-12 21:50:58 +08:00
if (searchHistory.status === 'fulfilled') {
cacheManager.cacheSearchHistory(searchHistory.value);
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: searchHistory.value,
})
);
}
2025-08-12 21:50:58 +08:00
if (skipConfigs.status === 'fulfilled') {
cacheManager.cacheSkipConfigs(skipConfigs.value);
window.dispatchEvent(
new CustomEvent('skipConfigsUpdated', {
detail: skipConfigs.value,
})
);
}
});
2025-08-12 21:50:58 +08:00
} catch (err) {
console.error('刷新缓存失败:', err);
triggerGlobalError('刷新缓存失败');
}
}
/**
*
*
*/
export function getCacheStatus(): {
hasPlayRecords: boolean;
hasFavorites: boolean;
hasSearchHistory: boolean;
hasSkipConfigs: boolean;
username: string | null;
} {
if (STORAGE_TYPE === 'localstorage') {
return {
hasPlayRecords: false,
hasFavorites: false,
hasSearchHistory: false,
hasSkipConfigs: false,
username: null,
};
}
const authInfo = getAuthInfoFromBrowserCookie();
return {
hasPlayRecords: !!cacheManager.getCachedPlayRecords(),
hasFavorites: !!cacheManager.getCachedFavorites(),
hasSearchHistory: !!cacheManager.getCachedSearchHistory(),
hasSkipConfigs: !!cacheManager.getCachedSkipConfigs(),
username: authInfo?.username || null,
};
}
// ---------------- React Hook 辅助类型 ----------------
export type CacheUpdateEvent =
| 'playRecordsUpdated'
| 'favoritesUpdated'
| 'searchHistoryUpdated'
| 'skipConfigsUpdated';
/**
* React
* 使
*
* useEffect(() => {
* const unsubscribe = subscribeToDataUpdates('playRecordsUpdated', (data) => {
* setPlayRecords(data);
* });
* return unsubscribe;
* }, []);
*/
export function subscribeToDataUpdates<T>(
eventType: CacheUpdateEvent,
callback: (data: T) => void
): () => void {
if (typeof window === 'undefined') {
return () => { };
2025-08-12 21:50:58 +08:00
}
const handleUpdate = (event: CustomEvent) => {
callback(event.detail);
};
window.addEventListener(eventType, handleUpdate as EventListener);
return () => {
window.removeEventListener(eventType, handleUpdate as EventListener);
};
}
/**
*
* 访
*/
export async function preloadUserData(): Promise<void> {
if (STORAGE_TYPE === 'localstorage') return;
// 检查是否已有有效缓存,避免重复请求
const status = getCacheStatus();
if (
status.hasPlayRecords &&
status.hasFavorites &&
status.hasSearchHistory &&
status.hasSkipConfigs
) {
return;
}
// 后台静默预加载,不阻塞界面
refreshAllCache().catch((err) => {
console.warn('预加载用户数据失败:', err);
triggerGlobalError('预加载用户数据失败');
});
}
// ---------------- 跳过片头片尾配置相关 API ----------------
/**
*
* 使
*/
export async function getSkipConfig(
source: string,
id: string
): Promise<SkipConfig | null> {
// 服务器端渲染阶段直接返回空
if (typeof window === 'undefined') {
return null;
}
const key = generateStorageKey(source, id);
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedSkipConfigs();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<Record<string, SkipConfig>>(`/api/skipconfigs`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheSkipConfigs(freshData);
// 触发数据更新事件
window.dispatchEvent(
new CustomEvent('skipConfigsUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步跳过片头片尾配置失败:', err);
});
return cachedData[key] || null;
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<Record<string, SkipConfig>>(
`/api/skipconfigs`
);
cacheManager.cacheSkipConfigs(freshData);
return freshData[key] || null;
} catch (err) {
console.error('获取跳过片头片尾配置失败:', err);
triggerGlobalError('获取跳过片头片尾配置失败');
return null;
}
}
}
// localStorage 模式
try {
const raw = localStorage.getItem('moontv_skip_configs');
if (!raw) return null;
const configs = JSON.parse(raw) as Record<string, SkipConfig>;
return configs[key] || null;
} catch (err) {
console.error('读取跳过片头片尾配置失败:', err);
triggerGlobalError('读取跳过片头片尾配置失败');
return null;
}
}
/**
*
* 使
*/
export async function saveSkipConfig(
source: string,
id: string,
config: SkipConfig
): Promise<void> {
const key = generateStorageKey(source, id);
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedConfigs = cacheManager.getCachedSkipConfigs() || {};
cachedConfigs[key] = config;
cacheManager.cacheSkipConfigs(cachedConfigs);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('skipConfigsUpdated', {
detail: cachedConfigs,
})
);
// 异步同步到数据库
try {
await fetchWithAuth('/api/skipconfigs', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key, config }),
});
} catch (err) {
console.error('保存跳过片头片尾配置失败:', err);
triggerGlobalError('保存跳过片头片尾配置失败');
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') {
console.warn('无法在服务端保存跳过片头片尾配置到 localStorage');
return;
}
try {
const raw = localStorage.getItem('moontv_skip_configs');
const configs = raw ? (JSON.parse(raw) as Record<string, SkipConfig>) : {};
configs[key] = config;
localStorage.setItem('moontv_skip_configs', JSON.stringify(configs));
window.dispatchEvent(
new CustomEvent('skipConfigsUpdated', {
detail: configs,
})
);
} catch (err) {
console.error('保存跳过片头片尾配置失败:', err);
triggerGlobalError('保存跳过片头片尾配置失败');
throw err;
}
}
/**
*
* 使
*/
export async function getAllSkipConfigs(): Promise<Record<string, SkipConfig>> {
// 服务器端渲染阶段直接返回空
if (typeof window === 'undefined') {
return {};
}
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedSkipConfigs();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<Record<string, SkipConfig>>(`/api/skipconfigs`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheSkipConfigs(freshData);
// 触发数据更新事件
window.dispatchEvent(
new CustomEvent('skipConfigsUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步跳过片头片尾配置失败:', err);
triggerGlobalError('后台同步跳过片头片尾配置失败');
});
return cachedData;
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<Record<string, SkipConfig>>(
`/api/skipconfigs`
);
cacheManager.cacheSkipConfigs(freshData);
return freshData;
} catch (err) {
console.error('获取跳过片头片尾配置失败:', err);
triggerGlobalError('获取跳过片头片尾配置失败');
return {};
}
}
}
// localStorage 模式
try {
const raw = localStorage.getItem('moontv_skip_configs');
if (!raw) return {};
return JSON.parse(raw) as Record<string, SkipConfig>;
} catch (err) {
console.error('读取跳过片头片尾配置失败:', err);
triggerGlobalError('读取跳过片头片尾配置失败');
return {};
}
}
/**
*
* 使
*/
export async function deleteSkipConfig(
source: string,
id: string
): Promise<void> {
const key = generateStorageKey(source, id);
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedConfigs = cacheManager.getCachedSkipConfigs() || {};
delete cachedConfigs[key];
cacheManager.cacheSkipConfigs(cachedConfigs);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('skipConfigsUpdated', {
detail: cachedConfigs,
})
);
// 异步同步到数据库
try {
await fetchWithAuth(`/api/skipconfigs?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
});
} catch (err) {
console.error('删除跳过片头片尾配置失败:', err);
triggerGlobalError('删除跳过片头片尾配置失败');
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') {
console.warn('无法在服务端删除跳过片头片尾配置到 localStorage');
return;
}
try {
const raw = localStorage.getItem('moontv_skip_configs');
if (raw) {
const configs = JSON.parse(raw) as Record<string, SkipConfig>;
delete configs[key];
localStorage.setItem('moontv_skip_configs', JSON.stringify(configs));
window.dispatchEvent(
new CustomEvent('skipConfigsUpdated', {
detail: configs,
})
);
}
} catch (err) {
console.error('删除跳过片头片尾配置失败:', err);
triggerGlobalError('删除跳过片头片尾配置失败');
throw err;
}
}
2025-12-03 21:34:05 +08:00
// ---------------- 弹幕过滤配置相关 API ----------------
/**
*
* 使
*/
export async function getDanmakuFilterConfig(): Promise<DanmakuFilterConfig | null> {
// 服务器端渲染阶段直接返回空
if (typeof window === 'undefined') {
return null;
}
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedDanmakuFilterConfig();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<DanmakuFilterConfig>(`/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<DanmakuFilterConfig>(
`/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<void> {
// 数据库存储模式:乐观更新策略(包括 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;
}
}
2025-12-15 00:16:50 +08:00
// ---------------- 集数过滤配置相关 API ----------------
/**
* localStorage
*/
export async function getEpisodeFilterConfig(): Promise<EpisodeFilterConfig | null> {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = localStorage.getItem('moontv_episode_filter_config');
if (!raw) return null;
return JSON.parse(raw) as EpisodeFilterConfig;
} catch (err) {
console.error('读取集数过滤配置失败:', err);
return null;
}
}
/**
* localStorage
*/
export async function saveEpisodeFilterConfig(
config: EpisodeFilterConfig
): Promise<void> {
if (typeof window === 'undefined') {
console.warn('无法在服务端保存集数过滤配置');
return;
}
try {
localStorage.setItem('moontv_episode_filter_config', JSON.stringify(config));
window.dispatchEvent(
new CustomEvent('episodeFilterConfigUpdated', {
detail: config,
})
);
} catch (err) {
console.error('保存集数过滤配置失败:', err);
throw err;
}
}