mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Chore: Implement Server Adapters & Request Manager
This commit is contained in:
Vendored
+2
-1
@@ -1,4 +1,5 @@
|
||||
declare global {
|
||||
namespace App {}
|
||||
const __APP_VERSION__: string
|
||||
}
|
||||
export {};
|
||||
export {}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type {
|
||||
PlatformAdapter,
|
||||
PlatformFeature,
|
||||
ServerLaunchConfig,
|
||||
DiscordPresence,
|
||||
AppUpdateInfo,
|
||||
} from '$lib/platform-adapters/types'
|
||||
|
||||
export class CapacitorAdapter implements PlatformAdapter {
|
||||
async init() {}
|
||||
|
||||
isSupported(feature: PlatformFeature): boolean {
|
||||
const supported: PlatformFeature[] = ['biometric-auth', 'filesystem']
|
||||
return supported.includes(feature)
|
||||
}
|
||||
|
||||
async launchServer(_config: ServerLaunchConfig) {}
|
||||
async stopServer() {}
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> {
|
||||
return 'stopped'
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<Uint8Array> {
|
||||
const { Filesystem, Directory } = await import('@capacitor/filesystem')
|
||||
const result = await Filesystem.readFile({ path, directory: Directory.Data })
|
||||
const base64 = result.data as string
|
||||
const binary = atob(base64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
async writeFile(path: string, data: Uint8Array): Promise<void> {
|
||||
const { Filesystem, Directory } = await import('@capacitor/filesystem')
|
||||
const binary = String.fromCharCode(...data)
|
||||
const base64 = btoa(binary)
|
||||
await Filesystem.writeFile({ path, data: base64, directory: Directory.Data })
|
||||
}
|
||||
|
||||
async pickFolder(): Promise<string | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
try {
|
||||
const { NativeBiometric } = await import('capacitor-native-biometric')
|
||||
await NativeBiometric.verifyIdentity({ reason: 'Authenticate to access Moku', title: 'Biometric Auth' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async storeCredential(key: string, value: string): Promise<void> {
|
||||
const { NativeBiometric } = await import('capacitor-native-biometric')
|
||||
await NativeBiometric.setCredentials({ username: key, password: value, server: 'moku' })
|
||||
}
|
||||
|
||||
async getCredential(key: string): Promise<string | null> {
|
||||
try {
|
||||
const { NativeBiometric } = await import('capacitor-native-biometric')
|
||||
const result = await NativeBiometric.getCredentials({ server: 'moku' })
|
||||
return result.username === key ? result.password : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async setTitle(_title: string) {}
|
||||
async minimize() {}
|
||||
async maximize() {}
|
||||
async close() {}
|
||||
|
||||
async setDiscordPresence(_presence: DiscordPresence) {}
|
||||
async clearDiscordPresence() {}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
const { App } = await import('@capacitor/app')
|
||||
const info = await App.getInfo()
|
||||
return info.version
|
||||
}
|
||||
|
||||
async openExternal(url: string): Promise<void> {
|
||||
const { Browser } = await import('@capacitor/browser')
|
||||
await Browser.open({ url })
|
||||
}
|
||||
|
||||
async checkForAppUpdate(): Promise<AppUpdateInfo | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async installAppUpdate(): Promise<void> {}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { readFile, writeFile } from '@tauri-apps/plugin-fs'
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { check } from '@tauri-apps/plugin-updater'
|
||||
import { relaunch } from '@tauri-apps/plugin-process'
|
||||
import type {
|
||||
PlatformAdapter,
|
||||
PlatformFeature,
|
||||
ServerLaunchConfig,
|
||||
DiscordPresence,
|
||||
AppUpdateInfo,
|
||||
} from '$lib/platform-adapters/types'
|
||||
|
||||
export class TauriAdapter implements PlatformAdapter {
|
||||
async init() {
|
||||
await invoke('init_app')
|
||||
}
|
||||
|
||||
isSupported(feature: PlatformFeature): boolean {
|
||||
const supported: PlatformFeature[] = [
|
||||
'server-management',
|
||||
'biometric-auth',
|
||||
'native-window',
|
||||
'filesystem',
|
||||
'app-updates',
|
||||
'discord-rpc',
|
||||
]
|
||||
return supported.includes(feature)
|
||||
}
|
||||
|
||||
async launchServer(config: ServerLaunchConfig) {
|
||||
await invoke('launch_server', { config })
|
||||
}
|
||||
|
||||
async stopServer() {
|
||||
await invoke('stop_server')
|
||||
}
|
||||
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> {
|
||||
return invoke('get_server_status')
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<Uint8Array> {
|
||||
return readFile(path)
|
||||
}
|
||||
|
||||
async writeFile(path: string, data: Uint8Array) {
|
||||
await writeFile(path, data)
|
||||
}
|
||||
|
||||
async pickFolder(): Promise<string | null> {
|
||||
const result = await open({ directory: true, multiple: false })
|
||||
return typeof result === 'string' ? result : null
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
return invoke('authenticate_biometric')
|
||||
}
|
||||
|
||||
async storeCredential(key: string, value: string) {
|
||||
await invoke('store_credential', { key, value })
|
||||
}
|
||||
|
||||
async getCredential(key: string): Promise<string | null> {
|
||||
return invoke('get_credential', { key })
|
||||
}
|
||||
|
||||
async setTitle(title: string) {
|
||||
await invoke('set_window_title', { title })
|
||||
}
|
||||
|
||||
async minimize() {
|
||||
await invoke('minimize_window')
|
||||
}
|
||||
|
||||
async maximize() {
|
||||
await invoke('maximize_window')
|
||||
}
|
||||
|
||||
async close() {
|
||||
await invoke('close_window')
|
||||
}
|
||||
|
||||
async setDiscordPresence(presence: DiscordPresence) {
|
||||
await invoke('set_discord_presence', { presence })
|
||||
}
|
||||
|
||||
async clearDiscordPresence() {
|
||||
await invoke('clear_discord_presence')
|
||||
}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
return getVersion()
|
||||
}
|
||||
|
||||
async openExternal(url: string) {
|
||||
await openUrl(url)
|
||||
}
|
||||
|
||||
async checkForAppUpdate(): Promise<AppUpdateInfo | null> {
|
||||
const update = await check()
|
||||
if (!update?.available) return null
|
||||
return {
|
||||
version: update.version,
|
||||
url: update.body ?? '',
|
||||
notes: update.body,
|
||||
}
|
||||
}
|
||||
|
||||
async installAppUpdate() {
|
||||
const update = await check()
|
||||
if (update?.available) {
|
||||
await update.downloadAndInstall()
|
||||
await relaunch()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export type PlatformFeature =
|
||||
| 'server-management'
|
||||
| 'biometric-auth'
|
||||
| 'native-window'
|
||||
| 'filesystem'
|
||||
| 'app-updates'
|
||||
| 'discord-rpc'
|
||||
|
||||
export interface ServerLaunchConfig {
|
||||
jarPath: string
|
||||
port: number
|
||||
dataPath: string
|
||||
}
|
||||
|
||||
export interface DiscordPresence {
|
||||
title: string
|
||||
chapter: string
|
||||
startTimestamp?: number
|
||||
}
|
||||
|
||||
export interface AppUpdateInfo {
|
||||
version: string
|
||||
url: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface PlatformAdapter {
|
||||
init(): Promise<void>
|
||||
isSupported(feature: PlatformFeature): boolean
|
||||
|
||||
launchServer(config: ServerLaunchConfig): Promise<void>
|
||||
stopServer(): Promise<void>
|
||||
getServerStatus(): Promise<'running' | 'stopped' | 'error'>
|
||||
|
||||
readFile(path: string): Promise<Uint8Array>
|
||||
writeFile(path: string, data: Uint8Array): Promise<void>
|
||||
pickFolder(): Promise<string | null>
|
||||
|
||||
authenticateBiometric(): Promise<boolean>
|
||||
storeCredential(key: string, value: string): Promise<void>
|
||||
getCredential(key: string): Promise<string | null>
|
||||
|
||||
setTitle(title: string): Promise<void>
|
||||
minimize(): Promise<void>
|
||||
maximize(): Promise<void>
|
||||
close(): Promise<void>
|
||||
|
||||
setDiscordPresence(presence: DiscordPresence): Promise<void>
|
||||
clearDiscordPresence(): Promise<void>
|
||||
|
||||
getVersion(): Promise<string>
|
||||
openExternal(url: string): Promise<void>
|
||||
checkForAppUpdate(): Promise<AppUpdateInfo | null>
|
||||
installAppUpdate(): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
PlatformAdapter,
|
||||
PlatformFeature,
|
||||
ServerLaunchConfig,
|
||||
DiscordPresence,
|
||||
AppUpdateInfo,
|
||||
} from '$lib/platform-adapters/types'
|
||||
|
||||
export class WebAdapter implements PlatformAdapter {
|
||||
async init() {}
|
||||
|
||||
isSupported(_feature: PlatformFeature): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
async launchServer(_config: ServerLaunchConfig) {}
|
||||
async stopServer() {}
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> {
|
||||
return 'stopped'
|
||||
}
|
||||
|
||||
async readFile(_path: string): Promise<Uint8Array> {
|
||||
return new Uint8Array()
|
||||
}
|
||||
|
||||
async writeFile(_path: string, _data: Uint8Array) {}
|
||||
|
||||
async pickFolder(): Promise<string | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
async storeCredential(_key: string, _value: string) {}
|
||||
|
||||
async getCredential(_key: string): Promise<string | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async setTitle(title: string) {
|
||||
document.title = title
|
||||
}
|
||||
|
||||
async minimize() {}
|
||||
async maximize() {}
|
||||
async close() {}
|
||||
|
||||
async setDiscordPresence(_presence: DiscordPresence) {}
|
||||
async clearDiscordPresence() {}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
return __APP_VERSION__
|
||||
}
|
||||
|
||||
async openExternal(url: string) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async checkForAppUpdate(): Promise<AppUpdateInfo | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async installAppUpdate() {}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type {
|
||||
PlatformAdapter,
|
||||
PlatformFeature,
|
||||
ServerLaunchConfig,
|
||||
DiscordPresence,
|
||||
AppUpdateInfo,
|
||||
} from '$lib/platform-adapters/types'
|
||||
|
||||
let adapter: PlatformAdapter
|
||||
|
||||
export function initPlatformService(a: PlatformAdapter) {
|
||||
adapter = a
|
||||
}
|
||||
|
||||
function getAdapter(): PlatformAdapter {
|
||||
if (!adapter) throw new Error('PlatformService not initialized')
|
||||
return adapter
|
||||
}
|
||||
|
||||
export function isSupported(feature: PlatformFeature): boolean {
|
||||
return getAdapter().isSupported(feature)
|
||||
}
|
||||
|
||||
export function launchServer(config: ServerLaunchConfig) {
|
||||
return getAdapter().launchServer(config)
|
||||
}
|
||||
|
||||
export function stopServer() {
|
||||
return getAdapter().stopServer()
|
||||
}
|
||||
|
||||
export function getServerStatus() {
|
||||
return getAdapter().getServerStatus()
|
||||
}
|
||||
|
||||
export function readFile(path: string) {
|
||||
return getAdapter().readFile(path)
|
||||
}
|
||||
|
||||
export function writeFile(path: string, data: Uint8Array) {
|
||||
return getAdapter().writeFile(path, data)
|
||||
}
|
||||
|
||||
export function pickFolder() {
|
||||
return getAdapter().pickFolder()
|
||||
}
|
||||
|
||||
export function authenticateBiometric() {
|
||||
return getAdapter().authenticateBiometric()
|
||||
}
|
||||
|
||||
export function storeCredential(key: string, value: string) {
|
||||
return getAdapter().storeCredential(key, value)
|
||||
}
|
||||
|
||||
export function getCredential(key: string) {
|
||||
return getAdapter().getCredential(key)
|
||||
}
|
||||
|
||||
export function setTitle(title: string) {
|
||||
return getAdapter().setTitle(title)
|
||||
}
|
||||
|
||||
export function minimize() {
|
||||
return getAdapter().minimize()
|
||||
}
|
||||
|
||||
export function maximize() {
|
||||
return getAdapter().maximize()
|
||||
}
|
||||
|
||||
export function close() {
|
||||
return getAdapter().close()
|
||||
}
|
||||
|
||||
export function setDiscordPresence(presence: DiscordPresence) {
|
||||
return getAdapter().setDiscordPresence(presence)
|
||||
}
|
||||
|
||||
export function clearDiscordPresence() {
|
||||
return getAdapter().clearDiscordPresence()
|
||||
}
|
||||
|
||||
export function getVersion() {
|
||||
return getAdapter().getVersion()
|
||||
}
|
||||
|
||||
export function openExternal(url: string) {
|
||||
return getAdapter().openExternal(url)
|
||||
}
|
||||
|
||||
export function checkForAppUpdate(): Promise<AppUpdateInfo | null> {
|
||||
return getAdapter().checkForAppUpdate()
|
||||
}
|
||||
|
||||
export function installAppUpdate() {
|
||||
return getAdapter().installAppUpdate()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ServerAdapter } from '$lib/server-adapters/types'
|
||||
|
||||
let adapter: ServerAdapter
|
||||
|
||||
export function initRequestManager(a: ServerAdapter) {
|
||||
adapter = a
|
||||
}
|
||||
|
||||
export function getAdapter(): ServerAdapter {
|
||||
if (!adapter) throw new Error('RequestManager not initialized')
|
||||
return adapter
|
||||
}
|
||||
@@ -47,12 +47,12 @@ export async function addToLibrary(mangaId: string) {
|
||||
|
||||
export async function removeFromLibrary(mangaId: string) {
|
||||
await getAdapter().removeFromLibrary(mangaId)
|
||||
libraryState.items = libraryState.items.filter(m => m.id !== mangaId)
|
||||
libraryState.items = libraryState.items.filter(m => String(m.id) !== mangaId)
|
||||
}
|
||||
|
||||
export async function updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
|
||||
await getAdapter().updateMangaMeta(id, meta)
|
||||
if (seriesState.current?.id === id) {
|
||||
if (String(seriesState.current?.id) === id) {
|
||||
await loadManga(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
ServerAdapter,
|
||||
ServerConfig,
|
||||
ServerStatus,
|
||||
MangaFilters,
|
||||
MangaMeta,
|
||||
PaginatedResult,
|
||||
Page,
|
||||
DownloadItem,
|
||||
UpdateResult,
|
||||
} from '$lib/server-adapters/types'
|
||||
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
|
||||
|
||||
function notImplemented(): never {
|
||||
throw new Error('MokuAdapter: not implemented')
|
||||
}
|
||||
|
||||
export class MokuAdapter implements ServerAdapter {
|
||||
async connect(_config: ServerConfig): Promise<void> { notImplemented() }
|
||||
async getStatus(): Promise<ServerStatus> { return notImplemented() }
|
||||
|
||||
async getManga(_id: string): Promise<Manga> { return notImplemented() }
|
||||
async getMangaList(_filters: MangaFilters): Promise<PaginatedResult<Manga>> { return notImplemented() }
|
||||
async searchManga(_query: string, _sourceId?: string): Promise<Manga[]> { return notImplemented() }
|
||||
async addToLibrary(_mangaId: string): Promise<void> { notImplemented() }
|
||||
async removeFromLibrary(_mangaId: string): Promise<void> { notImplemented() }
|
||||
async updateMangaMeta(_id: string, _meta: Partial<MangaMeta>): Promise<void> { notImplemented() }
|
||||
|
||||
async getChapters(_mangaId: string): Promise<Chapter[]> { return notImplemented() }
|
||||
async getChapter(_id: string): Promise<Chapter> { return notImplemented() }
|
||||
async getChapterPages(_id: string): Promise<Page[]> { return notImplemented() }
|
||||
async markChapterRead(_id: string, _read: boolean): Promise<void> { notImplemented() }
|
||||
async markChaptersRead(_ids: string[], _read: boolean): Promise<void> { notImplemented() }
|
||||
|
||||
async getDownloads(): Promise<DownloadItem[]> { return notImplemented() }
|
||||
async enqueueDownload(_chapterId: string): Promise<void> { notImplemented() }
|
||||
async dequeueDownload(_chapterId: string): Promise<void> { notImplemented() }
|
||||
async clearDownloads(): Promise<void> { notImplemented() }
|
||||
|
||||
async getExtensions(): Promise<Extension[]> { return notImplemented() }
|
||||
async installExtension(_id: string): Promise<void> { notImplemented() }
|
||||
async uninstallExtension(_id: string): Promise<void> { notImplemented() }
|
||||
async updateExtension(_id: string): Promise<void> { notImplemented() }
|
||||
|
||||
async getSources(): Promise<Source[]> { return notImplemented() }
|
||||
async browseSource(_sourceId: string, _page: number): Promise<PaginatedResult<Manga>> { return notImplemented() }
|
||||
|
||||
async getTrackers(): Promise<Tracker[]> { return notImplemented() }
|
||||
async linkTracker(_mangaId: string, _trackerId: string, _remoteId: string): Promise<void> { notImplemented() }
|
||||
async syncTracking(_mangaId: string): Promise<void> { notImplemented() }
|
||||
|
||||
async checkForUpdates(_mangaIds?: string[]): Promise<UpdateResult[]> { return notImplemented() }
|
||||
}
|
||||
@@ -10,299 +10,57 @@ import type {
|
||||
UpdateResult,
|
||||
} from '$lib/server-adapters/types'
|
||||
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
|
||||
import {
|
||||
GET_LIBRARY,
|
||||
GET_MANGA,
|
||||
GET_CATEGORIES,
|
||||
FETCH_MANGA,
|
||||
UPDATE_MANGA,
|
||||
SET_MANGA_META,
|
||||
UPDATE_LIBRARY,
|
||||
FETCH_SOURCE_MANGA,
|
||||
} from './manga'
|
||||
import {
|
||||
GET_CHAPTERS,
|
||||
FETCH_CHAPTERS,
|
||||
FETCH_CHAPTER_PAGES,
|
||||
MARK_CHAPTER_READ,
|
||||
MARK_CHAPTERS_READ,
|
||||
} from './chapters'
|
||||
import {
|
||||
GET_DOWNLOAD_STATUS,
|
||||
ENQUEUE_DOWNLOAD,
|
||||
DEQUEUE_DOWNLOAD,
|
||||
CLEAR_DOWNLOADER,
|
||||
} from './downloads'
|
||||
import {
|
||||
GET_EXTENSIONS,
|
||||
GET_SOURCES,
|
||||
FETCH_EXTENSIONS,
|
||||
UPDATE_EXTENSION,
|
||||
} from './extensions'
|
||||
import {
|
||||
GET_TRACKERS,
|
||||
BIND_TRACK,
|
||||
TRACK_PROGRESS,
|
||||
} from './tracking'
|
||||
import {
|
||||
GQLResponse,
|
||||
mapManga,
|
||||
mapChapter,
|
||||
mapExtension,
|
||||
mapDownloadItem,
|
||||
} from './types'
|
||||
|
||||
interface GQLResponse<T> {
|
||||
data: T
|
||||
errors?: { message: string }[]
|
||||
}
|
||||
|
||||
const GET_LIBRARY = `
|
||||
query GetLibrary {
|
||||
mangas(condition: { inLibrary: true }) {
|
||||
nodes {
|
||||
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
|
||||
description status author artist genre inLibraryAt lastFetchedAt
|
||||
source { id name displayName }
|
||||
chapters { totalCount }
|
||||
lastReadChapter { id chapterNumber }
|
||||
firstUnreadChapter { id chapterNumber }
|
||||
}
|
||||
const GET_CHAPTER = `
|
||||
query GetChapter($id: Int!) {
|
||||
chapter(id: $id) {
|
||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_MANGA = `
|
||||
query GetManga($id: Int!) {
|
||||
manga(id: $id) {
|
||||
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||
inLibraryAt lastFetchedAt updateStrategy
|
||||
source { id name displayName }
|
||||
lastReadChapter { id chapterNumber lastPageRead }
|
||||
firstUnreadChapter { id chapterNumber }
|
||||
highestNumberedChapter { id chapterNumber }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_CHAPTERS = `
|
||||
query GetChapters($mangaId: Int!) {
|
||||
chapters(condition: { mangaId: $mangaId }) {
|
||||
nodes {
|
||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_DOWNLOAD_STATUS = `
|
||||
query GetDownloadStatus {
|
||||
downloadStatus {
|
||||
state
|
||||
queue {
|
||||
progress state tries
|
||||
chapter {
|
||||
id name pageCount mangaId
|
||||
manga { id title thumbnailUrl }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_EXTENSIONS = `
|
||||
query GetExtensions {
|
||||
extensions {
|
||||
nodes {
|
||||
apkName pkgName name lang versionName
|
||||
isInstalled isObsolete hasUpdate iconUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_SOURCES = `
|
||||
query GetSources {
|
||||
sources {
|
||||
nodes {
|
||||
id name lang displayName iconUrl isNsfw
|
||||
isConfigurable supportsLatest
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_TRACKERS = `
|
||||
query GetTrackers {
|
||||
trackers {
|
||||
nodes {
|
||||
id name icon isLoggedIn isTokenExpired authUrl
|
||||
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
|
||||
scores
|
||||
statuses { value name }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const FETCH_MANGA = `
|
||||
mutation FetchManga($id: Int!) {
|
||||
fetchManga(input: { id: $id }) {
|
||||
manga {
|
||||
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||
source { id name displayName }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const FETCH_SOURCE_MANGA = `
|
||||
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) {
|
||||
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query }) {
|
||||
mangas { id title thumbnailUrl inLibrary }
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const UPDATE_MANGA = `
|
||||
mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
|
||||
updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) {
|
||||
manga { id inLibrary }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const SET_MANGA_META = `
|
||||
mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) {
|
||||
setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) {
|
||||
meta { key value }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const FETCH_CHAPTERS = `
|
||||
mutation FetchChapters($mangaId: Int!) {
|
||||
fetchChapters(input: { mangaId: $mangaId }) {
|
||||
chapters {
|
||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const FETCH_CHAPTER_PAGES = `
|
||||
mutation FetchChapterPages($chapterId: Int!) {
|
||||
fetchChapterPages(input: { chapterId: $chapterId }) { pages }
|
||||
}
|
||||
`
|
||||
|
||||
const MARK_CHAPTER_READ = `
|
||||
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
|
||||
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
|
||||
chapter { id isRead }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const MARK_CHAPTERS_READ = `
|
||||
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
|
||||
updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) {
|
||||
chapters { id isRead }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const ENQUEUE_DOWNLOAD = `
|
||||
mutation EnqueueDownload($chapterId: Int!) {
|
||||
enqueueChapterDownload(input: { id: $chapterId }) {
|
||||
downloadStatus { state }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const DEQUEUE_DOWNLOAD = `
|
||||
mutation DequeueDownload($chapterId: Int!) {
|
||||
dequeueChapterDownload(input: { id: $chapterId }) {
|
||||
downloadStatus { state }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CLEAR_DOWNLOADER = `
|
||||
mutation ClearDownloader {
|
||||
clearDownloader(input: {}) {
|
||||
downloadStatus { state }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const FETCH_EXTENSIONS = `
|
||||
mutation FetchExtensions {
|
||||
fetchExtensions(input: {}) {
|
||||
extensions {
|
||||
apkName pkgName name lang versionName
|
||||
isInstalled isObsolete hasUpdate iconUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const UPDATE_EXTENSION = `
|
||||
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
||||
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
||||
extension { apkName pkgName name isInstalled hasUpdate }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const BIND_TRACK = `
|
||||
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
||||
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
|
||||
trackRecord { id trackerId remoteId }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const TRACK_PROGRESS = `
|
||||
mutation TrackProgress($mangaId: Int!) {
|
||||
trackProgress(input: { mangaId: $mangaId }) {
|
||||
trackRecords { id trackerId lastChapterRead status }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const UPDATE_LIBRARY = `
|
||||
mutation UpdateLibrary {
|
||||
updateLibrary(input: {}) {
|
||||
updateStatus { jobsInfo { isRunning finishedJobs totalJobs } }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
function mapChapter(raw: Record<string, unknown>): Chapter {
|
||||
return {
|
||||
id: raw.id as number,
|
||||
name: raw.name as string,
|
||||
chapterNumber: raw.chapterNumber as number,
|
||||
sourceOrder: raw.sourceOrder as number,
|
||||
read: (raw.isRead as boolean) ?? false,
|
||||
downloaded: (raw.isDownloaded as boolean) ?? false,
|
||||
bookmarked: (raw.isBookmarked as boolean) ?? false,
|
||||
pageCount: (raw.pageCount as number) ?? 0,
|
||||
mangaId: raw.mangaId as number,
|
||||
fetchedAt: raw.fetchedAt as string | undefined,
|
||||
uploadDate: raw.uploadDate as string | null | undefined,
|
||||
realUrl: raw.realUrl as string | null | undefined,
|
||||
lastPageRead: raw.lastPageRead as number | undefined,
|
||||
lastReadAt: raw.lastReadAt as string | undefined,
|
||||
scanlator: raw.scanlator as string | null | undefined,
|
||||
manga: raw.manga as Chapter['manga'],
|
||||
}
|
||||
}
|
||||
|
||||
function mapManga(raw: Record<string, unknown>): Manga {
|
||||
const inLibraryAt = raw.inLibraryAt as string | null | undefined
|
||||
return {
|
||||
...(raw as unknown as Manga),
|
||||
tags: raw.genre as string[] | undefined,
|
||||
addedAt: inLibraryAt ? new Date(inLibraryAt).getTime() : undefined,
|
||||
lastReadAt: raw.lastReadChapter
|
||||
? Date.now()
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapExtension(raw: Record<string, unknown>): Extension {
|
||||
return {
|
||||
...(raw as unknown as Extension),
|
||||
id: raw.pkgName as string,
|
||||
}
|
||||
}
|
||||
|
||||
function mapDownloadItem(raw: Record<string, unknown>): DownloadItem {
|
||||
const chapter = raw.chapter as Record<string, unknown>
|
||||
const manga = chapter?.manga as Record<string, unknown>
|
||||
return {
|
||||
chapterId: String(chapter?.id),
|
||||
mangaId: String(chapter?.mangaId ?? manga?.id),
|
||||
chapterName: chapter?.name as string,
|
||||
mangaTitle: manga?.title as string,
|
||||
progress: (raw.progress as number) ?? 0,
|
||||
state: mapDownloadState(raw.state as string),
|
||||
}
|
||||
}
|
||||
|
||||
function mapDownloadState(state: string): DownloadItem['state'] {
|
||||
switch (state) {
|
||||
case 'DOWNLOADING': return 'downloading'
|
||||
case 'FINISHED': return 'finished'
|
||||
case 'ERROR': return 'error'
|
||||
default: return 'queued'
|
||||
}
|
||||
}
|
||||
|
||||
export class SuwayomiAdapter implements ServerAdapter {
|
||||
private baseUrl = 'http://127.0.0.1:4567'
|
||||
private authHeader: string | null = null
|
||||
@@ -347,31 +105,25 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
}
|
||||
|
||||
async getManga(id: string): Promise<Manga> {
|
||||
const data = await this.gql<{ manga: Record<string, unknown> }>(
|
||||
GET_MANGA, { id: Number(id) }
|
||||
)
|
||||
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>> {
|
||||
if (filters.inLibrary) {
|
||||
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
|
||||
return { items: data.mangas.nodes.map(mapManga), hasNextPage: false }
|
||||
}
|
||||
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
|
||||
return { items: data.mangas.nodes.map(mapManga), hasNextPage: false }
|
||||
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 []
|
||||
const data = await this.gql<{
|
||||
fetchSourceManga: { mangas: Record<string, unknown>[] }
|
||||
}>(FETCH_SOURCE_MANGA, {
|
||||
source: sourceId,
|
||||
type: 'SEARCH',
|
||||
page: 1,
|
||||
query,
|
||||
})
|
||||
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page: 1, query })
|
||||
return data.fetchSourceManga.mangas.map(mapManga)
|
||||
}
|
||||
|
||||
@@ -386,11 +138,7 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
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),
|
||||
})
|
||||
await this.gql(SET_MANGA_META, { mangaId: Number(id), key, value: String(value) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,12 +150,10 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
}
|
||||
|
||||
async getChapter(id: string): Promise<Chapter> {
|
||||
const chapters = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
|
||||
GET_CHAPTERS, { mangaId: 0 }
|
||||
const data = await this.gql<{ chapter: Record<string, unknown> }>(
|
||||
GET_CHAPTER, { id: Number(id) }
|
||||
)
|
||||
const found = chapters.chapters.nodes.find(c => String(c.id) === id)
|
||||
if (!found) throw new Error(`Chapter ${id} not found`)
|
||||
return mapChapter(found)
|
||||
return mapChapter(data.chapter)
|
||||
}
|
||||
|
||||
async getChapterPages(id: string): Promise<Page[]> {
|
||||
@@ -426,9 +172,9 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
}
|
||||
|
||||
async getDownloads(): Promise<DownloadItem[]> {
|
||||
const data = await this.gql<{
|
||||
downloadStatus: { queue: Record<string, unknown>[] }
|
||||
}>(GET_DOWNLOAD_STATUS)
|
||||
const data = await this.gql<{ downloadStatus: { queue: Record<string, unknown>[] } }>(
|
||||
GET_DOWNLOAD_STATUS
|
||||
)
|
||||
return data.downloadStatus.queue.map(mapDownloadItem)
|
||||
}
|
||||
|
||||
@@ -446,9 +192,7 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
|
||||
async getExtensions(): Promise<Extension[]> {
|
||||
await this.gql(FETCH_EXTENSIONS)
|
||||
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(
|
||||
GET_EXTENSIONS
|
||||
)
|
||||
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(GET_EXTENSIONS)
|
||||
return data.extensions.nodes.map(mapExtension)
|
||||
}
|
||||
|
||||
@@ -472,11 +216,7 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
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,
|
||||
})
|
||||
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'LATEST', page })
|
||||
return {
|
||||
items: data.fetchSourceManga.mangas.map(mapManga),
|
||||
hasNextPage: data.fetchSourceManga.hasNextPage,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
export const GET_TRACKERS = `
|
||||
query GetTrackers {
|
||||
trackers {
|
||||
nodes {
|
||||
id name icon isLoggedIn isTokenExpired authUrl
|
||||
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
|
||||
scores
|
||||
statuses { value name }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_MANGA_TRACK_RECORDS = `
|
||||
query GetMangaTrackRecords($mangaId: Int!) {
|
||||
manga(id: $mangaId) {
|
||||
trackRecords {
|
||||
nodes {
|
||||
id trackerId remoteId title status score displayScore
|
||||
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SEARCH_TRACKER = `
|
||||
query SearchTracker($trackerId: Int!, $query: String!) {
|
||||
searchTracker(input: { trackerId: $trackerId, query: $query }) {
|
||||
trackSearches {
|
||||
id trackerId remoteId title coverUrl summary
|
||||
publishingStatus publishingType startDate totalChapters trackingUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const BIND_TRACK = `
|
||||
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
||||
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
|
||||
trackRecord { id trackerId remoteId }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const TRACK_PROGRESS = `
|
||||
mutation TrackProgress($mangaId: Int!) {
|
||||
trackProgress(input: { mangaId: $mangaId }) {
|
||||
trackRecords { id trackerId lastChapterRead status }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_TRACK = `
|
||||
mutation UpdateTrack($recordId: Int!, $status: Int, $score: Float, $lastChapterRead: Float, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
|
||||
updateTrack(input: {
|
||||
recordId: $recordId
|
||||
status: $status
|
||||
score: $score
|
||||
lastChapterRead: $lastChapterRead
|
||||
startDate: $startDate
|
||||
finishDate: $finishDate
|
||||
private: $private
|
||||
}) {
|
||||
trackRecord { id status score lastChapterRead }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UNLINK_TRACK = `
|
||||
mutation UnlinkTrack($trackRecordId: Int!) {
|
||||
unlinkTrack(input: { trackRecordId: $trackRecordId }) {
|
||||
trackRecord { id }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const LOGIN_TRACKER_CREDENTIALS = `
|
||||
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
||||
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
|
||||
isLoggedIn
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const LOGOUT_TRACKER = `
|
||||
mutation LogoutTracker($trackerId: Int!) {
|
||||
logoutTracker(input: { trackerId: $trackerId }) {
|
||||
isLoggedIn
|
||||
}
|
||||
}
|
||||
`
|
||||
+6
-3
@@ -1,9 +1,12 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
clearScreen: false,
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(process.env.npm_package_version ?? '0.0.0'),
|
||||
},
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
@@ -17,4 +20,4 @@ export default defineConfig({
|
||||
minify: !process.env.TAURI_DEBUG ? 'oxc' : false,
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
},
|
||||
});
|
||||
})
|
||||
Reference in New Issue
Block a user