fix(server): handle Prowlarr torznab endpoint responses

Preserve Prowlarr's REST feed route and accept diagnostic responses so a server URL reports a useful Torznab mismatch instead of an opaque 406.

Fixes #1244
This commit is contained in:
neon
2026-09-02 00:32:47 +00:00
parent 3f06d4eee9
commit 19afacb0f9
2 changed files with 27 additions and 3 deletions
@@ -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('<caps><server title="Prowlarr" /></caps>'));
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.
@@ -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/<id>/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/<id>/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);