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 @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
namespace Listenarr.Tests.Features.Infrastructure.DownloadClients.Sabnzbd
{
[Trait("Name", "SabnzbdResponseMapperTests")]
[Trait("Category", "SabnzbdResponseMapper")]
public sealed class SabnzbdResponseMapperTests
{
/// <summary>
/// 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.
/// </summary>
[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);
}
}
}