├── .docs ├── .eslintignore ├── .eslintrc.cjs ├── .gitignore ├── app.config.ts ├── assets │ └── css │ │ └── main.css ├── content │ ├── 0.index.md │ ├── 1.get-started │ │ ├── 0.introduction.md │ │ ├── 1.quick-start.md │ │ ├── 3.examples.md │ │ ├── 4.changelog.md │ │ └── _dir.yml │ ├── 2.essentials │ │ ├── 0.usage-on-x.md │ │ ├── 1.targets.md │ │ ├── 2.fetchers.md │ │ ├── 3.customize-providers.md │ │ ├── 4.using-streams.md │ │ └── _dir.yml │ ├── 3.in-depth │ │ ├── 0.sources-and-embeds.md │ │ ├── 1.new-providers.md │ │ ├── 2.flags.md │ │ └── _dir.yml │ ├── 4.extra-topics │ │ ├── 0.development.md │ │ └── _dir.yml │ └── 5.api-reference │ │ ├── 0.makeProviders.md │ │ ├── 1.ProviderControlsRunAll.md │ │ ├── 2.ProviderControlsrunSourceScraper.md │ │ ├── 3.ProviderControlsrunEmbedScraper.md │ │ ├── 4.ProviderControlslistSources.md │ │ ├── 5.ProviderControlslistEmbeds.md │ │ ├── 6.ProviderControlsgetMetadata.md │ │ ├── 7.makeStandardFetcher.md │ │ ├── 8.makeSimpleProxyFetcher.md │ │ └── _dir.yml ├── nuxt.config.ts ├── package.json ├── pnpm-lock.yaml ├── public │ └── favicon.ico ├── renovate.json ├── tokens.config.ts └── tsconfig.json ├── .editorconfig ├── .eslintrc ├── .gitattributes ├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── SECURITY.md ├── pull_request_template.md └── workflows │ ├── docs.yml │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── examples └── .gitkeep ├── package.json ├── pnpm-lock.yaml ├── src ├── __test__ │ ├── providers │ │ ├── embedUtils.ts │ │ ├── embeds.test.ts │ │ ├── providerUtils.ts │ │ ├── providers.test.ts │ │ └── testMedia.ts │ ├── standard │ │ ├── fetchers │ │ │ ├── body.test.ts │ │ │ ├── common.test.ts │ │ │ ├── simpleProxy.test.ts │ │ │ └── standard.test.ts │ │ ├── providerTests.ts │ │ ├── providers │ │ │ └── checks.test.ts │ │ ├── runner │ │ │ ├── list.test.ts │ │ │ └── meta.test.ts │ │ └── utils │ │ │ ├── features.test.ts │ │ │ ├── list.test.ts │ │ │ └── valid.test.ts │ └── tsconfig.json ├── dev-cli │ ├── browser │ │ ├── .gitignore │ │ ├── index.html │ │ └── index.ts │ ├── config.ts │ ├── index.ts │ ├── logging.ts │ ├── scraper.ts │ ├── tmdb.ts │ └── validate.ts ├── entrypoint │ ├── builder.ts │ ├── controls.ts │ ├── declare.ts │ ├── providers.ts │ └── utils │ │ ├── events.ts │ │ ├── media.ts │ │ ├── meta.ts │ │ └── targets.ts ├── fetchers │ ├── body.ts │ ├── common.ts │ ├── fetch.ts │ ├── simpleProxy.ts │ ├── standardFetch.ts │ └── types.ts ├── index.ts ├── providers │ ├── all.ts │ ├── base.ts │ ├── captions.ts │ ├── embeds │ │ ├── closeload.ts │ │ ├── dood.ts │ │ ├── febbox │ │ │ ├── common.ts │ │ │ ├── fileList.ts │ │ │ ├── hls.ts │ │ │ ├── mp4.ts │ │ │ ├── qualities.ts │ │ │ └── subtitles.ts │ │ ├── filemoon │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── mixdrop.ts │ │ ├── mp4upload.ts │ │ ├── ridoo.ts │ │ ├── smashystream │ │ │ ├── dued.ts │ │ │ └── video1.ts │ │ ├── streambucket.ts │ │ ├── streamsb.ts │ │ ├── streamtape.ts │ │ ├── streamvid.ts │ │ ├── upcloud.ts │ │ ├── upstream.ts │ │ ├── vidcloud.ts │ │ ├── vidplay │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── vidsrc.ts │ │ ├── voe.ts │ │ └── wootly.ts │ ├── get.ts │ ├── sources │ │ ├── flixhq │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── scrape.ts │ │ │ └── search.ts │ │ ├── gomovies │ │ │ ├── index.ts │ │ │ └── source.ts │ │ ├── goojara │ │ │ ├── getEmbeds.ts │ │ │ ├── index.ts │ │ │ ├── type.ts │ │ │ └── util.ts │ │ ├── hdrezka │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── kissasian │ │ │ ├── common.ts │ │ │ ├── getEmbeds.ts │ │ │ ├── getEpisodes.ts │ │ │ ├── index.ts │ │ │ └── search.ts │ │ ├── lookmovie │ │ │ ├── index.ts │ │ │ ├── type.ts │ │ │ ├── util.ts │ │ │ └── video.ts │ │ ├── nepu │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── primewire │ │ │ ├── common.ts │ │ │ ├── decryption │ │ │ │ ├── README.md │ │ │ │ ├── blowfish.ts │ │ │ │ └── constants.ts │ │ │ └── index.ts │ │ ├── remotestream.ts │ │ ├── ridomovies │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── showbox │ │ │ ├── LICENSE │ │ │ ├── common.ts │ │ │ ├── crypto.ts │ │ │ ├── index.ts │ │ │ └── sendRequest.ts │ │ ├── smashystream │ │ │ └── index.ts │ │ ├── vidsrc │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── scrape-movie.ts │ │ │ ├── scrape-show.ts │ │ │ └── scrape.ts │ │ ├── vidsrcto │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ └── zoechip │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── scrape-movie.ts │ │ │ ├── scrape-show.ts │ │ │ ├── scrape.ts │ │ │ └── search.ts │ └── streams.ts ├── runners │ ├── individualRunner.ts │ └── runner.ts └── utils │ ├── compare.ts │ ├── context.ts │ ├── cookie.ts │ ├── errors.ts │ ├── list.ts │ ├── native.ts │ ├── predicates.ts │ ├── quality.ts │ └── valid.ts ├── tests ├── README.md ├── browser │ ├── .gitignore │ ├── index.html │ ├── index.ts │ ├── package.json │ └── startup.mjs ├── cjs │ ├── index.js │ └── package.json └── esm │ ├── index.mjs │ └── package.json ├── tsconfig.json └── vite.config.ts /.docs/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .output 4 | .nuxt -------------------------------------------------------------------------------- /.docs/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: '@nuxt/eslint-config', 4 | rules: { 5 | 'vue/max-attributes-per-line': 'off', 6 | 'vue/multi-word-component-names': 'off' 7 | } 8 | } -------------------------------------------------------------------------------- /.docs/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.iml 3 | .idea 4 | *.log* 5 | .nuxt 6 | .vscode 7 | .DS_Store 8 | coverage 9 | dist 10 | sw.* 11 | .env 12 | .output 13 | .nuxt 14 | -------------------------------------------------------------------------------- /.docs/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | docus: { 3 | title: '@movie-web/providers', 4 | description: 'For all your media scraping needs', 5 | socials: { 6 | github: 'movie-web/providers', 7 | }, 8 | image: '', 9 | aside: { 10 | level: 0, 11 | exclude: [], 12 | }, 13 | header: { 14 | logo: false, 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /.docs/assets/css/main.css: -------------------------------------------------------------------------------- 1 | code > span { 2 | white-space: pre; 3 | } 4 | -------------------------------------------------------------------------------- /.docs/content/0.index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "@movie-web/providers | For all your media scraping needs" 3 | navigation: false 4 | layout: page 5 | --- 6 | 7 | ::block-hero 8 | --- 9 | cta: 10 | - Get Started 11 | - /get-started/introduction 12 | secondary: 13 | - Open on GitHub → 14 | - https://github.com/movie-web/providers 15 | snippet: npm i @movie-web/providers 16 | --- 17 | 18 | #title 19 | @movie-web/providers 20 | 21 | #description 22 | Easily scrape all sorts of media sites for content 23 | :: 24 | 25 | ::card-grid 26 | #title 27 | What's included 28 | 29 | #root 30 | :ellipsis 31 | 32 | #default 33 | ::card{icon="vscode-icons:file-type-light-json"} 34 | #title 35 | Scrape popular streaming websites. 36 | #description 37 | Don't settle for just one media site for you content, use everything that's available. 38 | :: 39 | ::card{icon="codicon:source-control"} 40 | #title 41 | Multi-platform. 42 | #description 43 | Scrape from browser or server, whichever you prefer. 44 | :: 45 | ::card{icon="logos:typescript-icon-round"} 46 | #title 47 | Easy to use. 48 | #description 49 | Get started with scraping your favourite media sites with just 5 lines of code. Fully typed of course. 50 | :: 51 | :: 52 | -------------------------------------------------------------------------------- /.docs/content/1.get-started/0.introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | ## What is `@movie-web/providers`? 4 | 5 | `@movie-web/providers` is the soul of [movie-web](https://github.com/movie-web/movie-web). It's a collection of scrapers of various streaming sites. It extracts the raw streams from those sites, so you can watch them without any extra fluff from the original sites. 6 | 7 | ## What can I use this on? 8 | 9 | We support many different environments, here are a few examples: 10 | - In browser, watch streams without needing a server to scrape (does need a proxy) 11 | - In a native app, scrape in the app itself 12 | - In a backend server, scrape on the server and give the streams to the client to watch. 13 | 14 | To find out how to configure the library for your environment, You can read [How to use on X](../2.essentials/0.usage-on-x.md). 15 | -------------------------------------------------------------------------------- /.docs/content/1.get-started/1.quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick start 2 | 3 | ## Installation 4 | 5 | Let's get started with `@movie-web/providers`. First lets install the package. 6 | 7 | ::code-group 8 | ```bash [NPM] 9 | npm install @movie-web/providers 10 | ``` 11 | ```bash [Yarn] 12 | yarn add @movie-web/providers 13 | ``` 14 | ```bash [PNPM] 15 | pnpm install @movie-web/providers 16 | ``` 17 | :: 18 | 19 | ## Scrape your first item 20 | 21 | To get started with scraping on the **server**, first you have to make an instance of the providers. 22 | 23 | ::alert{type="warning"} 24 | This snippet will only work on a **server**. For other environments, check out [Usage on X](../2.essentials/0.usage-on-x.md). 25 | :: 26 | 27 | ```ts [index.ts (server)] 28 | import { makeProviders, makeStandardFetcher, targets } from '@movie-web/providers'; 29 | 30 | // this is how the library will make http requests 31 | const myFetcher = makeStandardFetcher(fetch); 32 | 33 | // make an instance of the providers library 34 | const providers = makeProviders({ 35 | fetcher: myFetcher, 36 | 37 | // will be played on a native video player 38 | target: targets.NATIVE 39 | }) 40 | ``` 41 | 42 | Perfect. You now have an instance of the providers you can reuse everywhere. 43 | Now let's scrape an item: 44 | 45 | ```ts [index.ts (server)] 46 | // fetch some data from TMDB 47 | const media = { 48 | type: 'movie', 49 | title: "Hamilton", 50 | releaseYear: 2020, 51 | tmdbId: "556574" 52 | } 53 | 54 | const output = await providers.runAll({ 55 | media: media 56 | }) 57 | ``` 58 | 59 | Now we have our stream in the output variable. (If the output is `null` then nothing could be found.) 60 | To find out how to use the streams, check out [Using streams](../2.essentials/4.using-streams.md). 61 | -------------------------------------------------------------------------------- /.docs/content/1.get-started/3.examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ::alert{type="warning"} 4 | There are no examples yet, stay tuned! 5 | :: 6 | -------------------------------------------------------------------------------- /.docs/content/1.get-started/_dir.yml: -------------------------------------------------------------------------------- 1 | icon: ph:shooting-star-fill 2 | navigation.redirect: /get-started/introduction 3 | -------------------------------------------------------------------------------- /.docs/content/2.essentials/0.usage-on-x.md: -------------------------------------------------------------------------------- 1 | # How to use on X 2 | 3 | The library can run in many environments, so it can be tricky to figure out how to set it up. 4 | 5 | Here is a checklist. For more specific environments, keep reading below: 6 | - When requests are very restricted (like browser client-side). Configure a proxied fetcher. 7 | - When your requests come from the same device on which it will be streamed (not compatible with proxied fetcher). Set `consistentIpForRequests: true`. 8 | - To set a target. Consult [Targets](./1.targets.md). 9 | 10 | To make use of the examples below, check out the following pages: 11 | - [Quick start](../1.get-started/1.quick-start.md) 12 | - [Using streams](../2.essentials/4.using-streams.md) 13 | 14 | ## NodeJs server 15 | ```ts 16 | import { makeProviders, makeStandardFetcher, targets } from '@movie-web/providers'; 17 | 18 | const providers = makeProviders({ 19 | fetcher: makeStandardFetcher(fetch), 20 | target: chooseYourself, // check out https://movie-web.github.io/providers/essentials/targets 21 | }) 22 | ``` 23 | 24 | ## Browser client-side 25 | 26 | Using the provider package client-side requires a hosted version of simple-proxy. 27 | Read more [about proxy fetchers](./2.fetchers.md#using-fetchers-on-the-browser). 28 | 29 | ```ts 30 | import { makeProviders, makeStandardFetcher, targets } from '@movie-web/providers'; 31 | 32 | const proxyUrl = "https://your.proxy.workers.dev/"; 33 | 34 | const providers = makeProviders({ 35 | fetcher: makeStandardFetcher(fetch), 36 | proxiedFetcher: makeSimpleProxyFetcher(proxyUrl, fetch), 37 | target: target.BROWSER, 38 | }) 39 | ``` 40 | 41 | ## React native 42 | To use the library in a react native app, you would also need a couple of polyfills to polyfill crypto and base64. 43 | 44 | 1. First install the polyfills: 45 | ```bash 46 | npm install @react-native-anywhere/polyfill-base64 react-native-quick-crypto 47 | ``` 48 | 49 | 2. Add the polyfills to your app: 50 | ```ts 51 | // Import in your entry file 52 | import '@react-native-anywhere/polyfill-base64'; 53 | ``` 54 | 55 | And follow the [react-native-quick-crypto documentation](https://github.com/margelo/react-native-quick-crypto) to set up the crypto polyfill. 56 | 57 | 3. Then you can use the library like this: 58 | 59 | ```ts 60 | import { makeProviders, makeStandardFetcher, targets } from '@movie-web/providers'; 61 | 62 | const providers = makeProviders({ 63 | fetcher: makeStandardFetcher(fetch), 64 | target: target.NATIVE, 65 | consistentIpForRequests: true, 66 | }) 67 | ``` 68 | -------------------------------------------------------------------------------- /.docs/content/2.essentials/1.targets.md: -------------------------------------------------------------------------------- 1 | # Targets 2 | 3 | When creating provider controls, you will immediately be required to choose a target. 4 | 5 | ::alert{type="warning"} 6 | A target is the device on which the stream will be played. 7 | **Where the scraping is run has nothing to do with the target**, only where the stream is finally played in the end is significant in choosing a target. 8 | :: 9 | 10 | #### Possible targets 11 | - **`targets.BROWSER`** Stream will be played in a browser with CORS 12 | - **`targets.BROWSER_EXTENSION`** Stream will be played in a browser using the movie-web extension (WIP) 13 | - **`targets.NATIVE`** Stream will be played on a native video player 14 | - **`targets.ANY`** No restrictions for selecting streams, will just give all of them 15 | -------------------------------------------------------------------------------- /.docs/content/2.essentials/2.fetchers.md: -------------------------------------------------------------------------------- 1 | # Fetchers 2 | 3 | When creating provider controls, a fetcher will need to be configured. 4 | Depending on your environment, this can come with some considerations: 5 | 6 | ## Using `fetch()` 7 | In most cases, you can use the `fetch()` API. This will work in newer versions of Node.js (18 and above) and on the browser. 8 | 9 | ```ts 10 | const fetcher = makeStandardFetcher(fetch); 11 | ``` 12 | 13 | If you using older version of Node.js. You can use the npm package `node-fetch` to polyfill fetch: 14 | 15 | ```ts 16 | import fetch from "node-fetch"; 17 | 18 | const fetcher = makeStandardFetcher(fetch); 19 | ``` 20 | 21 | ## Using fetchers on the browser 22 | When using this library on a browser, you will need a proxy. Browsers restrict when a web request can be made. To bypass those restrictions, you will need a CORS proxy. 23 | 24 | The movie-web team has a proxy pre-made and pre-configured for you to use. For more information, check out [movie-web/simple-proxy](https://github.com/movie-web/simple-proxy). After installing, you can use this proxy like so: 25 | 26 | ```ts 27 | const fetcher = makeSimpleProxyFetcher("https://your.proxy.workers.dev/", fetch); 28 | ``` 29 | 30 | If you aren't able to use this specific proxy and need to use a different one, you can make your own fetcher in the next section. 31 | 32 | ## Making a derived fetcher 33 | 34 | In some rare cases, a custom fetcher is necessary. This can be quite difficult to make from scratch so it's recommended to base it off of an existing fetcher and building your own functionality around it. 35 | 36 | ```ts 37 | export function makeCustomFetcher(): Fetcher { 38 | const fetcher = makeStandardFetcher(f); 39 | const customFetcher: Fetcher = (url, ops) => { 40 | // Do something with the options and URL here 41 | return fetcher(url, ops); 42 | }; 43 | 44 | return customFetcher; 45 | } 46 | ``` 47 | 48 | If you need to make your own fetcher for a proxy, ensure you make it compatible with the following headers: `Set-Cookie`, `Cookie`, `Referer`, `Origin`. Proxied fetchers need to be able to write/read those headers when making a request. 49 | 50 | 51 | ## Making a fetcher from scratch 52 | 53 | In some rare cases, you need to make a fetcher from scratch. 54 | This is the list of features it needs: 55 | - Send/read every header 56 | - Parse JSON, otherwise parse as text 57 | - Send JSON, Formdata or normal strings 58 | - get final destination URL 59 | 60 | It's not recommended to do this at all. If you have to, you can base your code on the original implementation of `makeStandardFetcher`. Check out the [source code for it here](https://github.com/movie-web/providers/blob/dev/src/fetchers/standardFetch.ts). 61 | 62 | Here is a basic template on how to make your own custom fetcher: 63 | 64 | ```ts 65 | const myFetcher: Fetcher = (url, ops) => { 66 | // Do some fetching 67 | return { 68 | body: {}, 69 | finalUrl: '', 70 | headers: new Headers(), // should only contain headers from ops.readHeaders 71 | statusCode: 200, 72 | }; 73 | } 74 | ``` 75 | -------------------------------------------------------------------------------- /.docs/content/2.essentials/3.customize-providers.md: -------------------------------------------------------------------------------- 1 | # Customize providers 2 | 3 | You make the provider controls in two ways. Either with `makeProviders()` (the simpler option) or with `buildProviders()` (more elaborate and extensive option). 4 | 5 | ## `makeProviders()` (simple) 6 | 7 | To know what to set the configuration to, you can read [How to use on X](./0.usage-on-x.md) for a detailed guide on how to configure your controls. 8 | 9 | ```ts 10 | const providers = makeProviders({ 11 | // fetcher, every web request gets called through here 12 | fetcher: makeStandardFetcher(fetch), 13 | 14 | // proxied fetcher, if the scraper needs to access a CORS proxy. this fetcher will be called instead 15 | // of the normal fetcher. Defaults to the normal fetcher. 16 | proxiedFetcher: undefined; 17 | 18 | // target of where the streams will be used 19 | target: targets.NATIVE; 20 | 21 | // Set this to true, if the requests will have the same IP as 22 | // the device that the stream will be played on. 23 | consistentIpForRequests: false; 24 | }) 25 | 26 | ``` 27 | 28 | ## `buildProviders()` (advanced) 29 | 30 | To know what to set the configuration to, you can read [How to use on X](./0.usage-on-x.md) for a detailed guide on how to configure your controls. 31 | 32 | ### Standard setup 33 | 34 | ```ts 35 | const providers = buildProviders() 36 | .setTarget(targets.NATIVE) // target of where the streams will be used 37 | .setFetcher(makeStandardFetcher(fetch)) // fetcher, every web request gets called through here 38 | .addBuiltinProviders() // add all builtin providers, if this is not called, no providers will be added to the controls 39 | .build(); 40 | ``` 41 | 42 | ### Adding only select few providers 43 | 44 | Not all providers are great quality, so you can make an instance of the controls with only the providers you want. 45 | 46 | ```ts 47 | const providers = buildProviders() 48 | .setTarget(targets.NATIVE) // target of where the streams will be used 49 | .setFetcher(makeStandardFetcher(fetch)) // fetcher, every web request gets called through here 50 | .addSource('showbox') // only add showbox source 51 | .addEmbed('febbox-hls') // add febbox-hls embed, which is returned by showbox 52 | .build(); 53 | ``` 54 | 55 | 56 | ### Adding your own scrapers to the providers 57 | 58 | If you have your own scraper and still want to use the nice utilities of the provider library or just want to add on to the built-in providers, you can add your own custom source. 59 | 60 | ```ts 61 | const providers = buildProviders() 62 | .setTarget(targets.NATIVE) // target of where the streams will be used 63 | .setFetcher(makeStandardFetcher(fetch)) // fetcher, every web request gets called through here 64 | .addSource({ // add your own source 65 | id: 'my-scraper', 66 | name: 'My scraper', 67 | rank: 800, 68 | flags: [], 69 | scrapeMovie(ctx) { 70 | throw new Error('Not implemented'); 71 | } 72 | }) 73 | .build(); 74 | ``` 75 | -------------------------------------------------------------------------------- /.docs/content/2.essentials/_dir.yml: -------------------------------------------------------------------------------- 1 | icon: ph:info-fill 2 | navigation.redirect: /essentials/usage 3 | navigation.title: "Get started" 4 | -------------------------------------------------------------------------------- /.docs/content/3.in-depth/0.sources-and-embeds.md: -------------------------------------------------------------------------------- 1 | # Sources vs embeds 2 | 3 | ::alert{type="warning"} 4 | This page isn't quite done yet, stay tuned! 5 | :: 6 | 7 | 12 | -------------------------------------------------------------------------------- /.docs/content/3.in-depth/1.new-providers.md: -------------------------------------------------------------------------------- 1 | # New providers 2 | 3 | ::alert{type="warning"} 4 | This page isn't quite done yet, stay tuned! 5 | :: 6 | 7 | 13 | -------------------------------------------------------------------------------- /.docs/content/3.in-depth/2.flags.md: -------------------------------------------------------------------------------- 1 | # Flags 2 | 3 | Flags is the primary way the library separates entities between different environments. 4 | For example, some sources only give back content that has the CORS headers set to allow anyone, so that source gets the flag `CORS_ALLOWED`. Now if you set your target to `BROWSER`, sources without that flag won't even get listed. 5 | 6 | This concept is applied in multiple away across the library. 7 | 8 | ## Flag options 9 | - `CORS_ALLOWED`: Headers from the output streams are set to allow any origin. 10 | - `IP_LOCKED`: The streams are locked by IP: requester and watcher must be the same. 11 | -------------------------------------------------------------------------------- /.docs/content/3.in-depth/_dir.yml: -------------------------------------------------------------------------------- 1 | icon: ph:atom-fill 2 | navigation.redirect: /in-depth/sources-and-embeds 3 | navigation.title: "In-depth" 4 | -------------------------------------------------------------------------------- /.docs/content/4.extra-topics/0.development.md: -------------------------------------------------------------------------------- 1 | # Development / contributing 2 | 3 | ::alert{type="warning"} 4 | This page isn't quite done yet, stay tuned! 5 | :: 6 | 7 | 14 | 15 | ## Testing using the CLI 16 | 17 | Testing can be quite difficult for this library, unit tests can't really be made because of the unreliable nature of scrapers. 18 | But manually testing by writing an entry-point is also really annoying. 19 | 20 | Our solution is to make a CLI that you can use to run the scrapers. For everything else there are unit tests. 21 | 22 | ### Setup 23 | Make a `.env` file in the root of the repository and add a TMDB API key: `MOVIE_WEB_TMDB_API_KEY=KEY_HERE`. 24 | Then make sure you've run `npm i` to get all the dependencies. 25 | 26 | ### Mode 1 - interactive 27 | 28 | To run the CLI without needing to learn all the arguments, simply run the following command and go with the flow. 29 | 30 | ```sh 31 | npm run cli 32 | ``` 33 | 34 | ### Mode 2 - arguments 35 | 36 | For repeatability, it can be useful to specify the arguments one by one. 37 | To see all the arguments, you can run the help command: 38 | ```sh 39 | npm run cli -- -h 40 | ``` 41 | 42 | Then just run it with your arguments, for example: 43 | ```sh 44 | npm run cli -- -sid showbox -tid 556574 45 | ``` 46 | 47 | ### Examples 48 | 49 | ```sh 50 | # Spirited away - showbox 51 | npm run cli -- -sid showbox -tid 129 52 | 53 | # Hamilton - flixhq 54 | npm run cli -- -sid flixhq -tid 556574 55 | 56 | # Arcane S1E1 - showbox 57 | npm run cli -- -sid zoechip -tid 94605 -s 1 -e 1 58 | 59 | # febbox mp4 - get streams from an embed (gotten from a source output) 60 | npm run cli -- -sid febbox-mp4 -u URL_HERE 61 | ``` 62 | 63 | ### Fetcher options 64 | 65 | The CLI comes with a few built-in fetchers: 66 | - `node-fetch`: Fetch using the "node-fetch" library. 67 | - `native`: Use the new fetch built into Node.JS (undici). 68 | - `browser`: Start up headless chrome, and run the library in that context using a proxied fetcher. 69 | 70 | ::alert{type="warning"} 71 | The browser fetcher will require you to run `npm run build` before running the CLI. Otherwise you will get outdated results. 72 | :: 73 | -------------------------------------------------------------------------------- /.docs/content/4.extra-topics/_dir.yml: -------------------------------------------------------------------------------- 1 | icon: ph:aperture-fill 2 | navigation.redirect: /extra-topics/development 3 | navigation.title: "Extra topics" 4 | -------------------------------------------------------------------------------- /.docs/content/5.api-reference/0.makeProviders.md: -------------------------------------------------------------------------------- 1 | # `makeProviders` 2 | 3 | Make an instance of provider controls with configuration. 4 | This is the main entry-point of the library. It is recommended to make one instance globally and reuse it throughout your application. 5 | 6 | ## Example 7 | 8 | ```ts 9 | import { targets, makeProviders, makeDefaultFetcher } from '@movie-web/providers'; 10 | 11 | const providers = makeProviders({ 12 | fetcher: makeDefaultFetcher(fetch), 13 | target: targets.NATIVE, // target native app streams 14 | }); 15 | ``` 16 | 17 | ## Type 18 | 19 | ```ts 20 | function makeProviders(ops: ProviderBuilderOptions): ProviderControls; 21 | 22 | interface ProviderBuilderOptions { 23 | // instance of a fetcher, all webrequests are made with the fetcher. 24 | fetcher: Fetcher; 25 | 26 | // instance of a fetcher, in case the request has CORS restrictions. 27 | // this fetcher will be called instead of normal fetcher. 28 | // if your environment doesn't have CORS restrictions (like Node.JS), there is no need to set this. 29 | proxiedFetcher?: Fetcher; 30 | 31 | // target to get streams for 32 | target: Targets; 33 | } 34 | ``` 35 | -------------------------------------------------------------------------------- /.docs/content/5.api-reference/1.ProviderControlsRunAll.md: -------------------------------------------------------------------------------- 1 | # `ProviderControls.runAll` 2 | 3 | Run all providers one by one in order of their built-in ranking. 4 | You can attach events if you need to know what is going on while it is processing. 5 | 6 | ## Example 7 | 8 | ```ts 9 | // media from TMDB 10 | const media = { 11 | type: 'movie', 12 | title: 'Hamilton', 13 | releaseYear: 2020, 14 | tmdbId: '556574' 15 | } 16 | 17 | // scrape a stream 18 | const stream = await providers.runAll({ 19 | media: media, 20 | }) 21 | 22 | // scrape a stream, but prioritize flixhq above all 23 | // (other scrapers are still run if flixhq fails, it just has priority) 24 | const flixhqStream = await providers.runAll({ 25 | media: media, 26 | sourceOrder: ['flixhq'] 27 | }) 28 | ``` 29 | 30 | ## Type 31 | 32 | ```ts 33 | function runAll(runnerOps: RunnerOptions): Promise; 34 | 35 | interface RunnerOptions { 36 | // overwrite the order of sources to run. List of IDs 37 | // any omitted IDs are added to the end in order of rank (highest first) 38 | sourceOrder?: string[]; 39 | 40 | // overwrite the order of embeds to run. List of IDs 41 | // any omitted IDs are added to the end in order of rank (highest first) 42 | embedOrder?: string[]; 43 | 44 | // object of event functions 45 | events?: FullScraperEvents; 46 | 47 | // the media you want to see sources from 48 | media: ScrapeMedia; 49 | } 50 | 51 | type RunOutput = { 52 | // source scraper ID 53 | sourceId: string; 54 | 55 | // if from an embed, this is the embed scraper ID 56 | embedId?: string; 57 | 58 | // the emitted stream 59 | stream: Stream; 60 | }; 61 | ``` 62 | -------------------------------------------------------------------------------- /.docs/content/5.api-reference/2.ProviderControlsrunSourceScraper.md: -------------------------------------------------------------------------------- 1 | # `ProviderControls.runSourceScraper` 2 | 3 | Run a specific source scraper and get its emitted streams. 4 | 5 | ## Example 6 | 7 | ```ts 8 | import { SourcererOutput, NotFoundError } from '@movie-web/providers'; 9 | 10 | // media from TMDB 11 | const media = { 12 | type: 'movie', 13 | title: 'Hamilton', 14 | releaseYear: 2020, 15 | tmdbId: '556574' 16 | } 17 | 18 | // scrape a stream from flixhq 19 | let output: SourcererOutput; 20 | try { 21 | output = await providers.runSourceScraper({ 22 | id: 'flixhq', 23 | media: media, 24 | }) 25 | } catch (err) { 26 | if (err instanceof NotFoundError) { 27 | console.log('source does not have this media'); 28 | } else { 29 | console.log('failed to scrape') 30 | } 31 | return; 32 | } 33 | 34 | if (!output.stream && output.embeds.length === 0) { 35 | console.log('no streams found'); 36 | } 37 | ``` 38 | 39 | ## Type 40 | 41 | ```ts 42 | function runSourceScraper(runnerOps: SourceRunnerOptions): Promise; 43 | 44 | interface SourceRunnerOptions { 45 | // object of event functions 46 | events?: IndividualScraperEvents; 47 | 48 | // the media you want to see sources from 49 | media: ScrapeMedia; 50 | 51 | // ID of the source scraper you want to scrape from 52 | id: string; 53 | } 54 | 55 | type SourcererOutput = { 56 | // list of embeds that the source scraper found. 57 | // embed ID is a reference to an embed scraper 58 | embeds: { 59 | embedId: string; 60 | url: string; 61 | }[]; 62 | 63 | // the stream that the scraper found 64 | stream?: Stream; 65 | }; 66 | ``` 67 | -------------------------------------------------------------------------------- /.docs/content/5.api-reference/3.ProviderControlsrunEmbedScraper.md: -------------------------------------------------------------------------------- 1 | # `ProviderControls.runEmbedScraper` 2 | 3 | Run a specific embed scraper and get its emitted streams. 4 | 5 | ## Example 6 | 7 | ```ts 8 | import { SourcererOutput } from '@movie-web/providers'; 9 | 10 | // scrape a stream from upcloud 11 | let output: EmbedOutput; 12 | try { 13 | output = await providers.runEmbedScraper({ 14 | id: 'upcloud', 15 | url: 'https://example.com/123', 16 | }) 17 | } catch (err) { 18 | console.log('failed to scrape') 19 | return; 20 | } 21 | 22 | // output.stream now has your stream 23 | ``` 24 | 25 | ## Type 26 | 27 | ```ts 28 | function runEmbedScraper(runnerOps: SourceRunnerOptions): Promise; 29 | 30 | interface EmbedRunnerOptions { 31 | // object of event functions 32 | events?: IndividualScraperEvents; 33 | 34 | // the embed URL 35 | url: string; 36 | 37 | // ID of the embed scraper you want to scrape from 38 | id: string; 39 | } 40 | 41 | type EmbedOutput = { 42 | stream: Stream; 43 | }; 44 | ``` 45 | -------------------------------------------------------------------------------- /.docs/content/5.api-reference/4.ProviderControlslistSources.md: -------------------------------------------------------------------------------- 1 | # `ProviderControls.listSources` 2 | 3 | List all source scrapers that are applicable for the target. 4 | They are sorted by rank; highest first 5 | 6 | ## Example 7 | 8 | ```ts 9 | const sourceScrapers = providers.listSources(); 10 | // Guaranteed to only return the type: 'source' 11 | ``` 12 | 13 | ## Type 14 | 15 | ```ts 16 | function listSources(): MetaOutput[]; 17 | 18 | type MetaOutput = { 19 | type: 'embed' | 'source'; 20 | id: string; 21 | rank: number; 22 | name: string; 23 | mediaTypes?: Array; 24 | }; 25 | ``` 26 | -------------------------------------------------------------------------------- /.docs/content/5.api-reference/5.ProviderControlslistEmbeds.md: -------------------------------------------------------------------------------- 1 | # `ProviderControls.listEmbeds` 2 | 3 | List all embed scrapers that are applicable for the target. 4 | They are sorted by rank; highest first 5 | 6 | ## Example 7 | 8 | ```ts 9 | const embedScrapers = providers.listEmbeds(); 10 | // Guaranteed to only return the type: 'embed' 11 | ``` 12 | 13 | ## Type 14 | 15 | ```ts 16 | function listEmbeds(): MetaOutput[]; 17 | 18 | type MetaOutput = { 19 | type: 'embed' | 'source'; 20 | id: string; 21 | rank: number; 22 | name: string; 23 | mediaTypes?: Array; 24 | }; 25 | ``` 26 | -------------------------------------------------------------------------------- /.docs/content/5.api-reference/6.ProviderControlsgetMetadata.md: -------------------------------------------------------------------------------- 1 | # `ProviderControls.getMetadata` 2 | 3 | Get meta data for a scraper, can be either source or embed scraper. 4 | Returns `null` if the `id` is not recognized. 5 | 6 | ## Example 7 | 8 | ```ts 9 | const flixhqSource = providers.getMetadata('flixhq'); 10 | ``` 11 | 12 | ## Type 13 | 14 | ```ts 15 | function getMetadata(id: string): MetaOutput | null; 16 | 17 | type MetaOutput = { 18 | type: 'embed' | 'source'; 19 | id: string; 20 | rank: number; 21 | name: string; 22 | mediaTypes?: Array; 23 | }; 24 | ``` 25 | -------------------------------------------------------------------------------- /.docs/content/5.api-reference/7.makeStandardFetcher.md: -------------------------------------------------------------------------------- 1 | # `makeStandardFetcher` 2 | 3 | Make a fetcher from a `fetch()` API. It is used for making an instance of provider controls. 4 | 5 | ## Example 6 | 7 | ```ts 8 | import { targets, makeProviders, makeDefaultFetcher } from '@movie-web/providers'; 9 | 10 | const providers = makeProviders({ 11 | fetcher: makeStandardFetcher(fetch), 12 | target: targets.ANY, 13 | }); 14 | ``` 15 | 16 | ## Type 17 | 18 | ```ts 19 | function makeStandardFetcher(fetchApi: typeof fetch): Fetcher; 20 | ``` 21 | -------------------------------------------------------------------------------- /.docs/content/5.api-reference/8.makeSimpleProxyFetcher.md: -------------------------------------------------------------------------------- 1 | # `makeSimpleProxyFetcher` 2 | 3 | Make a fetcher to use with [movie-web/simple-proxy](https://github.com/movie-web/simple-proxy). This is for making a proxiedFetcher, so you can run this library in the browser. 4 | 5 | ## Example 6 | 7 | ```ts 8 | import { targets, makeProviders, makeDefaultFetcher, makeSimpleProxyFetcher } from '@movie-web/providers'; 9 | 10 | const proxyUrl = 'https://your.proxy.workers.dev/' 11 | 12 | const providers = makeProviders({ 13 | fetcher: makeDefaultFetcher(fetch), 14 | proxiedFetcher: makeSimpleProxyFetcher(proxyUrl, fetch), 15 | target: targets.BROWSER, 16 | }); 17 | ``` 18 | 19 | ## Type 20 | 21 | ```ts 22 | function makeSimpleProxyFetcher(proxyUrl: string, fetchApi: typeof fetch): Fetcher; 23 | ``` 24 | -------------------------------------------------------------------------------- /.docs/content/5.api-reference/_dir.yml: -------------------------------------------------------------------------------- 1 | icon: ph:code-simple-fill 2 | navigation.redirect: /api/makeproviders 3 | navigation.title: "Api reference" 4 | -------------------------------------------------------------------------------- /.docs/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | // https://github.com/nuxt-themes/docus 3 | extends: '@nuxt-themes/docus', 4 | 5 | css: [ 6 | '@/assets/css/main.css', 7 | ], 8 | 9 | build: { 10 | transpile: [ 11 | "chalk" 12 | ] 13 | }, 14 | 15 | modules: [ 16 | // https://github.com/nuxt-modules/plausible 17 | '@nuxtjs/plausible', 18 | // https://github.com/nuxt/devtools 19 | '@nuxt/devtools' 20 | ] 21 | }) 22 | -------------------------------------------------------------------------------- /.docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "providers-docs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "nuxi dev", 7 | "build": "nuxi build", 8 | "generate": "nuxi generate", 9 | "preview": "nuxi preview", 10 | "lint": "eslint .", 11 | "preinstall": "npx -y only-allow pnpm" 12 | }, 13 | "devDependencies": { 14 | "@nuxt-themes/docus": "^1.13.1", 15 | "@nuxt/devtools": "^1.0.1", 16 | "@nuxt/eslint-config": "^0.1.1", 17 | "@nuxtjs/plausible": "^0.2.1", 18 | "@types/node": "^20.4.0", 19 | "eslint": "^8.44.0", 20 | "nuxt": "^3.6.2" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zisra/providers/9119c0675ed92ecf750577252cca702ee6bca517/.docs/public/favicon.ico -------------------------------------------------------------------------------- /.docs/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@nuxtjs" 4 | ], 5 | "lockFileMaintenance": { 6 | "enabled": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.docs/tokens.config.ts: -------------------------------------------------------------------------------- 1 | import { defineTheme } from 'pinceau' 2 | 3 | export default defineTheme({ 4 | color: { 5 | primary: { 6 | 50: "#F5E5FF", 7 | 100: "#E7CCFF", 8 | 200: "#D4A9FF", 9 | 300: "#BE85FF", 10 | 400: "#A861FF", 11 | 500: "#8E3DFF", 12 | 600: "#7F36D4", 13 | 700: "#662CA6", 14 | 800: "#552578", 15 | 900: "#441E49" 16 | } 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /.docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | insert_final_newline = true 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "extends": ["airbnb-base", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"], 6 | "ignorePatterns": ["lib/*", "tests/*", "/*.js", "/*.ts", "/src/__test__/*", "/**/*.test.ts", "test/*"], 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "project": "./tsconfig.json", 10 | "tsconfigRootDir": "./" 11 | }, 12 | "settings": { 13 | "import/resolver": { 14 | "typescript": { 15 | "project": "./tsconfig.json" 16 | } 17 | } 18 | }, 19 | "plugins": ["@typescript-eslint", "import", "prettier"], 20 | "rules": { 21 | "no-plusplus": "off", 22 | "class-methods-use-this": "off", 23 | "no-bitwise": "off", 24 | "no-underscore-dangle": "off", 25 | "@typescript-eslint/no-explicit-any": "off", 26 | "no-console": ["error", { "allow": ["warn", "error"] }], 27 | "@typescript-eslint/no-this-alias": "off", 28 | "import/prefer-default-export": "off", 29 | "@typescript-eslint/no-empty-function": "off", 30 | "no-shadow": "off", 31 | "@typescript-eslint/no-shadow": ["error"], 32 | "no-restricted-syntax": "off", 33 | "import/no-unresolved": ["error", { "ignore": ["^virtual:"] }], 34 | "consistent-return": "off", 35 | "no-continue": "off", 36 | "no-eval": "off", 37 | "no-await-in-loop": "off", 38 | "no-nested-ternary": "off", 39 | "no-param-reassign": ["error", { "props": false }], 40 | "prefer-destructuring": "off", 41 | "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], 42 | "import/extensions": [ 43 | "error", 44 | "ignorePackages", 45 | { 46 | "ts": "never", 47 | "tsx": "never" 48 | } 49 | ], 50 | "import/order": [ 51 | "error", 52 | { 53 | "groups": ["builtin", "external", "internal", ["sibling", "parent"], "index", "unknown"], 54 | "newlines-between": "always", 55 | "alphabetize": { 56 | "order": "asc", 57 | "caseInsensitive": true 58 | } 59 | } 60 | ], 61 | "sort-imports": [ 62 | "error", 63 | { 64 | "ignoreCase": false, 65 | "ignoreDeclarationSort": true, 66 | "ignoreMemberSort": false, 67 | "memberSyntaxSortOrder": ["none", "all", "multiple", "single"], 68 | "allowSeparatedGroups": true 69 | } 70 | ] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @movie-web/project-leads 2 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Please visit the [main document at primary repository](https://github.com/movie-web/movie-web/blob/dev/.github/CODE_OF_CONDUCT.md). 2 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Please visit the [main document at primary repository](https://github.com/movie-web/movie-web/blob/dev/.github/CONTRIBUTING.md). 2 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The movie-web maintainers only support the latest version of movie-web published at https://movie-web.app. 6 | This published version is equivalent to the master branch. 7 | 8 | Support is not provided for any forks or mirrors of movie-web. 9 | 10 | ## Reporting a Vulnerability 11 | 12 | There are two ways you can contact the movie-web maintainers to report a vulnerability: 13 | - Email [security@movie-web.app](mailto:security@movie-web.app) 14 | - Report the vulnerability in the [movie-web Discord server](https://movie-web.github.io/links/discord) 15 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | This pull request resolves #XXX 2 | 3 | - [ ] I have read and agreed to the [code of conduct](https://github.com/movie-web/movie-web/blob/dev/.github/CODE_OF_CONDUCT.md). 4 | - [ ] I have read and complied with the [contributing guidelines](https://github.com/movie-web/movie-web/blob/dev/.github/CONTRIBUTING.md). 5 | - [ ] What I'm implementing was assigned to me and is an [approved issue](https://github.com/movie-web/movie-web/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved). For reference, please take a look at our [GitHub projects](https://github.com/movie-web/movie-web/projects). 6 | - [ ] I have tested all of my changes. 7 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | name: Build 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v3 16 | 17 | - uses: pnpm/action-setup@v3 18 | with: 19 | version: 8 20 | 21 | - name: Install Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | cache: "pnpm" 26 | 27 | - name: Install packages 28 | working-directory: ./.docs 29 | run: pnpm install 30 | 31 | - name: Build project 32 | working-directory: ./.docs 33 | run: pnpm run generate 34 | env: 35 | NUXT_APP_BASE_URL: /providers/ 36 | 37 | - name: Upload production-ready build files 38 | uses: actions/upload-pages-artifact@v1 39 | with: 40 | path: ./.docs/.output/public 41 | 42 | deploy: 43 | name: Deploy 44 | needs: build 45 | permissions: 46 | pages: write 47 | id-token: write 48 | environment: 49 | name: docs 50 | url: ${{ steps.deployment.outputs.page_url }} 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: Deploy to GitHub Pages 54 | id: deployment 55 | uses: actions/deploy-pages@v2 56 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | environment: 12 | name: npm 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v3 16 | 17 | - uses: pnpm/action-setup@v3 18 | with: 19 | version: 8 20 | 21 | - name: Install Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | cache: "pnpm" 26 | registry-url: "https://registry.npmjs.org" 27 | 28 | - name: Install packages 29 | run: pnpm install --frozen-lockfile 30 | 31 | - name: Publish 32 | run: pnpm publish --access public 33 | env: 34 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | - dev 9 | 10 | jobs: 11 | testing: 12 | name: Testing 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v3 18 | 19 | - uses: pnpm/action-setup@v3 20 | with: 21 | version: 8 22 | 23 | - name: Install Node.js 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | cache: "pnpm" 28 | 29 | - name: Install packages 30 | run: pnpm install --frozen-lockfile 31 | 32 | - name: Install puppeteer 33 | run: node ./node_modules/puppeteer/install.mjs 34 | 35 | - name: Run tests 36 | run: pnpm run test 37 | 38 | - name: Run integration tests 39 | run: pnpm run build && pnpm run test:integration 40 | 41 | - name: Run linting 42 | run: pnpm run lint 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /lib 3 | coverage 4 | .env 5 | .eslintcache 6 | 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | always-auth=true -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "all", 4 | "singleQuote": true 5 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "editorconfig.editorconfig"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 4 | "eslint.format.enable": true 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 movie-web 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @movie-web/providers 2 | 3 | package that holds all providers of movie-web. 4 | Feel free to use for your own projects. 5 | 6 | features: 7 | - scrape popular streaming websites 8 | - works in both browser and server-side 9 | 10 | Visit documentation here: https://movie-web.github.io/providers/ 11 | 12 | ## How to run locally or test my changes 13 | 14 | These topics are also covered in the documentation, [read about it here](https://movie-web.github.io/providers/extra-topics/development). 15 | -------------------------------------------------------------------------------- /examples/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zisra/providers/9119c0675ed92ecf750577252cca702ee6bca517/examples/.gitkeep -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@movie-web/providers", 3 | "version": "2.2.7", 4 | "description": "Package that contains all the providers of movie-web", 5 | "type": "module", 6 | "main": "./lib/index.umd.js", 7 | "types": "./lib/index.d.ts", 8 | "files": [ 9 | "./lib" 10 | ], 11 | "exports": { 12 | ".": { 13 | "import": { 14 | "types": "./lib/index.d.ts", 15 | "default": "./lib/index.js" 16 | }, 17 | "require": { 18 | "types": "./lib/index.d.ts", 19 | "default": "./lib/index.umd.cjs" 20 | } 21 | } 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/movie-web/providers.git" 26 | }, 27 | "keywords": [ 28 | "movie-web", 29 | "providers" 30 | ], 31 | "author": "movie-web", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/movie-web/providers/issues" 35 | }, 36 | "homepage": "https://movie-web.github.io/providers/", 37 | "scripts": { 38 | "build": "vite build && tsc --noEmit", 39 | "cli": "vite-node ./src/dev-cli/index.ts", 40 | "test": "vitest run", 41 | "test:watch": "vitest", 42 | "test:providers": "cross-env MW_TEST_PROVIDERS=true vitest run --reporter verbose", 43 | "test:integration": "node ./tests/cjs && node ./tests/esm && node ./tests/browser", 44 | "test:coverage": "vitest run --coverage", 45 | "lint": "eslint --ext .ts,.js src/", 46 | "lint:fix": "eslint --fix --ext .ts,.js src/", 47 | "lint:report": "eslint --ext .ts,.js --output-file eslint_report.json --format json src/", 48 | "preinstall": "npx -y only-allow pnpm", 49 | "prepare": "pnpm run build", 50 | "prepublishOnly": "pnpm test && pnpm run lint" 51 | }, 52 | "devDependencies": { 53 | "@nabla/vite-plugin-eslint": "^2.0.2", 54 | "@types/cookie": "^0.6.0", 55 | "@types/crypto-js": "^4.2.2", 56 | "@types/node-fetch": "^2.6.11", 57 | "@types/randombytes": "^2.0.3", 58 | "@types/set-cookie-parser": "^2.4.7", 59 | "@types/spinnies": "^0.5.3", 60 | "@typescript-eslint/eslint-plugin": "^7.4.0", 61 | "@typescript-eslint/parser": "^7.4.0", 62 | "@vitest/coverage-v8": "^1.4.0", 63 | "commander": "^12.0.0", 64 | "cross-env": "^7.0.3", 65 | "dotenv": "^16.4.5", 66 | "enquirer": "^2.4.1", 67 | "eslint": "^8.57.0", 68 | "eslint-config-airbnb-base": "^15.0.0", 69 | "eslint-config-prettier": "^9.1.0", 70 | "eslint-import-resolver-typescript": "^3.6.1", 71 | "eslint-plugin-import": "^2.29.1", 72 | "eslint-plugin-prettier": "^5.1.3", 73 | "node-fetch": "^3.3.2", 74 | "prettier": "^3.2.5", 75 | "puppeteer": "^22.6.1", 76 | "spinnies": "^0.5.1", 77 | "tsc-alias": "^1.8.8", 78 | "tsconfig-paths": "^4.2.0", 79 | "typescript": "^5.4.3", 80 | "vite": "^5.2.7", 81 | "vite-node": "^1.4.0", 82 | "vite-plugin-dts": "^3.8.1", 83 | "vitest": "^1.4.0" 84 | }, 85 | "dependencies": { 86 | "cheerio": "^1.0.0-rc.12", 87 | "cookie": "^0.6.0", 88 | "crypto-js": "^4.2.0", 89 | "form-data": "^4.0.0", 90 | "iso-639-1": "^3.1.2", 91 | "nanoid": "^3.3.7", 92 | "node-fetch": "^3.3.2", 93 | "set-cookie-parser": "^2.6.0", 94 | "unpacker": "^1.0.1" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/__test__/providers/embeds.test.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import { febboxMp4Scraper } from '@/providers/embeds/febbox/mp4'; 3 | import { testEmbed } from './embedUtils'; 4 | import { showboxScraper } from '@/providers/sources/showbox'; 5 | import { testMedia } from './testMedia'; 6 | import { flixhqScraper } from '@/providers/sources/flixhq'; 7 | import { upcloudScraper } from '@/providers/embeds/upcloud'; 8 | import { goMoviesScraper } from '@/providers/sources/gomovies'; 9 | import { smashyStreamScraper } from '@/providers/sources/smashystream'; 10 | import { smashyStreamDScraper } from '@/providers/embeds/smashystream/dued'; 11 | import { vidsrcembedScraper } from '@/providers/embeds/vidsrc'; 12 | import { vidsrcScraper } from '@/providers/sources/vidsrc'; 13 | import { vidSrcToScraper } from '@/providers/sources/vidsrcto'; 14 | import { vidplayScraper } from '@/providers/embeds/vidplay'; 15 | import { fileMoonScraper } from '@/providers/embeds/filemoon'; 16 | import { zoechipScraper } from '@/providers/sources/zoechip'; 17 | import { mixdropScraper } from '@/providers/embeds/mixdrop'; 18 | 19 | dotenv.config(); 20 | 21 | testEmbed({ 22 | embed: febboxMp4Scraper, 23 | source: showboxScraper, 24 | testSuite: [testMedia.arcane, testMedia.hamilton], 25 | types: ['standard', 'proxied'], 26 | expect: { 27 | embeds: 1, 28 | streams: 1, 29 | }, 30 | }); 31 | 32 | testEmbed({ 33 | embed: upcloudScraper, 34 | source: flixhqScraper, 35 | testSuite: [testMedia.arcane, testMedia.hamilton], 36 | types: ['standard', 'proxied'], 37 | expect: { 38 | embeds: 1, 39 | streams: 1, 40 | }, 41 | }); 42 | 43 | testEmbed({ 44 | embed: upcloudScraper, 45 | source: goMoviesScraper, 46 | testSuite: [testMedia.arcane, testMedia.hamilton], 47 | types: ['standard', 'proxied'], 48 | expect: { 49 | embeds: 1, 50 | streams: 1, 51 | }, 52 | }); 53 | 54 | testEmbed({ 55 | embed: smashyStreamDScraper, 56 | source: smashyStreamScraper, 57 | testSuite: [testMedia.arcane, testMedia.hamilton], 58 | types: ['standard', 'proxied'], 59 | expect: { 60 | embeds: 1, 61 | streams: 1, 62 | }, 63 | }); 64 | 65 | testEmbed({ 66 | embed: vidsrcembedScraper, 67 | source: vidsrcScraper, 68 | testSuite: [testMedia.arcane, testMedia.hamilton], 69 | types: ['standard', 'proxied'], 70 | expect: { 71 | embeds: 1, 72 | streams: 1, 73 | }, 74 | }); 75 | 76 | testEmbed({ 77 | embed: vidplayScraper, 78 | source: vidSrcToScraper, 79 | testSuite: [testMedia.arcane, testMedia.hamilton], 80 | types: ['standard', 'proxied'], 81 | expect: { 82 | embeds: 1, 83 | streams: 1, 84 | }, 85 | }); 86 | 87 | testEmbed({ 88 | embed: fileMoonScraper, 89 | source: vidSrcToScraper, 90 | testSuite: [testMedia.arcane, testMedia.hamilton], 91 | types: ['standard', 'proxied'], 92 | expect: { 93 | embeds: 1, 94 | streams: 1, 95 | }, 96 | }); 97 | 98 | testEmbed({ 99 | embed: upcloudScraper, 100 | source: zoechipScraper, 101 | testSuite: [testMedia.arcane, testMedia.hamilton], 102 | types: ['standard', 'proxied'], 103 | expect: { 104 | embeds: 2, 105 | streams: 1, 106 | }, 107 | }); 108 | 109 | testEmbed({ 110 | embed: mixdropScraper, 111 | source: zoechipScraper, 112 | testSuite: [testMedia.arcane, testMedia.hamilton], 113 | types: ['standard', 'proxied'], 114 | expect: { 115 | embeds: 2, 116 | streams: 1, 117 | }, 118 | }); 119 | -------------------------------------------------------------------------------- /src/__test__/providers/providers.test.ts: -------------------------------------------------------------------------------- 1 | import { testSource } from './providerUtils'; 2 | import { lookmovieScraper } from '@/providers/sources/lookmovie'; 3 | import { testMedia } from './testMedia'; 4 | import { showboxScraper } from '@/providers/sources/showbox'; 5 | import dotenv from 'dotenv'; 6 | import { flixhqScraper } from '@/providers/sources/flixhq'; 7 | import { goMoviesScraper } from '@/providers/sources/gomovies'; 8 | import { smashyStreamScraper } from '@/providers/sources/smashystream'; 9 | import { vidsrcScraper } from '@/providers/sources/vidsrc'; 10 | import { vidSrcToScraper } from '@/providers/sources/vidsrcto'; 11 | import { zoechipScraper } from '@/providers/sources/zoechip'; 12 | import { remotestreamScraper } from '@/providers/sources/remotestream'; 13 | 14 | dotenv.config(); 15 | 16 | testSource({ 17 | source: lookmovieScraper, 18 | testSuite: [testMedia.arcane, testMedia.hamilton], 19 | types: ['ip:standard'], 20 | expect: { 21 | streams: 1, 22 | }, 23 | }); 24 | 25 | testSource({ 26 | source: showboxScraper, 27 | testSuite: [testMedia.arcane, testMedia.hamilton], 28 | types: ['standard', 'proxied'], 29 | expect: { 30 | embeds: 1, 31 | }, 32 | }); 33 | 34 | testSource({ 35 | source: flixhqScraper, 36 | testSuite: [testMedia.arcane, testMedia.hamilton], 37 | types: ['standard', 'proxied'], 38 | expect: { 39 | embeds: 1, 40 | }, 41 | }); 42 | 43 | testSource({ 44 | source: goMoviesScraper, 45 | testSuite: [testMedia.arcane, testMedia.hamilton], 46 | types: ['standard', 'proxied'], 47 | expect: { 48 | embeds: 1, 49 | }, 50 | }); 51 | 52 | testSource({ 53 | source: smashyStreamScraper, 54 | testSuite: [testMedia.arcane, testMedia.hamilton], 55 | types: ['standard', 'proxied'], 56 | expect: { 57 | embeds: 1, 58 | }, 59 | }); 60 | 61 | testSource({ 62 | source: vidsrcScraper, 63 | testSuite: [testMedia.arcane, testMedia.hamilton], 64 | types: ['standard', 'proxied'], 65 | expect: { 66 | embeds: 1, 67 | }, 68 | }); 69 | 70 | testSource({ 71 | source: vidSrcToScraper, 72 | testSuite: [testMedia.arcane, testMedia.hamilton], 73 | types: ['standard', 'proxied'], 74 | expect: { 75 | embeds: 2, 76 | }, 77 | }); 78 | 79 | testSource({ 80 | source: zoechipScraper, 81 | testSuite: [testMedia.arcane, testMedia.hamilton], 82 | types: ['standard', 'proxied'], 83 | expect: { 84 | embeds: 3, 85 | }, 86 | }); 87 | 88 | testSource({ 89 | source: remotestreamScraper, 90 | testSuite: [testMedia.arcane, testMedia.hamilton], 91 | types: ['standard', 'proxied'], 92 | expect: { 93 | streams: 1, 94 | }, 95 | }); 96 | -------------------------------------------------------------------------------- /src/__test__/providers/testMedia.ts: -------------------------------------------------------------------------------- 1 | import { ScrapeMedia } from '@/entrypoint/utils/media'; 2 | 3 | function makeMedia(media: ScrapeMedia): ScrapeMedia { 4 | return media; 5 | } 6 | 7 | export const testMedia = { 8 | arcane: makeMedia({ 9 | type: 'show', 10 | title: 'Arcane', 11 | tmdbId: '94605', 12 | releaseYear: 2021, 13 | episode: { 14 | number: 1, 15 | tmdbId: '1953812', 16 | }, 17 | season: { 18 | number: 1, 19 | tmdbId: '134187', 20 | }, 21 | imdbId: 'tt11126994', 22 | }), 23 | hamilton: makeMedia({ 24 | type: 'movie', 25 | tmdbId: '556574', 26 | imdbId: 'tt8503618', 27 | releaseYear: 2020, 28 | title: 'Hamilton', 29 | }), 30 | }; 31 | -------------------------------------------------------------------------------- /src/__test__/standard/fetchers/body.test.ts: -------------------------------------------------------------------------------- 1 | import { serializeBody } from '@/fetchers/body'; 2 | import FormData from 'form-data'; 3 | import { describe, expect, it } from 'vitest'; 4 | 5 | describe('serializeBody()', () => { 6 | it('should work with standard text', () => { 7 | expect(serializeBody('hello world')).toEqual({ 8 | headers: {}, 9 | body: 'hello world', 10 | }); 11 | }); 12 | 13 | it('should work with objects', () => { 14 | expect(serializeBody({ hello: 'world', a: 42 })).toEqual({ 15 | headers: { 16 | 'Content-Type': 'application/json', 17 | }, 18 | body: JSON.stringify({ hello: 'world', a: 42 }), 19 | }); 20 | }); 21 | 22 | it('should work x-www-form-urlencoded', () => { 23 | const obj = new URLSearchParams(); 24 | obj.set('a', 'b'); 25 | expect(serializeBody(obj)).toEqual({ 26 | headers: {}, 27 | body: obj, 28 | }); 29 | }); 30 | 31 | it('should work multipart/form-data', () => { 32 | const obj = new FormData(); 33 | obj.append('a', 'b'); 34 | expect(serializeBody(obj)).toEqual({ 35 | headers: {}, 36 | body: obj, 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/__test__/standard/providerTests.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { vi } from 'vitest'; 3 | 4 | import { gatherAllEmbeds, gatherAllSources } from '@/providers/all'; 5 | import { makeEmbed, makeSourcerer } from '@/providers/base'; 6 | 7 | export function makeProviderMocks() { 8 | const embedsMock = vi.fn, ReturnType>(); 9 | const sourcesMock = vi.fn, ReturnType>(); 10 | return { 11 | gatherAllEmbeds: embedsMock, 12 | gatherAllSources: sourcesMock, 13 | }; 14 | } 15 | 16 | const sourceA = makeSourcerer({ 17 | id: 'a', 18 | name: 'A', 19 | rank: 1, 20 | disabled: false, 21 | flags: [], 22 | }); 23 | const sourceB = makeSourcerer({ 24 | id: 'b', 25 | name: 'B', 26 | rank: 2, 27 | disabled: false, 28 | flags: [], 29 | }); 30 | const sourceCDisabled = makeSourcerer({ 31 | id: 'c', 32 | name: 'C', 33 | rank: 3, 34 | disabled: true, 35 | flags: [], 36 | }); 37 | const sourceAHigherRank = makeSourcerer({ 38 | id: 'a', 39 | name: 'A', 40 | rank: 100, 41 | disabled: false, 42 | flags: [], 43 | }); 44 | const sourceGSameRankAsA = makeSourcerer({ 45 | id: 'g', 46 | name: 'G', 47 | rank: 1, 48 | disabled: false, 49 | flags: [], 50 | }); 51 | const fullSourceYMovie = makeSourcerer({ 52 | id: 'y', 53 | name: 'Y', 54 | rank: 105, 55 | scrapeMovie: vi.fn(), 56 | flags: [], 57 | }); 58 | const fullSourceYShow = makeSourcerer({ 59 | id: 'y', 60 | name: 'Y', 61 | rank: 105, 62 | scrapeShow: vi.fn(), 63 | flags: [], 64 | }); 65 | const fullSourceZBoth = makeSourcerer({ 66 | id: 'z', 67 | name: 'Z', 68 | rank: 106, 69 | scrapeMovie: vi.fn(), 70 | scrapeShow: vi.fn(), 71 | flags: [], 72 | }); 73 | 74 | const embedD = makeEmbed({ 75 | id: 'd', 76 | rank: 4, 77 | disabled: false, 78 | } as any); 79 | const embedA = makeEmbed({ 80 | id: 'a', 81 | rank: 5, 82 | disabled: false, 83 | } as any); 84 | const embedEDisabled = makeEmbed({ 85 | id: 'e', 86 | rank: 6, 87 | disabled: true, 88 | } as any); 89 | const embedDHigherRank = makeEmbed({ 90 | id: 'd', 91 | rank: 4000, 92 | disabled: false, 93 | } as any); 94 | const embedFSameRankAsA = makeEmbed({ 95 | id: 'f', 96 | rank: 5, 97 | disabled: false, 98 | } as any); 99 | const embedHSameRankAsSourceA = makeEmbed({ 100 | id: 'h', 101 | rank: 1, 102 | disabled: false, 103 | } as any); 104 | const fullEmbedX = makeEmbed({ 105 | id: 'x', 106 | name: 'X', 107 | rank: 104, 108 | } as any); 109 | const fullEmbedZ = makeEmbed({ 110 | id: 'z', 111 | name: 'Z', 112 | rank: 109, 113 | } as any); 114 | 115 | export const mockSources = { 116 | sourceA, 117 | sourceB, 118 | sourceCDisabled, 119 | sourceAHigherRank, 120 | sourceGSameRankAsA, 121 | fullSourceYMovie, 122 | fullSourceYShow, 123 | fullSourceZBoth, 124 | }; 125 | 126 | export const mockEmbeds = { 127 | embedA, 128 | embedD, 129 | embedDHigherRank, 130 | embedEDisabled, 131 | embedFSameRankAsA, 132 | embedHSameRankAsSourceA, 133 | fullEmbedX, 134 | fullEmbedZ, 135 | }; 136 | -------------------------------------------------------------------------------- /src/__test__/standard/runner/meta.test.ts: -------------------------------------------------------------------------------- 1 | import { mockEmbeds, mockSources } from '../providerTests.ts'; 2 | import { makeProviders } from '@/entrypoint/declare'; 3 | import { targets } from '@/entrypoint/utils/targets'; 4 | import { afterEach, describe, expect, it, vi } from 'vitest'; 5 | 6 | const mocks = await vi.hoisted(async () => (await import('../providerTests.ts')).makeProviderMocks()); 7 | vi.mock('@/providers/all', () => mocks); 8 | 9 | describe('ProviderControls.getMetadata()', () => { 10 | afterEach(() => { 11 | vi.clearAllMocks(); 12 | }); 13 | 14 | it('should return null if not found', () => { 15 | mocks.gatherAllSources.mockReturnValue([]); 16 | mocks.gatherAllEmbeds.mockReturnValue([]); 17 | const p = makeProviders({ 18 | fetcher: null as any, 19 | target: targets.NATIVE, 20 | }); 21 | expect(p.getMetadata(':)')).toEqual(null); 22 | }); 23 | 24 | it('should return correct source meta', () => { 25 | mocks.gatherAllSources.mockReturnValue([mockSources.fullSourceZBoth]); 26 | mocks.gatherAllEmbeds.mockReturnValue([]); 27 | const p = makeProviders({ 28 | fetcher: null as any, 29 | target: targets.NATIVE, 30 | }); 31 | expect(p.getMetadata(mockSources.fullSourceZBoth.id)).toEqual({ 32 | type: 'source', 33 | id: 'z', 34 | name: 'Z', 35 | rank: mockSources.fullSourceZBoth.rank, 36 | mediaTypes: ['movie', 'show'], 37 | }); 38 | }); 39 | 40 | it('should return correct embed meta', () => { 41 | mocks.gatherAllSources.mockReturnValue([]); 42 | mocks.gatherAllEmbeds.mockReturnValue([mockEmbeds.fullEmbedX]); 43 | const p = makeProviders({ 44 | fetcher: null as any, 45 | target: targets.NATIVE, 46 | }); 47 | expect(p.getMetadata(mockEmbeds.fullEmbedX.id)).toEqual({ 48 | type: 'embed', 49 | id: 'x', 50 | name: 'X', 51 | rank: mockEmbeds.fullEmbedX.rank, 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/__test__/standard/utils/features.test.ts: -------------------------------------------------------------------------------- 1 | import { FeatureMap, Flags, flags, flagsAllowedInFeatures } from '@/entrypoint/utils/targets'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | describe('flagsAllowedInFeatures()', () => { 5 | function checkFeatures(featureMap: FeatureMap, flags: Flags[], output: boolean) { 6 | expect(flagsAllowedInFeatures(featureMap, flags)).toEqual(output); 7 | } 8 | 9 | it('should check required correctly', () => { 10 | checkFeatures( 11 | { 12 | requires: [], 13 | disallowed: [], 14 | }, 15 | [], 16 | true, 17 | ); 18 | checkFeatures( 19 | { 20 | requires: [flags.CORS_ALLOWED], 21 | disallowed: [], 22 | }, 23 | [flags.CORS_ALLOWED], 24 | true, 25 | ); 26 | checkFeatures( 27 | { 28 | requires: [flags.CORS_ALLOWED], 29 | disallowed: [], 30 | }, 31 | [], 32 | false, 33 | ); 34 | checkFeatures( 35 | { 36 | requires: [flags.CORS_ALLOWED, flags.IP_LOCKED], 37 | disallowed: [], 38 | }, 39 | [flags.CORS_ALLOWED, flags.IP_LOCKED], 40 | true, 41 | ); 42 | checkFeatures( 43 | { 44 | requires: [flags.IP_LOCKED], 45 | disallowed: [], 46 | }, 47 | [flags.CORS_ALLOWED], 48 | false, 49 | ); 50 | checkFeatures( 51 | { 52 | requires: [flags.IP_LOCKED], 53 | disallowed: [], 54 | }, 55 | [], 56 | false, 57 | ); 58 | }); 59 | 60 | it('should check disallowed correctly', () => { 61 | checkFeatures( 62 | { 63 | requires: [], 64 | disallowed: [], 65 | }, 66 | [], 67 | true, 68 | ); 69 | checkFeatures( 70 | { 71 | requires: [], 72 | disallowed: [flags.CORS_ALLOWED], 73 | }, 74 | [], 75 | true, 76 | ); 77 | checkFeatures( 78 | { 79 | requires: [], 80 | disallowed: [flags.CORS_ALLOWED], 81 | }, 82 | [flags.CORS_ALLOWED], 83 | false, 84 | ); 85 | checkFeatures( 86 | { 87 | requires: [], 88 | disallowed: [flags.CORS_ALLOWED], 89 | }, 90 | [flags.IP_LOCKED], 91 | true, 92 | ); 93 | checkFeatures( 94 | { 95 | requires: [], 96 | disallowed: [flags.CORS_ALLOWED, flags.IP_LOCKED], 97 | }, 98 | [flags.CORS_ALLOWED], 99 | false, 100 | ); 101 | }); 102 | 103 | it('should pass mixed tests', () => { 104 | checkFeatures( 105 | { 106 | requires: [flags.CORS_ALLOWED], 107 | disallowed: [flags.IP_LOCKED], 108 | }, 109 | [], 110 | false, 111 | ); 112 | checkFeatures( 113 | { 114 | requires: [flags.CORS_ALLOWED], 115 | disallowed: [flags.IP_LOCKED], 116 | }, 117 | [flags.CORS_ALLOWED], 118 | true, 119 | ); 120 | checkFeatures( 121 | { 122 | requires: [flags.CORS_ALLOWED], 123 | disallowed: [flags.IP_LOCKED], 124 | }, 125 | [flags.IP_LOCKED], 126 | false, 127 | ); 128 | checkFeatures( 129 | { 130 | requires: [flags.CORS_ALLOWED], 131 | disallowed: [flags.IP_LOCKED], 132 | }, 133 | [flags.IP_LOCKED, flags.CORS_ALLOWED], 134 | false, 135 | ); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/__test__/standard/utils/list.test.ts: -------------------------------------------------------------------------------- 1 | import { reorderOnIdList } from '@/utils/list'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | function list(def: string) { 5 | return def.split(',').map((v) => ({ 6 | rank: parseInt(v), 7 | id: v, 8 | })); 9 | } 10 | 11 | function expectListToEqual(l1: ReturnType, l2: ReturnType) { 12 | function flatten(l: ReturnType) { 13 | return l.map((v) => v.id).join(','); 14 | } 15 | expect(flatten(l1)).toEqual(flatten(l2)); 16 | } 17 | 18 | describe('reorderOnIdList()', () => { 19 | it('should reorder based on rank', () => { 20 | const l = list('2,1,4,3'); 21 | const sortedList = list('4,3,2,1'); 22 | expectListToEqual(reorderOnIdList([], l), sortedList); 23 | }); 24 | 25 | it('should work with empty input', () => { 26 | expectListToEqual(reorderOnIdList([], []), []); 27 | }); 28 | 29 | it('should reorder based on id list', () => { 30 | const l = list('4,2,1,3'); 31 | const sortedList = list('4,3,2,1'); 32 | expectListToEqual(reorderOnIdList(['4', '3', '2', '1'], l), sortedList); 33 | }); 34 | 35 | it('should reorder based on id list and rank second', () => { 36 | const l = list('4,2,1,3'); 37 | const sortedList = list('4,3,2,1'); 38 | expectListToEqual(reorderOnIdList(['4', '3'], l), sortedList); 39 | }); 40 | 41 | it('should work with only one item', () => { 42 | const l = list('1'); 43 | const sortedList = list('1'); 44 | expectListToEqual(reorderOnIdList(['1'], l), sortedList); 45 | expectListToEqual(reorderOnIdList([], l), sortedList); 46 | }); 47 | 48 | it('should not affect original list', () => { 49 | const l = list('4,3,2,1'); 50 | const unsortedList = list('4,3,2,1'); 51 | reorderOnIdList([], l); 52 | expectListToEqual(l, unsortedList); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/__test__/standard/utils/valid.test.ts: -------------------------------------------------------------------------------- 1 | import { isValidStream } from '@/utils/valid'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | describe('isValidStream()', () => { 5 | it('should pass valid streams', () => { 6 | expect( 7 | isValidStream({ 8 | type: 'file', 9 | id: 'a', 10 | flags: [], 11 | captions: [], 12 | qualities: { 13 | '1080': { 14 | type: 'mp4', 15 | url: 'hello-world', 16 | }, 17 | }, 18 | }), 19 | ).toBe(true); 20 | expect( 21 | isValidStream({ 22 | type: 'hls', 23 | id: 'a', 24 | flags: [], 25 | captions: [], 26 | playlist: 'hello-world', 27 | }), 28 | ).toBe(true); 29 | }); 30 | 31 | it('should detect empty qualities', () => { 32 | expect( 33 | isValidStream({ 34 | type: 'file', 35 | id: 'a', 36 | flags: [], 37 | captions: [], 38 | qualities: {}, 39 | }), 40 | ).toBe(false); 41 | }); 42 | 43 | it('should detect empty stream urls', () => { 44 | expect( 45 | isValidStream({ 46 | type: 'file', 47 | id: 'a', 48 | flags: [], 49 | captions: [], 50 | qualities: { 51 | '1080': { 52 | type: 'mp4', 53 | url: '', 54 | }, 55 | }, 56 | }), 57 | ).toBe(false); 58 | }); 59 | 60 | it('should detect emtpy HLS playlists', () => { 61 | expect( 62 | isValidStream({ 63 | type: 'hls', 64 | id: 'a', 65 | flags: [], 66 | captions: [], 67 | playlist: '', 68 | }), 69 | ).toBe(false); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/__test__/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["esnext"], 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "declaration": true, 8 | "outDir": "./lib", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "allowImportingTsExtensions": true, 12 | "noEmit": true, 13 | "experimentalDecorators": true, 14 | "isolatedModules": false, 15 | "skipLibCheck": true, 16 | "paths": { 17 | "@/*": ["../*"], 18 | "@entrypoint": ["../index.ts"] 19 | } 20 | }, 21 | "include": ["./"] 22 | } 23 | -------------------------------------------------------------------------------- /src/dev-cli/browser/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /src/dev-cli/browser/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Scraper CLI 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/dev-cli/browser/index.ts: -------------------------------------------------------------------------------- 1 | import { makeProviders, makeSimpleProxyFetcher, makeStandardFetcher, targets } from '../../../lib'; 2 | 3 | (window as any).scrape = (proxyUrl: string, type: 'source' | 'embed', input: any) => { 4 | const providers = makeProviders({ 5 | fetcher: makeStandardFetcher(fetch), 6 | target: targets.BROWSER, 7 | proxiedFetcher: makeSimpleProxyFetcher(proxyUrl, fetch), 8 | }); 9 | if (type === 'source') { 10 | return providers.runSourceScraper(input); 11 | } 12 | if (type === 'embed') { 13 | return providers.runEmbedScraper(input); 14 | } 15 | 16 | throw new Error('Input input type'); 17 | }; 18 | -------------------------------------------------------------------------------- /src/dev-cli/config.ts: -------------------------------------------------------------------------------- 1 | export function getConfig() { 2 | let tmdbApiKey = process.env.MOVIE_WEB_TMDB_API_KEY ?? ''; 3 | tmdbApiKey = tmdbApiKey.trim(); 4 | 5 | if (!tmdbApiKey) { 6 | throw new Error('Missing MOVIE_WEB_TMDB_API_KEY environment variable'); 7 | } 8 | 9 | let proxyUrl: undefined | string = process.env.MOVIE_WEB_PROXY_URL; 10 | proxyUrl = !proxyUrl ? undefined : proxyUrl; 11 | 12 | return { 13 | tmdbApiKey, 14 | proxyUrl, 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/dev-cli/logging.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from 'node:util'; 2 | 3 | export function logDeepObject(object: Record) { 4 | // This is the dev cli, so we can use console.log 5 | // eslint-disable-next-line no-console 6 | console.log(inspect(object, { showHidden: false, depth: null, colors: true })); 7 | } 8 | -------------------------------------------------------------------------------- /src/dev-cli/tmdb.ts: -------------------------------------------------------------------------------- 1 | import { getConfig } from '@/dev-cli/config'; 2 | 3 | import { MovieMedia, ShowMedia } from '..'; 4 | 5 | export async function makeTMDBRequest(url: string, appendToResponse?: string): Promise { 6 | const headers: { 7 | accept: 'application/json'; 8 | authorization?: string; 9 | } = { 10 | accept: 'application/json', 11 | }; 12 | 13 | const requestURL = new URL(url); 14 | const key = getConfig().tmdbApiKey; 15 | 16 | // * JWT keys always start with ey and are ONLY valid as a header. 17 | // * All other keys are ONLY valid as a query param. 18 | // * Thanks TMDB. 19 | if (key.startsWith('ey')) { 20 | headers.authorization = `Bearer ${key}`; 21 | } else { 22 | requestURL.searchParams.append('api_key', key); 23 | } 24 | 25 | if (appendToResponse) { 26 | requestURL.searchParams.append('append_to_response', appendToResponse); 27 | } 28 | 29 | return fetch(requestURL, { 30 | method: 'GET', 31 | headers, 32 | }); 33 | } 34 | 35 | export async function getMovieMediaDetails(id: string): Promise { 36 | const response = await makeTMDBRequest(`https://api.themoviedb.org/3/movie/${id}`, 'external_ids'); 37 | const movie = await response.json(); 38 | 39 | if (movie.success === false) { 40 | throw new Error(movie.status_message); 41 | } 42 | 43 | if (!movie.release_date) { 44 | throw new Error(`${movie.title} has no release_date. Assuming unreleased`); 45 | } 46 | 47 | return { 48 | type: 'movie', 49 | title: movie.title, 50 | releaseYear: Number(movie.release_date.split('-')[0]), 51 | tmdbId: id, 52 | imdbId: movie.imdb_id, 53 | }; 54 | } 55 | 56 | export async function getShowMediaDetails(id: string, seasonNumber: string, episodeNumber: string): Promise { 57 | // * TV shows require the TMDB ID for the series, season, and episode 58 | // * and the name of the series. Needs multiple requests 59 | let response = await makeTMDBRequest(`https://api.themoviedb.org/3/tv/${id}`, 'external_ids'); 60 | const series = await response.json(); 61 | 62 | if (series.success === false) { 63 | throw new Error(series.status_message); 64 | } 65 | 66 | if (!series.first_air_date) { 67 | throw new Error(`${series.name} has no first_air_date. Assuming unaired`); 68 | } 69 | 70 | response = await makeTMDBRequest(`https://api.themoviedb.org/3/tv/${id}/season/${seasonNumber}`); 71 | const season = await response.json(); 72 | 73 | if (season.success === false) { 74 | throw new Error(season.status_message); 75 | } 76 | 77 | response = await makeTMDBRequest( 78 | `https://api.themoviedb.org/3/tv/${id}/season/${seasonNumber}/episode/${episodeNumber}`, 79 | ); 80 | const episode = await response.json(); 81 | 82 | if (episode.success === false) { 83 | throw new Error(episode.status_message); 84 | } 85 | 86 | return { 87 | type: 'show', 88 | title: series.name, 89 | releaseYear: Number(series.first_air_date.split('-')[0]), 90 | tmdbId: id, 91 | episode: { 92 | number: episode.episode_number, 93 | tmdbId: episode.id, 94 | }, 95 | season: { 96 | number: season.season_number, 97 | tmdbId: season.id, 98 | }, 99 | imdbId: series.external_ids.imdb_id, 100 | }; 101 | } 102 | -------------------------------------------------------------------------------- /src/dev-cli/validate.ts: -------------------------------------------------------------------------------- 1 | import nodeFetch from 'node-fetch'; 2 | 3 | import { Embed, Sourcerer } from '@/providers/base'; 4 | 5 | import { ProviderMakerOptions, makeStandardFetcher, targets } from '..'; 6 | 7 | export type CommandLineArguments = { 8 | fetcher: string; 9 | sourceId: string; 10 | tmdbId: string; 11 | type: string; 12 | season: string; 13 | episode: string; 14 | url: string; 15 | }; 16 | 17 | export async function processOptions(sources: Array, options: CommandLineArguments) { 18 | const fetcherOptions = ['node-fetch', 'native', 'browser']; 19 | if (!fetcherOptions.includes(options.fetcher)) { 20 | throw new Error(`Fetcher must be any of: ${fetcherOptions.join()}`); 21 | } 22 | 23 | if (!options.sourceId.trim()) { 24 | throw new Error('Source ID must be provided'); 25 | } 26 | 27 | const source = sources.find(({ id }) => id === options.sourceId); 28 | 29 | if (!source) { 30 | throw new Error('Invalid source ID. No source found'); 31 | } 32 | 33 | if (source.type === 'embed' && !options.url.trim()) { 34 | throw new Error('Must provide an embed URL for embed sources'); 35 | } 36 | 37 | if (source.type === 'source') { 38 | if (!options.tmdbId.trim()) { 39 | throw new Error('Must provide a TMDB ID for provider sources'); 40 | } 41 | 42 | if (Number.isNaN(Number(options.tmdbId)) || Number(options.tmdbId) < 0) { 43 | throw new Error('TMDB ID must be a number greater than 0'); 44 | } 45 | 46 | if (!options.type.trim()) { 47 | throw new Error('Must provide a type for provider sources'); 48 | } 49 | 50 | if (options.type !== 'movie' && options.type !== 'show') { 51 | throw new Error("Invalid media type. Must be either 'movie' or 'show'"); 52 | } 53 | 54 | if (options.type === 'show') { 55 | if (!options.season.trim()) { 56 | throw new Error('Must provide a season number for TV shows'); 57 | } 58 | 59 | if (!options.episode.trim()) { 60 | throw new Error('Must provide an episode number for TV shows'); 61 | } 62 | 63 | if (Number.isNaN(Number(options.season)) || Number(options.season) <= 0) { 64 | throw new Error('Season number must be a number greater than 0'); 65 | } 66 | 67 | if (Number.isNaN(Number(options.episode)) || Number(options.episode) <= 0) { 68 | throw new Error('Episode number must be a number greater than 0'); 69 | } 70 | } 71 | } 72 | 73 | let fetcher; 74 | 75 | if (options.fetcher === 'native') { 76 | fetcher = makeStandardFetcher(fetch); 77 | } else { 78 | fetcher = makeStandardFetcher(nodeFetch); 79 | } 80 | 81 | const providerOptions: ProviderMakerOptions = { 82 | fetcher, 83 | target: targets.ANY, 84 | consistentIpForRequests: true, 85 | }; 86 | 87 | return { 88 | providerOptions, 89 | options, 90 | source, 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /src/entrypoint/builder.ts: -------------------------------------------------------------------------------- 1 | import { ProviderControls, makeControls } from '@/entrypoint/controls'; 2 | import { getBuiltinEmbeds, getBuiltinSources } from '@/entrypoint/providers'; 3 | import { Targets, getTargetFeatures } from '@/entrypoint/utils/targets'; 4 | import { Fetcher } from '@/fetchers/types'; 5 | import { Embed, Sourcerer } from '@/providers/base'; 6 | import { getProviders } from '@/providers/get'; 7 | 8 | export type ProviderBuilder = { 9 | setTarget(target: Targets): ProviderBuilder; 10 | setFetcher(fetcher: Fetcher): ProviderBuilder; 11 | setProxiedFetcher(fetcher: Fetcher): ProviderBuilder; 12 | addSource(scraper: Sourcerer): ProviderBuilder; 13 | addSource(name: string): ProviderBuilder; 14 | addEmbed(scraper: Embed): ProviderBuilder; 15 | addEmbed(name: string): ProviderBuilder; 16 | addBuiltinProviders(): ProviderBuilder; 17 | enableConsistentIpForRequests(): ProviderBuilder; 18 | build(): ProviderControls; 19 | }; 20 | 21 | export function buildProviders(): ProviderBuilder { 22 | let consistentIpForRequests = false; 23 | let target: Targets | null = null; 24 | let fetcher: Fetcher | null = null; 25 | let proxiedFetcher: Fetcher | null = null; 26 | const embeds: Embed[] = []; 27 | const sources: Sourcerer[] = []; 28 | const builtinSources = getBuiltinSources(); 29 | const builtinEmbeds = getBuiltinEmbeds(); 30 | 31 | return { 32 | enableConsistentIpForRequests() { 33 | consistentIpForRequests = true; 34 | return this; 35 | }, 36 | setFetcher(f) { 37 | fetcher = f; 38 | return this; 39 | }, 40 | setProxiedFetcher(f) { 41 | proxiedFetcher = f; 42 | return this; 43 | }, 44 | setTarget(t) { 45 | target = t; 46 | return this; 47 | }, 48 | addSource(input) { 49 | if (typeof input !== 'string') { 50 | sources.push(input); 51 | return this; 52 | } 53 | 54 | const matchingSource = builtinSources.find((v) => v.id === input); 55 | if (!matchingSource) throw new Error('Source not found'); 56 | sources.push(matchingSource); 57 | return this; 58 | }, 59 | addEmbed(input) { 60 | if (typeof input !== 'string') { 61 | embeds.push(input); 62 | return this; 63 | } 64 | 65 | const matchingEmbed = builtinEmbeds.find((v) => v.id === input); 66 | if (!matchingEmbed) throw new Error('Embed not found'); 67 | embeds.push(matchingEmbed); 68 | return this; 69 | }, 70 | addBuiltinProviders() { 71 | sources.push(...builtinSources); 72 | embeds.push(...builtinEmbeds); 73 | return this; 74 | }, 75 | build() { 76 | if (!target) throw new Error('Target not set'); 77 | if (!fetcher) throw new Error('Fetcher not set'); 78 | const features = getTargetFeatures(target, consistentIpForRequests); 79 | const list = getProviders(features, { 80 | embeds, 81 | sources, 82 | }); 83 | 84 | return makeControls({ 85 | fetcher, 86 | proxiedFetcher: proxiedFetcher ?? undefined, 87 | embeds: list.embeds, 88 | sources: list.sources, 89 | features, 90 | }); 91 | }, 92 | }; 93 | } 94 | -------------------------------------------------------------------------------- /src/entrypoint/declare.ts: -------------------------------------------------------------------------------- 1 | import { makeControls } from '@/entrypoint/controls'; 2 | import { getBuiltinEmbeds, getBuiltinSources } from '@/entrypoint/providers'; 3 | import { Targets, getTargetFeatures } from '@/entrypoint/utils/targets'; 4 | import { Fetcher } from '@/fetchers/types'; 5 | import { getProviders } from '@/providers/get'; 6 | 7 | export interface ProviderMakerOptions { 8 | // fetcher, every web request gets called through here 9 | fetcher: Fetcher; 10 | 11 | // proxied fetcher, if the scraper needs to access a CORS proxy. this fetcher will be called instead 12 | // of the normal fetcher. Defaults to the normal fetcher. 13 | proxiedFetcher?: Fetcher; 14 | 15 | // target of where the streams will be used 16 | target: Targets; 17 | 18 | // Set this to true, if the requests will have the same IP as 19 | // the device that the stream will be played on 20 | consistentIpForRequests?: boolean; 21 | } 22 | 23 | export function makeProviders(ops: ProviderMakerOptions) { 24 | const features = getTargetFeatures(ops.target, ops.consistentIpForRequests ?? false); 25 | const list = getProviders(features, { 26 | embeds: getBuiltinEmbeds(), 27 | sources: getBuiltinSources(), 28 | }); 29 | 30 | return makeControls({ 31 | embeds: list.embeds, 32 | sources: list.sources, 33 | features, 34 | fetcher: ops.fetcher, 35 | proxiedFetcher: ops.proxiedFetcher, 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /src/entrypoint/providers.ts: -------------------------------------------------------------------------------- 1 | import { gatherAllEmbeds, gatherAllSources } from '@/providers/all'; 2 | import { Embed, Sourcerer } from '@/providers/base'; 3 | 4 | export function getBuiltinSources(): Sourcerer[] { 5 | return gatherAllSources().filter((v) => !v.disabled); 6 | } 7 | 8 | export function getBuiltinEmbeds(): Embed[] { 9 | return gatherAllEmbeds().filter((v) => !v.disabled); 10 | } 11 | -------------------------------------------------------------------------------- /src/entrypoint/utils/events.ts: -------------------------------------------------------------------------------- 1 | export type UpdateEventStatus = 'success' | 'failure' | 'notfound' | 'pending'; 2 | 3 | export type UpdateEvent = { 4 | id: string; // id presented in start event 5 | percentage: number; 6 | status: UpdateEventStatus; 7 | error?: unknown; // set when status is failure 8 | reason?: string; // set when status is not-found 9 | }; 10 | 11 | export type InitEvent = { 12 | sourceIds: string[]; // list of source ids 13 | }; 14 | 15 | export type DiscoverEmbedsEvent = { 16 | sourceId: string; 17 | 18 | // list of embeds that will be scraped in order 19 | embeds: Array<{ 20 | id: string; 21 | embedScraperId: string; 22 | }>; 23 | }; 24 | 25 | export type SingleScraperEvents = { 26 | update?: (evt: UpdateEvent) => void; 27 | }; 28 | 29 | export type FullScraperEvents = { 30 | // update progress percentage and status of the currently scraping item 31 | update?: (evt: UpdateEvent) => void; 32 | 33 | // initial list of scrapers its running, only triggers once per run. 34 | init?: (evt: InitEvent) => void; 35 | 36 | // list of embeds are discovered for the currently running source scraper 37 | // triggers once per source scraper 38 | discoverEmbeds?: (evt: DiscoverEmbedsEvent) => void; 39 | 40 | // start scraping an item. 41 | start?: (id: string) => void; 42 | }; 43 | 44 | export type IndividualScraperEvents = { 45 | // update progress percentage and status of the currently scraping item 46 | update?: (evt: UpdateEvent) => void; 47 | }; 48 | -------------------------------------------------------------------------------- /src/entrypoint/utils/media.ts: -------------------------------------------------------------------------------- 1 | export type CommonMedia = { 2 | title: string; 3 | releaseYear: number; 4 | imdbId?: string; 5 | tmdbId: string; 6 | }; 7 | 8 | export type MediaTypes = 'show' | 'movie'; 9 | 10 | export type ShowMedia = CommonMedia & { 11 | type: 'show'; 12 | episode: { 13 | number: number; 14 | tmdbId: string; 15 | }; 16 | season: { 17 | number: number; 18 | tmdbId: string; 19 | }; 20 | }; 21 | 22 | export type MovieMedia = CommonMedia & { 23 | type: 'movie'; 24 | }; 25 | 26 | export type ScrapeMedia = ShowMedia | MovieMedia; 27 | -------------------------------------------------------------------------------- /src/entrypoint/utils/meta.ts: -------------------------------------------------------------------------------- 1 | import { MediaTypes } from '@/entrypoint/utils/media'; 2 | import { Embed, Sourcerer } from '@/providers/base'; 3 | import { ProviderList } from '@/providers/get'; 4 | 5 | export type MetaOutput = { 6 | type: 'embed' | 'source'; 7 | id: string; 8 | rank: number; 9 | name: string; 10 | mediaTypes?: Array; 11 | }; 12 | 13 | function formatSourceMeta(v: Sourcerer): MetaOutput { 14 | const types: Array = []; 15 | if (v.scrapeMovie) types.push('movie'); 16 | if (v.scrapeShow) types.push('show'); 17 | return { 18 | type: 'source', 19 | id: v.id, 20 | rank: v.rank, 21 | name: v.name, 22 | mediaTypes: types, 23 | }; 24 | } 25 | 26 | function formatEmbedMeta(v: Embed): MetaOutput { 27 | return { 28 | type: 'embed', 29 | id: v.id, 30 | rank: v.rank, 31 | name: v.name, 32 | }; 33 | } 34 | 35 | export function getAllSourceMetaSorted(list: ProviderList): MetaOutput[] { 36 | return list.sources.sort((a, b) => b.rank - a.rank).map(formatSourceMeta); 37 | } 38 | 39 | export function getAllEmbedMetaSorted(list: ProviderList): MetaOutput[] { 40 | return list.embeds.sort((a, b) => b.rank - a.rank).map(formatEmbedMeta); 41 | } 42 | 43 | export function getSpecificId(list: ProviderList, id: string): MetaOutput | null { 44 | const foundSource = list.sources.find((v) => v.id === id); 45 | if (foundSource) { 46 | return formatSourceMeta(foundSource); 47 | } 48 | 49 | const foundEmbed = list.embeds.find((v) => v.id === id); 50 | if (foundEmbed) { 51 | return formatEmbedMeta(foundEmbed); 52 | } 53 | 54 | return null; 55 | } 56 | -------------------------------------------------------------------------------- /src/entrypoint/utils/targets.ts: -------------------------------------------------------------------------------- 1 | export const flags = { 2 | // CORS are set to allow any origin 3 | CORS_ALLOWED: 'cors-allowed', 4 | 5 | // the stream is locked on IP, so only works if 6 | // request maker is same as player (not compatible with proxies) 7 | IP_LOCKED: 'ip-locked', 8 | 9 | // The source/embed is blocking cloudflare ip's 10 | // This flag is not compatible with a proxy hosted on cloudflare 11 | CF_BLOCKED: 'cf-blocked', 12 | } as const; 13 | 14 | export type Flags = (typeof flags)[keyof typeof flags]; 15 | 16 | export const targets = { 17 | // browser with CORS restrictions 18 | BROWSER: 'browser', 19 | 20 | // browser, but no CORS restrictions through a browser extension 21 | BROWSER_EXTENSION: 'browser-extension', 22 | 23 | // native app, so no restrictions in what can be played 24 | NATIVE: 'native', 25 | 26 | // any target, no target restrictions 27 | ANY: 'any', 28 | } as const; 29 | 30 | export type Targets = (typeof targets)[keyof typeof targets]; 31 | 32 | export type FeatureMap = { 33 | requires: Flags[]; 34 | disallowed: Flags[]; 35 | }; 36 | 37 | export const targetToFeatures: Record = { 38 | browser: { 39 | requires: [flags.CORS_ALLOWED], 40 | disallowed: [], 41 | }, 42 | 'browser-extension': { 43 | requires: [], 44 | disallowed: [], 45 | }, 46 | native: { 47 | requires: [], 48 | disallowed: [], 49 | }, 50 | any: { 51 | requires: [], 52 | disallowed: [], 53 | }, 54 | }; 55 | 56 | export function getTargetFeatures(target: Targets, consistentIpForRequests: boolean): FeatureMap { 57 | const features = targetToFeatures[target]; 58 | if (!consistentIpForRequests) features.disallowed.push(flags.IP_LOCKED); 59 | return features; 60 | } 61 | 62 | export function flagsAllowedInFeatures(features: FeatureMap, inputFlags: Flags[]): boolean { 63 | const hasAllFlags = features.requires.every((v) => inputFlags.includes(v)); 64 | if (!hasAllFlags) return false; 65 | const hasDisallowedFlag = features.disallowed.some((v) => inputFlags.includes(v)); 66 | if (hasDisallowedFlag) return false; 67 | return true; 68 | } 69 | -------------------------------------------------------------------------------- /src/fetchers/body.ts: -------------------------------------------------------------------------------- 1 | import FormData from 'form-data'; 2 | 3 | import { FetcherOptions } from '@/fetchers/types'; 4 | import { isReactNative } from '@/utils/native'; 5 | 6 | export interface SeralizedBody { 7 | headers: Record; 8 | body: FormData | URLSearchParams | string | undefined; 9 | } 10 | 11 | export function serializeBody(body: FetcherOptions['body']): SeralizedBody { 12 | if (body === undefined || typeof body === 'string' || body instanceof URLSearchParams || body instanceof FormData) { 13 | if (body instanceof URLSearchParams && isReactNative()) { 14 | return { 15 | headers: { 16 | 'Content-Type': 'application/x-www-form-urlencoded', 17 | }, 18 | body: body.toString(), 19 | }; 20 | } 21 | return { 22 | headers: {}, 23 | body, 24 | }; 25 | } 26 | 27 | // serialize as JSON 28 | return { 29 | headers: { 30 | 'Content-Type': 'application/json', 31 | }, 32 | body: JSON.stringify(body), 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/fetchers/common.ts: -------------------------------------------------------------------------------- 1 | import { Fetcher, FetcherOptions, UseableFetcher } from '@/fetchers/types'; 2 | 3 | export type FullUrlOptions = Pick; 4 | 5 | // make url with query params and base url used correctly 6 | export function makeFullUrl(url: string, ops?: FullUrlOptions): string { 7 | // glue baseUrl and rest of url together 8 | let leftSide = ops?.baseUrl ?? ''; 9 | let rightSide = url; 10 | 11 | // left side should always end with slash, if its set 12 | if (leftSide.length > 0 && !leftSide.endsWith('/')) leftSide += '/'; 13 | 14 | // right side should never start with slash 15 | if (rightSide.startsWith('/')) rightSide = rightSide.slice(1); 16 | 17 | const fullUrl = leftSide + rightSide; 18 | if (!fullUrl.startsWith('http://') && !fullUrl.startsWith('https://')) 19 | throw new Error(`Invald URL -- URL doesn't start with a http scheme: '${fullUrl}'`); 20 | 21 | const parsedUrl = new URL(fullUrl); 22 | Object.entries(ops?.query ?? {}).forEach(([k, v]) => { 23 | parsedUrl.searchParams.set(k, v); 24 | }); 25 | 26 | return parsedUrl.toString(); 27 | } 28 | 29 | export function makeFetcher(fetcher: Fetcher): UseableFetcher { 30 | const newFetcher = (url: string, ops?: FetcherOptions) => { 31 | return fetcher(url, { 32 | headers: ops?.headers ?? {}, 33 | method: ops?.method ?? 'GET', 34 | query: ops?.query ?? {}, 35 | baseUrl: ops?.baseUrl ?? '', 36 | readHeaders: ops?.readHeaders ?? [], 37 | body: ops?.body, 38 | }); 39 | }; 40 | const output: UseableFetcher = async (url, ops) => (await newFetcher(url, ops)).body; 41 | output.full = newFetcher; 42 | return output; 43 | } 44 | -------------------------------------------------------------------------------- /src/fetchers/fetch.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is a very relaxed definition of the fetch api 3 | * Only containing what we need for it to function. 4 | */ 5 | 6 | export type FetchOps = { 7 | headers: Record; 8 | method: string; 9 | body: any; 10 | }; 11 | 12 | export type FetchHeaders = { 13 | get(key: string): string | null; 14 | set(key: string, value: string): void; 15 | }; 16 | 17 | export type FetchReply = { 18 | text(): Promise; 19 | json(): Promise; 20 | extraHeaders?: FetchHeaders; 21 | extraUrl?: string; 22 | headers: FetchHeaders; 23 | url: string; 24 | status: number; 25 | }; 26 | 27 | export type FetchLike = (url: string, ops?: FetchOps | undefined) => Promise; 28 | -------------------------------------------------------------------------------- /src/fetchers/simpleProxy.ts: -------------------------------------------------------------------------------- 1 | import { makeFullUrl } from '@/fetchers/common'; 2 | import { FetchLike } from '@/fetchers/fetch'; 3 | import { makeStandardFetcher } from '@/fetchers/standardFetch'; 4 | import { Fetcher } from '@/fetchers/types'; 5 | 6 | const headerMap: Record = { 7 | cookie: 'X-Cookie', 8 | referer: 'X-Referer', 9 | origin: 'X-Origin', 10 | 'user-agent': 'X-User-Agent', 11 | 'x-real-ip': 'X-X-Real-Ip', 12 | }; 13 | 14 | const responseHeaderMap: Record = { 15 | 'x-set-cookie': 'Set-Cookie', 16 | }; 17 | 18 | export function makeSimpleProxyFetcher(proxyUrl: string, f: FetchLike): Fetcher { 19 | const proxiedFetch: Fetcher = async (url, ops) => { 20 | const fetcher = makeStandardFetcher(async (a, b) => { 21 | const res = await f(a, b); 22 | 23 | // set extra headers that cant normally be accessed 24 | res.extraHeaders = new Headers(); 25 | Object.entries(responseHeaderMap).forEach((entry) => { 26 | const value = res.headers.get(entry[0]); 27 | if (!value) return; 28 | res.extraHeaders?.set(entry[0].toLowerCase(), value); 29 | }); 30 | 31 | // set correct final url 32 | res.extraUrl = res.headers.get('X-Final-Destination') ?? res.url; 33 | return res; 34 | }); 35 | 36 | const fullUrl = makeFullUrl(url, ops); 37 | 38 | const headerEntries = Object.entries(ops.headers).map((entry) => { 39 | const key = entry[0].toLowerCase(); 40 | if (headerMap[key]) return [headerMap[key], entry[1]]; 41 | return entry; 42 | }); 43 | 44 | return fetcher(proxyUrl, { 45 | ...ops, 46 | query: { 47 | destination: fullUrl, 48 | }, 49 | headers: Object.fromEntries(headerEntries), 50 | baseUrl: undefined, 51 | }); 52 | }; 53 | 54 | return proxiedFetch; 55 | } 56 | -------------------------------------------------------------------------------- /src/fetchers/standardFetch.ts: -------------------------------------------------------------------------------- 1 | import { serializeBody } from '@/fetchers/body'; 2 | import { makeFullUrl } from '@/fetchers/common'; 3 | import { FetchLike, FetchReply } from '@/fetchers/fetch'; 4 | import { Fetcher } from '@/fetchers/types'; 5 | 6 | function getHeaders(list: string[], res: FetchReply): Headers { 7 | const output = new Headers(); 8 | list.forEach((header) => { 9 | const realHeader = header.toLowerCase(); 10 | const value = res.headers.get(realHeader); 11 | const extraValue = res.extraHeaders?.get(realHeader); 12 | if (!value) return; 13 | output.set(realHeader, extraValue ?? value); 14 | }); 15 | return output; 16 | } 17 | 18 | export function makeStandardFetcher(f: FetchLike): Fetcher { 19 | const normalFetch: Fetcher = async (url, ops) => { 20 | const fullUrl = makeFullUrl(url, ops); 21 | const seralizedBody = serializeBody(ops.body); 22 | 23 | const res = await f(fullUrl, { 24 | method: ops.method, 25 | headers: { 26 | ...seralizedBody.headers, 27 | ...ops.headers, 28 | }, 29 | body: seralizedBody.body, 30 | }); 31 | 32 | let body: any; 33 | const isJson = res.headers.get('content-type')?.includes('application/json'); 34 | if (isJson) body = await res.json(); 35 | else body = await res.text(); 36 | 37 | return { 38 | body, 39 | finalUrl: res.extraUrl ?? res.url, 40 | headers: getHeaders(ops.readHeaders, res), 41 | statusCode: res.status, 42 | }; 43 | }; 44 | 45 | return normalFetch; 46 | } 47 | -------------------------------------------------------------------------------- /src/fetchers/types.ts: -------------------------------------------------------------------------------- 1 | import * as FormData from 'form-data'; 2 | 3 | export type FetcherOptions = { 4 | baseUrl?: string; 5 | headers?: Record; 6 | query?: Record; 7 | method?: 'HEAD' | 'GET' | 'POST'; 8 | readHeaders?: string[]; 9 | body?: Record | string | FormData | URLSearchParams; 10 | }; 11 | 12 | // Version of the options that always has the defaults set 13 | // This is to make making fetchers yourself easier 14 | export type DefaultedFetcherOptions = { 15 | baseUrl?: string; 16 | body?: Record | string | FormData; 17 | headers: Record; 18 | query: Record; 19 | readHeaders: string[]; 20 | method: 'HEAD' | 'GET' | 'POST'; 21 | }; 22 | 23 | export type FetcherResponse = { 24 | statusCode: number; 25 | headers: Headers; 26 | finalUrl: string; 27 | body: T; 28 | }; 29 | 30 | // This is the version that will be inputted by library users 31 | export type Fetcher = { 32 | (url: string, ops: DefaultedFetcherOptions): Promise>; 33 | }; 34 | 35 | // This is the version that scrapers will be interacting with 36 | export type UseableFetcher = { 37 | (url: string, ops?: FetcherOptions): Promise; 38 | full: (url: string, ops?: FetcherOptions) => Promise>; 39 | }; 40 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { EmbedOutput, SourcererOutput } from '@/providers/base'; 2 | export type { Stream, StreamFile, FileBasedStream, HlsBasedStream, Qualities } from '@/providers/streams'; 3 | export type { Fetcher, DefaultedFetcherOptions, FetcherOptions, FetcherResponse } from '@/fetchers/types'; 4 | export type { RunOutput } from '@/runners/runner'; 5 | export type { MetaOutput } from '@/entrypoint/utils/meta'; 6 | export type { FullScraperEvents } from '@/entrypoint/utils/events'; 7 | export type { Targets, Flags } from '@/entrypoint/utils/targets'; 8 | export type { MediaTypes, ShowMedia, ScrapeMedia, MovieMedia } from '@/entrypoint/utils/media'; 9 | export type { ProviderControls, RunnerOptions, EmbedRunnerOptions, SourceRunnerOptions } from '@/entrypoint/controls'; 10 | export type { ProviderBuilder } from '@/entrypoint/builder'; 11 | export type { ProviderMakerOptions } from '@/entrypoint/declare'; 12 | export type { MovieScrapeContext, ShowScrapeContext, EmbedScrapeContext, ScrapeContext } from '@/utils/context'; 13 | export type { SourcererOptions, EmbedOptions } from '@/providers/base'; 14 | 15 | export { NotFoundError } from '@/utils/errors'; 16 | export { makeProviders } from '@/entrypoint/declare'; 17 | export { buildProviders } from '@/entrypoint/builder'; 18 | export { getBuiltinEmbeds, getBuiltinSources } from '@/entrypoint/providers'; 19 | export { makeStandardFetcher } from '@/fetchers/standardFetch'; 20 | export { makeSimpleProxyFetcher } from '@/fetchers/simpleProxy'; 21 | export { flags, targets } from '@/entrypoint/utils/targets'; 22 | -------------------------------------------------------------------------------- /src/providers/base.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '@/entrypoint/utils/targets'; 2 | import { Stream } from '@/providers/streams'; 3 | import { EmbedScrapeContext, MovieScrapeContext, ShowScrapeContext } from '@/utils/context'; 4 | 5 | export type MediaScraperTypes = 'show' | 'movie'; 6 | 7 | export type SourcererEmbed = { 8 | embedId: string; 9 | url: string; 10 | }; 11 | 12 | export type SourcererOutput = { 13 | embeds: SourcererEmbed[]; 14 | stream?: Stream[]; 15 | }; 16 | 17 | export type SourcererOptions = { 18 | id: string; 19 | name: string; // displayed in the UI 20 | rank: number; // the higher the number, the earlier it gets put on the queue 21 | disabled?: boolean; 22 | flags: Flags[]; 23 | scrapeMovie?: (input: MovieScrapeContext) => Promise; 24 | scrapeShow?: (input: ShowScrapeContext) => Promise; 25 | }; 26 | 27 | export type Sourcerer = SourcererOptions & { 28 | type: 'source'; 29 | disabled: boolean; 30 | mediaTypes: MediaScraperTypes[]; 31 | }; 32 | 33 | export function makeSourcerer(state: SourcererOptions): Sourcerer { 34 | const mediaTypes: MediaScraperTypes[] = []; 35 | if (state.scrapeMovie) mediaTypes.push('movie'); 36 | if (state.scrapeShow) mediaTypes.push('show'); 37 | return { 38 | ...state, 39 | type: 'source', 40 | disabled: state.disabled ?? false, 41 | mediaTypes, 42 | }; 43 | } 44 | 45 | export type EmbedOutput = { 46 | stream: Stream[]; 47 | }; 48 | 49 | export type EmbedOptions = { 50 | id: string; 51 | name: string; // displayed in the UI 52 | rank: number; // the higher the number, the earlier it gets put on the queue 53 | disabled?: boolean; 54 | scrape: (input: EmbedScrapeContext) => Promise; 55 | }; 56 | 57 | export type Embed = EmbedOptions & { 58 | type: 'embed'; 59 | disabled: boolean; 60 | mediaTypes: undefined; 61 | }; 62 | 63 | export function makeEmbed(state: EmbedOptions): Embed { 64 | return { 65 | ...state, 66 | type: 'embed', 67 | disabled: state.disabled ?? false, 68 | mediaTypes: undefined, 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /src/providers/captions.ts: -------------------------------------------------------------------------------- 1 | import ISO6391 from 'iso-639-1'; 2 | 3 | export const captionTypes = { 4 | srt: 'srt', 5 | vtt: 'vtt', 6 | }; 7 | export type CaptionType = keyof typeof captionTypes; 8 | 9 | export type Caption = { 10 | type: CaptionType; 11 | id: string; // only unique per stream 12 | url: string; 13 | hasCorsRestrictions: boolean; 14 | language: string; 15 | }; 16 | 17 | export function getCaptionTypeFromUrl(url: string): CaptionType | null { 18 | const extensions = Object.keys(captionTypes) as CaptionType[]; 19 | const type = extensions.find((v) => url.endsWith(`.${v}`)); 20 | if (!type) return null; 21 | return type; 22 | } 23 | 24 | export function labelToLanguageCode(label: string): string | null { 25 | const code = ISO6391.getCode(label); 26 | if (code.length === 0) return null; 27 | return code; 28 | } 29 | 30 | export function isValidLanguageCode(code: string | null): boolean { 31 | if (!code) return false; 32 | return ISO6391.validate(code); 33 | } 34 | 35 | export function removeDuplicatedLanguages(list: Caption[]) { 36 | const beenSeen: Record = {}; 37 | 38 | return list.filter((sub) => { 39 | if (beenSeen[sub.language]) return false; 40 | beenSeen[sub.language] = true; 41 | return true; 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /src/providers/embeds/closeload.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | import { unpack } from 'unpacker'; 3 | 4 | import { flags } from '@/entrypoint/utils/targets'; 5 | import { NotFoundError } from '@/utils/errors'; 6 | 7 | import { makeEmbed } from '../base'; 8 | import { Caption, getCaptionTypeFromUrl, labelToLanguageCode } from '../captions'; 9 | 10 | const referer = 'https://ridomovies.tv/'; 11 | 12 | export const closeLoadScraper = makeEmbed({ 13 | id: 'closeload', 14 | name: 'CloseLoad', 15 | rank: 106, 16 | async scrape(ctx) { 17 | const baseUrl = new URL(ctx.url).origin; 18 | 19 | const iframeRes = await ctx.proxiedFetcher(ctx.url, { 20 | headers: { referer }, 21 | }); 22 | const iframeRes$ = load(iframeRes); 23 | const captions: Caption[] = iframeRes$('track') 24 | .map((_, el) => { 25 | const track = iframeRes$(el); 26 | const url = `${baseUrl}${track.attr('src')}`; 27 | const label = track.attr('label') ?? ''; 28 | const language = labelToLanguageCode(label); 29 | const captionType = getCaptionTypeFromUrl(url); 30 | 31 | if (!language || !captionType) return null; 32 | return { 33 | id: url, 34 | language, 35 | hasCorsRestrictions: true, 36 | type: captionType, 37 | url, 38 | }; 39 | }) 40 | .get() 41 | .filter((x) => x !== null); 42 | 43 | const evalCode = iframeRes$('script') 44 | .filter((_, el) => { 45 | const script = iframeRes$(el); 46 | return (script.attr('type') === 'text/javascript' && script.html()?.includes('p,a,c,k,e,d')) ?? false; 47 | }) 48 | .html(); 49 | if (!evalCode) throw new Error("Couldn't find eval code"); 50 | const decoded = unpack(evalCode); 51 | const regexPattern = /var\s+(\w+)\s*=\s*"([^"]+)";/g; 52 | const base64EncodedUrl = regexPattern.exec(decoded)?.[2]; 53 | if (!base64EncodedUrl) throw new NotFoundError('Unable to find source url'); 54 | const url = atob(base64EncodedUrl); 55 | return { 56 | stream: [ 57 | { 58 | id: 'primary', 59 | type: 'hls', 60 | playlist: url, 61 | captions, 62 | flags: [flags.IP_LOCKED], 63 | headers: { 64 | Referer: 'https://closeload.top/', 65 | Origin: 'https://closeload.top', 66 | }, 67 | }, 68 | ], 69 | }; 70 | }, 71 | }); 72 | -------------------------------------------------------------------------------- /src/providers/embeds/dood.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from 'nanoid'; 2 | 3 | import { makeEmbed } from '@/providers/base'; 4 | 5 | const nanoid = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', 10); 6 | const baseUrl = 'https://d000d.com'; 7 | 8 | export const doodScraper = makeEmbed({ 9 | id: 'dood', 10 | name: 'dood', 11 | rank: 173, 12 | async scrape(ctx) { 13 | let url = ctx.url; 14 | if (ctx.url.includes('primewire')) { 15 | const request = await ctx.proxiedFetcher.full(ctx.url); 16 | url = request.finalUrl; 17 | } 18 | 19 | const id = url.split('/d/')[1] || url.split('/e/')[1]; 20 | 21 | const doodData = await ctx.proxiedFetcher(`/e/${id}`, { 22 | method: 'GET', 23 | baseUrl, 24 | }); 25 | 26 | const dataForLater = doodData.match(/\?token=([^&]+)&expiry=/)?.[1]; 27 | const path = doodData.match(/\$\.get\('\/pass_md5([^']+)/)?.[1]; 28 | 29 | const doodPage = await ctx.proxiedFetcher(`/pass_md5${path}`, { 30 | headers: { 31 | Referer: `${baseUrl}/e/${id}`, 32 | }, 33 | method: 'GET', 34 | baseUrl, 35 | }); 36 | const downloadURL = `${doodPage}${nanoid()}?token=${dataForLater}&expiry=${Date.now()}`; 37 | 38 | if (!downloadURL.startsWith('http')) throw new Error('Invalid URL'); 39 | 40 | return { 41 | stream: [ 42 | { 43 | id: 'primary', 44 | type: 'file', 45 | flags: [], 46 | captions: [], 47 | qualities: { 48 | unknown: { 49 | type: 'mp4', 50 | url: downloadURL, 51 | }, 52 | }, 53 | headers: { 54 | Referer: baseUrl, 55 | }, 56 | }, 57 | ], 58 | }; 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /src/providers/embeds/febbox/common.ts: -------------------------------------------------------------------------------- 1 | import { MediaTypes } from '@/entrypoint/utils/media'; 2 | 3 | export const febBoxBase = `https://www.febbox.com`; 4 | 5 | export interface FebboxFileList { 6 | file_name: string; 7 | ext: string; 8 | fid: number; 9 | oss_fid: number; 10 | is_dir: 0 | 1; 11 | } 12 | 13 | export function parseInputUrl(url: string) { 14 | const [type, id, seasonId, episodeId] = url.slice(1).split('/'); 15 | const season = seasonId ? parseInt(seasonId, 10) : undefined; 16 | const episode = episodeId ? parseInt(episodeId, 10) : undefined; 17 | 18 | return { 19 | type: type as MediaTypes, 20 | id, 21 | season, 22 | episode, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/providers/embeds/febbox/fileList.ts: -------------------------------------------------------------------------------- 1 | import { MediaTypes } from '@/entrypoint/utils/media'; 2 | import { FebboxFileList, febBoxBase } from '@/providers/embeds/febbox/common'; 3 | import { EmbedScrapeContext } from '@/utils/context'; 4 | 5 | export async function getFileList( 6 | ctx: EmbedScrapeContext, 7 | shareKey: string, 8 | parentId?: number, 9 | ): Promise { 10 | const query: Record = { 11 | share_key: shareKey, 12 | pwd: '', 13 | }; 14 | if (parentId) { 15 | query.parent_id = parentId.toString(); 16 | query.page = '1'; 17 | } 18 | 19 | const streams = await ctx.proxiedFetcher<{ 20 | data?: { 21 | file_list?: FebboxFileList[]; 22 | }; 23 | }>('/file/file_share_list', { 24 | headers: { 25 | 'accept-language': 'en', // without this header, the request is marked as a webscraper 26 | }, 27 | baseUrl: febBoxBase, 28 | query, 29 | }); 30 | 31 | return streams.data?.file_list ?? []; 32 | } 33 | 34 | function isValidStream(file: FebboxFileList): boolean { 35 | return file.ext === 'mp4' || file.ext === 'mkv'; 36 | } 37 | 38 | export async function getStreams( 39 | ctx: EmbedScrapeContext, 40 | shareKey: string, 41 | type: MediaTypes, 42 | season?: number, 43 | episode?: number, 44 | ): Promise { 45 | const streams = await getFileList(ctx, shareKey); 46 | 47 | if (type === 'show') { 48 | const seasonFolder = streams.find((v) => { 49 | if (!v.is_dir) return false; 50 | return v.file_name.toLowerCase() === `season ${season}`; 51 | }); 52 | if (!seasonFolder) return []; 53 | 54 | const episodes = await getFileList(ctx, shareKey, seasonFolder.fid); 55 | const s = season?.toString() ?? '0'; 56 | const e = episode?.toString() ?? '0'; 57 | const episodeRegex = new RegExp(`[Ss]0*${s}[Ee]0*${e}`); 58 | return episodes 59 | .filter((file) => { 60 | if (file.is_dir) return false; 61 | const match = file.file_name.match(episodeRegex); 62 | if (!match) return false; 63 | return true; 64 | }) 65 | .filter(isValidStream); 66 | } 67 | 68 | return streams.filter((v) => !v.is_dir).filter(isValidStream); 69 | } 70 | -------------------------------------------------------------------------------- /src/providers/embeds/febbox/hls.ts: -------------------------------------------------------------------------------- 1 | import { MediaTypes } from '@/entrypoint/utils/media'; 2 | import { makeEmbed } from '@/providers/base'; 3 | import { parseInputUrl } from '@/providers/embeds/febbox/common'; 4 | import { getStreams } from '@/providers/embeds/febbox/fileList'; 5 | import { getSubtitles } from '@/providers/embeds/febbox/subtitles'; 6 | import { showboxBase } from '@/providers/sources/showbox/common'; 7 | 8 | // structure: https://www.febbox.com/share/ 9 | export function extractShareKey(url: string): string { 10 | const parsedUrl = new URL(url); 11 | const shareKey = parsedUrl.pathname.split('/')[2]; 12 | return shareKey; 13 | } 14 | export const febboxHlsScraper = makeEmbed({ 15 | id: 'febbox-hls', 16 | name: 'Febbox (HLS)', 17 | rank: 160, 18 | disabled: true, 19 | async scrape(ctx) { 20 | const { type, id, season, episode } = parseInputUrl(ctx.url); 21 | const sharelinkResult = await ctx.proxiedFetcher<{ 22 | data?: { link?: string }; 23 | }>('/index/share_link', { 24 | baseUrl: showboxBase, 25 | query: { 26 | id, 27 | type: type === 'movie' ? '1' : '2', 28 | }, 29 | }); 30 | if (!sharelinkResult?.data?.link) throw new Error('No embed url found'); 31 | ctx.progress(30); 32 | const shareKey = extractShareKey(sharelinkResult.data.link); 33 | const fileList = await getStreams(ctx, shareKey, type, season, episode); 34 | const firstStream = fileList[0]; 35 | if (!firstStream) throw new Error('No playable mp4 stream found'); 36 | ctx.progress(70); 37 | 38 | return { 39 | stream: [ 40 | { 41 | id: 'primary', 42 | type: 'hls', 43 | flags: [], 44 | captions: await getSubtitles(ctx, id, firstStream.fid, type as MediaTypes, season, episode), 45 | playlist: `https://www.febbox.com/hls/main/${firstStream.oss_fid}.m3u8`, 46 | }, 47 | ], 48 | }; 49 | }, 50 | }); 51 | -------------------------------------------------------------------------------- /src/providers/embeds/febbox/mp4.ts: -------------------------------------------------------------------------------- 1 | import { flags } from '@/entrypoint/utils/targets'; 2 | import { makeEmbed } from '@/providers/base'; 3 | import { parseInputUrl } from '@/providers/embeds/febbox/common'; 4 | import { getStreamQualities } from '@/providers/embeds/febbox/qualities'; 5 | import { getSubtitles } from '@/providers/embeds/febbox/subtitles'; 6 | 7 | export const febboxMp4Scraper = makeEmbed({ 8 | id: 'febbox-mp4', 9 | name: 'Febbox (MP4)', 10 | rank: 190, 11 | async scrape(ctx) { 12 | const { type, id, season, episode } = parseInputUrl(ctx.url); 13 | let apiQuery: object | null = null; 14 | 15 | if (type === 'movie') { 16 | apiQuery = { 17 | uid: '', 18 | module: 'Movie_downloadurl_v3', 19 | mid: id, 20 | oss: '1', 21 | group: '', 22 | }; 23 | } else if (type === 'show') { 24 | apiQuery = { 25 | uid: '', 26 | module: 'TV_downloadurl_v3', 27 | tid: id, 28 | season, 29 | episode, 30 | oss: '1', 31 | group: '', 32 | }; 33 | } 34 | 35 | if (!apiQuery) throw Error('Incorrect type'); 36 | 37 | const { qualities, fid } = await getStreamQualities(ctx, apiQuery); 38 | if (fid === undefined) throw new Error('No streamable file found'); 39 | ctx.progress(70); 40 | 41 | return { 42 | stream: [ 43 | { 44 | id: 'primary', 45 | captions: await getSubtitles(ctx, id, fid, type, episode, season), 46 | qualities, 47 | type: 'file', 48 | flags: [flags.CORS_ALLOWED], 49 | }, 50 | ], 51 | }; 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /src/providers/embeds/febbox/qualities.ts: -------------------------------------------------------------------------------- 1 | import { sendRequest } from '@/providers/sources/showbox/sendRequest'; 2 | import { StreamFile } from '@/providers/streams'; 3 | import { ScrapeContext } from '@/utils/context'; 4 | 5 | const allowedQualities = ['360', '480', '720', '1080', '4k']; 6 | 7 | interface FebboxQuality { 8 | path: string; 9 | real_quality: string; 10 | fid?: number; 11 | } 12 | 13 | function mapToQuality(quality: FebboxQuality): FebboxQuality | null { 14 | const q = quality.real_quality.replace('p', '').toLowerCase(); 15 | if (!allowedQualities.includes(q)) return null; 16 | return { 17 | real_quality: q, 18 | path: quality.path, 19 | fid: quality.fid, 20 | }; 21 | } 22 | 23 | export async function getStreamQualities(ctx: ScrapeContext, apiQuery: object) { 24 | const mediaRes: { list: FebboxQuality[] } = (await sendRequest(ctx, apiQuery)).data; 25 | 26 | const qualityMap = mediaRes.list.map((v) => mapToQuality(v)).filter((v): v is FebboxQuality => !!v); 27 | 28 | const qualities: Record = {}; 29 | 30 | allowedQualities.forEach((quality) => { 31 | const foundQuality = qualityMap.find((q) => q.real_quality === quality && q.path); 32 | if (foundQuality) { 33 | qualities[quality] = { 34 | type: 'mp4', 35 | url: foundQuality.path, 36 | }; 37 | } 38 | }); 39 | 40 | return { 41 | qualities, 42 | fid: mediaRes.list[0]?.fid, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/providers/embeds/febbox/subtitles.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Caption, 3 | getCaptionTypeFromUrl, 4 | isValidLanguageCode, 5 | removeDuplicatedLanguages as removeDuplicateLanguages, 6 | } from '@/providers/captions'; 7 | import { captionsDomains } from '@/providers/sources/showbox/common'; 8 | import { sendRequest } from '@/providers/sources/showbox/sendRequest'; 9 | import { ScrapeContext } from '@/utils/context'; 10 | 11 | interface CaptionApiResponse { 12 | data: { 13 | list: { 14 | subtitles: { 15 | order: number; 16 | lang: string; 17 | file_path: string; 18 | }[]; 19 | }[]; 20 | }; 21 | } 22 | 23 | export async function getSubtitles( 24 | ctx: ScrapeContext, 25 | id: string, 26 | fid: number | undefined, 27 | type: 'show' | 'movie', 28 | episodeId?: number, 29 | seasonId?: number, 30 | ): Promise { 31 | const module = type === 'movie' ? 'Movie_srt_list_v2' : 'TV_srt_list_v2'; 32 | const subtitleApiQuery = { 33 | fid, 34 | uid: '', 35 | module, 36 | mid: type === 'movie' ? id : undefined, 37 | tid: type !== 'movie' ? id : undefined, 38 | episode: episodeId?.toString(), 39 | season: seasonId?.toString(), 40 | }; 41 | 42 | const subResult = (await sendRequest(ctx, subtitleApiQuery)) as CaptionApiResponse; 43 | const subtitleList = subResult.data.list; 44 | let output: Caption[] = []; 45 | 46 | subtitleList.forEach((sub) => { 47 | const subtitle = sub.subtitles.sort((a, b) => b.order - a.order)[0]; 48 | if (!subtitle) return; 49 | 50 | const subtitleFilePath = subtitle.file_path 51 | .replace(captionsDomains[0], captionsDomains[1]) 52 | .replace(/\s/g, '+') 53 | .replace(/[()]/g, (c) => { 54 | return `%${c.charCodeAt(0).toString(16)}`; 55 | }); 56 | 57 | const subtitleType = getCaptionTypeFromUrl(subtitleFilePath); 58 | if (!subtitleType) return; 59 | 60 | const validCode = isValidLanguageCode(subtitle.lang); 61 | if (!validCode) return; 62 | 63 | output.push({ 64 | id: subtitleFilePath, 65 | language: subtitle.lang, 66 | hasCorsRestrictions: true, 67 | type: subtitleType, 68 | url: subtitleFilePath, 69 | }); 70 | }); 71 | 72 | output = removeDuplicateLanguages(output); 73 | 74 | return output; 75 | } 76 | -------------------------------------------------------------------------------- /src/providers/embeds/filemoon/index.ts: -------------------------------------------------------------------------------- 1 | import { unpack } from 'unpacker'; 2 | 3 | import { flags } from '@/entrypoint/utils/targets'; 4 | 5 | import { SubtitleResult } from './types'; 6 | import { makeEmbed } from '../../base'; 7 | import { Caption, getCaptionTypeFromUrl, labelToLanguageCode } from '../../captions'; 8 | 9 | const evalCodeRegex = /eval\((.*)\)/g; 10 | const fileRegex = /file:"(.*?)"/g; 11 | 12 | export const fileMoonScraper = makeEmbed({ 13 | id: 'filemoon', 14 | name: 'Filemoon', 15 | rank: 400, 16 | scrape: async (ctx) => { 17 | const embedRes = await ctx.proxiedFetcher(ctx.url, { 18 | headers: { 19 | referer: ctx.url, 20 | }, 21 | }); 22 | const evalCode = embedRes.match(evalCodeRegex); 23 | if (!evalCode) throw new Error('Failed to find eval code'); 24 | const unpacked = unpack(evalCode[1]); 25 | const file = fileRegex.exec(unpacked); 26 | if (!file?.[1]) throw new Error('Failed to find file'); 27 | 28 | const url = new URL(ctx.url); 29 | const subtitlesLink = url.searchParams.get('sub.info'); 30 | const captions: Caption[] = []; 31 | if (subtitlesLink) { 32 | const captionsResult = await ctx.proxiedFetcher(subtitlesLink); 33 | 34 | for (const caption of captionsResult) { 35 | const language = labelToLanguageCode(caption.label); 36 | const captionType = getCaptionTypeFromUrl(caption.file); 37 | if (!language || !captionType) continue; 38 | captions.push({ 39 | id: caption.file, 40 | url: caption.file, 41 | type: captionType, 42 | language, 43 | hasCorsRestrictions: false, 44 | }); 45 | } 46 | } 47 | 48 | return { 49 | stream: [ 50 | { 51 | id: 'primary', 52 | type: 'hls', 53 | playlist: file[1], 54 | flags: [flags.CORS_ALLOWED], 55 | captions, 56 | }, 57 | ], 58 | }; 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /src/providers/embeds/filemoon/types.ts: -------------------------------------------------------------------------------- 1 | export type SubtitleResult = { 2 | file: string; 3 | label: string; 4 | kind: string; 5 | }[]; 6 | -------------------------------------------------------------------------------- /src/providers/embeds/mixdrop.ts: -------------------------------------------------------------------------------- 1 | import * as unpacker from 'unpacker'; 2 | 3 | import { makeEmbed } from '@/providers/base'; 4 | 5 | const packedRegex = /(eval\(function\(p,a,c,k,e,d\){.*{}\)\))/; 6 | const linkRegex = /MDCore\.wurl="(.*?)";/; 7 | 8 | export const mixdropScraper = makeEmbed({ 9 | id: 'mixdrop', 10 | name: 'MixDrop', 11 | rank: 198, 12 | async scrape(ctx) { 13 | // Example url: https://mixdrop.co/e/pkwrgp0pizgod0 14 | // Example url: https://mixdrop.vc/e/pkwrgp0pizgod0 15 | const streamRes = await ctx.proxiedFetcher(ctx.url); 16 | const packed = streamRes.match(packedRegex); 17 | 18 | // MixDrop uses a queue system for embeds 19 | // If an embed is too new, the queue will 20 | // not be completed and thus the packed 21 | // JavaScript not present 22 | if (!packed) { 23 | throw new Error('failed to find packed mixdrop JavaScript'); 24 | } 25 | 26 | const unpacked = unpacker.unpack(packed[1]); 27 | const link = unpacked.match(linkRegex); 28 | 29 | if (!link) { 30 | throw new Error('failed to find packed mixdrop source link'); 31 | } 32 | 33 | const url = link[1]; 34 | 35 | return { 36 | stream: [ 37 | { 38 | id: 'primary', 39 | type: 'file', 40 | flags: [], 41 | captions: [], 42 | qualities: { 43 | unknown: { 44 | type: 'mp4', 45 | url: url.startsWith('http') ? url : `https:${url}`, // URLs don't always start with the protocol 46 | headers: { 47 | // MixDrop requires this header on all streams 48 | Referer: 'https://mixdrop.co/', 49 | }, 50 | }, 51 | }, 52 | }, 53 | ], 54 | }; 55 | }, 56 | }); 57 | -------------------------------------------------------------------------------- /src/providers/embeds/mp4upload.ts: -------------------------------------------------------------------------------- 1 | import { flags } from '@/entrypoint/utils/targets'; 2 | import { makeEmbed } from '@/providers/base'; 3 | 4 | export const mp4uploadScraper = makeEmbed({ 5 | id: 'mp4upload', 6 | name: 'mp4upload', 7 | rank: 170, 8 | async scrape(ctx) { 9 | const embed = await ctx.proxiedFetcher(ctx.url); 10 | 11 | const playerSrcRegex = /(?<=player\.src\()\s*{\s*type:\s*"[^"]+",\s*src:\s*"([^"]+)"\s*}\s*(?=\);)/s; 12 | const playerSrc = embed.match(playerSrcRegex) ?? []; 13 | 14 | const streamUrl = playerSrc[1]; 15 | if (!streamUrl) throw new Error('Stream url not found in embed code'); 16 | 17 | return { 18 | stream: [ 19 | { 20 | id: 'primary', 21 | type: 'file', 22 | flags: [flags.CORS_ALLOWED], 23 | captions: [], 24 | qualities: { 25 | '1080': { 26 | type: 'mp4', 27 | url: streamUrl, 28 | }, 29 | }, 30 | }, 31 | ], 32 | }; 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /src/providers/embeds/ridoo.ts: -------------------------------------------------------------------------------- 1 | import { flags } from '@/entrypoint/utils/targets'; 2 | import { NotFoundError } from '@/utils/errors'; 3 | 4 | import { makeEmbed } from '../base'; 5 | 6 | const referer = 'https://ridomovies.tv/'; 7 | 8 | export const ridooScraper = makeEmbed({ 9 | id: 'ridoo', 10 | name: 'Ridoo', 11 | rank: 105, 12 | async scrape(ctx) { 13 | const res = await ctx.proxiedFetcher(ctx.url, { 14 | headers: { 15 | referer, 16 | }, 17 | }); 18 | const regexPattern = /file:"([^"]+)"/g; 19 | const url = regexPattern.exec(res)?.[1]; 20 | if (!url) throw new NotFoundError('Unable to find source url'); 21 | 22 | return { 23 | stream: [ 24 | { 25 | id: 'primary', 26 | type: 'hls', 27 | playlist: url, 28 | captions: [], 29 | flags: [flags.CORS_ALLOWED], 30 | }, 31 | ], 32 | }; 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /src/providers/embeds/smashystream/dued.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | 3 | import { flags } from '@/entrypoint/utils/targets'; 4 | import { makeEmbed } from '@/providers/base'; 5 | 6 | type DPlayerSourcesResponse = { 7 | title: string; 8 | id: string; 9 | file: string; 10 | }[]; 11 | 12 | export const smashyStreamDScraper = makeEmbed({ 13 | id: 'smashystream-d', 14 | name: 'SmashyStream (D)', 15 | rank: 71, 16 | async scrape(ctx) { 17 | const mainPageRes = await ctx.proxiedFetcher(ctx.url, { 18 | headers: { 19 | Referer: ctx.url, 20 | }, 21 | }); 22 | const mainPageRes$ = load(mainPageRes); 23 | const iframeUrl = mainPageRes$('iframe').attr('src'); 24 | if (!iframeUrl) throw new Error(`[${this.name}] failed to find iframe url`); 25 | const mainUrl = new URL(iframeUrl); 26 | const iframeRes = await ctx.proxiedFetcher(iframeUrl, { 27 | headers: { 28 | Referer: ctx.url, 29 | }, 30 | }); 31 | const textFilePath = iframeRes.match(/"file":"([^"]+)"/)?.[1]; 32 | const csrfToken = iframeRes.match(/"key":"([^"]+)"/)?.[1]; 33 | if (!textFilePath || !csrfToken) throw new Error(`[${this.name}] failed to find text file url or token`); 34 | const textFileUrl = `${mainUrl.origin}${textFilePath}`; 35 | const textFileRes = await ctx.proxiedFetcher(textFileUrl, { 36 | method: 'POST', 37 | headers: { 38 | 'Content-Type': 'application/x-www-form-urlencoded', 39 | 'X-CSRF-TOKEN': csrfToken, 40 | Referer: iframeUrl, 41 | }, 42 | }); 43 | // Playlists in Hindi, English, Tamil and Telugu are available. We only get the english one. 44 | const textFilePlaylist = textFileRes.find((x) => x.title === 'English')?.file; 45 | if (!textFilePlaylist) throw new Error(`[${this.name}] failed to find an english playlist`); 46 | 47 | const playlistRes = await ctx.proxiedFetcher( 48 | `${mainUrl.origin}/playlist/${textFilePlaylist.slice(1)}.txt`, 49 | { 50 | method: 'POST', 51 | headers: { 52 | 'Content-Type': 'application/x-www-form-urlencoded', 53 | 'X-CSRF-TOKEN': csrfToken, 54 | Referer: iframeUrl, 55 | }, 56 | }, 57 | ); 58 | 59 | return { 60 | stream: [ 61 | { 62 | id: 'primary', 63 | playlist: playlistRes, 64 | type: 'hls', 65 | flags: [flags.CORS_ALLOWED], 66 | captions: [], 67 | }, 68 | ], 69 | }; 70 | }, 71 | }); 72 | -------------------------------------------------------------------------------- /src/providers/embeds/smashystream/video1.ts: -------------------------------------------------------------------------------- 1 | import { flags } from '@/entrypoint/utils/targets'; 2 | import { makeEmbed } from '@/providers/base'; 3 | import { Caption, getCaptionTypeFromUrl, labelToLanguageCode } from '@/providers/captions'; 4 | 5 | type FPlayerResponse = { 6 | sourceUrls: string[]; 7 | subtitleUrls: string; 8 | }; 9 | 10 | export const smashyStreamFScraper = makeEmbed({ 11 | id: 'smashystream-f', 12 | name: 'SmashyStream (F)', 13 | rank: 70, 14 | async scrape(ctx) { 15 | const res = await ctx.proxiedFetcher(ctx.url, { 16 | headers: { 17 | Referer: ctx.url, 18 | }, 19 | }); 20 | 21 | const captions: Caption[] = 22 | res.subtitleUrls 23 | .match(/\[([^\]]+)\](https?:\/\/\S+?)(?=,\[|$)/g) 24 | ?.map((entry: string) => { 25 | const match = entry.match(/\[([^\]]+)\](https?:\/\/\S+?)(?=,\[|$)/); 26 | if (match) { 27 | const [, language, url] = match; 28 | if (language && url) { 29 | const languageCode = labelToLanguageCode(language); 30 | const captionType = getCaptionTypeFromUrl(url); 31 | if (!languageCode || !captionType) return null; 32 | return { 33 | id: url, 34 | url: url.replace(',', ''), 35 | language: languageCode, 36 | type: captionType, 37 | hasCorsRestrictions: false, 38 | }; 39 | } 40 | } 41 | return null; 42 | }) 43 | .filter((x): x is Caption => x !== null) ?? []; 44 | 45 | return { 46 | stream: [ 47 | { 48 | id: 'primary', 49 | playlist: res.sourceUrls[0], 50 | type: 'hls', 51 | flags: [flags.CORS_ALLOWED], 52 | captions, 53 | }, 54 | ], 55 | }; 56 | }, 57 | }); 58 | -------------------------------------------------------------------------------- /src/providers/embeds/streamtape.ts: -------------------------------------------------------------------------------- 1 | import { flags } from '@/entrypoint/utils/targets'; 2 | import { makeEmbed } from '@/providers/base'; 3 | 4 | export const streamtapeScraper = makeEmbed({ 5 | id: 'streamtape', 6 | name: 'Streamtape', 7 | rank: 160, 8 | async scrape(ctx) { 9 | const embed = await ctx.proxiedFetcher(ctx.url); 10 | 11 | const match = embed.match(/robotlink'\).innerHTML = (.*)'/); 12 | if (!match) throw new Error('No match found'); 13 | 14 | const [fh, sh] = match?.[1]?.split("+ ('") ?? []; 15 | if (!fh || !sh) throw new Error('No match found'); 16 | 17 | const url = `https:${fh?.replace(/'/g, '').trim()}${sh?.substring(3).trim()}`; 18 | 19 | return { 20 | stream: [ 21 | { 22 | id: 'primary', 23 | type: 'file', 24 | flags: [flags.CORS_ALLOWED, flags.IP_LOCKED], 25 | captions: [], 26 | qualities: { 27 | unknown: { 28 | type: 'mp4', 29 | url, 30 | }, 31 | }, 32 | headers: { 33 | Referer: 'https://streamtape.com', 34 | }, 35 | }, 36 | ], 37 | }; 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /src/providers/embeds/streamvid.ts: -------------------------------------------------------------------------------- 1 | import * as unpacker from 'unpacker'; 2 | 3 | import { flags } from '@/entrypoint/utils/targets'; 4 | import { makeEmbed } from '@/providers/base'; 5 | 6 | const packedRegex = /(eval\(function\(p,a,c,k,e,d\).*\)\)\))/; 7 | const linkRegex = /src:"(https:\/\/[^"]+)"/; 8 | 9 | export const streamvidScraper = makeEmbed({ 10 | id: 'streamvid', 11 | name: 'Streamvid', 12 | rank: 215, 13 | async scrape(ctx) { 14 | // Example url: https://streamvid.net/fu1jaf96vofx 15 | const streamRes = await ctx.proxiedFetcher(ctx.url); 16 | const packed = streamRes.match(packedRegex); 17 | 18 | if (!packed) throw new Error('streamvid packed not found'); 19 | 20 | const unpacked = unpacker.unpack(packed[1]); 21 | const link = unpacked.match(linkRegex); 22 | 23 | if (!link) throw new Error('streamvid link not found'); 24 | return { 25 | stream: [ 26 | { 27 | type: 'hls', 28 | id: 'primary', 29 | playlist: link[1], 30 | flags: [flags.CORS_ALLOWED], 31 | captions: [], 32 | }, 33 | ], 34 | }; 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /src/providers/embeds/upstream.ts: -------------------------------------------------------------------------------- 1 | import * as unpacker from 'unpacker'; 2 | 3 | import { flags } from '@/entrypoint/utils/targets'; 4 | import { makeEmbed } from '@/providers/base'; 5 | 6 | const packedRegex = /(eval\(function\(p,a,c,k,e,d\).*\)\)\))/; 7 | const linkRegex = /sources:\[{file:"(.*?)"/; 8 | 9 | export const upstreamScraper = makeEmbed({ 10 | id: 'upstream', 11 | name: 'UpStream', 12 | rank: 199, 13 | async scrape(ctx) { 14 | // Example url: https://upstream.to/embed-omscqgn6jc8r.html 15 | const streamRes = await ctx.proxiedFetcher(ctx.url); 16 | const packed = streamRes.match(packedRegex); 17 | 18 | if (packed) { 19 | const unpacked = unpacker.unpack(packed[1]); 20 | const link = unpacked.match(linkRegex); 21 | 22 | if (link) { 23 | return { 24 | stream: [ 25 | { 26 | id: 'primary', 27 | type: 'hls', 28 | playlist: link[1], 29 | flags: [flags.CORS_ALLOWED], 30 | captions: [], 31 | }, 32 | ], 33 | }; 34 | } 35 | } 36 | 37 | throw new Error('upstream source not found'); 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /src/providers/embeds/vidcloud.ts: -------------------------------------------------------------------------------- 1 | import { makeEmbed } from '@/providers/base'; 2 | 3 | import { upcloudScraper } from './upcloud'; 4 | 5 | export const vidCloudScraper = makeEmbed({ 6 | id: 'vidcloud', 7 | name: 'VidCloud', 8 | rank: 201, 9 | async scrape(ctx) { 10 | // Both vidcloud and upcloud have the same embed domain (rabbitstream.net) 11 | const result = await upcloudScraper.scrape(ctx); 12 | return { 13 | stream: result.stream.map((s) => ({ 14 | ...s, 15 | flags: [], 16 | })), 17 | }; 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /src/providers/embeds/vidplay/common.ts: -------------------------------------------------------------------------------- 1 | import { makeFullUrl } from '@/fetchers/common'; 2 | import { decodeData } from '@/providers/sources/vidsrcto/common'; 3 | import { EmbedScrapeContext } from '@/utils/context'; 4 | 5 | export const vidplayBase = 'https://vidplay.online'; 6 | export const referer = `${vidplayBase}/`; 7 | 8 | // This file is based on https://github.com/Ciarands/vidsrc-to-resolver/blob/dffa45e726a4b944cb9af0c9e7630476c93c0213/vidsrc.py#L16 9 | // Full credits to @Ciarands! 10 | 11 | export const getDecryptionKeys = async (ctx: EmbedScrapeContext): Promise => { 12 | const res = await ctx.proxiedFetcher('https://github.com/Ciarands/vidsrc-keys/blob/main/keys.json'); 13 | const regex = /"rawLines":\s*\[([\s\S]*?)\]/; 14 | const rawLines = res.match(regex)?.[1]; 15 | if (!rawLines) throw new Error('No keys found'); 16 | const keys = JSON.parse(`${rawLines.substring(1).replace(/\\"/g, '"')}]`); 17 | return keys; 18 | }; 19 | 20 | export const getEncodedId = async (ctx: EmbedScrapeContext) => { 21 | const url = new URL(ctx.url); 22 | const id = url.pathname.replace('/e/', ''); 23 | const keyList = await getDecryptionKeys(ctx); 24 | 25 | const decodedId = decodeData(keyList[0], id); 26 | const encodedResult = decodeData(keyList[1], decodedId); 27 | const b64encoded = btoa(encodedResult); 28 | return b64encoded.replace('/', '_'); 29 | }; 30 | 31 | export const getFuTokenKey = async (ctx: EmbedScrapeContext) => { 32 | const id = await getEncodedId(ctx); 33 | const fuTokenRes = await ctx.proxiedFetcher('/futoken', { 34 | baseUrl: vidplayBase, 35 | headers: { 36 | referer: ctx.url, 37 | }, 38 | }); 39 | const fuKey = fuTokenRes.match(/var\s+k\s*=\s*'([^']+)'/)?.[1]; 40 | if (!fuKey) throw new Error('No fuKey found'); 41 | const tokens = []; 42 | for (let i = 0; i < id.length; i += 1) { 43 | tokens.push(fuKey.charCodeAt(i % fuKey.length) + id.charCodeAt(i)); 44 | } 45 | return `${fuKey},${tokens.join(',')}`; 46 | }; 47 | 48 | export const getFileUrl = async (ctx: EmbedScrapeContext) => { 49 | const fuToken = await getFuTokenKey(ctx); 50 | return makeFullUrl(`/mediainfo/${fuToken}`, { 51 | baseUrl: vidplayBase, 52 | query: { 53 | ...Object.fromEntries(new URL(ctx.url).searchParams.entries()), 54 | autostart: 'true', 55 | }, 56 | }); 57 | }; 58 | -------------------------------------------------------------------------------- /src/providers/embeds/vidplay/index.ts: -------------------------------------------------------------------------------- 1 | import { flags } from '@/entrypoint/utils/targets'; 2 | import { makeEmbed } from '@/providers/base'; 3 | import { Caption, getCaptionTypeFromUrl, labelToLanguageCode } from '@/providers/captions'; 4 | 5 | import { getFileUrl } from './common'; 6 | import { SubtitleResult, VidplaySourceResponse } from './types'; 7 | 8 | export const vidplayScraper = makeEmbed({ 9 | id: 'vidplay', 10 | name: 'VidPlay', 11 | rank: 401, 12 | scrape: async (ctx) => { 13 | const fileUrl = await getFileUrl(ctx); 14 | const fileUrlRes = await ctx.proxiedFetcher(fileUrl, { 15 | headers: { 16 | referer: ctx.url, 17 | }, 18 | }); 19 | if (typeof fileUrlRes.result === 'number') throw new Error('File not found'); 20 | const source = fileUrlRes.result.sources[0].file; 21 | 22 | const url = new URL(ctx.url); 23 | const subtitlesLink = url.searchParams.get('sub.info'); 24 | const captions: Caption[] = []; 25 | if (subtitlesLink) { 26 | const captionsResult = await ctx.proxiedFetcher(subtitlesLink); 27 | 28 | for (const caption of captionsResult) { 29 | const language = labelToLanguageCode(caption.label); 30 | const captionType = getCaptionTypeFromUrl(caption.file); 31 | if (!language || !captionType) continue; 32 | captions.push({ 33 | id: caption.file, 34 | url: caption.file, 35 | type: captionType, 36 | language, 37 | hasCorsRestrictions: false, 38 | }); 39 | } 40 | } 41 | 42 | return { 43 | stream: [ 44 | { 45 | id: 'primary', 46 | type: 'hls', 47 | playlist: source, 48 | flags: [flags.CORS_ALLOWED], 49 | captions, 50 | }, 51 | ], 52 | }; 53 | }, 54 | }); 55 | -------------------------------------------------------------------------------- /src/providers/embeds/vidplay/types.ts: -------------------------------------------------------------------------------- 1 | export type VidplaySourceResponse = { 2 | result: 3 | | { 4 | sources: { 5 | file: string; 6 | tracks: { 7 | file: string; 8 | kind: string; 9 | }[]; 10 | }[]; 11 | } 12 | | number; 13 | }; 14 | 15 | export type SubtitleResult = { 16 | file: string; 17 | label: string; 18 | kind: string; 19 | }[]; 20 | -------------------------------------------------------------------------------- /src/providers/embeds/vidsrc.ts: -------------------------------------------------------------------------------- 1 | import { flags } from '@/entrypoint/utils/targets'; 2 | import { makeEmbed } from '@/providers/base'; 3 | 4 | const hlsURLRegex = /file:"(.*?)"/; 5 | const setPassRegex = /var pass_path = "(.*set_pass\.php.*)";/; 6 | 7 | function formatHlsB64(data: string): string { 8 | const encodedB64 = data.replace(/\/@#@\/[^=/]+==/g, ''); 9 | if (encodedB64.match(/\/@#@\/[^=/]+==/)) { 10 | return formatHlsB64(encodedB64); 11 | } 12 | return encodedB64; 13 | } 14 | 15 | export const vidsrcembedScraper = makeEmbed({ 16 | id: 'vidsrcembed', // VidSrc is both a source and an embed host 17 | name: 'VidSrc', 18 | rank: 197, 19 | async scrape(ctx) { 20 | const html = await ctx.proxiedFetcher(ctx.url, { 21 | headers: { 22 | referer: ctx.url, 23 | }, 24 | }); 25 | 26 | // When this eventually breaks see the player js @ pjs_main.js 27 | // If you know what youre doing and are slightly confused about how to reverse this feel free to reach out to ciaran_ds on discord with any queries 28 | let hlsMatch = html.match(hlsURLRegex)?.[1]?.slice(2); 29 | if (!hlsMatch) throw new Error('Unable to find HLS playlist'); 30 | hlsMatch = formatHlsB64(hlsMatch); 31 | const finalUrl = atob(hlsMatch); 32 | if (!finalUrl.includes('.m3u8')) throw new Error('Unable to find HLS playlist'); 33 | 34 | let setPassLink = html.match(setPassRegex)?.[1]; 35 | if (!setPassLink) throw new Error('Unable to find set_pass.php link'); 36 | 37 | if (setPassLink.startsWith('//')) { 38 | setPassLink = `https:${setPassLink}`; 39 | } 40 | 41 | // VidSrc uses a password endpoint to temporarily whitelist the user's IP. This is called in an interval by the player. 42 | // It currently has no effect on the player itself, the content plays fine without it. 43 | // In the future we might have to introduce hooks for the frontend to call this endpoint. 44 | await ctx.proxiedFetcher(setPassLink, { 45 | headers: { 46 | referer: ctx.url, 47 | }, 48 | }); 49 | 50 | return { 51 | stream: [ 52 | { 53 | id: 'primary', 54 | type: 'hls', 55 | playlist: finalUrl, 56 | flags: [flags.CORS_ALLOWED], 57 | captions: [], 58 | }, 59 | ], 60 | }; 61 | }, 62 | }); 63 | -------------------------------------------------------------------------------- /src/providers/embeds/voe.ts: -------------------------------------------------------------------------------- 1 | import { flags } from '@/entrypoint/utils/targets'; 2 | import { makeEmbed } from '@/providers/base'; 3 | 4 | const linkRegex = /'hls': ?'(http.*?)',/; 5 | 6 | export const voeScraper = makeEmbed({ 7 | id: 'voe', 8 | name: 'voe.sx', 9 | rank: 180, 10 | async scrape(ctx) { 11 | const embed = await ctx.proxiedFetcher(ctx.url); 12 | 13 | const playerSrc = embed.match(linkRegex) ?? []; 14 | 15 | const streamUrl = playerSrc[1]; 16 | if (!streamUrl) throw new Error('Stream url not found in embed code'); 17 | 18 | return { 19 | stream: [ 20 | { 21 | type: 'hls', 22 | id: 'primary', 23 | playlist: streamUrl, 24 | flags: [flags.CORS_ALLOWED], 25 | captions: [], 26 | headers: { 27 | Referer: 'https://voe.sx', 28 | }, 29 | }, 30 | ], 31 | }; 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /src/providers/embeds/wootly.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | 3 | import { flags } from '@/entrypoint/utils/targets'; 4 | import { makeEmbed } from '@/providers/base'; 5 | import { makeCookieHeader, parseSetCookie } from '@/utils/cookie'; 6 | 7 | export const wootlyScraper = makeEmbed({ 8 | id: 'wootly', 9 | name: 'wootly', 10 | rank: 172, 11 | async scrape(ctx) { 12 | const baseUrl = 'https://www.wootly.ch'; 13 | 14 | const wootlyData = await ctx.proxiedFetcher.full(ctx.url, { 15 | method: 'GET', 16 | readHeaders: ['Set-Cookie'], 17 | }); 18 | 19 | const cookies = parseSetCookie(wootlyData.headers.get('Set-Cookie') || ''); 20 | const wootssesCookie = cookies.wootsses.value; 21 | 22 | let $ = load(wootlyData.body); // load the html data 23 | const iframeSrc = $('iframe').attr('src') ?? ''; 24 | 25 | const woozCookieRequest = await ctx.proxiedFetcher.full(iframeSrc, { 26 | method: 'GET', 27 | readHeaders: ['Set-Cookie'], 28 | headers: { 29 | cookie: makeCookieHeader({ wootsses: wootssesCookie }), 30 | }, 31 | }); 32 | 33 | const woozCookies = parseSetCookie(woozCookieRequest.headers.get('Set-Cookie') || ''); 34 | const woozCookie = woozCookies.wooz.value; 35 | 36 | const iframeData = await ctx.proxiedFetcher(iframeSrc, { 37 | method: 'POST', 38 | body: new URLSearchParams({ qdf: '1' }), 39 | headers: { 40 | cookie: makeCookieHeader({ wooz: woozCookie }), 41 | Referer: iframeSrc, 42 | }, 43 | }); 44 | 45 | $ = load(iframeData); 46 | 47 | const scriptText = $('script').html() ?? ''; 48 | 49 | // Regular expressions to match the variables 50 | const tk = scriptText.match(/tk=([^;]+)/)?.[0].replace(/tk=|["\s]/g, ''); 51 | const vd = scriptText.match(/vd=([^,]+)/)?.[0].replace(/vd=|["\s]/g, ''); 52 | 53 | if (!tk || !vd) throw new Error('wootly source not found'); 54 | 55 | const url = await ctx.proxiedFetcher(`/grabd`, { 56 | baseUrl, 57 | query: { t: tk, id: vd }, 58 | method: 'GET', 59 | headers: { 60 | cookie: makeCookieHeader({ wooz: woozCookie, wootsses: wootssesCookie }), 61 | }, 62 | }); 63 | 64 | if (!url) throw new Error('wootly source not found'); 65 | 66 | return { 67 | stream: [ 68 | { 69 | id: 'primary', 70 | type: 'file', 71 | flags: [flags.IP_LOCKED], 72 | captions: [], 73 | qualities: { 74 | unknown: { 75 | type: 'mp4', 76 | url, 77 | }, 78 | }, 79 | }, 80 | ], 81 | }; 82 | }, 83 | }); 84 | -------------------------------------------------------------------------------- /src/providers/get.ts: -------------------------------------------------------------------------------- 1 | import { FeatureMap, flagsAllowedInFeatures } from '@/entrypoint/utils/targets'; 2 | import { Embed, Sourcerer } from '@/providers/base'; 3 | import { hasDuplicates } from '@/utils/predicates'; 4 | 5 | export interface ProviderList { 6 | sources: Sourcerer[]; 7 | embeds: Embed[]; 8 | } 9 | 10 | export function getProviders(features: FeatureMap, list: ProviderList): ProviderList { 11 | const sources = list.sources.filter((v) => !v?.disabled); 12 | const embeds = list.embeds.filter((v) => !v?.disabled); 13 | const combined = [...sources, ...embeds]; 14 | 15 | const anyDuplicateId = hasDuplicates(combined.map((v) => v.id)); 16 | const anyDuplicateSourceRank = hasDuplicates(sources.map((v) => v.rank)); 17 | const anyDuplicateEmbedRank = hasDuplicates(embeds.map((v) => v.rank)); 18 | 19 | if (anyDuplicateId) throw new Error('Duplicate id found in sources/embeds'); 20 | if (anyDuplicateSourceRank) throw new Error('Duplicate rank found in sources'); 21 | if (anyDuplicateEmbedRank) throw new Error('Duplicate rank found in embeds'); 22 | 23 | return { 24 | sources: sources.filter((s) => flagsAllowedInFeatures(features, s.flags)), 25 | embeds, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/providers/sources/flixhq/common.ts: -------------------------------------------------------------------------------- 1 | export const flixHqBase = 'https://flixhq.to'; 2 | -------------------------------------------------------------------------------- /src/providers/sources/flixhq/index.ts: -------------------------------------------------------------------------------- 1 | import { flags } from '@/entrypoint/utils/targets'; 2 | import { SourcererEmbed, makeSourcerer } from '@/providers/base'; 3 | import { upcloudScraper } from '@/providers/embeds/upcloud'; 4 | import { vidCloudScraper } from '@/providers/embeds/vidcloud'; 5 | import { getFlixhqMovieSources, getFlixhqShowSources, getFlixhqSourceDetails } from '@/providers/sources/flixhq/scrape'; 6 | import { getFlixhqId } from '@/providers/sources/flixhq/search'; 7 | import { NotFoundError } from '@/utils/errors'; 8 | 9 | export const flixhqScraper = makeSourcerer({ 10 | id: 'flixhq', 11 | name: 'FlixHQ', 12 | rank: 61, 13 | flags: [flags.CORS_ALLOWED], 14 | disabled: true, 15 | async scrapeMovie(ctx) { 16 | const id = await getFlixhqId(ctx, ctx.media); 17 | if (!id) throw new NotFoundError('no search results match'); 18 | 19 | const sources = await getFlixhqMovieSources(ctx, ctx.media, id); 20 | 21 | const embeds: SourcererEmbed[] = []; 22 | 23 | for (const source of sources) { 24 | if (source.embed.toLowerCase() === 'upcloud') { 25 | embeds.push({ 26 | embedId: upcloudScraper.id, 27 | url: await getFlixhqSourceDetails(ctx, source.episodeId), 28 | }); 29 | } else if (source.embed.toLowerCase() === 'vidcloud') { 30 | embeds.push({ 31 | embedId: vidCloudScraper.id, 32 | url: await getFlixhqSourceDetails(ctx, source.episodeId), 33 | }); 34 | } 35 | } 36 | 37 | return { 38 | embeds, 39 | }; 40 | }, 41 | async scrapeShow(ctx) { 42 | const id = await getFlixhqId(ctx, ctx.media); 43 | if (!id) throw new NotFoundError('no search results match'); 44 | 45 | const sources = await getFlixhqShowSources(ctx, ctx.media, id); 46 | 47 | const embeds: SourcererEmbed[] = []; 48 | for (const source of sources) { 49 | if (source.embed.toLowerCase() === 'server upcloud') { 50 | embeds.push({ 51 | embedId: upcloudScraper.id, 52 | url: await getFlixhqSourceDetails(ctx, source.episodeId), 53 | }); 54 | } else if (source.embed.toLowerCase() === 'server vidcloud') { 55 | embeds.push({ 56 | embedId: vidCloudScraper.id, 57 | url: await getFlixhqSourceDetails(ctx, source.episodeId), 58 | }); 59 | } 60 | } 61 | 62 | return { 63 | embeds, 64 | }; 65 | }, 66 | }); 67 | -------------------------------------------------------------------------------- /src/providers/sources/flixhq/scrape.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | 3 | import { MovieMedia, ShowMedia } from '@/entrypoint/utils/media'; 4 | import { flixHqBase } from '@/providers/sources/flixhq/common'; 5 | import { ScrapeContext } from '@/utils/context'; 6 | import { NotFoundError } from '@/utils/errors'; 7 | 8 | export async function getFlixhqSourceDetails(ctx: ScrapeContext, sourceId: string): Promise { 9 | const jsonData = await ctx.proxiedFetcher>(`/ajax/sources/${sourceId}`, { 10 | baseUrl: flixHqBase, 11 | }); 12 | 13 | return jsonData.link; 14 | } 15 | 16 | export async function getFlixhqMovieSources(ctx: ScrapeContext, media: MovieMedia, id: string) { 17 | const episodeParts = id.split('-'); 18 | const episodeId = episodeParts[episodeParts.length - 1]; 19 | 20 | const data = await ctx.proxiedFetcher(`/ajax/movie/episodes/${episodeId}`, { 21 | baseUrl: flixHqBase, 22 | }); 23 | 24 | const doc = load(data); 25 | const sourceLinks = doc('.nav-item > a') 26 | .toArray() 27 | .map((el) => { 28 | const query = doc(el); 29 | const embedTitle = query.attr('title'); 30 | const linkId = query.attr('data-linkid'); 31 | if (!embedTitle || !linkId) throw new Error('invalid sources'); 32 | return { 33 | embed: embedTitle, 34 | episodeId: linkId, 35 | }; 36 | }); 37 | 38 | return sourceLinks; 39 | } 40 | 41 | export async function getFlixhqShowSources(ctx: ScrapeContext, media: ShowMedia, id: string) { 42 | const episodeParts = id.split('-'); 43 | const episodeId = episodeParts[episodeParts.length - 1]; 44 | 45 | const seasonsListData = await ctx.proxiedFetcher(`/ajax/season/list/${episodeId}`, { 46 | baseUrl: flixHqBase, 47 | }); 48 | 49 | const seasonsDoc = load(seasonsListData); 50 | const season = seasonsDoc('.dropdown-item') 51 | .toArray() 52 | .find((el) => seasonsDoc(el).text() === `Season ${media.season.number}`)?.attribs['data-id']; 53 | 54 | if (!season) throw new NotFoundError('season not found'); 55 | 56 | const seasonData = await ctx.proxiedFetcher(`/ajax/season/episodes/${season}`, { 57 | baseUrl: flixHqBase, 58 | }); 59 | const seasonDoc = load(seasonData); 60 | const episode = seasonDoc('.nav-item > a') 61 | .toArray() 62 | .map((el) => { 63 | return { 64 | id: seasonDoc(el).attr('data-id'), 65 | title: seasonDoc(el).attr('title'), 66 | }; 67 | }) 68 | .find((e) => e.title?.startsWith(`Eps ${media.episode.number}`))?.id; 69 | 70 | if (!episode) throw new NotFoundError('episode not found'); 71 | 72 | const data = await ctx.proxiedFetcher(`/ajax/episode/servers/${episode}`, { 73 | baseUrl: flixHqBase, 74 | }); 75 | 76 | const doc = load(data); 77 | 78 | const sourceLinks = doc('.nav-item > a') 79 | .toArray() 80 | .map((el) => { 81 | const query = doc(el); 82 | const embedTitle = query.attr('title'); 83 | const linkId = query.attr('data-id'); 84 | if (!embedTitle || !linkId) throw new Error('invalid sources'); 85 | return { 86 | embed: embedTitle, 87 | episodeId: linkId, 88 | }; 89 | }); 90 | 91 | return sourceLinks; 92 | } 93 | -------------------------------------------------------------------------------- /src/providers/sources/flixhq/search.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | 3 | import { MovieMedia, ShowMedia } from '@/entrypoint/utils/media'; 4 | import { flixHqBase } from '@/providers/sources/flixhq/common'; 5 | import { compareMedia, compareTitle } from '@/utils/compare'; 6 | import { ScrapeContext } from '@/utils/context'; 7 | 8 | export async function getFlixhqId(ctx: ScrapeContext, media: MovieMedia | ShowMedia): Promise { 9 | const searchResults = await ctx.proxiedFetcher(`/search/${media.title.replaceAll(/[^a-z0-9A-Z]/g, '-')}`, { 10 | baseUrl: flixHqBase, 11 | }); 12 | 13 | const doc = load(searchResults); 14 | const items = doc('.film_list-wrap > div.flw-item') 15 | .toArray() 16 | .map((el) => { 17 | const query = doc(el); 18 | const id = query.find('div.film-poster > a').attr('href')?.slice(1); 19 | const title = query.find('div.film-detail > h2 > a').attr('title'); 20 | const year = query.find('div.film-detail > div.fd-infor > span:nth-child(1)').text(); 21 | const seasons = year.includes('SS') ? year.split('SS')[1] : '0'; 22 | 23 | if (!id || !title || !year) return null; 24 | return { 25 | id, 26 | title, 27 | year: parseInt(year, 10), 28 | seasons: parseInt(seasons, 10), 29 | }; 30 | }); 31 | 32 | const matchingItem = items.find((v) => { 33 | if (!v) return false; 34 | 35 | if (media.type === 'movie') { 36 | return compareMedia(media, v.title, v.year); 37 | } 38 | 39 | return compareTitle(media.title, v.title) && media.season.number < v.seasons + 1; 40 | }); 41 | 42 | if (!matchingItem) return null; 43 | return matchingItem.id; 44 | } 45 | -------------------------------------------------------------------------------- /src/providers/sources/gomovies/source.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | 3 | import { ScrapeContext } from '@/utils/context'; 4 | import { NotFoundError } from '@/utils/errors'; 5 | 6 | import { gomoviesBase } from '.'; 7 | 8 | export async function getSource(ctx: ScrapeContext, sources: any) { 9 | const upcloud = load(sources)('a[title*="upcloud" i]'); 10 | 11 | const upcloudDataId = upcloud?.attr('data-id') ?? upcloud?.attr('data-linkid'); 12 | 13 | if (!upcloudDataId) throw new NotFoundError('Upcloud source not available'); 14 | 15 | const upcloudSource = await ctx.proxiedFetcher<{ 16 | type: 'iframe' | string; 17 | link: string; 18 | sources: []; 19 | title: string; 20 | tracks: []; 21 | }>(`/ajax/sources/${upcloudDataId}`, { 22 | headers: { 23 | 'X-Requested-With': 'XMLHttpRequest', 24 | }, 25 | baseUrl: gomoviesBase, 26 | }); 27 | 28 | if (!upcloudSource.link || upcloudSource.type !== 'iframe') throw new NotFoundError('No upcloud stream found'); 29 | 30 | return upcloudSource; 31 | } 32 | -------------------------------------------------------------------------------- /src/providers/sources/goojara/getEmbeds.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | 3 | import { ScrapeContext } from '@/utils/context'; 4 | import { makeCookieHeader, parseSetCookie } from '@/utils/cookie'; 5 | 6 | import { EmbedsResult, baseUrl, baseUrl2 } from './type'; 7 | 8 | export async function getEmbeds(ctx: ScrapeContext, id: string): Promise { 9 | const data = await ctx.fetcher.full(`/${id}`, { 10 | baseUrl: baseUrl2, 11 | headers: { 12 | Referer: baseUrl, 13 | cookie: '', 14 | }, 15 | readHeaders: ['Set-Cookie'], 16 | method: 'GET', 17 | }); 18 | 19 | const cookies = parseSetCookie(data.headers.get('Set-Cookie') || ''); 20 | const RandomCookieName = data.body.split(`_3chk('`)[1].split(`'`)[0]; 21 | const RandomCookieValue = data.body.split(`_3chk('`)[1].split(`'`)[2]; 22 | 23 | let aGoozCookie = ''; 24 | let cookie = ''; 25 | if (cookies && cookies.aGooz && RandomCookieName && RandomCookieValue) { 26 | aGoozCookie = cookies.aGooz.value; 27 | cookie = makeCookieHeader({ 28 | aGooz: aGoozCookie, 29 | [RandomCookieName]: RandomCookieValue, 30 | }); 31 | } 32 | 33 | const $ = load(data.body); 34 | 35 | const embedRedirectURLs = $('a') 36 | .map((index, element) => $(element).attr('href')) 37 | .get() 38 | .filter((href) => href && href.includes(`${baseUrl2}/go.php`)); 39 | 40 | const embedPages = await Promise.all( 41 | embedRedirectURLs.map( 42 | (url) => 43 | ctx.fetcher 44 | .full(url, { 45 | headers: { 46 | cookie, 47 | Referer: baseUrl2, 48 | }, 49 | method: 'GET', 50 | }) 51 | .catch(() => null), // Handle errors gracefully 52 | ), 53 | ); 54 | 55 | // Initialize an array to hold the results 56 | const results: EmbedsResult = []; 57 | 58 | // Process each page result 59 | for (const result of embedPages) { 60 | if (result) { 61 | const embedId = ['wootly', 'upstream', 'mixdrop', 'dood'].find((a) => result.finalUrl.includes(a)); 62 | if (embedId) { 63 | results.push({ embedId, url: result.finalUrl }); 64 | } 65 | } 66 | } 67 | 68 | return results; 69 | } 70 | -------------------------------------------------------------------------------- /src/providers/sources/goojara/index.ts: -------------------------------------------------------------------------------- 1 | import { SourcererOutput, makeSourcerer } from '@/providers/base'; 2 | import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context'; 3 | import { NotFoundError } from '@/utils/errors'; 4 | 5 | import { scrapeIds, searchAndFindMedia } from './util'; 6 | 7 | async function universalScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promise { 8 | const goojaraData = await searchAndFindMedia(ctx, ctx.media); 9 | if (!goojaraData) throw new NotFoundError('Media not found'); 10 | 11 | ctx.progress(30); 12 | const embeds = await scrapeIds(ctx, ctx.media, goojaraData); 13 | if (embeds?.length === 0) throw new NotFoundError('No embeds found'); 14 | 15 | ctx.progress(60); 16 | 17 | return { 18 | embeds, 19 | }; 20 | } 21 | 22 | export const goojaraScraper = makeSourcerer({ 23 | id: 'goojara', 24 | name: 'Goojara', 25 | rank: 70, 26 | flags: [], 27 | disabled: true, 28 | scrapeShow: universalScraper, 29 | scrapeMovie: universalScraper, 30 | }); 31 | -------------------------------------------------------------------------------- /src/providers/sources/goojara/type.ts: -------------------------------------------------------------------------------- 1 | export const baseUrl = 'https://www.goojara.to'; 2 | 3 | export const baseUrl2 = 'https://ww1.goojara.to'; 4 | 5 | export type EmbedsResult = { embedId: string; url: string }[]; 6 | 7 | export interface Result { 8 | title: string; 9 | slug: string; 10 | year: string; 11 | type: string; 12 | id_movie?: string; 13 | id_show?: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/providers/sources/goojara/util.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | 3 | import { MovieMedia, ShowMedia } from '@/entrypoint/utils/media'; 4 | import { compareMedia } from '@/utils/compare'; 5 | import { ScrapeContext } from '@/utils/context'; 6 | import { NotFoundError } from '@/utils/errors'; 7 | 8 | import { getEmbeds } from './getEmbeds'; 9 | import { EmbedsResult, Result, baseUrl } from './type'; 10 | 11 | let data; 12 | 13 | // The cookie for this headerData doesn't matter, Goojara just checks it's there. 14 | const headersData = { 15 | cookie: `aGooz=t9pmkdtef1b3lg3pmo1u2re816; bd9aa48e=0d7b89e8c79844e9df07a2; _b414=2151C6B12E2A88379AFF2C0DD65AC8298DEC2BF4; 9d287aaa=8f32ad589e1c4288fe152f`, 16 | Referer: 'https://www.goojara.to/', 17 | }; 18 | 19 | export async function searchAndFindMedia( 20 | ctx: ScrapeContext, 21 | media: MovieMedia | ShowMedia, 22 | ): Promise { 23 | data = await ctx.fetcher(`/xhrr.php`, { 24 | baseUrl, 25 | headers: headersData, 26 | method: 'POST', 27 | body: new URLSearchParams({ q: media.title }), 28 | }); 29 | 30 | const $ = load(data); 31 | 32 | const results: Result[] = []; 33 | 34 | $('.mfeed > li').each((index, element) => { 35 | const title = $(element).find('strong').text(); 36 | const yearMatch = $(element) 37 | .text() 38 | .match(/\((\d{4})\)/); 39 | const typeDiv = $(element).find('div').attr('class'); 40 | const type = typeDiv === 'it' ? 'show' : typeDiv === 'im' ? 'movie' : ''; 41 | const year = yearMatch ? yearMatch[1] : ''; 42 | const slug = $(element).find('a').attr('href')?.split('/')[3]; 43 | 44 | if (!slug) throw new NotFoundError('Not found'); 45 | 46 | if (media.type === type) { 47 | results.push({ title, year, slug, type }); 48 | } 49 | }); 50 | 51 | const result = results.find((res: Result) => compareMedia(media, res.title, Number(res.year))); 52 | return result; 53 | } 54 | 55 | export async function scrapeIds( 56 | ctx: ScrapeContext, 57 | media: MovieMedia | ShowMedia, 58 | result: Result, 59 | ): Promise { 60 | // Find the relevant id 61 | let id = null; 62 | if (media.type === 'movie') { 63 | id = result.slug; 64 | } else if (media.type === 'show') { 65 | data = await ctx.fetcher(`/${result.slug}`, { 66 | baseUrl, 67 | headers: headersData, 68 | method: 'GET', 69 | query: { s: media.season.number.toString() }, 70 | }); 71 | 72 | let episodeId = ''; 73 | 74 | const $2 = load(data); 75 | 76 | $2('.seho').each((index, element) => { 77 | // Extracting the episode number as a string 78 | const episodeNumber = $2(element).find('.seep .sea').text().trim(); 79 | // Comparing with the desired episode number as a string 80 | if (parseInt(episodeNumber, 10) === media.episode.number) { 81 | const href = $2(element).find('.snfo h1 a').attr('href'); 82 | const idMatch = href?.match(/\/([a-zA-Z0-9]+)$/); 83 | if (idMatch && idMatch[1]) { 84 | episodeId = idMatch[1]; 85 | return false; // Break out of the loop once the episode is found 86 | } 87 | } 88 | }); 89 | 90 | id = episodeId; 91 | } 92 | 93 | // Check ID 94 | if (id === null) throw new NotFoundError('Not found'); 95 | 96 | const embeds = await getEmbeds(ctx, id); 97 | return embeds; 98 | } 99 | -------------------------------------------------------------------------------- /src/providers/sources/hdrezka/types.ts: -------------------------------------------------------------------------------- 1 | import { ScrapeMedia } from '@/index'; 2 | 3 | export type VideoLinks = { 4 | success: boolean; 5 | message: string; 6 | premium_content: number; 7 | url: string; 8 | quality: string; 9 | subtitle: boolean | string; 10 | subtitle_lns: boolean; 11 | subtitle_def: boolean; 12 | thumbnails: string; 13 | }; 14 | 15 | export interface MovieData { 16 | id: string | null; 17 | year: number; 18 | type: ScrapeMedia['type']; 19 | url: string; 20 | } 21 | -------------------------------------------------------------------------------- /src/providers/sources/hdrezka/utils.ts: -------------------------------------------------------------------------------- 1 | import { getCaptionTypeFromUrl, labelToLanguageCode } from '@/providers/captions'; 2 | import { FileBasedStream } from '@/providers/streams'; 3 | import { NotFoundError } from '@/utils/errors'; 4 | import { getValidQualityFromString } from '@/utils/quality'; 5 | 6 | function generateRandomFavs(): string { 7 | const randomHex = () => Math.floor(Math.random() * 16).toString(16); 8 | const generateSegment = (length: number) => Array.from({ length }, randomHex).join(''); 9 | 10 | return `${generateSegment(8)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment( 11 | 12, 12 | )}`; 13 | } 14 | 15 | function parseSubtitleLinks(inputString?: string | boolean): FileBasedStream['captions'] { 16 | if (!inputString || typeof inputString === 'boolean') return []; 17 | const linksArray = inputString.split(','); 18 | const captions: FileBasedStream['captions'] = []; 19 | 20 | linksArray.forEach((link) => { 21 | const match = link.match(/\[([^\]]+)\](https?:\/\/\S+?)(?=,\[|$)/); 22 | 23 | if (match) { 24 | const type = getCaptionTypeFromUrl(match[2]); 25 | const language = labelToLanguageCode(match[1]); 26 | if (!type || !language) return; 27 | 28 | captions.push({ 29 | id: match[2], 30 | language, 31 | hasCorsRestrictions: false, 32 | type, 33 | url: match[2], 34 | }); 35 | } 36 | }); 37 | 38 | return captions; 39 | } 40 | 41 | function parseVideoLinks(inputString?: string): FileBasedStream['qualities'] { 42 | if (!inputString) throw new NotFoundError('No video links found'); 43 | const linksArray = inputString.split(','); 44 | const result: FileBasedStream['qualities'] = {}; 45 | 46 | linksArray.forEach((link) => { 47 | const match = link.match(/\[([^]+)](https?:\/\/[^\s,]+\.mp4)/); 48 | if (match) { 49 | const qualityText = match[1]; 50 | const mp4Url = match[2]; 51 | 52 | const numericQualityMatch = qualityText.match(/(\d+p)/); 53 | const quality = numericQualityMatch ? numericQualityMatch[1] : 'Unknown'; 54 | 55 | const validQuality = getValidQualityFromString(quality); 56 | result[validQuality] = { type: 'mp4', url: mp4Url }; 57 | } 58 | }); 59 | 60 | return result; 61 | } 62 | 63 | function extractTitleAndYear(input: string) { 64 | const regex = /^(.*?),.*?(\d{4})/; 65 | const match = input.match(regex); 66 | 67 | if (match) { 68 | const title = match[1]; 69 | const year = match[2]; 70 | return { title: title.trim(), year: year ? parseInt(year, 10) : null }; 71 | } 72 | return null; 73 | } 74 | 75 | export { extractTitleAndYear, parseSubtitleLinks, parseVideoLinks, generateRandomFavs }; 76 | -------------------------------------------------------------------------------- /src/providers/sources/kissasian/common.ts: -------------------------------------------------------------------------------- 1 | import { mp4uploadScraper } from '@/providers/embeds/mp4upload'; 2 | import { streamsbScraper } from '@/providers/embeds/streamsb'; 3 | 4 | export const kissasianBase = 'https://kissasian.sh'; 5 | 6 | export const embedProviders = [ 7 | { 8 | type: mp4uploadScraper.id, 9 | id: 'mp', 10 | }, 11 | { 12 | type: streamsbScraper.id, 13 | id: 'sb', 14 | }, 15 | ]; 16 | -------------------------------------------------------------------------------- /src/providers/sources/kissasian/getEmbeds.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | 3 | import type { ScrapeContext } from '@/utils/context'; 4 | import { NotFoundError } from '@/utils/errors'; 5 | 6 | import { embedProviders, kissasianBase } from './common'; 7 | 8 | export async function getEmbeds( 9 | ctx: ScrapeContext, 10 | targetEpisode: { 11 | number: string; 12 | url?: string; 13 | }, 14 | ) { 15 | let embeds = await Promise.all( 16 | embedProviders.map(async (provider) => { 17 | if (!targetEpisode.url) throw new NotFoundError('Episode not found'); 18 | const watch = await ctx.proxiedFetcher(`${targetEpisode.url}&s=${provider.id}`, { 19 | baseUrl: kissasianBase, 20 | headers: { 21 | accept: 22 | 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', 23 | 'accept-language': 'en-US,en;q=0.9', 24 | 'cache-control': 'no-cache', 25 | pragma: 'no-cache', 26 | 'sec-ch-ua': '"Not)A;Brand";v="24", "Chromium";v="116"', 27 | 'sec-ch-ua-mobile': '?0', 28 | 'sec-ch-ua-platform': '"macOS"', 29 | 'sec-fetch-dest': 'document', 30 | 'sec-fetch-mode': 'navigate', 31 | 'sec-fetch-site': 'cross-site', 32 | 'sec-fetch-user': '?1', 33 | 'upgrade-insecure-requests': '1', 34 | cookie: 35 | '__rd=; ASP.NET_SessionId=jwnl2kmlw5h4mfdaxvpk30q0; k_token=OKbJDFNx3rUtaw7iAA6UxMKSJb79lgZ2X2rVC9aupJhycYQKVSLaW1y2B4K%2f%2fo3i6BuzhXgfkJGmKlKH6LpNlKPPpZUk31n9DapfMdJgjlLExgrPS3jpSKwGnNUI%2bOpNpZu9%2fFnkLZRxvVKCa8APMxrck1tYkKXWqfyJJh8%2b7hQTI1wfAOU%2fLEouHhtQGL%2fReTzElw2LQ0XSL1pjs%2fkWW3rM3of2je7Oo13I%2f7olLFuiJUVWyNbn%2fYKSgNrm%2bQ3p', 36 | }, 37 | }); 38 | 39 | const watchPage = load(watch); 40 | 41 | const embedUrl = watchPage('#my_video_1').attr('src'); 42 | 43 | if (!embedUrl) throw new Error('Embed not found'); 44 | 45 | return { 46 | embedId: provider.id, 47 | url: embedUrl, 48 | }; 49 | }), 50 | ); 51 | 52 | embeds = embeds.filter((e) => !!e.url); 53 | 54 | return embeds; 55 | } 56 | -------------------------------------------------------------------------------- /src/providers/sources/kissasian/getEpisodes.ts: -------------------------------------------------------------------------------- 1 | import type { CheerioAPI } from 'cheerio'; 2 | 3 | export function getEpisodes(dramaPage: CheerioAPI) { 4 | const episodesEl = dramaPage('.episodeSub'); 5 | 6 | return episodesEl 7 | .toArray() 8 | .map((ep) => { 9 | const number = dramaPage(ep).find('.episodeSub a').text().split('Episode')[1]?.trim(); 10 | const url = dramaPage(ep).find('.episodeSub a').attr('href'); 11 | return { number, url }; 12 | }) 13 | .filter((e) => !!e.url); 14 | } 15 | -------------------------------------------------------------------------------- /src/providers/sources/kissasian/index.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | 3 | import { flags } from '@/entrypoint/utils/targets'; 4 | import { makeSourcerer } from '@/providers/base'; 5 | import { NotFoundError } from '@/utils/errors'; 6 | 7 | import { kissasianBase } from './common'; 8 | import { getEmbeds } from './getEmbeds'; 9 | import { getEpisodes } from './getEpisodes'; 10 | import { search } from './search'; 11 | 12 | export const kissAsianScraper = makeSourcerer({ 13 | id: 'kissasian', 14 | name: 'KissAsian', 15 | rank: 40, 16 | flags: [flags.CORS_ALLOWED], 17 | disabled: true, 18 | 19 | async scrapeShow(ctx) { 20 | const seasonNumber = ctx.media.season.number; 21 | const episodeNumber = ctx.media.episode.number; 22 | 23 | const dramas = await search(ctx, ctx.media.title, seasonNumber); 24 | 25 | const targetDrama = dramas.find((d) => d.name?.toLowerCase() === ctx.media.title.toLowerCase()) ?? dramas[0]; 26 | if (!targetDrama) throw new NotFoundError('Drama not found'); 27 | 28 | ctx.progress(30); 29 | 30 | const drama = await ctx.proxiedFetcher(targetDrama.url, { 31 | baseUrl: kissasianBase, 32 | }); 33 | 34 | const dramaPage = load(drama); 35 | 36 | const episodes = await getEpisodes(dramaPage); 37 | 38 | const targetEpisode = episodes.find((e) => e.number === `${episodeNumber}`); 39 | if (!targetEpisode?.url) throw new NotFoundError('Episode not found'); 40 | 41 | ctx.progress(70); 42 | 43 | const embeds = await getEmbeds(ctx, targetEpisode); 44 | 45 | return { 46 | embeds, 47 | }; 48 | }, 49 | async scrapeMovie(ctx) { 50 | const dramas = await search(ctx, ctx.media.title, undefined); 51 | 52 | const targetDrama = dramas.find((d) => d.name?.toLowerCase() === ctx.media.title.toLowerCase()) ?? dramas[0]; 53 | if (!targetDrama) throw new NotFoundError('Drama not found'); 54 | 55 | ctx.progress(30); 56 | 57 | const drama = await ctx.proxiedFetcher(targetDrama.url, { 58 | baseUrl: kissasianBase, 59 | }); 60 | 61 | const dramaPage = load(drama); 62 | 63 | const episodes = getEpisodes(dramaPage); 64 | 65 | const targetEpisode = episodes[0]; 66 | if (!targetEpisode?.url) throw new NotFoundError('Episode not found'); 67 | 68 | ctx.progress(70); 69 | 70 | const embeds = await getEmbeds(ctx, targetEpisode); 71 | 72 | return { 73 | embeds, 74 | }; 75 | }, 76 | }); 77 | -------------------------------------------------------------------------------- /src/providers/sources/kissasian/search.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | import FormData from 'form-data'; 3 | 4 | import { ScrapeContext } from '@/utils/context'; 5 | 6 | import { kissasianBase } from './common'; 7 | 8 | export async function search(ctx: ScrapeContext, title: string, seasonNumber?: number) { 9 | const searchForm = new FormData(); 10 | searchForm.append('keyword', `${title} ${seasonNumber ?? ''}`.trim()); 11 | searchForm.append('type', 'Drama'); 12 | 13 | const searchResults = await ctx.proxiedFetcher('/Search/SearchSuggest', { 14 | baseUrl: kissasianBase, 15 | method: 'POST', 16 | body: searchForm, 17 | }); 18 | 19 | const searchPage = load(searchResults); 20 | 21 | return Array.from(searchPage('a')).map((drama) => { 22 | return { 23 | name: searchPage(drama).text(), 24 | url: drama.attribs.href, 25 | }; 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/providers/sources/lookmovie/index.ts: -------------------------------------------------------------------------------- 1 | import { flags } from '@/entrypoint/utils/targets'; 2 | import { SourcererOutput, makeSourcerer } from '@/providers/base'; 3 | import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context'; 4 | import { NotFoundError } from '@/utils/errors'; 5 | 6 | import { scrape, searchAndFindMedia } from './util'; 7 | 8 | async function universalScraper(ctx: MovieScrapeContext | ShowScrapeContext): Promise { 9 | const lookmovieData = await searchAndFindMedia(ctx, ctx.media); 10 | if (!lookmovieData) throw new NotFoundError('Media not found'); 11 | 12 | ctx.progress(30); 13 | const video = await scrape(ctx, ctx.media, lookmovieData); 14 | if (!video.playlist) throw new NotFoundError('No video found'); 15 | 16 | ctx.progress(60); 17 | 18 | return { 19 | embeds: [], 20 | stream: [ 21 | { 22 | id: 'primary', 23 | playlist: video.playlist, 24 | type: 'hls', 25 | flags: [flags.IP_LOCKED], 26 | captions: video.captions, 27 | }, 28 | ], 29 | }; 30 | } 31 | 32 | export const lookmovieScraper = makeSourcerer({ 33 | id: 'lookmovie', 34 | name: 'LookMovie', 35 | disabled: true, 36 | rank: 50, 37 | flags: [flags.IP_LOCKED], 38 | scrapeShow: universalScraper, 39 | scrapeMovie: universalScraper, 40 | }); 41 | -------------------------------------------------------------------------------- /src/providers/sources/lookmovie/type.ts: -------------------------------------------------------------------------------- 1 | import { MovieMedia } from '@/entrypoint/utils/media'; 2 | 3 | // ! Types 4 | interface BaseConfig { 5 | /** The website's slug. Formatted as `1839578-person-of-interest-2011` */ 6 | slug: string; 7 | /** Type of request */ 8 | type: 'show' | 'movie'; 9 | /** Hash */ 10 | hash: string; 11 | /** Hash expiry */ 12 | expires: number; 13 | } 14 | interface TvConfig extends BaseConfig { 15 | /** Type of request */ 16 | type: 'show'; 17 | /** The episode ID for a TV show. Given in search and URL */ 18 | episodeId: string; 19 | } 20 | interface MovieConfig extends BaseConfig { 21 | /** Type of request */ 22 | type: 'movie'; 23 | /** Movie's id */ 24 | id_movie: string; 25 | } 26 | export type Config = MovieConfig | TvConfig; 27 | 28 | export interface episodeObj { 29 | season: string; 30 | episode: string; 31 | id: string; 32 | } 33 | 34 | export interface ShowDataResult { 35 | episodes: episodeObj[]; 36 | } 37 | 38 | interface VideoSources { 39 | [key: string]: string; 40 | } 41 | 42 | interface VideoSubtitles { 43 | id?: number; 44 | id_movie?: number; 45 | url: string; 46 | language: string; 47 | shard?: string; 48 | } 49 | 50 | export interface StreamsDataResult { 51 | streams: VideoSources; 52 | subtitles: VideoSubtitles[]; 53 | } 54 | 55 | export interface ResultItem { 56 | title: string; 57 | slug: string; 58 | year: string; 59 | id_movie: string; 60 | id_show: string; 61 | } 62 | 63 | export interface Result { 64 | title(media: MovieMedia, title: any, arg2: number): boolean; 65 | year(year: any): number | undefined; 66 | id_movie: any; 67 | id_show: string; 68 | items: ResultItem[]; 69 | } 70 | -------------------------------------------------------------------------------- /src/providers/sources/lookmovie/util.ts: -------------------------------------------------------------------------------- 1 | import { MovieMedia, ShowMedia } from '@/entrypoint/utils/media'; 2 | import { compareMedia } from '@/utils/compare'; 3 | import { ScrapeContext } from '@/utils/context'; 4 | import { NotFoundError } from '@/utils/errors'; 5 | 6 | import { Result, ResultItem, ShowDataResult, episodeObj } from './type'; 7 | import { getVideo } from './video'; 8 | 9 | export const baseUrl = 'https://lmscript.xyz'; 10 | 11 | export async function searchAndFindMedia( 12 | ctx: ScrapeContext, 13 | media: MovieMedia | ShowMedia, 14 | ): Promise { 15 | if (media.type === 'show') { 16 | const searchRes = await ctx.fetcher(`/v1/shows`, { 17 | baseUrl, 18 | query: { 'filters[q]': media.title }, 19 | }); 20 | 21 | const results = searchRes.items; 22 | 23 | const result = results.find((res: ResultItem) => compareMedia(media, res.title, Number(res.year))); 24 | return result; 25 | } 26 | if (media.type === 'movie') { 27 | const searchRes = await ctx.fetcher(`/v1/movies`, { 28 | baseUrl, 29 | query: { 'filters[q]': media.title }, 30 | }); 31 | 32 | const results = searchRes.items; 33 | const result = results.find((res: ResultItem) => compareMedia(media, res.title, Number(res.year))); 34 | return result; 35 | } 36 | } 37 | 38 | export async function scrape(ctx: ScrapeContext, media: MovieMedia | ShowMedia, result: ResultItem) { 39 | // Find the relevant id 40 | let id = null; 41 | if (media.type === 'movie') { 42 | id = result.id_movie; 43 | } else if (media.type === 'show') { 44 | const data = await ctx.fetcher(`/v1/shows`, { 45 | baseUrl, 46 | query: { expand: 'episodes', id: result.id_show }, 47 | }); 48 | 49 | const episode = data.episodes?.find((v: episodeObj) => { 50 | return Number(v.season) === Number(media.season.number) && Number(v.episode) === Number(media.episode.number); 51 | }); 52 | 53 | if (episode) id = episode.id; 54 | } 55 | 56 | // Check ID 57 | if (id === null) throw new NotFoundError('Not found'); 58 | 59 | const video = await getVideo(ctx, id, media); 60 | return video; 61 | } 62 | -------------------------------------------------------------------------------- /src/providers/sources/lookmovie/video.ts: -------------------------------------------------------------------------------- 1 | import { MovieMedia, ShowMedia } from '@/entrypoint/utils/media'; 2 | import { Caption, labelToLanguageCode, removeDuplicatedLanguages } from '@/providers/captions'; 3 | import { ScrapeContext } from '@/utils/context'; 4 | 5 | import { StreamsDataResult } from './type'; 6 | import { baseUrl } from './util'; 7 | 8 | export async function getVideoSources( 9 | ctx: ScrapeContext, 10 | id: string, 11 | media: MovieMedia | ShowMedia, 12 | ): Promise { 13 | // Fetch video sources 14 | 15 | let path = ''; 16 | if (media.type === 'show') { 17 | path = `/v1/episodes/view`; 18 | } else if (media.type === 'movie') { 19 | path = `/v1/movies/view`; 20 | } 21 | const data = await ctx.fetcher(path, { 22 | baseUrl, 23 | query: { expand: 'streams,subtitles', id }, 24 | }); 25 | return data; 26 | } 27 | 28 | export async function getVideo( 29 | ctx: ScrapeContext, 30 | id: string, 31 | media: MovieMedia | ShowMedia, 32 | ): Promise<{ playlist: string | null; captions: Caption[] }> { 33 | // Get sources 34 | const data = await getVideoSources(ctx, id, media); 35 | const videoSources = data.streams; 36 | 37 | // Find video URL and return it 38 | const opts = ['auto', '1080p', '1080', '720p', '720', '480p', '480', '240p', '240', '360p', '360', '144', '144p']; 39 | 40 | let videoUrl: string | null = null; 41 | for (const res of opts) { 42 | if (videoSources[res] && !videoUrl) { 43 | videoUrl = videoSources[res]; 44 | } 45 | } 46 | 47 | let captions: Caption[] = []; 48 | 49 | for (const sub of data.subtitles) { 50 | const language = labelToLanguageCode(sub.language); 51 | if (!language) continue; 52 | captions.push({ 53 | id: sub.url, 54 | type: 'vtt', 55 | url: `${baseUrl}${sub.url}`, 56 | hasCorsRestrictions: false, 57 | language, 58 | }); 59 | } 60 | 61 | captions = removeDuplicatedLanguages(captions); 62 | 63 | return { 64 | playlist: videoUrl, 65 | captions, 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /src/providers/sources/nepu/index.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | 3 | import { SourcererOutput, makeSourcerer } from '@/providers/base'; 4 | import { compareTitle } from '@/utils/compare'; 5 | import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context'; 6 | import { NotFoundError } from '@/utils/errors'; 7 | 8 | import { SearchResults } from './types'; 9 | 10 | const nepuBase = 'https://nepu.to'; 11 | const nepuReferer = `${nepuBase}/`; 12 | 13 | const universalScraper = async (ctx: MovieScrapeContext | ShowScrapeContext) => { 14 | const searchResultRequest = await ctx.proxiedFetcher('/ajax/posts', { 15 | baseUrl: nepuBase, 16 | query: { 17 | q: ctx.media.title, 18 | }, 19 | }); 20 | 21 | // json isn't parsed by proxiedFetcher due to content-type being text/html. 22 | const searchResult = JSON.parse(searchResultRequest) as SearchResults; 23 | 24 | const show = searchResult.data.find((item) => { 25 | if (!item) return false; 26 | if (ctx.media.type === 'movie' && item.type !== 'Movie') return false; 27 | if (ctx.media.type === 'show' && item.type !== 'Serie') return false; 28 | 29 | return compareTitle(ctx.media.title, item.name); 30 | }); 31 | 32 | if (!show) throw new NotFoundError('No watchable item found'); 33 | 34 | let videoUrl = show.url; 35 | 36 | if (ctx.media.type === 'show') { 37 | videoUrl = `${show.url}/season/${ctx.media.season.number}/episode/${ctx.media.episode.number}`; 38 | } 39 | 40 | const videoPage = await ctx.proxiedFetcher(videoUrl, { 41 | baseUrl: nepuBase, 42 | }); 43 | const videoPage$ = load(videoPage); 44 | const embedId = videoPage$('a[data-embed]').attr('data-embed'); 45 | 46 | if (!embedId) throw new NotFoundError('No embed found.'); 47 | 48 | const playerPage = await ctx.proxiedFetcher('/ajax/embed', { 49 | method: 'POST', 50 | baseUrl: nepuBase, 51 | body: new URLSearchParams({ id: embedId }), 52 | }); 53 | 54 | const streamUrl = playerPage.match(/"file":"(http[^"]+)"/); 55 | 56 | if (!streamUrl) throw new NotFoundError('No stream found.'); 57 | 58 | return { 59 | embeds: [], 60 | stream: [ 61 | { 62 | id: 'primary', 63 | captions: [], 64 | playlist: streamUrl[1], 65 | type: 'hls', 66 | flags: [], 67 | headers: { 68 | Origin: nepuBase, 69 | Referer: nepuReferer, 70 | }, 71 | }, 72 | ], 73 | } as SourcererOutput; 74 | }; 75 | 76 | export const nepuScraper = makeSourcerer({ 77 | id: 'nepu', 78 | name: 'Nepu', 79 | rank: 80, 80 | flags: [], 81 | disabled: true, 82 | scrapeMovie: universalScraper, 83 | scrapeShow: universalScraper, 84 | }); 85 | -------------------------------------------------------------------------------- /src/providers/sources/nepu/types.ts: -------------------------------------------------------------------------------- 1 | export type SearchResults = { 2 | data: { 3 | id: number; 4 | name: string; 5 | url: string; 6 | type: 'Movie' | 'Serie'; 7 | }[]; 8 | }; 9 | -------------------------------------------------------------------------------- /src/providers/sources/primewire/common.ts: -------------------------------------------------------------------------------- 1 | export const primewireBase = 'https://www.primewire.tf'; 2 | export const primewireApiKey = atob('bHpRUHNYU0tjRw=='); 3 | -------------------------------------------------------------------------------- /src/providers/sources/primewire/decryption/README.md: -------------------------------------------------------------------------------- 1 | # Maintaining decryption 2 | 3 | This folder contains the decryption logic for the primewire provider 4 | 5 | The code in `blowfish.ts` is a de-obfuscated version of the original code that is used to decrypt the video links. You can find original the code [in this JavaScript file](https://www.primewire.tf/js/app-21205005105979fb964d17bf03570023.js?vsn=d]) by searching for the keyword "sBox0". 6 | 7 | The code is minified, so use prettier to deobfuscate it. In the case that the URL changes, you can find it used in the [primewire homepage](https://www.primewire.tf/). 8 | -------------------------------------------------------------------------------- /src/providers/sources/remotestream.ts: -------------------------------------------------------------------------------- 1 | import { flags } from '@/entrypoint/utils/targets'; 2 | import { makeSourcerer } from '@/providers/base'; 3 | import { NotFoundError } from '@/utils/errors'; 4 | 5 | const remotestreamBase = atob('aHR0cHM6Ly9mc2IuOG1ldDNkdGpmcmNxY2hjb25xcGtsd3hzeGIyb2N1bWMuc3RyZWFt'); 6 | 7 | const origin = 'https://remotestre.am'; 8 | const referer = 'https://remotestre.am/'; 9 | 10 | export const remotestreamScraper = makeSourcerer({ 11 | id: 'remotestream', 12 | name: 'Remote Stream', 13 | disabled: true, 14 | rank: 20, 15 | flags: [flags.CORS_ALLOWED], 16 | async scrapeShow(ctx) { 17 | const seasonNumber = ctx.media.season.number; 18 | const episodeNumber = ctx.media.episode.number; 19 | 20 | const playlistLink = `${remotestreamBase}/Shows/${ctx.media.tmdbId}/${seasonNumber}/${episodeNumber}/${episodeNumber}.m3u8`; 21 | 22 | ctx.progress(30); 23 | const streamRes = await ctx.proxiedFetcher.full(playlistLink, { 24 | method: 'GET', 25 | readHeaders: ['content-type'], 26 | headers: { 27 | Referer: referer, 28 | }, 29 | }); 30 | if (!streamRes.headers.get('content-type')?.toLowerCase().includes('application/x-mpegurl')) 31 | throw new NotFoundError('No watchable item found'); 32 | ctx.progress(90); 33 | 34 | return { 35 | embeds: [], 36 | stream: [ 37 | { 38 | id: 'primary', 39 | captions: [], 40 | playlist: playlistLink, 41 | type: 'hls', 42 | flags: [flags.CORS_ALLOWED], 43 | preferredHeaders: { 44 | Referer: referer, 45 | Origin: origin, 46 | }, 47 | }, 48 | ], 49 | }; 50 | }, 51 | async scrapeMovie(ctx) { 52 | const playlistLink = `${remotestreamBase}/Movies/${ctx.media.tmdbId}/${ctx.media.tmdbId}.m3u8`; 53 | 54 | ctx.progress(30); 55 | const streamRes = await ctx.proxiedFetcher.full(playlistLink, { 56 | method: 'GET', 57 | readHeaders: ['content-type'], 58 | headers: { 59 | Referer: referer, 60 | }, 61 | }); 62 | if (!streamRes.headers.get('content-type')?.toLowerCase().includes('application/x-mpegurl')) 63 | throw new NotFoundError('No watchable item found'); 64 | ctx.progress(90); 65 | 66 | return { 67 | embeds: [], 68 | stream: [ 69 | { 70 | id: 'primary', 71 | captions: [], 72 | playlist: playlistLink, 73 | type: 'hls', 74 | flags: [flags.CORS_ALLOWED], 75 | preferredHeaders: { 76 | Referer: referer, 77 | Origin: origin, 78 | }, 79 | }, 80 | ], 81 | }; 82 | }, 83 | }); 84 | -------------------------------------------------------------------------------- /src/providers/sources/ridomovies/index.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | 3 | import { flags } from '@/entrypoint/utils/targets'; 4 | import { SourcererEmbed, makeSourcerer } from '@/providers/base'; 5 | import { closeLoadScraper } from '@/providers/embeds/closeload'; 6 | import { ridooScraper } from '@/providers/embeds/ridoo'; 7 | import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context'; 8 | import { NotFoundError } from '@/utils/errors'; 9 | 10 | import { IframeSourceResult, SearchResult } from './types'; 11 | 12 | const ridoMoviesBase = `https://ridomovies.tv`; 13 | const ridoMoviesApiBase = `${ridoMoviesBase}/core/api`; 14 | 15 | const universalScraper = async (ctx: MovieScrapeContext | ShowScrapeContext) => { 16 | const searchResult = await ctx.proxiedFetcher('/search', { 17 | baseUrl: ridoMoviesApiBase, 18 | query: { 19 | q: ctx.media.title, 20 | }, 21 | }); 22 | const show = searchResult.data.items[0]; 23 | if (!show) throw new NotFoundError('No watchable item found'); 24 | 25 | let iframeSourceUrl = `/${show.fullSlug}/videos`; 26 | 27 | if (ctx.media.type === 'show') { 28 | const showPageResult = await ctx.proxiedFetcher(`/${show.fullSlug}`, { 29 | baseUrl: ridoMoviesBase, 30 | }); 31 | const fullEpisodeSlug = `season-${ctx.media.season.number}/episode-${ctx.media.episode.number}`; 32 | const regexPattern = new RegExp( 33 | `\\\\"id\\\\":\\\\"(\\d+)\\\\"(?=.*?\\\\\\"fullSlug\\\\\\":\\\\\\"[^"]*${fullEpisodeSlug}[^"]*\\\\\\")`, 34 | 'g', 35 | ); 36 | const matches = [...showPageResult.matchAll(regexPattern)]; 37 | const episodeIds = matches.map((match) => match[1]); 38 | if (episodeIds.length === 0) throw new NotFoundError('No watchable item found'); 39 | const episodeId = episodeIds.at(-1); 40 | iframeSourceUrl = `/episodes/${episodeId}/videos`; 41 | } 42 | 43 | const iframeSource = await ctx.proxiedFetcher(iframeSourceUrl, { 44 | baseUrl: ridoMoviesApiBase, 45 | }); 46 | const iframeSource$ = load(iframeSource.data[0].url); 47 | const iframeUrl = iframeSource$('iframe').attr('data-src'); 48 | if (!iframeUrl) throw new NotFoundError('No watchable item found'); 49 | 50 | const embeds: SourcererEmbed[] = []; 51 | if (iframeUrl.includes('closeload')) { 52 | embeds.push({ 53 | embedId: closeLoadScraper.id, 54 | url: iframeUrl, 55 | }); 56 | } 57 | if (iframeUrl.includes('ridoo')) { 58 | embeds.push({ 59 | embedId: ridooScraper.id, 60 | url: iframeUrl, 61 | }); 62 | } 63 | return { 64 | embeds, 65 | }; 66 | }; 67 | 68 | export const ridooMoviesScraper = makeSourcerer({ 69 | id: 'ridomovies', 70 | name: 'RidoMovies', 71 | rank: 100, 72 | flags: [flags.CORS_ALLOWED], 73 | scrapeMovie: universalScraper, 74 | scrapeShow: universalScraper, 75 | }); 76 | -------------------------------------------------------------------------------- /src/providers/sources/ridomovies/types.ts: -------------------------------------------------------------------------------- 1 | export interface Content { 2 | id: string; 3 | type: string; 4 | slug: string; 5 | title: string; 6 | metaTitle: any; 7 | metaDescription: any; 8 | usersOnly: boolean; 9 | userLevel: number; 10 | vipOnly: boolean; 11 | copyrighted: boolean; 12 | status: string; 13 | publishedAt: string; 14 | createdAt: string; 15 | updatedAt: string; 16 | fullSlug: string; 17 | } 18 | 19 | export interface Contentable { 20 | id: string; 21 | contentId: string; 22 | revisionId: any; 23 | originalTitle: string; 24 | overview: string; 25 | releaseDate: string; 26 | releaseYear: string; 27 | videoNote: any; 28 | posterNote: any; 29 | userRating: number; 30 | imdbRating: number; 31 | imdbVotes: number; 32 | imdbId: string; 33 | duration: number; 34 | countryCode: string; 35 | posterPath: string; 36 | backdropPath: string; 37 | apiPosterPath: string; 38 | apiBackdropPath: string; 39 | trailerUrl: string; 40 | mpaaRating: string; 41 | tmdbId: number; 42 | manual: number; 43 | directorId: number; 44 | createdAt: string; 45 | updatedAt: string; 46 | content: Content; 47 | } 48 | 49 | export interface SearchResultItem { 50 | id: string; 51 | type: string; 52 | slug: string; 53 | title: string; 54 | metaTitle: any; 55 | metaDescription: any; 56 | usersOnly: boolean; 57 | userLevel: number; 58 | vipOnly: boolean; 59 | copyrighted: boolean; 60 | status: string; 61 | publishedAt: string; 62 | createdAt: string; 63 | updatedAt: string; 64 | fullSlug: string; 65 | contentable: Contentable; 66 | } 67 | 68 | export type SearchResult = { 69 | data: { 70 | items: SearchResultItem[]; 71 | }; 72 | }; 73 | 74 | export type IframeSourceResult = { 75 | data: { 76 | url: string; 77 | }[]; 78 | }; 79 | -------------------------------------------------------------------------------- /src/providers/sources/showbox/common.ts: -------------------------------------------------------------------------------- 1 | // We do not want content scanners to notice this scraping going on so we've hidden all constants 2 | // The source has its origins in China so I added some extra security with banned words 3 | // Mayhaps a tiny bit unethical, but this source is just too good :) 4 | // If you are copying this code please use precautions so they do not change their api. 5 | 6 | export const iv = atob('d0VpcGhUbiE='); 7 | export const key = atob('MTIzZDZjZWRmNjI2ZHk1NDIzM2FhMXc2'); 8 | export const apiUrls = [ 9 | atob('aHR0cHM6Ly9zaG93Ym94LnNoZWd1Lm5ldC9hcGkvYXBpX2NsaWVudC9pbmRleC8='), 10 | atob('aHR0cHM6Ly9tYnBhcGkuc2hlZ3UubmV0L2FwaS9hcGlfY2xpZW50L2luZGV4Lw=='), 11 | ]; 12 | export const appKey = atob('bW92aWVib3g='); 13 | export const appId = atob('Y29tLnRkby5zaG93Ym94'); 14 | export const captionsDomains = [atob('bWJwaW1hZ2VzLmNodWF4aW4uY29t'), atob('aW1hZ2VzLnNoZWd1Lm5ldA==')]; 15 | 16 | export const showboxBase = 'https://www.showbox.media'; 17 | -------------------------------------------------------------------------------- /src/providers/sources/showbox/crypto.ts: -------------------------------------------------------------------------------- 1 | import CryptoJS from 'crypto-js'; 2 | 3 | import { iv, key } from './common'; 4 | 5 | export function encrypt(str: string) { 6 | return CryptoJS.TripleDES.encrypt(str, CryptoJS.enc.Utf8.parse(key), { 7 | iv: CryptoJS.enc.Utf8.parse(iv), 8 | }).toString(); 9 | } 10 | 11 | export function getVerify(str: string, str2: string, str3: string) { 12 | if (str) { 13 | return CryptoJS.MD5(CryptoJS.MD5(str2).toString() + str3 + str).toString(); 14 | } 15 | return null; 16 | } 17 | -------------------------------------------------------------------------------- /src/providers/sources/showbox/index.ts: -------------------------------------------------------------------------------- 1 | import { flags } from '@/entrypoint/utils/targets'; 2 | import { SourcererOutput, makeSourcerer } from '@/providers/base'; 3 | import { febboxMp4Scraper } from '@/providers/embeds/febbox/mp4'; 4 | import { compareTitle } from '@/utils/compare'; 5 | import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context'; 6 | import { NotFoundError } from '@/utils/errors'; 7 | 8 | import { sendRequest } from './sendRequest'; 9 | 10 | async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promise { 11 | const searchQuery = { 12 | module: 'Search4', 13 | page: '1', 14 | type: 'all', 15 | keyword: ctx.media.title, 16 | pagelimit: '20', 17 | }; 18 | 19 | const searchRes = (await sendRequest(ctx, searchQuery, true)).data.list; 20 | ctx.progress(50); 21 | 22 | const showboxEntry = searchRes.find( 23 | (res: any) => compareTitle(res.title, ctx.media.title) && res.year === Number(ctx.media.releaseYear), 24 | ); 25 | if (!showboxEntry) throw new NotFoundError('No entry found'); 26 | 27 | const id = showboxEntry.id; 28 | const season = ctx.media.type === 'show' ? ctx.media.season.number : ''; 29 | const episode = ctx.media.type === 'show' ? ctx.media.episode.number : ''; 30 | 31 | return { 32 | embeds: [ 33 | { 34 | embedId: febboxMp4Scraper.id, 35 | url: `/${ctx.media.type}/${id}/${season}/${episode}`, 36 | }, 37 | ], 38 | }; 39 | } 40 | 41 | export const showboxScraper = makeSourcerer({ 42 | id: 'showbox', 43 | name: 'Showbox', 44 | rank: 150, 45 | flags: [flags.CORS_ALLOWED, flags.CF_BLOCKED], 46 | scrapeShow: comboScraper, 47 | scrapeMovie: comboScraper, 48 | }); 49 | -------------------------------------------------------------------------------- /src/providers/sources/showbox/sendRequest.ts: -------------------------------------------------------------------------------- 1 | import CryptoJS from 'crypto-js'; 2 | import { customAlphabet } from 'nanoid'; 3 | 4 | import type { ScrapeContext } from '@/utils/context'; 5 | 6 | import { apiUrls, appId, appKey, key } from './common'; 7 | import { encrypt, getVerify } from './crypto'; 8 | 9 | const randomId = customAlphabet('1234567890abcdef'); 10 | const expiry = () => Math.floor(Date.now() / 1000 + 60 * 60 * 12); 11 | 12 | export const sendRequest = async (ctx: ScrapeContext, data: object, altApi = false) => { 13 | const defaultData = { 14 | childmode: '0', 15 | app_version: '11.5', 16 | appid: appId, 17 | lang: 'en', 18 | expired_date: `${expiry()}`, 19 | platform: 'android', 20 | channel: 'Website', 21 | }; 22 | const encryptedData = encrypt( 23 | JSON.stringify({ 24 | ...defaultData, 25 | ...data, 26 | }), 27 | ); 28 | const appKeyHash = CryptoJS.MD5(appKey).toString(); 29 | const verify = getVerify(encryptedData, appKey, key); 30 | const body = JSON.stringify({ 31 | app_key: appKeyHash, 32 | verify, 33 | encrypt_data: encryptedData, 34 | }); 35 | const base64body = btoa(body); 36 | 37 | const formatted = new URLSearchParams(); 38 | formatted.append('data', base64body); 39 | formatted.append('appid', '27'); 40 | formatted.append('platform', 'android'); 41 | formatted.append('version', '129'); 42 | formatted.append('medium', 'Website'); 43 | formatted.append('token', randomId(32)); 44 | 45 | const requestUrl = altApi ? apiUrls[1] : apiUrls[0]; 46 | 47 | const response = await ctx.proxiedFetcher(requestUrl, { 48 | method: 'POST', 49 | headers: { 50 | Platform: 'android', 51 | 'Content-Type': 'application/x-www-form-urlencoded', 52 | 'User-Agent': 'okhttp/3.2.0', 53 | }, 54 | body: formatted, 55 | }); 56 | return JSON.parse(response); 57 | }; 58 | -------------------------------------------------------------------------------- /src/providers/sources/smashystream/index.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | 3 | import { flags } from '@/entrypoint/utils/targets'; 4 | import { SourcererEmbed, SourcererOutput, makeSourcerer } from '@/providers/base'; 5 | import { smashyStreamDScraper } from '@/providers/embeds/smashystream/dued'; 6 | import { smashyStreamFScraper } from '@/providers/embeds/smashystream/video1'; 7 | import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context'; 8 | 9 | const smashyStreamBase = 'https://embed.smashystream.com'; 10 | const referer = 'https://smashystream.com/'; 11 | 12 | const universalScraper = async (ctx: ShowScrapeContext | MovieScrapeContext): Promise => { 13 | const mainPage = await ctx.proxiedFetcher('/playere.php', { 14 | query: { 15 | tmdb: ctx.media.tmdbId, 16 | ...(ctx.media.type === 'show' && { 17 | season: ctx.media.season.number.toString(), 18 | episode: ctx.media.episode.number.toString(), 19 | }), 20 | }, 21 | headers: { 22 | Referer: referer, 23 | }, 24 | baseUrl: smashyStreamBase, 25 | }); 26 | 27 | ctx.progress(30); 28 | 29 | const mainPage$ = load(mainPage); 30 | const sourceUrls = mainPage$('.dropdown-menu a[data-url]') 31 | .map((_, el) => mainPage$(el).attr('data-url')) 32 | .get(); 33 | 34 | const embeds: SourcererEmbed[] = []; 35 | for (const sourceUrl of sourceUrls) { 36 | if (sourceUrl.includes('video1d.php')) { 37 | embeds.push({ 38 | embedId: smashyStreamFScraper.id, 39 | url: sourceUrl, 40 | }); 41 | } 42 | if (sourceUrl.includes('dued.php')) { 43 | embeds.push({ 44 | embedId: smashyStreamDScraper.id, 45 | url: sourceUrl, 46 | }); 47 | } 48 | } 49 | 50 | ctx.progress(60); 51 | 52 | return { 53 | embeds, 54 | }; 55 | }; 56 | 57 | export const smashyStreamScraper = makeSourcerer({ 58 | id: 'smashystream', 59 | name: 'SmashyStream', 60 | rank: 30, 61 | flags: [flags.CORS_ALLOWED], 62 | disabled: true, 63 | scrapeMovie: universalScraper, 64 | scrapeShow: universalScraper, 65 | }); 66 | -------------------------------------------------------------------------------- /src/providers/sources/vidsrc/common.ts: -------------------------------------------------------------------------------- 1 | export const vidsrcBase = 'https://vidsrc.me'; 2 | export const vidsrcRCPBase = 'https://rcp.vidsrc.me'; 3 | -------------------------------------------------------------------------------- /src/providers/sources/vidsrc/index.ts: -------------------------------------------------------------------------------- 1 | import { flags } from '@/entrypoint/utils/targets'; 2 | import { makeSourcerer } from '@/providers/base'; 3 | import { scrapeMovie } from '@/providers/sources/vidsrc/scrape-movie'; 4 | import { scrapeShow } from '@/providers/sources/vidsrc/scrape-show'; 5 | 6 | export const vidsrcScraper = makeSourcerer({ 7 | id: 'vidsrc', 8 | name: 'VidSrc', 9 | rank: 90, 10 | flags: [flags.CORS_ALLOWED], 11 | scrapeMovie, 12 | scrapeShow, 13 | }); 14 | -------------------------------------------------------------------------------- /src/providers/sources/vidsrc/scrape-movie.ts: -------------------------------------------------------------------------------- 1 | import { getVidSrcMovieSources } from '@/providers/sources/vidsrc/scrape'; 2 | import { MovieScrapeContext } from '@/utils/context'; 3 | 4 | export async function scrapeMovie(ctx: MovieScrapeContext) { 5 | return { 6 | embeds: await getVidSrcMovieSources(ctx), 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/providers/sources/vidsrc/scrape-show.ts: -------------------------------------------------------------------------------- 1 | import { getVidSrcShowSources } from '@/providers/sources/vidsrc/scrape'; 2 | import { ShowScrapeContext } from '@/utils/context'; 3 | 4 | export async function scrapeShow(ctx: ShowScrapeContext) { 5 | return { 6 | embeds: await getVidSrcShowSources(ctx), 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/providers/sources/vidsrcto/common.ts: -------------------------------------------------------------------------------- 1 | // This file is based on https://github.com/Ciarands/vidsrc-to-resolver/blob/dffa45e726a4b944cb9af0c9e7630476c93c0213/vidsrc.py#L16 2 | // Full credits to @Ciarands! 3 | 4 | const DECRYPTION_KEY = '8z5Ag5wgagfsOuhz'; 5 | 6 | export const decodeBase64UrlSafe = (str: string) => { 7 | const standardizedInput = str.replace(/_/g, '/').replace(/-/g, '+'); 8 | const decodedData = atob(standardizedInput); 9 | 10 | const bytes = new Uint8Array(decodedData.length); 11 | for (let i = 0; i < bytes.length; i += 1) { 12 | bytes[i] = decodedData.charCodeAt(i); 13 | } 14 | 15 | return bytes; 16 | }; 17 | 18 | export const decodeData = (key: string, data: any) => { 19 | const state = Array.from(Array(256).keys()); 20 | let index1 = 0; 21 | for (let i = 0; i < 256; i += 1) { 22 | index1 = (index1 + state[i] + key.charCodeAt(i % key.length)) % 256; 23 | const temp = state[i]; 24 | state[i] = state[index1]; 25 | state[index1] = temp; 26 | } 27 | index1 = 0; 28 | let index2 = 0; 29 | let finalKey = ''; 30 | for (let char = 0; char < data.length; char += 1) { 31 | index1 = (index1 + 1) % 256; 32 | index2 = (index2 + state[index1]) % 256; 33 | const temp = state[index1]; 34 | state[index1] = state[index2]; 35 | state[index2] = temp; 36 | if (typeof data[char] === 'string') { 37 | finalKey += String.fromCharCode(data[char].charCodeAt(0) ^ state[(state[index1] + state[index2]) % 256]); 38 | } else if (typeof data[char] === 'number') { 39 | finalKey += String.fromCharCode(data[char] ^ state[(state[index1] + state[index2]) % 256]); 40 | } 41 | } 42 | return finalKey; 43 | }; 44 | 45 | export const decryptSourceUrl = (sourceUrl: string) => { 46 | const encoded = decodeBase64UrlSafe(sourceUrl); 47 | const decoded = decodeData(DECRYPTION_KEY, encoded); 48 | return decodeURIComponent(decodeURIComponent(decoded)); 49 | }; 50 | -------------------------------------------------------------------------------- /src/providers/sources/vidsrcto/index.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | 3 | import { flags } from '@/entrypoint/utils/targets'; 4 | import { SourcererEmbed, SourcererOutput, makeSourcerer } from '@/providers/base'; 5 | import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context'; 6 | 7 | import { decryptSourceUrl } from './common'; 8 | import { SourceResult, SourcesResult } from './types'; 9 | 10 | const vidSrcToBase = 'https://vidsrc.to'; 11 | const referer = `${vidSrcToBase}/`; 12 | 13 | const universalScraper = async (ctx: ShowScrapeContext | MovieScrapeContext): Promise => { 14 | const mediaId = ctx.media.imdbId ?? ctx.media.tmdbId; 15 | const url = 16 | ctx.media.type === 'movie' 17 | ? `/embed/movie/${mediaId}` 18 | : `/embed/tv/${mediaId}/${ctx.media.season.number}/${ctx.media.episode.number}`; 19 | const mainPage = await ctx.proxiedFetcher(url, { 20 | baseUrl: vidSrcToBase, 21 | headers: { 22 | referer, 23 | }, 24 | }); 25 | const mainPage$ = load(mainPage); 26 | const dataId = mainPage$('a[data-id]').attr('data-id'); 27 | if (!dataId) throw new Error('No data-id found'); 28 | const sources = await ctx.proxiedFetcher(`/ajax/embed/episode/${dataId}/sources`, { 29 | baseUrl: vidSrcToBase, 30 | headers: { 31 | referer, 32 | }, 33 | }); 34 | if (sources.status !== 200) throw new Error('No sources found'); 35 | 36 | const embeds: SourcererEmbed[] = []; 37 | const embedArr = []; 38 | for (const source of sources.result) { 39 | const sourceRes = await ctx.proxiedFetcher(`/ajax/embed/source/${source.id}`, { 40 | baseUrl: vidSrcToBase, 41 | headers: { 42 | referer, 43 | }, 44 | }); 45 | const decryptedUrl = decryptSourceUrl(sourceRes.result.url); 46 | embedArr.push({ source: source.title, url: decryptedUrl }); 47 | } 48 | 49 | for (const embedObj of embedArr) { 50 | if (embedObj.source === 'Vidplay') { 51 | const fullUrl = new URL(embedObj.url); 52 | embeds.push({ 53 | embedId: 'vidplay', 54 | url: fullUrl.toString(), 55 | }); 56 | } 57 | 58 | if (embedObj.source === 'Filemoon') { 59 | const fullUrl = new URL(embedObj.url); 60 | // Originally Filemoon does not have subtitles. But we can use the ones from Vidplay. 61 | const urlWithSubtitles = embedArr.find((v) => v.source === 'Vidplay' && v.url.includes('sub.info'))?.url; 62 | const subtitleUrl = urlWithSubtitles ? new URL(urlWithSubtitles).searchParams.get('sub.info') : null; 63 | if (subtitleUrl) fullUrl.searchParams.set('sub.info', subtitleUrl); 64 | embeds.push({ 65 | embedId: 'filemoon', 66 | url: fullUrl.toString(), 67 | }); 68 | } 69 | } 70 | 71 | return { 72 | embeds, 73 | }; 74 | }; 75 | 76 | export const vidSrcToScraper = makeSourcerer({ 77 | id: 'vidsrcto', 78 | name: 'VidSrcTo', 79 | scrapeMovie: universalScraper, 80 | scrapeShow: universalScraper, 81 | flags: [flags.CORS_ALLOWED], 82 | rank: 130, 83 | }); 84 | -------------------------------------------------------------------------------- /src/providers/sources/vidsrcto/types.ts: -------------------------------------------------------------------------------- 1 | export type VidSrcToResponse = { 2 | status: number; 3 | result: T; 4 | }; 5 | 6 | export type SourcesResult = VidSrcToResponse< 7 | { 8 | id: string; 9 | title: 'Filemoon' | 'Vidplay'; 10 | }[] 11 | >; 12 | 13 | export type SourceResult = VidSrcToResponse<{ 14 | url: string; 15 | }>; 16 | -------------------------------------------------------------------------------- /src/providers/sources/zoechip/common.ts: -------------------------------------------------------------------------------- 1 | import { mixdropScraper } from '@/providers/embeds/mixdrop'; 2 | import { upcloudScraper } from '@/providers/embeds/upcloud'; 3 | import { upstreamScraper } from '@/providers/embeds/upstream'; 4 | import { vidCloudScraper } from '@/providers/embeds/vidcloud'; 5 | import { getZoeChipSourceURL, getZoeChipSources } from '@/providers/sources/zoechip/scrape'; 6 | import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context'; 7 | 8 | export const zoeBase = 'https://zoechip.cc'; 9 | 10 | export type ZoeChipSourceDetails = { 11 | type: string; // Only seen "iframe" so far 12 | link: string; 13 | sources: string[]; // Never seen this populated, assuming it's a string array 14 | tracks: string[]; // Never seen this populated, assuming it's a string array 15 | title: string; 16 | }; 17 | 18 | export async function formatSource( 19 | ctx: MovieScrapeContext | ShowScrapeContext, 20 | source: { embed: string; episodeId: string }, 21 | ) { 22 | const link = await getZoeChipSourceURL(ctx, source.episodeId); 23 | if (link) { 24 | const embed = { 25 | embedId: '', 26 | url: link, 27 | }; 28 | 29 | const parsedUrl = new URL(link); 30 | 31 | switch (parsedUrl.host) { 32 | case 'rabbitstream.net': 33 | embed.embedId = upcloudScraper.id; 34 | break; 35 | case 'upstream.to': 36 | embed.embedId = upstreamScraper.id; 37 | break; 38 | case 'mixdrop.co': 39 | embed.embedId = mixdropScraper.id; 40 | break; 41 | default: 42 | return null; 43 | } 44 | 45 | return embed; 46 | } 47 | } 48 | 49 | export async function createZoeChipStreamData(ctx: MovieScrapeContext | ShowScrapeContext, id: string) { 50 | const sources = await getZoeChipSources(ctx, id); 51 | const embeds: { 52 | embedId: string; 53 | url: string; 54 | }[] = []; 55 | 56 | for (const source of sources) { 57 | const formatted = await formatSource(ctx, source); 58 | if (formatted) { 59 | // Zoechip does not return titles for their sources, so we can not check if a source is upcloud or vidcloud because the domain is the same. 60 | const upCloudAlreadyExists = embeds.find((e) => e.embedId === upcloudScraper.id); 61 | if (formatted.embedId === upcloudScraper.id && upCloudAlreadyExists) { 62 | formatted.embedId = vidCloudScraper.id; 63 | } 64 | embeds.push(formatted); 65 | } 66 | } 67 | 68 | return { 69 | embeds, 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/providers/sources/zoechip/index.ts: -------------------------------------------------------------------------------- 1 | import { flags } from '@/entrypoint/utils/targets'; 2 | import { makeSourcerer } from '@/providers/base'; 3 | import { scrapeMovie } from '@/providers/sources/zoechip/scrape-movie'; 4 | import { scrapeShow } from '@/providers/sources/zoechip/scrape-show'; 5 | 6 | export const zoechipScraper = makeSourcerer({ 7 | id: 'zoechip', 8 | name: 'ZoeChip', 9 | rank: 62, 10 | flags: [flags.CORS_ALLOWED], 11 | disabled: true, 12 | scrapeMovie, 13 | scrapeShow, 14 | }); 15 | -------------------------------------------------------------------------------- /src/providers/sources/zoechip/scrape-movie.ts: -------------------------------------------------------------------------------- 1 | import { createZoeChipStreamData } from '@/providers/sources/zoechip/common'; 2 | import { getZoeChipMovieID } from '@/providers/sources/zoechip/search'; 3 | import { MovieScrapeContext } from '@/utils/context'; 4 | import { NotFoundError } from '@/utils/errors'; 5 | 6 | export async function scrapeMovie(ctx: MovieScrapeContext) { 7 | const movieID = await getZoeChipMovieID(ctx, ctx.media); 8 | if (!movieID) { 9 | throw new NotFoundError('no search results match'); 10 | } 11 | 12 | return createZoeChipStreamData(ctx, movieID); 13 | } 14 | -------------------------------------------------------------------------------- /src/providers/sources/zoechip/scrape-show.ts: -------------------------------------------------------------------------------- 1 | import { createZoeChipStreamData } from '@/providers/sources/zoechip/common'; 2 | import { getZoeChipEpisodeID, getZoeChipSeasonID } from '@/providers/sources/zoechip/scrape'; 3 | import { getZoeChipShowID } from '@/providers/sources/zoechip/search'; 4 | import { ShowScrapeContext } from '@/utils/context'; 5 | import { NotFoundError } from '@/utils/errors'; 6 | 7 | export async function scrapeShow(ctx: ShowScrapeContext) { 8 | const showID = await getZoeChipShowID(ctx, ctx.media); 9 | if (!showID) { 10 | throw new NotFoundError('no search results match'); 11 | } 12 | 13 | const seasonID = await getZoeChipSeasonID(ctx, ctx.media, showID); 14 | if (!seasonID) { 15 | throw new NotFoundError('no season found'); 16 | } 17 | 18 | const episodeID = await getZoeChipEpisodeID(ctx, ctx.media, seasonID); 19 | if (!episodeID) { 20 | throw new NotFoundError('no episode found'); 21 | } 22 | 23 | return createZoeChipStreamData(ctx, episodeID); 24 | } 25 | -------------------------------------------------------------------------------- /src/providers/streams.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '@/entrypoint/utils/targets'; 2 | import { Caption } from '@/providers/captions'; 3 | 4 | export type StreamFile = { 5 | type: 'mp4'; 6 | url: string; 7 | }; 8 | 9 | export type Qualities = 'unknown' | '360' | '480' | '720' | '1080' | '4k'; 10 | 11 | type ThumbnailTrack = { 12 | type: 'vtt'; 13 | url: string; 14 | }; 15 | 16 | type StreamCommon = { 17 | id: string; // only unique per output 18 | flags: Flags[]; 19 | captions: Caption[]; 20 | thumbnailTrack?: ThumbnailTrack; 21 | headers?: Record; // these headers HAVE to be set to watch the stream 22 | preferredHeaders?: Record; // these headers are optional, would improve the stream 23 | }; 24 | 25 | export type FileBasedStream = StreamCommon & { 26 | type: 'file'; 27 | qualities: Partial>; 28 | }; 29 | 30 | export type HlsBasedStream = StreamCommon & { 31 | type: 'hls'; 32 | playlist: string; 33 | }; 34 | 35 | export type Stream = FileBasedStream | HlsBasedStream; 36 | -------------------------------------------------------------------------------- /src/utils/compare.ts: -------------------------------------------------------------------------------- 1 | import { CommonMedia } from '@/entrypoint/utils/media'; 2 | 3 | export function normalizeTitle(title: string): string { 4 | let titleTrimmed = title.trim().toLowerCase(); 5 | if (titleTrimmed !== 'the movie' && titleTrimmed.endsWith('the movie')) { 6 | titleTrimmed = titleTrimmed.replace('the movie', ''); 7 | } 8 | if (titleTrimmed !== 'the series' && titleTrimmed.endsWith('the series')) { 9 | titleTrimmed = titleTrimmed.replace('the series', ''); 10 | } 11 | return titleTrimmed.replace(/['":]/g, '').replace(/[^a-zA-Z0-9]+/g, '_'); 12 | } 13 | 14 | export function compareTitle(a: string, b: string): boolean { 15 | return normalizeTitle(a) === normalizeTitle(b); 16 | } 17 | 18 | export function compareMedia(media: CommonMedia, title: string, releaseYear?: number): boolean { 19 | // if no year is provided, count as if its the correct year 20 | const isSameYear = releaseYear === undefined ? true : media.releaseYear === releaseYear; 21 | return compareTitle(media.title, title) && isSameYear; 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/context.ts: -------------------------------------------------------------------------------- 1 | import { MovieMedia, ShowMedia } from '@/entrypoint/utils/media'; 2 | import { UseableFetcher } from '@/fetchers/types'; 3 | 4 | export type ScrapeContext = { 5 | proxiedFetcher: UseableFetcher; 6 | fetcher: UseableFetcher; 7 | progress(val: number): void; 8 | }; 9 | 10 | export type EmbedInput = { 11 | url: string; 12 | }; 13 | 14 | export type EmbedScrapeContext = EmbedInput & ScrapeContext; 15 | 16 | export type MovieScrapeContext = ScrapeContext & { 17 | media: MovieMedia; 18 | }; 19 | 20 | export type ShowScrapeContext = ScrapeContext & { 21 | media: ShowMedia; 22 | }; 23 | -------------------------------------------------------------------------------- /src/utils/cookie.ts: -------------------------------------------------------------------------------- 1 | import cookie from 'cookie'; 2 | import setCookieParser from 'set-cookie-parser'; 3 | 4 | export interface Cookie { 5 | name: string; 6 | value: string; 7 | } 8 | 9 | export function makeCookieHeader(cookies: Record): string { 10 | return Object.entries(cookies) 11 | .map(([name, value]) => cookie.serialize(name, value)) 12 | .join('; '); 13 | } 14 | 15 | export function parseSetCookie(headerValue: string): Record { 16 | const parsedCookies = setCookieParser.parse(headerValue, { 17 | map: true, 18 | }); 19 | return parsedCookies; 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | export class NotFoundError extends Error { 2 | constructor(reason?: string) { 3 | super(`Couldn't find a stream: ${reason ?? 'not found'}`); 4 | this.name = 'NotFoundError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/list.ts: -------------------------------------------------------------------------------- 1 | export function reorderOnIdList(order: string[], list: T): T { 2 | const copy = [...list] as T; 3 | copy.sort((a, b) => { 4 | const aIndex = order.indexOf(a.id); 5 | const bIndex = order.indexOf(b.id); 6 | 7 | // both in order list 8 | if (aIndex >= 0 && bIndex >= 0) return aIndex - bIndex; 9 | 10 | // only one in order list 11 | // negative means order [a,b] 12 | // positive means order [b,a] 13 | if (bIndex >= 0) return 1; // A isnt in list but B is, so A goes later on the list 14 | if (aIndex >= 0) return -1; // B isnt in list but A is, so B goes later on the list 15 | 16 | // both not in list, sort on rank 17 | return b.rank - a.rank; 18 | }); 19 | return copy; 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/native.ts: -------------------------------------------------------------------------------- 1 | export const isReactNative = () => { 2 | try { 3 | // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires 4 | require('react-native'); 5 | return true; 6 | } catch (e) { 7 | return false; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/predicates.ts: -------------------------------------------------------------------------------- 1 | export function hasDuplicates(values: Array): boolean { 2 | return new Set(values).size !== values.length; 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/quality.ts: -------------------------------------------------------------------------------- 1 | import { Qualities } from '@/providers/streams'; 2 | 3 | export function getValidQualityFromString(quality: string): Qualities { 4 | switch (quality.toLowerCase().replace('p', '')) { 5 | case '360': 6 | return '360'; 7 | case '480': 8 | return '480'; 9 | case '720': 10 | return '720'; 11 | case '1080': 12 | return '1080'; 13 | case '2160': 14 | return '4k'; 15 | case '4k': 16 | return '4k'; 17 | default: 18 | return 'unknown'; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/valid.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from '@/providers/streams'; 2 | import { IndividualEmbedRunnerOptions } from '@/runners/individualRunner'; 3 | import { ProviderRunnerOptions } from '@/runners/runner'; 4 | 5 | export function isValidStream(stream: Stream | undefined): boolean { 6 | if (!stream) return false; 7 | if (stream.type === 'hls') { 8 | if (!stream.playlist) return false; 9 | return true; 10 | } 11 | if (stream.type === 'file') { 12 | const validQualities = Object.values(stream.qualities).filter((v) => v.url.length > 0); 13 | if (validQualities.length === 0) return false; 14 | return true; 15 | } 16 | 17 | // unknown file type 18 | return false; 19 | } 20 | 21 | export async function validatePlayableStream( 22 | stream: Stream, 23 | ops: ProviderRunnerOptions | IndividualEmbedRunnerOptions, 24 | ): Promise { 25 | if (stream.type === 'hls') { 26 | const result = await ops.proxiedFetcher.full(stream.playlist, { 27 | method: 'GET', 28 | headers: { 29 | ...stream.preferredHeaders, 30 | ...stream.headers, 31 | }, 32 | }); 33 | if (result.statusCode < 200 || result.statusCode >= 400) return null; 34 | return stream; 35 | } 36 | if (stream.type === 'file') { 37 | const validQualitiesResults = await Promise.all( 38 | Object.values(stream.qualities).map((quality) => 39 | ops.proxiedFetcher.full(quality.url, { 40 | method: 'GET', 41 | headers: { 42 | ...stream.preferredHeaders, 43 | ...stream.headers, 44 | Range: 'bytes=0-1', 45 | }, 46 | }), 47 | ), 48 | ); 49 | // remove invalid qualities from the stream 50 | const validQualities = stream.qualities; 51 | Object.keys(stream.qualities).forEach((quality, index) => { 52 | if (validQualitiesResults[index].statusCode < 200 || validQualitiesResults[index].statusCode >= 400) { 53 | delete validQualities[quality as keyof typeof stream.qualities]; 54 | } 55 | }); 56 | 57 | if (Object.keys(validQualities).length === 0) return null; 58 | return { ...stream, qualities: validQualities }; 59 | } 60 | return null; 61 | } 62 | 63 | export async function validatePlayableStreams( 64 | streams: Stream[], 65 | ops: ProviderRunnerOptions | IndividualEmbedRunnerOptions, 66 | ): Promise { 67 | return (await Promise.all(streams.map((stream) => validatePlayableStream(stream, ops)))).filter( 68 | (v) => v !== null, 69 | ) as Stream[]; 70 | } 71 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Integration test folder 2 | 3 | This folder simply holds some import tests, to see if the library still works with all its dependencies. 4 | -------------------------------------------------------------------------------- /tests/browser/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /tests/browser/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Browser integration test 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/browser/index.ts: -------------------------------------------------------------------------------- 1 | import { makeProviders, makeStandardFetcher, targets } from '../../lib/index.js'; 2 | 3 | (window as any).TEST = () => { 4 | makeProviders({ 5 | fetcher: makeStandardFetcher(fetch), 6 | target: targets.ANY, 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /tests/browser/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "startup.mjs" 3 | } 4 | -------------------------------------------------------------------------------- /tests/browser/startup.mjs: -------------------------------------------------------------------------------- 1 | import { build, preview } from 'vite'; 2 | import puppeteer from 'puppeteer'; 3 | import { fileURLToPath } from 'url'; 4 | import { dirname } from 'path'; 5 | 6 | const root = dirname(fileURLToPath(import.meta.url)); 7 | 8 | await build({ 9 | root, 10 | }); 11 | const server = await preview({ 12 | root, 13 | }); 14 | let browser; 15 | try { 16 | browser = await puppeteer.launch({ 17 | headless: true, 18 | args: ['--no-sandbox', '--disable-setuid-sandbox'], 19 | }); 20 | const page = await browser.newPage(); 21 | await page.goto(server.resolvedUrls.local[0]); 22 | await page.waitForFunction('!!window.TEST', { timeout: 5000 }); 23 | await page.evaluate(() => { 24 | window.TEST(); 25 | }); 26 | } finally { 27 | server.httpServer.close(); 28 | try { 29 | await browser.close(); 30 | } catch (e) { 31 | console.error('Failed to close browser:', e); 32 | } 33 | } 34 | 35 | console.log('Success!'); 36 | process.exit(0); 37 | -------------------------------------------------------------------------------- /tests/cjs/index.js: -------------------------------------------------------------------------------- 1 | require('../../lib/index.umd.cjs'); 2 | console.log('import successful!'); 3 | -------------------------------------------------------------------------------- /tests/cjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "index.js", 3 | "type": "commonjs" 4 | } 5 | -------------------------------------------------------------------------------- /tests/esm/index.mjs: -------------------------------------------------------------------------------- 1 | import '../../lib/index.js'; 2 | console.log('import successful!'); 3 | -------------------------------------------------------------------------------- /tests/esm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "index.mjs", 3 | "type": "module" 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "lib": ["es2021", "DOM"], 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "esModuleInterop": true, 8 | "declaration": true, 9 | "outDir": "./lib", 10 | "strict": true, 11 | "baseUrl": "src", 12 | "experimentalDecorators": true, 13 | "isolatedModules": false, 14 | "skipLibCheck": true, 15 | "resolveJsonModule": true, 16 | "paths": { 17 | "@/*": ["./*"], 18 | "@entrypoint": ["./index.ts"] 19 | } 20 | }, 21 | "include": ["src", "vite.config.ts"], 22 | "exclude": ["node_modules", "**/__test__"] 23 | } 24 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { defineConfig } from 'vitest/config'; 3 | import eslintPlugin from '@nabla/vite-plugin-eslint'; 4 | import dts from 'vite-plugin-dts'; 5 | import pkg from './package.json'; 6 | 7 | const shouldTestProviders = process.env.MW_TEST_PROVIDERS === 'true'; 8 | let tests: string[] = ['src/__test__/standard/**/*.test.ts']; 9 | if (shouldTestProviders) tests = ['src/__test__/providers/**/*.test.ts']; 10 | 11 | export default defineConfig((env) => ({ 12 | plugins: [ 13 | env.mode !== 'test' && eslintPlugin(), 14 | dts({ 15 | rollupTypes: true, 16 | }), 17 | ], 18 | resolve: { 19 | alias: { 20 | '@': path.resolve(__dirname, './src'), 21 | }, 22 | }, 23 | build: { 24 | minify: false, 25 | rollupOptions: { 26 | external: Object.keys(pkg.dependencies), 27 | output: { 28 | globals: Object.fromEntries(Object.keys(pkg.dependencies).map((v) => [v, v])), 29 | }, 30 | }, 31 | outDir: 'lib', 32 | lib: { 33 | entry: path.resolve(__dirname, 'src/index.ts'), 34 | name: 'index', 35 | fileName: 'index', 36 | formats: ['umd', 'es'], 37 | }, 38 | }, 39 | test: { 40 | include: tests, 41 | }, 42 | })); 43 | --------------------------------------------------------------------------------