mirror of
https://github.com/Viren070/AIOStreams.git
synced 2025-12-01 23:14:04 +01:00
1364 lines
42 KiB
TypeScript
1364 lines
42 KiB
TypeScript
import {
|
|
Addon,
|
|
Manifest,
|
|
Resource,
|
|
StrictManifestResource,
|
|
UserData,
|
|
} from './db';
|
|
import {
|
|
constants,
|
|
createLogger,
|
|
Env,
|
|
getSimpleTextHash,
|
|
getTimeTakenSincePoint,
|
|
maskSensitiveInfo,
|
|
Cache,
|
|
ExtrasParser,
|
|
makeUrlLogSafe,
|
|
} from './utils';
|
|
import { Wrapper } from './wrapper';
|
|
import { PresetManager } from './presets';
|
|
import {
|
|
AddonCatalog,
|
|
Meta,
|
|
MetaPreview,
|
|
ParsedMeta,
|
|
ParsedStream,
|
|
Subtitle,
|
|
} from './db/schemas';
|
|
import { createProxy } from './proxy';
|
|
import { RPDB } from './utils/rpdb';
|
|
import { FeatureControl } from './utils/feature';
|
|
import Proxifier from './streams/proxifier';
|
|
import StreamLimiter from './streams/limiter';
|
|
import {
|
|
StreamFetcher as Fetcher,
|
|
StreamFilterer as Filterer,
|
|
StreamSorter as Sorter,
|
|
StreamDeduplicator as Deduplicator,
|
|
StreamPrecomputer as Precomputer,
|
|
StreamUtils,
|
|
} from './streams';
|
|
import { getAddonName } from './utils/general';
|
|
import { TMDBMetadata, TMDBMetadataResponse } from './metadata/tmdb';
|
|
const logger = createLogger('core');
|
|
|
|
const shuffleCache = Cache.getInstance<string, MetaPreview[]>('shuffle');
|
|
const precacheCache = Cache.getInstance<string, boolean>(
|
|
'precache',
|
|
undefined,
|
|
true
|
|
);
|
|
|
|
export interface AIOStreamsError {
|
|
title?: string;
|
|
description?: string;
|
|
}
|
|
|
|
export interface AIOStreamsResponse<T> {
|
|
success: boolean;
|
|
data: T;
|
|
errors: AIOStreamsError[];
|
|
}
|
|
|
|
export class AIOStreams {
|
|
private userData: UserData;
|
|
private manifestUrl: string;
|
|
private manifests: Record<string, Manifest | null>;
|
|
private supportedResources: Record<string, StrictManifestResource[]>;
|
|
private finalResources: StrictManifestResource[] = [];
|
|
private finalCatalogs: Manifest['catalogs'] = [];
|
|
private finalAddonCatalogs: Manifest['addonCatalogs'] = [];
|
|
private isInitialised: boolean = false;
|
|
private addons: Addon[] = [];
|
|
private skipFailedAddons: boolean = true;
|
|
private proxifier: Proxifier;
|
|
private limiter: StreamLimiter;
|
|
private fetcher: Fetcher;
|
|
private filterer: Filterer;
|
|
private deduplicator: Deduplicator;
|
|
private sorter: Sorter;
|
|
private precomputer: Precomputer;
|
|
|
|
private addonInitialisationErrors: {
|
|
addon: Addon;
|
|
error: string;
|
|
}[] = [];
|
|
|
|
constructor(userData: UserData, skipFailedAddons: boolean = true) {
|
|
this.addonInitialisationErrors = [];
|
|
this.userData = userData;
|
|
this.manifestUrl = `${Env.BASE_URL}/stremio/${this.userData.uuid}/${this.userData.encryptedPassword}/manifest.json`;
|
|
this.manifests = {};
|
|
this.supportedResources = {};
|
|
this.skipFailedAddons = skipFailedAddons;
|
|
this.proxifier = new Proxifier(userData);
|
|
this.limiter = new StreamLimiter(userData);
|
|
this.fetcher = new Fetcher(userData);
|
|
this.filterer = new Filterer(userData);
|
|
this.deduplicator = new Deduplicator(userData);
|
|
this.sorter = new Sorter(userData);
|
|
this.precomputer = new Precomputer(userData);
|
|
}
|
|
|
|
private setUserData(userData: UserData) {
|
|
this.userData = userData;
|
|
}
|
|
|
|
public async initialise(): Promise<AIOStreams> {
|
|
if (this.isInitialised) return this;
|
|
await this.applyPresets();
|
|
await this.assignPublicIps();
|
|
await this.fetchManifests();
|
|
await this.fetchResources();
|
|
this.isInitialised = true;
|
|
return this;
|
|
}
|
|
|
|
private checkInitialised() {
|
|
if (!this.isInitialised) {
|
|
throw new Error(
|
|
'AIOStreams is not initialised. Call initialise() first.'
|
|
);
|
|
}
|
|
}
|
|
|
|
public async getStreams(
|
|
id: string,
|
|
type: string,
|
|
preCaching: boolean = false
|
|
): Promise<
|
|
AIOStreamsResponse<{
|
|
streams: ParsedStream[];
|
|
statistics: { title: string; description: string }[];
|
|
}>
|
|
> {
|
|
logger.info(`Handling stream request`, { type, id });
|
|
|
|
// get a list of all addons that support the stream resource with the given type and id.
|
|
const supportedAddons = [];
|
|
for (const [instanceId, addonResources] of Object.entries(
|
|
this.supportedResources
|
|
)) {
|
|
const resource = addonResources.find(
|
|
(r) =>
|
|
r.name === 'stream' &&
|
|
r.types.includes(type) &&
|
|
(r.idPrefixes
|
|
? r.idPrefixes?.some((prefix) => id.startsWith(prefix))
|
|
: true) // if no id prefixes are defined, assume it supports all IDs
|
|
);
|
|
if (resource) {
|
|
const addon = this.getAddon(instanceId);
|
|
if (addon) {
|
|
supportedAddons.push(addon);
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.info(
|
|
`Found ${supportedAddons.length} addons that support the stream resource`,
|
|
{
|
|
supportedAddons: supportedAddons.map((a) => a.name),
|
|
}
|
|
);
|
|
|
|
const { streams, errors, statistics } = await this.fetcher.fetch(
|
|
supportedAddons,
|
|
type,
|
|
id
|
|
);
|
|
|
|
// append initialisation errors to the errors array
|
|
errors.push(
|
|
...this.addonInitialisationErrors.map((e) => ({
|
|
title: `[❌] ${getAddonName(e.addon)}`,
|
|
description: e.error,
|
|
}))
|
|
);
|
|
|
|
let finalStreams = await this._processStreams(streams, type, id);
|
|
|
|
// if this.userData.precacheNextEpisode is true, start a new thread to request the next episode, check if
|
|
// all provider streams are uncached, and only if so, then send a request to the first uncached stream in the list.
|
|
if (this.userData.precacheNextEpisode && !preCaching) {
|
|
// only precache if the same user hasn't previously cached the next episode of the current episode
|
|
// within the last 24 hours (Env.PRECACHE_NEXT_EPISODE_MIN_INTERVAL)
|
|
let precache = false;
|
|
const cacheKey = `precache-${type}-${id}-${this.userData.uuid}`;
|
|
const cachedNextEpisode = await precacheCache.get(cacheKey, false);
|
|
if (cachedNextEpisode) {
|
|
logger.info(
|
|
`The current request for ${type} ${id} has already had the next episode precached within the last ${Env.PRECACHE_NEXT_EPISODE_MIN_INTERVAL} seconds (${precacheCache.getTTL(cacheKey)} seconds left). Skipping precaching.`
|
|
);
|
|
precache = false;
|
|
} else {
|
|
precache = true;
|
|
}
|
|
if (precache) {
|
|
setImmediate(() => {
|
|
this.precacheNextEpisode(type, id).catch((error) => {
|
|
logger.error('Error during precaching:', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
type,
|
|
id,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// return the final list of streams, followed by the error streams.
|
|
logger.info(
|
|
`Returning ${finalStreams.length} streams and ${errors.length} errors and ${statistics.length} statistic`
|
|
);
|
|
return {
|
|
success: true,
|
|
data: {
|
|
streams: finalStreams,
|
|
statistics: statistics,
|
|
},
|
|
errors: errors,
|
|
};
|
|
}
|
|
|
|
public async getCatalog(
|
|
type: string,
|
|
id: string,
|
|
extras?: string
|
|
): Promise<AIOStreamsResponse<MetaPreview[]>> {
|
|
// step 1
|
|
// get the addon index from the id
|
|
logger.info(`Handling catalog request`, { type, id, extras });
|
|
const start = Date.now();
|
|
const addonInstanceId = id.split('.', 2)[0];
|
|
const addon = this.getAddon(addonInstanceId);
|
|
if (!addon) {
|
|
logger.error(`Addon ${addonInstanceId} not found`);
|
|
return {
|
|
success: false,
|
|
data: [],
|
|
errors: [
|
|
{
|
|
title: `Addon ${addonInstanceId} not found. Try reinstalling the addon.`,
|
|
description: 'Addon not found',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// step 2
|
|
// get the actual catalog id from the id
|
|
const actualCatalogId = id.split('.').slice(1).join('.');
|
|
let modification;
|
|
if (this.userData.catalogModifications) {
|
|
modification = this.userData.catalogModifications.find(
|
|
(mod) =>
|
|
mod.id === id && (mod.type === type || mod.overrideType === type)
|
|
);
|
|
}
|
|
if (modification?.overrideType) {
|
|
// reset the type from the request (which is the overriden type) to the actual type
|
|
type = modification.type;
|
|
}
|
|
const parsedExtras = new ExtrasParser(extras);
|
|
logger.debug(`Parsed extras: ${JSON.stringify(parsedExtras)}`);
|
|
if (parsedExtras.genre === 'None') {
|
|
logger.debug(`Genre extra is None, removing genre extra`);
|
|
parsedExtras.genre = undefined;
|
|
}
|
|
const extrasString = parsedExtras.toString();
|
|
|
|
// step 3
|
|
// get the catalog from the addon
|
|
let catalog;
|
|
try {
|
|
catalog = await new Wrapper(addon).getCatalog(
|
|
type,
|
|
actualCatalogId,
|
|
extrasString
|
|
);
|
|
} catch (error) {
|
|
if (extras && extras.includes('skip')) {
|
|
return {
|
|
success: true,
|
|
data: [],
|
|
errors: [],
|
|
};
|
|
}
|
|
return {
|
|
success: false,
|
|
data: [],
|
|
errors: [
|
|
{
|
|
title: `[❌] ${addon.name}`,
|
|
description: error instanceof Error ? error.message : String(error),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
logger.info(
|
|
`Received catalog ${actualCatalogId} of type ${type} from ${addon.name} in ${getTimeTakenSincePoint(start)}`
|
|
);
|
|
|
|
// apply catalog modifications
|
|
if (modification?.shuffle && !(extras && extras.includes('search'))) {
|
|
// shuffle the catalog array if it is not a search
|
|
const cacheKey = `shuffle-${type}-${actualCatalogId}-${extras}-${this.userData.uuid}`;
|
|
const cachedShuffle = await shuffleCache.get(cacheKey);
|
|
if (cachedShuffle) {
|
|
catalog = cachedShuffle;
|
|
} else {
|
|
for (let i = catalog.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[catalog[i], catalog[j]] = [catalog[j], catalog[i]];
|
|
}
|
|
if (modification.persistShuffleFor) {
|
|
await shuffleCache.set(
|
|
cacheKey,
|
|
catalog,
|
|
modification.persistShuffleFor * 3600
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const rpdbApiKey =
|
|
modification?.rpdb && this.userData.rpdbApiKey
|
|
? this.userData.rpdbApiKey
|
|
: undefined;
|
|
const rpdbApi = rpdbApiKey ? new RPDB(rpdbApiKey) : undefined;
|
|
|
|
catalog = await Promise.all(
|
|
catalog.map(async (item) => {
|
|
// Apply RPDB poster modification
|
|
if (rpdbApiKey && item.poster) {
|
|
let posterUrl = item.poster;
|
|
if (this.userData.rpdbUseRedirectApi !== false && Env.BASE_URL) {
|
|
const id = (item as any).imdb_id || item.id;
|
|
const url = new URL(Env.BASE_URL);
|
|
url.pathname = '/api/v1/rpdb';
|
|
url.searchParams.set('id', id);
|
|
url.searchParams.set('type', type);
|
|
url.searchParams.set('fallback', item.poster);
|
|
url.searchParams.set('apiKey', rpdbApiKey);
|
|
posterUrl = url.toString();
|
|
} else {
|
|
const rpdbPosterUrl = await rpdbApi!.getPosterUrl(
|
|
type,
|
|
(item as any).imdb_id || item.id,
|
|
false
|
|
);
|
|
if (rpdbPosterUrl) {
|
|
posterUrl = rpdbPosterUrl;
|
|
}
|
|
}
|
|
|
|
item.poster = posterUrl;
|
|
}
|
|
|
|
// Apply poster enhancement
|
|
if (this.userData.enhancePosters && Math.random() < 0.2) {
|
|
item.poster = Buffer.from(
|
|
constants.DEFAULT_POSTERS[
|
|
Math.floor(Math.random() * constants.DEFAULT_POSTERS.length)
|
|
],
|
|
'base64'
|
|
).toString('utf-8');
|
|
}
|
|
|
|
if (item.links) {
|
|
item.links = this.convertDiscoverDeepLinks(item.links);
|
|
}
|
|
return item;
|
|
})
|
|
);
|
|
|
|
return { success: true, data: catalog, errors: [] };
|
|
}
|
|
|
|
public async getMeta(
|
|
type: string,
|
|
id: string
|
|
): Promise<AIOStreamsResponse<ParsedMeta | null>> {
|
|
logger.info(`Handling meta request`, { type, id });
|
|
|
|
// Build prioritized list of candidate addons (naturally ordered by priority)
|
|
const candidates: Array<{
|
|
instanceId: string;
|
|
addon: any;
|
|
reason: string;
|
|
}> = [];
|
|
|
|
// Step 1: Find addons with matching idPrefix (added first = higher priority)
|
|
for (const [instanceId, resources] of Object.entries(
|
|
this.supportedResources
|
|
)) {
|
|
const resource = resources.find(
|
|
(r) =>
|
|
r.name === 'meta' &&
|
|
r.types.includes(type) &&
|
|
r.idPrefixes?.some((prefix) => id.startsWith(prefix))
|
|
);
|
|
|
|
if (resource) {
|
|
const addon = this.getAddon(instanceId);
|
|
if (addon) {
|
|
candidates.push({
|
|
instanceId,
|
|
addon,
|
|
reason: 'matching id prefix',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 2: Find addons that support meta for this type (added second = lower priority)
|
|
for (const [instanceId, resources] of Object.entries(
|
|
this.supportedResources
|
|
)) {
|
|
// Skip if already added with higher priority
|
|
if (candidates.some((c) => c.instanceId === instanceId)) {
|
|
continue;
|
|
}
|
|
|
|
// look for addons that support the type, but don't have an id prefix
|
|
const resource = resources.find(
|
|
(r) =>
|
|
r.name === 'meta' && r.types.includes(type) && !r.idPrefixes?.length
|
|
);
|
|
|
|
if (resource) {
|
|
const addon = this.getAddon(instanceId);
|
|
if (addon) {
|
|
candidates.push({
|
|
instanceId,
|
|
addon,
|
|
reason: 'general type support',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (candidates.length === 0) {
|
|
logger.warn(`No supported addon was found for the requested meta`, {
|
|
type,
|
|
id,
|
|
});
|
|
return {
|
|
success: false,
|
|
data: null,
|
|
errors: [],
|
|
};
|
|
}
|
|
|
|
// Try each candidate in order, collecting errors
|
|
const errors: Array<{ title: string; description: string }> = [];
|
|
|
|
for (const candidate of candidates) {
|
|
logger.info(`Trying addon for meta resource`, {
|
|
addonName: candidate.addon.name,
|
|
addonInstanceId: candidate.instanceId,
|
|
reason: candidate.reason,
|
|
});
|
|
|
|
try {
|
|
const meta = await new Wrapper(candidate.addon).getMeta(type, id);
|
|
logger.info(`Successfully got meta from addon`, {
|
|
addonName: candidate.addon.name,
|
|
addonInstanceId: candidate.instanceId,
|
|
});
|
|
meta.links = this.convertDiscoverDeepLinks(meta.links);
|
|
|
|
if (meta.videos) {
|
|
meta.videos = await Promise.all(
|
|
meta.videos.map(async (video) => {
|
|
if (!video.streams) {
|
|
return video;
|
|
}
|
|
video.streams = await this._processStreams(
|
|
video.streams,
|
|
type,
|
|
id,
|
|
true
|
|
);
|
|
return video;
|
|
})
|
|
);
|
|
}
|
|
return {
|
|
success: true,
|
|
data: meta,
|
|
errors: [], // Clear errors on success
|
|
};
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
logger.warn(`Failed to get meta from addon ${candidate.addon.name}`, {
|
|
error: errorMessage,
|
|
reason: candidate.reason,
|
|
});
|
|
|
|
// don't push errors if the reason for trying was general type support
|
|
// this is to ensure that we don't block stremio from making requests to other addons
|
|
// which may potentially be the intended addon
|
|
if (candidate.reason === 'general type support') {
|
|
continue;
|
|
}
|
|
|
|
errors.push({
|
|
title: `[❌] ${candidate.addon.name}`,
|
|
description: errorMessage,
|
|
});
|
|
}
|
|
}
|
|
|
|
// If we reach here, all addons failed
|
|
logger.error(
|
|
`All ${candidates.length} candidate addons failed for meta request`,
|
|
{
|
|
type,
|
|
id,
|
|
candidateCount: candidates.length,
|
|
}
|
|
);
|
|
|
|
return {
|
|
success: false,
|
|
data: null,
|
|
errors,
|
|
};
|
|
}
|
|
// subtitle resource
|
|
public async getSubtitles(
|
|
type: string,
|
|
id: string,
|
|
extras?: string
|
|
): Promise<AIOStreamsResponse<Subtitle[]>> {
|
|
logger.info(`Handling subtitle request`, { type, id, extras });
|
|
|
|
// Find all addons that support subtitles for this type and id prefix
|
|
const supportedAddons = [];
|
|
for (const [instanceId, addonResources] of Object.entries(
|
|
this.supportedResources
|
|
)) {
|
|
const resource = addonResources.find(
|
|
(r) =>
|
|
r.name === 'subtitles' &&
|
|
r.types.includes(type) &&
|
|
(r.idPrefixes
|
|
? r.idPrefixes.some((prefix) => id.startsWith(prefix))
|
|
: true)
|
|
);
|
|
if (resource) {
|
|
const addon = this.getAddon(instanceId);
|
|
if (addon) {
|
|
supportedAddons.push(addon);
|
|
}
|
|
}
|
|
}
|
|
const parsedExtras = new ExtrasParser(extras);
|
|
logger.debug(`Parsed extras: ${JSON.stringify(parsedExtras)}`);
|
|
|
|
// Request subtitles from all supported addons in parallel
|
|
let errors: AIOStreamsError[] = this.addonInitialisationErrors.map(
|
|
(error) => ({
|
|
title: `[❌] ${getAddonName(error.addon)}`,
|
|
description: error.error,
|
|
})
|
|
);
|
|
let allSubtitles: Subtitle[] = [];
|
|
|
|
await Promise.all(
|
|
supportedAddons.map(async (addon) => {
|
|
try {
|
|
const subtitles = await new Wrapper(addon).getSubtitles(
|
|
type,
|
|
id,
|
|
parsedExtras.toString()
|
|
);
|
|
if (subtitles) {
|
|
allSubtitles.push(...subtitles);
|
|
}
|
|
} catch (error) {
|
|
errors.push({
|
|
title: `[❌] ${getAddonName(addon)}`,
|
|
description: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
})
|
|
);
|
|
|
|
return {
|
|
success: true,
|
|
data: allSubtitles,
|
|
errors: errors,
|
|
};
|
|
}
|
|
|
|
// addon_catalog resource
|
|
public async getAddonCatalog(
|
|
type: string,
|
|
id: string
|
|
): Promise<AIOStreamsResponse<AddonCatalog[]>> {
|
|
logger.info(`getAddonCatalog: ${id}`);
|
|
// step 1
|
|
// get the addon instance id from the id
|
|
const addonInstanceId = id.split('.', 2)[0];
|
|
const addon = this.getAddon(addonInstanceId);
|
|
if (!addon) {
|
|
return {
|
|
success: false,
|
|
data: [],
|
|
errors: [
|
|
{
|
|
title: `Addon ${addonInstanceId} not found`,
|
|
description: 'Addon not found',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// step 2
|
|
// get the actual addon catalog id from the id
|
|
const actualAddonCatalogId = id.split('.').slice(1).join('.');
|
|
|
|
// step 3
|
|
// get the addon catalog from the addon
|
|
let addonCatalogs: AddonCatalog[] = [];
|
|
try {
|
|
addonCatalogs = await new Wrapper(addon).getAddonCatalog(
|
|
type,
|
|
actualAddonCatalogId
|
|
);
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
data: [],
|
|
errors: [
|
|
{
|
|
title: `[❌] ${getAddonName(addon)}`,
|
|
description: error instanceof Error ? error.message : String(error),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
// step 4
|
|
return {
|
|
success: true,
|
|
data: addonCatalogs,
|
|
errors: [],
|
|
};
|
|
}
|
|
// converts all addons to
|
|
private async applyPresets() {
|
|
if (!this.userData.presets) {
|
|
return;
|
|
}
|
|
|
|
for (const preset of this.userData.presets.filter((p) => p.enabled)) {
|
|
const addons = await PresetManager.fromId(preset.type).generateAddons(
|
|
this.userData,
|
|
preset.options
|
|
);
|
|
this.addons.push(
|
|
...addons.map(
|
|
(a): Addon => ({
|
|
...a,
|
|
preset: {
|
|
...a.preset,
|
|
id: preset.instanceId,
|
|
},
|
|
// if no identifier is present, we can assume that the preset can only generate one addon at a time and so no
|
|
// unique identifier is needed as the preset instance id is enough to identify the addon
|
|
instanceId: `${preset.instanceId}${getSimpleTextHash(`${a.identifier ?? ''}`).slice(0, 4)}`,
|
|
})
|
|
)
|
|
);
|
|
}
|
|
|
|
if (this.addons.length > Env.MAX_ADDONS) {
|
|
throw new Error(
|
|
`Your current configuration requires ${this.addons.length} addons, but the maximum allowed is ${Env.MAX_ADDONS}. Please reduce the number of addons installed or services enabled. If you own the instance or know the owner, increase the value of the MAX_ADDONS environment variable.`
|
|
);
|
|
}
|
|
}
|
|
|
|
private async fetchManifests() {
|
|
this.manifests = Object.fromEntries(
|
|
await Promise.all(
|
|
this.addons.map(async (addon) => {
|
|
try {
|
|
this.validateAddon(addon);
|
|
return [addon.instanceId, await new Wrapper(addon).getManifest()];
|
|
} catch (error: any) {
|
|
if (this.skipFailedAddons) {
|
|
this.addonInitialisationErrors.push({
|
|
addon: addon,
|
|
error: error.message,
|
|
});
|
|
logger.error(`${error.message}, skipping`);
|
|
return [addon.instanceId, null];
|
|
}
|
|
throw error;
|
|
}
|
|
})
|
|
)
|
|
);
|
|
}
|
|
|
|
private async fetchResources() {
|
|
for (const [instanceId, manifest] of Object.entries(this.manifests)) {
|
|
if (!manifest) continue;
|
|
|
|
// Convert string resources to StrictManifestResource objects
|
|
let addonResources = manifest.resources.map((resource) => {
|
|
if (typeof resource === 'string') {
|
|
return {
|
|
name: resource as Resource,
|
|
types: manifest.types,
|
|
idPrefixes: manifest.idPrefixes,
|
|
};
|
|
}
|
|
return resource;
|
|
});
|
|
|
|
const addon = this.getAddon(instanceId);
|
|
|
|
if (!addon) {
|
|
logger.error(`Addon with instanceId ${instanceId} not found`);
|
|
continue;
|
|
}
|
|
|
|
// Filter and merge resources
|
|
for (const resource of addonResources) {
|
|
if (
|
|
addon.resources &&
|
|
addon.resources.length > 0 &&
|
|
!addon.resources.includes(resource.name)
|
|
) {
|
|
addonResources = addonResources.filter(
|
|
(r) => r.name !== resource.name
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const existing = this.finalResources.find(
|
|
(r) => r.name === resource.name
|
|
);
|
|
// NOTE: we cannot push idPrefixes in the scenario that the user adds multiple addons that provide meta for example,
|
|
// and one of them has defined idPrefixes, while the other hasn't
|
|
// in this case, stremio assumes we only support that resource for the specified id prefix and then
|
|
// will not send a request to AIOStreams for other id prefixes even though our other addon that didn't specify
|
|
// an id prefix technically says it supports all ids
|
|
|
|
// leaving idPrefixes as null/undefined causes various odd issues with stremio even though it says it is optional.
|
|
// therefore, we set it as normal, but if there comes an addon that doesn't support any id prefixes, we set it to undefined
|
|
// this fixes issues in most cases as most addons do provide idPrefixes
|
|
if (existing) {
|
|
existing.types = [...new Set([...existing.types, ...resource.types])];
|
|
if (
|
|
existing.idPrefixes &&
|
|
existing.idPrefixes.length > 0 &&
|
|
resource.idPrefixes &&
|
|
resource.idPrefixes.length > 0
|
|
) {
|
|
existing.idPrefixes = [
|
|
...new Set([...existing.idPrefixes, ...resource.idPrefixes]),
|
|
];
|
|
} else {
|
|
if (resource.name !== 'catalog' && !resource.idPrefixes?.length) {
|
|
logger.warn(
|
|
`Addon ${getAddonName(addon)} does not provide idPrefixes for type ${resource.name}, setting idPrefixes to undefined`
|
|
);
|
|
}
|
|
// if an addon for this type does not provide idPrefixes, we set it to undefined
|
|
// to ensure it works with at least some platforms on stremio rather than none.
|
|
existing.idPrefixes = undefined;
|
|
}
|
|
} else {
|
|
if (!resource.idPrefixes?.length && resource.name !== 'catalog') {
|
|
logger.warn(
|
|
`Addon ${getAddonName(addon)} does not provide idPrefixes for type ${resource.name}, setting idPrefixes to undefined`
|
|
);
|
|
}
|
|
this.finalResources.push({
|
|
...resource,
|
|
// explicitly set to null
|
|
idPrefixes: resource.idPrefixes?.length
|
|
? resource.idPrefixes
|
|
: undefined,
|
|
// idPrefixes: resource.idPrefixes
|
|
// ? [...resource.idPrefixes]
|
|
// : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
logger.verbose(
|
|
`Determined that ${getAddonName(addon)} (Instance ID: ${instanceId}) has support for the following resources: ${JSON.stringify(
|
|
addonResources
|
|
)}`
|
|
);
|
|
|
|
// Add catalogs with prefixed IDs (ensure to check that if addon.resources is defined and does not have catalog
|
|
// then we do not add the catalogs)
|
|
|
|
if (
|
|
!addon.resources?.length ||
|
|
(addon.resources && addon.resources.includes('catalog'))
|
|
) {
|
|
this.finalCatalogs.push(
|
|
...manifest.catalogs.map((catalog) => ({
|
|
...catalog,
|
|
id: `${addon.instanceId}.${catalog.id}`,
|
|
}))
|
|
);
|
|
}
|
|
|
|
// add all addon catalogs, prefixing id with index
|
|
if (manifest.addonCatalogs) {
|
|
this.finalAddonCatalogs!.push(
|
|
...(manifest.addonCatalogs || []).map((catalog) => ({
|
|
...catalog,
|
|
id: `${addon.instanceId}.${catalog.id}`,
|
|
}))
|
|
);
|
|
}
|
|
|
|
this.supportedResources[instanceId] = addonResources;
|
|
}
|
|
|
|
logger.verbose(
|
|
`Parsed all catalogs and determined the following catalogs: ${JSON.stringify(
|
|
this.finalCatalogs.map((c) => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
type: c.type,
|
|
}))
|
|
)}`
|
|
);
|
|
|
|
logger.verbose(
|
|
`Parsed all addon catalogs and determined the following catalogs: ${JSON.stringify(
|
|
this.finalAddonCatalogs?.map((c) => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
type: c.type,
|
|
}))
|
|
)}`
|
|
);
|
|
|
|
logger.verbose(
|
|
`Parsed all resources and determined the following resources: ${JSON.stringify(
|
|
this.finalResources.map((r) => ({
|
|
name: r.name,
|
|
types: r.types,
|
|
idPrefixes: r.idPrefixes,
|
|
}))
|
|
)}`
|
|
);
|
|
|
|
// if meta resouce exists, and aiostreamserror to idPrefixes only if idPrefixes is defined
|
|
const metaResource = this.finalResources.find((r) => r.name === 'meta');
|
|
if (metaResource) {
|
|
if (metaResource.idPrefixes) {
|
|
metaResource.idPrefixes = [
|
|
...metaResource.idPrefixes,
|
|
'aiostreamserror',
|
|
];
|
|
}
|
|
}
|
|
|
|
if (this.userData.catalogModifications) {
|
|
this.finalCatalogs = this.finalCatalogs
|
|
// Sort catalogs based on catalogModifications order, with non-modified catalogs at the end
|
|
.sort((a, b) => {
|
|
const aModIndex = this.userData.catalogModifications!.findIndex(
|
|
(mod) => mod.id === a.id && mod.type === a.type
|
|
);
|
|
const bModIndex = this.userData.catalogModifications!.findIndex(
|
|
(mod) => mod.id === b.id && mod.type === b.type
|
|
);
|
|
|
|
// If neither catalog is in modifications, maintain original order
|
|
if (aModIndex === -1 && bModIndex === -1) {
|
|
return (
|
|
this.finalCatalogs.indexOf(a) - this.finalCatalogs.indexOf(b)
|
|
);
|
|
}
|
|
|
|
// If only one catalog is in modifications, it should come first
|
|
if (aModIndex === -1) return 1;
|
|
if (bModIndex === -1) return -1;
|
|
|
|
// If both are in modifications, sort by their order in modifications
|
|
return aModIndex - bModIndex;
|
|
})
|
|
// filter out any catalogs that are disabled
|
|
.filter((catalog) => {
|
|
const modification = this.userData.catalogModifications!.find(
|
|
(mod) => mod.id === catalog.id && mod.type === catalog.type
|
|
);
|
|
return modification?.enabled !== false; // only if explicity disabled i.e. enabled is true or undefined
|
|
})
|
|
// rename any catalogs if necessary and apply the onlyOnDiscover modification
|
|
.map((catalog) => {
|
|
const modification = this.userData.catalogModifications!.find(
|
|
(mod) => mod.id === catalog.id && mod.type === catalog.type
|
|
);
|
|
if (modification?.name) {
|
|
catalog.name = modification.name;
|
|
}
|
|
if (modification?.onlyOnDiscover) {
|
|
// A few cases
|
|
// the catalog already has genres. In which case we set isRequired for the genre extra to true
|
|
// and also add a new genre with name 'None' to the top - if isRequried was previously false.
|
|
|
|
// the catalog does not have genres. In which case we add a new genre extra with only one option 'None'
|
|
// and set isRequired to true
|
|
|
|
const genreExtra = catalog.extra?.find((e) => e.name === 'genre');
|
|
if (genreExtra) {
|
|
if (!genreExtra.isRequired) {
|
|
// if catalog supports a no genre option, we add none to the top so it is still accessible
|
|
genreExtra.options?.unshift('None');
|
|
}
|
|
// set it to required to hide it from the home page
|
|
genreExtra.isRequired = true;
|
|
} else {
|
|
// add a new genre extra with only one option 'None'
|
|
if (!catalog.extra) {
|
|
catalog.extra = [];
|
|
}
|
|
catalog.extra.push({
|
|
name: 'genre',
|
|
options: ['None'],
|
|
isRequired: true,
|
|
});
|
|
}
|
|
}
|
|
if (modification?.overrideType !== undefined) {
|
|
catalog.type = modification.overrideType;
|
|
}
|
|
if (modification?.disableSearch) {
|
|
catalog.extra = catalog.extra?.filter((e) => e.name !== 'search');
|
|
}
|
|
return catalog;
|
|
});
|
|
}
|
|
}
|
|
|
|
public getResources(): StrictManifestResource[] {
|
|
this.checkInitialised();
|
|
return this.finalResources;
|
|
}
|
|
|
|
public getCatalogs(): Manifest['catalogs'] {
|
|
this.checkInitialised();
|
|
return this.finalCatalogs;
|
|
}
|
|
|
|
public getAddonCatalogs(): Manifest['addonCatalogs'] {
|
|
this.checkInitialised();
|
|
return this.finalAddonCatalogs;
|
|
}
|
|
|
|
public getAddon(instanceId: string): Addon | undefined {
|
|
return this.addons.find((a) => a.instanceId === instanceId);
|
|
}
|
|
|
|
private async getProxyIp() {
|
|
let userIp = this.userData.ip;
|
|
const PRIVATE_IP_REGEX =
|
|
/^(::1|::ffff:(10|127|192|172)\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|10\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|192\.168\.(\d{1,3})\.(\d{1,3})|172\.(1[6-9]|2[0-9]|3[0-1])\.(\d{1,3})\.(\d{1,3}))$/;
|
|
|
|
if (userIp && PRIVATE_IP_REGEX.test(userIp)) {
|
|
userIp = undefined;
|
|
}
|
|
if (!this.userData.proxy) {
|
|
return userIp;
|
|
}
|
|
|
|
const proxy = createProxy(this.userData.proxy);
|
|
if (proxy.getConfig().enabled) {
|
|
userIp = await this.retryGetIp(
|
|
() => proxy.getPublicIp(),
|
|
'Proxy public IP'
|
|
);
|
|
}
|
|
return userIp;
|
|
}
|
|
|
|
private async retryGetIp<T>(
|
|
getter: () => Promise<T | null>,
|
|
label: string,
|
|
maxRetries: number = 3
|
|
): Promise<T> {
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
const result = await getter();
|
|
if (result) {
|
|
return result;
|
|
}
|
|
} catch (error) {
|
|
logger.warn(
|
|
`Failed to get ${label}, retrying... (${attempt}/${maxRetries})`
|
|
);
|
|
}
|
|
}
|
|
throw new Error(`Failed to get ${label} after ${maxRetries} attempts`);
|
|
}
|
|
// stream utility functions
|
|
private async assignPublicIps() {
|
|
let userIp = this.userData.ip;
|
|
let proxyIp = undefined;
|
|
if (this.userData.proxy?.enabled) {
|
|
proxyIp = await this.getProxyIp();
|
|
}
|
|
for (const addon of this.addons) {
|
|
const proxy =
|
|
this.userData.proxy?.enabled &&
|
|
(!this.userData.proxy?.proxiedAddons?.length ||
|
|
this.userData.proxy.proxiedAddons.includes(addon.preset.id));
|
|
logger.debug(
|
|
`Using ${proxy ? 'proxy' : 'user'} ip for ${getAddonName(addon)}: ${
|
|
proxy
|
|
? maskSensitiveInfo(proxyIp ?? 'none')
|
|
: maskSensitiveInfo(userIp ?? 'none')
|
|
}`
|
|
);
|
|
if (proxy) {
|
|
addon.ip = proxyIp;
|
|
} else {
|
|
addon.ip = userIp;
|
|
}
|
|
}
|
|
}
|
|
|
|
private validateAddon(addon: Addon) {
|
|
const manifestUrl = new URL(addon.manifestUrl);
|
|
const baseUrl = Env.BASE_URL ? new URL(Env.BASE_URL) : undefined;
|
|
if (this.userData.uuid && addon.manifestUrl.includes(this.userData.uuid)) {
|
|
logger.warn(
|
|
`${this.userData.uuid} detected to be trying to cause infinite self scraping`
|
|
);
|
|
throw new Error(
|
|
`${getAddonName(addon)} would cause infinite self scraping, ensure you wrap a different AIOStreams user.`
|
|
);
|
|
} else if (
|
|
((baseUrl && manifestUrl.host === baseUrl.host) ||
|
|
(manifestUrl.host.startsWith('localhost') &&
|
|
manifestUrl.port === Env.PORT.toString())) &&
|
|
!manifestUrl.pathname.startsWith('/builtins') &&
|
|
Env.DISABLE_SELF_SCRAPING === true
|
|
) {
|
|
throw new Error(
|
|
`Scraping the same AIOStreams instance is disabled. Please use a different AIOStreams instance, or enable it through the environment variables.`
|
|
);
|
|
}
|
|
if (
|
|
addon.preset.type &&
|
|
FeatureControl.disabledAddons.has(addon.preset.type)
|
|
) {
|
|
throw new Error(
|
|
`Addon ${getAddonName(addon)} is disabled: ${FeatureControl.disabledAddons.get(
|
|
addon.preset.type
|
|
)}`
|
|
);
|
|
} else if (
|
|
FeatureControl.disabledHosts.has(manifestUrl.host.split(':')[0])
|
|
) {
|
|
throw new Error(
|
|
`Addon ${getAddonName(addon)} is disabled: ${FeatureControl.disabledHosts.get(
|
|
manifestUrl.host.split(':')[0]
|
|
)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
private applyModifications(streams: ParsedStream[]): ParsedStream[] {
|
|
if (this.userData.randomiseResults) {
|
|
streams.sort(() => Math.random() - 0.5);
|
|
}
|
|
if (this.userData.enhanceResults) {
|
|
streams.forEach((stream) => {
|
|
if (Math.random() < 0.4) {
|
|
stream.filename = undefined;
|
|
stream.parsedFile = undefined;
|
|
stream.type = 'youtube';
|
|
stream.ytId = Buffer.from(constants.DEFAULT_YT_ID, 'base64').toString(
|
|
'utf-8'
|
|
);
|
|
stream.message =
|
|
'This stream has been artificially enhanced using the best AI on the market.';
|
|
}
|
|
});
|
|
}
|
|
return streams;
|
|
}
|
|
|
|
private convertDiscoverDeepLinks(items: Meta['links']) {
|
|
if (!items) {
|
|
return items;
|
|
}
|
|
return items.map((link) => {
|
|
try {
|
|
if (link.url.startsWith('stremio:///discover/')) {
|
|
const linkUrl = new URL(decodeURIComponent(link.url.split('/')[4]));
|
|
// see if the linked addon is one of our addons and replace the transport url with our manifest url if so
|
|
const addon = this.addons.find(
|
|
(a) => new URL(a.manifestUrl).hostname === linkUrl.hostname
|
|
);
|
|
if (addon) {
|
|
const [_, linkType, catalogIdAndQuery] = link.url
|
|
.replace('stremio:///discover/', '')
|
|
.split('/');
|
|
const newCatalogId = `${addon.instanceId}.${catalogIdAndQuery}`;
|
|
const newTransportUrl = encodeURIComponent(this.manifestUrl);
|
|
link.url = `stremio:///discover/${newTransportUrl}/${linkType}/${newCatalogId}`;
|
|
}
|
|
}
|
|
} catch {}
|
|
return link;
|
|
});
|
|
}
|
|
|
|
private async getMetadata(
|
|
id: string
|
|
): Promise<TMDBMetadataResponse | undefined> {
|
|
try {
|
|
const metadata = await new TMDBMetadata({
|
|
accessToken: this.userData.tmdbAccessToken,
|
|
apiKey: this.userData.tmdbApiKey,
|
|
}).getMetadata(id, 'series');
|
|
return metadata;
|
|
} catch (error) {
|
|
logger.warn(
|
|
`Error getting metadata for ${id}, will not be able to precache next season if necessary`,
|
|
{
|
|
error: error instanceof Error ? error.message : String(error),
|
|
}
|
|
);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private _getNextEpisode(
|
|
currentSeason: number,
|
|
currentEpisode: number,
|
|
metadata?: TMDBMetadataResponse
|
|
): {
|
|
season: number;
|
|
episode: number;
|
|
} {
|
|
let season = currentSeason;
|
|
let episode = currentEpisode + 1;
|
|
const episodeCount = metadata?.seasons?.find(
|
|
(s) => s.season_number === Number(season)
|
|
)?.episode_count;
|
|
|
|
// If we are at the last episode of the season, try to move to the next season
|
|
if (episodeCount && currentEpisode === episodeCount) {
|
|
const nextSeasonNumber = currentSeason + 1;
|
|
if (
|
|
metadata?.seasons?.find((s) => s.season_number === nextSeasonNumber)
|
|
) {
|
|
logger.debug(
|
|
`Current episode is the last of season ${currentSeason}, moving to S${nextSeasonNumber}E01.`
|
|
);
|
|
season = nextSeasonNumber;
|
|
episode = 1;
|
|
}
|
|
}
|
|
return { season, episode };
|
|
}
|
|
|
|
private async _processStreams(
|
|
streams: ParsedStream[],
|
|
type: string,
|
|
id: string,
|
|
isMeta: boolean = false
|
|
): Promise<ParsedStream[]> {
|
|
let processedStreams = streams;
|
|
|
|
if (isMeta) {
|
|
processedStreams = await this.filterer.filter(processedStreams, type, id);
|
|
}
|
|
|
|
processedStreams = await this.deduplicator.deduplicate(processedStreams);
|
|
|
|
if (isMeta) {
|
|
await this.precomputer.precompute(processedStreams);
|
|
}
|
|
|
|
let finalStreams = this.applyModifications(
|
|
await this.proxifier.proxify(
|
|
await this.filterer.applyStreamExpressionFilters(
|
|
await this.limiter.limit(
|
|
await this.sorter.sort(
|
|
processedStreams,
|
|
id.startsWith('kitsu') ? 'anime' : type
|
|
)
|
|
)
|
|
)
|
|
)
|
|
).map((stream) => {
|
|
if (stream.parsedFile) {
|
|
stream.parsedFile.visualTags = stream.parsedFile.visualTags.filter(
|
|
(tag) => !constants.FAKE_VISUAL_TAGS.includes(tag as any)
|
|
);
|
|
}
|
|
return stream;
|
|
});
|
|
|
|
if (this.userData.externalDownloads) {
|
|
const streamsWithExternalDownloads: ParsedStream[] = [];
|
|
for (const stream of finalStreams) {
|
|
streamsWithExternalDownloads.push(stream);
|
|
if (stream.url) {
|
|
const downloadableStream: ParsedStream =
|
|
StreamUtils.createDownloadableStream(stream);
|
|
streamsWithExternalDownloads.push(downloadableStream);
|
|
}
|
|
}
|
|
logger.info(
|
|
`Added ${streamsWithExternalDownloads.length - finalStreams.length} external downloads to streams`
|
|
);
|
|
finalStreams = streamsWithExternalDownloads;
|
|
}
|
|
|
|
return finalStreams;
|
|
}
|
|
|
|
private async _fetchAndHandleRedirects(stream: ParsedStream, id: string) {
|
|
const wrapper = new Wrapper(stream.addon);
|
|
if (!stream.url) {
|
|
throw new Error(`Stream URL is undefined`);
|
|
}
|
|
const initialResponse = await wrapper.makeRequest(stream.url, {
|
|
timeout: 30000,
|
|
rawOptions: { redirect: 'manual' },
|
|
});
|
|
|
|
// If it's a redirect, handle it
|
|
if (initialResponse.status >= 300 && initialResponse.status < 400) {
|
|
const redirectUrl = initialResponse.headers.get('Location');
|
|
if (!redirectUrl) {
|
|
throw new Error(
|
|
`Redirect response (${initialResponse.status}) has no Location header.`
|
|
);
|
|
}
|
|
|
|
const absoluteRedirectUrl = new URL(redirectUrl, stream.url).toString();
|
|
const originalHost = new URL(stream.url).host;
|
|
const redirectHost = new URL(absoluteRedirectUrl).host;
|
|
|
|
if (redirectHost !== originalHost) {
|
|
throw new Error(
|
|
`Host mismatch during redirect: original (${originalHost}) vs redirect (${redirectHost}). Not following.`
|
|
);
|
|
}
|
|
|
|
logger.debug(
|
|
`Following same-domain redirect to ${makeUrlLogSafe(absoluteRedirectUrl)} for precaching ${id}`
|
|
);
|
|
return wrapper.makeRequest(absoluteRedirectUrl, { timeout: 30000 });
|
|
}
|
|
|
|
return initialResponse;
|
|
}
|
|
|
|
private async precacheNextEpisode(type: string, id: string) {
|
|
const seasonEpisodeRegex = /:(\d+):(\d+)$/;
|
|
const match = id.match(seasonEpisodeRegex);
|
|
if (!match) {
|
|
return;
|
|
}
|
|
const titleId = id.replace(seasonEpisodeRegex, '');
|
|
const currentSeason = Number(match[1]);
|
|
const currentEpisode = Number(match[2]);
|
|
|
|
const metadata = await this.getMetadata(id);
|
|
|
|
const { season: seasonToPrecache, episode: episodeToPrecache } =
|
|
this._getNextEpisode(currentSeason, currentEpisode, metadata);
|
|
|
|
const precacheId = `${titleId}:${seasonToPrecache}:${episodeToPrecache}`;
|
|
logger.info(`Pre-caching next episode of ${titleId}`, {
|
|
currentSeason,
|
|
currentEpisode,
|
|
episodeToPrecache,
|
|
seasonToPrecache,
|
|
precacheId,
|
|
});
|
|
|
|
// modify userData to remove the excludeUncached filter
|
|
const userData = structuredClone(this.userData);
|
|
userData.excludeUncached = false;
|
|
userData.groups = undefined;
|
|
this.setUserData(userData);
|
|
|
|
const nextStreamsResponse = await this.getStreams(precacheId, type, true);
|
|
if (!nextStreamsResponse.success) {
|
|
logger.error(`Failed to get streams during precaching ${id}`, {
|
|
error: nextStreamsResponse.errors,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const serviceStreams = nextStreamsResponse.data.streams.filter(
|
|
(stream) => stream.service
|
|
);
|
|
const shouldPrecache =
|
|
serviceStreams.every((stream) => stream.service?.cached === false) ||
|
|
this.userData.alwaysPrecache;
|
|
|
|
if (!shouldPrecache) {
|
|
logger.debug(
|
|
`Skipping precaching ${id} as all streams are cached or Always Precache is disabled`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const firstUncachedStream = serviceStreams.find(
|
|
(stream) => stream.service?.cached === false
|
|
);
|
|
if (!firstUncachedStream || !firstUncachedStream.url) {
|
|
logger.debug(
|
|
`Skipping precaching ${id} as no uncached streams were found or it had no URL`
|
|
);
|
|
return;
|
|
}
|
|
|
|
logger.debug(
|
|
`Selected following stream for precaching:\n${firstUncachedStream.originalName}\n${firstUncachedStream.originalDescription}`
|
|
);
|
|
|
|
try {
|
|
const response = await this._fetchAndHandleRedirects(
|
|
firstUncachedStream,
|
|
precacheId
|
|
);
|
|
logger.debug(`Response: ${response.status} ${response.statusText}`);
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Final Response not OK: ${response.status} ${response.statusText}`
|
|
);
|
|
}
|
|
const cacheKey = `precache-${type}-${id}-${this.userData.uuid}`;
|
|
await precacheCache.set(
|
|
cacheKey,
|
|
true,
|
|
Env.PRECACHE_NEXT_EPISODE_MIN_INTERVAL
|
|
);
|
|
logger.info(`Successfully precached a stream for ${id} (${type})`);
|
|
} catch (error) {
|
|
logger.error(`Error pinging url of first uncached stream`, {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
}
|