-
- {children.slice(0, Math.max(1, columns)).map((child, idx) => (
-
- {child}
-
- ))}
+
+ {children
+ .slice(0, Math.min(children.length, MAX_MEASURE_ITEMS))
+ .map((child, idx) => (
+
+ {child}
+
+ ))}
diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts
index 8d00c70..fe83a00 100644
--- a/src/lib/admin.types.ts
+++ b/src/lib/admin.types.ts
@@ -20,6 +20,7 @@ export interface AdminConfig {
DanmakuSourceType?: 'builtin' | 'custom';
DanmakuApiBase: string;
DanmakuApiToken: string;
+ DanmakuAutoLoadDefault?: boolean; // 是否默认自动加载弹幕(用户可在本地覆盖)
// TMDB配置
TMDBApiKey?: string;
TMDBProxy?: string;
diff --git a/src/lib/config.ts b/src/lib/config.ts
index ca7e5ee..b59d187 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -255,6 +255,7 @@ async function getInitConfig(configFile: string, subConfig: {
process.env.DANMAKU_API_BASE ||
(hasCustomDanmakuEnv ? 'http://localhost:9321' : BUILTIN_DANMAKU_API_BASE),
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
+ DanmakuAutoLoadDefault: true,
// TMDB配置
TMDBApiKey: process.env.TMDB_API_KEY || '',
TMDBProxy: process.env.TMDB_PROXY || '',
@@ -450,6 +451,7 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
DanmakuSourceType: 'builtin',
DanmakuApiBase: BUILTIN_DANMAKU_API_BASE,
DanmakuApiToken: '87654321',
+ DanmakuAutoLoadDefault: true,
PansouApiUrl: '',
PansouUsername: '',
PansouPassword: '',
@@ -482,6 +484,9 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
if (!adminConfig.SiteConfig.DanmakuApiToken) {
adminConfig.SiteConfig.DanmakuApiToken = '87654321';
}
+ if (adminConfig.SiteConfig.DanmakuAutoLoadDefault === undefined) {
+ adminConfig.SiteConfig.DanmakuAutoLoadDefault = true;
+ }
// 确保评论开关存在
if (adminConfig.SiteConfig.EnableComments === undefined) {
adminConfig.SiteConfig.EnableComments = false;
diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts
index 62e4bea..8423df6 100644
--- a/src/lib/db.client.ts
+++ b/src/lib/db.client.ts
@@ -787,9 +787,21 @@ export async function savePlayRecord(
body: JSON.stringify({ key, record }),
});
} catch (err) {
- await handleDatabaseOperationFailure('playRecords', err);
- triggerGlobalError('保存播放记录失败');
- throw err;
+ // 播放记录以用户体验为优先:保留已经写入的本地缓存,避免切集后记忆进度被回滚。
+ console.warn('同步播放记录到数据库失败,保留本地缓存:', err);
+
+ // 后台再尝试补一次,不打断当前播放流程。
+ window.setTimeout(() => {
+ fetchWithAuth('/api/playrecords', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ key, record }),
+ }).catch((retryErr) => {
+ console.warn('播放记录后台重试失败:', retryErr);
+ });
+ }, 3000);
}
return;
}
@@ -875,6 +887,88 @@ export async function deletePlayRecord(
}
}
+/**
+ * 迁移播放记录到新的 source/id。
+ * 用于换源时保留单一记忆点语义:当前进度迁移到新源后,再清理旧源记录。
+ */
+export async function migratePlayRecord(
+ fromSource: string,
+ fromId: string,
+ toSource: string,
+ toId: string,
+ record: PlayRecord
+): Promise
{
+ const fromKey = generateStorageKey(fromSource, fromId);
+ const toKey = generateStorageKey(toSource, toId);
+
+ if (fromKey === toKey) {
+ await savePlayRecord(toSource, toId, record);
+ return;
+ }
+
+ if (STORAGE_TYPE !== 'localstorage') {
+ const cachedRecords = {
+ ...(cacheManager.getCachedPlayRecords() || {}),
+ };
+
+ delete cachedRecords[fromKey];
+ cachedRecords[toKey] = record;
+ cacheManager.cachePlayRecords(cachedRecords);
+
+ window.dispatchEvent(
+ new CustomEvent('playRecordsUpdated', {
+ detail: cachedRecords,
+ })
+ );
+
+ const persistMove = async () => {
+ await fetchWithAuth('/api/playrecords', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ key: toKey, record }),
+ });
+
+ await fetchWithAuth(`/api/playrecords?key=${encodeURIComponent(fromKey)}`, {
+ method: 'DELETE',
+ });
+ };
+
+ persistMove().catch((err) => {
+ console.warn('迁移播放记录到数据库失败,稍后重试:', err);
+
+ window.setTimeout(() => {
+ persistMove().catch((retryErr) => {
+ console.warn('迁移播放记录后台重试失败:', retryErr);
+ });
+ }, 3000);
+ });
+ return;
+ }
+
+ if (typeof window === 'undefined') {
+ console.warn('无法在服务端迁移播放记录到 localStorage');
+ return;
+ }
+
+ try {
+ const allRecords = await getAllPlayRecords();
+ delete allRecords[fromKey];
+ allRecords[toKey] = record;
+ localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
+ window.dispatchEvent(
+ new CustomEvent('playRecordsUpdated', {
+ detail: allRecords,
+ })
+ );
+ } catch (err) {
+ console.error('迁移播放记录失败:', err);
+ triggerGlobalError('迁移播放记录失败');
+ throw err;
+ }
+}
+
/* ---------------- 搜索历史相关 API ---------------- */
/**
@@ -2244,5 +2338,3 @@ export async function saveEpisodeFilterConfig(
throw err;
}
}
-
-
diff --git a/src/lib/episode-progress.ts b/src/lib/episode-progress.ts
new file mode 100644
index 0000000..4e6dd04
--- /dev/null
+++ b/src/lib/episode-progress.ts
@@ -0,0 +1,354 @@
+const EPISODE_PROGRESS_PREFIX = 'moontv_episode_progress:';
+const EPISODE_PROGRESS_MAX_SHOWS = 20;
+const EPISODE_PROGRESS_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 120;
+
+export interface LocalEpisodeProgressRecord {
+ playTime: number;
+ totalTime: number;
+ updatedAt: number;
+}
+
+interface EpisodeProgressContentIdentity {
+ doubanId?: number | string;
+ tmdbId?: number | string;
+ title?: string;
+ year?: string;
+ searchType?: string;
+}
+
+interface LocalEpisodeProgressStore {
+ updatedAt: number;
+ episodes: Record;
+}
+
+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(
+ 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);
+ const { store, changed } = normalizeEpisodeProgressStore(parsed);
+
+ if (!store) {
+ localStorage.removeItem(key);
+ return null;
+ }
+
+ if (changed) {
+ localStorage.setItem(key, JSON.stringify(store));
+ }
+
+ return store;
+ } catch {
+ localStorage.removeItem(key);
+ return null;
+ }
+}
+
+function collectEpisodeProgressEntries() {
+ if (!isBrowser()) {
+ return [];
+ }
+
+ 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 raw = localStorage.getItem(key);
+ if (!raw) {
+ localStorage.removeItem(key);
+ continue;
+ }
+
+ 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;
+}
+
+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();
+}
+
+function normalizeContentIdentityId(value: unknown) {
+ const numericValue = Number(value);
+ if (Number.isFinite(numericValue) && numericValue > 0) {
+ return String(Math.floor(numericValue));
+ }
+
+ const text = String(value || '').trim();
+ if (/^[1-9]\d*$/.test(text)) {
+ return text;
+ }
+
+ return null;
+}
+
+function buildLegacyEpisodeProgressContentKey(
+ identity: EpisodeProgressContentIdentity
+) {
+ 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 buildEpisodeProgressContentKey(
+ identity: EpisodeProgressContentIdentity
+) {
+ const doubanId = normalizeContentIdentityId(identity.doubanId);
+ if (doubanId) {
+ return `douban:${doubanId}`;
+ }
+
+ const tmdbId = normalizeContentIdentityId(identity.tmdbId);
+ if (tmdbId) {
+ const searchType = String(identity.searchType || '').trim().toLowerCase();
+ return searchType ? `tmdb:${searchType}:${tmdbId}` : `tmdb:${tmdbId}`;
+ }
+
+ return buildLegacyEpisodeProgressContentKey(identity);
+}
+
+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(
+ contentKey: string | null,
+ episodeIndex: number
+) {
+ const episodes = loadAllLocalEpisodeProgressRecords(contentKey);
+ return episodes[String(episodeIndex)] || null;
+}
+
+export function loadLocalEpisodeProgress(
+ contentKey: string | null,
+ episodeIndex: number
+) {
+ const record = loadLocalEpisodeProgressRecord(contentKey, episodeIndex);
+ if (!record) {
+ return null;
+ }
+
+ return Number.isFinite(record.playTime) && record.playTime > 1
+ ? Math.floor(record.playTime)
+ : null;
+}
+
+export function pruneLocalEpisodeProgressStorage(
+ maxShows = EPISODE_PROGRESS_MAX_SHOWS
+) {
+ if (!isBrowser()) {
+ return;
+ }
+
+ const entries = collectEpisodeProgressEntries().sort(
+ (a, b) => b.updatedAt - a.updatedAt
+ );
+
+ if (entries.length <= maxShows) {
+ return;
+ }
+
+ entries.slice(maxShows).forEach(({ key }) => {
+ localStorage.removeItem(key);
+ });
+}
+
+export function saveLocalEpisodeProgress(
+ contentKey: string | null,
+ episodeIndex: number,
+ playTime: number,
+ totalTime: number
+) {
+ if (
+ !isBrowser() ||
+ !contentKey ||
+ !Number.isFinite(playTime) ||
+ playTime <= 0
+ ) {
+ return;
+ }
+
+ const key = getEpisodeProgressStorageKey(contentKey);
+ const now = Date.now();
+ const currentStore = readEpisodeProgressStore(contentKey);
+ const shouldPruneAfterSave = !currentStore;
+ 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);
+ if (shouldPruneAfterSave) {
+ pruneLocalEpisodeProgressStorage();
+ }
+ } catch (error) {
+ if (!isQuotaExceededError(error)) {
+ throw error;
+ }
+
+ pruneLocalEpisodeProgressStorage(
+ Math.max(10, Math.floor(EPISODE_PROGRESS_MAX_SHOWS / 2))
+ );
+ localStorage.setItem(key, payload);
+ pruneLocalEpisodeProgressStorage();
+ }
+}