diff --git a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdConnectionTester.cs b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdConnectionTester.cs index 2ef850f95..b71235ca3 100644 --- a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdConnectionTester.cs +++ b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdConnectionTester.cs @@ -8,6 +8,7 @@ * (at your option) any later version. */ using System.Net; +using System.Text.Json; using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.DownloadClients.Sabnzbd @@ -51,7 +52,20 @@ internal sealed class SabnzbdConnectionTester( return (false, $"SABnzbd: returned {resp.StatusCode}"); } - return (true, "SABnzbd: connected"); + // Version check passed. If a category is configured, verify it exists in + // SABnzbd: unknown categories are silently reassigned to Default, which + // hides jobs from category-scoped reads and strands them unimported. + // This is an advisory, not a hard failure, so the connection tests green. + var configuredCategory = DownloadClientCategoryFilter.GetConfiguredCategory(client); + if (string.IsNullOrWhiteSpace(configuredCategory)) + { + return (true, "SABnzbd: connected"); + } + + var categoryWarning = await CheckCategoryExistsAsync(requestContext, http, configuredCategory, ct); + return categoryWarning is null + ? (true, "SABnzbd: connected") + : (true, categoryWarning); } catch (HttpRequestException httpEx) { @@ -69,5 +83,58 @@ internal sealed class SabnzbdConnectionTester( return (false, "SABnzbd: connection failed"); } } + + private async Task CheckCategoryExistsAsync( + SabnzbdRequestContext requestContext, + HttpClient http, + string configuredCategory, + CancellationToken ct) + { + try + { + var url = requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "get_cats", + ["output"] = "json" + }); + var resp = await http.GetAsync(url, ct); + if (!resp.IsSuccessStatusCode) + { + // Best effort: an unavailable category list must not fail an + // otherwise healthy connection. + return null; + } + + var json = await resp.Content.ReadAsStringAsync(ct); + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("categories", out var categories) || + categories.ValueKind != JsonValueKind.Array) + { + return null; + } + + foreach (var category in categories.EnumerateArray()) + { + var name = category.ValueKind == JsonValueKind.String ? category.GetString() : null; + if (string.Equals(name?.Trim(), configuredCategory.Trim(), StringComparison.OrdinalIgnoreCase)) + { + return null; + } + } + + return $"SABnzbd: connected, but category '{configuredCategory}' does not exist in SABnzbd. " + + "Jobs will fall into Default and may not import. Create the category in SABnzbd (Config > Categories)."; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogDebug(ex, "SABnzbd get_cats probe failed (non-fatal)"); + return null; + } + } } } diff --git a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdQueueFetchWorkflow.cs b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdQueueFetchWorkflow.cs index 19c398831..3e438944e 100644 --- a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdQueueFetchWorkflow.cs +++ b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdQueueFetchWorkflow.cs @@ -112,7 +112,7 @@ public async Task> GetQueueAsync( { try { - var queueItem = SabnzbdResponseMapper.MapQueueSlotToQueueItem(client, slot, configuredCategory ?? string.Empty, speed); + var queueItem = SabnzbdResponseMapper.MapQueueSlotToQueueItem(client, slot, configuredCategory ?? string.Empty, speed, monitoredIdSet); if (queueItem != null) { items.Add(queueItem); @@ -141,7 +141,7 @@ public async Task> GetQueueAsync( var historyLimit = isMonitorPoll ? MonitorHistoryLimit : DisplayHistoryLimit; var historyFailureIsFatal = isMonitorPoll && missingTrackedIds.Count > 0; - await AddHistoryItemsAsync(client, requestContext, configuredCategory, items, http, historyLimit, historyFailureIsFatal, ct); + await AddHistoryItemsAsync(client, requestContext, configuredCategory, items, http, historyLimit, historyFailureIsFatal, monitoredIdSet, ct); } catch (DownloadClientAdapterPollingException) { @@ -167,6 +167,7 @@ private async Task AddHistoryItemsAsync( HttpClient http, int historyLimit, bool historyFailureIsFatal, + ISet monitoredIdSet, CancellationToken ct) { var existingNzoIds = new HashSet(items.Select(i => i.Id), StringComparer.OrdinalIgnoreCase); @@ -220,7 +221,7 @@ private async Task AddHistoryItemsAsync( { try { - var historyItem = SabnzbdResponseMapper.MapHistorySlotToQueueItem(client, slot, configuredCategory ?? string.Empty, existingNzoIds); + var historyItem = SabnzbdResponseMapper.MapHistorySlotToQueueItem(client, slot, configuredCategory ?? string.Empty, existingNzoIds, monitoredIdSet); if (historyItem != null) { items.Add(historyItem); diff --git a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs index bb5f289c2..d82a75b4a 100644 --- a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs +++ b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs @@ -27,14 +27,19 @@ internal static class SabnzbdResponseMapper DownloadClientConfiguration client, JsonElement slot, string configuredCategory, - double speed) + double speed, + ISet? monitoredIds = null) { var nzoId = GetString(slot, "nzo_id"); var filename = GetString(slot, "filename", "Unknown"); var status = GetString(slot, "status", "Unknown"); var category = GetString(slot, "cat"); - if (!DownloadClientCategoryFilter.Matches(configuredCategory, category)) + // Category filtering scopes untracked discovery; it must never hide a job we + // grabbed. SABnzbd silently reassigns unknown categories to Default, so a slot + // whose nzo_id we track is reconciled by download ID regardless of its category. + var isTracked = monitoredIds is not null && !string.IsNullOrEmpty(nzoId) && monitoredIds.Contains(nzoId); + if (!isTracked && !DownloadClientCategoryFilter.Matches(configuredCategory, category)) return null; var sizeMb = GetDouble(slot, "mb"); @@ -85,14 +90,18 @@ internal static class SabnzbdResponseMapper DownloadClientConfiguration client, JsonElement slot, string configuredCategory, - ISet existingNzoIds) + ISet existingNzoIds, + ISet? monitoredIds = null) { var nzoId = GetString(slot, "nzo_id"); if (string.IsNullOrEmpty(nzoId) || existingNzoIds.Contains(nzoId)) return null; var histCategory = GetString(slot, "category"); - if (!DownloadClientCategoryFilter.Matches(configuredCategory, histCategory)) + // See MapQueueSlotToQueueItem: a tracked job reassigned to Default by SABnzbd + // must still be reconciled by download ID, not hidden by the category filter. + var isTracked = monitoredIds is not null && monitoredIds.Contains(nzoId); + if (!isTracked && !DownloadClientCategoryFilter.Matches(configuredCategory, histCategory)) return null; var histStatus = GetString(slot, "status"); diff --git a/tests/Features/Infrastructure/DownloadClients/Common/UsenetAdapterFilteringTests.cs b/tests/Features/Infrastructure/DownloadClients/Common/UsenetAdapterFilteringTests.cs index e9f55f6b3..0a3759b77 100644 --- a/tests/Features/Infrastructure/DownloadClients/Common/UsenetAdapterFilteringTests.cs +++ b/tests/Features/Infrastructure/DownloadClients/Common/UsenetAdapterFilteringTests.cs @@ -153,7 +153,11 @@ public async Task Sabnzbd_GetQueueAndItems_FilterByConfiguredCategory() } }; - var queue = await adapter.GetQueueAsync(client, ["SABnzbd_nzo_1", "SABnzbd_nzo_2"], CancellationToken.None); + // Category filtering scopes untracked discovery (the no-tracked-ids display + // path). A tracked nzo_id bypasses it so a grabbed job SABnzbd reassigned to + // Default is never hidden; here no ids are tracked, so the foreign-category + // "Movie One" slot is excluded and only the audiobooks slot survives. + var queue = await adapter.GetQueueAsync(client, CancellationToken.None); Assert.Single(queue); Assert.Equal("Book One", queue[0].Title); diff --git a/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdAdapterTests.cs b/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdAdapterTests.cs index 42df0cfa7..3b5b397f0 100644 --- a/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdAdapterTests.cs +++ b/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdAdapterTests.cs @@ -421,5 +421,133 @@ public void HistoryStatus_IsMappedWithoutBehaviorDrift(string status, string exp Assert.Equal(expected, item!.Status); Assert.Equal("/downloads/book", item.ContentPath); } + + [Fact] + public void QueueSlot_InDifferentCategory_ButTracked_IsReturned() + { + using var document = System.Text.Json.JsonDocument.Parse( + """ + { + "nzo_id": "sab-tracked-1", + "filename": "Book Folder", + "status": "Downloading", + "percentage": "40", + "mb": "100", + "mbleft": "60", + "cat": "*" + } + """); + + var item = SabnzbdResponseMapper.MapQueueSlotToQueueItem( + _client, + document.RootElement, + "audiobooks", + speed: 0, + monitoredIds: new HashSet { "sab-tracked-1" }); + + Assert.NotNull(item); + Assert.Equal("sab-tracked-1", item!.Id); + } + + [Fact] + public void QueueSlot_InDifferentCategory_NotTracked_IsFiltered() + { + using var document = System.Text.Json.JsonDocument.Parse( + """ + { + "nzo_id": "sab-untracked-1", + "filename": "Book Folder", + "status": "Downloading", + "percentage": "40", + "mb": "100", + "mbleft": "60", + "cat": "*" + } + """); + + var item = SabnzbdResponseMapper.MapQueueSlotToQueueItem( + _client, + document.RootElement, + "audiobooks", + speed: 0, + monitoredIds: new HashSet()); + + Assert.Null(item); + } + + [Fact] + public void HistorySlot_InDifferentCategory_ButTracked_IsReturned() + { + using var document = System.Text.Json.JsonDocument.Parse( + """{"nzo_id":"sab-tracked-2","name":"Book","status":"Completed","category":"*","storage":"/downloads/book"}"""); + + var item = SabnzbdResponseMapper.MapHistorySlotToQueueItem( + _client, + document.RootElement, + "audiobooks", + new HashSet(), + monitoredIds: new HashSet { "sab-tracked-2" }); + + Assert.NotNull(item); + Assert.Equal("sab-tracked-2", item!.Id); + } + + [Fact] + public void HistorySlot_InDifferentCategory_NotTracked_IsFiltered() + { + using var document = System.Text.Json.JsonDocument.Parse( + """{"nzo_id":"sab-untracked-2","name":"Book","status":"Completed","category":"*","storage":"/downloads/book"}"""); + + var item = SabnzbdResponseMapper.MapHistorySlotToQueueItem( + _client, + document.RootElement, + "audiobooks", + new HashSet(), + monitoredIds: new HashSet()); + + Assert.Null(item); + } + + [Fact] + public async Task TestConnectionAsync_CategoryMissingFromSabnzbd_ReturnsAdvisoryPass() + { + sabnzbdApiMock.Categories = ["*", "Default"]; + var client = new DownloadClientConfigurationBuilder() + .WithHost("http://192.168.50.111/sab") + .WithPort(8080) + .WithoutSsl() + .WithApiKey("secret") + .WithType("sabnzbd") + .WithSettings("category", "audiobooks") + .Build(); + + var gateway = _provider.GetRequiredService(); + var (success, message) = await gateway.TestConnectionAsync(client); + + Assert.True(success); + Assert.Contains("does not exist", message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("audiobooks", message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task TestConnectionAsync_CategoryPresentInSabnzbd_ReturnsCleanPass() + { + sabnzbdApiMock.Categories = ["*", "Default", "audiobooks"]; + var client = new DownloadClientConfigurationBuilder() + .WithHost("http://192.168.50.111/sab") + .WithPort(8080) + .WithoutSsl() + .WithApiKey("secret") + .WithType("sabnzbd") + .WithSettings("category", "audiobooks") + .Build(); + + var gateway = _provider.GetRequiredService(); + var (success, message) = await gateway.TestConnectionAsync(client); + + Assert.True(success); + Assert.Contains("connected", message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("does not exist", message, StringComparison.OrdinalIgnoreCase); + } } } diff --git a/tests/Mocks/Api/SabnzbdApiMock.cs b/tests/Mocks/Api/SabnzbdApiMock.cs index dbd0e7b8b..6f036585b 100644 --- a/tests/Mocks/Api/SabnzbdApiMock.cs +++ b/tests/Mocks/Api/SabnzbdApiMock.cs @@ -11,6 +11,7 @@ public class SabnzbdApiMock : BaseApiMock public static readonly string REMOTE_PATH = FileUtils.GetAbsolutePath("downloads", "completed"); public string contentPath = FileUtils.GetAbsolutePath("completed", "Book.m4b"); + public List Categories { get; set; } = ["*", "Default"]; public System.Net.HttpStatusCode HistoryStatusCode { get; set; } = System.Net.HttpStatusCode.OK; public string? HistoryResponseOverride { get; set; } public List RemovalRequests { get; } = []; @@ -92,6 +93,12 @@ public async Task GetQueue(HttpRequestMessage request, Canc return MockUtils.GetCannedResponse(response); } + public async Task GetCats(HttpRequestMessage request, CancellationToken ct) + { + var quoted = Categories.Select(category => $"\"{category}\""); + return MockUtils.GetCannedResponse($$"""{"categories": [{{string.Join(", ", quoted)}}]}"""); + } + public async Task GetVersion(HttpRequestMessage request, CancellationToken ct) { return MockUtils.GetCannedResponse(""" @@ -110,6 +117,10 @@ public async Task ProcessRequest(HttpRequestMessage request { return await GetVersion(request, ct); } + else if (string.Equals("get_cats", mode)) + { + return await GetCats(request, ct); + } else if (string.Equals("history", mode)) { if (string.Equals("delete", query["name"], StringComparison.Ordinal))