import type { AniListAuthorisation } from '$lib/Data/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'; import { options as getOptions, type Options } from '$lib/Notification/options'; import type { PrequelRelation, PrequelRelations } from './prequels'; export enum Type { Anime, Manga } export interface MediaTitle { romaji: string; english: string; native: string; } export type MediaStatus = 'FINISHED' | 'RELEASING' | 'NOT_YET_RELEASED' | 'CANCELLED' | 'HIATUS'; export type MediaListEntryStatus = | 'CURRENT' | 'PLANNING' | 'COMPLETED' | 'DROPPED' | 'PAUSED' | 'REPEATING'; export type MediaType = 'ANIME' | 'MANGA'; export type MediaFormat = | 'TV' | 'TV_SHORT' | 'MOVIE' | 'SPECIAL' | 'OVA' | 'ONA' | 'MUSIC' | 'MANGA' | 'NOVEL' | 'ONE_SHOT'; export type MediaSeason = 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; export interface Media { id: number; idMal: number; status: MediaStatus; type: MediaType; episodes: number; chapters: number; volumes: number; duration: number; format: MediaFormat; title: MediaTitle; nextAiringEpisode?: { episode: number; airingAt?: number; nativeAiringAt?: number; }; synonyms: string[]; mediaListEntry?: { progress: number; progressVolumes: number; status: MediaListEntryStatus; score: number; repeat: number; startedAt: { year: number; month: number; day: number; }; completedAt: { year: number; month: number; day: number; }; createdAt: number; updatedAt: number; customLists: Record; }; startDate: { year: number; month: number; }; endDate: { year: number; month: number; }; coverImage: { extraLarge: string; medium: string; }; tags: { name: string; rank: number; }[]; genres: string[]; season: MediaSeason; isAdult: boolean; relations: PrequelRelations; } export const flattenLists = ( lists: { name: string; entries: { media: Media }[] }[], all = false ) => { if (lists === undefined) return []; const flattenedList: Media[] = []; const markedMediaIds: number[] = []; let dueInclude = false; const processedList = (list: Media[], include: boolean) => list .filter((media) => include ? markedMediaIds.includes(media.id) : !markedMediaIds.includes(media.id) ) .filter( (item, index, self) => self.findIndex((itemToCompare) => itemToCompare.id === item.id) === index ); if (all) { for (const list of lists) flattenedList.push(...list.entries.map((entry) => entry.media)); } else { for (const list of lists) { if (list.name.toLowerCase().includes('#dueinclude')) { dueInclude = true; markedMediaIds.splice(0, markedMediaIds.length); markedMediaIds.push(...list.entries.map((entry) => entry.media.id)); } if (dueInclude) continue; if (list.name.toLowerCase().includes('#dueignore')) markedMediaIds.push(...list.entries.map((entry) => entry.media.id)); else flattenedList.push(...list.entries.map((entry) => entry.media)); } } return processedList(flattenedList, dueInclude); }; const collectionQueryTemplate = (type: Type, userId: number, options: CollectionOptions = {}) => `{ MediaListCollection( userId: ${userId}, type: ${type === Type.Anime ? 'ANIME' : 'MANGA'} ${options.includeCompleted ? '' : ', status_not_in: [ COMPLETED ]'}) { lists { name entries { media { id idMal status type episodes chapters format duration synonyms genres season isAdult tags { name rank } title { romaji english native } nextAiringEpisode { episode airingAt } mediaListEntry { progress progressVolumes status repeat createdAt updatedAt customLists score(format: POINT_100) startedAt { year month day } completedAt { year month day } } startDate { year month } endDate { year month } coverImage { extraLarge medium } ${ options.includeRelations ? ` relations { edges { relationType node { id status episodes title { english romaji native } mediaListEntry { status progress } startDate { year } coverImage { extraLarge medium } } } } ` : '' } } } } } }`; interface CollectionOptions { includeCompleted?: boolean; forcePrune?: boolean; all?: boolean; addNotification?: (preferences: Options) => void; notificationType?: string; includeRelations?: boolean; } const assignDefaultOptions = (options: CollectionOptions) => { const nonNullOptions: CollectionOptions = { includeCompleted: false, forcePrune: false, all: false, addNotification: undefined, notificationType: undefined, includeRelations: false }; if (options.includeCompleted !== undefined) nonNullOptions.includeCompleted = options.includeCompleted; if (options.forcePrune !== undefined) nonNullOptions.forcePrune = options.forcePrune; if (options.all !== undefined) nonNullOptions.all = options.all; if (options.addNotification !== undefined) nonNullOptions.addNotification = options.addNotification; if (options.notificationType !== undefined) nonNullOptions.notificationType = options.notificationType; if (options.includeRelations !== undefined) nonNullOptions.includeRelations = options.includeRelations; return nonNullOptions; }; export const mediaListCollection = async ( anilistAuthorisation: AniListAuthorisation, userIdentity: UserIdentity, type: Type, mediaCache: string | undefined, currentLastPruneAt: string | number, inputOptions: CollectionOptions = {} ): Promise => { if (userIdentity.id === -1 || userIdentity.id === -2) return []; const options = assignDefaultOptions(inputOptions); 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) || options.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, options) }) }) ).json(); if ( !userIdResponse['data'] || !userIdResponse['data']['MediaListCollection'] || !userIdResponse['data']['MediaListCollection']['lists'] ) return []; if (mediaCache === '') if (type === Type.Anime) anime.set( JSON.stringify( flattenLists(userIdResponse['data']['MediaListCollection']['lists'], options.all) ) ); else manga.set( JSON.stringify( flattenLists(userIdResponse['data']['MediaListCollection']['lists'], options.all) ) ); if (options.addNotification) options.addNotification( getOptions({ heading: options.notificationType ? options.notificationType : Type[type], description: 'Re-cached media lists from AniList' }) ); return flattenLists(userIdResponse['data']['MediaListCollection']['lists'], options.all); }; export const publicMediaListCollection = async (userId: number, type: Type): Promise => flattenLists( ( await ( await fetch('https://graphql.anilist.co', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ query: collectionQueryTemplate(type, userId, {}) }) }) ).json() )['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; }; export const mediaCover = async (id: number) => ( await ( await fetch('https://graphql.anilist.co', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ query: `{ Media(id: ${id}) { coverImage { extraLarge } } }` }) }) ).json() )['data']['Media']['coverImage']['extraLarge']; export interface UnwatchedRelationMap { media: Media; unwatchedRelations: PrequelRelation[]; } export const filterRelations = (media: Media[], includeSideStories = false) => { const unwatchedRelationsMap: UnwatchedRelationMap[] = []; for (const mediaItem of media) { const sequels = mediaItem.relations.edges.filter( (relation: PrequelRelation) => (relation.relationType === 'SEQUEL' || (relation.relationType === 'SIDE_STORY' && includeSideStories)) && !media.some((mediaItem) => mediaItem.id === relation.node.id) && (relation.node.mediaListEntry ? relation.node.mediaListEntry.status !== 'COMPLETED' : true) && relation.node.episodes && relation.node.status !== 'NOT_YET_RELEASED' && relation.node.status !== 'CANCELLED' ); if (sequels.length > 0) { unwatchedRelationsMap.push({ media: mediaItem, unwatchedRelations: sequels }); } } return unwatchedRelationsMap; };