feat: create route to return title information
Summary: Test Plan:
This commit is contained in:
@@ -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!",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
76
src/controllers/title/amvstrm.ts
Normal file
76
src/controllers/title/amvstrm.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
32
src/controllers/title/anilist.ts
Normal file
32
src/controllers/title/anilist.ts
Normal 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);
|
||||
}
|
||||
127
src/controllers/title/index.spec.ts
Normal file
127
src/controllers/title/index.spec.ts
Normal 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 doesn’t kill you makes you stronger, but that’s not the case for the world’s 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, that’s leveling him up in every way. Now, he’s 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);
|
||||
});
|
||||
});
|
||||
61
src/controllers/title/index.ts
Normal file
61
src/controllers/title/index.ts
Normal 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;
|
||||
35
src/controllers/title/mediaFragment.ts
Normal file
35
src/controllers/title/mediaFragment.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`);
|
||||
Reference in New Issue
Block a user