From 375d857f78bae6d8014528aba2e2eede3edf52f0 Mon Sep 17 00:00:00 2001 From: ShiGuangAlex Date: Wed, 15 Apr 2026 00:47:24 +0800 Subject: [PATCH] Use shared keys for episode progress cache --- src/app/play/page.tsx | 36 ++-- src/components/EpisodeSelector.tsx | 35 ++-- src/lib/episode-progress.ts | 280 +++++++++++++++++++++-------- 3 files changed, 247 insertions(+), 104 deletions(-) diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index e74c42d..3b27fd7 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -46,6 +46,7 @@ import { subscribeToDataUpdates, } from '@/lib/db.client'; import { + buildEpisodeProgressContentKey, loadLocalEpisodeProgress, pruneLocalEpisodeProgressStorage, saveLocalEpisodeProgress, @@ -632,6 +633,13 @@ function PlayPageClient() { // 搜索所需信息 const [searchTitle] = useState(searchParams.get('stitle') || ''); const [searchType] = useState(searchParams.get('stype') || ''); + const [episodeProgressContentKey] = useState(() => + buildEpisodeProgressContentKey({ + title: searchTitle || searchParams.get('title') || '', + year: searchParams.get('year') || '', + searchType, + }) + ); // 是否需要优选 const [needPrefer, setNeedPrefer] = useState( @@ -3915,8 +3923,7 @@ function PlayPageClient() { // 否则使用点击的文件集数,从头开始播放 initialIndex = detailData.initialEpisodeIndex; const localEpisodeTime = loadLocalEpisodeProgress( - detailData.source, - detailData.id, + episodeProgressContentKey, initialIndex ); resumeTimeRef.current = localEpisodeTime; @@ -3935,8 +3942,7 @@ function PlayPageClient() { // 使用点击的文件集数 initialIndex = detailData.initialEpisodeIndex; resumeTimeRef.current = loadLocalEpisodeProgress( - detailData.source, - detailData.id, + episodeProgressContentKey, initialIndex ); console.log('[Play] 没有播放记录,使用点击的文件集数:', initialIndex); @@ -3944,8 +3950,7 @@ function PlayPageClient() { // 默认从第0集开始 initialIndex = 0; resumeTimeRef.current = loadLocalEpisodeProgress( - detailData.source, - detailData.id, + episodeProgressContentKey, initialIndex ); console.log('[Play] 没有播放记录,从第0集开始'); @@ -4141,8 +4146,7 @@ function PlayPageClient() { } return loadLocalEpisodeProgress( - currentSourceRef.current, - currentIdRef.current, + episodeProgressContentKey, episodeIndex ); }; @@ -4231,8 +4235,7 @@ function PlayPageClient() { const resumeTime = isSameEpisodeSwitch ? await getSourceSwitchResumeTime(previousEpisodeIndex, currentPlayTime) : loadLocalEpisodeProgress( - newSource, - newId, + episodeProgressContentKey, targetIndex ); resumeTimeRef.current = resumeTime; @@ -4277,8 +4280,7 @@ function PlayPageClient() { if (isSameEpisodeSwitch && resumeTime && resumeTime > 1) { const currentDuration = artPlayerRef.current?.duration || 0; saveLocalEpisodeProgress( - newSource, - newId, + episodeProgressContentKey, targetIndex, resumeTime, currentDuration @@ -4352,8 +4354,7 @@ function PlayPageClient() { resumeTimeRef.current = record.play_time; } else { resumeTimeRef.current = loadLocalEpisodeProgress( - currentSourceRef.current, - currentIdRef.current, + episodeProgressContentKey, targetEpisodeIndex ); } @@ -4361,8 +4362,7 @@ function PlayPageClient() { console.warn('[Play] Failed to prime episode resume state:', error); if (currentSourceRef.current && currentIdRef.current) { resumeTimeRef.current = loadLocalEpisodeProgress( - currentSourceRef.current, - currentIdRef.current, + episodeProgressContentKey, targetEpisodeIndex ); } else { @@ -5415,8 +5415,7 @@ function PlayPageClient() { try { saveLocalEpisodeProgress( - currentSourceRef.current, - currentIdRef.current, + episodeProgressContentKey, currentEpisodeIndexRef.current, currentTime, duration @@ -9190,6 +9189,7 @@ function PlayPageClient() { isRoomMember={playSync.shouldDisableControls} currentSource={currentSource} currentId={currentId} + episodeProgressContentKey={episodeProgressContentKey || undefined} videoTitle={searchTitle || videoTitle} availableSources={availableSources} sourceSearchLoading={sourceSearchLoading} diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index fefc0e0..37fbd85 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -12,7 +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 { loadAllLocalEpisodeProgressRecords } from '@/lib/episode-progress'; import { EpisodeFilterConfig,SearchResult } from '@/lib/types'; import { getVideoResolutionFromM3u8 } from '@/lib/utils'; @@ -44,6 +44,7 @@ interface EpisodeSelectorProps { onSourceChange?: (source: string, id: string, title: string) => void; currentSource?: string; currentId?: string; + episodeProgressContentKey?: string; videoTitle?: string; videoYear?: string; availableSources?: SearchResult[]; @@ -77,6 +78,7 @@ const EpisodeSelector: React.FC = ({ onSourceChange, currentSource, currentId, + episodeProgressContentKey, videoTitle, availableSources = [], sourceSearchLoading = false, @@ -127,7 +129,12 @@ const EpisodeSelector: React.FC = ({ }, [videoInfoMap]); useEffect(() => { - if (typeof window === 'undefined' || !currentSource || !currentId) { + if ( + typeof window === 'undefined' || + !currentSource || + !currentId || + !episodeProgressContentKey + ) { setWatchedEpisodes(new Set()); return; } @@ -145,19 +152,21 @@ const EpisodeSelector: React.FC = ({ console.warn('[EpisodeSelector] Failed to read cached play records:', error); } - for (let episodeNumber = 1; episodeNumber <= totalEpisodes; episodeNumber++) { - try { - const record = loadLocalEpisodeProgressRecord( - currentSource, - currentId, - episodeNumber - 1 - ); + try { + const episodeRecords = loadAllLocalEpisodeProgressRecords( + episodeProgressContentKey + ); + + for (const [episodeIndex, record] of Object.entries(episodeRecords)) { if (Number(record?.playTime) > 1) { - watched.add(episodeNumber); + const episodeNumber = Number(episodeIndex) + 1; + if (episodeNumber >= 1 && episodeNumber <= totalEpisodes) { + watched.add(episodeNumber); + } } - } catch (error) { - console.warn('[EpisodeSelector] Failed to read local episode progress:', error); } + } catch (error) { + console.warn('[EpisodeSelector] Failed to read local episode progress:', error); } setWatchedEpisodes(watched); @@ -179,7 +188,7 @@ const EpisodeSelector: React.FC = ({ ); window.removeEventListener('storage', handlePlayRecordsUpdated); }; - }, [currentSource, currentId, totalEpisodes, value]); + }, [currentSource, currentId, episodeProgressContentKey, totalEpisodes, value]); // 主要的 tab 状态:'danmaku' | 'episodes' | 'sources' // 默认显示选集选项卡,但如果是房员则显示弹幕 diff --git a/src/lib/episode-progress.ts b/src/lib/episode-progress.ts index 2063b4b..6874621 100644 --- a/src/lib/episode-progress.ts +++ b/src/lib/episode-progress.ts @@ -1,13 +1,24 @@ const EPISODE_PROGRESS_PREFIX = 'moontv_episode_progress:'; -const EPISODE_PROGRESS_MAX_ENTRIES = 200; +const EPISODE_PROGRESS_MAX_SHOWS = 200; const EPISODE_PROGRESS_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 120; -interface LocalEpisodeProgressRecord { +export interface LocalEpisodeProgressRecord { playTime: number; totalTime: number; updatedAt: number; } +interface EpisodeProgressContentIdentity { + title?: string; + year?: string; + searchType?: string; +} + +interface LocalEpisodeProgressStore { + updatedAt: number; + episodes: Record; +} + function isBrowser() { return typeof window !== 'undefined'; } @@ -19,27 +30,116 @@ function isQuotaExceededError(error: unknown) { ); } -function parseEpisodeProgressRecord(raw: string | null): LocalEpisodeProgressRecord | null { +function parseEpisodeProgressRecord( + value: unknown +): LocalEpisodeProgressRecord | null { + if (!value || typeof value !== 'object') { + return null; + } + + const parsed = value 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: Math.floor(playTime), + totalTime: Number.isFinite(totalTime) && totalTime >= 0 ? Math.floor(totalTime) : 0, + updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : 0, + }; +} + +function normalizeEpisodeProgressStore( + value: unknown +): { store: LocalEpisodeProgressStore | null; changed: boolean } { + if (!value || typeof value !== 'object') { + return { store: null, changed: false }; + } + + const parsed = value as Partial; + const rawEpisodes = parsed.episodes; + if (!rawEpisodes || typeof rawEpisodes !== 'object') { + return { store: null, changed: false }; + } + + const now = Date.now(); + const episodes: Record = {}; + let latestUpdatedAt = 0; + let changed = false; + + for (const [episodeIndex, entry] of Object.entries(rawEpisodes)) { + const normalized = parseEpisodeProgressRecord(entry); + if (!normalized) { + changed = true; + continue; + } + + if ( + normalized.updatedAt > 0 && + now - normalized.updatedAt > EPISODE_PROGRESS_MAX_AGE_MS + ) { + changed = true; + continue; + } + + episodes[episodeIndex] = normalized; + latestUpdatedAt = Math.max(latestUpdatedAt, normalized.updatedAt); + } + + if (Object.keys(episodes).length === 0) { + return { store: null, changed: true }; + } + + const rootUpdatedAt = Number(parsed.updatedAt); + const normalizedUpdatedAt = + Number.isFinite(rootUpdatedAt) && rootUpdatedAt > 0 + ? Math.max(rootUpdatedAt, latestUpdatedAt) + : latestUpdatedAt; + + if (normalizedUpdatedAt !== rootUpdatedAt) { + changed = true; + } + + return { + store: { + updatedAt: normalizedUpdatedAt, + episodes, + }, + changed, + }; +} + +function readEpisodeProgressStore(contentKey: string): LocalEpisodeProgressStore | null { + if (!isBrowser()) { + return null; + } + + const key = getEpisodeProgressStorageKey(contentKey); + const raw = localStorage.getItem(key); 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); + const parsed = JSON.parse(raw); + const { store, changed } = normalizeEpisodeProgressStore(parsed); - if (!Number.isFinite(playTime) || playTime <= 0) { + if (!store) { + localStorage.removeItem(key); return null; } - return { - playTime, - totalTime: Number.isFinite(totalTime) && totalTime >= 0 ? totalTime : 0, - updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : 0, - }; + if (changed) { + localStorage.setItem(key, JSON.stringify(store)); + } + + return store; } catch { + localStorage.removeItem(key); return null; } } @@ -49,70 +149,97 @@ function collectEpisodeProgressEntries() { 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)); + const entries: Array<{ key: string; updatedAt: number }> = []; + for (const key of keys) { if (!key.startsWith(EPISODE_PROGRESS_PREFIX)) { continue; } - const record = parseEpisodeProgressRecord(localStorage.getItem(key)); - if (!record) { + const raw = localStorage.getItem(key); + if (!raw) { localStorage.removeItem(key); continue; } - entries.push({ key, record }); + try { + const parsed = JSON.parse(raw); + const { store, changed } = normalizeEpisodeProgressStore(parsed); + if (!store) { + localStorage.removeItem(key); + continue; + } + + if (changed) { + localStorage.setItem(key, JSON.stringify(store)); + } + + entries.push({ + key, + updatedAt: store.updatedAt, + }); + } catch { + localStorage.removeItem(key); + } } return entries; } -export function getEpisodeProgressStorageKey( - source: string, - id: string, - episodeIndex: number +function normalizeContentTitle(title: string) { + return title + .replace(/\s+/g, '') + .replace(/[\uff01-\uff5e]/g, (char) => + String.fromCharCode(char.charCodeAt(0) - 0xfee0) + ) + .replace(/[()()[\]【】{}「」『』<>《》]/g, '') + .replace(/[^\w\u4e00-\u9fa5]/g, '') + .toLowerCase(); +} + +export function buildEpisodeProgressContentKey( + identity: EpisodeProgressContentIdentity ) { - return `${EPISODE_PROGRESS_PREFIX}${source}+${id}:${episodeIndex}`; + const title = normalizeContentTitle(identity.title || ''); + const year = String(identity.year || '').trim(); + const searchType = String(identity.searchType || '').trim().toLowerCase(); + + if (!title) { + return null; + } + + return `${title}|${year}|${searchType}`; +} + +export function getEpisodeProgressStorageKey(contentKey: string) { + return `${EPISODE_PROGRESS_PREFIX}${contentKey}`; +} + +export function loadAllLocalEpisodeProgressRecords(contentKey: string | null) { + if (!contentKey) { + return {}; + } + + return readEpisodeProgressStore(contentKey)?.episodes || {}; } export function loadLocalEpisodeProgressRecord( - source: string, - id: string, + contentKey: string | null, 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; + const episodes = loadAllLocalEpisodeProgressRecords(contentKey); + return episodes[String(episodeIndex)] || null; } export function loadLocalEpisodeProgress( - source: string, - id: string, + contentKey: string | null, episodeIndex: number ) { - const record = loadLocalEpisodeProgressRecord(source, id, episodeIndex); + const record = loadLocalEpisodeProgressRecord(contentKey, episodeIndex); if (!record) { return null; } @@ -122,50 +249,57 @@ export function loadLocalEpisodeProgress( : null; } -export function pruneLocalEpisodeProgressStorage(maxEntries = EPISODE_PROGRESS_MAX_ENTRIES) { +export function pruneLocalEpisodeProgressStorage( + maxShows = EPISODE_PROGRESS_MAX_SHOWS +) { if (!isBrowser()) { return; } - const now = Date.now(); - const entries = collectEpisodeProgressEntries(); + const entries = collectEpisodeProgressEntries().sort( + (a, b) => b.updatedAt - a.updatedAt + ); - 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) { + if (entries.length <= maxShows) { return; } - validEntries.slice(maxEntries).forEach(({ key }) => { + entries.slice(maxShows).forEach(({ key }) => { localStorage.removeItem(key); }); } export function saveLocalEpisodeProgress( - source: string, - id: string, + contentKey: string | null, episodeIndex: number, playTime: number, totalTime: number ) { - if (!isBrowser() || !Number.isFinite(playTime) || playTime <= 0) { + if ( + !isBrowser() || + !contentKey || + !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(), - }); + const key = getEpisodeProgressStorageKey(contentKey); + const now = Date.now(); + const currentStore = readEpisodeProgressStore(contentKey); + const nextStore: LocalEpisodeProgressStore = { + updatedAt: now, + episodes: { + ...(currentStore?.episodes || {}), + [String(episodeIndex)]: { + playTime: Math.floor(playTime), + totalTime: Number.isFinite(totalTime) && totalTime >= 0 ? Math.floor(totalTime) : 0, + updatedAt: now, + }, + }, + }; + + const payload = JSON.stringify(nextStore); try { localStorage.setItem(key, payload); @@ -175,7 +309,7 @@ export function saveLocalEpisodeProgress( throw error; } - pruneLocalEpisodeProgressStorage(Math.max(50, Math.floor(EPISODE_PROGRESS_MAX_ENTRIES / 2))); + pruneLocalEpisodeProgressStorage(Math.max(50, Math.floor(EPISODE_PROGRESS_MAX_SHOWS / 2))); localStorage.setItem(key, payload); pruneLocalEpisodeProgressStorage(); }