aboutsummaryrefslogtreecommitdiff
path: root/src/lib/Data
diff options
context:
space:
mode:
authorFuwn <[email protected]>2024-02-08 00:01:24 -0800
committerFuwn <[email protected]>2024-02-08 00:01:24 -0800
commitf78f5f4857f24ee5338fb1643c666a6b18d75769 (patch)
tree57b1b09f20b6b261a3b1ae15bfa441965f71ecd9 /src/lib/Data
parentrefactor(data): move static data to module (diff)
downloaddue.moe-f78f5f4857f24ee5338fb1643c666a6b18d75769.tar.xz
due.moe-f78f5f4857f24ee5338fb1643c666a6b18d75769.zip
refactor(anilist): move to data module
Diffstat (limited to 'src/lib/Data')
-rw-r--r--src/lib/Data/AniList/activity.ts306
-rw-r--r--src/lib/Data/AniList/character.ts84
-rw-r--r--src/lib/Data/AniList/follow.ts48
-rw-r--r--src/lib/Data/AniList/following.ts56
-rw-r--r--src/lib/Data/AniList/forum.ts75
-rw-r--r--src/lib/Data/AniList/identity.ts34
-rw-r--r--src/lib/Data/AniList/media.ts441
-rw-r--r--src/lib/Data/AniList/notifications.ts78
-rw-r--r--src/lib/Data/AniList/prequels.ts175
-rw-r--r--src/lib/Data/AniList/schedule.ts105
-rw-r--r--src/lib/Data/AniList/user.ts92
-rw-r--r--src/lib/Data/AniList/wrapped.ts301
12 files changed, 1795 insertions, 0 deletions
diff --git a/src/lib/Data/AniList/activity.ts b/src/lib/Data/AniList/activity.ts
new file mode 100644
index 00000000..fb15521e
--- /dev/null
+++ b/src/lib/Data/AniList/activity.ts
@@ -0,0 +1,306 @@
+import { database } from '$lib/Database/activities';
+import type { User } from './follow';
+import type { AniListAuthorisation, UserIdentity } from './identity';
+
+export interface ActivityHistoryEntry {
+ date: number;
+ amount: number;
+}
+
+interface ActivityHistoryOptions {
+ stats: {
+ activityHistory: ActivityHistoryEntry[];
+ };
+ options: {
+ timezone: string;
+ };
+}
+
+interface LastActivity {
+ date: Date;
+ timezone: string;
+}
+
+export const fillMissingDays = (
+ inputActivities: ActivityHistoryEntry[],
+ startOfYear = false,
+ year = new Date().getFullYear()
+): ActivityHistoryEntry[] => {
+ const yearDate = new Date(year, 0, 0);
+
+ if (inputActivities.length === 0)
+ return startOfYear
+ ? fillDateRange(
+ new Date(yearDate.getUTCFullYear(), 0, 1),
+ new Date(yearDate.getUTCFullYear() + 1, 0, 1)
+ )
+ : [];
+
+ const sortedActivities = [...inputActivities].sort((a, b) => a.date - b.date);
+ const endDate = new Date(sortedActivities[sortedActivities.length - 1].date * 1000);
+
+ endDate.setUTCDate(endDate.getUTCDate() + 1);
+
+ return fillDateRange(
+ startOfYear
+ ? new Date(yearDate.getUTCFullYear(), 0, 1)
+ : new Date(sortedActivities[0].date * 1000),
+ endDate,
+ sortedActivities
+ );
+};
+
+const fillDateRange = (
+ startDate: Date,
+ endDate: Date,
+ existingActivities: ActivityHistoryEntry[] = []
+): ActivityHistoryEntry[] => {
+ const outputActivities: ActivityHistoryEntry[] = [];
+
+ for (let dt = new Date(startDate); dt < endDate; dt.setUTCDate(dt.getUTCDate() + 1)) {
+ const dateString = dt.toDateString();
+
+ if (
+ !new Set(
+ existingActivities.map((activity) => new Date(activity.date * 1000).toDateString())
+ ).has(dateString)
+ ) {
+ outputActivities.push({ date: Math.floor(dt.getTime() / 1000), amount: 0 });
+ } else {
+ const activity = existingActivities.find(
+ (activity) => new Date(activity.date * 1000).toDateString() === dateString
+ );
+
+ if (activity) outputActivities.push(activity);
+ }
+ }
+
+ return outputActivities;
+};
+
+export const activityHistoryOptions = async (
+ userIdentity: UserIdentity,
+ authorisation?: AniListAuthorisation
+): Promise<ActivityHistoryOptions> => {
+ return (
+ await (
+ await fetch('https://graphql.anilist.co', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ ...(authorisation
+ ? { Authorization: `${authorisation.tokenType} ${authorisation.accessToken}` }
+ : {})
+ },
+ body: JSON.stringify({
+ query: `{ User(id: ${userIdentity.id}) {
+ stats { activityHistory { date amount } }
+ ${authorisation ? 'options { timezone }' : ''}
+ } }`
+ })
+ })
+ ).json()
+ )['data']['User'];
+};
+
+const convertToTimezoneOffset = (timeStr: string) => {
+ const [hours, minutes] = timeStr.split(':');
+ let totalMinutes = parseInt(hours, 10) * 60 + parseInt(minutes, 10);
+
+ totalMinutes = -totalMinutes;
+
+ return totalMinutes;
+};
+
+export const activityHistory = async (
+ userIdentity: UserIdentity,
+ authorisation?: AniListAuthorisation
+): Promise<ActivityHistoryEntry[]> =>
+ (await activityHistoryOptions(userIdentity, authorisation)).stats.activityHistory;
+
+export const lastActivityDate = async (
+ userIdentity: UserIdentity,
+ authorisation: AniListAuthorisation
+): Promise<LastActivity> => {
+ if (userIdentity.id === -1 || userIdentity.id === -2)
+ return { date: new Date(8640000000000000), timezone: '' };
+
+ const history = await activityHistoryOptions(userIdentity, authorisation);
+ const date = new Date(
+ Number(history.stats.activityHistory[history.stats.activityHistory.length - 1].date) * 1000 +
+ convertToTimezoneOffset(history.options.timezone)
+ );
+
+ date.setDate(date.getDate() + 1);
+
+ return { date, timezone: history.options.timezone };
+};
+
+export interface ActivitiesPage {
+ data: {
+ Page: {
+ pageInfo: {
+ hasNextPage: boolean;
+ };
+ activities: {
+ createdAt: number;
+ }[];
+ };
+ };
+}
+
+const activitiesPage = async (
+ page: number,
+ anilistAuthorisation: AniListAuthorisation,
+ userIdentity: UserIdentity,
+ year = new Date().getFullYear()
+): Promise<ActivitiesPage> =>
+ 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: `{
+ Page(page: ${page}) {
+ pageInfo { hasNextPage }
+ activities(userId: ${userIdentity.id}, createdAt_greater: ${Math.floor(
+ new Date(year, 0, 1).getTime() / 1000
+ )}, createdAt_lesser: ${Math.floor(new Date(year, 7, 1).getTime() / 1000)}) {
+ ... on TextActivity { createdAt }
+ ... on ListActivity { createdAt }
+ ... on MessageActivity { createdAt }
+ }
+ }
+ }`
+ })
+ })
+ ).json();
+
+export const fullActivityHistory = async (
+ anilistAuthorisation: AniListAuthorisation,
+ userIdentity: UserIdentity,
+ year = new Date().getFullYear()
+): Promise<ActivityHistoryEntry[]> => {
+ const activities = [];
+ let page = 1;
+ let currentDatabasePage = await database.activities.get(page);
+ let currentPage;
+
+ if (currentDatabasePage) currentPage = currentDatabasePage.data;
+ else {
+ currentPage = await activitiesPage(page, anilistAuthorisation, userIdentity, year);
+ database.activities.add({
+ page,
+ data: currentPage
+ });
+ }
+
+ for (const activity of currentPage.data.Page.activities) activities.push(activity);
+
+ while (currentPage['data']['Page']['pageInfo']['hasNextPage']) {
+ for (const activity of currentPage.data.Page.activities) activities.push(activity);
+
+ page += 1;
+ currentDatabasePage = await database.activities.get(page);
+
+ if (currentDatabasePage) currentPage = currentDatabasePage.data;
+ else {
+ currentPage = await activitiesPage(page, anilistAuthorisation, userIdentity, year);
+ database.activities.add({
+ page,
+ data: currentPage
+ });
+ }
+ }
+
+ for (const activity of currentPage.data.Page.activities) activities.push(activity);
+
+ let fullLocalActivityHistory: ActivityHistoryEntry[] = [];
+
+ for (const activity of activities) {
+ const date = new Date(activity.createdAt * 1000);
+ const dateString = date.toDateString();
+
+ const activityHistoryEntry = fullLocalActivityHistory.find(
+ (activityHistoryEntry) =>
+ new Date(activityHistoryEntry.date * 1000).toDateString() === dateString
+ );
+
+ if (activityHistoryEntry) activityHistoryEntry.amount += 1;
+ else fullLocalActivityHistory.push({ date: Math.floor(date.getTime() / 1000), amount: 1 });
+ }
+
+ fullLocalActivityHistory = fullLocalActivityHistory.filter((a) => !isNaN(a.date));
+
+ if (new Date().getMonth() > 6)
+ fullLocalActivityHistory.push(...(await activityHistory(userIdentity)));
+
+ fullLocalActivityHistory = fullLocalActivityHistory.filter(
+ (activityHistoryEntry, index, self) =>
+ self.findIndex(
+ (a) =>
+ new Date(a.date * 1000).toDateString() ===
+ new Date(activityHistoryEntry.date * 1000).toDateString()
+ ) === index
+ );
+
+ fullLocalActivityHistory = fullLocalActivityHistory.filter(
+ (activityHistoryEntry) => new Date(activityHistoryEntry.date * 1000).getFullYear() === year
+ );
+
+ return fullLocalActivityHistory;
+};
+
+export const activityLikes = async (id: number): Promise<Partial<User>[]> => {
+ const activityResponse = await (
+ await fetch('https://graphql.anilist.co', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json'
+ },
+ body: JSON.stringify({
+ query: `{
+ Activity(id: ${id}) {
+ __typename
+ ... on TextActivity { likes { name avatar { large } } }
+ ... on ListActivity { likes { name avatar { large } } }
+ ... on MessageActivity { likes { name avatar { large } } }
+ }
+ }`
+ })
+ })
+ ).json();
+
+ return activityResponse['data']['Activity']['likes'];
+};
+
+export const activityText = async (id: number, replies = false): Promise<string> => {
+ const activityResponse = await (
+ await fetch('https://graphql.anilist.co', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json'
+ },
+ body: JSON.stringify({
+ query: `{
+ Activity(id: ${id}) {
+ ... on TextActivity { text(asHtml: true) ${replies ? 'replies { text(asHtml: true) }' : ''} }
+ }
+ }`
+ })
+ })
+ ).json();
+ let text = activityResponse['data']['Activity']['text'];
+
+ if (replies)
+ for (const reply of activityResponse['data']['Activity']['replies']) text += reply.text;
+
+ return text;
+};
diff --git a/src/lib/Data/AniList/character.ts b/src/lib/Data/AniList/character.ts
new file mode 100644
index 00000000..082af47a
--- /dev/null
+++ b/src/lib/Data/AniList/character.ts
@@ -0,0 +1,84 @@
+export interface Character {
+ name: {
+ full: string;
+ };
+ id: number;
+ image: {
+ large: string;
+ };
+}
+
+export interface CharactersPage {
+ data: {
+ Page: {
+ characters: Character[];
+ pageInfo: {
+ hasNextPage: boolean;
+ currentPage: number;
+ };
+ };
+ };
+}
+
+const charactersPage = async (page: number): Promise<CharactersPage> =>
+ await (
+ await fetch('https://graphql.anilist.co', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json'
+ },
+ body: JSON.stringify({
+ query: `{ Page(page: ${page}, perPage: 50) {
+ characters(isBirthday: true) { name { full } id image { large } }
+ pageInfo { hasNextPage currentPage }
+ } }`
+ })
+ })
+ ).json();
+
+export const todaysCharacterBirthdays = async (): Promise<Character[]> => {
+ const characters = [];
+ let page = 1;
+ let currentPage = await charactersPage(page);
+
+ for (const character of currentPage['data']['Page']['characters'])
+ characters.push({
+ id: character['id'],
+ name: {
+ full: character['name']['full']
+ },
+ image: {
+ large: character['image']['large']
+ }
+ });
+
+ while (currentPage['data']['Page']['pageInfo']['hasNextPage']) {
+ for (const character of currentPage['data']['Page']['characters'])
+ characters.push({
+ id: character['id'],
+ name: {
+ full: character['name']['full']
+ },
+ image: {
+ large: character['image']['large']
+ }
+ });
+
+ page += 1;
+ currentPage = await charactersPage(page);
+ }
+
+ for (const character of currentPage['data']['Page']['characters'])
+ characters.push({
+ id: character['id'],
+ name: {
+ full: character['name']['full']
+ },
+ image: {
+ large: character['image']['large']
+ }
+ });
+
+ return characters;
+};
diff --git a/src/lib/Data/AniList/follow.ts b/src/lib/Data/AniList/follow.ts
new file mode 100644
index 00000000..3601d510
--- /dev/null
+++ b/src/lib/Data/AniList/follow.ts
@@ -0,0 +1,48 @@
+import type { AniListAuthorisation } from './identity';
+
+export interface User {
+ id: number;
+ name: string;
+ isFollowing: boolean;
+ isFollower: boolean;
+ avatar: {
+ large: string;
+ };
+}
+
+export const toggleFollow = async (
+ anilistAuthorisation: AniListAuthorisation,
+ username: string
+): Promise<User> => {
+ const {
+ data: { User: user }
+ } = 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: `{ User(name: "${username}") { id } }`
+ })
+ })
+ ).json();
+
+ return (
+ 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({
+ mutation: `{ ToggleFollow(userId: ${user.id}) { id name isFollowing isFollower } }`
+ })
+ })
+ ).json()
+ )['data']['ToggleFollow'];
+};
diff --git a/src/lib/Data/AniList/following.ts b/src/lib/Data/AniList/following.ts
new file mode 100644
index 00000000..1bba66b9
--- /dev/null
+++ b/src/lib/Data/AniList/following.ts
@@ -0,0 +1,56 @@
+import { user, type User } from './user';
+
+export interface FollowingPage {
+ data: {
+ Page: {
+ pageInfo: {
+ hasNextPage: boolean;
+ };
+ following: Partial<User>[];
+ };
+ };
+}
+
+const followingPage = async (page: number, id: number): Promise<FollowingPage> =>
+ await (
+ await fetch('https://graphql.anilist.co', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json'
+ },
+ body: JSON.stringify({
+ query: `{
+ Page(page: ${page}) {
+ pageInfo { hasNextPage }
+ following(userId: ${id}) { name id }
+ }
+ }`
+ })
+ })
+ ).json();
+
+export const followers = async (name: string): Promise<Partial<User>[]> => {
+ const activities = [];
+ let page = 1;
+ const id = (await user(name)).id;
+ let currentPage = await followingPage(page, id);
+
+ for (const activity of currentPage.data.Page.following) activities.push(activity);
+
+ while (currentPage['data']['Page']['pageInfo']['hasNextPage']) {
+ for (const activity of currentPage.data.Page.following) activities.push(activity);
+
+ page += 1;
+ currentPage = await followingPage(page, id);
+ }
+
+ for (const activity of currentPage.data.Page.following) activities.push(activity);
+
+ for (let i = activities.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [activities[i], activities[j]] = [activities[j], activities[i]];
+ }
+
+ return activities;
+};
diff --git a/src/lib/Data/AniList/forum.ts b/src/lib/Data/AniList/forum.ts
new file mode 100644
index 00000000..5a1bfc25
--- /dev/null
+++ b/src/lib/Data/AniList/forum.ts
@@ -0,0 +1,75 @@
+import type { User } from './follow';
+import { user } from './user';
+
+export interface Thread {
+ id: number;
+ title: string;
+ createdAt: number;
+ mediaCategories: {
+ coverImage: {
+ extraLarge: string;
+ };
+ }[];
+}
+
+export interface ThreadPage {
+ data: {
+ Page: {
+ threads: Thread[];
+ pageInfo: {
+ hasNextPage: boolean;
+ currentPage: number;
+ };
+ };
+ };
+}
+
+const threadPage = async (page: number, userId: number): Promise<ThreadPage> =>
+ await (
+ await fetch('https://graphql.anilist.co', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json'
+ },
+ body: JSON.stringify({
+ query: `{ Page(perPage: 50, page: ${page}) {
+ threads(userId: ${userId}) { id title createdAt mediaCategories { coverImage { extraLarge } } }
+ pageInfo { hasNextPage }
+} }`
+ })
+ })
+ ).json();
+
+export const threads = async (username: string): Promise<Thread[]> => {
+ const allThreads = [];
+ const userId = (await user(username)).id;
+ let page = 1;
+ let currentPage = await threadPage(page, userId);
+
+ for (const thread of currentPage.data.Page.threads) allThreads.push(thread);
+
+ while (currentPage.data.Page.pageInfo.hasNextPage) {
+ page += 1;
+ currentPage = await threadPage(page, userId);
+
+ for (const thread of currentPage.data.Page.threads) allThreads.push(thread);
+ }
+
+ return allThreads;
+};
+
+export const threadLikes = async (id: number): Promise<Partial<User>[]> => {
+ const activityResponse = await (
+ await fetch('https://graphql.anilist.co', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json'
+ },
+ body: JSON.stringify({ query: `{ Thread(id: ${id}) { likes { name avatar { large } } } }` })
+ })
+ ).json();
+
+ return activityResponse['data']['Thread']['likes'];
+};
diff --git a/src/lib/Data/AniList/identity.ts b/src/lib/Data/AniList/identity.ts
new file mode 100644
index 00000000..7214ffa4
--- /dev/null
+++ b/src/lib/Data/AniList/identity.ts
@@ -0,0 +1,34 @@
+export interface UserIdentity {
+ id: number;
+ name: string;
+ avatar: string;
+}
+
+export interface AniListAuthorisation {
+ tokenType: string;
+ accessToken: string;
+ expiresIn: number;
+ refreshToken: string;
+}
+
+export const userIdentity = async (
+ anilistAuthorisation: AniListAuthorisation
+): Promise<UserIdentity> => {
+ 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: `{ Viewer { id name avatar { large } } }` })
+ })
+ ).json();
+
+ return {
+ id: userIdResponse['data']['Viewer']['id'],
+ name: userIdResponse['data']['Viewer']['name'],
+ avatar: userIdResponse['data']['Viewer']['avatar']['large']
+ };
+};
diff --git a/src/lib/Data/AniList/media.ts b/src/lib/Data/AniList/media.ts
new file mode 100644
index 00000000..ea6d79d3
--- /dev/null
+++ b/src/lib/Data/AniList/media.ts
@@ -0,0 +1,441 @@
+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';
+
+export enum Type {
+ Anime,
+ Manga
+}
+
+export interface MediaTitle {
+ romaji: string;
+ english: string;
+ native: string;
+}
+
+export interface Media {
+ id: number;
+ idMal: 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;
+ nativeAiringAt?: number;
+ };
+ synonyms: string[];
+ mediaListEntry?: {
+ progress: number;
+ progressVolumes: number;
+ status: 'CURRENT' | 'PLANNING' | 'COMPLETED' | 'DROPPED' | 'PAUSED' | 'REPEATING';
+ score: number;
+ repeat: number;
+ startedAt: {
+ year: number;
+ };
+ completedAt: {
+ year: number;
+ };
+ createdAt: number;
+ updatedAt: number;
+ };
+ startDate: {
+ year: number;
+ };
+ endDate: {
+ year: number;
+ };
+ coverImage: {
+ extraLarge: string;
+ };
+ tags: {
+ name: string;
+ rank: number;
+ }[];
+ genres: string[];
+}
+
+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, includeCompleted: boolean) =>
+ `{
+ MediaListCollection(
+ userId: ${userId},
+ type: ${type === Type.Anime ? 'ANIME' : 'MANGA'}
+ ${includeCompleted ? '' : ', status_not_in: [ COMPLETED ]'}) {
+ lists {
+ name entries {
+ media {
+ id idMal status type episodes chapters format duration synonyms genres
+ tags { name rank }
+ title { romaji english native }
+ nextAiringEpisode { episode airingAt }
+ mediaListEntry {
+ progress progressVolumes status repeat createdAt updatedAt
+ score(format: POINT_100) startedAt { year } completedAt { year }
+ }
+ startDate { year }
+ endDate { year }
+ coverImage { extraLarge }
+ }
+ }
+ }
+ }
+}`;
+
+interface CollectionOptions {
+ includeCompleted?: boolean;
+ forcePrune?: boolean;
+ all?: boolean;
+ addNotification?: (preferences: Options) => void;
+ notificationType?: string;
+}
+
+interface NonNullCollectionOptions {
+ includeCompleted: boolean;
+ forcePrune: boolean;
+ all?: boolean;
+ addNotification?: (preferences: Options) => void;
+ notificationType?: string;
+}
+
+const assignDefaultOptions = (options: CollectionOptions) => {
+ const nonNullOptions: NonNullCollectionOptions = {
+ includeCompleted: false,
+ forcePrune: false,
+ all: false,
+ addNotification: undefined,
+ notificationType: undefined
+ };
+
+ 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;
+
+ return nonNullOptions;
+};
+
+export const mediaListCollection = async (
+ anilistAuthorisation: AniListAuthorisation,
+ userIdentity: UserIdentity,
+ type: Type,
+ mediaCache: string | undefined,
+ currentLastPruneAt: string | number,
+ inputOptions: CollectionOptions = {}
+): Promise<Media[]> => {
+ 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.includeCompleted)
+ })
+ })
+ ).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<Media[]> =>
+ 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, false)
+ })
+ })
+ ).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<number | null> => {
+ 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'];
diff --git a/src/lib/Data/AniList/notifications.ts b/src/lib/Data/AniList/notifications.ts
new file mode 100644
index 00000000..bebf1b42
--- /dev/null
+++ b/src/lib/Data/AniList/notifications.ts
@@ -0,0 +1,78 @@
+export interface Notification {
+ user: {
+ name: string;
+ avatar: {
+ large: string;
+ };
+ };
+ thread: {
+ title: string;
+ id: number;
+ };
+ activity: {
+ id: number;
+ };
+ context: string;
+ id: number;
+ createdAt: number;
+ type:
+ | 'FOLLOWING'
+ | 'ACTIVITY_MESSAGE'
+ | 'ACTIVITY_MENTION'
+ | 'ACTIVITY_REPLY'
+ | 'ACTIVITY_REPLY_SUBSCRIBED'
+ | 'ACTIVITY_LIKE'
+ | 'ACTIVITY_REPLY_LIKE'
+ | 'THREAD_COMMENT_MENTION'
+ | 'THREAD_COMMENT_REPLY'
+ | 'THREAD_SUBSCRIBED'
+ | 'THREAD_COMMENT_LIKE'
+ | 'THREAD_LIKE';
+}
+
+export const notifications = async (accessToken: string): Promise<Notification[] | null> => {
+ const activityNotification = (type: string, extend = '') => `... on ${type} {
+ id user { name avatar { large } } context createdAt type ${extend}
+ }`;
+ const richActivityNotification = (type: string) =>
+ `${activityNotification(
+ type,
+ `activity {
+ ... on TextActivity { id }
+ ... on ListActivity { id }
+ ... on MessageActivity { id }
+ }`
+ )}`;
+ const threadNotification = (type: string) =>
+ `${activityNotification(type, `thread { title id }`)}`;
+ const data = await (
+ await fetch('https://graphql.anilist.co', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ Accept: 'application/json'
+ },
+ body: JSON.stringify({
+ query: `{ Page(page: 1, perPage: 50) { notifications {
+ ${activityNotification('FollowingNotification')}
+ ${activityNotification('ActivityMessageNotification')}
+ ${richActivityNotification('ActivityMentionNotification')}
+ ${richActivityNotification('ActivityReplyNotification')}
+ ${richActivityNotification('ActivityReplySubscribedNotification')}
+ ${richActivityNotification('ActivityLikeNotification')}
+ ${richActivityNotification('ActivityReplyLikeNotification')}
+ ${threadNotification('ThreadCommentMentionNotification')}
+ ${threadNotification('ThreadCommentReplyNotification')}
+ ${threadNotification('ThreadCommentSubscribedNotification')}
+ ${threadNotification('ThreadCommentLikeNotification')}
+ ${threadNotification('ThreadLikeNotification')}
+ } } }`
+ })
+ })
+ ).json();
+
+ if (data['errors']) return null;
+
+ return data['data']['Page']['notifications'];
+};
diff --git a/src/lib/Data/AniList/prequels.ts b/src/lib/Data/AniList/prequels.ts
new file mode 100644
index 00000000..3009e9ba
--- /dev/null
+++ b/src/lib/Data/AniList/prequels.ts
@@ -0,0 +1,175 @@
+import type { AniListAuthorisation } from './identity';
+import type { MediaTitle } from './media';
+
+export interface MediaPrequel {
+ id: number;
+ title: MediaTitle;
+ episodes: number;
+ seen: number;
+ nextAiringEpisode?: {
+ episode: number;
+ airingAt?: number;
+ };
+ startDate: {
+ year: number;
+ month: number;
+ day: number;
+ };
+ coverImage: {
+ extraLarge: string;
+ };
+}
+
+interface PrequelRelations {
+ edges: {
+ relationType: string;
+ node: {
+ title: MediaTitle;
+ episodes: number;
+ mediaListEntry: {
+ status: string;
+ progress: number;
+ };
+ coverImage: {
+ extraLarge: string;
+ };
+ };
+ }[];
+}
+
+interface PrequelsPage {
+ data: {
+ Page: {
+ media: {
+ title: MediaTitle;
+ id: number;
+ relations: PrequelRelations;
+ nextAiringEpisode?: {
+ episode: number;
+ airingAt?: number;
+ };
+ startDate: {
+ year: number;
+ month: number;
+ day: number;
+ };
+ coverImage: {
+ extraLarge: string;
+ };
+ }[];
+ pageInfo: {
+ hasNextPage: boolean;
+ };
+ };
+ };
+}
+
+const prequelsPage = async (
+ page: number,
+ anilistAuthorisation: AniListAuthorisation,
+ year: number,
+ season: 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'
+): Promise<PrequelsPage> =>
+ 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: `{
+ Page(page: ${page}) {
+ pageInfo {
+ hasNextPage
+ }
+ media(season: ${season}, seasonYear: ${year}) {
+ title {
+ english
+ romaji
+ }
+ coverImage { extraLarge }
+ id
+ nextAiringEpisode { episode airingAt }
+ startDate { year month day }
+ relations {
+ edges {
+ relationType
+ node {
+ title {
+ english
+ romaji
+ }
+ episodes
+ mediaListEntry {
+ status
+ progress
+ }
+ coverImage { extraLarge }
+ }
+ }
+ }
+ }
+ }
+}`
+ })
+ })
+ ).json();
+
+export const prequels = async (
+ anilistAuthorisation: AniListAuthorisation,
+ year: number,
+ season: 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'
+): Promise<MediaPrequel[]> => {
+ const candidates = [];
+ let page = 1;
+ let currentPage = await prequelsPage(page, anilistAuthorisation, year, season);
+
+ for (const candidate of currentPage.data.Page.media) candidates.push(candidate);
+
+ while (currentPage['data']['Page']['pageInfo']['hasNextPage']) {
+ for (const candidate of currentPage.data.Page.media) candidates.push(candidate);
+
+ page += 1;
+ currentPage = await prequelsPage(page, anilistAuthorisation, year, season);
+ }
+
+ for (const candidate of currentPage.data.Page.media) candidates.push(candidate);
+
+ const media: MediaPrequel[] = [];
+
+ for (const candidate of candidates) {
+ let episodes = 0;
+ let seen = 0;
+
+ for (const relation of candidate.relations.edges) {
+ if (relation.relationType === 'PREQUEL' || relation.relationType === 'PARENT') {
+ if (
+ relation.node.mediaListEntry === null ||
+ relation.node.mediaListEntry.status !== 'COMPLETED'
+ ) {
+ episodes += relation.node.episodes;
+
+ if (relation.node.mediaListEntry !== null)
+ seen += relation.node.mediaListEntry.progress || 0;
+ }
+ }
+ }
+
+ if (media.some((m) => m.id === candidate.id)) continue;
+
+ if (episodes !== 0)
+ media.push({
+ id: candidate.id,
+ title: candidate.title,
+ episodes,
+ seen,
+ nextAiringEpisode: candidate.nextAiringEpisode,
+ startDate: candidate.startDate,
+ coverImage: candidate.coverImage
+ });
+ }
+
+ return media;
+};
diff --git a/src/lib/Data/AniList/schedule.ts b/src/lib/Data/AniList/schedule.ts
new file mode 100644
index 00000000..0db130ec
--- /dev/null
+++ b/src/lib/Data/AniList/schedule.ts
@@ -0,0 +1,105 @@
+import type { Media, MediaTitle } from './media';
+
+interface SchedulePage {
+ data: {
+ Page: {
+ media: {
+ title: MediaTitle;
+ synonyms: string[];
+ id: number;
+ idMal: number;
+ episodes: number;
+ nextAiringEpisode?: {
+ episode: number;
+ airingAt?: number;
+ };
+ coverImage: {
+ extraLarge: string;
+ };
+ }[];
+ pageInfo: {
+ hasNextPage: boolean;
+ };
+ };
+ };
+}
+
+const schedulePage = async (
+ page: number,
+ year: number,
+ season: 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'
+): Promise<SchedulePage> =>
+ await (
+ await fetch('https://graphql.anilist.co', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json'
+ },
+ body: JSON.stringify({
+ query: `{
+ Page(page: ${page}) {
+ pageInfo {
+ hasNextPage
+ }
+ media(season: ${season}, seasonYear: ${year}) {
+ id idMal episodes synonyms
+ title { english romaji native }
+ nextAiringEpisode { episode airingAt }
+ coverImage { extraLarge }
+ }
+ }
+}`
+ })
+ })
+ ).json();
+
+type Season = 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL';
+
+export const scheduleMediaListCollection = async (
+ year: number,
+ season: Season,
+ includeLastSeason = false
+) => {
+ const scheduledMedia = [];
+ let page = 1;
+ let currentPage = await schedulePage(page, year, season);
+
+ for (const candidate of currentPage.data.Page.media) scheduledMedia.push(candidate);
+
+ while (currentPage['data']['Page']['pageInfo']['hasNextPage']) {
+ for (const candidate of currentPage.data.Page.media) scheduledMedia.push(candidate);
+
+ page += 1;
+ currentPage = await schedulePage(page, year, season);
+ }
+
+ for (const candidate of currentPage.data.Page.media) scheduledMedia.push(candidate);
+
+ if (includeLastSeason) {
+ const lastSeason = {
+ WINTER: 'FALL',
+ SPRING: 'WINTER',
+ SUMMER: 'SPRING',
+ FALL: 'SUMMER'
+ }[season];
+
+ const lastSeasonYear = season === 'WINTER' ? year - 1 : year;
+
+ let page = 1;
+ let currentPage = await schedulePage(page, lastSeasonYear, lastSeason as Season);
+
+ for (const candidate of currentPage.data.Page.media) scheduledMedia.push(candidate);
+
+ while (currentPage['data']['Page']['pageInfo']['hasNextPage']) {
+ for (const candidate of currentPage.data.Page.media) scheduledMedia.push(candidate);
+
+ page += 1;
+ currentPage = await schedulePage(page, lastSeasonYear, lastSeason as Season);
+ }
+
+ for (const candidate of currentPage.data.Page.media) scheduledMedia.push(candidate);
+ }
+
+ return scheduledMedia as Partial<Media[]>;
+};
diff --git a/src/lib/Data/AniList/user.ts b/src/lib/Data/AniList/user.ts
new file mode 100644
index 00000000..d2f06354
--- /dev/null
+++ b/src/lib/Data/AniList/user.ts
@@ -0,0 +1,92 @@
+export interface User {
+ name: string;
+ id: number;
+ statistics: {
+ anime: {
+ count: number;
+ meanScore: number;
+ minutesWatched: number;
+ episodesWatched: number;
+ };
+ manga: {
+ count: number;
+ meanScore: number;
+ chaptersRead: number;
+ volumesRead: number;
+ };
+ };
+ avatar: {
+ large: string;
+ };
+ bannerImage: string;
+}
+
+export interface FullUser {
+ id: number;
+ name: string;
+ avatar: {
+ large: string;
+ medium: string;
+ };
+ bans: JSON;
+ bannerImage: string;
+ siteUrl: string;
+ donatorTier: number;
+ donatorBadge: string;
+ moderatorRoles: string[];
+ createAt: number;
+ updatedAt: number;
+ previousNames: {
+ name: string;
+ createdAt: number;
+ updatedAt: string;
+ }[];
+ about: string;
+}
+
+export const user = async (username: string, id = false): Promise<User> =>
+ (
+ await (
+ await fetch('https://graphql.anilist.co', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json'
+ },
+ body: JSON.stringify({
+ query: `{ User(${id ? `id: ${username}` : `name: "${username}"`}) {
+ name id bannerImage avatar { large } statistics {
+ anime {
+ count meanScore minutesWatched episodesWatched
+ }
+ manga {
+ count meanScore chaptersRead volumesRead
+ }
+ }
+ } }`
+ })
+ })
+ ).json()
+ )['data']['User'];
+
+export const dumpUser = async (username: string): Promise<FullUser> =>
+ (
+ await (
+ await fetch('https://graphql.anilist.co', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json'
+ },
+ body: JSON.stringify({
+ query: `{ User(name: "${username}") {
+ id name about
+ avatar { large medium }
+ bannerImage bans siteUrl donatorTier donatorBadge moderatorRoles
+ createdAt updatedAt
+ previousNames { name createdAt updatedAt }
+ } }`
+ })
+ })
+ ).json()
+ )['data']['User'];
diff --git a/src/lib/Data/AniList/wrapped.ts b/src/lib/Data/AniList/wrapped.ts
new file mode 100644
index 00000000..72c81cd8
--- /dev/null
+++ b/src/lib/Data/AniList/wrapped.ts
@@ -0,0 +1,301 @@
+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()
+) => {
+ 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']) {
+ 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
+): Promise<Wrapped> => {
+ 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)
+ );
+
+ 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;
+
+ try {
+ topGenreMedia = media.find((m) => m.genres.includes(genres[0].genre)) || media[0];
+ } catch {
+ topGenreMedia = media[0];
+ }
+
+ let topTagMedia;
+
+ 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
+ };
+};