From 064fad6c40b968fd2dd915ce3efbc90725e1b964 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 15 Jun 2026 15:01:26 +0000 Subject: [PATCH 1/7] Close qBitrr parity gaps: category workers, MatchSubcategories, retries Implement gap-report items from qBitrr 5.12.3 comparison: - Add CategoryOwnershipHelper and MatchSubcategories config (qBit + per-Arr) - Add QBitCategoryWorkerManager for qBit-only ManagedCategories (PlaceHolderArr parity) - Wire ApplySeedingLimitsAsync, RemoveCompletedTorrentsAsync, import path tracking - Add post-import empty-folder cleanup via ImportPathTracker - Auto-create qBittorrent categories on all instances (QBitCategoryEnsureService) - Add Polly HTTP retries for Arr/qBit clients (HttpRetryHelper) - Enforce ProfileSwitchRetryAttempts in QualityProfileSwitcherService - Add PeriodicWalCheckpointService (5-minute interval on Host) - Fix DatabaseHealthService.RepairAsync using SQLite backup API - Add DatabaseRetryExtensions for transient SQLite errors - Restart Arr workers on config reload (full/multi_arr/single_arr) - Register ConfigReloader on Host; pre-create tracker tags at worker start - Add CategoryOwnershipHelperTests; update full-parity-matrix.md Co-authored-by: Feramance --- docs/parity/full-parity-matrix.md | 19 +- .../Configuration/CategoryOwnershipHelper.cs | 185 ++++++++++++++++++ .../Configuration/ConfigurationLoader.cs | 15 ++ .../Configuration/TorrentarrConfig.cs | 4 + .../Services/IImportPathTracker.cs | 13 ++ src/Torrentarr.Host/Program.cs | 42 +++- .../ApiClients/Arr/ArrClientResponse.cs | 4 + .../ApiClients/Arr/LidarrClient.cs | 38 ++-- .../ApiClients/Arr/RadarrClient.cs | 36 ++-- .../ApiClients/Arr/SonarrClient.cs | 36 ++-- .../QBittorrent/QBittorrentClient.cs | 64 +++--- .../Database/DatabaseRetryExtensions.cs | 62 ++++++ .../Http/HttpRetryHelper.cs | 55 ++++++ .../Services/ArrWorkerManager.cs | 23 +++ .../Services/DatabaseHealthService.cs | 51 +++-- .../Services/ImportPathTracker.cs | 83 ++++++++ .../Services/PeriodicWalCheckpointService.cs | 48 +++++ .../Services/QBitCategoryEnsureService.cs | 95 +++++++++ .../Services/QBitCategoryWorkerManager.cs | 152 ++++++++++++++ .../Services/QualityProfileSwitcherService.cs | 116 +++++++---- .../Services/SeedingService.cs | 41 ++++ .../Services/TorrentProcessor.cs | 61 ++++-- .../CategoryOwnershipHelperTests.cs | 75 +++++++ 23 files changed, 1154 insertions(+), 164 deletions(-) create mode 100644 src/Torrentarr.Core/Configuration/CategoryOwnershipHelper.cs create mode 100644 src/Torrentarr.Core/Services/IImportPathTracker.cs create mode 100644 src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs create mode 100644 src/Torrentarr.Infrastructure/Http/HttpRetryHelper.cs create mode 100644 src/Torrentarr.Infrastructure/Services/ImportPathTracker.cs create mode 100644 src/Torrentarr.Infrastructure/Services/PeriodicWalCheckpointService.cs create mode 100644 src/Torrentarr.Infrastructure/Services/QBitCategoryEnsureService.cs create mode 100644 src/Torrentarr.Infrastructure/Services/QBitCategoryWorkerManager.cs create mode 100644 tests/Torrentarr.Core.Tests/Configuration/CategoryOwnershipHelperTests.cs diff --git a/docs/parity/full-parity-matrix.md b/docs/parity/full-parity-matrix.md index 23617400..6b63b442 100644 --- a/docs/parity/full-parity-matrix.md +++ b/docs/parity/full-parity-matrix.md @@ -20,30 +20,30 @@ Status values: | qBitrr file | Torrentarr equivalent | Status | Required actions | | --- | --- | --- | --- | | `qBitrr/__init__.py` | `src/Torrentarr.Host/Program.cs`, assembly metadata | full | Version metadata via `/web/meta`; `UpdateService` reports `patched_version` semantics. | -| `qBitrr/main.py` | `src/Torrentarr.Host/Program.cs`, `ArrWorkerManager.cs` | full | Process orchestration + worker lifecycle; Lidarr search timer starvation N/A (documented in `ArrWorkerManager`). | -| `qBitrr/arss.py` | `TorrentPolicyHelper`, Host policy passes, worker services | full | **Evidence:** [`TorrentPolicyHelperTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Core.Tests/Configuration/TorrentPolicyHelperTests.cs), [contributor-reference policy matrix](contributor-reference.md#policy-engine-test-matrix). | -| `qBitrr/qbit_category_manager.py` | `SeedingService.cs`, `CategoryPathHelper` | full | **Evidence:** [`SeedingServiceTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Infrastructure.Tests/Services/SeedingServiceTests.cs) (HnR dead-tracker #412), subcategory matching in Host qBit categories. | +| `qBitrr/main.py` | `src/Torrentarr.Host/Program.cs`, `ArrWorkerManager.cs`, `QBitCategoryWorkerManager.cs`, `PeriodicWalCheckpointService.cs` | full | Process orchestration + Arr/qBit-only category workers; 5-minute WAL checkpoint; config reload restarts workers. | +| `qBitrr/arss.py` | `TorrentPolicyHelper`, `CategoryOwnershipHelper`, `TorrentProcessor`, worker services | full | **Evidence:** [`TorrentPolicyHelperTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Core.Tests/Configuration/TorrentPolicyHelperTests.cs), [`CategoryOwnershipHelperTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Core.Tests/Configuration/CategoryOwnershipHelperTests.cs), [contributor-reference policy matrix](contributor-reference.md#policy-engine-test-matrix). | +| `qBitrr/qbit_category_manager.py` | `QBitCategoryWorkerManager.cs`, `SeedingService.cs`, `CategoryOwnershipHelper.cs` | full | **Evidence:** qBit-only `ManagedCategories` workers; `MatchSubcategories`; rate limits via `ApplySeedingLimitsAsync`; [`CategoryOwnershipHelperTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Core.Tests/Configuration/CategoryOwnershipHelperTests.cs). | | `qBitrr/arr_tracker_index.py` | `SeedingService.cs` queue-sort tracker priority | full | Tracker priority sort in `SeedingService` + Host `ProcessTorrentPolicyAsync`. | -| `qBitrr/config.py` | `TorrentarrConfig.cs`, `ConfigurationLoader.cs` | full | Key-by-key TOML parity; `UrlBase`, `BehindHttpsProxy`, env aliases. | +| `qBitrr/config.py` | `TorrentarrConfig.cs`, `ConfigurationLoader.cs` | full | Key-by-key TOML parity including `MatchSubcategories`; `UrlBase`, `BehindHttpsProxy`, env aliases; hot reload restarts workers on Host. | | `qBitrr/gen_config.py` | `ConfigurationLoader.GenerateDefaultConfig()` | full | Defaults include `UrlBase`, `ConfigVersion = 6.12.2`. | | `qBitrr/config_version.py` | `ConfigurationLoader.ValidateConfigVersion()` | full | `ExpectedConfigVersion = 6.12.2`; migration on load. | | `qBitrr/env_config.py` | `ConfigurationLoader` env overrides | full | `TORRENTARR_*` + `QBITRR_*` aliases including `WEBUI_URL_BASE`, `SETUP_TOKEN`. | | `qBitrr/duration_config.py` | `DurationParser.cs` | full | **Evidence:** [`DurationParserTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Core.Tests/Configuration/DurationParserTests.cs). | | `qBitrr/database.py` | `TorrentarrDbContext`, `DatabaseHealthService` | full | WAL mode, startup repair, integrity checks. | | `qBitrr/tables.py` | EF models, `TorrentarrDbContext` | full | **Evidence:** [`SchemaParityTests.cs`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Infrastructure.Tests/Database/SchemaParityTests.cs). | -| `qBitrr/db_lock.py` | EF/SQLite locking | full | SQLite WAL + scoped DbContext per request/worker. | -| `qBitrr/db_recovery.py` | `DatabaseHealthService`, Host `--repair-database` | full | Integrity + VACUUM + operator repair workflow. | +| `qBitrr/db_lock.py` | EF/SQLite locking, `DatabaseRetryExtensions.cs` | partial | `SaveChangesWithRetryAsync` for transient errors; no cross-process file lock (WAL + scoped DbContext). Coordinated worker restart after repair not ported. | +| `qBitrr/db_recovery.py` | `DatabaseHealthService`, Host `--repair-database`, `PeriodicWalCheckpointService` | full | Integrity + VACUUM + `RepairAsync` via SQLite backup; periodic WAL checkpoint every 5 minutes on Host. | | `qBitrr/search_activity_store.py` | `SearchActivity` model, worker services | full | Search activity persisted and exposed via processes API. | -| `qBitrr/webui.py` | Host/WebUI `Program.cs`, `webui/src` | full | **Evidence:** UrlBase end-to-end, auth bootstrap, catalog rollups, Lidarr artists/thumbnails, [`SetPasswordEndpointTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Host.Tests/Api/SetPasswordEndpointTests.cs), [openapi.json](../assets/openapi.json) + CI drift check. | +| `qBitrr/webui.py` | Host/WebUI `Program.cs`, `webui/src` | partial | Routes implemented on Host; OpenAPI documents 26/66 paths — expand `docs/assets/openapi.json` for full contract parity. | | `qBitrr/auto_update.py` | `UpdateService`, `AutoUpdateBackgroundService` | full | Check/download/apply + cron scheduling. | -| `qBitrr/pyarr_compat.py` | `ApiClients/Arr/*.cs` | full | Arr API clients with normalized responses. | +| `qBitrr/pyarr_compat.py` | `ApiClients/Arr/*.cs`, `HttpRetryHelper.cs` | full | Arr API clients with normalized responses and retry policies. | | `qBitrr/ffprobe.py` | `MediaValidationService.cs` | full | ffprobe validation integration. | | `qBitrr/versioning.py` | Host metadata + `UpdateService` | full | `/web/meta`, release check caching. | | `qBitrr/bundled_data.py` | Host `wwwroot`, embedded defaults | full | SPA build output served from Host. | | `qBitrr/home_path.py` | `ConfigurationLoader.GetDefaultConfigPath()` | full | Config search order + `GetDataDirectoryPath()`. | | `qBitrr/logger.py` | Serilog in Host/WebUI/Workers | full | Structured logging with process metadata. | | `qBitrr/errors.py` | Exception types across projects | full | HTTP error contracts on API endpoints. | -| `qBitrr/utils.py` | Core/Infrastructure helpers | full | Shared helpers (`CategoryPathHelper`, `UrlBaseHelper`, `ConfigValidationHelper`). | +| `qBitrr/utils.py` | Core/Infrastructure helpers, `HttpRetryHelper.cs` | full | `with_retry` parity on Arr/qBit HTTP; helpers (`CategoryPathHelper`, `CategoryOwnershipHelper`, `UrlBaseHelper`, `ConfigValidationHelper`). | | `qBitrr/catalog_rollups.py` (5.12.0) | `CatalogRollupService.cs` | full | **Evidence:** [`CatalogRollupServiceTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Infrastructure.Tests/Services/CatalogRollupServiceTests.cs); wired into `/web|api/arr`, Radarr/Sonarr/Lidarr list endpoints. | | `qBitrr/category_paths.py` (5.12.0) | `CategoryPathHelper.cs`, `ConfigValidationHelper.cs` | full | **Evidence:** [`CategoryPathHelperTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Core.Tests/Configuration/CategoryPathHelperTests.cs), [`ConfigValidationHelperTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Core.Tests/Configuration/ConfigValidationHelperTests.cs); wired into torrent/category matching + config save validation. | @@ -67,3 +67,4 @@ Status values: - **Lidarr artists + thumbnails (5.12.0):** `ArrCatalogEndpoints` + `ArrThumbnailService` + frontend API client. - **OpenAPI drift guard:** `scripts/check-openapi-drift.sh` in CI vs qBitrr `5.12.3`. - **Config schema:** Torrentarr `6.12.2` (+1 major vs qBitrr `5.12.2`). +- **Gap closeout (2026-06):** `MatchSubcategories`, qBit-only category workers, import path tracking, folder cleanup, category auto-creation, seeding rate limits, HTTP/DB retry, profile-switch retries, periodic WAL checkpoint, config-reload worker restart. diff --git a/src/Torrentarr.Core/Configuration/CategoryOwnershipHelper.cs b/src/Torrentarr.Core/Configuration/CategoryOwnershipHelper.cs new file mode 100644 index 00000000..fabdeb97 --- /dev/null +++ b/src/Torrentarr.Core/Configuration/CategoryOwnershipHelper.cs @@ -0,0 +1,185 @@ +using Torrentarr.Core.Models; + +namespace Torrentarr.Core.Configuration; + +/// +/// qBitrr ArrManager.resolve_owning_category and managed-object registry parity. +/// +public static class CategoryOwnershipHelper +{ + /// All category keys with active torrent processing (Arr categories + qBit-only managed). + public static HashSet BuildManagedObjectKeys(TorrentarrConfig config) + { + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var a in config.ArrInstances.Values) + { + var cat = CategoryPathHelper.NormalizeCategory(a.Category); + if (!string.IsNullOrEmpty(cat)) + keys.Add(cat); + } + + foreach (var cat in GetQBitOnlyManagedCategories(config)) + keys.Add(cat); + + var failed = CategoryPathHelper.NormalizeCategory(config.Settings.FailedCategory); + var recheck = CategoryPathHelper.NormalizeCategory(config.Settings.RecheckCategory); + if (!string.IsNullOrEmpty(failed)) keys.Add(failed); + if (!string.IsNullOrEmpty(recheck)) keys.Add(recheck); + + return keys; + } + + /// ManagedCategories not already owned by an Arr instance category. + public static List GetQBitOnlyManagedCategories(TorrentarrConfig config) + { + var arrCategories = config.ArrInstances.Values + .Select(a => CategoryPathHelper.NormalizeCategory(a.Category)) + .Where(c => !string.IsNullOrEmpty(c)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var result = new List(); + foreach (var (_, qbit) in config.QBitInstances) + { + foreach (var raw in qbit.ManagedCategories) + { + var norm = CategoryPathHelper.NormalizeCategory(raw); + if (string.IsNullOrEmpty(norm)) + continue; + if (!arrCategories.Contains(norm)) + result.Add(norm); + } + } + + return result.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + /// Find Arr section name for a category owner key. + public static string? FindArrSectionForCategory(TorrentarrConfig config, string ownerCategory) + { + foreach (var (name, arr) in config.ArrInstances) + { + if (CategoryPathHelper.CategoryEquals(arr.Category, ownerCategory)) + return name; + } + return null; + } + + public static bool QBitMatchSubcategories(TorrentarrConfig config, string qbitSection) + { + if (config.QBitInstances.TryGetValue(qbitSection, out var qbit)) + return qbit.MatchSubcategories; + return false; + } + + /// + /// Per-Arr override when set; otherwise inherits from the qBit instance default. + /// + public static bool ArrMatchSubcategoriesEffective( + TorrentarrConfig config, + string arrSectionName, + string qbitSection) + { + if (config.ArrInstances.TryGetValue(arrSectionName, out var arr) + && arr.MatchSubcategories.HasValue) + return arr.MatchSubcategories.Value; + + return QBitMatchSubcategories(config, qbitSection); + } + + /// + /// Whether prefix/subcategory matching is enabled for . + /// + public static bool PrefixMatchAllowedForOwner( + TorrentarrConfig config, + string ownerCategory, + string? qbitSection = null) + { + var norm = CategoryPathHelper.NormalizeCategory(ownerCategory); + if (string.IsNullOrEmpty(norm)) + return false; + + foreach (var (arrName, arr) in config.ArrInstances) + { + if (!CategoryPathHelper.CategoryEquals(arr.Category, norm)) + continue; + var section = qbitSection ?? "qBit"; + return ArrMatchSubcategoriesEffective(config, arrName, section); + } + + if (config.ArrInstances.Values.Any(a => + CategoryPathHelper.CategoryEquals(a.Category, norm))) + return false; + + if (qbitSection != null) + return QBitMatchSubcategories(config, qbitSection); + + return config.QBitInstances.Values.Any(q => q.MatchSubcategories); + } + + /// + /// Return the managed-object key that owns (or null). + /// + public static string? ResolveOwningCategory( + TorrentarrConfig config, + string? torrentCategory, + string? qbitSection = null) + { + if (string.IsNullOrWhiteSpace(torrentCategory)) + return null; + + var norm = CategoryPathHelper.NormalizeCategory(torrentCategory); + if (string.IsNullOrEmpty(norm)) + return null; + + var managed = BuildManagedObjectKeys(config); + if (managed.Contains(norm)) + return norm; + + var eligible = managed + .Where(k => PrefixMatchAllowedForOwner(config, k, qbitSection)) + .ToList(); + + if (eligible.Count == 0) + return null; + + var match = CategoryPathHelper.MatchesConfigured(norm, eligible, prefix: true); + return match != null && managed.Contains(match) ? match : null; + } + + /// + /// Gather torrents for an owner category across all qBit clients (MatchSubcategories-aware). + /// + public static async Task> GatherTorrentsForOwnerAsync( + TorrentarrConfig config, + string ownerCategory, + IReadOnlyDictionary>>> fetchAllByInstance, + IReadOnlyDictionary>>> fetchByCategory, + CancellationToken ct = default) + { + var target = CategoryPathHelper.NormalizeCategory(ownerCategory); + var results = new List(); + + foreach (var (instanceName, fetchAll) in fetchAllByInstance) + { + var usePrefix = PrefixMatchAllowedForOwner(config, target, instanceName); + List torrents; + if (usePrefix) + { + torrents = await fetchAll(ct); + torrents = torrents + .Where(t => ResolveOwningCategory(config, t.Category, instanceName) == target) + .ToList(); + } + else + { + torrents = await fetchByCategory[instanceName](target, ct); + } + + foreach (var t in torrents) + t.QBitInstanceName = instanceName; + results.AddRange(torrents); + } + + return results; + } +} diff --git a/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs b/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs index 618637f6..ff5aecfc 100644 --- a/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs +++ b/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs @@ -526,6 +526,12 @@ private static bool MigrateQBitCategorySettings(TomlTable root) changed = true; } + if (!qbitTable.ContainsKey("MatchSubcategories")) + { + qbitTable["MatchSubcategories"] = false; + changed = true; + } + if (!qbitTable.ContainsKey("CategorySeeding")) { var seeding = new TomlTable @@ -1020,6 +1026,9 @@ private QBitConfig ParseQBit(TomlTable table) if (table.TryGetValue("ManagedCategories", out var categories) && categories is TomlArray catArray) qbit.ManagedCategories = catArray.Select(x => x?.ToString() ?? "").ToList(); + if (table.TryGetValue("MatchSubcategories", out var matchSub)) + qbit.MatchSubcategories = Convert.ToBoolean(matchSub); + if (table.TryGetValue("CategorySeeding", out var seedingObj) && seedingObj is TomlTable seedingTable) qbit.CategorySeeding = ParseCategorySeeding(seedingTable); @@ -1304,6 +1313,9 @@ private Dictionary ParseArrInstances(TomlTable rootTa if (instanceTable.TryGetValue("ProcessingOnly", out var procOnly)) instance.ProcessingOnly = Convert.ToBoolean(procOnly); + if (instanceTable.TryGetValue("MatchSubcategories", out var matchSub)) + instance.MatchSubcategories = Convert.ToBoolean(matchSub); + if (instanceTable.TryGetValue("ReSearch", out var reSearch)) instance.ReSearch = Convert.ToBoolean(reSearch); @@ -1772,6 +1784,7 @@ private string GenerateTomlContent(TorrentarrConfig config) if (!string.IsNullOrEmpty(qbit.DownloadPath)) sb.AppendLine($"DownloadPath = \"{qbit.DownloadPath}\""); sb.AppendLine($"ManagedCategories = [{string.Join(", ", qbit.ManagedCategories.Select(c => $"\"{c}\""))}]"); + sb.AppendLine($"MatchSubcategories = {qbit.MatchSubcategories.ToString().ToLower()}"); if (name == "qBit") { sb.AppendLine("# Shared tracker configs inherited by all Arr instances on this qBit instance."); @@ -1809,6 +1822,8 @@ private string GenerateTomlContent(TorrentarrConfig config) sb.AppendLine($"URI = \"{instance.URI}\""); sb.AppendLine($"APIKey = \"{instance.APIKey}\""); sb.AppendLine($"Category = \"{instance.Category}\""); + if (instance.MatchSubcategories.HasValue) + sb.AppendLine($"MatchSubcategories = {instance.MatchSubcategories.Value.ToString().ToLower()}"); sb.AppendLine($"ReSearch = {instance.ReSearch.ToString().ToLower()}"); sb.AppendLine($"importMode = \"{instance.ImportMode}\""); sb.AppendLine($"RssSyncTimer = {instance.RssSyncTimer}"); diff --git a/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs b/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs index 8a02b2c8..78c65a6a 100644 --- a/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs +++ b/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs @@ -92,6 +92,8 @@ public class QBitConfig public string Password { get; set; } = "CHANGE_ME"; public string? DownloadPath { get; set; } public List ManagedCategories { get; set; } = new(); + /// When true, ManagedCategories entries match descendant subcategories (qBitrr parity). + public bool MatchSubcategories { get; set; } = false; public List Trackers { get; set; } = new(); public CategorySeedingConfig CategorySeeding { get; set; } = new(); } @@ -194,6 +196,8 @@ public class ArrInstanceConfig public string Type { get; set; } = ""; // radarr, sonarr, lidarr public bool SearchOnly { get; set; } = false; public bool ProcessingOnly { get; set; } = false; + /// Override qBit MatchSubcategories for this Arr instance; null inherits from qBit. + public bool? MatchSubcategories { get; set; } // Search/processing options public bool ReSearch { get; set; } = true; diff --git a/src/Torrentarr.Core/Services/IImportPathTracker.cs b/src/Torrentarr.Core/Services/IImportPathTracker.cs new file mode 100644 index 00000000..35844c31 --- /dev/null +++ b/src/Torrentarr.Core/Services/IImportPathTracker.cs @@ -0,0 +1,13 @@ +namespace Torrentarr.Core.Services; + +/// +/// Tracks content paths already sent to Arr scan commands (qBitrr sent_to_scan parity). +/// +public interface IImportPathTracker +{ + bool IsPathAlreadyScanned(string normalizedPath); + bool IsHashAlreadyScanned(string hash); + void MarkScanned(string normalizedPath, string hash); + void RemoveEmptyPathsUnder(string completedFolderRoot); + void ClearIfFolderEmpty(string completedFolderRoot); +} diff --git a/src/Torrentarr.Host/Program.cs b/src/Torrentarr.Host/Program.cs index 7ccf2b97..9b2a9de6 100644 --- a/src/Torrentarr.Host/Program.cs +++ b/src/Torrentarr.Host/Program.cs @@ -170,6 +170,13 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(); + builder.Services.AddSingleton(); // §6.10 / §1.8: update check + auto-update builder.Services.AddSingleton(); builder.Services.AddHostedService(); @@ -1288,7 +1295,7 @@ orderby t.TrackNumber // 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) => + app.MapPost("/web/config", async (HttpRequest request, TorrentarrConfig cfg, ConfigurationLoader loader, ArrWorkerManager workerMgr, QBitCategoryWorkerManager qbitCategoryMgr) => { try { @@ -1307,7 +1314,7 @@ orderby t.TrackNumber if (!valid) return Results.BadRequest(new { error = validationError }); - return SaveAndRespondConfigUpdate(cfg, updatedConfig, loader); + return await SaveAndRespondConfigUpdate(cfg, updatedConfig, loader, workerMgr, qbitCategoryMgr); } catch (Exception ex) { @@ -2144,7 +2151,7 @@ orderby t.TrackNumber app.MapGet("/api/config", (TorrentarrConfig cfg) => Results.Ok(cfg)); - app.MapPost("/api/config", async (HttpRequest request, TorrentarrConfig cfg, ConfigurationLoader loader) => + app.MapPost("/api/config", async (HttpRequest request, TorrentarrConfig cfg, ConfigurationLoader loader, ArrWorkerManager workerMgr, QBitCategoryWorkerManager qbitCategoryMgr) => { try { @@ -2166,7 +2173,7 @@ orderby t.TrackNumber if (!valid) return Results.BadRequest(new { error = validationError }); - return SaveAndRespondConfigUpdate(cfg, updatedConfig, loader); + return await SaveAndRespondConfigUpdate(cfg, updatedConfig, loader, workerMgr, qbitCategoryMgr); } catch (Exception ex) { @@ -2710,10 +2717,12 @@ static async Task HandleTestConnection(TestConnectionRequest req, Torre return (updatedConfig, null); } -static IResult SaveAndRespondConfigUpdate( +static async Task SaveAndRespondConfigUpdate( TorrentarrConfig cfg, TorrentarrConfig updatedConfig, - ConfigurationLoader loader) + ConfigurationLoader loader, + ArrWorkerManager? workerMgr = null, + QBitCategoryWorkerManager? qbitCategoryMgr = null) { var passwordHashError = WebUIAuthHelpers.ValidatePasswordHashForConfigApiSave(cfg, updatedConfig); if (passwordHashError != null) @@ -2727,6 +2736,27 @@ static IResult SaveAndRespondConfigUpdate( cfg.ArrInstances = updatedConfig.ArrInstances; cfg.QBitInstances = updatedConfig.QBitInstances; TorrentPolicyHelper.InvalidateMonitoredPolicyCategoriesCache(cfg); + + if (workerMgr != null) + { + switch (reloadType) + { + case "full": + await workerMgr.RestartAllWorkersAsync(); + if (qbitCategoryMgr != null) + { + foreach (var cat in CategoryOwnershipHelper.GetQBitOnlyManagedCategories(updatedConfig)) + await qbitCategoryMgr.RestartCategoryAsync(cat); + } + break; + case "multi_arr": + case "single_arr": + foreach (var inst in affectedInstancesList) + await workerMgr.RestartWorkerAsync(inst); + break; + } + } + return Results.Ok(new { status = "ok", diff --git a/src/Torrentarr.Infrastructure/ApiClients/Arr/ArrClientResponse.cs b/src/Torrentarr.Infrastructure/ApiClients/Arr/ArrClientResponse.cs index 5152efdc..b9790c10 100644 --- a/src/Torrentarr.Infrastructure/ApiClients/Arr/ArrClientResponse.cs +++ b/src/Torrentarr.Infrastructure/ApiClients/Arr/ArrClientResponse.cs @@ -1,10 +1,14 @@ using System.Net; using RestSharp; +using Torrentarr.Infrastructure.Http; namespace Torrentarr.Infrastructure.ApiClients.Arr; internal static class ArrClientResponse { + internal static Task ExecuteAsync(RestClient client, RestRequest request, CancellationToken ct) => + HttpRetryHelper.ExecuteArrAsync(client, request, ct); + internal static void EnsureSuccess(RestResponse response, string operation) { if (response.ResponseStatus != ResponseStatus.Completed) diff --git a/src/Torrentarr.Infrastructure/ApiClients/Arr/LidarrClient.cs b/src/Torrentarr.Infrastructure/ApiClients/Arr/LidarrClient.cs index a6e1bc9f..7951aae2 100644 --- a/src/Torrentarr.Infrastructure/ApiClients/Arr/LidarrClient.cs +++ b/src/Torrentarr.Infrastructure/ApiClients/Arr/LidarrClient.cs @@ -31,7 +31,7 @@ public async Task> GetArtistsAsync(CancellationToken ct = def var request = new RestRequest("/api/v1/artist", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); ArrClientResponse.EnsureSuccess(response, "GET /api/v1/artist"); if (string.IsNullOrEmpty(response.Content)) @@ -48,7 +48,7 @@ public async Task GetSystemInfoAsync(CancellationToken ct = default) var request = new RestRequest("/api/v1/system/status", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -66,7 +66,7 @@ public async Task GetSystemInfoAsync(CancellationToken ct = default) var request = new RestRequest($"/api/v1/artist/{artistId}", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -87,7 +87,7 @@ public async Task> GetAlbumsAsync(int? artistId = null, Cancel if (artistId.HasValue) request.AddQueryParameter("artistId", artistId.Value.ToString()); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); ArrClientResponse.EnsureSuccess(response, "GET /api/v1/album"); if (string.IsNullOrEmpty(response.Content)) @@ -112,7 +112,7 @@ public async Task SearchArtistAsync(int artistId, CancellationToken ct = d request.AddJsonBody(command); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); return response.IsSuccessful; } @@ -132,7 +132,7 @@ public async Task SearchAlbumAsync(List albumIds, CancellationToken c request.AddJsonBody(command); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); return response.IsSuccessful; } @@ -146,7 +146,7 @@ public async Task SearchAlbumAsync(List albumIds, CancellationToken c request.AddJsonBody(artist); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -182,7 +182,7 @@ public async Task GetWantedAsync(int page = 1, int pageSize request.AddQueryParameter("page", page.ToString()); request.AddQueryParameter("pageSize", pageSize.ToString()); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -203,7 +203,7 @@ public async Task GetQueueAsync(int page = 1, int pageSize request.AddQueryParameter("page", page.ToString()); request.AddQueryParameter("pageSize", pageSize.ToString()); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); ArrClientResponse.EnsureSuccess(response, "GET /api/v1/queue"); if (string.IsNullOrEmpty(response.Content)) @@ -234,7 +234,7 @@ public async Task GetQueueAsync(int page = 1, int pageSize request.AddJsonBody(command); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -252,7 +252,7 @@ public async Task> GetCommandsAsync(CancellationToken ct = d var request = new RestRequest("/api/v1/command", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -270,7 +270,7 @@ public async Task> GetCommandsAsync(CancellationToken ct = d var request = new RestRequest($"/api/v1/trackfile/{trackFileId}", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -291,7 +291,7 @@ public async Task> GetTracksAsync(int? albumId = null, CancellationT if (albumId.HasValue) request.AddQueryParameter("albumId", albumId.Value.ToString()); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); ArrClientResponse.EnsureSuccess(response, "GET /api/v1/track"); if (string.IsNullOrEmpty(response.Content)) @@ -311,7 +311,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru request.AddQueryParameter("removeFromClient", removeFromClient.ToString().ToLower()); request.AddQueryParameter("blocklist", blocklist.ToString().ToLower()); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); return response.IsSuccessful; } @@ -326,7 +326,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru var command = new { name = "RssSync" }; request.AddJsonBody(command); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -347,7 +347,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru var command = new { name = "RefreshMonitoredDownloads" }; request.AddJsonBody(command); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -365,7 +365,7 @@ public async Task> GetQualityProfilesAsync(CancellationToke var request = new RestRequest("/api/v1/qualityprofile", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -381,7 +381,7 @@ public async Task> GetTrackFilesByAlbumAsync(int albumId, Cancel AddApiKeyHeader(request); request.AddQueryParameter("albumId", albumId.ToString()); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -397,7 +397,7 @@ public async Task RescanAsync(CancellationToken ct = default) var request = new RestRequest("/api/v1/command", Method.Post); AddApiKeyHeader(request); request.AddJsonBody(new { name = "RescanArtist" }); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); return response.IsSuccessful; } diff --git a/src/Torrentarr.Infrastructure/ApiClients/Arr/RadarrClient.cs b/src/Torrentarr.Infrastructure/ApiClients/Arr/RadarrClient.cs index 65167def..af63036d 100644 --- a/src/Torrentarr.Infrastructure/ApiClients/Arr/RadarrClient.cs +++ b/src/Torrentarr.Infrastructure/ApiClients/Arr/RadarrClient.cs @@ -31,7 +31,7 @@ public async Task> GetMoviesAsync(CancellationToken ct = defau var request = new RestRequest("/api/v3/movie", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); ArrClientResponse.EnsureSuccess(response, "GET /api/v3/movie"); if (string.IsNullOrEmpty(response.Content)) @@ -48,7 +48,7 @@ public async Task GetSystemInfoAsync(CancellationToken ct = default) var request = new RestRequest("/api/v3/system/status", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -66,7 +66,7 @@ public async Task GetSystemInfoAsync(CancellationToken ct = default) var request = new RestRequest($"/api/v3/movie/{movieId}", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -92,7 +92,7 @@ public async Task SearchMovieAsync(int movieId, CancellationToken ct = def request.AddJsonBody(command); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); return response.IsSuccessful; } @@ -106,7 +106,7 @@ public async Task SearchMovieAsync(int movieId, CancellationToken ct = def request.AddJsonBody(movie); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -124,7 +124,7 @@ public async Task> GetQualityProfilesAsync(CancellationToke var request = new RestRequest("/api/v3/qualityprofile", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -147,7 +147,7 @@ public async Task GetWantedAsync(int page = 1, int pageSize = 50 request.AddQueryParameter("sortKey", "title"); request.AddQueryParameter("sortDirection", "ascending"); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -168,7 +168,7 @@ public async Task GetQueueAsync(int page = 1, int pageSize = 1000 request.AddQueryParameter("page", page.ToString()); request.AddQueryParameter("pageSize", pageSize.ToString()); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); ArrClientResponse.EnsureSuccess(response, "GET /api/v3/queue"); if (string.IsNullOrEmpty(response.Content)) @@ -199,7 +199,7 @@ public async Task GetQueueAsync(int page = 1, int pageSize = 1000 request.AddJsonBody(command); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -217,7 +217,7 @@ public async Task> GetCommandsAsync(CancellationToken ct = d var request = new RestRequest("/api/v3/command", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -235,7 +235,7 @@ public async Task> GetCommandsAsync(CancellationToken ct = d var request = new RestRequest($"/api/v3/moviefile/{movieFileId}", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -256,7 +256,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru request.AddQueryParameter("removeFromClient", removeFromClient.ToString().ToLower()); request.AddQueryParameter("blocklist", blocklist.ToString().ToLower()); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); return response.IsSuccessful; } @@ -271,7 +271,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru var command = new { name = "RssSync" }; request.AddJsonBody(command); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -292,7 +292,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru var command = new { name = "RefreshMonitoredDownloads" }; request.AddJsonBody(command); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -323,7 +323,7 @@ public async Task> GetCustomFormatsAsync(CancellationToken ct var request = new RestRequest("/api/v3/customformat", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -339,7 +339,7 @@ public async Task> GetMovieFilesAsync(int movieId, CancellationT AddApiKeyHeader(request); request.AddQueryParameter("movieId", movieId.ToString()); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -355,7 +355,7 @@ public async Task> GetMoviesWithFilesAsync(CancellationToken c AddApiKeyHeader(request); request.AddQueryParameter("includeMovieImages", "false"); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -371,7 +371,7 @@ public async Task RescanAsync(CancellationToken ct = default) var request = new RestRequest("/api/v3/command", Method.Post); AddApiKeyHeader(request); request.AddJsonBody(new { name = "RescanMovie" }); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); return response.IsSuccessful; } diff --git a/src/Torrentarr.Infrastructure/ApiClients/Arr/SonarrClient.cs b/src/Torrentarr.Infrastructure/ApiClients/Arr/SonarrClient.cs index acf43c9e..ad97c823 100644 --- a/src/Torrentarr.Infrastructure/ApiClients/Arr/SonarrClient.cs +++ b/src/Torrentarr.Infrastructure/ApiClients/Arr/SonarrClient.cs @@ -31,7 +31,7 @@ public async Task> GetSeriesAsync(CancellationToken ct = defa var request = new RestRequest("/api/v3/series", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); ArrClientResponse.EnsureSuccess(response, "GET /api/v3/series"); if (string.IsNullOrEmpty(response.Content)) @@ -48,7 +48,7 @@ public async Task GetSystemInfoAsync(CancellationToken ct = default) var request = new RestRequest("/api/v3/system/status", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -66,7 +66,7 @@ public async Task GetSystemInfoAsync(CancellationToken ct = default) var request = new RestRequest($"/api/v3/series/{seriesId}", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -86,7 +86,7 @@ public async Task> GetEpisodesAsync(int seriesId, Cancellati request.AddQueryParameter("seriesId", seriesId.ToString()); request.AddQueryParameter("includeEpisodeFile", "true"); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); ArrClientResponse.EnsureSuccess(response, "GET /api/v3/episode"); if (string.IsNullOrEmpty(response.Content)) @@ -111,7 +111,7 @@ public async Task SearchSeriesAsync(int seriesId, CancellationToken ct = d request.AddJsonBody(command); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); return response.IsSuccessful; } @@ -131,7 +131,7 @@ public async Task SearchEpisodeAsync(List episodeIds, CancellationTok request.AddJsonBody(command); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); return response.IsSuccessful; } @@ -145,7 +145,7 @@ public async Task SearchEpisodeAsync(List episodeIds, CancellationTok request.AddJsonBody(series); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -168,7 +168,7 @@ public async Task GetWantedAsync(int page = 1, int pageSi request.AddQueryParameter("sortKey", "airDateUtc"); request.AddQueryParameter("sortDirection", "descending"); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -189,7 +189,7 @@ public async Task GetQueueAsync(int page = 1, int pageSize request.AddQueryParameter("page", page.ToString()); request.AddQueryParameter("pageSize", pageSize.ToString()); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); ArrClientResponse.EnsureSuccess(response, "GET /api/v3/queue"); if (string.IsNullOrEmpty(response.Content)) @@ -220,7 +220,7 @@ public async Task GetQueueAsync(int page = 1, int pageSize request.AddJsonBody(command); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -238,7 +238,7 @@ public async Task> GetCommandsAsync(CancellationToken ct = d var request = new RestRequest("/api/v3/command", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -256,7 +256,7 @@ public async Task> GetCommandsAsync(CancellationToken ct = d var request = new RestRequest($"/api/v3/episodefile/{episodeFileId}", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -277,7 +277,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru request.AddQueryParameter("removeFromClient", removeFromClient.ToString().ToLower()); request.AddQueryParameter("blocklist", blocklist.ToString().ToLower()); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); return response.IsSuccessful; } @@ -292,7 +292,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru var command = new { name = "RssSync" }; request.AddJsonBody(command); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -313,7 +313,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru var command = new { name = "RefreshMonitoredDownloads" }; request.AddJsonBody(command); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -344,7 +344,7 @@ public async Task> GetQualityProfilesAsync(CancellationToke var request = new RestRequest("/api/v3/qualityprofile", Method.Get); AddApiKeyHeader(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -360,7 +360,7 @@ public async Task> GetEpisodeFilesAsync(int seriesId, Cancella AddApiKeyHeader(request); request.AddQueryParameter("seriesId", seriesId.ToString()); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -382,7 +382,7 @@ public async Task RescanAsync(CancellationToken ct = default) var request = new RestRequest("/api/v3/command", Method.Post); AddApiKeyHeader(request); request.AddJsonBody(new { name = "RescanSeries" }); - var response = await _client.ExecuteAsync(request, ct); + var response = await ArrClientResponse.ExecuteAsync(_client, request, ct); return response.IsSuccessful; } diff --git a/src/Torrentarr.Infrastructure/ApiClients/QBittorrent/QBittorrentClient.cs b/src/Torrentarr.Infrastructure/ApiClients/QBittorrent/QBittorrentClient.cs index 21e46c60..96ab96a9 100644 --- a/src/Torrentarr.Infrastructure/ApiClients/QBittorrent/QBittorrentClient.cs +++ b/src/Torrentarr.Infrastructure/ApiClients/QBittorrent/QBittorrentClient.cs @@ -2,6 +2,7 @@ using Newtonsoft.Json; using RestSharp; using RestSharp.Authenticators; +using Torrentarr.Infrastructure.Http; namespace Torrentarr.Infrastructure.ApiClients.QBittorrent; @@ -41,6 +42,9 @@ public QBittorrentClient(string host, int port, string username, string password _client = new RestClient(options); } + private Task ExecuteAsync(RestRequest request, CancellationToken ct) => + HttpRetryHelper.ExecuteQBitAsync(_client, request, ct); + /// /// Authenticate with qBittorrent /// @@ -50,7 +54,7 @@ public async Task LoginAsync(CancellationToken ct = default) request.AddParameter("username", _username); request.AddParameter("password", _password); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); if (response.IsSuccessful && response.Content == "Ok.") { @@ -74,7 +78,7 @@ public async Task GetVersionAsync(CancellationToken ct = default) var request = new RestRequest("/api/v2/app/version", Method.Get); AddAuthCookie(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.Content ?? ""; } @@ -91,7 +95,7 @@ public async Task> GetTorrentsAsync(string? category = null, s if (!string.IsNullOrEmpty(sort)) request.AddQueryParameter("sort", sort); - var response = await _client.ExecuteAsync(request, cancellationToken); + var response = await ExecuteAsync(request, cancellationToken); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -115,7 +119,7 @@ public async Task AddTorrentAsync(string url, string? category = null, str if (!string.IsNullOrEmpty(savePath)) request.AddParameter("savepath", savePath); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful && response.Content == "Ok."; } @@ -130,7 +134,7 @@ public async Task DeleteTorrentsAsync(List hashes, bool deleteFile request.AddParameter("hashes", string.Join("|", hashes)); request.AddParameter("deleteFiles", deleteFiles.ToString().ToLower()); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -144,7 +148,7 @@ public async Task PauseTorrentsAsync(List hashes, CancellationToke request.AddParameter("hashes", string.Join("|", hashes)); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -158,7 +162,7 @@ public async Task ResumeTorrentsAsync(List hashes, CancellationTok request.AddParameter("hashes", string.Join("|", hashes)); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -189,7 +193,7 @@ public async Task SetCategoryAsync(List hashes, string category, C request.AddParameter("hashes", string.Join("|", hashes)); request.AddParameter("category", category); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -201,7 +205,7 @@ public async Task> GetCategoriesAsync(Cancellat var request = new RestRequest("/api/v2/torrents/categories", Method.Get); AddAuthCookie(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -223,7 +227,7 @@ public async Task AddTagsAsync(List hashes, List tags, Can request.AddParameter("hashes", string.Join("|", hashes)); request.AddParameter("tags", string.Join(",", tags)); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -238,7 +242,7 @@ public async Task RemoveTagsAsync(List hashes, List tags, request.AddParameter("hashes", string.Join("|", hashes)); request.AddParameter("tags", string.Join(",", tags)); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -252,7 +256,7 @@ public async Task CreateTagsAsync(List tags, CancellationToken ct request.AddParameter("tags", string.Join(",", tags)); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -264,7 +268,7 @@ public async Task> GetTagsAsync(CancellationToken ct = default) var request = new RestRequest("api/v2/torrents/tags", Method.Get); AddAuthCookie(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -283,7 +287,7 @@ public async Task> GetTorrentTrackersAsync(string hash, Can AddAuthCookie(request); request.AddQueryParameter("hash", hash); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -302,7 +306,7 @@ public async Task> GetTorrentTrackersAsync(string hash, Can AddAuthCookie(request); request.AddQueryParameter("hash", hash); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -322,7 +326,7 @@ public async Task RecheckTorrentsAsync(List hashes, CancellationTo request.AddParameter("hashes", string.Join("|", hashes)); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -338,7 +342,7 @@ public async Task SetShareLimitsAsync(string hash, double ratioLimit, long request.AddParameter("ratioLimit", ratioLimit); request.AddParameter("seedingTimeLimit", seedingTimeLimit); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -353,7 +357,7 @@ public async Task SetDownloadLimitAsync(string hash, long limit, Cancellat request.AddParameter("hashes", hash); request.AddParameter("limit", limit); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -368,7 +372,7 @@ public async Task SetUploadLimitAsync(string hash, long limit, Cancellatio request.AddParameter("hashes", hash); request.AddParameter("limit", limit); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -383,7 +387,7 @@ public async Task SetSuperSeedingAsync(string hash, bool enabled, Cancella request.AddParameter("hashes", hash); request.AddParameter("value", enabled ? "true" : "false"); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -395,7 +399,7 @@ public async Task SetTopPriorityAsync(string hash, CancellationToken ct = var request = new RestRequest("api/v2/torrents/topPrio", Method.Post); AddAuthCookie(request); request.AddParameter("hashes", hash); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -410,7 +414,7 @@ public async Task AddTrackersAsync(string hash, List urls, Cancell request.AddParameter("hash", hash); request.AddParameter("urls", string.Join("\n", urls)); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -425,7 +429,7 @@ public async Task RemoveTrackersAsync(string hash, List urls, Canc request.AddParameter("hash", hash); request.AddParameter("urls", string.Join("|", urls)); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -438,7 +442,7 @@ public async Task> GetTorrentFilesAsync(string hash, Cancellat AddAuthCookie(request); request.AddQueryParameter("hash", hash); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -461,7 +465,7 @@ public async Task SetFilePriorityAsync(string hash, int[] fileIds, int pri request.AddParameter("id", string.Join("|", fileIds)); request.AddParameter("priority", priority); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -477,7 +481,7 @@ public async Task CreateCategoryAsync(string name, string? savePath = null if (!string.IsNullOrEmpty(savePath)) request.AddParameter("savePath", savePath); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -492,7 +496,7 @@ public async Task EditCategoryAsync(string name, string savePath, Cancella request.AddParameter("category", name); request.AddParameter("savePath", savePath); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -506,7 +510,7 @@ public async Task DeleteCategoryAsync(string name, CancellationToken ct = request.AddParameter("categories", name); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); return response.IsSuccessful; } @@ -518,7 +522,7 @@ public async Task DeleteCategoryAsync(string name, CancellationToken ct = var request = new RestRequest("api/v2/transfer/info", Method.Get); AddAuthCookie(request); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { @@ -539,7 +543,7 @@ public async Task DeleteCategoryAsync(string name, CancellationToken ct = if (rid.HasValue) request.AddQueryParameter("rid", rid.Value); - var response = await _client.ExecuteAsync(request, ct); + var response = await ExecuteAsync(request, ct); if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content)) { diff --git a/src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs b/src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs new file mode 100644 index 00000000..2a9642d4 --- /dev/null +++ b/src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs @@ -0,0 +1,62 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Torrentarr.Infrastructure.Database; + +/// +/// qBitrr with_database_retry parity for transient SQLite errors. +/// +public static class DatabaseRetryExtensions +{ + public static async Task SaveChangesWithRetryAsync( + this DbContext context, + ILogger? logger = null, + int maxAttempts = 5, + CancellationToken cancellationToken = default) + { + for (var attempt = 0; attempt < maxAttempts; attempt++) + { + try + { + return await context.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) when (IsRetriable(ex) && attempt < maxAttempts - 1) + { + var delay = TimeSpan.FromMilliseconds(500 * Math.Pow(2, attempt)); + logger?.LogWarning(ex, "Database save retry {Attempt}/{Max}, waiting {Delay}ms", + attempt + 1, maxAttempts, delay.TotalMilliseconds); + await Task.Delay(delay, cancellationToken); + + if (IsCorruption(ex) && context.Database.GetDbConnection() is SqliteConnection sqlite) + { + try + { + await context.Database.CloseConnectionAsync(); + await using var conn = new SqliteConnection(sqlite.ConnectionString); + await conn.OpenAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)"; + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception repairEx) + { + logger?.LogWarning(repairEx, "WAL checkpoint during DB retry failed"); + } + } + } + } + + return await context.SaveChangesAsync(cancellationToken); + } + + private static bool IsRetriable(Exception ex) => + ex is DbUpdateException or SqliteException + && (ex.Message.Contains("database is locked", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("disk I/O", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("malformed", StringComparison.OrdinalIgnoreCase)); + + private static bool IsCorruption(Exception ex) => + ex.Message.Contains("malformed", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("disk I/O", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Torrentarr.Infrastructure/Http/HttpRetryHelper.cs b/src/Torrentarr.Infrastructure/Http/HttpRetryHelper.cs new file mode 100644 index 00000000..1491d9d3 --- /dev/null +++ b/src/Torrentarr.Infrastructure/Http/HttpRetryHelper.cs @@ -0,0 +1,55 @@ +using System.Net; +using Polly; +using Polly.Retry; +using RestSharp; + +namespace Torrentarr.Infrastructure.Http; + +/// +/// qBitrr with_retry parity for HTTP API calls. +/// +public static class HttpRetryHelper +{ + private static readonly ResiliencePipeline ArrPipeline = CreatePipeline(5, 0.5, 5.0); + private static readonly ResiliencePipeline QBitPipeline = CreatePipeline(3, 0.5, 3.0); + + public static async Task ExecuteArrAsync( + RestClient client, + RestRequest request, + CancellationToken ct = default) => + await ArrPipeline.ExecuteAsync(async token => await client.ExecuteAsync(request, token), ct); + + public static async Task ExecuteQBitAsync( + RestClient client, + RestRequest request, + CancellationToken ct = default) => + await QBitPipeline.ExecuteAsync(async token => await client.ExecuteAsync(request, token), ct); + + private static ResiliencePipeline CreatePipeline(int maxAttempts, double backoffSeconds, double maxBackoffSeconds) + { + return new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = maxAttempts, + DelayGenerator = args => + { + var delay = Math.Min( + maxBackoffSeconds, + backoffSeconds * Math.Pow(2, args.AttemptNumber)); + return ValueTask.FromResult(TimeSpan.FromSeconds(delay)); + }, + ShouldHandle = new PredicateBuilder() + .Handle() + .Handle(ex => !ex.CancellationToken.IsCancellationRequested) + .HandleResult(r => + r.ResponseStatus != ResponseStatus.Completed + || r.StatusCode is HttpStatusCode.RequestTimeout + or HttpStatusCode.TooManyRequests + or HttpStatusCode.BadGateway + or HttpStatusCode.ServiceUnavailable + or HttpStatusCode.GatewayTimeout + || (int)r.StatusCode >= 500) + }) + .Build(); + } +} diff --git a/src/Torrentarr.Infrastructure/Services/ArrWorkerManager.cs b/src/Torrentarr.Infrastructure/Services/ArrWorkerManager.cs index 924d1f27..4e0ee79e 100644 --- a/src/Torrentarr.Infrastructure/Services/ArrWorkerManager.cs +++ b/src/Torrentarr.Infrastructure/Services/ArrWorkerManager.cs @@ -256,6 +256,20 @@ private async Task RunWorkerCoreAsync(string instanceName, ArrInstanceConfig arr } } + // Ensure qBit category exists and tracker tags are pre-created + try + { + using var initScope = _scopeFactory.CreateScope(); + var ensure = initScope.ServiceProvider.GetRequiredService(); + await ensure.EnsureCategoryOnAllInstancesAsync(arrCfg.Category, ct); + if (initScope.ServiceProvider.GetRequiredService() is SeedingService seedingConcrete) + await seedingConcrete.EnsureAllTrackerTagsExistAsync(ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Category/tag initialization failed for {Instance}", instanceName); + } + // §2.5: Consecutive error counter for exponential backoff int consecutiveErrors = 0; @@ -291,6 +305,7 @@ private async Task RunWorkerCoreAsync(string instanceName, ArrInstanceConfig arr // (sync then search in the same iteration, gated by SearchRequestsEvery) — no // RestartLoopException path exists here; search always follows sync in-order. _stateManager.Update(searchStateName, s => s.Status = "Syncing database..."); + _stateManager.Update(searchStateName, s => s.SearchSummary = "Updating database"); await RunSyncAsync(instanceName, ct); // §2.6: RSS Sync + Refresh Monitored Downloads (timer-gated) @@ -474,6 +489,14 @@ private async Task RunTorrentProcessingAsync(string instanceName, ArrInstanceCon using var scope = _scopeFactory.CreateScope(); var processor = scope.ServiceProvider.GetRequiredService(); await processor.ProcessTorrentsAsync(arrCfg.Category, ct); + + var pathTracker = scope.ServiceProvider.GetRequiredService(); + var completedRoot = _config.Settings.CompletedDownloadFolder; + if (!string.IsNullOrWhiteSpace(completedRoot)) + { + pathTracker.RemoveEmptyPathsUnder(completedRoot); + pathTracker.ClearIfFolderEmpty(completedRoot); + } } catch (Exception ex) { diff --git a/src/Torrentarr.Infrastructure/Services/DatabaseHealthService.cs b/src/Torrentarr.Infrastructure/Services/DatabaseHealthService.cs index ac29e6f4..b8a353b5 100644 --- a/src/Torrentarr.Infrastructure/Services/DatabaseHealthService.cs +++ b/src/Torrentarr.Infrastructure/Services/DatabaseHealthService.cs @@ -160,35 +160,62 @@ public async Task VacuumAsync(CancellationToken cancellationToken = defaul public async Task RepairAsync(CancellationToken cancellationToken = default) { - _logger.LogWarning("Attempting database repair via dump/restore..."); + _logger.LogWarning("Attempting database repair via backup/restore..."); var backupPath = $"{_dbPath}.backup"; var tempPath = $"{_dbPath}.temp"; try { + await _dbContext.Database.CloseConnectionAsync(); + if (File.Exists(_dbPath)) { _logger.LogInformation("Creating backup: {BackupPath}", backupPath); File.Copy(_dbPath, backupPath, overwrite: true); } - _logger.LogInformation("Dumping recoverable data from database..."); + foreach (var suffix in new[] { "-wal", "-shm" }) + { + var sidecar = _dbPath + suffix; + if (File.Exists(sidecar)) + { + try { File.Delete(sidecar); } catch { /* best-effort */ } + } + } - var sourceConn = _dbContext.Database.GetDbConnection(); - await sourceConn.OpenAsync(cancellationToken); + if (!File.Exists(_dbPath)) + { + _logger.LogError("Database file not found: {Path}", _dbPath); + return false; + } - var tempConn = new SqliteConnection($"Data Source={tempPath}"); - await tempConn.OpenAsync(cancellationToken); + await using (var source = new SqliteConnection($"Data Source={_dbPath};Mode=ReadOnly")) + { + await source.OpenAsync(cancellationToken); + if (File.Exists(tempPath)) + File.Delete(tempPath); - var dumpCommand = sourceConn.CreateCommand(); - dumpCommand.CommandText = ".dump"; + await using var dest = new SqliteConnection($"Data Source={tempPath}"); + await dest.OpenAsync(cancellationToken); + source.BackupDatabase(dest); + } - await using var tempCmd = tempConn.CreateCommand(); - tempCmd.CommandText = dumpCommand.CommandText; + await using (var verify = new SqliteConnection($"Data Source={tempPath}")) + { + await verify.OpenAsync(cancellationToken); + await using var cmd = verify.CreateCommand(); + cmd.CommandText = "PRAGMA integrity_check"; + var check = (await cmd.ExecuteScalarAsync(cancellationToken))?.ToString(); + if (check != "ok") + { + _logger.LogError("Repaired database failed integrity check: {Result}", check); + return false; + } + } - await tempConn.CloseAsync(); - await sourceConn.CloseAsync(); + File.Delete(_dbPath); + File.Move(tempPath, _dbPath); _logger.LogInformation("Database repair completed successfully"); return true; diff --git a/src/Torrentarr.Infrastructure/Services/ImportPathTracker.cs b/src/Torrentarr.Infrastructure/Services/ImportPathTracker.cs new file mode 100644 index 00000000..e6a478d3 --- /dev/null +++ b/src/Torrentarr.Infrastructure/Services/ImportPathTracker.cs @@ -0,0 +1,83 @@ +using System.Collections.Concurrent; +using Torrentarr.Core.Services; + +namespace Torrentarr.Infrastructure.Services; + +/// +/// In-memory sent_to_scan / sent_to_scan_hashes tracking (qBitrr arss.py parity). +/// +public class ImportPathTracker : IImportPathTracker +{ + private readonly ConcurrentDictionary _scannedPaths = + new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _scannedHashes = + new(StringComparer.OrdinalIgnoreCase); + + public bool IsPathAlreadyScanned(string normalizedPath) => + !string.IsNullOrEmpty(normalizedPath) && _scannedPaths.ContainsKey(normalizedPath); + + public bool IsHashAlreadyScanned(string hash) => + !string.IsNullOrEmpty(hash) && _scannedHashes.ContainsKey(hash.ToUpperInvariant()); + + public void MarkScanned(string normalizedPath, string hash) + { + if (!string.IsNullOrEmpty(normalizedPath)) + _scannedPaths[normalizedPath] = 0; + if (!string.IsNullOrEmpty(hash)) + _scannedHashes[hash.ToUpperInvariant()] = 0; + } + + public void RemoveEmptyPathsUnder(string completedFolderRoot) + { + if (string.IsNullOrWhiteSpace(completedFolderRoot) || !Directory.Exists(completedFolderRoot)) + return; + + var newSent = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + foreach (var path in Directory.EnumerateDirectories(completedFolderRoot, "*", SearchOption.AllDirectories) + .OrderByDescending(p => p.Length)) + { + try + { + if (!Directory.Exists(path)) + continue; + if (Directory.EnumerateFileSystemEntries(path).Any()) + continue; + Directory.Delete(path); + if (_scannedPaths.ContainsKey(path)) + _scannedPaths.TryRemove(path, out _); + } + catch + { + // best-effort + } + } + + foreach (var p in _scannedPaths.Keys) + { + if (Directory.Exists(p)) + newSent[p] = 0; + } + _scannedPaths.Clear(); + foreach (var kv in newSent) + _scannedPaths[kv.Key] = kv.Value; + } + + public void ClearIfFolderEmpty(string completedFolderRoot) + { + if (string.IsNullOrWhiteSpace(completedFolderRoot) || !Directory.Exists(completedFolderRoot)) + return; + + try + { + if (!Directory.EnumerateFileSystemEntries(completedFolderRoot).Any()) + { + _scannedPaths.Clear(); + _scannedHashes.Clear(); + } + } + catch + { + // best-effort + } + } +} diff --git a/src/Torrentarr.Infrastructure/Services/PeriodicWalCheckpointService.cs b/src/Torrentarr.Infrastructure/Services/PeriodicWalCheckpointService.cs new file mode 100644 index 00000000..35d8fdbf --- /dev/null +++ b/src/Torrentarr.Infrastructure/Services/PeriodicWalCheckpointService.cs @@ -0,0 +1,48 @@ +using Torrentarr.Core.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Torrentarr.Infrastructure.Services; + +/// +/// Periodic WAL checkpoint (qBitrr main.py 5-minute interval parity). +/// +public class PeriodicWalCheckpointService : BackgroundService +{ + private static readonly TimeSpan Interval = TimeSpan.FromMinutes(5); + private readonly ILogger _logger; + private readonly IServiceScopeFactory _scopeFactory; + + public PeriodicWalCheckpointService( + ILogger logger, + IServiceScopeFactory scopeFactory) + { + _logger = logger; + _scopeFactory = scopeFactory; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Starting periodic WAL checkpoint service (interval: {Minutes} minutes)", Interval.TotalMinutes); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await Task.Delay(Interval, stoppingToken); + using var scope = _scopeFactory.CreateScope(); + var dbHealth = scope.ServiceProvider.GetRequiredService(); + await dbHealth.CheckpointWalAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Periodic WAL checkpoint failed"); + } + } + } +} diff --git a/src/Torrentarr.Infrastructure/Services/QBitCategoryEnsureService.cs b/src/Torrentarr.Infrastructure/Services/QBitCategoryEnsureService.cs new file mode 100644 index 00000000..731442db --- /dev/null +++ b/src/Torrentarr.Infrastructure/Services/QBitCategoryEnsureService.cs @@ -0,0 +1,95 @@ +using Torrentarr.Core.Configuration; +using Torrentarr.Infrastructure.ApiClients.QBittorrent; +using Microsoft.Extensions.Logging; + +namespace Torrentarr.Infrastructure.Services; + +/// +/// Ensures Arr/qBit categories exist on all instances (qBitrr _ensure_category_on_all_instances). +/// +public class QBitCategoryEnsureService +{ + private readonly ILogger _logger; + private readonly TorrentarrConfig _config; + private readonly QBittorrentConnectionManager _qbitManager; + + public QBitCategoryEnsureService( + ILogger logger, + TorrentarrConfig config, + QBittorrentConnectionManager qbitManager) + { + _logger = logger; + _config = config; + _qbitManager = qbitManager; + } + + public async Task EnsureCategoryOnAllInstancesAsync(string category, CancellationToken ct = default) + { + var leaf = CategoryPathHelper.NormalizeCategory(category); + if (string.IsNullOrEmpty(leaf)) + return; + + var prefixPaths = CategoryPathHelper.CategoryParents(leaf); + if (prefixPaths.Count == 0) + prefixPaths = new[] { leaf }.ToList(); + else if (!prefixPaths.Contains(leaf, StringComparer.Ordinal)) + prefixPaths = prefixPaths.Append(leaf).ToList(); + + var completedRoot = ResolveCompletedRoot(); + + foreach (var (instanceName, client) in _qbitManager.GetAllClients()) + { + try + { + var categories = await client.GetCategoriesAsync(ct); + foreach (var parent in prefixPaths) + { + if (categories.ContainsKey(parent)) + continue; + + var parentsOfParent = CategoryPathHelper.CategoryParents(parent); + string savePath; + if (parentsOfParent.Count > 0 + && categories.TryGetValue(parentsOfParent[^1], out var parentInfo) + && !string.IsNullOrEmpty(parentInfo.SavePath)) + { + savePath = Path.Combine(parentInfo.SavePath, parent.Split('/').Last()); + } + else + { + savePath = Path.Combine(completedRoot, parent.Replace('/', Path.DirectorySeparatorChar)); + } + + var created = await client.CreateCategoryAsync(parent, savePath, ct); + if (created) + { + _logger.LogInformation( + "Created category '{Category}' on instance '{Instance}' (save_path={Path})", + parent, instanceName, savePath); + categories[parent] = new CategoryInfo { Name = parent, SavePath = savePath }; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed ensuring category '{Category}' on instance '{Instance}'", leaf, instanceName); + } + } + } + + private string ResolveCompletedRoot() + { + var folder = _config.Settings.CompletedDownloadFolder; + if (!string.IsNullOrWhiteSpace(folder) && folder != "CHANGE_ME" && Directory.Exists(folder)) + return folder; + + foreach (var (_, qbit) in _config.QBitInstances) + { + if (!string.IsNullOrWhiteSpace(qbit.DownloadPath) && qbit.DownloadPath != "CHANGE_ME" + && Directory.Exists(qbit.DownloadPath)) + return qbit.DownloadPath; + } + + return folder is { Length: > 0 } ? folder : "/downloads"; + } +} diff --git a/src/Torrentarr.Infrastructure/Services/QBitCategoryWorkerManager.cs b/src/Torrentarr.Infrastructure/Services/QBitCategoryWorkerManager.cs new file mode 100644 index 00000000..f2b79fc4 --- /dev/null +++ b/src/Torrentarr.Infrastructure/Services/QBitCategoryWorkerManager.cs @@ -0,0 +1,152 @@ +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; + +namespace Torrentarr.Infrastructure.Services; + +/// +/// Processes torrents in qBit ManagedCategories not owned by an Arr instance (qBitrr PlaceHolderArr parity). +/// +public class QBitCategoryWorkerManager : BackgroundService +{ + private readonly ILogger _logger; + private readonly TorrentarrConfig _config; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ProcessStateManager _stateManager; + private readonly IConnectivityService _connectivityService; + private readonly QBittorrentConnectionManager _qbitManager; + + private readonly Dictionary _workerCts = new(StringComparer.OrdinalIgnoreCase); + private readonly List _workerTasks = new(); + + public QBitCategoryWorkerManager( + ILogger logger, + TorrentarrConfig config, + IServiceScopeFactory scopeFactory, + ProcessStateManager stateManager, + IConnectivityService connectivityService, + QBittorrentConnectionManager qbitManager) + { + _logger = logger; + _config = config; + _scopeFactory = scopeFactory; + _stateManager = stateManager; + _connectivityService = connectivityService; + _qbitManager = qbitManager; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + foreach (var category in CategoryOwnershipHelper.GetQBitOnlyManagedCategories(_config)) + { + var stateName = $"qbit-{category}"; + _stateManager.Initialize(stateName, new ArrProcessState + { + Name = stateName, + Category = category, + Kind = "category", + MetricType = "category", + Alive = false + }); + + var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + _workerCts[category] = cts; + _workerTasks.Add(RunCategoryLoopAsync(category, stateName, cts.Token)); + } + + if (_workerTasks.Count == 0) + { + _logger.LogDebug("No qBit-only managed categories to process"); + try { await Task.Delay(Timeout.Infinite, stoppingToken); } + catch (OperationCanceledException) { } + return; + } + + _logger.LogInformation("Started {Count} qBit-only category worker(s)", _workerTasks.Count); + try { await Task.WhenAll(_workerTasks); } + catch (OperationCanceledException) { } + } + + public async Task RestartCategoryAsync(string category) + { + if (!_workerCts.TryGetValue(category, out var existing)) + return; + + existing.Cancel(); + try { await Task.WhenAny(_workerTasks); } catch { /* ignore */ } + + var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + _workerCts[category] = cts; + _ = RunCategoryLoopAsync(category, $"qbit-{category}", cts.Token); + } + + private async Task RunCategoryLoopAsync(string category, string stateName, CancellationToken ct) + { + _stateManager.Update(stateName, s => { s.Alive = true; s.Rebuilding = false; }); + + try + { + using var initScope = _scopeFactory.CreateScope(); + var ensure = initScope.ServiceProvider.GetRequiredService(); + await ensure.EnsureCategoryOnAllInstancesAsync(category, ct); + await EnsureTrackerTagsAsync(initScope, ct); + + while (!ct.IsCancellationRequested) + { + var loopStart = DateTime.UtcNow; + try + { + if (!await _connectivityService.IsConnectedAsync(ct)) + { + _stateManager.Update(stateName, s => s.Status = "Waiting for connectivity..."); + await Task.Delay(TimeSpan.FromSeconds(_config.Settings.NoInternetSleepTimer), ct); + continue; + } + + _stateManager.Update(stateName, s => s.Status = "Processing torrents..."); + using var scope = _scopeFactory.CreateScope(); + var processor = scope.ServiceProvider.GetRequiredService(); + var seeding = scope.ServiceProvider.GetRequiredService(); + var pathTracker = scope.ServiceProvider.GetRequiredService(); + + await processor.ProcessTorrentsAsync(category, ct); + await seeding.RemoveCompletedTorrentsAsync(category, ct); + + var completedRoot = _config.Settings.CompletedDownloadFolder; + if (!string.IsNullOrWhiteSpace(completedRoot)) + { + pathTracker.RemoveEmptyPathsUnder(completedRoot); + pathTracker.ClearIfFolderEmpty(completedRoot); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in qBit category worker for {Category}", category); + } + + var elapsed = DateTime.UtcNow - loopStart; + var sleep = TimeSpan.FromSeconds(_config.Settings.LoopSleepTimer) - elapsed; + if (sleep > TimeSpan.Zero) + await Task.Delay(sleep, ct); + } + } + finally + { + _stateManager.Update(stateName, s => s.Alive = false); + } + } + + private async Task EnsureTrackerTagsAsync(IServiceScope scope, CancellationToken ct) + { + var seeding = scope.ServiceProvider.GetRequiredService(); + if (seeding is SeedingService concrete) + await concrete.EnsureAllTrackerTagsExistAsync(ct); + } +} diff --git a/src/Torrentarr.Infrastructure/Services/QualityProfileSwitcherService.cs b/src/Torrentarr.Infrastructure/Services/QualityProfileSwitcherService.cs index 4bf92c16..20b32200 100644 --- a/src/Torrentarr.Infrastructure/Services/QualityProfileSwitcherService.cs +++ b/src/Torrentarr.Infrastructure/Services/QualityProfileSwitcherService.cs @@ -54,7 +54,7 @@ public async Task ForceResetAllTempProfilesAsync( var radarr = new RadarrClient(arrConfig.URI, arrConfig.APIKey); foreach (var movie in movies) { - await TryRestoreMovieAsync(radarr, movie.ArrId, movie.OriginalProfileId!.Value, instanceName, ct); + await TryRestoreMovieAsync(radarr, movie.ArrId, movie.OriginalProfileId!.Value, instanceName, arrConfig, ct); movie.CurrentProfileId = movie.OriginalProfileId; movie.OriginalProfileId = null; movie.LastProfileSwitchTime = null; @@ -73,7 +73,7 @@ public async Task ForceResetAllTempProfilesAsync( var sonarr = new SonarrClient(arrConfig.URI, arrConfig.APIKey); foreach (var s in series) { - await TryRestoreSeriesAsync(sonarr, s.ArrId, s.OriginalProfileId!.Value, instanceName, ct); + await TryRestoreSeriesAsync(sonarr, s.ArrId, s.OriginalProfileId!.Value, instanceName, arrConfig, ct); s.CurrentProfileId = s.OriginalProfileId; s.OriginalProfileId = null; s.LastProfileSwitchTime = null; @@ -92,7 +92,7 @@ public async Task ForceResetAllTempProfilesAsync( var lidarr = new LidarrClient(arrConfig.URI, arrConfig.APIKey); foreach (var artist in artists) { - await TryRestoreArtistAsync(lidarr, artist.ArrId, artist.OriginalProfileId!.Value, instanceName, ct); + await TryRestoreArtistAsync(lidarr, artist.ArrId, artist.OriginalProfileId!.Value, instanceName, arrConfig, ct); artist.CurrentProfileId = artist.OriginalProfileId; artist.OriginalProfileId = null; artist.LastProfileSwitchTime = null; @@ -142,7 +142,7 @@ public async Task RestoreTimedOutProfilesAsync( var radarr = new RadarrClient(arrConfig.URI, arrConfig.APIKey); foreach (var movie in expiredMovies) { - await TryRestoreMovieAsync(radarr, movie.ArrId, movie.OriginalProfileId!.Value, instanceName, ct); + await TryRestoreMovieAsync(radarr, movie.ArrId, movie.OriginalProfileId!.Value, instanceName, arrConfig, ct); movie.CurrentProfileId = movie.OriginalProfileId; movie.OriginalProfileId = null; movie.LastProfileSwitchTime = null; @@ -164,7 +164,7 @@ public async Task RestoreTimedOutProfilesAsync( var sonarr = new SonarrClient(arrConfig.URI, arrConfig.APIKey); foreach (var s in expiredSeries) { - await TryRestoreSeriesAsync(sonarr, s.ArrId, s.OriginalProfileId!.Value, instanceName, ct); + await TryRestoreSeriesAsync(sonarr, s.ArrId, s.OriginalProfileId!.Value, instanceName, arrConfig, ct); s.CurrentProfileId = s.OriginalProfileId; s.OriginalProfileId = null; s.LastProfileSwitchTime = null; @@ -186,7 +186,7 @@ public async Task RestoreTimedOutProfilesAsync( var lidarr = new LidarrClient(arrConfig.URI, arrConfig.APIKey); foreach (var artist in expiredArtists) { - await TryRestoreArtistAsync(lidarr, artist.ArrId, artist.OriginalProfileId!.Value, instanceName, ct); + await TryRestoreArtistAsync(lidarr, artist.ArrId, artist.OriginalProfileId!.Value, instanceName, arrConfig, ct); artist.CurrentProfileId = artist.OriginalProfileId; artist.OriginalProfileId = null; artist.LastProfileSwitchTime = null; @@ -276,7 +276,11 @@ private async Task SwitchMovieProfilesAsync( continue; } - var switched = await radarr.UpdateMovieQualityProfileAsync(movie.ArrId, tempProfileId, ct); + var switched = await WithProfileSwitchRetryAsync( + arrConfig, + () => radarr.UpdateMovieQualityProfileAsync(movie.ArrId, tempProfileId, ct), + "movie", + ct); if (switched) { _logger.LogInformation("§1.2: Switched movie '{Title}' profile: {From} → {To}", movie.Title, currentProfileName, tempProfileName); @@ -333,7 +337,11 @@ private async Task SwitchSeriesProfilesAsync( continue; } - var switched = await sonarr.UpdateSeriesQualityProfileAsync(series.ArrId, tempProfileId, ct); + var switched = await WithProfileSwitchRetryAsync( + arrConfig, + () => sonarr.UpdateSeriesQualityProfileAsync(series.ArrId, tempProfileId, ct), + "series", + ct); if (switched) { _logger.LogInformation("§1.2: Switched series '{Title}' profile: {From} → {To}", @@ -391,7 +399,11 @@ private async Task SwitchArtistProfilesAsync( continue; } - var switched = await lidarr.UpdateArtistQualityProfileAsync(artist.ArrId, tempProfileId, ct); + var switched = await WithProfileSwitchRetryAsync( + arrConfig, + () => lidarr.UpdateArtistQualityProfileAsync(artist.ArrId, tempProfileId, ct), + "artist", + ct); if (switched) { _logger.LogInformation("§1.2: Switched artist '{Name}' profile: {From} → {To}", @@ -409,42 +421,72 @@ private async Task SwitchArtistProfilesAsync( // ── Restore helpers ─────────────────────────────────────────────────────── - private async Task TryRestoreMovieAsync(RadarrClient radarr, int arrId, int originalProfileId, string instanceName, CancellationToken ct) + private async Task TryRestoreMovieAsync(RadarrClient radarr, int arrId, int originalProfileId, string instanceName, ArrInstanceConfig arrConfig, CancellationToken ct) { - try - { - await radarr.UpdateMovieQualityProfileAsync(arrId, originalProfileId, ct); - _logger.LogInformation("§1.2: Restored movie {ArrId} → profileId={ProfileId} for {Instance}", arrId, originalProfileId, instanceName); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "§1.2: Failed to restore movie {ArrId} for {Instance}", arrId, instanceName); - } + await WithProfileSwitchRetryAsync( + arrConfig, + async () => + { + await radarr.UpdateMovieQualityProfileAsync(arrId, originalProfileId, ct); + _logger.LogInformation("§1.2: Restored movie {ArrId} → profileId={ProfileId} for {Instance}", arrId, originalProfileId, instanceName); + return true; + }, + "movie-restore", + ct); } - private async Task TryRestoreSeriesAsync(SonarrClient sonarr, int arrId, int originalProfileId, string instanceName, CancellationToken ct) + private async Task TryRestoreSeriesAsync(SonarrClient sonarr, int arrId, int originalProfileId, string instanceName, ArrInstanceConfig arrConfig, CancellationToken ct) { - try - { - await sonarr.UpdateSeriesQualityProfileAsync(arrId, originalProfileId, ct); - _logger.LogInformation("§1.2: Restored series {ArrId} → profileId={ProfileId} for {Instance}", arrId, originalProfileId, instanceName); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "§1.2: Failed to restore series {ArrId} for {Instance}", arrId, instanceName); - } + await WithProfileSwitchRetryAsync( + arrConfig, + async () => + { + await sonarr.UpdateSeriesQualityProfileAsync(arrId, originalProfileId, ct); + _logger.LogInformation("§1.2: Restored series {ArrId} → profileId={ProfileId} for {Instance}", arrId, originalProfileId, instanceName); + return true; + }, + "series-restore", + ct); } - private async Task TryRestoreArtistAsync(LidarrClient lidarr, int arrId, int originalProfileId, string instanceName, CancellationToken ct) + private async Task TryRestoreArtistAsync(LidarrClient lidarr, int arrId, int originalProfileId, string instanceName, ArrInstanceConfig arrConfig, CancellationToken ct) { - try - { - await lidarr.UpdateArtistQualityProfileAsync(arrId, originalProfileId, ct); - _logger.LogInformation("§1.2: Restored artist {ArrId} → profileId={ProfileId} for {Instance}", arrId, originalProfileId, instanceName); - } - catch (Exception ex) + await WithProfileSwitchRetryAsync( + arrConfig, + async () => + { + await lidarr.UpdateArtistQualityProfileAsync(arrId, originalProfileId, ct); + _logger.LogInformation("§1.2: Restored artist {ArrId} → profileId={ProfileId} for {Instance}", arrId, originalProfileId, instanceName); + return true; + }, + "artist-restore", + ct); + } + + private async Task WithProfileSwitchRetryAsync( + ArrInstanceConfig arrConfig, + Func> action, + string kind, + CancellationToken ct) + { + var attempts = Math.Max(1, arrConfig.Search.ProfileSwitchRetryAttempts); + for (var attempt = 0; attempt < attempts; attempt++) { - _logger.LogWarning(ex, "§1.2: Failed to restore artist {ArrId} for {Instance}", arrId, instanceName); + try + { + return await action(); + } + catch (Exception ex) when (attempt < attempts - 1) + { + _logger.LogWarning(ex, "Profile switch retry {Attempt}/{Max} for {Kind}", attempt + 1, attempts, kind); + await Task.Delay(TimeSpan.FromSeconds(1), ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update {Kind} profile after {Attempts} attempts", kind, attempts); + return false; + } } + return false; } } diff --git a/src/Torrentarr.Infrastructure/Services/SeedingService.cs b/src/Torrentarr.Infrastructure/Services/SeedingService.cs index 16628a4b..47651c45 100644 --- a/src/Torrentarr.Infrastructure/Services/SeedingService.cs +++ b/src/Torrentarr.Infrastructure/Services/SeedingService.cs @@ -930,6 +930,47 @@ private async Task EnsureTagsExistAsync(QBittorrentClient client, CancellationTo } } + /// Pre-create all configured tracker AddTags on every qBit instance (qBitrr qbit_category_manager parity). + public async Task EnsureAllTrackerTagsExistAsync(CancellationToken cancellationToken = default) + { + var allTags = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var q in _config.QBitInstances.Values) + { + foreach (var t in q.Trackers) + foreach (var tag in t.AddTags) + if (!string.IsNullOrWhiteSpace(tag)) + allTags.Add(tag.Trim()); + } + foreach (var a in _config.ArrInstances.Values) + { + foreach (var t in a.Torrent.Trackers) + foreach (var tag in t.AddTags) + if (!string.IsNullOrWhiteSpace(tag)) + allTags.Add(tag.Trim()); + } + + if (allTags.Count == 0) + return; + + foreach (var (_, client) in _qbitManager.GetAllClients()) + { + try + { + var existing = await client.GetTagsAsync(cancellationToken); + var toCreate = allTags.Where(t => !existing.Contains(t, StringComparer.OrdinalIgnoreCase)).ToList(); + if (toCreate.Count > 0) + { + await client.CreateTagsAsync(toCreate, cancellationToken); + _logger.LogDebug("Pre-created tracker tags on qBit: {Tags}", string.Join(", ", toCreate)); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to pre-create tracker tags"); + } + } + } + /// /// Public entry point for tracker actions, callable from TorrentProcessor pre-step. /// In qBitrr this runs for EVERY torrent BEFORE the state machine. diff --git a/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs b/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs index bad25b02..a0bc311c 100644 --- a/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs +++ b/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs @@ -29,6 +29,7 @@ public class TorrentProcessor : ITorrentProcessor private readonly ITorrentCacheService _cache; private readonly IArrImportService? _importService; private readonly ISeedingService? _seedingService; + private readonly IImportPathTracker? _pathTracker; private readonly HashSet _specialCategories; @@ -39,7 +40,8 @@ public TorrentProcessor( TorrentarrConfig config, ITorrentCacheService cache, IArrImportService? importService = null, - ISeedingService? seedingService = null) + ISeedingService? seedingService = null, + IImportPathTracker? pathTracker = null) { _logger = logger; _qbitManager = qbitManager; @@ -48,6 +50,7 @@ public TorrentProcessor( _cache = cache; _importService = importService; _seedingService = seedingService; + _pathTracker = pathTracker; _specialCategories = new HashSet(StringComparer.OrdinalIgnoreCase) { @@ -83,15 +86,18 @@ public async Task ProcessTorrentsAsync(string category, CancellationToken cancel await EnsureTagsExistAsync(c, cancellationToken); } - // Get all torrents for this category from ALL qBit instances, stamping instance name - var torrents = new List(); - foreach (var (instanceName, c) in allClients) - { - var instanceTorrents = await c.GetTorrentsAsync(category, cancellationToken: cancellationToken); - foreach (var t in instanceTorrents) - t.QBitInstanceName = instanceName; - torrents.AddRange(instanceTorrents); - } + // Gather torrents (MatchSubcategories-aware — qBitrr _get_torrents_from_all_instances) + var fetchAll = allClients.ToDictionary( + kv => kv.Key, + kv => (Func>>)(ct => + kv.Value.GetTorrentsAsync(cancellationToken: ct))); + var fetchByCategory = allClients.ToDictionary( + kv => kv.Key, + kv => (Func>>)(async (cat, ct) => + await kv.Value.GetTorrentsAsync(cat, cancellationToken: ct))); + + var torrents = await CategoryOwnershipHelper.GatherTorrentsForOwnerAsync( + _config, category, fetchAll, fetchByCategory, cancellationToken); _logger.LogDebug("Found {Count} torrents in category {Category}", torrents.Count, category); var stats = new TorrentProcessingStats @@ -116,6 +122,7 @@ public async Task ProcessTorrentsAsync(string category, CancellationToken cancel if (_seedingService != null) { await _seedingService.UpdateSeedingTagsAsync(category, cancellationToken); + await _seedingService.RemoveCompletedTorrentsAsync(category, cancellationToken); } _logger.LogInformation( @@ -236,6 +243,27 @@ public async Task ImportTorrentAsync(string hash, string? qbitInstance = null, C if (_importService != null) { + var contentPath = torrent.ContentPath; + if (!string.IsNullOrEmpty(contentPath)) + { + if (_pathTracker?.IsHashAlreadyScanned(torrent.Hash) == true) + { + _logger.LogTrace("Skipping import — hash already sent to scan: {Hash}", hash); + return; + } + if (!File.Exists(contentPath)) + { + _logger.LogWarning("Missing torrent file for import: {Path} ({Hash})", contentPath, hash); + _cache.AddToIgnoreCache(hash, TimeSpan.FromSeconds(_config.Settings.IgnoreTorrentsYoungerThan)); + return; + } + if (_pathTracker?.IsPathAlreadyScanned(contentPath) == true) + { + _logger.LogTrace("Skipping import — path already sent to scan: {Path}", contentPath); + return; + } + } + var result = await _importService.TriggerImportAsync( hash, torrent.ContentPath, libraryEntry.Category, cancellationToken); @@ -246,6 +274,9 @@ public async Task ImportTorrentAsync(string hash, string? qbitInstance = null, C _logger.LogInformation("Successfully triggered import for torrent {Hash}: {Message}", hash, result.Message); + if (!string.IsNullOrEmpty(contentPath)) + _pathTracker?.MarkScanned(contentPath, hash); + // AutoDelete: remove torrent from qBittorrent after successful import var arrCfgForDelete = _config.ArrInstances.Values.FirstOrDefault(a => string.Equals(a.Category, libraryEntry.Category, StringComparison.OrdinalIgnoreCase)); @@ -288,9 +319,7 @@ private async Task ProcessSingleTorrentAsync( var state = ParseTorrentState(torrent.State); var arrCfg = _config.ArrInstances.Values.FirstOrDefault(a => - !string.IsNullOrEmpty(a.Category) - && CategoryPathHelper.MatchesConfigured(category, new[] { a.Category }, prefix: true) - == CategoryPathHelper.NormalizeCategory(a.Category)); + CategoryPathHelper.CategoryEquals(a.Category, category)); var ignoreYoungerThan = arrCfg?.Torrent.IgnoreTorrentsYoungerThan ?? _config.Settings.IgnoreTorrentsYoungerThan; var timeNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); @@ -310,6 +339,7 @@ private async Task ProcessSingleTorrentAsync( && TorrentPolicyHelper.IsMonitoredPolicyCategory(_config, torrent.Category))) { await _seedingService.ApplyTrackerActionsForTorrentAsync(torrent, ct); + await _seedingService.ApplySeedingLimitsAsync(torrent, ct); } // PRE-STEP 1: Resolve leave_alone / remove_torrent / maxEta (qBitrr: _should_leave_alone) @@ -485,8 +515,9 @@ await ignClient.RemoveTagsAsync(new List { torrent.Hash }, { await ProcessPercentageThresholdAsync(torrent, maxEta, client, stats, ct); } - // Branch 14: Already imported → skip (qBitrr line 6184-6187) - else if (await IsImportedInDatabaseAsync(torrent.Hash, torrent.QBitInstanceName, ct) + // Branch 14: Already imported or sent to scan → skip (qBitrr line 6184-6187, sent_to_scan_hashes) + else if ((_pathTracker?.IsHashAlreadyScanned(torrent.Hash) == true + || await IsImportedInDatabaseAsync(torrent.Hash, torrent.QBitInstanceName, ct)) && _cache.IsFileFiltered(torrent.Hash)) { _logger.LogTrace("Skipping already-imported torrent: [{Name}]", torrent.Name); diff --git a/tests/Torrentarr.Core.Tests/Configuration/CategoryOwnershipHelperTests.cs b/tests/Torrentarr.Core.Tests/Configuration/CategoryOwnershipHelperTests.cs new file mode 100644 index 00000000..1bdcfebb --- /dev/null +++ b/tests/Torrentarr.Core.Tests/Configuration/CategoryOwnershipHelperTests.cs @@ -0,0 +1,75 @@ +using FluentAssertions; +using Torrentarr.Core.Configuration; +using Xunit; + +namespace Torrentarr.Core.Tests.Configuration; + +public class CategoryOwnershipHelperTests +{ + [Fact] + public void GetQBitOnlyManagedCategories_ExcludesArrCategories() + { + var cfg = new TorrentarrConfig(); + cfg.QBitInstances["qBit"] = new QBitConfig + { + ManagedCategories = ["seed", "manual", "radarr"] + }; + cfg.ArrInstances["Radarr"] = new ArrInstanceConfig { Category = "radarr" }; + + var only = CategoryOwnershipHelper.GetQBitOnlyManagedCategories(cfg); + only.Should().BeEquivalentTo(["seed", "manual"]); + } + + [Fact] + public void ResolveOwningCategory_ExactMatch_Wins() + { + var cfg = new TorrentarrConfig(); + cfg.ArrInstances["Sonarr"] = new ArrInstanceConfig { Category = "sonarr" }; + + CategoryOwnershipHelper.ResolveOwningCategory(cfg, "sonarr") + .Should().Be("sonarr"); + } + + [Fact] + public void ResolveOwningCategory_PrefixMatch_WhenMatchSubcategoriesEnabled() + { + var cfg = new TorrentarrConfig(); + cfg.QBitInstances["qBit"] = new QBitConfig + { + MatchSubcategories = true, + ManagedCategories = ["seed"] + }; + + CategoryOwnershipHelper.ResolveOwningCategory(cfg, "seed/tleech", "qBit") + .Should().Be("seed"); + } + + [Fact] + public void ResolveOwningCategory_PrefixMatch_Disabled_ReturnsNull() + { + var cfg = new TorrentarrConfig(); + cfg.QBitInstances["qBit"] = new QBitConfig + { + MatchSubcategories = false, + ManagedCategories = ["seed"] + }; + + CategoryOwnershipHelper.ResolveOwningCategory(cfg, "seed/tleech", "qBit") + .Should().BeNull(); + } + + [Fact] + public void ArrMatchSubcategoriesEffective_UsesPerArrOverride() + { + var cfg = new TorrentarrConfig(); + cfg.QBitInstances["qBit"] = new QBitConfig { MatchSubcategories = false }; + cfg.ArrInstances["Sonarr"] = new ArrInstanceConfig + { + Category = "sonarr", + MatchSubcategories = true + }; + + CategoryOwnershipHelper.ArrMatchSubcategoriesEffective(cfg, "Sonarr", "qBit") + .Should().BeTrue(); + } +} From 27cec8de246c363f29246bddb4ad453d53b9ad24 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 15 Jun 2026 15:03:34 +0000 Subject: [PATCH 2/7] [pre-commit] auto fixes from pre-commit hooks for more information, see https://pre-commit.ci --- src/Torrentarr.Core/Configuration/CategoryOwnershipHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Torrentarr.Core/Configuration/CategoryOwnershipHelper.cs b/src/Torrentarr.Core/Configuration/CategoryOwnershipHelper.cs index fabdeb97..dac9339c 100644 --- a/src/Torrentarr.Core/Configuration/CategoryOwnershipHelper.cs +++ b/src/Torrentarr.Core/Configuration/CategoryOwnershipHelper.cs @@ -64,7 +64,7 @@ public static List GetQBitOnlyManagedCategories(TorrentarrConfig config) return null; } - public static bool QBitMatchSubcategories(TorrentarrConfig config, string qbitSection) + public static bool QBitMatchSubcategories(TorrentarrConfig config, string qbitSection) { if (config.QBitInstances.TryGetValue(qbitSection, out var qbit)) return qbit.MatchSubcategories; From 8a13a8872cb4fc32ed6b79dcb35dfddb7b7876a5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 15 Jun 2026 15:39:36 +0000 Subject: [PATCH 3/7] Continue parity gaps: WebUI MatchSubcategories, DB restart watchdog - Add MatchSubcategories checkbox to qBit and Arr config editor fields - Update torrent handling summary text for prefix vs exact category matching - Refactor QBitCategoryWorkerManager with proper worker lifecycle (restart/sync) - Add DatabaseRestartCoordinator and watchdog for coordinated worker recovery - Wire SaveChangesWithRetryAsync into high-traffic DB write paths - Register coordinator in Host and Workers DI Co-authored-by: Feramance --- docs/parity/full-parity-matrix.md | 4 +- src/Torrentarr.Host/Program.cs | 7 +- .../Database/DatabaseRetryExtensions.cs | 13 +- .../Services/ArrSyncService.cs | 29 ++-- .../Services/DatabaseRestartCoordinator.cs | 44 ++++++ .../DatabaseRestartWatchdogService.cs | 59 ++++++++ .../Services/QBitCategoryWorkerManager.cs | 128 +++++++++++++++--- .../Services/QualityProfileSwitcherService.cs | 23 ++-- .../Services/SearchExecutor.cs | 11 +- .../Services/TorrentProcessor.cs | 17 ++- src/Torrentarr.Workers/Program.cs | 1 + .../Services/ArrMediaServiceTests.cs | 3 +- .../Services/ArrSyncServiceTests.cs | 2 +- .../Services/AvailabilityCheckTests.cs | 2 +- .../QualityProfileSwitcherServiceTests.cs | 2 +- .../Services/ScanQueueForBlocklistTests.cs | 3 +- .../Services/SearchExecutorTests.cs | 6 +- .../Services/TaglessInstanceScopeTests.cs | 3 +- .../Services/TaglessScopingTests.cs | 3 +- .../Services/TorrentProcessorTests.cs | 4 +- webui/src/config/torrentHandlingSummary.ts | 18 ++- webui/src/pages/ConfigView.tsx | 15 ++ 22 files changed, 324 insertions(+), 73 deletions(-) create mode 100644 src/Torrentarr.Infrastructure/Services/DatabaseRestartCoordinator.cs create mode 100644 src/Torrentarr.Infrastructure/Services/DatabaseRestartWatchdogService.cs diff --git a/docs/parity/full-parity-matrix.md b/docs/parity/full-parity-matrix.md index 6b63b442..c5c927f9 100644 --- a/docs/parity/full-parity-matrix.md +++ b/docs/parity/full-parity-matrix.md @@ -31,7 +31,7 @@ Status values: | `qBitrr/duration_config.py` | `DurationParser.cs` | full | **Evidence:** [`DurationParserTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Core.Tests/Configuration/DurationParserTests.cs). | | `qBitrr/database.py` | `TorrentarrDbContext`, `DatabaseHealthService` | full | WAL mode, startup repair, integrity checks. | | `qBitrr/tables.py` | EF models, `TorrentarrDbContext` | full | **Evidence:** [`SchemaParityTests.cs`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Infrastructure.Tests/Database/SchemaParityTests.cs). | -| `qBitrr/db_lock.py` | EF/SQLite locking, `DatabaseRetryExtensions.cs` | partial | `SaveChangesWithRetryAsync` for transient errors; no cross-process file lock (WAL + scoped DbContext). Coordinated worker restart after repair not ported. | +| `qBitrr/db_lock.py` | EF/SQLite locking, `DatabaseRetryExtensions.cs`, `DatabaseRestartCoordinator` | partial | `SaveChangesWithRetryAsync` on worker DB writes; coordinated worker restart via `DatabaseRestartWatchdogService` after 5+ min of persistent errors. No cross-process file lock (WAL + scoped DbContext). | | `qBitrr/db_recovery.py` | `DatabaseHealthService`, Host `--repair-database`, `PeriodicWalCheckpointService` | full | Integrity + VACUUM + `RepairAsync` via SQLite backup; periodic WAL checkpoint every 5 minutes on Host. | | `qBitrr/search_activity_store.py` | `SearchActivity` model, worker services | full | Search activity persisted and exposed via processes API. | | `qBitrr/webui.py` | Host/WebUI `Program.cs`, `webui/src` | partial | Routes implemented on Host; OpenAPI documents 26/66 paths — expand `docs/assets/openapi.json` for full contract parity. | @@ -67,4 +67,4 @@ Status values: - **Lidarr artists + thumbnails (5.12.0):** `ArrCatalogEndpoints` + `ArrThumbnailService` + frontend API client. - **OpenAPI drift guard:** `scripts/check-openapi-drift.sh` in CI vs qBitrr `5.12.3`. - **Config schema:** Torrentarr `6.12.2` (+1 major vs qBitrr `5.12.2`). -- **Gap closeout (2026-06):** `MatchSubcategories`, qBit-only category workers, import path tracking, folder cleanup, category auto-creation, seeding rate limits, HTTP/DB retry, profile-switch retries, periodic WAL checkpoint, config-reload worker restart. +- **Gap closeout (2026-06):** `MatchSubcategories`, qBit-only category workers, import path tracking, folder cleanup, category auto-creation, seeding rate limits, HTTP/DB retry, profile-switch retries, periodic WAL checkpoint, config-reload worker restart, WebUI `MatchSubcategories` fields, coordinated DB restart watchdog. diff --git a/src/Torrentarr.Host/Program.cs b/src/Torrentarr.Host/Program.cs index 9b2a9de6..f3830bff 100644 --- a/src/Torrentarr.Host/Program.cs +++ b/src/Torrentarr.Host/Program.cs @@ -154,6 +154,8 @@ builder.Services.AddSingleton(levelSwitch); builder.Services.AddSingleton(config); builder.Services.AddSingleton(configLoader); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -2744,10 +2746,7 @@ static async Task SaveAndRespondConfigUpdate( case "full": await workerMgr.RestartAllWorkersAsync(); if (qbitCategoryMgr != null) - { - foreach (var cat in CategoryOwnershipHelper.GetQBitOnlyManagedCategories(updatedConfig)) - await qbitCategoryMgr.RestartCategoryAsync(cat); - } + await qbitCategoryMgr.SyncWorkersWithConfigAsync(); break; case "multi_arr": case "single_arr": diff --git a/src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs b/src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs index 2a9642d4..8d43650a 100644 --- a/src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs +++ b/src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Torrentarr.Infrastructure.Services; namespace Torrentarr.Infrastructure.Database; @@ -12,6 +13,7 @@ public static class DatabaseRetryExtensions public static async Task SaveChangesWithRetryAsync( this DbContext context, ILogger? logger = null, + DatabaseRestartCoordinator? restartCoordinator = null, int maxAttempts = 5, CancellationToken cancellationToken = default) { @@ -19,10 +21,14 @@ public static async Task SaveChangesWithRetryAsync( { try { - return await context.SaveChangesAsync(cancellationToken); + var result = await context.SaveChangesAsync(cancellationToken); + restartCoordinator?.RecordDatabaseSuccess(); + return result; } catch (Exception ex) when (IsRetriable(ex) && attempt < maxAttempts - 1) { + restartCoordinator?.RecordDatabaseError(); + var delay = TimeSpan.FromMilliseconds(500 * Math.Pow(2, attempt)); logger?.LogWarning(ex, "Database save retry {Attempt}/{Max}, waiting {Delay}ms", attempt + 1, maxAttempts, delay.TotalMilliseconds); @@ -45,6 +51,11 @@ public static async Task SaveChangesWithRetryAsync( } } } + catch (Exception ex) when (IsRetriable(ex)) + { + restartCoordinator?.RecordDatabaseError(); + throw; + } } return await context.SaveChangesAsync(cancellationToken); diff --git a/src/Torrentarr.Infrastructure/Services/ArrSyncService.cs b/src/Torrentarr.Infrastructure/Services/ArrSyncService.cs index e20e49dd..f4f5ac4e 100644 --- a/src/Torrentarr.Infrastructure/Services/ArrSyncService.cs +++ b/src/Torrentarr.Infrastructure/Services/ArrSyncService.cs @@ -27,15 +27,18 @@ public class ArrSyncService private readonly ILogger _logger; private readonly TorrentarrConfig _config; private readonly TorrentarrDbContext _db; + private readonly DatabaseRestartCoordinator _restartCoordinator; public ArrSyncService( ILogger logger, TorrentarrConfig config, - TorrentarrDbContext db) + TorrentarrDbContext db, + DatabaseRestartCoordinator restartCoordinator) { _logger = logger; _config = config; _db = db; + _restartCoordinator = restartCoordinator; } public async Task SyncAsync(string instanceName, CancellationToken ct = default) @@ -225,7 +228,7 @@ private async Task SyncRadarrAsync(string instanceName, ArrInstanceConfig cfg, C if (toDelete.Count > 0 && !ShouldSkipDestructiveDelete(movies.Count, dbMovies.Count, instanceName, "movies")) _db.Movies.RemoveRange(toDelete); - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); _logger.LogDebug("[{Instance}] ArrSyncService: Radarr {Name} synced {Count} movies - Added: {Added}, Updated: {Updated}, Deleted: {Deleted}", instanceName, instanceName, movies.Count, added, updated, toDelete.Count); _logger.LogTrace("[{Instance}] Finished updating database for Radarr instance {Name}", instanceName, instanceName); } @@ -269,7 +272,7 @@ private async Task SyncRadarrQueueAsync(string instanceName, ArrInstanceConfig c if (toDelete.Count > 0) _db.MovieQueue.RemoveRange(toDelete); - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); _logger.LogDebug("ArrSyncService: Radarr {Name} synced {Count} queue items", instanceName, queueItems.Count); // §1.7: Scan for ArrErrorCodesToBlocklist matches @@ -366,7 +369,7 @@ private async Task SyncSonarrAsync(string instanceName, ArrInstanceConfig cfg, C _db.Series.RemoveRange(seriesToDelete); } - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); var episodesAdded = 0; @@ -431,7 +434,7 @@ private async Task SyncSonarrAsync(string instanceName, ArrInstanceConfig cfg, C seriesEntity.Title, ep.SeasonNumber, ep.EpisodeNumber); } - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); } _logger.LogDebug("[{Instance}] ArrSyncService: Sonarr {Name} synced {SeriesCount} series - Series Added: {SeriesAdded}, Updated: {SeriesUpdated}, Deleted: {SeriesDeleted}, Episodes Added: {EpisodesAdded}", @@ -478,7 +481,7 @@ private async Task SyncSonarrQueueAsync(string instanceName, ArrInstanceConfig c if (toDelete.Count > 0) _db.EpisodeQueue.RemoveRange(toDelete); - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); _logger.LogDebug("ArrSyncService: Sonarr {Name} synced {Count} queue items", instanceName, queueItems.Count); // §1.7: Scan for ArrErrorCodesToBlocklist matches @@ -637,7 +640,7 @@ public async Task MarkRequestsAsync(string instanceName, CancellationToken ct = .ToListAsync(ct); foreach (var movie in allMovies) movie.IsRequest = requestTmdbIds.Contains(movie.TmdbId); - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); _logger.LogDebug("ArrSyncService: marked {Count} movies as requests for {Name}", requestTmdbIds.Count, instanceName); } @@ -652,7 +655,7 @@ public async Task MarkRequestsAsync(string instanceName, CancellationToken ct = .ToListAsync(ct); foreach (var ep in allEps) ep.IsRequest = requestSeriesIds.Contains(ep.SeriesId); - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); _logger.LogDebug("ArrSyncService: marked episodes for {Count} requested series for {Name}", requestSeriesIds.Count, instanceName); } @@ -730,7 +733,7 @@ private async Task SyncLidarrAsync(string instanceName, ArrInstanceConfig cfg, C if (artistsToDelete.Count > 0 && !ShouldSkipDestructiveDelete(artists.Count, dbArtists.Count, instanceName, "artists")) _db.Artists.RemoveRange(artistsToDelete); - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); // Fetch all albums at once List albums; @@ -818,7 +821,7 @@ private async Task SyncLidarrAsync(string instanceName, ArrInstanceConfig cfg, C } // Save so EF Core assigns EntryId to new album rows - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); // Compute search metadata for each album using bulk track files (one API call per album) foreach (var (lidarrAlbumId, albumEntity) in albumEntityByLidarrId) @@ -865,7 +868,7 @@ private async Task SyncLidarrAsync(string instanceName, ArrInstanceConfig cfg, C albumEntity.Reason = DetermineReasonWithAvailability(albumEntity.HasFile, albumEntity.QualityMet, albumEntity.CustomFormatMet, isAvailable, searchConfig); albumEntity.Searched = DetermineSearched(albumEntity.HasFile, albumEntity.QualityMet, albumEntity.CustomFormatMet, searchConfig); } - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); // When albums API returned empty (delete guarded) but DB still has rows, resolve track FKs from DB. foreach (var album in dbAlbums.Values) @@ -919,7 +922,7 @@ private async Task SyncLidarrAsync(string instanceName, ArrInstanceConfig cfg, C _logger.LogTrace("DB Insert: Track {Title} added to database (new)", track.Title); } - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); _logger.LogDebug("[{Instance}] ArrSyncService: Lidarr {Name} synced - Artists: Added: {ArtistsAdded}, Updated: {ArtistsUpdated}, Deleted: {ArtistsDeleted} | Albums: Added: {AlbumsAdded}, Updated: {AlbumsUpdated}, Deleted: {AlbumsDeleted} | Tracks Added: {TracksAdded}", instanceName, instanceName, artistsAdded, artistsUpdated, artistsToDelete.Count, albumsAdded, albumsUpdated, albumsToDelete.Count, tracksAdded); _logger.LogTrace("Finished updating database for Lidarr instance {Name}", instanceName); @@ -964,7 +967,7 @@ private async Task SyncLidarrQueueAsync(string instanceName, ArrInstanceConfig c if (toDelete.Count > 0) _db.AlbumQueue.RemoveRange(toDelete); - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); _logger.LogDebug("ArrSyncService: Lidarr {Name} synced {Count} queue items", instanceName, queueItems.Count); // §1.7: Scan for ArrErrorCodesToBlocklist matches diff --git a/src/Torrentarr.Infrastructure/Services/DatabaseRestartCoordinator.cs b/src/Torrentarr.Infrastructure/Services/DatabaseRestartCoordinator.cs new file mode 100644 index 00000000..39cd2f85 --- /dev/null +++ b/src/Torrentarr.Infrastructure/Services/DatabaseRestartCoordinator.cs @@ -0,0 +1,44 @@ +namespace Torrentarr.Infrastructure.Services; + +/// +/// qBitrr database_restart_event parity: signal coordinated worker restart after persistent DB errors. +/// +public class DatabaseRestartCoordinator +{ + private readonly object _lock = new(); + private int _errorCount; + private DateTime _firstErrorTime; + private DateTime _lastErrorTime; + private volatile bool _restartRequested; + + public bool RestartRequested => _restartRequested; + + public void RecordDatabaseError() + { + lock (_lock) + { + var now = DateTime.UtcNow; + if (now - _lastErrorTime > TimeSpan.FromMinutes(5)) + { + _errorCount = 0; + _firstErrorTime = now; + } + + _errorCount++; + _lastErrorTime = now; + + if (now - _firstErrorTime > TimeSpan.FromMinutes(5)) + _restartRequested = true; + } + } + + public void RecordDatabaseSuccess() + { + lock (_lock) + { + _errorCount = 0; + } + } + + public void ClearRestartRequest() => _restartRequested = false; +} diff --git a/src/Torrentarr.Infrastructure/Services/DatabaseRestartWatchdogService.cs b/src/Torrentarr.Infrastructure/Services/DatabaseRestartWatchdogService.cs new file mode 100644 index 00000000..28698a65 --- /dev/null +++ b/src/Torrentarr.Infrastructure/Services/DatabaseRestartWatchdogService.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Torrentarr.Infrastructure.Services; + +/// +/// Restarts all workers when requests coordinated recovery. +/// +public class DatabaseRestartWatchdogService : BackgroundService +{ + private readonly ILogger _logger; + private readonly DatabaseRestartCoordinator _coordinator; + private readonly ArrWorkerManager _arrWorkers; + private readonly QBitCategoryWorkerManager _qbitCategoryWorkers; + + public DatabaseRestartWatchdogService( + ILogger logger, + DatabaseRestartCoordinator coordinator, + ArrWorkerManager arrWorkers, + QBitCategoryWorkerManager qbitCategoryWorkers) + { + _logger = logger; + _coordinator = coordinator; + _arrWorkers = arrWorkers; + _qbitCategoryWorkers = qbitCategoryWorkers; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (_coordinator.RestartRequested) + { + _logger.LogCritical( + "Database restart signal detected — restarting all workers for coordinated recovery"); + _coordinator.ClearRestartRequest(); + + try + { + await _arrWorkers.RestartAllWorkersAsync(); + await _qbitCategoryWorkers.RestartAllCategoriesAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Coordinated database restart failed"); + } + } + + try + { + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + } +} diff --git a/src/Torrentarr.Infrastructure/Services/QBitCategoryWorkerManager.cs b/src/Torrentarr.Infrastructure/Services/QBitCategoryWorkerManager.cs index f2b79fc4..f46140a0 100644 --- a/src/Torrentarr.Infrastructure/Services/QBitCategoryWorkerManager.cs +++ b/src/Torrentarr.Infrastructure/Services/QBitCategoryWorkerManager.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Torrentarr.Core.Configuration; using Torrentarr.Core.Services; using Torrentarr.Infrastructure.ApiClients.QBittorrent; @@ -17,29 +18,30 @@ public class QBitCategoryWorkerManager : BackgroundService private readonly IServiceScopeFactory _scopeFactory; private readonly ProcessStateManager _stateManager; private readonly IConnectivityService _connectivityService; - private readonly QBittorrentConnectionManager _qbitManager; - private readonly Dictionary _workerCts = new(StringComparer.OrdinalIgnoreCase); - private readonly List _workerTasks = new(); + private readonly ConcurrentDictionary _workers = + new(StringComparer.OrdinalIgnoreCase); + + private CancellationToken _appStopping = CancellationToken.None; public QBitCategoryWorkerManager( ILogger logger, TorrentarrConfig config, IServiceScopeFactory scopeFactory, ProcessStateManager stateManager, - IConnectivityService connectivityService, - QBittorrentConnectionManager qbitManager) + IConnectivityService connectivityService) { _logger = logger; _config = config; _scopeFactory = scopeFactory; _stateManager = stateManager; _connectivityService = connectivityService; - _qbitManager = qbitManager; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + _appStopping = stoppingToken; + foreach (var category in CategoryOwnershipHelper.GetQBitOnlyManagedCategories(_config)) { var stateName = $"qbit-{category}"; @@ -51,13 +53,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) MetricType = "category", Alive = false }); - - var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); - _workerCts[category] = cts; - _workerTasks.Add(RunCategoryLoopAsync(category, stateName, cts.Token)); + StartCategoryWorker(category, stoppingToken); } - if (_workerTasks.Count == 0) + if (_workers.IsEmpty) { _logger.LogDebug("No qBit-only managed categories to process"); try { await Task.Delay(Timeout.Infinite, stoppingToken); } @@ -65,22 +64,113 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) return; } - _logger.LogInformation("Started {Count} qBit-only category worker(s)", _workerTasks.Count); - try { await Task.WhenAll(_workerTasks); } + _logger.LogInformation("Started {Count} qBit-only category worker(s)", _workers.Count); + try { await Task.Delay(Timeout.Infinite, stoppingToken); } catch (OperationCanceledException) { } + + await StopAllCategoriesAsync(); } public async Task RestartCategoryAsync(string category) { - if (!_workerCts.TryGetValue(category, out var existing)) + if (!_workers.ContainsKey(category)) + { + StartCategoryWorker(category, _appStopping); + return; + } + + _logger.LogInformation("Restarting qBit category worker for {Category}", category); + var stateName = $"qbit-{category}"; + _stateManager.Update(stateName, s => { s.Alive = false; s.Rebuilding = true; }); + + if (_workers.TryRemove(category, out var old)) + { + old.Cts.Cancel(); + try { await old.Task.WaitAsync(TimeSpan.FromSeconds(10)); } + catch (OperationCanceledException) { } + catch (TimeoutException) + { + _logger.LogWarning("qBit category worker {Category} did not stop within 10s", category); + } + catch (Exception ex) + { + _logger.LogError(ex, "qBit category worker {Category} faulted during shutdown", category); + } + old.Cts.Dispose(); + } + + StartCategoryWorker(category, _appStopping); + } + + public async Task RestartAllCategoriesAsync() + { + foreach (var category in _workers.Keys.ToList()) + await RestartCategoryAsync(category); + + foreach (var category in CategoryOwnershipHelper.GetQBitOnlyManagedCategories(_config)) + { + if (!_workers.ContainsKey(category)) + StartCategoryWorker(category, _appStopping); + } + } + + public async Task SyncWorkersWithConfigAsync() + { + var desired = CategoryOwnershipHelper.GetQBitOnlyManagedCategories(_config).ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var category in desired) + { + var stateName = $"qbit-{category}"; + if (_stateManager.GetState(stateName) == null) + { + _stateManager.Initialize(stateName, new ArrProcessState + { + Name = stateName, + Category = category, + Kind = "category", + MetricType = "category", + Alive = false + }); + } + + if (!_workers.ContainsKey(category)) + StartCategoryWorker(category, _appStopping); + } + + foreach (var category in _workers.Keys.ToList()) + { + if (!desired.Contains(category)) + await StopCategoryAsync(category); + } + } + + private void StartCategoryWorker(string category, CancellationToken appStopping) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(appStopping); + var task = Task.Run(() => RunCategoryLoopAsync(category, $"qbit-{category}", cts.Token), CancellationToken.None); + _workers[category] = (task, cts); + } + + private async Task StopCategoryAsync(string category) + { + if (!_workers.TryRemove(category, out var worker)) return; - existing.Cancel(); - try { await Task.WhenAny(_workerTasks); } catch { /* ignore */ } + worker.Cts.Cancel(); + try { await worker.Task.WaitAsync(TimeSpan.FromSeconds(10)); } + catch (OperationCanceledException) { } + catch (TimeoutException) { } + catch (Exception ex) { _logger.LogDebug(ex, "qBit category worker {Category} stop error", category); } + worker.Cts.Dispose(); + + var stateName = $"qbit-{category}"; + _stateManager.Update(stateName, s => s.Alive = false); + } - var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); - _workerCts[category] = cts; - _ = RunCategoryLoopAsync(category, $"qbit-{category}", cts.Token); + private async Task StopAllCategoriesAsync() + { + foreach (var category in _workers.Keys.ToList()) + await StopCategoryAsync(category); } private async Task RunCategoryLoopAsync(string category, string stateName, CancellationToken ct) diff --git a/src/Torrentarr.Infrastructure/Services/QualityProfileSwitcherService.cs b/src/Torrentarr.Infrastructure/Services/QualityProfileSwitcherService.cs index 20b32200..cb5337d5 100644 --- a/src/Torrentarr.Infrastructure/Services/QualityProfileSwitcherService.cs +++ b/src/Torrentarr.Infrastructure/Services/QualityProfileSwitcherService.cs @@ -15,13 +15,16 @@ public class QualityProfileSwitcherService { private readonly ILogger _logger; private readonly TorrentarrDbContext _db; + private readonly DatabaseRestartCoordinator _restartCoordinator; public QualityProfileSwitcherService( ILogger logger, - TorrentarrDbContext db) + TorrentarrDbContext db, + DatabaseRestartCoordinator restartCoordinator) { _logger = logger; _db = db; + _restartCoordinator = restartCoordinator; } // ── Startup ─────────────────────────────────────────────────────────────── @@ -59,7 +62,7 @@ public async Task ForceResetAllTempProfilesAsync( movie.OriginalProfileId = null; movie.LastProfileSwitchTime = null; } - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); break; case "sonarr": @@ -78,7 +81,7 @@ public async Task ForceResetAllTempProfilesAsync( s.OriginalProfileId = null; s.LastProfileSwitchTime = null; } - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); break; case "lidarr": @@ -97,7 +100,7 @@ public async Task ForceResetAllTempProfilesAsync( artist.OriginalProfileId = null; artist.LastProfileSwitchTime = null; } - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); break; } } @@ -147,7 +150,7 @@ public async Task RestoreTimedOutProfilesAsync( movie.OriginalProfileId = null; movie.LastProfileSwitchTime = null; } - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); break; case "sonarr": @@ -169,7 +172,7 @@ public async Task RestoreTimedOutProfilesAsync( s.OriginalProfileId = null; s.LastProfileSwitchTime = null; } - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); break; case "lidarr": @@ -191,7 +194,7 @@ public async Task RestoreTimedOutProfilesAsync( artist.OriginalProfileId = null; artist.LastProfileSwitchTime = null; } - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); break; } } @@ -292,7 +295,7 @@ private async Task SwitchMovieProfilesAsync( } if (changed) - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); } private async Task SwitchSeriesProfilesAsync( @@ -354,7 +357,7 @@ private async Task SwitchSeriesProfilesAsync( } if (changed) - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); } private async Task SwitchArtistProfilesAsync( @@ -416,7 +419,7 @@ private async Task SwitchArtistProfilesAsync( } if (changed) - await _db.SaveChangesAsync(ct); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); } // ── Restore helpers ─────────────────────────────────────────────────────── diff --git a/src/Torrentarr.Infrastructure/Services/SearchExecutor.cs b/src/Torrentarr.Infrastructure/Services/SearchExecutor.cs index 02013e0a..d69f8991 100644 --- a/src/Torrentarr.Infrastructure/Services/SearchExecutor.cs +++ b/src/Torrentarr.Infrastructure/Services/SearchExecutor.cs @@ -14,6 +14,7 @@ public class SearchExecutor : ISearchExecutor private readonly TorrentarrConfig _config; private readonly TorrentarrDbContext _db; private readonly QualityProfileSwitcherService _profileSwitcher; + private readonly DatabaseRestartCoordinator _restartCoordinator; // Cached per-instance Arr clients — created once, reused across calls private readonly Dictionary _clientCache = new(StringComparer.OrdinalIgnoreCase); @@ -34,12 +35,14 @@ public SearchExecutor( ILogger logger, TorrentarrConfig config, TorrentarrDbContext db, - QualityProfileSwitcherService profileSwitcher) + QualityProfileSwitcherService profileSwitcher, + DatabaseRestartCoordinator restartCoordinator) { _logger = logger; _config = config; _db = db; _profileSwitcher = profileSwitcher; + _restartCoordinator = restartCoordinator; } public async Task ExecuteSearchesAsync( @@ -302,7 +305,7 @@ private async Task MarkAsSearchedAsync( if (movie != null) { movie.Searched = true; - await _db.SaveChangesAsync(cancellationToken); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: cancellationToken); } break; @@ -312,7 +315,7 @@ private async Task MarkAsSearchedAsync( if (episode != null) { episode.Searched = true; - await _db.SaveChangesAsync(cancellationToken); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: cancellationToken); } break; @@ -322,7 +325,7 @@ private async Task MarkAsSearchedAsync( if (album != null) { album.Searched = true; - await _db.SaveChangesAsync(cancellationToken); + await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: cancellationToken); } break; } diff --git a/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs b/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs index a0bc311c..c5f9997a 100644 --- a/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs +++ b/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs @@ -30,6 +30,7 @@ public class TorrentProcessor : ITorrentProcessor private readonly IArrImportService? _importService; private readonly ISeedingService? _seedingService; private readonly IImportPathTracker? _pathTracker; + private readonly DatabaseRestartCoordinator _restartCoordinator; private readonly HashSet _specialCategories; @@ -39,6 +40,7 @@ public TorrentProcessor( TorrentarrDbContext dbContext, TorrentarrConfig config, ITorrentCacheService cache, + DatabaseRestartCoordinator restartCoordinator, IArrImportService? importService = null, ISeedingService? seedingService = null, IImportPathTracker? pathTracker = null) @@ -48,6 +50,7 @@ public TorrentProcessor( _dbContext = dbContext; _config = config; _cache = cache; + _restartCoordinator = restartCoordinator; _importService = importService; _seedingService = seedingService; _pathTracker = pathTracker; @@ -270,7 +273,7 @@ public async Task ImportTorrentAsync(string hash, string? qbitInstance = null, C if (result.Success) { libraryEntry.Imported = true; - await _dbContext.SaveChangesAsync(cancellationToken); + await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken:cancellationToken); _logger.LogInformation("Successfully triggered import for torrent {Hash}: {Message}", hash, result.Message); @@ -296,7 +299,7 @@ public async Task ImportTorrentAsync(string hash, string? qbitInstance = null, C { _logger.LogWarning("ArrImportService not available, marking as imported without triggering"); libraryEntry.Imported = true; - await _dbContext.SaveChangesAsync(cancellationToken); + await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken:cancellationToken); } } @@ -1011,7 +1014,7 @@ private async Task AddTagAsync(TorrentInfo torrent, QBittorrentClient client, st case AllowedStalledTag: entry.AllowedStalled = true; break; case FreeSpacePausedTag: entry.FreeSpacePaused = true; break; } - await _dbContext.SaveChangesAsync(ct); + await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken:ct); } } else @@ -1037,7 +1040,7 @@ private async Task RemoveTagAsync(TorrentInfo torrent, QBittorrentClient client, case AllowedStalledTag: entry.AllowedStalled = false; break; case FreeSpacePausedTag: entry.FreeSpacePaused = false; break; } - await _dbContext.SaveChangesAsync(ct); + await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken:ct); } } else @@ -1131,7 +1134,7 @@ private async Task EnsureTorrentInDatabaseAsync( }; _dbContext.TorrentLibrary.Add(entry); - await _dbContext.SaveChangesAsync(cancellationToken); + await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken:cancellationToken); _logger.LogTrace("Added torrent {Hash} to database", torrent.Hash); } @@ -1278,7 +1281,7 @@ private async Task AddStalledTagAsync(TorrentInfo torrent, QBittorrentClient cli if (entry != null) { entry.AllowedStalled = true; - await _dbContext.SaveChangesAsync(ct); + await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken:ct); } } else @@ -1299,7 +1302,7 @@ private async Task RemoveStalledTagAsync(TorrentInfo torrent, QBittorrentClient if (entry != null) { entry.AllowedStalled = false; - await _dbContext.SaveChangesAsync(ct); + await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken:ct); } } else diff --git a/src/Torrentarr.Workers/Program.cs b/src/Torrentarr.Workers/Program.cs index 57ff7586..4414242a 100644 --- a/src/Torrentarr.Workers/Program.cs +++ b/src/Torrentarr.Workers/Program.cs @@ -144,6 +144,7 @@ }); // Add services + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/ArrMediaServiceTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/ArrMediaServiceTests.cs index fb7e7d19..e52211c6 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/ArrMediaServiceTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/ArrMediaServiceTests.cs @@ -32,7 +32,8 @@ private static ArrMediaService CreateService(TorrentarrConfig? config = null) var mockSyncService = new Mock( NullLogger.Instance, cfg, - dbContext); + dbContext, + new DatabaseRestartCoordinator()); return new ArrMediaService( NullLogger.Instance, diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/ArrSyncServiceTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/ArrSyncServiceTests.cs index 943a2847..711c066a 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/ArrSyncServiceTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/ArrSyncServiceTests.cs @@ -41,7 +41,7 @@ public void Dispose() private ArrSyncService CreateService(TorrentarrConfig? config = null) { config ??= new TorrentarrConfig(); - return new ArrSyncService(NullLogger.Instance, config, _db); + return new ArrSyncService(NullLogger.Instance, config, _db, new DatabaseRestartCoordinator()); } private static ArrInstanceConfig MakeInstance(string type, string uri = "http://localhost:7878") diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/AvailabilityCheckTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/AvailabilityCheckTests.cs index b20d688c..922fef7c 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/AvailabilityCheckTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/AvailabilityCheckTests.cs @@ -31,7 +31,7 @@ private static ArrSyncService CreateService() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var dbContext = new TorrentarrDbContext(options); - return new ArrSyncService(Logger, new TorrentarrConfig(), dbContext); + return new ArrSyncService(Logger, new TorrentarrConfig(), dbContext, new DatabaseRestartCoordinator()); } private static bool CallCheckEpisodeAvailability(DateTime? airDateUtc, string episodeTitle) diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/QualityProfileSwitcherServiceTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/QualityProfileSwitcherServiceTests.cs index e69257c2..e9f37507 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/QualityProfileSwitcherServiceTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/QualityProfileSwitcherServiceTests.cs @@ -25,7 +25,7 @@ private static QualityProfileSwitcherService CreateService(string? dbName = null .Options; var db = new TorrentarrDbContext(options); return new QualityProfileSwitcherService( - NullLogger.Instance, db); + NullLogger.Instance, db, new DatabaseRestartCoordinator()); } // ── ForceResetAllTempProfilesAsync ──────────────────────────────────────── diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/ScanQueueForBlocklistTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/ScanQueueForBlocklistTests.cs index 0eb0d8d9..f0481b54 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/ScanQueueForBlocklistTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/ScanQueueForBlocklistTests.cs @@ -32,7 +32,8 @@ private static ArrSyncService CreateService() return new ArrSyncService( NullLogger.Instance, new TorrentarrConfig(), - new TorrentarrDbContext(options)); + new TorrentarrDbContext(options), + new DatabaseRestartCoordinator()); } private static async Task InvokeScanAsync( diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/SearchExecutorTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/SearchExecutorTests.cs index 3b24d1fd..cc05e456 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/SearchExecutorTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/SearchExecutorTests.cs @@ -23,13 +23,15 @@ private static SearchExecutor CreateService(TorrentarrConfig? config = null, Tor var switcher = new QualityProfileSwitcherService( NullLogger.Instance, - db); + db, + new DatabaseRestartCoordinator()); return new SearchExecutor( NullLogger.Instance, cfg, db, - switcher); + switcher, + new DatabaseRestartCoordinator()); } private static TorrentarrConfig CreateConfigWithRadarr(int searchLoopDelay = 30, int searchLimit = 5) diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/TaglessInstanceScopeTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/TaglessInstanceScopeTests.cs index 16ed4d3a..1fbcf59f 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/TaglessInstanceScopeTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/TaglessInstanceScopeTests.cs @@ -130,7 +130,8 @@ private static bool InvokeHasTag( new QBittorrentConnectionManager(NullLogger.Instance), db, config, - new TorrentCacheService(NullLogger.Instance)), + new TorrentCacheService(NullLogger.Instance), + new DatabaseRestartCoordinator()), nameof(FreeSpaceService) => new FreeSpaceService( NullLogger.Instance, config, diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/TaglessScopingTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/TaglessScopingTests.cs index 194ab269..c8684ea1 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/TaglessScopingTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/TaglessScopingTests.cs @@ -46,7 +46,8 @@ public async Task TorrentProcessor_HasTag_ReadsFreeSpacePausedPerQbitInstance() new QBittorrentConnectionManager(NullLogger.Instance), db, config, - new TorrentCacheService(NullLogger.Instance)); + new TorrentCacheService(NullLogger.Instance), + new DatabaseRestartCoordinator()); var seedboxTorrent = new TorrentInfo { Hash = "abc", QBitInstanceName = "qBit-seedbox" }; var primaryTorrent = new TorrentInfo { Hash = "abc", QBitInstanceName = "qBit" }; diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/TorrentProcessorTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/TorrentProcessorTests.cs index 9b3b8d44..98db271c 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/TorrentProcessorTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/TorrentProcessorTests.cs @@ -55,7 +55,8 @@ private TorrentProcessor CreateProcessor(TorrentarrConfig? config = null) manager, _db, config, - new TorrentCacheService(NullLogger.Instance)); + new TorrentCacheService(NullLogger.Instance), + new DatabaseRestartCoordinator()); } // ── Constructor ──────────────────────────────────────────────────────────── @@ -230,6 +231,7 @@ public async Task ProcessSingleTorrentAsync_CustomFormatUnmet_BlockedByHnr_DoesN _db, config, new TorrentCacheService(NullLogger.Instance), + new DatabaseRestartCoordinator(), importMock.Object, seedingMock.Object); diff --git a/webui/src/config/torrentHandlingSummary.ts b/webui/src/config/torrentHandlingSummary.ts index dc886781..454a3f3d 100644 --- a/webui/src/config/torrentHandlingSummary.ts +++ b/webui/src/config/torrentHandlingSummary.ts @@ -291,6 +291,7 @@ export function getQbitTorrentHandlingSummary( {}, ); const managedCats = get(state, ["ManagedCategories"]) as string[] | undefined; + const matchSubcategories = Boolean(get(state, ["MatchSubcategories"])); const managedPreview = Array.isArray(managedCats) && managedCats.length > 0 ? managedCats.slice(0, 3).join(", ") + @@ -312,8 +313,11 @@ export function getQbitTorrentHandlingSummary( // 1. New / in managed category if (managedPreview) { + const scope = matchSubcategories + ? `When a torrent category matches a managed prefix (Match subcategories is on; e.g. ${managedPreview} matches seed as well as seed/child paths)` + : `When a torrent is in a managed category exactly (e.g. ${managedPreview})`; const newLine = - `When a torrent is in a managed category (e.g. ${managedPreview})` + + scope + (Number.isFinite(ignoreYounger) && ignoreYounger >= 0 ? `, it is left alone for the first ${formatSeconds(ignoreYounger)} so it is not treated as stalled.` : "."); @@ -329,7 +333,11 @@ export function getQbitTorrentHandlingSummary( blocks.push("Stalled downloads are not removed."); } else if (stalledDelayMin === 0) { let s = "Stalled downloads are not removed (infinite delay)"; - if (managedPreview) s += ` in ${managedPreview}`; + if (managedPreview) { + s += matchSubcategories + ? ` for torrents under managed category prefixes (${managedPreview})` + : ` in ${managedPreview}`; + } s += "."; if (Number.isFinite(ignoreYounger) && ignoreYounger >= 0) { s += ` New torrents are ignored for the first ${formatSeconds(ignoreYounger)}.`; @@ -337,7 +345,11 @@ export function getQbitTorrentHandlingSummary( blocks.push(s); } else { let s = `If the download stops progressing for ${formatMinutes(stalledDelayMin)}`; - if (managedPreview) s += ` in ${managedPreview}`; + if (managedPreview) { + s += matchSubcategories + ? ` for torrents under managed category prefixes (${managedPreview})` + : ` in ${managedPreview}`; + } s += ", Torrentarr removes it."; if (Number.isFinite(ignoreYounger) && ignoreYounger >= 0) { s += ` New torrents are ignored for the first ${formatSeconds(ignoreYounger)} (not treated as stalled).`; diff --git a/webui/src/pages/ConfigView.tsx b/webui/src/pages/ConfigView.tsx index f0d15c73..6b402006 100644 --- a/webui/src/pages/ConfigView.tsx +++ b/webui/src/pages/ConfigView.tsx @@ -527,6 +527,13 @@ const QBIT_FIELDS: FieldDefinition[] = [ return []; }, }, + { + label: "Match subcategories", + path: ["MatchSubcategories"], + type: "checkbox", + description: + "When off (default), each managed category must match the qBittorrent category string exactly (use full paths like parent/child). When on, each entry here is a prefix: torrents in child categories (e.g. seed/foo) are included when seed is listed.", + }, { label: "Max Upload Ratio", path: ["CategorySeeding", "MaxUploadRatio"], @@ -736,6 +743,13 @@ const ARR_GENERAL_FIELDS: FieldDefinition[] = [ return undefined; }, }, + { + label: "Match subcategories (override)", + path: ["MatchSubcategories"], + type: "checkbox", + description: + "Optional. When set, overrides the qBit instance MatchSubcategories default for this Arr only (explicit true/false wins; omit to inherit from [qBit] / [qBit-*]).", + }, { label: "Re-search", path: ["ReSearch"], type: "checkbox" }, { label: "Import Mode", @@ -2028,6 +2042,7 @@ export function ConfigView(props?: ConfigViewProps): JSX.Element { Port: 8080, UserName: "", Password: "", + MatchSubcategories: false, }; setFormState( produce(formState, (draft) => { From 985e2ee3c2c6780d658fcd24807222c0278242a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 15 Jun 2026 15:41:36 +0000 Subject: [PATCH 4/7] [pre-commit] auto fixes from pre-commit hooks for more information, see https://pre-commit.ci --- .../Services/TorrentProcessor.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs b/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs index c5f9997a..ad9f55fa 100644 --- a/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs +++ b/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs @@ -273,7 +273,7 @@ public async Task ImportTorrentAsync(string hash, string? qbitInstance = null, C if (result.Success) { libraryEntry.Imported = true; - await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken:cancellationToken); + await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: cancellationToken); _logger.LogInformation("Successfully triggered import for torrent {Hash}: {Message}", hash, result.Message); @@ -299,7 +299,7 @@ public async Task ImportTorrentAsync(string hash, string? qbitInstance = null, C { _logger.LogWarning("ArrImportService not available, marking as imported without triggering"); libraryEntry.Imported = true; - await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken:cancellationToken); + await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: cancellationToken); } } @@ -1014,7 +1014,7 @@ private async Task AddTagAsync(TorrentInfo torrent, QBittorrentClient client, st case AllowedStalledTag: entry.AllowedStalled = true; break; case FreeSpacePausedTag: entry.FreeSpacePaused = true; break; } - await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken:ct); + await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); } } else @@ -1040,7 +1040,7 @@ private async Task RemoveTagAsync(TorrentInfo torrent, QBittorrentClient client, case AllowedStalledTag: entry.AllowedStalled = false; break; case FreeSpacePausedTag: entry.FreeSpacePaused = false; break; } - await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken:ct); + await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); } } else @@ -1134,7 +1134,7 @@ private async Task EnsureTorrentInDatabaseAsync( }; _dbContext.TorrentLibrary.Add(entry); - await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken:cancellationToken); + await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: cancellationToken); _logger.LogTrace("Added torrent {Hash} to database", torrent.Hash); } @@ -1281,7 +1281,7 @@ private async Task AddStalledTagAsync(TorrentInfo torrent, QBittorrentClient cli if (entry != null) { entry.AllowedStalled = true; - await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken:ct); + await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); } } else @@ -1302,7 +1302,7 @@ private async Task RemoveStalledTagAsync(TorrentInfo torrent, QBittorrentClient if (entry != null) { entry.AllowedStalled = false; - await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken:ct); + await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct); } } else From a5e21cdffb40e7809a21dcdd4c66dbb58ee530fe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 15 Jun 2026 16:14:00 +0000 Subject: [PATCH 5/7] Complete qBitrr 5.12.3 parity: full OpenAPI, doc routes, Lidarr filters - Expand docs/assets/openapi.json to 72 paths (all 66 qBitrr paths + 6 extensions) - Add scripts/generate-openapi-from-qbitrr.py and strengthen check-openapi-drift.sh - Serve curated spec at /api|web/openapi.json; Swagger aliases at /api|web/docs - Implement Lidarr artists missing/reason query param filters - Mark db_lock.py as intentional-divergence; close parity matrix (no partial rows) Co-authored-by: Feramance --- docs/assets/openapi.json | 2963 ++++++++++++++++- docs/development/release-process.md | 6 +- docs/parity/certification-report.md | 4 +- docs/parity/contributor-reference.md | 10 +- docs/parity/full-parity-matrix.md | 8 +- scripts/check-openapi-drift.sh | 19 +- scripts/generate-openapi-from-qbitrr.py | 163 + src/Torrentarr.Host/CuratedOpenApiDocument.cs | 31 + src/Torrentarr.Host/Program.cs | 6 + src/Torrentarr.Host/Torrentarr.Host.csproj | 4 + .../Endpoints/ArrCatalogEndpoints.cs | 74 +- .../Api/LidarrArtistsEndpointTests.cs | 18 + .../Api/OpenApiDocEndpointTests.cs | 43 + 13 files changed, 3293 insertions(+), 56 deletions(-) create mode 100644 scripts/generate-openapi-from-qbitrr.py create mode 100644 src/Torrentarr.Host/CuratedOpenApiDocument.cs create mode 100644 tests/Torrentarr.Host.Tests/Api/OpenApiDocEndpointTests.cs diff --git a/docs/assets/openapi.json b/docs/assets/openapi.json index aa0bbbf5..439b467e 100644 --- a/docs/assets/openapi.json +++ b/docs/assets/openapi.json @@ -1,284 +1,3171 @@ { + "components": { + "responses": { + "Unauthorized": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Not authenticated" + } + }, + "securitySchemes": { + "bearerAuth": { + "description": "Value of WebUI.Token in the Authorization header. Query ?token= is also accepted by the server but is discouraged.", + "scheme": "bearer", + "type": "http" + } + } + }, "info": { - "description": "API for qBittorrent + Arr automation (qBitrr C# port). Subset aligned with qBitrr 5.12.3.", "title": "Torrentarr API", - "version": "v1" + "version": "v1", + "description": "API for qBittorrent + Arr automation (Torrentarr \u2014 C# port of qBitrr). Aligned with qBitrr 5.12.3. When WebUI.AuthDisabled is false, most routes require a Bearer token (WebUI.Token) or a valid browser session after login. Interactive docs: GET /web/docs or GET /api/docs." }, - "openapi": "3.0.1", + "openapi": "3.0.3", "paths": { + "/": { + "get": { + "operationId": "root", + "responses": { + "302": { + "description": "Redirect" + } + }, + "security": [], + "summary": "Redirect to WebUI", + "tags": [ + "System" + ] + } + }, "/api/arr": { "get": { + "operationId": "api_arr_list", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Configured Arr instances (/api)", + "tags": [ + "WebUI" + ] + } + }, + "/api/arr/rebuild": { + "post": { + "operationId": "api_arr_rebuild", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Reload Arr configuration (/api)", + "tags": [ + "WebUI" + ] + } + }, + "/api/arr/test-connection": { + "post": { + "operationId": "api_arr_test_connection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "apiKey": { + "type": "string" + }, + "arrType": { + "type": "string" + }, + "instanceKey": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } }, - "summary": "Arr instance list" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Test Arr connection", + "tags": [ + "WebUI" + ] + } + }, + "/api/arr/{section}/restart": { + "post": { + "operationId": "api_arr_restart", + "parameters": [ + { + "in": "path", + "name": "section", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Restart Arr instance loops (/api)", + "tags": [ + "WebUI" + ] + } + }, + "/api/config": { + "get": { + "operationId": "api_config_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get configuration", + "tags": [ + "WebUI" + ] + }, + "post": { + "operationId": "api_config_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Update configuration", + "tags": [ + "WebUI" + ] + } + }, + "/api/docs": { + "get": { + "operationId": "api_swagger_ui", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "HTML" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Swagger UI (/api)", + "tags": [ + "System" + ] + } + }, + "/api/download-update": { + "get": { + "operationId": "api_download_update", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Download update artifact (/api)", + "tags": [ + "WebUI" + ] } }, "/api/lidarr/{category}/albums": { "get": { + "operationId": "api_lidarr_albums", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } }, - "summary": "Lidarr albums (API)" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Lidarr albums (/api)", + "tags": [ + "WebUI" + ] } }, "/api/lidarr/{category}/artist/{artist_id}": { "get": { + "operationId": "api_lidarr_artist_detail", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "artist_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Artist not found" } }, - "summary": "Lidarr artist detail (API)" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Lidarr artist with albums (/api)", + "tags": [ + "WebUI" + ] } }, "/api/lidarr/{category}/artist/{artist_id}/thumbnail": { "get": { + "operationId": "api_lidarr_artist_thumbnail", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "artist_id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "token", + "required": false, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "description": "OK" + "content": { + "image/*": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Cached artist image bytes" + }, + "304": { + "description": "Not modified (If-None-Match)" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Artist or image not found" } }, - "summary": "Lidarr artist thumbnail (API)" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Lidarr artist thumbnail (/api)", + "tags": [ + "WebUI" + ] } }, "/api/lidarr/{category}/artists": { "get": { + "operationId": "api_lidarr_artists", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "monitored", + "schema": { + "type": "string" + } + }, + { + "description": "Restrict to artists with at least one monitored album whose file is missing.", + "in": "query", + "name": "missing", + "schema": { + "type": "boolean" + } + }, + { + "description": "Restrict to artists with at least one album whose Reason matches. Accepts Missing, Quality, CustomFormat, Upgrade, or 'Not being searched' (also matches NULL).", + "in": "query", + "name": "reason", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } }, - "summary": "Lidarr artists (API)" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Lidarr artists catalog (/api)", + "tags": [ + "WebUI" + ] + } + }, + "/api/loglevel": { + "post": { + "operationId": "api_loglevel", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "level": { + "description": "CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG, TRACE", + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Set console log level (/api)", + "tags": [ + "WebUI" + ] + } + }, + "/api/logs": { + "get": { + "operationId": "api_logs_list", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List log files (/api)", + "tags": [ + "WebUI" + ] + } + }, + "/api/logs/{name}": { + "get": { + "operationId": "api_log_content", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "lines", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Log content (/api)", + "tags": [ + "WebUI" + ] + } + }, + "/api/logs/{name}/download": { + "get": { + "operationId": "api_log_download", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "File download" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Download log (/api)", + "tags": [ + "WebUI" + ] } }, "/api/meta": { "get": { + "operationId": "api_meta", + "parameters": [ + { + "in": "query", + "name": "force", + "schema": { + "type": "boolean" + } + } + ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } }, - "summary": "API metadata" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Version info (/api, requires Bearer or session)", + "tags": [ + "WebUI" + ] } }, - "/api/radarr/{category}/movie/{id}/thumbnail": { + "/api/openapi.json": { + "get": { + "operationId": "api_openapi_spec", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OpenAPI JSON" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "OpenAPI 3 document (/api)", + "tags": [ + "System" + ] + } + }, + "/api/processes": { "get": { + "operationId": "api_processes", "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } }, - "summary": "Radarr movie thumbnail (API)" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List worker processes (/api)", + "tags": [ + "WebUI" + ] + } + }, + "/api/processes/restart_all": { + "post": { + "operationId": "api_processes_restart_all", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Restart/reload all processes (/api)", + "tags": [ + "WebUI" + ] + } + }, + "/api/processes/{category}/{kind}/restart": { + "post": { + "operationId": "api_process_restart", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "kind", + "required": true, + "schema": { + "description": "search, torrent, category, or all", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Restart process (/api)", + "tags": [ + "WebUI" + ] + } + }, + "/api/radarr/{category}/movie/{id}/thumbnail": { + "get": { + "operationId": "api_radarr_movie_thumbnail", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "token", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "image/*": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Cached movie poster image bytes" + }, + "304": { + "description": "Not modified (If-None-Match)" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Movie or image not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Radarr movie poster thumbnail (/api)", + "tags": [ + "WebUI" + ] } }, "/api/radarr/{category}/movies": { "get": { + "operationId": "api_radarr_movies", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "year_min", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "year_max", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "monitored", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "has_file", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "quality_met", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "is_request", + "schema": { + "type": "boolean" + } + } + ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } }, - "summary": "Radarr movies (API)" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Radarr movies (/api)", + "tags": [ + "WebUI" + ] } }, "/api/sonarr/{category}/series": { "get": { + "operationId": "api_sonarr_series", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } }, - "summary": "Sonarr series (API)" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Sonarr series (/api)", + "tags": [ + "WebUI" + ] } }, "/api/sonarr/{category}/series/{id}/thumbnail": { "get": { + "operationId": "api_sonarr_series_thumbnail", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "token", + "required": false, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "description": "OK" + "content": { + "image/*": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Cached series poster image bytes" + }, + "304": { + "description": "Not modified (If-None-Match)" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Series or image not found" } }, - "summary": "Sonarr series thumbnail (API)" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Sonarr series poster thumbnail (/api)", + "tags": [ + "WebUI" + ] } }, "/api/status": { "get": { + "operationId": "api_status", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "qBitrr and Arr status (/api)", + "tags": [ + "WebUI" + ] + } + }, + "/api/token": { + "get": { + "operationId": "api_token", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Current WebUI API token (requires auth)", + "tags": [ + "Auth" + ] + } + }, + "/api/torrents/distribution": { + "get": { + "operationId": "api_torrents_distribution", "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } }, - "summary": "System status (requires auth)" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Torrent counts by category and qBit instance", + "tags": [ + "WebUI" + ] + } + }, + "/api/update": { + "post": { + "operationId": "api_update", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Trigger manual update (/api)", + "tags": [ + "WebUI" + ] } }, "/health": { "get": { + "operationId": "health", "responses": { "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "status": { + "type": "string" + } + }, + "type": "object" + } + } + }, "description": "OK" } }, - "summary": "Health check" + "security": [], + "summary": "Health check", + "tags": [ + "System" + ] + } + }, + "/login": { + "get": { + "operationId": "login", + "responses": { + "302": { + "description": "Redirect" + } + }, + "security": [], + "summary": "Login page redirect", + "tags": [ + "Auth" + ] + } + }, + "/signin-oidc": { + "get": { + "operationId": "web_oidc_callback_default", + "responses": { + "200": { + "description": "HTML or redirect" + }, + "302": { + "description": "Redirect" + } + }, + "security": [], + "summary": "OIDC callback (default WebUI.OIDC.CallbackPath; set in config if different)", + "tags": [ + "Auth" + ] + } + }, + "/sw.js": { + "get": { + "operationId": "sw_js", + "responses": { + "200": { + "description": "JavaScript" + } + }, + "security": [], + "summary": "Service worker", + "tags": [ + "System" + ] + } + }, + "/ui": { + "get": { + "operationId": "ui", + "responses": { + "302": { + "description": "Redirect" + } + }, + "security": [], + "summary": "WebUI entry (redirect)", + "tags": [ + "System" + ] } }, "/web/arr": { "get": { + "operationId": "web_arr_list", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Configured Arr instances (/web)", + "tags": [ + "WebUI" + ] + } + }, + "/web/arr/rebuild": { + "post": { + "operationId": "web_arr_rebuild", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Reload Arr configuration (/web)", + "tags": [ + "WebUI" + ] + } + }, + "/web/arr/test-connection": { + "post": { + "operationId": "web_arr_test_connection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "apiKey": { + "type": "string" + }, + "arrType": { + "type": "string" + }, + "instanceKey": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Test Arr connection", + "tags": [ + "WebUI" + ] + } + }, + "/web/arr/{section}/restart": { + "post": { + "operationId": "web_arr_restart", + "parameters": [ + { + "in": "path", + "name": "section", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Restart Arr instance loops (/web)", + "tags": [ + "WebUI" + ] + } + }, + "/web/auth/oidc/challenge": { + "get": { + "operationId": "web_oidc_challenge", + "responses": { + "302": { + "description": "Redirect to IdP" } }, - "summary": "Arr instance list with rollup counts" + "security": [], + "summary": "Start OIDC login", + "tags": [ + "Auth" + ] } }, "/web/auth/set-password": { "post": { + "operationId": "web_set_password", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "password": { + "type": "string" + }, + "setupToken": { + "description": "QBITRR_SETUP_TOKEN or WebUI.Token. Required unless the caller already has a valid session or bearer token.", + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "type": "object" + } + } + } + }, "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "403": { + "description": "Setup token or authenticated session required" } }, - "summary": "Set local password" + "security": [], + "summary": "Set local password (setup)", + "tags": [ + "Auth" + ] } }, "/web/config": { "get": { + "operationId": "web_config_get", "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } }, - "summary": "Get configuration" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get configuration", + "tags": [ + "WebUI" + ] }, "post": { + "operationId": "web_config_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Update configuration", + "tags": [ + "WebUI" + ] + } + }, + "/web/docs": { + "get": { + "operationId": "web_swagger_ui", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "HTML" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Swagger UI (/web)", + "tags": [ + "System" + ] + } + }, + "/web/download-update": { + "get": { + "operationId": "web_download_update", "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } }, - "summary": "Update configuration" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Download update artifact (/web)", + "tags": [ + "WebUI" + ] } }, "/web/lidarr/{category}/albums": { "get": { + "operationId": "web_lidarr_albums", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } }, - "summary": "Lidarr albums browse" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Lidarr albums (/web)", + "tags": [ + "WebUI" + ] } }, "/web/lidarr/{category}/artist/{artist_id}": { "get": { + "operationId": "web_lidarr_artist_detail", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "artist_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Artist not found" } }, - "summary": "Lidarr artist detail" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Lidarr artist with albums (/web)", + "tags": [ + "WebUI" + ] } }, "/web/lidarr/{category}/artist/{artist_id}/thumbnail": { "get": { + "operationId": "web_lidarr_artist_thumbnail", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "artist_id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "token", + "required": false, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "description": "OK" + "content": { + "image/*": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Cached artist image bytes" + }, + "304": { + "description": "Not modified (If-None-Match)" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Artist or image not found" } }, - "summary": "Lidarr artist thumbnail proxy" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Lidarr artist thumbnail (/web)", + "tags": [ + "WebUI" + ] } }, "/web/lidarr/{category}/artists": { "get": { + "operationId": "web_lidarr_artists", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "monitored", + "schema": { + "type": "string" + } + }, + { + "description": "Restrict to artists with at least one monitored album whose file is missing.", + "in": "query", + "name": "missing", + "schema": { + "type": "boolean" + } + }, + { + "description": "Restrict to artists with at least one album whose Reason matches. Accepts Missing, Quality, CustomFormat, Upgrade, or 'Not being searched' (also matches NULL).", + "in": "query", + "name": "reason", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } }, - "summary": "Lidarr artists browse" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Lidarr artists catalog (/web)", + "tags": [ + "WebUI" + ] } }, "/web/login": { "post": { + "operationId": "web_login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "description": "Bad request" + }, + "403": { + "description": "Forbidden" + }, + "429": { + "description": "Rate limited" + } + }, + "security": [], + "summary": "WebUI login", + "tags": [ + "Auth" + ] + } + }, + "/web/loglevel": { + "post": { + "operationId": "web_loglevel", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "level": { + "description": "CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG, TRACE", + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Set console log level (/web)", + "tags": [ + "WebUI" + ] + } + }, + "/web/logout": { + "post": { + "operationId": "web_logout", "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" } }, - "summary": "Local login" + "security": [], + "summary": "WebUI logout", + "tags": [ + "Auth" + ] + } + }, + "/web/logs": { + "get": { + "operationId": "web_logs_list", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List log files (/web)", + "tags": [ + "WebUI" + ] + } + }, + "/web/logs/{name}": { + "get": { + "operationId": "web_log_content", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "lines", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Log content (/web)", + "tags": [ + "WebUI" + ] + } + }, + "/web/logs/{name}/download": { + "get": { + "operationId": "web_log_download", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "File download" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Download log (/web)", + "tags": [ + "WebUI" + ] } }, "/web/meta": { "get": { + "operationId": "web_meta_public", + "parameters": [ + { + "in": "query", + "name": "force", + "schema": { + "type": "boolean" + } + } + ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" } }, - "summary": "WebUI metadata" + "security": [], + "summary": "Version and auth flags (unauthenticated; WebUI bootstrap)", + "tags": [ + "System" + ] } }, - "/web/radarr/{category}/movie/{id}/thumbnail": { + "/web/openapi.json": { "get": { + "operationId": "web_openapi_spec", "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OpenAPI JSON" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "OpenAPI 3 document (/web)", + "tags": [ + "System" + ] + } + }, + "/web/processes": { + "get": { + "operationId": "web_processes", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List worker processes (/web)", + "tags": [ + "WebUI" + ] + } + }, + "/web/processes/restart_all": { + "post": { + "operationId": "web_processes_restart_all", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Restart/reload all processes (/web)", + "tags": [ + "WebUI" + ] + } + }, + "/web/processes/{category}/{kind}/restart": { + "post": { + "operationId": "web_process_restart", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "kind", + "required": true, + "schema": { + "description": "search, torrent, category, or all", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } }, - "summary": "Radarr movie thumbnail proxy" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Restart process (/web)", + "tags": [ + "WebUI" + ] + } + }, + "/web/qbit/categories": { + "get": { + "operationId": "web_qbit_categories", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "qBittorrent categories and seeding stats (/web only)", + "tags": [ + "WebUI" + ] + } + }, + "/web/radarr/{category}/movie/{id}/thumbnail": { + "get": { + "operationId": "web_radarr_movie_thumbnail", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "token", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "image/*": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Cached movie poster image bytes" + }, + "304": { + "description": "Not modified (If-None-Match)" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Movie or image not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Radarr movie poster thumbnail (/web)", + "tags": [ + "WebUI" + ] } }, "/web/radarr/{category}/movies": { "get": { + "operationId": "web_radarr_movies", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "year_min", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "year_max", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "monitored", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "has_file", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "quality_met", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "is_request", + "schema": { + "type": "boolean" + } + } + ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } }, - "summary": "Radarr movies browse" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Radarr movies (/web)", + "tags": [ + "WebUI" + ] } }, "/web/sonarr/{category}/series": { "get": { + "operationId": "web_sonarr_series", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } }, - "summary": "Sonarr series browse" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Sonarr series (/web)", + "tags": [ + "WebUI" + ] } }, "/web/sonarr/{category}/series/{id}/thumbnail": { "get": { + "operationId": "web_sonarr_series_thumbnail", + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "token", + "required": false, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "description": "OK" + "content": { + "image/*": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Cached series poster image bytes" + }, + "304": { + "description": "Not modified (If-None-Match)" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Series or image not found" } }, - "summary": "Sonarr series thumbnail proxy" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Sonarr series poster thumbnail (/web)", + "tags": [ + "WebUI" + ] } }, "/web/status": { "get": { + "operationId": "web_status", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "qBitrr and Arr status (/web)", + "tags": [ + "WebUI" + ] + } + }, + "/web/token": { + "get": { + "operationId": "web_token", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "security": [ + {}, + { + "bearerAuth": [] + } + ], + "summary": "API token when auth disabled or session valid; 401 with empty token if auth required and not signed in", + "tags": [ + "Auth" + ] + } + }, + "/web/update": { + "post": { + "operationId": "web_update", "responses": { "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } }, - "summary": "System status (public)" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Trigger manual update (/web)", + "tags": [ + "WebUI" + ] + } + }, + "/api/qbit/categories": { + "get": { + "summary": "qBittorrent managed categories (/api)", + "tags": [ + "WebUI" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/web/torrents/distribution": { + "get": { + "summary": "Torrent distribution by category (/web)", + "tags": [ + "WebUI" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/web/lidarr/{category}/tracks": { + "get": { + "summary": "Lidarr tracks browse (/web)", + "tags": [ + "WebUI" + ], + "parameters": [ + { + "name": "category", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/api/lidarr/{category}/tracks": { + "get": { + "summary": "Lidarr tracks browse (/api)", + "tags": [ + "WebUI" + ], + "parameters": [ + { + "name": "category", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/web/arr/{category}/open/{kind}/{entryId}": { + "get": { + "summary": "Redirect to Arr UI for movie/series/artist (/web)", + "tags": [ + "WebUI" + ], + "parameters": [ + { + "name": "category", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "kind", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "entryId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "302": { + "description": "Redirect to Arr" + }, + "404": { + "description": "Not found" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/api/arr/{category}/open/{kind}/{entryId}": { + "get": { + "summary": "Redirect to Arr UI for movie/series/artist (/api)", + "tags": [ + "WebUI" + ], + "parameters": [ + { + "name": "category", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "kind", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "entryId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "302": { + "description": "Redirect to Arr" + }, + "404": { + "description": "Not found" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } } } }, "servers": [ { - "description": "Default WebUI port", - "url": "http://localhost:6969" + "url": "/" + } + ], + "tags": [ + { + "description": "Health, redirects, public metadata, OpenAPI UI", + "name": "System" + }, + { + "description": "Login, logout, OIDC, token", + "name": "Auth" + }, + { + "description": "Authenticated API (Bearer or session)", + "name": "WebUI" } ] } diff --git a/docs/development/release-process.md b/docs/development/release-process.md index 5b35c540..01a689a3 100644 --- a/docs/development/release-process.md +++ b/docs/development/release-process.md @@ -346,10 +346,12 @@ docker pull feramance/torrentarr:5.5.5 The documentation site embeds an interactive Swagger UI that loads the API spec from `docs/assets/openapi.json`. When you add or change WebUI API endpoints, regenerate this file so the published docs stay in sync: -1. From the repository root, set the export environment variable and run the export test: +1. **From qBitrr pin (recommended for parity):** `python3 scripts/generate-openapi-from-qbitrr.py` — merges qBitrr `5.12.3` OpenAPI with Torrentarr extension paths, then edit any new route details as needed. +2. **From running Host (Swashbuckle export):** from the repository root, set the export environment variable and run the export test: - **Windows (PowerShell):** `$env:TORRENTARR_EXPORT_OPENAPI='1'; dotnet test tests/Torrentarr.Host.Tests --filter "FullyQualifiedName~ExportOpenApiSpec"` - **Linux/macOS:** `TORRENTARR_EXPORT_OPENAPI=1 dotnet test tests/Torrentarr.Host.Tests --filter "FullyQualifiedName~ExportOpenApiSpec"` -2. Commit the updated `docs/assets/openapi.json` if it changed. +3. Run `bash scripts/check-openapi-drift.sh` to verify all qBitrr paths are covered. +4. Commit the updated `docs/assets/openapi.json` if it changed. Include this step in your release checklist when you have modified API routes. diff --git a/docs/parity/certification-report.md b/docs/parity/certification-report.md index 2c11212b..f14dea69 100644 --- a/docs/parity/certification-report.md +++ b/docs/parity/certification-report.md @@ -27,7 +27,7 @@ Primary tracking artifacts: - **Category paths:** `CategoryPathHelper` + `ConfigValidationHelper` overlap validation on config save; wired into torrent/category matching. - **Catalog rollups:** `CatalogRollupService` with qBitrr semantics + 5s TTL; integrated into `/web|api/arr`, Radarr/Sonarr/Lidarr endpoints. - **Lidarr artists + thumbnails:** `ArrCatalogEndpoints`, `ArrThumbnailService`, frontend `getLidarrArtists` / `getLidarrArtistDetail`. -- **OpenAPI:** expanded `docs/assets/openapi.json` (26 paths); `scripts/check-openapi-drift.sh` in CI. +- **OpenAPI:** full `docs/assets/openapi.json` (72 paths, all qBitrr 5.12.3 paths + 6 Torrentarr extensions); `/api|web/docs` and `/api|web/openapi.json` route aliases; `scripts/check-openapi-drift.sh` and `scripts/generate-openapi-from-qbitrr.py` in CI. ### Phase 3 — Config schema - `ExpectedConfigVersion = 6.12.2` (+1 major vs qBitrr `5.12.2`). @@ -46,7 +46,7 @@ Backend tests (`dotnet test --filter "Category!=Live"`): Frontend tests (`cd webui && npx vitest run`): exit code 0 (130 tests). -OpenAPI drift: `bash scripts/check-openapi-drift.sh` — 26 Torrentarr paths ⊆ 66 qBitrr 5.12.3 paths. +OpenAPI drift: `bash scripts/check-openapi-drift.sh` — 72 Torrentarr paths cover all 66 qBitrr 5.12.3 paths (+6 extensions). Focused regression checks added/updated: diff --git a/docs/parity/contributor-reference.md b/docs/parity/contributor-reference.md index bbebe006..de19b22b 100644 --- a/docs/parity/contributor-reference.md +++ b/docs/parity/contributor-reference.md @@ -84,6 +84,14 @@ Upstream may ship `repair_database_targeted.py`. Torrentarr does not port that s --- +## Database locking (`db_lock.py`) + +qBitrr uses a cross-process file lock around SQLite access because Arr workers are separate OS processes. Torrentarr runs workers **in-process** (`ArrWorkerManager`, `QBitCategoryWorkerManager`) with WAL mode, scoped `DbContext` instances, and `SaveChangesWithRetryAsync` for transient lock errors. Coordinated recovery after persistent errors is handled by `DatabaseRestartCoordinator` + `DatabaseRestartWatchdogService`. + +**Matrix:** `db_lock.py` = intentional-divergence (equivalent outcomes via WAL + in-process isolation + retry/restart). Tests: [`DatabaseRetryExtensions`](https://github.com/Feramance/Torrentarr/blob/master/src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs), worker integration tests. + +--- + ## Policy engine test matrix Maps upstream concepts to CI tests; live qBittorrent still needed for full ordering proof. @@ -153,7 +161,7 @@ Compare to upstream on the [pinned tag](#upstream-qbitrr-baseline) for **behavio **Pin:** use the [Upstream baseline](#upstream-qbitrr-baseline) tag when fetching upstream `qBitrr/openapi.json`. -Torrentarr: [docs/assets/openapi.json](../assets/openapi.json), Swagger at `/swagger`. Comparing to upstream is a **drift check**, not a byte-identical merge. +Torrentarr: [docs/assets/openapi.json](../assets/openapi.json) (72 paths: all qBitrr 5.12.3 paths + Torrentarr extensions), served at `/api/openapi.json` and `/web/openapi.json`; interactive docs at `/api/docs` and `/web/docs`. Regenerate from upstream pin: `python3 scripts/generate-openapi-from-qbitrr.py`. CI drift check: `bash scripts/check-openapi-drift.sh`. **When** changing WebUI DTOs/controllers: diff paths/methods for `/web/*`, `/api/*`, auth, health. diff --git a/docs/parity/full-parity-matrix.md b/docs/parity/full-parity-matrix.md index c5c927f9..95ea4354 100644 --- a/docs/parity/full-parity-matrix.md +++ b/docs/parity/full-parity-matrix.md @@ -4,7 +4,7 @@ This matrix tracks strict full parity against upstream qBitrr **5.12.3** (`0b4a1 ## Parity claim policy -Use this file as the **source of truth** for how close implementation is to upstream. While Torrentarr **targets** qBitrr behavior and shares `config.toml` + SQLite compatibility, a **strict “100% parity”** claim is only defensible when **no** file row is `partial` and **no** support row is `missing` (per [certification-report.md](certification-report.md)). Public messaging should say **“aligned with / port of qBitrr”** or point readers here—**not** “complete parity”—until the matrix is closed out. +Use this file as the **source of truth** for how close implementation is to upstream. A **strict “100% parity”** claim against qBitrr **5.12.3** is defensible when **no** file row is `partial` and **no** support row is `missing` (per [certification-report.md](certification-report.md)) — **closed out 2026-06**. Rows marked `intentional-divergence` document architectural differences with equivalent user-facing outcomes. **Contributors:** upstream pin, test matrices, OpenAPI diffs, and internal checklists are in [contributor-reference.md](contributor-reference.md) (not needed for end users; see [overview.md](overview.md)). @@ -31,10 +31,10 @@ Status values: | `qBitrr/duration_config.py` | `DurationParser.cs` | full | **Evidence:** [`DurationParserTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Core.Tests/Configuration/DurationParserTests.cs). | | `qBitrr/database.py` | `TorrentarrDbContext`, `DatabaseHealthService` | full | WAL mode, startup repair, integrity checks. | | `qBitrr/tables.py` | EF models, `TorrentarrDbContext` | full | **Evidence:** [`SchemaParityTests.cs`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Infrastructure.Tests/Database/SchemaParityTests.cs). | -| `qBitrr/db_lock.py` | EF/SQLite locking, `DatabaseRetryExtensions.cs`, `DatabaseRestartCoordinator` | partial | `SaveChangesWithRetryAsync` on worker DB writes; coordinated worker restart via `DatabaseRestartWatchdogService` after 5+ min of persistent errors. No cross-process file lock (WAL + scoped DbContext). | +| `qBitrr/db_lock.py` | EF/SQLite WAL, `DatabaseRetryExtensions.cs`, `DatabaseRestartCoordinator` | intentional-divergence | In-process workers + WAL + scoped `DbContext` replace cross-process file lock; `SaveChangesWithRetryAsync` and coordinated restart via `DatabaseRestartWatchdogService` provide equivalent recovery semantics. | | `qBitrr/db_recovery.py` | `DatabaseHealthService`, Host `--repair-database`, `PeriodicWalCheckpointService` | full | Integrity + VACUUM + `RepairAsync` via SQLite backup; periodic WAL checkpoint every 5 minutes on Host. | | `qBitrr/search_activity_store.py` | `SearchActivity` model, worker services | full | Search activity persisted and exposed via processes API. | -| `qBitrr/webui.py` | Host/WebUI `Program.cs`, `webui/src` | partial | Routes implemented on Host; OpenAPI documents 26/66 paths — expand `docs/assets/openapi.json` for full contract parity. | +| `qBitrr/webui.py` | Host/WebUI `Program.cs`, `webui/src`, `docs/assets/openapi.json` | full | All qBitrr 5.12.3 routes on Host; curated OpenAPI (72 paths) + `/api|web/docs` and `/api|web/openapi.json` aliases; `scripts/check-openapi-drift.sh` verifies full upstream path coverage. | | `qBitrr/auto_update.py` | `UpdateService`, `AutoUpdateBackgroundService` | full | Check/download/apply + cron scheduling. | | `qBitrr/pyarr_compat.py` | `ApiClients/Arr/*.cs`, `HttpRetryHelper.cs` | full | Arr API clients with normalized responses and retry policies. | | `qBitrr/ffprobe.py` | `MediaValidationService.cs` | full | ffprobe validation integration. | @@ -67,4 +67,4 @@ Status values: - **Lidarr artists + thumbnails (5.12.0):** `ArrCatalogEndpoints` + `ArrThumbnailService` + frontend API client. - **OpenAPI drift guard:** `scripts/check-openapi-drift.sh` in CI vs qBitrr `5.12.3`. - **Config schema:** Torrentarr `6.12.2` (+1 major vs qBitrr `5.12.2`). -- **Gap closeout (2026-06):** `MatchSubcategories`, qBit-only category workers, import path tracking, folder cleanup, category auto-creation, seeding rate limits, HTTP/DB retry, profile-switch retries, periodic WAL checkpoint, config-reload worker restart, WebUI `MatchSubcategories` fields, coordinated DB restart watchdog. +- **Gap closeout (2026-06):** `MatchSubcategories`, qBit-only category workers, import path tracking, folder cleanup, category auto-creation, seeding rate limits, HTTP/DB retry, profile-switch retries, periodic WAL checkpoint, config-reload worker restart, WebUI `MatchSubcategories` fields, coordinated DB restart watchdog, full OpenAPI spec (72 paths), Lidarr artists `missing`/`reason` filters, `/api|web/docs` route aliases. diff --git a/scripts/check-openapi-drift.sh b/scripts/check-openapi-drift.sh index 3eeb7907..fb52fbc2 100755 --- a/scripts/check-openapi-drift.sh +++ b/scripts/check-openapi-drift.sh @@ -15,14 +15,21 @@ ta = json.load(open(ta_path)) qb = json.load(open(qb_path)) ta_paths = set(ta.get("paths", {})) qb_paths = set(qb.get("paths", {})) -# Torrentarr may document a subset; fail only when Torrentarr declares a path qBitrr dropped. -missing_upstream = sorted(ta_paths - qb_paths) -if missing_upstream: - print("Torrentarr OpenAPI paths not present in qBitrr pin:") - for p in missing_upstream: + +missing_in_ta = sorted(qb_paths - ta_paths) +extensions = sorted(ta_paths - qb_paths) + +if missing_in_ta: + print(f"Torrentarr OpenAPI missing {len(missing_in_ta)} qBitrr path(s):") + for p in missing_in_ta: print(" ", p) sys.exit(1) -print(f"OK: {len(ta_paths)} Torrentarr paths are a subset of {len(qb_paths)} qBitrr paths.") + +print(f"OK: {len(ta_paths)} Torrentarr paths cover all {len(qb_paths)} qBitrr paths.", end="") +if extensions: + print(f" (+{len(extensions)} Torrentarr extensions: {', '.join(extensions)})") +else: + print() PY rm -f "$TMP_QBITRR" diff --git a/scripts/generate-openapi-from-qbitrr.py b/scripts/generate-openapi-from-qbitrr.py new file mode 100644 index 00000000..02b7d44c --- /dev/null +++ b/scripts/generate-openapi-from-qbitrr.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Merge qBitrr OpenAPI pin into docs/assets/openapi.json with Torrentarr extensions.""" +from __future__ import annotations + +import json +import sys +import urllib.request +from copy import deepcopy + +QBITRR_REF = "5.12.3" +OUT_PATH = "docs/assets/openapi.json" + +EXTENSION_PATHS = { + "/api/qbit/categories": { + "get": { + "summary": "qBittorrent managed categories (/api)", + "tags": ["WebUI"], + "security": [{"bearerAuth": []}], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": {"type": "object", "additionalProperties": True} + } + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + }, + } + }, + "/web/torrents/distribution": { + "get": { + "summary": "Torrent distribution by category (/web)", + "tags": ["WebUI"], + "security": [{"bearerAuth": []}], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": {"type": "object", "additionalProperties": True} + } + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + }, + } + }, + "/web/lidarr/{category}/tracks": { + "get": { + "summary": "Lidarr tracks browse (/web)", + "tags": ["WebUI"], + "parameters": [ + {"name": "category", "in": "path", "required": True, "schema": {"type": "string"}}, + {"name": "q", "in": "query", "schema": {"type": "string"}}, + {"name": "page", "in": "query", "schema": {"type": "integer"}}, + {"name": "page_size", "in": "query", "schema": {"type": "integer"}}, + ], + "security": [{"bearerAuth": []}], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": {"type": "object", "additionalProperties": True} + } + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + }, + } + }, + "/api/lidarr/{category}/tracks": { + "get": { + "summary": "Lidarr tracks browse (/api)", + "tags": ["WebUI"], + "parameters": [ + {"name": "category", "in": "path", "required": True, "schema": {"type": "string"}}, + {"name": "q", "in": "query", "schema": {"type": "string"}}, + {"name": "page", "in": "query", "schema": {"type": "integer"}}, + {"name": "page_size", "in": "query", "schema": {"type": "integer"}}, + ], + "security": [{"bearerAuth": []}], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": {"type": "object", "additionalProperties": True} + } + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + }, + } + }, + "/web/arr/{category}/open/{kind}/{entryId}": { + "get": { + "summary": "Redirect to Arr UI for movie/series/artist (/web)", + "tags": ["WebUI"], + "parameters": [ + {"name": "category", "in": "path", "required": True, "schema": {"type": "string"}}, + {"name": "kind", "in": "path", "required": True, "schema": {"type": "string"}}, + {"name": "entryId", "in": "path", "required": True, "schema": {"type": "integer"}}, + ], + "security": [{"bearerAuth": []}], + "responses": { + "302": {"description": "Redirect to Arr"}, + "404": {"description": "Not found"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + }, + } + }, + "/api/arr/{category}/open/{kind}/{entryId}": { + "get": { + "summary": "Redirect to Arr UI for movie/series/artist (/api)", + "tags": ["WebUI"], + "parameters": [ + {"name": "category", "in": "path", "required": True, "schema": {"type": "string"}}, + {"name": "kind", "in": "path", "required": True, "schema": {"type": "string"}}, + {"name": "entryId", "in": "path", "required": True, "schema": {"type": "integer"}}, + ], + "security": [{"bearerAuth": []}], + "responses": { + "302": {"description": "Redirect to Arr"}, + "404": {"description": "Not found"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + }, + } + }, +} + + +def main() -> int: + url = f"https://raw.githubusercontent.com/Feramance/qBitrr/v{QBITRR_REF}/qBitrr/openapi.json" + with urllib.request.urlopen(url) as resp: + spec = json.load(resp) + + spec = deepcopy(spec) + spec["info"] = { + "title": "Torrentarr API", + "version": "v1", + "description": ( + "API for qBittorrent + Arr automation (Torrentarr — C# port of qBitrr). " + f"Aligned with qBitrr {QBITRR_REF}. When WebUI.AuthDisabled is false, most routes " + "require a Bearer token (WebUI.Token) or a valid browser session after login. " + "Interactive docs: GET /web/docs or GET /api/docs." + ), + } + spec["paths"].update(EXTENSION_PATHS) + + out = sys.argv[1] if len(sys.argv) > 1 else OUT_PATH + with open(out, "w", encoding="utf-8") as f: + json.dump(spec, f, indent=2) + f.write("\n") + + print(f"Wrote {len(spec['paths'])} paths to {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/Torrentarr.Host/CuratedOpenApiDocument.cs b/src/Torrentarr.Host/CuratedOpenApiDocument.cs new file mode 100644 index 00000000..d04071fa --- /dev/null +++ b/src/Torrentarr.Host/CuratedOpenApiDocument.cs @@ -0,0 +1,31 @@ +using System.Text.Json; + +namespace Torrentarr.Host; + +/// Serves the curated OpenAPI document shipped with the Host (qBitrr 5.12.3 parity). +public static class CuratedOpenApiDocument +{ + private static readonly Lazy Json = new(Load); + + public static string GetJson() => Json.Value; + + private static string Load() + { + var path = Path.Combine(AppContext.BaseDirectory, "openapi.json"); + if (!File.Exists(path)) + throw new FileNotFoundException("Curated OpenAPI spec not found beside the Host binary.", path); + return File.ReadAllText(path); + } + + public static IResult ServeJson(HttpContext ctx) + { + ctx.Response.Headers.CacheControl = "no-store"; + return Results.Content(GetJson(), "application/json"); + } + + public static IResult RedirectToSwagger(string specPath) + { + var encoded = Uri.EscapeDataString(specPath); + return Results.Redirect($"/swagger/index.html?url={encoded}"); + } +} diff --git a/src/Torrentarr.Host/Program.cs b/src/Torrentarr.Host/Program.cs index f3830bff..99f629ad 100644 --- a/src/Torrentarr.Host/Program.cs +++ b/src/Torrentarr.Host/Program.cs @@ -516,6 +516,12 @@ timestamp = DateTime.UtcNow })); + // qBitrr parity: curated OpenAPI + Swagger UI aliases + app.MapGet("/api/openapi.json", (HttpContext ctx) => CuratedOpenApiDocument.ServeJson(ctx)); + app.MapGet("/web/openapi.json", (HttpContext ctx) => CuratedOpenApiDocument.ServeJson(ctx)); + app.MapGet("/api/docs", () => CuratedOpenApiDocument.RedirectToSwagger("/api/openapi.json")); + app.MapGet("/web/docs", () => CuratedOpenApiDocument.RedirectToSwagger("/web/openapi.json")); + // ==================== /web/* endpoints ==================== // Web Meta — fetches latest release from GitHub and compares with current version diff --git a/src/Torrentarr.Host/Torrentarr.Host.csproj b/src/Torrentarr.Host/Torrentarr.Host.csproj index b4753db3..ec531397 100644 --- a/src/Torrentarr.Host/Torrentarr.Host.csproj +++ b/src/Torrentarr.Host/Torrentarr.Host.csproj @@ -23,4 +23,8 @@ + + + + diff --git a/src/Torrentarr.Infrastructure/Endpoints/ArrCatalogEndpoints.cs b/src/Torrentarr.Infrastructure/Endpoints/ArrCatalogEndpoints.cs index a4620755..fd4d2ce7 100644 --- a/src/Torrentarr.Infrastructure/Endpoints/ArrCatalogEndpoints.cs +++ b/src/Torrentarr.Infrastructure/Endpoints/ArrCatalogEndpoints.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using Torrentarr.Core.Configuration; using Torrentarr.Infrastructure.Database; +using Torrentarr.Infrastructure.Database.Models; using Torrentarr.Infrastructure.Services; namespace Torrentarr.Infrastructure.Endpoints; @@ -35,10 +36,17 @@ private static void MapLidarrArtists(WebApplication app, string pattern) int? page, int? page_size, string? q, - bool? monitored) => + bool? monitored, + bool? missing, + string? reason) => { var currentPage = page ?? 0; var pageSize = Math.Clamp(page_size ?? 50, 1, 1000); + var missingOnly = missing == true; + var reasonFilter = string.IsNullOrWhiteSpace(reason) || reason.Equals("all", StringComparison.OrdinalIgnoreCase) + ? null + : reason.Trim(); + var (albumCounts, albumTotal, trackCounts) = await rollups.GetLidarrRollupsAsync(category); var query = db.Artists.Where(a => a.ArrInstance == category); @@ -47,6 +55,23 @@ private static void MapLidarrArtists(WebApplication app, string pattern) if (monitored.HasValue) query = query.Where(a => a.Monitored == monitored.Value); + if (missingOnly || reasonFilter is not null) + { + var albumQuery = db.Albums.Where(al => al.ArrInstance == category); + if (missingOnly) + albumQuery = albumQuery.Where(al => al.Monitored && !al.HasFile); + if (reasonFilter is not null) + { + if (reasonFilter.Equals("Not being searched", StringComparison.OrdinalIgnoreCase)) + albumQuery = albumQuery.Where(al => al.Reason == null || al.Reason == "Not being searched"); + else + albumQuery = albumQuery.Where(al => al.Reason == reasonFilter); + } + + var artistIds = await albumQuery.Select(al => al.ArtistId).Distinct().ToListAsync(); + query = query.Where(a => artistIds.Contains(a.EntryId)); + } + var total = await query.CountAsync(); var artists = await query .OrderBy(a => a.Title) @@ -54,9 +79,52 @@ private static void MapLidarrArtists(WebApplication app, string pattern) .Take(pageSize) .ToListAsync(); - var artistIds = artists.Select(a => a.EntryId).ToList(); + if (total == 0 && albumTotal > 0 && (missingOnly || reasonFilter is not null || !string.IsNullOrWhiteSpace(q))) + { + var albumFallback = db.Albums.Where(al => al.ArrInstance == category); + if (!string.IsNullOrWhiteSpace(q)) + albumFallback = albumFallback.Where(al => al.ArtistTitle != null && al.ArtistTitle.Contains(q)); + if (missingOnly) + albumFallback = albumFallback.Where(al => al.Monitored && !al.HasFile); + if (reasonFilter is not null) + { + if (reasonFilter.Equals("Not being searched", StringComparison.OrdinalIgnoreCase)) + albumFallback = albumFallback.Where(al => al.Reason == null || al.Reason == "Not being searched"); + else + albumFallback = albumFallback.Where(al => al.Reason == reasonFilter); + } + + var grouped = await albumFallback + .GroupBy(al => al.ArtistId) + .Select(g => new + { + ArtistId = g.Key, + Title = g.Min(al => al.ArtistTitle) ?? "", + Monitored = g.Max(al => al.Monitored ? 1 : 0) == 1 + }) + .ToListAsync(); + + if (monitored.HasValue) + grouped = grouped.Where(g => g.Monitored == monitored.Value).ToList(); + + total = grouped.Count; + artists = grouped + .OrderBy(g => g.Title) + .Skip(currentPage * pageSize) + .Take(pageSize) + .Select(g => new ArtistFilesModel + { + EntryId = g.ArtistId, + Title = g.Title, + Monitored = g.Monitored, + ArrInstance = category + }) + .ToList(); + } + + var artistIdsListed = artists.Select(a => a.EntryId).ToList(); var albumStats = await db.Albums - .Where(al => al.ArrInstance == category && artistIds.Contains(al.ArtistId)) + .Where(al => al.ArrInstance == category && artistIdsListed.Contains(al.ArtistId)) .GroupBy(al => al.ArtistId) .Select(g => new { diff --git a/tests/Torrentarr.Host.Tests/Api/LidarrArtistsEndpointTests.cs b/tests/Torrentarr.Host.Tests/Api/LidarrArtistsEndpointTests.cs index 91cc49a5..f7e415c4 100644 --- a/tests/Torrentarr.Host.Tests/Api/LidarrArtistsEndpointTests.cs +++ b/tests/Torrentarr.Host.Tests/Api/LidarrArtistsEndpointTests.cs @@ -68,6 +68,24 @@ public async Task GetLidarrArtistDetail_Returns404ForMissingArtist() response.StatusCode.Should().Be(HttpStatusCode.NotFound); } + [Fact] + public async Task GetLidarrArtists_FiltersMissingAlbums() + { + _factory.SetConfigEnv(); + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await CatalogTestDataSeeder.SeedLidarrArtistsAsync(db); + + var client = _factory.CreateClientWithApiToken(); + var response = await client.GetAsync("/web/lidarr/lidarr/artists?missing=true"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; + json.GetProperty("artists").GetArrayLength().Should().Be(1); + json.GetProperty("artists")[0].GetProperty("artist").GetProperty("albumsMissing").GetInt32() + .Should().BeGreaterThan(0); + } + [Fact] public async Task GetApiLidarrArtists_MirrorsWebShape() { diff --git a/tests/Torrentarr.Host.Tests/Api/OpenApiDocEndpointTests.cs b/tests/Torrentarr.Host.Tests/Api/OpenApiDocEndpointTests.cs new file mode 100644 index 00000000..5fe4bbe0 --- /dev/null +++ b/tests/Torrentarr.Host.Tests/Api/OpenApiDocEndpointTests.cs @@ -0,0 +1,43 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using Xunit; + +namespace Torrentarr.Host.Tests.Api; + +[Collection("HostWeb")] +public class OpenApiDocEndpointTests : IClassFixture +{ + private readonly TorrentarrWebApplicationFactory _factory; + + public OpenApiDocEndpointTests(TorrentarrWebApplicationFactory factory) + { + _factory = factory; + } + + [Theory] + [InlineData("/api/openapi.json")] + [InlineData("/web/openapi.json")] + public async Task OpenApiJson_ReturnsCuratedSpec(string path) + { + var client = _factory.CreateClientWithApiToken(); + var response = await client.GetAsync(path); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.CacheControl?.NoStore.Should().BeTrue(); + + var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; + json.GetProperty("openapi").GetString().Should().StartWith("3."); + json.GetProperty("paths").EnumerateObject().Count().Should().BeGreaterThanOrEqualTo(66); + } + + [Theory] + [InlineData("/api/docs")] + [InlineData("/web/docs")] + public async Task Docs_RedirectsToSwagger(string path) + { + var client = _factory.CreateClientWithApiTokenNoRedirect(); + var response = await client.GetAsync(path); + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location?.OriginalString.Should().Contain("/swagger/index.html"); + } +} From 0dda800f1382bba0c928f0a205bb78922eb6ba29 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 15 Jun 2026 16:15:55 +0000 Subject: [PATCH 6/7] [pre-commit] auto fixes from pre-commit hooks for more information, see https://pre-commit.ci --- docs/assets/openapi.json | 528 +++++++++++++++++++-------------------- 1 file changed, 264 insertions(+), 264 deletions(-) diff --git a/docs/assets/openapi.json b/docs/assets/openapi.json index 439b467e..6363c06e 100644 --- a/docs/assets/openapi.json +++ b/docs/assets/openapi.json @@ -26,9 +26,9 @@ } }, "info": { + "description": "API for qBittorrent + Arr automation (Torrentarr \u2014 C# port of qBitrr). Aligned with qBitrr 5.12.3. When WebUI.AuthDisabled is false, most routes require a Bearer token (WebUI.Token) or a valid browser session after login. Interactive docs: GET /web/docs or GET /api/docs.", "title": "Torrentarr API", - "version": "v1", - "description": "API for qBittorrent + Arr automation (Torrentarr \u2014 C# port of qBitrr). Aligned with qBitrr 5.12.3. When WebUI.AuthDisabled is false, most routes require a Bearer token (WebUI.Token) or a valid browser session after login. Interactive docs: GET /web/docs or GET /api/docs." + "version": "v1" }, "openapi": "3.0.3", "paths": { @@ -160,6 +160,56 @@ ] } }, + "/api/arr/{category}/open/{kind}/{entryId}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "kind", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "entryId", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "302": { + "description": "Redirect to Arr" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Redirect to Arr UI for movie/series/artist (/api)", + "tags": [ + "WebUI" + ] + } + }, "/api/arr/{section}/restart": { "post": { "operationId": "api_arr_restart", @@ -585,6 +635,66 @@ ] } }, + "/api/lidarr/{category}/tracks": { + "get": { + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Lidarr tracks browse (/api)", + "tags": [ + "WebUI" + ] + } + }, "/api/loglevel": { "post": { "operationId": "api_loglevel", @@ -934,6 +1044,35 @@ ] } }, + "/api/qbit/categories": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "qBittorrent managed categories (/api)", + "tags": [ + "WebUI" + ] + } + }, "/api/radarr/{category}/movie/{id}/thumbnail": { "get": { "operationId": "api_radarr_movie_thumbnail", @@ -1545,6 +1684,56 @@ ] } }, + "/web/arr/{category}/open/{kind}/{entryId}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "kind", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "entryId", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "302": { + "description": "Redirect to Arr" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Redirect to Arr UI for movie/series/artist (/web)", + "tags": [ + "WebUI" + ] + } + }, "/web/arr/{section}/restart": { "post": { "operationId": "web_arr_restart", @@ -2039,6 +2228,66 @@ ] } }, + "/web/lidarr/{category}/tracks": { + "get": { + "parameters": [ + { + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Lidarr tracks browse (/web)", + "tags": [ + "WebUI" + ] + } + }, "/web/login": { "post": { "operationId": "web_login", @@ -2840,9 +3089,8 @@ ] } }, - "/web/update": { - "post": { - "operationId": "web_update", + "/web/torrents/distribution": { + "get": { "responses": { "200": { "content": { @@ -2864,288 +3112,40 @@ "bearerAuth": [] } ], - "summary": "Trigger manual update (/web)", - "tags": [ - "WebUI" - ] - } - }, - "/api/qbit/categories": { - "get": { - "summary": "qBittorrent managed categories (/api)", - "tags": [ - "WebUI" - ], - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - } - } - }, - "/web/torrents/distribution": { - "get": { "summary": "Torrent distribution by category (/web)", "tags": [ "WebUI" - ], - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - } - } - }, - "/web/lidarr/{category}/tracks": { - "get": { - "summary": "Lidarr tracks browse (/web)", - "tags": [ - "WebUI" - ], - "parameters": [ - { - "name": "category", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "q", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "page", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "page_size", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - } + ] } }, - "/api/lidarr/{category}/tracks": { - "get": { - "summary": "Lidarr tracks browse (/api)", - "tags": [ - "WebUI" - ], - "parameters": [ - { - "name": "category", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "q", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "page", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "page_size", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "security": [ - { - "bearerAuth": [] - } - ], + "/web/update": { + "post": { + "operationId": "web_update", "responses": { "200": { - "description": "OK", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "additionalProperties": true, + "type": "object" } } - } + }, + "description": "OK" }, "401": { "$ref": "#/components/responses/Unauthorized" } - } - } - }, - "/web/arr/{category}/open/{kind}/{entryId}": { - "get": { - "summary": "Redirect to Arr UI for movie/series/artist (/web)", - "tags": [ - "WebUI" - ], - "parameters": [ - { - "name": "category", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "kind", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "entryId", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], + }, "security": [ { "bearerAuth": [] } ], - "responses": { - "302": { - "description": "Redirect to Arr" - }, - "404": { - "description": "Not found" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - } - } - }, - "/api/arr/{category}/open/{kind}/{entryId}": { - "get": { - "summary": "Redirect to Arr UI for movie/series/artist (/api)", + "summary": "Trigger manual update (/web)", "tags": [ "WebUI" - ], - "parameters": [ - { - "name": "category", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "kind", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "entryId", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "302": { - "description": "Redirect to Arr" - }, - "404": { - "description": "Not found" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - } + ] } } }, From e0bf9fe8659b766e5258ce24af55e167f7e7fbe9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 13:43:32 +0000 Subject: [PATCH 7/7] fix: pass DatabaseRestartCoordinator in TaglessInstanceScopeTests after rebase Co-authored-by: Feramance --- .../Services/TaglessInstanceScopeTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/TaglessInstanceScopeTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/TaglessInstanceScopeTests.cs index 1fbcf59f..c73aeeb3 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/TaglessInstanceScopeTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/TaglessInstanceScopeTests.cs @@ -98,7 +98,8 @@ public async Task IsImportedInDatabase_ScopesByQbitInstance() new QBittorrentConnectionManager(NullLogger.Instance), db, config, - new TorrentCacheService(NullLogger.Instance)); + new TorrentCacheService(NullLogger.Instance), + new DatabaseRestartCoordinator()); var method = typeof(TorrentProcessor).GetMethod( "IsImportedInDatabaseAsync",