From 86a305ce7c421dc0e9c856ec97d8c8d6c97ac941 Mon Sep 17 00:00:00 2001
From: ThaUnknown <6506529+ThaUnknown@users.noreply.github.com>
Date: Mon, 7 Jul 2025 20:21:59 +0200
Subject: [PATCH] feat: MAL sync
---
.github/ISSUE_TEMPLATE/bug_report.yml | 6 +-
.github/ISSUE_TEMPLATE/feature_request.yml | 6 +-
.vscode/launch.json | 11 +
README.md | 4 +-
package.json | 2 +-
src/app.d.ts | 1 +
src/lib/index.ts | 1 -
src/lib/modules/anilist/client.ts | 31 ++
src/lib/modules/anizip/index.ts | 4 +
src/lib/modules/auth/client.ts | 22 +-
src/lib/modules/auth/mal.ts | 448 ++++++++++++++++++
src/lib/modules/native.ts | 19 +
src/routes/app/settings/accounts/+page.svelte | 41 ++
13 files changed, 581 insertions(+), 15 deletions(-)
create mode 100644 src/lib/modules/auth/mal.ts
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 4b26773..f2998b8 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -12,17 +12,17 @@ body:
options:
- label: >-
I have searched the [issue
- tracker](https://github.com/ThaUnknown/miru/issues) for a bug report
+ tracker](https://github.com/hayase-app/ui/issues) for a bug report
that matches the one I want to file, without success.
required: true
- label: >-
I have searched the [frequently asked
- questions](https://miru.watch/faq) for a solution to my problem,
+ questions](https://hayase.watch/faq) for a solution to my problem,
for a solution that fixes this problem, without success.
required: true
- label: >-
I have checked that I'm using the [latest
- stable](https://github.com/ThaUnknown/miru/releases/latest) version
+ stable](https://github.com/hayase-app/ui/releases/latest) version
of the app.
required: true
- type: input
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 8637bc4..3acd0a0 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -12,17 +12,17 @@ body:
options:
- label: >-
I have searched the [issue
- tracker](https://github.com/ThaUnknown/miru/issues) for a bug report
+ tracker](https://github.com/hayase-app/ui/issues) for a bug report
that matches the one I want to file, without success.
required: true
- label: >-
I have searched the [features
- list](https://github.com/ThaUnknown/miru#features) for this feature,
+ list](https://hayase.watch/features) for this feature,
and I couldn't find it.
required: true
- label: >-
I have checked that I'm using the [latest
- stable](https://github.com/ThaUnknown/miru/releases/latest) version
+ stable](https://github.com/hayase-app/ui/releases/latest) version
of the app.
required: true
- type: textarea
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 7048c72..b6ae873 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -12,6 +12,17 @@
"presentation": {
"hidden": true
}
+ },
+ {
+ "name": "Debug Renderer Process",
+ "port": 9222,
+ "request": "attach",
+ "type": "chrome",
+ "webRoot": "${workspaceFolder}/src",
+ "timeout": 60000,
+ "presentation": {
+ "hidden": false
+ }
}
],
"compounds": [
diff --git a/README.md b/README.md
index 9f52fa4..ef6c615 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
@@ -18,7 +18,7 @@
-
+
diff --git a/package.json b/package.json
index e8bca72..e96b89d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ui",
- "version": "6.4.23",
+ "version": "6.4.24",
"license": "BUSL-1.1",
"private": true,
"packageManager": "pnpm@9.14.4",
diff --git a/src/app.d.ts b/src/app.d.ts
index 651f6bf..8c414e2 100644
--- a/src/app.d.ts
+++ b/src/app.d.ts
@@ -113,6 +113,7 @@ export interface LibraryEntry {
export interface Native {
authAL: (url: string) => Promise
+ authMAL: (url: string) => Promise<{ code: string, state: string }>
restart: () => Promise
openURL: (url: string) => Promise
share: Navigator['share']
diff --git a/src/lib/index.ts b/src/lib/index.ts
index 6a276c5..1ffea1c 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -2,7 +2,6 @@ import { writable } from 'simple-store-svelte'
import type { Media } from './modules/anilist'
-// TODO: update these
export const COMMITS_URL = 'https://api.github.com/repos/hayase-app/ui/commits'
export const WEB_URL = 'https://hayase.watch'
export const DEFAULT_EXTENSIONS = 'gh:hayase-app/extensions'
diff --git a/src/lib/modules/anilist/client.ts b/src/lib/modules/anilist/client.ts
index ecbca10..5d5b4ac 100644
--- a/src/lib/modules/anilist/client.ts
+++ b/src/lib/modules/anilist/client.ts
@@ -440,6 +440,37 @@ class AnilistClient {
return Object.entries(searchResults).map(([filename, id]) => [filename, search.data!.Page!.media!.find(media => media?.id === id)]) as Array<[string, Media | undefined]>
}
+ async malIdsCompound (ids: number[]) {
+ if (!ids.length) return {}
+
+ // chunk every 50
+ let fragmentQueries = ''
+
+ for (let i = 0; i < ids.length; i += 50) {
+ fragmentQueries += /* gql */`
+ v${i}: Page(perPage: 50, page: ${Math.floor(i / 50) + 1}) {
+ media(idMal_in: $ids, type: ANIME) {
+ ...med
+ }
+ },
+ `
+ }
+
+ const query = _gql/* gql */`
+ query($ids: [Int]) {
+ ${fragmentQueries}
+ }
+
+ fragment med on Media {
+ id,
+ idMal
+ }`
+
+ const res = await this.client.query}>>(query, { ids })
+
+ return Object.fromEntries(Object.values(res.data ?? {}).flatMap(({ media }) => media).map(media => [media.idMal, media.id]))
+ }
+
schedule (ids?: number[], onList = true) {
return queryStore({ client: this.client, query: Schedule, variables: { ids, onList, seasonCurrent: currentSeason, seasonYearCurrent: currentYear, seasonLast: lastSeason, seasonYearLast: lastYear, seasonNext: nextSeason, seasonYearNext: nextYear } })
}
diff --git a/src/lib/modules/anizip/index.ts b/src/lib/modules/anizip/index.ts
index 9869607..26bc31a 100644
--- a/src/lib/modules/anizip/index.ts
+++ b/src/lib/modules/anizip/index.ts
@@ -16,3 +16,7 @@ export async function mappings (id: number, _fetch = fetch) {
export async function mappingsByKitsuId (kitsuId: number, _fetch = fetch) {
return await safefetch(_fetch, `https://hayase.ani.zip/v1/mappings?kitsu_id=${kitsuId}`)
}
+
+export async function mappingsByMalId (malId: number, _fetch = fetch) {
+ return await safefetch(_fetch, `https://hayase.ani.zip/v1/mappings?mal_id=${malId}`)
+}
diff --git a/src/lib/modules/auth/client.ts b/src/lib/modules/auth/client.ts
index 7713fbe..0ae76b1 100644
--- a/src/lib/modules/auth/client.ts
+++ b/src/lib/modules/auth/client.ts
@@ -6,6 +6,7 @@ import { client, episodes, type Media } from '../anilist'
import kitsu from './kitsu'
import local from './local'
+import mal from './mal'
import type { Entry, UserFrag } from '../anilist/queries'
import type { ResultOf, VariablesOf } from 'gql.tada'
@@ -15,7 +16,8 @@ export default new class AuthAggregator {
// add other subscriptions here for MAL, kitsu, tvdb, etc
const unsub = [
client.viewer.subscribe(() => set(this.checkAuth())),
- kitsu.viewer.subscribe(() => set(this.checkAuth()))
+ kitsu.viewer.subscribe(() => set(this.checkAuth())),
+ mal.viewer.subscribe(() => set(this.checkAuth()))
]
return () => unsub.forEach(fn => fn())
@@ -32,8 +34,12 @@ export default new class AuthAggregator {
return !!kitsu.id()
}
+ mal () {
+ return !!mal.id()
+ }
+
checkAuth () {
- return this.anilist() || this.kitsu()
+ return this.anilist() || this.kitsu() || this.mal()
}
id () {
@@ -46,11 +52,13 @@ export default new class AuthAggregator {
profile (): ResultOf | undefined {
if (this.anilist()) return client.viewer.value?.viewer ?? undefined
if (this.kitsu()) return kitsu.profile()
+ if (this.mal()) return mal.profile()
}
mediaListEntry (media: Pick) {
if (this.anilist()) return media.mediaListEntry
if (this.kitsu()) return kitsu.userlist.value[media.id]
+ if (this.mal()) return mal.userlist.value[media.id]
return local.get(media.id)?.mediaListEntry
}
@@ -65,9 +73,9 @@ export default new class AuthAggregator {
// QUERIES/MUTATIONS
schedule (onList = true) {
- console.log('re-running')
if (this.anilist()) return client.schedule(undefined, onList)
if (this.kitsu()) return kitsu.schedule(onList)
+ if (this.mal()) return mal.schedule(onList)
return local.schedule(onList)
}
@@ -86,16 +94,18 @@ export default new class AuthAggregator {
return null
}
- planningIDs = derived([client.planningIDs, kitsu.planningIDs, local.planningIDs], ([$client, $kitsu, $local]) => {
+ planningIDs = derived([client.planningIDs, kitsu.planningIDs, local.planningIDs, mal.planningIDs], ([$client, $kitsu, $local, $mal]) => {
if (this.anilist()) return $client
if (this.kitsu()) return $kitsu
+ if (this.mal()) return $mal
if ($local.length) return $local
return null
})
- continueIDs = derived([client.continueIDs, kitsu.continueIDs, local.continueIDs], ([$client, $kitsu, $local]) => {
+ continueIDs = derived([client.continueIDs, kitsu.continueIDs, local.continueIDs, mal.continueIDs], ([$client, $kitsu, $local, $mal]) => {
if (this.anilist()) return $client
if (this.kitsu()) return $kitsu
+ if (this.mal()) return $mal
if ($local.length) return $local
return null
})
@@ -133,6 +143,7 @@ export default new class AuthAggregator {
return Promise.allSettled([
sync.al && this.anilist() && client.deleteEntry(media),
sync.kitsu && this.kitsu() && kitsu.deleteEntry(media),
+ sync.mal && this.mal() && mal.deleteEntry(media),
sync.local && local.deleteEntry(media)
])
}
@@ -147,6 +158,7 @@ export default new class AuthAggregator {
return Promise.allSettled([
sync.al && this.anilist() && client.entry(variables),
sync.kitsu && this.kitsu() && kitsu.entry(variables),
+ sync.mal && this.mal() && mal.entry(variables),
sync.local && local.entry(variables)
])
}
diff --git a/src/lib/modules/auth/mal.ts b/src/lib/modules/auth/mal.ts
new file mode 100644
index 0000000..4381fe4
--- /dev/null
+++ b/src/lib/modules/auth/mal.ts
@@ -0,0 +1,448 @@
+import { writable } from 'simple-store-svelte'
+import { derived, get, readable } from 'svelte/store'
+import { persisted } from 'svelte-persisted-store'
+import { toast } from 'svelte-sonner'
+
+import { client, type Media } from '../anilist'
+import { mappings, mappingsByMalId } from '../anizip'
+import native from '../native'
+
+import type { Entry, FullMediaList, UserFrag } from '../anilist/queries'
+import type { ResultOf, VariablesOf } from 'gql.tada'
+
+import { arrayEqual } from '$lib/utils'
+
+type ALMediaStatus = 'CURRENT' | 'PLANNING' | 'COMPLETED' | 'DROPPED' | 'PAUSED' | 'REPEATING'
+
+const MAL_TO_AL_STATUS: Record = {
+ watching: 'CURRENT',
+ plan_to_watch: 'PLANNING',
+ completed: 'COMPLETED',
+ dropped: 'DROPPED',
+ on_hold: 'PAUSED'
+}
+
+const AL_TO_MAL_STATUS: Record = {
+ CURRENT: 'watching',
+ PLANNING: 'plan_to_watch',
+ COMPLETED: 'completed',
+ DROPPED: 'dropped',
+ PAUSED: 'on_hold',
+ REPEATING: 'watching'
+}
+
+type MALMediaStatus = 'watching' | 'completed' | 'on_hold' | 'dropped' | 'plan_to_watch'
+
+interface MALOAuth {
+ token_type: string
+ expires_in: number
+ access_token: string
+ refresh_token: string
+ created_at: number
+}
+
+interface MALUser {
+ id: number
+ name: string
+ picture?: string
+ gender?: string
+ joined_at: string
+ anime_statistics?: {
+ num_items: number
+ num_episodes: number
+ num_days: number
+ }
+}
+
+interface MALListUpdate {
+ status: MALMediaStatus
+ num_watched_episodes?: number
+ score?: number
+ num_times_rewatched?: number
+ is_rewatching?: boolean
+ rewatch_value?: number
+}
+
+interface MALStatus {
+ status: MALMediaStatus
+ score: number
+ num_episodes_watched: number
+ is_rewatching: boolean
+ updated_at: string
+ start_date?: string
+ finish_date?: string
+ num_times_rewatched: number
+}
+
+interface MALAnimeListItem {
+ node: {
+ id: number
+ title: string
+ main_picture?: {
+ medium: string
+ large: string
+ }
+ num_episodes: number
+ status: string
+ my_list_status: MALStatus
+ }
+}
+
+const ENDPOINTS = {
+ API_BASE: 'https://api.myanimelist.net/v2',
+ API_OAUTH: 'https://myanimelist.net/v1/oauth2/token',
+ API_AUTHORIZE: 'https://myanimelist.net/v1/oauth2/authorize',
+ API_USER: 'https://api.myanimelist.net/v2/users/@me',
+ API_ANIME_LIST: 'https://api.myanimelist.net/v2/users/@me/animelist',
+ API_ANIME: 'https://api.myanimelist.net/v2/anime'
+} as const
+
+export default new class MALSync {
+ auth = persisted('malAuth', undefined)
+ viewer = persisted | undefined>('malViewer', undefined)
+ userlist = writable>>({}) // al id to al mapped mal entry
+ malToAL: Record = {}
+ ALToMal: Record = {}
+
+ continueIDs = readable([], set => {
+ let oldvalue: number[] = []
+ const sub = this.userlist.subscribe(values => {
+ const entries = Object.entries(values)
+ if (!entries.length) return []
+
+ const ids: number[] = []
+
+ for (const [alId, entry] of entries) {
+ if (entry.status === 'REPEATING' || entry.status === 'CURRENT') {
+ ids.push(Number(alId))
+ }
+ }
+
+ if (arrayEqual(oldvalue, ids)) return
+ oldvalue = ids
+ set(ids)
+ })
+ return sub
+ })
+
+ planningIDs = readable([], set => {
+ let oldvalue: number[] = []
+ const sub = this.userlist.subscribe(values => {
+ const entries = Object.entries(values)
+ if (!entries.length) return []
+
+ const ids: number[] = []
+
+ for (const [alId, entry] of entries) {
+ if (entry.status === 'PLANNING') {
+ ids.push(Number(alId))
+ }
+ }
+
+ if (arrayEqual(oldvalue, ids)) return
+ oldvalue = ids
+ set(ids)
+ })
+ return sub
+ })
+
+ constructor () {
+ this.auth.subscribe((auth) => {
+ if (auth) this._user()
+ })
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ async _request (url: string | URL, method: string, body?: any): Promise {
+ const auth = get(this.auth)
+ try {
+ if (auth) {
+ const expiresAt = (auth.created_at + auth.expires_in) * 1000
+
+ if (expiresAt < Date.now() - 1000 * 60 * 5) { // 5 minutes before expiry
+ await this._refresh()
+ }
+ }
+
+ const headers: Record = {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ }
+
+ if (auth) {
+ headers.Authorization = `Bearer ${auth.access_token}`
+ }
+
+ const res = await fetch(url, {
+ method,
+ headers,
+ body
+ })
+
+ if (!res.ok) {
+ const errorText = await res.text()
+ throw new Error(`HTTP ${res.status}: ${errorText}`)
+ }
+
+ if (method === 'DELETE') return undefined as T
+
+ return await res.json() as T
+ } catch (error) {
+ const err = error as Error
+ toast.error('MAL Error', { description: err.message })
+ console.error(err)
+
+ return {
+ error: err.message
+ }
+ }
+ }
+
+ async _get (target: string, params: Record = {}): Promise {
+ const url = new URL(target)
+
+ for (const [key, value] of Object.entries(params)) {
+ url.searchParams.append(key, String(value))
+ }
+
+ return await this._request(url, 'GET')
+ }
+
+ async _post (url: string, body?: Record | URLSearchParams): Promise {
+ return await this._request(url, 'POST', body)
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ async _patch (url: string, body: Record): Promise {
+ return await this._request(url, 'PATCH', new URLSearchParams(body))
+ }
+
+ async _delete (url: string): Promise {
+ return await this._request(url, 'DELETE')
+ }
+
+ async _refresh () {
+ const auth = get(this.auth)
+ if (!auth?.refresh_token) return
+
+ const data = await this._post(
+ ENDPOINTS.API_OAUTH,
+ {
+ grant_type: 'refresh_token',
+ refresh_token: auth.refresh_token
+ }
+ )
+
+ if ('access_token' in data) {
+ this.auth.set({
+ ...data,
+ created_at: Math.floor(Date.now() / 1000)
+ })
+ }
+ }
+
+ async login () {
+ const state = crypto.randomUUID().replaceAll('-', '')
+ const challenge = (crypto.randomUUID() + crypto.randomUUID()).replaceAll('-', '')
+ const clientID = 'd93b624a92e431a9b6dfe7a66c0c5bbb'
+
+ const { code } = await native.authMAL(`${ENDPOINTS.API_AUTHORIZE}?response_type=code&client_id=${clientID}&state=${state}&code_challenge=${challenge}&code_challenge_method=plain&redirect_uri=http://localhost:7344/authorize`)
+
+ const data = await this._post(
+ ENDPOINTS.API_OAUTH,
+ new URLSearchParams({
+ client_id: clientID,
+ grant_type: 'authorization_code',
+ code,
+ code_verifier: challenge,
+ redirect_uri: 'http://localhost:7344/authorize'
+ })
+ )
+
+ if ('access_token' in data) {
+ this.auth.set({
+ ...data,
+ created_at: Math.floor(Date.now() / 1000)
+ })
+ }
+ }
+
+ logout () {
+ localStorage.removeItem('malViewer')
+ localStorage.removeItem('malAuth')
+ native.restart()
+ }
+
+ async _user () {
+ const res = await this._get(ENDPOINTS.API_USER, {
+ fields: 'anime_statistics'
+ })
+
+ if ('error' in res) return
+
+ this.viewer.set({
+ id: res.id,
+ name: res.name,
+ about: '',
+ avatar: {
+ large: res.picture ?? null
+ },
+ bannerImage: null,
+ createdAt: +new Date(res.joined_at),
+ isFollowing: false,
+ isFollower: false,
+ donatorBadge: null,
+ options: null,
+ statistics: {
+ anime: {
+ count: res.anime_statistics?.num_items ?? 0,
+ minutesWatched: res.anime_statistics?.num_days ?? 0 * 24 * 60, // Convert days to minutes
+ episodesWatched: res.anime_statistics?.num_episodes ?? 0,
+ genres: null
+ }
+ }
+ })
+
+ await this._loadUserList()
+ }
+
+ async _loadUserList () {
+ const entryMap: Record> = {}
+
+ let hasNextPage = true
+ let page = 0
+
+ let data: MALAnimeListItem[] = []
+
+ while (hasNextPage) {
+ const res = await this._get<{ data: MALAnimeListItem[] }>(ENDPOINTS.API_ANIME_LIST, {
+ sort: 'list_updated_at',
+ fields: 'node.my_list_status',
+ nsfw: true,
+ limit: 1000,
+ offset: page * 1000
+ })
+
+ if ('error' in res) break
+
+ hasNextPage = res.data.length === 1000
+ page++
+
+ data = data.concat(res.data)
+ }
+
+ const ids = data.map(item => item.node.id)
+
+ const malToAl = await client.malIdsCompound(ids)
+ for (const item of data) {
+ const malId = item.node.id
+ const alId = malToAl[malId] ?? await this._getAlId(malId)
+
+ if (!alId) continue
+
+ this.malToAL[malId] = alId.toString()
+ this.ALToMal[alId] = malId.toString()
+
+ entryMap[alId] = this._malEntryToAl(item.node.my_list_status, item.node.id)
+ }
+
+ this.userlist.set(entryMap)
+ }
+
+ _malEntryToAl (item: MALStatus, id: number): ResultOf {
+ return {
+ id,
+ status: item.is_rewatching ? 'REPEATING' : MAL_TO_AL_STATUS[item.status],
+ progress: item.num_episodes_watched,
+ score: item.score,
+ repeat: item.num_times_rewatched,
+ customLists: null
+ }
+ }
+
+ async _getMalId (alId: number): Promise {
+ const malId = this.ALToMal[alId]
+ if (malId) return malId
+
+ const res = await mappings(alId)
+ if (!res?.mal_id) return
+
+ this.ALToMal[alId] = res.mal_id.toString()
+ return res.mal_id.toString()
+ }
+
+ async _getAlId (malId: number): Promise {
+ const alId = this.malToAL[malId]
+ if (alId) return alId
+
+ const res = await mappingsByMalId(malId)
+ if (!res?.anilist_id) return
+
+ this.malToAL[malId] = res.anilist_id.toString()
+ return res.anilist_id.toString()
+ }
+
+ hasAuth = derived(this.viewer, (viewer) => {
+ return viewer !== undefined && !!viewer.id
+ })
+
+ id () {
+ return get(this.viewer)?.id
+ }
+
+ profile (): ResultOf | undefined {
+ return get(this.viewer)
+ }
+
+ // QUERIES/MUTATIONS
+
+ schedule (onList = true) {
+ const ids = Object.keys(this.userlist.value).map(id => parseInt(id))
+ return client.schedule(onList && ids.length ? ids : undefined)
+ }
+
+ async toggleFav (id: number) {
+ // MAL doesn't have a public favorites API endpoint
+ }
+
+ async deleteEntry (media: Media) {
+ const malId = media.idMal ?? await this._getMalId(media.id)
+ if (!malId) return
+
+ const res = await this._delete(`${ENDPOINTS.API_ANIME}/${malId}/my_list_status`)
+
+ if (res && 'error' in res) return
+
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+ delete this.userlist.value[media.id]
+ }
+
+ following (id: number) {
+ return null // MAL doesn't support following functionality
+ }
+
+ async entry (variables: VariablesOf) {
+ const targetMediaId = variables.id
+ const malId = (await client.single(targetMediaId)).data?.Media?.idMal ?? await this._getMalId(targetMediaId)
+
+ if (!malId) {
+ toast.error('MAL Sync', {
+ description: 'Could not find MAL ID for this media.'
+ })
+ return
+ }
+
+ const body: MALListUpdate = {
+ status: AL_TO_MAL_STATUS[variables.status!],
+ num_watched_episodes: variables.progress ?? 0,
+ score: variables.score ?? 0,
+ num_times_rewatched: variables.repeat ?? 0,
+ is_rewatching: variables.status === 'REPEATING'
+ }
+
+ const res = await this._patch(`${ENDPOINTS.API_ANIME}/${malId}/my_list_status`, body)
+
+ if ('error' in res) return
+
+ this.userlist.value[targetMediaId] = this._malEntryToAl(res, targetMediaId)
+ }
+}()
diff --git a/src/lib/modules/native.ts b/src/lib/modules/native.ts
index e7607de..a0944de 100644
--- a/src/lib/modules/native.ts
+++ b/src/lib/modules/native.ts
@@ -68,6 +68,25 @@ export default Object.assign>({
check()
})
},
+ authMAL: (url: string) => {
+ return new Promise<{ code: string, state: string }>((resolve, reject) => {
+ const popup = open(url, 'authframe', 'popup,width=540,height=782')
+ if (!popup) return reject(new Error('Failed to open popup'))
+ const check = () => {
+ if (popup.closed) return reject(new Error('Popup closed'))
+ try {
+ if (popup.location.search.startsWith('?code=')) {
+ const search = Object.fromEntries(new URLSearchParams(popup.location.search).entries()) as unknown as { code: string, state: string }
+ resolve(search)
+ popup.close()
+ return
+ }
+ } catch (e) {}
+ setTimeout(check, 100)
+ }
+ check()
+ })
+ },
restart: async () => location.reload(),
openURL: async (url: string) => { open(url) },
selectPlayer: async () => 'mpv',
diff --git a/src/routes/app/settings/accounts/+page.svelte b/src/routes/app/settings/accounts/+page.svelte
index 37db931..6a41148 100644
--- a/src/routes/app/settings/accounts/+page.svelte
+++ b/src/routes/app/settings/accounts/+page.svelte
@@ -5,6 +5,7 @@
import Anilist from '$lib/components/icons/Anilist.svelte'
import Kitsu from '$lib/components/icons/Kitsu.svelte'
+ import MyAnimeList from '$lib/components/icons/MyAnimeList.svelte'
import * as Avatar from '$lib/components/ui/avatar'
import { Button } from '$lib/components/ui/button'
import * as Dialog from '$lib/components/ui/dialog'
@@ -15,6 +16,7 @@
import { client } from '$lib/modules/anilist'
import { authAggregator } from '$lib/modules/auth'
import ksclient from '$lib/modules/auth/kitsu'
+ import malclient from '$lib/modules/auth/mal'
import native from '$lib/modules/native'
import { click } from '$lib/modules/navigate'
@@ -28,6 +30,10 @@
const syncSettings = authAggregator.syncSettings
+ const malviewer = malclient.viewer
+
+ $: mal = $malviewer
+
let kitsuLogin = ''
let kitsuPassword = ''
@@ -147,6 +153,41 @@
Enable Sync
+
+
+
+
+ {#if mal?.id}
+
native.openURL(`https://myanimelist.net/profile/${mal.name}`)} class='flex flex-row gap-3'>
+
+
+ {mal.name}
+
+
+
+ {mal.name}
+
+
+ MyAnimeList
+
+
+
+ {:else}
+
Not logged in
+ {/if}
+
+
+
+ {#if mal?.id}
+
malclient.logout()}>Logout
+ {:else}
+
malclient.login()}>Login
+ {/if}
+
+
+ Enable Sync
+
+