Skip to content

Commit

Permalink
refactor(anilist): remove Redis, improve autocomplete (#239)
Browse files Browse the repository at this point in the history
  • Loading branch information
kyranet authored Oct 27, 2024
1 parent 3fd0a49 commit cbfd805
Show file tree
Hide file tree
Showing 15 changed files with 268 additions and 218 deletions.
12 changes: 0 additions & 12 deletions docker-compose.yml

This file was deleted.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 0 additions & 6 deletions src/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion src/commands/search/anime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'>) {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/search/manga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'>) {
Expand Down
52 changes: 45 additions & 7 deletions src/lib/apis/anilist/anilist-types.ts
Original file line number Diff line number Diff line change
@@ -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 */
Expand All @@ -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'
}
145 changes: 80 additions & 65 deletions src/lib/apis/anilist/anilist-utilities.ts
Original file line number Diff line number Diff line change
@@ -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<string, readonly number[]>({ lifetime: Time.Hour, sweepInterval: Time.Minute }),
result: new TemporaryCollection<number, AnilistEntryAnime>({ lifetime: Time.Hour, sweepInterval: Time.Minute })
},
manga: {
search: new TemporaryCollection<string, readonly number[]>({ lifetime: Time.Hour, sweepInterval: Time.Minute }),
result: new TemporaryCollection<number, AnilistEntryManga>({ lifetime: Time.Hour, sweepInterval: Time.Minute })
}
};

export type AnilistEntryTypeByKind<Kind extends 'anime' | 'manga'> = {
anime: AnilistEntryAnime;
manga: AnilistEntryManga;
}[Kind];

/**
* Regex to remove excessive new lines from the Anime or Manga description
*/
Expand Down Expand Up @@ -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
Expand All @@ -54,70 +63,70 @@ export function parseAniListDescription(description: string) {
);
}

export async function anilistAnimeGet(query: string): Promise<Result<AnilistEntry | null, FetchError>> {
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<Result<AnilistEntryAnime | null, FetchError>> {
const cached = cache.anime.result.get(id);
return cached ? ok(cached) : sharedSearchId('anime', id);
}

export async function anilistMangaGet(query: string): Promise<Result<AnilistEntry | null, FetchError>> {
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<Result<AnilistEntryManga | null, FetchError>> {
const cached = cache.manga.result.get(id);
return cached ? ok(cached) : sharedSearchId('manga', id);
}

export async function anilistAnimeSearch(query: string): Promise<Result<readonly AnilistEntry[], FetchError>> {
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<Result<readonly AnilistEntryAnime[], FetchError>> {
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<Result<readonly AnilistEntry[], FetchError>> {
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<Result<readonly AnilistEntryManga[], FetchError>> {
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 extends 'anime' | 'manga'>(kind: Kind, id: number): Promise<FetchResult<AnilistEntryTypeByKind<Kind> | null>> {
const query = kind === 'anime' ? GetAnimeQuery : GetMangaQuery;
const result = await Json<{ data: { Page: { media: AnilistEntryTypeByKind<Kind>[] } } }>(
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 extends 'anime' | 'manga'>(
kind: Kind,
query: string,
body: string
): Promise<FetchResult<AnilistEntryTypeByKind<Kind>[]>> {
const result = await Json<{ data: { Page: { media: AnilistEntryTypeByKind<Kind>[] } } }>(
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 = {
Expand All @@ -134,7 +143,11 @@ const MediaFragment = `
native
}
description
isAdult
format
seasonYear
startDate {
year
}
countryOfOrigin
duration
siteUrl
Expand All @@ -154,8 +167,8 @@ const GetTrendingAnimeBody = JSON.stringify({
Page(page: 1, perPage: 25) {
media(type: ANIME, sort: $sort) {
...MediaFragment
chapters
volumes
episodes
duration
}
}
}`
Expand All @@ -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
Expand All @@ -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
}
}
}
Expand All @@ -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
}
}
}
Expand Down
13 changes: 13 additions & 0 deletions src/lib/i18n/LanguageKeys/Commands/AniList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
3 changes: 0 additions & 3 deletions src/lib/setup/all.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -11,6 +10,4 @@ export function setup() {
setRepository('nekokai');
setInvite('939613684592934992', '16384');
initializeSentry();

redisRun();
}
Loading

0 comments on commit cbfd805

Please # to comment.