From 4b82a38d29b7356fa44089b912be2a19f3475ded Mon Sep 17 00:00:00 2001 From: Patrick Dundas Date: Sat, 8 Aug 2026 21:36:23 -0600 Subject: [PATCH 1/2] feat(alerts): require an HnR rise to persist before notifying TorrentLeech publishes a LIVE "not currently satisfying" counter rather than a permanent strike record. Stale tracker-side leech records age out through it, so the count blips 0 -> 1 -> 0 with nothing actually wrong. checkHnrIncrease fires on every blip, and every one of them is a false alarm. Measured on 11 days of hourly TL polls: four separate blips, runs of 5, 4, 2 and 4 polls, all self-cleared. checkHnrSustained requires an increase to hold for N consecutive polls (default 6 - the smallest value that suppresses all four) and fires exactly once, on the poll where the run completes. A genuine hit-and-run is a recorded penalty that never clears, so the only cost is a few hours of notice on something already irreversible. - N is configurable per target via thresholds.hnrSustainedPolls, clamped to HNR_SUSTAINED_POLLS_MAX so an over-large value cannot silently never fire. - The scheduler now loads HNR_HISTORY_POLLS snapshots instead of one; only the HnR check reads the extra rows. - Callers that supply no history keep the original single-step behaviour. --- package.json | 2 +- src/lib/__tests__/tracker-events.test.ts | 91 ++++++++++++++++++++++++ src/lib/notifications/dispatch.ts | 12 +++- src/lib/notifications/types.ts | 6 ++ src/lib/tracker-events.ts | 63 ++++++++++++++++ src/lib/tracker-scheduler.ts | 14 +++- 6 files changed, 182 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index a4e4f148..638528fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "private-tracker-tracker", - "version": "2.8.9-homelab.4", + "version": "2.8.9-homelab.5", "description": "Self-hosted dashboard for monitoring private tracker stats over time", "license": "GPL-3.0", "repository": { diff --git a/src/lib/__tests__/tracker-events.test.ts b/src/lib/__tests__/tracker-events.test.ts index 7561e23c..14a9e6c8 100644 --- a/src/lib/__tests__/tracker-events.test.ts +++ b/src/lib/__tests__/tracker-events.test.ts @@ -8,6 +8,7 @@ import { checkBonusCapReached, checkBufferMilestoneCrossed, checkHnrIncrease, + checkHnrSustained, checkRankChange, checkRatioBelowMinimum, checkRatioBelowMinimumTransition, @@ -144,6 +145,96 @@ describe("checkHnrIncrease", () => { }) }) +describe("checkHnrSustained", () => { + // history is newest-first and includes the current value: [current, prev, prev-1, ...] + + it("fires on the poll where a 3-poll run completes", () => { + expect(checkHnrSustained([1, 1, 1, 0], 3)).toBe(true) + }) + + it("does not fire before the run is long enough", () => { + expect(checkHnrSustained([1, 0, 0, 0], 3)).toBe(false) + expect(checkHnrSustained([1, 1, 0, 0], 3)).toBe(false) + }) + + it("suppresses a transient blip that already cleared", () => { + // the TorrentLeech pattern: 0 -> 1 -> 0 with nothing actually wrong + expect(checkHnrSustained([0, 1, 0, 0], 3)).toBe(false) + expect(checkHnrSustained([0, 1, 1, 0], 3)).toBe(false) + }) + + it("fires only once for a sustained elevation", () => { + expect(checkHnrSustained([1, 1, 1, 0], 3)).toBe(true) // run reaches 3 — fire + expect(checkHnrSustained([1, 1, 1, 1], 3)).toBe(false) // still elevated — stay quiet + expect(checkHnrSustained([1, 1, 1, 1, 0], 3)).toBe(false) + }) + + it("fires again when the count climbs to a new level and holds", () => { + expect(checkHnrSustained([2, 2, 2, 1], 3)).toBe(true) + }) + + it("does not fire when a further increase has not yet held", () => { + expect(checkHnrSustained([2, 1, 1, 1], 3)).toBe(false) + }) + + it("still fires when the count keeps climbing during the run", () => { + expect(checkHnrSustained([3, 2, 1, 0], 3)).toBe(true) + }) + + it("requires a full window — no firing on short history", () => { + expect(checkHnrSustained([1, 1, 1], 3)).toBe(false) + expect(checkHnrSustained([], 3)).toBe(false) + }) + + it("refuses to guess through null samples", () => { + expect(checkHnrSustained([1, 1, null, 0], 3)).toBe(false) + expect(checkHnrSustained([1, 1, 1, null], 3)).toBe(false) + }) + + it("defaults to a 6-poll run", () => { + expect(checkHnrSustained([1, 1, 1, 1, 1, 1, 0])).toBe(true) + expect(checkHnrSustained([1, 1, 1, 1, 1, 0, 0])).toBe(false) + }) + + it("supports an immediate (1-poll) gate", () => { + expect(checkHnrSustained([1, 0], 1)).toBe(true) + expect(checkHnrSustained([0, 0], 1)).toBe(false) + }) + + it("clamps an out-of-range run rather than never firing", () => { + const longRun = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0] + expect(checkHnrSustained(longRun, 999)).toBe(true) // clamped to MAX (12) + expect(checkHnrSustained([1, 0], 0)).toBe(true) // clamped up to 1 + expect(checkHnrSustained([1, 1, 1, 1, 1, 1, 0], Number.NaN)).toBe(true) // default + }) + + it("stays silent across every real TorrentLeech flicker", () => { + // Verbatim hourly hit_and_runs from tracker_snapshots, 2026-07-29 -> 08-09. + // Four separate blips (runs of 5, 4, 2, 4), each self-cleared. None is a real strike. + const runs = [5, 4, 2, 4] + for (const runLength of runs) { + const series = [ + ...Array(10).fill(0), + ...Array(runLength).fill(1), + ...Array(10).fill(0), + ] as number[] + // Walk the series poll by poll, newest-first window at each step. + for (let i = 0; i < series.length; i++) { + const history = series.slice(0, i + 1).reverse() + expect(checkHnrSustained(history)).toBe(false) + } + } + }) + + it("still catches a strike that does not clear", () => { + const series = [...Array(10).fill(0), ...Array(10).fill(1)] as number[] + const fired = series + .map((_, i) => checkHnrSustained(series.slice(0, i + 1).reverse())) + .filter(Boolean) + expect(fired).toHaveLength(1) // exactly one alert, once the run reaches 6 + }) +}) + describe("checkBufferMilestoneCrossed", () => { const milestone = 10737418240n // 10 GiB it("returns true when crossing threshold upward", () => { diff --git a/src/lib/notifications/dispatch.ts b/src/lib/notifications/dispatch.ts index 689c8397..2b633e3d 100644 --- a/src/lib/notifications/dispatch.ts +++ b/src/lib/notifications/dispatch.ts @@ -30,6 +30,7 @@ import { checkBufferMilestoneCrossed, checkDownloadDisabled, checkHnrIncrease, + checkHnrSustained, checkRankChange, checkRatioBelowMinimumTransition, checkRatioDelta, @@ -232,8 +233,15 @@ export function detectEvents( events.push("ratio_drop") } - if (target.notifyHitAndRun && checkHnrIncrease(ctx.previousHnrs, ctx.currentHnrs)) { - events.push("hit_and_run") + // Prefer the debounced check when the caller supplied poll history — it suppresses the + // transient 0->1->0 blips that live "currently unsatisfied" counters emit (see + // checkHnrSustained). Callers without history keep the original single-step behaviour. + if (target.notifyHitAndRun) { + const hnrFired = + ctx.recentHnrs && ctx.recentHnrs.length > 0 + ? checkHnrSustained(ctx.recentHnrs, thresholds.hnrSustainedPolls) + : checkHnrIncrease(ctx.previousHnrs, ctx.currentHnrs) + if (hnrFired) events.push("hit_and_run") } if (target.notifyTrackerDown && ctx.trackerDown) { diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 8156acd2..554351f0 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -28,6 +28,7 @@ export interface NotificationThresholds { bonusCapLimit?: number // default 99999, used for MAM specifically vipExpiringDays?: number // default 7 unsatisfiedLimitPercent?: number // default 80 + hnrSustainedPolls?: number // default 3 — consecutive polls an HnR rise must hold before notifying } export interface DiscordConfig { @@ -68,6 +69,10 @@ export interface SnapshotContext { previousRatio: number | null currentHnrs: number | null previousHnrs: number | null + // Newest-first HnR samples INCLUDING the current one: [current, prev, prev-1, ...]. + // Drives the sustained-increase gate; omitted by callers that have no history to offer + // (they fall back to the single-step previousHnrs comparison). + recentHnrs?: (number | null)[] currentBufferBytes: bigint | null previousBufferBytes: bigint | null trackerDown: boolean @@ -109,6 +114,7 @@ export function parseThresholds(raw: unknown): NotificationThresholds { ...(typeof r.unsatisfiedLimitPercent === "number" ? { unsatisfiedLimitPercent: r.unsatisfiedLimitPercent } : {}), + ...(typeof r.hnrSustainedPolls === "number" ? { hnrSustainedPolls: r.hnrSustainedPolls } : {}), } } diff --git a/src/lib/tracker-events.ts b/src/lib/tracker-events.ts index 5a0b070a..048c280d 100644 --- a/src/lib/tracker-events.ts +++ b/src/lib/tracker-events.ts @@ -81,6 +81,69 @@ export function checkHnrIncrease(previousHnrs: number | null, currentHnrs: numbe return currentHnrs > previousHnrs } +/** + * Default number of consecutive polls an HnR increase must survive before it notifies. + * + * Sized from measurement, not taste: across 11 days of hourly TorrentLeech polls the counter + * blipped to 1 four separate times, for runs of 5, 4, 2 and 4 polls, self-clearing every time. + * 6 is the smallest value that suppresses all of them. A genuine hit-and-run is a recorded + * penalty that never clears, so the only cost of waiting is a few hours of notice on something + * already irreversible. + */ +export const HNR_SUSTAINED_POLLS_DEFAULT = 6 + +/** + * Upper bound on the configurable poll run. The scheduler fetches HNR_SUSTAINED_POLLS_MAX + 1 + * snapshots, so a larger threshold could never be satisfied — checkHnrSustained clamps to this + * rather than silently never firing, which is the failure mode that would look like "no HnRs". + */ +export const HNR_SUSTAINED_POLLS_MAX = 12 + +/** How many prior snapshots the scheduler must load to satisfy HNR_SUSTAINED_POLLS_MAX. */ +export const HNR_HISTORY_POLLS = HNR_SUSTAINED_POLLS_MAX + 1 + +/** + * Debounced variant of checkHnrIncrease: fires only once an increase has HELD for + * `requiredPolls` consecutive polls. + * + * Some trackers (confirmed on TorrentLeech) publish a LIVE "not currently satisfying" + * counter rather than a permanent strike record. Stale tracker-side leech records age + * out through it, so the count blips 0 -> 1 -> 0 over a few hours with nothing actually + * wrong. checkHnrIncrease fires on every one of those blips. A real hit-and-run is a + * recorded penalty and never clears, so requiring persistence separates the two without + * risking a missed strike — it only delays the alert by `requiredPolls` poll intervals. + * + * `history` is newest-first and INCLUDES the current value: [current, prev, prev-1, ...]. + * + * Fires exactly once per increase, on the poll where the run reaches `requiredPolls`: + * the increase must sit at index requiredPolls-1 (against the value immediately before + * it), and every newer sample must have held at or above that raised level. A later poll + * shifts the increase past that index and stops matching, so an elevated-but-flat counter + * does not re-notify. + */ +export function checkHnrSustained( + history: (number | null)[], + requiredPolls: number = HNR_SUSTAINED_POLLS_DEFAULT +): boolean { + const requested = Number.isFinite(requiredPolls) + ? Math.floor(requiredPolls) + : HNR_SUSTAINED_POLLS_DEFAULT + const n = Math.min(HNR_SUSTAINED_POLLS_MAX, Math.max(1, requested)) + + // Need the n samples of the run plus the one before it to prove an increase happened. + if (history.length < n + 1) return false + + const window = history.slice(0, n + 1) + if (window.some((v) => v === null || v === undefined)) return false + const values = window as number[] + + const raised = values[n - 1] // oldest sample of the candidate run + const baseline = values[n] // the sample immediately before the run + + if (raised <= baseline) return false // no increase at that offset + return values.slice(0, n - 1).every((v) => v >= raised) // and it held ever since +} + export function checkBufferMilestoneCrossed( currentBufferBytes: bigint | null, previousBufferBytes: bigint | null, diff --git a/src/lib/tracker-scheduler.ts b/src/lib/tracker-scheduler.ts index 84b6cdc4..884d37aa 100644 --- a/src/lib/tracker-scheduler.ts +++ b/src/lib/tracker-scheduler.ts @@ -33,6 +33,7 @@ import { log } from "@/lib/logger" import { dispatchNotifications } from "@/lib/notifications/dispatch" import { maskUsername } from "@/lib/privacy" import { recordDatabaseSize } from "@/lib/server-data" +import { HNR_HISTORY_POLLS } from "@/lib/tracker-events" import { getPauseState } from "@/lib/tracker-status" import { buildProxyAgentFromSettings } from "@/lib/tunnel" @@ -237,8 +238,12 @@ export async function pollTracker( await db.update(trackers).set(metaUpdates).where(eq(trackers.id, tracker.id)) } - // Fetch previous snapshot before inserting the new one (used for change detection in notifications) - const [previousSnapshot] = await db + // Fetch previous snapshots before inserting the new one (used for change detection in + // notifications). More than one row is pulled so the hit-and-run check can require an + // increase to persist across several polls instead of firing on a single blip; see + // checkHnrSustained. Only hitAndRuns uses the extra rows — every other comparison is + // still against previousSnapshot alone. + const previousSnapshots = await db .select({ ratio: trackerSnapshots.ratio, hitAndRuns: trackerSnapshots.hitAndRuns, @@ -251,7 +256,9 @@ export async function pollTracker( .from(trackerSnapshots) .where(eq(trackerSnapshots.trackerId, tracker.id)) .orderBy(desc(trackerSnapshots.polledAt)) - .limit(1) + .limit(HNR_HISTORY_POLLS) + + const [previousSnapshot] = previousSnapshots await db.insert(trackerSnapshots).values({ trackerId: tracker.id, @@ -330,6 +337,7 @@ export async function pollTracker( previousRatio: previousSnapshot?.ratio ?? null, currentHnrs: stats.hitAndRuns, previousHnrs: previousSnapshot?.hitAndRuns ?? null, + recentHnrs: [stats.hitAndRuns, ...previousSnapshots.map((s) => s.hitAndRuns)], currentBufferBytes: stats.bufferBytes, previousBufferBytes: previousSnapshot?.bufferBytes ?? null, trackerDown: false, From b64fea2132f22d8ef6dd733d581a7d649da479c0 Mon Sep 17 00:00:00 2001 From: Patrick Dundas Date: Sat, 8 Aug 2026 21:42:47 -0600 Subject: [PATCH 2/2] fix(deps): override nanoid to the patched 3.x line Trivy flagged CVE-2026-67213 (HIGH) against nanoid 3.3.16, reached transitively through postcss, which is itself pulled in by next, @tailwindcss/postcss and vite. The image build gates on the scan, so this blocked release. Pinned to ^3.3.17 rather than the >=3.3.17 that a naive read of the advisory suggests: the open range resolves to nanoid 6.x, which is a major version away from the ^3.3.11 postcss actually asks for. Staying inside 3.x takes the fix without swapping a scanner finding for a runtime break. Unrelated to the HnR work on this branch; it surfaced because this is the first build since the advisory landed. --- package.json | 3 ++- pnpm-lock.yaml | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 638528fe..24dbcea6 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "postcss": "^8.5.24", "sharp": "^0.35.3", "undici": "^7.28.0", - "vite": "^7.3.6" + "vite": "^7.3.6", + "nanoid": "^3.3.17" } }, "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82191a2d..30ebe29a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,7 @@ overrides: sharp: ^0.35.3 undici: ^7.28.0 vite: ^7.3.6 + nanoid: ^3.3.17 importers: @@ -2797,8 +2798,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -6097,7 +6098,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.16: {} + nanoid@3.3.18: {} neo-async@2.6.2: {} @@ -6370,7 +6371,7 @@ snapshots: postcss@8.5.24: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1