refactor: transform handlers to classes, improve config generation

This commit is contained in:
WebStreamr 2025-05-08 12:19:46 +00:00
parent c73c58ba3c
commit afec515287
No known key found for this signature in database
13 changed files with 206 additions and 158 deletions

View file

@ -1,12 +1,17 @@
import { ContentType, Manifest, addonBuilder } from 'stremio-addon-sdk';
import { Handler, HandlerStream, handleKinoKiste, handleMeineCloud } from './handler';
import { fulfillAllPromises, logInfo } from './utils';
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'] || '',
version: '0.0.1',
name: process.env['MANIFEST_NAME'] || '',
description: 'Provides HTTP URLs from streaming websites. Currently supports KinoKiste (DE) and MeineCloud (DE).',
description: `Provides HTTP URLs from streaming websites.`,
resources: [
'stream',
],
@ -21,28 +26,21 @@ const manifest: Manifest = {
configurable: true,
configurationRequired: true,
},
config: [
{
key: 'de',
type: 'checkbox',
title: '🇩🇪 DE | KinoKiste, MeineCloud',
},
],
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 collectHandlers = (config: RequestConfig): Handler[] => {
const handlers: Handler[] = [];
if ('de' in config) {
handlers.push(handleKinoKiste, handleMeineCloud);
}
return handlers;
};
const sortStreams = (streams: HandlerStream[]): void => {
streams.sort((a, b) => {
const resolutionComparison = parseInt(b.resolution) - parseInt(a.resolution);
@ -57,8 +55,8 @@ const sortStreams = (streams: HandlerStream[]): void => {
builder.defineStreamHandler(async (args: { type: ContentType; id: string; config?: RequestConfig }) => {
logInfo(`Search stream for type "${args.type}" and id "${args.id}"`);
const handlers = collectHandlers(args.config || {});
if (handlers.length === 0) {
const selectedHandlers = handlers.filter(handler => handler.id in (args.config || {}));
if (selectedHandlers.length === 0) {
logInfo('No handlers configured, bail out');
return {
@ -71,9 +69,13 @@ builder.defineStreamHandler(async (args: { type: ContentType; id: string; config
}
const streams: HandlerStream[] = [];
const handlerPromises = handlers.map(async (handler) => {
const handlerStreams = await handler(args);
logInfo(`${handler.name} returned ${handlerStreams.length} streams`);
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);
});

View file

@ -1,7 +1,9 @@
import fs from 'node:fs';
import slugify from 'slugify';
import { KinoKiste } from './KinoKiste';
import { cachedFetchText } from '../utils';
import { handleKinoKiste } from './kinokiste';
const kinokiste = new KinoKiste();
jest.mock('./../utils/fetch', () => ({
cachedFetchText: jest.fn(),
@ -11,26 +13,20 @@ jest.mock('./../utils/fetch', () => ({
);
describe('KinoKiste', () => {
test('does not handle movies', async () => {
const streams = await handleKinoKiste({ type: 'movie', id: 'tt29141112' });
expect(streams).toHaveLength(0);
});
test('does not handle non imdb series', async () => {
const streams = await handleKinoKiste({ type: 'series', id: 'kitsu:123' });
const streams = await kinokiste.handle('kitsu:123');
expect(streams).toHaveLength(0);
});
test('handles non-existent series gracefully', async () => {
const streams = await handleKinoKiste({ type: 'series', id: 'tt12345678:1:1' });
const streams = await kinokiste.handle('tt12345678:1:1');
expect(streams).toHaveLength(0);
});
test('handle imdb black mirror s2e4', async () => {
const streams = await handleKinoKiste({ type: 'series', id: 'tt2085059:2:4' });
const streams = await kinokiste.handle('tt2085059:2:4');
expect(streams).toHaveLength(2);
expect(streams[0]).toStrictEqual({

76
src/handler/KinoKiste.ts Normal file
View file

@ -0,0 +1,76 @@
import * as cheerio from 'cheerio';
import slugify from 'slugify';
import { Handler } from './types';
import { ImdbId, cachedFetchText, fulfillAllPromises, parseImdbId, parsePackedEmbed } from '../utils';
export class KinoKiste implements Handler {
readonly id = 'kinokiste';
readonly label = 'KinoKiste';
readonly contentTypes = ['series'];
readonly languages = ['de'];
readonly handle = async (id: string) => {
if (!id.startsWith('tt')) {
return Promise.resolve([]);
}
const imdbId = parseImdbId(id);
const seriesPageUrl = await this.fetchSeriesPageUrl(imdbId);
if (!seriesPageUrl) {
return Promise.resolve([]);
}
const streamsPromises = (await this.fetchStreamData(imdbId, seriesPageUrl))
.map(async ({ group, url }) => {
const { url: finalUrl, resolution, size } = await parsePackedEmbed(url);
return {
url: finalUrl,
name: `WebStreamr DE | ${resolution}`,
title: `${this.label} - ${group} | 💾 ${size} | 🇩🇪`,
behaviorHints: {
group: `webstreamr-${this.id}-${group}`,
},
resolution,
size,
};
});
return fulfillAllPromises(streamsPromises);
};
private fetchSeriesPageUrl = async (imdbId: ImdbId): Promise<string | undefined> => {
const html = await cachedFetchText(`https://kinokiste.live/serien/?do=search&subaction=search&story=${imdbId.id}`);
const $ = cheerio.load(html);
return $('.item-video a[href]:first')
.map((_i, el) => $(el).attr('href'))
.get(0);
};
private fetchStreamData = async (imdbId: ImdbId, seriesPageUrl: string): Promise<{ group: string; url: string }[]> => {
const html = await cachedFetchText(seriesPageUrl);
const $ = cheerio.load(html);
return $(`[data-num="${imdbId.series}x${imdbId.episode}"]`).map((_i, urlWrapperElement) => {
return $(urlWrapperElement).siblings('.mirrors').children('[data-link]')
.map((_i, urlElement) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
group: slugify($(urlElement).attr('data-m')!),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
url: $(urlElement).attr('data-link')!
.replace(/^(https:)?\/\//, 'https://')
.replace('/e/', '/')
.replace('/embed-', '/'),
}))
.toArray()
.filter(({ url }) => url.match(/dropload|supervideo/));
}).toArray();
};
}

View file

@ -1,7 +1,9 @@
import fs from 'node:fs';
import slugify from 'slugify';
import { MeineCloud } from './MeineCloud';
import { cachedFetchText } from '../utils';
import { handleMeineCloud } from './meinecloud';
const meinecloud = new MeineCloud();
jest.mock('./../utils/fetch', () => ({
cachedFetchText: jest.fn(),
@ -11,26 +13,20 @@ jest.mock('./../utils/fetch', () => ({
);
describe('MeineCloud', () => {
test('does not handle series', async () => {
const streams = await handleMeineCloud({ type: 'series', id: 'tt2085059:2:4' });
expect(streams).toHaveLength(0);
});
test('does not handle non imdb movies', async () => {
const streams = await handleMeineCloud({ type: 'movie', id: 'kitsu:123' });
const streams = await meinecloud.handle('kitsu:123');
expect(streams).toHaveLength(0);
});
test('handles non-existent movies gracefully', async () => {
const streams = await handleMeineCloud({ type: 'movie', id: 'tt12345678' });
const streams = await meinecloud.handle('tt12345678');
expect(streams).toHaveLength(0);
});
test('handle imdb the devil\'s bath', async () => {
const streams = await handleMeineCloud({ type: 'movie', id: 'tt29141112' });
const streams = await meinecloud.handle('tt29141112');
expect(streams).toHaveLength(2);
expect(streams[0]).toStrictEqual({

51
src/handler/MeineCloud.ts Normal file
View file

@ -0,0 +1,51 @@
import * as cheerio from 'cheerio';
import slugify from 'slugify';
import { Handler } from './types';
import { cachedFetchText, fulfillAllPromises, parseImdbId, parsePackedEmbed } from '../utils';
export class MeineCloud implements Handler {
readonly id = 'meinecloud';
readonly label = 'MeineCloud';
readonly contentTypes = ['movie'];
readonly languages = ['de'];
readonly handle = async (id: string) => {
if (!id.startsWith('tt')) {
return Promise.resolve([]);
}
const html = await cachedFetchText(`https://meinecloud.click/movie/${parseImdbId(id).id}`);
const $ = cheerio.load(html);
const streamsPromises = $('[data-link!=""]')
.map((_i, el) => ({
group: slugify($(el).text()),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
url: $(el).attr('data-link')!
.replace(/^(https:)?\/\//, 'https://')
.replace('/e/', '/')
.replace('/embed-', '/'),
}))
.toArray()
.filter(({ url }) => url.match(/dropload|supervideo/))
.map(async ({ group, url }) => {
const { url: finalUrl, resolution, size } = await parsePackedEmbed(url);
return {
url: finalUrl,
name: `WebStreamr DE | ${resolution}`,
title: `${this.label} - ${group} | 💾 ${size} | 🇩🇪`,
behaviorHints: {
group: `webstreamr-${this.id}-${group}`,
},
resolution,
size,
};
});
return fulfillAllPromises(streamsPromises);
};
}

View file

@ -1,3 +1,3 @@
export * from './kinokiste';
export * from './meinecloud';
export * from './KinoKiste';
export * from './MeineCloud';
export * from './types';

View file

@ -1,66 +0,0 @@
import * as cheerio from 'cheerio';
import slugify from 'slugify';
import { ImdbId, cachedFetchText, fulfillAllPromises, parseImdbId, parsePackedEmbed } from '../utils';
import { Handler } from './types';
const fetchSeriesPageUrl = async (imdbId: ImdbId): Promise<string | undefined> => {
const html = await cachedFetchText(`https://kinokiste.live/serien/?do=search&subaction=search&story=${imdbId.id}`);
const $ = cheerio.load(html);
return $('.item-video a[href]:first')
.map((_i, el) => $(el).attr('href'))
.get(0);
};
const fetchStreamData = async (imdbId: ImdbId, seriesPageUrl: string): Promise<{ group: string; url: string }[]> => {
const html = await cachedFetchText(seriesPageUrl);
const $ = cheerio.load(html);
return $(`[data-num="${imdbId.series}x${imdbId.episode}"]`).map((_i, urlWrapperElement) => {
return $(urlWrapperElement).siblings('.mirrors').children('[data-link]')
.map((_i, urlElement) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
group: slugify($(urlElement).attr('data-m')!),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
url: $(urlElement).attr('data-link')!
.replace(/^(https:)?\/\//, 'https://')
.replace('/e/', '/')
.replace('/embed-', '/'),
}))
.toArray()
.filter(({ url }) => url.match(/dropload|supervideo/));
}).toArray();
};
export const handleKinoKiste: Handler = async ({ type, id }) => {
if (type !== 'series' || !id.startsWith('tt')) {
return Promise.resolve([]);
}
const imdbId = parseImdbId(id);
const seriesPageUrl = await fetchSeriesPageUrl(imdbId);
if (!seriesPageUrl) {
return Promise.resolve([]);
}
const streamsPromises = (await fetchStreamData(imdbId, seriesPageUrl))
.map(async ({ group, url }) => {
const { url: finalUrl, resolution, size } = await parsePackedEmbed(url);
return {
url: finalUrl,
name: `WebStreamr DE | ${resolution}`,
title: `KinoKiste - ${group} | 💾 ${size} | 🇩🇪`,
behaviorHints: {
group: `webstreamr-kinokiste-${group}`,
},
resolution,
size,
};
});
return fulfillAllPromises(streamsPromises);
};

View file

@ -1,42 +0,0 @@
import * as cheerio from 'cheerio';
import slugify from 'slugify';
import { Handler } from './types';
import { cachedFetchText, fulfillAllPromises, parseImdbId, parsePackedEmbed } from '../utils';
export const handleMeineCloud: Handler = async ({ type, id }) => {
if (type !== 'movie' || !id.startsWith('tt')) {
return Promise.resolve([]);
}
const imdbId = parseImdbId(id);
const html = await cachedFetchText(`https://meinecloud.click/movie/${imdbId.id}`);
const $ = cheerio.load(html);
const streamsPromises = $('[data-link!=""]')
.map((_i, el) => ({
group: slugify($(el).text()),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
url: $(el).attr('data-link')!
.replace(/^(https:)?\/\//, 'https://')
.replace('/e/', '/')
.replace('/embed-', '/'),
}))
.toArray()
.filter(({ url }) => url.match(/dropload|supervideo/))
.map(async ({ group, url }) => {
const { url: finalUrl, resolution, size } = await parsePackedEmbed(url);
return {
url: finalUrl,
name: `WebStreamr DE | ${resolution}`,
title: `MeineCloud - ${group} | 💾 ${size} | 🇩🇪`,
behaviorHints: {
group: `webstreamr-meinecloud-${group}`,
},
resolution,
size,
};
});
return fulfillAllPromises(streamsPromises);
};

View file

@ -1,5 +1,15 @@
import { ContentType, Stream } from 'stremio-addon-sdk';
import { Stream } from 'stremio-addon-sdk';
export type HandlerStream = Stream & { resolution: string; size: string };
export type Handler = (args: { type: ContentType; id: string }) => Promise<(HandlerStream)[]>;
export interface Handler {
readonly id: string;
readonly label: string;
readonly contentTypes: string[];
readonly languages: string[];
readonly handle: (id: string) => Promise<HandlerStream[]>;
}

View file

@ -2,4 +2,7 @@
import { serveHTTP } from 'stremio-addon-sdk';
import addonInterface from './addon';
serveHTTP(addonInterface, { port: parseInt(process.env['PORT'] || '51546'), cacheMaxAge: 3600 });
serveHTTP(addonInterface, {
cacheMaxAge: process.env['NODE_ENV'] === 'production' ? 3600 : undefined,
port: parseInt(process.env['PORT'] || '51546'),
});

View file

@ -1,5 +1,6 @@
export * from './embed';
export * from './fetch';
export * from './imdb';
export * from './language';
export * from './log';
export * from './promise';

View file

@ -0,0 +1,12 @@
import { iso2ToFlag } from './language';
describe('iso2ToFlag', () => {
test('returns flag for known language', () => {
expect(iso2ToFlag('DE')).toBe('🇩🇪');
expect(iso2ToFlag('de')).toBe('🇩🇪');
});
test('returns "?" for unknown language', () => {
expect(iso2ToFlag('XX')).toBe('?');
});
});

9
src/utils/language.ts Normal file
View file

@ -0,0 +1,9 @@
export const iso2ToFlag = (iso2: string): string => {
const iso2Normalized = iso2.toLowerCase();
if (iso2Normalized === 'de') {
return '🇩🇪';
}
return '?';
};