Use shared keys for episode progress cache

This commit is contained in:
ShiGuangAlex
2026-04-15 00:47:24 +08:00
parent 2e7484723e
commit 375d857f78
3 changed files with 247 additions and 104 deletions
+18 -18
View File
@@ -46,6 +46,7 @@ import {
subscribeToDataUpdates, subscribeToDataUpdates,
} from '@/lib/db.client'; } from '@/lib/db.client';
import { import {
buildEpisodeProgressContentKey,
loadLocalEpisodeProgress, loadLocalEpisodeProgress,
pruneLocalEpisodeProgressStorage, pruneLocalEpisodeProgressStorage,
saveLocalEpisodeProgress, saveLocalEpisodeProgress,
@@ -632,6 +633,13 @@ function PlayPageClient() {
// 搜索所需信息 // 搜索所需信息
const [searchTitle] = useState(searchParams.get('stitle') || ''); const [searchTitle] = useState(searchParams.get('stitle') || '');
const [searchType] = useState(searchParams.get('stype') || ''); const [searchType] = useState(searchParams.get('stype') || '');
const [episodeProgressContentKey] = useState(() =>
buildEpisodeProgressContentKey({
title: searchTitle || searchParams.get('title') || '',
year: searchParams.get('year') || '',
searchType,
})
);
// 是否需要优选 // 是否需要优选
const [needPrefer, setNeedPrefer] = useState( const [needPrefer, setNeedPrefer] = useState(
@@ -3915,8 +3923,7 @@ function PlayPageClient() {
// 否则使用点击的文件集数,从头开始播放 // 否则使用点击的文件集数,从头开始播放
initialIndex = detailData.initialEpisodeIndex; initialIndex = detailData.initialEpisodeIndex;
const localEpisodeTime = loadLocalEpisodeProgress( const localEpisodeTime = loadLocalEpisodeProgress(
detailData.source, episodeProgressContentKey,
detailData.id,
initialIndex initialIndex
); );
resumeTimeRef.current = localEpisodeTime; resumeTimeRef.current = localEpisodeTime;
@@ -3935,8 +3942,7 @@ function PlayPageClient() {
// 使用点击的文件集数 // 使用点击的文件集数
initialIndex = detailData.initialEpisodeIndex; initialIndex = detailData.initialEpisodeIndex;
resumeTimeRef.current = loadLocalEpisodeProgress( resumeTimeRef.current = loadLocalEpisodeProgress(
detailData.source, episodeProgressContentKey,
detailData.id,
initialIndex initialIndex
); );
console.log('[Play] 没有播放记录,使用点击的文件集数:', initialIndex); console.log('[Play] 没有播放记录,使用点击的文件集数:', initialIndex);
@@ -3944,8 +3950,7 @@ function PlayPageClient() {
// 默认从第0集开始 // 默认从第0集开始
initialIndex = 0; initialIndex = 0;
resumeTimeRef.current = loadLocalEpisodeProgress( resumeTimeRef.current = loadLocalEpisodeProgress(
detailData.source, episodeProgressContentKey,
detailData.id,
initialIndex initialIndex
); );
console.log('[Play] 没有播放记录,从第0集开始'); console.log('[Play] 没有播放记录,从第0集开始');
@@ -4141,8 +4146,7 @@ function PlayPageClient() {
} }
return loadLocalEpisodeProgress( return loadLocalEpisodeProgress(
currentSourceRef.current, episodeProgressContentKey,
currentIdRef.current,
episodeIndex episodeIndex
); );
}; };
@@ -4231,8 +4235,7 @@ function PlayPageClient() {
const resumeTime = isSameEpisodeSwitch const resumeTime = isSameEpisodeSwitch
? await getSourceSwitchResumeTime(previousEpisodeIndex, currentPlayTime) ? await getSourceSwitchResumeTime(previousEpisodeIndex, currentPlayTime)
: loadLocalEpisodeProgress( : loadLocalEpisodeProgress(
newSource, episodeProgressContentKey,
newId,
targetIndex targetIndex
); );
resumeTimeRef.current = resumeTime; resumeTimeRef.current = resumeTime;
@@ -4277,8 +4280,7 @@ function PlayPageClient() {
if (isSameEpisodeSwitch && resumeTime && resumeTime > 1) { if (isSameEpisodeSwitch && resumeTime && resumeTime > 1) {
const currentDuration = artPlayerRef.current?.duration || 0; const currentDuration = artPlayerRef.current?.duration || 0;
saveLocalEpisodeProgress( saveLocalEpisodeProgress(
newSource, episodeProgressContentKey,
newId,
targetIndex, targetIndex,
resumeTime, resumeTime,
currentDuration currentDuration
@@ -4352,8 +4354,7 @@ function PlayPageClient() {
resumeTimeRef.current = record.play_time; resumeTimeRef.current = record.play_time;
} else { } else {
resumeTimeRef.current = loadLocalEpisodeProgress( resumeTimeRef.current = loadLocalEpisodeProgress(
currentSourceRef.current, episodeProgressContentKey,
currentIdRef.current,
targetEpisodeIndex targetEpisodeIndex
); );
} }
@@ -4361,8 +4362,7 @@ function PlayPageClient() {
console.warn('[Play] Failed to prime episode resume state:', error); console.warn('[Play] Failed to prime episode resume state:', error);
if (currentSourceRef.current && currentIdRef.current) { if (currentSourceRef.current && currentIdRef.current) {
resumeTimeRef.current = loadLocalEpisodeProgress( resumeTimeRef.current = loadLocalEpisodeProgress(
currentSourceRef.current, episodeProgressContentKey,
currentIdRef.current,
targetEpisodeIndex targetEpisodeIndex
); );
} else { } else {
@@ -5415,8 +5415,7 @@ function PlayPageClient() {
try { try {
saveLocalEpisodeProgress( saveLocalEpisodeProgress(
currentSourceRef.current, episodeProgressContentKey,
currentIdRef.current,
currentEpisodeIndexRef.current, currentEpisodeIndexRef.current,
currentTime, currentTime,
duration duration
@@ -9190,6 +9189,7 @@ function PlayPageClient() {
isRoomMember={playSync.shouldDisableControls} isRoomMember={playSync.shouldDisableControls}
currentSource={currentSource} currentSource={currentSource}
currentId={currentId} currentId={currentId}
episodeProgressContentKey={episodeProgressContentKey || undefined}
videoTitle={searchTitle || videoTitle} videoTitle={searchTitle || videoTitle}
availableSources={availableSources} availableSources={availableSources}
sourceSearchLoading={sourceSearchLoading} sourceSearchLoading={sourceSearchLoading}
+22 -13
View File
@@ -12,7 +12,7 @@ import React, {
import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types'; import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types';
import { generateStorageKey, getCachedPlayRecordsSnapshot } from '@/lib/db.client'; 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 { EpisodeFilterConfig,SearchResult } from '@/lib/types';
import { getVideoResolutionFromM3u8 } from '@/lib/utils'; import { getVideoResolutionFromM3u8 } from '@/lib/utils';
@@ -44,6 +44,7 @@ interface EpisodeSelectorProps {
onSourceChange?: (source: string, id: string, title: string) => void; onSourceChange?: (source: string, id: string, title: string) => void;
currentSource?: string; currentSource?: string;
currentId?: string; currentId?: string;
episodeProgressContentKey?: string;
videoTitle?: string; videoTitle?: string;
videoYear?: string; videoYear?: string;
availableSources?: SearchResult[]; availableSources?: SearchResult[];
@@ -77,6 +78,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
onSourceChange, onSourceChange,
currentSource, currentSource,
currentId, currentId,
episodeProgressContentKey,
videoTitle, videoTitle,
availableSources = [], availableSources = [],
sourceSearchLoading = false, sourceSearchLoading = false,
@@ -127,7 +129,12 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
}, [videoInfoMap]); }, [videoInfoMap]);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined' || !currentSource || !currentId) { if (
typeof window === 'undefined' ||
!currentSource ||
!currentId ||
!episodeProgressContentKey
) {
setWatchedEpisodes(new Set()); setWatchedEpisodes(new Set());
return; return;
} }
@@ -145,19 +152,21 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
console.warn('[EpisodeSelector] Failed to read cached play records:', error); console.warn('[EpisodeSelector] Failed to read cached play records:', error);
} }
for (let episodeNumber = 1; episodeNumber <= totalEpisodes; episodeNumber++) { try {
try { const episodeRecords = loadAllLocalEpisodeProgressRecords(
const record = loadLocalEpisodeProgressRecord( episodeProgressContentKey
currentSource, );
currentId,
episodeNumber - 1 for (const [episodeIndex, record] of Object.entries(episodeRecords)) {
);
if (Number(record?.playTime) > 1) { 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); setWatchedEpisodes(watched);
@@ -179,7 +188,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
); );
window.removeEventListener('storage', handlePlayRecordsUpdated); window.removeEventListener('storage', handlePlayRecordsUpdated);
}; };
}, [currentSource, currentId, totalEpisodes, value]); }, [currentSource, currentId, episodeProgressContentKey, totalEpisodes, value]);
// 主要的 tab 状态:'danmaku' | 'episodes' | 'sources' // 主要的 tab 状态:'danmaku' | 'episodes' | 'sources'
// 默认显示选集选项卡,但如果是房员则显示弹幕 // 默认显示选集选项卡,但如果是房员则显示弹幕
+207 -73
View File
@@ -1,13 +1,24 @@
const EPISODE_PROGRESS_PREFIX = 'moontv_episode_progress:'; 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; const EPISODE_PROGRESS_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 120;
interface LocalEpisodeProgressRecord { export interface LocalEpisodeProgressRecord {
playTime: number; playTime: number;
totalTime: number; totalTime: number;
updatedAt: number; updatedAt: number;
} }
interface EpisodeProgressContentIdentity {
title?: string;
year?: string;
searchType?: string;
}
interface LocalEpisodeProgressStore {
updatedAt: number;
episodes: Record<string, LocalEpisodeProgressRecord>;
}
function isBrowser() { function isBrowser() {
return typeof window !== 'undefined'; 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<LocalEpisodeProgressRecord>;
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<LocalEpisodeProgressStore>;
const rawEpisodes = parsed.episodes;
if (!rawEpisodes || typeof rawEpisodes !== 'object') {
return { store: null, changed: false };
}
const now = Date.now();
const episodes: Record<string, LocalEpisodeProgressRecord> = {};
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) { if (!raw) {
return null; return null;
} }
try { try {
const parsed = JSON.parse(raw) as Partial<LocalEpisodeProgressRecord>; const parsed = JSON.parse(raw);
const playTime = Number(parsed.playTime); const { store, changed } = normalizeEpisodeProgressStore(parsed);
const totalTime = Number(parsed.totalTime);
const updatedAt = Number(parsed.updatedAt);
if (!Number.isFinite(playTime) || playTime <= 0) { if (!store) {
localStorage.removeItem(key);
return null; return null;
} }
return { if (changed) {
playTime, localStorage.setItem(key, JSON.stringify(store));
totalTime: Number.isFinite(totalTime) && totalTime >= 0 ? totalTime : 0, }
updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : 0,
}; return store;
} catch { } catch {
localStorage.removeItem(key);
return null; return null;
} }
} }
@@ -49,70 +149,97 @@ function collectEpisodeProgressEntries() {
return []; return [];
} }
const entries: Array<{ key: string; record: LocalEpisodeProgressRecord }> = [];
const keys = Array.from({ length: localStorage.length }, (_, index) => const keys = Array.from({ length: localStorage.length }, (_, index) =>
localStorage.key(index) localStorage.key(index)
).filter((key): key is string => Boolean(key)); ).filter((key): key is string => Boolean(key));
const entries: Array<{ key: string; updatedAt: number }> = [];
for (const key of keys) { for (const key of keys) {
if (!key.startsWith(EPISODE_PROGRESS_PREFIX)) { if (!key.startsWith(EPISODE_PROGRESS_PREFIX)) {
continue; continue;
} }
const record = parseEpisodeProgressRecord(localStorage.getItem(key)); const raw = localStorage.getItem(key);
if (!record) { if (!raw) {
localStorage.removeItem(key); localStorage.removeItem(key);
continue; 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; return entries;
} }
export function getEpisodeProgressStorageKey( function normalizeContentTitle(title: string) {
source: string, return title
id: string, .replace(/\s+/g, '')
episodeIndex: number .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( export function loadLocalEpisodeProgressRecord(
source: string, contentKey: string | null,
id: string,
episodeIndex: number episodeIndex: number
) { ) {
if (!isBrowser()) { const episodes = loadAllLocalEpisodeProgressRecords(contentKey);
return null; return episodes[String(episodeIndex)] || 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( export function loadLocalEpisodeProgress(
source: string, contentKey: string | null,
id: string,
episodeIndex: number episodeIndex: number
) { ) {
const record = loadLocalEpisodeProgressRecord(source, id, episodeIndex); const record = loadLocalEpisodeProgressRecord(contentKey, episodeIndex);
if (!record) { if (!record) {
return null; return null;
} }
@@ -122,50 +249,57 @@ export function loadLocalEpisodeProgress(
: null; : null;
} }
export function pruneLocalEpisodeProgressStorage(maxEntries = EPISODE_PROGRESS_MAX_ENTRIES) { export function pruneLocalEpisodeProgressStorage(
maxShows = EPISODE_PROGRESS_MAX_SHOWS
) {
if (!isBrowser()) { if (!isBrowser()) {
return; return;
} }
const now = Date.now(); const entries = collectEpisodeProgressEntries().sort(
const entries = collectEpisodeProgressEntries(); (a, b) => b.updatedAt - a.updatedAt
);
entries.forEach(({ key, record }) => { if (entries.length <= maxShows) {
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; return;
} }
validEntries.slice(maxEntries).forEach(({ key }) => { entries.slice(maxShows).forEach(({ key }) => {
localStorage.removeItem(key); localStorage.removeItem(key);
}); });
} }
export function saveLocalEpisodeProgress( export function saveLocalEpisodeProgress(
source: string, contentKey: string | null,
id: string,
episodeIndex: number, episodeIndex: number,
playTime: number, playTime: number,
totalTime: number totalTime: number
) { ) {
if (!isBrowser() || !Number.isFinite(playTime) || playTime <= 0) { if (
!isBrowser() ||
!contentKey ||
!Number.isFinite(playTime) ||
playTime <= 0
) {
return; return;
} }
const key = getEpisodeProgressStorageKey(source, id, episodeIndex); const key = getEpisodeProgressStorageKey(contentKey);
const payload = JSON.stringify({ const now = Date.now();
playTime: Math.floor(playTime), const currentStore = readEpisodeProgressStore(contentKey);
totalTime: Number.isFinite(totalTime) && totalTime >= 0 ? Math.floor(totalTime) : 0, const nextStore: LocalEpisodeProgressStore = {
updatedAt: Date.now(), 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 { try {
localStorage.setItem(key, payload); localStorage.setItem(key, payload);
@@ -175,7 +309,7 @@ export function saveLocalEpisodeProgress(
throw error; 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); localStorage.setItem(key, payload);
pruneLocalEpisodeProgressStorage(); pruneLocalEpisodeProgressStorage();
} }