mirror of
https://github.com/Viren070/AIOStreams.git
synced 2025-12-01 23:14:04 +01:00
386 lines
11 KiB
TypeScript
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:///',
|
|
};
|
|
}
|
|
}
|