Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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": {
Expand Down
9 changes: 5 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

91 changes: 91 additions & 0 deletions src/lib/__tests__/tracker-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
checkBonusCapReached,
checkBufferMilestoneCrossed,
checkHnrIncrease,
checkHnrSustained,
checkRankChange,
checkRatioBelowMinimum,
checkRatioBelowMinimumTransition,
Expand Down Expand Up @@ -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", () => {
Expand Down
12 changes: 10 additions & 2 deletions src/lib/notifications/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
checkBufferMilestoneCrossed,
checkDownloadDisabled,
checkHnrIncrease,
checkHnrSustained,
checkRankChange,
checkRatioBelowMinimumTransition,
checkRatioDelta,
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions src/lib/notifications/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -109,6 +114,7 @@ export function parseThresholds(raw: unknown): NotificationThresholds {
...(typeof r.unsatisfiedLimitPercent === "number"
? { unsatisfiedLimitPercent: r.unsatisfiedLimitPercent }
: {}),
...(typeof r.hnrSustainedPolls === "number" ? { hnrSustainedPolls: r.hnrSustainedPolls } : {}),
}
}

Expand Down
63 changes: 63 additions & 0 deletions src/lib/tracker-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 11 additions & 3 deletions src/lib/tracker-scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading