migu/common/modules/myanimelist.js

298 lines
9.2 KiB
JavaScript

import { writable } from 'simple-store-svelte'
import Bottleneck from 'bottleneck'
import { malToken, refreshMalToken } from '@/modules/settings.js'
import { codes } from "@/modules/anilist.js"
import { toast } from 'svelte-sonner'
import { sleep } from "@/modules/util.js";
import Helper from '@/modules/helper.js'
import Debug from 'debug'
const debug = Debug('ui:myanimelist')
export const clientID = '813abe41d8df73cbc1fb39db129e8a87' // app type MUST be set to other, do not generate a seed.
function printError (error) {
debug(`Error: ${error.status || error || 429} - ${error.message || codes[error.status || error || 429]}`)
toast.error('Search Failed', {
description: `Failed making request to MyAnimeList!\nTry again in a minute.\n${error.status || error || 429} - ${error.message || codes[error.status || error || 429]}`,
duration: 3000
})
}
const queryFields = [
'synopsis',
'alternative_titles',
'mean',
'rank',
'popularity',
'num_list_users',
'num_scoring_users',
'related_anime',
'media_type',
'num_episodes',
'status',
'my_list_status',
'start_date',
'end_date',
'start_season',
'broadcast',
'studios',
'authors{first_name,last_name}',
'source',
'genres',
'average_episode_duration',
'rating'
]
class MALClient {
limiter = new Bottleneck({
reservoir: 20,
reservoirRefreshAmount: 20,
reservoirRefreshInterval: 4 * 1000,
maxConcurrent: 2,
minTime: 1000
})
rateLimitPromise = null
/** @type {import('simple-store-svelte').Writable<ReturnType<MALClient['getUserLists']>>} */
userLists = writable()
userID = malToken
constructor () {
debug('Initializing MyAnimeList Client for ID ' + this.userID?.viewer?.data?.Viewer?.id)
this.limiter.on('failed', async (error, jobInfo) => {
printError(error)
if (error.status === 500) return 1
if (!error.statusText) {
if (!this.rateLimitPromise) this.rateLimitPromise = sleep(5 * 1000).then(() => { this.rateLimitPromise = null })
return 5 * 1000
}
const time = ((error.headers.get('retry-after') || 5) + 1) * 1000
if (!this.rateLimitPromise) this.rateLimitPromise = sleep(time).then(() => { this.rateLimitPromise = null })
return time
})
if (this.userID?.viewer?.data?.Viewer) {
this.userLists.value = this.getUserLists({ sort: 'list_updated_at' })
// update userLists every 15 mins
setInterval(() => {
this.userLists.value = this.getUserLists({ sort: 'list_updated_at' })
}, 1000 * 60 * 15)
}
}
/**
* @param {Record<string, any>} query
* @param {Record<string, any>} body
* @returns {Promise<import('./mal').Query<any>>}
*/
malRequest (query, body = {}) {
/** @type {RequestInit} */
const options = {
method: `${query.type}`,
headers: {
'Authorization': `Bearer ${query.token ? query.token : this.userID.token}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
if (Object.keys(body).length > 0) {
options.body = new URLSearchParams(body)
}
return this.handleRequest(query, options)
}
/**
* @param {Record<string, any>} query
* @param {Record<string, any>} options
* @returns {Promise<import('./mal').Query<any>>}
*/
handleRequest = this.limiter.wrap(async (query, options) => {
await this.rateLimitPromise
let res = {}
try {
res = await fetch(`https://api.myanimelist.net/v2/${query.path}`, options)
} catch (e) {
if (!res || res.status !== 404) throw e
}
if (!res.ok && (res.status === 429 || res.status === 500)) {
throw res
}
let json = null
try {
json = await res.json()
} catch (error) {
if (res.ok) printError(error)
}
if (!res.ok && res.status !== 404) {
if (json) {
for (const error of json?.errors || [json?.error] || []) {
let code = error
switch (error) {
case 'forbidden':
code = 403
break
case 'invalid_token':
code = 401
const oauth = await refreshMalToken(query.token ? query.token : this.userID.token) // refresh authorization token as it typically expires every 31 days.
if (oauth) {
if (!query.token) {
this.userID = malToken
}
options.headers = {
'Authorization': `Bearer ${oauth.access_token}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
return this.handleRequest(query, options)
}
break
case 'invalid_content':
code = 422
break
default:
code = res.status
}
printError(code)
}
} else {
printError(res)
}
}
return json
})
async malEntry (media, variables) {
variables.idMal = media.idMal
const res = await malClient.entry(variables)
if (!variables.token) media.mediaListEntry = res?.data?.SaveMediaListEntry
return res
}
/** @returns {Promise<import('./mal').Query<{ MediaList: import('./mal').MediaList }>>} */
async getUserLists (variables) {
debug('Getting user lists')
const limit = 1000 // max possible you can fetch
let offset = 0
let allMediaList = []
let hasNextPage = true
// Check and replace specific sort values
const customSorts = ['list_start_date_nan', 'list_finish_date_nan', 'list_progress_nan']
if (customSorts.includes(variables.sort)) {
variables.originalSort = variables.sort
variables.sort = 'list_updated_at'
}
while (hasNextPage) {
const query = {
type: 'GET',
path: `users/@me/animelist?fields=${queryFields}&nsfw=true&limit=${limit}&offset=${offset}&sort=${variables.sort}`
}
const res = await this.malRequest(query)
allMediaList = allMediaList.concat(res?.data)
if (res?.data?.length < limit) {
hasNextPage = false
} else {
offset += limit
}
}
// Custom sorting based on original variables.sort value
if (variables.originalSort === 'list_start_date_nan') {
allMediaList.sort((a, b) => {
return new Date(b.node.my_list_status.start_date) - new Date(a.node.my_list_status.start_date)
})
} else if (variables.originalSort === 'list_finish_date_nan') {
allMediaList.sort((a, b) => {
return new Date(b.node.my_list_status.finish_date) - new Date(a.node.my_list_status.finish_date)
})
} else if (variables.originalSort === 'list_progress_nan') {
allMediaList.sort((a, b) => {
return b.node.my_list_status.num_episodes_watched - a.node.my_list_status.num_episodes_watched
})
}
return {
data: {
MediaList: allMediaList
}
}
}
/** @returns {Promise<import('./mal').Query<{ Viewer: import('./mal').Viewer }>>} */
async viewer (token) {
debug('Getting viewer')
const query = {
type: 'GET',
path: 'users/@me',
token
}
return {
data: {
Viewer: await this.malRequest(query)
}
}
}
async entry (variables) {
debug(`Updating entry for ${variables.idMal}`)
const query = {
type: 'PUT',
path: `anime/${variables.idMal}/my_list_status`,
token: variables.token
}
const padNumber = (num) => num !== undefined && num !== null ? String(num).padStart(2, '0') : null
const start_date = variables.startedAt?.year && variables.startedAt.month && variables.startedAt.day ? `${variables.startedAt.year}-${padNumber(variables.startedAt.month)}-${padNumber(variables.startedAt.day)}` : null
const finish_date = variables.completedAt?.year && variables.completedAt.month && variables.completedAt.day ? `${variables.completedAt.year}-${padNumber(variables.completedAt.month)}-${padNumber(variables.completedAt.day)}` : null
const updateData = {
status: Helper.statusMap(variables.status),
is_rewatching: variables.status?.includes('REPEATING'),
num_watched_episodes: variables.episode || 0,
num_times_rewatched: variables.repeat || 0,
score: variables.score || 0
}
if (start_date) {
updateData.start_date = start_date
}
if (finish_date) {
updateData.finish_date = finish_date
}
const res = await this.malRequest(query, updateData)
if (!variables.token) this.userLists.value = this.getUserLists({ sort: 'list_updated_at' })
return res ? {
data: {
SaveMediaListEntry: {
id: variables.id,
status: variables.status,
progress: variables.episode,
score: variables.score,
repeat: variables.repeat,
startedAt: variables.startedAt,
completedAt: variables.completedAt,
customLists: []
}
}
} : res
}
async delete (variables) {
debug(`Deleting entry for ${variables.idMal}`)
const query = {
type: 'DELETE',
path: `anime/${variables.idMal}/my_list_status`,
token: variables.token
}
const res = await this.malRequest(query)
if (!variables.token) this.userLists.value = this.getUserLists({ sort: 'list_updated_at' })
return res
}
}
export const malClient = new MALClient()