From 2e7484723e354d254bc68d0e87c2c4dba571ac73 Mon Sep 17 00:00:00 2001 From: ShiGuangAlex Date: Tue, 14 Apr 2026 23:34:10 +0800 Subject: [PATCH] Limit local episode progress cache --- src/app/play/page.tsx | 82 +++---------- src/components/EpisodeSelector.tsx | 12 +- src/lib/episode-progress.ts | 182 +++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 69 deletions(-) create mode 100644 src/lib/episode-progress.ts diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 0017719..e74c42d 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -45,6 +45,11 @@ import { saveSkipConfig, subscribeToDataUpdates, } from '@/lib/db.client'; +import { + loadLocalEpisodeProgress, + pruneLocalEpisodeProgressStorage, + saveLocalEpisodeProgress, +} from '@/lib/episode-progress'; import { getDoubanDetail } from '@/lib/douban.client'; import { getTMDBImageUrl } from '@/lib/tmdb.search'; import { DanmakuFilterConfig, EpisodeFilterConfig, SearchResult } from '@/lib/types'; @@ -748,10 +753,11 @@ function PlayPageClient() { return; } - // 检查是否禁用了自动装填弹幕 - const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; - if (disableAutoLoad) { - console.log('[弹幕] 已禁用自动装填弹幕,跳过自动加载'); + // 检查是否禁用了自动加载弹幕 + if (isDanmakuAutoLoadDisabled()) { + console.log('[弹幕] 已禁用自动加载弹幕,跳过自动加载'); + setShowDanmakuSourceSelector(false); + setDanmakuLoading(false); return; } @@ -1564,63 +1570,6 @@ function PlayPageClient() { return (window as any).RUNTIME_CONFIG?.DANMAKU_AUTO_LOAD_DEFAULT === false; }; - const getEpisodeProgressStorageKey = ( - source: string, - id: string, - episodeIndex: number - ) => `moontv_episode_progress:${source}+${id}:${episodeIndex}`; - - const loadLocalEpisodeProgress = ( - source: string, - id: string, - episodeIndex: number - ): number | null => { - if (typeof window === 'undefined') { - return null; - } - - try { - const raw = localStorage.getItem( - getEpisodeProgressStorageKey(source, id, episodeIndex) - ); - if (!raw) { - return null; - } - - const parsed = JSON.parse(raw) as { playTime?: number }; - const playTime = Number(parsed.playTime); - return Number.isFinite(playTime) && playTime > 1 ? playTime : null; - } catch (error) { - console.warn('[Play] Failed to load local episode progress:', error); - return null; - } - }; - - const saveLocalEpisodeProgress = ( - source: string, - id: string, - episodeIndex: number, - playTime: number, - totalTime: number - ) => { - if (typeof window === 'undefined' || !Number.isFinite(playTime) || playTime <= 0) { - return; - } - - try { - localStorage.setItem( - getEpisodeProgressStorageKey(source, id, episodeIndex), - JSON.stringify({ - playTime: Math.floor(playTime), - totalTime: Math.floor(totalTime), - updatedAt: Date.now(), - }) - ); - } catch (error) { - console.warn('[Play] Failed to save local episode progress:', error); - } - }; - // 用于记录是否需要在播放器 ready 后跳转到指定进度 const resumeTimeRef = useRef(null); // 播放记录跳转按钮状态 @@ -1642,6 +1591,14 @@ function PlayPageClient() { ); const [backgroundSourcesLoading, setBackgroundSourcesLoading] = useState(false); + useEffect(() => { + try { + pruneLocalEpisodeProgressStorage(); + } catch (error) { + console.warn('[Play] Failed to prune local episode progress:', error); + } + }, []); + // 优选和测速开关 const [optimizationEnabled] = useState(() => { if (typeof window !== 'undefined') { @@ -4779,8 +4736,7 @@ function PlayPageClient() { const preloadNextEpisodeDanmaku = async () => { try { if (isDirectPlay) return; - const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; - if (disableAutoLoad) return; + if (isDanmakuAutoLoadDisabled()) return; const title = videoTitleRef.current; if (!title) { diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index 430711c..fefc0e0 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -12,6 +12,7 @@ import React, { import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types'; import { generateStorageKey, getCachedPlayRecordsSnapshot } from '@/lib/db.client'; +import { loadLocalEpisodeProgressRecord } from '@/lib/episode-progress'; import { EpisodeFilterConfig,SearchResult } from '@/lib/types'; import { getVideoResolutionFromM3u8 } from '@/lib/utils'; @@ -146,13 +147,12 @@ const EpisodeSelector: React.FC = ({ for (let episodeNumber = 1; episodeNumber <= totalEpisodes; episodeNumber++) { try { - const raw = localStorage.getItem( - `moontv_episode_progress:${currentSource}+${currentId}:${episodeNumber - 1}` + const record = loadLocalEpisodeProgressRecord( + currentSource, + currentId, + episodeNumber - 1 ); - if (!raw) continue; - - const parsed = JSON.parse(raw) as { playTime?: number }; - if (Number(parsed.playTime) > 1) { + if (Number(record?.playTime) > 1) { watched.add(episodeNumber); } } catch (error) { diff --git a/src/lib/episode-progress.ts b/src/lib/episode-progress.ts new file mode 100644 index 0000000..2063b4b --- /dev/null +++ b/src/lib/episode-progress.ts @@ -0,0 +1,182 @@ +const EPISODE_PROGRESS_PREFIX = 'moontv_episode_progress:'; +const EPISODE_PROGRESS_MAX_ENTRIES = 200; +const EPISODE_PROGRESS_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 120; + +interface LocalEpisodeProgressRecord { + playTime: number; + totalTime: number; + updatedAt: number; +} + +function isBrowser() { + return typeof window !== 'undefined'; +} + +function isQuotaExceededError(error: unknown) { + return ( + error instanceof DOMException && + (error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED') + ); +} + +function parseEpisodeProgressRecord(raw: string | null): LocalEpisodeProgressRecord | null { + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as Partial; + const playTime = Number(parsed.playTime); + const totalTime = Number(parsed.totalTime); + const updatedAt = Number(parsed.updatedAt); + + if (!Number.isFinite(playTime) || playTime <= 0) { + return null; + } + + return { + playTime, + totalTime: Number.isFinite(totalTime) && totalTime >= 0 ? totalTime : 0, + updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : 0, + }; + } catch { + return null; + } +} + +function collectEpisodeProgressEntries() { + if (!isBrowser()) { + return []; + } + + const entries: Array<{ key: string; record: LocalEpisodeProgressRecord }> = []; + const keys = Array.from({ length: localStorage.length }, (_, index) => + localStorage.key(index) + ).filter((key): key is string => Boolean(key)); + + for (const key of keys) { + if (!key.startsWith(EPISODE_PROGRESS_PREFIX)) { + continue; + } + + const record = parseEpisodeProgressRecord(localStorage.getItem(key)); + if (!record) { + localStorage.removeItem(key); + continue; + } + + entries.push({ key, record }); + } + + return entries; +} + +export function getEpisodeProgressStorageKey( + source: string, + id: string, + episodeIndex: number +) { + return `${EPISODE_PROGRESS_PREFIX}${source}+${id}:${episodeIndex}`; +} + +export function loadLocalEpisodeProgressRecord( + source: string, + id: string, + episodeIndex: number +) { + if (!isBrowser()) { + return null; + } + + const key = getEpisodeProgressStorageKey(source, id, episodeIndex); + const record = parseEpisodeProgressRecord(localStorage.getItem(key)); + + if (!record) { + localStorage.removeItem(key); + return null; + } + + if ( + record.updatedAt > 0 && + Date.now() - record.updatedAt > EPISODE_PROGRESS_MAX_AGE_MS + ) { + localStorage.removeItem(key); + return null; + } + + return record; +} + +export function loadLocalEpisodeProgress( + source: string, + id: string, + episodeIndex: number +) { + const record = loadLocalEpisodeProgressRecord(source, id, episodeIndex); + if (!record) { + return null; + } + + return Number.isFinite(record.playTime) && record.playTime > 1 + ? Math.floor(record.playTime) + : null; +} + +export function pruneLocalEpisodeProgressStorage(maxEntries = EPISODE_PROGRESS_MAX_ENTRIES) { + if (!isBrowser()) { + return; + } + + const now = Date.now(); + const entries = collectEpisodeProgressEntries(); + + entries.forEach(({ key, record }) => { + if (record.updatedAt > 0 && now - record.updatedAt > EPISODE_PROGRESS_MAX_AGE_MS) { + localStorage.removeItem(key); + } + }); + + const validEntries = entries + .filter(({ record }) => record.updatedAt <= 0 || now - record.updatedAt <= EPISODE_PROGRESS_MAX_AGE_MS) + .sort((a, b) => b.record.updatedAt - a.record.updatedAt); + + if (validEntries.length <= maxEntries) { + return; + } + + validEntries.slice(maxEntries).forEach(({ key }) => { + localStorage.removeItem(key); + }); +} + +export function saveLocalEpisodeProgress( + source: string, + id: string, + episodeIndex: number, + playTime: number, + totalTime: number +) { + if (!isBrowser() || !Number.isFinite(playTime) || playTime <= 0) { + return; + } + + const key = getEpisodeProgressStorageKey(source, id, episodeIndex); + const payload = JSON.stringify({ + playTime: Math.floor(playTime), + totalTime: Number.isFinite(totalTime) && totalTime >= 0 ? Math.floor(totalTime) : 0, + updatedAt: Date.now(), + }); + + try { + localStorage.setItem(key, payload); + pruneLocalEpisodeProgressStorage(); + } catch (error) { + if (!isQuotaExceededError(error)) { + throw error; + } + + pruneLocalEpisodeProgressStorage(Math.max(50, Math.floor(EPISODE_PROGRESS_MAX_ENTRIES / 2))); + localStorage.setItem(key, payload); + pruneLocalEpisodeProgressStorage(); + } +}