Merge branch 'dev' of github.com:JamesHawkinss/movie-web into dev

This commit is contained in:
Jelle van Snik 2022-05-04 00:09:17 +02:00
commit 127788ef6e
15 changed files with 3069 additions and 3013 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
.git
node_modules
build
.env.local
.github
.vscode

View File

@ -1,5 +1,5 @@
const a11yOff = Object.keys(require('eslint-plugin-jsx-a11y').rules) const a11yOff = Object.keys(require('eslint-plugin-jsx-a11y').rules)
.reduce((acc, rule) => { acc[`jsx-a11y/${rule}`] = 'off'; return acc }, {}) .reduce((acc, rule) => { acc[`jsx-a11y/${rule}`] = 'off'; return acc }, {})
module.exports = { module.exports = {
extends: [ extends: [
@ -37,6 +37,10 @@ module.exports = {
"@typescript-eslint/no-shadow": ["error"], "@typescript-eslint/no-shadow": ["error"],
"no-restricted-syntax": "off", "no-restricted-syntax": "off",
"react/jsx-props-no-spreading": "off", "react/jsx-props-no-spreading": "off",
"consistent-return": "off",
"no-continue": "off",
"no-eval": "off",
"no-await-in-loop": "off",
"react/jsx-filename-extension": [ "react/jsx-filename-extension": [
"error", "error",
{ extensions: [".js", ".tsx", ".jsx"] }, { extensions: [".js", ".tsx", ".jsx"] },

13
dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM node:16.15-alpine as build
WORKDIR /app
ENV PATH /app/node_modules/.bin:$PATH
COPY package*.json ./
RUN yarn install
COPY . ./
RUN yarn build
# production environment
FROM nginx:stable-alpine
COPY --from=build /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -5,24 +5,15 @@
"homepage": "https://movie.squeezebox.dev", "homepage": "https://movie.squeezebox.dev",
"dependencies": { "dependencies": {
"@headlessui/react": "^1.5.0", "@headlessui/react": "^1.5.0",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/crypto-js": "^4.1.1",
"@types/react-router": "^5.1.18",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"fuse.js": "^6.4.6", "fuse.js": "^6.4.6",
"hls.js": "^1.0.7", "hls.js": "^1.0.7",
"json5": "^2.2.0", "json5": "^2.2.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-helmet": "^6.1.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scripts": "^5.0.0", "react-scripts": "5.0.1",
"react-tracked": "^1.7.6", "unpacker": "^1.0.1"
"scheduler": "^0.20.2",
"unpacker": "^1.0.1",
"web-vitals": "^1.0.1"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
@ -43,11 +34,12 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^27.4.0", "@types/crypto-js": "^4.1.1",
"@types/node": "^17.0.15", "@types/node": "^17.0.15",
"@types/react": "^17.0.39", "@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11", "@types/react-dom": "^17.0.11",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/react-router": "^5.1.18",
"@typescript-eslint/eslint-plugin": "^5.13.0", "@typescript-eslint/eslint-plugin": "^5.13.0",
"@typescript-eslint/parser": "^5.13.0", "@typescript-eslint/parser": "^5.13.0",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
@ -57,7 +49,7 @@
"eslint-import-resolver-typescript": "^2.5.0", "eslint-import-resolver-typescript": "^2.5.0",
"eslint-plugin-import": "^2.25.4", "eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-react": "7.28.0", "eslint-plugin-react": "7.29.4",
"eslint-plugin-react-hooks": "4.3.0", "eslint-plugin-react-hooks": "4.3.0",
"postcss": "^8.4.6", "postcss": "^8.4.6",
"prettier": "^2.5.1", "prettier": "^2.5.1",

View File

@ -8,7 +8,6 @@ import { MediaView } from "./views/MediaView";
import { SearchView } from "./views/SearchView"; import { SearchView } from "./views/SearchView";
function App() { function App() {
return ( return (
<WatchedContextProvider> <WatchedContextProvider>
<BookmarkContextProvider> <BookmarkContextProvider>

View File

@ -7,31 +7,39 @@ import { TextInputControl } from "./text-inputs/TextInputControl";
export interface SearchBarProps { export interface SearchBarProps {
buttonText?: string; buttonText?: string;
placeholder?: string; placeholder?: string;
onChange: (value: MWQuery) => void; onChange: (value: MWQuery, force: boolean) => void;
onUnFocus: () => void;
value: MWQuery; value: MWQuery;
} }
export function SearchBarInput(props: SearchBarProps) { export function SearchBarInput(props: SearchBarProps) {
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
function setSearch(value: string) { function setSearch(value: string) {
props.onChange({ props.onChange(
...props.value, {
searchQuery: value, ...props.value,
}); searchQuery: value,
},
false
);
} }
function setType(type: string) { function setType(type: string) {
props.onChange({ props.onChange(
...props.value, {
type: type as MWMediaType, ...props.value,
}); type: type as MWMediaType,
},
true
);
} }
return ( return (
<div className="bg-denim-300 hover:bg-denim-400 focus-within:bg-denim-400 flex flex-col items-center gap-4 rounded-[28px] px-4 py-4 transition-colors sm:flex-row sm:py-2 sm:pl-8 sm:pr-2"> <div className="flex flex-col items-center gap-4 rounded-[28px] bg-denim-300 px-4 py-4 transition-colors focus-within:bg-denim-400 hover:bg-denim-400 sm:flex-row sm:py-2 sm:pl-8 sm:pr-2">
<TextInputControl <TextInputControl
onUnFocus={props.onUnFocus}
onChange={(val) => setSearch(val)} onChange={(val) => setSearch(val)}
value={props.value.searchQuery} value={props.value.searchQuery}
className="placeholder-denim-700 w-full flex-1 bg-transparent text-white focus:outline-none" className="w-full flex-1 bg-transparent text-white placeholder-denim-700 focus:outline-none"
placeholder={props.placeholder} placeholder={props.placeholder}
/> />

View File

@ -48,7 +48,7 @@ export class ErrorBoundary extends Component<
} }
render() { render() {
if (!this.state.hasError) return this.props.children; if (!this.state.hasError) return this.props.children as any;
return ( return (
<div className="flex min-h-screen w-full flex-col items-center justify-center px-4 py-12"> <div className="flex min-h-screen w-full flex-col items-center justify-center px-4 py-12">
@ -69,7 +69,7 @@ export class ErrorBoundary extends Component<
</p> </p>
</div> </div>
{this.state.error ? ( {this.state.error ? (
<div className="bg-denim-300 w-4xl mt-12 max-w-full rounded px-6 py-4"> <div className="w-4xl mt-12 max-w-full rounded bg-denim-300 px-6 py-4">
<p className="mb-1 break-words font-bold text-white"> <p className="mb-1 break-words font-bold text-white">
{this.state.error.name} - {this.state.error.description} {this.state.error.name} - {this.state.error.description}
</p> </p>

View File

@ -1,5 +1,6 @@
export interface TextInputControlPropsNoLabel { export interface TextInputControlPropsNoLabel {
onChange?: (data: string) => void; onChange?: (data: string) => void;
onUnFocus?: () => void;
value?: string; value?: string;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
@ -11,6 +12,7 @@ export interface TextInputControlProps extends TextInputControlPropsNoLabel {
export function TextInputControl({ export function TextInputControl({
onChange, onChange,
onUnFocus,
value, value,
label, label,
className, className,
@ -23,6 +25,7 @@ export function TextInputControl({
placeholder={placeholder} placeholder={placeholder}
onChange={(e) => onChange && onChange(e.target.value)} onChange={(e) => onChange && onChange(e.target.value)}
value={value} value={value}
onBlur={() => onUnFocus && onUnFocus()}
/> />
); );

View File

@ -1,6 +1,6 @@
import { MWPortableMedia } from "providers"; import { MWPortableMedia } from "providers";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router-dom";
export function deserializePortableMedia(media: string): MWPortableMedia { export function deserializePortableMedia(media: string): MWPortableMedia {
return JSON.parse(atob(decodeURIComponent(media))); return JSON.parse(atob(decodeURIComponent(media)));

View File

@ -1,19 +1,25 @@
import { MWMediaType, MWQuery } from "providers"; import { MWMediaType, MWQuery } from "providers";
import React, { useState } from "react"; import React, { useRef, useState } from "react";
import { generatePath, useHistory, useRouteMatch } from "react-router-dom"; import { generatePath, useHistory, useRouteMatch } from "react-router-dom";
export function useSearchQuery(): [MWQuery, (inp: Partial<MWQuery>) => void] { export function useSearchQuery(): [
MWQuery,
(inp: Partial<MWQuery>, force: boolean) => void,
() => void
] {
const history = useHistory(); const history = useHistory();
const isFirstRender = useRef(true);
const { path, params } = useRouteMatch<{ type: string; query: string }>(); const { path, params } = useRouteMatch<{ type: string; query: string }>();
const [search, setSearch] = useState<MWQuery>({ const [search, setSearch] = useState<MWQuery>({
searchQuery: "", searchQuery: "",
type: MWMediaType.MOVIE, type: MWMediaType.MOVIE,
}); });
const updateParams = (inp: Partial<MWQuery>) => { const updateParams = (inp: Partial<MWQuery>, force: boolean) => {
const copySearch: MWQuery = { ...search }; const copySearch: MWQuery = { ...search };
Object.assign(copySearch, inp); Object.assign(copySearch, inp);
setSearch(copySearch); setSearch(copySearch);
if (!force) return;
history.replace( history.replace(
generatePath(path, { generatePath(path, {
query: query:
@ -23,13 +29,27 @@ export function useSearchQuery(): [MWQuery, (inp: Partial<MWQuery>) => void] {
); );
}; };
const onUnFocus = () => {
history.replace(
generatePath(path, {
query: search.searchQuery.length === 0 ? undefined : search.searchQuery,
type: search.type,
})
);
};
// only run on first load of the page
React.useEffect(() => { React.useEffect(() => {
if (isFirstRender.current === false) {
return;
}
isFirstRender.current = false;
const type = const type =
Object.values(MWMediaType).find((v) => params.type === v) || Object.values(MWMediaType).find((v) => params.type === v) ||
MWMediaType.MOVIE; MWMediaType.MOVIE;
const searchQuery = params.query || ""; const searchQuery = params.query || "";
setSearch({ type, searchQuery }); setSearch({ type, searchQuery });
}, [params, setSearch]); }, [setSearch, params, isFirstRender]);
return [search, updateParams]; return [search, updateParams, onUnFocus];
} }

View File

@ -0,0 +1,103 @@
import {
MWMediaProvider,
MWMediaType,
MWPortableMedia,
MWMediaStream,
MWQuery,
MWProviderMediaResult,
MWMediaCaption
} from "providers/types";
import { CORS_PROXY_URL } from "mw_constants";
export const xemovieScraper: MWMediaProvider = {
id: "xemovie",
enabled: true,
type: [MWMediaType.MOVIE],
displayName: "xemovie",
async getMediaFromPortable(media: MWPortableMedia): Promise<MWProviderMediaResult> {
const res = await fetch(
`${CORS_PROXY_URL}https://xemovie.co/movies/${media.mediaId}/watch`,
).then(d => d.text());
const DOM = new DOMParser().parseFromString(res, "text/html");
const title = DOM.querySelector(".text-primary.text-lg.font-extrabold")?.textContent || "";
const year = DOM.querySelector("div.justify-between:nth-child(3) > div:nth-child(2)")?.textContent || "";
return {
...media,
title,
year,
} as MWProviderMediaResult;
},
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> {
const term = query.searchQuery.toLowerCase();
const searchUrl = `${CORS_PROXY_URL}https://xemovie.co/search?q=${encodeURIComponent(term)}`;
const searchRes = await fetch(searchUrl).then((d) => d.text());
const parser = new DOMParser();
const doc = parser.parseFromString(searchRes, "text/html");
const movieContainer = doc.querySelectorAll(".py-10")[0].querySelector(".grid");
if (!movieContainer) return [];
const movieNodes = Array.from(movieContainer.querySelectorAll("a")).filter(link => !link.className);
const results: MWProviderMediaResult[] = movieNodes.map((node) => {
const parent = node.parentElement;
if (!parent) return;
const aElement = parent.querySelector("a");
if (!aElement) return;
return {
title: parent.querySelector("div > div > a > h6")?.textContent,
year: parent.querySelector("div.float-right")?.textContent,
mediaId: aElement.href.split('/').pop() || "",
}
}).filter((d): d is MWProviderMediaResult => !!d);
return results;
},
async getStream(media: MWPortableMedia): Promise<MWMediaStream> {
if (media.mediaType !== MWMediaType.MOVIE) throw new Error("Incorrect type")
const url = `${CORS_PROXY_URL}https://xemovie.co/movies/${media.mediaId}/watch`;
let streamUrl = "";
const subtitles: MWMediaCaption[] = [];
const res = await fetch(url).then(d => d.text());
const scripts = Array.from(new DOMParser().parseFromString(res, "text/html").querySelectorAll("script"));
for (const script of scripts) {
if (!script.textContent) continue;
if (script.textContent.match(/https:\/\/[a-z][0-9]\.xemovie\.com/)) {
const data = JSON.parse(JSON.stringify(eval(`(${script.textContent.replace("const data = ", "").split("};")[0]}})`)));
streamUrl = data.playlist[0].file;
for (const [index, subtitleTrack] of data.playlist[0].tracks.entries()) {
const subtitleBlob = URL.createObjectURL(
await fetch(`${CORS_PROXY_URL}${subtitleTrack.file}`).then((captionRes) => captionRes.blob())
); // do this so no need for CORS errors
subtitles.push({
id: index,
url: subtitleBlob,
label: subtitleTrack.label
})
}
}
}
const streamType = streamUrl.split('.').at(-1);
if (streamType !== "mp4" && streamType !== "m3u8") throw new Error("Unsupported stream type");
return { url: streamUrl, type: streamType, captions: subtitles } as MWMediaStream;
}
};

View File

@ -2,11 +2,13 @@ import { theFlixScraper } from "providers/list/theflix";
import { gDrivePlayerScraper } from "providers/list/gdriveplayer"; import { gDrivePlayerScraper } from "providers/list/gdriveplayer";
import { MWWrappedMediaProvider, WrapProvider } from "providers/wrapper"; import { MWWrappedMediaProvider, WrapProvider } from "providers/wrapper";
import { gomostreamScraper } from "providers/list/gomostream"; import { gomostreamScraper } from "providers/list/gomostream";
import { xemovieScraper } from "providers/list/xemovie";
export const mediaProvidersUnchecked: MWWrappedMediaProvider[] = [ export const mediaProvidersUnchecked: MWWrappedMediaProvider[] = [
WrapProvider(theFlixScraper), WrapProvider(theFlixScraper),
WrapProvider(gDrivePlayerScraper), WrapProvider(gDrivePlayerScraper),
WrapProvider(gomostreamScraper), WrapProvider(gomostreamScraper),
WrapProvider(xemovieScraper),
]; ];
export const mediaProviders: MWWrappedMediaProvider[] = export const mediaProviders: MWWrappedMediaProvider[] =

View File

@ -73,7 +73,11 @@ function sortResults(
providerResults: MWMassProviderOutput providerResults: MWMassProviderOutput
): MWMassProviderOutput { ): MWMassProviderOutput {
const results: MWMassProviderOutput = { ...providerResults }; const results: MWMassProviderOutput = { ...providerResults };
const fuse = new Fuse(results.results, { threshold: 0.3, keys: ["title"] }); const fuse = new Fuse(results.results, {
threshold: 0.3,
keys: ["title"],
fieldNormWeight: 0.5,
});
results.results = fuse.search(query.searchQuery).map((v) => v.item); results.results = fuse.search(query.searchQuery).map((v) => v.item);
return results; return results;
} }

View File

@ -165,7 +165,7 @@ function ExtraItems() {
export function SearchView() { export function SearchView() {
const [searching, setSearching] = useState<boolean>(false); const [searching, setSearching] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [search, setSearch] = useSearchQuery(); const [search, setSearch, setSearchUnFocus] = useSearchQuery();
const debouncedSearch = useDebounce<MWQuery>(search, 2000); const debouncedSearch = useDebounce<MWQuery>(search, 2000);
useEffect(() => { useEffect(() => {
@ -182,7 +182,7 @@ export function SearchView() {
return ( return (
<SearchResultsView <SearchResultsView
searchQuery={debouncedSearch} searchQuery={debouncedSearch}
clear={() => setSearch({ searchQuery: "" })} clear={() => setSearch({ searchQuery: "" }, true)}
/> />
); );
return <ExtraItems />; return <ExtraItems />;
@ -201,6 +201,7 @@ export function SearchView() {
<SearchBarInput <SearchBarInput
onChange={setSearch} onChange={setSearch}
value={search} value={search}
onUnFocus={setSearchUnFocus}
placeholder="What movie do you want to watch?" placeholder="What movie do you want to watch?"
/> />
</div> </div>

5853
yarn.lock

File diff suppressed because it is too large Load Diff