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(),
|
||||||
|
});
|
||||||
14
src/controllers/episodes/index.ts
Normal file
14
src/controllers/episodes/index.ts
Normal 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;
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
46
src/mocks/anify/sources.ts
Normal file
46
src/mocks/anify/sources.ts
Normal 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: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user