Reader route migration

This commit is contained in:
Zerebos
2026-05-23 16:21:09 -04:00
parent 54307d4411
commit 3d6b6430ed
9 changed files with 846 additions and 233 deletions
+96 -87
View File
@@ -8,8 +8,8 @@ import type {
Page,
DownloadItem,
UpdateResult,
} from '$lib/server-adapters/types'
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
} from '$lib/server-adapters/types';
import type {Manga, Chapter, Extension, Source, Tracker} from '$lib/types';
import {
GET_LIBRARY,
GET_MANGA,
@@ -19,38 +19,39 @@ import {
SET_MANGA_META,
UPDATE_LIBRARY,
FETCH_SOURCE_MANGA,
} from './manga'
} from './manga';
import {
GET_CHAPTERS,
FETCH_CHAPTERS,
FETCH_CHAPTER_PAGES,
MARK_CHAPTER_READ,
MARK_CHAPTERS_READ,
} from './chapters'
UPDATE_CHAPTERS_PROGRESS,
} from './chapters';
import {
GET_DOWNLOAD_STATUS,
ENQUEUE_DOWNLOAD,
DEQUEUE_DOWNLOAD,
CLEAR_DOWNLOADER,
} from './downloads'
} from './downloads';
import {
GET_EXTENSIONS,
GET_SOURCES,
FETCH_EXTENSIONS,
UPDATE_EXTENSION,
} from './extensions'
} from './extensions';
import {
GET_TRACKERS,
BIND_TRACK,
TRACK_PROGRESS,
} from './tracking'
} from './tracking';
import {
GQLResponse,
mapManga,
mapChapter,
mapExtension,
mapDownloadItem,
} from './types'
} from './types';
const GET_CHAPTER = `
query GetChapter($id: Int!) {
@@ -59,17 +60,17 @@ const GET_CHAPTER = `
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
}
}
`
`;
export class SuwayomiAdapter implements ServerAdapter {
private baseUrl = 'http://127.0.0.1:4567'
private authHeader: string | null = null
private baseUrl = 'http://127.0.0.1:4567';
private authHeader: string | null = null;
async connect(config: ServerConfig) {
this.baseUrl = config.baseUrl.replace(/\/$/, '')
this.baseUrl = config.baseUrl.replace(/\/$/, '');
if (config.credentials) {
const { username, password } = config.credentials
this.authHeader = 'Basic ' + btoa(`${username}:${password}`)
const {username, password} = config.credentials;
this.authHeader = 'Basic ' + btoa(`${username}:${password}`);
}
}
@@ -78,154 +79,162 @@ export class SuwayomiAdapter implements ServerAdapter {
const res = await fetch(`${this.baseUrl}/api/graphql`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ query: '{ aboutServer { name } }' }),
})
return res.ok ? 'connected' : 'error'
body: JSON.stringify({query: '{ aboutServer { name } }'}),
});
return res.ok ? 'connected' : 'error';
} catch {
return 'disconnected'
return 'disconnected';
}
}
private headers(): Record<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json' }
if (this.authHeader) h['Authorization'] = this.authHeader
return h
const h: Record<string, string> = {'Content-Type': 'application/json'};
if (this.authHeader) h['Authorization'] = this.authHeader;
return h;
}
private async gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
const res = await fetch(`${this.baseUrl}/api/graphql`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ query, variables }),
})
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
const json: GQLResponse<T> = await res.json()
if (json.errors?.length) throw new Error(json.errors[0].message)
return json.data
body: JSON.stringify({query, variables}),
});
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
const json: GQLResponse<T> = await res.json();
if (json.errors?.length) throw new Error(json.errors[0].message);
return json.data;
}
async getManga(id: string): Promise<Manga> {
const data = await this.gql<{ manga: Record<string, unknown> }>(GET_MANGA, { id: Number(id) })
return mapManga(data.manga)
const data = await this.gql<{manga: Record<string, unknown>;}>(GET_MANGA, {id: Number(id)});
return mapManga(data.manga);
}
async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> {
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(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 }
const data = await this.gql<{mangas: {nodes: Record<string, unknown>[];};}>(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 searchManga(query: string, sourceId?: string): Promise<Manga[]> {
if (!sourceId) return []
if (!sourceId) return [];
const data = await this.gql<{
fetchSourceManga: { mangas: Record<string, unknown>[] }
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page: 1, query })
return data.fetchSourceManga.mangas.map(mapManga)
fetchSourceManga: {mangas: Record<string, unknown>[];};
}>(FETCH_SOURCE_MANGA, {source: sourceId, type: 'SEARCH', page: 1, query});
return data.fetchSourceManga.mangas.map(mapManga);
}
async addToLibrary(mangaId: string) {
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: true })
await this.gql(UPDATE_MANGA, {id: Number(mangaId), inLibrary: true});
}
async removeFromLibrary(mangaId: string) {
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: false })
await this.gql(UPDATE_MANGA, {id: Number(mangaId), inLibrary: false});
}
async updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
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) })
if (value === undefined) continue;
await this.gql(SET_MANGA_META, {mangaId: Number(id), key, value: String(value)});
}
}
async getChapters(mangaId: string): Promise<Chapter[]> {
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
GET_CHAPTERS, { mangaId: Number(mangaId) }
)
return data.chapters.nodes.map(mapChapter)
const data = await this.gql<{chapters: {nodes: Record<string, unknown>[];};}>(
GET_CHAPTERS, {mangaId: Number(mangaId)}
);
return data.chapters.nodes.map(mapChapter);
}
async getChapter(id: string): Promise<Chapter> {
const data = await this.gql<{ chapter: Record<string, unknown> }>(
GET_CHAPTER, { id: Number(id) }
)
return mapChapter(data.chapter)
const data = await this.gql<{chapter: Record<string, unknown>;}>(
GET_CHAPTER, {id: Number(id)}
);
return mapChapter(data.chapter);
}
async getChapterPages(id: string): Promise<Page[]> {
const data = await this.gql<{ fetchChapterPages: { pages: string[] } }>(
FETCH_CHAPTER_PAGES, { chapterId: Number(id) }
)
return data.fetchChapterPages.pages.map((url, index) => ({ index, url }))
const data = await this.gql<{fetchChapterPages: {pages: string[];};}>(
FETCH_CHAPTER_PAGES, {chapterId: Number(id)}
);
return data.fetchChapterPages.pages.map((url, index) => ({index, url}));
}
async markChapterRead(id: string, read: boolean) {
await this.gql(MARK_CHAPTER_READ, { id: Number(id), isRead: read })
await this.gql(MARK_CHAPTER_READ, {id: Number(id), isRead: read});
}
async updateChapterProgress(id: string, lastPageRead: number, read?: boolean) {
await this.gql(UPDATE_CHAPTERS_PROGRESS, {
ids: [Number(id)],
lastPageRead,
isRead: read,
});
}
async markChaptersRead(ids: string[], read: boolean) {
await this.gql(MARK_CHAPTERS_READ, { ids: ids.map(Number), isRead: read })
await this.gql(MARK_CHAPTERS_READ, {ids: ids.map(Number), isRead: read});
}
async getDownloads(): Promise<DownloadItem[]> {
const data = await this.gql<{ downloadStatus: { queue: Record<string, unknown>[] } }>(
const data = await this.gql<{downloadStatus: {queue: Record<string, unknown>[];};}>(
GET_DOWNLOAD_STATUS
)
return data.downloadStatus.queue.map(mapDownloadItem)
);
return data.downloadStatus.queue.map(mapDownloadItem);
}
async enqueueDownload(chapterId: string) {
await this.gql(ENQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
await this.gql(ENQUEUE_DOWNLOAD, {chapterId: Number(chapterId)});
}
async dequeueDownload(chapterId: string) {
await this.gql(DEQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
await this.gql(DEQUEUE_DOWNLOAD, {chapterId: Number(chapterId)});
}
async clearDownloads() {
await this.gql(CLEAR_DOWNLOADER)
await this.gql(CLEAR_DOWNLOADER);
}
async getExtensions(): Promise<Extension[]> {
await this.gql(FETCH_EXTENSIONS)
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(GET_EXTENSIONS)
return data.extensions.nodes.map(mapExtension)
await this.gql(FETCH_EXTENSIONS);
const data = await this.gql<{extensions: {nodes: Record<string, unknown>[];};}>(GET_EXTENSIONS);
return data.extensions.nodes.map(mapExtension);
}
async installExtension(id: string) {
await this.gql(UPDATE_EXTENSION, { id, install: true })
await this.gql(UPDATE_EXTENSION, {id, install: true});
}
async uninstallExtension(id: string) {
await this.gql(UPDATE_EXTENSION, { id, uninstall: true })
await this.gql(UPDATE_EXTENSION, {id, uninstall: true});
}
async updateExtension(id: string) {
await this.gql(UPDATE_EXTENSION, { id, update: true })
await this.gql(UPDATE_EXTENSION, {id, update: true});
}
async getSources(): Promise<Source[]> {
const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
return data.sources.nodes
const data = await this.gql<{sources: {nodes: Source[];};}>(GET_SOURCES);
return data.sources.nodes;
}
async browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>> {
const data = await this.gql<{
fetchSourceManga: { mangas: Record<string, unknown>[]; hasNextPage: boolean }
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'LATEST', page })
fetchSourceManga: {mangas: Record<string, unknown>[]; hasNextPage: boolean;};
}>(FETCH_SOURCE_MANGA, {source: sourceId, type: 'LATEST', page});
return {
items: data.fetchSourceManga.mangas.map(mapManga),
hasNextPage: data.fetchSourceManga.hasNextPage,
}
};
}
async getTrackers(): Promise<Tracker[]> {
const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
return data.trackers.nodes
const data = await this.gql<{trackers: {nodes: Tracker[];};}>(GET_TRACKERS);
return data.trackers.nodes;
}
async linkTracker(mangaId: string, trackerId: string, remoteId: string) {
@@ -233,25 +242,25 @@ export class SuwayomiAdapter implements ServerAdapter {
mangaId: Number(mangaId),
trackerId: Number(trackerId),
remoteId,
})
});
}
async syncTracking(mangaId: string) {
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) })
await this.gql(TRACK_PROGRESS, {mangaId: Number(mangaId)});
}
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
if (mangaIds?.length) {
const results: UpdateResult[] = []
const results: UpdateResult[] = [];
for (const id of mangaIds) {
const before = await this.getChapters(id)
await this.gql(FETCH_CHAPTERS, { mangaId: Number(id) })
const after = await this.getChapters(id)
results.push({ mangaId: id, newChapters: after.length - before.length })
const before = await this.getChapters(id);
await this.gql(FETCH_CHAPTERS, {mangaId: Number(id)});
const after = await this.getChapters(id);
results.push({mangaId: id, newChapters: after.length - before.length});
}
return results
return results;
}
await this.gql(UPDATE_LIBRARY)
return []
await this.gql(UPDATE_LIBRARY);
return [];
}
}