Merge branch 'pr-265' into dev
This commit is contained in:
@@ -20,6 +20,7 @@ export interface AdminConfig {
|
||||
DanmakuSourceType?: 'builtin' | 'custom';
|
||||
DanmakuApiBase: string;
|
||||
DanmakuApiToken: string;
|
||||
DanmakuAutoLoadDefault?: boolean; // 是否默认自动加载弹幕(用户可在本地覆盖)
|
||||
// TMDB配置
|
||||
TMDBApiKey?: string;
|
||||
TMDBProxy?: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+97
-5
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<string, LocalEpisodeProgressRecord>;
|
||||
}
|
||||
|
||||
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<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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user