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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user