diff --git a/src/backend/embeds/mp4upload.ts b/src/backend/embeds/mp4upload.ts new file mode 100644 index 00000000..3902e20b --- /dev/null +++ b/src/backend/embeds/mp4upload.ts @@ -0,0 +1,32 @@ +import { MWEmbedType } from "@/backend/helpers/embed"; +import { registerEmbedScraper } from "@/backend/helpers/register"; +import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; + +import { proxiedFetch } from "../helpers/fetch"; + +registerEmbedScraper({ + id: "mp4upload", + displayName: "mp4upload", + for: MWEmbedType.MP4UPLOAD, + rank: 170, + async getStream({ url }) { + const embed = await proxiedFetch(url); + + const playerSrcRegex = + /(?<=player\.src\()\s*{\s*type:\s*"[^"]+",\s*src:\s*"([^"]+)"\s*}\s*(?=\);)/s; + + const playerSrc = embed.match(playerSrcRegex); + + const streamUrl = playerSrc[1]; + + if (!streamUrl) throw new Error("Stream url not found"); + + return { + embedId: MWEmbedType.MP4UPLOAD, + streamUrl, + quality: MWStreamQuality.Q1080P, + captions: [], + type: MWStreamType.MP4, + }; + }, +}); diff --git a/src/backend/embeds/streamsb.ts b/src/backend/embeds/streamsb.ts new file mode 100644 index 00000000..e91b43c7 --- /dev/null +++ b/src/backend/embeds/streamsb.ts @@ -0,0 +1,211 @@ +import Base64 from "crypto-js/enc-base64"; +import Utf8 from "crypto-js/enc-utf8"; + +import { MWEmbedType } from "@/backend/helpers/embed"; +import { proxiedFetch } from "@/backend/helpers/fetch"; +import { registerEmbedScraper } from "@/backend/helpers/register"; +import { + MWCaptionType, + MWStreamQuality, + MWStreamType, +} from "@/backend/helpers/streams"; + +const qualityOrder = [ + MWStreamQuality.Q1080P, + MWStreamQuality.Q720P, + MWStreamQuality.Q480P, + MWStreamQuality.Q360P, +]; + +async function fetchCaptchaToken(domain: string, recaptchaKey: string) { + const domainHash = Base64.stringify(Utf8.parse(domain)).replace(/=/g, "."); + + const recaptchaRender = await proxiedFetch( + `https://www.google.com/recaptcha/api.js?render=${recaptchaKey}` + ); + + const vToken = recaptchaRender.substring( + recaptchaRender.indexOf("/releases/") + 10, + recaptchaRender.indexOf("/recaptcha__en.js") + ); + + const recaptchaAnchor = await proxiedFetch( + `https://www.google.com/recaptcha/api2/anchor?ar=1&hl=en&size=invisible&cb=flicklax&k=${recaptchaKey}&co=${domainHash}&v=${vToken}` + ); + + const cToken = new DOMParser() + .parseFromString(recaptchaAnchor, "text/html") + .getElementById("recaptcha-token") + ?.getAttribute("value"); + + if (!cToken) throw new Error("Unable to find cToken"); + + const payload = { + v: vToken, + reason: "q", + k: recaptchaKey, + c: cToken, + sa: "", + co: domain, + }; + + const tokenData = await proxiedFetch( + `https://www.google.com/recaptcha/api2/reload?${new URLSearchParams( + payload + ).toString()}`, + { + headers: { referer: "https://www.google.com/recaptcha/api2/" }, + method: "POST", + } + ); + + const token = tokenData.match('rresp","(.+?)"'); + return token ? token[1] : null; +} + +registerEmbedScraper({ + id: "streamsb", + displayName: "StreamSB", + for: MWEmbedType.STREAMSB, + rank: 150, + async getStream({ url, progress }) { + /* Url variations + - domain.com/{id}?.html + - domain.com/{id} + - domain.com/embed-{id} + - domain.com/d/{id} + - domain.com/e/{id} + - domain.com/e/{id}-embed + */ + const streamsbUrl = url + .replace(".html", "") + .replace("embed-", "") + .replace("e/", "") + .replace("d/", ""); + + const parsedUrl = new URL(streamsbUrl); + const base = await proxiedFetch( + `${parsedUrl.origin}/d${parsedUrl.pathname}` + ); + + progress(20); + + // Parse captions from url + const captionUrl = parsedUrl.searchParams.get("caption_1"); + const captionLang = parsedUrl.searchParams.get("sub_1"); + + const basePage = new DOMParser().parseFromString(base, "text/html"); + + const downloadVideoFunctions = basePage.querySelectorAll( + "[onclick^=download_video]" + ); + + let dlDetails = []; + for (const func of downloadVideoFunctions) { + const funcContents = func.getAttribute("onclick"); + const regExpFunc = /download_video\('(.+?)','(.+?)','(.+?)'\)/; + const matchesFunc = regExpFunc.exec(funcContents ?? ""); + if (matchesFunc !== null) { + const quality = func.querySelector("span")?.textContent; + const regExpQuality = /(.+?) \((.+?)\)/; + const matchesQuality = regExpQuality.exec(quality ?? ""); + if (matchesQuality !== null) { + dlDetails.push({ + parameters: [matchesFunc[1], matchesFunc[2], matchesFunc[3]], + quality: { + label: matchesQuality[1].trim(), + size: matchesQuality[2], + }, + }); + } + } + } + + dlDetails = dlDetails.sort((a, b) => { + const aQuality = qualityOrder.indexOf(a.quality.label as MWStreamQuality); + const bQuality = qualityOrder.indexOf(b.quality.label as MWStreamQuality); + return aQuality - bQuality; + }); + + progress(40); + + let dls = await Promise.all( + dlDetails.map(async (dl) => { + const getDownload = await proxiedFetch( + `/dl?op=download_orig&id=${dl.parameters[0]}&mode=${dl.parameters[1]}&hash=${dl.parameters[2]}`, + { + baseURL: parsedUrl.origin, + } + ); + + const downloadPage = new DOMParser().parseFromString( + getDownload, + "text/html" + ); + + const recaptchaKey = downloadPage + .querySelector(".g-recaptcha") + ?.getAttribute("data-sitekey"); + if (!recaptchaKey) throw new Error("Unable to get captcha key"); + + const captchaToken = await fetchCaptchaToken( + parsedUrl.origin, + recaptchaKey + ); + if (!captchaToken) throw new Error("Unable to get captcha token"); + + const dlForm = new FormData(); + dlForm.append("op", "download_orig"); + dlForm.append("id", dl.parameters[0]); + dlForm.append("mode", dl.parameters[1]); + dlForm.append("hash", dl.parameters[2]); + dlForm.append("g-recaptcha-response", captchaToken); + + const download = await proxiedFetch( + `/dl?op=download_orig&id=${dl.parameters[0]}&mode=${dl.parameters[1]}&hash=${dl.parameters[2]}`, + { + baseURL: parsedUrl.origin, + method: "POST", + body: dlForm, + } + ); + + const dlLink = new DOMParser() + .parseFromString(download, "text/html") + .querySelector(".btn.btn-light.btn-lg") + ?.getAttribute("href"); + + return { + quality: dl.quality.label as MWStreamQuality, + url: dlLink, + size: dl.quality.size, + captions: + captionUrl && captionLang + ? [ + { + url: captionUrl, + langIso: captionLang, + type: MWCaptionType.VTT, + }, + ] + : [], + }; + }) + ); + dls = dls.filter((d) => !!d.url); + + progress(60); + + // TODO: Quality selection for embed scrapers + const dl = dls[0]; + if (!dl.url) throw new Error("No stream url found"); + + return { + embedId: MWEmbedType.STREAMSB, + streamUrl: dl.url, + quality: dl.quality, + captions: dl.captions, + type: MWStreamType.MP4, + }; + }, +}); diff --git a/src/backend/embeds/upcloud.ts b/src/backend/embeds/upcloud.ts new file mode 100644 index 00000000..b2877bb3 --- /dev/null +++ b/src/backend/embeds/upcloud.ts @@ -0,0 +1,93 @@ +import { AES, enc } from "crypto-js"; + +import { MWEmbedType } from "@/backend/helpers/embed"; +import { registerEmbedScraper } from "@/backend/helpers/register"; +import { + MWCaptionType, + MWStreamQuality, + MWStreamType, +} from "@/backend/helpers/streams"; + +import { proxiedFetch } from "../helpers/fetch"; + +interface StreamRes { + server: number; + sources: string; + tracks: { + file: string; + kind: "captions" | "thumbnails"; + label: string; + }[]; +} + +function isJSON(json: string) { + try { + JSON.parse(json); + return true; + } catch { + return false; + } +} + +registerEmbedScraper({ + id: "upcloud", + displayName: "UpCloud", + for: MWEmbedType.UPCLOUD, + rank: 200, + async getStream({ url }) { + // Example url: https://dokicloud.one/embed-4/{id}?z= + const parsedUrl = new URL(url.replace("embed-5", "embed-4")); + + const dataPath = parsedUrl.pathname.split("/"); + const dataId = dataPath[dataPath.length - 1]; + + const streamRes = await proxiedFetch( + `${parsedUrl.origin}/ajax/embed-4/getSources?id=${dataId}`, + { + headers: { + Referer: parsedUrl.origin, + "X-Requested-With": "XMLHttpRequest", + }, + } + ); + + let sources: + | { + file: string; + type: string; + } + | string = streamRes.sources; + + if (!isJSON(sources) || typeof sources === "string") { + const decryptionKey = await proxiedFetch( + `https://raw.githubusercontent.com/enimax-anime/key/e4/key.txt` + ); + + const decryptedStream = AES.decrypt(sources, decryptionKey).toString( + enc.Utf8 + ); + + const parsedStream = JSON.parse(decryptedStream)[0]; + if (!parsedStream) throw new Error("No stream found"); + sources = parsedStream as { file: string; type: string }; + } + + return { + embedId: MWEmbedType.UPCLOUD, + streamUrl: sources.file, + quality: MWStreamQuality.Q1080P, + type: MWStreamType.HLS, + captions: streamRes.tracks + .filter((sub) => sub.kind === "captions") + .map((sub) => { + return { + langIso: sub.label, + url: sub.file, + type: sub.file.endsWith("vtt") + ? MWCaptionType.VTT + : MWCaptionType.UNKNOWN, + }; + }), + }; + }, +}); diff --git a/src/backend/helpers/embed.ts b/src/backend/helpers/embed.ts index 0dec6422..1ec3362c 100644 --- a/src/backend/helpers/embed.ts +++ b/src/backend/helpers/embed.ts @@ -4,6 +4,9 @@ export enum MWEmbedType { M4UFREE = "m4ufree", STREAMM4U = "streamm4u", PLAYM4U = "playm4u", + UPCLOUD = "upcloud", + STREAMSB = "streamsb", + MP4UPLOAD = "mp4upload", } export type MWEmbed = { diff --git a/src/backend/index.ts b/src/backend/index.ts index bc8d9d6c..5fe33bd4 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -9,11 +9,16 @@ import "./providers/m4ufree"; import "./providers/hdwatched"; import "./providers/2embed"; import "./providers/sflix"; +import "./providers/gomovies"; +import "./providers/kissasian"; import "./providers/streamflix"; import "./providers/remotestream"; // embeds import "./embeds/streamm4u"; import "./embeds/playm4u"; +import "./embeds/upcloud"; +import "./embeds/streamsb"; +import "./embeds/mp4upload"; initializeScraperStore(); diff --git a/src/backend/providers/2embed.ts b/src/backend/providers/2embed.ts index 48056020..7cc8938e 100644 --- a/src/backend/providers/2embed.ts +++ b/src/backend/providers/2embed.ts @@ -191,6 +191,7 @@ registerProvider({ displayName: "2Embed", rank: 125, type: [MWMediaType.MOVIE, MWMediaType.SERIES], + disabled: true, // Disabled, not working async scrape({ media, episode, progress }) { let embedUrl = `${twoEmbedBase}/embed/tmdb/movie?id=${media.tmdbId}`; diff --git a/src/backend/providers/gomovies.ts b/src/backend/providers/gomovies.ts new file mode 100644 index 00000000..9e22d095 --- /dev/null +++ b/src/backend/providers/gomovies.ts @@ -0,0 +1,162 @@ +import { MWEmbedType } from "../helpers/embed"; +import { proxiedFetch } from "../helpers/fetch"; +import { registerProvider } from "../helpers/register"; +import { MWMediaType } from "../metadata/types"; + +const gomoviesBase = "https://gomovies.sx"; + +registerProvider({ + id: "gomovies", + displayName: "GOmovies", + rank: 300, + type: [MWMediaType.MOVIE, MWMediaType.SERIES], + + async scrape({ media, episode }) { + const search = await proxiedFetch("/ajax/search", { + baseURL: gomoviesBase, + method: "POST", + body: JSON.stringify({ + keyword: media.meta.title, + }), + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }); + + const searchPage = new DOMParser().parseFromString(search, "text/html"); + const mediaElements = searchPage.querySelectorAll("a.nav-item"); + + const mediaData = Array.from(mediaElements).map((movieEl) => { + const name = movieEl?.querySelector("h3.film-name")?.textContent; + const year = movieEl?.querySelector( + "div.film-infor span:first-of-type" + )?.textContent; + const path = movieEl.getAttribute("href"); + return { name, year, path }; + }); + + const targetMedia = mediaData.find( + (m) => + m.name === media.meta.title && + (media.meta.type === MWMediaType.MOVIE + ? m.year === media.meta.year + : true) + ); + if (!targetMedia?.path) throw new Error("Media not found"); + + // Example movie path: /movie/watch-{slug}-{id} + // Example series path: /tv/watch-{slug}-{id} + let mediaId = targetMedia.path.split("-").pop()?.replace("/", ""); + + let sources = null; + if (media.meta.type === MWMediaType.SERIES) { + const seasons = await proxiedFetch( + `/ajax/v2/tv/seasons/${mediaId}`, + { + baseURL: gomoviesBase, + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + } + ); + + const seasonsEl = new DOMParser() + .parseFromString(seasons, "text/html") + .querySelectorAll(".ss-item"); + + const seasonsData = [...seasonsEl].map((season) => ({ + number: season.innerHTML.replace("Season ", ""), + dataId: season.getAttribute("data-id"), + })); + + const seasonNumber = media.meta.seasonData.number; + const targetSeason = seasonsData.find( + (season) => +season.number === seasonNumber + ); + if (!targetSeason) throw new Error("Season not found"); + + const episodes = await proxiedFetch( + `/ajax/v2/season/episodes/${targetSeason.dataId}`, + { + baseURL: gomoviesBase, + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + } + ); + + const episodesEl = new DOMParser() + .parseFromString(episodes, "text/html") + .querySelectorAll(".eps-item"); + + const episodesData = Array.from(episodesEl).map((ep) => ({ + dataId: ep.getAttribute("data-id"), + number: ep + .querySelector("strong") + ?.textContent?.replace("Eps", "") + .replace(":", "") + .trim(), + })); + + const episodeNumber = media.meta.seasonData.episodes.find( + (e) => e.id === episode + )?.number; + + const targetEpisode = episodesData.find((ep) => + ep.number ? +ep.number : ep.number === episodeNumber + ); + + if (!targetEpisode?.dataId) throw new Error("Episode not found"); + + mediaId = targetEpisode.dataId; + + sources = await proxiedFetch(`/ajax/v2/episode/servers/${mediaId}`, { + baseURL: gomoviesBase, + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }); + } else { + sources = await proxiedFetch(`/ajax/movie/episodes/${mediaId}`, { + baseURL: gomoviesBase, + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }); + } + + const upcloud = new DOMParser() + .parseFromString(sources, "text/html") + .querySelector('a[title*="upcloud" i]'); + + const upcloudDataId = + upcloud?.getAttribute("data-id") ?? upcloud?.getAttribute("data-linkid"); + + if (!upcloudDataId) throw new Error("Upcloud source not available"); + + const upcloudSource = await proxiedFetch<{ + type: "iframe" | string; + link: string; + sources: []; + title: string; + tracks: []; + }>(`/ajax/sources/${upcloudDataId}`, { + baseURL: gomoviesBase, + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }); + + if (!upcloudSource.link || upcloudSource.type !== "iframe") + throw new Error("No upcloud stream found"); + + return { + embeds: [ + { + type: MWEmbedType.UPCLOUD, + url: upcloudSource.link, + }, + ], + }; + }, +}); diff --git a/src/backend/providers/kissasian.ts b/src/backend/providers/kissasian.ts new file mode 100644 index 00000000..90708970 --- /dev/null +++ b/src/backend/providers/kissasian.ts @@ -0,0 +1,119 @@ +import { MWEmbedType } from "../helpers/embed"; +import { proxiedFetch } from "../helpers/fetch"; +import { registerProvider } from "../helpers/register"; +import { MWMediaType } from "../metadata/types"; + +const kissasianBase = "https://kissasian.li"; + +const embedProviders = [ + { + type: MWEmbedType.MP4UPLOAD, + id: "mp", + }, + { + type: MWEmbedType.STREAMSB, + id: "sb", + }, +]; + +registerProvider({ + id: "kissasian", + displayName: "KissAsian", + rank: 130, + type: [MWMediaType.MOVIE, MWMediaType.SERIES], + + async scrape({ media, episode, progress }) { + let seasonNumber = ""; + let episodeNumber = ""; + + if (media.meta.type === MWMediaType.SERIES) { + seasonNumber = + media.meta.seasonData.number === 1 + ? "" + : `${media.meta.seasonData.number}`; + episodeNumber = `${ + media.meta.seasonData.episodes.find((e) => e.id === episode)?.number ?? + "" + }`; + } + + const searchForm = new FormData(); + searchForm.append("keyword", `${media.meta.title} ${seasonNumber}`.trim()); + searchForm.append("type", "Drama"); + + const search = await proxiedFetch("/Search/SearchSuggest", { + baseURL: kissasianBase, + method: "POST", + body: searchForm, + }); + + const searchPage = new DOMParser().parseFromString(search, "text/html"); + + const dramas = Array.from(searchPage.querySelectorAll("a")).map((drama) => { + return { + name: drama.textContent, + url: drama.href, + }; + }); + + const targetDrama = + dramas.find( + (d) => d.name?.toLowerCase() === media.meta.title.toLowerCase() + ) ?? dramas[0]; + if (!targetDrama) throw new Error("Drama not found"); + + progress(30); + + const drama = await proxiedFetch(targetDrama.url); + + const dramaPage = new DOMParser().parseFromString(drama, "text/html"); + + const episodesEl = dramaPage.querySelectorAll("tbody tr:not(:first-child)"); + + const episodes = Array.from(episodesEl) + .map((ep) => { + const number = ep + ?.querySelector("td.episodeSub a") + ?.textContent?.split("Episode")[1] + ?.trim(); + const url = ep?.querySelector("td.episodeSub a")?.getAttribute("href"); + return { number, url }; + }) + .filter((e) => !!e.url); + + const targetEpisode = + media.meta.type === MWMediaType.MOVIE + ? episodes[0] + : episodes.find((e) => e.number === `${episodeNumber}`); + if (!targetEpisode?.url) throw new Error("Episode not found"); + + progress(70); + + let embeds = await Promise.all( + embedProviders.map(async (provider) => { + const watch = await proxiedFetch( + `${targetEpisode.url}&s=${provider.id}`, + { + baseURL: kissasianBase, + } + ); + + const watchPage = new DOMParser().parseFromString(watch, "text/html"); + + const embedUrl = watchPage + .querySelector("iframe[id=my_video_1]") + ?.getAttribute("src"); + + return { + type: provider.type, + url: embedUrl ?? "", + }; + }) + ); + embeds = embeds.filter((e) => e.url !== ""); + + return { + embeds, + }; + }, +});