feat: create route to return title information

Summary:

Test Plan:
This commit is contained in:
2024-05-15 23:03:08 -04:00
parent 695a1bb4cd
commit 68c082493e
18 changed files with 1367 additions and 4 deletions

View File

@@ -7,6 +7,9 @@ const app = new OpenAPIHono();
const route = createRoute({
method: "get",
path: "/",
summary: "Health check",
operationId: "healthCheck",
tags: ["aniplay"],
responses: {
200: {
content: {
@@ -14,7 +17,7 @@ const route = createRoute({
schema: SuccessResponseSchema(),
},
},
description: "Retrieve the user",
description: "Server is up and running!",
},
},
});

View File

@@ -0,0 +1,76 @@
import { Title } from "~/types/title";
export async function fetchTitleFromAmvstrm(
aniListId: number,
): Promise<Title | undefined> {
return Promise.all([
fetch(`https://api-amvstrm.nyt92.eu.org/api/v2/info/${aniListId}`).then(
(res) => res.json() as Promise<any>,
),
fetchMissingInformationFromAnify(aniListId).catch((err) => {
console.error("Failed to get missing information from Anify", err);
return null;
}),
]).then(
async ([
{
id,
idMal,
title: { english: englishTitle, userPreferred: userPreferredTitle },
description,
episodes,
genres,
status,
bannerImage,
coverImage: {
extraLarge: extraLargeCoverImage,
large: largeCoverImage,
medium: mediumCoverImage,
},
countryOfOrigin,
nextair: nextAiringEpisode,
score: { averageScore },
},
anifyInfo,
]) => {
return {
id,
idMal,
title: {
userPreferred: userPreferredTitle,
english: englishTitle,
},
description,
episodes,
genres,
status,
averageScore,
bannerImage: bannerImage ?? anifyInfo?.bannerImage,
coverImage: {
extraLarge: extraLargeCoverImage,
large: largeCoverImage,
medium: mediumCoverImage,
},
countryOfOrigin: countryOfOrigin ?? anifyInfo?.countryOfOrigin,
nextAiringEpisode,
mediaListEntry: null,
};
},
);
}
type AnifyInformation = {
bannerImage: string | null;
countryOfOrigin: string;
};
function fetchMissingInformationFromAnify(
aniListId: number,
): Promise<AnifyInformation> {
return fetch(`https://api.anify.tv/info?id=${aniListId}`)
.then((res) => res.json() as Promise<AnifyInformation>)
.then(({ bannerImage, countryOfOrigin }) => ({
bannerImage,
countryOfOrigin,
}));
}

View File

@@ -0,0 +1,32 @@
import { graphql } from "gql.tada";
import { GraphQLClient } from "graphql-request";
import type { Title } from "~/types/title";
import { MediaFragment } from "./mediaFragment";
const GetTitleQuery = graphql(
`
query GetTitle($id: Int!) {
Media(id: $id) {
...Media
}
}
`,
[MediaFragment],
);
export async function fetchTitleFromAnilist(
id: number,
token: string | undefined,
): Promise<Title | undefined> {
const client = new GraphQLClient("https://graphql.anilist.co/");
const headers = new Headers();
if (token) {
headers.append("Authorization", `Bearer ${token}`);
}
return client
.request(GetTitleQuery, { id }, headers)
.then((data) => data?.Media ?? undefined);
}

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "bun:test";
import app from "~/index";
import { server } from "~/mocks";
server.listen();
describe('requests the "/title" route', () => {
it("with a valid id & token", async () => {
const response = await app.request("/title?id=10", {
headers: new Headers({ "x-anilist-token": "asd" }),
});
expect(response.json()).resolves.toEqual({
success: true,
result: {
nextAiringEpisode: null,
mediaListEntry: {
status: "CURRENT",
progress: 1,
id: 402665918,
},
countryOfOrigin: "JP",
coverImage: {
medium:
"https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
large:
"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
extraLarge:
"https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
},
averageScore: 66,
bannerImage:
"https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
status: "FINISHED",
genres: ["Fantasy", "Thriller"],
episodes: 6,
description:
'Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"\n<br><br>\nThe pages of Grimms\' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.\n<br><br>\n(Source: Netflix Anime)',
title: {
userPreferred: "The Grimm Variations",
english: "The Grimm Variations",
},
idMal: 49210,
id: 135643,
},
});
expect(response.status).toBe(200);
});
it("with a valid id but no token", async () => {
const response = await app.request("/title?id=10");
expect(response.json()).resolves.toEqual({
success: true,
result: {
nextAiringEpisode: null,
mediaListEntry: null,
countryOfOrigin: "JP",
coverImage: {
medium:
"https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
large:
"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
extraLarge:
"https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
},
averageScore: 66,
bannerImage:
"https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
status: "FINISHED",
genres: ["Fantasy", "Thriller"],
episodes: 6,
description:
'Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"\n<br><br>\nThe pages of Grimms\' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.\n<br><br>\n(Source: Netflix Anime)',
title: {
userPreferred: "The Grimm Variations",
english: "The Grimm Variations",
},
idMal: 49210,
id: 135643,
},
});
expect(response.status).toBe(200);
});
it("with an unknown title from anilist but valid title from amvstrm", async () => {
const response = await app.request("/title?id=50");
expect(response.json()).resolves.toEqual({
success: true,
result: {
nextAiringEpisode: null,
mediaListEntry: null,
coverImage: {
medium:
"https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx151807-yxY3olrjZH4k.png",
large:
"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx151807-yxY3olrjZH4k.png",
},
averageScore: 83,
bannerImage:
"https://s4.anilist.co/file/anilistcdn/media/anime/banner/151807-37yfQA3ym8PA.jpg",
status: "FINISHED",
genres: ["Action", "Adventure", "Fantasy"],
episodes: 12,
description:
"They say whatever doesnt kill you makes you stronger, but thats not the case for the worlds weakest hunter Sung Jinwoo. After being brutally slaughtered by monsters in a high-ranking dungeon, Jinwoo came back with the System, a program only he could see, thats leveling him up in every way. Now, hes inspired to discover the secrets behind his powers and the dungeon that spawned them.<br>\n<br>\n(Source: Crunchyroll) <br><br>",
title: {
userPreferred: "Ore dake Level Up na Ken",
english: "Solo Leveling",
},
idMal: 52299,
id: 151807,
countryOfOrigin: "JP",
},
});
expect(response.status).toBe(200);
});
it("with an unknown title from all sources", async () => {
const response = await app.request("/title?id=-1");
expect(response.json()).resolves.toEqual({ success: false });
expect(response.status).toBe(404);
});
});

View File

@@ -0,0 +1,61 @@
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { fetchFromMultipleSources } from "~/libs/fetchFromMultipleSources";
import {
AniListIdSchema,
ErrorResponseSchema,
SuccessResponseSchema,
} from "~/types/schema";
import { Title } from "~/types/title";
import { fetchTitleFromAmvstrm } from "./amvstrm";
import { fetchTitleFromAnilist } from "./anilist";
const app = new OpenAPIHono();
const route = createRoute({
tags: ["aniplay", "title"],
operationId: "fetchTitle",
summary: "Fetch title information",
method: "get",
path: "/",
request: {
query: z.object({ id: AniListIdSchema }),
headers: z.object({ "x-anilist-token": z.string().nullish() }),
},
responses: {
200: {
content: {
"application/json": {
schema: SuccessResponseSchema(Title),
},
},
description: "Returns title information",
},
"404": {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Title could not be found",
},
},
});
app.openapi(route, async (c) => {
const aniListId = Number(c.req.query("id"));
const aniListToken = c.req.header("X-AniList-Token");
const title = await fetchFromMultipleSources([
() => fetchTitleFromAnilist(aniListId, aniListToken ?? undefined),
() => fetchTitleFromAmvstrm(aniListId),
]);
if (!title) {
return c.json({ success: false }, 404);
}
return c.json({ success: true, result: title }, 200);
});
export default app;

View File

@@ -0,0 +1,35 @@
import { graphql } from "gql.tada";
export const MediaFragment = graphql(`
fragment Media on Media {
id
idMal
title {
english
userPreferred
}
type
description
episodes
genres
status
bannerImage
averageScore
coverImage {
extraLarge
large
medium
}
countryOfOrigin
mediaListEntry {
id
progress
status
}
nextAiringEpisode {
timeUntilAiring
airingAt
episode
}
}
`);