mirror of
https://github.com/ThaUnknown/miru.git
synced 2026-01-12 00:03:44 +00:00
feat: MAL sync
This commit is contained in:
parent
2c84ff0c62
commit
86a305ce7c
13 changed files with 581 additions and 15 deletions
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
6
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -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
11
.vscode/launch.json
vendored
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
1
src/app.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
448
src/lib/modules/auth/mal.ts
Normal 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)
|
||||
}
|
||||
}()
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
Loading…
Reference in a new issue