feat: introduce custom UrlResult which will be transformed to Stream

This commit is contained in:
WebStreamr 2025-05-11 20:39:57 +00:00
parent b114ac11b7
commit ee0efa5409
No known key found for this signature in database
12 changed files with 91 additions and 107 deletions

View file

@ -1,5 +1,5 @@
import { EmbedExtractor } from './types';
import { extractUrlFromPacked, Fetcher, iso2ToFlag, scanFromResolution } from '../utils';
import { extractUrlFromPacked, Fetcher } from '../utils';
import { Context } from '../types';
export class Dropload implements EmbedExtractor {
@ -19,20 +19,18 @@ export class Dropload implements EmbedExtractor {
const normalizedUrl = url.toString().replace('/e/', '').replace('/embed-', '/');
const html = await this.fetcher.text(ctx, normalizedUrl);
const resolution = scanFromResolution((html.match(/(\d{3,}x\d{3,}),/) as string[])[1] as string);
const height = (html.match(/\d{3,}x(\d{3,}),/) as string[])[1];
const sizeMatch = html.match(/([\d.]+) ?([GM]B)/) as string[];
const size = `${sizeMatch[1]} ${sizeMatch[2]}`;
return {
url: extractUrlFromPacked(html, [/sources:\[{file:"(.*?)"/]).toString(),
name: `WebStreamr ${resolution}`,
title: `${this.label} | 💾 ${size} | ${iso2ToFlag(language)}`,
behaviorHints: {
group: `webstreamr-${this.id}`,
},
resolution,
url: extractUrlFromPacked(html, [/sources:\[{file:"(.*?)"/]),
label: this.label,
sourceId: this.id,
height,
size,
language,
};
};
}

View file

@ -1,5 +1,5 @@
import { EmbedExtractor } from './types';
import { Context, StreamWithMeta } from '../types';
import { Context, UrlResult } from '../types';
export class EmbedExtractors {
private readonly embedExtractors: EmbedExtractor[];
@ -8,7 +8,7 @@ export class EmbedExtractors {
this.embedExtractors = embedExtractors;
}
readonly handle = async (ctx: Context, url: URL, language: string): Promise<StreamWithMeta> => {
readonly handle = async (ctx: Context, url: URL, language: string): Promise<UrlResult> => {
const embedExtractor = this.embedExtractors.find(embedExtractor => embedExtractor.supports(url));
if (undefined === embedExtractor) {

View file

@ -1,5 +1,5 @@
import { EmbedExtractor } from './types';
import { extractUrlFromPacked, Fetcher, iso2ToFlag, scanFromResolution } from '../utils';
import { extractUrlFromPacked, Fetcher } from '../utils';
import { Context } from '../types';
export class SuperVideo implements EmbedExtractor {
@ -19,19 +19,17 @@ export class SuperVideo implements EmbedExtractor {
const normalizedUrl = url.toString().replace('/e/', '/').replace('/embed-', '/');
const html = await this.fetcher.text(ctx, normalizedUrl);
const resolutionAndSizeMatch = html.match(/(\d{3,}x\d{3,}), ([\d.]+) ?([GM]B)/) as string[];
const resolution = scanFromResolution(resolutionAndSizeMatch[1] as string);
const size = `${resolutionAndSizeMatch[2]} ${resolutionAndSizeMatch[3]}`;
const heightAndSizeMatch = html.match(/\d{3,}x(\d{3,}), ([\d.]+) ?([GM]B)/) as string[];
const height = heightAndSizeMatch[1];
const size = `${heightAndSizeMatch[2]} ${heightAndSizeMatch[3]}`;
return {
url: extractUrlFromPacked(html, [/sources:\[{file:"(.*?)"/]).toString(),
name: `WebStreamr ${resolution}`,
title: `${this.label} | 💾 ${size} | ${iso2ToFlag(language)}`,
behaviorHints: {
group: `webstreamr-${this.id}`,
},
resolution,
url: extractUrlFromPacked(html, [/sources:\[{file:"(.*?)"/]),
label: this.label,
sourceId: this.id,
height,
size,
language,
};
};
}

View file

@ -1,4 +1,4 @@
import { Context, StreamWithMeta } from '../types';
import { Context, UrlResult } from '../types';
export interface EmbedExtractor {
readonly id: string;
@ -7,5 +7,5 @@ export interface EmbedExtractor {
readonly supports: (url: URL) => boolean;
readonly extract: (ctx: Context, url: URL, language: string) => Promise<StreamWithMeta>;
readonly extract: (ctx: Context, url: URL, language: string) => Promise<UrlResult>;
}

View file

@ -27,24 +27,22 @@ describe('KinoKiste', () => {
expect(streams).toHaveLength(2);
expect(streams[0]).toStrictEqual({
behaviorHints: {
group: 'webstreamr-supervideo',
},
name: 'WebStreamr 720p',
resolution: '720p',
url: expect.any(URL),
label: 'SuperVideo',
sourceId: 'supervideo',
height: '720',
size: '699.8 MB',
title: 'SuperVideo | 💾 699.8 MB | 🇩🇪',
url: expect.stringMatching(/^https:\/\/.*?.m3u8/),
language: 'de',
});
expect(streams[0]?.url.href).toMatch(/^https:\/\/.*?.m3u8/);
expect(streams[1]).toStrictEqual({
behaviorHints: {
group: 'webstreamr-dropload',
},
name: 'WebStreamr 720p',
resolution: '720p',
url: expect.any(URL),
label: 'Dropload',
sourceId: 'dropload',
height: '720',
size: '699.8 MB',
title: 'Dropload | 💾 699.8 MB | 🇩🇪',
url: expect.stringMatching(/^https:\/\/.*?.m3u8/),
language: 'de',
});
expect(streams[1]?.url.href).toMatch(/^https:\/\/.*?.m3u8/);
});
});

View file

@ -27,24 +27,22 @@ describe('MeineCloud', () => {
expect(streams).toHaveLength(2);
expect(streams[0]).toStrictEqual({
behaviorHints: {
group: 'webstreamr-supervideo',
},
name: 'WebStreamr 720p',
resolution: '720p',
url: expect.any(URL),
label: 'SuperVideo',
sourceId: 'supervideo',
height: '720',
size: '1.0 GB',
title: 'SuperVideo | 💾 1.0 GB | 🇩🇪',
url: expect.stringMatching(/^https:\/\/.*?.m3u8/),
language: 'de',
});
expect(streams[0]?.url.href).toMatch(/^https:\/\/.*?.m3u8/);
expect(streams[1]).toStrictEqual({
behaviorHints: {
group: 'webstreamr-dropload',
},
name: 'WebStreamr 1080p',
resolution: '1080p',
url: expect.any(URL),
label: 'Dropload',
sourceId: 'dropload',
height: '1080',
size: '1.3 GB',
title: 'Dropload | 💾 1.3 GB | 🇩🇪',
url: expect.stringMatching(/^https:\/\/.*?.m3u8/),
language: 'de',
});
expect(streams[1]?.url.href).toMatch(/^https:\/\/.*?.m3u8/);
});
});

View file

@ -1,4 +1,4 @@
import { Context, StreamWithMeta } from '../types';
import { Context, UrlResult } from '../types';
export interface Handler {
readonly id: string;
@ -9,5 +9,5 @@ export interface Handler {
readonly languages: string[];
readonly handle: (ctx: Context, id: string) => Promise<StreamWithMeta[]>;
readonly handle: (ctx: Context, id: string) => Promise<UrlResult[]>;
}

View file

@ -3,8 +3,8 @@ import makeFetchHappen from 'make-fetch-happen';
import { landingTemplate } from './landingTemplate';
import { Handler, KinoKiste, MeineCloud } from './handler';
import { Dropload, EmbedExtractors, SuperVideo } from './embed-extractor';
import { buildManifest, Fetcher, fulfillAllPromises, logInfo } from './utils';
import { Config, StreamWithMeta } from './types';
import { buildManifest, Fetcher, fulfillAllPromises, iso2ToFlag, logInfo } from './utils';
import { Config, UrlResult } from './types';
import fs from 'node:fs';
import * as os from 'node:os';
@ -95,29 +95,54 @@ addon.get('/:config/stream/:type/:id.json', async function (req: Request, res: R
return;
}
const streams: StreamWithMeta[] = [];
const urlResults: UrlResult[] = [];
const handlerPromises = selectedHandlers.map(async (handler) => {
if (!handler.contentTypes.includes(type)) {
return;
}
const handlerStreams = await handler.handle({ ip: req.ip as string }, id);
logInfo(`${handler.id} returned ${handlerStreams.length} streams`);
const handlerUrlResults = await handler.handle({ ip: req.ip as string }, id);
logInfo(`${handler.id} returned ${handlerUrlResults.length} urls`);
streams.push(...handlerStreams);
urlResults.push(...handlerUrlResults);
});
await fulfillAllPromises(handlerPromises);
streams.sort((a, b) => {
const resolutionComparison = parseInt(b.resolution ?? '0') - parseInt(a.resolution ?? '0');
if (resolutionComparison !== 0) {
return resolutionComparison;
urlResults.sort((a, b) => {
const heightComparison = parseInt(b.height ?? '0') - parseInt(a.height ?? '0');
if (heightComparison !== 0) {
return heightComparison;
}
return parseFloat(b.size ?? '0') - parseFloat(a.size ?? '0');
});
logInfo(`Return ${streams.length} streams`);
logInfo(`Return ${urlResults.length} streams`);
const streams = urlResults.map((urlResult) => {
let name = 'WebStreamr';
if (urlResult.height) {
name += ` ${urlResult.height}p`;
}
let title = urlResult.label;
if (urlResult.size) {
title += ` | 💾 ${urlResult.size}`;
}
if (urlResult.language) {
title += ` | ${iso2ToFlag(urlResult.language)}`;
}
return {
url: urlResult.url.toString(),
name,
title,
behaviourHints: {
group: `webstreamr-${urlResult.sourceId}`,
},
};
});
res.send(JSON.stringify({ streams }));
});

View file

@ -1,4 +1,4 @@
import { Manifest, ManifestConfig, Stream } from 'stremio-addon-sdk';
import { Manifest, ManifestConfig } from 'stremio-addon-sdk';
export interface Context { ip: string }
@ -6,4 +6,11 @@ export type ManifestWithConfig = Manifest & { config: ManifestConfig[] };
export type Config = Record<string, string>;
export type StreamWithMeta = Stream & { resolution: string | undefined; size: string | undefined };
export interface UrlResult {
url: URL;
label: string;
sourceId: string;
height: string | undefined;
size: string | undefined;
language: string | undefined;
}

View file

@ -5,4 +5,3 @@ export * from './language';
export * from './log';
export * from './manifest';
export * from './promise';
export * from './resolution';

View file

@ -1,25 +0,0 @@
import { scanFromResolution } from './resolution';
describe('scanFromResolution', () => {
test.each([
['720p', '1280x720'],
['720p', '1280 x 720 '],
['240p', '352x240'],
['240p', '352x240'],
['480p', '720x480'],
['1080p', '1280x1080'],
['2160p', '3840x2160'],
['4320p', '7680x4320'],
])('returns %s with %s', (output, input) => {
expect(scanFromResolution(input)).toBe(output);
});
test('logs and returns undefined for invalid resolution', () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
expect(scanFromResolution('foo')).toBe(undefined);
expect(consoleWarnSpy).toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('"foo" is not a valid resolution'));
});
});

View file

@ -1,14 +0,0 @@
import { logWarn } from './log';
// Returns the scan from a resolution string. E.g. 720p for "1280x720"
// See https://en.wikipedia.org/wiki/List_of_common_display_resolutions#Digital_standards
export const scanFromResolution = (resolution: string): string | undefined => {
const height = parseInt(resolution.split('x')[1]?.trim() || '');
if (isNaN(height)) {
logWarn(`"${resolution}" is not a valid resolution`);
return undefined;
}
return `${height}p`;
};