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
5 changes: 5 additions & 0 deletions listenarr.application/Common/Contracts/IWorkerProcessors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ public interface IMovedDownloadCleanupProcessor
Task RunCycleAsync(CancellationToken cancellationToken);
}

public interface IStaleBlockedDownloadCleanupProcessor
{
Task RunCycleAsync(CancellationToken cancellationToken);
}

public interface IScanJobProcessor
{
Task ProcessJobAsync(ScanJob job, CancellationToken cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public static IServiceCollection AddFeatureWorkers(
AddHostedProcessor<DownloadMonitorProcessor, IDownloadMonitorProcessor, DownloadMonitorService>(services);
AddHostedProcessor<DirectDownloadProcessor, IDirectDownloadProcessor, DirectDownloadService>(services);
AddHostedProcessor<MovedDownloadCleanupProcessor, IMovedDownloadCleanupProcessor, MovedDownloadCleanupService>(services);
AddHostedProcessor<StaleBlockedDownloadCleanupProcessor, IStaleBlockedDownloadCleanupProcessor, StaleBlockedDownloadCleanupService>(services);

AddProcessor<QueueMonitorProcessor, IQueueMonitorProcessor>(services);
services.AddHostedService<QueueMonitorService>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* 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.
*/

using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Listenarr.Infrastructure.Downloads.Cleanup;

/// <summary>
/// Periodically reaps stale <c>ImportBlocked</c> download records (see
/// <see cref="StaleBlockedDownloadCleanupProcessor"/>). This is low-urgency housekeeping, so it
/// runs on a relaxed fixed interval rather than the download polling cadence.
/// </summary>
public class StaleBlockedDownloadCleanupService(
IStaleBlockedDownloadCleanupProcessor processor,
ILogger<StaleBlockedDownloadCleanupService> logger,
IWorkerCycleRunner cycleRunner) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(5);
private static readonly TimeSpan InitialDelay = TimeSpan.FromMinutes(1);

protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
logger.LogInformation("StaleBlockedDownloadCleanupService background task started");

await cycleRunner.RunPeriodicAsync(
nameof(StaleBlockedDownloadCleanupService),
initialDelay: InitialDelay,
intervalProvider: () => Interval,
runCycle: processor.RunCycleAsync,
cancellationToken);

logger.LogInformation("StaleBlockedDownloadCleanupService background task stopped");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* 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/>.
*/

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Listenarr.Infrastructure.Downloads.Cleanup
{
/// <summary>
/// Removes download records that are parked in <see cref="DownloadStatus.ImportBlocked"/>
/// but can no longer resolve to anything actionable, so they stop lingering on the Activity
/// page after the book has been dealt with. A blocked download is reaped when:
/// <list type="bullet">
/// <item>it has no associated audiobook, or</item>
/// <item>its audiobook no longer exists (was deleted), or</item>
/// <item>its audiobook already has at least one file (the book was received another way,
/// so the blocked import is redundant).</item>
/// </list>
/// A blocked download whose audiobook still exists with no files is left untouched — that one
/// is a genuine unresolved failure the user may still want to retry.
/// </summary>
public class StaleBlockedDownloadCleanupProcessor(
IServiceScopeFactory scopeFactory,
ILogger<StaleBlockedDownloadCleanupProcessor> logger)
: IStaleBlockedDownloadCleanupProcessor
{
public async Task RunCycleAsync(CancellationToken cancellationToken)
{
using var scope = scopeFactory.CreateScope();
var downloadRepository = scope.ServiceProvider.GetRequiredService<IDownloadRepository>();
var audiobookRepository = scope.ServiceProvider.GetRequiredService<IAudiobookRepository>();
var audiobookFileRepository = scope.ServiceProvider.GetRequiredService<IAudiobookFileRepository>();

// GetActiveAsync deliberately excludes ImportBlocked, so read the full set and filter.
var blocked = (await downloadRepository.GetAllAsync())
.Where(download => download.Status == DownloadStatus.ImportBlocked)
.ToList();

if (blocked.Count == 0)
{
return;
}

foreach (var download in blocked)
{
cancellationToken.ThrowIfCancellationRequested();

var reason = await ResolveReapReasonAsync(
download,
audiobookRepository,
audiobookFileRepository,
cancellationToken);
if (reason == null)
{
continue;
}

try
{
await downloadRepository.RemoveAsync(download.Id);
logger.LogInformation(
"Reaped stale ImportBlocked download {DownloadId} for audiobook {AudiobookId}: {Reason}",
download.Id,
download.AudiobookId,
reason);
}
catch (Exception ex) when (ex is not (OperationCanceledException
or OutOfMemoryException
or StackOverflowException))
{
logger.LogWarning(
ex,
"Failed to reap stale ImportBlocked download {DownloadId}",
download.Id);
}
}
}

private static async Task<string?> ResolveReapReasonAsync(
Download download,
IAudiobookRepository audiobookRepository,
IAudiobookFileRepository audiobookFileRepository,
CancellationToken cancellationToken)
{
if (download.AudiobookId is not int audiobookId)
{
return "no associated audiobook";
}

var audiobook = await audiobookRepository.GetByIdAsync(audiobookId);
if (audiobook == null)
{
return $"audiobook {audiobookId} no longer exists";
}

var files = await audiobookFileRepository.GetByAudiobookIdAsync(audiobookId, cancellationToken);
if (files.Count > 0)
{
return $"audiobook {audiobookId} already has {files.Count} file(s)";
}

return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* 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.
*/
using Listenarr.Tests.Builders;
using Listenarr.Tests.Common;

namespace Listenarr.Tests.Features.Infrastructure.Downloads.Cleanup
{
[Trait("Name", nameof(StaleBlockedDownloadCleanupProcessorTests))]
[Trait("Category", "Downloads")]
public sealed class StaleBlockedDownloadCleanupProcessorTests : BaseTests
{
private readonly Mock<IDownloadRepository> _downloads = new();
private readonly Mock<IAudiobookRepository> _audiobooks = new();
private readonly Mock<IAudiobookFileRepository> _files = new();

private StaleBlockedDownloadCleanupProcessor CreateSut()
{
var provider = new Mock<IServiceProvider>();
provider.Setup(p => p.GetService(typeof(IDownloadRepository))).Returns(_downloads.Object);
provider.Setup(p => p.GetService(typeof(IAudiobookRepository))).Returns(_audiobooks.Object);
provider.Setup(p => p.GetService(typeof(IAudiobookFileRepository))).Returns(_files.Object);
var scope = new Mock<IServiceScope>();
scope.Setup(s => s.ServiceProvider).Returns(provider.Object);
var scopeFactory = new Mock<IServiceScopeFactory>();
scopeFactory.Setup(f => f.CreateScope()).Returns(scope.Object);
return new StaleBlockedDownloadCleanupProcessor(
scopeFactory.Object,
Mock.Of<ILogger<StaleBlockedDownloadCleanupProcessor>>());
}

private void GivenDownloads(params Download[] downloads) =>
_downloads.Setup(r => r.GetAllAsync()).ReturnsAsync(downloads.ToList());

private void GivenAudiobook(int id, Audiobook? audiobook) =>
_audiobooks.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(audiobook);

private void GivenFiles(int audiobookId, int count) =>
_files.Setup(r => r.GetByAudiobookIdAsync(audiobookId, It.IsAny<CancellationToken>()))
.ReturnsAsync(Enumerable.Range(0, count).Select(_ => new AudiobookFile()).ToList());

[Fact]
public async Task RunCycleAsync_ReapsBlockedDownload_WithNoAssociatedAudiobook()
{
var download = new DownloadBuilder().WithId("d-noaudio").WithBlockedStatus("blocked").Build();
GivenDownloads(download);

await CreateSut().RunCycleAsync(CancellationToken.None);

_downloads.Verify(r => r.RemoveAsync("d-noaudio"), Times.Once);
}

[Fact]
public async Task RunCycleAsync_ReapsBlockedDownload_WhenAudiobookNoLongerExists()
{
var download = new DownloadBuilder().WithId("d-deleted").WithBlockedStatus("blocked")
.WithAudiobookId(401).Build();
GivenDownloads(download);
GivenAudiobook(401, null);

await CreateSut().RunCycleAsync(CancellationToken.None);

_downloads.Verify(r => r.RemoveAsync("d-deleted"), Times.Once);
}

[Fact]
public async Task RunCycleAsync_ReapsBlockedDownload_WhenAudiobookAlreadyHasFiles()
{
var download = new DownloadBuilder().WithId("d-hasfiles").WithBlockedStatus("blocked")
.WithAudiobookId(380).Build();
GivenDownloads(download);
GivenAudiobook(380, new AudiobookBuilder().WithId(380).Build());
GivenFiles(380, 1);

await CreateSut().RunCycleAsync(CancellationToken.None);

_downloads.Verify(r => r.RemoveAsync("d-hasfiles"), Times.Once);
}

[Fact]
public async Task RunCycleAsync_LeavesBlockedDownload_WhenAudiobookExistsWithNoFiles()
{
var download = new DownloadBuilder().WithId("d-unresolved").WithBlockedStatus("blocked")
.WithAudiobookId(500).Build();
GivenDownloads(download);
GivenAudiobook(500, new AudiobookBuilder().WithId(500).Build());
GivenFiles(500, 0);

await CreateSut().RunCycleAsync(CancellationToken.None);

_downloads.Verify(r => r.RemoveAsync(It.IsAny<string>()), Times.Never);
}

[Fact]
public async Task RunCycleAsync_IgnoresDownloads_ThatAreNotImportBlocked()
{
// A Moved download whose audiobook is gone would meet the "deleted" reap condition,
// but it must be ignored because it is not ImportBlocked.
var moved = new DownloadBuilder().WithId("d-moved").WithStatus(DownloadStatus.Moved)
.WithAudiobookId(401).Build();
GivenDownloads(moved);
GivenAudiobook(401, null);

await CreateSut().RunCycleAsync(CancellationToken.None);

_downloads.Verify(r => r.RemoveAsync(It.IsAny<string>()), Times.Never);
}
}
}