diff --git a/README.md b/README.md index b40b609ba..9257294bc 100644 --- a/README.md +++ b/README.md @@ -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)) diff --git a/cmd/qui/main.go b/cmd/qui/main.go index d4a4c1626..4e3bdbd53 100644 --- a/cmd/qui/main.go +++ b/cmd/qui/main.go @@ -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" ) @@ -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) @@ -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) @@ -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()) @@ -636,6 +644,8 @@ func (app *Application) runServer() { CrossSeedService: crossSeedService, JackettService: jackettService, TorznabIndexerStore: torznabIndexerStore, + TrackerRuleStore: trackerRuleStore, + TrackerRuleService: trackerRuleService, }) errorChannel := make(chan error) diff --git a/internal/api/handlers/tracker_rules.go b/internal/api/handlers/tracker_rules.go new file mode 100644 index 000000000..c45de5d6b --- /dev/null +++ b/internal/api/handlers/tracker_rules.go @@ -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) +} + +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 +} + +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 +} diff --git a/internal/api/server.go b/internal/api/server.go index 3597d42d6..6729a252f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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" @@ -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 { @@ -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 { @@ -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 @@ -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 @@ -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) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 7181ad815..ff18245b0 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -48,6 +48,12 @@ var undocumentedRoutes = map[routeKey]struct{}{ {Method: http.MethodGet, Path: "/api/instances/{instanceId}/backups/runs/{runId}/manifest"}: {}, {Method: http.MethodGet, Path: "/api/instances/{instanceId}/backups/settings"}: {}, {Method: http.MethodPut, Path: "/api/instances/{instanceId}/backups/settings"}: {}, + {Method: http.MethodGet, Path: "/api/instances/{instanceId}/tracker-rules"}: {}, + {Method: http.MethodPost, Path: "/api/instances/{instanceId}/tracker-rules"}: {}, + {Method: http.MethodPost, Path: "/api/instances/{instanceId}/tracker-rules/apply"}: {}, + {Method: http.MethodPut, Path: "/api/instances/{instanceId}/tracker-rules/order"}: {}, + {Method: http.MethodDelete, Path: "/api/instances/{instanceId}/tracker-rules/{ruleID}"}: {}, + {Method: http.MethodPut, Path: "/api/instances/{instanceId}/tracker-rules/{ruleID}"}: {}, } func TestAllEndpointsDocumented(t *testing.T) { @@ -111,6 +117,7 @@ func newTestDependencies(t *testing.T) *Dependencies { UpdateService: &update.Service{}, TrackerIconService: trackerIconService, BackupService: &backups.Service{}, + TrackerRuleStore: models.NewTrackerRuleStore(db), } } diff --git a/internal/database/migrations/019_add_tracker_rules.sql b/internal/database/migrations/019_add_tracker_rules.sql new file mode 100644 index 000000000..bb01bc564 --- /dev/null +++ b/internal/database/migrations/019_add_tracker_rules.sql @@ -0,0 +1,29 @@ +-- Copyright (c) 2025, s0up and the autobrr contributors. +-- SPDX-License-Identifier: GPL-2.0-or-later + +CREATE TABLE IF NOT EXISTS tracker_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id INTEGER NOT NULL REFERENCES instances(id) ON DELETE CASCADE, + name TEXT NOT NULL, + tracker_pattern TEXT NOT NULL, + category TEXT, + tag TEXT, + upload_limit_kib INTEGER, + download_limit_kib INTEGER, + ratio_limit REAL, + seeding_time_limit_minutes INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_tracker_rules_instance ON tracker_rules(instance_id, sort_order, id); + +CREATE TRIGGER IF NOT EXISTS trg_tracker_rules_updated +AFTER UPDATE ON tracker_rules +BEGIN + UPDATE tracker_rules + SET updated_at = CURRENT_TIMESTAMP + WHERE id = NEW.id; +END; diff --git a/internal/models/tracker_rule.go b/internal/models/tracker_rule.go new file mode 100644 index 000000000..b28eb7c04 --- /dev/null +++ b/internal/models/tracker_rule.go @@ -0,0 +1,337 @@ +// Copyright (c) 2025, s0up and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package models + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" + + "github.com/autobrr/qui/internal/dbinterface" +) + +type TrackerRule struct { + ID int `json:"id"` + InstanceID int `json:"instanceId"` + Name string `json:"name"` + TrackerPattern string `json:"trackerPattern"` + TrackerDomains []string `json:"trackerDomains,omitempty"` + Category *string `json:"category,omitempty"` + Tag *string `json:"tag,omitempty"` + UploadLimitKiB *int64 `json:"uploadLimitKiB,omitempty"` + DownloadLimitKiB *int64 `json:"downloadLimitKiB,omitempty"` + RatioLimit *float64 `json:"ratioLimit,omitempty"` + SeedingTimeLimitMinutes *int64 `json:"seedingTimeLimitMinutes,omitempty"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sortOrder"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type TrackerRuleStore struct { + db dbinterface.Querier +} + +func NewTrackerRuleStore(db dbinterface.Querier) *TrackerRuleStore { + return &TrackerRuleStore{db: db} +} + +func splitPatterns(pattern string) []string { + if pattern == "" { + return nil + } + + rawParts := strings.FieldsFunc(pattern, func(r rune) bool { + return r == ',' || r == ';' || r == '|' + }) + + seen := make(map[string]struct{}) + var parts []string + for _, raw := range rawParts { + p := strings.TrimSpace(raw) + if p == "" { + continue + } + if _, exists := seen[p]; exists { + continue + } + seen[p] = struct{}{} + parts = append(parts, p) + } + return parts +} + +func normalizeTrackerPattern(pattern string, domains []string) string { + if len(domains) > 0 { + pattern = strings.Join(domains, ",") + } + pattern = strings.TrimSpace(pattern) + if pattern == "" { + return "" + } + parts := splitPatterns(pattern) + if len(parts) == 0 { + return "" + } + return strings.Join(parts, ",") +} + +func (s *TrackerRuleStore) ListByInstance(ctx context.Context, instanceID int) ([]*TrackerRule, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, instance_id, name, tracker_pattern, category, tag, upload_limit_kib, download_limit_kib, + ratio_limit, seeding_time_limit_minutes, enabled, sort_order, created_at, updated_at + FROM tracker_rules + WHERE instance_id = ? + ORDER BY sort_order ASC, id ASC + `, instanceID) + if err != nil { + return nil, err + } + defer rows.Close() + + var rules []*TrackerRule + for rows.Next() { + var rule TrackerRule + var category, tag sql.NullString + var upload, download sql.NullInt64 + var ratio sql.NullFloat64 + var seeding sql.NullInt64 + + if err := rows.Scan( + &rule.ID, + &rule.InstanceID, + &rule.Name, + &rule.TrackerPattern, + &category, + &tag, + &upload, + &download, + &ratio, + &seeding, + &rule.Enabled, + &rule.SortOrder, + &rule.CreatedAt, + &rule.UpdatedAt, + ); err != nil { + return nil, err + } + + if category.Valid { + rule.Category = &category.String + } + if tag.Valid { + rule.Tag = &tag.String + } + if upload.Valid { + rule.UploadLimitKiB = &upload.Int64 + } + if download.Valid { + rule.DownloadLimitKiB = &download.Int64 + } + if ratio.Valid { + rule.RatioLimit = &ratio.Float64 + } + if seeding.Valid { + rule.SeedingTimeLimitMinutes = &seeding.Int64 + } + + rule.TrackerDomains = splitPatterns(rule.TrackerPattern) + + rules = append(rules, &rule) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return rules, nil +} + +func (s *TrackerRuleStore) Get(ctx context.Context, id int) (*TrackerRule, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, instance_id, name, tracker_pattern, category, tag, upload_limit_kib, download_limit_kib, + ratio_limit, seeding_time_limit_minutes, enabled, sort_order, created_at, updated_at + FROM tracker_rules + WHERE id = ? + `, id) + + var rule TrackerRule + var category, tag sql.NullString + var upload, download sql.NullInt64 + var ratio sql.NullFloat64 + var seeding sql.NullInt64 + + if err := row.Scan( + &rule.ID, + &rule.InstanceID, + &rule.Name, + &rule.TrackerPattern, + &category, + &tag, + &upload, + &download, + &ratio, + &seeding, + &rule.Enabled, + &rule.SortOrder, + &rule.CreatedAt, + &rule.UpdatedAt, + ); err != nil { + return nil, err + } + + if category.Valid { + rule.Category = &category.String + } + if tag.Valid { + rule.Tag = &tag.String + } + if upload.Valid { + rule.UploadLimitKiB = &upload.Int64 + } + if download.Valid { + rule.DownloadLimitKiB = &download.Int64 + } + if ratio.Valid { + rule.RatioLimit = &ratio.Float64 + } + if seeding.Valid { + rule.SeedingTimeLimitMinutes = &seeding.Int64 + } + + rule.TrackerDomains = splitPatterns(rule.TrackerPattern) + + return &rule, nil +} + +func (s *TrackerRuleStore) nextSortOrder(ctx context.Context, instanceID int) (int, error) { + row := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order), 0) FROM tracker_rules WHERE instance_id = ?`, instanceID) + var maxOrder int + if err := row.Scan(&maxOrder); err != nil { + return 0, err + } + return maxOrder + 1, nil +} + +func (s *TrackerRuleStore) Create(ctx context.Context, rule *TrackerRule) (*TrackerRule, error) { + if rule == nil { + return nil, errors.New("rule is nil") + } + + rule.TrackerPattern = normalizeTrackerPattern(rule.TrackerPattern, rule.TrackerDomains) + + sortOrder := rule.SortOrder + if sortOrder == 0 { + next, err := s.nextSortOrder(ctx, rule.InstanceID) + if err != nil { + return nil, err + } + sortOrder = next + } + + res, err := s.db.ExecContext(ctx, ` + INSERT INTO tracker_rules + (instance_id, name, tracker_pattern, category, tag, upload_limit_kib, download_limit_kib, ratio_limit, seeding_time_limit_minutes, enabled, sort_order) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, rule.InstanceID, rule.Name, rule.TrackerPattern, nullableString(rule.Category), nullableString(rule.Tag), + nullableInt64(rule.UploadLimitKiB), nullableInt64(rule.DownloadLimitKiB), nullableFloat64(rule.RatioLimit), + nullableInt64(rule.SeedingTimeLimitMinutes), boolToInt(rule.Enabled), sortOrder) + if err != nil { + return nil, err + } + + id, err := res.LastInsertId() + if err != nil { + return nil, err + } + + return s.Get(ctx, int(id)) +} + +func (s *TrackerRuleStore) Update(ctx context.Context, rule *TrackerRule) (*TrackerRule, error) { + if rule == nil { + return nil, errors.New("rule is nil") + } + + rule.TrackerPattern = normalizeTrackerPattern(rule.TrackerPattern, rule.TrackerDomains) + + res, err := s.db.ExecContext(ctx, ` + UPDATE tracker_rules + SET name = ?, tracker_pattern = ?, category = ?, tag = ?, upload_limit_kib = ?, download_limit_kib = ?, + ratio_limit = ?, seeding_time_limit_minutes = ?, enabled = ?, sort_order = ? + WHERE id = ? AND instance_id = ? + `, rule.Name, rule.TrackerPattern, nullableString(rule.Category), nullableString(rule.Tag), + nullableInt64(rule.UploadLimitKiB), nullableInt64(rule.DownloadLimitKiB), nullableFloat64(rule.RatioLimit), + nullableInt64(rule.SeedingTimeLimitMinutes), boolToInt(rule.Enabled), rule.SortOrder, rule.ID, rule.InstanceID) + if err != nil { + return nil, err + } + rows, err := res.RowsAffected() + if err != nil { + return nil, err + } + if rows == 0 { + return nil, sql.ErrNoRows + } + + return s.Get(ctx, rule.ID) +} + +func (s *TrackerRuleStore) Delete(ctx context.Context, instanceID int, id int) error { + res, err := s.db.ExecContext(ctx, `DELETE FROM tracker_rules WHERE id = ? AND instance_id = ?`, id, instanceID) + if err != nil { + return err + } + if rows, err := res.RowsAffected(); err == nil && rows == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *TrackerRuleStore) Reorder(ctx context.Context, instanceID int, orderedIDs []int) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + for idx, id := range orderedIDs { + if _, err := tx.ExecContext(ctx, `UPDATE tracker_rules SET sort_order = ? WHERE id = ? AND instance_id = ?`, idx+1, id, instanceID); err != nil { + return err + } + } + + return tx.Commit() +} + +func nullableString(value *string) any { + if value == nil { + return nil + } + return *value +} + +func nullableInt64(value *int64) any { + if value == nil { + return nil + } + return *value +} + +func nullableFloat64(value *float64) any { + if value == nil { + return nil + } + return *value +} + +func boolToInt(v bool) int { + if v { + return 1 + } + return 0 +} diff --git a/internal/services/reannounce/REANNOUNCE.md b/internal/services/reannounce/REANNOUNCE.md index 49ac1c1c6..a3f35f3ac 100644 --- a/internal/services/reannounce/REANNOUNCE.md +++ b/internal/services/reannounce/REANNOUNCE.md @@ -2,11 +2,15 @@ qui can automatically fix stalled torrents by reannouncing them to trackers. This helps when a tracker fails to register a new upload immediately, ensuring your torrents start seeding without manual intervention. +qBittorrent doesn't retry failed announces quickly. When a tracker is slow to register a new upload or returns an error, you may be stuck waiting for a long time ([related issue](https://github.com/qbittorrent/qBittorrent/issues/11419)). qui handles this automatically and gracefully. + +qui never spams trackers. While a tracker is still updating or waiting for a response, qui waits patiently. It only acts once a tracker has responded and there's an actual problem to fix. + ## Quick Start -1. Go to **Settings > Instances** (or click the cogwheel on an instance card). -2. Open the **Tracker Reannounce** tab. -3. Toggle **Enabled** to turn it on. +1. Go to **Services** in the main navigation. +2. Select an instance from the dropdown. +3. In the **Tracker Reannounce** section, toggle **Enabled** to turn it on. 4. Click **Save Changes**. That’s it! qui will now monitor stalled torrents in the background. @@ -21,20 +25,22 @@ That’s it! qui will now monitor stalled torrents in the background. ### Monitoring Scope You can choose which torrents to monitor: -* **Monitor All Stalled Torrents (Default)**: Checks every stalled torrent. +* **Monitor All Stalled Torrents**: Checks every stalled torrent. * Use **Exclusions** below to ignore specific Categories, Tags, or Trackers (e.g., ignore "public" trackers). * **Custom Filter (Monitor All Disabled)**: * Only checks torrents that match your **Include** rules. * You can still add **Exclusions** to block specific items within those allowed groups. -### Aggressive Mode -By default, qui waits about **2 minutes** between reannounce attempts for the same torrent to be polite to trackers (a per-torrent cooldown between scans). -* **Enable Aggressive Mode** to remove this cooldown and retry on the very next scan (every 7s) if the torrent is still stalled. -* The **Retry Interval** still controls the spacing of the up-to-3 retries inside each scan attempt. +### Quick Retry +By default, qui waits about **2 minutes** between reannounce attempts for the same torrent (a per-torrent cooldown between scans). +* **Enable Quick Retry** to use the **Retry Interval** (default 7s) as the cooldown instead. This helps stalled torrents recover faster. +* The **Retry Interval** controls both the spacing of the up-to-3 retries inside each scan attempt and, with Quick Retry enabled, the cooldown between scans. + +This is especially useful on trackers that are slow to register new uploads. Some sites take a moment before they recognize a new torrent, which can cause initial stalls—Quick Retry helps work around this automatically. ## Activity Log -To see what’s happening: -1. Go to the **Tracker Reannounce** tab. -2. Click **Activity Log**. +To see what's happening: +1. Go to **Services** and select your instance. +2. Click the **Activity Log** tab in the Tracker Reannounce section. You will see a real-time feed of every torrent checked, whether the reannounce succeeded, failed, or was skipped (e.g., because the tracker is actually working fine). diff --git a/internal/services/reannounce/service.go b/internal/services/reannounce/service.go index 17d111151..9002571e2 100644 --- a/internal/services/reannounce/service.go +++ b/internal/services/reannounce/service.go @@ -263,6 +263,7 @@ func (s *Service) GetMonitoredTorrents(ctx context.Context, instanceID int) []Mo now := s.currentTime() instJobs := s.j[instanceID] + debounceWindow := s.effectiveDebounceWindow(settings) var result []MonitoredTorrent for _, torrent := range torrents { @@ -286,7 +287,7 @@ func (s *Service) GetMonitoredTorrents(ctx context.Context, instanceID int) []Mo if job, ok := instJobs[hashUpper]; ok { if job.isRunning { state = MonitoredTorrentStateReannouncing - } else if !job.lastCompleted.IsZero() && now.Sub(job.lastCompleted) < s.cfg.DebounceWindow { + } else if !job.lastCompleted.IsZero() && now.Sub(job.lastCompleted) < debounceWindow { state = MonitoredTorrentStateCooldown } } @@ -296,7 +297,7 @@ func (s *Service) GetMonitoredTorrents(ctx context.Context, instanceID int) []Mo result = append(result, MonitoredTorrent{ InstanceID: instanceID, - Hash: strings.ToLower(hashUpper), + Hash: hashUpper, TorrentName: torrent.Name, Trackers: trackers, TimeActiveSeconds: torrent.TimeActive, @@ -341,13 +342,17 @@ func (s *Service) enqueue(instanceID int, hash string, torrentName string, track return true } - // Check debounce window if Aggressive mode is disabled settings := s.getSettings(baseCtx, instanceID) isAggressive := settings != nil && settings.Aggressive + debounceWindow := s.effectiveDebounceWindow(settings) - if !isAggressive && !job.lastCompleted.IsZero() { - if elapsed := now.Sub(job.lastCompleted); elapsed < s.cfg.DebounceWindow { - s.recordActivity(instanceID, hash, torrentName, trackers, ActivityOutcomeSkipped, "debounced during cooldown window") + if !job.lastCompleted.IsZero() && debounceWindow > 0 { + if elapsed := now.Sub(job.lastCompleted); elapsed < debounceWindow { + reason := "debounced during cooldown window" + if isAggressive { + reason = "debounced during retry interval window" + } + s.recordActivity(instanceID, hash, torrentName, trackers, ActivityOutcomeSkipped, reason) return true } } @@ -527,6 +532,10 @@ func (s *Service) torrentMeetsCriteria(torrent qbt.Torrent, settings *models.Ins return false } + if settings.InitialWaitSeconds > 0 && torrent.TimeActive < int64(settings.InitialWaitSeconds) { + return false + } + // 1. Check exclusions first if settings.ExcludeCategories && len(settings.Categories) > 0 { for _, category := range settings.Categories { @@ -802,6 +811,17 @@ func (s *Service) currentTime() time.Time { return time.Now() } +// effectiveDebounceWindow returns the debounce duration to use for cooldown checks. +// aggressive mode uses the retry interval; otherwise the global debounce window applies. +func (s *Service) effectiveDebounceWindow(settings *models.InstanceReannounceSettings) time.Duration { + if settings != nil && settings.Aggressive { + if interval := time.Duration(settings.ReannounceIntervalSeconds) * time.Second; interval > 0 { + return interval + } + } + return s.cfg.DebounceWindow +} + func (s *Service) extractTrackerDomain(trackerURL string) string { if trackerURL == "" { return "" diff --git a/internal/services/reannounce/service_test.go b/internal/services/reannounce/service_test.go index e607a6002..8851e775e 100644 --- a/internal/services/reannounce/service_test.go +++ b/internal/services/reannounce/service_test.go @@ -108,6 +108,26 @@ func TestTorrentMeetsCriteria_IncludeExcludeLogic(t *testing.T) { torrent: qbt.Torrent{TimeActive: 61, State: qbt.TorrentStateStalledUp}, want: false, }, + { + name: "Initial Wait Not Met", + settings: models.InstanceReannounceSettings{ + Enabled: true, + MonitorAll: true, + InitialWaitSeconds: 15, + }, + torrent: qbt.Torrent{TimeActive: 10, State: qbt.TorrentStateStalledUp}, + want: false, + }, + { + name: "Initial Wait Met", + settings: models.InstanceReannounceSettings{ + Enabled: true, + MonitorAll: true, + InitialWaitSeconds: 15, + }, + torrent: qbt.Torrent{TimeActive: 20, State: qbt.TorrentStateStalledUp}, + want: true, + }, { name: "Monitor All - No Exclusions", settings: models.InstanceReannounceSettings{ @@ -393,10 +413,15 @@ func TestServiceEnqueue_AggressiveModeSkipsDebounce(t *testing.T) { require.True(t, svc.enqueue(1, "ABC", "Test", "tracker")) require.Equal(t, 1, started, "should NOT start new job in conservative mode") - // 4. Try enqueue with Aggressive=True - svc.settingsCache.Replace(&models.InstanceReannounceSettings{InstanceID: 1, Aggressive: true, Enabled: true}) + // 4. Try enqueue with Aggressive=True, retry interval governs cooldown + svc.settingsCache.Replace(&models.InstanceReannounceSettings{InstanceID: 1, Aggressive: true, Enabled: true, ReannounceIntervalSeconds: 7}) + require.True(t, svc.enqueue(1, "ABC", "Test", "tracker")) + require.Equal(t, 1, started, "should respect retry interval cooldown in aggressive mode") + + // 5. Advance past retry interval and ensure job starts + now = now.Add(10 * time.Second) require.True(t, svc.enqueue(1, "ABC", "Test", "tracker")) - require.Equal(t, 2, started, "should start new job immediately in aggressive mode") + require.Equal(t, 2, started, "should start new job after retry interval in aggressive mode") } func newTestServiceForDebounce(window time.Duration, now func() time.Time) *Service { diff --git a/internal/services/trackerrules/TRACKER_RULES.md b/internal/services/trackerrules/TRACKER_RULES.md new file mode 100644 index 000000000..4076de931 --- /dev/null +++ b/internal/services/trackerrules/TRACKER_RULES.md @@ -0,0 +1,74 @@ +# Tracker Rules + +Tracker Rules automatically apply speed limits, ratio caps, and seeding time limits to torrents based on their tracker domain. + +## How Rules Work + +Rules are evaluated in **sort order** (first match wins). Each rule can match torrents by: + +1. **Tracker domain** (required) - The tracker's hostname +2. **Category** (optional) - The torrent's category in qBittorrent +3. **Tag** (optional) - A tag assigned to the torrent + +Torrents that don't match any rule are left untouched. Disabled rules are skipped entirely. + +## Settings Applied + +When a rule matches a torrent, it can apply any combination of: + +| Setting | Description | +|---------|-------------| +| Upload limit | Maximum upload speed (KiB/s) | +| Download limit | Maximum download speed (KiB/s) | +| Ratio limit | Stop seeding when this ratio is reached | +| Seeding time limit | Stop seeding after this many minutes | + +## When Rules Run + +Rules are applied in two ways: + +- **Automatically** - A background service scans all torrents every 20 seconds +- **Manually** - Click "Apply Now" in the UI to trigger immediately + +To avoid hammering qBittorrent, the same torrent won't be re-processed within 2 minutes (debouncing). + +## Matching Logic + +### Domain Patterns + +Tracker domains can be matched in three ways: + +| Pattern | Example | Matches | +|---------|---------|---------| +| Exact | `tracker.example.com` | Only `tracker.example.com` | +| Glob | `*.example.com` | `sub.example.com`, `tracker.example.com` | +| Suffix | `.example.com` | `example.com`, `sub.example.com` | + +### Multiple Patterns + +Separate multiple patterns with commas, semicolons, or pipes: + +```text +tracker1.com,tracker2.org|tracker3.net +``` + +All matching is **case-insensitive**. + +## Important Behavior + +### Rules Only Set Values + +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. + +### Efficient Updates + +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. + +### Existing vs New Torrents + +- **Existing torrents** - Processed on the next scan cycle (within 20 seconds) +- **New torrents** - Picked up automatically within 20 seconds of appearing in qBittorrent + +### Batched API Calls + +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. diff --git a/internal/services/trackerrules/service.go b/internal/services/trackerrules/service.go new file mode 100644 index 000000000..926b873a5 --- /dev/null +++ b/internal/services/trackerrules/service.go @@ -0,0 +1,377 @@ +// Copyright (c) 2025, s0up and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +// Package trackerrules enforces tracker-scoped speed/ratio rules per instance. +package trackerrules + +import ( + "context" + "path" + "regexp" + "slices" + "strings" + "sync" + "time" + + qbt "github.com/autobrr/go-qbittorrent" + "github.com/rs/zerolog/log" + + "github.com/autobrr/qui/internal/models" + "github.com/autobrr/qui/internal/qbittorrent" +) + +// Config controls how often rules are re-applied and how long to debounce repeats. +type Config struct { + ScanInterval time.Duration + SkipWithin time.Duration + MaxBatchHashes int +} + +func DefaultConfig() Config { + return Config{ + ScanInterval: 20 * time.Second, + SkipWithin: 2 * time.Minute, + MaxBatchHashes: 150, + } +} + +// Service periodically applies tracker rules to torrents for all active instances. +type Service struct { + cfg Config + instanceStore *models.InstanceStore + ruleStore *models.TrackerRuleStore + syncManager *qbittorrent.SyncManager + + // keep lightweight memory of recent applications to avoid hammering qBittorrent + lastApplied map[int]map[string]time.Time // instanceID -> hash -> timestamp + mu sync.RWMutex +} + +func NewService(cfg Config, instanceStore *models.InstanceStore, ruleStore *models.TrackerRuleStore, syncManager *qbittorrent.SyncManager) *Service { + if cfg.ScanInterval <= 0 { + cfg.ScanInterval = DefaultConfig().ScanInterval + } + if cfg.SkipWithin <= 0 { + cfg.SkipWithin = DefaultConfig().SkipWithin + } + if cfg.MaxBatchHashes <= 0 { + cfg.MaxBatchHashes = DefaultConfig().MaxBatchHashes + } + return &Service{ + cfg: cfg, + instanceStore: instanceStore, + ruleStore: ruleStore, + syncManager: syncManager, + lastApplied: make(map[int]map[string]time.Time), + } +} + +func (s *Service) Start(ctx context.Context) { + if s == nil { + return + } + go s.loop(ctx) +} + +func (s *Service) loop(ctx context.Context) { + ticker := time.NewTicker(s.cfg.ScanInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.applyAll(ctx) + } + } +} + +func (s *Service) applyAll(ctx context.Context) { + if s == nil || s.syncManager == nil || s.ruleStore == nil || s.instanceStore == nil { + return + } + + instances, err := s.instanceStore.List(ctx) + if err != nil { + log.Error().Err(err).Msg("tracker rules: failed to list instances") + return + } + + for _, instance := range instances { + if !instance.IsActive { + continue + } + if err := s.applyForInstance(ctx, instance.ID); err != nil { + log.Error().Err(err).Int("instanceID", instance.ID).Msg("tracker rules: apply failed") + } + } +} + +// ApplyOnceForInstance allows manual triggering (API hook). +func (s *Service) ApplyOnceForInstance(ctx context.Context, instanceID int) error { + return s.applyForInstance(ctx, instanceID) +} + +func (s *Service) applyForInstance(ctx context.Context, instanceID int) error { + rules, err := s.ruleStore.ListByInstance(ctx, instanceID) + if err != nil { + log.Error().Err(err).Int("instanceID", instanceID).Msg("tracker rules: failed to load rules") + return err + } + if len(rules) == 0 { + return nil + } + + torrents, err := s.syncManager.GetAllTorrents(ctx, instanceID) + if err != nil { + log.Debug().Err(err).Int("instanceID", instanceID).Msg("tracker rules: unable to fetch torrents") + return err + } + + if len(torrents) == 0 { + return nil + } + + // snapshot lastApplied map for this instance under lock and ensure it's initialized + s.mu.RLock() + instLastApplied, ok := s.lastApplied[instanceID] + s.mu.RUnlock() + if !ok || instLastApplied == nil { + s.mu.Lock() + if s.lastApplied[instanceID] == nil { // re-check in case another goroutine initialized it + s.lastApplied[instanceID] = make(map[string]time.Time) + } + instLastApplied = s.lastApplied[instanceID] + s.mu.Unlock() + } + + now := time.Now() + + type shareKey struct { + ratio float64 + seed int64 + } + shareBatches := make(map[shareKey][]string) + uploadBatches := make(map[int64][]string) + downloadBatches := make(map[int64][]string) + + for _, torrent := range torrents { + s.mu.RLock() + ts, ok := instLastApplied[torrent.Hash] + s.mu.RUnlock() + if ok && now.Sub(ts) < s.cfg.SkipWithin { + continue + } + + rule := selectRule(torrent, rules, s.syncManager) + if rule == nil { + continue + } + + if rule.UploadLimitKiB != nil { + desired := *rule.UploadLimitKiB * 1024 + if torrent.UpLimit != desired { + uploadBatches[*rule.UploadLimitKiB] = append(uploadBatches[*rule.UploadLimitKiB], torrent.Hash) + } + } + if rule.DownloadLimitKiB != nil { + desired := *rule.DownloadLimitKiB * 1024 + if torrent.DlLimit != desired { + downloadBatches[*rule.DownloadLimitKiB] = append(downloadBatches[*rule.DownloadLimitKiB], torrent.Hash) + } + } + if rule.RatioLimit != nil || rule.SeedingTimeLimitMinutes != nil { + ratio := torrent.RatioLimit + if rule.RatioLimit != nil { + ratio = *rule.RatioLimit + } + seedMinutes := torrent.SeedingTimeLimit + if rule.SeedingTimeLimitMinutes != nil { + seedMinutes = *rule.SeedingTimeLimitMinutes + } + currentKey := shareKey{ratio: ratio, seed: seedMinutes} + needsShareUpdate := (rule.RatioLimit != nil && torrent.RatioLimit != ratio) || + (rule.SeedingTimeLimitMinutes != nil && torrent.SeedingTimeLimit != seedMinutes) + if needsShareUpdate { + shareBatches[currentKey] = append(shareBatches[currentKey], torrent.Hash) + } + } + + s.mu.Lock() + instLastApplied[torrent.Hash] = now + s.mu.Unlock() + } + + ctx, cancel := context.WithTimeout(ctx, 25*time.Second) + defer cancel() + + for limit, hashes := range uploadBatches { + limited := limitHashBatch(hashes, s.cfg.MaxBatchHashes) + for _, batch := range limited { + if err := s.syncManager.SetTorrentUploadLimit(ctx, instanceID, batch, limit); err != nil { + log.Warn().Err(err).Int("instanceID", instanceID).Int64("limitKiB", limit).Int("count", len(batch)).Msg("tracker rules: upload limit failed") + } + } + } + + for limit, hashes := range downloadBatches { + limited := limitHashBatch(hashes, s.cfg.MaxBatchHashes) + for _, batch := range limited { + if err := s.syncManager.SetTorrentDownloadLimit(ctx, instanceID, batch, limit); err != nil { + log.Warn().Err(err).Int("instanceID", instanceID).Int64("limitKiB", limit).Int("count", len(batch)).Msg("tracker rules: download limit failed") + } + } + } + + for key, hashes := range shareBatches { + limited := limitHashBatch(hashes, s.cfg.MaxBatchHashes) + for _, batch := range limited { + if err := s.syncManager.SetTorrentShareLimit(ctx, instanceID, batch, key.ratio, key.seed, -1); err != nil { + log.Warn().Err(err).Int("instanceID", instanceID).Float64("ratio", key.ratio).Int64("seedMinutes", key.seed).Int("count", len(batch)).Msg("tracker rules: share limit failed") + } + } + } + + return nil +} + +func limitHashBatch(hashes []string, max int) [][]string { + if max <= 0 || len(hashes) <= max { + return [][]string{hashes} + } + var batches [][]string + for len(hashes) > 0 { + end := max + if len(hashes) < max { + end = len(hashes) + } + batches = append(batches, slices.Clone(hashes[:end])) + hashes = hashes[end:] + } + return batches +} + +func selectRule(torrent qbt.Torrent, rules []*models.TrackerRule, sm *qbittorrent.SyncManager) *models.TrackerRule { + trackerDomains := collectTrackerDomains(torrent, sm) + + for _, rule := range rules { + if !rule.Enabled { + continue + } + if !matchesTracker(rule.TrackerPattern, trackerDomains) { + continue + } + if rule.Category != nil && strings.TrimSpace(*rule.Category) != "" { + if !strings.EqualFold(torrent.Category, strings.TrimSpace(*rule.Category)) { + continue + } + } + if rule.Tag != nil && strings.TrimSpace(*rule.Tag) != "" { + if !torrentHasTag(torrent.Tags, strings.TrimSpace(*rule.Tag)) { + continue + } + } + return rule + } + + return nil +} + +func matchesTracker(pattern string, domains []string) bool { + if pattern == "" { + return false + } + + tokens := strings.FieldsFunc(pattern, func(r rune) bool { + return r == ',' || r == ';' || r == '|' + }) + + for _, token := range tokens { + normalized := strings.ToLower(strings.TrimSpace(token)) + if normalized == "" { + continue + } + isGlob := strings.ContainsAny(normalized, "*?") + + for _, domain := range domains { + d := strings.ToLower(domain) + if isGlob { + ok, err := path.Match(normalized, d) + if err != nil { + log.Error().Err(err).Str("pattern", normalized).Msg("tracker rules: invalid glob pattern") + continue + } + if ok { + return true + } + } else if d == normalized { + return true + } else if strings.HasPrefix(normalized, ".") && strings.HasSuffix(d, normalized) { + return true + } + } + } + + return false +} + +func collectTrackerDomains(t qbt.Torrent, sm *qbittorrent.SyncManager) []string { + domainSet := make(map[string]struct{}) + + if t.Tracker != "" { + if domain := sm.ExtractDomainFromURL(t.Tracker); domain != "" && domain != "Unknown" { + domainSet[domain] = struct{}{} + } + } + + for _, tr := range t.Trackers { + if tr.Url == "" { + continue + } + if domain := sm.ExtractDomainFromURL(tr.Url); domain != "" && domain != "Unknown" { + domainSet[domain] = struct{}{} + } + } + + if len(domainSet) == 0 && t.Tracker != "" { + if domain := sanitizeTrackerHost(t.Tracker); domain != "" { + domainSet[domain] = struct{}{} + } + } + + var domains []string + for d := range domainSet { + domains = append(domains, d) + } + slices.Sort(domains) + return domains +} + +func sanitizeTrackerHost(urlOrHost string) string { + clean := strings.TrimSpace(urlOrHost) + if clean == "" { + return "" + } + if strings.Contains(clean, "://") { + return "" + } + // Remove URL-like path pieces + clean = strings.Split(clean, "/")[0] + clean = strings.Split(clean, ":")[0] + re := regexp.MustCompile(`[^a-zA-Z0-9\.-]`) + clean = re.ReplaceAllString(clean, "") + return clean +} + +func torrentHasTag(tags string, candidate string) bool { + if tags == "" { + return false + } + for _, tag := range strings.Split(tags, ",") { + if strings.EqualFold(strings.TrimSpace(tag), candidate) { + return true + } + } + return false +} diff --git a/web/package.json b/web/package.json index 7bbf49584..d01662bdd 100644 --- a/web/package.json +++ b/web/package.json @@ -68,6 +68,7 @@ "@eslint/js": "^9.38.0", "@stylistic/eslint-plugin": "^5.4.0", "@tailwindcss/vite": "^4.1.14", + "@tanstack/router-cli": "^1.133.10", "@types/culori": "^4.0.1", "@types/node": "^24.8.1", "@types/parse-torrent": "^5.8.7", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index e2afc4c70..45c074277 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -156,6 +156,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.14 version: 4.1.14(vite@7.1.11(@types/node@24.8.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.3)) + '@tanstack/router-cli': + specifier: ^1.133.10 + version: 1.139.1 '@types/culori': specifier: ^4.0.1 version: 4.0.1 @@ -232,6 +235,10 @@ packages: resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} @@ -240,8 +247,8 @@ packages: resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} engines: {node: '>=6.9.0'} - '@babel/helper-create-class-features-plugin@7.28.3': - resolution: {integrity: sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==} + '@babel/helper-create-class-features-plugin@7.28.5': + resolution: {integrity: sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 @@ -261,8 +268,8 @@ packages: resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-member-expression-to-functions@7.27.1': - resolution: {integrity: sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==} + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} '@babel/helper-module-imports@7.27.1': @@ -307,6 +314,10 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -324,6 +335,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.27.1': resolution: {integrity: sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==} engines: {node: '>=6.9.0'} @@ -372,6 +388,18 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-jsx@7.27.1': + resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.27.1': + resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} engines: {node: '>=6.9.0'} @@ -672,6 +700,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.28.5': + resolution: {integrity: sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-unicode-escapes@7.27.1': resolution: {integrity: sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==} engines: {node: '>=6.9.0'} @@ -707,6 +741,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} @@ -719,10 +759,18 @@ packages: resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + '@babel/types@7.28.4': resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -1804,6 +1852,10 @@ packages: resolution: {integrity: sha512-zFQnGdX0S4g5xRuS+95iiEXM+qlGvYG7ksmOKx7LaMv60lDWa0imR8/24WwXXvBWJT1KnwVdZcjvhCwz9IiJCw==} engines: {node: '>=12'} + '@tanstack/history@1.139.0': + resolution: {integrity: sha512-l6wcxwDBeh/7Dhles23U1O8lp9kNJmAb2yNjekR6olZwCRNAVA8TCXlVCrueELyFlYZqvQkh0ofxnzG62A1Kkg==} + engines: {node: '>=12'} + '@tanstack/query-core@5.90.5': resolution: {integrity: sha512-wLamYp7FaDq6ZnNehypKI5fNvxHPfTYylE0m/ZpuuzJfJqhR5Pxg9gvGBHZx4n7J+V5Rg5mZxHHTlv25Zt5u+w==} @@ -1847,13 +1899,33 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-cli@1.139.1': + resolution: {integrity: sha512-rSZSrI3f4A7V8LZRA6AHCOCv0WVYMLuyPjvHt2VBKeu8NrhC6bdIUmdn+3KxmDxIEqIO5cj/hP1o43+eMrgyGQ==} + engines: {node: '>=12'} + hasBin: true + '@tanstack/router-core@1.133.10': resolution: {integrity: sha512-7G4wCn3ex7AF6NhJ/R/K5wuIlMlVI8H7Gcr7vTjVtPkDZxeO7wB9qzKiEVcaINhikzTKMv+z2KJVGDlu2obcPA==} engines: {node: '>=12'} + '@tanstack/router-core@1.139.1': + resolution: {integrity: sha512-nPcP7mKmX38wOlqRZLITxXSo6y71LkK31Uh4xtLnO8SVEebEsNehO9p+wDYlUKnmQqWHqC+GQX5gLULVlTYmqg==} + engines: {node: '>=12'} + + '@tanstack/router-generator@1.139.1': + resolution: {integrity: sha512-HJaJlxilmn/Y2zSLOJ9tCreNaBUJaR2aB4tAJuINcjzd1rvJeB2eKJ5C4kRWD4pcJEznVUvy1sNfeJ3QSBj5Rg==} + engines: {node: '>=12'} + + '@tanstack/router-utils@1.139.0': + resolution: {integrity: sha512-jT7D6NimWqoFSkid4vCno8gvTyfL1+NHpgm3es0B2UNhKKRV3LngOGilm1m6v8Qvk/gy6Fh/tvB+s+hBl6GhOg==} + engines: {node: '>=12'} + '@tanstack/store@0.7.7': resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} + '@tanstack/store@0.8.0': + resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} + '@tanstack/table-core@8.21.3': resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} @@ -1861,6 +1933,10 @@ packages: '@tanstack/virtual-core@3.13.12': resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} + '@tanstack/virtual-file-routes@1.139.0': + resolution: {integrity: sha512-9PImF1d1tovTUIpjFVa0W7Fwj/MHif7BaaczgJJfbv3sDt1Gh+oW9W9uCw9M3ndEJynnp5ZD/TTs0RGubH5ssg==} + engines: {node: '>=12'} + '@thaunknown/thirty-two@1.0.5': resolution: {integrity: sha512-Q53KyCXweV1CS62EfqtPDqfpksn5keQ59PGqzzkK+g8Vif1jB4inoBCcs/BUSdsqddhE3G+2Fn+4RX3S6RqT0A==} engines: {node: '>=0.2.6'} @@ -2004,10 +2080,22 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2029,6 +2117,10 @@ packages: assert@2.1.0: resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -2085,6 +2177,10 @@ packages: resolution: {integrity: sha512-sMm2sV5PRs0YOVk0LTKtjuIprVzxgTQUsrGX/7Yph2Rm4FO2Fqqtq7hNjsOB5xezM4v4+5rljCgK++UeQJZguA==} engines: {node: '>=12.20.0'} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + bn.js@4.12.2: resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} @@ -2167,6 +2263,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -2178,6 +2278,10 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2313,6 +2417,10 @@ packages: devalue@5.4.1: resolution: {integrity: sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==} + diff@8.0.2: + resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} + engines: {node: '>=0.3.1'} + diffie-hellman@5.0.3: resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==} @@ -2335,6 +2443,9 @@ packages: elliptic@6.6.1: resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} @@ -2413,6 +2524,11 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + esquery@1.6.0: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} @@ -2563,6 +2679,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2722,6 +2842,10 @@ packages: resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} engines: {node: '>= 0.4'} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + is-boolean-object@1.2.2: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} @@ -2750,6 +2874,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.1: resolution: {integrity: sha512-Gn8BWUdrTzf9XUJAvqIYP7QnSC3mKs8QjQdGdJ7HmBemzZo14wj/OVmmAwgxDX/7WhFEjboybL4VhXGIQYPlOA==} engines: {node: '>= 0.4'} @@ -3108,6 +3236,10 @@ packages: resolution: {integrity: sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw==} engines: {node: '>=10'} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -3184,6 +3316,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pbkdf2@3.1.5: resolution: {integrity: sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==} engines: {node: '>= 0.10'} @@ -3215,6 +3350,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + pretty-bytes@5.6.0: resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} engines: {node: '>=6'} @@ -3331,6 +3471,14 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + recast@0.23.11: + resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} + engines: {node: '>= 4'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -3357,6 +3505,10 @@ packages: resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} hasBin: true + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -3438,10 +3590,20 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.4.0: + resolution: {integrity: sha512-zir1aWzoiax6pbBVjoYVd0O1QQXgIL3eVGBMsBsNmM8Ukq90yGaWlfx0AB9dTS8GPqrOrbXn79vmItCUP9U3BQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.3.2: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} + seroval@1.4.0: + resolution: {integrity: sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==} + engines: {node: '>=10'} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3506,6 +3668,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} @@ -3525,6 +3691,10 @@ packages: stream-http@3.2.0: resolution: {integrity: sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string.prototype.matchall@4.0.12: resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} engines: {node: '>= 0.4'} @@ -3551,6 +3721,10 @@ packages: resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} engines: {node: '>=4'} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-comments@2.0.1: resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} engines: {node: '>=10'} @@ -3901,6 +4075,10 @@ packages: workbox-window@7.3.0: resolution: {integrity: sha512-qW8PDy16OV1UBaUNGlTVcepzrlzyzNW/ZJvFQQs2j2TzGsg6IKjcpZC1RSquqQnTOafl5pCj5bGfAHlCjOOjdA==} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3908,6 +4086,10 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3915,6 +4097,14 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3925,6 +4115,9 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.12: resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} @@ -3973,9 +4166,17 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.28.4 + '@babel/types': 7.28.5 '@babel/helper-compilation-targets@7.27.2': dependencies: @@ -3985,15 +4186,15 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.28.3(@babel/core@7.28.4)': + '@babel/helper-create-class-features-plugin@7.28.5(@babel/core@7.28.4)': dependencies: '@babel/core': 7.28.4 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.27.1 + '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.4) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.28.4 + '@babel/traverse': 7.28.5 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -4018,10 +4219,10 @@ snapshots: '@babel/helper-globals@7.28.0': {} - '@babel/helper-member-expression-to-functions@7.27.1': + '@babel/helper-member-expression-to-functions@7.28.5': dependencies: - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color @@ -4043,7 +4244,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.28.4 + '@babel/types': 7.28.5 '@babel/helper-plugin-utils@7.27.1': {} @@ -4052,23 +4253,23 @@ snapshots: '@babel/core': 7.28.4 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-wrap-function': 7.28.3 - '@babel/traverse': 7.28.4 + '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color '@babel/helper-replace-supers@7.27.1(@babel/core@7.28.4)': dependencies: '@babel/core': 7.28.4 - '@babel/helper-member-expression-to-functions': 7.27.1 + '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.28.4 + '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color @@ -4076,13 +4277,15 @@ snapshots: '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helper-wrap-function@7.28.3': dependencies: '@babel/template': 7.27.2 - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color @@ -4095,11 +4298,15 @@ snapshots: dependencies: '@babel/types': 7.28.4 + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.27.1(@babel/core@7.28.4)': dependencies: '@babel/core': 7.28.4 '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.28.4 + '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color @@ -4126,7 +4333,7 @@ snapshots: dependencies: '@babel/core': 7.28.4 '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.28.4 + '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color @@ -4144,6 +4351,16 @@ snapshots: '@babel/core': 7.28.4 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.28.4)': dependencies: '@babel/core': 7.28.4 @@ -4160,7 +4377,7 @@ snapshots: '@babel/core': 7.28.4 '@babel/helper-plugin-utils': 7.27.1 '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.28.4) - '@babel/traverse': 7.28.4 + '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color @@ -4186,7 +4403,7 @@ snapshots: '@babel/plugin-transform-class-properties@7.27.1(@babel/core@7.28.4)': dependencies: '@babel/core': 7.28.4 - '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4) + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.4) '@babel/helper-plugin-utils': 7.27.1 transitivePeerDependencies: - supports-color @@ -4194,7 +4411,7 @@ snapshots: '@babel/plugin-transform-class-static-block@7.28.3(@babel/core@7.28.4)': dependencies: '@babel/core': 7.28.4 - '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4) + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.4) '@babel/helper-plugin-utils': 7.27.1 transitivePeerDependencies: - supports-color @@ -4207,7 +4424,7 @@ snapshots: '@babel/helper-globals': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.4) - '@babel/traverse': 7.28.4 + '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color @@ -4221,7 +4438,7 @@ snapshots: dependencies: '@babel/core': 7.28.4 '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.28.4 + '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color @@ -4278,7 +4495,7 @@ snapshots: '@babel/core': 7.28.4 '@babel/helper-compilation-targets': 7.27.2 '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.28.4 + '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color @@ -4323,8 +4540,8 @@ snapshots: '@babel/core': 7.28.4 '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.28.4 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color @@ -4364,7 +4581,7 @@ snapshots: '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-destructuring': 7.28.0(@babel/core@7.28.4) '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.28.4) - '@babel/traverse': 7.28.4 + '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color @@ -4397,7 +4614,7 @@ snapshots: '@babel/plugin-transform-private-methods@7.27.1(@babel/core@7.28.4)': dependencies: '@babel/core': 7.28.4 - '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4) + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.4) '@babel/helper-plugin-utils': 7.27.1 transitivePeerDependencies: - supports-color @@ -4406,7 +4623,7 @@ snapshots: dependencies: '@babel/core': 7.28.4 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4) + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.4) '@babel/helper-plugin-utils': 7.27.1 transitivePeerDependencies: - supports-color @@ -4470,6 +4687,17 @@ snapshots: '@babel/core': 7.28.4 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-typescript@7.28.5(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.4) + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.4) + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.28.4)': dependencies: '@babel/core': 7.28.4 @@ -4573,9 +4801,20 @@ snapshots: dependencies: '@babel/core': 7.28.4 '@babel/helper-plugin-utils': 7.27.1 - '@babel/types': 7.28.4 + '@babel/types': 7.28.5 esutils: 2.0.3 + '@babel/preset-typescript@7.28.5(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.4) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.4) + '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.4) + transitivePeerDependencies: + - supports-color + '@babel/runtime@7.28.4': {} '@babel/template@7.27.2': @@ -4596,11 +4835,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.28.4': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@dnd-kit/accessibility@3.1.1(react@19.2.0)': dependencies: react: 19.2.0 @@ -5588,6 +5844,8 @@ snapshots: '@tanstack/history@1.133.3': {} + '@tanstack/history@1.139.0': {} + '@tanstack/query-core@5.90.5': {} '@tanstack/react-form@1.23.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': @@ -5635,6 +5893,14 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + '@tanstack/router-cli@1.139.1': + dependencies: + '@tanstack/router-generator': 1.139.1 + chokidar: 3.6.0 + yargs: 17.7.2 + transitivePeerDependencies: + - supports-color + '@tanstack/router-core@1.133.10': dependencies: '@tanstack/history': 1.133.3 @@ -5645,12 +5911,52 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 + '@tanstack/router-core@1.139.1': + dependencies: + '@tanstack/history': 1.139.0 + '@tanstack/store': 0.8.0 + cookie-es: 2.0.0 + seroval: 1.4.0 + seroval-plugins: 1.4.0(seroval@1.4.0) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/router-generator@1.139.1': + dependencies: + '@tanstack/router-core': 1.139.1 + '@tanstack/router-utils': 1.139.0 + '@tanstack/virtual-file-routes': 1.139.0 + prettier: 3.6.2 + recast: 0.23.11 + source-map: 0.7.6 + tsx: 4.20.3 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-utils@1.139.0': + dependencies: + '@babel/core': 7.28.4 + '@babel/generator': 7.28.5 + '@babel/parser': 7.28.5 + '@babel/preset-typescript': 7.28.5(@babel/core@7.28.4) + ansis: 4.2.0 + diff: 8.0.2 + pathe: 2.0.3 + tinyglobby: 0.2.15 + transitivePeerDependencies: + - supports-color + '@tanstack/store@0.7.7': {} + '@tanstack/store@0.8.0': {} + '@tanstack/table-core@8.21.3': {} '@tanstack/virtual-core@3.13.12': {} + '@tanstack/virtual-file-routes@1.139.0': {} + '@thaunknown/thirty-two@1.0.5': dependencies: uint8-util: 2.2.5 @@ -5845,10 +6151,19 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansis@4.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -5884,6 +6199,10 @@ snapshots: object.assign: 4.1.7 util: 0.12.5 + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + async-function@1.0.0: {} async@3.2.6: {} @@ -5934,6 +6253,8 @@ snapshots: bep53-range@2.0.0: {} + binary-extensions@2.3.0: {} + bn.js@4.12.2: {} bn.js@5.2.2: {} @@ -6046,6 +6367,18 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + chownr@3.0.0: {} cipher-base@1.0.7: @@ -6058,6 +6391,12 @@ snapshots: dependencies: clsx: 2.1.1 + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): @@ -6207,6 +6546,8 @@ snapshots: devalue@5.4.1: {} + diff@8.0.2: {} + diffie-hellman@5.0.3: dependencies: bn.js: 4.12.2 @@ -6237,6 +6578,8 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 + emoji-regex@8.0.0: {} + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 @@ -6424,6 +6767,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 + esprima@4.0.1: {} + esquery@1.6.0: dependencies: estraverse: 5.3.0 @@ -6556,6 +6901,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6589,7 +6936,6 @@ snapshots: get-tsconfig@4.12.0: dependencies: resolve-pkg-maps: 1.0.0 - optional: true glob-parent@5.1.2: dependencies: @@ -6727,6 +7073,10 @@ snapshots: dependencies: has-bigints: 1.1.0 + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + is-boolean-object@1.2.2: dependencies: call-bound: 1.0.4 @@ -6755,6 +7105,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.1: dependencies: call-bound: 1.0.4 @@ -7085,6 +7437,8 @@ snapshots: util: 0.12.5 vm-browserify: 1.1.2 + normalize-path@3.0.0: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -7167,6 +7521,8 @@ snapshots: path-parse@1.0.7: {} + pathe@2.0.3: {} + pbkdf2@3.1.5: dependencies: create-hash: 1.2.0 @@ -7196,6 +7552,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.6.2: {} + pretty-bytes@5.6.0: {} pretty-bytes@6.1.1: {} @@ -7310,6 +7668,18 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + recast@0.23.11: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -7351,12 +7721,13 @@ snapshots: dependencies: jsesc: 3.1.0 + require-directory@2.1.1: {} + require-from-string@2.0.2: {} resolve-from@4.0.0: {} - resolve-pkg-maps@1.0.0: - optional: true + resolve-pkg-maps@1.0.0: {} resolve@1.22.10: dependencies: @@ -7450,8 +7821,14 @@ snapshots: dependencies: seroval: 1.3.2 + seroval-plugins@1.4.0(seroval@1.4.0): + dependencies: + seroval: 1.4.0 + seroval@1.3.2: {} + seroval@1.4.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -7532,6 +7909,8 @@ snapshots: source-map@0.6.1: {} + source-map@0.7.6: {} + source-map@0.8.0-beta.0: dependencies: whatwg-url: 7.1.0 @@ -7555,6 +7934,12 @@ snapshots: readable-stream: 3.6.2 xtend: 4.0.2 + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string.prototype.matchall@4.0.12: dependencies: call-bind: 1.0.8 @@ -7608,6 +7993,10 @@ snapshots: is-obj: 1.0.1 is-regexp: 1.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -7687,7 +8076,6 @@ snapshots: get-tsconfig: 4.12.0 optionalDependencies: fsevents: 2.3.3 - optional: true tty-browserify@0.0.1: {} @@ -8028,18 +8416,40 @@ snapshots: '@types/trusted-types': 2.0.7 workbox-core: 7.3.0 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} xtend@4.0.2: {} + y18n@5.0.8: {} + yallist@3.1.1: {} yallist@5.0.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@4.1.12): dependencies: zod: 4.1.12 + zod@3.25.76: {} + zod@4.1.12: {} diff --git a/web/src/components/instances/preferences/InstancePreferencesDialog.tsx b/web/src/components/instances/preferences/InstancePreferencesDialog.tsx index 98544e0e1..e3e91d652 100644 --- a/web/src/components/instances/preferences/InstancePreferencesDialog.tsx +++ b/web/src/components/instances/preferences/InstancePreferencesDialog.tsx @@ -5,7 +5,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Clock, Cog, Folder, Gauge, Radar, RefreshCcw, Settings, Upload, Wifi } from "lucide-react" +import { Clock, Cog, Folder, Gauge, Radar, Settings, Upload, Wifi } from "lucide-react" import { AdvancedNetworkForm } from "./AdvancedNetworkForm" import { ConnectionSettingsForm } from "./ConnectionSettingsForm" import { FileManagementForm } from "./FileManagementForm" @@ -13,7 +13,6 @@ import { NetworkDiscoveryForm } from "./NetworkDiscoveryForm" import { QueueManagementForm } from "./QueueManagementForm" import { SeedingLimitsForm } from "./SeedingLimitsForm" import { SpeedLimitsForm } from "./SpeedLimitsForm" -import { TrackerReannounceForm } from "./TrackerReannounceForm" interface InstancePreferencesDialogProps { open: boolean @@ -49,7 +48,7 @@ export function InstancePreferencesDialog({ - + Speed @@ -78,10 +77,6 @@ export function InstancePreferencesDialog({ Advanced - - - Reannounce - @@ -154,15 +149,6 @@ export function InstancePreferencesDialog({ - -
-

Tracker Reannounce

-

- Configure how qui monitors and reannounces torrents whose trackers initially respond with errors. -

-
- -
diff --git a/web/src/components/instances/preferences/TrackerReannounceForm.tsx b/web/src/components/instances/preferences/TrackerReannounceForm.tsx index ffed32fbd..f47c0fb43 100644 --- a/web/src/components/instances/preferences/TrackerReannounceForm.tsx +++ b/web/src/components/instances/preferences/TrackerReannounceForm.tsx @@ -14,6 +14,7 @@ import { Separator } from "@/components/ui/separator" import { Switch } from "@/components/ui/switch" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { buildCategoryTree, type CategoryNode } from "@/components/torrents/CategoryTree" import { useInstances } from "@/hooks/useInstances" import { useInstanceTrackers } from "@/hooks/useInstanceTrackers" import { api } from "@/lib/api" @@ -87,12 +88,24 @@ export function TrackerReannounceForm({ instanceId, onSuccess }: TrackerReannoun const categoryOptions: Option[] = useMemo(() => { if (!categoriesQuery.data) return [] - return Object.values(categoriesQuery.data) - .map((category) => ({ - label: category.name, - value: category.name, - })) - .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" })) + + // Build tree and flatten with level info for indentation + const tree = buildCategoryTree(categoriesQuery.data, {}) + const flattened: Option[] = [] + + const visitNodes = (nodes: CategoryNode[]) => { + for (const node of nodes) { + flattened.push({ + label: node.displayName, + value: node.name, + level: node.level, + }) + visitNodes(node.children) + } + } + + visitNodes(tree) + return flattened }, [categoriesQuery.data]) const tagOptions: Option[] = useMemo(() => { @@ -121,14 +134,13 @@ export function TrackerReannounceForm({ instanceId, onSuccess }: TrackerReannoun }) } - const handleSubmit = (event: React.FormEvent) => { - event.preventDefault() + const persistSettings = (nextSettings: InstanceReannounceSettings, successMessage = "Settings saved successfully.") => { if (!instance) { toast.error("Instance missing", { description: "Please close and reopen the dialog." }) return } - const sanitized = sanitizeSettings(settings) + const sanitized = sanitizeSettings(nextSettings) const payload: Partial = { name: instance.name, host: instance.host, @@ -145,7 +157,7 @@ export function TrackerReannounceForm({ instanceId, onSuccess }: TrackerReannoun { id: instanceId, data: payload }, { onSuccess: () => { - toast.success("Tracker monitoring updated", { description: "Settings saved successfully." }) + toast.success("Tracker monitoring updated", { description: successMessage }) onSuccess?.() }, onError: (error) => { @@ -155,6 +167,20 @@ export function TrackerReannounceForm({ instanceId, onSuccess }: TrackerReannoun ) } + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault() + persistSettings(settings) + } + + const handleToggleEnabled = (enabled: boolean) => { + const nextSettings = { ...settings, enabled } + setSettings(nextSettings) + + if (!enabled) { + persistSettings(nextSettings, "Monitoring disabled") + } + } + const activityQuery = useQuery({ queryKey: ["instance-reannounce-activity", instanceId], queryFn: () => api.getInstanceReannounceActivity(instanceId, 50), @@ -189,30 +215,41 @@ export function TrackerReannounceForm({ instanceId, onSuccess }: TrackerReannoun return (
- - -
+ + +
- Automatic Tracker Reannounce +
+ Automatic Tracker Reannounce + + + + + +

qBittorrent doesn't retry failed announces quickly. When a tracker is slow to register a new upload or returns an error, you may be stuck waiting. qui handles this automatically while never spamming trackers.

+
+
+
qui monitors stalled torrents and reannounces them if trackers report "unregistered" or errors. Background scan runs every {GLOBAL_SCAN_INTERVAL_SECONDS} seconds.
-
+
setSettings((prev) => ({ ...prev, enabled }))} + onCheckedChange={handleToggleEnabled} + disabled={isUpdating} />
- +
@@ -244,7 +281,7 @@ export function TrackerReannounceForm({ instanceId, onSuccess }: TrackerReannoun id="reannounce-interval" label="Retry Interval" description="Seconds between retries" - tooltip="How often to retry inside a single reannounce attempt (up to 3 tries). Aggressive Mode only removes the 2-minute cooldown between scans; this interval still applies. Minimum 5 seconds." + tooltip="How often to retry inside a single reannounce attempt (up to 3 tries). With Quick Retry enabled, this also becomes the cooldown between scans. Minimum 5 seconds." min={MIN_INTERVAL} value={settings.reannounceIntervalSeconds} onChange={(value) => setSettings((prev) => ({ ...prev, reannounceIntervalSeconds: value }))} @@ -263,26 +300,22 @@ export function TrackerReannounceForm({ instanceId, onSuccess }: TrackerReannoun
- + -

- Normally, we wait 2 minutes between attempts to be polite. - Enable this to skip that cooldown and let the next 7s scan retry immediately if the torrent is still stalled. - Per-torrent retries inside each attempt still follow the Retry Interval. -

+

Use the Retry Interval as the cooldown between scans instead of the default 2 minutes. Useful on trackers that are slow to register new uploads. qui always waits while a tracker is updating and never spams.

- Skip the 2-minute cooldown between scans (Retry Interval still applies) + Use Retry Interval for cooldown instead of 2 minutes

setSettings((prev) => ({ ...prev, aggressive }))} /> @@ -485,7 +518,7 @@ export function TrackerReannounceForm({ instanceId, onSuccess }: TrackerReannoun

Loading activity...

) : activityEvents.length === 0 ? ( -
+

No activity recorded yet.

{activityEnabled && (

diff --git a/web/src/components/instances/preferences/TrackerRulesPanel.tsx b/web/src/components/instances/preferences/TrackerRulesPanel.tsx new file mode 100644 index 000000000..d930bf4ce --- /dev/null +++ b/web/src/components/instances/preferences/TrackerRulesPanel.tsx @@ -0,0 +1,530 @@ +/* + * Copyright (c) 2025, s0up and the autobrr contributors. + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { MultiSelect, type Option } from "@/components/ui/multi-select" +import { Switch } from "@/components/ui/switch" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { useInstanceTrackers } from "@/hooks/useInstanceTrackers" +import { api } from "@/lib/api" +import { cn } from "@/lib/utils" +import type { TrackerRule, TrackerRuleInput } from "@/types" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { ArrowDown, ArrowUp, Clock, Loader2, Pencil, Plus, RefreshCw, Scale, Trash2 } from "lucide-react" +import { useMemo, useState } from "react" +import { toast } from "sonner" + +interface TrackerRulesPanelProps { + instanceId: number +} + +type FormState = TrackerRuleInput & { trackerDomains: string[] } + +const emptyFormState: FormState = { + name: "", + trackerPattern: "", + trackerDomains: [], + category: "", + tag: "", + uploadLimitKiB: undefined, + downloadLimitKiB: undefined, + ratioLimit: undefined, + seedingTimeLimitMinutes: undefined, + enabled: true, +} + +export function TrackerRulesPanel({ instanceId }: TrackerRulesPanelProps) { + const queryClient = useQueryClient() + const [dialogOpen, setDialogOpen] = useState(false) + const [editingRule, setEditingRule] = useState(null) + const [formState, setFormState] = useState(emptyFormState) + + const trackersQuery = useInstanceTrackers(instanceId, { enabled: true }) + const trackerOptions: Option[] = useMemo(() => { + if (!trackersQuery.data) return [] + return Object.keys(trackersQuery.data) + .map((domain) => ({ label: domain, value: domain })) + .sort((a, b) => a.label.localeCompare(b.label)) + }, [trackersQuery.data]) + + const rulesQuery = useQuery({ + queryKey: ["tracker-rules", instanceId], + queryFn: () => api.listTrackerRules(instanceId), + }) + + const createOrUpdate = useMutation({ + mutationFn: async (input: FormState) => { + if (editingRule) { + return api.updateTrackerRule(instanceId, editingRule.id, input) + } + return api.createTrackerRule(instanceId, input) + }, + onSuccess: () => { + toast.success(`Tracker rule ${editingRule ? "updated" : "created"}`) + setDialogOpen(false) + setEditingRule(null) + setFormState(emptyFormState) + void queryClient.invalidateQueries({ queryKey: ["tracker-rules", instanceId] }) + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to save tracker rule") + }, + }) + + const deleteRule = useMutation({ + mutationFn: (ruleId: number) => api.deleteTrackerRule(instanceId, ruleId), + onSuccess: () => { + toast.success("Tracker rule deleted") + void queryClient.invalidateQueries({ queryKey: ["tracker-rules", instanceId] }) + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to delete tracker rule") + }, + }) + + const reorderRules = useMutation({ + mutationFn: (orderedIds: number[]) => api.reorderTrackerRules(instanceId, orderedIds), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["tracker-rules", instanceId] }) + }, + }) + + const toggleEnabled = useMutation({ + mutationFn: (rule: TrackerRule) => api.updateTrackerRule(instanceId, rule.id, { ...rule, enabled: !rule.enabled }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["tracker-rules", instanceId] }) + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to toggle rule") + }, + }) + + const applyRules = useMutation({ + mutationFn: () => api.applyTrackerRules(instanceId), + onSuccess: () => { + toast.success("Tracker rules applied") + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to apply tracker rules") + }, + }) + + const sortedRules = useMemo(() => { + const rules = rulesQuery.data ?? [] + return [...rules].sort((a, b) => a.sortOrder - b.sortOrder || a.id - b.id) + }, [rulesQuery.data]) + + const openForCreate = () => { + setEditingRule(null) + setFormState(emptyFormState) + setDialogOpen(true) + } + + const openForEdit = (rule: TrackerRule) => { + const domains = parseTrackerDomains(rule) + setEditingRule(rule) + setFormState({ + name: rule.name, + trackerPattern: rule.trackerPattern, + trackerDomains: domains, + category: rule.category, + tag: rule.tag, + uploadLimitKiB: rule.uploadLimitKiB, + downloadLimitKiB: rule.downloadLimitKiB, + ratioLimit: rule.ratioLimit, + seedingTimeLimitMinutes: rule.seedingTimeLimitMinutes, + enabled: rule.enabled, + sortOrder: rule.sortOrder, + }) + setDialogOpen(true) + } + + const handleMove = (ruleId: number, direction: -1 | 1) => { + if (!sortedRules) return + const index = sortedRules.findIndex(r => r.id === ruleId) + const target = index + direction + if (index === -1 || target < 0 || target >= sortedRules.length) { + return + } + const nextOrder = sortedRules.map(r => r.id) + const [removed] = nextOrder.splice(index, 1) + nextOrder.splice(target, 0, removed) + reorderRules.mutate(nextOrder) + } + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault() + if (!formState.name) { + toast.error("Name is required") + return + } + const selectedTrackers = formState.trackerDomains.filter(Boolean) + if (selectedTrackers.length === 0) { + toast.error("Select at least one tracker") + return + } + const payload: FormState = { + ...formState, + trackerDomains: selectedTrackers, + trackerPattern: selectedTrackers.join(","), + category: formState.category || undefined, + tag: formState.tag || undefined, + } + createOrUpdate.mutate(payload) + } + + return ( +

+ + +
+ Tracker Rules + Apply speed and ratio caps per tracker domain. +
+
+ + +
+
+ + {rulesQuery.isLoading ? ( +
+ + Loading rules... +
+ ) : (sortedRules?.length ?? 0) === 0 ? ( +

No tracker rules yet. Add one to start enforcing per-tracker limits.

+ ) : ( +
+ {sortedRules.map((rule) => { + const actions = ( + <> + + + + + + ) + + return ( +
+
+
+
+ toggleEnabled.mutate(rule)} + disabled={toggleEnabled.isPending} + className="shrink-0" + /> + {rule.name} + {!rule.enabled && ( + + Disabled + + )} +
+
+ {actions} +
+
+ +
+ +
+ {actions} +
+
+ ) + })} +
+ )} +
+
+ + + + + {editingRule ? "Edit Tracker Rule" : "Add Tracker Rule"} + Match on tracker domain and optionally category/tag, then apply limits. + + +
+
+ + setFormState(prev => ({ ...prev, name: e.target.value }))} + required + placeholder="Tracker-specific rule" + autoComplete="off" + data-1p-ignore + /> +
+
+ + setFormState(prev => ({ ...prev, trackerDomains: next }))} + placeholder="Select trackers..." + creatable + onCreateOption={(value) => setFormState(prev => ({ ...prev, trackerDomains: [...prev.trackerDomains, value] }))} + disabled={trackersQuery.isLoading} + /> +

+ Choose from detected trackers or type a custom domain/glob (creates an entry). +

+
+
+ +
+
+ + setFormState(prev => ({ ...prev, category: e.target.value || undefined }))} + placeholder="e.g. tv" + /> +
+
+ + setFormState(prev => ({ ...prev, tag: e.target.value || undefined }))} + placeholder="e.g. autobrr" + /> +
+
+ +
+
+ + setFormState(prev => ({ ...prev, uploadLimitKiB: e.target.value ? Number(e.target.value) : undefined }))} + placeholder="Leave blank to skip" + /> +
+
+ + setFormState(prev => ({ ...prev, downloadLimitKiB: e.target.value ? Number(e.target.value) : undefined }))} + placeholder="Leave blank to skip" + /> +
+
+ +
+
+ + setFormState(prev => ({ ...prev, ratioLimit: e.target.value ? Number(e.target.value) : undefined }))} + placeholder="e.g. 2.0" + /> +
+
+ + setFormState(prev => ({ ...prev, seedingTimeLimitMinutes: e.target.value ? Number(e.target.value) : undefined }))} + placeholder="e.g. 1440" + /> +
+
+ +
+
+
+ +

Rule is active and will be applied.

+
+ setFormState(prev => ({ ...prev, enabled: checked }))} + /> +
+
+ + + + + + +
+
+
+ ) +} + +function RuleSummary({ rule }: { rule: TrackerRule }) { + const trackers = parseTrackerDomains(rule) + + const hasActions = + rule.downloadLimitKiB !== undefined || + rule.uploadLimitKiB !== undefined || + rule.ratioLimit !== undefined || + rule.seedingTimeLimitMinutes !== undefined + + if (!hasActions && trackers.length === 0 && !rule.category && !rule.tag) { + return No actions set + } + + return ( +
+ {trackers.length > 0 && ( + + + + {trackers[0]} + {trackers.length > 1 && ( + + +{trackers.length - 1} + + )} + + + +

{trackers.join(", ")}

+
+
+ )} + + {rule.category && ( + + Cat: {rule.category} + + )} + + {rule.tag && ( + + Tag: {rule.tag} + + )} + + {rule.uploadLimitKiB !== undefined && ( + + + UL {rule.uploadLimitKiB} KiB/s + + )} + + {rule.downloadLimitKiB !== undefined && ( + + + DL {rule.downloadLimitKiB} KiB/s + + )} + + {rule.ratioLimit !== undefined && ( + + + Ratio {rule.ratioLimit} + + )} + + {rule.seedingTimeLimitMinutes !== undefined && ( + + + {rule.seedingTimeLimitMinutes}m + + )} +
+ ) +} + +function parseTrackerDomains(rule: TrackerRule): string[] { + if (rule.trackerDomains && rule.trackerDomains.length > 0) { + return rule.trackerDomains + } + if (!rule.trackerPattern) return [] + return rule.trackerPattern + .split(/[|,;]/) + .map((item) => item.trim()) + .filter(Boolean) +} diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index a873df082..6376017d0 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -36,7 +36,7 @@ import { cn } from "@/lib/utils" import type { InstanceCapabilities } from "@/types" import { useQuery } from "@tanstack/react-query" import { Link, useNavigate, useSearch } from "@tanstack/react-router" -import { Archive, ChevronsUpDown, Download, FileEdit, FunnelPlus, FunnelX, GitBranch, HardDrive, Home, Info, ListTodo, Loader2, LogOut, Menu, Plus, Rss, Search, SearchCode, Server, Settings, X } from "lucide-react" +import { Archive, ChevronsUpDown, Download, FileEdit, FunnelPlus, FunnelX, GitBranch, HardDrive, Home, Info, ListTodo, Loader2, LogOut, Menu, Plus, Rss, Search, SearchCode, Server, Settings, Wrench, X } from "lucide-react" import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react" import { useHotkeys } from "react-hotkeys-hook" @@ -508,6 +508,15 @@ export function Header({ Cross-Seed + + + + Services + + + + + + Services + + ("speed") // Filter lifecycle state machine to replace fragile timing-based coordination type FilterLifecycleState = 'idle' | 'clearing-all' | 'clearing-columns-only' | 'cleared' @@ -877,6 +876,7 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ // Debounce search to prevent excessive filtering (200ms delay for faster response) const debouncedSearch = useDebounce(globalFilter, 200) const routeSearch = useSearch({ strict: false }) as { q?: string } + const navigate = useNavigate() const rawRouteSearch = typeof routeSearch?.q === "string" ? routeSearch.q : "" const searchFromRoute = rawRouteSearch.trim() @@ -2801,8 +2801,10 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ onClick={(e) => { e.preventDefault() e.stopPropagation() - setPreferencesDefaultTab("reannounce") - setPreferencesOpen(true) + void navigate({ + to: "/services", + search: { instanceId: String(instanceId) }, + }) }} className="h-6 w-6 text-muted-foreground hover:text-accent-foreground" > @@ -3073,7 +3075,6 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({ onOpenChange={setPreferencesOpen} instanceId={instanceId} instanceName={instance.name} - defaultTab={preferencesDefaultTab} /> )} diff --git a/web/src/components/ui/multi-select.tsx b/web/src/components/ui/multi-select.tsx index 33d27b6ee..856fb33ff 100644 --- a/web/src/components/ui/multi-select.tsx +++ b/web/src/components/ui/multi-select.tsx @@ -9,6 +9,7 @@ import * as React from "react" export interface Option { label: string value: string + level?: number } interface MultiSelectProps { @@ -152,7 +153,12 @@ export function MultiSelect({ selected.includes(option.value) ? "opacity-100" : "opacity-0" )} /> - {option.label} + + {option.label} + ))} diff --git a/web/src/hooks/useInstances.ts b/web/src/hooks/useInstances.ts index c688d2e85..71af6027d 100644 --- a/web/src/hooks/useInstances.ts +++ b/web/src/hooks/useInstances.ts @@ -50,10 +50,13 @@ export function useInstances() { data: Partial }) => api.updateInstance(id, data), onSuccess: async (updatedInstance) => { - // Immediately update the instance in cache + // Immediately update the instance in cache, preserving only the connected + // flag to avoid UI flicker (testConnection will refresh it) queryClient.setQueryData(["instances"], (old) => { if (!old) return [updatedInstance] - return old.map(i => i.id === updatedInstance.id ? updatedInstance : i) + return old.map(i => i.id === updatedInstance.id + ? { ...updatedInstance, connected: i.connected } + : i) }) // Test connection immediately to get actual status diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 46aeb141d..5e470bcd9 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -51,6 +51,8 @@ import type { TorrentProperties, TorrentResponse, TorrentTracker, + TrackerRule, + TrackerRuleInput, TorznabIndexer, TorznabIndexerError, TorznabIndexerFormData, @@ -1217,6 +1219,43 @@ class ApiClient { return this.request(`/instances/${instanceId}/trackers`) } + async listTrackerRules(instanceId: number): Promise { + return this.request(`/instances/${instanceId}/tracker-rules`) + } + + async createTrackerRule(instanceId: number, payload: TrackerRuleInput): Promise { + return this.request(`/instances/${instanceId}/tracker-rules`, { + method: "POST", + body: JSON.stringify(payload), + }) + } + + async updateTrackerRule(instanceId: number, ruleId: number, payload: TrackerRuleInput): Promise { + return this.request(`/instances/${instanceId}/tracker-rules/${ruleId}`, { + method: "PUT", + body: JSON.stringify(payload), + }) + } + + async deleteTrackerRule(instanceId: number, ruleId: number): Promise { + return this.request(`/instances/${instanceId}/tracker-rules/${ruleId}`, { + method: "DELETE", + }) + } + + async reorderTrackerRules(instanceId: number, orderedIds: number[]): Promise { + return this.request(`/instances/${instanceId}/tracker-rules/order`, { + method: "PUT", + body: JSON.stringify({ orderedIds }), + }) + } + + async applyTrackerRules(instanceId: number): Promise { + return this.request(`/instances/${instanceId}/tracker-rules/apply`, { + method: "POST", + }) + } + // User endpoints async changePassword(currentPassword: string, newPassword: string): Promise { return this.request("/auth/change-password", { diff --git a/web/src/pages/CrossSeedPage.tsx b/web/src/pages/CrossSeedPage.tsx index a33a89eca..86bba22e3 100644 --- a/web/src/pages/CrossSeedPage.tsx +++ b/web/src/pages/CrossSeedPage.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ +import { buildCategoryTree, type CategoryNode } from "@/components/torrents/CategoryTree" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -677,22 +678,37 @@ export function CrossSeedPage() { [enabledIndexers] ) - const searchCategoryNames = useMemo(() => { - if (!searchMetadata?.categories) return [] as string[] - return Object.keys(searchMetadata.categories).sort() - }, [searchMetadata]) - const searchTagNames = useMemo(() => searchMetadata?.tags ?? [], [searchMetadata]) const searchCategorySelectOptions = useMemo( () => { - const extras = searchCategories.filter(category => !searchCategoryNames.includes(category)) - return Array.from(new Set([...searchCategoryNames, ...extras])).map(category => ({ - label: category, - value: category, - })) + // Build tree from available categories for indentation + const categories = searchMetadata?.categories ?? {} + const tree = buildCategoryTree(categories, {}) + const flattened: { label: string; value: string; level: number }[] = [] + + const visitNodes = (nodes: CategoryNode[]) => { + for (const node of nodes) { + flattened.push({ + label: node.displayName, + value: node.name, + level: node.level, + }) + visitNodes(node.children) + } + } + + visitNodes(tree) + + // Add any extra categories that were manually typed but not in the list + const extras = searchCategories.filter(category => !flattened.some(opt => opt.value === category)) + for (const extra of extras) { + flattened.push({ label: extra, value: extra, level: 0 }) + } + + return flattened }, - [searchCategories, searchCategoryNames] + [searchCategories, searchMetadata?.categories] ) const searchTagSelectOptions = useMemo( diff --git a/web/src/pages/Services.tsx b/web/src/pages/Services.tsx new file mode 100644 index 000000000..ea8233794 --- /dev/null +++ b/web/src/pages/Services.tsx @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025, s0up and the autobrr contributors. + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import { TrackerReannounceForm } from "@/components/instances/preferences/TrackerReannounceForm" +import { TrackerRulesPanel } from "@/components/instances/preferences/TrackerRulesPanel" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { useInstances } from "@/hooks/useInstances" +import { useNavigate, useSearch } from "@tanstack/react-router" +import { HardDrive } from "lucide-react" +import { useMemo } from "react" + +type ServicesSearch = { + instanceId?: string +} + +export function Services() { + const { instances } = useInstances() + const navigate = useNavigate() + const search = useSearch({ from: "/_authenticated/services" }) as ServicesSearch + + const activeInstances = useMemo( + () => (instances ?? []).filter((instance) => instance.isActive), + [instances] + ) + + const selectedInstanceId = useMemo(() => { + const fromSearch = search.instanceId ? Number(search.instanceId) : undefined + const allInstances = instances ?? [] + if (fromSearch && allInstances.some((inst) => inst.id === fromSearch)) { + return fromSearch + } + if (allInstances.length > 0) { + return allInstances[0]?.id + } + return undefined + }, [instances, search.instanceId]) + + const handleInstanceChange = (value: string) => { + navigate({ + to: "/services", + search: (prev: ServicesSearch) => ({ + ...prev, + instanceId: value, + }) satisfies ServicesSearch, + replace: true, + }) + } + + const selectedInstance = activeInstances.find((inst) => inst.id === selectedInstanceId) + + return ( +
+
+
+

Services

+

+ Instance-level automation and helper services managed by qui. +

+
+ +
+ {instances && instances.length > 0 && ( + + )} +
+
+ + {instances && instances.length === 0 && ( +

+ No instances configured yet. Add one in Settings to use services. +

+ )} + + {!selectedInstance && instances && instances.length > 0 && ( +

+ Select an active instance to configure services. +

+ )} + + {selectedInstance && ( +
+ + + +
+ )} +
+ ) +} diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 62aaf3eab..2309b1e99 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as AuthenticatedInstancesRouteImport } from './routes/_authentica import { Route as AuthenticatedInstancesInstanceIdRouteImport } from './routes/_authenticated/instances.$instanceId' import { Route as AuthenticatedInstancesIndexRouteImport } from './routes/_authenticated/instances.index' import { Route as AuthenticatedSearchRouteImport } from './routes/_authenticated/search' +import { Route as AuthenticatedServicesRouteImport } from './routes/_authenticated/services' import { Route as AuthenticatedSettingsRouteImport } from './routes/_authenticated/settings' import { Route as IndexRouteImport } from './routes/index' import { Route as LoginRouteImport } from './routes/login' @@ -46,6 +47,11 @@ const AuthenticatedSettingsRoute = AuthenticatedSettingsRouteImport.update({ path: '/settings', getParentRoute: () => AuthenticatedRoute, } as any) +const AuthenticatedServicesRoute = AuthenticatedServicesRouteImport.update({ + id: '/services', + path: '/services', + getParentRoute: () => AuthenticatedRoute, +} as any) const AuthenticatedSearchRoute = AuthenticatedSearchRouteImport.update({ id: '/search', path: '/search', @@ -93,6 +99,7 @@ export interface FileRoutesByFullPath { '/dashboard': typeof AuthenticatedDashboardRoute '/instances': typeof AuthenticatedInstancesRouteWithChildren '/search': typeof AuthenticatedSearchRoute + '/services': typeof AuthenticatedServicesRoute '/settings': typeof AuthenticatedSettingsRoute '/instances/$instanceId': typeof AuthenticatedInstancesInstanceIdRoute '/instances/': typeof AuthenticatedInstancesIndexRoute @@ -105,6 +112,7 @@ export interface FileRoutesByTo { '/cross-seed': typeof AuthenticatedCrossSeedRoute '/dashboard': typeof AuthenticatedDashboardRoute '/search': typeof AuthenticatedSearchRoute + '/services': typeof AuthenticatedServicesRoute '/settings': typeof AuthenticatedSettingsRoute '/instances/$instanceId': typeof AuthenticatedInstancesInstanceIdRoute '/instances': typeof AuthenticatedInstancesIndexRoute @@ -120,6 +128,7 @@ export interface FileRoutesById { '/_authenticated/dashboard': typeof AuthenticatedDashboardRoute '/_authenticated/instances': typeof AuthenticatedInstancesRouteWithChildren '/_authenticated/search': typeof AuthenticatedSearchRoute + '/_authenticated/services': typeof AuthenticatedServicesRoute '/_authenticated/settings': typeof AuthenticatedSettingsRoute '/_authenticated/instances/$instanceId': typeof AuthenticatedInstancesInstanceIdRoute '/_authenticated/instances/': typeof AuthenticatedInstancesIndexRoute @@ -135,6 +144,7 @@ export interface FileRouteTypes { | '/dashboard' | '/instances' | '/search' + | '/services' | '/settings' | '/instances/$instanceId' | '/instances/' @@ -147,6 +157,7 @@ export interface FileRouteTypes { | '/cross-seed' | '/dashboard' | '/search' + | '/services' | '/settings' | '/instances/$instanceId' | '/instances' @@ -161,6 +172,7 @@ export interface FileRouteTypes { | '/_authenticated/dashboard' | '/_authenticated/instances' | '/_authenticated/search' + | '/_authenticated/services' | '/_authenticated/settings' | '/_authenticated/instances/$instanceId' | '/_authenticated/instances/' @@ -210,6 +222,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedSettingsRouteImport parentRoute: typeof AuthenticatedRoute } + '/_authenticated/services': { + id: '/_authenticated/services' + path: '/services' + fullPath: '/services' + preLoaderRoute: typeof AuthenticatedServicesRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_authenticated/search': { id: '/_authenticated/search' path: '/search' @@ -285,6 +304,7 @@ interface AuthenticatedRouteChildren { AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute AuthenticatedInstancesRoute: typeof AuthenticatedInstancesRouteWithChildren AuthenticatedSearchRoute: typeof AuthenticatedSearchRoute + AuthenticatedServicesRoute: typeof AuthenticatedServicesRoute AuthenticatedSettingsRoute: typeof AuthenticatedSettingsRoute } @@ -294,6 +314,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { AuthenticatedDashboardRoute: AuthenticatedDashboardRoute, AuthenticatedInstancesRoute: AuthenticatedInstancesRouteWithChildren, AuthenticatedSearchRoute: AuthenticatedSearchRoute, + AuthenticatedServicesRoute: AuthenticatedServicesRoute, AuthenticatedSettingsRoute: AuthenticatedSettingsRoute, } diff --git a/web/src/routes/_authenticated/services.tsx b/web/src/routes/_authenticated/services.tsx new file mode 100644 index 000000000..045793d48 --- /dev/null +++ b/web/src/routes/_authenticated/services.tsx @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025, s0up and the autobrr contributors. + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import { Services } from "@/pages/Services" +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/_authenticated/services")({ + component: Services, +}) diff --git a/web/src/types/index.ts b/web/src/types/index.ts index ee587b651..f5ab4b796 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -85,6 +85,38 @@ export interface InstanceError { occurredAt: string } +export interface TrackerRule { + id: number + instanceId: number + name: string + trackerPattern: string + trackerDomains?: string[] + category?: string + tag?: string + uploadLimitKiB?: number + downloadLimitKiB?: number + ratioLimit?: number + seedingTimeLimitMinutes?: number + enabled: boolean + sortOrder: number + createdAt?: string + updatedAt?: string +} + +export interface TrackerRuleInput { + name: string + trackerPattern?: string + trackerDomains?: string[] + category?: string + tag?: string + uploadLimitKiB?: number + downloadLimitKiB?: number + ratioLimit?: number + seedingTimeLimitMinutes?: number + enabled?: boolean + sortOrder?: number +} + export interface InstanceResponse extends Instance { connected: boolean hasDecryptionError: boolean