import { browser } from "$app/environment"; import type { AniListAuthorisation } from "$lib/Data/AniList/identity"; import { parseJsonStringOrDefault } from "$lib/Effect/json"; 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"; import localforage from "localforage"; 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; }; const inFlightCollections = new Map>(); const collectionKey = ( type: Type, userId: number, options: CollectionOptions, ) => JSON.stringify({ type, userId, includeCompleted: options.includeCompleted, all: options.all, includeRelations: options.includeRelations, }); const usesSharedMediaStore = (options: CollectionOptions) => !options.includeCompleted && !options.all && !options.includeRelations; const cacheStorageKey = (type: Type, options: CollectionOptions) => { const baseKey = type === Type.Anime ? "anime:v3" : "manga:v3"; if (usesSharedMediaStore(options)) return baseKey; return [ baseKey, options.includeCompleted ? "completed" : "default", options.all ? "all" : "scoped", options.includeRelations ? "relations" : "plain", ].join(":"); }; const hydrateCollectionInputs = async ( type: Type, options: CollectionOptions, mediaCache: string | undefined, currentLastPruneAt: string | number, ) => { if (!browser) return { mediaCache, currentLastPruneAt }; if (!usesSharedMediaStore(options)) mediaCache = ""; const shouldReadStoredCache = !usesSharedMediaStore(options) || mediaCache === undefined || mediaCache === ""; const shouldReadStoredPruneTime = String(currentLastPruneAt) === "" || Number(currentLastPruneAt) <= 1; if (!shouldReadStoredCache && !shouldReadStoredPruneTime) return { mediaCache, currentLastPruneAt }; const [storedCache, storedPruneTimes] = await Promise.all([ shouldReadStoredCache ? localforage.getItem(cacheStorageKey(type, options)) : Promise.resolve(null), shouldReadStoredPruneTime ? localforage.getItem<{ anime?: number; manga?: number; chapters?: number; }>("lastPruneTimes") : Promise.resolve(null), ]); if ( shouldReadStoredCache && typeof storedCache === "string" && storedCache.length ) mediaCache = storedCache; const storedPruneAt = type === Type.Anime ? storedPruneTimes?.anime : storedPruneTimes?.manga; if (shouldReadStoredPruneTime && typeof storedPruneAt === "number") currentLastPruneAt = storedPruneAt; return { mediaCache, currentLastPruneAt }; }; 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); const currentCacheMinutes = settings.get().cacheMinutes; const hydratedInputs = await hydrateCollectionInputs( type, options, mediaCache, currentLastPruneAt, ); mediaCache = hydratedInputs.mediaCache; currentLastPruneAt = hydratedInputs.currentLastPruneAt; 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()); if (usesSharedMediaStore(options)) anime.set(""); } else { lastPruneTimes.setKey("manga", new Date().getTime()); if (usesSharedMediaStore(options)) manga.set(""); } mediaCache = ""; } } if (mediaCache !== undefined && mediaCache !== "") return parseJsonStringOrDefault(mediaCache, []); const key = collectionKey(type, userIdentity.id, options); const existing = inFlightCollections.get(key); if (existing) return existing; const request = (async () => { 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 []; const flattened = flattenLists( userIdResponse["data"]["MediaListCollection"]["lists"], options.all, ); if (mediaCache === "") { const serialized = JSON.stringify(flattened); if (usesSharedMediaStore(options)) { if (type === Type.Anime) anime.set(serialized); else manga.set(serialized); } else if (browser) await localforage.setItem(cacheStorageKey(type, options), serialized); } if (options.addNotification) options.addNotification( getOptions({ heading: options.notificationType ? options.notificationType : Type[type], description: "Re-cached media lists from AniList", }), ); return flattened; })().finally(() => { inFlightCollections.delete(key); }); inFlightCollections.set(key, request); return request; }; 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; };