Merge pull request #60 from mrjvs/master

V2 🎉
This commit is contained in:
James Hawkins 2022-05-01 15:47:32 +01:00 committed by GitHub
commit 97d26b6a49
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
121 changed files with 9257 additions and 10733 deletions

1
.env
View file

@ -1 +0,0 @@
REACT_APP_CORS_PROXY_URL=https://proxy-1.movie-web.workers.dev/?destination=

54
.eslintrc.js Normal file
View file

@ -0,0 +1,54 @@
const a11yOff = Object.keys(require('eslint-plugin-jsx-a11y').rules)
.reduce((acc, rule) => { acc[`jsx-a11y/${rule}`] = 'off'; return acc }, {})
module.exports = {
extends: [
"airbnb",
"airbnb/hooks",
"plugin:@typescript-eslint/recommended",
"prettier",
],
settings: {
"import/resolver": {
typescript: {},
},
},
parser: "@typescript-eslint/parser",
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: "./",
},
plugins: ["@typescript-eslint", "import"],
env: {
browser: true,
},
rules: {
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/require-default-props": "off",
"react/destructuring-assignment": "off",
"no-underscore-dangle": "off",
"@typescript-eslint/no-explicit-any": "off",
"no-console": "off",
"@typescript-eslint/no-this-alias": "off",
"import/prefer-default-export": "off",
"@typescript-eslint/no-empty-function": "off",
"no-shadow": "off",
"@typescript-eslint/no-shadow": ["error"],
"no-restricted-syntax": "off",
"react/jsx-props-no-spreading": "off",
"react/jsx-filename-extension": [
"error",
{ extensions: [".js", ".tsx", ".jsx"] },
],
"import/extensions": [
"error",
"ignorePackages",
{
ts: "never",
tsx: "never",
},
],
...a11yOff
},
};

6
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,6 @@
{
"files.eol": "\n",
"editor.detectIndentation": false,
"editor.formatOnSave": true,
"editor.tabSize": 2
}

View file

@ -1,26 +1,64 @@
# movie-web
Small web app for watching movies easily. Check it out at **[movie.squeezebox.dev](https://movie.squeezebox.dev)**.
<h1>movie-web</h1>
**[Join the Discord community](https://discord.gg/vXsRvye8BS)**
<p align="center">
<a href="https://github.com/JamesHawkinss/movie-web/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/workflow/status/JamesHawkinss/movie-web/Build%20&%20deploy?style=flat-square"></a>
<a href="https://github.com/JamesHawkinss/movie-web/blob/master/LICENSE.md"><img alt="GitHub license" src="https://img.shields.io/github/license/JamesHawkinss/movie-web?style=flat-square"></a>
<a href="https://github.com/JamesHawkinss/movie-web/network"><img alt="GitHub forks" src="https://img.shields.io/github/forks/JamesHawkinss/movie-web?style=flat-square"></a>
<a href="https://github.com/JamesHawkinss/movie-web/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/JamesHawkinss/movie-web?style=flat-square"></a><br/>
<a href="https://discord.gg/vXsRvye8BS"><img src="https://discordapp.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
</p>
## Credits
- Thanks to [@JipFr](https://github.com/JipFr) for initial work on [movie-cli](https://github.com/JipFr/movie-cli)
- Thanks to [@mrjvs](https://github.com/mrjvs) for help porting to React, and for the beautiful design
- Thanks to [@JoshHeng](https://github.com/JoshHeng/) for the Cloudflare CORS Proxy and URL routing
movie-web is a web app for watching movies easily. Check it out at **[movie.squeezebox.dev](https://movie.squeezebox.dev)**.
This service works by displaying video files from third-party providers inside an intuitive and aesthic user interface.
Features include:
- 🕑 Saving of your progress so you can come back to a video at any time!
- 🔖 Bookmarks to keep track of videos you would like to watch.
- 🎞️ Easy switching between seasons and episodes for a TV series; binge away!
- ✖️ Supports multiple types of content including movies, TV shows and Anime (coming soon™)
## Self-hosting
## Installation
To run this project locally for contributing or testing, run the following commands:
```
```bash
git clone https://github.com/JamesHawkinss/movie-web
cd movie-web
yarn install
yarn start
```
To build production files, simply run `yarn build`.
## Environment
* `REACT_APP_CORS_PROXY_URL` - The Cloudflare CORS Proxy, will be something like `https://PROXY.workers.dev?destination=`
<h2>Contributing - <a href="https://github.com/JamesHawkinss/movie-web/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/JamesHawkinss/movie-web?style=flat-square"></a>
<a href="https://github.com/JamesHawkinss/movie-web/pulls"><img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/JamesHawkinss/movie-web?style=flat-square"></a></h2>
## Contributing
Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/issues) for inspiration for contribution. Pull requests are always welcome.
Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/issues) for inspiration for contribution. Pull requests are always welcome.
## Credits
This project would not be possible without our amazing contributors and the community.
<a href="https://github.com/JamesHawkinss/movie-web/graphs/contributors"><img alt="GitHub contributors" src="https://img.shields.io/github/contributors/JamesHawkinss/movie-web?style=flat-square"></a>
<div style="display:flex;align-items:center;grid-gap:10px">
<img src="https://github.com/JipFr.png?size=20" width="20"><span><a href="https://github.com/JipFr">@JipFr</a> for initial work on <a href="https://github.com/JipFr/movie-cli">movie-cli</a>.</span>
</div>
<div style="display:flex;align-items:center;grid-gap:10px">
<img src="https://github.com/mrjvs.png?size=20" width="20"><span><a href="https://github.com/mrjvs">@mrjvs</a> for leading the port to React, and for the beautiful design.</span>
</div>
<div style="display:flex;align-items:center;grid-gap:10px">
<img src="https://github.com/JoshHeng.png?size=20" width="20"><span><a href="https://github.com/JoshHeng">@JoshHeng</a> for the Cloudflare CORS Proxy and URL routing.</span>
</div>
<div style="display:flex;align-items:center;grid-gap:10px">
<img src="https://github.com/binaryoverload.png?size=20" width="20"><span><a href="https://github.com/binaryoverload">@binaryoverload</a> for help rewriting the application into React and making the README look ✨ pretty ✨.</span>
</div>
<div style="display:flex;align-items:center;grid-gap:10px">
<img src="https://github.com/lem6ns.png?size=20" width="20"><span><a href="https://github.com/lem6ns">@lem6ns</a> for helpfully implementing extra scrapers.</span>
</div>

View file

@ -4,10 +4,13 @@
"private": true,
"homepage": "https://movie.squeezebox.dev",
"dependencies": {
"@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",
"crypto-js": "^4.0.0",
"@types/crypto-js": "^4.1.1",
"@types/react-router": "^5.1.18",
"crypto-js": "^4.1.1",
"fuse.js": "^6.4.6",
"hls.js": "^1.0.7",
"json5": "^2.2.0",
@ -15,21 +18,16 @@
"react-dom": "^17.0.2",
"react-helmet": "^6.1.0",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"react-scripts": "^5.0.0",
"react-tracked": "^1.7.6",
"scheduler": "^0.20.2",
"unpacker": "^1.0.1",
"web-vitals": "^1.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
"lint": "eslint --ext .tsx,.ts src"
},
"browserslist": {
"production": [
@ -42,5 +40,29 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/jest": "^27.4.0",
"@types/node": "^17.0.15",
"@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11",
"@types/react-router-dom": "^5.3.3",
"@typescript-eslint/eslint-plugin": "^5.13.0",
"@typescript-eslint/parser": "^5.13.0",
"autoprefixer": "^10.4.2",
"eslint": "^8.10.0",
"eslint-config-airbnb": "19.0.4",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^2.5.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-react": "7.28.0",
"eslint-plugin-react-hooks": "4.3.0",
"postcss": "^8.4.6",
"prettier": "^2.5.1",
"prettier-plugin-tailwindcss": "^0.1.7",
"tailwind-scrollbar": "^1.3.1",
"tailwindcss": "^3.0.20",
"typescript": "^4.6.4"
}
}

View file

@ -27,10 +27,14 @@
<meta name="msapplication-TileColor" content="#E880C5">
<meta name="theme-color" content="#E880C5">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap" rel="stylesheet">
<title>movie-web</title>
</head>
<body>
<noscript style="color: var(--text)">You need to enable JavaScript to run this app.</noscript>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View file

@ -1,19 +0,0 @@
import { SearchView } from './views/Search';
import { MovieView } from './views/Movie';
import { useMovie, MovieProvider } from './hooks/useMovie';
import './index.css';
function Router() {
const { streamData } = useMovie();
return streamData ? <MovieView /> : <SearchView />;
}
function App() {
return (
<MovieProvider>
<Router />
</MovieProvider>
);
}
export default App;

28
src/App.tsx Normal file
View file

@ -0,0 +1,28 @@
import { MWMediaType } from "providers";
import { Redirect, Route, Switch } from "react-router-dom";
import { BookmarkContextProvider } from "state/bookmark";
import { WatchedContextProvider } from "state/watched";
import { NotFoundPage } from "views/notfound/NotFoundView";
import "./index.css";
import { MediaView } from "./views/MediaView";
import { SearchView } from "./views/SearchView";
function App() {
return (
<WatchedContextProvider>
<BookmarkContextProvider>
<Switch>
<Route exact path="/">
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
</Route>
<Route exact path="/media/movie/:media" component={MediaView} />
<Route exact path="/media/series/:media" component={MediaView} />
<Route exact path="/search/:type/:query?" component={SearchView} />
<Route path="*" component={NotFoundPage} />
</Switch>
</BookmarkContextProvider>
</WatchedContextProvider>
);
}
export default App;

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>

Before

Width:  |  Height:  |  Size: 261 B

View file

@ -1,7 +0,0 @@
.feather.left {
transform: rotate(180deg);
}
.arrow {
display: inline-block;
}

View file

@ -1,15 +0,0 @@
import React from 'react'
import './Arrow.css'
// left?: boolean
export function Arrow(props) {
return (
<span className="arrow" dangerouslySetInnerHTML={{ __html: `
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather ${props.left?'left':''}"}>
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
`}}>
</span>
)
}

View file

@ -1,31 +0,0 @@
.card {
background-color: var(--card);
padding: 3rem 4rem;
margin: 0 3rem;
margin-bottom: 6rem;
border-radius: 10px;
box-sizing: border-box;
transition: height 500ms ease-in-out;
}
.card-wrapper.full {
width: 81rem;
}
.card-wrapper {
transition: height 500ms ease-in-out;
width: 45rem;
max-width: 100%;
}
.card-wrapper.overflow-hidden {
overflow: hidden;
}
@media screen and (max-width: 700px) {
.card {
margin: 0;
margin-bottom: 6rem;
padding: 3rem 2rem;
}
}

View file

@ -1,28 +0,0 @@
import React from 'react'
import './Card.css'
// fullWidth: boolean
// show: boolean
// doTransition: boolean
export function Card(props) {
const [showing, setShowing] = React.useState(false);
const measureRef = React.useRef(null)
const [height, setHeight] = React.useState(0);
React.useEffect(() => {
if (!measureRef?.current) return;
setShowing(props.show);
setHeight(measureRef.current.clientHeight)
}, [props.show, measureRef])
return (
<div className={`card-wrapper ${ props.fullWidth ? 'full' : '' } ${ props.doTransition ? 'overflow-hidden' : '' }`} style={{
height: props.doTransition ? (showing ? height : 0) : "initial",
}}>
<div className={`card ${ showing ? 'show' : '' } ${ props.doTransition ? 'doTransition' : '' }`} ref={measureRef}>
{props.children}
</div>
</div>
)
}

View file

@ -0,0 +1,61 @@
import { Icon, Icons } from "components/Icon";
import React, { Fragment } from "react";
import { Listbox, Transition } from "@headlessui/react";
export interface OptionItem {
id: string;
name: string;
}
interface DropdownProps {
selectedItem: OptionItem;
setSelectedItem: (value: OptionItem) => void;
options: Array<OptionItem>;
}
export const Dropdown = React.forwardRef<HTMLDivElement, DropdownProps>(
(props: DropdownProps) => (
<div className="relative my-4 max-w-[18rem]">
<Listbox value={props.selectedItem} onChange={props.setSelectedItem}>
{({ open }) => (
<>
<Listbox.Button className="bg-denim-500 focus-visible:ring-bink-500 focus-visible:ring-offset-bink-300 relative w-full cursor-default rounded-lg py-2 pl-3 pr-10 text-left text-white shadow-md focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-opacity-75 focus-visible:ring-offset-2 sm:text-sm">
<span className="block truncate">{props.selectedItem.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<Icon
icon={Icons.CHEVRON_DOWN}
className={`transform transition-transform ${
open ? "rotate-180" : ""
}`}
/>
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="bg-denim-500 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 absolute bottom-11 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:bottom-10 sm:text-sm">
{props.options.map((opt) => (
<Listbox.Option
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active ? "bg-denim-400 text-bink-700" : "text-white"
}`
}
key={opt.id}
value={opt}
>
{opt.name}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</>
)}
</Listbox>
</div>
)
);

View file

@ -1,3 +0,0 @@
.episodeSelector {
margin-top: 20px;
}

View file

@ -1,50 +0,0 @@
import React from 'react';
import { TypeSelector } from './TypeSelector';
import { NumberSelector } from './NumberSelector';
import { VideoProgressStore } from '../lib/storage/VideoProgress'
import { SelectBox } from '../components/SelectBox';
import './EpisodeSelector.css'
import { useWindowSize } from '../hooks/useWindowSize';
export function EpisodeSelector({ setSelectedSeason, selectedSeason, setEpisode, seasons, episodes, currentSeason, currentEpisode, streamData }) {
const choices = episodes ? episodes.map(v => {
const progressData = VideoProgressStore.get();
let currentlyAt = 0;
let totalDuration = 0;
const progress = progressData?.[streamData.source]?.[streamData.type]?.[streamData.slug]?.[`${selectedSeason}-${v}`]
if (progress) {
currentlyAt = progress.currentlyAt
totalDuration = progress.totalDuration
}
const percentage = Math.round((currentlyAt / totalDuration) * 100)
return {
value: v.toString(),
label: v,
percentage
}
}) : [];
const windowSize = useWindowSize()
return (
<div className="episodeSelector">
{
(seasons.length > 0 && (windowSize.width <= 768 || seasons.length > 4)) ?
(
<SelectBox setSelectedItem={(index) => setSelectedSeason(seasons[index])} selectedItem={seasons.findIndex(s => s === selectedSeason)} options={seasons.map(season => { return {id: season, name: `Season ${season}` }})}/>
)
:
(
<TypeSelector setType={setSelectedSeason} selected={selectedSeason} choices={seasons.map(v=>({ value: v.toString(), label: `Season ${v}`}))} />
)
}
<br></br>
<NumberSelector setType={(e) => setEpisode({episode: e, season: selectedSeason})} choices={choices} selected={(selectedSeason.toString() === currentSeason) ? currentEpisode : null} />
</div>
)
}

View file

@ -1,11 +0,0 @@
.errorBanner {
margin-top: 0.5rem;
border-inline-start: none;
font-size: 16px;
font-weight: normal;
letter-spacing: -.01em;
padding: .5rem 1rem .5rem .75rem;
border-radius: .25rem;
background-color: var(--button);
color: var(--button-text);
}

View file

@ -1,10 +0,0 @@
import React from 'react';
import './ErrorBanner.css';
export function ErrorBanner({children}) {
return (
<div className="errorBanner">
{children}
</div>
)
}

49
src/components/Icon.tsx Normal file
View file

@ -0,0 +1,49 @@
export enum Icons {
SEARCH = "search",
BOOKMARK = "bookmark",
CLOCK = "clock",
EYE_SLASH = "eyeSlash",
ARROW_LEFT = "arrowLeft",
ARROW_RIGHT = "arrowRight",
CHEVRON_DOWN = "chevronDown",
CHEVRON_RIGHT = "chevronRight",
CLAPPER_BOARD = "clapperBoard",
FILM = "film",
DRAGON = "dragon",
WARNING = "warning",
MOVIE_WEB = "movieWeb",
DISCORD = "discord",
GITHUB = "github",
}
export interface IconProps {
icon: Icons;
className?: string;
}
const iconList: Record<Icons, string> = {
search: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z"/></svg>`,
bookmark: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M384 48V512l-192-112L0 512V48C0 21.5 21.5 0 48 0h288C362.5 0 384 21.5 384 48z"/></svg>`,
clock: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M256 512C114.6 512 0 397.4 0 256C0 114.6 114.6 0 256 0C397.4 0 512 114.6 512 256C512 397.4 397.4 512 256 512zM232 256C232 264 236 271.5 242.7 275.1L338.7 339.1C349.7 347.3 364.6 344.3 371.1 333.3C379.3 322.3 376.3 307.4 365.3 300L280 243.2V120C280 106.7 269.3 96 255.1 96C242.7 96 231.1 106.7 231.1 120L232 256z"/></svg>`,
eyeSlash: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M150.7 92.77C195 58.27 251.8 32 320 32C400.8 32 465.5 68.84 512.6 112.6C559.4 156 590.7 207.1 605.5 243.7C608.8 251.6 608.8 260.4 605.5 268.3C592.1 300.6 565.2 346.1 525.6 386.7L630.8 469.1C641.2 477.3 643.1 492.4 634.9 502.8C626.7 513.2 611.6 515.1 601.2 506.9L9.196 42.89C-1.236 34.71-3.065 19.63 5.112 9.196C13.29-1.236 28.37-3.065 38.81 5.112L150.7 92.77zM223.1 149.5L313.4 220.3C317.6 211.8 320 202.2 320 191.1C320 180.5 316.1 169.7 311.6 160.4C314.4 160.1 317.2 159.1 320 159.1C373 159.1 416 202.1 416 255.1C416 269.7 413.1 282.7 407.1 294.5L446.6 324.7C457.7 304.3 464 280.9 464 255.1C464 176.5 399.5 111.1 320 111.1C282.7 111.1 248.6 126.2 223.1 149.5zM320 480C239.2 480 174.5 443.2 127.4 399.4C80.62 355.1 49.34 304 34.46 268.3C31.18 260.4 31.18 251.6 34.46 243.7C44 220.8 60.29 191.2 83.09 161.5L177.4 235.8C176.5 242.4 176 249.1 176 255.1C176 335.5 240.5 400 320 400C338.7 400 356.6 396.4 373 389.9L446.2 447.5C409.9 467.1 367.8 480 320 480H320z"/></svg>`,
arrowLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-left"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>`,
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
chevronRight: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-right"><polyline points="9 18 15 12 9 6"></polyline></svg>`,
clapperBoard: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M326.1 160l127.4-127.4C451.7 32.39 449.9 32 448 32h-86.06l-128 128H326.1zM166.1 160l128-128H201.9l-128 128H166.1zM497.7 56.19L393.9 160H512V96C512 80.87 506.5 67.15 497.7 56.19zM134.1 32H64C28.65 32 0 60.65 0 96v64h6.062L134.1 32zM0 416c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V192H0V416z"/></svg>`,
film: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M463.1 32h-416C21.49 32-.0001 53.49-.0001 80v352c0 26.51 21.49 48 47.1 48h416c26.51 0 48-21.49 48-48v-352C511.1 53.49 490.5 32 463.1 32zM111.1 408c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8L111.1 408zM111.1 280c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V280zM111.1 152c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8L111.1 152zM351.1 400c0 8.836-7.164 16-16 16H175.1c-8.836 0-16-7.164-16-16v-96c0-8.838 7.164-16 16-16h160c8.836 0 16 7.162 16 16V400zM351.1 208c0 8.836-7.164 16-16 16H175.1c-8.836 0-16-7.164-16-16v-96c0-8.838 7.164-16 16-16h160c8.836 0 16 7.162 16 16V208zM463.1 408c0 4.418-3.582 8-8 8h-47.1c-4.418 0-7.1-3.582-7.1-8l0-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V408zM463.1 280c0 4.418-3.582 8-8 8h-47.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V280zM463.1 152c0 4.418-3.582 8-8 8h-47.1c-4.418 0-8-3.582-8-8l0-48c0-4.418 3.582-8 7.1-8h47.1c4.418 0 8 3.582 8 8V152z"/></svg>`,
dragon: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M18.43 255.8L192 224L100.8 292.6C90.67 302.8 97.8 320 112 320h222.7c-9.499-26.5-14.75-54.5-14.75-83.38V194.2L200.3 106.8C176.5 90.88 145 92.75 123.3 111.2l-117.5 116.4C-6.562 238 2.436 258 18.43 255.8zM575.2 289.9l-100.7-50.25c-16.25-8.125-26.5-24.75-26.5-43V160h63.99l28.12 22.62C546.1 188.6 554.2 192 562.7 192h30.1c11.1 0 23.12-6.875 28.5-17.75l14.37-28.62c5.374-10.87 4.25-23.75-2.999-33.5l-74.49-99.37C552.1 4.75 543.5 0 533.5 0H296C288.9 0 285.4 8.625 290.4 13.62L351.1 64L292.4 88.75c-5.874 3-5.874 11.37 0 14.37L351.1 128l-.0011 108.6c0 72 35.99 139.4 95.99 179.4c-195.6 6.75-344.4 41-434.1 60.88c-8.124 1.75-13.87 9-13.87 17.38C.0463 504 8.045 512 17.79 512h499.1c63.24 0 119.6-47.5 122.1-110.8C642.3 354 617.1 310.9 575.2 289.9zM489.1 66.25l45.74 11.38c-2.75 11-12.5 18.88-24.12 18.25C497.7 95.25 484.8 83.38 489.1 66.25z"/></svg>`,
warning: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-triangle"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
arrowRight: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-right"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>`,
movieWeb: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 20.927 20.927"><path d="M18.186,4.5V6.241H16.445V4.5H9.482V6.241H7.741V4.5H6V20.168H7.741V18.427H9.482v1.741h6.964V18.427h1.741v1.741h1.741V4.5Zm-8.7,12.186H7.741V14.945H9.482Zm0-3.482H7.741V11.464H9.482Zm0-3.482H7.741V7.982H9.482Zm8.7,6.964H16.445V14.945h1.741Zm0-3.482H16.445V11.464h1.741Zm0-3.482H16.445V7.982h1.741Z" transform="translate(10.018 -7.425) rotate(45)" fill="currentColor"/></svg>`,
discord: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"/></svg>`,
github: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 496 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>`,
};
export function Icon(props: IconProps) {
return (
<span
dangerouslySetInnerHTML={{ __html: iconList[props.icon] }} // eslint-disable-line react/no-danger
className={props.className}
/>
);
}

View file

@ -1,95 +0,0 @@
.inputBar {
width: 100%;
display: flex;
height: 3rem;
}
.inputBar > *:first-child{
border-radius: 0 !important;
border-top-left-radius: 10px !important;
border-bottom-left-radius: 10px !important;
}
.inputBar > *:last-child {
border-radius: 0 !important;
border-top-right-radius: 10px !important;
border-bottom-right-radius: 10px !important;
}
.inputTextBox {
border-width: 0;
outline: none;
background-color: var(--content);
color: var(--text);
padding: .7rem 1.5rem;
height: auto;
flex: 1;
box-sizing: border-box;
}
.inputSearchButton {
background-color: var(--button);
border-width: 0;
color: var(--button-text, var(--text));
padding: .5rem 2.1rem;
font-weight: bold;
cursor: pointer;
}
.inputSearchButton:hover {
background-color: var(--button-hover);
}
.inputTextBox:hover {
background-color: var(--content-hover);
}
.inputSearchButton .text > .arrow {
opacity: 0;
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
position: absolute;
right: -0.8rem;
bottom: -0.2rem;
}
.inputSearchButton .text {
display: flex;
position: relative;
transition: transform 0.2s ease-in-out;
}
.inputSearchButton:hover .text > .arrow {
transform: translateX(8px);
opacity: 1;
}
.inputSearchButton:hover .text {
transform: translateX(-10px);
}
.inputSearchButton:active {
background-color: var(--button-active);
}
@media screen and (max-width: 700px) {
.inputBar {
flex-direction: column;
align-items: flex-start;
height: auto;
}
.inputBar > *:nth-child(n) {
border-radius: 10px !important;
}
.inputSearchButton {
margin-top: .5rem;
align-self: center;
}
.inputTextBox {
margin-top: .5rem;
width: 100%;
}
}

View file

@ -1,29 +0,0 @@
import React from 'react';
import { Arrow } from './Arrow';
import './InputBox.css'
// props = { onSubmit: (str) => {}, placeholder: string}
export function InputBox({ onSubmit, placeholder }) {
const [searchTerm, setSearchTerm] = React.useState("");
return (
<form className="inputBar" onSubmit={(e) => {
e.preventDefault();
onSubmit(searchTerm)
return false;
}}>
<input
type='text'
className="inputTextBox"
id="inputTextBox"
placeholder={placeholder}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
required
/>
<button className="inputSearchButton">
<span className="text">Search<span className="arrow"><Arrow /></span></span>
</button>
</form>
)
}

View file

@ -1,97 +0,0 @@
.movieRow {
position: relative;
display: flex;
border-radius: 5px;
background-color: var(--content);
color: var(--text);
padding: .8rem 1.5rem;
margin-top: .5rem;
cursor: pointer;
transition: transform 50ms ease-in-out;
user-select: none;
overflow: hidden;
}
.movieRow p {
margin: 0;
}
.movieRow .left {
flex: 1;
display: flex;
flex-flow: row wrap;
align-items: flex-start;
margin-right: 0.5rem;
}
.movieRow .left .titleWrapper {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.movieRow .left .seasonEpisodeSubtitle,
.movieRow .left .year {
color: var(--text-secondary);
}
.movieRow .watch {
color: var(--theme-color-text);
display: flex;
align-items: center;
}
.movieRow .watch .arrow {
margin-left: .5rem;
transition: transform 50ms ease-in-out;
transform: translateY(.1rem);
}
.movieRow:active {
transform: scale(1.02);
}
.movieRow:hover {
background-color: var(--content-hover);
}
.movieRow:hover .watch .arrow {
transform: translateX(.3rem) translateY(.1rem);
}
.movieRow:focus-visible {
border: 1px solid #fff;
background-color: var(--content-hover);
}
.movieRow:focus-visible .watch .arrow {
transform: translateX(.3rem) translateY(.1rem);
}
.attribute {
color: var(--text);
background-color: var(--theme-color);
font-size: .75rem;
padding: .25rem;
border-radius: 10px;
margin-right: 10px;
}
.subtitleIcon {
width: 30px;
display: flex;
justify-content: center;
align-items: center;
margin-right: 10px;
}
@media screen and (max-width: 400px) {
.movieRow {
flex-direction: column;
}
.movieRow .watch {
margin-top: .5rem;
}
}

View file

@ -1,59 +0,0 @@
import React from 'react'
import { Arrow } from './Arrow'
import { PercentageOverlay } from './PercentageOverlay'
import { VideoProgressStore } from '../lib/storage/VideoProgress'
import './MovieRow.css'
// title: string
// onClick: () => void
export function MovieRow(props) {
const progressData = VideoProgressStore.get();
let progress;
let percentage = null;
if (props.type === "movie") {
progress = progressData?.[props.source]?.movie?.[props.slug]?.full
if (progress) {
percentage = Math.floor((progress.currentlyAt / progress.totalDuration) * 100)
}
}
function handleKeyPress(event){
if ((event.code === 'Enter' || event.code === 'Space') && props.onClick){
props.onClick();
}
}
return (
<div className="movieRow" tabIndex={0} onKeyPress={handleKeyPress} onClick={() => props.onClick && props.onClick()}>
{ (props.source === "lookmovie" || props.source === "xemovie") && (
<div className="subtitleIcon">
<svg id="subtitleIcon" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 4H4C2.897 4 2 4.897 2 6V18C2 19.103 2.897 20 4 20H20C21.103 20 22 19.103 22 18V6C22 4.897 21.103 4 20 4ZM11 10H8V14H11V16H8C6.897 16 6 15.103 6 14V10C6 8.897 6.897 8 8 8H11V10ZM18 10H15V14H18V16H15C13.897 16 13 15.103 13 14V10C13 8.897 13.897 8 15 8H18V10Z" fill="#EEEEEE"/>
</svg>
</div>
) }
<div className="left">
{/* <Cross /> */}
<div className="titleWrapper">
<div className="titleText">
{props.title}
&nbsp;
<span className="year">({props.year})</span>
<span className="seasonEpisodeSubtitle">{props.place ? ` - S${props.place.season}:E${props.place.episode}` : ''}</span>
</div>
</div>
</div>
<div className="watch">
<p>Watch {props.type}</p>
<Arrow/>
</div>
<PercentageOverlay percentage={props.percentage || percentage} />
</div>
)
}

View file

@ -1,55 +0,0 @@
.numberSelector {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(2.5rem, 1fr));
gap: 5px;
position: relative;
margin-bottom: 1.5rem;
}
.numberSelector .choiceWrapper {
position: relative;
border-radius: 10%;
overflow: hidden;
}
.numberSelector .choiceWrapper::before {
content: '';
display: block;
width: 100%;
padding-bottom: 100%;
}
.numberSelector .choice {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--choice);
margin-right: 5px;
padding: .2rem;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
color: var(--text);
font-weight: bold;
cursor: pointer;
user-select: none;
box-sizing: border-box;
}
.numberSelector .choice:hover,
.numberSelector .choiceWrapper:focus-visible .choice {
background-color: var(--choice-hover);
}
.numberSelector .choiceWrapper:focus-visible {
border: 1px solid #fff;
}
.numberSelector .choice.selected {
color: var(--choice-active-text, var(--text));
background-color: var(--choice-active);
}

View file

@ -1,27 +0,0 @@
import React from 'react';
// import { Arrow } from './Arrow';
import './NumberSelector.css'
import { PercentageOverlay } from './PercentageOverlay';
// setType: (txt: string) => void
// choices: { label: string, value: string }[]
// selected: string
export function NumberSelector({ setType, choices, selected }) {
const handleKeyPress = choice => event => {
if (event.code === 'Space' || event.code === 'Enter'){
setType(choice);
}
}
return (
<div className="numberSelector">
{choices.map(v=>(
<div key={v.value} className="choiceWrapper" tabIndex={0} onKeyPress={handleKeyPress(v.value)}>
<div className={`choice ${selected&&selected===v.value?'selected':''}`} onClick={() => setType(v.value)}>
{v.label}
<PercentageOverlay percentage={v.percentage} />
</div>
</div>
))}
</div>
)
}

View file

@ -1,12 +0,0 @@
.progressBar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.2;
}
.progressBarInner {
background: var(--theme-color);
height: 100%;
}

View file

@ -1,13 +0,0 @@
import React from 'react'
import './PercentageOverlay.css'
export function PercentageOverlay({ percentage }) {
if(percentage && percentage > 3) percentage = Math.max(20, percentage < 90 ? percentage : 100)
return percentage > 0 ? (
<div className="progressBar">
<div className="progressBarInner" style={{width: `${percentage}%`}}></div>
</div>
) : <React.Fragment></React.Fragment>
}

View file

@ -1,43 +0,0 @@
.progress {
text-align: center;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
height: 5rem;
margin-top: 1rem;
transition: height 800ms ease-in-out, opacity 800ms ease-in-out;
opacity: 1;
}
.progress.hide {
opacity: 0;
height: 0rem;
}
.progress p {
margin: 0;
margin-bottom: 1rem;
}
.progress .bar {
width: 13rem;
max-width: 100%;
background-color: var(--content);
border-radius: 10px;
height: 7px;
display: inline-block;
}
.progress .bar .bar-inner {
transition: width 400ms ease-in-out, background-color 100ms ease-in-out;
background-color: var(--theme-color);
border-radius: 10px;
height: 100%;
width: 0%;
}
.progress.failed .bar .bar-inner {
background-color: var(--failed);
}

View file

@ -1,21 +0,0 @@
import React from 'react'
import './Progress.css'
// show: boolean
// progress: number
// steps: number
// text: string
// failed: boolean
export function Progress(props) {
return (
<div className={`progress ${props.show ? '' : 'hide'} ${props.failed ? 'failed' : ''}`}>
{ props.text && props.text.length > 0 ? (
<p>{props.text}</p>) : null}
<div className="bar">
<div className="bar-inner" style={{
width: (props.progress / props.steps * 100).toFixed(0) + "%"
}}/>
</div>
</div>
)
}

View file

@ -0,0 +1,67 @@
import { useState } from "react";
import { MWMediaType, MWQuery } from "providers";
import { DropdownButton } from "./buttons/DropdownButton";
import { Icons } from "./Icon";
import { TextInputControl } from "./text-inputs/TextInputControl";
export interface SearchBarProps {
buttonText?: string;
placeholder?: string;
onChange: (value: MWQuery) => void;
value: MWQuery;
}
export function SearchBarInput(props: SearchBarProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
function setSearch(value: string) {
props.onChange({
...props.value,
searchQuery: value,
});
}
function setType(type: string) {
props.onChange({
...props.value,
type: type as MWMediaType,
});
}
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">
<TextInputControl
onChange={(val) => setSearch(val)}
value={props.value.searchQuery}
className="placeholder-denim-700 w-full flex-1 bg-transparent text-white focus:outline-none"
placeholder={props.placeholder}
/>
<DropdownButton
icon={Icons.SEARCH}
open={dropdownOpen}
setOpen={(val) => setDropdownOpen(val)}
selectedItem={props.value.type}
setSelectedItem={(val) => setType(val)}
options={[
{
id: MWMediaType.MOVIE,
name: "Movie",
icon: Icons.FILM,
},
{
id: MWMediaType.SERIES,
name: "Series",
icon: Icons.CLAPPER_BOARD,
},
// {
// id: MWMediaType.ANIME,
// name: "Anime",
// icon: Icons.DRAGON,
// },
]}
onClick={() => setDropdownOpen((old) => !old)}
>
{props.buttonText || "Search"}
</DropdownButton>
</div>
);
}

View file

@ -1,111 +0,0 @@
@import url('https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,600,600i,700,700i,800,800i&display=swap');
/* select box styling */
.select-box {
display: flex;
width: 200px;
flex-direction: column;
position: relative;
}
.select-box:focus-visible .selected {
border: 1px solid #fff;
}
.select-box > * {
box-sizing: border-box;
}
.select-box .options-container {
max-height: 0;
width: calc( 100% - 12px);
opacity: 0;
transition: all 0.2s ease-in-out;
overflow: hidden;
border-radius: 5px;
background-color: var(--choice);
order: 1;
position: absolute;
z-index: 1;
top: 50px;
}
.select-box .selected {
margin-bottom: 8px;
position: relative;
width: 188px;
height: 45px;
border-radius: 5px;
display: flex;
align-items: center;
background-color: var(--choice);
color: white;
order: 0;
}
.select-box .selected::after {
content: "";
width: 1.2rem;
height: 1.2rem;
background: url(../assets/down-arrow.svg);
position: absolute;
right: 15px;
top: 50%;
transition: transform 150ms;
transform: translateY(-50%);
background-size: contain;
background-position: center;
}
.select-box .option .item {
color: var(--text);
font-weight: bold;
}
.select-box .options-container.active {
max-height: 240px;
opacity: 1;
overflow-y: scroll;
}
.select-box .options-container.active + .selected::after {
transform: translateY(-50%) rotateX(180deg);
}
.select-box .options-container::-webkit-scrollbar {
width: 8px;
background: #0d141f;
background: #81878f;
background: #f1f2f3;
border-radius: 0 5px 5px 0;
}
.select-box .options-container::-webkit-scrollbar-thumb {
background: #525861;
background: #81878f;
border-radius: 0 5px 5px 0;
}
.select-box .option {
padding: 12px 15px;
}
.select-box .option,
.selected {
cursor: pointer;
}
.select-box .options-container .option:hover {
background: var(--choice-hover);
}
.select-box .options-container .option:hover .item {
color: var(--choice-active-text, var(--text));
}
.select-box label {
cursor: pointer;
}
.select-box .option .radio {
display: none;
}

View file

@ -1,82 +0,0 @@
import { useRef, useState, useEffect } from "react"
import "./SelectBox.css"
function Option({ option, ...props }) {
return (
<div className="option" {...props}>
<input
type="radio"
className="radio"
id={option.id} />
<label htmlFor={option.id}>
<div className="item">{option.name}</div>
</label>
</div>
)
}
export function SelectBox({ options, selectedItem, setSelectedItem }) {
if (!Array.isArray(options)) {
throw new Error("Items must be an array!")
}
const [active, setActive] = useState(false)
const containerRef = useRef();
const handleClick = e => {
if (containerRef.current.contains(e.target)) {
// inside click
return;
}
// outside click
closeDropdown()
};
const closeDropdown = () => {
setActive(false)
}
useEffect(() => {
// add when mounted
document.addEventListener("mousedown", handleClick);
// return function to be called when unmounted
return () => {
document.removeEventListener("mousedown", handleClick);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onOptionClick = (e, option, i) => {
e.stopPropagation()
setSelectedItem(i)
closeDropdown()
}
const handleSelectedKeyPress = event => {
if (event.code === 'Enter' || event.code === 'Space'){
setActive(a => !a);
}
}
const handleOptionKeyPress = (option, i) => event => {
if (event.code === 'Enter' || event.code === 'Space'){
onOptionClick(event, option, i);
}
}
return (
<div className="select-box" ref={containerRef} onClick={() => setActive(a => !a)} >
<div className="selected" tabIndex={0} onKeyPress={handleSelectedKeyPress}>
{options ? (
<Option option={options[selectedItem]} />
) : null}
</div>
<div className={"options-container" + (active ? " active" : "")}>
{options.map((opt, i) => (
<Option option={opt} key={i} onClick={(e) => onOptionClick(e, opt, i)} tabIndex={active ? 0 : undefined} onKeyPress={active ? handleOptionKeyPress(opt, i) : undefined} />
))}
</div>
</div>
)
}

View file

@ -1,52 +0,0 @@
.title {
font-size: 2rem;
color: var(--text);
/* max-width: 20rem; */
margin: 0;
padding: 0;
margin-bottom: 3.5rem;
}
.title-size-medium {
font-size: 1.5rem;
}
.title-size-small {
font-size: 1.1rem;
color: var(--text-secondary);
}
.title-accent {
color: var(--theme-color);
font-weight: 600;
margin: 0;
padding: 0;
margin-bottom: 0.5rem;
margin-top: 1rem;
display: inline-block;
}
.title-accent.title-accent-link {
cursor: pointer;
}
.title.accent.title-accent-link:focus-visible {
border: 1px solid #ffffff;
}
.title.accent.title-accent-link:focus-visible .arrow {
transform: translateY(.1rem) translateX(-.5rem);
}
.title-accent.title-accent-link .arrow {
transition: transform 100ms ease-in-out;
transform: translateY(.1rem);
margin-right: .2rem;
}
.title-accent.title-accent-link:hover .arrow {
transform: translateY(.1rem) translateX(-.5rem);
}

View file

@ -1,41 +0,0 @@
import React from 'react';
import { useHistory } from 'react-router-dom';
import { useMovie } from '../hooks/useMovie'
import { Arrow } from '../components/Arrow'
import './Title.css'
// size: "big" | "medium" | "small" | null
// accent: string | null
// accentLink: string | null
export function Title(props) {
const { streamData, resetStreamData } = useMovie();
const history = useHistory();
const size = props.size || "big";
const accentLink = props.accentLink || "";
const accent = props.accent || "";
function handleAccentClick(){
if (accentLink.length > 0) {
history.push(`/${streamData.type}`);
resetStreamData();
}
}
function handleKeyPress(event){
if (event.code === 'Enter' || event.code === 'Space'){
handleAccentClick();
}
}
return (
<div>
{accent.length > 0 ? (
<p onClick={handleAccentClick} className={`title-accent ${accentLink.length > 0 ? 'title-accent-link' : ''}`} tabIndex={accentLink.length > 0 ? 0 : undefined} onKeyPress={handleKeyPress}>
{accentLink.length > 0 ? (<Arrow left/>) : null}{accent}
</p>
) : null}
<h1 className={"title " + ( size ? `title-size-${size}` : '' )}>{props.children}</h1>
</div>
)
}

View file

@ -1,65 +0,0 @@
/* TODO better responsiveness, use dropdown if more than 5 options */
.typeSelector {
display: inline-flex;
position: relative;
margin-bottom: 1.5rem;
max-width: 100%;
}
.typeSelector:not(.nowrap) {
flex-wrap: wrap;
}
.typeSelector::before {
content: "";
position: absolute;
width: 100%;
bottom: 0;
background-color: var(--content);
height: 4px;
border-radius: 2px;
}
.typeSelector .choice {
width: 7rem;
height: 3rem;
padding: .3rem .2rem;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
box-sizing: border-box;
color: var(--text-tertiary);
font-weight: bold;
cursor: pointer;
user-select: none;
}
.typeSelector .choice:hover {
color: var(--text-secondary);
}
.typeSelector .choice:focus-visible {
border: 1px solid #fff;
color: var(--text-secondary);
}
.typeSelector .choice.selected {
color: var(--text);
}
.typeSelector .selectedBar {
position: absolute;
height: 4px;
width: 7rem;
background-color: var(--theme-color);
border-radius: 2px;
bottom: 0;
transition: transform 150ms ease-in-out;
}
@media screen and (max-width: 700px) {
.typeSelector:not(.nowrap) {
display: block;
}
}

View file

@ -1,36 +0,0 @@
import React from 'react';
import './TypeSelector.css';
// setType: (txt: string) => void
// choices: { label: string, value: string }[]
// selected: string
export function TypeSelector({ setType, choices, selected, noWrap = false }) {
const selectedIndex = choices.findIndex(v => v.value === selected);
const transformStyles = {
opacity: selectedIndex !== -1 ? 1 : 0,
transform: `translateX(${selectedIndex !== -1 ? selectedIndex * 7 : 0}rem)`,
};
const handleKeyPress = choice => event => {
if (event.code === 'Enter' || event.code === 'Space') {
setType(choice);
}
};
return (
<div className={`typeSelector ${noWrap ? 'nowrap' : ''}`}>
{choices.map(v => (
<div
key={v.value}
className={`choice ${selected === v.value ? 'selected' : ''}`}
onClick={() => setType(v.value)}
onKeyPress={handleKeyPress(v.value)}
tabIndex={0}
>
{v.label}
</div>
))}
<div className="selectedBar" style={transformStyles} />
</div>
);
}

View file

@ -1,10 +0,0 @@
.videoElement {
width: 100%;
background-color: black;
border-radius: 5px;
}
.videoElementText {
color: var(--text);
margin: 0;
}

View file

@ -1,64 +0,0 @@
import React from 'react'
import Hls from 'hls.js'
import { VideoPlaceholder } from './VideoPlaceholder'
import './VideoElement.css'
// streamUrl: string
// loading: boolean
// setProgress: (event: NativeEvent) => void
// videoRef: useRef
// startTime: number
export function VideoElement({ streamUrl, loading, setProgress, videoRef, startTime, streamData }) {
const [error, setError] = React.useState(false);
function onLoad() {
if (startTime)
videoRef.current.currentTime = startTime;
}
React.useEffect(() => {
if (!streamUrl.includes('.mp4') && !streamUrl.includes('redirector.php')) {
console.log(streamUrl)
setError(false)
if (!videoRef || !videoRef.current || !streamUrl || streamUrl.length === 0 || loading) return;
const hls = new Hls();
if (!Hls.isSupported() && videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
videoRef.current.src = streamUrl;
return;
} else if (!Hls.isSupported()) {
setError(true)
return;
}
hls.attachMedia(videoRef.current);
hls.loadSource(streamUrl);
}
}, [videoRef, streamUrl, loading]);
if (error)
return (<VideoPlaceholder>Your browser is not supported</VideoPlaceholder>)
if (loading)
return <VideoPlaceholder>Loading episode...</VideoPlaceholder>
if (!streamUrl || streamUrl.length === 0)
return <VideoPlaceholder>No video selected</VideoPlaceholder>
if (!streamUrl.includes('.mp4') && !streamUrl.includes('redirector.php')) {
return (
<video className="videoElement" ref={videoRef} controls autoPlay onProgress={setProgress} onLoadedData={onLoad}>
{ streamData.subtitles && streamData.subtitles.map((sub, index) => <track key={index} kind="captions" label={sub.language} src={sub.file} />) }
</video>
)
} else {
return (
<video className="videoElement" ref={videoRef} controls autoPlay onProgress={setProgress} onLoadedData={onLoad}>
{ streamData.subtitles && streamData.subtitles.map((sub, index) => <track key={index} kind="captions" label={sub.language} src={sub.file} />) }
<source src={streamUrl} type="video/mp4" />
</video>
)
}
}

View file

@ -1,23 +0,0 @@
.videoPlaceholder {
width: 100%;
position: relative;
}
.videoPlaceholder::before {
content: '';
display: block;
width: 100%;
padding-bottom: 56.25%;
}
.videoPlaceholderBox {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
top: 0;
left: 0;
position: absolute;
background: var(--choice);
border-radius: 6px;
color: var(--text);
}

View file

@ -1,12 +0,0 @@
import React from 'react'
import './VideoPlaceholder.css'
export function VideoPlaceholder(props) {
return (
<div className="videoPlaceholder">
<div className="videoPlaceholderBox">
<p>{props.children}</p>
</div>
</div>
)
}

View file

@ -0,0 +1,17 @@
export interface ButtonControlProps {
onClick?: () => void;
children?: React.ReactNode;
className?: string;
}
export function ButtonControl({
onClick,
children,
className,
}: ButtonControlProps) {
return (
<button onClick={onClick} className={className} type="button">
{children}
</button>
);
}

View file

@ -0,0 +1,129 @@
import { Icon, Icons } from "components/Icon";
import React, {
MouseEventHandler,
SyntheticEvent,
useEffect,
useState,
} from "react";
import { Backdrop, useBackdrop } from "components/layout/Backdrop";
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
export interface OptionItem {
id: string;
name: string;
icon: Icons;
}
interface DropdownButtonProps extends ButtonControlProps {
icon: Icons;
open: boolean;
setOpen: (open: boolean) => void;
selectedItem: string;
setSelectedItem: (value: string) => void;
options: Array<OptionItem>;
}
export interface OptionProps {
option: OptionItem;
onClick: MouseEventHandler<HTMLDivElement>;
tabIndex?: number;
}
function Option({ option, onClick, tabIndex }: OptionProps) {
return (
<div
className="text-denim-700 flex h-10 cursor-pointer items-center space-x-2 px-4 py-2 text-left transition-colors hover:text-white"
onClick={onClick}
tabIndex={tabIndex}
>
<Icon icon={option.icon} />
<input type="radio" className="hidden" id={option.id} />
<label htmlFor={option.id} className="cursor-pointer ">
<div className="item">{option.name}</div>
</label>
</div>
);
}
export const DropdownButton = React.forwardRef<
HTMLDivElement,
DropdownButtonProps
>((props: DropdownButtonProps, ref) => {
const [setBackdrop, backdropProps, highlightedProps] = useBackdrop();
const [delayedSelectedId, setDelayedSelectedId] = useState(
props.selectedItem
);
useEffect(() => {
let id: NodeJS.Timeout;
if (props.open) {
setDelayedSelectedId(props.selectedItem);
} else {
id = setTimeout(() => {
setDelayedSelectedId(props.selectedItem);
}, 200);
}
return () => {
if (id) clearTimeout(id);
};
/* eslint-disable-next-line */
}, [props.open]);
const selectedItem: OptionItem = props.options.find(
(opt) => opt.id === props.selectedItem
) || { id: "movie", name: "movie", icon: Icons.ARROW_LEFT };
useEffect(() => {
setBackdrop(props.open);
/* eslint-disable-next-line */
}, [props.open]);
const onOptionClick = (e: SyntheticEvent, option: OptionItem) => {
e.stopPropagation();
props.setSelectedItem(option.id);
props.setOpen(false);
};
return (
<div className="w-full min-w-[140px] sm:w-auto">
<div
ref={ref}
className="relative w-full sm:w-auto"
{...highlightedProps}
>
<ButtonControl
{...props}
className="sm:justify-left bg-bink-200 hover:bg-bink-300 relative z-20 flex h-10 w-full items-center justify-center space-x-2 rounded-[20px] px-4 py-2 text-white"
>
<Icon icon={selectedItem.icon} />
<span className="flex-1">{selectedItem.name}</span>
<Icon
icon={Icons.CHEVRON_DOWN}
className={`transition-transform ${props.open ? "rotate-180" : ""}`}
/>
</ButtonControl>
<div
className={`bg-denim-300 absolute top-0 z-10 w-full rounded-[20px] pt-[40px] transition-all duration-200 ${
props.open
? "block max-h-60 opacity-100"
: "invisible max-h-0 opacity-0"
}`}
>
{props.options
.filter((opt) => opt.id !== delayedSelectedId)
.map((opt) => (
<Option
option={opt}
key={opt.id}
onClick={(e) => onOptionClick(e, opt)}
tabIndex={props.open ? 0 : undefined}
/>
))}
</div>
</div>
<Backdrop onClick={() => props.setOpen(false)} {...backdropProps} />
</div>
);
});

View file

@ -0,0 +1,18 @@
import { Icon, Icons } from "components/Icon";
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
export interface IconButtonProps extends ButtonControlProps {
icon: Icons;
}
export function IconButton(props: IconButtonProps) {
return (
<ButtonControl
{...props}
className="flex items-center px-4 py-2 space-x-2 bg-bink-200 hover:bg-bink-300 text-white rounded-full"
>
<Icon icon={props.icon} />
<span>{props.children}</span>
</ButtonControl>
);
}

View file

@ -0,0 +1,25 @@
import { Icon, Icons } from "components/Icon";
export interface IconPatchProps {
active?: boolean;
onClick?: () => void;
clickable?: boolean;
className?: string;
icon: Icons;
}
export function IconPatch(props: IconPatchProps) {
return (
<div className={props.className || undefined} onClick={props.onClick}>
<div
className={`bg-denim-300 flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent transition-[color,transform,border-color] duration-75 ${
props.clickable
? "hover:bg-denim-400 m-2 cursor-pointer hover:scale-110 hover:text-white active:scale-125"
: ""
} ${props.active ? "text-bink-600 border-bink-600 bg-bink-100" : ""}`}
>
<Icon icon={props.icon} />
</div>
</div>
);
}

View file

@ -0,0 +1,68 @@
import { useFade } from "hooks/useFade";
import { useEffect, useState } from "react";
interface BackdropProps {
onClick?: (e: MouseEvent) => void;
onBackdropHide?: () => void;
active?: boolean;
}
export function useBackdrop(): [
(state: boolean) => void,
BackdropProps,
{ style: any }
] {
const [backdrop, setBackdropState] = useState(false);
const [isHighlighted, setisHighlighted] = useState(false);
const setBackdrop = (state: boolean) => {
setBackdropState(state);
if (state) setisHighlighted(true);
};
const backdropProps: BackdropProps = {
active: backdrop,
onBackdropHide() {
setisHighlighted(false);
},
};
const highlightedProps = {
style: isHighlighted
? {
zIndex: "1000",
position: "relative",
}
: {},
};
return [setBackdrop, backdropProps, highlightedProps];
}
export function Backdrop(props: BackdropProps) {
const clickEvent = props.onClick || (() => {});
const animationEvent = props.onBackdropHide || (() => {});
const [isVisible, setVisible, fadeProps] = useFade();
useEffect(() => {
setVisible(!!props.active);
/* eslint-disable-next-line */
}, [props.active, setVisible]);
useEffect(() => {
if (!isVisible) animationEvent();
/* eslint-disable-next-line */
}, [isVisible]);
if (!isVisible) return null;
return (
<div
className={`fixed top-0 left-0 right-0 z-[999] h-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${
!isVisible ? "opacity-0" : ""
}`}
{...fadeProps}
onClick={(e) => clickEvent(e.nativeEvent)}
/>
);
}

View file

@ -0,0 +1,16 @@
import { Icon, Icons } from "components/Icon";
export function BrandPill(props: { clickable?: boolean }) {
return (
<div
className={`bg-bink-100 text-bink-600 flex items-center space-x-2 rounded-full bg-opacity-50 px-4 py-2 ${
props.clickable
? "hover:bg-bink-200 hover:text-bink-700 transition-[transform,background-color] hover:scale-105 active:scale-95"
: ""
}`}
>
<Icon className="text-xl" icon={Icons.MOVIE_WEB} />
<span className="font-semibold text-white">movie-web</span>
</div>
);
}

View file

@ -0,0 +1,82 @@
import { IconPatch } from "components/buttons/IconPatch";
import { Icons } from "components/Icon";
import { Link } from "components/text/Link";
import { Title } from "components/text/Title";
import { DISCORD_LINK, GITHUB_LINK } from "mw_constants";
import { Component } from "react";
interface ErrorBoundaryState {
hasError: boolean;
error?: {
name: string;
description: string;
path: string;
};
}
export class ErrorBoundary extends Component<
Record<string, unknown>,
ErrorBoundaryState
> {
constructor(props: { children: any }) {
super(props);
this.state = {
hasError: false,
};
}
static getDerivedStateFromError() {
return {
hasError: true,
};
}
componentDidCatch(error: any, errorInfo: any) {
console.error("Render error caught", error, errorInfo);
if (error instanceof Error) {
const realError: Error = error as Error;
this.setState((s) => ({
...s,
hasError: true,
error: {
name: realError.name,
description: realError.message,
path: errorInfo.componentStack.split("\n")[1],
},
}));
}
}
render() {
if (!this.state.hasError) return this.props.children;
return (
<div className="flex min-h-screen w-full flex-col items-center justify-center px-4 py-12">
<div className="flex flex-col items-center justify-start text-center">
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
<Title>Whoops, it broke</Title>
<p className="my-6 max-w-lg">
The app encountered an error and wasn&apos;t able to recover, please
report it to the{" "}
<Link url={DISCORD_LINK} newTab>
Discord server
</Link>{" "}
or on{" "}
<Link url={GITHUB_LINK} newTab>
GitHub
</Link>
.
</p>
</div>
{this.state.error ? (
<div className="bg-denim-300 w-4xl mt-12 max-w-full rounded px-6 py-4">
<p className="mb-1 break-words font-bold text-white">
{this.state.error.name} - {this.state.error.description}
</p>
<p className="break-words">{this.state.error.path}</p>
</div>
) : null}
</div>
);
}
}

View file

@ -0,0 +1,22 @@
export interface LoadingProps {
text?: string;
className?: string;
}
export function Loading(props: LoadingProps) {
return (
<div className={props.className}>
<div className="flex flex-col items-center justify-center">
<div className="flex h-12 items-center justify-center">
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full" />
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:150ms]" />
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:300ms]" />
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:450ms]" />
</div>
{props.text && props.text.length ? (
<p className="mt-3 max-w-xs text-sm opacity-75">{props.text}</p>
) : null}
</div>
</div>
);
}

View file

@ -0,0 +1,43 @@
import { IconPatch } from "components/buttons/IconPatch";
import { Icons } from "components/Icon";
import { DISCORD_LINK, GITHUB_LINK } from "mw_constants";
import { ReactNode } from "react";
import { Link } from "react-router-dom";
import { BrandPill } from "./BrandPill";
export interface NavigationProps {
children?: ReactNode;
}
export function Navigation(props: NavigationProps) {
return (
<div className="absolute left-0 right-0 top-0 flex items-center justify-between py-5 px-7">
<div className="flex items-center justify-center">
<div className="mr-6">
<Link to="/">
<BrandPill clickable />
</Link>
</div>
{props.children}
</div>
<div className="flex">
<a
href={DISCORD_LINK}
target="_blank"
rel="noreferrer"
className="text-2xl text-white"
>
<IconPatch icon={Icons.DISCORD} clickable />
</a>
<a
href={GITHUB_LINK}
target="_blank"
rel="noreferrer"
className="text-2xl text-white"
>
<IconPatch icon={Icons.GITHUB} clickable />
</a>
</div>
</div>
);
}

View file

@ -0,0 +1,14 @@
import { ReactNode } from "react";
export interface PaperProps {
children?: ReactNode,
className?: string,
}
export function Paper(props: PaperProps) {
return (
<div className={`bg-denim-200 rounded-xl p-12 ${props.className}`}>
{props.children}
</div>
)
}

View file

@ -0,0 +1,119 @@
import { IconPatch } from "components/buttons/IconPatch";
import { Dropdown, OptionItem } from "components/Dropdown";
import { Icons } from "components/Icon";
import { WatchedEpisode } from "components/media/WatchedEpisodeButton";
import { useLoading } from "hooks/useLoading";
import { serializePortableMedia } from "hooks/usePortableMedia";
import {
convertMediaToPortable,
MWMedia,
MWMediaSeasons,
MWMediaSeason,
MWPortableMedia,
} from "providers";
import { getSeasonDataFromMedia } from "providers/methods/seasons";
import { useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
export interface SeasonsProps {
media: MWMedia;
}
export function LoadingSeasons(props: { error?: boolean }) {
return (
<div>
<div>
<div className="bg-denim-400 mb-3 mt-5 h-10 w-56 rounded opacity-50" />
</div>
{!props.error ? (
<>
<div className="bg-denim-400 mr-3 mb-3 inline-block h-10 w-10 rounded opacity-50" />
<div className="bg-denim-400 mr-3 mb-3 inline-block h-10 w-10 rounded opacity-50" />
<div className="bg-denim-400 mr-3 mb-3 inline-block h-10 w-10 rounded opacity-50" />
</>
) : (
<div className="flex items-center space-x-3">
<IconPatch icon={Icons.WARNING} className="text-red-400" />
<p>Failed to load seasons and episodes</p>
</div>
)}
</div>
);
}
export function Seasons(props: SeasonsProps) {
const [searchSeasons, loading, error, success] = useLoading(
(portableMedia: MWPortableMedia) => getSeasonDataFromMedia(portableMedia)
);
const history = useHistory();
const [seasons, setSeasons] = useState<MWMediaSeasons>({ seasons: [] });
const seasonSelected = props.media.seasonId as string;
const episodeSelected = props.media.episodeId as string;
useEffect(() => {
(async () => {
const seasonData = await searchSeasons(props.media);
setSeasons(seasonData);
})();
}, [searchSeasons, props.media]);
function navigateToSeasonAndEpisode(seasonId: string, episodeId: string) {
const newMedia: MWMedia = { ...props.media };
newMedia.episodeId = episodeId;
newMedia.seasonId = seasonId;
history.replace(
`/media/${newMedia.mediaType}/${serializePortableMedia(
convertMediaToPortable(newMedia)
)}`
);
}
const mapSeason = (season: MWMediaSeason) => ({
id: season.id,
name: season.title || `Season ${season.sort}`,
});
const options = seasons.seasons.map(mapSeason);
const foundSeason = seasons.seasons.find(
(season) => season.id === seasonSelected
);
const selectedItem = foundSeason ? mapSeason(foundSeason) : null;
return (
<>
{loading ? <LoadingSeasons /> : null}
{error ? <LoadingSeasons error /> : null}
{success && seasons.seasons.length ? (
<>
<Dropdown
selectedItem={selectedItem as OptionItem}
options={options}
setSelectedItem={(seasonItem) =>
navigateToSeasonAndEpisode(
seasonItem.id,
seasons.seasons.find((s) => s.id === seasonItem.id)?.episodes[0]
.id as string
)
}
/>
{seasons.seasons
.find((s) => s.id === seasonSelected)
?.episodes.map((v) => (
<WatchedEpisode
key={v.id}
media={{
...props.media,
seriesData: seasons,
episodeId: v.id,
seasonId: seasonSelected,
}}
active={v.id === episodeSelected}
onClick={() => navigateToSeasonAndEpisode(seasonSelected, v.id)}
/>
))}
</>
) : null}
</>
);
}

View file

@ -0,0 +1,37 @@
import { Icon, Icons } from "components/Icon";
import { ArrowLink } from "components/text/ArrowLink";
import { ReactNode } from "react";
interface SectionHeadingProps {
icon?: Icons;
title: string;
children?: ReactNode;
linkText?: string;
onClick?: () => void;
className?: string;
}
export function SectionHeading(props: SectionHeadingProps) {
return (
<div className={`mt-12 ${props.className}`}>
<div className="mb-4 flex items-end">
<p className="text-denim-700 flex flex-1 items-center font-bold uppercase">
{props.icon ? (
<span className="mr-2 text-xl">
<Icon icon={props.icon} />
</span>
) : null}
{props.title}
</p>
{props.linkText ? (
<ArrowLink
linkText={props.linkText}
direction="left"
onClick={props.onClick}
/>
) : null}
</div>
{props.children}
</div>
);
}

View file

@ -0,0 +1,16 @@
import { ReactNode } from "react";
interface ThinContainerProps {
classNames?: string;
children?: ReactNode;
}
export function ThinContainer(props: ThinContainerProps) {
return (
<div
className={`max-w-[600px] mx-auto px-2 sm:px-0 ${props.classNames || ""}`}
>
{props.children}
</div>
);
}

View file

@ -0,0 +1,25 @@
export interface EpisodeProps {
progress?: number;
episodeNumber: number;
onClick?: () => void;
active?: boolean;
}
export function Episode(props: EpisodeProps) {
return (
<div
onClick={props.onClick}
className={`bg-denim-500 hover:bg-denim-400 transition-[background-color, transform, box-shadow] relative mr-3 mb-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded font-bold text-white active:scale-110 ${
props.active ? "shadow-bink-500 shadow-[inset_0_0_0_2px]" : ""
}`}
>
<div
className="bg-bink-500 absolute bottom-0 top-0 left-0 bg-opacity-50"
style={{
width: `${props.progress || 0}%`,
}}
/>
<span className="relative">{props.episodeNumber}</span>
</div>
);
}

View file

@ -0,0 +1,97 @@
import {
convertMediaToPortable,
getProviderFromId,
MWMediaMeta,
MWMediaType,
} from "providers";
import { Link } from "react-router-dom";
import { Icon, Icons } from "components/Icon";
import { serializePortableMedia } from "hooks/usePortableMedia";
import { DotList } from "components/text/DotList";
export interface MediaCardProps {
media: MWMediaMeta;
watchedPercentage: number;
linkable?: boolean;
series?: boolean;
}
function MediaCardContent({
media,
linkable,
watchedPercentage,
series,
}: MediaCardProps) {
const provider = getProviderFromId(media.providerId);
if (!provider) {
return null;
}
return (
<article
className={`bg-denim-300 group relative mb-4 flex overflow-hidden rounded py-4 px-5 ${
linkable ? "hover:bg-denim-400" : ""
}`}
>
{/* progress background */}
{watchedPercentage > 0 ? (
<div className="absolute top-0 left-0 right-0 bottom-0">
<div
className="bg-bink-300 relative h-full bg-opacity-30"
style={{
width: `${watchedPercentage}%`,
}}
>
<div className="from-bink-400 absolute right-0 top-0 bottom-0 ml-auto w-40 bg-gradient-to-l to-transparent opacity-40" />
</div>
</div>
) : null}
<div className="relative flex flex-1">
{/* card content */}
<div className="flex-1">
<h1 className="mb-1 font-bold text-white">
{media.title}
{series && media.seasonId && media.episodeId ? (
<span className="text-denim-700 ml-2 text-xs">
S{media.seasonId} E{media.episodeId}
</span>
) : null}
</h1>
<DotList
className="text-xs"
content={[provider.displayName, media.mediaType, media.year]}
/>
</div>
{/* hoverable chevron */}
<div
className={`flex translate-x-3 items-center justify-end text-xl text-white opacity-0 transition-[opacity,transform] ${
linkable ? "group-hover:translate-x-0 group-hover:opacity-100" : ""
}`}
>
<Icon icon={Icons.CHEVRON_RIGHT} />
</div>
</div>
</article>
);
}
export function MediaCard(props: MediaCardProps) {
let link = "movie";
if (props.media.mediaType === MWMediaType.SERIES) link = "series";
const content = <MediaCardContent {...props} />;
if (!props.linkable) return <span>{content}</span>;
return (
<Link
to={`/media/${link}/${serializePortableMedia(
convertMediaToPortable(props.media)
)}`}
>
{content}
</Link>
);
}

View file

@ -0,0 +1,111 @@
import { IconPatch } from "components/buttons/IconPatch";
import { Icons } from "components/Icon";
import { Loading } from "components/layout/Loading";
import { MWMediaCaption, MWMediaStream } from "providers";
import { ReactElement, useEffect, useRef, useState } from "react";
import Hls from "hls.js";
export interface VideoPlayerProps {
source: MWMediaStream;
captions: MWMediaCaption[];
startAt?: number;
onProgress?: (event: ProgressEvent) => void;
}
export function SkeletonVideoPlayer(props: { error?: boolean }) {
return (
<div className="bg-denim-200 flex aspect-video w-full items-center justify-center rounded-xl">
{props.error ? (
<div className="flex flex-col items-center">
<IconPatch icon={Icons.WARNING} className="text-red-400" />
<p className="mt-5 text-white">Couldn&apos;t get your stream</p>
</div>
) : (
<div className="flex flex-col items-center">
<Loading />
<p className="mt-3 text-white">Getting your stream...</p>
</div>
)}
</div>
);
}
export function VideoPlayer(props: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [hasErrored, setErrored] = useState(false);
const [isLoading, setLoading] = useState(true);
const showVideo = !isLoading && !hasErrored;
const mustUseHls = props.source.type === "m3u8";
// reset if stream url changes
useEffect(() => {
setLoading(true);
setErrored(false);
// hls support
if (mustUseHls) {
if (!videoRef.current)
return;
if (!Hls.isSupported()) {
setLoading(false);
setErrored(true);
return;
}
const hls = new Hls();
if (videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
videoRef.current.src = props.source.url;
return;
}
hls.attachMedia(videoRef.current);
hls.loadSource(props.source.url);
hls.on(Hls.Events.ERROR, (event, data) => {
setErrored(true);
console.error(data);
});
}
}, [props.source.url, videoRef, mustUseHls]);
let skeletonUi: null | ReactElement = null;
if (hasErrored) {
skeletonUi = <SkeletonVideoPlayer error />;
} else if (isLoading) {
skeletonUi = <SkeletonVideoPlayer />;
}
return (
<>
{skeletonUi}
<video
className={`bg-denim-500 w-full rounded-xl ${!showVideo ? "hidden" : ""
}`}
ref={videoRef}
onProgress={(e) =>
props.onProgress && props.onProgress(e.nativeEvent as ProgressEvent)
}
onLoadedData={(e) => {
setLoading(false);
if (props.startAt)
(e.target as HTMLVideoElement).currentTime = props.startAt;
}}
onError={(e) => {
console.error("failed to playback stream", e);
setErrored(true);
}}
controls
autoPlay
>
{!mustUseHls ? (
<source src={props.source.url} type="video/mp4" />
) : null}
{props.captions.map((v) => (
<track key={v.id} kind="captions" label={v.label} src={v.url} />
))}
</video>
</>
);
}

View file

@ -0,0 +1,25 @@
import { getEpisodeFromMedia, MWMedia } from "providers";
import { useWatchedContext, getWatchedFromPortable } from "state/watched";
import { Episode } from "./EpisodeButton";
export interface WatchedEpisodeProps {
media: MWMedia;
onClick?: () => void;
active?: boolean;
}
export function WatchedEpisode(props: WatchedEpisodeProps) {
const { watched } = useWatchedContext();
const foundWatched = getWatchedFromPortable(watched.items, props.media);
const episode = getEpisodeFromMedia(props.media);
const watchedPercentage = (foundWatched && foundWatched.percentage) || 0;
return (
<Episode
progress={watchedPercentage}
episodeNumber={episode?.episode?.sort ?? 1}
active={props.active}
onClick={props.onClick}
/>
);
}

View file

@ -0,0 +1,23 @@
import { MWMediaMeta } from "providers";
import { useWatchedContext, getWatchedFromPortable } from "state/watched";
import { MediaCard } from "./MediaCard";
export interface WatchedMediaCardProps {
media: MWMediaMeta;
series?: boolean;
}
export function WatchedMediaCard(props: WatchedMediaCardProps) {
const { watched } = useWatchedContext();
const foundWatched = getWatchedFromPortable(watched.items, props.media);
const watchedPercentage = (foundWatched && foundWatched.percentage) || 0;
return (
<MediaCard
watchedPercentage={watchedPercentage}
media={props.media}
series={props.series && props.media.episodeId !== undefined}
linkable
/>
);
}

View file

@ -0,0 +1,39 @@
export interface TextInputControlPropsNoLabel {
onChange?: (data: string) => void;
value?: string;
placeholder?: string;
className?: string;
}
export interface TextInputControlProps extends TextInputControlPropsNoLabel {
label?: string;
}
export function TextInputControl({
onChange,
value,
label,
className,
placeholder,
}: TextInputControlProps) {
const input = (
<input
type="text"
className={className}
placeholder={placeholder}
onChange={(e) => onChange && onChange(e.target.value)}
value={value}
/>
);
if (label) {
return (
<label>
<span>{label}</span>
{input}
</label>
);
}
return input;
}

View file

@ -0,0 +1,53 @@
import { Icon, Icons } from "components/Icon";
import { Link as LinkRouter } from "react-router-dom";
interface IArrowLinkPropsBase {
linkText: string;
className?: string;
onClick?: () => void;
direction?: "left" | "right";
}
interface IArrowLinkPropsExternal extends IArrowLinkPropsBase {
url: string;
}
interface IArrowLinkPropsInternal extends IArrowLinkPropsBase {
to: string;
}
export type ArrowLinkProps =
| IArrowLinkPropsExternal
| IArrowLinkPropsInternal
| IArrowLinkPropsBase;
export function ArrowLink(props: ArrowLinkProps) {
const direction = props.direction || "right";
const isExternal = !!(props as IArrowLinkPropsExternal).url;
const isInternal = !!(props as IArrowLinkPropsInternal).to;
const content = (
<span className="text-bink-600 hover:text-bink-700 group inline-flex cursor-pointer items-center space-x-1 font-bold active:scale-95">
{direction === "left" ? (
<span className="text-xl transition-transform group-hover:-translate-x-1">
<Icon icon={Icons.ARROW_LEFT} />
</span>
) : null}
<span className="flex-1">{props.linkText}</span>
{direction === "right" ? (
<span className="text-xl transition-transform group-hover:translate-x-1">
<Icon icon={Icons.ARROW_RIGHT} />
</span>
) : null}
</span>
);
if (isExternal)
return <a href={(props as IArrowLinkPropsExternal).url}>{content}</a>;
if (isInternal)
return (
<LinkRouter to={(props as IArrowLinkPropsInternal).to}>{content}</LinkRouter>
);
return (
<span onClick={() => props.onClick && props.onClick()}>{content}</span>
);
}

View file

@ -0,0 +1,19 @@
export interface DotListProps {
content: string[];
className?: string;
}
export function DotList(props: DotListProps) {
return (
<p className={`text-denim-700 font-semibold ${props.className || ""}`}>
{props.content.map((item, index) => (
<span key={item}>
{index !== 0 ? (
<span className="mx-[0.6em] text-[1em]">&#9679;</span>
) : null}
{item}
</span>
))}
</p>
);
}

View file

@ -0,0 +1,42 @@
import { ReactNode } from "react";
import { Link as LinkRouter } from "react-router-dom";
interface ILinkPropsBase {
children?: ReactNode;
className?: string;
onClick?: () => void;
}
interface ILinkPropsExternal extends ILinkPropsBase {
url: string;
newTab?: boolean;
}
interface ILinkPropsInternal extends ILinkPropsBase {
to: string;
}
type LinkProps =
| ILinkPropsExternal
| ILinkPropsInternal
| ILinkPropsBase;
export function Link(props: LinkProps) {
const isExternal = !!(props as ILinkPropsExternal).url;
const isInternal = !!(props as ILinkPropsInternal).to;
const content = (
<span className="text-bink-600 hover:text-bink-700 cursor-pointer font-bold">
{props.children}
</span>
);
if (isExternal)
return <a target={(props as ILinkPropsExternal).newTab ? "_blank" : undefined} rel="noreferrer" href={(props as ILinkPropsExternal).url}>{content}</a>;
if (isInternal)
return (
<LinkRouter to={(props as ILinkPropsInternal).to}>{content}</LinkRouter>
);
return (
<span onClick={() => props.onClick && props.onClick()}>{content}</span>
);
}

View file

@ -0,0 +1,7 @@
export interface TaglineProps {
children?: React.ReactNode;
}
export function Tagline(props: TaglineProps) {
return <p className="font-bold text-bink-600">{props.children}</p>;
}

View file

@ -0,0 +1,7 @@
export interface TitleProps {
children?: React.ReactNode;
}
export function Title(props: TitleProps) {
return <h1 className="text-4xl font-bold text-white">{props.children}</h1>;
}

20
src/hooks/useDebounce.ts Normal file
View file

@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay: number): T {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(
() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
},
[value, delay]
);
return debouncedValue;
}

17
src/hooks/useFade.css Normal file
View file

@ -0,0 +1,17 @@
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}

27
src/hooks/useFade.ts Normal file
View file

@ -0,0 +1,27 @@
import React, { useEffect, useState } from "react";
import './useFade.css'
export const useFade = (initial = false): [boolean, React.Dispatch<React.SetStateAction<boolean>>, any] => {
const [show, setShow] = useState<boolean>(initial);
const [isVisible, setVisible] = useState<boolean>(show);
// Update visibility when show changes
useEffect(() => {
if (show) setVisible(true);
}, [show]);
// When the animation finishes, set visibility to false
const onAnimationEnd = () => {
if (!show) setVisible(false);
};
const style = { animation: `${show ? "fadeIn" : "fadeOut"} .3s` };
// These props go on the fading DOM element
const fadeProps = {
style,
onAnimationEnd
};
return [isVisible, setShow, fadeProps];
};

47
src/hooks/useLoading.ts Normal file
View file

@ -0,0 +1,47 @@
import React, { useMemo, useRef, useState } from "react";
export function useLoading<T extends (...args: any) => Promise<any>>(
action: T
) {
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<any | undefined>(undefined);
const isMounted = useRef(true);
// we want action to be memoized forever
const actionMemo = useMemo(() => action, []); // eslint-disable-line react-hooks/exhaustive-deps
React.useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
const doAction = useMemo(
() =>
async (...args: Parameters<T>) => {
setLoading(true);
setSuccess(false);
setError(undefined);
return new Promise((resolve) => {
actionMemo(...args)
.then((v) => {
if (!isMounted.current) return resolve(undefined);
setSuccess(true);
resolve(v);
return null;
})
.catch((err) => {
if (isMounted) {
setError(err);
setSuccess(false);
}
resolve(undefined);
});
}).finally(() => isMounted.current && setLoading(false));
},
[actionMemo]
);
return [doAction, loading, error, success];
}

View file

@ -1,30 +0,0 @@
import React from 'react'
const MovieContext = React.createContext(null)
export function MovieProvider(props) {
const [page, setPage] = React.useState("search");
const [stream, setStream] = React.useState("");
const [streamData, setStreamData] = React.useState(null); //{ title: "", slug: "", type: "", episodes: [], seasons: [] })
return (
<MovieContext.Provider value={{
navigate(str) {
setPage(str)
},
page,
setStreamUrl: setStream,
streamUrl: stream,
streamData,
setStreamData(d) {
setStreamData(p => ({...p,...d}))
},
resetStreamData() { setStreamData(null) }
}}>
{props.children}
</MovieContext.Provider>
)
}
export function useMovie(props) {
return React.useContext(MovieContext);
}

View file

@ -0,0 +1,30 @@
import { MWPortableMedia } from "providers";
import { useEffect, useState } from "react";
import { useParams } from "react-router";
export function deserializePortableMedia(media: string): MWPortableMedia {
return JSON.parse(atob(decodeURIComponent(media)));
}
export function serializePortableMedia(media: MWPortableMedia): string {
const data = encodeURIComponent(btoa(JSON.stringify(media)));
return data;
}
export function usePortableMedia(): MWPortableMedia | undefined {
const { media } = useParams<{ media: string }>();
const [mediaObject, setMediaObject] = useState<MWPortableMedia | undefined>(
undefined
);
useEffect(() => {
try {
setMediaObject(deserializePortableMedia(media));
} catch (err) {
console.error("Failed to deserialize portable media", err);
setMediaObject(undefined);
}
}, [media, setMediaObject]);
return mediaObject;
}

View file

@ -0,0 +1,34 @@
import { MWMediaType, MWQuery } from "providers";
import React, { useState } from "react";
import { generatePath, useHistory, useRouteMatch } from "react-router-dom";
export function useSearchQuery(): [MWQuery, (inp: Partial<MWQuery>) => void] {
const history = useHistory();
const { path, params } = useRouteMatch<{ type: string; query: string }>();
const [search, setSearch] = useState<MWQuery>({
searchQuery: "",
type: MWMediaType.MOVIE,
});
const updateParams = (inp: Partial<MWQuery>) => {
const copySearch: MWQuery = { ...search };
Object.assign(copySearch, inp);
history.replace(
generatePath(path, {
query:
copySearch.searchQuery.length === 0 ? undefined : inp.searchQuery,
type: copySearch.type,
})
);
};
React.useEffect(() => {
const type =
Object.values(MWMediaType).find((v) => params.type === v) ||
MWMediaType.MOVIE;
const searchQuery = params.query || "";
setSearch({ type, searchQuery });
}, [params, setSearch]);
return [search, updateParams];
}

View file

@ -1,28 +0,0 @@
import { useEffect, useState } from "react";
// https://usehooks.com/useWindowSize/
export function useWindowSize() {
// Initialize state with undefined width/height so server and client renders match
// Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/
const [windowSize, setWindowSize] = useState({
width: undefined,
height: undefined,
});
useEffect(() => {
// Handler to call on window resize
function handleResize() {
// Set window width/height to state
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// Add event listener
window.addEventListener("resize", handleResize);
// Call handler right away so state gets updated with initial window size
handleResize();
// Remove event listener on cleanup
return () => window.removeEventListener("resize", handleResize);
}, []); // Empty array ensures that effect is only run on mount
return windowSize;
}

View file

@ -1,70 +1,16 @@
:root {
--theme-color: #E880C5;
--theme-color-text: var(--theme-color);
--failed: #d85b66;
@tailwind base;
@tailwind components;
@tailwind utilities;
--body: #16171D;
--card: #22232A;
--text: white;
--text-secondary: #BCBECB;
--text-tertiary: #585A67;
--content: #36363e;
--content-hover: #3C3D44;
--button: #A73B83;
--button-hover: #9C3179;
--button-active: #8b286a;
--button-text: var(--text);
--choice: #2E2F37;
--choice-hover: #45464D;
--choice-active: #45464D;
--source-headings: #5b5c63;
html,
body {
@apply bg-denim-100 text-denim-700 font-open-sans min-h-screen;
}
/* @media (prefers-color-scheme: light) {
:root {
--theme-color: #457461;
--body: white;
--card: #f8f9fa;
--content: #eee;
--content-hover: #e7e7e7;
--text: #333;
--text-secondary: #616161;
--text-tertiary: #aaa;
--button: #457461;
--button-hover: #4e836e;
--button-active: #437a64;
--button-text: white;
--choice: var(--content);
--choice-hover: var(--content-hover);
--choice-active: var(--content-hover);
}
} */
body, html {
margin: 0;
background-color: var(--body);
#root {
display: flex;
justify-content: flex-start;
align-items: flex-start;
min-height: 100vh;
width: 100%;
}
body, html, input, button {
font-family: 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 1rem;
}
*:focus {
outline: none;
}

View file

@ -1,14 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { HashRouter } from 'react-router-dom';
import './index.css';
import App from './App';
ReactDOM.render(
<React.StrictMode>
<HashRouter>
<App />
</HashRouter>
</React.StrictMode>,
document.getElementById('root')
);

17
src/index.tsx Normal file
View file

@ -0,0 +1,17 @@
import React from "react";
import ReactDOM from "react-dom";
import { HashRouter } from "react-router-dom";
import "./index.css";
import { ErrorBoundary } from "components/layout/ErrorBoundary";
import App from "./App";
ReactDOM.render(
<React.StrictMode>
<ErrorBoundary>
<HashRouter>
<App />
</HashRouter>
</ErrorBoundary>
</React.StrictMode>,
document.getElementById("root")
);

View file

@ -1,57 +0,0 @@
import xemovie from './scraper/xemovie';
import theflix from './scraper/theflix';
import vidzstore from './scraper/vidzstore';
import gdriveplayer from './scraper/gdriveplayer';
import gomostream from './scraper/gomostream';
async function findContent(searchTerm, type) {
const results = { options: []};
const content = await Promise.all([
// theflix.findContent(searchTerm, type),
gomostream.findContent(searchTerm, type),
gdriveplayer.findContent(searchTerm, type),
xemovie.findContent(searchTerm, type),
// vidzstore.findContent(searchTerm, type),
]);
content.forEach((o) => {
if (!o || !o.options) return;
o.options.forEach((i) => {
if (!i) return;
results.options.push(i)
})
});
return results;
}
async function getStreamUrl(slug, type, source, season, episode) {
switch (source) {
case 'theflix':
return await theflix.getStreamUrl(slug, type, season, episode);
case 'vidzstore':
return await vidzstore.getStreamUrl(slug);
case 'xemovie':
return await xemovie.getStreamUrl(slug, type, season, episode);
case 'gdriveplayer':
return await gdriveplayer.getStreamUrl(slug, type, season, episode);
case 'gomostream':
return await gomostream.getStreamUrl(slug, type, season, episode);
default:
return;
}
}
async function getEpisodes(slug, source) {
switch (source) {
case 'theflix':
return await theflix.getEpisodes(slug);
case 'xemovie':
return await xemovie.getEpisodes(slug);
default:
return;
}
}
export { findContent, getStreamUrl, getEpisodes }

View file

@ -1,80 +0,0 @@
// THIS SCRAPER CURRENTLY WORKS AND IS IN USE
import CryptoJS from "crypto-js";
import { unpack } from "unpacker";
const BASE_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://database.gdriveplayer.us`;
const API_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://api.gdriveplayer.us`;
const format = {
stringify: (cipher) => {
const ct = cipher.ciphertext.toString(CryptoJS.enc.Base64);
const iv = cipher.iv.toString() || "";
const salt = cipher.salt.toString() || "";
return JSON.stringify({
ct,
iv,
salt,
});
},
parse: (jsonStr) => {
const json = JSON.parse(jsonStr);
const ciphertext = CryptoJS.enc.Base64.parse(json.ct);
const iv = CryptoJS.enc.Hex.parse(json.iv) || "";
const salt = CryptoJS.enc.Hex.parse(json.s) || "";
const cipher = CryptoJS.lib.CipherParams.create({
ciphertext,
iv,
salt,
});
return cipher;
}
};
async function findContent(searchTerm, type) {
if (type === 'show') return { options: [] }; // will be like this until rewrite
try {
const searchUrl = `${API_URL}/v1/movie/search?title=${searchTerm}`;
const searchRes = await fetch(searchUrl).then((r) => r.json());
const results = searchRes.map(movie => {
return {
type,
title: movie.title,
year: movie.year,
slug: encodeURIComponent(`player.php?imdb=${movie.imdb}`),
source: "gdriveplayer",
}
});
return { options: results };
} catch {
return { options: [] };
}
}
async function getStreamUrl(slug) {
const url = `${BASE_URL}/${decodeURIComponent(slug)}`;
const res = await fetch(url).then(d => d.text());
const DOM = new DOMParser().parseFromString(res, "text/html");
const script = [...DOM.querySelectorAll("script")].find(s => s.textContent.includes("eval"));
const unpacked = unpack(script.textContent);
const data = unpacked.split("var data=\\'")[1].split("\\'")[0].replace(/\\/g, "");
const decryptedData = unpack(CryptoJS.AES.decrypt(data, "alsfheafsjklNIWORNiolNIOWNKLNXakjsfwnBdwjbwfkjbJjkopfjweopjASoiwnrflakefneiofrt", { format }).toString(CryptoJS.enc.Utf8));
// eslint-disable-next-line
const sources = JSON.parse(JSON.stringify(eval(decryptedData.split("sources:")[1].split(",image")[0].replace(/\\/g, "").replace(/document\.referrer/g, "\"\""))));
const unmappedSubtitles = JSON.parse(DOM.querySelector("#subtitlez").textContent).filter(s => s.file.length !== 2);
const subtitles = unmappedSubtitles.map(async (sub) => {
const subtitleBlob = URL.createObjectURL(await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}${sub.file}`).then(res => res.blob()));
return {
file: subtitleBlob,
language: sub.label,
}
});
return { url: sources[sources.length - 1].file, subtitles };
}
const gdriveplayer = { findContent, getStreamUrl }
export default gdriveplayer;

View file

@ -1,91 +0,0 @@
// THIS SCRAPER DOES NOT CURRENTLY WORK AND IS NOT IN USE
import { unpack } from '../util/unpacker';
const BASE_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://gomo.to`;
const MOVIE_URL = `${BASE_URL}/movie`
const DECODING_URL = `${BASE_URL}/decoding_v3.php`
async function findContent(searchTerm, type) {
try {
if (type !== 'movie') return;
const term = searchTerm.toLowerCase()
const imdbRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://v2.sg.media-imdb.com/suggestion/${term.slice(0, 1)}/${term}.json`).then(d => d.json())
const results = [];
imdbRes.d.forEach((e) => {
if (!e.id.startsWith('tt')) return;
// Block tv shows
if (e.q === "TV series") return;
if (e.q === "TV mini-series") return;
if (e.q === "video game") return;
if (e.q === "TV movie") return;
if (e.q === "TV special") return;
results.push({
title: e.l,
slug: e.id,
type: 'movie',
year: e.y,
source: 'gomostream'
})
});
if (results.length > 1) {
return { options: results };
} else {
return { options: [ results[0] ] }
}
} catch (err) {
console.error(err);
throw new Error(err)
}
}
async function getStreamUrl(slug, type, season, episode) {
if (type !== 'movie') return;
// Get stream to go with IMDB ID
const site1 = await fetch(`${MOVIE_URL}/${slug}`).then((d) => d.text());
if (site1 === "Movie not available.")
return { url: '' };
const tc = site1.match(/var tc = '(.+)';/)?.[1]
const _token = site1.match(/"_token": "(.+)",/)?.[1]
const fd = new FormData()
fd.append('tokenCode', tc)
fd.append('_token', _token)
const src = await fetch(DECODING_URL, {
method: "POST",
body: fd,
headers: {
'x-token': tc.slice(5, 13).split("").reverse().join("") + "13574199"
}
}).then((d) => d.json());
const embedUrl = src.find(url => url.includes('gomo.to'));
const site2 = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}${embedUrl}`).then((d) => d.text());
const parser = new DOMParser();
const site2Dom = parser.parseFromString(site2, "text/html");
if (site2Dom.body.innerText === "File was deleted")
return { url: '' }
const script = site2Dom.querySelectorAll("script")[8].innerHTML;
let unpacked = unpack(script).split('');
unpacked.splice(0, 43);
let index = unpacked.findIndex((e) => e === '"');
const url = unpacked.slice(0, index).join('');
return { url }
}
const gomostream = { findContent, getStreamUrl }
export default gomostream;

View file

@ -1,164 +0,0 @@
import Fuse from 'fuse.js'
import JSON5 from 'json5'
const BASE_URL = `https://lookmovie.io`;
const API_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://lookmovie125.xyz`;
const CORS_URL = `${process.env.REACT_APP_CORS_PROXY_URL}${BASE_URL}`;
let phpsessid;
async function findContent(searchTerm, type) {
try {
const searchUrl = `${CORS_URL}/${type}s/search/?q=${encodeURIComponent(searchTerm)}`;
const searchRes = await fetch(searchUrl).then((d) => d.text());
// Parse DOM to find search results on full search page
const parser = new DOMParser();
const doc = parser.parseFromString(searchRes, "text/html");
const nodes = Array.from(doc.querySelectorAll('.movie-item-style-1'));
const results = nodes.map(node => {
return {
type,
title: node.querySelector('h6 a').innerText.trim(),
year: node.querySelector('.year').innerText.trim(),
slug: node.querySelector('a').href.split('/').pop(),
}
});
const fuse = new Fuse(results, { threshold: 0.3, distance: 200, keys: ["title"] });
const matchedResults = fuse
.search(searchTerm.toString())
.map((result) => result.item);
if (matchedResults.length === 0) {
return { options: [] }
}
if (matchedResults.length > 1) {
const res = { options: [] };
matchedResults.forEach((r) => res.options.push({
title: r.title,
slug: r.slug,
type: r.type,
year: r.year,
source: 'lookmovie'
}));
return res;
} else {
const { title, slug, type, year } = matchedResults[0];
return {
options: [{ title, slug, type, year, source: 'lookmovie' }]
}
}
} catch (e) {
return { options: [] }
}
}
async function getVideoUrl(config) {
let url = '';
if (config.type === 'movie') {
url = `${API_URL}/api/v1/security/movie-access?id_movie=${config.id}&token=1&sk=&step=1`;
} else if (config.type === 'show') {
url = `${API_URL}/api/v1/security/episode-access?id_episode=${config.id}`;
}
const data = await fetch(url, {
headers: { phpsessid },
}).then((d) => d.json());
const subs = data?.subtitles.filter((sub) => {
if (typeof sub.file === 'object') return false;
return true;
})
// Find video URL and return it (with a check for a full url if needed)
const opts = ["1080p", "1080", "720p", "720", "480p", "480", "auto"];
let videoUrl = "";
for (let res of opts) {
if (data.streams[res] && !data.streams[res].includes('dummy') && !data.streams[res].includes('earth-1984') && !videoUrl) {
videoUrl = data.streams[res]
}
}
return {
videoUrl: videoUrl.startsWith("/") ? `${BASE_URL}${videoUrl}` : videoUrl,
subs: subs,
};
}
async function getEpisodes(slug) {
const url = `${CORS_URL}/shows/view/${slug}`;
const pageReq = await fetch(url, {
headers: { phpsessid },
}).then((d) => d.text());
const data = JSON5.parse("{" +
pageReq
.slice(pageReq.indexOf(`show_storage`))
.split("};")[0]
.split("= {")[1]
.trim() +
"}"
);
let seasons = [];
let episodes = [];
data.seasons.forEach((e) => {
if (!seasons.includes(e.season))
seasons.push(e.season);
if (!episodes[e.season])
episodes[e.season] = []
episodes[e.season].push(e.episode)
})
return { seasons, episodes }
}
async function getStreamUrl(slug, type, season, episode) {
const url = `${CORS_URL}/${type}s/view/${slug}`;
const pageRes = await fetch(url);
if (pageRes.headers.get('phpsessid')) phpsessid = pageRes.headers.get('phpsessid');
const pageResText = await pageRes.text();
const data = JSON5.parse("{" +
pageResText
.slice(pageResText.indexOf(`${type}_storage`))
.split("};")[0]
.split("= {")[1]
.trim() +
"}"
);
let id = '';
if (type === "movie") {
id = data.id_movie;
} else if (type === "show") {
const episodeObj = data.seasons.find((v) => { return v.season === season && v.episode === episode; });
if (episodeObj) {
id = episodeObj.id_episode;
}
}
if (id === '') {
return { url: '' }
}
const videoUrl = await getVideoUrl({
slug: slug,
id: id,
type: type,
});
return { url: videoUrl.videoUrl, subtitles: videoUrl.subs };
}
const lookMovie = { findContent, getStreamUrl, getEpisodes };
export default lookMovie;

View file

@ -1,120 +0,0 @@
const BASE_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://theflix.to`;
async function findContent(searchTerm, type) {
try {
const term = searchTerm.toLowerCase()
const tmdbRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://www.themoviedb.org/search/${type === 'show' ? 'tv' : type}?query=${term}`).then(d => d.text());
const doc = new DOMParser().parseFromString(tmdbRes, 'text/html');
const nodes = Array.from(doc.querySelectorAll('div.results > div > div.wrapper'));
const results = nodes.slice(0, 10).map((node) => {
let type = node.querySelector('div.details > div.wrapper > div.title > div > a').getAttribute('data-media-type');
type = type === 'tv' ? 'show' : type;
let title;
let year;
let slug;
if (type === 'movie') {
try {
title = node.querySelector('div.details > div.wrapper > div.title > div > a').textContent;
year = node.querySelector('div.details > div.wrapper > div.title > span').textContent.trim().split(' ')[2];
slug = node.querySelector('div.details > div.wrapper > div.title > div > a').getAttribute('href').split('/')[2];
} catch (e) {
// eslint-disable-next-line array-callback-return
return;
}
} else if (type === 'show') {
try {
title = node.querySelector('div.details > div.wrapper > div.title > div > a > h2').textContent;
year = node.querySelector('div.details > div.wrapper > div.title > span').textContent.trim().split(' ')[2];
slug = node.querySelector('div.details > div.wrapper > div.title > div > a').getAttribute('href').split('/')[2];
} catch (e) {
// eslint-disable-next-line array-callback-return
return;
}
}
return {
type: type,
title: title,
year: year,
slug: slug + '-' + title.replace(/[^a-z0-9]+|\s+/gmi, " ").replace(/\s+/g, '-').toLowerCase(),
source: 'theflix'
}
});
if (results.length > 1) {
return { options: results };
} else {
return { options: [ results[0] ] }
}
} catch (err) {
console.error(err);
throw new Error(err)
}
}
async function getEpisodes(slug) {
let tmdbRes;
try {
tmdbRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://www.themoviedb.org/tv/${slug}/seasons`).then(d => d.text());
} catch (err) {
tmdbRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://www.themoviedb.org/tv/${slug.split('-')[0]}/seasons`).then(d => d.text());
if (tmdbRes)
slug = slug.split('-')[0];
}
const sNodes = Array.from(new DOMParser().parseFromString(tmdbRes, 'text/html').querySelectorAll('div.column_wrapper > div.flex > div'));
let seasons = [];
let episodes = [];
for (let s of sNodes) {
const text = s.querySelector('div > section > div > div > div > h2 > a').textContent;
if (!text.includes('Season')) continue;
const season = text.split(' ')[1];
if (!seasons.includes(season)) {
seasons.push(season);
}
if (!episodes[season]) {
episodes[season] = [];
}
const epRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://www.themoviedb.org/tv/${slug}/season/${season}`).then(d => d.text());
const epNodes = Array.from(new DOMParser().parseFromString(epRes, 'text/html').querySelectorAll('div.episode_list > div.card'));
epNodes.forEach((e, i) => episodes[season].push(++i));
}
return { seasons, episodes };
}
async function getStreamUrl(slug, type, season, episode) {
let url;
if (type === 'show') {
url = `${BASE_URL}/tv-show/${slug}/season-${season}/episode-${episode}`;
} else {
url = `${BASE_URL}/movie/${slug}?movieInfo=${slug}`;
}
const res = await fetch(url).then(d => d.text());
const scripts = Array.from(new DOMParser().parseFromString(res, "text/html").querySelectorAll('script'));
const prop = scripts.find((e) => e.textContent.includes("theflixvd.b-cdn"));
if (prop) {
const data = JSON.parse(prop.textContent);
return { url: data.props.pageProps.videoUrl };
}
return { url: '' }
}
const theflix = { findContent, getStreamUrl, getEpisodes }
export default theflix;

View file

@ -1,70 +0,0 @@
import Fuse from 'fuse.js'
const BASE_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://stream.vidzstore.com`;
async function findContent(searchTerm, type) {
if (type === 'show') return { options: [] };
try {
const searchUrl = `${BASE_URL}/search.php?sd=${searchTerm.replace(/ /g, "_")}`;
const searchRes = await fetch(searchUrl).then((d) => d.text());
const parser = new DOMParser();
const doc = parser.parseFromString(searchRes, "text/html");
const nodes = [...doc.querySelectorAll(".post")];
const results = nodes.map(node => {
const title = node.querySelector("a").title.replace(/-/g, " ").trim();
const titleArray = title.split(" ");
titleArray.splice(-2);
return {
type,
title: titleArray.join(" "),
year: node.querySelector(".post-meta").innerText.split(" ").pop().split("-").shift(),
slug: encodeURIComponent(node.querySelector("a").href.split('/').pop()),
source: "vidzstore",
}
});
const fuse = new Fuse(results, { threshold: 0.3, keys: ["title"] });
const matchedResults = fuse
.search(searchTerm)
.map(result => result.item);
if (matchedResults.length === 0) {
return { options: [] };
}
if (matchedResults.length > 1) {
const res = { options: [] };
matchedResults.forEach((r) => res.options.push({
title: r.title,
slug: r.slug,
type: r.type,
year: r.year,
source: 'vidzstore'
}));
return res;
} else {
const { title, slug, type, year } = matchedResults[0];
return {
options: [{ title, slug, type, year, source: 'vidzstore' }]
}
}
} catch {
return { options: [] };
}
}
async function getStreamUrl(slug) {
const url = `${BASE_URL}/${decodeURIComponent(slug)}`;
const res = await fetch(url).then(d => d.text());
const DOM = new DOMParser().parseFromString(res, "text/html");
return { url: DOM.querySelector("source").src };
}
const vidzstore = { findContent, getStreamUrl }
export default vidzstore;

View file

@ -1,84 +0,0 @@
// THIS SCRAPER DOES NOT CURRENTLY WORK AND IS NOT IN USE
import { unpack } from '../util/unpacker';
const BASE_URL = `https://www.vmovee.watch`;
const CORS_URL = `${process.env.REACT_APP_CORS_PROXY_URL}${BASE_URL}`;
const SHOW_URL = `${CORS_URL}/series`
const MOVIE_URL = `${CORS_URL}/movies`
const MOVIE_URL_NO_CORS = `${BASE_URL}/movies`
async function findContent(searchTerm, type) {
try {
if (type !== 'movie') return;
const searchUrl = `${CORS_URL}/?s=${encodeURIComponent(searchTerm)}`;
const searchRes = await fetch(searchUrl).then((d) => d.text());
const parser = new DOMParser();
const doc = parser.parseFromString(searchRes, "text/html");
const nodes = Array.from(doc.querySelectorAll('div.search-page > div.result-item > article'));
const results = nodes.map(node => {
const imgHolder = node.querySelector('div.image > div.thumbnail > a');
const titleHolder = node.querySelector('div.title > a');
return {
type: imgHolder.querySelector('span').textContent === 'TV' ? 'show' : 'movie',
title: titleHolder.textContent,
year: node.querySelector('div.details > div.meta > span.year').textContent,
slug: titleHolder.href.split('/')[4],
source: 'vmovee'
}
});
if (results.length > 1) {
return { options: results };
} else {
return { options: [ results[0] ] }
}
} catch (err) {
throw new Error(err)
}
}
async function getStreamUrl(slug, type, season, episode) {
let url = '';
if (type === 'movie') {
url = `${MOVIE_URL}/${slug}`;
} else if (type === 'show') {
url = `${SHOW_URL}/${slug}`;
}
const res1 = await fetch(url, { headers: new Headers().append('referer', `${BASE_URL}/dashboard/admin-ajax.php`) });
const id = res1.headers.get('link').split('>')[0].split('?p=')[1];
const res2Headers = new Headers().append('referer', `${BASE_URL}/dashboard/admin-ajax.php`);
const form = new FormData();
form.append('action', 'doo_player_ajax')
form.append('post', id)
form.append('nume', '2')
form.append('type', type)
const res2 = await fetch(`${CORS_URL}/dashboard/admin-ajax.php`, {
method: 'POST',
headers: res2Headers,
body: form
}).then((res) => res.json());
let realUrl = res2.embed_url;
console.log(res2)
if (realUrl.startsWith('//')) {
realUrl = `https:${realUrl}`;
}
const res3 = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}${realUrl}`);
res3.headers.forEach(console.log)
return { url: '' }
}
const vmovee = { findContent, getStreamUrl }
export default vmovee;

View file

@ -1,119 +0,0 @@
import Fuse from 'fuse.js'
const BASE_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://xemovie.co`;
async function findContent(searchTerm, type) {
try {
let results;
const searchUrl = `${BASE_URL}/search?q=${encodeURIComponent(searchTerm)}`;
const searchRes = await fetch(searchUrl).then((d) => d.text());
const parser = new DOMParser();
const doc = parser.parseFromString(searchRes, "text/html");
switch (type) {
case 'show':
// const showContainer = doc.querySelectorAll(".py-10")[1].querySelector(".grid");
// const showNodes = [...showContainer.querySelectorAll("a")].filter(link => !link.className);
// results = showNodes.map(node => {
// node = node.parentElement
// return {
// type,
// title: [...new Set(node.innerText.split("\n"))][1].split("(")[0].trim(),
// year: [...new Set(node.innerText.split("\n"))][3],
// slug: node.querySelector("a").href.split('/').pop(),
// source: "xemovie"
// }
// })
// break;
return { options: [] };
case 'movie':
const movieContainer = doc.querySelectorAll(".py-10")[0].querySelector(".grid");
const movieNodes = [...movieContainer.querySelectorAll("a")].filter(link => !link.className);
results = movieNodes.map(node => {
node = node.parentElement
return {
type,
title: [...new Set(node.innerText.split("\n"))][1].split("(")[0].trim(),
year: [...new Set(node.innerText.split("\n"))][3],
slug: node.querySelector("a").href.split('/').pop(),
source: "xemovie"
}
})
break;
default:
results = [];
break;
}
const fuse = new Fuse(results, { threshold: 0.3, keys: ["title"] });
const matchedResults = fuse
.search(searchTerm)
.map(result => result.item);
if (matchedResults.length === 0) {
return { options: [] };
}
if (matchedResults.length > 1) {
const res = { options: [] };
matchedResults.forEach((r) => res.options.push({
title: r.title,
slug: r.slug,
type: r.type,
year: r.year,
source: 'xemovie'
}));
return res;
} else {
const { title, slug, type, year } = matchedResults[0];
return {
options: [{ title, slug, type, year, source: 'xemovie' }]
}
}
} catch {
return { options: [] };
}
}
async function getStreamUrl(slug, type, season, episode) {
let url;
if (type === "show") {
} else {
url = `${BASE_URL}/movies/${slug}/watch`;
}
let mediaUrl = "";
let subtitles = [];
const res = await fetch(url).then(d => d.text());
const DOM = new DOMParser().parseFromString(res, "text/html");
for (const script of DOM.scripts) {
if (script.textContent.match(/https:\/\/s[0-9]\.xemovie\.com/)) {
// eslint-disable-next-line
let data = JSON.parse(JSON.stringify(eval(`(${script.textContent.replace("const data = ", "").split("};")[0]}})`)));
mediaUrl = data.playlist[0].file;
for (const subtitleTrack of data.playlist[0].tracks) {
const subtitleBlob = URL.createObjectURL(await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}${subtitleTrack.file}`).then(res => res.blob())); // do this so no need for CORS errors
subtitles.push({
file: subtitleBlob,
language: subtitleTrack.label
})
}
}
}
return { url: `${process.env.REACT_APP_CORS_PROXY_URL}${mediaUrl}`, subtitles: subtitles }
}
async function getEpisodes(slug) {
}
const xemovie = { findContent, getStreamUrl, getEpisodes }
export default xemovie;

View file

@ -1,43 +0,0 @@
import { versionedStoreBuilder } from './base.js';
/*
version 0
{
[{scraperid}]: {
movie: {
[{movie-id}]: {
full: {
currentlyAt: number,
totalDuration: number,
updatedAt: number, // unix timestamp in ms
meta: FullMetaObject, // no idea whats in here
}
}
},
show: {
[{show-id}]: {
[{season}-{episode}]: {
currentlyAt: number,
totalDuration: number,
updatedAt: number, // unix timestamp in ms
show: {
episode: string,
season: string,
},
meta: FullMetaObject, // no idea whats in here
}
}
}
}
}
*/
export const VideoProgressStore = versionedStoreBuilder()
.setKey('video-progress')
.addVersion({
version: 0,
create() {
return {}
}
})
.build()

View file

@ -1,230 +0,0 @@
function buildStoreObject(d) {
const data = {
versions: d.versions,
currentVersion: d.maxVersion,
id: d.storageString,
}
function update(obj) {
if (!obj)
throw new Error("object to update is not an object");
// repeat until object fully updated
if (obj["--version"] === undefined)
obj["--version"] = 0;
while (obj["--version"] !== this.currentVersion) {
// get version
let version = obj["--version"] || 0;
if (version.constructor !== Number || version < 0)
version = -42; // invalid on purpose so it will reset
else {
version = (version+1).toString()
}
// check if version exists
if (!this.versions[version]) {
console.error(`Version not found for storage item in store ${this.id}, resetting`);
obj = null;
break;
}
// update object
obj = this.versions[version].update(obj);
}
// if resulting obj is null, use latest version as init object
if (obj === null) {
console.error(`Storage item for store ${this.id} has been reset due to faulty updates`);
return this.versions[this.currentVersion.toString()].init();
}
// updates succesful, return
return obj;
}
function get() {
// get from storage api
const store = this;
let data = localStorage.getItem(this.id);
// parse json if item exists
if (data) {
try {
data = JSON.parse(data);
if (!data.constructor) {
console.error(`Storage item for store ${this.id} has not constructor`)
throw new Error("storage item has no constructor")
}
if (data.constructor !== Object) {
console.error(`Storage item for store ${this.id} is not an object`)
throw new Error("storage item is not an object")
}
} catch (_) {
// if errored, set to null so it generates new one, see below
console.error(`Failed to parse storage item for store ${this.id}`)
data = null;
}
}
// if item doesnt exist, generate from version init
if (!data) {
data = this.versions[this.currentVersion.toString()].init();
}
// update the data if needed
data = this.update(data);
// add a save object to return value
data.save = function save() {
localStorage.setItem(store.id, JSON.stringify(data));
}
// add instance helpers
Object.entries(d.instanceHelpers).forEach(([name, helper]) => {
if (data[name] !== undefined)
throw new Error(`helper name: ${name} on instance of store ${this.id} is reserved`)
data[name] = helper.bind(data);
})
// return data
return data;
}
// add functions to store
data.get = get.bind(data);
data.update = update.bind(data);
// add static helpers
Object.entries(d.staticHelpers).forEach(([name, helper]) => {
if (data[name] !== undefined)
throw new Error(`helper name: ${name} on store ${data.id} is reserved`)
data[name] = helper.bind({});
})
return data;
}
/*
* Builds a versioned store
*
* manages versioning of localstorage items
*/
export function versionedStoreBuilder() {
return {
_data: {
versionList: [],
maxVersion: 0,
versions: {},
storageString: null,
instanceHelpers: {},
staticHelpers: {},
},
/*
* set key of localstorage item, must be unique
*/
setKey(str) {
this._data.storageString = str;
return this;
},
/*
* add a version to the store
*
* version: version number
* migrate: function to update from previous version to this version
* create: function to return an empty storage item from this version (in correct syntax)
*/
addVersion({ version, migrate, create }) {
// input checking
if (version < 0)
throw new Error("Cannot add version below 0 in store");
if (version > 0 && !migrate)
throw new Error(`Missing migration on version ${version} (needed for any version above 0)`);
// update max version list
if (version > this._data.maxVersion)
this._data.maxVersion = version;
// add to version list
this._data.versionList.push(version);
// register version
this._data.versions[version.toString()] = {
version: version, // version number
update: migrate ? (data) => { // update function, and increment version
migrate(data);
data["--version"] = version;
return data;
} : null,
init: create ? () => { // return an initial object
const data = create();
data["--version"] = version;
return data;
} : null
}
return this;
},
/*
* register a instance or static helper to the store
*
* name: name of the helper function
* helper: function to execute, the 'this' context is the current storage item (type is instance)
* type: "instance" or "static". instance is put on the storage item when you store.get() it, static is on the store
*/
registerHelper({ name, helper, type }) {
// type
if (!type)
type = "instance"
// input checking
if (!name || name.constructor !== String) {
throw new Error("helper name is not a string")
}
if (!helper || helper.constructor !== Function) {
throw new Error("helper function is not a function")
}
if (!["instance", "static"].includes(type)) {
throw new Error("helper type must be either 'instance' or 'static'")
}
// register helper
if (type === "instance")
this._data.instanceHelpers[name] = helper
else if (type === "static")
this._data.staticHelpers[name] = helper
return this;
},
/*
* returns function store based on what has been set
*/
build() {
// check if version list doesnt skip versions
const versionListSorted = this._data.versionList.sort((a,b)=>a-b);
versionListSorted.forEach((v, i, arr) => {
if (i === 0)
return;
if (v !== arr[i-1]+1)
throw new Error("Version list of store is not incremental");
})
// version zero must exist
if (versionListSorted[0] !== 0)
throw new Error("Version 0 doesn't exist in version list of store");
// max version must have init function
if (!this._data.versions[this._data.maxVersion.toString()].init)
throw new Error(`Missing create function on version ${this._data.maxVersion} (needed for latest version of store)`);
// check storage string
if (!this._data.storageString)
throw new Error("storage key not set in store");
// build versioned store
return buildStoreObject(this._data);
}
}
}

View file

@ -1,53 +0,0 @@
const alphabet = {
62: "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
95: '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'
};
function _filterargs(str) {
var juicers = [
/}\('([\s\S]*)', *(\d+), *(\d+), *'([\s\S]*)'\.split\('\|'\), *(\d+), *([\s\S]*)\)\)/,
/}\('([\s\S]*)', *(\d+), *(\d+), *'([\s\S]*)'\.split\('\|'\)/
];
for (var c = 0; c < juicers.length; ++c) {
var m, juicer = juicers[c];
// eslint-disable-next-line no-cond-assign
if (m = juicer.exec(str)) {
return [m[1], m[4].split('|'), parseInt(m[2]), parseInt(m[3])];
}
}
throw new Error("Could not make sense of p.a.c.k.e.r data (unexpected code structure)");
}
function _unbaser(base) {
if (2 <= base <= 36) return (str) => parseInt(str, base);
const dictionary = {};
var alpha = alphabet[base];
if (!alpha) throw new Error("Unsupported encoding");
for (let c = 0; c < alpha.length; ++alpha) {
dictionary[alpha[c]] = c;
}
return (str) => str.split("").reverse().reduce((cipher, ind) => Math.pow(base, ind) * dictionary[cipher]);
}
function unpack(str) {
var params = _filterargs(str);
var payload = params[0], symtab = params[1], radix = params[2], count = params[3];
if (count !== symtab.length) {
throw new Error("Malformed p.a.c.k.e.r. symtab. (" + count + " != " + symtab.length + ")");
}
var unbase = _unbaser(radix);
var lookup = (word) => symtab[unbase(word)] || word;
var source = payload.replace(/\b\w+\b/g, lookup);
return source;
}
export { unpack };

4
src/mw_constants.ts Normal file
View file

@ -0,0 +1,4 @@
export const CORS_PROXY_URL = "https://proxy-1.movie-web.workers.dev/?destination=";
export const OMDB_API_KEY = "aa0937c0";
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
export const GITHUB_LINK = "https://github.com/JamesHawkinss/movie-web";

31
src/providers/README.md Normal file
View file

@ -0,0 +1,31 @@
# the providers
to make this as clear as possible, here is some extra information on how the interal system works regarding providers.
| Term | explanation |
| ------------- | ------------------------------------------------------------------------------------- |
| Media | Object containing information about a piece of media. like title and its id's |
| PortableMedia | Object with just the identifiers of a piece of media. used for transport and saving |
| MediaStream | Object with a stream url in it. use it to view a piece of media. |
| Provider | group of methods to generate media and mediastreams from a source. aliased as scraper |
All types are prefixed with MW (MovieWeb) to prevent clashing names.
## Some rules
1. **Never** remove a provider completely if it's been in use before. just disable it.
2. **Never** change the ID of a provider if it's been in use before.
3. **Never** change system of the media ID of a provider without making it backwards compatible
All these rules are because `PortableMedia` objects need to stay functional. because:
- It's used for routing, links would stop working
- It's used for storage, continue watching and bookmarks would stop working
# The list of providers and their quirks
Some providers have quirks, stuff they do differently than other providers
## TheFlix
- for series, the latest episode released will be one playing at first when you select it from search results

42
src/providers/index.ts Normal file
View file

@ -0,0 +1,42 @@
import { getProviderFromId } from "./methods/helpers";
import { MWMedia, MWPortableMedia, MWMediaStream } from "./types";
export * from "./types";
export * from "./methods/helpers";
export * from "./methods/providers";
export * from "./methods/search";
/*
** Turn media object into a portable media object
*/
export function convertMediaToPortable(media: MWMedia): MWPortableMedia {
return {
mediaId: media.mediaId,
providerId: media.providerId,
mediaType: media.mediaType,
episodeId: media.episodeId,
seasonId: media.seasonId,
};
}
/*
** Turn portable media into media object
*/
export async function convertPortableToMedia(
portable: MWPortableMedia
): Promise<MWMedia | undefined> {
const provider = getProviderFromId(portable.providerId);
return provider?.getMediaFromPortable(portable);
}
/*
** find provider from portable and get stream from that provider
*/
export async function getStream(
media: MWPortableMedia
): Promise<MWMediaStream | undefined> {
const provider = getProviderFromId(media.providerId);
if (!provider) return undefined;
return provider.getStream(media);
}

View file

@ -0,0 +1,90 @@
import {
MWMediaProvider,
MWMediaType,
MWPortableMedia,
MWMediaStream,
MWQuery,
MWProviderMediaResult
} from "providers/types";
import { CORS_PROXY_URL } from "mw_constants";
import { unpack } from "unpacker";
import CryptoJS from "crypto-js";
const format = {
stringify: (cipher: any) => {
const ct = cipher.ciphertext.toString(CryptoJS.enc.Base64);
const iv = cipher.iv.toString() || "";
const salt = cipher.salt.toString() || "";
return JSON.stringify({
ct,
iv,
salt,
});
},
parse: (jsonStr: string) => {
const json = JSON.parse(jsonStr);
const ciphertext = CryptoJS.enc.Base64.parse(json.ct);
const iv = CryptoJS.enc.Hex.parse(json.iv) || "";
const salt = CryptoJS.enc.Hex.parse(json.s) || "";
const cipher = CryptoJS.lib.CipherParams.create({
ciphertext,
iv,
salt,
});
return cipher;
}
};
export const gDrivePlayerScraper: MWMediaProvider = {
id: "gdriveplayer",
enabled: true,
type: [MWMediaType.MOVIE],
displayName: "gdriveplayer",
async getMediaFromPortable(media: MWPortableMedia): Promise<MWProviderMediaResult> {
const res = await fetch(`${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<MWProviderMediaResult[]> {
const searchRes = await fetch(`${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,
}));
return results;
},
async getStream(media: MWPortableMedia): Promise<MWMediaStream> {
const streamRes = await fetch(`${CORS_PROXY_URL}https://database.gdriveplayer.us/player.php?imdb=${media.mediaId}`).then((d) => d.text());
const page = new DOMParser().parseFromString(streamRes, "text/html");
const script: HTMLElement | undefined = Array.from(
page.querySelectorAll("script")
).find((e) => e.textContent?.includes("eval"));
if (!script || !script.textContent) {
throw new Error("Could not find stream");
}
/// NOTE: this code requires re-write, it's not safe
const data = unpack(script.textContent).split("var data=\\'")[1].split("\\'")[0].replace(/\\/g, "");
const decryptedData = unpack(CryptoJS.AES.decrypt(data, "alsfheafsjklNIWORNiolNIOWNKLNXakjsfwnBdwjbwfkjbJjkopfjweopjASoiwnrflakefneiofrt", { format }).toString(CryptoJS.enc.Utf8));
// eslint-disable-next-line
const sources = JSON.parse(JSON.stringify(eval(decryptedData.split("sources:")[1].split(",image")[0].replace(/\\/g, "").replace(/document\.referrer/g, "\"\""))));
const source = sources[sources.length - 1];
/// END
return { url: `https:${source.file}`, type: source.type, captions: [] };
},
};

View file

@ -0,0 +1,95 @@
import {
MWMediaProvider,
MWMediaType,
MWPortableMedia,
MWMediaStream,
MWQuery,
MWProviderMediaResult
} from "providers/types";
import { CORS_PROXY_URL, OMDB_API_KEY } from "mw_constants";
import { unpack } from "unpacker";
export const gomostreamScraper: MWMediaProvider = {
id: "gomostream",
enabled: true,
type: [MWMediaType.MOVIE],
displayName: "gomostream",
async getMediaFromPortable(media: MWPortableMedia): Promise<MWProviderMediaResult> {
const params = new URLSearchParams({
apikey: OMDB_API_KEY,
i: media.mediaId,
type: media.mediaType
});
const res = await fetch(
`${CORS_PROXY_URL}http://www.omdbapi.com/?${encodeURIComponent(params.toString())}`,
).then(d => d.json())
return {
...media,
title: res.Title,
year: res.Year
} as MWProviderMediaResult;
},
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> {
const term = query.searchQuery.toLowerCase();
const params = new URLSearchParams({
apikey: OMDB_API_KEY,
s: term,
type: query.type
});
const searchRes = await fetch(
`${CORS_PROXY_URL}http://www.omdbapi.com/?${encodeURIComponent(params.toString())}`,
).then(d => d.json())
const results: MWProviderMediaResult[] = (searchRes.Search || []).map((d: any) => ({
title: d.Title,
year: d.Year,
mediaId: d.imdbID
} as MWProviderMediaResult));
return results;
},
async getStream(media: MWPortableMedia): Promise<MWMediaStream> {
const type = media.mediaType === MWMediaType.SERIES ? 'show' : media.mediaType;
const res1 = await fetch(`${CORS_PROXY_URL}https://gomo.to/${type}/${media.mediaId}`).then((d) => d.text());
if (res1 === "Movie not available." || res1 === "Episode not available.") throw new Error(res1);
const tc = res1.match(/var tc = '(.+)';/)?.[1] || "";
const _token = res1.match(/"_token": "(.+)",/)?.[1] || "";
const fd = new FormData()
fd.append('tokenCode', tc)
fd.append('_token', _token)
const src = await fetch(`${CORS_PROXY_URL}https://gomo.to/decoding_v3.php`, {
method: "POST",
body: fd,
headers: {
'x-token': `${tc.slice(5, 13).split("").reverse().join("")}13574199`
}
}).then((d) => d.json());
const embedUrl = src.find((url: string) => url.includes('gomo.to'));
const res2 = await fetch(`${CORS_PROXY_URL}${embedUrl}`).then((d) => d.text());
const res2DOM = new DOMParser().parseFromString(res2, "text/html");
if (res2DOM.body.innerText === "File was deleted") throw new Error("File was deleted");
const script = res2DOM.querySelectorAll("script")[8].innerHTML;
const unpacked = unpack(script).split('');
unpacked.splice(0, 43);
const index = unpacked.findIndex((e) => e === '"');
const streamUrl = unpacked.slice(0, index).join('');
const streamType = streamUrl.split('.').at(-1);
if (streamType !== "mp4" && streamType !== "m3u8") throw new Error("Unsupported stream type");
return { url: streamUrl, type: streamType, captions: [] };
}
};

View file

@ -0,0 +1,113 @@
import {
MWMediaProvider,
MWMediaType,
MWPortableMedia,
MWMediaStream,
MWQuery,
MWMediaSeasons,
MWProviderMediaResult
} from "providers/types";
import {
searchTheFlix,
getDataFromSearch,
turnDataIntoMedia,
} from "providers/list/theflix/search";
import { getDataFromPortableSearch } from "providers/list/theflix/portableToMedia";
import { CORS_PROXY_URL } from "mw_constants";
export const theFlixScraper: MWMediaProvider = {
id: "theflix",
enabled: false,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
displayName: "theflix",
async getMediaFromPortable(
media: MWPortableMedia
): Promise<MWProviderMediaResult> {
const data: any = await getDataFromPortableSearch(media);
return {
...media,
year: new Date(data.releaseDate).getFullYear().toString(),
title: data.name,
};
},
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> {
const searchRes = await searchTheFlix(query);
const searchData = await getDataFromSearch(searchRes, 10);
const results: MWProviderMediaResult[] = [];
for (const item of searchData) {
results.push(turnDataIntoMedia(item));
}
return results;
},
async getStream(media: MWPortableMedia): Promise<MWMediaStream> {
let url = "";
if (media.mediaType === MWMediaType.MOVIE) {
url = `${CORS_PROXY_URL}https://theflix.to/movie/${media.mediaId}?movieInfo=${media.mediaId}`;
} else if (media.mediaType === MWMediaType.SERIES) {
url = `${CORS_PROXY_URL}https://theflix.to/tv-show/${media.mediaId}/season-${media.seasonId}/episode-${media.episodeId}`;
}
const res = await fetch(url).then((d) => d.text());
const prop: HTMLElement | undefined = Array.from(
new DOMParser()
.parseFromString(res, "text/html")
.querySelectorAll("script")
).find((e) => e.textContent?.includes("theflixvd.b-cdn"));
if (!prop || !prop.textContent) {
throw new Error("Could not find stream");
}
const data = JSON.parse(prop.textContent);
return { url: data.props.pageProps.videoUrl, type: "mp4", captions: [] };
},
async getSeasonDataFromMedia(
media: MWPortableMedia
): Promise<MWMediaSeasons> {
const url = `${CORS_PROXY_URL}https://theflix.to/tv-show/${media.mediaId}/season-${media.seasonId}/episode-${media.episodeId}`;
const res = await fetch(url).then((d) => d.text());
const node: Element = Array.from(
new DOMParser()
.parseFromString(res, "text/html")
.querySelectorAll(`script[id="__NEXT_DATA__"]`)
)[0];
let data = JSON.parse(node.innerHTML).props.pageProps.selectedTv.seasons;
data = data.filter((season: any) => season.releaseDate != null);
data = data.map((season: any) => {
const episodes = season.episodes.filter(
(episode: any) => episode.releaseDate != null
);
return { ...season, episodes };
});
return {
seasons: data.map((d: any) => ({
sort: d.seasonNumber === 0 ? 999 : d.seasonNumber,
id: d.seasonNumber.toString(),
type: d.seasonNumber === 0 ? "special" : "season",
title: d.name,
episodes: d.episodes.map((e: any) => ({
title: e.name,
sort: e.episodeNumber,
id: e.episodeNumber.toString(),
episodeNumber: e.episodeNumber,
})),
})),
};
},
};

View file

@ -0,0 +1,36 @@
import { CORS_PROXY_URL } from "mw_constants";
import { MWMediaType, MWPortableMedia } from "providers/types";
const getTheFlixUrl = (media: MWPortableMedia, params?: URLSearchParams) => {
if (media.mediaType === MWMediaType.MOVIE) {
return `https://theflix.to/movie/${media.mediaId}?${params}`;
}
if (media.mediaType === MWMediaType.SERIES) {
return `https://theflix.to/tv-show/${media.mediaId}/season-${media.seasonId}/episode-${media.episodeId}`;
}
return "";
};
export async function getDataFromPortableSearch(
media: MWPortableMedia
): Promise<any> {
const params = new URLSearchParams();
params.append("movieInfo", media.mediaId);
const res = await fetch(CORS_PROXY_URL + getTheFlixUrl(media, params)).then(
(d) => d.text()
);
const node: Element = Array.from(
new DOMParser()
.parseFromString(res, "text/html")
.querySelectorAll(`script[id="__NEXT_DATA__"]`)
)[0];
if (media.mediaType === MWMediaType.MOVIE) {
return JSON.parse(node.innerHTML).props.pageProps.movie;
}
// must be series here
return JSON.parse(node.innerHTML).props.pageProps.selectedTv;
}

View file

@ -0,0 +1,48 @@
import { CORS_PROXY_URL } from "mw_constants";
import { MWMediaType, MWProviderMediaResult, MWQuery } from "providers";
const getTheFlixUrl = (type: "tv-shows" | "movies", params: URLSearchParams) =>
`https://theflix.to/${type}/trending?${params}`;
export function searchTheFlix(query: MWQuery): Promise<string> {
const params = new URLSearchParams();
params.append("search", query.searchQuery);
return fetch(
CORS_PROXY_URL +
getTheFlixUrl(
query.type === MWMediaType.MOVIE ? "movies" : "tv-shows",
params
)
).then((d) => d.text());
}
export function getDataFromSearch(page: string, limit = 10): any[] {
const node: Element = Array.from(
new DOMParser()
.parseFromString(page, "text/html")
.querySelectorAll(`script[id="__NEXT_DATA__"]`)
)[0];
const data = JSON.parse(node.innerHTML);
return data.props.pageProps.mainList.docs
.filter((d: any) => d.available)
.slice(0, limit);
}
export function turnDataIntoMedia(data: any): MWProviderMediaResult {
return {
mediaId: `${data.id}-${data.name
.replace(/[^a-z0-9]+|\s+/gim, " ")
.trim()
.replace(/\s+/g, "-")
.toLowerCase()}`,
title: data.name,
year: new Date(data.releaseDate).getFullYear().toString(),
seasonCount: data.numberOfSeasons,
episodeId: data.lastReleasedEpisode
? data.lastReleasedEpisode.episodeNumber.toString()
: null,
seasonId: data.lastReleasedEpisode
? data.lastReleasedEpisode.seasonNumber.toString()
: null,
};
}

View file

@ -0,0 +1,9 @@
import { SimpleCache } from "utils/cache";
import { MWPortableMedia, MWMedia } from "providers";
// cache
const contentCache = new SimpleCache<MWPortableMedia, MWMedia>();
contentCache.setCompare((a,b) => a.mediaId === b.mediaId && a.providerId === b.providerId);
contentCache.initialize();
export default contentCache;

View file

@ -0,0 +1,65 @@
import { MWMediaType, MWMediaProviderMetadata } from "providers";
import { MWMedia, MWMediaEpisode, MWMediaSeason } from "providers/types";
import { mediaProviders, mediaProvidersUnchecked } from "./providers";
/*
** Fetch all enabled providers for a specific type
*/
export function GetProvidersForType(type: MWMediaType) {
return mediaProviders.filter((v) => v.type.includes(type));
}
/*
** Get a provider by a id
*/
export function getProviderFromId(id: string) {
return mediaProviders.find((v) => v.id === id);
}
/*
** Get a provider metadata
*/
export function getProviderMetadata(id: string): MWMediaProviderMetadata {
const provider = mediaProvidersUnchecked.find((v) => v.id === id);
if (!provider) {
return {
exists: false,
type: [],
enabled: false,
id,
};
}
return {
exists: true,
type: provider.type,
enabled: provider.enabled,
id,
provider,
};
}
/*
** get episode and season from media
*/
export function getEpisodeFromMedia(
media: MWMedia
): { season: MWMediaSeason; episode: MWMediaEpisode } | null {
if (
media.seasonId === undefined ||
media.episodeId === undefined ||
media.seriesData === undefined
) {
return null;
}
const season = media.seriesData.seasons.find((v) => v.id === media.seasonId);
if (!season) return null;
const episode = season?.episodes.find((v) => v.id === media.episodeId);
if (!episode) return null;
return {
season,
episode,
};
}

View file

@ -0,0 +1,13 @@
import { theFlixScraper } from "providers/list/theflix";
import { gDrivePlayerScraper } from "providers/list/gdriveplayer";
import { MWWrappedMediaProvider, WrapProvider } from "providers/wrapper";
import { gomostreamScraper } from "providers/list/gomostream";
export const mediaProvidersUnchecked: MWWrappedMediaProvider[] = [
WrapProvider(theFlixScraper),
WrapProvider(gDrivePlayerScraper),
WrapProvider(gomostreamScraper),
];
export const mediaProviders: MWWrappedMediaProvider[] =
mediaProvidersUnchecked.filter((v) => v.enabled);

View file

@ -0,0 +1,101 @@
import Fuse from "fuse.js";
import {
MWMassProviderOutput,
MWMedia,
MWQuery,
convertMediaToPortable,
} from "providers";
import { SimpleCache } from "utils/cache";
import { GetProvidersForType } from "./helpers";
import contentCache from "./contentCache";
// cache
const resultCache = new SimpleCache<MWQuery, MWMassProviderOutput>();
resultCache.setCompare(
(a, b) => a.searchQuery === b.searchQuery && a.type === b.type
);
resultCache.initialize();
/*
** actually call all providers with the search query
*/
async function callProviders(query: MWQuery): Promise<MWMassProviderOutput> {
const allQueries = GetProvidersForType(query.type).map<
Promise<{ media: MWMedia[]; success: boolean; id: string }>
>(async (provider) => {
try {
return {
media: await provider.searchForMedia(query),
success: true,
id: provider.id,
};
} catch (err) {
console.error(`Failed running provider ${provider.id}`, err, query);
return {
media: [],
success: false,
id: provider.id,
};
}
});
const allResults = await Promise.all(allQueries);
const providerResults = allResults.map((provider) => ({
success: provider.success,
id: provider.id,
}));
const output: MWMassProviderOutput = {
results: allResults.flatMap((results) => results.media),
providers: providerResults,
stats: {
total: providerResults.length,
failed: providerResults.filter((v) => !v.success).length,
succeeded: providerResults.filter((v) => v.success).length,
},
};
// save in cache if all successfull
if (output.stats.failed === 0) {
resultCache.set(query, output, 60 * 60); // cache for an hour
}
output.results.forEach((result: MWMedia) => {
contentCache.set(convertMediaToPortable(result), result, 60 * 60);
});
return output;
}
/*
** sort results based on query
*/
function sortResults(
query: MWQuery,
providerResults: MWMassProviderOutput
): MWMassProviderOutput {
const results: MWMassProviderOutput = { ...providerResults };
const fuse = new Fuse(results.results, { threshold: 0.3, keys: ["title"] });
results.results = fuse.search(query.searchQuery).map((v) => v.item);
return results;
}
/*
** Call search on all providers that matches query type
*/
export async function SearchProviders(
inputQuery: MWQuery
): Promise<MWMassProviderOutput> {
// input normalisation
const query = { ...inputQuery };
query.searchQuery = query.searchQuery.toLowerCase().trim();
// consult cache first
let output = resultCache.get(query);
if (!output) output = await callProviders(query);
// sort results
output = sortResults(query, output);
if (output.stats.total === output.stats.failed)
throw new Error("All Scrapers failed");
return output;
}

View file

@ -0,0 +1,43 @@
import { SimpleCache } from "utils/cache";
import { MWPortableMedia } from "providers";
import { MWMediaSeasons, MWMediaType, MWMediaProviderSeries } from "providers/types";
import { getProviderFromId } from "./helpers";
// cache
const seasonCache = new SimpleCache<MWPortableMedia, MWMediaSeasons>();
seasonCache.setCompare(
(a, b) => a.mediaId === b.mediaId && a.providerId === b.providerId
);
seasonCache.initialize();
/*
** get season data from a (portable) media object, seasons and episodes will be sorted
*/
export async function getSeasonDataFromMedia(
media: MWPortableMedia
): Promise<MWMediaSeasons> {
const provider = getProviderFromId(media.providerId) as MWMediaProviderSeries;
if (!provider) {
return {
seasons: [],
};
}
if (!provider.type.includes(MWMediaType.SERIES) && !provider.type.includes(MWMediaType.ANIME)) {
return {
seasons: [],
};
}
if (seasonCache.has(media)) {
return seasonCache.get(media) as MWMediaSeasons;
}
const seasonData = await provider.getSeasonDataFromMedia(media);
seasonData.seasons.sort((a, b) => a.sort - b.sort);
seasonData.seasons.forEach((s) => s.episodes.sort((a, b) => a.sort - b.sort));
// cache it
seasonCache.set(media, seasonData, 60 * 60); // cache it for an hour
return seasonData;
}

97
src/providers/types.ts Normal file
View file

@ -0,0 +1,97 @@
export enum MWMediaType {
MOVIE = "movie",
SERIES = "series",
ANIME = "anime",
}
export interface MWPortableMedia {
mediaId: string;
mediaType: MWMediaType;
providerId: string;
seasonId?: string;
episodeId?: string;
}
export type MWMediaStreamType = "m3u8" | "mp4";
export interface MWMediaCaption {
id: string;
url: string;
label: string;
}
export interface MWMediaStream {
url: string;
type: MWMediaStreamType;
captions: MWMediaCaption[];
}
export interface MWMediaMeta extends MWPortableMedia {
title: string;
year: string;
seasonCount?: number;
}
export interface MWMediaEpisode {
sort: number;
id: string;
title: string;
}
export interface MWMediaSeason {
sort: number;
id: string;
title?: string;
type: "season" | "special";
episodes: MWMediaEpisode[];
}
export interface MWMediaSeasons {
seasons: MWMediaSeason[];
}
export interface MWMedia extends MWMediaMeta {
seriesData?: MWMediaSeasons;
}
export type MWProviderMediaResult = Omit<MWMedia, "mediaType" | "providerId">;
export interface MWQuery {
searchQuery: string;
type: MWMediaType;
}
export interface MWMediaProviderBase {
id: string; // id of provider, must be unique
enabled: boolean;
type: MWMediaType[];
displayName: string;
getMediaFromPortable(media: MWPortableMedia): Promise<MWProviderMediaResult>;
searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]>;
getStream(media: MWPortableMedia): Promise<MWMediaStream>;
getSeasonDataFromMedia?: (media: MWPortableMedia) => Promise<MWMediaSeasons>;
}
export type MWMediaProviderSeries = MWMediaProviderBase & {
getSeasonDataFromMedia: (media: MWPortableMedia) => Promise<MWMediaSeasons>;
};
export type MWMediaProvider = MWMediaProviderBase;
export interface MWMediaProviderMetadata {
exists: boolean;
id?: string;
enabled: boolean;
type: MWMediaType[];
provider?: MWMediaProvider;
}
export interface MWMassProviderOutput {
providers: {
id: string;
success: boolean;
}[];
results: MWMedia[];
stats: {
total: number;
failed: number;
succeeded: number;
};
}

Some files were not shown because too many files have changed in this diff Show more