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 @@ -226,11 +226,16 @@ private static IServiceCollection AddNzbgetWorkflows(this IServiceCollection ser
sp.GetRequiredService<IHttpClientFactory>(),
DownloadClientTypes.Nzbget));
services.AddScoped<NzbgetHistoryReader>();

// Warn-once state for failed history entries has to outlive the scoped workflow
// that reads it, or every poll would be a first sighting again.
services.AddSingleton<NzbgetFailedHistoryWarningTracker>();
services.AddScoped<NzbgetHistoryEnrichmentWorkflow>(sp =>
new NzbgetHistoryEnrichmentWorkflow(
sp.GetRequiredService<NzbgetHistoryReader>(),
sp.GetRequiredService<ILogger<NzbgetAdapter>>(),
sp.GetRequiredService<TimeProvider>()));
sp.GetRequiredService<TimeProvider>(),
sp.GetRequiredService<NzbgetFailedHistoryWarningTracker>()));
services.AddScoped<NzbgetConnectionTester>(sp =>
new NzbgetConnectionTester(
sp.GetRequiredService<NzbgetXmlRpcClient>(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ internal NzbgetAdapter(
IHttpClientFactory httpClientFactory,
INzbUrlResolver nzbUrlResolver,
ILogger<NzbgetAdapter> logger,
TimeProvider timeProvider)
TimeProvider timeProvider,
NzbgetFailedHistoryWarningTracker? failedHistoryWarningTracker = null)
{
ArgumentNullException.ThrowIfNull(httpClientFactory);
ArgumentNullException.ThrowIfNull(nzbUrlResolver);
Expand All @@ -68,10 +69,15 @@ internal NzbgetAdapter(

var xmlRpcClient = new NzbgetXmlRpcClient(httpClientFactory, ClientType);
var historyReader = new NzbgetHistoryReader(xmlRpcClient);

// This constructor builds a self-contained adapter for callers that have no
// container. Production resolves the adapter from DI, where the tracker is a
// singleton and so outlives the scoped workflow that reads it.
var historyEnrichmentWorkflow = new NzbgetHistoryEnrichmentWorkflow(
historyReader,
logger,
timeProvider);
timeProvider,
failedHistoryWarningTracker ?? new NzbgetFailedHistoryWarningTracker());

_connectionTester = new NzbgetConnectionTester(xmlRpcClient, logger);
_addWorkflow = new NzbgetAddWorkflow(xmlRpcClient, logger);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* 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.Infrastructure.DownloadClients.Nzbget;

/// <summary>
/// Remembers the failed NZBGet history entries already warned about, per download client and
/// reading surface, so an entry NZBGet keeps in history is reported the first time it is seen
/// instead of on every poll. State lives in memory only, so each entry still in history warns
/// once more after a restart.
/// </summary>
internal sealed class NzbgetFailedHistoryWarningTracker
{
private readonly Lock _gate = new();
private readonly Dictionary<(string Surface, string ClientKey), HashSet<string>> _lastReadKeys = new();

/// <summary>
/// Records the failed entry keys one history read produced for a client and surface, and
/// returns the ones not already recorded. Keys that are empty are not tracked. Pass
/// isScopedRead when the read covered only part of the client's history, which is what a
/// monitor poll does because it asks NZBGet about specific downloads.
/// </summary>
public IReadOnlySet<string> MarkFailed(
string clientKey,
string surface,
IReadOnlyCollection<string> failedKeysThisRead,
bool isScopedRead)
{
var currentKeys = failedKeysThisRead
.Where(key => key.Length > 0)
.ToHashSet(StringComparer.OrdinalIgnoreCase);

lock (_gate)
{
var previousKeys = _lastReadKeys.GetValueOrDefault((surface, clientKey));

// A scoped read saw only the entries it asked about, so absence from it is not
// evidence that anything left NZBGet history. It may only add. Letting it replace
// would drop every entry outside its scope, and the next unscoped read would then
// report those as new all over again.
//
// An unscoped read did see the whole category, so replacing the stored set is what
// evicts entries that have genuinely left history. That keeps memory bounded by
// the size of the history itself with no expiry timer, and it still happens
// regularly because the queue poller reads unscoped on a timer. An entry that
// leaves history and later comes back is a new sighting and warns again, which is
// the intent.
//
// The surface is part of the key because the surfaces read different slices, so
// one surface's read must not evict what another surface saw.
_lastReadKeys[(surface, clientKey)] = isScopedRead && previousKeys != null
? [.. previousKeys, .. currentKeys]
: currentKeys;

return currentKeys
.Where(key => previousKeys?.Contains(key) != true)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
}

/// <summary>
/// Collects the tracking keys of the failed entries in one history read, dropping the
/// entries that carry neither an ID nor a title.
/// </summary>
public static IReadOnlyCollection<string> GetFailedEntryKeys(
IEnumerable<NzbgetHistoryEntry> historyEntries)
{
return historyEntries
.Where(entry => entry.Outcome == NzbgetHistoryOutcome.Failed)
.Select(entry => GetEntryKey(entry.CanonicalNzbId, entry.Title))
.Where(key => key.Length > 0)
.ToList();
}

/// <summary>
/// Identifies a history entry by its canonical NZBID, falling back to its title when
/// NZBGet supplies no ID. An entry with neither returns an empty key and is not tracked.
/// </summary>
public static string GetEntryKey(string canonicalNzbId, string title)
{
if (!string.IsNullOrWhiteSpace(canonicalNzbId))
{
return canonicalNzbId;
}

return string.IsNullOrWhiteSpace(title) ? string.Empty : title;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ namespace Listenarr.Infrastructure.DownloadClients.Nzbget
internal sealed class NzbgetHistoryEnrichmentWorkflow(
NzbgetHistoryReader historyReader,
ILogger logger,
TimeProvider timeProvider)
TimeProvider timeProvider,
NzbgetFailedHistoryWarningTracker failedHistoryWarningTracker)
{
private const long SlowHistoryThresholdMilliseconds = 2_000;
private const string QueueSurface = "GetQueueAsync";
Expand Down Expand Up @@ -86,7 +87,8 @@ public async Task EnrichQueueAsync(
activeIdentities,
history,
cancellationToken,
entry => TryMergeOrAppendQueueItem(client, entry, activeIdentities, items));
entry => TryMergeOrAppendQueueItem(client, entry, activeIdentities, items),
monitoredIdSet);
}
catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException)
{
Expand Down Expand Up @@ -152,25 +154,36 @@ private void AppendHistory(
IReadOnlyList<ActiveHistoryIdentity> activeIdentities,
IReadOnlyList<NzbgetHistoryEntry> history,
CancellationToken cancellationToken,
Action<NzbgetHistoryEntry> append)
Action<NzbgetHistoryEntry> append,
ISet<string>? monitoredIds = null)
{
var processedHistoryIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var matchedTerminalActiveIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var candidates = new List<NzbgetHistoryEntry>();

foreach (var entry in history)
{
cancellationToken.ThrowIfCancellationRequested();
if (!IsHistoryCandidate(
if (IsHistoryCandidate(
entry,
configuredCategory,
activeIdentities,
processedHistoryIds,
matchedTerminalActiveIds))
matchedTerminalActiveIds,
monitoredIds))
{
continue;
candidates.Add(entry);
}
}

LogFailedHistoryEntry(client, surface, entry);
var unwarnedFailureKeys = failedHistoryWarningTracker.MarkFailed(
client.Id ?? client.Name ?? client.Type,
surface,
NzbgetFailedHistoryWarningTracker.GetFailedEntryKeys(candidates),
isScopedRead: monitoredIds is { Count: > 0 });
foreach (var entry in candidates)
{
LogFailedHistoryEntry(client, surface, entry, unwarnedFailureKeys);
append(entry);
}

Expand All @@ -182,7 +195,8 @@ private static bool IsHistoryCandidate(
string? configuredCategory,
IReadOnlyList<ActiveHistoryIdentity> activeIdentities,
ISet<string> processedHistoryIds,
ISet<string> matchedTerminalActiveIds)
ISet<string> matchedTerminalActiveIds,
ISet<string>? monitoredIds)
{
if (entry.Outcome == NzbgetHistoryOutcome.Ignored ||
!DownloadClientCategoryFilter.Matches(configuredCategory, entry.Category))
Expand All @@ -198,6 +212,15 @@ private static bool IsHistoryCandidate(

var activeMatch = FindActiveIdentity(activeIdentities, entry);

// A monitor poll asks about specific downloads and NzbgetQueueFilter.FilterByIds
// discards the rest of the category once enrichment has run. Attributing the
// entry here keeps those rows out of the log and out of the merge.
if (monitoredIds is { Count: > 0 } &&
!NzbgetQueueFilter.IsRequestedByIds(entry.CanonicalNzbId, activeMatch, monitoredIds))
{
return false;
}

// Active listgroups records are progress telemetry. They suppress older
// history only while they still look like active work. If NZBGet reports
// a terminal-looking active status, history becomes authoritative because
Expand Down Expand Up @@ -288,13 +311,20 @@ private void TryMergeOrAppendDownloadClientItem(
private void LogFailedHistoryEntry(
DownloadClientConfiguration client,
string surface,
NzbgetHistoryEntry entry)
NzbgetHistoryEntry entry,
IReadOnlySet<string> unwarnedFailureKeys)
{
if (entry.Outcome != NzbgetHistoryOutcome.Failed)
{
return;
}

var entryKey = NzbgetFailedHistoryWarningTracker.GetEntryKey(entry.CanonicalNzbId, entry.Title);
if (entryKey.Length > 0 && !unwarnedFailureKeys.Contains(entryKey))
{
return;
}

logger.LogWarning(
"NZBGet history reported failure for {NzbId}: Status={Status}, FinalDir={FinalDir}, DestDir={DestDir}, Title={Title}, Category={Category}, ClientId={ClientId}, Surface={Surface}",
LogRedaction.SanitizeText(entry.CanonicalNzbId),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ namespace Listenarr.Infrastructure.DownloadClients.Nzbget;

internal static class NzbgetQueueFilter
{
/// <summary>
/// Whether a history entry belongs to one of the requested IDs, either directly or
/// through the active queue row it matches. History enrichment applies this before it
/// logs or merges an entry, so it deliberately admits a superset of what
/// <see cref="FilterByIds"/> keeps: an entry with no canonical NZBID can be attributed
/// here through the active row its title matches, and is then dropped by FilterByIds,
/// which requires a non-empty item ID. Erring wide keeps queue output unchanged.
/// </summary>
public static bool IsRequestedByIds(
string canonicalNzbId,
NzbgetHistoryEnrichmentWorkflow.ActiveHistoryIdentity? activeMatch,
ISet<string> ids)
{
return ids.Contains(canonicalNzbId) || activeMatch?.MatchesAny(ids) == true;
}

public static List<QueueItem> FilterByIds(
List<QueueItem> items,
List<string> ids,
Expand Down
Loading