play页面缓存引入主动清理机制

This commit is contained in:
mtvpls
2026-04-11 22:25:13 +08:00
parent 351a5847b9
commit f4d7565d67
7 changed files with 291 additions and 173 deletions
+2 -2
View File
@@ -8,7 +8,7 @@ import './globals.css';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { listEnabledSourceScripts } from '@/lib/source-script'; import { listEnabledSourceScripts } from '@/lib/source-script';
import { DanmakuCacheCleanup } from '../components/DanmakuCacheCleanup'; import { StartupCacheCleanup } from '../components/DanmakuCacheCleanup';
import { DownloadBubble } from '../components/DownloadBubble'; import { DownloadBubble } from '../components/DownloadBubble';
import { DownloadPanel } from '../components/DownloadPanel'; import { DownloadPanel } from '../components/DownloadPanel';
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator'; import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
@@ -259,7 +259,7 @@ export default async function RootLayout({
<SiteProvider siteName={siteName} announcement={announcement} tmdbApiKey={tmdbApiKey}> <SiteProvider siteName={siteName} announcement={announcement} tmdbApiKey={tmdbApiKey}>
<WatchRoomProvider> <WatchRoomProvider>
<DownloadProvider> <DownloadProvider>
<DanmakuCacheCleanup /> <StartupCacheCleanup />
{children} {children}
<GlobalErrorIndicator /> <GlobalErrorIndicator />
<ChatFloatingWindow /> <ChatFloatingWindow />
+25 -58
View File
@@ -47,6 +47,11 @@ import {
} from '@/lib/db.client'; } from '@/lib/db.client';
import { getDoubanDetail } from '@/lib/douban.client'; import { getDoubanDetail } from '@/lib/douban.client';
import { getTMDBImageUrl } from '@/lib/tmdb.search'; import { getTMDBImageUrl } from '@/lib/tmdb.search';
import {
getRecommendationCache,
recommendationCacheKeys,
setRecommendationCache,
} from '@/lib/recommendations/cache';
import { DanmakuFilterConfig, EpisodeFilterConfig, SearchResult } from '@/lib/types'; import { DanmakuFilterConfig, EpisodeFilterConfig, SearchResult } from '@/lib/types';
import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
import { useEnableAIComments } from '@/hooks/useEnableAIComments'; import { useEnableAIComments } from '@/hooks/useEnableAIComments';
@@ -1175,52 +1180,27 @@ function PlayPageClient() {
} }
try { try {
// 检查title到tmdbId的映射缓存(1个月) const mappingCacheKey = recommendationCacheKeys.tmdbTitleMapping(videoTitle);
const mappingCacheKey = `tmdb_title_mapping_${videoTitle}`; const cachedId = getRecommendationCache<string>(mappingCacheKey);
const mappingCache = localStorage.getItem(mappingCacheKey);
let cachedId: string | null = null;
if (mappingCache) { if (cachedId) {
try { console.log('使用缓存的TMDB ID映射');
const { tmdbId, timestamp } = JSON.parse(mappingCache);
const cacheAge = Date.now() - timestamp;
const cacheMaxAge = 30 * 24 * 60 * 60 * 1000; // 1个月
if (cacheAge < cacheMaxAge && tmdbId) { const detailsCacheKey = recommendationCacheKeys.tmdbDetails(cachedId);
console.log('使用缓存的TMDB ID映射'); const detailsCache = getRecommendationCache<any>(detailsCacheKey);
cachedId = tmdbId;
// 检查TMDB详情缓存(1天) if (detailsCache) {
const detailsCacheKey = `tmdb_details_${tmdbId}`; if (detailsCache.backdrop) {
const detailsCache = localStorage.getItem(detailsCacheKey); setTmdbBackdrop(processImageUrl(detailsCache.backdrop));
} else {
if (detailsCache) { setTmdbBackdrop(null);
try {
const { data, timestamp: detTimestamp } = JSON.parse(detailsCache);
const detCacheAge = Date.now() - detTimestamp;
const detCacheMaxAge = 24 * 60 * 60 * 1000; // 1天
if (detCacheAge < detCacheMaxAge && data) {
if (data.backdrop) {
setTmdbBackdrop(processImageUrl(data.backdrop));
} else {
setTmdbBackdrop(null);
}
// 如果没有豆瓣ID,使用TMDb数据补充
if (!videoDoubanId || videoDoubanId === 0) {
populateDoubanFieldsFromTMDB(data);
}
populatePlayMetadataFromTMDB(data);
return;
}
} catch (e) {
console.error('解析详情缓存失败:', e);
}
}
} }
} catch (e) {
console.error('解析映射缓存失败:', e); if (!videoDoubanId || videoDoubanId === 0) {
populateDoubanFieldsFromTMDB(detailsCache);
}
populatePlayMetadataFromTMDB(detailsCache);
return;
} }
} }
@@ -1253,23 +1233,10 @@ function PlayPageClient() {
// 保存title到tmdbId的映射到localStorage1个月) // 保存title到tmdbId的映射到localStorage1个月)
if (result.tmdbId) { if (result.tmdbId) {
try { try {
localStorage.setItem( setRecommendationCache(mappingCacheKey, String(result.tmdbId));
mappingCacheKey,
JSON.stringify({
tmdbId: result.tmdbId,
timestamp: Date.now(),
})
);
// 保存TMDB详情数据到localStorage1天) const detailsCacheKey = recommendationCacheKeys.tmdbDetails(result.tmdbId);
const detailsCacheKey = `tmdb_details_${result.tmdbId}`; setRecommendationCache(detailsCacheKey, result);
localStorage.setItem(
detailsCacheKey,
JSON.stringify({
data: result,
timestamp: Date.now(),
})
);
} catch (e) { } catch (e) {
console.error('保存缓存失败:', e); console.error('保存缓存失败:', e);
} }
+5 -7
View File
@@ -2,18 +2,16 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { initDanmakuModule } from '@/lib/danmaku/api'; import { initStartupCacheCleanup } from '@/lib/startup/cacheCleanup';
/** /**
* 弹幕缓存清理组件 * 启动缓存清理组件
* 在应用启动时执行一次过期缓存清理 * 在应用启动时异步执行一次缓存清理
*/ */
export function DanmakuCacheCleanup() { export function StartupCacheCleanup() {
useEffect(() => { useEffect(() => {
// 只在客户端执行一次 initStartupCacheCleanup();
initDanmakuModule();
}, []); }, []);
// 这个组件不渲染任何内容
return null; return null;
} }
+13 -26
View File
@@ -7,6 +7,12 @@ import { useEnableComments } from '@/hooks/useEnableComments';
import ScrollableRow from '@/components/ScrollableRow'; import ScrollableRow from '@/components/ScrollableRow';
import VideoCard from '@/components/VideoCard'; import VideoCard from '@/components/VideoCard';
import {
getRecommendationCache,
recommendationCacheKeys,
setRecommendationCache,
} from '@/lib/recommendations/cache';
interface DoubanRecommendation { interface DoubanRecommendation {
doubanId: string; doubanId: string;
title: string; title: string;
@@ -31,25 +37,14 @@ export default function DoubanRecommendations({ doubanId }: DoubanRecommendation
setLoading(true); setLoading(true);
setError(null); setError(null);
// 检查localStorage缓存 const cacheKey = recommendationCacheKeys.doubanRecommendations(doubanId);
const cacheKey = `douban_recommendations_${doubanId}`; const cached = getRecommendationCache<DoubanRecommendation[]>(cacheKey);
const cached = localStorage.getItem(cacheKey);
if (cached) { if (cached) {
try { console.log('使用缓存的推荐数据');
const { data, timestamp } = JSON.parse(cached); setRecommendations(cached);
const cacheAge = Date.now() - timestamp; setLoading(false);
const cacheMaxAge = 7 * 24 * 60 * 60 * 1000; // 7天 return;
if (cacheAge < cacheMaxAge) {
console.log('使用缓存的推荐数据');
setRecommendations(data);
setLoading(false);
return;
}
} catch (e) {
console.error('解析缓存失败:', e);
}
} }
const response = await fetch( const response = await fetch(
@@ -66,15 +61,7 @@ export default function DoubanRecommendations({ doubanId }: DoubanRecommendation
const recommendationsData = result.recommendations || []; const recommendationsData = result.recommendations || [];
setRecommendations(recommendationsData); setRecommendations(recommendationsData);
// 保存到localStorage setRecommendationCache(cacheKey, recommendationsData);
try {
localStorage.setItem(cacheKey, JSON.stringify({
data: recommendationsData,
timestamp: Date.now()
}));
} catch (e) {
console.error('保存缓存失败:', e);
}
} catch (err) { } catch (err) {
console.error('获取推荐失败:', err); console.error('获取推荐失败:', err);
setError(err instanceof Error ? err.message : '获取推荐失败'); setError(err instanceof Error ? err.message : '获取推荐失败');
+27 -80
View File
@@ -8,6 +8,12 @@ import { useRecommendationDataSource } from '@/hooks/useRecommendationDataSource
import ScrollableRow from '@/components/ScrollableRow'; import ScrollableRow from '@/components/ScrollableRow';
import VideoCard from '@/components/VideoCard'; import VideoCard from '@/components/VideoCard';
import {
getRecommendationCache,
recommendationCacheKeys,
setRecommendationCache,
} from '@/lib/recommendations/cache';
interface Recommendation { interface Recommendation {
doubanId?: string; doubanId?: string;
tmdbId?: number; tmdbId?: number;
@@ -63,25 +69,14 @@ export default function SmartRecommendations({
setLoading(true); setLoading(true);
setError(null); setError(null);
// 检查localStorage缓存 const cacheKey = recommendationCacheKeys.doubanRecommendations(doubanId);
const cacheKey = `douban_recommendations_${doubanId}`; const cached = getRecommendationCache<Recommendation[]>(cacheKey);
const cached = localStorage.getItem(cacheKey);
if (cached) { if (cached) {
try { console.log('使用缓存的豆瓣推荐数据');
const { data, timestamp } = JSON.parse(cached); setRecommendations(cached);
const cacheAge = Date.now() - timestamp; setLoading(false);
const cacheMaxAge = 7 * 24 * 60 * 60 * 1000; // 7天 return;
if (cacheAge < cacheMaxAge) {
console.log('使用缓存的豆瓣推荐数据');
setRecommendations(data);
setLoading(false);
return;
}
} catch (e) {
console.error('解析缓存失败:', e);
}
} }
const response = await fetch(`/api/douban-recommendations?id=${doubanId}`); const response = await fetch(`/api/douban-recommendations?id=${doubanId}`);
@@ -94,18 +89,7 @@ export default function SmartRecommendations({
const recommendationsData = result.recommendations || []; const recommendationsData = result.recommendations || [];
setRecommendations(recommendationsData); setRecommendations(recommendationsData);
// 保存到localStorage setRecommendationCache(cacheKey, recommendationsData);
try {
localStorage.setItem(
cacheKey,
JSON.stringify({
data: recommendationsData,
timestamp: Date.now(),
})
);
} catch (e) {
console.error('保存缓存失败:', e);
}
} catch (err) { } catch (err) {
console.error('获取豆瓣推荐失败:', err); console.error('获取豆瓣推荐失败:', err);
setError(err instanceof Error ? err.message : '获取推荐失败'); setError(err instanceof Error ? err.message : '获取推荐失败');
@@ -122,44 +106,20 @@ export default function SmartRecommendations({
setLoading(true); setLoading(true);
setError(null); setError(null);
// 检查title到tmdbId的映射缓存(1个月) const mappingCacheKey = recommendationCacheKeys.tmdbTitleMapping(videoTitle);
const mappingCacheKey = `tmdb_title_mapping_${videoTitle}`; const cachedId = getRecommendationCache<string>(mappingCacheKey);
const mappingCache = localStorage.getItem(mappingCacheKey);
let cachedId: string | null = null;
if (mappingCache) { if (cachedId) {
try { console.log('使用缓存的TMDB ID映射');
const { tmdbId, timestamp } = JSON.parse(mappingCache);
const cacheAge = Date.now() - timestamp;
const cacheMaxAge = 30 * 24 * 60 * 60 * 1000; // 1个月
if (cacheAge < cacheMaxAge && tmdbId) { const recommendationsCacheKey = recommendationCacheKeys.tmdbRecommendations(cachedId);
console.log('使用缓存的TMDB ID映射'); const recommendationsCache = getRecommendationCache<Recommendation[]>(recommendationsCacheKey);
cachedId = tmdbId;
// 检查TMDB推荐数据缓存(1天) if (recommendationsCache) {
const recommendationsCacheKey = `tmdb_recommendations_${tmdbId}`; console.log('使用缓存的TMDB推荐数据');
const recommendationsCache = localStorage.getItem(recommendationsCacheKey); setRecommendations(recommendationsCache);
setLoading(false);
if (recommendationsCache) { return;
try {
const { data, timestamp: recTimestamp } = JSON.parse(recommendationsCache);
const recCacheAge = Date.now() - recTimestamp;
const recCacheMaxAge = 24 * 60 * 60 * 1000; // 1天
if (recCacheAge < recCacheMaxAge && data) {
console.log('使用缓存的TMDB推荐数据');
setRecommendations(data);
setLoading(false);
return;
}
} catch (e) {
console.error('解析推荐缓存失败:', e);
}
}
}
} catch (e) {
console.error('解析映射缓存失败:', e);
} }
} }
@@ -181,23 +141,10 @@ export default function SmartRecommendations({
// 保存title到tmdbId的映射到localStorage1个月) // 保存title到tmdbId的映射到localStorage1个月)
if (result.tmdbId) { if (result.tmdbId) {
try { try {
localStorage.setItem( setRecommendationCache(mappingCacheKey, String(result.tmdbId));
mappingCacheKey,
JSON.stringify({
tmdbId: result.tmdbId,
timestamp: Date.now(),
})
);
// 保存TMDB推荐数据到localStorage1天) const recommendationsCacheKey = recommendationCacheKeys.tmdbRecommendations(result.tmdbId);
const recommendationsCacheKey = `tmdb_recommendations_${result.tmdbId}`; setRecommendationCache(recommendationsCacheKey, recommendationsData);
localStorage.setItem(
recommendationsCacheKey,
JSON.stringify({
data: recommendationsData,
timestamp: Date.now(),
})
);
} catch (e) { } catch (e) {
console.error('保存缓存失败:', e); console.error('保存缓存失败:', e);
} }
+204
View File
@@ -0,0 +1,204 @@
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const RECOMMENDATION_CACHE_CONFIG = {
doubanRecommendations: {
prefix: 'douban_recommendations_',
ttlMs: 7 * DAY_IN_MS,
},
tmdbTitleMapping: {
prefix: 'tmdb_title_mapping_',
ttlMs: 30 * DAY_IN_MS,
},
tmdbRecommendations: {
prefix: 'tmdb_recommendations_',
ttlMs: DAY_IN_MS,
},
tmdbDetails: {
prefix: 'tmdb_details_',
ttlMs: DAY_IN_MS,
},
} as const;
type RecommendationCacheType = keyof typeof RECOMMENDATION_CACHE_CONFIG;
interface RecommendationCacheEntry<T> {
value: T;
timestamp: number;
}
let recommendationCacheCleanupInitialized = false;
export const recommendationCacheKeys = {
doubanRecommendations: (doubanId: string | number) =>
`${RECOMMENDATION_CACHE_CONFIG.doubanRecommendations.prefix}${doubanId}`,
tmdbTitleMapping: (title: string) =>
`${RECOMMENDATION_CACHE_CONFIG.tmdbTitleMapping.prefix}${title}`,
tmdbRecommendations: (tmdbId: string | number) =>
`${RECOMMENDATION_CACHE_CONFIG.tmdbRecommendations.prefix}${tmdbId}`,
tmdbDetails: (tmdbId: string | number) =>
`${RECOMMENDATION_CACHE_CONFIG.tmdbDetails.prefix}${tmdbId}`,
};
function scheduleCleanup(task: () => void): void {
if (typeof window === 'undefined') return;
if ('requestIdleCallback' in window) {
window.requestIdleCallback(() => task());
return;
}
setTimeout(task, 0);
}
function getCacheTypeForKey(key: string): RecommendationCacheType | null {
for (const [type, config] of Object.entries(RECOMMENDATION_CACHE_CONFIG) as Array<
[RecommendationCacheType, (typeof RECOMMENDATION_CACHE_CONFIG)[RecommendationCacheType]]
>) {
if (key.startsWith(config.prefix)) {
return type;
}
}
return null;
}
function parseCacheEntry<T>(rawValue: string | null): RecommendationCacheEntry<T> | null {
if (!rawValue) return null;
const parsed = JSON.parse(rawValue) as {
value?: T;
data?: T;
tmdbId?: T;
timestamp?: number;
};
if (typeof parsed.timestamp !== 'number' || Number.isNaN(parsed.timestamp)) {
return null;
}
if ('value' in parsed) {
return {
value: parsed.value as T,
timestamp: parsed.timestamp,
};
}
if ('data' in parsed) {
return {
value: parsed.data as T,
timestamp: parsed.timestamp,
};
}
if ('tmdbId' in parsed) {
return {
value: parsed.tmdbId as T,
timestamp: parsed.timestamp,
};
}
return null;
}
function isExpired(type: RecommendationCacheType, timestamp: number): boolean {
return Date.now() - timestamp >= RECOMMENDATION_CACHE_CONFIG[type].ttlMs;
}
export function getRecommendationCache<T>(
key: string
): T | null {
if (typeof window === 'undefined') return null;
const cacheType = getCacheTypeForKey(key);
if (!cacheType) return null;
try {
const entry = parseCacheEntry<T>(localStorage.getItem(key));
if (!entry) {
localStorage.removeItem(key);
return null;
}
if (isExpired(cacheType, entry.timestamp)) {
localStorage.removeItem(key);
return null;
}
return entry.value;
} catch (error) {
console.error('读取推荐缓存失败:', error);
localStorage.removeItem(key);
return null;
}
}
export function setRecommendationCache<T>(key: string, value: T): void {
if (typeof window === 'undefined') return;
try {
const entry: RecommendationCacheEntry<T> = {
value,
timestamp: Date.now(),
};
localStorage.setItem(key, JSON.stringify(entry));
} catch (error) {
console.error('保存推荐缓存失败:', error);
}
}
export function clearRecommendationCache(key: string): void {
if (typeof window === 'undefined') return;
localStorage.removeItem(key);
}
export async function clearExpiredRecommendationCaches(): Promise<number> {
if (typeof window === 'undefined') return 0;
try {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key) continue;
const cacheType = getCacheTypeForKey(key);
if (!cacheType) continue;
try {
const entry = parseCacheEntry<unknown>(localStorage.getItem(key));
if (!entry || isExpired(cacheType, entry.timestamp)) {
keysToRemove.push(key);
}
} catch {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
return keysToRemove.length;
} catch (error) {
console.error('清理推荐缓存失败:', error);
return 0;
}
}
export function initRecommendationCacheModule(): void {
if (typeof window === 'undefined' || recommendationCacheCleanupInitialized) {
return;
}
recommendationCacheCleanupInitialized = true;
scheduleCleanup(() => {
void clearExpiredRecommendationCaches()
.then((count) => {
if (count > 0) {
console.log(`[推荐缓存] 启动清理: 已删除 ${count} 个过期缓存`);
}
})
.catch((error) => {
console.error('[推荐缓存] 清理失败:', error);
});
});
}
+15
View File
@@ -0,0 +1,15 @@
import { initDanmakuModule } from '@/lib/danmaku/api';
import { initRecommendationCacheModule } from '@/lib/recommendations/cache';
let startupCacheCleanupInitialized = false;
export function initStartupCacheCleanup(): void {
if (typeof window === 'undefined' || startupCacheCleanupInitialized) {
return;
}
startupCacheCleanupInitialized = true;
initDanmakuModule();
initRecommendationCacheModule();
}