feat: add per-tracker SortTorrents queue ordering#64
feat: add per-tracker SortTorrents queue ordering#64
Conversation
Add tracker-level SortTorrents config across backend and WebUI, and reorder qBittorrent queues by effective tracker priority during processing when enabled. This preserves existing behavior by default and documents/tests the new setting end-to-end.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Batch topPrio call ignores hash parameter ordering
- The sorting routine now issues
topPrioper hash in computed order so qBittorrent batch reordering no longer discards tracker-priority intent.
- The sorting routine now issues
Preview (3cd21b5bb1)
diff --git a/config.example.toml b/config.example.toml
--- a/config.example.toml
+++ b/config.example.toml
@@ -123,6 +123,13 @@
# Global tracker configurations (inherited by all Arr instances)
Trackers = []
+# Example per-tracker override:
+# [[qBit.Trackers]]
+# Name = "Private Tracker"
+# URI = "https://tracker.example.com/announce"
+# Priority = 10
+# SortTorrents = false
+
[qBit.CategorySeeding]
# Download rate limit per torrent in KB/s (-1 = unlimited)
DownloadRateLimitPerTorrent = -1
diff --git a/docs/configuration/seeding.md b/docs/configuration/seeding.md
--- a/docs/configuration/seeding.md
+++ b/docs/configuration/seeding.md
@@ -437,6 +437,23 @@
---
+#### SortTorrents
+
+```toml
+SortTorrents = false
+```
+
+**Type:** Boolean
+**Default:** `false`
+
+When enabled for a tracker, torrents matching that tracker are reordered in the qBittorrent queue by tracker `Priority` during processing.
+
+- Higher `Priority` trackers are pushed toward the top of the queue.
+- This setting only affects torrents whose effective tracker configuration has `SortTorrents = true`.
+- qBittorrent **Torrent Queuing** must be enabled for queue ordering effects to be visible.
+
+---
+
#### URI
```toml
diff --git a/docs/webui/config-editor.md b/docs/webui/config-editor.md
--- a/docs/webui/config-editor.md
+++ b/docs/webui/config-editor.md
@@ -445,6 +445,7 @@
- **Name**: Tracker name (for display purposes)
- **URI**: Tracker URL (used for matching)
- **Priority**: Tracker priority (higher = preferred)
+- **Sort Torrents**: Reorder matching torrents in qBittorrent queue by tracker priority
- **Maximum ETA (s)**: Override global ETA limit for this tracker
- **Download Rate Limit**: Override global download limit
- **Upload Rate Limit**: Override global upload limit
@@ -461,6 +462,7 @@
Name = "Premium Tracker"
URI = "https://premium.tracker.com/announce"
Priority = 10
+SortTorrents = false
MaximumETA = 86400
DownloadRateLimit = -1
UploadRateLimit = -1
diff --git a/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs b/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs
--- a/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs
+++ b/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs
@@ -789,6 +789,10 @@
}
// --- HnR defaults on CategorySeeding and Tracker sections ---
+ var trackerDefaults = new Dictionary<string, object>
+ {
+ ["SortTorrents"] = false
+ };
var hnrDefaults = new Dictionary<string, object>
{
["HitAndRunMode"] = "disabled",
@@ -823,6 +827,14 @@
{
foreach (var trackerTable in GetTrackerTables(trObj))
{
+ foreach (var (field, defaultVal) in trackerDefaults)
+ {
+ if (!trackerTable.ContainsKey(field))
+ {
+ trackerTable[field] = defaultVal;
+ changed = true;
+ }
+ }
foreach (var (field, defaultVal) in hnrDefaults)
{
if (!trackerTable.ContainsKey(field))
@@ -840,6 +852,14 @@
{
foreach (var trackerTable in GetTrackerTables(atrObj))
{
+ foreach (var (field, defaultVal) in trackerDefaults)
+ {
+ if (!trackerTable.ContainsKey(field))
+ {
+ trackerTable[field] = defaultVal;
+ changed = true;
+ }
+ }
foreach (var (field, defaultVal) in hnrDefaults)
{
if (!trackerTable.ContainsKey(field))
@@ -977,6 +997,9 @@
if (table.TryGetValue("Priority", out var priority))
tracker.Priority = Convert.ToInt32(priority);
+ if (table.TryGetValue("SortTorrents", out var sortTorrents))
+ tracker.SortTorrents = Convert.ToBoolean(sortTorrents);
+
if (table.TryGetValue("MaxUploadRatio", out var maxRatio))
tracker.MaxUploadRatio = Convert.ToDouble(maxRatio);
@@ -1773,6 +1796,7 @@
sb.AppendLine($"Name = \"{EscapeTomlString(tracker.Name)}\"");
sb.AppendLine($"URI = \"{tracker.Uri}\"");
sb.AppendLine($"Priority = {tracker.Priority}");
+ sb.AppendLine($"SortTorrents = {tracker.SortTorrents.ToString().ToLower()}");
sb.AppendLine($"MaximumETA = {tracker.MaxETA ?? -1}");
sb.AppendLine($"DownloadRateLimit = {tracker.DownloadRateLimit ?? -1}");
sb.AppendLine($"UploadRateLimit = {tracker.UploadRateLimit ?? -1}");
diff --git a/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs b/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs
--- a/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs
+++ b/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs
@@ -107,6 +107,7 @@
public string? Name { get; set; } // Human-readable tracker name
public string Uri { get; set; } = "";
public int Priority { get; set; } = 0;
+ public bool SortTorrents { get; set; } = false;
public double? MaxUploadRatio { get; set; }
public int? MaxSeedingTime { get; set; }
public int? RemoveTorrent { get; set; }
diff --git a/src/Torrentarr.Infrastructure/ApiClients/QBittorrent/QBittorrentClient.cs b/src/Torrentarr.Infrastructure/ApiClients/QBittorrent/QBittorrentClient.cs
--- a/src/Torrentarr.Infrastructure/ApiClients/QBittorrent/QBittorrentClient.cs
+++ b/src/Torrentarr.Infrastructure/ApiClients/QBittorrent/QBittorrentClient.cs
@@ -452,6 +452,23 @@
}
/// <summary>
+ /// Move torrents to the top of the queue priority list.
+ /// POST /api/v2/torrents/topPrio
+ /// </summary>
+ public async Task<bool> TopPriorityAsync(List<string> hashes, CancellationToken ct = default)
+ {
+ if (hashes == null || hashes.Count == 0)
+ return true;
+
+ var request = new RestRequest("api/v2/torrents/topPrio", Method.Post);
+ AddAuthCookie(request);
+ request.AddParameter("hashes", string.Join("|", hashes));
+
+ var response = await _client.ExecuteAsync(request, ct);
+ return response.IsSuccessful;
+ }
+
+ /// <summary>
/// Create a new category
/// </summary>
public async Task<bool> CreateCategoryAsync(string name, string? savePath = null, CancellationToken ct = default)
diff --git a/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs b/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs
--- a/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs
+++ b/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs
@@ -94,6 +94,8 @@
}
_logger.LogDebug("Found {Count} torrents in category {Category}", torrents.Count, category);
+ await SortTorrentsByTrackerPriorityAsync(torrents, cancellationToken);
+
var stats = new TorrentProcessingStats
{
TotalTorrents = torrents.Count
@@ -587,6 +589,83 @@
}
}
+ /// <summary>
+ /// Reorder qBittorrent queue by tracker priority for torrents whose effective tracker
+ /// config has SortTorrents=true. Runs once per processing cycle per category.
+ /// </summary>
+ private async Task SortTorrentsByTrackerPriorityAsync(
+ List<TorrentInfo> torrents,
+ CancellationToken ct)
+ {
+ if (_seedingService == null || torrents.Count == 0)
+ return;
+
+ // Fast-path: if no configured tracker enables sorting, skip per-torrent tracker API lookups.
+ var hasSortTorrentsEnabled = _config.QBitInstances.Values.Any(q =>
+ q.Trackers.Any(t => t.SortTorrents))
+ || _config.ArrInstances.Values.Any(a =>
+ a.Torrent.Trackers.Any(t => t.SortTorrents));
+
+ if (!hasSortTorrentsEnabled)
+ return;
+
+ var sortableByInstance = new Dictionary<string, List<(TorrentInfo Torrent, int Priority)>>(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var torrent in torrents)
+ {
+ try
+ {
+ var trackerConfig = await _seedingService.GetTrackerConfigAsync(torrent, ct);
+ if (trackerConfig?.SortTorrents != true)
+ continue;
+
+ if (!sortableByInstance.TryGetValue(torrent.QBitInstanceName, out var list))
+ {
+ list = new List<(TorrentInfo Torrent, int Priority)>();
+ sortableByInstance[torrent.QBitInstanceName] = list;
+ }
+
+ list.Add((torrent, trackerConfig.Priority));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Skipping sort evaluation for torrent {Hash}", torrent.Hash);
+ }
+ }
+
+ foreach (var (instanceName, sortable) in sortableByInstance)
+ {
+ if (sortable.Count == 0)
+ continue;
+
+ var client = _qbitManager.GetClient(instanceName);
+ if (client == null)
+ continue;
+
+ try
+ {
+ // qB topPrio moves torrents to the top of the queue.
+ // Apply reverse order of descending priority one hash at a time so each later
+ // call is placed above the previous one (highest priority ends up at the top).
+ var ordered = sortable
+ .OrderByDescending(t => t.Priority)
+ .ThenBy(t => t.Torrent.AddedOn)
+ .Select(t => t.Torrent.Hash)
+ .Reverse()
+ .ToList();
+
+ foreach (var hash in ordered)
+ {
+ await client.TopPriorityAsync(new List<string> { hash }, ct);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to sort torrent queue for qBit instance {Instance}", instanceName);
+ }
+ }
+ }
+
// ========================================================================================
// PRE-STEP: leave_alone resolution (qBitrr: _should_leave_alone — arss.py:5804-5892)
// ========================================================================================
diff --git a/tests/Torrentarr.Core.Tests/Configuration/ConfigurationLoaderTests.cs b/tests/Torrentarr.Core.Tests/Configuration/ConfigurationLoaderTests.cs
--- a/tests/Torrentarr.Core.Tests/Configuration/ConfigurationLoaderTests.cs
+++ b/tests/Torrentarr.Core.Tests/Configuration/ConfigurationLoaderTests.cs
@@ -593,4 +593,78 @@
config.WebUI.AuthDisabled.Should().BeFalse("new installs get auth enabled by default");
config.WebUI.LocalAuthEnabled.Should().BeTrue("new installs get local auth enabled by default");
}
+
+ [Fact]
+ public void Load_ParsesTrackerSortTorrents_DefaultsFalseWhenMissing()
+ {
+ WriteToml("""
+ [qBit]
+ Host = "localhost"
+
+ [Radarr-Movies]
+ URI = "http://radarr:7878"
+ APIKey = "key"
+ Category = "radarr"
+
+ [[Radarr-Movies.Torrent.Trackers]]
+ URI = "https://tracker.example.com/announce"
+ Priority = 10
+ """);
+
+ var config = new ConfigurationLoader(_tempFilePath).Load();
+
+ config.ArrInstances["Radarr-Movies"].Torrent.Trackers.Should().HaveCount(1);
+ config.ArrInstances["Radarr-Movies"].Torrent.Trackers[0].SortTorrents.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Save_WritesTrackerSortTorrents()
+ {
+ WriteToml("""
+ [qBit]
+ Host = "localhost"
+
+ [Radarr-Movies]
+ URI = "http://radarr:7878"
+ APIKey = "key"
+ Category = "radarr"
+ """);
+
+ var loader = new ConfigurationLoader(_tempFilePath);
+ var config = loader.Load();
+ config.ArrInstances["Radarr-Movies"].Torrent.Trackers.Add(new TrackerConfig
+ {
+ Uri = "https://tracker.example.com/announce",
+ Priority = 10,
+ SortTorrents = true
+ });
+
+ loader.SaveConfig(config);
+ var content = File.ReadAllText(_tempFilePath);
+
+ content.Should().Contain("SortTorrents = true");
+ }
+
+ [Fact]
+ public void Load_ParsesTrackerSortTorrents_TrueWhenSet()
+ {
+ WriteToml("""
+ [qBit]
+ Host = "localhost"
+
+ [Radarr-Movies]
+ URI = "http://radarr:7878"
+ APIKey = "key"
+ Category = "radarr"
+
+ [[Radarr-Movies.Torrent.Trackers]]
+ URI = "https://tracker.example.com/announce"
+ Priority = 10
+ SortTorrents = true
+ """);
+
+ var config = new ConfigurationLoader(_tempFilePath).Load();
+
+ config.ArrInstances["Radarr-Movies"].Torrent.Trackers[0].SortTorrents.Should().BeTrue();
+ }
}
diff --git a/tests/Torrentarr.Core.Tests/Configuration/TorrentarrConfigDefaultsTests.cs b/tests/Torrentarr.Core.Tests/Configuration/TorrentarrConfigDefaultsTests.cs
--- a/tests/Torrentarr.Core.Tests/Configuration/TorrentarrConfigDefaultsTests.cs
+++ b/tests/Torrentarr.Core.Tests/Configuration/TorrentarrConfigDefaultsTests.cs
@@ -101,6 +101,7 @@
{
var tracker = new TrackerConfig();
+ tracker.SortTorrents.Should().BeFalse();
tracker.HitAndRunMode.Should().BeNull(); // string? defaults to null
tracker.MinSeedRatio.Should().BeNull();
tracker.MinSeedingTimeDays.Should().BeNull();
diff --git a/webui/src/__tests__/config/torrentHandlingSummary.test.ts b/webui/src/__tests__/config/torrentHandlingSummary.test.ts
new file mode 100644
--- /dev/null
+++ b/webui/src/__tests__/config/torrentHandlingSummary.test.ts
@@ -1,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import {
+ getArrTorrentHandlingSummary,
+ getQbitTorrentHandlingSummary,
+} from "../../config/torrentHandlingSummary";
+
+describe("torrentHandlingSummary", () => {
+ it("includes queue sorting line for Arr tracker when SortTorrents is enabled", () => {
+ const summary = getArrTorrentHandlingSummary({
+ Torrent: {
+ Trackers: [
+ {
+ Name: "Private",
+ HitAndRunMode: "disabled",
+ SortTorrents: true,
+ },
+ ],
+ },
+ } as never);
+
+ expect(summary).toContain("Queue sorting is enabled for this tracker.");
+ });
+
+ it("does not include queue sorting line when SortTorrents is absent", () => {
+ const summary = getQbitTorrentHandlingSummary({
+ Trackers: [
+ {
+ Name: "Private",
+ HitAndRunMode: "disabled",
+ },
+ ],
+ } as never);
+
+ expect(summary).not.toContain("Queue sorting is enabled for this tracker.");
+ });
+});
diff --git a/webui/src/config/tooltips.ts b/webui/src/config/tooltips.ts
--- a/webui/src/config/tooltips.ts
+++ b/webui/src/config/tooltips.ts
@@ -200,6 +200,8 @@
"Ignore torrents younger than this many seconds in managed categories when evaluating failures.",
HitAndRunMode:
"Hit and Run protection mode for this tracker: 'and' requires both ratio and time, 'or' clears on either, 'disabled' turns off HnR.",
+ SortTorrents:
+ "When enabled, torrents matching this tracker are moved to the top of the qBittorrent queue by tracker priority. Requires qBittorrent Torrent Queuing to be enabled.",
MinSeedRatio:
"Minimum seed ratio before HnR obligation is cleared (e.g. 1.0 for 1:1 ratio).",
MinSeedingTimeDays:
diff --git a/webui/src/config/torrentHandlingSummary.ts b/webui/src/config/torrentHandlingSummary.ts
--- a/webui/src/config/torrentHandlingSummary.ts
+++ b/webui/src/config/torrentHandlingSummary.ts
@@ -245,6 +245,7 @@
const t = raw as Record<string, unknown>;
const name = String(t.Name ?? "Tracker").trim() || "Tracker";
const mode = resolveHnrMode(t.HitAndRunMode);
+ const sortTorrents = Boolean(t.SortTorrents);
const tMinRatio = Number(t.MinSeedRatio ?? 1);
const tMinDays = Number(t.MinSeedingTimeDays ?? 0);
const ratioStr = Number.isFinite(tMinRatio) ? tMinRatio : 1;
@@ -256,12 +257,12 @@
);
if (mode === "disabled") {
blocks.push(
- `- **${name}** \u2014 HnR is off. The torrent may be removed as soon as the seeding rules above are met.`,
+ `- **${name}** \u2014 HnR is off. The torrent may be removed as soon as the seeding rules above are met.${sortTorrents ? " Queue sorting is enabled for this tracker." : ""}`,
);
} else {
const both = mode === "and" ? "both required" : "either allows removal";
blocks.push(
- `- **${name}** \u2014 HnR is on (${both}). The torrent will **not** be removed until it has reached ratio ${ratioStr} and been seeding for ${daysVal} ${daysLabel}. Until then, it is protected from removal even if stalled or if the global seeding limit would allow removal.`,
+ `- **${name}** \u2014 HnR is on (${both}). The torrent will **not** be removed until it has reached ratio ${ratioStr} and been seeding for ${daysVal} ${daysLabel}. Until then, it is protected from removal even if stalled or if the global seeding limit would allow removal.${sortTorrents ? " Queue sorting is enabled for this tracker." : ""}`,
);
}
});
@@ -398,6 +399,7 @@
const t = raw as Record<string, unknown>;
const name = String(t.Name ?? "Tracker").trim() || "Tracker";
const mode = resolveHnrMode(t.HitAndRunMode);
+ const sortTorrents = Boolean(t.SortTorrents);
const tMinRatio = Number(t.MinSeedRatio ?? 1);
const tMinDays = Number(t.MinSeedingTimeDays ?? 0);
const ratioStr = Number.isFinite(tMinRatio) ? tMinRatio : 1;
@@ -409,7 +411,7 @@
);
if (mode === "disabled") {
blocks.push(
- `- **${name}** \u2014 HnR is off. The torrent may be removed as soon as the seeding rules above are met (e.g. after max time or when max ratio is reached).`,
+ `- **${name}** \u2014 HnR is off. The torrent may be removed as soon as the seeding rules above are met (e.g. after max time or when max ratio is reached).${sortTorrents ? " Queue sorting is enabled for this tracker." : ""}`,
);
} else {
const both = mode === "and" ? "both required" : "either allows removal";
@@ -418,7 +420,7 @@
? `until it has reached ratio ${ratioStr} and been seeding for ${daysVal} ${daysLabel}`
: `until it has reached ratio ${ratioStr} or been seeding for ${daysVal} ${daysLabel}`;
blocks.push(
- `- **${name}** \u2014 HnR is on (${both}). The torrent will **not** be removed ${until}. Until then, it is protected from removal even if stalled or if the global seeding time would allow removal.`,
+ `- **${name}** \u2014 HnR is on (${both}). The torrent will **not** be removed ${until}. Until then, it is protected from removal even if stalled or if the global seeding time would allow removal.${sortTorrents ? " Queue sorting is enabled for this tracker." : ""}`,
);
}
});
diff --git a/webui/src/pages/ConfigView.tsx b/webui/src/pages/ConfigView.tsx
--- a/webui/src/pages/ConfigView.tsx
+++ b/webui/src/pages/ConfigView.tsx
@@ -1148,6 +1148,11 @@
},
},
{
+ label: "Sort Torrents",
+ path: ["SortTorrents"],
+ type: "checkbox",
+ },
+ {
label: "Maximum ETA",
path: ["MaxETA"],
type: "duration",
@@ -3187,6 +3192,7 @@
Name: "",
Uri: "",
Priority: 0,
+ SortTorrents: false,
RemoveIfExists: false,
SuperSeedMode: false,
AddTags: [],Document latest-version-only security support, add configurable CORS allowlist, and add a confirmation warning before disabling auth so users keep intended behavior while getting a clear internet-exposure warning.
Assert on the rendered movie row value instead of the column header text so the test waits for loaded data and remains stable on slower Linux/macOS runners.
…ories work and also added the Tracker sorter
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: SortTorrents not serialized for qBit-level trackers on save
- SaveConfig now serializes qBit tracker entries as
[[qBit*.Trackers]]and writesSortTorrents, and a regression test was added to verify the field persists on save.
- SaveConfig now serializes qBit tracker entries as
Preview (c4b472177b)
diff --git a/AGENTS.md b/AGENTS.md
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -174,6 +174,12 @@
GitHub Actions runs a matrix build across Ubuntu, Windows, and macOS with .NET 10 + Node 20. Pipeline: restore → build → test (non-live) → frontend build → Docker build (on `master` push). Artifacts retained 7 days.
+## Documentation and Mermaid
+
+- **Tabular data:** Use **markdown tables** by default. Do not recreate tables as Mermaid **flowcharts** (or subgraph “grids”). If you use Mermaid for a table, use a dedicated **table** diagram type only when your Mermaid version and doc host support it; otherwise stay with markdown.
+- **Mermaid for non-tabular content:** Use `flowchart`, `sequenceDiagram`, architecture diagrams, `xychart-beta`, and similar for processes, relationships, and charts—not for spreadsheet-style comparisons or lookup matrices.
+- **Rendering:** Confirm new diagrams in GitHub’s Markdown preview (or your doc host). Follow existing diagrams in `docs/`: no custom Mermaid colors, use camelCase or underscores for node IDs (no spaces), quote edge labels that contain parentheses.
+
## Git commits
**Do not use `git commit --no-verify` or `git commit -n`.** Pre-commit hooks must run on every commit. When committing or pushing on the user's behalf, use `git commit` without the `--no-verify` flag so that pre-commit runs. If hooks fail, fix the reported issues (e.g. formatting) or inform the user—do not bypass hooks.
diff --git a/CLAUDE.md b/CLAUDE.md
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -179,6 +179,12 @@
GitHub Actions runs a matrix build across Ubuntu, Windows, and macOS with .NET 10 + Node 20. Pipeline: restore → build → test (non-live) → frontend build → Docker build (on `master` push). Artifacts retained 7 days.
+## Documentation and Mermaid
+
+- **Tabular data:** Use **markdown tables** by default. Do not recreate tables as Mermaid **flowcharts** (or subgraph “grids”). If you use Mermaid for a table, use a dedicated **table** diagram type only when your Mermaid version and doc host support it; otherwise stay with markdown.
+- **Mermaid for non-tabular content:** Use `flowchart`, `sequenceDiagram`, architecture diagrams, `xychart-beta`, and similar for processes, relationships, and charts—not for spreadsheet-style comparisons or lookup matrices.
+- **Rendering:** Confirm new diagrams in GitHub’s Markdown preview (or your doc host). Follow existing diagrams in `docs/`: no custom Mermaid colors, use camelCase or underscores for node IDs (no spaces), quote edge labels that contain parentheses.
+
## Git commits
**Do not use `git commit --no-verify` or `git commit -n`.** Pre-commit hooks must run on every commit. When committing or pushing on the user's behalf, use `git commit` without the `--no-verify` flag so that pre-commit runs. If hooks fail, fix the reported issues (e.g. formatting) or inform the user—do not bypass hooks.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -177,6 +177,7 @@
- **Report Bugs:** [GitHub Issues](https://github.com/Feramance/Torrentarr/issues)
- **Discussions:** [GitHub Discussions](https://github.com/Feramance/Torrentarr/discussions)
+- **Security:** [SECURITY.md](SECURITY.md) (supported versions, vulnerability reporting)
## Contributing
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
--- /dev/null
+++ b/SECURITY.md
@@ -1,0 +1,31 @@
+# Security policy
+
+## Supported versions
+
+Security fixes are applied only to the **latest released version** of Torrentarr. Older releases are not maintained with backported security patches. If you report an issue affecting an older version, we may ask you to upgrade to the current release and confirm whether the problem still exists.
+
+## Reporting a vulnerability
+
+Please report security vulnerabilities responsibly so we can address them before public disclosure.
+
+**Preferred:** Use [GitHub Security Advisories](https://github.com/Feramance/Torrentarr/security/advisories) for this repository (Report a vulnerability).
+
+**Alternative:** Open a private discussion with maintainers if GitHub Advisories is not available, or contact the maintainers through the channels listed in the [README](README.md).
+
+Include:
+
+- A description of the issue and its impact
+- Steps to reproduce or proof-of-concept, if safe to share
+- Affected component (WebUI, Host, config, etc.) and version
+
+We aim to acknowledge reports in a timely manner. Please avoid testing against production systems you do not own.
+
+## Scope
+
+This policy applies to the Torrentarr application and its official distribution artifacts (for example, release binaries published on GitHub). Third-party services you configure (qBittorrent, Radarr, Sonarr, Lidarr, reverse proxies, identity providers) follow their own security practices and are outside this project’s control.
+
+## Deployment reminders
+
+- When **`AuthDisabled`** is true, `/web/*` is not behind the login screen—restrict network access (firewall, bind address, or reverse proxy with authentication) if needed. **`/api/*`** still requires `WebUI.Token` (Bearer). Prefer keeping authentication enabled or using network controls when exposing Torrentarr beyond a trusted LAN.
+- Prefer HTTPS in production (e.g. terminate TLS at a reverse proxy).
+- See the project documentation for [WebUI authentication](docs/configuration/webui-authentication.md) and [API usage](docs/webui/api.md).
diff --git a/config.example.toml b/config.example.toml
--- a/config.example.toml
+++ b/config.example.toml
@@ -103,6 +103,9 @@
# View density (Comfortable or Compact)
ViewDensity = "Comfortable"
+# Optional: restrict browser CORS to these origins (omit or set [] to allow any origin). See docs/configuration/webui-authentication.md
+# CorsAllowedOrigins = [ "https://torrentarr.example.com" ]
+
[qBit]
# Disable qBittorrent integration (headless search-only mode)
Disabled = false
@@ -123,6 +126,13 @@
# Global tracker configurations (inherited by all Arr instances)
Trackers = []
+# Example per-tracker override:
+# [[qBit.Trackers]]
+# Name = "Private Tracker"
+# URI = "https://tracker.example.com/announce"
+# Priority = 10
+# SortTorrents = false
+
[qBit.CategorySeeding]
# Download rate limit per torrent in KB/s (-1 = unlimited)
DownloadRateLimitPerTorrent = -1
diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md
--- a/docs/advanced/architecture.md
+++ b/docs/advanced/architecture.md
@@ -346,6 +346,8 @@
└───────────────────┘+Host-only workers: HostWorkerManager (in Infrastructure, hosted by Torrentarr.Host) runs concurrent Task.Run loops for Failed/Recheck category handling, global free-space pause/resume, and tracker-priority queue ordering when SortTorrents is enabled. It monitors worker tasks and restarts them on unexpected completion, similar in spirit to ArrWorkerManager for OS worker processes.
+
Service Registration (Program.cs):
diff --git a/docs/configuration/seeding.md b/docs/configuration/seeding.md
--- a/docs/configuration/seeding.md
+++ b/docs/configuration/seeding.md
@@ -437,6 +437,24 @@
---
+#### SortTorrents
+
+```toml
+SortTorrents = false
+```
+
+**Type:** Boolean
+**Default:** `false`
+
+When enabled for a tracker, torrents matching that tracker are reordered in the qBittorrent queue by tracker `Priority`.
+
+- Ordering runs in the **Host** `TrackerSortManager` subprocess (fire-and-forget loop managed with Failed/Recheck/Free Space), not inside each Arr torrent worker.
+- Higher `Priority` trackers are pushed toward the top of the queue.
+- This setting only affects torrents whose effective tracker configuration has `SortTorrents = true`.
+- qBittorrent **Torrent Queuing** must be enabled for queue ordering effects to be visible.
+
+---
+
#### URI
```toml
diff --git a/docs/configuration/webui-authentication.md b/docs/configuration/webui-authentication.md
--- a/docs/configuration/webui-authentication.md
+++ b/docs/configuration/webui-authentication.md
@@ -209,7 +209,8 @@
## Deployment and security
-- **CORS:** The WebUI server may allow cross-origin requests (e.g. `AllowAnyOrigin` in development). In sensitive deployments, restrict CORS to trusted origins via your reverse proxy or application configuration so that only your intended UI origin can call the API.
+- **CORS:** When **`CorsAllowedOrigins`** in `[WebUI]` is **empty** (default), the server allows any origin (`AllowAnyOrigin`), which is convenient for local development and simple LAN setups. When you set **`CorsAllowedOrigins`** to one or more origins (e.g. `["https://torrentarr.example.com"]`), only those origins may call the API from the browser, with credentials allowed; restart after changing this value. You can also restrict cross-origin access entirely at your reverse proxy.
+- **Auth disabled:** When **`AuthDisabled = true`**, `/web/*` routes do not require a login; **`/api/*`** still requires **`WebUI.Token`**. The WebUI shows a confirmation when you turn off authentication. If the service is exposed to the internet or untrusted networks, use a reverse proxy with authentication, VPN, or firewall rules—or keep authentication enabled.
- **HTTPS:** In production, serve Torrentarr over HTTPS (e.g. behind a reverse proxy with TLS). Session cookies use `SecurePolicy=SameAsRequest`, so they are only sent over HTTPS when the request is HTTPS.
---
diff --git a/docs/configuration/webui.md b/docs/configuration/webui.md
--- a/docs/configuration/webui.md
+++ b/docs/configuration/webui.md
@@ -188,7 +188,7 @@
## Authentication
-When **AuthDisabled** = `true` (default for existing configs), there is no login screen; the WebUI and API are protected only by the Token (or are public if Token was empty and has not yet been auto-generated). When **AuthDisabled** = `false`, browser users must either log in (local username/password and/or OIDC) or present the Bearer token. At least one of **LocalAuthEnabled** or **OIDCEnabled** should be true so the login page can offer a sign-in method.
+When **AuthDisabled** = `true` (default for existing configs), there is no login screen; **`/api/*`** is protected by the **Token** (Bearer), while **`/web/*`** is open to the network path—use firewall, reverse proxy auth, or keep **AuthDisabled** = `false` if exposing to untrusted networks. The WebUI warns you when disabling authentication. When **AuthDisabled** = `false`, browser users must either log in (local username/password and/or OIDC) or present the Bearer token. At least one of **LocalAuthEnabled** or **OIDCEnabled** should be true so the login page can offer a sign-in method.
**New installs:** If Torrentarr creates the config file on first run (it did not exist before), the generated config has **AuthDisabled = false** and **LocalAuthEnabled = true**. Users see a welcome screen to set an admin username and password before accessing the rest of the WebUI. Existing configs are unchanged unless you edit auth settings.
@@ -337,6 +337,19 @@
---
+## CorsAllowedOrigins
+
+```toml
+# CorsAllowedOrigins = [ "https://torrentarr.example.com" ]
+```
+
+**Type:** Array of strings (optional)
+**Default:** omitted or empty — any origin is allowed for cross-origin browser requests (legacy behavior).
+
+When set to one or more origins, the server restricts CORS to those origins and allows credentials. Restart after changing this value. See [WebUI authentication — Deployment and security](webui-authentication.md#deployment-and-security).
+
+---
+
## Complete Configuration Examples
### Example 1: Default (Public Access)
diff --git a/docs/development/index.md b/docs/development/index.md
--- a/docs/development/index.md
+++ b/docs/development/index.md
@@ -294,7 +294,7 @@
Torrentarr's backend is **.NET (C#)** with ASP.NET Core and separate worker processes. Key points:
-- **Torrentarr.Host** — Orchestrator: hosts WebUI (ASP.NET Core minimal API), manages free space, spawns per-Arr **Torrentarr.Workers** processes.
+- **Torrentarr.Host** — Orchestrator: hosts WebUI (ASP.NET Core minimal API), runs **HostWorkerManager** (Failed/Recheck/free space/tracker sort loops with auto-restart), spawns per-Arr **Torrentarr.Workers** processes.
- **Torrentarr.Infrastructure** — EF Core (SQLite), qBittorrent/Arr API clients, services (TorrentProcessor, SeedingService, ArrSyncService, etc.).
- **Torrentarr.Core** — Config models, interfaces.
diff --git a/docs/webui/api.md b/docs/webui/api.md
--- a/docs/webui/api.md
+++ b/docs/webui/api.md
@@ -21,15 +21,15 @@
| Pattern | Purpose | Authentication | Use Case |
|---------|---------|----------------|----------|
| `/api/*` | API-first endpoints | **Required** (Bearer token) | External clients, scripts, automation |
-| `/web/*` | First-party endpoints | **Optional** (no token required) | WebUI, reverse proxies with auth bypass |
+| `/web/*` | First-party endpoints | **Optional** when `AuthDisabled` is true (no login); login/OIDC or Bearer when auth is required | WebUI, reverse proxies with auth bypass |
-Both patterns return identical responses. Choose based on your authentication requirements.
+Both patterns return identical responses. Choose based on your authentication requirements. If you disable authentication in config, confirm the warning in the WebUI: untrusted network access should use a reverse proxy, VPN, or firewall—or keep authentication enabled.
### Interactive API (Swagger)
When Torrentarr is running, interactive API documentation is available at **`/swagger`** (for example, `http://localhost:6969/swagger`). Swagger UI lists all endpoints and lets you try them from the browser.
-When `WebUI.Token` is set, use the **Authorize** button in Swagger UI, enter your Bearer token (or paste the value from `GET /web/token`), then click Authorize. Requests to `/api/*` endpoints will then include the token. `/web/*` endpoints do not require authorization.
+When `WebUI.Token` is set, use the **Authorize** button in Swagger UI, enter your Bearer token (or paste the value from `GET /web/token`), then click Authorize. Requests to `/api/*` endpoints will then include the token. `/web/*` endpoints do not require the Bearer token when `AuthDisabled` is true (see [WebUI authentication](../configuration/webui-authentication.md) for login when auth is enabled).
---
@@ -63,7 +63,7 @@
- `GET /ui` - WebUI entry point
- `GET /sw.js` - Service worker
- `GET /static/*` - Static assets
-- `GET /web/*` - All first-party endpoints
+- `GET /web/*` - All first-party endpoints when `AuthDisabled` is true; when authentication is required, see [WebUI authentication](../configuration/webui-authentication.md)
### Token Authentication
@@ -1292,7 +1292,7 @@
## Best Practices
-1. **Use `/web/*` endpoints** for WebUI to avoid token management
+1. **Use `/web/*` endpoints** for WebUI when `AuthDisabled` is true to avoid token management for browser traffic
2. **Use `/api/*` endpoints** for external clients with Bearer token
3. **Cache `/api/meta` responses** for 1 hour to reduce GitHub API load
4. **Poll `/api/processes`** every 5-10 seconds (not faster to avoid overhead)
diff --git a/docs/webui/config-editor.md b/docs/webui/config-editor.md
--- a/docs/webui/config-editor.md
+++ b/docs/webui/config-editor.md
@@ -445,6 +445,7 @@
- **Name**: Tracker name (for display purposes)
- **URI**: Tracker URL (used for matching)
- **Priority**: Tracker priority (higher = preferred)
+- **Sort Torrents**: Reorder matching torrents in qBittorrent queue by tracker priority
- **Maximum ETA (s)**: Override global ETA limit for this tracker
- **Download Rate Limit**: Override global download limit
- **Upload Rate Limit**: Override global upload limit
@@ -461,6 +462,7 @@
Name = "Premium Tracker"
URI = "https://premium.tracker.com/announce"
Priority = 10
+SortTorrents = false
MaximumETA = 86400
DownloadRateLimit = -1
UploadRateLimit = -1
diff --git a/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs b/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs
--- a/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs
+++ b/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs
@@ -789,6 +789,10 @@
}
// --- HnR defaults on CategorySeeding and Tracker sections ---
+ var trackerDefaults = new Dictionary<string, object>
+ {
+ ["SortTorrents"] = false
+ };
var hnrDefaults = new Dictionary<string, object>
{
["HitAndRunMode"] = "disabled",
@@ -823,6 +827,14 @@
{
foreach (var trackerTable in GetTrackerTables(trObj))
{
+ foreach (var (field, defaultVal) in trackerDefaults)
+ {
+ if (!trackerTable.ContainsKey(field))
+ {
+ trackerTable[field] = defaultVal;
+ changed = true;
+ }
+ }
foreach (var (field, defaultVal) in hnrDefaults)
{
if (!trackerTable.ContainsKey(field))
@@ -840,6 +852,14 @@
{
foreach (var trackerTable in GetTrackerTables(atrObj))
{
+ foreach (var (field, defaultVal) in trackerDefaults)
+ {
+ if (!trackerTable.ContainsKey(field))
+ {
+ trackerTable[field] = defaultVal;
+ changed = true;
+ }
+ }
foreach (var (field, defaultVal) in hnrDefaults)
{
if (!trackerTable.ContainsKey(field))
@@ -977,6 +997,9 @@
if (table.TryGetValue("Priority", out var priority))
tracker.Priority = Convert.ToInt32(priority);
+ if (table.TryGetValue("SortTorrents", out var sortTorrents))
+ tracker.SortTorrents = Convert.ToBoolean(sortTorrents);
+
if (table.TryGetValue("MaxUploadRatio", out var maxRatio))
tracker.MaxUploadRatio = Convert.ToDouble(maxRatio);
@@ -1166,6 +1189,16 @@
if (table.TryGetValue("ViewDensity", out var viewDensity))
webui.ViewDensity = viewDensity?.ToString() ?? "Comfortable";
+ if (table.TryGetValue("CorsAllowedOrigins", out var corsVal) && corsVal is TomlArray corsArr)
+ {
+ foreach (var item in corsArr)
+ {
+ var s = item?.ToString()?.Trim();
+ if (!string.IsNullOrEmpty(s))
+ webui.CorsAllowedOrigins.Add(s);
+ }
+ }
+
if (table.TryGetValue("OIDC", out var oidcObj) && oidcObj is TomlTable oidcTable)
webui.OIDC = ParseOIDC(oidcTable);
@@ -1661,6 +1694,9 @@
sb.AppendLine($"GroupLidarr = {config.WebUI.GroupLidarr.ToString().ToLower()}");
sb.AppendLine($"Theme = \"{config.WebUI.Theme}\"");
sb.AppendLine($"ViewDensity = \"{config.WebUI.ViewDensity}\"");
+ if (config.WebUI.CorsAllowedOrigins.Count > 0)
+ sb.AppendLine(
+ $"CorsAllowedOrigins = [{string.Join(", ", config.WebUI.CorsAllowedOrigins.Select(o => $"\"{EscapeTomlString(o)}\""))}]");
if (config.WebUI.OIDC != null)
{
var o = config.WebUI.OIDC;
@@ -1699,7 +1735,40 @@
sb.AppendLine("# URI = \"tracker.example.com\"");
sb.AppendLine("# Priority = 1");
}
- sb.AppendLine("Trackers = []");
+ if (qbit.Trackers.Count == 0)
+ {
+ sb.AppendLine("Trackers = []");
+ }
+ else
+ {
+ foreach (var tracker in qbit.Trackers)
+ {
+ sb.AppendLine($"[[{name}.Trackers]]");
+ if (!string.IsNullOrEmpty(tracker.Name))
+ sb.AppendLine($"Name = \"{EscapeTomlString(tracker.Name)}\"");
+ sb.AppendLine($"URI = \"{tracker.Uri}\"");
+ sb.AppendLine($"Priority = {tracker.Priority}");
+ sb.AppendLine($"SortTorrents = {tracker.SortTorrents.ToString().ToLower()}");
+ sb.AppendLine($"MaxETA = {tracker.MaxETA ?? -1}");
+ sb.AppendLine($"DownloadRateLimit = {tracker.DownloadRateLimit ?? -1}");
+ sb.AppendLine($"UploadRateLimit = {tracker.UploadRateLimit ?? -1}");
+ sb.AppendLine($"MaxUploadRatio = {tracker.MaxUploadRatio ?? -1}");
+ sb.AppendLine($"MaxSeedingTime = {tracker.MaxSeedingTime ?? -1}");
+ sb.AppendLine($"HitAndRunMode = \"{tracker.HitAndRunMode ?? "disabled"}\"");
+ sb.AppendLine($"MinSeedRatio = {tracker.MinSeedRatio ?? 1.0}");
+ sb.AppendLine($"MinSeedingTime = {tracker.MinSeedingTimeDays ?? 0}");
+ sb.AppendLine($"HitAndRunPartialSeedRatio = {tracker.HitAndRunPartialSeedRatio ?? 1.0}");
+ sb.AppendLine($"TrackerUpdateBuffer = {tracker.TrackerUpdateBuffer ?? 0}");
+ sb.AppendLine($"HitAndRunMinimumDownloadPercent = {tracker.HitAndRunMinimumDownloadPercent ?? 10}");
+ if (tracker.SuperSeedMode.HasValue)
+ sb.AppendLine($"SuperSeedMode = {tracker.SuperSeedMode.Value.ToString().ToLower()}");
+ sb.AppendLine($"RemoveIfExists = {tracker.RemoveIfExists.ToString().ToLower()}");
+ sb.AppendLine($"AddTrackerIfMissing = {tracker.AddTrackerIfMissing.ToString().ToLower()}");
+ if (tracker.AddTags.Count > 0)
+ sb.AppendLine($"AddTags = [{string.Join(", ", tracker.AddTags.Select(t => $"'{t}'"))}]");
+ sb.AppendLine();
+ }
+ }
sb.AppendLine();
sb.AppendLine($"[{name}.CategorySeeding]");
@@ -1773,6 +1842,7 @@
sb.AppendLine($"Name = \"{EscapeTomlString(tracker.Name)}\"");
sb.AppendLine($"URI = \"{tracker.Uri}\"");
sb.AppendLine($"Priority = {tracker.Priority}");
+ sb.AppendLine($"SortTorrents = {tracker.SortTorrents.ToString().ToLower()}");
sb.AppendLine($"MaximumETA = {tracker.MaxETA ?? -1}");
sb.AppendLine($"DownloadRateLimit = {tracker.DownloadRateLimit ?? -1}");
sb.AppendLine($"UploadRateLimit = {tracker.UploadRateLimit ?? -1}");
diff --git a/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs b/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs
--- a/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs
+++ b/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs
@@ -17,6 +17,22 @@
/// Helper property to get Arr instances as a list
/// </summary>
public List<ArrInstanceConfig> Arrs => ArrInstances.Values.ToList();
+
+ public HashSet<string> BuildManagedCategoriesSet()
+ {
+ var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+ foreach (var arrInstance in ArrInstances.Where(x => !string.IsNullOrEmpty(x.Value.Category)))
+ set.Add(arrInstance.Value.Category!);
+ foreach (var qbit in QBitInstances.Values)
+ {
+ if (qbit.ManagedCategories != null)
+ {
+ foreach (var cat in qbit.ManagedCategories)
+ set.Add(cat);
+ }
+ }
+ return set;
+ }
}
public class SettingsConfig
@@ -107,6 +123,7 @@
public string? Name { get; set; } // Human-readable tracker name
public string Uri { get; set; } = "";
public int Priority { get; set; } = 0;
+ public bool SortTorrents { get; set; } = false;
public double? MaxUploadRatio { get; set; }
public int? MaxSeedingTime { get; set; }
public int? RemoveTorrent { get; set; }
@@ -149,6 +166,10 @@
public bool GroupLidarr { get; set; } = true;
public string Theme { get; set; } = "Dark";
public string ViewDensity { get; set; } = "Comfortable";
+ /// <summary>
+ /// When non-empty, CORS allows only these origins (with credentials). When empty, any origin is allowed (legacy behavior).
+ /// </summary>
+ public List<string> CorsAllowedOrigins { get; set; } = new();
/// <summary>OIDC settings when OIDCEnabled is true. Optional.</summary>
public OIDCConfig? OIDC { get; set; }
}
diff --git a/src/Torrentarr.Core/Services/IFreeSpaceService.cs b/src/Torrentarr.Core/Services/IFreeSpaceService.cs
--- a/src/Torrentarr.Core/Services/IFreeSpaceService.cs
+++ b/src/Torrentarr.Core/Services/IFreeSpaceService.cs
@@ -30,8 +30,18 @@
/// Pauses torrents that would exceed free space threshold and manages tags.
/// </summary>
Task ProcessTorrentsForSpaceAsync(string category, CancellationToken cancellationToken = default);
+
+ /// <summary>
+ /// Host free-space manager pass: all Arr + qBit-managed categories, DriveInfo on resolved folder,
+ /// per-torrent pause/resume matching former Host orchestrator behavior (tagless DB column supported).
+ /// Intended when Settings.AutoPauseResume is true and free-space string is not disabled (-1).
+ /// </summary>
+ Task<GlobalFreeSpacePassResult> ProcessGlobalManagedCategoriesHostPassAsync(CancellationToken cancellationToken = default);
}
+/// <summary>Result of <see cref="IFreeSpaceService.ProcessGlobalManagedCategoriesHostPassAsync"/>.</summary>
+public sealed record GlobalFreeSpacePassResult(int PausedTorrentCount, bool ManagerAlive);
+
public class FreeSpaceStats
{
public long TotalBytes { get; set; }
diff --git a/src/Torrentarr.Core/Services/ITrackerQueueSortService.cs b/src/Torrentarr.Core/Services/ITrackerQueueSortService.cs
new file mode 100644
--- /dev/null
+++ b/src/Torrentarr.Core/Services/ITrackerQueueSortService.cs
@@ -1,0 +1,12 @@
+namespace Torrentarr.Core.Services;
+
+/// <summary>
+/// Reorders qBittorrent queue by tracker priority when SortTorrents is enabled (global per qBit instance).
+/// </summary>
+public interface ITrackerQueueSortService
+{
+ /// <summary>
+ /// Applies tracker-priority ordering for all torrents across all qBit instances.
+ /// </summary>
+ Task SortTorrentQueuesByTrackerPriorityAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Torrentarr.Host/Program.cs b/src/Torrentarr.Host/Program.cs
--- a/src/Torrentarr.Host/Program.cs
+++ b/src/Torrentarr.Host/Program.cs
@@ -14,6 +14,7 @@
using Serilog;
using Serilog.Core;
using Serilog.Events;
+using System.Linq;
using System.Security.Claims;
using System.Security.Cryptography;
@@ -35,7 +36,7 @@
const string REDACTED_PLACEHOLDER = "[redacted]";
const string SensitiveKeyPatternRegex = @"(apikey|api_key|token|password|secret|passkey|credential)";
-// Mutable level switch — lets /web/loglevel and /api/loglevel change the level at runtime
+// Mutable level switch — lets /web/loglevel and /api/loglevel change the level at runtime
var levelSwitch = new LoggingLevelSwitch(LogEventLevel.Information);
// Create custom sink for per-worker log files
@@ -162,17 +163,20 @@
// ArrWorkerManager registered as both singleton and IHostedService so it's injectable in endpoints
builder.Services.AddSingleton<ArrWorkerManager>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ArrWorkerManager>());
- builder.Services.AddHostedService<ProcessOrchestratorService>();
+ builder.Services.AddSingleton<HostWorkerManager>();
+ builder.Services.AddHostedService(sp => sp.GetRequiredService<HostWorkerManager>());
// Scoped services (one per request / scope)
builder.Services.AddScoped<ArrSyncService>();
builder.Services.AddScoped<IArrImportService, ArrImportService>();
builder.Services.AddScoped<ISeedingService, SeedingService>();
+ builder.Services.AddScoped<IFreeSpaceService, FreeSpaceService>();
+ builder.Services.AddScoped<ITrackerQueueSortService, TrackerQueueSortService>();
builder.Services.AddScoped<ITorrentProcessor, TorrentProcessor>();
builder.Services.AddScoped<IArrMediaService, ArrMediaService>();
builder.Services.AddScoped<ISearchExecutor, SearchExecutor>();
builder.Services.AddScoped<QualityProfileSwitcherService>();
builder.Services.AddSingleton<ITorrentCacheService, TorrentCacheService>();
- // §6.10 / §1.8: update check + auto-update
+ // §6.10 / §1.8: update check + auto-update
builder.Services.AddSingleton<UpdateService>();
builder.Services.AddHostedService<AutoUpdateBackgroundService>();
@@ -251,7 +255,19 @@
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
- policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());
+ {
+ if (config.WebUI.CorsAllowedOrigins.Count > 0)
+ {
+ policy.WithOrigins(config.WebUI.CorsAllowedOrigins.ToArray())
+ .AllowAnyMethod()
+ .AllowAnyHeader()
+ .AllowCredentials();
+ }
+ else
+ {
+ policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
+ }
+ });
});
// Database - paths already defined at top of file
@@ -357,7 +373,7 @@
await next(context);
});
- // Static files — add cache-busting headers for the service worker
+ // Static files — add cache-busting headers for the service worker
app.UseDefaultFiles();
app.Use(async (context, next) =>
{
@@ -420,7 +436,7 @@
return;
}
- // 1) Bearer token (constant-time) — always accepted for API when Token is set
+ // 1) Bearer token (constant-time) — always accepted for API when Token is set
var webToken = cfg.WebUI.Token;
if (!string.IsNullOrEmpty(webToken))
{
@@ -465,7 +481,7 @@
app.MapControllers();
- // Home redirect: / → /ui
+ // Home redirect: / → /ui
app.MapGet("/", () => Results.Redirect("/ui"));
// Health check
@@ -478,15 +494,15 @@
// ==================== /web/* endpoints ====================
- // Web Meta — fetches latest release from GitHub and compares with current version
- // §6.10: GET /web/meta — version info + update state + auth flags (MetaResponse-compatible)
+ // Web Meta — fetches latest release from GitHub and compares with current version
+ // §6.10: GET /web/meta — version info + update state + auth flags (MetaResponse-compatible)
app.MapGet("/web/meta", async (UpdateService updater, TorrentarrConfig cfg, int? force) =>
{
await updater.CheckForUpdateAsync(forceRefresh: force.GetValueOrDefault() != 0);
return Results.Ok(updater.BuildMetaResponse(cfg.WebUI));
});
- // Web Status — matches TypeScript StatusResponse (no extra webui field)
+ // Web Status — matches TypeScript StatusResponse (no extra webui field)
app.MapGet("/web/status", async (TorrentarrConfig cfg, QBittorrentConnectionManager qbitManager) =>
{
var primaryQbit = (cfg.QBitInstances.GetValueOrDefault("qBit") ?? new QBitConfig());
@@ -537,17 +553,17 @@
});
});
- // Web Qbit Categories — full QbitCategory shape
+ // Web Qbit Categories — full QbitCategory shape
// Only returns categories that are configured to be monitored:
- // • cfg.QBit.ManagedCategories (qBit-managed)
- // • each Arr instance's Category (Arr-managed)
+ // • cfg.QBit.ManagedCategories (qBit-managed)
+ // • each Arr instance's Category (Arr-managed)
// The "instance" field is always the qBit instance name (never the Arr instance name)
// so that ProcessesView can match categories to the correct qBit process card.
app.MapGet("/web/qbit/categories", async (QBittorrentConnectionManager qbitManager, TorrentarrConfig cfg) =>
{
var categories = new List<object>();
- // Build Arr-managed category lookup: category name → ArrInstanceConfig
+ // Build Arr-managed category lookup: category name → ArrInstanceConfig
var arrCategoryToConfig = cfg.ArrInstances
.Where(kvp => !string.IsNullOrEmpty(kvp.Value.Category))
.ToDictionary(kvp => kvp.Value.Category!, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
@@ -598,7 +614,7 @@
categories.Add(new
{
category = catName,
- // Always the qBit instance name — ProcessesView matches on this field
+ // Always the qBit instance name — ProcessesView matches on this field
instance = "qBit",
managedBy,
torrentCount = torrentsInCat.Count,
@@ -621,7 +637,7 @@
catch { /* qBit not reachable */ }
}
- // Additional qBit instances — only their own ManagedCategories are monitored
+ // Additional qBit instances — only their own ManagedCategories are monitored
foreach (var (instName, instCfg) in cfg.QBitInstances.Where(q => q.Key != "qBit" && q.Value.Host != "CHANGE_ME"))
{
if (instCfg.ManagedCategories.Count == 0) continue;
@@ -665,7 +681,7 @@
return Results.Ok(new { categories, ready = true });
});
- // Web Processes — reads live state from ProcessStateManager + qBit connection status
+ // Web Processes — reads live state from ProcessStateManager + qBit connection status
app.MapGet("/web/processes", async (ProcessStateManager stateMgr, TorrentarrConfig cfg, QBittorrentConnectionManager qbitMgr) =>
{
var processes = stateMgr.GetAll().Select(s => new
@@ -740,13 +756,19 @@
return Results.Ok(new { processes });
});
- // Web Restart Process — stops and restarts the named instance worker (kind is advisory; one loop per Arr)
- app.MapPost("/web/processes/{category}/{kind}/restart", async (string category, string kind, TorrentarrConfig cfg, ArrWorkerManager workerMgr) =>
+ // Web Restart Process — stops and restarts the named instance worker (kind is advisory; one loop per Arr)
+ app.MapPost("/web/processes/{category}/{kind}/restart", async (string category, string kind, TorrentarrConfig cfg, ArrWorkerManager workerMgr, HostWorkerManager hostWorkerMgr) =>
{
var kindNorm = (kind ?? "").Trim().ToLowerInvariant();
if (kindNorm != "search" && kindNorm != "torrent" && kindNorm != "category" && kindNorm != "arr")
return Results.BadRequest(new { error = "kind must be search, torrent, category, or arr" });
+ if (HostWorkerManager.AllHostWorkerNames.Contains(category, StringComparer.OrdinalIgnoreCase))
+ {
+ var ok = await hostWorkerMgr.RestartWorkerAsync(category);
+ return Results.Ok(new { status = ok ? "restarted" : "skipped", restarted = ok ? new[] { category } : Array.Empty<string>() });
+ }
+
var instanceName = cfg.ArrInstances
.FirstOrDefault(kv => kv.Value.Category.Equals(category, StringComparison.OrdinalIgnoreCase)).Key;
if (instanceName != null)
@@ -755,20 +777,22 @@
});
// Web Restart All Processes
- app.MapPost("/web/processes/restart_all", async (TorrentarrConfig cfg, ArrWorkerManager workerMgr) =>
+ app.MapPost("/web/processes/restart_all", async (TorrentarrConfig cfg, ArrWorkerManager workerMgr, HostWorkerManager hostWorkerMgr) =>
{
await workerMgr.RestartAllWorkersAsync();
- return Results.Ok(new { status = "restarted", restarted = cfg.ArrInstances.Keys.ToArray() });
+ await hostWorkerMgr.RestartAllWorkersAsync();
+ return Results.Ok(new { status = "restarted", restarted = cfg.ArrInstances.Keys.Concat(HostWorkerManager.AllHostWorkerNames).ToArray() });
});
- // Web Arr Rebuild — same shape as RestartResponse
- app.MapPost("/web/arr/rebuild", async (TorrentarrConfig cfg, ArrWorkerManager workerMgr) =>
+ // Web Arr Rebuild — same shape as RestartResponse
+ app.MapPost("/web/arr/rebuild", async (TorrentarrConfig cfg, ArrWorkerManager workerMgr, HostWorkerManager hostWorkerMgr) =>
{
await workerMgr.RestartAllWorkersAsync();
- return Results.Ok(new { status = "restarted", restarted = cfg.ArrInstances.Keys.ToArray() });
+ await hostWorkerMgr.RestartAllWorkersAsync();
+ return Results.Ok(new { status = "restarted", restarted = cfg.ArrInstances.Keys.Concat(HostWorkerManager.AllHostWorkerNames).ToArray() });
});
- // Web Log Level — actually changes the Serilog level at runtime
+ // Web Log Level — actually changes the Serilog level at runtime
app.MapPost("/web/loglevel", (LoggerConfigurationRequest req, LoggingLevelSwitch ls) =>
{
ls.MinimumLevel = req.Level?.ToUpperInvariant() switch
@@ -782,7 +806,7 @@
return Results.Ok(new { success = true, level = ls.MinimumLevel.ToString() });
});
- // Web Logs List — returns name, size, and last-modified for each .log file
+ // Web Logs List — returns name, size, and last-modified for each .log file
app.MapGet("/web/logs", () =>
{
var files = new List<object>();
@@ -797,7 +821,7 @@
return Results.Ok(new { files });
});
- // Web Log Tail — last 1000 lines, plain text so frontend res.text() gets unquoted content
+ // Web Log Tail — last 1000 lines, plain text so frontend res.text() gets unquoted content
app.MapGet("/web/logs/{name}", async (string name) =>
{
if (!IsValidLogFileName(name))
@@ -910,7 +934,7 @@
});
});
- // Web Sonarr Series — seasons populated from episodes table
+ // Web Sonarr Series — seasons populated from episodes table
app.MapGet("/web/sonarr/{category}/series", async (string category, TorrentarrDbContext db, int? page, int? page_size, string? q, int? missing) =>
{
var currentPage = page ?? 0;
@@ -1010,7 +1034,7 @@
});
});
- // Web Lidarr Albums — tracks populated from tracks table
+ // Web Lidarr Albums — tracks populated from tracks table
app.MapGet("/web/lidarr/{category}/albums", async (string category, TorrentarrDbContext db, int? page, int? page_size, string? q, bool? monitored, bool? has_file, bool? quality_met, bool? is_request, bool? flat_mode) =>
{
var currentPage = page ?? 0;
@@ -1144,7 +1168,7 @@
});
});
- // Web Lidarr Tracks — paginated flat track list for a Lidarr instance
+ // Web Lidarr Tracks — paginated flat track list for a Lidarr instance
app.MapGet("/web/lidarr/{category}/tracks", async (string category, TorrentarrDbContext db, int? page, int? page_size, string? q) =>
{
var currentPage = page ?? 0;
@@ -1205,7 +1229,7 @@
return Results.Ok(new { success = instanceName != null, message = instanceName != null ? $"Restarted {instanceName}" : $"No worker found for category '{category}'" });
});
- // Web Config Get — return a FLAT structure matching Python qBitrr's config format.
+ // Web Config Get — return a FLAT structure matching Python qBitrr's config format.
// ConfigView.tsx expects all sections at the top level (e.g. "Radarr-1080", "qBit"),
// NOT nested under "ArrInstances" / "QBit". Keys use PascalCase to match field paths.
app.MapGet("/web/config", (TorrentarrConfig cfg) =>
@@ -1235,7 +1259,7 @@
return Results.Content(redacted.ToString(Newtonsoft.Json.Formatting.None), "application/json");
});
- // Web Config Update — frontend sends { changes: { "Section.Key": value, ... } } (dotted keys).
+ // Web Config Update — frontend sends { changes: { "Section.Key": value, ... } } (dotted keys).
// ConfigView.tsx flatten()s the hierarchical config into dotted paths before sending only the
// changed keys. We apply those changes onto the current in-memory config and save.
app.MapPost("/web/config", async (HttpRequest request, TorrentarrConfig cfg, ConfigurationLoader loader) =>
@@ -1256,7 +1280,7 @@
var serializer = Newtonsoft.Json.JsonSerializer.Create(newtonsoftSettings);
// Step 1: Snapshot current config as a flat-section JObject (mirrors GET /web/config).
- // Keys are section names ("Settings", "WebUI", "qBit", "Radarr-1080", …).
+ // Keys are section names ("Settings", "WebUI", "qBit", "Radarr-1080", …).
var currentObj = new Newtonsoft.Json.Linq.JObject();
currentObj["Settings"] = Newtonsoft.Json.Linq.JObject.FromObject(cfg.Settings, serializer);
currentObj["WebUI"] = Newtonsoft.Json.Linq.JObject.FromObject(cfg.WebUI, serializer);
@@ -1266,7 +1290,7 @@
currentObj[key] = Newtonsoft.Json.Linq.JObject.FromObject(arr, serializer);
// Step 2: Apply each dotted-key change onto the snapshot.
- // e.g. "Settings.ConsoleLevel" → sets currentObj["Settings"]["ConsoleLevel"].
+ // e.g. "Settings.ConsoleLevel" → sets currentObj["Settings"]["ConsoleLevel"].
// null value means delete.
var changesObj = Newtonsoft.Json.Linq.JObject.Parse(changesEl.GetRawText());
foreach (var change in changesObj.Properties())
@@ -1283,7 +1307,7 @@
var parts = change.Name.Split('.');
var rawSectionKey = parts[0];
- // Case-insensitive section key: "webui" → "WebUI", "settings" → "Settings"
+ // Case-insensitive section key: "webui" → "WebUI", "settings" → "Settings"
var sectionKey = currentObj.Properties()
.FirstOrDefault(p => p.Name.Equals(rawSectionKey, StringComparison.OrdinalIgnoreCase))?.Name
?? rawSectionKey;
@@ -1311,7 +1335,7 @@
}
// Cleanup: remove sections that had all their keys deleted (became empty {}).
- // This handles renames: the old section has all sub-keys set to null → empty JObject.
+ // This handles renames: the old section has all sub-keys set to null → empty JObject.
foreach (var emptyProp in currentObj.Properties().ToList())
... diff truncated: showing 800 of 2988 linesYou can send follow-ups to this agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Redundant
ParseFreeSpaceStringduplicated across two files- I extracted
ParseFreeSpaceStringinto a sharedFreeSpaceParserutility and updated both services to use it, eliminating the duplicate implementations.
- I extracted
Preview (5830dcaf88)
diff --git a/AGENTS.md b/AGENTS.md
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -174,6 +174,12 @@
GitHub Actions runs a matrix build across Ubuntu, Windows, and macOS with .NET 10 + Node 20. Pipeline: restore → build → test (non-live) → frontend build → Docker build (on `master` push). Artifacts retained 7 days.
+## Documentation and Mermaid
+
+- **Tabular data:** Use **markdown tables** by default. Do not recreate tables as Mermaid **flowcharts** (or subgraph “grids”). If you use Mermaid for a table, use a dedicated **table** diagram type only when your Mermaid version and doc host support it; otherwise stay with markdown.
+- **Mermaid for non-tabular content:** Use `flowchart`, `sequenceDiagram`, architecture diagrams, `xychart-beta`, and similar for processes, relationships, and charts—not for spreadsheet-style comparisons or lookup matrices.
+- **Rendering:** Confirm new diagrams in GitHub’s Markdown preview (or your doc host). Follow existing diagrams in `docs/`: no custom Mermaid colors, use camelCase or underscores for node IDs (no spaces), quote edge labels that contain parentheses.
+
## Git commits
**Do not use `git commit --no-verify` or `git commit -n`.** Pre-commit hooks must run on every commit. When committing or pushing on the user's behalf, use `git commit` without the `--no-verify` flag so that pre-commit runs. If hooks fail, fix the reported issues (e.g. formatting) or inform the user—do not bypass hooks.
diff --git a/CLAUDE.md b/CLAUDE.md
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -179,6 +179,12 @@
GitHub Actions runs a matrix build across Ubuntu, Windows, and macOS with .NET 10 + Node 20. Pipeline: restore → build → test (non-live) → frontend build → Docker build (on `master` push). Artifacts retained 7 days.
+## Documentation and Mermaid
+
+- **Tabular data:** Use **markdown tables** by default. Do not recreate tables as Mermaid **flowcharts** (or subgraph “grids”). If you use Mermaid for a table, use a dedicated **table** diagram type only when your Mermaid version and doc host support it; otherwise stay with markdown.
+- **Mermaid for non-tabular content:** Use `flowchart`, `sequenceDiagram`, architecture diagrams, `xychart-beta`, and similar for processes, relationships, and charts—not for spreadsheet-style comparisons or lookup matrices.
+- **Rendering:** Confirm new diagrams in GitHub’s Markdown preview (or your doc host). Follow existing diagrams in `docs/`: no custom Mermaid colors, use camelCase or underscores for node IDs (no spaces), quote edge labels that contain parentheses.
+
## Git commits
**Do not use `git commit --no-verify` or `git commit -n`.** Pre-commit hooks must run on every commit. When committing or pushing on the user's behalf, use `git commit` without the `--no-verify` flag so that pre-commit runs. If hooks fail, fix the reported issues (e.g. formatting) or inform the user—do not bypass hooks.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -177,6 +177,7 @@
- **Report Bugs:** [GitHub Issues](https://github.com/Feramance/Torrentarr/issues)
- **Discussions:** [GitHub Discussions](https://github.com/Feramance/Torrentarr/discussions)
+- **Security:** [SECURITY.md](SECURITY.md) (supported versions, vulnerability reporting)
## Contributing
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
--- /dev/null
+++ b/SECURITY.md
@@ -1,0 +1,31 @@
+# Security policy
+
+## Supported versions
+
+Security fixes are applied only to the **latest released version** of Torrentarr. Older releases are not maintained with backported security patches. If you report an issue affecting an older version, we may ask you to upgrade to the current release and confirm whether the problem still exists.
+
+## Reporting a vulnerability
+
+Please report security vulnerabilities responsibly so we can address them before public disclosure.
+
+**Preferred:** Use [GitHub Security Advisories](https://github.com/Feramance/Torrentarr/security/advisories) for this repository (Report a vulnerability).
+
+**Alternative:** Open a private discussion with maintainers if GitHub Advisories is not available, or contact the maintainers through the channels listed in the [README](README.md).
+
+Include:
+
+- A description of the issue and its impact
+- Steps to reproduce or proof-of-concept, if safe to share
+- Affected component (WebUI, Host, config, etc.) and version
+
+We aim to acknowledge reports in a timely manner. Please avoid testing against production systems you do not own.
+
+## Scope
+
+This policy applies to the Torrentarr application and its official distribution artifacts (for example, release binaries published on GitHub). Third-party services you configure (qBittorrent, Radarr, Sonarr, Lidarr, reverse proxies, identity providers) follow their own security practices and are outside this project’s control.
+
+## Deployment reminders
+
+- When **`AuthDisabled`** is true, `/web/*` is not behind the login screen—restrict network access (firewall, bind address, or reverse proxy with authentication) if needed. **`/api/*`** still requires `WebUI.Token` (Bearer). Prefer keeping authentication enabled or using network controls when exposing Torrentarr beyond a trusted LAN.
+- Prefer HTTPS in production (e.g. terminate TLS at a reverse proxy).
+- See the project documentation for [WebUI authentication](docs/configuration/webui-authentication.md) and [API usage](docs/webui/api.md).
diff --git a/config.example.toml b/config.example.toml
--- a/config.example.toml
+++ b/config.example.toml
@@ -103,6 +103,9 @@
# View density (Comfortable or Compact)
ViewDensity = "Comfortable"
+# Optional: restrict browser CORS to these origins (omit or set [] to allow any origin). See docs/configuration/webui-authentication.md
+# CorsAllowedOrigins = [ "https://torrentarr.example.com" ]
+
[qBit]
# Disable qBittorrent integration (headless search-only mode)
Disabled = false
@@ -123,6 +126,13 @@
# Global tracker configurations (inherited by all Arr instances)
Trackers = []
+# Example per-tracker override:
+# [[qBit.Trackers]]
+# Name = "Private Tracker"
+# URI = "https://tracker.example.com/announce"
+# Priority = 10
+# SortTorrents = false
+
[qBit.CategorySeeding]
# Download rate limit per torrent in KB/s (-1 = unlimited)
DownloadRateLimitPerTorrent = -1
diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md
--- a/docs/advanced/architecture.md
+++ b/docs/advanced/architecture.md
@@ -346,6 +346,8 @@
└───────────────────┘+Host-only workers: HostWorkerManager (in Infrastructure, hosted by Torrentarr.Host) runs concurrent Task.Run loops for Failed/Recheck category handling, global free-space pause/resume, and tracker-priority queue ordering when SortTorrents is enabled. It monitors worker tasks and restarts them on unexpected completion, similar in spirit to ArrWorkerManager for OS worker processes.
+
Service Registration (Program.cs):
diff --git a/docs/configuration/seeding.md b/docs/configuration/seeding.md
--- a/docs/configuration/seeding.md
+++ b/docs/configuration/seeding.md
@@ -437,6 +437,24 @@
---
+#### SortTorrents
+
+```toml
+SortTorrents = false
+```
+
+**Type:** Boolean
+**Default:** `false`
+
+When enabled for a tracker, torrents matching that tracker are reordered in the qBittorrent queue by tracker `Priority`.
+
+- Ordering runs in the **Host** `TrackerSortManager` subprocess (fire-and-forget loop managed with Failed/Recheck/Free Space), not inside each Arr torrent worker.
+- Higher `Priority` trackers are pushed toward the top of the queue.
+- This setting only affects torrents whose effective tracker configuration has `SortTorrents = true`.
+- qBittorrent **Torrent Queuing** must be enabled for queue ordering effects to be visible.
+
+---
+
#### URI
```toml
diff --git a/docs/configuration/webui-authentication.md b/docs/configuration/webui-authentication.md
--- a/docs/configuration/webui-authentication.md
+++ b/docs/configuration/webui-authentication.md
@@ -209,7 +209,8 @@
## Deployment and security
-- **CORS:** The WebUI server may allow cross-origin requests (e.g. `AllowAnyOrigin` in development). In sensitive deployments, restrict CORS to trusted origins via your reverse proxy or application configuration so that only your intended UI origin can call the API.
+- **CORS:** When **`CorsAllowedOrigins`** in `[WebUI]` is **empty** (default), the server allows any origin (`AllowAnyOrigin`), which is convenient for local development and simple LAN setups. When you set **`CorsAllowedOrigins`** to one or more origins (e.g. `["https://torrentarr.example.com"]`), only those origins may call the API from the browser, with credentials allowed; restart after changing this value. You can also restrict cross-origin access entirely at your reverse proxy.
+- **Auth disabled:** When **`AuthDisabled = true`**, `/web/*` routes do not require a login; **`/api/*`** still requires **`WebUI.Token`**. The WebUI shows a confirmation when you turn off authentication. If the service is exposed to the internet or untrusted networks, use a reverse proxy with authentication, VPN, or firewall rules—or keep authentication enabled.
- **HTTPS:** In production, serve Torrentarr over HTTPS (e.g. behind a reverse proxy with TLS). Session cookies use `SecurePolicy=SameAsRequest`, so they are only sent over HTTPS when the request is HTTPS.
---
diff --git a/docs/configuration/webui.md b/docs/configuration/webui.md
--- a/docs/configuration/webui.md
+++ b/docs/configuration/webui.md
@@ -188,7 +188,7 @@
## Authentication
-When **AuthDisabled** = `true` (default for existing configs), there is no login screen; the WebUI and API are protected only by the Token (or are public if Token was empty and has not yet been auto-generated). When **AuthDisabled** = `false`, browser users must either log in (local username/password and/or OIDC) or present the Bearer token. At least one of **LocalAuthEnabled** or **OIDCEnabled** should be true so the login page can offer a sign-in method.
+When **AuthDisabled** = `true` (default for existing configs), there is no login screen; **`/api/*`** is protected by the **Token** (Bearer), while **`/web/*`** is open to the network path—use firewall, reverse proxy auth, or keep **AuthDisabled** = `false` if exposing to untrusted networks. The WebUI warns you when disabling authentication. When **AuthDisabled** = `false`, browser users must either log in (local username/password and/or OIDC) or present the Bearer token. At least one of **LocalAuthEnabled** or **OIDCEnabled** should be true so the login page can offer a sign-in method.
**New installs:** If Torrentarr creates the config file on first run (it did not exist before), the generated config has **AuthDisabled = false** and **LocalAuthEnabled = true**. Users see a welcome screen to set an admin username and password before accessing the rest of the WebUI. Existing configs are unchanged unless you edit auth settings.
@@ -337,6 +337,19 @@
---
+## CorsAllowedOrigins
+
+```toml
+# CorsAllowedOrigins = [ "https://torrentarr.example.com" ]
+```
+
+**Type:** Array of strings (optional)
+**Default:** omitted or empty — any origin is allowed for cross-origin browser requests (legacy behavior).
+
+When set to one or more origins, the server restricts CORS to those origins and allows credentials. Restart after changing this value. See [WebUI authentication — Deployment and security](webui-authentication.md#deployment-and-security).
+
+---
+
## Complete Configuration Examples
### Example 1: Default (Public Access)
diff --git a/docs/development/index.md b/docs/development/index.md
--- a/docs/development/index.md
+++ b/docs/development/index.md
@@ -294,7 +294,7 @@
Torrentarr's backend is **.NET (C#)** with ASP.NET Core and separate worker processes. Key points:
-- **Torrentarr.Host** — Orchestrator: hosts WebUI (ASP.NET Core minimal API), manages free space, spawns per-Arr **Torrentarr.Workers** processes.
+- **Torrentarr.Host** — Orchestrator: hosts WebUI (ASP.NET Core minimal API), runs **HostWorkerManager** (Failed/Recheck/free space/tracker sort loops with auto-restart), spawns per-Arr **Torrentarr.Workers** processes.
- **Torrentarr.Infrastructure** — EF Core (SQLite), qBittorrent/Arr API clients, services (TorrentProcessor, SeedingService, ArrSyncService, etc.).
- **Torrentarr.Core** — Config models, interfaces.
diff --git a/docs/webui/api.md b/docs/webui/api.md
--- a/docs/webui/api.md
+++ b/docs/webui/api.md
@@ -21,15 +21,15 @@
| Pattern | Purpose | Authentication | Use Case |
|---------|---------|----------------|----------|
| `/api/*` | API-first endpoints | **Required** (Bearer token) | External clients, scripts, automation |
-| `/web/*` | First-party endpoints | **Optional** (no token required) | WebUI, reverse proxies with auth bypass |
+| `/web/*` | First-party endpoints | **Optional** when `AuthDisabled` is true (no login); login/OIDC or Bearer when auth is required | WebUI, reverse proxies with auth bypass |
-Both patterns return identical responses. Choose based on your authentication requirements.
+Both patterns return identical responses. Choose based on your authentication requirements. If you disable authentication in config, confirm the warning in the WebUI: untrusted network access should use a reverse proxy, VPN, or firewall—or keep authentication enabled.
### Interactive API (Swagger)
When Torrentarr is running, interactive API documentation is available at **`/swagger`** (for example, `http://localhost:6969/swagger`). Swagger UI lists all endpoints and lets you try them from the browser.
-When `WebUI.Token` is set, use the **Authorize** button in Swagger UI, enter your Bearer token (or paste the value from `GET /web/token`), then click Authorize. Requests to `/api/*` endpoints will then include the token. `/web/*` endpoints do not require authorization.
+When `WebUI.Token` is set, use the **Authorize** button in Swagger UI, enter your Bearer token (or paste the value from `GET /web/token`), then click Authorize. Requests to `/api/*` endpoints will then include the token. `/web/*` endpoints do not require the Bearer token when `AuthDisabled` is true (see [WebUI authentication](../configuration/webui-authentication.md) for login when auth is enabled).
---
@@ -63,7 +63,7 @@
- `GET /ui` - WebUI entry point
- `GET /sw.js` - Service worker
- `GET /static/*` - Static assets
-- `GET /web/*` - All first-party endpoints
+- `GET /web/*` - All first-party endpoints when `AuthDisabled` is true; when authentication is required, see [WebUI authentication](../configuration/webui-authentication.md)
### Token Authentication
@@ -1292,7 +1292,7 @@
## Best Practices
-1. **Use `/web/*` endpoints** for WebUI to avoid token management
+1. **Use `/web/*` endpoints** for WebUI when `AuthDisabled` is true to avoid token management for browser traffic
2. **Use `/api/*` endpoints** for external clients with Bearer token
3. **Cache `/api/meta` responses** for 1 hour to reduce GitHub API load
4. **Poll `/api/processes`** every 5-10 seconds (not faster to avoid overhead)
diff --git a/docs/webui/config-editor.md b/docs/webui/config-editor.md
--- a/docs/webui/config-editor.md
+++ b/docs/webui/config-editor.md
@@ -445,6 +445,7 @@
- **Name**: Tracker name (for display purposes)
- **URI**: Tracker URL (used for matching)
- **Priority**: Tracker priority (higher = preferred)
+- **Sort Torrents**: Reorder matching torrents in qBittorrent queue by tracker priority
- **Maximum ETA (s)**: Override global ETA limit for this tracker
- **Download Rate Limit**: Override global download limit
- **Upload Rate Limit**: Override global upload limit
@@ -461,6 +462,7 @@
Name = "Premium Tracker"
URI = "https://premium.tracker.com/announce"
Priority = 10
+SortTorrents = false
MaximumETA = 86400
DownloadRateLimit = -1
UploadRateLimit = -1
diff --git a/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs b/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs
--- a/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs
+++ b/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs
@@ -789,6 +789,10 @@
}
// --- HnR defaults on CategorySeeding and Tracker sections ---
+ var trackerDefaults = new Dictionary<string, object>
+ {
+ ["SortTorrents"] = false
+ };
var hnrDefaults = new Dictionary<string, object>
{
["HitAndRunMode"] = "disabled",
@@ -823,6 +827,14 @@
{
foreach (var trackerTable in GetTrackerTables(trObj))
{
+ foreach (var (field, defaultVal) in trackerDefaults)
+ {
+ if (!trackerTable.ContainsKey(field))
+ {
+ trackerTable[field] = defaultVal;
+ changed = true;
+ }
+ }
foreach (var (field, defaultVal) in hnrDefaults)
{
if (!trackerTable.ContainsKey(field))
@@ -840,6 +852,14 @@
{
foreach (var trackerTable in GetTrackerTables(atrObj))
{
+ foreach (var (field, defaultVal) in trackerDefaults)
+ {
+ if (!trackerTable.ContainsKey(field))
+ {
+ trackerTable[field] = defaultVal;
+ changed = true;
+ }
+ }
foreach (var (field, defaultVal) in hnrDefaults)
{
if (!trackerTable.ContainsKey(field))
@@ -977,6 +997,9 @@
if (table.TryGetValue("Priority", out var priority))
tracker.Priority = Convert.ToInt32(priority);
+ if (table.TryGetValue("SortTorrents", out var sortTorrents))
+ tracker.SortTorrents = Convert.ToBoolean(sortTorrents);
+
if (table.TryGetValue("MaxUploadRatio", out var maxRatio))
tracker.MaxUploadRatio = Convert.ToDouble(maxRatio);
@@ -1166,6 +1189,16 @@
if (table.TryGetValue("ViewDensity", out var viewDensity))
webui.ViewDensity = viewDensity?.ToString() ?? "Comfortable";
+ if (table.TryGetValue("CorsAllowedOrigins", out var corsVal) && corsVal is TomlArray corsArr)
+ {
+ foreach (var item in corsArr)
+ {
+ var s = item?.ToString()?.Trim();
+ if (!string.IsNullOrEmpty(s))
+ webui.CorsAllowedOrigins.Add(s);
+ }
+ }
+
if (table.TryGetValue("OIDC", out var oidcObj) && oidcObj is TomlTable oidcTable)
webui.OIDC = ParseOIDC(oidcTable);
@@ -1661,6 +1694,9 @@
sb.AppendLine($"GroupLidarr = {config.WebUI.GroupLidarr.ToString().ToLower()}");
sb.AppendLine($"Theme = \"{config.WebUI.Theme}\"");
sb.AppendLine($"ViewDensity = \"{config.WebUI.ViewDensity}\"");
+ if (config.WebUI.CorsAllowedOrigins.Count > 0)
+ sb.AppendLine(
+ $"CorsAllowedOrigins = [{string.Join(", ", config.WebUI.CorsAllowedOrigins.Select(o => $"\"{EscapeTomlString(o)}\""))}]");
if (config.WebUI.OIDC != null)
{
var o = config.WebUI.OIDC;
@@ -1699,7 +1735,40 @@
sb.AppendLine("# URI = \"tracker.example.com\"");
sb.AppendLine("# Priority = 1");
}
- sb.AppendLine("Trackers = []");
+ if (qbit.Trackers.Count == 0)
+ {
+ sb.AppendLine("Trackers = []");
+ }
+ else
+ {
+ foreach (var tracker in qbit.Trackers)
+ {
+ sb.AppendLine($"[[{name}.Trackers]]");
+ if (!string.IsNullOrEmpty(tracker.Name))
+ sb.AppendLine($"Name = \"{EscapeTomlString(tracker.Name)}\"");
+ sb.AppendLine($"URI = \"{tracker.Uri}\"");
+ sb.AppendLine($"Priority = {tracker.Priority}");
+ sb.AppendLine($"SortTorrents = {tracker.SortTorrents.ToString().ToLower()}");
+ sb.AppendLine($"MaxETA = {tracker.MaxETA ?? -1}");
+ sb.AppendLine($"DownloadRateLimit = {tracker.DownloadRateLimit ?? -1}");
+ sb.AppendLine($"UploadRateLimit = {tracker.UploadRateLimit ?? -1}");
+ sb.AppendLine($"MaxUploadRatio = {tracker.MaxUploadRatio ?? -1}");
+ sb.AppendLine($"MaxSeedingTime = {tracker.MaxSeedingTime ?? -1}");
+ sb.AppendLine($"HitAndRunMode = \"{tracker.HitAndRunMode ?? "disabled"}\"");
+ sb.AppendLine($"MinSeedRatio = {tracker.MinSeedRatio ?? 1.0}");
+ sb.AppendLine($"MinSeedingTime = {tracker.MinSeedingTimeDays ?? 0}");
+ sb.AppendLine($"HitAndRunPartialSeedRatio = {tracker.HitAndRunPartialSeedRatio ?? 1.0}");
+ sb.AppendLine($"TrackerUpdateBuffer = {tracker.TrackerUpdateBuffer ?? 0}");
+ sb.AppendLine($"HitAndRunMinimumDownloadPercent = {tracker.HitAndRunMinimumDownloadPercent ?? 10}");
+ if (tracker.SuperSeedMode.HasValue)
+ sb.AppendLine($"SuperSeedMode = {tracker.SuperSeedMode.Value.ToString().ToLower()}");
+ sb.AppendLine($"RemoveIfExists = {tracker.RemoveIfExists.ToString().ToLower()}");
+ sb.AppendLine($"AddTrackerIfMissing = {tracker.AddTrackerIfMissing.ToString().ToLower()}");
+ if (tracker.AddTags.Count > 0)
+ sb.AppendLine($"AddTags = [{string.Join(", ", tracker.AddTags.Select(t => $"'{t}'"))}]");
+ sb.AppendLine();
+ }
+ }
sb.AppendLine();
sb.AppendLine($"[{name}.CategorySeeding]");
@@ -1773,6 +1842,7 @@
sb.AppendLine($"Name = \"{EscapeTomlString(tracker.Name)}\"");
sb.AppendLine($"URI = \"{tracker.Uri}\"");
sb.AppendLine($"Priority = {tracker.Priority}");
+ sb.AppendLine($"SortTorrents = {tracker.SortTorrents.ToString().ToLower()}");
sb.AppendLine($"MaximumETA = {tracker.MaxETA ?? -1}");
sb.AppendLine($"DownloadRateLimit = {tracker.DownloadRateLimit ?? -1}");
sb.AppendLine($"UploadRateLimit = {tracker.UploadRateLimit ?? -1}");
diff --git a/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs b/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs
--- a/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs
+++ b/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs
@@ -17,6 +17,22 @@
/// Helper property to get Arr instances as a list
/// </summary>
public List<ArrInstanceConfig> Arrs => ArrInstances.Values.ToList();
+
+ public HashSet<string> BuildManagedCategoriesSet()
+ {
+ var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+ foreach (var arrInstance in ArrInstances.Where(x => !string.IsNullOrEmpty(x.Value.Category)))
+ set.Add(arrInstance.Value.Category!);
+ foreach (var qbit in QBitInstances.Values)
+ {
+ if (qbit.ManagedCategories != null)
+ {
+ foreach (var cat in qbit.ManagedCategories)
+ set.Add(cat);
+ }
+ }
+ return set;
+ }
}
public class SettingsConfig
@@ -107,6 +123,7 @@
public string? Name { get; set; } // Human-readable tracker name
public string Uri { get; set; } = "";
public int Priority { get; set; } = 0;
+ public bool SortTorrents { get; set; } = false;
public double? MaxUploadRatio { get; set; }
public int? MaxSeedingTime { get; set; }
public int? RemoveTorrent { get; set; }
@@ -149,6 +166,10 @@
public bool GroupLidarr { get; set; } = true;
public string Theme { get; set; } = "Dark";
public string ViewDensity { get; set; } = "Comfortable";
+ /// <summary>
+ /// When non-empty, CORS allows only these origins (with credentials). When empty, any origin is allowed (legacy behavior).
+ /// </summary>
+ public List<string> CorsAllowedOrigins { get; set; } = new();
/// <summary>OIDC settings when OIDCEnabled is true. Optional.</summary>
public OIDCConfig? OIDC { get; set; }
}
diff --git a/src/Torrentarr.Core/Services/IFreeSpaceService.cs b/src/Torrentarr.Core/Services/IFreeSpaceService.cs
--- a/src/Torrentarr.Core/Services/IFreeSpaceService.cs
+++ b/src/Torrentarr.Core/Services/IFreeSpaceService.cs
@@ -30,8 +30,18 @@
/// Pauses torrents that would exceed free space threshold and manages tags.
/// </summary>
Task ProcessTorrentsForSpaceAsync(string category, CancellationToken cancellationToken = default);
+
+ /// <summary>
+ /// Host free-space manager pass: all Arr + qBit-managed categories, DriveInfo on resolved folder,
+ /// per-torrent pause/resume matching former Host orchestrator behavior (tagless DB column supported).
+ /// Intended when Settings.AutoPauseResume is true and free-space string is not disabled (-1).
+ /// </summary>
+ Task<GlobalFreeSpacePassResult> ProcessGlobalManagedCategoriesHostPassAsync(CancellationToken cancellationToken = default);
}
+/// <summary>Result of <see cref="IFreeSpaceService.ProcessGlobalManagedCategoriesHostPassAsync"/>.</summary>
+public sealed record GlobalFreeSpacePassResult(int PausedTorrentCount, bool ManagerAlive);
+
public class FreeSpaceStats
{
public long TotalBytes { get; set; }
diff --git a/src/Torrentarr.Core/Services/ITrackerQueueSortService.cs b/src/Torrentarr.Core/Services/ITrackerQueueSortService.cs
new file mode 100644
--- /dev/null
+++ b/src/Torrentarr.Core/Services/ITrackerQueueSortService.cs
@@ -1,0 +1,12 @@
+namespace Torrentarr.Core.Services;
+
+/// <summary>
+/// Reorders qBittorrent queue by tracker priority when SortTorrents is enabled (global per qBit instance).
+/// </summary>
+public interface ITrackerQueueSortService
+{
+ /// <summary>
+ /// Applies tracker-priority ordering for all torrents across all qBit instances.
+ /// </summary>
+ Task SortTorrentQueuesByTrackerPriorityAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Torrentarr.Host/Program.cs b/src/Torrentarr.Host/Program.cs
--- a/src/Torrentarr.Host/Program.cs
+++ b/src/Torrentarr.Host/Program.cs
@@ -14,6 +14,7 @@
using Serilog;
using Serilog.Core;
using Serilog.Events;
+using System.Linq;
using System.Security.Claims;
using System.Security.Cryptography;
@@ -35,7 +36,7 @@
const string REDACTED_PLACEHOLDER = "[redacted]";
const string SensitiveKeyPatternRegex = @"(apikey|api_key|token|password|secret|passkey|credential)";
-// Mutable level switch — lets /web/loglevel and /api/loglevel change the level at runtime
+// Mutable level switch — lets /web/loglevel and /api/loglevel change the level at runtime
var levelSwitch = new LoggingLevelSwitch(LogEventLevel.Information);
// Create custom sink for per-worker log files
@@ -162,17 +163,20 @@
// ArrWorkerManager registered as both singleton and IHostedService so it's injectable in endpoints
builder.Services.AddSingleton<ArrWorkerManager>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ArrWorkerManager>());
- builder.Services.AddHostedService<ProcessOrchestratorService>();
+ builder.Services.AddSingleton<HostWorkerManager>();
+ builder.Services.AddHostedService(sp => sp.GetRequiredService<HostWorkerManager>());
// Scoped services (one per request / scope)
builder.Services.AddScoped<ArrSyncService>();
builder.Services.AddScoped<IArrImportService, ArrImportService>();
builder.Services.AddScoped<ISeedingService, SeedingService>();
+ builder.Services.AddScoped<IFreeSpaceService, FreeSpaceService>();
+ builder.Services.AddScoped<ITrackerQueueSortService, TrackerQueueSortService>();
builder.Services.AddScoped<ITorrentProcessor, TorrentProcessor>();
builder.Services.AddScoped<IArrMediaService, ArrMediaService>();
builder.Services.AddScoped<ISearchExecutor, SearchExecutor>();
builder.Services.AddScoped<QualityProfileSwitcherService>();
builder.Services.AddSingleton<ITorrentCacheService, TorrentCacheService>();
- // §6.10 / §1.8: update check + auto-update
+ // §6.10 / §1.8: update check + auto-update
builder.Services.AddSingleton<UpdateService>();
builder.Services.AddHostedService<AutoUpdateBackgroundService>();
@@ -251,7 +255,19 @@
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
- policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());
+ {
+ if (config.WebUI.CorsAllowedOrigins.Count > 0)
+ {
+ policy.WithOrigins(config.WebUI.CorsAllowedOrigins.ToArray())
+ .AllowAnyMethod()
+ .AllowAnyHeader()
+ .AllowCredentials();
+ }
+ else
+ {
+ policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
+ }
+ });
});
// Database - paths already defined at top of file
@@ -357,7 +373,7 @@
await next(context);
});
- // Static files — add cache-busting headers for the service worker
+ // Static files — add cache-busting headers for the service worker
app.UseDefaultFiles();
app.Use(async (context, next) =>
{
@@ -420,7 +436,7 @@
return;
}
- // 1) Bearer token (constant-time) — always accepted for API when Token is set
+ // 1) Bearer token (constant-time) — always accepted for API when Token is set
var webToken = cfg.WebUI.Token;
if (!string.IsNullOrEmpty(webToken))
{
@@ -465,7 +481,7 @@
app.MapControllers();
- // Home redirect: / → /ui
+ // Home redirect: / → /ui
app.MapGet("/", () => Results.Redirect("/ui"));
// Health check
@@ -478,15 +494,15 @@
// ==================== /web/* endpoints ====================
- // Web Meta — fetches latest release from GitHub and compares with current version
- // §6.10: GET /web/meta — version info + update state + auth flags (MetaResponse-compatible)
+ // Web Meta — fetches latest release from GitHub and compares with current version
+ // §6.10: GET /web/meta — version info + update state + auth flags (MetaResponse-compatible)
app.MapGet("/web/meta", async (UpdateService updater, TorrentarrConfig cfg, int? force) =>
{
await updater.CheckForUpdateAsync(forceRefresh: force.GetValueOrDefault() != 0);
return Results.Ok(updater.BuildMetaResponse(cfg.WebUI));
});
- // Web Status — matches TypeScript StatusResponse (no extra webui field)
+ // Web Status — matches TypeScript StatusResponse (no extra webui field)
app.MapGet("/web/status", async (TorrentarrConfig cfg, QBittorrentConnectionManager qbitManager) =>
{
var primaryQbit = (cfg.QBitInstances.GetValueOrDefault("qBit") ?? new QBitConfig());
@@ -537,17 +553,17 @@
});
});
- // Web Qbit Categories — full QbitCategory shape
+ // Web Qbit Categories — full QbitCategory shape
// Only returns categories that are configured to be monitored:
- // • cfg.QBit.ManagedCategories (qBit-managed)
- // • each Arr instance's Category (Arr-managed)
+ // • cfg.QBit.ManagedCategories (qBit-managed)
+ // • each Arr instance's Category (Arr-managed)
// The "instance" field is always the qBit instance name (never the Arr instance name)
// so that ProcessesView can match categories to the correct qBit process card.
app.MapGet("/web/qbit/categories", async (QBittorrentConnectionManager qbitManager, TorrentarrConfig cfg) =>
{
var categories = new List<object>();
- // Build Arr-managed category lookup: category name → ArrInstanceConfig
+ // Build Arr-managed category lookup: category name → ArrInstanceConfig
var arrCategoryToConfig = cfg.ArrInstances
.Where(kvp => !string.IsNullOrEmpty(kvp.Value.Category))
.ToDictionary(kvp => kvp.Value.Category!, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
@@ -598,7 +614,7 @@
categories.Add(new
{
category = catName,
- // Always the qBit instance name — ProcessesView matches on this field
+ // Always the qBit instance name — ProcessesView matches on this field
instance = "qBit",
managedBy,
torrentCount = torrentsInCat.Count,
@@ -621,7 +637,7 @@
catch { /* qBit not reachable */ }
}
- // Additional qBit instances — only their own ManagedCategories are monitored
+ // Additional qBit instances — only their own ManagedCategories are monitored
foreach (var (instName, instCfg) in cfg.QBitInstances.Where(q => q.Key != "qBit" && q.Value.Host != "CHANGE_ME"))
{
if (instCfg.ManagedCategories.Count == 0) continue;
@@ -665,7 +681,7 @@
return Results.Ok(new { categories, ready = true });
});
- // Web Processes — reads live state from ProcessStateManager + qBit connection status
+ // Web Processes — reads live state from ProcessStateManager + qBit connection status
app.MapGet("/web/processes", async (ProcessStateManager stateMgr, TorrentarrConfig cfg, QBittorrentConnectionManager qbitMgr) =>
{
var processes = stateMgr.GetAll().Select(s => new
@@ -740,13 +756,19 @@
return Results.Ok(new { processes });
});
- // Web Restart Process — stops and restarts the named instance worker (kind is advisory; one loop per Arr)
- app.MapPost("/web/processes/{category}/{kind}/restart", async (string category, string kind, TorrentarrConfig cfg, ArrWorkerManager workerMgr) =>
+ // Web Restart Process — stops and restarts the named instance worker (kind is advisory; one loop per Arr)
+ app.MapPost("/web/processes/{category}/{kind}/restart", async (string category, string kind, TorrentarrConfig cfg, ArrWorkerManager workerMgr, HostWorkerManager hostWorkerMgr) =>
{
var kindNorm = (kind ?? "").Trim().ToLowerInvariant();
if (kindNorm != "search" && kindNorm != "torrent" && kindNorm != "category" && kindNorm != "arr")
return Results.BadRequest(new { error = "kind must be search, torrent, category, or arr" });
+ if (HostWorkerManager.AllHostWorkerNames.Contains(category, StringComparer.OrdinalIgnoreCase))
+ {
+ var ok = await hostWorkerMgr.RestartWorkerAsync(category);
+ return Results.Ok(new { status = ok ? "restarted" : "skipped", restarted = ok ? new[] { category } : Array.Empty<string>() });
+ }
+
var instanceName = cfg.ArrInstances
.FirstOrDefault(kv => kv.Value.Category.Equals(category, StringComparison.OrdinalIgnoreCase)).Key;
if (instanceName != null)
@@ -755,20 +777,22 @@
});
// Web Restart All Processes
- app.MapPost("/web/processes/restart_all", async (TorrentarrConfig cfg, ArrWorkerManager workerMgr) =>
+ app.MapPost("/web/processes/restart_all", async (TorrentarrConfig cfg, ArrWorkerManager workerMgr, HostWorkerManager hostWorkerMgr) =>
{
await workerMgr.RestartAllWorkersAsync();
- return Results.Ok(new { status = "restarted", restarted = cfg.ArrInstances.Keys.ToArray() });
+ var restartedHostWorkers = await hostWorkerMgr.RestartAllWorkersAsync();
+ return Results.Ok(new { status = "restarted", restarted = cfg.ArrInstances.Keys.Concat(restartedHostWorkers).ToArray() });
});
- // Web Arr Rebuild — same shape as RestartResponse
- app.MapPost("/web/arr/rebuild", async (TorrentarrConfig cfg, ArrWorkerManager workerMgr) =>
+ // Web Arr Rebuild — same shape as RestartResponse
+ app.MapPost("/web/arr/rebuild", async (TorrentarrConfig cfg, ArrWorkerManager workerMgr, HostWorkerManager hostWorkerMgr) =>
{
await workerMgr.RestartAllWorkersAsync();
- return Results.Ok(new { status = "restarted", restarted = cfg.ArrInstances.Keys.ToArray() });
+ var restartedHostWorkers = await hostWorkerMgr.RestartAllWorkersAsync();
+ return Results.Ok(new { status = "restarted", restarted = cfg.ArrInstances.Keys.Concat(restartedHostWorkers).ToArray() });
});
- // Web Log Level — actually changes the Serilog level at runtime
+ // Web Log Level — actually changes the Serilog level at runtime
app.MapPost("/web/loglevel", (LoggerConfigurationRequest req, LoggingLevelSwitch ls) =>
{
ls.MinimumLevel = req.Level?.ToUpperInvariant() switch
@@ -782,7 +806,7 @@
return Results.Ok(new { success = true, level = ls.MinimumLevel.ToString() });
});
- // Web Logs List — returns name, size, and last-modified for each .log file
+ // Web Logs List — returns name, size, and last-modified for each .log file
app.MapGet("/web/logs", () =>
{
var files = new List<object>();
@@ -797,7 +821,7 @@
return Results.Ok(new { files });
});
- // Web Log Tail — last 1000 lines, plain text so frontend res.text() gets unquoted content
+ // Web Log Tail — last 1000 lines, plain text so frontend res.text() gets unquoted content
app.MapGet("/web/logs/{name}", async (string name) =>
{
if (!IsValidLogFileName(name))
@@ -910,7 +934,7 @@
});
});
- // Web Sonarr Series — seasons populated from episodes table
+ // Web Sonarr Series — seasons populated from episodes table
app.MapGet("/web/sonarr/{category}/series", async (string category, TorrentarrDbContext db, int? page, int? page_size, string? q, int? missing) =>
{
var currentPage = page ?? 0;
@@ -1010,7 +1034,7 @@
});
});
- // Web Lidarr Albums — tracks populated from tracks table
+ // Web Lidarr Albums — tracks populated from tracks table
app.MapGet("/web/lidarr/{category}/albums", async (string category, TorrentarrDbContext db, int? page, int? page_size, string? q, bool? monitored, bool? has_file, bool? quality_met, bool? is_request, bool? flat_mode) =>
{
var currentPage = page ?? 0;
@@ -1144,7 +1168,7 @@
});
});
- // Web Lidarr Tracks — paginated flat track list for a Lidarr instance
+ // Web Lidarr Tracks — paginated flat track list for a Lidarr instance
app.MapGet("/web/lidarr/{category}/tracks", async (string category, TorrentarrDbContext db, int? page, int? page_size, string? q) =>
{
var currentPage = page ?? 0;
@@ -1205,7 +1229,7 @@
return Results.Ok(new { success = instanceName != null, message = instanceName != null ? $"Restarted {instanceName}" : $"No worker found for category '{category}'" });
});
- // Web Config Get — return a FLAT structure matching Python qBitrr's config format.
+ // Web Config Get — return a FLAT structure matching Python qBitrr's config format.
// ConfigView.tsx expects all sections at the top level (e.g. "Radarr-1080", "qBit"),
// NOT nested under "ArrInstances" / "QBit". Keys use PascalCase to match field paths.
app.MapGet("/web/config", (TorrentarrConfig cfg) =>
@@ -1235,7 +1259,7 @@
return Results.Content(redacted.ToString(Newtonsoft.Json.Formatting.None), "application/json");
});
- // Web Config Update — frontend sends { changes: { "Section.Key": value, ... } } (dotted keys).
+ // Web Config Update — frontend sends { changes: { "Section.Key": value, ... } } (dotted keys).
// ConfigView.tsx flatten()s the hierarchical config into dotted paths before sending only the
// changed keys. We apply those changes onto the current in-memory config and save.
app.MapPost("/web/config", async (HttpRequest request, TorrentarrConfig cfg, ConfigurationLoader loader) =>
@@ -1256,7 +1280,7 @@
var serializer = Newtonsoft.Json.JsonSerializer.Create(newtonsoftSettings);
// Step 1: Snapshot current config as a flat-section JObject (mirrors GET /web/config).
- // Keys are section names ("Settings", "WebUI", "qBit", "Radarr-1080", …).
+ // Keys are section names ("Settings", "WebUI", "qBit", "Radarr-1080", …).
var currentObj = new Newtonsoft.Json.Linq.JObject();
currentObj["Settings"] = Newtonsoft.Json.Linq.JObject.FromObject(cfg.Settings, serializer);
currentObj["WebUI"] = Newtonsoft.Json.Linq.JObject.FromObject(cfg.WebUI, serializer);
@@ -1266,7 +1290,7 @@
currentObj[key] = Newtonsoft.Json.Linq.JObject.FromObject(arr, serializer);
// Step 2: Apply each dotted-key change onto the snapshot.
- // e.g. "Settings.ConsoleLevel" → sets currentObj["Settings"]["ConsoleLevel"].
+ // e.g. "Settings.ConsoleLevel" → sets currentObj["Settings"]["ConsoleLevel"].
// null value means delete.
var changesObj = Newtonsoft.Json.Linq.JObject.Parse(changesEl.GetRawText());
foreach (var change in changesObj.Properties())
@@ -1283,7 +1307,7 @@
var parts = change.Name.Split('.');
var rawSectionKey = parts[0];
- // Case-insensitive section key: "webui" → "WebUI", "settings" → "Settings"
+ // Case-insensitive section key: "webui" → "WebUI", "settings" → "Settings"
var sectionKey = currentObj.Properties()
.FirstOrDefault(p => p.Name.Equals(rawSectionKey, StringComparison.OrdinalIgnoreCase))?.Name
?? rawSectionKey;
@@ -1311,7 +1335,7 @@
}
// Cleanup: remove sections that had all their keys deleted (became empty {}).
- // This handles renames: the old section has all sub-keys set to null → empty JObject.
+ // This handles renames: the old section has all sub-keys set to null → empty JObject.
foreach (var emptyProp in currentObj.Properties().ToList())
... diff truncated: showing 800 of 3186 linesYou can send follow-ups to this agent here.

Add tracker-level SortTorrents config across backend and WebUI, and reorder qBittorrent queues by effective tracker priority during processing when enabled. This preserves existing behavior by default and documents/tests the new setting end-to-end.
Summary
Provide a short overview of the change and why it is needed.
Testing
Describe how you validated this change.
Manual test notes:
Checklist
dotnet formatandnpm run lint(if touching frontend)Additional Notes
Optional context for reviewers (follow-up work, screenshots, rollout plan, etc.).
Note
Medium Risk
Adds new host-managed background loops that can reorder qBittorrent queues and manage global free-space/category tasks, plus introduces configurable CORS; mistakes here could impact torrent processing behavior and browser/API accessibility.
Overview
Adds per-tracker
SortTorrentssupport end-to-end: config parsing/defaulting/serialization now includesSortTorrents, and a new host-managedTrackerSortManagerloop reorders qBittorrent’s queue by effective trackerPriority(via newQBittorrentClient.TopPriorityAsync).Refactors Host “special category” and free-space orchestration by replacing
ProcessOrchestratorServicewith a resilientHostWorkerManagerthat runs and restarts Host-only loops (Failed/Recheck/FreeSpace/TrackerSort) and wires restart endpoints/UI to these host workers.Introduces optional
WebUI.CorsAllowedOriginsto restrict CORS (otherwise legacy allow-any-origin) in bothTorrentarr.HostandTorrentarr.WebUI, adds a confirmation warning when disabling auth in the config editor, and updates docs/tests (including newSECURITY.md, seeding docs, and Processes UI) accordingly.Written by Cursor Bugbot for commit 9cfb811. This will update automatically on new commits. Configure here.