import type { ServerAdapter, ServerConfig, ServerStatus, MangaFilters, MangaMeta, PaginatedResult, Page, DownloadItem, UpdateResult, LibraryUpdateProgress, ServerSecurity, SetServerAuthInput, SetSocksProxyInput, SetFlareSolverrInput, TrackRecordPatch, AboutServer, AboutWebUI, RestoreStatus, ValidateBackupResult, } from '$lib/server-adapters/types' import type { DownloadStatus } from '$lib/types/api' import type { Manga, Chapter, Extension, Source, Tracker, TrackRecord, Category } from '$lib/types' import { GET_LIBRARY, GET_MANGA, GET_CATEGORIES, GET_DOWNLOADS_PATH, FETCH_MANGA, UPDATE_MANGA, UPDATE_MANGAS, UPDATE_MANGA_CATEGORIES, UPDATE_MANGAS_CATEGORIES, CREATE_CATEGORY, UPDATE_CATEGORY, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER, UPDATE_CATEGORY_MANGA, UPDATE_LIBRARY, UPDATE_LIBRARY_MANGA, UPDATE_STOP, SET_MANGA_META, DELETE_MANGA_META, CREATE_BACKUP, FETCH_SOURCE_MANGA, LIBRARY_UPDATE_STATUS, MANGAS_BY_GENRE, POLL_RESTORE_STATUS, } from './manga' import { GET_CHAPTERS, GET_CHAPTER, GET_RECENTLY_UPDATED, FETCH_CHAPTERS, FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, UPDATE_CHAPTERS_PROGRESS, DELETE_DOWNLOADED_CHAPTERS, SET_CHAPTER_META, DELETE_CHAPTER_META, } from './chapters' import { GET_DOWNLOAD_STATUS, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD, DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD, REORDER_DOWNLOAD, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH, } from './downloads' import { GET_EXTENSIONS, GET_SOURCES, GET_SOURCE_SETTINGS, GET_SETTINGS, GET_SERVER_SECURITY, FETCH_EXTENSIONS, UPDATE_EXTENSION, UPDATE_EXTENSIONS, INSTALL_EXTERNAL_EXTENSION, UPDATE_SOURCE_PREFERENCE, SET_SOURCE_META, DELETE_SOURCE_META, SET_EXTENSION_REPOS, SET_SERVER_AUTH, CLEAR_CACHED_IMAGES, RESET_SETTINGS, } from './extensions' import { GET_TRACKERS, GET_ALL_TRACKER_RECORDS, GET_MANGA_TRACK_RECORDS, SEARCH_TRACKER, FETCH_TRACK, BIND_TRACK, UNLINK_TRACK, TRACK_PROGRESS, UPDATE_TRACK, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER, LOGIN_TRACKER_OAUTH, } from './tracking' import { GET_ABOUT_SERVER, GET_ABOUT_WEBUI, CHECK_FOR_SERVER_UPDATES, GET_META, GET_METAS, SET_SOCKS_PROXY, SET_FLARE_SOLVERR, RESTORE_BACKUP, VALIDATE_BACKUP, } from './meta' import { authHeaders } from '$lib/core/auth' import { type GQLResponse, mapManga, mapChapter, mapExtension, mapDownloadItem, mapCategory, } from './types' import { initPageCache, clearPageCache as _clearPageCache } from './pageCache' type RawQueueItem = Record function mapDownloadStatus(raw: { state: string; queue: RawQueueItem[] }): DownloadStatus { return { state: raw.state, queue: raw.queue.map(item => ({ progress: (item.progress as number) ?? 0, state: item.state as string, tries: (item.tries as number) ?? 0, chapter: item.chapter as DownloadStatus['queue'][number]['chapter'], })), } } export class SuwayomiAdapter implements ServerAdapter { private baseUrl = 'http://127.0.0.1:4567' async connect(config: ServerConfig): Promise { this.baseUrl = config.baseUrl.replace(/\/$/, '') initPageCache(this.gql.bind(this), this.getServerUrl.bind(this)) } getServerUrl(): string { return this.baseUrl } async getStatus(): Promise { try { const res = await fetch(`${this.baseUrl}/api/graphql`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ query: '{ aboutServer { name } }' }), }) return res.ok ? 'connected' : 'error' } catch { return 'disconnected' } } private headers(): Record { return { 'Content-Type': 'application/json', ...authHeaders() } } private async gql( query: string, variables?: Record, signal?: AbortSignal, ): Promise { const res = await fetch(`${this.baseUrl}/api/graphql`, { method: 'POST', headers: this.headers(), body: JSON.stringify({ query, variables }), signal, }) if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`) const json: GQLResponse = await res.json() if (json.errors?.length) throw new Error(json.errors[0].message) return json.data } async getAboutServer(): Promise { const data = await this.gql<{ aboutServer: AboutServer }>(GET_ABOUT_SERVER) return data.aboutServer } async getAboutWebUI(): Promise { const data = await this.gql<{ aboutWebUI: AboutWebUI }>(GET_ABOUT_WEBUI) return data.aboutWebUI } async getManga(id: string): Promise { const data = await this.gql<{ manga: Record }>(GET_MANGA, { id: Number(id) }) return mapManga(data.manga) } async fetchManga(id: string): Promise { const data = await this.gql<{ fetchManga: { manga: Record } }>( FETCH_MANGA, { id: Number(id) } ) return mapManga(data.fetchManga.manga) } async getMangaList(filters: MangaFilters): Promise> { const data = await this.gql<{ mangas: { nodes: Record[] } }>(GET_LIBRARY) let items = data.mangas.nodes.map(mapManga) if (filters.status) items = items.filter(m => m.status === filters.status) if (filters.tags?.length) items = items.filter(m => filters.tags!.every(t => m.tags?.includes(t))) if (filters.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0) if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId) return { items, hasNextPage: false } } async getMangasByGenre( filter: Record, first: number, offset: number, signal?: AbortSignal, ): Promise<{ items: Manga[]; hasNextPage: boolean; totalCount: number }> { const data = await this.gql<{ mangas: { nodes: Record[] pageInfo: { hasNextPage: boolean } totalCount: number } }>(MANGAS_BY_GENRE, { filter, first, offset }, signal) return { items: data.mangas.nodes.map(mapManga), hasNextPage: data.mangas.pageInfo.hasNextPage, totalCount: data.mangas.totalCount, } } async searchManga(query: string, sourceId?: string): Promise { if (!sourceId) return [] const data = await this.gql<{ fetchSourceManga: { mangas: Record[] } }>( FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page: 1, query } ) return data.fetchSourceManga.mangas.map(mapManga) } async addToLibrary(mangaId: string): Promise { await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: true }) } async removeFromLibrary(mangaId: string): Promise { await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: false }) } async updateMangas(ids: string[], patch: { inLibrary?: boolean }): Promise { await this.gql(UPDATE_MANGAS, { ids: ids.map(Number), ...patch }) } async updateMangaMeta(id: string, meta: Partial): Promise { for (const [key, value] of Object.entries(meta)) { if (value === undefined) continue await this.gql(SET_MANGA_META, { mangaId: Number(id), key, value: String(value) }) } } async deleteMangaMeta(id: string, key: string): Promise { await this.gql(DELETE_MANGA_META, { mangaId: Number(id), key }) } async getChapters(mangaId: string, signal?: AbortSignal): Promise { const data = await this.gql<{ chapters: { nodes: Record[] } }>( GET_CHAPTERS, { mangaId: Number(mangaId) }, signal ) return data.chapters.nodes.map(mapChapter) } async getChapter(id: string): Promise { const data = await this.gql<{ chapter: Record }>( GET_CHAPTER, { id: Number(id) } ) return mapChapter(data.chapter) } async getChapterPages(id: string, signal?: AbortSignal): Promise { const data = await this.gql<{ fetchChapterPages: { pages: string[] } }>( FETCH_CHAPTER_PAGES, { chapterId: Number(id) }, signal ) return data.fetchChapterPages.pages.map((url, index) => ({ index, url })) } async fetchChapters(mangaId: string, signal?: AbortSignal): Promise { const data = await this.gql<{ fetchChapters: { chapters: Record[] } }>( FETCH_CHAPTERS, { mangaId: Number(mangaId) }, signal ) return data.fetchChapters.chapters.map(mapChapter) } async getRecentlyUpdated(): Promise { const data = await this.gql<{ chapters: { nodes: Record[] } }>(GET_RECENTLY_UPDATED) return data.chapters.nodes.map(mapChapter) } async markChapterRead(id: string, read: boolean): Promise { await this.gql(MARK_CHAPTER_READ, { id: Number(id), isRead: read }) } async markChaptersRead(ids: string[], read: boolean): Promise { await this.gql(MARK_CHAPTERS_READ, { ids: ids.map(Number), isRead: read }) } async updateChaptersProgress( ids: string[], patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number }, ): Promise { await this.gql(UPDATE_CHAPTERS_PROGRESS, { ids: ids.map(Number), ...patch }) } async deleteDownloadedChapters(ids: string[]): Promise { await this.gql(DELETE_DOWNLOADED_CHAPTERS, { ids: ids.map(Number) }) } async setChapterMeta(chapterId: string, key: string, value: string): Promise { await this.gql(SET_CHAPTER_META, { chapterId: Number(chapterId), key, value }) } async deleteChapterMeta(chapterId: string, key: string): Promise { await this.gql(DELETE_CHAPTER_META, { chapterId: Number(chapterId), key }) } async getDownloads(): Promise { const status = await this.getDownloadStatus() return status.queue.map(item => ({ chapterId: String(item.chapter.id), mangaId: String(item.chapter.mangaId ?? item.chapter.manga?.id), chapterName: item.chapter.name, mangaTitle: item.chapter.manga?.title ?? '', progress: item.progress, state: item.state === 'DOWNLOADING' ? 'downloading' : item.state === 'FINISHED' ? 'finished' : item.state === 'ERROR' ? 'error' : 'queued', })) } async getDownloadStatus(): Promise { const data = await this.gql<{ downloadStatus: { state: string; queue: RawQueueItem[] } }>(GET_DOWNLOAD_STATUS) return mapDownloadStatus(data.downloadStatus) } async enqueueDownload(chapterId: string): Promise { await this.gql(ENQUEUE_DOWNLOAD, { chapterId: Number(chapterId) }) } async enqueueDownloads(chapterIds: string[]): Promise { await this.gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: chapterIds.map(Number) }) } async dequeueDownload(chapterId: string): Promise { await this.gql(DEQUEUE_DOWNLOAD, { chapterId: Number(chapterId) }) } async dequeueDownloads(chapterIds: string[]): Promise { await this.gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: chapterIds.map(Number) }) } async reorderDownload(chapterId: string, to: number): Promise { try { const data = await this.gql<{ reorderChapterDownload: { downloadStatus: { state: string; queue: RawQueueItem[] } } }>(REORDER_DOWNLOAD, { chapterId: Number(chapterId), to }) return mapDownloadStatus(data.reorderChapterDownload.downloadStatus) } catch { return null } } async clearDownloads(): Promise { await this.gql(CLEAR_DOWNLOADER) } async startDownloader(): Promise { try { const data = await this.gql<{ startDownloader: { downloadStatus: { state: string; queue: RawQueueItem[] } } }>(START_DOWNLOADER) return mapDownloadStatus(data.startDownloader.downloadStatus) } catch { return null } } async stopDownloader(): Promise { try { const data = await this.gql<{ stopDownloader: { downloadStatus: { state: string; queue: RawQueueItem[] } } }>(STOP_DOWNLOADER) return mapDownloadStatus(data.stopDownloader.downloadStatus) } catch { return null } } async getExtensions(): Promise { await this.gql(FETCH_EXTENSIONS) const data = await this.gql<{ extensions: { nodes: Record[] } }>(GET_EXTENSIONS) return data.extensions.nodes.map(mapExtension) } async installExtension(id: string): Promise { await this.gql(UPDATE_EXTENSION, { id, install: true }) } async uninstallExtension(id: string): Promise { await this.gql(UPDATE_EXTENSION, { id, uninstall: true }) } async updateExtension(id: string): Promise { await this.gql(UPDATE_EXTENSION, { id, update: true }) } async updateExtensions(ids: string[]): Promise { await this.gql(UPDATE_EXTENSIONS, { ids, update: true }) } async installExternalExtension(url: string): Promise { await this.gql(INSTALL_EXTERNAL_EXTENSION, { url }) } async getExtensionRepos(): Promise { const data = await this.gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS) return data.settings.extensionRepos ?? [] } async setExtensionRepos(repos: string[]): Promise { const data = await this.gql<{ setSettings: { settings: { extensionRepos: string[] } } }>( SET_EXTENSION_REPOS, { repos } ) return data.setSettings.settings.extensionRepos } async getSources(): Promise { const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) return data.sources.nodes } async browseSource(sourceId: string, page: number): Promise> { const data = await this.gql<{ fetchSourceManga: { mangas: Record[]; hasNextPage: boolean } }>(FETCH_SOURCE_MANGA, { source: sourceId, type: sourceId === '0' ? 'POPULAR' : 'LATEST', page }) return { items: data.fetchSourceManga.mangas.map(mapManga), hasNextPage: data.fetchSourceManga.hasNextPage, } } async getSourceSettings(sourceId: string): Promise { const data = await this.gql<{ source: { preferences: unknown[] } }>( GET_SOURCE_SETTINGS, { id: sourceId } ) return data.source.preferences ?? [] } async updateSourcePreference( sourceId: string, position: number, changeType: string, value: unknown, ): Promise { await this.gql(UPDATE_SOURCE_PREFERENCE, { source: sourceId, change: { position, [changeType]: value }, }) const data = await this.gql<{ source: { preferences: unknown[] } }>( GET_SOURCE_SETTINGS, { id: sourceId } ) return data.source.preferences ?? [] } async getCategories(): Promise { const data = await this.gql<{ categories: { nodes: Record[] } }>(GET_CATEGORIES) return data.categories.nodes.map(mapCategory) } async createCategory(name: string): Promise { const data = await this.gql<{ createCategory: { category: Record } }>( CREATE_CATEGORY, { name } ) return mapCategory(data.createCategory.category) } async deleteCategory(id: number): Promise { await this.gql(DELETE_CATEGORY, { id }) } async updateCategory(id: number, patch: { name?: string; includeInUpdate?: string; includeInDownload?: string }): Promise { const data = await this.gql<{ updateCategory: { category: Record } }>( UPDATE_CATEGORY, { id, ...patch } ) return mapCategory(data.updateCategory.category) } async updateCategories( ids: number[], patch: { includeInUpdate?: 'INCLUDE' | 'EXCLUDE'; includeInDownload?: 'INCLUDE' | 'EXCLUDE' }, ): Promise { // Suwayomi has no bulk-category-patch mutation; fan out individually. await Promise.all(ids.map(id => this.gql(UPDATE_CATEGORY, { id, ...patch }))) } async updateCategoryOrder(id: number, position: number): Promise { const data = await this.gql<{ updateCategoryOrder: { categories: Record[] } }>( UPDATE_CATEGORY_ORDER, { id, position } ) return data.updateCategoryOrder.categories.map(mapCategory) } async updateMangaCategories(mangaId: string, addTo: number[], removeFrom: number[]): Promise { await this.gql(UPDATE_MANGA_CATEGORIES, { mangaId: Number(mangaId), addTo, removeFrom }) } async updateMangasCategories(mangaIds: string[], addTo: number[], removeFrom: number[]): Promise { await this.gql(UPDATE_MANGAS_CATEGORIES, { ids: mangaIds.map(Number), addTo, removeFrom }) } async updateCategoryManga(categoryId: number): Promise { await this.gql(UPDATE_CATEGORY_MANGA, { categoryId }) } async getTrackers(): Promise { const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS) return data.trackers.nodes } async getAllTrackerRecords(): Promise { const data = await this.gql<{ trackers: { nodes: unknown[] } }>(GET_ALL_TRACKER_RECORDS) return data.trackers.nodes } async getMangaTrackRecords(mangaId: string): Promise { const data = await this.gql<{ manga: { trackRecords: { nodes: unknown[] } } }>( GET_MANGA_TRACK_RECORDS, { mangaId: Number(mangaId) } ) return data.manga.trackRecords.nodes } async searchTracker(trackerId: string, query: string): Promise { const data = await this.gql<{ searchTracker: { trackSearches: unknown[] } }>( SEARCH_TRACKER, { trackerId: Number(trackerId), query } ) return data.searchTracker.trackSearches } async linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise { await this.gql(BIND_TRACK, { mangaId: Number(mangaId), trackerId: Number(trackerId), remoteId }) } async unlinkTracker(recordId: string): Promise { await this.gql(UNLINK_TRACK, { trackRecordId: Number(recordId) }) } async updateTrackRecord(recordId: string, patch: TrackRecordPatch): Promise { const data = await this.gql<{ updateTrack: { trackRecord: TrackRecord } }>( UPDATE_TRACK, { recordId: Number(recordId), ...patch } ) return data.updateTrack.trackRecord } async fetchTrackRecord(recordId: string): Promise { const data = await this.gql<{ fetchTrack: { trackRecord: TrackRecord } }>( FETCH_TRACK, { recordId: Number(recordId) } ) return data.fetchTrack.trackRecord } async syncTracking(mangaId: string): Promise { await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) }) } async loginTrackerOAuth(trackerId: string, callbackUrl: string): Promise { await this.gql(LOGIN_TRACKER_OAUTH, { trackerId: Number(trackerId), callbackUrl }) } async loginTrackerCredentials(trackerId: string, username: string, password: string): Promise { await this.gql(LOGIN_TRACKER_CREDENTIALS, { trackerId: Number(trackerId), username, password }) } async logoutTracker(trackerId: string): Promise { await this.gql(LOGOUT_TRACKER, { trackerId: Number(trackerId) }) } async getServerSecurity(): Promise { const data = await this.gql<{ settings: ServerSecurity }>(GET_SERVER_SECURITY) return data.settings } async setServerAuth(input: SetServerAuthInput): Promise { await this.gql(SET_SERVER_AUTH, { authMode: input.authMode, authUsername: input.authUsername, authPassword: input.authPassword, }) } async setSocksProxy(input: SetSocksProxyInput): Promise { await this.gql(SET_SOCKS_PROXY, input) } async setFlareSolverr(input: SetFlareSolverrInput): Promise { await this.gql(SET_FLARE_SOLVERR, input) } async searchSource( sourceId: string, query: string, page = 1, signal?: AbortSignal, ): Promise> { const data = await this.gql<{ fetchSourceManga: { mangas: Record[]; hasNextPage: boolean } }>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page, query }, signal) return { items: data.fetchSourceManga.mangas.map(mapManga), hasNextPage: data.fetchSourceManga.hasNextPage, } } async getDownloadsPath(): Promise<{ downloadsPath: string; localSourcePath: string }> { const data = await this.gql<{ settings: { downloadsPath: string | null; localSourcePath: string | null } }>( GET_DOWNLOADS_PATH ) return { downloadsPath: data.settings.downloadsPath ?? '', localSourcePath: data.settings.localSourcePath ?? '', } } async setDownloadsPath(path: string): Promise { await this.gql(SET_DOWNLOADS_PATH, { path }) } async setLocalSourcePath(path: string): Promise { await this.gql(SET_LOCAL_SOURCE_PATH, { path }) } async createBackup(): Promise<{ url: string }> { const data = await this.gql<{ createBackup: { url: string } }>(CREATE_BACKUP) return data.createBackup } private multipartGql(query: string, file: File): Promise { const form = new FormData() form.append('operations', JSON.stringify({ query, variables: { backup: null } })) form.append('map', JSON.stringify({ '0': ['variables.backup'] })) form.append('0', file, file.name) const headers: Record = { Accept: 'application/json', ...authHeaders() } return fetch(`${this.baseUrl}/api/graphql`, { method: 'POST', headers, body: form }) .then(r => { if (!r.ok) throw new Error(`Suwayomi HTTP ${r.status}`); return r.json() }) .then((json: GQLResponse) => { if (json.errors?.length) throw new Error(json.errors[0].message); return json.data }) } async restoreBackup(file: File): Promise<{ id: string; status: RestoreStatus }> { const data = await this.multipartGql<{ restoreBackup: { id: string; status: RestoreStatus } }>(RESTORE_BACKUP, file) return data.restoreBackup } async validateBackup(file: File): Promise { const data = await this.multipartGql<{ validateBackup: ValidateBackupResult }>(VALIDATE_BACKUP, file) return data.validateBackup } async pollRestoreStatus(id: string): Promise { const data = await this.gql<{ restoreStatus: RestoreStatus }>(POLL_RESTORE_STATUS, { id }) return data.restoreStatus } async clearCachedImages(opts: { cachedPages: boolean; cachedThumbnails: boolean; downloadedThumbnails: boolean }): Promise { await this.gql(CLEAR_CACHED_IMAGES, opts) } async checkForUpdates(mangaIds?: string[]): Promise { if (mangaIds?.length) { const results: UpdateResult[] = [] for (const id of mangaIds) { const before = await this.getChapters(id) await this.gql(UPDATE_LIBRARY_MANGA, { mangaId: Number(id) }) const after = await this.getChapters(id) results.push({ mangaId: id, newChapters: after.length - before.length }) } return results } await this.gql(UPDATE_LIBRARY) return [] } async startLibraryUpdate(): Promise { await this.gql(UPDATE_LIBRARY) } async stopLibraryUpdate(): Promise { await this.gql(UPDATE_STOP) } async getLibraryUpdateStatus(): Promise { const data = await this.gql<{ libraryUpdateStatus: { jobsInfo: { isRunning: boolean; finishedJobs: number; totalJobs: number } } lastUpdateTimestamp: { timestamp: string } | null }>(LIBRARY_UPDATE_STATUS) const { isRunning, finishedJobs, totalJobs } = data.libraryUpdateStatus.jobsInfo const lastUpdated = data.lastUpdateTimestamp ? Number(data.lastUpdateTimestamp.timestamp) : undefined return { isRunning, finishedJobs, totalJobs, lastUpdated } } clearPageCache(chapterId?: number): void { _clearPageCache(chapterId) } }