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; nativeEpisode?: 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 = 0; 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: number; 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; };