mirror of
https://github.com/NoCrypt/migu.git
synced 2026-03-11 17:45:32 +00:00
760 lines
22 KiB
JavaScript
760 lines
22 KiB
JavaScript
import lavenshtein from 'js-levenshtein'
|
|
import { writable } from 'simple-store-svelte'
|
|
import Bottleneck from 'bottleneck'
|
|
|
|
import { alToken } from '@/modules/settings.js'
|
|
import { toast } from 'svelte-sonner'
|
|
import { sleep } from './util.js'
|
|
import IPC from '@/modules/ipc.js'
|
|
import Debug from 'debug'
|
|
|
|
const debug = Debug('ui:anilist')
|
|
|
|
const codes = {
|
|
400: 'Bad Request',
|
|
401: 'Unauthorized',
|
|
402: 'Payment Required',
|
|
403: 'Forbidden',
|
|
404: 'Not Found',
|
|
406: 'Not Acceptable',
|
|
408: 'Request Time-out',
|
|
409: 'Conflict',
|
|
410: 'Gone',
|
|
412: 'Precondition Failed',
|
|
413: 'Request Entity Too Large',
|
|
429: 'Too Many Requests',
|
|
500: 'Internal Server Error',
|
|
501: 'Not Implemented',
|
|
502: 'Bad Gateway',
|
|
503: 'Service Unavailable',
|
|
504: 'Gateway Time-out',
|
|
505: 'HTTP Version Not Supported',
|
|
506: 'Variant Also Negotiates',
|
|
507: 'Insufficient Storage',
|
|
509: 'Bandwidth Limit Exceeded',
|
|
510: 'Not Extended',
|
|
511: 'Network Authentication Required'
|
|
}
|
|
|
|
function printError (error) {
|
|
debug(`Error: ${error.status || 429} - ${error.message || codes[error.status || 429]}`)
|
|
toast.error('Search Failed', {
|
|
description: `Failed making request to anilist!\nTry again in a minute.\n${error.status || 429} - ${error.message || codes[error.status || 429]}`,
|
|
duration: 3000
|
|
})
|
|
}
|
|
|
|
const date = new Date()
|
|
export const currentSeason = ['WINTER', 'SPRING', 'SUMMER', 'FALL'][Math.floor((date.getMonth() / 12) * 4) % 4]
|
|
export const currentYear = date.getFullYear()
|
|
|
|
/**
|
|
* @param {import('./al.d.ts').Media & {lavenshtein?: number}} media
|
|
* @param {string} name
|
|
*/
|
|
function getDistanceFromTitle (media, name) {
|
|
if (media) {
|
|
const titles = Object.values(media.title).filter(v => v).map(title => lavenshtein(title.toLowerCase(), name.toLowerCase()))
|
|
const synonyms = media.synonyms.filter(v => v).map(title => lavenshtein(title.toLowerCase(), name.toLowerCase()) + 2)
|
|
const distances = [...titles, ...synonyms]
|
|
const min = distances.reduce((prev, curr) => prev < curr ? prev : curr)
|
|
media.lavenshtein = min
|
|
return media
|
|
}
|
|
}
|
|
|
|
const queryObjects = /* js */`
|
|
id,
|
|
idMal,
|
|
title {
|
|
romaji,
|
|
english,
|
|
native,
|
|
userPreferred
|
|
},
|
|
description(asHtml: false),
|
|
season,
|
|
seasonYear,
|
|
format,
|
|
status,
|
|
episodes,
|
|
duration,
|
|
averageScore,
|
|
genres,
|
|
isFavourite,
|
|
coverImage {
|
|
extraLarge,
|
|
medium,
|
|
color
|
|
},
|
|
source,
|
|
countryOfOrigin,
|
|
isAdult,
|
|
bannerImage,
|
|
synonyms,
|
|
nextAiringEpisode {
|
|
timeUntilAiring,
|
|
episode
|
|
},
|
|
startDate {
|
|
year,
|
|
month,
|
|
day
|
|
},
|
|
trailer {
|
|
id,
|
|
site
|
|
},
|
|
streamingEpisodes {
|
|
title,
|
|
thumbnail
|
|
},
|
|
mediaListEntry {
|
|
id,
|
|
progress,
|
|
repeat,
|
|
status,
|
|
customLists(asArray: true),
|
|
score(format: POINT_10)
|
|
},
|
|
studios(isMain: true) {
|
|
nodes {
|
|
name
|
|
}
|
|
},
|
|
airingSchedule(page: 1, perPage: 1, notYetAired: true) {
|
|
nodes {
|
|
episode,
|
|
airingAt
|
|
}
|
|
},
|
|
relations {
|
|
edges {
|
|
relationType(version:2),
|
|
node {
|
|
id,
|
|
title {userPreferred},
|
|
coverImage {medium},
|
|
type,
|
|
status,
|
|
format,
|
|
episodes,
|
|
synonyms,
|
|
season,
|
|
seasonYear,
|
|
startDate {
|
|
year,
|
|
month,
|
|
day
|
|
},
|
|
endDate {
|
|
year,
|
|
month,
|
|
day
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
// recommendations {
|
|
// edges {
|
|
// node {
|
|
// mediaRecommendation {
|
|
// id,
|
|
// title {
|
|
// userPreferred
|
|
// },
|
|
// coverImage {
|
|
// medium
|
|
// }
|
|
// }
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
class AnilistClient {
|
|
limiter = new Bottleneck({
|
|
reservoir: 90,
|
|
reservoirRefreshAmount: 90,
|
|
reservoirRefreshInterval: 60 * 1000,
|
|
maxConcurrent: 10,
|
|
minTime: 100
|
|
})
|
|
|
|
rateLimitPromise = null
|
|
|
|
/** @type {import('simple-store-svelte').Writable<ReturnType<AnilistClient['getUserLists']>>} */
|
|
userLists = writable()
|
|
|
|
userID = alToken
|
|
|
|
/** @type {Record<number, import('./al.d.ts').Media>} */
|
|
mediaCache = {}
|
|
|
|
lastNotificationDate = Date.now() / 1000
|
|
|
|
constructor () {
|
|
debug('Initializing Anilist 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(61 * 1000).then(() => { this.rateLimitPromise = null })
|
|
return 61 * 1000
|
|
}
|
|
const time = ((error.headers.get('retry-after') || 60) + 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()
|
|
// update userLists every 15 mins
|
|
setInterval(() => { this.userLists.value = this.getUserLists() }, 1000 * 60 * 15)
|
|
// check notifications every 5 mins
|
|
setInterval(() => { this.findNewNotifications() }, 1000 * 60 * 5)
|
|
}
|
|
}
|
|
|
|
/** @type {(options: RequestInit) => Promise<any>} */
|
|
handleRequest = this.limiter.wrap(async opts => {
|
|
await this.rateLimitPromise
|
|
let res = {}
|
|
try {
|
|
res = await fetch('https://graphql.anilist.co', opts)
|
|
} 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 || []) {
|
|
printError(error)
|
|
}
|
|
} else {
|
|
printError(res)
|
|
}
|
|
}
|
|
return json
|
|
})
|
|
|
|
/**
|
|
* @param {string} query
|
|
* @param {Record<string, any>} variables
|
|
*/
|
|
alRequest (query, variables) {
|
|
/** @type {RequestInit} */
|
|
const options = {
|
|
method: 'POST',
|
|
credentials: 'omit',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
Referer: 'https://anilist.co'
|
|
},
|
|
body: JSON.stringify({
|
|
query: query.replace(/\s/g, '').replaceAll(' ', ' '),
|
|
variables: {
|
|
page: 1,
|
|
perPage: 30,
|
|
status_in: '[CURRENT,PLANNING]',
|
|
...variables
|
|
}
|
|
})
|
|
}
|
|
// @ts-ignore
|
|
if (alToken?.token) options.headers.Authorization = alToken.token
|
|
|
|
return this.handleRequest(options)
|
|
}
|
|
|
|
async alSearch (method) {
|
|
const res = await this.searchName(method)
|
|
const media = res.data.Page.media.map(media => getDistanceFromTitle(media, method.name))
|
|
if (!media.length) return null
|
|
return media.reduce((prev, curr) => prev.lavenshtein <= curr.lavenshtein ? prev : curr)
|
|
}
|
|
|
|
async findNewNotifications () {
|
|
debug('Checking for new notifications')
|
|
const res = await this.getNotifications()
|
|
const notifications = res.data.Page.notifications
|
|
const newNotifications = notifications.filter(({ createdAt }) => createdAt > this.lastNotificationDate)
|
|
this.lastNotificationDate = Date.now() / 1000
|
|
debug(`Found ${newNotifications.length} new notifications`)
|
|
for (const { media, episode, type } of newNotifications) {
|
|
const options = {
|
|
title: media.title.userPreferred,
|
|
body: type === 'AIRING' ? `Episode ${episode} is out in Japan, it should be available soon.` : 'Was recently added to AniList.',
|
|
icon: media.coverImage.medium,
|
|
data: { id: media.id }
|
|
}
|
|
IPC.emit('notification', options)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{key: string, title: string, year?: string, isAdult: boolean}[]} flattenedTitles
|
|
**/
|
|
async alSearchCompound (flattenedTitles) {
|
|
debug(`Searching for ${flattenedTitles.length} titles via compound search`)
|
|
if (!flattenedTitles.length) return []
|
|
// isAdult doesn't need an extra variable, as the title is the same regardless of type, so we re-use the same variable for adult and non-adult requests
|
|
/** @type {Record<`v${number}`, string>} */
|
|
const requestVariables = flattenedTitles.reduce((obj, { title, isAdult }, i) => {
|
|
if (isAdult && i !== 0) return obj
|
|
obj[`v${i}`] = title
|
|
return obj
|
|
}, {})
|
|
|
|
const queryVariables = flattenedTitles.reduce((arr, { isAdult }, i) => {
|
|
if (isAdult && i !== 0) return arr
|
|
arr.push(`$v${i}: String`)
|
|
return arr
|
|
}, []).join(', ')
|
|
const fragmentQueries = flattenedTitles.map(({ year, isAdult }, i) => /* js */`
|
|
v${i}: Page(perPage: 10) {
|
|
media(type: ANIME, search: $v${(isAdult && i !== 0) ? i - 1 : i}, status_in: [RELEASING, FINISHED], isAdult: ${!!isAdult} ${year ? `, seasonYear: ${year}` : ''}) {
|
|
...med
|
|
}
|
|
}`)
|
|
|
|
const query = /* js */`
|
|
query(${queryVariables}) {
|
|
${fragmentQueries}
|
|
}
|
|
|
|
fragment med on Media {
|
|
id,
|
|
title {
|
|
romaji,
|
|
english,
|
|
native
|
|
},
|
|
synonyms
|
|
}`
|
|
|
|
/**
|
|
* @type {import('./al.d.ts').Query<Record<string, {media: import('./al.d.ts').Media[]}>>}
|
|
* @returns {Promise<[string, import('./al.d.ts').Media][]>}
|
|
* */
|
|
const res = await this.alRequest(query, requestVariables)
|
|
|
|
/** @type {Record<string, number>} */
|
|
const searchResults = {}
|
|
for (const [variableName, { media }] of Object.entries(res.data)) {
|
|
if (!media.length) continue
|
|
const titleObject = flattenedTitles[Number(variableName.slice(1))]
|
|
if (searchResults[titleObject.key]) continue
|
|
searchResults[titleObject.key] = media.map(media => getDistanceFromTitle(media, titleObject.title)).reduce((prev, curr) => prev.lavenshtein <= curr.lavenshtein ? prev : curr).id
|
|
}
|
|
|
|
const ids = Object.values(searchResults)
|
|
const search = await this.searchIDS({ id: ids, perPage: 50 })
|
|
return Object.entries(searchResults).map(([filename, id]) => [filename, search.data.Page.media.find(media => media.id === id)])
|
|
}
|
|
|
|
async alEntry (filemedia) {
|
|
// check if values exist
|
|
if (filemedia.media && alToken) {
|
|
const { media, failed } = filemedia
|
|
debug(`Checking entry for ${media.title.userPreferred}`)
|
|
|
|
debug(`Media viability: ${media.status}, Is from failed resolve: ${failed}`)
|
|
if (failed) return
|
|
if (media.status !== 'FINISHED' && media.status !== 'RELEASING') return
|
|
|
|
// check if media can even be watched, ex: it was resolved incorrectly
|
|
// some anime/OVA's can have a single episode, or some movies can have multiple episodes
|
|
const singleEpisode = ((!media.episodes && (Number(filemedia.episode) === 1 || isNaN(Number(filemedia.episode)))) || (media.format === 'MOVIE' && media.episodes === 1)) && 1 // movie check
|
|
const videoEpisode = Number(filemedia.episode) || singleEpisode
|
|
const mediaEpisode = media.episodes || media.nextAiringEpisode?.episode || singleEpisode
|
|
|
|
debug(`Episode viability: ${videoEpisode}, ${mediaEpisode}, ${singleEpisode}`)
|
|
if (!videoEpisode || !mediaEpisode) return
|
|
// check episode range, safety check if `failed` didn't catch this
|
|
if (videoEpisode > mediaEpisode) return
|
|
|
|
const lists = media.mediaListEntry?.customLists.filter(list => list.enabled).map(list => list.name) || []
|
|
|
|
const status = media.mediaListEntry?.status === 'REPEATING' ? 'REPEATING' : 'CURRENT'
|
|
const progress = media.mediaListEntry?.progress
|
|
|
|
debug(`User's progress: ${progress}, Media's progress: ${videoEpisode}`)
|
|
// check user's own watch progress
|
|
if (progress > videoEpisode) return
|
|
if (progress === videoEpisode && videoEpisode !== mediaEpisode && !singleEpisode) return
|
|
|
|
debug(`Updating entry for ${media.title.userPreferred}`)
|
|
const variables = {
|
|
repeat: media.mediaListEntry?.repeat || 0,
|
|
id: media.id,
|
|
status,
|
|
episode: videoEpisode,
|
|
lists
|
|
}
|
|
if (videoEpisode === mediaEpisode) {
|
|
variables.status = 'COMPLETED'
|
|
if (media.mediaListEntry?.status === 'REPEATING') variables.repeat = media.mediaListEntry.repeat + 1
|
|
}
|
|
if (!lists.includes('Watched using Miru')) {
|
|
variables.lists.push('Watched using Miru')
|
|
}
|
|
await this.entry(variables)
|
|
this.userLists.value = this.getUserLists()
|
|
}
|
|
}
|
|
|
|
async searchName (variables = {}) {
|
|
debug(`Searching name for ${variables.name}`)
|
|
const query = /* js */`
|
|
query($page: Int, $perPage: Int, $sort: [MediaSort], $name: String, $status: [MediaStatus], $year: Int, $isAdult: Boolean) {
|
|
Page(page: $page, perPage: $perPage) {
|
|
pageInfo {
|
|
hasNextPage
|
|
},
|
|
media(type: ANIME, search: $name, sort: $sort, status_in: $status, isAdult: $isAdult, format_not: MUSIC, seasonYear: $year) {
|
|
${queryObjects}
|
|
}
|
|
}
|
|
}`
|
|
|
|
variables.isAdult = variables.isAdult ?? false
|
|
|
|
/** @type {import('./al.d.ts').PagedQuery<{media: import('./al.d.ts').Media[]}>} */
|
|
const res = await this.alRequest(query, variables)
|
|
|
|
this.updateCache(res.data.Page.media)
|
|
|
|
return res
|
|
}
|
|
|
|
async searchIDSingle (variables) {
|
|
debug(`Searching for ID: ${variables.id}`)
|
|
const query = /* js */`
|
|
query($id: Int) {
|
|
Media(id: $id, type: ANIME) {
|
|
${queryObjects}
|
|
}
|
|
}`
|
|
|
|
/** @type {import('./al.d.ts').Query<{Media: import('./al.d.ts').Media}>} */
|
|
const res = await this.alRequest(query, variables)
|
|
|
|
this.updateCache([res.data.Media])
|
|
|
|
return res
|
|
}
|
|
|
|
async searchIDS (variables) {
|
|
debug(`Searching for IDs: ${variables.id.length}`)
|
|
const query = /* js */`
|
|
query($id: [Int], $page: Int, $perPage: Int, $status: [MediaStatus], $onList: Boolean, $sort: [MediaSort], $search: String, $season: MediaSeason, $year: Int, $genre: String, $format: MediaFormat) {
|
|
Page(page: $page, perPage: $perPage) {
|
|
pageInfo {
|
|
hasNextPage
|
|
},
|
|
media(id_in: $id, type: ANIME, status_in: $status, onList: $onList, search: $search, sort: $sort, season: $season, seasonYear: $year, genre: $genre, format: $format) {
|
|
${queryObjects}
|
|
}
|
|
}
|
|
}`
|
|
|
|
/** @type {import('./al.d.ts').PagedQuery<{media: import('./al.d.ts').Media[]}>} */
|
|
const res = await this.alRequest(query, variables)
|
|
|
|
this.updateCache(res.data.Page.media)
|
|
|
|
return res
|
|
}
|
|
|
|
/** @returns {Promise<import('./al.d.ts').PagedQuery<{ notifications: { id: number, type: string, createdAt: number, episode: number, media: import('./al.d.ts').Media}[] }>>} */
|
|
getNotifications (variables = {}) {
|
|
debug('Getting notifications')
|
|
const query = /* js */`
|
|
query($page: Int, $perPage: Int) {
|
|
Page(page: $page, perPage: $perPage) {
|
|
notifications(resetNotificationCount: false, type_in: [AIRING, RELATED_MEDIA_ADDITION]) {
|
|
...on AiringNotification {
|
|
id,
|
|
type,
|
|
episode,
|
|
createdAt,
|
|
media {
|
|
id,
|
|
title {
|
|
userPreferred
|
|
},
|
|
coverImage {
|
|
medium
|
|
}
|
|
}
|
|
},
|
|
...on RelatedMediaAdditionNotification {
|
|
id,
|
|
type,
|
|
createdAt,
|
|
media {
|
|
id,
|
|
title {
|
|
userPreferred
|
|
},
|
|
coverImage {
|
|
medium
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
return this.alRequest(query, variables)
|
|
}
|
|
|
|
/** @returns {Promise<import('./al.d.ts').Query<{ Viewer: import('./al.d.ts').Viewer }>>} */
|
|
viewer (variables = {}) {
|
|
debug('Getting viewer')
|
|
const query = /* js */`
|
|
query {
|
|
Viewer {
|
|
avatar {
|
|
medium
|
|
},
|
|
name,
|
|
id,
|
|
mediaListOptions {
|
|
animeList {
|
|
customLists
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
return this.alRequest(query, variables)
|
|
}
|
|
|
|
/** @returns {Promise<import('./al.d.ts').Query<{ MediaListCollection: import('./al.d.ts').MediaListCollection }>>} */
|
|
async getUserLists (variables = {}) {
|
|
debug('Getting user lists')
|
|
const userId = this.userID?.viewer?.data?.Viewer.id
|
|
variables.id = userId
|
|
const query = /* js */`
|
|
query($id: Int) {
|
|
MediaListCollection(userId: $id, type: ANIME, forceSingleCompletedList: true, sort: UPDATED_TIME_DESC) {
|
|
lists {
|
|
status,
|
|
entries {
|
|
media {
|
|
id,
|
|
status,
|
|
mediaListEntry {
|
|
progress
|
|
},
|
|
nextAiringEpisode {
|
|
episode
|
|
},
|
|
relations {
|
|
edges {
|
|
relationType(version:2)
|
|
node {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
// this doesn't need to be cached, as SearchIDStatus is already cached, which is the only thing that uses this
|
|
return await this.alRequest(query, variables)
|
|
}
|
|
|
|
/** @returns {Promise<import('./al.d.ts').Query<{ MediaList: { status: string, progress: number, repeat: number }}>>} */
|
|
async searchIDStatus (variables = {}) {
|
|
debug(`Searching for ID status: ${variables.id}`)
|
|
const userId = this.userID?.viewer?.data?.Viewer.id
|
|
variables.id = userId
|
|
const query = /* js */`
|
|
query($id: Int, $mediaId: Int) {
|
|
MediaList(userId: $id, mediaId: $mediaId) {
|
|
status,
|
|
progress,
|
|
repeat
|
|
}
|
|
}`
|
|
|
|
return await this.alRequest(query, variables)
|
|
}
|
|
|
|
async searchAiringSchedule (variables = {}) {
|
|
debug('Searching for airing schedule')
|
|
variables.to = (variables.from + 7 * 24 * 60 * 60)
|
|
const query = /* js */`
|
|
query($page: Int, $perPage: Int, $from: Int, $to: Int) {
|
|
Page(page: $page, perPage: $perPage) {
|
|
pageInfo {
|
|
hasNextPage
|
|
},
|
|
airingSchedules(airingAt_greater: $from, airingAt_lesser: $to) {
|
|
episode,
|
|
timeUntilAiring,
|
|
airingAt,
|
|
media {
|
|
${queryObjects}
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
/** @type {import('./al.d.ts').PagedQuery<{ airingSchedules: { timeUntilAiring: number, airingAt: number, episode: number, media: import('./al.d.ts').Media}[]}>} */
|
|
const res = await this.alRequest(query, variables)
|
|
|
|
this.updateCache(res.data.Page.airingSchedules?.map(({ media }) => media))
|
|
|
|
return res
|
|
}
|
|
|
|
/** @returns {Promise<import('./al.d.ts').PagedQuery<{ airingSchedules: { airingAt: number, episode: number }[]}>>} */
|
|
episodes (variables = {}) {
|
|
debug(`Searching for episodes: ${variables.id}`)
|
|
const query = /* js */`
|
|
query($id: Int) {
|
|
Page(page: 1, perPage: 1000) {
|
|
airingSchedules(mediaId: $id) {
|
|
airingAt,
|
|
episode
|
|
}
|
|
}
|
|
}`
|
|
|
|
return this.alRequest(query, variables)
|
|
}
|
|
|
|
async search (variables = {}) {
|
|
debug(`Searching ${JSON.stringify(variables)}`)
|
|
variables.sort ||= 'SEARCH_MATCH'
|
|
const query = /* js */`
|
|
query($page: Int, $perPage: Int, $sort: [MediaSort], $search: String, $onList: Boolean, $status: MediaStatus, $status_not: MediaStatus, $season: MediaSeason, $year: Int, $genre: String, $format: MediaFormat) {
|
|
Page(page: $page, perPage: $perPage) {
|
|
pageInfo {
|
|
hasNextPage
|
|
},
|
|
media(type: ANIME, search: $search, sort: $sort, onList: $onList, status: $status, status_not: $status_not, season: $season, seasonYear: $year, genre: $genre, format: $format, format_not: MUSIC) {
|
|
${queryObjects}
|
|
}
|
|
}
|
|
}`
|
|
|
|
/** @type {import('./al.d.ts').PagedQuery<{media: import('./al.d.ts').Media[]}>} */
|
|
const res = await this.alRequest(query, variables)
|
|
|
|
this.updateCache(res.data.Page.media)
|
|
|
|
return res
|
|
}
|
|
|
|
/** @returns {Promise<import('./al.d.ts').Query<{ AiringSchedule: { airingAt: number }}>>} */
|
|
episodeDate (variables) {
|
|
debug(`Searching for episode date: ${variables.id}, ${variables.ep}`)
|
|
const query = /* js */`
|
|
query($id: Int, $ep: Int) {
|
|
AiringSchedule(mediaId: $id, episode: $ep) {
|
|
airingAt
|
|
}
|
|
}`
|
|
|
|
return this.alRequest(query, variables)
|
|
}
|
|
|
|
/** @returns {Promise<import('./al.d.ts').PagedQuery<{ mediaList: import('./al.d.ts').Following[]}>>} */
|
|
following (variables) {
|
|
debug('Getting following')
|
|
const query = /* js */`
|
|
query($id: Int) {
|
|
Page {
|
|
mediaList(mediaId: $id, isFollowing: true, sort: UPDATED_TIME_DESC) {
|
|
status,
|
|
score,
|
|
progress,
|
|
user {
|
|
name,
|
|
avatar {
|
|
medium
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
return this.alRequest(query, variables)
|
|
}
|
|
|
|
entry (variables) {
|
|
debug(`Updating entry for ${variables.id}`)
|
|
const query = /* js */`
|
|
mutation($lists: [String], $id: Int, $status: MediaListStatus, $episode: Int, $repeat: Int, $score: Int) {
|
|
SaveMediaListEntry(mediaId: $id, status: $status, progress: $episode, repeat: $repeat, scoreRaw: $score, customLists: $lists) {
|
|
id,
|
|
status,
|
|
progress,
|
|
repeat
|
|
}
|
|
}`
|
|
|
|
return this.alRequest(query, variables)
|
|
}
|
|
|
|
delete (variables) {
|
|
debug(`Deleting entry for ${variables.id}`)
|
|
const query = /* js */`
|
|
mutation($id: Int) {
|
|
DeleteMediaListEntry(id: $id) {
|
|
deleted
|
|
}
|
|
}`
|
|
|
|
return this.alRequest(query, variables)
|
|
}
|
|
|
|
favourite (variables) {
|
|
debug(`Toggling favourite for ${variables.id}`)
|
|
const query = /* js */`
|
|
mutation($id: Int) {
|
|
ToggleFavourite(animeId: $id) { anime { nodes { id } } }
|
|
}`
|
|
|
|
return this.alRequest(query, variables)
|
|
}
|
|
|
|
customList (variables = {}) {
|
|
debug('Updating custom list')
|
|
variables.lists = [...variables.lists, 'Watched using Miru']
|
|
const query = /* js */`
|
|
mutation($lists: [String]) {
|
|
UpdateUser(animeListOptions: { customLists: $lists }) {
|
|
id
|
|
}
|
|
}`
|
|
|
|
return this.alRequest(query, variables)
|
|
}
|
|
|
|
/** @param {import('./al.d.ts').Media[]} medias */
|
|
updateCache (medias) {
|
|
this.mediaCache = { ...this.mediaCache, ...Object.fromEntries(medias.map(media => [media.id, media])) }
|
|
}
|
|
}
|
|
|
|
export const anilistClient = new AnilistClient()
|