Skip to content

Commit fb6a03c

Browse files
committed
refactor(tracker-rules): remove default rule feature
1 parent ade7a15 commit fb6a03c

8 files changed

Lines changed: 93 additions & 84 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ A fast, modern web interface for qBittorrent. Supports managing multiple qBittor
3636
- **OIDC Single Sign-On**: Authenticate through your OpenID Connect provider
3737
- **External Programs**: Launch custom scripts from the torrent context menu ([guide](internal/api/handlers/EXTERNAL_PROGRAMS.md))
3838
- **Tracker Reannounce**: Automatically fix stalled torrents when qBittorrent doesn't retry fast enough ([info](internal/services/reannounce/REANNOUNCE.md))
39-
- **Tracker Rules**: Apply per-tracker speed limits, ratio caps, and seeding time limits automatically
39+
- **Tracker Rules**: Apply per-tracker speed limits, ratio caps, and seeding time limits automatically ([info](internal/services/trackerrules/TRACKER_RULES.md))
4040

4141

4242

internal/api/handlers/tracker_rules.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ type TrackerRulePayload struct {
4141
DownloadLimitKiB *int64 `json:"downloadLimitKiB"`
4242
RatioLimit *float64 `json:"ratioLimit"`
4343
SeedingTimeLimitMinutes *int64 `json:"seedingTimeLimitMinutes"`
44-
IsDefault bool `json:"isDefault"`
4544
Enabled *bool `json:"enabled"`
4645
SortOrder *int `json:"sortOrder"`
4746
}
@@ -65,7 +64,6 @@ func (p *TrackerRulePayload) toModel(instanceID int, id int) *models.TrackerRule
6564
DownloadLimitKiB: p.DownloadLimitKiB,
6665
RatioLimit: p.RatioLimit,
6766
SeedingTimeLimitMinutes: p.SeedingTimeLimitMinutes,
68-
IsDefault: p.IsDefault,
6967
Enabled: true,
7068
}
7169
if p.Enabled != nil {
@@ -110,8 +108,8 @@ func (h *TrackerRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
110108
return
111109
}
112110

113-
if len(normalizeTrackerDomains(payload.TrackerDomains)) == 0 && strings.TrimSpace(payload.TrackerPattern) == "" && !payload.IsDefault {
114-
RespondError(w, http.StatusBadRequest, "Select at least one tracker (or mark as default)")
111+
if len(normalizeTrackerDomains(payload.TrackerDomains)) == 0 && strings.TrimSpace(payload.TrackerPattern) == "" {
112+
RespondError(w, http.StatusBadRequest, "Select at least one tracker")
115113
return
116114
}
117115

@@ -149,8 +147,8 @@ func (h *TrackerRuleHandler) Update(w http.ResponseWriter, r *http.Request) {
149147
return
150148
}
151149

152-
if len(normalizeTrackerDomains(payload.TrackerDomains)) == 0 && strings.TrimSpace(payload.TrackerPattern) == "" && !payload.IsDefault {
153-
RespondError(w, http.StatusBadRequest, "Select at least one tracker (or mark as default)")
150+
if len(normalizeTrackerDomains(payload.TrackerDomains)) == 0 && strings.TrimSpace(payload.TrackerPattern) == "" {
151+
RespondError(w, http.StatusBadRequest, "Select at least one tracker")
154152
return
155153
}
156154

internal/database/migrations/019_add_tracker_rules.sql

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,13 @@ CREATE TABLE IF NOT EXISTS tracker_rules (
1212
download_limit_kib INTEGER,
1313
ratio_limit REAL,
1414
seeding_time_limit_minutes INTEGER,
15-
is_default INTEGER NOT NULL DEFAULT 0,
1615
enabled INTEGER NOT NULL DEFAULT 1,
1716
sort_order INTEGER NOT NULL DEFAULT 0,
1817
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
1918
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2019
);
2120

2221
CREATE INDEX IF NOT EXISTS idx_tracker_rules_instance ON tracker_rules(instance_id, sort_order, id);
23-
CREATE INDEX IF NOT EXISTS idx_tracker_rules_default ON tracker_rules(instance_id, is_default);
2422

2523
CREATE TRIGGER IF NOT EXISTS trg_tracker_rules_updated
2624
AFTER UPDATE ON tracker_rules

internal/models/tracker_rule.go

Lines changed: 8 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ type TrackerRule struct {
2525
DownloadLimitKiB *int64 `json:"downloadLimitKiB,omitempty"`
2626
RatioLimit *float64 `json:"ratioLimit,omitempty"`
2727
SeedingTimeLimitMinutes *int64 `json:"seedingTimeLimitMinutes,omitempty"`
28-
IsDefault bool `json:"isDefault"`
2928
Enabled bool `json:"enabled"`
3029
SortOrder int `json:"sortOrder"`
3130
CreatedAt time.Time `json:"createdAt"`
@@ -83,7 +82,7 @@ func normalizeTrackerPattern(pattern string, domains []string) string {
8382
func (s *TrackerRuleStore) ListByInstance(ctx context.Context, instanceID int) ([]*TrackerRule, error) {
8483
rows, err := s.db.QueryContext(ctx, `
8584
SELECT id, instance_id, name, tracker_pattern, category, tag, upload_limit_kib, download_limit_kib,
86-
ratio_limit, seeding_time_limit_minutes, is_default, enabled, sort_order, created_at, updated_at
85+
ratio_limit, seeding_time_limit_minutes, enabled, sort_order, created_at, updated_at
8786
FROM tracker_rules
8887
WHERE instance_id = ?
8988
ORDER BY sort_order ASC, id ASC
@@ -112,7 +111,6 @@ func (s *TrackerRuleStore) ListByInstance(ctx context.Context, instanceID int) (
112111
&download,
113112
&ratio,
114113
&seeding,
115-
&rule.IsDefault,
116114
&rule.Enabled,
117115
&rule.SortOrder,
118116
&rule.CreatedAt,
@@ -151,7 +149,7 @@ func (s *TrackerRuleStore) ListByInstance(ctx context.Context, instanceID int) (
151149
func (s *TrackerRuleStore) Get(ctx context.Context, id int) (*TrackerRule, error) {
152150
row := s.db.QueryRowContext(ctx, `
153151
SELECT id, instance_id, name, tracker_pattern, category, tag, upload_limit_kib, download_limit_kib,
154-
ratio_limit, seeding_time_limit_minutes, is_default, enabled, sort_order, created_at, updated_at
152+
ratio_limit, seeding_time_limit_minutes, enabled, sort_order, created_at, updated_at
155153
FROM tracker_rules
156154
WHERE id = ?
157155
`, id)
@@ -173,7 +171,6 @@ func (s *TrackerRuleStore) Get(ctx context.Context, id int) (*TrackerRule, error
173171
&download,
174172
&ratio,
175173
&seeding,
176-
&rule.IsDefault,
177174
&rule.Enabled,
178175
&rule.SortOrder,
179176
&rule.CreatedAt,
@@ -231,20 +228,14 @@ func (s *TrackerRuleStore) Create(ctx context.Context, rule *TrackerRule) (*Trac
231228
sortOrder = next
232229
}
233230

234-
if rule.IsDefault {
235-
if err := s.clearDefault(ctx, rule.InstanceID); err != nil {
236-
return nil, err
237-
}
238-
}
239-
240231
res, err := s.db.ExecContext(ctx, `
241232
INSERT INTO tracker_rules
242-
(instance_id, name, tracker_pattern, category, tag, upload_limit_kib, download_limit_kib, ratio_limit, seeding_time_limit_minutes, is_default, enabled, sort_order)
233+
(instance_id, name, tracker_pattern, category, tag, upload_limit_kib, download_limit_kib, ratio_limit, seeding_time_limit_minutes, enabled, sort_order)
243234
VALUES
244-
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
235+
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
245236
`, rule.InstanceID, rule.Name, rule.TrackerPattern, nullableString(rule.Category), nullableString(rule.Tag),
246237
nullableInt64(rule.UploadLimitKiB), nullableInt64(rule.DownloadLimitKiB), nullableFloat64(rule.RatioLimit),
247-
nullableInt64(rule.SeedingTimeLimitMinutes), boolToInt(rule.IsDefault), boolToInt(rule.Enabled), sortOrder)
238+
nullableInt64(rule.SeedingTimeLimitMinutes), boolToInt(rule.Enabled), sortOrder)
248239
if err != nil {
249240
return nil, err
250241
}
@@ -262,32 +253,16 @@ func (s *TrackerRuleStore) Update(ctx context.Context, rule *TrackerRule) (*Trac
262253
return nil, errors.New("rule is nil")
263254
}
264255

265-
tx, err := s.db.BeginTx(ctx, nil)
266-
if err != nil {
267-
return nil, err
268-
}
269-
defer tx.Rollback()
270-
271256
rule.TrackerPattern = normalizeTrackerPattern(rule.TrackerPattern, rule.TrackerDomains)
272257

273-
if rule.IsDefault {
274-
if err := s.clearDefaultTx(ctx, tx, rule.InstanceID); err != nil {
275-
return nil, err
276-
}
277-
}
278-
279-
if _, err := tx.ExecContext(ctx, `
258+
if _, err := s.db.ExecContext(ctx, `
280259
UPDATE tracker_rules
281260
SET name = ?, tracker_pattern = ?, category = ?, tag = ?, upload_limit_kib = ?, download_limit_kib = ?,
282-
ratio_limit = ?, seeding_time_limit_minutes = ?, is_default = ?, enabled = ?, sort_order = ?
261+
ratio_limit = ?, seeding_time_limit_minutes = ?, enabled = ?, sort_order = ?
283262
WHERE id = ? AND instance_id = ?
284263
`, rule.Name, rule.TrackerPattern, nullableString(rule.Category), nullableString(rule.Tag),
285264
nullableInt64(rule.UploadLimitKiB), nullableInt64(rule.DownloadLimitKiB), nullableFloat64(rule.RatioLimit),
286-
nullableInt64(rule.SeedingTimeLimitMinutes), boolToInt(rule.IsDefault), boolToInt(rule.Enabled), rule.SortOrder, rule.ID, rule.InstanceID); err != nil {
287-
return nil, err
288-
}
289-
290-
if err := tx.Commit(); err != nil {
265+
nullableInt64(rule.SeedingTimeLimitMinutes), boolToInt(rule.Enabled), rule.SortOrder, rule.ID, rule.InstanceID); err != nil {
291266
return nil, err
292267
}
293268

@@ -321,19 +296,6 @@ func (s *TrackerRuleStore) Reorder(ctx context.Context, instanceID int, orderedI
321296
return tx.Commit()
322297
}
323298

324-
func (s *TrackerRuleStore) clearDefault(ctx context.Context, instanceID int) error {
325-
_, err := s.db.ExecContext(ctx, `UPDATE tracker_rules SET is_default = 0 WHERE instance_id = ?`, instanceID)
326-
return err
327-
}
328-
329-
func (s *TrackerRuleStore) clearDefaultTx(ctx context.Context, tx dbinterface.TxQuerier, instanceID int) error {
330-
if tx == nil {
331-
return errors.New("transaction is nil")
332-
}
333-
_, err := tx.ExecContext(ctx, `UPDATE tracker_rules SET is_default = 0 WHERE instance_id = ?`, instanceID)
334-
return err
335-
}
336-
337299
func nullableString(value *string) any {
338300
if value == nil {
339301
return nil
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Tracker Rules
2+
3+
Tracker Rules automatically apply speed limits, ratio caps, and seeding time limits to torrents based on their tracker domain.
4+
5+
## How Rules Work
6+
7+
Rules are evaluated in **sort order** (first match wins). Each rule can match torrents by:
8+
9+
1. **Tracker domain** (required) - The tracker's hostname
10+
2. **Category** (optional) - The torrent's category in qBittorrent
11+
3. **Tag** (optional) - A tag assigned to the torrent
12+
13+
Torrents that don't match any rule are left untouched. Disabled rules are skipped entirely.
14+
15+
## Settings Applied
16+
17+
When a rule matches a torrent, it can apply any combination of:
18+
19+
| Setting | Description |
20+
|---------|-------------|
21+
| Upload limit | Maximum upload speed (KiB/s) |
22+
| Download limit | Maximum download speed (KiB/s) |
23+
| Ratio limit | Stop seeding when this ratio is reached |
24+
| Seeding time limit | Stop seeding after this many minutes |
25+
26+
## When Rules Run
27+
28+
Rules are applied in two ways:
29+
30+
- **Automatically** - A background service scans all torrents every 20 seconds
31+
- **Manually** - Click "Apply Now" in the UI to trigger immediately
32+
33+
To avoid hammering qBittorrent, the same torrent won't be re-processed within 2 minutes (debouncing).
34+
35+
## Matching Logic
36+
37+
### Domain Patterns
38+
39+
Tracker domains can be matched in three ways:
40+
41+
| Pattern | Example | Matches |
42+
|---------|---------|---------|
43+
| Exact | `tracker.example.com` | Only `tracker.example.com` |
44+
| Glob | `*.example.com` | `sub.example.com`, `tracker.example.com` |
45+
| Suffix | `.example.com` | `example.com`, `sub.example.com` |
46+
47+
### Multiple Patterns
48+
49+
Separate multiple patterns with commas, semicolons, or pipes:
50+
51+
```
52+
tracker1.com,tracker2.org|tracker3.net
53+
```
54+
55+
All matching is **case-insensitive**.
56+
57+
## Important Behavior
58+
59+
### Rules Only Set Values
60+
61+
Rules apply settings to torrents - they **do not revert** settings when the rule is disabled or deleted. If you disable a rule that set upload limit to 1000 KiB/s, affected torrents keep that limit until you manually change it or another rule applies a different value.
62+
63+
### Efficient Updates
64+
65+
The service only sends API calls to qBittorrent when the torrent's current setting differs from what the rule specifies. If a torrent already has the correct limits, it's skipped.
66+
67+
### Existing vs New Torrents
68+
69+
- **Existing torrents** - Processed on the next scan cycle (within 20 seconds)
70+
- **New torrents** - Picked up automatically within 20 seconds of appearing in qBittorrent
71+
72+
### Batched API Calls
73+
74+
To handle large torrent collections efficiently, torrents are grouped by setting value and sent to qBittorrent in batches of up to 150 hashes per API call.

internal/services/trackerrules/service.go

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,9 @@ func selectRule(torrent qbt.Torrent, rules []*models.TrackerRule, sm *qbittorren
256256
trackerDomains := collectTrackerDomains(torrent, sm)
257257

258258
for _, rule := range rules {
259+
if !rule.Enabled {
260+
continue
261+
}
259262
if !matchesTracker(rule.TrackerPattern, trackerDomains) {
260263
continue
261264
}
@@ -272,12 +275,6 @@ func selectRule(torrent qbt.Torrent, rules []*models.TrackerRule, sm *qbittorren
272275
return rule
273276
}
274277

275-
for _, rule := range rules {
276-
if rule.IsDefault {
277-
return rule
278-
}
279-
}
280-
281278
return nil
282279
}
283280

web/src/components/instances/preferences/TrackerRulesPanel.tsx

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ const emptyFormState: FormState = {
4444
downloadLimitKiB: undefined,
4545
ratioLimit: undefined,
4646
seedingTimeLimitMinutes: undefined,
47-
isDefault: false,
4847
enabled: true,
4948
}
5049

@@ -148,7 +147,6 @@ export function TrackerRulesPanel({ instanceId }: TrackerRulesPanelProps) {
148147
downloadLimitKiB: rule.downloadLimitKiB,
149148
ratioLimit: rule.ratioLimit,
150149
seedingTimeLimitMinutes: rule.seedingTimeLimitMinutes,
151-
isDefault: rule.isDefault,
152150
enabled: rule.enabled,
153151
sortOrder: rule.sortOrder,
154152
})
@@ -175,8 +173,8 @@ export function TrackerRulesPanel({ instanceId }: TrackerRulesPanelProps) {
175173
return
176174
}
177175
const selectedTrackers = formState.trackerDomains.filter(Boolean)
178-
if (!formState.isDefault && selectedTrackers.length === 0) {
179-
toast.error("Select at least one tracker or mark as default")
176+
if (selectedTrackers.length === 0) {
177+
toast.error("Select at least one tracker")
180178
return
181179
}
182180
const payload: FormState = {
@@ -278,11 +276,6 @@ export function TrackerRulesPanel({ instanceId }: TrackerRulesPanelProps) {
278276
className="shrink-0"
279277
/>
280278
<span className={cn("font-medium truncate", !rule.enabled && "text-muted-foreground")}>{rule.name}</span>
281-
{rule.isDefault && (
282-
<Badge variant="secondary" className="shrink-0">
283-
Default
284-
</Badge>
285-
)}
286279
{!rule.enabled && (
287280
<Badge variant="outline" className="shrink-0 text-muted-foreground">
288281
Disabled
@@ -416,7 +409,7 @@ export function TrackerRulesPanel({ instanceId }: TrackerRulesPanelProps) {
416409
</div>
417410
</div>
418411

419-
<div className="grid gap-4 sm:grid-cols-2">
412+
<div className="grid gap-4 sm:grid-cols-1">
420413
<div className="flex items-center justify-between rounded-lg border p-3">
421414
<div>
422415
<Label htmlFor="rule-enabled">Enabled</Label>
@@ -428,17 +421,6 @@ export function TrackerRulesPanel({ instanceId }: TrackerRulesPanelProps) {
428421
onCheckedChange={(checked) => setFormState(prev => ({ ...prev, enabled: checked }))}
429422
/>
430423
</div>
431-
<div className="flex items-center justify-between rounded-lg border p-3">
432-
<div>
433-
<Label htmlFor="rule-default">Default rule</Label>
434-
<p className="text-sm text-muted-foreground">Applies when no other rule matches.</p>
435-
</div>
436-
<Switch
437-
id="rule-default"
438-
checked={formState.isDefault ?? false}
439-
onCheckedChange={(checked) => setFormState(prev => ({ ...prev, isDefault: checked }))}
440-
/>
441-
</div>
442424
</div>
443425

444426
<DialogFooter>

web/src/types/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,6 @@ export interface TrackerRule {
9797
downloadLimitKiB?: number
9898
ratioLimit?: number
9999
seedingTimeLimitMinutes?: number
100-
isDefault: boolean
101100
enabled: boolean
102101
sortOrder: number
103102
createdAt?: string
@@ -114,7 +113,6 @@ export interface TrackerRuleInput {
114113
downloadLimitKiB?: number
115114
ratioLimit?: number
116115
seedingTimeLimitMinutes?: number
117-
isDefault?: boolean
118116
enabled?: boolean
119117
sortOrder?: number
120118
}

0 commit comments

Comments
 (0)