diff options
| author | Fuwn <[email protected]> | 2024-02-08 00:01:24 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2024-02-08 00:01:24 -0800 |
| commit | f78f5f4857f24ee5338fb1643c666a6b18d75769 (patch) | |
| tree | 57b1b09f20b6b261a3b1ae15bfa441965f71ecd9 /src/lib/Data | |
| parent | refactor(data): move static data to module (diff) | |
| download | due.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.ts | 306 | ||||
| -rw-r--r-- | src/lib/Data/AniList/character.ts | 84 | ||||
| -rw-r--r-- | src/lib/Data/AniList/follow.ts | 48 | ||||
| -rw-r--r-- | src/lib/Data/AniList/following.ts | 56 | ||||
| -rw-r--r-- | src/lib/Data/AniList/forum.ts | 75 | ||||
| -rw-r--r-- | src/lib/Data/AniList/identity.ts | 34 | ||||
| -rw-r--r-- | src/lib/Data/AniList/media.ts | 441 | ||||
| -rw-r--r-- | src/lib/Data/AniList/notifications.ts | 78 | ||||
| -rw-r--r-- | src/lib/Data/AniList/prequels.ts | 175 | ||||
| -rw-r--r-- | src/lib/Data/AniList/schedule.ts | 105 | ||||
| -rw-r--r-- | src/lib/Data/AniList/user.ts | 92 | ||||
| -rw-r--r-- | src/lib/Data/AniList/wrapped.ts | 301 |
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 + }; +}; |