import { alToken, malToken, isAuthorized } from '@/modules/settings.js' import { anilistClient, codes } from '@/modules/anilist.js' import { malClient } from '@/modules/myanimelist.js' import { malDubs } from "@/modules/animedubs.js" import { profiles } from '@/modules/settings.js' import { toast } from 'svelte-sonner' import { get } from 'svelte/store' import Fuse from 'fuse.js' import Debug from 'debug' const debug = Debug('ui:helper') export default class Helper { static statusName = { CURRENT: 'Watching', PLANNING: 'Planning', COMPLETED: 'Completed', PAUSED: 'Paused', DROPPED: 'Dropped', REPEATING: 'Rewatching' } static sortMap(sort) { switch(sort) { case 'UPDATED_TIME_DESC': return 'list_updated_at' case 'STARTED_ON_DESC': return 'list_start_date_nan' // doesn't exist, therefore we use custom logic. case 'FINISHED_ON_DESC': return 'list_finish_date_nan' // doesn't exist, therefore we use custom logic. case 'PROGRESS_DESC': return 'list_progress_nan' // doesn't exist, therefore we use custom logic. case 'USER_SCORE_DESC': return 'list_score' } } static statusMap(status) { switch(status) { // MyAnimeList to AniList case 'watching': return 'CURRENT' case 'rewatching': return 'REPEATING' // rewatching is determined by is_rewatching boolean (no individual list) case 'plan_to_watch': return 'PLANNING' case 'completed': return 'COMPLETED' case 'dropped': return 'DROPPED' case 'on_hold': return 'PAUSED' // AniList to MyAnimeList case 'CURRENT': return 'watching' case 'PLANNING': return 'plan_to_watch' case 'COMPLETED': return 'completed' case 'DROPPED': return 'dropped' case 'PAUSED': return 'on_hold' case 'REPEATING': return 'watching' // repeating is determined by is_rewatching boolean (no individual list) } } static airingMap(status) { switch(status) { case 'finished_airing': return 'FINISHED' case 'currently_airing': return 'RELEASING' case 'not_yet_aired': return 'NOT_YET_RELEASED' } } static getFuzzyDate(media, status) { const updatedDate = new Date() const fuzzyDate = { year: updatedDate.getFullYear(), month: updatedDate.getMonth() + 1, day: updatedDate.getDate() } const startedAt = media.mediaListEntry?.startedAt?.year && media.mediaListEntry?.startedAt?.month && media.mediaListEntry?.startedAt?.day ? media.mediaListEntry.startedAt : (['CURRENT', 'REPEATING'].includes(status) ? fuzzyDate : undefined) const completedAt = media.mediaListEntry?.completedAt?.year && media.mediaListEntry?.completedAt?.month && media.mediaListEntry?.completedAt?.day ? media.mediaListEntry.completedAt : (status === 'COMPLETED' ? fuzzyDate : undefined) return {startedAt, completedAt} } static sanitiseObject (object = {}) { const safe = {} for (const [key, value] of Object.entries(object)) { if (value) safe[key] = value } return safe } static isAniAuth() { return alToken } static isMalAuth() { return malToken } static isAuthorized() { return isAuthorized() } static getClient() { return this.isAniAuth() ? anilistClient : malClient } static getUser() { return this.getClient().userID?.viewer?.data?.Viewer } static getUserAvatar() { if (anilistClient.userID?.viewer?.data?.Viewer) { return anilistClient.userID.viewer.data.Viewer.avatar.large || anilistClient.userID.viewer.data.Viewer.avatar.medium } else if (malClient.userID?.viewer?.data?.Viewer) { return malClient.userID.viewer.data.Viewer.picture } } static isUserSort(variables) { return ['UPDATED_TIME_DESC', 'STARTED_ON_DESC', 'FINISHED_ON_DESC', 'PROGRESS_DESC', 'USER_SCORE_DESC'].includes(variables?.sort) } static userLists(variables) { return (!this.isUserSort(variables) || variables.sort === 'UPDATED_TIME_DESC') ? this.getClient().userLists.value : this.getClient().getUserLists({sort: (this.isAniAuth() ? variables.sort : this.sortMap(variables.sort))}) } static async entry(media, variables) { let res if (!variables.token) { res = await this.getClient().entry(variables) media.mediaListEntry = res?.data?.SaveMediaListEntry } else { if (variables.anilist) { res = await anilistClient.entry(variables) } else { res = await malClient.entry(variables) } } return res } static async delete(variables) { if (!variables.token) { return await this.getClient().delete(variables) } else { if (variables.anilist) { return await anilistClient.delete(variables) } else { return await malClient.delete(variables) } } } static matchTitle(media, phrase, keys) { if (!phrase) { return true } const options = { includeScore: true, threshold: 0.4, keys: keys } return new Fuse([media], options).search(phrase).length > 0 } /* * This exists to fill in any queried AniList media with the user list media data from alternate authorizations. */ static async fillEntry(media) { if (this.isMalAuth()) { debug(`Filling MyAnimeList entry data for ${media?.id} (AniList)`) const userLists = await malClient.userLists.value const malEntry = userLists.data.MediaList.find(({ node }) => node.id === media.idMal) if (malEntry) { const start_date = malEntry.node.my_list_status.start_date ? new Date(malEntry.node.my_list_status.start_date) : undefined const finish_date = malEntry.node.my_list_status.finish_date ? new Date(malEntry.node.my_list_status.finish_date) : undefined const startedAt = start_date ? { year: start_date.getFullYear(), month: start_date.getMonth() + 1, day: start_date.getDate() } : undefined const completedAt = finish_date ? { year: finish_date.getFullYear(), month: finish_date.getMonth() + 1, day: finish_date.getDate() } : undefined media.mediaListEntry = { id: media.id, progress: malEntry.node.my_list_status.num_episodes_watched, repeat: malEntry.node.my_list_status.number_times_rewatched, status: this.statusMap(malEntry.node.my_list_status?.is_rewatching ? 'rewatching' : malEntry.node.my_list_status?.status), customLists: [], score: malEntry.node.my_list_status.score, startedAt, completedAt } } } } static async updateEntry(filemedia) { // check if values exist if (filemedia.media && this.isAuthorized()) { 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, score: (media.mediaListEntry?.score ? media.mediaListEntry?.score : 0), episode: videoEpisode, lists } if (videoEpisode === mediaEpisode) { variables.status = 'COMPLETED' if (media.mediaListEntry?.status === 'REPEATING') variables.repeat = media.mediaListEntry.repeat + 1 } Object.assign(variables, this.getFuzzyDate(media, status)) let res const description = `Title: ${anilistClient.title(media)}\nStatus: ${this.statusName[variables.status]}\nEpisode: ${videoEpisode} / ${media.episodes ? media.episodes : '?'}` if (this.isAniAuth()) { res = await anilistClient.alEntry(lists, variables) } else if (this.isMalAuth()) { res = await malClient.malEntry(media, variables) } this.listToast(res, description, false) if (this.getUser().sync) { // handle profile entry syncing const mediaId = media.id for (const profile of get(profiles)) { if (profile.viewer?.data?.Viewer.sync) { let res if (profile.viewer?.data?.Viewer?.avatar) { const currentLists = (await anilistClient.getUserLists({userID: profile.viewer.data.Viewer.id, token: profile.token}))?.data?.MediaListCollection?.lists?.flatMap(list => list.entries).find(({ media }) => media.id === mediaId)?.media?.mediaListEntry?.customLists?.filter(list => list.enabled).map(list => list.name) || [] res = await anilistClient.alEntry(currentLists, {...variables, token: profile.token}) } else { res = await malClient.malEntry(media, {...variables, token: profile.token}) } this.listToast(res, description, profile) } } } } } static listToast(res, description, profile){ const who = (profile ? ' for ' + profile.viewer.data.Viewer.name + (profile.viewer?.data?.Viewer?.avatar ? ' (AniList)' : ' (MyAnimeList)') : '') if (res?.data?.mediaListEntry || res?.data?.SaveMediaListEntry) { debug(`List Updated ${who}: ${description.replace(/\n/g, ', ')}`) if (!profile) { toast.success('List Updated', { description, duration: 6000 }) } } else { const error = `\n${429} - ${codes[429]}` debug(`Error: Failed to update user list${who} with: ${description.replace(/\n/g, ', ')} ${error}`) toast.error('Failed to Update List' + who, { description: description + error, duration: 9000 }) } } static getPaginatedMediaList(page, perPage, variables, mediaList) { debug('Getting custom paged media list') const ids = this.isAniAuth() ? mediaList.filter(({ media }) => { if ((!variables.hideSubs || malDubs.dubLists.value.dubbed.includes(media.idMal)) && this.matchTitle(media, variables.search, ['title.userPreferred', 'title.english', 'title.romaji', 'title.native']) && (!variables.genre || variables.genre.map(genre => genre.trim().toLowerCase()).every(genre => media.genres.map(genre => genre.trim().toLowerCase()).includes(genre))) && (!variables.tag || variables.tag.map(tag => tag.trim().toLowerCase()).every(tag => media.tags.map(tag => tag.name.trim().toLowerCase()).includes(tag))) && (!variables.season || variables.season === media.season) && (!variables.year || variables.year === media.seasonYear) && (!variables.format || variables.format === media.format) && (!variables.status || variables.status === media.status) && (!variables.continueWatching || (media.status === 'FINISHED' || media.mediaListEntry?.progress < media.nextAiringEpisode?.episode - 1))) { return true } }).map(({ media }) => (this.isUserSort(variables) ? media : media.id)) : mediaList.filter(({ node }) => { if ((!variables.hideSubs || malDubs.dubLists.value.dubbed.includes(node.id)) && this.matchTitle(node, variables.search, ['title', 'alternative_titles.en', 'alternative_titles.ja']) && (!variables.season || variables.season.toLowerCase() === node.start_season?.season.toLowerCase()) && (!variables.year || variables.year === node.start_season?.year) && (!variables.format || (variables.format !== 'TV_SHORT' && variables.format === node.media_type.toUpperCase()) || (variables.format === 'TV_SHORT' && node.average_episode_duration < 1200)) && (!variables.status || variables.status === 'CANCELLED' || variables.status === this.airingMap(node.status))) { // api does provide airing episode or tags, additionally genres are inaccurate and tags do not exist. return true } }).map(({ node }) => node.id) if (!ids.length) return {} if (this.isUserSort(variables)) { debug(`Handling page media list with user specific sorting ${variables.sort}`) const updatedVariables = { ...variables } delete updatedVariables.sort // delete user sort as you can't sort by user specific sorting on AniList when logged into MyAnimeList. const startIndex = (perPage * (page - 1)) const endIndex = startIndex + perPage const paginatedIds = ids.slice(startIndex, endIndex) const hasNextPage = ids.length > endIndex const idIndexMap = paginatedIds.reduce((map, id, index) => { map[id] = index; return map }, {}) return this.isAniAuth() ? { data: { Page: { pageInfo: { hasNextPage: hasNextPage }, media: paginatedIds } } } : anilistClient.searchIDS({ page: 1, perPage, idMal: paginatedIds, ...this.sanitiseObject(updatedVariables) }).then(res => { res.data.Page.pageInfo.hasNextPage = hasNextPage res.data.Page.media = res.data.Page.media.sort((a, b) => { return idIndexMap[a.idMal] - idIndexMap[b.idMal] }) return res }) } else { debug(`Handling page media list with non-specific sorting ${variables.sort}`) return anilistClient.searchIDS({ page, perPage, ...({[this.isAniAuth() ? 'id' : 'idMal']: ids}), ...this.sanitiseObject(variables) }).then(res => { return res }) } } }