refactor: transform handlers to classes, improve config generation
This commit is contained in:
parent
c73c58ba3c
commit
afec515287
13 changed files with 206 additions and 158 deletions
52
src/addon.ts
52
src/addon.ts
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
76
src/handler/KinoKiste.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
|
|
@ -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
51
src/handler/MeineCloud.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
export * from './kinokiste';
|
||||
export * from './meinecloud';
|
||||
export * from './KinoKiste';
|
||||
export * from './MeineCloud';
|
||||
export * from './types';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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[]>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * from './embed';
|
||||
export * from './fetch';
|
||||
export * from './imdb';
|
||||
export * from './language';
|
||||
export * from './log';
|
||||
export * from './promise';
|
||||
|
|
|
|||
12
src/utils/language.test.ts
Normal file
12
src/utils/language.test.ts
Normal 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
9
src/utils/language.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const iso2ToFlag = (iso2: string): string => {
|
||||
const iso2Normalized = iso2.toLowerCase();
|
||||
|
||||
if (iso2Normalized === 'de') {
|
||||
return '🇩🇪';
|
||||
}
|
||||
|
||||
return '?';
|
||||
};
|
||||
Loading…
Reference in a new issue