feat: MAL sync

This commit is contained in:
ThaUnknown 2025-07-07 20:21:59 +02:00
parent 2c84ff0c62
commit 86a305ce7c
No known key found for this signature in database
13 changed files with 581 additions and 15 deletions

View file

@ -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

View file

@ -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

11
.vscode/launch.json vendored
View file

@ -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": [

View file

@ -1,5 +1,5 @@
<p align="center">
<a href="https://github.com/ThaUnknown/miru">
<a href="https://github.com/hayase-app/ui">
<img src="./static/logo_white.svg" width="300">
</a>
</p>
@ -18,7 +18,7 @@
<img src="https://img.shields.io/discord/953341991134064651?style=flat-square" alt="chat">
</a>
<a href="https://hayase.watch/download/">
<img alt="Download" src="https://img.shields.io/github/downloads/ThaUnknown/miru/total?style=flat-square">
<img alt="Download" src="https://img.shields.io/github/downloads/hayase-app/ui/total?style=flat-square">
</a>
</p>

View file

@ -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",

1
src/app.d.ts vendored
View file

@ -113,6 +113,7 @@ export interface LibraryEntry {
export interface Native {
authAL: (url: string) => Promise<AuthResponse>
authMAL: (url: string) => Promise<{ code: string, state: string }>
restart: () => Promise<void>
openURL: (url: string) => Promise<void>
share: Navigator['share']

View file

@ -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'

View file

@ -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<Record<string, { media: Array<{ id: number, idMal: number }>}>>(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 } })
}

View file

@ -16,3 +16,7 @@ export async function mappings (id: number, _fetch = fetch) {
export async function mappingsByKitsuId (kitsuId: number, _fetch = fetch) {
return await safefetch<MappingsResponse>(_fetch, `https://hayase.ani.zip/v1/mappings?kitsu_id=${kitsuId}`)
}
export async function mappingsByMalId (malId: number, _fetch = fetch) {
return await safefetch<MappingsResponse>(_fetch, `https://hayase.ani.zip/v1/mappings?mal_id=${malId}`)
}

View file

@ -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<typeof UserFrag> | 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<Media, 'mediaListEntry' | 'id'>) {
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)
])
}

448
src/lib/modules/auth/mal.ts Normal file
View file

@ -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<MALMediaStatus, ALMediaStatus> = {
watching: 'CURRENT',
plan_to_watch: 'PLANNING',
completed: 'COMPLETED',
dropped: 'DROPPED',
on_hold: 'PAUSED'
}
const AL_TO_MAL_STATUS: Record<ALMediaStatus, MALMediaStatus> = {
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<MALOAuth | undefined>('malAuth', undefined)
viewer = persisted<ResultOf<typeof UserFrag> | undefined>('malViewer', undefined)
userlist = writable<Record<string, ResultOf<typeof FullMediaList>>>({}) // al id to al mapped mal entry
malToAL: Record<string, string> = {}
ALToMal: Record<string, string> = {}
continueIDs = readable<number[]>([], 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<number[]>([], 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<T = object> (url: string | URL, method: string, body?: any): Promise<T | { error: string }> {
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<string, string> = {
'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<T> (target: string, params: Record<string, unknown> = {}): Promise<T | { error: string }> {
const url = new URL(target)
for (const [key, value] of Object.entries(params)) {
url.searchParams.append(key, String(value))
}
return await this._request<T>(url, 'GET')
}
async _post <T> (url: string, body?: Record<string, unknown> | URLSearchParams): Promise<T | { error: string }> {
return await this._request<T>(url, 'POST', body)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async _patch<T> (url: string, body: Record<string, any>): Promise<T | { error: string }> {
return await this._request<T>(url, 'PATCH', new URLSearchParams(body))
}
async _delete<T> (url: string): Promise<T | { error: string }> {
return await this._request<T>(url, 'DELETE')
}
async _refresh () {
const auth = get(this.auth)
if (!auth?.refresh_token) return
const data = await this._post<MALOAuth>(
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<MALOAuth>(
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<MALUser>(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<string, ResultOf<typeof FullMediaList>> = {}
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<typeof FullMediaList> {
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<string | undefined> {
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<string | undefined> {
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<typeof UserFrag> | 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<undefined>(`${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<typeof Entry>) {
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<MALStatus>(`${ENDPOINTS.API_ANIME}/${malId}/my_list_status`, body)
if ('error' in res) return
this.userlist.value[targetMediaId] = this._malEntryToAl(res, targetMediaId)
}
}()

View file

@ -68,6 +68,25 @@ export default Object.assign<Native, Partial<Native>>({
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',

View file

@ -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 = ''
</script>
@ -147,6 +153,41 @@
<Label for='kitsu-sync-switch' class='cursor-pointer'>Enable Sync</Label>
</div>
</div>
</div>
<div>
<div class='bg-neutral-900 px-6 py-4 rounded-t-md flex flex-row gap-3'>
{#if mal?.id}
<div use:click={() => native.openURL(`https://myanimelist.net/profile/${mal.name}`)} class='flex flex-row gap-3'>
<Avatar.Root class='size-8 rounded-md'>
<Avatar.Image src={mal.avatar?.large ?? ''} alt={mal.name} />
<Avatar.Fallback>{mal.name}</Avatar.Fallback>
</Avatar.Root>
<div class='flex flex-col'>
<div class='text-sm'>
{mal.name}
</div>
<div class='text-[9px] text-muted-foreground leading-snug'>
MyAnimeList
</div>
</div>
</div>
{:else}
<div>Not logged in</div>
{/if}
<MyAnimeList class='size-6 ml-auto' />
</div>
<div class='bg-neutral-950 px-6 py-4 rounded-b-md flex justify-between'>
{#if mal?.id}
<Button variant='secondary' on:click={() => malclient.logout()}>Logout</Button>
{:else}
<Button variant='secondary' on:click={() => malclient.login()}>Login</Button>
{/if}
<div class='flex gap-2 items-center'>
<Switch hideState={true} id='mal-sync-switch' bind:checked={$syncSettings.mal} />
<Label for='mal-sync-switch' class='cursor-pointer'>Enable Sync</Label>
</div>
</div>
</div>
<div>
<div class='bg-neutral-900 px-6 py-4 rounded-t-md flex flex-row gap-3'>