Skip to content

Seed bonus economy ​

Trackarr ships a seed-bonus economy: users earn points for seeding, spend them in a shop on upload-credit and invites, and admins can grant or revoke points by hand. Distinct from bonus events (which scale users.uploaded deltas globally during a window), the bonus economy is per-user, persistent, and operator-tunable end-to-end.

The bonus shop — upload-credit and invitation items priced in points, with the member's reserve on the right

What users see ​

/me — Bonus reserve ​

The profile page surfaces a compact wallet readout in the hero KPI strip (Uploaded · Ratio · Downloaded · Released), plus a dedicated bonus row that shows the running balance with an animated counter. The display is deliberately understated — large enough to read, small enough not to shove an admin metric into the user's face every page load.

The Uploaded KPI splits into "real seeded" + "(incl. X bonus)" so the user can see at a glance how much of their lifetime upload came from shop purchases vs. real seeding. Same data shape as the admin user registry uses.

/shop — Bonus shop ​

Operator-curated catalogue of items. Each item has:

FieldNotes
nameDisplay name, free text
descriptionOptional one-liner shown under the name
costPoints required to purchase
stocknull for unlimited; otherwise a hard cap
effectOne of upload_credit / invite
effect.argsEffect-specific payload (bytes / count)

The two built-in effects:

  • upload_credit — adds args.bytes to both users.uploaded and users.bonusUploaded in one transaction. The user's ratio improves; the bonusUploaded subset lets the UI label that the gain came from the shop, not from real seeding.
  • invite — adds args.count to users.invitesRemaining. Useful when registration is invite-only and the operator wants seeded users to bring in more.

Buying is gated on cost <= bonusPoints and on stock; the buy endpoint runs everything inside a single transaction so a concurrent purchase can't drop the balance into negative territory or sell the last copy twice.

How points are earned ​

The earning rules are fully customisable from /admin/seed-bonus-rules. Each active rule contributes to a per-minute rate:

points/minute  ←  Σ active rules
                       per-user-context (ratio, age, seed count, torrent age, …)

Out of the box, three rule presets cover the classics:

RuleCurve
Time seedingFlat rate per minute of active seeding (e.g. 0.1 pts/min/seed)
Per-torrent ageOlder torrents pay more (rewards keeping the long tail alive)
Per-torrent rarityFew seeds → higher per-minute rate (rewards filling rare swarms)

Tiered curves are a first-class concept: every rule carries an array of {threshold, multiplier} tiers so the operator can write something like "first 5 active seeds × 1.0, next 5 × 1.5, beyond × 2.0" without writing custom code. The curves render as live previews in the admin UI so an operator can see the shape before saving.

Granting happens off the announce path, in the API's bonus collector (apps/api/plugins/bonus-collector.ts): once an hour (BONUS_COLLECTION_INTERVAL), under a cross-replica lock, it walks the swarms in Redis, credits each member for the torrents they seed, and records one-shot grants in a ledger so a rerun never pays twice. A seeder counts only while it is still announcing — seen within the last hour, the tracker's own notion of "in the swarm". A peer entry lives 24 h in Redis, and before that check a single left=0 announce a day on a torrent nobody seeds was paid as a full day of seeding, rarity and first-seeder multipliers included.

Admin levers ​

/admin/seed-bonus-rules — Edit, enable/disable, and re-order earning rules. Each row carries:

  • A live curve preview.
  • The expected pts/minute for a "median" user (computed from current swarm stats).
  • A toggle to disable the rule without deleting it.

/admin/shop — CRUD on shop items. Adding a new effect type is a purely SQL-driven affair: insert the item with effect.type = 'foo', extend the buy endpoint's effect dispatcher, and it shows up in the storefront immediately.

/admin/users — The bonus column shows the current balance and the icon turns accent when the user has any positive points. Clicking opens the Adjust bonus modal: add or remove an integer amount with a free-text reason that's persisted in the audit log. Useful for:

  • Compensating users after a tracker outage.
  • Refunding shop purchases that triggered an upload_credit apply failure (rare; the buy path is transactional but the on-disk apply has retried writes).
  • Removing points after detecting cheating.

Adjust bonus is admin-only — moderators can see the registry on /mod/users but the bonus column hides its action button there to keep the surface focused on moderation.

Real upload vs. bonus upload ​

Two separate columns track upload:

ColumnDriven by
users.uploadedEvery announce delta + any upload_credit shop purchase
users.bonusUploadedOnly the upload_credit shop purchase delta

The starter upload credit a new user gets at registration also lands in bonusUploaded so the /me Uploaded KPI shows X — (incl. Y bonus) from day zero. There's a one-shot backfill heuristic for users who registered before this split landed: if users.uploaded == settings.starter_upload, we infer the entire balance came from the starter and stamp bonusUploaded to match.

The split is informational — ratio and HnR enforcement still operate on users.uploaded alone. There's no concept of "real ratio"; the distinction exists for the user's own situational awareness.

The shop ​

Users spend bonus_points from /shop. The catalogue lives in shop_items and is curated from /admin/shop; every purchase writes a row in shop_purchases so the operator has a full audit ledger.

Item types ​

The type column is a closed enum — adding a type is a three-step contract in apps/api/utils/shop.ts (extend the union, add a Zod payload schema, add the handler). Today two are shipped:

TypePayloadEffect
upload_credit{ bytes: integer ≤ 10 TiB }Adds bytes to both users.uploaded and users.bonusUploaded. Pure ratio relief.
invite{ count: integer ≤ 10 }Bumps users.invites_remaining by count slots.

The per-purchase caps (10 TiB, 10 invites) are hard-coded — they exist so a typo in /admin/shop can't gift petabyte-scale ratio or hundreds of invite slots. If you need a bigger bundle, create a second item.

Catalogue fields ​

FieldNotes
nameShown on the shop card.
descriptionMarkdown body, rendered below the title.
iconPhosphor icon id (e.g. ph:gift-bold). Optional.
costWhole points (same unit as users.bonus_points).
stocknull = unlimited. When set, decrements per purchase and the item disappears from /shop at 0.
enabledLets an admin pause an item without losing its config.

Purchase flow ​

              /shop                              POST /api/shop/buy
   ┌─────────────────────────┐                  ┌──────────────────┐
   │ user browses items      │ ───── buy ─────► │ purchaseItem()   │
   │ /api/shop/items         │                  │ (utils/shop.ts)  │
   └─────────────────────────┘                  └────────┬─────────┘
                                                         │
                                                         ▼
                                              ┌────────────────────┐
                                              │ DB transaction:    │
                                              │  - FOR UPDATE row  │
                                              │    lock on item    │
                                              │  - FOR UPDATE row  │
                                              │    lock on user    │
                                              │  - check balance   │
                                              │  - check stock     │
                                              │  - decrement both  │
                                              │  - apply effect    │
                                              │  - insert row in   │
                                              │    shop_purchases  │
                                              └────────────────────┘

The FOR UPDATE locks make race conditions on stock or balance impossible. POST /api/shop/buy is rate-limited as a mutation, so a hijacked session can't churn the ledger faster than the bucket allows.

Errors ​

HTTPCause
400Malformed payload (typically a stale admin-form submission).
403Item exists but enabled = false (paused by admin).
404Item id doesn't exist.
409Stock hit zero between the listing and the buy click.
402Not enough bonus_points.

Every error returns the post-balance + a structured reason so the FE can render a precise toast.

Audit ledger (shop_purchases) ​

Each purchase row preserves a snapshot of the item at buy time — cost, name, payload — so renaming or reconfiguring an item later doesn't rewrite history. Useful for refund flows or compliance questions: an admin can always answer "what did this user actually spend their points on?" from the ledger alone.

Implementation reference ​

ConcernFile
Shop catalogue + buy endpointapps/api/routes/api/shop/items.get.ts, apps/api/routes/api/shop/buy.post.ts
Earning rules CRUDapps/api/routes/api/admin/seed-bonus-rules*
Admin shop CRUDapps/api/routes/api/admin/shop*
Admin user bonus adjustmentapps/api/routes/api/admin/users/[id]/bonus.post.ts
Tracker-side accumulator (announce hot path)apps/tracker/internal/server/handler.go (seed-time recorder)
Bonus point flush (Redis → Postgres)apps/api/redis/seed-bonus-flush.ts (periodic)
User-facing UIapps/web/app/pages/me.vue, apps/web/app/pages/shop.vue

For the announce-time multipliers (Freeleech / Silverleech / custom), see Bonus events. The two systems are independent — a user can earn rate-limited points while a global Freeleech window also doubles their uploaded deltas — and the announce code applies both in the same atomic block so an event boundary can never desync from a bonus accumulator tick.

Released under the MIT License.