refactor: DI for handlers, extractors and fetcher
This commit is contained in:
parent
d7bfe5fee9
commit
96dde8a634
30 changed files with 170 additions and 114 deletions
2
.gitattributes
vendored
2
.gitattributes
vendored
|
|
@ -1 +1 @@
|
|||
/fixtures/** linguist-generated
|
||||
**/__fixtures__/** linguist-generated
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ const config: Config = {
|
|||
},
|
||||
resetModules: true,
|
||||
restoreMocks: true,
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+.tsx?$': ['ts-jest', {}],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
9
src/embed-extractor/EmbedExtractors.test.ts
Normal file
9
src/embed-extractor/EmbedExtractors.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
20
src/embed-extractor/EmbedExtractors.ts
Normal file
20
src/embed-extractor/EmbedExtractors.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,2 +1,4 @@
|
|||
export * from './EmbedExtractorRegistry';
|
||||
export * from './EmbedExtractors';
|
||||
export * from './Dropload';
|
||||
export * from './SuperVideo';
|
||||
export * from './types';
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
19
src/index.ts
19
src/index.ts
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
21
src/utils/Fetcher.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
20
src/utils/__mocks__/Fetcher.ts
Normal file
20
src/utils/__mocks__/Fetcher.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
export * from './Fetcher';
|
||||
export * from './embed';
|
||||
export * from './fetch';
|
||||
export * from './imdb';
|
||||
export * from './language';
|
||||
export * from './log';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue