From 872fe582416bc460a0b43eff8c4814656f4ad660 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 20 Aug 2026 19:21:46 +0930 Subject: [PATCH] Fix SABnzbd downloads completing with no path set SABnzbd briefly reports an item as status "Completed" in its active queue before archiving it to history with the real storage path. MapQueueSlotToQueueItem mapped that slot into a completed QueueItem regardless, which let QueueItemConverter mark the download Completed with an empty DownloadPath - permanently blocking import with "Inconsistency: Download {id} has no path set", since nothing ever backfills the path afterward. When status is "completed" but no storage path is present yet, return null instead of a pathless completed item. Excluding it from the active-queue result set makes the poller treat the download as missing, which triggers a same-cycle history lookup - and history reliably has the storage path by then. This resolves the download via the code path that already works correctly, rather than completing early on unreliable telemetry. Fixes #839. Co-Authored-By: Claude Sonnet 5 --- .../Sabnzbd/SabnzbdResponseMapper.cs | 11 ++ .../Sabnzbd/SabnzbdResponseMapperTests.cs | 119 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapperTests.cs diff --git a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs index bb5f289c2..c2fac13a8 100644 --- a/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs +++ b/listenarr.infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapper.cs @@ -49,6 +49,17 @@ internal static class SabnzbdResponseMapper var mappedStatus = MapQueueStatus(status); var storagePath = GetString(slot, "storage"); var explicitContentPath = string.IsNullOrWhiteSpace(storagePath) ? null : storagePath; + + // SABnzbd reports an item as "Completed" in the active queue briefly before + // archiving it to history with the real storage path - active-queue slots don't + // reliably expose it (see comment below). Reporting completion here anyway lets + // QueueItemConverter mark the download Completed with no DownloadPath, which + // permanently blocks import. Excluding it instead makes the caller treat this + // download as missing from the active queue, which triggers a same-cycle history + // lookup - and history has the storage path by then. + if (mappedStatus == "completed" && explicitContentPath == null) + return null; + var remotePath = explicitContentPath ?? (string.IsNullOrWhiteSpace(client.DownloadPath) ? null : client.DownloadPath); diff --git a/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapperTests.cs b/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapperTests.cs new file mode 100644 index 000000000..e98bab628 --- /dev/null +++ b/tests/Features/Infrastructure/DownloadClients/Sabnzbd/SabnzbdResponseMapperTests.cs @@ -0,0 +1,119 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Listenarr.Tests.Features.Infrastructure.DownloadClients.Sabnzbd +{ + [Trait("Name", "SabnzbdResponseMapperTests")] + [Trait("Category", "SabnzbdResponseMapper")] + public sealed class SabnzbdResponseMapperTests + { + /// + /// Regression test for https://github.com/Listenarrs/Listenarr/issues/839 + /// SABnzbd reports an item as "Completed" in the active queue before it is + /// archived to history with a real storage path. Mapping that slot to a + /// completed QueueItem let downloads reach DownloadStatus.Completed with an + /// empty DownloadPath, permanently blocking import. + /// + [Fact] + public void MapQueueSlotToQueueItem_CompletedStatusWithoutStorage_ReturnsNull() + { + // Given: an active-queue slot reporting Completed with no storage field yet + var client = new DownloadClientConfiguration { DownloadPath = "/downloads" }; + using var document = System.Text.Json.JsonDocument.Parse( + """ + { + "nzo_id": "sab-race-1", + "filename": "Book", + "status": "Completed", + "percentage": "100", + "mb": "100", + "mbleft": "0" + } + """); + + // When + var item = SabnzbdResponseMapper.MapQueueSlotToQueueItem( + client, + document.RootElement, + configuredCategory: string.Empty, + speed: 0); + + // Then: excluded rather than reported as a pathless completion + Assert.Null(item); + } + + [Fact] + public void MapQueueSlotToQueueItem_CompletedStatusWithStorage_ReturnsCompletedItem() + { + // Given: an active-queue slot reporting Completed with a real storage path + var client = new DownloadClientConfiguration { DownloadPath = "/downloads" }; + using var document = System.Text.Json.JsonDocument.Parse( + """ + { + "nzo_id": "sab-race-2", + "filename": "Book", + "status": "Completed", + "percentage": "100", + "mb": "100", + "mbleft": "0", + "storage": "/downloads/complete/Book" + } + """); + + // When + var item = SabnzbdResponseMapper.MapQueueSlotToQueueItem( + client, + document.RootElement, + configuredCategory: string.Empty, + speed: 0); + + // Then: a real storage path still resolves to a completed item, unaffected + Assert.NotNull(item); + Assert.Equal("completed", item!.Status); + Assert.Equal("/downloads/complete/Book", item.ContentPath); + } + + [Fact] + public void MapQueueSlotToQueueItem_DownloadingStatusWithoutStorage_StillReturnsItem() + { + // Given: a genuinely still-downloading slot, which never carries storage + var client = new DownloadClientConfiguration { DownloadPath = "/downloads" }; + using var document = System.Text.Json.JsonDocument.Parse( + """ + { + "nzo_id": "sab-race-3", + "filename": "Book", + "status": "Downloading", + "percentage": "50", + "mb": "100", + "mbleft": "50" + } + """); + + // When + var item = SabnzbdResponseMapper.MapQueueSlotToQueueItem( + client, + document.RootElement, + configuredCategory: string.Empty, + speed: 0); + + // Then: the new guard only targets "completed" - in-progress items are unaffected + Assert.NotNull(item); + Assert.Equal("downloading", item!.Status); + } + } +}