增加漫画展馆功能
This commit is contained in:
@@ -243,6 +243,14 @@ export interface AdminConfig {
|
||||
Password?: string; // 密码认证(备选)
|
||||
DisableVideoPreview?: boolean; // 禁用预览视频,直接返回直连链接
|
||||
};
|
||||
SuwayomiConfig?: {
|
||||
Enabled: boolean; // 是否启用漫画展馆
|
||||
ServerURL: string; // Suwayomi 服务地址
|
||||
AuthToken?: string; // 可选认证 Token
|
||||
DefaultLang?: string; // 默认语言,如 zh
|
||||
SourceIds?: string[]; // 限制可用源
|
||||
MaxSources?: number; // 搜索时最多查询多少个源
|
||||
};
|
||||
EmailConfig?: {
|
||||
enabled: boolean; // 是否启用邮件通知
|
||||
provider: 'smtp' | 'resend'; // 邮件发送方式
|
||||
|
||||
@@ -623,6 +623,35 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
}
|
||||
}
|
||||
|
||||
if (!adminConfig.SuwayomiConfig) {
|
||||
adminConfig.SuwayomiConfig = {
|
||||
Enabled: process.env.SUWAYOMI_ENABLED === 'true',
|
||||
ServerURL: process.env.SUWAYOMI_URL || process.env.NEXT_PUBLIC_SUWAYOMI_URL || '',
|
||||
AuthToken: process.env.SUWAYOMI_AUTH_TOKEN || '',
|
||||
DefaultLang: process.env.SUWAYOMI_DEFAULT_LANG || 'zh',
|
||||
SourceIds: [],
|
||||
MaxSources: Number(process.env.SUWAYOMI_MAX_SOURCES || 10),
|
||||
};
|
||||
}
|
||||
if (adminConfig.SuwayomiConfig.Enabled === undefined) {
|
||||
adminConfig.SuwayomiConfig.Enabled = false;
|
||||
}
|
||||
if (adminConfig.SuwayomiConfig.ServerURL === undefined) {
|
||||
adminConfig.SuwayomiConfig.ServerURL = '';
|
||||
}
|
||||
if (adminConfig.SuwayomiConfig.AuthToken === undefined) {
|
||||
adminConfig.SuwayomiConfig.AuthToken = '';
|
||||
}
|
||||
if (adminConfig.SuwayomiConfig.DefaultLang === undefined) {
|
||||
adminConfig.SuwayomiConfig.DefaultLang = 'zh';
|
||||
}
|
||||
if (!Array.isArray(adminConfig.SuwayomiConfig.SourceIds)) {
|
||||
adminConfig.SuwayomiConfig.SourceIds = [];
|
||||
}
|
||||
if (adminConfig.SuwayomiConfig.MaxSources === undefined || Number.isNaN(adminConfig.SuwayomiConfig.MaxSources)) {
|
||||
adminConfig.SuwayomiConfig.MaxSources = 10;
|
||||
}
|
||||
|
||||
if (!adminConfig.NetDiskConfig) {
|
||||
adminConfig.NetDiskConfig = {
|
||||
Quark: {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
MovieRequest,
|
||||
} from './types';
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { MangaReadRecord, MangaShelfItem } from './manga.types';
|
||||
import { DatabaseAdapter } from './d1-adapter';
|
||||
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
|
||||
import { userInfoCache } from './user-cache';
|
||||
@@ -1775,6 +1776,268 @@ export class D1Storage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 漫画书架 ====================
|
||||
|
||||
async getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM manga_shelf WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
.first();
|
||||
|
||||
if (!result) return null;
|
||||
return {
|
||||
title: result.title as string,
|
||||
cover: (result.cover as string) || '',
|
||||
sourceId: result.source_id as string,
|
||||
sourceName: result.source_name as string,
|
||||
mangaId: result.manga_id as string,
|
||||
saveTime: Number(result.save_time || 0),
|
||||
description: (result.description as string) || undefined,
|
||||
author: (result.author as string) || undefined,
|
||||
status: (result.status as string) || undefined,
|
||||
lastChapterId: (result.last_chapter_id as string) || undefined,
|
||||
lastChapterName: (result.last_chapter_name as string) || undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getMangaShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO manga_shelf (
|
||||
username, key, source_id, source_name, manga_id, title, cover, save_time,
|
||||
description, author, status, last_chapter_id, last_chapter_name
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(username, key) DO UPDATE SET
|
||||
source_id = excluded.source_id,
|
||||
source_name = excluded.source_name,
|
||||
manga_id = excluded.manga_id,
|
||||
title = excluded.title,
|
||||
cover = excluded.cover,
|
||||
save_time = excluded.save_time,
|
||||
description = excluded.description,
|
||||
author = excluded.author,
|
||||
status = excluded.status,
|
||||
last_chapter_id = excluded.last_chapter_id,
|
||||
last_chapter_name = excluded.last_chapter_name
|
||||
`)
|
||||
.bind(
|
||||
userName,
|
||||
key,
|
||||
item.sourceId,
|
||||
item.sourceName,
|
||||
item.mangaId,
|
||||
item.title,
|
||||
item.cover || '',
|
||||
item.saveTime,
|
||||
item.description || null,
|
||||
item.author || null,
|
||||
item.status || null,
|
||||
item.lastChapterId || null,
|
||||
item.lastChapterName || null
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.setMangaShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }> {
|
||||
try {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM manga_shelf WHERE username = ? ORDER BY save_time DESC')
|
||||
.bind(userName)
|
||||
.all();
|
||||
|
||||
const shelves: { [key: string]: MangaShelfItem } = {};
|
||||
if (!results.results) return shelves;
|
||||
|
||||
for (const row of results.results) {
|
||||
shelves[row.key as string] = {
|
||||
title: row.title as string,
|
||||
cover: (row.cover as string) || '',
|
||||
sourceId: row.source_id as string,
|
||||
sourceName: row.source_name as string,
|
||||
mangaId: row.manga_id as string,
|
||||
saveTime: Number(row.save_time || 0),
|
||||
description: (row.description as string) || undefined,
|
||||
author: (row.author as string) || undefined,
|
||||
status: (row.status as string) || undefined,
|
||||
lastChapterId: (row.last_chapter_id as string) || undefined,
|
||||
lastChapterName: (row.last_chapter_name as string) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return shelves;
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getAllMangaShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMangaShelf(userName: string, key: string): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare('DELETE FROM manga_shelf WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.deleteMangaShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 漫画阅读历史 ====================
|
||||
|
||||
async getMangaReadRecord(userName: string, key: string): Promise<MangaReadRecord | null> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM manga_read_records WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
.first();
|
||||
|
||||
if (!result) return null;
|
||||
return {
|
||||
title: result.title as string,
|
||||
cover: (result.cover as string) || '',
|
||||
sourceId: result.source_id as string,
|
||||
sourceName: result.source_name as string,
|
||||
mangaId: result.manga_id as string,
|
||||
chapterId: result.chapter_id as string,
|
||||
chapterName: result.chapter_name as string,
|
||||
pageIndex: Number(result.page_index || 0),
|
||||
pageCount: Number(result.page_count || 0),
|
||||
saveTime: Number(result.save_time || 0),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getMangaReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO manga_read_records (
|
||||
username, key, source_id, source_name, manga_id, title, cover,
|
||||
chapter_id, chapter_name, page_index, page_count, save_time
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(username, key) DO UPDATE SET
|
||||
source_id = excluded.source_id,
|
||||
source_name = excluded.source_name,
|
||||
manga_id = excluded.manga_id,
|
||||
title = excluded.title,
|
||||
cover = excluded.cover,
|
||||
chapter_id = excluded.chapter_id,
|
||||
chapter_name = excluded.chapter_name,
|
||||
page_index = excluded.page_index,
|
||||
page_count = excluded.page_count,
|
||||
save_time = excluded.save_time
|
||||
`)
|
||||
.bind(
|
||||
userName,
|
||||
key,
|
||||
record.sourceId,
|
||||
record.sourceName,
|
||||
record.mangaId,
|
||||
record.title,
|
||||
record.cover || '',
|
||||
record.chapterId,
|
||||
record.chapterName,
|
||||
record.pageIndex,
|
||||
record.pageCount,
|
||||
record.saveTime
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.setMangaReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }> {
|
||||
try {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM manga_read_records WHERE username = ? ORDER BY save_time DESC')
|
||||
.bind(userName)
|
||||
.all();
|
||||
|
||||
const records: { [key: string]: MangaReadRecord } = {};
|
||||
if (!results.results) return records;
|
||||
|
||||
for (const row of results.results) {
|
||||
records[row.key as string] = {
|
||||
title: row.title as string,
|
||||
cover: (row.cover as string) || '',
|
||||
sourceId: row.source_id as string,
|
||||
sourceName: row.source_name as string,
|
||||
mangaId: row.manga_id as string,
|
||||
chapterId: row.chapter_id as string,
|
||||
chapterName: row.chapter_name as string,
|
||||
pageIndex: Number(row.page_index || 0),
|
||||
pageCount: Number(row.page_count || 0),
|
||||
saveTime: Number(row.save_time || 0),
|
||||
};
|
||||
}
|
||||
|
||||
return records;
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getAllMangaReadRecords error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMangaReadRecord(userName: string, key: string): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare('DELETE FROM manga_read_records WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.deleteMangaReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupOldMangaReadRecords(userName: string): Promise<void> {
|
||||
try {
|
||||
const maxRecords = parseInt(process.env.MAX_MANGA_HISTORY_PER_USER || '100', 10);
|
||||
const countResult = await this.db
|
||||
.prepare('SELECT COUNT(*) as count FROM manga_read_records WHERE username = ?')
|
||||
.bind(userName)
|
||||
.first();
|
||||
|
||||
const count = Number(countResult?.count || 0);
|
||||
if (count <= maxRecords) return;
|
||||
|
||||
await this.db
|
||||
.prepare(`
|
||||
DELETE FROM manga_read_records
|
||||
WHERE username = ?
|
||||
AND key NOT IN (
|
||||
SELECT key FROM manga_read_records
|
||||
WHERE username = ?
|
||||
ORDER BY save_time DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`)
|
||||
.bind(userName, userName, maxRecords)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.cleanupOldMangaReadRecords error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 跳过配置 ====================
|
||||
|
||||
async getSkipConfig(userName: string, source: string, id: string): Promise<SkipConfig | null> {
|
||||
@@ -2235,6 +2498,8 @@ export class D1Storage implements IStorage {
|
||||
'play_records',
|
||||
'favorites',
|
||||
'search_history',
|
||||
'manga_shelf',
|
||||
'manga_read_records',
|
||||
'skip_configs',
|
||||
'music_play_records',
|
||||
'music_playlists',
|
||||
|
||||
+323
-3
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { getAuthInfoFromBrowserCookie, clearAuthCookie } from './auth';
|
||||
import { MangaReadRecord, MangaShelfItem } from './manga.types';
|
||||
import { DanmakuFilterConfig, EpisodeFilterConfig,SkipConfig } from './types';
|
||||
|
||||
// 全局错误触发函数
|
||||
@@ -81,6 +82,8 @@ interface CacheData<T> {
|
||||
interface UserCacheStore {
|
||||
playRecords?: CacheData<Record<string, PlayRecord>>;
|
||||
favorites?: CacheData<Record<string, Favorite>>;
|
||||
mangaShelf?: CacheData<Record<string, MangaShelfItem>>;
|
||||
mangaReadRecords?: CacheData<Record<string, MangaReadRecord>>;
|
||||
searchHistory?: CacheData<string[]>;
|
||||
skipConfigs?: CacheData<Record<string, SkipConfig>>;
|
||||
danmakuFilterConfig?: CacheData<DanmakuFilterConfig>;
|
||||
@@ -90,6 +93,8 @@ interface UserCacheStore {
|
||||
// ---- 常量 ----
|
||||
const PLAY_RECORDS_KEY = 'moontv_play_records';
|
||||
const FAVORITES_KEY = 'moontv_favorites';
|
||||
const MANGA_SHELF_KEY = 'moontv_manga_shelf';
|
||||
const MANGA_HISTORY_KEY = 'moontv_manga_history';
|
||||
const SEARCH_HISTORY_KEY = 'moontv_search_history';
|
||||
const MUSIC_PLAY_RECORDS_KEY = 'moontv_music_play_records';
|
||||
|
||||
@@ -236,6 +241,14 @@ class HybridCacheManager {
|
||||
if (cache.favorites && now - cache.favorites.timestamp > maxAge) {
|
||||
delete cache.favorites;
|
||||
}
|
||||
|
||||
if (cache.mangaShelf && now - cache.mangaShelf.timestamp > maxAge) {
|
||||
delete cache.mangaShelf;
|
||||
}
|
||||
|
||||
if (cache.mangaReadRecords && now - cache.mangaReadRecords.timestamp > maxAge) {
|
||||
delete cache.mangaReadRecords;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,6 +343,52 @@ class HybridCacheManager {
|
||||
this.saveUserCache(username, userCache);
|
||||
}
|
||||
|
||||
getCachedMangaShelf(): Record<string, MangaShelfItem> | null {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return null;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
const cached = userCache.mangaShelf;
|
||||
|
||||
if (cached && this.isCacheValid(cached)) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
cacheMangaShelf(data: Record<string, MangaShelfItem>): void {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
userCache.mangaShelf = this.createCacheData(data);
|
||||
this.saveUserCache(username, userCache);
|
||||
}
|
||||
|
||||
getCachedMangaReadRecords(): Record<string, MangaReadRecord> | null {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return null;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
const cached = userCache.mangaReadRecords;
|
||||
|
||||
if (cached && this.isCacheValid(cached)) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
cacheMangaReadRecords(data: Record<string, MangaReadRecord>): void {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
userCache.mangaReadRecords = this.createCacheData(data);
|
||||
this.saveUserCache(username, userCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的搜索历史
|
||||
*/
|
||||
@@ -503,7 +562,7 @@ const cacheManager = HybridCacheManager.getInstance();
|
||||
* 立即从数据库刷新对应类型的缓存以保持数据一致性
|
||||
*/
|
||||
async function handleDatabaseOperationFailure(
|
||||
dataType: 'playRecords' | 'favorites' | 'searchHistory',
|
||||
dataType: 'playRecords' | 'favorites' | 'searchHistory' | 'mangaShelf' | 'mangaHistory',
|
||||
error: any
|
||||
): Promise<void> {
|
||||
console.error(`数据库操作失败 (${dataType}):`, error);
|
||||
@@ -535,6 +594,16 @@ async function handleDatabaseOperationFailure(
|
||||
cacheManager.cacheSearchHistory(freshData);
|
||||
eventName = 'searchHistoryUpdated';
|
||||
break;
|
||||
case 'mangaShelf':
|
||||
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`);
|
||||
cacheManager.cacheMangaReadRecords(freshData);
|
||||
eventName = 'mangaHistoryUpdated';
|
||||
break;
|
||||
}
|
||||
|
||||
// 触发更新事件通知组件
|
||||
@@ -1519,6 +1588,229 @@ export async function clearAllFavorites(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ---------------- 漫画书架 / 历史 API ----------------
|
||||
|
||||
export async function getAllMangaShelf(): Promise<Record<string, MangaShelfItem>> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
const cachedData = cacheManager.getCachedMangaShelf();
|
||||
if (cachedData) {
|
||||
fetchFromApi<Record<string, MangaShelfItem>>('/api/manga/shelf')
|
||||
.then((freshData) => {
|
||||
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
||||
cacheManager.cacheMangaShelf(freshData);
|
||||
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: freshData }));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('后台同步漫画书架失败:', err);
|
||||
});
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
try {
|
||||
const freshData = await fetchFromApi<Record<string, MangaShelfItem>>('/api/manga/shelf');
|
||||
cacheManager.cacheMangaShelf(freshData);
|
||||
return freshData;
|
||||
} catch (err) {
|
||||
console.error('获取漫画书架失败:', err);
|
||||
triggerGlobalError('获取漫画书架失败');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(MANGA_SHELF_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Record<string, MangaShelfItem>;
|
||||
} catch (err) {
|
||||
console.error('读取漫画书架失败:', err);
|
||||
triggerGlobalError('读取漫画书架失败');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
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 }));
|
||||
|
||||
try {
|
||||
await fetchWithAuth('/api/manga/shelf', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, item }),
|
||||
});
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('mangaShelf', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const allItems = await getAllMangaShelf();
|
||||
allItems[key] = item;
|
||||
localStorage.setItem(MANGA_SHELF_KEY, JSON.stringify(allItems));
|
||||
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: allItems }));
|
||||
}
|
||||
|
||||
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 }));
|
||||
|
||||
try {
|
||||
await fetchWithAuth(`/api/manga/shelf?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('mangaShelf', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const allItems = await getAllMangaShelf();
|
||||
delete allItems[key];
|
||||
localStorage.setItem(MANGA_SHELF_KEY, JSON.stringify(allItems));
|
||||
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: allItems }));
|
||||
}
|
||||
|
||||
export async function clearAllMangaShelf(): Promise<void> {
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
cacheManager.cacheMangaShelf({});
|
||||
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: {} }));
|
||||
try {
|
||||
await fetchWithAuth('/api/manga/shelf', { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('mangaShelf', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(MANGA_SHELF_KEY);
|
||||
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: {} }));
|
||||
}
|
||||
|
||||
export async function getAllMangaReadRecords(): Promise<Record<string, MangaReadRecord>> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
const cachedData = cacheManager.getCachedMangaReadRecords();
|
||||
if (cachedData) {
|
||||
fetchFromApi<Record<string, MangaReadRecord>>('/api/manga/history')
|
||||
.then((freshData) => {
|
||||
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
||||
cacheManager.cacheMangaReadRecords(freshData);
|
||||
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: freshData }));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('后台同步漫画历史失败:', err);
|
||||
});
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
try {
|
||||
const freshData = await fetchFromApi<Record<string, MangaReadRecord>>('/api/manga/history');
|
||||
cacheManager.cacheMangaReadRecords(freshData);
|
||||
return freshData;
|
||||
} catch (err) {
|
||||
console.error('获取漫画历史失败:', err);
|
||||
triggerGlobalError('获取漫画历史失败');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(MANGA_HISTORY_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Record<string, MangaReadRecord>;
|
||||
} catch (err) {
|
||||
console.error('读取漫画历史失败:', err);
|
||||
triggerGlobalError('读取漫画历史失败');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMangaReadRecord(sourceId: string, mangaId: string, record: MangaReadRecord): Promise<void> {
|
||||
const key = generateStorageKey(sourceId, mangaId);
|
||||
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
const cached = cacheManager.getCachedMangaReadRecords() || {};
|
||||
cached[key] = record;
|
||||
cacheManager.cacheMangaReadRecords(cached);
|
||||
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: cached }));
|
||||
|
||||
try {
|
||||
await fetchWithAuth('/api/manga/history', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, record }),
|
||||
});
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('mangaHistory', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const allRecords = await getAllMangaReadRecords();
|
||||
allRecords[key] = record;
|
||||
localStorage.setItem(MANGA_HISTORY_KEY, JSON.stringify(allRecords));
|
||||
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: allRecords }));
|
||||
}
|
||||
|
||||
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 }));
|
||||
|
||||
try {
|
||||
await fetchWithAuth(`/api/manga/history?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('mangaHistory', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const allRecords = await getAllMangaReadRecords();
|
||||
delete allRecords[key];
|
||||
localStorage.setItem(MANGA_HISTORY_KEY, JSON.stringify(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: {} }));
|
||||
try {
|
||||
await fetchWithAuth('/api/manga/history', { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('mangaHistory', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(MANGA_HISTORY_KEY);
|
||||
window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: {} }));
|
||||
}
|
||||
|
||||
// ---------------- 混合缓存辅助函数 ----------------
|
||||
|
||||
/**
|
||||
@@ -1542,10 +1834,12 @@ export async function refreshAllCache(): Promise<void> {
|
||||
// 使用 Promise 缓存防止并发重复刷新
|
||||
await cacheManager.getOrCreateRequest('refresh-all-cache', async () => {
|
||||
// 并行刷新所有数据
|
||||
const [playRecords, favorites, searchHistory, 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`),
|
||||
]);
|
||||
@@ -1568,6 +1862,24 @@ export async function refreshAllCache(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
if (mangaShelf.status === 'fulfilled') {
|
||||
cacheManager.cacheMangaShelf(mangaShelf.value);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mangaShelfUpdated', {
|
||||
detail: mangaShelf.value,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (mangaHistory.status === 'fulfilled') {
|
||||
cacheManager.cacheMangaReadRecords(mangaHistory.value);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mangaHistoryUpdated', {
|
||||
detail: mangaHistory.value,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (searchHistory.status === 'fulfilled') {
|
||||
cacheManager.cacheSearchHistory(searchHistory.value);
|
||||
window.dispatchEvent(
|
||||
@@ -1601,6 +1913,8 @@ export function getCacheStatus(): {
|
||||
hasFavorites: boolean;
|
||||
hasSearchHistory: boolean;
|
||||
hasSkipConfigs: boolean;
|
||||
hasMangaShelf: boolean;
|
||||
hasMangaHistory: boolean;
|
||||
username: string | null;
|
||||
} {
|
||||
if (STORAGE_TYPE === 'localstorage') {
|
||||
@@ -1609,6 +1923,8 @@ export function getCacheStatus(): {
|
||||
hasFavorites: false,
|
||||
hasSearchHistory: false,
|
||||
hasSkipConfigs: false,
|
||||
hasMangaShelf: false,
|
||||
hasMangaHistory: false,
|
||||
username: null,
|
||||
};
|
||||
}
|
||||
@@ -1619,6 +1935,8 @@ export function getCacheStatus(): {
|
||||
hasFavorites: !!cacheManager.getCachedFavorites(),
|
||||
hasSearchHistory: !!cacheManager.getCachedSearchHistory(),
|
||||
hasSkipConfigs: !!cacheManager.getCachedSkipConfigs(),
|
||||
hasMangaShelf: !!cacheManager.getCachedMangaShelf(),
|
||||
hasMangaHistory: !!cacheManager.getCachedMangaReadRecords(),
|
||||
username: authInfo?.username || null,
|
||||
};
|
||||
}
|
||||
@@ -1629,7 +1947,9 @@ export type CacheUpdateEvent =
|
||||
| 'playRecordsUpdated'
|
||||
| 'favoritesUpdated'
|
||||
| 'searchHistoryUpdated'
|
||||
| 'skipConfigsUpdated';
|
||||
| 'skipConfigsUpdated'
|
||||
| 'mangaShelfUpdated'
|
||||
| 'mangaHistoryUpdated';
|
||||
|
||||
/**
|
||||
* 用于 React 组件监听数据更新的事件监听器
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { MusicPlayRecord } from './db.client';
|
||||
import { KvrocksStorage } from './kvrocks.db';
|
||||
import { MangaReadRecord, MangaShelfItem } from './manga.types';
|
||||
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
|
||||
import { RedisStorage } from './redis.db';
|
||||
import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
@@ -726,6 +727,40 @@ export class DbManager {
|
||||
await this.storage.deleteSearchHistory(userName, keyword);
|
||||
}
|
||||
|
||||
// ---------- 漫画书架 ----------
|
||||
async getMangaShelf(userName: string, sourceId: string, mangaId: string): Promise<MangaShelfItem | null> {
|
||||
return this.storage.getMangaShelf(userName, generateStorageKey(sourceId, mangaId));
|
||||
}
|
||||
|
||||
async saveMangaShelf(userName: string, sourceId: string, mangaId: string, item: MangaShelfItem): Promise<void> {
|
||||
await this.storage.setMangaShelf(userName, generateStorageKey(sourceId, mangaId), item);
|
||||
}
|
||||
|
||||
async getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }> {
|
||||
return this.storage.getAllMangaShelf(userName);
|
||||
}
|
||||
|
||||
async deleteMangaShelf(userName: string, sourceId: string, mangaId: string): Promise<void> {
|
||||
await this.storage.deleteMangaShelf(userName, generateStorageKey(sourceId, mangaId));
|
||||
}
|
||||
|
||||
// ---------- 漫画阅读历史 ----------
|
||||
async getMangaReadRecord(userName: string, sourceId: string, mangaId: string): Promise<MangaReadRecord | null> {
|
||||
return this.storage.getMangaReadRecord(userName, generateStorageKey(sourceId, mangaId));
|
||||
}
|
||||
|
||||
async saveMangaReadRecord(userName: string, sourceId: string, mangaId: string, record: MangaReadRecord): Promise<void> {
|
||||
await this.storage.setMangaReadRecord(userName, generateStorageKey(sourceId, mangaId), record);
|
||||
}
|
||||
|
||||
async getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }> {
|
||||
return this.storage.getAllMangaReadRecords(userName);
|
||||
}
|
||||
|
||||
async deleteMangaReadRecord(userName: string, sourceId: string, mangaId: string): Promise<void> {
|
||||
await this.storage.deleteMangaReadRecord(userName, generateStorageKey(sourceId, mangaId));
|
||||
}
|
||||
|
||||
// 获取全部用户名
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
if (typeof (this.storage as any).getAllUsers === 'function') {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface MangaSource {
|
||||
id: string;
|
||||
name: string;
|
||||
lang?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export interface MangaSearchItem {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
title: string;
|
||||
cover: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
status?: string;
|
||||
artist?: string;
|
||||
genre?: string;
|
||||
}
|
||||
|
||||
export interface MangaChapter {
|
||||
id: string;
|
||||
mangaId: string;
|
||||
name: string;
|
||||
chapterNumber?: number;
|
||||
scanlator?: string;
|
||||
isRead?: boolean;
|
||||
isDownloaded?: boolean;
|
||||
pageCount?: number;
|
||||
uploadDate?: number;
|
||||
}
|
||||
|
||||
export interface MangaDetail extends MangaSearchItem {
|
||||
chapters: MangaChapter[];
|
||||
}
|
||||
|
||||
export interface MangaShelfItem {
|
||||
title: string;
|
||||
cover: string;
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
mangaId: string;
|
||||
saveTime: number;
|
||||
description?: string;
|
||||
author?: string;
|
||||
status?: string;
|
||||
lastChapterId?: string;
|
||||
lastChapterName?: string;
|
||||
}
|
||||
|
||||
export interface MangaReadRecord {
|
||||
title: string;
|
||||
cover: string;
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
mangaId: string;
|
||||
chapterId: string;
|
||||
chapterName: string;
|
||||
pageIndex: number;
|
||||
pageCount: number;
|
||||
saveTime: number;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
MovieRequest,
|
||||
} from './types';
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { MangaReadRecord, MangaShelfItem } from './manga.types';
|
||||
import { DatabaseAdapter } from './d1-adapter';
|
||||
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
|
||||
|
||||
@@ -1747,6 +1748,268 @@ export class PostgresStorage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 漫画书架 ====================
|
||||
|
||||
async getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM manga_shelf WHERE username = $1 AND key = $2')
|
||||
.bind(userName, key)
|
||||
.first();
|
||||
|
||||
if (!result) return null;
|
||||
return {
|
||||
title: result.title as string,
|
||||
cover: (result.cover as string) || '',
|
||||
sourceId: result.source_id as string,
|
||||
sourceName: result.source_name as string,
|
||||
mangaId: result.manga_id as string,
|
||||
saveTime: Number(result.save_time || 0),
|
||||
description: (result.description as string) || undefined,
|
||||
author: (result.author as string) || undefined,
|
||||
status: (result.status as string) || undefined,
|
||||
lastChapterId: (result.last_chapter_id as string) || undefined,
|
||||
lastChapterName: (result.last_chapter_name as string) || undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.getMangaShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO manga_shelf (
|
||||
username, key, source_id, source_name, manga_id, title, cover, save_time,
|
||||
description, author, status, last_chapter_id, last_chapter_name
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
ON CONFLICT (username, key) DO UPDATE SET
|
||||
source_id = EXCLUDED.source_id,
|
||||
source_name = EXCLUDED.source_name,
|
||||
manga_id = EXCLUDED.manga_id,
|
||||
title = EXCLUDED.title,
|
||||
cover = EXCLUDED.cover,
|
||||
save_time = EXCLUDED.save_time,
|
||||
description = EXCLUDED.description,
|
||||
author = EXCLUDED.author,
|
||||
status = EXCLUDED.status,
|
||||
last_chapter_id = EXCLUDED.last_chapter_id,
|
||||
last_chapter_name = EXCLUDED.last_chapter_name
|
||||
`)
|
||||
.bind(
|
||||
userName,
|
||||
key,
|
||||
item.sourceId,
|
||||
item.sourceName,
|
||||
item.mangaId,
|
||||
item.title,
|
||||
item.cover || '',
|
||||
item.saveTime,
|
||||
item.description || null,
|
||||
item.author || null,
|
||||
item.status || null,
|
||||
item.lastChapterId || null,
|
||||
item.lastChapterName || null
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.setMangaShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }> {
|
||||
try {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM manga_shelf WHERE username = $1 ORDER BY save_time DESC')
|
||||
.bind(userName)
|
||||
.all();
|
||||
|
||||
const shelves: { [key: string]: MangaShelfItem } = {};
|
||||
if (!results.results) return shelves;
|
||||
|
||||
for (const row of results.results) {
|
||||
shelves[row.key as string] = {
|
||||
title: row.title as string,
|
||||
cover: (row.cover as string) || '',
|
||||
sourceId: row.source_id as string,
|
||||
sourceName: row.source_name as string,
|
||||
mangaId: row.manga_id as string,
|
||||
saveTime: Number(row.save_time || 0),
|
||||
description: (row.description as string) || undefined,
|
||||
author: (row.author as string) || undefined,
|
||||
status: (row.status as string) || undefined,
|
||||
lastChapterId: (row.last_chapter_id as string) || undefined,
|
||||
lastChapterName: (row.last_chapter_name as string) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return shelves;
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.getAllMangaShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMangaShelf(userName: string, key: string): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare('DELETE FROM manga_shelf WHERE username = $1 AND key = $2')
|
||||
.bind(userName, key)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.deleteMangaShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 漫画阅读历史 ====================
|
||||
|
||||
async getMangaReadRecord(userName: string, key: string): Promise<MangaReadRecord | null> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM manga_read_records WHERE username = $1 AND key = $2')
|
||||
.bind(userName, key)
|
||||
.first();
|
||||
|
||||
if (!result) return null;
|
||||
return {
|
||||
title: result.title as string,
|
||||
cover: (result.cover as string) || '',
|
||||
sourceId: result.source_id as string,
|
||||
sourceName: result.source_name as string,
|
||||
mangaId: result.manga_id as string,
|
||||
chapterId: result.chapter_id as string,
|
||||
chapterName: result.chapter_name as string,
|
||||
pageIndex: Number(result.page_index || 0),
|
||||
pageCount: Number(result.page_count || 0),
|
||||
saveTime: Number(result.save_time || 0),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.getMangaReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO manga_read_records (
|
||||
username, key, source_id, source_name, manga_id, title, cover,
|
||||
chapter_id, chapter_name, page_index, page_count, save_time
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (username, key) DO UPDATE SET
|
||||
source_id = EXCLUDED.source_id,
|
||||
source_name = EXCLUDED.source_name,
|
||||
manga_id = EXCLUDED.manga_id,
|
||||
title = EXCLUDED.title,
|
||||
cover = EXCLUDED.cover,
|
||||
chapter_id = EXCLUDED.chapter_id,
|
||||
chapter_name = EXCLUDED.chapter_name,
|
||||
page_index = EXCLUDED.page_index,
|
||||
page_count = EXCLUDED.page_count,
|
||||
save_time = EXCLUDED.save_time
|
||||
`)
|
||||
.bind(
|
||||
userName,
|
||||
key,
|
||||
record.sourceId,
|
||||
record.sourceName,
|
||||
record.mangaId,
|
||||
record.title,
|
||||
record.cover || '',
|
||||
record.chapterId,
|
||||
record.chapterName,
|
||||
record.pageIndex,
|
||||
record.pageCount,
|
||||
record.saveTime
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.setMangaReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }> {
|
||||
try {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM manga_read_records WHERE username = $1 ORDER BY save_time DESC')
|
||||
.bind(userName)
|
||||
.all();
|
||||
|
||||
const records: { [key: string]: MangaReadRecord } = {};
|
||||
if (!results.results) return records;
|
||||
|
||||
for (const row of results.results) {
|
||||
records[row.key as string] = {
|
||||
title: row.title as string,
|
||||
cover: (row.cover as string) || '',
|
||||
sourceId: row.source_id as string,
|
||||
sourceName: row.source_name as string,
|
||||
mangaId: row.manga_id as string,
|
||||
chapterId: row.chapter_id as string,
|
||||
chapterName: row.chapter_name as string,
|
||||
pageIndex: Number(row.page_index || 0),
|
||||
pageCount: Number(row.page_count || 0),
|
||||
saveTime: Number(row.save_time || 0),
|
||||
};
|
||||
}
|
||||
|
||||
return records;
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.getAllMangaReadRecords error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMangaReadRecord(userName: string, key: string): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare('DELETE FROM manga_read_records WHERE username = $1 AND key = $2')
|
||||
.bind(userName, key)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.deleteMangaReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupOldMangaReadRecords(userName: string): Promise<void> {
|
||||
try {
|
||||
const maxRecords = parseInt(process.env.MAX_MANGA_HISTORY_PER_USER || '100', 10);
|
||||
const countResult = await this.db
|
||||
.prepare('SELECT COUNT(*) as count FROM manga_read_records WHERE username = $1')
|
||||
.bind(userName)
|
||||
.first();
|
||||
|
||||
const count = Number(countResult?.count || 0);
|
||||
if (count <= maxRecords) return;
|
||||
|
||||
await this.db
|
||||
.prepare(`
|
||||
DELETE FROM manga_read_records
|
||||
WHERE username = $1
|
||||
AND key NOT IN (
|
||||
SELECT key FROM manga_read_records
|
||||
WHERE username = $1
|
||||
ORDER BY save_time DESC
|
||||
LIMIT $2
|
||||
)
|
||||
`)
|
||||
.bind(userName, maxRecords)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.cleanupOldMangaReadRecords error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 跳过配置 ====================
|
||||
|
||||
async getSkipConfig(userName: string, source: string, id: string): Promise<SkipConfig | null> {
|
||||
@@ -2205,6 +2468,8 @@ export class PostgresStorage implements IStorage {
|
||||
'play_records',
|
||||
'favorites',
|
||||
'search_history',
|
||||
'manga_shelf',
|
||||
'manga_read_records',
|
||||
'skip_configs',
|
||||
'music_play_records',
|
||||
'music_playlists',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { createClient, RedisClientType } from 'redis';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { MangaReadRecord, MangaShelfItem } from './manga.types';
|
||||
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
|
||||
import { RedisAdapter } from './redis-adapter';
|
||||
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
@@ -1005,6 +1006,10 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
// 删除收藏夹(新hash结构)
|
||||
await this.withRetry(() => this.adapter.del(this.favHashKey(userName)));
|
||||
|
||||
// 删除漫画书架与历史
|
||||
await this.withRetry(() => this.adapter.del(this.mangaShelfHashKey(userName)));
|
||||
await this.withRetry(() => this.adapter.del(this.mangaReadHashKey(userName)));
|
||||
|
||||
// 删除旧的收藏key(如果有)
|
||||
const favoritePattern = `u:${userName}:fav:*`;
|
||||
const favoriteKeys = await this.withRetry(() =>
|
||||
@@ -1495,6 +1500,73 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 漫画书架 ----------
|
||||
private mangaShelfHashKey(user: string) {
|
||||
return `u:${user}:manga:shelf`;
|
||||
}
|
||||
|
||||
async getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null> {
|
||||
const val = await this.withRetry(() => this.adapter.hGet(this.mangaShelfHashKey(userName), key));
|
||||
return val ? (JSON.parse(val) as MangaShelfItem) : null;
|
||||
}
|
||||
|
||||
async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void> {
|
||||
await this.withRetry(() => this.adapter.hSet(this.mangaShelfHashKey(userName), key, JSON.stringify(item)));
|
||||
}
|
||||
|
||||
async getAllMangaShelf(userName: string): Promise<Record<string, MangaShelfItem>> {
|
||||
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.mangaShelfHashKey(userName)));
|
||||
const result: Record<string, MangaShelfItem> = {};
|
||||
for (const [key, value] of Object.entries(hashData)) {
|
||||
if (value) result[key] = JSON.parse(value) as MangaShelfItem;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteMangaShelf(userName: string, key: string): Promise<void> {
|
||||
await this.withRetry(() => this.adapter.hDel(this.mangaShelfHashKey(userName), key));
|
||||
}
|
||||
|
||||
// ---------- 漫画阅读历史 ----------
|
||||
private mangaReadHashKey(user: string) {
|
||||
return `u:${user}:manga:history`;
|
||||
}
|
||||
|
||||
async getMangaReadRecord(userName: string, key: string): Promise<MangaReadRecord | null> {
|
||||
const val = await this.withRetry(() => this.adapter.hGet(this.mangaReadHashKey(userName), key));
|
||||
return val ? (JSON.parse(val) as MangaReadRecord) : null;
|
||||
}
|
||||
|
||||
async setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise<void> {
|
||||
await this.withRetry(() => this.adapter.hSet(this.mangaReadHashKey(userName), key, JSON.stringify(record)));
|
||||
}
|
||||
|
||||
async getAllMangaReadRecords(userName: string): Promise<Record<string, MangaReadRecord>> {
|
||||
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.mangaReadHashKey(userName)));
|
||||
const result: Record<string, MangaReadRecord> = {};
|
||||
for (const [key, value] of Object.entries(hashData)) {
|
||||
if (value) result[key] = JSON.parse(value) as MangaReadRecord;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteMangaReadRecord(userName: string, key: string): Promise<void> {
|
||||
await this.withRetry(() => this.adapter.hDel(this.mangaReadHashKey(userName), key));
|
||||
}
|
||||
|
||||
async cleanupOldMangaReadRecords(userName: string): Promise<void> {
|
||||
const records = await this.getAllMangaReadRecords(userName);
|
||||
const maxRecords = parseInt(process.env.MAX_MANGA_HISTORY_PER_USER || '100', 10);
|
||||
const keys = Object.entries(records)
|
||||
.sort(([, a], [, b]) => b.saveTime - a.saveTime)
|
||||
.slice(maxRecords)
|
||||
.map(([key]) => key);
|
||||
|
||||
if (keys.length > 0) {
|
||||
await this.withRetry(() => this.adapter.hDel(this.mangaReadHashKey(userName), ...keys));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 获取全部用户 ----------
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
// 从新版用户列表获取
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { getConfig } from './config';
|
||||
import {
|
||||
MangaChapter,
|
||||
MangaDetail,
|
||||
MangaSearchItem,
|
||||
MangaSource,
|
||||
} from './manga.types';
|
||||
|
||||
interface GraphQLResponse<T> {
|
||||
data?: T;
|
||||
errors?: Array<{ message?: string }>;
|
||||
}
|
||||
|
||||
interface SuwayomiClientOptions {
|
||||
serverUrl?: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface ResolvedSuwayomiConfig {
|
||||
serverBaseUrl: string;
|
||||
serverUrl: string;
|
||||
token?: string;
|
||||
defaultLang: string;
|
||||
sourceIds: string[];
|
||||
maxSources: number;
|
||||
}
|
||||
|
||||
async function resolveSuwayomiConfig(options: SuwayomiClientOptions = {}): Promise<ResolvedSuwayomiConfig> {
|
||||
let serverUrl = options.serverUrl || process.env.SUWAYOMI_URL || process.env.NEXT_PUBLIC_SUWAYOMI_URL || '';
|
||||
let token = options.token || process.env.SUWAYOMI_AUTH_TOKEN || '';
|
||||
let defaultLang = process.env.SUWAYOMI_DEFAULT_LANG || 'zh';
|
||||
let sourceIds: string[] = [];
|
||||
let maxSources = Number(process.env.SUWAYOMI_MAX_SOURCES || 10);
|
||||
|
||||
try {
|
||||
const config = await getConfig();
|
||||
if (config.SuwayomiConfig?.Enabled) {
|
||||
serverUrl = config.SuwayomiConfig.ServerURL || serverUrl;
|
||||
token = config.SuwayomiConfig.AuthToken || token;
|
||||
defaultLang = config.SuwayomiConfig.DefaultLang || defaultLang;
|
||||
sourceIds = config.SuwayomiConfig.SourceIds || sourceIds;
|
||||
maxSources = config.SuwayomiConfig.MaxSources || maxSources;
|
||||
}
|
||||
} catch {
|
||||
// 配置读取失败时回退到环境变量
|
||||
}
|
||||
|
||||
if (!serverUrl) {
|
||||
throw new Error('Suwayomi 未配置,请先在管理面板或环境变量中设置服务地址');
|
||||
}
|
||||
|
||||
const normalizedBaseUrl = serverUrl.replace(/\/$/, '');
|
||||
|
||||
return {
|
||||
serverBaseUrl: normalizedBaseUrl,
|
||||
serverUrl: normalizedBaseUrl + '/api/graphql',
|
||||
token: token || undefined,
|
||||
defaultLang,
|
||||
sourceIds,
|
||||
maxSources,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSuwayomiConfig(options: SuwayomiClientOptions = {}): Promise<ResolvedSuwayomiConfig> {
|
||||
return resolveSuwayomiConfig(options);
|
||||
}
|
||||
|
||||
export function buildSuwayomiImageProxyUrl(pathOrUrl: string): string {
|
||||
if (!pathOrUrl) return '';
|
||||
if (pathOrUrl.startsWith('/api/manga/image?')) return pathOrUrl;
|
||||
return `/api/manga/image?path=${encodeURIComponent(pathOrUrl)}`;
|
||||
}
|
||||
|
||||
export class SuwayomiClient {
|
||||
private options: SuwayomiClientOptions;
|
||||
|
||||
constructor(options: SuwayomiClientOptions = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
private async graphqlRequest<T>(query: string, variables?: Record<string, any>, operationName?: string): Promise<T> {
|
||||
const resolved = await resolveSuwayomiConfig(this.options);
|
||||
const response = await fetch(resolved.serverUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(resolved.token ? { Authorization: `Bearer ${resolved.token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ query, variables, operationName }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Suwayomi 请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GraphQLResponse<T>;
|
||||
if (data.errors?.length) {
|
||||
throw new Error(data.errors.map((item) => item.message || 'Unknown error').join('; '));
|
||||
}
|
||||
if (!data.data) {
|
||||
throw new Error('Suwayomi 返回空数据');
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async getSources(lang?: string): Promise<MangaSource[]> {
|
||||
const resolved = await resolveSuwayomiConfig(this.options);
|
||||
const query = `
|
||||
query GetSources {
|
||||
sources {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
lang
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await this.graphqlRequest<{
|
||||
sources?: { nodes?: Array<{ id: string; name?: string; lang?: string; displayName?: string }> };
|
||||
}>(query);
|
||||
|
||||
const nodes = data.sources?.nodes || [];
|
||||
const filtered = nodes.filter((item) => !lang || item.lang === lang);
|
||||
const scoped = resolved.sourceIds.length > 0
|
||||
? filtered.filter((item) => resolved.sourceIds.includes(String(item.id)))
|
||||
: filtered;
|
||||
|
||||
return scoped.map((item) => ({
|
||||
id: String(item.id),
|
||||
name: item.name || item.displayName || String(item.id),
|
||||
lang: item.lang,
|
||||
displayName: item.displayName,
|
||||
}));
|
||||
}
|
||||
|
||||
async searchManga(keyword: string, sourceId?: string, page = 1): Promise<MangaSearchItem[]> {
|
||||
const resolved = await resolveSuwayomiConfig(this.options);
|
||||
const sources = sourceId
|
||||
? [{ id: sourceId, displayName: sourceId, name: sourceId }]
|
||||
: (await this.getSources(resolved.defaultLang)).slice(0, resolved.maxSources);
|
||||
const query = `
|
||||
mutation GET_SOURCE_MANGAS_FETCH($input: FetchSourceMangaInput!) {
|
||||
fetchSourceManga(input: $input) {
|
||||
mangas {
|
||||
id
|
||||
title
|
||||
thumbnailUrl
|
||||
sourceId
|
||||
description
|
||||
author
|
||||
artist
|
||||
genre
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const results: MangaSearchItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const source of sources) {
|
||||
const data = await this.graphqlRequest<{
|
||||
fetchSourceManga?: {
|
||||
mangas?: Array<{
|
||||
id: string | number;
|
||||
title?: string;
|
||||
thumbnailUrl?: string;
|
||||
sourceId?: string | number;
|
||||
description?: string;
|
||||
author?: string;
|
||||
artist?: string;
|
||||
genre?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
};
|
||||
}>(
|
||||
query,
|
||||
{
|
||||
input: {
|
||||
type: 'SEARCH',
|
||||
source: source.id,
|
||||
query: keyword,
|
||||
page,
|
||||
},
|
||||
},
|
||||
'GET_SOURCE_MANGAS_FETCH'
|
||||
).catch(() => ({ fetchSourceManga: { mangas: [] } }));
|
||||
|
||||
const mangas = data.fetchSourceManga?.mangas || [];
|
||||
for (const manga of mangas) {
|
||||
const key = `${source.id}:${manga.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
results.push({
|
||||
id: String(manga.id),
|
||||
sourceId: String(manga.sourceId || source.id),
|
||||
sourceName: source.displayName || source.name || String(source.id),
|
||||
title: manga.title || '未命名漫画',
|
||||
cover: buildSuwayomiImageProxyUrl(manga.thumbnailUrl || ''),
|
||||
description: manga.description,
|
||||
author: manga.author,
|
||||
artist: manga.artist,
|
||||
genre: manga.genre,
|
||||
status: manga.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async getChapters(mangaId: string): Promise<MangaChapter[]> {
|
||||
const mutation = `
|
||||
mutation GET_MANGA_CHAPTERS_FETCH($input: FetchChaptersInput!) {
|
||||
fetchChapters(input: $input) {
|
||||
chapters {
|
||||
id
|
||||
mangaId
|
||||
name
|
||||
chapterNumber
|
||||
scanlator
|
||||
isRead
|
||||
isDownloaded
|
||||
pageCount
|
||||
uploadDate
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await this.graphqlRequest<{
|
||||
fetchChapters?: {
|
||||
chapters?: Array<{
|
||||
id: string | number;
|
||||
mangaId?: string | number;
|
||||
name?: string;
|
||||
chapterNumber?: number;
|
||||
scanlator?: string;
|
||||
isRead?: boolean;
|
||||
isDownloaded?: boolean;
|
||||
pageCount?: number;
|
||||
uploadDate?: number;
|
||||
}>;
|
||||
};
|
||||
}>(mutation, { input: { mangaId: Number(mangaId) || mangaId } }, 'GET_MANGA_CHAPTERS_FETCH');
|
||||
|
||||
return (data.fetchChapters?.chapters || []).map((chapter) => ({
|
||||
id: String(chapter.id),
|
||||
mangaId: String(chapter.mangaId || mangaId),
|
||||
name: chapter.name || '未命名章节',
|
||||
chapterNumber: chapter.chapterNumber,
|
||||
scanlator: chapter.scanlator,
|
||||
isRead: chapter.isRead,
|
||||
isDownloaded: chapter.isDownloaded,
|
||||
pageCount: chapter.pageCount,
|
||||
uploadDate: chapter.uploadDate,
|
||||
}));
|
||||
}
|
||||
|
||||
async getMangaDetail(input: {
|
||||
mangaId: string;
|
||||
sourceId: string;
|
||||
title?: string;
|
||||
cover?: string;
|
||||
sourceName?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
status?: string;
|
||||
}): Promise<MangaDetail> {
|
||||
const chapters = await this.getChapters(input.mangaId);
|
||||
|
||||
let metadata: Partial<MangaSearchItem> = {
|
||||
id: input.mangaId,
|
||||
sourceId: input.sourceId,
|
||||
sourceName: input.sourceName || input.sourceId,
|
||||
title: input.title || '漫画详情',
|
||||
cover: input.cover || '',
|
||||
description: input.description,
|
||||
author: input.author,
|
||||
status: input.status,
|
||||
};
|
||||
|
||||
const detailQuery = `
|
||||
query MangaDetail($id: LongString!) {
|
||||
manga(id: $id) {
|
||||
id
|
||||
title
|
||||
thumbnailUrl
|
||||
sourceId
|
||||
description
|
||||
author
|
||||
artist
|
||||
genre
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const detailData = await this.graphqlRequest<{
|
||||
manga?: {
|
||||
id: string | number;
|
||||
title?: string;
|
||||
thumbnailUrl?: string;
|
||||
sourceId?: string | number;
|
||||
description?: string;
|
||||
author?: string;
|
||||
artist?: string;
|
||||
genre?: string;
|
||||
status?: string;
|
||||
};
|
||||
}>(detailQuery, { id: input.mangaId }, 'MangaDetail');
|
||||
|
||||
if (detailData.manga) {
|
||||
metadata = {
|
||||
id: String(detailData.manga.id),
|
||||
sourceId: String(detailData.manga.sourceId || input.sourceId),
|
||||
sourceName: input.sourceName || input.sourceId,
|
||||
title: detailData.manga.title || metadata.title || '漫画详情',
|
||||
cover: buildSuwayomiImageProxyUrl(detailData.manga.thumbnailUrl || metadata.cover || ''),
|
||||
description: detailData.manga.description || metadata.description,
|
||||
author: detailData.manga.author || metadata.author,
|
||||
artist: detailData.manga.artist,
|
||||
genre: detailData.manga.genre,
|
||||
status: detailData.manga.status || metadata.status,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// 某些 Suwayomi 版本不支持直接 manga(id) 查询,降级为外部参数 + 章节信息
|
||||
}
|
||||
|
||||
return {
|
||||
id: metadata.id || input.mangaId,
|
||||
sourceId: metadata.sourceId || input.sourceId,
|
||||
sourceName: metadata.sourceName || input.sourceId,
|
||||
title: metadata.title || '漫画详情',
|
||||
cover: buildSuwayomiImageProxyUrl(metadata.cover || ''),
|
||||
description: metadata.description,
|
||||
author: metadata.author,
|
||||
artist: metadata.artist,
|
||||
genre: metadata.genre,
|
||||
status: metadata.status,
|
||||
chapters,
|
||||
};
|
||||
}
|
||||
|
||||
async getChapterPages(chapterId: string): Promise<string[]> {
|
||||
const mutation = `
|
||||
mutation GET_CHAPTER_PAGES_FETCH($input: FetchChapterPagesInput!) {
|
||||
fetchChapterPages(input: $input) {
|
||||
pages
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await this.graphqlRequest<{
|
||||
fetchChapterPages?: { pages?: string[] };
|
||||
}>(mutation, { input: { chapterId: Number(chapterId) || chapterId } }, 'GET_CHAPTER_PAGES_FETCH');
|
||||
|
||||
return (data.fetchChapterPages?.pages || []).map((item) => buildSuwayomiImageProxyUrl(item));
|
||||
}
|
||||
}
|
||||
|
||||
export const suwayomiClient = new SuwayomiClient();
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { MangaReadRecord, MangaShelfItem } from './manga.types';
|
||||
|
||||
// 播放记录数据结构
|
||||
export interface PlayRecord {
|
||||
@@ -75,6 +76,19 @@ export interface IStorage {
|
||||
addSearchHistory(userName: string, keyword: string): Promise<void>;
|
||||
deleteSearchHistory(userName: string, keyword?: string): Promise<void>;
|
||||
|
||||
// 漫画书架相关
|
||||
getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null>;
|
||||
setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void>;
|
||||
getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }>;
|
||||
deleteMangaShelf(userName: string, key: string): Promise<void>;
|
||||
|
||||
// 漫画阅读历史相关
|
||||
getMangaReadRecord(userName: string, key: string): Promise<MangaReadRecord | null>;
|
||||
setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise<void>;
|
||||
getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }>;
|
||||
deleteMangaReadRecord(userName: string, key: string): Promise<void>;
|
||||
cleanupOldMangaReadRecords?(userName: string): Promise<void>;
|
||||
|
||||
// 用户列表
|
||||
getAllUsers(): Promise<string[]>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user