增加漫画展馆功能

This commit is contained in:
mtvpls
2026-04-16 16:01:47 +08:00
parent 4fe9314c57
commit cb225c3291
33 changed files with 3242 additions and 5 deletions
+8
View File
@@ -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'; // 邮件发送方式
+29
View File
@@ -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: {
+265
View File
@@ -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
View File
@@ -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 组件监听数据更新的事件监听器
+35
View File
@@ -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') {
+62
View File
@@ -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;
}
+265
View File
@@ -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',
+72
View File
@@ -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[]> {
// 从新版用户列表获取
+371
View File
@@ -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();
+14
View File
@@ -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[]>;