import type { AniListAuthorisation, UserIdentity } from "./identity"; import type { Media } from "./media"; export enum SortOptions { SCORE, MINUTES_WATCHED, COUNT, } export interface WrappedMediaFormat { startYears: { startYear: number; minutesWatched: number; count: number; }[]; genres: { meanScore: number; minutesWatched: number; chaptersRead: number; genre: string; mediaIds: number[]; }[]; tags: { meanScore: number; minutesWatched: number; chaptersRead: number; tag: { name: string; }; mediaIds: number[]; }[]; } export interface Wrapped { statistics: { anime: WrappedMediaFormat; manga: WrappedMediaFormat; }; activities: { statusCount: number; messageCount: number; }; avatar: { large: string; }; } const profileActivities = async ( user: AniListAuthorisation, identity: UserIdentity, date = new Date(), disableLoopingActivityCounter = false, ) => { const now = date.getTime(); const get = async (page: number) => await ( await fetch("https://graphql.anilist.co", { method: "POST", headers: { Authorization: `${user.tokenType} ${user.accessToken}`, "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ query: `{ Page(page: ${page}) { activities(userId: ${identity.id}, type_in: [ TEXT, MESSAGE ], createdAt_greater: ${Math.floor( new Date(date.getFullYear(), 0, 1).getTime() / 1000, )}, createdAt_lesser: ${Math.floor(new Date(date.getFullYear(), 7, 1).getTime() / 1000)}) { ... on TextActivity { type createdAt } ... on MessageActivity { type createdAt } } pageInfo { hasNextPage } } }`, }), }) ).json(); const pages = []; let page = 1; let response = await get(page); const beginningOfYear = new Date(now).setMonth(0, 1) / 1000; pages.push(response["data"]["Page"]["activities"]); while (response["data"]["Page"]["pageInfo"]["hasNextPage"]) { if (disableLoopingActivityCounter) break; page += 1; response = await get(page); pages.push(response["data"]["Page"]["activities"]); } return { statusCount: pages .flat() .filter( (activity) => activity.type === "TEXT" && activity.createdAt > beginningOfYear && activity.createdAt < now / 1000, ).length, messageCount: pages .flat() .filter( (activity) => activity.type === "MESSAGE" && activity.createdAt > beginningOfYear && activity.createdAt < now / 1000, ).length, }; }; export const wrapped = async ( anilistAuthorisation: AniListAuthorisation | undefined, identity: UserIdentity, year = new Date().getFullYear(), skipActivities = false, disableLoopingActivityCounter = false, ): Promise => { const headers: { [key: string]: string } = { "Content-Type": "application/json", Accept: "application/json", }; if (anilistAuthorisation) { headers["Authorization"] = `${anilistAuthorisation.tokenType} ${anilistAuthorisation.accessToken}`; } const wrappedResponse = await ( await fetch("https://graphql.anilist.co", { method: "POST", headers, body: JSON.stringify({ query: `{ User(name: "${identity.name}") { avatar { large } statistics { anime { startYears { startYear minutesWatched count } genres(sort: [ MEAN_SCORE_DESC ]) { meanScore minutesWatched chaptersRead genre mediaIds } tags(sort: [ MEAN_SCORE_DESC ]) { meanScore minutesWatched chaptersRead tag { name } mediaIds } } manga { startYears { startYear chaptersRead count } genres(sort: [ MEAN_SCORE_DESC ]) { meanScore minutesWatched chaptersRead genre mediaIds } tags(sort: [ MEAN_SCORE_DESC ]) { meanScore minutesWatched chaptersRead tag { name } mediaIds } } } } }`, }), }) ).json(); let statusCountActivities = 0; let messageCountActivities = 0; if (!skipActivities && anilistAuthorisation) { const { statusCount, messageCount } = await profileActivities( anilistAuthorisation, identity, new Date(year, 11, 31), disableLoopingActivityCounter, ); statusCountActivities = statusCount; messageCountActivities = messageCount; } return { statistics: wrappedResponse["data"]["User"]["statistics"], activities: { statusCount: statusCountActivities, messageCount: messageCountActivities, }, avatar: wrappedResponse["data"]["User"]["avatar"], }; }; export interface TopMedia { genres: { genre: string; averageScore: number; }[]; tags: { tag: string; averageScore: number; }[]; topGenreMedia: Media; topTagMedia: Media; } export const tops = ( media: Media[], amount: number, sortMode: SortOptions, excludedKeywords: string[] = [], ): TopMedia => { const genresMap: { [genre: string]: { totalScore: number; count: number; minutesWatched: number; }; } = {}; const tagsMap: { [tag: string]: { totalScore: number; count: number; minutesWatched: number; }; } = {}; media.forEach((m) => { if (m.mediaListEntry && m.mediaListEntry.score) { m.genres.forEach((genre) => { if (!genresMap[genre]) genresMap[genre] = { totalScore: 0, count: 0, minutesWatched: 0 }; const score = m.mediaListEntry?.score; if (score) { genresMap[genre].totalScore += score; genresMap[genre].minutesWatched += m.duration; genresMap[genre].count++; } }); m.tags.forEach((tag) => { if (tag.rank < 50) return; if (!tagsMap[tag.name]) tagsMap[tag.name] = { totalScore: 0, count: 0, minutesWatched: 0 }; const score = m.mediaListEntry?.score; if (score) { tagsMap[tag.name].totalScore += score; tagsMap[tag.name].minutesWatched += m.duration; tagsMap[tag.name].count++; } }); } }); let genres = Object.keys(genresMap) .filter( (genre) => !excludedKeywords.some((keyword) => genre.includes(keyword)), ) .map((genre) => ({ genre, averageScore: Math.round( genresMap[genre].totalScore / genresMap[genre].count, ), })); let tags = Object.keys(tagsMap) .filter( (genre) => !excludedKeywords.some((keyword) => genre.includes(keyword)), ) .map((tag) => ({ tag, averageScore: Math.round(tagsMap[tag].totalScore / tagsMap[tag].count), })); switch (sortMode) { case SortOptions.SCORE: genres = genres.sort((a, b) => b.averageScore - a.averageScore); tags = tags.sort((a, b) => b.averageScore - a.averageScore); break; case SortOptions.MINUTES_WATCHED: genres = genres.sort( (a, b) => genresMap[b.genre].minutesWatched - genresMap[a.genre].minutesWatched, ); tags = tags.sort( (a, b) => tagsMap[b.tag].minutesWatched - tagsMap[a.tag].minutesWatched, ); break; case SortOptions.COUNT: genres = genres.sort( (a, b) => genresMap[b.genre].count - genresMap[a.genre].count, ); tags = tags.sort((a, b) => tagsMap[b.tag].count - tagsMap[a.tag].count); break; } genres = genres.slice(0, amount); tags = tags.slice(0, amount); let topGenreMedia: Media; try { topGenreMedia = media.find((m) => m.genres.includes(genres[0].genre)) || media[0]; } catch { topGenreMedia = media[0]; } let topTagMedia: Media; try { topTagMedia = media.find((m) => m.tags.some((tag) => tag.name === tags[0].tag)) || media[0]; } catch { topTagMedia = media[0]; } return { genres, tags, topGenreMedia, topTagMedia, }; };