diff --git a/src/controllers/episodes/anify.ts b/src/controllers/episodes/anify.ts new file mode 100644 index 0000000..0c1e0eb --- /dev/null +++ b/src/controllers/episodes/anify.ts @@ -0,0 +1,110 @@ +import { sortByProperty } from "~/libs/sortByProperty"; + +import type { EpisodesResponse } from "./episode"; + +export async function getEpisodesFromAnify( + isAnifyEnabled: boolean, + aniListId: number, +): Promise { + if (shouldSkipAnify(isAnifyEnabled, aniListId)) { + return null; + } + + let response: AnifyEpisodesResponse[] | null = null; + try { + const abortController = new AbortController(); + response = await Promise.race([ + fetch(`https://api.anify.tv/episodes/${aniListId}`, { + signal: abortController.signal, + }).then((res) => res.json() as Promise), + // set a limit of 30 seconds + new Promise((resolve) => setTimeout(resolve, 30 * 1000)).then(() => { + abortController.abort("Loading episodes from Anify timed out"); + console.error( + `Loading episodes from Anify timed out; aniListId: ${aniListId}`, + ); + return null; + }), + ]); + } catch (e) { + console.error( + new Error( + `Error trying to load episodes from anify; aniListId: ${aniListId}`, + { cause: e }, + ), + ); + } + + if (!response || response.length === 0) { + return null; + } + + const sourcePriority = { + gogoanime: 1, + }; + const filteredEpisodesData = response + .filter(({ providerId }) => { + if (providerId === "9anime") { + return false; + } + if (aniListId == 166873 && providerId === "zoro") { + // Mushoku Tensei: Job Reincarnation S2 Part 2 returns incorrect mapping for Zoro only + return false; + } + + return true; + }) + .sort(sortByProperty(sourcePriority, "providerId")); + + const selectedEpisodeData = filteredEpisodesData[0]; + return { + providerId: selectedEpisodeData.providerId, + episodes: selectedEpisodeData.episodes.map( + ({ id, number, description, img, rating, title, updatedAt }) => ({ + id, + number, + description, + img, + rating, + title, + updatedAt: updatedAt ?? 0, + }), + ), + }; +} + +export function shouldSkipAnify( + isAnifyEnabled: boolean, + aniListId: number, +): boolean { + if (!isAnifyEnabled) { + return true; + } + + // Some mappings on Anify are incorrect so they return episodes from a similar title + if ( + [ + 158927, // Spy x Family S2 + 166873, // Mushoku Tensei: Jobless Reincarnation S2 part 2 + ].includes(aniListId) + ) { + return true; + } + + return false; +} + +interface AnifyEpisodesResponse { + providerId: string; + episodes: { + id: string; + isFiller: boolean | undefined; + number: number; + title: string; + img: string | null; + hasDub: boolean; + description: string | null; + rating: number | null; + updatedAt: number | undefined; + }[]; +} diff --git a/src/controllers/episodes/episode.ts b/src/controllers/episodes/episode.ts new file mode 100644 index 0000000..b85332c --- /dev/null +++ b/src/controllers/episodes/episode.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +import { EpisodeNumberSchema } from "~/types/schema"; + +export type Episode = z.infer; +export const Episode = z.object({ + id: z.string(), + number: EpisodeNumberSchema, + title: z.string().nullish(), + img: z.string().nullish(), + description: z.string().nullish(), + rating: z.number().int().nullish(), + updatedAt: z.number().int().default(0).openapi({ format: "int64" }), +}); + +export type EpisodesResponse = z.infer; +export const EpisodesResponse = z.object({ + providerId: z.string(), + episodes: z.array(Episode), +}); diff --git a/src/controllers/episodes/index.spec.ts b/src/controllers/episodes/index.spec.ts new file mode 100644 index 0000000..8ff8676 --- /dev/null +++ b/src/controllers/episodes/index.spec.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "bun:test"; + +import app from "~/index"; +import { server } from "~/mocks"; + +server.listen(); + +describe('requests the "/episodes" route', () => { + it("with list of episodes from Anify", async () => { + const response = await app.request( + "/episodes/1", + {}, + { + ENABLE_ANIFY: "true", + }, + ); + + expect(response.json()).resolves.toEqual({ + success: true, + result: { + providerId: "zoro", + episodes: [ + { + id: "/watch/spy-classroom-season-2-18468?ep=103233", + number: 1, + description: null, + img: null, + rating: null, + title: "Mission: Forgetter I", + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=103632", + number: 2, + description: null, + img: null, + rating: null, + title: "Mission: Forgetter II", + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=104244", + number: 3, + description: null, + img: null, + rating: null, + title: "Mission: Forgetter III", + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=104620", + number: 4, + description: null, + img: null, + rating: null, + title: "Mission: Forgetter IV", + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=104844", + number: 5, + description: null, + img: null, + rating: null, + title: "File: Glint", + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=105761", + number: 6, + description: null, + img: null, + rating: null, + title: "File: Dreamspeaker Thea", + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=106135", + number: 7, + description: null, + img: null, + rating: null, + title: "File: Forgetter Annette", + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=106518", + number: 8, + description: null, + img: null, + rating: null, + title: "Mission: Dreamspeaker I", + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=106606", + number: 9, + description: null, + img: null, + rating: null, + title: "Mission: Dreamspeaker II", + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=106981", + number: 10, + description: null, + img: null, + rating: null, + title: "Mission: Dreamspeaker III", + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=107176", + number: 11, + description: null, + img: null, + rating: null, + title: "Mission: Dreamspeaker IV", + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=107247", + number: 12, + description: null, + img: null, + rating: null, + title: "File: Flower Garden Lily", + updatedAt: 0, + }, + ], + }, + }); + }); + + it("Anify ID filtered out, returns no episode list from Anify", async () => { + const response = await app.request( + "/episodes/158927", + {}, + { + ENABLE_ANIFY: "true", + }, + ); + + expect(response.json()).resolves.toEqual({ success: false }); + expect(response.status).toBe(404); + }); + + it("Anify is disabled, returns no episode list from Anify", async () => { + const response = await app.request( + "/episodes/1", + {}, + { + ENABLE_ANIFY: "false", + }, + ); + + expect(response.json()).resolves.toEqual({ success: false }); + expect(response.status).toBe(404); + }); + + 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/episodes/index.ts b/src/controllers/episodes/index.ts new file mode 100644 index 0000000..9e16a70 --- /dev/null +++ b/src/controllers/episodes/index.ts @@ -0,0 +1,63 @@ +import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; + +import { fetchFromMultipleSources } from "~/libs/fetchFromMultipleSources"; +import { + AniListIdQuerySchema, + ErrorResponse, + ErrorResponseSchema, + SuccessResponseSchema, +} from "~/types/schema"; + +import { getEpisodesFromAnify } from "./anify"; +import { EpisodesResponse } from "./episode"; + +const EpisodesResponseSchema = SuccessResponseSchema(EpisodesResponse); + +const route = createRoute({ + tags: ["aniplay", "episodes"], + summary: "Fetch episodes for a title", + method: "get", + path: "/{aniListId}", + request: { + params: z.object({ aniListId: AniListIdQuerySchema }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: EpisodesResponseSchema, + }, + }, + description: "Returns a list of episodes", + }, + 404: { + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + description: "Returns an empty list because episodes not found", + }, + }, +}); + +const app = new OpenAPIHono(); + +app.openapi(route, async (c) => { + const aniListId = Number(c.req.param("aniListId")); + + const episodes = await fetchFromMultipleSources([ + () => getEpisodesFromAnify(JSON.parse(c.env.ENABLE_ANIFY), aniListId), + ]); + + if (!episodes) { + return c.json(ErrorResponse, { status: 404 }); + } + + return c.json({ + success: true, + result: episodes, + }); +}); + +export default app; diff --git a/src/controllers/search/index.ts b/src/controllers/search/index.ts index 0727ca4..9c2b67f 100644 --- a/src/controllers/search/index.ts +++ b/src/controllers/search/index.ts @@ -2,7 +2,6 @@ import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; import { fetchFromMultipleSources } from "~/libs/fetchFromMultipleSources"; import { PaginatedResponseSchema } from "~/types/schema"; -import { Title } from "~/types/title"; import { fetchSearchResultsFromAmvstrm } from "./amvstrm"; import { fetchSearchResultsFromAnilist } from "./anilist"; diff --git a/src/index.ts b/src/index.ts index 4babaed..4e561d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,12 @@ app.route( "/title", await import("~/controllers/title").then((controller) => controller.default), ); +app.route( + "/episodes", + await import("~/controllers/episodes").then( + (controller) => controller.default, + ), +); app.route( "/search", await import("~/controllers/search").then((controller) => controller.default), diff --git a/src/mocks/anify/episodes.ts b/src/mocks/anify/episodes.ts new file mode 100644 index 0000000..5d0daab --- /dev/null +++ b/src/mocks/anify/episodes.ts @@ -0,0 +1,145 @@ +import { HttpResponse, http } from "msw"; + +export function getAnifyEpisodes() { + return http.get("https://api.anify.tv/episodes/:aniListId", () => { + return HttpResponse.json([ + { + providerId: "zoro", + episodes: [ + { + id: "/watch/spy-classroom-season-2-18468?ep=103233", + isFiller: false, + number: 1, + title: "Mission: Forgetter I", + img: null, + hasDub: false, + description: null, + rating: null, + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=103632", + isFiller: false, + number: 2, + title: "Mission: Forgetter II", + img: null, + hasDub: false, + description: null, + rating: null, + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=104244", + isFiller: false, + number: 3, + title: "Mission: Forgetter III", + img: null, + hasDub: false, + description: null, + rating: null, + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=104620", + isFiller: false, + number: 4, + title: "Mission: Forgetter IV", + img: null, + hasDub: false, + description: null, + rating: null, + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=104844", + isFiller: false, + number: 5, + title: "File: Glint", + img: null, + hasDub: false, + description: null, + rating: null, + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=105761", + isFiller: false, + number: 6, + title: "File: Dreamspeaker Thea", + img: null, + hasDub: false, + description: null, + rating: null, + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=106135", + isFiller: false, + number: 7, + title: "File: Forgetter Annette", + img: null, + hasDub: false, + description: null, + rating: null, + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=106518", + isFiller: false, + number: 8, + title: "Mission: Dreamspeaker I", + img: null, + hasDub: false, + description: null, + rating: null, + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=106606", + isFiller: false, + number: 9, + title: "Mission: Dreamspeaker II", + img: null, + hasDub: false, + description: null, + rating: null, + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=106981", + isFiller: false, + number: 10, + title: "Mission: Dreamspeaker III", + img: null, + hasDub: false, + description: null, + rating: null, + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=107176", + isFiller: false, + number: 11, + title: "Mission: Dreamspeaker IV", + img: null, + hasDub: false, + description: null, + rating: null, + updatedAt: 0, + }, + { + id: "/watch/spy-classroom-season-2-18468?ep=107247", + isFiller: false, + number: 12, + title: "File: Flower Garden Lily", + img: null, + hasDub: false, + description: null, + rating: null, + updatedAt: 0, + }, + ], + }, + ]); + }); +} diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 7dad273..445eda8 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,5 +1,6 @@ import { getAmvstrmSearchResults } from "./amvstrm/search"; import { getAmvstrmTitle } from "./amvstrm/title"; +import { getAnifyEpisodes } from "./anify/episodes"; import { getAnifyTitle } from "./anify/title"; import { getAnilistSearchResults } from "./anilist/search"; import { getAnilistTitle } from "./anilist/title"; @@ -9,5 +10,6 @@ export const handlers = [ getAnilistTitle(), getAmvstrmSearchResults(), getAmvstrmTitle(), + getAnifyEpisodes(), getAnifyTitle(), ]; diff --git a/src/types/anilist-graphql.d.ts b/src/types/anilist-graphql.d.ts index 926d3ec..f2b3039 100644 --- a/src/types/anilist-graphql.d.ts +++ b/src/types/anilist-graphql.d.ts @@ -1,5 +1,3 @@ -import * as gqlTada from "gql.tada"; - /* eslint-disable */ /* prettier-ignore */ @@ -205,8 +203,10 @@ export type introspection = { }; }; -declare module "gql.tada" { +import * as gqlTada from 'gql.tada'; + +declare module 'gql.tada' { interface setupSchema { - introspection: introspection; + introspection: introspection } -} +} \ No newline at end of file diff --git a/src/types/env.ts b/src/types/env.ts new file mode 100644 index 0000000..bd4bf00 --- /dev/null +++ b/src/types/env.ts @@ -0,0 +1,5 @@ +import type { Env as HonoEnv } from "hono"; + +export interface Env extends HonoEnv { + ENABLE_ANIFY: string; +} diff --git a/src/types/schema.ts b/src/types/schema.ts index b1daac6..23ee052 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -19,6 +19,7 @@ export const PaginatedResponseSchema = (schema: T) => { }); }; +export const ErrorResponse = { success: false } as const; export const ErrorResponseSchema = z.object({ success: z.literal(false).openapi({ type: "boolean" }), }); @@ -29,3 +30,11 @@ export const AniListIdSchema = z.number().int().openapi({ format: "int64" }); export const AniListIdQuerySchema = z .string() .openapi({ type: "integer", format: "int64" }); + +export const EpisodeNumberSchema = z.number().openapi({ + minimum: 0, + multipleOf: 0.5, + examples: [1, 2, 3.5], + type: "number", + format: "float", +});