Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
{
Expand All @@ -69,5 +83,58 @@ internal sealed class SabnzbdConnectionTester(
return (false, "SABnzbd: connection failed");
}
}

private async Task<string?> CheckCategoryExistsAsync(
SabnzbdRequestContext requestContext,
HttpClient http,
string configuredCategory,
CancellationToken ct)
{
try
{
var url = requestBuilder.BuildUrl(requestContext, new Dictionary<string, string>
{
["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;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ public async Task<List<QueueItem>> 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);
Expand Down Expand Up @@ -141,7 +141,7 @@ public async Task<List<QueueItem>> 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)
{
Expand All @@ -167,6 +167,7 @@ private async Task AddHistoryItemsAsync(
HttpClient http,
int historyLimit,
bool historyFailureIsFatal,
ISet<string> monitoredIdSet,
CancellationToken ct)
{
var existingNzoIds = new HashSet<string>(items.Select(i => i.Id), StringComparer.OrdinalIgnoreCase);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,19 @@ internal static class SabnzbdResponseMapper
DownloadClientConfiguration client,
JsonElement slot,
string configuredCategory,
double speed)
double speed,
ISet<string>? 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");
Expand Down Expand Up @@ -85,14 +90,18 @@ internal static class SabnzbdResponseMapper
DownloadClientConfiguration client,
JsonElement slot,
string configuredCategory,
ISet<string> existingNzoIds)
ISet<string> existingNzoIds,
ISet<string>? 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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> { "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<string>());

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<string>(),
monitoredIds: new HashSet<string> { "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<string>(),
monitoredIds: new HashSet<string>());

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<IDownloadClientGateway>();
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<IDownloadClientGateway>();
var (success, message) = await gateway.TestConnectionAsync(client);

Assert.True(success);
Assert.Contains("connected", message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("does not exist", message, StringComparison.OrdinalIgnoreCase);
}
}
}
11 changes: 11 additions & 0 deletions tests/Mocks/Api/SabnzbdApiMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> Categories { get; set; } = ["*", "Default"];
public System.Net.HttpStatusCode HistoryStatusCode { get; set; } = System.Net.HttpStatusCode.OK;
public string? HistoryResponseOverride { get; set; }
public List<Uri> RemovalRequests { get; } = [];
Expand Down Expand Up @@ -92,6 +93,12 @@ public async Task<HttpResponseMessage> GetQueue(HttpRequestMessage request, Canc
return MockUtils.GetCannedResponse(response);
}

public async Task<HttpResponseMessage> GetCats(HttpRequestMessage request, CancellationToken ct)
{
var quoted = Categories.Select(category => $"\"{category}\"");
return MockUtils.GetCannedResponse($$"""{"categories": [{{string.Join(", ", quoted)}}]}""");
}

public async Task<HttpResponseMessage> GetVersion(HttpRequestMessage request, CancellationToken ct)
{
return MockUtils.GetCannedResponse("""
Expand All @@ -110,6 +117,10 @@ public async Task<HttpResponseMessage> 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))
Expand Down