diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..6c2eeb87
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,7 @@
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+indent_size = 2
+indent_style = space
diff --git a/.eslintrc.js b/.eslintrc.js
index d7dcebbb..40cda0da 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -7,27 +7,28 @@ const a11yOff = Object.keys(require("eslint-plugin-jsx-a11y").rules).reduce(
);
module.exports = {
+ env: {
+ browser: true
+ },
extends: [
"airbnb",
"airbnb/hooks",
"plugin:@typescript-eslint/recommended",
- "prettier"
+ "prettier",
+ "plugin:prettier/recommended"
],
- settings: {
- "import/resolver": {
- typescript: {}
- }
- },
ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: "./"
},
- plugins: ["@typescript-eslint", "import"],
- env: {
- browser: true
+ settings: {
+ "import/resolver": {
+ typescript: {}
+ }
},
+ plugins: ["@typescript-eslint", "import"],
rules: {
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
@@ -47,6 +48,9 @@ module.exports = {
"no-continue": "off",
"no-eval": "off",
"no-await-in-loop": "off",
+ "no-nested-ternary": "off",
+ "prefer-destructuring": "off",
+ "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"react/jsx-filename-extension": [
"error",
{ extensions: [".js", ".tsx", ".jsx"] }
diff --git a/.github/workflows/deploying.yml b/.github/workflows/deploying.yml
index 00068928..182a7895 100644
--- a/.github/workflows/deploying.yml
+++ b/.github/workflows/deploying.yml
@@ -31,30 +31,6 @@ jobs:
name: production-files
path: ./dist
- deploy:
- name: Deploy
- needs: build
- runs-on: ubuntu-latest
-
- steps:
- - name: Download artifact
- uses: actions/download-artifact@v3
- with:
- name: production-files
- path: ./dist
-
- - name: Insert config
- env:
- DEPLOY_CONFIG: ${{ secrets.DEPLOY_CONFIG }}
- run: echo "$DEPLOY_CONFIG" > ./dist/config.js
-
- - name: Deploy to gh-pages
- uses: peaceiris/actions-gh-pages@v3
- with:
- github_token: ${{ secrets.GITHUB_TOKEN }}
- publish_dir: ./dist
- cname: movie.squeezebox.dev
-
release:
name: Release
needs: build
diff --git a/.github/workflows/linting_testing.yml b/.github/workflows/linting_testing.yml
index 24d22635..e51fc630 100644
--- a/.github/workflows/linting_testing.yml
+++ b/.github/workflows/linting_testing.yml
@@ -5,7 +5,7 @@ on:
branches:
- master
- dev
- pull_request:
+ pull_request_target:
types: [opened, reopened, synchronize]
jobs:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 8ef45565..e4df0f2a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,7 +1,5 @@
-{
- "files.eol": "\n",
- "editor.detectIndentation": false,
- "editor.tabSize": 2,
- "editor.formatOnSave": true,
- "editor.defaultFormatter": "dbaeumer.vscode-eslint",
-}
\ No newline at end of file
+{
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "dbaeumer.vscode-eslint",
+ "eslint.format.enable": true
+}
diff --git a/README.md b/README.md
index 58d782fb..78cbb30d 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
@JamesHawkinss for original concept.
diff --git a/index.html b/index.html
index c6f4d667..7f198e1d 100644
--- a/index.html
+++ b/index.html
@@ -18,7 +18,7 @@
-
+
-
+
+
movie-web
diff --git a/package.json b/package.json
index f3cfd55c..e7883b1d 100644
--- a/package.json
+++ b/package.json
@@ -4,19 +4,27 @@
"private": true,
"homepage": "https://movie.squeezebox.dev",
"dependencies": {
+ "@formkit/auto-animate": "^1.0.0-beta.5",
"@headlessui/react": "^1.5.0",
+ "@types/react-helmet": "^6.1.6",
"crypto-js": "^4.1.1",
+ "fscreen": "^1.2.0",
"fuse.js": "^6.4.6",
"hls.js": "^1.0.7",
"i18next": "^22.4.5",
"i18next-browser-languagedetector": "^7.0.1",
- "i18next-http-backend": "^2.1.0",
"json5": "^2.2.0",
+ "lodash.throttle": "^4.1.1",
"nanoid": "^4.0.0",
+ "ofetch": "^1.0.0",
+ "pako": "^2.1.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
+ "react-helmet": "^6.1.0",
"react-i18next": "^12.1.1",
"react-router-dom": "^5.2.0",
+ "react-stickynode": "^4.1.0",
+ "react-transition-group": "^4.4.5",
"srt-webvtt": "^2.0.0",
"unpacker": "^1.0.1"
},
@@ -25,7 +33,7 @@
"build": "vite build",
"preview": "vite preview",
"lint": "eslint --ext .tsx,.ts src",
- "lint:strict": "eslint --ext .tsx,.ts --max-warnings 0 src",
+ "lint:fix": "eslint --fix --ext .tsx,.ts src",
"lint:report": "eslint --ext .tsx,.ts --output-file eslint_report.json --format json src"
},
"browserslist": {
@@ -41,22 +49,30 @@
]
},
"devDependencies": {
+ "@tailwindcss/line-clamp": "^0.4.2",
+ "@types/chromecast-caf-sender": "^1.0.5",
"@types/crypto-js": "^4.1.1",
+ "@types/fscreen": "^1.0.1",
+ "@types/lodash.throttle": "^4.1.7",
"@types/node": "^17.0.15",
+ "@types/pako": "^2.0.0",
"@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11",
"@types/react-router": "^5.1.18",
"@types/react-router-dom": "^5.3.3",
+ "@types/react-stickynode": "^4.0.0",
+ "@types/react-transition-group": "^4.4.5",
"@typescript-eslint/eslint-plugin": "^5.13.0",
"@typescript-eslint/parser": "^5.13.0",
"@vitejs/plugin-react-swc": "^3.0.0",
"autoprefixer": "^10.4.13",
"eslint": "^8.10.0",
"eslint-config-airbnb": "19.0.4",
- "eslint-config-prettier": "^8.5.0",
+ "eslint-config-prettier": "^8.6.0",
"eslint-import-resolver-typescript": "^2.5.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsx-a11y": "^6.5.1",
+ "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "7.29.4",
"eslint-plugin-react-hooks": "4.3.0",
"postcss": "^8.4.20",
@@ -65,6 +81,8 @@
"tailwind-scrollbar": "^2.0.1",
"tailwindcss": "^3.2.4",
"typescript": "^4.6.4",
- "vite": "^4.0.1"
+ "vite": "^4.0.1",
+ "vite-plugin-checker": "^0.5.6",
+ "vite-plugin-package-version": "^1.0.2"
}
}
diff --git a/prettierrc.js b/prettierrc.js
new file mode 100644
index 00000000..0af34d8e
--- /dev/null
+++ b/prettierrc.js
@@ -0,0 +1,4 @@
+module.exports = {
+ trailingComma: "all",
+ singleQuote: true
+};
diff --git a/public/_redirects b/public/_redirects
new file mode 100644
index 00000000..7797f7c6
--- /dev/null
+++ b/public/_redirects
@@ -0,0 +1 @@
+/* /index.html 200
diff --git a/public/locales/en-GB/translation.json b/public/locales/en-GB/translation.json
deleted file mode 100644
index f50d3c0b..00000000
--- a/public/locales/en-GB/translation.json
+++ /dev/null
@@ -1,50 +0,0 @@
-{
- "global": {
- "name": "movie-web"
- },
- "search": {
- "loading": "Fetching your favourite shows...",
- "providersFailed": "{{fails}}/{{total}} providers failed!",
- "allResults": "That's all we have!",
- "noResults": "We couldn't find anything!",
- "allFailed": "All providers have failed!",
- "headingTitle": "Search results",
- "headingLink": "Back to home",
- "bookmarks": "Bookmarks",
- "continueWatching": "Continue Watching",
- "tagline": "Because watching legally is boring",
- "title": "What do you want to watch?",
- "placeholder": "What do you want to watch?"
- },
- "media": {
- "invalidUrl": "Your URL may be invalid",
- "arrowText": "Go back"
- },
- "seasons": {
- "season": "Season {{season}}",
- "failed": "Failed to get season data"
- },
- "notFound": {
- "backArrow": "Back to home",
- "media": {
- "title": "Couldn't find that media",
- "description": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL"
- },
- "provider": {
- "title": "This provider has been disabled",
- "description": "We had issues with the provider or it was too unstable to use, so we had to disable it."
- },
- "page": {
- "title": "Couldn't find that page",
- "description": "We looked everywhere: under the bins, in the closet, behind the proxy but ultimately couldn't find the page you are looking for."
- }
- },
- "searchBar": {
- "movie": "Movie",
- "series": "Series",
- "Search": "Search"
- },
- "errorBoundary": {
- "text": "The app encountered an error and wasn't able to recover, please report it to the"
- }
-}
diff --git a/src/backend/embeds/.gitkeep b/src/backend/embeds/.gitkeep
new file mode 100644
index 00000000..f42d5aa9
--- /dev/null
+++ b/src/backend/embeds/.gitkeep
@@ -0,0 +1 @@
+embed scrapers go here
diff --git a/src/backend/embeds/playm4u.ts b/src/backend/embeds/playm4u.ts
new file mode 100644
index 00000000..8328d337
--- /dev/null
+++ b/src/backend/embeds/playm4u.ts
@@ -0,0 +1,19 @@
+import { MWEmbedType } from "@/backend/helpers/embed";
+import { registerEmbedScraper } from "@/backend/helpers/register";
+import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
+
+registerEmbedScraper({
+ id: "playm4u",
+ displayName: "playm4u",
+ for: MWEmbedType.PLAYM4U,
+ rank: 0,
+ async getStream() {
+ // throw new Error("Oh well 2")
+ return {
+ streamUrl: "",
+ quality: MWStreamQuality.Q1080P,
+ captions: [],
+ type: MWStreamType.MP4,
+ };
+ },
+});
diff --git a/src/backend/embeds/streamm4u.ts b/src/backend/embeds/streamm4u.ts
new file mode 100644
index 00000000..d0eba66a
--- /dev/null
+++ b/src/backend/embeds/streamm4u.ts
@@ -0,0 +1,65 @@
+import { MWEmbedType } from "@/backend/helpers/embed";
+import { registerEmbedScraper } from "@/backend/helpers/register";
+import {
+ MWStreamQuality,
+ MWStreamType,
+ MWStream,
+} from "@/backend/helpers/streams";
+import { proxiedFetch } from "@/backend/helpers/fetch";
+
+const HOST = "streamm4u.club";
+const URL_BASE = `https://${HOST}`;
+const URL_API = `${URL_BASE}/api`;
+const URL_API_SOURCE = `${URL_API}/source`;
+
+async function scrape(embed: string) {
+ const sources: MWStream[] = [];
+
+ const embedID = embed.split("/").pop();
+
+ console.log(`${URL_API_SOURCE}/${embedID}`);
+ const json = await proxiedFetch
(`${URL_API_SOURCE}/${embedID}`, {
+ method: "POST",
+ body: `r=&d=${HOST}`,
+ });
+
+ if (json.success) {
+ const streams = json.data;
+
+ for (const stream of streams) {
+ sources.push({
+ streamUrl: stream.file as string,
+ quality: stream.label as MWStreamQuality,
+ type: stream.type as MWStreamType,
+ captions: [],
+ });
+ }
+ }
+
+ return sources;
+}
+
+// TODO check out 403 / 404 on successfully returned video stream URLs
+registerEmbedScraper({
+ id: "streamm4u",
+ displayName: "streamm4u",
+ for: MWEmbedType.STREAMM4U,
+ rank: 100,
+ async getStream({ progress, url }) {
+ // const scrapingThreads = [];
+ // const streams = [];
+
+ const sources = (await scrape(url)).sort(
+ (a, b) =>
+ Number(b.quality.replace("p", "")) - Number(a.quality.replace("p", ""))
+ );
+ // const preferredSourceIndex = 0;
+ const preferredSource = sources[0];
+
+ if (!preferredSource) throw new Error("No source found");
+
+ progress(100);
+
+ return preferredSource;
+ },
+});
diff --git a/src/backend/helpers/captions.ts b/src/backend/helpers/captions.ts
new file mode 100644
index 00000000..ca230fa9
--- /dev/null
+++ b/src/backend/helpers/captions.ts
@@ -0,0 +1,34 @@
+import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
+import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
+import toWebVTT from "srt-webvtt";
+
+export async function getCaptionUrl(caption: MWCaption): Promise {
+ if (caption.type === MWCaptionType.SRT) {
+ let captionBlob: Blob;
+
+ if (caption.needsProxy) {
+ captionBlob = await proxiedFetch(caption.url, {
+ responseType: "blob" as any,
+ });
+ } else {
+ captionBlob = await mwFetch(caption.url, {
+ responseType: "blob" as any,
+ });
+ }
+
+ return toWebVTT(captionBlob);
+ }
+
+ if (caption.type === MWCaptionType.VTT) {
+ if (caption.needsProxy) {
+ const blob = await proxiedFetch(caption.url, {
+ responseType: "blob" as any,
+ });
+ return URL.createObjectURL(blob);
+ }
+
+ return caption.url;
+ }
+
+ throw new Error("invalid type");
+}
diff --git a/src/backend/helpers/embed.ts b/src/backend/helpers/embed.ts
new file mode 100644
index 00000000..64d039b7
--- /dev/null
+++ b/src/backend/helpers/embed.ts
@@ -0,0 +1,27 @@
+import { MWStream } from "./streams";
+
+export enum MWEmbedType {
+ M4UFREE = "m4ufree",
+ STREAMM4U = "streamm4u",
+ PLAYM4U = "playm4u",
+}
+
+export type MWEmbed = {
+ type: MWEmbedType;
+ url: string;
+};
+
+export type MWEmbedContext = {
+ progress(percentage: number): void;
+ url: string;
+};
+
+export type MWEmbedScraper = {
+ id: string;
+ displayName: string;
+ for: MWEmbedType;
+ rank: number;
+ disabled?: boolean;
+
+ getStream(ctx: MWEmbedContext): Promise;
+};
diff --git a/src/backend/helpers/fetch.ts b/src/backend/helpers/fetch.ts
new file mode 100644
index 00000000..9428ab03
--- /dev/null
+++ b/src/backend/helpers/fetch.ts
@@ -0,0 +1,51 @@
+import { conf } from "@/setup/config";
+import { ofetch } from "ofetch";
+
+type P = Parameters>;
+type R = ReturnType>;
+
+const baseFetch = ofetch.create({
+ retry: 0,
+});
+
+export function makeUrl(url: string, data: Record) {
+ let parsedUrl: string = url;
+ Object.entries(data).forEach(([k, v]) => {
+ parsedUrl = parsedUrl.replace(`{${k}}`, encodeURIComponent(v));
+ });
+ return parsedUrl;
+}
+
+export function mwFetch(url: string, ops: P[1] = {}): R {
+ return baseFetch(url, ops);
+}
+
+export function proxiedFetch(url: string, ops: P[1] = {}): R {
+ let combinedUrl = ops?.baseURL ?? "";
+ if (
+ combinedUrl.length > 0 &&
+ combinedUrl.endsWith("/") &&
+ url.startsWith("/")
+ )
+ combinedUrl += url.slice(1);
+ else if (
+ combinedUrl.length > 0 &&
+ !combinedUrl.endsWith("/") &&
+ !url.startsWith("/")
+ )
+ combinedUrl += `/${url}`;
+ else combinedUrl += url;
+
+ const parsedUrl = new URL(combinedUrl);
+ Object.entries(ops?.params ?? {}).forEach(([k, v]) => {
+ parsedUrl.searchParams.set(k, v);
+ });
+
+ return baseFetch(conf().BASE_PROXY_URL, {
+ ...ops,
+ baseURL: undefined,
+ params: {
+ destination: parsedUrl.toString(),
+ },
+ });
+}
diff --git a/src/backend/helpers/provider.ts b/src/backend/helpers/provider.ts
new file mode 100644
index 00000000..95f5e374
--- /dev/null
+++ b/src/backend/helpers/provider.ts
@@ -0,0 +1,36 @@
+import { DetailedMeta } from "../metadata/getmeta";
+import { MWMediaType } from "../metadata/types";
+import { MWEmbed } from "./embed";
+import { MWStream } from "./streams";
+
+export type MWProviderScrapeResult = {
+ stream?: MWStream;
+ embeds: MWEmbed[];
+};
+
+type MWProviderBase = {
+ progress(percentage: number): void;
+ media: DetailedMeta;
+};
+type MWProviderTypeSpecific =
+ | {
+ type: MWMediaType.MOVIE | MWMediaType.ANIME;
+ episode?: undefined;
+ season?: undefined;
+ }
+ | {
+ type: MWMediaType.SERIES;
+ episode: string;
+ season: string;
+ };
+export type MWProviderContext = MWProviderTypeSpecific & MWProviderBase;
+
+export type MWProvider = {
+ id: string;
+ displayName: string;
+ rank: number;
+ disabled?: boolean;
+ type: MWMediaType[];
+
+ scrape(ctx: MWProviderContext): Promise;
+};
diff --git a/src/backend/helpers/register.ts b/src/backend/helpers/register.ts
new file mode 100644
index 00000000..9d3f76c2
--- /dev/null
+++ b/src/backend/helpers/register.ts
@@ -0,0 +1,72 @@
+import { MWEmbedScraper, MWEmbedType } from "./embed";
+import { MWProvider } from "./provider";
+
+let providers: MWProvider[] = [];
+let embeds: MWEmbedScraper[] = [];
+
+export function registerProvider(provider: MWProvider) {
+ if (provider.disabled) return;
+ providers.push(provider);
+}
+export function registerEmbedScraper(embed: MWEmbedScraper) {
+ if (embed.disabled) return;
+ embeds.push(embed);
+}
+
+export function initializeScraperStore() {
+ // sort by ranking
+ providers = providers.sort((a, b) => b.rank - a.rank);
+ embeds = embeds.sort((a, b) => b.rank - a.rank);
+
+ // check for invalid ranks
+ let lastRank: null | number = null;
+ providers.forEach((v) => {
+ if (lastRank === null) {
+ lastRank = v.rank;
+ return;
+ }
+ if (lastRank === v.rank)
+ throw new Error(`Duplicate rank number for provider ${v.id}`);
+ lastRank = v.rank;
+ });
+ lastRank = null;
+ providers.forEach((v) => {
+ if (lastRank === null) {
+ lastRank = v.rank;
+ return;
+ }
+ if (lastRank === v.rank)
+ throw new Error(`Duplicate rank number for embed scraper ${v.id}`);
+ lastRank = v.rank;
+ });
+
+ // check for duplicate ids
+ const providerIds = providers.map((v) => v.id);
+ if (
+ providerIds.length > 0 &&
+ new Set(providerIds).size !== providerIds.length
+ )
+ throw new Error("Duplicate IDS in providers");
+ const embedIds = embeds.map((v) => v.id);
+ if (embedIds.length > 0 && new Set(embedIds).size !== embedIds.length)
+ throw new Error("Duplicate IDS in embed scrapers");
+
+ // check for duplicate embed types
+ const embedTypes = embeds.map((v) => v.for);
+ if (embedTypes.length > 0 && new Set(embedTypes).size !== embedTypes.length)
+ throw new Error("Duplicate types in embed scrapers");
+}
+
+export function getProviders(): MWProvider[] {
+ return providers;
+}
+
+export function getEmbeds(): MWEmbedScraper[] {
+ return embeds;
+}
+
+export function getEmbedScraperByType(
+ type: MWEmbedType
+): MWEmbedScraper | null {
+ return getEmbeds().find((v) => v.for === type) ?? null;
+}
diff --git a/src/backend/helpers/run.ts b/src/backend/helpers/run.ts
new file mode 100644
index 00000000..f2f9bc9c
--- /dev/null
+++ b/src/backend/helpers/run.ts
@@ -0,0 +1,52 @@
+import { MWEmbed, MWEmbedContext, MWEmbedScraper } from "./embed";
+import {
+ MWProvider,
+ MWProviderContext,
+ MWProviderScrapeResult,
+} from "./provider";
+import { getEmbedScraperByType } from "./register";
+import { MWStream } from "./streams";
+
+function sortProviderResult(
+ ctx: MWProviderScrapeResult
+): MWProviderScrapeResult {
+ ctx.embeds = ctx.embeds
+ .map<[MWEmbed, MWEmbedScraper | null]>((v) => [
+ v,
+ v.type ? getEmbedScraperByType(v.type) : null,
+ ])
+ .sort(([, a], [, b]) => (b?.rank ?? 0) - (a?.rank ?? 0))
+ .map((v) => v[0]);
+ return ctx;
+}
+
+export async function runProvider(
+ provider: MWProvider,
+ ctx: MWProviderContext
+): Promise {
+ try {
+ const data = await provider.scrape(ctx);
+ return sortProviderResult(data);
+ } catch (err) {
+ console.error("Failed to run provider", err, {
+ id: provider.id,
+ ctx: { ...ctx },
+ });
+ throw err;
+ }
+}
+
+export async function runEmbedScraper(
+ scraper: MWEmbedScraper,
+ ctx: MWEmbedContext
+): Promise {
+ try {
+ return await scraper.getStream(ctx);
+ } catch (err) {
+ console.error("Failed to run embed scraper", {
+ id: scraper.id,
+ ctx: { ...ctx },
+ });
+ throw err;
+ }
+}
diff --git a/src/backend/helpers/scrape.ts b/src/backend/helpers/scrape.ts
new file mode 100644
index 00000000..cb160305
--- /dev/null
+++ b/src/backend/helpers/scrape.ts
@@ -0,0 +1,166 @@
+import { MWProviderContext, MWProviderScrapeResult } from "./provider";
+import { getEmbedScraperByType, getProviders } from "./register";
+import { runEmbedScraper, runProvider } from "./run";
+import { MWStream } from "./streams";
+import { DetailedMeta } from "../metadata/getmeta";
+import { MWMediaType } from "../metadata/types";
+
+interface MWProgressData {
+ type: "embed" | "provider";
+ id: string;
+ eventId: string;
+ percentage: number;
+ errored: boolean;
+}
+interface MWNextData {
+ id: string;
+ eventId: string;
+ type: "embed" | "provider";
+}
+
+type MWProviderRunContextBase = {
+ media: DetailedMeta;
+ onProgress?: (data: MWProgressData) => void;
+ onNext?: (data: MWNextData) => void;
+};
+type MWProviderRunContextTypeSpecific =
+ | {
+ type: MWMediaType.MOVIE | MWMediaType.ANIME;
+ episode: undefined;
+ season: undefined;
+ }
+ | {
+ type: MWMediaType.SERIES;
+ episode: string;
+ season: string;
+ };
+
+export type MWProviderRunContext = MWProviderRunContextBase &
+ MWProviderRunContextTypeSpecific;
+
+async function findBestEmbedStream(
+ result: MWProviderScrapeResult,
+ providerId: string,
+ ctx: MWProviderRunContext
+): Promise {
+ if (result.stream) return result.stream;
+
+ let embedNum = 0;
+ for (const embed of result.embeds) {
+ embedNum += 1;
+ if (!embed.type) continue;
+ const scraper = getEmbedScraperByType(embed.type);
+ if (!scraper) throw new Error(`Type for embed not found: ${embed.type}`);
+
+ const eventId = [providerId, scraper.id, embedNum].join("|");
+
+ ctx.onNext?.({ id: scraper.id, type: "embed", eventId });
+
+ let stream: MWStream;
+ try {
+ stream = await runEmbedScraper(scraper, {
+ url: embed.url,
+ progress(num) {
+ ctx.onProgress?.({
+ errored: false,
+ eventId,
+ id: scraper.id,
+ percentage: num,
+ type: "embed",
+ });
+ },
+ });
+ } catch {
+ ctx.onProgress?.({
+ errored: true,
+ eventId,
+ id: scraper.id,
+ percentage: 100,
+ type: "embed",
+ });
+ continue;
+ }
+
+ ctx.onProgress?.({
+ errored: false,
+ eventId,
+ id: scraper.id,
+ percentage: 100,
+ type: "embed",
+ });
+
+ return stream;
+ }
+
+ return null;
+}
+
+export async function findBestStream(
+ ctx: MWProviderRunContext
+): Promise {
+ const providers = getProviders();
+
+ for (const provider of providers) {
+ const eventId = provider.id;
+ ctx.onNext?.({ id: provider.id, type: "provider", eventId });
+ let result: MWProviderScrapeResult;
+ try {
+ let context: MWProviderContext;
+ if (ctx.type === MWMediaType.SERIES) {
+ context = {
+ media: ctx.media,
+ type: ctx.type,
+ episode: ctx.episode,
+ season: ctx.season,
+ progress(num) {
+ ctx.onProgress?.({
+ percentage: num,
+ eventId,
+ errored: false,
+ id: provider.id,
+ type: "provider",
+ });
+ },
+ };
+ } else {
+ context = {
+ media: ctx.media,
+ type: ctx.type,
+ progress(num) {
+ ctx.onProgress?.({
+ percentage: num,
+ eventId,
+ errored: false,
+ id: provider.id,
+ type: "provider",
+ });
+ },
+ };
+ }
+ result = await runProvider(provider, context);
+ } catch (err) {
+ ctx.onProgress?.({
+ percentage: 100,
+ errored: true,
+ eventId,
+ id: provider.id,
+ type: "provider",
+ });
+ continue;
+ }
+
+ ctx.onProgress?.({
+ errored: false,
+ id: provider.id,
+ eventId,
+ percentage: 100,
+ type: "provider",
+ });
+
+ const stream = await findBestEmbedStream(result, provider.id, ctx);
+ if (!stream) continue;
+ return stream;
+ }
+
+ return null;
+}
diff --git a/src/backend/helpers/streams.ts b/src/backend/helpers/streams.ts
new file mode 100644
index 00000000..92943d94
--- /dev/null
+++ b/src/backend/helpers/streams.ts
@@ -0,0 +1,31 @@
+export enum MWStreamType {
+ MP4 = "mp4",
+ HLS = "hls",
+}
+
+export enum MWCaptionType {
+ VTT = "vtt",
+ SRT = "srt",
+}
+
+export enum MWStreamQuality {
+ Q360P = "360p",
+ Q480P = "480p",
+ Q720P = "720p",
+ Q1080P = "1080p",
+ QUNKNOWN = "unknown",
+}
+
+export type MWCaption = {
+ needsProxy?: boolean;
+ url: string;
+ type: MWCaptionType;
+ langIso: string;
+};
+
+export type MWStream = {
+ streamUrl: string;
+ type: MWStreamType;
+ quality: MWStreamQuality;
+ captions: MWCaption[];
+};
diff --git a/src/backend/index.ts b/src/backend/index.ts
new file mode 100644
index 00000000..7a13a445
--- /dev/null
+++ b/src/backend/index.ts
@@ -0,0 +1,14 @@
+import { initializeScraperStore } from "./helpers/register";
+
+// providers
+import "./providers/gdriveplayer";
+import "./providers/flixhq";
+import "./providers/superstream";
+import "./providers/netfilm";
+import "./providers/m4ufree";
+
+// embeds
+import "./embeds/streamm4u";
+import "./embeds/playm4u";
+
+initializeScraperStore();
diff --git a/src/backend/metadata/getmeta.ts b/src/backend/metadata/getmeta.ts
new file mode 100644
index 00000000..cb622e3b
--- /dev/null
+++ b/src/backend/metadata/getmeta.ts
@@ -0,0 +1,80 @@
+import { FetchError } from "ofetch";
+import { makeUrl, proxiedFetch } from "../helpers/fetch";
+import {
+ formatJWMeta,
+ JWMediaResult,
+ JWSeasonMetaResult,
+ JW_API_BASE,
+ mediaTypeToJW,
+} from "./justwatch";
+import { MWMediaMeta, MWMediaType } from "./types";
+
+type JWExternalIdType =
+ | "eidr"
+ | "imdb_latest"
+ | "imdb"
+ | "tmdb_latest"
+ | "tmdb"
+ | "tms";
+
+interface JWExternalId {
+ provider: JWExternalIdType;
+ external_id: string;
+}
+
+interface JWDetailedMeta extends JWMediaResult {
+ external_ids: JWExternalId[];
+}
+
+export interface DetailedMeta {
+ meta: MWMediaMeta;
+ tmdbId: string;
+ imdbId: string;
+}
+
+export async function getMetaFromId(
+ type: MWMediaType,
+ id: string,
+ seasonId?: string
+): Promise {
+ const queryType = mediaTypeToJW(type);
+
+ let data: JWDetailedMeta;
+ try {
+ const url = makeUrl("/content/titles/{type}/{id}/locale/en_US", {
+ type: queryType,
+ id,
+ });
+ data = await proxiedFetch(url, { baseURL: JW_API_BASE });
+ } catch (err) {
+ if (err instanceof FetchError) {
+ // 400 and 404 are treated as not found
+ if (err.statusCode === 400 || err.statusCode === 404) return null;
+ }
+ throw err;
+ }
+
+ const imdbId = data.external_ids.find(
+ (v) => v.provider === "imdb_latest"
+ )?.external_id;
+ const tmdbId = data.external_ids.find(
+ (v) => v.provider === "tmdb_latest"
+ )?.external_id;
+
+ if (!imdbId || !tmdbId) throw new Error("not enough info");
+
+ let seasonData: JWSeasonMetaResult | undefined;
+ if (data.object_type === "show") {
+ const seasonToScrape = seasonId ?? data.seasons?.[0].id.toString() ?? "";
+ const url = makeUrl("/content/titles/show_season/{id}/locale/en_US", {
+ id: seasonToScrape,
+ });
+ seasonData = await proxiedFetch(url, { baseURL: JW_API_BASE });
+ }
+
+ return {
+ meta: formatJWMeta(data, seasonData),
+ imdbId,
+ tmdbId,
+ };
+}
diff --git a/src/backend/metadata/justwatch.ts b/src/backend/metadata/justwatch.ts
new file mode 100644
index 00000000..b3ef32f7
--- /dev/null
+++ b/src/backend/metadata/justwatch.ts
@@ -0,0 +1,112 @@
+import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types";
+
+export const JW_API_BASE = "https://apis.justwatch.com";
+export const JW_IMAGE_BASE = "https://images.justwatch.com";
+
+export type JWContentTypes = "movie" | "show";
+
+export type JWSeasonShort = {
+ title: string;
+ id: number;
+ season_number: number;
+};
+
+export type JWEpisodeShort = {
+ title: string;
+ id: number;
+ episode_number: number;
+};
+
+export type JWMediaResult = {
+ title: string;
+ poster?: string;
+ id: number;
+ original_release_year: number;
+ jw_entity_id: string;
+ object_type: JWContentTypes;
+ seasons?: JWSeasonShort[];
+};
+
+export type JWSeasonMetaResult = {
+ title: string;
+ id: string;
+ season_number: number;
+ episodes: JWEpisodeShort[];
+};
+
+export function mediaTypeToJW(type: MWMediaType): JWContentTypes {
+ if (type === MWMediaType.MOVIE) return "movie";
+ if (type === MWMediaType.SERIES) return "show";
+ throw new Error("unsupported type");
+}
+
+export function JWMediaToMediaType(type: string): MWMediaType {
+ if (type === "movie") return MWMediaType.MOVIE;
+ if (type === "show") return MWMediaType.SERIES;
+ throw new Error("unsupported type");
+}
+
+export function formatJWMeta(
+ media: JWMediaResult,
+ season?: JWSeasonMetaResult
+): MWMediaMeta {
+ const type = JWMediaToMediaType(media.object_type);
+ let seasons: undefined | MWSeasonMeta[];
+ if (type === MWMediaType.SERIES) {
+ seasons = media.seasons
+ ?.sort((a, b) => a.season_number - b.season_number)
+ .map(
+ (v): MWSeasonMeta => ({
+ id: v.id.toString(),
+ number: v.season_number,
+ title: v.title,
+ })
+ );
+ }
+
+ return {
+ title: media.title,
+ id: media.id.toString(),
+ year: media.original_release_year.toString(),
+ poster: media.poster
+ ? `${JW_IMAGE_BASE}${media.poster.replace("{profile}", "s166")}`
+ : undefined,
+ type,
+ seasons: seasons as any,
+ seasonData: season
+ ? ({
+ id: season.id.toString(),
+ number: season.season_number,
+ title: season.title,
+ episodes: season.episodes
+ .sort((a, b) => a.episode_number - b.episode_number)
+ .map((v) => ({
+ id: v.id.toString(),
+ number: v.episode_number,
+ title: v.title,
+ })),
+ } as any)
+ : (undefined as any),
+ };
+}
+
+export function JWMediaToId(media: MWMediaMeta): string {
+ return ["JW", mediaTypeToJW(media.type), media.id].join("-");
+}
+
+export function decodeJWId(
+ paramId: string
+): { id: string; type: MWMediaType } | null {
+ const [prefix, type, id] = paramId.split("-", 3);
+ if (prefix !== "JW") return null;
+ let mediaType;
+ try {
+ mediaType = JWMediaToMediaType(type);
+ } catch {
+ return null;
+ }
+ return {
+ type: mediaType,
+ id,
+ };
+}
diff --git a/src/backend/metadata/search.ts b/src/backend/metadata/search.ts
new file mode 100644
index 00000000..1c3c4598
--- /dev/null
+++ b/src/backend/metadata/search.ts
@@ -0,0 +1,58 @@
+import { SimpleCache } from "@/utils/cache";
+import { proxiedFetch } from "../helpers/fetch";
+import {
+ formatJWMeta,
+ JWContentTypes,
+ JWMediaResult,
+ JW_API_BASE,
+ mediaTypeToJW,
+} from "./justwatch";
+import { MWMediaMeta, MWQuery } from "./types";
+
+const cache = new SimpleCache();
+cache.setCompare((a, b) => {
+ return a.type === b.type && a.searchQuery.trim() === b.searchQuery.trim();
+});
+cache.initialize();
+
+type JWSearchQuery = {
+ content_types: JWContentTypes[];
+ page: number;
+ page_size: number;
+ query: string;
+};
+
+type JWPage = {
+ items: T[];
+ page: number;
+ page_size: number;
+ total_pages: number;
+ total_results: number;
+};
+
+export async function searchForMedia(query: MWQuery): Promise {
+ if (cache.has(query)) return cache.get(query) as MWMediaMeta[];
+ const { searchQuery, type } = query;
+
+ const contentType = mediaTypeToJW(type);
+ const body: JWSearchQuery = {
+ content_types: [contentType],
+ page: 1,
+ query: searchQuery,
+ page_size: 40,
+ };
+
+ const data = await proxiedFetch>(
+ "/content/titles/en_US/popular",
+ {
+ baseURL: JW_API_BASE,
+ params: {
+ body: JSON.stringify(body),
+ },
+ }
+ );
+
+ const returnData = data.items.map((v) => formatJWMeta(v));
+ cache.set(query, returnData, 3600); // cache for an hour
+ return returnData;
+}
diff --git a/src/backend/metadata/types.ts b/src/backend/metadata/types.ts
new file mode 100644
index 00000000..66bb9c1a
--- /dev/null
+++ b/src/backend/metadata/types.ts
@@ -0,0 +1,47 @@
+export enum MWMediaType {
+ MOVIE = "movie",
+ SERIES = "series",
+ ANIME = "anime",
+}
+
+export type MWSeasonMeta = {
+ id: string;
+ number: number;
+ title: string;
+};
+
+export type MWSeasonWithEpisodeMeta = {
+ id: string;
+ number: number;
+ title: string;
+ episodes: {
+ id: string;
+ number: number;
+ title: string;
+ }[];
+};
+
+type MWMediaMetaBase = {
+ title: string;
+ id: string;
+ year: string;
+ poster?: string;
+};
+
+type MWMediaMetaSpecific =
+ | {
+ type: MWMediaType.MOVIE | MWMediaType.ANIME;
+ seasons: undefined;
+ }
+ | {
+ type: MWMediaType.SERIES;
+ seasons: MWSeasonMeta[];
+ seasonData: MWSeasonWithEpisodeMeta;
+ };
+
+export type MWMediaMeta = MWMediaMetaBase & MWMediaMetaSpecific;
+
+export interface MWQuery {
+ searchQuery: string;
+ type: MWMediaType;
+}
diff --git a/src/backend/providers/flixhq.ts b/src/backend/providers/flixhq.ts
new file mode 100644
index 00000000..fdab1292
--- /dev/null
+++ b/src/backend/providers/flixhq.ts
@@ -0,0 +1,66 @@
+import { compareTitle } from "@/utils/titleMatch";
+import { proxiedFetch } from "../helpers/fetch";
+import { registerProvider } from "../helpers/register";
+import { MWStreamQuality, MWStreamType } from "../helpers/streams";
+import { MWMediaType } from "../metadata/types";
+
+const flixHqBase = "https://api.consumet.org/movies/flixhq";
+
+registerProvider({
+ id: "flixhq",
+ displayName: "FlixHQ",
+ rank: 100,
+ type: [MWMediaType.MOVIE],
+
+ async scrape({ media, progress }) {
+ // search for relevant item
+ const searchResults = await proxiedFetch(
+ `/${encodeURIComponent(media.meta.title)}`,
+ {
+ baseURL: flixHqBase,
+ }
+ );
+ const foundItem = searchResults.results.find((v: any) => {
+ return (
+ compareTitle(v.title, media.meta.title) &&
+ v.releaseDate === media.meta.year
+ );
+ });
+ if (!foundItem) throw new Error("No watchable item found");
+ const flixId = foundItem.id;
+
+ // get media info
+ progress(25);
+ const mediaInfo = await proxiedFetch("/info", {
+ baseURL: flixHqBase,
+ params: {
+ id: flixId,
+ },
+ });
+
+ // get stream info from media
+ progress(75);
+ const watchInfo = await proxiedFetch("/watch", {
+ baseURL: flixHqBase,
+ params: {
+ episodeId: mediaInfo.episodes[0].id,
+ mediaId: flixId,
+ },
+ });
+
+ // get best quality source
+ const source = watchInfo.sources.reduce((p: any, c: any) =>
+ c.quality > p.quality ? c : p
+ );
+
+ return {
+ embeds: [],
+ stream: {
+ streamUrl: source.url,
+ quality: MWStreamQuality.QUNKNOWN,
+ type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
+ captions: [],
+ },
+ };
+ },
+});
diff --git a/src/providers/list/gdriveplayer/index.ts b/src/backend/providers/gdriveplayer.ts
similarity index 57%
rename from src/providers/list/gdriveplayer/index.ts
rename to src/backend/providers/gdriveplayer.ts
index d13d2414..36cc470b 100644
--- a/src/providers/list/gdriveplayer/index.ts
+++ b/src/backend/providers/gdriveplayer.ts
@@ -1,15 +1,10 @@
import { unpack } from "unpacker";
import CryptoJS from "crypto-js";
-import {
- MWMediaProvider,
- MWMediaType,
- MWPortableMedia,
- MWMediaStream,
- MWQuery,
- MWProviderMediaResult,
-} from "@/providers/types";
-import { conf } from "@/config";
+import { registerProvider } from "@/backend/helpers/register";
+import { MWMediaType } from "@/backend/metadata/types";
+import { MWStreamQuality } from "@/backend/helpers/streams";
+import { proxiedFetch } from "../helpers/fetch";
const format = {
stringify: (cipher: any) => {
@@ -37,52 +32,23 @@ const format = {
},
};
-export const gDrivePlayerScraper: MWMediaProvider = {
+registerProvider({
id: "gdriveplayer",
- enabled: true,
- type: [MWMediaType.MOVIE],
displayName: "gdriveplayer",
+ rank: 69,
+ type: [MWMediaType.MOVIE],
- async getMediaFromPortable(
- media: MWPortableMedia
- ): Promise {
- const res = await fetch(
- `${conf().CORS_PROXY_URL}https://api.gdriveplayer.us/v1/imdb/${
- media.mediaId
- }`
- ).then((d) => d.json());
-
- return {
- ...media,
- title: res.Title,
- year: res.Year,
- } as MWProviderMediaResult;
- },
-
- async searchForMedia(query: MWQuery): Promise {
- const searchRes = await fetch(
- `${
- conf().CORS_PROXY_URL
- }https://api.gdriveplayer.us/v1/movie/search?title=${query.searchQuery}`
- ).then((d) => d.json());
-
- const results: MWProviderMediaResult[] = (searchRes || []).map(
- (item: any) => ({
- title: item.title,
- year: item.year,
- mediaId: item.imdb,
- })
+ async scrape({ progress, media: { imdbId } }) {
+ progress(10);
+ const streamRes = await proxiedFetch(
+ "https://database.gdriveplayer.us/player.php",
+ {
+ params: {
+ imdb: imdbId,
+ },
+ }
);
-
- return results;
- },
-
- async getStream(media: MWPortableMedia): Promise {
- const streamRes = await fetch(
- `${
- conf().CORS_PROXY_URL
- }https://database.gdriveplayer.us/player.php?imdb=${media.mediaId}`
- ).then((d) => d.text());
+ progress(90);
const page = new DOMParser().parseFromString(streamRes, "text/html");
const script: HTMLElement | undefined = Array.from(
@@ -105,6 +71,7 @@ export const gDrivePlayerScraper: MWMediaProvider = {
{ format }
).toString(CryptoJS.enc.Utf8)
);
+
// eslint-disable-next-line
const sources = JSON.parse(
JSON.stringify(
@@ -120,6 +87,18 @@ export const gDrivePlayerScraper: MWMediaProvider = {
const source = sources[sources.length - 1];
/// END
- return { url: `https:${source.file}`, type: source.type, captions: [] };
+ let quality;
+ if (source.label === "720p") quality = MWStreamQuality.Q720P;
+ else quality = MWStreamQuality.QUNKNOWN;
+
+ return {
+ stream: {
+ streamUrl: `https:${source.file}`,
+ type: source.type,
+ quality,
+ captions: [],
+ },
+ embeds: [],
+ };
},
-};
+});
diff --git a/src/backend/providers/m4ufree.ts b/src/backend/providers/m4ufree.ts
new file mode 100644
index 00000000..f4e79f5b
--- /dev/null
+++ b/src/backend/providers/m4ufree.ts
@@ -0,0 +1,235 @@
+import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
+import { proxiedFetch } from "../helpers/fetch";
+import { registerProvider } from "../helpers/register";
+import { MWMediaType } from "../metadata/types";
+
+const HOST = "m4ufree.com";
+const URL_BASE = `https://${HOST}`;
+const URL_SEARCH = `${URL_BASE}/search`;
+const URL_AJAX = `${URL_BASE}/ajax`;
+const URL_AJAX_TV = `${URL_BASE}/ajaxtv`;
+
+// * Years can be in one of 4 formats:
+// * - "startyear" (for movies, EX: 2022)
+// * - "startyear-" (for TV series which has not ended, EX: 2022-)
+// * - "startyear-endyear" (for TV series which has ended, EX: 2022-2023)
+// * - "startyearendyear" (for TV series which has ended, EX: 20222023)
+const REGEX_TITLE_AND_YEAR = /(.*) \(?(\d*|\d*-|\d*-\d*)\)?$/;
+const REGEX_TYPE = /.*-(movie|tvshow)-online-free-m4ufree\.html/;
+const REGEX_COOKIES = /XSRF-TOKEN=(.*?);.*laravel_session=(.*?);/;
+const REGEX_SEASON_EPISODE = /S(\d*)-E(\d*)/;
+
+function toDom(html: string) {
+ return new DOMParser().parseFromString(html, "text/html");
+}
+
+registerProvider({
+ id: "m4ufree",
+ displayName: "m4ufree",
+ rank: -1,
+ disabled: true, // Disables because the redirector URLs it returns will throw 404 / 403 depending on if you view it in the browser or fetch it respectively. It just does not work.
+ type: [MWMediaType.MOVIE, MWMediaType.SERIES],
+
+ async scrape({ media, type, episode: episodeId, season: seasonId }) {
+ const season =
+ media.meta.seasons?.find((s) => s.id === seasonId)?.number || 1;
+ const episode =
+ media.meta.type === MWMediaType.SERIES
+ ? media.meta.seasonData.episodes.find((ep) => ep.id === episodeId)
+ ?.number || 1
+ : undefined;
+
+ const embeds: MWEmbed[] = [];
+
+ /*
+, {
+ responseType: "text" as any,
+ }
+ */
+ const responseText = await proxiedFetch(
+ `${URL_SEARCH}/${encodeURIComponent(media.meta.title)}.html`
+ );
+ let dom = toDom(responseText);
+
+ const searchResults = [...dom.querySelectorAll(".item")]
+ .map((element) => {
+ const tooltipText = element.querySelector(".tiptitle p")?.innerHTML;
+ if (!tooltipText) return;
+
+ let regexResult = REGEX_TITLE_AND_YEAR.exec(tooltipText);
+
+ if (!regexResult || !regexResult[1] || !regexResult[2]) {
+ return;
+ }
+
+ const title = regexResult[1];
+ const year = Number(regexResult[2].slice(0, 4)); // * Some media stores the start AND end year. Only need start year
+ const a = element.querySelector("a");
+ if (!a) return;
+ const href = a.href;
+
+ regexResult = REGEX_TYPE.exec(href);
+
+ if (!regexResult || !regexResult[1]) {
+ return;
+ }
+
+ let scraperDeterminedType = regexResult[1];
+
+ scraperDeterminedType =
+ scraperDeterminedType === "tvshow" ? "show" : "movie"; // * Map to Trakt type
+
+ return { type: scraperDeterminedType, title, year, href };
+ })
+ .filter((item) => item);
+
+ const mediaInResults = searchResults.find(
+ (item) =>
+ item &&
+ item.title === media.meta.title &&
+ item.year.toString() === media.meta.year
+ );
+
+ if (!mediaInResults) {
+ // * Nothing found
+ return {
+ embeds,
+ };
+ }
+
+ let cookies: string | null = "";
+ const responseTextFromMedia = await proxiedFetch(
+ mediaInResults.href,
+ {
+ onResponse(context) {
+ cookies = context.response.headers.get("X-Set-Cookie");
+ },
+ }
+ );
+ dom = toDom(responseTextFromMedia);
+
+ let regexResult = REGEX_COOKIES.exec(cookies);
+
+ if (!regexResult || !regexResult[1] || !regexResult[2]) {
+ // * DO SOMETHING?
+ throw new Error("No regexResults, yikesssssss kinda gross idk");
+ }
+
+ const cookieHeader = `XSRF-TOKEN=${regexResult[1]}; laravel_session=${regexResult[2]}`;
+
+ const token = dom
+ .querySelector('meta[name="csrf-token"]')
+ ?.getAttribute("content");
+ if (!token) return { embeds };
+
+ if (type === MWMediaType.SERIES) {
+ // * Get the season/episode data
+ const episodes = [...dom.querySelectorAll(".episode")]
+ .map((element) => {
+ regexResult = REGEX_SEASON_EPISODE.exec(element.innerHTML);
+
+ if (!regexResult || !regexResult[1] || !regexResult[2]) {
+ return;
+ }
+
+ const newEpisode = Number(regexResult[1]);
+ const newSeason = Number(regexResult[2]);
+
+ return {
+ id: element.getAttribute("idepisode"),
+ episode: newEpisode,
+ season: newSeason,
+ };
+ })
+ .filter((item) => item);
+
+ const ep = episodes.find(
+ (newEp) => newEp && newEp.episode === episode && newEp.season === season
+ );
+ if (!ep) return { embeds };
+
+ const form = `idepisode=${ep.id}&_token=${token}`;
+
+ const response = await proxiedFetch(URL_AJAX_TV, {
+ method: "POST",
+ headers: {
+ Accept: "*/*",
+ "Accept-Encoding": "gzip, deflate, br",
+ "Accept-Language": "en-US,en;q=0.9",
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
+ "X-Requested-With": "XMLHttpRequest",
+ "Sec-CH-UA":
+ '"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"',
+ "Sec-CH-UA-Mobile": "?0",
+ "Sec-CH-UA-Platform": '"Linux"',
+ "Sec-Fetch-Site": "same-origin",
+ "Sec-Fetch-Mode": "cors",
+ "Sec-Fetch-Dest": "empty",
+ "X-Cookie": cookieHeader,
+ "X-Origin": URL_BASE,
+ "X-Referer": mediaInResults.href,
+ },
+ body: form,
+ });
+
+ dom = toDom(response);
+ }
+
+ const servers = [...dom.querySelectorAll(".singlemv")].map((element) =>
+ element.getAttribute("data")
+ );
+
+ for (const server of servers) {
+ const form = `m4u=${server}&_token=${token}`;
+
+ const response = await proxiedFetch(URL_AJAX, {
+ method: "POST",
+ headers: {
+ Accept: "*/*",
+ "Accept-Encoding": "gzip, deflate, br",
+ "Accept-Language": "en-US,en;q=0.9",
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
+ "X-Requested-With": "XMLHttpRequest",
+ "Sec-CH-UA":
+ '"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"',
+ "Sec-CH-UA-Mobile": "?0",
+ "Sec-CH-UA-Platform": '"Linux"',
+ "Sec-Fetch-Site": "same-origin",
+ "Sec-Fetch-Mode": "cors",
+ "Sec-Fetch-Dest": "empty",
+ "X-Cookie": cookieHeader,
+ "X-Origin": URL_BASE,
+ "X-Referer": mediaInResults.href,
+ },
+ body: form,
+ });
+
+ const serverDom = toDom(response);
+
+ const link = serverDom.querySelector("iframe")?.src;
+
+ const getEmbedType = (url: string) => {
+ if (url.startsWith("https://streamm4u.club"))
+ return MWEmbedType.STREAMM4U;
+ if (url.startsWith("https://play.playm4u.xyz"))
+ return MWEmbedType.PLAYM4U;
+ return null;
+ };
+
+ if (!link) continue;
+
+ const embedType = getEmbedType(link);
+ if (embedType) {
+ embeds.push({
+ url: link,
+ type: embedType,
+ });
+ }
+ }
+
+ console.log(embeds);
+ return {
+ embeds,
+ };
+ },
+});
diff --git a/src/backend/providers/netfilm.ts b/src/backend/providers/netfilm.ts
new file mode 100644
index 00000000..c33caa1b
--- /dev/null
+++ b/src/backend/providers/netfilm.ts
@@ -0,0 +1,128 @@
+import { proxiedFetch } from "../helpers/fetch";
+import { registerProvider } from "../helpers/register";
+import { MWStreamQuality, MWStreamType } from "../helpers/streams";
+import { MWMediaType } from "../metadata/types";
+
+const netfilmBase = "https://net-film.vercel.app";
+
+const qualityMap = {
+ "360": MWStreamQuality.Q360P,
+ "480": MWStreamQuality.Q480P,
+ "720": MWStreamQuality.Q720P,
+ "1080": MWStreamQuality.Q1080P,
+};
+type QualityInMap = keyof typeof qualityMap;
+
+registerProvider({
+ id: "netfilm",
+ displayName: "NetFilm",
+ rank: 150,
+ type: [MWMediaType.MOVIE, MWMediaType.SERIES],
+
+ async scrape({ media, episode, progress }) {
+ // search for relevant item
+ const searchResponse = await proxiedFetch(
+ `/api/search?keyword=${encodeURIComponent(media.meta.title)}`,
+ {
+ baseURL: netfilmBase,
+ }
+ );
+
+ const searchResults = searchResponse.data.results;
+ progress(25);
+
+ if (media.meta.type === MWMediaType.MOVIE) {
+ const foundItem = searchResults.find((v: any) => {
+ return v.name === media.meta.title && v.releaseTime === media.meta.year;
+ });
+ if (!foundItem) throw new Error("No watchable item found");
+ const netfilmId = foundItem.id;
+
+ // get stream info from media
+ progress(75);
+ const watchInfo = await proxiedFetch(
+ `/api/episode?id=${netfilmId}`,
+ {
+ baseURL: netfilmBase,
+ }
+ );
+
+ const { qualities } = watchInfo.data;
+
+ // get best quality source
+ const source = qualities.reduce((p: any, c: any) =>
+ c.quality > p.quality ? c : p
+ );
+
+ return {
+ embeds: [],
+ stream: {
+ streamUrl: source.url,
+ quality: qualityMap[source.quality as QualityInMap],
+ type: MWStreamType.HLS,
+ captions: [],
+ },
+ };
+ }
+
+ if (media.meta.type !== MWMediaType.SERIES)
+ throw new Error("Unsupported type");
+
+ const desiredSeason = media.meta.seasonData.number;
+
+ const searchItems = searchResults
+ .filter((v: any) => {
+ return v.name.includes(media.meta.title);
+ })
+ .map((v: any) => {
+ return {
+ ...v,
+ season: parseInt(v.name.split(" ").at(-1), 10) || 1,
+ };
+ });
+
+ const foundItem = searchItems.find((v: any) => {
+ return v.season === desiredSeason;
+ });
+
+ progress(50);
+ const seasonDetail = await proxiedFetch(
+ `/api/detail?id=${foundItem.id}&category=${foundItem.categoryTag[0].id}`,
+ {
+ baseURL: netfilmBase,
+ }
+ );
+
+ const episodeNo = media.meta.seasonData.episodes.find(
+ (v: any) => v.id === episode
+ )?.number;
+ const episodeData = seasonDetail.data.episodeVo.find(
+ (v: any) => v.seriesNo === episodeNo
+ );
+
+ progress(75);
+ const episodeStream = await proxiedFetch(
+ `/api/episode?id=${foundItem.id}&category=1&episode=${episodeData.id}`,
+ {
+ baseURL: netfilmBase,
+ }
+ );
+
+ const { qualities } = episodeStream.data;
+
+ // get best quality source
+ const source = qualities.reduce((p: any, c: any) =>
+ c.quality > p.quality ? c : p
+ );
+
+ return {
+ embeds: [],
+ stream: {
+ streamUrl: source.url,
+ quality: qualityMap[source.quality as QualityInMap],
+ type: MWStreamType.HLS,
+ captions: [],
+ },
+ };
+ },
+});
diff --git a/src/providers/list/superstream/LICENSE b/src/backend/providers/superstream/LICENSE
similarity index 100%
rename from src/providers/list/superstream/LICENSE
rename to src/backend/providers/superstream/LICENSE
diff --git a/src/backend/providers/superstream/index.ts b/src/backend/providers/superstream/index.ts
new file mode 100644
index 00000000..8abed467
--- /dev/null
+++ b/src/backend/providers/superstream/index.ts
@@ -0,0 +1,249 @@
+import { registerProvider } from "@/backend/helpers/register";
+import { MWMediaType } from "@/backend/metadata/types";
+
+import { customAlphabet } from "nanoid";
+import CryptoJS from "crypto-js";
+import { proxiedFetch } from "@/backend/helpers/fetch";
+import {
+ MWCaption,
+ MWCaptionType,
+ MWStreamQuality,
+ MWStreamType,
+} from "@/backend/helpers/streams";
+import { compareTitle } from "@/utils/titleMatch";
+
+const nanoid = customAlphabet("0123456789abcdef", 32);
+
+const qualityMap = {
+ "360p": MWStreamQuality.Q360P,
+ "480p": MWStreamQuality.Q480P,
+ "720p": MWStreamQuality.Q720P,
+ "1080p": MWStreamQuality.Q1080P,
+};
+type QualityInMap = keyof typeof qualityMap;
+
+// CONSTANTS, read below (taken from og)
+// We do not want content scanners to notice this scraping going on so we've hidden all constants
+// The source has its origins in China so I added some extra security with banned words
+// Mayhaps a tiny bit unethical, but this source is just too good :)
+// If you are copying this code please use precautions so they do not change their api.
+const iv = atob("d0VpcGhUbiE=");
+const key = atob("MTIzZDZjZWRmNjI2ZHk1NDIzM2FhMXc2");
+const apiUrls = [
+ atob("aHR0cHM6Ly9zaG93Ym94LnNoZWd1Lm5ldC9hcGkvYXBpX2NsaWVudC9pbmRleC8="),
+ atob("aHR0cHM6Ly9tYnBhcGkuc2hlZ3UubmV0L2FwaS9hcGlfY2xpZW50L2luZGV4Lw=="),
+];
+const appKey = atob("bW92aWVib3g=");
+const appId = atob("Y29tLnRkby5zaG93Ym94");
+
+// cryptography stuff
+const crypto = {
+ encrypt(str: string) {
+ return CryptoJS.TripleDES.encrypt(str, CryptoJS.enc.Utf8.parse(key), {
+ iv: CryptoJS.enc.Utf8.parse(iv),
+ }).toString();
+ },
+ getVerify(str: string, str2: string, str3: string) {
+ if (str) {
+ return CryptoJS.MD5(
+ CryptoJS.MD5(str2).toString() + str3 + str
+ ).toString();
+ }
+ return null;
+ },
+};
+
+// get expire time
+const expiry = () => Math.floor(Date.now() / 1000 + 60 * 60 * 12);
+
+// sending requests
+const get = (data: object, altApi = false) => {
+ const defaultData = {
+ childmode: "0",
+ app_version: "11.5",
+ appid: appId,
+ lang: "en",
+ expired_date: `${expiry()}`,
+ platform: "android",
+ channel: "Website",
+ };
+ const encryptedData = crypto.encrypt(
+ JSON.stringify({
+ ...defaultData,
+ ...data,
+ })
+ );
+ const appKeyHash = CryptoJS.MD5(appKey).toString();
+ const verify = crypto.getVerify(encryptedData, appKey, key);
+ const body = JSON.stringify({
+ app_key: appKeyHash,
+ verify,
+ encrypt_data: encryptedData,
+ });
+ const b64Body = btoa(body);
+
+ const formatted = new URLSearchParams();
+ formatted.append("data", b64Body);
+ formatted.append("appid", "27");
+ formatted.append("platform", "android");
+ formatted.append("version", "129");
+ formatted.append("medium", "Website");
+
+ const requestUrl = altApi ? apiUrls[1] : apiUrls[0];
+ return proxiedFetch(requestUrl, {
+ method: "POST",
+ parseResponse: JSON.parse,
+ headers: {
+ Platform: "android",
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: `${formatted.toString()}&token${nanoid()}`,
+ });
+};
+
+// Find best resolution
+const getBestQuality = (list: any[]) => {
+ return (
+ list.find((quality: any) => quality.quality === "1080p" && quality.path) ??
+ list.find((quality: any) => quality.quality === "720p" && quality.path) ??
+ list.find((quality: any) => quality.quality === "480p" && quality.path) ??
+ list.find((quality: any) => quality.quality === "360p" && quality.path)
+ );
+};
+
+registerProvider({
+ id: "superstream",
+ displayName: "Superstream",
+ rank: 200,
+ type: [MWMediaType.MOVIE, MWMediaType.SERIES],
+
+ async scrape({ media, episode, progress }) {
+ // Find Superstream ID for show
+ const searchQuery = {
+ module: "Search3",
+ page: "1",
+ type: "all",
+ keyword: media.meta.title,
+ pagelimit: "20",
+ };
+ const searchRes = (await get(searchQuery, true)).data;
+ progress(33);
+
+ const superstreamEntry = searchRes.find(
+ (res: any) =>
+ compareTitle(res.title, media.meta.title) &&
+ res.year === Number(media.meta.year)
+ );
+
+ if (!superstreamEntry) throw new Error("No entry found on SuperStream");
+ const superstreamId = superstreamEntry.id;
+
+ // Movie logic
+ if (media.meta.type === MWMediaType.MOVIE) {
+ const apiQuery = {
+ uid: "",
+ module: "Movie_downloadurl_v3",
+ mid: superstreamId,
+ oss: "1",
+ group: "",
+ };
+
+ const mediaRes = (await get(apiQuery)).data;
+ progress(50);
+
+ const hdQuality = getBestQuality(mediaRes.list);
+
+ if (!hdQuality) throw new Error("No quality could be found.");
+
+ const subtitleApiQuery = {
+ fid: hdQuality.fid,
+ uid: "",
+ module: "Movie_srt_list_v2",
+ mid: superstreamId,
+ };
+
+ const subtitleRes = (await get(subtitleApiQuery)).data;
+
+ const mappedCaptions = subtitleRes.list.map(
+ (subtitle: any): MWCaption => {
+ return {
+ needsProxy: true,
+ langIso: subtitle.language,
+ url: subtitle.subtitles[0].file_path,
+ type: MWCaptionType.SRT,
+ };
+ }
+ );
+
+ return {
+ embeds: [],
+ stream: {
+ streamUrl: hdQuality.path,
+ quality: qualityMap[hdQuality.quality as QualityInMap],
+ type: MWStreamType.MP4,
+ captions: mappedCaptions,
+ },
+ };
+ }
+
+ if (media.meta.type !== MWMediaType.SERIES)
+ throw new Error("Unsupported type");
+
+ // Fetch requested episode
+ const apiQuery = {
+ uid: "",
+ module: "TV_downloadurl_v3",
+ tid: superstreamId,
+ season: media.meta.seasonData.number.toString(),
+ episode: (
+ media.meta.seasonData.episodes.find(
+ (episodeInfo) => episodeInfo.id === episode
+ )?.number ?? 1
+ ).toString(),
+ oss: "1",
+ group: "",
+ };
+
+ const mediaRes = (await get(apiQuery)).data;
+ progress(66);
+
+ const hdQuality = getBestQuality(mediaRes.list);
+
+ if (!hdQuality) throw new Error("No quality could be found.");
+
+ const subtitleApiQuery = {
+ fid: hdQuality.fid,
+ uid: "",
+ module: "TV_srt_list_v2",
+ episode:
+ media.meta.seasonData.episodes.find(
+ (episodeInfo) => episodeInfo.id === episode
+ )?.number ?? 1,
+ tid: superstreamId,
+ season: media.meta.seasonData.number.toString(),
+ };
+
+ const subtitleRes = (await get(subtitleApiQuery)).data;
+
+ const mappedCaptions = subtitleRes.list.map((subtitle: any): MWCaption => {
+ return {
+ needsProxy: true,
+ langIso: subtitle.language,
+ url: subtitle.subtitles[0].file_path,
+ type: MWCaptionType.SRT,
+ };
+ });
+
+ return {
+ embeds: [],
+ stream: {
+ quality: qualityMap[
+ hdQuality.quality as QualityInMap
+ ] as MWStreamQuality,
+ streamUrl: hdQuality.path,
+ type: MWStreamType.MP4,
+ captions: mappedCaptions,
+ },
+ };
+ },
+});
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
new file mode 100644
index 00000000..9a74f84d
--- /dev/null
+++ b/src/components/Button.tsx
@@ -0,0 +1,25 @@
+import { Icon, Icons } from "@/components/Icon";
+import { ReactNode } from "react";
+
+interface Props {
+ icon?: Icons;
+ onClick?: () => void;
+ children?: ReactNode;
+}
+
+export function Button(props: Props) {
+ return (
+
+ {props.icon ? (
+
+
+
+ ) : null}
+ {props.children}
+
+ );
+}
diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx
index a73ad138..84ab2957 100644
--- a/src/components/Dropdown.tsx
+++ b/src/components/Dropdown.tsx
@@ -57,5 +57,5 @@ export function Dropdown(props: DropdownProps) {
)}
- )
+ );
}
diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx
index 65d47891..bf0a0ae2 100644
--- a/src/components/Icon.tsx
+++ b/src/components/Icon.tsx
@@ -1,12 +1,16 @@
+import { memo, useEffect, useRef } from "react";
+
export enum Icons {
SEARCH = "search",
BOOKMARK = "bookmark",
+ BOOKMARK_OUTLINE = "bookmark_outline",
CLOCK = "clock",
EYE_SLASH = "eyeSlash",
ARROW_LEFT = "arrowLeft",
ARROW_RIGHT = "arrowRight",
CHEVRON_DOWN = "chevronDown",
CHEVRON_RIGHT = "chevronRight",
+ CHEVRON_LEFT = "chevronLeft",
CLAPPER_BOARD = "clapperBoard",
FILM = "film",
DRAGON = "dragon",
@@ -14,6 +18,22 @@ export enum Icons {
MOVIE_WEB = "movieWeb",
DISCORD = "discord",
GITHUB = "github",
+ PLAY = "play",
+ PAUSE = "pause",
+ EXPAND = "expand",
+ COMPRESS = "compress",
+ VOLUME = "volume",
+ VOLUME_X = "volume_x",
+ X = "x",
+ EDIT = "edit",
+ AIRPLAY = "airplay",
+ EPISODES = "episodes",
+ SKIP_FORWARD = "skip_forward",
+ SKIP_BACKWARD = "skip_backward",
+ FILE = "file",
+ CAPTIONS = "captions",
+ LINK = "link",
+ CASTING = "casting",
}
export interface IconProps {
@@ -29,6 +49,7 @@ const iconList: Record