diff --git a/src/lib/constants/download-timeouts.ts b/src/lib/constants/download-timeouts.ts new file mode 100644 index 0000000..b909904 --- /dev/null +++ b/src/lib/constants/download-timeouts.ts @@ -0,0 +1,10 @@ +/** + * Component: Download Client Timeout Constants + * Documentation: documentation/phase3/download-clients.md + * + * Some indexers (e.g. YGGtorrent) enforce a ~30s wait before allowing + * .torrent file downloads. 60s gives sufficient headroom. + */ + +/** Timeout for download client API calls and .torrent file fetches (ms) */ +export const DOWNLOAD_CLIENT_TIMEOUT = 60000; diff --git a/src/lib/integrations/deluge.service.ts b/src/lib/integrations/deluge.service.ts index 8c73296..f20d5d6 100644 --- a/src/lib/integrations/deluge.service.ts +++ b/src/lib/integrations/deluge.service.ts @@ -6,6 +6,7 @@ import axios, { AxiosInstance } from 'axios'; import https from 'https'; import path from 'path'; +import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts'; import * as parseTorrentModule from 'parse-torrent'; import { RMABLogger } from '../utils/logger'; import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; @@ -49,7 +50,7 @@ export class DelugeService implements IDownloadClient { ? new https.Agent({ rejectUnauthorized: false }) : undefined; if (httpsAgent) logger.info('[Deluge] SSL certificate verification disabled'); - this.client = axios.create({ baseURL: this.baseUrl, timeout: 60000, httpsAgent }); // 60 seconds - some indexers (e.g. yggtorrent) enforce a 30s wait before download + this.client = axios.create({ baseURL: this.baseUrl, timeout: DOWNLOAD_CLIENT_TIMEOUT, httpsAgent }); } /** JSON-RPC call with automatic re-authentication on auth failure */ @@ -190,7 +191,7 @@ export class DelugeService implements IDownloadClient { try { torrentResponse = await axios.get(torrentUrl, { responseType: 'arraybuffer', maxRedirects: 0, - validateStatus: (s) => s >= 200 && s < 300, timeout: 60000, // 60 seconds - some indexers (e.g. yggtorrent) enforce a 30s wait before download + validateStatus: (s) => s >= 200 && s < 300, timeout: DOWNLOAD_CLIENT_TIMEOUT, }); if (torrentResponse.data.length > 0) { const magnetMatch = torrentResponse.data.toString().match(/^magnet:\?[^\s]+$/); @@ -203,7 +204,7 @@ export class DelugeService implements IDownloadClient { const loc = error.response.headers['location']; if (loc?.startsWith('magnet:')) return this.addMagnetLink(loc, category, options); if (loc?.startsWith('http://') || loc?.startsWith('https://')) { - try { torrentResponse = await axios.get(loc, { responseType: 'arraybuffer', timeout: 60000, maxRedirects: 5 }); } // 60 seconds - some indexers (e.g. yggtorrent) enforce a 30s wait before download + try { torrentResponse = await axios.get(loc, { responseType: 'arraybuffer', timeout: DOWNLOAD_CLIENT_TIMEOUT, maxRedirects: 5 }); } catch { throw new Error('Failed to download torrent file after redirect'); } } else { throw new Error(`Invalid redirect location: ${loc}`); } } else { throw new Error(`Failed to download torrent: HTTP ${status}`); } diff --git a/src/lib/integrations/prowlarr.service.ts b/src/lib/integrations/prowlarr.service.ts index 7532563..a2f0689 100644 --- a/src/lib/integrations/prowlarr.service.ts +++ b/src/lib/integrations/prowlarr.service.ts @@ -5,6 +5,7 @@ import axios, { AxiosInstance } from 'axios'; import { XMLParser } from 'fast-xml-parser'; +import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts'; import { TorrentResult } from '../utils/ranking-algorithm'; import { RMABLogger } from '../utils/logger'; @@ -87,7 +88,7 @@ export class ProwlarrService { headers: { 'X-Api-Key': this.apiKey, }, - timeout: 60000, // 60 seconds - some indexers (e.g. yggtorrent) enforce a 30s wait before download + timeout: DOWNLOAD_CLIENT_TIMEOUT, paramsSerializer: { serialize: (params) => { // Custom serializer to handle arrays correctly for Prowlarr API @@ -314,7 +315,7 @@ export class ProwlarrService { limit: 100, extended: 1, }, - timeout: 60000, + timeout: DOWNLOAD_CLIENT_TIMEOUT, responseType: 'text', // Get XML as text }); diff --git a/src/lib/integrations/qbittorrent.service.ts b/src/lib/integrations/qbittorrent.service.ts index f1803fe..7a53447 100644 --- a/src/lib/integrations/qbittorrent.service.ts +++ b/src/lib/integrations/qbittorrent.service.ts @@ -6,6 +6,7 @@ import axios, { AxiosInstance } from 'axios'; import https from 'https'; import path from 'path'; +import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts'; import * as parseTorrentModule from 'parse-torrent'; import FormData from 'form-data'; import { RMABLogger } from '../utils/logger'; @@ -140,7 +141,7 @@ export class QBittorrentService implements IDownloadClient { this.client = axios.create({ baseURL: `${this.baseUrl}/api/v2`, - timeout: 60000, // 60 seconds - some indexers (e.g. yggtorrent) enforce a 30s wait before download + timeout: DOWNLOAD_CLIENT_TIMEOUT, httpsAgent: this.httpsAgent, // Support nginx/Apache reverse proxy with HTTP Basic Auth auth: { @@ -352,7 +353,7 @@ export class QBittorrentService implements IDownloadClient { responseType: 'arraybuffer', maxRedirects: 0, validateStatus: (status) => status >= 200 && status < 300, // Only 2xx is success - timeout: 60000, // 60 seconds - some indexers (e.g. yggtorrent) enforce a 30s wait before download + timeout: DOWNLOAD_CLIENT_TIMEOUT, }); logger.info(` Got 2xx response, size=${torrentResponse.data.length} bytes`); @@ -394,7 +395,7 @@ export class QBittorrentService implements IDownloadClient { try { torrentResponse = await axios.get(location, { responseType: 'arraybuffer', - timeout: 60000, // 60 seconds - some indexers (e.g. yggtorrent) enforce a 30s wait before download + timeout: DOWNLOAD_CLIENT_TIMEOUT, maxRedirects: 5, }); logger.info(` After following redirect: size=${torrentResponse.data.length} bytes`); diff --git a/src/lib/integrations/transmission.service.ts b/src/lib/integrations/transmission.service.ts index 1a2ea6e..3381cf7 100644 --- a/src/lib/integrations/transmission.service.ts +++ b/src/lib/integrations/transmission.service.ts @@ -6,6 +6,7 @@ import axios, { AxiosInstance } from 'axios'; import https from 'https'; import path from 'path'; +import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts'; import * as parseTorrentModule from 'parse-torrent'; import { RMABLogger } from '../utils/logger'; import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; @@ -106,7 +107,7 @@ export class TransmissionService implements IDownloadClient { this.client = axios.create({ baseURL: this.baseUrl, - timeout: 60000, // 60 seconds - some indexers (e.g. yggtorrent) enforce a 30s wait before download + timeout: DOWNLOAD_CLIENT_TIMEOUT, httpsAgent: this.httpsAgent, }); } @@ -274,7 +275,7 @@ export class TransmissionService implements IDownloadClient { responseType: 'arraybuffer', maxRedirects: 0, validateStatus: (status) => status >= 200 && status < 300, - timeout: 60000, // 60 seconds - some indexers (e.g. yggtorrent) enforce a 30s wait before download + timeout: DOWNLOAD_CLIENT_TIMEOUT, }); // Check if response body is a magnet link @@ -302,7 +303,7 @@ export class TransmissionService implements IDownloadClient { try { torrentResponse = await axios.get(location, { responseType: 'arraybuffer', - timeout: 60000, // 60 seconds - some indexers (e.g. yggtorrent) enforce a 30s wait before download + timeout: DOWNLOAD_CLIENT_TIMEOUT, maxRedirects: 5, }); } catch {