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(),
});