Limit local episode progress cache

This commit is contained in:
ShiGuangAlex
2026-04-14 23:34:10 +08:00
parent aa5426c186
commit 2e7484723e
3 changed files with 207 additions and 69 deletions
+19 -63
View File
@@ -45,6 +45,11 @@ import {
saveSkipConfig, saveSkipConfig,
subscribeToDataUpdates, subscribeToDataUpdates,
} from '@/lib/db.client'; } from '@/lib/db.client';
import {
loadLocalEpisodeProgress,
pruneLocalEpisodeProgressStorage,
saveLocalEpisodeProgress,
} from '@/lib/episode-progress';
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 { DanmakuFilterConfig, EpisodeFilterConfig, SearchResult } from '@/lib/types'; import { DanmakuFilterConfig, EpisodeFilterConfig, SearchResult } from '@/lib/types';
@@ -748,10 +753,11 @@ function PlayPageClient() {
return; return;
} }
// 检查是否禁用了自动装填弹幕 // 检查是否禁用了自动加载弹幕
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; if (isDanmakuAutoLoadDisabled()) {
if (disableAutoLoad) { console.log('[弹幕] 已禁用自动加载弹幕,跳过自动加载');
console.log('[弹幕] 已禁用自动装填弹幕,跳过自动加载'); setShowDanmakuSourceSelector(false);
setDanmakuLoading(false);
return; return;
} }
@@ -1564,63 +1570,6 @@ function PlayPageClient() {
return (window as any).RUNTIME_CONFIG?.DANMAKU_AUTO_LOAD_DEFAULT === false; 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 后跳转到指定进度 // 用于记录是否需要在播放器 ready 后跳转到指定进度
const resumeTimeRef = useRef<number | null>(null); const resumeTimeRef = useRef<number | null>(null);
// 播放记录跳转按钮状态 // 播放记录跳转按钮状态
@@ -1642,6 +1591,14 @@ function PlayPageClient() {
); );
const [backgroundSourcesLoading, setBackgroundSourcesLoading] = useState(false); const [backgroundSourcesLoading, setBackgroundSourcesLoading] = useState(false);
useEffect(() => {
try {
pruneLocalEpisodeProgressStorage();
} catch (error) {
console.warn('[Play] Failed to prune local episode progress:', error);
}
}, []);
// 优选和测速开关 // 优选和测速开关
const [optimizationEnabled] = useState<boolean>(() => { const [optimizationEnabled] = useState<boolean>(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -4779,8 +4736,7 @@ function PlayPageClient() {
const preloadNextEpisodeDanmaku = async () => { const preloadNextEpisodeDanmaku = async () => {
try { try {
if (isDirectPlay) return; if (isDirectPlay) return;
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; if (isDanmakuAutoLoadDisabled()) return;
if (disableAutoLoad) return;
const title = videoTitleRef.current; const title = videoTitleRef.current;
if (!title) { if (!title) {
+6 -6
View File
@@ -12,6 +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 { EpisodeFilterConfig,SearchResult } from '@/lib/types'; import { EpisodeFilterConfig,SearchResult } from '@/lib/types';
import { getVideoResolutionFromM3u8 } from '@/lib/utils'; import { getVideoResolutionFromM3u8 } from '@/lib/utils';
@@ -146,13 +147,12 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
for (let episodeNumber = 1; episodeNumber <= totalEpisodes; episodeNumber++) { for (let episodeNumber = 1; episodeNumber <= totalEpisodes; episodeNumber++) {
try { try {
const raw = localStorage.getItem( const record = loadLocalEpisodeProgressRecord(
`moontv_episode_progress:${currentSource}+${currentId}:${episodeNumber - 1}` currentSource,
currentId,
episodeNumber - 1
); );
if (!raw) continue; if (Number(record?.playTime) > 1) {
const parsed = JSON.parse(raw) as { playTime?: number };
if (Number(parsed.playTime) > 1) {
watched.add(episodeNumber); watched.add(episodeNumber);
} }
} catch (error) { } catch (error) {
+182
View File
@@ -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<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,
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();
}
}