Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,13 @@ ManagedCategories = []
# 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
Expand Down
19 changes: 18 additions & 1 deletion docs/configuration/seeding.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Seeding Configuration
# Seeding Configuration

Configure intelligent seeding management with per-torrent and per-tracker rules for ratio limits, seeding time, and automatic cleanup.

Expand Down Expand Up @@ -437,6 +437,23 @@ MaxUploadRatio = 2.0

---

#### 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
Expand Down
4 changes: 3 additions & 1 deletion docs/webui/config-editor.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Configuration Editor
# Configuration Editor

The Configuration Editor provides a user-friendly interface for managing Torrentarr's configuration through the WebUI. All changes are saved to the `config.toml` file and can trigger live reloads of affected components without requiring a full application restart.

Expand Down Expand Up @@ -445,6 +445,7 @@ Define custom per-tracker seeding policies and tagging rules.
- **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
Expand All @@ -461,6 +462,7 @@ Define custom per-tracker seeding policies and tagging rules.
Name = "Premium Tracker"
URI = "https://premium.tracker.com/announce"
Priority = 10
SortTorrents = false
MaximumETA = 86400
DownloadRateLimit = -1
UploadRateLimit = -1
Expand Down
24 changes: 24 additions & 0 deletions src/Torrentarr.Core/Configuration/ConfigurationLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,10 @@ private static bool ValidateAndFillConfig(TomlTable root)
}

// --- HnR defaults on CategorySeeding and Tracker sections ---
var trackerDefaults = new Dictionary<string, object>
{
["SortTorrents"] = false
};
var hnrDefaults = new Dictionary<string, object>
{
["HitAndRunMode"] = "disabled",
Expand Down Expand Up @@ -823,6 +827,14 @@ private static bool ValidateAndFillConfig(TomlTable root)
{
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))
Expand All @@ -840,6 +852,14 @@ private static bool ValidateAndFillConfig(TomlTable root)
{
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))
Expand Down Expand Up @@ -977,6 +997,9 @@ private QBitConfig ParseQBit(TomlTable table)
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);

Expand Down Expand Up @@ -1773,6 +1796,7 @@ private string GenerateTomlContent(TorrentarrConfig config)
sb.AppendLine($"Name = \"{EscapeTomlString(tracker.Name)}\"");
sb.AppendLine($"URI = \"{tracker.Uri}\"");
sb.AppendLine($"Priority = {tracker.Priority}");
sb.AppendLine($"SortTorrents = {tracker.SortTorrents.ToString().ToLower()}");
Comment thread
cursor[bot] marked this conversation as resolved.
sb.AppendLine($"MaximumETA = {tracker.MaxETA ?? -1}");
sb.AppendLine($"DownloadRateLimit = {tracker.DownloadRateLimit ?? -1}");
sb.AppendLine($"UploadRateLimit = {tracker.UploadRateLimit ?? -1}");
Expand Down
1 change: 1 addition & 0 deletions src/Torrentarr.Core/Configuration/TorrentarrConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ public class TrackerConfig
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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,23 @@ public async Task<bool> SetFilePriorityAsync(string hash, int[] fileIds, int pri
return response.IsSuccessful;
}

/// <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>
Expand Down
67 changes: 67 additions & 0 deletions src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ public async Task ProcessTorrentsAsync(string category, CancellationToken cancel
}
_logger.LogDebug("Found {Count} torrents in category {Category}", torrents.Count, category);

await SortTorrentsByTrackerPriorityAsync(torrents, cancellationToken);

var stats = new TorrentProcessingStats
{
TotalTorrents = torrents.Count
Expand Down Expand Up @@ -587,6 +589,71 @@ await ignClient.RemoveTagsAsync(new List<string> { torrent.Hash },
}
}

/// <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;

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 is move-to-top, so apply reverse order of descending priority
// to preserve highest-priority torrents at the very top.
var ordered = sortable
.OrderByDescending(t => t.Priority)
.ThenBy(t => t.Torrent.AddedOn)
.Select(t => t.Torrent.Hash)
.Reverse()
.ToList();

if (ordered.Count > 0)
await client.TopPriorityAsync(ordered, ct);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to sort torrent queue for qBit instance {Instance}", instanceName);
}
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

// ========================================================================================
// PRE-STEP: leave_alone resolution (qBitrr: _should_leave_alone — arss.py:5804-5892)
// ========================================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -593,4 +593,78 @@ public void GenerateDefaultConfig_ReturnsAuthEnabledForNewInstalls()
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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ public void TrackerConfig_HasHnRFields()
{
var tracker = new TrackerConfig();

tracker.SortTorrents.Should().BeFalse();
tracker.HitAndRunMode.Should().BeNull(); // string? defaults to null
tracker.MinSeedRatio.Should().BeNull();
tracker.MinSeedingTimeDays.Should().BeNull();
Expand Down
36 changes: 36 additions & 0 deletions webui/src/__tests__/config/torrentHandlingSummary.test.ts
Original file line number Diff line number Diff line change
@@ -0,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.");
});
});
2 changes: 2 additions & 0 deletions webui/src/config/tooltips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ export const FIELD_TOOLTIPS: Record<string, string> = {
"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:
Expand Down
Loading
Loading