diff --git a/src/lib/integrations/qbittorrent.service.ts b/src/lib/integrations/qbittorrent.service.ts index 516c325..6f73be9 100644 --- a/src/lib/integrations/qbittorrent.service.ts +++ b/src/lib/integrations/qbittorrent.service.ts @@ -108,6 +108,7 @@ export class QBittorrentService implements IDownloadClient { private username: string; private password: string; private cookie?: string; + private authOptional: boolean; private defaultSavePath: string; private defaultCategory: string; private disableSSLVerify: boolean; @@ -126,11 +127,16 @@ export class QBittorrentService implements IDownloadClient { this.baseUrl = baseUrl.replace(/\/$/, ''); this.username = username; this.password = password; + this.authOptional = !username && !password; this.defaultSavePath = defaultSavePath; this.defaultCategory = defaultCategory; this.disableSSLVerify = disableSSLVerify; this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' }; + if (this.authOptional) { + logger.info('[QBittorrent] No credentials configured — running in auth-optional mode (suitable for IP-whitelisted qBittorrent or auth-less proxies like Decypharr)'); + } + // Create HTTPS agent if SSL verification is disabled if (disableSSLVerify && this.baseUrl.startsWith('https')) { this.httpsAgent = new https.Agent({ @@ -152,9 +158,23 @@ export class QBittorrentService implements IDownloadClient { } /** - * Authenticate and establish session + * Build request headers including the session cookie when one exists. + * In auth-optional mode no cookie is set and the Cookie header is omitted. + */ + private authHeaders(): Record { + return this.cookie ? { Cookie: this.cookie } : {}; + } + + /** + * Authenticate and establish session. + * In auth-optional mode (no username/password configured) this is a no-op. */ async login(): Promise { + if (this.authOptional) { + logger.debug('[QBittorrent] Skipping login — auth-optional mode'); + return; + } + const loginUrl = `${this.baseUrl}/api/v2/auth/login`; logger.debug('[QBittorrent] Attempting login', { @@ -241,7 +261,7 @@ export class QBittorrentService implements IDownloadClient { } // Ensure we're authenticated - if (!this.cookie) { + if (!this.cookie && !this.authOptional) { await this.login(); } @@ -260,8 +280,10 @@ export class QBittorrentService implements IDownloadClient { return await this.addTorrentFile(url, category, options); } } catch (error) { - // Try re-authenticating once if we get a 403 - if (!retried && axios.isAxiosError(error) && error.response?.status === 403) { + // Try re-authenticating once if we get a 403 — only meaningful when credentials are configured. + // In auth-optional mode a 403 means the server actually wants auth (e.g. IP no longer whitelisted), + // so retrying login is pointless and would mask the real error. + if (!retried && !this.authOptional && axios.isAxiosError(error) && error.response?.status === 403) { logger.info('[QBittorrent] Session expired, re-authenticating...'); await this.login(); return this.addTorrent(url, options, true); @@ -322,7 +344,7 @@ export class QBittorrentService implements IDownloadClient { const response = await this.client.post('/torrents/add', form, { headers: { - Cookie: this.cookie, + ...this.authHeaders(), 'Content-Type': 'application/x-www-form-urlencoded', }, }); @@ -470,7 +492,7 @@ export class QBittorrentService implements IDownloadClient { const response = await this.client.post('/torrents/add', formData, { headers: { - Cookie: this.cookie, + ...this.authHeaders(), ...formData.getHeaders(), }, maxBodyLength: Infinity, @@ -491,7 +513,7 @@ export class QBittorrentService implements IDownloadClient { * Applies reverse path mapping (local → remote) for remote seedbox scenarios */ protected async ensureCategory(category: string): Promise { - if (!this.cookie) { + if (!this.cookie && !this.authOptional) { await this.login(); } @@ -501,7 +523,7 @@ export class QBittorrentService implements IDownloadClient { try { // First, get all categories to check if it exists and what save path it has const categoriesResponse = await this.client.get('/torrents/categories', { - headers: { Cookie: this.cookie }, + headers: this.authHeaders(), }); const categories = categoriesResponse.data; @@ -519,7 +541,7 @@ export class QBittorrentService implements IDownloadClient { }), { headers: { - Cookie: this.cookie, + ...this.authHeaders(), 'Content-Type': 'application/x-www-form-urlencoded', }, } @@ -541,7 +563,7 @@ export class QBittorrentService implements IDownloadClient { }), { headers: { - Cookie: this.cookie, + ...this.authHeaders(), 'Content-Type': 'application/x-www-form-urlencoded', }, } @@ -572,13 +594,13 @@ export class QBittorrentService implements IDownloadClient { * Get torrent status and progress */ async getTorrent(hash: string): Promise { - if (!this.cookie) { + if (!this.cookie && !this.authOptional) { await this.login(); } try { const response = await this.client.get('/torrents/info', { - headers: { Cookie: this.cookie }, + headers: this.authHeaders(), params: { hashes: hash }, }); @@ -610,7 +632,7 @@ export class QBittorrentService implements IDownloadClient { * Get all torrents (optionally filtered by category) */ async getTorrents(category?: string): Promise { - if (!this.cookie) { + if (!this.cookie && !this.authOptional) { await this.login(); } @@ -621,7 +643,7 @@ export class QBittorrentService implements IDownloadClient { } const response = await this.client.get('/torrents/info', { - headers: { Cookie: this.cookie }, + headers: this.authHeaders(), params, }); @@ -636,7 +658,7 @@ export class QBittorrentService implements IDownloadClient { * Pause torrent */ async pauseTorrent(hash: string): Promise { - if (!this.cookie) { + if (!this.cookie && !this.authOptional) { await this.login(); } @@ -646,7 +668,7 @@ export class QBittorrentService implements IDownloadClient { new URLSearchParams({ hashes: hash }), { headers: { - Cookie: this.cookie, + ...this.authHeaders(), 'Content-Type': 'application/x-www-form-urlencoded', }, } @@ -663,7 +685,7 @@ export class QBittorrentService implements IDownloadClient { * Resume torrent */ async resumeTorrent(hash: string): Promise { - if (!this.cookie) { + if (!this.cookie && !this.authOptional) { await this.login(); } @@ -673,7 +695,7 @@ export class QBittorrentService implements IDownloadClient { new URLSearchParams({ hashes: hash }), { headers: { - Cookie: this.cookie, + ...this.authHeaders(), 'Content-Type': 'application/x-www-form-urlencoded', }, } @@ -690,7 +712,7 @@ export class QBittorrentService implements IDownloadClient { * Delete torrent */ async deleteTorrent(hash: string, deleteFiles: boolean = false): Promise { - if (!this.cookie) { + if (!this.cookie && !this.authOptional) { await this.login(); } @@ -703,7 +725,7 @@ export class QBittorrentService implements IDownloadClient { }), { headers: { - Cookie: this.cookie, + ...this.authHeaders(), 'Content-Type': 'application/x-www-form-urlencoded', }, } @@ -720,13 +742,13 @@ export class QBittorrentService implements IDownloadClient { * Get files in torrent */ async getFiles(hash: string): Promise { - if (!this.cookie) { + if (!this.cookie && !this.authOptional) { await this.login(); } try { const response = await this.client.get('/torrents/files', { - headers: { Cookie: this.cookie }, + headers: this.authHeaders(), params: { hash }, }); @@ -741,13 +763,13 @@ export class QBittorrentService implements IDownloadClient { * Get all configured categories from qBittorrent */ async getCategories(): Promise { - if (!this.cookie) { + if (!this.cookie && !this.authOptional) { await this.login(); } try { const response = await this.client.get('/torrents/categories', { - headers: { Cookie: this.cookie }, + headers: this.authHeaders(), }); return Object.keys(response.data || {}); @@ -761,7 +783,7 @@ export class QBittorrentService implements IDownloadClient { * Set category for torrent */ async setCategory(hash: string, category: string): Promise { - if (!this.cookie) { + if (!this.cookie && !this.authOptional) { await this.login(); } @@ -774,7 +796,7 @@ export class QBittorrentService implements IDownloadClient { }), { headers: { - Cookie: this.cookie, + ...this.authHeaders(), 'Content-Type': 'application/x-www-form-urlencoded', }, } @@ -788,26 +810,36 @@ export class QBittorrentService implements IDownloadClient { } /** - * Test connection to qBittorrent + * Test connection to qBittorrent. + * In auth-optional mode the /app/version probe IS the connectivity check, so it must succeed. + * In credentialed mode login() is the connectivity check and version is best-effort. */ async testConnection(): Promise { try { - await this.login(); + await this.login(); // no-op when authOptional; throws on real auth failure - // Fetch version after successful login - let version: string | undefined; try { const versionResponse = await this.client.get('/app/version', { - headers: { Cookie: this.cookie }, + headers: this.authHeaders(), }); const raw = versionResponse.data || ''; - version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined; - } catch { - // Version fetch is non-critical - connection is still valid + const version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined; + return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` }; + } catch (versionError) { + if (this.authOptional) { + // No login happened — version probe was our only connectivity signal. + const status = axios.isAxiosError(versionError) ? versionError.response?.status : undefined; + const baseMessage = versionError instanceof Error ? versionError.message : 'Connection failed'; + const message = status === 401 || status === 403 + ? `qBittorrent requires authentication (HTTP ${status}). Provide username/password or whitelist this app's IP in qBittorrent.` + : `Failed to reach qBittorrent: ${baseMessage}`; + logger.error('[QBittorrent] Auth-optional connection probe failed', { status, message: baseMessage }); + return { success: false, message }; + } + // Credentialed path: login already succeeded, version is nice-to-have. logger.debug('Could not fetch qBittorrent version'); + return { success: true, message: 'Connected to qBittorrent' }; } - - return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` }; } catch (error) { const message = error instanceof Error ? error.message : 'Connection failed'; logger.error('Connection test failed', { error: message }); @@ -826,6 +858,7 @@ export class QBittorrentService implements IDownloadClient { ): Promise { const baseUrl = url.replace(/\/$/, ''); const loginUrl = `${baseUrl}/api/v2/auth/login`; + const authOptional = !username && !password; // Create HTTPS agent if SSL verification is disabled let httpsAgent: https.Agent | undefined; @@ -844,9 +877,25 @@ export class QBittorrentService implements IDownloadClient { passwordLength: password?.length, sslVerifyDisabled: disableSSLVerify, hasHttpsAgent: !!httpsAgent, + authOptional, }); try { + if (authOptional) { + // No credentials provided — skip /auth/login and probe /app/version directly. + // Works for IP-whitelisted qBittorrent and auth-less qBit-compatible proxies (e.g. Decypharr). + logger.info('[QBittorrent] No credentials provided, probing /app/version directly'); + const versionResponse = await axios.get(`${baseUrl}/api/v2/app/version`, { + httpsAgent, + timeout: DOWNLOAD_CLIENT_TIMEOUT, + }); + logger.info('[QBittorrent] Auth-optional version check successful', { + version: versionResponse.data, + }); + const rawVersion = versionResponse.data || ''; + return typeof rawVersion === 'string' ? rawVersion.replace(/^v/i, '') || 'Connected' : 'Connected'; + } + const requestBody = new URLSearchParams({ username, password }); const requestHeaders = { 'Content-Type': 'application/x-www-form-urlencoded', @@ -980,6 +1029,11 @@ export class QBittorrentService implements IDownloadClient { // HTTP status errors if (status === 401 || status === 403) { + if (authOptional) { + throw new Error( + `qBittorrent requires authentication (HTTP ${status}). Provide username/password, or whitelist this app's IP in qBittorrent's Web UI settings.` + ); + } throw new Error( `Authentication failed (HTTP ${status}). Check your username and password.` ); diff --git a/tests/integrations/qbittorrent.service.test.ts b/tests/integrations/qbittorrent.service.test.ts index ec6fb29..f419021 100644 --- a/tests/integrations/qbittorrent.service.test.ts +++ b/tests/integrations/qbittorrent.service.test.ts @@ -1217,4 +1217,124 @@ describe('QBittorrentService', () => { expect(result.success).toBe(true); expect(loginSpy).toHaveBeenCalled(); }); + + describe('auth-optional mode (blank credentials)', () => { + it('flags service as auth-optional when both credentials are blank', () => { + const service = new QBittorrentService('http://qb', '', ''); + expect((service as any).authOptional).toBe(true); + }); + + it('flags service as credentialed when any credential is provided', () => { + const withUser = new QBittorrentService('http://qb', 'user', ''); + const withPass = new QBittorrentService('http://qb', '', 'pass'); + expect((withUser as any).authOptional).toBe(false); + expect((withPass as any).authOptional).toBe(false); + }); + + it('login() is a no-op when auth-optional', async () => { + const service = new QBittorrentService('http://qb', '', ''); + await service.login(); + expect(axiosMock.post).not.toHaveBeenCalled(); + expect((service as any).cookie).toBeUndefined(); + }); + + it('testConnection() succeeds when /app/version returns a version (auth-optional)', async () => { + const service = new QBittorrentService('http://qb', '', ''); + clientMock.get.mockResolvedValueOnce({ data: 'v4.6.0' }); + + const result = await service.testConnection(); + + expect(result.success).toBe(true); + expect(result.version).toBe('4.6.0'); + expect(axiosMock.post).not.toHaveBeenCalled(); + expect(clientMock.get).toHaveBeenCalledWith('/app/version', expect.objectContaining({ + headers: {}, + })); + }); + + it('testConnection() returns failure when /app/version returns 401 (auth-optional)', async () => { + const service = new QBittorrentService('http://qb', '', ''); + clientMock.get.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 401 }, + message: 'Unauthorized', + }); + + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.message).toMatch(/requires authentication/i); + }); + + it('testConnection() returns failure when /app/version is unreachable (auth-optional)', async () => { + const service = new QBittorrentService('http://qb', '', ''); + clientMock.get.mockRejectedValueOnce({ + isAxiosError: true, + code: 'ECONNREFUSED', + message: 'connect ECONNREFUSED', + }); + + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.message).toMatch(/Failed to reach qBittorrent/i); + }); + + it('testConnectionWithCredentials() probes /app/version directly when both creds blank', async () => { + axiosMock.get.mockResolvedValueOnce({ data: 'v4.6.0' }); + + const version = await QBittorrentService.testConnectionWithCredentials('http://qb', '', ''); + + expect(version).toBe('4.6.0'); + expect(axiosMock.post).not.toHaveBeenCalled(); + expect(axiosMock.get).toHaveBeenCalledWith( + 'http://qb/api/v2/app/version', + expect.objectContaining({ httpsAgent: undefined }) + ); + }); + + it('testConnectionWithCredentials() reports auth-required when blank creds get 401', async () => { + axiosMock.get.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 401 }, + message: 'Unauthorized', + config: { url: 'http://qb/api/v2/app/version' }, + }); + + await expect( + QBittorrentService.testConnectionWithCredentials('http://qb', '', '') + ).rejects.toThrow(/requires authentication/i); + }); + + it('addTorrent does not attempt re-login on 403 when auth-optional', async () => { + const service = new QBittorrentService('http://qb', '', ''); + vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined); + const loginSpy = vi.spyOn(service, 'login'); + vi.spyOn(service as any, 'addMagnetLink').mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 403 }, + }); + + await expect( + service.addTorrent('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567') + ).rejects.toThrow('Failed to add torrent'); + + expect(loginSpy).not.toHaveBeenCalled(); + }); + + it('omits Cookie header on requests when auth-optional', async () => { + const service = new QBittorrentService('http://qb', '', ''); + vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found')); + clientMock.post.mockResolvedValue({ data: 'Ok.' }); + + await (service as any).addMagnetLink( + 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567', + 'readmeabook' + ); + + const headers = clientMock.post.mock.calls[0][2].headers; + expect(headers.Cookie).toBeUndefined(); + expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded'); + }); + }); });