diff --git a/README.md b/README.md
index 7dce3b3..43386ab 100644
--- a/README.md
+++ b/README.md
@@ -13,3 +13,7 @@ pre-commit hook for Sapling (`.sl/config`):
[hooks]
precommit = echo $HG_PARENT1 && bun prettier $(sl show -T "{file_adds} {file_mods}\n\n" $HG_PARENT1 --stat | head -n 1) --write
```
+
+## Development
+
+If a route is internal-only or doesn't need to appear on the OpenAPI spec (that's autogenerated by Hono), use the `Hono` class. Otherwise, use the `OpenAPIHono` class from `@hono/zod-openapi`.
diff --git a/bun.lockb b/bun.lockb
index 7e88c6e..46b2da3 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 4729e9d..9bc6da3 100644
--- a/package.json
+++ b/package.json
@@ -5,10 +5,13 @@
},
"dependencies": {
"@hono/zod-openapi": "^0.12.0",
+ "gql.tada": "^1.7.4",
+ "graphql-request": "^7.0.1",
"hono": "^4.3.6",
"zod": "^3.23.8"
},
"devDependencies": {
+ "@0no-co/graphqlsp": "^1.12.3",
"@cloudflare/workers-types": "^4.20240403.0",
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/bun": "^1.1.2",
diff --git a/src/controllers/health-check/index.ts b/src/controllers/health-check/index.ts
index c1be941..9870299 100644
--- a/src/controllers/health-check/index.ts
+++ b/src/controllers/health-check/index.ts
@@ -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!",
},
},
});
diff --git a/src/controllers/title/amvstrm.ts b/src/controllers/title/amvstrm.ts
new file mode 100644
index 0000000..ee65ecc
--- /dev/null
+++ b/src/controllers/title/amvstrm.ts
@@ -0,0 +1,76 @@
+import { Title } from "~/types/title";
+
+export async function fetchTitleFromAmvstrm(
+ aniListId: number,
+): Promise
{
+ return Promise.all([
+ fetch(`https://api-amvstrm.nyt92.eu.org/api/v2/info/${aniListId}`).then(
+ (res) => res.json() as Promise,
+ ),
+ 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 {
+ return fetch(`https://api.anify.tv/info?id=${aniListId}`)
+ .then((res) => res.json() as Promise)
+ .then(({ bannerImage, countryOfOrigin }) => ({
+ bannerImage,
+ countryOfOrigin,
+ }));
+}
diff --git a/src/controllers/title/anilist.ts b/src/controllers/title/anilist.ts
new file mode 100644
index 0000000..fb8fd21
--- /dev/null
+++ b/src/controllers/title/anilist.ts
@@ -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 {
+ 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);
+}
diff --git a/src/controllers/title/index.spec.ts b/src/controllers/title/index.spec.ts
new file mode 100644
index 0000000..9a709a8
--- /dev/null
+++ b/src/controllers/title/index.spec.ts
@@ -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
\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
\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
\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
\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.
\n
\n(Source: Crunchyroll)
",
+ 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);
+ });
+});
diff --git a/src/controllers/title/index.ts b/src/controllers/title/index.ts
new file mode 100644
index 0000000..d3a1d66
--- /dev/null
+++ b/src/controllers/title/index.ts
@@ -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;
diff --git a/src/controllers/title/mediaFragment.ts b/src/controllers/title/mediaFragment.ts
new file mode 100644
index 0000000..edd62e0
--- /dev/null
+++ b/src/controllers/title/mediaFragment.ts
@@ -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
+ }
+ }
+`);
diff --git a/src/index.ts b/src/index.ts
index 7048a4e..206aa92 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -8,6 +8,10 @@ app.route(
(controller) => controller.default,
),
);
+app.route(
+ "/title",
+ await import("~/controllers/title").then((controller) => controller.default),
+);
// The OpenAPI documentation will be available at /doc
app.doc("/doc", {
diff --git a/src/mocks/amvstrm/title.ts b/src/mocks/amvstrm/title.ts
new file mode 100644
index 0000000..97e2105
--- /dev/null
+++ b/src/mocks/amvstrm/title.ts
@@ -0,0 +1,602 @@
+import { HttpResponse, http } from "msw";
+
+export function getAmvstrmTitle() {
+ return http.get(
+ "https://api-amvstrm.nyt92.eu.org/api/v2/info/:aniListId",
+ ({ params }) => {
+ const aniListId = Number(params["aniListId"] as string);
+
+ if (aniListId == -1) {
+ return HttpResponse.json({
+ code: 404,
+ message:
+ "The requested resource could not be found but may be available in the future. Subsequent requests by the client are permissible.",
+ });
+ }
+
+ if (aniListId == 50) {
+ return HttpResponse.json({
+ code: 200,
+ message: "success",
+ id: 151807,
+ idMal: 52299,
+ id_provider: {
+ idGogo: "ore-dake-level-up-na-ken",
+ idGogoDub: "ore-dake-level-up-na-ken-korean-dub",
+ idZoro: "solo-leveling-18718",
+ id9anime: "solo-leveling.3rpv2",
+ idPahe: "5421",
+ },
+ title: {
+ romaji: "Ore dake Level Up na Ken",
+ english: "Solo Leveling",
+ native: "俺だけレベルアップな件",
+ userPreferred: "Ore dake Level Up na Ken",
+ },
+ dub: true,
+ 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.
\n
\n(Source: Crunchyroll)
",
+ coverImage: {
+ large:
+ "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx151807-yxY3olrjZH4k.png",
+ medium:
+ "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx151807-yxY3olrjZH4k.png",
+ color: "#35bbf1",
+ },
+ bannerImage:
+ "https://s4.anilist.co/file/anilistcdn/media/anime/banner/151807-37yfQA3ym8PA.jpg",
+ genres: ["Action", "Adventure", "Fantasy"],
+ tags: [
+ {
+ id: 604,
+ name: "Dungeon",
+ },
+ {
+ id: 82,
+ name: "Male Protagonist",
+ },
+ {
+ id: 321,
+ name: "Urban Fantasy",
+ },
+ {
+ id: 66,
+ name: "Super Power",
+ },
+ {
+ id: 29,
+ name: "Magic",
+ },
+ {
+ id: 1243,
+ name: "Necromancy",
+ },
+ {
+ id: 326,
+ name: "Cultivation",
+ },
+ {
+ id: 111,
+ name: "War",
+ },
+ {
+ id: 104,
+ name: "Anti-Hero",
+ },
+ {
+ id: 94,
+ name: "Gore",
+ },
+ {
+ id: 636,
+ name: "Cosmic Horror",
+ },
+ {
+ id: 85,
+ name: "Tragedy",
+ },
+ {
+ id: 43,
+ name: "Swordplay",
+ },
+ {
+ id: 56,
+ name: "Shounen",
+ },
+ {
+ id: 146,
+ name: "Alternate Universe",
+ },
+ {
+ id: 96,
+ name: "Time Manipulation",
+ },
+ {
+ id: 1068,
+ name: "Angels",
+ },
+ {
+ id: 93,
+ name: "Post-Apocalyptic",
+ },
+ {
+ id: 217,
+ name: "Dystopian",
+ },
+ {
+ id: 1310,
+ name: "Travel",
+ },
+ {
+ id: 1045,
+ name: "Heterosexual",
+ },
+ {
+ id: 488,
+ name: "Age Regression",
+ },
+ {
+ id: 244,
+ name: "Isekai",
+ },
+ {
+ id: 171,
+ name: "Bullying",
+ },
+ {
+ id: 224,
+ name: "Dragons",
+ },
+ {
+ id: 255,
+ name: "Ninja",
+ },
+ ],
+ status: "FINISHED",
+ format: "TV",
+ episodes: 12,
+ year: 2024,
+ season: "WINTER",
+ duration: 24,
+ startIn: {
+ year: 2024,
+ month: 1,
+ day: 7,
+ },
+ endIn: {
+ year: 2024,
+ month: 3,
+ day: 31,
+ },
+ nextair: null,
+ score: {
+ averageScore: 83,
+ decimalScore: 8.3,
+ },
+ popularity: 196143,
+ siteUrl: "https://anilist.co/anime/151807",
+ trailer: {
+ id: "HkIKAnwLZCw",
+ site: "youtube",
+ thumbnail: "https://i.ytimg.com/vi/HkIKAnwLZCw/hqdefault.jpg",
+ },
+ studios: [
+ {
+ name: "A-1 Pictures",
+ },
+ {
+ name: "Aniplex",
+ },
+ {
+ name: "Netmarble",
+ },
+ {
+ name: "D&C MEDIA",
+ },
+ {
+ name: "Kakao piccoma",
+ },
+ {
+ name: "Crunchyroll",
+ },
+ ],
+ relation: [
+ {
+ id: 105398,
+ idMal: 121496,
+ title: {
+ romaji: "Na Honjaman Level Up",
+ english: "Solo Leveling",
+ native: "나 혼자만 레벨업",
+ userPreferred: "Na Honjaman Level Up",
+ },
+ coverImage: {
+ large:
+ "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx105398-b673Vt5ZSuz3.jpg",
+ medium:
+ "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx105398-b673Vt5ZSuz3.jpg",
+ color: null,
+ },
+ bannerImage:
+ "https://s4.anilist.co/file/anilistcdn/media/manga/banner/105398-4UrEhdqZukrg.jpg",
+ genres: ["Action", "Adventure", "Fantasy"],
+ tags: [
+ {
+ id: 604,
+ name: "Dungeon",
+ },
+ {
+ id: 82,
+ name: "Male Protagonist",
+ },
+ {
+ id: 111,
+ name: "War",
+ },
+ {
+ id: 66,
+ name: "Super Power",
+ },
+ {
+ id: 207,
+ name: "Full Color",
+ },
+ {
+ id: 29,
+ name: "Magic",
+ },
+ {
+ id: 1243,
+ name: "Necromancy",
+ },
+ {
+ id: 321,
+ name: "Urban Fantasy",
+ },
+ {
+ id: 253,
+ name: "Gods",
+ },
+ {
+ id: 109,
+ name: "Primarily Adult Cast",
+ },
+ {
+ id: 103,
+ name: "Politics",
+ },
+ {
+ id: 93,
+ name: "Post-Apocalyptic",
+ },
+ {
+ id: 15,
+ name: "Demons",
+ },
+ {
+ id: 85,
+ name: "Tragedy",
+ },
+ {
+ id: 308,
+ name: "Video Games",
+ },
+ {
+ id: 365,
+ name: "Memory Manipulation",
+ },
+ {
+ id: 96,
+ name: "Time Manipulation",
+ },
+ {
+ id: 198,
+ name: "Foreign",
+ },
+ {
+ id: 1564,
+ name: "Estranged Family",
+ },
+ {
+ id: 171,
+ name: "Bullying",
+ },
+ {
+ id: 488,
+ name: "Age Regression",
+ },
+ {
+ id: 104,
+ name: "Anti-Hero",
+ },
+ {
+ id: 322,
+ name: "Assassins",
+ },
+ {
+ id: 774,
+ name: "Chimera",
+ },
+ {
+ id: 1045,
+ name: "Heterosexual",
+ },
+ {
+ id: 516,
+ name: "Language Barrier",
+ },
+ {
+ id: 153,
+ name: "Time Skip",
+ },
+ ],
+ type: "MANGA",
+ format: "MANGA",
+ status: "FINISHED",
+ episodes: null,
+ duration: null,
+ averageScore: 85,
+ season: null,
+ },
+ {
+ id: 176496,
+ idMal: 58567,
+ title: {
+ romaji:
+ "Ore dake Level Up na Ken: Season 2 - Arise from the Shadow",
+ english: "Solo Leveling Season 2 -Arise from the Shadow-",
+ native:
+ "俺だけレベルアップな件 Season 2 -Arise from the Shadow-",
+ userPreferred:
+ "Ore dake Level Up na Ken: Season 2 - Arise from the Shadow",
+ },
+ coverImage: {
+ large:
+ "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx176496-r6oXxEqdZL0n.jpg",
+ medium:
+ "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx176496-r6oXxEqdZL0n.jpg",
+ color: "#a1bbe4",
+ },
+ bannerImage: null,
+ genres: ["Action", "Adventure", "Fantasy"],
+ tags: [
+ {
+ id: 1243,
+ name: "Necromancy",
+ },
+ {
+ id: 604,
+ name: "Dungeon",
+ },
+ ],
+ type: "ANIME",
+ format: "TV",
+ status: "NOT_YET_RELEASED",
+ episodes: null,
+ duration: null,
+ averageScore: null,
+ season: null,
+ },
+ ],
+ });
+ }
+
+ return HttpResponse.json({
+ code: 200,
+ message: "success",
+ id: 135643,
+ idMal: 49210,
+ id_provider: {
+ idGogo: "grimm-kumikyoku-dub",
+ idGogoDub: "grimm-kumikyoku",
+ idZoro: "the-grimm-variations-19092",
+ id9anime: "grimm-kumikyoku.qxvzn",
+ idPahe: "",
+ },
+ title: {
+ romaji: "Grimm Kumikyoku",
+ english: "The Grimm Variations",
+ native: "グリム組曲",
+ userPreferred: "Grimm Kumikyoku",
+ },
+ dub: true,
+ 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
\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
\n(Source: Netflix Anime)',
+ coverImage: {
+ large:
+ "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
+ medium:
+ "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
+ color: "#fea150",
+ },
+ bannerImage:
+ "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
+ genres: ["Fantasy", "Thriller"],
+ tags: [
+ {
+ id: 400,
+ name: "Fairy Tale",
+ },
+ {
+ id: 193,
+ name: "Episodic",
+ },
+ {
+ id: 471,
+ name: "Anthology",
+ },
+ {
+ id: 227,
+ name: "Classic Literature",
+ },
+ {
+ id: 179,
+ name: "Witch",
+ },
+ {
+ id: 1219,
+ name: "Disability",
+ },
+ {
+ id: 93,
+ name: "Post-Apocalyptic",
+ },
+ {
+ id: 94,
+ name: "Gore",
+ },
+ {
+ id: 25,
+ name: "Historical",
+ },
+ {
+ id: 250,
+ name: "Rural",
+ },
+ {
+ id: 394,
+ name: "Writing",
+ },
+ {
+ id: 29,
+ name: "Magic",
+ },
+ {
+ id: 161,
+ name: "Bar",
+ },
+ {
+ id: 1578,
+ name: "Arranged Marriage",
+ },
+ {
+ id: 654,
+ name: "Denpa",
+ },
+ {
+ id: 217,
+ name: "Dystopian",
+ },
+ {
+ id: 598,
+ name: "Elf",
+ },
+ {
+ id: 456,
+ name: "Conspiracy",
+ },
+ {
+ id: 63,
+ name: "Space",
+ },
+ {
+ id: 364,
+ name: "Augmented Reality",
+ },
+ {
+ id: 112,
+ name: "Virtual World",
+ },
+ {
+ id: 639,
+ name: "Body Horror",
+ },
+ {
+ id: 163,
+ name: "Yandere",
+ },
+ {
+ id: 154,
+ name: "Body Swapping",
+ },
+ {
+ id: 100,
+ name: "Nudity",
+ },
+ ],
+ status: "FINISHED",
+ format: "ONA",
+ episodes: 6,
+ year: 2024,
+ season: "SPRING",
+ duration: 44,
+ startIn: {
+ year: 2024,
+ month: 4,
+ day: 17,
+ },
+ endIn: {
+ year: 2024,
+ month: 4,
+ day: 17,
+ },
+ nextair: null,
+ score: {
+ averageScore: 66,
+ decimalScore: 6.6,
+ },
+ popularity: 8486,
+ siteUrl: "https://anilist.co/anime/135643",
+ trailer: {
+ id: "bTU3detmX_I",
+ site: "youtube",
+ thumbnail: "https://i.ytimg.com/vi/bTU3detmX_I/hqdefault.jpg",
+ },
+ studios: [
+ {
+ name: "Netflix",
+ },
+ {
+ name: "Wit Studio",
+ },
+ ],
+ relation: [
+ {
+ id: 177039,
+ idMal: 169338,
+ title: {
+ romaji: "Grimm Kumikyoku",
+ english: null,
+ native: "グリム組曲",
+ userPreferred: "Grimm Kumikyoku",
+ },
+ coverImage: {
+ large:
+ "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx177039-672FYniIpHIL.jpg",
+ medium:
+ "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx177039-672FYniIpHIL.jpg",
+ color: "#865028",
+ },
+ bannerImage: null,
+ genres: ["Fantasy", "Thriller"],
+ tags: [
+ {
+ id: 400,
+ name: "Fairy Tale",
+ },
+ {
+ id: 94,
+ name: "Gore",
+ },
+ {
+ id: 85,
+ name: "Tragedy",
+ },
+ {
+ id: 63,
+ name: "Space",
+ },
+ ],
+ type: "MANGA",
+ format: "MANGA",
+ status: "RELEASING",
+ episodes: null,
+ duration: null,
+ averageScore: null,
+ season: null,
+ },
+ ],
+ });
+ },
+ );
+}
diff --git a/src/mocks/anify/title.ts b/src/mocks/anify/title.ts
new file mode 100644
index 0000000..47e7cac
--- /dev/null
+++ b/src/mocks/anify/title.ts
@@ -0,0 +1,12 @@
+import { HttpResponse, http } from "msw";
+
+export function getAnifyTitle() {
+ return http.get(`https://api.anify.tv/info`, ({ request }) => {
+ // Construct a URL instance out of the intercepted request.
+ const url = new URL(request.url);
+ const id = url.searchParams.get("id");
+
+ // TODO: Actually return a response
+ return HttpResponse.json({ bannerImage: null, countryOfOrigin: "JP" });
+ });
+}
diff --git a/src/mocks/anilist/title.ts b/src/mocks/anilist/title.ts
new file mode 100644
index 0000000..9a9b0e1
--- /dev/null
+++ b/src/mocks/anilist/title.ts
@@ -0,0 +1,70 @@
+import { HttpResponse, graphql } from "msw";
+
+export function getAnilistTitle() {
+ return graphql.query(
+ "GetTitle",
+ ({ variables: { id }, request: { headers } }) => {
+ console.log(
+ `Intercepting GetTitle query with ID ${id} and Authorization header ${headers.get("authorization")}`,
+ );
+
+ if (id === -1 || id === 50) {
+ return HttpResponse.json({
+ errors: [
+ {
+ message: "Not Found.",
+ status: 404,
+ locations: [
+ {
+ line: 2,
+ column: 2,
+ },
+ ],
+ },
+ ],
+ data: {
+ Media: null,
+ },
+ });
+ }
+
+ return HttpResponse.json({
+ data: {
+ Media: {
+ id: 135643,
+ idMal: 49210,
+ title: {
+ english: "The Grimm Variations",
+ userPreferred: "The Grimm Variations",
+ },
+ 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
\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
\n(Source: Netflix Anime)',
+ episodes: 6,
+ genres: ["Fantasy", "Thriller"],
+ status: "FINISHED",
+ bannerImage:
+ "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
+ averageScore: 66,
+ coverImage: {
+ extraLarge:
+ "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
+ large:
+ "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
+ medium:
+ "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
+ },
+ countryOfOrigin: "JP",
+ mediaListEntry: headers.has("authorization")
+ ? {
+ id: 402665918,
+ progress: 1,
+ status: "CURRENT",
+ }
+ : null,
+ nextAiringEpisode: null,
+ },
+ },
+ });
+ },
+ );
+}
diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts
index 0f3710c..0a8cbfb 100644
--- a/src/mocks/handlers.ts
+++ b/src/mocks/handlers.ts
@@ -1 +1,5 @@
-export const handlers = [];
+import { getAmvstrmTitle } from "./amvstrm/title";
+import { getAnifyTitle } from "./anify/title";
+import { getAnilistTitle } from "./anilist/title";
+
+export const handlers = [getAnilistTitle(), getAmvstrmTitle(), getAnifyTitle()];
diff --git a/src/types/schema.ts b/src/types/schema.ts
index bcb07ff..6b60d11 100644
--- a/src/types/schema.ts
+++ b/src/types/schema.ts
@@ -8,3 +8,11 @@ export const SuccessResponseSchema = (schema?: T) => {
return z.object({ success: z.literal(true), result: schema });
};
+
+export const ErrorResponseSchema = z.object({
+ success: z.literal(false),
+});
+
+export const AniListIdSchema = z
+ .number({ coerce: true })
+ .openapi({ type: "integer" });
diff --git a/src/types/title/countryCodes.ts b/src/types/title/countryCodes.ts
new file mode 100644
index 0000000..db2546f
--- /dev/null
+++ b/src/types/title/countryCodes.ts
@@ -0,0 +1,253 @@
+import { z } from "zod";
+
+export const countryCodeSchema = z.enum([
+ "AD",
+ "AE",
+ "AF",
+ "AG",
+ "AI",
+ "AL",
+ "AM",
+ "AO",
+ "AQ",
+ "AR",
+ "AS",
+ "AT",
+ "AU",
+ "AW",
+ "AX",
+ "AZ",
+ "BA",
+ "BB",
+ "BD",
+ "BE",
+ "BF",
+ "BG",
+ "BH",
+ "BI",
+ "BJ",
+ "BL",
+ "BM",
+ "BN",
+ "BO",
+ "BQ",
+ "BR",
+ "BS",
+ "BT",
+ "BV",
+ "BW",
+ "BY",
+ "BZ",
+ "CA",
+ "CC",
+ "CD",
+ "CF",
+ "CG",
+ "CH",
+ "CI",
+ "CK",
+ "CL",
+ "CM",
+ "CN",
+ "CO",
+ "CR",
+ "CU",
+ "CV",
+ "CW",
+ "CX",
+ "CY",
+ "CZ",
+ "DE",
+ "DJ",
+ "DK",
+ "DM",
+ "DO",
+ "DZ",
+ "EC",
+ "EE",
+ "EG",
+ "EH",
+ "ER",
+ "ES",
+ "ET",
+ "FI",
+ "FJ",
+ "FK",
+ "FM",
+ "FO",
+ "FR",
+ "GA",
+ "GB",
+ "GD",
+ "GE",
+ "GF",
+ "GG",
+ "GH",
+ "GI",
+ "GL",
+ "GM",
+ "GN",
+ "GP",
+ "GQ",
+ "GR",
+ "GS",
+ "GT",
+ "GU",
+ "GW",
+ "GY",
+ "HK",
+ "HM",
+ "HN",
+ "HR",
+ "HT",
+ "HU",
+ "ID",
+ "IE",
+ "IL",
+ "IM",
+ "IN",
+ "IO",
+ "IQ",
+ "IR",
+ "IS",
+ "IT",
+ "JE",
+ "JM",
+ "JO",
+ "JP",
+ "KE",
+ "KG",
+ "KH",
+ "KI",
+ "KM",
+ "KN",
+ "KP",
+ "KR",
+ "KW",
+ "KY",
+ "KZ",
+ "LA",
+ "LB",
+ "LC",
+ "LI",
+ "LK",
+ "LR",
+ "LS",
+ "LT",
+ "LU",
+ "LV",
+ "LY",
+ "MA",
+ "MC",
+ "MD",
+ "ME",
+ "MF",
+ "MG",
+ "MH",
+ "MK",
+ "ML",
+ "MM",
+ "MN",
+ "MO",
+ "MP",
+ "MQ",
+ "MR",
+ "MS",
+ "MT",
+ "MU",
+ "MV",
+ "MW",
+ "MX",
+ "MY",
+ "MZ",
+ "NA",
+ "NC",
+ "NE",
+ "NF",
+ "NG",
+ "NI",
+ "NL",
+ "NO",
+ "NP",
+ "NR",
+ "NU",
+ "NZ",
+ "OM",
+ "PA",
+ "PE",
+ "PF",
+ "PG",
+ "PH",
+ "PK",
+ "PL",
+ "PM",
+ "PN",
+ "PR",
+ "PS",
+ "PT",
+ "PW",
+ "PY",
+ "QA",
+ "RE",
+ "RO",
+ "RS",
+ "RU",
+ "RW",
+ "SA",
+ "SB",
+ "SC",
+ "SD",
+ "SE",
+ "SG",
+ "SH",
+ "SI",
+ "SJ",
+ "SK",
+ "SL",
+ "SM",
+ "SN",
+ "SO",
+ "SR",
+ "SS",
+ "ST",
+ "SV",
+ "SX",
+ "SY",
+ "SZ",
+ "TC",
+ "TD",
+ "TF",
+ "TG",
+ "TH",
+ "TJ",
+ "TK",
+ "TL",
+ "TM",
+ "TN",
+ "TO",
+ "TR",
+ "TT",
+ "TV",
+ "TW",
+ "TZ",
+ "UA",
+ "UG",
+ "UM",
+ "US",
+ "UY",
+ "UZ",
+ "VA",
+ "VC",
+ "VE",
+ "VG",
+ "VI",
+ "VN",
+ "VU",
+ "WF",
+ "WS",
+ "YE",
+ "YT",
+ "ZA",
+ "ZM",
+ "ZW",
+]);
diff --git a/src/types/title/index.ts b/src/types/title/index.ts
new file mode 100644
index 0000000..096b0f6
--- /dev/null
+++ b/src/types/title/index.ts
@@ -0,0 +1,60 @@
+import { z } from "zod";
+
+import { countryCodeSchema } from "./countryCodes";
+
+export type Title = z.infer;
+export const Title = z.object({
+ nextAiringEpisode: z.nullable(
+ z.object({
+ episode: z.number(),
+ airingAt: z.number(),
+ timeUntilAiring: z.number(),
+ }),
+ ),
+ mediaListEntry: z.nullable(
+ z.object({
+ status: z.nullable(
+ z.enum([
+ "CURRENT",
+ "PLANNING",
+ "COMPLETED",
+ "DROPPED",
+ "PAUSED",
+ "REPEATING",
+ ]),
+ ),
+ progress: z.number().nullable(),
+ id: z.number(),
+ }),
+ ),
+ countryOfOrigin: z.optional(countryCodeSchema),
+ coverImage: z.nullable(
+ z.object({
+ medium: z.nullable(z.string()).optional(),
+ large: z.nullable(z.string()).optional(),
+ extraLarge: z.nullable(z.string()).optional(),
+ }),
+ ),
+ averageScore: z.number().nullable(),
+ bannerImage: z.nullable(z.string()),
+ status: z.nullable(
+ z.enum([
+ "FINISHED",
+ "RELEASING",
+ "NOT_YET_RELEASED",
+ "CANCELLED",
+ "HIATUS",
+ ]),
+ ),
+ genres: z.nullable(z.array(z.nullable(z.string()))),
+ episodes: z.number().nullable(),
+ description: z.nullable(z.string()),
+ title: z.nullable(
+ z.object({
+ userPreferred: z.nullable(z.string()),
+ english: z.nullable(z.string()),
+ }),
+ ),
+ idMal: z.number().nullable(),
+ id: z.number(),
+});
diff --git a/tsconfig.json b/tsconfig.json
index 0a09870..6043be9 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,6 @@
{
"compilerOptions": {
- "types": ["@cloudflare/workers-types"],
+ "types": ["@cloudflare/workers-types", "@types/bun"],
"baseUrl": "./",
"paths": {
"~/*": ["src/*"]
@@ -25,6 +25,15 @@
// Some stricter flags
"noUnusedLocals": true,
"noUnusedParameters": false,
- "noPropertyAccessFromIndexSignature": true
+ "noPropertyAccessFromIndexSignature": true,
+
+ // plugins
+ "plugins": [
+ {
+ "name": "@0no-co/graphqlsp",
+ "schema": "https://graphql.anilist.co",
+ "tadaOutputLocation": "./src/types/anilist-graphql.d.ts"
+ }
+ ]
}
}