feat: migrate to dodopayments - #1407
Conversation
WalkthroughAdds Dodo Payments as a second license provider: new Dodo client, DB schema and model fields for provider and instance ID, repo and service changes for provider-aware activate/validate/deactivate flows, API and frontend exposure of provider, tests and migration/rename handling. Changes
Sequence Diagram(s)sequenceDiagram
participant UI as Frontend
participant API as API Handler
participant Svc as License Service
participant Dodo as Dodo Client
participant Polar as Polar Client
participant DB as Database
rect rgba(100,150,200,0.5)
note over UI,API: Activation request (provider-aware)
UI->>API: POST /api/license/activate (licenseKey, username)
API->>Svc: ActivateAndStoreLicense(licenseKey, username)
Svc->>DB: GetLicenseByKey(licenseKey)
alt provider == "dodo"
Svc->>Dodo: Activate(...)
Dodo-->>Svc: ActivateResponse / error
else provider == "polar"
Svc->>Polar: Activate(...)
Polar-->>Svc: ActivateResponse / error
else provider unknown
Svc->>Dodo: Validate(...)
alt Dodo valid
Svc->>DB: UpdateLicenseProvider("dodo", instanceID)
else
Svc->>Polar: Validate/Activate(...)
Polar-->>Svc: response
Svc->>DB: UpdateLicenseProvider("polar", NULL)
end
end
Svc->>DB: Store/Update license (status, provider, dodo_instance_id)
DB-->>Svc: commit
Svc-->>API: ProductLicense
API-->>UI: {licenseKey, status, provider, ...}
end
sequenceDiagram
participant Scheduler as Scheduler
participant Svc as License Service
participant DB as Database
participant Dodo as Dodo Client
participant Polar as Polar Client
rect rgba(150,100,200,0.5)
note over Scheduler,Svc: Periodic refresh / validation
Scheduler->>Svc: RefreshAllLicenses()
Svc->>DB: GetAllLicenses()
DB-->>Svc: licenses[]
loop each license
alt license.provider == "dodo"
Svc->>Dodo: Validate(licenseKey, instanceID)
Dodo-->>Svc: ValidateResponse
else license.provider == "polar"
Svc->>Polar: Validate(licenseKey)
Polar-->>Svc: ValidateResponse
else provider unknown
Svc->>Dodo: Validate(licenseKey)
alt Dodo valid (returns instance_id)
Svc->>DB: UpdateLicenseProvider("dodo", instance_id)
else
Svc->>Polar: Validate(licenseKey)
alt Polar valid
Svc->>DB: UpdateLicenseProvider("polar", NULL)
end
end
end
Svc->>DB: Update status/fields as needed
end
Svc-->>DB: done
end
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested labels
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 |
This comment has been minimized.
This comment has been minimized.
93ea6c3 to
3d87591
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In @.gitignore:
- Line 62: The .gitignore contains a duplicate `.envrc` entry; remove the
redundant occurrence so `.envrc` appears only once (keep the first or the
intended single entry) and delete the duplicate lines (the repeated `.envrc` at
lines referenced in the review) ensuring no extra blank lines remain—update the
`.gitignore` so the only unique symbol `.envrc` is present once.
In `@internal/dodo/client.go`:
- Around line 213-255: Replace the unbounded io.ReadAll in parseError with a
bounded read (e.g., use io.LimitReader or io.ReadAll(io.LimitReader(resp.Body,
maxErrorBodyBytes))) and define a reasonable constant like maxErrorBodyBytes
(e.g., 64*1024). Use the resulting truncated buffer for the json.Unmarshal into
errorResponse and for building the message; if the buffer was truncated, append
a short marker like " (truncated)" to the message before calling wrapError or
returning APIError. Keep wrapError and the APIError logic unchanged, only ensure
parseError uses the size-limited read and the new maxErrorBodyBytes constant.
In `@internal/services/license/license_service.go`:
- Around line 52-81: The fallback logic fails to detect the "dodo client not
configured" case because activateWithDodo returns a plain error string; add a
typed package-level sentinel error (e.g., ErrDodoClientNotConfigured =
errors.New("dodo client not configured")), return that sentinel from
activateWithDodo when the Dodo client is nil, and update isDodoFallbackError to
include errors.Is(err, ErrDodoClientNotConfigured) (in addition to the existing
checks for dodo.ErrLicenseNotFound and dodo.ErrInvalidLicenseKey) so
activateWithPolar is attempted when Dodo is not configured.
🧹 Nitpick comments (4)
internal/web/swagger/openapi.yaml (1)
2445-2453: Consider documentingprovideras an enum.This makes the API contract clearer for clients and keeps it aligned with backend provider constants.
♻️ Suggested schema refinement
provider: type: string + enum: + - polar + - dodoweb/src/hooks/useLicense.ts (1)
78-88: Keep delete error messaging consistent with other license hooks.Other license mutations use
getLicenseErrorMessage, which likely preserves backend-specific messages; consider reusing it here too.♻️ Suggested tweak
- onError: (error: Error) => { - toast.error(error.message || "Failed to remove license") - }, + onError: (error: Error) => { + toast.error(getLicenseErrorMessage(error)) + },internal/models/license.go (1)
50-51: Consider grouping provider constants separately.The provider constants are placed under the "LicenseStatus constants" comment, but they're conceptually different. Consider either updating the comment to encompass both or creating a separate const block for providers.
♻️ Suggested organization
// LicenseStatus constants const ( LicenseStatusActive = "active" LicenseStatusInvalid = "invalid" +) + +// LicenseProvider constants +const ( LicenseProviderDodo = "dodo" LicenseProviderPolar = "polar" )web/src/components/themes/LicenseManager.tsx (1)
54-58: Consider guarding for future multi‑license support.
portalUrl/portalLabelare derived fromlicenses[0]. If multi‑license support is introduced later, consider deriving this from the license being rendered/acted on to avoid provider mismatches.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@internal/services/license/license_service.go`:
- Around line 582-584: The default branch in RefreshAllLicenses currently
returns ErrDodoClientNotConfigured immediately, which bypasses the fallback
logic used in ValidateLicenses; instead, change the default branch in
RefreshAllLicenses to treat the missing s.dodoClient like a transient/fallback
error so it flows into handleTransient()/isDodoFallbackError() the same way
ValidateLicenses does (i.e., do not return ErrDodoClientNotConfigured
directly—invoke the same fallback/error-path handling used elsewhere so Polar
fallback is attempted).
- Around line 217-221: The switch branch that currently returns
errors.New("activation limit exceeded") discards the sentinel
polar.ErrActivationLimitExceeded; change that branch to wrap and return the
original sentinel (e.g., use errors.Wrap(polar.ErrActivationLimitExceeded,
"activation limit exceeded") or fmt.Errorf("activation limit exceeded: %w",
polar.ErrActivationLimitExceeded)) so callers can still use errors.Is(...,
polar.ErrActivationLimitExceeded); update the switch in the license activation
logic where the code checks errors.Is(err, polar.ErrActivationLimitExceeded) to
return the wrapped sentinel instead of a new error.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@internal/services/license/license_service.go`:
- Around line 474-478: The loop in Refresh/validation over licenses should not
abort on single-provider failures; replace hard returns (e.g., returning
ErrDodoClientNotConfigured and other `return err` occurrences inside the
for-loop for Dodo/Polar) with a continue plus a logged error or accumulation of
the error so the loop proceeds to subsequent licenses. Specifically, in the
switch handling normalizeProvider(license.Provider) for
models.LicenseProviderDodo and models.LicenseProviderPolar, change checks like
`if s.dodoClient == nil { return ErrDodoClientNotConfigured }` and any `return
err` inside the loop to `log`/record the error and `continue` (mirror how
ErrActivationLimitExceeded branches do), or delegate to the existing
handleTransient pattern used by ValidateLicenses to mark transient failures
without aborting the whole refresh. Ensure you reference
s.dodoClient/s.polarClient, ErrDodoClientNotConfigured, and
handleTransient/ValidateLicenses behavior when making the changes.
- Around line 656-677: ValidateLicenses can dereference a nil s.dodoClient after
ensureDodoActivation returns early; add the same nil-check used in
RefreshAllLicenses before calling s.dodoClient.Validate: inside the switch case
handling models.LicenseProviderDodo (in ValidateLicenses), ensure s.dodoClient
!= nil (and return/handle as transient or mark invalid as appropriate) before
invoking s.dodoClient.Validate(...) so we avoid a nil pointer panic from
s.dodoClient; update error handling to mirror the existing RefreshAllLicenses
guard behavior.
🧹 Nitpick comments (1)
internal/services/license/license_service.go (1)
639-642: Commented-out code: remove or restore the recently-validated skip.The
time.Since(license.LastValidated) < time.Hourguard is active inRefreshAllLicenses(line 458) but commented out here. If the skip is intentionally disabled forValidateLicenses, a brief comment explaining why would help; otherwise, clean it up.
# Conflicts: # internal/api/ctxkeys/ctxkeys.go # internal/api/handlers/licenses.go
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
internal/services/license/license_service.go (2)
20-22:⚠️ Potential issue | 🟠 MajorRemove duplicate
ErrLicenseNotFoundsentinel — usemodels.ErrLicenseNotFoundconsistently.This package declares its own
ErrLicenseNotFoundat line 21 whilemodels.ErrLicenseNotFoundalready exists (internal/models/license.go line 13), both with identical error text. Sinceerrors.Newcreates distinct sentinel objects, they cannot be matched by a singleerrors.Is()call. The handler in internal/api/handlers/licenses.go line 177 must check both sentinels separately, which is unnecessary duplication. Delete the declaration at line 21 and usemodels.ErrLicenseNotFoundthroughout this package.
27-43:⚠️ Potential issue | 🟡 MinorRemove unused
dbfield fromServicestruct.The
db *database.DBfield (line 28) is declared but never initialized in the constructor (lines 36-43) and is not used anywhere in the service. Remove it to avoid confusion.
🤖 Fix all issues with AI agents
In `@internal/services/license/license_service.go`:
- Around line 884-911: The Dodo branch in DeleteLicense enforces stricter
behavior than the Polar branch by returning ErrDodoClientNotConfigured when
s.dodoClient is nil, preventing local DB deletion; make the behaviors consistent
by changing the Dodo handling in DeleteLicense (the switch on normalizeProvider
and the block referencing s.dodoClient, DodoInstanceID, and
ErrDodoClientNotConfigured) so that if s.dodoClient is nil you log a warning and
skip remote deactivation (similar to the Polar path) instead of returning an
error—keep existing error handling for actual Deactivate() failures but do not
block local deletion when the client is unavailable.
🧹 Nitpick comments (5)
internal/services/license/license_service_dodo_regression_test.go (1)
221-282: Solid multi-license continuation test.Verifies that a Dodo client error for one license doesn't block processing of a subsequent Polar license. The assertion that the Polar license remains active after the Dodo error confirms the
recordRefreshErr+continuepattern works correctly.One observation: the test doesn't assert the Dodo license's final status. If you want full coverage, consider also fetching and checking the Dodo license to confirm it wasn't inadvertently modified.
internal/services/license/license_service.go (4)
532-536: Asymmetric nil-client handling: Polar configuration errors are silently swallowed.When
dodoClientis nil (lines 483-488), the error is recorded viarecordRefreshErrand eventually returned to the caller. WhenpolarClientis nil (lines 533-536), the license is silently skipped with only a log warning —recordRefreshErris not called. The same asymmetry exists inValidateLicenses(line 740-744 vs 680-683).If this is intentional (Polar is legacy, Dodo is primary), a brief comment explaining the difference would help future maintainers. Otherwise, consider recording the error consistently.
661-664: Remove or restore the commented-out time-check block.This 1-hour skip is active in
RefreshAllLicenses(line 465) but commented out here. If intentionally disabled forValidateLicenses, add a brief comment explaining why. If this is leftover debug code, remove it to avoid confusion.
863-868: Return logic is subtle but correct — consider a clarifying comment.The intent: when a license is definitively invalid (
!allValid), suppress any transient error so the caller can cleanly distinguish "invalid" from "transient failure". When everything appears valid but a transient error occurred, surface it so the caller knows validation was incomplete.A brief inline comment would help future readers understand the priority-based error suppression.
385-430:ensurePolarActivationdoes not setlicense.Status = ActiveunlikeensureDodoActivation.
ensureDodoActivation(line 371) explicitly setslicense.Status = models.LicenseStatusActive, butensurePolarActivationomits this. Currently safe because callers pre-filter for active licenses, but this inconsistency could bite if the method is reused in a broader context later.Proposed fix
license.Provider = models.LicenseProviderPolar license.DodoInstanceID = "" license.PolarActivationID = activateResp.Id license.PolarCustomerID = &activateResp.LicenseKey.CustomerID license.PolarProductID = &activateResp.LicenseKey.BenefitID license.ActivatedAt = time.Now() license.ExpiresAt = activateResp.LicenseKey.ExpiresAt + license.Status = models.LicenseStatusActive
There was a problem hiding this comment.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/api/handlers/licenses.go (1)
177-183:⚠️ Potential issue | 🟠 MajorAdd check for
dodo.ErrLicenseNotFoundin the error handling.The handler checks
errors.Is(err, models.ErrLicenseNotFound)which will match repository errors, butvalidateExistingDodoLicensewraps dodo client errors withfmt.Errorf, allowingdodo.ErrLicenseNotFoundto propagate unwrapped in the error chain. Sincedodo.ErrLicenseNotFoundis a distinct sentinel frommodels.ErrLicenseNotFound, it will not match the check and will fall through to the generic403 Forbiddenresponse instead of404 Not Found. Consider also checkingerrors.Is(err, dodo.ErrLicenseNotFound)or normalize the error in the service layer.
🧹 Nitpick comments (4)
internal/services/license/license_service_dodo_regression_test.go (1)
1-1: Copyright year inconsistency.This file uses
2025whilelicenses.goandlicense_service.gouse2025-2026. Consider updating for consistency.internal/services/license/license_service.go (3)
528-532: Asymmetric error recording: Polar nil-client skipsrecordRefreshErr, Dodo does not.When
dodoClientis nil (lines 479-484), the error is captured viarecordRefreshErr(ErrDodoClientNotConfigured)and surfaces to the caller. WhenpolarClientis nil (lines 529-532), it silently skips without recording. This meansRefreshAllLicensesreturnsnilif only Polar licenses couldn't be refreshed due to a missing client, but returns an error if only Dodo licenses couldn't.If this asymmetry is intentional (e.g., Polar is being phased out), a brief comment would clarify intent.
446-451:recordRefreshErronly captures the first error — considererrors.Joinfor richer diagnostics.If multiple licenses fail for different reasons, only the first error is preserved. With Go 1.20+
errors.Join, you could accumulate all errors while still letting callers useerrors.Isagainst any of them. This is optional and low priority.
130-142: Multipletime.Now()calls per activation create slight timestamp drift between fields.In both
activateWithDodoandactivateWithPolar,ActivatedAt,LastValidated,CreatedAt, andUpdatedAteach calltime.Now()separately, which could produce slightly different timestamps. Consider capturingnow := time.Now()once and reusing it.Example for activateWithDodo
+ now := time.Now() if existingLicense != nil { existingLicense.ProductName = productName existingLicense.Status = models.LicenseStatusActive - existingLicense.ActivatedAt = time.Now() + existingLicense.ActivatedAt = now existingLicense.ExpiresAt = activateResp.ExpiresAt - existingLicense.LastValidated = time.Now() + existingLicense.LastValidated = now // ... - existingLicense.UpdatedAt = time.Now() + existingLicense.UpdatedAt = nowAlso applies to: 156-167, 205-217, 231-244
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 by CodeRabbit
New Features
UI/UX Updates
Data & Migrations
Tests & Stability