Skip to content

Hit-and-Run (HnR)

A hit-and-run is a user who downloads a torrent and stops seeding before the operator-defined seed-time threshold. Trackarr enforces HnR at the per-(user, torrent) level so the consequences land on the responsible account, not the operator's mood.

Concepts

TermMeaning
Required seed timeHow long (seconds) a user must seed a given torrent before HnR is cleared. Default 86400 (24 h). Operator-configurable from /admin/settings.
Grace periodWindow after downloadedAt during which a not-yet-completed download is not flagged. Configurable in /admin/settings.
CompletedA row reached seedTime >= requiredSeedTime. completedAt is set; isHnr is forced to false and never re-flips.
ExemptModerator manually waives the requirement. Engine ignores the row from that point onward.
Flagged (isHnr = true)The grace period expired, the user still hadn't met the requirement. Auto-clearable by completing or by moderator action.

Data model — hnr_tracking

One row per (user, torrent). The unique index hnr_user_torrent_idx guarantees idempotency.

ColumnNotes
userIdFK → users (cascade delete).
torrentIdFK → torrents (cascade delete).
downloadedAtWhen the user first either clicked Download or completed the torrent.
seedTimeAccumulated seconds seeded (incremented by the tracker on every announce).
requiredSeedTimeSnapshot of the threshold at insert time. Lets the operator change the global default without retroactively re-graded existing rows.
isHnrtrue once the grace window expired without completion.
isExemptManual moderator waiver.
completedAtSet when seedTime >= requiredSeedTime. Final state for that row.
uploaded, downloadedPer-(user, torrent) byte counters maintained by the tracker. Drives the /downloads history page.

Lifecycle

                     POST /api/torrents/:hash/download


                          recordDownloadClick()
                          (ON CONFLICT DO NOTHING)


                       ┌─────────────────────────┐
                       │ row inserted            │
                       │  isHnr     = false      │
                       │  completedAt = null     │
                       └────────────┬────────────┘

                ┌───────────────────┼───────────────────┐
                │                   │                   │
                ▼                   ▼                   ▼
   tracker announce         grace expired,       moderator action
   updates seedTime         not yet completed     `exempt` / `clear`
                │                   │                   │
                ▼                   ▼                   ▼
        seedTime ≥ required?   checkAndMarkHnrs    isExempt = true
                │              flips isHnr=true    OR isHnr=false
                ▼              + notify             + completedAt=now
        completedAt = now
        isHnr = false (final)

Where the seed-time delta comes from

The Go tracker's announce handler diffs uploaded / downloaded against the previous peer entry on every announce and persists them into hnr_tracking in the same transaction it updates users.uploaded / users.downloaded. A separate ticker calls updateSeedTime(user, torrent, secondsSeeded) to bump seedTime. Exempt or completed rows short-circuit early so no further state churn happens on them.

The previous-announce snapshot lives in Redis with a 24 h TTL by default, configurable via TRACKER_PEER_TTL (Go duration string — 24h, 90m, 7200s; values below 15m are clamped to that floor). Two cases used to silently zero the delta:

  • First announce ever for a given (peer_id, info_hash) pair — Redis has no prev, so a naive diff produced 0.
  • Gap longer than the TTL — same effect, the peer hash expired between announces.

To recover those bytes, when the very first announce for a peer carries event=started, the tracker trusts the client's declared cumulative uploaded / downloaded as the initial delta. That covers honest clients that resume a session or rejoin after sleep without losing what they already pushed. The existing 1 TiB/announce cap bounds the worst-case spoofing window, and a started announce is still rate-limited like any other.

When rows get flagged

checkAndMarkHnrs() runs from the API plugin loop. On each pass:

  1. It reads the current grace period from /admin/settings (getHnrGracePeriod).
  2. It selects every row where isHnr = false, isExempt = false, completedAt IS NULL, and downloadedAt < now - graceWindow.
  3. It flips those rows to isHnr = true in a single UPDATE … RETURNING.
  4. For each flipped row it fans out a hnr_violation_marked notification to the user (resolving the torrent name so the inbox row is meaningful).

This means the user is always notified the moment they cross the line — they don't have to discover the flag by checking their profile.

What gets flagged user-visibly

SurfaceNotes
/downloadsPersonal history of every torrent the user clicked Download on. HnR rows are colour-rail flagged with a countdown ("⏳ 4h 12m remaining" before grace expiry) or the active flag once expired.
/meHero KPI strip carries the user's current HnR count.
/users/[id]Public profile shows the same KPI to other members.
/mod/hnrModerator queue (filterable by status: open / completed / exempt).
Notificationhnr_violation_marked → goes through the user's notification routing.

Moderator actions

The queue at /mod/hnr exposes two actions per row:

ActionEffect
ExemptSets isExempt = true. Future seed-time updates skip the row; the flag never re-fires.
ClearSets isHnr = false and completedAt = now, the same final state a legitimate completion reaches. Use this for one-off forgiveness.

Both end up in hnr_user_is_hnr_idx-indexed queries the same way — the audit columns (completedAt, isExempt) tell the two paths apart.

Auto-role interactions

roleRules.ts exposes hnrCount (current isHnr = true rows) and completedSeeds (rows with completedAt IS NOT NULL) as auto-assignment fields. Common patterns:

  • "Member" → "Power User" when completedSeeds ≥ 50 AND hnrCount = 0.
  • "Member" → "Probation" when hnrCount ≥ 3.

When a moderator exempts or clears a flag, the affected user's roles are not auto-recomputed by the action itself — admins can trigger a sweep from /admin/roles (POST /api/admin/roles/recompute).

See Roles & Permissions.

Disabling HnR

Set the feature flag to off in /admin/settings:

  • New downloads stop inserting hnr_tracking rows via createHnrEntry().
  • Existing rows are kept, but checkAndMarkHnrs() becomes a no-op (returns 0 immediately).
  • The recordDownloadClick() path still records a row, because the Downloads page is a personal log — it shouldn't vanish when the operator switches enforcement off.

Bonus interactions

The seed-bonus accumulator reads seedTime from the same table to credit seeding_milestone grants (24 h / 1 week / 30 d thresholds). HnR enforcement and bonus crediting share their seedTime source-of-truth — there's no risk of "rewarded for seeding but still flagged" inconsistencies.

See Seed Bonus.

API surface

MethodPathAuthNotes
GET/api/users/hnruserCurrent user's flagged rows (used by the bell badge fallback).
GET/api/admin/hnrmodQueue. Filters: `status=open
PUT/api/admin/hnr/:idmodBody: `{ action: 'exempt'
GET/api/me/downloadsuserPer-torrent history with HnR state inline.

Implementation reference

ConcernFile
Schemapackages/db/src/schema.ts (hnr_tracking)
HnR logic (insert / update / mark)apps/api/utils/hnr.ts
Tracker-side delta updatesapps/tracker/internal/server/handler.go (announce path)
Mark-as-HnR sweepercalled from the API plugin loop (checkAndMarkHnrs)
Moderator queueapps/api/routes/api/admin/hnr/*, apps/web/app/pages/mod/hnr.vue
User downloads pageapps/web/app/pages/downloads.vue

Released under the MIT License.