refactor: DI for handlers, extractors and fetcher

This commit is contained in:
WebStreamr 2025-05-11 19:11:14 +00:00
parent d7bfe5fee9
commit 96dde8a634
No known key found for this signature in database
30 changed files with 170 additions and 114 deletions

2
.gitattributes vendored
View file

@ -1 +1 @@
/fixtures/** linguist-generated
**/__fixtures__/** linguist-generated

View file

@ -20,7 +20,6 @@ const config: Config = {
},
resetModules: true,
restoreMocks: true,
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testEnvironment: 'node',
transform: {
'^.+.tsx?$': ['ts-jest', {}],

View file

@ -1,22 +0,0 @@
import { cachedFetchText } from './src/utils/fetch';
import fs from 'node:fs';
import slugify from 'slugify';
// Mocks cachedFetchText and either returns existing fixtures or creates new ones
jest.mock('./src/utils/fetch', () => ({
cachedFetchText: jest.fn(),
}));
(cachedFetchText as jest.Mock).mockImplementation(
async (url: string) => {
const path = `${__dirname}/fixtures/cachedFetchText/${slugify(url)}`;
if (fs.existsSync(path)) {
return fs.readFileSync(path).toString();
} else {
const realFetchModule = jest.requireActual('./src/utils/fetch');
const text = await realFetchModule.cachedFetchText(url);
fs.writeFileSync(path, text);
return text;
}
},
);

View file

@ -1,14 +1,22 @@
import { EmbedExtractor } from './types';
import { cachedFetchText, extractUrlFromPacked, iso2ToFlag, scanFromResolution } from '../utils';
import { extractUrlFromPacked, Fetcher, iso2ToFlag, scanFromResolution } from '../utils';
export class Dropload implements EmbedExtractor {
readonly id = 'dropload';
readonly label = 'Dropload';
private readonly fetcher: Fetcher;
constructor(fetcher: Fetcher) {
this.fetcher = fetcher;
}
readonly supports = (url: string): boolean => null !== url.match(/dropload/);
readonly extract = async (url: string, language: string) => {
const normalizedUrl = url.replace('/e/', '').replace('/embed-', '/');
const html = await cachedFetchText(normalizedUrl);
const html = await this.fetcher.text(normalizedUrl);
const resolution = scanFromResolution((html.match(/(\d{3,}x\d{3,}),/) as string[])[1] as string);

View file

@ -1,10 +0,0 @@
import { EmbedExtractor } from './types';
import { Dropload } from './Dropload';
import { SuperVideo } from './SuperVideo';
type EmbedExtractorRegistryType = Record<string, EmbedExtractor>;
export const EmbedExtractorRegistry: EmbedExtractorRegistryType = {
dropload: new Dropload(),
supervideo: new SuperVideo(),
};

View file

@ -0,0 +1,9 @@
import { EmbedExtractors } from './EmbedExtractors';
describe('EmbedExtractors', () => {
test('throws when no embed extractor can be found', () => {
const embedExtractors = new EmbedExtractors([]);
expect(embedExtractors.handle('https://some-url.test', 'en')).rejects.toThrow('No embed extractor found that supports url https://some-url.test');
});
});

View file

@ -0,0 +1,20 @@
import { EmbedExtractor } from './types';
import { StreamWithMeta } from '../types';
export class EmbedExtractors {
private readonly embedExtractors: EmbedExtractor[];
constructor(embedExtractors: EmbedExtractor[]) {
this.embedExtractors = embedExtractors;
}
readonly handle = async (url: string, language: string): Promise<StreamWithMeta> => {
const embedExtractor = this.embedExtractors.find(embedExtractor => embedExtractor.supports(url));
if (undefined === embedExtractor) {
throw new Error(`No embed extractor found that supports url ${url}`);
}
return embedExtractor.extract(url, language);
};
}

View file

@ -1,14 +1,22 @@
import { EmbedExtractor } from './types';
import { cachedFetchText, extractUrlFromPacked, iso2ToFlag, scanFromResolution } from '../utils';
import { extractUrlFromPacked, Fetcher, iso2ToFlag, scanFromResolution } from '../utils';
export class SuperVideo implements EmbedExtractor {
readonly id = 'supervideo';
readonly label = 'SuperVideo';
private readonly fetcher: Fetcher;
constructor(fetcher: Fetcher) {
this.fetcher = fetcher;
}
readonly supports = (url: string): boolean => null !== url.match(/supervideo/);
readonly extract = async (url: string, language: string) => {
const normalizedUrl = url.replace('/e/', '/').replace('/embed-', '/');
const html = await cachedFetchText(normalizedUrl);
const html = await this.fetcher.text(normalizedUrl);
const resolutionAndSizeMatch = html.match(/(\d{3,}x\d{3,}), ([\d.]+) ?([GM]B)/) as string[];
const resolution = scanFromResolution(resolutionAndSizeMatch[1] as string);

View file

@ -1,2 +1,4 @@
export * from './EmbedExtractorRegistry';
export * from './EmbedExtractors';
export * from './Dropload';
export * from './SuperVideo';
export * from './types';

View file

@ -5,5 +5,7 @@ export interface EmbedExtractor {
readonly label: string;
readonly extract: (embedUrl: string, language: string) => Promise<StreamWithMeta>;
readonly supports: (url: string) => boolean;
readonly extract: (url: string, language: string) => Promise<StreamWithMeta>;
}

View file

@ -1,6 +1,11 @@
import { KinoKiste } from './KinoKiste';
import { Dropload, EmbedExtractors, SuperVideo } from '../embed-extractor';
import { Fetcher } from '../utils';
jest.mock('../utils/Fetcher');
const kinokiste = new KinoKiste();
// @ts-expect-error No constructor args needed
const fetcher = new Fetcher();
const kinokiste = new KinoKiste(fetcher, new EmbedExtractors([new Dropload(fetcher), new SuperVideo(fetcher)]));
describe('KinoKiste', () => {
test('does not handle non imdb series', async () => {

View file

@ -1,8 +1,7 @@
import * as cheerio from 'cheerio';
import slugify from 'slugify';
import { Handler } from './types';
import { ImdbId, cachedFetchText, fulfillAllPromises, parseImdbId } from '../utils';
import { EmbedExtractor, EmbedExtractorRegistry } from '../embed-extractor';
import { ImdbId, fulfillAllPromises, parseImdbId, Fetcher } from '../utils';
import { EmbedExtractors } from '../embed-extractor';
export class KinoKiste implements Handler {
readonly id = 'kinokiste';
@ -13,6 +12,14 @@ export class KinoKiste implements Handler {
readonly languages = ['de'];
private readonly fetcher: Fetcher;
private readonly embedExtractors: EmbedExtractors;
constructor(fetcher: Fetcher, embedExtractors: EmbedExtractors) {
this.fetcher = fetcher;
this.embedExtractors = embedExtractors;
}
readonly handle = async (id: string) => {
if (!id.startsWith('tt')) {
return Promise.resolve([]);
@ -25,7 +32,7 @@ export class KinoKiste implements Handler {
return Promise.resolve([]);
}
const html = await cachedFetchText(seriesPageUrl);
const html = await this.fetcher.text(seriesPageUrl);
const $ = cheerio.load(html);
@ -33,19 +40,15 @@ export class KinoKiste implements Handler {
$(`[data-num="${imdbId.series}x${imdbId.episode}"]`)
.siblings('.mirrors')
.children('[data-link]')
.map((_i, urlElement) => ({
embedId: slugify($(urlElement).attr('data-m') as string),
embedUrl: ($(urlElement).attr('data-link') as string).replace(/^(https:)?\/\//, 'https://'),
}))
.map((_i, el) => ($(el).attr('data-link') as string).replace(/^(https:)?\/\//, 'https://'))
.toArray()
.filter(({ embedId }) => embedId.match(/^(dropload|supervideo)$/))
.map(({ embedId, embedUrl }) => (EmbedExtractorRegistry[embedId] as EmbedExtractor).extract(embedUrl, 'de'))
.filter(stream => stream !== undefined),
.filter(embedUrl => embedUrl.match(/(dropload|supervideo)/))
.map(embedUrl => this.embedExtractors.handle(embedUrl, 'de')),
);
};
private fetchSeriesPageUrl = async (imdbId: ImdbId): Promise<string | undefined> => {
const html = await cachedFetchText(`https://kinokiste.live/serien/?do=search&subaction=search&story=${imdbId.id}`);
const html = await this.fetcher.text(`https://kinokiste.live/serien/?do=search&subaction=search&story=${imdbId.id}`);
const $ = cheerio.load(html);

View file

@ -1,6 +1,11 @@
import { MeineCloud } from './MeineCloud';
import { Fetcher } from '../utils';
import { Dropload, EmbedExtractors, SuperVideo } from '../embed-extractor';
jest.mock('../utils/Fetcher');
const meinecloud = new MeineCloud();
// @ts-expect-error No constructor args needed
const fetcher = new Fetcher();
const meinecloud = new MeineCloud(fetcher, new EmbedExtractors([new Dropload(fetcher), new SuperVideo(fetcher)]));
describe('MeineCloud', () => {
test('does not handle non imdb movies', async () => {

View file

@ -1,8 +1,7 @@
import * as cheerio from 'cheerio';
import slugify from 'slugify';
import { Handler } from './types';
import { cachedFetchText, fulfillAllPromises, parseImdbId } from '../utils';
import { EmbedExtractor, EmbedExtractorRegistry } from '../embed-extractor';
import { Fetcher, fulfillAllPromises, parseImdbId } from '../utils';
import { EmbedExtractors } from '../embed-extractor';
export class MeineCloud implements Handler {
readonly id = 'meinecloud';
@ -13,25 +12,29 @@ export class MeineCloud implements Handler {
readonly languages = ['de'];
private readonly fetcher: Fetcher;
private readonly embedExtractors: EmbedExtractors;
constructor(fetcher: Fetcher, embedExtractors: EmbedExtractors) {
this.fetcher = fetcher;
this.embedExtractors = embedExtractors;
}
readonly handle = async (id: string) => {
if (!id.startsWith('tt')) {
return Promise.resolve([]);
}
const html = await cachedFetchText(`https://meinecloud.click/movie/${parseImdbId(id).id}`);
const html = await this.fetcher.text(`https://meinecloud.click/movie/${parseImdbId(id).id}`);
const $ = cheerio.load(html);
return fulfillAllPromises(
$('[data-link!=""]')
.map((_i, el) => ({
embedId: slugify($(el).text()),
embedUrl: ($(el).attr('data-link') as string).replace(/^(https:)?\/\//, 'https://'),
}))
.map((_i, el) => ($(el).attr('data-link') as string).replace(/^(https:)?\/\//, 'https://'))
.toArray()
.filter(({ embedId }) => embedId.match(/^(dropload|supervideo)$/))
.map(({ embedId, embedUrl }) => (EmbedExtractorRegistry[embedId] as EmbedExtractor).extract(embedUrl, 'de'))
.filter(stream => stream !== undefined),
.filter(embedUrl => embedUrl.match(/(dropload|supervideo)/))
.map(embedUrl => this.embedExtractors.handle(embedUrl, 'de')),
);
};
}

View file

@ -1,14 +1,27 @@
import express, { NextFunction, Request, Response } from 'express';
import makeFetchHappen from 'make-fetch-happen';
import { landingTemplate } from './landingTemplate';
import { Handler, KinoKiste, MeineCloud } from './handler';
import { buildManifest, fulfillAllPromises, logInfo } from './utils';
import { Dropload, EmbedExtractors, SuperVideo } from './embed-extractor';
import { buildManifest, Fetcher, fulfillAllPromises, logInfo } from './utils';
import { Config, StreamWithMeta } from './types';
import fs from 'node:fs';
import * as os from 'node:os';
const addon = express();
const fetcher = new Fetcher(makeFetchHappen.defaults({
cachePath: `${fs.realpathSync(os.tmpdir())}/webstreamr`,
}));
const embedExtractors = new EmbedExtractors([
new Dropload(fetcher),
new SuperVideo(fetcher),
]);
const handlers: Handler[] = [
new KinoKiste(),
new MeineCloud(),
new KinoKiste(fetcher, embedExtractors),
new MeineCloud(fetcher, embedExtractors),
];
addon.use((_req: Request, res: Response, next: NextFunction) => {

View file

@ -1,5 +1,5 @@
const realFetchModule = jest.requireActual('./fetch');
const cachedFetchText = realFetchModule.cachedFetchText;
import { Fetcher } from './Fetcher';
import makeFetchHappen from 'make-fetch-happen';
global.console = {
...console,
@ -15,17 +15,19 @@ jest.mock('make-fetch-happen', () => ({
},
}));
const fetcher = new Fetcher(makeFetchHappen.defaults());
describe('fetch', () => {
test('cachedFetchText throws if the response is not OK', async () => {
test('text throws if the response is not OK', async () => {
mockedFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'some error happened' });
await expect(cachedFetchText('https://some-url.test')).rejects.toThrow(new Error('HTTP error: 500 - some error happened'));
await expect(fetcher.text('https://some-url.test')).rejects.toThrow(new Error('HTTP error: 500 - some error happened'));
});
test('cachedFetchText passes response through if it is OK', async () => {
test('text passes response through if it is OK', async () => {
mockedFetch.mockResolvedValue({ ok: true, text: () => Promise.resolve('some text') });
const responseText = await cachedFetchText('https://some-url.test');
const responseText = await fetcher.text('https://some-url.test');
expect(responseText).toBe('some text');
});

21
src/utils/Fetcher.ts Normal file
View file

@ -0,0 +1,21 @@
import { FetchInterface, FetchOptions } from 'make-fetch-happen';
import { logInfo } from './log';
export class Fetcher {
private readonly fetch: FetchInterface;
constructor(fetch: FetchInterface) {
this.fetch = fetch;
}
readonly text = async (uriOrRequest: string | Request, opts?: FetchOptions): Promise<string> => {
logInfo(`Fetch ${uriOrRequest}`);
const response = await this.fetch(uriOrRequest, opts);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status} - ${response.statusText}`);
}
return await response.text();
};
}

View file

@ -0,0 +1,20 @@
import { FetchOptions } from 'make-fetch-happen';
import makeFetchHappen from 'make-fetch-happen';
import fs from 'node:fs';
import slugify from 'slugify';
export class Fetcher {
readonly text = async (uriOrRequest: string, opts?: FetchOptions): Promise<string> => {
const path = `${__dirname}/../__fixtures__/Fetcher/${slugify(uriOrRequest)}`;
if (fs.existsSync(path)) {
return fs.readFileSync(path).toString();
} else {
const text = await (await makeFetchHappen.defaults()(uriOrRequest, opts)).text();
fs.writeFileSync(path, text);
return text;
}
};
}

View file

@ -1,37 +0,0 @@
import makeFetchHappen from 'make-fetch-happen';
import { FetchInterface, FetchOptions } from 'make-fetch-happen';
import fs from 'node:fs';
import * as os from 'node:os';
import { Response } from 'node-fetch';
import { logInfo } from './log';
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
class MakeFetchHappenSingleton {
private static instance: FetchInterface;
public static getInstance(): FetchInterface {
if (!MakeFetchHappenSingleton.instance) {
MakeFetchHappenSingleton.instance = makeFetchHappen.defaults({
cachePath: `${fs.realpathSync(os.tmpdir())}/webstreamr`,
});
}
return MakeFetchHappenSingleton.instance;
}
}
const cachedFetch = async (uriOrRequest: string | Request, opts?: FetchOptions): Promise<Response> => {
logInfo(`Fetch ${uriOrRequest}`);
const fetch = MakeFetchHappenSingleton.getInstance();
return await fetch(uriOrRequest, opts);
};
export const cachedFetchText = async (uriOrRequest: string | Request, opts?: FetchOptions): Promise<string> => {
const response = await cachedFetch(uriOrRequest, opts);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status} - ${response.statusText}`);
}
return await response.text();
};

View file

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

View file

@ -1,5 +1,10 @@
import { buildManifest } from './manifest';
import { KinoKiste } from '../handler';
import { Fetcher } from './Fetcher';
import { EmbedExtractors } from '../embed-extractor';
// @ts-expect-error No constructor args needed
const fetcher = new Fetcher();
describe('buildManifest', () => {
test('has an empty config without handlers', () => {
@ -9,14 +14,14 @@ describe('buildManifest', () => {
});
test('has unchecked handler without a config', () => {
const manifest = buildManifest([new KinoKiste()], {});
const manifest = buildManifest([new KinoKiste(fetcher, new EmbedExtractors([]))], {});
expect(manifest.config).toHaveLength(1);
expect(manifest.config[0]?.default).toBeUndefined();
});
test('has checked handler with appropriate config', () => {
const kinokiste = new KinoKiste();
const kinokiste = new KinoKiste(fetcher, new EmbedExtractors([]));
const manifest = buildManifest([kinokiste], { [kinokiste.id]: 'on' });
expect(manifest.config).toHaveLength(1);