增加播放记录批量删除功能

This commit is contained in:
mtvpls
2026-06-01 17:52:16 +08:00
parent 2f1a763367
commit 0201891efa
8 changed files with 2721 additions and 828 deletions
+233 -67
View File
@@ -17,7 +17,7 @@
import { getAuthInfoFromBrowserCookie, clearAuthCookie } from './auth';
import { normalizeEpisodeFilterConfig } from './episode-filter';
import { MangaReadRecord, MangaShelfItem } from './manga.types';
import { DanmakuFilterConfig, EpisodeFilterConfig,SkipConfig } from './types';
import { DanmakuFilterConfig, EpisodeFilterConfig, SkipConfig } from './types';
// 全局错误触发函数
function triggerGlobalError(message: string) {
@@ -97,7 +97,8 @@ const FAVORITES_KEY = 'moontv_favorites';
const MANGA_SHELF_KEY = 'moontv_manga_shelf';
const MANGA_HISTORY_KEY = 'moontv_manga_history';
const DEFAULT_MAX_MANGA_HISTORY_RECORDS = 100;
const DEFAULT_MAX_MANGA_HISTORY_THRESHOLD = DEFAULT_MAX_MANGA_HISTORY_RECORDS + 10;
const DEFAULT_MAX_MANGA_HISTORY_THRESHOLD =
DEFAULT_MAX_MANGA_HISTORY_RECORDS + 10;
const SEARCH_HISTORY_KEY = 'moontv_search_history';
const MUSIC_PLAY_RECORDS_KEY = 'moontv_music_play_records';
@@ -141,10 +142,7 @@ class HybridCacheManager {
/**
* 获取或创建请求 Promise(防止并发重复请求)
*/
getOrCreateRequest<T>(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
getOrCreateRequest<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
// 如果已有正在进行的请求,直接返回
if (this.pendingRequests.has(key)) {
console.log(`[${key}] 复用进行中的请求`);
@@ -153,11 +151,10 @@ class HybridCacheManager {
console.log(`[${key}] 创建新请求`);
// 创建新请求
const promise = fetcher()
.finally(() => {
// 请求完成后清除缓存
this.pendingRequests.delete(key);
});
const promise = fetcher().finally(() => {
// 请求完成后清除缓存
this.pendingRequests.delete(key);
});
this.pendingRequests.set(key, promise);
return promise;
@@ -249,7 +246,10 @@ class HybridCacheManager {
delete cache.mangaShelf;
}
if (cache.mangaReadRecords && now - cache.mangaReadRecords.timestamp > maxAge) {
if (
cache.mangaReadRecords &&
now - cache.mangaReadRecords.timestamp > maxAge
) {
delete cache.mangaReadRecords;
}
}
@@ -565,7 +565,12 @@ const cacheManager = HybridCacheManager.getInstance();
* 立即从数据库刷新对应类型的缓存以保持数据一致性
*/
async function handleDatabaseOperationFailure(
dataType: 'playRecords' | 'favorites' | 'searchHistory' | 'mangaShelf' | 'mangaHistory',
dataType:
| 'playRecords'
| 'favorites'
| 'searchHistory'
| 'mangaShelf'
| 'mangaHistory',
error: any
): Promise<void> {
console.error(`数据库操作失败 (${dataType}):`, error);
@@ -598,12 +603,16 @@ async function handleDatabaseOperationFailure(
eventName = 'searchHistoryUpdated';
break;
case 'mangaShelf':
freshData = await fetchFromApi<Record<string, MangaShelfItem>>(`/api/manga/shelf`);
freshData = await fetchFromApi<Record<string, MangaShelfItem>>(
`/api/manga/shelf`
);
cacheManager.cacheMangaShelf(freshData);
eventName = 'mangaShelfUpdated';
break;
case 'mangaHistory':
freshData = await fetchFromApi<Record<string, MangaReadRecord>>(`/api/manga/history`);
freshData = await fetchFromApi<Record<string, MangaReadRecord>>(
`/api/manga/history`
);
cacheManager.cacheMangaReadRecords(freshData);
eventName = 'mangaHistoryUpdated';
break;
@@ -642,9 +651,16 @@ export async function fetchWithAuth(
const text = await res.clone().text();
// 只有当响应体包含 "Unauthorized" 或 "Refresh token expired" 或 "Access token expired" 时才处理
if (text.includes('Unauthorized') || text.includes('Refresh token expired') || text.includes('Access token expired')) {
if (
text.includes('Unauthorized') ||
text.includes('Refresh token expired') ||
text.includes('Access token expired')
) {
// 如果在登录页面,跳过刷新逻辑
if (typeof window !== 'undefined' && window.location.pathname === '/login') {
if (
typeof window !== 'undefined' &&
window.location.pathname === '/login'
) {
console.log('[fetchWithAuth] On login page, skipping refresh logic');
return res;
}
@@ -671,7 +687,9 @@ export async function fetchWithAuth(
}
} else {
// 不是认证错误的401,直接返回
console.log('[fetchWithAuth] Received 401 but not an auth error, skipping refresh');
console.log(
'[fetchWithAuth] Received 401 but not an auth error, skipping refresh'
);
return res;
}
@@ -679,9 +697,16 @@ export async function fetchWithAuth(
if (res.status === 401) {
const text2 = await res.clone().text();
// 再次检查响应体
if (text2.includes('Unauthorized') || text2.includes('Refresh token expired') || text2.includes('Access token expired')) {
if (
text2.includes('Unauthorized') ||
text2.includes('Refresh token expired') ||
text2.includes('Access token expired')
) {
// 检查当前页面是否已经是登录页,避免重复跳转
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
if (
typeof window !== 'undefined' &&
!window.location.pathname.startsWith('/login')
) {
// 调用 logout 接口
try {
await fetch('/api/logout', {
@@ -824,7 +849,10 @@ export function getCachedPlayRecordsSnapshot(): Record<string, PlayRecord> {
}
}
export function getCachedMangaReadRecordsSnapshot(): Record<string, MangaReadRecord> {
export function getCachedMangaReadRecordsSnapshot(): Record<
string,
MangaReadRecord
> {
if (typeof window === 'undefined') {
return {};
}
@@ -995,6 +1023,66 @@ export async function deletePlayRecord(
}
}
/**
* 批量删除播放记录。
* 数据库存储模式下只发起一次 API 请求,并进行一次缓存/事件更新。
*/
export async function deletePlayRecords(keys: string[]): Promise<void> {
const uniqueKeys = Array.from(new Set(keys)).filter(Boolean);
if (uniqueKeys.length === 0) return;
// 数据库存储模式:一次性乐观更新 + 一次 API 请求
if (STORAGE_TYPE !== 'localstorage') {
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
uniqueKeys.forEach((key) => {
delete cachedRecords[key];
});
cacheManager.cachePlayRecords(cachedRecords);
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: cachedRecords,
})
);
try {
await fetchWithAuth('/api/playrecords', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keys: uniqueKeys }),
});
} catch (err) {
await handleDatabaseOperationFailure('playRecords', err);
triggerGlobalError('删除播放记录失败');
throw err;
}
return;
}
// localstorage 模式:一次性更新本地数据和事件
if (typeof window === 'undefined') {
console.warn('无法在服务端删除播放记录到 localStorage');
return;
}
try {
const allRecords = await getAllPlayRecords();
uniqueKeys.forEach((key) => {
delete allRecords[key];
});
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: allRecords,
})
);
} catch (err) {
console.error('批量删除播放记录失败:', err);
triggerGlobalError('删除播放记录失败');
throw err;
}
}
/**
* 迁移播放记录到新的 source/id。
* 用于换源时保留单一记忆点语义:当前进度迁移到新源后,再清理旧源记录。
@@ -1038,9 +1126,12 @@ export async function migratePlayRecord(
body: JSON.stringify({ key: toKey, record }),
});
await fetchWithAuth(`/api/playrecords?key=${encodeURIComponent(fromKey)}`, {
method: 'DELETE',
});
await fetchWithAuth(
`/api/playrecords?key=${encodeURIComponent(fromKey)}`,
{
method: 'DELETE',
}
);
};
persistMove().catch((err) => {
@@ -1307,7 +1398,8 @@ export async function deleteSearchHistory(keyword: string): Promise<void> {
// 模块级别的防重复请求机制
let pendingFavoritesBackgroundRequest: Promise<void> | null = null;
let pendingFavoritesFetchRequest: Promise<Record<string, Favorite>> | null = null;
let pendingFavoritesFetchRequest: Promise<Record<string, Favorite>> | null =
null;
let lastFavoritesBackgroundFetchTime = 0;
const MIN_BACKGROUND_FETCH_INTERVAL = 3000; // 3秒内不重复后台请求
@@ -1329,12 +1421,18 @@ export async function getAllFavorites(): Promise<Record<string, Favorite>> {
if (cachedData) {
// 有缓存:返回缓存,后台异步刷新(带防抖和防重复)
const now = Date.now();
if (now - lastFavoritesBackgroundFetchTime > MIN_BACKGROUND_FETCH_INTERVAL && !pendingFavoritesBackgroundRequest) {
if (
now - lastFavoritesBackgroundFetchTime >
MIN_BACKGROUND_FETCH_INTERVAL &&
!pendingFavoritesBackgroundRequest
) {
lastFavoritesBackgroundFetchTime = now;
pendingFavoritesBackgroundRequest = (async () => {
try {
const freshData = await fetchFromApi<Record<string, Favorite>>(`/api/favorites`);
const freshData = await fetchFromApi<Record<string, Favorite>>(
`/api/favorites`
);
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheFavorites(freshData);
@@ -1363,7 +1461,9 @@ export async function getAllFavorites(): Promise<Record<string, Favorite>> {
pendingFavoritesFetchRequest = (async () => {
try {
const freshData = await fetchFromApi<Record<string, Favorite>>(`/api/favorites`);
const freshData = await fetchFromApi<Record<string, Favorite>>(
`/api/favorites`
);
cacheManager.cacheFavorites(freshData);
return freshData;
} catch (err) {
@@ -1627,10 +1727,11 @@ export async function clearAllFavorites(): Promise<void> {
);
}
// ---------------- 漫画书架 / 历史 API ----------------
export async function getAllMangaShelf(): Promise<Record<string, MangaShelfItem>> {
export async function getAllMangaShelf(): Promise<
Record<string, MangaShelfItem>
> {
if (typeof window === 'undefined') return {};
if (STORAGE_TYPE !== 'localstorage') {
@@ -1640,7 +1741,9 @@ export async function getAllMangaShelf(): Promise<Record<string, MangaShelfItem>
.then((freshData) => {
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheMangaShelf(freshData);
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: freshData }));
window.dispatchEvent(
new CustomEvent('mangaShelfUpdated', { detail: freshData })
);
}
})
.catch((err) => {
@@ -1650,7 +1753,9 @@ export async function getAllMangaShelf(): Promise<Record<string, MangaShelfItem>
}
try {
const freshData = await fetchFromApi<Record<string, MangaShelfItem>>('/api/manga/shelf');
const freshData = await fetchFromApi<Record<string, MangaShelfItem>>(
'/api/manga/shelf'
);
cacheManager.cacheMangaShelf(freshData);
return freshData;
} catch (err) {
@@ -1671,14 +1776,20 @@ export async function getAllMangaShelf(): Promise<Record<string, MangaShelfItem>
}
}
export async function saveMangaShelf(sourceId: string, mangaId: string, item: MangaShelfItem): Promise<void> {
export async function saveMangaShelf(
sourceId: string,
mangaId: string,
item: MangaShelfItem
): Promise<void> {
const key = generateStorageKey(sourceId, mangaId);
if (STORAGE_TYPE !== 'localstorage') {
const cached = cacheManager.getCachedMangaShelf() || {};
cached[key] = item;
cacheManager.cacheMangaShelf(cached);
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: cached }));
window.dispatchEvent(
new CustomEvent('mangaShelfUpdated', { detail: cached })
);
try {
await fetchWithAuth('/api/manga/shelf', {
@@ -1696,20 +1807,29 @@ export async function saveMangaShelf(sourceId: string, mangaId: string, item: Ma
const allItems = await getAllMangaShelf();
allItems[key] = item;
localStorage.setItem(MANGA_SHELF_KEY, JSON.stringify(allItems));
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: allItems }));
window.dispatchEvent(
new CustomEvent('mangaShelfUpdated', { detail: allItems })
);
}
export async function deleteMangaShelf(sourceId: string, mangaId: string): Promise<void> {
export async function deleteMangaShelf(
sourceId: string,
mangaId: string
): Promise<void> {
const key = generateStorageKey(sourceId, mangaId);
if (STORAGE_TYPE !== 'localstorage') {
const cached = cacheManager.getCachedMangaShelf() || {};
delete cached[key];
cacheManager.cacheMangaShelf(cached);
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: cached }));
window.dispatchEvent(
new CustomEvent('mangaShelfUpdated', { detail: cached })
);
try {
await fetchWithAuth(`/api/manga/shelf?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
await fetchWithAuth(`/api/manga/shelf?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
});
} catch (err) {
await handleDatabaseOperationFailure('mangaShelf', err);
throw err;
@@ -1720,7 +1840,9 @@ export async function deleteMangaShelf(sourceId: string, mangaId: string): Promi
const allItems = await getAllMangaShelf();
delete allItems[key];
localStorage.setItem(MANGA_SHELF_KEY, JSON.stringify(allItems));
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: allItems }));
window.dispatchEvent(
new CustomEvent('mangaShelfUpdated', { detail: allItems })
);
}
export async function clearAllMangaShelf(): Promise<void> {
@@ -1740,7 +1862,9 @@ export async function clearAllMangaShelf(): Promise<void> {
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: {} }));
}
function trimMangaReadRecords(records: Record<string, MangaReadRecord>): Record<string, MangaReadRecord> {
function trimMangaReadRecords(
records: Record<string, MangaReadRecord>
): Record<string, MangaReadRecord> {
const entries = Object.entries(records);
if (entries.length <= DEFAULT_MAX_MANGA_HISTORY_THRESHOLD) return records;
@@ -1751,7 +1875,9 @@ function trimMangaReadRecords(records: Record<string, MangaReadRecord>): Record<
);
}
export async function getAllMangaReadRecords(): Promise<Record<string, MangaReadRecord>> {
export async function getAllMangaReadRecords(): Promise<
Record<string, MangaReadRecord>
> {
if (typeof window === 'undefined') return {};
if (STORAGE_TYPE !== 'localstorage') {
@@ -1761,7 +1887,9 @@ export async function getAllMangaReadRecords(): Promise<Record<string, MangaRead
.then((freshData) => {
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheMangaReadRecords(freshData);
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: freshData }));
window.dispatchEvent(
new CustomEvent('mangaHistoryUpdated', { detail: freshData })
);
}
})
.catch((err) => {
@@ -1771,7 +1899,9 @@ export async function getAllMangaReadRecords(): Promise<Record<string, MangaRead
}
try {
const freshData = await fetchFromApi<Record<string, MangaReadRecord>>('/api/manga/history');
const freshData = await fetchFromApi<Record<string, MangaReadRecord>>(
'/api/manga/history'
);
cacheManager.cacheMangaReadRecords(freshData);
return freshData;
} catch (err) {
@@ -1792,7 +1922,11 @@ export async function getAllMangaReadRecords(): Promise<Record<string, MangaRead
}
}
export async function saveMangaReadRecord(sourceId: string, mangaId: string, record: MangaReadRecord): Promise<void> {
export async function saveMangaReadRecord(
sourceId: string,
mangaId: string,
record: MangaReadRecord
): Promise<void> {
const key = generateStorageKey(sourceId, mangaId);
if (STORAGE_TYPE !== 'localstorage') {
@@ -1800,7 +1934,9 @@ export async function saveMangaReadRecord(sourceId: string, mangaId: string, rec
cached[key] = record;
const trimmedRecords = trimMangaReadRecords(cached);
cacheManager.cacheMangaReadRecords(trimmedRecords);
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: trimmedRecords }));
window.dispatchEvent(
new CustomEvent('mangaHistoryUpdated', { detail: trimmedRecords })
);
try {
await fetchWithAuth('/api/manga/history', {
@@ -1819,20 +1955,29 @@ export async function saveMangaReadRecord(sourceId: string, mangaId: string, rec
allRecords[key] = record;
const trimmedRecords = trimMangaReadRecords(allRecords);
localStorage.setItem(MANGA_HISTORY_KEY, JSON.stringify(trimmedRecords));
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: trimmedRecords }));
window.dispatchEvent(
new CustomEvent('mangaHistoryUpdated', { detail: trimmedRecords })
);
}
export async function deleteMangaReadRecord(sourceId: string, mangaId: string): Promise<void> {
export async function deleteMangaReadRecord(
sourceId: string,
mangaId: string
): Promise<void> {
const key = generateStorageKey(sourceId, mangaId);
if (STORAGE_TYPE !== 'localstorage') {
const cached = cacheManager.getCachedMangaReadRecords() || {};
delete cached[key];
cacheManager.cacheMangaReadRecords(cached);
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: cached }));
window.dispatchEvent(
new CustomEvent('mangaHistoryUpdated', { detail: cached })
);
try {
await fetchWithAuth(`/api/manga/history?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
await fetchWithAuth(`/api/manga/history?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
});
} catch (err) {
await handleDatabaseOperationFailure('mangaHistory', err);
throw err;
@@ -1843,13 +1988,17 @@ export async function deleteMangaReadRecord(sourceId: string, mangaId: string):
const allRecords = await getAllMangaReadRecords();
delete allRecords[key];
localStorage.setItem(MANGA_HISTORY_KEY, JSON.stringify(allRecords));
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: allRecords }));
window.dispatchEvent(
new CustomEvent('mangaHistoryUpdated', { detail: allRecords })
);
}
export async function clearAllMangaReadRecords(): Promise<void> {
if (STORAGE_TYPE !== 'localstorage') {
cacheManager.cacheMangaReadRecords({});
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: {} }));
window.dispatchEvent(
new CustomEvent('mangaHistoryUpdated', { detail: {} })
);
try {
await fetchWithAuth('/api/manga/history', { method: 'DELETE' });
} catch (err) {
@@ -1886,15 +2035,21 @@ export async function refreshAllCache(): Promise<void> {
// 使用 Promise 缓存防止并发重复刷新
await cacheManager.getOrCreateRequest('refresh-all-cache', async () => {
// 并行刷新所有数据
const [playRecords, favorites, mangaShelf, mangaHistory, searchHistory, skipConfigs] =
await Promise.allSettled([
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
fetchFromApi<Record<string, MangaShelfItem>>(`/api/manga/shelf`),
fetchFromApi<Record<string, MangaReadRecord>>(`/api/manga/history`),
fetchFromApi<string[]>(`/api/searchhistory`),
fetchFromApi<Record<string, SkipConfig>>(`/api/skipconfigs`),
]);
const [
playRecords,
favorites,
mangaShelf,
mangaHistory,
searchHistory,
skipConfigs,
] = await Promise.allSettled([
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
fetchFromApi<Record<string, MangaShelfItem>>(`/api/manga/shelf`),
fetchFromApi<Record<string, MangaReadRecord>>(`/api/manga/history`),
fetchFromApi<string[]>(`/api/searchhistory`),
fetchFromApi<Record<string, SkipConfig>>(`/api/skipconfigs`),
]);
if (playRecords.status === 'fulfilled') {
cacheManager.cachePlayRecords(playRecords.value);
@@ -2020,7 +2175,7 @@ export function subscribeToDataUpdates<T>(
callback: (data: T) => void
): () => void {
if (typeof window === 'undefined') {
return () => { };
return () => {};
}
const handleUpdate = (event: CustomEvent) => {
@@ -2425,7 +2580,10 @@ export async function saveDanmakuFilterConfig(
}
try {
localStorage.setItem('moontv_danmaku_filter_config', JSON.stringify(config));
localStorage.setItem(
'moontv_danmaku_filter_config',
JSON.stringify(config)
);
window.dispatchEvent(
new CustomEvent('danmakuFilterConfigUpdated', {
detail: config,
@@ -2444,7 +2602,9 @@ export async function saveDanmakuFilterConfig(
* 获取全部音乐播放记录。
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
*/
export async function getAllMusicPlayRecords(): Promise<Record<string, MusicPlayRecord>> {
export async function getAllMusicPlayRecords(): Promise<
Record<string, MusicPlayRecord>
> {
// 服务器端渲染阶段直接返回空
if (typeof window === 'undefined') {
return {};
@@ -2594,9 +2754,12 @@ export async function deleteMusicPlayRecord(
// 异步同步到数据库
try {
await fetchWithAuth(`/api/music/playrecords?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
});
await fetchWithAuth(
`/api/music/playrecords?key=${encodeURIComponent(key)}`,
{
method: 'DELETE',
}
);
} catch (err) {
console.error('删除音乐播放记录失败:', err);
triggerGlobalError('删除音乐播放记录失败');
@@ -2701,7 +2864,10 @@ export async function saveEpisodeFilterConfig(
try {
const normalizedConfig = normalizeEpisodeFilterConfig(config);
localStorage.setItem('moontv_episode_filter_config', JSON.stringify(normalizedConfig));
localStorage.setItem(
'moontv_episode_filter_config',
JSON.stringify(normalizedConfig)
);
window.dispatchEvent(
new CustomEvent('episodeFilterConfigUpdated', {
detail: normalizedConfig,