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.
![]()
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:
| Field | Notes |
|---|---|
name | Display name, free text |
description | Optional one-liner shown under the name |
cost | Points required to purchase |
stock | null for unlimited; otherwise a hard cap |
effect | One of upload_credit / invite |
effect.args | Effect-specific payload (bytes / count) |
The two built-in effects:
upload_credit— addsargs.bytesto bothusers.uploadedandusers.bonusUploadedin one transaction. The user's ratio improves; thebonusUploadedsubset lets the UI label that the gain came from the shop, not from real seeding.invite— addsargs.counttousers.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:
| Rule | Curve |
|---|---|
| Time seeding | Flat rate per minute of active seeding (e.g. 0.1 pts/min/seed) |
| Per-torrent age | Older torrents pay more (rewards keeping the long tail alive) |
| Per-torrent rarity | Few 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_creditapply 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/usersbut 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:
| Column | Driven by |
|---|---|
users.uploaded | Every announce delta + any upload_credit shop purchase |
users.bonusUploaded | Only 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:
| Type | Payload | Effect |
|---|---|---|
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
| Field | Notes |
|---|---|
name | Shown on the shop card. |
description | Markdown body, rendered below the title. |
icon | Phosphor icon id (e.g. ph:gift-bold). Optional. |
cost | Whole points (same unit as users.bonus_points). |
stock | null = unlimited. When set, decrements per purchase and the item disappears from /shop at 0. |
enabled | Lets 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
| HTTP | Cause |
|---|---|
| 400 | Malformed payload (typically a stale admin-form submission). |
| 403 | Item exists but enabled = false (paused by admin). |
| 404 | Item id doesn't exist. |
| 409 | Stock hit zero between the listing and the buy click. |
| 402 | Not 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
| Concern | File |
|---|---|
| Shop catalogue + buy endpoint | apps/api/routes/api/shop/items.get.ts, apps/api/routes/api/shop/buy.post.ts |
| Earning rules CRUD | apps/api/routes/api/admin/seed-bonus-rules* |
| Admin shop CRUD | apps/api/routes/api/admin/shop* |
| Admin user bonus adjustment | apps/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 UI | apps/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.