diff --git a/AGENTS.md b/AGENTS.md index f9f5f9f..f2346f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,6 +174,12 @@ Three test projects under `tests/`, plus frontend tests in `webui/src/__tests__/ 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 index 9f34afa..02429fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -179,6 +179,12 @@ Three test projects under `tests/`, plus frontend tests in `webui/src/__tests__/ 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 index f544f59..08e2a7e 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,7 @@ npm run build # Production bundle - **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 index 0000000..7fdcfc8 --- /dev/null +++ b/SECURITY.md @@ -0,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 index b77c38b..a0f4587 100644 --- a/config.example.toml +++ b/config.example.toml @@ -103,6 +103,9 @@ Theme = "Dark" # 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 @@ 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 diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index a8c0bb4..8f9dce3 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -346,6 +346,8 @@ Torrentarr uses .NET's `IHostedService` / `BackgroundService` pattern: └───────────────────┘ ``` +**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`): ```csharp diff --git a/docs/configuration/seeding.md b/docs/configuration/seeding.md index 379a7b6..05249ec 100644 --- a/docs/configuration/seeding.md +++ b/docs/configuration/seeding.md @@ -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. @@ -437,6 +437,24 @@ 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`. + +- 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 index 56c39b1..655123d 100644 --- a/docs/configuration/webui-authentication.md +++ b/docs/configuration/webui-authentication.md @@ -209,7 +209,8 @@ RequireHttpsMetadata = true ## 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 index 00035b2..bf1133c 100644 --- a/docs/configuration/webui.md +++ b/docs/configuration/webui.md @@ -188,7 +188,7 @@ curl -H "Authorization: Bearer my-secure-token-12345" \ ## 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 @@ Default color theme for the WebUI. --- +## 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 index 118fcc0..32c093d 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -294,7 +294,7 @@ Open the solution (e.g. `Torrentarr.sln` if present) or the folder; set `Torrent 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 index faa2405..be9cd31 100644 --- a/docs/webui/api.md +++ b/docs/webui/api.md @@ -21,15 +21,15 @@ Torrentarr provides dual endpoint patterns for flexibility: | 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 @@ The following endpoints are **always public** (no authentication): - `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 @@ curl "http://localhost:6969/web/radarr/radarr-4k/movies?has_file=false&year_min= ## 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 index 4587fa7..299c1f0 100644 --- a/docs/webui/config-editor.md +++ b/docs/webui/config-editor.md @@ -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. @@ -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 @@ -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 diff --git a/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs b/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs index 7c7bcf3..4dc1e09 100644 --- a/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs +++ b/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs @@ -789,6 +789,10 @@ private static bool ValidateAndFillConfig(TomlTable root) } // --- HnR defaults on CategorySeeding and Tracker sections --- + var trackerDefaults = new Dictionary + { + ["SortTorrents"] = false + }; var hnrDefaults = new Dictionary { ["HitAndRunMode"] = "disabled", @@ -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)) @@ -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)) @@ -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); @@ -1166,6 +1189,16 @@ private WebUIConfig ParseWebUI(TomlTable table) 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 @@ private string GenerateTomlContent(TorrentarrConfig config) 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 @@ private string GenerateTomlContent(TorrentarrConfig config) 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 @@ 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()}"); 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 index 3a55e5a..10cc162 100644 --- a/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs +++ b/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs @@ -17,6 +17,22 @@ public class TorrentarrConfig /// Helper property to get Arr instances as a list /// public List Arrs => ArrInstances.Values.ToList(); + + public HashSet BuildManagedCategoriesSet() + { + var set = new HashSet(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 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; } @@ -149,6 +166,10 @@ public class WebUIConfig public bool GroupLidarr { get; set; } = true; public string Theme { get; set; } = "Dark"; public string ViewDensity { get; set; } = "Comfortable"; + /// + /// When non-empty, CORS allows only these origins (with credentials). When empty, any origin is allowed (legacy behavior). + /// + public List CorsAllowedOrigins { get; set; } = new(); /// OIDC settings when OIDCEnabled is true. Optional. public OIDCConfig? OIDC { get; set; } } diff --git a/src/Torrentarr.Core/Services/IFreeSpaceService.cs b/src/Torrentarr.Core/Services/IFreeSpaceService.cs index ca788a6..e4a8a9a 100644 --- a/src/Torrentarr.Core/Services/IFreeSpaceService.cs +++ b/src/Torrentarr.Core/Services/IFreeSpaceService.cs @@ -30,8 +30,18 @@ public interface IFreeSpaceService /// Pauses torrents that would exceed free space threshold and manages tags. /// Task ProcessTorrentsForSpaceAsync(string category, CancellationToken cancellationToken = default); + + /// + /// 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). + /// + Task ProcessGlobalManagedCategoriesHostPassAsync(CancellationToken cancellationToken = default); } +/// Result of . +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 index 0000000..2165e81 --- /dev/null +++ b/src/Torrentarr.Core/Services/ITrackerQueueSortService.cs @@ -0,0 +1,12 @@ +namespace Torrentarr.Core.Services; + +/// +/// Reorders qBittorrent queue by tracker priority when SortTorrents is enabled (global per qBit instance). +/// +public interface ITrackerQueueSortService +{ + /// + /// Applies tracker-priority ordering for all torrents across all qBit instances. + /// + Task SortTorrentQueuesByTrackerPriorityAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Torrentarr.Host/Program.cs b/src/Torrentarr.Host/Program.cs index 50814e4..ddbd1a7 100644 --- 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(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); - builder.Services.AddHostedService(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); // Scoped services (one per request / scope) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); - // §6.10 / §1.8: update check + auto-update + // §6.10 / §1.8: update check + auto-update builder.Services.AddSingleton(); builder.Services.AddHostedService(); @@ -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 @@ -343,9 +359,6 @@ ApplyManualMigrations(db); } - app.UseSwagger(); - app.UseSwaggerUI(); - app.UseCors("AllowAll"); // Security headers @@ -357,7 +370,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 +433,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)) { @@ -463,9 +476,12 @@ static bool IsAuthRequired(TorrentarrConfig c) => !c.WebUI.AuthDisabled; + app.UseSwagger(); + app.UseSwaggerUI(); + 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(); - // 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() }); + } + 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(); @@ -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 @@ orderby t.TrackNumber }); }); - // 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 @@ orderby t.TrackNumber 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 @@ orderby t.TrackNumber 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 @@ orderby t.TrackNumber 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 @@ orderby t.TrackNumber 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 @@ orderby t.TrackNumber 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 @@ orderby t.TrackNumber } // 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()) { if (emptyProp.Value is Newtonsoft.Json.Linq.JObject emptyObj && !emptyObj.Properties().Any()) @@ -1367,7 +1391,7 @@ orderby t.TrackNumber } }); - // §6.10: POST /web/update — trigger binary download + in-place apply + // §6.10: POST /web/update — trigger binary download + in-place apply app.MapPost("/web/update", async (UpdateService updater, IHostApplicationLifetime lifetime) => { if (updater.ApplyState.InProgress) @@ -1376,10 +1400,10 @@ orderby t.TrackNumber // Ensure we have a fresh check before applying await updater.CheckForUpdateAsync(); await updater.ApplyUpdateAsync(lifetime); - return Results.Ok(new { success = true, message = "Update started — application will restart when complete" }); + return Results.Ok(new { success = true, message = "Update started - application will restart when complete" }); }); - // §6.10: GET /web/download-update — return download URL/name/size for the latest binary + // §6.10: GET /web/download-update — return download URL/name/size for the latest binary app.MapGet("/web/download-update", async (UpdateService updater) => { await updater.CheckForUpdateAsync(); @@ -1395,11 +1419,11 @@ orderby t.TrackNumber }); }); - // Web Test Arr Connection (no auth — frontend uses this directly) + // Web Test Arr Connection (no auth — frontend uses this directly) app.MapPost("/web/arr/test-connection", (TestConnectionRequest req, TorrentarrConfig cfg) => HandleTestConnection(req, cfg)); - // Web Torrents Distribution — count media items per qBit category per Arr instance + // Web Torrents Distribution — count media items per qBit category per Arr instance app.MapGet("/web/torrents/distribution", async (TorrentarrConfig cfg, TorrentarrDbContext db) => { var distribution = new Dictionary>(); @@ -1420,7 +1444,7 @@ orderby t.TrackNumber return Results.Ok(new { distribution }); }); - // Web Token — only returned when already authenticated (middleware enforces when auth enabled) + // Web Token — only returned when already authenticated (middleware enforces when auth enabled) app.MapGet("/web/token", (TorrentarrConfig cfg, HttpContext ctx) => { var isAuthenticated = ctx.User?.Identity?.IsAuthenticated == true; @@ -1429,7 +1453,7 @@ orderby t.TrackNumber return Results.Ok(new { token = cfg.WebUI.Token }); }); - // Local login: username + password → session cookie + // Local login: username + password → session cookie app.MapPost("/web/login", async (HttpContext ctx, TorrentarrConfig cfg, IPasswordHasher hasher, ILogger log) => { var ip = ctx.Connection.RemoteIpAddress?.ToString(); @@ -1537,7 +1561,7 @@ orderby t.TrackNumber return NoOpAfterChallengeResult.Instance; }); - // Web Qbit Categories (api mirror — same logic as /web/qbit/categories, token-protected) + // Web Qbit Categories (api mirror — same logic as /web/qbit/categories, token-protected) app.MapGet("/api/qbit/categories", async (QBittorrentConnectionManager qbitManager, TorrentarrConfig cfg) => { var categories = new List(); @@ -1721,12 +1745,18 @@ orderby t.TrackNumber return Results.Ok(new { processes }); }); - app.MapPost("/api/processes/{category}/{kind}/restart", async (string category, string kind, TorrentarrConfig cfg, ArrWorkerManager workerMgr) => + app.MapPost("/api/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() }); + } + var instanceName = cfg.ArrInstances .FirstOrDefault(kv => kv.Value.Category.Equals(category, StringComparison.OrdinalIgnoreCase)).Key; if (instanceName != null) @@ -1734,16 +1764,18 @@ orderby t.TrackNumber return Results.Ok(new { status = "restarted", restarted = instanceName != null ? new[] { instanceName } : Array.Empty() }); }); - app.MapPost("/api/processes/restart_all", async (TorrentarrConfig cfg, ArrWorkerManager workerMgr) => + app.MapPost("/api/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() }); }); - app.MapPost("/api/arr/rebuild", async (TorrentarrConfig cfg, ArrWorkerManager workerMgr) => + app.MapPost("/api/arr/rebuild", async (TorrentarrConfig cfg, ArrWorkerManager workerMgr, HostWorkerManager hostWorkerManager) => { await workerMgr.RestartAllWorkersAsync(); - return Results.Ok(new { status = "restarted", restarted = cfg.ArrInstances.Keys.ToArray() }); + var restartedHostWorkers = await hostWorkerManager.RestartAllWorkersAsync(); + return Results.Ok(new { status = "restarted", restarted = cfg.ArrInstances.Keys.Concat(restartedHostWorkers).ToArray() }); }); app.MapPost("/api/loglevel", (LoggerConfigurationRequest req, LoggingLevelSwitch ls) => @@ -2208,7 +2240,7 @@ orderby t.TrackNumber return Results.Ok(new { success = false, message = "Update already in progress" }); await updater.CheckForUpdateAsync(); await updater.ApplyUpdateAsync(lifetime); - return Results.Ok(new { success = true, message = "Update started — application will restart when complete" }); + return Results.Ok(new { success = true, message = "Update started - application will restart when complete" }); }); app.MapGet("/api/download-update", async (UpdateService updater) => @@ -2271,7 +2303,7 @@ orderby t.TrackNumber return 0; -// ── Manual DB migrations (columns added after initial EnsureCreated) ────── +// ── Manual DB migrations (columns added after initial EnsureCreated) ────── static void ApplyManualMigrations(TorrentarrDbContext db) { // Add tvdbid to seriesfilesmodel if it doesn't exist (added in v1.1) @@ -2295,7 +2327,7 @@ static void ApplyManualMigrations(TorrentarrDbContext db) AddColumnIfMissing(db, "albumfilesmodel", "PhysicalRelease", "TEXT"); AddColumnIfMissing(db, "albumfilesmodel", "MinimumAvailability", "TEXT"); - // §5: Search activity table for Processes page (qBitrr parity) + // §5: Search activity table for Processes page (qBitrr parity) CreateTableIfMissing(db, "searchactivity", "CREATE TABLE IF NOT EXISTS searchactivity ( category TEXT NOT NULL PRIMARY KEY, summary TEXT, timestamp TEXT );"); } @@ -2354,7 +2386,7 @@ static void AddColumnIfMissing(TorrentarrDbContext db, string table, string colu } } -// ── Log file helpers ────────────────────────────────────────────────────── +// ── Log file helpers ────────────────────────────────────────────────────── /// /// Validates that a log file name is a plain filename ending in .log with no path components. @@ -2397,7 +2429,7 @@ static async Task TailLogFileAsync(string path, int maxLines) if (lines.Count >= maxLines) return string.Join("\n", lines.TakeLast(maxLines)); - // Heuristic undershot — not enough lines captured; fall back to full read. + // Heuristic undershot — not enough lines captured; fall back to full read. fs.Seek(0, SeekOrigin.Begin); reader.DiscardBufferedData(); lines.Clear(); @@ -2594,18 +2626,18 @@ static void DeleteNestedToken(Newtonsoft.Json.Linq.JObject obj, string[] parts, { var serialize = (object? o) => Newtonsoft.Json.JsonConvert.SerializeObject(o); - // QBit instance changes → full reload (requires process restart) + // QBit instance changes → full reload (requires process restart) bool hasQBitChanges = serialize(oldCfg.QBitInstances) != serialize(newCfg.QBitInstances); - // Settings changes → webui reload (workers pick up changes at next cycle) + // Settings changes → webui reload (workers pick up changes at next cycle) bool hasSettingsChanges = serialize(oldCfg.Settings) != serialize(newCfg.Settings); - // WebUI connection fields (host/port/token) → webui restart + // WebUI connection fields (host/port/token) → webui restart bool hasWebuiKeyChanges = oldCfg.WebUI.Host != newCfg.WebUI.Host || oldCfg.WebUI.Port != newCfg.WebUI.Port || oldCfg.WebUI.Token != newCfg.WebUI.Token; - // Other WebUI fields (theme, density, grouping, liveArr) → frontend-only, no restart + // Other WebUI fields (theme, density, grouping, liveArr) → frontend-only, no restart bool hasFrontendOnlyChanges = !hasWebuiKeyChanges && serialize(oldCfg.WebUI) != serialize(newCfg.WebUI); @@ -2632,430 +2664,6 @@ static void DeleteNestedToken(Newtonsoft.Json.Linq.JObject obj, string[] parts, return ("none", []); } -/// -/// Background service that orchestrates all processes. -/// -class ProcessOrchestratorService : BackgroundService -{ - private readonly ILogger _logger; - private readonly TorrentarrConfig _config; - private readonly QBittorrentConnectionManager _qbitManager; - private readonly IServiceScopeFactory _scopeFactory; - private readonly ProcessStateManager _stateManager; - private readonly HashSet _managedCategories; - private long _currentFreeSpace; - private long _minFreeSpaceBytes; - private string? _freeSpaceFolder; - private bool _qbitConfigured; - private bool _freeSpaceEnabled; - - public ProcessOrchestratorService( - ILogger logger, - TorrentarrConfig config, - QBittorrentConnectionManager qbitManager, - IServiceScopeFactory scopeFactory, - ProcessStateManager stateManager) - { - _logger = logger; - _config = config; - _qbitManager = qbitManager; - _scopeFactory = scopeFactory; - _stateManager = stateManager; - _managedCategories = new HashSet(StringComparer.OrdinalIgnoreCase); - // §8: Respect Settings.FreeSpace string ("-1" = disabled, "10G"/"500M" = threshold) - var freeSpaceBytes = ParseFreeSpaceString(_config.Settings.FreeSpace); - if (freeSpaceBytes < 0) - { - _freeSpaceEnabled = false; - _minFreeSpaceBytes = (long)(_config.Settings.FreeSpaceThresholdGB ?? 10) * 1024L * 1024L * 1024L; - } - else - { - _freeSpaceEnabled = true; - _minFreeSpaceBytes = freeSpaceBytes; - } - _qbitConfigured = config.QBitInstances.Values.Any(q => - !q.Disabled && q.Host != "CHANGE_ME" && q.UserName != "CHANGE_ME" && q.Password != "CHANGE_ME"); - } - - /// Parse qBitrr FreeSpace string: "-1" = disabled, "10G"/"500M"/"1024K" or raw number = threshold bytes. - private static long ParseFreeSpaceString(string? value) - { - if (string.IsNullOrWhiteSpace(value) || value.Trim() == "-1") return -1; - var v = value.Trim().ToUpperInvariant(); - try - { - if (v.EndsWith("G")) return long.Parse(v[..^1]) * 1024L * 1024L * 1024L; - if (v.EndsWith("M")) return long.Parse(v[..^1]) * 1024L * 1024L; - if (v.EndsWith("K")) return long.Parse(v[..^1]) * 1024L; - return long.Parse(v); - } - catch { return -1; } - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation("Process Orchestrator starting"); - - try - { - if (!_qbitConfigured) - { - _logger.LogWarning("No qBittorrent instances configured - WebUI only mode"); - } - else - { - var anyConnected = false; - foreach (var (name, qbit) in _config.QBitInstances) - { - if (!qbit.Disabled && qbit.Host != "CHANGE_ME") - { - var ok = await _qbitManager.InitializeAsync(name, qbit, stoppingToken); - if (ok) anyConnected = true; - } - } - if (!anyConnected) - { - _logger.LogWarning("Failed to connect to any qBittorrent instance. WebUI is still available."); - _qbitConfigured = false; - } - } - - // Get ALL categories from ALL Arr instances (not just managed ones) - matches qBitrr behavior - foreach (var arrInstance in _config.ArrInstances.Where(x => !string.IsNullOrEmpty(x.Value.Category))) - _managedCategories.Add(arrInstance.Value.Category!); - - // Also add qBit-managed categories from all qBit instances - foreach (var qbit in _config.QBitInstances.Values) - { - if (qbit.ManagedCategories != null) - { - foreach (var cat in qbit.ManagedCategories) - _managedCategories.Add(cat); - } - } - - if (_managedCategories.Count > 0) - _logger.LogInformation("FreeSpace categories: {Categories}", string.Join(", ", _managedCategories)); - - _freeSpaceFolder = GetFreeSpaceFolder(); - - // Other section (Recheck, Failed, Free Space Manager): only when qBit is configured - if (_qbitConfigured) - { - _stateManager.Initialize("Recheck", new ArrProcessState - { - Name = "Recheck", - Category = "Recheck", - Kind = "category", - Alive = false, - CategoryCount = null - }); - _stateManager.Initialize("Failed", new ArrProcessState - { - Name = "Failed", - Category = "Failed", - Kind = "category", - Alive = false, - CategoryCount = null - }); - _stateManager.Initialize("FreeSpaceManager", new ArrProcessState - { - Name = "FreeSpaceManager", - Category = "FreeSpaceManager", - Kind = "torrent", - MetricType = "free-space", - Alive = false, - CategoryCount = null - }); - } - - while (!stoppingToken.IsCancellationRequested) - { - try - { - if (_qbitConfigured) - { - await ProcessSpecialCategoriesAsync(stoppingToken); - - if (_config.Settings.AutoPauseResume && _freeSpaceEnabled && _minFreeSpaceBytes > 0) - await ProcessFreeSpaceManagerAsync(stoppingToken); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in orchestrator loop"); - } - - await Task.Delay(TimeSpan.FromSeconds(_config.Settings.LoopSleepTimer), stoppingToken); - } - } - catch (OperationCanceledException) - { - _logger.LogInformation("Orchestrator shutting down gracefully"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in process orchestrator"); - } - } - - private string? GetFreeSpaceFolder() - { - // Try configured folder first - if (!string.IsNullOrEmpty(_config.Settings.FreeSpaceFolder) && _config.Settings.FreeSpaceFolder != "CHANGE_ME") - { - // Check if path exists, if not return null - if (Directory.Exists(_config.Settings.FreeSpaceFolder)) - return _config.Settings.FreeSpaceFolder; - } - - // Fallback to completed download folder - if (!string.IsNullOrEmpty(_config.Settings.CompletedDownloadFolder) && _config.Settings.CompletedDownloadFolder != "CHANGE_ME") - { - if (Directory.Exists(_config.Settings.CompletedDownloadFolder)) - return _config.Settings.CompletedDownloadFolder; - } - - // Final fallback: use /config which is always available in container - return "/config"; - } - - private async Task ProcessSpecialCategoriesAsync(CancellationToken cancellationToken) - { - int totalFailed = 0; - int totalRecheck = 0; - foreach (var (instanceName, client) in _qbitManager.GetAllClients()) - { - try - { - var failedTorrents = await client.GetTorrentsAsync(_config.Settings.FailedCategory, cancellationToken); - totalFailed += failedTorrents.Count; - foreach (var torrent in failedTorrents) - { - // §2.13: Settings-level IgnoreTorrentsYoungerThan applies to failed/recheck - if (torrent.AddedOn > 0) - { - var addedAt = DateTimeOffset.FromUnixTimeSeconds(torrent.AddedOn).UtcDateTime; - if ((DateTime.UtcNow - addedAt).TotalSeconds < _config.Settings.IgnoreTorrentsYoungerThan) - { - _logger.LogTrace("[{Instance}] Skipping failed torrent too young: {Name} (age {Age:F0}s < {Threshold}s)", - instanceName, torrent.Name, - (DateTime.UtcNow - addedAt).TotalSeconds, - _config.Settings.IgnoreTorrentsYoungerThan); - continue; - } - } - _logger.LogWarning("[{Instance}] Deleting failed torrent: {Name}", instanceName, torrent.Name); - await client.DeleteTorrentsAsync(new List { torrent.Hash }, deleteFiles: true, cancellationToken); - } - - var recheckTorrents = await client.GetTorrentsAsync(_config.Settings.RecheckCategory, cancellationToken); - totalRecheck += recheckTorrents.Count; - foreach (var torrent in recheckTorrents) - { - // §2.13: Settings-level IgnoreTorrentsYoungerThan applies to failed/recheck - if (torrent.AddedOn > 0) - { - var addedAt = DateTimeOffset.FromUnixTimeSeconds(torrent.AddedOn).UtcDateTime; - if ((DateTime.UtcNow - addedAt).TotalSeconds < _config.Settings.IgnoreTorrentsYoungerThan) - { - _logger.LogTrace("[{Instance}] Skipping recheck torrent too young: {Name} (age {Age:F0}s < {Threshold}s)", - instanceName, torrent.Name, - (DateTime.UtcNow - addedAt).TotalSeconds, - _config.Settings.IgnoreTorrentsYoungerThan); - continue; - } - } - _logger.LogInformation("[{Instance}] Re-checking torrent: {Name}", instanceName, torrent.Name); - await client.RecheckTorrentsAsync(new List { torrent.Hash }, cancellationToken); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "[{Instance}] Error processing special categories", instanceName); - } - } - _stateManager.Update("Failed", s => { s.CategoryCount = totalFailed; s.Alive = true; }); - _stateManager.Update("Recheck", s => { s.CategoryCount = totalRecheck; s.Alive = true; }); - } - - private async Task ProcessFreeSpaceManagerAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("FreeSpace: Starting FreeSpace manager check"); - - if (string.IsNullOrEmpty(_freeSpaceFolder)) - { - _logger.LogWarning("FreeSpace: No free space folder configured or folder doesn't exist"); - _stateManager.Update("FreeSpaceManager", s => { s.Alive = false; s.CategoryCount = 0; }); - return; - } - - _logger.LogInformation("FreeSpace: Using folder {Folder} for space monitoring", _freeSpaceFolder); - - // §1.6: tagless mode needs a DB scope to read/write FreeSpacePaused column - IServiceScope? scope = null; - TorrentarrDbContext? dbContext = null; - if (_config.Settings.Tagless) - { - scope = _scopeFactory.CreateScope(); - dbContext = scope.ServiceProvider.GetRequiredService(); - } - - const string freeSpacePausedTag = "qBitrr-free_space_paused"; - int pausedCount = 0; - - try - { - var driveInfo = new DriveInfo(_freeSpaceFolder); - _currentFreeSpace = driveInfo.AvailableFreeSpace - _minFreeSpaceBytes; - - // Gather torrents from ALL qBit instances across all managed categories, sorted by added date - var allTorrents = new List<(QBittorrentClient client, TorrentInfo torrent)>(); - foreach (var (_, client) in _qbitManager.GetAllClients()) - { - foreach (var category in _managedCategories) - { - var torrents = await client.GetTorrentsAsync(category, cancellationToken); - allTorrents.AddRange(torrents.Select(t => (client, t))); - } - } - - int[]? pausedCountRef = null; - if (!_config.Settings.Tagless) - { - pausedCount = allTorrents.Count(t => t.torrent.Tags?.Contains(freeSpacePausedTag) == true); - pausedCountRef = new int[] { pausedCount }; - } - - foreach (var (client, torrent) in allTorrents.OrderBy(x => x.torrent.AddedOn)) - await ProcessSingleTorrentSpaceAsync(client, torrent, dbContext, pausedCountRef, cancellationToken); - - if (_config.Settings.Tagless && dbContext != null) - pausedCount = await dbContext.TorrentLibrary.CountAsync(t => t.FreeSpacePaused, cancellationToken); - else if (pausedCountRef != null) - pausedCount = pausedCountRef[0]; - - _stateManager.Update("FreeSpaceManager", s => - { - s.CategoryCount = pausedCount; - s.MetricType = "free-space"; - s.Alive = _freeSpaceEnabled && _minFreeSpaceBytes > 0 && !string.IsNullOrEmpty(_freeSpaceFolder); - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in free space manager"); - } - finally - { - scope?.Dispose(); - } - } - - private async Task ProcessSingleTorrentSpaceAsync( - QBittorrentClient client, TorrentInfo torrent, TorrentarrDbContext? dbContext, int[]? pausedCountRef, CancellationToken cancellationToken) - { - const string freeSpacePausedTag = "qBitrr-free_space_paused"; - var tagless = _config.Settings.Tagless; - - var isDownloading = torrent.State.Contains("downloading", StringComparison.OrdinalIgnoreCase) || - torrent.State.Contains("stalledDL", StringComparison.OrdinalIgnoreCase); - var isPausedDownload = torrent.State.Contains("pausedDL", StringComparison.OrdinalIgnoreCase); - - // §1.6: tagless mode reads FreeSpacePaused from DB column; otherwise check qBit tag - bool hasFreeSpaceTag; - if (tagless && dbContext != null) - { - var dbEntry = await dbContext.TorrentLibrary.AsNoTracking() - .FirstOrDefaultAsync(t => t.Hash == torrent.Hash, cancellationToken); - hasFreeSpaceTag = dbEntry?.FreeSpacePaused == true; - } - else - { - hasFreeSpaceTag = torrent.Tags?.Contains(freeSpacePausedTag) == true; - } - - if (isDownloading || (isPausedDownload && hasFreeSpaceTag)) - { - var freeSpaceTest = _currentFreeSpace - torrent.AmountLeft; - - _logger.LogInformation( - "FreeSpace: Evaluating torrent: {Name} | Current space: {Available} | Space after: {SpaceAfter} | Remaining: {Needed}", - torrent.Name, FormatBytes(_currentFreeSpace), FormatBytes(freeSpaceTest), FormatBytes(torrent.AmountLeft)); - - if (!isPausedDownload && freeSpaceTest < 0) - { - _logger.LogInformation( - "FreeSpace: Pausing download (insufficient space) | Torrent: {Name} | Available: {Available} | Needed: {Needed} | Deficit: {Deficit}", - torrent.Name, FormatBytes(_currentFreeSpace), FormatBytes(torrent.AmountLeft), FormatBytes(-freeSpaceTest)); - // §1.6: tagless — set DB column; else apply qBit tag - if (tagless && dbContext != null) - await dbContext.TorrentLibrary.Where(t => t.Hash == torrent.Hash) - .ExecuteUpdateAsync(s => s.SetProperty(t => t.FreeSpacePaused, true), cancellationToken); - else - await client.AddTagsAsync(new List { torrent.Hash }, new List { freeSpacePausedTag }, cancellationToken); - if (pausedCountRef != null) pausedCountRef[0]++; - await client.PauseTorrentAsync(torrent.Hash, cancellationToken); - } - else if (isPausedDownload && freeSpaceTest >= 0) - { - _logger.LogInformation( - "FreeSpace: Resuming download (space available) | Torrent: {Name} | Available: {Available} | Space after: {SpaceAfter}", - torrent.Name, FormatBytes(_currentFreeSpace), FormatBytes(freeSpaceTest)); - _currentFreeSpace = freeSpaceTest; - // §1.6: tagless — clear DB column; else remove qBit tag - if (tagless && dbContext != null) - await dbContext.TorrentLibrary.Where(t => t.Hash == torrent.Hash) - .ExecuteUpdateAsync(s => s.SetProperty(t => t.FreeSpacePaused, false), cancellationToken); - else - await client.RemoveTagsAsync(new List { torrent.Hash }, new List { freeSpacePausedTag }, cancellationToken); - if (pausedCountRef != null) pausedCountRef[0]--; - await client.ResumeTorrentAsync(torrent.Hash, cancellationToken); - } - else if (isPausedDownload && freeSpaceTest < 0) - { - _logger.LogInformation( - "FreeSpace: Keeping paused (insufficient space) | Torrent: {Name} | Available: {Available} | Needed: {Needed} | Deficit: {Deficit}", - torrent.Name, FormatBytes(_currentFreeSpace), FormatBytes(torrent.AmountLeft), FormatBytes(-freeSpaceTest)); - } - else if (!isPausedDownload && freeSpaceTest >= 0) - { - _logger.LogInformation( - "FreeSpace: Continuing download (sufficient space) | Torrent: {Name} | Available: {Available} | Space after: {SpaceAfter}", - torrent.Name, FormatBytes(_currentFreeSpace), FormatBytes(freeSpaceTest)); - _currentFreeSpace = freeSpaceTest; - } - } - else if (!isDownloading && hasFreeSpaceTag) - { - // Torrent completed — clear the paused marker - _logger.LogInformation( - "FreeSpace: Torrent completed, removing free space tag | Torrent: {Name} | Available: {Available}", - torrent.Name, FormatBytes(_currentFreeSpace + _minFreeSpaceBytes)); - // §1.6: tagless — clear DB column; else remove qBit tag - if (tagless && dbContext != null) - await dbContext.TorrentLibrary.Where(t => t.Hash == torrent.Hash) - .ExecuteUpdateAsync(s => s.SetProperty(t => t.FreeSpacePaused, false), cancellationToken); - else - await client.RemoveTagsAsync(new List { torrent.Hash }, new List { freeSpacePausedTag }, cancellationToken); - if (pausedCountRef != null) pausedCountRef[0]--; - } - } - - private static string FormatBytes(long bytes) - { - string[] sizes = { "B", "KB", "MB", "GB", "TB" }; - double len = bytes; - int order = 0; - while (len >= 1024 && order < sizes.Length - 1) - { - order++; - len /= 1024; - } - return $"{len:0.##} {sizes[order]}"; - } -} // Request models for API endpoints public record TestConnectionRequest( diff --git a/src/Torrentarr.Infrastructure/ApiClients/QBittorrent/QBittorrentClient.cs b/src/Torrentarr.Infrastructure/ApiClients/QBittorrent/QBittorrentClient.cs index 2ccb3ce..d07367f 100644 --- a/src/Torrentarr.Infrastructure/ApiClients/QBittorrent/QBittorrentClient.cs +++ b/src/Torrentarr.Infrastructure/ApiClients/QBittorrent/QBittorrentClient.cs @@ -451,6 +451,23 @@ public async Task SetFilePriorityAsync(string hash, int[] fileIds, int pri return response.IsSuccessful; } + /// + /// Move torrents to the top of the queue priority list. + /// POST /api/v2/torrents/topPrio + /// + public async Task TopPriorityAsync(List 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; + } + /// /// Create a new category /// diff --git a/src/Torrentarr.Infrastructure/Services/FreeSpaceParser.cs b/src/Torrentarr.Infrastructure/Services/FreeSpaceParser.cs new file mode 100644 index 0000000..d977519 --- /dev/null +++ b/src/Torrentarr.Infrastructure/Services/FreeSpaceParser.cs @@ -0,0 +1,18 @@ +namespace Torrentarr.Infrastructure.Services; + +internal static class FreeSpaceParser +{ + public static long ParseFreeSpaceString(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Trim() == "-1") return -1; + var v = value.Trim().ToUpperInvariant(); + try + { + if (v.EndsWith("G")) return long.Parse(v[..^1]) * 1024L * 1024L * 1024L; + if (v.EndsWith("M")) return long.Parse(v[..^1]) * 1024L * 1024L; + if (v.EndsWith("K")) return long.Parse(v[..^1]) * 1024L; + return long.Parse(v); + } + catch { return -1; } + } +} diff --git a/src/Torrentarr.Infrastructure/Services/FreeSpaceService.cs b/src/Torrentarr.Infrastructure/Services/FreeSpaceService.cs index 5222a84..b288023 100644 --- a/src/Torrentarr.Infrastructure/Services/FreeSpaceService.cs +++ b/src/Torrentarr.Infrastructure/Services/FreeSpaceService.cs @@ -1,3 +1,4 @@ +using System.Linq; using Torrentarr.Core.Configuration; using Torrentarr.Core.Models; using Torrentarr.Core.Services; @@ -36,29 +37,12 @@ public FreeSpaceService( _qbitManager = qbitManager; _dbContext = dbContext; // §FreeSpace parity: prefer Settings.FreeSpace (qBitrr string format) over FreeSpaceThresholdGB - var freeSpaceBytes = ParseFreeSpaceString(config.Settings.FreeSpace); + var freeSpaceBytes = FreeSpaceParser.ParseFreeSpaceString(config.Settings.FreeSpace); _minFreeSpaceBytes = freeSpaceBytes > 0 ? freeSpaceBytes : (long)(_config.Settings.FreeSpaceThresholdGB ?? 10) * 1024L * 1024L * 1024L; } - /// - /// Parse qBitrr FreeSpace config string: "-1" → disabled (-1), "10G" → 10 GiB, "500M" → 500 MiB, "1024K" → 1 KiB, raw number → bytes. - /// - private static long ParseFreeSpaceString(string? value) - { - if (string.IsNullOrWhiteSpace(value) || value.Trim() == "-1") return -1; - var v = value.Trim().ToUpperInvariant(); - try - { - if (v.EndsWith("G")) return long.Parse(v[..^1]) * 1024L * 1024L * 1024L; - if (v.EndsWith("M")) return long.Parse(v[..^1]) * 1024L * 1024L; - if (v.EndsWith("K")) return long.Parse(v[..^1]) * 1024L; - return long.Parse(v); - } - catch { return -1; } - } - public async Task HasEnoughFreeSpaceAsync(long requiredBytes, CancellationToken cancellationToken = default) { var stats = await GetFreeSpaceStatsAsync(cancellationToken); @@ -510,4 +494,174 @@ private static string FormatBytes(long bytes) while (size >= 1024 && order < sizes.Length - 1) { order++; size /= 1024; } return $"{size:0.##} {sizes[order]}"; } + + /// + public async Task ProcessGlobalManagedCategoriesHostPassAsync(CancellationToken cancellationToken = default) + { + var freeSpaceCfg = FreeSpaceParser.ParseFreeSpaceString(_config.Settings.FreeSpace); + if (freeSpaceCfg < 0 || !_config.Settings.AutoPauseResume) + return new GlobalFreeSpacePassResult(0, false); + + var minBytes = freeSpaceCfg; + var folder = GetResolvedFreeSpaceFolderPath(); + if (string.IsNullOrEmpty(folder)) + { + _logger.LogWarning("FreeSpace: No free space folder configured or folder doesn't exist"); + return new GlobalFreeSpacePassResult(0, false); + } + + _logger.LogInformation("FreeSpace: Starting FreeSpace manager check"); + _logger.LogInformation("FreeSpace: Using folder {Folder} for space monitoring", folder); + + var managedCategories = _config.BuildManagedCategoriesSet(); + long currentFreeSpace; + try + { + var driveInfo = new DriveInfo(folder); + currentFreeSpace = driveInfo.AvailableFreeSpace - minBytes; + } + catch (Exception ex) + { + _logger.LogError(ex, "FreeSpace: Failed to read drive for folder {Folder}", folder); + return new GlobalFreeSpacePassResult(0, false); + } + + var allTorrents = new List<(QBittorrentClient client, TorrentInfo torrent)>(); + foreach (var (_, client) in _qbitManager.GetAllClients()) + { + foreach (var category in managedCategories) + { + var torrents = await client.GetTorrentsAsync(category, cancellationToken); + allTorrents.AddRange(torrents.Select(t => (client, t))); + } + } + + int pausedCount; + int[]? pausedCountRef = null; + if (!_config.Settings.Tagless) + { + pausedCount = allTorrents.Count(t => t.torrent.Tags?.Contains(FreeSpacePausedTag) == true); + pausedCountRef = new int[] { pausedCount }; + } + else + pausedCount = 0; + + foreach (var (client, torrent) in allTorrents.OrderBy(x => x.torrent.AddedOn)) + { + currentFreeSpace = await ProcessSingleTorrentSpaceHostOrchestratorStyleAsync( + client, torrent, currentFreeSpace, minBytes, pausedCountRef, cancellationToken); + } + + if (_config.Settings.Tagless) + pausedCount = await _dbContext.TorrentLibrary.CountAsync(t => t.FreeSpacePaused, cancellationToken); + else if (pausedCountRef != null) + pausedCount = pausedCountRef[0]; + + return new GlobalFreeSpacePassResult(pausedCount, minBytes > 0 && !string.IsNullOrEmpty(folder)); + } + + private string? GetResolvedFreeSpaceFolderPath() + { + if (!string.IsNullOrEmpty(_config.Settings.FreeSpaceFolder) && _config.Settings.FreeSpaceFolder != "CHANGE_ME") + { + if (Directory.Exists(_config.Settings.FreeSpaceFolder)) + return _config.Settings.FreeSpaceFolder; + } + if (!string.IsNullOrEmpty(_config.Settings.CompletedDownloadFolder) && _config.Settings.CompletedDownloadFolder != "CHANGE_ME") + { + if (Directory.Exists(_config.Settings.CompletedDownloadFolder)) + return _config.Settings.CompletedDownloadFolder; + } + return "/config"; + } + + /// Matches former Host ProcessSingleTorrentSpaceAsync (downloading = DL + stalledDL only, not metaDL). + private async Task ProcessSingleTorrentSpaceHostOrchestratorStyleAsync( + QBittorrentClient client, + TorrentInfo torrent, + long currentFreeSpace, + long minFreeSpaceBytes, + int[]? pausedCountRef, + CancellationToken cancellationToken) + { + var tagless = _config.Settings.Tagless; + + var isDownloading = torrent.State.Contains("downloading", StringComparison.OrdinalIgnoreCase) || + torrent.State.Contains("stalledDL", StringComparison.OrdinalIgnoreCase); + var isPausedDownload = torrent.State.Contains("pausedDL", StringComparison.OrdinalIgnoreCase); + + bool hasFreeSpaceTag; + if (tagless) + { + var dbEntry = await _dbContext.TorrentLibrary.AsNoTracking() + .FirstOrDefaultAsync(t => t.Hash == torrent.Hash, cancellationToken); + hasFreeSpaceTag = dbEntry?.FreeSpacePaused == true; + } + else + hasFreeSpaceTag = torrent.Tags?.Contains(FreeSpacePausedTag, StringComparison.OrdinalIgnoreCase) == true; + + if (isDownloading || (isPausedDownload && hasFreeSpaceTag)) + { + var freeSpaceTest = currentFreeSpace - torrent.AmountLeft; + + _logger.LogInformation( + "FreeSpace: Evaluating torrent: {Name} | Current space: {Available} | Space after: {SpaceAfter} | Remaining: {Needed}", + torrent.Name, FormatBytes(currentFreeSpace), FormatBytes(freeSpaceTest), FormatBytes(torrent.AmountLeft)); + + if (!isPausedDownload && freeSpaceTest < 0) + { + _logger.LogInformation( + "FreeSpace: Pausing download (insufficient space) | Torrent: {Name} | Available: {Available} | Needed: {Needed} | Deficit: {Deficit}", + torrent.Name, FormatBytes(currentFreeSpace), FormatBytes(torrent.AmountLeft), FormatBytes(-freeSpaceTest)); + if (tagless) + await _dbContext.TorrentLibrary.Where(t => t.Hash == torrent.Hash) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.FreeSpacePaused, true), cancellationToken); + else + await client.AddTagsAsync(new List { torrent.Hash }, new List { FreeSpacePausedTag }, cancellationToken); + if (pausedCountRef != null) pausedCountRef[0]++; + await client.PauseTorrentAsync(torrent.Hash, cancellationToken); + } + else if (isPausedDownload && freeSpaceTest >= 0) + { + _logger.LogInformation( + "FreeSpace: Resuming download (space available) | Torrent: {Name} | Available: {Available} | Space after: {SpaceAfter}", + torrent.Name, FormatBytes(currentFreeSpace), FormatBytes(freeSpaceTest)); + currentFreeSpace = freeSpaceTest; + if (tagless) + await _dbContext.TorrentLibrary.Where(t => t.Hash == torrent.Hash) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.FreeSpacePaused, false), cancellationToken); + else + await client.RemoveTagsAsync(new List { torrent.Hash }, new List { FreeSpacePausedTag }, cancellationToken); + if (pausedCountRef != null) pausedCountRef[0]--; + await client.ResumeTorrentAsync(torrent.Hash, cancellationToken); + } + else if (isPausedDownload && freeSpaceTest < 0) + { + _logger.LogInformation( + "FreeSpace: Keeping paused (insufficient space) | Torrent: {Name} | Available: {Available} | Needed: {Needed} | Deficit: {Deficit}", + torrent.Name, FormatBytes(currentFreeSpace), FormatBytes(torrent.AmountLeft), FormatBytes(-freeSpaceTest)); + } + else if (!isPausedDownload && freeSpaceTest >= 0) + { + _logger.LogInformation( + "FreeSpace: Continuing download (sufficient space) | Torrent: {Name} | Available: {Available} | Space after: {SpaceAfter}", + torrent.Name, FormatBytes(currentFreeSpace), FormatBytes(freeSpaceTest)); + currentFreeSpace = freeSpaceTest; + } + } + else if (!isDownloading && hasFreeSpaceTag) + { + _logger.LogInformation( + "FreeSpace: Torrent completed, removing free space tag | Torrent: {Name} | Available: {Available}", + torrent.Name, FormatBytes(currentFreeSpace + minFreeSpaceBytes)); + if (tagless) + await _dbContext.TorrentLibrary.Where(t => t.Hash == torrent.Hash) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.FreeSpacePaused, false), cancellationToken); + else + await client.RemoveTagsAsync(new List { torrent.Hash }, new List { FreeSpacePausedTag }, cancellationToken); + if (pausedCountRef != null) pausedCountRef[0]--; + } + + return currentFreeSpace; + } } diff --git a/src/Torrentarr.Infrastructure/Services/HostWorkerManager.cs b/src/Torrentarr.Infrastructure/Services/HostWorkerManager.cs new file mode 100644 index 0000000..49d193e --- /dev/null +++ b/src/Torrentarr.Infrastructure/Services/HostWorkerManager.cs @@ -0,0 +1,537 @@ +using System.Collections.Concurrent; +using System.Linq; +using Torrentarr.Core.Configuration; +using Torrentarr.Core.Services; +using Torrentarr.Infrastructure.ApiClients.QBittorrent; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Serilog.Context; + +namespace Torrentarr.Infrastructure.Services; + +/// +/// Manages fire-and-forget Task.Run loops for Host-only work (Failed/Recheck categories, free space, tracker sort). +/// Modeled after — monitors tasks and restarts faulted workers. +/// +public class HostWorkerManager : BackgroundService +{ + public const string FailedWorkerName = "Failed"; + public const string RecheckWorkerName = "Recheck"; + public const string FreeSpaceWorkerName = "FreeSpaceManager"; + public const string TrackerSortWorkerName = "TrackerSortManager"; + + /// Names used in and restart API paths. + public static readonly string[] AllHostWorkerNames = + { + FailedWorkerName, RecheckWorkerName, FreeSpaceWorkerName, TrackerSortWorkerName + }; + + private readonly ILogger _logger; + private readonly TorrentarrConfig _config; + private readonly QBittorrentConnectionManager _qbitManager; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ProcessStateManager _stateManager; + + private readonly ConcurrentDictionary _workers = + new(StringComparer.OrdinalIgnoreCase); + + private readonly ConcurrentDictionary> _restartTimestamps = new(StringComparer.OrdinalIgnoreCase); + private readonly object _restartLock = new(); + + private CancellationToken _appStopping; + private bool _qbitConfigured; + private long _lastTrackerSortTicksUtc = DateTime.MinValue.Ticks; + + public HostWorkerManager( + ILogger logger, + TorrentarrConfig config, + QBittorrentConnectionManager qbitManager, + IServiceScopeFactory scopeFactory, + ProcessStateManager stateManager) + { + _logger = logger; + _config = config; + _qbitManager = qbitManager; + _scopeFactory = scopeFactory; + _stateManager = stateManager; + + _qbitConfigured = config.QBitInstances.Values.Any(q => + !q.Disabled && q.Host != "CHANGE_ME" && q.UserName != "CHANGE_ME" && q.Password != "CHANGE_ME"); + } + + private static bool GlobalSortTorrentsEnabled(TorrentarrConfig config) => + config.QBitInstances.Values.Any(q => q.Trackers.Any(t => t.SortTorrents)) + || config.ArrInstances.Values.Any(a => a.Torrent.Trackers.Any(t => t.SortTorrents)); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _appStopping = stoppingToken; + _logger.LogInformation("Host worker manager starting"); + + if (!_qbitConfigured) + { + _logger.LogWarning("No qBittorrent instances configured — Host subprocess workers not started"); + try { await Task.Delay(Timeout.Infinite, stoppingToken); } + catch (OperationCanceledException) { } + return; + } + + var anyConnected = false; + foreach (var (name, qbit) in _config.QBitInstances) + { + if (!qbit.Disabled && qbit.Host != "CHANGE_ME") + { + var ok = await _qbitManager.InitializeAsync(name, qbit, stoppingToken); + if (ok) anyConnected = true; + } + } + + if (!anyConnected) + { + _logger.LogWarning("Failed to connect to any qBittorrent instance — Host subprocess workers not started"); + try { await Task.Delay(Timeout.Infinite, stoppingToken); } + catch (OperationCanceledException) { } + return; + } + + InitializeProcessStates(); + + StartHostWorker(FailedWorkerName, RunFailedLoopAsync, stoppingToken); + StartHostWorker(RecheckWorkerName, RunRecheckLoopAsync, stoppingToken); + + var freeSpaceBytes = FreeSpaceParser.ParseFreeSpaceString(_config.Settings.FreeSpace); + if (_config.Settings.AutoPauseResume && freeSpaceBytes > 0) + StartHostWorker(FreeSpaceWorkerName, RunFreeSpaceLoopAsync, stoppingToken); + + if (GlobalSortTorrentsEnabled(_config)) + StartHostWorker(TrackerSortWorkerName, RunTrackerSortLoopAsync, stoppingToken); + + try + { + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + foreach (var name in _workers.Keys.ToList()) + { + if (!_workers.TryGetValue(name, out var pair)) + continue; + var (task, _) = pair; + if (stoppingToken.IsCancellationRequested) + break; + if (task.IsFaulted) + { + _logger.LogWarning(task.Exception?.GetBaseException(), + "Host worker {Name} faulted — restarting", name); + await RestartHostWorkerFromWatchAsync(name, stoppingToken); + } + else if (task.IsCompletedSuccessfully) + { + _logger.LogWarning("Host worker {Name} exited unexpectedly — restarting", name); + await RestartHostWorkerFromWatchAsync(name, stoppingToken); + } + } + } + } + catch (OperationCanceledException) { } + finally + { + await StopAllHostWorkersAsync(); + } + } + + private void InitializeProcessStates() + { + _stateManager.Initialize(FailedWorkerName, new ArrProcessState + { + Name = FailedWorkerName, + Category = FailedWorkerName, + Kind = "category", + Alive = false, + CategoryCount = null + }); + _stateManager.Initialize(RecheckWorkerName, new ArrProcessState + { + Name = RecheckWorkerName, + Category = RecheckWorkerName, + Kind = "category", + Alive = false, + CategoryCount = null + }); + _stateManager.Initialize(FreeSpaceWorkerName, new ArrProcessState + { + Name = FreeSpaceWorkerName, + Category = FreeSpaceWorkerName, + Kind = "torrent", + MetricType = "free-space", + Alive = false, + CategoryCount = null + }); + _stateManager.Initialize(TrackerSortWorkerName, new ArrProcessState + { + Name = TrackerSortWorkerName, + Category = TrackerSortWorkerName, + Kind = "torrent", + MetricType = "tracker-sort", + Alive = false, + CategoryCount = null + }); + } + + /// Restart a Host subprocess worker (Failed, Recheck, FreeSpaceManager, TrackerSortManager). + public async Task RestartWorkerAsync(string workerName) + { + if (!AllHostWorkerNames.Contains(workerName, StringComparer.OrdinalIgnoreCase)) + return false; + + var settings = _config.Settings; + if (!settings.AutoRestartProcesses) + { + _logger.LogWarning("Host worker restart skipped for {Name}: AutoRestartProcesses is disabled", workerName); + return false; + } + + var windowSeconds = settings.ProcessRestartWindow; + var maxRestarts = settings.MaxProcessRestarts; + var delaySeconds = settings.ProcessRestartDelay; + + lock (_restartLock) + { + var list = _restartTimestamps.GetOrAdd(workerName, _ => new List()); + var cutoff = DateTime.UtcNow.AddSeconds(-windowSeconds); + list.RemoveAll(d => d < cutoff); + if (list.Count >= maxRestarts) + { + _logger.LogWarning( + "Host worker restart skipped for {Name}: {Count} restarts in last {Window}s (max {Max})", + workerName, list.Count, windowSeconds, maxRestarts); + return false; + } + list.Add(DateTime.UtcNow); + } + + if (delaySeconds > 0) + await Task.Delay(TimeSpan.FromSeconds(delaySeconds)); + + _logger.LogInformation("Restarting host worker {Name}", workerName); + await RestartHostWorkerFromWatchAsync(workerName, _appStopping); + return true; + } + + /// Restart every running Host subprocess worker. + public async Task RestartAllWorkersAsync() + { + var workerNames = _workers.Keys.ToList(); + var restartedWorkers = new List(workerNames.Count); + foreach (var name in workerNames) + { + await RestartHostWorkerFromWatchAsync(name, _appStopping); + if (_workers.ContainsKey(name)) + restartedWorkers.Add(name); + } + return restartedWorkers.ToArray(); + } + + private async Task RestartHostWorkerFromWatchAsync(string name, CancellationToken appStopping) + { + var workerName = AllHostWorkerNames.FirstOrDefault(n => n.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (workerName is null) + return; + + if (_workers.TryRemove(name, out var old)) + { + try { old.Cts.Cancel(); } catch { /* ignore */ } + try { await old.Task.WaitAsync(TimeSpan.FromSeconds(10)); } + catch (OperationCanceledException) { } + catch (TimeoutException) { _logger.LogWarning("Host worker {Name} did not stop within 10s", name); } + catch (Exception ex) { _logger.LogError(ex, "Host worker {Name} faulted during shutdown", name); } + try { old.Cts.Dispose(); } catch { /* ignore */ } + } + + if (appStopping.IsCancellationRequested) + return; + + switch (workerName) + { + case FailedWorkerName: + StartHostWorker(FailedWorkerName, RunFailedLoopAsync, appStopping); + break; + case RecheckWorkerName: + StartHostWorker(RecheckWorkerName, RunRecheckLoopAsync, appStopping); + break; + case FreeSpaceWorkerName: + if (_config.Settings.AutoPauseResume && FreeSpaceParser.ParseFreeSpaceString(_config.Settings.FreeSpace) > 0) + StartHostWorker(FreeSpaceWorkerName, RunFreeSpaceLoopAsync, appStopping); + break; + case TrackerSortWorkerName: + if (GlobalSortTorrentsEnabled(_config)) + StartHostWorker(TrackerSortWorkerName, RunTrackerSortLoopAsync, appStopping); + break; + } + } + + private void StartHostWorker(string name, Func loop, CancellationToken appStopping) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(appStopping); + var task = Task.Run(async () => + { + using (LogContext.PushProperty("ProcessInstance", name)) + using (LogContext.PushProperty("ProcessType", "HostSubprocess")) + await loop(cts.Token); + }, CancellationToken.None); + _workers[name] = (task, cts); + } + + private async Task StopAllHostWorkersAsync() + { + var snapshot = _workers.ToArray(); + foreach (var (_, (_, cts)) in snapshot) + { + try { cts.Cancel(); } catch { /* ignore */ } + } + foreach (var (name, (task, cts)) in snapshot) + { + try { await task.WaitAsync(TimeSpan.FromSeconds(10)); } + catch (OperationCanceledException) { } + catch (TimeoutException) { _logger.LogWarning("Host worker {Name} did not stop within 10s during shutdown", name); } + catch (Exception ex) { _logger.LogError(ex, "Host worker {Name} faulted during shutdown", name); } + try { cts.Dispose(); } catch { /* ignore */ } + } + _workers.Clear(); + } + + private Task RunFailedLoopAsync(CancellationToken ct) => + RunCategoryLoopAsync( + FailedWorkerName, + _config.Settings.FailedCategory, + "[{Instance}] Deleting failed torrent: {Name}", + LogLevel.Warning, + "[{Instance}] Error processing failed category", + (client, hash, token) => client.DeleteTorrentsAsync(new List { hash }, deleteFiles: true, token), + ct); + + private Task RunRecheckLoopAsync(CancellationToken ct) => + RunCategoryLoopAsync( + RecheckWorkerName, + _config.Settings.RecheckCategory, + "[{Instance}] Re-checking torrent: {Name}", + LogLevel.Information, + "[{Instance}] Error processing recheck category", + (client, hash, token) => client.RecheckTorrentsAsync(new List { hash }, token), + ct); + + private async Task RunCategoryLoopAsync( + string workerName, + string category, + string actionLogTemplate, + LogLevel actionLogLevel, + string categoryErrorLogTemplate, + Func processTorrentAsync, + CancellationToken ct) + { + _stateManager.Update(workerName, s => { s.Alive = true; s.Rebuilding = false; }); + var consecutiveErrors = 0; + try + { + while (!ct.IsCancellationRequested) + { + var loopStart = DateTime.UtcNow; + try + { + if (_qbitManager.GetAllClients().Count == 0) + { + await Task.Delay(TimeSpan.FromSeconds(_config.Settings.LoopSleepTimer), ct); + continue; + } + + var totalCount = 0; + foreach (var (instanceName, client) in _qbitManager.GetAllClients()) + { + try + { + var categoryTorrents = await client.GetTorrentsAsync(category, ct); + totalCount += categoryTorrents.Count; + foreach (var torrent in categoryTorrents) + { + if (torrent.AddedOn > 0) + { + var addedAt = DateTimeOffset.FromUnixTimeSeconds(torrent.AddedOn).UtcDateTime; + if ((DateTime.UtcNow - addedAt).TotalSeconds < _config.Settings.IgnoreTorrentsYoungerThan) + continue; + } + if (actionLogLevel == LogLevel.Warning) + _logger.LogWarning(actionLogTemplate, instanceName, torrent.Name); + else + _logger.LogInformation(actionLogTemplate, instanceName, torrent.Name); + await processTorrentAsync(client, torrent.Hash, ct); + } + } + catch (Exception ex) + { + _logger.LogError(ex, categoryErrorLogTemplate, instanceName); + } + } + _stateManager.Update(workerName, s => { s.CategoryCount = totalCount; s.Alive = true; }); + consecutiveErrors = 0; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + consecutiveErrors++; + var backoffMinutes = Math.Min(2.0 * Math.Pow(1.5, consecutiveErrors), 30.0); + _logger.LogError(ex, "Host worker {Name} loop error #{Count} — backing off {Minutes:F1} min", + workerName, consecutiveErrors, backoffMinutes); + try { await Task.Delay(TimeSpan.FromMinutes(backoffMinutes), ct); } + catch (OperationCanceledException) { break; } + continue; + } + + var elapsed = (int)(DateTime.UtcNow - loopStart).TotalMilliseconds; + var sleepMs = Math.Max(0, _config.Settings.LoopSleepTimer * 1000 - elapsed); + if (sleepMs > 0) + { + try { await Task.Delay(sleepMs, ct); } + catch (OperationCanceledException) { break; } + } + } + } + finally + { + _stateManager.Update(workerName, s => { s.Alive = false; s.Rebuilding = false; }); + } + } + + private async Task RunFreeSpaceLoopAsync(CancellationToken ct) + { + _stateManager.Update(FreeSpaceWorkerName, s => { s.Alive = true; s.Rebuilding = false; }); + var consecutiveErrors = 0; + try + { + while (!ct.IsCancellationRequested) + { + var loopStart = DateTime.UtcNow; + try + { + if (_qbitManager.GetAllClients().Count == 0) + { + await Task.Delay(TimeSpan.FromSeconds(_config.Settings.LoopSleepTimer), ct); + continue; + } + + using var scope = _scopeFactory.CreateScope(); + var freeSpace = scope.ServiceProvider.GetRequiredService(); + var result = await freeSpace.ProcessGlobalManagedCategoriesHostPassAsync(ct); + _stateManager.Update(FreeSpaceWorkerName, s => + { + s.CategoryCount = result.PausedTorrentCount; + s.MetricType = "free-space"; + s.Alive = result.ManagerAlive; + }); + consecutiveErrors = 0; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + consecutiveErrors++; + var backoffMinutes = Math.Min(2.0 * Math.Pow(1.5, consecutiveErrors), 30.0); + _logger.LogError(ex, "Host worker {Name} loop error #{Count} — backing off {Minutes:F1} min", + FreeSpaceWorkerName, consecutiveErrors, backoffMinutes); + try { await Task.Delay(TimeSpan.FromMinutes(backoffMinutes), ct); } + catch (OperationCanceledException) { break; } + continue; + } + + var elapsed = (int)(DateTime.UtcNow - loopStart).TotalMilliseconds; + var sleepMs = Math.Max(0, _config.Settings.LoopSleepTimer * 1000 - elapsed); + if (sleepMs > 0) + { + try { await Task.Delay(sleepMs, ct); } + catch (OperationCanceledException) { break; } + } + } + } + finally + { + _stateManager.Update(FreeSpaceWorkerName, s => { s.Alive = false; s.Rebuilding = false; }); + } + } + + private async Task RunTrackerSortLoopAsync(CancellationToken ct) + { + _stateManager.Update(TrackerSortWorkerName, s => { s.Alive = true; s.Rebuilding = false; }); + var consecutiveErrors = 0; + var minimumSortInterval = TimeSpan.FromSeconds(Math.Max(1, _config.Settings.LoopSleepTimer)); + try + { + while (!ct.IsCancellationRequested) + { + var loopStart = DateTime.UtcNow; + try + { + if (_qbitManager.GetAllClients().Count == 0) + { + await Task.Delay(TimeSpan.FromSeconds(_config.Settings.LoopSleepTimer), ct); + continue; + } + + if (!GlobalSortTorrentsEnabled(_config)) + { + await Task.Delay(TimeSpan.FromSeconds(_config.Settings.LoopSleepTimer), ct); + continue; + } + + var lastTicks = System.Threading.Interlocked.Read(ref _lastTrackerSortTicksUtc); + if (DateTime.UtcNow - new DateTime(lastTicks, DateTimeKind.Utc) < minimumSortInterval) + { + var waitMs = Math.Max(0, (int)(minimumSortInterval - (DateTime.UtcNow - new DateTime(lastTicks, DateTimeKind.Utc))).TotalMilliseconds); + if (waitMs > 0) + { + try { await Task.Delay(waitMs, ct); } + catch (OperationCanceledException) { break; } + } + continue; + } + + using var scope = _scopeFactory.CreateScope(); + var sorter = scope.ServiceProvider.GetRequiredService(); + await sorter.SortTorrentQueuesByTrackerPriorityAsync(ct); + System.Threading.Interlocked.Exchange(ref _lastTrackerSortTicksUtc, DateTime.UtcNow.Ticks); + _stateManager.Update(TrackerSortWorkerName, s => { s.Alive = true; s.MetricType = "tracker-sort"; }); + consecutiveErrors = 0; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + consecutiveErrors++; + var backoffMinutes = Math.Min(2.0 * Math.Pow(1.5, consecutiveErrors), 30.0); + _logger.LogError(ex, "Host worker {Name} loop error #{Count} — backing off {Minutes:F1} min", + TrackerSortWorkerName, consecutiveErrors, backoffMinutes); + try { await Task.Delay(TimeSpan.FromMinutes(backoffMinutes), ct); } + catch (OperationCanceledException) { break; } + continue; + } + + var elapsed = (int)(DateTime.UtcNow - loopStart).TotalMilliseconds; + var sleepMs = Math.Max(0, _config.Settings.LoopSleepTimer * 1000 - elapsed); + if (sleepMs > 0) + { + try { await Task.Delay(sleepMs, ct); } + catch (OperationCanceledException) { break; } + } + } + } + finally + { + _stateManager.Update(TrackerSortWorkerName, s => { s.Alive = false; s.Rebuilding = false; }); + } + } +} diff --git a/src/Torrentarr.Infrastructure/Services/ProcessStateManager.cs b/src/Torrentarr.Infrastructure/Services/ProcessStateManager.cs index e460b23..0d0a291 100644 --- a/src/Torrentarr.Infrastructure/Services/ProcessStateManager.cs +++ b/src/Torrentarr.Infrastructure/Services/ProcessStateManager.cs @@ -23,7 +23,7 @@ public class ArrProcessState /// /// Singleton that tracks per-Arr-instance runtime state. Thread-safe. -/// Populated by ArrWorkerManager and by the Host's ProcessOrchestratorService (Recheck, Failed, FreeSpaceManager). +/// Populated by ArrWorkerManager and by (Failed, Recheck, FreeSpaceManager, TrackerSortManager). /// Read by /web/processes and /api/processes endpoints. /// public class ProcessStateManager diff --git a/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs b/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs index a19a68e..6134012 100644 --- a/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs +++ b/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs @@ -67,10 +67,10 @@ public async Task ProcessTorrentsAsync(string category, CancellationToken cancel return; } - // Skip special categories - they are handled globally by the Host orchestrator + // Skip special categories — handled by HostWorkerManager (Failed / Recheck loops) if (_specialCategories.Contains(category)) { - _logger.LogTrace("Skipping special category {Category} - handled by Host orchestrator", category); + _logger.LogTrace("Skipping special category {Category} - handled by HostWorkerManager", category); return; } diff --git a/src/Torrentarr.Infrastructure/Services/TrackerQueueSortService.cs b/src/Torrentarr.Infrastructure/Services/TrackerQueueSortService.cs new file mode 100644 index 0000000..3ca6194 --- /dev/null +++ b/src/Torrentarr.Infrastructure/Services/TrackerQueueSortService.cs @@ -0,0 +1,122 @@ +using Torrentarr.Core.Configuration; +using Torrentarr.Core.Models; +using Torrentarr.Core.Services; +using Torrentarr.Infrastructure.ApiClients.QBittorrent; +using Microsoft.Extensions.Logging; + +namespace Torrentarr.Infrastructure.Services; + +/// +/// Global tracker-priority queue ordering (single Host worker; no cross-process lock). +/// +public class TrackerQueueSortService : ITrackerQueueSortService +{ + private readonly ILogger _logger; + private readonly TorrentarrConfig _config; + private readonly QBittorrentConnectionManager _qbitManager; + private readonly ISeedingService _seedingService; + + public TrackerQueueSortService( + ILogger logger, + TorrentarrConfig config, + QBittorrentConnectionManager qbitManager, + ISeedingService seedingService) + { + _logger = logger; + _config = config; + _qbitManager = qbitManager; + _seedingService = seedingService; + } + + /// + public async Task SortTorrentQueuesByTrackerPriorityAsync(CancellationToken ct = default) + { + 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 managedCategories = _config.BuildManagedCategoriesSet(); + var allTorrents = new List(); + foreach (var (instanceName, client) in _qbitManager.GetAllClients()) + { + foreach (var category in managedCategories) + { + var torrents = await client.GetTorrentsAsync(category, ct); + foreach (var t in torrents) + t.QBitInstanceName = instanceName; + allTorrents.AddRange(torrents); + } + } + + if (allTorrents.Count == 0) + return; + + var sortableByInstance = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var torrent in allTorrents) + { + 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 + { + var ordered = TrackerQueueSortOrdering.BuildOrderedHashesForTopPriorityCalls(sortable); + + foreach (var hash in ordered) + await client.TopPriorityAsync(new List { hash }, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to sort torrent queue for qBit instance {Instance}", instanceName); + } + } + } + +} + +/// +/// Pure ordering for qBit topPrio calls: last hash in the returned list ends up at the front of the queue. +/// +internal static class TrackerQueueSortOrdering +{ + public static List BuildOrderedHashesForTopPriorityCalls( + IEnumerable<(TorrentInfo Torrent, int Priority)> sortable) + { + return sortable + .OrderByDescending(t => t.Priority) + .ThenBy(t => t.Torrent.AddedOn) + .Select(t => t.Torrent.Hash) + .Reverse() + .ToList(); + } +} diff --git a/src/Torrentarr.WebUI/Program.cs b/src/Torrentarr.WebUI/Program.cs index 3a36c3a..a62f55d 100644 --- a/src/Torrentarr.WebUI/Program.cs +++ b/src/Torrentarr.WebUI/Program.cs @@ -99,17 +99,6 @@ options.EnableForHttps = true; }); -// Add CORS for development -builder.Services.AddCors(options => -{ - options.AddPolicy("AllowAll", policy => - { - policy.AllowAnyOrigin() - .AllowAnyMethod() - .AllowAnyHeader(); - }); -}); - // Configure Database - use same dbPath as defined at startup builder.Services.AddDbContext(options => { @@ -137,6 +126,26 @@ Log.Information("Generated and persisted API token (Token was empty)"); } +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", policy => + { + if (configForDI.WebUI.CorsAllowedOrigins.Count > 0) + { + policy.WithOrigins(configForDI.WebUI.CorsAllowedOrigins.ToArray()) + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials(); + } + else + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + } + }); +}); + builder.Services.AddSingleton(configForDI); builder.Services.AddSingleton(); @@ -183,9 +192,6 @@ } // Configure the HTTP request pipeline -app.UseSwagger(); -app.UseSwaggerUI(); - app.UseResponseCompression(); app.UseCors("AllowAll"); @@ -302,6 +308,9 @@ static bool WebUIAuthRequired(TorrentarrConfig c) => !c.WebUI.AuthDisabled; +app.UseSwagger(); +app.UseSwaggerUI(); + app.MapControllers(); // Health check endpoint diff --git a/src/Torrentarr.Workers/Program.cs b/src/Torrentarr.Workers/Program.cs index 036c16f..c14cd25 100644 --- a/src/Torrentarr.Workers/Program.cs +++ b/src/Torrentarr.Workers/Program.cs @@ -342,10 +342,8 @@ private async Task ProcessTorrentsAsync(CancellationToken cancellationToken) var dbHealthService = scope.ServiceProvider.GetRequiredService(); var cacheService = scope.ServiceProvider.GetRequiredService(); - // NOTE: Free space management and special categories (failed, recheck) are handled - // GLOBALLY by the Host orchestrator - not per-worker. This matches qBitrr's design where: - // - FreeSpaceManager runs ONCE per qBittorrent instance, handling ALL categories - // - PlaceHolderArr handles special categories globally + // NOTE: Free space management, special categories (failed, recheck), and tracker queue sort + // are handled GLOBALLY by the Host's HostWorkerManager (fire-and-forget loops), not per Arr worker. // Clean expired cache entries cacheService.CleanExpired(); diff --git a/tests/Torrentarr.Core.Tests/Configuration/ConfigurationLoaderTests.cs b/tests/Torrentarr.Core.Tests/Configuration/ConfigurationLoaderTests.cs index 47930c6..e14b049 100644 --- a/tests/Torrentarr.Core.Tests/Configuration/ConfigurationLoaderTests.cs +++ b/tests/Torrentarr.Core.Tests/Configuration/ConfigurationLoaderTests.cs @@ -593,4 +593,102 @@ 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 Save_WritesQBitTrackerSortTorrents() + { + WriteToml(""" + [qBit] + Host = "localhost" + """); + + var loader = new ConfigurationLoader(_tempFilePath); + var config = loader.Load(); + config.QBitInstances["qBit"].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("[[qBit.Trackers]]"); + 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 index 9a72b0a..510f4c8 100644 --- a/tests/Torrentarr.Core.Tests/Configuration/TorrentarrConfigDefaultsTests.cs +++ b/tests/Torrentarr.Core.Tests/Configuration/TorrentarrConfigDefaultsTests.cs @@ -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(); diff --git a/tests/Torrentarr.Host.Tests/Api/ProcessesEndpointTests.cs b/tests/Torrentarr.Host.Tests/Api/ProcessesEndpointTests.cs index 868f5c5..7801bc7 100644 --- a/tests/Torrentarr.Host.Tests/Api/ProcessesEndpointTests.cs +++ b/tests/Torrentarr.Host.Tests/Api/ProcessesEndpointTests.cs @@ -47,6 +47,15 @@ public async Task GetProcesses_ReturnsOtherSectionEntries_WhenStatePrePopulated( Alive = true, CategoryCount = 0 }); + stateMgr.Initialize("TrackerSortManager", new ArrProcessState + { + Name = "TrackerSortManager", + Category = "TrackerSortManager", + Kind = "torrent", + MetricType = "tracker-sort", + Alive = true, + CategoryCount = null + }); var client = _factory.CreateClientWithApiToken(); var response = await client.GetAsync("/web/processes"); @@ -63,6 +72,7 @@ public async Task GetProcesses_ReturnsOtherSectionEntries_WhenStatePrePopulated( names.Should().Contain("Recheck"); names.Should().Contain("Failed"); names.Should().Contain("FreeSpaceManager"); + names.Should().Contain("TrackerSortManager"); } [Fact] diff --git a/tests/Torrentarr.Host.Tests/Api/TorrentarrWebApplicationFactory.cs b/tests/Torrentarr.Host.Tests/Api/TorrentarrWebApplicationFactory.cs index c01c1e0..53cf9c5 100644 --- a/tests/Torrentarr.Host.Tests/Api/TorrentarrWebApplicationFactory.cs +++ b/tests/Torrentarr.Host.Tests/Api/TorrentarrWebApplicationFactory.cs @@ -36,7 +36,7 @@ public class HostWebLocalAuthNoPasswordCollection : ICollectionFixture public class TorrentarrWebApplicationFactory : WebApplicationFactory, IDisposable { diff --git a/tests/Torrentarr.Infrastructure.Tests/ApiClients/QBittorrentClientLiveTests.cs b/tests/Torrentarr.Infrastructure.Tests/ApiClients/QBittorrentClientLiveTests.cs index 6556ed3..af341e3 100644 --- a/tests/Torrentarr.Infrastructure.Tests/ApiClients/QBittorrentClientLiveTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/ApiClients/QBittorrentClientLiveTests.cs @@ -98,4 +98,18 @@ public async Task GetTagsAsync_ReturnsList() var tags = await _client!.GetTagsAsync(); tags.Should().NotBeNull(); } + + [SkippableFact] + public async Task TopPriorityAsync_WithExistingTorrentHash_ReturnsTrue() + { + Skip.If(_skipReason != null, _skipReason!); + var torrents = await _client!.GetTorrentsAsync(); + Skip.If(torrents.Count == 0, "No torrents in qBit — skipping TopPriority live test."); + + var hash = torrents[0].Hash; + Skip.If(string.IsNullOrWhiteSpace(hash), "Torrent has no hash — skipping TopPriority live test."); + + var ok = await _client.TopPriorityAsync([hash]); + ok.Should().BeTrue("qBit topPrio API should accept a valid torrent hash"); + } } diff --git a/tests/Torrentarr.Infrastructure.Tests/ApiClients/QBittorrentClientTests.cs b/tests/Torrentarr.Infrastructure.Tests/ApiClients/QBittorrentClientTests.cs new file mode 100644 index 0000000..0e5a374 --- /dev/null +++ b/tests/Torrentarr.Infrastructure.Tests/ApiClients/QBittorrentClientTests.cs @@ -0,0 +1,21 @@ +using FluentAssertions; +using Torrentarr.Infrastructure.ApiClients.QBittorrent; +using Xunit; + +namespace Torrentarr.Infrastructure.Tests.ApiClients; + +/// +/// Unit tests for that do not require a running qBit instance. +/// +public class QBittorrentClientTests +{ + [Fact] + public async Task TopPriorityAsync_EmptyHashList_ReturnsTrueWithoutHttp() + { + var client = new QBittorrentClient("http://127.0.0.1", 8080, "", ""); + + var ok = await client.TopPriorityAsync([]); + + ok.Should().BeTrue(); + } +} diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/TrackerQueueSortOrderingTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/TrackerQueueSortOrderingTests.cs new file mode 100644 index 0000000..e787b31 --- /dev/null +++ b/tests/Torrentarr.Infrastructure.Tests/Services/TrackerQueueSortOrderingTests.cs @@ -0,0 +1,72 @@ +using FluentAssertions; +using Torrentarr.Core.Models; +using Torrentarr.Infrastructure.Services; +using Xunit; + +namespace Torrentarr.Infrastructure.Tests.Services; + +public class TrackerQueueSortOrderingTests +{ + [Fact] + public void BuildOrderedHashes_EmptyInput_ReturnsEmptyList() + { + var ordered = TrackerQueueSortOrdering.BuildOrderedHashesForTopPriorityCalls([]); + + ordered.Should().BeEmpty(); + } + + [Fact] + public void BuildOrderedHashes_SingleTorrent_ReturnsSingleHash() + { + var t = new TorrentInfo { Hash = "only", AddedOn = 42 }; + var sortable = new List<(TorrentInfo, int)> { (t, 7) }; + + var ordered = TrackerQueueSortOrdering.BuildOrderedHashesForTopPriorityCalls(sortable); + + ordered.Should().Equal("only"); + } + + [Fact] + public void BuildOrderedHashes_HigherPriorityCalledLastSoItEndsOnTop() + { + var low = new TorrentInfo { Hash = "low", AddedOn = 100 }; + var high = new TorrentInfo { Hash = "high", AddedOn = 200 }; + var sortable = new List<(TorrentInfo, int)> { (low, 1), (high, 10) }; + + var ordered = TrackerQueueSortOrdering.BuildOrderedHashesForTopPriorityCalls(sortable); + + // TopPrio is applied in sequence; last call wins → "high" must be last in the list. + ordered.Should().Equal("low", "high"); + } + + [Fact] + public void BuildOrderedHashes_SamePriority_UsesEarlierAddedOnFirst() + { + var a = new TorrentInfo { Hash = "a", AddedOn = 10 }; + var b = new TorrentInfo { Hash = "b", AddedOn = 20 }; + var sortable = new List<(TorrentInfo, int)> { (b, 5), (a, 5) }; + + var ordered = TrackerQueueSortOrdering.BuildOrderedHashesForTopPriorityCalls(sortable); + + ordered.Should().Equal("b", "a"); + } + + [Fact] + public void BuildOrderedHashes_ThreeDistinctPriorities_LowestPrioFirstHighestPrioLastInTopPrioSequence() + { + var low = new TorrentInfo { Hash = "low", AddedOn = 1 }; + var mid = new TorrentInfo { Hash = "mid", AddedOn = 2 }; + var high = new TorrentInfo { Hash = "high", AddedOn = 3 }; + var sortable = new List<(TorrentInfo, int)> + { + (mid, 5), + (low, 1), + (high, 10) + }; + + var ordered = TrackerQueueSortOrdering.BuildOrderedHashesForTopPriorityCalls(sortable); + + // Sequential topPrio: last call wins → highest priority hash must be last. + ordered.Should().Equal("low", "mid", "high"); + } +} diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/TrackerQueueSortServiceTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/TrackerQueueSortServiceTests.cs new file mode 100644 index 0000000..20f88f0 --- /dev/null +++ b/tests/Torrentarr.Infrastructure.Tests/Services/TrackerQueueSortServiceTests.cs @@ -0,0 +1,95 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Torrentarr.Core.Configuration; +using Torrentarr.Core.Models; +using Torrentarr.Core.Services; +using Torrentarr.Infrastructure.Services; +using Xunit; + +namespace Torrentarr.Infrastructure.Tests.Services; + +/// +/// Unit tests for branches that do not require a connected qBit client. +/// +public class TrackerQueueSortServiceTests +{ + private static TrackerQueueSortService CreateService( + TorrentarrConfig config, + Mock? seedingMock = null) + { + seedingMock ??= new Mock(); + var mgr = new QBittorrentConnectionManager(NullLogger.Instance); + return new TrackerQueueSortService( + NullLogger.Instance, + config, + mgr, + seedingMock.Object); + } + + [Fact] + public async Task SortTorrentQueuesByTrackerPriorityAsync_NoSortTorrentsAnywhere_DoesNotCallSeeding() + { + var config = new TorrentarrConfig(); + var seedingMock = new Mock(); + var svc = CreateService(config, seedingMock); + + await svc.SortTorrentQueuesByTrackerPriorityAsync(); + + seedingMock.Verify( + s => s.GetTrackerConfigAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task SortTorrentQueuesByTrackerPriorityAsync_SortTorrentsOnQBitTrackerButNoConnectedClients_CompletesWithoutCallingSeeding() + { + var config = new TorrentarrConfig + { + QBitInstances = + { + ["qBit"] = new QBitConfig + { + Trackers = [new TrackerConfig { SortTorrents = true }] + } + } + }; + var seedingMock = new Mock(); + var svc = CreateService(config, seedingMock); + + await FluentActions.Invoking(() => svc.SortTorrentQueuesByTrackerPriorityAsync()) + .Should().NotThrowAsync(); + + seedingMock.Verify( + s => s.GetTrackerConfigAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task SortTorrentQueuesByTrackerPriorityAsync_SortTorrentsOnArrTrackerButNoConnectedClients_CompletesWithoutCallingSeeding() + { + var config = new TorrentarrConfig + { + ArrInstances = + { + ["Radarr-Movies"] = new ArrInstanceConfig + { + Category = "movies", + Torrent = + { + Trackers = [new TrackerConfig { SortTorrents = true }] + } + } + } + }; + var seedingMock = new Mock(); + var svc = CreateService(config, seedingMock); + + await FluentActions.Invoking(() => svc.SortTorrentQueuesByTrackerPriorityAsync()) + .Should().NotThrowAsync(); + + seedingMock.Verify( + s => s.GetTrackerConfigAsync(It.IsAny(), It.IsAny()), + Times.Never); + } +} diff --git a/webui/src/__tests__/config/torrentHandlingSummary.test.ts b/webui/src/__tests__/config/torrentHandlingSummary.test.ts new file mode 100644 index 0000000..167fbfb --- /dev/null +++ b/webui/src/__tests__/config/torrentHandlingSummary.test.ts @@ -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."); + }); +}); diff --git a/webui/src/__tests__/pages/ProcessesView.test.tsx b/webui/src/__tests__/pages/ProcessesView.test.tsx index 45d23ac..912ef42 100644 --- a/webui/src/__tests__/pages/ProcessesView.test.tsx +++ b/webui/src/__tests__/pages/ProcessesView.test.tsx @@ -270,7 +270,7 @@ describe("ProcessesView – qBit category chips", () => { // ── Other section (Recheck, Failed, Free Space Manager) ─────────────────────── describe("ProcessesView – Other section", () => { - it("shows Other section with Recheck, Failed, and Free Space Manager cards", async () => { + it("shows Other section with host worker cards including Tracker Sort Manager", async () => { server.use( http.get("/web/processes", () => HttpResponse.json({ @@ -300,6 +300,15 @@ describe("ProcessesView – Other section", () => { alive: true, categoryCount: 0, }, + { + category: "TrackerSortManager", + name: "TrackerSortManager", + kind: "torrent", + metricType: "tracker-sort", + pid: null, + alive: true, + categoryCount: null, + }, ], }), ), @@ -317,6 +326,10 @@ describe("ProcessesView – Other section", () => { expect( screen.getAllByText("Free Space Manager").length, ).toBeGreaterThanOrEqual(1); + expect( + screen.getAllByText("Tracker Sort Manager").length, + ).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Tracker queue ordering")).toBeInTheDocument(); expect( screen.getAllByText("Torrent count 0").length, ).toBeGreaterThanOrEqual(2); diff --git a/webui/src/__tests__/pages/RadarrView.test.tsx b/webui/src/__tests__/pages/RadarrView.test.tsx index fbe6bdd..a2dfb91 100644 --- a/webui/src/__tests__/pages/RadarrView.test.tsx +++ b/webui/src/__tests__/pages/RadarrView.test.tsx @@ -185,8 +185,8 @@ describe("RadarrView – instance sidebar", () => { renderView(); - // Table columns always appear when movies are loaded - await screen.findByText("Title", {}, { timeout: 8000 }); + // Wait for returned movie row; this is more stable than asserting header text. + await screen.findByText("The Matrix", {}, { timeout: 8000 }); expect(screen.queryByText("No movies found.")).not.toBeInTheDocument(); }, 10000); }); diff --git a/webui/src/config/tooltips.ts b/webui/src/config/tooltips.ts index 788870d..e92b5e6 100644 --- a/webui/src/config/tooltips.ts +++ b/webui/src/config/tooltips.ts @@ -200,6 +200,8 @@ export const FIELD_TOOLTIPS: Record = { "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 index 20a6aeb..29910cb 100644 --- a/webui/src/config/torrentHandlingSummary.ts +++ b/webui/src/config/torrentHandlingSummary.ts @@ -245,6 +245,7 @@ export function getArrTorrentHandlingSummary( const t = raw as Record; 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 @@ export function getArrTorrentHandlingSummary( ); 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 @@ export function getQbitTorrentHandlingSummary( const t = raw as Record; 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 @@ export function getQbitTorrentHandlingSummary( ); 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 @@ export function getQbitTorrentHandlingSummary( ? `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 index eec1b65..50c370a 100644 --- a/webui/src/pages/ConfigView.tsx +++ b/webui/src/pages/ConfigView.tsx @@ -1147,6 +1147,11 @@ const ARR_TRACKER_FIELDS: FieldDefinition[] = [ return undefined; }, }, + { + label: "Sort Torrents", + path: ["SortTorrents"], + type: "checkbox", + }, { label: "Maximum ETA", path: ["MaxETA"], @@ -2634,20 +2639,31 @@ function AuthConfigModal({ + onChange={(e) => { + const next = e.target.checked; + if (next) { + const ok = window.confirm( + "Disabling authentication removes the login screen. Anyone who can reach this WebUI can change settings and control Torrentarr.\n\n" + + "If Torrentarr is exposed to the internet or untrusted networks, use a reverse proxy with authentication, a VPN, or firewall rules—or keep authentication enabled.\n\n" + + "Disable authentication anyway?", + ); + if (!ok) return; + } onChange( ["WebUI", "AuthDisabled"], {} as FieldDefinition, - e.target.checked, - ) - } + next, + ); + }} />{" "} Disable authentication {authDisabled && (

- The login screen is skipped. The API token below still works for - script/API access. + The login screen is skipped. The API token below still protects{" "} + /api/* for scripts. If this server is reachable + from the internet, add network-level protection or re-enable + authentication.

)} @@ -3187,6 +3203,7 @@ function FieldGroup({ Name: "", Uri: "", Priority: 0, + SortTorrents: false, RemoveIfExists: false, SuperSeedMode: false, AddTags: [], diff --git a/webui/src/pages/ProcessesView.tsx b/webui/src/pages/ProcessesView.tsx index 36d0af0..59da6f0 100644 --- a/webui/src/pages/ProcessesView.tsx +++ b/webui/src/pages/ProcessesView.tsx @@ -29,8 +29,24 @@ const RELEASE_TOKEN_REGEX = const EPISODE_TOKEN_REGEX = /\bS\d{1,3}E\d{1,3}\b/i; const SEASON_TOKEN_REGEX = /\bSeason\s+\d+\b/i; -/** Other section entries that are display-only (no restart). */ -const OTHER_DISPLAY_ONLY_NAMES = ["Recheck", "Failed", "FreeSpaceManager"]; +/** Host subprocess rows (Failed / Recheck / free space / tracker sort) — friendly card title, restart supported. */ +const HOST_SECTION_NAMES = [ + "Recheck", + "Failed", + "FreeSpaceManager", + "TrackerSortManager", +]; + +function hostSectionDisplayName(sectionName: string): string { + switch (sectionName) { + case "FreeSpaceManager": + return "Free Space Manager"; + case "TrackerSortManager": + return "Tracker Sort Manager"; + default: + return sectionName; + } +} function sanitizeSearchSummary(raw: string): string { const trimmed = raw.trim(); @@ -388,11 +404,12 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { : `${runningCount}/${totalCount} running`; const summaryLabel = totalCount === 1 ? "1 process" : `${totalCount} processes`; - const displayName = - name === "FreeSpaceManager" ? "Free Space Manager" : name; - const isOtherDisplayOnly = items.every((item) => - OTHER_DISPLAY_ONLY_NAMES.includes(item.name), + const isHostSection = items.every((item) => + HOST_SECTION_NAMES.includes(item.name), ); + const groupDisplayName = isHostSection + ? hostSectionDisplayName(name) + : name; const uniqueKinds = Array.from(new Set(items.map((item) => item.kind))); const filteredKinds = uniqueKinds.filter((kind) => { const lower = kind.toLowerCase(); @@ -405,7 +422,7 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element {
-
{displayName}
+
{groupDisplayName}
{summaryLabel}
{filteredKinds.length ? (
@@ -430,7 +447,9 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { >
- {isOtherDisplayOnly ? displayName : formatKind(item.kind)} + {isHostSection + ? hostSectionDisplayName(item.name) + : formatKind(item.kind)}
- {!isOtherDisplayOnly && ( -
- -
- )} +
+ +
))} {instanceCategories.map((cat) => { @@ -539,16 +560,14 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { ); })}
- {!isOtherDisplayOnly && ( -
- -
- )} +
+ +
); }); diff --git a/webui/src/styles.css b/webui/src/styles.css index c0157c9..438edca 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -222,7 +222,8 @@ body { flex-wrap: wrap; width: 100%; } -.nav button { +.nav button, +.nav a.nav-api-docs { color: var(--muted); background: transparent; border: 1px solid var(--border); @@ -236,9 +237,13 @@ body { transition: all 0.15s ease; position: relative; overflow: hidden; + text-decoration: none; + box-sizing: border-box; + font: inherit; } -.nav button::before { +.nav button::before, +.nav a.nav-api-docs::before { content: ""; position: absolute; top: 0; @@ -250,13 +255,15 @@ body { transition: opacity 0.15s ease; } -.nav button .icon { +.nav button .icon, +.nav a.nav-api-docs .icon { width: 1.4em; height: 1.4em; transition: transform 0.15s ease; } -.nav button:hover .icon { +.nav button:hover .icon, +.nav a.nav-api-docs:hover .icon { transform: scale(1.1); } @@ -276,14 +283,16 @@ body { 0 2px 8px rgba(0, 113, 227, 0.2); } -.nav button:hover:not(.active) { +.nav button:hover:not(.active), +.nav a.nav-api-docs:hover { color: var(--on-surface); border-color: rgba(122, 162, 247, 0.4); background: rgba(122, 162, 247, 0.06); transform: translateY(-1px); } -.nav button:hover::before { +.nav button:hover::before, +.nav a.nav-api-docs:hover::before { opacity: 1; } @@ -478,12 +487,14 @@ body { box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.3); } -.nav button:focus-visible { +.nav button:focus-visible, +.nav a.nav-api-docs:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(122, 162, 247, 0.25); } -[data-theme="light"] .nav button:focus-visible { +[data-theme="light"] .nav button:focus-visible, +[data-theme="light"] .nav a.nav-api-docs:focus-visible { box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.25); } .btn:disabled { diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 47ca2da..16af1cd 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ proxy: { "/api": "http://localhost:6969", "/web": "http://localhost:6969", + "/swagger": "http://localhost:6969", }, }, });