feat: drop stremio-addon-sdk (#24)

This commit is contained in:
webstreamr 2025-05-09 21:59:36 +02:00 committed by GitHub
parent 34fc1f7b43
commit 66a70dc22d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 589 additions and 1046 deletions

View file

@ -6,8 +6,7 @@ const config: Config = {
'./src/**/*.ts',
'!./src/**/index.ts',
'!./src/**/types.ts',
'!./src/addon.ts',
'!./src/server.ts',
'!./src/landingTemplate.ts',
],
coverageDirectory: 'coverage',
coverageProvider: 'babel',

1032
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,10 +6,10 @@
"scripts": {
"analyse": "tsc --noEmit",
"build": "rm -rf ./dist && tsc",
"dev": "nodemon src/server.ts",
"dev": "nodemon src/index.ts",
"lint": "eslint",
"lint:fix": "eslint --fix",
"start": "node dist/server.js",
"start": "node dist/index.js",
"test": "jest",
"ci": "npm run lint && npm run analyse && npm run test"
},
@ -31,14 +31,15 @@
},
"dependencies": {
"cheerio": "^1.0.0",
"express": "^5.1.0",
"make-fetch-happen": "^14.0.3",
"slugify": "^1.6.6",
"stremio-addon-sdk": "^1.6.10",
"unpacker": "^1.0.1"
},
"devDependencies": {
"@eslint/js": "^9.26.0",
"@stylistic/eslint-plugin": "^4.2.0",
"@types/express": "^5.0.1",
"@types/jest": "^29.5.14",
"@types/make-fetch-happen": "^10.0.4",
"@types/node": "^22.15.3",

View file

@ -1,13 +0,0 @@
import addon from './addon';
describe('addon', () => {
test('manifest can be retrieved with defaults', () => {
expect(addon.manifest).toStrictEqual(
expect.objectContaining({
id: 'webstreamr',
name: 'WebStreamr',
description: 'Provides HTTP URLs from streaming websites.',
}),
);
});
});

View file

@ -1,90 +0,0 @@
import { ContentType, Manifest, addonBuilder } from 'stremio-addon-sdk';
import { Handler, HandlerStream, KinoKiste, MeineCloud } from './handler';
import { fulfillAllPromises, iso2ToFlag, logInfo } from './utils';
const handlers: Handler[] = [
new KinoKiste(),
new MeineCloud(),
];
const manifest: Manifest = {
id: process.env['MANIFEST_ID'] || 'webstreamr',
version: '0.3.0', // x-release-please-version
name: process.env['MANIFEST_NAME'] || 'WebStreamr',
description: process.env['MANIFEST_DESCRIPTION'] || 'Provides HTTP URLs from streaming websites.',
resources: [
'stream',
],
types: [
'movie',
'series',
],
catalogs: [],
idPrefixes: ['tt'],
behaviorHints: {
p2p: false,
configurable: true,
configurationRequired: true,
},
config: [],
};
handlers.forEach((handler) => {
manifest.config?.push({
key: handler.id,
type: 'checkbox',
title: `${handler.languages.map(language => iso2ToFlag(language) + ' ' + language.toUpperCase()).join(', ')} | ${handler.label}`,
});
});
const builder = new addonBuilder(manifest);
type RequestConfig = Record<string, boolean | string | number>;
const sortStreams = (streams: HandlerStream[]): void => {
streams.sort((a, b) => {
const resolutionComparison = parseInt(b.resolution ?? '0') - parseInt(a.resolution ?? '0');
if (resolutionComparison !== 0) {
return resolutionComparison;
}
return parseFloat(b.size ?? '0') - parseFloat(a.size ?? '0');
});
};
builder.defineStreamHandler(async (args: { type: ContentType; id: string; config?: RequestConfig }) => {
logInfo(`Search stream for type "${args.type}" and id "${args.id}"`);
const selectedHandlers = handlers.filter(handler => handler.id in (args.config || {}));
if (selectedHandlers.length === 0) {
logInfo('No handlers configured, bail out');
return {
streams: [{
name: 'WebStreamr',
title: '⚠️ No handlers configured. Please re-configure the plugin.',
ytId: 'E4WlUXrJgy4',
}],
};
}
const streams: HandlerStream[] = [];
const handlerPromises = selectedHandlers.map(async (handler) => {
if (!handler.contentTypes.includes(args.type)) {
return;
}
const handlerStreams = await handler.handle(args.id);
logInfo(`${handler.id} returned ${handlerStreams.length} streams`);
streams.push(...handlerStreams);
});
await fulfillAllPromises(handlerPromises);
sortStreams(streams);
logInfo(`Return ${streams.length} streams`);
return { streams };
});
export default builder.getInterface();

113
src/index.ts Normal file
View file

@ -0,0 +1,113 @@
import express, { NextFunction, Request, Response } from 'express';
import { landingTemplate } from './landingTemplate';
import { Handler, HandlerStream, KinoKiste, MeineCloud } from './handler';
import { buildManifest, fulfillAllPromises, logInfo } from './utils';
import { Config } from './types';
const addon = express();
const handlers: Handler[] = [
new KinoKiste(),
new MeineCloud(),
];
addon.use((_req: Request, res: Response, next: NextFunction) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', '*');
if (process.env['NODE_ENV'] === 'production') {
res.setHeader('Cache-Control', 'max-age=3600, public');
}
next();
});
addon.get('/', (_req: Request, res: Response) => {
res.redirect('/configure');
});
addon.get('/configure', (_req: Request, res: Response) => {
const manifest = buildManifest(handlers, {});
res.setHeader('content-type', 'text/html');
res.send(landingTemplate(manifest));
});
addon.get('/:config/configure', (req: Request, res: Response) => {
const config: Config = JSON.parse(req.params['config'] || '{}');
const manifest = buildManifest(handlers, config);
res.setHeader('content-type', 'text/html');
res.send(landingTemplate(manifest));
});
addon.get('/manifest.json', (_req: Request, res: Response) => {
const manifest = buildManifest(handlers, {});
res.setHeader('Content-Type', 'application/json');
res.send(manifest);
});
addon.get('/:config/manifest.json', (req: Request, res: Response) => {
const config: Config = JSON.parse(req.params['config'] || '{}');
const manifest = buildManifest(handlers, config);
res.setHeader('Content-Type', 'application/json');
res.send(manifest);
});
addon.get('/:config/stream/:type/:id.json', async function (req: Request, res: Response) {
const config: Config = JSON.parse(req.params['config'] || '{}');
const type: string = req.params['type'] || '';
const id: string = req.params['id'] || '';
logInfo(`Search stream for type "${type}" and id "${id}"`);
res.setHeader('Content-Type', 'application/json');
const selectedHandlers = handlers.filter(handler => handler.id in config);
if (selectedHandlers.length === 0) {
logInfo('No handlers configured, bail out');
res.send(JSON.stringify({
streams: [{
name: 'WebStreamr',
title: '⚠️ No handlers found. Please re-configure the plugin.',
ytId: 'E4WlUXrJgy4',
}],
}));
return;
}
const streams: HandlerStream[] = [];
const handlerPromises = selectedHandlers.map(async (handler) => {
if (!handler.contentTypes.includes(type)) {
return;
}
const handlerStreams = await handler.handle(id);
logInfo(`${handler.id} returned ${handlerStreams.length} streams`);
streams.push(...handlerStreams);
});
await fulfillAllPromises(handlerPromises);
streams.sort((a, b) => {
const resolutionComparison = parseInt(b.resolution ?? '0') - parseInt(a.resolution ?? '0');
if (resolutionComparison !== 0) {
return resolutionComparison;
}
return parseFloat(b.size ?? '0') - parseFloat(a.size ?? '0');
});
logInfo(`Return ${streams.length} streams`);
res.send(JSON.stringify({ streams }));
});
const port = parseInt(process.env['PORT'] || '51546');
addon.listen(port, () => {
logInfo(`Add-on Repository URL: http://127.0.0.1:${port}/manifest.json`);
});

300
src/landingTemplate.ts Normal file
View file

@ -0,0 +1,300 @@
// Adapted version of https://github.com/Stremio/stremio-addon-sdk/blob/v1.6.2/src/landingTemplate.js
import { ManifestWithConfig } from './types';
const STYLESHEET = `
* {
box-sizing: border-box;
}
body,
html {
margin: 0;
padding: 0;
width: 100%;
min-height: 100%;
}
body {
padding: 2vh;
font-size: 2.2vh;
}
html {
background-size: auto 100%;
background-size: cover;
background-position: center center;
background-repeat: no-repeat;
box-shadow: inset 0 0 0 2000px rgb(0 0 0 / 60%);
}
body {
display: flex;
font-family: 'Open Sans', Arial, sans-serif;
color: white;
}
h1 {
font-size: 4.5vh;
font-weight: 700;
}
h2 {
font-size: 2.2vh;
font-weight: normal;
font-style: italic;
opacity: 0.8;
}
h3 {
font-size: 2.2vh;
}
h1,
h2,
h3,
p {
margin: 0;
text-shadow: 0 0 1vh rgba(0, 0, 0, 0.15);
}
p {
font-size: 1.75vh;
}
ul {
font-size: 1.75vh;
margin: 0;
margin-top: 1vh;
padding-left: 3vh;
}
a {
color: white
}
a.install-link {
text-decoration: none
}
button {
border: 0;
outline: 0;
color: white;
background: #8A5AAB;
padding: 1.2vh 3.5vh;
margin: auto;
text-align: center;
font-family: 'Open Sans', Arial, sans-serif;
font-size: 2.2vh;
font-weight: 600;
cursor: pointer;
display: block;
box-shadow: 0 0.5vh 1vh rgba(0, 0, 0, 0.2);
transition: box-shadow 0.1s ease-in-out;
}
button:hover {
box-shadow: none;
}
button:active {
box-shadow: 0 0 0 0.5vh white inset;
}
#addon {
width: 40vh;
margin: auto;
}
.logo {
height: 14vh;
width: 14vh;
margin: auto;
margin-bottom: 3vh;
}
.logo img {
width: 100%;
}
.name, .version {
display: inline-block;
vertical-align: top;
}
.name {
line-height: 5vh;
margin: 0;
}
.version {
position: relative;
line-height: 5vh;
opacity: 0.8;
margin-bottom: 2vh;
}
.contact {
position: absolute;
left: 0;
bottom: 4vh;
width: 100%;
text-align: center;
}
.contact a {
font-size: 1.4vh;
font-style: italic;
}
.separator {
margin-bottom: 4vh;
}
.form-element {
margin-bottom: 2vh;
}
.label-to-top {
margin-bottom: 2vh;
}
.label-to-right {
margin-left: 1vh !important;
}
.full-width {
width: 100%;
}
`;
export function landingTemplate(manifest: ManifestWithConfig) {
const background = manifest.background || 'https://dl.strem.io/addon-background.jpg';
const logo = manifest.logo || 'https://dl.strem.io/addon-logo.png';
const contactHTML = manifest.contactEmail
? `<div class="contact">
<p>Contact ${manifest.name} creator:</p>
<a href="mailto:${manifest.contactEmail}">${manifest.contactEmail}</a>
</div>`
: '';
const stylizedTypes = manifest.types
.map(types => types.charAt(0).toUpperCase() + types.slice(1) + (types !== 'series' ? 's' : ''));
let formHTML = '';
let script = '';
if ((manifest.config || []).length) {
let options = '';
manifest.config.forEach((elem) => {
const key = elem.key;
if (['text', 'number', 'password'].includes(elem.type)) {
const isRequired = elem.required ? ' required' : '';
const defaultHTML = elem.default ? ` value="${elem.default}"` : '';
const inputType = elem.type;
options += `
<div class="form-element">
<div class="label-to-top">${elem.title}</div>
<input type="${inputType}" id="${key}" name="${key}" class="full-width"${defaultHTML}${isRequired}/>
</div>
`;
} else if (elem.type === 'checkbox') {
const isChecked = elem.default === 'checked' ? ' checked' : '';
options += `
<div class="form-element">
<label for="${key}">
<input type="checkbox" id="${key}" name="${key}"${isChecked}> <span class="label-to-right">${elem.title}</span>
</label>
</div>
`;
} else if (elem.type === 'select') {
const defaultValue = elem.default || (elem.options || [])[0];
options += `<div class="form-element">
<div class="label-to-top">${elem.title}</div>
<select id="${key}" name="${key}" class="full-width">
`;
const selections = elem.options || [];
selections.forEach((el) => {
const isSelected = el === defaultValue ? ' selected' : '';
options += `<option value="${el}"${isSelected}>${el}</option>`;
});
options += `</select>
</div>
`;
}
});
if (options.length) {
formHTML = `
<form class="pure-form" id="mainForm">
${options}
</form>
<div class="separator"></div>
`;
script += `
installLink.onclick = () => {
return mainForm.reportValidity()
}
const updateLink = () => {
const config = Object.fromEntries(new FormData(mainForm))
installLink.href = 'stremio://' + window.location.host + '/' + encodeURIComponent(JSON.stringify(config)) + '/manifest.json'
}
mainForm.onchange = updateLink
`;
}
}
return `
<!DOCTYPE html>
<html style="background-image: url(${background});" lang="en">
<head>
<meta charset="utf-8">
<title>${manifest.name} - Stremio Addon</title>
<style>${STYLESHEET}</style>
<link rel="shortcut icon" href="${logo}" type="image/x-icon">
<link href="https://fonts.googleapis.com/css?family=Open+Sans:400,600,700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@2.1.0/build/pure-min.css" integrity="sha384-yHIFVG6ClnONEA5yB5DJXfW2/KC173DIQrYoZMEtBvGzmf0PKiGyNEqe9N6BNDBH" crossorigin="anonymous">
</head>
<body>
<div id="addon">
<div class="logo">
<img src="${logo}" alt="logo">
</div>
<h1 class="name">${manifest.name}</h1>
<h2 class="version">v${manifest.version || '0.0.0'}</h2>
<h2 class="description">${manifest.description || ''}</h2>
<div class="separator"></div>
${process.env['CONFIGURATION_DESCRIPTION'] || ''}
<div class="separator"></div>
<h3 class="gives">This addon has more :</h3>
<ul>
${stylizedTypes.map(t => `<li>${t}</li>`).join('')}
</ul>
<div class="separator"></div>
${formHTML}
<a id="installLink" class="install-link" href="#">
<button name="Install">INSTALL</button>
</a>
${contactHTML}
</div>
<script>
${script}
if (typeof updateLink === 'function')
updateLink()
else
installLink.href = 'stremio://' + window.location.host + '/manifest.json'
</script>
</body>
</html>`;
}

View file

@ -1,8 +0,0 @@
#!/usr/bin/env node
import { serveHTTP } from 'stremio-addon-sdk';
import addonInterface from './addon';
serveHTTP(addonInterface, {
cacheMaxAge: process.env['NODE_ENV'] === 'production' ? 3600 : undefined,
port: parseInt(process.env['PORT'] || '51546'),
});

5
src/types.ts Normal file
View file

@ -0,0 +1,5 @@
import { Manifest, ManifestConfig } from 'stremio-addon-sdk';
export type ManifestWithConfig = Manifest & { config: ManifestConfig[] };
export type Config = Record<string, string>;

View file

@ -3,4 +3,5 @@ export * from './fetch';
export * from './imdb';
export * from './language';
export * from './log';
export * from './manifest';
export * from './promise';

View file

@ -0,0 +1,25 @@
import { buildManifest } from './manifest';
import { KinoKiste } from '../handler';
describe('buildManifest', () => {
test('has an empty config without handlers', () => {
const manifest = buildManifest([], {});
expect(manifest.config).toStrictEqual([]);
});
test('has unchecked handler without a config', () => {
const manifest = buildManifest([new KinoKiste()], {});
expect(manifest.config).toHaveLength(1);
expect(manifest.config[0]?.default).toBeUndefined();
});
test('has checked handler with appropriate config', () => {
const kinokiste = new KinoKiste();
const manifest = buildManifest([kinokiste], { [kinokiste.id]: 'on' });
expect(manifest.config).toHaveLength(1);
expect(manifest.config[0]?.default).toBe('checked');
});
});

38
src/utils/manifest.ts Normal file
View file

@ -0,0 +1,38 @@
import { iso2ToFlag } from './language';
import { Handler } from '../handler';
import { Config, ManifestWithConfig } from '../types';
export const buildManifest = (handlers: Handler[], config: Config): ManifestWithConfig => {
const manifest: ManifestWithConfig = {
id: process.env['MANIFEST_ID'] || 'webstreamr',
version: '0.3.0', // x-release-please-version
name: process.env['MANIFEST_NAME'] || 'WebStreamr',
description: 'Provides HTTP URLs from streaming websites.',
resources: [
'stream',
],
types: [
'movie',
'series',
],
catalogs: [],
idPrefixes: ['tt'],
behaviorHints: {
p2p: false,
configurable: true,
configurationRequired: Object.keys(config).length === 0,
},
config: [],
};
handlers.forEach((handler) => {
manifest.config.push({
key: handler.id,
type: 'checkbox',
title: `${handler.languages.map(language => iso2ToFlag(language) + ' ' + language.toUpperCase()).join(', ')} | ${handler.label}`,
...(handler.id in config && { default: 'checked' }),
});
});
return manifest;
};