From ed11df401fb2cffe5aac138bc4893d1fbe79f478 Mon Sep 17 00:00:00 2001 From: hung319 Date: Mon, 22 Dec 2025 08:46:29 +0700 Subject: [PATCH 01/10] up --- packages/core/src/debrid/index.ts | 4 + packages/core/src/debrid/torrserver.ts | 383 ++++++++++++++++++ packages/core/src/main.ts | 6 + packages/core/src/streams/index.ts | 2 + .../core/src/streams/torrserver-converter.ts | 128 ++++++ packages/core/src/utils/constants.ts | 30 ++ 6 files changed, 553 insertions(+) create mode 100644 packages/core/src/debrid/torrserver.ts create mode 100644 packages/core/src/streams/torrserver-converter.ts 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..e5fa08564 --- /dev/null +++ b/packages/core/src/debrid/torrserver.ts @@ -0,0 +1,383 @@ +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 + +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) { + const parsedConfig = TorrServerConfig.parse(JSON.parse(config.token)); + + 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 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) { + // Check if auth looks like Basic Auth (contains :) or just an API Key + if (this.torrserverAuth.includes(':')) { + headers['Authorization'] = `Basic ${Buffer.from(this.torrserverAuth).toString('base64')}`; + } else { + // Assume API Key passed in header or usually query param, + // but generic Basic auth header usually works for simple setups + headers['Authorization'] = `Basic ${this.torrserverAuth}`; + } + } + + // 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: 30000, + ttl: 10000, + } + ); + 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; + const parsed = parseTorrentTitle(file.name); + parsedFiles.set(file.name, { + title: parsed?.title, + seasons: parsed?.seasons, + episodes: parsed?.episodes, + year: parsed?.year, + }); + } + } + + 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', 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', '1'); // Default to 1 if selection failed + } + + // AUTH HANDLING FOR STREAM LINK + this.addApiKeyToUrl(streamUrlObj); + + const streamUrl = streamUrlObj.toString(); + + await TorrServerDebridService.playbackLinkCache.set( + cacheKey, + streamUrl, + Env.BUILTIN_DEBRID_PLAYBACK_LINK_CACHE_TTL + ); + + return streamUrl; + } +} \ No newline at end of file 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..7426e40a6 --- /dev/null +++ b/packages/core/src/streams/torrserver-converter.ts @@ -0,0 +1,128 @@ +import { ParsedStream, UserData } from '../db/schemas.js'; +import { createLogger, ServiceId } from '../utils/index.js'; // Removed 'constants' +import { TorrServerConfig } from '../debrid/torrserver.js'; + +const logger = createLogger('torrserver-converter'); + +// Define constant locally to avoid missing export issues in main repo +const TORRSERVER_SERVICE_ID = 'torrserver'; + +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_ID && 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 addApiKeyToUrl(url: URL): void { + if (this.torrServerAuth && !this.torrServerAuth.includes(':')) { + const trimmedKey = this.torrServerAuth.trim(); + if (trimmedKey !== '') { + url.searchParams.set('apikey', trimmedKey); + } + } + } + + 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 = this.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) + ); + } else { + // If no index is provided in P2P stream, default to 1 (usually main file) + streamUrlObj.searchParams.set('index', '1'); + } + + // IMPORTANT: Append API Key to the playback URL if configured + this.addApiKeyToUrl(streamUrlObj); + + const torrServerUrl = streamUrlObj.toString(); + + convertedCount++; + + return { + ...stream, + url: torrServerUrl, + type: 'debrid' as const, + service: { + id: TORRSERVER_SERVICE_ID 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 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; \ No newline at end of file diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts index 7b3994304..51eb89985 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 the Base64-encoded Basic auth token (username:password)', + 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, From a4633044714fe6f600f42bcb60bf48c991d35c66 Mon Sep 17 00:00:00 2001 From: Yuuta <161178554+hoangxg4@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:33:19 +0700 Subject: [PATCH 02/10] Update torrserver.ts --- packages/core/src/debrid/torrserver.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/core/src/debrid/torrserver.ts b/packages/core/src/debrid/torrserver.ts index e5fa08564..99d24e42e 100644 --- a/packages/core/src/debrid/torrserver.ts +++ b/packages/core/src/debrid/torrserver.ts @@ -98,15 +98,9 @@ export class TorrServerDebridService implements DebridService { }; // Add Auth headers for API control - if (this.torrserverAuth) { - // Check if auth looks like Basic Auth (contains :) or just an API Key - if (this.torrserverAuth.includes(':')) { - headers['Authorization'] = `Basic ${Buffer.from(this.torrserverAuth).toString('base64')}`; - } else { - // Assume API Key passed in header or usually query param, - // but generic Basic auth header usually works for simple setups - headers['Authorization'] = `Basic ${this.torrserverAuth}`; - } + 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 @@ -380,4 +374,4 @@ export class TorrServerDebridService implements DebridService { return streamUrl; } -} \ No newline at end of file +} From df479cd9a83ecc796498eed926662fa84c6ff62b Mon Sep 17 00:00:00 2001 From: Yuuta <161178554+hoangxg4@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:34:03 +0700 Subject: [PATCH 03/10] Update torrserver-converter.ts --- packages/core/src/streams/torrserver-converter.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/core/src/streams/torrserver-converter.ts b/packages/core/src/streams/torrserver-converter.ts index 7426e40a6..70dbf957b 100644 --- a/packages/core/src/streams/torrserver-converter.ts +++ b/packages/core/src/streams/torrserver-converter.ts @@ -1,12 +1,9 @@ import { ParsedStream, UserData } from '../db/schemas.js'; -import { createLogger, ServiceId } from '../utils/index.js'; // Removed 'constants' +import { createLogger, ServiceId, TORRSERVER_SERVICE } from '../utils/index.js'; import { TorrServerConfig } from '../debrid/torrserver.js'; const logger = createLogger('torrserver-converter'); -// Define constant locally to avoid missing export issues in main repo -const TORRSERVER_SERVICE_ID = 'torrserver'; - class TorrServerConverter { private userData: UserData; private torrServerUrl?: string; @@ -21,7 +18,7 @@ class TorrServerConverter { private initializeTorrServer() { // Check if TorrServer is configured in services const torrServerService = this.userData.services?.find( - (s) => s.id === TORRSERVER_SERVICE_ID && s.enabled !== false + (s) => s.id === TORRSERVER_SERVICE && s.enabled !== false ); if (torrServerService) { @@ -78,7 +75,7 @@ class TorrServerConverter { if (stream.torrent.fileIdx !== undefined) { streamUrlObj.searchParams.set( 'index', - String(stream.torrent.fileIdx) + String(stream.torrent.fileIdx + 1) ); } else { // If no index is provided in P2P stream, default to 1 (usually main file) @@ -97,7 +94,7 @@ class TorrServerConverter { url: torrServerUrl, type: 'debrid' as const, service: { - id: TORRSERVER_SERVICE_ID as ServiceId, + id: TORRSERVER_SERVICE as ServiceId, cached: true, // Mark as cached so AIOStreams treats it as instant play }, }; @@ -125,4 +122,4 @@ class TorrServerConverter { } } -export default TorrServerConverter; \ No newline at end of file +export default TorrServerConverter; From 4ddc4107147e8326bc458b15612fa2f843354a2f Mon Sep 17 00:00:00 2001 From: Yuuta <161178554+hoangxg4@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:35:16 +0700 Subject: [PATCH 04/10] Update constants.ts --- packages/core/src/utils/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts index 51eb89985..a96598780 100644 --- a/packages/core/src/utils/constants.ts +++ b/packages/core/src/utils/constants.ts @@ -735,7 +735,7 @@ const SERVICE_DETAILS: Record< id: 'torrserverAuth', name: 'Basic Auth Token (Optional)', description: - 'If your TorrServer requires authentication, provide the Base64-encoded Basic auth token (username:password)', + '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, }, From b5d364bdcfc4a620eb5674c1d014d8b9e34904f0 Mon Sep 17 00:00:00 2001 From: Yuuta <161178554+hoangxg4@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:55:15 +0700 Subject: [PATCH 05/10] Update torrserver.ts --- packages/core/src/debrid/torrserver.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/core/src/debrid/torrserver.ts b/packages/core/src/debrid/torrserver.ts index 99d24e42e..94fab738f 100644 --- a/packages/core/src/debrid/torrserver.ts +++ b/packages/core/src/debrid/torrserver.ts @@ -82,6 +82,26 @@ export class TorrServerDebridService implements DebridService { } } + 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?: { @@ -351,7 +371,7 @@ export class TorrServerDebridService implements DebridService { // Build Stream URL const streamUrlObj = new URL('/stream', this.torrserverUrl); - streamUrlObj.searchParams.set('link', magnet); + 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 @@ -361,8 +381,8 @@ export class TorrServerDebridService implements DebridService { streamUrlObj.searchParams.set('index', '1'); // Default to 1 if selection failed } - // AUTH HANDLING FOR STREAM LINK - this.addApiKeyToUrl(streamUrlObj); + // AUTH HANDLING FOR STREAM LINK - supports both API keys and Basic auth + this.addAuthToStreamUrl(streamUrlObj); const streamUrl = streamUrlObj.toString(); From 75eb98c4f1438ed8576aca9e3a0842c26b9b8eae Mon Sep 17 00:00:00 2001 From: Yuuta <161178554+hoangxg4@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:37:08 +0700 Subject: [PATCH 06/10] Update torrserver.ts --- packages/core/src/debrid/torrserver.ts | 109 +++++++++++++++---------- 1 file changed, 66 insertions(+), 43 deletions(-) diff --git a/packages/core/src/debrid/torrserver.ts b/packages/core/src/debrid/torrserver.ts index 94fab738f..2ae69fce2 100644 --- a/packages/core/src/debrid/torrserver.ts +++ b/packages/core/src/debrid/torrserver.ts @@ -67,7 +67,17 @@ export class TorrServerDebridService implements DebridService { readonly serviceName: ServiceId = 'torrserver' as ServiceId; constructor(private readonly config: DebridServiceConfig) { - const parsedConfig = TorrServerConfig.parse(JSON.parse(config.token)); + 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; @@ -120,14 +130,14 @@ export class TorrServerDebridService implements DebridService { // 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')}`; + 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, @@ -154,11 +164,12 @@ export class TorrServerDebridService implements DebridService { 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'); - + const response = await this.torrserverRequest('/torrents'); + // Handle response structure which might vary slightly - const torrents = Array.isArray(response) ? response : (response.torrents || []); + const torrents = Array.isArray(response) + ? response + : response.torrents || []; return ( torrents.map((torrent: TorrServerTorrent) => ({ @@ -187,7 +198,7 @@ export class TorrServerDebridService implements DebridService { 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'; + return 'cached'; default: return 'unknown'; } @@ -204,7 +215,7 @@ export class TorrServerDebridService implements DebridService { for (const magnet of magnets) { const hash = this.extractHashFromMagnet(magnet); if (!hash) continue; - + results.push({ id: hash, hash, @@ -252,12 +263,12 @@ export class TorrServerDebridService implements DebridService { // Even if not found immediately (rare race condition), return a dummy valid object if (!torrent) { - return { - id: hash, - hash: hash, - status: 'cached', - files: [] - } + return { + id: hash, + hash: hash, + status: 'cached', + files: [], + }; } return torrent; @@ -307,9 +318,10 @@ export class TorrServerDebridService implements DebridService { const { hash, metadata } = playbackInfo; const cacheKey = `torrserver:resolve:${hash}:${filename}`; - + // Check Cache first - const cachedLink = await TorrServerDebridService.playbackLinkCache.get(cacheKey); + const cachedLink = + await TorrServerDebridService.playbackLinkCache.get(cacheKey); if (cachedLink) return cachedLink; let magnet = `magnet:?xt=urn:btih:${hash}`; @@ -322,43 +334,54 @@ export class TorrServerDebridService implements DebridService { // 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 && 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`); + // 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; - const parsed = parseTorrentTitle(file.name); - parsedFiles.set(file.name, { - title: parsed?.title, - seasons: parsed?.seasons, - episodes: parsed?.episodes, - year: parsed?.year, - }); + 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: [], + type: 'torrent', + hash, + title: magnetDownload.name || filename, + size: magnetDownload.size || 0, + seeders: 1, + sources: [], }, magnetDownload, parsedFiles, @@ -376,9 +399,9 @@ export class TorrServerDebridService implements DebridService { streamUrlObj.searchParams.set('save', 'true'); // Save to DB if (selectedFile) { - streamUrlObj.searchParams.set('index', String(selectedFile.index)); + streamUrlObj.searchParams.set('index', String(selectedFile.index)); } else { - streamUrlObj.searchParams.set('index', '1'); // Default to 1 if selection failed + streamUrlObj.searchParams.set('index', '0'); // Default to 0 for 0-based indexing } // AUTH HANDLING FOR STREAM LINK - supports both API keys and Basic auth From 63f010c5b61b8bc63be1cde24b1a8282fe95344c Mon Sep 17 00:00:00 2001 From: Yuuta <161178554+hoangxg4@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:37:38 +0700 Subject: [PATCH 07/10] Update torrserver-converter.ts --- packages/core/src/streams/torrserver-converter.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/streams/torrserver-converter.ts b/packages/core/src/streams/torrserver-converter.ts index 70dbf957b..86f6c57df 100644 --- a/packages/core/src/streams/torrserver-converter.ts +++ b/packages/core/src/streams/torrserver-converter.ts @@ -70,7 +70,7 @@ class TorrServerConverter { 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'); + streamUrlObj.searchParams.set('save', 'true'); if (stream.torrent.fileIdx !== undefined) { streamUrlObj.searchParams.set( @@ -78,8 +78,8 @@ class TorrServerConverter { 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'); + // If no index is provided in P2P stream, default to 1 (usually main file) + streamUrlObj.searchParams.set('index', '1'); } // IMPORTANT: Append API Key to the playback URL if configured From 1895d4e09d62b524b2a56ac150ba72140b255287 Mon Sep 17 00:00:00 2001 From: Yuuta <161178554+hoangxg4@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:55:38 +0700 Subject: [PATCH 08/10] Update torrserver.ts --- packages/core/src/debrid/torrserver.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/debrid/torrserver.ts b/packages/core/src/debrid/torrserver.ts index 2ae69fce2..bb6b6d9f2 100644 --- a/packages/core/src/debrid/torrserver.ts +++ b/packages/core/src/debrid/torrserver.ts @@ -24,6 +24,7 @@ const logger = createLogger('debrid:torrserver'); 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 @@ -302,8 +303,8 @@ export class TorrServerDebridService implements DebridService { `torrserver:resolve:${playbackInfo.hash}:${this.config.clientIp}`, () => this._resolve(playbackInfo, filename, cacheAndPlay), { - timeout: 30000, - ttl: 10000, + timeout: TORRSERVER_RESOLVE_LOCK_TIMEOUT_MS, + ttl: TORRSERVER_RESOLVE_LOCK_TIMEOUT_MS, } ); return result; From 5be8350018db9ae1aa0112d519c8d60cfda62ed2 Mon Sep 17 00:00:00 2001 From: Yuuta <161178554+hoangxg4@users.noreply.github.com> Date: Tue, 23 Dec 2025 10:45:31 +0700 Subject: [PATCH 09/10] fix auth not work --- .../core/src/streams/torrserver-converter.ts | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/core/src/streams/torrserver-converter.ts b/packages/core/src/streams/torrserver-converter.ts index 86f6c57df..ed61ccce4 100644 --- a/packages/core/src/streams/torrserver-converter.ts +++ b/packages/core/src/streams/torrserver-converter.ts @@ -36,12 +36,23 @@ class TorrServerConverter { } } - 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); } } @@ -82,8 +93,8 @@ class TorrServerConverter { streamUrlObj.searchParams.set('index', '1'); } - // IMPORTANT: Append API Key to the playback URL if configured - this.addApiKeyToUrl(streamUrlObj); + // IMPORTANT: Append auth (API Key or Basic Auth) to the playback URL if configured + this.addAuthToStreamUrl(streamUrlObj); const torrServerUrl = streamUrlObj.toString(); From b20b4181771515cea9b79a89a5708146bd3d463f Mon Sep 17 00:00:00 2001 From: Yuuta <161178554+hoangxg4@users.noreply.github.com> Date: Tue, 23 Dec 2025 19:20:46 +0700 Subject: [PATCH 10/10] Update torrserver-converter.ts --- packages/core/src/streams/torrserver-converter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/streams/torrserver-converter.ts b/packages/core/src/streams/torrserver-converter.ts index ed61ccce4..4e81666e7 100644 --- a/packages/core/src/streams/torrserver-converter.ts +++ b/packages/core/src/streams/torrserver-converter.ts @@ -72,7 +72,7 @@ class TorrServerConverter { !stream.externalUrl ) { const infoHash = stream.torrent.infoHash; - const magnet = this.buildMagnetLink( + const magnet = TorrServerConverter.buildMagnetLink( infoHash, stream.torrent.sources || [] ); @@ -123,7 +123,7 @@ class TorrServerConverter { return convertedStreams; } - private buildMagnetLink(infoHash: string, trackers: string[]): string { + 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));