Compare commits

..

No commits in common. "main" and "v0.49.6" have entirely different histories.

729 changed files with 43113 additions and 101923 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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@v5
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

View file

@ -1,3 +1,3 @@
{ {
".": "0.69.1" ".": "0.49.6"
} }

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
FROM node:24 AS builder FROM node:22.19-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.19-alpine
WORKDIR /app WORKDIR /app
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist

View file

@ -1,47 +1,15 @@
# WebStreamr # WebStreamr
[![Tests](https://github.com/webstreamr/webstreamr/actions/workflows/tests.yml/badge.svg)](https://github.com/webstreamr/webstreamr/actions/workflows/tests.yml) [![Tests](https://github.com/webstreamr/webstreamr/workflows/Tests/badge.svg)](https://github.com/webstreamr/webstreamr/actions/workflows/tests.yml)
[![GitHub release](https://img.shields.io/github/v/release/webstreamr/webstreamr)](https://github.com/webstreamr/webstreamr/releases) [![GitHub release](https://img.shields.io/github/v/release/webstreamr/webstreamr)](https://github.com/webstreamr/webstreamr/releases)
![GitHub License](https://img.shields.io/github/license/webstreamr/webstreamr) ![GitHub License](https://img.shields.io/github/license/webstreamr/webstreamr)
[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).

View file

@ -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: {

View file

@ -1,10 +1,7 @@
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['CACHE_DIR'] = '/dev/null';
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'),
@ -15,3 +12,20 @@ beforeEach(() => {
}); });
console.log = console.warn = console.error = console.info = console.debug = () => { /* disable in favor of logger */ }; 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);

4168
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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.49.6",
"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", "@keyv/sqlite": "^4.0.5",
"@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", "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",
"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", "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",
"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",

View file

@ -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)) {

View file

@ -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);
}
};
}

View file

@ -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);

View file

@ -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);
@ -56,7 +54,7 @@ export class StreamController {
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=${Math.floor(ttl / 1000)}, public`);
} }
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');

View file

@ -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';

View file

@ -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;
} }

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
} }
} }

View file

@ -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;
}
}

View file

@ -17,54 +17,54 @@ 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 (
logger.warn(`${source}: Request to ${error.url} timed out.`, ctx); error instanceof TimeoutError
|| (error instanceof DOMException && error.name === 'TimeoutError') // sometimes this gets through, no idea why..
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;

View file

@ -7,18 +7,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'))).toMatchSnapshot();
}); });
test('doodster', async () => {
expect(await extractorRegistry.handle(ctx, new URL('https://dooodster.com/e/1cfcevn6dg8shrfvht22odxw2lty18hr'))).toMatchSnapshot();
});
test('missing pass_md5 -> not found', async () => {
expect(await extractorRegistry.handle(ctx, new URL('https://dood.to/e/gy8l8mb2i311'))).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'))).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'))).toMatchSnapshot();
}); });
}); });

View file

@ -1,8 +1,8 @@
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, Format, Meta, UrlResult } from '../types';
import { buildMediaFlowProxyExtractorRedirectUrl, supportsMediaFlowProxy } from '../utils'; import { guessSizeFromMp4 } from '../utils/size';
import { Extractor } from './Extractor'; import { Extractor } from './Extractor';
export class DoodStream extends Extractor { export class DoodStream extends Extractor {
@ -12,42 +12,55 @@ export class DoodStream extends Extractor {
public override readonly ttl: number = 21600000; // 6h public override readonly ttl: number = 21600000; // 6h
public override viaMediaFlowProxy = true;
/** @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 null !== url.host.match(/dood|do[0-9]go|doood|dooood|ds2play|ds2video|d0o0d|do0od|d0000d|d000d|vidply|all3do|doply|vide0|vvide0|d-s/);
}; };
public override normalize(url: URL): URL { public override normalize(url: URL): URL {
const videoId = url.pathname.replace(/\/+$/, '').split('/').at(-1) as string; const videoId = url.pathname.split('/').slice(-1)[0] as string;
return new URL(`http://dood.to/e/${videoId}`); return new URL(`http://dood.to/e/${videoId}`);
}; };
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> { protected async extractInternal(ctx: Context, url: URL, meta: Meta): 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 downloadHtml = await this.fetcher.text(ctx, new URL(url.href.replace('/e/', '/d/'))); let mp4Url: URL;
const sizeMatch = downloadHtml.match(/([\d.]+ ?[GM]B)/); let bytes: number | undefined;
if (baseUrl.includes('cloudflarestorage')) {
mp4Url = new URL(baseUrl);
} else {
mp4Url = new URL(`${baseUrl}${randomstring.generate(10)}?token=${token}&expiry=${Date.now()}`);
bytes = await guessSizeFromMp4(ctx, this.fetcher, mp4Url, { headers: { Referer: url.origin } });
}
return [ return [
{ {
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Doodstream', url, headers), url: mp4Url,
format: Format.mp4, format: Format.mp4,
label: this.label,
sourceId: `${this.id}_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta: { meta: {
...meta, ...meta,
title, title,
...(sizeMatch && { bytes: bytes.parse(sizeMatch[1] as string) as number }), ...(bytes && { bytes }),
},
requestHeaders: {
Referer: url.origin,
}, },
}, },
]; ];

View file

@ -14,10 +14,6 @@ describe('Dropload', () => {
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'))).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'))).toMatchSnapshot();
}); });

View file

@ -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, Format, Meta, UrlResult } from '../types';
import { extractUrlFromPacked, guessHeightFromPlaylist } from '../utils'; import { extractUrlFromPacked } from '../utils';
import { Extractor } from './Extractor'; import { Extractor } from './Extractor';
export class Dropload extends Extractor { export class Dropload extends Extractor {
@ -13,45 +13,38 @@ export class Dropload extends Extractor {
public override readonly ttl: number = 10800000; // 3h public override readonly ttl: number = 10800000; // 3h
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, meta: Meta): 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}_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta: { meta: {
...meta, ...meta,
bytes: bytes.parse(sizeMatch[1] as string) as number,
height: parseInt(heightMatch[1] as string),
title, title,
...(size && { bytes: size }),
...(height && { height }),
}, },
requestHeaders: playlistHeaders,
}, },
]; ];
}; };

View file

@ -1,4 +1,5 @@
import { Context, Format, InternalUrlResult, Meta } from '../types'; import { BlockedError } from '../error';
import { Context, Format, Meta, UrlResult } from '../types';
import { showExternalUrls } from '../utils'; import { showExternalUrls } from '../utils';
import { Extractor } from './Extractor'; import { Extractor } from './Extractor';
@ -13,13 +14,25 @@ export class ExternalUrl extends Extractor {
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, meta: Meta): 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}`,
sourceId: `${this.id}_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta, meta,
}, },
]; ];

View file

@ -1,5 +1,5 @@
import { NotFoundError } from '../error'; import { NotFoundError } from '../error';
import { Context, Format, InternalUrlResult, Meta, UrlResult } from '../types'; import { Context, Format, Meta, UrlResult } from '../types';
import { Fetcher } from '../utils'; import { Fetcher } from '../utils';
export abstract class Extractor { export abstract class Extractor {
@ -9,8 +9,6 @@ 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; protected readonly fetcher: Fetcher;
@ -25,18 +23,11 @@ export abstract class Extractor {
return url; return url;
}; };
protected abstract extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]>; protected abstract extractInternal(ctx: Context, url: URL, meta: Meta): Promise<UrlResult[]>;
public async extract(ctx: Context, url: URL, meta: Meta): Promise<UrlResult[]> { public async extract(ctx: Context, url: URL, meta: Meta): Promise<UrlResult[]> {
try { try {
return (await this.extractInternal(ctx, url, meta)).map( return await this.extractInternal(ctx, url, meta);
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 +39,11 @@ 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 && meta),
}, },
]; ];
} }
}; };
private formatLabel(label: string): string {
return this.viaMediaFlowProxy ? `${label} (MFP)` : label;
}
} }

View file

@ -49,11 +49,6 @@ describe('ExtractorRegistry', () => {
expect(urlResults).toMatchSnapshot(); expect(urlResults).toMatchSnapshot();
}); });
test('empty results are cached', async () => {
const urlResults = await extractorRegistry.handle(ctx, new URL('https://dropload.io/asdfghijklmn.html'), { title: 'title' });
expect(urlResults).toMatchSnapshot();
});
test('stats returns something', async () => { test('stats returns something', async () => {
const stats = extractorRegistry.stats(); const stats = extractorRegistry.stats();

View file

@ -1,31 +1,23 @@
// eslint-disable-next-line import/no-named-as-default
import KeyvSqlite from '@keyv/sqlite';
import { Cacheable, CacheableMemory, Keyv } from 'cacheable'; import { Cacheable, CacheableMemory, Keyv } from 'cacheable';
import winston from 'winston'; import winston from 'winston';
import { Context, Format, Meta, UrlResult } from '../types'; import { Context, Meta, UrlResult } from '../types';
import { createKeyvSqlite, envGet, isExtractorDisabled } from '../utils'; import { getCacheDir, 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: Cacheable; 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 Cacheable({ this.urlResultCache = new Cacheable({
nonBlocking: true,
primary: new Keyv({ store: new CacheableMemory({ lruSize: 1024 }) }), primary: new Keyv({ store: new CacheableMemory({ lruSize: 1024 }) }),
secondary: createKeyvSqlite('extractor-cache'), secondary: new Keyv(new KeyvSqlite(`sqlite://${getCacheDir()}/webstreamr-extractor-cache.sqlite`)),
stats: true,
});
this.lazyUrlResultCache = new Cacheable({
nonBlocking: true,
primary: new Keyv({ store: new CacheableMemory({ lruSize: 1024 }) }),
secondary: createKeyvSqlite('extractor-lazy-cache'),
stats: true, stats: true,
}); });
} }
@ -33,80 +25,45 @@ export class ExtractorRegistry {
public stats() { public stats() {
return { return {
urlResultCache: this.urlResultCache.stats, urlResultCache: this.urlResultCache.stats,
lazyUrlResultCache: this.lazyUrlResultCache.stats,
}; };
}; };
public async handle(ctx: Context, url: URL, meta?: Meta, allowLazy?: boolean): Promise<UrlResult[]> { public async handle(ctx: Context, url: URL, meta?: Meta): 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.viaMediaFlowProxy
? `${extractor.id}_${normalizedUrl}_${ctx.config.mediaFlowProxyUrl}`
: `${extractor.id}_${normalizedUrl}`;
const storedDataRaw = await this.urlResultCache.getRaw<UrlResult[]>(cacheKey); const storedDataRaw = await this.urlResultCache.getRaw<UrlResult[]>(cacheKey);
const expires = storedDataRaw?.expires; const expires = storedDataRaw?.expires;
if (storedDataRaw && expires) { if (storedDataRaw && expires) {
const ttl = expires - Date.now(); // Ignore the cache randomly after at least 2/3 of the TTL passed to start refreshing results slowly
const refreshTimestamp = this.randomInteger(expires - extractor.ttl * (2 / 3), expires);
const now = Date.now();
/* istanbul ignore if */ /* istanbul ignore if */
if (ttl > 0) { if (refreshTimestamp > now) {
return (storedDataRaw.value as UrlResult[]).map(urlResult => ({ ...urlResult, ttl, url: new URL(urlResult.url) })); return (storedDataRaw.value as UrlResult[]).map(urlResult => ({ ...urlResult, ttl: expires - now, 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 */ const urlResults = await extractor.extract(ctx, normalizedUrl, { ...meta, countryCodes: meta?.countryCodes ?? [] });
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); await this.urlResultCache.set<UrlResult[]>(cacheKey, urlResults, 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 { private randomInteger(min: number, max: number) {
let suffix = ''; return Math.floor(Math.random() * (max - min + 1)) + min;
if (extractor.viaMediaFlowProxy) {
suffix += `_${ctx.config.mediaFlowProxyUrl}`;
}
if (extractor.cacheVersion) {
suffix += `_${extractor.cacheVersion}`;
}
return `${extractor.id}_${url}${suffix}`;
} }
} }

View file

@ -13,8 +13,4 @@ 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'))).toMatchSnapshot();
}); });
test('expired or deleted', async () => {
expect(await extractorRegistry.handle(ctx, new URL('https://fastream.to/embed-a9rp1d1pqom7.html'))).toMatchSnapshot();
});
}); });

View file

@ -1,13 +1,12 @@
import bytes from 'bytes'; import bytes from 'bytes';
import { NotFoundError } from '../error'; import { Context, Format, Meta, UrlResult } from '../types';
import { Context, Format, InternalUrlResult, Meta } from '../types';
import { buildMediaFlowProxyExtractorStreamUrl, 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 viaMediaFlowProxy = true; public override viaMediaFlowProxy = true;
@ -19,17 +18,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, meta: Meta): 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,6 +31,9 @@ export class Fastream extends Extractor {
{ {
url: playlistUrl, url: playlistUrl,
format: Format.hls, format: Format.hls,
label: this.label,
sourceId: `${this.id}_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta: { meta: {
...meta, ...meta,
bytes: bytes.parse(heightAndSizeMatch[2] as string) as number, bytes: bytes.parse(heightAndSizeMatch[2] as string) as number,

View file

@ -17,12 +17,4 @@ describe('FileLions', () => {
test('mivalyo v referer lock', async () => { test('mivalyo v referer lock', async () => {
expect(await extractorRegistry.handle(ctx, new URL('https://mivalyo.com/v/tah5znapz3e5'), { referer: 'https://kinoger.com' })).toMatchSnapshot(); 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();
});
}); });

View file

@ -1,15 +1,16 @@
import bytes from 'bytes'; import bytes from 'bytes';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { NotFoundError } from '../error'; import { Context, Format, Meta, UrlResult } from '../types';
import { Context, Format, InternalUrlResult, Meta } from '../types'; import { buildMediaFlowProxyExtractorStreamUrl, guessHeightFromPlaylist, supportsMediaFlowProxy } from '../utils';
import { buildMediaFlowProxyExtractorStreamUrl, supportsMediaFlowProxy, unpackEval } from '../utils';
import { Extractor } from './Extractor'; import { Extractor } from './Extractor';
/** @see https://github.com/Gujal00/ResolveURL/commits/master/script.module.resolveurl/lib/resolveurl/plugins/filelions.py */ /** @see https://github.com/Gujal00/ResolveURL/commits/master/script.module.resolveurl/lib/resolveurl/plugins/filelions.py */
export class FileLions extends Extractor { export class FileLions extends Extractor {
public readonly id = 'filelions'; public readonly id = 'filelions';
public readonly label = 'FileLions'; public readonly label = 'FileLions (via MediaFlow Proxy)';
public override readonly ttl = 0;
public override viaMediaFlowProxy = true; public override viaMediaFlowProxy = true;
@ -21,20 +22,16 @@ export class FileLions extends Extractor {
'alhayabambi.sbs', 'alhayabambi.sbs',
'anime7u.com', 'anime7u.com',
'azipcdn.com', 'azipcdn.com',
'bingezove.com',
'callistanise.com',
'coolciima.online', 'coolciima.online',
'dhtpre.com', 'dhtpre.com',
'dingtezuni.com',
'dintezuvio.com',
'e4xb5c2xnz.sbs', 'e4xb5c2xnz.sbs',
'egsyxutd.sbs', 'egsyxutd.sbs',
'fdewsdc.sbs', 'fdewsdc.sbs',
'gsfomqu.sbs', 'gsfomqu.sbs',
'javplaya.com', 'javplaya.com',
'katomen.online', 'katomen.online',
'kinoger.be',
'lumiawatch.top', 'lumiawatch.top',
'minochinos.com',
'mivalyo.com', 'mivalyo.com',
'moflix-stream.click', 'moflix-stream.click',
'motvy55.store', 'motvy55.store',
@ -59,38 +56,35 @@ export class FileLions extends Extractor {
} }
public override normalize(url: URL): URL { public override normalize(url: URL): URL {
return new URL(url.href.replace('/v/', '/f/').replace('/download/', '/f/').replace('/file/', '/f/')); return new URL(url.href.replace('/f/', '/v/').replace('/download/', '/v/').replace('/file/', '/v/'));
} }
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> { protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<UrlResult[]> {
const headers = { Referer: meta.referer ?? url.href }; const headers = { ...(meta.referer && { Referer: meta.referer }) };
const html = await this.fetcher.text(ctx, url, { headers }); const playlistUrl = await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'FileLions', url, headers);
if (html.includes('This video can be watched as embed only')) { const fileUrl = new URL(url.href.replace('/v/', '/f/'));
return await this.extractInternal(ctx, new URL(url.href.replace('/f/', '/v/')), meta); const html = await this.fetcher.text(ctx, fileUrl, { headers });
}
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 sizeMatch = html.match(/([\d.]+ ?[GM]B)/);
const $ = cheerio.load(html); const $ = cheerio.load(html);
const title = $('meta[name="description"]').attr('content') as string; const title = $('title').text().trim().replace(/^Watch /, '').trim();
return [ return [
{ {
url: await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'FileLions', url, headers), url: playlistUrl,
format: Format.hls, format: Format.hls,
label: this.label,
sourceId: `${this.id}_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta: { meta: {
...meta, ...meta,
height: parseInt(heightMatch[1] as string), height: await guessHeightFromPlaylist(ctx, this.fetcher, playlistUrl),
...(sizeMatch && { bytes: bytes.parse(sizeMatch[1] as string) as number }), ...(sizeMatch && {
bytes: bytes.parse(sizeMatch[1] as string) as number,
}),
...(title && { title }), ...(title && { title }),
}, },
}, },

View file

@ -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();
});
});

View file

@ -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) }),
},
},
];
};
}

View file

@ -10,7 +10,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/'))).toMatchSnapshot();
});
test('Dead City', async () => {
expect(await extractorRegistry.handle(ctx, new URL('https://fsst.online/embed/901994/'))).toMatchSnapshot();
});
test('How to Train Your Dragon', async () => {
expect(await extractorRegistry.handle(ctx, new URL('https://fsst.online/embed/902668/'))).toMatchSnapshot();
}); });
}); });

View file

@ -1,5 +1,5 @@
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { Context, Format, InternalUrlResult, Meta } from '../types'; import { Context, Format, Meta, UrlResult } from '../types';
import { Extractor } from './Extractor'; import { Extractor } from './Extractor';
export class Fsst extends Extractor { export class Fsst extends Extractor {
@ -7,35 +7,36 @@ export class Fsst extends Extractor {
public readonly label = 'Fsst'; public readonly label = 'Fsst';
public override readonly ttl: number = 10800000; // 3h
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, meta: Meta): Promise<UrlResult[]> {
const headers = { Referer: meta.referer ?? url.href }; const headers = { Referer: meta.referer ?? url.origin };
const html = await this.fetcher.text(ctx, url, { headers, noProxyHeaders: true }); const html = await this.fetcher.text(ctx, url, { headers });
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) => {
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}_${meta.countryCodes?.join('_')}`,
format: Format.mp4, meta: {
meta: { ...meta,
...meta, height: parseInt(heightAndUrlMatch[1] || heightFromFileHrefMatch[1] as string),
height: parseInt(heightAndUrlMatch[1] as string), title,
title, },
}, };
}]; });
}; };
} }

View file

@ -21,8 +21,4 @@ describe('HubCloud', () => {
test('handle dark 2017 s03e08', async () => { test('handle dark 2017 s03e08', async () => {
expect(await extractorRegistry.handle(ctx, new URL('https://hubcloud.one/drive/nknlofk8snfnknh'))).toMatchSnapshot(); 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();
});
}); });

View file

@ -1,7 +1,6 @@
import bytes from 'bytes'; import bytes from 'bytes';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { Context, Format, InternalUrlResult, Meta } from '../types'; import { Context, Format, Meta, UrlResult } from '../types';
import { findCountryCodes, findHeight } from '../utils';
import { Extractor } from './Extractor'; import { Extractor } from './Extractor';
export class HubCloud extends Extractor { export class HubCloud extends Extractor {
@ -9,33 +8,25 @@ export class HubCloud extends Extractor {
public readonly label = 'HubCloud'; public readonly label = 'HubCloud';
public override readonly ttl: number = 43200000; // 12h public override readonly ttl: number = 259200000; // 3d
public override readonly cacheVersion = 1;
public supports(_ctx: Context, url: URL): boolean { public supports(_ctx: Context, url: URL): boolean {
return null !== url.host.match(/hubcloud/); return null !== url.host.match(/hubcloud/);
} }
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> { protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<UrlResult[]> {
const headers = { Referer: meta.referer ?? url.href }; const redirectHtml = await this.fetcher.text(ctx, url);
const redirectHtml = await this.fetcher.text(ctx, url, { headers });
const redirectUrlMatch = redirectHtml.match(/var url ?= ?'(.*?)'/) as string[]; 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 linksHtml = await this.fetcher.text(ctx, new URL(redirectUrlMatch[1] as string));
const $ = cheerio.load(linksHtml); const $ = cheerio.load(linksHtml);
const title = $('title').text().trim(); return [
const countryCodes = [...new Set([...meta.countryCodes ?? [], ...findCountryCodes(title)])];
const height = meta.height ?? findHeight(title);
return Promise.all([
...$('a') ...$('a')
.filter((_i, el) => { .filter((_i, el) => {
const text = $(el).text(); const text = $(el).text();
return text.includes('FSL') && !text.includes('FSLv2'); return text.includes('FSL') || text.includes('Download File');
}) })
.map((_i, el) => { .map((_i, el) => {
const url = new URL($(el).attr('href') as string); const url = new URL($(el).attr('href') as string);
@ -43,60 +34,32 @@ export class HubCloud extends Extractor {
url, url,
format: Format.unknown, format: Format.unknown,
label: `${this.label} (FSL)`, label: `${this.label} (FSL)`,
sourceId: `${this.id}_fsl`,
ttl: this.ttl,
meta: { meta: {
...meta, ...meta,
bytes: bytes.parse($('#size').text()) as number, bytes: bytes.parse($('#size').text()) as number,
extractorId: `${this.id}_fsl`, title: $('title').text().trim(),
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(), }).toArray(),
...$('a') ...$('a')
.filter((_i, el) => $(el).text().includes('PixelServer')) .filter((_i, el) => $(el).text().includes('PixelServer'))
.map((_i, el) => { .map((_i, el) => {
const userUrl = new URL(($(el).attr('href') as string).replace('/api/file/', '/u/')); const url = new URL(($(el).attr('href') as string).replace('/u/', '/api/file/'));
const url = new URL(userUrl.href.replace('/u/', '/api/file/'));
url.searchParams.set('download', '');
return { return {
url, url,
format: Format.unknown, format: Format.unknown,
label: `${this.label} (PixelServer)`, label: `${this.label} (PixelServer)`,
sourceId: `${this.id}_pixelserver`,
ttl: this.ttl,
meta: { meta: {
...meta, ...meta,
bytes: bytes.parse($('#size').text()) as number, bytes: bytes.parse($('#size').text()) as number,
extractorId: `${this.id}_pixelserver`, title: $('title').text().trim(),
countryCodes,
height,
title,
}, },
requestHeaders: { Referer: userUrl.href },
}; };
}).toArray(), }).toArray(),
]); ];
}; };
} }

View file

@ -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();
});
});

View file

@ -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) : [];
};
}

View file

@ -17,12 +17,4 @@ describe('KinoGer', () => {
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'))).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();
});
}); });

View file

@ -1,5 +1,5 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { Context, Format, InternalUrlResult, Meta } from '../types'; import { Context, Format, Meta, UrlResult } from '../types';
import { guessHeightFromPlaylist } from '../utils'; import { guessHeightFromPlaylist } from '../utils';
import { Extractor } from './Extractor'; import { Extractor } from './Extractor';
@ -17,19 +17,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 +35,36 @@ 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, meta: Meta): Promise<UrlResult[]> {
const headers = { const headers = { 'Referer': meta.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' };
'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 hexData = await this.fetcher.text(ctx, url, { headers });
const encrypted = Buffer.from(hexData.slice(0, -1), 'hex'); const encrypted = Buffer.from(hexData, '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}_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta: { meta: {
...meta, ...meta,
height: meta.height ?? await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url, { headers }), height: await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url, { headers }),
title, title,
}, },
requestHeaders: headers, requestHeaders: {
Referer: url.origin,
},
}, },
]; ];
}; };

View file

@ -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();
});
});

View file

@ -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),
},
},
];
};
}

View file

@ -1,24 +1,24 @@
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, Format, Meta, UrlResult } from '../types';
import { buildMediaFlowProxyExtractorRedirectUrl, supportsMediaFlowProxy } from '../utils'; import { buildMediaFlowProxyExtractorRedirectUrl, 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 viaMediaFlowProxy = true; public override viaMediaFlowProxy = true;
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, meta: Meta): 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);
@ -35,6 +35,9 @@ 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}_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta: { meta: {
...meta, ...meta,
bytes: bytes.parse((sizeMatch[1] as string).replace(',', '')) as number, bytes: bytes.parse((sizeMatch[1] as string).replace(',', '')) as number,

View file

@ -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,
},
];
};
}

View file

@ -1,6 +1,6 @@
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, Format, Meta, UrlResult } from '../types';
import { Extractor } from './Extractor'; import { Extractor } from './Extractor';
export class SaveFiles extends Extractor { export class SaveFiles extends Extractor {
@ -18,10 +18,8 @@ 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, meta: Meta): 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|file was deleted/i.test(html)) { if (/file was locked|file was deleted/i.test(html)) {
throw new NotFoundError(); throw new NotFoundError();
@ -37,6 +35,9 @@ 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}_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta: { meta: {
...meta, ...meta,
title, title,

View file

@ -2,23 +2,23 @@ import winston from 'winston';
import { createTestContext } from '../test'; import { createTestContext } from '../test';
import { FetcherMock } from '../utils'; import { FetcherMock } from '../utils';
import { ExtractorRegistry } from './ExtractorRegistry'; import { ExtractorRegistry } from './ExtractorRegistry';
import { RgShows } from './RgShows'; import { Soaper } from './Soaper';
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 RgShows(new FetcherMock(`${__dirname}/__fixtures__/RgShows`))]); const extractorRegistry = new ExtractorRegistry(logger, [new Soaper(new FetcherMock(`${__dirname}/__fixtures__/Soaper`))]);
const ctx = createTestContext(); const ctx = createTestContext();
describe('RgShows', () => { describe('Soaper', () => {
test('handle one battle after another', async () => { test('Full Metal Jacket', async () => {
expect(await extractorRegistry.handle(ctx, new URL('https://api.rgshows.ru/main/movie/1054867'))).toMatchSnapshot(); expect(await extractorRegistry.handle(ctx, new URL('https://soaper.live/movie_d8kdeypDY9.html'))).toMatchSnapshot();
}); });
test('handle stranger things s05e01', async () => { test('Black Mirror', async () => {
expect(await extractorRegistry.handle(ctx, new URL('https://api.rgshows.ru/main/tv/66732/5/1'))).toMatchSnapshot(); expect(await extractorRegistry.handle(ctx, new URL('https://soaper.live/episode_5KDq78eGp1.html'))).toMatchSnapshot();
}); });
test('handle parasite 2019 (mp4)', async () => { test('last of us s2e3', async () => {
expect(await extractorRegistry.handle(ctx, new URL('https://api.rgshows.ru/main/movie/496243'))).toMatchSnapshot(); expect(await extractorRegistry.handle(ctx, new URL('https://soaper.live/episode_rYg3vMEDL1.html'))).toMatchSnapshot();
}); });
}); });

60
src/extractor/Soaper.ts Normal file
View file

@ -0,0 +1,60 @@
import { Context, Format, Meta, UrlResult } from '../types';
import { 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';
public override readonly ttl: number = 43200000; // 12h
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, meta: Meta): 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}_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta: {
...meta,
height: await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url),
},
},
];
};
}

View file

@ -13,8 +13,4 @@ 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'))).toMatchSnapshot();
}); });
test('video is not ready', async () => {
expect(await extractorRegistry.handle(ctx, new URL('https://watch.gxplayer.xyz/watch?v=PBO90WAS'))).toMatchSnapshot();
});
}); });

View file

@ -1,5 +1,4 @@
import { NotFoundError } from '../error'; import { Context, Format, Meta, UrlResult } from '../types';
import { Context, Format, InternalUrlResult, Meta } from '../types';
import { Extractor } from './Extractor'; import { Extractor } from './Extractor';
export class StreamEmbed extends Extractor { export class StreamEmbed extends Extractor {
@ -7,20 +6,12 @@ export class StreamEmbed extends Extractor {
public readonly label = 'StreamEmbed'; public readonly label = 'StreamEmbed';
public override readonly ttl: number = 259200000; // 3d
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, meta: Meta): 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,6 +19,9 @@ 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}_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta: { meta: {
...meta, ...meta,
height: parseInt(JSON.parse(video.quality)[0]), height: parseInt(JSON.parse(video.quality)[0]),

View file

@ -11,6 +11,6 @@ const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow.test.org',
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'))).toMatchSnapshot();
}); });
}); });

View file

@ -1,68 +1,40 @@
import bytes from 'bytes';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { Context, Format, InternalUrlResult, Meta } from '../types'; import { Context, Format, Meta, UrlResult } from '../types';
import { buildMediaFlowProxyExtractorRedirectUrl, supportsMediaFlowProxy } from '../utils'; import {
buildMediaFlowProxyExtractorRedirectUrl,
supportsMediaFlowProxy,
} from '../utils';
import { guessSizeFromMp4 } from '../utils/size';
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 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, meta: Meta): 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;
const mp4Url = buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Streamtape', url);
return [ return [
{ {
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Streamtape', url, headers), url: mp4Url,
format: Format.mp4, format: Format.mp4,
label: this.label,
sourceId: `${this.id}_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta: { meta: {
...meta, ...meta,
title, title,
bytes: bytes.parse(sizeMatch[1] as string) as number, bytes: await guessSizeFromMp4(ctx, this.fetcher, mp4Url),
}, },
}, },
]; ];

View file

@ -1,7 +1,5 @@
import bytes from 'bytes';
import * as cheerio from 'cheerio';
import { NotFoundError } from '../error'; import { NotFoundError } from '../error';
import { Context, Format, InternalUrlResult, Meta } from '../types'; import { Context, Format, Meta, UrlResult } from '../types';
import { extractUrlFromPacked, guessHeightFromPlaylist } from '../utils'; import { extractUrlFromPacked, guessHeightFromPlaylist } from '../utils';
import { Extractor } from './Extractor'; import { Extractor } from './Extractor';
@ -17,45 +15,33 @@ export class SuperVideo extends Extractor {
} }
public override normalize(url: URL): URL { public override normalize(url: URL): URL {
return new URL(url.href.replace('/e/', '/').replace('/k/', '/').replace('/embed-', '/')); const fileUrl = new URL(url.href.replace('/e/', '/').replace('/k/', '/').replace('/embed-', '/').replace('.html', ''));
return new URL(`/e${fileUrl.pathname}`, fileUrl.origin);
} }
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> { protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<UrlResult[]> {
const headers = { Referer: meta.referer ?? url.href }; const headers = { Referer: meta.referer ?? url.origin };
const html = await this.fetcher.text(ctx, url, { headers }); const html = await this.fetcher.text(ctx, url, { headers });
if (html.includes('This video can be watched as embed only')) { if (/'The file was deleted|The file expired|Video is processing/.test(html) || !html.includes('p,a,c,k,e,d')) {
return await this.extractInternal(ctx, new URL(`/e${url.pathname}`, url.origin), meta);
}
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 m3u8Url = extractUrlFromPacked(html, [/sources:\[{file:"(.*?)"/]);
const playlistHeaders = { Referer: 'https://supervideo.cc/' };
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 title = $('.download__title').text().trim();
return [ return [
{ {
url: playlistUrl, url: m3u8Url,
format: Format.hls, format: Format.hls,
label: this.label,
sourceId: `${this.id}_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta: { meta: {
...meta, ...meta,
title, height: await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url, { headers }),
...(size && { bytes: size }),
...(height && { height }),
}, },
requestHeaders: playlistHeaders,
}, },
]; ];
}; };

View file

@ -13,8 +13,4 @@ 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'))).toMatchSnapshot();
}); });
test('file not found', async () => {
expect(await extractorRegistry.handle(ctx, new URL('https://uqload.cx/2ua47fdfkjgd.html'))).toMatchSnapshot();
});
}); });

View file

@ -1,13 +1,13 @@
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { NotFoundError } from '../error'; import { Context, Format, Meta, UrlResult } from '../types';
import { Context, Format, InternalUrlResult, Meta } from '../types';
import { buildMediaFlowProxyExtractorRedirectUrl, supportsMediaFlowProxy } from '../utils'; import { buildMediaFlowProxyExtractorRedirectUrl, supportsMediaFlowProxy } from '../utils';
import { guessSizeFromMp4 } from '../utils/size';
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 viaMediaFlowProxy = true; public override viaMediaFlowProxy = true;
@ -19,25 +19,27 @@ 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, meta: Meta): 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);
const title = $('h1').text().trim(); const title = $('h1').text().trim();
const mp4Url = buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Uqload', url);
return [ return [
{ {
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Uqload', url), url: mp4Url,
format: Format.mp4, format: Format.mp4,
label: this.label,
sourceId: `${this.id}_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta: { meta: {
...meta, ...meta,
title, title,
bytes: await guessSizeFromMp4(ctx, this.fetcher, mp4Url),
...(heightMatch && { ...(heightMatch && {
height: parseInt(heightMatch[1] as string), height: parseInt(heightMatch[1] as string),
}), }),

View file

@ -1,20 +1,57 @@
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 { 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'))).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'))).toMatchSnapshot();
});
test('not found', async () => {
expect(await extractorRegistry.handle(ctx, new URL('https://vidsrc.xyz/embed/movie/tt35628853'))).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'), {})).toMatchSnapshot();
});
test('blocking 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(403);
mockAgent.get('https://vidsrc.net')
.intercept({ path: '/embed/movie/tt33043892/1/1' }).reply(403);
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'), {})).toMatchSnapshot();
}); });
}); });

View file

@ -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 { BlockedError, NotFoundError, TooManyRequestsError } from '../error';
import { Context, Format, Meta, NonEmptyArray, UrlResult } from '../types';
import { Fetcher, guessHeightFromPlaylist } from '../utils'; import { Fetcher, guessHeightFromPlaylist } from '../utils';
import { Extractor } from './Extractor'; import { Extractor } from './Extractor';
@ -11,49 +12,46 @@ export class VidSrc extends Extractor {
public override readonly ttl: number = 10800000; // 3h public override readonly ttl: number = 10800000; // 3h
private readonly domains: NonEmptyArray<string>; private readonly tlds: NonEmptyArray<string>;
public constructor(fetcher: Fetcher, domains: NonEmptyArray<string>) { public constructor(fetcher: Fetcher, tlds: NonEmptyArray<string>) {
super(fetcher); super(fetcher);
this.domains = domains; this.tlds = tlds;
} }
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, meta: Meta): Promise<UrlResult[]> {
// While this is a crappy thing to do, they seem to be blocking overly strict IMO return this.extractUsingRandomTld(ctx, url, meta, [...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, meta: Meta, 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, { headers: { Referer: meta.referer ?? newUrl.origin }, queueLimit: 1 });
} catch (error) { } catch (error) {
/* istanbul ignore next */ if (tlds.length && (error instanceof TooManyRequestsError || error instanceof BlockedError)) {
if (domains.length && (error instanceof TooManyRequestsError || error instanceof BlockedError)) { return this.extractUsingRandomTld(ctx, url, meta, 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 headers = { Referer: iframeUrl.origin };
const title = $('title').text().trim(); const title = $('title').text().trim();
return Promise.all( return Promise.all(
@ -62,21 +60,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 });
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 });
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)}_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta: { meta: {
...meta, ...meta,
height: await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url, { headers: { Referer: iframeUrl.href } }), height: await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url),
title, title,
}, },
}; };

View file

@ -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();
});
});

View file

@ -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,
},
];
};
}

View file

@ -7,7 +7,7 @@ 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 () => {
@ -18,13 +18,9 @@ describe('VixSrc', () => {
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'))).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-proxy2.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'))).toMatchSnapshot();
}); });
}); });

View file

@ -1,34 +1,26 @@
import { Context, CountryCode, Format, InternalUrlResult, Meta } from '../types'; import { Context, CountryCode, Format, Meta, UrlResult } from '../types';
import { CustomRequestConfig, guessHeightFromPlaylist, hasMultiEnabled, iso639FromCountryCode } from '../utils'; import {
buildMediaFlowProxyExtractorStreamUrl, guessHeightFromPlaylist,
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 viaMediaFlowProxy = true;
public supports(_ctx: Context, url: URL): boolean { public supports(ctx: Context, url: URL): boolean {
return null !== url.host.match(/vixsrc/); return null !== url.host.match(/vixsrc/) && supportsMediaFlowProxy(ctx);
} }
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> { protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<UrlResult[]> {
const headers = { Referer: url.href }; const playlistUrl = await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'VixCloud', url);
const countryCodes = await this.determineCountryCodesFromPlaylist(ctx, playlistUrl);
const html = await this.fetcher.text(ctx, url);
const tokenMatch = html.match(/['"]token['"]: ?['"](.*?)['"]/) as string[];
const expiresMatch = html.match(/['"]expires['"]: ?['"](.*?)['"]/) as string[];
const urlMatch = html.match(/url: ?['"](.*?)['"]/) as string[];
const baseUrl = new URL(`${urlMatch[1]}`);
const playlistUrl = new URL(`${baseUrl.origin}${baseUrl.pathname}.m3u8?${baseUrl.searchParams}`);
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 +30,21 @@ export class VixSrc extends Extractor {
{ {
url: playlistUrl, url: playlistUrl,
format: Format.hls, format: Format.hls,
label: this.label,
sourceId: `${this.id}_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta: { meta: {
...meta,
countryCodes, countryCodes,
height: meta.height ?? await guessHeightFromPlaylist(ctx, this.fetcher, playlistUrl, { headers }), height: await guessHeightFromPlaylist(ctx, this.fetcher, playlistUrl),
}, },
}, },
]; ];
}; };
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);

View file

@ -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();
});
});

View file

@ -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 }),
},
},
];
};
}

View file

@ -0,0 +1,28 @@
import winston from 'winston';
import { createTestContext } from '../test';
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&id=1061474&imdbId=tt5950044'))).toMatchSnapshot();
});
test('primebox Superman', async () => {
expect(await extractorRegistry.handle(ctx, new URL('https://backend.xprime.tv/primebox?name=Superman&year=2025&id=1061474&imdbId=tt5950044'))).toMatchSnapshot();
});
test('primebox Alien: Earth', async () => {
expect(await extractorRegistry.handle(ctx, new URL('https://backend.xprime.tv/primebox?name=Alien%3A%20Earth&year=2025&id=157239&imdb=tt13623632&season=1&episode=1'))).toMatchSnapshot();
});
test('primebox not found', async () => {
expect(await extractorRegistry.handle(ctx, new URL('https://backend.xprime.tv/primebox?name=Jurassic%20World%20Rebirth&year=2025'))).toMatchSnapshot();
});
});

62
src/extractor/XPrime.ts Normal file
View file

@ -0,0 +1,62 @@
import { Context, Format, Meta, UrlResult } from '../types';
import { guessSizeFromMp4 } from '../utils/size';
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';
public override readonly ttl: number = 21600000; // 6h
public supports(_ctx: Context, url: URL): boolean {
return null !== url.host.match(/xprime/);
}
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<UrlResult[]> {
const urlResults: UrlResult[] = [];
const headers = {
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}_primebox_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta: {
...meta,
bytes: await guessSizeFromMp4(ctx, this.fetcher, url, { headers, minCacheTtl: this.ttl }),
height: parseInt(resolution),
title,
},
requestHeaders: headers,
});
}
}
return urlResults;
};
}

View file

@ -1,4 +1,4 @@
import { Context, Format, InternalUrlResult, Meta } from '../types'; import { Context, Format, Meta, UrlResult } from '../types';
import { Extractor } from './Extractor'; import { Extractor } from './Extractor';
export class YouTube extends Extractor { export class YouTube extends Extractor {
@ -12,10 +12,8 @@ export class YouTube extends Extractor {
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, meta: Meta): 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,6 +22,9 @@ 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}_${meta.countryCodes?.join('_')}`,
ttl: this.ttl,
meta: { meta: {
...meta, ...meta,
title: titleMatch[1] as string, title: titleMatch[1] as string,

View file

@ -0,0 +1 @@
{"server":"nginx","date":"Sun, 08 Jun 2025 19:20:59 GMT","content-type":"video/mp4","content-length":791702409,"last-modified":"Wed, 03 Apr 2024 09:53:31 GMT","connection":"keep-alive","etag":"660d271b-2f306b89","access-control-allow-origin":"*","accept-ranges":"bytes"}

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

File diff suppressed because one or more lines are too long

View file

@ -1,2 +1 @@
<!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> <!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></body></html>
</body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
https://ijs155l.cloudatacdn.com/u5kj2rz5qtdlsdgge4hrgj2flz5cjodea6auatveaphxogr2vvukxfzmwzva/egprvdjvpr~

View file

@ -0,0 +1 @@
https://ee317r.cloudatacdn.com/u5kj63yvxddlsdgge7qremqqka27irwiupc4k7o5cikbhjliz6xwv2xr53uq/lco4d3heif~

View file

@ -0,0 +1 @@
https://wec6bnh7rx7rw11bv9e9vslw4rud8nam.5f1ebd98099ce35faeeddb30c1752191.r2.cloudflarestorage.com/icxxqqdsfdc4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ab085b515b950aae0a86cae59456cacd%2F20250714%2Fauto%2Fs3%2Faws4_request&X-Amz-Date=20250714T154400Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=845ebfa5feedd840027e431f31e013513dd4c4f74364b4e75a2c8ddb213bd97a

View file

@ -0,0 +1 @@
https://aa360cc.cloudatacdn.com/u5kjv4jxytd3sdgge5uogji5dg4huat2pxrm2qibdbm5rlyusvl46iz3u3ta/1f4isxg0tw~

File diff suppressed because one or more lines are too long

View file

@ -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

View file

@ -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">&copy; 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>

View file

@ -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&#39;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

View file

@ -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&#39;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>

View file

@ -0,0 +1,6 @@
#EXTM3U
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio0",NAME="Deutsch",LANGUAGE="de",AUTOSELECT=YES,DEFAULT=YES,CHANNELS="2",URI="https://mediaflow.test.org/proxy/hls/manifest.m3u8?api_password=test&h_user-agent=Mozilla%2F5.0+%28Windows+NT+10.0%3B+Win64%3B+x64%29+AppleWebKit%2F537.36+%28KHTML%2C+like+Gecko%29+Chrome%2F136.0.0.0+Safari%2F537.36&h_referer=https%3A%2F%2Fkinoger.com&d=https%3A%2F%2F6dkdxt1udlqhbho.ovaltinecdn.com%2Fhls2%2F01%2F01552%2Ftah5znapz3e5_n%2Findex-a1.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio0",NAME="English",LANGUAGE="en",AUTOSELECT=NO,DEFAULT=NO,CHANNELS="2",URI="https://mediaflow.test.org/proxy/hls/manifest.m3u8?api_password=test&h_user-agent=Mozilla%2F5.0+%28Windows+NT+10.0%3B+Win64%3B+x64%29+AppleWebKit%2F537.36+%28KHTML%2C+like+Gecko%29+Chrome%2F136.0.0.0+Safari%2F537.36&h_referer=https%3A%2F%2Fkinoger.com&d=https%3A%2F%2F6dkdxt1udlqhbho.ovaltinecdn.com%2Fhls2%2F01%2F01552%2Ftah5znapz3e5_n%2Findex-a2.m3u8"
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2862563,RESOLUTION=1920x1080,FRAME-RATE=25.000,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,AUDIO="audio0"
https://mediaflow.test.org/proxy/hls/manifest.m3u8?api_password=test&h_user-agent=Mozilla%2F5.0+%28Windows+NT+10.0%3B+Win64%3B+x64%29+AppleWebKit%2F537.36+%28KHTML%2C+like+Gecko%29+Chrome%2F136.0.0.0+Safari%2F537.36&h_referer=https%3A%2F%2Fkinoger.com&d=https%3A%2F%2F6dkdxt1udlqhbho.ovaltinecdn.com%2Fhls2%2F01%2F01552%2Ftah5znapz3e5_n%2Findex-v1-a1.m3u8
#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=123231,RESOLUTION=1920x1080,CODECS="avc1.640028",URI="https://mediaflow.test.org/proxy/hls/manifest.m3u8?api_password=test&h_user-agent=Mozilla%2F5.0+%28Windows+NT+10.0%3B+Win64%3B+x64%29+AppleWebKit%2F537.36+%28KHTML%2C+like+Gecko%29+Chrome%2F136.0.0.0+Safari%2F537.36&h_referer=https%3A%2F%2Fkinoger.com&d=https%3A%2F%2F6dkdxt1udlqhbho.ovaltinecdn.com%2Fhls2%2F01%2F01552%2Ftah5znapz3e5_n%2Fiframes-v1-a1.m3u8",VIDEO-RANGE=SDR

View file

@ -0,0 +1,4 @@
#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1242387,RESOLUTION=1728x720,FRAME-RATE=23.974,CODECS="avc1.640020,mp4a.40.2",VIDEO-RANGE=SDR
https://mediaflow.test.org/proxy/hls/manifest.m3u8?api_password=test&h_user-agent=Mozilla%2F5.0+%28Windows+NT+10.0%3B+Win64%3B+x64%29+AppleWebKit%2F537.36+%28KHTML%2C+like+Gecko%29+Chrome%2F136.0.0.0+Safari%2F537.36&h_referer=https%3A%2F%2Ffilelions.to%2Fv%2Ftyn45apubte2&d=https%3A%2F%2Frnzt2t4xvku08.acek-cdn.com%2Fhls2%2F01%2F05594%2Ftyn45apubte2_n%2Findex-v1-a1.m3u8%3Ft%3D8y6hK40boUynXnkyW5s63kFaWpt6_A5UcG5LBUX3j_4%26s%3D1756131487%26e%3D129600%26f%3D27974684%26srv%3DF6vE2P5Wjncn%26i%3D0.4%26sp%3D500%26p1%3DF6vE2P5Wjncn%26p2%3DF6vE2P5Wjncn%26asn%3D31898
#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=69531,RESOLUTION=1728x720,CODECS="avc1.640020",URI="https://mediaflow.test.org/proxy/hls/manifest.m3u8?api_password=test&h_user-agent=Mozilla%2F5.0+%28Windows+NT+10.0%3B+Win64%3B+x64%29+AppleWebKit%2F537.36+%28KHTML%2C+like+Gecko%29+Chrome%2F136.0.0.0+Safari%2F537.36&h_referer=https%3A%2F%2Ffilelions.to%2Fv%2Ftyn45apubte2&d=https%3A%2F%2Frnzt2t4xvku08.acek-cdn.com%2Fhls2%2F01%2F05594%2Ftyn45apubte2_n%2Fiframes-v1-a1.m3u8%3Ft%3D8y6hK40boUynXnkyW5s63kFaWpt6_A5UcG5LBUX3j_4%26s%3D1756131487%26e%3D129600%26f%3D27974684%26srv%3DF6vE2P5Wjncn%26i%3D0.4%26sp%3D500%26p1%3DF6vE2P5Wjncn%26p2%3DF6vE2P5Wjncn%26asn%3D31898",VIDEO-RANGE=SDR

File diff suppressed because one or more lines are too long

View file

@ -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>

View file

@ -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>

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