feat: create route to fetch stream URL from provider

Summary:

Test Plan:
This commit is contained in:
2024-05-30 08:44:20 -04:00
parent 63cb1b26d9
commit 7aab9a19ec
10 changed files with 302 additions and 1 deletions

View File

@@ -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<FetchUrlResponse | null> {
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<AnifySourcesResponse>);
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<string, string>;
}
interface SkipTime {
start: number;
end: number;
}
interface VideoSource {
url: string;
quality: string;
}
interface LanguageSource {
url: string;
lang: string;
}

View File

@@ -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);
});
});

View File

@@ -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<Env>();
app.openapi(route, async (c) => {
const aniListId = Number(c.req.param("aniListId"));
const { provider, id } = await c.req.json<typeof FetchUrlRequest._type>();
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;

View File

@@ -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 };

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
import { SkippableSchema } from "~/types/schema";
export type FetchUrlResponse = z.infer<typeof FetchUrlResponse>;
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(),
});

View File

@@ -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;

View File

@@ -15,7 +15,7 @@ app.route(
); );
app.route( app.route(
"/episodes", "/episodes",
await import("~/controllers/episodes/getByAniListId").then( await import("~/controllers/episodes").then(
(controller) => controller.default, (controller) => controller.default,
), ),
); );

View File

@@ -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: {},
});
});
}

View File

@@ -2,6 +2,7 @@ import { getAmvstrmEpisodes } from "./amvstrm/episodes";
import { getAmvstrmSearchResults } from "./amvstrm/search"; import { getAmvstrmSearchResults } from "./amvstrm/search";
import { getAmvstrmTitle } from "./amvstrm/title"; import { getAmvstrmTitle } from "./amvstrm/title";
import { getAnifyEpisodes } from "./anify/episodes"; import { getAnifyEpisodes } from "./anify/episodes";
import { getAnifySources } from "./anify/sources";
import { getAnifyTitle } from "./anify/title"; import { getAnifyTitle } from "./anify/title";
import { getAnilistSearchResults } from "./anilist/search"; import { getAnilistSearchResults } from "./anilist/search";
import { getAnilistTitle } from "./anilist/title"; import { getAnilistTitle } from "./anilist/title";
@@ -13,5 +14,6 @@ export const handlers = [
getAmvstrmSearchResults(), getAmvstrmSearchResults(),
getAmvstrmTitle(), getAmvstrmTitle(),
getAnifyEpisodes(), getAnifyEpisodes(),
getAnifySources(),
getAnifyTitle(), getAnifyTitle(),
]; ];

View File

@@ -38,3 +38,8 @@ export const EpisodeNumberSchema = z.number().openapi({
type: "number", type: "number",
format: "float", 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 });