A dead tracker is not the machine's failure (hq ADR 0187)

The step already left a found feed as found when the app refused to save it. An app can also save
it and then fail its own test — saving validates settings, the test runs a live search — and that
shape failed the whole apply. One dead public tracker stopped the home server converging for six
hours, and a clean apply gates the found firewall's retirement, so a tracker was holding a firewall
record hostage. What decides is whose entry it is: one the mesh only found is a notice, one the
operator listed is still a failure. All four copies of the step, and a test with the machine's own
message that models the app's two validations apart.
This commit is contained in:
2026-10-02 20:41:29 +02:00
parent 96fa0c8e60
commit 1a2f48492d
8 changed files with 256 additions and 28 deletions
+17 -4
View File
@@ -676,11 +676,24 @@ export async function reconcileIndexers(
const outcome = await reconcileEntry(
http, app, "indexer", what, e, feedConnection(ep, feed.id), feedDiffers(e, ep, feed.id), "apiKey", ep.credential, known,
);
if (outcome.result === "refused" && !settings.indexers.includes(feed.id) && /would not save it/.test(outcome.problem ?? "")) {
if (outcome.result === "refused" && !settings.indexers.includes(feed.id)) {
// An entry the mesh found and only re-pointed — not one it was told to make — that the app
// refuses because the feed behind it is gone (a public tracker that died, an indexer jackett
// no longer has). Nothing here can put that right, and failing the node's apply on every
// heartbeat for it reports the mesh as wrong about a tracker. Said, and left as found.
// cannot use because the feed behind it is gone (a public tracker that died, an indexer
// jackett no longer has). Nothing here can put that right, and failing the node's apply on
// every heartbeat for it reports the mesh as wrong about a tracker. Said, and left as found.
//
// **Both shapes of "the feed is gone", because the first cut only caught one.** An app may
// refuse to save such an entry, and it may save it and then fail its own test against the
// dead tracker — which is what a Servarr does with a jackett indexer that answers 400. The
// narrower condition let the second shape fail the apply: one dead public tracker stopped
// the home server converging for six hours, and with it everything a clean apply gates,
// the found firewall's retirement among it (novox/hq ADR 0187).
//
// What the mesh is answerable for is the plumbing: the entry exists, points at this mesh's
// jackett, and carries the delivered credential — which was checked against jackett before
// any of this was written. Whether a public tracker answers is not the mesh's to promise.
// An indexer the operator listed in downloads.jackett-api.indexers is theirs to insist on
// and still fails.
outcomes.push({ what, result: "notice", note: `left as found — ${outcome.problem}; remove the entry in ${app.module} or repair the feed in jackett, or list the indexer in downloads.jackett-api.indexers to make this a failure` });
continue;
}
+47 -3
View File
@@ -88,7 +88,7 @@ interface Call {
}
/** A Servarr app and the three providers behind one fetch. */
function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
function fakes(start: { clients?: Entry[]; indexers?: Entry[]; dead?: string[] }) {
const calls: Call[] = [];
const store: Record<string, Entry[]> = {
downloadclient: structuredClone(start.clients ?? []),
@@ -100,7 +100,7 @@ function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
const val = (e: Entry, n: string) => e.fields?.find((x) => x.name === n)?.value;
/** What the app's own test says: dialled from its container, with its stored secret for a mask. */
const appTest = (kind: string, e: Entry): { propertyName: string; errorMessage: string; isWarning: boolean }[] => {
const appTest = (kind: string, e: Entry, live = true): { propertyName: string; errorMessage: string; isWarning: boolean }[] => {
let secret = val(e, secretOf[kind]);
if (secret === MASK) secret = val(store[kind].find((s) => s.id === e.id) ?? {}, secretOf[kind]);
if (kind === "downloadclient") {
@@ -116,6 +116,13 @@ function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
if (secret !== jackett.secret) return [{ propertyName: "ApiKey", errorMessage: "Invalid API Key", isWarning: false }];
const id = /indexers\/([^/]+)\//.exec(String(val(e, "apiPath")))?.[1] ?? "";
if (!(id in jackett.configured!)) return [{ propertyName: "", errorMessage: "Unknown indexer", isWarning: false }];
if (live && (start.dead ?? []).includes(id)) {
// jackett has the indexer and the tracker behind it is gone. **Only a live query sees it**:
// saving an indexer validates its settings, and the app's own test runs a search — which is
// why the app saves such an entry and then fails its own test, the shape the home server hit
// on 2026-10-02. The message is that machine's, verbatim.
return [{ propertyName: "", errorMessage: `Unable to connect to indexer: HTTP request failed: [400:BadRequest] [GET] at [http://ace.internal:20204/api/v2.0/indexers/${id}/results/torznab?t=tvsearch]`, isWarning: false }];
}
return [];
};
@@ -164,7 +171,7 @@ function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
const e = body as Entry;
const on = e.enable === true || e.enableRss === true || e.enableAutomaticSearch === true;
if (on) {
const fails = appTest(kind, e);
const fails = appTest(kind, e, false);
if (fails.some((x) => !x.isWarning) || (fails.length && !force)) return reply(400, fails);
}
if (method === "POST" && !sub) {
@@ -363,3 +370,40 @@ test("the step is the same in sonarr, radarr, lidarr and bookshelf", () => {
}
}
});
test("a found feed whose tracker is dead is a notice, not the node's failure", async () => {
// The home server, 2026-10-02: jackett has `therarbg` configured and the tracker behind it
// answers 400. The app saves the entry and its own test then fails, which is a different shape
// from the app refusing to save — and the narrower condition let it fail the whole apply. One
// dead public tracker stopped that machine converging for six hours (novox/hq ADR 0187).
const dying = { ...jackett, configured: { ...jackett.configured!, therarbg: "TheRARBG" } };
const saved = jackett.configured;
jackett.configured = dying.configured;
try {
const f = fakes({
indexers: [feed(4, "Jackett - RARBG", "http://ace.internal:20204", "therarbg", "jackett-real")],
dead: ["therarbg"],
});
const configured = new Map(Object.entries(jackett.configured!));
const out = await reconcileIndexers(
f.http, APP, endpoint(jackett, "jackett-api"), configured, { adoptHosts: [], indexers: [] }, [], f.store.indexer,
);
assert.equal(out.length, 1);
assert.equal(out[0].result, "notice", `a dead tracker failed the apply: ${JSON.stringify(out[0])}`);
assert.match((out[0] as { note: string }).note, /left as found/);
assert.match((out[0] as { note: string }).note, /list the indexer in downloads\.jackett-api\.indexers to make this a failure/);
// And an indexer the operator listed is theirs to insist on: still a failure.
const g = fakes({
indexers: [feed(4, "Jackett - RARBG", "http://ace.internal:20204", "therarbg", "jackett-real")],
dead: ["therarbg"],
});
const insisted = await reconcileIndexers(
g.http, APP, endpoint(jackett, "jackett-api"), configured, { adoptHosts: [], indexers: ["therarbg"] }, [], g.store.indexer,
);
assert.equal(insisted[0].result, "refused");
} finally {
jackett.configured = saved;
}
});
+17 -4
View File
@@ -676,11 +676,24 @@ export async function reconcileIndexers(
const outcome = await reconcileEntry(
http, app, "indexer", what, e, feedConnection(ep, feed.id), feedDiffers(e, ep, feed.id), "apiKey", ep.credential, known,
);
if (outcome.result === "refused" && !settings.indexers.includes(feed.id) && /would not save it/.test(outcome.problem ?? "")) {
if (outcome.result === "refused" && !settings.indexers.includes(feed.id)) {
// An entry the mesh found and only re-pointed — not one it was told to make — that the app
// refuses because the feed behind it is gone (a public tracker that died, an indexer jackett
// no longer has). Nothing here can put that right, and failing the node's apply on every
// heartbeat for it reports the mesh as wrong about a tracker. Said, and left as found.
// cannot use because the feed behind it is gone (a public tracker that died, an indexer
// jackett no longer has). Nothing here can put that right, and failing the node's apply on
// every heartbeat for it reports the mesh as wrong about a tracker. Said, and left as found.
//
// **Both shapes of "the feed is gone", because the first cut only caught one.** An app may
// refuse to save such an entry, and it may save it and then fail its own test against the
// dead tracker — which is what a Servarr does with a jackett indexer that answers 400. The
// narrower condition let the second shape fail the apply: one dead public tracker stopped
// the home server converging for six hours, and with it everything a clean apply gates,
// the found firewall's retirement among it (novox/hq ADR 0187).
//
// What the mesh is answerable for is the plumbing: the entry exists, points at this mesh's
// jackett, and carries the delivered credential — which was checked against jackett before
// any of this was written. Whether a public tracker answers is not the mesh's to promise.
// An indexer the operator listed in downloads.jackett-api.indexers is theirs to insist on
// and still fails.
outcomes.push({ what, result: "notice", note: `left as found — ${outcome.problem}; remove the entry in ${app.module} or repair the feed in jackett, or list the indexer in downloads.jackett-api.indexers to make this a failure` });
continue;
}
+47 -3
View File
@@ -88,7 +88,7 @@ interface Call {
}
/** A Servarr app and the three providers behind one fetch. */
function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
function fakes(start: { clients?: Entry[]; indexers?: Entry[]; dead?: string[] }) {
const calls: Call[] = [];
const store: Record<string, Entry[]> = {
downloadclient: structuredClone(start.clients ?? []),
@@ -100,7 +100,7 @@ function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
const val = (e: Entry, n: string) => e.fields?.find((x) => x.name === n)?.value;
/** What the app's own test says: dialled from its container, with its stored secret for a mask. */
const appTest = (kind: string, e: Entry): { propertyName: string; errorMessage: string; isWarning: boolean }[] => {
const appTest = (kind: string, e: Entry, live = true): { propertyName: string; errorMessage: string; isWarning: boolean }[] => {
let secret = val(e, secretOf[kind]);
if (secret === MASK) secret = val(store[kind].find((s) => s.id === e.id) ?? {}, secretOf[kind]);
if (kind === "downloadclient") {
@@ -116,6 +116,13 @@ function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
if (secret !== jackett.secret) return [{ propertyName: "ApiKey", errorMessage: "Invalid API Key", isWarning: false }];
const id = /indexers\/([^/]+)\//.exec(String(val(e, "apiPath")))?.[1] ?? "";
if (!(id in jackett.configured!)) return [{ propertyName: "", errorMessage: "Unknown indexer", isWarning: false }];
if (live && (start.dead ?? []).includes(id)) {
// jackett has the indexer and the tracker behind it is gone. **Only a live query sees it**:
// saving an indexer validates its settings, and the app's own test runs a search — which is
// why the app saves such an entry and then fails its own test, the shape the home server hit
// on 2026-10-02. The message is that machine's, verbatim.
return [{ propertyName: "", errorMessage: `Unable to connect to indexer: HTTP request failed: [400:BadRequest] [GET] at [http://ace.internal:20204/api/v2.0/indexers/${id}/results/torznab?t=tvsearch]`, isWarning: false }];
}
return [];
};
@@ -164,7 +171,7 @@ function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
const e = body as Entry;
const on = e.enable === true || e.enableRss === true || e.enableAutomaticSearch === true;
if (on) {
const fails = appTest(kind, e);
const fails = appTest(kind, e, false);
if (fails.some((x) => !x.isWarning) || (fails.length && !force)) return reply(400, fails);
}
if (method === "POST" && !sub) {
@@ -363,3 +370,40 @@ test("the step is the same in sonarr, radarr, lidarr and bookshelf", () => {
}
}
});
test("a found feed whose tracker is dead is a notice, not the node's failure", async () => {
// The home server, 2026-10-02: jackett has `therarbg` configured and the tracker behind it
// answers 400. The app saves the entry and its own test then fails, which is a different shape
// from the app refusing to save — and the narrower condition let it fail the whole apply. One
// dead public tracker stopped that machine converging for six hours (novox/hq ADR 0187).
const dying = { ...jackett, configured: { ...jackett.configured!, therarbg: "TheRARBG" } };
const saved = jackett.configured;
jackett.configured = dying.configured;
try {
const f = fakes({
indexers: [feed(4, "Jackett - RARBG", "http://ace.internal:20204", "therarbg", "jackett-real")],
dead: ["therarbg"],
});
const configured = new Map(Object.entries(jackett.configured!));
const out = await reconcileIndexers(
f.http, APP, endpoint(jackett, "jackett-api"), configured, { adoptHosts: [], indexers: [] }, [], f.store.indexer,
);
assert.equal(out.length, 1);
assert.equal(out[0].result, "notice", `a dead tracker failed the apply: ${JSON.stringify(out[0])}`);
assert.match((out[0] as { note: string }).note, /left as found/);
assert.match((out[0] as { note: string }).note, /list the indexer in downloads\.jackett-api\.indexers to make this a failure/);
// And an indexer the operator listed is theirs to insist on: still a failure.
const g = fakes({
indexers: [feed(4, "Jackett - RARBG", "http://ace.internal:20204", "therarbg", "jackett-real")],
dead: ["therarbg"],
});
const insisted = await reconcileIndexers(
g.http, APP, endpoint(jackett, "jackett-api"), configured, { adoptHosts: [], indexers: ["therarbg"] }, [], g.store.indexer,
);
assert.equal(insisted[0].result, "refused");
} finally {
jackett.configured = saved;
}
});
+17 -4
View File
@@ -676,11 +676,24 @@ export async function reconcileIndexers(
const outcome = await reconcileEntry(
http, app, "indexer", what, e, feedConnection(ep, feed.id), feedDiffers(e, ep, feed.id), "apiKey", ep.credential, known,
);
if (outcome.result === "refused" && !settings.indexers.includes(feed.id) && /would not save it/.test(outcome.problem ?? "")) {
if (outcome.result === "refused" && !settings.indexers.includes(feed.id)) {
// An entry the mesh found and only re-pointed — not one it was told to make — that the app
// refuses because the feed behind it is gone (a public tracker that died, an indexer jackett
// no longer has). Nothing here can put that right, and failing the node's apply on every
// heartbeat for it reports the mesh as wrong about a tracker. Said, and left as found.
// cannot use because the feed behind it is gone (a public tracker that died, an indexer
// jackett no longer has). Nothing here can put that right, and failing the node's apply on
// every heartbeat for it reports the mesh as wrong about a tracker. Said, and left as found.
//
// **Both shapes of "the feed is gone", because the first cut only caught one.** An app may
// refuse to save such an entry, and it may save it and then fail its own test against the
// dead tracker — which is what a Servarr does with a jackett indexer that answers 400. The
// narrower condition let the second shape fail the apply: one dead public tracker stopped
// the home server converging for six hours, and with it everything a clean apply gates,
// the found firewall's retirement among it (novox/hq ADR 0187).
//
// What the mesh is answerable for is the plumbing: the entry exists, points at this mesh's
// jackett, and carries the delivered credential — which was checked against jackett before
// any of this was written. Whether a public tracker answers is not the mesh's to promise.
// An indexer the operator listed in downloads.jackett-api.indexers is theirs to insist on
// and still fails.
outcomes.push({ what, result: "notice", note: `left as found — ${outcome.problem}; remove the entry in ${app.module} or repair the feed in jackett, or list the indexer in downloads.jackett-api.indexers to make this a failure` });
continue;
}
+47 -3
View File
@@ -88,7 +88,7 @@ interface Call {
}
/** A Servarr app and the three providers behind one fetch. */
function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
function fakes(start: { clients?: Entry[]; indexers?: Entry[]; dead?: string[] }) {
const calls: Call[] = [];
const store: Record<string, Entry[]> = {
downloadclient: structuredClone(start.clients ?? []),
@@ -100,7 +100,7 @@ function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
const val = (e: Entry, n: string) => e.fields?.find((x) => x.name === n)?.value;
/** What the app's own test says: dialled from its container, with its stored secret for a mask. */
const appTest = (kind: string, e: Entry): { propertyName: string; errorMessage: string; isWarning: boolean }[] => {
const appTest = (kind: string, e: Entry, live = true): { propertyName: string; errorMessage: string; isWarning: boolean }[] => {
let secret = val(e, secretOf[kind]);
if (secret === MASK) secret = val(store[kind].find((s) => s.id === e.id) ?? {}, secretOf[kind]);
if (kind === "downloadclient") {
@@ -116,6 +116,13 @@ function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
if (secret !== jackett.secret) return [{ propertyName: "ApiKey", errorMessage: "Invalid API Key", isWarning: false }];
const id = /indexers\/([^/]+)\//.exec(String(val(e, "apiPath")))?.[1] ?? "";
if (!(id in jackett.configured!)) return [{ propertyName: "", errorMessage: "Unknown indexer", isWarning: false }];
if (live && (start.dead ?? []).includes(id)) {
// jackett has the indexer and the tracker behind it is gone. **Only a live query sees it**:
// saving an indexer validates its settings, and the app's own test runs a search — which is
// why the app saves such an entry and then fails its own test, the shape the home server hit
// on 2026-10-02. The message is that machine's, verbatim.
return [{ propertyName: "", errorMessage: `Unable to connect to indexer: HTTP request failed: [400:BadRequest] [GET] at [http://ace.internal:20204/api/v2.0/indexers/${id}/results/torznab?t=tvsearch]`, isWarning: false }];
}
return [];
};
@@ -164,7 +171,7 @@ function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
const e = body as Entry;
const on = e.enable === true || e.enableRss === true || e.enableAutomaticSearch === true;
if (on) {
const fails = appTest(kind, e);
const fails = appTest(kind, e, false);
if (fails.some((x) => !x.isWarning) || (fails.length && !force)) return reply(400, fails);
}
if (method === "POST" && !sub) {
@@ -363,3 +370,40 @@ test("the step is the same in sonarr, radarr, lidarr and bookshelf", () => {
}
}
});
test("a found feed whose tracker is dead is a notice, not the node's failure", async () => {
// The home server, 2026-10-02: jackett has `therarbg` configured and the tracker behind it
// answers 400. The app saves the entry and its own test then fails, which is a different shape
// from the app refusing to save — and the narrower condition let it fail the whole apply. One
// dead public tracker stopped that machine converging for six hours (novox/hq ADR 0187).
const dying = { ...jackett, configured: { ...jackett.configured!, therarbg: "TheRARBG" } };
const saved = jackett.configured;
jackett.configured = dying.configured;
try {
const f = fakes({
indexers: [feed(4, "Jackett - RARBG", "http://ace.internal:20204", "therarbg", "jackett-real")],
dead: ["therarbg"],
});
const configured = new Map(Object.entries(jackett.configured!));
const out = await reconcileIndexers(
f.http, APP, endpoint(jackett, "jackett-api"), configured, { adoptHosts: [], indexers: [] }, [], f.store.indexer,
);
assert.equal(out.length, 1);
assert.equal(out[0].result, "notice", `a dead tracker failed the apply: ${JSON.stringify(out[0])}`);
assert.match((out[0] as { note: string }).note, /left as found/);
assert.match((out[0] as { note: string }).note, /list the indexer in downloads\.jackett-api\.indexers to make this a failure/);
// And an indexer the operator listed is theirs to insist on: still a failure.
const g = fakes({
indexers: [feed(4, "Jackett - RARBG", "http://ace.internal:20204", "therarbg", "jackett-real")],
dead: ["therarbg"],
});
const insisted = await reconcileIndexers(
g.http, APP, endpoint(jackett, "jackett-api"), configured, { adoptHosts: [], indexers: ["therarbg"] }, [], g.store.indexer,
);
assert.equal(insisted[0].result, "refused");
} finally {
jackett.configured = saved;
}
});
+17 -4
View File
@@ -676,11 +676,24 @@ export async function reconcileIndexers(
const outcome = await reconcileEntry(
http, app, "indexer", what, e, feedConnection(ep, feed.id), feedDiffers(e, ep, feed.id), "apiKey", ep.credential, known,
);
if (outcome.result === "refused" && !settings.indexers.includes(feed.id) && /would not save it/.test(outcome.problem ?? "")) {
if (outcome.result === "refused" && !settings.indexers.includes(feed.id)) {
// An entry the mesh found and only re-pointed — not one it was told to make — that the app
// refuses because the feed behind it is gone (a public tracker that died, an indexer jackett
// no longer has). Nothing here can put that right, and failing the node's apply on every
// heartbeat for it reports the mesh as wrong about a tracker. Said, and left as found.
// cannot use because the feed behind it is gone (a public tracker that died, an indexer
// jackett no longer has). Nothing here can put that right, and failing the node's apply on
// every heartbeat for it reports the mesh as wrong about a tracker. Said, and left as found.
//
// **Both shapes of "the feed is gone", because the first cut only caught one.** An app may
// refuse to save such an entry, and it may save it and then fail its own test against the
// dead tracker — which is what a Servarr does with a jackett indexer that answers 400. The
// narrower condition let the second shape fail the apply: one dead public tracker stopped
// the home server converging for six hours, and with it everything a clean apply gates,
// the found firewall's retirement among it (novox/hq ADR 0187).
//
// What the mesh is answerable for is the plumbing: the entry exists, points at this mesh's
// jackett, and carries the delivered credential — which was checked against jackett before
// any of this was written. Whether a public tracker answers is not the mesh's to promise.
// An indexer the operator listed in downloads.jackett-api.indexers is theirs to insist on
// and still fails.
outcomes.push({ what, result: "notice", note: `left as found — ${outcome.problem}; remove the entry in ${app.module} or repair the feed in jackett, or list the indexer in downloads.jackett-api.indexers to make this a failure` });
continue;
}
+47 -3
View File
@@ -88,7 +88,7 @@ interface Call {
}
/** A Servarr app and the three providers behind one fetch. */
function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
function fakes(start: { clients?: Entry[]; indexers?: Entry[]; dead?: string[] }) {
const calls: Call[] = [];
const store: Record<string, Entry[]> = {
downloadclient: structuredClone(start.clients ?? []),
@@ -100,7 +100,7 @@ function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
const val = (e: Entry, n: string) => e.fields?.find((x) => x.name === n)?.value;
/** What the app's own test says: dialled from its container, with its stored secret for a mask. */
const appTest = (kind: string, e: Entry): { propertyName: string; errorMessage: string; isWarning: boolean }[] => {
const appTest = (kind: string, e: Entry, live = true): { propertyName: string; errorMessage: string; isWarning: boolean }[] => {
let secret = val(e, secretOf[kind]);
if (secret === MASK) secret = val(store[kind].find((s) => s.id === e.id) ?? {}, secretOf[kind]);
if (kind === "downloadclient") {
@@ -116,6 +116,13 @@ function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
if (secret !== jackett.secret) return [{ propertyName: "ApiKey", errorMessage: "Invalid API Key", isWarning: false }];
const id = /indexers\/([^/]+)\//.exec(String(val(e, "apiPath")))?.[1] ?? "";
if (!(id in jackett.configured!)) return [{ propertyName: "", errorMessage: "Unknown indexer", isWarning: false }];
if (live && (start.dead ?? []).includes(id)) {
// jackett has the indexer and the tracker behind it is gone. **Only a live query sees it**:
// saving an indexer validates its settings, and the app's own test runs a search — which is
// why the app saves such an entry and then fails its own test, the shape the home server hit
// on 2026-10-02. The message is that machine's, verbatim.
return [{ propertyName: "", errorMessage: `Unable to connect to indexer: HTTP request failed: [400:BadRequest] [GET] at [http://ace.internal:20204/api/v2.0/indexers/${id}/results/torznab?t=tvsearch]`, isWarning: false }];
}
return [];
};
@@ -164,7 +171,7 @@ function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
const e = body as Entry;
const on = e.enable === true || e.enableRss === true || e.enableAutomaticSearch === true;
if (on) {
const fails = appTest(kind, e);
const fails = appTest(kind, e, false);
if (fails.some((x) => !x.isWarning) || (fails.length && !force)) return reply(400, fails);
}
if (method === "POST" && !sub) {
@@ -363,3 +370,40 @@ test("the step is the same in sonarr, radarr, lidarr and bookshelf", () => {
}
}
});
test("a found feed whose tracker is dead is a notice, not the node's failure", async () => {
// The home server, 2026-10-02: jackett has `therarbg` configured and the tracker behind it
// answers 400. The app saves the entry and its own test then fails, which is a different shape
// from the app refusing to save — and the narrower condition let it fail the whole apply. One
// dead public tracker stopped that machine converging for six hours (novox/hq ADR 0187).
const dying = { ...jackett, configured: { ...jackett.configured!, therarbg: "TheRARBG" } };
const saved = jackett.configured;
jackett.configured = dying.configured;
try {
const f = fakes({
indexers: [feed(4, "Jackett - RARBG", "http://ace.internal:20204", "therarbg", "jackett-real")],
dead: ["therarbg"],
});
const configured = new Map(Object.entries(jackett.configured!));
const out = await reconcileIndexers(
f.http, APP, endpoint(jackett, "jackett-api"), configured, { adoptHosts: [], indexers: [] }, [], f.store.indexer,
);
assert.equal(out.length, 1);
assert.equal(out[0].result, "notice", `a dead tracker failed the apply: ${JSON.stringify(out[0])}`);
assert.match((out[0] as { note: string }).note, /left as found/);
assert.match((out[0] as { note: string }).note, /list the indexer in downloads\.jackett-api\.indexers to make this a failure/);
// And an indexer the operator listed is theirs to insist on: still a failure.
const g = fakes({
indexers: [feed(4, "Jackett - RARBG", "http://ace.internal:20204", "therarbg", "jackett-real")],
dead: ["therarbg"],
});
const insisted = await reconcileIndexers(
g.http, APP, endpoint(jackett, "jackett-api"), configured, { adoptHosts: [], indexers: ["therarbg"] }, [], g.store.indexer,
);
assert.equal(insisted[0].result, "refused");
} finally {
jackett.configured = saved;
}
});