Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d339da4
feat(tracker-rules): add per-tracker limits and services page
s0up4200 Nov 22, 2025
8d2b3f3
fix test
s0up4200 Nov 22, 2025
c9a6fb9
fix(services): harden tracker rule updates, reannounce debounce, and …
s0up4200 Nov 22, 2025
8baf753
Merge branch 'main' into feat/tracker-rules-services-page
s0up4200 Nov 24, 2025
3e7f5c8
fix(reannounce): use consistent debounce window for cooldown checks
s0up4200 Nov 24, 2025
d3ed6db
Merge branch 'main' into feat/tracker-rules-services-page
s0up4200 Nov 24, 2025
fad9e4f
fix(docs): clarify reannounce and tracker rules
s0up4200 Nov 24, 2025
6afda8b
typo
s0up4200 Nov 24, 2025
d8f8f3f
typo
s0up4200 Nov 24, 2025
38e6f18
docs(reannounce): update quick start and activity log instructions fo…
s0up4200 Nov 24, 2025
120d3f4
Merge branch 'main' into feat/tracker-rules-services-page
s0up4200 Nov 25, 2025
a1391ef
fix(web): preserve connection status when updating instance in cache …
s0up4200 Nov 25, 2025
bd8b4bb
fix(web): indent subcategories in MultiSelect category options
s0up4200 Nov 25, 2025
2669fff
fix(reannounce): enforce InitialWaitSeconds filtering on monitored to…
s0up4200 Nov 25, 2025
82f0b17
fix(web): stop preserving stale connectionStatus in instance cache up…
s0up4200 Nov 25, 2025
ade7a15
feat(tracker-rules): add enabled field to allow disabling rules witho…
s0up4200 Nov 25, 2025
fb6a03c
refactor(tracker-rules): remove default rule feature
s0up4200 Nov 25, 2025
5d8192b
fix(tracker-rules): prevent cross-instance data leak in Update
s0up4200 Nov 25, 2025
16cf1f0
fix(models): check rows.Err() after iteration in TrackerRuleStore.List
s0up4200 Nov 25, 2025
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ A fast, modern web interface for qBittorrent. Supports managing multiple qBittor
- **Base URL Support**: Serve from a subdirectory (e.g., `/qui/`) for reverse proxy setups
- **OIDC Single Sign-On**: Authenticate through your OpenID Connect provider
- **External Programs**: Launch custom scripts from the torrent context menu ([guide](internal/api/handlers/EXTERNAL_PROGRAMS.md))
- **Tracker Reannounce**: Proactively reannounce torrents with unregistered or error status ([guide](internal/services/reannounce/REANNOUNCE.md))
- **Tracker Reannounce**: Automatically fix stalled torrents when qBittorrent doesn't retry fast enough ([info](internal/services/reannounce/REANNOUNCE.md))
- **Tracker Rules**: Apply per-tracker speed limits, ratio caps, and seeding time limits automatically ([info](internal/services/trackerrules/TRACKER_RULES.md))



Expand Down
10 changes: 10 additions & 0 deletions cmd/qui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import (
"github.com/autobrr/qui/internal/services/license"
"github.com/autobrr/qui/internal/services/reannounce"
"github.com/autobrr/qui/internal/services/trackericons"
"github.com/autobrr/qui/internal/services/trackerrules"
"github.com/autobrr/qui/internal/update"
"github.com/autobrr/qui/pkg/sqlite3store"
)
Expand Down Expand Up @@ -473,6 +474,8 @@ func (app *Application) runServer() {
log.Warn().Err(err).Msg("Failed to preload reannounce settings cache")
}

trackerRuleStore := models.NewTrackerRuleStore(db)

clientAPIKeyStore := models.NewClientAPIKeyStore(db)
externalProgramStore := models.NewExternalProgramStore(db)
errorStore := models.NewInstanceErrorStore(db)
Expand Down Expand Up @@ -540,6 +543,7 @@ func (app *Application) runServer() {
crossSeedStore := models.NewCrossSeedStore(db)
crossSeedService := crossseed.NewService(instanceStore, syncManager, filesManagerService, crossSeedStore, jackettService, externalProgramStore)
reannounceService := reannounce.NewService(reannounce.DefaultConfig(), instanceStore, instanceReannounceStore, reannounceSettingsCache, clientPool, syncManager)
trackerRuleService := trackerrules.NewService(trackerrules.DefaultConfig(), instanceStore, trackerRuleStore, syncManager)

syncManager.SetTorrentCompletionHandler(crossSeedService.HandleTorrentCompletion)

Expand All @@ -553,6 +557,10 @@ func (app *Application) runServer() {
defer reannounceCancel()
reannounceService.Start(reannounceCtx)

trackerRulesCtx, trackerRulesCancel := context.WithCancel(context.Background())
defer trackerRulesCancel()
trackerRuleService.Start(trackerRulesCtx)

backupStore := models.NewBackupStore(db)
backupService := backups.NewService(backupStore, syncManager, backups.Config{DataDir: cfg.GetDataDir()})
backupService.Start(context.Background())
Expand Down Expand Up @@ -636,6 +644,8 @@ func (app *Application) runServer() {
CrossSeedService: crossSeedService,
JackettService: jackettService,
TorznabIndexerStore: torznabIndexerStore,
TrackerRuleStore: trackerRuleStore,
TrackerRuleService: trackerRuleService,
})

errorChannel := make(chan error)
Expand Down
272 changes: 272 additions & 0 deletions internal/api/handlers/tracker_rules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
// Copyright (c) 2025, s0up and the autobrr contributors.
// SPDX-License-Identifier: GPL-2.0-or-later

package handlers

import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"

"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"

"github.com/autobrr/qui/internal/models"
"github.com/autobrr/qui/internal/services/trackerrules"
)

type TrackerRuleHandler struct {
store *models.TrackerRuleStore
service *trackerrules.Service
}

func NewTrackerRuleHandler(store *models.TrackerRuleStore, service *trackerrules.Service) *TrackerRuleHandler {
return &TrackerRuleHandler{
store: store,
service: service,
}
}

type TrackerRulePayload struct {
Name string `json:"name"`
TrackerPattern string `json:"trackerPattern"`
TrackerDomains []string `json:"trackerDomains"`
Category *string `json:"category"`
Tag *string `json:"tag"`
UploadLimitKiB *int64 `json:"uploadLimitKiB"`
DownloadLimitKiB *int64 `json:"downloadLimitKiB"`
RatioLimit *float64 `json:"ratioLimit"`
SeedingTimeLimitMinutes *int64 `json:"seedingTimeLimitMinutes"`
Enabled *bool `json:"enabled"`
SortOrder *int `json:"sortOrder"`
}

func (p *TrackerRulePayload) toModel(instanceID int, id int) *models.TrackerRule {
normalizedDomains := normalizeTrackerDomains(p.TrackerDomains)
trackerPattern := p.TrackerPattern
if len(normalizedDomains) > 0 {
trackerPattern = strings.Join(normalizedDomains, ",")
}

rule := &models.TrackerRule{
ID: id,
InstanceID: instanceID,
Name: p.Name,
TrackerPattern: trackerPattern,
TrackerDomains: normalizedDomains,
Category: cleanStringPtr(p.Category),
Tag: cleanStringPtr(p.Tag),
UploadLimitKiB: p.UploadLimitKiB,
DownloadLimitKiB: p.DownloadLimitKiB,
RatioLimit: p.RatioLimit,
SeedingTimeLimitMinutes: p.SeedingTimeLimitMinutes,
Enabled: true,
}
if p.Enabled != nil {
rule.Enabled = *p.Enabled
}
if p.SortOrder != nil {
rule.SortOrder = *p.SortOrder
}
return rule
}

func (h *TrackerRuleHandler) List(w http.ResponseWriter, r *http.Request) {
instanceID, err := parseInstanceID(w, r)
if err != nil {
return
}

rules, err := h.store.ListByInstance(r.Context(), instanceID)
if err != nil {
log.Error().Err(err).Int("instanceID", instanceID).Msg("failed to list tracker rules")
RespondError(w, http.StatusInternalServerError, "Failed to load tracker rules")
return
}

RespondJSON(w, http.StatusOK, rules)
}

func (h *TrackerRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
instanceID, err := parseInstanceID(w, r)
if err != nil {
return
}

var payload TrackerRulePayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
RespondError(w, http.StatusBadRequest, "Invalid request payload")
return
}

if payload.Name == "" {
RespondError(w, http.StatusBadRequest, "Name is required")
return
}

if len(normalizeTrackerDomains(payload.TrackerDomains)) == 0 && strings.TrimSpace(payload.TrackerPattern) == "" {
RespondError(w, http.StatusBadRequest, "Select at least one tracker")
return
}

rule, err := h.store.Create(r.Context(), payload.toModel(instanceID, 0))
if err != nil {
log.Error().Err(err).Int("instanceID", instanceID).Msg("failed to create tracker rule")
RespondError(w, http.StatusInternalServerError, "Failed to create tracker rule")
return
}

RespondJSON(w, http.StatusCreated, rule)
}

func (h *TrackerRuleHandler) Update(w http.ResponseWriter, r *http.Request) {
instanceID, err := parseInstanceID(w, r)
if err != nil {
return
}

ruleIDStr := chi.URLParam(r, "ruleID")
ruleID, err := strconv.Atoi(ruleIDStr)
if err != nil || ruleID <= 0 {
RespondError(w, http.StatusBadRequest, "Invalid rule ID")
return
}

var payload TrackerRulePayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
RespondError(w, http.StatusBadRequest, "Invalid request payload")
return
}

if payload.Name == "" {
RespondError(w, http.StatusBadRequest, "Name is required")
return
}

if len(normalizeTrackerDomains(payload.TrackerDomains)) == 0 && strings.TrimSpace(payload.TrackerPattern) == "" {
RespondError(w, http.StatusBadRequest, "Select at least one tracker")
return
}

rule, err := h.store.Update(r.Context(), payload.toModel(instanceID, ruleID))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
log.Error().Err(err).Int("instanceID", instanceID).Int("ruleID", ruleID).Msg("tracker rule not found for update")
RespondError(w, http.StatusNotFound, "Tracker rule not found")
return
}
log.Error().Err(err).Int("instanceID", instanceID).Int("ruleID", ruleID).Msg("failed to update tracker rule")
RespondError(w, http.StatusInternalServerError, "Failed to update tracker rule")
return
}

RespondJSON(w, http.StatusOK, rule)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func (h *TrackerRuleHandler) Delete(w http.ResponseWriter, r *http.Request) {
instanceID, err := parseInstanceID(w, r)
if err != nil {
return
}

ruleIDStr := chi.URLParam(r, "ruleID")
ruleID, err := strconv.Atoi(ruleIDStr)
if err != nil || ruleID <= 0 {
RespondError(w, http.StatusBadRequest, "Invalid rule ID")
return
}

if err := h.store.Delete(r.Context(), instanceID, ruleID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, http.StatusNotFound, "Tracker rule not found")
return
}
log.Error().Err(err).Int("instanceID", instanceID).Int("ruleID", ruleID).Msg("failed to delete tracker rule")
RespondError(w, http.StatusInternalServerError, "Failed to delete tracker rule")
return
}

RespondJSON(w, http.StatusNoContent, nil)
}

func (h *TrackerRuleHandler) Reorder(w http.ResponseWriter, r *http.Request) {
instanceID, err := parseInstanceID(w, r)
if err != nil {
return
}

var payload struct {
OrderedIDs []int `json:"orderedIds"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil || len(payload.OrderedIDs) == 0 {
RespondError(w, http.StatusBadRequest, "Invalid request payload")
return
}

if err := h.store.Reorder(r.Context(), instanceID, payload.OrderedIDs); err != nil {
log.Error().Err(err).Int("instanceID", instanceID).Msg("failed to reorder tracker rules")
RespondError(w, http.StatusInternalServerError, "Failed to reorder tracker rules")
return
}

RespondJSON(w, http.StatusNoContent, nil)
}

func (h *TrackerRuleHandler) ApplyNow(w http.ResponseWriter, r *http.Request) {
instanceID, err := parseInstanceID(w, r)
if err != nil {
return
}

if h.service != nil {
if err := h.service.ApplyOnceForInstance(r.Context(), instanceID); err != nil {
log.Error().Err(err).Int("instanceID", instanceID).Msg("tracker rules: manual apply failed")
RespondError(w, http.StatusInternalServerError, "Failed to apply tracker rules")
return
}
}

RespondJSON(w, http.StatusAccepted, map[string]string{"status": "applied"})
}

func parseInstanceID(w http.ResponseWriter, r *http.Request) (int, error) {
instanceIDStr := chi.URLParam(r, "instanceID")
instanceID, err := strconv.Atoi(instanceIDStr)
if err != nil || instanceID <= 0 {
RespondError(w, http.StatusBadRequest, "Invalid instance ID")
return 0, fmt.Errorf("invalid instance ID: %s", instanceIDStr)
}
return instanceID, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func cleanStringPtr(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}

func normalizeTrackerDomains(domains []string) []string {
seen := make(map[string]struct{})
var out []string
for _, d := range domains {
trimmed := strings.TrimSpace(d)
if trimmed == "" {
continue
}
if _, exists := seen[trimmed]; exists {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
return out
}
21 changes: 21 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"github.com/autobrr/qui/internal/services/license"
"github.com/autobrr/qui/internal/services/reannounce"
"github.com/autobrr/qui/internal/services/trackericons"
"github.com/autobrr/qui/internal/services/trackerrules"
"github.com/autobrr/qui/internal/update"
"github.com/autobrr/qui/internal/web"
"github.com/autobrr/qui/internal/web/swagger"
Expand Down Expand Up @@ -63,6 +64,8 @@ type Server struct {
crossSeedService *crossseed.Service
jackettService *jackett.Service
torznabIndexerStore *models.TorznabIndexerStore
trackerRuleStore *models.TrackerRuleStore
trackerRuleService *trackerrules.Service
}

type Dependencies struct {
Expand All @@ -87,6 +90,8 @@ type Dependencies struct {
CrossSeedService *crossseed.Service
JackettService *jackett.Service
TorznabIndexerStore *models.TorznabIndexerStore
TrackerRuleStore *models.TrackerRuleStore
TrackerRuleService *trackerrules.Service
}

func NewServer(deps *Dependencies) *Server {
Expand Down Expand Up @@ -118,6 +123,8 @@ func NewServer(deps *Dependencies) *Server {
reannounceService: deps.ReannounceService,
jackettService: deps.JackettService,
torznabIndexerStore: deps.TorznabIndexerStore,
trackerRuleStore: deps.TrackerRuleStore,
trackerRuleService: deps.TrackerRuleService,
}

return &s
Expand Down Expand Up @@ -253,6 +260,7 @@ func (s *Server) Handler() (*chi.Mux, error) {
proxyHandler := proxy.NewHandler(s.clientPool, s.clientAPIKeyStore, s.instanceStore, s.syncManager, s.reannounceCache, s.reannounceService, s.config.Config.BaseURL)
licenseHandler := handlers.NewLicenseHandler(s.licenseService)
crossSeedHandler := handlers.NewCrossSeedHandler(s.crossSeedService)
trackerRulesHandler := handlers.NewTrackerRuleHandler(s.trackerRuleStore, s.trackerRuleService)

// Torznab/Jackett handler
var jackettHandler *handlers.JackettHandler
Expand Down Expand Up @@ -396,6 +404,19 @@ func (s *Server) Handler() (*chi.Mux, error) {
// Trackers
r.Get("/trackers", torrentsHandler.GetActiveTrackers)

// Tracker rules
r.Route("/tracker-rules", func(r chi.Router) {
r.Get("/", trackerRulesHandler.List)
r.Post("/", trackerRulesHandler.Create)
r.Put("/order", trackerRulesHandler.Reorder)
r.Post("/apply", trackerRulesHandler.ApplyNow)

r.Route("/{ruleID}", func(r chi.Router) {
r.Put("/", trackerRulesHandler.Update)
r.Delete("/", trackerRulesHandler.Delete)
})
})

// Preferences
r.Get("/preferences", preferencesHandler.GetPreferences)
r.Patch("/preferences", preferencesHandler.UpdatePreferences)
Expand Down
Loading
Loading