Compare commits
2 commits
main
...
dood-refac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c2d3bba01 | ||
|
|
e8ad568327 |
682 changed files with 35771 additions and 126960 deletions
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -1,38 +0,0 @@
|
||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: ''
|
|
||||||
labels: bug
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
**Expected behavior**
|
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Desktop (please complete the following information):**
|
|
||||||
- OS: [e.g. iOS]
|
|
||||||
- Browser [e.g. chrome, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Smartphone (please complete the following information):**
|
|
||||||
- Device: [e.g. iPhone6]
|
|
||||||
- OS: [e.g. iOS8.1]
|
|
||||||
- Browser [e.g. stock browser, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context about the problem here.
|
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
|
@ -1,20 +0,0 @@
|
||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: ''
|
|
||||||
labels: enhancement
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context or screenshots about the feature request here.
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
---
|
|
||||||
name: Request source/extractor
|
|
||||||
about: Request a source or extractor to get URLs from
|
|
||||||
title: Request to add <source/extractor>
|
|
||||||
labels: enhancement
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**What is the site you want to get added?**
|
|
||||||
A link to the source or embed hoster
|
|
||||||
|
|
||||||
**What is the benefit of adding this?**
|
|
||||||
More content is not a good reason. If it's already covered via an existing source/extractor, why should we add another one?
|
|
||||||
|
|
||||||
**Are you aware of any protection the site uses?**
|
|
||||||
E.g. rate-limits or Cloudflare captchas and such
|
|
||||||
|
|
||||||
**What languages does this relate to?**
|
|
||||||
E.g. multi, German, ..
|
|
||||||
|
|
||||||
**Example**
|
|
||||||
A movie/series IMDb/TMDB ID + source link or embed URL to test with.
|
|
||||||
28
.github/workflows/build-tag.yml
vendored
28
.github/workflows/build-tag.yml
vendored
|
|
@ -1,28 +0,0 @@
|
||||||
name: Build and push latest
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
jobs:
|
|
||||||
docker:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
-
|
|
||||||
name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@v4
|
|
||||||
with:
|
|
||||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
-
|
|
||||||
name: Set up QEMU (for cross-arch)
|
|
||||||
uses: docker/setup-qemu-action@v4
|
|
||||||
-
|
|
||||||
name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v4
|
|
||||||
-
|
|
||||||
name: Build and push
|
|
||||||
uses: docker/build-push-action@v7
|
|
||||||
with:
|
|
||||||
push: true
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
tags: webstreamr/webstreamr:latest
|
|
||||||
9
.github/workflows/build.yml
vendored
9
.github/workflows/build.yml
vendored
|
|
@ -1,18 +1,15 @@
|
||||||
name: Build
|
name: Build
|
||||||
on:
|
on: push
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
jobs:
|
jobs:
|
||||||
docker:
|
docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
push: false
|
push: false
|
||||||
tags: webstreamr/webstreamr:latest
|
tags: webstreamr/webstreamr:latest
|
||||||
|
|
|
||||||
23
.github/workflows/tests.yml
vendored
23
.github/workflows/tests.yml
vendored
|
|
@ -1,17 +1,26 @@
|
||||||
name: Tests
|
name: Tests
|
||||||
on:
|
on: push
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v5
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Cache node modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
env:
|
||||||
|
cache-name: cache-node-modules
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-build-${{ env.cache-name }}-
|
||||||
|
${{ runner.os }}-build-
|
||||||
|
${{ runner.os }}-
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
".": "0.69.1"
|
".": "0.44.1"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1667
CHANGELOG.md
1667
CHANGELOG.md
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:24 AS builder
|
FROM node:22.18-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
@ -10,7 +10,7 @@ RUN npm run build
|
||||||
|
|
||||||
RUN npm ci --only=production
|
RUN npm ci --only=production
|
||||||
|
|
||||||
FROM node:24
|
FROM node:22.18-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
|
||||||
98
README.md
98
README.md
|
|
@ -1,47 +1,15 @@
|
||||||
# WebStreamr
|
# WebStreamr
|
||||||
|
|
||||||
[](https://github.com/webstreamr/webstreamr/actions/workflows/tests.yml)
|
[](https://github.com/webstreamr/webstreamr/actions/workflows/tests.yml)
|
||||||
[](https://github.com/webstreamr/webstreamr/releases)
|
[](https://github.com/webstreamr/webstreamr/releases)
|
||||||

|

|
||||||
|
|
||||||
[Stremio](https://www.stremio.com/) add-on which provides HTTP URLs from streaming websites.
|
[Stremio](https://www.stremio.com/) add-on which provides HTTP URLs from streaming websites.
|
||||||
|
|
||||||
HTTP streams have limitations.
|
|
||||||
For a better experience, I'd advise using a Debrid service and WebStreamr as fallback.
|
|
||||||
[TorBox](https://torbox.app/subscription?referral=f22eb00d-27ce-4e20-85fc-68da3d018b99) is working very well.
|
|
||||||
|
|
||||||
## Public instance
|
## Public instance
|
||||||
|
|
||||||
A public instance is available at https://webstreamr.hayd.uk. Hosting infrastructure for this instance is donated by [ElfHosted](https://elfhosted.com), and independently maintained by [Hayduk](https://hayd.uk).
|
A public instance is available at https://webstreamr.hayd.uk. Hosting infrastructure for this instance is donated by [ElfHosted](https://elfhosted.com), and independently maintained by [Hayduk](https://hayd.uk).
|
||||||
|
|
||||||
## Known issues / limitations
|
|
||||||
|
|
||||||
- PixelServer / pixeldrain has a daily limit of 6 GB per IP: https://pixeldrain.dev
|
|
||||||
- Dropload and SuperVideo on Android do not work because Stremio does not use the `Referer` header properly via HLS playlists: https://github.com/Stremio/stremio-bugs/issues/2389, maybe https://github.com/Stremio/stremio-bugs/issues/1579
|
|
||||||
- MediaFlow proxy has to be used in an inefficient way because Stremio on Android or its players cannot deal with HLS playlist with redirects: https://github.com/Stremio/stremio-bugs/issues/1574
|
|
||||||
- FlareSolverr cookies cannot be used because Cloudflare does techniques like TLS fingerprinting most likely. But FlareSolverr uses a session per host and should be quick.
|
|
||||||
- VidSrc works but rate limits heavily and is therefore only queried as fallback if nothing else is found.
|
|
||||||
- RgShows detects shared usage and blocks IPs. It therefore only works on private instances.
|
|
||||||
|
|
||||||
## MediaFlow Proxy
|
|
||||||
|
|
||||||
[MediaFlow Proxy](https://github.com/mhdzumair/mediaflow-proxy/) can be added when configuration the add-on to gain access to a couple of more file hosters.
|
|
||||||
It depends highly on the language / source used if that unlocks more streams or not.
|
|
||||||
|
|
||||||
MediaFlow proxy is needed because some hosters ip-lock streams and the add-on does not run on the same device that will stream the video.
|
|
||||||
|
|
||||||
The following hosters can be used only with MediaFlow Proxy:
|
|
||||||
|
|
||||||
- Fastream
|
|
||||||
- FileLions
|
|
||||||
- FileMoon
|
|
||||||
- LuluStream
|
|
||||||
- Mixdrop
|
|
||||||
- Streamtape
|
|
||||||
- VOE
|
|
||||||
|
|
||||||
MediaFlow proxy can either be self-hosted or acquired via bundle from [ElfHosted](https://docs.elfhosted.com/app/mediaflow-proxy/).
|
|
||||||
|
|
||||||
## Hosting
|
## Hosting
|
||||||
|
|
||||||
Don't want to use the public instance, or concerned about reliability during periods of high use? It's open-source, you can host it yourself!
|
Don't want to use the public instance, or concerned about reliability during periods of high use? It's open-source, you can host it yourself!
|
||||||
|
|
@ -54,64 +22,12 @@ Self-hosting to stressful? ElfHosted offer [ready-to-go, turn-key WebStreamr ins
|
||||||
|
|
||||||
### Self-Hosting
|
### Self-Hosting
|
||||||
|
|
||||||
You can run the latest WebStreamr via Docker. E.g.
|
You can build an image using the [Dockerfile](./Dockerfile) and run it.
|
||||||
|
|
||||||
|
Alternatively, you can also start WebStreamr directly via
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker run \
|
npm install
|
||||||
--detach=true \
|
npm run build
|
||||||
--name webstreamr \
|
npm start
|
||||||
--rm \
|
|
||||||
--pull always \
|
|
||||||
--publish 51546:51546 \
|
|
||||||
--env TMDB_ACCESS_TOKEN="YOUR_TOKEN" \
|
|
||||||
--volume /tmp:/tmp \
|
|
||||||
webstreamr/webstreamr
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment variables
|
|
||||||
|
|
||||||
#### `CACHE_DIR`
|
|
||||||
|
|
||||||
Optional. Directory for persistent caches using SQLite files. Default: OS tmp dir.
|
|
||||||
|
|
||||||
#### `CONFIGURATION_DESCRIPTION`
|
|
||||||
|
|
||||||
Optional. To customize the description shown on the configuration page.
|
|
||||||
|
|
||||||
#### `DISABLED_EXTRACTORS`
|
|
||||||
|
|
||||||
Optional. Comma separated list of extractors which should be disabled. E.g. `doodstream,vidsrc`
|
|
||||||
|
|
||||||
#### `DISABLED_SOURCES`
|
|
||||||
|
|
||||||
Optional. Comma separated list of sources which should be disabled. E.g. `frembed,vidsrc`
|
|
||||||
|
|
||||||
#### `FLARESOLVERR_ENDPOINT`
|
|
||||||
|
|
||||||
Optional. If domains show Cloudflare challenges, FlareSolverr can be used to work around them. E.g. `http://flaresolverr:8191`
|
|
||||||
Proxy configuration is passed-through and only a single session is used to save resources. Byparr is not supported.
|
|
||||||
|
|
||||||
#### `MANIFEST_ID`
|
|
||||||
|
|
||||||
Optional. Add-on manifest ID. Default: `webstreamr`
|
|
||||||
|
|
||||||
#### `MANIFEST_NAME`
|
|
||||||
|
|
||||||
Optional. Add-on manifest name. Default: `WebStreamr`
|
|
||||||
|
|
||||||
#### `PORT`
|
|
||||||
|
|
||||||
Optional. Port of the node web server. Default: `51546`
|
|
||||||
|
|
||||||
#### `PROXY_CONFIG`
|
|
||||||
|
|
||||||
Optional. Proxies which should be used based on domain. Supports minimatch. E.g. `dood.to:http://USERNAME:PASSWORD@IP:PORT,*:socks5://172.17.0.1:1080` would use an http proxy for dood.to and a socks5 proxy for all other domains.
|
|
||||||
|
|
||||||
Some hosters are a bit picky when it comes to IPs. Best case is if you use a residential IP.
|
|
||||||
If you can't do that, then I suggest to use a VPN / proxy like Cloudflare WARP.
|
|
||||||
DoodStream is not working with WARP.
|
|
||||||
Free Webshare proxies seem to work with it.
|
|
||||||
|
|
||||||
#### `TMDB_ACCESS_TOKEN`
|
|
||||||
|
|
||||||
**Required**. TMDB access token to get information like title and year for content. Use the [API Read Access Token](https://www.themoviedb.org/settings/api).
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ const config: Config = {
|
||||||
coverageDirectory: '<rootDir>/coverage',
|
coverageDirectory: '<rootDir>/coverage',
|
||||||
coveragePathIgnorePatterns: [
|
coveragePathIgnorePatterns: [
|
||||||
'/src/controller/',
|
'/src/controller/',
|
||||||
'/src/utils/dispatcher.ts',
|
|
||||||
],
|
],
|
||||||
coverageProvider: 'babel',
|
coverageProvider: 'babel',
|
||||||
coverageThreshold: {
|
coverageThreshold: {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
process.env['CACHE_DIR'] = '/dev/null';
|
import { socksDispatcher } from 'fetch-socks';
|
||||||
process.env['HOST'] = 'example.test';
|
import { Agent, Dispatcher, interceptors, ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||||
process.env['PROTOCOL'] = 'https';
|
|
||||||
|
|
||||||
if (!process.env['TMDB_ACCESS_TOKEN']) {
|
|
||||||
process.env['TMDB_ACCESS_TOKEN'] = 'some access token';
|
|
||||||
}
|
|
||||||
|
|
||||||
jest.mock('randomstring', () => ({
|
jest.mock('randomstring', () => ({
|
||||||
generate: jest.fn(() => 'mocked-random-string'),
|
generate: jest.fn(() => 'mocked-random-string'),
|
||||||
|
|
@ -14,4 +9,19 @@ beforeEach(() => {
|
||||||
jest.spyOn(Date, 'now').mockImplementation(() => 639837296000);
|
jest.spyOn(Date, 'now').mockImplementation(() => 639837296000);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log = console.warn = console.error = console.info = console.debug = () => { /* disable in favor of logger */ };
|
let dispatcher: Dispatcher;
|
||||||
|
if (process.env['ALL_PROXY']) {
|
||||||
|
const proxyUrl = new URL(process.env['ALL_PROXY']);
|
||||||
|
if (proxyUrl.protocol === 'socks5:') {
|
||||||
|
dispatcher = socksDispatcher({ type: 5, host: proxyUrl.hostname, port: parseInt(proxyUrl.port) }, { allowH2: true });
|
||||||
|
} else {
|
||||||
|
dispatcher = new ProxyAgent({ uri: proxyUrl.href, allowH2: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dispatcher = new Agent({ allowH2: true });
|
||||||
|
}
|
||||||
|
dispatcher.compose(
|
||||||
|
interceptors.dns(),
|
||||||
|
interceptors.retry({ maxRetries: 3 }),
|
||||||
|
);
|
||||||
|
setGlobalDispatcher(dispatcher);
|
||||||
|
|
|
||||||
5520
package-lock.json
generated
5520
package-lock.json
generated
File diff suppressed because it is too large
Load diff
46
package.json
46
package.json
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "webstreamr",
|
"name": "webstreamr",
|
||||||
"description": "Provides HTTP URLs from streaming websites.",
|
"description": "Provides HTTP URLs from streaming websites.",
|
||||||
"version": "0.69.1",
|
"version": "0.44.1",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyse": "tsc --noEmit --project ./tsconfig.dev.json",
|
"analyse": "tsc --noEmit --project ./tsconfig.dev.json",
|
||||||
|
|
@ -28,55 +28,37 @@
|
||||||
"url": "https://github.com/webstreamr/webstreamr/issues"
|
"url": "https://github.com/webstreamr/webstreamr/issues"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.0.0 || ^24.0.0"
|
"node": "^22.0.0"
|
||||||
},
|
|
||||||
"devEngines": {
|
|
||||||
"runtime": {
|
|
||||||
"name": "node",
|
|
||||||
"onFail": "error"
|
|
||||||
},
|
|
||||||
"packageManager": {
|
|
||||||
"name": "npm",
|
|
||||||
"onFail": "error"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@keyv/sqlite": "^4.0.5",
|
"@isaacs/ttlcache": "^1.4.1",
|
||||||
|
"@types/jsdom": "^21.1.7",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
"axios": "^1.13.2",
|
|
||||||
"axios-cache-interceptor": "^1.8.3",
|
|
||||||
"axios-mock-adapter": "^2.1.0",
|
|
||||||
"axios-retry": "^4.5.0",
|
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"cacheable": "^2.0.0",
|
|
||||||
"cheerio": "^1.0.0",
|
"cheerio": "^1.0.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-rate-limit": "^8.2.1",
|
"fetch-socks": "^1.3.2",
|
||||||
"fast-levenshtein": "^3.0.0",
|
"http-cache-semantics": "^4.2.0",
|
||||||
"glob": "^13.0.0",
|
"jsdom": "^26.1.0",
|
||||||
"http-proxy-agent": "^7.0.2",
|
"lru-cache": "^11.1.0",
|
||||||
"https-proxy-agent": "^7.0.6",
|
|
||||||
"memoizee": "^0.4.17",
|
|
||||||
"minimatch": "^10.0.3",
|
|
||||||
"randomstring": "^1.3.1",
|
"randomstring": "^1.3.1",
|
||||||
"rot13-cipher": "^1.0.0",
|
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"socks-proxy-agent": "^8.0.5",
|
|
||||||
"sqlite3": "^5.1.7",
|
|
||||||
"tough-cookie": "^6.0.0",
|
"tough-cookie": "^6.0.0",
|
||||||
|
"undici": "^7.10.0",
|
||||||
"unpacker": "^1.0.1",
|
"unpacker": "^1.0.1",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"winston": "^3.17.0"
|
"winston": "^3.17.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.26.0",
|
"@eslint/js": "^9.26.0",
|
||||||
"@stylistic/eslint-plugin": "^5.0.0",
|
"@stylistic/eslint-plugin": "^5.0.0",
|
||||||
"@tsconfig/node24": "^24.0.0",
|
"@tsconfig/node22": "^22.0.2",
|
||||||
"@types/bytes": "^3.1.5",
|
"@types/bytes": "^3.1.5",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/fast-levenshtein": "^0.0.4",
|
"@types/http-cache-semantics": "^4.0.4",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/memoizee": "^0.4.12",
|
"@types/make-fetch-happen": "^10.0.4",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^22.15.3",
|
||||||
"@types/randomstring": "^1.3.0",
|
"@types/randomstring": "^1.3.0",
|
||||||
"@types/stremio-addon-sdk": "^1.6.11",
|
"@types/stremio-addon-sdk": "^1.6.11",
|
||||||
"babel-jest": "^30.0.0",
|
"babel-jest": "^30.0.0",
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export class ConfigureController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getConfigure(req: Request, res: Response) {
|
private getConfigure(req: Request, res: Response) {
|
||||||
const config: Config = JSON.parse(req.params['config'] as string || JSON.stringify(getDefaultConfig()));
|
const config: Config = JSON.parse(req.params['config'] || JSON.stringify(getDefaultConfig()));
|
||||||
|
|
||||||
// Convenience preset for ElfHosted WebStreamr bundle including Media Flow Proxy
|
// Convenience preset for ElfHosted WebStreamr bundle including Media Flow Proxy
|
||||||
if (!req.params['config'] && isElfHostedInstance(req)) {
|
if (!req.params['config'] && isElfHostedInstance(req)) {
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import { Mutex } from 'async-mutex';
|
|
||||||
import { Request, Response, Router } from 'express';
|
|
||||||
import winston from 'winston';
|
|
||||||
import { ExtractorRegistry } from '../extractor';
|
|
||||||
import { contextFromRequestAndResponse, Fetcher } from '../utils';
|
|
||||||
|
|
||||||
export class ExtractController {
|
|
||||||
public readonly router: Router;
|
|
||||||
|
|
||||||
private readonly logger: winston.Logger;
|
|
||||||
private readonly extractorRegistry: ExtractorRegistry;
|
|
||||||
|
|
||||||
private readonly locks = new Map<string, Mutex>();
|
|
||||||
|
|
||||||
public constructor(logger: winston.Logger, _fetcher: Fetcher, extractorRegistry: ExtractorRegistry) {
|
|
||||||
this.router = Router();
|
|
||||||
|
|
||||||
this.logger = logger;
|
|
||||||
this.extractorRegistry = extractorRegistry;
|
|
||||||
|
|
||||||
this.router.get('/extract', this.extract.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async extract(req: Request, res: Response) {
|
|
||||||
if (req.method !== 'GET') {
|
|
||||||
res.status(405).send('Method Not Allowed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = contextFromRequestAndResponse(req, res);
|
|
||||||
|
|
||||||
const index = parseInt(req.query['index'] as string);
|
|
||||||
const url = new URL(req.query['url'] as string);
|
|
||||||
|
|
||||||
this.logger.info(`Lazy extract index ${index} of URL ${url} for ip ${ctx.ip}`, ctx);
|
|
||||||
|
|
||||||
let mutex = this.locks.get(url.href);
|
|
||||||
if (!mutex) {
|
|
||||||
mutex = new Mutex();
|
|
||||||
this.locks.set(url.href, mutex);
|
|
||||||
}
|
|
||||||
|
|
||||||
await mutex.runExclusive(async () => {
|
|
||||||
const urlResults = await this.extractorRegistry.handle(ctx, url);
|
|
||||||
|
|
||||||
res.redirect(urlResults[index]?.url.href as string);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!mutex.isLocked()) {
|
|
||||||
this.locks.delete(url.href);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -21,7 +21,7 @@ export class ManifestController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getManifest(req: Request, res: Response) {
|
private getManifest(req: Request, res: Response) {
|
||||||
const config: Config = JSON.parse(req.params['config'] as string || JSON.stringify(getDefaultConfig()));
|
const config: Config = JSON.parse(req.params['config'] || JSON.stringify(getDefaultConfig()));
|
||||||
|
|
||||||
const manifest = buildManifest(this.sources, this.extractors, config);
|
const manifest = buildManifest(this.sources, this.extractors, config);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import winston from 'winston';
|
||||||
import { Source } from '../source';
|
import { Source } from '../source';
|
||||||
import { contextFromRequestAndResponse, envIsProd, Id, ImdbId, StreamResolver, TmdbId } from '../utils';
|
import { contextFromRequestAndResponse, envIsProd, Id, ImdbId, StreamResolver, TmdbId } from '../utils';
|
||||||
|
|
||||||
|
const locks = new Map<string, Mutex>();
|
||||||
|
|
||||||
export class StreamController {
|
export class StreamController {
|
||||||
public readonly router: Router;
|
public readonly router: Router;
|
||||||
|
|
||||||
|
|
@ -12,8 +14,6 @@ export class StreamController {
|
||||||
private readonly sources: Source[];
|
private readonly sources: Source[];
|
||||||
private readonly streamResolver: StreamResolver;
|
private readonly streamResolver: StreamResolver;
|
||||||
|
|
||||||
private readonly locks = new Map<string, Mutex>();
|
|
||||||
|
|
||||||
public constructor(logger: winston.Logger, sources: Source[], streams: StreamResolver) {
|
public constructor(logger: winston.Logger, sources: Source[], streams: StreamResolver) {
|
||||||
this.router = Router();
|
this.router = Router();
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@ export class StreamController {
|
||||||
|
|
||||||
private async getStream(req: Request, res: Response) {
|
private async getStream(req: Request, res: Response) {
|
||||||
const type: ContentType = (req.params['type'] || '') as ContentType;
|
const type: ContentType = (req.params['type'] || '') as ContentType;
|
||||||
const rawId: string = req.params['id'] as string || '';
|
const rawId: string = req.params['id'] || '';
|
||||||
|
|
||||||
let id: Id;
|
let id: Id;
|
||||||
if (rawId.startsWith('tmdb:')) {
|
if (rawId.startsWith('tmdb:')) {
|
||||||
|
|
@ -35,9 +35,7 @@ export class StreamController {
|
||||||
} else if (rawId.startsWith('tt')) {
|
} else if (rawId.startsWith('tt')) {
|
||||||
id = ImdbId.fromString(rawId);
|
id = ImdbId.fromString(rawId);
|
||||||
} else {
|
} else {
|
||||||
res.status(400).send(`Unsupported ID: ${rawId}`);
|
throw new Error(`Unsupported ID: ${rawId}`);
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = contextFromRequestAndResponse(req, res);
|
const ctx = contextFromRequestAndResponse(req, res);
|
||||||
|
|
@ -46,17 +44,17 @@ export class StreamController {
|
||||||
|
|
||||||
const sources = this.sources.filter(source => source.countryCodes.filter(countryCode => countryCode in ctx.config).length);
|
const sources = this.sources.filter(source => source.countryCodes.filter(countryCode => countryCode in ctx.config).length);
|
||||||
|
|
||||||
let mutex = this.locks.get(rawId);
|
let mutex = locks.get(rawId);
|
||||||
if (!mutex) {
|
if (!mutex) {
|
||||||
mutex = new Mutex();
|
mutex = new Mutex();
|
||||||
this.locks.set(rawId, mutex);
|
locks.set(rawId, mutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
await mutex.runExclusive(async () => {
|
await mutex.runExclusive(async () => {
|
||||||
const { streams, ttl } = await this.streamResolver.resolve(ctx, sources, type, id);
|
const { streams, ttl } = await this.streamResolver.resolve(ctx, sources, type, id);
|
||||||
|
|
||||||
if (ttl && envIsProd()) {
|
if (ttl && envIsProd()) {
|
||||||
res.setHeader('Cache-Control', `public, max-age=${Math.floor(ttl / 1000)}, immutable`);
|
res.setHeader('Cache-Control', `max-age=${ttl / 1000}, public`);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
|
@ -64,7 +62,7 @@ export class StreamController {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!mutex.isLocked()) {
|
if (!mutex.isLocked()) {
|
||||||
this.locks.delete(rawId);
|
locks.delete(rawId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
export * from './ExtractController';
|
|
||||||
export * from './ConfigureController';
|
export * from './ConfigureController';
|
||||||
export * from './ManifestController';
|
export * from './ManifestController';
|
||||||
export * from './StreamController';
|
export * from './StreamController';
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import { AxiosResponse } from 'axios';
|
|
||||||
import { BlockedReason } from '../types';
|
import { BlockedReason } from '../types';
|
||||||
|
|
||||||
export class BlockedError extends Error {
|
export class BlockedError extends Error {
|
||||||
public readonly url: URL;
|
|
||||||
public readonly reason: BlockedReason;
|
public readonly reason: BlockedReason;
|
||||||
public readonly headers: AxiosResponse['headers'];
|
public readonly headers: Record<string, string[] | string | undefined>;
|
||||||
|
|
||||||
public constructor(url: URL, reason: BlockedReason, headers: AxiosResponse['headers']) {
|
public constructor(reason: BlockedReason, headers: Record<string, string[] | string | undefined>) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.url = url;
|
|
||||||
this.reason = reason;
|
this.reason = reason;
|
||||||
this.headers = headers;
|
this.headers = headers;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
import { AxiosResponse } from 'axios';
|
|
||||||
|
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
public readonly url: URL;
|
|
||||||
public readonly status: number;
|
public readonly status: number;
|
||||||
public readonly statusText: string;
|
public readonly statusText: string;
|
||||||
public readonly headers: AxiosResponse['headers'];
|
public readonly headers: Record<string, string[] | string | undefined>;
|
||||||
|
|
||||||
public constructor(url: URL, status: number, statusText: string, headers: AxiosResponse['headers']) {
|
public constructor(status: number, statusText: string, headers: Record<string, string[] | string | undefined>) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.url = url;
|
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.statusText = statusText;
|
this.statusText = statusText;
|
||||||
this.headers = headers;
|
this.headers = headers;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1 @@
|
||||||
export class QueueIsFullError extends Error {
|
export class QueueIsFullError extends Error {}
|
||||||
public readonly url: URL;
|
|
||||||
|
|
||||||
public constructor(url: URL) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.url = url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1 @@
|
||||||
export class TimeoutError extends Error {
|
export class TimeoutError extends Error {}
|
||||||
public readonly url: URL;
|
|
||||||
|
|
||||||
public constructor(url: URL) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.url = url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
export class TooManyRequestsError extends Error {
|
export class TooManyRequestsError extends Error {
|
||||||
public readonly url: URL;
|
|
||||||
public readonly retryAfter: number;
|
public readonly retryAfter: number;
|
||||||
|
|
||||||
public constructor(url: URL, retryAfter: number) {
|
public constructor(retryAfter: number) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.url = url;
|
|
||||||
this.retryAfter = retryAfter;
|
this.retryAfter = retryAfter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1 @@
|
||||||
export class TooManyTimeoutsError extends Error {
|
export class TooManyTimeoutsError extends Error {}
|
||||||
public readonly url: URL;
|
|
||||||
|
|
||||||
public constructor(url: URL) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.url = url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -17,54 +17,51 @@ export * from './TooManyTimeoutsError';
|
||||||
|
|
||||||
export const logErrorAndReturnNiceString = (ctx: Context, logger: winston.Logger, source: string, error: unknown): string => {
|
export const logErrorAndReturnNiceString = (ctx: Context, logger: winston.Logger, source: string, error: unknown): string => {
|
||||||
if (error instanceof BlockedError) {
|
if (error instanceof BlockedError) {
|
||||||
if (error.reason === BlockedReason.media_flow_proxy_auth) {
|
if (error.reason === BlockedReason.cloudflare_challenge) {
|
||||||
|
logger.warn(`${source}: Request was blocked via Cloudflare challenge.`, ctx);
|
||||||
|
} else if (error.reason === BlockedReason.cloudflare_censor) {
|
||||||
|
logger.warn(`${source}: Request was censored by Cloudflare.`, ctx);
|
||||||
|
} else if (error.reason === BlockedReason.media_flow_proxy_auth) {
|
||||||
return '⚠️ MediaFlow Proxy authentication failed. Please set the correct password.';
|
return '⚠️ MediaFlow Proxy authentication failed. Please set the correct password.';
|
||||||
|
} else {
|
||||||
|
logger.warn(`${source}: Request was blocked, headers: ${JSON.stringify(error.headers)}.`, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn(`${source}: Request to ${error.url} was blocked, reason: ${error.reason}, headers: ${JSON.stringify(error.headers)}.`, ctx);
|
return `⚠️ Request was blocked. Reason: ${error.reason}`;
|
||||||
|
|
||||||
return `⚠️ Request to ${error.url.host} was blocked. Reason: ${error.reason}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof TooManyRequestsError) {
|
if (error instanceof TooManyRequestsError) {
|
||||||
logger.warn(`${source}: Request to ${error.url} was rate limited for ${error.retryAfter} seconds.`, ctx);
|
logger.warn(`${source}: Rate limited for ${error.retryAfter} seconds.`, ctx);
|
||||||
|
|
||||||
return `🚦 Request to ${error.url.host} was rate-limited. Please try again later or consider self-hosting.`;
|
return '🚦 Request was rate-limited. Please try again later or consider self-hosting.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof TooManyTimeoutsError) {
|
if (error instanceof TooManyTimeoutsError) {
|
||||||
logger.warn(`${source}: Too many timeouts when requesting ${error.url}.`, ctx);
|
logger.warn(`${source}: Too many timeouts.`, ctx);
|
||||||
|
|
||||||
return `🚦 Too many recent timeouts when requesting ${error.url.host}. Please try again later.`;
|
return '🚦 Too many recent timeouts. Please try again later.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof TimeoutError) {
|
if (error instanceof TimeoutError) {
|
||||||
logger.warn(`${source}: Request to ${error.url} timed out.`, ctx);
|
|
||||||
|
|
||||||
return `🐢 Request to ${error.url.host} timed out.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof DOMException && ['AbortError', 'TimeoutError'].includes(error.name)) {
|
|
||||||
// sometimes this gets through, no idea why..
|
|
||||||
logger.warn(`${source}: Request timed out.`, ctx);
|
logger.warn(`${source}: Request timed out.`, ctx);
|
||||||
|
|
||||||
return '🐢 Request timed out.';
|
return '🐢 Request timed out.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof QueueIsFullError) {
|
if (error instanceof QueueIsFullError) {
|
||||||
logger.warn(`${source}: Request queue for ${error.url.host} is full.`, ctx);
|
logger.warn(`${source}: Request queue is full.`, ctx);
|
||||||
|
|
||||||
return `⏳ Request queue for ${error.url.host} is full. Please try again later or consider self-hosting.`;
|
return '⏳ Request queue is full. Please try again later or consider self-hosting.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof HttpError) {
|
if (error instanceof HttpError) {
|
||||||
logger.error(`${source}: Error when requesting url ${error.url}, HTTP status ${error.status} (${error.statusText}), headers: ${JSON.stringify(error.headers)}, stack: ${error.stack}.`, ctx);
|
logger.error(`${source}: HTTP status ${error.status} (${error.statusText}), headers: ${JSON.stringify(error.headers)}, stack: ${error.stack}.`, ctx);
|
||||||
|
|
||||||
if (error.status >= 500) {
|
if (error.status >= 500) {
|
||||||
return `❌ Remote server ${error.url.host} has issues. We can't fix this, please try later again.`;
|
return `❌ Remote server has issues. We can't fix this, please try later again.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `❌ Request to ${error.url.host} failed with status ${error.status} (${error.statusText}). Request-id: ${ctx.id}.`;
|
return `❌ Request failed with status ${error.status} (${error.statusText}). Request-id: ${ctx.id}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cause = (error as Error & { cause?: unknown }).cause;
|
const cause = (error as Error & { cause?: unknown }).cause;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { DoodStream } from './DoodStream';
|
import { DoodStream } from './DoodStream';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
|
|
@ -7,18 +8,26 @@ import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new DoodStream(new FetcherMock(`${__dirname}/__fixtures__/DoodStream`))]);
|
const extractorRegistry = new ExtractorRegistry(logger, [new DoodStream(new FetcherMock(`${__dirname}/__fixtures__/DoodStream`))]);
|
||||||
|
|
||||||
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow.test.org', mediaFlowProxyPassword: 'test' });
|
const ctx = createTestContext();
|
||||||
|
|
||||||
describe('DoodStream', () => {
|
describe('DoodStream', () => {
|
||||||
test('dood.to', async () => {
|
test('dood.to', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('http://dood.to/e/sk1m9eumzyjj'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('http://dood.to/e/sk1m9eumzyjj'), CountryCode.de)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doodster', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://dooodster.com/e/1cfcevn6dg8shrfvht22odxw2lty18hr'), CountryCode.de)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing pass_md5 -> not found', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://dood.to/e/gy8l8mb2i311'), CountryCode.mx)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can guess height from title', async () => {
|
test('can guess height from title', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://do7go.com/e/dfx8me4un4ul'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://do7go.com/e/dfx8me4un4ul'), CountryCode.fr)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('not found', async () => {
|
test('cloudflare storage', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://myvidplay.com/e/cz7cus0bvlzr'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://doodstream.com/e/wfpwtsgyr1xi'), CountryCode.mx)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import bytes from 'bytes';
|
import bytes from 'bytes';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
|
import randomstring from 'randomstring';
|
||||||
import { NotFoundError } from '../error';
|
import { NotFoundError } from '../error';
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
import { buildMediaFlowProxyExtractorRedirectUrl, supportsMediaFlowProxy } from '../utils';
|
import { Fetcher } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class DoodStream extends Extractor {
|
export class DoodStream extends Extractor {
|
||||||
|
|
@ -10,44 +11,102 @@ export class DoodStream extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'DoodStream';
|
public readonly label = 'DoodStream';
|
||||||
|
|
||||||
public override readonly ttl: number = 21600000; // 6h
|
private readonly fetcher: Fetcher;
|
||||||
|
|
||||||
public override viaMediaFlowProxy = true;
|
public constructor(fetcher: Fetcher) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
/** @see https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/doodstream.py */
|
/** @see https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/doodstream.py */
|
||||||
public supports(ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/dood|do[0-9]go|doood|dooood|ds2play|ds2video|dsvplay|d0o0d|do0od|d0000d|d000d|myvidplay|vidply|all3do|doply|vide0|vvide0|d-s/) && supportsMediaFlowProxy(ctx);
|
return [
|
||||||
|
'all3do.com',
|
||||||
|
'd-s.io',
|
||||||
|
'd0000d.com',
|
||||||
|
'd000d.com',
|
||||||
|
'd0o0d.com',
|
||||||
|
'do0od.com',
|
||||||
|
'do7go.com',
|
||||||
|
'dood.cx',
|
||||||
|
'dood.la',
|
||||||
|
'dood.li',
|
||||||
|
'dood.pm',
|
||||||
|
'dood.re',
|
||||||
|
'dood.sh',
|
||||||
|
'dood.so',
|
||||||
|
'dood.stream',
|
||||||
|
'dood.to',
|
||||||
|
'dood.watch',
|
||||||
|
'dood.wf',
|
||||||
|
'dood.work',
|
||||||
|
'dood.ws',
|
||||||
|
'dood.yt',
|
||||||
|
'doodcdn.io',
|
||||||
|
'doods.pro',
|
||||||
|
'doodstream.co',
|
||||||
|
'doodstream.com',
|
||||||
|
'dooodster.com',
|
||||||
|
'dooood.com',
|
||||||
|
'doply.net',
|
||||||
|
'ds2play.com',
|
||||||
|
'ds2video.com',
|
||||||
|
'vide0.net',
|
||||||
|
'vidply.com',
|
||||||
|
'vvide0.com',
|
||||||
|
].includes(url.host);
|
||||||
};
|
};
|
||||||
|
|
||||||
public override normalize(url: URL): URL {
|
public override normalize(url: URL): URL {
|
||||||
const videoId = url.pathname.replace(/\/+$/, '').split('/').at(-1) as string;
|
const supportedHosts = [
|
||||||
|
'all3do.com',
|
||||||
|
'd-s.io',
|
||||||
|
'doodstream.com',
|
||||||
|
'vide0.net',
|
||||||
|
'vidply.com',
|
||||||
|
];
|
||||||
|
const host = supportedHosts.includes(url.host) ? url.host : 'd-s.io';
|
||||||
|
|
||||||
return new URL(`http://dood.to/e/${videoId}`);
|
const videoId = url.pathname.split('/').slice(-1)[0] as string;
|
||||||
|
|
||||||
|
return new URL(`/d/${videoId}`, `${url.protocol}//${host}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
||||||
const headers = { Referer: meta.referer ?? url.href };
|
const html = await this.fetcher.text(ctx, url);
|
||||||
|
|
||||||
const html = await this.fetcher.text(ctx, url, { headers });
|
const passMd5Match = html.match(/\/pass_md5\/[\w-]+\/([\w-]+)/);
|
||||||
|
if (!passMd5Match) {
|
||||||
if (/Video not found/.test(html)) {
|
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = passMd5Match[1] as string;
|
||||||
|
|
||||||
|
const baseUrl = await this.fetcher.text(ctx, new URL(passMd5Match[0], url.origin));
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const title = $('title').text().trim().replace(/ - DoodStream$/, '').trim();
|
const title = $('title').text().trim().replace(/ - DoodStream$/, '').trim();
|
||||||
|
const sizeMatch = html.match(/([\d.]+ ?[GM]B)/) as string[];
|
||||||
|
|
||||||
const downloadHtml = await this.fetcher.text(ctx, new URL(url.href.replace('/e/', '/d/')));
|
const mp4Url = baseUrl.includes('cloudflarestorage.')
|
||||||
const sizeMatch = downloadHtml.match(/([\d.]+ ?[GM]B)/);
|
? new URL(baseUrl) // TODO: headers needed here? compare with resolveurl
|
||||||
|
: new URL(`${baseUrl}${randomstring.generate(10)}?token=${token}&expiry=${Date.now()}`);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Doodstream', url, headers),
|
url: mp4Url,
|
||||||
format: Format.mp4,
|
format: Format.mp4,
|
||||||
|
label: this.label,
|
||||||
|
sourceId: `${this.id}_${countryCode}`,
|
||||||
|
ttl: this.ttl,
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
countryCodes: [countryCode],
|
||||||
title,
|
title,
|
||||||
...(sizeMatch && { bytes: bytes.parse(sizeMatch[1] as string) as number }),
|
bytes: bytes.parse(sizeMatch[1] as string) as number,
|
||||||
|
},
|
||||||
|
requestHeaders: {
|
||||||
|
Referer: url.origin,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { Dropload } from './Dropload';
|
import { Dropload } from './Dropload';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
|
|
@ -11,22 +12,18 @@ const ctx = createTestContext();
|
||||||
|
|
||||||
describe('Dropload', () => {
|
describe('Dropload', () => {
|
||||||
test('dropload.io', async () => {
|
test('dropload.io', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/embed-lyo2h1snpe5c.html'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/embed-lyo2h1snpe5c.html'), CountryCode.de)).toMatchSnapshot();
|
||||||
});
|
|
||||||
|
|
||||||
test('dropload unknown height and size', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/gf2apij3ql37'))).toMatchSnapshot();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('file not found', async () => {
|
test('file not found', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/asdfghijklmn.html'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/asdfghijklmn.html'), CountryCode.de)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('processing / internal problem', async () => {
|
test('processing / internal problem', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/7xtxuac84xyh.html'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/7xtxuac84xyh.html'), CountryCode.de)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('download URL', async () => {
|
test('download URL', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/d/xhcmgcc2txnv'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/d/xhcmgcc2txnv'), CountryCode.en)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import bytes from 'bytes';
|
import bytes from 'bytes';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { NotFoundError } from '../error';
|
import { NotFoundError } from '../error';
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
import { extractUrlFromPacked, guessHeightFromPlaylist } from '../utils';
|
import { extractUrlFromPacked, Fetcher } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class Dropload extends Extractor {
|
export class Dropload extends Extractor {
|
||||||
|
|
@ -10,48 +10,47 @@ export class Dropload extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'Dropload';
|
public readonly label = 'Dropload';
|
||||||
|
|
||||||
public override readonly ttl: number = 10800000; // 3h
|
private readonly fetcher: Fetcher;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/dropload|dr0pstream/);
|
return null !== url.host.match(/dropload/);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override readonly normalize = (url: URL): URL => new URL(url.href.replace('/d/', '/').replace('/e/', '/').replace('/embed-', '/'));
|
public override readonly normalize = (url: URL): URL => new URL(url.href.replace('/d/', '/').replace('/e/', '/').replace('/embed-', '/'));
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
||||||
const headers = { Referer: meta.referer ?? url.href };
|
const html = await this.fetcher.text(ctx, url);
|
||||||
|
|
||||||
const html = await this.fetcher.text(ctx, url, { headers });
|
|
||||||
|
|
||||||
if (html.includes('File Not Found') || html.includes('Pending in queue')) {
|
if (html.includes('File Not Found') || html.includes('Pending in queue')) {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const playlistUrl = extractUrlFromPacked(html, [/sources:\[{file:"(.*?)"/]);
|
const heightMatch = html.match(/\d{3,}x(\d{3,}),/) as string[];
|
||||||
const playlistHeaders = { Referer: 'https://dr0pstream.com/' };
|
|
||||||
|
|
||||||
const heightMatch = html.match(/\d{3,}x(\d{3,}),/);
|
const sizeMatch = html.match(/([\d.]+ ?[GM]B)/) as string[];
|
||||||
const height = heightMatch
|
|
||||||
? parseInt(heightMatch[1] as string)
|
|
||||||
: meta.height ?? await guessHeightFromPlaylist(ctx, this.fetcher, playlistUrl, { headers: playlistHeaders });
|
|
||||||
|
|
||||||
const sizeMatch = html.match(/([\d.]+ ?[GM]B)/);
|
|
||||||
const size = sizeMatch ? bytes.parse(sizeMatch[1] as string) as number : undefined;
|
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const title = $('.videoplayer h1').text().trim();
|
const title = $('.videoplayer h1').text().trim();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: playlistUrl,
|
url: extractUrlFromPacked(html, [/sources:\[{file:"(.*?)"/]),
|
||||||
format: Format.hls,
|
format: Format.hls,
|
||||||
|
label: this.label,
|
||||||
|
sourceId: `${this.id}_${countryCode}`,
|
||||||
|
ttl: this.ttl,
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
bytes: bytes.parse(sizeMatch[1] as string) as number,
|
||||||
|
countryCodes: [countryCode],
|
||||||
|
height: parseInt(heightMatch[1] as string),
|
||||||
title,
|
title,
|
||||||
...(size && { bytes: size }),
|
|
||||||
...(height && { height }),
|
|
||||||
},
|
},
|
||||||
requestHeaders: playlistHeaders,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExternalUrl } from './ExternalUrl';
|
import { ExternalUrl } from './ExternalUrl';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
|
|
@ -11,10 +12,14 @@ const ctx = createTestContext({ includeExternalUrls: 'on' });
|
||||||
|
|
||||||
describe('ExternalUrl', () => {
|
describe('ExternalUrl', () => {
|
||||||
test('404 - not found', async () => {
|
test('404 - not found', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://streamtape.com/e/gjA1OQ4klyHxgJ'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://streamtape.com/e/gjA1OQ4klyHxgJ'), CountryCode.fr)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('netu.fanstream.us', async () => {
|
test('netu.fanstream.us', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://netu.fanstream.us/e/0DFgfkcXOsDP'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://netu.fanstream.us/e/0DFgfkcXOsDP'), CountryCode.fr)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('johnalwayssame.com with title', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://johnalwayssame.com/e/cqy9oue7sv0g'), CountryCode.fr, 'Black Mirror 4x2')).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
import { BlockedError } from '../error';
|
||||||
import { showExternalUrls } from '../utils';
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
|
import { Fetcher, showExternalUrls } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class ExternalUrl extends Extractor {
|
export class ExternalUrl extends Extractor {
|
||||||
|
|
@ -7,20 +8,43 @@ export class ExternalUrl extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'External';
|
public readonly label = 'External';
|
||||||
|
|
||||||
public override readonly ttl = 21600000; // 6h
|
public override readonly ttl = 3600000; // 1h
|
||||||
|
|
||||||
|
private readonly fetcher: Fetcher;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
public supports(ctx: Context, url: URL): boolean {
|
public supports(ctx: Context, url: URL): boolean {
|
||||||
return showExternalUrls(ctx.config) && null !== url.host.match(/.*/);
|
return showExternalUrls(ctx.config) && null !== url.host.match(/.*/);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(_ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode, title: string | undefined): Promise<UrlResult[]> {
|
||||||
|
try {
|
||||||
|
// Make sure the URL is accessible, but avoid causing noise and delays doing this
|
||||||
|
await this.fetcher.head(ctx, url, { noFlareSolverr: true, timeout: 1000, headers: { Referer: url.origin } });
|
||||||
|
} catch (error) {
|
||||||
|
/* istanbul ignore if */
|
||||||
|
if (!(error instanceof BlockedError)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: url,
|
url: url,
|
||||||
format: Format.unknown,
|
format: Format.unknown,
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
label: `${url.host}`,
|
label: `${url.host}`,
|
||||||
meta,
|
sourceId: `${this.id}_${countryCode}`,
|
||||||
|
ttl: this.ttl,
|
||||||
|
meta: {
|
||||||
|
countryCodes: [countryCode],
|
||||||
|
title,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { NotFoundError } from '../error';
|
import { NotFoundError } from '../error';
|
||||||
import { Context, Format, InternalUrlResult, Meta, UrlResult } from '../types';
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
import { Fetcher } from '../utils';
|
|
||||||
|
|
||||||
export abstract class Extractor {
|
export abstract class Extractor {
|
||||||
public abstract readonly id: string;
|
public abstract readonly id: string;
|
||||||
|
|
@ -9,34 +8,19 @@ export abstract class Extractor {
|
||||||
|
|
||||||
public readonly ttl: number = 900000; // 15m
|
public readonly ttl: number = 900000; // 15m
|
||||||
|
|
||||||
public readonly cacheVersion: number | undefined = undefined;
|
|
||||||
|
|
||||||
public readonly viaMediaFlowProxy: boolean = false;
|
public readonly viaMediaFlowProxy: boolean = false;
|
||||||
|
|
||||||
protected readonly fetcher: Fetcher;
|
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract supports(ctx: Context, url: URL): boolean;
|
public abstract supports(ctx: Context, url: URL): boolean;
|
||||||
|
|
||||||
public normalize(url: URL): URL {
|
public normalize(url: URL): URL {
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
protected abstract extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]>;
|
protected abstract extractInternal(ctx: Context, url: URL, countryCode: CountryCode, title?: string | undefined): Promise<UrlResult[]>;
|
||||||
|
|
||||||
public async extract(ctx: Context, url: URL, meta: Meta): Promise<UrlResult[]> {
|
public async extract(ctx: Context, url: URL, countryCode: CountryCode, title?: string | undefined): Promise<UrlResult[]> {
|
||||||
try {
|
try {
|
||||||
return (await this.extractInternal(ctx, url, meta)).map(
|
return await this.extractInternal(ctx, url, countryCode, title);
|
||||||
urlResult => ({
|
|
||||||
...urlResult,
|
|
||||||
label: this.formatLabel(urlResult.label ?? this.label),
|
|
||||||
ttl: this.ttl,
|
|
||||||
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof NotFoundError) {
|
if (error instanceof NotFoundError) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -48,15 +32,14 @@ export abstract class Extractor {
|
||||||
format: Format.unknown,
|
format: Format.unknown,
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
error,
|
error,
|
||||||
label: this.formatLabel(this.label),
|
label: this.label,
|
||||||
ttl: 0,
|
sourceId: `${this.id}`,
|
||||||
meta,
|
meta: {
|
||||||
|
countryCodes: [countryCode],
|
||||||
|
...(title && { title }),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private formatLabel(label: string): string {
|
|
||||||
return this.viaMediaFlowProxy ? `${label} (MFP)` : label;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { createExtractors } from './index';
|
import { createExtractors } from './index';
|
||||||
|
|
@ -11,53 +12,46 @@ describe('ExtractorRegistry', () => {
|
||||||
const ctx = createTestContext();
|
const ctx = createTestContext();
|
||||||
|
|
||||||
test('returns error result from extractor', async () => {
|
test('returns error result from extractor', async () => {
|
||||||
const urlResult = await extractorRegistry.handle(ctx, new URL('https://some-url.test'));
|
const urlResult = await extractorRegistry.handle(ctx, new URL('https://some-url.test'), CountryCode.de);
|
||||||
|
|
||||||
expect(urlResult).toMatchSnapshot();
|
expect(urlResult).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns external URLs if enabled by config', async () => {
|
test('returns external URLs if enabled by config', async () => {
|
||||||
const urlResult = await extractorRegistry.handle({ ...ctx, config: { ...ctx.config, includeExternalUrls: 'on' } }, new URL('https://mixdrop.ag/e/3nzwveprim63or6'));
|
const urlResult = await extractorRegistry.handle({ ...ctx, config: { ...ctx.config, includeExternalUrls: 'on' } }, new URL('https://mixdrop.ag/e/3nzwveprim63or6'), CountryCode.de);
|
||||||
|
|
||||||
expect(urlResult).toMatchSnapshot();
|
expect(urlResult).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not return external URLs by default', async () => {
|
test('does not return external URLs by default', async () => {
|
||||||
const urlResult = await extractorRegistry.handle(ctx, new URL('https://mixdrop.ag/e/l7v73zqrfdj19z'));
|
const urlResult = await extractorRegistry.handle(ctx, new URL('https://mixdrop.ag/e/l7v73zqrfdj19z'), CountryCode.de);
|
||||||
|
|
||||||
expect(urlResult).toStrictEqual([]);
|
expect(urlResult).toStrictEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns from memory cache if possible', async () => {
|
test('returns from memory cache if possible', async () => {
|
||||||
const urlResults1 = await extractorRegistry.handle(ctx, new URL('https://dropload.io/lyo2h1snpe5c.html'));
|
const urlResults1 = await extractorRegistry.handle(ctx, new URL('https://dropload.io/lyo2h1snpe5c.html'), CountryCode.de);
|
||||||
const urlResults2 = await extractorRegistry.handle(ctx, new URL('https://dropload.io/lyo2h1snpe5c.html'));
|
const urlResults2 = await extractorRegistry.handle(ctx, new URL('https://dropload.io/lyo2h1snpe5c.html'), CountryCode.de);
|
||||||
|
|
||||||
expect(urlResults1).not.toStrictEqual([]);
|
expect(urlResults1).not.toStrictEqual([]);
|
||||||
expect(urlResults2).not.toStrictEqual([]);
|
expect(urlResults2).not.toStrictEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ignores not found errors but caches them', async () => {
|
test('ignores not found errors but caches them', async () => {
|
||||||
const urlResults1 = await extractorRegistry.handle(ctx, new URL('https://dropload.io/asdfghijklmn.html'));
|
const urlResults1 = await extractorRegistry.handle(ctx, new URL('https://dropload.io/asdfghijklmn.html'), CountryCode.de);
|
||||||
const urlResults2 = await extractorRegistry.handle(ctx, new URL('https://dropload.io/asdfghijklmn.html'));
|
const urlResults2 = await extractorRegistry.handle(ctx, new URL('https://dropload.io/asdfghijklmn.html'), CountryCode.de);
|
||||||
|
|
||||||
expect(urlResults1).toStrictEqual([]);
|
expect(urlResults1).toStrictEqual([]);
|
||||||
expect(urlResults2).toStrictEqual([]);
|
expect(urlResults2).toStrictEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns external url for error', async () => {
|
test('returns external url for error', async () => {
|
||||||
const urlResults = await extractorRegistry.handle(ctx, new URL('https://dropload.io/mocked-blocked.html'));
|
const urlResults = await extractorRegistry.handle(ctx, new URL('https://dropload.io/mocked-blocked.html'), CountryCode.de);
|
||||||
expect(urlResults).toMatchSnapshot();
|
expect(urlResults).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('empty results are cached', async () => {
|
test('returns external url for error with title', async () => {
|
||||||
const urlResults = await extractorRegistry.handle(ctx, new URL('https://dropload.io/asdfghijklmn.html'), { title: 'title' });
|
const urlResults = await extractorRegistry.handle(ctx, new URL('https://dropload.io/mocked-blocked-2.html'), CountryCode.de, 'a title!');
|
||||||
expect(urlResults).toMatchSnapshot();
|
expect(urlResults).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats returns something', async () => {
|
|
||||||
const stats = extractorRegistry.stats();
|
|
||||||
|
|
||||||
expect(stats).toHaveProperty('urlResultCache');
|
|
||||||
expect(stats.urlResultCache).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,112 +1,42 @@
|
||||||
import { Cacheable, CacheableMemory, Keyv } from 'cacheable';
|
import TTLCache from '@isaacs/ttlcache';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { Context, Format, Meta, UrlResult } from '../types';
|
import { Context, CountryCode, UrlResult } from '../types';
|
||||||
import { createKeyvSqlite, envGet, isExtractorDisabled } from '../utils';
|
import { isExtractorDisabled } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class ExtractorRegistry {
|
export class ExtractorRegistry {
|
||||||
private readonly logger: winston.Logger;
|
private readonly logger: winston.Logger;
|
||||||
private readonly extractors: Extractor[];
|
private readonly extractors: Extractor[];
|
||||||
|
private readonly urlResultCache: TTLCache<string, UrlResult[]>;
|
||||||
private readonly urlResultCache: Cacheable;
|
|
||||||
private readonly lazyUrlResultCache: Cacheable;
|
|
||||||
|
|
||||||
public constructor(logger: winston.Logger, extractors: Extractor[]) {
|
public constructor(logger: winston.Logger, extractors: Extractor[]) {
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.extractors = extractors;
|
this.extractors = extractors;
|
||||||
|
this.urlResultCache = new TTLCache({ max: 4096 });
|
||||||
this.urlResultCache = new Cacheable({
|
|
||||||
nonBlocking: true,
|
|
||||||
primary: new Keyv({ store: new CacheableMemory({ lruSize: 1024 }) }),
|
|
||||||
secondary: createKeyvSqlite('extractor-cache'),
|
|
||||||
stats: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.lazyUrlResultCache = new Cacheable({
|
|
||||||
nonBlocking: true,
|
|
||||||
primary: new Keyv({ store: new CacheableMemory({ lruSize: 1024 }) }),
|
|
||||||
secondary: createKeyvSqlite('extractor-lazy-cache'),
|
|
||||||
stats: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public stats() {
|
public async handle(ctx: Context, url: URL, countryCode: CountryCode, title?: string | undefined): Promise<UrlResult[]> {
|
||||||
return {
|
|
||||||
urlResultCache: this.urlResultCache.stats,
|
|
||||||
lazyUrlResultCache: this.lazyUrlResultCache.stats,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
public async handle(ctx: Context, url: URL, meta?: Meta, allowLazy?: boolean): Promise<UrlResult[]> {
|
|
||||||
const extractor = this.extractors.find(extractor => !isExtractorDisabled(ctx.config, extractor) && extractor.supports(ctx, url));
|
const extractor = this.extractors.find(extractor => !isExtractorDisabled(ctx.config, extractor) && extractor.supports(ctx, url));
|
||||||
if (!extractor) {
|
if (!extractor) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedUrl = extractor.normalize(url);
|
const normalizedUrl = extractor.normalize(url);
|
||||||
const cacheKey = this.determineCacheKey(ctx, extractor, normalizedUrl);
|
const cacheKey = `${extractor.id}_${normalizedUrl}`;
|
||||||
|
|
||||||
const storedDataRaw = await this.urlResultCache.getRaw<UrlResult[]>(cacheKey);
|
let urlResults = this.urlResultCache.get(cacheKey) ?? [];
|
||||||
const expires = storedDataRaw?.expires;
|
if (this.urlResultCache.has(cacheKey)) {
|
||||||
if (storedDataRaw && expires) {
|
return urlResults.map(urlResult => ({ ...urlResult, ttl: this.urlResultCache.getRemainingTTL(cacheKey) }));
|
||||||
const ttl = expires - Date.now();
|
|
||||||
|
|
||||||
/* istanbul ignore if */
|
|
||||||
if (ttl > 0) {
|
|
||||||
return (storedDataRaw.value as UrlResult[]).map(urlResult => ({ ...urlResult, ttl, url: new URL(urlResult.url) }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const lazyUrlResults = await this.lazyUrlResultCache.get<UrlResult[]>(normalizedUrl.href) ?? [];
|
this.logger.info(`Extract stream URL using ${extractor.id} extractor from ${url}`, ctx);
|
||||||
|
|
||||||
/* istanbul ignore next */
|
urlResults = await extractor.extract(ctx, normalizedUrl, countryCode, title);
|
||||||
if (
|
|
||||||
lazyUrlResults.length && allowLazy && !extractor.viaMediaFlowProxy
|
|
||||||
&& lazyUrlResults.every(urlResult => urlResult.format !== Format.hls) // related to Android issues, e.g. https://github.com/Stremio/stremio-bugs/issues/1574 or https://github.com/Stremio/stremio-bugs/issues/1579
|
|
||||||
) {
|
|
||||||
// generate lazy extract urls
|
|
||||||
return lazyUrlResults.map((urlResult, index) => {
|
|
||||||
const extractUrl = new URL(`${envGet('PROTOCOL')}:${envGet('HOST')}/extract/`);
|
|
||||||
|
|
||||||
extractUrl.searchParams.set('index', `${index}`);
|
if (!urlResults.some(urlResult => urlResult.error) && extractor.ttl) {
|
||||||
extractUrl.searchParams.set('url', url.href);
|
this.urlResultCache.set(cacheKey, urlResults, { ttl: extractor.ttl });
|
||||||
|
|
||||||
return { ...urlResult, url: extractUrl };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.info(`Extract ${url} using ${extractor.id} extractor`, ctx);
|
|
||||||
|
|
||||||
const mergedMeta: Meta = { ...meta, ...lazyUrlResults[0]?.meta };
|
|
||||||
const urlResults = await extractor.extract(ctx, normalizedUrl, { extractorId: extractor.id, ...mergedMeta });
|
|
||||||
|
|
||||||
if (!Object.keys(mergedMeta).length || urlResults.some(urlResult => urlResult.error)) {
|
|
||||||
await this.urlResultCache.delete(cacheKey);
|
|
||||||
await this.lazyUrlResultCache.delete(normalizedUrl.href);
|
|
||||||
|
|
||||||
return urlResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ttl = urlResults.length ? extractor.ttl : 43200000; // 12h
|
|
||||||
|
|
||||||
await this.urlResultCache.set<UrlResult[]>(cacheKey, urlResults, ttl);
|
|
||||||
|
|
||||||
if (extractor.id !== 'external') {
|
|
||||||
await this.lazyUrlResultCache.set<UrlResult[]>(normalizedUrl.href, urlResults, 2629800000); // 1 month
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return urlResults;
|
return urlResults;
|
||||||
};
|
};
|
||||||
|
|
||||||
private determineCacheKey(ctx: Context, extractor: Extractor, url: URL): string {
|
|
||||||
let suffix = '';
|
|
||||||
if (extractor.viaMediaFlowProxy) {
|
|
||||||
suffix += `_${ctx.config.mediaFlowProxyUrl}`;
|
|
||||||
}
|
|
||||||
if (extractor.cacheVersion) {
|
|
||||||
suffix += `_${extractor.cacheVersion}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${extractor.id}_${url}${suffix}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { Fastream } from './Fastream';
|
import { Fastream } from './Fastream';
|
||||||
|
|
@ -11,10 +12,6 @@ const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow-proxy.test
|
||||||
|
|
||||||
describe('Fastream', () => {
|
describe('Fastream', () => {
|
||||||
test('fastream.to embed', async () => {
|
test('fastream.to embed', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://fastream.to/embed-3aooif4ozt10.html'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://fastream.to/embed-3aooif4ozt10.html'), CountryCode.es)).toMatchSnapshot();
|
||||||
});
|
|
||||||
|
|
||||||
test('expired or deleted', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://fastream.to/embed-a9rp1d1pqom7.html'))).toMatchSnapshot();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,25 @@
|
||||||
import bytes from 'bytes';
|
import bytes from 'bytes';
|
||||||
import { NotFoundError } from '../error';
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
import { buildMediaFlowProxyExtractorStreamUrl, Fetcher, supportsMediaFlowProxy } from '../utils';
|
||||||
import { buildMediaFlowProxyExtractorStreamUrl, supportsMediaFlowProxy } from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class Fastream extends Extractor {
|
export class Fastream extends Extractor {
|
||||||
public readonly id = 'fastream';
|
public readonly id = 'fastream';
|
||||||
|
|
||||||
public readonly label = 'Fastream';
|
public readonly label = 'Fastream (via MediaFlow Proxy)';
|
||||||
|
|
||||||
|
public override readonly ttl = 0;
|
||||||
|
|
||||||
public override viaMediaFlowProxy = true;
|
public override viaMediaFlowProxy = true;
|
||||||
|
|
||||||
|
private readonly fetcher: Fetcher;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
public supports(ctx: Context, url: URL): boolean {
|
public supports(ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/fastream/) && supportsMediaFlowProxy(ctx);
|
return null !== url.host.match(/fastream/) && supportsMediaFlowProxy(ctx);
|
||||||
}
|
}
|
||||||
|
|
@ -19,17 +28,11 @@ export class Fastream extends Extractor {
|
||||||
return new URL(url.href.replace('/e/', '/embed-').replace('/d/', '/embed-'));
|
return new URL(url.href.replace('/e/', '/embed-').replace('/d/', '/embed-'));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
||||||
const headers = { Referer: meta.referer ?? url.href };
|
const playlistUrl = await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'Fastream', url);
|
||||||
|
|
||||||
const downloadUrl = new URL(url.href.replace('/embed-', '/d/'));
|
const downloadUrl = new URL(url.href.replace('/embed-', '/d/'));
|
||||||
const html = await this.fetcher.text(ctx, downloadUrl, { headers });
|
const html = await this.fetcher.text(ctx, downloadUrl);
|
||||||
|
|
||||||
if (/No such file/.test(html)) {
|
|
||||||
throw new NotFoundError();
|
|
||||||
}
|
|
||||||
|
|
||||||
const playlistUrl = await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'Fastream', url, headers);
|
|
||||||
|
|
||||||
const heightAndSizeMatch = html.match(/\d{3,}x(\d{3,}), ([\d.]+ ?[GM]B)/) as string[];
|
const heightAndSizeMatch = html.match(/\d{3,}x(\d{3,}), ([\d.]+ ?[GM]B)/) as string[];
|
||||||
const titleMatch = html.match(/>Download (.*?)</) as string[];
|
const titleMatch = html.match(/>Download (.*?)</) as string[];
|
||||||
|
|
@ -38,8 +41,11 @@ export class Fastream extends Extractor {
|
||||||
{
|
{
|
||||||
url: playlistUrl,
|
url: playlistUrl,
|
||||||
format: Format.hls,
|
format: Format.hls,
|
||||||
|
label: this.label,
|
||||||
|
sourceId: `${this.id}_${countryCode}`,
|
||||||
|
ttl: this.ttl,
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
countryCodes: [countryCode],
|
||||||
bytes: bytes.parse(heightAndSizeMatch[2] as string) as number,
|
bytes: bytes.parse(heightAndSizeMatch[2] as string) as number,
|
||||||
height: parseInt(heightAndSizeMatch[1] as string),
|
height: parseInt(heightAndSizeMatch[1] as string),
|
||||||
title: titleMatch[1],
|
title: titleMatch[1],
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import winston from 'winston';
|
|
||||||
import { createTestContext } from '../test';
|
|
||||||
import { FetcherMock } from '../utils';
|
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
|
||||||
import { FileLions } from './FileLions';
|
|
||||||
|
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new FileLions(new FetcherMock(`${__dirname}/__fixtures__/FileLions`))]);
|
|
||||||
|
|
||||||
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow.test.org', mediaFlowProxyPassword: 'test' });
|
|
||||||
|
|
||||||
describe('FileLions', () => {
|
|
||||||
test('filelions f', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://filelions.to/f/tyn45apubte2'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mivalyo v referer lock', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://mivalyo.com/v/tah5znapz3e5'), { referer: 'https://kinoger.com' })).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('file not found', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://filelions.to/v/ylcp2cu5qanb'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deleted by administration', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://callistanise.com/f/cy4t5nkerjrt'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
import bytes from 'bytes';
|
|
||||||
import * as cheerio from 'cheerio';
|
|
||||||
import { NotFoundError } from '../error';
|
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
|
||||||
import { buildMediaFlowProxyExtractorStreamUrl, supportsMediaFlowProxy, unpackEval } from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
|
||||||
|
|
||||||
/** @see https://github.com/Gujal00/ResolveURL/commits/master/script.module.resolveurl/lib/resolveurl/plugins/filelions.py */
|
|
||||||
export class FileLions extends Extractor {
|
|
||||||
public readonly id = 'filelions';
|
|
||||||
|
|
||||||
public readonly label = 'FileLions';
|
|
||||||
|
|
||||||
public override viaMediaFlowProxy = true;
|
|
||||||
|
|
||||||
public supports(ctx: Context, url: URL): boolean {
|
|
||||||
const supportedDomain = null !== url.host.match(/.*lions?/)
|
|
||||||
|| [
|
|
||||||
'6sfkrspw4u.sbs',
|
|
||||||
'ajmidyadfihayh.sbs',
|
|
||||||
'alhayabambi.sbs',
|
|
||||||
'anime7u.com',
|
|
||||||
'azipcdn.com',
|
|
||||||
'bingezove.com',
|
|
||||||
'callistanise.com',
|
|
||||||
'coolciima.online',
|
|
||||||
'dhtpre.com',
|
|
||||||
'dingtezuni.com',
|
|
||||||
'dintezuvio.com',
|
|
||||||
'e4xb5c2xnz.sbs',
|
|
||||||
'egsyxutd.sbs',
|
|
||||||
'fdewsdc.sbs',
|
|
||||||
'gsfomqu.sbs',
|
|
||||||
'javplaya.com',
|
|
||||||
'katomen.online',
|
|
||||||
'lumiawatch.top',
|
|
||||||
'minochinos.com',
|
|
||||||
'mivalyo.com',
|
|
||||||
'moflix-stream.click',
|
|
||||||
'motvy55.store',
|
|
||||||
'movearnpre.com',
|
|
||||||
'peytonepre.com',
|
|
||||||
'ryderjet.com',
|
|
||||||
'smoothpre.com',
|
|
||||||
'taylorplayer.com',
|
|
||||||
'techradar.ink',
|
|
||||||
'videoland.sbs',
|
|
||||||
'vidhide.com',
|
|
||||||
'vidhide.fun',
|
|
||||||
'vidhidefast.com',
|
|
||||||
'vidhidehub.com',
|
|
||||||
'vidhideplus.com',
|
|
||||||
'vidhidepre.com',
|
|
||||||
'vidhidepro.com',
|
|
||||||
'vidhidevip.com',
|
|
||||||
].includes(url.host);
|
|
||||||
|
|
||||||
return supportedDomain && supportsMediaFlowProxy(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override normalize(url: URL): URL {
|
|
||||||
return new URL(url.href.replace('/v/', '/f/').replace('/download/', '/f/').replace('/file/', '/f/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
|
||||||
const headers = { Referer: meta.referer ?? url.href };
|
|
||||||
|
|
||||||
const html = await this.fetcher.text(ctx, url, { headers });
|
|
||||||
|
|
||||||
if (html.includes('This video can be watched as embed only')) {
|
|
||||||
return await this.extractInternal(ctx, new URL(url.href.replace('/f/', '/v/')), meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/File Not Found|deleted by administration/.test(html)) {
|
|
||||||
throw new NotFoundError();
|
|
||||||
}
|
|
||||||
|
|
||||||
const unpacked = unpackEval(html);
|
|
||||||
const heightMatch = unpacked.match(/(\d{3,})p/) as string[];
|
|
||||||
|
|
||||||
const sizeMatch = html.match(/([\d.]+ ?[GM]B)/);
|
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
|
||||||
const title = $('meta[name="description"]').attr('content') as string;
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
url: await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'FileLions', url, headers),
|
|
||||||
format: Format.hls,
|
|
||||||
meta: {
|
|
||||||
...meta,
|
|
||||||
height: parseInt(heightMatch[1] as string),
|
|
||||||
...(sizeMatch && { bytes: bytes.parse(sizeMatch[1] as string) as number }),
|
|
||||||
...(title && { title }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import winston from 'winston';
|
|
||||||
import { createTestContext } from '../test';
|
|
||||||
import { FetcherMock } from '../utils';
|
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
|
||||||
import { FileMoon } from './FileMoon';
|
|
||||||
|
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new FileMoon(new FetcherMock(`${__dirname}/__fixtures__/FileMoon`))]);
|
|
||||||
|
|
||||||
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow.test.org', mediaFlowProxyPassword: 'test' });
|
|
||||||
|
|
||||||
describe('FileMoon', () => {
|
|
||||||
test('z1ekv717 d', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://z1ekv717.fun/d/wkhcbggdxf1d'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('page not found', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://filemoon.sx/e/n7i8zodwjqr9'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('missing height', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://filemoon.to/d/ugj7f1kq94p1'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import * as cheerio from 'cheerio';
|
|
||||||
import { NotFoundError } from '../error';
|
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
|
||||||
import {
|
|
||||||
buildMediaFlowProxyExtractorStreamUrl,
|
|
||||||
supportsMediaFlowProxy,
|
|
||||||
unpackEval,
|
|
||||||
} from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
|
||||||
|
|
||||||
/** @see https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/filemoon.py */
|
|
||||||
export class FileMoon extends Extractor {
|
|
||||||
public readonly id = 'filemoon';
|
|
||||||
|
|
||||||
public readonly label = 'FileMoon';
|
|
||||||
|
|
||||||
public override viaMediaFlowProxy = true;
|
|
||||||
|
|
||||||
public supports(ctx: Context, url: URL): boolean {
|
|
||||||
const supportedDomain = null !== url.host.match(/filemoon/)
|
|
||||||
|| [
|
|
||||||
'1azayf9w.xyz',
|
|
||||||
'222i8x.lol',
|
|
||||||
'81u6xl9d.xyz',
|
|
||||||
'8mhlloqo.fun',
|
|
||||||
'96ar.com',
|
|
||||||
'bf0skv.org',
|
|
||||||
'boosteradx.online',
|
|
||||||
'c1z39.com',
|
|
||||||
'cinegrab.com',
|
|
||||||
'f51rm.com',
|
|
||||||
'furher.in',
|
|
||||||
'kerapoxy.cc',
|
|
||||||
'l1afav.net',
|
|
||||||
'moonmov.pro',
|
|
||||||
'smdfs40r.skin',
|
|
||||||
'xcoic.com',
|
|
||||||
'z1ekv717.fun',
|
|
||||||
].includes(url.host);
|
|
||||||
|
|
||||||
return supportedDomain && supportsMediaFlowProxy(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override normalize(url: URL): URL {
|
|
||||||
return new URL(url.href.replace('/e/', '/d/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta, originalUrl?: URL): Promise<InternalUrlResult[]> {
|
|
||||||
const headers = { Referer: meta.referer ?? url.href };
|
|
||||||
|
|
||||||
const html = await this.fetcher.text(ctx, url, { headers });
|
|
||||||
|
|
||||||
if (/Page not found/.test(html)) {
|
|
||||||
throw new NotFoundError();
|
|
||||||
}
|
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
|
||||||
const title = $('h3').text().trim();
|
|
||||||
|
|
||||||
const iframeUrlMatches = Array.from(html.matchAll(/iframe.*?src=["'](.*?)["']/g));
|
|
||||||
if (iframeUrlMatches.length) {
|
|
||||||
// Use last match because there can be fake adblock catcher urls before
|
|
||||||
return await this.extractInternal(ctx, new URL((iframeUrlMatches[iframeUrlMatches.length - 1] as RegExpExecArray)[1] as string), { title, ...meta }, url);
|
|
||||||
}
|
|
||||||
|
|
||||||
const playlistUrl = await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'FileMoon', originalUrl as URL, headers);
|
|
||||||
|
|
||||||
const unpacked = unpackEval(html);
|
|
||||||
const heightMatch = unpacked.match(/(\d{3,})p/);
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
url: playlistUrl,
|
|
||||||
format: Format.hls,
|
|
||||||
meta: {
|
|
||||||
...meta,
|
|
||||||
...(heightMatch && { height: parseInt(heightMatch[1] as string) }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { Fsst } from './Fsst';
|
import { Fsst } from './Fsst';
|
||||||
|
|
@ -10,7 +11,15 @@ const extractorRegistry = new ExtractorRegistry(logger, [new Fsst(new FetcherMoc
|
||||||
const ctx = createTestContext();
|
const ctx = createTestContext();
|
||||||
|
|
||||||
describe('Fsst', () => {
|
describe('Fsst', () => {
|
||||||
test('Wake up Dead Man', async () => {
|
test('Blood & Sinners', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://fsst.online/embed/948429/'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://fsst.online/embed/900576/'), CountryCode.de)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Dead City', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://fsst.online/embed/901994/'), CountryCode.de)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('How to Train Your Dragon', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://fsst.online/embed/902668/'), CountryCode.de)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
|
import { Fetcher } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class Fsst extends Extractor {
|
export class Fsst extends Extractor {
|
||||||
|
|
@ -7,35 +8,44 @@ export class Fsst extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'Fsst';
|
public readonly label = 'Fsst';
|
||||||
|
|
||||||
public override readonly ttl: number = 10800000; // 3h
|
public override readonly ttl = 0;
|
||||||
|
|
||||||
|
private readonly fetcher: Fetcher;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/fsst/);
|
return null !== url.host.match(/fsst/);
|
||||||
};
|
};
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
||||||
const headers = { Referer: meta.referer ?? url.href };
|
const html = await this.fetcher.text(ctx, url);
|
||||||
|
|
||||||
const html = await this.fetcher.text(ctx, url, { headers, noProxyHeaders: true });
|
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const title = $('title').text().trim();
|
const title = $('title').text().trim();
|
||||||
|
|
||||||
const filesMatch = html.match(/file:"(.*)"/) as string[];
|
const filesMatch = html.match(/file:"(.*)"/) as string[];
|
||||||
|
|
||||||
const lastFile = (filesMatch[1] as string).split(',').pop() as string;
|
return (filesMatch[1] as string).split(',').map((fileString, index) => {
|
||||||
|
const heightAndUrlMatch = fileString.match(/\[?([\d]*)p?]?(.*)/) as string[];
|
||||||
|
const fileHref = heightAndUrlMatch[2] as string;
|
||||||
|
const heightFromFileHrefMatch = fileHref.match(/([\d]+)p/) as string[];
|
||||||
|
|
||||||
const heightAndUrlMatch = lastFile.match(/\[?([\d]*)p?]?(.*)/) as string[];
|
return {
|
||||||
const fileHref = heightAndUrlMatch[2] as string;
|
url: new URL(fileHref),
|
||||||
|
format: Format.mp4,
|
||||||
return [{
|
label: this.label,
|
||||||
url: await this.fetcher.getFinalRedirectUrl(ctx, new URL(fileHref), { headers, noProxyHeaders: true }, 1),
|
sourceId: `${this.id}_${countryCode}_${index}`,
|
||||||
format: Format.mp4,
|
meta: {
|
||||||
meta: {
|
countryCodes: [countryCode],
|
||||||
...meta,
|
height: parseInt(heightAndUrlMatch[1] || heightFromFileHrefMatch[1] as string),
|
||||||
height: parseInt(heightAndUrlMatch[1] as string),
|
title,
|
||||||
title,
|
},
|
||||||
},
|
};
|
||||||
}];
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import winston from 'winston';
|
|
||||||
import { createTestContext } from '../test';
|
|
||||||
import { FetcherMock } from '../utils';
|
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
|
||||||
import { HubCloud } from './HubCloud';
|
|
||||||
|
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new HubCloud(new FetcherMock(`${__dirname}/__fixtures__/HubCloud`))]);
|
|
||||||
|
|
||||||
const ctx = createTestContext();
|
|
||||||
|
|
||||||
describe('HubCloud', () => {
|
|
||||||
test('handle dexter original sin 2024 s01e01', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://hubcloud.one/drive/idt1evqfuviqiei'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handle crayon shin-chan 1993', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://hubcloud.one/drive/bffzqlpqfllfcld'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handle dark 2017 s03e08', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://hubcloud.one/drive/nknlofk8snfnknh'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handle goat 2026', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://hubcloud.foo/drive/p94k4dccjwxjcx4'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import bytes from 'bytes';
|
|
||||||
import * as cheerio from 'cheerio';
|
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
|
||||||
import { findCountryCodes, findHeight } from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
|
||||||
|
|
||||||
export class HubCloud extends Extractor {
|
|
||||||
public readonly id = 'hubcloud';
|
|
||||||
|
|
||||||
public readonly label = 'HubCloud';
|
|
||||||
|
|
||||||
public override readonly ttl: number = 43200000; // 12h
|
|
||||||
|
|
||||||
public override readonly cacheVersion = 1;
|
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
|
||||||
return null !== url.host.match(/hubcloud/);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
|
||||||
const headers = { Referer: meta.referer ?? url.href };
|
|
||||||
|
|
||||||
const redirectHtml = await this.fetcher.text(ctx, url, { headers });
|
|
||||||
const redirectUrlMatch = redirectHtml.match(/var url ?= ?'(.*?)'/) as string[];
|
|
||||||
|
|
||||||
const linksHtml = await this.fetcher.text(ctx, new URL(redirectUrlMatch[1] as string), { headers: { Referer: url.href } });
|
|
||||||
const $ = cheerio.load(linksHtml);
|
|
||||||
|
|
||||||
const title = $('title').text().trim();
|
|
||||||
const countryCodes = [...new Set([...meta.countryCodes ?? [], ...findCountryCodes(title)])];
|
|
||||||
const height = meta.height ?? findHeight(title);
|
|
||||||
|
|
||||||
return Promise.all([
|
|
||||||
...$('a')
|
|
||||||
.filter((_i, el) => {
|
|
||||||
const text = $(el).text();
|
|
||||||
|
|
||||||
return text.includes('FSL') && !text.includes('FSLv2');
|
|
||||||
})
|
|
||||||
.map((_i, el) => {
|
|
||||||
const url = new URL($(el).attr('href') as string);
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
format: Format.unknown,
|
|
||||||
label: `${this.label} (FSL)`,
|
|
||||||
meta: {
|
|
||||||
...meta,
|
|
||||||
bytes: bytes.parse($('#size').text()) as number,
|
|
||||||
extractorId: `${this.id}_fsl`,
|
|
||||||
countryCodes,
|
|
||||||
height,
|
|
||||||
title,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}).toArray(),
|
|
||||||
...$('a')
|
|
||||||
.filter((_i, el) => {
|
|
||||||
const text = $(el).text();
|
|
||||||
|
|
||||||
return text.includes('FSLv2');
|
|
||||||
})
|
|
||||||
.map((_i, el) => {
|
|
||||||
const url = new URL($(el).attr('href') as string);
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
format: Format.unknown,
|
|
||||||
label: `${this.label} (FSLv2)`,
|
|
||||||
meta: {
|
|
||||||
...meta,
|
|
||||||
bytes: bytes.parse($('#size').text()) as number,
|
|
||||||
extractorId: `${this.id}_fslv2`,
|
|
||||||
countryCodes,
|
|
||||||
height,
|
|
||||||
title,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}).toArray(),
|
|
||||||
...$('a')
|
|
||||||
.filter((_i, el) => $(el).text().includes('PixelServer'))
|
|
||||||
.map((_i, el) => {
|
|
||||||
const userUrl = new URL(($(el).attr('href') as string).replace('/api/file/', '/u/'));
|
|
||||||
const url = new URL(userUrl.href.replace('/u/', '/api/file/'));
|
|
||||||
url.searchParams.set('download', '');
|
|
||||||
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
format: Format.unknown,
|
|
||||||
label: `${this.label} (PixelServer)`,
|
|
||||||
meta: {
|
|
||||||
...meta,
|
|
||||||
bytes: bytes.parse($('#size').text()) as number,
|
|
||||||
extractorId: `${this.id}_pixelserver`,
|
|
||||||
countryCodes,
|
|
||||||
height,
|
|
||||||
title,
|
|
||||||
},
|
|
||||||
requestHeaders: { Referer: userUrl.href },
|
|
||||||
};
|
|
||||||
}).toArray(),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import winston from 'winston';
|
|
||||||
import { createTestContext } from '../test';
|
|
||||||
import { FetcherMock } from '../utils';
|
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
|
||||||
import { HubCloud } from './HubCloud';
|
|
||||||
import { HubDrive } from './HubDrive';
|
|
||||||
|
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
|
||||||
const extractorRegistry = new ExtractorRegistry(
|
|
||||||
logger,
|
|
||||||
[
|
|
||||||
new HubDrive(
|
|
||||||
new FetcherMock(`${__dirname}/__fixtures__/HubDrive`),
|
|
||||||
new HubCloud(new FetcherMock(`${__dirname}/__fixtures__/HubDrive/HubCloud`)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const ctx = createTestContext();
|
|
||||||
|
|
||||||
describe('HubDrive', () => {
|
|
||||||
test('handle avatar', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://hubdrive.space/file/7283903021'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handle missing hubcloud', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://hubdrive.space/file/2243124026'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import * as cheerio from 'cheerio';
|
|
||||||
import { Context, InternalUrlResult, Meta } from '../types';
|
|
||||||
import { Fetcher } from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
|
||||||
import { HubCloud } from './HubCloud';
|
|
||||||
|
|
||||||
export class HubDrive extends Extractor {
|
|
||||||
public readonly id = 'hubdrive';
|
|
||||||
|
|
||||||
public readonly label = 'HubDrive';
|
|
||||||
|
|
||||||
public override readonly ttl: number = 43200000; // 12h
|
|
||||||
|
|
||||||
public override readonly cacheVersion = 1;
|
|
||||||
|
|
||||||
private readonly hubCloud: HubCloud;
|
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher, hubCloud: HubCloud) {
|
|
||||||
super(fetcher);
|
|
||||||
|
|
||||||
this.hubCloud = hubCloud;
|
|
||||||
}
|
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
|
||||||
return null !== url.host.match(/hubdrive/);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
|
||||||
const headers = { Referer: meta.referer ?? url.href };
|
|
||||||
|
|
||||||
const html = await this.fetcher.text(ctx, url, { headers });
|
|
||||||
const $ = cheerio.load(html);
|
|
||||||
|
|
||||||
const hubCloudUrl = $('a:contains("HubCloud")')
|
|
||||||
.map((_i, el) => new URL($(el).attr('href') as string))
|
|
||||||
.get(0);
|
|
||||||
|
|
||||||
return hubCloudUrl ? this.hubCloud.extract(ctx, hubCloudUrl, meta) : [];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { KinoGer } from './KinoGer';
|
import { KinoGer } from './KinoGer';
|
||||||
|
|
@ -11,18 +12,10 @@ const ctx = createTestContext();
|
||||||
|
|
||||||
describe('KinoGer', () => {
|
describe('KinoGer', () => {
|
||||||
test('Blood & Sinners', async () => {
|
test('Blood & Sinners', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://kinoger.re/#ge5fhb'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://kinoger.re/#ge5fhb'), CountryCode.de)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Dead City', async () => {
|
test('Dead City', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://kinoger.re/#x6tsx9'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://kinoger.re/#x6tsx9'), CountryCode.de)).toMatchSnapshot();
|
||||||
});
|
|
||||||
|
|
||||||
test('filma365.strp2p.site', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://filma365.strp2p.site/#vih6bx'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('kinoger.p2pplay.pro', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://kinoger.p2pplay.pro/#k53k9'))).toMatchSnapshot();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
import { guessHeightFromPlaylist } from '../utils';
|
import { Fetcher, guessHeightFromPlaylist } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
/** @see https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/kinoger.py */
|
/** @see https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/kinoger.py */
|
||||||
|
|
@ -9,7 +9,13 @@ export class KinoGer extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'KinoGer';
|
public readonly label = 'KinoGer';
|
||||||
|
|
||||||
public override readonly ttl: number = 21600000; // 6h
|
private readonly fetcher: Fetcher;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return [
|
return [
|
||||||
|
|
@ -17,19 +23,11 @@ export class KinoGer extends Extractor {
|
||||||
'disneycdn.net',
|
'disneycdn.net',
|
||||||
'dzo.vidplayer.live',
|
'dzo.vidplayer.live',
|
||||||
'filedecrypt.link',
|
'filedecrypt.link',
|
||||||
'filma365.strp2p.site',
|
|
||||||
'flimmer.rpmvip.com',
|
|
||||||
'flixfilmesonline.strp2p.site',
|
|
||||||
'kinoger.p2pplay.pro',
|
|
||||||
'kinoger.re',
|
'kinoger.re',
|
||||||
'moflix.rpmplay.xyz',
|
'moflix.rpmplay.xyz',
|
||||||
'moflix.upns.xyz',
|
'moflix.upns.xyz',
|
||||||
'player.upn.one',
|
'player.upn.one',
|
||||||
'securecdn.shop',
|
|
||||||
'shiid4u.upn.one',
|
'shiid4u.upn.one',
|
||||||
'srbe84.vidplayer.live',
|
|
||||||
'strp2p.site',
|
|
||||||
't1.p2pplay.pro',
|
|
||||||
'tuktuk.rpmvid.com',
|
'tuktuk.rpmvid.com',
|
||||||
'ultrastream.online',
|
'ultrastream.online',
|
||||||
'videoland.cfd',
|
'videoland.cfd',
|
||||||
|
|
@ -43,35 +41,34 @@ export class KinoGer extends Extractor {
|
||||||
return new URL(`${url.origin}/api/v1/video?id=${url.hash.slice(1)}`);
|
return new URL(`${url.origin}/api/v1/video?id=${url.hash.slice(1)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
||||||
const headers = {
|
const hexData = await this.fetcher.text(ctx, url, { headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36' } });
|
||||||
'Origin': url.origin,
|
|
||||||
'Referer': url.origin + '/',
|
|
||||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
|
||||||
};
|
|
||||||
|
|
||||||
const hexData = await this.fetcher.text(ctx, url, { headers });
|
const encrypted = Buffer.from(hexData, 'hex');
|
||||||
|
|
||||||
const encrypted = Buffer.from(hexData.slice(0, -1), 'hex');
|
|
||||||
const key = Buffer.from('6b69656d7469656e6d75613931316361', 'hex');
|
const key = Buffer.from('6b69656d7469656e6d75613931316361', 'hex');
|
||||||
const iv = Buffer.from('313233343536373839306f6975797472', 'hex');
|
const iv = Buffer.from('313233343536373839306f6975797472', 'hex');
|
||||||
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
|
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
|
||||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
|
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
|
||||||
|
|
||||||
const { source, title } = JSON.parse(decrypted) as { source: string; title: string };
|
const { cf, title } = JSON.parse(decrypted) as { cf: string; title: string };
|
||||||
|
|
||||||
const m3u8Url = new URL(source);
|
const m3u8Url = new URL(cf);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: m3u8Url,
|
url: new URL(cf),
|
||||||
format: Format.hls,
|
format: Format.hls,
|
||||||
|
label: this.label,
|
||||||
|
sourceId: `${this.id}_${countryCode}`,
|
||||||
|
ttl: this.ttl,
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
countryCodes: [countryCode],
|
||||||
height: meta.height ?? await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url, { headers }),
|
height: await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url, { headers: { Referer: url.origin } }),
|
||||||
title,
|
title,
|
||||||
},
|
},
|
||||||
requestHeaders: headers,
|
requestHeaders: {
|
||||||
|
Referer: url.origin,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import winston from 'winston';
|
|
||||||
import { createTestContext } from '../test';
|
|
||||||
import { FetcherMock } from '../utils';
|
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
|
||||||
import { LuluStream } from './LuluStream';
|
|
||||||
|
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new LuluStream(new FetcherMock(`${__dirname}/__fixtures__/LuluStream`))]);
|
|
||||||
|
|
||||||
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow.test.org', mediaFlowProxyPassword: 'test' });
|
|
||||||
|
|
||||||
describe('LuluStream', () => {
|
|
||||||
test('streamhihi d', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://streamhihi.com/d/mk9m58lz8ts6'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('streamhihi', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://streamhihi.com/mk9m58lz8ts6'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no such file', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://lulustream.com/e/uthq0o0sljnx'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import bytes from 'bytes';
|
|
||||||
import * as cheerio from 'cheerio';
|
|
||||||
import { NotFoundError } from '../error';
|
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
|
||||||
import {
|
|
||||||
buildMediaFlowProxyExtractorStreamUrl,
|
|
||||||
supportsMediaFlowProxy,
|
|
||||||
} from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
|
||||||
|
|
||||||
/** @see https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/lulustream.py */
|
|
||||||
export class LuluStream extends Extractor {
|
|
||||||
public readonly id = 'lulustream';
|
|
||||||
|
|
||||||
public readonly label = 'LuluStream';
|
|
||||||
|
|
||||||
public override viaMediaFlowProxy = true;
|
|
||||||
|
|
||||||
public supports(ctx: Context, url: URL): boolean {
|
|
||||||
const supportedDomain = null !== url.host.match(/lulu/)
|
|
||||||
|| [
|
|
||||||
'732eg54de642sa.sbs',
|
|
||||||
'cdn1.site',
|
|
||||||
'd00ds.site',
|
|
||||||
'streamhihi.com',
|
|
||||||
].includes(url.host);
|
|
||||||
|
|
||||||
return supportedDomain && supportsMediaFlowProxy(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override normalize(url: URL): URL {
|
|
||||||
const videoId = url.pathname.replace(/\/+$/, '').split('/').at(-1) as string;
|
|
||||||
|
|
||||||
return new URL(`/e/${videoId}`, url);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
|
||||||
const headers = { Referer: meta.referer ?? url.href };
|
|
||||||
|
|
||||||
const fileUrl = new URL(url.href.replace('/e/', '/d/'));
|
|
||||||
const html = await this.fetcher.text(ctx, fileUrl, { headers });
|
|
||||||
|
|
||||||
if (/No such file|File Not Found/.test(html)) {
|
|
||||||
throw new NotFoundError();
|
|
||||||
}
|
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
|
||||||
const title = $('h1').text().trim();
|
|
||||||
|
|
||||||
const heightAndSizeMatch = html.match(/\d{3,}x(\d{3,}), ([\d.]+ ?[GM]B)/) as string[];
|
|
||||||
|
|
||||||
const playlistUrl = await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'LuluStream', url, headers);
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
url: playlistUrl,
|
|
||||||
format: Format.hls,
|
|
||||||
meta: {
|
|
||||||
...meta,
|
|
||||||
title,
|
|
||||||
bytes: bytes.parse(heightAndSizeMatch[2] as string) as number,
|
|
||||||
height: parseInt(heightAndSizeMatch[1] as string),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { Mixdrop } from './Mixdrop';
|
import { Mixdrop } from './Mixdrop';
|
||||||
|
|
@ -11,10 +12,14 @@ const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow-proxy.test
|
||||||
|
|
||||||
describe('Mixdrop', () => {
|
describe('Mixdrop', () => {
|
||||||
test('mixdrop.my /e/', async () => {
|
test('mixdrop.my /e/', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://mixdrop.my/e/knq0kj8waq44l8'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://mixdrop.my/e/knq0kj8waq44l8'), CountryCode.de)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mixdrop.my /f/', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://mixdrop.my/f/knq0kj8waq44l8'), CountryCode.de)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleted or expired file', async () => {
|
test('deleted or expired file', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://mixdrop.ag/e/123456789'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://mixdrop.ag/e/123456789'), CountryCode.de)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,34 @@
|
||||||
import bytes from 'bytes';
|
import bytes from 'bytes';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { NotFoundError } from '../error';
|
import { NotFoundError } from '../error';
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
import { buildMediaFlowProxyExtractorRedirectUrl, supportsMediaFlowProxy } from '../utils';
|
import { buildMediaFlowProxyExtractorRedirectUrl, Fetcher, supportsMediaFlowProxy } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class Mixdrop extends Extractor {
|
export class Mixdrop extends Extractor {
|
||||||
public readonly id = 'mixdrop';
|
public readonly id = 'mixdrop';
|
||||||
|
|
||||||
public readonly label = 'Mixdrop';
|
public readonly label = 'Mixdrop (via MediaFlow Proxy)';
|
||||||
|
|
||||||
|
public override readonly ttl = 0;
|
||||||
|
|
||||||
public override viaMediaFlowProxy = true;
|
public override viaMediaFlowProxy = true;
|
||||||
|
|
||||||
|
private readonly fetcher: Fetcher;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
public supports(ctx: Context, url: URL): boolean {
|
public supports(ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/mixdrop|mixdrp|mixdroop|m1xdrop/) && supportsMediaFlowProxy(ctx);
|
return null !== url.host.match(/mixdrop/) && supportsMediaFlowProxy(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override readonly normalize = (url: URL): URL => new URL(url.href.replace('/f/', '/e/'));
|
public override readonly normalize = (url: URL): URL => new URL(url.href.replace('/f/', '/e/'));
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
||||||
const fileUrl = new URL(url.href.replace('/e/', '/f/'));
|
const fileUrl = new URL(url.href.replace('/e/', '/f/'));
|
||||||
const html = await this.fetcher.text(ctx, fileUrl);
|
const html = await this.fetcher.text(ctx, fileUrl);
|
||||||
|
|
||||||
|
|
@ -26,7 +36,7 @@ export class Mixdrop extends Extractor {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeMatch = html.match(/([\d.,]+ ?[GM]B)/) as string[];
|
const sizeMatch = html.match(/([\d.,]+ ?[GM]B)/);
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const title = $('.title b').text().trim();
|
const title = $('.title b').text().trim();
|
||||||
|
|
@ -35,10 +45,15 @@ export class Mixdrop extends Extractor {
|
||||||
{
|
{
|
||||||
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Mixdrop', url),
|
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Mixdrop', url),
|
||||||
format: Format.mp4,
|
format: Format.mp4,
|
||||||
|
label: this.label,
|
||||||
|
sourceId: `${this.id}_${countryCode}`,
|
||||||
|
ttl: this.ttl,
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
countryCodes: [countryCode],
|
||||||
bytes: bytes.parse((sizeMatch[1] as string).replace(',', '')) as number,
|
|
||||||
title,
|
title,
|
||||||
|
...(sizeMatch && {
|
||||||
|
bytes: bytes.parse((sizeMatch[1] as string).replace(',', '')) as number,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import winston from 'winston';
|
|
||||||
import { createTestContext } from '../test';
|
|
||||||
import { FetcherMock } from '../utils';
|
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
|
||||||
import { RgShows } from './RgShows';
|
|
||||||
|
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new RgShows(new FetcherMock(`${__dirname}/__fixtures__/RgShows`))]);
|
|
||||||
|
|
||||||
const ctx = createTestContext();
|
|
||||||
|
|
||||||
describe('RgShows', () => {
|
|
||||||
test('handle one battle after another', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://api.rgshows.ru/main/movie/1054867'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handle stranger things s05e01', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://api.rgshows.ru/main/tv/66732/5/1'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handle parasite 2019 (mp4)', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://api.rgshows.ru/main/movie/496243'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { BlockedError } from '../error';
|
|
||||||
import { BlockedReason, Context, Format, InternalUrlResult, Meta } from '../types';
|
|
||||||
import { guessHeightFromPlaylist } from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
|
||||||
|
|
||||||
interface RgShowsApiData {
|
|
||||||
stream: {
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RgShows extends Extractor {
|
|
||||||
public readonly id = 'rgshows';
|
|
||||||
|
|
||||||
public readonly label = 'RgShows';
|
|
||||||
|
|
||||||
public override readonly ttl: number = 10800000; // 3h
|
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
|
||||||
return null !== url.host.match(/rgshows/);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
|
||||||
const headers = { 'Referer': 'https://www.rgshows.ru/', 'Origin': 'https://www.rgshows.ru', 'User-Agent': 'Mozilla' };
|
|
||||||
|
|
||||||
const data = await this.fetcher.json(ctx, url, { headers }) as RgShowsApiData;
|
|
||||||
|
|
||||||
const streamUrl = new URL(data.stream.url);
|
|
||||||
/* istanbul ignore if */
|
|
||||||
if (streamUrl.host.includes('vidzee')) {
|
|
||||||
throw new BlockedError(url, BlockedReason.unknown, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMp4 = streamUrl.href.includes('mp4');
|
|
||||||
const isHls = streamUrl.href.includes('m3u8') || streamUrl.href.includes('txt');
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
url: streamUrl,
|
|
||||||
format: isMp4 ? Format.mp4 : (isHls ? Format.hls : /* istanbul ignore next */ Format.unknown),
|
|
||||||
meta: {
|
|
||||||
...meta,
|
|
||||||
...(isHls && { height: meta.height ?? await guessHeightFromPlaylist(ctx, this.fetcher, streamUrl, { headers }) }),
|
|
||||||
},
|
|
||||||
requestHeaders: headers,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { SaveFiles } from './SaveFiles';
|
import { SaveFiles } from './SaveFiles';
|
||||||
|
|
@ -11,26 +12,22 @@ const ctx = createTestContext();
|
||||||
|
|
||||||
describe('SafeFiles', () => {
|
describe('SafeFiles', () => {
|
||||||
test('savefiles', async () => {
|
test('savefiles', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/ip0k0dj2g0i3'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/ip0k0dj2g0i3'), CountryCode.en)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('savefiles /e/', async () => {
|
test('savefiles /e/', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/e/edptfmhyjr39'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/e/edptfmhyjr39'), CountryCode.en)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('savefiles /d/', async () => {
|
test('savefiles /d/', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/d/s9g6zb5kjbqd'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/d/s9g6zb5kjbqd'), CountryCode.en)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('savefiles locked file', async () => {
|
test('savefiles locked file', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/omqq55i59nvv'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/omqq55i59nvv'), CountryCode.en)).toMatchSnapshot();
|
||||||
});
|
|
||||||
|
|
||||||
test('savefiles deleted file', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/bejqk3vlsv02'))).toMatchSnapshot();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('streamhls /e/', async () => {
|
test('streamhls /e/', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://streamhls.to/e/ip0k0dj2g0i3'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://streamhls.to/e/ip0k0dj2g0i3'), CountryCode.en)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { NotFoundError } from '../error';
|
import { NotFoundError } from '../error';
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
|
import { Fetcher } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class SaveFiles extends Extractor {
|
export class SaveFiles extends Extractor {
|
||||||
|
|
@ -8,7 +9,13 @@ export class SaveFiles extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'SaveFiles';
|
public readonly label = 'SaveFiles';
|
||||||
|
|
||||||
public override readonly ttl: number = 21600000; // 6h
|
private readonly fetcher: Fetcher;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/savefiles|streamhls/);
|
return null !== url.host.match(/savefiles|streamhls/);
|
||||||
|
|
@ -18,12 +25,10 @@ export class SaveFiles extends Extractor {
|
||||||
return new URL(url.href.replace('/e/', '/').replace('/d/', '/'));
|
return new URL(url.href.replace('/e/', '/').replace('/d/', '/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
||||||
const headers = { Referer: meta.referer ?? url.href };
|
const html = await this.fetcher.text(ctx, url);
|
||||||
|
|
||||||
const html = await this.fetcher.text(ctx, url, { headers });
|
if (/File was locked by administrator/.test(html)) {
|
||||||
|
|
||||||
if (/file was locked|file was deleted/i.test(html)) {
|
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,8 +42,11 @@ export class SaveFiles extends Extractor {
|
||||||
{
|
{
|
||||||
url: new URL(fileMatch[1] as string),
|
url: new URL(fileMatch[1] as string),
|
||||||
format: Format.hls,
|
format: Format.hls,
|
||||||
|
label: this.label,
|
||||||
|
sourceId: `${this.id}_${countryCode}`,
|
||||||
|
ttl: this.ttl,
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
countryCodes: [countryCode],
|
||||||
title,
|
title,
|
||||||
height: parseInt(sizeMatch[1] as string),
|
height: parseInt(sizeMatch[1] as string),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
25
src/extractor/Soaper.test.ts
Normal file
25
src/extractor/Soaper.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import winston from 'winston';
|
||||||
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
|
import { FetcherMock } from '../utils';
|
||||||
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
|
import { Soaper } from './Soaper';
|
||||||
|
|
||||||
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
|
const extractorRegistry = new ExtractorRegistry(logger, [new Soaper(new FetcherMock(`${__dirname}/__fixtures__/Soaper`))]);
|
||||||
|
|
||||||
|
const ctx = createTestContext();
|
||||||
|
|
||||||
|
describe('Soaper', () => {
|
||||||
|
test('Full Metal Jacket', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://soaper.live/movie_d8kdeypDY9.html'), CountryCode.en, 'Full Metal Jacket (1987)')).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Black Mirror', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://soaper.live/episode_5KDq78eGp1.html'), CountryCode.en, 'Black Mirror 4x2')).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('last of us s2e3', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://soaper.live/episode_rYg3vMEDL1.html'), CountryCode.de, 'The Last of Us 2x3')).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
src/extractor/Soaper.ts
Normal file
67
src/extractor/Soaper.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
|
import { Fetcher, guessHeightFromPlaylist } from '../utils';
|
||||||
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
|
interface SoaperInfoResponsePartial {
|
||||||
|
val: string;
|
||||||
|
val_bak: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Soaper extends Extractor {
|
||||||
|
public readonly id = 'soaper';
|
||||||
|
|
||||||
|
public readonly label = 'Soaper';
|
||||||
|
|
||||||
|
private readonly fetcher: Fetcher;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
|
return null !== url.host.match(/soaper/) && null !== url.pathname.match(/^\/(episode|movie)_/);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode, title: string | undefined): Promise<UrlResult[]> {
|
||||||
|
const movieOrEpisodeId = (url.pathname.match(/\/\w+_(\w+)/) as string[])[1] as string;
|
||||||
|
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
form.append('pass', movieOrEpisodeId);
|
||||||
|
form.append('param', '');
|
||||||
|
form.append('extra', '');
|
||||||
|
form.append('e2', '');
|
||||||
|
form.append('server', '0');
|
||||||
|
|
||||||
|
const response = await this.fetcher.textPost(
|
||||||
|
ctx,
|
||||||
|
new URL(url.pathname.includes('episode') ? '/home/index/GetEInfoAjax' : '/home/index/GetMInfoAjax', url.origin),
|
||||||
|
form.toString(),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Referer': url.origin,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const jsonResponse = JSON.parse(response) as SoaperInfoResponsePartial;
|
||||||
|
|
||||||
|
const m3u8Url = new URL(jsonResponse['val'], url.origin);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: m3u8Url,
|
||||||
|
format: Format.hls,
|
||||||
|
label: this.label,
|
||||||
|
sourceId: `${this.id}_${countryCode}`,
|
||||||
|
ttl: this.ttl,
|
||||||
|
meta: {
|
||||||
|
countryCodes: [countryCode],
|
||||||
|
height: await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url),
|
||||||
|
title: `${title}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { StreamEmbed } from './StreamEmbed';
|
import { StreamEmbed } from './StreamEmbed';
|
||||||
|
|
@ -11,10 +12,6 @@ const ctx = createTestContext();
|
||||||
|
|
||||||
describe('StreamEmbed', () => {
|
describe('StreamEmbed', () => {
|
||||||
test('watch.gxplayer.xyz', async () => {
|
test('watch.gxplayer.xyz', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://watch.gxplayer.xyz/watch?v=MEKI92PU'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://watch.gxplayer.xyz/watch?v=MEKI92PU'), CountryCode.de)).toMatchSnapshot();
|
||||||
});
|
|
||||||
|
|
||||||
test('video is not ready', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://watch.gxplayer.xyz/watch?v=PBO90WAS'))).toMatchSnapshot();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NotFoundError } from '../error';
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
import { Fetcher } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class StreamEmbed extends Extractor {
|
export class StreamEmbed extends Extractor {
|
||||||
|
|
@ -7,20 +7,20 @@ export class StreamEmbed extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'StreamEmbed';
|
public readonly label = 'StreamEmbed';
|
||||||
|
|
||||||
public override readonly ttl: number = 259200000; // 3d
|
private readonly fetcher: Fetcher;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/bullstream|mp4player|watch\.gxplayer/);
|
return null !== url.host.match(/bullstream|mp4player|watch\.gxplayer/);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
||||||
const headers = { Referer: meta.referer ?? url.href };
|
const html = await this.fetcher.text(ctx, url);
|
||||||
|
|
||||||
const html = await this.fetcher.text(ctx, url, { headers });
|
|
||||||
|
|
||||||
if (/Video is not ready/.test(html)) {
|
|
||||||
throw new NotFoundError();
|
|
||||||
}
|
|
||||||
|
|
||||||
const video = JSON.parse((html.match(/video ?= ?(.*);/) as string[])[1] as string);
|
const video = JSON.parse((html.match(/video ?= ?(.*);/) as string[])[1] as string);
|
||||||
|
|
||||||
|
|
@ -28,8 +28,11 @@ export class StreamEmbed extends Extractor {
|
||||||
{
|
{
|
||||||
url: new URL(`/m3u8/${video.uid}/${video.md5}/master.txt?s=1&id=${video.id}&cache=${video.status}`, url.origin),
|
url: new URL(`/m3u8/${video.uid}/${video.md5}/master.txt?s=1&id=${video.id}&cache=${video.status}`, url.origin),
|
||||||
format: Format.hls,
|
format: Format.hls,
|
||||||
|
label: this.label,
|
||||||
|
sourceId: `${this.id}_${countryCode}`,
|
||||||
|
ttl: this.ttl,
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
countryCodes: [countryCode],
|
||||||
height: parseInt(JSON.parse(video.quality)[0]),
|
height: parseInt(JSON.parse(video.quality)[0]),
|
||||||
title: decodeURIComponent(video.title),
|
title: decodeURIComponent(video.title),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { Streamtape } from './Streamtape';
|
import { Streamtape } from './Streamtape';
|
||||||
|
|
@ -7,10 +8,10 @@ import { Streamtape } from './Streamtape';
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new Streamtape(new FetcherMock(`${__dirname}/__fixtures__/Streamtape`))]);
|
const extractorRegistry = new ExtractorRegistry(logger, [new Streamtape(new FetcherMock(`${__dirname}/__fixtures__/Streamtape`))]);
|
||||||
|
|
||||||
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow.test.org', mediaFlowProxyPassword: 'test' });
|
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow-proxy.test', mediaFlowProxyPassword: 'asdfg' });
|
||||||
|
|
||||||
describe('Streamtape', () => {
|
describe('Streamtape', () => {
|
||||||
test('streamtape.com /e', async () => {
|
test('streamtape.com /e', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://streamtape.com/e/DjmZBG0K9gHkJ7z/WALL-E.2008.1080p.BrRip.x264.YIFY.mp4'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://streamtape.com/e/84Kb3mkoYAiokmW'), CountryCode.es)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,47 @@
|
||||||
import bytes from 'bytes';
|
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
import { buildMediaFlowProxyExtractorRedirectUrl, supportsMediaFlowProxy } from '../utils';
|
import {
|
||||||
|
buildMediaFlowProxyExtractorRedirectUrl,
|
||||||
|
Fetcher,
|
||||||
|
supportsMediaFlowProxy,
|
||||||
|
} from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class Streamtape extends Extractor {
|
export class Streamtape extends Extractor {
|
||||||
public readonly id = 'streamtape';
|
public readonly id = 'streamtape';
|
||||||
|
|
||||||
public readonly label = 'Streamtape';
|
public readonly label = 'Streamtape (via MediaFlow Proxy)';
|
||||||
|
|
||||||
public override viaMediaFlowProxy = true;
|
public override readonly ttl = 0;
|
||||||
|
|
||||||
|
private readonly fetcher: Fetcher;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
public supports(ctx: Context, url: URL): boolean {
|
public supports(ctx: Context, url: URL): boolean {
|
||||||
const supportedDomain = null !== url.host.match(/streamtape/)
|
return null !== url.host.match(/streamtape/) && supportsMediaFlowProxy(ctx);
|
||||||
|| [
|
|
||||||
'strtape.cloud',
|
|
||||||
'streamta.pe',
|
|
||||||
'strcloud.link',
|
|
||||||
'strcloud.club',
|
|
||||||
'strtpe.link',
|
|
||||||
'scloud.online',
|
|
||||||
'stape.fun',
|
|
||||||
'streamadblockplus.com',
|
|
||||||
'shavetape.cash',
|
|
||||||
'streamta.site',
|
|
||||||
'streamadblocker.xyz',
|
|
||||||
'tapewithadblock.org',
|
|
||||||
'adblocktape.wiki',
|
|
||||||
'antiadtape.com',
|
|
||||||
'tapeblocker.com',
|
|
||||||
'streamnoads.com',
|
|
||||||
'tapeadvertisement.com',
|
|
||||||
'tapeadsenjoyer.com',
|
|
||||||
'watchadsontape.com',
|
|
||||||
].includes(url.host);
|
|
||||||
|
|
||||||
return supportedDomain && supportsMediaFlowProxy(ctx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override normalize(url: URL): URL {
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
||||||
return new URL(url.href.replace('/e/', '/v/'));
|
const html = await this.fetcher.text(ctx, url);
|
||||||
}
|
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
|
||||||
const headers = { Referer: meta.referer ?? url.href };
|
|
||||||
|
|
||||||
// Only needed to properly find non-existing files via 404 response
|
|
||||||
await this.fetcher.text(ctx, new URL(url.href.replace('/v/', '/e/')), { headers });
|
|
||||||
|
|
||||||
const html = await this.fetcher.text(ctx, url, { headers });
|
|
||||||
|
|
||||||
const sizeMatch = html.match(/([\d.]+ ?[GM]B)/) as string[];
|
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const title = $('meta[name="og:title"]').attr('content') as string;
|
const title = $('meta[name="og:title"]').attr('content') as string;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Streamtape', url, headers),
|
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Streamtape', url),
|
||||||
format: Format.mp4,
|
format: Format.mp4,
|
||||||
|
label: this.label,
|
||||||
|
sourceId: `${this.id}_${countryCode}`,
|
||||||
|
ttl: this.ttl,
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
countryCodes: [countryCode],
|
||||||
title,
|
title,
|
||||||
bytes: bytes.parse(sizeMatch[1] as string) as number,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { SuperVideo } from './SuperVideo';
|
import { SuperVideo } from './SuperVideo';
|
||||||
|
|
@ -11,22 +12,22 @@ const ctx = createTestContext();
|
||||||
|
|
||||||
describe('SuperVideo', () => {
|
describe('SuperVideo', () => {
|
||||||
test('supervideo.cc /e/', async () => {
|
test('supervideo.cc /e/', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.cc/e/q7i0sw1oytw3'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.cc/e/q7i0sw1oytw3'), CountryCode.de)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('supervideo.tv /embed-/', async () => {
|
test('supervideo.tv /embed-/', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.tv/embed-1p0m1fi9mok8.html'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.tv/embed-1p0m1fi9mok8.html'), CountryCode.it)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('embed only', async () => {
|
test('embed only', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.cc/e/bj6szat1pval'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.cc/e/bj6szat1pval'), CountryCode.it)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleted or expired file', async () => {
|
test('deleted or expired file', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.cc/ndf5shmy9lpt'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.cc/ndf5shmy9lpt'), CountryCode.it)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('processing video', async () => {
|
test('processing video', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.cc/3h1qqoqtldo8'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.cc/3h1qqoqtldo8'), CountryCode.it)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import bytes from 'bytes';
|
import bytes from 'bytes';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { NotFoundError } from '../error';
|
import { NotFoundError } from '../error';
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
import { extractUrlFromPacked, guessHeightFromPlaylist } from '../utils';
|
import { extractUrlFromPacked, Fetcher } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class SuperVideo extends Extractor {
|
export class SuperVideo extends Extractor {
|
||||||
|
|
@ -10,52 +10,53 @@ export class SuperVideo extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'SuperVideo';
|
public readonly label = 'SuperVideo';
|
||||||
|
|
||||||
public override readonly ttl: number = 10800000; // 3h
|
private readonly fetcher: Fetcher;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/supervideo/);
|
return null !== url.host.match(/supervideo/);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override normalize(url: URL): URL {
|
public override normalize(url: URL): URL {
|
||||||
return new URL(url.href.replace('/e/', '/').replace('/k/', '/').replace('/embed-', '/'));
|
return new URL(url.href.replace('/e/', '/').replace('/embed-', '/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
||||||
const headers = { Referer: meta.referer ?? url.href };
|
const html = await this.fetcher.text(ctx, url);
|
||||||
|
|
||||||
const html = await this.fetcher.text(ctx, url, { headers });
|
|
||||||
|
|
||||||
if (html.includes('This video can be watched as embed only')) {
|
if (html.includes('This video can be watched as embed only')) {
|
||||||
return await this.extractInternal(ctx, new URL(`/e${url.pathname}`, url.origin), meta);
|
return await this.extractInternal(ctx, new URL(`/e${url.pathname}`, url.origin), countryCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/'The file was deleted|The file expired|Video is processing/.test(html)) {
|
if (/'The file was deleted|The file expired|Video is processing/.test(html)) {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const playlistUrl = extractUrlFromPacked(html, [/sources:\[{file:"(.*?)"/]);
|
|
||||||
const playlistHeaders = { Referer: 'https://supervideo.cc/' };
|
|
||||||
|
|
||||||
const heightAndSizeMatch = html.match(/\d{3,}x(\d{3,}), ([\d.]+ ?[GM]B)/);
|
const heightAndSizeMatch = html.match(/\d{3,}x(\d{3,}), ([\d.]+ ?[GM]B)/);
|
||||||
const size = heightAndSizeMatch ? bytes.parse(heightAndSizeMatch[2] as string) as number : undefined;
|
|
||||||
const height = heightAndSizeMatch
|
|
||||||
? parseInt(heightAndSizeMatch[1] as string)
|
|
||||||
: meta.height ?? await guessHeightFromPlaylist(ctx, this.fetcher, playlistUrl, { headers: playlistHeaders });
|
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const title = $('.download__title').text().trim();
|
const title = $('.download__title').text().trim();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: playlistUrl,
|
url: extractUrlFromPacked(html, [/sources:\[{file:"(.*?)"/]),
|
||||||
format: Format.hls,
|
format: Format.hls,
|
||||||
|
label: this.label,
|
||||||
|
sourceId: `${this.id}_${countryCode}`,
|
||||||
|
ttl: this.ttl,
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
countryCodes: [countryCode],
|
||||||
title,
|
title,
|
||||||
...(size && { bytes: size }),
|
...(heightAndSizeMatch && {
|
||||||
...(height && { height }),
|
bytes: bytes.parse(heightAndSizeMatch[2] as string) as number,
|
||||||
|
height: parseInt(heightAndSizeMatch[1] as string),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
requestHeaders: playlistHeaders,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { Uqload } from './Uqload';
|
import { Uqload } from './Uqload';
|
||||||
|
|
@ -7,14 +8,14 @@ import { Uqload } from './Uqload';
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new Uqload(new FetcherMock(`${__dirname}/__fixtures__/Uqload`))]);
|
const extractorRegistry = new ExtractorRegistry(logger, [new Uqload(new FetcherMock(`${__dirname}/__fixtures__/Uqload`))]);
|
||||||
|
|
||||||
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow.test.org', mediaFlowProxyPassword: 'test' });
|
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow-proxy.test', mediaFlowProxyPassword: 'asdfg' });
|
||||||
|
|
||||||
describe('Uqload', () => {
|
describe('Uqload', () => {
|
||||||
test('uqload.net /embed-', async () => {
|
test('uqload.net /embed-', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://uqload.net/embed-z0xbr87oz637.html'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://uqload.net/embed-z0xbr87oz637.html'), CountryCode.fr)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('file not found', async () => {
|
test('uqload.net /', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://uqload.cx/2ua47fdfkjgd.html'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://uqload.net/z0xbr87oz637.html'), CountryCode.fr)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,25 @@
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { NotFoundError } from '../error';
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
import { buildMediaFlowProxyExtractorRedirectUrl, Fetcher, supportsMediaFlowProxy } from '../utils';
|
||||||
import { buildMediaFlowProxyExtractorRedirectUrl, supportsMediaFlowProxy } from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class Uqload extends Extractor {
|
export class Uqload extends Extractor {
|
||||||
public readonly id = 'uqload';
|
public readonly id = 'uqload';
|
||||||
|
|
||||||
public readonly label = 'Uqload';
|
public readonly label = 'Uqload (via MediaFlow Proxy)';
|
||||||
|
|
||||||
|
public override readonly ttl = 0;
|
||||||
|
|
||||||
public override viaMediaFlowProxy = true;
|
public override viaMediaFlowProxy = true;
|
||||||
|
|
||||||
|
private readonly fetcher: Fetcher;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
public supports(ctx: Context, url: URL): boolean {
|
public supports(ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/uqload/) && supportsMediaFlowProxy(ctx);
|
return null !== url.host.match(/uqload/) && supportsMediaFlowProxy(ctx);
|
||||||
}
|
}
|
||||||
|
|
@ -19,13 +28,9 @@ export class Uqload extends Extractor {
|
||||||
return new URL(url.href.replace('/embed-', '/'));
|
return new URL(url.href.replace('/embed-', '/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
||||||
const html = await this.fetcher.text(ctx, url);
|
const html = await this.fetcher.text(ctx, url);
|
||||||
|
|
||||||
if (/File Not Found/.test(html)) {
|
|
||||||
throw new NotFoundError();
|
|
||||||
}
|
|
||||||
|
|
||||||
const heightMatch = html.match(/\d{3,}x(\d{3,})/);
|
const heightMatch = html.match(/\d{3,}x(\d{3,})/);
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
@ -35,8 +40,11 @@ export class Uqload extends Extractor {
|
||||||
{
|
{
|
||||||
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Uqload', url),
|
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Uqload', url),
|
||||||
format: Format.mp4,
|
format: Format.mp4,
|
||||||
|
label: this.label,
|
||||||
|
sourceId: `${this.id}_${countryCode}`,
|
||||||
|
ttl: this.ttl,
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
countryCodes: [countryCode],
|
||||||
title,
|
title,
|
||||||
...(heightMatch && {
|
...(heightMatch && {
|
||||||
height: parseInt(heightMatch[1] as string),
|
height: parseInt(heightMatch[1] as string),
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,42 @@
|
||||||
|
import { MockAgent, setGlobalDispatcher } from 'undici';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { FetcherMock } from '../utils';
|
import { CountryCode } from '../types';
|
||||||
|
import { Fetcher, FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { VidSrc } from './VidSrc';
|
import { VidSrc } from './VidSrc';
|
||||||
|
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new VidSrc(new FetcherMock(`${__dirname}/__fixtures__/VidSrc`), ['vidsrc-embed.ru'])]);
|
const extractorRegistry = new ExtractorRegistry(logger, [new VidSrc(new FetcherMock(`${__dirname}/__fixtures__/VidSrc`), ['xyz'])]);
|
||||||
|
|
||||||
const ctx = createTestContext();
|
const ctx = createTestContext();
|
||||||
|
|
||||||
describe('VidSrc', () => {
|
describe('VidSrc', () => {
|
||||||
test('Full Metal Jacket', async () => {
|
test('Full Metal Jacket', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://vidsrc-embed.ru/embed/movie/tt0093058'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://vidsrc.xyz/embed/movie/tt0093058'), CountryCode.en, 'Full Metal Jacket (1987)')).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Black Mirror', async () => {
|
test('Black Mirror', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://vidsrc-embed.ru/embed/tv/tt2085059/4-2'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://vidsrc.xyz/embed/tv/tt2085059/4/2'), CountryCode.en, 'Black Mirror 4x2')).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('not found', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://vidsrc.xyz/embed/movie/tt35628853'), CountryCode.en, 'Titan: The OceanGate Disaster')).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rate limit issues are retried and fail if no tlds are left', async () => {
|
||||||
|
const mockAgent = new MockAgent({ enableCallHistory: true });
|
||||||
|
mockAgent.disableNetConnect();
|
||||||
|
setGlobalDispatcher(mockAgent);
|
||||||
|
|
||||||
|
mockAgent.get('https://vidsrc.xyz')
|
||||||
|
.intercept({ path: '/embed/movie/tt33043892/1/1' }).reply(429);
|
||||||
|
mockAgent.get('https://vidsrc.net')
|
||||||
|
.intercept({ path: '/embed/movie/tt33043892/1/1' }).reply(429);
|
||||||
|
|
||||||
|
const fetcher = new Fetcher(winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] }));
|
||||||
|
const vidSrc = new VidSrc(fetcher, ['net', 'xyz']);
|
||||||
|
|
||||||
|
expect(await vidSrc.extract(ctx, new URL('https://vidsrc.xyz/embed/movie/tt33043892/1/1'), CountryCode.en, 'Dexter: Resurrection 1x1')).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { BlockedError, TooManyRequestsError } from '../error';
|
import slugify from 'slugify';
|
||||||
import { Context, Format, InternalUrlResult, Meta, NonEmptyArray } from '../types';
|
import { NotFoundError, TooManyRequestsError } from '../error';
|
||||||
|
import { Context, CountryCode, Format, NonEmptyArray, UrlResult } from '../types';
|
||||||
import { Fetcher, guessHeightFromPlaylist } from '../utils';
|
import { Fetcher, guessHeightFromPlaylist } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
|
|
@ -9,49 +10,45 @@ export class VidSrc extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'VidSrc';
|
public readonly label = 'VidSrc';
|
||||||
|
|
||||||
public override readonly ttl: number = 10800000; // 3h
|
private readonly fetcher: Fetcher;
|
||||||
|
private readonly tlds: NonEmptyArray<string>;
|
||||||
|
|
||||||
private readonly domains: NonEmptyArray<string>;
|
public constructor(fetcher: Fetcher, tlds: NonEmptyArray<string>) {
|
||||||
|
super();
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher, domains: NonEmptyArray<string>) {
|
this.fetcher = fetcher;
|
||||||
super(fetcher);
|
this.tlds = tlds;
|
||||||
|
|
||||||
this.domains = domains;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/vidsrc|vsrc/);
|
return null !== url.host.match(/vidsrc/);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
||||||
// While this is a crappy thing to do, they seem to be blocking overly strict IMO
|
return this.extractUsingRandomTld(ctx, url, countryCode, [...this.tlds]);
|
||||||
const randomIp = `${Math.floor(Math.random() * 223) + 1}.${Math.floor(Math.random() * 256)}.${Math.floor(Math.random() * 256)}.${Math.floor(Math.random() * 256)}`;
|
|
||||||
const newCtx = { ...ctx, ip: randomIp };
|
|
||||||
|
|
||||||
return this.extractUsingRandomDomain(newCtx, url, meta, [...this.domains]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private async extractUsingRandomDomain(ctx: Context, url: URL, meta: Meta, domains: string[]): Promise<InternalUrlResult[]> {
|
private async extractUsingRandomTld(ctx: Context, url: URL, countryCode: CountryCode, tlds: string[]): Promise<UrlResult[]> {
|
||||||
const domainIndex = Math.floor(Math.random() * domains.length);
|
const tldIndex = Math.floor(Math.random() * tlds.length);
|
||||||
const [domain] = domains.splice(domainIndex, 1) as [string];
|
const [tld] = tlds.splice(tldIndex, 1) as [string];
|
||||||
|
|
||||||
const newUrl = new URL(url);
|
const newUrl = new URL(url);
|
||||||
newUrl.hostname = domain;
|
const hostnameParts = newUrl.hostname.split('.');
|
||||||
|
hostnameParts[hostnameParts.length - 1] = tld;
|
||||||
|
newUrl.hostname = hostnameParts.join('.');
|
||||||
|
|
||||||
let html: string;
|
let html: string;
|
||||||
try {
|
try {
|
||||||
html = await this.fetcher.text(ctx, newUrl, { queueLimit: 1 });
|
html = await this.fetcher.text(ctx, newUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
/* istanbul ignore next */
|
if (error instanceof TooManyRequestsError && tlds.length) {
|
||||||
if (domains.length && (error instanceof TooManyRequestsError || error instanceof BlockedError)) {
|
return this.extractUsingRandomTld(ctx, url, countryCode, tlds);
|
||||||
return this.extractUsingRandomDomain(ctx, url, meta, domains);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* istanbul ignore next */
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const $ = cheerio.load(html.replace('<!--', '').replace('-->', '')); // server HTML is commented-out
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
const iframeUrl = new URL(($('#player_iframe').attr('src') as string).replace(/^\/\//, 'https://'));
|
const iframeUrl = new URL(($('#player_iframe').attr('src') as string).replace(/^\/\//, 'https://'));
|
||||||
const title = $('title').text().trim();
|
const title = $('title').text().trim();
|
||||||
|
|
@ -62,21 +59,26 @@ export class VidSrc extends Extractor {
|
||||||
.toArray()
|
.toArray()
|
||||||
.filter(({ serverName }) => serverName === 'CloudStream Pro')
|
.filter(({ serverName }) => serverName === 'CloudStream Pro')
|
||||||
.map(async ({ serverName, dataHash }) => {
|
.map(async ({ serverName, dataHash }) => {
|
||||||
const rcpUrl = new URL(`/rcp/${dataHash}`, iframeUrl.origin);
|
const iframeHtml = await this.fetcher.text(ctx, new URL(`/rcp/${dataHash}`, iframeUrl.origin), { headers: { Referer: iframeUrl.origin } });
|
||||||
const iframeHtml = await this.fetcher.text(ctx, rcpUrl, { headers: { Referer: newUrl.origin } });
|
|
||||||
const srcMatch = iframeHtml.match(`src:\\s?'(.*)'`) as string[];
|
const srcMatch = iframeHtml.match(`src:\\s?'(.*)'`) as string[];
|
||||||
|
|
||||||
const playerHtml = await this.fetcher.text(ctx, new URL(srcMatch[1] as string, iframeUrl.origin), { headers: { Referer: rcpUrl.href } });
|
const playerHtml = await this.fetcher.text(ctx, new URL(srcMatch[1] as string, iframeUrl.origin), { headers: { Referer: iframeUrl.origin } });
|
||||||
const fileMatch = playerHtml.match(`(https:\\/\\/.*?{v\\d}.*?) or`) as string[];
|
const fileMatch = playerHtml.match(`file:\\s?'(.*)'`);
|
||||||
const m3u8Url = new URL((fileMatch[1] as string).replace(/{v\d}/, iframeUrl.host));
|
if (!fileMatch) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const m3u8Url = new URL(fileMatch[1] as string);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: m3u8Url,
|
url: m3u8Url,
|
||||||
format: Format.hls,
|
format: Format.hls,
|
||||||
label: serverName,
|
label: `${this.label} (${serverName})`,
|
||||||
|
sourceId: `${this.id}_${slugify(serverName)}_${countryCode}`,
|
||||||
|
ttl: this.ttl,
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
countryCodes: [countryCode],
|
||||||
height: await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url, { headers: { Referer: iframeUrl.href } }),
|
height: await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url),
|
||||||
title,
|
title,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import winston from 'winston';
|
|
||||||
import { createTestContext } from '../test';
|
|
||||||
import { FetcherMock } from '../utils';
|
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
|
||||||
import { Vidora } from './Vidora';
|
|
||||||
|
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new Vidora(new FetcherMock(`${__dirname}/__fixtures__/Vidora`))]);
|
|
||||||
|
|
||||||
const ctx = createTestContext();
|
|
||||||
|
|
||||||
describe('Vidora', () => {
|
|
||||||
test('vidora.stream', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://vidora.stream/5iz6c1elaqgq'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('vidora.stream /embed/', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://vidora.stream/embed/5iz6c1elaqgq'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import * as cheerio from 'cheerio';
|
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
|
||||||
import { extractUrlFromPacked, guessHeightFromPlaylist } from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
|
||||||
|
|
||||||
export class Vidora extends Extractor {
|
|
||||||
public readonly id = 'vidora';
|
|
||||||
|
|
||||||
public readonly label = 'Vidora';
|
|
||||||
|
|
||||||
public override readonly ttl: number = 43200000; // 12h
|
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
|
||||||
return null !== url.host.match(/vidora/);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override normalize(url: URL): URL {
|
|
||||||
return new URL(url.href.replace('/embed/', '/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
|
||||||
const html = await this.fetcher.text(ctx, url);
|
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
|
||||||
const title = $('title').text().trim().replace(/^Watch /, '').trim();
|
|
||||||
|
|
||||||
const m3u8Url = extractUrlFromPacked(html, [/file: ?"(.*?)"/]);
|
|
||||||
const headers = { Origin: url.origin };
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
url: m3u8Url,
|
|
||||||
format: Format.hls,
|
|
||||||
meta: {
|
|
||||||
...meta,
|
|
||||||
height: meta.height ?? await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url, { headers }),
|
|
||||||
title,
|
|
||||||
},
|
|
||||||
requestHeaders: headers,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { VixSrc } from './VixSrc';
|
import { VixSrc } from './VixSrc';
|
||||||
|
|
@ -7,24 +8,20 @@ import { VixSrc } from './VixSrc';
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new VixSrc(new FetcherMock(`${__dirname}/__fixtures__/VixSrc`))]);
|
const extractorRegistry = new ExtractorRegistry(logger, [new VixSrc(new FetcherMock(`${__dirname}/__fixtures__/VixSrc`))]);
|
||||||
|
|
||||||
const ctx = createTestContext();
|
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow-proxy.test', mediaFlowProxyPassword: 'asdfg', de: 'on', en: 'on', it: 'on' });
|
||||||
|
|
||||||
describe('VixSrc', () => {
|
describe('VixSrc', () => {
|
||||||
test('Full Metal Jacket', async () => {
|
test('Full Metal Jacket', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://vixsrc.to/movie/600'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://vixsrc.to/movie/600'), CountryCode.it, 'Full Metal Jacket (1987)')).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Black Mirror', async () => {
|
test('Black Mirror', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://vixsrc.to/tv/42009/4/2'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://vixsrc.to/tv/42009/4/2'), CountryCode.it, 'Black Mirror 4x2')).toMatchSnapshot();
|
||||||
});
|
|
||||||
|
|
||||||
test('Rental Family', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://vixsrc.to/movie/1208348'))).toMatchSnapshot();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Black Mirror is excluded if no matching language was found', async () => {
|
test('Black Mirror is excluded if no matching language was found', async () => {
|
||||||
const ctx = createTestContext({ de: 'on' });
|
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow-proxy.test', mediaFlowProxyPassword: 'asdfg', de: 'on' });
|
||||||
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://vixsrc.to/tv/42009/4/3'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://vixsrc.to/tv/42009/4/2'), CountryCode.it, 'Black Mirror 4x2')).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,44 @@
|
||||||
import { Context, CountryCode, Format, InternalUrlResult, Meta } from '../types';
|
import bytes from 'bytes';
|
||||||
import { CustomRequestConfig, guessHeightFromPlaylist, hasMultiEnabled, iso639FromCountryCode } from '../utils';
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
|
import {
|
||||||
|
buildMediaFlowProxyExtractorStreamUrl,
|
||||||
|
Fetcher,
|
||||||
|
hasMultiEnabled,
|
||||||
|
iso639FromCountryCode,
|
||||||
|
supportsMediaFlowProxy,
|
||||||
|
} from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class VixSrc extends Extractor {
|
export class VixSrc extends Extractor {
|
||||||
public readonly id = 'vixsrc';
|
public readonly id = 'vixsrc';
|
||||||
|
|
||||||
public readonly label = 'VixSrc';
|
public readonly label = 'VixSrc (via MediaFlow Proxy)';
|
||||||
|
|
||||||
public override readonly ttl: number = 21600000; // 6h
|
public override readonly ttl = 0;
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public override viaMediaFlowProxy = true;
|
||||||
return null !== url.host.match(/vixsrc/);
|
|
||||||
|
private readonly fetcher: Fetcher;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.fetcher = fetcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
public supports(ctx: Context, url: URL): boolean {
|
||||||
const headers = { Referer: url.href };
|
return null !== url.host.match(/vixsrc/) && supportsMediaFlowProxy(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
||||||
const html = await this.fetcher.text(ctx, url);
|
const html = await this.fetcher.text(ctx, url);
|
||||||
|
|
||||||
const tokenMatch = html.match(/['"]token['"]: ?['"](.*?)['"]/) as string[];
|
const filenameMatch = html.match(/"filename":"(.*?)"/);
|
||||||
const expiresMatch = html.match(/['"]expires['"]: ?['"](.*?)['"]/) as string[];
|
const sizeMatch = html.match(/"size":(\d+)/);
|
||||||
const urlMatch = html.match(/url: ?['"](.*?)['"]/) as string[];
|
const qualityMatch = html.match(/"quality":(\d+)/);
|
||||||
|
|
||||||
const baseUrl = new URL(`${urlMatch[1]}`);
|
const playlistUrl = await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'VixCloud', url);
|
||||||
const playlistUrl = new URL(`${baseUrl.origin}${baseUrl.pathname}.m3u8?${baseUrl.searchParams}`);
|
const countryCodes = await this.determineCountryCodesFromPlaylist(ctx, playlistUrl);
|
||||||
playlistUrl.searchParams.append('token', tokenMatch[1] as string);
|
|
||||||
playlistUrl.searchParams.append('expires', expiresMatch[1] as string);
|
|
||||||
playlistUrl.searchParams.append('h', '1');
|
|
||||||
|
|
||||||
const countryCodes = meta.countryCodes ?? [CountryCode.multi, ...(await this.determineCountryCodesFromPlaylist(ctx, playlistUrl, { headers }))];
|
|
||||||
|
|
||||||
if (!hasMultiEnabled(ctx.config) && !countryCodes.some(countryCode => countryCode in ctx.config)) {
|
if (!hasMultiEnabled(ctx.config) && !countryCodes.some(countryCode => countryCode in ctx.config)) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -38,19 +48,23 @@ export class VixSrc extends Extractor {
|
||||||
{
|
{
|
||||||
url: playlistUrl,
|
url: playlistUrl,
|
||||||
format: Format.hls,
|
format: Format.hls,
|
||||||
|
label: this.label,
|
||||||
|
sourceId: `${this.id}_${countryCode}`,
|
||||||
|
ttl: this.ttl,
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
|
||||||
countryCodes,
|
countryCodes,
|
||||||
height: meta.height ?? await guessHeightFromPlaylist(ctx, this.fetcher, playlistUrl, { headers }),
|
...(filenameMatch && { title: filenameMatch[1] }),
|
||||||
|
...(sizeMatch && { bytes: bytes.parse(`${sizeMatch[1]} kb`) as number }),
|
||||||
|
...(qualityMatch && { height: parseInt(qualityMatch[1] as string) }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
private async determineCountryCodesFromPlaylist(ctx: Context, playlistUrl: URL, init?: CustomRequestConfig): Promise<CountryCode[]> {
|
private async determineCountryCodesFromPlaylist(ctx: Context, playlistUrl: URL): Promise<CountryCode[]> {
|
||||||
const playlist = await this.fetcher.text(ctx, playlistUrl, init);
|
const playlist = await this.fetcher.text(ctx, playlistUrl);
|
||||||
|
|
||||||
const countryCodes: CountryCode[] = [];
|
const countryCodes: CountryCode[] = [CountryCode.it];
|
||||||
|
|
||||||
(Object.keys(CountryCode) as CountryCode[]).forEach((countryCode) => {
|
(Object.keys(CountryCode) as CountryCode[]).forEach((countryCode) => {
|
||||||
const iso639 = iso639FromCountryCode(countryCode);
|
const iso639 = iso639FromCountryCode(countryCode);
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import winston from 'winston';
|
|
||||||
import { createTestContext } from '../test';
|
|
||||||
import { FetcherMock } from '../utils';
|
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
|
||||||
import { Voe } from './Voe';
|
|
||||||
|
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new Voe(new FetcherMock(`${__dirname}/__fixtures__/Voe`))]);
|
|
||||||
|
|
||||||
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow.test.org', mediaFlowProxyPassword: 'test' });
|
|
||||||
|
|
||||||
describe('Voe', () => {
|
|
||||||
test('jilliandescribecompany', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://jilliandescribecompany.com/e/ea21l02gcygw'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('premium only without resolution', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://jilliandescribecompany.com/qqfyi04w52mj'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('encoding error', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://voe.sx/e/c2yxvit4f6bz'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('embed only urls which otherwise lead to 404', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://mikaylaarealike.com/e/gqlhm9hbobwu'))).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
import bytes from 'bytes';
|
|
||||||
import * as cheerio from 'cheerio';
|
|
||||||
import { NotFoundError } from '../error';
|
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
|
||||||
import {
|
|
||||||
buildMediaFlowProxyExtractorStreamUrl, guessHeightFromPlaylist,
|
|
||||||
supportsMediaFlowProxy,
|
|
||||||
} from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
|
||||||
|
|
||||||
/** @see https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/voesx.py */
|
|
||||||
export class Voe extends Extractor {
|
|
||||||
public readonly id = 'voe';
|
|
||||||
|
|
||||||
public readonly label = 'VOE';
|
|
||||||
|
|
||||||
public override viaMediaFlowProxy = true;
|
|
||||||
|
|
||||||
public supports(ctx: Context, url: URL): boolean {
|
|
||||||
const supportedDomain = null !== url.host.match(/voe/)
|
|
||||||
|| [
|
|
||||||
'19turanosephantasia.com',
|
|
||||||
'20demidistance9elongations.com',
|
|
||||||
'30sensualizeexpression.com',
|
|
||||||
'321naturelikefurfuroid.com',
|
|
||||||
'35volitantplimsoles5.com',
|
|
||||||
'449unceremoniousnasoseptal.com',
|
|
||||||
'745mingiestblissfully.com',
|
|
||||||
'adrianmissionminute.com',
|
|
||||||
'alleneconomicmatter.com',
|
|
||||||
'antecoxalbobbing1010.com',
|
|
||||||
'apinchcaseation.com',
|
|
||||||
'audaciousdefaulthouse.com',
|
|
||||||
'availedsmallest.com',
|
|
||||||
'bigclatterhomesguideservice.com',
|
|
||||||
'boonlessbestselling244.com',
|
|
||||||
'bradleyviewdoctor.com',
|
|
||||||
'brittneystandardwestern.com',
|
|
||||||
'brucevotewithin.com',
|
|
||||||
'christopheruntilpoint.com',
|
|
||||||
'chromotypic.com',
|
|
||||||
'chuckle-tube.com',
|
|
||||||
'cindyeyefinal.com',
|
|
||||||
'counterclockwisejacky.com',
|
|
||||||
'crownmakermacaronicism.com',
|
|
||||||
'crystaltreatmenteast.com',
|
|
||||||
'cyamidpulverulence530.com',
|
|
||||||
'diananatureforeign.com',
|
|
||||||
'donaldlineelse.com',
|
|
||||||
'edwardarriveoften.com',
|
|
||||||
'erikcoldperson.com',
|
|
||||||
'figeterpiazine.com',
|
|
||||||
'fittingcentermondaysunday.com',
|
|
||||||
'fraudclatterflyingcar.com',
|
|
||||||
'gamoneinterrupted.com',
|
|
||||||
'generatesnitrosate.com',
|
|
||||||
'goofy-banana.com',
|
|
||||||
'graceaddresscommunity.com',
|
|
||||||
'greaseball6eventual20.com',
|
|
||||||
'guidon40hyporadius9.com',
|
|
||||||
'heatherdiscussionwhen.com',
|
|
||||||
'housecardsummerbutton.com',
|
|
||||||
'jamessoundcost.com',
|
|
||||||
'jamiesamewalk.com',
|
|
||||||
'jasminetesttry.com',
|
|
||||||
'jayservicestuff.com',
|
|
||||||
'jennifercertaindevelopment.com',
|
|
||||||
'jilliandescribecompany.com',
|
|
||||||
'johnalwayssame.com',
|
|
||||||
'jonathansociallike.com',
|
|
||||||
'josephseveralconcern.com',
|
|
||||||
'kathleenmemberhistory.com',
|
|
||||||
'kellywhatcould.com',
|
|
||||||
'kennethofficialitem.com',
|
|
||||||
'kinoger.ru',
|
|
||||||
'kristiesoundsimply.com',
|
|
||||||
'lancewhosedifficult.com',
|
|
||||||
'launchreliantcleaverriver.com',
|
|
||||||
'lauradaydo.com',
|
|
||||||
'lisatrialidea.com',
|
|
||||||
'loriwithinfamily.com',
|
|
||||||
'lukecomparetwo.com',
|
|
||||||
'lukesitturn.com',
|
|
||||||
'mariatheserepublican.com',
|
|
||||||
'matriculant401merited.com',
|
|
||||||
'maxfinishseveral.com',
|
|
||||||
'metagnathtuggers.com',
|
|
||||||
'michaelapplysome.com',
|
|
||||||
'mikaylaarealike.com',
|
|
||||||
'nathanfromsubject.com',
|
|
||||||
'nectareousoverelate.com',
|
|
||||||
'nonesnanking.com',
|
|
||||||
'paulkitchendark.com',
|
|
||||||
'realfinanceblogcenter.com',
|
|
||||||
'rebeccaneverbase.com',
|
|
||||||
'reputationsheriffkennethsand.com',
|
|
||||||
'richardsignfish.com',
|
|
||||||
'roberteachfinal.com',
|
|
||||||
'robertordercharacter.com',
|
|
||||||
'robertplacespace.com',
|
|
||||||
'sandratableother.com',
|
|
||||||
'sandrataxeight.com',
|
|
||||||
'scatch176duplicities.com',
|
|
||||||
'sethniceletter.com',
|
|
||||||
'shannonpersonalcost.com',
|
|
||||||
'simpulumlamerop.com',
|
|
||||||
'smoki.cc',
|
|
||||||
'stevenimaginelittle.com',
|
|
||||||
'strawberriesporail.com',
|
|
||||||
'telyn610zoanthropy.com',
|
|
||||||
'timberwoodanotia.com',
|
|
||||||
'toddpartneranimal.com',
|
|
||||||
'toxitabellaeatrebates306.com',
|
|
||||||
'uptodatefinishconferenceroom.com',
|
|
||||||
'v-o-e-unblock.com',
|
|
||||||
'valeronevijao.com',
|
|
||||||
'walterprettytheir.com',
|
|
||||||
'wolfdyslectic.com',
|
|
||||||
'yodelswartlike.com',
|
|
||||||
].includes(url.host);
|
|
||||||
|
|
||||||
return supportedDomain && supportsMediaFlowProxy(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override normalize(url: URL): URL {
|
|
||||||
return new URL(`/${url.pathname.replace(/\/+$/, '').split('/').at(-1)}`, url);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
|
||||||
const headers = { Referer: meta.referer ?? url.href };
|
|
||||||
|
|
||||||
let html: string;
|
|
||||||
try {
|
|
||||||
html = await this.fetcher.text(ctx, url, { headers });
|
|
||||||
} catch (error) {
|
|
||||||
/* istanbul ignore next */
|
|
||||||
if (error instanceof NotFoundError && !url.href.includes('/e/')) {
|
|
||||||
return await this.extractInternal(ctx, new URL(`/e${url.pathname}`, url.origin), meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* istanbul ignore next */
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectMatch = html.match(/window\.location\.href\s*=\s*'([^']+)/);
|
|
||||||
if (redirectMatch && redirectMatch[1]) {
|
|
||||||
return await this.extractInternal(ctx, new URL(redirectMatch[1]), meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/An error occurred during encoding/.test(html)) {
|
|
||||||
throw new NotFoundError();
|
|
||||||
}
|
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
|
||||||
const title = $('meta[name="description"]').attr('content')?.trim().replace(/^Watch /, '').replace(/ at VOE$/, '').trim();
|
|
||||||
|
|
||||||
const sizeMatch = html.matchAll(/[\d.]+ ?[GM]B/g).toArray().at(-1);
|
|
||||||
const size = sizeMatch ? bytes.parse(sizeMatch[0] as string) as number : /* istanbul ignore next */null;
|
|
||||||
|
|
||||||
const playlistUrl = await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'Voe', url, headers);
|
|
||||||
|
|
||||||
const heightMatch = html.match(/<b>(\d{3,})p<\/b>/);
|
|
||||||
const height = heightMatch
|
|
||||||
? parseInt(heightMatch[1] as string)
|
|
||||||
: meta.height ?? await guessHeightFromPlaylist(ctx, this.fetcher, playlistUrl);
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
url: playlistUrl,
|
|
||||||
format: Format.hls,
|
|
||||||
meta: {
|
|
||||||
...meta,
|
|
||||||
height,
|
|
||||||
title,
|
|
||||||
...(size && size > 16777216 && { bytes: size }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
29
src/extractor/XPrime.test.ts
Normal file
29
src/extractor/XPrime.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import winston from 'winston';
|
||||||
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
|
import { FetcherMock } from '../utils';
|
||||||
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
|
import { XPrime } from './XPrime';
|
||||||
|
|
||||||
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
|
const extractorRegistry = new ExtractorRegistry(logger, [new XPrime(new FetcherMock(`${__dirname}/__fixtures__/XPrime`))]);
|
||||||
|
|
||||||
|
const ctx = createTestContext();
|
||||||
|
|
||||||
|
describe('XPrime', () => {
|
||||||
|
test('unknown backend is ignored', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://backend.xprime.tv/unknown?name=Superman&year=2025'), CountryCode.en, 'Superman (2025)')).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('primebox Superman', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://backend.xprime.tv/primebox?name=Superman&year=2025'), CountryCode.en, 'Superman (2025)')).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('primebox Alien: Earth', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://backend.xprime.tv/primebox?name=Alien%3A%20Earth&year=2025&season=1&episode=1'), CountryCode.en, 'Alien: Earth 1x1')).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('primebox not found', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://backend.xprime.tv/primebox?name=Jurassic%20World%20Rebirth&year=2025'), CountryCode.en, 'Jurassic World Rebirth (2025)')).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
68
src/extractor/XPrime.ts
Normal file
68
src/extractor/XPrime.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
|
import { Fetcher } from '../utils';
|
||||||
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
|
interface XPrimePrimeboxResponsePartial {
|
||||||
|
streams: Record<string, string>;
|
||||||
|
series_info?: {
|
||||||
|
episode: number;
|
||||||
|
season: number;
|
||||||
|
title: string;
|
||||||
|
year: string;
|
||||||
|
};
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class XPrime extends Extractor {
|
||||||
|
public readonly id = 'xprime';
|
||||||
|
|
||||||
|
public readonly label = 'XPrime';
|
||||||
|
|
||||||
|
private readonly fetcher: Fetcher;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
|
return null !== url.host.match(/xprime/);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
||||||
|
const urlResults: UrlResult[] = [];
|
||||||
|
|
||||||
|
const referer = url.protocol + '//' + url.hostname.split('.').slice(-2).join('.'); // Strip subdomains
|
||||||
|
|
||||||
|
if (url.href.includes('primebox')) {
|
||||||
|
const jsonResponse = JSON.parse(await this.fetcher.text(ctx, url)) as XPrimePrimeboxResponsePartial;
|
||||||
|
|
||||||
|
for (const [resolution, stream] of Object.entries(jsonResponse.streams)) {
|
||||||
|
const url = new URL(stream);
|
||||||
|
const title = jsonResponse.series_info
|
||||||
|
? `${jsonResponse.series_info.title} ${jsonResponse.series_info.season}x${jsonResponse.series_info.episode} (${jsonResponse.series_info.year})`
|
||||||
|
: jsonResponse.title;
|
||||||
|
|
||||||
|
urlResults.push({
|
||||||
|
url,
|
||||||
|
format: Format.mp4,
|
||||||
|
label: `${this.label} Primebox`,
|
||||||
|
sourceId: `${this.id}_${countryCode}_primebox`,
|
||||||
|
ttl: this.ttl,
|
||||||
|
meta: {
|
||||||
|
countryCodes: [countryCode],
|
||||||
|
bytes: parseInt((await this.fetcher.head(ctx, url, { headers: { Referer: referer }, minCacheTtl: this.ttl }))['content-length'] as string),
|
||||||
|
height: parseInt(resolution),
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
requestHeaders: {
|
||||||
|
Referer: referer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return urlResults;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
|
import { CountryCode } from '../types';
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { YouTube } from './YouTube';
|
import { YouTube } from './YouTube';
|
||||||
|
|
@ -11,10 +12,10 @@ const ctx = createTestContext();
|
||||||
|
|
||||||
describe('YouTube', () => {
|
describe('YouTube', () => {
|
||||||
test('Solaris', async () => {
|
test('Solaris', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://www.youtube.com/watch?v=Z8ZhQPaw4rE'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://www.youtube.com/watch?v=Z8ZhQPaw4rE'), CountryCode.en)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unsupported format', async () => {
|
test('unsupported format', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://youtu.be/Z8ZhQPaw4rE?feature=shared'))).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://youtu.be/Z8ZhQPaw4rE?feature=shared'), CountryCode.en)).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
import { Context, CountryCode, Format, UrlResult } from '../types';
|
||||||
|
import { Fetcher } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class YouTube extends Extractor {
|
export class YouTube extends Extractor {
|
||||||
|
|
@ -6,16 +7,20 @@ export class YouTube extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'YouTube';
|
public readonly label = 'YouTube';
|
||||||
|
|
||||||
public override readonly ttl: number = 21600000; // 6h
|
private readonly fetcher: Fetcher;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/youtube/) && url.searchParams.has('v');
|
return null !== url.host.match(/youtube/) && url.searchParams.has('v');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
||||||
const headers = { Referer: meta.referer ?? url.href };
|
const html = await this.fetcher.text(ctx, url);
|
||||||
|
|
||||||
const html = await this.fetcher.text(ctx, url, { headers });
|
|
||||||
|
|
||||||
const titleMatch = html.match(/"title":{"runs":\[{"text":"(.*?)"/) as string[];
|
const titleMatch = html.match(/"title":{"runs":\[{"text":"(.*?)"/) as string[];
|
||||||
|
|
||||||
|
|
@ -24,8 +29,11 @@ export class YouTube extends Extractor {
|
||||||
url,
|
url,
|
||||||
format: Format.unknown,
|
format: Format.unknown,
|
||||||
ytId: url.searchParams.get('v') as string,
|
ytId: url.searchParams.get('v') as string,
|
||||||
|
label: this.label,
|
||||||
|
sourceId: `${this.id}_${countryCode}`,
|
||||||
|
ttl: this.ttl,
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
countryCodes: [countryCode],
|
||||||
title: titleMatch[1] as string,
|
title: titleMatch[1] as string,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,2 +0,0 @@
|
||||||
<!doctype html><html lang="en"><head> <meta charset="utf-8"> <title>Video not found | DoodStream</title> <link rel="preconnect" href="//i.doodcdn.io"><link rel="preconnect" href="//cdnjs.cloudflare.com"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <script src="//cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script> <link rel="stylesheet" href="//i.doodcdn.io/theme_2/css/bootstrap.min.css"> <link rel="stylesheet" type="text/css" href="//i.doodcdn.io/theme_2/css/style.css" /></head><body> <style type="text/css"> .not_found img {height:250px !important;} @media (max-width: 768px){.not_found h1{font-size:1.8rem;}.not_found img {height:200px !important;margin: 0 auto 15px;}} @media (max-width: 480px){.not_found h1{font-size:1.4rem;}.not_found img {height:120px !important;margin: 0 auto 15px;}.not_found p{margin-top: -5px;}} </style> <div class="container pt-5 not_found"> <div class="row"> <div class="col-md-12 pt-2 text-center"> <img src="//i.doodcdn.io/img/no_video_3.svg"> <h1>Not Found</h1> <p>video you are looking for is not found.</p> </div> </div> </div><script data-cfasync="false" src="//d1f05vr3sjsuy7.cloudfront.net/?srvfd=908056"></script><script data-cfasync="false" async type="text/javascript" src="//faqirsgoliard.top/gHzOaAdOhbZ/71405"></script><script data-cfasync="false" async type="text/javascript" src="//faqirsgoliard.top/r67c0fc81985e5/70849"></script><script type="text/javascript" data-cfasync="false">/*<![CDATA[/* */(function(){var g=window,r="ca0967284e67bc53cb5b626985619403",w=[["siteId",317+185*82*507-3600486],["minBid",0],["popundersPerIP","0"],["delayBetween",300],["default","https://strettechoco.com/iYa7dlULceKw/27615"],["defaultPerDay",0],["topmostLayer","never"]],n=["d3d3LmJsb2NrYWRzbm90LmNvbS9iYWphLm1pbi5jc3M=","ZG5oZmk1bm4yZHQ2Ny5jbG91ZGZyb250Lm5ldC9RVHd0eC91YWxnZWJyYS5taW4uanM=","d3d3LmJibHZwbGd1b21hLmNvbS9mYWphLm1pbi5jc3M=","d3d3LndqZ3BzcGt4empiZGd1LmNvbS9NTmlIWS9rYWxnZWJyYS5taW4uanM="],f=-1,v,q,h=function(){clearTimeout(q);f++;if(n[f]&&!(1766620801000<(new Date).getTime()&&1<f)){v=g.document.createElement("script");v.type="text/javascript";v.async=!0;var o=g.document.getElementsByTagName("script")[0];v.src="https://"+atob(n[f]);v.crossOrigin="anonymous";v.onerror=h;v.onload=function(){clearTimeout(q);g[r.slice(0,16)+r.slice(0,16)]||h()};q=setTimeout(h,5E3);o.parentNode.insertBefore(v,o)}};if(!g[r]){try{Object.freeze(g[r]=w)}catch(e){}h()}})();/*]]>/* */</script><script defer src="https://static.cloudflareinsights.com/beacon.min.js/vcd15cbe7772f49c399c6a5babf22c1241717689176015" integrity="sha512-ZpsOmlRQV6y907TI0dKBHq9Md29nnaEIPlkf84rnaERnq6zvWvPUqr2ft8M1aS28oN72PdrCzSjY4U6VaAw1EQ==" data-cf-beacon='{"version":"2024.11.0","token":"1c76230f8ab248eab0b0f2bf7c205c51","server_timing":{"name":{"cfCacheStatus":true,"cfEdge":true,"cfExtPri":true,"cfL4":true,"cfOrigin":true,"cfSpeedBrain":true},"location_startswith":null}}' crossorigin="anonymous"></script>
|
|
||||||
</body></html>
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,5 +0,0 @@
|
||||||
#EXTM3U
|
|
||||||
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3955353,RESOLUTION=1920x800,FRAME-RATE=23.974,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR
|
|
||||||
index-v1-a1.m3u8?t=jq0VCV2dOyLIIH07IGIpzruCXvbfdVqzGLWfMeo00AM&s=1759845990&e=14400&f=1571895&srv=ds3&i=0.0&sp=0&ii=2.184.0.222&p1=ds3&p2=ds3
|
|
||||||
|
|
||||||
#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=68483,RESOLUTION=1920x800,CODECS="avc1.640028",URI="iframes-v1-a1.m3u8?t=jq0VCV2dOyLIIH07IGIpzruCXvbfdVqzGLWfMeo00AM&s=1759845990&e=14400&f=1571895&srv=ds3&i=0.0&sp=0&ii=2.184.0.222&p1=ds3&p2=ds3",VIDEO-RANGE=SDR
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Fastream</title>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
|
||||||
<meta name="theme-color" content="#FFF">
|
|
||||||
<link rel="apple-touch-icon" href="https://fastream.to/images2/favicon/apple-touch-icon.png" sizes="180x180">
|
|
||||||
<link rel="icon" href="https://fastream.to/images2/favicon/favicon-32x32.png" sizes="32x32">
|
|
||||||
<link rel="icon" href="https://fastream.to/images2/favicon/favicon-16x16.png" sizes="16x16">
|
|
||||||
<link rel="manifest" href="https://fastream.to/images2/favicon/site.webmanifest" crossorigin="use-credentials">
|
|
||||||
<link rel="icon" href="https://fastream.to/images2/favicon/favicon.ico">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400..800&display=swap" rel="stylesheet">
|
|
||||||
<link href="https://fastream.to/css2/bootstrap.css?" rel="stylesheet">
|
|
||||||
|
|
||||||
<link href="https://fastream.to/css2/style.css?" rel="stylesheet">
|
|
||||||
|
|
||||||
<script src="https://fastream.to/js2/jquery.min.js?"></script>
|
|
||||||
<script src="https://fastream.to/js2/bootstrap.bundle.min.js?"></script>
|
|
||||||
<script src="https://fastream.to/js2/app.js?aс1"></script>
|
|
||||||
<script src="https://fastream.to/js2/xupload.js?aс1"></script>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body class="">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<header class="header">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row header-row align-items-center flex-nowrap">
|
|
||||||
<div class="col-auto flex-grow-1 flex-shrink-1">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col-auto d-lg-none">
|
|
||||||
<div class="dropdown">
|
|
||||||
<button class="btn icon-btn" data-bs-toggle="dropdown" data-bs-offset="0, 16">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="22" height="16" viewBox="0 0 22 16">
|
|
||||||
<path d="M0 0h22v2H0V0zm0 7h22v2H0V7zm0 7h22v2H0v-2z"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
|
||||||
|
|
||||||
<a href="/premium" class="dropdown-item">Premium</a>
|
|
||||||
|
|
||||||
<a href="/make_money" class="dropdown-item">Make Money</a>
|
|
||||||
<a href="/check_files" class="dropdown-item">Link Checker</a>
|
|
||||||
|
|
||||||
<a href="/api.html" class="dropdown-item">API</a>
|
|
||||||
<a href="/tos" class="dropdown-item">Terms of service</a>
|
|
||||||
|
|
||||||
<a href="/contact" class="dropdown-item">Contact Us</a>
|
|
||||||
<a href="/faq" class="dropdown-item">FAQ</a>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto me-lg-4">
|
|
||||||
<a href="/" class="logo">
|
|
||||||
<img src="https://fastream.to/images2/logo.svg?" alt="">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto d-none d-lg-block">
|
|
||||||
<nav class="nav">
|
|
||||||
|
|
||||||
|
|
||||||
<a class="nav-link link-body-emphasis" href="/premium">Premium</a>
|
|
||||||
|
|
||||||
<a class="nav-link link-body-emphasis" href="/make_money">Make Money</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-auto">
|
|
||||||
<div class="dropdown header-lang">
|
|
||||||
<button type="button" class="btn icon-btn" data-bs-toggle="dropdown" data-bs-offset="0, 16">
|
|
||||||
<img src="https://fastream.to/images2/flags/english.svg" alt="">
|
|
||||||
</button>
|
|
||||||
<div class="dropdown-menu">
|
|
||||||
<a href="/?op=change_lang&lang=English" class="dropdown-item"><img src="https://fastream.to/images2/flags/english.svg" alt="">English</a>
|
|
||||||
<a href="/?op=change_lang&lang=Russian" class="dropdown-item"><img src="https://fastream.to/images2/flags/russian.svg" alt="">Russian</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="col-auto d-none d-lg-block">
|
|
||||||
<a href="/login.html" class="btn me-2 login-btn link-body-emphasis">Login</a>
|
|
||||||
<a href="/?op=registration" class="btn btn-primary reg-btn">Sign Up</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto d-lg-none">
|
|
||||||
<div class="dropdown">
|
|
||||||
<button class="btn icon-btn" data-bs-toggle="dropdown" data-bs-offset="0, 16">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="22" height="22" viewBox="0 0 22 22">
|
|
||||||
<path d="M11 22C4.9 22 0 17.1 0 11S4.9 0 11 0s11 4.9 11 11-4.9 11-11 11zm0-20c-5 0-9 4-9 9s4 9 9 9 9-4 9-9-4-9-9-9z"></path>
|
|
||||||
<path d="M11 15.3c-3 0-5.4-2.4-5.4-5.4S8 4.4 11 4.4s5.4 2.4 5.4 5.4-2.4 5.5-5.4 5.5zm0-8.9c-1.9 0-3.4 1.5-3.4 3.4s1.5 3.4 3.4 3.4 3.4-1.5 3.4-3.4-1.5-3.4-3.4-3.4z"></path>
|
|
||||||
<path d="M4.9 19.8c-.2 0-.3 0-.5-.1-.5-.3-.6-.9-.4-1.4 1.5-2.5 3.9-3.8 7-3.8s5.5 1.3 7 3.8c.3.5.1 1.1-.3 1.4-.5.3-1.1.1-1.4-.3-1.1-1.9-2.9-2.8-5.2-2.8-2.4 0-4.1.9-5.3 2.8-.2.2-.6.4-.9.4z"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="dropdown-menu dropdown-menu-end">
|
|
||||||
<a class="dropdown-item" href="/login.html">Login</a>
|
|
||||||
<a class="dropdown-item" href="/?op=registration">Sign Up</a>
|
|
||||||
<a class="dropdown-item" href="/?op=forgot_pass">Forgot your password?</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<main>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
|
|
||||||
<div class="alert alert-danger" role="alert">
|
|
||||||
No such file=a9rp1d1pqom7
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</main>
|
|
||||||
|
|
||||||
|
|
||||||
<footer class="footer">
|
|
||||||
<div class="container">
|
|
||||||
<div class="footer-main">
|
|
||||||
<div class="row justify-content-center justify-content-lg-start">
|
|
||||||
<div class="col-12 col-lg-4 col-xl-3 text-center text-lg-start">
|
|
||||||
<div class="mb-4">
|
|
||||||
<a href="https://fastream.to/" class="logo">
|
|
||||||
<img src="https://fastream.to/images2/logo-w.svg?" alt="">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2 text-uppercase"><!--Share us: --></div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<!--span class="me-1">
|
|
||||||
<a href="#"><img src="https://fastream.to/images2/social/fb.svg" alt=""></a>
|
|
||||||
</span>
|
|
||||||
<span class="me-1">
|
|
||||||
<a href="#"><img src="https://fastream.to/images2/social/red.svg" alt=""></a>
|
|
||||||
</span>
|
|
||||||
<span class="me-1">
|
|
||||||
<a href="#"><img src="https://fastream.to/images2/social/wha.svg" alt=""></a>
|
|
||||||
</span>
|
|
||||||
<span class="me-1">
|
|
||||||
<a href="#"><img src="https://fastream.to/images2/social/x.svg" alt=""></a>
|
|
||||||
</span>
|
|
||||||
<span class="me-1">
|
|
||||||
<a href="#"><img src="https://fastream.to/images2/social/msg.svg" alt=""></a>
|
|
||||||
</span>
|
|
||||||
<span class="me-1">
|
|
||||||
<a href="#"><img src="https://fastream.to/images2/social/in.svg" alt=""></a>
|
|
||||||
</span-->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-8 col-xl-7 d-none d-lg-block">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<div class="text-uppercase text-dark large">Info</div>
|
|
||||||
<div class="nav flex-column">
|
|
||||||
<a href="https://fastream.to/" class="nav-link">Home</a>
|
|
||||||
<a href="/api.html" class="nav-link">API</a>
|
|
||||||
<a href="/tos" class="nav-link">Terms of service</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<div class="text-uppercase text-dark large">Features</div>
|
|
||||||
<div class="nav flex-column">
|
|
||||||
|
|
||||||
<a href="/premium" class="nav-link">Premium</a>
|
|
||||||
|
|
||||||
<a href="/make_money" class="nav-link">Make Money</a>
|
|
||||||
<a href="/check_files" class="nav-link">Link Checker</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<div class="text-uppercase text-dark large">Support</div>
|
|
||||||
<div class="nav flex-column">
|
|
||||||
<a href="/contact" class="nav-link">Contact Us</a>
|
|
||||||
<a href="/faq" class="nav-link">FAQ</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="col-xl-auto text-nowrap text-end d-none d-xl-block">
|
|
||||||
<a href="https://fastream.to/login.html" class="btn btn-light login-btn me-2">Login</a>
|
|
||||||
<a href="https://fastream.to/?op=registration" class="btn btn-primary reg-btn">Sign Up</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer-bottom small">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row align-items-center justify-content-between">
|
|
||||||
<div class="col-auto">
|
|
||||||
<div class="text-muted">© 2024 Fastream</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div style ="display:none"><script id="_wau1xg">var _wau = _wau || []; _wau.push(["classic", "y7jk20u14x", "1xg"]);</script><script async src="//waust.at/c.js"></script></div>
|
|
||||||
|
|
||||||
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'993a4e2d4ac5d2ab',t:'MTc2MTMxNzYyMw=='};var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>
|
|
||||||
|
|
||||||
Watch 300938 570f5a24 c281 484d 93fa 734dabbec813 jkgg 2651705 vidhide mp4
|
|
||||||
|
|
||||||
</title>
|
|
||||||
<META NAME="description" CONTENT="">
|
|
||||||
<META NAME="keywords" CONTENT="">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
|
||||||
<meta name="theme-color" content="#FFF">
|
|
||||||
|
|
||||||
<link rel="apple-touch-icon" href="/static/images/favicon/apple-touch-icon.png" sizes="180x180">
|
|
||||||
<link rel="icon" href="/static/images/favicon/favicon-32x32.png" sizes="32x32">
|
|
||||||
<link rel="icon" href="/static/images/favicon/favicon-16x16.png" sizes="16x16">
|
|
||||||
<link rel="manifest" href="/static/images/favicon/manifest.json">
|
|
||||||
<link rel="icon" href="/static/images/favicon/favicon.ico">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="crossorigin">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;700&display=swap" rel="stylesheet">
|
|
||||||
<link href="/static/css/style.css?" rel="stylesheet">
|
|
||||||
|
|
||||||
<script src="/static/js/jquery-3.2.1.min.js?"></script>
|
|
||||||
<script src="/static/js/bootstrap.bundle.min.js?"></script>
|
|
||||||
<script src="/static/js/app.js?"></script>
|
|
||||||
<script src="/static/js/xupload.js?"></script>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Google tag (gtag.js) -->
|
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-48ZJD1VPGZ"></script>
|
|
||||||
<script>
|
|
||||||
window.dataLayer = window.dataLayer || [];
|
|
||||||
function gtag(){dataLayer.push(arguments);}
|
|
||||||
gtag('js', new Date());
|
|
||||||
|
|
||||||
gtag('config', 'G-48ZJD1VPGZ');
|
|
||||||
</script>
|
|
||||||
<!-- Yandex.Metrika counter -->
|
|
||||||
<script type="text/javascript" >
|
|
||||||
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
|
||||||
m[i].l=1*new Date();
|
|
||||||
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
|
||||||
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
|
|
||||||
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
|
|
||||||
|
|
||||||
ym(93381808, "init", {
|
|
||||||
clickmap:true,
|
|
||||||
trackLinks:true,
|
|
||||||
accurateTrackBounce:true,
|
|
||||||
webvisor:true
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<noscript><div><img src="https://mc.yandex.ru/watch/93381808" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
|
||||||
<!-- /Yandex.Metrika counter -->
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<header class="header">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row align-items-center flex-nowrap">
|
|
||||||
<div class="col-auto flex-grow-1 flex-shrink-1">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
|
|
||||||
<div class="col-auto" style="margin: 0 auto;">
|
|
||||||
<a href="/" class="logo">
|
|
||||||
|
|
||||||
<img src="/static/images/logo.svg" alt="">
|
|
||||||
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section class="section">
|
|
||||||
<div class="container">
|
|
||||||
<div class="block">
|
|
||||||
<div class="mb-3">
|
|
||||||
The file was deleted by administration because it didn't comply with our Terms of Use:
|
|
||||||
</div>
|
|
||||||
<h4>
|
|
||||||
300938--570f5a24-c281-484d-93fa-734dabbec813--jkgg--2651705-vidhide.mp4
|
|
||||||
</h4>
|
|
||||||
<br>
|
|
||||||
Reason for deletion: <br>
|
|
||||||
<b class="text-danger">
|
|
||||||
DMCA request
|
|
||||||
</b>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<footer class="footer">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-4 col-xs-12">
|
|
||||||
<div class="mb-3 text-center">
|
|
||||||
<a href="#" class="logo">
|
|
||||||
|
|
||||||
<img src="/static/images/logo.svg" alt="">
|
|
||||||
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-8 col-xs-12" style="position: relative">
|
|
||||||
<div class="mb-4 text-center">
|
|
||||||
<p class="text-muted ">
|
|
||||||
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
<style>
|
|
||||||
@media (min-width:481px) {
|
|
||||||
.text-muted {}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width:961px) {
|
|
||||||
.text-muted {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 15px
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,161 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>
|
|
||||||
|
|
||||||
Watch
|
|
||||||
|
|
||||||
</title>
|
|
||||||
<META NAME="description" CONTENT="">
|
|
||||||
<META NAME="keywords" CONTENT="">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
|
||||||
<meta name="theme-color" content="#FFF">
|
|
||||||
|
|
||||||
<link rel="apple-touch-icon" href="/static/images/favicon/apple-touch-icon.png" sizes="180x180">
|
|
||||||
<link rel="icon" href="/static/images/favicon/favicon-32x32.png" sizes="32x32">
|
|
||||||
<link rel="icon" href="/static/images/favicon/favicon-16x16.png" sizes="16x16">
|
|
||||||
<link rel="manifest" href="/static/images/favicon/manifest.json">
|
|
||||||
<link rel="icon" href="/static/images/favicon/favicon.ico">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="crossorigin">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;700&display=swap" rel="stylesheet">
|
|
||||||
<link href="/static/css/style.css?" rel="stylesheet">
|
|
||||||
|
|
||||||
<script src="/static/js/jquery-3.2.1.min.js?"></script>
|
|
||||||
<script src="/static/js/bootstrap.bundle.min.js?"></script>
|
|
||||||
<script src="/static/js/app.js?"></script>
|
|
||||||
<script src="/static/js/xupload.js?"></script>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Google tag (gtag.js) -->
|
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-48ZJD1VPGZ"></script>
|
|
||||||
<script>
|
|
||||||
window.dataLayer = window.dataLayer || [];
|
|
||||||
function gtag(){dataLayer.push(arguments);}
|
|
||||||
gtag('js', new Date());
|
|
||||||
|
|
||||||
gtag('config', 'G-48ZJD1VPGZ');
|
|
||||||
</script>
|
|
||||||
<!-- Yandex.Metrika counter -->
|
|
||||||
<script type="text/javascript" >
|
|
||||||
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
|
||||||
m[i].l=1*new Date();
|
|
||||||
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
|
||||||
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
|
|
||||||
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
|
|
||||||
|
|
||||||
ym(93381808, "init", {
|
|
||||||
clickmap:true,
|
|
||||||
trackLinks:true,
|
|
||||||
accurateTrackBounce:true,
|
|
||||||
webvisor:true
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<noscript><div><img src="https://mc.yandex.ru/watch/93381808" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
|
||||||
<!-- /Yandex.Metrika counter -->
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<header class="header">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row align-items-center flex-nowrap">
|
|
||||||
<div class="col-auto flex-grow-1 flex-shrink-1">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
|
|
||||||
<div class="col-auto" style="margin: 0 auto;">
|
|
||||||
<a href="/" class="logo">
|
|
||||||
|
|
||||||
<img src="/static/images/logo.svg" alt="">
|
|
||||||
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section class="section">
|
|
||||||
<div class="container">
|
|
||||||
<div class="block">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="mb-4">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><path fill-rule="evenodd" fill="#DC3545" d="M32 64C14.326 64 0 49.674 0 32S14.326 0 32 0c17.666.019 31.981 14.334 32 32 0 17.674-14.326 32-32 32Zm0-61C15.984 3 3 15.984 3 32s12.984 29 29 29c16.008-.019 28.981-12.992 29-29C61 15.984 48.016 3 32 3Zm14.065 43.125a1.504 1.504 0 0 1-2.125 0l-11.94-12L20.065 46.12A1.502 1.502 0 0 1 17.941 44L29.88 32 17.941 20a1.503 1.503 0 0 1 2.124-2.125l11.935 12 11.94-11.98a1.502 1.502 0 0 1 2.12 0A1.5 1.5 0 0 1 46.065 20L34.12 32l11.945 12a1.504 1.504 0 0 1 0 2.125Z"/></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-center mb-3">
|
|
||||||
File Not Found
|
|
||||||
</h3>
|
|
||||||
<div>
|
|
||||||
<p class="text-center">The file you were looking for could not be found, sorry for any inconvenience.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-muted">
|
|
||||||
<p class="text-center"><b>Possible causes of this error could be:</b></p>
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-auto">
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
The file expired
|
|
||||||
<li>
|
|
||||||
The file was deleted by its owner
|
|
||||||
<li>
|
|
||||||
The file was deleted by administration because it didn't comply with our Terms of Use
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<footer class="footer">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-4 col-xs-12">
|
|
||||||
<div class="mb-3 text-center">
|
|
||||||
<a href="#" class="logo">
|
|
||||||
|
|
||||||
<img src="/static/images/logo.svg" alt="">
|
|
||||||
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-8 col-xs-12" style="position: relative">
|
|
||||||
<div class="mb-4 text-center">
|
|
||||||
<p class="text-muted ">
|
|
||||||
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
<style>
|
|
||||||
@media (min-width:481px) {
|
|
||||||
.text-muted {}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width:961px) {
|
|
||||||
.text-muted {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 15px
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"destination_url":"https://RNzT2t4XVKU08.acek-cdn.com/hls2/01/05594/tyn45apubte2_n/master.m3u8?t=8y6hK40boUynXnkyW5s63kFaWpt6_A5UcG5LBUX3j_4&s=1756131487&e=129600&f=27974684&srv=F6vE2P5Wjncn&i=0.4&sp=500&p1=F6vE2P5Wjncn&p2=F6vE2P5Wjncn&asn=31898","request_headers":{"user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36","referer":"https://filelions.to/v/tyn45apubte2"},"mediaflow_proxy_url":"https://mediaflow.test.org/proxy/hls/manifest.m3u8","query_params":{"api_password":"test"}}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"destination_url":"https://6dkDxt1udLQhbho.ovaltinecdn.com/hls2/01/01552/tah5znapz3e5_n/master.m3u8?t=MnSHt6lZx3bdKvTdoU1JsTLWp56dlRDLwze5Prnefew&s=1756131487&e=129600&f=7760835&srv=GOweneve6B0I&i=0.4&sp=500&p1=GOweneve6B0I&p2=GOweneve6B0I&asn=31898","request_headers":{"user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36","referer":"https://kinoger.com"},"mediaflow_proxy_url":"https://mediaflow.test.org/proxy/hls/manifest.m3u8","query_params":{"api_password":"test"}}
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>
|
|
||||||
|
|
||||||
Watch Versailles S01E01 German AC3 1080p BDRip x265 FuN
|
|
||||||
|
|
||||||
</title>
|
|
||||||
<META NAME="description" CONTENT="">
|
|
||||||
<META NAME="keywords" CONTENT="">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
|
||||||
<meta name="theme-color" content="#FFF">
|
|
||||||
|
|
||||||
<link rel="apple-touch-icon" href="/static/images/favicon/apple-touch-icon.png" sizes="180x180">
|
|
||||||
<link rel="icon" href="/static/images/favicon/favicon-32x32.png" sizes="32x32">
|
|
||||||
<link rel="icon" href="/static/images/favicon/favicon-16x16.png" sizes="16x16">
|
|
||||||
<link rel="manifest" href="/static/images/favicon/manifest.json">
|
|
||||||
<link rel="icon" href="/static/images/favicon/favicon.ico">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="crossorigin">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;700&display=swap" rel="stylesheet">
|
|
||||||
<link href="/static/css/style.css?" rel="stylesheet">
|
|
||||||
|
|
||||||
<script src="/static/js/jquery-3.2.1.min.js?"></script>
|
|
||||||
<script src="/static/js/bootstrap.bundle.min.js?"></script>
|
|
||||||
<script src="/static/js/app.js?"></script>
|
|
||||||
<script src="/static/js/xupload.js?"></script>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Google tag (gtag.js) -->
|
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-48ZJD1VPGZ"></script>
|
|
||||||
<script>
|
|
||||||
window.dataLayer = window.dataLayer || [];
|
|
||||||
function gtag(){dataLayer.push(arguments);}
|
|
||||||
gtag('js', new Date());
|
|
||||||
|
|
||||||
gtag('config', 'G-48ZJD1VPGZ');
|
|
||||||
</script>
|
|
||||||
<!-- Yandex.Metrika counter -->
|
|
||||||
<script type="text/javascript" >
|
|
||||||
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
|
||||||
m[i].l=1*new Date();
|
|
||||||
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
|
||||||
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
|
|
||||||
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
|
|
||||||
|
|
||||||
ym(93381808, "init", {
|
|
||||||
clickmap:true,
|
|
||||||
trackLinks:true,
|
|
||||||
accurateTrackBounce:true,
|
|
||||||
webvisor:true
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<noscript><div><img src="https://mc.yandex.ru/watch/93381808" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
|
||||||
<!-- /Yandex.Metrika counter -->
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<header class="header">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row align-items-center flex-nowrap">
|
|
||||||
<div class="col-auto flex-grow-1 flex-shrink-1">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
|
|
||||||
<div class="col-auto" style="margin: 0 auto;">
|
|
||||||
<a href="/" class="logo">
|
|
||||||
|
|
||||||
<img src="/static/images/logo.svg" alt="">
|
|
||||||
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section class="section">
|
|
||||||
<div class="container">
|
|
||||||
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
This video can be watched as embed only
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<footer class="footer">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-4 col-xs-12">
|
|
||||||
<div class="mb-3 text-center">
|
|
||||||
<a href="#" class="logo">
|
|
||||||
|
|
||||||
<img src="/static/images/logo.svg" alt="">
|
|
||||||
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-8 col-xs-12" style="position: relative">
|
|
||||||
<div class="mb-4 text-center">
|
|
||||||
<p class="text-muted ">
|
|
||||||
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
<style>
|
|
||||||
@media (min-width:481px) {
|
|
||||||
.text-muted {}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width:961px) {
|
|
||||||
.text-muted {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 15px
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,57 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
// Get all script tags in the <head>
|
|
||||||
const scripts = document.head.querySelectorAll('script');
|
|
||||||
|
|
||||||
// Iterate through the scripts to find the one injecting the Proxy
|
|
||||||
scripts.forEach((script) => {
|
|
||||||
if (script.innerHTML.includes('window.open=new Proxy(window.open')) {
|
|
||||||
console.log('Detected and removing injected script:', script);
|
|
||||||
script.remove(); // Remove the script from the <head>
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore the original window.open functionality (optional)
|
|
||||||
const originalOpen = Function.prototype.apply.bind(window.open);
|
|
||||||
window.open = function (...args) {
|
|
||||||
console.log('Restored window.open called:', args);
|
|
||||||
return originalOpen(this, args);
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('Injected script removed and window.open restored.');
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<title>Not Found</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="/assets/css/404.css?v=2">
|
|
||||||
<link rel="apple-touch-icon" href="https://filemoon.to/assets/images/favicon/apple-touch-icon.png" sizes="180x180">
|
|
||||||
<link rel="icon" href="https://filemoon.to/assets/images/favicon/favicon-32x32.png" sizes="32x32">
|
|
||||||
<link rel="icon" href="https://filemoon.to/assets/images/favicon/favicon-16x16.png" sizes="16x16">
|
|
||||||
<link rel="icon" href="https://filemoon.to/assets/images/favicon/favicon.ico">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
</body>
|
|
||||||
<script src="/ad.js" type="text/javascript"></script>
|
|
||||||
<div id="adb" class="ad doubleclick skyscraper" style="height:1px;width:1px;position:absolute;left:-999px;top:-999px;">ad</div>
|
|
||||||
<script>
|
|
||||||
window.fmn7i8zodwjqr9 = { token: "bed5d8dfa2e30a9a04", adblock: false, blockadblock: true, fileid: "n7i8zodwjqr9", ampallow: false };
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="error e404">
|
|
||||||
<div class="error-footer">
|
|
||||||
<h1>Page not found</h1>
|
|
||||||
<a href="/" class="button">Return home</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Watch tt0307453</title>
|
|
||||||
<meta name="description" content="">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
|
||||||
<meta name="theme-color" content="#FFF">
|
|
||||||
|
|
||||||
<link rel="apple-touch-icon" href="//filemoon.to/assets/images/favicon/apple-touch-icon.png" sizes="180x180">
|
|
||||||
<link rel="icon" href="//filemoon.to/assets/images/favicon/favicon-32x32.png" sizes="32x32">
|
|
||||||
<link rel="icon" href="//filemoon.to/assets/images/favicon/favicon-16x16.png" sizes="16x16">
|
|
||||||
<link rel="icon" href="//filemoon.to/assets/images/favicon/favicon.ico">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="//filemoon.to/assets/css/front.css?v=51">
|
|
||||||
<META NAME="description" CONTENT="Watch video tt0307453">
|
|
||||||
<META NAME="keywords" CONTENT="tt0307453">
|
|
||||||
<meta name="robots" content="noindex, nofollow">
|
|
||||||
|
|
||||||
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="is-home d-flex flex-column justify-content-between ">
|
|
||||||
<header class="header py-3 py-lg-4">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row align-items-center justify-content-between justify-content-lg-start">
|
|
||||||
<div class="col-auto d-lg-none">
|
|
||||||
<button class="btn text-muted ssm-toggle-nav px-1" type="button">
|
|
||||||
<i class="icon icon-menu icon-size-22 text-muted"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<a href="/" class="logo d-block"><img src="//filemoon.to/assets/images/logo.svg" alt=""></a>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto d-none d-lg-block flex-lg-grow-1">
|
|
||||||
<nav class="nav ml-lg-3">
|
|
||||||
<a class="nav-link" href="/premium">Premium</a>
|
|
||||||
<a class="nav-link" href="/affiliate">Make Money</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<div class="d-none d-lg-flex">
|
|
||||||
|
|
||||||
<a href="/login" class="btn btn-dark sign-btn rounded-pill mr-2">Log In</a>
|
|
||||||
<a href="/register" class="btn btn-success sign-btn rounded-pill">Sign Up</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="d-lg-none">
|
|
||||||
<button class="btn text-muted usrmobile-toggle-nav px-1" type="button">
|
|
||||||
<i class="icon icon-user icon-size-18 text-muted"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script src="https://filemoon.to/js/jquery.cookie.js"></script>
|
|
||||||
<script>
|
|
||||||
$.cookie('file_id', '48721931', { expires: 10 });
|
|
||||||
$.cookie('aff', '89667', { expires: 10 });
|
|
||||||
$.cookie('ref_url', 'https://filemoon.to/d/ugj7f1kq94p1', { expires: 10 });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script type="text/javascript" src='/js/ls.js'></script>
|
|
||||||
<main class="flex-grow-1 a-bean">
|
|
||||||
<div class="container mt-5">
|
|
||||||
<div class="mb-4 mb-lg-5 sm-bg px-4 py-5 p-lg-0 mx-lg-5 rounded-lg">
|
|
||||||
<div class="row justify-content-between align-items-center text-muted mb-4 mb-lg-5">
|
|
||||||
<div class="col-auto mb-2">
|
|
||||||
<h3 class="text-light mb-0 font-weight-bold">tt0307453</h3>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto mb-2"> Uploaded: <span class="text-success">2025-08-24 00:12:36</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-4 mb-lg-5 ">
|
|
||||||
<div class="position-relative">
|
|
||||||
|
|
||||||
<div class="bimg-top text-center">
|
|
||||||
<div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 640px;" id="iframe-holder">
|
|
||||||
|
|
||||||
<iframe src="https://ico3c.com/bkg/ugj7f1kq94p1" frameborder="0" marginwidth="0" marginheight="0" scrolling="no" width="100%" height="100%" allowfullscreen></iframe>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bimg-bottom text-center">
|
|
||||||
<div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="mb-4 mb-lg-5 text-center">
|
|
||||||
<a href="/download/ugj7f1kq94p1" class="btn btn-success px-5">Download</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
#vplayer {padding-top: 56.25%;}
|
|
||||||
.bimg-top,
|
|
||||||
.bimg-bottom {
|
|
||||||
display: -webkit-flex;
|
|
||||||
display: -ms-flex;
|
|
||||||
display: flex;
|
|
||||||
-ms-align-items: center;
|
|
||||||
align-items: center;
|
|
||||||
-webkit-flex-wrap: wrap;
|
|
||||||
-ms-flex-wrap: wrap;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.bimg-top div,
|
|
||||||
.bimg-bottom div {
|
|
||||||
margin: .5rem;
|
|
||||||
}
|
|
||||||
@media(min-width:1600px){
|
|
||||||
.bimg-top,
|
|
||||||
.bimg-bottom {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.bimg-top {
|
|
||||||
left: -310px;
|
|
||||||
}
|
|
||||||
.bimg-bottom {
|
|
||||||
right: -310px;
|
|
||||||
}
|
|
||||||
.bimg-top div,
|
|
||||||
.bimg-bottom div {
|
|
||||||
margin: .5rem 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media(min-width:1800px){
|
|
||||||
.bimg-top {
|
|
||||||
left: -350px;
|
|
||||||
}
|
|
||||||
.bimg-bottom {
|
|
||||||
right: -350px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<footer class="footer py-4 py-lg-5">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row align-items-center justify-content-between">
|
|
||||||
<div class="col-auto my-1">
|
|
||||||
<div class="text-muted"> FileMoon © 2022 </div>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto my-1">
|
|
||||||
<nav class="nav">
|
|
||||||
<a class="nav-link text-muted footer-link px-0 mr-4" href="/">Home</a>
|
|
||||||
<a class="nav-link text-muted footer-link px-0 mr-4" href="/tos">Terms of Service</a>
|
|
||||||
<a class="nav-link text-muted footer-link px-0 mr-4" href="/privacy">Privacy Policy</a>
|
|
||||||
<a class="nav-link text-muted footer-link px-0 mr-4" href="/copyright">Copyright Policy</a>
|
|
||||||
<a class="nav-link text-muted footer-link px-0 mr-4" href="/affiliate">Make Money</a>
|
|
||||||
<a class="nav-link text-muted footer-link px-0 mr-4" href="/api">API Documentation</a>
|
|
||||||
<a class="nav-link text-muted footer-link px-0 mr-4" href="https://status.filemoon.sx">Server Status</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
<div class="ssm">
|
|
||||||
<nav class="nav ssm-nav flex-column">
|
|
||||||
<a class="nav-link ssm-nav-link active" href="/"><i class="icon icon-home icon-size-16 ssm-nav-icon mr-3"></i>Home</a>
|
|
||||||
<a class="nav-link ssm-nav-link" href="/dashboard"><i class="icon icon-account icon-size-16 ssm-nav-icon mr-3"></i>My account</a>
|
|
||||||
<a class="nav-link ssm-nav-link" href="/premium"><i class="icon icon-money icon-size-16 ssm-nav-icon mr-3"></i>Premium</a>
|
|
||||||
<a class="nav-link ssm-nav-link" href="/affiliate"><i class="icon icon-money icon-size-16 ssm-nav-icon mr-3"></i>Make Money</a>
|
|
||||||
</nav>
|
|
||||||
<hr class="mx-3">
|
|
||||||
<nav class="nav flex-column ssm-nav">
|
|
||||||
<a class="nav-link ssm-nav-link" href="/tos"><i class="icon icon-pen icon-size-16 ssm-nav-icon mr-3"></i>Terms of Service</a>
|
|
||||||
<a class="nav-link ssm-nav-link" href="/privacy"><i class="icon icon-key icon-size-16 ssm-nav-icon mr-3"></i>Privacy Policy</a>
|
|
||||||
<a class="nav-link ssm-nav-link" href="/api"><i class="icon icon-note icon-size-16 ssm-nav-icon mr-3"></i>API Documentation</a>
|
|
||||||
<a class="nav-link ssm-nav-link" href="https://status.filemoon.sx"><i class="icon icon-note icon-size-16 ssm-nav-icon mr-3"></i>Server Status</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="ssm-overlay ssm-toggle-nav"></div>
|
|
||||||
<div class="usrmobile p-5 usrmobile-toggle-nav">
|
|
||||||
|
|
||||||
<a href="/login" class="btn btn-success btn-success-custom btn-lg btn-block text-uppercase shadow">Sign in</a>
|
|
||||||
<a href="/register" class="btn btn-outline-success btn-lg btn-block mb-2 text-uppercase">Sign up</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<script src="//filemoon.to/assets/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="//filemoon.to/assets/js/jquery.slideandswipe.min.js"></script>
|
|
||||||
<script src="//filemoon.to/assets/js/jquery.touchSwipe.min.js"></script>
|
|
||||||
<script src="//filemoon.to/assets/js/front.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
||||||
{"destination_url":"https://be7713.rcr82.waw05.i8yz83pn.com/hls2/05/09711/aeuhexjcoysl_x/master.m3u8?t=fiqqJThcgnrc77etu-nLPLodbF1UeqY5Cou0vvnLIB0&s=1765556185&e=10800&f=48721931&srv=1050&asn=13335&sp=5500&p=","request_headers":{"user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36","referer":"https://ico3c.com/bkg/ugj7f1kq94p1"},"mediaflow_proxy_url":"https://mediaflow.test.org/proxy/hls/manifest.m3u8","query_params":{"api_password":"test"}}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"destination_url":"https://be7713.rcr82.waw05.i8yz83pn.com/hls2/01/09665/wkhcbggdxf1d_h/master.m3u8?t=359izbQ_L-sOHaIjj275YKdBDyiPzUi3ETfbNoN9bzU&s=1759754698&e=10800&f=48329866&srv=1075&asn=13335&sp=4000&p=","request_headers":{"user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36","referer":"https://z1ekv717.fun/d/wkhcbggdxf1d"},"mediaflow_proxy_url":"https://mediaflow.test.org/proxy/hls/manifest.m3u8","query_params":{"api_password":"test"}}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue