refactor: simplify embed extractor handling and hide away internals

This commit is contained in:
WebStreamr 2025-05-13 11:48:31 +00:00
parent 15a53de504
commit 68a7a41606
No known key found for this signature in database
18 changed files with 89 additions and 91 deletions

View file

@ -53,7 +53,7 @@ export class StreamController {
const handlerUrlResults = await handler.handle({ ip: req.ip as string }, id);
this.logger.info(`${handler.id} returned ${handlerUrlResults.length} urls`);
urlResults.push(...handlerUrlResults);
urlResults.push(...(handlerUrlResults.filter(handlerUrlResult => handlerUrlResult !== undefined)));
} catch (err) {
this.logger.error(`${handler.id} error: ` + err);
}

View file

@ -0,0 +1,25 @@
import { EmbedExtractorRegistry } from './EmbedExtractorRegistry';
import { Context } from '../types';
import { Fetcher } from '../utils';
jest.mock('../utils/Fetcher');
// @ts-expect-error No constructor args needed
const fetcher = new Fetcher();
const embedExtractors = new EmbedExtractorRegistry(fetcher);
describe('EmbedExtractorRegistry', () => {
const ctx: Context = { ip: '127.0.0.1' };
test('returns undefined when no embed extractor can be found', async () => {
const urlResult = await embedExtractors.handle(ctx, new URL('https://some-url.test'), 'en');
expect(urlResult).toBeUndefined();
});
test('returns from memory cache if possible', async () => {
const urlResult1 = await embedExtractors.handle(ctx, new URL('https://dropload.io/lyo2h1snpe5c.html'), 'de');
const urlResult2 = await embedExtractors.handle(ctx, new URL('https://dropload.io/lyo2h1snpe5c.html'), 'de');
expect(urlResult2).toBe(urlResult1);
});
});

View file

@ -1,18 +1,24 @@
import TTLCache from '@isaacs/ttlcache';
import { EmbedExtractor } from './types';
import { Context, UrlResult } from '../types';
import { Fetcher } from '../utils';
import { Dropload } from './Dropload';
import { SuperVideo } from './SuperVideo';
export class EmbedExtractors {
export class EmbedExtractorRegistry {
private readonly embedExtractors: EmbedExtractor[];
private readonly urlResultCache: TTLCache<string, UrlResult>;
constructor(embedExtractors: EmbedExtractor[]) {
this.embedExtractors = embedExtractors;
constructor(fetcher: Fetcher) {
this.embedExtractors = [
new Dropload(fetcher),
new SuperVideo(fetcher),
];
this.urlResultCache = new TTLCache({ max: 1024 });
}
readonly handle = async (ctx: Context, url: URL, countryCode: string): Promise<UrlResult> => {
readonly handle = async (ctx: Context, url: URL, countryCode: string): Promise<UrlResult | undefined> => {
let urlResult = this.urlResultCache.get(url.href);
if (urlResult) {
return urlResult;
@ -20,7 +26,7 @@ export class EmbedExtractors {
const embedExtractor = this.embedExtractors.find(embedExtractor => embedExtractor.supports(url));
if (undefined === embedExtractor) {
throw new Error(`No embed extractor found that supports url ${url}`);
return undefined;
}
urlResult = await embedExtractor.extract(ctx, url, countryCode);

View file

@ -1,27 +0,0 @@
import { EmbedExtractors } from './EmbedExtractors';
import { Context } from '../types';
import { Fetcher } from '../utils';
import { Dropload } from './Dropload';
jest.mock('../utils/Fetcher');
describe('EmbedExtractors', () => {
const ctx: Context = { ip: '127.0.0.1' };
test('throws when no embed extractor can be found', () => {
const embedExtractors = new EmbedExtractors([]);
expect(embedExtractors.handle(ctx, new URL('https://some-url.test'), 'en'))
.rejects.toThrow('No embed extractor found that supports url https://some-url.test');
});
test('returns from memory cache if possible', async () => {
// @ts-expect-error No constructor args needed
const fetcher = new Fetcher();
const embedExtractors = new EmbedExtractors([new Dropload(fetcher)]);
const urlResult1 = await embedExtractors.handle(ctx, new URL('https://dropload.io/lyo2h1snpe5c.html'), 'de');
const urlResult2 = await embedExtractors.handle(ctx, new URL('https://dropload.io/lyo2h1snpe5c.html'), 'de');
expect(urlResult2).toBe(urlResult1);
});
});

View file

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

View file

@ -1,29 +1,29 @@
import { FrenchCloud } from './FrenchCloud';
import { Fetcher } from '../utils';
import { Dropload, EmbedExtractors, SuperVideo } from '../embed-extractor';
import { EmbedExtractorRegistry } from '../embed-extractor';
import { Context } from '../types';
jest.mock('../utils/Fetcher');
// @ts-expect-error No constructor args needed
const fetcher = new Fetcher();
const frenchcloud = new FrenchCloud(fetcher, new EmbedExtractors([new Dropload(fetcher), new SuperVideo(fetcher)]));
const handler = new FrenchCloud(fetcher, new EmbedExtractorRegistry(fetcher));
const ctx: Context = { ip: '127.0.0.1' };
describe('FrenchCloud', () => {
test('does not handle non imdb movies', async () => {
const streams = await frenchcloud.handle(ctx, 'kitsu:123');
const streams = await handler.handle(ctx, 'kitsu:123');
expect(streams).toHaveLength(0);
});
test('handles non-existent movies gracefully', async () => {
const streams = await frenchcloud.handle(ctx, 'tt12345678');
const streams = await handler.handle(ctx, 'tt12345678');
expect(streams).toHaveLength(0);
});
test('handle imdb the devil\'s bath', async () => {
const streams = await frenchcloud.handle(ctx, 'tt29141112');
const streams = (await handler.handle(ctx, 'tt29141112')).filter(stream => stream !== undefined);
expect(streams).toHaveLength(2);
expect(streams[0]).toStrictEqual({

View file

@ -1,7 +1,7 @@
import * as cheerio from 'cheerio';
import { Handler } from './types';
import { Fetcher, parseImdbId } from '../utils';
import { EmbedExtractors } from '../embed-extractor';
import { EmbedExtractorRegistry } from '../embed-extractor';
import { Context } from '../types';
export class FrenchCloud implements Handler {
@ -14,9 +14,9 @@ export class FrenchCloud implements Handler {
readonly languages = ['fr'];
private readonly fetcher: Fetcher;
private readonly embedExtractors: EmbedExtractors;
private readonly embedExtractors: EmbedExtractorRegistry;
constructor(fetcher: Fetcher, embedExtractors: EmbedExtractors) {
constructor(fetcher: Fetcher, embedExtractors: EmbedExtractorRegistry) {
this.fetcher = fetcher;
this.embedExtractors = embedExtractors;
}
@ -34,7 +34,7 @@ export class FrenchCloud implements Handler {
$('[data-link!=""]')
.map((_i, el) => new URL(($(el).attr('data-link') as string).replace(/^(https:)?\/\//, 'https://')))
.toArray()
.filter(embedUrl => embedUrl.host.match(/(dropload|supervideo)/))
.filter(embedUrl => !embedUrl.host.match(/frenchcloud/))
.map(embedUrl => this.embedExtractors.handle(ctx, embedUrl, 'fr')),
);
};

View file

@ -1,29 +1,29 @@
import { KinoKiste } from './KinoKiste';
import { Dropload, EmbedExtractors, SuperVideo } from '../embed-extractor';
import { EmbedExtractorRegistry } from '../embed-extractor';
import { Fetcher } from '../utils';
import { Context } from '../types';
jest.mock('../utils/Fetcher');
// @ts-expect-error No constructor args needed
const fetcher = new Fetcher();
const kinokiste = new KinoKiste(fetcher, new EmbedExtractors([new Dropload(fetcher), new SuperVideo(fetcher)]));
const handler = new KinoKiste(fetcher, new EmbedExtractorRegistry(fetcher));
const ctx: Context = { ip: '127.0.0.1' };
describe('KinoKiste', () => {
test('does not handle non imdb series', async () => {
const streams = await kinokiste.handle(ctx, 'kitsu:123');
const streams = await handler.handle(ctx, 'kitsu:123');
expect(streams).toHaveLength(0);
});
test('handles non-existent series gracefully', async () => {
const streams = await kinokiste.handle(ctx, 'tt12345678:1:1');
const streams = await handler.handle(ctx, 'tt12345678:1:1');
expect(streams).toHaveLength(0);
});
test('handle imdb black mirror s2e4', async () => {
const streams = await kinokiste.handle(ctx, 'tt2085059:2:4');
const streams = (await handler.handle(ctx, 'tt2085059:2:4')).filter(stream => stream !== undefined);
expect(streams).toHaveLength(2);
expect(streams[0]).toStrictEqual({

View file

@ -1,7 +1,7 @@
import * as cheerio from 'cheerio';
import { Handler } from './types';
import { ImdbId, parseImdbId, Fetcher } from '../utils';
import { EmbedExtractors } from '../embed-extractor';
import { EmbedExtractorRegistry } from '../embed-extractor';
import { Context } from '../types';
export class KinoKiste implements Handler {
@ -14,9 +14,9 @@ export class KinoKiste implements Handler {
readonly languages = ['de'];
private readonly fetcher: Fetcher;
private readonly embedExtractors: EmbedExtractors;
private readonly embedExtractors: EmbedExtractorRegistry;
constructor(fetcher: Fetcher, embedExtractors: EmbedExtractors) {
constructor(fetcher: Fetcher, embedExtractors: EmbedExtractorRegistry) {
this.fetcher = fetcher;
this.embedExtractors = embedExtractors;
}
@ -43,7 +43,7 @@ export class KinoKiste implements Handler {
.children('[data-link]')
.map((_i, el) => new URL(($(el).attr('data-link') as string).replace(/^(https:)?\/\//, 'https://')))
.toArray()
.filter(embedUrl => embedUrl.host.match(/(dropload|supervideo)/))
.filter(embedUrl => !embedUrl.host.match(/kinokiste/))
.map(embedUrl => this.embedExtractors.handle(ctx, embedUrl, 'de')),
);
};

View file

@ -1,29 +1,29 @@
import { MeineCloud } from './MeineCloud';
import { Fetcher } from '../utils';
import { Dropload, EmbedExtractors, SuperVideo } from '../embed-extractor';
import { EmbedExtractorRegistry } from '../embed-extractor';
import { Context } from '../types';
jest.mock('../utils/Fetcher');
// @ts-expect-error No constructor args needed
const fetcher = new Fetcher();
const meinecloud = new MeineCloud(fetcher, new EmbedExtractors([new Dropload(fetcher), new SuperVideo(fetcher)]));
const handler = new MeineCloud(fetcher, new EmbedExtractorRegistry(fetcher));
const ctx: Context = { ip: '127.0.0.1' };
describe('MeineCloud', () => {
test('does not handle non imdb movies', async () => {
const streams = await meinecloud.handle(ctx, 'kitsu:123');
const streams = await handler.handle(ctx, 'kitsu:123');
expect(streams).toHaveLength(0);
});
test('handles non-existent movies gracefully', async () => {
const streams = await meinecloud.handle(ctx, 'tt12345678');
const streams = await handler.handle(ctx, 'tt12345678');
expect(streams).toHaveLength(0);
});
test('handle imdb the devil\'s bath', async () => {
const streams = await meinecloud.handle(ctx, 'tt29141112');
const streams = (await handler.handle(ctx, 'tt29141112')).filter(stream => stream !== undefined);
expect(streams).toHaveLength(2);
expect(streams[0]).toStrictEqual({

View file

@ -1,7 +1,7 @@
import * as cheerio from 'cheerio';
import { Handler } from './types';
import { Fetcher, parseImdbId } from '../utils';
import { EmbedExtractors } from '../embed-extractor';
import { EmbedExtractorRegistry } from '../embed-extractor';
import { Context } from '../types';
export class MeineCloud implements Handler {
@ -14,9 +14,9 @@ export class MeineCloud implements Handler {
readonly languages = ['de'];
private readonly fetcher: Fetcher;
private readonly embedExtractors: EmbedExtractors;
private readonly embedExtractors: EmbedExtractorRegistry;
constructor(fetcher: Fetcher, embedExtractors: EmbedExtractors) {
constructor(fetcher: Fetcher, embedExtractors: EmbedExtractorRegistry) {
this.fetcher = fetcher;
this.embedExtractors = embedExtractors;
}
@ -34,7 +34,7 @@ export class MeineCloud implements Handler {
$('[data-link!=""]')
.map((_i, el) => new URL(($(el).attr('data-link') as string).replace(/^(https:)?\/\//, 'https://')))
.toArray()
.filter(embedUrl => embedUrl.host.match(/(dropload|supervideo)/))
.filter(embedUrl => !embedUrl.host.match(/meinecloud/))
.map(embedUrl => this.embedExtractors.handle(ctx, embedUrl, 'de')),
);
};

View file

@ -1,29 +1,29 @@
import { MostraGuarda } from './MostraGuarda';
import { Fetcher } from '../utils';
import { Dropload, EmbedExtractors, SuperVideo } from '../embed-extractor';
import { EmbedExtractorRegistry } from '../embed-extractor';
import { Context } from '../types';
jest.mock('../utils/Fetcher');
// @ts-expect-error No constructor args needed
const fetcher = new Fetcher();
const mostraguarda = new MostraGuarda(fetcher, new EmbedExtractors([new Dropload(fetcher), new SuperVideo(fetcher)]));
const handler = new MostraGuarda(fetcher, new EmbedExtractorRegistry(fetcher));
const ctx: Context = { ip: '127.0.0.1' };
describe('MostraGuarda', () => {
test('does not handle non imdb movies', async () => {
const streams = await mostraguarda.handle(ctx, 'kitsu:123');
const streams = await handler.handle(ctx, 'kitsu:123');
expect(streams).toHaveLength(0);
});
test('handles non-existent movies gracefully', async () => {
const streams = await mostraguarda.handle(ctx, 'tt12345678');
const streams = await handler.handle(ctx, 'tt12345678');
expect(streams).toHaveLength(0);
});
test('handle imdb the devil\'s bath', async () => {
const streams = await mostraguarda.handle(ctx, 'tt29141112');
const streams = (await handler.handle(ctx, 'tt29141112')).filter(stream => stream !== undefined);
expect(streams).toHaveLength(2);
expect(streams[0]).toStrictEqual({

View file

@ -1,7 +1,7 @@
import * as cheerio from 'cheerio';
import { Handler } from './types';
import { Fetcher, parseImdbId } from '../utils';
import { EmbedExtractors } from '../embed-extractor';
import { EmbedExtractorRegistry } from '../embed-extractor';
import { Context } from '../types';
export class MostraGuarda implements Handler {
@ -14,9 +14,9 @@ export class MostraGuarda implements Handler {
readonly languages = ['it'];
private readonly fetcher: Fetcher;
private readonly embedExtractors: EmbedExtractors;
private readonly embedExtractors: EmbedExtractorRegistry;
constructor(fetcher: Fetcher, embedExtractors: EmbedExtractors) {
constructor(fetcher: Fetcher, embedExtractors: EmbedExtractorRegistry) {
this.fetcher = fetcher;
this.embedExtractors = embedExtractors;
}
@ -34,7 +34,7 @@ export class MostraGuarda implements Handler {
$('[data-link!=""]')
.map((_i, el) => new URL(($(el).attr('data-link') as string).replace(/^(https:)?\/\//, 'https://')))
.toArray()
.filter(embedUrl => embedUrl.host.match(/(dropload|supervideo)/))
.filter(embedUrl => !embedUrl.host.match(/mostraguarda/))
.map(embedUrl => this.embedExtractors.handle(ctx, embedUrl, 'it')),
);
};

View file

@ -1,29 +1,29 @@
import { VerHdLink } from './VerHdLink';
import { Fetcher } from '../utils';
import { Dropload, EmbedExtractors, SuperVideo } from '../embed-extractor';
import { EmbedExtractorRegistry } from '../embed-extractor';
import { Context } from '../types';
jest.mock('../utils/Fetcher');
// @ts-expect-error No constructor args needed
const fetcher = new Fetcher();
const mostraguarda = new VerHdLink(fetcher, new EmbedExtractors([new Dropload(fetcher), new SuperVideo(fetcher)]));
const handler = new VerHdLink(fetcher, new EmbedExtractorRegistry(fetcher));
const ctx: Context = { ip: '127.0.0.1' };
describe('VerHdLink', () => {
test('does not handle non imdb movies', async () => {
const streams = await mostraguarda.handle(ctx, 'kitsu:123');
const streams = await handler.handle(ctx, 'kitsu:123');
expect(streams).toHaveLength(0);
});
test('handles non-existent movies gracefully', async () => {
const streams = await mostraguarda.handle(ctx, 'tt12345678');
const streams = await handler.handle(ctx, 'tt12345678');
expect(streams).toHaveLength(0);
});
test('handle titanic', async () => {
const streams = await mostraguarda.handle(ctx, 'tt0120338');
const streams = (await handler.handle(ctx, 'tt0120338')).filter(stream => stream !== undefined);
expect(streams).toHaveLength(4);
expect(streams[0]).toStrictEqual({

View file

@ -1,7 +1,7 @@
import * as cheerio from 'cheerio';
import { Handler } from './types';
import { Fetcher, parseImdbId } from '../utils';
import { EmbedExtractors } from '../embed-extractor';
import { EmbedExtractorRegistry } from '../embed-extractor';
import { Context } from '../types';
export class VerHdLink implements Handler {
@ -14,9 +14,9 @@ export class VerHdLink implements Handler {
readonly languages = ['es', 'mx'];
private readonly fetcher: Fetcher;
private readonly embedExtractors: EmbedExtractors;
private readonly embedExtractors: EmbedExtractorRegistry;
constructor(fetcher: Fetcher, embedExtractors: EmbedExtractors) {
constructor(fetcher: Fetcher, embedExtractors: EmbedExtractorRegistry) {
this.fetcher = fetcher;
this.embedExtractors = embedExtractors;
}
@ -45,7 +45,7 @@ export class VerHdLink implements Handler {
return $('[data-link!=""]', el)
.map((_i, el) => new URL(($(el).attr('data-link') as string).replace(/^(https:)?\/\//, 'https://')))
.toArray()
.filter(embedUrl => embedUrl.host.match(/(dropload|supervideo)/))
.filter(embedUrl => !embedUrl.host.match(/verhdlink/))
.map(embedUrl => this.embedExtractors.handle(ctx, embedUrl, countryCode));
}),
);

View file

@ -9,5 +9,5 @@ export interface Handler {
readonly languages: string[];
readonly handle: (ctx: Context, id: string) => Promise<UrlResult[]>;
readonly handle: (ctx: Context, id: string) => Promise<(UrlResult | undefined)[]>;
}

View file

@ -2,7 +2,7 @@ import express, { NextFunction, Request, Response } from 'express';
import makeFetchHappen from 'make-fetch-happen';
import winston from 'winston';
import { FrenchCloud, Handler, KinoKiste, MeineCloud, MostraGuarda, VerHdLink } from './handler';
import { Dropload, EmbedExtractors, SuperVideo } from './embed-extractor';
import { EmbedExtractorRegistry } from './embed-extractor';
import { ConfigureController, ManifestController, StreamController } from './controller';
import { Fetcher } from './utils';
import fs from 'node:fs';
@ -28,10 +28,7 @@ const fetcher = new Fetcher(
logger,
);
const embedExtractors = new EmbedExtractors([
new Dropload(fetcher),
new SuperVideo(fetcher),
]);
const embedExtractors = new EmbedExtractorRegistry(fetcher);
const handlers: Handler[] = [
new FrenchCloud(fetcher, embedExtractors),

View file

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