diff --git a/src/controllers/episodes/getEpisodeUrl/anify.ts b/src/controllers/episodes/getEpisodeUrl/anify.ts new file mode 100644 index 0000000..0040c54 --- /dev/null +++ b/src/controllers/episodes/getEpisodeUrl/anify.ts @@ -0,0 +1,74 @@ +import { sortByProperty } from "~/libs/sortByProperty"; + +import { + audioPriority, + qualityPriority, + subtitlesPriority, +} from "./priorities"; +import type { FetchUrlResponse } from "./responseType"; + +export async function getSourcesFromAnify( + provider: string, + watchId: string, + aniListId: number, +): Promise { + const response = await fetch("https://api.anify.tv/sources", { + body: JSON.stringify({ + watchId, + providerId: provider, + episodeNumber: "1", + id: aniListId.toString(), + subType: "sub", + }), + method: "POST", + }).then((res) => res.json() as Promise); + const { sources, subtitles, audio, intro, outro, headers } = response; + + if (!sources || sources.length === 0) { + return null; + } + + const source = sources.sort(sortByProperty(qualityPriority, "quality"))[0] + ?.url; + subtitles?.sort(sortByProperty(subtitlesPriority, "lang")); + audio?.sort(sortByProperty(audioPriority, "lang")); + + return { + source, + audio, + subtitles, + intro: + typeof intro?.start === "number" && typeof intro?.end === "number" + ? [intro.start, intro.end].map((seconds) => Math.floor(seconds)) + : undefined, + outro: + typeof outro?.start === "number" && typeof outro?.end === "number" + ? [outro.start, outro.end].map((seconds) => Math.floor(seconds)) + : undefined, + headers: Object.keys(headers ?? {}).length > 0 ? headers : undefined, + }; +} + +interface AnifySourcesResponse { + sources: VideoSource[]; + subtitles: LanguageSource[]; + audio: LanguageSource[]; + intro: SkipTime; + outro: SkipTime; + headers?: Record; +} + +interface SkipTime { + start: number; + end: number; +} + +interface VideoSource { + url: string; + quality: string; +} + +interface LanguageSource { + url: string; + lang: string; +} diff --git a/src/controllers/episodes/getEpisodeUrl/index.spec.ts b/src/controllers/episodes/getEpisodeUrl/index.spec.ts new file mode 100644 index 0000000..ab5d18f --- /dev/null +++ b/src/controllers/episodes/getEpisodeUrl/index.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "bun:test"; + +import app from "~/index"; +import { server } from "~/mocks"; +import { mockConsumet } from "~/mocks/consumet"; + +server.listen(); +mockConsumet(); + +describe('requests the "/episodes/:id/url" route', () => { + it("with sources from Anify", async () => { + const response = await app.request( + "/episodes/1/url", + { + method: "POST", + body: JSON.stringify({ + provider: "anify", + id: "/ore-dake-level-up-na-ken-episode-2", + }), + headers: { "Content-Type": "application/json" }, + }, + { + ENABLE_ANIFY: "true", + }, + ); + + expect(response.json()).resolves.toEqual({ + success: true, + result: { + source: + "https://proxy.anify.tv/video/8CLGIJg8G3k%252BH%252BYV9xyOYVGZ8al8uZqqtbXk44wKRco%252BGATkCrqlkgdRiam3owmOU4f2MAB89GOblOuZbxifwbGsjvp32uxhRC4kZVYrWnZmP%252FrLxtqwi0n6zY%252BvrffUh6dbg6DADSLCWhd2bNUUIg%253D%253D/%7B%7D/.m3u8", + subtitles: [], + audio: [], + intro: [0, 0], + outro: [0, 0], + }, + }); + }); + + it("with no URL from Anify source", async () => { + const response = await app.request("/episodes/-1/url", { + method: "POST", + body: JSON.stringify({ + provider: "anify", + id: "/ore-dake-level-up-na-ken-episode-2", + }), + headers: { "Content-Type": "application/json" }, + }); + + expect(response.json()).resolves.toEqual({ success: false }); + expect(response.status).toBe(404); + }); +}); diff --git a/src/controllers/episodes/getEpisodeUrl/index.ts b/src/controllers/episodes/getEpisodeUrl/index.ts new file mode 100644 index 0000000..97a3364 --- /dev/null +++ b/src/controllers/episodes/getEpisodeUrl/index.ts @@ -0,0 +1,85 @@ +import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; + +import type { Env } from "~/types/env"; +import { + ErrorResponse, + ErrorResponseSchema, + SuccessResponseSchema, +} from "~/types/schema"; + +import { getSourcesFromAnify } from "./anify"; +import { FetchUrlResponse as FetchUrlResponseSchema } from "./responseType"; + +const FetchUrlRequest = z.object({ id: z.string(), provider: z.string() }); + +const FetchUrlResponse = SuccessResponseSchema(FetchUrlResponseSchema); + +const route = createRoute({ + tags: ["aniplay", "episodes"], + summary: "Fetch stream URL for an episode", + operationId: "fetchStreamUrl", + method: "post", + path: "/{aniListId}/url", + request: { + body: { + content: { + "application/json": { + schema: FetchUrlRequest, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: FetchUrlResponse, + }, + }, + description: "Returns a stream URL", + }, + 404: { + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + description: "Provider did not return a source", + }, + 500: { + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + description: "Failed to fetch stream URL from provider", + }, + }, +}); + +const app = new OpenAPIHono(); + +app.openapi(route, async (c) => { + const aniListId = Number(c.req.param("aniListId")); + const { provider, id } = await c.req.json(); + + if (provider === "anify") { + try { + const result = await getSourcesFromAnify(provider, id, aniListId); + if (!result) { + return c.json({ success: false }, { status: 404 }); + } + + return c.json({ + success: true, + result, + }); + } catch (e) { + console.error("Failed to fetch download URL from Anify", e); + + return c.json(ErrorResponse, { status: 500 }); + } + } +}); + +export default app; diff --git a/src/controllers/episodes/getEpisodeUrl/priorities.ts b/src/controllers/episodes/getEpisodeUrl/priorities.ts new file mode 100644 index 0000000..0691c36 --- /dev/null +++ b/src/controllers/episodes/getEpisodeUrl/priorities.ts @@ -0,0 +1,9 @@ +export const qualityPriority = { + default: 1, + auto: 1, + backup: 2, + "1080p": 3, + "720p": 4, +}; +export const subtitlesPriority = { English: 1 }; +export const audioPriority = { Japanese: 1 }; diff --git a/src/controllers/episodes/getEpisodeUrl/responseType.ts b/src/controllers/episodes/getEpisodeUrl/responseType.ts new file mode 100644 index 0000000..78d046d --- /dev/null +++ b/src/controllers/episodes/getEpisodeUrl/responseType.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +import { SkippableSchema } from "~/types/schema"; + +export type FetchUrlResponse = z.infer; +export const FetchUrlResponse = z.object({ + source: z.string(), + subtitles: z.array(z.object({ url: z.string(), lang: z.string() })), + audio: z.array(z.object({ url: z.string(), lang: z.string() })), + intro: SkippableSchema, + outro: SkippableSchema, + headers: z.record(z.string()).optional(), +}); diff --git a/src/controllers/episodes/index.ts b/src/controllers/episodes/index.ts new file mode 100644 index 0000000..ce33e0f --- /dev/null +++ b/src/controllers/episodes/index.ts @@ -0,0 +1,14 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; + +const app = new OpenAPIHono(); + +app.route( + "/", + await import("./getByAniListId").then((controller) => controller.default), +); +app.route( + "/", + await import("./getEpisodeUrl").then((controller) => controller.default), +); + +export default app; diff --git a/src/index.ts b/src/index.ts index f7de466..4e561d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,7 @@ app.route( ); app.route( "/episodes", - await import("~/controllers/episodes/getByAniListId").then( + await import("~/controllers/episodes").then( (controller) => controller.default, ), ); diff --git a/src/mocks/anify/sources.ts b/src/mocks/anify/sources.ts new file mode 100644 index 0000000..abf72bc --- /dev/null +++ b/src/mocks/anify/sources.ts @@ -0,0 +1,46 @@ +import { HttpResponse, http } from "msw"; + +export function getAnifySources() { + return http.post("https://api.anify.tv/sources", async ({ request }) => { + const { id: aniListId } = await request.json(); + if (aniListId < 0) { + return HttpResponse.json({ sources: [] }); + } + + return HttpResponse.json({ + sources: [ + { + url: "https://proxy.anify.tv/video/jCB57RSXMJNw%252Bl%252F7FyBhTJgxyu4fxWq%252BaNKwhio1LIFFWpAYK7%252F8XSh%252BAuGkDcb9ncmrm8yVcsjzS1idTV1sEjbb0BtANg2FkrmhfZi4%252Bgg%252F1JfCmyBOq9QkhiZYHedLzHQ8Q6aQc2riLeYsblZY7Kgw%252Filz%252BitXh1tUI97Qd1k%253D/%7B%7D/.m3u8", + quality: "360p", + }, + { + url: "https://proxy.anify.tv/video/Yo7Z6i%252FaG8OYgX8PODTiATrhzRg640USqkzuH1RalwnianjLBAQnbcW3XxVqci8EZw3f6Ui%252FbBC2BpJUOpqLmHOr8GEK%252BRCAvdbXfQ8m5iip%252FWzmMrYp5tcOE6kcFcrPwm1DGNMhz%252BqX3k1Je8QbiuFofSBsCTfmh83vy4uUBhc%253D/%7B%7D/.m3u8", + quality: "480p", + }, + { + url: "https://proxy.anify.tv/video/cqJw05VAzYMnw721FBjS2LG4BTFvwPYYQz9BxZmCy0ZbDMyD4tJGg%252BmsZonVvfDEb%252BL65I8Y9YNCMKB%252BRYkIvpTy9n1dNGp3sTWXk6%252F3nAlhbR8h8iPjbHqaurUhmw5CCV4Po%252BPQuRFubkWdQG2h0n7GqQrv6tn6FfbcoasDiSM%253D/%7B%7D/.m3u8", + quality: "720p", + }, + { + url: "https://proxy.anify.tv/video/MZQCOq%252Baw9w6ywreT8qXviX%252B%252B%252Bhisr%252Bp8qWdyEaCphHla9y%252F4afGVnnObG50pzlK8Km7og6l6v68EKKunByKexiLTivV7oOYMklcZL2Dq3wPleeicg93olUBmztLEvwWWLP8nemmEjy%252BcUBhxaSreVJYzOJpH84hSC7glHsOXig%253D/%7B%7D/.m3u8", + quality: "1080p", + }, + { + url: "https://proxy.anify.tv/video/8CLGIJg8G3k%252BH%252BYV9xyOYVGZ8al8uZqqtbXk44wKRco%252BGATkCrqlkgdRiam3owmOU4f2MAB89GOblOuZbxifwbGsjvp32uxhRC4kZVYrWnZmP%252FrLxtqwi0n6zY%252BvrffUh6dbg6DADSLCWhd2bNUUIg%253D%253D/%7B%7D/.m3u8", + quality: "default", + }, + ], + subtitles: [], + audio: [], + intro: { + start: 0, + end: 0, + }, + outro: { + start: 0, + end: 0, + }, + headers: {}, + }); + }); +} diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index ae0b743..ec6d4d7 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -2,6 +2,7 @@ import { getAmvstrmEpisodes } from "./amvstrm/episodes"; import { getAmvstrmSearchResults } from "./amvstrm/search"; import { getAmvstrmTitle } from "./amvstrm/title"; import { getAnifyEpisodes } from "./anify/episodes"; +import { getAnifySources } from "./anify/sources"; import { getAnifyTitle } from "./anify/title"; import { getAnilistSearchResults } from "./anilist/search"; import { getAnilistTitle } from "./anilist/title"; @@ -13,5 +14,6 @@ export const handlers = [ getAmvstrmSearchResults(), getAmvstrmTitle(), getAnifyEpisodes(), + getAnifySources(), getAnifyTitle(), ]; diff --git a/src/types/schema.ts b/src/types/schema.ts index 23ee052..90eed54 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -38,3 +38,8 @@ export const EpisodeNumberSchema = z.number().openapi({ type: "number", format: "float", }); + +export const SkippableSchema = z + .array(z.number().openapi({ minimum: 0, type: "integer", format: "int64" })) + .nullish() + .openapi({ examples: [[200, 289]], minItems: 2, maxItems: 2 });