import type { AniListAuthorisation } from '$lib/AniList/identity'; import type { UserIdentity } from './identity'; import anime from '../../stores/anime'; import manga from '../../stores/manga'; import settings from '../../stores/settings'; import lastPruneTimes from '../../stores/lastPruneTimes'; export enum Type { Anime, Manga } export interface MediaTitle { romaji: string; english: string; native: string; } export interface Media { id: number; status: 'FINISHED' | 'RELEASING' | 'NOT_YET_RELEASED' | 'CANCELLED' | 'HIATUS'; type: 'ANIME' | 'MANGA'; episodes: number; chapters: number; volumes: number; duration: number; format: | 'TV' | 'TV_SHORT' | 'MOVIE' | 'SPECIAL' | 'OVA' | 'ONA' | 'MUSIC' | 'MANGA' | 'NOVEL' | 'ONE_SHOT'; title: MediaTitle; nextAiringEpisode?: { episode: number; airingAt?: number; }; mediaListEntry?: { progress: number; progressVolumes: number; status: 'CURRENT' | 'PLANNING' | 'COMPLETED' | 'DROPPED' | 'PAUSED' | 'REPEATING'; score: number; repeat: number; startedAt: { year: number; }; completedAt: { year: number; }; }; startDate: { year: number; }; coverImage: { extraLarge: string; }; } export const flattenLists = (lists: { name: string; entries: { media: Media }[] }[]) => { if (lists === undefined) { return []; } const flattenedList: Media[] = []; const ignoredMediaIds: number[] = []; for (const list of lists) { if (list.name.toLowerCase().includes('#dueignore')) { ignoredMediaIds.push(...list.entries.map((entry) => entry.media.id)); } else { flattenedList.push(...list.entries.map((entry) => entry.media)); } } return flattenedList .filter((media) => !ignoredMediaIds.includes(media.id)) .filter( (item, index, self) => self.findIndex((itemToCompare) => itemToCompare.id === item.id) === index ); }; const collectionQueryTemplate = (type: Type, userId: number, includeCompleted: boolean) => `{ MediaListCollection( userId: ${userId}, type: ${type === Type.Anime ? 'ANIME' : 'MANGA'} ${includeCompleted ? '' : ', status_not_in: [ COMPLETED ]'}) { lists { name entries { media { id status type episodes chapters format duration title { romaji english native } nextAiringEpisode { episode airingAt } mediaListEntry { progress progressVolumes status repeat score(format: POINT_100) startedAt { year } completedAt { year } } startDate { year } coverImage { extraLarge } } } } } }`; export const mediaListCollection = async ( anilistAuthorisation: AniListAuthorisation, userIdentity: UserIdentity, type: Type, mediaCache: string | undefined, currentLastPruneAt: string | number, forcePrune = false, includeCompleted = false ): Promise => { let currentCacheMinutes; settings.subscribe((value) => { currentCacheMinutes = value.cacheMinutes; }); if (String(currentLastPruneAt) === '') { if (type === Type.Anime) { lastPruneTimes.setKey('anime', new Date().getTime()); } else { lastPruneTimes.setKey('manga', new Date().getTime()); } } else { if ( (new Date().getTime() - Number(currentLastPruneAt)) / 1000 / 60 > Number(currentCacheMinutes) || forcePrune ) { if (type === Type.Anime) { lastPruneTimes.setKey('anime', new Date().getTime()); anime.set(''); } else { lastPruneTimes.setKey('manga', new Date().getTime()); manga.set(''); } mediaCache = ''; } } if (mediaCache !== undefined && mediaCache !== '') { return JSON.parse(mediaCache); } const userIdResponse = await ( await fetch('https://graphql.anilist.co', { method: 'POST', headers: { Authorization: `${anilistAuthorisation.tokenType} ${anilistAuthorisation.accessToken}`, 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ query: collectionQueryTemplate(type, userIdentity.id, includeCompleted) }) }) ).json(); if (mediaCache === '') { if (type === Type.Anime) { anime.set( JSON.stringify(flattenLists(userIdResponse['data']['MediaListCollection']['lists'])) ); } else { manga.set( JSON.stringify(flattenLists(userIdResponse['data']['MediaListCollection']['lists'])) ); } } return flattenLists(userIdResponse['data']['MediaListCollection']['lists']); }; export const publicMediaListCollection = async (userId: number, type: Type): Promise => { const userIdResponse = await ( await fetch('https://graphql.anilist.co', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ query: collectionQueryTemplate(type, userId, false) }) }) ).json(); return flattenLists(userIdResponse['data']['MediaListCollection']['lists']); }; const countMedian = (guesses: number[]) => { guesses.sort((a: number, b: number) => a - b); const mid = Math.floor(guesses.length / 2); return guesses.length % 2 !== 0 ? guesses[mid] : (guesses[mid - 1] + guesses[mid]) / 2; }; const countIQR = (guesses: number[]) => { guesses.sort((a: number, b: number) => a - b); const q1 = guesses[Math.floor(guesses.length / 4)]; const q3 = guesses[Math.ceil(guesses.length * (3 / 4))]; const iqr = q3 - q1; return guesses.filter((guess: number) => guess >= q1 - 1.5 * iqr && guess <= q3 + 1.5 * iqr); }; const countMode = (guesses: number[]) => { const frequency: { [key: number]: number } = {}; let maximumFrequency = 0; let mode = 0; for (const guess of guesses) { frequency[guess] = (frequency[guess] || 0) + 1; if (frequency[guess] > maximumFrequency) { maximumFrequency = frequency[guess]; mode = guess; } } return mode; }; export const recentMediaActivities = async ( userIdentity: UserIdentity, media: Media, method: 'median' | 'iqr_median' | 'iqr_mode' | 'mode' ): Promise => { const activities = await ( await fetch('https://graphql.anilist.co', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ query: `{ Page(page: 1) { activities(mediaId: ${media.id}, sort: ID_DESC) { ... on ListActivity { progress } } } MediaList(userId: ${userIdentity.id}, mediaId: ${media.id}) { progress } }` }) }) ).json(); const guesses: number[] = []; activities['data']['Page']['activities'].forEach((activity: { progress: string }) => { if (activity.progress !== null) { const progress = Number((activity.progress.match(/\d+$/) || [0])[0]); if (progress !== 65535) { guesses.push(progress); } } }); guesses.sort((a, b) => b - a); if (guesses.length) { let bestGuess; switch (method) { case 'median': { bestGuess = countMedian(guesses); } break; case 'iqr_median': { bestGuess = countMedian(countIQR(guesses)); } break; case 'iqr_mode': { bestGuess = countMode(countIQR(guesses)); } break; case 'mode': { bestGuess = countMode(guesses); } break; default: { bestGuess = countMedian(guesses); } break; } // if (guesses.length > 2) { // if (guesses.filter((val) => val < 7000).length) { // guesses = guesses.filter((val) => val < 7000); // } // const difference = guesses[0] - guesses[1]; // const inverseDifference = 1 + Math.ceil(25 / (difference + 1)); // if (guesses.length >= inverseDifference) { // if ( // guesses[1] === guesses[inverseDifference] || // guesses[0] - guesses[1] > 500 || // (guesses[0] - guesses[1] > 100 && guesses[1] >= guesses[inverseDifference] - 1) // ) { // bestGuess = guesses[1]; // if ( // guesses.length > 15 && // guesses[1] - guesses[2] > 50 && // guesses[2] === guesses[guesses.length - 1] // ) { // bestGuess = guesses[2]; // } // } // } // } // if (activities['data']['MediaList']['progress'] !== null) { if (activities['data']['MediaList']['progress'] > bestGuess) { bestGuess = activities['data']['MediaList']['progress']; } // } return Math.round(bestGuess); } return null; };