From 5665c6a6ff375e0600eb2a7c4b7cb3e5ca768a5a Mon Sep 17 00:00:00 2001 From: WebStreamr <210764791+webstreamr@users.noreply.github.com> Date: Tue, 13 May 2025 12:42:10 +0000 Subject: [PATCH] refactor: extract stream resolving and add tests --- src/controller/StreamController.ts | 88 +++-------------------------- src/index.ts | 4 +- src/types.ts | 4 +- src/utils/StreamResolver.test.ts | 90 ++++++++++++++++++++++++++++++ src/utils/StreamResolver.ts | 88 +++++++++++++++++++++++++++++ src/utils/index.ts | 1 + 6 files changed, 191 insertions(+), 84 deletions(-) create mode 100644 src/utils/StreamResolver.test.ts create mode 100644 src/utils/StreamResolver.ts diff --git a/src/controller/StreamController.ts b/src/controller/StreamController.ts index a60b47b..04b8b1b 100644 --- a/src/controller/StreamController.ts +++ b/src/controller/StreamController.ts @@ -1,22 +1,22 @@ import { Request, Response, Router } from 'express'; -import { Stream } from 'stremio-addon-sdk'; -import { Handler } from '../handler'; -import { Config, UrlResult } from '../types'; -import bytes from 'bytes'; -import { flag } from 'country-emoji'; import winston from 'winston'; +import { Handler } from '../handler'; +import { Config } from '../types'; +import { StreamResolver } from '../utils'; export class StreamController { public readonly router: Router; private readonly logger: winston.Logger; private readonly handlers: Handler[]; + private readonly streamResolver: StreamResolver; - constructor(logger: winston.Logger, handlers: Handler[]) { + constructor(logger: winston.Logger, handlers: Handler[], streams: StreamResolver) { this.router = Router(); this.logger = logger; this.handlers = handlers; + this.streamResolver = streams; this.router.get('/:config/stream/:type/:id.json', this.getStream.bind(this)); } @@ -28,83 +28,11 @@ export class StreamController { this.logger.info(`Search stream for type "${type}" and id "${id}"`); - res.setHeader('Content-Type', 'application/json'); - const selectedHandlers = this.handlers.filter(handler => handler.id in config); - if (selectedHandlers.length === 0) { - this.logger.info('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: Stream[] = []; - - const urlResults: UrlResult[] = []; - const handlerPromises = selectedHandlers.map(async (handler) => { - if (!handler.contentTypes.includes(type)) { - return; - } - - try { - const handlerUrlResults = await handler.handle({ ip: req.ip as string }, id); - this.logger.info(`${handler.id} returned ${handlerUrlResults.length} urls`); - - urlResults.push(...(handlerUrlResults.filter(handlerUrlResult => handlerUrlResult !== undefined))); - } catch (err) { - streams.push({ - name: 'WebStreamr', - title: `❌ Error with handler "${handler.id}". Please check the logs or create an issue if this persists.`, - ytId: 'E4WlUXrJgy4', - }); - this.logger.error(`${handler.id} error: ` + err); - } - }); - await Promise.all(handlerPromises); - - urlResults.sort((a, b) => { - const heightComparison = (b.height ?? 0) - (a.height ?? 0); - if (heightComparison !== 0) { - return heightComparison; - } - - return (b.bytes ?? 0) - (a.bytes ?? 0); - }); - - this.logger.info(`Return ${urlResults.length} streams`); - - streams.push( - ...urlResults.map((urlResult) => { - let name = 'WebStreamr'; - if (urlResult.height) { - name += ` ${urlResult.height}p`; - } - - let title = urlResult.label; - if (urlResult.bytes) { - title += ` | 💾 ${bytes.format(urlResult.bytes, { unitSeparator: ' ' })}`; - } - if (urlResult.countryCode) { - title += ` | ${flag(urlResult.countryCode)}`; - } - - return { - url: urlResult.url.toString(), - name, - title, - behaviourHints: { - group: `webstreamr-${urlResult.sourceId}`, - }, - }; - }), - ); + const streams = await this.streamResolver.resolve({ ip: (req.ip as string) }, selectedHandlers, type, id); + res.setHeader('Content-Type', 'application/json'); res.send(JSON.stringify({ streams })); }; } diff --git a/src/index.ts b/src/index.ts index 47b5653..f1c6fd4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import winston from 'winston'; import { FrenchCloud, Handler, KinoKiste, MeineCloud, MostraGuarda, VerHdLink } from './handler'; import { EmbedExtractorRegistry } from './embed-extractor'; import { ConfigureController, ManifestController, StreamController } from './controller'; -import { Fetcher } from './utils'; +import { Fetcher, StreamResolver } from './utils'; import fs from 'node:fs'; import * as os from 'node:os'; @@ -50,7 +50,7 @@ addon.use((_req: Request, res: Response, next: NextFunction) => { addon.use('/', (new ConfigureController(handlers)).router); addon.use('/', (new ManifestController(handlers)).router); -addon.use('/', (new StreamController(logger, handlers)).router); +addon.use('/', (new StreamController(logger, handlers, new StreamResolver(logger))).router); addon.get('/', (_req: Request, res: Response) => { res.redirect('/configure'); diff --git a/src/types.ts b/src/types.ts index 3101d97..3a1f6ca 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,7 +10,7 @@ export interface UrlResult { url: URL; label: string; sourceId: string; - height: number | undefined; - bytes: number | undefined; + height: number; + bytes: number; countryCode: string | undefined; } diff --git a/src/utils/StreamResolver.test.ts b/src/utils/StreamResolver.test.ts new file mode 100644 index 0000000..68ae332 --- /dev/null +++ b/src/utils/StreamResolver.test.ts @@ -0,0 +1,90 @@ +import winston from 'winston'; +import { EmbedExtractorRegistry } from '../embed-extractor'; +import { StreamResolver } from './StreamResolver'; +import { MeineCloud, MostraGuarda } from '../handler'; +import { Fetcher } from './Fetcher'; +import { Context } from '../types'; +jest.mock('../utils/Fetcher'); +const streamResolver = new StreamResolver(winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] })); +const ctx: Context = { ip: '127.0.0.1' }; + +// @ts-expect-error No constructor args needed +const fetcher = new Fetcher(); +const embedExtractorRegistry = new EmbedExtractorRegistry(fetcher); +const meineCloud = new MeineCloud(fetcher, embedExtractorRegistry); +const mostraGuarda = new MostraGuarda(fetcher, embedExtractorRegistry); + +describe('resolve', () => { + test('returns info as stream if no handlers were configured', async () => { + const streams = await streamResolver.resolve(ctx, [], 'movie', 'tt123456789'); + + expect(streams).toStrictEqual([{ + name: 'WebStreamr', + title: '⚠️ No handlers found. Please re-configure the plugin.', + ytId: 'E4WlUXrJgy4', + }]); + }); + + test('returns handler errors as stream', async () => { + jest.spyOn(fetcher, 'text').mockRejectedValue('ups, an error occurred.'); + + const streams = await streamResolver.resolve(ctx, [meineCloud], 'movie', 'tt123456789'); + + expect(streams).toStrictEqual([{ + name: 'WebStreamr', + title: '❌ Error with handler "meinecloud". Please check the logs or create an issue if this persists.', + ytId: 'E4WlUXrJgy4', + }]); + }); + + test('returns empty array if no handler found anything', async () => { + const streams = await streamResolver.resolve(ctx, [meineCloud, mostraGuarda], 'movie', 'tt12345678'); + + expect(streams).toStrictEqual([]); + }); + + test('returns empty array if no handler supported the type', async () => { + const streams = await streamResolver.resolve(ctx, [meineCloud, mostraGuarda], 'series', 'tt12345678:1:1'); + + expect(streams).toStrictEqual([]); + }); + + test('returns sorted results', async () => { + const streams = await streamResolver.resolve(ctx, [meineCloud, mostraGuarda], 'movie', 'tt29141112'); + + expect(streams).toStrictEqual([ + { + url: expect.any(String), + name: 'WebStreamr 1080p', + title: 'Dropload | 💾 1.3 GB | 🇩🇪', + behaviourHints: { + group: 'webstreamr-dropload_de', + }, + }, + { + url: expect.any(String), + name: 'WebStreamr 720p', + title: 'SuperVideo | 💾 1.1 GB | 🇮🇹', + behaviourHints: { + group: 'webstreamr-supervideo_it', + }, + }, + { + url: expect.any(String), + name: 'WebStreamr 720p', + title: 'Dropload | 💾 1.1 GB | 🇮🇹', + behaviourHints: { + group: 'webstreamr-dropload_it', + }, + }, + { + url: expect.any(String), + name: 'WebStreamr 720p', + title: 'SuperVideo | 💾 1 GB | 🇩🇪', + behaviourHints: { + group: 'webstreamr-supervideo_de', + }, + }, + ]); + }); +}); diff --git a/src/utils/StreamResolver.ts b/src/utils/StreamResolver.ts new file mode 100644 index 0000000..33021de --- /dev/null +++ b/src/utils/StreamResolver.ts @@ -0,0 +1,88 @@ +import { Stream } from 'stremio-addon-sdk'; +import { flag } from 'country-emoji'; +import winston from 'winston'; +import bytes from 'bytes'; +import { Context, UrlResult } from '../types'; +import { Handler } from '../handler'; + +export class StreamResolver { + private readonly logger: winston.Logger; + + constructor(logger: winston.Logger) { + this.logger = logger; + } + + readonly resolve = async (ctx: Context, handlers: Handler[], type: string, id: string): Promise => { + if (handlers.length === 0) { + return [{ + name: 'WebStreamr', + title: '⚠️ No handlers found. Please re-configure the plugin.', + ytId: 'E4WlUXrJgy4', + }]; + } + + const streams: Stream[] = []; + + const urlResults: UrlResult[] = []; + const handlerPromises = handlers.map(async (handler) => { + if (!handler.contentTypes.includes(type)) { + return; + } + + try { + const handlerUrlResults = await handler.handle(ctx, id); + this.logger.info(`${handler.id} returned ${handlerUrlResults.length} urls`); + + urlResults.push(...(handlerUrlResults.filter(handlerUrlResult => handlerUrlResult !== undefined))); + } catch (err) { + streams.push({ + name: 'WebStreamr', + title: `❌ Error with handler "${handler.id}". Please check the logs or create an issue if this persists.`, + ytId: 'E4WlUXrJgy4', + }); + + this.logger.error(`${handler.id} error: ` + err); + } + }); + await Promise.all(handlerPromises); + + urlResults.sort((a, b) => { + const heightComparison = b.height - a.height; + if (heightComparison !== 0) { + return heightComparison; + } + + return b.bytes - a.bytes; + }); + + this.logger.info(`Return ${urlResults.length} streams`); + + streams.push( + ...urlResults.map((urlResult) => { + let name = 'WebStreamr'; + if (urlResult.height) { + name += ` ${urlResult.height}p`; + } + + let title = urlResult.label; + if (urlResult.bytes) { + title += ` | 💾 ${bytes.format(urlResult.bytes, { unitSeparator: ' ' })}`; + } + if (urlResult.countryCode) { + title += ` | ${flag(urlResult.countryCode)}`; + } + + return { + url: urlResult.url.toString(), + name, + title, + behaviourHints: { + group: `webstreamr-${urlResult.sourceId}`, + }, + }; + }), + ); + + return streams; + }; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index a492636..eb1e328 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './Fetcher'; +export * from './StreamResolver'; export * from './embed'; export * from './imdb'; export * from './manifest';