feat: introduce custom UrlResult which will be transformed to Stream
This commit is contained in:
parent
b114ac11b7
commit
ee0efa5409
12 changed files with 91 additions and 107 deletions
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
}
|
||||
|
|
|
|||
47
src/index.ts
47
src/index.ts
|
|
@ -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 }));
|
||||
});
|
||||
|
||||
|
|
|
|||
11
src/types.ts
11
src/types.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,4 +5,3 @@ export * from './language';
|
|||
export * from './log';
|
||||
export * from './manifest';
|
||||
export * from './promise';
|
||||
export * from './resolution';
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
|
|
@ -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`;
|
||||
};
|
||||
Loading…
Reference in a new issue