From cbfd805fecde04da8ffa57cd9fe8d348f91cc058 Mon Sep 17 00:00:00 2001 From: Aura Date: Sun, 27 Oct 2024 12:23:07 +0100 Subject: [PATCH] refactor(anilist): remove Redis, improve autocomplete (#239) --- docker-compose.yml | 12 -- package.json | 1 - src/.env | 6 - src/commands/search/anime.ts | 2 +- src/commands/search/manga.ts | 2 +- src/lib/apis/anilist/anilist-types.ts | 52 ++++++- src/lib/apis/anilist/anilist-utilities.ts | 145 ++++++++++-------- src/lib/i18n/LanguageKeys/Commands/AniList.ts | 13 ++ src/lib/setup/all.ts | 3 - src/lib/setup/redis.ts | 18 --- src/lib/structures/AnimeCommand.ts | 68 +++++--- src/lib/types/augments.d.ts | 5 - src/lib/utilities/temporary-collection.ts | 72 +++++++++ src/locales/en-US/commands/anilist.json | 11 ++ yarn.lock | 76 --------- 15 files changed, 268 insertions(+), 218 deletions(-) delete mode 100644 docker-compose.yml delete mode 100644 src/lib/setup/redis.ts create mode 100644 src/lib/utilities/temporary-collection.ts diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index f7db032d..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,12 +0,0 @@ -services: - redis: - command: 'redis-server --requirepass redis' - container_name: redis - image: 'redis:alpine' - ports: - - '8287:6379' - restart: always - logging: - options: - max-size: '20m' - max-file: '3' diff --git a/package.json b/package.json index 93a6c86e..617b742b 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "@skyra/start-banner": "^2.0.1", "discord-api-types": "^0.37.100", "he": "^1.2.0", - "ioredis": "^5.4.1", "tslib": "^2.8.0" }, "devDependencies": { diff --git a/src/.env b/src/.env index 617362b0..18cadaee 100644 --- a/src/.env +++ b/src/.env @@ -18,9 +18,3 @@ WEEB_SH_TOKEN= # The address and port on which to start the HTTP server HTTP_ADDRESS=0.0.0.0 HTTP_PORT=3000 - -# The Redis server configuration -REDIS_PORT=8287 -REDIS_PASSWORD=redis -REDIS_HOST=localhost -REDIS_DB=0 diff --git a/src/commands/search/anime.ts b/src/commands/search/anime.ts index 1e9786a4..3cca6d9b 100644 --- a/src/commands/search/anime.ts +++ b/src/commands/search/anime.ts @@ -8,7 +8,7 @@ const Root = LanguageKeys.Commands.AniList.Anime; @RegisterCommand((builder) => applyLocalizedBuilder(builder, Root.RootName, Root.RootDescription) // - .addStringOption((option) => applyLocalizedBuilder(option, Root.OptionsAnime).setRequired(true).setAutocomplete(true)) + .addIntegerOption((option) => applyLocalizedBuilder(option, Root.OptionsAnime).setRequired(true).setAutocomplete(true)) ) export class UserCommand extends AnimeCommand<'anime'> { public override async chatInputRun(interaction: Command.ChatInputInteraction, { anime }: AnimeCommand.Arguments<'anime'>) { diff --git a/src/commands/search/manga.ts b/src/commands/search/manga.ts index 800b31e9..671f3e32 100644 --- a/src/commands/search/manga.ts +++ b/src/commands/search/manga.ts @@ -8,7 +8,7 @@ const Root = LanguageKeys.Commands.AniList.Manga; @RegisterCommand((builder) => applyLocalizedBuilder(builder, Root.RootName, Root.RootDescription) // - .addStringOption((option) => applyLocalizedBuilder(option, Root.OptionsManga).setRequired(true).setAutocomplete(true)) + .addIntegerOption((option) => applyLocalizedBuilder(option, Root.OptionsManga).setRequired(true).setAutocomplete(true)) ) export class UserCommand extends AnimeCommand<'manga'> { public override async chatInputRun(interaction: Command.ChatInputInteraction, { manga }: AnimeCommand.Arguments<'manga'>) { diff --git a/src/lib/apis/anilist/anilist-types.ts b/src/lib/apis/anilist/anilist-types.ts index 1aab7224..685462f5 100644 --- a/src/lib/apis/anilist/anilist-types.ts +++ b/src/lib/apis/anilist/anilist-types.ts @@ -1,25 +1,40 @@ /** Anime or Manga */ -export interface AnilistEntry { +interface AnilistEntryBase { /** The id of the media */ readonly id: number; /** The official titles of the media in various languages */ readonly title: MediaTitle; /** Short description of the media's story and characters */ readonly description?: string | null; + /** The format the media was released in */ + readonly format?: MediaFormat | null; + /** The first official release date of the media */ + readonly startDate?: { + /** Numeric Year (2017) */ + readonly year?: number | null; + }; + /** The season year the media was initially released in */ + readonly seasonYear?: number | null; + /** Where the media was created. (ISO 3166-1 alpha-2) */ + readonly countryOfOrigin?: 'JP' | 'KR' | 'CN' | 'TW' | null; + /** External links to another site related to the media */ + readonly externalLinks?: readonly (MediaExternalLink | null)[] | null; + /** The url for the media page on the AniList website */ + readonly siteUrl?: string | null; +} + +export interface AnilistEntryAnime extends AnilistEntryBase { /** The amount of episodes the anime has when complete */ readonly episodes?: number | null; /** The general length of each anime episode in minutes */ readonly duration?: number | null; +} + +export interface AnilistEntryManga extends AnilistEntryBase { /** The amount of chapters the manga has when complete */ readonly chapters?: number | null; /** The amount of volumes the manga has when complete */ readonly volumes?: number | null; - /** Where the media was created. (ISO 3166-1 alpha-2) */ - readonly countryOfOrigin?: 'JP' | 'KR' | 'CN' | 'TW' | null; - /** External links to another site related to the media */ - readonly externalLinks?: readonly (MediaExternalLink | null)[] | null; - /** The url for the media page on the AniList website */ - readonly siteUrl?: string | null; } /** An external link to another site related to the media */ @@ -39,3 +54,26 @@ export interface MediaTitle { /** Official title in it's native language */ readonly native?: string | null; } + +export enum MediaFormat { + /** Professionally published manga with more than one chapter */ + Manga = 'MANGA', + /** Anime movies with a theatrical release */ + Movie = 'MOVIE', + /** Short anime released as a music video */ + Music = 'MUSIC', + /** Written books released as a series of light novels */ + Novel = 'NOVEL', + /** Anime that have been originally released online or are only available through streaming services. */ + OriginalNetAnimation = 'ONA', + /** Manga with just one chapter */ + OneShot = 'ONE_SHOT', + /** Anime that have been released directly on DVD/Blu-ray without originally going through a theatrical release or television broadcast */ + OriginalVideoAnimation = 'OVA', + /** Special episodes that have been included in DVD/Blu-ray releases, picture dramas, pilots, etc */ + Special = 'SPECIAL', + /** Anime broadcast on television */ + TV = 'TV', + /** Anime which are under 15 minutes in length and broadcast on television */ + TVShort = 'TV_SHORT' +} diff --git a/src/lib/apis/anilist/anilist-utilities.ts b/src/lib/apis/anilist/anilist-utilities.ts index fe168f82..faf79b53 100644 --- a/src/lib/apis/anilist/anilist-utilities.ts +++ b/src/lib/apis/anilist/anilist-utilities.ts @@ -1,11 +1,27 @@ -import type { AnilistEntry } from '#lib/apis/anilist/anilist-types'; +import type { AnilistEntryAnime, AnilistEntryManga } from '#lib/apis/anilist/anilist-types'; +import { TemporaryCollection } from '#lib/utilities/temporary-collection'; import { Result, ok } from '@sapphire/result'; import { Time } from '@sapphire/time-utilities'; import { cutText, isNullishOrEmpty } from '@sapphire/utilities'; -import { container } from '@skyra/http-framework'; -import { Json, safeTimedFetch, type FetchError } from '@skyra/safe-fetch'; +import { Json, safeTimedFetch, type FetchError, type FetchResult } from '@skyra/safe-fetch'; import he from 'he'; +const cache = { + anime: { + search: new TemporaryCollection({ lifetime: Time.Hour, sweepInterval: Time.Minute }), + result: new TemporaryCollection({ lifetime: Time.Hour, sweepInterval: Time.Minute }) + }, + manga: { + search: new TemporaryCollection({ lifetime: Time.Hour, sweepInterval: Time.Minute }), + result: new TemporaryCollection({ lifetime: Time.Hour, sweepInterval: Time.Minute }) + } +}; + +export type AnilistEntryTypeByKind = { + anime: AnilistEntryAnime; + manga: AnilistEntryManga; +}[Kind]; + /** * Regex to remove excessive new lines from the Anime or Manga description */ @@ -34,13 +50,6 @@ const htmlEntityReplacements = Object.freeze({ u: '__' } as const); -export enum AnilistKeys { - AnimeSearch = 'aas', - AnimeResult = 'aar', - MangaSearch = 'ams', - MangaResult = 'amr' -} - export function parseAniListDescription(description: string) { return cutText( he @@ -54,70 +63,70 @@ export function parseAniListDescription(description: string) { ); } -export async function anilistAnimeGet(query: string): Promise> { - const key = `${AnilistKeys.AnimeResult}:${query.toLowerCase()}`; - const cached = await container.redis.get(key); - if (cached) return ok(JSON.parse(cached)); - - const result = await anilistAnimeSearch(query); - return result.map((entries) => (isNullishOrEmpty(entries) ? null : entries[0])); +export async function anilistAnimeGet(id: number): Promise> { + const cached = cache.anime.result.get(id); + return cached ? ok(cached) : sharedSearchId('anime', id); } -export async function anilistMangaGet(query: string): Promise> { - const key = `${AnilistKeys.MangaResult}:${query.toLowerCase()}`; - const cached = await container.redis.get(key); - if (cached) return ok(JSON.parse(cached)); - - const result = await anilistMangaSearch(query); - return result.map((entries) => (isNullishOrEmpty(entries) ? null : entries[0])); +export async function anilistMangaGet(id: number): Promise> { + const cached = cache.manga.result.get(id); + return cached ? ok(cached) : sharedSearchId('manga', id); } -export async function anilistAnimeSearch(query: string): Promise> { - const key = `${AnilistKeys.AnimeSearch}:${query.toLowerCase()}`; - const cached = await loadSearchResultsFromRedis(key, AnilistKeys.AnimeResult); - if (cached) return ok(cached); +export async function anilistAnimeSearch(query: string): Promise> { + const cached = cache.anime.search.get(query.toLowerCase()); + if (cached) return ok(cached.map((id) => cache.anime.result.get(id)!)); const body = isNullishOrEmpty(query) ? GetTrendingAnimeBody : JSON.stringify({ variables: { query }, query: GetAnimeQuery }); - return sharedSearch(key, AnilistKeys.AnimeResult, body); + return sharedSearch('anime', query, body); } -export async function anilistMangaSearch(query: string): Promise> { - const key = `${AnilistKeys.MangaSearch}:${query.toLowerCase()}`; - const cached = await loadSearchResultsFromRedis(key, AnilistKeys.MangaResult); - if (cached) return ok(cached); +export async function anilistMangaSearch(query: string): Promise> { + const cached = cache.manga.search.get(query.toLowerCase()); + if (cached) return ok(cached.map((id) => cache.manga.result.get(id)!)); const body = isNullishOrEmpty(query) ? GetTrendingMangaBody : JSON.stringify({ variables: { query }, query: GetMangaQuery }); - return sharedSearch(key, AnilistKeys.MangaResult, body); + return sharedSearch('manga', query, body); } -async function sharedSearch(key: string, prefix: AnilistKeys, body: string) { - const result = await Json<{ data: { Page: { media: readonly AnilistEntry[] } } }>( - safeTimedFetch('https://graphql.anilist.co/', 2000, { method: 'POST', body, headers: Headers }) +async function sharedSearchId(kind: Kind, id: number): Promise | null>> { + const query = kind === 'anime' ? GetAnimeQuery : GetMangaQuery; + const result = await Json<{ data: { Page: { media: AnilistEntryTypeByKind[] } } }>( + safeTimedFetch('https://graphql.anilist.co/', 2000, { + method: 'POST', + body: JSON.stringify({ variables: { id }, query }), + headers: Headers + }) ); - return result.map((data) => data.data.Page.media).inspectAsync((entries) => saveSearchResultsToRedis(key, prefix, entries)); + return result + .map((data) => data.data.Page.media.at(0) ?? null) + .inspect((entry) => { + if (entry) cache[kind].result.add(id, entry); + }); } -async function loadSearchResultsFromRedis(key: string, prefix: AnilistKeys) { - const list = await container.redis.get(key); - if (isNullishOrEmpty(list)) return null; - - const ids = JSON.parse(list) as readonly string[]; - if (isNullishOrEmpty(ids)) return null; - - const entries = await container.redis.mget(...ids.map((id) => `${prefix}:${id.toLowerCase()}`)); - return entries.map((entry) => JSON.parse(entry!) as AnilistEntry); -} +async function sharedSearch( + kind: Kind, + query: string, + body: string +): Promise[]>> { + const result = await Json<{ data: { Page: { media: AnilistEntryTypeByKind[] } } }>( + safeTimedFetch('https://graphql.anilist.co/', 2000, { method: 'POST', body, headers: Headers }) + ); -async function saveSearchResultsToRedis(key: string, prefix: AnilistKeys, entries: readonly AnilistEntry[]) { - const names = entries.map((entry) => cutText(entry.title.english || entry.title.romaji || entry.title.native || entry.id.toString(), 100)); - const pipeline = container.redis.pipeline(); - pipeline.set(key, JSON.stringify(names), 'EX', Time.Hour); - for (const [index, entry] of entries.entries()) { - pipeline.set(`${prefix}:${names[index].toLowerCase()}`, JSON.stringify(entry), 'EX', Time.Hour); - } + return result + .map((data) => data.data.Page.media) + .inspect((entries) => { + cache[kind].search.add( + query, + entries.map((entry) => entry.id) + ); - await pipeline.exec(); + for (const entry of entries) { + cache[kind].result.add(entry.id, entry); + } + }); } export const Headers = { @@ -134,7 +143,11 @@ const MediaFragment = ` native } description - isAdult + format + seasonYear + startDate { + year + } countryOfOrigin duration siteUrl @@ -154,8 +167,8 @@ const GetTrendingAnimeBody = JSON.stringify({ Page(page: 1, perPage: 25) { media(type: ANIME, sort: $sort) { ...MediaFragment - chapters - volumes + episodes + duration } } }` @@ -166,7 +179,7 @@ const GetTrendingMangaBody = JSON.stringify({ query: ` ${MediaFragment} - query getTrendingAnime($sort: [MediaSort] = [TRENDING_DESC]) { + query getTrendingManga($sort: [MediaSort] = [TRENDING_DESC]) { Page(page: 1, perPage: 25) { media(type: MANGA, sort: $sort) { ...MediaFragment @@ -180,11 +193,12 @@ const GetTrendingMangaBody = JSON.stringify({ export const GetAnimeQuery = ` ${MediaFragment} - query getAnime($query: String!) { + query getAnime($id: Int, $query: String) { Page(page: 1, perPage: 25) { - media(search: $query, type: ANIME, isAdult: false) { + media(id: $id, search: $query, type: ANIME, isAdult: false) { ...MediaFragment episodes + duration } } } @@ -193,11 +207,12 @@ export const GetAnimeQuery = ` export const GetMangaQuery = ` ${MediaFragment} - query getManga($query: String!) { + query getManga($id: Int, $query: String) { Page(page: 1, perPage: 25) { - media(search: $query, type: MANGA, isAdult: false) { + media(id: $id, search: $query, type: MANGA, isAdult: false) { ...MediaFragment - episodes + chapters + volumes } } } diff --git a/src/lib/i18n/LanguageKeys/Commands/AniList.ts b/src/lib/i18n/LanguageKeys/Commands/AniList.ts index 9b169fca..175c841a 100644 --- a/src/lib/i18n/LanguageKeys/Commands/AniList.ts +++ b/src/lib/i18n/LanguageKeys/Commands/AniList.ts @@ -15,6 +15,19 @@ export const EmbedTitles = T<{ romajiName: string; }>('commands/anilist:embedTitles'); +export const MediaFormatManga = T('commands/anilist:mediaFormatManga'); +export const MediaFormatMovie = T('commands/anilist:mediaFormatMovie'); +export const MediaFormatMusic = T('commands/anilist:mediaFormatMusic'); +export const MediaFormatNovel = T('commands/anilist:mediaFormatNovel'); +export const MediaFormatOriginalNetAnimation = T('commands/anilist:mediaFormatOriginalNetAnimation'); +export const MediaFormatOneShot = T('commands/anilist:mediaFormatOneShot'); +export const MediaFormatOriginalVideoAnimation = T('commands/anilist:mediaFormatOriginalVideoAnimation'); +export const MediaFormatSpecial = T('commands/anilist:mediaFormatSpecial'); +export const MediaFormatTV = T('commands/anilist:mediaFormatTV'); +export const MediaFormatTVShort = T('commands/anilist:mediaFormatTVShort'); + +export const Unknown = T('commands/anilist:unknown'); + export const CountryChina = T('commands/anilist:countryChina'); export const CountryJapan = T('commands/anilist:countryJapan'); export const CountryKorea = T('commands/anilist:countryKorea'); diff --git a/src/lib/setup/all.ts b/src/lib/setup/all.ts index ef11e3e4..458b11cb 100644 --- a/src/lib/setup/all.ts +++ b/src/lib/setup/all.ts @@ -1,5 +1,4 @@ import '#lib/setup/logger'; -import { run as redisRun } from '#lib/setup/redis'; import { setup as envRun } from '@skyra/env-utilities'; import { initializeSentry, setInvite, setRepository } from '@skyra/shared-http-pieces'; @@ -11,6 +10,4 @@ export function setup() { setRepository('nekokai'); setInvite('939613684592934992', '16384'); initializeSentry(); - - redisRun(); } diff --git a/src/lib/setup/redis.ts b/src/lib/setup/redis.ts deleted file mode 100644 index 98c370ee..00000000 --- a/src/lib/setup/redis.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { envParseInteger, envParseString } from '@skyra/env-utilities'; -import { container } from '@skyra/http-framework'; -import { Redis } from 'ioredis'; - -export function run() { - container.redis = new Redis({ - port: envParseInteger('REDIS_PORT'), - password: envParseString('REDIS_PASSWORD'), - host: envParseString('REDIS_HOST'), - db: envParseInteger('REDIS_DB') - }); -} - -declare module '@sapphire/pieces' { - interface Container { - redis: Redis; - } -} diff --git a/src/lib/structures/AnimeCommand.ts b/src/lib/structures/AnimeCommand.ts index ee814e24..7bd4fde8 100644 --- a/src/lib/structures/AnimeCommand.ts +++ b/src/lib/structures/AnimeCommand.ts @@ -1,37 +1,55 @@ -import type { AnilistEntry, MediaTitle } from '#lib/apis/anilist/anilist-types'; -import { parseAniListDescription } from '#lib/apis/anilist/anilist-utilities'; +import { MediaFormat, type MediaTitle } from '#lib/apis/anilist/anilist-types'; +import { parseAniListDescription, type AnilistEntryTypeByKind } from '#lib/apis/anilist/anilist-utilities'; import { BrandingColors } from '#lib/common/constants'; import { LanguageKeys } from '#lib/i18n/LanguageKeys'; import { durationFormatter } from '#lib/utilities/duration-formatter'; import { minutes } from '#lib/utilities/time-utilities'; import { EmbedBuilder, bold, hideLinkEmbed, hyperlink } from '@discordjs/builders'; import type { Result } from '@sapphire/result'; -import { cutText, filterNullish, isNullishOrEmpty } from '@sapphire/utilities'; -import { Command, type AutocompleteInteractionArguments, type InteractionArguments } from '@skyra/http-framework'; -import { getSupportedLanguageT, getSupportedUserLanguageName, resolveUserKey, type TFunction } from '@skyra/http-framework-i18n'; +import { cutText, filterNullish, isNullishOrEmpty, isNullishOrZero } from '@sapphire/utilities'; +import { Command, type AutocompleteInteractionArguments } from '@skyra/http-framework'; +import { getSupportedLanguageT, getSupportedUserLanguageT, resolveUserKey, type TFunction, type TypedT } from '@skyra/http-framework-i18n'; import type { FetchError } from '@skyra/safe-fetch'; import { MessageFlags, type APIEmbed, type LocaleString } from 'discord-api-types/v10'; const Root = LanguageKeys.Commands.AniList; +const FormatKeys = { + [MediaFormat.Manga]: Root.MediaFormatManga, + [MediaFormat.Movie]: Root.MediaFormatMovie, + [MediaFormat.Music]: Root.MediaFormatMusic, + [MediaFormat.Novel]: Root.MediaFormatNovel, + [MediaFormat.OriginalNetAnimation]: Root.MediaFormatOriginalNetAnimation, + [MediaFormat.OneShot]: Root.MediaFormatOneShot, + [MediaFormat.OriginalVideoAnimation]: Root.MediaFormatOriginalVideoAnimation, + [MediaFormat.Special]: Root.MediaFormatSpecial, + [MediaFormat.TV]: Root.MediaFormatTV, + [MediaFormat.TVShort]: Root.MediaFormatTVShort +} satisfies Record; + export abstract class AnimeCommand extends Command { public override async autocompleteRun(interaction: Command.AutocompleteInteraction, options: AnimeCommand.AutocompleteArguments) { const result = await this.autocompleteFetch(options); - const locale = getSupportedUserLanguageName(interaction); + const t = getSupportedUserLanguageT(interaction); const entries = result.match({ - ok: (values) => { - return values.map((value) => ({ - name: cutText(this.getTitle(value.title, locale, value.countryOfOrigin), 100), - value: cutText(value.title.english || value.title.romaji || value.title.native!, 100) - })); - }, + ok: (values) => values.map((value) => ({ name: this.renderAutocompleteOptionName(t, value), value: value.id })), err: () => [] }); return interaction.reply({ choices: entries }); } - protected handleResult(interaction: Command.ChatInputInteraction, result: Result, kind: Kind) { + protected renderAutocompleteOptionName(t: TFunction, value: AnilistEntryTypeByKind) { + const rawYear = value.seasonYear ?? value.startDate?.year ?? null; + const year = isNullishOrZero(rawYear) ? t(Root.Unknown) : rawYear.toString(); + const kind = t(isNullishOrEmpty(value.format) ? Root.Unknown : FormatKeys[value.format]); + const description = ` — ${year} ${kind}`; + + const title = this.getTitle(value.title, t.lng as LocaleString, value.countryOfOrigin); + return `${cutText(title, 100 - description.length)}${description}`; + } + + protected handleResult(interaction: Command.ChatInputInteraction, result: Result | null, FetchError>, kind: Kind) { const response = result.match({ ok: (value) => isNullishOrEmpty(value) @@ -42,7 +60,7 @@ export abstract class AnimeCommand extends Comma return interaction.reply(response); } - protected createResponse(value: AnilistEntry, t: TFunction): { embeds: APIEmbed[] } { + protected createResponse(value: AnilistEntryTypeByKind, t: TFunction): { embeds: APIEmbed[] } { return { embeds: [this.createEmbed(value, t).toJSON()] }; } @@ -53,9 +71,11 @@ export abstract class AnimeCommand extends Comma }; } - protected abstract autocompleteFetch(options: AnimeCommand.AutocompleteArguments): Promise>; + protected abstract autocompleteFetch( + options: AnimeCommand.AutocompleteArguments + ): Promise[], FetchError>>; - private createEmbed(value: AnilistEntry, t: TFunction) { + private createEmbed(value: AnilistEntryTypeByKind, t: TFunction) { const anilistTitles = t(Root.EmbedTitles); const description = [ `**${anilistTitles.romajiName}**: ${value.title.romaji || t(LanguageKeys.Common.None)}`, @@ -67,19 +87,19 @@ export abstract class AnimeCommand extends Comma description.push(`${bold(anilistTitles.countryOfOrigin)}: ${this.getCountry(t, value.countryOfOrigin)}`); } - if (value.episodes) { + if ('episodes' in value && value.episodes) { description.push(`${bold(anilistTitles.episodes)}: ${t(LanguageKeys.Common.FormatNumber, { value: value.episodes })}`); } - if (value.chapters) { + if ('chapters' in value && value.chapters) { description.push(`${bold(anilistTitles.chapters)}: ${t(LanguageKeys.Common.FormatNumber, { value: value.chapters })}`); } - if (value.volumes) { + if ('volumes' in value && value.volumes) { description.push(`${bold(anilistTitles.volumes)}: ${t(LanguageKeys.Common.FormatNumber, { value: value.volumes })}`); } - if (value.duration) { + if ('duration' in value && value.duration) { description.push(`${bold(anilistTitles.episodeLength)}: ${durationFormatter.format(minutes(value.duration), 1)}`); } @@ -107,7 +127,7 @@ export abstract class AnimeCommand extends Comma .setFooter({ text: '© anilist.co' }); } - private getCountry(t: TFunction, origin: NonNullable) { + private getCountry(t: TFunction, origin: NonNullable['countryOfOrigin']>) { switch (origin) { case 'CN': return `${t(Root.CountryChina)} 🇨🇳`; @@ -140,6 +160,8 @@ export abstract class AnimeCommand extends Comma export namespace AnimeCommand { export type LoaderContext = Command.LoaderContext; export type Options = Command.Options; - export type Arguments = InteractionArguments; - export type AutocompleteArguments = AutocompleteInteractionArguments>; + export type Arguments = MakeArguments; + export type AutocompleteArguments = AutocompleteInteractionArguments>; } + +type MakeArguments = Kind extends 'anime' ? { anime: Value } : { manga: Value }; diff --git a/src/lib/types/augments.d.ts b/src/lib/types/augments.d.ts index c5efd86b..d485ab27 100644 --- a/src/lib/types/augments.d.ts +++ b/src/lib/types/augments.d.ts @@ -10,10 +10,5 @@ declare module '@skyra/env-utilities' { REGISTRY_GUILD_ID: string; WEEB_SH_TOKEN: string; - - REDIS_PORT: IntegerString; - REDIS_PASSWORD: string; - REDIS_HOST: string; - REDIS_DB: IntegerString; } } diff --git a/src/lib/utilities/temporary-collection.ts b/src/lib/utilities/temporary-collection.ts new file mode 100644 index 00000000..373a2663 --- /dev/null +++ b/src/lib/utilities/temporary-collection.ts @@ -0,0 +1,72 @@ +import { Collection } from '@discordjs/collection'; +import { isNullish } from '@sapphire/utilities'; + +export class TemporaryCollection { + public readonly lifetime: number; + public readonly sweepInterval: number; + readonly #data = new Collection>(); + readonly #sweepFn: () => void; + #interval: NodeJS.Timeout | null = null; + + public constructor(options: TemporaryCollectionOptions) { + this.lifetime = options.lifetime; + this.sweepInterval = options.sweepInterval; + this.#sweepFn = () => { + const now = Date.now(); + for (const [key, value] of this.#data.entries()) { + // If the current value doesn't expire yet, the next won't, + // since the entries are stored in ascending order of `expires`: + if (value.expires > now) break; + + // Otherwise if it's expiring now, we delete it: + this.#data.delete(key); + } + + this.#updateInternalInterval(); + }; + } + + public get(key: Key): Value | undefined { + const entry = this.#data.get(key); + return entry && entry.expires > Date.now() ? entry.value : undefined; + } + + public add(key: Key, value: Value): this { + this.#data.delete(key); + this.#data.set(key, { expires: Date.now() + this.lifetime, value }); + this.#updateInternalInterval(); + + return this; + } + + public delete(key: Key) { + const success = this.#data.delete(key); + this.#updateInternalInterval(); + + return success; + } + + #updateInternalInterval() { + if (this.#data.size === 0) { + if (isNullish(this.#interval)) return; + + clearTimeout(this.#interval); + this.#interval = null; + } else { + if (!isNullish(this.#interval)) return; + + this.#interval = setTimeout(this.#sweepFn, this.sweepInterval); + this.#interval.unref(); + } + } +} + +export interface TemporaryCollectionOptions { + lifetime: number; + sweepInterval: number; +} + +export interface TemporaryCollectionEntry { + expires: number; + value: Value; +} diff --git a/src/locales/en-US/commands/anilist.json b/src/locales/en-US/commands/anilist.json index d82bd340..d6da7d74 100644 --- a/src/locales/en-US/commands/anilist.json +++ b/src/locales/en-US/commands/anilist.json @@ -10,6 +10,17 @@ "romajiName": "Romanized name", "volumes": "Amount of volumes" }, + "mediaFormatManga": "Manga", + "mediaFormatMovie": "Movie", + "mediaFormatMusic": "Music", + "mediaFormatNovel": "Light Novel", + "mediaFormatOriginalNetAnimation": "ONA", + "mediaFormatOneShot": "One Shot", + "mediaFormatOriginalVideoAnimation": "OVA", + "mediaFormatSpecial": "Special", + "mediaFormatTV": "TV", + "mediaFormatTVShort": "TV Short", + "unknown": "Unknown", "countryChina": "China", "countryJapan": "Japan", "countryKorea": "South Korea", diff --git a/yarn.lock b/yarn.lock index 2e44f7a0..20aa7630 100644 --- a/yarn.lock +++ b/yarn.lock @@ -376,13 +376,6 @@ __metadata: languageName: node linkType: hard -"@ioredis/commands@npm:^1.1.1": - version: 1.2.0 - resolution: "@ioredis/commands@npm:1.2.0" - checksum: 10/a8253c9539b7e5463d4a98e6aa5b1b863fb4a4978191ba9dc42ec2c0fb5179d8d1fe4a29096d5954f91ba9600d1bdc6c1d18b044eab36f645f267fd37d7c0906 - languageName: node - linkType: hard - "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -702,7 +695,6 @@ __metadata: eslint-config-prettier: "npm:^9.1.0" eslint-plugin-prettier: "npm:^5.2.1" he: "npm:^1.2.0" - ioredis: "npm:^5.4.1" lint-staged: "npm:^15.2.10" prettier: "npm:^3.3.3" tslib: "npm:^2.8.0" @@ -1213,13 +1205,6 @@ __metadata: languageName: node linkType: hard -"cluster-key-slot@npm:^1.1.0": - version: 1.1.2 - resolution: "cluster-key-slot@npm:1.1.2" - checksum: 10/516ed8b5e1a14d9c3a9c96c72ef6de2d70dfcdbaa0ec3a90bc7b9216c5457e39c09a5775750c272369070308542e671146120153062ab5f2f481bed5de2c925f - languageName: node - linkType: hard - "color-convert@npm:^1.9.0": version: 1.9.3 resolution: "color-convert@npm:1.9.3" @@ -1449,13 +1434,6 @@ __metadata: languageName: node linkType: hard -"denque@npm:^2.1.0": - version: 2.1.0 - resolution: "denque@npm:2.1.0" - checksum: 10/8ea05321576624b90acfc1ee9208b8d1d04b425cf7573b9b4fa40a2c3ed4d4b0af5190567858f532f677ed2003d4d2b73c8130b34e3c7b8d5e88cdcfbfaa1fe7 - languageName: node - linkType: hard - "detect-file@npm:^1.0.0": version: 1.0.0 resolution: "detect-file@npm:1.0.0" @@ -2225,23 +2203,6 @@ __metadata: languageName: node linkType: hard -"ioredis@npm:^5.4.1": - version: 5.4.1 - resolution: "ioredis@npm:5.4.1" - dependencies: - "@ioredis/commands": "npm:^1.1.1" - cluster-key-slot: "npm:^1.1.0" - debug: "npm:^4.3.4" - denque: "npm:^2.1.0" - lodash.defaults: "npm:^4.2.0" - lodash.isarguments: "npm:^3.1.0" - redis-errors: "npm:^1.2.0" - redis-parser: "npm:^3.0.0" - standard-as-callback: "npm:^2.1.0" - checksum: 10/9043b812ac58065e80c759d130602cc64490fcaeaacf93723453fda04c7ba61dab0e2f50380eacb045592378ededf44f270c0d43e13e3e8b8d7c5a8d7fecb823 - languageName: node - linkType: hard - "is-arrayish@npm:^0.2.1": version: 0.2.1 resolution: "is-arrayish@npm:0.2.1" @@ -2552,20 +2513,6 @@ __metadata: languageName: node linkType: hard -"lodash.defaults@npm:^4.2.0": - version: 4.2.0 - resolution: "lodash.defaults@npm:4.2.0" - checksum: 10/6a2a9ea5ad7585aff8d76836c9e1db4528e5f5fa50fc4ad81183152ba8717d83aef8aec4fa88bf3417ed946fd4b4358f145ee08fbc77fb82736788714d3e12db - languageName: node - linkType: hard - -"lodash.isarguments@npm:^3.1.0": - version: 3.1.0 - resolution: "lodash.isarguments@npm:3.1.0" - checksum: 10/e5186d5fe0384dcb0652501d9d04ebb984863ebc9c9faa2d4b9d5dfd81baef9ffe8e2887b9dc471d62ed092bc0788e5f1d42e45c72457a2884bbb54ac132ed92 - languageName: node - linkType: hard - "lodash.isplainobject@npm:^4.0.6": version: 4.0.6 resolution: "lodash.isplainobject@npm:4.0.6" @@ -3042,22 +2989,6 @@ __metadata: languageName: node linkType: hard -"redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0": - version: 1.2.0 - resolution: "redis-errors@npm:1.2.0" - checksum: 10/001c11f63ddd52d7c80eb4f4ede3a9433d29a458a7eea06b9154cb37c9802a218d93b7988247aa8c958d4b5d274b18354e8853c148f1096fda87c6e675cfd3ee - languageName: node - linkType: hard - -"redis-parser@npm:^3.0.0": - version: 3.0.0 - resolution: "redis-parser@npm:3.0.0" - dependencies: - redis-errors: "npm:^1.0.0" - checksum: 10/b10846844b4267f19ce1a6529465819c3d78c3e89db7eb0c3bb4eb19f83784797ec411274d15a77dbe08038b48f95f76014b83ca366dc955a016a3a0a0234650 - languageName: node - linkType: hard - "regenerator-runtime@npm:^0.14.0": version: 0.14.1 resolution: "regenerator-runtime@npm:0.14.1" @@ -3262,13 +3193,6 @@ __metadata: languageName: node linkType: hard -"standard-as-callback@npm:^2.1.0": - version: 2.1.0 - resolution: "standard-as-callback@npm:2.1.0" - checksum: 10/88bec83ee220687c72d94fd86a98d5272c91d37ec64b66d830dbc0d79b62bfa6e47f53b71646011835fc9ce7fae62739545d13124262b53be4fbb3e2ebad551c - languageName: node - linkType: hard - "string-argv@npm:~0.3.2": version: 0.3.2 resolution: "string-argv@npm:0.3.2"