diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1652fc2..54378f6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,7 +8,7 @@ import './globals.css'; import { getConfig } from '@/lib/config'; import { listEnabledSourceScripts } from '@/lib/source-script'; -import { DanmakuCacheCleanup } from '../components/DanmakuCacheCleanup'; +import { StartupCacheCleanup } from '../components/DanmakuCacheCleanup'; import { DownloadBubble } from '../components/DownloadBubble'; import { DownloadPanel } from '../components/DownloadPanel'; import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator'; @@ -259,7 +259,7 @@ export default async function RootLayout({ - + {children} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 6ceff05..28cbb2e 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -47,6 +47,11 @@ import { } from '@/lib/db.client'; import { getDoubanDetail } from '@/lib/douban.client'; import { getTMDBImageUrl } from '@/lib/tmdb.search'; +import { + getRecommendationCache, + recommendationCacheKeys, + setRecommendationCache, +} from '@/lib/recommendations/cache'; import { DanmakuFilterConfig, EpisodeFilterConfig, SearchResult } from '@/lib/types'; import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; import { useEnableAIComments } from '@/hooks/useEnableAIComments'; @@ -1175,52 +1180,27 @@ function PlayPageClient() { } try { - // 检查title到tmdbId的映射缓存(1个月) - const mappingCacheKey = `tmdb_title_mapping_${videoTitle}`; - const mappingCache = localStorage.getItem(mappingCacheKey); - let cachedId: string | null = null; + const mappingCacheKey = recommendationCacheKeys.tmdbTitleMapping(videoTitle); + const cachedId = getRecommendationCache(mappingCacheKey); - if (mappingCache) { - try { - const { tmdbId, timestamp } = JSON.parse(mappingCache); - const cacheAge = Date.now() - timestamp; - const cacheMaxAge = 30 * 24 * 60 * 60 * 1000; // 1个月 + if (cachedId) { + console.log('使用缓存的TMDB ID映射'); - if (cacheAge < cacheMaxAge && tmdbId) { - console.log('使用缓存的TMDB ID映射'); - cachedId = tmdbId; + const detailsCacheKey = recommendationCacheKeys.tmdbDetails(cachedId); + const detailsCache = getRecommendationCache(detailsCacheKey); - // 检查TMDB详情缓存(1天) - const detailsCacheKey = `tmdb_details_${tmdbId}`; - const detailsCache = localStorage.getItem(detailsCacheKey); - - if (detailsCache) { - 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); - } - } + if (detailsCache) { + if (detailsCache.backdrop) { + setTmdbBackdrop(processImageUrl(detailsCache.backdrop)); + } else { + setTmdbBackdrop(null); } - } catch (e) { - console.error('解析映射缓存失败:', e); + + if (!videoDoubanId || videoDoubanId === 0) { + populateDoubanFieldsFromTMDB(detailsCache); + } + populatePlayMetadataFromTMDB(detailsCache); + return; } } @@ -1253,23 +1233,10 @@ function PlayPageClient() { // 保存title到tmdbId的映射到localStorage(1个月) if (result.tmdbId) { try { - localStorage.setItem( - mappingCacheKey, - JSON.stringify({ - tmdbId: result.tmdbId, - timestamp: Date.now(), - }) - ); + setRecommendationCache(mappingCacheKey, String(result.tmdbId)); - // 保存TMDB详情数据到localStorage(1天) - const detailsCacheKey = `tmdb_details_${result.tmdbId}`; - localStorage.setItem( - detailsCacheKey, - JSON.stringify({ - data: result, - timestamp: Date.now(), - }) - ); + const detailsCacheKey = recommendationCacheKeys.tmdbDetails(result.tmdbId); + setRecommendationCache(detailsCacheKey, result); } catch (e) { console.error('保存缓存失败:', e); } diff --git a/src/components/DanmakuCacheCleanup.tsx b/src/components/DanmakuCacheCleanup.tsx index 1a58cf9..b2ffb97 100644 --- a/src/components/DanmakuCacheCleanup.tsx +++ b/src/components/DanmakuCacheCleanup.tsx @@ -2,18 +2,16 @@ import { useEffect } from 'react'; -import { initDanmakuModule } from '@/lib/danmaku/api'; +import { initStartupCacheCleanup } from '@/lib/startup/cacheCleanup'; /** - * 弹幕缓存清理组件 - * 在应用启动时执行一次过期缓存清理 + * 启动缓存清理组件 + * 在应用启动时异步执行一次缓存清理 */ -export function DanmakuCacheCleanup() { +export function StartupCacheCleanup() { useEffect(() => { - // 只在客户端执行一次 - initDanmakuModule(); + initStartupCacheCleanup(); }, []); - // 这个组件不渲染任何内容 return null; } diff --git a/src/components/DoubanRecommendations.tsx b/src/components/DoubanRecommendations.tsx index aa13709..1320f6c 100644 --- a/src/components/DoubanRecommendations.tsx +++ b/src/components/DoubanRecommendations.tsx @@ -7,6 +7,12 @@ import { useEnableComments } from '@/hooks/useEnableComments'; import ScrollableRow from '@/components/ScrollableRow'; import VideoCard from '@/components/VideoCard'; +import { + getRecommendationCache, + recommendationCacheKeys, + setRecommendationCache, +} from '@/lib/recommendations/cache'; + interface DoubanRecommendation { doubanId: string; title: string; @@ -31,25 +37,14 @@ export default function DoubanRecommendations({ doubanId }: DoubanRecommendation setLoading(true); setError(null); - // 检查localStorage缓存 - const cacheKey = `douban_recommendations_${doubanId}`; - const cached = localStorage.getItem(cacheKey); + const cacheKey = recommendationCacheKeys.doubanRecommendations(doubanId); + const cached = getRecommendationCache(cacheKey); if (cached) { - try { - const { data, timestamp } = JSON.parse(cached); - const cacheAge = Date.now() - timestamp; - const cacheMaxAge = 7 * 24 * 60 * 60 * 1000; // 7天 - - if (cacheAge < cacheMaxAge) { - console.log('使用缓存的推荐数据'); - setRecommendations(data); - setLoading(false); - return; - } - } catch (e) { - console.error('解析缓存失败:', e); - } + console.log('使用缓存的推荐数据'); + setRecommendations(cached); + setLoading(false); + return; } const response = await fetch( @@ -66,15 +61,7 @@ export default function DoubanRecommendations({ doubanId }: DoubanRecommendation const recommendationsData = result.recommendations || []; setRecommendations(recommendationsData); - // 保存到localStorage - try { - localStorage.setItem(cacheKey, JSON.stringify({ - data: recommendationsData, - timestamp: Date.now() - })); - } catch (e) { - console.error('保存缓存失败:', e); - } + setRecommendationCache(cacheKey, recommendationsData); } catch (err) { console.error('获取推荐失败:', err); setError(err instanceof Error ? err.message : '获取推荐失败'); diff --git a/src/components/SmartRecommendations.tsx b/src/components/SmartRecommendations.tsx index e8b2db4..0e0562e 100644 --- a/src/components/SmartRecommendations.tsx +++ b/src/components/SmartRecommendations.tsx @@ -8,6 +8,12 @@ import { useRecommendationDataSource } from '@/hooks/useRecommendationDataSource import ScrollableRow from '@/components/ScrollableRow'; import VideoCard from '@/components/VideoCard'; +import { + getRecommendationCache, + recommendationCacheKeys, + setRecommendationCache, +} from '@/lib/recommendations/cache'; + interface Recommendation { doubanId?: string; tmdbId?: number; @@ -63,25 +69,14 @@ export default function SmartRecommendations({ setLoading(true); setError(null); - // 检查localStorage缓存 - const cacheKey = `douban_recommendations_${doubanId}`; - const cached = localStorage.getItem(cacheKey); + const cacheKey = recommendationCacheKeys.doubanRecommendations(doubanId); + const cached = getRecommendationCache(cacheKey); if (cached) { - try { - const { data, timestamp } = JSON.parse(cached); - const cacheAge = Date.now() - timestamp; - const cacheMaxAge = 7 * 24 * 60 * 60 * 1000; // 7天 - - if (cacheAge < cacheMaxAge) { - console.log('使用缓存的豆瓣推荐数据'); - setRecommendations(data); - setLoading(false); - return; - } - } catch (e) { - console.error('解析缓存失败:', e); - } + console.log('使用缓存的豆瓣推荐数据'); + setRecommendations(cached); + setLoading(false); + return; } const response = await fetch(`/api/douban-recommendations?id=${doubanId}`); @@ -94,18 +89,7 @@ export default function SmartRecommendations({ const recommendationsData = result.recommendations || []; setRecommendations(recommendationsData); - // 保存到localStorage - try { - localStorage.setItem( - cacheKey, - JSON.stringify({ - data: recommendationsData, - timestamp: Date.now(), - }) - ); - } catch (e) { - console.error('保存缓存失败:', e); - } + setRecommendationCache(cacheKey, recommendationsData); } catch (err) { console.error('获取豆瓣推荐失败:', err); setError(err instanceof Error ? err.message : '获取推荐失败'); @@ -122,44 +106,20 @@ export default function SmartRecommendations({ setLoading(true); setError(null); - // 检查title到tmdbId的映射缓存(1个月) - const mappingCacheKey = `tmdb_title_mapping_${videoTitle}`; - const mappingCache = localStorage.getItem(mappingCacheKey); - let cachedId: string | null = null; + const mappingCacheKey = recommendationCacheKeys.tmdbTitleMapping(videoTitle); + const cachedId = getRecommendationCache(mappingCacheKey); - if (mappingCache) { - try { - const { tmdbId, timestamp } = JSON.parse(mappingCache); - const cacheAge = Date.now() - timestamp; - const cacheMaxAge = 30 * 24 * 60 * 60 * 1000; // 1个月 + if (cachedId) { + console.log('使用缓存的TMDB ID映射'); - if (cacheAge < cacheMaxAge && tmdbId) { - console.log('使用缓存的TMDB ID映射'); - cachedId = tmdbId; + const recommendationsCacheKey = recommendationCacheKeys.tmdbRecommendations(cachedId); + const recommendationsCache = getRecommendationCache(recommendationsCacheKey); - // 检查TMDB推荐数据缓存(1天) - const recommendationsCacheKey = `tmdb_recommendations_${tmdbId}`; - const recommendationsCache = localStorage.getItem(recommendationsCacheKey); - - if (recommendationsCache) { - 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); + if (recommendationsCache) { + console.log('使用缓存的TMDB推荐数据'); + setRecommendations(recommendationsCache); + setLoading(false); + return; } } @@ -181,23 +141,10 @@ export default function SmartRecommendations({ // 保存title到tmdbId的映射到localStorage(1个月) if (result.tmdbId) { try { - localStorage.setItem( - mappingCacheKey, - JSON.stringify({ - tmdbId: result.tmdbId, - timestamp: Date.now(), - }) - ); + setRecommendationCache(mappingCacheKey, String(result.tmdbId)); - // 保存TMDB推荐数据到localStorage(1天) - const recommendationsCacheKey = `tmdb_recommendations_${result.tmdbId}`; - localStorage.setItem( - recommendationsCacheKey, - JSON.stringify({ - data: recommendationsData, - timestamp: Date.now(), - }) - ); + const recommendationsCacheKey = recommendationCacheKeys.tmdbRecommendations(result.tmdbId); + setRecommendationCache(recommendationsCacheKey, recommendationsData); } catch (e) { console.error('保存缓存失败:', e); } diff --git a/src/lib/recommendations/cache.ts b/src/lib/recommendations/cache.ts new file mode 100644 index 0000000..5dcc52a --- /dev/null +++ b/src/lib/recommendations/cache.ts @@ -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 { + 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(rawValue: string | null): RecommendationCacheEntry | 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( + key: string +): T | null { + if (typeof window === 'undefined') return null; + + const cacheType = getCacheTypeForKey(key); + if (!cacheType) return null; + + try { + const entry = parseCacheEntry(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(key: string, value: T): void { + if (typeof window === 'undefined') return; + + try { + const entry: RecommendationCacheEntry = { + 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 { + 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(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); + }); + }); +} diff --git a/src/lib/startup/cacheCleanup.ts b/src/lib/startup/cacheCleanup.ts new file mode 100644 index 0000000..b0b0d62 --- /dev/null +++ b/src/lib/startup/cacheCleanup.ts @@ -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(); +}