feat(notifications): add shoutrrr and notifiarr#1371
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdded a new in-process notifications system (service, types, storage, API, UI) and wired it into core services and startup so services emit rich notification events on success/failure/completion. Changes
Sequence Diagram(s)sequenceDiagram
participant UI as Client/UI
participant API as HTTP API
participant H as NotificationsHandler
participant S as notifications.Service
participant Q as EventQueue
participant W as Worker
participant B as ExternalBackend
rect rgba(100,150,200,0.5)
UI->>API: POST /api/notifications/targets (create)
API->>H: CreateTarget(req)
H->>S: store.Create(ctx, target)
S-->>H: created target
H-->>API: 201 Created
end
rect rgba(150,100,200,0.5)
Note over UI,B: Event emission & dispatch
S->>Q: Notify(ctx, Event)
Q->>W: Dequeue Event
W->>S: ListEnabledTargets(event.Type)
loop for each target
W->>S: Format payload for target
W->>B: Send payload (Discord/Notifiarr/other)
B-->>W: Response
end
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@internal/services/notifications/service.go`:
- Around line 533-542: Replace the if-else chain that checks
strings.EqualFold(label, ...) by converting label to lowercase once (e.g., l :=
strings.ToLower(label)) and switching on l: case "torrent": description = value;
case "run": description = fmt.Sprintf("Run %s", value); default: description =
fmt.Sprintf("%s: %s", label, value); then keep the existing continue; this
change should be applied in the same function in
internal/services/notifications/service.go where the variables description and
label are used.
In `@web/src/components/settings/NotificationsManager.tsx`:
- Around line 64-72: The useEffect in NotificationsManager marks initialized too
early, causing defaults from eventDefinitions to be skipped when they load after
first render; modify the effect so setInitialized(true) is called only inside
the branches that actually populate eventTypes: call
setEventTypes(initial.eventTypes ?? []) and then setInitialized(true) when
initial exists, and in the else-if branch setEventTypes(eventDefinitions.map(e
=> e.type)) and then setInitialized(true); leave setInitialized untouched when
neither branch runs so the effect can re-run once eventDefinitions become
available.
In `@web/src/pages/Settings.tsx`:
- Around line 274-358: The new API key (newKey) is not cleared when the
create-dialog is dismissed via overlay/ESC, so add logic to clear it when the
dialog closes: update the dialog's close handler (where
setShowCreateDialog(false) is called or by adding an onOpenChange/onClose prop
on the Dialog component controlling show state) to also call setNewKey(null),
ensuring newKey is cleared on any dismissal path (overlay, ESC, or programmatic
close). Reference newKey and the setters setNewKey and setShowCreateDialog (or
the Dialog's onOpenChange handler) to implement this single-step clear when the
dialog transitions to closed.
🧹 Nitpick comments (4)
go.mod (1)
81-81: Consider movingshoutrrrto the direct dependencies block.The
shoutrrrlibrary is placed in the indirect dependencies block (lines 50-104) but appears to be a direct dependency used by the notifications service. If it's directly imported by your code, it should be in the firstrequireblock (lines 5-46) for clarity and consistency with Go module conventions.Alternatively, if
go mod tidyplaced it here, it may be transitively resolved—verify withgo mod why github.com/nicholas-fedor/shoutrrr.docs/notifications.md (1)
47-48: Format the bare URL as a markdown link.The URL should be wrapped in angle brackets or formatted as a proper markdown link to comply with markdown best practices and fix the linting warning.
📝 Suggested fix
See the Shoutrrr documentation for the full list of services and URL formats: -https://github.com/nicholas-fedor/shoutrrr +<https://github.com/nicholas-fedor/shoutrrr>internal/services/crossseed/service.go (1)
7445-7454: Optional: avoidfmt.Sprintffor fixed prefixes in notification lines.
This aligns with the perfsprint hint and trims a little overhead in a hot-ish helper.♻️ Suggested change
- lines = append(lines, fmt.Sprintf("Message: %s", strings.TrimSpace(*run.Message))) + lines = append(lines, "Message: "+strings.TrimSpace(*run.Message)) - lines = append(lines, fmt.Sprintf("Error: %s", strings.TrimSpace(*run.ErrorMessage))) + lines = append(lines, "Error: "+strings.TrimSpace(*run.ErrorMessage)) - lines = append(lines, fmt.Sprintf("Error: %s", runErr.Error())) + lines = append(lines, "Error: "+runErr.Error()) - lines = append(lines, fmt.Sprintf("Samples: %s", formatSampleText(samples))) + lines = append(lines, "Samples: "+formatSampleText(samples))internal/services/automations/service.go (1)
102-123: Replacefmt.Sprintfwith string concatenation per linter.The static analysis tool correctly identifies that these simple string concatenations don't require
fmt.Sprintf.♻️ Proposed fix for perfsprint linter warnings
func (s *automationSummary) message() string { if s == nil { return "" } lines := []string{fmt.Sprintf("Applied: %d", s.applied)} if s.failed > 0 { lines = append(lines, fmt.Sprintf("Failed: %d", s.failed)) } if formatted := formatActionCounts(s.appliedByAction, 3); formatted != "" { - lines = append(lines, fmt.Sprintf("Top actions: %s", formatted)) + lines = append(lines, "Top actions: "+formatted) } if formatted := formatActionCounts(s.failedByAction, 3); formatted != "" { - lines = append(lines, fmt.Sprintf("Top failures: %s", formatted)) + lines = append(lines, "Top failures: "+formatted) } if len(s.sampleTorrents) > 0 { - lines = append(lines, fmt.Sprintf("Samples: %s", strings.Join(s.sampleTorrents, "; "))) + lines = append(lines, "Samples: "+strings.Join(s.sampleTorrents, "; ")) } if len(s.sampleErrors) > 0 { - lines = append(lines, fmt.Sprintf("Errors: %s", strings.Join(s.sampleErrors, "; "))) + lines = append(lines, "Errors: "+strings.Join(s.sampleErrors, "; ")) } return strings.Join(lines, "\n") }
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@web/src/components/settings/NotificationsManager.tsx`:
- Around line 467-469: The CardDescription currently renders the raw target.url
in NotificationsManager which may expose tokens; update rendering to use a
sanitizer/masking helper (e.g., maskNotificationUrl or formatTargetUrl) that
detects scheme://[userinfo@]host... and replaces any userinfo/token portion with
a placeholder like **** or only returns the scheme/service name (e.g.,
"discord://••••"). Locate where target.url is used in NotificationsManager and
call this helper instead of outputting target.url directly, ensuring the helper
handles common Shoutrrr schemes and falls back to a safe masked string for
unknown formats.
🧹 Nitpick comments (8)
internal/services/notifications/service.go (5)
85-95: Workers lack graceful shutdown and WaitGroup tracking.When
ctxis cancelled, the workers exit but there's no mechanism to wait for in-flight dispatches to complete. If the service is stopped during active sends, notifications may be dropped silently.Consider adding a
sync.WaitGroupto track active workers and aStop()method that waits for completion.♻️ Suggested approach
type Service struct { store *models.NotificationTargetStore instanceStore *models.InstanceStore logger zerolog.Logger queue chan Event startOnce sync.Once + wg sync.WaitGroup }func (s *Service) Start(ctx context.Context) { if s == nil { return } s.startOnce.Do(func() { for range defaultWorkers { + s.wg.Add(1) - go s.worker(ctx) + go func() { + defer s.wg.Done() + s.worker(ctx) + }() } }) } + +func (s *Service) Stop() { + if s == nil { + return + } + s.wg.Wait() +}
122-131: Worker can block indefinitely on closed context if queue is empty.When the context is cancelled, if the queue channel is empty, the worker correctly exits via
<-ctx.Done(). However, ifs.queueis closed elsewhere (e.g., during shutdown), reading from a closed channel returns the zero value immediately, potentially causing an infinite loop of dispatching empty events.Consider checking for channel closure or using a dedicated done channel.
♻️ Safer worker loop
func (s *Service) worker(ctx context.Context) { for { select { case <-ctx.Done(): return - case event := <-s.queue: + case event, ok := <-s.queue: + if !ok { + return + } s.dispatch(ctx, event) } } }
163-176: Context parameter is unused insendmethod.The
ctxparameter is ignored (named_), but it should be propagated to the underlying send methods for cancellation support, especially for HTTP-based backends like Discord and Notifiarr.♻️ Propagate context
-func (s *Service) send(_ context.Context, target *models.NotificationTarget, event Event, title, message string) error { +func (s *Service) send(ctx context.Context, target *models.NotificationTarget, event Event, title, message string) error { if target == nil { return errors.New("notification target required") } switch targetScheme(target.URL) { case "discord": - return s.sendDiscord(target.URL, event, title, message) + return s.sendDiscord(ctx, target.URL, event, title, message) case "notifiarr": - return s.sendNotifiarr(target.URL, event, title, message) + return s.sendNotifiarr(ctx, target.URL, event, title, message) default: - return s.sendDefault(target.URL, title, message) + return s.sendDefault(ctx, target.URL, title, message) } }
228-264: Event parameter is unused insendNotifiarr.The
event Eventparameter is received but never used, unlike insendDiscordwhere it's used for color selection. Either remove it for consistency or use it for additional metadata.
376-382: EmptyeventTypesslice allows all events—document this behavior.When
eventTypesis empty,allowsEventreturnstruefor any event type. This might be intentional (default to all events), but it's not obvious. Consider adding a comment to clarify the design decision.📝 Add clarifying comment
+// allowsEvent returns true if the target accepts the given event type. +// An empty eventTypes slice means "all events are allowed" (default behavior). func allowsEvent(eventTypes []string, eventType EventType) bool { if len(eventTypes) == 0 { return true } return slices.Contains(eventTypes, string(eventType)) }web/src/components/settings/NotificationsManager.tsx (3)
126-162: Event grouping logic is hardcoded; consider making it data-driven.The grouping logic uses hardcoded event type strings and prefix checks. If new event types are added, this code must be updated. Consider deriving groups from the
NotificationEventDefinitiondata (e.g., adding acategoryfield to the API response).
471-497: Test button lacks visual feedback during pending state.While the button is disabled during
testMutation.isPending, there's no spinner or loading indicator to show that a test is in progress. Consider adding a loading state similar to other buttons.♻️ Add loading indicator
<Button variant="ghost" size="sm" onClick={() => testMutation.mutate(target.id)} aria-label={`Send test to ${target.name}`} disabled={testMutation.isPending} > - <Send className="h-4 w-4" /> + {testMutation.isPending ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Send className="h-4 w-4" /> + )} </Button>
388-401: Backend behavior aligns with UI—document the coupling for clarity.When
events.length === 0, the UI displays "All events" badge. The backend'sallowsEventfunction ininternal/services/notifications/service.goreturnstruefor empty event type slices, so the behaviors match. Consider adding a comment in either the frontend or backend code to document this intentional coupling, since it's not immediately obvious that empty means "allow all."
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@web/src/components/settings/NotificationsManager.tsx`:
- Around line 571-576: The Delete button (AlertDialogAction) currently calls
deleteMutation.mutate multiple times because it stays clickable while the
mutation is in-flight; update the AlertDialogAction to be disabled while the
mutation is pending by binding its disabled prop to the mutation loading state
(e.g., deleteMutation.isLoading or equivalent) and keep the existing onClick
handler using deleteTarget.id; optionally add an accessibility hint like
aria-busy or a spinner when deleteMutation.isLoading to indicate progress.
🧹 Nitpick comments (1)
web/src/components/settings/NotificationsManager.tsx (1)
502-510: Test button pending state applies globally across all targets.The
disabled={testMutation.isPending}check disables all test buttons when any single test is in progress. This is simple but may confuse users if they expect to test multiple targets concurrently. Consider tracking the specific target ID being tested if independent testing is desired.💡 Optional: Track specific target being tested
+ const [testingTargetId, setTestingTargetId] = useState<number | null>(null) const testMutation = useMutation({ mutationFn: (id: number) => api.testNotificationTarget(id), onSuccess: () => { + setTestingTargetId(null) toast.success("Test notification sent") }, onError: (error: unknown) => { + setTestingTargetId(null) toast.error(getErrorMessage(error, "Failed to send test notification")) }, }) // In the button: <Button variant="ghost" size="sm" - onClick={() => testMutation.mutate(target.id)} + onClick={() => { + setTestingTargetId(target.id) + testMutation.mutate(target.id) + }} aria-label={`Send test to ${target.name}`} - disabled={testMutation.isPending} + disabled={testingTargetId !== null} > + {testingTargetId === target.id ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( <Send className="h-4 w-4" /> + )} </Button>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@web/src/components/settings/NotificationsManager.tsx`:
- Around line 519-526: The trash icon Button that calls setDeleteTarget should
be disabled while a deletion is pending to prevent changing deleteTarget
mid-flight; update the Button (the one with onClick={() =>
setDeleteTarget(target)} and Trash2 icon) to use the same pending condition used
to disable the AlertDialogAction (e.g., deletionPending, isDeleting or the
deleting target id) by adding disabled={deletionPending} (and optionally
aria-disabled={deletionPending}) and guard the onClick so it no-ops when
deletionPending is true; this ensures the dialog cannot be re-opened for another
target while a deletion is in progress.
🧹 Nitpick comments (1)
web/src/components/settings/NotificationsManager.tsx (1)
418-420: Semantically misleading badge for empty event types.When
events.length === 0, displaying "All events" is misleading—an empty array means no events are subscribed, not all. While the form validation prevents saving targets with zero events, this could still cause confusion if the backend ever returns an empty array or for defensive clarity.♻️ Suggested improvement
const renderEventBadges = (events: string[]) => { if (events.length === 0) { - return <Badge variant="secondary">All events</Badge> + return <Badge variant="secondary">No events</Badge> }
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@web/src/components/settings/NotificationsManager.tsx`:
- Around line 418-431: renderEventBadges currently treats an empty events array
as "All events", which is misleading if empty means "no events"; update
renderEventBadges (and any callers that pass eventTypes) to distinguish
empty-from-all by: when events.length === 0 return a Badge variant="secondary"
with text like "No events" (or "None") and, if there is a semantic convention
that empty means all, instead add explicit handling where eventTypes is
null/undefined means "All events" and document that; ensure the change
references renderEventBadges and the eventTypes property so callers and API
consumers remain consistent.
🧹 Nitpick comments (1)
web/src/components/settings/NotificationsManager.tsx (1)
156-192: Consider extracting event categorization logic.The hardcoded event type strings for categorization (e.g.,
"torrent_completed","backup_succeeded") could become fragile if new event types are added server-side. Consider having the backend include acategoryfield inNotificationEventDefinitionto avoid client-side maintenance.
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
internal/services/crossseed/service.go (1)
1946-2012:⚠️ Potential issue | 🟠 MajorUse
context.WithoutCancel(ctx)for notifications to prevent skipping them on cancellation.When the run context is canceled (user cancel, timeout, or request-scoped context), the
Notifycall can fail becausedispatchcallsstore.ListEnabled(ctx), which will return an error if the context is already canceled. This causes the notification to be skipped entirely.Use
context.WithoutCancel(ctx)to ensure notifications are always sent, even when the run context is canceled. The codebase already uses this pattern for database updates in the same function (lines 157-163).🔧 Suggested fix
- var runErr error - defer func() { - s.notifyAutomationRun(ctx, run, runErr) - }() + var runErr error + notifyCtx := context.WithoutCancel(ctx) + defer func() { + s.notifyAutomationRun(notifyCtx, run, runErr) + }()internal/services/dirscan/service.go (1)
584-603:⚠️ Potential issue | 🟡 MinorAvoid DirScanFailed notifications with unknown instance IDs.
When directory lookup fails,
markRunFailedcan be invoked withinstanceID=0, leading to EventDirScanFailed with an invalid instance ID. Consider skipping notification when instanceID is unknown.🛠️ Proposed guard
func (s *Service) markRunFailed(_ context.Context, runID int64, errMsg string, instanceID int, l *zerolog.Logger) { if err := s.store.UpdateRunFailed(context.Background(), runID, errMsg); err != nil { l.Error().Err(err).Msg("dirscan: failed to mark run as failed") return } + if instanceID <= 0 { + return + } s.notify(context.Background(), notifications.Event{ Type: notifications.EventDirScanFailed, InstanceID: instanceID, DirScanRunID: runID, ErrorMessage: errMsg, }) }Also applies to: 2164-2183
🤖 Fix all issues with AI agents
In `@cmd/qui/main.go`:
- Around line 599-626: The completion handler registered with
syncManager.SetTorrentCompletionHandler contains a redundant nil check for
notificationService; remove the inner "if notificationService != nil { ... }"
block and unindent its contents so the handler directly builds
trackerDomain/tags and calls notificationService.Notify. Keep the existing guard
that initializes/validates notificationService earlier, and ensure you reference
the same captured variable (notificationService) inside the closure when calling
Notify so syncManager.SetTorrentCompletionHandler's closure uses the
already-validated service.
In `@internal/services/notifications/service.go`:
- Around line 63-70: The notificationTask struct currently stores a
request-scoped context (ctx context.Context) which causes the containedctx lint
failure; remove the ctx field and change the queue to only carry Event values
(or change uses of notificationTask to enqueue Event) and update the
worker/consumer to use the service's long-lived context (e.g.,
service.run/worker loop using s.ctx or svcCtx) when dispatching; locate and
update all places that construct or read notificationTask (enqueue call sites
and the worker that dequeues and calls dispatch/handle functions) to stop
passing/expecting ctx and instead use the worker/service context for any
context-aware calls.
🧹 Nitpick comments (1)
cmd/qui/main.go (1)
577-583: Redundant nil check can be simplified.The
notificationService != nilcheck at line 581 appears redundant sincenotifications.NewServiceis called immediately above and doesn't return an error - it should always return a non-nil service. IfNewServicecan legitimately returnnilin some cases, consider documenting that behavior; otherwise, this check adds unnecessary cognitive overhead.♻️ Suggested simplification
notificationTargetStore := models.NewNotificationTargetStore(db) notificationService := notifications.NewService(notificationTargetStore, instanceStore, log.Logger.With().Str("module", "notifications").Logger()) notificationCtx, notificationCancel := context.WithCancel(context.Background()) defer notificationCancel() -if notificationService != nil { - notificationService.Start(notificationCtx) -} +notificationService.Start(notificationCtx)
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/services/automations/service.go (1)
2091-2213:⚠️ Potential issue | 🟡 MinorTag activity summary can over‑report when tag updates fail.
If any tag batch errors, the success summary still counts all planned changes. Consider suppressing success summary on failures (or tracking successful hashes) so notifications reflect reality.
🛠️ Proposed fix (suppress success summary when any tag batch fails)
- if len(tagChanges) > 0 { + if len(tagChanges) > 0 { + tagFailures := false // Try SetTags first (more efficient for qBit 5.1+) // Group by desired tag set for batching setTagsBatches := make(map[string][]string) // key = sorted tags, value = hashes @@ - if err != nil { + if err != nil { // Check if it's an unsupported version error if strings.Contains(err.Error(), "requires qBittorrent") { useSetTags = false break } log.Warn().Err(err).Int("instanceID", instanceID).Strs("tags", tags).Int("count", len(batch)).Msg("automations: set tags failed") + tagFailures = true } else { log.Debug().Int("instanceID", instanceID).Strs("tags", tags).Int("count", len(batch)).Msg("automations: set tags on torrents") } @@ - for _, batch := range batches { - if err := s.syncManager.AddTorrentTags(ctx, instanceID, batch, []string{tag}); err != nil { + for _, batch := range batches { + if err := s.syncManager.AddTorrentTags(ctx, instanceID, batch, []string{tag}); err != nil { log.Warn().Err(err).Int("instanceID", instanceID).Str("tag", tag).Int("count", len(batch)).Msg("automations: add tags failed") + tagFailures = true } else { log.Debug().Int("instanceID", instanceID).Str("tag", tag).Int("count", len(batch)).Msg("automations: added tag to torrents") } } } @@ - for _, batch := range batches { - if err := s.syncManager.RemoveTorrentTags(ctx, instanceID, batch, []string{tag}); err != nil { + for _, batch := range batches { + if err := s.syncManager.RemoveTorrentTags(ctx, instanceID, batch, []string{tag}); err != nil { log.Warn().Err(err).Int("instanceID", instanceID).Str("tag", tag).Int("count", len(batch)).Msg("automations: remove tags failed") + tagFailures = true } else { log.Debug().Int("instanceID", instanceID).Str("tag", tag).Int("count", len(batch)).Msg("automations: removed tag from torrents") } } } @@ - if len(addCounts) > 0 || len(removeCounts) > 0 { + if !tagFailures && (len(addCounts) > 0 || len(removeCounts) > 0) { detailsJSON, _ := json.Marshal(map[string]any{ "added": addCounts, "removed": removeCounts, })
🧹 Nitpick comments (1)
internal/services/dirscan/service.go (1)
511-553: Consider usingcontext.Background()forgetRunTimescall.Line 529 uses
context.Background()forUpdateRunCompletedto ensure completion even if the run context is canceled. However, line 536 passesctxtogetRunTimes. While this works becausectx.Err() != nilis checked earlier (lines 516-527), usingcontext.Background()would be more consistent with the pattern established for post-completion operations.This is a minor consistency point - the current code will work correctly since
getRunTimeshandles errors gracefully by returningniltimestamps.♻️ Optional: Use consistent context for completion operations
- startedAt, completedAt := s.getRunTimes(ctx, runID) + startedAt, completedAt := s.getRunTimes(context.Background(), runID) s.notify(ctx, notifications.Event{
…tifications-automation-crossseed # Conflicts: # cmd/qui/main.go # internal/services/crossseed/service.go
This PR contains the following updates: | Package | Update | Change | |---|---|---| | [ghcr.io/autobrr/qui](https://github.com/autobrr/qui) | minor | `v1.13.1` → `v1.14.0` | --- ### Release Notes <details> <summary>autobrr/qui (ghcr.io/autobrr/qui)</summary> ### [`v1.14.0`](https://github.com/autobrr/qui/releases/tag/v1.14.0) [Compare Source](autobrr/qui@v1.13.1...v1.14.0) #### Changelog ##### New Features - [`6f8e6ed`](autobrr/qui@6f8e6ed): feat(api): add torrent field endpoint for select all copy ([#​1477](autobrr/qui#1477)) ([@​jabloink](https://github.com/jabloink)) - [`2d9b4c7`](autobrr/qui@2d9b4c7): feat(automation): trigger external programs automatically via automation rules ([#​1284](autobrr/qui#1284)) ([@​0rkag](https://github.com/0rkag)) - [`32692a4`](autobrr/qui@32692a4): feat(automations): Add the ability to define the move automation with a templated path ([#​1376](autobrr/qui#1376)) ([@​ColinHebert](https://github.com/ColinHebert)) - [`61bbeb1`](autobrr/qui@61bbeb1): feat(automations): add Resume action to Automations ([#​1350](autobrr/qui#1350)) ([@​cy1der](https://github.com/cy1der)) - [`450b98f`](autobrr/qui@450b98f): feat(automations): grouping + release fields ([#​1467](autobrr/qui#1467)) ([@​s0up4200](https://github.com/s0up4200)) - [`18d4a64`](autobrr/qui@18d4a64): feat(automations): match tracker conditions by display name ([#​1420](autobrr/qui#1420)) ([@​s0up4200](https://github.com/s0up4200)) - [`7c67b82`](autobrr/qui@7c67b82): feat(automations): show activity run details ([#​1385](autobrr/qui#1385)) ([@​s0up4200](https://github.com/s0up4200)) - [`177ef4d`](autobrr/qui@177ef4d): feat(crossseed): Multiple hard/reflink dirs ([#​1289](autobrr/qui#1289)) ([@​rybertm](https://github.com/rybertm)) - [`a72b673`](autobrr/qui@a72b673): feat(crossseed): gazelle-only OPS/RED ([#​1436](autobrr/qui#1436)) ([@​s0up4200](https://github.com/s0up4200)) - [`6a29384`](autobrr/qui@6a29384): feat(crossseed): match bit depth ([#​1427](autobrr/qui#1427)) ([@​s0up4200](https://github.com/s0up4200)) - [`c7fd5aa`](autobrr/qui@c7fd5aa): feat(dirscan): add max searchee age filter ([#​1486](autobrr/qui#1486)) ([@​s0up4200](https://github.com/s0up4200)) - [`d595a55`](autobrr/qui@d595a55): feat(documentation): add AI doc actions and llms discoverability ([#​1451](autobrr/qui#1451)) ([@​s0up4200](https://github.com/s0up4200)) - [`562ab3f`](autobrr/qui@562ab3f): feat(metrics): add tracker metrics ([#​1073](autobrr/qui#1073)) ([@​Winter](https://github.com/Winter)) - [`1b9aa9d`](autobrr/qui@1b9aa9d): feat(notifications): add shoutrrr and notifiarr ([#​1371](autobrr/qui#1371)) ([@​s0up4200](https://github.com/s0up4200)) - [`6d1dac7`](autobrr/qui@6d1dac7): feat(pwa): add protocol and file handlers for magnet links and torrent files ([#​783](autobrr/qui#783)) ([@​s0up4200](https://github.com/s0up4200)) - [`42fa501`](autobrr/qui@42fa501): feat(torrents): add unified cross-instance torrent table ([#​1481](autobrr/qui#1481)) ([@​s0up4200](https://github.com/s0up4200)) - [`498eaca`](autobrr/qui@498eaca): feat(ui): show speeds in page title ([#​1292](autobrr/qui#1292)) ([@​NoLife141](https://github.com/NoLife141)) - [`94a506e`](autobrr/qui@94a506e): feat(unregistered): nem talalhato ([#​1483](autobrr/qui#1483)) ([@​KyleSanderson](https://github.com/KyleSanderson)) - [`8bf366c`](autobrr/qui@8bf366c): feat(web): add logs nav ([#​1458](autobrr/qui#1458)) ([@​s0up4200](https://github.com/s0up4200)) - [`babc88d`](autobrr/qui@babc88d): feat(web): add responsive popover with mobile drawer support ([#​1398](autobrr/qui#1398)) ([@​jabloink](https://github.com/jabloink)) - [`06d341b`](autobrr/qui@06d341b): feat(web): add torrent table selection quick wins ([#​1455](autobrr/qui#1455)) ([@​s0up4200](https://github.com/s0up4200)) - [`56fbbec`](autobrr/qui@56fbbec): feat(web): hide selection column ([#​1460](autobrr/qui#1460)) ([@​s0up4200](https://github.com/s0up4200)) - [`46814aa`](autobrr/qui@46814aa): feat(web): qBittorrent autorun preferences ([#​1430](autobrr/qui#1430)) ([@​s0up4200](https://github.com/s0up4200)) - [`342643e`](autobrr/qui@342643e): feat(web): unify instance settings & qbit options dialog ([#​1257](autobrr/qui#1257)) ([@​0rkag](https://github.com/0rkag)) - [`e634d01`](autobrr/qui@e634d01): feat: add cross-seed blocklist ([#​1391](autobrr/qui#1391)) ([@​s0up4200](https://github.com/s0up4200)) - [`13aaac8`](autobrr/qui@13aaac8): feat: add dry-run workflows ([#​1395](autobrr/qui#1395)) ([@​s0up4200](https://github.com/s0up4200)) - [`f01101d`](autobrr/qui@f01101d): feat: add option to disable built-in authentication ([#​1464](autobrr/qui#1464)) ([@​libussa](https://github.com/libussa)) - [`6d1da50`](autobrr/qui@6d1da50): feat: download individual content files from context menu ([#​1465](autobrr/qui#1465)) ([@​libussa](https://github.com/libussa)) - [`77e9abf`](autobrr/qui@77e9abf): feat: migrate to dodopayments ([#​1407](autobrr/qui#1407)) ([@​s0up4200](https://github.com/s0up4200)) - [`9f6c856`](autobrr/qui@9f6c856): feat: support basic auth for ARR and Torznab ([#​1442](autobrr/qui#1442)) ([@​s0up4200](https://github.com/s0up4200)) ##### Bug Fixes - [`8a06d4b`](autobrr/qui@8a06d4b): fix(api): correct add-torrent OpenAPI param names and add missing fields ([#​1426](autobrr/qui#1426)) ([@​s0up4200](https://github.com/s0up4200)) - [`b9a687c`](autobrr/qui@b9a687c): fix(api): honor explicit basic auth clear from URL userinfo ([@​s0up4200](https://github.com/s0up4200)) - [`948ca67`](autobrr/qui@948ca67): fix(api): tighten CORS/auth routing and base URL joins ([#​1325](autobrr/qui#1325)) ([@​s0up4200](https://github.com/s0up4200)) - [`12bea13`](autobrr/qui@12bea13): fix(automations): improve applied action summaries ([#​1478](autobrr/qui#1478)) ([@​s0up4200](https://github.com/s0up4200)) - [`8fe658b`](autobrr/qui@8fe658b): fix(automations): negate regex match for NotContains/NotEqual operators ([#​1441](autobrr/qui#1441)) ([@​andresatierf](https://github.com/andresatierf)) - [`8a808eb`](autobrr/qui@8a808eb): fix(automations): respect remove-only tag conditions ([#​1444](autobrr/qui#1444)) ([@​s0up4200](https://github.com/s0up4200)) - [`a72715e`](autobrr/qui@a72715e): fix(backups): add failure cooldown and export throttling ([#​1214](autobrr/qui#1214)) ([@​s0up4200](https://github.com/s0up4200)) - [`2e75c14`](autobrr/qui@2e75c14): fix(backups): skip exports missing metadata ([#​1362](autobrr/qui#1362)) ([@​s0up4200](https://github.com/s0up4200)) - [`5658421`](autobrr/qui@5658421): fix(config): update commented log settings in place ([#​1402](autobrr/qui#1402)) ([@​s0up4200](https://github.com/s0up4200)) - [`62c50c0`](autobrr/qui@62c50c0): fix(crossseed): tighten TV title matching ([#​1445](autobrr/qui#1445)) ([@​s0up4200](https://github.com/s0up4200)) - [`e7cc489`](autobrr/qui@e7cc489): fix(dirscan): prevent immediate requeue after cancel ([#​1446](autobrr/qui#1446)) ([@​s0up4200](https://github.com/s0up4200)) - [`36cbfcf`](autobrr/qui@36cbfcf): fix(docs): avoid mdx jsx parse error ([@​s0up4200](https://github.com/s0up4200)) - [`d8d6f62`](autobrr/qui@d8d6f62): fix(filters): stabilize dense sidebar layout ([#​1384](autobrr/qui#1384)) ([@​s0up4200](https://github.com/s0up4200)) - [`b959fc6`](autobrr/qui@b959fc6): fix(orphanscan): NFC-normalize paths to avoid false orphans ([#​1422](autobrr/qui#1422)) ([@​s0up4200](https://github.com/s0up4200)) - [`598e994`](autobrr/qui@598e994): fix(reflink): retry EAGAIN clones ([#​1360](autobrr/qui#1360)) ([@​s0up4200](https://github.com/s0up4200)) - [`aaa5ee0`](autobrr/qui@aaa5ee0): fix(reflinktree): retry transient FICLONE EINVAL and add diagnostics ([#​1487](autobrr/qui#1487)) ([@​s0up4200](https://github.com/s0up4200)) - [`647af31`](autobrr/qui@647af31): fix(rss): enable rules list scrolling ([#​1359](autobrr/qui#1359)) ([@​s0up4200](https://github.com/s0up4200)) - [`c356a6f`](autobrr/qui@c356a6f): fix(sync): Optimize torrent sorting and reference management ([#​1474](autobrr/qui#1474)) ([@​KyleSanderson](https://github.com/KyleSanderson)) - [`cf4310e`](autobrr/qui@cf4310e): fix(ui): update placeholder text in ArrInstanceForm based on instance type ([#​1375](autobrr/qui#1375)) ([@​pashioya](https://github.com/pashioya)) - [`92b6748`](autobrr/qui@92b6748): fix(web): format IPv6 peer addresses and copy IP without port ([#​1417](autobrr/qui#1417)) ([@​sleepm](https://github.com/sleepm)) - [`25039bc`](autobrr/qui@25039bc): fix(web): handle SSO session expiry behind Cloudflare Access and other proxies ([#​1438](autobrr/qui#1438)) ([@​nitrobass24](https://github.com/nitrobass24)) - [`77fe310`](autobrr/qui@77fe310): fix(web): prevent category submenu re-render ([#​1357](autobrr/qui#1357)) ([@​jabloink](https://github.com/jabloink)) - [`a42ab1e`](autobrr/qui@a42ab1e): fix(web): raise instance preferences max value from 999 to 99999 ([#​1311](autobrr/qui#1311)) ([@​s0up4200](https://github.com/s0up4200)) - [`540168c`](autobrr/qui@540168c): fix(web): raise virtualization threshold ([#​1355](autobrr/qui#1355)) ([@​jabloink](https://github.com/jabloink)) - [`8547dc6`](autobrr/qui@8547dc6): fix(web): remove column filters when column is hidden ([#​1418](autobrr/qui#1418)) ([@​jabloink](https://github.com/jabloink)) - [`6b09b8d`](autobrr/qui@6b09b8d): fix(web): remove panel size bounds ([@​s0up4200](https://github.com/s0up4200)) - [`db4cdc4`](autobrr/qui@db4cdc4): fix(web): show piece size in torrent details ([#​1365](autobrr/qui#1365)) ([@​s0up4200](https://github.com/s0up4200)) - [`1f94a06`](autobrr/qui@1f94a06): fix(web): use absolute for scroll-to-top on desktop ([#​1419](autobrr/qui#1419)) ([@​jabloink](https://github.com/jabloink)) - [`e31fe3a`](autobrr/qui@e31fe3a): fix: detect tracker health support after qBit upgrade ([#​909](autobrr/qui#909)) ([@​s0up4200](https://github.com/s0up4200)) - [`52f01da`](autobrr/qui@52f01da): fix: disable update indicators when update checks are off ([#​1364](autobrr/qui#1364)) ([@​s0up4200](https://github.com/s0up4200)) - [`f7e3fed`](autobrr/qui@f7e3fed): fix: normalize DD+ and DDP file keys ([#​1456](autobrr/qui#1456)) ([@​s0up4200](https://github.com/s0up4200)) ##### Other Changes - [`d914301`](autobrr/qui@d914301): chore(ci): fire Blacksmith (my wallet screamed) ([#​1408](autobrr/qui#1408)) ([@​s0up4200](https://github.com/s0up4200)) - [`b43327d`](autobrr/qui@b43327d): chore(deps): bump the golang group with 2 updates ([#​1378](autobrr/qui#1378)) ([@​dependabot](https://github.com/dependabot)\[bot]) - [`57747bd`](autobrr/qui@57747bd): chore(deps): bump the npm group across 1 directory with 27 updates ([#​1379](autobrr/qui#1379)) ([@​dependabot](https://github.com/dependabot)\[bot]) - [`a43850d`](autobrr/qui@a43850d): chore(docs): add BIMI SVG logo ([@​s0up4200](https://github.com/s0up4200)) - [`914bede`](autobrr/qui@914bede): chore(funding): add Patreon to FUNDING.yml ([@​s0up4200](https://github.com/s0up4200)) - [`8b76f1e`](autobrr/qui@8b76f1e): docs(automations): clarify tag matching examples ([#​1457](autobrr/qui#1457)) ([@​s0up4200](https://github.com/s0up4200)) - [`2994054`](autobrr/qui@2994054): docs(readme): restore concise README ([#​1452](autobrr/qui#1452)) ([@​s0up4200](https://github.com/s0up4200)) - [`51237d4`](autobrr/qui@51237d4): docs: Add configuration reference ([#​1440](autobrr/qui#1440)) ([@​s0up4200](https://github.com/s0up4200)) - [`741462c`](autobrr/qui@741462c): docs: add Windows installation guide ([#​1463](autobrr/qui#1463)) ([@​soggy-cr0uton](https://github.com/soggy-cr0uton)) - [`6a11430`](autobrr/qui@6a11430): docs: clarify autobrr filter + apply troubleshooting ([#​1459](autobrr/qui#1459)) ([@​s0up4200](https://github.com/s0up4200)) - [`5a2edc2`](autobrr/qui@5a2edc2): docs: update 2 documentation files ([#​1454](autobrr/qui#1454)) ([@​s0up4200](https://github.com/s0up4200)) - [`139ada9`](autobrr/qui@139ada9): docs: update contributing.md ([#​1470](autobrr/qui#1470)) ([@​s0up4200](https://github.com/s0up4200)) - [`3909aa1`](autobrr/qui@3909aa1): docs: update docs/features/automations.md ([#​1447](autobrr/qui#1447)) ([@​s0up4200](https://github.com/s0up4200)) - [`5dc57ca`](autobrr/qui@5dc57ca): docs: update intro.md ([#​1453](autobrr/qui#1453)) ([@​s0up4200](https://github.com/s0up4200)) - [`5d9e986`](autobrr/qui@5d9e986): perf(web): memoize useDateTimeFormatters ([#​1403](autobrr/qui#1403)) ([@​jabloink](https://github.com/jabloink)) **Full Changelog**: <autobrr/qui@v1.13.1...v1.14.0> #### Docker images - `docker pull ghcr.io/autobrr/qui:v1.14.0` - `docker pull ghcr.io/autobrr/qui:latest` #### What to do next? - Join our [Discord server](https://discord.autobrr.com/qui) Thank you for using qui! </details> --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yNS43IiwidXBkYXRlZEluVmVyIjoiNDMuMjUuNyIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiaW1hZ2UiXX0=--> Reviewed-on: https://gitea.alexlebens.dev/alexlebens/infrastructure/pulls/4154 Co-authored-by: Renovate Bot <renovate-bot@alexlebens.net> Co-committed-by: Renovate Bot <renovate-bot@alexlebens.net>

Summary
Summary by CodeRabbit
New Features
Database
Documentation