diff --git a/src/controllers/episodes/getEpisodeUrl/amvstrm.ts b/src/controllers/episodes/getEpisodeUrl/amvstrm.ts new file mode 100644 index 0000000..58d4c72 --- /dev/null +++ b/src/controllers/episodes/getEpisodeUrl/amvstrm.ts @@ -0,0 +1,75 @@ +import type { FetchUrlResponse } from "./responseType"; + +export async function getSourcesFromAmvstrm( + watchId: string, +): Promise { + const source = await fetch( + `https://api-amvstrm.nyt92.eu.org/api/v2/stream/${watchId}`, + ) + .then((res) => res.json()) + .then(({ stream }) => { + const streamObj = stream?.multi; + if (!!streamObj) { + return streamObj.main ?? streamObj.backup; + } + }) + .then((streamObj) => streamObj?.url); + + if (!source) { + return null; + } + + return { + source, + subtitles: [], + audio: [], + }; +} + +interface AmvstrmStreamResponse { + code: number; + message: string; + info: Info; + stream: Stream; + iframe: Iframe[]; + plyr: Nspl; + nspl: Nspl; +} + +interface Iframe { + name: string; + iframe: string; +} + +interface Info { + title: string; + id: string; + episode: string; +} + +interface Nspl { + main: string; + backup: string; +} + +interface Stream { + multi: Multi; + tracks: Tracks; +} + +interface Multi { + main: Backup; + backup: Backup; +} + +interface Backup { + url: string; + label: string; + isM3U8: boolean; + quality: string; +} + +interface Tracks { + file: string; + kind: string; +} diff --git a/src/controllers/episodes/getEpisodeUrl/index.spec.ts b/src/controllers/episodes/getEpisodeUrl/index.spec.ts index 645b8fb..ec67324 100644 --- a/src/controllers/episodes/getEpisodeUrl/index.spec.ts +++ b/src/controllers/episodes/getEpisodeUrl/index.spec.ts @@ -90,4 +90,45 @@ describe('requests the "/episodes/:id/url" route', () => { expect(response.json()).resolves.toEqual({ success: false }); expect(response.status).toBe(404); }); + + it("with sources from Amvstrm", async () => { + const response = await app.request( + "/episodes/1/url", + { + method: "POST", + body: JSON.stringify({ + provider: "amvstrm", + 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://www032.vipanicdn.net/streamhls/aa804a2400535d84dd59454b28d329fb/ep.1.1712504065.m3u8", + subtitles: [], + audio: [], + }, + }); + }); + + it("with no URL from Amvstrm source", async () => { + const response = await app.request("/episodes/-1/url", { + method: "POST", + body: JSON.stringify({ + provider: "amvstrm", + id: "unknown", + }), + 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 index 5066f27..c59d278 100644 --- a/src/controllers/episodes/getEpisodeUrl/index.ts +++ b/src/controllers/episodes/getEpisodeUrl/index.ts @@ -112,6 +112,26 @@ app.openapi(route, async (c) => { } } + if (provider === "amvstrm") { + try { + const result = await import("./amvstrm").then( + ({ getSourcesFromAmvstrm }) => getSourcesFromAmvstrm(id), + ); + 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 Amvstrm", e); + + return c.json(ErrorResponse, { status: 500 }); + } + } + return c.json(ErrorResponse, { status: 400 }); }); diff --git a/src/mocks/amvstrm/sources.ts b/src/mocks/amvstrm/sources.ts new file mode 100644 index 0000000..c15965c --- /dev/null +++ b/src/mocks/amvstrm/sources.ts @@ -0,0 +1,77 @@ +import { HttpResponse, http } from "msw"; + +export function getAmvstrmSources() { + return http.get( + "https://api-amvstrm.nyt92.eu.org/api/v2/stream/:id", + ({ params }) => { + const { id } = params; + + if (id === "unknown") { + return HttpResponse.json( + { + code: 404, + message: + "The requested resource could not be found but may be available in the future. Subsequent requests by the client are permissible.", + }, + { status: 404 }, + ); + } + + return HttpResponse.json({ + code: 200, + message: "success", + info: { + title: "Mushoku Tensei II: Isekai Ittara Honki Dasu Part 2", + id: "mushoku-tensei-ii-isekai-ittara-honki-dasu-part-2", + episode: "1", + }, + stream: { + multi: { + main: { + url: "https://www032.vipanicdn.net/streamhls/aa804a2400535d84dd59454b28d329fb/ep.1.1712504065.m3u8", + label: "hls P", + isM3U8: true, + quality: "default", + }, + backup: { + url: "https://www032.anicdnstream.info/videos/hls/6Ogzt4UOJPbzciJM8EJvgg/1717137410/223419/aa804a2400535d84dd59454b28d329fb/ep.1.1712504065.m3u8", + label: "hls P", + isM3U8: true, + quality: "backup", + }, + }, + tracks: "", + }, + iframe: [ + { + name: "Multiquality Server", + iframe: + "https://embtaku.com/embedplus?id=MjIzNDE5&token=dvjcF3MKtKBIeAe7rQhIpw&expires=1717137409", + }, + { + name: "Streamwish", + iframe: "https://awish.pro/e/nr6ogony8osz", + }, + { + name: "Doodstream", + iframe: "https://dood.wf/e/4g6gt8yygdnz", + }, + { + name: "Mp4upload", + iframe: "https://www.mp4upload.com/embed-3dshuf4wf6md.html", + }, + ], + plyr: { + main: "https://plyr.link/p/player.html#aHR0cHM6Ly93d3cwMzIudmlwYW5pY2RuLm5ldC9zdHJlYW1obHMvYWE4MDRhMjQwMDUzNWQ4NGRkNTk0NTRiMjhkMzI5ZmIvZXAuMS4xNzEyNTA0MDY1Lm0zdTg=", + backup: + "https://plyr.link/p/player.html#aHR0cHM6Ly93d3cwMzIuYW5pY2Ruc3RyZWFtLmluZm8vdmlkZW9zL2hscy82T2d6dDRVT0pQYnpjaUpNOEVKdmdnLzE3MTcxMzc0MTAvMjIzNDE5L2FhODA0YTI0MDA1MzVkODRkZDU5NDU0YjI4ZDMyOWZiL2VwLjEuMTcxMjUwNDA2NS5tM3U4", + }, + nspl: { + main: "https://nspl.nyt92.eu.org/player?p=JnRpdGxlPW11c2hva3UtdGVuc2VpLWlpLWlzZWthaS1pdHRhcmEtaG9ua2ktZGFzdS1wYXJ0LTItZXBpc29kZS0xJmZpbGU9aHR0cHM6Ly93d3cwMzIudmlwYW5pY2RuLm5ldC9zdHJlYW1obHMvYWE4MDRhMjQwMDUzNWQ4NGRkNTk0NTRiMjhkMzI5ZmIvZXAuMS4xNzEyNTA0MDY1Lm0zdTgmdGh1bWJuYWlscz11bmRlZmluZWQ=", + backup: + "https://nspl.nyt92.eu.org/player?p=JnRpdGxlPW11c2hva3UtdGVuc2VpLWlpLWlzZWthaS1pdHRhcmEtaG9ua2ktZGFzdS1wYXJ0LTItZXBpc29kZS0xJmZpbGU9aHR0cHM6Ly93d3cwMzIuYW5pY2Ruc3RyZWFtLmluZm8vdmlkZW9zL2hscy82T2d6dDRVT0pQYnpjaUpNOEVKdmdnLzE3MTcxMzc0MTAvMjIzNDE5L2FhODA0YTI0MDA1MzVkODRkZDU5NDU0YjI4ZDMyOWZiL2VwLjEuMTcxMjUwNDA2NS5tM3U4JnRodW1ibmFpbHM9dW5kZWZpbmVk", + }, + }); + }, + ); +} diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index ec6d4d7..adfc3d9 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,5 +1,6 @@ import { getAmvstrmEpisodes } from "./amvstrm/episodes"; import { getAmvstrmSearchResults } from "./amvstrm/search"; +import { getAmvstrmSources } from "./amvstrm/sources"; import { getAmvstrmTitle } from "./amvstrm/title"; import { getAnifyEpisodes } from "./anify/episodes"; import { getAnifySources } from "./anify/sources"; @@ -11,6 +12,7 @@ export const handlers = [ getAnilistSearchResults(), getAnilistTitle(), getAmvstrmEpisodes(), + getAmvstrmSources(), getAmvstrmSearchResults(), getAmvstrmTitle(), getAnifyEpisodes(), diff --git a/src/types/anilist-graphql.d.ts b/src/types/anilist-graphql.d.ts index 926d3ec..f2b3039 100644 --- a/src/types/anilist-graphql.d.ts +++ b/src/types/anilist-graphql.d.ts @@ -1,5 +1,3 @@ -import * as gqlTada from "gql.tada"; - /* eslint-disable */ /* prettier-ignore */ @@ -205,8 +203,10 @@ export type introspection = { }; }; -declare module "gql.tada" { +import * as gqlTada from 'gql.tada'; + +declare module 'gql.tada' { interface setupSchema { - introspection: introspection; + introspection: introspection } -} +} \ No newline at end of file