Use shared keys for episode progress cache
This commit is contained in:
+18
-18
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user