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 @@ chat - Download + Download

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 @@ + + +
+
+ {#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} + + {:else} + + {/if} +
+ + +
+