diff --git a/server/src/modules/book-request/indexers/adapters/torznab.adapter.test.ts b/server/src/modules/book-request/indexers/adapters/torznab.adapter.test.ts index ade7f331f..e8802ce74 100644 --- a/server/src/modules/book-request/indexers/adapters/torznab.adapter.test.ts +++ b/server/src/modules/book-request/indexers/adapters/torznab.adapter.test.ts @@ -165,6 +165,16 @@ describe('TorznabAdapter', () => { expect(lastUrl().pathname).toBe('/api'); }); + it('uses Prowlarr REST Torznab feed URLs without appending /api', async () => { + fetchMock.mockResolvedValue(new Response('')); + + await expect(new TorznabAdapter().test(config({ baseUrl: 'http://127.0.0.1:9696/api/v1/indexer/7/newznab' }))).resolves.toEqual({ + success: true, + indexerName: 'Prowlarr', + }); + expect(lastUrl().pathname).toBe('/api/v1/indexer/7/newznab'); + }); + it('reads seeders, size, infohash and freeleech off the torznab attributes', async () => { fetchMock.mockResolvedValue(new Response(feed(ITEM))); @@ -231,6 +241,17 @@ describe('TorznabAdapter', () => { await expect(new TorznabAdapter().test(config())).resolves.toMatchObject({ success: false }); }); + it('reports a Prowlarr server URL as a non-torznab response instead of an opaque 406', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ current: 'v1', deprecated: [] }), { headers: { 'content-type': 'application/json' } })); + + await expect(new TorznabAdapter().test(config({ name: 'Prowlarr', baseUrl: 'http://127.0.0.1:9696' }))).resolves.toEqual({ + success: false, + error: 'That URL answered, but not with a torznab capabilities document', + }); + expect(lastUrl().pathname).toBe('/api'); + expect(new Headers(fetchMock.mock.calls.at(-1)![1].headers).get('accept')).toBe('application/rss+xml, application/xml, text/xml, */*;q=0.1'); + }); + /** * Real Prowlarr output. `booktitle` is the bare work title where the release name is decorated, * and the seed figures are the tracker's own requirements passed through. diff --git a/server/src/modules/book-request/indexers/adapters/torznab.adapter.ts b/server/src/modules/book-request/indexers/adapters/torznab.adapter.ts index 3cba4a1b5..354b6d8f8 100644 --- a/server/src/modules/book-request/indexers/adapters/torznab.adapter.ts +++ b/server/src/modules/book-request/indexers/adapters/torznab.adapter.ts @@ -35,6 +35,7 @@ const MAX_FEED_FIELD_CHARS = MAX_INDEXER_RELEASE_GUID_LENGTH; const REQUEST_TIMEOUT_MS = 25_000; /** The same ceiling the direct fetcher uses, and for the same reason: a chain, not a loop. */ const MAX_REDIRECTS = 5; +const XML_ACCEPT = 'application/rss+xml, application/xml, text/xml, */*;q=0.1'; const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', textNodeName: '#text' }); @@ -162,7 +163,7 @@ export class TorznabAdapter implements IndexerAdapter { for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { try { - response = await safeFetch(current.href, { signal, redirect: 'manual', headers: { Accept: 'application/xml, text/xml' } }, options); + response = await safeFetch(current.href, { signal, redirect: 'manual', headers: { Accept: XML_ACCEPT } }, options); } catch (error) { if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) { throw new IndexerSearchException('timeout', `${config.name} did not answer in time`); @@ -200,7 +201,8 @@ export class TorznabAdapter implements IndexerAdapter { /** * `new URL('/api', base)` throws away any path the base carries, which breaks every Jackett * deployment (its endpoints live under `/api/v2.0/indexers//results/torznab`). Join under - * the base's own path instead, and accept a base that already ends in `/api`. + * the base's own path instead, and accept either the conventional endpoint ending in `/api` or + * Prowlarr's REST-shaped `/api/v1/indexer//newznab` endpoint as complete. * * Anything the base's own query string carries is kept underneath what this call asks for. Some * proxies hand the operator a base with the key already in it (`.../api?apikey=...`), and @@ -212,7 +214,8 @@ export class TorznabAdapter implements IndexerAdapter { const base = await ensureSafeUrl(config.baseUrl, { allowPrivate: config.allowPrivateAddress }); const prefix = base.pathname.replace(/\/+$/, ''); const target = new URL(base.href); - target.pathname = prefix.endsWith('/api') ? prefix : `${prefix}/api`; + const completeEndpoint = prefix.endsWith('/api') || /\/api\/v\d+\/indexer\/\d+\/newznab$/i.test(prefix); + target.pathname = completeEndpoint ? prefix : `${prefix}/api`; const merged = new URLSearchParams(base.search); for (const key of new Set(params.keys())) merged.delete(key);