feat: create route to fetch stream URL from provider
Summary: Test Plan:
This commit is contained in:
74
src/controllers/episodes/getEpisodeUrl/anify.ts
Normal file
74
src/controllers/episodes/getEpisodeUrl/anify.ts
Normal 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;
|
||||
}
|
||||
53
src/controllers/episodes/getEpisodeUrl/index.spec.ts
Normal file
53
src/controllers/episodes/getEpisodeUrl/index.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
85
src/controllers/episodes/getEpisodeUrl/index.ts
Normal file
85
src/controllers/episodes/getEpisodeUrl/index.ts
Normal 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;
|
||||
9
src/controllers/episodes/getEpisodeUrl/priorities.ts
Normal file
9
src/controllers/episodes/getEpisodeUrl/priorities.ts
Normal 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 };
|
||||
13
src/controllers/episodes/getEpisodeUrl/responseType.ts
Normal file
13
src/controllers/episodes/getEpisodeUrl/responseType.ts
Normal 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(),
|
||||
});
|
||||
Reference in New Issue
Block a user