增加漫画展馆功能

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
+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();