Files
AIOStreams/packages/core/src/builtins/base/debrid.ts
T
2025-09-13 17:32:20 +01:00

386 lines
11 KiB
TypeScript

import { Manifest, Meta, Stream } from '../../db/schemas';
import { z, ZodError } from 'zod';
import { IdParser, IdType, ParsedId } from '../../utils/id-parser';
import {
AnimeDatabase,
constants,
Env,
formatZodError,
getTimeTakenSincePoint,
SERVICE_DETAILS,
} from '../../utils';
import { TorrentClient } from '../../utils/torrent';
import {
BuiltinDebridServices,
PlaybackInfo,
Torrent,
NZB,
TorrentWithSelectedFile,
NZBWithSelectedFile,
UnprocessedTorrent,
ServiceAuth,
} from '../../debrid';
import { processTorrents, processNZBs } from '../utils/debrid';
import { calculateAbsoluteEpisode } from '../utils/general';
import { TitleMetadata } from '../torbox-search/source-handlers';
import { MetadataService } from '../../metadata/service';
import { Logger } from 'winston';
export interface SearchMetadata extends TitleMetadata {
primaryTitle?: string;
year?: number;
imdbId?: string | null;
tmdbId?: string | null;
tvdbId?: string | null;
}
export const BaseDebridConfigSchema = z.object({
services: BuiltinDebridServices,
tmdbApiKey: z.string().optional(),
tmdbReadAccessToken: z.string().optional(),
});
export type BaseDebridConfig = z.infer<typeof BaseDebridConfigSchema>;
export abstract class BaseDebridAddon<T extends BaseDebridConfig> {
abstract readonly id: string;
abstract readonly name: string;
abstract readonly version: string;
get addonId(): string {
return `com.${this.name.toLowerCase().replace(/\s/g, '')}.viren070`;
}
abstract readonly logger: Logger;
protected readonly userData: T;
protected readonly clientIp?: string;
private static readonly supportedIdTypes: IdType[] = [
'imdbId',
'kitsuId',
'malId',
'themoviedbId',
'thetvdbId',
];
constructor(userData: T, configSchema: z.ZodType<T>, clientIp?: string) {
try {
this.userData = configSchema.parse(userData);
} catch (error) {
throw new Error(
`Invalid user data: ${formatZodError(error as ZodError)}`
);
}
this.clientIp = clientIp;
}
public getManifest(): Manifest {
return {
id: this.addonId,
name: this.name,
version: this.version,
types: ['movie', 'series', 'anime'],
catalogs: [],
description: `${this.name} addon`,
resources: [
{
name: 'stream',
types: ['movie', 'series', 'anime'],
idPrefixes: IdParser.getPrefixes(BaseDebridAddon.supportedIdTypes),
},
],
};
}
public async getStreams(type: string, id: string): Promise<Stream[]> {
const parsedId = IdParser.parse(id, type);
if (
!parsedId ||
!BaseDebridAddon.supportedIdTypes.includes(parsedId.type)
) {
throw new Error(`Unsupported ID: ${id}`);
}
this.logger.info(`Handling stream request for ${this.name}`, {
requestType: type,
requestId: id,
});
const searchMetadata = await this._getSearchMetadata(parsedId, type);
const searchPromises = await Promise.allSettled([
this._searchTorrents(parsedId, searchMetadata),
this._searchNzbs(parsedId, searchMetadata),
]);
let torrentResults =
searchPromises[0].status === 'fulfilled' ? searchPromises[0].value : [];
const nzbResults =
searchPromises[1].status === 'fulfilled' ? searchPromises[1].value : [];
const searchErrors: Stream[] = [];
if (searchPromises[0].status === 'rejected') {
searchErrors.push(
this._createErrorStream({
title: `${this.name}`,
description: searchPromises[0].reason.message,
})
);
}
if (searchPromises[1].status === 'rejected') {
searchErrors.push(
this._createErrorStream({
title: `${this.name}`,
description: searchPromises[1].reason.message,
})
);
}
if (torrentResults.some((t) => !t.hash && t.downloadUrl)) {
// Process torrents in batches of 5 to avoid overwhelming the system
const BATCH_SIZE = 15;
const enrichedResults: Torrent[] = [];
const torrentsToProcess = [...torrentResults];
while (torrentsToProcess.length > 0) {
const batch = torrentsToProcess.splice(0, BATCH_SIZE);
const metadataPromises = batch.map(async (torrent) => {
try {
const metadata = await TorrentClient.getMetadata(torrent);
if (!metadata) {
return torrent.hash ? (torrent as Torrent) : null;
}
return {
...torrent,
hash: metadata.hash,
sources: metadata.sources,
files: metadata.files,
} as Torrent;
} catch (error) {
this.logger.error(`Failed to fetch metadata for torrent: ${error}`);
return torrent.hash ? (torrent as Torrent) : null;
}
});
const batchResults = await Promise.all(metadataPromises);
enrichedResults.push(
...batchResults.filter((r): r is Torrent => r !== null)
);
}
torrentResults = enrichedResults;
}
const [processedTorrents, processedNzbs] = await Promise.all([
processTorrents(
torrentResults as Torrent[],
this.userData.services,
id,
searchMetadata,
this.clientIp
),
processNZBs(
nzbResults,
this.userData.services,
id,
searchMetadata,
this.clientIp
),
]);
const resultStreams = [
...processedTorrents.results,
...processedNzbs.results,
].map((result) =>
this._createStream(result, this.userData, searchMetadata)
);
const processingErrors = [
...processedTorrents.errors,
...processedNzbs.errors,
].map((error) =>
this._createErrorStream({
title: `${this.name} ${constants.SERVICE_DETAILS[error.serviceId].shortName}`,
description: error.error.message,
})
);
return [...resultStreams, ...searchErrors, ...processingErrors];
}
protected abstract _searchTorrents(
parsedId: ParsedId,
metadata: SearchMetadata
): Promise<UnprocessedTorrent[]>;
protected abstract _searchNzbs(
parsedId: ParsedId,
metadata: SearchMetadata
): Promise<NZB[]>;
protected async _getSearchMetadata(
parsedId: ParsedId,
type: string
): Promise<SearchMetadata> {
const start = Date.now();
const animeEntry = AnimeDatabase.getInstance().getEntryById(
parsedId.type,
parsedId.value
);
// Update season from anime entry if available
if (animeEntry && !parsedId.season) {
parsedId.season =
animeEntry.imdb?.fromImdbSeason?.toString() ??
animeEntry.trakt?.season?.toString();
}
const metadata = await new MetadataService({
tmdbAccessToken: this.userData.tmdbReadAccessToken,
tmdbApiKey: this.userData.tmdbApiKey,
}).getMetadata(parsedId, type === 'movie' ? 'movie' : 'series');
// Calculate absolute episode if needed
let absoluteEpisode: number | undefined;
if (animeEntry && parsedId.season && parsedId.episode && metadata.seasons) {
const seasons = metadata.seasons.map(
({ season_number, episode_count }) => ({
number: season_number.toString(),
episodes: episode_count,
})
);
this.logger.debug(
`Calculating absolute episode with current season and episode: ${parsedId.season}, ${parsedId.episode} and seasons: ${JSON.stringify(seasons)}`
);
absoluteEpisode = Number(
calculateAbsoluteEpisode(parsedId.season, parsedId.episode, seasons)
);
}
// Map IDs
const imdbId =
parsedId.type === 'imdbId'
? parsedId.value.toString()
: (animeEntry?.mappings?.imdbId?.toString() ?? null);
const tmdbId =
parsedId.type === 'themoviedbId'
? parsedId.value.toString()
: (animeEntry?.mappings?.themoviedbId?.toString() ?? null);
const tvdbId =
parsedId.type === 'thetvdbId'
? parsedId.value.toString()
: (animeEntry?.mappings?.thetvdbId?.toString() ?? null);
const searchMetadata: SearchMetadata = {
primaryTitle: metadata.title,
titles: metadata.titles ?? [],
season: parsedId.season ? Number(parsedId.season) : undefined,
episode: parsedId.episode ? Number(parsedId.episode) : undefined,
absoluteEpisode,
year: metadata.year,
imdbId,
tmdbId,
tvdbId,
};
this.logger.debug(
`Got search metadata for ${parsedId.type}:${parsedId.value} in ${getTimeTakenSincePoint(start)}`,
{
...searchMetadata,
titles: searchMetadata.titles.length,
}
);
return searchMetadata;
}
protected _createStream(
torrentOrNzb: TorrentWithSelectedFile | NZBWithSelectedFile,
userData: T,
titleMetadata?: TitleMetadata
): Stream {
// Handle debrid streaming
const storeAuth: ServiceAuth | undefined = torrentOrNzb.service
? {
id: torrentOrNzb.service!.id,
credential:
userData.services.find(
(service) => service.id === torrentOrNzb.service!.id
)?.credential ?? '',
}
: undefined;
const playbackInfo: PlaybackInfo | undefined = torrentOrNzb.service
? torrentOrNzb.type === 'torrent'
? {
type: 'torrent',
hash: torrentOrNzb.hash,
sources: torrentOrNzb.sources,
title: torrentOrNzb.title,
file: torrentOrNzb.file,
metadata: titleMetadata,
}
: {
type: 'usenet',
nzb: torrentOrNzb.nzb,
title: torrentOrNzb.title,
hash: torrentOrNzb.hash,
file: torrentOrNzb.file,
metadata: titleMetadata,
}
: undefined;
const svcMeta = torrentOrNzb.service
? SERVICE_DETAILS[torrentOrNzb.service.id]
: undefined;
// const svcMeta = SERVICE_DETAILS[torrentOrNzb.service.id];
const shortCode = svcMeta?.shortName || 'P2P';
const cacheIndicator = torrentOrNzb.service
? torrentOrNzb.service.cached
? '⚡'
: '⏳'
: '';
const name = `[${shortCode} ${cacheIndicator}${torrentOrNzb.service?.owned ? ' ☁️' : ''}] ${this.name}`;
const description = `${torrentOrNzb.title}\n${torrentOrNzb.file.name}\n${
torrentOrNzb.indexer ? `🔍 ${torrentOrNzb.indexer}` : ''
} ${'seeders' in torrentOrNzb && torrentOrNzb.seeders ? `👤 ${torrentOrNzb.seeders}` : ''} ${
torrentOrNzb.age && torrentOrNzb.age !== '0d'
? `🕒 ${torrentOrNzb.age}`
: ''
}`;
return {
url: torrentOrNzb.service
? `${Env.BASE_URL}/api/v1/debrid/playback/${encodeURIComponent(
Buffer.from(JSON.stringify(storeAuth)).toString('base64')
)}/${encodeURIComponent(
Buffer.from(JSON.stringify(playbackInfo)).toString('base64')
)}/${encodeURIComponent(torrentOrNzb.file.name || torrentOrNzb.title || 'unknown')}`
: undefined,
name,
description,
type: torrentOrNzb.type,
infoHash: torrentOrNzb.hash,
fileIdx: torrentOrNzb.file.index,
behaviorHints: {
videoSize: torrentOrNzb.file.size,
filename: torrentOrNzb.file.name,
},
};
}
protected _createErrorStream({
title,
description,
}: {
title: string;
description: string;
}): Stream {
return {
name: `[❌] ${title}`,
description: description,
externalUrl: 'stremio:///',
};
}
}