first commit

This commit is contained in:
Viren070
2024-12-22 21:55:52 +00:00
commit cb2e3940cd
41 changed files with 5824 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"endOfLine": "lf"
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Viren070
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+4
View File
@@ -0,0 +1,4 @@
{
"projectName": "stremio-aiostreams",
"lastCommit": ""
}
+4533
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "aiostreams",
"version": "1.0.0",
"description": "Stremio addon to combine streams into one addon",
"main": "dist/server.js",
"scripts": {
"test": "npm run test --workspaces",
"format": "prettier --write .",
"build": "npm -w packages/types run build && npm -w packages/parser run build && npm -w packages/formatters run build && npm -w packages/wrappers run build && npm -w packages/addon run build",
"build:watch": "tsc --build --watch",
"start:addon": "npm -w packages/addon start",
"start:addon:dev": "npm -w packages/addon run start:dev",
"start:cloudflare-worker:dev": "npm -w packages/cloudflare-worker run dev",
"deploy:beamup": "beamup",
"deploy:cloudflare-worker": "npm -w packages/cloudflare-worker run deploy",
"version": "npm version $npm_package_version --workspaces && git add **/package.json"
},
"author": "Viren070",
"license": "MIT",
"workspaces": [
"packages/*"
],
"devDependencies": {
"@types/node": "^20.14.10",
"beamup-cli": "^1.3.0",
"cross-env": "^7.0.3",
"prettier": "^3.3.2",
"tsx": "^4.16.2",
"typescript": "^5.5.3",
"vitest": "^2.1.5"
},
"engines": {
"node": ">=20.0.0"
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@aiostreams/addon",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"test": "vitest run --passWithNoTests",
"test:watch": "vitest watch",
"build": "tsc",
"prepublish": "npm run build",
"start": "node dist/server.js",
"start:dev": "cross-env NODE_ENV=dev tsx watch src/server.ts"
},
"description": "Ps",
"dependencies": {
"@aiostreams/wrappers": "^1.0.0",
"@aiostreams/formatters": "^1.0.0",
"@aiostreams/types": "^1.0.0",
"express": "^4.21.2"
},
"devDependencies": {
"@types/express": "^5.0.0"
}
}
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Configure Addon</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<h1>Configure Addon</h1>
<form id="configForm">
<label for="apiKey">API Key:</label>
<input type="text" id="apiKey" name="apiKey" required>
<button type="submit">Submit</button>
</form>
<script src="script.js"></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
document.getElementById('configForm').addEventListener('submit', function(event) {
event.preventDefault();
const apiKey = document.getElementById('apiKey').value;
alert(`API Key submitted: ${apiKey}`);
// You can add more logic here to handle the form submission
});
+47
View File
@@ -0,0 +1,47 @@
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f0f0f0;
}
h1 {
margin-bottom: 20px;
}
form {
background: white;
padding: 20px;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
label {
display: block;
margin-bottom: 10px;
}
input {
width: 100%;
padding: 8px;
margin-bottom: 20px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
padding: 10px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
+261
View File
@@ -0,0 +1,261 @@
import { BaseWrapper, Torbox, Torrentio } from '@aiostreams/wrappers';
import { Stream, ParsedStream, StreamRequest, Config } from '@aiostreams/types';
import { gdriveFormat } from '@aiostreams/formatters';
export class AIOStreams {
private config: Config;
constructor(config: any) {
this.config = config;
this.configValidator();
}
private configValidator() {
if (!this.config) {
throw new Error('No config provided');
}
if (!this.config.resolutions) {
throw new Error('No resolutions provided');
}
if (!this.config.qualities) {
throw new Error('No qualities provided');
}
if (!this.config.visualTags) {
throw new Error('No visualTags provided');
}
}
public async getStreams (streamRequest: StreamRequest): Promise<Stream[]> {
const streams: Stream[] = [];
const parsedStreams = await this.getParsedStreams(streamRequest);
console.log(`Got ${parsedStreams.length} streams`);
// Apply filtering
const filteredResults = parsedStreams.filter(
(parsedStream) =>
this.config.resolutions.includes(parsedStream.resolution) &&
this.config.qualities.includes(parsedStream.quality) &&
parsedStream.visualTags.every((tag) => this.config.visualTags.includes(tag))// &&
//(!parsedStream.provider || this.config.onlyShowCachedStreams || parsedStream.provider.cached)
);
console.log(`Filtered to ${filteredResults.length} streams`);
// Apply sorting
// initially sort by filename
filteredResults.sort((a, b) => a.filename.localeCompare(b.filename));
// then apply our this.config sorting
filteredResults.sort((a, b) => {
const languageComparison = this.compareLanguages(a, b);
if (languageComparison !== 0) return languageComparison;
for (const sortByField of this.config.sortBy) {
const fieldComparison = this.compareByField(a, b, sortByField);
if (fieldComparison !== 0) return fieldComparison;
}
return 0;
});
console.log("Sorted streams")
// Create stream objects
filteredResults.map((parsedStream) => {
streams.push(this.createStreamObject(parsedStream));
});
console.log("Created stream objects")
return streams;
}
private createStreamObject(parsedStream: ParsedStream): Stream {
let name: string = '';
let description: string = '';
console.log(`Creating stream object for ${parsedStream.filename} with formatter ${this.config.formatter}`);
switch (this.config.formatter) {
case 'gdrive': {
const { name: _name, description: _description } = gdriveFormat(parsedStream);
name = _name;
description = _description;
break;
}
default: {
throw new Error('Unsupported formatter');
}
}
const combinedTags = [
parsedStream.resolution,
parsedStream.quality,
parsedStream.encode,
...parsedStream.visualTags,
...parsedStream.audioTags,
...parsedStream.languages,
];
let stream: Stream;
stream = {
url: parsedStream.url,
externalUrl: parsedStream.externalUrl,
infoHash: parsedStream.torrent?.infoHash,
fileIdx: parsedStream.torrent?.fileIdx,
name: name,
description: description,
subtitles: parsedStream.stream?.subtitles,
sources: parsedStream.torrent?.sources,
behaviorHints: {
videoSize: Math.floor(parsedStream.size || 0),
filename: parsedStream.filename,
bingeGroup: `${parsedStream.addonName}|${combinedTags.join('|')}`,
proxyHeaders: parsedStream.stream?.behaviorHints?.proxyHeaders,
notWebReady: parsedStream.stream?.behaviorHints?.notWebReady,
},
}
return stream;
}
private compareLanguages(a: ParsedStream, b: ParsedStream) {
if (this.config.prioritiseLanguage) {
const aHasPrioritisedLanguage = a.languages.includes(
this.config.prioritiseLanguage
);
const bHasPrioritisedLanguage = b.languages.includes(
this.config.prioritiseLanguage
);
const aHasMultiLanguage = a.languages.includes('Multi');
const bHasMultiLanguage = b.languages.includes('Multi');
if (aHasPrioritisedLanguage && !bHasPrioritisedLanguage) return -1;
if (!aHasPrioritisedLanguage && bHasPrioritisedLanguage) return 1;
if (aHasMultiLanguage && !bHasMultiLanguage) return -1;
if (!aHasMultiLanguage && bHasMultiLanguage) return 1;
}
return 0;
}
private compareByField(a: ParsedStream, b: ParsedStream, field: string) {
if (field === 'resolution') {
return (
this.config.resolutions.indexOf(a.resolution) -
this.config.resolutions.indexOf(b.resolution)
);
} else if (field === 'cached') {
let aCanbeCached = a.provider
let bCanbeCached = b.provider;
let aCached = a.provider?.cached
let bCached = b.provider?.cached;
// prioritise non debrid/usenet p2p over uncached
if (aCanbeCached && !bCanbeCached && !aCached) return 1;
if (!aCanbeCached && bCanbeCached && !bCached) return -1;
if (aCanbeCached && bCanbeCached) {
if (aCached === bCached) return 0;
return aCached ? -1 : 1;
}
} else if (field === 'hasProvider') {
// files from a provider should be prioritised and then
let aHasProvider = a.provider;
let bHasProvider = b.provider;
if (aHasProvider && !bHasProvider) return -1;
if (!aHasProvider && bHasProvider) return 1;
} else if (field === 'provider') {
// sort files with providers by name
let aProvider = a.provider?.name;
let bProvider = b.provider?.name;
if (aProvider && bProvider) {
return aProvider.localeCompare(bProvider);
}
} else if (field === 'size') {
return (b.size || 0) - (a.size || 0);
} else if (field === 'seeders') {
if (a.torrent?.seeders && b.torrent?.seeders) {
return b.torrent.seeders - a.torrent.seeders;
}
} else if (field === 'quality') {
return (
this.config.qualities.indexOf(a.quality) - this.config.qualities.indexOf(b.quality)
);
} else if (field === 'visualTag') {
// Find the highest priority visual tag in each file
const getIndexOfTag = (tag: string) =>
this.config.considerHdrTagsAsEqual && tag.startsWith('HDR')
? this.config.visualTags.indexOf('HDR10+')
: this.config.visualTags.indexOf(tag);
const aVisualTagIndex = a.visualTags.reduce(
(minIndex, tag) => Math.min(minIndex, getIndexOfTag(tag)),
this.config.visualTags.length
);
const bVisualTagIndex = b.visualTags.reduce(
(minIndex, tag) => Math.min(minIndex, getIndexOfTag(tag)),
this.config.visualTags.length
);
// Sort by the visual tag index
return aVisualTagIndex - bVisualTagIndex;
}
return 0;
}
private async getParsedStreams(streamRequest: StreamRequest): Promise<ParsedStream[]> {
const parsedStreams: ParsedStream[] = [];
for (const addon of this.config.addons) {
switch (addon) {
case ('gdrive'): {
break;
}
case ('torbox'): {
try {
if (!this.config.apiKeys.torbox) {
throw new Error('No torbox api key provided');
}
const wrapper = new Torbox(this.config.apiKeys.torbox);
const streams = await wrapper.getParsedStreams(streamRequest);
console.log(`Got streams from ${addon}: ${streams.length}`);
parsedStreams.push(...streams);
} catch (e) {
console.error(`Error getting streams from ${addon}: ${e}`);
}
break;
}
case ('torrentio'): {
try {
const wrapper = new Torrentio(this.config.apiKeys);
const streams = await wrapper.getParsedStreams(streamRequest);
console.log(`Got streams from ${addon}: ${streams.length}`);
parsedStreams.push(...streams);
} catch (e) {
console.error(`Error getting streams from ${addon}: ${e}`);
}
break;
}
default: {
try {
const wrapper = new BaseWrapper("unknown", addon);
const streams = await wrapper.getParsedStreams(streamRequest);
console.log(`Got streams from ${addon}: ${streams.length}`);
parsedStreams.push(...streams);
} catch (e) {
console.error(`Error getting streams from ${addon}: ${e}`);
}
}
}
}
return parsedStreams;
}
}
+1
View File
@@ -0,0 +1 @@
export * from './addon';
+104
View File
@@ -0,0 +1,104 @@
import express, { Request, Response} from 'express';
import path from 'path';
import { AIOStreams } from './addon';
import { StreamRequest } from '@aiostreams/types';
const app = express();
const port = process.env.PORT || 3000;
// Built-in middleware for parsing JSON
app.use(express.json());
// Built-in middleware for parsing URL-encoded data
app.use(express.urlencoded({ extended: true }));
// Serve static files
app.use(express.static(path.join(__dirname, '..', 'public')));
app.get('/', (req, res) => {
res.redirect('/configure');
});
app.get('/configure', (req, res) => {
// Render a form for configuring the addon
res.sendFile(path.join(__dirname, '..', 'public', 'configure.html'));
});
app.get('/stream/:type/:id', (req, res) => {
const response = {
streams: [
{
url: 'https://example.com',
name: 'Missing Config',
description: 'You must configure this addon to use it',
},
],
};
res.send(JSON.stringify(response, null, 2)); // 2 spaces for indentation
});
app.get('/:config/stream/:type/:id', (req: Request, res: Response) => {
const config = req.params.config;
// Decode Base64 encoded JSON config
const decodedConfig = Buffer.from(config, 'base64').toString('utf-8');
const configJson = JSON.parse(decodedConfig);
console.log(`Config: ${JSON.stringify(configJson, null, 2)}`);
const streamMatch = new RegExp(`/${config}/stream/(movie|series)/tt([0-9]{7,})(?::([0-9]+):([0-9]+))?\.json`).exec(req.path);
if (!streamMatch) {
res.status(400).send('Invalid request');
return;
}
const [type, id, season, episode] = streamMatch.slice(1);
console.log(`Type: ${type}, ID: ${id}, Season: ${season}, Episode: ${episode}`);
let streamRequest: StreamRequest;
switch (type) {
case 'series':
if (!season || !episode) {
res.status(400).send('Invalid request');
return;
}
streamRequest = {
id,
type: 'series',
season,
episode,
};
break;
case 'movie':
if (season || episode) {
res.status(400).send('Invalid request');
return;
}
streamRequest = {
id,
type: 'movie',
};
break;
default:
res.status(400).send('Invalid request');
return;
}
try {
const aioStreams = new AIOStreams(configJson);
aioStreams.getStreams({ id, type, season, episode }).then((streams) => {
res.send(streams);
});
} catch (error: any) {
res.status(500).send(error.message);
}
});
app.listen(port, () => {
console.log(`Server is running at http://localhost:${port}`);
});
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"resolveJsonModule": true
},
"references": [
{
"path": "../wrappers"
},
{
"path": "../formatters"
},
{
"path": "../types"
}
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@aiostreams/cloudflare-worker",
"version": "1.0.0",
"scripts": {
"test": "vitest run --passWithNoTests",
"dev": "wrangler dev src/index.ts",
"deploy": "wrangler deploy --minify src/index.ts",
"build": "exit 0"
},
"dependencies": {
"@aiostreams/addon": "^1.0.0",
"hono": "^4.5.3",
"hono-stremio": "^0.1.1"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240529.0",
"wrangler": "^3.57.2"
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Hono } from 'hono';
import { getRouter } from 'hono-stremio';
import { addonInterface, landingHTML } from '@aiostreams/addon';
const addonRouter = getRouter(addonInterface, { landingHTML });
const app = new Hono();
app.route('/', addonRouter);
export default app;
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"lib": ["ESNext"],
"types": ["@cloudflare/workers-types"],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
},
"references": [
{
"path": "../addon"
}
]
}
@@ -0,0 +1,8 @@
name = "aiostreams-cloudflare-worker"
main = "src/index.js"
compatibility_date = "2024-12-02"
workers_dev = true
preview_urls = true
compatibility_flags = [ "nodejs_compat" ]
[observability.logs]
enabled = true
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@aiostreams/formatters",
"version": "1.0.0",
"main": "./dist/index.js",
"scripts": {
"test": "vitest run --passWithNoTests",
"build": "tsc"
},
"description": "Library to take parsed information and return a formatted Stremio stream name and description",
"dependencies": {
"@aiostreams/types": "1.0.0"
}
}
+42
View File
@@ -0,0 +1,42 @@
import { ParsedStream } from '@aiostreams/types';
import { formatSize } from './utils';
export function gdriveFormat(stream: ParsedStream): { name: string, description: string} {
let name: string = '';
if (stream.provider) {
name += stream.provider.cached ? `[${stream.provider.name}⚡]\n` : `[${stream.provider.name}⏳]\n`;
}
if (stream.torrent?.infoHash) {
name += `[P2P]\n`;
}
name += `${stream.addonName} ${stream.resolution}`;
let description: string = `🎥 ${stream.quality} ${stream.encode ? '🎞️ ' + stream.encode : ''}`;
if (stream.visualTags.length > 0 || stream.audioTags.length > 0) {
description += '\n';
description += stream.visualTags.length > 0 ? `📺 ${stream.visualTags.join(' | ')} ` : '';
description += stream.audioTags.length > 0 ? `🎧 ${stream.audioTags.join(' | ')}` : '';
}
if (stream.size || stream.torrent?.seeders || stream.usenet?.age) {
description += '\n';
description += `📦 ${formatSize(stream.size || 0)}`;
description += stream.torrent?.seeders ? `👥 ${stream.torrent.seeders} ` : '';
description += stream.usenet?.age ? `📅 ${stream.usenet.age}d` : '';
}
if (stream.languages.length !== 0) {
description += `\n🔊 ${stream.languages.join(' | ')}`;
}
description += `\n📄 ${stream.filename}`;
return { name, description };
}
+2
View File
@@ -0,0 +1,2 @@
export * from './gdrive';
export * from './utils';
+7
View File
@@ -0,0 +1,7 @@
export function formatSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1000;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"resolveJsonModule": true
},
"references": [
{
"path": "../types"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@aiostreams/parser",
"version": "1.0.0",
"main": "./dist/index.js",
"scripts": {
"test": "vitest run --passWithNoTests",
"build": "tsc"
},
"description": "Library to parse a torrent filename ",
"dependencies": {
"@aiostreams/types": "1.0.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './parser';
export * from './regex';
export * from './utils';
+45
View File
@@ -0,0 +1,45 @@
import { ParsedNameData } from '@aiostreams/types';
import { PARSE_REGEX } from './regex';
const DEFAULT_RESOLUTION = 'Unknown';
const DEFAULT_QUALITY = 'Unknown';
function matchPattern(
filename: string,
patterns: Record<string, RegExp>
): string {
return (
Object.entries(patterns).find(([_, pattern]) =>
pattern.test(filename)
)?.[0] ?? ''
);
}
function matchMultiplePatterns(
filename: string,
patterns: Record<string, RegExp>
): string[] {
return Object.entries(patterns)
.filter(([_, pattern]) => pattern.test(filename))
.map(([tag]) => tag);
}
export function parseFilename(filename: string): ParsedNameData {
const resolution =
matchPattern(filename, PARSE_REGEX.resolutions) || DEFAULT_RESOLUTION;
const quality =
matchPattern(filename, PARSE_REGEX.qualities) || DEFAULT_QUALITY;
let visualTags = matchMultiplePatterns(filename, PARSE_REGEX.visualTags);
const audioTags = matchMultiplePatterns(filename, PARSE_REGEX.audioTags);
const encode = matchPattern(filename, PARSE_REGEX.encodes);
const languages = matchMultiplePatterns(filename, PARSE_REGEX.languages);
return {
resolution,
quality,
languages,
encode,
audioTags,
visualTags,
};
}
+74
View File
@@ -0,0 +1,74 @@
export const PARSE_REGEX = {
resolutions: {
'2160p': /(?<![^ [_\-.])(4k|2160p|uhd)(?=[ \]_.-]|$)/i,
'1080p': /(?<![^ [_\-.])(1080p|fhd)(?=[ \]_.-]|$)/i,
'720p': /(?<![^ [_\-.])(720p|hd)(?=[ \]_.-]|$)/i,
'480p': /(?<![^ [_\-.])(480p|sd)(?=[ \]_.-]|$)/i,
},
qualities: {
'BluRay REMUX':
/(?<![^ [_\-.])((blu[ .\-_]?ray|bd|br|b)[ .\-_]?remux)(?=[ \]_.-]|$)/i,
BluRay:
/(?<![^ [_\-.])(blu[ .\-_]?ray|((bd|br|b)[ .\-_]?(rip|r)?))(?![ .\-_]?remux)(?=[ \]_.-]|$)/i,
'WEB-DL':
/(?<![^ [_\-.])(web[ .\-_]?(dl)?)(?![ .\-_]?DLRip)(?=[ \]_.-]|$)/i,
WEBRip: /(?<![^ [_\-.])(web[ .\-_]?rip)(?=[ \]_.-]|$)/i,
HDRip:
/(?<![^ [_\-.])(hd[ .\-_]?rip|web[ .\-_]?dl[ .\-_]?rip)(?=[ \]_.-]|$)/i,
'HC HD-Rip': /(?<![^ [_\-.])(hc|hd[ .\-_]?rip)(?=[ \]_.-]|$)/i,
DVDRip: /(?<![^ [_\-.])(dvd[ .\-_]?(rip|mux|r|full|5|9))(?=[ \]_.-]|$)/i,
HDTV: /(?<![^ [_\-.])((hd|pd)tv|tv[ .\-_]?rip|hdtv[ .\-_]?rip|dsr(ip)?|sat[ .\-_]?rip)(?=[ \]_.-]|$)/i,
CAM: /(?<![^ [_\-.])(cam|hdcam|cam[ .\-_]?rip)(?=[ \]_.-]|$)/i,
TS: /(?<![^ [_\-.])(telesync|ts|hd[ .\-_]?ts|pdvd|predvdrip)(?=[ \]_.-]|$)/i,
TC: /(?<![^ [_\-.])(telecine|tc|hd[ .\-_]?tc)(?=[ \]_.-]|$)/i,
SCR: /(?<![^ [_\-.])(((dvd|bd|web)?[ .\-_]?)?(scr(eener)?))(?=[ \]_.-]|$)/i,
},
visualTags: {
'HDR10+': /(?<![^ [_\-.])(hdr10[ .\-_]?[+]|hdr10plus)(?=[ \]_.-]|$)/i,
HDR10: /(?<![^ [_\-.])(hdr10)(?=[ \]_.-]|$)/i,
HDR: /(?<![^ [_\-.])(hdr)(?=[ \]_.-]|$)/i,
DV: /(?<![^ [_\-.])(dolby[ .\-_]?vision(?:[ .\-_]?atmos)?|dv)(?=[ \]_.-]|$)/i,
IMAX: /(?<![^ [_\-.])(imax)(?=[ \]_.-]|$)/i,
AI: /(?<![^ [_\-.])(ai[ .\-_]?(upscale|enhanced|remaster))(?=[ \]_.-]|$)/i,
},
audioTags: {
Atmos: /(?<![^ [_\-.])(atmos)(?=[ \]_.-]|$)/i,
'DD+':
/(?<![^ [_\-.])((?:ddp|dolby[ .\-_]?digital[ .\-_]?plus)(?:[ .\-_]?(5\.1|7\.1))?)(?=[ \]_.-]|$)/i,
DD: /(?<![^ [_\-.])((?:dd|dolby[ .\-_]?digital)(?:[ .\-_]?(5\.1|7\.1))?)(?=[ \]_.-]|$)/i,
'DTS-HD': /(?<![^ [_\-.])(dts[ .\-_]?hd[ .\-_]?ma?)(?=[ \]_.-]|$)/i,
DTS: /(?<![^ [_\-.])(dts)(?=[ \]_.-]|$)/i,
TrueHD: /(?<![^ [_\-.])(truehd)(?=[ \]_.-]|$)/i,
5.1: /(?<![^ [_\-.])((?:ddp|dd)?[ .\-_]?5\.1)(?=[ \]_.-]|$)/i,
7.1: /(?<![^ [_\-.])((?:ddp|dd)?[ .\-_]?7\.1)(?=[ \]_.-]|$)/i,
AC3: /(?<![^ [_\-.])(ac[ .\-_]?3)(?=[ \]_.-]|$)/i,
AAC: /(?<![^ [_\-.])(aac)(?=[ \]_.-]|$)/i,
},
encodes: {
x265: /(?<![^ [_\-.])(x265|h265|h\.265)(?=[ \]_.-]|$)/i,
x264: /(?<![^ [_\-.])(x264|h264|h\.264)(?=[ \]_.-]|$)/i,
HEVC: /(?<![^ [_\-.])(hevc)(?=[ \]_.-]|$)/i,
AVC: /(?<![^ [_\-.])(avc)(?=[ \]_.-]|$)/i,
},
languages: {
English: /(?<![^ [_\-.])(english|eng)(?=[ \]_.-]|$)/i,
Hindi: /(?<![^ [_\-.])(hindi|hin)(?=[ \]_.-]|$)/i,
Tamil: /(?<![^ [_\-.])(tamil|tam)(?=[ \]_.-]|$)/i,
Telugu: /(?<![^ [_\-.])(telugu)(?=[ \]_.-]|$)/i,
Malayalam: /(?<![^ [_\-.])(malayalam)(?=[ \]_.-]|$)/i,
Kannada: /(?<![^ [_\-.])(kannada)(?=[ \]_.-]|$)/i,
Bengali: /(?<![^ [_\-.])(bengali)(?=[ \]_.-]|$)/i,
Punjabi: /(?<![^ [_\-.])(punjabi)(?=[ \]_.-]|$)/i,
Marathi: /(?<![^ [_\-.])(marathi)(?=[ \]_.-]|$)/i,
French: /(?<![^ [_\-.])(french|fra)(?=[ \]_.-]|$)/i,
Spanish: /(?<![^ [_\-.])(spanish|spa)(?=[ \]_.-]|$)/i,
German: /(?<![^ [_\-.])(german|deu)(?=[ \]_.-]|$)/i,
Italian: /(?<![^ [_\-.])(italian|ita)(?=[ \]_.-]|$)/i,
Japanese: /(?<![^ [_\-.])(japanese|jpn)(?=[ \]_.-]|$)/i,
Korean: /(?<![^ [_\-.])(korean|kor)(?=[ \]_.-]|$)/i,
Chinese: /(?<![^ [_\-.])(chinese|chn)(?=[ \]_.-]|$)/i,
Russian: /(?<![^ [_\-.])(russian|rus)(?=[ \]_.-]|$)/i,
Multi: /(?<![^ [_\-.])(MULTi)(?=[ \]_.-]|$)/i,
'Dual Audio': /(?<![^ [_\-.])(dual[ .\-_]?audio)(?=[ \]_.-]|$)/i,
},
};
+19
View File
@@ -0,0 +1,19 @@
export function extractSizeInBytes(string: string, k: number): number {
const sizePattern = /(\d+(\.\d+)?)\s?(KB|MB|GB)/;
const match = string.match(sizePattern);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = match[3];
switch (unit) {
case "GB":
return value * k * k * k;
case "MB":
return value * k * k;
case "KB":
return value * k;
default:
return 0;
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"resolveJsonModule": true
},
"references": [
{
"path": "../types"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@aiostreams/types",
"version": "1.0.0",
"main": "./dist/index.js",
"scripts": {
"test": "vitest run --passWithNoTests",
"build": "tsc"
},
"description": "Library to parse a torrent filename "
}
+1
View File
@@ -0,0 +1 @@
export * from './types';
+113
View File
@@ -0,0 +1,113 @@
// the data that is parsed from a filename
export interface ParsedNameData {
resolution: string;
quality: string;
encode: string;
visualTags: string[];
audioTags: string[];
languages: string[];
}
// the parsed stream data which is to be used to create the final stream object
export interface ParsedStream extends ParsedNameData {
addonName: string;
filename: string;
size?: number;
provider?: {
name: string;
cached: boolean;
};
torrent?: {
infoHash?: string;
fileIdx?: number;
seeders?: number;
sources?: string[];
};
usenet?: {
age?: number;
};
url?: string;
externalUrl?: string;
stream?: {
subtitles?: Subtitle[];
behaviorHints?: {
countryWhitelist?: string[];
notWebReady?: boolean;
proxyHeaders?: {
request?: { [key: string]: string };
response?: { [key: string]: string };
};
videoHash?: string;
};
};
}
export interface Stream {
url?: string;
externalUrl?: string;
infoHash?: string;
fileIdx?: number;
name?: string;
title?: string;
description?: string;
subtitles?: Subtitle[];
sources?: string[];
behaviorHints?: {
countryWhitelist?: string[];
notWebReady?: boolean;
bingeGroup?: string;
proxyHeaders?: {
request?: { [key: string]: string };
response?: { [key: string]: string };
};
videoHash?: string;
videoSize?: number;
filename?: string;
};
}
export interface Subtitle {
id: string;
url: string;
lang: string;
}
interface MovieRequest {
id: string;
type: 'movie';
}
interface SeriesRequest {
id: string;
type: 'series';
season: string;
episode: string;
}
export type StreamRequest = MovieRequest | SeriesRequest;
export interface Config {
resolutions: string[];
qualities: string[];
visualTags: string[];
sortBy: string[];
onlyShowCachedStreams: boolean;
considerHdrTagsAsEqual: boolean;
prioritiseLanguage: string | null;
addons: string[];
formatter: string;
apiKeys: {
realDebrid: string;
torbox: string;
debridLink: string;
allDebrid: string;
premiumize: string;
offcloud: string;
putIo: string;
};
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@aiostreams/wrappers",
"version": "1.0.0",
"main": "./dist/index.js",
"scripts": {
"test": "vitest run --passWithNoTests",
"build": "tsc"
},
"description": "Library with wrappers for different addons",
"devDependencies": {
"@aiostreams/parser": "^1.0.0",
"@aiostreams/types": "^1.0.0"
}
}
+122
View File
@@ -0,0 +1,122 @@
import {
Stream,
ParsedStream,
StreamRequest,
ParsedNameData,
} from '@aiostreams/types';
import { parseFilename } from '@aiostreams/parser';
/*
1. wrapper gets the streams from the addon using the StreamRequest object which contains the type, id, season, and episode
This returns a dictionary with a streams key that contains an array of Stream objects
2. wrapper parses each stream, and returns an object that contains all the necessary information
*/
export class BaseWrapper {
private readonly streamPath: string = 'stream/{type}/tt{id}.json';
private indexerTimeout: number;
private addonName: string;
private addonUrl: string;
constructor(addonName: string, addonUrl: string, indexerTimeout?: number) {
this.addonName = addonName;
this.addonUrl = addonUrl;
this.indexerTimeout = indexerTimeout || 3000;
}
public async getParsedStreams(
streamRequest: StreamRequest
): Promise<ParsedStream[]> {
const streams: Stream[] = await this.getStreams(streamRequest);
console.log('Streams:', streams);
const parsedStreams: ParsedStream[] = streams
.map((stream) => this.parseStream(stream))
.filter((parsedStream) => parsedStream !== undefined);
return parsedStreams;
}
private getStreamUrl(streamRequest: StreamRequest) {
let finalId =
streamRequest.type === 'series'
? `${streamRequest.id}:${streamRequest.season}:${streamRequest.episode}`
: streamRequest.id;
return (
this.addonUrl +
this.streamPath
.replace('{type}', streamRequest.type)
.replace('{id}', finalId)
);
}
protected async getStreams(streamRequest: StreamRequest): Promise<Stream[]> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, this.indexerTimeout);
console.log('Fetching streams from', this.getStreamUrl(streamRequest), 'with timeout', this.indexerTimeout);
let url = this.getStreamUrl(streamRequest);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) {
return Promise.reject(new Error(await response.text()));
}
let results: { streams: Stream[] } = await response.json();
return results.streams;
}
protected createParsedResult(
parsedInfo: ParsedNameData,
stream: Stream,
filename: string,
size?: number
): ParsedStream {
return {
...parsedInfo,
addonName: this.addonName,
filename: filename,
size: size,
url: stream.url,
externalUrl: stream.externalUrl,
torrent: {
infoHash: stream.infoHash,
fileIdx: stream.fileIdx,
sources: stream.sources,
},
stream: {
subtitles: stream.subtitles,
behaviorHints: {
countryWhitelist: stream.behaviorHints?.countryWhitelist,
notWebReady: stream.behaviorHints?.notWebReady,
proxyHeaders: {
request: stream.behaviorHints?.proxyHeaders?.request,
response: stream.behaviorHints?.proxyHeaders?.response,
},
videoHash: stream.behaviorHints?.videoHash,
},
},
};
}
protected parseStream(stream: Stream): ParsedStream | undefined {
// attempt to look for filename in behaviorHints.filename, return undefined if not found
let filename = stream.behaviorHints?.filename || undefined;
if (!filename) {
return undefined;
}
// parse the filename using our parser.
let parsedInfo: ParsedNameData = parseFilename(filename);
// see if size is present in behaviorHints
let size: number | undefined;
let formattedSize: string | undefined;
if (stream.behaviorHints?.videoSize) {
size = stream.behaviorHints.videoSize;
}
return this.createParsedResult(parsedInfo, stream, filename, size);
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './base';
export * from './torbox';
export * from './torrentio';
+73
View File
@@ -0,0 +1,73 @@
import { BaseWrapper } from "./base";
import { ParsedNameData, ParsedStream } from "@aiostreams/types";
import { parseFilename } from "@aiostreams/parser";
interface TorboxStream {
name: string;
description: string;
size: number;
url: string;
type: string;
seeders: number;
is_cached: boolean;
}
export class Torbox extends BaseWrapper {
private readonly name: string = "Torbox";
constructor(apiKey: string) {
super("Torbox", "https://stremio.torbox.app/" + apiKey + "/", 10000);
}
protected parseStream(stream: TorboxStream): ParsedStream | undefined {
if (stream.name.includes("Your Media")) {
return undefined;
}
let type = stream.type;
const [quality, filename, _, language, ageOrSeeders] = stream.description.split("\n").map(
(field: string) => {
if (field.startsWith("Type")) {
const [typeField, ageOrSeeders] = field.split("|")
if (!["torrent", "usenet"].includes(type)) {
type = typeField.split(":")[1].trim().toLowerCase();
}
const [_, value] = ageOrSeeders.split(":");
return value.trim();
}
const [_, value] = field.split(":");
return value.trim();
}
)
const parsedFilename: ParsedNameData = parseFilename(filename);
if (parsedFilename.quality === "Unknown" && quality !== "Unknown") {
parsedFilename.quality = quality;
}
if (!parsedFilename.languages.some((lang: string) => lang.toLowerCase() === language.toLowerCase()) && language !== "Unknown") {
if (!(language === "BENGALI" && RegExp(/(?<![^ [_\-.])(ben[ _\-.]?the[ _\-.]?men)(?=[ \]_.-]|$)/i).test(filename))) {
parsedFilename.languages.push(language);
}
}
const sizeInBytes = stream.size;
return {
...parsedFilename,
filename,
size: sizeInBytes,
addonName: this.name,
url: stream.url,
torrent: type === "torrent" ? {
seeders: stream.seeders,
} : undefined,
usenet: type === "usenet" ? {
age: parseInt(ageOrSeeders)
} : undefined,
provider: {
name: "TB",
cached: stream.is_cached
}
};
}
}
+46
View File
@@ -0,0 +1,46 @@
import { ParsedNameData } from "@aiostreams/types";
import { parseFilename, extractSizeInBytes } from "@aiostreams/parser";
import { ParsedStream, Stream } from "@aiostreams/types";
import { BaseWrapper } from "./base";
export class Torrentio extends BaseWrapper {
private readonly name: string = "Torrentio";
constructor(apiKeys: Record<string, string>) {
// create a string of name=apikey pairs separated by |
super("Torrentio", "https://torrentio.strem.fun/" + Object.entries(apiKeys).map(([name, apiKey]) => `${name.toLowerCase()}=${apiKey}`).join("|") + "/");
}
protected parseStream(stream: Stream): ParsedStream {
const filename = stream?.behaviorHints?.filename ? stream.behaviorHints.filename.trim() : stream.title!.split("\n")[0];
const parsedFilename: ParsedNameData = parseFilename(filename);
const sizeInBytes = stream.title ? extractSizeInBytes(stream.title, 1024) : 0;
const debridMatch = RegExp(/^\[([a-zA-Z]{2})(\+| download)\]/).exec(stream.name!);
const debrid = debridMatch ? {
provider: debridMatch[1],
cached: debridMatch[2] === "+"
} : undefined;
const seedersMatch = RegExp(/👤 (\d+)/).exec(stream.title!)
const seeders = seedersMatch ? parseInt(seedersMatch[1]) : undefined;
return {
...parsedFilename,
filename,
size: sizeInBytes,
addonName: this.name,
url: stream.url,
externalUrl: stream.externalUrl,
torrent: {
infoHash: stream.infoHash,
fileIdx: stream.fileIdx,
seeders: seeders,
sources: stream.sources
},
provider: debrid ? {
name: debrid.provider,
cached: debrid.cached
} : undefined
};
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"resolveJsonModule": true,
"composite": true
},
"references": [
{
"path": "../parser"
},
{
"path": "../types"
}
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es2021",
"module": "commonjs",
"moduleResolution": "node",
"incremental": true,
"composite": true,
"declaration": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./tsconfig.base.json",
"references": [
{
"path": "./packages/addon"
},
{
"path": "./packages/wrappers"
},
{
"path": "./packages/parser"
},
{
"path": "./packages/formatters"
},
{
"path": "./packages/types"
}
]
}