Skip to content

Fix NZBGet import completion#634

Open
therobbiedavis wants to merge 8 commits into
canaryfrom
bugfix/fix-nzbget-import
Open

Fix NZBGet import completion#634
therobbiedavis wants to merge 8 commits into
canaryfrom
bugfix/fix-nzbget-import

Conversation

@therobbiedavis

@therobbiedavis therobbiedavis commented May 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #619 and follows up #628.

This PR keeps the original NZBGet listgroups JSON-number fix from #628, then completes the larger import path: NZBGet downloads that have already left the active queue can now be reconciled from history, marked complete, and queued for import with a usable source path.

Changed

  • NzbgetAdapter.FetchDownloadsAsync now reads FileSizeMB and RemainingSizeMB as JSON numbers or numeric strings.
  • Active NZBGet queue progress is now reported on the 0-100 scale expected by AdapterUtils.MapDownloadProgress.
  • NZBGet history matching now uses the stored ClientDownloadId / NZBGet NZBID instead of the history row ID.
  • Completed history items now prefer FinalDir and fall back to DestDir when resolving the import source path.
  • NZBGet history reconciliation now targets only tracked NZB IDs instead of materializing every history entry for every poll.
  • Active listgroups NZB IDs are excluded from the history pass, so normal active progress polling does not also scan history.
  • History reconciliation is now best-effort: if NZBGet history is temporarily unavailable, active queue updates are still returned and persisted.
  • NZBGet queue items no longer expose the client category as their display quality.
  • .github/CLAUDE.md no longer instructs AI agents to add changelog entries for meaningful changes.

Fixed

  • Fixed the original listgroups JSON type issue where numeric size fields could throw during polling, while preserving support for numeric strings from proxies or mocks.
  • Fixed a follow-up robustness gap where string-valued active queue size fields could deserialize as 0 and skip progress updates.
  • Fixed active progress being calculated as a 0-1 fraction, which made 50% complete downloads show as 0.5% instead of 50%.
  • Fixed the broader NZBGet import issue where completed items disappeared from listgroups before Listenarr could mark them Completed and enqueue import processing.
  • Fixed completed NZBGet imports resolving against the history row ID instead of the actual NZB download ID.
  • Fixed completed imports losing the resolved source path before the processing job is created.
  • Fixed a follow-up polling regression where a history endpoint failure could have caused the whole NZBGet poll to fail even after active queue updates were read successfully.
  • Fixed the Activity view showing the NZBGet category, such as audiobooks, in the Quality column.

Added

  • Added shared NZBGet history parsing/resolution helpers for NZBID, Status, NZBName, FinalDir, and DestDir.
  • Added a JSON double helper for active queue size fields that accepts both JSON numbers and numeric strings.
  • Added a monitor-level regression test proving a completed NZBGet history item queues a processing job with the resolved path.
  • Added coverage for numeric and string-valued listgroups progress mapping, including the expected 50M progress value.
  • Added coverage proving active downloads and empty/no-eligible polls skip NZBGet history lookup.
  • Added coverage proving active queue updates still return when the history endpoint fails.
  • Added coverage proving NZBGet category filtering still works while queue-item quality stays empty.
  • Added NZBGet mock support for distinct history ID vs NZBID, active queue toggling, completed content paths, history request counts, simulated history failures, and string-valued active queue sizes.

Removed

  • Removed the old assumption that NZBGet history ID is the same identifier stored on tracked downloads.
  • Removed the old single-shape parsing assumption for NZBGet FileSizeMB / RemainingSizeMB in FetchDownloadsAsync.
  • Removed unnecessary history requests when there are no eligible tracked downloads to reconcile.
  • Removed unnecessary history reconciliation for downloads already present in the active NZBGet queue.
  • Removed the unreleased 0.2.72 changelog entry for this PR.
  • Removed the AI-agent changelog-maintenance instructions from .github/CLAUDE.md.
  • No user-facing features were removed.

Testing

  • dotnet test tests/Listenarr.Tests.csproj --filter FullyQualifiedName~NzbgetAdapterTests - 8 passed
  • dotnet test tests/Listenarr.Tests.csproj --filter FullyQualifiedName~DownloadClientAdapterTests - 7 passed
  • dotnet test tests/Listenarr.Tests.csproj --filter "FullyQualifiedName~UsenetAdapterFilteringTests|FullyQualifiedName~NzbgetAdapterTests" - 10 passed
  • dotnet test tests/Listenarr.Tests.csproj --no-build - 677 passed
  • pre-push hook: backend format/layering checks passed
  • pre-push hook: frontend type check passed
  • pre-push hook: frontend tests passed - 361 passed, 1 skipped

Note: backend restore/build still reports the pre-existing NU1902 advisory for SharpCompress 0.47.4.

whacky671 and others added 3 commits May 30, 2026 10:21
NzbgetAdapter.FetchDownloadsAsync read the listgroups fields FileSizeMB
and RemainingSizeMB via JsonElement.GetString(), but NZBGet returns
them as JSON Number, so GetString() threw InvalidOperationException by
design. The inner try/catch suppressed the exception but aborted
progress mapping for the group and emitted "Error updating NZBGet queue
progress for group", so completed downloads were never imported.

NZBGet has always documented these fields as int, so the fix reads them
straight into a double via a new JsonExtensions.GetDoubleOrDefault
helper. The downstream double.TryParse round-trip goes away.

NzbgetApiMock gets a /jsonrpc route returning a canned listgroups
response with numeric size fields plus public constants
(ACTIVE_DOWNLOAD_NZBID / FILE_SIZE_MB / REMAINING_SIZE_MB). The test
builds a Download via DownloadBuilder with the mock's NZBID, runs
FetchDownloadsAsync, and asserts the resulting Status / Progress /
Metadata["ClientState"|"AmountLeft"] are correctly mutated by
AdapterUtils.MapDownloadProgress.

Addresses review feedback on #628 (inline comment removed,
NzbgetApiMock used directly, builders for client and Download, state
assertion instead of log-warning absence, helper in
Listenarr.Application.Extensions.JsonExtensions with double return
type).

Signed-off-by: pascalriemer <155780777+pascalriemer@users.noreply.github.com>
- Drop JsonExtensions.GetDoubleOrDefault (added in 5c289e9). The existing
  GetPropertyOrDefault<T> deserialises a JSON Number into double via
  JsonSerializer.Deserialize<double>(GetRawText()) without going through
  a string, so a new method adds no value. NzbgetAdapter now calls
  group.GetPropertyOrDefault("FileSizeMB", 0d) directly.

- Refactor NzbgetAdapterTests onto BaseTests + IDownloadClientGateway,
  matching SabnzbdAdapterTests. _provider resolves the gateway,
  _downloadClientConfigurationRepository persists the test config, and
  the NzbgetApiMock is wired automatically through the named "nzbget"
  HttpClient registered by ServiceCollectionBuilder. The three existing
  TestConnection*/GetQueue tests keep their hand-rolled handlers and are
  not touched.

- Assert Download.Status and Download.Progress (direct properties) in
  the new test, instead of Metadata["ClientState"]/["AmountLeft"].
  DownloadedSize would also be a direct property but is 0 here because
  Download.TotalSize defaults to 0 (and an existing off-by-100 between
  the adapter's 0..1 progress formula and MapDownloadProgress's 0..100
  input would otherwise show through — pre-existing, separate concern).

- Drop the comment in NzbgetApiMock.GetJsonrpc that referenced the
  adapter's polling behaviour.

Signed-off-by: pascalriemer <155780777+pascalriemer@users.noreply.github.com>
@therobbiedavis therobbiedavis added the patch patch version bump - backward compatible bug fixes label May 31, 2026
@therobbiedavis therobbiedavis marked this pull request as ready for review May 31, 2026 18:18
@therobbiedavis therobbiedavis requested review from a team and Copilot May 31, 2026 18:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes NZBGet completion/import reconciliation by improving JSON-RPC listgroups parsing, normalizing progress reporting to the 0–100 scale used by AdapterUtils.MapDownloadProgress, and adding a history-based reconciliation pass so downloads that have already left the active queue can still be marked Completed and queued for import with a resolved source path.

Changes:

  • Update NZBGet JSON-RPC queue polling to read numeric size fields as numbers and report progress as a 0–100 percentage.
  • Reconcile tracked downloads against NZBGet history (by NZBID) to detect completions that have left listgroups, preferring FinalDir with DestDir fallback for the import path.
  • Expand test/mocks to cover numeric progress mapping, history reconciliation behavior, and best-effort handling when history is temporarily unavailable.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
listenarr.infrastructure/Adapters/NzbgetAdapter.cs Adds history reconciliation helpers, resolves import paths via FinalDir/DestDir, normalizes listgroups progress to 0–100, and makes history lookup best-effort.
tests/Mocks/Api/NzbgetApiMock.cs Extends the NZBGet mock to support JSON-RPC listgroups, distinct history ID vs NZBID, selectable active queue presence, and simulated history failures.
tests/Features/Infrastructure/Adapters/NzbgetAdapterTests.cs Adds regression coverage for numeric progress mapping, skipping history lookup when not needed, best-effort history failures, and completion→job enqueue with resolved path.
CHANGELOG.md Adds a changelog entry describing the NZBGet import completion fix.

Comment on lines +1310 to +1313
var nzbName = group.TryGetProperty("NZBName", out var nameProp) ? nameProp.GetString() ?? "" : "";
var status = group.TryGetProperty("Status", out var statusProp) ? statusProp.GetString() ?? "" : "";
var fileSizeMB = group.TryGetProperty("FileSizeMB", out var sizeProp) ? sizeProp.GetString() ?? "" : "";
var remainingSizeMB = group.TryGetProperty("RemainingSizeMB", out var remainingSizeProp) ? remainingSizeProp.GetString() ?? "" : "";
var fileSizeMB = group.GetPropertyOrDefault("FileSizeMB", 0d);
var remainingSizeMB = group.GetPropertyOrDefault("RemainingSizeMB", 0d);
Comment thread CHANGELOG.md Outdated
Comment on lines +184 to +187
// Issue #618 — NZBGet's JSON-RPC `listgroups` returns FileSizeMB / RemainingSizeMB
// as JSON Number; verify FetchDownloadsAsync maps progress onto the matching
// Download via AdapterUtils.MapDownloadProgress, instead of throwing
// InvalidOperationException at the Number-as-String access (the original bug).
Comment thread .github/CLAUDE.md
- Use provide/inject for deep component communication
- Use async components for code-splitting

---

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fyi: If we are more rigorous regarding the commit messages, we could parse them to generate the changelog automatically. In Jira, I also did some proof of concept to manage changelog based on ticket/issue metadata that may be done too with Github issue/PR

};
}

var response = """

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can simplify this a bit with the $$""" notation (i was not aware of it's existence before the previous PR)

return MockUtils.GetCannedResponse(response, "text/xml");
}

private async Task<HttpResponseMessage> GetJsonrpc(HttpRequestMessage request, CancellationToken ct)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another suggestion but if NZBGet allows to do the same thing with XML and JSON RPC, we probably want to stick to one unless we have good reasons to keep both

public class NzbgetAdapterTests
public class NzbgetAdapterTests : BaseTests
{
private DownloadClientConfiguration? _client;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i believe you can also use the null! notation here if you want

@T4g1 T4g1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Only some minor change request and suggestions

return null;
}

private sealed record NzbgetHistoryItem(string NzbId, string Name, string Status, string ContentPath);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would put this at the start or at the end of the class declaration (depending on which standard you want to follow) but not in the middle of it


private sealed record NzbgetHistoryItem(string NzbId, string Name, string Status, string ContentPath);

private static IEnumerable<IReadOnlyDictionary<string, string>> EnumerateXmlRpcStructMembers(XElement? arrayData)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be in a XML utils file probably (it will probably be handy for Sabnzbd too)

status.StartsWith("FAILED", StringComparison.OrdinalIgnoreCase);
}

private static double GetJsonDoubleOrDefault(JsonElement element, string propertyName, double defaultValue = 0d)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not reuse GetPropertyOrDefault in JsonExtensions ?

try
{
var nzbId = group.TryGetProperty("NZBID", out var nzbIdProp) ? nzbIdProp.GetInt32() : 0;
if (nzbId > 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure 0 is an invalid NZBID ? (i dont see anything about that here: https://nzbget.com/documentation/api/listgroups/)

{
await ApplyHistoryUpdatesAsync(client, downloads, activeNzbIds);
}
catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think of catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) instead ?

}
}

private static string ResolveHistoryContentPath(IReadOnlyDictionary<string, string> members)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its not obvious what this method does with only the prototype, maybe we want to document what is the members parameter or what it does

s3ntin3l8 added a commit to s3ntin3l8/Listenarr that referenced this pull request Jun 7, 2026
Add an UPSTREAM_PRS list (by PR number); each is fetched fresh from
refs/pull/<N>/head every run and merged like a patch branch. Extract the
merge+rerere logic into a shared merge_branch() helper. Seeded with Listenarrs#634
(NZBGet import fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kevinheneveld

Copy link
Copy Markdown
Contributor

Adjacent gap: DELETED/* history statuses still strand downloads in Queued

Not asking to expand this PR's scope — flagging for a possible follow-up, since it lives in the exact reconciliation path this PR adds. (Independently verified the SUCCESS path here works: deployed an equivalent fix and watched 6 stranded grabs go Queued→Moved with real files imported — so this isn't a critique, just an adjacent gap found in the same path on a production instance.)

NZBGet's built-in DupeCheck removes a redundant grab from the active queue and records it in history as DELETED/COPY (also DELETED/DUPE, DELETED/GOOD); manual/scan removals are DELETED/MANUAL/DELETED/SCAN; corrupt/unhealthy removals are DELETED/HEALTH/DELETED/BAD. None of these start with SUCCESS or FAILURE, so with this PR's IsSuccessfulHistoryStatus / IsFailedHistoryStatus branches a DELETED/* item hits neither branch and the tracked download stays stuck in Queued indefinitely — the same orphaned-in-Queued symptom this PR fixes for the success/fail cases, still present for the deleted case.

This is not rare in practice. Any title grabbed more than once (re-search, manual + auto, etc.) trips DupeCheck: on one instance 147 of 501 history items were DELETED/COPY, leaving 419 tracked downloads stuck. (Ironically, fixing the SUCCESS path here reduces future dupes, because books finally import instead of being re-grabbed — but the existing backlog and any concurrent re-grabs still produce DELETED/COPY.)

Suggested handling (the nuance is block vs fail):

  • DELETED/COPY | DELETED/DUPE | DELETED/GOOD → mark ImportBlocked (terminal), not Failed. With FailedDownloadHandlingEnabled + FailedDownloadAutoSearch on, failing a dupe fires an auto-search that re-grabs the same release, which NZBGet deletes again → a churn loop. Blocking is terminal and doesn't auto-search.
  • DELETED/HEALTH | DELETED/BADFail (genuinely bad; normal retry/auto-search should apply).
  • DELETED/MANUAL | DELETED/SCAN | other DELETED/*block (ambiguous removal; terminal, don't re-search).

Two implementation notes from doing this elsewhere:

  1. Queued → ImportBlocked is an invalid state-machine transition (only reachable from Downloading/Processing/ImportPending/Completed), so a still-queued dupe needs a nudge Queued → Downloading → ImportBlocked.
  2. Blocking alone doesn't fully stop the churn: AutomaticSearchService treats Queued as "active → skip re-search" but not ImportBlocked, so moving a dupe Queued→ImportBlocked actually lifts the accidental suppression the stuck-Queued state was giving it. To close the loop, the background auto-search cycle should also treat ImportBlocked as blocking (while leaving manual "Search now" able to override).

Happy to open a separate PR for this if useful, or it can fold into this one — your call on scope.

s3ntin3l8 added a commit to s3ntin3l8/Listenarr that referenced this pull request Jun 10, 2026
Add an UPSTREAM_PRS list (by PR number); each is fetched fresh from
refs/pull/<N>/head every run and merged like a patch branch. Extract the
merge+rerere logic into a shared merge_branch() helper. Seeded with Listenarrs#634
(NZBGet import fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

patch patch version bump - backward compatible bug fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: NZBGet downloads are never imported — FetchDownloadsAsync reads numeric listgroups fields (FileSizeMB/RemainingSizeMB) with .GetString()

5 participants