mirror of
https://github.com/movie-web/movie-web.git
synced 2025-09-13 13:53:27 +00:00
Compare commits
37 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
47eba8caa4 | ||
|
1dc957b56a | ||
|
e653c72d87 | ||
|
c39d61cf53 | ||
|
b14a73378f | ||
|
43d1e290fc | ||
|
1f6318360e | ||
|
791299dd43 | ||
|
2c92bbf94e | ||
|
e3569c7ed7 | ||
|
196a805d32 | ||
|
94d6d7b37e | ||
|
fde5f0c82e | ||
|
bb449d6dfb | ||
|
bb8b21324b | ||
|
53fe6031d1 | ||
|
ee9400373d | ||
|
6c8cc63cbc | ||
|
8c105e78b5 | ||
|
28f253c542 | ||
|
38fa25da2c | ||
|
efb9a7a076 | ||
|
5eab635f19 | ||
|
c1dceab8eb | ||
|
e202229766 | ||
|
2e3684eaad | ||
|
31fcd22822 | ||
|
1524a3af39 | ||
|
072b2d134b | ||
|
606e55d552 | ||
|
0b8aeb1832 | ||
|
3bd2bb4b2c | ||
|
6e8e323417 | ||
|
50fdf230a1 | ||
|
765cf2a17a | ||
|
2d431595cd | ||
|
3bceb2a905 |
3
.github/CODEOWNERS
vendored
Normal file
3
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
* @movie-web/core
|
||||
|
||||
.github @binaryoverload
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "movie-web",
|
||||
"version": "3.1.3",
|
||||
"version": "3.2.5",
|
||||
"private": true,
|
||||
"homepage": "https://movie-web.app",
|
||||
"dependencies": {
|
||||
|
@@ -29,6 +29,32 @@ function isJSON(json: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function extractKey(script: string): [number, number][] | null {
|
||||
const startOfSwitch = script.lastIndexOf("switch");
|
||||
const endOfCases = script.indexOf("partKeyStartPosition");
|
||||
const switchBody = script.slice(startOfSwitch, endOfCases);
|
||||
|
||||
const nums: [number, number][] = [];
|
||||
const matches = switchBody.matchAll(
|
||||
/:[a-zA-Z0-9]+=([a-zA-Z0-9]+),[a-zA-Z0-9]+=([a-zA-Z0-9]+);/g
|
||||
);
|
||||
for (const match of matches) {
|
||||
const innerNumbers: number[] = [];
|
||||
for (const varMatch of [match[1], match[2]]) {
|
||||
const regex = new RegExp(`${varMatch}=0x([a-zA-Z0-9]+)`, "g");
|
||||
const varMatches = [...script.matchAll(regex)];
|
||||
const lastMatch = varMatches[varMatches.length - 1];
|
||||
if (!lastMatch) return null;
|
||||
const number = parseInt(lastMatch[1], 16);
|
||||
innerNumbers.push(number);
|
||||
}
|
||||
|
||||
nums.push([innerNumbers[0], innerNumbers[1]]);
|
||||
}
|
||||
|
||||
return nums;
|
||||
}
|
||||
|
||||
registerEmbedScraper({
|
||||
id: "upcloud",
|
||||
displayName: "UpCloud",
|
||||
@@ -51,27 +77,43 @@ registerEmbedScraper({
|
||||
}
|
||||
);
|
||||
|
||||
let sources:
|
||||
| {
|
||||
file: string;
|
||||
type: string;
|
||||
let sources: { file: string; type: string } | null = null;
|
||||
|
||||
if (!isJSON(streamRes.sources)) {
|
||||
const scriptJs = await proxiedFetch<string>(
|
||||
`https://rabbitstream.net/js/player/prod/e4-player.min.js`,
|
||||
{
|
||||
responseType: "text" as any,
|
||||
}
|
||||
| string = streamRes.sources;
|
||||
|
||||
if (!isJSON(sources) || typeof sources === "string") {
|
||||
const decryptionKey = await proxiedFetch<string>(
|
||||
`https://raw.githubusercontent.com/enimax-anime/key/e4/key.txt`
|
||||
);
|
||||
const decryptionKey = extractKey(scriptJs);
|
||||
if (!decryptionKey) throw new Error("Key extraction failed");
|
||||
|
||||
const decryptedStream = AES.decrypt(sources, decryptionKey).toString(
|
||||
enc.Utf8
|
||||
);
|
||||
let extractedKey = "";
|
||||
let strippedSources = streamRes.sources;
|
||||
let totalledOffset = 0;
|
||||
decryptionKey.forEach(([a, b]) => {
|
||||
const start = a + totalledOffset;
|
||||
const end = start + b;
|
||||
extractedKey += streamRes.sources.slice(start, end);
|
||||
strippedSources = strippedSources.replace(
|
||||
streamRes.sources.substring(start, end),
|
||||
""
|
||||
);
|
||||
totalledOffset += b;
|
||||
});
|
||||
|
||||
const decryptedStream = AES.decrypt(
|
||||
strippedSources,
|
||||
extractedKey
|
||||
).toString(enc.Utf8);
|
||||
const parsedStream = JSON.parse(decryptedStream)[0];
|
||||
if (!parsedStream) throw new Error("No stream found");
|
||||
sources = parsedStream as { file: string; type: string };
|
||||
sources = parsedStream;
|
||||
}
|
||||
|
||||
if (!sources) throw new Error("upcloud source not found");
|
||||
|
||||
return {
|
||||
embedId: MWEmbedType.UPCLOUD,
|
||||
streamUrl: sources.file,
|
||||
|
@@ -1,128 +0,0 @@
|
||||
import { compareTitle } from "@/utils/titleMatch";
|
||||
|
||||
import {
|
||||
getMWCaptionTypeFromUrl,
|
||||
isSupportedSubtitle,
|
||||
} from "../helpers/captions";
|
||||
import { mwFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWCaption, MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
const flixHqBase = "https://consumet-api-clone.vercel.app/meta/tmdb"; // instance stolen from streaminal :)
|
||||
|
||||
type FlixHQMediaType = "Movie" | "TV Series";
|
||||
interface FLIXMediaBase {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
image: string;
|
||||
type: FlixHQMediaType;
|
||||
releaseDate: string;
|
||||
}
|
||||
interface FLIXSubType {
|
||||
url: string;
|
||||
lang: string;
|
||||
}
|
||||
function convertSubtitles({ url, lang }: FLIXSubType): MWCaption | null {
|
||||
if (lang.includes("(maybe)")) return null;
|
||||
const supported = isSupportedSubtitle(url);
|
||||
if (!supported) return null;
|
||||
const type = getMWCaptionTypeFromUrl(url);
|
||||
return {
|
||||
url,
|
||||
langIso: lang,
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
const qualityMap: Record<string, MWStreamQuality> = {
|
||||
"360": MWStreamQuality.Q360P,
|
||||
"540": MWStreamQuality.Q540P,
|
||||
"480": MWStreamQuality.Q480P,
|
||||
"720": MWStreamQuality.Q720P,
|
||||
"1080": MWStreamQuality.Q1080P,
|
||||
};
|
||||
|
||||
function flixTypeToMWType(type: FlixHQMediaType) {
|
||||
if (type === "Movie") return MWMediaType.MOVIE;
|
||||
return MWMediaType.SERIES;
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
id: "flixhq",
|
||||
displayName: "FlixHQ",
|
||||
rank: 100,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
async scrape({ media, episode, progress }) {
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
// search for relevant item
|
||||
const searchResults = await mwFetch<any>(
|
||||
`/${encodeURIComponent(media.meta.title)}`,
|
||||
{
|
||||
baseURL: flixHqBase,
|
||||
}
|
||||
);
|
||||
|
||||
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
|
||||
if (v.type !== "Movie" && v.type !== "TV Series") return false;
|
||||
return (
|
||||
compareTitle(v.title, media.meta.title) &&
|
||||
flixTypeToMWType(v.type) === media.meta.type &&
|
||||
v.releaseDate === media.meta.year
|
||||
);
|
||||
});
|
||||
|
||||
if (!foundItem) throw new Error("No watchable item found");
|
||||
|
||||
// get media info
|
||||
progress(25);
|
||||
const mediaInfo = await mwFetch<any>(`/info/${foundItem.id}`, {
|
||||
baseURL: flixHqBase,
|
||||
params: {
|
||||
type: flixTypeToMWType(foundItem.type),
|
||||
},
|
||||
});
|
||||
if (!mediaInfo.id) throw new Error("No watchable item found");
|
||||
// get stream info from media
|
||||
progress(50);
|
||||
|
||||
let episodeId: string | undefined;
|
||||
if (media.meta.type === MWMediaType.MOVIE) {
|
||||
episodeId = mediaInfo.episodeId;
|
||||
} else if (media.meta.type === MWMediaType.SERIES) {
|
||||
const seasonNo = media.meta.seasonData.number;
|
||||
const episodeNo = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
|
||||
const season = mediaInfo.seasons.find((o: any) => o.season === seasonNo);
|
||||
episodeId = season.episodes.find((o: any) => o.episode === episodeNo).id;
|
||||
}
|
||||
if (!episodeId) throw new Error("No watchable item found");
|
||||
progress(75);
|
||||
const watchInfo = await mwFetch<any>(`/watch/${episodeId}`, {
|
||||
baseURL: flixHqBase,
|
||||
params: {
|
||||
id: mediaInfo.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!watchInfo.sources) throw new Error("No watchable item found");
|
||||
|
||||
// get best quality source
|
||||
// comes sorted by quality in descending order
|
||||
const source = watchInfo.sources[0];
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: source.url,
|
||||
quality: qualityMap[source.quality],
|
||||
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
|
||||
captions: watchInfo.subtitles.map(convertSubtitles).filter(Boolean),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
1
src/backend/providers/flixhq/common.ts
Normal file
1
src/backend/providers/flixhq/common.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const flixHqBase = "https://flixhq.to";
|
36
src/backend/providers/flixhq/index.ts
Normal file
36
src/backend/providers/flixhq/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { registerProvider } from "@/backend/helpers/register";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
import {
|
||||
getFlixhqSourceDetails,
|
||||
getFlixhqSources,
|
||||
} from "@/backend/providers/flixhq/scrape";
|
||||
import { getFlixhqId } from "@/backend/providers/flixhq/search";
|
||||
|
||||
registerProvider({
|
||||
id: "flixhq",
|
||||
displayName: "FlixHQ",
|
||||
rank: 100,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
async scrape({ media }) {
|
||||
const id = await getFlixhqId(media.meta);
|
||||
if (!id) throw new Error("flixhq no matching item found");
|
||||
|
||||
// TODO tv shows not supported. just need to scrape the specific episode sources
|
||||
|
||||
const sources = await getFlixhqSources(id);
|
||||
const upcloudStream = sources.find(
|
||||
(v) => v.embed.toLowerCase() === "upcloud"
|
||||
);
|
||||
if (!upcloudStream) throw new Error("upcloud stream not found for flixhq");
|
||||
|
||||
return {
|
||||
embeds: [
|
||||
{
|
||||
type: MWEmbedType.UPCLOUD,
|
||||
url: await getFlixhqSourceDetails(upcloudStream.episodeId),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
41
src/backend/providers/flixhq/scrape.ts
Normal file
41
src/backend/providers/flixhq/scrape.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { flixHqBase } from "@/backend/providers/flixhq/common";
|
||||
|
||||
export async function getFlixhqSources(id: string) {
|
||||
const type = id.split("/")[0];
|
||||
const episodeParts = id.split("-");
|
||||
const episodeId = episodeParts[episodeParts.length - 1];
|
||||
|
||||
const data = await proxiedFetch<string>(
|
||||
`/ajax/${type}/episodes/${episodeId}`,
|
||||
{
|
||||
baseURL: flixHqBase,
|
||||
}
|
||||
);
|
||||
const doc = new DOMParser().parseFromString(data, "text/html");
|
||||
|
||||
const sourceLinks = [...doc.querySelectorAll(".nav-item > a")].map((el) => {
|
||||
const embedTitle = el.getAttribute("title");
|
||||
const linkId = el.getAttribute("data-linkid");
|
||||
if (!embedTitle || !linkId) throw new Error("invalid sources");
|
||||
return {
|
||||
embed: embedTitle,
|
||||
episodeId: linkId,
|
||||
};
|
||||
});
|
||||
|
||||
return sourceLinks;
|
||||
}
|
||||
|
||||
export async function getFlixhqSourceDetails(
|
||||
sourceId: string
|
||||
): Promise<string> {
|
||||
const jsonData = await proxiedFetch<Record<string, any>>(
|
||||
`/ajax/sources/${sourceId}`,
|
||||
{
|
||||
baseURL: flixHqBase,
|
||||
}
|
||||
);
|
||||
|
||||
return jsonData.link;
|
||||
}
|
43
src/backend/providers/flixhq/search.ts
Normal file
43
src/backend/providers/flixhq/search.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||
import { flixHqBase } from "@/backend/providers/flixhq/common";
|
||||
import { compareTitle } from "@/utils/titleMatch";
|
||||
|
||||
export async function getFlixhqId(meta: MWMediaMeta): Promise<string | null> {
|
||||
const searchResults = await proxiedFetch<string>(
|
||||
`/search/${meta.title.replaceAll(/[^a-z0-9A-Z]/g, "-")}`,
|
||||
{
|
||||
baseURL: flixHqBase,
|
||||
}
|
||||
);
|
||||
|
||||
const doc = new DOMParser().parseFromString(searchResults, "text/html");
|
||||
const items = [...doc.querySelectorAll(".film_list-wrap > div.flw-item")].map(
|
||||
(el) => {
|
||||
const id = el
|
||||
.querySelector("div.film-poster > a")
|
||||
?.getAttribute("href")
|
||||
?.slice(1);
|
||||
const title = el
|
||||
.querySelector("div.film-detail > h2 > a")
|
||||
?.getAttribute("title");
|
||||
const year = el.querySelector(
|
||||
"div.film-detail > div.fd-infor > span:nth-child(1)"
|
||||
)?.textContent;
|
||||
|
||||
if (!id || !title || !year) return null;
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
year,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const matchingItem = items.find(
|
||||
(v) => v && compareTitle(meta.title, v.title) && meta.year === v.year
|
||||
);
|
||||
|
||||
if (!matchingItem) return null;
|
||||
return matchingItem.id;
|
||||
}
|
@@ -120,6 +120,7 @@ registerProvider({
|
||||
id: "hdwatched",
|
||||
displayName: "HDwatched",
|
||||
rank: 150,
|
||||
disabled: true, // very slow, haven't seen it work for a while
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
async scrape(options) {
|
||||
const { media, progress } = options;
|
||||
|
@@ -9,6 +9,7 @@ registerProvider({
|
||||
id: "sflix",
|
||||
displayName: "Sflix",
|
||||
rank: 50,
|
||||
disabled: true, // domain dead
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
async scrape({ media, episode, progress }) {
|
||||
let searchQuery = `${media.meta.title} `;
|
||||
|
@@ -148,13 +148,13 @@ registerProvider({
|
||||
async scrape({ media, episode, progress }) {
|
||||
// Find Superstream ID for show
|
||||
const searchQuery = {
|
||||
module: "Search3",
|
||||
module: "Search4",
|
||||
page: "1",
|
||||
type: "all",
|
||||
keyword: media.meta.title,
|
||||
pagelimit: "20",
|
||||
};
|
||||
const searchRes = (await get(searchQuery, true)).data;
|
||||
const searchRes = (await get(searchQuery, true)).data.list;
|
||||
progress(33);
|
||||
|
||||
const superstreamEntry = searchRes.find(
|
||||
@@ -248,6 +248,7 @@ registerProvider({
|
||||
const mappedCaptions = subtitleRes.list
|
||||
.map(convertSubtitles)
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
|
@@ -51,7 +51,7 @@ function MediaCardContent({
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
"relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100",
|
||||
"relative mb-4 w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center pb-[150%] transition-[border-radius] duration-100",
|
||||
closable ? "" : "group-hover:rounded-lg",
|
||||
].join(" ")}
|
||||
style={{
|
||||
@@ -117,7 +117,7 @@ function MediaCardContent({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
|
||||
<h1 className="mb-1 line-clamp-3 max-h-[4.5rem] text-ellipsis break-words font-bold text-white">
|
||||
<span>{media.title}</span>
|
||||
</h1>
|
||||
<DotList className="text-xs" content={dotListContent} />
|
||||
|
@@ -7,6 +7,7 @@ interface Config {
|
||||
TMDB_READ_API_KEY: string;
|
||||
CORS_PROXY_URL: string;
|
||||
NORMAL_ROUTER: boolean;
|
||||
DISALLOWED_IDS: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
@@ -16,6 +17,7 @@ export interface RuntimeConfig {
|
||||
TMDB_READ_API_KEY: string;
|
||||
NORMAL_ROUTER: boolean;
|
||||
PROXY_URLS: string[];
|
||||
DISALLOWED_IDS: string[];
|
||||
}
|
||||
|
||||
const env: Record<keyof Config, undefined | string> = {
|
||||
@@ -25,6 +27,7 @@ const env: Record<keyof Config, undefined | string> = {
|
||||
DISCORD_LINK: undefined,
|
||||
CORS_PROXY_URL: import.meta.env.VITE_CORS_PROXY_URL,
|
||||
NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER,
|
||||
DISALLOWED_IDS: import.meta.env.VITE_DISALLOWED_IDS,
|
||||
};
|
||||
|
||||
// loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js)
|
||||
@@ -61,5 +64,8 @@ export function conf(): RuntimeConfig {
|
||||
.split(",")
|
||||
.map((v) => v.trim()),
|
||||
NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true",
|
||||
DISALLOWED_IDS: getKey("DISALLOWED_IDS", "")
|
||||
.split(",")
|
||||
.map((v) => v.trim()), // Should be comma-seperated and contain the media type and ID, formatted like so: movie-753342,movie-753342,movie-753342
|
||||
};
|
||||
}
|
||||
|
@@ -15,10 +15,12 @@ import {
|
||||
} from "@/backend/metadata/types/mw";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
||||
import { Loading } from "@/components/layout/Loading";
|
||||
import { useGoBack } from "@/hooks/useGoBack";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { SelectedMediaData, useScrape } from "@/hooks/useScrape";
|
||||
import { conf } from "@/setup/config";
|
||||
import { useWatchedItem } from "@/state/watched";
|
||||
import { MetaController } from "@/video/components/controllers/MetaController";
|
||||
import { ProgressListenerController } from "@/video/components/controllers/ProgressListenerController";
|
||||
@@ -53,6 +55,31 @@ function MediaViewLoading(props: { onGoBack(): void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MediaVIewNotAllowed(props: { onGoBack(): void }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 items-center justify-center">
|
||||
<Helmet>
|
||||
<title>{t("videoPlayer.got")}</title>
|
||||
</Helmet>
|
||||
<div className="absolute inset-x-0 top-0 px-8 py-6">
|
||||
<VideoPlayerHeader onClick={props.onGoBack} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<ErrorMessage
|
||||
error={{
|
||||
name: "Media not allowed",
|
||||
description:
|
||||
"this media is no longer available due to a takedown notice or copyright claim",
|
||||
path: "",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MediaViewScrapingProps {
|
||||
onStream(stream: MWStream): void;
|
||||
onGoBack(): void;
|
||||
@@ -240,6 +267,14 @@ export function MediaView() {
|
||||
});
|
||||
}, [exec, history, params]);
|
||||
|
||||
const disallowedEntries = conf().DISALLOWED_IDS.map((id) => id.split("-"));
|
||||
if (
|
||||
disallowedEntries.find(
|
||||
(entry) => meta?.tmdbId === entry[1] && meta?.meta?.type === entry[0]
|
||||
)
|
||||
)
|
||||
return <MediaVIewNotAllowed onGoBack={goBack} />;
|
||||
|
||||
if (loading) return <MediaViewLoading onGoBack={goBack} />;
|
||||
if (error) return <MediaFetchErrorView />;
|
||||
if (!meta || !selected)
|
||||
|
@@ -42,5 +42,5 @@ module.exports = {
|
||||
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" }
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwind-scrollbar"), require("@tailwindcss/line-clamp")]
|
||||
plugins: [require("tailwind-scrollbar")]
|
||||
};
|
||||
|
Reference in New Issue
Block a user