style: format with prettier

This commit is contained in:
Viren070
2025-01-16 14:11:49 +00:00
parent 87d2e02046
commit fc9a704c2b
39 changed files with 1436 additions and 948 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
name: Build Addon
on:
on:
push:
branches:
- main
@@ -11,7 +11,7 @@ on:
- 'tsconfig.json'
- 'tsconfig.base.json'
pull_request:
branches:
branches:
- main
paths:
- 'packages/**'
@@ -29,8 +29,8 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '22.x'
- name: Install Dependencies
- name: Install Dependencies
run: npm ci
- name: Build Addon
run: npm run build
+3 -3
View File
@@ -1,4 +1,4 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
+131 -117
View File
@@ -1,21 +1,21 @@
<p align="center"><img src="https://raw.githubusercontent.com/Viren070/AIOStreams/refs/heads/main/packages/frontend/public/assets/logo.png" /></p>
<h1 align="center" id="title">AIOStreams</h1>
## Table of Contents
## Table of Contents
- [Description](#description)
- [FAQ](#faq)
- [How does it work?](#how-does-it-work)
- [How does it work?](#how-does-it-work)
- [Usage](#usage)
- [Public Instance](#public-instance)
- [Personal Instance](#deploying-your-own-instance)
- [Hugging Face](#hugging-face)
- [Cloudflare Workers](#cloudflare-workers)
- [Render](#render)
- [ElfHosted](#elfhosted-paid)
- [Self Hosting](#self-hosting)
- [Docker](#docker)
- [From Source](#from-source)
- [Public Instance](#public-instance)
- [Personal Instance](#deploying-your-own-instance)
- [Hugging Face](#hugging-face)
- [Cloudflare Workers](#cloudflare-workers)
- [Render](#render)
- [ElfHosted](#elfhosted-paid)
- [Self Hosting](#self-hosting)
- [Docker](#docker)
- [From Source](#from-source)
- [Configuring](#configuring)
- [Environment Variables](#environment-variables)
- [Cloudflare Workers](#cloudflare-workers-1)
@@ -24,7 +24,7 @@
- [Local](#local)
- [Development](#development)
- [Credits](#credits)
## Description
Combines streams from other addons into one and provides much greater customisation:
@@ -106,12 +106,13 @@ The addon can display your results in different formats. The formats available a
![image](https://github.com/user-attachments/assets/21f90ee2-e81d-4a56-9e64-8937fb7ab2bc)
## Usage
### Public Instance
[ElfHosted](https://elfhosted.com/) have been kind enough to host a [community instance of AIOStreams](https://aiostreams.elfhosted.com/configure).
### Public Instance
This community instance does have a ratelimit in place, but it is unlikely you will reach it. It also avoids the ratelimits of ElfHosted addons like Comet and MediaFusion as AIOStreams' requests to these addons are routed internally.
However, other non-ElfHosted addons may rate limit the community instance.
[ElfHosted](https://elfhosted.com/) have been kind enough to host a [community instance of AIOStreams](https://aiostreams.elfhosted.com/configure).
This community instance does have a ratelimit in place, but it is unlikely you will reach it. It also avoids the ratelimits of ElfHosted addons like Comet and MediaFusion as AIOStreams' requests to these addons are routed internally.
However, other non-ElfHosted addons may rate limit the community instance.
### Deploying your own instance
@@ -172,7 +173,7 @@ COPY --from=builder /build/packages/formatters/package.*json ./packages/formatte
COPY --from=builder /build/packages/parser/package.*json ./packages/parser/
COPY --from=builder /build/packages/types/package.*json ./packages/types/
COPY --from=builder /build/packages/wrappers/package.*json ./packages/wrappers/
COPY --from=builder /build/packages/utils/package.*json ./packages/utils/
COPY --from=builder /build/packages/utils/package.\*json ./packages/utils/
COPY --from=builder /build/packages/addon/dist ./packages/addon/dist
COPY --from=builder /build/packages/frontend/out ./packages/frontend/out
@@ -197,10 +198,9 @@ ENTRYPOINT ["npm", "run", "start:addon"]
6. Your addon will be hosted at {username}-{space-name}.hf.space. You can also find a direct URL to it by clicking the 3 dots > Embed this space > Direct URL > Copy
##### Updating
##### Updating
To update the addon, you can simply go to the `Settings` tab and click `Factory rebuild`. This will rebuild the addon with the latest changes.
To update the addon, you can simply go to the `Settings` tab and click `Factory rebuild`. This will rebuild the addon with the latest changes.
#### Cloudflare Workers
@@ -212,7 +212,8 @@ This addon can be deployed as a [Cloudflare Worker](https://workers.cloudflare.c
1. Sign up for a [Cloudflare Account](https://dash.cloudflare.com/sign-up/workers-and-pages)
2. Install Node.js (I would recommend using package managers e.g. fnm on Windows)
3. Install Git
4. Run the following commands:
4. Run the following commands:
```
git clone https://github.com/Viren070/AIOStreams.git
cd AIOStreams
@@ -244,15 +245,15 @@ https://render.com/
3. Enter `https://github.com/Viren070/AIOStreams`
4. Deploy
##### Updating
##### Updating
When you deploy with Render, it automatically builds the addon every time a commit is pushed to this repository. You can also manually trigger a build by clicking the `Deploy` button.
When you deploy with Render, it automatically builds the addon every time a commit is pushed to this repository. You can also manually trigger a build by clicking the `Deploy` button.
It is recommend to disable the `Auto Deploy` feature as the latest changes may not be stable. You can do this by going to the `Settings` tab and scrolling down to the `Auto Deploy` setting near the bottom of the `Build & Deploy` section.
#### ElfHosted (paid)
AIOStreams is available as a [paid product](https://store.elfhosted.com/product/aiostreams/) on [ElfHosted](https://elfhosted.com). This offers you a no-hassle setup where everything is done for you.
AIOStreams is available as a [paid product](https://store.elfhosted.com/product/aiostreams/) on [ElfHosted](https://elfhosted.com). This offers you a no-hassle setup where everything is done for you.
### Self-Hosting
@@ -312,126 +313,146 @@ With encryption, someone who has your manifest URL can't directly see your API k
To modify the behaviour of the addon, you can provide the following environment variables:
- **ADDON_NAME**
- Type: string
- Default: 'AIOStreams'
- Description: The name of the addon.
- Type: string
- Default: 'AIOStreams'
- Description: The name of the addon.
- **ADDON_ID**
- Type: string
- Default: 'aiostreams.viren070.com'
- Description: The unique identifier for the addon.
- Type: string
- Default: 'aiostreams.viren070.com'
- Description: The unique identifier for the addon.
- **PORT**
- Type: number
- Default: 3000
- Description: The port on which the server will run.
- Type: number
- Default: 3000
- Description: The port on which the server will run.
- **BRANDING**
- Type: string
- Default: undefined
- Description: Custom branding HTML content. This can be customized by setting the BRANDING environment variable. This HTML content will be displayed at the top of the addon configuration page.
>[!NOTE]
>`BRANDING` is a **build-time** environment variable. It must be present during the build process.
- Type: string
- Default: undefined
- Description: Custom branding HTML content. This can be customized by setting the BRANDING environment variable. This HTML content will be displayed at the top of the addon configuration page.
> [!NOTE] >`BRANDING` is a **build-time** environment variable. It must be present during the build process.
- **SECRET_KEY**
- Type: string
- Default: ''
- Description: The secret key used for encryption. This should be set using the SECRET_KEY environment variable. ```openssl rand -hex 16``` or ```[System.Guid]::NewGuid().ToString("N").Substring(0, 32)``` can be used to generate a new secret key for Linux/MacoS and Windows respectively.
- Type: string
- Default: ''
- Description: The secret key used for encryption. This should be set using the SECRET_KEY environment variable. `openssl rand -hex 16` or `[System.Guid]::NewGuid().ToString("N").Substring(0, 32)` can be used to generate a new secret key for Linux/MacoS and Windows respectively.
- **COMET_URL**
- Type: string
- Default: `'https://comet.elfhosted.com/'`
- Description: The URL for the Comet addon. This URL is used internally by the addon to fetch results from the Comet addon. This environment variable allows you to modify the base URL being used without having to configure the addon yourself and then providing an `Override URL` in the addon configuration.
- Type: string
- Default: `'https://comet.elfhosted.com/'`
- Description: The URL for the Comet addon. This URL is used internally by the addon to fetch results from the Comet addon. This environment variable allows you to modify the base URL being used without having to configure the addon yourself and then providing an `Override URL` in the addon configuration.
- **MEDIAFUSION_URL**
- Type: string
- Default: `'https://mediafusion.elfhosted.com/'`
- Description: The URL for the MediaFusion addon.
- Type: string
- Default: `'https://mediafusion.elfhosted.com/'`
- Description: The URL for the MediaFusion addon.
- **TORRENTIO_URL**
- Type: string
- Default: `'https://torrentio.strem.fun/'`
- Description: The URL for the Torrentio addon.
- Type: string
- Default: `'https://torrentio.strem.fun/'`
- Description: The URL for the Torrentio addon.
- **TORBOX_STREMIO_URL**
- Type: string
- Default: `'https://stremio.torbox.app/'`
- Description: The URL for the Torbox Stremio addon.
- Type: string
- Default: `'https://stremio.torbox.app/'`
- Description: The URL for the Torbox Stremio addon.
- **EASYNEWS_URL**
- Type: string
- Default: `'https://ea627ddf0ee7-easynews.baby-beamup.club/'`
- Description: The url for the easynews addon
- Type: string
- Default: `'https://ea627ddf0ee7-easynews.baby-beamup.club/'`
- Description: The url for the easynews addon
- **EASYNEWS_URL**
- Type: string
- Default: `'https://b89262c192b0-stremio-easynews-addon.baby-beamup.club/'`
- Description: The url for the easynews+ addon
- Type: string
- Default: `'https://b89262c192b0-stremio-easynews-addon.baby-beamup.club/'`
- Description: The url for the easynews+ addon
- **MAX_ADDONS**
- Type: number
- Default: 15
- Description: The maximum number of addons that is allowed. This is checked when the addon is configured and when you make a request to the addon.
- Type: number
- Default: 15
- Description: The maximum number of addons that is allowed. This is checked when the addon is configured and when you make a request to the addon.
- **MAX_MOVIE_SIZE**
- Type: number
- Default: 150000000000 (150GB)
- Description: The maximum size for movie files in bytes. This URL controls the maximum size you can set for the movie size filters. This affects the maximum value for the sliders in the configure page.
- Type: number
- Default: 150000000000 (150GB)
- Description: The maximum size for movie files in bytes. This URL controls the maximum size you can set for the movie size filters. This affects the maximum value for the sliders in the configure page.
- **MAX_EPISODE_SIZE**
- Type: number
- Default: 15000000000 (15GB)
- Description: The maximum size for episode files in bytes. This URL controls the maximum size you can set for the episode size filters. This affects the maximum value for the sliders in the configure page.
- Type: number
- Default: 15000000000 (15GB)
- Description: The maximum size for episode files in bytes. This URL controls the maximum size you can set for the episode size filters. This affects the maximum value for the sliders in the configure page.
- **MAX_TIMEOUT**
- Type: number
- Default: 50000
- Description: The maximum timeout value in milliseconds. The timeout value controls how long the addon will wait for a response from an addon before moving on to the next addon. This controls the max value the user can set for the timeout slider in the configure page.
- Type: number
- Default: 50000
- Description: The maximum timeout value in milliseconds. The timeout value controls how long the addon will wait for a response from an addon before moving on to the next addon. This controls the max value the user can set for the timeout slider in the configure page.
- **MIN_TIMEOUT**
- Type: number
- Default: 1000
- Description: The minimum timeout value in milliseconds.
- Type: number
- Default: 1000
- Description: The minimum timeout value in milliseconds.
- **DEFAULT_TIMEOUT**
- Type: number
- Default: 15000
- Description: The default timeout value in milliseconds.
- Type: number
- Default: 15000
- Description: The default timeout value in milliseconds.
- **DEFAULT_TORRENTIO_TIMEOUT**
- Type: number
- Default: 5000
- Description: The default timeout value for Torrentio in milliseconds.
- Type: number
- Default: 5000
- Description: The default timeout value for Torrentio in milliseconds.
- **DEFAULT_TORBOX_TIMEOUT**
- Type: number
- Default: 15000
- Description: The default timeout value for Torbox in milliseconds.
- Type: number
- Default: 15000
- Description: The default timeout value for Torbox in milliseconds.
- **DEFAULT_COMET_TIMEOUT**
- Type: number
- Default: 15000
- Description: The default timeout value for Comet in milliseconds.
- Type: number
- Default: 15000
- Description: The default timeout value for Comet in milliseconds.
- **DEFAULT_MEDIAFUSION_TIMEOUT**
- Type: number
- Default: 15000
- Description: The default timeout value for MediaFusion in milliseconds.
- Type: number
- Default: 15000
- Description: The default timeout value for MediaFusion in milliseconds.
- **DEFAULT_EASYNEWS_TIMEOUT**
- Type: number
- Default: 15000
- Description: The default timeout value for the easynews addon in milliseconds
- Type: number
- Default: 15000
- Description: The default timeout value for the easynews addon in milliseconds
- **DEFAULT_EASYNEWS_PLUS_TIMEOUT**
- Type: number
- Default: 15000
- Description: The default timeout value for the easynews+ addon in milliseconds
- Type: number
- Default: 15000
- Description: The default timeout value for the easynews+ addon in milliseconds
- **SHOW_DIE**
- Type: boolean
- Default: true
- Description: A flag to indicate whether to show a die (singular dice) emoji in the stream result names. This is available to distinguish results from the AIOStreams addon from other addons.
- Type: boolean
- Default: true
- Description: A flag to indicate whether to show a die (singular dice) emoji in the stream result names. This is available to distinguish results from the AIOStreams addon from other addons.
Below, you can find how to set environment variables for the different methods of deployment.
@@ -439,7 +460,7 @@ Below, you can find how to set environment variables for the different methods o
Unfortunately, it is not currently possible to set environment variables for this addon on a Cloudflare Worker. You will have to modify the code directly. You can look in `packages/utils/src/settings.ts` to change the default values.
### Render
### Render
You can set environment variables in the Render dashboard.
@@ -449,19 +470,17 @@ You can set environment variables in the Render dashboard.
4. Click `Add Environment Variable` and enter the name and value of the environment variable you want to set.
5. Once you have added all the environment variables you want to set, click `Save, build, and deploy`.
### Hugging Face
1. Go to your Hugging Face space and click on the `Settings` tab.
2. Scroll down to `Variables and Secrets` and click on `New secret`.
> [!WARNING]
> Ensure you are using `Secrets`, especially for `SECRET_KEY`. Variables are public and can be seen by anyone.
3. Enter the name and value of the environment variable you want to set. The description is optional and can be left empty.
2. Scroll down to `Variables and Secrets` and click on `New secret`.
> [!WARNING]
> Ensure you are using `Secrets`, especially for `SECRET_KEY`. Variables are public and can be seen by anyone.
3. Enter the name and value of the environment variable you want to set. The description is optional and can be left empty.
### Local
### Local
You can set environment variables using a .env file in the root of the project.
You can set environment variables using a .env file in the root of the project.
```
ADDON_NAME=AIOStreams
@@ -472,9 +491,7 @@ COMET_URL=https://comet.elfhosted.com/
...
```
## Development
## Development
1. Clone the project and set it as the current directory
```
@@ -488,21 +505,19 @@ COMET_URL=https://comet.elfhosted.com/
npm i
```
Now, you can run various aspects of the project in development.
> [!NOTE]
> Most of these commands require that you build the project beforehand. Changes in other packages do not reflect immediately as it needs to be compiled into JavaScript first.
> Run `npm run build` to build the project.
To start the addon in development mode, run the following command:
To start the addon in development mode, run the following command:
```
npm run start:addon:dev
```
To run the cloudflare worker in development mode, run the following command
To run the cloudflare worker in development mode, run the following command
```
npm run start:cloudflare-worker:dev
@@ -514,15 +529,14 @@ To run the frontend of the project, run the following command
npm run start:frontend:dev
```
### Deploying
### Deploying
To deploy your cloudflare worker, run the following command:
To deploy your cloudflare worker, run the following command:
```
npm run deploy:cloudflare-worker
```
## Credits
Thanks to [sleeyax/stremio-easynews-addon](https://github.com/Sleeyax/stremio-easynews-addon) for the repository structure and dockerfile.
+255 -153
View File
@@ -34,22 +34,24 @@ export class AIOStreams {
const getTimeTakenSincePoint = (point: number) => {
const timeNow = new Date().getTime();
const duration = timeNow - point;
// format duration and choose unit and return
// format duration and choose unit and return
const nanos = duration * 1_000_000; // Convert to nanoseconds
const micros = duration * 1_000; // Convert to microseconds
if (nanos < 1) {
return `${nanos.toFixed(2)}ns`;
return `${nanos.toFixed(2)}ns`;
} else if (micros < 1) {
return `${micros.toFixed(2)}µs`;
return `${micros.toFixed(2)}µs`;
} else if (duration < 1000) {
return `${duration.toFixed(2)}ms`;
return `${duration.toFixed(2)}ms`;
} else {
return `${(duration / 1000).toFixed(2)}s`;
return `${(duration / 1000).toFixed(2)}s`;
}
}
};
const parsedStreams = await this.getParsedStreams(streamRequest);
console.log(`|INF| addon > getStreams: Got ${parsedStreams.length} total parsed streams in ${getTimeTakenSincePoint(startTime)}`);
console.log(
`|INF| addon > getStreams: Got ${parsedStreams.length} total parsed streams in ${getTimeTakenSincePoint(startTime)}`
);
const filterStartTime = new Date().getTime();
let filteredResults = parsedStreams.filter((parsedStream) => {
@@ -64,28 +66,37 @@ export class AIOStreams {
if (!qualityFilter) return false;
// Check for HDR and DV tags in the parsed stream
const hasHDR = parsedStream.visualTags.some((tag) => tag.startsWith('HDR'));
const hasHDR = parsedStream.visualTags.some((tag) =>
tag.startsWith('HDR')
);
const hasDV = parsedStream.visualTags.includes('DV');
const hasHDRAndDV = hasHDR && hasDV;
const HDRAndDVEnabled = this.config.visualTags.some((visualTag) => visualTag['HDR+DV'] === true);
const HDRAndDVEnabled = this.config.visualTags.some(
(visualTag) => visualTag['HDR+DV'] === true
);
// Helper function to check if a specific tag is enabled
const isTagEnabled = (tag: string) => this.config.visualTags.some((visualTag) => visualTag[tag] === true);
const isTagEnabled = (tag: string) =>
this.config.visualTags.some((visualTag) => visualTag[tag] === true);
if (hasHDRAndDV) {
if (!HDRAndDVEnabled) {
return false;
}
} else if (hasHDR) {
const specificHdrTags = parsedStream.visualTags.filter((tag) => tag.startsWith('HDR'));
const disabledTags = specificHdrTags.filter((tag) => !isTagEnabled(tag));
const specificHdrTags = parsedStream.visualTags.filter((tag) =>
tag.startsWith('HDR')
);
const disabledTags = specificHdrTags.filter(
(tag) => !isTagEnabled(tag)
);
if (disabledTags.length > 0) {
return false;
}
} else if (hasDV && !isTagEnabled('DV')) {
return false;
}
// Check other visual tags for explicit disabling
for (const tag of parsedStream.visualTags) {
if (tag.startsWith('HDR') || tag === 'DV') continue;
@@ -93,13 +104,15 @@ export class AIOStreams {
return false;
}
}
// apply excludedLanguages filter
const excludedLanguages = this.config.excludedLanguages;
if (excludedLanguages && parsedStream.languages.length > 0) {
if (parsedStream.languages.every(lang => excludedLanguages.includes(lang))) {
if (
parsedStream.languages.every((lang) =>
excludedLanguages.includes(lang)
)
) {
return false;
}
}
@@ -171,103 +184,130 @@ export class AIOStreams {
return true;
});
console.log(`|INF| addon > getStreams: Initial filter to ${filteredResults.length} streams in ${getTimeTakenSincePoint(filterStartTime)}`);
console.log(
`|INF| addon > getStreams: Initial filter to ${filteredResults.length} streams in ${getTimeTakenSincePoint(filterStartTime)}`
);
if (this.config.cleanResults) {
const cleanResultsStartTime = new Date().getTime();
const uniqueStreams: ParsedStream[] = [];
// Group streams by normalized filename
const streamsByHashOrName = filteredResults.reduce((acc, stream) => {
const streamsByHashOrName = filteredResults.reduce(
(acc, stream) => {
const normalizedFilename = stream.filename
? stream.filename.replace(/[^\p{L}\p{N}]/gu, '').toLowerCase()
: undefined;
? stream.filename.replace(/[^\p{L}\p{N}]/gu, '').toLowerCase()
: undefined;
const key = stream._infoHash || normalizedFilename;
if (!key) {
uniqueStreams.push(stream);
return acc;
uniqueStreams.push(stream);
return acc;
}
acc[key] = acc[key] || [];
acc[key].push(stream);
return acc;
}, {} as Record<string, ParsedStream[]>);
},
{} as Record<string, ParsedStream[]>
);
Object.values(streamsByHashOrName).forEach((groupedStreams) => {
if (groupedStreams.length === 1) {
uniqueStreams.push(groupedStreams[0]);
return;
}
//console.log(`==================\nDetermining unique streams for ${groupedStreams[0].filename} from ${groupedStreams.length} total duplicates`);
//console.log(groupedStreams.map(stream => `Addon ID: ${stream.addon.id}, Provider ID: ${stream.provider?.id}, Provider Cached: ${stream.provider?.cached}`));
// Separate streams into categories
const cachedStreams = groupedStreams.filter(stream => stream.provider?.cached);
const uncachedStreams = groupedStreams.filter(stream => stream.provider && !stream.provider.cached);
const noProviderStreams = groupedStreams.filter(stream => !stream.provider);
// Select uncached streams by addon priority (one per provider)
const selectedUncachedStreams = Object.values(uncachedStreams.reduce((acc, stream) => {
if (groupedStreams.length === 1) {
uniqueStreams.push(groupedStreams[0]);
return;
}
//console.log(`==================\nDetermining unique streams for ${groupedStreams[0].filename} from ${groupedStreams.length} total duplicates`);
//console.log(groupedStreams.map(stream => `Addon ID: ${stream.addon.id}, Provider ID: ${stream.provider?.id}, Provider Cached: ${stream.provider?.cached}`));
// Separate streams into categories
const cachedStreams = groupedStreams.filter(
(stream) => stream.provider?.cached
);
const uncachedStreams = groupedStreams.filter(
(stream) => stream.provider && !stream.provider.cached
);
const noProviderStreams = groupedStreams.filter(
(stream) => !stream.provider
);
// Select uncached streams by addon priority (one per provider)
const selectedUncachedStreams = Object.values(
uncachedStreams.reduce(
(acc, stream) => {
acc[stream.provider!.id] = acc[stream.provider!.id] || [];
acc[stream.provider!.id].push(stream);
return acc;
}, {} as Record<string, ParsedStream[]>)).map(providerGroup => {
return providerGroup.sort((a, b) => {
const aIndex = this.config.addons.findIndex(
addon => `${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id
);
const bIndex = this.config.addons.findIndex(
addon => `${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id
);
return aIndex - bIndex;
})[0];
});
//selectedUncachedStreams.forEach(stream => console.log(`Selected uncached stream for provider ${stream.provider!.id}: Addon ID: ${stream.addon.id}`));
// Select cached streams by provider and addon priority
const selectedCachedStream = cachedStreams.sort((a, b) => {
const aProviderIndex = this.config.services.findIndex(service => service.id === a.provider!.id);
const bProviderIndex = this.config.services.findIndex(service => service.id === b.provider!.id);
if (aProviderIndex !== bProviderIndex) {
return aProviderIndex - bProviderIndex;
}
const aAddonIndex = this.config.addons.findIndex(
addon => `${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id
);
const bAddonIndex = this.config.addons.findIndex(
addon => `${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id
);
return aAddonIndex - bAddonIndex;
},
{} as Record<string, ParsedStream[]>
)
).map((providerGroup) => {
return providerGroup.sort((a, b) => {
const aIndex = this.config.addons.findIndex(
(addon) =>
`${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id
);
const bIndex = this.config.addons.findIndex(
(addon) =>
`${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id
);
return aIndex - bIndex;
})[0];
// Select one non-provider stream (highest addon priority)
const selectedNoProviderStream = noProviderStreams.sort((a, b) => {
const aIndex = this.config.addons.findIndex(
addon => `${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id
);
const bIndex = this.config.addons.findIndex(
addon => `${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id
);
return aIndex - bIndex;
})[0];
// Combine selected streams for this group
if (selectedNoProviderStream) {
//console.log(`Selected no provider stream: Addon ID: ${selectedNoProviderStream.addon.id}`);
uniqueStreams.push(selectedNoProviderStream);
});
//selectedUncachedStreams.forEach(stream => console.log(`Selected uncached stream for provider ${stream.provider!.id}: Addon ID: ${stream.addon.id}`));
// Select cached streams by provider and addon priority
const selectedCachedStream = cachedStreams.sort((a, b) => {
const aProviderIndex = this.config.services.findIndex(
(service) => service.id === a.provider!.id
);
const bProviderIndex = this.config.services.findIndex(
(service) => service.id === b.provider!.id
);
if (aProviderIndex !== bProviderIndex) {
return aProviderIndex - bProviderIndex;
}
if (selectedCachedStream) {
//console.log(`Selected cached stream for provider ${selectedCachedStream.provider!.id} from Addon ID: ${selectedCachedStream.addon.id}`);
uniqueStreams.push(selectedCachedStream);
}
uniqueStreams.push(...selectedUncachedStreams);
const aAddonIndex = this.config.addons.findIndex(
(addon) =>
`${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id
);
const bAddonIndex = this.config.addons.findIndex(
(addon) =>
`${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id
);
return aAddonIndex - bAddonIndex;
})[0];
// Select one non-provider stream (highest addon priority)
const selectedNoProviderStream = noProviderStreams.sort((a, b) => {
const aIndex = this.config.addons.findIndex(
(addon) =>
`${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id
);
const bIndex = this.config.addons.findIndex(
(addon) =>
`${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id
);
return aIndex - bIndex;
})[0];
// Combine selected streams for this group
if (selectedNoProviderStream) {
//console.log(`Selected no provider stream: Addon ID: ${selectedNoProviderStream.addon.id}`);
uniqueStreams.push(selectedNoProviderStream);
}
if (selectedCachedStream) {
//console.log(`Selected cached stream for provider ${selectedCachedStream.provider!.id} from Addon ID: ${selectedCachedStream.addon.id}`);
uniqueStreams.push(selectedCachedStream);
}
uniqueStreams.push(...selectedUncachedStreams);
});
filteredResults = uniqueStreams;
console.log(`|INF| addon > getStreams: Cleaned results to ${filteredResults.length} streams in ${getTimeTakenSincePoint(cleanResultsStartTime)}`);
console.log(
`|INF| addon > getStreams: Cleaned results to ${filteredResults.length} streams in ${getTimeTakenSincePoint(cleanResultsStartTime)}`
);
}
// Apply sorting
const sortStartTime = new Date().getTime();
@@ -291,42 +331,66 @@ export class AIOStreams {
return 0;
});
console.log(`|INF| addon > getStreams: Sorted results in ${getTimeTakenSincePoint(sortStartTime)}`);
console.log(
`|INF| addon > getStreams: Sorted results in ${getTimeTakenSincePoint(sortStartTime)}`
);
// apply config.maxResultsPerResolution
if (this.config.maxResultsPerResolution) {
const startTime = new Date().getTime();
const streamsByResolution = filteredResults.reduce((acc, stream) => {
acc[stream.resolution] = acc[stream.resolution] || [];
acc[stream.resolution].push(stream);
return acc;
}, {} as Record<string, ParsedStream[]>);
const streamsByResolution = filteredResults.reduce(
(acc, stream) => {
acc[stream.resolution] = acc[stream.resolution] || [];
acc[stream.resolution].push(stream);
return acc;
},
{} as Record<string, ParsedStream[]>
);
const limitedStreams = Object.values(streamsByResolution).map((streams) => {
return streams.slice(0, this.config.maxResultsPerResolution!);
});
const limitedStreams = Object.values(streamsByResolution).map(
(streams) => {
return streams.slice(0, this.config.maxResultsPerResolution!);
}
);
filteredResults = limitedStreams.flat();
console.log(`|INF| addon > getStreams: Limited results to ${filteredResults.length} streams after applying maxResultsPerResolution in ${getTimeTakenSincePoint(startTime)}`);
console.log(
`|INF| addon > getStreams: Limited results to ${filteredResults.length} streams after applying maxResultsPerResolution in ${getTimeTakenSincePoint(startTime)}`
);
}
// Create stream objects
const streamsStartTime = new Date().getTime();
const streamObjects = await Promise.all(filteredResults.map(this.createStreamObject.bind(this)));
streams.push(...streamObjects.filter(s => s !== null));
const streamObjects = await Promise.all(
filteredResults.map(this.createStreamObject.bind(this))
);
streams.push(...streamObjects.filter((s) => s !== null));
console.log(`|INF| addon > getStreams: Created ${streams.length} stream objects in ${getTimeTakenSincePoint(streamsStartTime)}`);
console.log(`|INF| addon > getStreams: Total time taken to serve streams: ${getTimeTakenSincePoint(startTime)}`);
console.log(
`|INF| addon > getStreams: Created ${streams.length} stream objects in ${getTimeTakenSincePoint(streamsStartTime)}`
);
console.log(
`|INF| addon > getStreams: Total time taken to serve streams: ${getTimeTakenSincePoint(startTime)}`
);
return streams;
}
private createMediaFlowStream(parsedStream: ParsedStream, name: string, description: string): Stream {
private createMediaFlowStream(
parsedStream: ParsedStream,
name: string,
description: string
): Stream {
if (!parsedStream.url) {
console.error(`|ERR| addon > createMediaFlowStream: Stream URL is missing, cannot proxy a stream without a URL`);
console.error(
`|ERR| addon > createMediaFlowStream: Stream URL is missing, cannot proxy a stream without a URL`
);
throw new Error('Stream URL is missing');
}
const proxiedUrl = createProxiedMediaFlowUrl(parsedStream.url, this.config.mediaFlowConfig, parsedStream.stream?.behaviorHints?.proxyHeaders);
const proxiedUrl = createProxiedMediaFlowUrl(
parsedStream.url,
this.config.mediaFlowConfig,
parsedStream.stream?.behaviorHints?.proxyHeaders
);
if (!proxiedUrl) {
throw new Error('Could not create MediaFlow proxied URL');
}
@@ -341,10 +405,10 @@ export class AIOStreams {
return {
url: proxiedUrl,
name: this.config.addonNameInDescription
name: this.config.addonNameInDescription
? Settings.ADDON_NAME
: `🕵️ ${name}`,
description: this.config.addonNameInDescription
description: this.config.addonNameInDescription
? `🕵️ ${name}\n${description}`
: description,
subtitles: parsedStream.stream?.subtitles,
@@ -354,12 +418,13 @@ export class AIOStreams {
videoSize: Math.floor(parsedStream.size || 0) || undefined,
videoHash: parsedStream.stream?.behaviorHints?.videoHash,
bingeGroup: `mfp.${Settings.ADDON_ID}|${parsedStream.addon.name}|${combinedTags.join('|')}`,
}
}
},
};
}
private async createStreamObject(parsedStream: ParsedStream): Promise<Stream | null> {
private async createStreamObject(
parsedStream: ParsedStream
): Promise<Stream | null> {
let name: string = '';
let description: string = '';
switch (this.config.formatter) {
@@ -402,7 +467,11 @@ export class AIOStreams {
if (this.config.mediaFlowConfig?.mediaFlowEnabled && parsedStream.url) {
try {
const mediaFlowStream = this.createMediaFlowStream(parsedStream, name, description);
const mediaFlowStream = this.createMediaFlowStream(
parsedStream,
name,
description
);
if (!mediaFlowStream) {
throw new Error('Unknown error creating MediaFlow stream');
}
@@ -420,7 +489,9 @@ export class AIOStreams {
fileIdx: parsedStream.torrent?.fileIdx,
name: this.config.addonNameInDescription
? Settings.ADDON_NAME
: Settings.SHOW_DIE ? `🎲 ${name}` : name,
: Settings.SHOW_DIE
? `🎲 ${name}`
: name,
description: this.config.addonNameInDescription
? `🎲 ${name}\n${description}`
: description,
@@ -449,7 +520,6 @@ export class AIOStreams {
if (aHasPrioritisedLanguage && !bHasPrioritisedLanguage) return -1;
if (!aHasPrioritisedLanguage && bHasPrioritisedLanguage) return 1;
}
return 0;
}
@@ -478,9 +548,15 @@ export class AIOStreams {
// prioritise a false value over undefined
if (aCached === false && bCached === undefined) return -1;
if (aCached === undefined && bCached === false) return 1;
return this.config.sortBy.find((sort) => Object.keys(sort)[0] === 'cached')?.direction === 'asc'
? aCached ? 1 : -1 // uncached > cached
: aCached ? -1 : 1; // cached > uncached
return this.config.sortBy.find(
(sort) => Object.keys(sort)[0] === 'cached'
)?.direction === 'asc'
? aCached
? 1
: -1 // uncached > cached
: aCached
? -1
: 1; // cached > uncached
}
} else if (field === 'hasProvider') {
// files from a provider should be prioritised and then
@@ -503,17 +579,29 @@ export class AIOStreams {
return aIndex - bIndex;
}
} else if (field === 'size') {
return this.config.sortBy.find((sort) => Object.keys(sort)[0] === 'size')?.direction === 'asc'
return this.config.sortBy.find((sort) => Object.keys(sort)[0] === 'size')
?.direction === 'asc'
? (a.size || 0) - (b.size || 0)
: (b.size || 0) - (a.size || 0)
: (b.size || 0) - (a.size || 0);
} else if (field === 'seeders') {
if (a.torrent?.seeders !== undefined && b.torrent?.seeders !== undefined) {
return this.config.sortBy.find((sort) => Object.keys(sort)[0] === 'seeders')?.direction === 'asc'
if (
a.torrent?.seeders !== undefined &&
b.torrent?.seeders !== undefined
) {
return this.config.sortBy.find(
(sort) => Object.keys(sort)[0] === 'seeders'
)?.direction === 'asc'
? a.torrent.seeders - b.torrent.seeders
: b.torrent.seeders - a.torrent.seeders;
} else if (a.torrent?.seeders !== undefined && b.torrent?.seeders === undefined) {
} else if (
a.torrent?.seeders !== undefined &&
b.torrent?.seeders === undefined
) {
return -1;
} else if (a.torrent?.seeders === undefined && b.torrent?.seeders !== undefined) {
} else if (
a.torrent?.seeders === undefined &&
b.torrent?.seeders !== undefined
) {
return 1;
}
} else if (field === 'quality') {
@@ -523,21 +611,24 @@ export class AIOStreams {
);
} else if (field === 'visualTag') {
// Find the highest priority visual tag in each file
const getIndexOfTag = (tag: string) => this.config.visualTags.findIndex((t) => t[tag]);
const getIndexOfTag = (tag: string) =>
this.config.visualTags.findIndex((t) => t[tag]);
const getHighestPriorityTagIndex = (tags: string[]) => {
// Check if the file contains both any HDR tag and DV
const hasHDR = tags.some((tag) => tag.startsWith('HDR'));
const hasDV = tags.includes('DV');
if (hasHDR && hasDV) {
// Sort according to the position of the HDR+DV tag
const hdrDvIndex = this.config.visualTags.findIndex((t) => t['HDR+DV']);
const hdrDvIndex = this.config.visualTags.findIndex(
(t) => t['HDR+DV']
);
if (hdrDvIndex !== -1) {
return hdrDvIndex;
}
}
// If the file contains multiple HDR tags, look at the HDR tag that has the highest priority
const hdrTagIndices = tags
.filter((tag) => tag.startsWith('HDR'))
@@ -545,17 +636,17 @@ export class AIOStreams {
if (hdrTagIndices.length > 0) {
return Math.min(...hdrTagIndices);
}
// Always consider the highest priority visual tag when a file has multiple visual tags
return tags.reduce(
(minIndex, tag) => Math.min(minIndex, getIndexOfTag(tag)),
this.config.visualTags.length
);
};
const aVisualTagIndex = getHighestPriorityTagIndex(a.visualTags);
const bVisualTagIndex = getHighestPriorityTagIndex(b.visualTags);
// Sort by the visual tag index
return aVisualTagIndex - bVisualTagIndex;
} else if (field === 'audioTag') {
@@ -593,7 +684,7 @@ export class AIOStreams {
if (!this.config.prioritisedLanguages) {
return 0;
}
// else, we look at the array of prioritisedLanguages.
// else, we look at the array of prioritisedLanguages.
// any file with a language in the prioritisedLanguages array should be prioritised
// if both files contain a prioritisedLanguage, we compare the index of the highest priority language
@@ -610,14 +701,20 @@ export class AIOStreams {
if (aHasPrioritisedLanguage && bHasPrioritisedLanguage) {
const getHighestPriorityLanguageIndex = (languages: string[]) => {
return languages.reduce((minIndex, lang) => {
const index = this.config.prioritisedLanguages?.indexOf(lang) ?? this.config.prioritisedLanguages!.length;
const index =
this.config.prioritisedLanguages?.indexOf(lang) ??
this.config.prioritisedLanguages!.length;
return index !== -1 ? Math.min(minIndex, index) : minIndex;
}, this.config.prioritisedLanguages!.length);
};
const aHighestPriorityLanguageIndex = getHighestPriorityLanguageIndex(a.languages);
const bHighestPriorityLanguageIndex = getHighestPriorityLanguageIndex(b.languages);
const aHighestPriorityLanguageIndex = getHighestPriorityLanguageIndex(
a.languages
);
const bHighestPriorityLanguageIndex = getHighestPriorityLanguageIndex(
b.languages
);
return aHighestPriorityLanguageIndex - bHighestPriorityLanguageIndex;
}
}
@@ -629,7 +726,8 @@ export class AIOStreams {
): Promise<ParsedStream[]> {
const parsedStreams: ParsedStream[] = [];
const addonPromises = this.config.addons.map(async (addon) => {
const addonName = addon.options.name || addon.options.overrideName || addon.id;
const addonName =
addon.options.name || addon.options.overrideName || addon.id;
try {
const addonId = `${addon.id}-${JSON.stringify(addon.options)}`;
const streams = await this.getStreamsFromAddon(
@@ -638,9 +736,13 @@ export class AIOStreams {
streamRequest
);
parsedStreams.push(...streams);
console.log(`|INF| addon > getParsedStreams: Got ${streams.length} streams from addon ${addonName}`);
console.log(
`|INF| addon > getParsedStreams: Got ${streams.length} streams from addon ${addonName}`
);
} catch (error) {
console.error(`|ERR| addon > getParsedStreams: Failed to get streams from ${addonName}: ${error}`);
console.error(
`|ERR| addon > getParsedStreams: Failed to get streams from ${addonName}: ${error}`
);
}
});
@@ -675,7 +777,7 @@ export class AIOStreams {
this.config,
addon.options,
streamRequest,
addonId,
addonId
);
}
case 'mediafusion': {
@@ -683,7 +785,7 @@ export class AIOStreams {
this.config,
addon.options,
streamRequest,
addonId,
addonId
);
}
case 'easynews': {
@@ -692,7 +794,7 @@ export class AIOStreams {
addon.options,
streamRequest,
addonId
)
);
}
case 'easynews-plus': {
return await getEasynewsPlusStreams(
@@ -713,7 +815,7 @@ export class AIOStreams {
? parseInt(addon.options.indexerTimeout)
: undefined,
addonId,
this.config,
this.config
);
return await wrapper.getParsedStreams(streamRequest);
}
@@ -730,7 +832,7 @@ export class AIOStreams {
? parseInt(addon.options.indexerTimeout)
: undefined,
addonId,
this.config,
this.config
);
return await wrapper.getParsedStreams(streamRequest);
}
+3 -1
View File
@@ -129,7 +129,9 @@ export function validateConfig(config: Config): {
if (
option.id.toLowerCase().includes('url') &&
addon.options[option.id] &&
addon.options[option.id]?.match(/^E-[0-9a-fA-F]{32}-[0-9a-fA-F]+$/) === null
addon.options[option.id]?.match(
/^E-[0-9a-fA-F]{32}-[0-9a-fA-F]+$/
) === null
) {
try {
new URL(addon.options[option.id] as string);
+2 -2
View File
@@ -15,8 +15,8 @@ const manifest = (configured: boolean) => {
types: ['movie', 'series'],
behaviorHints: {
configurable: true,
configurationRequired: configured ? false : true
}
configurationRequired: configured ? false : true,
},
};
};
+1 -1
View File
@@ -1,4 +1,4 @@
import { Settings } from "@aiostreams/utils";
import { Settings } from '@aiostreams/utils';
export const missingConfig = (origin: string) => {
return {
+120 -42
View File
@@ -6,14 +6,21 @@ import { Config, StreamRequest } from '@aiostreams/types';
import { validateConfig } from './config';
import manifest from './manifest';
import { invalidConfig, missingConfig } from './responses';
import {Settings, compressAndEncrypt, decryptAndDecompress, parseAndDecryptString} from '@aiostreams/utils';
import {
Settings,
compressAndEncrypt,
decryptAndDecompress,
parseAndDecryptString,
} from '@aiostreams/utils';
const app = express();
console.log(`|INF| server > init: Starting server and loading settings...`);
(Object.keys(Settings) as Array<keyof typeof Settings>).forEach((key) => {
if (key === 'SECRET_KEY' || key === 'BRANDING') {
if (Settings[key]) {
console.log(`|INF| server > init: ${key} = ${Settings[key].replace(/./g, '*').slice(0, 32)}`)
console.log(
`|INF| server > init: ${key} = ${Settings[key].replace(/./g, '*').slice(0, 32)}`
);
}
if (key === 'SECRET_KEY' && !Settings[key]) {
console.warn(`|WRN| server > init: ${key} = ${Settings[key]}`);
@@ -21,9 +28,11 @@ console.log(`|INF| server > init: Starting server and loading settings...`);
return;
}
console.log(`|INF| server > init: ${key} = ${Settings[key]}`);
})
});
if (!Settings.SECRET_KEY) {
console.warn(`|WRN| server > init: SECRET_KEY is not set, data encryption is disabled! `);
console.warn(
`|WRN| server > init: SECRET_KEY is not set, data encryption is disabled! `
);
}
const rootUrl = (req: Request) =>
@@ -37,7 +46,9 @@ app.use(express.urlencoded({ extended: true }));
app.use((req, res, next) => {
res.append('Access-Control-Allow-Origin', '*');
res.append('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE');
console.log(`|DBG| server > ${req.method} ${req.path.replace(/\/eyJ[\w\=]+/g, '/*******').replace(/\/E-[\w-]+/g, '/*******')}`);
console.log(
`|DBG| server > ${req.method} ${req.path.replace(/\/eyJ[\w\=]+/g, '/*******').replace(/\/E-[\w-]+/g, '/*******')}`
);
next();
});
@@ -45,9 +56,12 @@ app.get('/', (req, res) => {
res.redirect('/configure');
});
app.get(['/_next/*', '/assets/*', '/icon.ico', '/configure.txt', ], (req, res) => {
res.sendFile(path.join(__dirname, '../../frontend/out', req.path));
});
app.get(
['/_next/*', '/assets/*', '/icon.ico', '/configure.txt'],
(req, res) => {
res.sendFile(path.join(__dirname, '../../frontend/out', req.path));
}
);
app.get('/configure', (req, res) => {
res.sendFile(path.join(__dirname, '../../frontend/out/configure.html'));
@@ -55,10 +69,12 @@ app.get('/configure', (req, res) => {
app.get('/:config/configure', (req, res) => {
const config = req.params.config;
if (config.startsWith("E-")) {
if (config.startsWith('E-')) {
if (!Settings.SECRET_KEY) {
res.status(302).redirect('/configure');
console.log(`|INF| server > Received encrypted config but no secret key set, redirecting to /configure`);
console.log(
`|INF| server > Received encrypted config but no secret key set, redirecting to /configure`
);
return;
}
const encryptedConfig = config.replace('E-', '');
@@ -74,12 +90,17 @@ app.get('/:config/configure', (req, res) => {
// encrypt the value and replace the value with the encrypted value
Object.keys(service.credentials).forEach((key) => {
const value = service.credentials[key];
if (value && value.match(/^E-[0-9a-fA-F]{32}-[0-9a-fA-F]+$/) === null) {
if (
value &&
value.match(/^E-[0-9a-fA-F]{32}-[0-9a-fA-F]+$/) === null
) {
// if the value is not already encrypted, encrypt it
try {
service.credentials[key] = compressAndEncrypt(value);
} catch (error: any) {
console.error(`Failed to encrypt ${key} for service ${service.id}`);
console.error(
`Failed to encrypt ${key} for service ${service.id}`
);
service.credentials[key] = '';
}
}
@@ -88,25 +109,46 @@ app.get('/:config/configure', (req, res) => {
});
}
// also encrypt the apiPassword, proxyUrl, and publicIp if they are not already encrypted
if (configJson.mediaFlowConfig?.apiPassword && configJson.mediaFlowConfig.apiPassword.match(/^E-[0-9a-fA-F]{32}-[0-9a-fA-F]+$/) === null) {
if (
configJson.mediaFlowConfig?.apiPassword &&
configJson.mediaFlowConfig.apiPassword.match(
/^E-[0-9a-fA-F]{32}-[0-9a-fA-F]+$/
) === null
) {
try {
configJson.mediaFlowConfig.apiPassword = compressAndEncrypt(configJson.mediaFlowConfig.apiPassword);
configJson.mediaFlowConfig.apiPassword = compressAndEncrypt(
configJson.mediaFlowConfig.apiPassword
);
} catch (error: any) {
console.error(`Failed to encrypt apiPassword for MediaFlow`);
configJson.mediaFlowConfig.apiPassword = '';
}
}
if (configJson.mediaFlowConfig?.proxyUrl && configJson.mediaFlowConfig.proxyUrl.match(/^E-[0-9a-fA-F]{32}-[0-9a-fA-F]+$/) === null) {
if (
configJson.mediaFlowConfig?.proxyUrl &&
configJson.mediaFlowConfig.proxyUrl.match(
/^E-[0-9a-fA-F]{32}-[0-9a-fA-F]+$/
) === null
) {
try {
configJson.mediaFlowConfig.proxyUrl = compressAndEncrypt(configJson.mediaFlowConfig.proxyUrl);
configJson.mediaFlowConfig.proxyUrl = compressAndEncrypt(
configJson.mediaFlowConfig.proxyUrl
);
} catch (error: any) {
console.error(`Failed to encrypt proxyUrl for MediaFlow`);
configJson.mediaFlowConfig.proxyUrl = '';
}
}
if (configJson.mediaFlowConfig?.publicIp && configJson.mediaFlowConfig.publicIp.match(/^E-[0-9a-fA-F]{32}-[0-9a-fA-F]+$/) === null) {
if (
configJson.mediaFlowConfig?.publicIp &&
configJson.mediaFlowConfig.publicIp.match(
/^E-[0-9a-fA-F]{32}-[0-9a-fA-F]+$/
) === null
) {
try {
configJson.mediaFlowConfig.publicIp = compressAndEncrypt(configJson.mediaFlowConfig.publicIp);
configJson.mediaFlowConfig.publicIp = compressAndEncrypt(
configJson.mediaFlowConfig.publicIp
);
} catch (error: any) {
console.error(`Failed to encrypt publicIp for MediaFlow`);
configJson.mediaFlowConfig.publicIp = '';
@@ -120,8 +162,14 @@ app.get('/:config/configure', (req, res) => {
Object.keys(addon.options).forEach((key) => {
const value = addon.options[key];
console.log(`|DBG| server > ${key} = ${value}`);
if (value && value.match(/^E-[0-9a-fA-F]{32}-[0-9a-fA-F]+$/) === null && key.match(/url/i)) {
console.log(`|DBG| server > Encrypting ${key} for addon ${addon.id}`);
if (
value &&
value.match(/^E-[0-9a-fA-F]{32}-[0-9a-fA-F]+$/) === null &&
key.match(/url/i)
) {
console.log(
`|DBG| server > Encrypting ${key} for addon ${addon.id}`
);
try {
addon.options[key] = compressAndEncrypt(value);
} catch (error: any) {
@@ -133,11 +181,13 @@ app.get('/:config/configure', (req, res) => {
}
});
}
const base64Config = Buffer.from(JSON.stringify(configJson)).toString('base64');
const base64Config = Buffer.from(JSON.stringify(configJson)).toString(
'base64'
);
res.redirect(`/${base64Config}/configure`);
return;
}
res.sendFile(path.join(__dirname, '../../frontend/out/configure.html'));
res.sendFile(path.join(__dirname, '../../frontend/out/configure.html'));
});
app.get('/manifest.json', (req, res) => {
@@ -160,7 +210,9 @@ app.get('/:config/stream/:type/:id.json', (req, res: Response): void => {
let configJson: Config;
if (config.startsWith('E-')) {
if (!Settings.SECRET_KEY) {
console.error(`|ERR| server > Secret key not set, cannot decrypt encrypted config`);
console.error(
`|ERR| server > Secret key not set, cannot decrypt encrypted config`
);
res.status(200).json(invalidConfig(rootUrl(req), 'Secret key not set'));
return;
}
@@ -171,17 +223,21 @@ app.get('/:config/stream/:type/:id.json', (req, res: Response): void => {
}
configJson = JSON.parse(decryptedConfig);
} catch (error: any) {
res.status(200).json(invalidConfig(rootUrl(req), 'Unable to decrypt config'));
res
.status(200)
.json(invalidConfig(rootUrl(req), 'Unable to decrypt config'));
return;
}
} else {
// Decode Base64 encoded JSON config
const decodedConfig = Buffer.from(config, 'base64').toString('utf-8');
try {
configJson = JSON.parse((decodedConfig));
configJson = JSON.parse(decodedConfig);
} catch (error: any) {
console.error(`|ERR| server > Unable to parse config: ${error.message}`);
res.status(200).json(invalidConfig(rootUrl(req), 'Unable to parse config'));
res
.status(200)
.json(invalidConfig(rootUrl(req), 'Unable to parse config'));
return;
}
}
@@ -195,39 +251,57 @@ app.get('/:config/stream/:type/:id.json', (req, res: Response): void => {
const value = service.credentials[key];
const decrypted = parseAndDecryptString(value);
if (decrypted === null) {
console.error(`|ERR| server > Failed to decrypt ${key} for service ${service.id}`);
res.status(200).json(invalidConfig(rootUrl(req), 'Failed to decrypt config'));
console.error(
`|ERR| server > Failed to decrypt ${key} for service ${service.id}`
);
res
.status(200)
.json(invalidConfig(rootUrl(req), 'Failed to decrypt config'));
return;
}
service.credentials[key] = decrypted;
});
}
})
};
});
}
// also decrypt the apiPassword, proxyUrl, and publicIp if they are encrypted
if (configJson.mediaFlowConfig?.apiPassword) {
const decrypted = parseAndDecryptString(configJson.mediaFlowConfig.apiPassword);
const decrypted = parseAndDecryptString(
configJson.mediaFlowConfig.apiPassword
);
if (!decrypted) {
console.error(`|ERR| server > Failed to decrypt apiPassword for MediaFlow`);
res.status(200).json(invalidConfig(rootUrl(req), 'Failed to decrypt config'));
console.error(
`|ERR| server > Failed to decrypt apiPassword for MediaFlow`
);
res
.status(200)
.json(invalidConfig(rootUrl(req), 'Failed to decrypt config'));
return;
}
configJson.mediaFlowConfig.apiPassword = decrypted;
}
if (configJson.mediaFlowConfig?.proxyUrl) {
const decrypted = parseAndDecryptString(configJson.mediaFlowConfig.proxyUrl);
const decrypted = parseAndDecryptString(
configJson.mediaFlowConfig.proxyUrl
);
if (!decrypted) {
console.error(`|ERR| server > Failed to decrypt proxyUrl for MediaFlow`);
res.status(200).json(invalidConfig(rootUrl(req), 'Failed to decrypt config'));
res
.status(200)
.json(invalidConfig(rootUrl(req), 'Failed to decrypt config'));
return;
}
configJson.mediaFlowConfig.proxyUrl = decrypted;
}
if (configJson.mediaFlowConfig?.publicIp) {
const decrypted = parseAndDecryptString(configJson.mediaFlowConfig.publicIp);
const decrypted = parseAndDecryptString(
configJson.mediaFlowConfig.publicIp
);
if (!decrypted) {
console.error(`|ERR| server > Failed to decrypt publicIp for MediaFlow`);
res.status(200).json(invalidConfig(rootUrl(req), 'Failed to decrypt config'));
res
.status(200)
.json(invalidConfig(rootUrl(req), 'Failed to decrypt config'));
return;
}
configJson.mediaFlowConfig.publicIp = decrypted;
@@ -240,8 +314,12 @@ app.get('/:config/stream/:type/:id.json', (req, res: Response): void => {
const value = addon.options[key];
const decrypted = parseAndDecryptString(value);
if (decrypted === null) {
console.error(`|ERR| server > Failed to decrypt ${key} for addon ${addon.id}`);
res.status(200).json(invalidConfig(rootUrl(req), 'Failed to decrypt config'));
console.error(
`|ERR| server > Failed to decrypt ${key} for addon ${addon.id}`
);
res
.status(200)
.json(invalidConfig(rootUrl(req), 'Failed to decrypt config'));
return;
}
addon.options[key] = decrypted;
@@ -249,8 +327,6 @@ app.get('/:config/stream/:type/:id.json', (req, res: Response): void => {
}
});
}
const decodedPath = decodeURIComponent(req.path);
@@ -278,7 +354,9 @@ app.get('/:config/stream/:type/:id.json', (req, res: Response): void => {
try {
const { valid, errorCode, errorMessage } = validateConfig(configJson);
if (!valid) {
console.error(`|ERR| server > Received invalid config: ${errorCode} - ${errorMessage}`);
console.error(
`|ERR| server > Received invalid config: ${errorCode} - ${errorMessage}`
);
res
.status(200)
.json(invalidConfig(rootUrl(req), errorMessage ?? 'Unknown'));
+16 -6
View File
@@ -8,13 +8,15 @@ export function gdriveFormat(stream: ParsedStream): {
} {
let name: string = '';
if (stream.provider) {
if (stream.provider) {
const cacheStatus = stream.provider.cached
? '⚡'
: stream.provider.cached === undefined
? '❓'
: '⏳';
const serviceShortName = serviceDetails.find((service) => service.id === stream.provider!.id)?.shortName || stream.provider.id;
const serviceShortName =
serviceDetails.find((service) => service.id === stream.provider!.id)
?.shortName || stream.provider.id;
name += `[${serviceShortName}${cacheStatus}] `;
}
@@ -36,14 +38,22 @@ export function gdriveFormat(stream: ParsedStream): {
description +=
stream.audioTags.length > 0 ? `🎧 ${stream.audioTags.join(' | ')}` : '';
}
if (stream.size || stream.torrent?.seeders || stream.usenet?.age || stream.duration) {
if (
stream.size ||
stream.torrent?.seeders ||
stream.usenet?.age ||
stream.duration
) {
description += '\n';
description += `📦 ${formatSize(stream.size || 0)} `;
description += stream.duration ? `⏱️ ${formatDuration(stream.duration)} ` : '';
description += stream.torrent?.seeders !== undefined
? `👥 ${stream.torrent.seeders}`
description += stream.duration
? `⏱️ ${formatDuration(stream.duration)} `
: '';
description +=
stream.torrent?.seeders !== undefined
? `👥 ${stream.torrent.seeders}`
: '';
description += stream.usenet?.age ? `📅 ${stream.usenet.age}` : '';
}
+3 -1
View File
@@ -10,7 +10,9 @@ export function torboxFormat(stream: ParsedStream): {
name += `${stream.addon.name}`;
if (stream.provider) {
const serviceShortName = serviceDetails.find((service) => service.id === stream.provider!.id)?.shortName || stream.provider.id;
const serviceShortName =
serviceDetails.find((service) => service.id === stream.provider!.id)
?.shortName || stream.provider.id;
name += ` (${serviceShortName}${stream.provider.cached === undefined ? ' Unknown' : stream.provider.cached ? ' Instant' : ''})`;
}
+7 -4
View File
@@ -14,7 +14,9 @@ export function torrentioFormat(stream: ParsedStream): {
: stream.provider.cached === undefined
? ' Unknown'
: ' download';
const serviceShortName = serviceDetails.find((service) => service.id === stream.provider!.id)?.shortName || stream.provider.id;
const serviceShortName =
serviceDetails.find((service) => service.id === stream.provider!.id)
?.shortName || stream.provider.id;
name += `[${serviceShortName}${cacheStatus}] `;
}
@@ -37,9 +39,10 @@ export function torrentioFormat(stream: ParsedStream): {
) {
description += '\n';
description += stream.torrent?.seeders !== undefined
? `👤 ${stream.torrent.seeders} `
: '';
description +=
stream.torrent?.seeders !== undefined
? `👤 ${stream.torrent.seeders} `
: '';
description += stream.usenet?.age ? `📅 ${stream.usenet.age} ` : '';
+1 -1
View File
@@ -88,5 +88,5 @@ const languageEmojiMap: Record<string, string> = {
norwegian: '🇳🇴',
malay: '🇲🇾',
latino: '💃🏻',
Latino: '🇲🇽'
Latino: '🇲🇽',
};
+4 -3
View File
@@ -1,6 +1,6 @@
import type { NextConfig } from 'next';
import dotenv from 'dotenv';
import path from 'path'
import path from 'path';
try {
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
@@ -8,7 +8,8 @@ try {
console.error('Error loading .env file:', error);
}
const branding = process.env.NEXT_PUBLIC_ELFHOSTED_BRANDING ?? process.env.BRANDING;
const branding =
process.env.NEXT_PUBLIC_ELFHOSTED_BRANDING ?? process.env.BRANDING;
if (branding) {
console.log(`Branding set`);
@@ -23,7 +24,7 @@ const nextConfig: NextConfig = {
},
env: {
NEXT_PUBLIC_BRANDING: branding,
}
},
};
export default nextConfig;
@@ -13,7 +13,6 @@
margin: 0 auto; /* Center the header within the content */
}
.content {
background-color: #000000; /* Slightly lighter black */
border-radius: var(--borderRadius);
@@ -143,12 +142,18 @@
border-radius: var(--borderRadius);
border-color: #777777;
padding: 10px;
transition: margin-top 0.4s, opacity 0.3s, transform 0.6s;
transition:
margin-top 0.4s,
opacity 0.3s,
transform 0.6s;
}
.mediaFlowConfig.hidden {
opacity: 0;
margin-top: -340px;
transform: scale(0);
transition: margin-top 0.5s, opacity 0.2s, transform 0.4s;
}
transition:
margin-top 0.5s,
opacity 0.2s,
transform 0.4s;
}
+329 -215
View File
@@ -51,12 +51,12 @@ const defaultQualities: Quality[] = [
];
const defaultVisualTags: VisualTag[] = [
{ "HDR+DV": true },
{ 'HDR+DV': true },
{ 'HDR10+': true },
{ HDR10: true },
{ HDR: true },
{ DV: true },
{ "3D": true },
{ '3D': true },
{ IMAX: true },
{ AI: true },
];
@@ -86,10 +86,10 @@ const defaultEncodes: Encode[] = [
const defaultSortCriteria: SortBy[] = [
{ cached: true, direction: 'desc' },
{ resolution: true },
{ language: true},
{ language: true },
{ size: true, direction: 'desc' },
{ visualTag: false },
{ service: false, },
{ service: false },
{ audioTag: false },
{ encode: false },
{ quality: false },
@@ -129,7 +129,6 @@ const defaultResolutions: Resolution[] = [
{ Unknown: true },
];
const defaultServices = serviceDetails.map((service) => ({
name: service.name,
id: service.id,
@@ -138,7 +137,7 @@ const defaultServices = serviceDetails.map((service) => ({
}));
const selectStyles: StylesConfig = {
control: (baseStyles: any, state: { isFocused: boolean; }) => ({
control: (baseStyles: any, state: { isFocused: boolean }) => ({
...baseStyles,
borderWidth: '0px',
backgroundColor: 'white',
@@ -149,10 +148,12 @@ const selectStyles: StylesConfig = {
margin: '10px 0 0 -0px',
transition: 'border-color 0.2s, box-shadow 0.2s',
boxShadow: state.isFocused ? '0 0 0 3px rgb(112, 112, 112)' : 'none',
"&:hover": {
'&:hover': {
borderColor: '#5c5c5c',
boxShadow: state.isFocused ? '0 0 0 3px rgb(128, 128, 128)' : '0 0 0 2px rgb(161, 161, 161)',
}
boxShadow: state.isFocused
? '0 0 0 3px rgb(128, 128, 128)'
: '0 0 0 2px rgb(161, 161, 161)',
},
}),
input: (baseStyles: any) => ({
...baseStyles,
@@ -176,7 +177,7 @@ const selectStyles: StylesConfig = {
...baseStyles,
color: 'white',
transition: 'color 0.2s',
"&:hover": {
'&:hover': {
backgroundColor: 'transparent',
color: 'rgb(141, 141, 141)',
cursor: 'pointer',
@@ -192,17 +193,17 @@ const selectStyles: StylesConfig = {
valueContainer: (baseStyles: any) => ({
...baseStyles,
}),
option: (baseStyles: any, state: { isFocused: any; }) => ({
option: (baseStyles: any, state: { isFocused: any }) => ({
...baseStyles,
color: state.isFocused ? 'white' : 'black',
backgroundColor: state.isFocused ? 'rgb(68, 68, 68)' : 'white',
"&:hover": {
'&:hover': {
backgroundColor: 'rgb(68, 68, 68)', //'#9c9c9c',
},
"&:active": {
transition: 'background-color 0.4s, color 0.1s',
backgroundColor: 'rgb(26, 26, 26)',
color: 'white',
'&:active': {
transition: 'background-color 0.4s, color 0.1s',
backgroundColor: 'rgb(26, 26, 26)',
color: 'white',
},
}),
};
@@ -221,8 +222,12 @@ export default function Configure() {
const [services, setServices] = useState<Config['services']>(defaultServices);
const [onlyShowCachedStreams, setOnlyShowCachedStreams] =
useState<boolean>(false);
const [prioritisedLanguages, setPrioritisedLanguages] = useState<string[] | null>(null);
const [excludedLanguages, setExcludedLanguages] = useState<string[] | null>(null);
const [prioritisedLanguages, setPrioritisedLanguages] = useState<
string[] | null
>(null);
const [excludedLanguages, setExcludedLanguages] = useState<string[] | null>(
null
);
const [addons, setAddons] = useState<Config['addons']>([]);
/*
const [maxSize, setMaxSize] = useState<number | null>(null);
@@ -232,15 +237,20 @@ export default function Configure() {
const [minMovieSize, setMinMovieSize] = useState<number | null>(null);
const [maxEpisodeSize, setMaxEpisodeSize] = useState<number | null>(null);
const [minEpisodeSize, setMinEpisodeSize] = useState<number | null>(null);
const [addonNameInDescription, setAddonNameInDescription] = useState<boolean>(false);
const [addonNameInDescription, setAddonNameInDescription] =
useState<boolean>(false);
const [cleanResults, setCleanResults] = useState<boolean>(false);
const [maxResultsPerResolution, setMaxResultsPerResolution] = useState<number | null>(null);
const [maxResultsPerResolution, setMaxResultsPerResolution] = useState<
number | null
>(null);
const [mediaFlowEnabled, setMediaFlowEnabled] = useState<boolean>(false);
const [mediaFlowProxyUrl, setmediaFlowProxyUrl] = useState<string>('');
const [mediaFlowApiPassword, setmediaFlowApiPassword] = useState<string>('');
const [mediaFlowPublicIp, setMediaFlowPublicIp] = useState<string>('');
const [disableButtons, setDisableButtons] = useState<boolean>(false);
const [manualManifestUrl, setManualManifestUrl] = useState<string | null>(null);
const [manualManifestUrl, setManualManifestUrl] = useState<string | null>(
null
);
const getChoosableAddons = () => {
// only if torbox service is enabled we can use torbox addon
@@ -288,14 +298,18 @@ export default function Configure() {
mediaFlowEnabled,
proxyUrl: mediaFlowProxyUrl,
apiPassword: mediaFlowApiPassword,
publicIp: mediaFlowPublicIp
publicIp: mediaFlowPublicIp,
},
addons,
services,
};
};
const fetchWithTimeout = async (url: string, options: RequestInit | undefined, timeoutMs = 5000) => {
const fetchWithTimeout = async (
url: string,
options: RequestInit | undefined,
timeoutMs = 5000
) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
@@ -309,42 +323,61 @@ export default function Configure() {
}
};
const getManifestUrl = async (protocol = window.location.protocol, root = window.location.host) => {
const getManifestUrl = async (
protocol = window.location.protocol,
root = window.location.host
) => {
const config = createConfig();
console.log('Config', config);
setDisableButtons(true);
try {
const encryptPath = `/encrypt-user-data`;
const response = await fetchWithTimeout(encryptPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: JSON.stringify(config) }),
});
if (!response) {
throw new Error('encrypt-user-data failed: no response within timeout');
}
if (!response.ok) {
throw new Error(`encrypt-user-data failed with status ${response.status}`);
}
const encryptPath = `/encrypt-user-data`;
const response = await fetchWithTimeout(encryptPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: JSON.stringify(config) }),
});
if (!response) {
throw new Error('encrypt-user-data failed: no response within timeout');
}
if (!response.ok) {
throw new Error(
`encrypt-user-data failed with status ${response.status}`
);
}
const data = await response.json();
if (!data.success) throw new Error(`Encryption service failed, ${data.message}`);
const data = await response.json();
if (!data.success)
throw new Error(`Encryption service failed, ${data.message}`);
const encryptedConfig = data.data;
return { success: true, manifest: `${protocol}//${root}/${encryptedConfig}/manifest.json` };
const encryptedConfig = data.data;
return {
success: true,
manifest: `${protocol}//${root}/${encryptedConfig}/manifest.json`,
};
} catch (error: any) {
console.error('Error during encryption:', error.message, '\nFalling back to base64 encoding');
try {
const base64Config = btoa(JSON.stringify(config));
return { success: true, manifest: `${protocol}//${root}/${base64Config}/manifest.json` };
} catch (base64Error: any) {
console.error('Error during base64 encoding:', base64Error.message);
return { success: false, manifest: null, message: 'Failed to encode config' };
}
console.error(
'Error during encryption:',
error.message,
'\nFalling back to base64 encoding'
);
try {
const base64Config = btoa(JSON.stringify(config));
return {
success: true,
manifest: `${protocol}//${root}/${base64Config}/manifest.json`,
};
} catch (base64Error: any) {
console.error('Error during base64 encoding:', base64Error.message);
return {
success: false,
manifest: null,
message: 'Failed to encode config',
};
}
}
};
};
const createAndValidateConfig = () => {
const config = createConfig();
@@ -408,7 +441,9 @@ export default function Configure() {
}
};
const handleInstallToWeb = async (event: React.MouseEvent<HTMLButtonElement>) => {
const handleInstallToWeb = async (
event: React.MouseEvent<HTMLButtonElement>
) => {
event.preventDefault();
if (createAndValidateConfig()) {
const id = toast.loading('Generating manifest URL...', toastOptions);
@@ -449,7 +484,9 @@ export default function Configure() {
autoClose: 5000,
isLoading: false,
});
setManualManifestUrl(`https://web.stremio.com/#/addons?addon=${encodedManifestUrl}`);
setManualManifestUrl(
`https://web.stremio.com/#/addons?addon=${encodedManifestUrl}`
);
}
setDisableButtons(false);
}
@@ -470,16 +507,19 @@ export default function Configure() {
setDisableButtons(false);
return;
}
navigator.clipboard.writeText(manifestUrl.manifest).then(() => {
toast.update(id, {
render: 'Manifest URL copied to clipboard',
type: 'success',
autoClose: 5000,
toastId: 'copiedManifestUrl',
isLoading: false,
});
setManualManifestUrl(null);
}).catch((err: any) => {
navigator.clipboard
.writeText(manifestUrl.manifest)
.then(() => {
toast.update(id, {
render: 'Manifest URL copied to clipboard',
type: 'success',
autoClose: 5000,
toastId: 'copiedManifestUrl',
isLoading: false,
});
setManualManifestUrl(null);
})
.catch((err: any) => {
console.error('Failed to copy manifest URL to clipboard', err);
toast.update(id, {
render: 'Failed to copy manifest URL to clipboard.',
@@ -488,7 +528,7 @@ export default function Configure() {
isLoading: false,
});
setManualManifestUrl(manifestUrl.manifest);
});
});
setDisableButtons(false);
}
};
@@ -513,9 +553,11 @@ export default function Configure() {
return defaultSortCriteria;
}
return defaultSortCriteria.map((defaultSort) => {
// we find the one with the same key in the first index.
// we find the one with the same key in the first index.
// and we only load direction if it exists in the defaultSort
const sort = sortCriteria.find((s) => Object.keys(s)[0] === Object.keys(defaultSort)[0]);
const sort = sortCriteria.find(
(s) => Object.keys(s)[0] === Object.keys(defaultSort)[0]
);
if (sort && defaultSort.direction) {
return { ...sort, direction: sort.direction || defaultSort.direction };
}
@@ -555,7 +597,7 @@ export default function Configure() {
};
// Load config from the window path if it exists
useEffect(() => {
useEffect(() => {
setIsClient(true);
async function decodeConfig(config: string) {
let decodedConfig: Config;
@@ -570,10 +612,7 @@ export default function Configure() {
function loadFromConfig(decodedConfig: Config) {
console.log('Loaded config', decodedConfig);
setResolutions(
loadValidValuesFromObject(
decodedConfig.resolutions,
defaultResolutions
)
loadValidValuesFromObject(decodedConfig.resolutions, defaultResolutions)
);
setQualities(
loadValidValuesFromObject(decodedConfig.qualities, defaultQualities)
@@ -587,12 +626,11 @@ export default function Configure() {
setEncodes(
loadValidValuesFromObject(decodedConfig.encodes, defaultEncodes)
);
setSortCriteria(
loadValidSortCriteria(decodedConfig.sortBy)
);
setSortCriteria(loadValidSortCriteria(decodedConfig.sortBy));
setOnlyShowCachedStreams(decodedConfig.onlyShowCachedStreams || false);
// create an array for prioritised languages. if the old prioritiseLanguage is set, add it to the array
const finalPrioritisedLanguages = decodedConfig.prioritisedLanguages || [];
const finalPrioritisedLanguages =
decodedConfig.prioritisedLanguages || [];
if (decodedConfig.prioritiseLanguage) {
finalPrioritisedLanguages.push(decodedConfig.prioritiseLanguage);
}
@@ -626,7 +664,9 @@ export default function Configure() {
setAddonNameInDescription(decodedConfig.addonNameInDescription || false);
setCleanResults(decodedConfig.cleanResults || false);
setMaxResultsPerResolution(decodedConfig.maxResultsPerResolution || null);
setMediaFlowEnabled(decodedConfig.mediaFlowConfig?.mediaFlowEnabled || false);
setMediaFlowEnabled(
decodedConfig.mediaFlowConfig?.mediaFlowEnabled || false
);
setmediaFlowProxyUrl(decodedConfig.mediaFlowConfig?.proxyUrl || '');
setmediaFlowApiPassword(decodedConfig.mediaFlowConfig?.apiPassword || '');
}
@@ -659,16 +699,28 @@ export default function Configure() {
<h1 style={{ textAlign: 'center' }}>AIOStreams</h1>
<span className={styles.version}>v{version}</span>
</div>
{process.env.NEXT_PUBLIC_BRANDING && <div className={styles.branding} dangerouslySetInnerHTML={{ __html: process.env.NEXT_PUBLIC_BRANDING || '' }} />}
{process.env.NEXT_PUBLIC_BRANDING && (
<div
className={styles.branding}
dangerouslySetInnerHTML={{
__html: process.env.NEXT_PUBLIC_BRANDING || '',
}}
/>
)}
<p style={{ textAlign: 'center', padding: '15px' }}>
AIOStreams, the all-in-one streaming addon for Stremio. Combine your
streams from all your addons into one and filter them by resolution,
quality, visual tags and more.
<br /><br/>
This addon will return any result from the addons you enable. These can be P2P results, direct links, or anything else.
Results that are P2P are marked as P2P, however.
<br /><br/>
This addon also has no persistence. Nothing you enter here is stored. They are encrypted within the manifest URL and are only used to retrieve streams from any addons you enable.
<br />
<br />
This addon will return any result from the addons you enable. These
can be P2P results, direct links, or anything else. Results that are
P2P are marked as P2P, however.
<br />
<br />
This addon also has no persistence. Nothing you enter here is
stored. They are encrypted within the manifest URL and are only used
to retrieve streams from any addons you enable.
</p>
<p style={{ textAlign: 'center', padding: '15px' }}>
Made by Viren070. Source code on{' '}
@@ -682,7 +734,7 @@ export default function Configure() {
</a>
</p>
</div>
<div className={styles.section}>
<h2 style={{ padding: '5px' }}>Services</h2>
<p style={{ padding: '5px' }}>
@@ -733,7 +785,10 @@ export default function Configure() {
const [movedService] = newServices.splice(serviceIndex, 1);
if (direction === 'up' && serviceIndex > 0) {
newServices.splice(serviceIndex - 1, 0, movedService);
} else if (direction === 'down' && serviceIndex < newServices.length) {
} else if (
direction === 'down' &&
serviceIndex < newServices.length
) {
newServices.splice(serviceIndex + 1, 0, movedService);
}
setServices(newServices);
@@ -836,72 +891,110 @@ export default function Configure() {
</div>
<div className={styles.section}>
<h2
style={{ padding: '5px', margin: '0px '}}
>Languages
</h2>
<p style={{margin: '5px 0 12px 5px' }}>
Choose which languages you want to prioritise and exclude from the results
<h2 style={{ padding: '5px', margin: '0px ' }}>Languages</h2>
<p style={{ margin: '5px 0 12px 5px' }}>
Choose which languages you want to prioritise and exclude from the
results
</p>
<div className={styles.section}>
<div>
<h3 style={{ margin: '2px 0 2px 0'}}>Prioritise Languages</h3>
<p style={{ margin: '10px 0 10px 0'}}>
Any results that are detected to have one of the prioritised languages will be sorted according to your sort criteria.
You must have the <code>Langage</code> sort criteria enabled for this to work.
If there are multiple results with a different prioritised language, the order is determined by the order of the prioritised languages.
<h3 style={{ margin: '2px 0 2px 0' }}>Prioritise Languages</h3>
<p style={{ margin: '10px 0 10px 0' }}>
Any results that are detected to have one of the prioritised
languages will be sorted according to your sort criteria. You
must have the <code>Langage</code> sort criteria enabled for
this to work. If there are multiple results with a different
prioritised language, the order is determined by the order of
the prioritised languages.
</p>
</div>
<div>
{isClient ? ( // https://github.com/JedWatson/react-select/issues/5859
<Select
isMulti
closeMenuOnSelect={false}
options={allowedLanguages.sort((a,b) => a.localeCompare(b)).map((language) => ({ value: language, label: language }))}
value={prioritisedLanguages?.map((language) => ({ value: language, label: language })) || []}
onChange={(selectedOptions: any) => {
const selectedLanguages = selectedOptions.map((option: any) => option.value);
setPrioritisedLanguages(selectedLanguages || null);
}}
styles={selectStyles}
/>
) :
// render a fake select box until the actual one is rendered
<div style={{ height: '42px', margin: '0', backgroundColor: 'white', borderRadius: 'var(--borderRadius)', display: 'flex', alignItems: 'center'}}><p style={{margin: '10px', color: '#808090'}}>Select...</p></div>
}
<Select
isMulti
closeMenuOnSelect={false}
options={allowedLanguages
.sort((a, b) => a.localeCompare(b))
.map((language) => ({ value: language, label: language }))}
value={
prioritisedLanguages?.map((language) => ({
value: language,
label: language,
})) || []
}
onChange={(selectedOptions: any) => {
const selectedLanguages = selectedOptions.map(
(option: any) => option.value
);
setPrioritisedLanguages(selectedLanguages || null);
}}
styles={selectStyles}
/>
) : (
// render a fake select box until the actual one is rendered
<div
style={{
height: '42px',
margin: '0',
backgroundColor: 'white',
borderRadius: 'var(--borderRadius)',
display: 'flex',
alignItems: 'center',
}}
>
<p style={{ margin: '10px', color: '#808090' }}>Select...</p>
</div>
)}
</div>
</div>
<div style={{marginBottom: '0px'}} className={styles.section} >
<div style={{ marginBottom: '0px' }} className={styles.section}>
<div>
<h3
style={{ margin: '2px 0 2px 0'}}
>Exclude Languages</h3>
<p style={{ margin: '10px 0 10px 0'}}>
<h3 style={{ margin: '2px 0 2px 0' }}>Exclude Languages</h3>
<p style={{ margin: '10px 0 10px 0' }}>
Any results that are detected to have an excluded language will
be removed from the results. A result will only be excluded if
it only has one of or more of the excluded languages. If it contains a
language that is not excluded, it will still be included.
be removed from the results. A result will only be excluded if
it only has one of or more of the excluded languages. If it
contains a language that is not excluded, it will still be
included.
</p>
</div>
<div>
{isClient ? (
<Select
isMulti
closeMenuOnSelect={false}
options={allowedLanguages.sort((a,b) => a.localeCompare(b)).map((language) => ({ value: language, label: language }))}
value={excludedLanguages?.map((language) => ({ value: language, label: language })) || []}
onChange={(selectedOptions: any) => {
const selectedLanguages = selectedOptions.map((option: any) => option.value);
setExcludedLanguages(selectedLanguages || null);
}}
styles={selectStyles}
/>
) :
// render a fake select box until the actual one is rendered
<div style={{ height: '42px', margin: '0', backgroundColor: 'white', borderRadius: 'var(--borderRadius)', display: 'flex', alignItems: 'center'}}><p style={{margin: '10px', color: '#808090'}}>Select...</p></div>
}
<Select
isMulti
closeMenuOnSelect={false}
options={allowedLanguages
.sort((a, b) => a.localeCompare(b))
.map((language) => ({ value: language, label: language }))}
value={
excludedLanguages?.map((language) => ({
value: language,
label: language,
})) || []
}
onChange={(selectedOptions: any) => {
const selectedLanguages = selectedOptions.map(
(option: any) => option.value
);
setExcludedLanguages(selectedLanguages || null);
}}
styles={selectStyles}
/>
) : (
// render a fake select box until the actual one is rendered
<div
style={{
height: '42px',
margin: '0',
backgroundColor: 'white',
borderRadius: 'var(--borderRadius)',
display: 'flex',
alignItems: 'center',
}}
>
<p style={{ margin: '10px', color: '#808090' }}>Select...</p>
</div>
)}
</div>
</div>
</div>
@@ -911,7 +1004,8 @@ export default function Configure() {
<div>
<h2 style={{ padding: '5px' }}>Size Filter</h2>
<p style={{ padding: '5px' }}>
Filter streams by size. Leave the maximum and minimum size sliders at opposite ends to disable the filter.
Filter streams by size. Leave the maximum and minimum size
sliders at opposite ends to disable the filter.
</p>
</div>
<div className={styles.slidersContainer}>
@@ -927,7 +1021,9 @@ export default function Configure() {
</div>
<Slider
maxValue={Settings.MAX_MOVIE_SIZE}
value={maxMovieSize === null ? Settings.MAX_MOVIE_SIZE : maxMovieSize}
value={
maxMovieSize === null ? Settings.MAX_MOVIE_SIZE : maxMovieSize
}
setValue={setMaxMovieSize}
defaultValue="max"
id="maxMovieSizeSlider"
@@ -949,7 +1045,9 @@ export default function Configure() {
<Slider
maxValue={Settings.MAX_EPISODE_SIZE}
value={
maxEpisodeSize === null ? Settings.MAX_EPISODE_SIZE : maxEpisodeSize
maxEpisodeSize === null
? Settings.MAX_EPISODE_SIZE
: maxEpisodeSize
}
setValue={setMaxEpisodeSize}
defaultValue="max"
@@ -970,15 +1068,19 @@ export default function Configure() {
<div className={styles.settingDescription}>
<h2 style={{ padding: '5px' }}>Limit results per resolution</h2>
<p style={{ padding: '5px' }}>
Limit the number of results per resolution. Leave empty to show all results.
Limit the number of results per resolution. Leave empty to show
all results.
</p>
</div>
<div className={styles.settingInput}>
<input
type="number"
value={maxResultsPerResolution || ''}
onChange={(e) => setMaxResultsPerResolution(e.target.value ? parseInt(e.target.value) : null)}
onChange={(e) =>
setMaxResultsPerResolution(
e.target.value ? parseInt(e.target.value) : null
)
}
style={{
marginLeft: 'auto',
marginRight: '20px',
@@ -1018,8 +1120,10 @@ export default function Configure() {
<div className={styles.settingDescription}>
<h2 style={{ padding: '5px' }}>Move Addon Name to Description</h2>
<p style={{ padding: '5px' }}>
Move the addon name to the description of the stream. This will show <code>AIOStreams</code> as the stream title,
but move the name of the addon that the stream is from to the description. This is useful for Vidi users.
Move the addon name to the description of the stream. This will
show <code>AIOStreams</code> as the stream title, but move the
name of the addon that the stream is from to the description.
This is useful for Vidi users.
</p>
</div>
<div className={styles.settingInput}>
@@ -1037,14 +1141,18 @@ export default function Configure() {
/>
</div>
</div>
</div>
</div>
<div className={styles.section}>
<div className={styles.setting}>
<div className={styles.settingDescription}>
<h2 style={{ padding: '5px' }}>Clean Results</h2>
<p style={{ padding: '5px' }}>
Attempt to remove duplicate results. For a given file with duplicate streams: one uncached stream from all uncached streams is selected per provider. One cached stream from only one provider is selected. For duplicates without a provider, one stream is selected at random.
Attempt to remove duplicate results. For a given file with
duplicate streams: one uncached stream from all uncached streams
is selected per provider. One cached stream from only one
provider is selected. For duplicates without a provider, one
stream is selected at random.
</p>
</div>
<div className={styles.settingInput}>
@@ -1088,70 +1196,69 @@ export default function Configure() {
/>
</div>
</div>
{(
<div className={`${styles.mediaFlowConfig} ${mediaFlowEnabled ? '' : styles.hidden}`}>
<div>
{
<div
className={`${styles.mediaFlowConfig} ${mediaFlowEnabled ? '' : styles.hidden}`}
>
<div>
<h3 style={{ padding: '5px' }}>Proxy URL</h3>
<p style={{ padding: '5px' }}>
The URL of the MediaFlow proxy server
</p>
<div>
<h3 style={{ padding: '5px' }}>Proxy URL</h3>
<p style={{ padding: '5px' }}>
The URL of the MediaFlow proxy server
</p>
</div>
<div>
<CredentialInput
credential={mediaFlowProxyUrl}
setCredential={setmediaFlowProxyUrl}
inputProps={{
placeholder: 'Enter your MediaFlow proxy URL',
disabled: !mediaFlowEnabled,
}}
/>
</div>
</div>
<div>
<CredentialInput
credential={mediaFlowProxyUrl}
setCredential={setmediaFlowProxyUrl}
inputProps={
{
'placeholder': 'Enter your MediaFlow proxy URL',
'disabled': !mediaFlowEnabled
}
}
/>
<div>
<h3 style={{ padding: '5px' }}>API Password</h3>
<p style={{ padding: '5px' }}>
Your MediaFlow&apos;s API password
</p>
</div>
<div>
<CredentialInput
credential={mediaFlowApiPassword}
setCredential={setmediaFlowApiPassword}
inputProps={{
placeholder: 'Enter your MediaFlow API password',
disabled: !mediaFlowEnabled,
}}
/>
</div>
</div>
<div>
<div>
<h3 style={{ padding: '5px' }}>Public IP (Optional)</h3>
<p style={{ padding: '5px' }}>
Configure this only when running MediaFlow locally with a
proxy service. Leave empty if MediaFlow is configured
locally without a proxy server or if it&apos;s hosted on a
remote server.
</p>
</div>
<div>
<CredentialInput
credential={mediaFlowPublicIp}
setCredential={setMediaFlowPublicIp}
inputProps={{
placeholder: 'Enter your MediaFlow public IP',
disabled: !mediaFlowEnabled,
}}
/>
</div>
</div>
</div>
<div>
<div>
<h3 style={{ padding: '5px' }}>API Password</h3>
<p style={{ padding: '5px' }}>
Your MediaFlow&apos;s API password
</p>
</div>
<div>
<CredentialInput
credential={mediaFlowApiPassword}
setCredential={setmediaFlowApiPassword}
inputProps={
{
'placeholder': 'Enter your MediaFlow API password',
'disabled': !mediaFlowEnabled
}
}
/>
</div>
</div>
<div>
<div>
<h3 style={{ padding: '5px' }}>Public IP (Optional)</h3>
<p style={{ padding: '5px' }}>
Configure this only when running MediaFlow locally with a proxy service. Leave empty if MediaFlow is configured locally without a proxy server or if it&apos;s hosted on a remote server.
</p>
</div>
<div>
<CredentialInput
credential={mediaFlowPublicIp}
setCredential={setMediaFlowPublicIp}
inputProps={
{
'placeholder': 'Enter your MediaFlow public IP',
'disabled': !mediaFlowEnabled
}
}
/>
</div>
</div>
</div>
)}
}
</div>
<div className={styles.installButtons}>
@@ -1178,17 +1285,24 @@ export default function Configure() {
</button>
{manualManifestUrl && (
<>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<p style={{ padding: '5px' }}>
If the above buttons do not work, you can use the following manifest URL to install the addon.
</p>
<input
type="text"
value={manualManifestUrl}
readOnly
style={{ width: '100%', padding: '5px', margin: '5px' }}
/>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<p style={{ padding: '5px' }}>
If the above buttons do not work, you can use the following
manifest URL to install the addon.
</p>
<input
type="text"
value={manualManifestUrl}
readOnly
style={{ width: '100%', padding: '5px', margin: '5px' }}
/>
</div>
</>
)}
</div>
@@ -53,8 +53,6 @@
align-items: center;
}
.cardBody {
display: flex;
flex-direction: column;
@@ -110,4 +108,4 @@
.actionButton:active {
transform: scale(0.9);
}
}
@@ -182,8 +182,8 @@ const AddonsList: React.FC<AddonsListProps> = ({
)}
</label>
{option.description && <small>{option.description}</small>}
{option.type === 'text' && (
option.id.toLowerCase().includes('url') ? (
{option.type === 'text' &&
(option.id.toLowerCase().includes('url') ? (
<CredentialInput
credential={addon.options[option.id] || ''}
setCredential={(value) =>
@@ -203,8 +203,7 @@ const AddonsList: React.FC<AddonsListProps> = ({
}
className={styles.textInput}
/>
)
)}
))}
{option.type === 'select' && (
<select
value={addon.options[option.id] || ''}
@@ -18,7 +18,9 @@
color: rgb(104, 104, 104);
border-radius: var(--borderRadius);
cursor: pointer;
transition: color 0.3s ease, transform 0.2s;
transition:
color 0.3s ease,
transform 0.2s;
}
.resetCredentialButton:hover {
@@ -17,33 +17,35 @@ const CredentialInput: React.FC<CredentialInputProps> = ({
return (
<div className={styles.credentialsInputContainer}>
<input
type={isEncrypted(credential) ? 'password' : 'text'}
value={isEncrypted(credential) ? '••••••••••••••••••••••••' : credential}
onChange={(e) => setCredential(e.target.value.trim())}
className={styles.credentialInput}
{...inputProps}
disabled={isEncrypted(credential) ? true : inputProps.disabled || false}
type={isEncrypted(credential) ? 'password' : 'text'}
value={
isEncrypted(credential) ? '••••••••••••••••••••••••' : credential
}
onChange={(e) => setCredential(e.target.value.trim())}
className={styles.credentialInput}
{...inputProps}
disabled={isEncrypted(credential) ? true : inputProps.disabled || false}
/>
{isEncrypted(credential) && (
<button
className={styles.resetCredentialButton}
onClick={() => setCredential('')}
>
<svg
fill="currentColor"
version="1.1"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
width="26px"
height="26px"
viewBox="0 0 489.533 489.533"
<button
className={styles.resetCredentialButton}
onClick={() => setCredential('')}
>
<g>
<path d="M244.767,0C109.767,0,0,109.767,0,244.767s109.767,244.767,244.767,244.767s244.767-109.767,244.767-244.767 S379.767,0,244.767,0z M244.767,448.533c-112.267,0-203.767-91.5-203.767-203.767S132.5,40.999,244.767,40.999 S448.533,132.5,448.533,244.767S357.033,448.533,244.767,448.533z" />
<path d="M244.767,122.5c-67.5,0-122.5,55-122.5,122.5h40.999c0-45.5,36-81.5,81.5-81.5s81.5,36,81.5,81.5 c0,45.5-36,81.5-81.5,81.5c-22.5,0-42.5-9-57.5-24l40.999-40.999h-122.5v122.5l40.999-40.999c22.5,22.5,53.5,36,87.5,36 c67.5,0,122.5-55,122.5-122.5S312.267,122.5,244.767,122.5z" />
</g>
</svg>
</button>
<svg
fill="currentColor"
version="1.1"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
width="26px"
height="26px"
viewBox="0 0 489.533 489.533"
>
<g>
<path d="M244.767,0C109.767,0,0,109.767,0,244.767s109.767,244.767,244.767,244.767s244.767-109.767,244.767-244.767 S379.767,0,244.767,0z M244.767,448.533c-112.267,0-203.767-91.5-203.767-203.767S132.5,40.999,244.767,40.999 S448.533,132.5,448.533,244.767S357.033,448.533,244.767,448.533z" />
<path d="M244.767,122.5c-67.5,0-122.5,55-122.5,122.5h40.999c0-45.5,36-81.5,81.5-81.5s81.5,36,81.5,81.5 c0,45.5-36,81.5-81.5,81.5c-22.5,0-42.5-9-57.5-24l40.999-40.999h-122.5v122.5l40.999-40.999c22.5,22.5,53.5,36,87.5,36 c67.5,0,122.5-55,122.5-122.5S312.267,122.5,244.767,122.5z" />
</g>
</svg>
</button>
)}
</div>
);
@@ -118,25 +118,26 @@ const ServiceInput: React.FC<ServiceInputProps> = ({
{fields.map((field, index) => (
<div key={index} className={styles.field}>
<label>
{field.label}
{field.link && (
<>
{'. Get it '}
<a
href={field.link}
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
here
</a>
</>
)}
{field.value && isEncrypted(field.value) && (
<span className={styles.encrypted}>
{'. '}Encrypted. To modify this, please reset the API key using the button to the right
</span>
)}
{field.label}
{field.link && (
<>
{'. Get it '}
<a
href={field.link}
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
here
</a>
</>
)}
{field.value && isEncrypted(field.value) && (
<span className={styles.encrypted}>
{'. '}Encrypted. To modify this, please reset the API key
using the button to the right
</span>
)}
</label>
<CredentialInput
credential={field.value}
@@ -67,7 +67,12 @@
background-color: rgb(26, 26, 26);
border-radius: 0 0 var(--borderRadius) var(--borderRadius);
padding: 0;
transition: margin-top 0.4s ease, background-color 0.4s ease, opacity 0.4s ease, transform 1s ease, opacity 0.4s ease;
transition:
margin-top 0.4s ease,
background-color 0.4s ease,
opacity 0.4s ease,
transform 1s ease,
opacity 0.4s ease;
margin-top: -10px;
z-index: 0;
}
@@ -80,7 +85,11 @@
margin: 0;
border-radius: 0 0 var(--borderRadius) var(--borderRadius);
width: 100%;
transition: color 0.4s, background-color 0.4s, transform 1s, opacity 0.4s;
transition:
color 0.4s,
background-color 0.4s,
transform 1s,
opacity 0.4s;
}
.directionButton:hover {
@@ -91,7 +100,7 @@
background-color: transparent;
color: transparent;
opacity: 0.4;
transform: scale(0.1)
transform: scale(0.1);
}
.directionBar.disabled {
@@ -100,4 +109,4 @@
transform: scale(0.1);
opacity: 0.4;
border-radius: var(--borderRadius);
}
}
@@ -35,112 +35,119 @@ const SortableCardList: React.FC<SortableCardListProps> = ({
const toggleDirection = (itemKey: string) => {
const newItems = items.map((item) => {
if (Object.keys(item)[0] === itemKey) {
return { [itemKey]: item[itemKey], direction: item.direction === 'asc' ? 'desc' : 'asc' };
return {
[itemKey]: item[itemKey],
direction: item.direction === 'asc' ? 'desc' : 'asc',
};
}
return item;
});
setItems(newItems);
}
};
return (
<div className={styles.container}>
{items.map((item, index) => {
const itemKey = Object.keys(item)[0];
const isEnabled = typeof item[itemKey] === 'boolean' ? item[itemKey] : false;
const isEnabled =
typeof item[itemKey] === 'boolean' ? item[itemKey] : false;
const directionChangeable = item.direction;
return (
<div key={itemKey}>
<div
className={`${styles.card} ${!isEnabled ? styles.disabled : ''}`}
>
<div className={styles.actions}>
{index > 0 && (
<button
className={styles.arrowButton}
onClick={() => moveItem(index, 'up')}
disabled={!isEnabled}
>
<svg
fill="#ffffff"
version="1.1"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
width="32px"
height="32px"
viewBox="0 0 96.154 96.154"
xmlSpace="preserve"
stroke="#ffffff"
transform="matrix(1, 0, 0, -1, 0, 0)"
<div
className={`${styles.card} ${!isEnabled ? styles.disabled : ''}`}
>
<div className={styles.actions}>
{index > 0 && (
<button
className={styles.arrowButton}
onClick={() => moveItem(index, 'up')}
disabled={!isEnabled}
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g
id="SVGRepo_tracerCarrier"
strokeLinecap="round"
strokeLinejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
{' '}
<g>
<svg
fill="#ffffff"
version="1.1"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
width="32px"
height="32px"
viewBox="0 0 96.154 96.154"
xmlSpace="preserve"
stroke="#ffffff"
transform="matrix(1, 0, 0, -1, 0, 0)"
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g
id="SVGRepo_tracerCarrier"
strokeLinecap="round"
strokeLinejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
{' '}
<path d="M0.561,20.971l45.951,57.605c0.76,0.951,2.367,0.951,3.127,0l45.956-57.609c0.547-0.689,0.709-1.716,0.414-2.61 c-0.061-0.187-0.129-0.33-0.186-0.437c-0.351-0.65-1.025-1.056-1.765-1.056H2.093c-0.736,0-1.414,0.405-1.762,1.056 c-0.059,0.109-0.127,0.253-0.184,0.426C-0.15,19.251,0.011,20.28,0.561,20.971z"></path>{' '}
</g>{' '}
</g>
</svg>
</button>
)}
{index < items.length - 1 && (
<button
className={styles.arrowButton}
onClick={() => moveItem(index, 'down')}
disabled={!isEnabled}
>
<svg
fill="#ffffff"
version="1.1"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
width="32px"
height="32px"
viewBox="0 0 96.154 96.154"
stroke="#ffffff"
<g>
{' '}
<path d="M0.561,20.971l45.951,57.605c0.76,0.951,2.367,0.951,3.127,0l45.956-57.609c0.547-0.689,0.709-1.716,0.414-2.61 c-0.061-0.187-0.129-0.33-0.186-0.437c-0.351-0.65-1.025-1.056-1.765-1.056H2.093c-0.736,0-1.414,0.405-1.762,1.056 c-0.059,0.109-0.127,0.253-0.184,0.426C-0.15,19.251,0.011,20.28,0.561,20.971z"></path>{' '}
</g>{' '}
</g>
</svg>
</button>
)}
{index < items.length - 1 && (
<button
className={styles.arrowButton}
onClick={() => moveItem(index, 'down')}
disabled={!isEnabled}
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g
id="SVGRepo_tracerCarrier"
strokeLinecap="round"
strokeLinejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
{' '}
<g>
<svg
fill="#ffffff"
version="1.1"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
width="32px"
height="32px"
viewBox="0 0 96.154 96.154"
stroke="#ffffff"
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g
id="SVGRepo_tracerCarrier"
strokeLinecap="round"
strokeLinejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
{' '}
<path d="M0.561,20.971l45.951,57.605c0.76,0.951,2.367,0.951,3.127,0l45.956-57.609c0.547-0.689,0.709-1.716,0.414-2.61 c-0.061-0.187-0.129-0.33-0.186-0.437c-0.351-0.65-1.025-1.056-1.765-1.056H2.093c-0.736,0-1.414,0.405-1.762,1.056 c-0.059,0.109-0.127,0.253-0.184,0.426C-0.15,19.251,0.011,20.28,0.561,20.971z"></path>{' '}
</g>{' '}
</g>
</svg>
</button>
)}
<g>
{' '}
<path d="M0.561,20.971l45.951,57.605c0.76,0.951,2.367,0.951,3.127,0l45.956-57.609c0.547-0.689,0.709-1.716,0.414-2.61 c-0.061-0.187-0.129-0.33-0.186-0.437c-0.351-0.65-1.025-1.056-1.765-1.056H2.093c-0.736,0-1.414,0.405-1.762,1.056 c-0.059,0.109-0.127,0.253-0.184,0.426C-0.15,19.251,0.011,20.28,0.561,20.971z"></path>{' '}
</g>{' '}
</g>
</svg>
</button>
)}
</div>
<span>
<p style={{ fontSize: '1.2em' }}>{itemKey}</p>
</span>
<input
type="checkbox"
checked={isEnabled}
onChange={() => toggleItem(itemKey)}
className={styles.checkbox}
/>
</div>
<span>
<p style={{ fontSize: '1.2em' }}>{itemKey}</p>
</span>
<input
type="checkbox"
checked={isEnabled}
onChange={() => toggleItem(itemKey)}
className={styles.checkbox}
/>
</div>
{directionChangeable &&
(
<div className={`${styles.directionBar} ${!isEnabled ? styles.disabled : ''}`} key={`direction-${itemKey}`}>
{directionChangeable && (
<div
className={`${styles.directionBar} ${!isEnabled ? styles.disabled : ''}`}
key={`direction-${itemKey}`}
>
<button
className={`${styles.directionButton} ${!isEnabled ? styles.disabled : ''}`}
onClick={() => toggleDirection(itemKey)}
>
{item.direction === 'asc' ? '↑ Ascending ↑' : '↓ Descending ↓'}
{item.direction === 'asc'
? '↑ Ascending ↑'
: '↓ Descending ↓'}
</button>
</div>
)}
+9 -5
View File
@@ -24,11 +24,13 @@ export const PARSE_REGEX = {
SCR: /(?<![^ [(_\-.])(((dvd|bd|web)?[ .\-_]?)?(scr(eener)?))(?=[ \)\]_.-]|$)/i,
},
visualTags: {
'HDR10+': /(?<![^ [(_\-.])(hdr[ .\-_]?10[ .\-_]?(plus|[+]))(?=[ \)\]_.-]|$)/i,
HDR10: /(?<![^ [(_\-.])(hdr[ .\-_]?10)(?![ .\-_]?(?:\+|plus))(?=[ \)\]_.-]|$)/i,
'HDR10+':
/(?<![^ [(_\-.])(hdr[ .\-_]?10[ .\-_]?(plus|[+]))(?=[ \)\]_.-]|$)/i,
HDR10:
/(?<![^ [(_\-.])(hdr[ .\-_]?10)(?![ .\-_]?(?:\+|plus))(?=[ \)\]_.-]|$)/i,
HDR: /(?<![^ [(_\-.])(hdr)(?![ .\-_]?10)(?![ .\-_]?(?:\+|plus))(?=[ \)\]_.-]|$)/i,
DV: /(?<![^ [(_\-.])(dolby[ .\-_]?vision(?:[ .\-_]?atmos)?|dv)(?=[ \)\]_.-]|$)/i,
"3D": /(?<![^ [(_\-.])((3|three)[ .\-_]?(d(imension)?(al)?))(?=[ \)\]_.-]|$)/i,
'3D': /(?<![^ [(_\-.])((3|three)[ .\-_]?(d(imension)?(al)?))(?=[ \)\]_.-]|$)/i,
IMAX: /(?<![^ [(_\-.])(imax)(?=[ \)\]_.-]|$)/i,
AI: /(?<![^ [(_\-.])(ai[ .\-_]?(upscale|enhanced|remaster))(?=[ \)\]_.-]|$)/i,
},
@@ -49,8 +51,10 @@ export const PARSE_REGEX = {
encodes: {
HEVC: /(?<![^ [(_\-.])(hevc|x265|h265|h\.265)(?=[ \)\]_.-]|$)/i,
AVC: /(?<![^ [(_\-.])(avc|x264|h264|h\.264)(?=[ \)\]_.-]|$)/i,
"H-OU": /(?<![^ [(_\-.])(h(alf)?[ .\-_]?(ou|over[ .\-_]?under))(?=[ \)\]_.-]|$)/i,
"H-SBS": /(?<![^ [(_\-.])(h(alf)?[ .\-_]?(sbs|side[ .\-_]?by[ .\-_]?side))(?=[ \)\]_.-]|$)/i,
'H-OU':
/(?<![^ [(_\-.])(h(alf)?[ .\-_]?(ou|over[ .\-_]?under))(?=[ \)\]_.-]|$)/i,
'H-SBS':
/(?<![^ [(_\-.])(h(alf)?[ .\-_]?(sbs|side[ .\-_]?by[ .\-_]?side))(?=[ \)\]_.-]|$)/i,
},
languages: {
Multi: /(?<![^ [(_\-.])(multi|multi[ .\-_]?audio)(?=[ \)\]_.-]|$)/i,
+1 -1
View File
@@ -103,7 +103,7 @@ export interface Config {
encodes: Encode[];
sortBy: SortBy[];
onlyShowCachedStreams: boolean;
prioritiseLanguage?: string // from older configurations
prioritiseLanguage?: string; // from older configurations
prioritisedLanguages: string[] | null;
excludedLanguages: string[] | null;
formatter: string;
+8 -8
View File
@@ -1,10 +1,10 @@
{
"name": "@aiostreams/utils",
"version": "1.7.4",
"main": "./dist/index.js",
"scripts": {
"test": "vitest run --passWithNoTests",
"build": "tsc"
},
"description": "Library to parse a torrent filename "
"name": "@aiostreams/utils",
"version": "1.7.4",
"main": "./dist/index.js",
"scripts": {
"test": "vitest run --passWithNoTests",
"build": "tsc"
},
"description": "Library to parse a torrent filename "
}
+8 -6
View File
@@ -1,6 +1,6 @@
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';
import { deflateSync, inflateSync } from 'zlib';
import {Settings} from './settings';
import { Settings } from './settings';
const pad = (data: Buffer, blockSize: number): Buffer => {
const padding = blockSize - (data.length % blockSize);
@@ -40,7 +40,9 @@ export const decryptAndDecompress = (
): string => {
const secretKey = Settings.SECRET_KEY;
if (!secretKey) {
console.error('|ERR| crypto > decryptAndDecompress > No secret key provided');
console.error(
'|ERR| crypto > decryptAndDecompress > No secret key provided'
);
throw new Error('No secret key provided');
}
const decipher = createDecipheriv('aes-256-cbc', secretKey, iv);
@@ -60,8 +62,6 @@ export const decryptAndDecompress = (
return decompressedData.toString('utf-8');
};
export function parseAndDecryptString(data: string): string | null {
try {
if (data.startsWith('E-')) {
@@ -72,7 +72,9 @@ export function parseAndDecryptString(data: string): string | null {
}
return data;
} catch (error: any) {
console.error(`|ERR| crypto > parseAndDecryptString > Failed to decrypt data: ${error.message}`);
console.error(
`|ERR| crypto > parseAndDecryptString > Failed to decrypt data: ${error.message}`
);
return null;
}
}
}
+10 -10
View File
@@ -1,6 +1,5 @@
import { AddonDetail } from '@aiostreams/types';
import {Settings} from './settings';
import { Settings } from './settings';
export const addonDetails: AddonDetail[] = [
{
@@ -38,9 +37,9 @@ export const addonDetails: AddonDetail[] = [
required: false,
label: 'Use Multiple Instances',
description:
'Torrentio supports multiple services. By default, AIOStreams will pass all services to Torrentio. However, Torrentio has its\
own service priority system which can\'t be overriden. If you want to use a custom priority system for your services, you can enable this option.\
to create a separate request for each service.',
"Torrentio supports multiple services. By default, AIOStreams will pass all services to Torrentio. However, Torrentio has its\
own service priority system which can't be overriden. If you want to use a custom priority system for your services, you can enable this option.\
to create a separate request for each service.",
type: 'checkbox',
},
{
@@ -238,8 +237,8 @@ export const addonDetails: AddonDetail[] = [
min: Settings.MIN_TIMEOUT,
max: Settings.MAX_TIMEOUT,
},
}
]
},
],
},
{
name: 'Easynews+',
@@ -274,8 +273,8 @@ export const addonDetails: AddonDetail[] = [
min: Settings.MIN_TIMEOUT,
max: Settings.MAX_TIMEOUT,
},
}
]
},
],
},
{
name: 'Stremio GDrive',
@@ -502,7 +501,8 @@ export const serviceDetails = [
knownNames: ['SR', 'Seedr', 'SDR'],
credentials: [
{
label: 'Encoded Token. Please authorise at MediaFusion and copy the token into here.',
label:
'Encoded Token. Please authorise at MediaFusion and copy the token into here.',
id: 'apiKey',
},
],
+1 -1
View File
@@ -1,4 +1,4 @@
export * from './crypto';
export * from './details';
export * from './settings';
export * from './mediaflow';
export * from './mediaflow';
+87 -65
View File
@@ -1,64 +1,81 @@
import { Config } from "@aiostreams/types";
import path from "path";
import { Config } from '@aiostreams/types';
import path from 'path';
const PRIVATE_CIDR = /^(10\.|127\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/;
export function createProxiedMediaFlowUrl(url: string, mediaFlowConfig: Config["mediaFlowConfig"], headers?:
{ request?: Record<string, string>, response?: Record<string, string> }) {
const streamUrl = url;
const mediaFlowUrl = mediaFlowConfig?.proxyUrl?.replace(/\/$/, '');
const mediaFlowApiPassword = mediaFlowConfig?.apiPassword;
if (!streamUrl) {
console.error('|ERR| mediaflow > createProxiedMediaFlowUrl > streamUrl is missing, could not create proxied URL');
throw new Error('Stream URL is missing');
}
if (!mediaFlowConfig) {
console.error('|ERR| mediaflow > createProxiedMediaFlowUrl > mediaFlowConfig is missing');
throw new Error('MediaFlow configuration is missing');
}
if (!mediaFlowUrl || !mediaFlowApiPassword) {
console.error('|ERR| mediaflow > createProxiedMediaFlowUrl > mediaFlowUrl or API password is missing');
throw new Error('MediaFlow URL or API password is missing');
}
export function createProxiedMediaFlowUrl(
url: string,
mediaFlowConfig: Config['mediaFlowConfig'],
headers?: {
request?: Record<string, string>;
response?: Record<string, string>;
}
) {
const streamUrl = url;
const mediaFlowUrl = mediaFlowConfig?.proxyUrl?.replace(/\/$/, '');
const mediaFlowApiPassword = mediaFlowConfig?.apiPassword;
if (!streamUrl) {
console.error(
'|ERR| mediaflow > createProxiedMediaFlowUrl > streamUrl is missing, could not create proxied URL'
);
throw new Error('Stream URL is missing');
}
if (!mediaFlowConfig) {
console.error(
'|ERR| mediaflow > createProxiedMediaFlowUrl > mediaFlowConfig is missing'
);
throw new Error('MediaFlow configuration is missing');
}
if (!mediaFlowUrl || !mediaFlowApiPassword) {
console.error(
'|ERR| mediaflow > createProxiedMediaFlowUrl > mediaFlowUrl or API password is missing'
);
throw new Error('MediaFlow URL or API password is missing');
}
const queryParams: Record<string, string> = {
api_password: mediaFlowApiPassword,
}
queryParams.d = streamUrl;
const queryParams: Record<string, string> = {
api_password: mediaFlowApiPassword,
};
queryParams.d = streamUrl;
const responseHeaders = headers?.response || {
"Content-Disposition": `attachment; filename=${path.basename(streamUrl)}`
};
const requestHeaders = headers?.request || {};
const responseHeaders = headers?.response || {
'Content-Disposition': `attachment; filename=${path.basename(streamUrl)}`,
};
const requestHeaders = headers?.request || {};
if (requestHeaders) {
Object.entries(requestHeaders).forEach(([key, value]) => {
queryParams[`h_${key}`] = value;
});
}
if (requestHeaders) {
Object.entries(requestHeaders).forEach(([key, value]) => {
queryParams[`h_${key}`] = value;
});
}
if (responseHeaders) {
Object.entries(responseHeaders).forEach(([key, value]) => {
queryParams[`r_${key}`] = value;
});
}
const encodedParams = new URLSearchParams(queryParams).toString();
const baseUrl = new URL('/proxy/stream', mediaFlowUrl).toString();
const proxiedUrl = `${baseUrl}?${encodedParams}`;
//console.debug(`|DBG| mediaflow > createProxiedMediaFlowUrl > Proxied URL: ${proxiedUrl.replace(mediaFlowApiPassword, '***').replace(streamUrl, '***')}`);
return proxiedUrl;
if (responseHeaders) {
Object.entries(responseHeaders).forEach(([key, value]) => {
queryParams[`r_${key}`] = value;
});
}
const encodedParams = new URLSearchParams(queryParams).toString();
const baseUrl = new URL('/proxy/stream', mediaFlowUrl).toString();
const proxiedUrl = `${baseUrl}?${encodedParams}`;
//console.debug(`|DBG| mediaflow > createProxiedMediaFlowUrl > Proxied URL: ${proxiedUrl.replace(mediaFlowApiPassword, '***').replace(streamUrl, '***')}`);
return proxiedUrl;
}
export async function getMediaFlowPublicIp(mediaFlowConfig: Config["mediaFlowConfig"]) {
export async function getMediaFlowPublicIp(
mediaFlowConfig: Config['mediaFlowConfig']
) {
if (!mediaFlowConfig) {
console.error('|ERR| mediaflow > getMediaFlowPublicIp > mediaFlowConfig is missing');
console.error(
'|ERR| mediaflow > getMediaFlowPublicIp > mediaFlowConfig is missing'
);
throw new Error('MediaFlow configuration is missing');
}
const mediaFlowUrl = mediaFlowConfig?.proxyUrl?.replace(/\/$/, '');
if (!mediaFlowUrl) {
console.error('|ERR| mediaflow > getMediaFlowPublicIp > mediaFlowUrl is missing');
console.error(
'|ERR| mediaflow > getMediaFlowPublicIp > mediaFlowUrl is missing'
);
throw new Error('MediaFlow URL is missing');
}
if (mediaFlowConfig.publicIp) {
@@ -68,20 +85,29 @@ export async function getMediaFlowPublicIp(mediaFlowConfig: Config["mediaFlowCon
const parsedUrl = new URL(mediaFlowUrl);
if (PRIVATE_CIDR.test(parsedUrl.hostname)) {
// MediaFlow proxy URL is a private IP address
console.debug('|DBG| mediaflow > getMediaFlowPublicIp > MediaFlow proxy URL is a private IP address so returning null');
console.debug(
'|DBG| mediaflow > getMediaFlowPublicIp > MediaFlow proxy URL is a private IP address so returning null'
);
return null;
}
try {
console.debug('|DBG| mediaflow > getMediaFlowPublicIp > GET /proxy/ip?api_password=***');
const response = await fetch(new URL(`/proxy/ip?api_password=${encodeURIComponent(mediaFlowConfig.apiPassword)}`, mediaFlowUrl).toString(), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(5000),
});
console.debug(
'|DBG| mediaflow > getMediaFlowPublicIp > GET /proxy/ip?api_password=***'
);
const response = await fetch(
new URL(
`/proxy/ip?api_password=${encodeURIComponent(mediaFlowConfig.apiPassword)}`,
mediaFlowUrl
).toString(),
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(5000),
}
);
if (!response.ok) {
throw new Error(`${response.status}: ${response.statusText}`);
@@ -90,14 +116,10 @@ export async function getMediaFlowPublicIp(mediaFlowConfig: Config["mediaFlowCon
const data = await response.json();
const publicIp = data.ip;
return publicIp;
} catch (error: any) {
console.error(`|ERR| mediaflow > getMediaFlowPublicIp > Failed to get public IP from MediaFlow - ${error.message}`);
console.error(
`|ERR| mediaflow > getMediaFlowPublicIp > Failed to get public IP from MediaFlow - ${error.message}`
);
return null;
}
}
}
+63 -24
View File
@@ -7,32 +7,71 @@ try {
console.error('Error loading .env file:', error);
}
export class Settings {
public static readonly ADDON_NAME = process.env.ADDON_NAME ?? 'AIOStreams';
public static readonly ADDON_ID = process.env.ADDON_ID ?? 'aiostreams.viren070.com';
public static readonly ADDON_ID =
process.env.ADDON_ID ?? 'aiostreams.viren070.com';
public static readonly PORT = process.env.PORT ?? 3000;
public static readonly BRANDING = process.env.BRANDING ?? process.env.NEXT_PUBLIC_ELFHOSTED_BRANDING
public static readonly BRANDING =
process.env.BRANDING ?? process.env.NEXT_PUBLIC_ELFHOSTED_BRANDING;
public static readonly SECRET_KEY = process.env.SECRET_KEY ?? '';
public static readonly COMET_URL = process.env.COMET_URL ?? 'https://comet.elfhosted.com/';
public static readonly MEDIAFUSION_URL = process.env.MEDIAFUSION_URL ?? 'https://mediafusion.elfhosted.com/';
public static readonly TORRENTIO_URL = process.env.TORRENTIO_URL ?? 'https://torrentio.strem.fun/';
public static readonly TORBOX_STREMIO_URL = process.env.TORBOX_STREMIO_URL ?? 'https://stremio.torbox.app/';
public static readonly EASYNEWS_URL = process.env.EASYNEWS_URL ?? 'https://ea627ddf0ee7-easynews.baby-beamup.club/';
public static readonly EASYNEWS_PLUS_URL = process.env.EASYNEWS_PLUS_URL ?? 'https://b89262c192b0-stremio-easynews-addon.baby-beamup.club/';
public static readonly MAX_ADDONS = process.env.MAX_ADDONS ? parseInt(process.env.MAX_ADDONS) : 15;
public static readonly MAX_MOVIE_SIZE = process.env.MAX_MOVIE_SIZE ? parseInt(process.env.MAX_MOVIE_SIZE) : 150000000000; // 150GB
public static readonly MAX_EPISODE_SIZE = process.env.MAX_EPISODE_SIZE ? parseInt(process.env.MAX_EPISODE_SIZE) : 15000000000; // 15GB
public static readonly MAX_TIMEOUT = process.env.MAX_TIMEOUT ? parseInt(process.env.MAX_TIMEOUT) : 50000;
public static readonly MIN_TIMEOUT = process.env.MIN_TIMEOUT ? parseInt(process.env.MIN_TIMEOUT) : 1000;
public static readonly DEFAULT_TIMEOUT = process.env.DEFAULT_TIMEOUT ? parseInt(process.env.DEFAULT_TIMEOUT) : 15000;
public static readonly DEFAULT_TORRENTIO_TIMEOUT = process.env.DEFAULT_TORRENTIO_TIMEOUT ? parseInt(process.env.DEFAULT_TORRENTIO_TIMEOUT) : 5000;
public static readonly DEFAULT_TORBOX_TIMEOUT = process.env.DEFAULT_TORBOX_TIMEOUT ? parseInt(process.env.DEFAULT_TORBOX_TIMEOUT) : 15000;
public static readonly DEFAULT_COMET_TIMEOUT = process.env.DEFAULT_COMET_TIMEOUT ? parseInt(process.env.DEFAULT_COMET_TIMEOUT) : 15000;
public static readonly DEFAULT_MEDIAFUSION_TIMEOUT = process.env.DEFAULT_MEDIAFUSION_TIMEOUT ? parseInt(process.env.DEFAULT_MEDIAFUSION_TIMEOUT) : 15000;
public static readonly DEFAULT_EASYNEWS_TIMEMOUT = process.env.DEFAULT_EASYNEWS_TIMEMOUT ? parseInt(process.env.DEFAULT_EASYNEWS_TIMEMOUT) : 15000;
public static readonly DEFAULT_EASYNEWS_PLUS_TIMEMOUT = process.env.DEFAULT_EASYNEWS_PLUS_TIMEMOUT ? parseInt(process.env.DEFAULT_EASYNEWS_PLUS_TIMEMOUT) : 15000;
public static readonly SHOW_DIE = process.env.SHOW_DIE ? process.env.SHOW_DIE === 'true' : true;
public static readonly COMET_URL =
process.env.COMET_URL ?? 'https://comet.elfhosted.com/';
public static readonly MEDIAFUSION_URL =
process.env.MEDIAFUSION_URL ?? 'https://mediafusion.elfhosted.com/';
public static readonly TORRENTIO_URL =
process.env.TORRENTIO_URL ?? 'https://torrentio.strem.fun/';
public static readonly TORBOX_STREMIO_URL =
process.env.TORBOX_STREMIO_URL ?? 'https://stremio.torbox.app/';
public static readonly EASYNEWS_URL =
process.env.EASYNEWS_URL ??
'https://ea627ddf0ee7-easynews.baby-beamup.club/';
public static readonly EASYNEWS_PLUS_URL =
process.env.EASYNEWS_PLUS_URL ??
'https://b89262c192b0-stremio-easynews-addon.baby-beamup.club/';
public static readonly MAX_ADDONS = process.env.MAX_ADDONS
? parseInt(process.env.MAX_ADDONS)
: 15;
public static readonly MAX_MOVIE_SIZE = process.env.MAX_MOVIE_SIZE
? parseInt(process.env.MAX_MOVIE_SIZE)
: 150000000000; // 150GB
public static readonly MAX_EPISODE_SIZE = process.env.MAX_EPISODE_SIZE
? parseInt(process.env.MAX_EPISODE_SIZE)
: 15000000000; // 15GB
public static readonly MAX_TIMEOUT = process.env.MAX_TIMEOUT
? parseInt(process.env.MAX_TIMEOUT)
: 50000;
public static readonly MIN_TIMEOUT = process.env.MIN_TIMEOUT
? parseInt(process.env.MIN_TIMEOUT)
: 1000;
public static readonly DEFAULT_TIMEOUT = process.env.DEFAULT_TIMEOUT
? parseInt(process.env.DEFAULT_TIMEOUT)
: 15000;
public static readonly DEFAULT_TORRENTIO_TIMEOUT = process.env
.DEFAULT_TORRENTIO_TIMEOUT
? parseInt(process.env.DEFAULT_TORRENTIO_TIMEOUT)
: 5000;
public static readonly DEFAULT_TORBOX_TIMEOUT = process.env
.DEFAULT_TORBOX_TIMEOUT
? parseInt(process.env.DEFAULT_TORBOX_TIMEOUT)
: 15000;
public static readonly DEFAULT_COMET_TIMEOUT = process.env
.DEFAULT_COMET_TIMEOUT
? parseInt(process.env.DEFAULT_COMET_TIMEOUT)
: 15000;
public static readonly DEFAULT_MEDIAFUSION_TIMEOUT = process.env
.DEFAULT_MEDIAFUSION_TIMEOUT
? parseInt(process.env.DEFAULT_MEDIAFUSION_TIMEOUT)
: 15000;
public static readonly DEFAULT_EASYNEWS_TIMEMOUT = process.env
.DEFAULT_EASYNEWS_TIMEMOUT
? parseInt(process.env.DEFAULT_EASYNEWS_TIMEMOUT)
: 15000;
public static readonly DEFAULT_EASYNEWS_PLUS_TIMEMOUT = process.env
.DEFAULT_EASYNEWS_PLUS_TIMEMOUT
? parseInt(process.env.DEFAULT_EASYNEWS_PLUS_TIMEMOUT)
: 15000;
public static readonly SHOW_DIE = process.env.SHOW_DIE
? process.env.SHOW_DIE === 'true'
: true;
}
+5 -6
View File
@@ -1,8 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
}
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
}
}
+44 -24
View File
@@ -6,7 +6,11 @@ import {
Config,
} from '@aiostreams/types';
import { extractSizeInBytes, parseFilename } from '@aiostreams/parser';
import { getMediaFlowPublicIp, serviceDetails, Settings } from '@aiostreams/utils';
import {
getMediaFlowPublicIp,
serviceDetails,
Settings,
} from '@aiostreams/utils';
export class BaseWrapper {
private readonly streamPath: string = 'stream/{type}/{id}.json';
@@ -69,20 +73,28 @@ export class BaseWrapper {
if (this.userConfig.requestingIp) {
headers.set('X-Forwarded-For', this.userConfig.requestingIp);
headers.set('X-Real-IP', this.userConfig.requestingIp);
}
}
if (this.userConfig.mediaFlowConfig?.mediaFlowEnabled) {
const mediaFlowIp = await getMediaFlowPublicIp(this.userConfig.mediaFlowConfig);
const mediaFlowIp = await getMediaFlowPublicIp(
this.userConfig.mediaFlowConfig
);
if (mediaFlowIp) {
console.log(`|DBG| wrappers > base > Forwarding IP from MediaFlow to ${this.addonName}`);
console.log(
`|DBG| wrappers > base > Forwarding IP from MediaFlow to ${this.addonName}`
);
headers.set('X-Forwarded-For', mediaFlowIp);
headers.set('X-Real-IP', mediaFlowIp);
}
} else {
console.log(`|DBG| wrappers > base > Forwarding IP from request to ${this.addonName}`);
console.log(
`|DBG| wrappers > base > Forwarding IP from request to ${this.addonName}`
);
}
const urlParts = url.split('/');
const sanitisedUrl = `${urlParts[0]}//${urlParts[2]}/*************/${urlParts.slice(-3).join('/')}`;
console.log(`|INF| wrappers > base > ${this.addonName}: GET ${sanitisedUrl}`);
console.log(
`|INF| wrappers > base > ${this.addonName}: GET ${sanitisedUrl}`
);
const response = await fetch(url, {
headers: headers,
signal: controller.signal,
@@ -127,7 +139,9 @@ export class BaseWrapper {
size: size,
url: stream.url,
externalUrl: stream.externalUrl,
_infoHash: stream.infoHash || (stream.url ? (stream.url.match(/[a-fA-F0-9]{40}/)?.[0]) : undefined),
_infoHash:
stream.infoHash ||
(stream.url ? stream.url.match(/[a-fA-F0-9]{40}/)?.[0] : undefined),
torrent: {
infoHash: stream.infoHash,
fileIdx: stream.fileIdx,
@@ -145,12 +159,14 @@ export class BaseWrapper {
behaviorHints: {
countryWhitelist: stream.behaviorHints?.countryWhitelist,
notWebReady: stream.behaviorHints?.notWebReady,
proxyHeaders: stream.behaviorHints?.proxyHeaders?.request || stream.behaviorHints?.proxyHeaders?.response
? {
request: stream.behaviorHints?.proxyHeaders?.request,
response: stream.behaviorHints?.proxyHeaders?.response,
}
: undefined,
proxyHeaders:
stream.behaviorHints?.proxyHeaders?.request ||
stream.behaviorHints?.proxyHeaders?.response
? {
request: stream.behaviorHints?.proxyHeaders?.request,
response: stream.behaviorHints?.proxyHeaders?.response,
}
: undefined,
videoHash: stream.behaviorHints?.videoHash,
},
},
@@ -165,7 +181,9 @@ export class BaseWrapper {
let description = stream.description || stream.title;
if (!filename && description) {
console.log(`|DBG| wrappers > base > parseStream: No filename found in behaviorHints, attempting to parse from description`);
console.log(
`|DBG| wrappers > base > parseStream: No filename found in behaviorHints, attempting to parse from description`
);
const lines = description.split('\n');
filename =
lines.find(
@@ -174,9 +192,13 @@ export class BaseWrapper {
/(?<![^ [_(\-.]])(?:s(?:eason)?[ .\-_]?(\d+)[ .\-_]?(?:e(?:pisode)?[ .\-_]?(\d+))?|(\d+)[xX](\d+))(?![^ \])_.-])/
) || line.match(/(?<![^ [_(\-.])(\d{4})(?=[ \])_.-]|$)/i)
) || lines[0];
console.log(`|DBG| wrappers > base > parseStream: With description: ${description}, found filename: ${filename}`);
console.log(
`|DBG| wrappers > base > parseStream: With description: ${description}, found filename: ${filename}`
);
} else if (!description) {
console.log(`|WRN| wrappers > base > parseStream: No description found, filename could not be determined`);
console.log(
`|WRN| wrappers > base > parseStream: No description found, filename could not be determined`
);
}
let parsedInfo: ParsedNameData = parseFilename(filename || '');
@@ -187,11 +209,9 @@ export class BaseWrapper {
stream.behaviorHints?.videoSize ||
stream.size ||
stream.sizebytes ||
description && extractSizeInBytes(description, 1024) ||
stream.name && extractSizeInBytes(stream.name, 1024) ||
(description && extractSizeInBytes(description, 1024)) ||
(stream.name && extractSizeInBytes(stream.name, 1024)) ||
undefined;
// look for seeders
let seeders: string | undefined;
@@ -202,10 +222,10 @@ export class BaseWrapper {
// look for indexer
let indexer: string | undefined;
if (description) {
const indexerMatch = RegExp(/[🌐🔗] ([^\s\p{Emoji_Presentation}]+(?:\s[^\s\p{Emoji_Presentation}]+)*)/u).exec(
description || ''
);
indexer = indexerMatch ? indexerMatch[1] : undefined
const indexerMatch = RegExp(
/[🌐🔗] ([^\s\p{Emoji_Presentation}]+(?:\s[^\s\p{Emoji_Presentation}]+)*)/u
).exec(description || '');
indexer = indexerMatch ? indexerMatch[1] : undefined;
}
// look for providers
+8 -7
View File
@@ -21,8 +21,7 @@ export class Comet extends BaseWrapper {
) {
let url = overrideUrl
? overrideUrl
: Settings.COMET_URL +
(configString ? configString + '/' : '');
: Settings.COMET_URL + (configString ? configString + '/' : '');
super(addonName, url, indexerTimeout, addonId, userConfig);
}
@@ -45,7 +44,10 @@ export class Comet extends BaseWrapper {
const debridMatch = RegExp(/^\[([a-zA-Z]{2})(\⚡)\]/).exec(stream.name!);
const debrid = debridMatch
? {
id: serviceDetails.find((service) => service.knownNames.includes(debridMatch[1]))?.id || debridMatch[1],
id:
serviceDetails.find((service) =>
service.knownNames.includes(debridMatch[1])
)?.id || debridMatch[1],
cached: debridMatch[2] === '⚡',
}
: undefined;
@@ -96,7 +98,7 @@ export async function getCometStreams(
overrideName?: string;
},
streamRequest: StreamRequest,
addonId: string,
addonId: string
): Promise<ParsedStream[]> {
const supportedServices: string[] =
addonDetails.find((addon: AddonDetail) => addon.id === 'comet')
@@ -114,15 +116,14 @@ export async function getCometStreams(
indexerTimeout,
cometOptions.overrideName,
addonId,
config,
config
);
return comet.getParsedStreams(streamRequest);
}
// find all usable and enabled services
const usableServices = config.services.filter(
(service) =>
supportedServices.includes(service.id) && service.enabled
(service) => supportedServices.includes(service.id) && service.enabled
);
// if no usable services found, throw an error
+29 -18
View File
@@ -1,11 +1,14 @@
import { AddonDetail, ParsedNameData, StreamRequest } from '@aiostreams/types';
import { parseFilename, extractSizeInBytes, extractDurationInMs } from '@aiostreams/parser';
import {
parseFilename,
extractSizeInBytes,
extractDurationInMs,
} from '@aiostreams/parser';
import { ParsedStream, Stream, Config } from '@aiostreams/types';
import { BaseWrapper } from './base';
import { addonDetails, serviceDetails } from '@aiostreams/utils';
import { Settings } from '@aiostreams/utils';
export class Easynews extends BaseWrapper {
constructor(
configString: string | null,
@@ -17,26 +20,26 @@ export class Easynews extends BaseWrapper {
) {
let url = overrideUrl
? overrideUrl
: Settings.EASYNEWS_URL +
(configString ? configString + '/' : '');
: Settings.EASYNEWS_URL + (configString ? configString + '/' : '');
super(addonName, url, indexerTimeout, addonId, userConfig);
}
protected parseStream(stream: Stream): ParsedStream {
const [filename, sizeString, durationString] = stream.description?.split('\n') || [];
const [filename, sizeString, durationString] =
stream.description?.split('\n') || [];
const parsedFilename: ParsedNameData = parseFilename(
filename || stream.description || ''
);
const sizeInBytes = stream.description
? extractSizeInBytes(sizeString, 1024)
: undefined;
? extractSizeInBytes(sizeString, 1024)
: undefined;
const provider = {
id: 'easynews',
cached: true,
}
id: 'easynews',
cached: true,
};
const durationInMs = extractDurationInMs(durationString || '');
@@ -56,7 +59,7 @@ export class Easynews extends BaseWrapper {
}
const getEasynewsConfigString = (username: string, password: string) => {
return `%7B%22username%22%3A%22${username}%22%2C%22password%22%3A%22${password}%22%7D`
return `%7B%22username%22%3A%22${username}%22%2C%22password%22%3A%22${password}%22%7D`;
};
export async function getEasynewsStreams(
@@ -67,29 +70,37 @@ export async function getEasynewsStreams(
indexerTimeout?: string;
},
streamRequest: StreamRequest,
addonId: string,
addonId: string
): Promise<ParsedStream[]> {
// look for the 'easynews' id in the services array and destructure the username and password
// if we cant find it, throw an error
const easynewsService = serviceDetails.find((service) => service.id === 'easynews');
const easynewsService = serviceDetails.find(
(service) => service.id === 'easynews'
);
if (!easynewsService) {
throw new Error('Easynews service not found');
}
// check for the presence of the username and password in teh easynewsService.credentials object
// if not found, throw an error
const credentails = config.services.find((service) => service.id === 'easynews')?.credentials;
const credentails = config.services.find(
(service) => service.id === 'easynews'
)?.credentials;
if (!credentails || !credentails.username || !credentails.password) {
throw new Error('Easynews credentials not found');
}
const easynewsConfigString = getEasynewsConfigString(credentails.username, credentails.password);
const easynewsConfigString = getEasynewsConfigString(
credentails.username,
credentails.password
);
const easynews = new Easynews(
easynewsConfigString,
easynewsOptions.overrideUrl ?? null,
easynewsOptions.indexerTimeout ? parseInt(easynewsOptions.indexerTimeout) : undefined,
easynewsOptions.indexerTimeout
? parseInt(easynewsOptions.indexerTimeout)
: undefined,
easynewsOptions.overrideName,
addonId,
config
+30 -19
View File
@@ -1,11 +1,14 @@
import { AddonDetail, ParsedNameData, StreamRequest } from '@aiostreams/types';
import { parseFilename, extractSizeInBytes, extractDurationInMs } from '@aiostreams/parser';
import {
parseFilename,
extractSizeInBytes,
extractDurationInMs,
} from '@aiostreams/parser';
import { ParsedStream, Stream, Config } from '@aiostreams/types';
import { BaseWrapper } from './base';
import { addonDetails, serviceDetails } from '@aiostreams/utils';
import { Settings } from '@aiostreams/utils';
export class EasynewsPlus extends BaseWrapper {
constructor(
configString: string | null,
@@ -17,28 +20,28 @@ export class EasynewsPlus extends BaseWrapper {
) {
let url = overrideUrl
? overrideUrl
: Settings.EASYNEWS_PLUS_URL +
(configString ? configString + '/' : '');
: Settings.EASYNEWS_PLUS_URL + (configString ? configString + '/' : '');
super(addonName, url, indexerTimeout, addonId, userConfig);
}
protected parseStream(stream: Stream): ParsedStream {
const [filename, durationString, sizeString] = stream.description?.split('\n') || [];
const [filename, durationString, sizeString] =
stream.description?.split('\n') || [];
const parsedFilename: ParsedNameData = parseFilename(
filename || stream.description || ''
);
const sizeInBytes = stream.behaviorHints?.videoSize
? stream.behaviorHints.videoSize
: sizeString
const sizeInBytes = stream.behaviorHints?.videoSize
? stream.behaviorHints.videoSize
: sizeString
? extractSizeInBytes(sizeString, 1024)
: undefined;
const provider = {
id: 'easynews',
cached: true,
}
id: 'easynews',
cached: true,
};
const durationInMs = extractDurationInMs(durationString || '');
@@ -58,7 +61,7 @@ export class EasynewsPlus extends BaseWrapper {
}
const getEasynewsPlusConfigString = (username: string, password: string) => {
return `%7B%22username%22%3A%22${username}%22%2C%22password%22%3A%22${password}%22%2C%22sort1%22%3A%22Size%22%2C%22sort1Direction%22%3A%22Descending%22%2C%22sort2%22%3A%22Relevance%22%2C%22sort2Direction%22%3A%22Descending%22%2C%22sort3%22%3A%22Date%20%26%20Time%22%2C%22sort3Direction%22%3A%22Descending%22%7D`
return `%7B%22username%22%3A%22${username}%22%2C%22password%22%3A%22${password}%22%2C%22sort1%22%3A%22Size%22%2C%22sort1Direction%22%3A%22Descending%22%2C%22sort2%22%3A%22Relevance%22%2C%22sort2Direction%22%3A%22Descending%22%2C%22sort3%22%3A%22Date%20%26%20Time%22%2C%22sort3Direction%22%3A%22Descending%22%7D`;
};
export async function getEasynewsPlusStreams(
@@ -69,29 +72,37 @@ export async function getEasynewsPlusStreams(
indexerTimeout?: string;
},
streamRequest: StreamRequest,
addonId: string,
addonId: string
): Promise<ParsedStream[]> {
// look for the 'easynews' id in the services array and destructure the username and password
// if we cant find it, throw an error
const easynewsService = serviceDetails.find((service) => service.id === 'easynews');
const easynewsService = serviceDetails.find(
(service) => service.id === 'easynews'
);
if (!easynewsService) {
throw new Error('Easynews service not found');
}
// check for the presence of the username and password in teh easynewsService.credentials object
// if not found, throw an error
const credentails = config.services.find((service) => service.id === 'easynews')?.credentials;
const credentails = config.services.find(
(service) => service.id === 'easynews'
)?.credentials;
if (!credentails || !credentails.username || !credentails.password) {
throw new Error('Easynews credentials not found');
}
const easynewsPlusConfigString = getEasynewsPlusConfigString(credentails.username, credentails.password);
const easynewsPlusConfigString = getEasynewsPlusConfigString(
credentails.username,
credentails.password
);
const easynews = new EasynewsPlus(
easynewsPlusConfigString,
easynewsPlusOptions.overrideUrl ?? null,
easynewsPlusOptions.indexerTimeout ? parseInt(easynewsPlusOptions.indexerTimeout) : undefined,
easynewsPlusOptions.indexerTimeout
? parseInt(easynewsPlusOptions.indexerTimeout)
: undefined,
easynewsPlusOptions.overrideName,
addonId,
config
+1 -1
View File
@@ -4,4 +4,4 @@ export * from './torrentio';
export * from './comet';
export * from './mediafusion';
export * from './easynews';
export * from './easynewsPlus';
export * from './easynewsPlus';
+25 -26
View File
@@ -16,8 +16,7 @@ export class MediaFusion extends BaseWrapper {
) {
let url = overrideUrl
? overrideUrl
: Settings.MEDIAFUSION_URL +
(configString ? configString + '/' : '');
: Settings.MEDIAFUSION_URL + (configString ? configString + '/' : '');
super(addonName, url, indexerTimeout, addonId, userConfig);
}
@@ -27,7 +26,11 @@ export class MediaFusion extends BaseWrapper {
stream.behaviorHints?.filename?.trim() ||
stream.description?.split('\n')[0].replace('📂 ', '');
if (filename && stream.description && filename.includes('Content Warning')) {
if (
filename &&
stream.description &&
filename.includes('Content Warning')
) {
filename = stream.description.split('\n').join(' ');
}
@@ -55,9 +58,9 @@ export class MediaFusion extends BaseWrapper {
}
: undefined;
const indexerMatch = RegExp(/🔗 ([^\s\p{Emoji_Presentation}]+(?:\s[^\s\p{Emoji_Presentation}]+)*)/u).exec(
stream.description || ''
);
const indexerMatch = RegExp(
/🔗 ([^\s\p{Emoji_Presentation}]+(?:\s[^\s\p{Emoji_Presentation}]+)*)/u
).exec(stream.description || '');
const indexer = indexerMatch ? indexerMatch[1] : undefined;
const seedersMatch = RegExp(/👤 (\d+)/).exec(stream.description || '');
@@ -65,12 +68,14 @@ export class MediaFusion extends BaseWrapper {
stream.description?.split('\n').forEach((line) => {
if (line.startsWith('🌐')) {
// the line contains the languages separated by ' + '.
// the line contains the languages separated by ' + '.
// the languages can either be flag emojis or the language name.
const normaliseLanguage = (lang: string) => {
// convert emojis to language names, and uppercase the first letter of each word
return (emojiToLanguage(lang) || lang).replace(/\b\w/g, (char) => char.toUpperCase());
}
return (emojiToLanguage(lang) || lang).replace(/\b\w/g, (char) =>
char.toUpperCase()
);
};
const languages = line.replace('🌐 ', '').split(' + ');
languages.forEach((lang) => {
const normalisedLanguage = normaliseLanguage(lang);
@@ -104,7 +109,7 @@ export async function getMediafusionStreams(
overrideName?: string;
},
streamRequest: StreamRequest,
addonId: string,
addonId: string
): Promise<ParsedStream[]> {
const supportedServices: string[] =
addonDetails.find((addon: AddonDetail) => addon.id === 'mediafusion')
@@ -129,15 +134,12 @@ export async function getMediafusionStreams(
// find all usable and enabled services
const usableServices = config.services.filter(
(service) =>
supportedServices.includes(service.id) && service.enabled
(service) => supportedServices.includes(service.id) && service.enabled
);
// if no usable services found, use mediafusion without debrid
if (usableServices.length < 1) {
const configString = await getConfigString(
getMediaFusionConfig()
);
const configString = await getConfigString(getMediaFusionConfig());
const mediafusion = new MediaFusion(
configString,
null,
@@ -179,9 +181,7 @@ export async function getMediafusionStreams(
debridService.id,
debridService.credentials
);
const encryptedStr = await getConfigString(
mediafusionConfig
);
const encryptedStr = await getConfigString(mediafusionConfig);
const mediafusion = new MediaFusion(
encryptedStr,
null,
@@ -204,9 +204,7 @@ export async function getMediafusionStreams(
service.id,
service.credentials
);
const encryptedStr = await getConfigString(
mediafusionConfig
);
const encryptedStr = await getConfigString(mediafusionConfig);
const mediafusion = new MediaFusion(
encryptedStr,
null,
@@ -224,11 +222,14 @@ export async function getMediafusionStreams(
return parsedStreams;
}
const getMediaFusionConfig = (service?: string, credentials: { [key: string]: string } = {}): any => {
const getMediaFusionConfig = (
service?: string,
credentials: { [key: string]: string } = {}
): any => {
return {
streaming_provider: service
? {
token: !["pikpak"].includes(service) ? credentials.apiKey : undefined,
token: !['pikpak'].includes(service) ? credentials.apiKey : undefined,
email: credentials.email,
password: credentials.password,
service: service,
@@ -313,9 +314,7 @@ const getMediaFusionConfig = (service?: string, credentials: { [key: string]: st
};
};
async function getConfigString(
data: any
): Promise<string> {
async function getConfigString(data: any): Promise<string> {
const encryptUrl = `${Settings.MEDIAFUSION_URL}encrypt-user-data`;
const response = await fetch(encryptUrl, {
method: 'POST',
+34 -16
View File
@@ -46,26 +46,28 @@ export class Torbox extends BaseWrapper {
protected parseStream(stream: TorboxStream): ParsedStream | undefined {
let type = stream.type;
const [dQuality, dFilename, dSize, dLanguage, dAgeOrSeeders] = stream.description
.split('\n')
.map((field: string) => {
const [dQuality, dFilename, dSize, dLanguage, dAgeOrSeeders] =
stream.description.split('\n').map((field: string) => {
if (field.startsWith('Type')) {
// the last line can either contain only the type or the type and the seeders/age
// we will always return the age or seeders and assign the type to the variable declared outside the map
const parts = field.split('|');
type = ['torrent', 'usenet', 'web'].includes(type || '') ? type : parts[0].split(':')[1].trim().toLowerCase();
type = ['torrent', 'usenet', 'web'].includes(type || '')
? type
: parts[0].split(':')[1].trim().toLowerCase();
if (parts.length > 1) {
return parts[1].split(':')[1].trim();
}
}
// since the last line only contains the type, we will return undefined
return undefined;
}
const [_, value] = field.split(':');
return value.trim();
});
const filename = stream.behaviorHints?.filename || dFilename
const parsedFilename: ParsedNameData = parseFilename(filename || stream.description);
const filename = stream.behaviorHints?.filename || dFilename;
const parsedFilename: ParsedNameData = parseFilename(
filename || stream.description
);
/* If the quality from Torbox is not one of the qualities in the Config, they get filtered out
So, for now, we will not update the quality from Torbox
@@ -74,31 +76,47 @@ export class Torbox extends BaseWrapper {
parsedFilename.quality = quality;
}
*/
const language = stream.language || dLanguage;
const normaliseLanguage = (lang: string) => {
if (lang.toLowerCase() === 'multi audio') {
return 'Multi';
return 'Multi';
}
return lang.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
return lang
.split(' ')
.map(
(word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
)
.join(' ');
};
if (language) {
const normalisedLanguage = normaliseLanguage(language);
if (normalisedLanguage !== 'Unknown' && !parsedFilename.languages.includes(normalisedLanguage)) {
if (
normalisedLanguage !== 'Unknown' &&
!parsedFilename.languages.includes(normalisedLanguage)
) {
parsedFilename.languages.push(normalisedLanguage);
}
}
// usenet results provide size as a string, we need to convert it to a number
const validateBehaviorHintSize = (size: string | number | undefined) => typeof size === 'string' ? parseInt(size) : size;
const sizeInBytes = stream.size || validateBehaviorHintSize(stream.behaviorHints?.videoSize) || (dSize ? extractSizeInBytes(dSize, 1000) : undefined)
const validateBehaviorHintSize = (size: string | number | undefined) =>
typeof size === 'string' ? parseInt(size) : size;
const sizeInBytes =
stream.size ||
validateBehaviorHintSize(stream.behaviorHints?.videoSize) ||
(dSize ? extractSizeInBytes(dSize, 1000) : undefined);
const provider = {
id: 'torbox',
cached: stream.is_cached,
};
const seeders = type === 'torrent' ? (stream.seeders || (dAgeOrSeeders ? parseInt(dAgeOrSeeders) : undefined)) : undefined;
const seeders =
type === 'torrent'
? stream.seeders ||
(dAgeOrSeeders ? parseInt(dAgeOrSeeders) : undefined)
: undefined;
const age = type === 'usenet' ? dAgeOrSeeders || undefined : undefined;
const parsedStream: ParsedStream = this.createParsedResult(
+27 -14
View File
@@ -2,7 +2,7 @@ import { AddonDetail, ParsedNameData, StreamRequest } from '@aiostreams/types';
import { parseFilename, extractSizeInBytes } from '@aiostreams/parser';
import { ParsedStream, Stream, Config } from '@aiostreams/types';
import { BaseWrapper } from './base';
import { addonDetails, serviceDetails } from '@aiostreams/utils'
import { addonDetails, serviceDetails } from '@aiostreams/utils';
import { Settings } from '@aiostreams/utils';
import { emojiToLanguage } from '@aiostreams/formatters';
@@ -17,17 +17,16 @@ export class Torrentio extends BaseWrapper {
) {
let url = overrideUrl
? overrideUrl
: Settings.TORRENTIO_URL +
(configString ? configString + '/' : '');
: Settings.TORRENTIO_URL + (configString ? configString + '/' : '');
super(addonName, url, indexerTimeout, addonId, userConfig);
}
protected parseStream(stream: Stream): ParsedStream {
const filename = stream.title
? stream.title.split('\n')[0]
const filename = stream.title
? stream.title.split('\n')[0]
: stream.behaviorHints?.filename?.trim();
const parsedFilename: ParsedNameData = parseFilename(filename || '');
const sizeInBytes = stream.title
? extractSizeInBytes(stream.title, 1024)
@@ -37,22 +36,34 @@ export class Torrentio extends BaseWrapper {
);
const debrid = debridMatch
? {
id: serviceDetails.find((service) => service.knownNames.includes(debridMatch[1]))?.id || debridMatch[1],
id:
serviceDetails.find((service) =>
service.knownNames.includes(debridMatch[1])
)?.id || debridMatch[1],
cached: debridMatch[2] === '+',
}
: undefined;
const seedersMatch = RegExp(/👤 (\d+)/).exec(stream.title!);
const seeders = seedersMatch ? parseInt(seedersMatch[1]) : undefined;
const indexerMatch = RegExp(/⚙️ (.+)/).exec(stream.title?.split('\n')[1] || '');
const indexerMatch = RegExp(/⚙️ (.+)/).exec(
stream.title?.split('\n')[1] || ''
);
const indexer = indexerMatch ? indexerMatch[1] : undefined;
const lastLine = stream.title?.split('\n').pop();
if (lastLine && !(lastLine.includes('👤') && lastLine.includes('💾') && lastLine.includes('⚙️'))) {
// this line contains languages separated by ' / '.
if (
lastLine &&
!(
lastLine.includes('👤') &&
lastLine.includes('💾') &&
lastLine.includes('⚙️')
)
) {
// this line contains languages separated by ' / '.
const languages = lastLine.split(' / ');
// 'Multi Audio' can be converted to 'Multi'
// other ones are flag emojis and need to be converted to languages.
// other ones are flag emojis and need to be converted to languages.
languages.forEach((language, index) => {
let convertedLanguage = language.trim();
if (convertedLanguage === 'Multi Audio') {
@@ -60,7 +71,9 @@ export class Torrentio extends BaseWrapper {
} else {
convertedLanguage = emojiToLanguage(language) || language;
// uppercase the first letter of each word
convertedLanguage = convertedLanguage.replace(/\b\w/g, (char) => char.toUpperCase());
convertedLanguage = convertedLanguage.replace(/\b\w/g, (char) =>
char.toUpperCase()
);
}
if (!parsedFilename.languages.includes(convertedLanguage)) {
parsedFilename.languages.push(convertedLanguage);
@@ -115,8 +128,8 @@ export async function getTorrentioStreams(
}
// find all usable services
const usableServices = config.services.filter((service) =>
supportedServices.includes(service.id) && service.enabled
const usableServices = config.services.filter(
(service) => supportedServices.includes(service.id) && service.enabled
);
// if no usable services found, use torrentio without any configuration