diff --git a/packages/core/src/debrid/index.ts b/packages/core/src/debrid/index.ts index 55735ee74..acbe6a1fd 100644 --- a/packages/core/src/debrid/index.ts +++ b/packages/core/src/debrid/index.ts @@ -4,6 +4,7 @@ export * from './stremthru.js'; export * from './torbox.js'; export * from './nzbdav.js'; export * from './altmount.js'; +export * from './torrserver.js'; import { ServiceId } from '../utils/index.js'; import { DebridService, DebridServiceConfig } from './base.js'; @@ -14,6 +15,7 @@ import { NzbDAVService } from './nzbdav.js'; import { AltmountService } from './altmount.js'; import { StremioNNTPService } from './stremio-nntp.js'; import { EasynewsService } from './easynews.js'; +import { TorrServerDebridService } from './torrserver.js'; export function getDebridService( serviceName: ServiceId, @@ -36,6 +38,8 @@ export function getDebridService( return new StremioNNTPService(config); case 'easynews': return new EasynewsService(config); + case 'torrserver': + return new TorrServerDebridService(config); default: if (StremThruPreset.supportedServices.includes(serviceName)) { return new StremThruInterface({ ...config, serviceName }); diff --git a/packages/core/src/debrid/torrserver.ts b/packages/core/src/debrid/torrserver.ts new file mode 100644 index 000000000..bb6b6d9f2 --- /dev/null +++ b/packages/core/src/debrid/torrserver.ts @@ -0,0 +1,421 @@ +import { z } from 'zod'; +import { + Env, + ServiceId, + createLogger, + getSimpleTextHash, + Cache, + DistributedLock, +} from '../utils/index.js'; +import { selectFileInTorrentOrNZB, Torrent } from './utils.js'; +import { + DebridService, + DebridServiceConfig, + DebridDownload, + PlaybackInfo, + DebridError, +} from './base.js'; +import { parseTorrentTitle } from '@viren070/parse-torrent-title'; +// import { fetch } from 'undici'; // Use global fetch if available in Node 18+, otherwise keep this + +const logger = createLogger('debrid:torrserver'); + +// Constants for TorrServer operations +const TORRSERVER_ADD_DELAY_MS = 1000; +const TORRSERVER_MAX_POLL_ATTEMPTS = 15; +const TORRSERVER_POLL_INTERVAL_MS = 1000; // Poll faster for responsiveness +const TORRSERVER_RESOLVE_LOCK_TIMEOUT_MS = 30000; // Timeout and TTL for resolve lock + +export const TorrServerConfig = z.object({ + torrserverUrl: z + .string() + .url() + .transform((s) => s.trim().replace(/\/+$/, '')), + torrserverAuth: z.string().optional(), +}); + +interface TorrServerTorrent { + hash: string; + title?: string; + size?: number; + stat?: number; // 0 - stopped, 1 - downloading, 2 - seeding + file_stats?: Array<{ + id: number; + path: string; + length: number; + }>; +} + +interface TorrServerListResponse { + torrents: TorrServerTorrent[]; +} + +interface TorrServerAddResponse { + hash: string; +} + +export class TorrServerDebridService implements DebridService { + private readonly torrserverUrl: string; + private readonly torrserverAuth?: string; + private static playbackLinkCache = Cache.getInstance( + 'ts:link' + ); + private static checkCache = Cache.getInstance( + 'ts:instant-check' + ); + + readonly supportsUsenet = false; + readonly serviceName: ServiceId = 'torrserver' as ServiceId; + + constructor(private readonly config: DebridServiceConfig) { + let tokenData: any; + try { + tokenData = JSON.parse(config.token); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + throw new Error( + `Invalid TorrServer token JSON: ${errorMessage}` + ); + } + + const parsedConfig = TorrServerConfig.parse(tokenData); + + this.torrserverUrl = parsedConfig.torrserverUrl; + this.torrserverAuth = parsedConfig.torrserverAuth; + } + + private addApiKeyToUrl(url: URL): void { + if (this.torrserverAuth && !this.torrserverAuth.includes(':')) { + const trimmedKey = this.torrserverAuth.trim(); + if (trimmedKey !== '') { + url.searchParams.set('apikey', trimmedKey); + } + } + } + + private addAuthToStreamUrl(url: URL): void { + if (!this.torrserverAuth) return; + + const trimmedAuth = this.torrserverAuth.trim(); + if (trimmedAuth === '') return; + + if (trimmedAuth.includes(':')) { + // Basic auth credentials (username:password) - add to URL + // Handle passwords that may contain colons by only splitting on the first colon + const colonIndex = trimmedAuth.indexOf(':'); + const username = trimmedAuth.substring(0, colonIndex); + const password = trimmedAuth.substring(colonIndex + 1); + url.username = username; + url.password = password; + } else { + // API key - add as query parameter + url.searchParams.set('apikey', trimmedAuth); + } + } + + private async torrserverRequest( + endpoint: string, + options?: { + method?: string; + body?: any; + } + ): Promise { + const url = `${this.torrserverUrl}${endpoint}`; + const method = options?.method || 'GET'; + + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + // Add Auth headers for API control + if (this.torrserverAuth && this.torrserverAuth.includes(':')) { + // Only set Basic auth header for username:password format + headers['Authorization'] = + `Basic ${Buffer.from(this.torrserverAuth).toString('base64')}`; + } + + // Append API Key to URL if it's not Basic Auth style + const fetchUrl = new URL(url); + this.addApiKeyToUrl(fetchUrl); + + const response = await fetch(fetchUrl.toString(), { + method, + body: options?.body ? JSON.stringify(options.body) : undefined, + headers, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return (await response.json()) as T; + } catch (error: any) { + throw new DebridError('TorrServer request failed', { + statusCode: error?.statusCode || 500, + statusText: error?.message || 'Unknown error', + code: 'INTERNAL_SERVER_ERROR', + headers: {}, + body: error, + cause: error, + }); + } + } + + public async listMagnets(): Promise { + try { + // POST usually works better for /torrents/list in some versions, but GET /torrents is standard + const response = await this.torrserverRequest('/torrents'); + + // Handle response structure which might vary slightly + const torrents = Array.isArray(response) + ? response + : response.torrents || []; + + return ( + torrents.map((torrent: TorrServerTorrent) => ({ + id: torrent.hash, + hash: torrent.hash, + name: torrent.title, + size: torrent.size, + status: this.mapTorrServerStatus(torrent.stat), + files: torrent.file_stats?.map((file) => ({ + index: file.id, + name: file.path, + size: file.length, + })), + })) || [] + ); + } catch (error) { + logger.error('Failed to list torrents from TorrServer:', error); + return []; + } + } + + private mapTorrServerStatus(stat?: number): DebridDownload['status'] { + switch (stat) { + case 0: // Loaded/Paused + case 1: // Downloading + case 2: // Seeding/Up + // IMPORTANT: We treat downloading (1) as 'cached' because TorrServer allows streaming while downloading. + // If we return 'downloading', AIOStreams might wait for 100% completion. + return 'cached'; + default: + return 'unknown'; + } + } + + public async checkMagnets( + magnets: string[], + sid?: string + ): Promise { + // TorrServer streams "instantly", so we can assume availability for valid magnets + // Real logic would be checking if we have bandwidth, but here we just pass them through. + const results: DebridDownload[] = []; + + for (const magnet of magnets) { + const hash = this.extractHashFromMagnet(magnet); + if (!hash) continue; + + results.push({ + id: hash, + hash, + status: 'cached', // Assume cached to trigger "instant play" logic + files: [], + }); + } + return results; + } + + private extractHashFromMagnet(magnet: string): string | null { + const match = magnet.match(/btih:([a-fA-F0-9]{40})/i); + return match ? match[1].toLowerCase() : null; + } + + public async addMagnet(magnet: string): Promise { + try { + const hash = this.extractHashFromMagnet(magnet); + if (!hash) { + throw new DebridError('Invalid magnet link', { + statusCode: 400, + statusText: 'Invalid magnet link', + code: 'BAD_REQUEST', + headers: {}, + }); + } + + // Add torrent to TorrServer + await this.torrserverRequest('/torrents/add', { + method: 'POST', + body: { + link: magnet, + title: hash, + save: true, // Auto save to history + }, + }); + + await new Promise((resolve) => + setTimeout(resolve, TORRSERVER_ADD_DELAY_MS) + ); + + // Get torrent info + const torrents = await this.listMagnets(); + const torrent = torrents.find((t) => t.hash === hash); + + // Even if not found immediately (rare race condition), return a dummy valid object + if (!torrent) { + return { + id: hash, + hash: hash, + status: 'cached', + files: [], + }; + } + + return torrent; + } catch (error) { + if (error instanceof DebridError) { + throw error; + } + throw new DebridError('Failed to add magnet to TorrServer', { + statusCode: 500, + statusText: error instanceof Error ? error.message : 'Unknown error', + code: 'INTERNAL_SERVER_ERROR', + headers: {}, + cause: error, + }); + } + } + + public async generateTorrentLink( + link: string, + clientIp?: string + ): Promise { + return link; + } + + public async resolve( + playbackInfo: PlaybackInfo, + filename: string, + cacheAndPlay: boolean + ): Promise { + const { result } = await DistributedLock.getInstance().withLock( + `torrserver:resolve:${playbackInfo.hash}:${this.config.clientIp}`, + () => this._resolve(playbackInfo, filename, cacheAndPlay), + { + timeout: TORRSERVER_RESOLVE_LOCK_TIMEOUT_MS, + ttl: TORRSERVER_RESOLVE_LOCK_TIMEOUT_MS, + } + ); + return result; + } + + private async _resolve( + playbackInfo: PlaybackInfo, + filename: string, + cacheAndPlay: boolean + ): Promise { + if (playbackInfo.type === 'usenet') return undefined; + + const { hash, metadata } = playbackInfo; + const cacheKey = `torrserver:resolve:${hash}:${filename}`; + + // Check Cache first + const cachedLink = + await TorrServerDebridService.playbackLinkCache.get(cacheKey); + if (cachedLink) return cachedLink; + + let magnet = `magnet:?xt=urn:btih:${hash}`; + if (playbackInfo.sources.length > 0) { + magnet += `&tr=${playbackInfo.sources.map(encodeURIComponent).join('&tr=')}`; + } + + // Add to TorrServer + let magnetDownload = await this.addMagnet(magnet); + + // Poll until files are populated + for (let i = 0; i < TORRSERVER_MAX_POLL_ATTEMPTS; i++) { + if (magnetDownload.files && magnetDownload.files.length > 0) break; + + await new Promise((resolve) => + setTimeout(resolve, TORRSERVER_POLL_INTERVAL_MS) + ); + const list = await this.listMagnets(); + const found = list.find((t) => t.hash === hash); + if (found) magnetDownload = found; + } + + if (!magnetDownload.files?.length) { + // Fallback: If we can't get file list, we can't select file index. + // However, we can try to return a link without index and let TorrServer guess/play first file + logger.warn(`No files found for ${hash}, trying blind stream`); + } + + // Select file logic + const parsedFiles = new Map(); + if (magnetDownload.files) { + for (const file of magnetDownload.files) { + if (!file.name) continue; + try { + const parsed = parseTorrentTitle(file.name); + parsedFiles.set(file.name, { + title: parsed?.title, + seasons: parsed?.seasons, + episodes: parsed?.episodes, + year: parsed?.year, + }); + } catch (err) { + logger.debug( + `Failed to parse torrent title for file: ${file.name}`, + err + ); + // Continue processing other files; treat this file as unparsed + continue; + } + } + } + + const selectedFile = await selectFileInTorrentOrNZB( + { + type: 'torrent', + hash, + title: magnetDownload.name || filename, + size: magnetDownload.size || 0, + seeders: 1, + sources: [], + }, + magnetDownload, + parsedFiles, + metadata, + { + chosenFilename: playbackInfo.filename, + chosenIndex: playbackInfo.index, + } + ); + + // Build Stream URL + const streamUrlObj = new URL('/stream', this.torrserverUrl); + streamUrlObj.searchParams.set('link', hash); // Use hash instead of full magnet + streamUrlObj.searchParams.set('play', '1'); // Force play + streamUrlObj.searchParams.set('save', 'true'); // Save to DB + + if (selectedFile) { + streamUrlObj.searchParams.set('index', String(selectedFile.index)); + } else { + streamUrlObj.searchParams.set('index', '0'); // Default to 0 for 0-based indexing + } + + // AUTH HANDLING FOR STREAM LINK - supports both API keys and Basic auth + this.addAuthToStreamUrl(streamUrlObj); + + const streamUrl = streamUrlObj.toString(); + + await TorrServerDebridService.playbackLinkCache.set( + cacheKey, + streamUrl, + Env.BUILTIN_DEBRID_PLAYBACK_LINK_CACHE_TTL + ); + + return streamUrl; + } +} diff --git a/packages/core/src/main.ts b/packages/core/src/main.ts index 98a2f5bd2..d4cc3e4e2 100644 --- a/packages/core/src/main.ts +++ b/packages/core/src/main.ts @@ -42,6 +42,7 @@ import { StreamDeduplicator as Deduplicator, StreamPrecomputer as Precomputer, StreamUtils, + TorrServerConverter, } from './streams/index.js'; import { getAddonName } from './utils/general.js'; import { TMDBMetadata } from './metadata/tmdb.js'; @@ -90,6 +91,7 @@ export class AIOStreams { private deduplicator: Deduplicator; private sorter: Sorter; private precomputer: Precomputer; + private torrServerConverter: TorrServerConverter; private addonInitialisationErrors: { addon: Addon | Preset; @@ -110,6 +112,7 @@ export class AIOStreams { this.fetcher = new Fetcher(userData, this.filterer, this.precomputer); this.deduplicator = new Deduplicator(userData); this.sorter = new Sorter(userData); + this.torrServerConverter = new TorrServerConverter(userData); } private setUserData(userData: UserData) { @@ -1376,6 +1379,9 @@ export class AIOStreams { } processedStreams = await this.deduplicator.deduplicate(processedStreams); + + // Convert P2P streams to TorrServer URLs if TorrServer is configured + processedStreams = await this.torrServerConverter.convert(processedStreams); if (isMeta) { await this.precomputer.precompute(processedStreams, type, id); diff --git a/packages/core/src/streams/index.ts b/packages/core/src/streams/index.ts index 61db8c92f..c12c9ea35 100644 --- a/packages/core/src/streams/index.ts +++ b/packages/core/src/streams/index.ts @@ -4,6 +4,7 @@ import StreamSorter from './sorter.js'; import StreamDeduplicator from './deduplicator.js'; import StreamPrecomputer from './precomputer.js'; import StreamUtils from './utils.js'; +import TorrServerConverter from './torrserver-converter.js'; export { StreamFetcher, @@ -12,4 +13,5 @@ export { StreamDeduplicator, StreamPrecomputer, StreamUtils, + TorrServerConverter, }; diff --git a/packages/core/src/streams/torrserver-converter.ts b/packages/core/src/streams/torrserver-converter.ts new file mode 100644 index 000000000..4e81666e7 --- /dev/null +++ b/packages/core/src/streams/torrserver-converter.ts @@ -0,0 +1,136 @@ +import { ParsedStream, UserData } from '../db/schemas.js'; +import { createLogger, ServiceId, TORRSERVER_SERVICE } from '../utils/index.js'; +import { TorrServerConfig } from '../debrid/torrserver.js'; + +const logger = createLogger('torrserver-converter'); + +class TorrServerConverter { + private userData: UserData; + private torrServerUrl?: string; + private torrServerAuth?: string; + private hasTorrServer: boolean = false; + + constructor(userData: UserData) { + this.userData = userData; + this.initializeTorrServer(); + } + + private initializeTorrServer() { + // Check if TorrServer is configured in services + const torrServerService = this.userData.services?.find( + (s) => s.id === TORRSERVER_SERVICE && s.enabled !== false + ); + + if (torrServerService) { + try { + const config = TorrServerConfig.parse(torrServerService.credentials); + this.torrServerUrl = config.torrserverUrl; + this.torrServerAuth = config.torrserverAuth; + this.hasTorrServer = true; + logger.info('TorrServer service configured for P2P stream conversion'); + } catch (error) { + logger.error( + `Failed to parse TorrServer credentials: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } + + private addAuthToStreamUrl(url: URL): void { + if (!this.torrServerAuth) return; + + const trimmedAuth = this.torrServerAuth.trim(); + if (trimmedAuth === '') return; + + if (trimmedAuth.includes(':')) { + // Basic auth credentials (username:password) - add to URL + // Handle passwords that may contain colons by only splitting on the first colon + const colonIndex = trimmedAuth.indexOf(':'); + const username = trimmedAuth.substring(0, colonIndex); + const password = trimmedAuth.substring(colonIndex + 1); + url.username = username; + url.password = password; + } else { + // API key - add as query parameter + url.searchParams.set('apikey', trimmedAuth); + } + } + + public async convert(streams: ParsedStream[]): Promise { + if (!this.hasTorrServer || !this.torrServerUrl) { + return streams; + } + + let convertedCount = 0; + + const convertedStreams = streams.map((stream) => { + // Only convert P2P streams that don't already have a URL + if ( + stream.type === 'p2p' && + stream.torrent?.infoHash && + !stream.url && + !stream.externalUrl + ) { + const infoHash = stream.torrent.infoHash; + const magnet = TorrServerConverter.buildMagnetLink( + infoHash, + stream.torrent.sources || [] + ); + + // Build TorrServer stream URL + const streamUrlObj = new URL('/stream', this.torrServerUrl!); // Non-null assertion safe due to check above + streamUrlObj.searchParams.set('link', magnet); + streamUrlObj.searchParams.set('play', '1'); // Auto play + streamUrlObj.searchParams.set('save', 'true'); + + if (stream.torrent.fileIdx !== undefined) { + streamUrlObj.searchParams.set( + 'index', + String(stream.torrent.fileIdx + 1) + ); + } else { + // If no index is provided in P2P stream, default to 1 (usually main file) + streamUrlObj.searchParams.set('index', '1'); + } + + // IMPORTANT: Append auth (API Key or Basic Auth) to the playback URL if configured + this.addAuthToStreamUrl(streamUrlObj); + + const torrServerUrl = streamUrlObj.toString(); + + convertedCount++; + + return { + ...stream, + url: torrServerUrl, + type: 'debrid' as const, + service: { + id: TORRSERVER_SERVICE as ServiceId, + cached: true, // Mark as cached so AIOStreams treats it as instant play + }, + }; + } + + return stream; + }); + + if (convertedCount > 0) { + logger.info( + `Converted ${convertedCount} P2P streams to TorrServer playback URLs` + ); + } + + return convertedStreams; + } + + private static buildMagnetLink(infoHash: string, trackers: string[]): string { + let magnet = `magnet:?xt=urn:btih:${infoHash}`; + if (trackers && trackers.length > 0) { + const encodedTrackers = trackers.map((t) => encodeURIComponent(t)); + magnet += `&tr=${encodedTrackers.join('&tr=')}`; + } + return magnet; + } +} + +export default TorrServerConverter; diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts index 7b3994304..a96598780 100644 --- a/packages/core/src/utils/constants.ts +++ b/packages/core/src/utils/constants.ts @@ -213,6 +213,7 @@ const EASYNEWS_SERVICE = 'easynews'; const NZBDAV_SERVICE = 'nzbdav'; const ALTMOUNT_SERVICE = 'altmount'; const STREMIO_NNTP_SERVICE = 'stremio_nntp'; +const TORRSERVER_SERVICE = 'torrserver'; const SERVICES = [ REALDEBRID_SERVICE, @@ -230,6 +231,7 @@ const SERVICES = [ NZBDAV_SERVICE, ALTMOUNT_SERVICE, STREMIO_NNTP_SERVICE, + TORRSERVER_SERVICE, ] as const; export const BUILTIN_SUPPORTED_SERVICES = [ @@ -246,6 +248,7 @@ export const BUILTIN_SUPPORTED_SERVICES = [ ALTMOUNT_SERVICE, STREMIO_NNTP_SERVICE, EASYNEWS_SERVICE, + TORRSERVER_SERVICE, ] as const; export type ServiceId = (typeof SERVICES)[number]; @@ -712,6 +715,32 @@ const SERVICE_DETAILS: Record< }, ], }, + [TORRSERVER_SERVICE]: { + id: TORRSERVER_SERVICE, + name: 'TorrServer', + shortName: 'TS', + knownNames: ['TS', 'TorrServer', 'Torrserver'], + signUpText: + 'TorrServer is a self-hosted torrent streaming server. [Learn more](https://github.com/YouROK/TorrServer)', + credentials: [ + { + id: 'torrserverUrl', + name: 'TorrServer URL', + description: + 'The base URL of your TorrServer instance. E.g., http://torrserver:8090', + type: 'string', + required: true, + }, + { + id: 'torrserverAuth', + name: 'Basic Auth Token (Optional)', + description: + 'If your TorrServer requires authentication, provide either username:password (will be Base64-encoded automatically) or a plain API key (will be passed as query parameter)', + type: 'password', + required: false, + }, + ], + }, }; const TOP_LEVEL_OPTION_DETAILS: Record< @@ -1307,6 +1336,7 @@ export { ALTMOUNT_SERVICE, STREMIO_NNTP_SERVICE, EASYNEWS_SERVICE, + TORRSERVER_SERVICE, SERVICE_DETAILS, TOP_LEVEL_OPTION_DETAILS, HEADERS_FOR_IP_FORWARDING,