215 lines
6.9 KiB
TypeScript
215 lines
6.9 KiB
TypeScript
import { ContentType, Stream } from 'stremio-addon-sdk';
|
|
import winston from 'winston';
|
|
import bytes from 'bytes';
|
|
import { Context, UrlResult } from '../types';
|
|
import { Source } from '../source';
|
|
import { BlockedError, HttpError, NotFoundError, QueueIsFullError, TimeoutError, TooManyRequestsError } from '../error';
|
|
import { flagFromCountryCode, languageFromCountryCode } from './language';
|
|
import { envGetAppName } from './env';
|
|
import { Id } from './id';
|
|
import { ExtractorRegistry } from '../extractor';
|
|
import { showExternalUrls } from './config';
|
|
|
|
interface ResolveResponse {
|
|
streams: Stream[];
|
|
ttl?: number;
|
|
}
|
|
|
|
export class StreamResolver {
|
|
private readonly logger: winston.Logger;
|
|
private readonly extractorRegistry: ExtractorRegistry;
|
|
|
|
public constructor(logger: winston.Logger, extractorRegistry: ExtractorRegistry) {
|
|
this.logger = logger;
|
|
this.extractorRegistry = extractorRegistry;
|
|
}
|
|
|
|
public async resolve(ctx: Context, sources: Source[], type: ContentType, id: Id): Promise<ResolveResponse> {
|
|
if (sources.length === 0) {
|
|
return {
|
|
streams: [
|
|
{
|
|
name: 'WebStreamr',
|
|
title: '⚠️ No sources found. Please re-configure the plugin.',
|
|
ytId: 'E4WlUXrJgy4',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
const streams: Stream[] = [];
|
|
|
|
let handlerErrorOccurred = false;
|
|
const urlResults: UrlResult[] = [];
|
|
const handlerPromises = sources.map(async (handler) => {
|
|
if (!handler.contentTypes.includes(type)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const handleResults = await handler.handle(ctx, type, id);
|
|
this.logger.info(`${handler.id} returned ${handleResults.length} urls`, ctx);
|
|
|
|
const handlerUrlResults = await Promise.all(
|
|
handleResults.map(({ countryCode, title, url }) => this.extractorRegistry.handle(ctx, url, countryCode, title)),
|
|
);
|
|
|
|
urlResults.push(...handlerUrlResults.flat());
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) {
|
|
return;
|
|
}
|
|
|
|
handlerErrorOccurred = true;
|
|
|
|
streams.push({
|
|
name: envGetAppName(),
|
|
title: [`🔗 ${handler.label}`, this.logErrorAndReturnNiceString(ctx, handler.id, error)].join('\n'),
|
|
ytId: 'E4WlUXrJgy4',
|
|
});
|
|
}
|
|
});
|
|
await Promise.all(handlerPromises);
|
|
|
|
urlResults.sort((a, b) => {
|
|
const heightComparison = (b.meta.height ?? 0) - (a.meta.height ?? 0);
|
|
if (heightComparison !== 0) {
|
|
return heightComparison;
|
|
}
|
|
|
|
const bytesComparison = (b.meta.bytes ?? 0) - (a.meta.bytes ?? 0);
|
|
if (bytesComparison !== 0) {
|
|
return bytesComparison;
|
|
}
|
|
|
|
if (a.isExternal || b.isExternal) {
|
|
return a.isExternal ? 1 : -1;
|
|
}
|
|
|
|
return a.label.localeCompare(b.label);
|
|
});
|
|
|
|
this.logger.info(`Return ${urlResults.length} streams`, ctx);
|
|
|
|
streams.push(
|
|
...urlResults.filter(urlResult => !urlResult.isExternal || showExternalUrls(ctx.config) || urlResult.error)
|
|
.map(urlResult => ({
|
|
...this.buildUrl(ctx, urlResult),
|
|
name: this.buildName(ctx, urlResult),
|
|
title: this.buildTitle(ctx, urlResult),
|
|
behaviorHints: {
|
|
...(urlResult.sourceId && { bingeGroup: `webstreamr-${urlResult.sourceId}` }),
|
|
...(urlResult.requestHeaders !== undefined && {
|
|
notWebReady: true,
|
|
proxyHeaders: { request: urlResult.requestHeaders },
|
|
}),
|
|
...(urlResult.meta.bytes && { videoSize: urlResult.meta.bytes }),
|
|
},
|
|
})),
|
|
);
|
|
|
|
const ttl = !handlerErrorOccurred ? this.determineTtl(urlResults) : undefined;
|
|
|
|
return {
|
|
streams,
|
|
...(ttl && { ttl }),
|
|
};
|
|
};
|
|
|
|
private determineTtl(urlResults: UrlResult[]): number | undefined {
|
|
if (!urlResults.length) {
|
|
return 900000; // 15m
|
|
}
|
|
|
|
if (urlResults.some(urlResult => urlResult.ttl === undefined)) {
|
|
return undefined;
|
|
}
|
|
|
|
return Math.min(...urlResults.map(urlResult => urlResult.ttl as number));
|
|
};
|
|
|
|
private buildUrl(ctx: Context, urlResult: UrlResult): { externalUrl: string } | { url: string } | { ytId: string } {
|
|
if (!urlResult.isExternal) {
|
|
return { url: urlResult.url.href };
|
|
}
|
|
|
|
if (showExternalUrls(ctx.config)) {
|
|
return { externalUrl: urlResult.url.href };
|
|
}
|
|
|
|
return { ytId: 'E4WlUXrJgy4' };
|
|
};
|
|
|
|
private buildName(ctx: Context, urlResult: UrlResult): string {
|
|
let name = envGetAppName();
|
|
|
|
name += urlResult.meta.height ? ` ${urlResult.meta.height}P` : ' N/A';
|
|
|
|
if (urlResult.isExternal && showExternalUrls(ctx.config)) {
|
|
name += ` ⚠️ external`;
|
|
}
|
|
|
|
return name;
|
|
};
|
|
|
|
private logErrorAndReturnNiceString(ctx: Context, source: string, error: unknown): string {
|
|
if (error instanceof BlockedError) {
|
|
if (error.reason === 'cloudflare_challenge') {
|
|
this.logger.warn(`${source}: Request was blocked via Cloudflare challenge.`, ctx);
|
|
} else {
|
|
this.logger.warn(`${source}: Request was blocked, headers: ${JSON.stringify(error.headers)}.`, ctx);
|
|
}
|
|
|
|
return '⚠️ Request was blocked.';
|
|
}
|
|
|
|
if (error instanceof TooManyRequestsError) {
|
|
this.logger.warn(`${source}: Rate limited for ${error.retryAfter} seconds.`, ctx);
|
|
|
|
return '🚦 Request was rate-limited. Please try again later or consider self-hosting.';
|
|
}
|
|
|
|
if (error instanceof TimeoutError) {
|
|
this.logger.warn(`${source}: Request timed out.`, ctx);
|
|
|
|
return '🐢 Request timed out.';
|
|
}
|
|
|
|
if (error instanceof QueueIsFullError) {
|
|
this.logger.warn(`${source}: Request queue is full.`, ctx);
|
|
|
|
return '⏳ Request queue is full. Please try again later or consider self-hosting.';
|
|
}
|
|
|
|
if (error instanceof HttpError) {
|
|
this.logger.error(`${source}: HTTP status ${error.status} (${error.statusText}), headers: ${JSON.stringify(error.headers)}, stack: ${error.stack}.`, ctx);
|
|
return `❌ Request failed with status ${error.status} (${error.statusText}). Request-id: ${ctx.id}.`;
|
|
}
|
|
|
|
const cause = (error as Error & { cause?: unknown }).cause;
|
|
this.logger.error(`${source} error: ${error}, cause: ${cause}, stack: ${(error as Error).stack}`, ctx);
|
|
|
|
return `❌ Request failed. Request-id: ${ctx.id}.`;
|
|
};
|
|
|
|
private buildTitle(ctx: Context, urlResult: UrlResult): string {
|
|
const titleLines = [];
|
|
|
|
if (urlResult.meta.title) {
|
|
titleLines.push(`📂 ${urlResult.meta.title}`);
|
|
}
|
|
|
|
if (urlResult.meta.bytes) {
|
|
titleLines.push(`💾 ${bytes.format(urlResult.meta.bytes, { unitSeparator: ' ' })}`);
|
|
}
|
|
|
|
titleLines.push(`🌐 ${languageFromCountryCode(urlResult.meta.countryCode)} ${flagFromCountryCode(urlResult.meta.countryCode)}`);
|
|
titleLines.push(`🔗 ${urlResult.label}`);
|
|
|
|
if (urlResult.error) {
|
|
titleLines.push(this.logErrorAndReturnNiceString(ctx, urlResult.sourceId, urlResult.error));
|
|
}
|
|
|
|
return titleLines.join('\n');
|
|
};
|
|
}
|